在 Vue 3 项目里ref 绑定元素这件事十个人里有九个都只在模板里写refxxx然后在script setup里用同名的 ref 接住。但真实业务不会一直这么规整。我最近就接了个需求后台配置结构返回一串动态字段前端要渲染成校验表单提交时定位到第一个非法输入框。模板里根本没有办法提前写好refinput-1这种名字必须用 JS 在运行时决定 ref 绑到谁身上、以及绑定后怎么取用。折腾完这个需求我把 Vue 3 里获取元素 动态绑定 ref 对象的几种做法、底层机制和踩坑过程都捋清楚了这篇就当是复盘记录给遇到同样问题的朋友做个参考。1. 先说我遇到的那个具体场景动态表单校验1.1 需求长什么样后台返回的配置大概是这样的结构{ fields: [ { id: name, label: 姓名, type: input, required: true }, { id: age, label: 年龄, type: number, required: true }, { id: remark, label: 备注, type: textarea, required: false } ] }前端要把这些字段渲染成表单用户点击提交时做两件事逐个校验必填项把校验失败的字段标红。找到第一个校验失败的输入框滚动到可视区域并聚焦。第二件事看起来简单但定位到那个输入框才是关键。字段数量、字段顺序、字段类型全是运行时才知道的模板里不可能写成input refnameInput / input refageInput /因为name、age这些 id 是动态数据不是代码里写死的。所以这里就变成了典型的通过 JS 获取元素后再给元素绑定/管理 ref 对象的需求。1.2 第一版方案为什么很别扭当时组里有同事第一反应是走原生 DOM 路线在模板里给输入框加一个:data-field-idfield.id然后提交时用const firstInvalidEl document.querySelector( .form-field.invalid input )粗看能跑但用下来有三个很别扭的问题类名和属性名是运行时拼出来的。比如标红样式类.invalid一旦和其他组件样式冲突querySelector 选中的可能是完全无关的节点排查起来非常痛苦。静态快照问题。document.querySelectorAll返回的 NodeList 是静态的。如果校验过程中列表发生了重新渲染旧的 NodeList 里可能还躺着已经被销毁的 DOM 节点操作它们容易报 Cannot read properties of null。完全绕开了 Vue 的响应式体系。ref 对象拿到的元素在组件状态变化时会由 Vue 管理而手动 querySelector 出来的节点Vue 不知道你在用它更新、销毁时不会做任何通知。更关键的是后来我们想给校验失败的字段加一个状态统一管理DOM 元素引用最好能和响应式数据放一块querySelector 的方式根本做不到这一点。所以必须回到 Vue 的 ref 机制上来。2. 模板 ref 的真正工作机制理解了就不会乱绑定2.1 编译后的代码长啥样很多同学以为refxxx是给 DOM 元素加了一个叫ref的属性所以在 JS 里拿到元素后el.setAttribute(ref, xxx)或者el.ref myRef以为这样就能绑定。这是对 Vue ref 机制最大的误解。Vue 模板里的 ref 不是 DOM 原生属性而是 Vue 编译器识别的一个特殊标记。SFC 编译器在编译模板时会把 ref 属性的处理逻辑编译成 vnode 创建和 patch 时的一个钩子。简化一下大概是这样的概念// 编译后的 render 函数概念示意 return createVNode(input, { ref: ageInput, onInput: handleInput })Vue 在挂载或更新 vnode 时会检查这个ref字段根据它的值类型走不同的逻辑如果值是字符串就从 setup 上下文中找到同名的 ref 对象把对应的 DOM 元素或组件实例塞进去。如果值是函数直接调用这个函数参数是元素或组件实例。如果值是响应式 ref 对象就更新.value。卸载的时候Vue 会再处理一次把 ref 置为null或者以null为参数调用函数。所以ref 绑定是 Vue 在渲染周期内做的一步接线工作它发生在组件渲染过程中而不是 DOM 渲染完成之后。你手动给 DOM 元素设置ref属性Vue 根本不会感知到因为渲染流程已经走完了。2.2 静态绑定和动态绑定的本质区别静态绑定的写法template input refnameInput / /template script setup import { ref } from vue const nameInput ref(null) /script这里refnameInput是一个字符串编译器会把这个字符串名字嵌入到 vnode 里Vue 运行时再通过这个名字去 setup 上下文里找nameInput。动态绑定的写法最常见的两种template input :ref(el) setRef(name, el) / input :refel (refs[age] el) / /template区别在于:ref传进去的不再是一个写死的字符串而是一个运行时计算出来的值。这个值可以是个字符串 key也可以是个函数。无论哪种Vue 只负责每当这个 vnode 挂载/更新/卸载时把元素或 null 作为参数传给你给定的处理逻辑至于你怎么存、存到哪由 JS 侧代码决定。理清了这一层标题里说的通过 JS 获取元素后给元素绑定 ref 对象就清晰了你不需要也不可能在元素身上创建一个 ref 属性而是要在渲染期间用 JS 提供一个处理函数/存储位置让 Vue 把元素交到某个 ref 对象手里。3. 三种把 ref 绑到 JS 手里的正确打开方式基于上面这套机制我在项目里实际验证了三种写法分别适合不同场景。3.1 函数式 ref最通用的写法这是我最推荐的一种适应性最好。核心思路是利用:ref可以接收函数这个特性在渲染期间把元素存入自定义容器。template div v-forfield in formConfig.fields :keyfield.id classform-item label{{ field.label }}/label input :typefield.type :ref(el) collectInput(field.id, el) classform-control / /div /template script setup import { reactive } from vue const formConfig reactive({ fields: [ { id: name, label: 姓名, type: text }, { id: age, label: 年龄, type: number } ] }) // 用一个 Map 作为 ref 对象的容器key 是字段 id const inputRefMap new Map() function collectInput(id, el) { if (el) { inputRefMap.set(id, el) } else { inputRefMap.delete(id) } } // 提交校验时定位第一个非法输入框 function scrollToFirstInvalid() { for (const field of formConfig.fields) { const el inputRefMap.get(field.id) if (el !el.value) { el.scrollIntoView({ behavior: smooth, block: center }) el.focus() return true } } return false } /script关键点在于collectInput函数的两个参数分支el有值时说明元素刚挂载或者重新渲染完成把它存进 Map。el为null时说明元素即将被卸载必须从 Map 里删掉否则 Map 里会残留无效的 DOM 引用后续聚焦时可能报错或者聚焦到一个看不见的幽灵节点。这种写法不需要依赖任何新 APIVue 3.0 到最新版都能用而且可以直接在函数里写复杂的判断逻辑。3.2 动态 key 绑定到响应式对象如果你习惯把所有引用信息放在响应式对象里也可以这样写template input v-forfield in fields :keyfield.id :refform-input-${field.id} / /template script setup import { ref, reactive } from vue const fields ref([...]) // 用一个响应式对象统一管理 const elementRefs reactive({}) function getRefName(fieldId) { return form-input-${fieldId} } /script这种写法的麻烦之处在于动态 key 是字符串模板拼出来的ref字符串本质上会被 Vue 当成去 setup 上下文找同名变量来处理。如果你在模板里写:refform-input- field.id但 setup 里并没有定义form-input-xxx这个 ref 变量那 Vue 大概率只会把它当成普通的字符串处理无法自动存进你的elementRefs。所以字符串动态 key 和自动挂到 ref 变量这种便利是绑定的要真想用响应式对象统一管理还是得回到函数式 reftemplate input v-forfield in fields :keyfield.id :ref(el) { if (el) elementRefs[field.id] el } / /template script setup import { reactive } from vue const elementRefs reactive({}) /script这样elementRefs.name就是一个 DOM 元素并且因为是 reactive 对象其他组件逻辑里 watch 它也能生效。3.3 官方新方案useTemplateRefVue 3.5Vue 3.5 引入了useTemplateRef专门解决动态 ref 绑定的痛点。它的用法是传入一个字符串 key返回一个 ref 对象script setup import { useTemplateRef, nextTick } from vue const inputRefs useTemplateRef(inputs) /script template input :refinputs / /template不过这个 API 在动态 key 场景下有个更灵活的玩法可以把 key 本身做成响应式script setup import { useTemplateRef, ref } from vue const currentId ref(name) const currentInput useTemplateRef(input- currentId.value) /script template input :refinput- currentId / /template需要注意的是useTemplateRef要求在模板里渲染的 ref 名和创建时传入的 key 保持一致。如果你的场景需要同时收集一堆动态元素函数式 ref 的表达能力更强useTemplateRef 更适合按需取单个元素引用。3.4 三种写法的对比写法Vue 版本要求适用场景能处理卸载函数式 ref Map3.0动态列表、批量收集元素能需要自己判断el null函数式 ref reactive 对象3.0需要响应式追踪元素引用能赋值/删除由你控制useTemplateRef 动态 key3.5按 key 取单个元素引用能框架自动处理如果项目还是 3.4 及以下但又想用这种写法函数式 ref 是唯一稳妥的选择。我们线上项目是 3.4所以最后统一用的函数式 ref。4. 用 JS 拿到元素后还有几个绕不开的时序坑4.1 ref 的值什么时候才真正存在有次我在一个watch回调里试图立即访问刚绑定的 ref结果打了半天全是null。排查后确认是时序问题Vue 在组件更新前后ref 的赋值时机和 watch 回调默认时机不一致。在script setup的同步代码区域比如setup函数体顶层直接访问 ref值一定是null因为此时组件还没有挂载DOM 元素根本不存在。你至少要等到onMountedimport { ref, onMounted } from vue const nameInput ref(null) onMounted(() { console.log(nameInput.value) // 这里才能拿到元素 })如果用 watch 监听某个数据变化然后基于变化后的列表去拿新的 ref必须把 flush 设为postwatch( () formConfig.fields, (newFields) { // 此时 DOM 更新完ref 才重新赋值完成 newFields.forEach((field) { const el inputRefMap.get(field.id) if (el) { // 做聚焦、校验等操作 } }) }, { flush: post } )默认的flush: pre会在组件更新之前触发 watch 回调这时候inputRefMap里存的还是上一个版本的 DOM 节点很容易踩空。4.2 卸载时的 null 回调不能省函数式 ref 在元素卸载时会以null为参数再调用一次。如果处理函数只做了存元素function collectInput(id, el) { inputRefMap.set(id, el) }那么列表删除一项后Map 里会永远保留一个已经卸载的 DOM 节点。后续校验时拿到这个节点调用el.focus()可能没反应调用el.scrollIntoView()在部分浏览器里直接抛异常。所以一定要补上删除分支function collectInput(id, el) { if (el) { inputRefMap.set(id, el) } else { inputRefMap.delete(id) } }这个else分支不是可有可无它决定了你的引用容器能不能正确反映当前页面真实存在哪些元素。4.3 不要在函数式 ref 里做重操作函数式 ref 每次渲染更新都会执行。列表项从 3 个变成 2 个或者某个字段 label 变了都会触发一次重新渲染更新阶段 Vue 会先以旧元素调用一次此时可能是 null也可能还是旧元素再以新元素调用一次。如果你在这个函数里触发了响应式写入比如function collectInput(id, el) { if (el) { inputRefMap.set(id, el) // 错误示范写响应式数据可能导致无限循环 formConfig.fields.forEach((f) (f.touched true)) } }那formConfig一旦变化又会触发重新渲染重新渲染又调用collectInput又触发formConfig变化……直接栈溢出。函数式 ref 只应该做存取元素这种轻量操作其他副作用一律丢到nextTick或watch里。5. 组件 ref 和 DOM ref 的动态绑定要分开想5.1 动态绑定子组件实例标题里虽然讲的是获取元素但实际业务里动态表单不会只有原生 input。我的需求里还有自定义封装的金额输入组件、日期选择组件这些是子组件不是 DOM 元素ref 拿到的是组件实例不是 DOM 节点。动态绑定的写法其实一样template CustomInput v-forfield in formConfig.fields :keyfield.id :ref(instance) collectComponent(field.id, instance) / /template script setup const componentRefMap new Map() function collectComponent(id, instance) { if (instance) { componentRefMap.set(id, instance) } else { componentRefMap.delete(id) } } /script拿到的是子组件暴露的公开实例能否调用它的方法取决于子组件有没有defineExpose。5.2 配合 defineExpose 暴露方法比如封装的自定义输入组件内部结构是外层 div 包着一个 input!-- CustomInput.vue -- template div classcustom-input input refinnerInput / /div /template script setup import { ref } from vue const innerInput ref(null) function focusInner() { innerInput.value?.focus() } // 关键不 expose 的话父组件拿到的实例上是没有 focusInner 的 defineExpose({ focusInner }) /script父组件动态绑定后function scrollAndFocusFirstInvalid() { const comp componentRefMap.get(amount) // comp 是 CustomInput 暴露的实例 comp?.focusInner() }这里有个细节绑定子组件时函数式 ref 的入参是组件公开实例和 DOM 元素一样卸载时也会传null。所以collectComponent同样要处理删除分支。5.3 动态嵌套结构的组件引用管理如果动态表单里还有字段组的概念一组字段包在一个FieldGroup组件里组件内部又有若干子元素我的做法是组件的 ref 收集在父级的 Map组件内部的元素引用由组件自己管父子各管一层不要全部堆到父级。!-- FieldGroup.vue -- script setup const innerInputMap new Map() function collectInner(id, el) { if (el) innerInputMap.set(id, el) else innerInputMap.delete(id) } function focusByFieldId(id) { innerInputMap.get(id)?.focus() } defineExpose({ focusByFieldId }) /script template div input v-forf in groupFields :keyf.id :ref(el) collectInner(f.id, el) / /div /template父组件只需要拿FieldGroup实例调用focusByFieldId即可。这样 ref 的职责边界清晰不会出现一个超大 Map 里混着各层元素的乱象。6. 我的建议什么样的场景值得用这套方案经历了这个需求我总结了一下不是所有获取元素的场景都值得上动态 ref。值得用动态 ref 的场景动态配置渲染的表单/列表元素 id 来自运行时数据。自定义渲染器、拖拽平台、低代码画布这类节点频繁增删的应用。需要在响应式系统内统一管理元素是否存在的状态的场景。不建议用动态 ref 的场景只有一两个固定元素直接refxxx简单可靠没必要绕。纯粹读取一次元素坐标、宽度完成后不需要维护引用用document.querySelector反而更省事。元素引用要传给非 Vue 代码比如第三方原生库静态 querySelector 更直观。真要动手我个人的实操建议是优先函数式 ref Map并且从一开始就写好el null的清理分支不要等项目跑起来出现幽灵引用再补。函数式 ref 虽然写起来比字符串 ref 多几行字但它把元素何时进来、何时离开这两个生命周期都交到你手里可控性是最好的。另外如果团队升级到 Vue 3.5可以尝试用useTemplateRef配合动态 key官方 API 在语义上更清晰。但要注意模板里 key 字符串和useTemplateRef参数必须严格匹配大小写、连字符都不要有偏差这个我在本地试过几次写错后拿到的 ref 一直是undefined排查起来比较隐蔽。最后说个可能不太起眼但很实用的点在函数式 ref 里存元素时Map 的 key 不要直接用数组 index要用业务唯一 id。我们最初用 index 做 key结果列表做插删排序后index 对应的元素全错位了校验定位到完全错误的输入框。换成字段 id 之后一切正常。这就是动态绑定 ref 最该守住的底线——你要绑定的是某个逻辑上稳定的实体而不是某个临时出现的位置。