vue早期源码学习系列之六:如何实现计算属性

@youngwind 2016-09-09 12:26:19发表于 youngwind/blog Vue

前言

vue中有一个非常好用的功能:计算属性(computed)

在模板中绑定表达式是非常便利的,但是它们实际上只用于简单的操作。模板是为了描述视图的结构。在模板中放入太多的逻辑会让模板过重且难以维护。这就是为什么 Vue.js 将绑定表达式限制为一个表达式。如果需要多于一个表达式的逻辑,应当使用计算属性。
你可以像绑定普通属性一样在模板中绑定计算属性。Vue 知道 vm.b 依赖于 vm.a,因此当 vm.a 发生改变时,依赖于 vm.b 的绑定也会更新。

来源:https://vuejs.org.cn/guide/computed.html

问题

我们先来具象化一下问题。

// html
<div id="app">
    <p>姓名:{{user.name}}</p>
    <p>年龄: {{user.age}}</p>
    <p>{{info}}</p>
</div>
// js
const app = new Bue({
    el: '#app',
    data: {
        user: {
            name: 'youngwind',
            age: 24
        }
    },
    computed: {
        info: function () {
            return `计算出来的属性-> 姓名: ${this.user.name}, 年龄: ${this.user.age}`;
        }
    }
});

问题是:如何让info跟着name和age动态改变呢?

我们把这个问题拆解成两个更小的问题,然后逐个击破。

  1. 如何根据name和age计算出info?
  2. 当name或者age改变的时候,重新进行第一步的计算。

静态计算属性

ok,我们先来解决第一个问题。(先把第二个问题放一边)

/**
 * 初始化所有计算属性
 * 主要完成一个功能:将计算属性定义的function当成是该属性的getter函数
 * @private
 */
exports._initComputed = function () {
    // 注意,这里的this指的是bue实例
    let computed = this.$options.computed;
    if (!computed) return;
    for (let key in computed) {
        let def = computed[key];
        if (typeof def === 'function') {
            def = {
                get: def
            };
            def.enumerable = true;
            def.configurable = true;
            Object.defineProperty(this.$data, key, def);
        }
    }
};

关键点说明:

  1. info后面跟的是一个有return值的函数,我们直接把这个函数当成info的getter函数。
  2. 虽然info是计算出来的属性,但是到时候对DOM模板进行遍历的时候也是需要用到它的,所以需要将info定义到$data里面去。

实现效果如下图所示。
bug

从图中我们可以看到,$data里面的info已经有值,并且DOM模板里面的{{info}}也已经正确解析了。
第一个问题解决了,但是,我们同时也看到,当我们改变name和age的时候,info并不会跟着改变!!
下面我们来看看怎么解决这第二个问题。

动态计算属性

动态计算难在什么地方?
难在:当name或者age改变的时候,程序如何知道要改变info?
你可能会说,这不明摆着吗,一眼就看出来。
然而,程序不知道啊!我们拆解一下问题。

  1. 如何让程序知道info依赖于name和age。-> 如何收集计算属性的依赖属性
  2. 当name和age改变的时候,更新info对应的DOM元素

第2个问题好办,因为我们在《如何实现动态数据绑定》 #87 的时候就已经建立起一套完整的Binding、Watcher和Directive的体系。我们只需要把info指令分别push到wathcer name和wathcer age的_subs里面不就可以了,在这儿就不细说了。
我们重点看第一个问题。
解决思路还是从getter入手:定义info的function被当成了getter,那么当我们访问this.$data.info的时候,就会调用这个function。这个function又会去访问this.user.name和this.user.age,这意味着什么呢?
这意味着会去执行name和age的getter函数啊!所以我们可以自定义name和age的getter函数,让它做一些特殊的事情。
那要做什么事情呢?我们触发(notify)一个get事件,然后这个get事件会传播到$data顶层。我们在$data顶层注册一个colletDep(收集依赖)函数,这样我们不就能知道info依赖于user.name和user.age了吗?
嗯,没错,大概思路就是这样。下面展示部分关键代码,完整的代码可以参考这里

function Watcher(vm, expression, cb, ctx) {
    this.id = ++uid;
    this.vm = vm;
    this.expression = expression;
    this.cb = cb;
    this.ctx = ctx || vm;
    this.deps = Object.create(null);

    // 这里的getter可以不去细究,其实就是根据expression(比如user.name)
    // 拼接出它对应的函数,当成getter
    // 你完全可以理解为调用this.getter()方法其实就是为了得到user.name的值
    this.getter = expParser.compileGetter(expression);

    this.initDeps(expression);
}
/**
 * 要注意,这里的getter.call是完成计算属性的核心,
 * 因为正是这里的getter.call, 执行了该计算属性的getter方法,
 * 从而执行该计算属性所依赖的其他属性的get方法
 * 从而发出get事件,冒泡到底层, 触发collectDep事件
 * @param path {String} 指令表达式对应的路径, 例如: "user.name"
 */
Watcher.prototype.initDeps = function (path) {
    this.addDep(path);
    Observer.emitGet = true;
    this.vm._activeWatcher = this;

    // 就是在这儿调用info的getter,进而调用name和age的getter
    // 进入触发和传播get事件
    this.value = this.getter.call(this.vm, this.vm.$data);

    Observer.emitGet = false;
    this.vm._activeWatcher = null;
};
/**
 * 收集依赖。
 * 为什么需要这个东西呢?
 * 因为在实现computed计算属性功能的过程中,
 * 发现程序需要知晓计算出来的属性到底依赖于哪些原先就有的属性
 * 这样才能做到在对应原有的属性的_subs数组中添加新属性指令的watcher事件
 * @param path {String} get事件传播到顶层时的路径,比如"user.name"
 * @private
 */
exports._collectDep = function (event, path) {
    let watcher = this._activeWatcher;
    if (watcher) {
        watcher.addDep(path);
    }
};

// 看,就是在这儿给$data顶层注册收集依赖的事件的
this.observer.on('set', this._updateBindingAt.bind(this))
        .on('get', this._collectDep.bind(this));

有两个小细节务必要注意:

  1. Observer构造函数有一个emitGet属性,默认为false。当emitGet为true时,代表调用属性的getter会触发并且传播get事件,当emitGet为false时,则不会触发。只有在初始化构造Binding才将它开启,因为不能任意时候调用属性getter都触发这个get事件,这个get事件只是我们在收集依赖才需要的。
  2. Watcher实例有一个dep字段,它的作用是为了避免重复收集依赖。如果没有他,那么在当前例子的情况下,user的bingding._subs里面会出现两个user的watcher,name亦然。原因是我们在计算属性的时候再次读取了user和name。为了避免这种情况,需要引入dep,记录下:“哦,原来程序刚刚已经分析过这里了,忽略就好。”

最后构造出来的_rootBinding数据结构如下图所示。
data

具体的实现效果如下图所示,完整的代码参考这里
demo

---EOF---