Vue 操作 DOM 的完整指南:模板引用、nextTick 与自定义指令

发布时间:2026/10/20 18:53:34
Vue 操作 DOM 的完整指南:模板引用、nextTick 与自定义指令
先从我很早就想问的一个问题聊起在 Vue 里到底怎么操作 DOM很多人刚写完 jQuery 时代代码转过来第一反应是document.getElementById或者document.querySelector然后发现自己改完一个元素的class组件随便一刷新改动就没了开始怀疑人生。我也经历过这个阶段后来才慢慢摸透Vue 不是不让你碰 DOM而是把你的操作入口重新设计了一遍。如果你还停留在全局找节点、改节点、再找下一个节点的思路写出来的代码大概率会跟响应式系统打架。这篇文章我想系统地把Vue 中操作 DOM 的方法拆开讲从最正统的模板引用ref到等 DOM 更新完的nextTick再到事件对象$event、自定义指令以及图表、表格滚动、动态内容回填这些绕不开原生 DOM的真实场景。顺带把 Vue2 和 Vue3 写法的差异也点出来。适合刚学完 Vue 基础准备做真实业务的读者也适合写了两三年但一直没系统归纳过这套东西的前端。1. 先搞懂一件事Vue 为什么劝你少碰 DOM1.1 声明式渲染把 DOM 变成了结果Vue 的核心思路是声明式渲染你关心的是数据DOM 只是数据的一个投影。模板里写button clickcount{{ count }}/button你只需要改count按钮里的文字自己会变。这个过程中 Vue 内部靠虚拟 DOM 做 diff精准地找到需要更新的节点然后打补丁。对比一下传统的命令式写法你要手动document.createElement、appendChild、更新textContent每一步都得自己安排。Vue 把这些工作接管了所以你嵌在组件里的每一段手动操作 DOM代码都相当于在一个被别人托管的地盘上搞事情。短时间没事一旦响应式更新触发 patch你手动改过的地方就可能被整体覆盖掉。1.2 直接操作 DOM 的三个典型坑第一个坑是被覆盖。你手动给某个div加了active类过一会儿依赖的数据变化Vue 重新渲染class按照模板重新算你加的类没了。第二个坑是状态失同步。你改了 DOM 上的文字但数据源还是旧值下次有需求要读这个状态你只能再从 DOM 里抠出来数据流从此开始混乱。第三个坑是无法复用。操作 DOM 的代码散落在各种方法里换个页面想用同一个交互逻辑只能复制粘贴改一个地方要全局搜索。这些坑我都踩过。我自己的经验是一旦发现自己在组件里频繁用querySelector改内容先停一下大概率是应该用:class、v-if、v-model这类声明式语法去解决的问题而不是把节点拎出来手动改。1.3 也有不用不行的时候不过 Vue 并没有把路堵死因为确实存在一些数据驱动做不到的场景某个图表库需要在真实 DOM 节点上做初始化弹层组件要挂载到document.body业务需要测量某个元素的offsetWidth、scrollHeight或者要控制video、canvas这类原生能力比较强的媒体元素。我把这套边界想清楚之后写代码的判断就简单了很多能用模板语法解决的一律不碰 DOM只有第三方库初始化、测量尺寸、管理焦点、控制滚动位置这些场景才去走 Vue 提供的 DOM 操作通道。后面几章讲的就是这个通道里最常用的几把钥匙。2. ref最正统的模板引用方法2.1 从 Vue2 的 $refs 到 Vue3 的 ref模板引用Template Refs是 Vue 官方的 DOM 操作入口。Vue2 里最经典的是this.$refs.xxxtemplate div refbox classbox/div /template script export default { mounted() { this.$refs.box.style.color red } } /scriptVue3 的 Composition API 改成用普通ref变量去接住DOM 节点template div refbox classbox/div /template script setup import { ref, onMounted } from vue const box ref(null) onMounted(() { box.value.style.color red }) /script很多初学者对 Vue3 的这个写法心存疑惑为什么初始化是null在onMounted里才拿得到很简单模板ref的赋值时机是组件挂载时模板里的节点渲染完成box.value才会被赋成这个真实节点。你在script setup顶层立刻打印它当然是null。我个人的习惯是给这种用来接 DOM 的 ref起一个带后缀的变量名比如boxRef、inputRef。因为 Vue3 里ref同时是响应式数据的入口如果所有变量都叫ref模板里的refbox和脚本里的const box ref(null)到底谁对应谁很容易绕晕。Vue 3.5 之后的版本还提供了useTemplateRefAPI它的核心思路也是把模板引用和业务状态在语义上分离开逻辑清爽很多。2.2 v-for 里拿一堆 DOM当你在v-for里给元素设置相同的refVue 会把它们收集成一个数组。这个行为 Vue2 和 Vue3 都有但 Vue3 在 3.2.25 之前对数组的顺序没有严格保证如果你依赖了v-for遍历顺序和refs数组顺序的对应关系升级版本后可能莫名踩坑。更可控的是使用函数 reftemplate div v-for(item, index) in list :ref(el) setItemRef(el, index) :keyitem.id {{ item.name }} /div /template script setup import { onBeforeUnmount } from vue const itemRefs new Map() function setItemRef(el, index) { if (el) { itemRefs.set(index, el) } else { itemRefs.delete(index) } } onBeforeUnmount(() { itemRefs.clear() }) /script注意函数 ref 在渲染阶段会先后以null和真实元素各调用一次用来处理节点卸载和复用。所以实现里要判断el是否为空空的时候把对应的引用删掉。这种写法在走马灯、列表测量、拖拽排序这类需要精确拿到某个下标对应的 DOM的场景里非常稳。2.3 组件 ref 拿到的是组件还是 DOM给普通标签写ref拿到的是元素节点但如果给组件写ref拿到的就是组件实例不是根 DOM。Vue3 里从组件实例访问根元素的常见方式是$el或者让子组件通过defineExpose暴露自己要暴露的东西。template ChildComp refchildRef / /template script setup import { ref, onMounted } from vue const childRef ref(null) onMounted(() { // childRef.value 是组件实例$el 是组件根节点 console.log(childRef.value.$el) }) /script但我的建议是慎用$el去改子组件根元素样式。因为组件根节点只是子组件对外呈现的一部分你直接改它相当于打破封装等子组件内部条件一变根节点换了元素你的改动就悬空了。更合适的做法是让子组件用defineExpose明确暴露一个 DOM 节点、一个方法父组件只跟约定好的接口打交道各留余地。3. nextTick 和生命周期总拿不到最新 DOM3.1 异步更新队列是拿不到最新 DOM的根源在 Vue 里改了数据DOM 并不会立刻同步更新。Vue 会把同一个事件循环里的所有数据变更收进一个队列当前任务执行完毕后在下一个微任务里统一执行渲染。这是为了性能考虑避免你连续改 100 次数据组件也跟着渲染 100 次而是批量算一次最后只 patch 一次。这也直接导致了一种高频问题数据改了紧接着读 DOM 尺寸、设置焦点、调滚动位置结果拿到的都是旧值。一个很形象的类比是食堂打饭你喊加个菜后厨不会立刻把菜端到你面前而是等这一波人点完统一炒一批再叫号取餐。你如果站在队伍里马上回头去拿盘子盘子里肯定是空的。3.2 等 DOM 更新的三件套等 DOM 更新最直接的工具是nextTickimport { ref, nextTick } from vue const list ref([]) const containerRef ref(null) async function addItem() { list.value.push(新条目) await nextTick() // 此时 DOM 已经基于最新数据渲染完成 const container containerRef.value if (container) { container.scrollTop container.scrollHeight } }除了nextTick生命周期钩子也是天然正确的时机。组件挂载完成后才能拿到节点所以onMounted是很多初始化逻辑的默认位置。onUpdated则在组件因数据变化重新渲染之后触发适合处理渲染后必须跟着做一遍的 DOM 副作用。watch的默认触发时机也在 DOM 更新之前如果需要在数据变化后、DOM 更新完成后做点什么把flush: post加上watch( () props.data, async () { await nextTick() // 处理 DOM 副作用 }, { flush: post } )3.3 一个真实案例v-if 切换后的自动 focus我做过一个搜索面板展开条件区块时希望第一个输入框自动获得焦点。结构大概是v-ifshowPanel控制一个表单的显隐每次打开都要focus到第一个输入框。最早我直接写function openPanel() { showPanel.value true inputRef.value.focus() }结果控制台报错inputRef.value是null。原因就是showPanel刚改成true面板还没渲染节点不存在。改成下面这样就通了async function openPanel() { showPanel.value true await nextTick() inputRef.value?.focus() }nextTick之后v-if渲染出的输入框已经在 DOM 里ref也赋值完成focus()自然能生效。这个场景我见过太多人踩几乎可以当标准面试题讲。3.4 排查 ref 拿不到的标准清单如果你nextTick都用了节点还是null建议按这个顺序查第一步模板里的ref值是否和脚本里的变量名完全一致大小写敏感第二步元素是否被v-if/v-for控制此刻条件是否真的成立第三步是否在循环里用普通字符串ref循环场景优先改成函数 ref第四步是否在错误的生命周期里读比如在setup顶层同步读那是肯定读不到的第五步是否组件被缓存组件包裹缓存的组件实例可能还活着但节点不在你预期的位置。4. $event事件处理里顺便掏 DOM4.1 内联事件里的 $event 怎么用有时候你并不需要长期持有某个 DOM 节点只是在事件回调里顺手拿一下触发这次事件的元素。这时模板里可以直接传$eventbutton>function handleClick(e) { const id e.currentTarget.dataset.id // 业务处理 }如果不写圆括号直接clickhandleClickVue 也会把原生事件对象作为第一个参数自动传进去。两种写法都可以但我建议在模板里显式写$event读代码的人一眼能看出这个函数需要事件对象比隐式传递更清晰。4.2 target 和 currentTarget两个最容易用错的属性原生事件对象里有target和currentTarget两个属性很多人在这一步栽过。target是事件真正触发的元素可能是你点击的按钮内部某个子元素currentTarget是绑定事件监听的元素也就是模板里写click的那个元素。上面的例子中如果按钮内部还包了一个 icon 图标你点击图标时e.target是图标元素而e.currentTarget是按钮本身。想拿绑定事件的节点一定要用currentTarget。特别是从事件委托的角度去判断我点到了哪里时target和currentTarget的差异会直接决定判断结果。4.3 什么时候用 $event什么时候用 ref我自己的取舍标准很简单如果只是这一次事件回调里短暂取一下节点用$event currentTarget最方便代码也短但如果同一个节点要在多个不同方法里反复读取每次都用事件对象还得保证事件源正确不如在模板里打个ref之后随时随地读它。前者适合一次性交互后者适合持续跟踪。在这个基础上还可以做一层扩展事件绑定在父容器上用e.target.matches判断是不是目标子元素能实现事件委托。数据管理用>const vFocus { mounted: (el) el.focus() }点击外部关闭这个稍微复杂一点要绑定全局事件并且在卸载时移除监听否则会内存泄漏const vClickOutside { mounted(el, binding) { el.__clickOutsideHandler (e) { const isInside el e.target || el.contains(e.target) if (!isInside typeof binding.value function) { binding.value(e) } } document.addEventListener(click, el.__clickOutsideHandler) }, beforeUnmount(el) { document.removeEventListener(click, el.__clickOutsideHandler) delete el.__clickOutsideHandler } }这里有个细节我在el上挂了一个__clickOutsideHandler属性用来保存匿名函数。如果不存下来beforeUnmount里removeEventListener就找不到要移除的函数了。这是编写自定义指令时非常容易忽略的一点。实际在使用时只需要div v-click-outsideonClose菜单内容/div5.3 用指令封装第三方库初始化的思路我有一个习惯只要某个第三方库或工具类需要在多个页面初始化就优先想能不能写成指令。比如封装一个图表容器const vChart { mounted(el, binding) { el.__chart new ChartLib(el, binding.value) }, updated(el, binding) { if (binding.value ! binding.oldValue) { el.__chart.setOption(binding.value) } }, beforeUnmount(el) { if (el.__chart) { el.__chart.dispose() el.__chart null } } }模板里用起来很直观div v-chartchartOption styleheight: 400px/div这样做的价值在于图表实例的创建、更新、销毁全在一个指令里管理组件代码不再关心生命周期细节。以后要换图表库或者调整初始化行为只需要改指令文件。把怎么用某个库和业务怎么写解耦是我维护大型项目时最喜欢的做法之一。6. 特殊场景实录那些不用不行的 DOM 操作6.1 图表初始化与容器尺寸监听做数据可视化项目时最常见的一类 DOM 操作是给图表库提供初始化容器。这类库确实要求在真实 DOM 上执行init所以流程基本固定onMounted等组件挂载必要时nextTick再等下一次渲染然后初始化设置配置项。如果容器一开始是display:none或者宽度为 0初始化出来图表尺寸会是 0等面板展开后会变形。这时要么等容器真正可见再初始化要么在展开后手动触发resize。尺寸变化监听我建议用浏览器的ResizeObserver而不是只挂window.resize。因为容器大小可能由其他元素宽度决定整个窗口尺寸没变但图表容器宽度变了window.resize监听不到。实践里我在onMounted注册观察器在onBeforeUnmount里disconnect同时把图表实例dispose()掉避免页面切换后定时器和事件监听残留。6.2 表格滚动与滚动条定位后台管理系统里有个出现频率很高的需求渲染完长列表后把容器滚动到某个位置或者让某一行出现在可视区域里。很多 UI 组件库的表格内部真正滚动的是某一个.body-wrapper容器并不是表格组件最外层。你要做的是拿到这个内部容器再去设置scrollTop或调用scrollIntoView。这里的坑是UI 组件的内部 DOM 结构属于私有实现。今天拿到的类名明天组件库升级可能就变了。稳妥的做法是优先查找组件实例暴露的滚动方法找不到时再查询内部 wrapper同时加一个判断查不到就静默降级不让报错炸掉整个页面。这种我明确知道自己在跟第三方内部 DOM 打交道的代码注释里一定要写清楚缘由半年后你自己回头读也能想得起来。6.3 v-html 回填内容的二次操作服务端返回一段富文本模板里用v-html直接渲染是最省事的方案。但如果你要在渲染出来的内容里定位某个元素做高亮、加事件就必须先拿到这个动态内容的 DOM。做法是先给容器加一个ref然后await nextTick()再在这个容器里querySelectordiv refcontentRef v-htmlrichText/divasync function highlightContent() { await nextTick() const target contentRef.value?.querySelector(.highlight-target) if (target) { target.scrollIntoView() } }这类场景要注意两点一是富文本内容来自服务端时要确认渲染前已经做了必要的安全过滤避免不可信脚本直接注入二是给动态内容绑定事件时尽量用事件委托挂在容器上而不是给每个子元素挂监听否则内容一换监听就残留在旧元素上。6.4 手动创建临时 DOM 与 iframe 管理有些场景里我们需要绕过模板直接创建 DOM 节点插进页面。比如某些外部 SDK 要求你提供一个 div它会自己往里写内容或者你要临时创建一个iframe用来做统计。这类节点不在 Vue 的虚拟 DOM 管理范围内生命周期必须自己负责插入的时候记下引用销毁时手动移除并把相关的src置空、事件监听一并清掉。我处理这类代码的经验是把创建、插入、清理三件事放在一个独立的函数或自定义 hook 里组件卸载时统一调用清理函数。千万不要把document.body.appendChild这种全局操作散落在组件各处全局副作用一旦失控排查成本非常高。6.5 顺带拆穿 v-model 的魔法理解v-model的底层机制对判断什么时候该操作 DOM特别有帮助。看这个例子input :valuemsg inputmsg $event.target.value /它和v-modelmsg做的事情几乎一样。v-model并不是什么凭空冒出来的魔法它本质就是在模板编译阶段帮你展开了绑定 value 监听 input/change 事件这段逻辑。明白这一点你就知道许多看似框架自动完成的事情底层都是对 DOM 事件的读取和赋值。Vue 把高频路径封装好了遇到封装覆盖不到的边界你再回到$event、ref、nextTick这些基础工具上去解决问题思路就会很顺畅。7. 我的个人选择与建议把这套东西用过一遍之后我最大的体会是从老一代 DOM 操作库转过来的同学最难改的不是 API 用法而是思维方式。在 Vue 里DOM 已经是个外包员工了你只需要向数据源提需求不需要亲自去指挥 DOM 怎么渲染。我现在的决策路径基本稳定成这么几条第一能用模板语法解决的:class、v-if、v-for、v-model绝对不碰 DOM第二必须给具体节点打标记时优先用ref或函数ref而不是在组件里堆document.querySelector第三遇到数据变了但 DOM 还没更新的时机问题先想到nextTick和生命周期钩子第四如果某个 DOM 操作要复用在多个元素上写成自定义指令第五集成第三方库时在onMounted里初始化、在onBeforeUnmount里销毁这是底线。最后再分享一个小技巧如果你确实在某个一次性工具函数里用了document.getElementById这类全局查找我不会说这是错的但请在旁边加一行注释说明为什么绕过了 Vue 提供的通道。是第三方库需要是测量初始布局是临时脚本写清楚之后代码审查的人不会误判你自己日后维护也省心。Vue 其实给了你完整的操作 DOM 的通道只是需要你按它的节奏来。