重学React(五):脱围机制一
背景: 之前将React的基础知识以及状态管理相关的知识都过了一遍,查漏补缺的同时对React也有了一些新鲜的认知,接下来这个模块的名字很有意思:脱围机制,内容也比之前的部分难理解一些。但整体看下来,理解之后对React的使用上也会更上一层楼。就继续学习吧~
前期回顾:
重学React(一):描述UI
重学React(二):添加交互
重学React(三):状态管理
重学React(四):状态管理二
学习内容:
React官网教程:https://zh-hans.react.dev/learn/escape-hatches
其他辅助资料(看到再补充)
补充说明:这次学习更多的是以学习笔记的形式记录,看到哪记到哪
什么是脱围机制
在React中,除了React之外,我们还需要连接外部系统,比如需要连接服务器接口,获取服务器传来的数据,再比如操作DOM方法,比如focus,scroll等等。这些功能的前提需要“跳出”React自身的渲染逻辑,所以被称为脱围机制。接下来就开始学习如何脱围吧~
使用 ref 引用值
在实际编码中,偶尔会遇到希望组件能记住某些信息,这些信息的修改不触发页面重新渲染,比如记录setTimeout的id,这个id本身跟渲染毫无关系,只是用来标识当前的计时器以及在卸载组件时销毁它,如果不记录下来,就很难实现销毁,容易造成内存泄漏,此时就需要使用ref
给组件添加ref
import { useRef } from 'react';export const App () {
// useRef返回一个current对象
// { current: 0 } // current的value是向 useRef 传入的值,任何类型都可以const ref = useRef(0);
}
可以使用ref.current 属性访问该 ref 的当前值。ref 是一个普通的 JavaScript 对象,具有可以被读取和修改的 current 属性。
这个值是有意被设置为可变的,意味着既可以读取它也可以写入它。就像一个 React 追踪不到的、用来存储组件信息的秘密“口袋”。
示例:制作秒表
import { useState, useRef } from 'react';export default function Stopwatch() {
// 记录开始时间和当前时间,因为这两个时间需要计算并渲染出最后的结果,所以使用state,实现实时渲染const [startTime, setStartTime] = useState(null);const [now, setNow] = useState(null);// 用来记录当前计时器的id,便于重置时clearInterval,它在页面重新渲染时不需要改变,而是在进行操作时手动处理,所以使用ref进行记录const intervalRef = useRef(null);
// 每次点击开始时,将当前时间和记录时间重置function handleStart() {setStartTime(Date.now());setNow(Date.now());clearInterval(intervalRef.current);intervalRef.current = setInterval(() => {// 每隔十秒更新当前时间setNow(Date.now());}, 10);}function handleStop() {clearInterval(intervalRef.current);}let secondsPassed = 0;// 每次渲染用当前时间减去开始时间,就能得到过去了多少时间if (startTime != null && now != null) {secondsPassed = (now - startTime) / 1000;}return (<><h1>时间过去了: {secondsPassed.toFixed(3)}</h1><button onClick={handleStart}>开始</button><button onClick={handleStop}>停止</button></>);
}
ref 和 state 的不同之处
// React 内部,useRef的内部运行机制可以简单由useState实现
// 第一次渲染期间,useRef 返回 { current: initialValue }。 该对象由 React 存储,因此在下一次渲染期间将返回相同的对象。
// 在这个示例中,state 设置函数没有被用到。它是不必要的,因为 useRef 总是需要返回相同的对象!
function useRef(initialValue) {const [ref, unused] = useState({ current: initialValue });return ref;
}
ref使用场景
- 存储 timeout ID
- 存储和操作 DOM 元素
- 存储不需要被用来计算 JSX 的其他对象。
总的来说,如果组件需要存储一些值,但不影响渲染逻辑,请选择 ref,这通常是不会影响组件外观的浏览器 API。
ref 的最佳实践
使用ref的原则
- 将 ref 视为脱围机制。 在使用外部系统或浏览器 API 时,ref 很有用。但如果很大一部分应用程序逻辑和数据流都依赖于 ref,可能需要重新考虑方法是否有问题。
- 不要在渲染过程中读取或写入 ref.current。 如果渲染过程中需要某些信息,请使用 state 代替。由于 React 不知道 ref.current 何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只在第一次渲染期间设置一次 ref。)
ref本身就是一个普通的js对象,所以它的数据会实时更新,不会像state一样以快照的形式每隔一段时间才更新。所以只要ref的值不涉及渲染,React就不会关心你对 ref 或其内容做了什么。
使用Ref操作DOM
这是ref最常见的使用场景。在大部分情况下,React 会自动处理更新 DOM 以匹配渲染输出,所以不需要操作DOM。但在实现某些效果的情况下,比如控制DOM的滚动,让某个元素获得焦点等等,React没有内置方法,而是需要一个指向 DOM 节点的 ref 来实现。
接下来是具体的实现以及原理:
使文本输入框获得焦点
// 引入hook
import { useRef } from 'react';export default function Form() {
// 声明一个refconst inputRef = useRef(null);function handleClick() {// inputRef.current中保存的就是input节点,可以直接使用这个节点内置的API,这里使用的是focusinputRef.current.focus();}return (<>// 将 ref 作为 ref 属性值传递给想要获取的 DOM 节点的 JSX 标签<input ref={inputRef} /><button onClick={handleClick}>聚焦输入框</button></>);
}
如何使用 ref 回调管理 ref 列表
考虑一个场景:有n个列表,需要给每个列表都绑定一个ref,n的个数是未知的,所以我们不能预先将ref给一一声明了,因为 Hook 只能在组件的顶层被调用。所以不能在循环语句、条件语句或 map() 函数中调用 useRef 。解决这个问题有两种思路:
- 用一个 ref 引用其父元素,然后用 DOM 操作方法如 querySelectorAll 来寻找它的子节点。然而,这种方法很脆弱,如果 DOM 结构发生变化,可能会失效或报错
- ref 回调,也就是将函数传递给 ref 属性。当需要设置 ref 时,React 将传入 DOM 节点来调用 ref 回调,并在需要清除它时传入 null 。这可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref
看个例子如何用第二个方法来解决问题:
注意事项:启用严格模式后,ref 回调将在开发中运行两次
import { useRef, useState } from "react";export default function CatFriends() {const itemsRef = useRef(null);const [catList, setCatList] = useState(setupCatList);function scrollToCat(cat) {const map = getMap();const node = map.get(cat);node.scrollIntoView({behavior: "smooth",block: "nearest",inline: "center",});}function getMap() {if (!itemsRef.current) {// 首次运行时初始化 Map。itemsRef.current = new Map();}return itemsRef.current;}return (<><nav><button onClick={() => scrollToCat(catList[0])}>Neo</button><button onClick={() => scrollToCat(catList[5])}>Millie</button><button onClick={() => scrollToCat(catList[9])}>Bella</button></nav><div><ul>{catList.map((cat) => (<likey={cat}ref={(node) => {// 将这个getMap函数传入,这样DOM的ref就可以以map的形式操作const map = getMap();// 添加到 Map 中map.set(cat, node);// 从 Map 中移除return () => {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;
}
访问另一个组件的 DOM 节点
有时候会有A组件操作B组件DOM节点的需求,比如在执行某些操作后,实现表单输入框的自动聚焦。但Ref 是一个脱围机制,也就是除了在迫不得已的情况下尽量别用。手动操作其它 组件的 DOM 节点可能会让代码变得脆弱。如果真的要用,可以看看这个例子。
import { useRef } from 'react';function MyInput({ ref }) {
// 子组件从props中获取ref,绑定在对应的DOM节点上return <input ref={ref} />;
}export default function MyForm() {
// 在父组件里声明refconst inputRef = useRef(null);function handleClick() {inputRef.current.focus();}return (<>// 把ref作为参数传到子组件中<MyInput ref={inputRef} /><button onClick={handleClick}>聚焦输入框</button></>);
}
这样做确实可以实现在A组件中调用B组件的DOM,但某些情况下,可能只需要调用B组件DOM的其中一些方法,比如在这个例子里只需要调用focus方法,但这样写会将DOM所有方法都给了MyForm组件。还有些更加极端的需求,A组件可能需要调用B组件中的某些方法,这个时候,可以使用useImperativeHandle
来实现
import { useRef, useImperativeHandle } from "react";function MyInput({ ref }) {const realInputRef = useRef(null);// useImperativeHandle 指示 React 将你自己指定的对象作为父组件的 ref 值。 // 所以 Form 组件内的 inputRef.current 将只有 focus 方法。useImperativeHandle(ref, () => ({// 只暴露 focus,没有别的// ref在这里不是 DOM 节点,而是在 useImperativeHandle 调用中创建的自定义对象。所以除了DOM方法外,还可以将其他A组件需要调用的方法也一并传入focus() {realInputRef.current.focus();},someFun() {console.log('test')}}));return <input ref={realInputRef} />;
};export default function Form() {const inputRef = useRef(null);function handleClick() {inputRef.current.focus();}return (<><MyInput ref={inputRef} /><button onClick={handleClick}>聚焦输入框</button></>);
}
React 何时添加 refs
在 React 中,每次更新都分为 两个阶段:
- 在 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
- 在 提交 阶段, React 把变更应用于 DOM。
在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。
React 在提交阶段设置 ref.current。在更新 DOM 之前,React 将受影响的 ref.current 值设置为 null。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。
通常,你将从事件处理器访问 refs。 如果想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,可能需要一个 effect。这就是后面的内容了。
彩蛋:用 flushSync 同步更新 state
请看下面这个代码,需要实现的是添加一个新的待办事项,并将屏幕向下滚动到列表的最后一个子项。请注意,出于某种原因,它总是滚动到最后一个添加之前的待办事项
import { useState, useRef } from 'react';export default function TodoList() {const listRef = useRef(null);const [text, setText] = useState('');const [todos, setTodos] = useState(initialTodos);function handleAdd() {const newTodo = { id: nextId++, text: text };setText('');setTodos([ ...todos, newTodo]);listRef.current.lastChild.scrollIntoView({behavior: 'smooth',block: 'nearest'});}return (<><button onClick={handleAdd}>添加</button><inputvalue={text}onChange={e => setText(e.target.value)}/><ul ref={listRef}>{todos.map(todo => (<li key={todo.id}>{todo.text}</li>))}</ul></>);
}let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {initialTodos.push({id: nextId++,text: '待办 #' + (i + 1)});
}
执行代码后会发现,原本想要滚动到最后新加的待办事项中,但实际上会滚到上一个事项,自动滚动无法定位到新添加的待办事项中。
问题出现在这两行代码中:
// 在 React 中,state 更新是排队进行的,setTodos 不会立即更新 DOM。
// 当ref操作scroll事件使得列表滚动到最后一个元素时,尚未添加待办事项
// 因此这里需要实现setTodos立即更新
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();// 可以使用react-dom中的flushSync来实现这个强制更新DOM的过程
import { flushSync } from 'react-dom';
// ...只展示关键代码
flushSync(() => {setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
使用 refs 操作 DOM 的最佳实践
还是反复强调的事情,Ref是一种脱围机制,所以必须只在需要跳出“React”范围的时候才能使用,否则如果胡乱修改DOM元素,一旦跟React自身的渲染机制冲突了,就容易造成不可预期的后果。
因此,需要避免更改由 React 管理的 DOM 节点。 对 React 管理的元素进行修改、添加子元素、从中删除子元素会导致不一致的视觉结果,或造成代码崩溃。总之就是,不是不能改,而是改的时候需要小心些。
ref的场景就学完了,接下来是Effect的模块,这个模块比较长,就单独再开一篇来讲好了~