沐光

记录在前端之路的点点滴滴

React 18 简介

前言

2022 年 3 月 29 号,React 18 正式发布了,其实从 React 16 版本开始,React 团队已经开始普及“并发”的概念,在 v18 版本迭代的过程中,也有不少相关的并发特性知识在科普,那么v18 版本到底有哪些知识,这里就给大家介绍一下。

并发特性(Concurrent Features)

最早此概念受启发于 React Native,因为 JavaScript 是单线程的,而本地平台则是运行在多个线程上的,由于为了良好的移动端的用户体验,React Native 就不得不考虑如何实现“并发调度”特性,这么好的特性如果运用在 Web 上,那么性能岂不会大大提升吗,于是 React 团队便开始结合此开始深入研究网络功能,比如“代码拆分”、“数据获取”、“流服务器渲染”、“服务器组件” 等,最终发现这些能力都是可构建的,因此就设计出了最初版的 Suspense API ,如我们最熟悉的 React.lazy + Suspense 实现组件懒加载。

Concurent Mode

⚠️ 注:目前 React 18 的新暂时未在 React Native 内实现(技术上的限制)。

React 18 新功能

React 18 中的许多功能都建立在新的并发渲染器(Concurrent 模式)之上,React 在内部以优先队列和多重缓冲等复杂技术,来创建并行机制,让 React 得以实现新功能提升用户体验。

New Feature

  • 自动批处理能力;
  • 支持 Suspense 的流式服务器端渲染;
  • 新的严格模式(StrictMode);
  • 新的 React Hook API(useIduseTransitionuseDeferredValue);
  • 服务器组件(Server Components,开发中,不会包含在 React 18 稳定版中 => Next.jsHydrogen 已支持)

启用 Concurrent 模式

启用 Concurrent 模式比较简单,只需要改造一下 index.tsx 代码,改造结果如下:

1
2
3
4
5
6
7
8
9
10
import React from 'react';
// Step1: 更改 ReactDom 引用位置
import ReactDOM from 'react-dom/client';
import App from './App';
import 'antd/dist/antd.css';
import './assets/style/index.css';

// Step2: 更改 dom 节点的绑定姿势
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

能力介绍

自动批处理

批处理是 React 将多个状态更新分组到一个重新渲染中以获得更好的性能。如果没有自动批处理,我们只能在 React 事件处理程序中批处理更新。

React 18 版本之前,在合成事件之外的原生事件中(例如 setTimeoutonclickPromise) ,更新状态并不会进行批量处理(合并处理),这意味着在原生事件中多次调用状态更新会造成多次应用的重新渲染,往往我们只需要最后一次即可,在 React 18 版本后优化了这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, { useState } from 'react';

const App = () => {
const [num1, setNum1] = useState(1);
const [num2, setNum2] = useState(1);

const add = () => {
setTimeout(() => {
setNum1(pre => pre + 1);
setNum2(pre => pre + 1);
});
};

console.log('渲染了');

return (
<section className='App'>
<header className='App-header'>react 18</header>
<p>num1 : {num1}</p>
<p>num2 : {num2}</p>
<button onClick={add}>+1</button>
</section>
);
};

export default App;
  • 17 版本(点击 4 次渲染了 8 次)

React 17 Count

  • 18 版本(点击 4 次渲染 4 次)

React 18 Count

内容源自“React 18 之状态批处理”(见底部“参考文章”)

新的严格模式

StrictMode 是一个用来突出显示应用程序中潜在问题的工具。与 Fragment 一样,StrictMode 不会渲染任何可见的 UI。它为其后代元素触发额外的检查和警告。

可以为应用程序的任何部分启用严格模式,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react';

function ExampleApplication() {
return (
<div>
<Header />
<React.StrictMode>
<div>
<ComponentOne />
<ComponentTwo />
</div>
</React.StrictMode>
<Footer />
</div>
);
}

StrictMode 目前有助于:

未来的 React 版本将添加更多额外功能

Suspense 新功能

React 18 中,服务器添加了对 Suspense 的支持,并使用并发渲染特性扩展了它的功能。如果组件树的一部分尚未准备好显示,Suspense 允许您以声明方式指定其加载状态,流程可如下所示:

Before Ready

Ready

新的客户端和服务器渲染 API

在上述 启用 Concurrent 能力 中已经介绍了客户端渲染写法,需要关注的是新 API 的到处地址均为 react-dom/client

由于暂未尝试服务端能力,详情可参考 React Dom 服务器文档

新 Hook

useId

其作用就是用于在客户端和服务器上生成唯一 ID

在之前的版本中,我们可以使用 React 进行服务端渲染(SSR)。在开发模式上,我们可以在客户端与服务端共享同一个 React 组件。但是,这里就会有一个小问题:如果当前组件已经在服务端渲染过了,但是在客户端我们并没有什么手段知道这个事情,于是客户端还会重新再渲染一次,这样就造成了冗余的渲染。

SSR Workflow

要理解这个背景,我们需要对 SSR 的流程有一个简单的概念。

在服务端,我们会将 React 组件渲染成为一个字符串,这个过程叫做脱水「dehydrate」。字符串以 html 的形式传送给客户端,作为首屏直出的内容。到了客户端之后,React 还需要对该组件重新激活,用于参与新的渲染更新等过程中,这个过程叫做注水「hydrate」,那么这个过程中,同一个组件在服务端和客户端之间就需要有一个相对稳定的 id 来确定对应的匹配关系。

但是,React 在后续的更新中,就开始搞事情,客户端渲染有 reconciler ,服务端渲染有 fizz,他们的作用大概相同,那就是根据某种优先级进行任务调度。于是,无论是客户端还是服务端,都可能不会按照稳定的顺序渲染组件了,递增的计数器方案就无法解决问题,而组件的树状结构即使渲染顺序不同,但整体的优先级顺序结构能保持稳定,可以用这种思路来解决重复渲染问题。

Treenode Style

在同一个组件中,我们需要多个 id,那么一定不要重复的使用 useId,而是基于一个 id 来创建不同的身份标识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用姿势
function NameFields() {
const id = useId();

return (
<div>
<label htmlFor={id + '-firstName'}>First Name</label>
<div>
<input id={id + '-firstName'} type='text' />
</div>
<label htmlFor={id + '-lastName'}>Last Name</label>
<div>
<input id={id + '-lastName'} type='text' />
</div>
</div>
);
}

内容源自 “React 18 新特性之 useId 详细解读”(见底部参考文章)

useTransition

【使用场景】

异步加载数据的过渡状态管控(可以逐步淘汰 const [loading, setLoading] = useState(false),转而用 useTransition 来管控加载状态)

【使用方法】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import React, { useState, useTransition, useCallback } from 'react';
import { Card, Input, Spin } from 'antd';

const UseTransitionDemo = () => {
const [value, setValue] = useState('');
const [searchQuery, setSearchQuery] = useState([]);

// 最迟 3s 会响应一次
const [loading, startTransition] = useTransition({
timeoutMs: 3000,
});

const handleChange = useCallback(
e => {
setValue(e.target.value);

startTransition(() => {
// 用大量数据来换取过渡耗时(渲染耗时)
setSearchQuery(Array(20000).fill(e.target.value));
});
},
[startTransition],
);

return (
<Card title='useTransition Demo'>
<Input
value={value}
placeholder='请输入内容查看变化'
onChange={handleChange}
style={{ marginBottom: '12px' }}
/>

<br />

<Spin spinning={loading}>
{searchQuery.map((item, index) => (
<p key={index}>{item}</p>
))}
</Spin>
</Card>
);
};

export default UseTransitionDemo;

【实际演练】

useTransition Demo

useDeferredValue

【使用场景】

大量数据在页面频繁重绘的情况,当然使用 lodashdebounce 也能实现类似的效果,只不过此 API 不用限制死时间,一旦当前的绘制完毕会自动进入下一次处理。

【使用方法】

⚠️ 注:使用方法比较简单,需要配合 SuspenseuseMemo 缓存(或手动对比判断)来得到中间加载状态,其中 Suspense 主要用于初始化部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { useDeferredValue, useState, Suspense } from 'react';

const UseDeferredValueDemo = () => {
const [value, setValue] = useState();

// 监听值的变化 (timeoutMs: 最多可延迟的时长,当网络和设备允许时,React 始终会尝试使用较短的延迟,此配置貌似最新版本的 React 将其去掉了)
const defferedValue = useDeferredValue(value, { timeoutMs: 1000 });

// 异步内容呈现
const defferedContent = useMemo(() => {
// ...
}, [defferedValue]);

return (
<>
<Suspense fallback='Loading...'>{defferedContent}</Suspense>
</>
);
};

export default UseDeferredValueDemo;

【与 debounce 的区别】

debounce 有一个固定的人为延迟(如 80 个项,每项延迟 3ms),所以它总是会延迟,不论我们的电脑有多快。然而,useDeferredValue 的值只会在渲染耗费时间的情况下 “滞后”,React 并不会加入一点多余的延迟。在一个更实际的工作负荷,你可以预期这个滞后会根据用户的设备而不同。在较快的机器上,滞后会更少或者根本不存在,在较慢的机器上,它会变得更明显。但不论哪种情况,应用都会保持可响应。这就是此机制优于 debouncingthrottling 的地方,它们总是会引入最小延迟而且不可避免的会在渲染的时候阻塞进程。

【实际演练】

useDefferedValue Demo

未来对服务器端的支持

Suspense Feature

原有能力

  • Suspense + React.lazy 实现代码分割,方便打包工具打包;

现有能力

  • 【服务端】流式渲染、唯一 ID(useId)、服务器组件(暂未推出,但 next.js 等部分后端框架已实现);
  • 【客户端】异步加载(useTransitionuseDeferredValue);

未来能力

  • 异步数据获取能力(不用手动处理 Loading,全交给 Suspense);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 现有类似 ahook 的写法(自己处理状态)
export const Child = (id: number) => {
const [isLoading, data] = useFetchDate(id);

if (isLoading) {
return <Spinner />
}

return <>The title is: {data?.title || '-'}</>
}
export Parent = () => {
const [id, setId] = useState(0);

return <>
<Search onSearch={setId} />
<Child id={id} />
</>
}


// 未来写法(由 Suspense 统一接管状态)
export const Child = (id: number) => {
const [data] = useFetchDate(id);

return <>The title is: {data?.title || '-'}</>
}
export Parent = () => {
const [id, setId] = useState(0);

return <>
<Search onSearch={setId} />
<Suspense fallback={<Spinner />}>
<Child id={id} />
</Suspense>
</>
}
  • 服务器组件推广,减少前端重复性组件成本;

参考文章