Một cách tiếp cận phổ biến trong phát triển ứng dụng React là bao gồm mọi thứ bằng khả năng ghi nhớ. Nhiều nhà phát triển áp dụng kỹ thuật tối ưu hóa này một cách tự do, gói các thành phần trong bản ghi nhớ để ngăn chặn việc hiển thị lại không cần thiết. Nhìn bề ngoài, nó có vẻ giống như một chiến lược hoàn hảo để nâng cao hiệu suất. Tuy nhiên, nhóm Social Discovery Group đã phát hiện ra rằng khi áp dụng sai, tính năng ghi nhớ thực sự có thể bị hỏng theo những cách không mong muốn, dẫn đến các vấn đề về hiệu suất. Trong bài viết này, chúng ta sẽ khám phá những điểm đáng ngạc nhiên mà việc ghi nhớ có thể gặp khó khăn và cách tránh những bẫy ẩn này trong ứng dụng React của bạn.
Ghi nhớ là một kỹ thuật tối ưu hóa trong lập trình liên quan đến việc lưu kết quả của các hoạt động tốn kém và sử dụng lại chúng khi gặp lại các đầu vào tương tự. Bản chất của việc ghi nhớ là tránh tính toán dư thừa cho cùng một dữ liệu đầu vào. Mô tả này thực sự đúng với cách ghi nhớ truyền thống. Bạn có thể thấy trong ví dụ mã rằng tất cả các tính toán đều được lưu vào bộ đệm.
const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }
Việc ghi nhớ mang lại một số lợi ích, bao gồm cải thiện hiệu suất, tiết kiệm tài nguyên và lưu vào bộ nhớ đệm kết quả. Tuy nhiên, tính năng ghi nhớ của React hoạt động cho đến khi có đạo cụ mới, nghĩa là chỉ kết quả của lệnh gọi cuối cùng được lưu.
const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }
Thư viện React cung cấp cho chúng ta một số công cụ để ghi nhớ. Đây là HOC React.memo, hook useCallback, useMemo và useEvent, cũng như React.PureComponent và phương thức vòng đời của các thành phần lớp, ShouldComponentUpdate. Hãy cùng xem xét ba công cụ ghi nhớ đầu tiên và khám phá mục đích cũng như cách sử dụng chúng trong React.
Phản ứng.memo
Thành phần bậc cao hơn (HOC) này chấp nhận một thành phần làm đối số đầu tiên và hàm so sánh tùy chọn làm đối số thứ hai. Chức năng so sánh cho phép so sánh thủ công các đạo cụ trước đó và hiện tại. Khi không có chức năng so sánh nào được cung cấp, React mặc định ở mức bình đẳng nông. Điều quan trọng là phải hiểu rằng sự bình đẳng nông cạn chỉ thực hiện so sánh ở cấp độ bề mặt. Do đó, nếu prop chứa các loại tham chiếu có tham chiếu không cố định, React sẽ kích hoạt kết xuất lại thành phần.
const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })
React.useGọi lại
Hook useCallback cho phép chúng ta duy trì tham chiếu đến một hàm được truyền trong quá trình kết xuất ban đầu. Trong các lần hiển thị tiếp theo, React sẽ so sánh các giá trị trong mảng phụ thuộc của hook và nếu không có phần phụ thuộc nào thay đổi, nó sẽ trả về cùng một tham chiếu hàm được lưu trong bộ nhớ cache như lần trước. Nói cách khác, useCallback lưu trữ tham chiếu đến hàm giữa các lần hiển thị cho đến khi phần phụ thuộc của nó thay đổi.
const callback = useCallback(() => { // do something }, [a, b, c])
React.useBản ghi nhớ
Hook useMemo cho phép bạn lưu trữ kết quả tính toán giữa các lần hiển thị. Thông thường, useMemo được sử dụng để lưu vào bộ nhớ đệm các phép tính tốn kém cũng như lưu trữ tham chiếu đến một đối tượng khi chuyển nó đến các thành phần khác được gói trong bản ghi nhớ HOC hoặc dưới dạng phần phụ thuộc trong hook.
const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])
Trong các nhóm phát triển React, một phương pháp phổ biến là ghi nhớ toàn diện. Cách tiếp cận này thường bao gồm:
Tuy nhiên, các nhà phát triển không phải lúc nào cũng nắm bắt được sự phức tạp của chiến lược này và việc ghi nhớ có thể dễ dàng bị xâm phạm như thế nào. Không có gì lạ khi các thành phần được bao bọc trong bản ghi nhớ HOC hiển thị lại một cách bất ngờ. Tại Social Discovery Group, chúng tôi đã nghe các đồng nghiệp khẳng định: "Ghi nhớ mọi thứ không đắt hơn nhiều so với việc không ghi nhớ gì cả".
Chúng tôi nhận thấy rằng không phải ai cũng nắm bắt đầy đủ khía cạnh quan trọng của việc ghi nhớ: nó hoạt động tối ưu mà không cần chỉnh sửa bổ sung chỉ khi các giá trị gốc được chuyển đến thành phần được ghi nhớ.
Trong những trường hợp như vậy, thành phần sẽ chỉ hiển thị lại nếu giá trị prop thực sự thay đổi. Nguyên thủy trong đạo cụ là tốt.
Điểm thứ hai là khi chúng ta truyền các kiểu tham chiếu trong props. Điều quan trọng cần nhớ và hiểu là không có phép thuật nào trong React - đó là thư viện JavaScript hoạt động theo các quy tắc của JavaScript. Các kiểu tham chiếu trong props (hàm, đối tượng, mảng) rất nguy hiểm.
Ví dụ:
const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false
Nếu bạn tạo một đối tượng, một đối tượng khác có cùng thuộc tính và giá trị sẽ không bằng đối tượng đầu tiên vì chúng có các tham chiếu khác nhau.
Nếu chúng ta chuyển những gì có vẻ là cùng một đối tượng trong lệnh gọi thành phần tiếp theo, nhưng thực tế nó là một đối tượng khác (vì tham chiếu của nó khác), so sánh nông mà React sử dụng sẽ nhận ra các đối tượng này là khác nhau. Điều này sẽ kích hoạt kết xuất lại thành phần được bao bọc trong bản ghi nhớ, do đó phá vỡ quá trình ghi nhớ của thành phần đó.
Để đảm bảo vận hành an toàn với các thành phần được ghi nhớ, điều quan trọng là sử dụng kết hợp memo, useCallback và useMemo. Bằng cách này, tất cả các loại tham chiếu sẽ có tham chiếu không đổi.
Làm việc nhóm: ghi nhớ, useCallback, useMemo
Mọi thứ được mô tả ở trên nghe có vẻ hợp lý và đơn giản, nhưng chúng ta hãy cùng nhau xem xét những lỗi phổ biến nhất có thể mắc phải khi làm việc với phương pháp này. Một số trong số chúng có thể tinh tế và một số có thể hơi phức tạp, nhưng chúng ta cần phải biết về chúng và quan trọng nhất là hiểu chúng để đảm bảo rằng logic mà chúng ta triển khai với tính năng ghi nhớ đầy đủ không bị phá vỡ.
Nội tuyến để ghi nhớ
Hãy bắt đầu với một lỗi cổ điển, trong đó trong mỗi lần kết xuất tiếp theo của thành phần gốc, thành phần được ghi nhớ MemoComponent sẽ liên tục kết xuất lại vì tham chiếu đến đối tượng được truyền trong các thông số sẽ luôn mới.
const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }
Để giải quyết vấn đề này, chỉ cần sử dụng hook useMemo đã đề cập trước đó là đủ. Bây giờ, tham chiếu đến đối tượng của chúng ta sẽ luôn không đổi.
const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }
Ngoài ra, bạn có thể di chuyển giá trị này thành một hằng số bên ngoài thành phần nếu mảng luôn chứa dữ liệu tĩnh.
const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }
Một tình huống tương tự trong ví dụ này là truyền một hàm mà không cần ghi nhớ. Trong trường hợp này, giống như trong ví dụ trước, quá trình ghi nhớ của MemoComponent sẽ bị phá vỡ bằng cách chuyển một hàm tới nó để có một tham chiếu mới trên mỗi kết xuất của thành phần gốc. Do đó, MemoComponent sẽ hiển thị lại như thể nó chưa được ghi nhớ.
const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }
Ở đây, chúng ta có thể sử dụng hook useCallback để duy trì tham chiếu đến hàm được truyền giữa các lần hiển thị của thành phần Parent.
const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }
Ghi chú được thực hiện.
Ngoài ra, trong useCallback, không có gì ngăn cản bạn chuyển một hàm trả về một hàm khác. Tuy nhiên, điều quan trọng cần nhớ là trong phương pháp này, hàm `someFunction` sẽ được gọi trên mỗi kết xuất. Điều quan trọng là tránh các phép tính phức tạp bên trong `someFunction`.
function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }
Tình trạng phổ biến tiếp theo là đạo cụ lan rộng. Hãy tưởng tượng bạn có một chuỗi các thành phần. Bạn có thường xuyên cân nhắc xem dữ liệu được truyền từ LaunchComponent có thể di chuyển bao xa, có khả năng không cần thiết đối với một số thành phần trong chuỗi này? Trong ví dụ này, prop này sẽ phá vỡ quá trình ghi nhớ trong thành phần ChildMemo vì trên mỗi lần kết xuất của Thành phần ban đầu, giá trị của nó sẽ luôn thay đổi. Trong một dự án thực tế, nơi chuỗi các thành phần được ghi nhớ có thể dài, tất cả việc ghi nhớ sẽ bị phá vỡ vì các đạo cụ không cần thiết với các giá trị thay đổi liên tục được truyền cho chúng:
const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }
Để tự bảo vệ mình, hãy đảm bảo rằng chỉ những giá trị cần thiết mới được chuyển đến thành phần được ghi nhớ. Thay vì đó:
const Component = (props) => { return <ChildMemo {...props} /> }
Sử dụng (chỉ truyền các đạo cụ cần thiết):
const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )
Hãy xem xét ví dụ sau. Một tình huống quen thuộc là khi chúng ta viết một thành phần chấp nhận JSX ở dạng con.
const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }
Thoạt nhìn, nó có vẻ vô hại nhưng thực tế thì không phải vậy. Chúng ta hãy xem xét kỹ hơn đoạn mã mà chúng ta chuyển JSX ở dạng con cho một thành phần được ghi nhớ. Cú pháp này không gì khác hơn là cú pháp để truyền `div` này làm chỗ dựa có tên `children`.
Trẻ em không khác gì bất kỳ chỗ dựa nào khác mà chúng ta truyền cho một thành phần. Trong trường hợp của chúng tôi, chúng tôi đang chuyển JSX và JSX lần lượt là đường cú pháp cho phương thức `createElement`, vì vậy về cơ bản, chúng tôi đang chuyển một đối tượng thông thường có loại `div`. Và ở đây, quy tắc thông thường cho thành phần được ghi nhớ được áp dụng: nếu một đối tượng không được ghi nhớ được truyền vào đạo cụ, thì thành phần đó sẽ được hiển thị lại khi thành phần gốc của nó được hiển thị, vì mỗi lần tham chiếu đến đối tượng này sẽ là mới.
Giải pháp cho vấn đề như vậy đã được thảo luận trong một số phần tóm tắt trước đó, trong phần liên quan đến việc truyền một đối tượng không được ghi nhớ. Vì vậy, ở đây, nội dung đang được chuyển có thể được ghi nhớ bằng useMemo và việc chuyển nội dung đó dưới dạng con tới ChildMemo sẽ không phá vỡ quá trình ghi nhớ của thành phần này.
const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }
Hãy xem xét một ví dụ thú vị hơn.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }
Thoạt nhìn, nó có vẻ vô hại: chúng ta có hai thành phần, cả hai đều được ghi nhớ. Tuy nhiên, trong ví dụ này, ParentMemo sẽ hoạt động như thể nó không được gói trong bản ghi nhớ vì các phần tử con của nó, ChildMemo, không được ghi nhớ. Kết quả của thành phần ChildMemo sẽ là JSX và JSX chỉ là đường cú pháp cho React.createElement, trả về một đối tượng. Vì vậy, sau khi phương thức React.createElement thực thi, ParentMemo và ChildMemo sẽ trở thành các đối tượng JavaScript thông thường và các đối tượng này không được ghi nhớ theo bất kỳ cách nào.
Bản ghi nhớ dành cho phụ huynh | ConBản ghi nhớ |
---|---|
| |
Kết quả là chúng ta chuyển một đối tượng không được ghi nhớ vào props, do đó phá vỡ quá trình ghi nhớ của thành phần Parent.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }
Để giải quyết vấn đề này, việc ghi nhớ thành phần con đã truyền là đủ, đảm bảo tham chiếu của nó không đổi trong quá trình kết xuất thành phần Ứng dụng gốc.
const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }
Một lĩnh vực nguy hiểm và tiềm ẩn khác là móc tùy chỉnh. Móc tùy chỉnh giúp chúng tôi trích xuất logic từ các thành phần của mình, làm cho mã dễ đọc hơn và ẩn logic phức tạp. Tuy nhiên, họ cũng giấu chúng tôi xem dữ liệu và hàm của họ có tham chiếu không đổi hay không. Trong ví dụ của tôi, việc triển khai hàm gửi bị ẩn trong useForm hook tùy chỉnh và trên mỗi kết xuất của thành phần Parent, các hook sẽ được thực thi lại.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };
Từ mã, chúng ta có thể hiểu được liệu có an toàn khi chuyển phương thức gửi làm chỗ dựa cho thành phần ComponentMemo được ghi nhớ không? Dĩ nhiên là không. Và trong trường hợp xấu nhất, việc triển khai hook tùy chỉnh có thể trông như thế này:
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }
Bằng cách chuyển phương thức gửi vào thành phần được ghi nhớ, chúng ta sẽ phá vỡ quá trình ghi nhớ vì tham chiếu đến phương thức gửi sẽ mới với mỗi lần hiển thị của thành phần gốc. Để giải quyết vấn đề này, bạn có thể sử dụng hook useCallback. Nhưng điểm chính tôi muốn nhấn mạnh là bạn không nên sử dụng dữ liệu từ móc tùy chỉnh một cách mù quáng để chuyển chúng vào các thành phần được ghi nhớ nếu bạn không muốn phá vỡ quá trình ghi nhớ mà bạn đã triển khai.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }
Giống như bất kỳ cách tiếp cận nào, việc ghi nhớ đầy đủ phải được sử dụng một cách thận trọng và người ta nên cố gắng tránh việc ghi nhớ quá mức một cách trắng trợn. Hãy xem xét ví dụ sau:
export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))
Trong ví dụ này, bạn nên gói phương thức handChange trong useCallback vì việc truyền handChange ở dạng hiện tại sẽ phá vỡ quá trình ghi nhớ của thành phần Đầu vào vì tham chiếu đến handChange sẽ luôn mới. Tuy nhiên, khi trạng thái thay đổi, thành phần Đầu vào vẫn sẽ được kết xuất lại vì một giá trị mới sẽ được chuyển cho nó trong value prop. Vì vậy, việc chúng ta không bao bọc handChange trong useCallback sẽ không ngăn thành phần Đầu vào liên tục hiển thị lại. Trong trường hợp này, sử dụng useCallback sẽ là quá mức. Tiếp theo, tôi muốn cung cấp một số ví dụ về mã thực được nhìn thấy trong quá trình đánh giá mã.
const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])
Xem xét mức độ đơn giản của thao tác cộng hai số hoặc nối chuỗi và chúng tôi lấy các giá trị gốc làm đầu ra trong các ví dụ này, rõ ràng là việc sử dụng useMemo ở đây không có ý nghĩa gì. Ví dụ tương tự ở đây.
const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])
Dựa trên các phần phụ thuộc trong useMemo, bạn có thể thu được các kết quả khác nhau, nhưng một lần nữa, chúng là kết quả nguyên thủy và không có phép tính phức tạp bên trong. Việc thực thi bất kỳ điều kiện nào trong số này trên mỗi kết xuất của thành phần sẽ rẻ hơn so với sử dụng useMemo.
Chúng tôi có thể đề xuất ít nhất 4 công cụ này để đo hiệu suất mã ứng dụng của bạn.
React.Profiler
Với
Công cụ dành cho nhà phát triển React
Trình theo dõi kết xuất phản ứng
Một công cụ thú vị khác là
Storybook với addon storybook-addon-performance.
Ngoài ra, trong Storybook, bạn có thể cài đặt một plugin thú vị có tên
** Viết bởi Sergey Levkovich, Kỹ sư phần mềm cao cấp tại Social Discovery Group