ARTICLE DETAIL

资讯详情

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

Vue3先获取元素再绑定ref:动态DOM绑定与函数式ref实战

Vue3先获取元素再绑定ref:动态DOM绑定与函数式ref实战 1. 为什么要在 Vue3 里折腾“先获取元素再绑定 ref”1.1 很多人在写 Vue3 时对 ref 的理解只停留在模板的 refxxxVue3 的 ref 是个很有意思的东西。刚入门的时候大家通常会在模板里写div refmyDiv/div然后在script setup里声明const myDiv ref(null)等组件挂载完毕myDiv.value就能拿到那个 DOM 元素。这是最常见的用法也确实能解决大部分需求。但实际项目里常常会遇到更复杂的场景某个元素是后端接口返回数据后动态渲染出来的或者列表是v-for循环生成的甚至有些元素压根不在模板里而是你在 JS 里手动document.createElement建出来的。这时候如果你还想用一个统一的ref对象去管理它就需要动点脑筋了。我见过不少同事遇到这类需求时的第一反应是直接用document.getElementById或document.querySelector去查元素查到了就操作完全不管 ref。这个思路没有问题但会带来两个隐患第一脱离 Vue 的响应式体系之后元素的变化时机和组件的生命周期容易出现错位。第二如果同一页面里有多个组件实例用 id 或全局选择器去查很容易互相污染。所以“通过 JS 获取元素后再给元素绑定一个 ref 对象”这个思路的核心价值不是绕路而是让你既能享受原生 DOM API 的灵活性又能把拿到的元素放进 Vue 的响应式体系里统一管理。说白了就是土办法和正规军结合。1.2 绑定 ref 对象绑定的是什么需要先厘清一个概念Vue3 里的ref函数会返回一个带有.value属性的响应式对象。当你把它放到模板的ref属性上时Vue 在渲染完成后会把对应的 DOM 元素或组件实例赋值给这个.value。而“通过 JS 获取元素后给元素绑定 ref 对象”这句话可以拆成两个动作用原生 JS API比如document.querySelector、getElementById、createElement拿到一个真实 DOM 元素。把这个 DOM 元素赋值给一个已声明的ref对象的.value属性。比如下面这段代码const myBox ref(null) function onMounted() { const el document.querySelector(.box) myBox.value el }之后myBox.value就指向这个.box元素。你可以在逻辑代码里操作它也可以把它传给子组件甚至放在reactive对象里。它依然是一个响应式数据虽然 DOM 本身不是响应式的但这个 ref 对象能帮你追踪“元素是否已经赋值”这一状态。很多人误以为只有模板的refxxx才会触发绑定其实手动赋值也完全可以。Vue 自身在渲染 vnode 时做的绑定本质也是把 DOM 元素塞给对应的响应式 ref和你的手动赋值没有区别只是时机不同。1.3 什么业务场景下真的需要这么做举几个我真实碰到过的场景你看看自己有没有同款需求图表库初始化用 ECharts 或 Three.js 时需要一个容器 DOM。通常我们会在模板里写好div refchartContainer/div但如果这个容器是从接口数据生成的或者是在动态插槽里创建的就得在拿到 DOM 之后再手动关联 ref。富文本编辑器有些编辑器初始化时只接受一个 DOM 节点参数而这个节点可能是动态插入的。虚拟滚动列表每个列表项高度不定需要拿到每一项的真实高度来动态计算滚动位置。这个时候每个列表项都需要一个 ref但模板里一个个写显然不现实。原生 Canvas 绘制Canvas 的上下文对象绕不开 DOM如果多个 Canvas 由动态数据生成那用统一 map 记录再合适不过。这些场景的共同特点是元素不是模板静态写死的而是运行时生成的。这时候再把绑定交给模板的静态 ref就不太顺手了。2. 基础操作先获取元素再回填 ref.value2.1 从模板 ref 到 JS 二次获取我们先从最稳妥的路径开始。这种方式适用于元素本来就在模板里但你需要在 JS 中通过其它方式再确认一下、或者拿到元素后做一些别的绑定。假设模板里有这样一个结构template div classcontainer div classmain-box refmainBox/div /div /template正常情况下你直接用mainBox这个 ref 就能拿到.main-box。但如果某个第三方库要求传入元素的id而你又不想给模板里的元素加太多属性你可以这样做import { ref, onMounted, nextTick } from vue const mainBox ref(null) onMounted(() { // 模板 ref 已经自动赋值 console.log(mainBox.value) // 如果不想依赖 ref 属性也可以先用 class 获取 const el document.querySelector(.main-box) if (el) { // 再将获取到的元素回填给 ref 对象 mainBox.value el } })这看起来多此一举但其实有实际价值你可以通过 JS 把元素重新赋值给 ref以便在一个统一的变量里继续操作。比如在一个组合式函数composable里外界传进来的可能是ref对象也可能是 DOM 选择器字符串你想兼容两种用法这时候“先获取再回填”就非常好用。function useBindElement(targetRef, selector) { const el document.querySelector(selector) targetRef.value el }这样做的好处是后续所有逻辑只需要关心mainBox.value是否存在不需要反复去 query。2.2 什么时候赋值才靠谱这里必须点名一个坑如果你在setup里直接做document.querySelector十有八九会拿到null。因为setup执行的时候组件还没挂载DOM 还没渲染出来。所以“先获取元素”必须发生在合适的生命周期onMounted之后此时组件挂载完成模板中的 DOM 已被 Vue 渲染到页面。nextTick回调里如果在onMounted前修改了数据需要确保 DOM 更新后再获取元素。事件回调里用户点击、滚动等事件触发时元素基本已经存在但要注意事件可能是通过事件委托绑定的目标元素可能动态变化。有一个更保险的写法用一个监听函数封装function getElementWhenReady(selector, callback) { const check () { const el document.querySelector(selector) if (el) { callback(el) return true } return false } if (!check()) { // 监听 DOM 变化如果目标元素出现就执行 const observer new MutationObserver(() { if (check()) { observer.disconnect() } }) observer.observe(document.body, { childList: true, subtree: true }) } }这样即便元素是异步渲染出来的也能通过监听 DOM 变化捕获到。不过实际情况中Vue 的响应式系统已经能帮我们处理大部分时机问题用watchnextTick通常更优雅。2.3 不要随便覆盖已有 ref这里要特别提醒如果模板里已经写了refmainBox你在onMounted里手动把另一个元素赋值给mainBox.value原来的绑定就被覆盖了。这不是报错但会让你困惑。所以有两种做法二选一模板用ref绑定不要额外手动覆盖直接使用 Vue 帮你赋好的值。模板不写ref用document.querySelector获取后手动赋值。两者混用不是不行但要非常清楚自己在干什么。我一般建议静态元素让模板绑定动态元素单独用一个Map或Object存起来各管各的不要混在同一个 ref 上。3. 进阶玩法动态列表、v-for 元素批量绑定 ref3.1 用“函数式 ref”替代字符串 ref模板里的ref属性除了能写字符串还能写函数。这是处理动态元素绑定的关键手段。比如列表里的每一项你要拿到每个 item 的 DOM 节点不能写refitemRef因为这样所有项都指向同一个名字最后只会保留最后一个。你应该这样写div v-foritem in items :keyitem.id :refel setItemRef(el, item.id) classitem-card /div在 JS 里维护一个Mapconst itemRefs new Map() function setItemRef(el, id) { if (el) { itemRefs.set(id, el) } else { itemRefs.delete(id) } }仔细看一下这个setItemRef它会在元素挂载时收到 el卸载时收到 null。这个设计是 Vue 特意为动态列表准备的。通过函数式 ref你可以在元素出现时把它放进 Map 里元素销毁时再从 Map 里移除非常省心。3.2 动态创建的元素怎么绑定 ref如果元素不是模板渲染而是你手动创建出来的比如const div document.createElement(div) div.className dynamic-item div.textContent 我是动态创建的你同样可以把它绑定到一个 refconst dynamicRef ref(null) const div document.createElement(div) div.className dynamic-item dynamicRef.value div document.body.appendChild(div)这样dynamicRef.value就是那个 div。注意ref不影响元素是否被插入到 DOM它只是一个变量引用。你仍然需要手动appendChild。如果你不想维护全局变量也可以把动态元素塞进一个Map里等要操作的时候再取出来。这种做法的好处是统一生命周期当组件卸载时你可以遍历 Map 里的所有动态元素逐一移除或销毁资源。3.3 动态 ref 绑定配合响应式数据你可能会想如果元素是响应式数据生成那 ref 是否会自动更新答案是Vue 的 DOM 更新是异步的但 ref 函数会在元素挂载或卸载时同步调用。所以只要你的列表数据是响应式的DOM 一旦变化setItemRef就会被触发Map 里的内容也会跟着变化。这就是函数式 ref 比手动查询更可靠的原因。不过要注意如果你在同一个函数里即读取itemRefs又修改列表可能会遇到 Map 还没来得及更新的情况。这时候需要用nextTick保证 DOM 更新完成后再访问。比如import { nextTick } from vue async function scrollToItem(id) { await nextTick() const el itemRefs.get(id) if (el) { el.scrollIntoView({ behavior: smooth }) } }大多数情况下这样的顺序是稳定的先改数据再等 DOM 更新再取 ref 做操作。4. 常见问题与排查实录4.1 为什么 ref.value 还是 null这恐怕是所有 Vue3 开发者初期最常遇到的情况。明明模板里写了refxxx为什么在onMounted里取到的还是 null排查步骤看ref是否声明了同名变量。script setup必须const xxx ref(null)名字一致才能绑定。看元素是否被v-if控制。如果元素在v-iffalse状态下DOM 根本不存在ref 自然是 null。看是否在setup里直接读取。setup执行时还没有 DOM。看是否在onMounted之后又被覆盖。如果使用的是组合式函数还需要确认暴露回来的 ref 是不是同一个对象。有些封装喜欢返回一个新的 ref但模板绑定的是原来的那就对不上号了。4.2 v-for 里使用字符串 ref 的坑在 Vue2 时代v-for中使用refxxx会得到一个由所有子元素组成的数组。在 Vue3 里这种行为不再默认支持。如果你仍然在 v-for 的元素上写字符串 ref最终只会保留绑定到最后一项的单个元素而且官方并不推荐。正确做法就是前面说的函数式 ref。但函数式 ref 也有一个隐蔽的坑如果你直接写成:ref(el) itemRefs.set(item.id, el)这个箭头函数每次渲染都会重新创建Vue 会在更新时先调用旧函数传 null再调用新函数传元素。这样会导致 Map 中出现瞬时的空档。虽然大多数业务逻辑感知不到但如果你在watch里深度监听 Map 变化可能会看到一些异常。更稳定一点的做法是定义一个固定的具名函数确保 Vue 每次调用同一个引用function setItemRef(el, id) { if (el) { itemRefs.set(id, el) } else { itemRefs.delete(id) } }模板里写:ref(el) setItemRef(el, item.id)这也是 OK 的。因为固定的setItemRef是一样的外层的箭头函数只是传参封装Vue 依然能正确调用卸载回调。4.3 响应式数据更新后ref 指向的还是旧元素动态渲染的场景里比如点击按钮后给列表头部插入一条数据此时旧的数据项 DOM 被复用还是重建取决于key。如果你没有设置合理的:keyVue 会尽量复用 DOM这会导致你 Map 里存的元素虽然存在但内容已经变了。解决办法很简单给每一个需要绑定 ref 的项设置稳定的 key最好使用业务 id而不是 index。使用 index 时插入数据会让后面所有项的 DOM 被复用Ref 虽然还在但对应的内容和你的数据对不上号了。如果遇到“数据变了但保存的 DOM 高度没变”这类问题优先检查 key 配置。4.4 常见问题排查速查表问题现象常见原因解决方案ref.value 始终为 null在 setup 中直接获取移到 onMounted 回调ref.value 为 null 且无报错元素被 v-if 控制未渲染先确保条件成立v-for 中只能拿到最后一个元素使用了字符串 ref改为函数式 ref动态创建的元素绑定后无法操作忘了 appendChild手动插入 DOM更新后拿到的元素是旧的key 使用不当或 DOM 复用设置稳定 key组件卸载后 Map 里还有元素没有清理卸载回调在 ref 函数中删除对应值这个表可以给你快速定位问题。真正干活的时候95% 的 “ref 没绑定上” 都能从前三行找到答案。5. 实战案例动态卡片列表高度测量5.1 需求描述假设我在做一个资讯信息流页面每个卡片高度不一定用户点击“加载更多”后会在列表尾部插入新卡片。我需要统计每张卡片的高度用于计算整个列表的最大高度或实现锚点定位。这个需求如果用document.querySelectorAll去查每次插入数据后都要重新查询而且容易拿到不属于当前组件的元素。更好的方式是通过函数式 ref 把所有卡片元素记录到 Map 里。5.2 完整实现代码template div classfeed-list div v-foritem in feedList :keyitem.id :ref(el) setCardRef(el, item.id) classfeed-card h3{{ item.title }}/h3 p{{ item.content }}/p /div button clickloadMore加载更多/button /div /template script setup import { ref, nextTick } from vue const feedList ref([ { id: 1, title: 第一条, content: 内容略长高度不定 }, { id: 2, title: 第二条, content: 内容短 }, ]) const cardRefs new Map() function setCardRef(el, id) { if (el) { cardRefs.set(id, el) } else { cardRefs.delete(id) } } async function loadMore() { feedList.value.push({ id: Date.now(), title: 新卡片, content: 新增内容高度不同, }) await nextTick() // 现在可以在 map 里拿到最新插入的元素 const lastCard [...cardRefs.values()].pop() if (lastCard) { console.log(最新卡片高度, lastCard.offsetHeight) } } /script这段代码已经能满足大多数动态高度的需求。关键在于setCardRef函数被 Vue 自动回调不依赖手动查询 DOM也不会因为列表变化而漏掉元素。5.3 这条实现路径的原理拆解这里有一个值得展开的点为什么在loadMore里await nextTick()之后cardRefs里一定会有最新的元素原因是 Vue 的更新机制。当你修改feedList.value后DOM 更新会被排队不会立即执行这也是为了性能考虑。而nextTick返回一个 Promise它会在 DOM 更新完成后 resolve。所以你在 await 之后去读cardRefsVue 已经完成了 DOM diff 和挂载新的卡片元素已经出现对应的setCardRef也被调用了。实际上函数式 ref 的调用发生在 DOM 提交阶段比onMounted更贴近渲染过程。当我们不需要引入一个额外的 watch 时用 await nextTick 是最直接的同步方式。对于高度测量这种场景还需要注意图片加载。如果卡片里有图片offsetHeight在图片未加载完时可能不准确需要结合onload事件再计算一次。这也是一个容易遗漏的细节。最简单的处理是给图片绑定load然后在这个事件里重新读取高度。5.4 实测中的三个避坑点先说第一个坑如果列表项用了v-show而不是v-if元素其实一直都在 DOM 里ref 也会一直存在但它可能是隐藏的offsetHeight会拿到 0。这时候不要用 hidden 状态去测量高度要先让元素可见或者用getBoundingClientRect()获取完整尺寸信息隐藏元素的 rect 也基本不可用。第二个坑在setCardRef里直接读取高度可能拿到的是旧高度。因为 Vue 做 DOM 更新时ref 函数的调用时机不一定精确到布局计算完成。如果你在 ref 回调里同步读取的话建议包一层requestAnimationFramefunction setCardRef(el, id) { if (el) { requestAnimationFrame(() { if (document.body.contains(el)) { cardRefs.set(id, el) } }) } else { cardRefs.delete(id) } }第三个坑多个组件实例共用全局的 Map 会串数据。最好把 Map 放进组件实例内部或者用reactive包装一层确保每个组件有自己的数据。const state reactive({ cardRefs: new Map(), })虽然reactive包装 Map 后Map 内部的增删改是否触发响应式在不同版本里表现有差异但你在组合式函数里用它做存储容器配合 set/delete 方法实测没有问题。如果对响应式要求不高直接声明一个普通 Map 放在setup里也行。5.5 更进一步把 ref 绑定包装成自定义指令如果你发现项目中到处都要做“动态获取元素并绑定 ref”可以考虑封装一个自定义指令。Vue3 的自定义指令同样可以拿到元素和 binding本质上和函数式 ref 是同一套机制。// directive.js export const collectRefs { mounted(el, binding) { const { value } binding value.set(el.dataset.id, el) }, unmounted(el, binding) { const { value } binding value.delete(el.dataset.id) }, }模板里这样用div v-foritem in items :keyitem.id v-collect-refscardRefs :data-iditem.id /div这样的好处是逻辑收敛badge 上不需要写重复的script函数。当然对于简单场景还是用函数式 ref 更直观自定义指令适合需要跨组件复用的场景。6. 几个跨界场景JSX、uni-app 里的 ref 绑定6.1 在 Vue3 JSX 中动态绑定 refVue3 本身支持 JSX很多后台管理项目里会用 JSX 渲染表格列或动态表单。在 JSX 中绑定 ref 的写法和模板略有不同const itemRefs new Map() function setRef(el, id) { if (el) itemRefs.set(id, el) else itemRefs.delete(id) } const renderItem (item) ( div key{item.id} ref{(el) setRef(el, item.id)} {item.name} /div )JSX 中的ref接收函数跟模板中的函数式 ref 行为一致。如果你在 JSX 中写了字符串 ref它同样只会保留最后一个所以尽量都用函数式。6.2 在 uni-app 中处理元素绑定uni-app 里很多 API 是跨端的比如uni.createSelectorQuery().select(.class)。这种场景下“获取元素”和 DOM 的 API 不同但思路相通先拿到元素再赋值给 ref 或者存入 Map。import { ref, nextTick } from vue const elementRef ref(null) async function getCanvasInfo() { await nextTick() uni.createSelectorQuery() .select(.paint-canvas) .fields({ node: true, size: true }) .exec((res) { if (res[0]) { elementRef.value res[0].node } }) }这里的本质是一样的利用nextTick等待渲染完成再通过平台提供的查询 API 获取元素最后塞给 ref。只是平台 API 不同核心思想完全一致。6.3 如果必须要用 document.getElementById有些老项目里的第三方库只认文档里的 id。这种情况下你没法要求它读 ref那你可以这样做在拿到元素后给元素补一个 id等第三方库初始化完再在卸载时移除 id。const chartBox ref(null) onMounted(() { const el chartBox.value if (!el) return el.id chart-${Date.now()} myLib.init(el.id) }) onBeforeUnmount(() { const el chartBox.value if (el) { el.removeAttribute(id) } })这是比较保守的兼容方案。优先用 Vue 的数据和绑定方式只有在接入老库时再考虑这种操作。7. 关于“先获取元素再绑定 ref”这件事我的个人体会做前端这几年我越来越觉得 Vue 的 ref 本质上就是“变量引用”。它不神奇只是帮你在合适的时机把 DOM 放到一个普通变量里。理解了这一点就能明白不管是模板绑定、JS 二次获取还是函数式 ref核心逻辑完全一致。如果你现在新写组件我建议优先用模板 ref 和函数式 ref这两种方式是“响应式正确”的做法不容易出时序问题。document.querySelector也不是不能用但尽量避免在setup里直接跑实在要跑就放在onMounted或用户事件回调里。另外一个小技巧如果你要在同一个组件里维护很多个动态元素不要零散地声明十几个 ref把它们放到一个Map里用数据 id 作为 key。这样不管是遍历、查询还是清理都非常顺手。最后再分享一个我自己写代码时坚持的小习惯任何通过 JS 动态创建的元素一定要在组件卸载的时候考虑清理。简单点说你在setup里addEventListener了就要在onBeforeUnmount里 remove你往document.body里 appendChild 了就要做好移除的准备。ref 帮你存了元素但不会帮你处理生命周期这一步只能靠自己。把这些细节都捋顺之后Vue3 里的元素获取与绑定就会变得很有套路踩坑也会少很多。
返回列表