☰
Vue3中JS获取DOM后再绑定ref的完整指南:从原理到实战
2026/10/5 11:23:39 网站建设 项目流程

先别急着把这个标题当成一个伪需求——Vue3 里ref绑定,谁不知道模板里写ref="box"就行?但真到了业务里,你会遇到这种尴尬:节点是第三方库内部渲染的,模板压根管不到;节点是在v-for里动态生成、数量还不固定的;甚至是你自己用document.createElement动态拼出来的 DOM。这时候你必须在拿到真实DOM元素之后,再反过来和ref对象建立联系。这篇文章把这个过程拆开讲清楚:template ref 到底是怎么工作的,JS 获取元素后再绑定 ref 有哪些写法,动态场景下函数 ref 怎么用,以及我踩过的那些坑。

1. 先把 Vue3 里 ref 这件事彻底弄清楚

1.1 ref 的本质是一个响应式容器,和 DOM 没有天然关系

很多人一看到ref就觉得它是“引用的意思”,以为它天生就是用来挂 DOM 的。其实ref()函数返回的是一个RefImpl实例,它是一个独立的响应式容器,内部通过getter/setter拦截.value的读写,仅此而已。你把一个数字塞进去也行,把一个对象塞进去也行,把一个DOM节点塞进去,当然也行。

import { ref } from 'vue' const num = ref(0) const obj = ref({ name: 'test' }) const dom = ref(null) // 这里未来存的就是一个 DOM 节点

所以“给元素绑定 ref 对象”在机制上就变得很简单:dom.value = 你找到的那个元素。ref不关心容器里装了什么,它只负责让这个值具备响应式访问的能力。明白了这一点,后面所有“JS 获取元素后绑定 ref”的写法你都能理解,因为本质上就是给一个响应式容器赋值的过程。

补充一个细节,ref对对象类型会套一层reactive代理,但对DOM节点这类原生对象,这种深层代理既没意义又有额外开销。所以如果你的 ref 明确是用来存 DOM 的,建议用shallowRef,只保留.value这一层的响应式拦截,不往下深钻,性能和语义都更清晰。

import { shallowRef } from 'vue' const container = shallowRef(null)

1.2 模板里写 ref="xxx" 时,Vue 替你做了什么

搞懂模板 ref 的编译行为,你才知道什么时候“省事”、什么时候“不够用”。以<script setup>为例:

<script setup> import { ref } from 'vue' const box = ref(null) </script> <template> <div ref="box" class="target">目标节点</div> </template>

这段模板会被编译器处理,最终渲染函数的 VNode 上会带一个ref属性。Vue 在组件挂载完成、DOM 已经插入文档之后,会根据ref的值去做匹配:字符串时,在当前组件的setupState里找到同名的ref对象,把对应的 DOM 元素赋给.value。组件卸载时,再把这个值置回null。

因为这套机制的存在,模板 ref 有一个硬性限制:只能命中当前组件模板直接或间接渲染出来的节点。如果某个 DOM 是第三方库在事件回调里临时插入的,或通过 Teleport 传到了body下面,又或者是你用原生 JS 手动拼出来的,模板 ref 就无能为力了。这时候你就要主动用 JS 去获取元素,再手动绑定给 ref 对象。

2. 用 JS 获取元素后手动绑定 ref:经典操作与适用场景

2.1 最直接的写法:querySelector + ref.value 赋值

这是标题描述场景的最直观实现。组件挂载完成后,通过document.querySelector或其变体拿到元素,直接赋给 ref 的.value:

<script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' const panel = shallowRef(null) function bindPanel() { const el = document.querySelector('#remote-panel') if (el) { panel.value = el // 绑定后的 DOM 可以放心操作了 el.addEventListener('click', handleClick) } } function handleClick() { console.log('remote panel clicked') } onMounted(() => { bindPanel() }) onBeforeUnmount(() => { const el = panel.value el?.removeEventListener('click', handleClick) }) </script>

这段代码里的核心就一行:panel.value = el。ref 对象本身是普通的响应式容器,赋值来源是原生querySelector还是模板编译期,结果都一样。注意我用的shallowRef,上面解释过了,存 DOM 用它更合适。

2.2 为什么这种“笨办法”在真实项目中反而常见

很多人觉得模板 ref 才是“正规军”,手动 querySelector 是“野路子”。但实际开发里,后者出现的频率远超想象。我遇到过几个典型场景:

  • 第三方库封装:插件内部创建 DOM,比如富文本编辑器、图表库、拖拽面板,它们渲染出来的节点不在当前组件的模板管辖范围内。你想给这些节点绑定事件、做尺寸测量,就得先querySelector拿到节点,再存进 ref。
  • Teleport 传送内容:组件模板里用<Teleport to="body">渲染的弹窗,虽然源码写在组件里,但真实 DOM 在body下。模板 ref 指向的仍然是 Teleport 的目标节点,如果你需要操作弹窗内部的某些子节点,还是要靠 JS 去定位。
  • 非 Vue 代码的胶水层:老项目迁移,部分模块还是原生 jQuery/原生 JS 维护的 DOM,Vue 组件要把这些旧节点纳管进来,用 JS 获取节点再绑定 ref 是最平滑的接缝。
  • 动态创建的表格节点:热词里提到“js 动态创建的表格合并”,这种需求我做过。后端返回不规则数据,你不得不用原生 table API 拼节点,合并单元格。拼完的表格要暴露给组件用,同样是一行tableRef.value = tableEl的事。

模板 ref 解决的是“模板内的元素引用”,JS 手动绑定解决的是“模板外、运行时才出现的元素引用”。两者不是替代关系,而是互补关系。你早学会这套手动绑定,封装能力会上一个台阶。

3. 动态渲染场景:函数 ref 才是真正的“绑定”

3.1 v-for 循环里没有 ref 字符串怎么办

列表渲染时,如果你给每个循环项写死ref="item",恭喜你,所有项共享一个同名 ref,后渲染的会覆盖先渲染的,最后只剩下最后一个。正确姿势是用函数 ref::ref接收一个函数,这个函数在元素挂载时拿到真实 DOM,卸载时拿到null。

<script setup> import { ref } from 'vue' const tableRows = ref({}) function setRowRef(el) { if (el) { tableRows.value[el.dataset.rowId] = el } } </script> <template> <tr v-for="row in rows" :key="row.id" :data-row-id="row.id" :ref="setRowRef" > <td>{{ row.name }}</td> </tr> </template>

这种写法下,tableRows.value就是一个以row.id为键的 DOM 映射表。你想操作哪一行、想给哪一行的某个单元格加高亮,直接tableRows.value[id]取出来操作即可。等于用函数 ref 把动态 DOM 收集成了一个可索引的集合。

3.2 函数 ref 的更新机制与卸载处理

函数 ref 不是只在挂载时调用一次,它和元素的生命周期绑定得很紧:

  • 元素被创建、插入 DOM 时,调用一次,参数为元素。
  • 元素在v-for中因数据变化被复用、移动时,会按需重新调用。
  • 元素被移除时,调用一次,参数为null。

所以函数 ref 内部的if (el)分支负责“收集”,else分支负责“清理”。写函数 ref 有个好习惯:不要只做赋值,要同时想想该元素被移除时,你的映射表里是不是也得删掉对应项。

function setRowRef(el) { const rows = tableRows.value if (el) { rows[el.dataset.rowId] = el } else { // 此处需要注意:找不到 rowId 时遍历删除,或记录 key } }

实践中更稳妥的做法是结合:key做数据映射,直接缓存整个列表元素数组,比依赖dataset更可靠。因为>import { shallowRef, onBeforeUnmount } from 'vue' const dynamicTable = shallowRef(null) function buildTable(data) { const table = document.createElement('table') const thead = document.createElement('thead') const tbody = document.createElement('tbody') // 表头 const headerRow = document.createElement('tr') Object.keys(data[0]).forEach((key) => { const th = document.createElement('th') th.textContent = key headerRow.appendChild(th) }) thead.appendChild(headerRow) table.appendChild(thead) // 表体 + 单元格合并 data.forEach((row, rowIndex) => { const tr = document.createElement('tr') Object.values(row).forEach((cell, colIndex) => { const td = document.createElement('td') td.textContent = cell if (colIndex === 0 && rowIndex % 2 === 0) { td.rowSpan = 2 // 演示合并单元格 } tr.appendChild(td) }) tbody.appendChild(tr) }) table.appendChild(tbody) const wrap = document.getElementById('table-wrap') wrap?.appendChild(table) // 关键:把 JS 创建的表格绑定到 ref 上 dynamicTable.value = table } onBeforeUnmount(() => { dynamicTable.value?.remove() })

绑定之后,不管是后续做事件委托、样式覆盖,还是在组件销毁时把动态节点清理掉,你都有一个统一的入口。这个模式在后台管理系统的复杂报表、可视化配置器、拖拽表单里非常常用。

4. 组件跨层级的 ref 绑定实践

4.1 让子组件的 DOM 可以被父组件拿到:defineExpose

有时候父组件需要拿到子组件内部的 DOM 节点。常规做法是:父组件在模板里给子组件打ref="childRef",子组件用defineExpose把内部元素暴露出去。

<!-- Child.vue --> <script setup> import { ref } from 'vue' const innerBox = ref(null) const innerHeight = () => innerBox.value?.offsetHeight ?? 0 defineExpose({ innerHeight, getInnerDom: () => innerBox.value }) </script> <template> <div ref="innerBox" class="child-box">子组件内容</div> </template>
<!-- Parent.vue --> <script setup> import { ref, onMounted } from 'vue' import Child from './Child.vue' const childRef = ref(null) onMounted(() => { // 通过暴露出的函数拿到 DOM const dom = childRef.value?.getInnerDom() if (dom) { // 接下来想做什么都行 dom.style.border = '1px solid red' } }) </script> <template> <Child ref="childRef" /> </template>

这里我故意用函数而不是直接暴露innerBox这个 ref 对象,是因为嵌套 ref 的访问链很别扭:childRef.value.innerBox拿到的可能仍然是个 ref 实例,还得再多剥一层.value,而且行为在不同版本里有细微差异。暴露函数最保险,调用即得真实 DOM,没有歧义。

4.2 跨层访问时,JS 查询和 ref 绑定怎么配合

有些场景下,子组件的兄弟组件想拿子组件内部节点,但又没有模板上的 direct 关系。这时候强行用模板 ref 是拿不到的,你只能在事件回调里用 JS 去查询。比如子组件在message事件里通知父组件“我渲染完了”,父组件在回调里执行:

import { shallowRef } from 'vue' const siblingDom = shallowRef(null) function handleChildMessage() { // 子组件已经渲染完成,JS 获取元素后绑定到 ref const target = document.querySelector('.child-box .specific-node') if (target) { siblingDom.value = target } }

这种写法在组件树复杂、又确实不想层层传递 ref 的情况下,能快速破局。但它有两个前提:一是子组件 DOM 必须已经挂载,二是选择器要足够稳定。所以回调里最好先await nextTick()再查询,避免时序问题。

4.3 v-if 切换、异步渲染之后怎么重新绑定

最典型的问题是:节点一开始不存在,等异步数据返回、v-if变为true后 DOM 才出现。如果你在onMounted里绑定,拿到的只能是null。解决办法是用watchEffect配合nextTick动态重绑:

import { shallowRef, watchEffect, nextTick } from 'vue' const asyncPanel = shallowRef(null) const showPanel = ref(false) watchEffect(async () => { if (showPanel.value) { // 此时面板即将渲染,DOM 还没更新 await nextTick() const el = document.querySelector('.async-panel') if (el) { asyncPanel.value = el } } })

watchEffect会在showPanel变化后重新执行,nextTick确保此时 DOM 已经更新。这种“监控状态变化 → 等待渲染 → JS 获取元素 → 绑定 ref”的组合,在异步弹窗、Tab 切换、数据加载后渲染列表中非常常用。比单纯在onMounted里做一次绑定要可靠得多。

5. 常见坑位排查与经验总结

5.1 为什么 ref.value 一拿到就是 null

这一类问题我帮人排查过无数次。先分清楚情况:

  • 模板 ref 在 onMounted 里为 null:大概率是模板里元素被v-if包住了,条件为假时节点不存在。或者你用了<KeepAlive>,组件缓存状态下onMounted只触发一次,元素可能处于非挂载窗口。
  • querySelector 查不到节点:先判断是否在 DOM 更新前执行了查询。watch回调里状态已变但 DOM 还没渲染,必须先nextTick。
  • querySelector 查到了,但 ref 还是 null:检查选择器是不是原生 API 查到的“另一个元素”,比如框架渲染出的不是单个根节点时,你选中的可能是整个根容器下的某个子节点,而模板 ref 指向的是根节点本身,两者不是同一个东西。

排查身份是否一致有个技巧:在绑定位置打印el === querySelector(选择器)的结果,或者打印双方的outerHTML对比。实际问题往往出在“你以为你拿到的是那个元素,实际上拿到了另一个元素”。

5.2 不要把 DOM 直接放进 reactive 里

我在很长时间里犯过这个错误。reactive会对对象做深度代理,你把一个 DOM 节点塞进去,相当于用 Proxy 包了一层原生对象。浏览器在操作 DOM 时,内部会访问大量原生属性,这些访问都被 Proxy 拦截,既慢又容易触发奇怪的行为。

// 不推荐,典型反面教材 const state = reactive({ dom: null }) state.dom = document.querySelector('.target') // 推荐 const dom = shallowRef(null) dom.value = document.querySelector('.target')

这种性能损失在低频操作时看不出来,但如果你在做拖拽、滚动监听这种高频场景,reactive包裹的 DOM 会让事件触发链路上多出无谓的代理开销。规矩很简单:DOM 一律用 ref 或 shallowRef 存,别放进 reactive。这也是 Vue 官方文档里明确提醒过的点。

5.3 卸载时不清引用,内存泄漏悄悄发生

用 JS 手动绑定 ref 的最大隐患是:这个 ref 不再由 Vue 自动管理。模板 ref 组件卸载时 Vue 会置空,但你手动el.value = xxx绑定的节点,没人替你清理。如果这个节点是你自己创建的、挂在document.body下的,组件销毁后它会一直留在页面里。

对应的清理代码必须写在onBeforeUnmount里:

onBeforeUnmount(() => { // 1. 解绑事件 dom.value?.removeEventListener('click', handler) // 2. 从文档流移除 dom.value?.remove() // 3. 清空引用(顺手,让 GC 更快回收) dom.value = null })

事件监听和动态节点移除是双重点,漏一个都可能造成页面卡顿或重复渲染。我这边的经验是:只要手动绑定过 DOM,onBeforeUnmount里必写清理逻辑,不管当下看起来有没有必要。

5.4 一个综合实战:实时日志面板的滚动定位

最后用一个常见需求把所有知识点串起来。后台管理系统的实时日志面板,日志由外部推送后动态插入容器,你要在每次新增日志后自动滚动到底部。

<script setup> import { ref, shallowRef, nextTick, watch } from 'vue' const logContainer = ref(null) // 模板 ref,拿滚动容器 const lastLogItem = shallowRef(null) // JS 获取元素后绑定,拿最后一跳日志 const logs = ref([]) function pushLog(message) { logs.value.push({ id: Date.now(), message }) } watch(logs, async () => { await nextTick() const container = logContainer.value if (!container) return // 通过 JS 获取最后一条日志节点,绑定到 ref lastLogItem.value = container.querySelector('.log-item:last-child') // 滚动定位 requestAnimationFrame(() => { const target = lastLogItem.value if (target) { target.scrollIntoView({ block: 'end', behavior: 'smooth' }) } else { container.scrollTop = container.scrollHeight } }) }) </script> <template> <div ref="logContainer" class="log-container"> <div v-for="log in logs" :key="log.id" class="log-item"> {{ log.message }} </div> </div> </template>

这个例子里,滚动容器是模板 ref,最后一跳日志节点因为是动态渲染、数量不确定,我用 JS 查询获取后绑定到shallowRef。整个操作链路完整覆盖了“template ref 获取容器”和“JS 获取元素后再绑定 ref 对象”两种模式。需要强调的是,requestAnimationFrame在这里比直接操作scrollTop更平滑,而且能避开浏览器渲染时机导致的滚动位置不准问题。

我在实际项目中还会给这种面板加一个“暂停自动滚动”开关,因为用户可能正在往上翻看历史日志。做法是再加一个布尔 ref,滚动前判断用户是否主动滚动到非底部区域。这种细节放到真实业务里都是刚需,不过那又是另一篇文章的话题了。核心思路你抓住就行:ref 就是响应式容器,模板能绑、JS 也能绑,重点是想清楚节点何时存在、何时清理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询