|
| 1 | +维护大型项目 OR UI 组件模块时,一定会遇到全局数据传递问题。 |
| 2 | + |
| 3 | +维护项目时,像全局用户信息、全局项目配置、全局功能配置等等,都是跨模块复用的全局数据。 |
| 4 | + |
| 5 | +维护 UI 组件时,调用组件的入口只有一个,但组件内部会继续拆模块,分文件,对于这些组件内模块而言,入口文件的参数也就是全局数据。 |
| 6 | + |
| 7 | +这时一般有三种方案: |
| 8 | + |
| 9 | +1. props 透传。 |
| 10 | +2. 上下文。 |
| 11 | +3. 全局数据流。 |
| 12 | + |
| 13 | +props 透传方案,因为任何一个节点掉链子都会导致参数传递失败,因此带来的维护成本与心智负担都特别大。 |
| 14 | + |
| 15 | +上下文即 `useContext` 利用上下文共享全局数据,带来的问题是更新粒度太粗,同上下文中任何值的改变都会导致重渲染。有一种较为 Hack 的解决方案 [use-context-selector](https://github.com/dai-shi/use-context-selector),不过这个和下面说到的全局数据流很像。 |
| 16 | + |
| 17 | +全局数据流即利用 `react-redux` 等工具,绕过 React 更新机制进行全局数据传递的方案,这种方案较好解决了项目问题,但很少有组件会使用。以前也有过不少利用 Redux 做局部数据流的方案,但本质上还是全局数据流。现在 `react-redux` 支持了局部作用域方案: |
| 18 | + |
| 19 | +```javascript |
| 20 | +import { shallowEqual, createSelectorHook, createStoreHook } from 'react-redux' |
| 21 | + |
| 22 | +const context = React.createContext(null) |
| 23 | +const useStore = createStoreHook(context) |
| 24 | +const useSelector = createSelectorHook(context) |
| 25 | +const useDispatch = createDispatchHook(context) |
| 26 | +``` |
| 27 | + |
| 28 | +因此是机会好好梳理一下数据流管理方案,做一个项目、组件通用的数据流管理方案。 |
| 29 | + |
| 30 | +## 精读 |
| 31 | + |
| 32 | +对项目、组件来说,数据流包含两种数据: |
| 33 | + |
| 34 | +1. 可变数据。 |
| 35 | +2. 不可变数据。 |
| 36 | + |
| 37 | +对项目来说,可变数据的来源有: |
| 38 | + |
| 39 | +1. 全局外部参数。 |
| 40 | +2. 全局项目自定义变量。 |
| 41 | + |
| 42 | +不可变数据来源有: |
| 43 | + |
| 44 | +1. 操作数据或行为的函数方法。 |
| 45 | + |
| 46 | +> 全局外部参数指不受项目代码控制的,比如登陆用户信息数据。全局项目自定义变量是由项目代码控制的,比如定义了一些模型数据、状态数据。 |
| 47 | +
|
| 48 | +对组件来说,可变数据的来源有: |
| 49 | + |
| 50 | +1. 组件被调用时的传参。 |
| 51 | +2. 全局组件自定义变量。 |
| 52 | + |
| 53 | +不可变数据来源有: |
| 54 | + |
| 55 | +1. 组件被调用时的传参。 |
| 56 | +2. 操作数据或行为的函数方法。 |
| 57 | + |
| 58 | +对组件来说,被调用时的传参既可能是可变数据,也可能是不可变数据。比如传入的 `props.color` 可能就是可变数据,而 `props.defaultValue`、`props.onChange` 就是不可变数据。 |
| 59 | + |
| 60 | +当梳理清楚项目与组件到底有哪些全局数据后,我们就可以按照注册与调用这两步来设计数据流管理规范了。 |
| 61 | + |
| 62 | +### 数据流调用 |
| 63 | + |
| 64 | +首先来看调用。为了同时保证使用的便捷与应用程序的性能,我们希望使用一个统一的 API `useXXX` 来访问所有全局数据与方法,并满足: |
| 65 | + |
| 66 | +1. `{} = useXXX()` 只能引用到不可变数据,包括变量与方法。 |
| 67 | +2. `{ value } = useXXX(state => ({ value: state.value }))` 可以引用到可变数据,但必须通过选择器来调用。 |
| 68 | + |
| 69 | +比如一个应用叫 `gaea`,那么 `useGaea` 就是对这个应用全局数据的唯一调用入口,我可以在组件里这么调用数据与方法: |
| 70 | + |
| 71 | +```typescript |
| 72 | +const Panel = () => { |
| 73 | + // appId 是应用不可变数据,所以即使是变量也可以直接获取,因为它不会变化,也不会导致重渲染 |
| 74 | + // fetchData 是取数函数,内置发送了 appId,所以绑定了一定上下文,也属于不可变数据 |
| 75 | + const { appId, fetchData } = useGaea() |
| 76 | + |
| 77 | + // 主题色可能在运行时修改,只能通过选择器获取 |
| 78 | + // 此时这个组件会额外在 color 变化时重渲染 |
| 79 | + const { color } = useGaea(state => ({ |
| 80 | + color: state.theme?.color |
| 81 | + })) |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +比如一个组件叫 `Menu`,那么 `useMenu` 就是这个组件的全局数据调用入口,可以这么使用: |
| 86 | + |
| 87 | +```typescript |
| 88 | +// SubMenu 是 Menu 组件的子组件,可以直接使用 useMenu |
| 89 | +const SubMenu = () => { |
| 90 | + // defaultValue 是一次性值,所以处理时做了不可变处理,这里已经是不可变数据了 |
| 91 | + // onMenuClick 是回调函数,不管传参引用如何变化,这里都处理成不可变的引用 |
| 92 | + const { defaultValue, onMenuClick } = useMenu() |
| 93 | + |
| 94 | + // disabled 是 menu 的参数,需要在变化时立即响应,所以是可变数据 |
| 95 | + const { disabled } = useMenu(state => ({ |
| 96 | + disabled: state.disabled |
| 97 | + })) |
| 98 | + |
| 99 | + // selectedMenu 是 Menu 组件的内部状态,也作为可变数据调用 |
| 100 | + const { selectedMenu } = useMenu(state => ({ |
| 101 | + selectedMenu: state.selectedMenu |
| 102 | + })) |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +可以发现,在整个应用或者组件的使用 Scope 中,已经做了一层抽象,即不关心数据是怎么来的,只关心数据是否可变。这样对于组件或应用,随时可以将内部状态开放到 API 层,而内部代码完全不用修改。 |
| 107 | + |
| 108 | +### 数据流注册 |
| 109 | + |
| 110 | +数据流注册的时候,我们只要定义三种参数: |
| 111 | + |
| 112 | +1. `dynamicValue`: 动态参数,通过 `useInput(state => state.xxx)` 才能访问到。 |
| 113 | +2. `staticValue`: 静态参数,引用永远不会改变,可以直接通过 `useInput().xxx` 访问到。 |
| 114 | +3. 自定义 hooks,入参是 `staticValue` `getState` `setState`,这里可以封装自定义方法,并且定义的方法都必须是静态的,可以直接通过 `useInput().xxx` 访问到。 |
| 115 | + |
| 116 | +```typescript |
| 117 | +const { useState: useInput, Provider } = createHookStore<{ |
| 118 | + dynamicValue: { |
| 119 | + fontSize: number |
| 120 | + } |
| 121 | + staticValue: { |
| 122 | + onChange: (value: number) => void |
| 123 | + } |
| 124 | +}>(({ staticValue }) => { |
| 125 | + const onCustomChange = React.useCallback((value: number) => { |
| 126 | + staticValue.onChange(value + 1) |
| 127 | + }, [staticValue]) |
| 128 | + |
| 129 | + return React.useMemo(() => ({ |
| 130 | + onCustomChange |
| 131 | + }), [onCustomChange]) |
| 132 | +}) |
| 133 | +``` |
| 134 | + |
| 135 | +上面的方法暴露了 `Provider` 与 `useInput` 两个对象,我们首先需要在组件里给它传输数据。比如我写的是组件 `Input`,就可以这么调用: |
| 136 | + |
| 137 | +```jsx |
| 138 | +function Input({ onChange, fontSize }) { |
| 139 | + return ( |
| 140 | + <Provider dynamicValue={{fontSize}} staticValue={{onChange}}> |
| 141 | + <InputComponent /> |
| 142 | + </Provider> |
| 143 | + ) |
| 144 | +} |
| 145 | +``` |
| 146 | + |
| 147 | +如果对于某些动态数据,我们只想赋初值,可以使用 `defaultDynamicValue`: |
| 148 | + |
| 149 | +```jsx |
| 150 | +function Input({ onChange, fontSize }) { |
| 151 | + return ( |
| 152 | + <Provider dynamicValue={{fontSize}} defaultDynamicValue={{count: 1}}> |
| 153 | + <InputComponent /> |
| 154 | + </Provider> |
| 155 | + ) |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +这样 `count` 就是一个动态值,必须通过 `useInput(state => ({ count: state.count }))` 才能取到,但又不会因为外层组件 Rerender 而被重新赋值为 `1`。所有动态值都可以通过 `setState` 来修改,这个后面再说。 |
| 160 | + |
| 161 | +这样所有 Input 下的子组件就可以通过 `useInput` 访问到全局数据流的数据啦,我们有三种访问数据的场景。 |
| 162 | + |
| 163 | +一:访问传给 `Input` 组件的 `onChange`。 |
| 164 | + |
| 165 | +因为 `onChange` 是不可变对象,因此可以通过如下方式访问: |
| 166 | + |
| 167 | +```typescript |
| 168 | +function InputComponent() { |
| 169 | + const { onChange } = useInput() |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +二:访问我们自定义的全局 Hooks 函数 `onCustomChange`: |
| 174 | + |
| 175 | +```typescript |
| 176 | +function InputComponent() { |
| 177 | + const { onCustomChange } = useInput() |
| 178 | +} |
| 179 | +``` |
| 180 | + |
| 181 | +三:访问可能变化的数据 `fontSize`。由于我们需要在 `fontSize` 变化时让组件重渲染,又不想让上面两种调用方式受到 `fontSize` 的影响,需要通过如下方式访问: |
| 182 | + |
| 183 | +```typescript |
| 184 | +function InputComponent() { |
| 185 | + const { fontSize } = useInput(state => ({ |
| 186 | + fontSize: state.fontSize |
| 187 | + })) |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +最后在自定义方法中,如果我们想修改可变数据,都要通过 `updateStore` 封装好并暴露给外部,而不能直接调用。具体方式是这样的,举个例子,假设我们需要定义一个应用状态 `status`,其可选值为 `edit` 与 `preview`,那么可以这么去定义: |
| 192 | + |
| 193 | +```jsx |
| 194 | +const { useState: useInput, Provider } = createHookStore<{ |
| 195 | + dynamicValue: { |
| 196 | + isAdmin: boolean |
| 197 | + status: 'edit' | 'preview' |
| 198 | + } |
| 199 | +}>(({ getState, setState }) => { |
| 200 | + const toggleStatus = React.useCallback(() => { |
| 201 | + // 管理员才能切换应用状态 |
| 202 | + if (!getState().isAdmin) { |
| 203 | + return |
| 204 | + } |
| 205 | + |
| 206 | + setState(state => ({ |
| 207 | + ...state, |
| 208 | + status: state.status === 'edit' ? 'preview' : 'edit' |
| 209 | + })) |
| 210 | + }, [getState, setState]) |
| 211 | + |
| 212 | + return React.useMemo(() => ({ |
| 213 | + toggleStatus |
| 214 | + }), [toggleStatus]) |
| 215 | +}) |
| 216 | +``` |
| 217 | + |
| 218 | +下面是调用: |
| 219 | + |
| 220 | +```jsx |
| 221 | +function InputComponent() { |
| 222 | + const { toggleStatus } = useInput() |
| 223 | + |
| 224 | + return ( |
| 225 | + <button onClick={toggleStatus} /> |
| 226 | + ) |
| 227 | +} |
| 228 | +``` |
| 229 | + |
| 230 | +而且整个链路的类型定义也是完全自动推导的,这套数据流管理方案到这里就讲完了。 |
| 231 | + |
| 232 | +## 总结 |
| 233 | + |
| 234 | +对全局数据的使用,最方便的就是收拢到一个 `useXXX` API,并且还能区分静态、动态值,并在访问静态值时完全不会导致重渲染。 |
| 235 | + |
| 236 | +而之所以动态值 `dynamicValue` 需要在 `Provider` 里定义,是因为当动态值变化时,会自动更新数据流中的数据,使整个应用数据与外部动态数据同步。而这个更新步骤就是通过 Redux Store 来完成的。 |
| 237 | + |
| 238 | +本文特意没有给出实现源码,感兴趣的同学可以自己实现一个试一试。 |
| 239 | + |
| 240 | +> 讨论地址是:[精读《一种 Hooks 数据流管理方案》· Issue #345 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/345) |
| 241 | +
|
| 242 | +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** |
| 243 | + |
| 244 | +> 关注 **前端精读微信公众号** |
| 245 | +
|
| 246 | +<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> |
| 247 | + |
| 248 | +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |
0 commit comments