React18 带来的变化,如何正确升级到新版本

关于 React 的闲言碎语,React18 带来的变化,如何正确升级到新版本。

React18 的一些变化

groups multiple state updates into a single re-render

This is because React used to only batch updates during a browser event (like click), but here we’re updating the state after the event has already been handled.

  • React17 和之前的版本只会 browser event 的同步过程中执行 batch updates。
  • React18 版本中使用 createRoot, 所用更新会自动合并。This means that updates inside of timeouts, promises, native event handlers or any other event will batch the same way as updates inside of React events. We expect this to result in less work rendering, and therefore better performance in your applications

Note: React only batches updates when it’s generally safe to do. For example, React ensures that for each user-initiated event like a click or a keypress, the DOM is fully updated before the next event. This ensures, for example, that a form that disables on submit can’t be submitted twice. 所以用 state 来展示按钮的 loading 状态是可行的。

flushSync

Usually, batching is safe, but some code may depend on reading something from the DOM immediately after a state change. For those use cases, you can use ReactDOM.flushSync() to opt out of batching.

一些 hack 失效

比如 class component 中 setTimeout 中的更新会合并。

Class components had an implementation quirk where it was possible to synchronously read state updates inside of events. This means you would be able to read this.state between the calls to setState:

js
1handleClick = () => {
2  setTimeout(() => {
3    this.setState(({ count }) => ({ count: count + 1 }));
4
5    // { count: 1, flag: false }
6    console.log(this.state);
7
8    this.setState(({ flag }) => ({ flag: !flag }));
9  });
10};

In React 18, this is no longer the case. Since all of the updates even in setTimeout are batched, React doesn’t render the result of the first setState synchronously—the render occurs during the next browser tick. So the render hasn’t happened yet:

js
1handleClick = () => {
2  setTimeout(() => {
3    this.setState(({ count }) => ({ count: count + 1 }));
4
5    // { count: 0, flag: false }
6    console.log(this.state);
7
8    this.setState(({ flag }) => ({ flag: !flag }));
9  });
10};

If this is a blocker to upgrading to React 18, you can use ReactDOM.flushSync to force an update, but we recommend using this sparingly:

js
1handleClick = () => {
2  setTimeout(() => {
3    ReactDOM.flushSync(() => {
4      this.setState(({ count }) => ({ count: count + 1 }));
5    });
6
7    // { count: 1, flag: false }
8    console.log(this.state);
9
10    this.setState(({ flag }) => ({ flag: !flag }));
11  });
12};
  1. Updates queued in the commit phase (componentDidMount/componentDidUpdate/useLayoutEffect) are executed synchronously
  2. Updates queued in the passive effects phase (useEffect callbacks) are deferred to the end of the effects phase
    • Effects originating from intentional user-initiated events (like clicks, key presses, etc — we call them discrete, here's the list) now run synchronously at the end of the current task (facebook/react#21150). This gives them consistent timing. In Legacy roots, they are unfortunately inconsistent and we can't fix that (facebook/react#20074 (comment)). So I would expect setting state from such effects to also be synchronous.
    • Updates queued in passive effects are scheduled to run at different priorities after all passive effects run.
    • The priority they're scheduled at depends on how the original update(s) were scheduled. Most of the time, updates inside of passive effects are flushed synchronously in a task with normal priority, at the latest. This is true even if the original update was inside a discrete event, flush sync, a timeout, promise tick, or transition.
    • The only time this is different (currently) is in offscreen trees. Those passive effects are scheduled in a task at Idle priority.
    • The "passive effect phase" ends when all effects have run. During that phase, updates may be scheduled, but they will run after the passive effect phase ends, according to their priority and how they were scheduled.

过去存在的误解

js
1setState(1);
2
3setTimeout(() => {
4  setState(2)
5}, 0)
6// 过去认为会批量更新

The updates outside setTimeout will be batched together and updates inside setTimeout will be batched together separately so it will be 2 separate renders. As far as I understand since the code inside setTimeout is asynchronous and will be executed after some delay(until event loop pops from the task queue and pushes to the call stack) hence React will not be batching it.

I know I also saw a mention of "5ms" timing somewhere, but I think that was in reference to how long React will work before yielding back to the browser, and I may have misinterpreted that as "React will batch updates across ticks that occur within 5ms of each other".

I am not sure about the 5ms timing, but batching isn't related to queuing within 5ms as far as I understand, batching makes sure that app isn't rendered until it executes the whole callback. But If we use the concurrent api startTransition instead of setTimeout then the startTransition will be executed immediately and the updates inside it will be marked as transition which could be interrupted incase of some urgent update (click) kicking in.

discrete input

In React 17 and below, the function passed to useEffect typically fires asynchronously after the browser has painted. The idea is to defer as much work as possible until paint so that the user experience is not delayed. This includes things like setting up subscriptions.

For example, imagine you're building a form that disables "submit" after the first submission:

js
1useEffect(() => {
2  if (!disableSubmit) {
3    const form = formRef.current;
4    form.addEventListener('submit', onSubmit);
5    return () => {
6      form.removeEventListener('submit', onSubmit);
7    };
8  }
9}, [disableSubmit, onSubmit]);

If the user attempts to submit the form multiple times in quick succession, we need to make sure the effects from the first event have completely finished before the next input is processed. So we synchronously flush them.

We don't need to do this for events that aren't discrete, because we assume they are not order-dependent and do not need to be observed by external systems.

似乎其实用 useLayoutEffect 也就行了,但是 useLayoutEffect 会让其导致的更新也会同步执行。
Note that this only affects the timing of when useEffect functions are called. It does not affect the priority of updates that are triggered inside a useEffect. They still get the default priority. (This is in contrast to useLayoutEffect, which not only calls the effect callback synchronously but also gives synchronous priority to its updates.)

runtime: 'automatic'

react 17 没有任何功能上的提升,重点就是优化了编译后的代码。以前的 jsx 编译后的代码为

javascript
1React.createElement('tagName', props, ...)

所以需要在每个 jsx/tsx 文件中需要引入一个 umd 的 React,所以之前的代码都是这样的

javascript
1//  component.tsx
2import React from 'react'
3
4export const Component = () => {
5  return <div/>
6}

但是在17之后的 React 编译有所优化,编译后的代码变为了

javascript
1_jsx('tagName', props, ...)

此时我们的代码也可以随之精简为

javascript
1//  component.tsx
2export const Component = () => {
3  return <div/>
4}

但是要达到这样的目的需要设置 tsconfig: "jsx": "react-jsx" 或者类似 babel 或者 swc 等的 runtime: 'automatic' 的值。

来自新版 React 文档的感悟

新版文档地址: https://react.dev

为什么多个 JSX 标签需要被一个父元素包裹?

JSX 虽然看起来很像 HTML,但在底层其实被转化为了 JavaScript 对象,你不能在一个函数中返回多个对象,除非用一个数组把他们包装起来。这就是为什么多个 JSX 标签必须要用一个父元素或者 Fragment 来包裹。

  • key 值在兄弟节点之间必须是唯一的。 不过不要求全局唯一,在不同的数组中可以使用相同的 key。
  • key 值不能改变,否则就失去了使用 key 的意义!所以千万不要在渲染时动态地生成 key。

设想一下,假如你桌面上的文件都没有文件名,取而代之的是,你需要通过文件的位置顺序来区分它们 ——— 第一个文件,第二个文件,以此类推。也许你也不是不能接受这种方式,可是一旦你删除了其中的一个文件,这种组织方式就会变得混乱无比。原来的第二个文件可能会变成第一个文件,第三个文件会成为第二个文件……

React 里需要 key 和文件夹里的文件需要有文件名的道理是类似的。它们(key 和文件名)都让我们可以从众多的兄弟元素中唯一标识出某一项(JSX 节点或文件)。而一个精心选择的 key 值所能提供的信息远远不止于这个元素在数组中的位置。即使元素的位置在渲染的过程中发生了改变,它提供的 key 值也能让 React 在整个生命周期中一直认得它。

你可能会想直接把数组项的索引当作 key 值来用,实际上,如果你没有显式地指定 key 值,React 确实默认会这么做。但是数组项的顺序在插入、删除或者重新排序等操作中会发生改变,此时把索引顺序用作 key 值会产生一些微妙且令人困惑的 bug。
与之类似,请不要在运行过程中动态地产生 key,像是 key={Math.random()} 这种方式。这会导致每次重新渲染后的 key 值都不一样,从而使得所有的组件和 DOM 元素每次都要重新创建。这不仅会造成运行变慢的问题,更有可能导致用户输入的丢失。所以,使用能从给定数据中稳定取得的值才是明智的选择。
有一点需要注意,组件不会把 key 当作 props 的一部分。Key 的存在只对 React 本身起到提示作用。如果你的组件需要一个 ID,那么请把它作为一个单独的 prop 传给组件: <Profile key={id} userId={id} />