【react】React 18 预热

323 阅读35分钟

前言

曾经觉得 react 18 会离我比较远,上一次跟进 react 特性还是在 「react server component」 被宣布的时候,当时我写了一篇文章【react】初探 server component。如今回首,已然是 1 年前,真是白驹过隙,时光飞逝。

在上周(2021年12月8日),react conf 大会顺利举行。这次大会虽然也提到了 react native 和 react developer tool, 但是整个大会的主题显然是 react 18 。截止到我撰写这篇文章,react 团队已经发布了 18.0.0-rc.0-next-3dc41d8a2-20211223(候选版本),预计在 2022 年初的时候发布正式版本,届时,react 社区又会踏上一个新征程。根据目前的信息来看,react 18 显然是承载了react fiber reconciler 架构提出之时的诸多雄心壮志:

  • 其一是「UX 的极致追求」
  • 其二是「打造一个以 react 为中心的 web 全栈框架/跨平台架构」。 这两个目标都是要靠 react 18+ 或大或小的 feature 来支撑,下面我们不妨通过梳理 react 18 中的新特性来一探究竟。

react 18 之新

react 18 包含了两种类型新鲜事物:

  • 新特性
  • 新概念/术语

没有「Concurrent Mode」,只有「Concurrent Features」

react conf 2021 之后,react 团队抛出了一个定论:

there is no concurrent mode, only concurrent features.

至此,他们又抛出了一个新术语:“concurrent features”。在想弄清楚“concurrent features”之前,其实像我们这么没有密切跟进 react 版本迭代的人可能也不知道什么是“concurrent mode”,我们不妨先弄清楚什么是“concurrent mode”。

什么是「Concurrent Mode」?

其实,伴随 concurrent mode 提出的同时,还有其他的两个“mode”:

  • strict mode
  • legacy Mode
  • blocking mode

这四个 mode 都是在react core team 制定引导社区从 sync rendering(同步渲染,底层是 stack reconciler 架构) 到 concurrent rendering(并发渲染,底层是 fiber reconciler 架构) 的迁移策略这样的大背景下诞生的。说到这里,我们得好好追溯一下,react 团队的 react 18 迁移策略变迁之路。

首先,react 团队在 react 16.3 这个版本中,react 引入了一个叫“strict mode”的特性,目的是提示开发者去逐渐弃用那些从并发渲染角度来看不是很好的 API。从代码的角度来讲,strict mode 是一个 react 组件,如下代码所示:

import React from 'react';

function ExampleApplication() {
  return (
    <div>
      <Header />
      <React.StrictMode>       
          <div>
              <ComponentOne />
              <ComponentTwo />
          </div>
      </React.StrictMode>      
      <Footer />
    </div>
  );
}

<React.Fragment> 组件一样,<React.StrictMode> 是不会渲染任何成可见的 UI 元素,它就是一个抽象函数。它负责监视它的子组件们,当它们中有不良的 API 消费行为时,react 会在控制台打印出告警。strict mode 认定以下 API 是不良的(容易产生隐匿 bug 的,又或者说,从并发渲染来看,是不安全的):

  • 使用了以"unsafe"为前缀的声明周期函数
  • 使用了废弃的 string ref API
  • 使用了废弃的 findDOMNode API
  • 在生命周期函数里面写了一些 side effect 代码
  • 使用了废弃的,有 bug 的老 context API

以上简单地介绍了一下 strict mode,更多详情请查阅官方文档 Strict Mode

react 16.3 引入的 strict mode 只是 react core team 引导社区迁移到并发渲染的一个热身活动。在此之后,他们制定了一个策略,我称之为:“为你好,一步走”。这句话的意思,react core team 认为并发渲染能够为开发者带来很多的正向收益,我爱你们才会为你好,你们只要将版本更新到 react 18 ,你们的应用就处于并发渲染模式里面了,其余的啥事都不用做。这明显是属于断崖式升级的迁移策略,结果可想而知,社区里面的反馈很不好,估计是产生了太多了与当前类库不兼容的 bug。Rick Hanlon 在 What happened to concurrent "mode"? 一文中如此写道:

However, after hearing feedback from the community about the impact of the change and concerns about gradually upgrading, it was clear that this strategy needed an incremental story.

所以,接下来,react team 意识到「凡事操之过急则容易过犹不及」,于是乎将迁移策略从“一步到位”分解为“三步走”。“三步走”中的每一步代表一个迁移阶段,依次递进,最终到达“全量并发渲染”状态。每一个阶段的 react 应用都包含三个子规则子集:

  • 第一步 - legacy mode : ReactDOM.render(<App />, rootNode)
    • 默认不开启 strict mode
    • 默认使用 sync rendering
    • 使用老的 suspense 语义
  • 第二步 - blocking mode : eactDOM.createBlockingRoot(rootNode).render(<App />)
    • 默认开启 strict mode
    • 默认使用 sync rendering
    • 支持某些并发特性
  • 第三步 - concurrent mode : ReactDOM.createRoot(rootNode).render(<App />)
    • 默认开启 strict mode
    • 默认使用 concurrent rendering
    • 支持所有的并发特性

到这里,我们基本上可以回答“什么是 concurrent mode” 了。“concurrent mode” 就是 react core team为了让社区迁移到并发渲染版本 react 所制定的迁移策略“三步走”中的最后一步。在这个阶段中,你的 react 应用是处于全量的并发特性当中,也因而拥有一个全新的渲染语义 - “并发渲染”。再换句高逼格的话来解释,concurrent mode 就是 “All in concurrent”。

什么是「Concurrent Features」?

上面提高的第一步其实就是原汁原味的 react 17,当前社区就是处于这样的阶段。

接下来,react 团队希望社区遵循他们的迁移策略,依次从第一步迁移到第二步,最后从第二步迁移到第三步。但是后来,他们发现社区对于 blocking mode 的试用结果还是挺差强人意的。这是由于react 团队默认开启了 strict mode 并且对于理论错误过于谨慎, 这就导致了在开发环境下制造了太多信息噪音。因为开发者在不修复的情况下,应用还是会正常运行,再加上开发者们对并发渲染下这里理论上的错误的理解和处理能力不足,因此,最终的结果往往是开发者对这些 warning 熟视无睹,置之不理。如此说来,那 strict mode 形同虚设,没有取得 react 团队想要的效果。

另外一点是,react 团队发现大家从并发渲染中受益最大的地方集中在其中的一两个 API(比如 startTransition 或者 SSR suspense 等),与此同时设定一个全新的 mode 去强制启用全量并发特性反而会打压社区迁移到 react 18 的积极性,因为大家对工作量的增加没有好感,对大面积改动后,程序是否能够正常运行没有信心。

基于上面三步骤的迁移策略试验结果和过程中取得的认知,react 团队决定吸收“三步走”策略中的每一个阶段的精华部分,糅杂成一个“新”的 “concurrent mode”。「新 concurrent mode」 具有以下规则子集:

  • 默认不开启 strict mode
  • 同时支持 sync rendering 和 concurrent rendering(当前处于何种渲染模式,由用户自己来选择)。
  • 用户需要手动打开 concurrent rendering 开关,才能使用相应的 concurrent API。

在「新 concurrent mode」下,concurrent API 可以在脱离全量并发渲染的语义下使用 - 也就是说,单独的一个 concurrent API 可以 lead 一个局部的并发渲染。react 只会在你使用了 concurrent API 的地方进行并发渲染,其他地方还是采用同步渲染。这样一个个的 concurrent API, 我们就称之为 “concurrent features”。用一句高逼格的话来解释就是,concurrent features 就是 “Opt in concurrent”。最后在借用官方说辞来总结一下,采用 concurrent feature 的 react 应用的行为表现其实就是:

The way this works is essentially a hybrid of blocking mode and concurrent mode.

到目前为止,官方已经公布的 concurrent features 有:

  • createRoot()
  • startTransition()
  • useTransition
  • useDeferredValue()
  • 服务端 <Suspense>
  • <SuspenseList>

为什么这么说?

最后,我们来总结一下,官方为什么这么说。其实经过上面的一番阐述,我们应该明白,官方对“concurrent mode”原始语义的定义是“react 应用的全量并发渲染”。到了如今提出的“concurrent features”,它的语义已经是“react 应用中允许局部的并发渲染”。术语的原始语义已经不符合最终的迁移策略的表现,所以提出“no concurrent mode”也是合情合理的。

其实,从这句话背后,我们看到 react 团队制定迁移策略的思维变迁过程。从“all or nothing”的断崖式迁移再到“opt in and out”的渐进式迁移,他们做了很多策略尝试和代码实现,这个过程中所耗费的人力和物力不是普通的技术团队能够承担得起的。对我们开发者来说,这种做法最重要的是体现了 react core team 对社区的尊重。与此同时,也算是用实际行动来证明了团队对类库升级进化的看法:

We think it is better to offer a gradual migration strategy than to make huge breaking changes — or to let React stagnate into irrelevance.

Automatic Batching

什么是 batching(批量更新)?

准确来说,“批量更新”已经不算是什么新机制了,早就在 react 还是 stack reconciler 架构之时已经存在。不过,我们还是得来简单地温习一下:“什么是批量更新?”

“批量更新”是指用户在同一个上下文中(比如:同步代码中的函数作用域,event loop 的 micro task queue和 macro task queue, 原生的事件处理器等等)先后调用多个 “setState” 来请求更新界面,react 实质上将多个请求合并成为一个,因此只会触发一次的组件渲染,向浏览器 commit 一次 DOM 变化。在 react 18 之前,更准确地说,“批量更新” 只会发生在合成事件的 event handler 里面。请看以下示例:

示例 1

import React, { useState, useEffect } from 'react'; // 版本:18.0.0-rc.0
import { createRoot, render } from 'react-dom'; // 版本:18.0.0-rc.0


const App =()=> {
  console.log('render component')
  const [count, setCount] = useState(0)

  useEffect(()=> {
    console.log('commit to screen')
  })

  return (
    <button onClick={()=>{
      setCount(count=> count + 1)
      setCount(count=> count + 1)
    }}>
        {count}
    </button>
  )
}

// 注意,我这里虽然用了 react 18 的 rc 版本,
// 但是由于我没有使用`createRoot()`来开启 concurrent 模式,所以但是相当于 react 17。
render(<App />, document.getElementById('root'));

当我点击按钮,控制台的打印如下:

render component
commit to screen

但是,如果我的setCount()调用是发生在微任务或者宏任务中的话,那么,在 react 17 中就不会触发批量更新。为了验证这一点,我么不妨把 click handler 的代码改写一下:

示例 2

// ...上面的代码
<button onClick={()=>{
  new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, 10)
  }).then(() => {
    setCount(count => count + 1);
    setCount(count => count + 1);
  })
}}>
    {count}
</button>
// ...下面的代码

现在打印结果如下:

render component
commit to screen
render component
commit to screen

可以看出,react 17 中批量更新机制的表现跟我上面的描述是一致的。与此同时,从这个示例中,我们可以明白“什么是批量更新”。

react 18中的「批量更新」

在 react 18 中,默认在所有的场景中开启批量更新。现在,无论你是在浏览器 event loop 的微任务(Promise.then())中还是宏任务中(setTimeout/setInterval)中或者是浏览器原生的事件处理器中,都是处于批量更新的模式中。还是利用上面的示例 1 的代码,我们只需要切换一下挂载根组件的方式,就可以切回「真正的」 react 18 中来:

示例 3

import React, { useState, useEffect } from 'react'; // 版本:18.0.0-rc.0
import { createRoot, render } from 'react-dom'; // 版本:18.0.0-rc.0


const App =()=> {
  console.log('render component')
  const [count, setCount] = useState(0)

  useEffect(()=> {
    console.log('commit to screen')
  })

  return (
    <button onClick={()=>{
      // 测试用例1
      setCount(count=> count + 1)
      setCount(count=> count + 1)
      
      // 测试用例2
       new Promise((resolve) => {
        setTimeout(() => {
          resolve()
        }, 0)
      }).then(() => {
        setCount(count => count + 1);
        setCount(count => count + 1);
      })
    }}>
        {count}
    </button>
  )
}

// 通过调用`createRoot()`, 切回「真正的」 react 18
const root = createRoot(document.getElementById('root'))
root.render(<App />);

在 react 18 中,如今无论是测试用例 1 还是测试用例 2,它们的表现都是一致的,都是批量更新 - 只 render 组件一次,commit 一次到屏幕。

unstable_batchedUpdatesflushSync

在 react 17 中,如果你想在合成事件的 event handler 之外的场景使用批量更新机制,react 17 提供了一个实验性的,不稳定的 API:unstable_batchedUpdates(这个 API 在 react 18 中还会存在)。在示例 2 中,你用它包裹所有的setCount() 即可实现批量更新:

示例 4

// ...上面的代码
<button onClick={()=>{
  new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, 11)
  }).then(() => {
    unstable_batchedUpdates(()=> {
        setCount(count => count + 1);
        setCount(count => count + 1);
    })
  })
}}>
    {count}
</button>
// ...下面的代码

在react 18 中,一旦你使用 ReactDOM.createRoot()来挂载根组件,那么所有 setState 都是处于批量更新模式下。所谓,进可攻,退可守: 万一,我想退出这个默认的批量更新机制呢?。react 团队贴心地想到了你的需求。这时候,该是 flushSync 出场了。具体用法就是,用flushSync()去一个个地包住 setState(), 如示例 5 所示:

示例 5

// ...上面的代码
<button onClick={()=>{
  new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, 11)
  }).then(() => {
    flushSync(()=>{setCount(count => count + 1);})
    flushSync(()=>{setCount(count => count + 1);})
  })
}}>
    {count}
</button>
// ...下面的代码

如此一来,以上代码执行之后,组件就会被渲染 2 次,并 commit 2 次到屏幕上。

Concurrent Features

react 18 中引入了以下的 concurrent features 性质的 API:

  • createRoot()
  • startTransition()
  • useTransition
  • useDeferredValue()

下面我们简单地来说说这些 API。

createRoot

automatic batching 演示中也提到了,react 18 携带了两个渲染根组件的方法:

  • ReactDOM.render: 如果我们用这个方法来挂载根组件的话,那么我们的 react 应用就是处于同步渲染机制中,也就是所谓的 “legacy mode”。它的表现就跟现在的 react 17 是一模一样的。也就是说,升级到 react 18 之后,如果你在代码层面啥都不改的,react 应用也是能够正常运行,就相当于你是在用 react 17。

  • ReactDOM.createRoot: 如果我们用这个方法来挂载根组件的话,相当于打开了“并发渲染”的开关。react 只会在你使用了 concurrent feature 的组件树中使用并发渲染。也就是说,升级到 react 18 之后,「用不用并发渲染」,「用多少」都是由开发者决定的。

下面简单说说,这两者的不同。

  • createRoot API 不支持传递 callback 参数了。如果你想去监听整颗组件树挂载完毕的话,那么你有以下的解决方法:
    • 使用 requestIdleCallback API(在 event loop 空闲的时候执行)
    • 使用 setTimeout API (在下一个 event loop 执行)
    • 使用 ref callback (在当前的 event loop,DOM 更新 commit 到屏幕之前)
    • 使用 useEffect(在当前的 event loop, DOM 更新 commit 到屏幕之后)

下面用一个示例做个总体演示:

import React, { useState, useEffect } from 'react';
import { createRoot, render, flushSync, unstable_batchedUpdates   } from 'react-dom';


const App = (props) => {
  console.log('start to render component')
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log('renderered  from useEffect')
  })

  return (
    <button ref={props.callback}>{count}</button>
  )
}

const root = createRoot(document.getElementById('root'))
root.render(<App callback={()=>{console.log('renderered  from callback')}} />);

window.setTimeout(()=>{console.log('renderered  from setTimeout')},0)
window.requestIdleCallback(()=>{console.log('renderered  from requestIdleCallback')});

最终的打印结果如下:

企业微信截图_1640174324205.png

  • hydrate 的方式不一样。在 createRoot API 中,没有单独的 hydrate 方法,hydrate 会作为一个 option 传入 createRoot API 中:
import ReactDOM from 'react-dom';
import App from 'App';

const container = document.getElementById('app');

hydration from React18 will be an option flag
const root = ReactDOM.createRoot(container, { hydratetrue });

root.render(<App tab="home" />);

startTransition 和 useTransition

并发渲染的初衷之一是为了实现「渲染可中断/恢复」,「渲染任务可以区分优先级」等特性,这些特性的目的是为了打造出极致的用户体验。startTransition 就是 react 团队在 UX 的极致追求上的一个产物。这个 API 的设计的背后折射的是 react 团队对 “什么是优秀的 UX” 这个问题的看法。startTransition API 主要用于“视图切换”(注意,不是“页面切换”。页面切换只是视图切换的一个子集)这种 UX 场景,对于这个场景,react 团队有着这样的看法:

For example, if we switch from one page to another, and none of the code or data for the next screen has loaded yet, it might be frustrating to immediately see a blank page with a loading indicator. We might prefer to stay longer on the previous screen.

也就是说,他们认为:“过早地跳转到下一屏,并且显示白屏 + loading 指示器” 的这种用户体验是不好的。而“在下一屏完全可见之前,保留当前视图。直到下一屏准备好了,才直接切换过去” 这种用户体验是好的。个人对此看法并不十分赞同。他们认为好的用户体验,在我看来却是容易让用户获得界面反应迟钝的用户认知。或者说,至少这两种用户体验的差距并不大,react 团队(或者说 facebook)花这么大的人力和物力在上面,真的有点匪夷所思和吹毛求疵!

什么时候用 startTransition 呢?官方的口吻是:

If some state update causes a component to suspend, that state update should be wrapped in a transition.

翻译一下,就是说,如果一个异步获取的 state 至少被一个组件消费了,那么这个「异步获取最新 state 的动作」就应该用 startTransition 包起来。

单纯使用 startTransition 来创造的用户体验,个人觉得完全不能接受,甚至让我感到愤怒。因为,这种用户体验实际上是「用户点击了界面,“相当长的”一段时间里面,一点反应都没有」,给我的感觉是“页面是不是坏掉了,怎么没反应啊?”。

能够削减我的“愤怒情绪”的是,react 团队提供了 useTransition API。这个 API 会暴露出当前的 transition 状态。我们可以根据这个状态来对用户的交互做出响应。用一个粗陋一点的示例解释如下:

function FooComponent() {
    const [isPending, startTransition] = useTransition();
    
    startTransition(() => {
        setSearchQuery(input); // 耗时的 state 更新动作
    });
    
    if(isPending){
        // show the spin here which means 'I am transitioning now......'
    }
}

我对官方给出的 demo 进行了细微的改造,完整的 API 使用方式和用户体验,可以看看我这里的例子:React 18 - useTransition demo。至于 startTransitionuseTransition 是否就是你想要的用户体验,各位看官则可以见仁见智。

useDeferredValue

如果你想将依赖同一个 state,原本进行一致性渲染的两个 UI 组件进行非一致性渲染的时候,那么你可以用useDeferredValue 将 state 把包裹住,产生一个新的 deferred value 传递给那个你想要延迟渲染的 UI 组件。用并发的术语来讲,就是通过useDeferredValue来降低某个渲染任务的优先级,把浏览器 event loop 的时间资源优先分配给拥有更高优先级的渲染任务。

上面这句话解答的是:“useDeferredValue 有什么用?”的问题。但是大部分读者读下来可能会有一个疑惑。因为一致性渲染的需求是符合人类对视觉效果的正常认知的,我们也一直将非一致的 UI 渲染结果视为 bug,为什么我们还反过来追求非一致性渲染呢?对于前半句观点,react 团队认为确实如此。对于半句的疑问,他们觉得特殊情况下(两个组件的渲染耗时存在比较明显的差异时,我们不想让整个渲染陷入到木桶效应中),我们还是会有非一致性渲染需求的。react 官方文档就给出了两个这样的使用场景。

1.非一致性渲染不会对用户交互结果造成不良影响

当我们面临依赖同一个 state 的两个组件的渲染耗时存在比较大的差异时,如果我们判断当前的非一致性渲染不会给用户的交互带来坏结果的话,那么我们就可以这么做。比如,官方就针对这种场景给了一个示例,这个示例中,渲染结果只是用来查看,并且非一致性渲染的耗时差距是可以接受的。因此这是一个比较恰当的使用场景。

从这个示例来看,<ProfileDetails><ProfileTimeline>组件同时依赖 resource 这个 state。但是,<ProfileTimeline>的渲染占用现线程 1000ms,而<ProfileDetails>只需要占用线程 300ms。鉴于「非一致性渲染不会对用户交互结果造成不良影响(或者说造成的结果,我们开发者是可以接受的)」的前提,而我们不想让<ProfileTimeline>的渲染拖累我们对<ProfileDetails>的渲染,因此我们决定对这两个组件进行非一致性渲染。具体的做法就是用 useDeferredValue API 包住所依赖的那个 state,然后产生一个新的 deferred value 传递给<ProfileTimeline>组件,如下:

function ProfilePage({ resource }) {
  const deferredResource = useDeferredValue(
    resource,
    {
      timeoutMs: 1000
    }
  );
  return (
    <Suspense
      fallback={<h1>Loading profile...</h1>}
    >
      <ProfileDetails resource={resource} />
      <Suspense
        fallback={<h1>Loading posts...</h1>}
      >
        <ProfileTimeline
          resource={deferredResource}
          isStale={deferredResource !== resource}
        />
      </Suspense>
    </Suspense>
  );
}

本示例,最终出来的效果就是:用户点击“next”按钮,他等了 300ms 看到 <ProfileDetails>组件的更新结果,然后再等 700ms 才看到 <ProfileTimeline>组件的更新结果。

2.我们想优先响应用户的交互行为

当我们面临依赖同一个 state 的两个组件的渲染耗时存在比较大的差异时,如果我们认为用户当前正在交互的界面的渲染比较重要(应该这么认为),而其他部分的 UI 实时渲染没那么重要,那么我们就也可以用非一致性渲染。针对这个场景,官方就针对这种场景给了一个示例。个人认为,这是一个最适合使用useDeferredValue API 的场景了。

企业微信截图_16404898291110.png

在这个示例中,用不用 useDeferredValue API 所出来的用户体验的差异还是很明显的。不用之前,因为是采用同步渲染技术,而这里的搜索结果列表的渲染是很耗时的,最终呈现出来的体验是,用户输入的字符被输入框“吃掉了”,等了一会,它才跟搜索结果一块“吐出来”。这种体验其实就是「卡顿」。用useDeferredValue API之后,界面的交互顺畅了很多,输入框能够实时地显示出我的输入字符。但是如果是眼尖的用户还是能够看到一些不足 - 搜索结果列表的渲染还是没有跟我的实时输入同步,总是有那么一点滞后。对于这种结果,react 团队认为,这种用户体验我们是可以接受的。因为当用户在输入框输入的时候,用户的注意力是集中在输入框。只有当用户输入完毕之后,才会将注意力转移到搜索结果区域,而此时,我们也完成了对搜索结果列表的渲染了。

针对这种场景的解决方案,你好像想到了什么。对,就是 「debounce 技术」。从表面上来看,useDeferredValue 和 debounce 技术都是只关心用户连续输入动作的最后一帧,然后拿最后一帧的用户输入来渲染页面。其实,这两者是有比较大的区别:

  • 实现的原理不一样。debounce 技术是通过防止过多地执行耗时渲染任务来实现的。而useDeferredValue 则是将耗时渲染任务暂时暂停,然后等到线程空闲的时候再恢复执行。
  • 无论在什么环境,debounce 技术的表现都是一样的,具有一个固定的延迟渲染时间,而useDeferredValue的延迟渲染时间是不确定的。它的表现会跟当前的网络环境和用户设备性能来挂钩。在网络好或者高性能的用户设备上,出来的效果可能跟流畅的同步渲染效果是一样的。在网络差或者低性能的用户设备上,它可能则会表现得跟 debounce 技术 一样。
小结

这里,我们针对useDeferredValue API 小结一下。

useDeferredValue本质上在 react 并发渲染架构下进行渲染任务优先级编排的 API。 在屏幕的每一帧渲染过程中,浏览器留给 js 执行的时间是相对固定并有限的(大概是 6.66ms),通过将使用了 deferred value 的那个组件的渲染节点挪到当前 event loop 空闲的时候或者下一个 event loop 来执行的方式,避免了当前因为资源竞争所带来的渲染卡顿的后果,也因此相对地提高了正常消费 state 的那个组件的渲染优先级。

于此同时,useDeferredValue API跟 debounce 技术是不同的。不同点是:

  • 两者的实现的原理不一样;
  • useDeferredValue 的延迟渲染时间是根据当前环境动态变化的,而 debounce 技术的延迟渲染时间是固定的。

SuspenseList

SuspenseList 实际上是一个组件:<SuspenseList>。通过用<SuspenseList> 组件包裹多个<Suspense> 组件,我们能够控制<Suspense> 组件在页面上展现的先后顺序,从而解决当多个 suspense boundary 被 resolve 时间不一致性所带来的页面语义的不一致性。

具体来讲,我们可以通过设置<SuspenseList>revealOrder属性值来达到上面的这个目的。 revealOrder 属性,顾名思义,就是“展现的顺序”。这个属性有三个值:

  • forward
  • backwards
  • together

上面的这些值的语义都是相对<Suspense> 组件正在组件树出现的先后顺序而言的。如果你想你的<Suspense> 组件按照它们在组件树中出现的先后顺序来渲染的话,那么你就是用“forward”,你如果你想组件树中处于较后的那个<Suspense> 组件优先渲染的话,那么你就用“backwards”。如果你想所有的<Suspense> 组件同时出现,那么你就用“together”。

这里,我们得理解我们上面所提到的“优先展示”的准确含义。拿官方给出的示例举个例子,假如你有以下代码:

function ProfilePage({ resource }) {
  return (
    <SuspenseList revealOrder="forwards">
      <ProfileDetails resource={resource} />
      <Suspense
        fallback={<h2>Loading posts...</h2>}
      >
        <ProfileTimeline resource={resource} />
      </Suspense>
      <Suspense
        fallback={<h2>Loading fun facts...</h2>}
      >
        <ProfileTrivia resource={resource} />
      </Suspense>
    </SuspenseList>
  );
}

上面的revealOrder属性值为“forwards”,那么是不是意味着一定是先展现 <ProfileTimeline> 再展示 <ProfileTrivia> 呢?答案是:“不是的”。因为这里“forwards”意思只是「优先」展示在组件树中排在前面的组件。这里的另外一层含义是:排在组件树后面的组件渲染耗时比前面的渲染耗时少,最后的 UI 效果是,前后两者是一起展现出来的。这就是「优先」的具体含义。对于revealOrder属性值为“backwards”也是一样的,这里就不赘述了。

<SuspenseList> 还有一个叫 tail的属性,当它的值为“collapsed”的时候,同一时间内,整个<SuspenseList>内最多只会显示一个 fallback UI,至于显示哪个 <Suspense> 组件的 fallback UI,具体就看的你的revealOrder属性值。这里面的展示逻辑跟revealOrder属性值的语义是也一致的。

最后,值得一提的是,<SuspenseList>还能嵌套。一旦多重嵌套起来,理解组件的展现顺序就变得复杂了,react 团队还真会搞事情。

note: 截止 2021-12-27, SuspenseList API 已经从 react 18 的 RC npm 包里面移除,如果要体验的话,需要安装react@experimental npm 包,而不是react@next npm 包,详见这个 issue

New APIs for Libraries

  • useId()
  • useSyncExternalStore()

useId()

useId() - 生成一个全局唯一的 id 。

这个 API 之前@lunaruan实现了一版(之前 API 名叫“useOpaqueIdentifier”),但是鉴于其功能有限性和一些已知的 bug,再加上服务端流式渲染是乱序传输 HTML 到客户端的,也需要一个动态生成唯一 id 的特性,借此契机,react 团队就实现了这个 API,打算在 react 18 中发布。

useSyncExternalStore()

seSyncExternalStore 这个 API 一开始是叫 “useMutableSource”。从它的 RFC 来看,它提出的初衷主要是为第三方库(主要是 redux/relay)服务的。它的目的是想实现了当「组件所依赖的外部可变对象的某个属性值」发生了改变的时候,那么 react 就会正确地重新渲染当前的组件(这不就是精确的,细粒度的组件更新机制吗?有点像 vuejs 的更新机制啊)。

后面,因为社区在试用的过程中发现了该 API 难以整合,并且发现了一些 bug,react 团队决定对之前的 API 进行改进,并将 API 名字重命名为 “useSyncExternalStore”。在这个 API 改版中,react 团队删除之前配套seSyncExternalStore使用的 createMutableSource API, 同时精简了该 API 函数的签名,整个 API 变得清爽起来的。我们可以从官方 「react - 18」 这个 working group 的讨论中所给出的示例一窥useSyncExternalStore() API 的风采:


import {useSyncExternalStore} from 'react';

// We will also publish a backwards compatible shim
// It will prefer the native API, when available
import {useSyncExternalStore} from 'use-sync-external-store/shim';

// Basic usage. getSnapshot must return a cached/memoized result
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);

New Streaming Server Rendering Archetecture

老 SSR 架构

首先我们得知道,服务端渲染(Server Side Rendering,此文简称“SSR”)在 react 18 之前的已有的实现。这里我就不介绍什么是 SSR,直接介绍一下在 react 18 之前,开发者是通过消费什么 API 来实现 SSR的。

在 react 18 之前, 开发者实现 SSR 无非就是两板斧:

  • 在服务端使用 ReactDOMServer 暴露的渲染 API。其中,这样的 API 又存在四个:
    • renderToString()
    • renderToStaticMarkup()
    • renderToNodeStream()
    • renderToStaticNodeStream()
  • 在浏览器端使用 ReactDOM 暴露的 hydration API:

我们可以根据我们的业务场景(静态网站生成,首屏渲染加速)来随意组合 ReactDOMServer 渲染 API和hydrate()来实现我们自己的 SSR 框架。其中renderToNodeStream()renderToStaticNodeStream()是属于流式渲染框架里面的,而renderToString()renderToStaticMarkup()则算是非流式渲染。

也许你有疑惑:“流式渲染和非流式渲染有什么区别吗?”。个人理解是:它们的区别在于传输性能上。如果交付到浏览器的首屏 HMTL 文件不是很大的时候,两者之间没有明显的差异。只有 HTML 文件大于某个值的时候(比如说1M),如果你用流式渲染的话,那么 react 就会使用一个继承于 stream.Readable的渲染器来将 HTML 切片传输(即边渲染,边传输),而非流式渲染则不会这样。它会在渲染工作完全完成之后,一次性将所有的 HTML 传输给浏览器。

既然已经有了(老的)流式渲染,为什么还要推出新的流式渲染架构呢?答案比较显而易见:“老流式渲染存在明显的缺陷。”其实不但是老的流式渲染框架有问题,非流式渲染也有问题,所以,下面统称为“老 SSR 框架”。那么, 老 SSR 框架有什么缺陷呢?

下面,我们来看看之前的老 SSR 框架的整体渲染流程:

老 SSR 框架时序图

传统 SSR 时序图.png

从上面的时序图来看, 老 SSR 框架的渲染流程是一个 waterfall 般的阻塞模型 - 只有上一个阶段完成了,下一个 SSR 阶段才能开始。Dan 叔对老 SSR 框架的缺陷作出如下总结:

  • You have to fetch everything before you can show anything

    1. 服务器只有获取完所有的 API 数据才会开始渲染出 HTML,
    2. 浏览器只有获取完所有的 HTML 片段,
    3. 用户才能看到界面。否则的话,看到的就是白屏。
  • You have to load everything before you can hydrate anything

    1. 只有加载完所有的 HTML 片段 和 js 资源
    2. 客户端的 react 组件树才会进入 hydration 阶段。
  • You have to hydrate everything before you can interact with anything

    1. 只有客户端所有的 react 组件都完成了 hydration,
    2. 整个页面才会处于可交互状态。

这种 waterfall 模型的 SSR 框架,很容易产生木桶效应 - 一旦整体流程中的某个环节比较慢,它就会阻塞整个渲染流程,导致最终的渲染速度也变慢。也就是说,整体的 SSR 性能表现很大程度上取决于最慢渲染环节的性能表现。不让 SSR 渲染流程陷入到木桶效应中,这就是 react 团队在新的(流式) SSR 框架中要达成的目标

新 SSR 架构

新 SSR 框架是通过支持在服务端使用<Suspense>组件来将 react 组件树划分为多个独立的 UI 单元。每 UI 单元拥有自己独立的「渲染」->「streaming」->「HTML 加载和解析」->「JS 加载和解析」->「hydration」流程,于此同时,UI 单元之间的相同的阶段之间也是独立的,不存在先后顺序的那种相互阻塞的关系。各个独立 UI 单元就好像是跑在单独的“线程”之上。如果说,老 SSR 框架是属于「同步单线程」架构,那么新 SSR 框架就是「异步多进程」架构。

下面,我们来看看新 SSR 框架的时序图:

新 SSR 框架时序图

reac-18 新 SSR 架构时序图.png

1. 用户在浏览器输入 url
2. 浏览器发出首屏 HMTL 页面请求
3. renderToPipeableStream()
4. 渲染不依赖外部 API 数据那部分组件

    - 4.1 - 返回快的那部分组件的 HTML 片段
    - 4.2 - 页面非完整地,初次可见
    - 4.3 - 加载和解析 HMTL
    - 4.4 - 发起 main bundle js 的请求
    - 4.5 - 返回 main bundle js 文件
    - 4.6 - 加载和解析 main bundle js
    - 4.7 - 快的那部分组件的 HMTL 的 hydration
    - 4.8 - 页面初次,非完整地可交互
    
5. 渲染依赖外部 API 数据的那部分组件(也就是说用`<Suspense>`组件包裹的组件)

    - 5.1 - 发起 API 请求
    - 5.2 - 返回渲染所需要的 json 数据
    - 5.3 - Streamimg  in 慢的那部分组件的 HTML
    - 5.4 - 把慢的那部分组件的 HTML 放回到正确的位置
    - 5.5 - 页面完整可见
    - 5.6 - 请求慢的那部分组件的 HTML hydration 所需要的 chunk js
    - 5.7 - 返回 chunk js
    - 5.8 - 加载和解/执行 chunk js
    - 5.9 - 慢的那部分组件的 HMTL 的 hydration
    - 5.10 - 页面完整地可交互

对比老 SSR 框架时序图,我们可以看到新架构现实了它的目标:克服渲染流程的木桶效应,使得用户更快地看到界面,更早地与之交互。

从概念和术语上来阐述的话,新 SSR 架构是由三个特性来支撑的:

  • 局部的 HTML streaming(Streaming HTML
  • 局部的 hydration(Selective Hydration
  • 多个 hydration 任务优先级调优

note: 官方只提出说两个特性:「局部的 HTML streaming」和「局部的 hydration」。他们把「多个 hydration 任务优先级调优」这个特性归纳到「局部的 hydration」里面。但是,我个人觉得可以单独拎出来讲。

局部的 HTML streaming

关于 HTML streaming 的实现,之前是只有把组件所有依赖的外部 API 数据请求回来,才能继续推进 react 组件树在服务端的渲染流程,也只有完成了整颗 react 组件树的渲染工作,产出最后的,完整的 HTML 文档,服务端才会开始 streaming。

现在不一样了。通过启发式算法,react 鼓励开发者用<Suspense>组件对需要消耗外部 API 数据的组件进行包裹,建立一个 suspense boundary。这样一来,react 就会将 suspense boundary 之外的组件(上面我称之为“快的那部分 HTML”)快速渲染完毕,然后不需要等待<Suspense>组件的渲染内容(上面,我们称之为“慢的那部分 HTML”)的完成就直接返回给浏览器端。如果一来,快的那部分 HTML就不会被慢的那部分 HTML 所拖累。相对“全量返回”的做法,用户就能更快地看到页面内容。

通过<Suspense>组件对渲染快慢的组件进行分离,优先交付快的那部分 HTML 给用户,此谓之为“局部的 HTML streaming”。

也许你会问,那慢的那部分 HTML 最终是怎么交付的啊?从 Dan 叔给出的 demo 的实现来看,慢的那部分组件最终的渲染产物是:

  • HTML 片段
  • JS 代码

这些渲染产物会最终被追加(streaming back in)到 HTML 文档的尾部,如下截图:

suspense-component-SSR-output.png

最后,JS 代码会通过准确的 DOM 操作来将原本在文档流中隐藏的 HTML 片段填充回正确的位置。

局部的 hydration

在老 SSR 框架中,react 组件树是作为不可切割的整体而存在的,react 组件树在服务端会产出整体的 HTML。这个整体的 HTML又有着与之对应的的整体 hydration 所需的 JS 资源.只有整体的 JS 资源加载完毕,整个页面才能开始 hydration。

在新 SSR 框架中,我们通过<Suspense>组件在 react 组件树中划分出一个区别快与慢的清晰边界。快的那部分组件它会有自己的 HTML 片段和用于 hydration 的 JS 代码,慢的部分它也会有自己的 HTML 片段和用于 hydration 的 JS 代码。无论是快的那部分还是慢的那部分,只要相应的 JS 资源加载完毕,它就可以开始 hydrate 了,它不需要等待另外那部分 UI 的 hydration 所需的 JS 资源的加载完毕才开始。因为,就如上面说的那样,不同的 UI 单元的 hydration 所需的 JS 资源的加载和执行都是独立的,互不依赖的。

以上就是“局部的 hydration”的含义。

多个 hydration 任务优先级动态调整

当不同的 UI 单元 hydration 所需要的 JS 资源都加载完毕。react 18 的新 SSR 框架能够根据用户的交互动作来确定哪一个 UI 单元的 hydration 任务的优先级更高。

就拿 Dan 叔给出的例子来说,sidebar 和 comment 组件的 hydration 所依赖的 JS 资源已经加载完毕了。在没有用户的交互之前,两个 hydration 任务会按照组件在组件树中出现的顺序来执行。但是,假如现在用户点击了 comment 组件里面的一条 comment,那么,react 会把当前的点击事件相关信息记录下来,与此同时提高 comment 组件的 hydration 的优先级,然后优先 hydrate comment 组件。当 comment 组件 hydrate 完毕,react 会在合成事件系统里面重新 dispatch 相同的 click 事件,以此响应用户交互。响应完了用户的交互行为,react 才会接着去 hydrate sidebar 组件。

以上说的是 hydration 任务优先级被提高的情况,还有一种优先级被降低的情况。

假如一个 UI 单元(比如说 sidebar 组件)已经完成了hydration,而另外一个(比如说 comment 组件)正在进行时,此时如果用户点击了 sidebar 组件跳转到另外一个页面的<a>标签的话,那么 react 会中断当前的 hydration 任务,转而响应用户的交互,从当前页面跳走。换句话说,为了优先响应用户的点击事件,react 会将当前的 hydration 任务降低。换用官方的说法是:“以前,hydration 任务是会阻塞用户的交互。只有所有的组件的 hydration 都完成了,页面才具备可交互性。现在不一样了, 有了基于 fiber 的并发渲染架构,hydration 任务是不会阻塞(这里是相对而言,因为细粒度的局部 hydration 是很快的,用户觉察不到这种阻塞)用户的交互。”

有了局部 hydration 和 hydration 任务优先级智能调整,如果你用<Suspense>组件划分组件树的粒度越小,那么这局部 UI hydration 所需要的时间也是越小的。JS 的执行本来就足够快,所以这个时候就会出现一种用户体验 - 「即时 hydration」。“即时 hydration”无疑能大大提提高了用户体验的细腻度。

总结

上面介绍的细节已经够多了,下面我用一句话来概括一下 react 18 的精神要领:

  • No “All or Nothing”

能够很好地体现这个精神要领的有以下两点:

  • 升级策略的变迁 - 去 concurrent mode,而是采用 concurrent feature 来支持开发者按需使用并发特性
  • SSR 架构的升级- 在页面的可见和可交互性两个方面进行尽快的/局部的交付
    • 局部的 HTML streaming
    • 局部的 hydration
    • 多个 hydration 任务优先级动态调整

最后,通过 react 18,我们能看到 react 未来的两大进化方向

  • 极致 UX 的探索

  • 以 react 为中心的 web 全栈框架/跨平台架构

而这一切都是建立在 fiber reconcile 基础架构和并发渲染特新之上。逼乎上有不少人说并发渲染是 react 团队的过度设计。个人觉得,这观点可以是对的,也可以是错的。这取决于你是从什么角度出发思考这个问题的。我的理解,react 的很多设计其实源于 facebook 在大规模项目进行企业级开发上的需求,人家有人力和物力来支撑其对用户体验和生产力的极致追求。从 facebook 这种体量的公司来看,react 的并发渲染并不是过度设计。

但是,对于国内的大部分公司来说,我们单单是功能实现都忙不过来,更不过说只是锦上添花的用户体验了。这是大环境和大氛围。除非是由自我驱动意识或者上级的硬性要求,否则,用户体验做不做都无所谓。因为,绝大部分情况下,将用户体验做好,这事不会写到 OKR 或者 KPI 里面,我花了心血去做好它,对我没有任何利好。因此,我根本不不需要支持并发渲染的 react 18(react 16 已经很香了)。从这个角度来看,认为 react 的并发渲染确实是过度设计也是合情合理的。

react 18 估计会在 2022 年初发布,它无疑是 react 发展史上的一个里程碑。让我们一起翘首以盼吧,看看一个新世界是怎样到来的。

参考资料