浅析 React18 更新特性

1,062 阅读10分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

浅析 React18 更新特性

v17.0.0 - 2020.10.21

v17.0.1 - 2020.10.22

v17.0.2 - 2021.05.23

v18.0.0 - 2022.05.30

我们从时间线可以看出,React 为了推出 18 的版本,经历了很长时间的开发,随着18的上线,他带来了哪些新的东西呢?我们一起来看一下。

批处理(Batch Update)

批处理:如果一个点击事件中有两个状态需要被更新,React会将他们处理为一次。即在一次更新中修改两个状态。

在 React 17 中,并不是每次都会被批处理合并为一次。这里列举批处理的3种场景:

第一种场景 (最常见)

//React 17
function App() {
  console.log('rendering。。。。: ');
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount((c) => c + 1); 
    setFlag((f) => !f); 
  }
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

我们触发点击事件以后, handleClick 方法会修改 count 与 flag 这两个状态,虽然是两个状态的改变,但是在 render 阶段会被合并到一次更新任务中,即只触发一次更新。效果如下:

正常情况 (1).gif

第二种场景

修改的状态行为存在于 setTimeout 或者 Promise 中。我们先来看 setTimeout 的情况。

setTimeout

function App() {
  console.log("rendering。。。。: ");
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setTimeout(() => {
      setCount((c) => c + 1);
      setFlag((f) => !f);
    },300);
  }
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

tutieshi_640x404_4s.gif

可以看到存在于 setTimeout 中的两个状态的变化并没有合并为一次更新任务,而是触发了两次 render。我们再来看 promise 的情况:

promise

function App() {
  console.log("rendering。。。。: ");
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    new Promise((resolve) => {
      resolve()
    }).then(() => {
      setCount((c) => c + 1);
      setFlag((f) => !f);
    });
  }
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

tutieshi_640x404_6s.gif

可以看出,情况与 setTimeout 一样,修改两次状态变化,触发两次的 render。

第三种场景

原生 js 情况:

function App() {
  console.log("rendering。。。。: ");
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  function EventListen(){
    setCount((c) => c + 1);
    setFlag((f) => !f);
  }
  let handleClick = useCallback( () => {
    document.body.addEventListener("click", EventListen);
  });

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

在点击事件 handleClick 中,我们监听 click 事件,在监听的回调函数中修改 count 、flag 两个状态的变化。我们来看具体的效果:

tutieshi_640x404_2s.gif 可以看到,依旧是触发了两次的 render。

React 18 上面三种情况所有的更新都变为一次更新。

Until React 18, we only batched updates during the React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.

在 React18 之前,我们只在 React 事件处理程序期间批量更新。其他的情况,例如 Promise、setTimeout、原生事件的处理程序或者其他事件的更新,不会触发 React 的批处理。

如果我不想批处理我该怎么办?

通过 flushSync 方法。

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

import ReactDOM from 'react-dom/client';

function App() {
  console.log('render。。。')
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
      flushSync(() => {
        setCount((c) => c + 1)
      });
      flushSync(() => {
        setFlag((f) => !f);
      })
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <App />
);

tutieshi_640x360_16s.gif 注意:

  1. 这个方法显著的影响性能。
  2. 这个方法会强制显示 Suspense 上的 fallback。

React18 中程序运行期间更新状态始终通过批处理。但是有一种边界情况。

在 React17 中, 类组件 有一个特殊情况:

当我们在 setTimeout 中,我们在两个 setState 之间设置状态,状态可以被修改掉。如下

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      flag: false,
      count: 0,
    };
  }
  handleClick = () => {
    setTimeout(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
      // { count: 1, flag: false }
      console.log(this.state);

      this.setState(({ flag }) => ({ flag: !flag }));
    });
  };
  render() {
    return (
      <div>
        <button onClick={this.handleClick.bind(this)}>Next</button>
        <h1 style={{ color: this.state.flag ? "blue" : "black" }}>
          {this.state.count}
        </h1>
      </div>
    );
  }
}

tutieshi_640x427_6s.gif

但是在 React18 中状态改变是批处理,所以不会被同步的渲染。(等到下一个浏览器 tick)。

如果我们想要让他强制更新,调用 ReactDOM.flushSync 强制更新

tutieshi_640x427_4s.gif

在 React17 的一些优化的工具库中通过 unstable_batchedUpdates 进行性能优化,这个Api 在 18 的版本中仍然会存在,但是未来的主要版本中,可能会被删除。

根应用创建替换

在 React18 以前我们创建应用根节点:

import { render } from 'react-dom';
const container = document.getElementById('app');
render(<App tab="home" />, container);

//卸载应用根节点
unmountComponentAtNode(container);

在 React18 中创建应用根节点:

import { createRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App tab="home" />);

//卸载应用根节点
root.unmount();

如果我们依赖的 React 版本在18,我们使用 legacyMode 模式,会提示这样的 warning,但并不影响我们的运行,这只是提示我们可以使用 currentMode 的方式。

image

这样设计的原因:

First, this fixes some of the ergonomics of the API when running updates. As shown above, in the legacy API, you need to continue to pass the container into render, even though it never changes. This also mean that we don’t need to store the root on the DOM node, though we still do that today.

// SSR 相关

Second, this change allows us to remove the hydrate method and replace with with an option on the root; and remove the render callback, which does not make sense in a world with partial hydration.

除了创建的方面提供了新的方法,在render 完成后的回调函数也做了更新。

React 18 之前的版本:

ReactDOM.render(container, <App tab="home" />, function() {
  // Called after inital render or any update.
  console.log('rendered').
});

React 18 版本,删除了上面的回调函数:这个回调函数在某些情况下并不能在期望的时间被触发。现在可以通过在根元素上绑定requestIdleCallback, setTimeout, or a ref 。

With partial hydration and progressive SSR, the timing for this callback would not match what the user expects. To avoid the confusion moving forward, we recommend using requestIdleCallback, setTimeout, or a ref callback on the root.

function App({ callback }) {
  // Callback will be called when the div is first created.
  return (
    <div ref={callback}>
      <h1>Hello World</h1>
    </div>
  );
}
const rootElement = document.getElementById("root");
const root = ReactDOMClient.createRoot(rootElement);
root.render(<App callback={() => console.log("renderered")} />);

严格模式(Strict Mode)

严格模式只在开发环境中才会起作用,不会影响生产构建!

React 17 严格模式介绍

React18 的严格模式新功能:

  1. 修复了在 React 17的时候每个组件都会render两次,但是其中一次是被 React 默认隐藏了,用户无法得到任何提示。现在是通过柔和的方式告诉用户,颜色为灰色。#20090

image

  1. 为了消除不安全的副作用。在添加了<StrictMode> 以后。React 会有意渲染两次。(mount -> unmount ->mount)。

Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:

  1. 在以下的生命周期,在提交 render 阶段之前会多次调用渲染阶段生命周期,或者在不进入 render 流程的时候进行调用。目的是检验是否存在副作用,React 自身不能检测副作用,但是通过这些手段来辅助用户发现它们。
  • constructor

  • componentWillMount(或UNSAFE_componentWillMount)

  • componentWillReceiveProps(或UNSAFE_componentWillReceiveProps)

  • componentWillUpdate(或UNSAFE_componentWillUpdate)

  • getDerivedStateFromProps

  • shouldComponentUpdate

  • render

  • setState更新函数(第一个参数)

React 18 以前,添加 StrictMode 的情况:

  useEffect(() => {
    console.log('mount: ', count);
    return () => {
      console.log('unmounted')
    }
  },[])
  
const container = document.getElementById("root");
render(
  <React.StrictMode>
    <App tab="home" />
  </React.StrictMode>,
  container
);

image

React18

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

image

  useEffect(() => {
    console.log('mount: ', count);
    setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
    return () => {
      console.log('unmounted')
    }
  },[])

当我们的程序中有这样一逻辑的时候,我们忘记关闭定时器,页面中始终会有一个定时器在跑。我们来看一下React 在 17,18的表现。

tutieshi_640x427_5s.gif

通过上面的效果,我们可以看得到 React 17每次都会递增1.

React18,由于批处理的存在,在同一时间切片内合并两次状态更新,也就是每 1秒加 2,这个错误的呈现结果,可以帮助用户提早的发现问题。

React 18 的初衷是希望帮助用户提早发现问题。但实际情况它所造成的恶劣影响已经远远超过了它产生的高光时刻,比如我们如果有两次的 ajax 请求,或者赋值等等...

解决方案:

tutieshi_640x427_4s.gif 如果我们想要避免这种情况,将 React.StrictMode 标签注释掉即可。

  1. 在未来,React 18的严格模式可以保证组件拥有可重用的状态。在组件卸载以后,重新挂载可以使用卸载前的状态。类似于 vue 中的 keep-alive。#19

不能缓存的场景,组件挂载的效果:

* React mounts the component.
  * Layout effects are created.
  * Effects are created.

缓存的场景,组件挂载的效果:

* React mounts the component.
    * Layout effects are created.
    * Effect effects are created.
* React simulates effects being destroyed on a mounted component.
    * Layout effects are destroyed.
    * Effects are destroyed.
* React simulates effects being re-created on a mounted component.
    * Layout effects are created
    * Effect setup code runs

组件卸载时,按照正常流程卸载:

* React unmounts the component.
  * Layout effects are destroyed.
  * Effect effects are destroyed.

卸载与安装涉及到 Api:

  • componentDidMount

  • componentWillUnmount

  • useEffect

  • useLayoutEffect

  • useInsertionEffect

新 Hook

useId

支持同一个组件在客户端和服务端生成相同的唯一的 ID.

useId不适用于在列表中生成 key

function Checkbox() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Do you like React?</label>
      <input id={id} type="checkbox" name="react"/>
    </>
  );
};

startTransition(过渡)

这个 Api 可以让我们状态改变的过程中,保持视觉上的反馈并且还可以让浏览器保持响应。

场景介绍

如果我们有一个搜索的 input ,在输入信息以后,需要渲染搜索后的内容,如果我们的数据量很大的情况,就会阻塞页面的渲染进程,我们输入在 input 上的内容也会不流畅,(我们通常采用 debouncing 来进行处理)。我们先看效果如下:

function App() {
  const [content, setContent] = useState("");
  const [value, setInputValue] = useState("");
  return (
    <div>
      <div>
        <input
          value={value}
          onChange={(e) => {
            setInputValue(e.target.value);
            setContent(e.target.value);
          }}
        />
      </div>
      {Array.from(new Array(30000)).map((_, index) => (
        <div key={index}>{content}</div>
      ))}
    </div>
  );
}

tutieshi_640x427_9s.gif

我们使用 startTransition 以后:

function App() {
  const [content, setContent] = useState("");
  const [value, setInputValue] = useState("");
  return (
    <div>
      <div>
        <input
          value={value}
          onChange={(e) => {
            setInputValue(e.target.value);
            startTransition(() => {
              setContent(e.target.value);
            });
          }}
        />
      </div>
      {Array.from(new Array(30000)).map((_, index) => (
        <div key={index}>{content}</div>
      ))}
    </div>
  );
}

tutieshi_640x427_6s.gif

解决了什么

我们根据上面的场景,我们通过伪代码来描述过程。

  1. 用户信息录入 setSearchQuery(input).

  2. 根据信息的录入,渲染搜索结果。 setSearchQuery(input)

用户希望在录入的时候能够看到录入信息,即过程一是立刻更新的。而第二次的更新,可以接受具有一定的延时效果。

但遗憾的是,在 React18 以前,所有的更新都是最高优先级的更新,并且不可以被中断。而 React18 增加了优先级的逻辑,更新流程变为异步可中断。

所以上面的过程: 过程一:紧急最高优的任务。过程二高优不紧急任务。

过渡期间可以做什么?

React 提供了一个带有 isPending 标示的Hook。当我们非紧急任务执行的 isPending = true,可以增加一个loading的过渡UI组件。

const [isPending, startTransition] = useTransition();

什么时间使用?

  1. 涉及到大量更新的情况。

  2. 网络请求影响 UI 更新的情况。搭配 Suspense 食用更好。

useDeferredValue

接受一个值并返回这个值的副本,这个副本会被推迟更新。与startTransition类似,都会在commit阶段打上延迟更新的tag,不同的是 startTransition 针对的是一个函数,而useDeferredValue针对的是一个value。

function ListDemo(props) {
  return Array.from(new Array(30000))
    .map((item, index) => <div key={index}>{props.content}</div>);
}
function App() {
  const [content, setContent] = useState("");
  const [value, setInputValue] = useState("");
  const contentReferred = useDeferredValue(content);
  return (
    // const deferredList = useDeferredValue(list);
    <div>
      <div>
        <input
          value={value}
          onChange={(e) => {
            setInputValue(e.target.value);
            setContent(e.target.value);
          }}
        />
      </div>
        <ListDemo
          content={contentReferred}
        ></ListDemo>
    </div>
  );
}

image

试一试

不使用此 Api

image

试一试

useSyncExternalStore

提供给 React 外部状态集成的库。

useInsertionEffect

它允许 CSS-in-JS 库解决在渲染中注入样式的性能问题。

其他

React 组件的返回值

如果组件返回的是 undefined,以后就不会再抛出异常。支持组件 return undefined。

Suspense

如果我们没有在 suspense 上提供 fallback ,向上寻找下一个边界。这样就导致调试过程中比较混乱。

<Suspense fallback={<Loading />}>   // <--- this boundary is used
  <Suspense>                        // <--- this boundary is skipped, no fallback
    <Page />
  </Suspense>
</Suspense>

React 18的版本

如果没有提供 fallback ,默认将 fallback 渲染为 null。不会再出现越界的情况。

<Suspense fallback={<Loading />}>   // <--- not used
  <Suspense>                        // <--- this boundary is used, rendering null for the fallback
    <Page />
  </Suspense>
</Suspense>

Type 类型的变化

props 中的 children 必须显示定义。

相关 Type 的改动 --> React18 width TypeScript

React18 类型签名的升级指南 --> upgrading-react-18-typescript

脚本迁移方案: --> upgrading to @types/react@^18.0.0

除了上述的一些重要的变化,还有一些其他的改动,比如 删除了 unstable_changedBits 这个 Api删除object-assign** polyfill.等等**。

有兴趣的可以看 All Changes --> React18 tag18.0.0

文档参考

reactjs.org/docs/hooks-…

github.com/reactwg/rea…

github.com/reactwg/rea…

github.com/reactwg/rea…

github.com/facebook/re…

github.com/facebook/re…