一、序篇
1、React 18 会给我们带来什么
现 React 18 已经进入 RC 阶段了,大部分新特性已经基本敲定和完成,距离上线已剩下几周时间。
React 18 发版以后,会有以下变化:
⭐️ 1、改进已有的属性,如:自动批处理、改进 Suspense、组件返回 undefined 不再报错等;
⭐️ 2、全面支持 Concurrent Mode,不再是实验版功能,并且带来新的 API,如:startTransition、useDeferredValue 等。
⭐️ 3、为支持以上特性,React 18 不仅加入了多任务处理,还加入了基于优先级的渲染、调度和打断;
⭐️ 4、加入新的渲染模式,“Concurrent Render”模式,这个模式是开发者可选的,同时对开发者来说是大部分不可见的,只是解锁了 React 应用在性能提升方面的一些新特性。
2、尝鲜 React 18
创建应用
npx create-react-app react18
cd react18
npm i react@bate react-dom@bate
npm start
替换 src/index.js 的挂载方式
// 把
ReactDOM.render(<App />, document.getElementById("root"));
// 替换成
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
二、Automatic Batching
1、什么是批处理?
- 批处理是 React 将多个状态更新分组到一个重新渲染中以获得更好的性能。
- 在过往的 React 版本中,如果一个点击事件会触发两个状态的改变,React 总是将他们批量处理到同一个重新渲染当中。
- 这对性能非常有用,因为避免了不必要的渲染,也防止了组件呈现一个仅更新一个状态的“半完成”状态而导致的异常错误。
2、React 18 以前,组件何时批量更新呢?
- React 在批量更新方面并不一致。
- 如果一次点击事件,立即触发两个状态的更新,则 React 会对此进行批量更新。
- 如果一次点击事件,需要获取了数据,再进行两个状态的更新,则 React 并不会批量更新,而是进行两次独立的更新。
- 在 React 18 之前,React 只在事件处理期间进行批量更新,默认情况下,Promise、setTimeout 或者其他事件内部的更新都不会在 React 中批处理。
3、什么是自动批处理?
- React 团队希望减少工作渲染,从而提高应用程序的性能。从 React18 开始,使用 creatRoot,所有更新都将进行批处理,无论来自何处(setTimeout、Promise、addEventListener 或者其他事件内部的更新)。
- React 会确保每个用户发起的事件,DOM 在下一次事件之前完全更新。
4、那如果不想批处理怎么办?
- 可能在实际操作中,某些代码必须要依赖于状态更改后立即从 DOM 中读取某些内容。对于这些情况,可以使用
ReactDOM.flushSync()来选择不进行批处理。 - ⚠️ 注意:是从 ReactDOM 导入,不是 React 。
import { flushSync } from "react-dom";
const func = () => {
flushSync(() => {
setCount((c) => c + 1);
});
flushSync(() => {
setFlag((f) => !f);
});
};
- 更有意思的是,加入了 flushSync 后,React 的批量更新是根据上下文来合并批量更新的。当遇到更新是普通更新,则先收集起来,暂不更新。这时如果遇到了 flushSync ,会将上文收集到的普通更新和这次 flushSync 包含的更新做一次批量更新。
import { flushSync } from "react-dom";
const func = () => {
// 第一次批量更新
setCount((c) => c + 1);
setCount((c) => c + 1);
flushSync(() => {
setCount((c) => c + 1);
});
// 第二次批量更新
setCount((c) => c + 1);
flushSync(() => {
setFlag((f) => !f);
});
// 第三次批量更新
setCount((c) => c + 1);
setCount((c) => c + 1);
};
5、对 hooks 有影响吗?
React 会确保 hooks 都是自动批处理的。
6、对 class 有影响吗?
类组件在 v18 之前有一个特殊情况,可以在事件内部同步读取状态更新
// 初始状态:{count: 0, flag: false}
// class 组件
const handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
console.log(this.state); // {count: 1, flag: false}
this.setState(({ flag }) => ({ flag: !flag }));
});
};
// function 组件
const handleClick = () => {
setTimeout(() => {
setCount((c) => c + 1);
console.log(this.state); // {count: 0, flag: false}
setFlag((f) => !f);
});
};
在 React 18 中,这个情况将不会再出现,所有都是批处理。
// React 18
// 初始状态:{count: 0, flag: false}
// class 组件
const handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
console.log(this.state); // {count: 0, flag: false}
this.setState(({ flag }) => ({ flag: !flag }));
});
};
这也可能成为升级 React 18 的最大障碍,可以使用 ReactDOM.flushSync 强制更新,但是 React 官方强烈提醒慎用。
// React 18
import { flushSync } from "react-dom";
// 初始状态:{count: 0, flag: false}
// class 组件
const handleClick = () => {
setTimeout(() => {
flushSync(() => {
this.setState(({ count }) => ({ count: count + 1 }));
});
console.log(this.state); // {count: 1, flag: false}
flushSync(() => {
this.setState(({ flag }) => ({ flag: !flag }));
});
});
};
三、Concurrent Mode
1、React 三种模式
- legacy 模式:ReactDOM.render(, rootNode),这是当前 React app 使用的方式。
- blocking 模式:ReactDOM.createBlockingRoot(rootNode).render(),迁移到 concurrent 模式的第一个步骤。
- concurrent 模式:ReactDOM.createRoot(rootNode).render(),这个模式开启所有 React 新功能。
2、什么是 Concurrent Mode?
concurrent 模式是一组 React 的新功能,可以帮助应用保持相应,并根据用户的设备性能和网速进行适当调整。
在 concurrent 模式中,React 可以同时更新多个状态:
-
对于 CPU-bound 更新(如创建新的 DOM 节点和运行代码),并发意味一个更加迫切的更新可以中断已经开始的渲染。
-
对于 IO-bound 的更新(如从网络加载数据),并发意味着 React 甚至可以在全部数据到达之前就已经在内存中开始了渲染,跳过令人不愉快的空白加载过程。(这不是双缓冲嘛?听起来就很 amazing 啊~)
React 使用一种启发式方法决定更新的“紧急性”,并且允许你用几行代码对其进行调整,以便于每次交互中实现理想的用户体验。
简单的说就是,concurrent 模式想做到开发者可以自定义更新任务优先级并且能够通知到 React,React 再来处理不用优先级的更新的任务,优先处理优先级高的任务,低优先级的任务可以被中断。
concurrent 模式减少了防抖和节流在 UI 中的需求,渲染可以中断,React 不需要人为的延迟工作以避免卡顿(比如使用 setTimeout)。它可以立即开始渲染,也可以当需要保持应用响应时中断这项工作。
四、useDeferredValue
<>
<PageOne info={info} />
<PageTwo info={info} />
</>
无论何时去看这两个组件,它们反映的数据都来自同个 info ,因为是同个 state 更新传递下去的,它们是同时变化。
但有些时候会出现 UI 不一致的情况,比如:每次输入都要请求接口然后得到结果再渲染等,这样就给到用户很不愉快的感觉了。
针对这个问题,React 官方提供了一个内置的 Hook 来做这个事情。
场景一: 假设 pageOne 的数据获取非常快,而 pageTwo 非常慢,我们不想页面长时间的空白。为了让 pageOne 能够早点显示出来,可以给 pageTwo 设置一个延迟。但这也带出了新的问题就是每次切换,都可能在页面上存在一个旧数据仍留存的情况。
const MainPage = ({ resource }) => {
const deferredResource = useDeferredValue(resource, {
timeoutMs: 1000,
});
return (
<>
<PageOne resource={resource} />
<PageTwo resource={deferredResource} />
</>
);
};
场景二:
useDeferredValue并不仅仅在获取数据的时候有用。它在更新组件树的工作量过大导致交互(例如:在输入框输入内容)卡顿的情况也是有用的
import { useState, useDeferredValue } from 'react
const App = () => {
const [value, setValue] = useState('')
const lazyValue = useDeferredValue(value)
return (
<>
<Input
value={value}
onChange={e => setValue(e.target.value)}
/>
<SlowList value={lazyValue} />
</>
)
}
五、Suspense
在章节三时,讲过“对于 IO-bound 的更新”,接下来将在章节五、六深入解析这个点。
1、什么是 Suspense?
Suspense 让组件可以“等待”某个异步操作,直到该异步操作结束才渲染。
Suspense 不是一个数据请求的库,而是一个机制。这个机制用来给数据请求库(如:fetch 等)向 React 说明某个组件正在读取数据中,当前不可用。React 可以等待到数据返回并更新 UI。
2、Suspense 可以做什么?
- 让数据请求库与 React 紧密结合,在接口请求后就立马开始更新 state(需要这个数据请求库支持 Suspense);
- 有针对性的安排加载状态的展示(可以控制应用视图的加载顺序);
- 能够消除race conditions(相比于await,Suspense 能够更好的减少出错,给予一种同步读取数据的感觉)。
3、传统方式 vs Suspense
到这里,应该思考了一下:Suspense 解决了什么问题、为什么要处理这些问题、传统方式与 Suspense 有何区别?
-
fetch-on-render(渲染后再获取数据,如:在 useEffect 中 fetch):先开始加载组件,渲染完成后在 effects 或者 didMount 生命周期获取数据。
假如我们使用并行的方式获取数据,这可能会出现:文章接口响应比较快,文章组件渲染了,而用户信息还未响应,等到响应后渲染用户信息组件,就可能会引起页面的“瀑布”问题;
假如我们使用串型的方式获取数据,这可能会出现:用户信息接口响应比较慢,等了很久才响应,再请求文章接口,文章接口也响应很慢,这可能“对电脑的健康不太好”。
<UserInfo info={userInfo} /> // 需要先看到用户信息
<ArticleList list={list} /> // 再看到用户的发表的文章
提前抛出一个疑问:能不能并行的获取用户信息和文章,即便文章响应数据到了,也先不响应,等到用户信息数据到了,再一起渲染呢?
- fetch-then-render(接收到所有数据后渲染,如:PLM 系统中的列表页 ready 方式):先获取所有需要的数据,数据准备好了再渲染出新的 UI,但是数据还未准备好前,只能得到一个白屏 loading。
render() {
if (!ready) {
return (
<Spin />
)
}
return (
...
)
}
-
render-as-you-fetch(获取数据后渲染,如:使用了 Suspense):先获取下一屏需要的所有数据,然后立马渲染新的页面。
React 官方:有了 Suspense,在接口刚请求时,其实就已经开始渲染了,在接收到数据后,立刻迭代渲染需要数据的组件,直到所有内容渲染完成。
// 主页面
function PageMain() {
return (
<Suspense fallback={<h1>Loading PageOne...</h1>}>
<PageOne resource={resource} />
<Suspense fallback={<h1>Loading PageTwo...</h1>}>
<PageTwo resource={resource} />
</Suspense>
</Suspense>
);
}
// 页面1
function PageOne({ resource }) {
// 尝试读取页面1,尽管信息可能未加载完毕
const pageOne = resource.pageOne.read();
return <h1>{pageOne.name}</h1>;
}
// 页面2
function pageOTwo({ resource }) {
// 尝试读取页面2,尽管信息可能未加载完毕
const pageTwo = resource.pageTwo.read();
return <h1>{pageTwo.name}</h1>;
}
我们一开始就通过 fetchData() 发出请求。这个方法返回给我们一个特殊的对象resource,而不是一个Promise,我们暂时不用管resource具体是什么。
React 尝试渲染 <PageMain>,该组件返回两个子组件:<PageOne> 和 <PageTwo>。
React 尝试渲染 <PageOne>,该组件调用了resource.pageOne.read(),但因为读取的数据还没被获取完毕,所以组件会处于一个挂起的状态。React 会跳过这个组件,继续渲染组件树中的其他组件。
React 尝试渲染<PageTwo>,该组件调用了resource.pageTwo.read(),和上面一样,数据还没获取完毕,所以这个组件也是处在挂起的状态。React 同样跳过这个组件,去渲染组件树中的其他组件。
组件树中已经没有其他组件需要渲染了,因为 <PageOne>组件处于挂起状态,React 则是显示出距其上游最近的<Suspense>的fallback:<h1>Loading PageOne...</h1>,渲染到这里就结束了。
随着数据的到来,React 将尝试重新渲染,并且每次都可能渲染出更加完整的组件树。当resource.pageOne的数据获取完毕之后,<PageOne>组件就能被顺利渲染出来,这时,就不再需要展示<h1>Loading PageOne...</h1>这个 fallback 了。当我们拿到全部数据之后,所有的 fallback 就都可以不展示了。
⚠️ 注意:使用 Suspense 意味着代码不再需要if(!ready)...这种代码,有效提高了代码设计的快速转变。如果我们想要同时展示<PageOne>和<PageTwo>两个组件,只需删除两者之间的<Suspense>,或者我们也可以给它们设置独立的<Suspense>,通过 Suspense,更改加载状态的粒度并控制顺序,而无需大幅度调整代码。
🍭 小彩蛋
-
我们在前文提到:React 18 支持组件返回 undefined 不再报错。这是一个历史原因,React 历史版本中,为了帮助开发者迅速排错,防止开发者忘记 return 组件,在 2017 年时把组件返回 undefined 做报错处理了。但是现在各类编译器的检测插件已经非常流行且可靠,再加上 Ts 问世,React 已不再需要帮助开发者排查组件没有 return 值的情况了。
-
还有一点就是
Suspense的问世,开发者可以不想要配置fallback,直接赋值 undefined,但是 React 报错,就有点自相矛盾了。
4、如何控制应用视图的加载顺序?
SuspenseList 用于控制 Suspense 组件的显示顺序。
revealOrder:Suspense 加载顺序
- together:所有 Suspense 一起展示,也就是最后一个加载完再全部展示
- forwards:顺序展示 Suspense
- backwards:反序展示 Suspense
tail:是否显示 fallback
只在 revealOrder 为 forwards 或者 backwards 时候有效
- hidden:不显示
- collapsed:轮到自己再显示。
// 强制控制顺序展示,即便pageTwo的接口先响应
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<h1>loading - pageOne</h1>}>
<PageOne resource={resource} />
</Suspense>
<Suspense fallback={<h1>loading - pageTwo</h1>}>
<PageTwo resource={resource} />
</Suspense>
</SuspenseList>
5、错误处理
每当我们使用 Promise,我们都会用catch()做错误处理。但当我们使用 Suspense 时,不等待 Promise 就直接开始渲染,这个时候catch()已经不管用了。
在 Suspense 中,就需要一个层级渲染一个错误边界来“捕捉”层级下面的所有错误信息了。
export default class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return {
hasError: true,
error,
};
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function MainPage() {
return (
<Suspense fallback={<h1>Loading PageOne...</h1>}>
<PageOne />
<ErrorBoundary fallback={<h2>Could not fetch pageTwo.</h2>}>
<Suspense fallback={<h1>Loading PageTwo...</h1>}>
<PageTwo />
</Suspense>
</ErrorBoundary>
</Suspense>
);
}
React 官方:理论上,在组件树中插入多少个错误边界组件都是可以的,但这并不是推荐的做法,错误边界组件的位置最好是深思熟虑之后再确定。
6、Race Conditions 问题
React 官方 一直有个问题令他们棘手。React 组件有自己的“生命周期”,组件可能在任意时间点接收到 props 或者更新 state。而每一个异步请求同样也有自己的“生命周期”,异步请求的生命周期开始于发出请求,结束于收到响应报文。在这里,如何在这两类生命周期之间及时进行“同步”呢?
回顾 Sespense,貌似已经能够解决这种问题了。因为使用了 Sespense 后,并不是干等到响应报文接收后,才去更新 state。而是反过来,发出请求之后,就马上就开始更新 state(外加开始渲染),一旦收到响应,React 就把可用的数据用到 组件里头。
五、Transition
回顾前文,我们点击某个“next”按钮来切换激活页面时,现存页面就会立马消失,然后我们看到整个页面就只有一个加载提示,用户体验是非常差,非常“不受欢迎”的。如果我们能在跳转之前,就先把内容加载好再过度到新页面,貌似效果更好。
1、使用方式
React Concurrent Mode 提供了一个新的内置的useTransition()的 hook 来实现这个设计。
import { useTransition } from "react";
const App = () => {
const [isPending, startTransition] = useTransition({
timeoutMs: 3000,
});
};
useTransition 包含两个返回值:
- startTransition 类型为函数。我们用它来告诉 React 我们希望的延迟的是哪个 state 的更新。
- isPending 类型为 boolean。此变量在 React 中用于告知我们该转换是否正在进行。
useTransition 还支持传入了一个配置对象。此对象包含 timeoutMs 属性,该属性指定了我们希望这个转换在多久之内完成。
import { useState, useTransition } from "react";
const initialResource = fetchData();
function App() {
const [resource, setResource] = useState(initialResource);
const [isPending, startTransition] = useTransition({
timeoutMs: 3000, // 前一个页面最多只在屏幕中最多保持三秒
});
return (
<>
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
setResource(fetchData());
});
}}
>
Next
</button>
<Suspense fallback={<h1>loading...</h1>}>
<MainPage resource={resource} />
</Suspense>
</>
);
}
2、组件是在哪里更新的?
其实,在用户点击切换页面的时候,就已经存在两个“版本”的<PageMain>,旧的存在于当前页面上,还有一个进度的,新的存在于正在等待的那个页面。
但是同一个组件的两个版本怎么同时存在呢?
可以认为 state 内部存在 branch 管理。React 可以在不同版本的树上进行切换,一个是你屏幕上看到的那个版本,另一个是它“准备”接下来给你显示的版本。
3、与 setTimeout 异同
在 startTransition 出现之前,我们可以使用 setTimeout 来实现优化。但是现在在处理这方面优化时,有了 startTransition 基本上可以抛弃 setTimeout 了。
与 setTimeout 不同的是,startTransition 并不会延迟调度,而是会立即执行。startTransition 接收的函数是同步执行的,只是这个 update 被加了一个 “transitions" 标记,给到 React 内部处理更新时作为参考信息的。相比于 setTimeout ,把一个 update 交给 startTransition 能够更早地被处理,在较快的设备上,这个过度是用户感知不到的。
4、使用场景
startTransition 可以用在任何你想更新的时候。但从实际出发,以下是两种典型适用场景:
- 渲染慢:如果有很多没那么着急的内容要渲染更新;
- 网络慢:如果更新需要花较多时间从服务端获取,这个时候也可以再结合 Suspense。
5、学习三个阶段
默认方式:Receded → Skeleton → Complete
在我们日常的进入页面时,会经历这样的过程:
Receded(后退):第一秒,你会看到一个白屏并且正在loading。
Skeleton:加载完初始化的一些基本数据后,页面渲染大致的模块,这是你可能会看到其他区域(如:表格区域)还在loading。
Complete:当所有内容获取完毕,页面不再有loading。
如何区分 Receded 和 Skeleton 状态呢?
它们之间的区别在于 Receded 感觉像是面向用户“向后退一步”,而 Skeleton 模式感觉像是在我们的进程中“向前走一步”来展示更多的内容。
这个过程(Receded → Skeleton → Complete)是默认情况。但是 Receded 状态是非常不友好的,因为它“隐藏”了已经存在的信息。这正是 React 让我们通过 useTransition 进入另一个序列(Pending(等待) → Skeleton → Complete)的原因。
期望方式: Pending → Skeleton → Complete
当我们使用 useTransition 的时候,React 会让我们“停留”在前一个页面,并在那显示一个进度提示。我们称它为一个 Pending(暂停)状态。这种的体验会比 Receded 状态好很多,因为我们已经显示的信息不会消失,而且页面仍可以交互。
六、肖肖的疑惑
- 通过路由调整,在渲染下一个页面之前,我们怎么知道获取什么数据?
- 如何更好的避免瀑布的问题?
- 怎么更好的处理 Skeleton ?