主题
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 false5. 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?);