React 学习笔记-系列
React-记-1
React-记-2
React-记-3

前言

React 没法做到 JS 做不到的事,其任务调度也逃不开浏览器的事件循环。
相比起写 Vue,React 更需要开发者知道浏览器本身的各种特性。

缓存

一个父组件重新渲染时,它的所有后代组件也会重新渲染,即使它们的 props、state 等状态都没有变化。

区分 diff 算法的作用:
diff 算法的作用范围是虚拟 DOM,它不会阻止组件的函数被重新执行,它只负责比较新旧虚拟 DOM 树的差异,并且只会对有差异的部分更新真实 DOM。
当新一轮的渲染逻辑执行完毕后,会生成新的虚拟 DOM 树,并通过 diff 算法与上一轮旧的虚拟 DOM 树进行比较,找出差异,然后最小更新真实 DOM。

React 提供了一些优化手段,可以避免不必要的重新执行渲染逻辑,缓存组件、函数、计算结果等。

React.memo 缓存组件

memo 允许组件在 props 没有改变的情况下跳过重新渲染。

使用 memo 将组件包装起来,以获得该组件的一个记忆化版本。通常情况下,只要该组件的 props 没有改变,这个记忆化版本就不会在其父组件重新渲染时重新渲染。但 React 仍可能会重新渲染它:记忆化是一种性能优化,而非保证。

1
2
3
4
import { memo } from "react";
export default memo(function Child() {
// ......
});

可以传入第二个参数,一个比较函数 arePropsEqual,接受两个参数:新旧props,用于自定义比较 props 的逻辑,返回 true 表示 props 没有改变。默认情况下,React 将使用 Object.is 比较每个 prop。

父组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export default function Parent() {
const [, forceUpdate] = useState(0);

console.log("Parent fn");
useInsertionEffect(() => {
console.log("Parent insertionEffect");
});
useLayoutEffect(() => {
console.log("Parent layoutEffect");
});
useEffect(() => {
console.log("Parent rendered");
});

const data = "Child";

return (
<div>
Parent
<button
onClick={() => {
forceUpdate((prev) => prev + 1);
}}
>
父组件重新渲染
</button>
<Child data={data}></Child>
</div>
);
}
子组件
1
2
3
4
5
6
7
8
9
10
11
12
13
export default memo(function Child({ data }: { data: string }) {
console.log("Child fn");
useInsertionEffect(() => {
console.log("Child insertionEffect");
});
useLayoutEffect(() => {
console.log("Child layoutEffect");
});
useEffect(() => {
console.log("Child rendered");
});
return <div>{data}</div>;
});

父组件重新渲染时,子组件将不再重新渲染(组件函数不被执行),所以子组件的 Effect 也不会被调用。

1
2
3
4
Parent fn
Parent insertionEffect
Parent layoutEffect
Parent rendered

useMemo 缓存计算结果

如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。如果这个计算量较大,可以使用 useMemo 缓存计算结果。

1
const cachedValue = useMemo(calculateValue, dependencies)

将上一节的 data 改为一个对象再传递给子组件。

1
2
3
4
5
6
7
// 父组件
const data = { title: "Child" };
<Child data={data}></Child>
// 子组件
export default memo(function Child({ data }: { data: { title: string } }) {
return <div>{data.title}</div>;
});

再触发父组件重新渲染时,记忆化后的子组件也会重新渲染,因为 props 传递的是一个对象,每次父组件函数重新执行都会生成一个新的对象,导致 props 发生变化。

简单的无需计算得出的对象可以使用 useRef 保证引用不变。

1
2
const dataRef = useRef({ title: "Child" });
// 现在重新渲染父组件时,子组件不会重新渲染

但有时候数据是需要计算得出的,特别是有响应式值参与计算时,就可以使用 useMemo 缓存计算结果。和 Effect 一样,第二个参数是依赖项数组,只有依赖项发生变化时才会重新计算。

1
const data = useMemo(() => ({ title: "title" }), []);

useMemo 经常与 memo 相配合,保证组件的记忆化,而不需要手动深比较 props。

useCallback 缓存函数

组件的内部函数在每次渲染时都会被重新创建,useCallback 可以缓存函数,避免不必要的重新创建。

1
const cachedFn = useCallback(fn, dependencies)

和 useMemo 其实差不多,只是 useCallback 缓存的是函数,useMemo 缓存的是任意值(也可以是函数)。

非阻塞更新 state

有些时候,某些 state 的更新并不需要立即生效,也就是不需要立刻触发重新渲染,React 提供了一些 API 来实现非阻塞更新 state。让开发者可以控制更新的优先级,提高用户体验。

useDeferredValue

useDeferredValue 接受一个值,返回一个延迟更新的值。通常传入一个 state,可以延迟更新 state。

在初始渲染期间,返回的延迟值与你提供的值相同。在更新期间,延迟值滞后于最新的值,React 首先会在不更新延迟值的情况下进行重新渲染,然后在后台尝试使用新接收到的值进行重新渲染。

下面是一个搜索的例子,用户在输入期间,通常不要求立即更新搜索结果,可以使用 useDeferredValue 延迟更新搜索结果,直到用户停止输入。试着一直按住键盘不放,搜索结果不会立即更新,而是等待用户停止输入后再更新。

一个搜索的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useState, useDeferredValue, memo } from "react";

export default function SearchComponent() {
const [text, setText] = useState("");
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}

// 使用memo缓存SlowList组件,只有在text变化时才会重新渲染
const SlowList = memo(function SlowList({ text }: { text: string }) {
const items = Array.from({ length: 200 }, (_, i) => (
<SlowItem key={i} text={text} />
));
return <ul className="items">{items}</ul>;
});

function SlowItem({ text }: { text: string }) {
const startTime = performance.now();
while (performance.now() - startTime < 1) {
// 每个 item 暂停 1ms,模拟极其缓慢的代码
}
return <li className="item">Text: {text}</li>;
}

不要遗忘了 memo,否则每次父组件重新渲染时,SlowList 也会重新渲染,达不到优化的目的。

当迫切的任务执行后,再得到新的状态,触发重新渲染。

通过 useDeferredValue 你可以控制视图的更新优先级,让用户体验更加流畅。

被推迟的“后台”渲染是可中断的。例如,如果你再次在输入框中输入,React 将会中断渲染,并从新值开始重新渲染。React 总是使用最新提供的值。
如果 React 正在重新渲染一个大型列表,但用户进行了另一次键盘输入,React 会放弃该重新渲染,先处理键盘输入,然后再次开始在后台渲染。详见:延迟一个值与防抖和节流之间有什么不同?

让 React 可以切分任务

React 是通过调度器来调度任务的,为了好理解,可以将一个任务单元理解为一次组件函数的调用。而 React 时间切片的最小单位也是组件函数,你应该保证单个组件函数的执行时间尽可能短,以便 React 执行单个任务时,能够在一帧内完成,不造成页面卡顿。

时间切片的最小单位是组件函数:你没有办法打断一个 JS 函数的执行,或者在一个 JS 函数执行时动态插入其它函数的执行。即使是 Generator 函数,你也只能控制它每一段的执行时机,而不能在执行栈中间让出。

错误的模拟
1
2
3
4
5
6
7
const SlowComponent = function () {
const startTime = performance.now();
while (performance.now() - startTime < 1000) {
// 模拟极其缓慢的代码
}
return <div>SlowComponent</div>;
};

上面的 SlowComponent 组件函数执行时间过长,React 的调度器不会起作用,该函数进入到执行栈后,一切都晚了,页面将会老老实实被阻塞 1s。

正确的模拟,将 1s 耗时的任务,分散为 1000 个 1ms 的任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const SlowComponent = function SlowList() {
const items = Array.from({ length: 1000 }, (_, i) => (
<SlowItem key={i} text={i.toString()} />
));
return <ul className="items">{items}</ul>;
};

function SlowItem({ text }: { text: string }) {
const startTime = performance.now();
while (performance.now() - startTime < 1) {
// 每个 item 暂停 1ms,模拟极其缓慢的代码
}
return <li className="item">Text: {text}</li>;
}

React 没法做到 JS 做不到的事,其任务调度也逃不开浏览器的事件循环
React 将组件函数的调用作为宏任务,而宏任务本身是不会阻塞浏览器渲染的,React 通过调度器在一帧内尽可能多的执行宏任务,如果单个宏任务被拿到执行栈中执行耗时过长,那么就会阻塞浏览器渲染、后续任务的执行,造成页面卡顿。所以,尽可能写出 React 可以切分的任务。

涉及到 React Fiber 的内容了,这些底层的具体实现以后再说吧。

useTransition

useTransition 用于将状态更新标记为非阻塞的 Transition,使得开发者可以控制状态更新的优先级,确保用户交互不被阻塞,适用于复杂或耗时的操作。

标记为 Transition 的状态更新可以被其他状态更新打断

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

isPending 是一个布尔值,表示是否存在待处理的 transition。通常用于做一些指示效果。
startTransition 接受一个回调函数,将其中的状态更新标记为 transition,该回调函数会在后台执行,不会阻塞当前渲染。

与 useDeferredValue 的区别:useDeferredValue 产生一个延迟更新的副本,绑定了该副本的 UI 会在后台延迟更新,而 useTransition 是直接将该状态更新标记为非阻塞的 transition,降低了优先级,不会产生副本。所以 useDeferredValue 可以用于处理用户输入,而 useTransition 不能。

不应将控制输入框的状态变量标记为 transition
1
2
3
4
5
6
7
8
9
10
const [text, setText] = useState('');
// ...
function handleChange(e) {
// ❌ 不应将受控输入框的状态变量标记为 Transition
startTransition(() => {
setText(e.target.value);
});
}
// ...
return <input value={text} onChange={handleChange} />;

例子

下面是一个 Tab 切换的选项卡的例子,其中渲染第二个选项卡是耗时的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { memo, useState } from "react";

export default function Tab() {
const [tab, setTab] = useState(0);

function switchTab(tab: number) {
setTab(tab);
}

return (
<div>
<button onClick={() => switchTab(0)}>栏一</button>
<button onClick={() => switchTab(1)}>栏二</button>
<button onClick={() => switchTab(2)}>栏三</button>
<TabContent tab={tab}></TabContent>
</div>
);
}

const TabContent = memo(function ({ tab }: { tab: number }) {
return (
<div>
{tab === 0 && <div>栏一内容</div>}
{tab === 1 && <SlowComponent></SlowComponent>}
{tab === 2 && <div>栏三内容</div>}
</div>
);
});

当点击栏二后,整个应用都被阻塞了 1s,用户体验很差。可以使用 useTransition 来优化。

现在,点击栏二后,栏二的内容不会立即显示,而是在后台渲染,且不会阻塞用户的操作,如果用户在栏二渲染完成前切换到其他栏,那么栏二的内容渲染会被取消。

1
2
3
4
5
function switchTab(tab: number) {
startTransition(() => {
setTab(tab);
});
}

startTransition

可以直接从 react 中导入 startTransition 函数使用(如果你用不到 isPending 的话),效果是一样的。

1
import { startTransition } from "react";

lazy 组件懒加载

lazy 函数可以让你懒加载一个组件,只有在组件首次渲染时才会加载组件代码(按需加载)。

1
2
import { lazy } from 'react';
const LazyComponent = lazy(() => import('./LazyComponent.js'));

load 函数:
lazy 接受一个 load 函数,该函数需要返回一个 Promiseresolve 后,React 将结果 .default 渲染为 React 组件。如果 reject,则 React 将抛出 reason 给最近的错误边界处理。
返回的 Promise 和 Promise 的解析值都将被缓存,因此 React 不会多次调用 load 函数

使用位置:
应该在模块顶层使用 lazy,不要在组件内部使用,否则每次组件重新渲染时都会重新加载子组件代码,导致状态重置。

1
2
3
4
function App() {
// 🔴 Bad: 这将导致在重新渲染时重置所有状态
const LazyComponent = lazy(() => import('./LazyComponent.js'));
}

lazy 组件通常和 Suspense 组件一起使用,以指示子组件正在加载中。

Suspense 后备方案组件

Suspense 组件可以指示 React 在等待加载异步组件时渲染一些 fallback(后备方案) 内容。

React 将展示后备方案直到 children 需要的所有代码数据都加载完成。

1
2
3
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>

只有启用了 Suspense 的数据源才会激活 Suspense 组件,包括:

  1. 支持 Suspense 的框架如 Relay 和 Next.js。
  2. 使用 lazy 懒加载组件代码。
  3. 使用 use 读取 Promise 的值。

Suspense 无法检测在 Effect 或事件处理程序中获取数据的情况。

配合 lazy 使用:

Loading.tsx
1
2
3
4
5
6
7
export default function Loading() {
return (
<p>
<i>Loading...</i>
</p>
);
}

dangerouslySetInnerHTML 和 innerHTML 作用一样,但名称用于警告开发者,这个属性是危险的,可能导致 XSS 攻击,应该手动对插入的内容进行过滤。

MarkdownPreview.tsx
1
2
3
4
5
6
7
8
9
10
11
12
import { marked } from "marked";
import DOMPurify from "dompurify";

export default function MarkdownPreview({ markdown }: { markdown: string }) {
const sanitizedHTML = DOMPurify.sanitize(marked.parse(markdown) as string);
return (
<div
className="md-content"
dangerouslySetInnerHTML={{ __html: sanitizedHTML }}
/>
);
}

首次点击显示预览时,会显示 Loading…,2s 后显示 markdown 的预览,并能看到网络请求了相关组件文件,往后多次点击显示预览时,不会再加载组件文件,因为已经缓存了。

LazyComponent.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { Suspense, useState, lazy, ComponentType } from "react";
import Loading from "../Markdown/Loading";
// 懒加载 MarkdownPreview 组件
const MarkdownPreview = lazy(() =>
delayForComponent(import("../Markdown/MarkdownPreview"))
);

export default function LazyComponent() {
const [markdown, setMarkdown] = useState("Hello, **world**!");
const [showPreview, setShowPreview] = useState(false);
return (
<div>
<label>
<p>输入 Markdown:</p>
<textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
/>
<input
type="checkbox"
checked={showPreview}
onChange={(e) => setShowPreview(e.target.checked)}
/>
显示预览
</label>
{showPreview && (
// 使用 Suspense 包裹懒加载的组件
<Suspense fallback={<Loading />}>
<h2>预览</h2>
<MarkdownPreview markdown={markdown}></MarkdownPreview>
</Suspense>
)}
</div>
);
}

// 模拟组件延迟加载
function delayForComponent(promise: Promise<{ default: ComponentType<any> }>) {
return new Promise((resolve) => {
setTimeout(resolve, 2000);
}).then(() => promise);
}

use 读取Promise值(Canary)

use 函数用于读取类似于 PromiseContext 的值。这是一个预发布的 API。

读取 Context:
不同于 useContext,use 可以在条件语句和循环中调用,更加灵活。

1
2
3
4
if (show) {
const theme = use(ThemeContext);
return <hr className={theme} />;
}

读取 Promise:
使用 use 可以让组件在异步数据还未准备好时暂停渲染,等数据准备好后再继续渲染,从而避免显示不完整的 UI。

如果没有 use,你可能会这样写:通过 useState 设置一个状态,然后在 useEffect 中读取 Promise 的值。

使用 useState 和 useEffect 读取 Promise 的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function UseTest() {
const [message, setMessage] = useState<string | null>(null);

useEffect(() => {
fetchMessage().then((message) => setMessage(message));
}, []);

return <div>{message ?? "Loading..."}</div>;
}

// 模拟请求数据
function fetchMessage(): Promise<string> {
return new Promise((resolve) => setTimeout(resolve, 1000, "Hello, world!"));
}

与传统的异步数据加载(使用 useEffect + useState)不同,use 函数让你无需管理这些状态,直接返回异步结果,代码更加简洁。

使用 use 读取 Promise 的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export default function UseTest() {
// 还是需要一个状态来保存 Promise
const [messagePromise, setMessagePromise] = useState<Promise<string>>();
function postMessage() {
setMessagePromise(fetchMessage());
}

return (
<>
<button onClick={postMessage}>请求消息</button>
{messagePromise && (
<Suspense fallback={<p>⌛清求中...</p>}>
<Message messagePromise={messagePromise!} />
</Suspense>
)}
</>
);
}

function Message({ messagePromise }: { messagePromise: Promise<string> }) {
// 使用 use 函数处理 Promise,获取数据
const messageContent = use(messagePromise);
return <p>{messageContent}</p>;
}

// 模拟请求数据
function fetchMessage(): Promise<string> {
return new Promise((resolve) => setTimeout(resolve, 2000, "Hello, world!"));
}

实现 use 函数

首先需要知道,单独使用 use、lazy 是会报错的,必须配合 Suspense 使用。

1
2
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.

use、lazy 等钩子在渲染过程中可能会抛出一个 Promise 来表示数据尚未准备好,而 Suspense 会 catch 捕获该 promise 来管理异步流程。

  1. 组件挂起:当一个组件在渲染过程中需要等待某些异步操作完成(如数据获取),它会抛出一个 Promise。
  2. 捕获挂起:Suspense 会捕获到这个被抛出的 Promise。此时,React 知道该组件还未准备好渲染,需要等待异步操作完成。
  3. 显示备用内容:查找最近的 Suspense 组件,并显示其 fallback 属性中定义的备用内容。
  4. 异步操作完成:一旦 Promise 完成,React 会重新尝试渲染挂起的组件。这时,组件所需的数据或资源已经准备好,可以正常渲染。

这里我们不关心 Suspense 的实现,它可能是在 .then 中告诉 React 重新渲染挂起的组件。

思路很清楚,在组件首次渲染时给 promise 加上 pending 状态标识,然后调用 then 方法,监听状态改变、保存结果,状态改变后,React 会自动重新渲染该组件,这时 use 就能直接返回结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function use<T = any>(
promise: { status?: string; result?: T } & Promise<T>
): T {
// 初始状态 status 在浏览器 Promise 中没有,所以需要自己定义
if (!promise.status) {
promise.status = "pending";
}
if (promise.status === "fulfilled") {
// 状态是 fulfilled,直接返回数据
return promise.result!;
} else if (promise.status === "rejected") {
// 状态是 rejected,抛出错误
throw promise.result;
} else {
// 状态是 pending
// 绑定 then 方法
promise.then(
(result: any) => {
// 成功时,设置状态为 fulfilled
promise.status = "fulfilled";
// 保存数据
promise.result = result;
},
(reason: any) => {
// 失败时,设置状态为 rejected
promise.status = "rejected";
// 保存错误
promise.result = reason;
}
);
// 抛出 Promise 对象
throw promise;
}
}

因为 Suspense 需要捕获 use 抛出的 promise 错误,且 React 需要在 promise 完成后重新渲染 Suspense.children,所以 use 和 对应的 Suspense 不能在同一个组件中使用,应该是父子组件的关系。

useSyncExternalStore

useSyncExternalStore 用于同步外部状态 store 到 React 组件。

参数:

  1. subscribe 函数,用于订阅 store 的变化,返回一个取消订阅的函数。当外部 store 发生变化时,能通过该函数通知 React 重新调用 getSnapshot 并在需要的时候重新渲染组件。
  2. getSnapshot 函数,用于获取 store 的快照。当新旧快照不相同(Object.is),React 会重新渲染组件。
  3. getServerSnapshot (可选),在服务端渲染时,React 会使用该快照作为初始值。

简单说,你需要在外部状态变化时执行 subscribe 函数传入的 callback 回调,以通知 React 通过 getSnapshot 获取最新的状态,并在新旧状态不同时重新渲染组件。

一个获取网络状态的自定义 Hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useSyncExternalStore } from "react";
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true
);
return isOnline;
}
function subscribe(callback: () => void) {
// 利用浏览器的原生 API 订阅网络状态变化,与 React 建立联系。
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}

使用:

1
2
3
4
5
6
7
8
9
10
import { useOnlineStatus } from "./useOnlineStatus";
export default function Online() {
const isOnline = useOnlineStatus();

return (
<div>
<h2>{isOnline ? "Online" : "Offline"}</h2>
</div>
);
}

useDebugValue

useDebugValue 用于在 React 开发者工具中为自定义 Hook 添加标签。

以 useOnlineStatus 为例,默认情况下,React 开发者工具只会显示其用到的 Hook 的标签。可以预见,当内部数据结构变得复杂后,这么显示是不够直观反应 Hook 的作用的。

1
2
3
hooks
OnlineStatus:
1 SyncExternalStore: true

通过 useDebugValue 我们可以为其添加标签,让开发者工具更加直观。

1
2
3
4
5
6
export function useOnlineStatus() {
// ...
useDebugValue(isOnline ? "Online" : "Offline");
}
OnlineStatus: "Online"
1 SyncExternalStore: true

用什么作为标签,完全取决于开发者,可以是任何值,只要能更好的理解这个 Hook 的作用。

useId

useId 用于生成稳定唯一的 ID,通常用于传递给无障碍属性。

该 ID 在单个 React 渲染树内是唯一的,也就是说在同一次渲染中,useId 生成的 ID 对于每个组件实例都是唯一的,不会重复。且多次渲染间,ID 值也是稳定和组件实例一一对应,不会变化。

1
2
3
4
5
6
7
8
9
10
11
12
export default function Form() {
const id = useId(); // 生成唯一 ID,作为前缀。
return (
<form>
<label htmlFor={id + '-firstName'}>名字:</label>
<input id={id + '-firstName'} type="text" />
<hr />
<label htmlFor={id + '-lastName'}>姓氏:</label>
<input id={id + '-lastName'} type="text" />
</form>
);
}