宇宙厂:谈谈React优雅操作DOM的四种方式?

前有科技后进阶 2024-11-14 05:39:23

大家好,很高兴又见面了,我是"高级前端‬进阶‬",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

最近在写一个基于大模型的聊天页面,遇到的第一个问题就是当有新消息的时候如何自动滚动页面,从而展示最新的聊天消息。于是深入了解了在 React 中如何优雅操作 DOM ,最终也认识了 react-dom 提供的 flushSync 方法。

1. 事件处理程序内的滚动逻辑非同步执行

如果只是将滚动逻辑放入事件处理程序中,可能和预取的效果不符。

const onAdd = (newTask) => { setTodos([...todos, { id: uuid(), task: newTask }]); // 不会立即执行 listRef.current.scrollTop = listRef.current.scrollHeight;};

这是因为 setTodos 并非同步,所以页面会先滚动然后 todos 才会更新。因此,视图中显示的始终不是最后一个 todos 而是倒数第二个。

2. 使用 useEffect Hooks

useEffect 是一个 React Hook,其根据 React state 控制非 React 组件,例如:设置服务器连接或在组件出现在屏幕上时发送分析日志等等。

为了自动滚动到最后一条记录,开发者可以使用 useEffect Hooks,比如下面的示例:

useEffect(() => { // 副作用 listRef.current.scrollTop = listRef.current.scrollHeight;}, [todos]);

或者通过下面的方法实现:

useEffect(() => { const lastTodo = listRef.current.lastElementChild; lastTodo.scrollIntoView();}, [todos]);

上述两种滚动逻辑都能工作正常,然而 useEffect Hooks(或者下面的 useLayoutEffect)有时候并非最优解。主要包括以下原因:

尝试的 DOM 操作,例如:滚动,是一种副作用,即渲染期间不会发生。而且在 React 中,副作用通常发生在事件处理程序内部,所以将其放在 onAdd 方法中更加合理按照官方文档声明,当尝试所有其他选项仍未找到正确的事件处理程序时才考虑 useEffect3. 使用 useLayoutEffect Hooks

在 React 中元素测量还可以使用 useLayoutEffect 这个 Hooks,其在浏览器重新绘制(repaint)屏幕之前测量布局,防止用户看到 tooltip 等类似内容移动。该 Hooks 在 ToolTip 等场景非常重要,因为该场景需要分两遍进行渲染:

在任何地方渲染 tooltip 提示,即使位置错误测量其高度并决定 tooltip 放置位置在正确的位置再次渲染 tooltip 提示

下面示例代码也能够实现自动滚动到最后一条元素:

useLayoutEffect(() => { console.log('scrollRef', scrollRef); scrollRef.current.scrollTop = scrollRef.current.scrollHeight;}, [messages]);4. 使用回调引用 (Callback ref) 测量元素位置和大小

测量 DOM 节点位置或大小的一种基本方法是使用回调引用,回调函数会在组件挂载 (mounted)、重新渲染 (re-rendered) 或卸载 (unmounted) 后运行。同时,每当 ref 附加到不同的节点时,React 也会调用该回调。

同时,使用回调引用可确保即使子组件稍后显示测量的节点,例如:响应点击,开发者仍然会在父组件中收到有关通知并更新测量值。

下面示例是实现滚动到最后一条元素的代码:

let scrollRef = useCallback(node => { if (node !== null) { node.scrollTop = node.scrollHeight; }}, [messages]);<Chat messages={messages} ref={scrollRef} />const Chat = forwardRef((props, ref) => {})

以上示例没有使用 useRef,因为对象 ref 不会通知当前 ref 值的更改。 使用回调引用可确保即使子组件稍后显示测量的节点,例如:正在响应用户点击时仍会在父组件中收到通知并更新测量值。

通常,这种回调引用用在 React 中动态添加或删除元素的地方。

5. 使用 flushSync

开发者还可以使用 flushSync,其会强制 React 同步刷新提供的回调中的任何更新,从而确保 DOM 立即生效。

但是,开发者需要从 react-dom 导入相应的方法,然后将 setTodos 调用包裹在 flushSync 处理程序中:

import {flushSync} from "react-dom";const onAdd = (newTask) => { flushSync(() => { setTodos([...todos, { id: uuid(), task: newTask }]); }); listRef.current.scrollTop = listRef.current.scrollHeight;};

flushSync 可以确保状态更新同步发生,并且滚动逻辑仅在状态更新后执行。

注意:使用 flushSync 并不常见,经常使用会严重损害应用程序的性能。如果应用程序仅使用 React API,并且不与第三方库集成(例如:浏览器 API 或 UI 库),则不需要 flushSync

最后值得一提的是,开发者还可以使用 useImperativeHandle 这个 React Hook,其可让开发者自定义作为 ref 公开的句柄,从而避免暴露整个DOM:

const Chat = forwardRef((props, ref) => { const scrollRef = useRef(null); useImperativeHandle(ref, () => { return { scrollIntoView() { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, }; }, []); }参考资料

https://dev.to/somshekhar/have-you-used-flushsync-in-react-4cpo

https://react.dev/learn/keeping-components-pure

https://react.dev/reference/react-dom/flushSync

https://dev.to/gilfink/quick-tip-using-callback-refs-in-react-4gef

https://react.dev/learn/synchronizing-with-effects

https://www.linkedin.cn/

https://react.dev/reference/react/useImperativeHandle

0 阅读:0

前有科技后进阶

简介:感谢大家的关注