Vue2 源码简介

响应式原理:Object.defineProperty 与 Observer

掌握 Vue2 响应式的核心实现,理解 defineReactive、Observer 与数组方法拦截的工作原理。

🎯 引言

「修改 data 里的数据,页面自动更新」是 Vue2 给我们最直接的印象。这篇我们就打开这个黑盒,看看它是怎么做到的。学完之后,你能够:

  • 说清楚 Object.defineProperty 是如何拦截数据的读写的。
  • 理解 defineReactiveObserver 和数组方法拦截的分工。
  • 手写一个迷你响应式系统,在数据被读取和修改时打印日志。
  • 知道 Vue2 响应式的两个经典局限,以及用 Vue.set 解决的方法。

本篇源码基于 Vue 2.7.16(GitHub vuejs/vue 仓库),主要涉及 src/core/observer/index.jssrc/core/observer/array.js。示例代码可以直接用 Node.js v22 运行。


🧱 什么是响应式

响应式就是:数据一变,用到这个数据的地方自动跟着变。

生活化一点说,它像我们订的报纸。你向报社登记「我要订这份报」(依赖收集),报社每天出新报纸(数据变化)就主动送到你家(触发更新)。你不需要每天跑去报社问「今天出新报了吗」。

在 Vue2 里,「报社」就是我们写在 data 里的数据。Vue 会悄悄改造这些数据:读它的时候登记一次「谁在用」,改它的时候通知所有使用者更新。这一「登记」一「通知」,靠的就是 Object.defineProperty

如果你学过 Vue2 课程,可以回想一下:我们在 data 里声明的字段,模板里改了输入框、页面上立刻变,背后就是这套机制在工作。


🧱 Object.defineProperty 基础回顾

Object.defineProperty 可以给对象的某个属性定义 gettersetter。当别人读取这个属性时会走 getter,修改时会走 setter,我们就有机会在中间插入自己的逻辑。

先写一个能跑的小例子感受一下:

demo.js
const user = {};
let name = 'noxussj';

Object.defineProperty(user, 'name', {
    get() {
        console.log('读取了 name');
        return name;
    },
    set(newVal) {
        console.log('修改了 name:', newVal);
        name = newVal;
    },
});

user.name; // 打印:读取了 name
user.name = '小明'; // 打印:修改了 name: 小明

用 Node.js v22 运行 node demo.js,就能看到读取和修改都被我们拦截下来了。

真正的数据保存在局部变量 name 里,user.name 只是一个「入口」。getter/setter 通过闭包记住了这个变量,所以读写都能拿到正确的值。

能拦截读写,就有了实现响应式的基础:在 getter 里登记使用者(依赖收集),在 setter 里通知更新


🧱 defineReactive:把一个属性变成响应式

Vue2 把「给单个属性加 getter/setter」封装成了 defineReactive 函数。下面是教学简化版,去掉了 Flow 类型注解和边界分支,保留核心逻辑:

// 简化自 src/core/observer/index.js 的 defineReactive
function defineReactive(obj, key, val) {
    // 嵌套对象也要递归变成响应式
    observe(val);

    Object.defineProperty(obj, key, {
        get() {
            // 依赖收集:登记「谁在用这个属性」
            // 细节下一篇讲,这里先占位:dep.depend()
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            // 新赋的值如果是对象,也要变成响应式
            observe(newVal);
            // 触发更新:通知视图重新渲染,下一篇细讲
            // dep.notify()
            console.log(`触发更新:${key} 变成了`, newVal);
        },
    });
}

注意三个要点:

  • 递归观测:调 observe(val),保证 user.info.name 这种深层属性也是响应式的。
  • get 里收集依赖:谁在渲染时读了这个属性,就把它记下来。具体怎么记,下一篇「依赖收集与 Watcher」会展开。
  • set 里触发更新:值变了就通知视图。同时对新值再调一次 observe,防止新赋的对象「漏网」。

🧱 Observer 类:遍历整个对象

defineReactive 一次只能处理一个 key,而 data 往往是一整个对象。Observer 类负责遍历对象的每个 key,逐个调用 defineReactive

// 简化自 src/core/observer/index.js 的 Observer 类
class Observer {
    constructor(data) {
        // 给数据打上 __ob__ 标记,表示「已观测」,避免重复处理
        Object.defineProperty(data, '__ob__', {
            value: this,
            enumerable: false, // 不可枚举,遍历时不会出现
        });

        if (Array.isArray(data)) {
            // 数组走特殊处理,下一节讲
            protoAugment(data, arrayMethods);
            this.observeArray(data);
        } else {
            // 对象:遍历每个 key,逐个响应式化
            this.walk(data);
        }
    }

    walk(data) {
        Object.keys(data).forEach((key) => {
            defineReactive(data, key, data[key]);
        });
    }

    observeArray(items) {
        items.forEach((item) => observe(item));
    }
}

function observe(data) {
    if (typeof data !== 'object' || data === null) return;
    // 已经观测过(有 __ob__)就直接复用,不再重复处理
    if (data.__ob__) return data.__ob__;
    return new Observer(data);
}

这里的 __ob__ 是一个重要的细节:

  • 标记作用:看到数据身上有 __ob__,就知道它已经被观测过,直接跳过,避免重复包一层 getter/setter。
  • enumerable: false:让 __ob__ 不出现在 Object.keysfor...in 的结果里,也不会参与后续的递归观测,防止干扰业务数据和造成死循环。

把对象交给 observe 之后,它身上的每一层属性都被装上了 getter/setter。读取时收集依赖,修改时触发更新,对象的响应式就完成了。


🧱 数组的特殊处理:重写七个方法

对象可以靠 defineReactive 逐 key 拦截,但数组不行。如果给一个有几千个元素的数组逐下标加 getter/setter,性能开销太大,而且通过下标赋值是数组的高频操作,拦截收益很低。

Vue2 换了个思路:数组的内容变化,几乎都是通过 pushsplice 这些方法触发的,重写这些方法就够了。 具体做法是创建一个以数组原型为原型的新对象,在新对象上重写七个会改变原数组的方法,再把这个新对象设为响应式数组的原型:

// 简化自 src/core/observer/array.js
const arrayProto = Array.prototype;
// 以数组原型为原型,创建一个新对象
const arrayMethods = Object.create(arrayProto);

// 七个会改变原数组的方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

methodsToPatch.forEach((method) => {
    const original = arrayProto[method];

    arrayMethods[method] = function (...args) {
        // 1. 先调用原方法,保证数组行为不变
        const result = original.apply(this, args);

        // 2. 对 push / unshift / splice 新插入的元素做观测
        let inserted;
        if (method === 'push' || method === 'unshift') {
            inserted = args;
        } else if (method === 'splice') {
            inserted = args.slice(2); // splice 第三个参数起是新增元素
        }
        if (inserted) {
            inserted.forEach((item) => observe(item));
        }

        // 3. 通知视图更新(同样先占位,下一篇细讲)
        // ob.dep.notify()
        console.log('数组变化了,触发更新');
        return result;
    };
});

// 把响应式数组的原型指向 arrayMethods
function protoAugment(data, proto) {
    Object.setPrototypeOf(data, proto);
}

用一个比喻帮助理解:普通数组的原型链是 数组 -> Array.prototype。改造后变成 数组 -> arrayMethods -> Array.prototype。调用 list.push() 时,先命中 arrayMethods 上重写的版本,执行完拦截逻辑后,再转发给 Array.prototype 上的原版方法。

这样设计带来两个能力:

  • 拦截变更:调 list.push(4) 能触发更新,因为 push 被我们重写过。
  • 新增元素也是响应式的push 进去一个对象,会被 observe 处理,之后改它的属性照样能触发更新。

没有改动数组行为本身,list.lengthfor 循环这些照常工作,只是原型链上多了一层「哨兵」。


⚠️ 两个经典局限

defineProperty 这套方案有一个硬伤:它只能拦截对象上「已经存在」的 key。这导致了两个经典局限,实际开发中很容易踩到。

局限一:对象新增属性不触发响应

data() {
    return {
        user: { name: 'noxussj' },
    };
},
mounted() {
    // user 上没有 age 这个 key,新增它不会触发更新
    this.user.age = 18;
},

原因是 walk 只在初始化时遍历了已有的 key,age 是后来才出现的,身上没有 getter/setter,Vue 感知不到。

解决办法是用 Vue.set(组件内是 this.$set),它会手动把新属性变成响应式并触发更新:

this.$set(this.user, 'age', 18);

局限二:数组通过下标赋值或改 length 不触发响应

// 这两种写法都不会触发更新
this.list[0] = '新值';
this.list.length = 0;

上一节说过,数组的下标没有被逐个拦截(性能考虑),所以直接按下标赋值绕开了所有拦截逻辑,length 也不是被重写的七个方法之一。

解决办法同样用 $set,或者用被重写过的 splice

this.$set(this.list, 0, '新值');
// 或者
this.list.splice(0, 1, '新值');

// 清空数组
this.list.splice(0);
这两个局限是 Vue2 响应式的「知名陷阱」:视图不更新但数据明明变了,往往就是这两种写法导致的。遇到「新增属性」或「数组下标赋值」,请改用 this.$setsplice。Vue3 改用 Proxy 重写响应式后,这两个问题就不存在了。

🧾 小节总结

  • 响应式的核心:读取时收集依赖,修改时触发更新,读写都靠 Object.defineProperty 拦截。
  • defineReactive 把单个属性变成响应式,并对嵌套对象递归 observe
  • Observer 遍历对象的每个 key 调 defineReactive,并用不可枚举的 __ob__ 标记已观测的数据。
  • 数组不拦截下标,而是重写 push 等七个方法:先调原方法,再观测新增元素,最后触发更新。
  • 两个经典局限:对象新增属性、数组下标赋值或改 length 都不触发更新,用 $setsplice 解决。

❓ 知识问答

Q1:为什么 Vue2 不拦截数组的下标?

A:主要是性能考虑。数组可能有成百上千个元素,逐个下标加 getter/setter 开销大,而下标赋值又不是高频操作。重写七个变更方法,就能覆盖绝大多数数组修改场景,性价比更高。

Q2:__ob__ 为什么要设置成不可枚举?

A:如果可枚举,Object.keysfor...in 都会多出一个 __ob__,污染业务数据;更严重的是递归观测时会再次碰到它,可能造成死循环。设成 enumerable: false 后它就「隐身」了,只作为标记存在。

Q3:push 进去的新对象,改它的属性能触发更新吗?

A:能。重写的 push 里会对新插入的元素调 observe,新对象同样被装上了 getter/setter,所以后续修改它的属性照常触发更新。

Q4:Vue3 是怎么解决这两个局限的?

A:Vue3 用 Proxy 替代 Object.definePropertyProxy 拦截的是整个对象而不是单个 key,新增属性、数组下标赋值、改 length 都能被拦截到,这两个局限自然消失。


🧪 小练习

不查资料,补全下面两个函数,实现一个迷你响应式系统:数据被读取时打印「读取了 xxx」,被修改时打印「xxx 修改为:新值」。用 Node.js v22 保存为 .js 文件直接运行验证。

function defineReactive(obj, key, val) {
    // 请在这里编写代码
}

function observe(obj) {
    // 请在这里编写代码(提示:遍历 obj 的每个 key)
}

const data = { name: 'noxussj', age: 18 };
observe(data);

// 期望输出:
// 读取了 name
// name 修改为: 小明
// 读取了 age
console.log(data.name);
data.name = '小明';
console.log(data.age);

完成后给自己加个挑战:如果 data 里再嵌套一层对象(如 info: { city: '杭州' }),你的实现能让 data.info.city 也打印日志吗?需要改动哪里?


🎉 恭喜你已经掌握 Vue2 响应式的核心原理啦!下一篇我们接着讲依赖收集与 Watcher,看看「谁在用这个数据」到底是怎么被登记下来的,以及更新是如何精确到具体节点的。