23 React18新增了哪些特性

前言

  • 20216 月份,React 18 Working GroupReact 18 工作组,简称 reactwg)成立了,并且公布了 v18 版本的发布计划,经过将近一年的迭代和准备,在 2022329 日,React 18 正式版终于发布了
  • react 17 的发布时间是 20201020号,距离 React 18 发布足足间隔一年半,并且v17中只有三个小版本,分别是17.0.017.0.117.0.2
    • 17.0.0 - React 17 正式版发布
    • 17.0.1 - 只改动了 1 个文件,修复 ie 兼容问题,同时提升了 V8 内部对数组的执行性能
    • 17.0.2 - 改动集中于 Scheduler 包, 主干逻辑没有变动,只与性能统计相关
  • React 17 的两次迭代中,都是只更新了补丁号,并且都是一些比较细节的更新,直到 React 18 正式版发布,React 17 都没有任何更新

注意

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

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

新特性一览

  • 新增了useIdstartTransitionuseTransitionuseDeferredValueuseSyncExternalStoreuseInsertionEffect等新的 hook API
  • 针对浏览器和服务端渲染的 React DOM API 都有新的变化
    • React DOM Client 新增 createRoothydrateRoot 方法
    • React DOM Server 新增 renderToPipeableStreamrenderToReadableStream 方法
  • 部分弃用特性
    • ReactDOM.render 已被弃用。使用它会警告:在 React 17 模式下运行您的应用程序
    • ReactDOM.hydrate已被弃用。使用它会警告:在React 17 模式下运行您的应用程序
    • ReactDOM.unmountComponentAtNode已被弃用。 ReactDOM.renderSubtreeIntoContainer 已被弃用
    • ReactDOMServer.renderToNodeStream 已被弃用
  • breaking change
    • setState自动批处理
    • Stricter Strict Mode严格模式

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(<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

    // React 17
    ReactDOM.unmountComponentAtNode(root);
    
    // React 18
    root.unmount();

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

    // React 17
    const root = document.getElementById('root')!;
    ReactDOM.render(<App />, root, () => {
      console.log('渲染完成');
    });
    
    // React 18
    // React 18 从 render 方法中删除了回调函数,因为当使用Suspense时,它通常不会有预期的结果
    const AppWithCallback = () => {
      useEffect(() => {
        console.log('渲染完成');
      }, []);
      return <App />;
    };
    const root = document.getElementById('root')!;
    ReactDOM.createRoot(root).render(<AppWithCallback />);

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

    // 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,那么你需要显式的定义它,例如这样:

    // 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 通过在默认情况下执行批处理来实现了开箱即用的性能改进
  • 批处理是指为了获得更好的性能,在数据层,将多个状态更新批量处理,合并成一次更新(在视图层,将多个渲染合并成一次渲染

在 React 18 之前

React 18之前,setStateReact的合成事件中是合并更新的,在setTimeout的原生事件中是同步按序更新的。例如

    handleClick = () => {
      this.setState({ age: this.state.age + 1 });
      console.log(this.state.age); // 0
      this.setState({ age: this.state.age + 1 });
      console.log(this.state.age); // 0
      this.setState({ age: this.state.age + 1 });
      console.log(this.state.age); // 0
      setTimeout(() => {
        this.setState({ age: this.state.age + 1 });
        console.log(this.state.age); // 2
        this.setState({ age: this.state.age + 1 });
        console.log(this.state.age); // 3
      });
    };

而在React 18中,不论是在合成事件中,还是在宏任务中,都是会合并更新

    function handleClick() {
      setState({ age: state.age + 1 }, onePriority);
      console.log(state.age);// 0
      setState({ age: state.age + 1 }, onePriority);
      console.log(state.age); // 0
      setTimeout(() => {
        setState({ age: state.age + 1 }, twoPriority);
        console.log(state.age); // 1
        setState({ age: state.age + 1 }, twoPriority);
        console.log(state.age); // 1
      });
    }

总结:

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

flushSync

批处理是一个破坏性改动,如果你想退出批量更新,你可以使用 flushSync,建议尽量不要这么做

    import React, { useState } from 'react';
    import { flushSync } from 'react-dom';
    
    const App: React.FC = () => {
      const [count, setCount] = useState(0);
      return (
        <div
          onClick={() => {
            flushSync(() => {
              setCount(count => count + 1);
            });
            flushSync(() => {
              setCount(count => count + 2);
            });
          }}
        >
          <div>count: {count}</div>
        </div>
      );
    };
    
    export default App;

注意:flushSync 函数内部的多个 setState 仍然为批量更新

改进Suspense

Suspense用于数据获取,可以“等待”目标代码加载,并且可以直接指定一个加载的界面(像是个 spinner),让它在用户等待的时候显示。

    import {useState, Suspense} from "react";
    import User from "../components/User";
    import Num from "../components/Num";
    import {fetchData} from "../utils";
    import ErrorBoundaryPage from "./ErrorBoundaryPage";
    
    const initialResource = fetchData();
    
    export default function SuspensePage(props) {
      const [resource, setResource] = useState(initialResource);
    
      return (
        <div>
          <h3>SuspensePage</h3>
          <ErrorBoundaryPage fallback={<h1>网络出错了</h1>}>
            <Suspense fallback={<h1>loading - user</h1>}>
              <User resource={resource} />
            </Suspense>
          </ErrorBoundaryPage>
    
          <Suspense fallback={<h1>loading-num</h1>}>
            <Num resource={resource} />
          </Suspense>
    
          <button onClick={() => setResource(fetchData())}>refresh</button>
        </div>
      );
    }

错误处理

每当使用 Promises,大概率我们会用 catch() 来做错误处理。但当我们用 Suspense 时,我们不等待 Promises 就直接开始渲染,这时 catch() 就不适用了。这种情况下,错误处理该怎么进行呢?

Suspense 中,获取数据时抛出的错误和组件渲染时的报错处理方式一样——你可以在需要的层级渲染一个错误边界组件来“捕捉”层级下面的所有的报错信息。

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

支持Concurrent模式

带来新的API,如startTransitionuseDeferredValue

  • 为了支持以上特性,React18不仅加入了多任务处理,还加入了基于优先级的渲染、调度和打断
  • React18加入的新的模式,即"并发渲染(concurrent rendering)"模式,当然这个模式是可选的,这个模式也使得React能够同时支持多个UI版本。这个变化对于开发者来说大部分是不可见的,但是它解锁了React应用在性能提升方面的一些新特性

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

Concurrent 模式中,React 可以 同时 更新多个状态 —— 就像分支可以让不同的团队成员独立地工作一样

  • 对于 CPU-bound 的更新 (例如创建新的 DOM节点和运行组件中的代码),并发意味着一个更急迫的更新可以“中断”已经开始的渲染。
  • 对于 IO-bound 的更新 (例如从网络加载代码或数据),并发意味着 React 甚至可以在全部数据到达之前就在内存中开始渲染,然后跳过令人不愉快的空白加载状态

重要的是,你使用 React 的方式是相同的。componentsprops,和 state 等概念的基本工作方式是相同的。当你想更新屏幕,设置 state即可

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

简单来说,Concurrent模式想做到的事情就是用户可以自定义更新任务优先级并且能够通知到ReactReact再来处理不同优先级的更新任务,当然,优先处理高优先级任务,并且低优先级任务可以中断

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

组件返回undefined不再报错

    export default function UndefinedPage(props) {
      return undefined;
    }

React以前之所以返回undefined会报错,是为了帮助用户快速排错,因为用户可能会忘记返回组件。这是当时2017年把组件返回undefined报错处理的原因,但是现在来看呢,今时不同往日了,现在的类型检测工具都非常流行并且可靠了,比如ts。所以现在React可以不再帮助用户排查忘记给组件添加返回值的情况了。

并且还有一点,这个改动和React18之后的特性也相关。比如Suspense,如果我不想要fallback所以才赋值undefined,但是React报错,这理论上有点矛盾

startTransition

startTransition包裹里的更新函数被当做是非紧急事件,如果有别的紧急更新(urgent update)进来,那么这个startTransition包裹里的更新则会被打断

React把状态更新分成两种:

  • Urgent updates 紧急更新,指直接交互。如点击、输入、滚动、拖拽等
  • Transition updates 过渡更新,如UI从一个视图向另一个视图的更新
    import {useEffect, useState, Suspense} from "react";
    import Button from "../components/Button";
    import User from "../components/User";
    import Num from "../components/Num";
    import {fetchData} from "../utils";
    
    const initialResource = fetchData();
    
    export default function TransitionPage(props) {
      const [resource, setResource] = useState(initialResource);
    
      // useEffect(() => {
      //   console.log("resource", resource); //sy-log
      // }, [resource]);
    
      return (
        <div>
          <h3>TransitionPage</h3>
          <Suspense fallback={<h1>loading - user</h1>}>
            <User resource={resource} />
          </Suspense>
    
          <Suspense fallback={<h1>loading-num</h1>}>
            <Num resource={resource} />
          </Suspense>
    
          <Button
            refresh={() => {
              setResource(fetchData());
            }}
          />
        </div>
      );
    }
    import {
      //startTransition,
      useTransition,
    } from "react";
    
    export default function Button({refresh}) {
      const [isPending, startTransition] = useTransition();
    
      return (
        <div className="border">
          <h3>Button</h3>
          <button
            onClick={() => {
              startTransition(() => {
                refresh();
              });
            }}
            disabled={isPending}>
            点击刷新数据
          </button>
          {isPending ? <div>loading...</div> : null}
        </div>
      );
    }

与setTimeout异同

  • startTransition出现之前,我们可以使用setTimeout来实现优化。但是现在在处理上面的优化的时候,有了startTransition基本上可以抛弃setTimeout了,原因主要有以三点:
  • 首先,与setTimeout不同的是,startTransition并不会延迟调度,而是会立即执行,startTransition接收的函数是同步执行的,只是这个update被加了一个“transitions"的标记。而这个标记,React内部处理更新的时候是会作为参考信息的。这就意味着,相比于setTimeout, 把一个update交给startTransition能够更早地被处理。而在于较快的设备上,这个过度是用户感知不到的

使用场景

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

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

useTransition

在使用startTransition更新状态的时候,用户可能想要知道transition的实时情况,这个时候可以使用React提供的hook api useTransition

    import { useTransition } from 'react';
    const [isPending, startTransition] = useTransition();

如果transition未完成,isPending值为true,否则为false

useDeferredValue

使得我们可以延迟更新某个不那么重要的部分

举例:如下图,当用户在输入框输入“书”的时候,用户应该立马看到输入框的反应,而相比之下,下面的模糊查询框如果延迟出现一会儿其实是完全可以接受的,因为用户可能会继续修改输入框内容,这个过程中模糊查询结果还是会变化,但是这个变化对用户来说相对没那么重要,用户最关心的是看到最后的匹配结果

    import {useDeferredValue, useState} from "react";
    import MySlowList from "../components/MySlowList";
    
    export default function UseDeferredValuePage(props) {
      const [text, setText] = useState("hello");
      const deferredText = useDeferredValue(text);
    
      const handleChange = (e) => {
        setText(e.target.value);
      };
      return (
        <div>
          <h3>UseDeferredValuePage</h3>
          {/* 保持将当前文本传递给 input */}
          <input value={text} onChange={handleChange} />
          {/* 但在必要时可以将列表“延后” */}
          <p>{deferredText}</p>
    
          <MySlowList text={deferredText} />
        </div>
      );
    }
    // MySlowList.js
    
    import React, {memo} from "react";
    
    function ListItem({children}) {
      let now = performance.now();
      while (performance.now() - now < 3) {}
      return <div className="ListItem">{children}</div>;
    }
    
    export default memo(function MySlowList({text}) {
      let items = [];
      for (let i = 0; i < 80; i++) {
        items.push(
          <ListItem key={i}>
            Result #{i} for "{text}"
          </ListItem>
        );
      }
      return (
        <div className="border">
          <p>
            <b>Results for "{text}":</b>
          </p>
          <ul className="List">{items}</ul>
        </div>
      );
    });

源码

补充(现代做法):本文止于 React 18,面试中常被追问 React 19(2024 年底稳定)。要点速记:

  • Actions:用 useActionStateuseFormStatus<form action={fn}> 统一处理表单提交的 pending / error / 乐观更新。
  • useOptimistic:在异步请求未完成前先渲染乐观结果。
  • use():可在渲染中读取 Promise 或 Context(可配合 Suspense),且允许条件调用,是 Hook 规则的一个例外。
  • ref 作为 prop:函数组件可直接接收 ref 作为普通 prop,forwardRef 不再必需。
  • 元数据 / 资源:组件内直接写 <title><meta><link> 会被提升到 <head>;样式表与脚本支持优先级与去重。
  • Server Components / Server Actions 进入稳定,配合 RSC 框架(如 Next.js App Router)使用。
  • ReactDOM.renderunmountComponentAtNode、字符串 ref、legacy Context 等已彻底移除,必须使用 createRoot / hydrateRoot

阅读全文

Last Updated:
Contributors: leeguooooo