React18 新特性解读 & 完整版升级指南

注意

React 18 已经放弃了对 ie11 的支持,将于 2022年6月15日 停止支持 ie,如需兼容,需要回退到 React 17 版本。

React 18 中引入的新特性是使用现代浏览器的特性构建的,在IE中无法充分polyfill,比如micro-tasks 

升级

  • 新项目: 直接用 npm 或者 yarn 安装最新版依赖即可(如果是js,可以不需要安装types类型声明文件)
npm i react react-dom --save
    
npm i @types/react @types/react-dom -D 
  • 旧项目: 先把依赖中的版本号改成最新,然后删掉 node_modules 文件夹,重新安装:
npm i 

新特性

一、 Render API

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

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

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

ReactDOM.render(, 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(); 

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

// React 17
ReactDOM.unmountComponentAtNode(root);

// React 18
root.unmount(); 

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

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

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

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

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

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

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

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

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

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

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

const MyButton: React.FC = ({ children }) => {
  // 在 React 17 的 FC 中,默认携带了 children 属性
  return 
{children}
; }; export default MyButton; // React 18 interface MyButtonProps { color: string; children?: React.ReactNode; } const MyButton: React.FC = ({ children }) => { // 在 React 18 的 FC 中,不存在 children 属性,需要手动申明 return
{children}
; }; export default MyButton;

二、 setState 自动批处理

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

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

1. 在 React 18 之前:

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

情况一:React 事件处理函数

import React, { useState } from 'react';

// React 18 之前
const App: React.FC = () => {
  console.log('App组件渲染了!');
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    
  );
};

export default App; 

点击button,打印console.log:

React18 新特性解读 & 完整版升级指南_第1张图片

可以看到,渲染次数和更新次数是一样的,即使我们更新了两个状态,每次更新组件也只渲染一次。

但是,如果我们把状态的更新放在promise或者setTimeout里面:

情况二:setTimeout

import React, { useState } from 'react';

// React 18 之前
const App: React.FC = () => {
  console.log('App组件渲染了!');
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    
{ setTimeout(() => { setCount1(count => count + 1); setCount2(count => count + 1); }); // 在 setTimeout 中不会进行批处理 }} >
count1: {count1}
count2: {count2}
); }; export default App;

点击button,重新打印console.log:

React18 新特性解读 & 完整版升级指南_第2张图片

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

情况三:原生js事件

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

// React 18 之前
const App: React.FC = () => {
  console.log('App组件渲染了!');
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  useEffect(() => {
    document.body.addEventListener('click', () => {
      setCount1(count => count + 1);
      setCount2(count => count + 1);
    });
    // 在原生js事件中不会进行批处理
  }, []);
  return (
    <>
      
count1: {count1}
count2: {count2}
); }; export default App;

点击button,重新打印console.log:

React18 新特性解读 & 完整版升级指南_第3张图片

可以看到,在原生js事件中,结果跟情况二是一样的,每次点击更新两个状态,组件都会渲染两次,不会进行批量更新。

2. 在 React 18 中:

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

不过以下例子会在 React 18 中执行两次 render

import React, { useState } from 'react';

// React 18
const App: React.FC = () => {
  console.log('App组件渲染了!');
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    
{ await setCount1(count => count + 1); setCount2(count => count + 1); }} >
count1: {count1}
count2: {count2}
); }; export default App;

总结:

  • 在 18 之前,只有在react事件处理函数中,才会自动执行批处理,其它情况会多次更新
  • 在 18 之后,任何情况都会自动执行批处理,多次更新始终合并为一次

三、flushSync

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

import React, { useState } from 'react';
import { flushSync } from 'react-dom';

const App: React.FC = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    
{ flushSync(() => { setCount1(count => count + 1); }); // 第一次更新 flushSync(() => { setCount2(count => count + 1); }); // 第二次更新 }} >
count1: {count1}
count2: {count2}
); }; export default App;

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

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

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

我们在开发时,偶尔会遇到以下错误:

React18 新特性解读 & 完整版升级指南_第4张图片

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

实际上,这个错误并不多见,在以往的版本中,这个警告被广泛误解,并且有些误导。

这个错误的初衷,原本旨在针对一些特殊场景,譬如 你在useEffect里面设置了定时器,或者订阅了某个事件,从而在组件内部产生了副作用,而且忘记return一个函数清除副作用,则会发生内存泄漏…… 之类的场景

但是在实际开发中,更多的场景是,我们在 useEffect 里面发送了一个异步请求,在异步函数还没有被 resolve 或者被 reject 的时候,我们就卸载了组件。 在这种场景中,警告同样会触发。但是,在这种情况下,组件内部并没有内存泄漏,因为这个异步函数已经被垃圾回收了,此时,警告具有误导性

关于这点,React 官方也有解释:

React18 新特性解读 & 完整版升级指南_第5张图片

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

有关这个报错的更多信息,你可以参阅 React 官方的说明,点击这里查看。

五、关于 React 组件的返回值

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

关于组件返回值的官方解释: github.com/reactwg/rea…

六、Strict Mode

不再抑制控制台日志:

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

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

React18 新特性解读 & 完整版升级指南_第6张图片

关于 Strict Mode 的官方解释: github.com/reactwg/rea…

七、 Suspense 不再需要 fallback 来捕获

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

更新前:

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

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

export default App; 

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

更新后:

现在,React将使用当前组件的 Suspense 作为边界,即使当前组件的 Suspense 的值为 nullundefined

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

export default App; 

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

关于 Suspense 的官方解释: github.com/reactwg/rea…

新的 API

一、useId

const id = useId(); 

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

有关useId的更多信息,请参阅 useId post in the working group。

二、useSyncExternalStore

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

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

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

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

有关useSyncExternalStore的更多信息,请参阅 useSyncExternalStore overview post 和 useSyncExternalStore API details。

三、useInsertionEffect

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 
; }; export default App;

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