维护大型项目 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 来完成的。
本文特意没有给出实现源码,感兴趣的同学可以自己实现一个试一试。
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)