【React】1293- React 18 超全升级指南

共 8947字,需浏览 18分钟

 ·

2022-04-18 21:13

React 18 RC.3 版已经发布,并且 API 已经稳定下来,现在主要是一些 BUG 修复,相信不久后便会发布正式版。React 团队对新特性的探索相当谨慎,距离 16.8 版本已经有 3 年时间了,完全版的并发模式终于到来。今天我们从使用者的角度来探索下 React 17 升级到 18 会遇到的问题和一些新增的功能。

升级

使用 yarn 要安装最新的 React 18 RC

yarn add react@rc react-dom@rc

变更

React 18 已经放弃对 IE 11 的支持,有兼容 IE 的需求则使用 React 17

createRoot

React 18 提供了两个根 API,我们称之为 Legacy Root APINew Root API

  • Legacy root API:即 ReactDOM.render。这将创建一个以“遗留”模式运行的 root,其工作方式与 React 17 完全相同。使用此 API 会有一个警告,表明它已被弃用并切换到 New Root API
  • New Root API:即 createRoot。这将创建一个在 React 18 中运行的 root,它添加了 React 18 的所有改进并允许使用并发功能。

我们以 Vite + TS 作为脚手架启动项目。项目启动后你会在控制台中看到一个警告:


也就意味着你可以直接将项目升级到 React 18 版本而不会直接造成 break change。因为它仅仅给予了一个警告,并且在整个 18 版本中都为可用兼容状态,并保持着 React 17 版本的特性。

为什么要这样做呢?因为仅仅为项目升级的话比较干脆利落,遇见一个地方改一个地方,无历史包袱。但是 React 组件生态非常庞大,很多组件会用到 ReactDOM.render 直接渲染,比如常见 UI 库中的 Modal.confirm 类似的 API,这时就需要一个版本的周期让这些生态组件升级上来。

// React 17
import ReactDOM from 'react-dom';
const container = document.getElementById('app');
// 装载
ReactDOM.render(<App tab="home" />, container);
// 卸载
ReactDOM.unmountComponentAtNode(container);

// React 18
import { createRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = createRoot(container);
// 装载
root.render(<App tab="home" />);
// 卸载
root.unmount();

还不得不说 createRoot API 和 Vue3createApp 形式一模一样。

FAQ: 在 TypeScriptcreateRoot 中参数 container 可接收 HTMLElement ,但不能为空。使用要么断言,要么加判断吧~

服务端渲染

hydrateRoot

如果的应用使用带注水的服务端渲染,请升级 hydratehydrateRoot

const root = hydrateRoot(container, "home" />);
// 这里无需执行 root.render

在此版本中,也改进了 react-dom/serverAPI 以完全支持服务器上的 Suspense 和流式 SSR。作为这些更改的一部分,将弃用旧的 Node 流式 API,它不支持服务器上的增量 Suspense 流式传输。

  • renderToNodeStream => renderToPipeableStream
  • 新增 renderToReadableStream 以支持 Deno
  • 继续使用 renderToString (对 Suspense 支持有限)
  • 继续使用 renderToStaticMarkup (对 Suspense 支持有限)

setState 同步/异步

这是 React 此次版本中最大的破坏性更新,并且无法向下兼容

React 中的批处理简单来说就是将多个状态更新合并为一次重新渲染,以获得更好的性能,在 React 18 之前,React 只能在组件的生命周期函数或者合成事件函数中进行批处理。默认情况下,PromisesetTimeout 以及原生事件中是不会对其进行批处理的。如果需要保持批处理,则可以用 unstable_batchedUpdates 来实现,但它不是一个正式的 API。

React 18 之前:

function handleClick() {
setCount(1);
setFlag(true);
// 批处理:会合并为一次 render
}

async function handleClick() {
await setCount(2);
setFlag(false);
// 同步模式:会执行两次 render
// 并且在 setCount 后,在 setFlag 之前能通过 Ref 获取到最新的 count 值
}

值得注意 React 18 上面的第二个例子依然有两次 render,此外,异步后同时的更新都将自动批处理。这样无疑是很好的提高了应用的整体性能。

flushSync

如果我想在 React 18 退出批处理该怎么做呢?官方提供了一个 API flushSync

flushSync(fn: () => R): R 它接收一个函数作为参数,并且允许有返回值。

function handleClick() {
flushSync(() => {
setCount(3);
});
// 会在 setCount 并 render 之后再执行 setFlag
setFlag(true);
}

注意:flushSync 会以函数为作用域,函数内部的多个 setState 仍然为批量更新,这样可以精准控制哪些不需要的批量更新:

function handleClick() {
flushSync(() => {
setCount(3);
setFlag(true);
});
// setCount 和 setFlag 为批量更新,结束后
setLoading(false);
// 此方法会触发两次 render
}

这种方式会比 React 17 及以前的方式更优雅的颗粒度控制 rerender

flushSync 再某些场景中非常有用,比如在点击一个表单中点击保存按钮,并触发子表单关闭,并同步到全局 state,状态更新后再调用保存方法:

子表单:

export default function ChildForm({ storeTo }) {
const [form] = Form.useForm();

// 当前组件卸载时将子表单的值同步到全局
// 若要触发父组件同步 setState,必须使用 useLayoutEffect
useLayoutEffect(() => {
return () => {
storeTo(form.getFieldsValue());
};
}, []);

return (
<Form form={form}>
<Form.Item name="email">
<Input />
Form.Item>

Form>
);
}

外部容器:

  onClick={() => {
// 触发子表单卸载关闭
flushSync(() => setVisible(false));
// 子表单值更新到全局后,触发保存方法,可以保证 onSave 获取到最新填写的表单值
onSave();
}}
>
保存
</div>
{visible && >}</div>

不过 unstable_batchedUpdatesReact 18 中将继续保留整个版本,因为许多开源库用了它。

已卸载组件更新状态警告

我们在正常开发时难免会出现以下错误:


这个警告被广泛误解并且有些误导。原本旨在针对如下场景:

useEffect(() => {
function handleChange() {
setState(store.getState());
}
store.subscribe(handleChange);
return () => store.unsubscribe(handleChange);
}, []);

如果您忘记了 unsubscribe 效果清理中的调用,则会发生内存泄漏。在实践中,上述情况并不常见。这在我们的代码中更为常见:

async function handleSubmit() {
setLoading(true);
// 在我们等待时组件可能会卸载
await post('/some-api');
setLoading(false);
}

在这里,警告也会触发。但是,在这种情况下,警告具有误导性

这里没有实际的内存泄漏,Promise 会很快 resolve,之后它可以被垃圾回收。为了抑制这个警告,我们可能会写很多 isMounted 无用的判断,会使代码变得更加复杂。

React 18 中这个警告已经被移除掉了。

组件返回 null

React 17 中,如果组件在 render 中返回了 undefinedReact 会在运行时抛出一个错误:

function Demo() {
return undefined;
}



这里我们可以把 undefined 换成 null,程序将继续运行。此行为的目的是帮助用户发现意外忘记 return 语句的常见问题。对于 React 18Suspense fallback 会出现 undefined 而不报错从而导致出现不一致。

现在类型系统和 Eslint 都非常健壮可以很好避免这类低级错误,因此 React 18 不再检查因返回 undefined 而导致崩溃。

StrictMode

从 React 17 开始,React 会自动修改控制台方法,例如 console.log() 在第二次调用生命周期函数时使日志静音。但是,在某些可以使用变通方法的情况下,它可能会导致不良行为。

这这种行为在 React 18 中已经移除,如果安装了 React DevTools > 4.18.0,那么第二次渲染期间的日志现在将以柔和的颜色显示在控制台中。

新 API

useSyncExternalStore

useSyncExternalStore 经历了一次修改,由 unstable_useMutableSource 改变而来,用于订阅外部数据源。主要帮助有外部 store 需求的开发者解决撕裂问题。

一个监听 innerWidth 变化的 hook 最简单例子:

import { useMemo, useSyncExternalStore } from'react';

function useInnerWidth(): number {
 // 保持 subscribe 固定引用,避免 resize 监听器重复执行
 const [subscribe, getSnapshot] = useMemo(() => {
   return [
     (notify: () =>void) => {
       // 真实情况这里会用到节流
       window.addEventListener('resize', notify);
       return() => {
         window.removeEventListener('resize', notify);
       };
     },
     // 返回 resize 后需要的快照
     () => window.innerWidth,
   ];
 }, []);
 return useSyncExternalStore(subscribe, getSnapshot);
}
function WindowInnerWidthExample() {
 const width = useInnerWidth();

 return<p>宽度: {width}p>;
}

Demo 地址:https://codesandbox.io/s/usesyncexternalstore-demo-q47kyn

React 自身 state 已经原生的解决的并发特性下的撕裂(tear) 问题。useSyncExternalStore 主要对于框架开发者,比如 redux,它在控制状态时可能并非直接使用的 Reactstate,而是自己在外部维护了一个 store 对象,脱离了 React 的管理,也就无法依靠 React 自动解决撕裂问题。因此 React 对外提供了这样一个 API。

目前 React-Redux 8.0 已经基于 useSyncExternalStore 实现。

useInsertionEffect

useInsertionEffect 的工作原理大致 useLayoutEffect 相同,只是此时无法访问 DOM 节点的引用。

因此推荐的解决方案是使用这个 Hook 来插入样式表(或者如果你需要删除它们,可以引用它们):

function useCSS(rule) {
useInsertionEffect(() => {
if (!isInserted.has(rule)) {
isInserted.add(rule);
document.head.appendChild(getStyleForRule(rule));
}
});
return rule;
}
function Component() {
let className = useCSS(rule);
return <div className={className} />;
}

useId

useId 是一个 API,用于在客户端和服务器上生成唯一 ID,同时避免水合不匹配。使用示例:

function Checkbox() {
const id = useId();
return (
<div>
<label htmlFor={id}>选择框label>

<input type="checkbox" name="sex" id={id} />
div>
);
}

Concurrent(并发) 模式

Concurrent 模式是一组 React 的新功能,可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整,该模式通过使渲染可中断来修复阻塞渲染限制。在 Concurrent 模式中,React 可以同时更新多个状态。

通常,当我们更新 state 的时候,我们会期望这些变化立刻反映到屏幕上。我们期望应用能够持续响应用户的输入,这是符合常理的。但是,有时我们会期望更新延迟响应在屏幕上。在 React 中实现这个功能在之前是很难做到的。Concurrent 模式提供了一系列的新工具使之成为可能。

Transition

React 18 中,引入的一个新的 API startTransition,主要为了能在大量的任务下也能保持 UI 响应。这个新的 API 可以通过将特定更新标记为“过渡”来显着改善用户交互。

概览:

import { startTransition } from 'react';

// 紧急:显示输入的内容
setInputValue(input);

// 标记回调函数内的更新为非紧急更新
startTransition(() => {
setSearchQuery(input);
});

简单来说,就是被 startTransition 回调包裹的 setState 触发的渲染 被标记为不紧急渲染,这些渲染可能被其他紧急渲染所抢占。

一般情况下,我们需要通知用户后台正在工作。为此提供了一个带有 isPending 转换标志的 useTransitionReact 将在状态转换期间提供视觉反馈,并在转换发生时保持浏览器响应。

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();

isPending 值在转换挂起时为 true,这时可以在页面中放置一个加载器。

普通情况下:


使用 useTransition 表现:


Demo 地址:https://codesandbox.io/s/starttransition-demo-o59ld2

我们可以使用 startTransition 包装任何要移至后台的更新,通常,这些类型的更新分为两类:

  1. 渲染缓慢:这些更新需要时间,因为 React 需要执行大量工作才能转换 UI 以显示结果
  2. 网络慢:这些更新需要时间,因为 React 正在等待来自网络的一些数据。这个方式与 Suspense 紧密集成

网络慢场景:一个列表页,当我们点击 “下一页”,现存的列表立刻消失了,然后我们看到整个页面只有一个加载提示。可以说这是一个“不受欢迎”的加载状态。如果我们可以“跳过”这个过程,并且等到内容加载后再过渡到新的页面,效果会更好

这里我们结合 Suspense 做加载边界处理:

import React, { useState, useTransition, Suspense } from 'react';
import { fetchMockData, MockItem } from './utils';
import styles from './DemoList.module.less';

const mockResource = fetchMockData(1);

export default function DemoList() {
const [resource, setResource] = useState(mockResource);
const [isPending, startTransition] = useTransition();

return (
<Suspense fallback="加载中">
<UserList resource={resource} />
<button
className={styles.button}
type="button"
onClick={() =>

startTransition(() => {
setResource(fetchMockData(2));
})
}
>
下一页
button>

{isPending && <div className={styles.loading}>加载中div>}
Suspense>
);
}

function UserList({ resource }: UserListProps) {
const mockList = resource.read();
return (
<div className={styles.list}>
{mockList.map((item) => (
<div key={item.id} className={styles.row}>
<div className={styles.col}>{item.id}div>

<div className={styles.col}>{item.name}div>
<div className={styles.col}>{item.age} 岁div>
div>
))}
div>
);
}

结果展示:


Demo 地址:https://codesandbox.io/s/usetransition-request-demo-wgedzw

Transition 融合到应用的设计系统

useTransition 是非常常见的需求。几乎所有可能导致组件挂起的点击或交互操作都需要使用 useTransition,以避免意外隐藏用户正在交互的内容。

这可能会导致组件存在大量重复代码。通常建议把 useTransition 融合到应用的设计系统组件中。例如,我们可以把 useTransition 逻辑抽取到我们自己的

浏览 37
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报