1 引言
React Hooks 漸漸被國內前端團隊所接受,但基於 Hooks 的資料流方案卻還未固定,我們有 “100 種” 類似的選擇,卻各有利弊,讓人難以取捨。
本週筆者就深入談一談對 Hooks 資料流的理解,相信讀完文章後,可以從百花齊放的 Hooks 資料流方案中看到本質。
2 精讀
基於 React Hooks 談資料流,我們先從最不容易產生分歧的基礎方案說起。
單元件資料流
單元件最簡單的資料流一定是 useState
:
function App() {
const [count, setCount] = useState();
}
複製程式碼
useState
在元件內用是毫無爭議的,那麼下個話題就一定是跨元件共享資料流了。
元件間共享資料流
跨元件最簡單的方案就是 useContext
:
const CountContext = createContext();
function App() {
const [count, setCount] = useState();
return (
<CountContext.Provider value={{ count, setCount }}>
<Child />
</CountContext.Provider>
);
}
function Child() {
const { count } = useContext(CountContext);
}
複製程式碼
用法都是官方 API,顯然也是毫無爭議的,但問題是資料與 UI 不解耦,這個問題 unstated-next 已經為你想好解決方案了。
資料流與元件解耦
unstated-next 可以幫你把上面例子中,定義在 App
中的資料單獨出來,形成一個自定義資料管理 Hook:
import { createContainer } from "unstated-next";
function useCounter() {
const [count, setCount] = useState();
return { count, setCount };
}
const Counter = createContainer(useCounter);
function App() {
return (
<Counter.Provider>
<Child />
</Counter.Provider>
);
}
function Child() {
const { count } = Counter.useContainer();
}
複製程式碼
資料與 App
就解耦了,這下 Counter
再也不和 App
繫結了,Counter
可以和其他元件繫結作用了。
這個時候效能問題就慢慢浮出了水面,首當其衝的就是 useState
無法合併更新的問題,我們自然想到利用 useReducer
解決。
合併更新
useReducer
可以讓資料合併更新,這也是 React 官方 API,毫無爭議:
import { createContainer } from "unstated-next";
function useCounter() {
const [state, dispath] = useReducer(
(state, action) => {
switch (action.type) {
case "setCount":
return {
...state,
count: action.setCount(state.count),
};
case "setFoo":
return {
...state,
foo: action.setFoo(state.foo),
};
default:
return state;
}
return state;
},
{ count: 0, foo: 0 }
);
return { ...state, dispatch };
}
const Counter = createContainer(useCounter);
function App() {
return (
<Counter.Provider>
<Child />
</Counter.Provider>
);
}
function Child() {
const { count } = Counter.useContainer();
}
複製程式碼
這下即便要同時更新 count
和 foo
,我們也能通過抽象成一個 reducer
的方式合併更新。
然而還有效能問題:
function ChildCount() {
const { count } = Counter.useContainer();
}
function ChildFoo() {
const { foo } = Counter.useContainer();
}
複製程式碼
更新 foo
時,ChildCount
和 ChildFoo
同時會執行,但 ChildCount
沒用到 foo
呀?這個原因是 Counter.useContainer
提供的資料流是一個引用整體,其子節點 foo
引用變化後會導致整個 Hook 重新執行,繼而所有引用它的元件也會重新渲染。
此時我們發現可以利用 Redux useSelector
實現按需更新。
按需更新
首先我們利用 Redux 對資料流做一次改造:
import { createStore } from "redux";
import { Provider, useSelector } from "react-redux";
function reducer(state, action) {
switch (action.type) {
case "setCount":
return {
...state,
count: action.setCount(state.count),
};
case "setFoo":
return {
...state,
foo: action.setFoo(state.foo),
};
default:
return state;
}
return state;
}
function App() {
return (
<Provider store={store}>
<Child />
</Provider>
);
}
function Child() {
const { count } = useSelector(
(state) => ({ count: state.count }),
shallowEqual
);
}
複製程式碼
useSelector
可以讓 Child
在 count
變化時才更新,而 foo
變化時不更新,這已經接近較為理想的效能目標了。
但 useSelector
的作用僅僅是計算結果不變化時阻止元件重新整理,但並不能保證返回結果的引用不變化。
防止資料引用頻繁變化
對於上面的場景,拿到 count
的引用是不變的,但對於其他場景就不一定了。
舉個例子:
function Child() {
const user = useSelector((state) => ({ user: state.user }), shallowEqual);
return <UserPage user={user} />;
}
複製程式碼
假設 user
物件在每次資料流更新引用都會發生變化,那麼 shallowEqual
自然是不起作用,那我們換成 deepEqual
深對比呢?結果是引用依然會變,只是重渲染不那麼頻繁了:
function Child() {
const user = useSelector(
(state) => ({ user: state.user }),
// 當 user 值變化時才重渲染
deepEqual
);
// 但此處拿到的 user 引用還是會變化
return <UserPage user={user} />;
}
複製程式碼
是不是覺得在 deepEqual
的作用下,沒有觸發重渲染,user
的引用就不會變呢?答案是會變,因為 user
物件在每次資料流更新都會變,useSelector
在 deepEqual
作用下沒有觸發重渲染,但因為全域性 reducer 隱去元件自己的重渲染依然會重新執行此函式,此時拿到的 user
引用會不斷變化。
因此 useSelector
deepEqual
一定要和 useDeepMemo
結合使用,才能保證 user
引用不會頻繁改變:
function Child() {
const user = useSelector(
(state) => ({ user: state.user }),
// 當 user 值變化時才重渲染
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={user} />;
}
複製程式碼
當然這是比較極端的情況,只要看到 deepEqual
與 useSelector
同時作用了,就要問問自己其返回的值的引用會不會發生意外變化。
快取查詢函式
對於極限場景,即便控制了重渲染次數與返回結果的引用最大程度不變,還是可能存在效能問題,這最後一塊效能問題就處在查詢函式上。
上面的例子中,查詢函式比較簡單,但如果查詢函式非常複雜就不一樣了:
function Child() {
const user = useSelector(
(state) => ({ user: verySlowFunction(state.user) }),
// 當 user 值變化時才重渲染
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={user} />;
}
複製程式碼
我們假設 verySlowFunction
要遍歷畫布中 1000 個元件的 n 3 次方次,那元件的重渲染時間消耗與查詢時間相比完全不值一提,我們需要考慮快取查詢函式。
一種方式是利用 reselect 根據引數引用進行快取。
想象一下,如果 state.user
的引用不頻繁變化,但 verySlowFunction
非常慢,理想情況是 state.user
引用變化後才重新執行 verySlowFunction
,但上面的例子中,useSelector
並不知道還能這麼優化,只能傻傻的每次渲染重複執行 verySlowFunction
,哪怕 state.user
沒有變。
此時我們要告訴引用,state.user
是否變化才是重新執行的關鍵:
import { createSelector } from "reselect";
const userSelector = createSelector(
(state) => state.user,
(user) => verySlowFunction(user)
);
function Child() {
const user = useSelector(
(state) => userSelector(state),
// 當 user 值變化時才重渲染
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={user} />;
}
複製程式碼
在上面的例子中,通過 createSelector
建立的 userSelector
會一層層進行快取,當第一個引數返回的 state.user
引用不變時,會直接返回上一次執行結果,直到其應用變化了才會繼續往下執行。
這也說明了函式式保持冪等的重要性,如果
verySlowFunction
不是嚴格冪等的,這種快取也無法實施。
看上去很美好,然而實戰中你可能發現沒有那麼美好,因為上面的例子都建立在 Selector 完全不依賴外部變數。
結合外部變數的快取查詢
如果我們要查詢的使用者來自於不同地區,需要傳遞 areaId
加以識別,那麼可以拆分為兩個 Selector 函式:
import { createSelector } from "reselect";
const areaSelector = (state, props) => state.areas[props.areaId].user;
const userSelector = createSelector(areaSelector, (user) =>
verySlowFunction(user)
);
function Child() {
const user = useSelector(
(state) => userSelector(state, { areaId: 1 }),
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={user} />;
}
複製程式碼
所以為了不在元件函式內呼叫 createSelector
,我們需要儘可能將用到外部變數的地方抽象成一個通用 Selector,並作為 createSelector
的一個先手環節。
但 userSelector
提供給多個元件使用時快取會失效,原因是我們只建立了一個 Selector 例項,因此這個函式還需要再包裝一層高階形態:
import { createSelector } from "reselect";
const userSelector = () =>
createSelector(areaSelector, (user) => verySlowFunction(user));
function Child() {
const customSelector = useMemo(userSelector, []);
const user = useSelector(
(state) => customSelector(state, { areaId: 1 }),
deepEqual
);
}
複製程式碼
所以對於外部變數結合的環節,還需要 useMemo
與 useSelector
結合使用,useMemo
處理外部變數依賴的引用快取,useSelector
處理 Store 相關引用快取。
3 總結
基於 Hooks 的資料流方案不能算完美,我在寫作這篇文章時就感覺到這種方案屬於 “淺入深出”,簡單場景還容易理解,隨著場景逐步複雜,方案也變得越來越複雜。
但這種 Immutable 的資料流管理思路給了開發者非常自由的快取控制能力,只要透徹理解上述概念,就可以開發出非常 “符合預期” 的資料快取管理模型,只要精心維護,一切就變得非常有秩序。
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)