开门见山的说,Vue 2.0 响应式原理的精髓就在于 Object.defineProperty, 因此 Vue 不支持 IE8 以下的浏览器。这个方法可以定义一个对象的某个属性的描述符,来具体看一下:

const obj = {
    k: 1
}
let value = 0;
Object.defineProperty(obj, 'k', {
    enumerable: true,   // 该属性是否可以被枚举
    configurable: true,  // 该属性的描述符是否可以修改
    get: function() {
        // 魔法的起源地,每当 obj 的 k 属性的值被读取,都会执行这个方法,Vue 在这里收集依赖
        return value;
    },
    set: function(newVal) {
        // Vue 在这里通知所有依赖,该属性被修改了
        value = newVal;
    }
});

上面的 getset 函数分别就是 obj 对象 k 属性的 gettersetter

看起来很简单,实则还有很多问题:

  1. 依赖是什么?
  2. 依赖存在哪?
  3. 如何收集依赖?

我们一个一个来解决。

依赖是什么

在 Vue 里,依赖是 Watcher 对象的实例,我们先来列举一下 Vue 在什么情况下会创建 Watcher

  1. 一个组件在挂载的时候会为自己创建一个 Watcher,因此每个 Vue 组件都会对应一个 Watcher
  2. 初始化 computed 属性时,会为每一个属性创建一个 Watcher
  3. 初始化 watch 属性时,会为每一个被 watch 的属性创建一个 Watcher
  4. 调用 $watch 方法的时候。

第 3 和第 4 种情况其实是一样的,唯一区别是,手动调用 $watch 会返回一个 unwatch 方法,可以用来取消 watch

接下来,简单了解 Watcher 对象的构造:

class Watcher {
    constructor(vm, expOrFn, cb) {
        this.depIds = new Set();    // 一个集合,存储 Dep 的 id,防止重复收集依赖
        this.deps = [];     // 一个列表,存储以自己为依赖的依赖表
        this.vm = vm;   // 响应式数据的上下文
        if (typeof expOrFn === 'function') {
            this.getter = expOrFn;
        } else {
            this.getter = parsePath(expOrFn);   // parsePath 用于解析 expOrFn,返回一个方法用来读取并返回响应式数据的值
        }
        this.cb = cb;   // 响应式数据更新后触发的回调函数
        this.value = this.get();    // 用 get 方法取得数据,顺便 “摸一下” 响应式数据的 getter 方法,收集依赖
    }

    // 下面的 Dep 是一个类,用于维护响应式数据的依赖表,这里把 Watcher 自身添加到 Dep 的静态成员变量上,然后执行 this.getter 方法
    // this.getter 在上面介绍过,会读取并返回响应式数据的值
    get() {
        Dep.target = this;
        let value = this.getter.call(this.vm, this.vm);
        Dep.target = undefined;
        return value;
    }

    // addDep 方法会在 Dep 的 depend 方法中被调用,Dep 把自身作为参数传进来
    // 简单判断没有重复收集之后,调用 Dep 的 addSub 方法来让 Dep 实例收集这个 Watcher
    // 正是这里的交叉传递自身,让 Watcher 拥有把自己添加进响应式数据依赖表中的能力。
    addDep(dep) {
        const id = dep.id;
        if (!this.depIds.has(id))
            this.depIds.add(id);
            this.deps.push(dep);
            dep.addSub(this);
        }
    }

    // 响应式数据更新后会通知所有的依赖(Watcher)执行该方法。
    // 方法内部会调用 Watcher 的回调函数,并传入新旧值作为参数。
    update() {
        const oldVal = this.value;
        this.value = this.get();
        this.cb.call(this.vm, this.value, oldVal);
    }
}

上面代码中 this.getter 的值是 parsePath(expOrFn) 返回的。下面来了解一下 parsePath 的原理:

// 解析简单路径,路径通常的模样为:'a.b.c'
const bailRE = /[^\w.$]/;
function parsePath(path) {
    if (bailRE.test(path)) {
        return;
    }
    const segments = path.split('.');
    return function(obj) {
        for (let i = 0; i < segments.length; i++) {
            if (!obj) return;
            obj = obj[segments[i]];
        }
        return obj;
    }
}

可以看到,parsePath 的返回值是一个函数,这个函数有个特点,就是会 “Touch”,摸一下 objpath 路径下的对象,这会触发对象的 getter,从而把 Watcher 自身添加进对象的依赖表里。

依赖存在哪?

在 Vue 里,每一个响应式数据都有各自的 Dep 对象,你可以这样得到它 data.__ob__.depDep 对象的结构很简单,就是一个维护依赖表的类:

let uid = 0;
class Dep {
    constructor() {
        this.id = uid++;    // Dep 的 id
        this.subs = []; // 一个列表用来存储依赖(Watcher)
    }

    // 添加一个依赖,sub 就是 Watcher 实例
    addSub(sub) {
        this.subs.push(sub);
    }

    // 故名思议,移除一个依赖
    removeSub(sub) {
        remove(this.subs, sub);
    }

    // 把 Dep.target 添加进依赖表,Dep.target 就是 Watcher
    depend() {
        if (Dep.target) {
            Dep.target.addDep(this);
        }
    }

    // 通知所有依赖触发更新,可以看到,更新是通过调用 Watcher 对象的 update 方法进行的
    notify() {
        const subs = this.subs.slice();
        for (let i = 0, l = subs.length; i < l; i++) {
            subs[i].update();
        }
    }
}

如何收集依赖

每一个响应式数据的依赖都可能是不同的,因此他们都应该有自己的依赖表。

上面提到,依赖是 WatcherWatcher 在初始化的时候会把自己赋值到 Dep.target,然后触发响应式数据的 getter

而收集依赖的方法是在响应式数据的 getter 中把 Dep.target 添加进依赖表,再结合上面 Dep 类的结构可以知道,收集依赖就是调用 Dep 对象的 depend 方法:

// 构造一个函数来把数据变成响应式数据
function defineReactive(data, key, val) {
    const dep = new Dep();
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            // 收集依赖
            dep.depend();
            return val;
        },
        set: function(newVal) {
            val = newVal;
            // 通知s所有收集到的依赖
            for (let i = 0; i < dep.length; i++) {
                dep[i].notify();
            }
        }
    });
}

汇总

一个完整的依赖收集流程是这样的:

  1. 创建 Watcher 实例,初始化时访问响应式数据的值,把自身赋值给 Dep 类的静态变量 target,触发 getter
  2. getter 中能访问到响应式数据的依赖表维护对象 dep,调用 depdepend 方法。
  3. depend 方法会把 Dep 实例自身作为参数传给 Watcher 实例的 addDep 方法。
  4. addDep 方法会把参数中的 Dep 实例收集起来,并把自身作为参数传给 Dep 实例的 addSub 方法。
  5. addSub 方法会把参数中的 Watcher 实例作为依赖收集起来。

最后来看一下 Vue 官方提供的响应式原理图:

Reactivity

稍微解释一下,Vue 会把模板编译成代码字符串,运行时渲染函数会执行代码字符串生成虚拟 DOM,在生成虚拟 DOM 的过程中会读取许多响应式数据,读取值会触发对应的 gettergetter 会把组件对应的 Watcher 收集到依赖表中,每当修改的响应式数据的值都会触发 settersetter 会通知依赖表中的每一个 Watcher,接到通知后,Watcher 对应的回调函数会被执行,对应的组件就会重新生成虚拟 DOM

看到这里你也许会注意到,响应式的原理是在 getter 中收集依赖,当调用操作数组的原型方法或者使用索引取值的时候,是不会触发 getter 的,这时响应式会失效。为避免这种情况,Vue 的做法是把数组和对象分开进行不同的响应式处理,这就不是本文所要讲的内容了。

最后,我们学习一个框架,了解源码不是必需的,确是必要的,这样在遇到疑难杂症的时候就能知道是框架本身的机制,还是代码逻辑的问题。除此之外,Vue 本身的设计思路和编码方式其实也是一个很好的学习对象。