第 1 章

Vue 的三次重写:从 Angular 指令到 Proxy 响应式的演进史

第1章:Vue 的三次重写——从 Angular 指令到 Proxy 响应式的演进史

2013 年 7 月,Evan You 用了一个周末写出了 Vue 的第一个版本,最初叫 Seed.js,整个代码库不超过 1000 行。十年后,Vue 3 的源码超过 40 万行,服务于全球超过 300 万开发者。

本章核心问题:Vue 为什么要进行三次重写?每次重写解决了什么根本性矛盾?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

1.1 一切始于一个 Angular 的"太重了"

2013 年,Evan You 在 Google Creative Lab 做前端开发,日常工作是使用 AngularJS 构建内部工具。AngularJS 的双向绑定和依赖注入确实强大,但对于简单的 UI 原型来说,整套框架太笨重了——学习曲线陡峭,启动配置繁琐,运行时体积庞大。

Evan 想要的是 Angular 的数据绑定功能,但剥掉其他所有东西。他用了一个周末,写出了第一个版本,最初叫 Seed.js,后来改名为 Vue.js。核心思路非常简单:一个轻量的数据-视图绑定库,不是框架,不需要学习"Angular 方式"才能用。

这是 Vue 的第一次定位:Angular 的数据绑定,去掉所有框架开销

1.2 Vue 1.x:细粒度依赖追踪的初次尝试

Vue 1.x 发布于 2015 年,它建立在 Object.defineProperty 之上。核心思路是:把每个数据属性转换成 getter/setter,当属性被读取时记录"谁依赖了我",当属性被修改时通知所有依赖者更新。

这套机制叫做依赖追踪(dependency tracking),它让 Vue 知道精确更新哪个 DOM 节点,而不是重新渲染整棵树。

// Vue 1.x 内部的简化版响应式机制
function defineReactive(obj, key, val) {
  const dep = new Dep(); // 收集依赖的容器
  
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) { // 有组件正在渲染
        dep.depend(); // 记录这个组件依赖了这个属性
      }
      return val;
    },
    set(newVal) {
      val = newVal;
      dep.notify(); // 通知所有依赖者更新
    }
  });
}

Vue 1.x 的响应式是极细粒度的:每一个数据属性对应一组 Watcher,数据改变触发精确更新,没有虚拟 DOM,直接操作真实 DOM。在小型应用里,这套机制效率极高。

但它有一个根本性问题Object.defineProperty 只能拦截已经存在的属性。如果你在 Vue 实例创建之后给 data 对象新增属性,Vue 根本不知道这件事发生了。

// Vue 1.x 中这样做不会触发更新
this.someObject.newProp = 'hello'; // 响应式系统看不见这个操作

Vue 1.x 通过暴露 $set 方法来绕开这个限制,但这是一个外科手术式的补丁,不是根本解决。

1.3 Vue 2.x:引入虚拟 DOM,但保留了同样的限制

Vue 2.0 发布于 2016 年。这次重写的最大变化是引入了虚拟 DOM(Virtual DOM),灵感来自 Snabbdom 库(作者 Simon Friis Vindum 的实现只有约 200 行)。

为什么需要虚拟 DOM?

Vue 1.x 的精细粒度依赖追踪在组件层面运作良好,但在大型应用中,当一个状态被数百个 Watcher 订阅时,管理这些 Watcher 本身的开销变得不可忽视。Vue 2 的解决方案是将粒度从"每个属性-每个 DOM 节点"提升到"每个属性-每个组件":数据变化时,重新运行组件的渲染函数,生成新的虚拟 DOM 树,再通过 diff 算法找出最小更新集合。

// Vue 2.x 的组件更新流程(简化)
// 1. 数据变化触发 Watcher
// 2. Watcher 调用组件的 _update 方法
// 3. _update 内部调用 render() 生成新 VNode
// 4. patch(oldVNode, newVNode) 更新真实 DOM

这次重写还引入了 render function:你可以不使用模板,而是直接写 JavaScript 函数来描述视图结构。这让 Vue 的能力边界大幅扩展,JSX 支持、高阶组件、更复杂的渲染逻辑都变得可能。

但 Vue 2 继承了 Vue 1 的同样限制,因为它仍然基于 Object.defineProperty

限制一:无法检测属性新增

// 这不会触发视图更新
this.user.age = 18; // user 已存在,age 是新增的属性
// 必须用:
this.$set(this.user, 'age', 18);

限制二:无法检测属性删除

// 这不会触发视图更新
delete this.user.name;
// 必须用:
this.$delete(this.user, 'name');

限制三:无法检测数组索引赋值

// 这不会触发视图更新
this.list[0] = newItem;
// 必须用:
this.$set(this.list, 0, newItem);
// 或者:
this.list.splice(0, 1, newItem);

Vue 2 对数组的处理是一个有趣的工程折衷:它通过劫持数组的 7 个变异方法pushpopshiftunshiftsplicesortreverse)来实现响应式,但直接的索引赋值和 length 修改仍然无法追踪。

这三个限制困扰了 Vue 2 开发者整整四年。文档里有专门一节列出"注意事项",Vue 相关的 Stack Overflow 问题有相当大比例都与这三个限制有关。

1.4 Vue 3.x:从头重写的五个动机

2018 年末,Evan You 在 Medium 发布了一篇文章,宣布 Vue 3 将从头重写。他给出了五个动机:

动机一:TypeScript 支持 Vue 2 是用 Flow(Facebook 的类型检查工具)写的,但 Flow 的类型推断能力远不及 TypeScript。更重要的是,Vue 2 的 this 上下文让 TypeScript 的类型推断几乎无法正常工作——你拿不到组件数据和方法的类型提示。

Vue 3 完全用 TypeScript 重写,API 设计时把类型推断作为第一优先级。

动机二:Composition API 当组件逻辑复杂时,Options API 会把同一个功能的代码分散在 datamethodscomputedwatch 四个地方。Composition API 允许按功能来组织代码,而不是按选项类型。(详见第2章)

动机三:Proxy 响应式 Proxy 是 ES2015 原生支持的对象代理机制,它能拦截对象的所有基本操作,包括属性新增、属性删除、数组索引赋值。Object.defineProperty 的三个限制全部消失。

动机四:Tree-shakable 架构 Vue 2 的所有 API 都挂在 Vue 全局对象上(Vue.componentVue.filterVue.mixin),无法被打包工具摇掉。Vue 3 改为命名导出,未使用的 API 不会打包进最终产物。

动机五:性能提升 基于编译时分析的 PatchFlag 系统:编译器在生成虚拟 DOM 时标记每个节点的动态部分,运行时 diff 只检查标记了动态的部分,跳过静态内容。

Vue 团队在 Chrome 的 js-framework-benchmark 上测量了 Vue 3 vs Vue 2 的性能差异:

1.5 Vue vs React:两条平行的演进轨迹

Vue 和 React 在差不多同一时期(2013年前后)诞生,但演进轨迹截然不同:

维度 React 演进 Vue 演进
状态管理 Class 组件 state → Hooks useState Options API data → Composition API ref/reactive
逻辑复用 HOC → Render Props → Hooks Mixin → Composition API
类型支持 逐步完善 → Hooks 后大幅改善 Flow → TypeScript 完全重写
渲染优化 手动 memo/useMemo/useCallback 编译时自动追踪依赖
哲学 函数式优先,UI = f(state) 响应式优先,自动追踪变化

React Hooks(2019)和 Vue Composition API(2020)几乎同时解决了相同的问题:逻辑复用和代码组织。但解决方式根本不同:React Hooks 在每次渲染时重新执行,Vue 的 setup() 只执行一次。这个差异导致 React 需要 useCallbackuseMemo、依赖数组等概念,Vue 完全不需要。


Level 2 · 它是怎么运行的(3-5年经验)

2.1 Object.defineProperty 的工作原理与根本限制

要理解 Vue 3 为什么需要 Proxy,需要先深入理解 Object.defineProperty 在做什么,以及它为什么无法解决那三个限制。

Object.defineProperty 是 ES5 引入的,用于在一个对象上定义新属性,或修改现有属性的特性。它操作的是属性描述符(Property Descriptor):

Object.defineProperty(obj, 'count', {
  enumerable: true,   // for...in 和 Object.keys() 能枚举到
  configurable: true, // 可以被 delete,可以再次 defineProperty
  writable: true,     // 可以用赋值运算符修改
  value: 0            // 属性的值
});

// 或者用 accessor descriptor(不能同时有 value/writable):
Object.defineProperty(obj, 'count', {
  enumerable: true,
  configurable: true,
  get() { return this._count; },
  set(val) { this._count = val; }
});

Vue 的响应式系统用的是 accessor descriptor(getter/setter)。当你在 Vue 2 中定义 data() { return { count: 0 } },Vue 内部遍历这个对象的所有属性,对每个属性调用 defineProperty,把它变成 getter/setter 对。

为什么无法检测属性新增?

defineProperty 只能操作已经存在于对象上的属性。当你写 this.user.newProp = 1 时,JavaScript 引擎执行的是一个普通的属性赋值操作——它不会触发任何已有的 getter/setter,因为 newProp 这个 accessor descriptor 根本不存在。Vue 没有机会拦截这个操作。

要拦截属性新增,你需要的是能拦截"对象上任何属性的赋值"的机制,而不是某个具体属性的赋值——这正是 Proxy 的 set trap 能做的事。

为什么无法检测属性删除?

delete obj.prop 是一个独立的操作符,不会触发 getter/setter。即使属性有 setter,delete 操作也绕过它直接从对象上移除属性描述符。没有任何方式在 defineProperty 层面拦截 delete

为什么无法检测数组索引赋值?

这个最微妙。理论上,你可以对数组的每个索引调用 defineProperty

const arr = [1, 2, 3];
Object.defineProperty(arr, '0', {
  get() { return this._0; },
  set(val) { this._0 = val; dep.notify(); }
});

Vue 2 实际上就是这样做的!对于已知长度的数组,Vue 2 会对每个索引调用 defineProperty

问题在于:当你写 arr[100] = 'new' 时,100 这个索引是新增的,同样无法拦截。更严重的是,arr.length = 0 这样的操作会批量删除元素,也无法通过 getter/setter 拦截。

Vue 2 的解决方案是劫持数组的变异方法,但这只覆盖了 push/pop/shift/unshift/splice/sort/reverse 这 7 个方法,索引赋值和 length 修改依然是盲区。

2.2 Proxy 的工作原理

Proxy 是 ES2015 引入的,它允许创建一个对象的"代理",可以拦截并重新定义该对象的基本操作。

const handler = {
  get(target, key, receiver) {
    console.log(`读取属性: ${String(key)}`);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`设置属性: ${String(key)} = ${value}`);
    return Reflect.set(target, key, value, receiver);
  }
};

const obj = {};
const proxy = new Proxy(obj, handler);

proxy.newProp = 1;     // 触发 set trap: "设置属性: newProp = 1"
delete proxy.newProp;  // 触发 deleteProperty trap(如果定义了的话)

Proxy 的 set trap 会在任何属性赋值时触发,无论这个属性是否已经存在。这直接解决了"无法检测属性新增"的问题。

Object.defineProperty 拦截层:

  obj                    observer
  ┌──────────────────┐   ┌────────────────────────────────────────┐
  │ .name  [getter]  │   │  defineProperty('name') 时注册          │
  │        [setter]  │◄──│  只能拦截已知属性                        │
  │ .age   [getter]  │   │                                        │
  │        [setter]  │   │  新增属性 obj.xxx = 1 → 无法拦截 ✗      │
  │ .xxx   ???       │   │  删除属性 delete obj.yyy → 无法拦截 ✗   │
  └──────────────────┘   └────────────────────────────────────────┘

Proxy 拦截层:

  target               handler
  ┌──────────────────┐  ┌────────────────────────────────────────┐
  │ .name            │  │  get trap:任何属性读取都经过这里        │
  │ .age             │  │  set trap:任何属性写入都经过这里        │
  │ .xxx(新增)     │◄─│  deleteProperty trap:delete 操作       │
  │ .yyy             │  │  has trap:in 操作符                    │
  └──────────────────┘  │  ownKeys trap:Object.keys() 等         │
                        └────────────────────────────────────────┘

2.3 Vue 3 响应式系统的架构变化

Vue 3 的响应式系统被完全提取成一个独立的包:@vue/reactivity。这个包可以独立使用,不依赖 Vue 的其他部分。

Vue 3 包结构(关键部分):

  @vue/reactivity          @vue/runtime-core          @vue/runtime-dom
  ┌─────────────────┐      ┌──────────────────┐       ┌───────────────────┐
  │ reactive()      │      │ defineComponent() │       │ createApp()       │
  │ ref()           │─────►│ computed()        │──────►│ render (DOM)      │
  │ effect()        │      │ watch/watchEffect │       │ patch (DOM diff)  │
  │ track/trigger   │      │ 组件生命周期      │       │ nodeOps           │
  │ Proxy handlers  │      │ VNode 系统        │       └───────────────────┘
  └─────────────────┘      └──────────────────┘

Vue 3 的核心依赖追踪数据结构是一个三层嵌套的 Map:

WeakMap<target, Map<key, Set<ReactiveEffect>>>

  targetMap (WeakMap)
  │
  ├── target1 (depsMap: Map)
  │   ├── "key1" → dep (Set<ReactiveEffect>)
  │   │   ├── effect1
  │   │   └── effect2
  │   └── "key2" → dep (Set<ReactiveEffect>)
  │       └── effect3
  │
  └── target2 (depsMap: Map)
      └── "keyA" → dep (Set<ReactiveEffect>)
          └── effect4

使用 WeakMap 的原因:当 target 对象没有其他引用时,GC 可以自动回收,不需要手动清理依赖图。

2.4 Vue 3 Composition API 的实际代码组织优势

来看一个真实案例:一个包含搜索、分页、排序功能的列表页面。

Options API 写法:逻辑分散在各个选项中:

// Vue 2 Options API
export default {
  data() {
    return {
      // 搜索相关
      searchQuery: '',
      searchResults: [],
      isSearching: false,
      // 分页相关
      currentPage: 1,
      pageSize: 20,
      total: 0,
      // 排序相关
      sortBy: 'name',
      sortOrder: 'asc'
    }
  },
  computed: {
    // 搜索相关
    hasResults() { return this.searchResults.length > 0 },
    // 分页相关
    totalPages() { return Math.ceil(this.total / this.pageSize) },
    // 排序相关
    sortIcon() { return this.sortOrder === 'asc' ? '↑' : '↓' }
  },
  methods: {
    // 搜索相关
    async search() { /* ... */ },
    clearSearch() { /* ... */ },
    // 分页相关
    goToPage(page) { /* ... */ },
    changePageSize(size) { /* ... */ },
    // 排序相关
    sort(field) { /* ... */ }
  },
  watch: {
    searchQuery(val) { this.search(); }
  }
}

Composition API 写法:每个功能封装成独立的 composable:

// useSearch.js
export function useSearch() {
  const searchQuery = ref('');
  const searchResults = ref([]);
  const isSearching = ref(false);
  const hasResults = computed(() => searchResults.value.length > 0);
  
  async function search() { /* 完整逻辑 */ }
  function clearSearch() { /* 完整逻辑 */ }
  
  watch(searchQuery, search);
  
  return { searchQuery, searchResults, isSearching, hasResults, search, clearSearch };
}

// usePagination.js
export function usePagination() { /* 完整分页逻辑 */ }

// useSorting.js  
export function useSorting() { /* 完整排序逻辑 */ }

// 组件内
export default {
  setup() {
    const search = useSearch();
    const pagination = usePagination();
    const sorting = useSorting();
    
    return { ...search, ...pagination, ...sorting };
  }
}

Composition API 的优势不在于代码量减少,而在于同一功能的代码在物理上靠近,便于理解和迁移。


Level 3 · 设计文档与源码(资深开发者)

3.1 Vue 3 重写过程中的关键设计决策

Vue 3 的重写过程被记录在 GitHub 的多个 RFC(Request for Comments)中。最关键的几个:

RFC-0004(2019.01):Function-based Component API 这是 Composition API 的最初提案。引发了 GitHub 有史以来讨论最激烈的 RFC 之一(超过 1000 条评论)。最终保留了 Options API,Composition API 作为可选项并存。

RFC-0006(2019.06):Slots Unification Vue 3 统一了普通 slot 和 scoped slot 的语法,底层统一为函数。这个决策让 TypeScript 类型推断更容易实现。

RFC-0008(2019.10):Composition API(正式版) 在社区讨论六个月后,Composition API 以独立 API 的形式被正式接受,setup() 函数成为入口点。

3.2 Proxy handler 的源码实现

Vue 3 响应式系统的核心在 packages/reactivity/src/baseHandlers.ts

// packages/reactivity/src/baseHandlers.ts
import { track, trigger } from './effect'
import { TrackOpTypes, TriggerOpTypes } from './operations'
import { reactive, readonly, isReactive } from './reactive'

// 普通可变对象的 handler
export const mutableHandlers: ProxyHandler<object> = {
  get,      // 读取属性时触发 track
  set,      // 写入属性时触发 trigger
  deleteProperty, // delete 操作时触发 trigger
  has,      // in 操作符时触发 track
  ownKeys   // Object.keys() 等时触发 track
}

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // 特殊 key 处理
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (
      key === ReactiveFlags.RAW &&
      receiver === (isReadonly ? readonly : reactive)(target)
    ) {
      return target // toRaw() 实现原理
    }

    const targetIsArray = isArray(target)

    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      // 数组的特殊方法处理(includes, indexOf, lastIndexOf)
      return Reflect.get(arrayInstrumentations, key, receiver)
    }

    const res = Reflect.get(target, key, receiver)

    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res // 内置 Symbol 不追踪
    }

    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key) // 关键:触发依赖收集
    }

    if (shallow) {
      return res // shallowReactive 不递归代理
    }

    if (isRef(res)) {
      // 嵌套 ref 自动解包(但数组中的 ref 不自动解包)
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }

    if (isObject(res)) {
      // 懒代理:访问时才对嵌套对象创建 Proxy
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

注意这里的懒代理(lazy proxy)策略:嵌套对象不是在 reactive() 调用时就全部代理,而是在访问到时才创建代理。这避免了对深层嵌套对象的提前遍历,提升了初始化性能。

// set handler 的实现
function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    let oldValue = (target as any)[key]
    
    if (!shallow) {
      // 处理嵌套的 ref
      if (!isShallow(value) && !isReadonly(value)) {
        oldValue = toRaw(oldValue)
        value = toRaw(value)
      }
      // 如果旧值是 ref,新值不是 ref,自动更新 ref.value
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value
        return true
      }
    }
    
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
    
    const result = Reflect.set(target, key, value, receiver)
    
    // 只在 target 是 receiver 的原型链上最近的代理时触发
    // 这避免了原型链上的重复触发
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value) // 新增属性
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue) // 修改属性
      }
    }
    
    return result
  }
}

这段代码中有一个精妙之处:通过 target === toRaw(receiver) 判断,防止当对象被继承时,父对象的 set 被触发两次。

3.3 Vue 3 的编译时优化:PatchFlag 系统

Vue 3 的编译器在分析模板时,会为动态节点打上标记(PatchFlag),运行时 diff 只检查有标记的部分:

// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
  TEXT = 1,           // 动态文本内容
  CLASS = 1 << 1,     // 动态 class
  STYLE = 1 << 2,     // 动态 style
  PROPS = 1 << 3,     // 动态 prop(排除 class 和 style)
  FULL_PROPS = 1 << 4, // 有动态 key 的 prop(需要完整 diff)
  HYDRATE_EVENTS = 1 << 5, // SSR 水合时需要处理事件
  STABLE_FRAGMENT = 1 << 6,
  KEYED_FRAGMENT = 1 << 7,
  UNKEYED_FRAGMENT = 1 << 8,
  NEED_PATCH = 1 << 9,
  DYNAMIC_SLOTS = 1 << 10,
  DEV_ROOT_FRAGMENT = 1 << 11,
  HOISTED = -1,       // 静态提升的节点,永不 diff
  BAIL = -2           // diff 时完全跳过优化
}

一个模板 <div :class="cls">{{ text }}</div> 会编译成:

// 编译后的 render function
createElementVNode("div", { class: _ctx.cls }, _ctx.text, 
  PatchFlags.CLASS | PatchFlags.TEXT // = 3
)

运行时收到 PatchFlag = 3 时,只检查 class 和 text,不检查其他 prop,跳过完整的属性比对。这就是 Vue 3 更新速度提升 133% 的核心来源。

3.4 @vue/reactivity 的独立性与生态影响

Vue 3 把响应式系统完全解耦,@vue/reactivity 可以在任何 JavaScript 环境中独立使用:

import { reactive, effect } from '@vue/reactivity';

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

effect(() => {
  console.log('count is:', state.count); // 立即打印
});

state.count++; // 触发 effect 重新执行,打印 "count is: 1"

这个设计影响深远:


Level 4 · 边界与陷阱(全体适用)

陷阱1:reactive() 对基础类型无效

import { reactive } from 'vue';

// 错误用法
const count = reactive(0); // 返回值仍然是数字 0,不是响应式的
count++; // 这不会触发任何更新

// 正确用法
const count = ref(0);
count.value++;

// 为什么?
// reactive() 内部是 new Proxy(target, handlers)
// Proxy 只能代理对象,不能代理 number/string/boolean 等基础类型
// Vue 会打印警告:
// [Vue warn]: value cannot be made reactive: 0

根本原因:Proxy 的第一个参数必须是对象。typeof null === 'object' 所以 null 也不行,Vue 内部有专门检查。

陷阱2:解构 reactive 对象丢失响应性

import { reactive, watch } from 'vue';

const state = reactive({ count: 0, name: 'Vue' });

// 错误:解构后 count 是普通数字,不是响应式的
const { count } = state;
console.log(count); // 0
state.count = 10;
console.log(count); // 仍然是 0,没有跟随变化

// 正确方案1:不解构,直接用 state.count
// 正确方案2:用 toRefs
import { toRefs } from 'vue';
const { count } = toRefs(state);
console.log(count.value); // 0,count 现在是一个 ref,链接到 state.count
state.count = 10;
console.log(count.value); // 10,正确跟随

// 正确方案3:直接用 ref 而不是 reactive(推荐)
const count = ref(0);
const name = ref('Vue');

根本原因const { count } = state 等价于 const count = state.count。这只是读取了当前值(触发一次 get trap,返回数字 0),并把这个数字赋给了 count 变量。数字是基础类型,没有响应性。后续 state.count 的变化不会影响 count 变量。

陷阱3:在 watchEffect 外访问响应式数据不触发追踪

import { reactive, watchEffect } from 'vue';

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

// 错误理解:以为响应式对象被修改时会"主动通知"订阅者
// 实际上:必须先在 effect 中访问,才能建立追踪关系

// 这段代码不会在 state.count 变化时重新打印
let lastCount = state.count; // 在 effect 外读取,不建立追踪
state.count = 5;
console.log(lastCount); // 仍然是 0

// 正确:在 watchEffect 内访问
watchEffect(() => {
  lastCount = state.count; // 在 effect 内读取,建立追踪
  console.log('count changed:', lastCount);
});

state.count = 5; // 触发 watchEffect 重新执行

根本原因:响应式追踪是"拉取式"的,必须先有消费者(effect),在 effect 执行时读取数据,才能建立 target → key → effect 的依赖关系。没有 effect 上下文时,track() 函数会检查 activeEffect 是否存在,如果不存在则直接返回不做任何事。

陷阱4:reactive 嵌套时 toRaw 的必要性

import { reactive, toRaw } from 'vue';

const original = { nested: { value: 1 } };
const state = reactive(original);

// 由于懒代理,第一次访问时 state.nested 会变成 Proxy
console.log(state.nested === original.nested); // false!
// state.nested 是 original.nested 的 Proxy 版本

// 场景:需要把数据传给第三方库(不需要响应式)
thirdPartyLib.process(state); // 可能引起问题,传的是 Proxy

// 正确做法:用 toRaw 获取原始对象
thirdPartyLib.process(toRaw(state)); // 传原始对象

// 另一个常见陷阱:比较时
const rawState = toRaw(state);
console.log(rawState === original); // true,toRaw 返回的是原始对象
console.log(rawState.nested === original.nested); // true

根本原因:Vue 3 的懒代理策略使得每次通过 Proxy 访问嵌套对象,都会返回该嵌套对象的 Proxy 版本。这意味着在 Proxy 环境中看到的对象引用与原始对象引用不同。=== 比较会失败,如果你缓存了从 Proxy 中取到的子对象引用,它是一个 Proxy,传给不了解 Vue 的第三方代码可能导致意外行为。

陷阱5:Vue 3 的数组响应式与索引追踪

import { reactive, watchEffect } from 'vue';

const list = reactive([1, 2, 3]);

// 这些操作都是响应式的(Vue 3 不同于 Vue 2)
watchEffect(() => {
  console.log(list[0]); // 追踪索引 0
});

list[0] = 99; // 触发更新!Vue 3 可以追踪索引赋值

// 但这个陷阱仍然存在:
watchEffect(() => {
  console.log(list.length);
});

// 以下两种方式的追踪效果不同
list.push(4);    // 触发 length 追踪(length 变化)
list[10] = 99;   // 也会触发 length 追踪(稀疏数组,length 变为 11)

// 但:
const arr = reactive(['a', 'b', 'c']);
watchEffect(() => {
  // 这里访问了 arr 的 length,但没有访问具体元素
  console.log('length:', arr.length);
});
arr[0] = 'A'; // 不触发!因为只追踪了 length,没追踪索引 0

根本原因:Vue 3 追踪的是你实际访问的属性。如果你的 effect 只读取了 arr.length,它只会在 length 变化时重新执行。索引赋值在 length 不变时(原位替换),不会触发只追踪 length 的 effect。这是精确追踪的代价:你追踪什么,就更新什么,没有多余的更新,但也不会帮你追踪你没有访问的东西。


本章小结

  1. Vue 的三次重写各有明确动机:Vue 1 是"Angular 精简版",Vue 2 引入虚拟 DOM 解决大规模更新问题,Vue 3 用 Proxy 解决了 Object.defineProperty 的三个根本限制,同时用 TypeScript 重写提升开发体验。

  2. Object.defineProperty 的三个限制是结构性的,无法通过补丁解决:无法拦截属性新增、属性删除、数组索引赋值,$set/$delete 是 API 层面的变通,不是技术层面的解决。

  3. Proxy 解决的不只是那三个限制:Proxy 的 13 个 trap 覆盖了对象操作的所有基本行为,让 Vue 3 可以追踪 in 操作符、Object.keys()delete 等之前完全无法响应的操作。

  4. Vue 3 的性能提升来自两个正交的优化:运行时的 Proxy 响应式(精确追踪,无需 $set)+ 编译时的 PatchFlag 系统(静态分析减少 diff 工作量),两者叠加产生了初始化 +55%、更新 +133% 的效果。

  5. Vue 与 React 的演进解决了相同的问题,但方式根本不同:Hooks 每次渲染重新执行(闭包陷阱、依赖数组是代价),setup() 只执行一次(没有闭包陷阱,没有依赖数组)。这不是优劣之分,是不同响应式模型的必然结果。

本章评分
4.7  / 5  (112 评分)

💬 留言讨论