跳到主要内容

Vue3 中 ref 的原理

面试速答(30 秒版 TL;DR)

  • ref(x) 会返回一个“响应式引用对象”,核心是一个带 getter/setter.value
  • ref.value 会触发依赖收集(track), ref.value = ... 会触发依赖更新(trigger)。
  • ref 的依赖不挂在 target -> key 的那套 WeakMap 结构上,而是 ref 自己维护一个 dep(可以理解为 dep = Set<effect>)。
  • ref 包装对象时,默认会把对象再转成 reactive(深层响应式),所以 ref({a:1}).value.a++ 也是响应式的。
  • 模板里对 ref 有“自动解包”(不用写 .value);reactive 里嵌套的 ref 在读取/赋值时也有“自动解包/回写”的特殊处理。

心智模型:ref 是“单个值的响应式容器”

ref 讲清楚,关键是对比两套依赖收集入口:

  • reactive(obj):依赖按 “对象 target + 属性 key” 维度收集(Proxy 的 get/settrack(target, key))。
  • ref(value):依赖按 “这个 ref 实例” 维度收集(.valueget/settrackRefValue(this))。

你可以把 ref 想成:

type Ref<T> = { value: T };

只是 .value 的读写被 Vue 接管了。


关键流程图(简化版)

面试说人话版本:谁在渲染时读了 .value,谁就会在 .value 变化时被重新执行。


原理细化:RefImpl 的 .value 做了什么

下面是接近 Vue 3 响应式实现的“伪代码”,用来回答“ref 到底怎么收集依赖”的追问:

// 伪代码:只保留与 ref 原理强相关的结构
type Effect = () => void;

let activeEffect: Effect | null = null;

class RefImpl<T> {
private _value: T;
// 可以把 dep 理解为 Set<Effect>,Vue 内部会有更丰富的结构与位标记优化
public dep = new Set<Effect>();

constructor(v: T) {
this._value = v;
}

get value(): T {
if (activeEffect) this.dep.add(activeEffect);
return this._value;
}

set value(next: T) {
if (Object.is(next, this._value)) return;
this._value = next;
for (const e of this.dep) e();
}
}

真实 Vue 3 里(@vue/reactivity)会多做这些事(面试点到即可):

  • ref 包装对象时会转成响应式:ref({}) 内部会做 toReactive(value)(也就是走 reactive)。
  • 触发更新不是立刻同步跑一遍,而是交给 scheduler 做 批处理(所以你经常需要 nextTick)。
  • 会有位运算/脏标记等优化,避免重复收集与无效触发。

“自动解包”到底是怎么回事(高频追问)

1) 模板自动解包(template ref unwrapping)

在模板里:

<template>
<div>{{ count }}</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
</script>

可以不写 .value,原因是模板编译阶段会把 count 访问改写为对 .value 的访问(或等价的解包逻辑)。

2) reactive 里嵌套 ref 的解包与“回写”

import { reactive, ref } from 'vue';

const state = reactive({ count: ref(0) });

state.count; // 读到的是 0(解包后的值),不是 Ref 对象
state.count = 1; // 不是替换属性,而是写回到内部 ref.value

这能解释一个经典面试坑:

  • 为什么 reactive({ count: ref(0) })count 看起来像普通 number?
  • 为什么给它赋值也能触发更新?

本质:Proxy 的 get/set 对 “值是 ref” 的情况做了特殊分支处理。


shallowRef / triggerRef / customRef(什么时候用)

shallowRef:只追踪 .value 本身,不做深层 reactive

import { shallowRef } from 'vue';

const obj = shallowRef({ a: 1 });
obj.value.a++; // 不会因为 a 变化而触发依赖(因为内部对象不是 reactive)
obj.value = { a: 2 }; // 会触发(因为 .value 变了)

适用场景:

  • 大对象,内部频繁变更但你不希望深层追踪(性能/可控性)。
  • 你希望用不可变数据风格:通过整体替换 .value 来触发更新。

triggerRef:强制触发依赖

当你使用 shallowRef 并且内部对象发生了原地变更时,可以手动触发:

import { shallowRef, triggerRef } from 'vue';

const obj = shallowRef({ a: 1 });
obj.value.a++;
triggerRef(obj);

customRef:自定义 track/trigger(防抖输入这类)

核心思路:你自己决定什么时候收集依赖、什么时候触发更新。


典型题与标准答法

题 1:ref 为什么要 .value

标准答法(简洁但有底层感):

  • 因为 ref 是“把一个值包装进对象里”,Vue 需要在读写时插入 track/trigger
  • JavaScript 里无法拦截“变量本身的读写”,但能拦截“对象属性的读写”,所以用 .value 这个属性作为响应式入口。

题 2:ref 包对象时,为什么 ref({}) 也能响应式?

  • ref 内部会把对象转换成 reactive(默认深层响应式),所以 .value 是个 Proxy。
  • 因此依赖可能来自两处:读 .value(ref 的 dep),读 .value.xxx(reactive 的 dep)。

常见追问

  • refreactive 怎么选?
    • 单值、基本类型、需要整体替换:优先 ref
    • 多字段对象、偏好 state.x:用 reactive,需要解构时配合 toRef/toRefs
  • ref 的依赖存哪?
    • reactiveWeakMap(target) -> Map(key) -> dep
    • refref.dep(一个 dep 容器)
  • .value 的更新是同步的吗?
    • 触发依赖是同步发生的,但 effect 的执行通常会被 scheduler 批处理;需要读更新后的 DOM 用 nextTick

易错点 / 坑

  • 把 ref 解构/传参时忘了 .value:在 JS/TS 逻辑里不会自动解包(模板才会)。
  • reactive 内嵌 ref 的赋值行为是“写回 .value”,这会让你误以为替换了属性,但其实没替换 Ref 对象本身。
  • shallowRef 内部对象原地改字段不会触发更新,除非你整体替换或 triggerRef

速记要点(可背)

  • ref = “值容器 + .value getter/setter + dep”
  • .value:track;写 .value:trigger
  • ref(obj) 默认把 objreactive(深层)
  • 模板自动解包;JS 里要 .value