维护大型项目 OR UI 组件模块时，一定会遇到全局数据传递问题。

维护项目时，像全局用户信息、全局项目配置、全局功能配置等等，都是跨模块复用的全局数据。

维护 UI 组件时，调用组件的入口只有一个，但组件内部会继续拆模块，分文件，对于这些组件内模块而言，入口文件的参数也就是全局数据。

这时一般有三种方案：

1. props 透传。
2. 上下文。
3. 全局数据流。

props 透传方案，因为任何一个节点掉链子都会导致参数传递失败，因此带来的维护成本与心智负担都特别大。

上下文即 `useContext` 利用上下文共享全局数据，带来的问题是更新粒度太粗，同上下文中任何值的改变都会导致重渲染。有一种较为 Hack 的解决方案 [use-context-selector](https://github.com/dai-shi/use-context-selector)，不过这个和下面说到的全局数据流很像。

全局数据流即利用 `react-redux` 等工具，绕过 React 更新机制进行全局数据传递的方案，这种方案较好解决了项目问题，但很少有组件会使用。以前也有过不少利用 Redux 做局部数据流的方案，但本质上还是全局数据流。现在 `react-redux` 支持了局部作用域方案：

```javascript
import { shallowEqual, createSelectorHook, createStoreHook } from 'react-redux'

const context = React.createContext(null)
const useStore = createStoreHook(context)
const useSelector = createSelectorHook(context)
const useDispatch = createDispatchHook(context)
```

因此是机会好好梳理一下数据流管理方案，做一个项目、组件通用的数据流管理方案。

## 精读

对项目、组件来说，数据流包含两种数据：

1. 可变数据。
2. 不可变数据。

对项目来说，可变数据的来源有：

1. 全局外部参数。
2. 全局项目自定义变量。

不可变数据来源有：

1. 操作数据或行为的函数方法。

> 全局外部参数指不受项目代码控制的，比如登陆用户信息数据。全局项目自定义变量是由项目代码控制的，比如定义了一些模型数据、状态数据。

对组件来说，可变数据的来源有：

1. 组件被调用时的传参。
2. 全局组件自定义变量。

不可变数据来源有：

1. 组件被调用时的传参。
2. 操作数据或行为的函数方法。

对组件来说，被调用时的传参既可能是可变数据，也可能是不可变数据。比如传入的 `props.color` 可能就是可变数据，而 `props.defaultValue`、`props.onChange` 就是不可变数据。

当梳理清楚项目与组件到底有哪些全局数据后，我们就可以按照注册与调用这两步来设计数据流管理规范了。

### 数据流调用

首先来看调用。为了同时保证使用的便捷与应用程序的性能，我们希望使用一个统一的 API `useXXX` 来访问所有全局数据与方法，并满足：

1. `{} = useXXX()` 只能引用到不可变数据，包括变量与方法。
2. `{ value } = useXXX(state => ({ value: state.value }))` 可以引用到可变数据，但必须通过选择器来调用。

比如一个应用叫 `gaea`，那么 `useGaea` 就是对这个应用全局数据的唯一调用入口，我可以在组件里这么调用数据与方法：

```typescript
const Panel = () => {
  // appId 是应用不可变数据，所以即使是变量也可以直接获取，因为它不会变化，也不会导致重渲染
  // fetchData 是取数函数，内置发送了 appId，所以绑定了一定上下文，也属于不可变数据
  const { appId, fetchData } = useGaea()

  // 主题色可能在运行时修改，只能通过选择器获取
  // 此时这个组件会额外在 color 变化时重渲染
  const { color } = useGaea(state => ({
    color: state.theme?.color
  }))
}
```

比如一个组件叫 `Menu`，那么 `useMenu` 就是这个组件的全局数据调用入口，可以这么使用：

```typescript
// SubMenu 是 Menu 组件的子组件，可以直接使用 useMenu
const SubMenu = () => {
  // defaultValue 是一次性值，所以处理时做了不可变处理，这里已经是不可变数据了
  // onMenuClick 是回调函数，不管传参引用如何变化，这里都处理成不可变的引用
  const { defaultValue, onMenuClick } = useMenu()

  // disabled 是 menu 的参数，需要在变化时立即响应，所以是可变数据
  const { disabled } = useMenu(state => ({
    disabled: state.disabled
  }))

  // selectedMenu 是 Menu 组件的内部状态，也作为可变数据调用
  const { selectedMenu } = useMenu(state => ({
    selectedMenu: state.selectedMenu
  }))
}
```

可以发现，在整个应用或者组件的使用 Scope 中，已经做了一层抽象，即不关心数据是怎么来的，只关心数据是否可变。这样对于组件或应用，随时可以将内部状态开放到 API 层，而内部代码完全不用修改。

### 数据流注册

数据流注册的时候，我们只要定义三种参数：

1. `dynamicValue`: 动态参数，通过 `useInput(state => state.xxx)` 才能访问到。
2. `staticValue`: 静态参数，引用永远不会改变，可以直接通过 `useInput().xxx` 访问到。
3. 自定义 hooks，入参是 `staticValue` `getState` `setState`，这里可以封装自定义方法，并且定义的方法都必须是静态的，可以直接通过 `useInput().xxx` 访问到。

```typescript
const { useState: useInput, Provider } = createHookStore<{
  dynamicValue: {
    fontSize: number
  }
  staticValue: {
    onChange: (value: number) => void
  }
}>(({ staticValue }) => {
  const onCustomChange = React.useCallback((value: number) => {
    staticValue.onChange(value + 1)
  }, [staticValue])

  return React.useMemo(() => ({
    onCustomChange
  }), [onCustomChange])
})
```

上面的方法暴露了 `Provider` 与 `useInput` 两个对象，我们首先需要在组件里给它传输数据。比如我写的是组件 `Input`，就可以这么调用：

```jsx
function Input({ onChange, fontSize }) {
  return (
    <Provider dynamicValue={{fontSize}} staticValue={{onChange}}>
      <InputComponent />
    </Provider>
  )
}
```

如果对于某些动态数据，我们只想赋初值，可以使用 `defaultDynamicValue`：

```jsx
function Input({ onChange, fontSize }) {
  return (
    <Provider dynamicValue={{fontSize}} defaultDynamicValue={{count: 1}}>
      <InputComponent />
    </Provider>
  )
}
```

这样 `count` 就是一个动态值，必须通过 `useInput(state => ({ count: state.count }))` 才能取到，但又不会因为外层组件 Rerender 而被重新赋值为 `1`。所有动态值都可以通过 `setState` 来修改，这个后面再说。

这样所有 Input 下的子组件就可以通过 `useInput` 访问到全局数据流的数据啦，我们有三种访问数据的场景。

一：访问传给 `Input` 组件的 `onChange`。

因为 `onChange` 是不可变对象，因此可以通过如下方式访问：

```typescript
function InputComponent() {
  const { onChange } = useInput()
}
```

二：访问我们自定义的全局 Hooks 函数 `onCustomChange`：

```typescript
function InputComponent() {
  const { onCustomChange } = useInput()
}
```

三：访问可能变化的数据 `fontSize`。由于我们需要在 `fontSize` 变化时让组件重渲染，又不想让上面两种调用方式受到 `fontSize` 的影响，需要通过如下方式访问：

```typescript
function InputComponent() {
  const { fontSize } = useInput(state => ({
    fontSize: state.fontSize
  }))
}
```

最后在自定义方法中，如果我们想修改可变数据，都要通过 `updateStore` 封装好并暴露给外部，而不能直接调用。具体方式是这样的，举个例子，假设我们需要定义一个应用状态 `status`，其可选值为 `edit` 与 `preview`，那么可以这么去定义：

```jsx
const { useState: useInput, Provider } = createHookStore<{
  dynamicValue: {
    isAdmin: boolean
    status: 'edit' | 'preview'
  }
}>(({ getState, setState }) => {
  const toggleStatus = React.useCallback(() => {
    // 管理员才能切换应用状态
    if (!getState().isAdmin) {
      return
    }

    setState(state => ({
      ...state,
      status: state.status === 'edit' ? 'preview' : 'edit'
    }))
  }, [getState, setState])

  return React.useMemo(() => ({
    toggleStatus
  }), [toggleStatus])
})
```

下面是调用：

```jsx
function InputComponent() {
  const { toggleStatus } = useInput()

  return (
    <button onClick={toggleStatus} />
  )
}
```

而且整个链路的类型定义也是完全自动推导的，这套数据流管理方案到这里就讲完了。

## 总结

对全局数据的使用，最方便的就是收拢到一个 `useXXX` API，并且还能区分静态、动态值，并在访问静态值时完全不会导致重渲染。

而之所以动态值 `dynamicValue` 需要在 `Provider` 里定义，是因为当动态值变化时，会自动更新数据流中的数据，使整个应用数据与外部动态数据同步。而这个更新步骤就是通过 Redux Store 来完成的。

本文特意没有给出实现源码，感兴趣的同学可以自己实现一个试一试。

> 讨论地址是：[精读《一种 Hooks 数据流管理方案》· Issue #345 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/345)

**如果你想参与讨论，请 [点击这里](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)）
