曾经的前端代码里 document.getElementById 满天飞,经常要手动 appendChild 和innerHTML,现在因为有了 React、Vue 这些前端框架,我们手动操作 DOM 变得很少了。数据一变界面就自动更新,确实很爽。
但在实际项目里,有些问题还是会悄悄冒出来:列表莫名其妙串数据了、页面滚动开始掉帧了、ref 操作后视图不对了、某个图表在组件卸载后还赖在页面上……排查一圈,最后发现还是得回到 DOM 的基本原理上。
下面这 4 个坑,都是前端项目里高频出现的 DOM 相关问题。看看你遇到过几个。
坑一:列表渲染不写 key,或者 key 用 index
场景:一个待办列表,支持勾选完成和删除,数据从 useState 或 ref 中来。
// React 写法(错误示例)
{items.map((item, index) => (
<TodoItem key={index} data={item} onDelete={() => deleteItem(item.id)} />
))}
看起来没问题,key 也写了。直到有一天,我勾选了第一条待办,然后删掉了它——奇怪的事情发生了:原来的第二条勾选状态保留,但内容变成了第一条的。
为什么:React 和 Vue 都用 key 来识别每个虚拟 DOM 节点的身份。如果你用 index 作为 key,当数组元素顺序改变(删除、排序)时,同一个 index 可能指向了不同的数据,框架就会错误地复用旧的 DOM 节点,导致状态错乱。
怎么处理:永远用稳定唯一的业务 ID 作为 key。
{items.map(item => (
<TodoItem key={item.id} data={item} onDelete={() => deleteItem(item.id)} />
))}
这样即使元素删除,剩下的元素 key 不变,DOM 不会错误复用。
避坑点:React 的 key 或 Vue 的
:key,不要用循环的 index,除非列表永远不增删重排。
坑二:用 ref 操作滚动位置,但时机不对导致无效或闪回
真实需求:一个聊天窗口,新消息到达后自动滚到底部。
很多人第一反应是:
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}, [messages]); // messages 更新后就滚到底部
现象:有时候滚动正常,有时候根本没滚到最底下,或者滚下去了又闪回来。
为什么:messages 更新只代表 state 变了,不代表 React 已经把新的 DOM 节点渲染到页面上了。React 的状态更新和 DOM 提交之间有批处理(batched updates),如果你的 useEffect 执行时新消息对应的 DOM 还没挂上去,scrollIntoView 就作用在一个不存在或高度还没确定的新节点上,表现就是没滚动或者滚到一半停住了。
怎么处理:用 requestAnimationFrame 或在 useLayoutEffect 中处理。
const bottomRef = useRef(null);
const messagesEndRef = useRef(null);
useLayoutEffect(() => {
// useLayoutEffect 在 DOM 更新后、浏览器绘制前同步执行
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' });
}, [messages]);
或者如果必须用 useEffect(不阻塞渲染),加一个 requestAnimationFrame 保证 DOM 已经稳定:
useEffect(() => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
});
}, [messages]);
避坑点:读 DOM 尺寸、做滚动定位这类“依赖最新布局信息”的操作,放在
useLayoutEffect里更安全。用useEffect的话,记得加一层requestAnimationFrame兜底。
坑三:第三方库操作了 DOM,组件卸载时没销毁
项目里用 ECharts、高德地图、富文本编辑器这些库的时候,它们都是直接在一个 DOM 容器上创建的实例。
useEffect(() => {
const chart = echarts.init(document.getElementById('chart'));
chart.setOption(options);
}, []);
看起来没问题,但当你切走路由再回来,图表变成了两个,旧的还挂着,甚至控制台开始报一些莫名其妙的错。
为什么:组件卸载时,React 只清理了虚拟 DOM 对应的真实 DOM。但 ECharts 在容器上创建的 canvas、绑定的 resize 事件、启动的定时器,并不归 React 管。下次组件重新挂载,又一个新实例被创建,老的那个就成了“幽灵节点”。
怎么处理:清理函数里手动销毁:
useEffect(() => {
const chart = echarts.init(document.getElementById('chart'));
chart.setOption(options);
return () => {
chart.dispose(); // 这个不能忘
};
}, []);
Vue 里在 onUnmounted 里做同样的事。
避坑点:任何直接操作了真实 DOM 的第三方库,一定要在组件卸载时手动销毁。页面切久了越来越卡,很可能就是这里没清理干净。
坑四:长列表直接渲染,页面越来越卡
聊天记录、操作日志、用户列表这些场景,数据量一上来就容易出问题:
<div v-for="msg in messages" :key="msg.id">
<ChatBubble :data="msg" />
</div>
几十条的时候完全没问题。等用户聊了几百条、上千条,发新消息有明显延迟,滚动也开始掉帧。
为什么:框架虽然只更新变化的部分,但当页面上的 DOM 节点数量达到几千个时,浏览器需要维护的 DOM 树本身就变成了性能瓶颈——内存占用、布局计算、绘制开销都会指数级上升。这不是框架的锅,是物理限制:页面上同时存在几千个 DOM 节点,本身就重。
怎么处理:虚拟滚动。只渲染你眼睛能看到的那一屏元素,滚出屏幕的用占位符替代。
React 项目用 react-window 或 @tanstack/react-virtual,Vue 项目用 vue-virtual-scroller:
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={messages.length} itemSize={80}>
{({ index, style }) => (
<div style={style}><ChatBubble data={messages[index]} /></div>
)}
</FixedSizeList>
避坑点:不要用
map或v-for无脑渲染长列表。用虚拟滚动库,让 DOM 节点数保持在一个恒定水平。
总结
上面这四个坑,每一个本质上都是框架和 DOM 的边界问题:框架帮你管理了声明式的 UI,但 DOM 的底层限制(节点数量、渲染开销)依然存在;框架帮你隔离了直接操作 DOM 的脏活,但你引入的第三方库不受框架管理。
用框架的自信来源不是“我不用管 DOM 了”,而是我能分清哪些事框架替我做了,哪些事还在我手里。列表 key 的设计、ref 的边界、第三方库的生命周期、长列表的渲染策略——这些才是现代前端项目里依旧需要掌握的 DOM 相关知识。