React18,不远啦?
在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,这个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
怎么样?欢迎留下你的讨论。