根据React 18的介绍,最新的React将带来主要3个方面的新特性:
自动批处理state更新
支持Suspense的新SSR架构
Concurrent features
下面我们将逐一进行简要的介绍和解读。
Automatic batching(批处理)
批处理是指是React能够将多个状态更新合并到单个re-render中一次性更新以获得更好的渲染性能。
比如下面这段:
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1); // 不触发re-render
setFlag(f => !f); // 不触发re-render
// 在调用结束是只触发一次re-render(这就是batching)
}
return <button onClick={handleClick}>Next</button>;
}
但是在React 18以前,对于在异步回调中调用的updates,React Batching将无法生效:
function handleClick() {
fetchSomething().then(() => {
setCount(c => c + 1); // 触发re-render
setFlag(f => !f); // 再次触发re-render
});
}
这是因为在React 18之前,React只在事件处理程序期间批量更新。默认情况下,React不会对promise、setTimeout或任意event事件中的更新进行批处理。
跳过批处理
通常情况下,React 18的批处理是安全的,而且是框架层面自动处理,开发者无需过多关注。但某些情况下可能依赖于在状态更改后立即从 DOM 中读取某些内容。对于这些case,可以使用ReactDOM.flushSync()选择跳过批处理:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React has updated the DOM by now
flushSync(() => {
setFlag(f => !f);
});
// React has updated the DOM by now
}
类组件的breaking change
类组件有个实现上的怪异,它可以同步读取事件内部的状态更新。这意味着可以在setState之间读取到最新的state:
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
console.log(this.state.count); // => 1
this.setState(({ flag }) => ({ flag: !flag }));
});
};
在 React 18,情况不再如此。由于即使在setTimeout中的所有更新都是批处理的,因此React不会同步渲染第一个setState的结果——渲染发生在browser nextTick,所以在setState之间读取state将获取不到上一个setState的结果:
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
console.log(this.state.count); // 0 依然是初始值
this.setState(({ flag }) => ({ flag: !flag }));
});
};
unstable_batchedUpdates
这是一个不太常用的api,通常一些库会使用它来强制对事件之外的setState进行强制批处理:
import { unstable_batchedUpdates } from 'react-dom';
unstable_batchedUpdates(() => {
setCount(c => c + 1);
setFlag(f => !f);
});
React 18将保留unstable_batchedUpdates,但开发者无需再手动调用它了,后续的主版本可能会移除此api。
关于批处理的详细内容,可以参考Automatic batching for fewer renders in React 18
新的Suspense SSR架构
除了Concurrent Mode即将正式随18正式发布之外,SSR功能也将带来全新的架构升级。
传统React SSR
在React 18之前,React SSR架构通常分以下几个环节:
server端,解析server entry并fetch所需数据(通常的做法是在Component上挂载静态方法,server直接调用)。
数据准备好之后,调用ReactDOM/server renderToString api将组件渲染成string形式作为response返回
client等待2中的response text,渲染到页面上,开始加载entry assets。
client等entry js加载完成后,执行entry js,合成(hydrate)最终的页面内容(渲染剩余内容、挂载js事件等)。
// server.js
import { renderToString } from 'react-dom/server';
import App from '@/components/App';
app.get('/', async (req, res) => {
const postId = req.query.postId;
const post = await fetchPost(postId);
const comments = await fetchComments(postId);
const reactStr = renderToString(<App post={post} comments={comments} />);
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>title</title>
</head>
<body>
<div id="root">${reactStr}</div>
<script src="client.js"></script>
</body>
</html>`;
return res.send(html);
});
// client.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from '@/components/App';
ReactDOM.hydrate(
<App />,
document.getElementById('root')
);
在实际项目中可能会做大量的工程和框架上的改进,但核心的原理正如上面的示例代码所示。
传统SSR架构里边,上面列举的几个环节每一个环节都依赖上一个的执行结果,如果某一个环节特别慢会拖慢整体的渲染速度。例如首屏内容依赖server端准备好数据,但如果获取数据超时则client端依然会有很长的白屏时间,依然解决不了体验(性能)问题。
Streaming HTML and Selective Hydration
中文翻译:流式传输HTML + 选择性水合
Suspense解锁了React 18中有两个主要的SSR特性:
在server端流式传输HTML:使用全新的pipeToNodeWritable api来搭建从server端到client的流式渲染通道,可以持续不断地向client输出已经ready的html区块,而不需要像传统SSR那样需要整体ready之后通过renderToString一次性输出。
client端进行选择性水合(hydration):将组件(或者widget)使用组件进行包裹,这样它们就不会阻塞渲染,在组件内容数据ready之前,框架将渲染通过fallback参数指定的内容。
server端代码调整:
// server.js
import { pipeToNodeWritable } from 'react-dom/server';
import App from '@/components/App';
app.get('/', async (req, res) => {
let didError = false;
const data = fetchData();
const { startWriting } = pipeToNodeWritable(
<DataProvider data={data}>
<App />
</DataProvider>,
res,
{
onReadyToStream() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
res.write('<!DOCTYPE html>');
startWriting();
},
onError(x) {
didError = true;
},
}
);
});
Suspense + lazy组合(分区块)渲染:
// App.js
import { Suspense, lazy } from 'react';
const Comments = lazy(() => import('./Comments'));
const Post = lazy(() => import('./Post'));
function App() {
return (
<section className="post">
<Suspense fallback={<Spinner />}>
<Post />
</Suspense>
<section className="comments">
<h2>Comments</h2>
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</section>
</section>
);
}
然后在组件内部读取数据:
// Post.js
function usePostData() {
// DataContext就是上面的DataProvider创建的context
const ctx = useContext(DataContext);
if (ctx !== null) {
// 如果数据还没拿到,会throw promise
ctx.read();
}
return null;
}
export default function Post() {
const post = usePostData();
return post ? (
<>
<h1>{post.title}</h1>
<article>{post.content}</article>
</>
) : null;
}
Comments组件同理。
可以看到,在新的SSR架构下,能够实现页面上的组件单独render,React无需等待所有组件“ready”后再展现给用户,实现了页面的分片渲染。
以上Features使得传统SSR模式的一些瓶颈得到解决:
server端无需等待所有数据(和HTML)都ready再响应客户端,相反地,当应用骨架准备好时你就可以发送给客户端显示,剩余的内容将在它们ready之后流式传输给客户端。
client端来说,不再需要等待所有 JavaScript 加载完毕才能开始渲染。可以结合code spliting与SSR一起使用,在server端的HTML(片段)将被保留(在server),当相关代码加载时,React将对其进行合成。
不再需要等待所有组件都加载完成才运行用户交互。相反,可以依靠Selective Hydration 来确定用户与之交互的组件的优先级。
Streaming HTML(对应pipeToNodeWritable)这个特性其实并不是React首创,事实上熟悉互联网历史的同学可能或多或少接触过BigPipe技术,它最早是facebook先提出来的,原理就是通过server端生成html(或者js片段),通过预先内置的runtime sdk解析下载的片段并执行。不过BigPipe技术没有流行起来是因为它依赖了非前端的一些技术栈,没有和前端框架、工程体系结合起来,而React 18的SSR做到了和开发框架的完美融合,在框架层面实现了片段的生成和装载(hydrate),这使得一些需要动态能力的场景多了一种全新的技术选型(比如投放)。
Selective Hydration(对应hydrateRoot以及)表明hydration过程也是逐步进行的,不会一下子执行完所有js导致页面卡顿。正常情况下,框架会根据node tree的顺序执行hydration,但React会监听用户行为事件(如click),提升被用户点击的区块在hydration的优先级。
总结下,Suspense SSR架构的核心原理:渐进式SSR(Progressive SSR) + 部分非阻塞式水合(Partial non-blocking hydration)。它把一张页面分割成了无数个「插槽」区块,然后server端可以持续不断地将已经ready的区块HTML返回给client,client端根据优先级决定合成哪些区块,从而带来相对完善的用户体验效果。
New root apis
在React 18以前的版本中,root元素被绑定(attached)到了DOM根节点上,它对用户是不可见的:
const container = document.getElementById('app');
// 初始化 render.
ReactDOM.render(<App tab="home" />, container);
// 为了更新app,你不得不保留container的引用
ReactDOM.render(<App tab="profile" />, container);
React 18带来了更加语义化的root api:
// 创建root节点
const root = ReactDOM.createRoot(container);
// 初始化:将组建渲染到root
root.render(<App tab="home" />);
// 更新组件时,无需再关注container的引用了,因为它已经提前绑定在root上
root.render(<App tab="profile" />);
SSR(hydrate)root api:
// 旧的api
ReactDOM.hydrate(<App tab="home" />, container);
// 新的root api
ReactDOM.hydrateRoot(container, <App tab="home" />);
注意,和createRoot不同的是,hydrateRoot的第二个参数就需要传入初始化jsx(而无需调用root.render()),这是因为初始client端渲染是特殊的,需要与server端的节点树匹配。
首次hydration之后如果想更新root,可以调用render:
// You can later update it.
root.render(<App tab="profile" />);
React 18新的root api移除了render的callback,原因是Suspense SSR采用了渐进式SSR以及部分水合,回调的时机可能与用户预期不符,为了避免混淆,React推荐的做法是使用requestIdleCallback、setTimeout或者在root上ref回调:
// 旧的root api,直接通过render参数传入callback
ReactDOM.render(container, <App />, function() {
console.log('rendered').
});
// 新的root api,通过ref传入render callback
const root = ReactDOM.createRoot(container);
root.render(<App callback={() => console.log("renderered")} />);
function App({ callback }) {
// 当div第一次被创建时callback将会被调用
return <div ref={callback}>...</div>;
}
Concurrent new feature: startTransition
React 18以前,Concurrent Mode一直处于实验性阶段,React将正式发布Concurrent功能。Concurrent Mode简单说就是一种非阻塞UI、可中断式的渲染架构。它可以灵活调度渲染任务以避免js长时间执行导致UI进程无法响应。
React将状态更新分为两类:
紧急更新(Urgent updates):反映直接的交互,如输入、点击、按键按下等等。
过渡更新(Transition updates):将UI从一个视图过渡到另一个视图。
输入、点击、按键按下等需要立即响应以符合人类的物理认知,符合人的直觉。但是过渡更新却不同,用户不会期望看到中间的转换过程(只需要结果),因此可能不需要立即更新视图。
在React 18以前的版本以及React 18默认情况下(为了向前兼容),所有的更新都会认为是紧急更新。而startTransition提供api给用户来手动将某些更新标记为非紧急更新,从而避免浪费时间去渲染不必要的内容。
如下面的例子:
// 在界面上显示用户输入
setInputValue(input);
// 在界面上呈现查询结果
setSearchQuery(input);
setInputValue会立即更新用户的输入到界面上,属于需要紧急更新的操作。setSearchQuery是根据用户输入,查询相应的内容,用户可以输入很多次,如果一直查询会可能会导致过多的js计算消耗在查询上面(甚至阻塞输入操作),而一般来说用户的期望值是「等待输入完成之后,查询并显示最终的结果」,因此这里的setSearchQuery可以看成是非紧急更新。
通过startTransition包裹setSearchQuery将其标记为非紧急更新:
setInputValue(input);
// 标记为非紧急更新
startTransition(() => {
React.setSearchQuery(input);
});
和setTimeout的区别
就上面setSearchQuery的例子,使用setTimeout(或者debounce or throttle)也能达到相似的目的,那这个startTransition和setTimeout有啥区别?
一个重要区别是setTimeout是「延迟」执行,startTransition是立即执行的,传递给startTransition的函数是同步运行,但是其内部的所有更新都会标记为非紧急,React将在稍后处理更新时决定如何render这些updates,这意味着将会比setTimeout中的更新更早地被render。
另一个重要区别是用setTimeout包裹的如果是内大面积的更新操作会导致页面阻塞不可交互,直到超时。这时候用户的输入、键盘按下等紧急更新操作将被阻止。而startTransition则不同,由于它所标记的更新都是可中断的,所以不会阻塞UI交互。即使用户输入发生变化,React也不必继续渲染用户不再感兴趣的内容。
最后,因为setTimeout是异步执行,哪怕只是展示一个小小的loading也要编写异步代码。而通过transitions,React可以使用hook来追踪transition的执行状态,根据transition的当前状态来更新loading。
追踪transition的执行状态
useTransition hook:
function App() {
const [isPending, startTransition] = React.useTransition();
startTransition(() => {
// 标记非紧急更新
});
return isPending ? <Spinner /> : <Content />;
}
总结:可以使用startTransition来wrapper想要移动到后台执行的任何更新。通常,这些类型的更新分为两类:
慢渲染(Slow rendering):那些需要耗费大量的时间的UI转换任务。
弱网络(Slow network):网络请求更新也可以标记为非紧急更新。这与Suspense能力密切集成。
这里有个利用startTransition来优化用户体验的例子,几个视频看下来可以说很直观体现出startTransition的效果了。
Refs
Introducing React 18
Automatic batching for fewer renders in React 18
New Suspense SSR Architecture in React 18
Replacing render with createRoot
Upgrading to React 18 on the server
New feature: startTransition
Real world example: adding startTransition for slow renders
Everything New in React 18
推荐阅读
(点击标题可跳转阅读)
Javascript条件逻辑设计重构
Promise知识点自测
你不知道的React Diff
你不知道的GIT神操作
程序中代码坏味道(上)
一文掌握Linux实战技能-系统管理篇
一文掌握Linux实战技能-系统操作篇
vue源码分析(1)- new Vue
一文读懂 React16.0-16.6 新特性(实践 思考)
30分钟学会 snabbdom 源码,实现精简的 Virtual DOM 库
觉得本文对你有帮助?请分享给更多人
关注「React中文社区」加星标,每天进步