React 实验阶段的 Concurrent 模式

参考文章:
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 模式的区别。

非 Concurrnet 模式

开启了 Concurrent 模式的记录

能明显地看到,Concurrent 模式下,原本的同步更新,通过时间切片(每帧时,留出一点固定时间给JS)变为可中断的异步更新。

突破IO的瓶颈

网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知?
可以先看一下,业界人机交互最顶尖的苹果举例,在IOS系统中:

点击“设置”面板中的“通用”,进入“通用”界面(点击“通用”后的交互是同步的,直接显示后续界面)
作为对比,再点击“设置”面板中的“Siri与搜索”,进入“Siri与搜索”界面(点击“Siri与搜索”后的交互是异步的,需要等待请求返回后再显示后续界面)

虽然点击“通用”后的交互直接跳转到后续页面是同步的,点击“Siri与搜索”后的交互因为需要请求数据所以是异步的,但是你能感受到两者体验上的区别么?

这里的窍门在于:点击“Siri与搜索”后,先在当前页面停留了一小段时间,这一小段时间被用来请求数据。

当“这一小段时间”足够短时,用户是无感知的。如果请求时间超过一个范围,再显示loading的效果。

试想如果我们一点击“Siri与搜索”就显示loading效果,即使数据请求时间很短,loading效果一闪而过。用户也是可以感知到的。

React 为了达到这样的效果,在实验性阶段推出了 用于数据获取的SuspenseConcurrent 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 在渲染完成后请求数据造成的瀑布问题

假设获取父级组件数据需要 2 秒,那么在这个方法中,我们只能在 2 秒之后,才开始获取子级组件数据。这就是上面提到的“瀑布”问题:本该并行发出的请求无意中被串行发送出去。
  • fetch-then-render(接收到全部数据之后渲染,不使用 Suspense),先尽早获取下一屏需要的所有数据,数据准备好后,渲染新的屏幕。但在数据拿到之前,我们什么事也做不了。
    codesandbox 代码示例链接:接收到全部数据之后渲染
    利用 Promise.all() 来等待所有数据,解决了之前出现的网络“瀑布”问题,却意外引出另外一个问题。这就导致了,我们即使先接收完子组件数据,也不能先渲染子组件,还得等到父组件也接收完才行
  • Render-as-you-fetch(获取数据之后渲染,使用 Suspense)
    在上面方法 2 中,我们是在调用 setState 之前就开始获取数据:
  1. 开始获取数据
  2. 结束获取数据
  3. 开始渲染

有了 Suspense,我们依然可以先获取数据,而且可以给上面流程的 2、3 步骤调换顺序:

  1. 开始获取数据
  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 可以实现这个设计。
我们通过几个步骤来使用它。

  1. 保项目中正在使用 Concurrent 模式,需要使用 ReactDOM.unstable_createRoot()或者也可能是ReactDOM.createRoot()(需要React的实验版本) 而非 ReactDOM.render()
ReactDOM.unstable_createRoot(
  document.getElementById('root')
  ).render(<App />);
  1. 增加一个从 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 中用于告知我们该转换是否正在进行。
  1. 把期望不跳出加载页面的 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…”提示让用户知道程序并没有卡住。
总结:

我们分四步步实现了这个功能

  1. 我们引入了 useTransition Hook 并在更新 state 的组件中使用了它。
  2. 我们传入了 {timeoutMs: 3000} 使得前一个页面在屏幕上最多保持3秒钟。
  3. 我们把 state 更新包裹在 startTransition 中,以通知 React 可以延迟这个更新。
  4. 我们使用 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 模式。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271