在React
前不久的一次PR #21488中,核心成员Brian Vaughn对React
内一些API
、以及内部flag
作出调整。
其中最引人注目的改动是:React
入口增加createRoot API
。
业界将这一变化解读为:Concurrent Mode
(后文简称为CM
)将在不久后稳定,并出现在正式版中。
React17
是一个过渡版本,用以稳定CM
。一旦CM
稳定,那v18的进度会大大加快。
可以说从18年到21年,React
团队的主要工作就是围绕CM
展开的,那么:
CM
是什么?CM
能解决React
什么问题?- 为什么经历快4年,跨越16、17两个版本,
CM
还不稳定?
本文将作出解答。
CM是什么
要了解CM
(并发模式)是什么,首先需要知道React
源码的运行流程。
React
大体可以分为两个工作阶段:
render
阶段
在render
阶段会计算一次更新中变化的部分(通过diff算法),因组件的render
函数在该阶段调用而得名。
render
阶段可能是异步的(取决于触发更新的场景)。
commit
阶段
在commit
阶段会将render
阶段计算的需要变化的部分渲染在视图中。对应ReactDOM
来说会执行appendChild
、removeChild
等。
commit
阶段一定是同步调用(这样用户不会看到渲染不完全的UI
)
我们通过ReactDOM.render
创建的应用属于legacy
模式。
在该模式下一次render
阶段对应一次commit
阶段。
如果我们通过ReactDOM.createRoot
(当前稳定版本中还没有此API
)创建的应用属于开篇提到的CM
(concurrent
模式)
在CM
下,更新有了优先级的概念,render
阶段可能被高优先级的更新打断。
所以render
阶段可能会重复多次(被打断后重新开始)。
可能多次render
阶段对应一次commit
阶段。
此外,还有个blocking
模式用于方便开发者慢慢从legacy
模式过渡到CM
。
你可以从特性对比看到不同模式支持的特性:
为什么需要CM?
知道了CM
是什么,那么他有什么用?为什么React
核心团队会耗时3年多(18年开始)来实现他?
这得从React
的设计理念聊起。
我们可以从官网React哲学看到React
的设计理念:
我们认为,React
是用JavaScript
构建快速响应的大型Web
应用程序的首选方式。
其中快速响应是重点。
那么什么影响快速响应呢?React
团队给出的答案:
CPU
的瓶颈和IO
的瓶颈
CPU的瓶颈
考虑如下demo
,我们渲染3000的列表项:
function App() {
const len = 3000;
return (
<ul>
{Array(len).fill(0).map((_, i) => <li>{i}</li>)}
</ul>
);
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);
刚才说过,在legacy
模式下render
阶段不会被打断,则这3000个li
的render
都得在同一个浏览器宏任务中完成。
长时间的计算会阻塞线程,造成页面掉帧,这就是CPU
的瓶颈。
解决的办法就是:启用CM
,将render
阶段变为可中断的,
当浏览器一帧剩余时间不多时将控制权交给浏览器。等下一帧的空余时间再继续组件render
。
IO的瓶颈
除了长时间计算导致的卡顿,网络请求时的loading
状态也会造成页面不可交互,这就是IO
的瓶颈。
IO
瓶颈是客观存在的。
作为前端,能做的只能是尽早请求需要的数据。
但是,通常情况下:代码可维护性与请求效率是相悖的。
什么意思呢,举个例子:
假设我们封装了请求数据的方法useFetch
,通过返回值是否存在区分是否请求到数据。
function App() {
const data = useFetch();
return {data ? <User data={data}/> : null};
}
为了提高代码可维护性,useFetch
与要渲染的组件User
存在于同一个组件App
中。
然而,如果User
组件内还需要进一步请求数据呢(如下profile
数据)?
function User({data}) {
const {id, name} = data?.id || {};
const profile = useFetch(id);
return (
<div>
<p>{name}</p>
{profile ? <Profile data={profile} /> : null}
</div>
)
}
本着代码可维护性原则,useFetch
与要渲染的组件Profile
存在于同一个组件User
中。
但是,这样组织代码,Profile
组件只能等User
render
后再render
。
数据只能像瀑布的水一样,一层一层流下来。
这种低效的请求数据方式被称为waterfall
。
为了提高请求效率,我们可以将“请求Profile
组件所需数据的操作”提到App
组件内,合并在useFetch
中:
function App() {
const data = useFetch();
return {data ? <User data={data}/> : null};
}
但是这样就降低了代码可维护性(Profile
组件离profile
数据太远)。
React
团队从Relay
团队借鉴经验,借助Suspense
特性,提出了Server Components。
就是为了在处理IO
瓶颈时兼顾代码可维护性与请求效率。
这一特性的实现需要CM
中更新有不同优先级。
CM为什么花费这么久?
接下来,我们从源码
、特性
、生态
三个方面,自底向上看看CM
的普及有多么不容易。
源码层面
优先级算法改造
在v16.13之前,React
已经实现了基本的CM
功能。
我们之前聊过,CM
有更新优先级的概念。之前是通过一个毫秒数expirationTime
标记更新的过期时间。
- 通过对比不同更新的
expirationTime
判断优先级高低 - 通过对比更新的
expirationTime
与当前时间判断更新是否过期(过期需要同步执行)
但是,expirationTime
作为一个与时间相关的浮点数,无法表示一批优先级这个概念。
为了实现更上层的Server Components
特性,需要有一批优先级这个概念。
于是,核心成员Andrew Clark开始了旷日持久的优先级算法改造,见:PR lanes
Offscreen支持
在此同时,另一个成员Luna Ruan在开发一个新API
—— Offscreen
。
可以理解这是React
版的Keep-Alive
特性。
订阅外部源
未开启CM
前,在一次更新如下三个生命周期只会调用一次:
componentWillMount
componentWillReceiveProps
componentWillUpdate
但是开启CM
后,由于render
阶段可能被打断、重复,所以他们可能被调用多次。
在订阅外部源(比如注册事件回调)时,可能更新不及时或者内存泄漏。
举个例子:bindEvent
是一个基于发布订阅的外部依赖(比如一个原生DOM
事件):
class App {
componentWillMount() {
bindEvent('eventA', data => {
thie.setState({data});
});
}
componentWillUnmount() {
bindEvent('eventA');
}
render() {
return <Card data={this.state.data}/>;
}
}
在componentWillMount
中绑定,在componentWillUnmount
中解绑。
当接收到事件后,更新data
。
当render
阶段反复中断、暂停后,有可能出现:
事件最终绑定前(bindEvent
执行前),事件源触发了事件
此时App
组件还未注册该事件(bindEvent
还未执行),那么App
获取的data
就是旧的。
为了解决这个潜在问题,核心成员Brian Vaughn开发了特性:create-subscription
用来在React
中规范外部源的订阅与更新。
简单说就是将外部源的注册与更新在commit
阶段与组件的状态更新机制绑定上。
特性层面
当源码层面的支持完备后,基于CM
的新特性开发便提上日程。
这便是Suspense
。
[[Umbrella] Releasing Suspense #13206](https://github.com/facebook/r...PR
负责记录Suspense
特性的进展。
Umbrella
标记代表这个PR
会影响非常多库、组件、工具
可以看到,长长的时间线从18年一直到最近几天。
最初Suspense
只是前端特性,当时React SSR
只能向前端传递字符串数据(也就是俗称的脱水
)
后来React
实现了一套SSR
时的组件流式传输协议,可以流式传输组件,而不仅仅是HTML
字符串。
此时,Suspense
被赋予更多职责。也拥有了更复杂的优先级,这也是刚才讲过的优先级算法改造的一大原因。
最终的成果,就是今年早些时候推出的Server Components
概念。
生态层面
当源码层面支持了、特性也开发完成了,是不是就能无缝接入呢?
还早。
作为一艘行驶了8年的巨轮,React
每次升级到最终社区普及,中间都有巨量的工作要做。
为了帮助社区慢慢过渡到CM
,React
做了如下工作:
- 开发
ScrictMode
特性,并且是默认启用的,规范开发者写法 - 将
componentWillXXX
标记为unsafe
,提醒用户不要使用,未来会废弃 - 提出了新生命周期(
getDerivedStateFromProps
、getSnapshotBeforeUpdate
)替代如上将被废弃的生命周期 - 开发了
legacy
模式与CM
过渡的中间模式 ——blocking
模式
而这,只是过渡过程中最简单的部分。
难的部分是:
社区当前积累的大量基于legacy
模式的库如何迁移?
很多动画库、状态管理库(比如mobX
)的迁移并不简单。
总结
我们介绍了CM
的来龙去脉以及他迁移的难点。
通过这篇文章,想必你也知道了开头那个为React
增加createRoot
(开启CM
的方法)是多么不容易。
好在一切都是值得的,如果说以前React
的壁垒在于:开源时间早、社区规模大。
那么从CM
开始,React
可能会是前端领域最复杂的视图框架。
届时,不会有任何一个React-like
的框架能实现React
同样的feature
。
但是也有人说,CM
带来的这些功能就是鸡肋,我根本不需要。
你觉得CM
怎么样?欢迎留下你的讨论。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。