当前期刊数: 146
1 引言
React Hooks 渐渐被国内前端团队所接受,但基于 Hooks 的数据流方案却还未固定,我们有 “100 种” 类似的选择,却各有利弊,让人难以取舍。
本周笔者就深入谈一谈对 Hooks 数据流的理解,相信读完文章后,可以从百花齐放的 Hooks 数据流方案中看到本质。
2 精读
基于 React Hooks 谈数据流,我们先从最不容易产生分歧的基础方案说起。
单组件数据流
单组件最简单的数据流一定是 useState
:
1 2 3
| function App() { const [count, setCount] = useState(); }
|
useState
在组件内用是毫无争议的,那么下个话题就一定是跨组件共享数据流了。
组件间共享数据流
跨组件最简单的方案就是 useContext
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const CountContext = createContext();
function App() { const [count, setCount] = useState(); return ( <CountContext.Provider value={{ count, setCount }}> <Child /> </CountContext.Provider> ); }
function Child() { const { count } = useContext(CountContext); }
|
用法都是官方 API,显然也是毫无争议的,但问题是数据与 UI 不解耦,这个问题 unstated-next 已经为你想好解决方案了。
数据流与组件解耦
unstated-next 可以帮你把上面例子中,定义在 App
中的数据单独出来,形成一个自定义数据管理 Hook:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { createContainer } from "unstated-next";
function useCounter() { const [count, setCount] = useState(); return { count, setCount }; }
const Counter = createContainer(useCounter);
function App() { return ( <Counter.Provider> <Child /> </Counter.Provider> ); }
function Child() { const { count } = Counter.useContainer(); }
|
数据与 App
就解耦了,这下 Counter
再也不和 App
绑定了,Counter
可以和其他组件绑定作用了。
这个时候性能问题就慢慢浮出了水面,首当其冲的就是 useState
无法合并更新的问题,我们自然想到利用 useReducer
解决。
合并更新
useReducer
可以让数据合并更新,这也是 React 官方 API,毫无争议:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import { createContainer } from "unstated-next";
function useCounter() { const [state, dispath] = useReducer( (state, action) => { switch (action.type) { case "setCount": return { ...state, count: action.setCount(state.count), }; case "setFoo": return { ...state, foo: action.setFoo(state.foo), }; default: return state; } return state; }, { count: 0, foo: 0 } );
return { ...state, dispatch }; }
const Counter = createContainer(useCounter);
function App() { return ( <Counter.Provider> <Child /> </Counter.Provider> ); }
function Child() { const { count } = Counter.useContainer(); }
|
这下即便要同时更新 count
和 foo
,我们也能通过抽象成一个 reducer
的方式合并更新。
然而还有性能问题:
1 2 3 4 5 6 7
| function ChildCount() { const { count } = Counter.useContainer(); }
function ChildFoo() { const { foo } = Counter.useContainer(); }
|
更新 foo
时,ChildCount
和 ChildFoo
同时会执行,但 ChildCount
没用到 foo
呀?这个原因是 Counter.useContainer
提供的数据流是一个引用整体,其子节点 foo
引用变化后会导致整个 Hook 重新执行,继而所有引用它的组件也会重新渲染。
此时我们发现可以利用 Redux useSelector
实现按需更新。
按需更新
首先我们利用 Redux 对数据流做一次改造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { createStore } from "redux"; import { Provider, useSelector } from "react-redux";
function reducer(state, action) { switch (action.type) { case "setCount": return { ...state, count: action.setCount(state.count), }; case "setFoo": return { ...state, foo: action.setFoo(state.foo), }; default: return state; } return state; }
function App() { return ( <Provider store={store}> <Child /> </Provider> ); }
function Child() { const { count } = useSelector( (state) => ({ count: state.count }), shallowEqual ); }
|
useSelector
可以让 Child
在 count
变化时才更新,而 foo
变化时不更新,这已经接近较为理想的性能目标了。
但 useSelector
的作用仅仅是计算结果不变化时阻止组件刷新,但并不能保证返回结果的引用不变化。
防止数据引用频繁变化
对于上面的场景,拿到 count
的引用是不变的,但对于其他场景就不一定了。
举个例子:
1 2 3 4 5
| function Child() { const user = useSelector((state) => ({ user: state.user }), shallowEqual);
return <UserPage user={user} />; }
|
假设 user
对象在每次数据流更新引用都会发生变化,那么 shallowEqual
自然是不起作用,那我们换成 deepEqual
深对比呢?结果是引用依然会变,只是重渲染不那么频繁了:
1 2 3 4 5 6 7 8 9 10 11
| function Child() { const user = useSelector( (state) => ({ user: state.user }), deepEqual );
return <UserPage user={user} />; }
|
是不是觉得在 deepEqual
的作用下,没有触发重渲染,user
的引用就不会变呢?答案是会变,因为 user
对象在每次数据流更新都会变,useSelector
在 deepEqual
作用下没有触发重渲染,但因为全局 reducer 隐去组件自己的重渲染依然会重新执行此函数,此时拿到的 user
引用会不断变化。
因此 useSelector
deepEqual
一定要和 useDeepMemo
结合使用,才能保证 user
引用不会频繁改变:
1 2 3 4 5 6 7 8 9 10 11
| function Child() { const user = useSelector( (state) => ({ user: state.user }), deepEqual );
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />; }
|
当然这是比较极端的情况,只要看到 deepEqual
与 useSelector
同时作用了,就要问问自己其返回的值的引用会不会发生意外变化。
缓存查询函数
对于极限场景,即便控制了重渲染次数与返回结果的引用最大程度不变,还是可能存在性能问题,这最后一块性能问题就处在查询函数上。
上面的例子中,查询函数比较简单,但如果查询函数非常复杂就不一样了:
1 2 3 4 5 6 7 8 9 10 11
| function Child() { const user = useSelector( (state) => ({ user: verySlowFunction(state.user) }), deepEqual );
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />; }
|
我们假设 verySlowFunction
要遍历画布中 1000 个组件的 n 3 次方次,那组件的重渲染时间消耗与查询时间相比完全不值一提,我们需要考虑缓存查询函数。
一种方式是利用 reselect 根据参数引用进行缓存。
想象一下,如果 state.user
的引用不频繁变化,但 verySlowFunction
非常慢,理想情况是 state.user
引用变化后才重新执行 verySlowFunction
,但上面的例子中,useSelector
并不知道还能这么优化,只能傻傻的每次渲染重复执行 verySlowFunction
,哪怕 state.user
没有变。
此时我们要告诉引用,state.user
是否变化才是重新执行的关键:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { createSelector } from "reselect";
const userSelector = createSelector( (state) => state.user, (user) => verySlowFunction(user) );
function Child() { const user = useSelector( (state) => userSelector(state), deepEqual );
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />; }
|
在上面的例子中,通过 createSelector
创建的 userSelector
会一层层进行缓存,当第一个参数返回的 state.user
引用不变时,会直接返回上一次执行结果,直到其应用变化了才会继续往下执行。
这也说明了函数式保持幂等的重要性,如果 verySlowFunction
不是严格幂等的,这种缓存也无法实施。
看上去很美好,然而实战中你可能发现没有那么美好,因为上面的例子都建立在 Selector 完全不依赖外部变量。
结合外部变量的缓存查询
如果我们要查询的用户来自于不同地区,需要传递 areaId
加以识别,那么可以拆分为两个 Selector 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { createSelector } from "reselect";
const areaSelector = (state, props) => state.areas[props.areaId].user;
const userSelector = createSelector(areaSelector, (user) => verySlowFunction(user) );
function Child() { const user = useSelector( (state) => userSelector(state, { areaId: 1 }), deepEqual );
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />; }
|
所以为了不在组件函数内调用 createSelector
,我们需要尽可能将用到外部变量的地方抽象成一个通用 Selector,并作为 createSelector
的一个先手环节。
但 userSelector
提供给多个组件使用时缓存会失效,原因是我们只创建了一个 Selector 实例,因此这个函数还需要再包装一层高阶形态:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { createSelector } from "reselect";
const userSelector = () => createSelector(areaSelector, (user) => verySlowFunction(user));
function Child() { const customSelector = useMemo(userSelector, []);
const user = useSelector( (state) => customSelector(state, { areaId: 1 }), deepEqual ); }
|
所以对于外部变量结合的环节,还需要 useMemo
与 useSelector
结合使用,useMemo
处理外部变量依赖的引用缓存,useSelector
处理 Store 相关引用缓存。
3 总结
基于 Hooks 的数据流方案不能算完美,我在写作这篇文章时就感觉到这种方案属于 “浅入深出”,简单场景还容易理解,随着场景逐步复杂,方案也变得越来越复杂。
但这种 Immutable 的数据流管理思路给了开发者非常自由的缓存控制能力,只要透彻理解上述概念,就可以开发出非常 “符合预期” 的数据缓存管理模型,只要精心维护,一切就变得非常有秩序。
讨论地址是:精读《React Hooks 数据流》 · Issue ##242 · dt-fe/weekly
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)