抛出问题
React 目前主要的问题有两个
- 页面在大量 DOM 节点同时更新的情况下,会出现延迟很严重的现象,具体表现为交互/渲染的卡顿现象
- 异步操作(比如 data fetching)需要在组件 mount 之后进行,导致子组件的 fetching 有可能被父组件的 fetching 阻塞(下一篇会细讲)
其中第一个问题会在设备计算能力差(比如 mobile device)的情况下被放大,我们可以把这个问题和 CPU 绑在一起(CPU-bound)
第二个问题会在设备网络情况差(比如 3G slow network)的情况下被放大,我们可以把这个问题和 IO 绑在一起(IO-bound)
今天我们主要来讲下,React concurrent mode 是如何解决第一个问题的,第二个问题会在后面的代码实战文章再仔细讲下。
分析问题
首先,这个问题并不是一个 performance 问题,而是一个调度 (scheduling) 问题,所以提高处理速度并不能完全解决这个问题,就像提高网速并不能解决 HTTP/1.1 协议的线头阻塞 (Head-of-line blocking) 问 题。
这个问题的本质是,浏览器的 main thread 是单线程的,短时间大量 CPU consuming 的 task 被加到了 call stack,导致 event loop 在好几个周期都没有空闲去处理 queue 里面的用户交互 (click, scroll, etc.),从而用户感知到了卡顿。
这两个问题在目前能不能解决呢?实际上是可以的,但是解决的方式不够优雅,或者说,没有完全解决。比如第一个问题在 Table 搜索的时候很常见,如果 Table 数据特别多,搜索的时候的卡顿是一定的。解决方法也很简单,使用 debounce 函数在用户连续输入的时候,不进行操作,等用户输入暂停再搜索。这种 workaround 相当于解决了一半的问题,用户能接受最好,不能接受,其实也没有别的更好的方法了。
解决问题 - React Fiber
解决这个问题的原理其实很简单,HTTP/2 采用分帧 (Frame) 的方式解决的 HTTP/1.1 的线头阻塞问题,我们也可以将大量的更新任务 (Batch update task) 拆分成一个个小的 task,从而让 event loop 有机会处理用户交互的 callback。
React Fiber 就是 React Team 一直在持续开发用来解决上面问题的新 algorithm 或者叫 reconciler。
Fiber 的原理挺复杂的,简单说就是原来需要一口气放到 call stack 里面执行的一堆任务,被放到了 heap 里面由一个 scheduler 来调度执行。
其实 React Team 应该在一开始就意识到这个问题了,所以 setState() 被设计成了异步的,但就像官方文档里说的,他们并没有完全利用 asynchronous update (not taking advantage of this) 。仅仅异步是不够的,blocking 还是会 blocking,只有 scheduling the update 才能实现可中断渲染 (interruptible rendering)
React Concurrent Mode
Concurrent Mode 指的就是 React 利用上面 Fiber 带来的新特性的开启的新模式 (mode)。目前 React 实验版本允许用户选择三种 mode
- Legacy Mode: 就相当于目前稳定版的模式
- Blocking Mode: 应该是以后会代替 Legacy Mode 而长期存在的模式
- Concurrent Mode: 以后会变成 default 的模式
Concurrent Mode 其实开启了一堆新特性,其中有两个最重要的特性可以用来解决我们开头提到的两个问题
- Suspense
- useTrasition
其中 Suspense 可以用来解决请求阻塞的问题,UI 卡顿的问题其实开启 concurrent mode 就已经解决的,但如何利用 concurrent mode 来实现更友好的交互还是需要对代码做一番改动的,后面的文章会用实际代码的方式讲下这两个的使用,这里给一个大数据下的不同 mode 下的页面更新速度的对照 demo
需要注意
上面 Fiber 的 update 机制,有个细节问题就是,既然现在的更新是可以被打断的,那如果打断的操作影响了更新怎么办?比如说有个 background-color 我原来要变成绿色,但是用户点击了一个按钮,background-color 要变成红色,那最终结果会怎样?期望肯定是红色,不然就是 bug 了。因此 React 这里做了处理,更新到一半的 work 是可以被丢弃而重新开始的。
这样的做法没错,但是就带来一个问题,有些生命周期函数可能会被多次调用。
我们可以将所有的生命周期函数分成两类,分别运行在 Render 阶段和 Commit 阶段。
-
Render phase 简单说就是决定究竟渲染什么的阶段,涉及绝大多数生命周期。
-
Commit phase 简单说就是将需要渲染的内容推到 DOM (如果是 DOM 环境) 的阶段。
componentDidMount(), componentDidUpdate(), componentWillUnmount() 这三个生命周期函数 (lifecycle method) 运行在 commit 阶段,其余的生命周期函数都可以简单理解为运行在 render 阶段。
我们上面说更新到一半的 work 有可能被重新启动,这就意味着,像是 render,shouldComponentUpdate 等等这些在 render 阶段的生命周期函数并不是像你期望的那样只执行一个,而是有可能被执行多次。
解决方式有两种:
- 把在这些生命周期执行的逻辑放到 commit phase 执行,如果能完全做到,这里可以直接将组件改成 React hook 的形式。
- 保证 render phase 执行的逻辑是幂等的 (idempotent),不理解的可以查下,这个很简单不多讲
总结
React Concurrent Mode 通过启用 Fiber reconciler 实现了可中断渲染 (interruptible render),从而让一系列的交互优化变成了可能。下篇文章会使用实际代码来讲解究竟如何正确使用 Concurrent Mode API