ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue中操作DOM的完整指南:ref、nextTick与自定义指令实战

Vue中操作DOM的完整指南:ref、nextTick与自定义指令实战 做前端久了你会发现一个挺有意思的规律Vue官方文档反复强调数据驱动、避免操作DOM但真正写复杂业务的时候谁也绕不开直接操作DOM的需求。从聚焦输入框、动态设置滚动位置到接入富文本编辑器、地图、播放器这类第三方库再到做性能优化时的节流防抖背后全都要用到vue里操作dom的方法。这篇文章不聊虚的就围绕在Vue项目里到底怎么优雅、安全、不出bug地操作DOM这一件事从最基础的模板ref讲到自定义指令封装再讲生命周期中的执行时机、Vue 2与Vue 3的差异最后落到高频真实业务场景和性能坑点上。不管是刚上手Vue的新人还是从Vue 2往Vue 3迁移的兄弟这篇都能给你一些值得直接抄走的经验。1. 为什么绕不开DOM操作数据驱动不是万能的1.1 数据驱动的边界到底在哪里Vue的核心设计哲学是数据驱动视图模板里写div{{ msg }}/div只要改了msg页面上的文字就跟着变。这套声明式渲染机制让开发者不用再像jQuery时代那样手动$(#xxx).text(新内容)大幅降低了心智负担。但这套机制解决的是视图跟随数据变化的问题它并不覆盖所有前端场景。我打个比方数据驱动相当于是给水龙头装好了自动开关你只要拧阀门水就会流出来但DOM操作解决的是水管的接头松了要手动拧紧想在水管上接个不一样的喷头这种数据驱动没法自动处理的事。换句话说凡是视图本身的状态变化不等于数据变化的场景基本都需要手动碰一下DOM。这里有几个典型场景第三方库接管了DOM元素比如编辑器、播放器、地图它们内部自己维护一套状态Vue的数据变化并不会同步给它们。焦点、光标、滚动位置这类页面状态不属于业务数据你总不能为了聚焦一个输入框在data里存一个isFocused字段吧。纯视觉类的动画、样式效果比如拖拽排序、自定义滚动条、canvas绘制这些不经过真实DOM就实现不了。性能敏感的操作比如长列表滚动时直接操作style属性比走一遍响应式更新要快得多。1.2 操作DOM的错误姿势与正确姿势很多新手一上来就写document.getElementById(xxx)这在Vue项目里不是不能用但隐患很大。因为Vue的组件是可以复用的同一个组件在页面上可能同时渲染出多个实例你用全局id去定位组件一多、一嵌套拿到的往往是错的节点。而且getElementById这类原生查询绕过了Vue的组件封装导致代码难以维护别人根本不知道这个节点是从哪冒出来的。正确的思路是能用模板ref解决的绝不用全局查询能用数据驱动的绝不手动改DOM必须手动操作的封装成指令或方法并限制在组件内部。这套方法论是后面所有操作方案的前提。2. 基础方案模板ref和$refs的完整用法2.1 模板里怎么声明refVue 2和Vue 3的模板语法一致在目标DOM上加上ref属性即可template div input refsearchInput typetext placeholder搜索... / button clickfocusSearch聚焦搜索框/button /div /template关键点在于ref这个属性会在组件实例的this.$refs上注册一个对应的引用。注册的时机是组件挂载完成以后。你不需要在data里声明它也不用做任何初始化它天然就是响应式之外的旁路系统。这里有一个容易忽略的知识点ref可以加在普通DOM元素上也可以加在子组件上。加在子组件上时拿到的是子组件的实例你可以直接调用子组件内部的方法、读取子组件的data。这在父子通信时偶尔会派上用场但要注意别滥用否则会让组件间的边界变模糊。2.2 Options API里通过this.$refs访问在Vue 2和Vue 3的Options API版本中通过this.$refs就能拿到所有声明过ref的元素export default { methods: { focusSearch() { const input this.$refs.searchInput; if (input) { input.focus(); } } } }这里有个高频bug在created生命周期里访问this.$refs是拿不到东西的。因为created阶段组件实例刚创建模板还没编译DOM节点还没渲染。你最早只能在mounted里访问。这一条我记得特别清楚早年刚用Vue时踩过后面所有面试也几乎都会问。另外this.$refs是一个普通对象键名就是ref属性的字符串值。注意不要跟data里的属性重名不然容易把自己绕晕。2.3 Composition API中通过ref函数访问Vue 3的Composition API给ref这个关键词带来了新的含义——它既可以是模板引用也可以是响应式数据。这就导致很多刚切换到Vue 3的开发者特别容易混淆。在script setup语法下事情简单了很多script setup import { ref, onMounted } from vue; // 变量名必须和模板中的refxxx保持一致 const searchInput ref(null); onMounted(() { // 此时searchInput.value就是真实的DOM节点 searchInput.value.focus(); }); function focusSearch() { searchInput.value?.focus(); } /script注意几个细节const searchInput ref(null)这里的变量名必须和模板里的refsearchInput字符串完全一致Vue 3在script setup下靠变量名做自动绑定。初始值是null因为DOM还没渲染出来所以提前访问searchInput.value会得到null。访问时必须通过.value取到真实DOM模板里自动解包但JavaScript里不会。很多从Vue 2切过来的人在这里犯迷糊以前this.$refs.searchInput直接就是DOM怎么现在成了searchInput.value因为Vue 3把响应式数据和DOM引用统一成了ref这个API两者在底层都是通过对象包装的。3. 在v-for列表里获取多个DOM节点3.1 $refs在v-for中的行为特征业务里经常有这种需求渲染一个列表然后操作其中某一项或者全部项的DOM。比如一个手风琴效果、一个聊天消息列表要自动滚到底部。先看Vue 2中的写法ul li v-for(item, index) in list :refitem-${index} :keyitem.id {{ item.name }} /li /ul在Vue 2里如果在v-for上写refitem那么this.$refs.item会是一个数组数组元素是DOM节点。如果你期望它是个对象或单个节点就容易拿错。到了Vue 3的Composition API里行为发生了一些变化。同样在script setup中你可以用一个ref数组来接收script setup import { ref, onMounted } from vue; const list ref([ { id: 1, name: 张三 }, { id: 2, name: 李四 }, { id: 3, name: 王五 } ]); // 这个数组用来收集所有li的DOM引用 const itemRefs ref([]); // 注意在模板里ref直接写itemRefs不用加item-前缀 // Vue 3中ref函数会自动把函数值收集到数组中 onMounted(() { console.log(itemRefs.value.length); // 3 }); /script在Vue 3的模板中可以这样写ul li v-foritem in list :refsetItemRef :keyitem.id {{ item.name }} /li /ul这里的setItemRef是一个函数需要写到script里const itemRefs []; function setItemRef(el) { if (el) { itemRefs.push(el); } }3.2 函数式ref的使用技巧上面这种函数式ref是处理v-for列表的推荐方式。它的原理是Vue在挂载、更新时调用你传入的函数把当前节点的真实DOM作为参数传过来。通过闭包你可以在外部维护一个数组。这里有一个重要的提醒函数式ref在每次重渲染时都会被重新调用。如果直接在函数里push列表更新一次数组就会多出重复项。所以需要加去重逻辑或者用Set结构const itemRefs new Set(); function setItemRef(el) { if (el) { itemRefs.add(el); } else { itemRefs.delete(el); } }当节点卸载时Vue会传入null所以主动清理。这个细节在我的实际项目中救过命否则列表翻页几次数组里全是重复的DOM引用滚动的计算全乱套。4. 操作时机生命周期、nextTick与渲染边界4.1 created、mounted和updated里的DOM状态操作DOM最绕不开的话题就是时机。Vue的生命周期是有严格顺序的created实例创建完成data、methods都能访问但模板还没挂载$el都是undefined这时候拿不到任何DOM。mounted模板已经渲染并挂载到页面上DOM节点真实存在可以操作了。updated数据变化导致DOM重新渲染完成后这里是重新布局后的时刻。一个非常常见的场景你在mounted里初始化一个图表库比如EChartsonMounted(() { const chartDom document.getElementById(chart); // 不建议 // 更推荐通过ref拿节点 const chartDom chartRef.value; const chart echarts.init(chartDom); chart.setOption(option); });这种写法在首次渲染时是没问题的因为mounted保证节点已存在。问题通常出在数据更新后DOM还没渲染完就要操作的场景。4.2 nextTick到底解决了什么来看一个典型例子const showBox ref(false); function toggleAndScroll() { showBox.value true; // 此时box刚变成显示状态但DOM还没渲染出来 // 直接操作boxRef.value得到的是null或者旧状态 boxRef.value.scrollTop 100; // 这里会报错或无效 }Vue的响应式更新是异步的。你修改数据之后DOM更新不是立即发生的而是被放进了队列等当前事件循环的微任务阶段统一处理。所以你在修改数据的下一行代码里去操作DOM大概率拿到旧的DOM。解决方案就是nextTickimport { nextTick } from vue; async function toggleAndScroll() { showBox.value true; await nextTick(); // 此时DOM已经最新 boxRef.value.scrollTop 100; }在Options API里也可以this.$nextTick(callback)两者本质一样。我记得曾在一个聊天项目里写消息自动滚动一开始是this.messages.push(...); this.$refs.chatList.scrollTop this.$refs.chatList.scrollHeight结果滚动永远少一段。后来在push之后套了一层this.$nextTick()问题立刻消失。这个坑在几乎所有动态内容类项目里都会遇到值得记下来。4.3 nextTick的原理和注意事项nextTick底层其实是用Promise降级方案是setTimeout把任务推迟到下一个微任务执行。Vue的响应式DOM更新也是排到微任务队列里的所以nextTick的回调会排在那次DOM更新之后。更准确地说每次数据变化Vue会把对应的渲染Watcher放入队列队列会在当前事件循环结束后执行并且会对多个变化做批量合并。nextTick中的回调是放在这个队列清空之后执行。这就保证了你拿到的DOM是最新状态。使用时有几个要注意的点同一时间修改多个数据只需要一个nextTick不用每个赋值后面跟一个。在mounted里如果理论上DOM应该渲染完了但某些子组件可能还没完成这时候await nextTick()也未必能拿到子组件内部已经异步渲染出的结构需要依据实际场景判断必要时多等一轮。nextTick只是把操作延后并不能消灭根因——如果你在一个大数据渲染后频繁调用nextTick性能不会变好只是延迟了问题。5. 自定义指令把DOM操作封装成可复用能力5.1 什么时候应该从ref升级到自定义指令在组件里用ref操作DOM没问题但有几种情况ref写起来很啰嗦同一个操作要在多个组件里重复使用比如自动聚焦、权限隐藏、水印、拖拽。操作逻辑和组件业务无关属于通用能力比如点击外部关闭下拉。你希望在DOM生命周期的各个阶段都能插手插入、更新、卸载。这时候就应该把DOM操作封装成自定义指令。自定义指令是Vue提供的官方扩展点专门用来做这类事。5.2 Vue 2与Vue 3的指令钩子变化先看Vue 2的写法// 自动聚焦指令 Vue.directive(focus, { inserted(el) { el.focus(); } });Vue 3把钩子函数改了个名语义更清晰同时去掉了inserted和componentUpdated// Vue 3 app.directive(focus, { mounted(el) { el.focus(); } });钩子函数对照表在这里直接收藏Vue 2钩子Vue 3钩子触发时机bindbeforeMount指令绑定到元素时insertedmounted元素插入父节点时updateupdated所在组件更新时componentUpdatedupdated所在组件及子组件更新后unbindunmounted指令与元素解除绑定时5.3 实用案例v-watermark、v-permission、v-debounce空谈理论没有意思我直接分享几个实际项目里经常用的自定义指令。第一个水印指令// watermark.js const defaultOptions { text: 内部资料, fontSize: 16, color: rgba(0, 0, 0, 0.1), rotate: -20 }; function createWatermark(el, binding) { const options Object.assign({}, defaultOptions, binding.value || {}); const canvas document.createElement(canvas); canvas.width 240; canvas.height 120; const ctx canvas.getContext(2d); ctx.font ${options.fontSize}px Arial; ctx.fillStyle options.color; ctx.translate(80, 60); ctx.rotate((options.rotate * Math.PI) / 180); ctx.fillText(options.text, 0, 0); // 用canvas生成的base64图片作为div背景避免重复生成 const wrapper document.createElement(div); wrapper.style.position absolute; wrapper.style.inset 0; wrapper.style.pointerEvents none; wrapper.style.zIndex 9999; wrapper.style.backgroundImage url(${canvas.toDataURL(image/png)}); wrapper.style.backgroundRepeat repeat; el.style.position relative; el.appendChild(wrapper); } const watermark { mounted(el, binding) { createWatermark(el, binding); }, unmounted(el) { const wrappers el.querySelectorAll(div[style*pointer-events]); wrappers.forEach(w w.remove()); } }; export default watermark;第二种权限控制指令这个比用v-if写权限判断要干净得多const permission { mounted(el, binding) { const requiredPermission binding.value; // 从store里取当前用户权限列表 const permissionList useUserStore().permissions; if (!permissionList.includes(requiredPermission)) { el.parentNode el.parentNode.removeChild(el); } } };第三种防抖指令处理输入框的搜索请求const debounce { mounted(el, binding) { const { value, arg } binding; let timer null; el.addEventListener(input, () { clearTimeout(timer); timer setTimeout(() { value(); }, Number(arg) || 300); }); }, unmounted(el) { el.__debounceTimer clearTimeout(el.__debounceTimer); } };自定义指令的精髓在于你把DOM操作写进了组件无关的层以后换项目、换团队这些指令可以原封不动带走。我自己的习惯是把通用指令统一放进src/directives/目录在main.js里一次性注册后续业务代码里一行v-focus、v-watermark就搞定了。6. 典型业务场景实操第三方库、滚动、富文本与地图6.1 富文本编辑器的正确接入姿势富文本编辑器是DOM操作的重灾区。以wangeditor、quill为例它们都需要一个真实的DOM容器来初始化template div div refeditorContainer/div /div /templateonMounted(() { const editor new E(editorContainer.value); editor.config editorConfig; editor.create(); });这里面有几个坑容器必须是一个真实的块级元素不能是自定义组件否则编辑器初始化时会找不到正确的DOM结构。编辑器有自己的内部DOM结构你可以在它的官方配置里设置内容但不要外部手动改它的DOM否则数据同步会错乱。组件销毁时一定要调用编辑器实例的销毁方法否则会造成内存泄漏尤其在一个列表里反复创建销毁编辑器时问题特别明显。6.2 聊天消息列表自动滚动到最新这是一个很经典的实战需求实现起来非常讲究async function sendMessage() { messages.value.push(newMessage); await nextTick(); // 要拿到消息容器的scrollHeight const container chatListRef.value; container.scrollTop container.scrollHeight; }这里还有个优化技巧如果用户正在往上翻看历史消息你不应该强行把滚动条拉到底部。检测方式很简单function isNearBottom() { const container chatListRef.value; // 距离底部小于30px就认为是正在看最新消息 return container.scrollHeight - container.scrollTop - container.clientHeight 30; }这个判断在你做消息推送自动滚动时特别有用。实际在大量消息渲染的时候单纯操作scrollTop可能还不够因为一次性渲染上千条消息会导致页面卡顿这时候就要考虑虚拟滚动。6.3 地图组件中操作DOM的最佳实践接入腾讯地图、高德地图或者百度地图时一个最容易踩的坑是组件实例创建和地图实例生命周期冲突。地图SDK通常需要拿到容器DOM之后立即初始化但这个容器可能在v-if、v-show的控制下还没渲染出来。我的建议是使用v-show控制地图容器的显隐这样DOM始终存在避免地图实例被重建。地图初始化放到mountednextTick之后确保容器尺寸正常。组件销毁时调用map.destroy()清理地图实例。如果地图被放在弹窗Modal / Drawer里需要等弹窗动画结束、容器有宽高之后再去初始化。否则地图可能出现灰色区域。我踩过最狠的一个坑是把地图放在el-dialog里第一次打开地图正常第二次打开是灰的。原因就是弹窗关闭时地图实例被销毁了但DOM容器还在第二次初始化时地图SDK认为容器已经初始化过了不走正常创建流程。解决方式是每次打开弹窗时手动重建地图实例并且在destroy阶段完整清理。6.4 高性能滚动容器中的直接DOM操作当你做拖拽排序、自定义滚动、canvas图表这类高性能要求的功能时直接操作style往往比通过data绑定更高效。看一个低延迟拖拽的例子function onDragMove(e) { // 不通过data实时更新坐标而是直接操作DOM的transform // 这样可以避免Vue的响应式开销拖拽不掉帧 draggingEl.value.style.transform translate(${e.clientX - offsetX}px, ${e.clientY - offsetY}px); }但这里有个很重要的原则当你直接通过style操作DOM时这个状态就脱离了Vue的响应式系统你必须在合适的时机把最终结果同步回data否则下次渲染这块DOM时style会被覆盖或者状态不一致。通常我会在drag start时记录快照在drag end时把最终坐标写入data中间过程全部用原生style。这是一种混合驱动策略兼顾了性能和可维护性。7. Vue 2和Vue 3操作DOM的差异清单7.1 ref返回值与this指向的变化如果你是从Vue 2的老项目迁到Vue 3下面这个差异清单可以存一下Vue 3中script setup内声明的ref变量在模板里自动解包但JS里必须.value才能拿到DOM。Options API中this.$refs的用法在Vue 3里依然可用但更推荐组件内使用组合式API风格。函数式ref在Vue 3里是官方推荐的处理列表方式Vue 2里同样支持但很多老项目没用上。7.2 自定义指令钩子、生命周期命名差异Vue 3把生命周期钩子和指令钩子的命名做了统一组件生命周期beforeUnmount替换beforeDestroyunmounted替换destroyed。指令钩子unmounted替换unbind删除inserted和componentUpdated。Vue 3中指令的bind改名叫beforeMount语义上更符合即将挂载的时机。记得我上线的第一个Vue 3项目里多个v-watermark指令突然失效一查就是钩子名没改。这种问题往往不是报错而是静默失效特别烦人排查了半天才怀疑到钩子名称上。7.3 同一组件多个实例时的ref隔离Vue 2时代如果你在多个子组件上用了同一个ref名this.$refs取到的可能是一个数组也可能是最后一个实例行为比较混乱。Vue 3在Composition API里ref名和变量名绑定天然做了隔离不会互相污染。这在写复杂页面时省心不少。7.4 Vue 3中模板引用的null重置行为还有一个容易忽略的点在Vue 3中每次组件卸载前模板引用会被设为null。比如一个子组件v-ifshow当show变为false时父组件里关联的ref值会变成null。这个行为在写卸载动画、清理事件时很关键。8. 性能优化什么时候不要用ref操作DOM8.1 避免频繁触发重排和重绘操作DOM本身没什么问题问题是在关键渲染路径上做了高成本操作。比如在滚动事件里反复读写scrollHeight、offsetTop会造成布局抖动layout thrashing。正确的姿势是把读写分离// 坏示例循环里交替读和写 for (let i 0; i items.length; i) { items[i].style.top items[i].offsetHeight i px; } // 好示例先批量读取再批量写入 const heights items.map(item item.offsetHeight); items.forEach((item, i) { item.style.top heights[i] i px; });8.2 用CSS和事件委托替代部分DOM操作很多DOM操作的目的是样式变化比如切换class。这件事用Vue的:class绑定就能完成不需要手动改className。事件监听也可以利用事件委托在父容器上挂一次监听而不是每个子元素都挂这样可以减少内存占用。8.3 高频场景用requestAnimationFrame合并如果必须在滚动、拖拽中操作DOM推荐用requestAnimationFrame做节流let ticking false; function onScroll() { if (ticking) return; ticking true; requestAnimationFrame(() { updatePosition(); // 真正操作DOM的代码 ticking false; }); }这样可以保证一帧内只执行一次DOM更新避免浏览器做无数次的重复布局。8.4 操作完记得清理事件监听在自定义指令里绑定的事件如果在unmounted时不清理会保留在全局或父元素上导致内存泄漏。比如const tooltip { mounted(el, binding) { const handler () { console.log(show tooltip); }; el.__tooltipHandler handler; el.addEventListener(mouseenter, handler); }, unmounted(el) { el.removeEventListener(mouseenter, el.__tooltipHandler); } };我的习惯是所有手动addEventListener的地方都要成对出现removeEventListener并且把handler保存到el.__xxx这种自定义属性上方便卸载时拿到。这一条虽然是老生常谈但我见过太多线上问题最后都归结于事件泄漏。9. 常见坑与排查思路从拿不到DOM到拿到了错的DOM9.1 拿不到DOM的排查链路如果你在操作DOM时遇到Cannot read properties of null按下面顺序排查是不是在created里访问了$refs改成mounted或nextTick。是不是元素被v-if控制当前不在DOM里检查一下条件或者用v-show替换。是不是在父组件的mounted里去访问子组件内部的DOM父组件mounted不代表子组件的子节点都渲染好了必要时await nextTick()或者setTimeout延后一轮。是不是用了动态ref名但值里带了字符串拼接导致key对不上9.2 拿到了错的DOM的排查链路另一种情况是ref能拿到但拿到的节点不是你预期的检查是不是同一个名字在多个地方使用尤其是v-for里ref重复导致覆盖。检查是不是加了v-if和v-else两个分支同时有相同ref名。检查是不是在子组件上加了ref你拿到的是组件实例而不是真实DOM元素。Vue 3中如果要在子组件根节点上拿到DOM可以往子组件上暴露方法或者用defineExpose显式暴露。Vue 3的script setup组件默认关闭了实例属性的外部访问所以父组件拿到的子组件实例结构发生了变化不能像Vue 2那样随便$refs.child.xxx去取子组件内部的东西。9.3 操作没生效检查CSS、样式优先级和强制刷新有时候ref拿到节点了也做了.style.display none但页面没变化。这种问题多半不是JavaScript的锅而是CSS优先级被别的class覆盖了。排查时用开发者工具的Elements面板看看目标元素的最终样式确认一下是哪个规则覆盖了你的赋值。也有可能是你赋值的是非法值比如style.width abc浏览器会静默忽略。写代码时养成一个好习惯对DOM操作的结果做断言式的验证尤其在做自动化测试的时候。10. 写在最后我把这些经验沉淀成了什么操作DOM这件事本身不难难点在于在正确的时间、用正确的方式、操作正确的节点。我在实际项目里总结出这么几条原则分享给你第一优先用数据驱动。只要你能用Vue的响应式系统解决的问题就不要去碰DOM。数据驱动是更省心、更不容易出bug的方案DOM操作是用在数据驱动鞭长莫及的边缘地带。第二优先用模板ref。它比原生getElementById更安全因为它是组件实例级别的不存在全局污染。拿到的节点和组件的生命周期强绑定心智负担小。第三需要复用的DOM操作封装成自定义指令。一个团队里如果每个组件都用不同的方式处理聚焦水印权限按钮代码风格会非常混乱。统一定义后新人拿到代码也能一眼看懂。第四操作前后考虑生命周期和时机。mounted能不能操作、数据更新后要不要nextTick、组件卸载后要不要清理都要在写之前想清楚。第五做性能优化时要时刻想着渲染管线。不要在一个函数里频繁改DOM把读写分离能用CSS的就别用JS能用事件委托的别每个节点挂监听。如果把Vue的开发比作开车数据驱动是自动挡那DOM操作就是手动模式的换挡拨片。自动挡覆盖95%的路况但真正玩车的人都知道关键时刻的那么几次手动操作会让你整个项目体验完全不一样。希望这篇关于vue中操作dom的方法的梳理能帮你把这块拨片用得更加顺手。
返回列表