参考文章:
React技术揭秘——React理念
官方文档——Concurrent 模式介绍 (实验性)
为什么 React 要推出这个模式
我们可以从官网看到React
的理念:
用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。
那制约快速响应的因素有哪些呢?
- 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
- 发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。
这两类场景可以概括为
- CPU的瓶颈
- IO的瓶颈
那 React 是如何解决这俩个瓶颈的呢?
突破CPU的瓶颈
我在 React15 和 React16 的架构对比 有对于屏幕刷新的介绍:
市场上主流的屏幕刷新率都是60HZ(一秒刷新60次),也就是(1000ms / 60)。即16.6ms浏览器刷新一次。在每16.6ms时间内,需要完成
JS脚本执行 ----- 样式布局 ----- 样式绘制
。当JS执行时间过长,超出该帧,那么这次刷新就没有时间执行样式布局和样式绘制了。
那如何解决这个问题呢?
在每帧时,留出一点固定时间给JS。当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。
看下初次渲染情况下非Concurrent 模式和 Concurrent 模式的区别。
能明显地看到,Concurrent 模式下,原本的同步更新,通过时间切片(每帧时,留出一点固定时间给JS)变为可中断的异步更新。
突破IO的瓶颈
网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知?
可以先看一下,业界人机交互最顶尖的苹果举例,在IOS系统中:
虽然点击“通用”后的交互直接跳转到后续页面是同步的,点击“Siri与搜索”后的交互因为需要请求数据所以是异步的,但是你能感受到两者体验上的区别么?
这里的窍门在于:点击“Siri与搜索”后,先在当前页面停留了一小段时间,这一小段时间被用来请求数据。
当“这一小段时间”足够短时,用户是无感知的。如果请求时间超过一个范围,再显示loading的效果。
试想如果我们一点击“Siri与搜索”就显示loading效果,即使数据请求时间很短,loading效果一闪而过。用户也是可以感知到的。
React 为了达到这样的效果,在实验性阶段推出了 用于数据获取的Suspense 和 Concurrent UI 模式
首先介绍一下用于数据获取的 Suspense(试验阶段)
首先强调:用于数据获取的 Suspense 是一个新特性,你可以使用 <Suspense> 以声明的方式来“等待”任何内容,包括数据。
“等待”目标代码加载,并且可以直接指定一个加载的界面,,让它在用户等待的时候显示。示例:
const ProfilePage = React.lazy(() => import('./ProfilePage')); // 懒加载
// 在 ProfilePage 组件处于加载阶段时显示一个 Loading 组件
<Suspense fallback={<Loading />}>
<ProfilePage />
</Suspense>
先抛出问题,在React中哪个阶段获取数据合适?为什么要使用Suspense用于数据获取?
- fetch-on-render(渲染之后获取数据,如:在 useEffect 中 fetch)
// 在函数组件中:
useEffect(() => {
fetchSomething();
}, []);
// 或者,在 class 组件里:
componentDidMount() {
fetchSomething();
}
React 称这种方法为“fetch-on-render”(渲染之后获取数据),因为数据的获取是发生在组件被渲染到屏幕之后。这种方法会导致“瀑布”的问题。
codesandbox 代码示例链接:React 在渲染完成后请求数据造成的瀑布问题
-
fetch-then-render(接收到全部数据之后渲染,不使用 Suspense),先尽早获取下一屏需要的所有数据,数据准备好后,渲染新的屏幕。但在数据拿到之前,我们什么事也做不了。
codesandbox 代码示例链接:接收到全部数据之后渲染
利用 Promise.all() 来等待所有数据,解决了之前出现的网络“瀑布”问题,却意外引出另外一个问题。这就导致了,我们即使先接收完子组件数据,也不能先渲染子组件,还得等到父组件也接收完才行 -
Render-as-you-fetch(获取数据之后渲染,使用 Suspense)
在上面方法 2 中,我们是在调用setState
之前就开始获取数据:
- 开始获取数据
- 结束获取数据
- 开始渲染
有了 Suspense,我们依然可以先获取数据,而且可以给上面流程的 2、3 步骤调换顺序:
- 开始获取数据
- 开始渲染
- 结束获取数据
有了 Suspense,我们不必等到数据全部返回才开始渲染。实际上,我们是一发送网络请求,就马上开始渲染:codesandbox代码示例
// 一早就开始数据获取,在渲染之前!
const resources = fetchAll();
// ...
function Father() {
// 尝试读取用户信息
const fatherName = resources.fatherName.read();
return <h1>FatherName: {fatherName} </h1>;
}
需要将 resource 对象设计成在数据开始请求之前无法被获取,来实现数据请求先发生于组件渲染。 本文中所有使用“伪 API” 的演示仿照官方示例都实现了对请求和渲染的顺序控制。
再次表示:用于数据获取的 Suspense 只是一个新特性,你可以使用 <Suspense> 以声明的方式来“等待”任何内容,包括数据。本文重点介绍它在数据获取的用例,它也可以用于等待图像、脚本或其他异步的操作。
同时需要注意到的是:Suspense 模式并不与苹果在 IOS 给出的教科书级别优化中的逻辑一样,貌似它并没有在请求时,在当前页面停留一下的机制,而是在请求时渲染一下预设的 Loading 组件。此时就需要介绍 Concurrent UI 模式了。
Concurrent UI 模式 (试验阶段)
给出一个关于 Suspense 用于数据获取的代码示例:在codesandbox上查看
当我们点击 “Next” 按钮来切换激活的页面,现存的页面立刻消失了,然后我们看到整个页面只有一个加载提示。可以说这是一个“不受欢迎”的加载状态。如果我们可以“跳过”加载提示这个过程,并且等到内容加载后再过渡到新的页面,效果会更好。
React 提供了一个新的内置的 useTransition() 的 Hook 可以实现这个设计。
我们通过几个步骤来使用它。
- 保项目中正在使用 Concurrent 模式,需要使用
ReactDOM.unstable_createRoot()
或者也可能是ReactDOM.createRoot()
(需要React的实验版本) 而非ReactDOM.render()
ReactDOM.unstable_createRoot(
document.getElementById('root')
).render(<App />);
- 增加一个从 React 引入 useTransition Hook 的 import:
import React, { useState, useTransition, Suspense } from "react";
3.我们在 App 组件中使用它:
function App() {
const [resource, setResource] = useState(initialResource);
const [startTransition, isPending] = useTransition({
timeoutMs: 3000
});
就这段代码而言,它还什么都做不了。我们需要使用这个 Hook 的返回值来配置我们的界面切换。useTransition 包含两个返回值:
startTransition
类型为function
。我们用它来告诉 React 我们希望的延迟的是哪个 state 的更新isPending
类型为boolean
。此变量在 React 中用于告知我们该转换是否正在进行。
- 把期望不跳出加载页面的 state 更新包裹在 startTransition 中
例如,案例中点击 Next 按钮,不希望它显示Loading字样,而是希望能暂时停留。在 CodeSandbox 中尝试
<button
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>
当点击时,我们没有直接切换到一个空白的页面,而是在前一个页面停留了一段时间。当数据加载好的时候 React 会帮我们切换到新的界面。
还是有地方体验不友好。最好不要显示加载中。但是如果没有这个过程提示的话体验会更糟糕!当我们点击 “Next”按钮,什么都没有发生,就好像整个应用卡死一样。
我们需要注意的是,调用 useTransition() 包含两个值返回值:startTransition 和 isPending。
const [startTransition, isPending] = useTransition({ timeoutMs: 3000 });
我们已经使用了 startTransition 来包裹 state 更新。现在我们要使用 isPending 了。React 提供了这个布尔值来告诉我们当前我们是否正在等待界面切换完成。在 CodeSandbox 中尝试
return (
<>
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>
Next
</button>
{isPending ? " Loading..." : null}
</>
);
现在,这感觉好多了!当我们点击 Next 按钮的时候,它变得不可用,因为点击它很多次并没有意义。而且新增的“Loading…”提示让用户知道程序并没有卡住。
总结:
我们分四步步实现了这个功能
- 我们引入了 useTransition Hook 并在更新 state 的组件中使用了它。
- 我们传入了 {timeoutMs: 3000} 使得前一个页面在屏幕上最多保持3秒钟。
- 我们把 state 更新包裹在 startTransition 中,以通知 React 可以延迟这个更新。
- 我们使用 isPending 来告诉用户界面切换的进展并禁用按钮。
一些小技巧
把过慢的组件用 <Suspense> 把它包裹起来使其惰性加载。
如果有些特性并不是下个界面所必要的部分,用 <Suspense> 把它包裹起来使其惰性加载。这样就确保了我们能够尽可能快的给用户显示其他的内容,相反的,如果有些组件缺少了这个界面就不值得显示,就使用 useTransition,这样 transition 就会“等待”直到它准备好。
你可以在 这里 查看这个示例。“文章”和“趣闻”响应相差了 100ms。但是 React 把他们的变化合并在一起并同时更新了他们的 Suspense 的区域。
延迟显示过快的等待提示
在上面的示例中,当我们点击 Button 组件时它会立刻显示一个 Pending 状态提示,这让用户知道有什么事情正在发生。但是,如果这个 transition 过程相对较短的时候(小于 500ms),它有可能过于分散注意力,并且使得 transition 本身感觉上更慢。
一个可能的方法就是延迟等待提示的显示:
.DelayedSpinner {
animation: 0s linear 0.5s forwards makeVisible;
visibility: hidden;
}
@keyframes makeVisible {
to {
visibility: visible;
}
}
const spinner = (
<span className="DelayedSpinner">
{/* ... */}
</span>
);
return (
<>
<button onClick={handleClick}>{children}</button>
{isPending ? spinner : null}
</>
);
通过这个更改(通过 CSS 动画延迟 Loading 组件的显示),即使我们进入了 Pending 状态,在 500ms 过去之前我们都不会给用户显示任何提示。
在 API 响应快的情况下对比感受下 使用前 和 使用后。即使其他的代码并没有更改,通过不在延迟上吸引用户注意,隐藏掉“过快”的加载状态以达到提升感官体验。
官网和参考文章还有一些其他内容,这里仅是一些我所关注的重点。还是希望能早日使用上这个稳定的 Concurrent 模式。
网友评论