你不知道的React 18 新特性

根据React 18的介绍,最新的React将带来主要3个方面的新特性:

  • 自动批处理state更新

  • 支持Suspense的新SSR架构

  • Concurrent features

下面我们将逐一进行简要的介绍和解读。

Automatic batching(批处理)

批处理是指是React能够将多个状态更新合并到单个re-render中一次性更新以获得更好的渲染性能。

比如下面这段:

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1); // 不触发re-render
    setFlag(f => !f); // 不触发re-render
    // 在调用结束是只触发一次re-render(这就是batching)
  }

  return <button onClick={handleClick}>Next</button>;
}

但是在React 18以前,对于在异步回调中调用的updates,React Batching将无法生效:

function handleClick() {
  fetchSomething().then(() => {
    setCount(c => c + 1); // 触发re-render
    setFlag(f => !f); // 再次触发re-render
  });
}

这是因为在React 18之前,React只在事件处理程序期间批量更新。默认情况下,React不会对promise、setTimeout或任意event事件中的更新进行批处理。

跳过批处理

通常情况下,React 18的批处理是安全的,而且是框架层面自动处理,开发者无需过多关注。但某些情况下可能依赖于在状态更改后立即从 DOM 中读取某些内容。对于这些case,可以使用ReactDOM.flushSync()选择跳过批处理:

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}
类组件的breaking change

类组件有个实现上的怪异,它可以同步读取事件内部的状态更新。这意味着可以在setState之间读取到最新的state:

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

    console.log(this.state.count); // => 1

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

在 React 18,情况不再如此。由于即使在setTimeout中的所有更新都是批处理的,因此React不会同步渲染第一个setState的结果——渲染发生在browser nextTick,所以在setState之间读取state将获取不到上一个setState的结果:

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

    console.log(this.state.count); // 0 依然是初始值

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

这是一个不太常用的api,通常一些库会使用它来强制对事件之外的setState进行强制批处理:

import { unstable_batchedUpdates } from 'react-dom';

unstable_batchedUpdates(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
});

React 18将保留unstable_batchedUpdates,但开发者无需再手动调用它了,后续的主版本可能会移除此api。

关于批处理的详细内容,可以参考Automatic batching for fewer renders in React 18

新的Suspense SSR架构

除了Concurrent Mode即将正式随18正式发布之外,SSR功能也将带来全新的架构升级。

传统React SSR

在React 18之前,React SSR架构通常分以下几个环节:

  • server端,解析server entry并fetch所需数据(通常的做法是在Component上挂载静态方法,server直接调用)。

  • 数据准备好之后,调用ReactDOM/server renderToString api将组件渲染成string形式作为response返回

  • client等待2中的response text,渲染到页面上,开始加载entry assets。

  • client等entry js加载完成后,执行entry js,合成(hydrate)最终的页面内容(渲染剩余内容、挂载js事件等)。

// server.js
import { renderToString } from 'react-dom/server';

import App from '@/components/App';

app.get('/', async (req, res) => {
  const postId = req.query.postId;
  const post = await fetchPost(postId);
  const comments = await fetchComments(postId);
  const reactStr = renderToString(<App post={post} comments={comments} />);

  const html = `<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>title</title>
  </head>
  <body>
    <div id="root">${reactStr}</div>
    <script src="client.js"></script>
  </body>
  </html>`;

  return res.send(html);
});
// client.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from '@/components/App';

ReactDOM.hydrate(
  <App />,
  document.getElementById('root')
);

在实际项目中可能会做大量的工程和框架上的改进,但核心的原理正如上面的示例代码所示。

传统SSR架构里边,上面列举的几个环节每一个环节都依赖上一个的执行结果,如果某一个环节特别慢会拖慢整体的渲染速度。例如首屏内容依赖server端准备好数据,但如果获取数据超时则client端依然会有很长的白屏时间,依然解决不了体验(性能)问题。

Streaming HTML and Selective Hydration

中文翻译:流式传输HTML + 选择性水合

Suspense解锁了React 18中有两个主要的SSR特性:

  • 在server端流式传输HTML:使用全新的pipeToNodeWritable api来搭建从server端到client的流式渲染通道,可以持续不断地向client输出已经ready的html区块,而不需要像传统SSR那样需要整体ready之后通过renderToString一次性输出。

  • client端进行选择性水合(hydration):将组件(或者widget)使用组件进行包裹,这样它们就不会阻塞渲染,在组件内容数据ready之前,框架将渲染通过fallback参数指定的内容。

server端代码调整:

// server.js
import { pipeToNodeWritable } from 'react-dom/server';

import App from '@/components/App';

app.get('/', async (req, res) => {
 let didError = false;
 const data = fetchData();
 const { startWriting } = pipeToNodeWritable(
   <DataProvider data={data}>
     <App />
   </DataProvider>,
   res,
   {
     onReadyToStream() {
       res.statusCode = didError ? 500 : 200;
       res.setHeader('Content-type', 'text/html');
       res.write('<!DOCTYPE html>');
       startWriting();
     },
     onError(x) {
       didError = true;
     },
   }
 );
});

Suspense + lazy组合(分区块)渲染:

// App.js
import { Suspense, lazy } from 'react';

const Comments = lazy(() => import('./Comments'));
const Post = lazy(() => import('./Post'));

function App() {
  return (
    <section className="post">
      <Suspense fallback={<Spinner />}>
        <Post />
      </Suspense>

      <section className="comments">
        <h2>Comments</h2>
        <Suspense fallback={<Spinner />}>
          <Comments />
        </Suspense>
      </section>
    </section>
  );
}

然后在组件内部读取数据:

// Post.js
function usePostData() {
  // DataContext就是上面的DataProvider创建的context
  const ctx = useContext(DataContext);
  if (ctx !== null) {
    // 如果数据还没拿到,会throw promise
    ctx.read();
  }
  return null;
}

export default function Post() {
  const post = usePostData();
  return post ? (
    <>
      <h1>{post.title}</h1>
        <article>{post.content}</article>
    </>
  ) : null;
}

Comments组件同理。

可以看到,在新的SSR架构下,能够实现页面上的组件单独render,React无需等待所有组件“ready”后再展现给用户,实现了页面的分片渲染。

以上Features使得传统SSR模式的一些瓶颈得到解决:

  • server端无需等待所有数据(和HTML)都ready再响应客户端,相反地,当应用骨架准备好时你就可以发送给客户端显示,剩余的内容将在它们ready之后流式传输给客户端。

  • client端来说,不再需要等待所有 JavaScript 加载完毕才能开始渲染。可以结合code spliting与SSR一起使用,在server端的HTML(片段)将被保留(在server),当相关代码加载时,React将对其进行合成。

  • 不再需要等待所有组件都加载完成才运行用户交互。相反,可以依靠Selective Hydration 来确定用户与之交互的组件的优先级。

Streaming HTML(对应pipeToNodeWritable)这个特性其实并不是React首创,事实上熟悉互联网历史的同学可能或多或少接触过BigPipe技术,它最早是facebook先提出来的,原理就是通过server端生成html(或者js片段),通过预先内置的runtime sdk解析下载的片段并执行。不过BigPipe技术没有流行起来是因为它依赖了非前端的一些技术栈,没有和前端框架、工程体系结合起来,而React 18的SSR做到了和开发框架的完美融合,在框架层面实现了片段的生成和装载(hydrate),这使得一些需要动态能力的场景多了一种全新的技术选型(比如投放)。

Selective Hydration(对应hydrateRoot以及)表明hydration过程也是逐步进行的,不会一下子执行完所有js导致页面卡顿。正常情况下,框架会根据node tree的顺序执行hydration,但React会监听用户行为事件(如click),提升被用户点击的区块在hydration的优先级。

总结下,Suspense SSR架构的核心原理:渐进式SSR(Progressive SSR) + 部分非阻塞式水合(Partial non-blocking hydration)。它把一张页面分割成了无数个「插槽」区块,然后server端可以持续不断地将已经ready的区块HTML返回给client,client端根据优先级决定合成哪些区块,从而带来相对完善的用户体验效果。

New root apis

在React 18以前的版本中,root元素被绑定(attached)到了DOM根节点上,它对用户是不可见的:

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

// 初始化 render.
ReactDOM.render(<App tab="home" />, container);

// 为了更新app,你不得不保留container的引用
ReactDOM.render(<App tab="profile" />, container);

React 18带来了更加语义化的root api:

// 创建root节点
const root = ReactDOM.createRoot(container);

// 初始化:将组建渲染到root
root.render(<App tab="home" />);

// 更新组件时,无需再关注container的引用了,因为它已经提前绑定在root上
root.render(<App tab="profile" />);

SSR(hydrate)root api:

// 旧的api
ReactDOM.hydrate(<App tab="home" />, container);

// 新的root api
ReactDOM.hydrateRoot(container, <App tab="home" />);

注意,和createRoot不同的是,hydrateRoot的第二个参数就需要传入初始化jsx(而无需调用root.render()),这是因为初始client端渲染是特殊的,需要与server端的节点树匹配。

首次hydration之后如果想更新root,可以调用render:

// You can later update it.
root.render(<App tab="profile" />);

React 18新的root api移除了render的callback,原因是Suspense SSR采用了渐进式SSR以及部分水合,回调的时机可能与用户预期不符,为了避免混淆,React推荐的做法是使用requestIdleCallback、setTimeout或者在root上ref回调:

// 旧的root api,直接通过render参数传入callback
ReactDOM.render(container, <App />, function() {
  console.log('rendered').
});

// 新的root api,通过ref传入render callback
const root = ReactDOM.createRoot(container);
root.render(<App callback={() => console.log("renderered")} />);

function App({ callback }) {
  // 当div第一次被创建时callback将会被调用
  return <div ref={callback}>...</div>;
}
Concurrent new feature: startTransition

React 18以前,Concurrent Mode一直处于实验性阶段,React将正式发布Concurrent功能。Concurrent Mode简单说就是一种非阻塞UI、可中断式的渲染架构。它可以灵活调度渲染任务以避免js长时间执行导致UI进程无法响应。

React将状态更新分为两类:

  • 紧急更新(Urgent updates):反映直接的交互,如输入、点击、按键按下等等。

  • 过渡更新(Transition updates):将UI从一个视图过渡到另一个视图。

输入、点击、按键按下等需要立即响应以符合人类的物理认知,符合人的直觉。但是过渡更新却不同,用户不会期望看到中间的转换过程(只需要结果),因此可能不需要立即更新视图。

在React 18以前的版本以及React 18默认情况下(为了向前兼容),所有的更新都会认为是紧急更新。而startTransition提供api给用户来手动将某些更新标记为非紧急更新,从而避免浪费时间去渲染不必要的内容。

如下面的例子:

// 在界面上显示用户输入
setInputValue(input);

// 在界面上呈现查询结果
setSearchQuery(input);

setInputValue会立即更新用户的输入到界面上,属于需要紧急更新的操作。setSearchQuery是根据用户输入,查询相应的内容,用户可以输入很多次,如果一直查询会可能会导致过多的js计算消耗在查询上面(甚至阻塞输入操作),而一般来说用户的期望值是「等待输入完成之后,查询并显示最终的结果」,因此这里的setSearchQuery可以看成是非紧急更新。

通过startTransition包裹setSearchQuery将其标记为非紧急更新:

setInputValue(input);

// 标记为非紧急更新
startTransition(() => {
  React.setSearchQuery(input);
});
和setTimeout的区别

就上面setSearchQuery的例子,使用setTimeout(或者debounce or throttle)也能达到相似的目的,那这个startTransition和setTimeout有啥区别?

  • 一个重要区别是setTimeout是「延迟」执行,startTransition是立即执行的,传递给startTransition的函数是同步运行,但是其内部的所有更新都会标记为非紧急,React将在稍后处理更新时决定如何render这些updates,这意味着将会比setTimeout中的更新更早地被render。

  • 另一个重要区别是用setTimeout包裹的如果是内大面积的更新操作会导致页面阻塞不可交互,直到超时。这时候用户的输入、键盘按下等紧急更新操作将被阻止。而startTransition则不同,由于它所标记的更新都是可中断的,所以不会阻塞UI交互。即使用户输入发生变化,React也不必继续渲染用户不再感兴趣的内容。

  • 最后,因为setTimeout是异步执行,哪怕只是展示一个小小的loading也要编写异步代码。而通过transitions,React可以使用hook来追踪transition的执行状态,根据transition的当前状态来更新loading。

追踪transition的执行状态

useTransition hook:

function App() {
  const [isPending, startTransition] = React.useTransition();

  startTransition(() => {
    // 标记非紧急更新
  });

  return isPending ? <Spinner /> : <Content />;
}

总结:可以使用startTransition来wrapper想要移动到后台执行的任何更新。通常,这些类型的更新分为两类:

  • 慢渲染(Slow rendering):那些需要耗费大量的时间的UI转换任务。

  • 弱网络(Slow network):网络请求更新也可以标记为非紧急更新。这与Suspense能力密切集成。

这里有个利用startTransition来优化用户体验的例子,几个视频看下来可以说很直观体现出startTransition的效果了。

Refs

  • Introducing React 18

  • Automatic batching for fewer renders in React 18

  • New Suspense SSR Architecture in React 18

  • Replacing render with createRoot

  • Upgrading to React 18 on the server

  • New feature: startTransition

  • Real world example: adding startTransition for slow renders

  • Everything New in React 18

推荐阅读

(点击标题可跳转阅读)

RxJS入门

一文掌握Webpack编译流程

一文深度剖析Axios源码

Javascript条件逻辑设计重构
Promise知识点自测

你不知道的React Diff
你不知道的GIT神操作
程序中代码坏味道(上)

程序中代码坏味道(下)

学习Less,看这篇就够了

一文掌握GO语言实战技能(一)

一文掌握GO语言实战技能(二)

一文掌握Linux实战技能-系统管理篇

一文掌握Linux实战技能-系统操作篇

一文达到Mysql实战水平

一文达到Mysql实战水平-习题答案

从表单抽象到表单中台

vue源码分析(1)- new Vue

实战LeetCode 系列(一) (题目+解析)

一文掌握Javascript函数式编程重点

实战LeetCode - 前端面试必备二叉树算法

一文读懂 React16.0-16.6 新特性(实践 思考)

阿里、网易、滴滴、今日头条、有赞.....等20家面试真题

30分钟学会 snabbdom 源码,实现精简的 Virtual DOM 库


觉得本文对你有帮助?请分享给更多人

关注「React中文社区」加星标,每天进步

bf7f74eff7b311eea69a592783e1da30.png

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值