React 18 来了,前台端茶们即将开启新篇章

371 阅读16分钟

一、序篇

img1.png

1、React 18 会给我们带来什么

现 React 18 已经进入 RC 阶段了,大部分新特性已经基本敲定和完成,距离上线已剩下几周时间。

React 18 发版以后,会有以下变化:

⭐️ 1、改进已有的属性,如:自动批处理、改进 Suspense、组件返回 undefined 不再报错等;

⭐️ 2、全面支持 Concurrent Mode,不再是实验版功能,并且带来新的 API,如:startTransition、useDeferredValue 等。

⭐️ 3、为支持以上特性,React 18 不仅加入了多任务处理,还加入了基于优先级的渲染、调度和打断;

⭐️ 4、加入新的渲染模式,“Concurrent Render”模式,这个模式是开发者可选的,同时对开发者来说是大部分不可见的,只是解锁了 React 应用在性能提升方面的一些新特性。

2、尝鲜 React 18

创建应用
  npx create-react-app react18
  cd react18
  npm i react@bate react-dom@bate
  npm start
替换 src/index.js 的挂载方式
// 把
ReactDOM.render(<App />, document.getElementById("root"));
// 替换成
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

二、Automatic Batching

1、什么是批处理?

  • 批处理是 React 将多个状态更新分组到一个重新渲染中以获得更好的性能。
  • 在过往的 React 版本中,如果一个点击事件会触发两个状态的改变,React 总是将他们批量处理到同一个重新渲染当中。
  • 这对性能非常有用,因为避免了不必要的渲染,也防止了组件呈现一个仅更新一个状态的“半完成”状态而导致的异常错误。

2、React 18 以前,组件何时批量更新呢?

  • React 在批量更新方面并不一致。
  • 如果一次点击事件,立即触发两个状态的更新,则 React 会对此进行批量更新。
  • 如果一次点击事件,需要获取了数据,再进行两个状态的更新,则 React 并不会批量更新,而是进行两次独立的更新。
  • 在 React 18 之前,React 只在事件处理期间进行批量更新,默认情况下,Promise、setTimeout 或者其他事件内部的更新都不会在 React 中批处理。

3、什么是自动批处理?

  • React 团队希望减少工作渲染,从而提高应用程序的性能。从 React18 开始,使用 creatRoot,所有更新都将进行批处理,无论来自何处(setTimeout、Promise、addEventListener 或者其他事件内部的更新)。
  • React 会确保每个用户发起的事件,DOM 在下一次事件之前完全更新。

4、那如果不想批处理怎么办?

  • 可能在实际操作中,某些代码必须要依赖于状态更改后立即从 DOM 中读取某些内容。对于这些情况,可以使用ReactDOM.flushSync()来选择不进行批处理。
  • ⚠️ 注意:是从 ReactDOM 导入,不是 React 。
import { flushSync } from "react-dom";
const func = () => {
  flushSync(() => {
    setCount((c) => c + 1);
  });
  flushSync(() => {
    setFlag((f) => !f);
  });
};
  • 更有意思的是,加入了 flushSync 后,React 的批量更新是根据上下文来合并批量更新的。当遇到更新是普通更新,则先收集起来,暂不更新。这时如果遇到了 flushSync ,会将上文收集到的普通更新和这次 flushSync 包含的更新做一次批量更新。
import { flushSync } from "react-dom";
const func = () => {
  // 第一次批量更新
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  flushSync(() => {
    setCount((c) => c + 1);
  });

  // 第二次批量更新
  setCount((c) => c + 1);
  flushSync(() => {
    setFlag((f) => !f);
  });

  // 第三次批量更新
  setCount((c) => c + 1);
  setCount((c) => c + 1);
};

5、对 hooks 有影响吗?

React 会确保 hooks 都是自动批处理的。

6、对 class 有影响吗?

类组件在 v18 之前有一个特殊情况,可以在事件内部同步读取状态更新

// 初始状态:{count: 0, flag: false}

// class 组件
const handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));
    console.log(this.state); // {count: 1, flag: false}
    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

// function 组件
const handleClick = () => {
  setTimeout(() => {
    setCount((c) => c + 1);
    console.log(this.state); // {count: 0, flag: false}
    setFlag((f) => !f);
  });
};

在 React 18 中,这个情况将不会再出现,所有都是批处理。

// React 18
// 初始状态:{count: 0, flag: false}

// class 组件
const handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));
    console.log(this.state); // {count: 0, flag: false}
    this.setState(({ flag }) => ({ flag: !flag }));
  });
};

这也可能成为升级 React 18 的最大障碍,可以使用 ReactDOM.flushSync 强制更新,但是 React 官方强烈提醒慎用。

// React 18
import { flushSync } from "react-dom";
// 初始状态:{count: 0, flag: false}

// class 组件
const handleClick = () => {
  setTimeout(() => {
    flushSync(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
    });
    console.log(this.state); // {count: 1, flag: false}
    flushSync(() => {
      this.setState(({ flag }) => ({ flag: !flag }));
    });
  });
};

三、Concurrent Mode

1、React 三种模式

  • legacy 模式:ReactDOM.render(, rootNode),这是当前 React app 使用的方式。
  • blocking 模式:ReactDOM.createBlockingRoot(rootNode).render(),迁移到 concurrent 模式的第一个步骤。
  • concurrent 模式:ReactDOM.createRoot(rootNode).render(),这个模式开启所有 React 新功能。

img2.png

2、什么是 Concurrent Mode?

concurrent 模式是一组 React 的新功能,可以帮助应用保持相应,并根据用户的设备性能和网速进行适当调整。

在 concurrent 模式中,React 可以同时更新多个状态

  • 对于 CPU-bound 更新(如创建新的 DOM 节点和运行代码),并发意味一个更加迫切的更新可以中断已经开始的渲染。

  • 对于 IO-bound 的更新(如从网络加载数据),并发意味着 React 甚至可以在全部数据到达之前就已经在内存中开始了渲染,跳过令人不愉快的空白加载过程。(这不是双缓冲嘛?听起来就很 amazing 啊~)

    React 使用一种启发式方法决定更新的“紧急性”,并且允许你用几行代码对其进行调整,以便于每次交互中实现理想的用户体验。

    简单的说就是,concurrent 模式想做到开发者可以自定义更新任务优先级并且能够通知到 React,React 再来处理不用优先级的更新的任务,优先处理优先级高的任务,低优先级的任务可以被中断。

    concurrent 模式减少了防抖和节流在 UI 中的需求,渲染可以中断,React 不需要人为的延迟工作以避免卡顿(比如使用 setTimeout)。它可以立即开始渲染,也可以当需要保持应用响应时中断这项工作。

四、useDeferredValue

<>
  <PageOne info={info} />
  <PageTwo info={info} />
</>

无论何时去看这两个组件,它们反映的数据都来自同个 info ,因为是同个 state 更新传递下去的,它们是同时变化。

但有些时候会出现 UI 不一致的情况,比如:每次输入都要请求接口然后得到结果再渲染等,这样就给到用户很不愉快的感觉了。

针对这个问题,React 官方提供了一个内置的 Hook 来做这个事情。

场景一: 假设 pageOne 的数据获取非常快,而 pageTwo 非常慢,我们不想页面长时间的空白。为了让 pageOne 能够早点显示出来,可以给 pageTwo 设置一个延迟。但这也带出了新的问题就是每次切换,都可能在页面上存在一个旧数据仍留存的情况。

const MainPage = ({ resource }) => {
  const deferredResource = useDeferredValue(resource, {
    timeoutMs: 1000,
  });
  return (
    <>
      <PageOne resource={resource} />
      <PageTwo resource={deferredResource} />
    </>
  );
};

场景二: useDeferredValue并不仅仅在获取数据的时候有用。它在更新组件树的工作量过大导致交互(例如:在输入框输入内容)卡顿的情况也是有用的

  import { useState, useDeferredValue } from 'react
  const App = () => {
    const [value, setValue] = useState('')
    const lazyValue = useDeferredValue(value)

    return (
      <>
        <Input
          value={value}
          onChange={e => setValue(e.target.value)}
        />
        <SlowList value={lazyValue} />
      </>
    )
  }

五、Suspense

在章节三时,讲过“对于 IO-bound 的更新”,接下来将在章节五、六深入解析这个点。

1、什么是 Suspense?

Suspense 让组件可以“等待”某个异步操作,直到该异步操作结束才渲染。

Suspense 不是一个数据请求的库,而是一个机制。这个机制用来给数据请求库(如:fetch 等)向 React 说明某个组件正在读取数据中,当前不可用。React 可以等待到数据返回并更新 UI。

2、Suspense 可以做什么?

  • 让数据请求库与 React 紧密结合,在接口请求后就立马开始更新 state(需要这个数据请求库支持 Suspense);
  • 有针对性的安排加载状态的展示(可以控制应用视图的加载顺序);
  • 能够消除race conditions(相比于await,Suspense 能够更好的减少出错,给予一种同步读取数据的感觉)。

3、传统方式 vs Suspense

到这里,应该思考了一下:Suspense 解决了什么问题、为什么要处理这些问题、传统方式与 Suspense 有何区别?

  • fetch-on-render(渲染后再获取数据,如:在 useEffect 中 fetch):先开始加载组件,渲染完成后在 effects 或者 didMount 生命周期获取数据。

    假如我们使用并行的方式获取数据,这可能会出现:文章接口响应比较快,文章组件渲染了,而用户信息还未响应,等到响应后渲染用户信息组件,就可能会引起页面的“瀑布”问题;

    假如我们使用串型的方式获取数据,这可能会出现:用户信息接口响应比较慢,等了很久才响应,再请求文章接口,文章接口也响应很慢,这可能“对电脑的健康不太好”。

  <UserInfo info={userInfo} />  // 需要先看到用户信息
  <ArticleList list={list} /> // 再看到用户的发表的文章

提前抛出一个疑问:能不能并行的获取用户信息和文章,即便文章响应数据到了,也先不响应,等到用户信息数据到了,再一起渲染呢?

  • fetch-then-render(接收到所有数据后渲染,如:PLM 系统中的列表页 ready 方式):先获取所有需要的数据,数据准备好了再渲染出新的 UI,但是数据还未准备好前,只能得到一个白屏 loading。
  render() {
    if (!ready) {
      return (
        <Spin />
      )
    }
    return (
      ...
    )
  }
  • render-as-you-fetch(获取数据后渲染,如:使用了 Suspense):先获取下一屏需要的所有数据,然后立马渲染新的页面。

    React 官方:有了 Suspense,在接口刚请求时,其实就已经开始渲染了,在接收到数据后,立刻迭代渲染需要数据的组件,直到所有内容渲染完成。

// 主页面
function PageMain() {
  return (
    <Suspense fallback={<h1>Loading PageOne...</h1>}>
      <PageOne resource={resource} />
      <Suspense fallback={<h1>Loading PageTwo...</h1>}>
        <PageTwo resource={resource} />
      </Suspense>
    </Suspense>
  );
}
// 页面1
function PageOne({ resource }) {
  // 尝试读取页面1,尽管信息可能未加载完毕
  const pageOne = resource.pageOne.read();
  return <h1>{pageOne.name}</h1>;
}
// 页面2
function pageOTwo({ resource }) {
  // 尝试读取页面2,尽管信息可能未加载完毕
  const pageTwo = resource.pageTwo.read();
  return <h1>{pageTwo.name}</h1>;
}

我们一开始就通过 fetchData() 发出请求。这个方法返回给我们一个特殊的对象resource,而不是一个Promise,我们暂时不用管resource具体是什么。

React 尝试渲染 <PageMain>,该组件返回两个子组件:<PageOne><PageTwo>

React 尝试渲染 <PageOne>,该组件调用了resource.pageOne.read(),但因为读取的数据还没被获取完毕,所以组件会处于一个挂起的状态。React 会跳过这个组件,继续渲染组件树中的其他组件。

React 尝试渲染<PageTwo>,该组件调用了resource.pageTwo.read(),和上面一样,数据还没获取完毕,所以这个组件也是处在挂起的状态。React 同样跳过这个组件,去渲染组件树中的其他组件。

组件树中已经没有其他组件需要渲染了,因为 <PageOne>组件处于挂起状态,React 则是显示出距其上游最近的<Suspense>fallback:<h1>Loading PageOne...</h1>,渲染到这里就结束了。

随着数据的到来,React 将尝试重新渲染,并且每次都可能渲染出更加完整的组件树。当resource.pageOne的数据获取完毕之后,<PageOne>组件就能被顺利渲染出来,这时,就不再需要展示<h1>Loading PageOne...</h1>这个 fallback 了。当我们拿到全部数据之后,所有的 fallback 就都可以不展示了。

⚠️ 注意:使用 Suspense 意味着代码不再需要if(!ready)...这种代码,有效提高了代码设计的快速转变。如果我们想要同时展示<PageOne><PageTwo>两个组件,只需删除两者之间的<Suspense>,或者我们也可以给它们设置独立的<Suspense>,通过 Suspense,更改加载状态的粒度并控制顺序,而无需大幅度调整代码。

🍭 小彩蛋
  • 我们在前文提到:React 18 支持组件返回 undefined 不再报错。这是一个历史原因,React 历史版本中,为了帮助开发者迅速排错,防止开发者忘记 return 组件,在 2017 年时把组件返回 undefined 做报错处理了。但是现在各类编译器的检测插件已经非常流行且可靠,再加上 Ts 问世,React 已不再需要帮助开发者排查组件没有 return 值的情况了。

  • 还有一点就是Suspense的问世,开发者可以不想要配置fallback,直接赋值 undefined,但是 React 报错,就有点自相矛盾了。

4、如何控制应用视图的加载顺序?

SuspenseList 用于控制 Suspense 组件的显示顺序。

revealOrder:Suspense 加载顺序
  • together:所有 Suspense 一起展示,也就是最后一个加载完再全部展示
  • forwards:顺序展示 Suspense
  • backwards:反序展示 Suspense
tail:是否显示 fallback

只在 revealOrder 为 forwards 或者 backwards 时候有效

  • hidden:不显示
  • collapsed:轮到自己再显示。
// 强制控制顺序展示,即便pageTwo的接口先响应
<SuspenseList revealOrder="forwards" tail="collapsed">
  <Suspense fallback={<h1>loading - pageOne</h1>}>
    <PageOne resource={resource} />
  </Suspense>

  <Suspense fallback={<h1>loading - pageTwo</h1>}>
    <PageTwo resource={resource} />
  </Suspense>
</SuspenseList>

5、错误处理

每当我们使用 Promise,我们都会用catch()做错误处理。但当我们使用 Suspense 时,不等待 Promise 就直接开始渲染,这个时候catch()已经不管用了。

在 Suspense 中,就需要一个层级渲染一个错误边界来“捕捉”层级下面的所有错误信息了。

export default class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };
  static getDerivedStateFromError(error) {
    return {
      hasError: true,
      error,
    };
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}
function MainPage() {
  return (
    <Suspense fallback={<h1>Loading PageOne...</h1>}>
      <PageOne />
      <ErrorBoundary fallback={<h2>Could not fetch pageTwo.</h2>}>
        <Suspense fallback={<h1>Loading PageTwo...</h1>}>
          <PageTwo />
        </Suspense>
      </ErrorBoundary>
    </Suspense>
  );
}

React 官方:理论上,在组件树中插入多少个错误边界组件都是可以的,但这并不是推荐的做法,错误边界组件的位置最好是深思熟虑之后再确定。

6、Race Conditions 问题

React 官方 一直有个问题令他们棘手。React 组件有自己的“生命周期”,组件可能在任意时间点接收到 props 或者更新 state。而每一个异步请求同样也有自己的“生命周期”,异步请求的生命周期开始于发出请求,结束于收到响应报文。在这里,如何在这两类生命周期之间及时进行“同步”呢?

回顾 Sespense,貌似已经能够解决这种问题了。因为使用了 Sespense 后,并不是干等到响应报文接收后,才去更新 state。而是反过来,发出请求之后,就马上就开始更新 state(外加开始渲染),一旦收到响应,React 就把可用的数据用到 组件里头。

五、Transition

回顾前文,我们点击某个“next”按钮来切换激活页面时,现存页面就会立马消失,然后我们看到整个页面就只有一个加载提示,用户体验是非常差,非常“不受欢迎”的。如果我们能在跳转之前,就先把内容加载好再过度到新页面,貌似效果更好。

1、使用方式

React Concurrent Mode 提供了一个新的内置的useTransition()的 hook 来实现这个设计。

import { useTransition } from "react";

const App = () => {
  const [isPending, startTransition] = useTransition({
    timeoutMs: 3000,
  });
};

useTransition 包含两个返回值:

  • startTransition 类型为函数。我们用它来告诉 React 我们希望的延迟的是哪个 state 的更新。
  • isPending 类型为 boolean。此变量在 React 中用于告知我们该转换是否正在进行。

useTransition 还支持传入了一个配置对象。此对象包含 timeoutMs 属性,该属性指定了我们希望这个转换在多久之内完成。

import { useState, useTransition } from "react";

const initialResource = fetchData();

function App() {
  const [resource, setResource] = useState(initialResource);
  const [isPending, startTransition] = useTransition({
    timeoutMs: 3000, // 前一个页面最多只在屏幕中最多保持三秒
  });
  return (
    <>
      <button
        disabled={isPending}
        onClick={() => {
          startTransition(() => {
            setResource(fetchData());
          });
        }}
      >
        Next
      </button>
      <Suspense fallback={<h1>loading...</h1>}>
        <MainPage resource={resource} />
      </Suspense>
    </>
  );
}

2、组件是在哪里更新的?

其实,在用户点击切换页面的时候,就已经存在两个“版本”的<PageMain>,旧的存在于当前页面上,还有一个进度的,新的存在于正在等待的那个页面。

但是同一个组件的两个版本怎么同时存在呢?

可以认为 state 内部存在 branch 管理。React 可以在不同版本的树上进行切换,一个是你屏幕上看到的那个版本,另一个是它“准备”接下来给你显示的版本。

3、与 setTimeout 异同

在 startTransition 出现之前,我们可以使用 setTimeout 来实现优化。但是现在在处理这方面优化时,有了 startTransition 基本上可以抛弃 setTimeout 了。

与 setTimeout 不同的是,startTransition 并不会延迟调度,而是会立即执行。startTransition 接收的函数是同步执行的,只是这个 update 被加了一个 “transitions" 标记,给到 React 内部处理更新时作为参考信息的。相比于 setTimeout ,把一个 update 交给 startTransition 能够更早地被处理,在较快的设备上,这个过度是用户感知不到的。

4、使用场景

startTransition 可以用在任何你想更新的时候。但从实际出发,以下是两种典型适用场景:

  • 渲染慢:如果有很多没那么着急的内容要渲染更新;
  • 网络慢:如果更新需要花较多时间从服务端获取,这个时候也可以再结合 Suspense。

5、学习三个阶段

img3.png

默认方式:Receded → Skeleton → Complete

在我们日常的进入页面时,会经历这样的过程: Receded(后退):第一秒,你会看到一个白屏并且正在loading。 Skeleton:加载完初始化的一些基本数据后,页面渲染大致的模块,这是你可能会看到其他区域(如:表格区域)还在loading。 Complete:当所有内容获取完毕,页面不再有loading

如何区分 Receded 和 Skeleton 状态呢?

它们之间的区别在于 Receded 感觉像是面向用户“向后退一步”,而 Skeleton 模式感觉像是在我们的进程中“向前走一步”来展示更多的内容。

这个过程(Receded → Skeleton → Complete)是默认情况。但是 Receded 状态是非常不友好的,因为它“隐藏”了已经存在的信息。这正是 React 让我们通过 useTransition 进入另一个序列(Pending(等待) → Skeleton → Complete)的原因。

期望方式: Pending → Skeleton → Complete

当我们使用 useTransition 的时候,React 会让我们“停留”在前一个页面,并在那显示一个进度提示。我们称它为一个 Pending(暂停)状态。这种的体验会比 Receded 状态好很多,因为我们已经显示的信息不会消失,而且页面仍可以交互。

六、肖肖的疑惑

  • 通过路由调整,在渲染下一个页面之前,我们怎么知道获取什么数据?
  • 如何更好的避免瀑布的问题?
  • 怎么更好的处理 Skeleton ?

参考文献