## 1 引言

React Hooks 渐渐被国内前端团队所接受，但基于 Hooks 的数据流方案却还未固定，我们有 “100 种” 类似的选择，却各有利弊，让人难以取舍。

本周笔者就深入谈一谈对 Hooks 数据流的理解，相信读完文章后，可以从百花齐放的 Hooks 数据流方案中看到本质。

## 2 精读

基于 React Hooks 谈数据流，我们先从最不容易产生分歧的基础方案说起。

### 单组件数据流

单组件最简单的数据流一定是 `useState`：

```jsx
function App() {
  const [count, setCount] = useState();
}
```

`useState` 在组件内用是毫无争议的，那么下个话题就一定是跨组件共享数据流了。

### 组件间共享数据流

跨组件最简单的方案就是 `useContext`：

```jsx
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](https://github.com/jamiebuilds/unstated-next) 已经为你想好解决方案了。

### 数据流与组件解耦

[unstated-next](https://github.com/jamiebuilds/unstated-next) 可以帮你把上面例子中，定义在 `App` 中的数据单独出来，形成一个自定义数据管理 Hook：

```jsx
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，毫无争议：

```jsx
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` 的方式合并更新。

然而还有性能问题：

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

function ChildFoo() {
  const { foo } = Counter.useContainer();
}
```

更新 `foo` 时，`ChildCount` 和 `ChildFoo` 同时会执行，但 `ChildCount` 没用到 `foo` 呀？这个原因是 `Counter.useContainer` 提供的数据流是一个引用整体，其子节点 `foo` 引用变化后会导致整个 Hook 重新执行，继而所有引用它的组件也会重新渲染。

此时我们发现可以利用 Redux `useSelector` 实现按需更新。

### 按需更新

首先我们利用 Redux 对数据流做一次改造：

```jsx
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` 的引用是不变的，**但对于其他场景就不一定了**。

举个例子：

```jsx
function Child() {
  const user = useSelector((state) => ({ user: state.user }), shallowEqual);

  return <UserPage user={user} />;
}
```

**假设 `user` 对象在每次数据流更新引用都会发生变化**，那么 `shallowEqual` 自然是不起作用，那我们换成 `deepEqual`深对比呢？结果是引用依然会变，只是重渲染不那么频繁了：

```jsx
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` 引用不会频繁改变：

```jsx
function Child() {
  const user = useSelector(
    (state) => ({ user: state.user }),
    // 当 user 值变化时才重渲染
    deepEqual
  );

  const userDeep = useDeepMemo(() => user, [user]);

  return <UserPage user={userDeep} />;
}
```

当然这是比较极端的情况，只要看到 `deepEqual` 与 `useSelector` 同时作用了，就要问问自己其返回的值的引用会不会发生意外变化。

### 缓存查询函数

对于极限场景，即便控制了重渲染次数与返回结果的引用最大程度不变，还是可能存在性能问题，这最后一块性能问题就处在查询函数上。

上面的例子中，查询函数比较简单，但如果查询函数非常复杂就不一样了：

```jsx
function Child() {
  const user = useSelector(
    (state) => ({ user: verySlowFunction(state.user) }),
    // 当 user 值变化时才重渲染
    deepEqual
  );

  const userDeep = useDeepMemo(() => user, [user]);

  return <UserPage user={userDeep} />;
}
```

我们假设 `verySlowFunction` 要遍历画布中 1000 个组件的 n 3 次方次，那组件的重渲染时间消耗与查询时间相比完全不值一提，我们需要考虑缓存查询函数。

一种方式是利用 [reselect](https://github.com/reduxjs/reselect) 根据参数引用进行缓存。

想象一下，如果 `state.user` 的引用不频繁变化，但 `verySlowFunction` 非常慢，理想情况是 `state.user` 引用变化后才重新执行 `verySlowFunction`，但上面的例子中，`useSelector` 并不知道还能这么优化，只能傻傻的每次渲染重复执行 `verySlowFunction`，哪怕 `state.user` 没有变。

此时我们要告诉引用，`state.user` 是否变化才是重新执行的关键：

```jsx
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={userDeep} />;
}
```

在上面的例子中，通过 `createSelector` 创建的 `userSelector` 会一层层进行缓存，当第一个参数返回的 `state.user` 引用不变时，会直接返回上一次执行结果，直到其应用变化了才会继续往下执行。

> 这也说明了函数式保持幂等的重要性，如果 `verySlowFunction` 不是严格幂等的，这种缓存也无法实施。

看上去很美好，然而实战中你可能发现没有那么美好，因为上面的例子都建立在 **Selector 完全不依赖外部变量**。

### 结合外部变量的缓存查询

如果我们要查询的用户来自于不同地区，需要传递 `areaId` 加以识别，那么可以拆分为两个 Selector 函数：

```jsx
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={userDeep} />;
}
```

所以为了不在组件函数内调用 `createSelector`，我们需要尽可能将用到外部变量的地方抽象成一个通用 Selector，并作为 `createSelector` 的一个先手环节。

但 `userSelector` 提供给多个组件使用时缓存会失效，原因是我们只创建了一个 Selector 实例，因此这个函数还需要再包装一层高阶形态：

```jsx
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 的数据流管理思路给了开发者非常自由的缓存控制能力，只要透彻理解上述概念，就可以开发出非常 “符合预期” 的数据缓存管理模型，只要精心维护，一切就变得非常有秩序。

> 讨论地址是：[精读《React Hooks 数据流》 · Issue #242 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/242)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
