精讀《React Hooks 資料流》

黃子毅發表於2020-04-07

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();
}
複製程式碼

這下即便要同時更新 countfoo,我們也能通過抽象成一個 reducer 的方式合併更新。

然而還有效能問題:

function ChildCount() {
  const { count } = Counter.useContainer();
}

function ChildFoo() {
  const { foo } = Counter.useContainer();
}
複製程式碼

更新 foo 時,ChildCountChildFoo 同時會執行,但 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 可以讓 Childcount 變化時才更新,而 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 物件在每次資料流更新都會變,useSelectordeepEqual 作用下沒有觸發重渲染,但因為全域性 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} />;
}
複製程式碼

當然這是比較極端的情況,只要看到 deepEqualuseSelector 同時作用了,就要問問自己其返回的值的引用會不會發生意外變化。

快取查詢函式

對於極限場景,即便控制了重渲染次數與返回結果的引用最大程度不變,還是可能存在效能問題,這最後一塊效能問題就處在查詢函式上。

上面的例子中,查詢函式比較簡單,但如果查詢函式非常複雜就不一樣了:

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
  );
}
複製程式碼

所以對於外部變數結合的環節,還需要 useMemouseSelector 結合使用,useMemo 處理外部變數依賴的引用快取,useSelector 處理 Store 相關引用快取。

3 總結

基於 Hooks 的資料流方案不能算完美,我在寫作這篇文章時就感覺到這種方案屬於 “淺入深出”,簡單場景還容易理解,隨著場景逐步複雜,方案也變得越來越複雜。

但這種 Immutable 的資料流管理思路給了開發者非常自由的快取控制能力,只要透徹理解上述概念,就可以開發出非常 “符合預期” 的資料快取管理模型,只要精心維護,一切就變得非常有秩序。

討論地址是:精讀《React Hooks 資料流》 · Issue #242 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《React Hooks 資料流》

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章