Skip to content

React 18 升级日志 ​

汇总 React 18 带来的 TypeScript 类型变动、并发模式下的批处理与 Suspense 生命周期差异,以及 flushSync、startTransition 等新增 API。 官方文档:升级日志、升级文档。

一、TypeScript 类型变动 ​

1. FC 中不再有 children 类型 ​

原先:

tsx
interface modalContentProps {
  id?: string;
  [key: string]: string | ReactNode;
}

现在需要手动指定 children:

tsx
interface modalContentProps {
  id?: string;
  children?: ReactNode; // 需手动指定 children
  [key: string]: string | ReactNode;
}

2. FC 中不再包含 ref 的类型定义 ​

原先把 ref 写在 props 里,并给 forwardRef 的返回值标注 FC:

tsx
interface Props {
  ref: ForwardedRef<any>;
  line: number;
}

const Single: FC<Props> = forwardRef(({ line }, ref) => {});

现在 ref 交给 forwardRef 的泛型声明,Props 中不再包含 ref:

tsx
interface Props {
  line: number;
}

const Single = forwardRef<ForwardedRef<any>, Props>(({ line }, ref) => {});

3. React.Key 新增 bigint 类型 ​

原先 React.Key 的类型为 string | number,现在扩展为:

ts
type Key = string | number | bigint;

4. ReactNode 移除了对 DOM Element 的直接支持 ​

原先在 React 17 中,Element 可以赋值给 ReactNode:

tsx
// ✅ React 17 中,Element 可以赋值给 ReactNode
const element: Element = document.createElement("div");
const reactNode: React.ReactNode = element; // ✅ 编译通过

现在在 React 18 中会报类型错误:

tsx
// ❌ React 18 中,Element 不能赋值给 ReactNode
const element: Element = document.createElement("div");
const reactNode: React.ReactNode = element; // ❌ 类型错误

二、行为变动 ​

1. 并发模式与同步模式 ​

使用新的 API createRoot 开启并发模式:

tsx
import ReactDOM from "react-dom/client";

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

在 React 18 中,依旧可以使用同步模式:

tsx
import ReactDOM from "react-dom";

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

2. defaultProps 属性会告警提示 ​

原先使用 defaultProps:

tsx
SingleTagModal.defaultProps = {
  title: "标签",
};

现在改为函数参数的默认值:

tsx
const SingleTagModal = ({ title = "标签" }) => {};

3. @testing-library/react-hooks 弃用,合并到 @testing-library/react ​

@testing-library/react 从 13 版本开始支持 React 18 的并发模式,此时 @testing-library/react-hooks 的核心功能(主要是 renderHook)已内置到 @testing-library/react 13 中。

原先:

tsx
import { renderHook } from "@testing-library/react-hooks";

现在:

tsx
import { renderHook } from "@testing-library/react";

4. 自动批处理 ​

React 17 同步模式:在 React 合成事件中,setState 的更新会被批处理;在 JS 原生事件、宏任务、微任务的情况下,setState 的更新不会被批处理。

tsx
// 同步模式
setTimeout(() => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React 会渲染两次,每次更新一个状态(没有批处理)
}, 1000);

React 18 开启并发模式后,所有的更新都会自动批量处理:

tsx
// 并发模式
setTimeout(() => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // 最终,React 将仅会重新渲染一次(批处理)
}, 1000);

1. 示例:ahooks 的 useRequest ​

比较常用的 ahooks useRequest,常常在 onSuccess 回调中处理请求成功后的行为。useRequest 部分源码:

ts
// 请求之前,将 loading 置为 true
this.setState({
  loading: true,
  params,
  ...state,
});

// 处理请求
const res = await servicePromise;

// 请求结束,将 loading 置为 false
this.setState({
  data: res,
  error: undefined,
  loading: false,
});

// 执行 onSuccess
this.options.onSuccess?.(res, params);

await 后面的任务为微任务,因此同步模式与并发模式的执行结果不一样:

tsx
import { useState } from "react";

import { useRequest } from "ahooks";

const getData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(1);
    }, 1000);
  });
};

export default function User() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  const { loading } = useRequest(getData, {
    onSuccess: () => {
      setCount(count + 1);
      setFlag(!flag);
    },
  });

  console.log("渲染", count, flag, loading);

  return <span> {loading ? "loading" : count}</span>;
}

同步模式下的打印结果:loading 置为 false 后,又分别渲染了两次,分别将 count 置为 1、flag 置为 true:

text
渲染 0 false true
渲染 0 false true
渲染 0 false false
渲染 1 false false
渲染 1 true false

并发模式下的打印结果:loading 置为 false 与 count、flag 的更新是一起的:

text
渲染 0 false true
渲染 0 false true
渲染 1 true false

5. Suspense 内的生命周期 ​

情景:为实现 KeepAlive 功能,使用冻结库 react-freeze 进行组件冻结,本质是借助 Suspense 实现。

1. 在函数组件中的表现 ​

阶段同步模式并发模式
重新挂起不触发任何生命周期钩子触发 useLayoutEffect 的 cleanup
重新恢复不触发任何生命周期钩子触发 useLayoutEffect

2. 在类组件中的表现 ​

阶段同步模式并发模式
重新挂起不触发任何生命周期钩子触发 componentWillUnmount
重新恢复触发 componentDidUpdate触发 componentDidMount

为了解决重新触发 useLayoutEffect 的问题,可以自定义 Hook:

ts
import type { DependencyList, EffectCallback } from "react";
import { useLayoutEffect, useRef } from "react";

export default function useSuspenseMountEffect(effect: EffectCallback, deps?: DependencyList) {
  // 追踪是否已经执行过
  const hasRunRef = useRef(false);
  // 保存上一次的依赖项
  const prevDepsRef = useRef<DependencyList | undefined>(deps);

  useLayoutEffect(() => {
    // 检查依赖项是否发生变化
    const depsChanged =
      !deps || !prevDepsRef.current || deps.some((dep, i) => !Object.is(dep, prevDepsRef.current?.[i]));

    // 只有在首次执行或依赖项变化时才执行 effect
    if (!hasRunRef.current || depsChanged) {
      hasRunRef.current = true;
      prevDepsRef.current = deps;
      return effect();
    }
  }, deps);
}

6. 子组件不能在 useLayoutEffect 阶段读取到父组件传递的 ref ​

场景:使用 ref 引用一个 DOM 节点,然后将 ref 传递给子组件,子组件在挂载时不能获取到 DOM 节点。

原因:React 在 commit 阶段才把 ref.current 写到 DOM 节点;useLayoutEffect 也在 commit 后执行,但不同组件的副作用调用顺序可能导致子组件在其 useLayoutEffect 里看到 ref.current 仍为 null(尤其在并发模式或组件分层复杂时)。

结论:父组件内直接读取自己挂的 ref 更可靠;把 refObject 传给子组件,不保证子组件在其布局 effect 中能同步读到非 null。

三、新增的 API 与 Hook ​

1. flushSync ​

1. 退出批量更新 ​

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

const App: React.FC = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  console.log("更新");

  return (
    <div
      onClick={() => {
        // 第一次更新
        flushSync(() => {
          setCount1((count) => count + 1);
        });

        // 第二次更新
        flushSync(() => {
          setCount2((count) => count + 1);
        });
      }}
    >
      <div>count1:{count1}</div>
      <div>count2:{count2}</div>
    </div>
  );
};

export default App;

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

2. 确保在下一行代码运行时 DOM 已更新 ​

tsx
import { useRef, useState } from "react";

function App() {
  const [count, setCount] = useState(0);

  const ref = useRef(null);

  const onClick = () => {
    setCount((count) => count + 1);

    // 打印值为 0,因为此时 DOM 还未更新
    // 如果使用 flushSync 包裹 setCount,打印结果将变为 1
    console.log("ref", ref.current.innerHTML);
  };

  return (
    <button onClick={onClick} ref={ref}>
      {count}
    </button>
  );
}

export default App;

当遇到 React 17 中没有、但在 18 中出现的难以解决的问题时,优先考虑使用 flushSync,而非 setTimeout。

2. startTransition ​

可以让你在不阻塞 UI 的情况下更新 state:

tsx
import { startTransition } from "react";

// 紧急更新:显示输入的内容
setInputValue(input);

// 将任何内部的状态更新都标记为过渡更新
startTransition(() => {
  // 过渡更新:展示结果
  setSearchQuery(input);
});

3. useTransition ​

可以让你在不阻塞 UI 的情况下更新 state,比 startTransition 多一个 pending 状态:

tsx
const [isPending, startTransition] = useTransition();

4. useDeferredValue ​

让开发者延迟更新 UI 的某些部分。可以延迟渲染不紧急的部分,类似于防抖但没有固定的延迟时间:

tsx
const [value, setValue] = useState("");

const deferredValue = useDeferredValue(value);

5. useId ​

支持同一个组件在客户端和服务端生成相同的唯一 ID,避免 hydration 的不兼容:

tsx
const id = useId();

6. useSyncExternalStore ​

用于订阅外部数据源的变化,并确保 React 组件能够同步地响应这些变化。大部分情景是用来处理 Redux、MobX 等外部状态管理库的数据订阅:

ts
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);

7. useInsertionEffect ​

useInsertionEffect 是一个专为 CSS-in-JS 库的开发者打造的钩子,比 useLayoutEffect 执行时机更早:

ts
useInsertionEffect(setup, deps?);