维护大型项目 OR UI 组件模块时, 一定会遇到全局数据传递问题.
维护项目时, 像全局用户信息, 全局项目配置, 全局功能配置等等, 都是跨模块复用的全局数据.
维护 UI 组件时, 调用组件的入口只有一个, 但组件内部会继续拆模块, 分文件, 对于这些组件内模块而言, 入口文件的参数也就是全局数据.
这时一般有三种方案:
props 透传.
上下文.
全局数据流.
props 透传方案, 因为任何一个节点掉链子都会导致参数传递失败, 因此带来的维护成本与心智负担都特别大.
上下文即 useContext 利用上下文共享全局数据, 带来的问题是更新粒度太粗, 同上下文中任何值的改变都会导致重渲染. 有一种较为 Hack 的解决方案 use-context-selector, 不过这个和下面说到的全局数据流很像.
全局数据流即利用 react-redux 等工具, 绕过 React 更新机制进行全局数据传递的方案, 这种方案较好解决了项目问题, 但很少有组件会使用. 以前也有过不少利用 Redux 做局部数据流的方案, 但本质上还是全局数据流. 现在 react-redux 支持了局部作用域方案:
- import {
- shallowEqual, createSelectorHook, createStoreHook
- } from 'react-redux'
- const context = React.createContext(null)
- const useStore = createStoreHook(context)
- const useSelector = createSelectorHook(context)
- const useDispatch = createDispatchHook(context)
因此是机会好好梳理一下数据流管理方案, 做一个项目, 组件通用的数据流管理方案.
精读
对项目, 组件来说, 数据流包含两种数据:
可变数据.
不可变数据.
对项目来说, 可变数据的来源有:
全局外部参数.
全局项目自定义变量.
不可变数据来源有:
操作数据或行为的函数方法.
全局外部参数指不受项目代码控制的, 比如登陆用户信息数据. 全局项目自定义变量是由项目代码控制的, 比如定义了一些模型数据, 状态数据.
对组件来说, 可变数据的来源有:
组件被调用时的传参.
全局组件自定义变量.
不可变数据来源有:
组件被调用时的传参.
操作数据或行为的函数方法.
对组件来说, 被调用时的传参既可能是可变数据, 也可能是不可变数据. 比如传入的 props.color 可能就是可变数据, 而 props.defaultValue,props.onChange 就是不可变数据.
当梳理清楚项目与组件到底有哪些全局数据后, 我们就可以按照注册与调用这两步来设计数据流管理规范了.
数据流调用
首先来看调用. 为了同时保证使用的便捷与应用程序的性能, 我们希望使用一个统一的 API useXXX 来访问所有全局数据与方法, 并满足:
{} = useXXX() 只能引用到不可变数据, 包括变量与方法.
{ value } = useXXX(state => ({ value: state.value }))
可以引用到可变数据, 但必须通过选择器来调用.
比如一个应用叫 gaea, 那么 useGaea 就是对这个应用全局数据的唯一调用入口, 我可以在组件里这么调用数据与方法:
- const Panel = () => {
- // appId 是应用不可变数据, 所以即使是变量也可以直接获取, 因为它不会变化, 也不会导致重渲染
- // fetchData 是取数函数, 内置发送了 appId, 所以绑定了一定上下文, 也属于不可变数据
- const { appId, fetchData } = useGaea()
- // 主题色可能在运行时修改, 只能通过选择器获取
- // 此时这个组件会额外在 color 变化时重渲染
- const { color } = useGaea(state => ({
- color: state.theme?.color
- }))
- }
比如一个组件叫 Menu, 那么 useMenu 就是这个组件的全局数据调用入口, 可以这么使用:
- // 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 层, 而内部代码完全不用修改.
数据流注册
数据流注册的时候, 我们只要定义三种参数:
dynamicValue: 动态参数, 通过
useInput(state => state.xxx)
才能访问到.
staticValue: 静态参数, 引用永远不会改变, 可以直接通过 useInput().xxx 访问到.
自定义 hooks, 入参是 staticValue getState setState, 这里可以封装自定义方法, 并且定义的方法都必须是静态的, 可以直接通过 useInput().xxx 访问到.
- 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, 就可以这么调用:
- function Input({ onChange, fontSize }) {
- return (
- <Provider dynamicValue={{fontSize}} staticValue={{onChange}}>
- <InputComponent />
- </Provider>
- )
- }
如果对于某些动态数据, 我们只想赋初值, 可以使用 defaultDynamicValue:
- 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 是不可变对象, 因此可以通过如下方式访问:
- function InputComponent() {
- const { onChange } = useInput()
- }
二: 访问我们自定义的全局 Hooks 函数 onCustomChange:
- function InputComponent() {
- const { onCustomChange } = useInput()
- }
三: 访问可能变化的数据 fontSize. 由于我们需要在 fontSize 变化时让组件重渲染, 又不想让上面两种调用方式受到 fontSize 的影响, 需要通过如下方式访问:
- function InputComponent() {
- const { fontSize } = useInput(state => ({
- fontSize: state.fontSize
- }))
- }
最后在自定义方法中, 如果我们想修改可变数据, 都要通过 updateStore 封装好并暴露给外部, 而不能直接调用. 具体方式是这样的, 举个例子, 假设我们需要定义一个应用状态 status, 其可选值为 edit 与 preview, 那么可以这么去定义:
- 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])
- })
下面是调用:
- function InputComponent() {
- const { toggleStatus } = useInput()
- return (
- <button onClick={toggleStatus} />
- )
- }
而且整个链路的类型定义也是完全自动推导的, 这套数据流管理方案到这里就讲完了.
总结
对全局数据的使用, 最方便的就是收拢到一个 useXXX API, 并且还能区分静态, 动态值, 并在访问静态值时完全不会导致重渲染.
而之所以动态值 dynamicValue 需要在 Provider 里定义, 是因为当动态值变化时, 会自动更新数据流中的数据, 使整个应用数据与外部动态数据同步. 而这个更新步骤就是通过 Redux Store 来完成的.
本文特意没有给出实现源码, 感兴趣的同学可以自己实现一个试一试.
讨论地址是: 精读《一种 Hooks 数据流管理方案》. Issue #345 . dt-fe/weekly
如果你想参与讨论, 请 点击这里, 每周都有新的主题, 周末或周一发布. 前端精读 - 帮你筛选靠谱的内容.
关注 前端精读微信公众号
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
来源: https://segmentfault.com/a/1190000040519386