前言
React 没法做到 JS 做不到的事,其任务调度也逃不开浏览器的事件循环。
相比起写 Vue,React 更需要开发者知道浏览器本身的各种特性。
缓存
一个父组件重新渲染时,它的所有后代组件也会重新渲染,即使它们的 props、state 等状态都没有变化。
区分 diff 算法的作用:
diff 算法的作用范围是虚拟 DOM,它不会阻止组件的函数被重新执行,它只负责比较新旧虚拟 DOM 树的差异,并且只会对有差异的部分更新真实 DOM。
当新一轮的渲染逻辑执行完毕后,会生成新的虚拟 DOM 树,并通过 diff 算法与上一轮旧的虚拟 DOM 树进行比较,找出差异,然后最小更新真实 DOM。
React 提供了一些优化手段,可以避免不必要的重新执行渲染逻辑,缓存组件、函数、计算结果等。
React.memo 缓存组件
memo 允许组件在 props 没有改变的情况下跳过重新渲染。
使用 memo 将组件包装起来,以获得该组件的一个记忆化版本。通常情况下,只要该组件的 props 没有改变,这个记忆化版本就不会在其父组件重新渲染时重新渲染。但 React 仍可能会重新渲染它:记忆化是一种性能优化,而非保证。
1 | import { memo } from "react"; |
可以传入第二个参数,一个比较函数 arePropsEqual
,接受两个参数:新旧props,用于自定义比较 props 的逻辑,返回 true
表示 props 没有改变。默认情况下,React 将使用 Object.is
比较每个 prop。
1 | export default function Parent() { |
1 | export default memo(function Child({ data }: { data: string }) { |
父组件重新渲染时,子组件将不再重新渲染(组件函数不被执行),所以子组件的 Effect 也不会被调用。
1 | Parent fn |
useMemo 缓存计算结果
如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。如果这个计算量较大,可以使用 useMemo 缓存计算结果。
1 | const cachedValue = useMemo(calculateValue, dependencies) |
将上一节的 data 改为一个对象再传递给子组件。
1 | // 父组件 |
再触发父组件重新渲染时,记忆化后的子组件也会重新渲染,因为 props 传递的是一个对象,每次父组件函数重新执行都会生成一个新的对象,导致 props 发生变化。
简单的无需计算得出的对象可以使用 useRef
保证引用不变。
1 | 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 | import { useState, useDeferredValue, memo } from "react"; |
不要遗忘了
memo
,否则每次父组件重新渲染时,SlowList 也会重新渲染,达不到优化的目的。
当迫切的任务执行后,再得到新的状态,触发重新渲染。
通过 useDeferredValue
你可以控制视图的更新优先级,让用户体验更加流畅。
被推迟的“后台”渲染是可中断的。例如,如果你再次在输入框中输入,React 将会中断渲染,并从新值开始重新渲染。React 总是使用最新提供的值。
如果 React 正在重新渲染一个大型列表,但用户进行了另一次键盘输入,React 会放弃该重新渲染,先处理键盘输入,然后再次开始在后台渲染。详见:延迟一个值与防抖和节流之间有什么不同?
让 React 可以切分任务
React 是通过调度器来调度任务的,为了好理解,可以将一个任务单元理解为一次组件函数的调用。而 React 时间切片的最小单位也是组件函数,你应该保证单个组件函数的执行时间尽可能短,以便 React 执行单个任务时,能够在一帧内完成,不造成页面卡顿。
时间切片的最小单位是组件函数:你没有办法打断一个 JS 函数的执行,或者在一个 JS 函数执行时动态插入其它函数的执行。即使是 Generator 函数,你也只能控制它每一段的执行时机,而不能在执行栈中间让出。
1 | const SlowComponent = function () { |
上面的 SlowComponent 组件函数执行时间过长,React 的调度器不会起作用,该函数进入到执行栈后,一切都晚了,页面将会老老实实被阻塞 1s。
1 | const SlowComponent = function SlowList() { |
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 不能。
1 | const [text, setText] = useState(''); |
例子
下面是一个 Tab 切换的选项卡的例子,其中渲染第二个选项卡是耗时的。
1 | import { memo, useState } from "react"; |
当点击栏二后,整个应用都被阻塞了 1s,用户体验很差。可以使用 useTransition
来优化。
现在,点击栏二后,栏二的内容不会立即显示,而是在后台渲染,且不会阻塞用户的操作,如果用户在栏二渲染完成前切换到其他栏,那么栏二的内容渲染会被取消。
1 | function switchTab(tab: number) { |
startTransition
可以直接从 react 中导入 startTransition
函数使用(如果你用不到 isPending 的话),效果是一样的。
1 | import { startTransition } from "react"; |
lazy 组件懒加载
lazy 函数可以让你懒加载一个组件,只有在组件首次渲染时才会加载组件代码(按需加载)。
1 | import { lazy } from 'react'; |
load 函数:
lazy 接受一个 load
函数,该函数需要返回一个 Promise
,resolve 后,React 将结果 .default
渲染为 React 组件。如果 reject,则 React 将抛出 reason 给最近的错误边界处理。
返回的 Promise 和 Promise 的解析值都将被缓存,因此 React 不会多次调用 load 函数。
使用位置:
应该在模块顶层使用 lazy,不要在组件内部使用,否则每次组件重新渲染时都会重新加载子组件代码,导致状态重置。
1 | function App() { |
lazy 组件通常和 Suspense 组件一起使用,以指示子组件正在加载中。
Suspense 后备方案组件
Suspense 组件可以指示 React 在等待加载异步组件时渲染一些 fallback
(后备方案) 内容。
React 将展示后备方案直到 children 需要的所有代码和数据都加载完成。
1 | <Suspense fallback={<Loading />}> |
只有启用了 Suspense 的数据源才会激活 Suspense 组件,包括:
- 支持 Suspense 的框架如 Relay 和 Next.js。
- 使用 lazy 懒加载组件代码。
- 使用 use 读取 Promise 的值。
Suspense 无法检测在 Effect 或事件处理程序中获取数据的情况。
配合 lazy 使用:
1 | export default function Loading() { |
dangerouslySetInnerHTML 和 innerHTML 作用一样,但名称用于警告开发者,这个属性是危险的,可能导致 XSS 攻击,应该手动对插入的内容进行过滤。
1 | import { marked } from "marked"; |
首次点击显示预览时,会显示 Loading…,2s 后显示 markdown 的预览,并能看到网络请求了相关组件文件,往后多次点击显示预览时,不会再加载组件文件,因为已经缓存了。
1 | import { Suspense, useState, lazy, ComponentType } from "react"; |
use 读取Promise值(Canary)
use 函数用于读取类似于 Promise 或 Context 的值。这是一个预发布的 API。
读取 Context:
不同于 useContext
,use 可以在条件语句和循环中调用,更加灵活。
1 | if (show) { |
读取 Promise:
使用 use 可以让组件在异步数据还未准备好时暂停渲染,等数据准备好后再继续渲染,从而避免显示不完整的 UI。
如果没有 use,你可能会这样写:通过 useState 设置一个状态,然后在 useEffect 中读取 Promise 的值。
1 | export default function UseTest() { |
与传统的异步数据加载(使用 useEffect + useState)不同,use 函数让你无需管理这些状态,直接返回异步结果,代码更加简洁。
1 | export default function UseTest() { |
实现 use 函数
首先需要知道,单独使用 use、lazy 是会报错的,必须配合 Suspense 使用。
1 | Consider adding an error boundary to your tree to customize error handling behavior. |
use、lazy 等钩子在渲染过程中可能会抛出一个 Promise 来表示数据尚未准备好,而 Suspense 会 catch 捕获该 promise 来管理异步流程。
- 组件挂起:当一个组件在渲染过程中需要等待某些异步操作完成(如数据获取),它会抛出一个 Promise。
- 捕获挂起:Suspense 会捕获到这个被抛出的 Promise。此时,React 知道该组件还未准备好渲染,需要等待异步操作完成。
- 显示备用内容:查找最近的 Suspense 组件,并显示其 fallback 属性中定义的备用内容。
- 异步操作完成:一旦 Promise 完成,React 会重新尝试渲染挂起的组件。这时,组件所需的数据或资源已经准备好,可以正常渲染。
这里我们不关心 Suspense 的实现,它可能是在 .then 中告诉 React 重新渲染挂起的组件。
思路很清楚,在组件首次渲染时给 promise 加上 pending 状态标识,然后调用 then 方法,监听状态改变、保存结果,状态改变后,React 会自动重新渲染该组件,这时 use 就能直接返回结果了。
1 | function use<T = any>( |
因为 Suspense 需要捕获 use 抛出的 promise 错误,且 React 需要在 promise 完成后重新渲染 Suspense.children,所以 use 和 对应的 Suspense 不能在同一个组件中使用,应该是父子组件的关系。
useSyncExternalStore
useSyncExternalStore 用于同步外部状态 store 到 React 组件。
参数:
subscribe
函数,用于订阅 store 的变化,返回一个取消订阅的函数。当外部 store 发生变化时,能通过该函数通知 React 重新调用 getSnapshot 并在需要的时候重新渲染组件。getSnapshot
函数,用于获取 store 的快照。当新旧快照不相同(Object.is),React 会重新渲染组件。getServerSnapshot
(可选),在服务端渲染时,React 会使用该快照作为初始值。
简单说,你需要在外部状态变化时执行 subscribe 函数传入的 callback
回调,以通知 React 通过 getSnapshot
获取最新的状态,并在新旧状态不同时重新渲染组件。
1 | import { useSyncExternalStore } from "react"; |
使用:
1 | import { useOnlineStatus } from "./useOnlineStatus"; |
useDebugValue
useDebugValue 用于在 React 开发者工具中为自定义 Hook 添加标签。
以 useOnlineStatus 为例,默认情况下,React 开发者工具只会显示其用到的 Hook 的标签。可以预见,当内部数据结构变得复杂后,这么显示是不够直观反应 Hook 的作用的。
1 | hooks |
通过 useDebugValue
我们可以为其添加标签,让开发者工具更加直观。
1 | export function useOnlineStatus() { |
用什么作为标签,完全取决于开发者,可以是任何值,只要能更好的理解这个 Hook 的作用。
useId
useId 用于生成稳定且唯一的 ID,通常用于传递给无障碍属性。
该 ID 在单个 React 渲染树内是唯一的,也就是说在同一次渲染中,useId 生成的 ID 对于每个组件实例都是唯一的,不会重复。且多次渲染间,ID 值也是稳定和组件实例一一对应,不会变化。
1 | export default function Form() { |