大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。
最近在写一个基于大模型的聊天页面,遇到的第一个问题就是当有新消息的时候如何自动滚动页面,从而展示最新的聊天消息。于是深入了解了在 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 HooksuseEffect 是一个 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