51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

React-记-2

Context {#Context}

父子组件通过 props 显式传递数据,当组件层级较深时,这种方式会变得很麻烦。Context(上下文)允许向所有后代组件传递数据(信息)。

不同于 Vue 的 provide/inject,React 的 Context 和 State 一样,是一个 hook,第一次使用会感觉有点奇怪。

基本操作: 通过 createContext 创建一个 Context 对象(通常在独立的文件中),然后使用 Provider 组件 向后代组件传递数据,后代组件通过 useContext hook 获取数据。

下面直接使用 React 官网的例子。
一个带层级的嵌套的标题区域,如果不使用 Context,需要一层一层的给每个 Heading 传递 level。

首先创建一个 Context 对象,用于传递标题的等级。 LevelContext.ts

|---------------|------------------------------------------------------------------------------------------------------------------------| | 1 2 3 | import { createContext } from "react"; // 创建一个 Context 对象,默认值为 1 export const LevelContext = createContext(1); |

Heading 组件很简单,从上下文中获取 level 返回对应等级的标题标签,别忘了还得接收标题内容 children。 Heading.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 | import { useContext } from "react"; import { LevelContext } from "./LevelContext"; interface HeadingProps { children: React.ReactNode; } export default function Heading({ children }: HeadingProps) { // 从 Context 上下文中获取 level const level = useContext(LevelContext); switch (level) { case 1: return <h1>{children}</h1>; case 2: return <h2>{children}</h2>; case 3: return <h3>{children}</h3>; case 4: return <h4>{children}</h4>; case 5: return <h5>{children}</h5>; case 6: return <h6>{children}</h6>; default: throw Error("未知的 level:" + level); } } |

一个区域的标题大小是相同的,所以 level 绑定在每个 Section 上很合理,再由 Section 通过 Provider 组件value 属性提供 levelContext 上下文值给 Heading 组件。

区域的等级通常是递增的,所以后代 Section 组件也可以是之前的 level + 1,如果没有传入 level,则使用上下文中的 level + 1。 Section.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 | import { LevelContext } from "./LevelContext.js"; import "./Section.css"; interface SectionProps { level: number; children: React.ReactNode; } export default function Section({ level, children }: SectionProps) { const _level = useContext(LevelContext); // 确定最终的 level let finalLevel = level; // 如果没有传入 level,则使用上下文中的 level + 1 if (finalLevel === undefined || finalLevel === null) { finalLevel = _level + 1; } return ( <section className="section"> {/* 通过 Provider 组件的 value 属性提供该 Context 值*/} <LevelContext.Provider value={finalLevel}> {children} </LevelContext.Provider> </section> ); } |

最后当然是使用 Section 组件了。

|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 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 | import Heading from "./Heading.js"; import Section from "./Section.js"; export default function Page() { return ( <Section level={1}> <Heading>主标题</Heading> <Section> <Heading>副标题</Heading> <Heading>副标题</Heading> <Heading>副标题</Heading> <Section> <Heading>子标题</Heading> <Heading>子标题</Heading> <Heading>子标题</Heading> <Section> <Heading>子子标题</Heading> <Heading>子子标题</Heading> <Heading>子子标题</Heading> </Section> </Section> </Section> </Section> ); } |

后代组件始终会从最近的 祖先 Provider 组件获取对应的 Context 值,如果没有 Provider 组件,会使用 Context 的默认值

祖先组件可以预先提供 好各种 Context 值,而后代组件只需要关心自己需要的值,这样可以写出适应周围环境的组件。在主题切换、国际化、身份验证、路由等场景下,Context 会非常有用。

Consumer 组件订阅上下文 {#Consumer-组件订阅上下文}

除了 Provider 组件与 useContext 配合。上下文对象上还有个 Consumer 组件,可以用于订阅 Context 对象变化。

不过现在大多数还是使用 useContext 更加简洁,但如果想更明确地使用 render props 模式,就可以使用 Consumer

|------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | export default function Heading({ children }: HeadingProps) { return ( <LevelContext.Consumer> {(level) => { switch (level) { case 1: return <h1>{children}</h1>; case 2: return <h2>{children}</h2>; case 3: return <h3>{children}</h3>; case 4: return <h4>{children}</h4>; case 5: return <h5>{children}</h5>; case 6: return <h6>{children}</h6>; default: throw Error("未知的 level:" + level); } }} </LevelContext.Consumer> ); } |

displayName {#displayName}

displayName 是一个字符串,用于调试 目的,React DevTools 使用它来确定 Provider 组件的名称,如果没有设置,则都显示为 Context.Provider。 在创建 Context 时设置 displayName

|-------------|-------------------------------------------------------------------------------------------------------------------------------| | 1 2 | export const LevelContext = createContext(1); LevelContext.displayName = "LevelContext"; // 显示为 LevelContext.Provider |

Context 与 State {#Context-与-State}

Context 不局限于静态值,如果在下一次渲染时传递不同的值,React 将会更新依赖它的所有后代组件,所以 Context 经常与 State 一起使用。

如下,每次点击按钮,整体标题等级都会增加。

|---------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 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 | export default function Page() { const [level, setLevel] = useState(1); return ( <> <button onClick={() => setLevel(level + 1)}>增加等级</button> <Section level={level}> <Heading>主标题</Heading> <Section> <Heading>副标题</Heading> <Heading>副标题</Heading> <Heading>副标题</Heading> <Section> <Heading>子标题</Heading> <Heading>子标题</Heading> <Heading>子标题</Heading> <Section> <Heading>子子标题</Heading> <Heading>子子标题</Heading> <Heading>子子标题</Heading> </Section> </Section> </Section> </Section> </> ); } |

Context 与 Reducer {#Context-与-Reducer}

Reducer 可以整合组件的状态更新逻辑,Context 可以将信息深入传递给其他组件,两者结合可以实现复杂的业务逻辑。文档

在上一章中,我们使用 Reducer 实现了一个 TaskList (前往)。

可以发现 onChangeTask 等事件处理程序以及 taskList 在 props 中被传递了多层,但 TaskList 本身并没有使用这些方法,只是作为中转,将这些方法继续传递给 TaskItem、AddTask 组件。

Reducer 有助于保持事件处理程序的简短明了。但随着应用规模越来越庞大,你就可能会遇到别的困难。目前,tasks 状态和 dispatch 函数仅在顶级 TaskApp 组件中可用。要让其他组件读取任务列表或更改它,你必须显式传递当前状态和事件处理程序,将其作为 props。
比起通过 props 传递它们,你可能想把 tasks 状态和 dispatch 函数都 放入 context。这样,所有的在 TaskApp 组件树之下的组件都不必一直往下传 props 而可以直接读取 tasks 和 dispatch 函数。

1、首先为数据和 dispatch 函数各创建一个 Context 对象。 TaskContext.ts

|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 | import { createContext } from "react"; import { TaskList } from "./type"; import { TaskAction } from "./TaskApp"; export const TaskContext = createContext<TaskList>([]); export const TaskDispatchContext = createContext<React.Dispatch<TaskAction>>( () => {} ); |

2、在 TaskApp 组件中使用 Provider 组件提供数据和 dispatch 函数。 TaskApp.tsx

|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 | import { TaskContext, TaskDispatchContext } from "../Task/TaskContext"; <TaskContext.Provider value={taskList}> <TaskDispatchContext.Provider value={dispatch}> <TaskList></TaskList> </TaskDispatchContext.Provider> </TaskContext.Provider> |

3、去掉各个后代组件的 props,直接使用 useContext 获取数据和 dispatch 函数。 TaskList.tsx

|---------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import { TaskContext } from "./TaskContext"; export default function TaskList() { const taskList = useContext<TaskList>(TaskContext); return ( <div className="task-list"> <AddTask></AddTask> <ul> {taskList.map((task) => ( <li key={task.id}> <TaskItem task={task} /> </li> ))} </ul> </div> ); } |

TaskItem.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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | import { TaskDispatchContext } from "./TaskContext"; interface TaskProps { task: Task; } export default function TaskItem({ task }: TaskProps) { const [isEdit, setIsEdit] = useState(false); const [title, setTitle] = useState(task.title); const taskDispatchContext = useContext(TaskDispatchContext); function onChange(task: Task) { taskDispatchContext({ type: "change", payload: task }); } function onDelete(task: Task) { taskDispatchContext({ type: "delete", payload: task }); } let taskContent = null; if (isEdit) { taskContent = ( <> <input type="text" value={title} onChange={(e) => { setTitle(e.target.value); }} /> <button onClick={() => { if (!title.trim()) { return; } setIsEdit(false); onChange({ ...task, title }); }} > 保存 </button> <button onClick={() => { setIsEdit(false); setTitle(task.title); }} > 取消 </button> </> ); } else { taskContent = ( <> <span>{task.title}</span> <button onClick={() => { setIsEdit(true); }} > 编辑 </button> </> ); } return ( <div> <input type="checkbox" checked={task.done} onChange={() => { onChange({ ...task, done: !task.done }); }} /> {taskContent} <button onClick={() => { onDelete(task); }} > 删除 </button> </div> ); } |

AddTask.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 | import { TaskDispatchContext } from "./TaskContext"; export default function AddTask() { const [title, setTitle] = useState(""); const taskDispatchContext = useContext(TaskDispatchContext); function onAddTask(task: Task) { taskDispatchContext({ type: "add", payload: task }); } return ( <div> <input type="text" value={title} onChange={(e) => { setTitle(e.target.value); }} /> <button onClick={() => { if (!title.trim()) { return; } onAddTask({ id: Date.now(), title, done: false }); setTitle(""); }} > 添加 </button> </div> ); } |

现在,TaskApp 组件不会向下传递任何事件处理程序,TaskList 也不会。每个组件都会读取它需要的 context。
state 仍然 "存在于" 顶层 Task 组件中,由 useReducer 进行管理。不过,组件树里的组件只要导入这些 context 之后就可以获取 tasks 和 dispatch。

封装 Provider 组件 {#封装-Provider-组件}

为了更好的管理、提供数据,可以将 Context 与 Reducer 写在同一个文件中,并提供一个封装后的 Provider 组件。文档 TaskContext.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 43 44 45 46 47 48 49 50 51 52 53 | import { createContext, useReducer } from "react"; import { Task, TaskList } from "./type"; export const TaskContext = createContext<TaskList>([]); export const TaskDispatchContext = createContext<React.Dispatch<TaskAction>>( () => {} ); const initialTasksList = [ { id: 1, title: "任务1", done: false }, { id: 2, title: "任务2", done: false }, { id: 3, title: "任务3", done: false }, { id: 4, title: "任务4", done: false }, ]; interface TaskProviderProps { children: React.ReactNode; } export default function TaskProvider({ children }: TaskProviderProps) { const [taskList, dispatch] = useReducer(taskListReducer, initialTasksList); return ( <TaskContext.Provider value={taskList}> <TaskDispatchContext.Provider value={dispatch}> {children} </TaskDispatchContext.Provider> </TaskContext.Provider> ); } export interface TaskAction { type: "add" | "delete" | "change"; payload: Task; } function taskListReducer(taskList: Task[], action: TaskAction) { switch (action.type) { case "add": return [action.payload, ...taskList]; case "delete": return taskList.filter((item) => item.id !== action.payload.id); case "change": return taskList.map((item) => { if (item.id === action.payload.id) { return action.payload; } return item; }); default: return taskList; } } |

在 TaskApp 中使用 TaskProvider 为后代组件提供数据。 TaskApp.tsx

|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 | import TaskList from "./TaskList"; import TaskProvider from "./TaskContext"; export default function TaskApp() { return ( <TaskProvider> <TaskList></TaskList> </TaskProvider> ); } |

还可以封装 useContext 实现自定义 hook,使获取数据更加简洁。

像 useTasks 和 useTasksDispatch 这样的函数被称为自定义 Hook。 如果你的函数名以 use 开头,它就被认为是一个自定义 Hook。这让你可以使用其他 Hook,比如 useContext。 TaskContext.tsx

|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 | export function useTaskList() { return useContext(TaskContext); } export function useTaskDispatch() { return useContext(TaskDispatchContext); } |

现在所有的 context 和 reducer 连接部分都在 TasksContext 中。这保持了组件的干净和整洁,让我们专注于它们显示的内容,而不是它们从哪里获得数据。
TasksProvider 被视为页面的一部分,它知道如何处理 taskList。useTaskList 用来读取它们,useTaskDispatch 用来从组件树下的任何组件更新它们。

脱围机制 {#脱围机制}

React 在设计上是平台无关性的,在 React 设计的系统里,通过单向数据流、声明式描述 UI 组件,已经可以构建一个像模像样的应用了。但在实际项目中,总是会需要和外部环境交互,比如请求数据、操作 DOM等。

React 提供了一些 API 以实现脱围机制,让 React 应用可以和外部环境交互。

ref {#ref}

ref 和 state 一样可以存储组件的状态,但不会触发新的渲染 。使用 useRef 为组件添加 ref。通常用于获取 DOM 元素、组件实例等

|-------------------|------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 | import { useRef } from 'react'; const ref = useRef(0); // { // current: 0 // 你向 useRef 传入的值 // } |

useRef 返回一个具有 current 属性的对象,可以在渲染过程之外 读取和修改 current 属性的值。在渲染期间操作 ref 是不可靠的。如果组件需要存储一些值,但不影响渲染逻辑,就可以使用 ref。

ref 本身是一个普通对象,当改变 current 值时,它会立即改变,而不像 state 的快照机制。 一个秒表的例子,使用 ref 存储定时器的 ID。

|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 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 | import { useRef, useState } from "react"; export default function RefTest() { const [startTime, setStartTime] = useState(0); const [now, setNow] = useState(0); const intervalRef = useRef<number | undefined>(undefined); const secondsPassed = (now - startTime) / 1000; function handleStart() { // 保存开始时间 setStartTime(Date.now()); setNow(Date.now()); clearInterval(intervalRef.current); // 每 10 毫秒更新一次当前时间,记录当前定时器的 ID intervalRef.current = setInterval(() => { setNow(Date.now()); }, 10); } function handleStop() { clearInterval(intervalRef.current); } return ( <> <h1>时间过去了: {secondsPassed.toFixed(3)}</h1> <button onClick={handleStart}>开始</button> <button onClick={handleStop}>停止</button> </> ); } |

虽然 ref 本身的变化不会触发重新渲染,但其它 state 触发渲染后,ref 的变化也能反应到视图上。

总之,当你需要在多次渲染之间存储一些值,但不希望这些值的变化触发新的渲染时,就可以使用 ref。

操作 DOM {#操作-DOM}

React 会自动处理更新 DOM 以匹配组件的渲染输出,通常情况下并不需要直接操作 DOM。但还是难免会需要使用 DOM API,比如获取元素的尺寸、滚动位置等,ref 就可以帮我们获取原生 DOM 元素。

|-------------|-------------------------------------------------------------------------------------| | 1 2 | const titleRef = useRef<HTMLDivElement>(null); <div ref={titleRef}>秒表</div> |

前面提到过,不能在渲染过程中读取 ref 的值,因为 ref 的值在渲染过程中可能还没有被赋值。但你可以在组件挂载后读取 ref 的值。

|-------------|-----------------------------------------------------------------------------------------------| | 1 2 | const titleRef = useRef<HTMLDivElement>(null); console.log(titleRef.current); // null |

ref 回调函数 {#ref-回调函数}

每个组件的 ref 属性可以接受一个回调函数,当组件挂载、卸载、重渲染时,React 会调用这个函数,并传入组件的 DOM 元素。文档

这在获取数量不确定的 DOM 元素时非常有用,通常初始化 ref 为一个 Map,然后在回调函数中将 DOM 元素存入/移除 Map。 一个猫猫图片列表,点击按钮滚动到指定猫猫项。

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | import { useRef, useState } from "react"; export default function RefList() { // 猫猫图片列表 const [catList, setCatList] = useState(setupCatList); // 获取每个列表项DOM const itemsRef = useRef<Map<string, HTMLElement>>(new Map()); // 滚动到指定猫猫项 function scrollToCat(cat: string) { const node = itemsRef.current.get(cat); if (!node) return; node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); } return ( <> <nav> <button onClick={() => scrollToCat(catList[0])}>滚动到第一个</button> <button onClick={() => scrollToCat(catList[catList.length - 1])}> 滚动到最后一个 </button> </nav> <div> <ul style={{ display: "flex", gap: 10, width: 500, overflow: "auto", }} > {/* 遍历渲染猫猫图片列表 */} {catList.map((cat) => ( <li key={cat} // 将ref写成函数形式,将每个列表项的DOM节点存入Map中 ref={(node) => { const map = itemsRef.current; // 节点存在说明是挂载,不存在说明是卸载 if (node) { map.set(cat, node); } else { map.delete(cat); } }} > <img src={cat} /> </li> ))} </ul> </div> </> ); } // 获取猫猫图片列表 function setupCatList() { const catList = []; for (let i = 0; i < 10; i++) { catList.push("https://loremflickr.com/320/240/cat?lock=" + i); } return catList; } |

ref 回调的参数是 node,也就是 DOM 元素null(当ref分离时)。

  1. 首次渲染时,node 为 DOM 元素。
  2. 组件卸载时,node 为 null。
  3. 重渲染时,如果 ref 回调函数每次渲染都是不同的函数(通常就是这样),React 会先调用旧的回调函数,并传递 null,随后调用新的回调函数并传递 DOM 节点。
  4. 当某个元素被条件渲染移除时,React 会调用 ref 回调传递 null。

所以我们经常会写出如下代码,以确保 Map 中永远只有实际存在的 DOM 元素,防止内存泄漏。

|-------------------------|-----------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 | ref={(node) => { const map = itemsRef.current; if (node) { map.set(/** */); } else { map.delete(/** */); } }} |

清理逻辑(Canary) {#清理逻辑-Canary}

标题前带有 (Canary) 的内容是实验性的,版本策略Canary 渠道

除了主动判断 node 是否为 null,还可以使用回调函数的清理逻辑

ref 回调函数可以返回一个清理函数,当 ref 分离时(指当 DOM 元素从组件中被移除,或是 ref 指向的元素发生改变时),React 会调用这个清理函数。如果 ref 回调未返回函数,则当 ref 分离时,React 将以 null 重新调用该 ref 回调。 你可以写出如下代码,优化上面的例子

|-----------------------|-----------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 | ref={(node) => { const map = itemsRef.current; map.set(cat, node); return () => { map.delete(cat); } }} |

重渲染时,当你传递不同的 ref 回调,React 将调用前一个回调的清理函数(如果提供)。如果没有定义清理函数,ref则将使用null参数调用回调。下一个函数将使用 DOM 节点调用。
让 useEffect 绑定一个 ref,也能实现类似的效果。

forwardRef {#forwardRef}

ref 可以很轻松获取输出浏览器元素 的内置组件的对应 DOM 元素,但对于自定义组件,ref 会报错。

|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 | const inputRef = useRef<HTMLInputElement>(null); <MyInput ref={inputRef} /> Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? |

这是因为 React 不知道这个 ref 该绑定到哪个 DOM 元素上,需要子组件使用 forwardRef() 创建组件函数,以便将外部 ref 传递给内置组件。文档

|---------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 | const MyInput = forwardRef< HTMLInputElement, InputHTMLAttributes<HTMLInputElement> >((props, ref) => { return <input {...props} ref={ref} />; }); |

forwardRef<T, P = {}> 接受两个泛型参数 ,第一个是 ref 的类型,第二个是组件的 props 类型。forwardRef 返回一个组件,这个组件可以接受 ref 属性,然后将 ref 传递给内置组件。 让父组件控制子组件的输入框 DOM 元素

|---------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | const MyInput = forwardRef< HTMLInputElement, InputHTMLAttributes<HTMLInputElement> >((props, ref) => { return <input {...props} ref={ref} />; }); export function MyForm() { const inputRef = useRef<HTMLInputElement>(null); function handleClick() { inputRef.current!.focus(); } return ( <> <MyInput ref={inputRef} /> <button onClick={handleClick}>聚焦输入框</button> </> ); } |

useImperativeHandle {#useImperativeHandle}

useImperativeHandle 通常与 forwardRef 配合,以自定义向 ref 暴露的对象。文档

useImperativeHandle 接收两个参数,第一个是 ref 对象,第二个是一个函数,返回一个对象,这个对象就是向外暴露的对象。这样就可以在父组件中通过 ref.current 调用子组件中的方法。

|---------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 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 43 | interface MyInputRef { focus: () => void; clear: () => void; } const MyInput = forwardRef<MyInputRef, InputHTMLAttributes<HTMLInputElement>>( (props, ref) => { const inputRef = useRef<HTMLInputElement>(null); useImperativeHandle(ref, () => { return { focus() { inputRef.current!.focus(); }, clear() { inputRef.current!.value = ""; }, }; }); return <input {...props} ref={inputRef} />; } ); export function MyForm() { const inputRef = useRef<MyInputRef>(null); function handleClick() { inputRef.current!.focus(); } function handleClear() { inputRef.current!.clear(); } return ( <> <MyInput ref={inputRef} /> <button onClick={handleClick}>聚焦输入框</button> <button onClick={handleClear}>清空输入框</button> </> ); } |

何时使用:

  1. 当你希望子组件的某些方法能够被父组件调用时,例如触发一个动画、清理某些资源,或者执行某些状态更新。
  2. 当你希望提供一个简单的 API 来与子组件交互,而不直接暴露子组件的实现细节。

flushSync {#flushSync}

flushSync(cb) 会立即执行回调函数,通常用于强制同步更新组件的状态并立即重新渲染。文档

|------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | import { flushSync } from "react-dom"; export function FlushSync() { const [count, setCount] = useState(0); const pRef = useRef<HTMLParagraphElement>(null); const handleClick = () => { // 使用 flushSync 确保状态更新是同步的 flushSync(() => { setCount(count + 1); }); // 此时 pRef.current 已经是最新的 DOM 了 console.log(pRef.current?.textContent); }; return ( <div> <p ref={pRef}>Count: {count}</p> <button onClick={handleClick}>Increment</button> </div> ); } |

何时使用:

  1. 需要立即更新 UI:在某些情况下,你可能希望在处理某个事件时,立即看到 UI 的更新。例如,在复杂的交互中,如果需要在状态更改后立即读取更新后的值。
  2. 与 DOM 操作结合使用:在与 DOM 交互时,确保 React 的渲染与 DOM 的操作是同步的。
  3. 在调度器任务或异步任务中使用,而不是在渲染过程中使用。

flushSync 可能会严重影响性能,因此请谨慎使用。
flushSync 可能会强制挂起的 Suspense 边界显示其 fallback 状态。
flushSync 可能会在返回之前运行挂起的 Effect,并同步应用其包含的任何更新。
flushSync 可能会在必要时刷新回调函数之外的更新,以便刷新回调函数内部的更新。例如,如果有来自点击事件的挂起更新,React 可能会在刷新回调函数内部的更新之前刷新这些更新。

需要注意,你还是无法同步立刻获取 state 的最新值,因为 [[Scopes]] 中的闭包捕获的还是上一个值,详见闭包与内存泄漏

吐槽:React 确实不太好写,需要经常把握局部代码执行过程、时机,不过也可是我没写习惯,~~被Vue惯坏了(bushi)~~。

Effect {#Effect}

Effect 是 React 的一个重要概念,用于处理副作用与外部系统同步 ,比如数据获取、订阅、DOM 操作等。文档

在React组件中有两种代码逻辑:

  1. 渲染代码:位于组件的顶层,在这里处理 props 和 state,对它们进行转换,并返回希望在页面上显示的 JSX,这是纯计算的过程,得到一个确定的输出。
  2. 事件处理程序:由用户主动的交互触发,比如点击按钮、输入框输入等,这些事件处理程序可能会引起状态的改变,产生副作用。

但副作用不光只由用户交互引起,一些无条件的、业务逻辑需要的操作也可能引起副作用,但这些代码显然不能放在渲染代码中,所以 React 提供了多种 Effect 来处理这些副作用。

Effect 在提交结束后、页面更新后运行。此时是将 React 组件与外部系统(如网络或第三方库)同步的最佳时机。
副作用:任何改变程序状态或外部系统的行为。而在 React 中,大写的 Effect 是 React 中的专有定义------由渲染引起的副作用。

useEffect {#useEffect}

useEffect 当 React 组件完成渲染后,它会调用在组件内部定义的所有 useEffect 回调函数,你能在这里访问到最新的 state 和 DOM,因为其回调总是在更新后的组件状态下执行。 useEffect 的基本用法,每次渲染后获取最新的 count、DOM 元素。

|---------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import { useEffect, useRef, useState } from "react"; export default function EffectTest() { const [count, setCount] = useState(0); const pRef = useRef<HTMLParagraphElement>(null); useEffect(() => { console.log("effect", count, pRef.current?.textContent); }); return ( <div> <button onClick={() => { setCount(count + 1); }} > 增加 </button> <p ref={pRef}>{count}</p> </div> ); } |

每轮渲染(每次调用组件函数),都会产生一个新的 Effect,由于闭包的存在,每个 Effect 总是会捕获其创建时的 state 和 props,详见闭包与内存泄漏

依赖项 {#依赖项}

默认情况下,Effect 会在每次渲染后运行。但有时候我们只想在某些 state 或 props 改变时才运行 Effect,可以传入第二个参数,一个数组,包含 Effect 的依赖项。

只有所有 依赖项的值都与上一次渲染时完全相同,React 才会跳过重新运行该 Effect。React 使用 Object.is 来比较依赖项的值。 空数组表示只在组件挂载时运行 Effect。

|---------------|------------------------------------------| | 1 2 3 | useEffect(() => { // ... }, []); |

组件内部的所有值(包括 props、state 和组件体内的变量)都是响应式的。任何响应式值都可以在重新渲染时发生变化,都可以包括在 Effect 的依赖项中。

全局变量或组件外部可变值不能直接作为依赖项,这些值可以在 React 渲染数据流之外的任何时间发生变化,React 无法在其更改时重新同步 Effect。但你可以使用 useSyncExternalStore 来读取和订阅外部可变值,让其能被 React 追踪。

可以省略的依赖项:
组件内的 ref 可以在依赖数组中省略,ref 是稳定的,每轮渲染中调用同一个 useRef 时,总能获得相同的对象,永远不会导致重新运行 Effect。当然如果是外部传入的 ref,还是需要加入依赖项的。
useState 返回的 set 函数也是稳定的,因此通常也会被省略。

如果 lint 工具配置了 React,它将检查 Effect 代码中使用的每个响应式值是否已声明为其依赖项。
如果在省略某个依赖项时 linter 不会报错,那么这么做就是安全的。

依赖项并不是完全自由选择的,Effect 内部使用到的响应式值都应该作为依赖项,以确保 Effect 在这些值发生变化、触发渲染时可以重新运行。

清理函数 {#清理函数}

Effect 可以返回一个清理函数,用于清理 Effect 的副作用。通俗来说就是停止或撤销 Effect 所做的一切。

**执行时机:**React 会在每次 Effect 重新运行之前调用清理函数,并在组件卸载时最后一次调用清理函数。 清理 Effect 的副作用

|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 | useEffect(() => { console.log("添加副作用"); window.addEventListener("scroll", handleScroll); return () => { console.log("清除副作用"); window.removeEventListener("scroll", handleScroll); }; }); |

首次渲染时,你会在控制台看到如下输出,这是因为在开发环境中,React 会通过重新挂载组件来检测 BUG。

|---------------|---------------------------| | 1 2 3 | 添加副作用 清除副作用 添加副作用 |

你需要思考的不是"如何只运行一次 Effect",而是"如何修复我的 Effect 来让它在重新挂载后正常运行"。如果重新挂载破坏了应用的逻辑,通常便暴露了存在的 bug。

如果 Effect 需要获取数据,清理函数应中止请求或忽略其结果。

|------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | useEffect(() => { let ignore = false; async function startFetching() { const json = await fetchTodos(userId); // 如果组件已经卸载,忽略结果 if (!ignore) { setTodos(json); } } startFetching(); return () => { ignore = true; }; }, [userId]); // 当 userId 改变时,应该取消、忽略上一次请求 |

一些该看文档的内容 {#一些该看文档的内容}

这些内容是如何更好地使用 Effect,以及如何避免一些错误,直接看文档就好。
你可能不需要 Effect
响应式 Effect 的生命周期
将事件从 Effect 中分开
移除 Effect 依赖

文档中举了很多例子,可以总结为以下内容:

  1. 事件处理程序仅在重复相同交互时重新运行,而 Effect 会根据依赖项(如 props 或 state)变化重新同步。
  2. Effect 是响应式的,必须将读取的响应式值指定为依赖项,依赖变化时会再次运行。
  3. 避免将对象和函数作为 Effect 的依赖,可将它们移至组件外部、Effect 内部,或提取原始值。
  4. 不必要的依赖关系会导致 Effect 过度运行,甚至无限循环。
  5. 改变依赖项需通过修改代码。
  6. 使用自定义 Hook 复用逻辑。

下面这个例子绘制了一个随鼠标移动的点,并制造了一些延迟跟随的重影效果。

自定义 Hook usePointerPosition 追踪当前指针位置,而 useDelayedValue 延迟一定毫秒数后返回传入的值。每次把上一次的位置延迟一段时间给下一个 Dot 位置,就制造了重影效果。 index.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 | import { usePointerPosition } from "./usePointerPosition.ts"; import { useDelayedValue } from "./useDelayedValue.ts"; import Dot from "./Dot.js"; import "./index.css"; import { useRef } from "react"; export default function DotContainer() { const containerRef = useRef(null); // 实时的鼠标位置 const pos1 = usePointerPosition(containerRef); // 延迟的鼠标位置,每次把上一次的位置延迟一段时间给下一个 Dot 位置 const pos2 = useDelayedValue(pos1, 100); const pos3 = useDelayedValue(pos2, 200); const pos4 = useDelayedValue(pos3, 100); const pos5 = useDelayedValue(pos4, 50); return ( <div className="dot-container" ref={containerRef}> <Dot position={pos1} opacity={1} /> <Dot position={pos2} opacity={0.8} /> <Dot position={pos3} opacity={0.6} /> <Dot position={pos4} opacity={0.4} /> <Dot position={pos5} opacity={0.2} /> </div> ); } |

Dot.tsx

|---------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | interface DotProps { position: { x: number; y: number }; opacity: number; } export default function Dot({ position, opacity }: DotProps) { return ( <div style={{ position: "absolute", backgroundColor: "pink", borderRadius: "50%", opacity, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: "none", left: -20, top: -20, width: 40, height: 40, }} /> ); } |

useDelayedValue.ts

|---------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import { useState, useEffect } from "react"; export function useDelayedValue( value: { x: number; y: number; }, delay: number ) { const [delayedValue, setDelayedValue] = useState(value); useEffect(() => { setTimeout(() => { setDelayedValue(value); }, delay); }, [value, delay]); return delayedValue; } |

usePointerPosition.ts

|---------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import { useState, useEffect } from "react"; export function usePointerPosition( ref: React.MutableRefObject<HTMLElement | null> ) { const [position, setPosition] = useState({ x: 0, y: 0 }); useEffect(() => { const el = ref.current; if (!el) return; function handleMove(e: PointerEvent) { setPosition({ x: e.offsetX, y: e.offsetY }); } el.addEventListener("pointermove", handleMove); return () => el.removeEventListener("pointermove", handleMove); }); return position; } |

useEffectEvent(实验性) {#useEffectEvent-实验性}

useEffectEvent 可以提取非响应式逻辑到 Effect Event 中。其内部始终可以获取到 props 和 state 的最新值。

|-----------|------------------------------------------------------| | 1 | const onSomething = useEffectEvent(callback) |

**场景:**当你需要在 Effect 中读取响应式值,但又不希望其变化触发 Effect 重新运行时,可以使用 useEffectEvent。通常可以修改代码避免这一情况,但有时候这是不可避免的,或者代价太大。

|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 | const [count, setCount] = useState(0); const onScroll = useEffectEvent(() => { console.log(count); // useEffectEvent 保证总是能够访问到最新的 count }); useEffect(() => { window.addEventListener("scroll", onScroll); return () => { window.removeEventListener("scroll", onScroll); }; }, []); |

上面的代码中,确保在读取了 count 后,不会因为 count 的变化而重新运行 Effect,只有在组件挂载时运行一次进行事件绑定。

但实际上,不使用 useEffectEvent 也可以达到相同的效果,只需要在 Effect 中使用 ref 保存 count 的值即可。

|---------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | const [count, setCount] = useState(0); const countRef = useRef(count); // 更新 countRef 的值 useEffect(() => { countRef.current = count; }, [count]); useEffect(() => { const handleScroll = () => { console.log(countRef.current); }; window.addEventListener("scroll", handleScroll); return () => { window.removeEventListener("scroll", handleScroll); }; }, []); |

使用 useEffectEvent 会更加简洁,但需要注意,useEffectEvent 是实验性的,可能会有变化。

  1. 避免了事件处理函数捕获旧状态的问题。
  2. 不需要使用 useRef 来手动跟踪状态。
  3. 减少了在依赖数组中加入多余的依赖,优化性能。

目前即使升级到了实验版本,也无法使用 useEffectEvent。

不过手写一个也不难,原理就使用 ref 保存状态,并利用对象的引用传递特性,保证每次读取的都是最新的状态。 useEffectEvent.ts

|---------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | import { useMemo, useRef } from "react"; export default function useEffectEvent(fn: () => void) { // 使用ref保存传入的函数,每次执行 useEffectEvent 时都会将最新的fn更新给ref const fnRef = useRef(fn); // 当fn发生变化时,更新fnRef.current fnRef.current = useMemo(() => fn, [fn]); // 使用ref保存需要返回的函数,此处使用ref的目的是为了保证每次返回的函数都是同一个 const effectEvenFn = useRef<(...args: any) => void>(); // 初始化effectEvenFn.current,保证每次返回的函数都是同一个 if (!effectEvenFn.current) { // 返回的函数,每次执行都会调用fnRef.current effectEvenFn.current = function (this, ...args: any) { // fnRef.current 保存的是最新的 fn,而fnRef本身在多次渲染中是不会发生变化的 // 所以每次都能调用到最新的fn,保证了fn中能访问到最新的状态 return fnRef.current.apply(this, args); }; } return effectEvenFn.current; } |

本质还是闭包的问题,详见闭包与内存泄漏

useLayoutEffect {#useLayoutEffect}

useLayoutEffect 与 useEffect 类似,但它会在 DOM 更新完成后立即执行,且会阻塞浏览器的绘制过程。

**使用场景:**直接操作 DOM 的情况,比如测量布局、同步样式或强制浏览器重绘。可以避免多次渲染的闪烁、布局错乱等问题。

useEffect 是在浏览器绘制完成后执行的,因此它不会阻塞浏览器的重绘。
useLayoutEffect 可能会影响性能。尽可能使用 useEffect。
在底层上 useLayoutEffect 在提交阶段同步调用,而 useEffect 在提交阶段异步调用,放入调度器队列中,作为宏任务执行,自然不会阻塞绘制。

|-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 | const ref = useRef(null); const [height, setHeight] = useState(0); useLayoutEffect(() => { const { height } = ref.current.getBoundingClientRect(); setHeight(height); }, []); |

useInsertionEffect {#useInsertionEffect}

useInsertionEffect 在提交 DOM 更新之前执行,它是专为 CSS-in-JS 库的作者打造的。

专门用于在 DOM 更新提交前进行一些低级别的影响布局的 DOM 插入工作,这样可以减少浏览器回流、重绘,避免出现闪烁或样式未能及时插入的问题。

useInsertionEffect 会同时触发 cleanup 函数和 setup 函数。这会导致 cleanup 函数和 setup 函数的交错执行。

通常情况下,你不需要使用 useInsertionEffect。

生命周期 {#生命周期}

函数组件的本质是一个渲染纯函数,并不存在生命周期,但通过 Effect Hook 可以模拟类组件的生命周期。

Effect 执行顺序:

  1. 不同 Effect:
    useInsertionEffect -> 提交 DOM 更新 -> useLayoutEffect -> 页面渲染 -> useEffect
  2. 父子组件间:
    子组件的 Effect 会在父组件的 Effect 之前执行。

父组件

|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 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 | import Child from "./Child"; 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"); }); return ( <div> Parent <button onClick={() => { forceUpdate((prev) => prev + 1); }} > 父组件重新渲染 </button> <Child></Child> </div> ); } |

子组件

|------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | export default function Child() { console.log("Child fn"); useInsertionEffect(() => { console.log("Child insertionEffect"); }); useLayoutEffect(() => { console.log("Child layoutEffect"); }); useEffect(() => { console.log("Child rendered"); }); return <div>Child</div>; } |

父组件渲染时,控制台输出如下:

|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 1 2 3 4 5 6 7 8 9 10 | Parent fn Child fn Child insertionEffect Parent insertionEffect // 提交 DOM 更新 Child layoutEffect Parent layoutEffect // 页面渲染 Child rendered Parent rendered |

先执行父组件的组件函数,再执行子组件的组件函数。

React 按照深度优先遍历的顺序构建和更新组件树,因此子组件的 Effect 会比父组件先执行。这种执行顺序确保子组件的所有更新都准备好后,父组件再执行自己的布局和副作用逻辑,从而保证渲染的一致性和性能。

类组件已不再推荐使用,它的那堆生命周期就不看了,碰到再翻文档吧。

赞(0)
未经允许不得转载:工具盒子 » React-记-2