React 中解決 JS 引用變化問題的探索與展望

語言: CN / TW / HK

theme: juejin

本文首發於公眾號「小李的前端小屋」,歡迎關注~

前言

為了讓開發者更簡單的構建符合 UI = f(state) 哲學理念的 UX,React 引入了函式式元件和一套邏輯複用的解決方案 —— Hooks。

但是 Hooks 的引入也帶來一些問題:

  • useCallbackuseMmeouseRef 等使用讓程式碼不好讀。
  • 需要關心 JS 複雜型別的引用變化,有一定心智負擔,甚至會影響業務邏輯的正確與否。

引用變化造成的問題

引用型別是 JS 一種複雜資料型別,統稱為 object 型別,包括物件,陣列,函式等。在比較 object 型別時,實際上比較的是它們的引用,使用 == / === 無法判斷兩個物件的“值”否相等。

js const a = {}; const b = {}; console.log(a === b); // false

而 React 函式元件每次渲染都會呼叫自身函式,函式內定義的所有區域性變數都會被重新建立。如果其中有引用型別的物件變數,重新建立會導致引用改變,使用在下列場景中會有一定風險:

  • 這個物件被作為 useEffectuseMemouseCallback 的依賴,會導致邏輯和計算頻繁執行。

  • 這個物件作為 prop 被傳遞給下游被 React.memo 的元件或 React.PureComponent 繼承元件,引起下游元件的非預期重新渲染,如果下游元件的渲染開銷較大,會引起效能問題。

探索

為了保持引用的穩定,可以藉助 React 提供的 Hook API:

  1. 使用 useCallbackuseMemo 包一下引用型別
  2. 將引用型別掛在 Ref

使用它們,我們能產出最佳實踐嗎?

memo 所有物件

我們分場景討論一下:

對於業務程式碼

業務程式碼功能複雜,DOM 樹層級很深很龐大,所以對應的 React 元件樹也會很複雜。如果在每一個元件中都有 useMemo/useCallback,會讓元件的渲染時間長,佔用更多的記憶體。幾百個元件加在一起,對效能的損害比它們本身起到的作用要大。

所以業務程式碼裡的 useMemouseCallback 需要有節制的去使用,關於它們使用場景的討論一直都是 React 的熱點話題,網上文章一搜一大把,但到目前也沒有一個受到廣泛認可的最佳實踐,這裡不再多討論了。但有一點我比較贊同的是,應該保證 useEffectuseMemouseCallback 的依賴項和元件的 props 都是基本型別,能有效減小引用變化帶來的影響。

對於第三方庫

作為第三方庫,穩定性是比較重要的,應該保證不出現自身原因導致的下游依賴方問題,「memo 所有物件」是沒有辦法中的辦法。比如 React Hook Formahooks,它們為了解決引用問題,所有暴露的物件都是 memoized 的。

```js // react-hook-form

return { swap: React.useCallback(swap, [updateValues, name, control, keyName]), move: React.useCallback(move, [updateValues, name, control, keyName]), prepend: React.useCallback(prepend, [updateValues, name, control, keyName]), append: React.useCallback(append, [updateValues, name, control, keyName]), remove: React.useCallback(remove, [updateValues, name, control, keyName]), insert: React.useCallback(insert, [updateValues, name, control, keyName]), update: React.useCallback(update, [updateValues, name, control, keyName]), replace: React.useCallback(replace, [updateValues, name, control, keyName]), fields: React.useMemo( () => fields.map((field, index) => ({ ...field, [keyName]: ids.current[index] || generateId(), })) as FieldArrayWithId[], [fields, keyName], ) }; ```

useMemoOne

事實上,即使值被 useMemo 和 useCallback 快取起來了,快取也有可能丟失。

這點在 React 文件裡也有說明

你可以把 useMemo 作為效能優化的手段,但不要把它當成語義上的保證。 將來,React 可能會選擇“遺忘”以前的一些 memoized 值,並在下次渲染時重新計算它們,比如為離屏元件釋放記憶體。先編寫在沒有 useMemo 的情況下也可以執行的程式碼 —— 之後再在你的程式碼中新增 useMemo,以達到優化效能的目的。

(但是,目前我還沒有聽說過該機制引發的問題)。

為了解決”遺忘“可能會造成的引用變化,社群裡有一種永遠不會被"遺忘"的 useMemo 設計 ——useMemoOne,而且在併發模式下,它也是安全的。

```ts // before const value = useMemo(() => ({ hello: name }), [name]);

// after const value = useMemoOne(() => ({ hello: name }), [name]); ```

但是為了永久的穩定引用,保證在垃圾回收之前不會釋放快取,useMemoOne 會比 useMemo 佔用的更多的記憶體。

因此,useMemoOne 也只是個使用於個別場景的備選方案。

狀態都掛到 Ref 上

React 選擇性”遺忘“也並不是一個大問題,把這些值都掛在 Ref 上就行了。

因為複雜引用的問題根本原因是物件的引用會隨著重新渲染而變化,而 Ref 中儲存的值不會在每次渲染時銷燬和新建。

可以把 useRef 當作 useState({current: initialValue })[0]

具體做法是使用 useRef 建立元件例項 instanceRef,並把這個元件用到的所有狀態都儲存在這個 instanceRef 當中。

```ts const myComponent = () => { const instanceRef = useRef({ state1: ... state2: ...

value1: ...
value2: ...

func1: ...
func2: ...

});

// ... } ```

需要更新檢視時,手動呼叫 forceUpdate()

ts const forceUpdate = React.useReducer(() => ({}), {})[1]

這是一個比較少眾的方案,但社群裡也有對應的實踐。比如 react-table 中的 useTable API,它將 table 有關的屬性和方法都存在了 instanceRef 中,並用 rerender 方法(也就是 forceUpdate) 手動控制檢視更新。

```js function useTable(options: Options) { const instanceRef = React.useRef(undefined!)

const rerender = React.useReducer(() => ({}), {})[1]

if (!instanceRef.current) { instanceRef.current = createTableInstance< TData, TValue, TFilterFns, TSortingFns, TAggregationFns >(options, rerender) }

return instanceRef.current } ```

這種做法確實可以解決引用變化的問題,但是程式碼不宜維護:

  1. 對 state 的取值需要從 someState 改為 ref.someState,一旦一個元件這樣寫了之後,之後要有什麼新的狀態也只好放到 ref 裡面,整個 instanceRef 的複雜度會不斷提高。
  2. 檢視上有任何狀態不匹配的表現時,問題排查困難,為了同步狀態只有使用 forceUpdate 來解決。
  3. 每次更新檢視需要手動呼叫 forceUpdate,不太符合函數語言程式設計的思想,官方是不推薦這種方式的。

WX20220329-100156@2x.png

展望

以上的方案都有點投機取巧,算不上最佳實踐。未來會有更好的方案嗎?

Record 和 Tuple 型別

在 JS 中,物件的比較不是值的比較,而是引用的比較。這點是由 JS 語言本身決定的。有沒有可能從 JS 語言這方面去解決呢?

在最近的 proposal-record-tuple 提案中,JS 新增了兩個原始資料型別:Record 和 Tuple。它讓 js 原生支援了不可變資料型別,可以讓 js 開出一條原生 immutable 賽道。

Record 和 Tuple 其實就是一個只讀的 Object 和 Array,在原先的物件和陣列前面加 # 就能完成定義。

```ts // Record const myRecords = #{ x: 1, y: 2 };

// Tuple const myTuplee = #[1, 2, 3]; ```

關鍵的是,Record 和 Tuple 的比較採用的是值比較,而不是引用比較:

```ts const a = #{}; const b = #{}; console.log(a === b); // true

const c = #[] const d = #[]; console.log(c === d); // true ```

有了這個機制,我們不用再 memo 所有物件,不用再見到 useMemo 滿天飛的程式碼了!但遺憾的是,這次只提案了 ObjectArrayFunction 仍然沒有得到支援,所以 useCallback 還是得繼續接著用。

如果你還想深入瞭解該提案能夠幫助解決 React 的哪些問題,推薦精讀《Records & Tuples for React》 by 黃子毅老師

React Forget

在 React Conf 2021 上,黃玄老師分享了一個名為 React Forget 的編譯器。

v2-52d7502e71bae8204aaba2647fb7da47_1440w.jpeg

簡單來說,這個編譯器會在程式碼編譯時,檢測 useMemouseCallback 的必要性並自動幫你加上,來優化元件的重新渲染過程。

不只是 useMemouseCallback,React 節點是否需要 memo 也會被檢測,所以未來 React.memo 可能也不再需要了,真正實現 React without memo。

v2-9aa6eba4308dadf31b9d7d0514047837_1440w.jpeg

完整演講影片 請點此。

結語

JS 引用型別特性給 React 函式元件的使用帶來了心智負擔和使用成本。

在當下,React 的高自由度可以讓我們去選擇契合業務場景的解決方案。

在未來,可能會從 JS 語言本身和 React 方面來根本解決引用型別問題。

謝謝支援❤️

如果本文對你有幫助,幫我點個贊吧(關注更好^ ^。

我的公眾號「小李的前端小屋」最近剛剛建立,還在不斷完善中,關注我,一起變強!🚀

參考

  • https://giacomocerquone.com/whats-an-object-identity-in-javascript/
  • https://www.zhenghao.io/posts/memo-or-not?utm_campaign=This%20Week%20In%20React&utm_medium=email&utm_source=Revue%20newsletter
  • https://kentcdodds.com/blog/usememo-and-usecallback#why-is-usecallback-worse
  • https://royi-codes.vercel.app/thousand-usecallbacks/
  • https://zhuanlan.zhihu.com/p/443807113
  • https://javascript.plainenglish.io/react-conf-2021-react-forget-310ef73e9a70