【转载】React 18:新玩具、新陷阱以及新可能性

作者 | Prithwish Nath
译者 | 马可薇
策划 | 张卫滨

坦白地说,我最近也没怎么用过 React,只用过 Vanilla React(我在另一篇文章里总结过版本13的复杂性),以及Astro + Preact的组合工具。别误会,React 依旧很赞,但多数情况下,你大概会觉得 React 可行性在很大程度上会取决于你愿意投入多少时间学习它的怪癖,以及你愿意写多少代码来对抗黑客。

但 React 18(在我写这篇文章时是 18.2.0)为弥补这一差距迈出了巨大一步,提供了许多开箱即用的新功能,如并发渲染、过渡(Transitions)和悬停(Suspense),以及一些锦上添花的变化。

那么代价是什么呢?更多“神奇”的抽象。并不是所有人都吃这一套,但就结果而言,我们或许可以考虑在下一个项目中跳过“功能齐全”框架,并用 React 18 取而代之,让 react-query 成为我们数据获取或缓存的解决方案。

那究竟是什么说服了我呢?容我慢慢道来。

并发渲染

突击问答:JavaScript 是单线程的吗?

JavaScript 本身是单线程的,初始代码不会等 DOM 树完成立刻执行,但其他基于浏览器 Web 接口的,如 Ajax 请求、渲染、事件触发等却不是单线程。React 的开发者或许已经对这种独立地从不同组件中获取数据并遭遇竞赛条件的情况驾轻就熟了。

要想应对这种情况,我们需要求助并发。并发让 React 具备并行性,且有能力在响应性方面与本地设备 UI 相匹配。

怎么做到这一点?要回答这个问题,让我们先看看 React 幕后的工作原理。

React 的核心设计是维护一个虚拟或影子 DOM,渲染 DOM 树的副本,其中每一个独立的节点都代表一个 React 元素。在对 UI 做更新后,React 都会递归更新两个树之间的差异,并将累计的变更传递到渲染通道。

在 React 16 中引入了一套新算法来完成这段流程,也就是React Fiber,取代了原先基于堆栈的算法。所有 React 元素或者说是组件都是一个 Fiber,每个 Fiber 的子和兄弟都可以延迟渲染,React 通过对这些 Fiber 的延迟渲染实现数量级更高、效果更好的 UI 响应。具体观感对比可见这里

React 17 以此为基础构建,而 React 18 则为这套流程带来了更多可控性。

React 18 所做的是在所有子树被评估之前暂停 DOM 树之后的差异化渲染传递。最终结果?每个渲染通道现在都可以中断。React 可以有选择地在后台更新部分 UI,暂停、恢复或者放弃正在进行的渲染过程,同时保证 UI 不会崩溃,不会掉帧,或帧数时间一致(如,60 FPS 的 UI 应该需要 16.67 毫秒来渲染每一帧)。

💡 随着 React 18 加入 React Native,移动设备的游戏规则将彻底改变。

React 18 功能背后的核心概念是并发渲染,其中包括悬念、流式 HTML、过渡 API,等等。每次这些新功能都是并发式的,用户不用具体了解其背后的机制原理。

悬停

悬停(Suspense)最早出现在 React 16.6.0 中,但也只能用于动态导入 React.lazy,如:

1
const CustomButton = React.lazy(() => import(‘./components/CustomButton’));

在 React 18 中,悬停有了新的扩展,应用也更加普遍。你是否有遇到过组件树还没有完成数据获取,什么都显示不出来的情况?在能够给出真正的数据之前,指定一个默认的、非阻塞的加载状态展示给用户。

1
2
3
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>

这样能够提升用户体验的方式的原因有:

1. 用户不用等待所有数据获取完毕后才能看到东西;

2. 用户会看到一个加载按钮,动态骨架,或者仅仅是一个

加载中

之类的即时反馈,告诉用户程序正在运行,应用程序并没有崩溃;

3. 用户不用等待所有交互元素或组件完成水合(hydration),就能开始交互。还没加载完?没问题,用户完全可以先点点看,或者里的数据。

与此同时,开发者体验也得到了改善。在构建应用程序或是在使用 Next.js 和 Hydrogen 类似的元框架时,开发者们可以参考 React 新定义的,规范的“加载状态”。另外,如果你已经知道要怎么在 Vanilla JavaScript 中写 try-catch 模块,那你应该如何使用悬停边界。

  1. 悬停会捕捉“悬停状态”的组件,而不是错误。比如在数据、代码缺失之类的情况中,给出“嘿我还没准备好所有东西”的信息。

  2. 抛出的错误会触发最近的 catch 模块,无论其中有多少组件,最邻近的都会捕获其下第一个暂停组件,并展示其回退 UI。

悬停的边界再加上 React 编程模型中的“加载状态”概念,让 UI 的设计更加精细化。不过,当你将其与过渡 API 相结合,以指定组件渲染的“优先级”时,那么这一功能将会更加强大。

过渡 API

我应该还没有提过我最喜欢的 React 自定义 hook?

在多个产品的发行中,这个简单的 hook 都为我带来了非常好的服务体验,我认为它对于我写的任何用户输入组件来说都是无价的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
   /* 只有在用户停止打字的几毫秒延迟后,才会设置变量 */
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
/* 1. 延迟数毫秒后的新防抖值 */
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
/* 2. 如果变量值在延迟的毫秒内有变动,则防抖值保持不变 */
return () => {
clearTimeout(handler);
};
},[value, delay]);

return debouncedValue;
}

功能背后的想法很简单,在用户搜索栏中输入或下拉列表选择过滤器时,你不会想在每次按键输入时都对下拉列表更新(甚至是调用 API 搜索)。这个 hook 可以节流调用或者说“防抖”,确保服务器不会崩溃。

但缺点也很明显,那就是感知滞后。本质上这个功能是引入任意延迟,以 UI 响应性为代价,确保应用程序的内部结构不被破坏。

在 React 18 中,并发性支持一种更直观的方法:接收新状态后可以自如地打断计算及其渲染,以提高响应性和稳定性。

新的过渡 API 支持进一步微调,将状态更新划分为像是前文中 SearchField 例子中的打字、点击、高亮和更新查询文本的紧急状态(Urgent),以及例子中更新实际展示列表的,可以暂缓直到数据准备好的过渡(Transition)更新。过渡是可以随时中断,且不会阻碍用户输入的,让应用程序保持更高的响应速度。

1
2
3
4
5
6
7
8
9
import { startTransition } from 'react';

// UI updates are Urgent
setSearchFieldValue(input);

// State updates are Transitions
startTransition(() => {
setSearchQuery(input);
});

你可能也猜到了,这段代码在悬停边界上效果更好,也避免了明显的 UI 问题:如果你在过渡期间悬停,React 实际只是在展示旧状态和旧数据,而不是用回退内容替代已经在界面上展示的内容。新的渲染将被延迟直到有数据加载完毕。

悬停、过渡以及流式 SSR,并发 React 到底对用户体验和开发者体验有多少改善呢?

服务器组件

这是 React 18 中的又一个重要的新功能,能够让网页构建工作变得更简单,更容易。唯一的问题就是……它仍然不够稳定,只能通过 Next.js 13 等元框架使用。

React 服务器组件(RSC)实际只是在服务器上渲染的组件,而不是客户端。

那又有什么影响呢?很多,这里给出一个太长不看版:

  1. 在使用 RSC 时,完全不会向客户端发送任何 JavaScript。光是考虑这点就很强了,你再也不用担心发送庞大的客户端库(比如 GraphQL 客户端就是个常见的例子),影响产品的程序包大小及首字节时间(Time-to-First-Byte)。

  2. 你可以直接在其中运行数据获取操作,如数据库查询、API、微服务交互等,随后直接通过 props 将结果数据返回给客户端组件(如传统 React 组件)。这些查询的速度会是倍数级增长,因为通常来说服务器都会比客户端快上非常多,客户端与服务器之间的通信一般也只用于 UI,而不是数据。

  3. RSC 和悬停相辅相成。我们可以在服务器上获取数据,并将渲染好的 UI 单元流式递增地传递到客户侧。同时,RSC 也不会在重新加载或获取时丢失客户端的状态,确保用户体验和开发者体验的一致性。

  4. 你不能像是用 useState/useEffect 一样用 hook,就像不能像 onClick()一样用事件监听器,访问画布或剪贴板的浏览器 API,或者像 CSS-in-JS 的引擎一样用 emotion 或 styled-components。

  5. 你可以在服务器和客户端之间共享代码,从而更容易确保类型安全。

现在,网页开发变得更加容易,可以混搭服务器和客户端组件,根据是否需要在较小的软件包上运行,或需要更丰富的用户互动性,有选择地在二者之间跳转。帮你构建灵活且多功能混合的应用程序,适应不断变化的技术或业务需求。

自动批处理:看不见的性能优化

React 在幕后的渲染流程就是:一次状态更新=一次新的渲染。你可能不知道的是,React 如何通过将多个状态更新集中到一个渲染通道,以达到优化效果的。当然,既然状态更新=重新渲染,你会想尽量减少这种情况的。

在 React 17 以及更低的版本中,这种情况只会出现在事件监听器中。任何在 React 管理之外的事件处理程序都不会被批处理,当然也包括 Promise.then()里的、await 之后的,以及 setTimeout 之内的东西。因此,你大概会遇到多次意料之外的重新渲染,这是因为其背后的批处理是基于调用堆栈的,而 Promise(或回调)= 首次浏览器事件之外的多个新调用堆栈 = 多次批处理 = 多个渲染过程。

那有什么变化呢?好吧,React 现在变聪明了,会将所有状态更新排序成一个事件循环,以确保尽量减少重新渲染。但这点你并不用去考虑或选择,因为这些在 React 18 中是自动发生的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function App() {
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);

function handleClick() {
fetch('/api').then((response) => {
setData(response.json()); // In React 17 this causes re-render #1
setIsLoading(false); // In React 17 this causes re-render #2
// In React 18 the first and only re-render is triggered here, AFTER the 2 state updates
});
}

return (
<div>
<button onClick={handleClick}> Get Data </button>
<div> {JSON.stringify(data)} </div>
</div>
);
}

对 Async/Await 的原生支持:usehook 介绍

好消息!好消息!React 终于接受了大部分数据操作都是异步的现实,并在 React 18 中新增了对其的原生支持。那对开发者体验来说意味着什么呢?可以分为两部分:

  1. 服务器组件不能也不需要使用 hook,因为它们是无状态的,async/await 可以使用任何 Promise。

  2. 客户端组件却不是异步的,并且不能用 await 来解包 Promise 值。React 18 为此提供了一个全新的 usehook。

这个 usehook(顺带一提,我不是很喜欢这个名字)是唯一可以被条件调用的 React hook,而且是可以在任何地方调用的,即使是在循环之中。以后,React也将包含对Context等其他值的解包支持

那要怎么用 use 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { experimental_use as use, Suspense } from 'react';

const getData = fetch("/api/posts").then((res) => res.json());
const Posts = () => {
const data = use(getData);
return <div> { JSON.stringify(data) } </div>
};

function App() {
return (
<div>
<Suspense fallback={ <Spinner /> }>
<Posts />
</Suspense>
</div>
);
}

是的,非常简单,但也非常容易翻车。举例来说,你可能会遇到这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { experimental_use as use, Suspense } from 'react';

// 哈,你刚刚触发了一个无限加载
const PostsWhoops = () => {
// 因为这个最后总是会回到一个新的引用
const data = use(fetch("/api/posts").then(res) => res.json()));
return <div> { JSON.stringify(data) } </div>
};

// 正确方法
const getData = fetch("/api/posts").then((res) => res.json());
const Posts = () => {
const data = use(getData);
return <div> { JSON.stringify(data) } </div>
};

// ...
}

为什么会这样?

假设一种情况,hook 解包了一个出于各种原因(网络速度或数据错误)还没完成加载的 Promise。那么,这种在悬停边界的使用将被悬停,但由于组件的工作方式和 vanilla JS 中的异步或等待不同,它不会在故障点恢复执行,而是会在问题解决后重新渲染组件,并在下一次渲染中解包 Promise 的真实值,也就是非未定义值。

然而,这也就意味着每次对 Promise 的引用都是全新的引用,这一过程会重复执行,也就是为什么会触发例子中的无限渲染循环。

为避免这种情况,我们应该把 use 和即将发布的Cache API一起使用,用于自动记忆打包好的函数结果。Next.js 13 中实现了自动缓存和清理缓存,甚至可以按路由字段而不是像上面例子中一样按请求实现,以作为新的 API 扩展 fetch。

这就是真相了。React 目前对服务器和客户端的异步代码都有完全的原生支持,确保对其余 JavaScript 的完全兼容。

如何更新?

你可能已经用上 React 18 了!无论是 CRA、Vite 还是 Next.js 通过 npx 的启动模板,都已经在使用 React 18.2.0 了。

但如果你想把 React 17 及以下的版本升级,那还需要注意以下几点。

1. 替换为 createRoot

根管理换成了一个新的 API,且不再支持 ReactDOM.render,取而代之的是 createRoot。随着 createRoot 而来的还有新的并发渲染器,以启动所有新奇的新功能。替换之前的应用不会中断,但会和 React 17 一样运行,无法获得 React 18 的任何优势。

1
2
3
4
5
6
7
8
9
10
// React 17
import { render } from 'react-dom';
const container = document.getElementById('app');
render(<App tab="home" />, container);

// React 18
import { createRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = createRoot(container); // createRoot(container!) if you use TypeScript
root.render(<App tab="home" />);

2. 替换为 hydrateRoot

同样,对于 SSR 来说,ReactDOM.hydrate 也没有了,取而代之的是 hydrateRoot。如果你不想换,那 React 18 会和 React 17 的行为一样:

1
2
3
4
5
6
7
8
9
// React 17
import { hydrate } from 'react-dom';
const container = document.getElementById('app');
hydrate(<App tab="home" />, container);

// React 18
import { hydrateRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = hydrateRoot(container, <App tab="home" />);

3. 没有渲染回调了

如果你的应用程序在用回调(callback)作为渲染函数的第三个参数,并且还想保留的话,就必须用 useEffect 替代,旧方法会破坏悬停。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// React 17
const container = document.getElementById('app');
render(<App tab="home" />, container, () => {
console.log('rendered');
});

// React 18
function AppWithCallbackAfterRender() {
useEffect(() => {
console.log('rendered');
});

return <App tab="home" />
}

const container = document.getElementById('app');
const root = createRoot(container);
root.render(<AppWithCallbackAfterRender />);

4. 严格模式

React 18 中的一大性能提升就在于并发,但它也要求组件能与可复用的状态兼容。为了实现并发,我们需要能够中断正在进行的渲染,同时复用旧的状态以保持 UI 一致性。

为了消除反模式,React 18 的严格模式将通过两次调用功能组件、初始化器以及更新器,模拟效果被多次加载和销毁,具体过程如下:

  • 第一步:安装组件(Layout 影响代码运行,Effect 影响代码运行)

  • 第二步:React 模拟组件隐藏及卸除效果(Layout 影响清理代码运行+Effect 影响清理代码运行)

  • 第三步:React 模拟组件以旧的状态重新安装(返回第一步)

为了展示 React 在保持纯组件理念中与并发相关的代码错误,可以参考这个例子:

1
2
3
setTodos(prevTodos => {
prevTodos.push(createTodo());
});

例子中的函数直接修改了数据状态,因此是一个不纯的函数。在严格模式中,React 会调用两次 Updater 函数,也就是说同一个 Todo 会被添加两次,可以非常明显地看到错误问题。

正确的解决方法是:替换数据,不要直接改变状态。

1
2
3
setTodos(prevTodos => {
return […prevTodos, createTodo()];
});

如果你的组件、初始化器和更新器都是幂等的,那这种仅存在于开发模式,不上生产的双重渲染不会破坏代码。事件处理程序因为不是纯函数,所以不受新严格模式的影响。

5. 关于 TypeScript

如果你在用 TypeScript(强烈推荐),那还需要更新类型定义(@types/react 以及 @types/react-dom)到最新版本。除此之外,新版本还要求明确列出 children 项:

1
2
3
4
interface MyButtonProps {
color: string;
children?: React.ReactNode;
}

6. 不再支持 IE 浏览器

虽然目前代码还在,估计直到 React 19 都不会删,但如果你必须要支持 IE 的话,建议保持 React 17 版本不要升级。

未来的日子

React 18 是向着正确的前进方向迈出的一大步,是预示着更美好的 webdev 生态系统。但如果你对 React 的奇思妙想和抽象不太满意,那你大概是不会喜欢这个包含诸多超赞的新功能,但同时也有更多神奇抽象的版本。

React 18.2.01 的开发者,目前的工作流程应该大致是这样的:

  1. 默认情况下,数据操作、鉴权、以及任何后端代码等组件渲染都是在服务器上进行的。

  2. 在需要互动性时,选择性地添加客户端组件(useState/useEffect/DOM API),流式传输结果。

更快的页面加载速度,更小的 JavaScript 程序包,更短的可交互时间(TTI),全是为了更好的用户体验和开发体验。React 的下一步是什么?以目前来看,我觉得会是自动记忆的编译器,激动人心的时刻即将到来!

原文链接:

https://blog.bitsrc.io/whats-new-in-react-js-v18-new-toys-new-footguns-new-possibilities-baa0bb6ee863