尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3模板引用实战:DOM操作与组件实例全解析

Vue3模板引用实战:DOM操作与组件实例全解析 1. 模板引用的基础认知为什么需要 ref 操作 DOM做 Vue 开发的人早晚都会遇到这么一件事明明数据驱动视图已经很爽了但总有几个场景你不得不直接去碰真实的 DOM。比如组件挂载后要自动聚焦一个输入框、根据内容高度动态调整某个容器的高度、或者集成一个非 Vue 生态的第三方库这时候你手里需要一把“钥匙”能直接摸到真实渲染出来的元素——这就是模板引用Template Ref存在的意义。在 Vue2 时代我们用refxxx然后在this.$refs.xxx里拿元素用起来也算顺手。进入 Vue3组合式 API 成为主流模板引用的写法也跟着变了你得先声明一个变量并且让变量名和模板里的ref属性值保持一致。写法看起来有点绕但核心思路没变——就是给某个 DOM 节点或子组件打一个标记然后通过标记去访问它。template div refcontainer classbox/div /template script setup import { ref, onMounted } from vue const container ref(null) onMounted(() { console.log(container.value) // div classbox/div }) /script这段代码是 Vue3 模板引用的最基础形态ref(null)声明一个空引用模板里refcontainer把这个引用和 DOM 节点绑定组件挂载完成后container.value就指向那个真实元素了。为什么这里声明的是一个ref因为ref对象的.value属性是响应式的Vue 内部会在组件挂载时把元素实例赋给它卸载时再置回null这整个过程是自动的。这个项目适合谁看如果你刚接触 Vue3正在为操作 DOM 发愁或者你从 Vue2 迁移过来对$refs和组合式 API 的写法差异还不太熟悉再或者你用一段时间ref了遇到数组、组件实例、异步渲染这类稍微复杂的情况想系统地梳理一遍。这篇文章适合你。我尽量把每一个细节都讲透包括那些官方文档里写得不明显、只有踩过坑才明白的地方。2. 模板引用的几种形态与作用范围2.1 声明方式与变量名绑定的规则前面提到了Vue3 中模板引用最基本的规则是模板里的reffoo必须和script setup中的变量名foo保持一致。这个规则对新手来说比较容易搞混因为在 Vue2 里this.$refs.foo并不需要提前声明到了 Vue3 你少了声明那一步foo就不存在模板引用也会静默失效。template input refinputEl typetext / /template script setup import { ref, onMounted } from vue const inputEl ref(null) onMounted(() { inputEl.value.focus() }) /script注意这里的inputEl必须先声明为ref(null)模板才能正确绑定。我曾经见过有人直接在onMounted里写inputEl.focus()结果报错inputEl is not defined就是这个原因。还有一个小细节如果你在script setup中使用ref声明了变量但模板中没有对应的refxxx这个变量就只是一个普通的ref不会产生任何副作用不会报错也不会警告所以写的时候要自己留意是否真的绑定了。2.2 模板引用在 v-for 中的特殊行为如果你在一个v-for循环中使用了ref情况会有些不同。Vue3 中数组形式的模板引用会把所有循环项的 DOM 元素依次收集到一个数组里顺序和渲染顺序一致。template ul li v-for(item, index) in list :keyitem.id refitems {{ item.name }} /li /ul /template script setup import { ref, onMounted } from vue const list [ { id: 1, name: 苹果 }, { id: 2, name: 香蕉 }, { id: 3, name: 橘子 } ] const items ref([]) onMounted(() { items.value.forEach((el, i) { console.log(el.textContent, i) }) }) /script这里的关键是items必须初始化为ref([])这样 Vue 才能知道它应该收集数组。如果初始化为ref(null)v-for里的引用可能不会正常填充成数组。另外有一个比较隐蔽的问题——v-for和v-if不要同时用在一个元素上这其实和模板引用没有直接关系但会影响你拿到的数组元素数量和顺序。Vue3 中v-if优先级高于v-for这会导致循环中的某些项根本不渲染ref数组也会相应缺少某些槽位。推荐的做法是把v-if判断放到循环外面或者用计算属性先过滤再循环这样渲染结果和引用数组都更可预测。组件更新后v-for的 ref 数组也会自动更新不需要你手动重新赋值。不过数组的顺序和当前渲染顺序严格对应如果你在渲染后对数据做了排序记得遍历引用数组时要从当前数据状态去理解元素的排列。2.3 函数引用从静态标记到动态回调除了字符串标记Vue3 还支持函数形式模板里可以直接写一个函数作为ref的值。当元素挂载或者卸载时这个函数会被调用参数就是对应的 DOM 元素或组件实例。template div :refel setItemRef(el)/div /template script setup const itemRefs [] const setItemRef (el) { if (el) { itemRefs.push(el) } else { itemRefs.splice(itemRefs.indexOf(el), 1) } } /script函数引用的好处是你可以在回调里自己控制怎么保存这些元素。比如用Map以某个标识为 key 来保存而不是单纯依赖数组下标或者在元素挂载时立刻执行某些初始化动作而不用等到onMounted。不过函数引用有一个性能上的注意点如果函数是内联定义的就像上面这样每次都重新创建那么每次组件更新时Vue 都会先调用一次旧函数传入null再调用新函数传入元素造成额外的清理和重建。如果这个逻辑比较重推荐把函数定义成组件方法或者用useCallback如果你在写组合式函数来稳定函数引用。3. 模板引用与 DOM 操作的实战场景3.1 组件挂载后获取元素并做初始化最经典的场景就是弹窗打开后聚焦到输入框或者根据内容设置滚动条位置。直接看代码。template div v-ifvisible classmodal input refsearchInput v-modelkeyword placeholder搜索... / /div /template script setup import { ref, watch, nextTick } from vue const visible ref(false) const searchInput ref(null) const openModal async () { visible.value true await nextTick() searchInput.value?.focus() } // 或者用 watch 监听 visible watch(visible, async (val) { if (val) { await nextTick() searchInput.value?.focus() } }) /script这里特别要强调nextTick的作用。模板引用是在 DOM 更新之后才被赋值的如果你在visible true的同步代码里立刻访问searchInput.value一定还是null因为 Vue 的 DOM 更新是异步的。nextTick会等待 Vue 完成本轮渲染后再执行回调确保searchInput.value已经指向真实输入框。v-if 和 ref 的组合还有一个值得注意的点。如果元素被v-if从条件真变为假ref的值会被置回初始值null或[]反过来从假变真又会重新赋值。所以如果你频繁操作弹窗开关不要在初始化时把ref存在别的变量里应该每次需要使用前都从ref本身取值。因为组件可能在某个时刻解绑了这个引用旧变量指向的还是那个已经卸载了的元素。3.2 监听 DOM 尺寸变化与 scrollHeight很多业务场景需要知道某个容器的实际渲染高度最典型的就是折叠面板展开后要自适应高度或者对话列表要自动滚动到底部。Vue3 中监听scrollHeight不会自动触发需要配合 MutationObserver 或 ResizeObserver 来做。template div refscrollPanel classchat-panel div v-for(msg, index) in messages :keyindex classmsg{{ msg }}/div /div /template script setup import { ref, nextTick, onMounted, onBeforeUnmount } from vue const scrollPanel ref(null) const messages ref([]) let observer null let resizeObserver null const scrollToBottom async () { await nextTick() if (scrollPanel.value) { scrollPanel.value.scrollTop scrollPanel.value.scrollHeight } } onMounted(async () { await scrollToBottom() // 监听内容变化 observer new MutationObserver(() { scrollToBottom() }) observer.observe(scrollPanel.value, { childList: true, subtree: true }) // 监听容器尺寸变化 resizeObserver new ResizeObserver(() { scrollToBottom() }) resizeObserver.observe(scrollPanel.value) }) onBeforeUnmount(() { observer?.disconnect() resizeObserver?.disconnect() }) /script这里有两个监听器MutationObserver监听子节点的增删ResizeObserver监听容器尺寸变化。scrollHeight是指元素内容的实际高度scrollTop是滚动条滚动的距离把scrollTop设为scrollHeight就能让滚动条直接跳到底部。注意scrollHeight这个属性的变化本身不会触发任何事件你只能借助 DOM 变化来间接感知所以要么用上面的观察者方案要么在添加消息后手动执行一次滚动逻辑。如果你只是想监听某个特定区域的滚动位置可以用原生scroll事件script setup const handleScroll (e) { const el e.target if (el.scrollTop el.clientHeight el.scrollHeight) { console.log(已滚动到底部) } } /script这两种方式适用场景不同事件适合用户滚动交互观察者适合内容或尺寸被动变化。3.3 模板引用与第三方库的集成操作 DOM 的一个高频需求就是集成第三方库比如富文本编辑器、地图组件、PDF 预览器等。这些库通常需要一个容器元素来挂载实例模板引用就成了连接 Vue 和第三方库的桥梁。以 PDF 预览为例假设你要渲染一个多页 PDFVue 模板可能是这样template div classpdf-container pdf v-fori in numPages :keyi refpdfRefs :pagei :srcpdfUrl / /div /template每个pdf组件实例都会被收集进pdfRefs数组之后你可以遍历它们调用各自的内部方法比如获取某一页的渲染状态。这里要提醒一个使用组件引用的要点当你给一个子组件设置ref时在 Vue3 中你拿到的默认是子组件的公开实例public instance。在script setup模式下子组件的公开实例只暴露通过defineExpose明确导出的方法或属性其余内部状态都是私有的。这一点很多接手老项目的人容易踩坑等下第四部分详细说。第三方的富文本编辑器、地图组件通常不是 Vue 组件而是一个纯 JavaScript 类。这时候常见的做法是template div refeditorHost classeditor-host/div /template script setup import { ref, onMounted } from vue const editorHost ref(null) onMounted(() { const editor new SomeEditor(editorHost.value, { theme: light, language: zh-CN }) // 保存实例组件卸载时销毁 editor.destroy() }) /script这种模式的核心价值在于ref提供了一个稳定的真实 DOM 容器第三方库的各种操作不会受 Vue 虚拟 DOM 重新渲染的影响除非你主动销毁重建。需要注意如果父组件因为数据变化导致这个容器所在分支被更新而第三方库在容器里已经生成了自己的节点结构两者可能会冲突。稳妥做法是把第三方库的容器单独隔离放在一个不会被频繁重渲染的区域内并且尽量让 Vue 不干预它内部的节点。4. 子组件实例引用越过组件边界的关键4.1 默认情况下你能拿到什么给子组件标签设置ref在 Vue3 组合式 API 中有一个重要变化默认你拿到的不是子组件的完整内部实例而是一个代理对象。在script setup下这个代理对象只暴露defineExpose显式声明的部分。!-- 子组件 Child.vue -- template div classchild{{ message }}/div /template script setup import { ref } from vue const message ref(hello) const secret 这是私有的 const updateMessage (val) { message.value val } defineExpose({ updateMessage, }) /script父组件中template Child refchildRef / /template script setup import { ref, onMounted } from vue import Child from ./Child.vue const childRef ref(null) onMounted(() { console.log(childRef.value.updateMessage) // 可用 console.log(childRef.value.message) // undefined没有暴露 console.log(childRef.value.secret) // undefined }) /script这是 Vue3 的一个破坏性改动。Vue2 中this.$refs.child可以直接访问子组件的 data、methods到了 Vue3子组件的内部状态被保护起来了。这个设计其实更合理它给了组件作者一个接口控制的意识哪些能力允许父组件访问通过defineExpose明确声明就像后端 API 的鉴权一样。4.2 为什么说 defineExpose 是组件设计的一部分很多人觉得defineExpose很麻烦但一旦你把它当成组件 API 设计的一部分思路就清晰了。当你写一个表单子组件你希望父组件能调用它的validate方法当你写一个图片上传子组件你希望父组件能调用triggerUpload。其他的内部状态、计算属性、中间变量都不应该被外部碰。在设计子组件时我建议问自己三个问题父组件真的需要调用这个方法吗还是可以用事件emit代替这个属性暴露出去后会不会被外部意外修改如果子组件内部重构这个暴露的 API 会不会变化大部分场景下优先用props传入数据、emit向父组件通信、slot分发内容只有这些机制覆盖不了的时候才用模板引用暴露实例方法。例如表单校验、编辑器内容获取、滚动跳转这类命令式操作用ref合适。还有一种常见的做法是父组件通过defineExpose暴露一个整体对象script setup const api { validate, reset, getFormData } defineExpose(api) /script这样父组件拿到的childRef.value就是一个包含validate、reset、getFormData的对象语义清晰调用方便也便于后续扩展。4.3 在父组件中安全地使用子组件实例父组件中使用子组件实例要特别注意时机。和 DOM 引用一样子组件引用也要等挂载完成后才能使用。在onMounted里拿是稳定的在setup的同步代码里拿一定是null。如果你在事件回调或异步逻辑中访问子组件引用最好加一个可选链或判空const handleClick () { childRef.value?.validate() }因为组件可能因为v-if被卸载此时childRef.value会变成null不加判断直接调用会报错。可选链写法简洁安全推荐使用。另外如果子组件是异步组件defineAsyncComponent它的引用赋值的时机会有延迟你需要等它加载完成后才能访问。这种场景下用v-if配合一个loaded状态来控制渲染时机比在onMounted里傻等更保险。5. 模板引用与响应式系统的边界5.1 ref 变量本身是响应式的但 DOM 引用不是这是一个很容易混淆的概念。const el ref(null)这个el是一个响应式对象但它的.value指向的 DOM 元素本身不是响应式的也不会被 Vue 代理。你修改el.value.textContent不会触发 Vue 的响应式系统Vue 不会因为这个 DOM 内容的变化去重新渲染任何东西。反过来Vue 重新渲染时如果这个 DOM 元素被更新了el.value可能还是同一个元素如果 Vue 复用了它也可能被替换成一个全新的元素。在模板里你自己用到的ref变量会被 Vue 响应式追踪所以如果你在模板中直接渲染el本身Vue 会在挂载、卸载时更新对应的内容。但实践中很少有人会把 DOM 元素渲染到模板里这没有意义。正确理解方式是模板引用的ref用它做“标记”和“访问”不要指望它参与数据流。5.2 模板引用与 v-show 的兼容性v-show是通过display: none隐藏元素元素本身一直在 DOM 中所以ref从组件挂载到卸载始终保持有效不会因为v-show切换变成null。这一点和v-if完全不同。template div refpanel v-showvisible内容区域/div /template script setup import { ref } from vue const visible ref(true) const panel ref(null) // 不管 visible 怎么切换panel.value 都会指向同一个 DOM 元素 const toggle () { visible.value !visible.value console.log(panel.value.offsetHeight) // 注意此时可能是 0因为 display: none } /script这里有个实际开发中常踩的坑v-show隐藏的元素offsetHeight、getBoundingClientRect()等布局属性全都会变成 0但元素依然存在于 DOM 中访问它本身不会报错。如果你要获取元素尺寸必须先确保它是可见状态或者等待nextTick后再获取。5.3 避免在模板引用上做响应式数据流有人可能会尝试这样写template div :refel someRef el/div /template然后在某个状态变化时去修改元素的属性希望视图自动更新。这不是模板引用的用途也不应该这么做。如果在渲染过程中根据someRef的值去决定其他展示会让代码变得难以追踪。一个反面案例你在ref回调里把 DOM 元素存到了一个响应式数组里然后模板用v-for渲染这个数组。结果就是 DOM 元素被当作普通数据存在 Vue 的响应式系统里既降低了可追踪性还可能因为 DOM 元素的循环引用导致内存泄漏。正确的做法是把 DOM 元素存在普通变量或非响应式容器中只在onMounted、事件回调等具体时机使用。6. 模板引用常见问题与排查技巧6.1 ref 拿到 null 的典型原因这是出现频率最高的问题。我整理了一个排查清单现象可能原因处理方式onMounted中ref.value还是 null元素在v-iffalse分支中未渲染确保元素已渲染或等待条件成立模板里reffoo但script setup没有声明const foo ref(null)变量未定义补上声明名字必须一致script setup中声明了const foo ref(0)初始值不是 null初始值不是 nullVue 可能不会正确赋值改成ref(null)尝试在setup同步代码中访问ref.value组件尚未挂载移到onMounted或nextTick子组件引用想拿内部方法但子组件没有defineExpose子组件未暴露 API在子组件中defineExpose多个组件实例复用同一个ref名变量名冲突给每个引用起不同的名字或使用函数引用组件包含在KeepAlive中激活/失活状态切换ref 在失活时可能为 null用onActivated生命周期访问最实用的排查手段是在onMounted里打印ref.value如果输出null先检查模板中对应的元素是否真的渲染了再看变量名是否完全一致。Vue 的模板引用匹配是名字精确匹配一个字符都不能差。6.2 模板引用数组顺序错乱问题在v-for中使用引用时如果列表项有添加、删除、排序数组的顺序会对应当前渲染顺序。但有时你在某个事件回调中遍历这个数组发现和预期不一致原因通常是你操作的是旧数组快照。比如在一个列表中用户点击某一项你要通过 ref 数组找到对应项做高亮。这时如果依赖数组下标去匹配 DOM 元素一旦列表排序过下标就和数据不对应了。更稳妥的方案是使用函数引用在回调里以数据项 id 为 key 保存到Map中template div v-foritem in list :keyitem.id :refel setItemRef(el, item.id) clickhandleClick(item) {{ item.name }} /div /template script setup const refMap new Map() const setItemRef (el, id) { if (el) { refMap.set(id, el) } else { refMap.delete(id) } } const handleClick (item) { const el refMap.get(item.id) if (el) { el.classList.add(active) } } /script用Map的好处是元素和数据项始终通过 id 关联不依赖下标不怕排序。6.3 模板引用配合第三方库时的内存泄漏引入第三方库后组件卸载时经常忘记销毁库实例导致内存泄漏。特别是那些绑定了事件监听器或观察者的库比如 ResizeObserver、IntersectionObserver、自定义事件。推荐的做法是在onBeforeUnmount中统一清理script setup import { onMounted, onBeforeUnmount, ref } from vue const container ref(null) let editor null onMounted(() { editor new Editor(container.value) }) onBeforeUnmount(() { editor?.destroy() editor null }) /script如果你在组件里多次创建销毁这类实例可以在热更新或路由切换时留意浏览器 DevTools 的 Memory 面板看是否有明显的对象数量增长。使用模板引用集成第三方库清理工作不是可选项而是必须项。6.4 组合式函数中复用模板引用逻辑如果你有很多组件都需要“内容变化后滚动到底部”这类的逻辑把它抽成一个组合式函数是很好的工程化选择// useScrollToBottom.js import { ref, onMounted, onBeforeUnmount } from vue export function useScrollToBottom(containerRef) { const scrollToBottom async () { await nextTick() if (containerRef.value) { containerRef.value.scrollTop containerRef.value.scrollHeight } } let observer onMounted(() { observer new MutationObserver(() scrollToBottom()) if (containerRef.value) { observer.observe(containerRef.value, { childList: true, subtree: true }) } }) onBeforeUnmount(() { observer?.disconnect() }) return { scrollToBottom } }组件里这样用script setup import { ref } from vue import { useScrollToBottom } from ./useScrollToBottom const listRef ref(null) const { scrollToBottom } useScrollToBottom(listRef) // 调用 scrollToBottom 即可 /script组合式函数的优势是把onMounted、onBeforeUnmount、观察者管理这些生命周期逻辑封装在一个作用域中组件代码变得非常干净而且多个组件复用同一份逻辑时行为一致性也有保障。7. 模板引用与 Vue 2 迁移的差异对照我在很多项目里做过 Vue2 到 Vue3 的迁移模板引用这块是大家普遍会卡一下的地方。这里梳理一下主要差异。7.1 声明方式的差异Vue2 中// 选项式 API this.$refs.inputEl.focus() // 数组的话 this.$refs.items.forEach(...)Vue3 中script setup const inputEl ref(null) const items ref([]) /script差异在于 Vue3 需要提前声明变量。另外 Vue2 中this.$refs是一个对象所有的引用都挂在上面Vue3 中每个引用是独立的ref不存在统一的$refs集合。这也意味着你失去了“动态获取某个 ref”的能力——你不能写refs[someDynamicName]了因为根本没有这个集合。如果确实需要动态访问可以用函数引用配合对象的 key或者使用模板里的:refsomeVariable动态改变函数名。7.2 组件实例访问的差异Vue2 中this.$refs.child能访问子组件所有属性Vue3 中必须依赖defineExpose。这个变化对老项目影响很大特别是那些深度耦合的父子组件。我在迁移时见过不少案例父组件直接操作子组件的 data 来修改状态在 Vue3 中这些代码全都失效了。建议在迁移时把这种“直接操作子组件内部数据”的代码改成子组件通过defineExpose暴露必要的方法用v-model或:valueupdate做受控通信用provide/inject做跨层级状态共享长远来看这会让组件边界更明确也更容易维护。7.3 生命周期时机的差异Vue2 中mounted里访问$refs是安全的Vue3 中onMounted里访问同样安全但 Vue3 的异步渲染机制在某些边界情况下会让nextTick的使用频率变高。特别是当你先在父组件中修改了状态又在watch回调里去拿子组件引用时记得加await nextTick()。Vue3 的watch默认是异步触发的但它触发时可能 DOM 还没更新完毕。所以watch(visible, async (val) { if (val) { await nextTick() childRef.value?.doSomething() } })这个模式非常常见建议养成习惯。8. 实际应用过程中我踩过的几个坑最后分享几个我真实遇到过的案例都是网上文档里写得不多、但实际开发很容易碰到的。第一个是层级嵌套回调问题。在onMounted里访问子组件的模板引用时如果子组件内部又异步渲染了自己的 DOM那么父组件的onMounted并不代表子组件的 DOM 已经完全就绪。你需要子在组件内部自己nextTick后再对外暴露状态或者在父组件中对子组件的引用做轮询等待不推荐但有时候能应急。第二个是我遇到过的一个特殊情况一个元素同时设置了ref和v-for并且这个元素的渲染结果还依赖一个异步数据。结果列表数据还没回来时ref数组是空的数据到达渲染后数组自然有了内容。这时候如果你要在数据到达后立刻操作 DOM必须把操作放在数据变更的watch回调里并且加上nextTick而不是在onMounted里。因为onMounted是组件初始化后触发的此时异步数据大概率还没回来。第三个是关于KeepAlive组件的陷阱。被KeepAlive包裹的组件在切换页面时不会销毁只是失活。这种组件的模板引用在失活时可能依然指向元素也可能因为内部条件渲染变成null取决于你具体怎么写的。如果你在onActivated中需要访问模板引用记得先检查是否为null并且可以主动触发一次 DOM 更新后再读取。第四个坑是关于 SSR服务端渲染。如果你的项目用到vue-server-renderer或Nuxt模板引用在服务端渲染环境下根本不会有值因为这些代码只在浏览器运行。如果你在setup顶层写了访问ref.value的逻辑在服务端会直接报错。这种情况下要加环境判断只在客户端执行if (typeof window ! undefined) { // 访问模板引用的代码 }或者在onMounted里访问因为服务端不会执行onMounted。模板引用看起来只是一个小 API但用好了能解决很多开发中的实际问题用不好也会埋下不少隐蔽的 bug。我的个人习惯是能用数据驱动解决的就尽量不碰指令式操作但真到了非碰不可的场景一定要弄清楚当前是哪个生命周期、元素是否已渲染、组件实例是否暴露了需要的 API。理清这几个问题模板引用用起来就会顺手很多。
返回列表