React 18 全览

共 10310字,需浏览 21分钟

 ·

2022-04-19 12:07

在 2021 年 6 月 8 号,React 公布了 v18 版本的发布计划,并发布了 alpha 版本。经过将近一年的发布前准备,在 2022 年 3 月 29 日,React 18 正式版终于和大家见面了。

React 18 应该是最近几年的一个重磅版本,React 官方对它寄予了厚望。不然也不会将 React 17 作为一个过渡版本,也不会光发布准备工作就做了一年。

在过去一年,我们已经或多或少了解到一些 React 18 的新功能。这篇文章我会通过丰富的示例,向大家系统的介绍 React 18 带来的改变。当然本文融入了很多个人理解,如有不对,烦请指正。

Concurrent Mode

Concurrent Mode(以下简称 CM)翻译叫并发模式,这个概念我已经听了好多年了,并且一度非常担忧

  • React 官方憋了好多年的大招,会不会是一个破坏性不兼容的超级大版本?就像 VUE v3 和 v2。
  • 现有的生态是不是都得跟着大版本升级?比如 ant design,ahooks 等。

随着对 CM 的了解,我发现它其实是人畜无害的。

CM 本身并不是一个功能,而是一个底层设计,它使 React 能够同时准备多个版本的 UI

在以前,React 在状态变更后,会开始准备虚拟 DOM,然后渲染真实 DOM,整个流程是串行的。一旦开始触发更新,只能等流程完全结束,期间是无法中断的。

在 CM 模式下,React 在执行过程中,每执行一个 Fiber,都会看看有没有更高优先级的更新,如果有,则当前低优先级的的更新会被暂停,待高优先级任务执行完之后,再继续执行或重新执行。

CM 模式有点类似计算机的多任务处理,处理器在同时进行的应用程序之间快速切换,也许 React 应该改名叫 ReactOS 了。

这里举个例子:我们正在看电影,这时候门铃响了,我们要去开门拿快递。在 React 18 以前,一旦我们开始看电影,就不能被终止,必须等电影看完之后,才会去开门。而在 React 18 CM 模式之后,我们就可以暂停电影,等开门拿完快递之后,再重新继续看电影。

不过对于普通开发者来说,我们一般是不会感知到 CM 的存在的,在升级到 React 18 之后,我们的项目不会有任何变化

我们需要关注的是基于 CM 实现的上层功能,比如 Suspense、Transitions、streaming server rendering(流式服务端渲染), 等等。

React 18 的大部分功能都是基于 CM 架构实现出来的,并且这这是一个开始,未来会有更多基于 CM 实现的高级能力。

startTransition

我们如果要主动发挥 CM 的优势,那就离不开 startTransition。

React 的状态更新可以分为两类:

  • 紧急更新(Urgent updates):比如打字、点击、拖动等,需要立即响应的行为,如果不立即响应会给人很卡,或者出问题了的感觉
  • 过渡更新(Transition updates):将 UI 从一个视图过渡到另一个视图。不需要即时响应,有些延迟是可以接受的。

我以前会认为,CM 模式会自动帮我们区分不同优先级的更新,一键无忧享受。很遗憾的是,CM 只是提供了可中断的能力,默认情况下,所有的更新都是紧急更新。

这是因为 React 并不能自动识别哪些更新是优先级更高的。

const [inputValue, setInputValue] = useState();

const onChange = (e)=>{
  setInputValue(e.target.value);
  // 更新搜索列表
  setSearchQuery(e.target.value);
}

return (
  <input value={inputValue} onChange={onChange} />
)

比如以上示例,用户的键盘输入操作后,setInputValue会立即更新用户的输入到界面上,是紧急更新。而setSearchQuery是根据用户输入,查询相应的内容,是非紧急的。

但是 React 确实没有能力自动识别。所以它提供了 startTransition让我们手动指定哪些更新是紧急的,哪些是非紧急的。

// 紧急的
setInputValue(e.target.value);
startTransition(() => {
  setSearchQuery(input); // 非紧急的
});

如上代码,我们通过 startTransition来标记一个非紧急更新,让该状态触发的变更变成低优先级的。

光用文字描述大家可能没有体验,接下来我们通过一个示例来认识下可中断渲染对性能的爆炸提升。

示例页面:https://react-fractals-git-react-18-swizec.vercel.app/[1]

如下图,我们需要画一个毕达哥拉斯树,通过一个 Slider 来控制树的倾斜。

那我们的代码会很简单,如下所示,我们只需要一个 treeLeanstate 来管理状态。

const [treeLean, setTreeLean] = useState(0)

function changeTreeLean(event{
  const value = Number(event.target.value);
  setTreeLean(value);
}

return (
  <>
    <input type="range" value={treeLean} onChange={changeTreeLean} />
    <Pythagoras lean={treeLean} />
  

)

在每次 Slider 拖动后,React 执行流程大致如下:

  1. 更新 treeLean
  2. 渲染 input,填充新的 value
  3. 重新渲染树组件 Pythagoras

每一次用户拖动 Slider,都会同步执行上述三步。但当树的节点足够多的时候,Pythagoras 渲染一次就非常慢,就会导致 Slider 的 value 回填变慢,用户感觉到严重的卡顿。如下图。

当数的节点足够大时,已经卡到爆炸了。在 React 18 以前,我们是没有什么好的办法来解决这个问题的。但基于 React 18 CM 的可中断渲染机制,我们可以将树的更新渲染标记为低优先级的,就不会感觉到卡顿了。



const [treeLeanInput, setTreeLeanInput] = useState(0);
const [treeLean, setTreeLean] = useState(0);

function changeTreeLean(event{
  const value = Number(event.target.value);
  setTreeLeanInput(value)

  // 将 treeLean 的更新用 startTransition 包裹
  React.startTransition(() => {
    setTreeLean(value);
  });
}

return (
  <>
    <input type="range" value={treeLeanInput} onChange={changeTreeLean} />
    <Pythagoras lean={treeLean} />
  

)

以上代码,我们通过 startTransition 标记了非紧急更新,让树的更新变成低优先级的,可以被随时中止,保证了高优先级的 Slider 的体验。

此时更新流程变为了

  1. input 更新
    1. treeLeanInput 状态变更
    2. 准备新的 DOM
    3. 渲染 DOM
  2. 树更新(这一次更新是低优先级的,随时可以被中止)
    1. treeLean 状态变更
    2. 准备新的 DOM
    3. 渲染 DOM

React 会在高优先级更新渲染完成之后,才会启动低优先级更新渲染,并且低优先级渲染随时可被其它高优先级更新中断。

当然,在低优先状态等待更新过程中,如果能有一个 Loading 状态,那就更好了。React 18 提供了 useTransition来跟踪 transition 状态。

const [treeLeanInput, setTreeLeanInput] = useState(0);
const [treeLean, setTreeLean] = useState(0);

// 实时监听 transition 状态
const [isPending, startTransition] = useTransition();

function changeTreeLean(event{
  const value = Number(event.target.value);
  setTreeLeanInput(value)

  React.startTransition(() => {
    setTreeLean(value);
  });
}

return (
  <>
    <input type="range" value={treeLeanInput} onChange={changeTreeLean} />
    <Spin spinning={isPending}>
      <Pythagoras lean={treeLean} />
    Spin>

  
)

自动批处理 Automatic Batching

批处理是指 React 将多个状态更新,聚合到一次 render 中执行,以提升性能。比如

function handleClick({
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 只会 re-render 一次,这就是批处理
}

在 React 18 之前,React 只会在事件回调中使用批处理,而在 Promise、setTimeout、原生事件等场景下,是不能使用批处理的。

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 会 render 两次,每次 state 变化更新一次
}, 1000);

而在 React 18 中,所有的状态更新,都会自动使用批处理,不关心场景。

function handleClick({
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 只会 re-render 一次,这就是批处理
}

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 只会 re-render 一次,这就是批处理
}, 1000);

如果你在某种场景下不想使用批处理,你可以通过 flushSync来强制同步执行(比如:你需要在状态更新后,立刻读取新 DOM 上的数据等。)

import { flushSync } from 'react-dom';

function handleClick({
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React 更新一次 DOM
  flushSync(() => {
    setFlag(f => !f);
  });
  // React 更新一次 DOM
}

React 18 的批处理在绝大部分场景下是没有影响,但在 Class 组件中,如果你在两次 setState 中间读取了 state 值,会出现不兼容的情况,如下示例。

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // 在 React17 及之前,打印出来是 { count: 1, flag: false }
    // 在 React18,打印出来是 { count: 0, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

当然你可以通过 flushSync来修正它。

handleClick = () => {
  setTimeout(() => {
    ReactDOM.flushSync(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
    });

    // 在 React18,打印出来是 { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

流式 SSR

SSR 一次页面渲染的流程大概为:

  1. 服务器 fetch 页面所需数据
  2. 数据准备好之后,将组件渲染成 string 形式作为 response 返回
  3. 客户端加载资源
  4. 客户端合成(hydrate)最终的页面内容

在传统的 SSR 模式中,上述流程是串行执行的,如果其中有一步比较慢,都会影响整体的渲染速度。

而在 React 18 中,基于全新的 Suspense,支持了流式 SSR,也就是允许服务端一点一点的返回页面。

假设我们有一个页面,包含了 NavBar、Sidebar、Post、Comments 等几个部分,在传统的 SSR 模式下,我们必须请求到 Post 数据,请求到 Comments 数据后,才能返回完整的 HTML。


  <nav>
    
    <a href="/">Homea>

   nav>
  <aside>
    
    <a href="/profile">Profilea>

  aside>
  <article>
    
    <p>Hello worldp>

  article>
  <section>
    
    <p>First commentp>

    <p>Second commentp>
  section>
</main>

但如果 Comments 数据请求很慢,会拖慢整个流程。

在 React 18 中,我们通过 Suspense包裹,可以告诉 React,我们不需要等这个组件,可以先返回其它内容,等这个组件准备好之后,单独返回。


  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    Suspense>

  RightPane>
</Layout>

如上,我们通过 Suspense包裹了 Comments 组件,那服务器首次返回的 HTML 是下面这样的,组件处通过 loading进行了占位。


  <nav>
    
    <a href="/">Homea>

   nav>
  <aside>
    
    <a href="/profile">Profilea>

  aside>
  <article>
    
    <p>Hello worldp>

  article>
  <section id="comments-spinner">
    
    <img width=400 src="spinner.gif" alt="Loading..." />
  section>

</main>

组件准备好之后,React 会通过同一个流(stream)发送给浏览器(res.send 替换成 res.socket),并替换到相应位置。

"comments">
  
  <p>First commentp>
  <p>Second commentp>
</div>