Vue中操作DOM的正确姿势:ref、自定义指令与模板引用全解析
入行这几年带过不少刚转Vue的同事几乎每周都会有人拿着代码来问同一个问题我想拿到某个元素然后改一下样式或者滚动位置到底怎么操作DOM这个问题在不同阶段答案完全不一样。在原生JS里是document.querySelector在jQuery里是选择器加方法而到了Vue里如果你不管三七二十一继续用document.querySelector很容易写出绕开框架、数据状态和视图不同步的坑代码。这篇文章就把Vue里正规的DOM操作姿势掰开揉碎讲清楚包括ref和$refs、自定义指令、组合式API模板引用、vnode层面访问元素以及各自的使用边界和避坑经验。适合刚接触Vue、想搞清楚模板引用用法的前端新手也适合已经在项目里写了很多命令式DOM操作、想整理一套规范的老手。1. 为什么在Vue里操作DOM要讲方法1.1 声明式渲染和命令式操作之间的矛盾Vue的核心设计理念是“数据驱动”我们只需要维护状态框架负责把状态变成DOM。模板里的{{ msg }}、:class、v-if本质都是在描述“数据长什么样界面就长什么样”。这就是声明式渲染。你不需要告诉Vue每一步怎么增删节点只需要告诉它目标状态是什么剩下的事情交给虚拟DOM和diff算法。但这套设计也有一个隐含前提尽量不要绕开框架去手动改DOM。我见过不少刚上手Vue的人遇到“按钮点击后把这个div变红”的需求第一反应是document.querySelector(.box).style.color red。第一次点击确实有效可一旦组件重新渲染数据一变这条手动修改就会被虚拟DOM的patch过程覆盖屏幕上的效果又“消失”了。这不是Vue不让你改而是你改的方式和框架的管理方式冲突了。我经常用一个类比来解释数据是图纸DOM是施工完的墙面。正确做法是改图纸、重新施工直接拿锤子敲墙等下一次重新装修敲掉的地方又会被糊回去。Vue里也一样能用v-bind、v-if、v-for这类声明式语法表达的效果永远优先用声明式。手动操作DOM是例外不是常态。1.2 哪些场景逼着你非操作DOM不可当然声明式不是万能的。模板语法再怎么强大也描述不了下面这些情况初始化某个第三方图表库必须传入一个真实的DOM容器节点。日期选择器、富文本编辑器这类组件本身就要挂载到指定元素上。需要精确测量元素的尺寸、位置比如做拖拽边界计算。元素聚焦、选中文本、设置剪贴板内容。操作canvas、video、audio这类有内部状态的媒体元素。做复杂滚动控制比如聊天列表滚动到底部、懒加载触发判断。判断标准我归纳成一句话模板语法能不能表达能就交给模板不能才考虑用ref、自定义指令或者其他方式去拿DOM。这个原则看起来简单但在真实项目里很容易被忽略。拿一个最常见的场景来说给列表项加一个当前选中样式完全可以通过v-for循环里的:class绑定实现根本不需要操作DOM。可如果不假思索地用document.querySelectorAll去改不仅会导致状态和UI不一致还会让组件难以测试、难以复用。2. 最入门的操作方式ref 与 $refs2.1 模板引用怎么用元素和组件Options API时代最直观的DOM操作方式就是ref属性加this.$refs。在模板任意元素上写一个refbox组件实例上就会多出一个$refs对象里面存着对应的DOM元素。template div classchart refchartBox/div /template script export default { mounted() { const el this.$refs.chartBox if (el) { // 这时候才能安全地初始化图表、绑定事件 this.initChart(el) } }, methods: { initChart(el) { // 把第三方库挂在el上 } } } /script需要特别注意的是时机。组件实例在created阶段已经创建但此时模板对应的DOM还没有挂载this.$refs.chartBox是undefined。最早能安全访问的钩子是mounted。如果你的某个DOM是v-if控制的那么在条件不成立的时候$refs里也没有这个属性必须等条件成立后下一次事件循环才能拿到。如果在子组件上使用ref拿到的就不再是DOM元素而是子组件的组件实例。通过这个实例访问data、methods或者调用$emit等方法。有时候你想拿子组件内部的根节点DOM可以用this.$refs.child.$el但我不太建议养成这个习惯。一旦子组件结构变化$el指向的节点可能就不符合预期了还容易造成组件间强耦合后面会专门讲这个问题。2.2 使用时机与注意点$refs 并不是响应式的this.$refs虽然挂在组件实例上但它本质上一个普通对象并不会触发响应式更新。这意味着你不能在computed里依赖它也不能通过watch监听某个$refs属性变化。很多新手在这里踩坑想在数据变化后立刻拿$refs读取新DOM的尺寸结果拿到的还是旧值然后就怀疑是不是框架有bug。其实这不是bug而是Vue的异步更新机制。当你修改响应式数据后DOM并不会同步更新Vue会把这个更新放到一个异步队列里等当前脚本执行完再统一patch。所以数据变了你想立刻拿到新DOM正确的姿势是等nextTickmethods: { async sendMessage() { this.messages.push(新消息) // 如果在下面直接访问this.$refs.scroller高度还是旧的 await this.$nextTick() this.$refs.scroller.scrollTop this.$refs.scroller.scrollHeight } }除了时机$refs还有两个容易忽略的细节。第一在v-for中使用ref$refs对应的值会是一个数组数组顺序和渲染顺序保持一致。但如果你同时在循环里做条件渲染数组的下标不一定等于数据下标操作时最好通过v-for的索引去对应别想当然。第二模板里的ref名字写错了不会报错$refs只会静默地返回undefined。调试这种问题最浪费时间我一般会先检查拼写再检查当前元素是否处于v-iffalse状态这两种情况占了九成。3. 把DOM操作封装成自定义指令3.1 指令钩子和生命周期如果同一个DOM操作需要在很多组件里重复使用每次都写在mounted里再复制粘贴代码会越来越难维护。这时候就该用自定义指令。自定义指令本身就是一个针对“单个元素DOM生命周期”的封装你可以指定它在什么时机做初始化、什么时候做清理。Vue 3里指令的钩子统一成了一套和组件生命周期对应created元素的属性或事件监听初始化前beforeMount元素挂载前mounted元素挂载完成可以访问DOM大部分初始操作放在这里beforeUpdate组件更新前updated组件更新后beforeUnmount元素卸载前unmounted元素卸载后举个例子一个最简单的“自动聚焦”指令const vFocus { mounted(el) { el.focus() } }在模板里用v-focus即可。这比在每个组件的mounted里手动this.$refs.input.focus()干净得多而且指令的钩子天然帮你处理了“元素刚挂载好”这个时机不需要再等nextTick。不过指令最容易被忽略的是清理工作。很多教程只讲mounted里加事件不讲unmounted里移除事件导致组件销毁后全局监听器还在内存泄漏、重复绑定问题接踵而至。我在实际开发里凡是往document、window上挂过监听的指令都会用el上挂一个私有属性来保存清理函数再在卸载钩子里调用。3.2 一个clickOutside实例点击元素外部关闭下拉框是自定义指令里最有代表性的场景。原因是这个行为涉及“全局监听”没法靠模板语法实现而且几乎每个下拉、弹窗、气泡组件都需要非常适合封装。const vClickOutside { mounted(el, binding) { const handler (event) { if (el.contains(event.target)) return if (typeof binding.value function) { binding.value(event) } } el.__clickOutsideHandler handler document.addEventListener(click, handler) }, unmounted(el) { document.removeEventListener(click, el.__clickOutsideHandler) delete el.__clickOutsideHandler } }用法template div v-click-outsidecloseDropdown button clickvisible !visible切换/button div v-ifvisible下拉内容/div /div /template几个细节要说明一下。事件监听器如果直接在mounted里写匿名函数unmounted里无法移除同一个引用所以你必须要用一个变量把它存下来。我习惯直接挂在el上这样即使多个指令叠加使用也不会互相干扰。其次判断是否点击外部用el.contains而不是document.contains因为el.contains(event.target)天然兼容点击了元素内部的文本节点、子元素等情况。指令里能拿到的binding还有很多内容binding.value表示传给指令的绑定值binding.arg表示指令参数binding.modifiers表示修饰符。比如v-click-outside:click.capture里的:click和.capture都能让你在不改代码逻辑的情况下区分监听事件类型或捕获阶段这是做通用设计时很有用的扩展点。4. Composition API 下的DOM操作4.1 script setup中如何绑定模板引用到了script setup组合式API时代模板引用变得更简洁了。核心规则是你在模板里写refxxx时它会自动去setup作用域里找有没有同名变量。如果存在一个const xxx ref(null)就把对应的DOM元素或组件实例赋给这个引用变量的.value。script setup import { ref, onMounted } from vue const inputRef ref(null) onMounted(() { inputRef.value?.focus() }) /script template input refinputRef typetext / /template这里有两个容易踩的点。第一变量名必须和模板里的ref字符串完全一致否则绑定不上但也不报错拿到的是null。第二访问模板引用要通过.value这跟在普通ref变量里存数字一样。很多人刚转组合式API时在onMounted里写inputRef.focus()结果调用的其实是ref对象的某个方法自然报错。组合式API下同样存在DOM更新时机问题。想在修改数据后马上操作DOM有两个选择。一是用await nextTick()这和Options API里的$nextTick是同一个机制。二是在watch回调里指定flush: post这样回调会在DOM更新之后执行适合“当某个数据变化后需要基于新DOM做下一步处理”的场景watch(() props.activeIndex, (newIndex) { listRef.value?.children[newIndex]?.scrollIntoView() }, { flush: post })4.2 v-for列表与函数式ref处理动态列表时简单地在v-for里写reflist会得到一个数组。这个数组能用但如果有大量条件渲染、元素增删数组的下标和当前数据项容易错位。更稳的方案是使用函数式ref也就是把ref绑定成一个函数由你自己来控制把元素存到哪里。script setup import { ref } from vue const itemRefs ref([]) function setItemRef(el, index) { if (el) { itemRefs.value[index] el } else { itemRefs.value.splice(index, 1) } } /script template div v-for(item, index) in items :keyitem.id :ref(el) setItemRef(el, index) {{ item.name }} /div /template函数式ref的规则是每次DOM更新后会用新的元素调用一次元素卸载时也会用null调用一次。所以函数里必须判断el是否存在。如果只做itemRefs.value[index] el而不处理null删除列表项时数组里会残留旧引用造成“明明删除了但还能拿到那个节点”的诡异问题。还有一点在模板里写:ref(el) setItemRef(el, index)每次重新渲染都会生成一个新的箭头函数。Vue在这种场景下把旧的函数以null调用所以处理逻辑要放在同一个命名函数里不要在setItemRef里依赖旧闭包。如果只是读取元素不涉及元素增删用内置数组ref的写法也可以但要注意索引稳定性。5. 更底层和更可靠的辅助手段5.1 组件实例与$el的边界除了模板ref组件实例本身也暴露了一些和DOM相关的入口。Vue 3里每个组件实例可以通过$el拿到当前组件渲染出的根元素。在Options API里是this.$el在script setup中可以通过getCurrentInstance()的proxy.$el访问但后者属于底层API日常开发不推荐直接使用。真正的核心问题是你有没有必要去拿一个子组件的根元素。很多人在父组件里获取子组件实例然后child.$el.querySelector(.xxx)去改里面的DOM这会把父组件和子组件的内部实现死死绑在一起。子组件一旦改了根节点结构父组件里的DOM操作可能直接失效而且这种跨组件修改对子组件来说是一种“意外入侵”。更合理的做法是让子组件自己暴露一个方法父组件通过模板引用调用。比如一个滚动容器子组件内部维护列表DOM然后提供scrollToBottom方法defineExpose({ scrollToBottom() { scrollerRef.value.scrollTop scrollerRef.value.scrollHeight } })父组件拿到的是子组件实例调用这个方法而不需要知道内部用的是哪个元素、类名是什么。这样DOM操作被封装在离DOM最近的地方也是我比较推荐的一种“安全操作DOM”姿势。5.2 render函数中的ref与vnodeVue的模板最终会被编译成render函数ref在render函数里其实就是一个普通的vnode属性。如果你在写手写render函数同样可以给节点挂ref而且可以用ref变量直接接收import { h, ref, onMounted } from vue export default { setup() { const box ref(null) onMounted(() { console.log(box.value) }) return () h(div, { ref: box }) } }这种写法能从更底层理解ref的本质它不是DOM操作API而是Vnode创建过程中预留的钩子。当Vnode被patch成真实DOM后Vue会把节点实例赋值给对应的ref变量。理解了这一点也就明白为什么ref只能在初始化之后拿到值因为虚拟DOM还没有变成真实DOM之前能赋值的对象根本不存在。5.3 nextTick异步更新下的DOM时机前面已经多次提到nextTick它值得单独拎出来说清楚。Vue的数据更新不是同步的。当你修改一个响应式数据页面上的DOM不会立刻变化而是等当前宏任务运行完微任务队列里执行patch。这个设计是为了合并同一轮里多次数据修改避免反复触发渲染代价就是你无法在数据修改后立刻读取到最新DOM。nextTick就是帮你等下一次DOM更新结束的Promise。在组合式API中可以直接await nextTick()在Options API里可以用this.$nextTick。很多情况下这是个兜底方案但我们还可以更进一步用watch搭配flush: post把“等DOM更新”这个动作和数据变化监听合并起来代码更清晰watch(source, async () { // 这时候DOM还没更新 await nextTick() // 这里可以安全读取最新DOM })需要警惕的是nextTick只保证“Vue的patch完成”不保证浏览器已经完成布局、样式计算和绘制。如果你需要读取元素的实际高度、位置建议在nextTick之后再用requestAnimationFrame包一层。这个组合我在做拖拽、动画的时候经常用能有效避开样式还没计算完导致的偏差。6. 实战中常见问题与避坑6.1 几个典型的坑第一个坑created里访问$refs。这个钩子里组件实例已经创建但渲染还没开始所以$refs一定是undefined。不只是$refs依赖DOM的第三方库初始化也绝对不能在created里做。所有涉及DOM的操作都推迟到mounted或者用户交互事件里。第二个坑把DOM节点放进reactive。有些人喜欢用reactive({ list: [] })来存一组元素引用结果发现操作卡顿或者引用行为诡异。因为reactive会给对象做深度代理而DOM节点本身结构复杂、属性很多被代理后会生成大量Proxy对象白白增加性能开销。存储DOM引用用普通数组或者ref就足够了别让响应式系统去劫持一个本不需要响应式的对象。第三个坑自定义指令卸载后没清理。在document、window上绑定的监听器、IntersectionObserver实例、定时器等如果在unmounted里不清理组件销毁后依然会被触发。尤其是Vue 3的keep-alive缓存组件组件不是销毁而是停用指令的unmounted可能根本不会被调用这时你就要考虑用deactivated钩子配合清理。第四个坑通过$el拿子组件根元素后直接改子组件内部的DOM。这会让数据流变得不可控。你在父组件里改了一个DOM类名但子组件内部的数据状态并没有同步下次子组件更新时改动就被覆盖。排查起来特别费劲因为“改的时候有效、刷新后失效”这种问题很难一眼定位。第五个坑动态ref的索引错位。在v-for里用数组ref时如果列表数据有插入、删除元素数组的顺序不一定和你的v-for索引一致。尤其配合v-if或动态组件时更容易出现“拿到的元素不是当前项”的问题。函数式ref虽然要自己维护数组但可控性更高。6.2 问题速查表症状常见原因推荐解法this.$refs.xxx或xxx.value是undefined访问时机太早元素还没挂载或v-if为false放在mounted、nextTick、watch的flush: post中修改数据后读取的DOM尺寸是旧值Vue异步更新DOM尚未patch完成使用await nextTick()后再读取v-for中ref数组顺序对不上循环里有条件渲染或动态增删改用函数式ref显式维护元素数组指令绑定的全局事件触发两次以上指令卸载时没有解绑或组件被keep-alive缓存重复触发在unmounted中移除监听必要时处理activated/deactivatedref拿到的是组件实例而不是DOM在组件标签上使用ref需要DOM时用$el但更推荐子组件通过defineExpose暴露方法列表删除后函数式ref里仍残留旧引用没有在el null时清理数组项在函数式ref中判断el为null时splice或置空6.3 我的操作习惯最后分享几个我这些年沉淀下来的操作习惯也算是对整个主题的实践总结。第一凡是能通过数据驱动解决的问题坚决不在业务代码里写querySelector。比如切换类名用:class绑定控制显隐用v-if修改文本用插值表达式。这不是教条而是为了让你维护状态时有一个唯一可信的数据来源。一但开始直接操作DOM以后别人改代码时很难猜到这个状态是从哪里来的。第二如果必须操作DOM优先找“最靠近这个DOM的位置”去操作。一个组件内部的ref好过父组件跨越层级去拿子组件的$el一个自定义指令的mounted钩子好过在每个页面里重复写初始化和清理逻辑。把DOM操作约束在局部后续重构时才不会牵一发而动全身。第三写任何和DOM相关的代码前先想清楚清理工作。事件监听要解绑定时器要清除第三方实例要销毁。把这些代码和初始化代码放在一起形成一种条件反射。我见过太多因为漏了清理导致的内存泄漏问题最后排查时都特别痛苦因为问题往往在页面切换几十次之后才出现。在Vue里操作DOM并不难难的是在正确的地方、用正确的方式去操作。理解了声明式渲染、ref、自定义指令、组件边界和异步更新时机你在真实项目里遇到任何“拿不到DOM”“改完没生效”的问题基本都能快速定位。希望这篇经验整理能给正在跟DOM搏斗的你一点帮助。