Skip to content
目录

React18的新特性

新特性

Render API

为了更好的管理root节点,React 18 引入了一个新的 root API,新的 root API 还支持 new concurrent renderer(并发模式的渲染), 它允许你进入concurrent mode(并发模式)。

javascript
// React 16/17
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

const root = document.getElementById('root');

ReactDOM.render(<App />, root);

// React 18
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = document.getElementById('root');

ReactDOM.createRoot(root).render(<App />);

同时,在卸载组件时,我们也需要将 unmountComponentAtNode 升级为 root.unmount

javascript
// React 17
ReactDOM.unmountComponentAtNode(root);

// React 18
root.unmount();

注意

我们如果在 React 18 中使用旧的 render api,在项目启动后,你将会在控制台中看到一个警告:

react-dom-render.png

这表示你可以将项目直接升级到 React 18 版本,而不会直接造成 break change。 如果你需要保持着 React 17 版本的特性的话,那么你可以无视这个报错,因为它在整个 18 版本中都是兼容的。

除此之外,React 18 还从 render 方法中删除了回调函数,因为当使用Suspense时,它通常不会有预期的结果。

在新版本中,如果需要在 render 方法中使用回调函数,我们可以在组件中通过 useEffect 实现:

javascript
// React 17
const root = document.getElementById('root')!;
ReactDOM.render(<App />, root, () => {
  console.log('渲染完成');
});

// React 18
const AppWithCallback: React.FC = () => {
  useEffect(() => {
    console.log('渲染完成');
  }, []);
  return <App />;
};
const root = document.getElementById('root')!;
ReactDOM.createRoot(root).render(<AppWithCallback />);

最后,如果你的项目使用了ssr服务端渲染,需要把hydration升级为hydrateRoot

javascript
// React 17
import ReactDOM from 'react-dom';
const root = document.getElementById('root');
ReactDOM.hydrate(<App />, root);

// React 18
import ReactDOM from 'react-dom/client';
const root = document.getElementById('root');
ReactDOM.hydrateRoot(root, <App />);

另外,还需要更新 TypeScript 类型定义,如果你的项目使用了 TypeScript,最值得注意的变化是, 现在在定义props类型时,如果需要获取子组件children,那么你需要显式的定义它,例如这样:

typescript
// React 17
interface MyButtonProps {
  color: string;
}

const MyButton: React.FC<MyButtonProps> = ({ children }) => {
  // 在 React 17 的 FC 中,默认携带了 children 属性
  return <div>{children}</div>;
};

export default MyButton;

// React 18
interface MyButtonProps {
  color: string;
  children?: React.ReactNode;
}

const MyButton: React.FC<MyButtonProps> = ({ children }) => {
  // 在 React 18 的 FC 中,不存在 children 属性,需要手动申明
  return <div>{children}</div>;
};

export default MyButton;

setState 自动批处理

React 18 通过在默认情况下执行批处理来实现了开箱即用的性能改进。

批处理是指为了获得更好的性能,在数据层,将多个状态更新批量处理,合并成一次更新(在视图层,将多个渲染合并成一次渲染)。

1. 在 React 18 之前:

在React 18 之前,我们只在 React 事件处理函数 中进行批处理更新。默认情况下,在promise、setTimeout、原生事件处理函数中、或任何其它事件内的更新都不会进行批处理:

  • react事件处理函数
typescript
class Component extends React.Component {
  constructor() {
    super()
    this.state = {
      count1: 0,
      count2: 0
    }
    this.handleClick = this.handleClick.bind(this)
  }
  handleClick() {
    this.setState({ count1: this.state.count1 + 1})
    this.setState({count2: this.state.count2 + 1})
  }
  render() {
    console.log('render')
    return (
      <div>
        <div>{ this.state.count1 }</div> 
        <div>{ this.state.count2 }</div> 
        <button onClick={this.handleClick}>点击</button>
      </div>
    )
  }
}

batch-update-count.png 可以看到,渲染次数和更新次数是一样的,即使我们更新了两个状态,每次更新组件也只渲染一次。 但是,如果我们把状态的更新放在promise或者setTimeout里面:

  • setTimeout
typescript
class Component extends React.Component {
  constructor() {
    super()
    this.state = {
      count1: 0,
      count2: 0
    }
    this.handleClick = this.handleClick.bind(this)
  }
  handleClick() {
    setTimeout(() => {
      this.setState({ count1: this.state.count1 + 1})
      this.setState({count2: this.state.count2 + 1})
    })
  }
  render() {
    console.log('render')
    return (
      <div>
        <div>{ this.state.count1 }</div> 
        <div>{ this.state.count2 }</div> 
        <button onClick={this.handleClick}>点击</button>
      </div>
    )
  }
}

batch-update-count.png 可以看到,每次点击更新两个状态,组件都会渲染两次,不会进行批量更新。

在原生事件中和setTimeout中的表现是一致的

2. 在 React 18中

上面的三个例子只会有一次 render,因为所有的更新都将自动批处理。这样无疑是很好的提高了应用的整体性能。

flushSync

批处理是一个破坏性改动,如果想退出批量更新,可以使用 flushSync

typescript
handleClick() {
    // 第一次更新
    flushSync(() => {
      this.setState({
        count1: this.state.count1 + 1
      })
    })
    // 第二次更新
    flushSync(() => {
      this.setState({
        count2: this.state.count2 + 1
      })
    })
  }

注意

flushSync 函数内部的多个 setState 仍然为批量更新,这样可以精准控制哪些不需要的批量更新。

有关批处理和flushSync的更多信息,你可以参阅 React 官方的Automatic batching deep dive(批处理深度分析)

关于卸载组件时的更新状态警告

在开发过程中,经常遇到以下报错:

destory-effect

这个错误表示:无法对未挂载(已卸载)的组件执行状态更新。这是一个无效操作,并且表明我们的代码中存在内存泄漏

实际上,这个错误并不多见,在以往的版本中,这个警告被广泛误解,并且有些误导。
这个错误的初衷,原本旨在针对一些特殊场景,譬如 你在useEffect里面设置了定时器,或者订阅了某个事件,从而在组件内部产生了副作用,而且忘记return一个函数清除副作用,则会发生内存泄漏等之类的场景
但是在实际开发中,更多的场景是,我们在 useEffect 里面发送了一个异步请求,在异步函数还没有被 resolve 或者被 reject 的时候,我们就卸载了组件。 在这种场景中,警告同样会触发。但是,在这种情况下,组件内部并没有内存泄漏,因为这个异步函数已经被垃圾回收了,此时,警告具有误导性。 关于这点,React 官方也有解释:

综上所述原因,在 React 18 中,官方删除了这个报错。

关于 React 组件的返回值

  • 在React 17中,如果你需要返回一个空组件,React只允许返回null。如果你显式的返回了undefined,控制台则会在运行时抛出一个错误。
  • 在React 18中,不再检查因返回undefined而导致崩溃。既能返回 null,也能返回 undefined(但是 React 18 的dts文件还是会检查,只允许返回 null,你可以忽略这个类型错误)。

Strict Mode

不再抑制控制台日志:

当你使用严格模式时,React 会对每个组件进行两次渲染,以便你观察一些意想不到的结果。在 React 17 中,取消了其中一次渲染的控制台日志,以便让日志更容易阅读。

为了解决社区对这个问题的困惑,在 React 18 中,官方取消了这个限制。如果你安装了React DevTools,第二次渲染的日志信息将显示为灰色,以柔和的方式显式在控制台。

Suspense 不再需要 fallback 来捕获

在 React 18 的 Suspense 组件中,官方对 空的fallback 属性的处理方式做了改变:不再跳过 缺失值 或 值为null 的 fallback 的 Suspense 边界。相反,会捕获边界并且向外层查找,如果查找不到,将会把 fallback 呈现为 null。

更新前

以前,如果你的 Suspense 组件没有提供 fallback 属性,React 就会悄悄跳过它,继续向上搜索下一个边界:

typescript
// React 17
const App = () => {
  return (
    <Suspense fallback={<Loading />}> // <--- 这个边界被使用,显示 Loading 组件
      <Suspense>                      // <--- 这个边界被跳过,没有 fallback 属性
        <Page />
      </Suspense>
    </Suspense>
  );
};
export default App;

React 工作组发现这可能会导致混乱、难以调试的情况发生。例如,你正在debug一个问题,并且在没有 fallback 属性的 Suspense 组件中抛出一个边界来测试一个问题,它可能会带来一些意想不到的结果,并且 不会警告 说它 没有fallback 属性。

更新后

现在,React将使用当前组件的 Suspense 作为边界,即使当前组件的 Suspense 的值为 null 或 undefined:

typescript
// React 18
const App = () => {
  return (
    <Suspense fallback={<Loading />}> // <--- 不使用
      <Suspense>                      // <--- 这个边界被使用,将 fallback 渲染为 null
        <Page />
      </Suspense>
    </Suspense>
  );
};
export default App;

这个更新意味着我们不再跨越边界组件。相反,我们将在边界处捕获并呈现 fallback,就像你提供了一个返回值为 null 的组件一样。这意味着被挂起的 Suspense 组件将按照预期结果去执行,如果忘记提供 fallback 属性,也不会有什么问题。

新的 API

useId

typescript
const id = useId();

支持同一个组件在客户端和服务端生成相同的唯一的 ID,避免hydration的不兼容,这解决了在 React 17 及 17 以下版本中已经存在的问题。因为我们的服务器渲染时提供的 HTML 是无序的,useId 的原理就是每个 id 代表该组件在组件树中的层级结构

useSyncExternalStore

useSyncExternalStore 是一个新的api,经历了一次修改,由 useMutableSource 改变而来,主要用来解决外部数据撕裂问题。

useSyncExternalStore 能够通过强制同步更新数据让 React 组件在 CM 下安全地有效地读取外接数据源。 在 Concurrent Mode 下,React 一次渲染会分片执行(以 fiber 为单位),中间可能穿插优先级更高的更新。假如在高优先级的更新中改变了公共数据(比如 redux 中的数据),那之前低优先的渲染必须要重新开始执行,否则就会出现前后状态不一致的情况。

useSyncExternalStore 一般是三方状态管理库使用,我们在日常业务中不需要关注。因为 React 自身的 useState 已经原生的解决的并发特性下的 tear(撕裂)问题。useSyncExternalStore 主要对于框架开发者,比如 redux,它在控制状态时可能并非直接使用的 React 的 state,而是自己在外部维护了一个 store 对象,用发布订阅模式实现了数据更新,脱离了 React 的管理,也就无法依靠 React 自动解决撕裂问题。因此 React 对外提供了这样一个 API。

目前 React-Redux 8.0 已经基于 useSyncExternalStore 实现。

useInsertionEffect

typescript
const useCSS = rule => {
  useInsertionEffect(() => {
    if (!isInserted.has(rule)) {
      isInserted.add(rule);
      document.head.appendChild(getStyleForRule(rule));
    }
  });
  return rule;
};

const App: React.FC = () => {
  const className = useCSS(rule);
  return <div className={className} />;
};

export default App;

这个 Hooks 只建议css-in-js 库来使用。 这个 Hooks 执行时机在 DOM 生成之后,useLayoutEffect 之前,它的工作原理大致和useLayoutEffect相同,只是此时无法访问DOM节点的引用,一般用于提前注入 style 脚本。

Concurrent Mode(并发模式)

Concurrent Mode(以下简称 CM)翻译叫并发模式,这个概念我们或许已经听过很多次了,实际上,在去年这个概念已经很成熟了,在 React 17 中就可以通过一些试验性的api开启 CM。

CM 本身并不是一个功能,而是一个底层设计

并发模式可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整,该模式通过使渲染可中断来修复阻塞渲染限制。在 Concurrent 模式中,React 可以同时更新多个状态。

总结一句话就是:

React 17 和 React 18 的区别就是:从同步不可中断更新变成了异步可中断更新。

重点 我们在文章开始提到过:在 React 18 中,提供了新的 root api,我们只需要把 render 升级成

typescript
createRoot(root).render(<App />)

就可以开启并发模式了。

那么这个时候,可能有同学会提问:开启并发模式就是开启了并发更新么

NO! 在 React 17 中一些实验性功能里面,开启并发模式就是开启了并发更新,但是在 React 18 正式版发布后,由于官方策略调整,React 不再依赖并发模式开启并发更新了。

换句话说:开启了并发模式,并不一定开启了并发更新! 一句话总结:在 18 中,不再有多种模式,而是以是否使用并发特性作为是否开启并发更新的依据

从最老的版本到当前的v18,市面上有多少个版本的React?

可以从架构角度来概括下,当前一共有两种架构:

  • 采用不可中断的递归方式更新的Stack Reconciler(老架构)
  • 采用可中断的遍历方式更新的Fiber Reconciler(新架构)

新架构可以选择是否开启并发更新,所以当前市面上所有 React 版本有四种情况:

  1. 老架构(v15及之前版本)
  2. 新架构,未开启并发更新,与情况1行为一致(v16、v17 默认属于这种情况)
  3. 新架构,未开启并发更新,但是启用了并发模式和一些新功能(比如 Automatic Batching,v18 默认属于这种情况)
  4. 新架构,开启并发模式,开启并发更新

并发特性指开启并发模式后才能使用的特性,比如:

  • useDeferredValue
  • useTransition

了解清楚他们的关系之后,我们可以继续探索并发更新了:

并发特性:

一、startTransition

在react 18中运行如下代码:

typescript
import React, { useState, useEffect, useTransition } from 'react';

const App: React.FC = () => {
  const [list, setList] = useState<any[]>([]);
  const [isPending, startTransition] = useTransition();
  useEffect(() => {
    // 使用了并发特性,开启并发更新
    startTransition(() => {
      setList(new Array(10000).fill(null));
    });
  }, []);
  return (
    <>
      {list.map((_, i) => (
        <div key={i}>{i}</div>
      ))}
    </>
  );
};

export default App;

由于 setListstartTransition 的回调函数中执行(使用了并发特性),所以 setList 会触发并发更新

startTransition,主要为了能在大量的任务下也能保持 UI 响应。这个新的 API 可以通过将特定更新标记为“过渡”来显著改善用户交互, 简单来说,就是被startTransition回调包裹的setState触发的渲染被标记为不紧急渲染,这些渲染可能被其他紧急渲染所抢占。

二、useDeferredValue

返回一个延迟响应的值,可以让一个state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。 useDeferredValuestartTransition 一样,都是标记了一次非紧急更新。

从介绍上来看 useDeferredValue 与 useTransition 是否感觉很相似呢?

  • 相同:useDeferredValue 本质上和内部实现与 useTransition 一样,都是标记成了延迟更新任务。
  • 不同:useTransition 是把更新任务变成了延迟更新任务,而 useDeferredValue 是产生一个新的值,这个值作为延时状态。(一个用来包装方法,一个用来包装值)

所以,上面 startTransition 的例子,我们也可以用 useDeferredValue 来实现:

typescript
import React, { useState, useEffect, useDeferredValue } from 'react';

const App: React.FC = () => {
  const [list, setList] = useState<any[]>([]);
  useEffect(() => {
    setList(new Array(10000).fill(null));
  }, []);
  // 使用了并发特性,开启并发更新
  const deferredList = useDeferredValue(list);
  return (
    <>
      {deferredList.map((_, i) => (
        <div key={i}>{i}</div>
      ))}
    </>
  );
};

export default App;

然后启动项目,查看一下打印的执行堆栈图:

此时我们的任务被拆分到每一帧不同的 task 中,JS脚本执行时间大体在5ms左右,这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。

三、普通情况

可以从打印的执行堆栈图看到,此时由于组件数量繁多(10000个),JS执行时间为300ms,也就是意味着,在没有并发特性的情况下:一次性渲染10000个标签的时候,页面会阻塞大约0.3秒,造成卡顿,但是如果开启了并发更新,就不会存在这样的问题。

这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)

结论

  • 并发更新的意义就是交替执行不同的任务,当预留的时间不够用时,React 将线程控制权交还给浏览器,等待下一帧时间到来,然后继续被中断的工作
  • 并发模式是实现并发更新的基本前提
  • 时间切片是实现并发更新的具体手段
  • 上面所有的东西都是基于 fiber 架构实现的,fiber为状态更新提供了可中断的能力

参考

上次更新:

备案号: 浙ICP备2023000081号