Code前端首页关于Code前端联系我们

Vue3动态ref怎么写才不会踩坑?列表/循环组件/表单元素通用吗?

terry 14小时前 阅读数 361 #Vue

为什么要用动态ref?静态ref不够用吗?

刚开始接触Vue3的同学,可能一开始只会用静态ref——给固定的DOM或者组件起个名字,比如<input ref="usernameInput" />,然后在setup里声明同名的ref变量,直接点.value就能拿到节点实例,但实际开发里,固定的元素/组件占比真的不高:你要渲染3个到N个完全一样的商品卡片组件,每个卡片可能有自己的“加入购物车”弹窗触发按钮;你要做动态表单,用户点“添加一行”就出来一组新的姓名、手机号输入框,还要单独获取每行的表单值或者校验结果;甚至你要做轮播图,需要索引精准拿到每一张slide的DOM节点,这时候静态ref就完全不够用了——你总不能硬编码card1、card2……到一百个吧?动态ref就是为了解决这种多个同类节点/组件需要批量、精准获取实例的场景而生的。

Vue3和Vue2动态ref写法完全不一样?区别在哪?

对,别直接照搬Vue2的经验!这是新手踩的第一个大雷,Vue2里动态ref是通过字符串拼接实现的,比如<div v-for="(item, idx) in list" :ref="'item-' + idx"></div>,然后在代码里通过this.$refs['item-' + idx]或者this.$refs['item-' + idx][0](当只渲染一个同名动态ref时会自动包数组,多个也是数组)来取。

但Vue3彻底重构了响应式系统和ref的绑定逻辑,字符串拼接的动态ref失效了——哪怕你写了同样的ref="'item-' + idx",在setup里声明的const item1 = ref(null)根本不会自动绑定到对应节点,Vue3的官方解决方案是函数式ref绑定或者使用ref数组/对象来收集。

Vue3动态ref的三种安全写法

接下来给大家说三种完全不会踩坑的写法,分别覆盖不同的开发习惯和场景,都是经过实际项目验证的。

ref数组绑定(最常用、适合循环渲染同类型元素)

这种写法最省心,适合所有通过v-for循环渲染出来的同类型DOM或组件,不管是固定长度还是动态增删的列表都能用。

你需要在setup里先初始化一个空的ref数组,注意数组本身也要用ref包裹,不然数组的变化不会触发响应式(虽然获取节点本身不需要响应式,但有时候你要监听列表更新后节点的重新绑定,或者数组长度的变化,还是包ref更稳妥)。

在v-for循环的节点上,绑定一个动态的回调函数作为ref属性,这个回调函数会接收两个参数:第一个是当前节点的实例(DOM元素或者组件实例),第二个是渲染该节点时的key值(注意,这个key必须是唯一的,比如item.id,别用索引,否则列表增删时索引会变,收集的节点就乱了!),然后我们把当前节点实例存到数组的对应位置或者直接push?不对,直接push的话如果列表重新渲染,原来的节点实例可能会变成null,数组里会混进脏数据,正确的做法是用key作为数组的索引?或者用对象存?哦对,或者用ref数组配合key来确保位置唯一,但其实更推荐的是用函数式ref里的判断逻辑:如果实例存在,就存到数组的对应位置;如果不存在(说明节点被销毁了),就把对应位置设为undefined或者过滤掉。

不过还有更简单的官方推荐小技巧:直接给ref数组赋值一个回调函数的简化版?不对,等下,实际开发中用得最多的是:在v-for里写ref="el => { if (el) itemRefs[idx] = el }"?不,刚才说了别用索引当数组索引,应该用对象!哦对,我刚才差点说漏嘴,新手用索引当数组的存储位置踩的坑比字符串拼接还多——比如用户删除了列表的第2个元素,原来的第3个元素变成第2个,索引变了,对应的ref数组位置也变了,但实例还在原来的位置,最后取出来的都是错的。

所以调整一下,用ref对象配合唯一key:

// setup 里
import { ref, onMounted } from 'vue';
export default {
  setup() {
    // 用ref包裹空对象,用来存键值对:key是唯一标识,value是节点/组件实例
    const dynamicRefs = ref({});
    const list = ref([
      { id: 1, name: '商品A' },
      { id: 2, name: '商品B' },
      { id: 3, name: '商品C' },
    ]);
    // 比如点击按钮获取指定id的商品卡片组件的某个方法
    const openProductModal = (productId) => {
      const productCard = dynamicRefs.value[productId];
      if (productCard) {
        // 调用子组件的方法,或者拿DOM节点的属性
        productCard.openModal();
      }
    };
    onMounted(() => {
      // 打印一下看看有没有收集到所有实例
      console.log(dynamicRefs.value);
    });
    return { dynamicRefs, list, openProductModal };
  }
};

然后模板里这样写:

<!-- 模板里 -->
<div v-for="item in list" :key="item.id">
  <!-- 假设product-card是子组件,有openModal方法 -->
  <product-card 
    :ref="el => { if (el) dynamicRefs[item.id] = el }" 
    :product="item"
  />
  <button @click="openProductModal(item.id)">查看详情</button>
</div>

对,这里的回调函数逻辑很重要:if (el)——因为Vue在渲染节点时会调用一次ref回调(传真实实例),在销毁节点时会再调用一次(传null),加个判断就能避免在对象里存null这种脏数据。

变量名动态拼接的ref数组绑定?(仅限非常固定的、不会动态增删的场景)

刚才说了Vue3不支持直接字符串拼接同名静态ref,但有没有办法间接实现类似的效果?比如有时候你有三个完全固定的步骤条,不想用对象存,就想单独用step1、step2、step3的ref,然后用索引动态取?

可以,但这种写法只能用在不会动态增删、key完全固定、索引不会变的极端场景,而且官方不太推荐,但确实能用,你可以在setup里先把所有固定的ref变量放到一个普通数组里(注意是普通数组,不是ref数组,因为变量名本身是静态的,只是要动态访问数组元素),然后在模板里用回调函数把实例存到对应索引:

// setup 里
import { ref } from 'vue';
export default {
  setup() {
    // 先声明三个固定的ref
    const step1 = ref(null);
    const step2 = ref(null);
    const step3 = ref(null);
    // 放到普通数组里
    const stepRefs = [step1, step2, step3];
    const steps = ['填写信息', '上传资料', '确认提交'];
    const goToStep = (stepIdx) => {
      const targetStep = stepRefs[stepIdx].value;
      if (targetStep) {
        targetStep.scrollIntoView({ behavior: 'smooth' });
      }
    };
    return { stepRefs, steps, goToStep };
  }
};

模板里:

<div v-for="(step, idx) in steps" :key="idx">
  <div :ref="el => { if (el) stepRefs[idx].value = el }">
    {{ idx + 1 }}. {{ step }}
  </div>
</div>
<button @click="goToStep(1)">跳转到第二步</button>

这种写法虽然能用,但真的别乱用,只要步骤条变成动态的(比如根据用户权限去掉第二步),索引一乱,stepRefs里的对应元素就存错了。

单个动态ref绑定(适合条件渲染的单个元素)

还有一种情况:不是循环,是条件渲染的单个元素,比如根据isShowModal来渲染弹窗,每次弹窗的内容不一样,需要获取当前弹窗里的某个输入框,这时候也可以用函数式ref,不过不需要数组或对象,直接用一个单独的ref变量就行:

// setup 里
import { ref, watch } from 'vue';
export default {
  setup() {
    const isShowModal = ref(false);
    const currentModalType = ref('login');
    const modalInput = ref(null); // 单个动态ref
    // 监听弹窗打开,自动聚焦输入框
    watch(isShowModal, (newVal) => {
      if (newVal && modalInput.value) {
        modalInput.value.focus();
      }
    });
    return { isShowModal, currentModalType, modalInput };
  }
};

模板里:

<div v-if="isShowModal">
  <div v-if="currentModalType === 'login'">
    <label>账号:</label>
    <input :ref="el => modalInput = el" type="text" />
  </div>
  <div v-else>
    <label>邮箱:</label>
    <input :ref="el => modalInput = el" type="email" />
  </div>
</div>

这种写法很简单,条件渲染时,Vue会自动销毁旧节点(调用ref回调传null给modalInput),然后渲染新节点(传新实例给modalInput),不需要额外的判断逻辑——不过如果你想在旧节点销毁时做一些清理工作,也可以加个if-else。

这三个动态ref踩坑点一定要避开!

刚才说了一些,现在集中总结新手90%会踩的坑,避坑指南收好:

踩坑点1:继续用Vue2的字符串拼接动态ref

比如写ref="'item-' + idx",然后在setup里声明const item1 = ref(null),结果item1.value永远是null——Vue3已经完全抛弃了这种通过模板字符串匹配setup里同名变量的机制,改成了更灵活的函数式绑定,一定要改过来。

踩坑点2:用v-for的索引当ref数组/对象的存储位置

比如前面的写法一,如果改成key="idx",然后ref="el => { if (el) dynamicRefs[idx] = el }",当用户删除列表的第0个元素,原来的第1个元素变成第0个,模板重新渲染时,会把原来的第1个元素的实例存到dynamicRefs[0],但原来的第0个元素的实例还在dynamicRefs[0]吗?不,会先调用原来第0个元素的ref回调传null,然后调用新第0个元素的ref回调传实例,但如果是列表重新排序呢?比如从[0,1,2]变成[2,1,0],索引对应的元素全变了,dynamicRefs里的内容也全乱了,所以v-for一定要用唯一的key(比如item.id、item.uuid),然后用这个唯一key当ref对象的属性名,这样不管列表怎么增删、排序,每个实例都能精准对应到自己的唯一标识。

踩坑点3:ref回调里没有加if (el)的判断

刚才说了,Vue在渲染和销毁节点时都会调用ref回调,渲染传真实实例,销毁传null,如果你不加判断,直接ref="el => dynamicRefs[item.id] = el",当节点被销毁后,dynamicRefs[item.id]就会变成null,下次再取的时候就会报错(比如调用子组件的方法时,Cannot read properties of null),所以一定要加if (el)的判断,只存真实的实例。

动态ref取出来的是DOM还是组件实例?怎么区分?

这是新手常问的另一个问题:同样的写法,为什么有时候取到的是HTMLDivElement,有时候是Proxy? 很简单,看你绑定的是普通HTML标签还是Vue组件:

  • 如果绑定的是<div>、<input>、<button>这种普通HTML标签,取出来的就是真实的DOM元素,可以直接调用DOM API(比如.focus()、.scrollIntoView()、.style);
  • 如果绑定的是<product-card>、<my-modal>这种自定义Vue组件,取出来的是组件的公开实例Proxy,只能访问组件里通过defineExpose暴露出来的属性和方法——这是Vue3的另一个重要变化:Vue2里组件实例的所有属性和方法都能通过$refs访问,Vue3里默认是私有的,必须显式暴露!

比如刚才的product-card子组件,如果要让父组件能调用openModal方法,必须在子组件的setup里用defineExpose:

// product-card 子组件的setup里
import { ref, defineExpose } from 'vue';
export default {
  props: ['product'],
  setup(props) {
    const isModalOpen = ref(false);
    const openModal = () => {
      isModalOpen.value = true;
    };
    // 必须显式暴露,父组件才能通过dynamicRefs[productId].openModal()调用
    defineExpose({ openModal });
    return { isModalOpen, openModal };
  }
};

如果你没加defineExpose,父组件取到的Proxy里只有$el、$parent这些内置属性,找不到你自己写的openModal方法。

什么时候该用动态ref?什么时候该用其他方案?

动态ref虽然好用,但不是万能的,有些场景用其他方案更优雅、更符合Vue的响应式理念:

应该用动态ref的场景

  1. 需要调用DOM API:比如自动聚焦输入框、滚动到指定位置、获取元素的宽高/位置(虽然也可以用CSS变量或响应式数据,但有时候直接获取DOM更准确);
  2. 需要调用子组件的非响应式触发方法:比如打开弹窗、播放视频、清空画布这些不需要通过props改变就能触发的方法;
  3. 需要批量操作多个同类型节点:比如批量禁用一组输入框、批量设置一组轮播图slide的样式。

不该用动态ref的场景

  1. 传递数据:父子组件传递数据应该用props和emits,或者pinia/vuex,不要通过ref直接修改子组件的属性;
  2. 触发子组件的响应式更新:比如子组件的某个显示状态,应该通过props传值,不要通过ref调用子组件的方法来修改;
  3. 监听子组件的事件:应该用v-on或者来监听子组件的emits事件,不要通过ref在父组件里手动绑定事件监听器。

Vue3动态ref的核心是函数式绑定,配合唯一key和ref对象使用,就能解决所有循环、条件渲染场景下的节点/组件获取问题,记住三个安全写法、三个避坑点、什么时候用什么时候不用,就能彻底掌握动态ref,再也不会踩坑。

最后再提一句,如果你用的是<script setup>语法糖,写法完全一样,只是不需要写export default { setup() { ... } }这个壳子,直接在<script setup>里写就行,defineExpose也不需要额外导入,直接用就行——<script setup>真的是Vue3的神器,强烈推荐大家使用。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门