1

精读《一种 Hooks 数据流管理方案》

 2 years ago
source link: https://segmentfault.com/a/1190000040519386
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

精读《一种 Hooks 数据流管理方案》

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

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

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

这时一般有三种方案:

  1. props 透传。
  2. 全局数据流。

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)

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

对项目、组件来说,数据流包含两种数据:

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

对项目来说,可变数据的来源有:

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

不可变数据来源有:

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

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

对组件来说,可变数据的来源有:

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

不可变数据来源有:

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

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

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

数据流调用

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

  1. {} = useXXX() 只能引用到不可变数据,包括变量与方法。
  2. { 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 层,而内部代码完全不用修改。

数据流注册

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

  1. dynamicValue: 动态参数,通过 useInput(state => state.xxx) 才能访问到。
  2. staticValue: 静态参数,引用永远不会改变,可以直接通过 useInput().xxx 访问到。
  3. 自定义 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])
})

上面的方法暴露了 ProvideruseInput 两个对象,我们首先需要在组件里给它传输数据。比如我写的是组件 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,其可选值为 editpreview,那么可以这么去定义:

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">

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK