转载自前端备忘录-江湖术士

React Hooks DDD 性能优化方案

使用 DDD 的方案,只通过 基础 Hooks 组织业务代码,实际上是对开发常识的一种回归

DDD 并不比分层架构架构更难,相比,它比分层架构集成度更高,依赖更少,开发更自然,更符合人的思维习惯

不同于后端,涉及多节点,多服务,涉及统一数据访问结构等,抽象成 DDD 要麻烦很多,前端不涉及这些问题

因此,对于前端来讲,DDD 只是对基础面向对象的一次回归,只是对之前妄图只通过函数式解决应用架构级别问题的拨乱反正,应该是大幅提高生产效率,大幅降低学习成本的方案

废话不多说,我们来看看,就性能优化来说,在 DDD 下应该如何进行

调度

组件渲染过程,我们无法干预,这个是 React 自己的逻辑,这部分没必要纠结

patch 过程对开发者来说完全封闭,无法干预

但是,如同其他所有异步方案,调度是性能优化的核心

我们的注意力总是应该集中在调度上

即 —— 控制 React 何时进入 协调过程,协调过程中是否要进行 patch

何时进入协调?

首先明确一个问题,你需要视图更新,必然会进入协调,这个过程不以人的意志为转移,不可能跳过协调进行视图更新

1
2
3
4
5
6
7
8
9
10
function useTest() {
const [value, setValue] = useState(0);
return { value, setValue };
}
const TestService = useToken(useTest);

const Display = React.memo(function () {
const { value, setValue } = useContext(TestService);
return <div>{value}</div>;
});

如果我修改 useTest 中的 valueDisplay 会更新么?

答案是会更新~

因为 Contextprops 是一个东西,只是一个是单层级组件通讯,一个是跨层级组件通讯而已(从这里可以发现,尽可能使用 Context 也是一个好习惯,props仅仅为了配合组件 map 实现多实例)

很多朋友们说,不对!

你这样的话,修改 Context 会造成额外更新,岂不是会有性能问题?

1
2
3
4
5
6
7
8
9
10
11
function useTest() {
const [value, setValue] = useState(0);
const [anotherValue, setAnotherValue] = useState(0);
useEffect(() => {
setTimeout(() => {
setAnotherValue(3);
}, 3000);
}, []);

return { value, setValue, anotherValue };
}

原因很正常,因为

React.Memo 是高阶组件,并非 hooks api

既然用了 Hooks,要用 Hooks 来做 DDD,那么我们就要彻底抛弃非 hooks 的使用方式:

1
2
3
4
const Display = function () {
const { value, setValue } = useContext(TestService);
return useMemo(() => <div>{value}</div>, [value]);
};

直接这样就可以了,React.Memo 这个 api,在 hooks DDD 下,你用不到~

相信我,在 Hooks 中,你用不到任何高阶组件,比如 React 官方所言:

1
如果你使用过 `React` 一段时间,你也许会熟悉一些解决此类问题的方案,比如`render props`和高阶组件。但是这类方案需要重新组织你的组件结构,这可能会很麻烦,使你的代码难以理解

不用想太多,是啥就是啥,你想要的只渲染一次?那就 useMemo 呗,言下之意就是,我啥数变了,你就给协调,仅此而已

协调真的耗性能么?

当前 React 的渲染方式是怎样的?

React

采用的是强制 requestAnimationFrame 协调

其次,createFiber 调用 FiberNode 类生成新 Node,其中只有部分比较逻辑

最后,compare 并 创建 WorkInTask,也只有比较逻辑

最后,memoizedPropsmemoizedState(以及其他 compare 项目) 无变化,则不会有 updatepatch 操作发生

即便有 patch 操作,也是在 requestAnimationFrame 中则时渲染,并抛弃掉过时的 Fiber 比较节点

总结一下:

  • 协调不一定要 patch

  • patch 也不一定损耗太多性能(虽然这条没有保证)

  • 协调创建 FiberNode 开销不高

    你可以把 useMemo 作为一种性能优化的手段,但不要把它当做一种语义上的保证。未来,React 可能会选择「忘掉」一些之前记住的值并在下一次渲染时重新计算它们,比如为离屏组件释放内存。建议自己编写相关代码以便没有 useMemo 也能正常工作 —— 然后把它加入性能优化。(在某些取值必须从不被重新计算的罕见场景,你可以惰性初始化一个 ref。)

官方原文如此,对于 useMemo,也不应该当成语义上的保证

重新运行服务函数,并不会有太大的开销(几乎忽略不计),除非一种情况:

1
2
3
return <div>{
const a = /* 开销非常大的计算 */
}</div>

注意,useMemo 性能优化在这里,才有最大的作用

但是这个性能劣化不是 hooks 带来的,是你自己的复杂操作带来的

正如 React 所言,这时候你应该考虑的是将其进行惰性初始化,或者甚至直接静态化:

1
2
3
4
5
6
7
8
9
const SomeData = /* expensive data construct*/

function someService(){
return useState(SomeData)
}

function someService(){
return useState(()=> /* expensive data construct*/)
}

Memo 领域

你说,不不不,我还是觉得膈应,为啥?因为我还是想要隔离变更!

好!

既然 Component 将变更约束在组件内

我们采用领域将变更约束在领域内如何?

1
2
3
4
5
6
function SomeModule(props){
const someService = useService()
return useMemo(()=>(<SomeService.Provider value={someService}>
{props.children}
</SomeService.Provider),[props,someService])
}

强制为每一个注入点加入 memo 即可

setState 和 dispatch 是一个东西

React 官方文档中,有个东西比较含糊其辞:

我们已经发现大部分人并不喜欢在组件树的每一层手动传递回调。尽管这种写法更明确,但这给人感觉像错综复杂的管道工程一样麻烦。
在大型的组件树中,我们推荐的替代方案是通过 context 用 useReducer 往下传一个 dispatch 函数

⚠️ 注意,推荐 useReducerReact 官方的喜好问题,因为可以防止你自己写 useMemo,但是正如上文所言,只要你组件内按照正常方式,将大开销数据惰性加载,你没有必要在意这些性能问题(而且 React 喜欢分层 …… )

用过 Typescript 的同学知道,setState 的类型是啥?大声告诉我?Dispatch

setState(res=>{...res,someOther})dispatch('change', someOther),一个意思

作为我个人,推荐的是将函数返回对象作为一个没有 this 的类,只要确保只创建一次,即可达到和 useReducer 同样的效果(甚至 useReducer 也是如此实现的)

好吧,这个问题不在纠结,就像我之前在文章中缩写:

  • 到了服务连接的层级,已经到了顶层领域设计(面向对象)和底层实现(函数式)连接处,这部分你愿意偏向函数式,就偏向函数式,愿意偏向面向对象,就偏向面向对象
  • 所以之前的 react ddd 实践,说的是不禁用 useReducer,但是会禁用全局唯一 useReducerReact 官方也不希望你如此做

把所有 state 都放在同一个 useState 调用中,或是每一个字段都对应一个 useState 调用,这两方式都能跑通。当你在这两个极端之间找到平衡,然后把相关 state 组合到几个独立的 state 变量时,组件就会更加的可读

❓ hook 会因为在渲染时创建函数而变慢吗

不会。在现代浏览器中,闭包和类的原始性能只有在极端场景下才会有明显的差别。
除此之外,可以认为 `Hook` 的设计在某些方面更加高效:
`Hook` 避免了 `class` 需要的额外开支,像是创建类实例和在构造函数中绑定事件处理器的成本。
符合语言习惯的代码在使用 `Hook` 时不需要很深的组件树嵌套。这个现象在使用高阶组件、`render props`、和 `context` 的代码库中非常普遍。组件树小了,`React`的工作量也随之减少。

业务层级的优化

其实大部分时候,框架的基准优化都是次要的,最重要的是业务级别的优化

基本上你在使用 React DDD 构建应用方案时,性能不会更慢,反而会更快

  • 对第三方库依赖更少了
  • 能灵活控制的地方更多了
  • 更加符合自然思维习惯了

所以,你只需要一个 useMemo,不想动脑筋的话,在每个注入点加入 useMemo 就好,想动脑筋的话,好好规划一下自己的高成本数据初始化方案即可

其他地方 ——

React DDD 不需要性能优化

你的逻辑就是如此,如果需要性能优化,优先考虑是否是自己的逻辑出错,或者还未适应管道风格吧

Context 性能答疑

为什么 Hooks 下你不用担心性能?

首先,大家要区分这两个 api :

React.memouseMemo

这是两个 apiReact.memo 也只是比较 props 的浅层,防止深层比较的性能问题而已

这些跟你的 hooks 没有任何关系:

所谓 React.Memo 包裹组件提供性能优化的说法,在良好的 DDD 范式下,没有任何作用:

DDD中,优先使用 Context 传递状态逻辑,保证复用性!

props只起到传递 map keyContext token 的作用

而这些东西,都是浅层的,不会出现深层的情况,因此 React.Memo 毫无作用

其次,useMemo 包裹 返回值,是个好的设计么?

不是!

你当然可以按照官方文档的说法这么写:

但是,看文档要看全套,除了告诉你这个方式可以防止多余渲染,更多的话你也要听:

首先,useMemo 没有语义上的保证(因为这部分优化框架内部还有工作要做)

其次,useMemo 本身就在提供 调度控制

我们来看一个用了 hooks 的函数:

hooks

请问,这里,如果依赖导致函数重新运行,会有性能损耗么?

会有 workInProgress 加入,没问题,除了从运行到加入 workInProgress 外,有其他性能损耗么?

实话告诉你,没有!

这部分性能损耗很小,但是的确应该由框架优化,不过这部分内容不应该是你操心的,这不属于应用开发!

做自己该做的事情!别去做框架该做的事情!

你不应该为了优化这个微小部分的性能损耗,影响你的代码

这部分只涉及 React 一个 FirberNode 的协调过程,是否要重新渲染,还需要涉及 memoized 的比较

变更检测和patch,哪个消耗性能?后者是前者的很多倍!

但是你看看你的组件,memoized 的控制权早就已经移交到了你的手上,你为什么还要担心性能问题呢?

是否要重绘?

由你的 useMemosetState 决定,不是这个函数运行一次,你就一定要重绘的

什么地方性能损耗大?

你写在函数中,没有用 hooks 的那些昂贵初始化操作,会在多次运行函数式消耗性能

惰性初始化是性能优化的唯一考量

你的函数只要保证全用 hooks 写,react 的调度控制权就给到了你,你想怎样就怎样,如果出现重新渲染,那是你的逻辑如此

至于 React 能否跟着你的步调动,或者有什么其他逻辑?

这就不是你该考虑的问题!
一个成熟的框架不会吧框架的性能问题交给用户解决,你不要纠结这些问题,好好写你的业务代码就行了!

控制好调度,身下的交给 React ,相信他就可以了!