讲解

Vue 的理念是「不直接碰 DOM」,但总有例外:聚焦输入框、初始化地图/图表库、测量元素尺寸、播放视频。这些场景用模板引用(template ref):模板里写 <input ref="inputEl">,脚本里声明同名 ref const inputEl = ref(null),挂载后 inputEl.value 就是真实 DOM 节点。注意时机:挂载前它是 null,所以访问要放在 onMounted 里。

ref 也能挂在子组件上:<Child ref="childRef"> 之后 childRef.value 拿到的是组件实例。但 <script setup> 的组件默认是封闭的——父组件拿不到它的内部状态,需要子组件用 defineExpose 显式暴露:defineExpose({ focus, reset })。这是个好设计:组件对外暴露的「命令式把手」被收敛成显式清单,而不是整个实例裸奔。

v-for 里的 ref 会得到数组:<li v-for="item in list" :ref="setItemRef"> 或 Vue 3.5 前的 ref="items" 自动收集(顺序不保证与数据源一致,别依赖它做索引映射)。函数式 ref :ref="(el) => ..." 更灵活,每次绑定更新都会调用。最后提醒:能用 props/emit 解决的交互就不要用 ref 命令式调用——ref 是逃生舱,不是正门。

示例

模拟 ref 从 null 到绑定的时机,以及 defineExpose 的「白名单」语义(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 模板引用的生命周期:null → mounted 后可用
function createTemplateRef() {
  let el = null;
  return {
    get value() {
      return el;
    },
    _bind(domNode) {
      el = domNode; // Vue 在挂载时帮你做的事
    },
  };
}

const inputEl = createTemplateRef();
assert.strictEqual(inputEl.value, null); // setup 阶段是 null

const fakeDom = { focus: () => '已聚焦', tagName: 'INPUT' };
inputEl._bind(fakeDom); // 挂载完成
assert.strictEqual(inputEl.value.tagName, 'INPUT');
assert.strictEqual(inputEl.value.focus(), '已聚焦');

// defineExpose:只暴露白名单里的成员
function createChildComponent() {
  const internal = { secret: '内部状态,不外露' };
  const exposed = {
    reset: () => '已重置',
    focus: () => '已聚焦',
  };
  return {
    internal,
    instance: new Proxy(exposed, {
      get: (t, k) => (k in t ? t[k] : undefined), // 父组件只能摸到暴露的
    }),
  };
}

const child = createChildComponent();
assert.strictEqual(child.instance.reset(), '已重置');
assert.strictEqual(child.instance.secret, undefined); // 未暴露的拿不到

console.log('ref 在挂载前:', inputEl.value === null ? 'null' : '有值', '→ 挂载后可调用 focus()');
console.log('父组件只能访问 defineExpose 暴露的方法: reset / focus');

真实组件:

<script setup>
// SearchBox.vue
import { ref } from 'vue';

const inputEl = ref(null);
const keyword = ref('');

function focus() {
  inputEl.value.focus();
}
function reset() {
  keyword.value = '';
  focus();
}
defineExpose({ focus, reset }); // 只暴露这两个方法
</script>

<template>
  <input ref="inputEl" v-model="keyword" placeholder="搜索" />
</template>
<script setup>
// Parent.vue
import { ref, onMounted } from 'vue';
import SearchBox from './SearchBox.vue';

const box = ref(null);

onMounted(() => {
  box.value.focus(); // 进入页面自动聚焦
});
</script>

<template>
  <SearchBox ref="box" />
  <button @click="box.reset()">清空并重置</button>
</template>

常见坑

  • 挂载前访问 ref.value:顶层代码里它是 null,DOM 操作放 onMounted。
  • 忘记声明同名 ref:模板 ref="xxx" 必须有对应的 const xxx = ref(null),Vue 3.5+ 可用 useTemplateRef。
  • 依赖 v-for ref 数组的顺序:不保证和数据顺序一致,需要映射就用函数式 ref 自己建 Map。
  • 把 ref 当常规通信手段:能声明式(props/emits)就别命令式(ref.xxx()),否则数据流会乱。
  • 暴露整个实例:defineExpose 给最小集合,暴露越多,组件越难重构。

小结

ref="xxx" + 同名 ref,挂载后拿 DOM;组件实例默认封闭,defineExpose 开白名单;ref 是逃生舱不是正门。下一章把逻辑抽出组件:组合式函数。