React的核心思想是声明式渲染:你描述界面应该是什么样子,React负责把它渲染到真实DOM上。但实际项目中,总有一些场景需要直接操作DOM,比如聚焦输入框、获取元素尺寸、集成第三方库等。如果不理解React的渲染机制就随意操作DOM,很容易出现状态与界面不同步、操作被覆盖、内存泄漏等问题。本文将从原理到实践,全面梳理React中DOM操作的注意事项和常见错误。

一、理解虚拟DOM与真实DOM的关系
React维护一棵虚拟DOM树,每次状态更新后会生成新的虚拟DOM树,再通过diff算法比较新旧两棵树的差异,最后把差异批量应用到真实DOM上。这个过程是异步批处理的,也就是说调用setState之后,真实DOM并不会立刻更新。很多初学者在setState之后立即用原生方法读取DOM属性,拿到的往往是旧值,这是最常见的错误之一。
正确的做法是使用生命周期钩子或钩子函数的依赖机制来等待渲染完成。类组件中可以使用componentDidUpdate,函数组件中可以使用useEffect,它们都在DOM更新之后执行。例如:
import { useEffect, useRef, useState } from 'react';
function Demo() {
const [count, setCount] = useState(0);
const boxRef = useRef(null);
useEffect(() => {
// useEffect 在渲染完成后执行,此时读取的才是最新 DOM
console.log(boxRef.current.offsetHeight);
}, [count]);
return (
<div ref={boxRef} onClick={() => setCount(count + 1)}>
点击了 {count} 次
</div>
);
}
需要特别注意的是,在React 18的并发模式下,渲染过程可能被中断和恢复,任何在渲染期间直接修改DOM的做法都会带来不可预测的后果。操作DOM的代码应该集中放在副作用阶段,即useEffect或useLayoutEffect中,而不是渲染函数体内。
二、ref的正确使用方式与常见误区
ref是React提供的访问真实DOM的官方通道。创建ref用useRef或React.createRef,把ref挂到元素上之后,通过ref.current就能拿到对应的DOM节点。这里有几个容易出错的地方需要重点说明。
第一个误区是在渲染期间读写ref.current。渲染阶段应该是纯粹的,读写ref属于副作用,React严格模式下会执行两次渲染来暴露这类问题。第二个误区是忘记判断空值。ref在首次渲染完成前是null,条件渲染的元素在卸载后ref也会变回null,直接调用ref.current.focus()会直接报错,使用前必须判空。
function SearchBox() {
const inputRef = useRef(null);
useEffect(() => {
// 条件渲染的元素可能不存在,必须判空
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return <input ref={inputRef} type="text" placeholder="请输入关键词" />;
}
第三个误区是过度使用ref。如果某个效果可以通过状态和样式驱动实现,就不要手动操作DOM。比如显示隐藏元素,用状态控制className即可;只有聚焦、播放视频、读取布局信息、与Canvas交互这类状态驱动做不到的事情,才应该使用ref。另外,回调形式的ref如果写成内联函数,每次渲染都会以null和节点各调用一次,必要时用useCallback缓存回调,或者改用useRef。
三、直接修改DOM引发的同步冲突
React默认通过diff来决定是否更新某个DOM节点。如果你绕过React直接修改了DOM的属性或内容,而React后续重新渲染时diff认为该节点没有变化,就不会更新它,你的修改会一直残留;反过来,如果diff认为需要更新,你的修改又会被覆盖。这种不确定性是很多诡异bug的来源。
典型例子是直接用innerHTML或innerText改写React管理的元素内容。正确的替代方案有三种:一是把内容放进状态里让React渲染;二是把不希望React管理的部分放到dangerouslySetInnerHTML中,虽然它名字里带dangerously,但至少React知道这段内容是自己人改的,不会去diff它;三是把第三方库渲染的目标做成一个空容器,让React完全不去碰它内部的节点。
function RichText({ html }) {
// React 不会对 dangerouslySetInnerHTML 的内容做 diff
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
表单元素是另一个重灾区。如果一个<input>设置了value却没有提供onChange,输入框将无法输入,控制台会给出警告。如果既要受控又要临时改值,应通过修改状态而不是直接改DOM的value属性。同样,stopPropagation与React的合成事件系统交织时也要小心,React把事件委托到根容器上,在原生监听器里阻止冒泡可能拦不住React的处理器,必要时用e.nativeEvent.stopImmediatePropagation()处理。
四、性能与内存方面的注意事项
直接操作DOM还有一个隐性成本:频繁读写布局信息会触发强制重排。比如在循环中反复读取offsetHeight再修改样式,浏览器每次都要重新计算布局,性能会急剧下降。如果确实需要先读后写,应该把读取操作集中在一起,写入操作集中在后面,或者使用requestAnimationFrame把写入推迟到下一帧。
在useEffect中绑定原生事件监听或创建定时器时,一定要在清理函数中解绑和销毁,否则组件卸载后监听器仍然引用着已卸载的DOM节点,造成内存泄漏。使用document.querySelector全局查找节点也是反模式,一旦同一个组件在页面上渲染多份,选择器就会命中错误的元素,永远优先使用ref。
function Modal({ onClose }) {
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
// 清理函数:组件卸载时移除监听,防止内存泄漏
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
return <div className="modal">弹窗内容</div>;
}
最后补充一个工具层面的建议:useLayoutEffect和useEffect的区别在于前者在浏览器绘制前同步执行,适合需要先测量DOM再决定样式的场景(例如提示框定位),后者在绘制后异步执行,绝大多数情况下优先用它,避免阻塞绘制。掌握这些原则后,在React中操作DOM既能解决问题,又不会破坏框架本身的渲染秩序。
React DOM操作虚拟DOMref修改时间:2026-08-31 22:50:40