Vue 中的计算属性,在开发中经常会用到,但是理解它似乎不是那么容易,这篇文章会用尽量简单的代码介绍计算属性的运行过程。

首先讲讲计算属性大概的思路,计算属性其实是一个 getter 函数,这个 getter 函数通过 Object.defineProperty 来定义,当每次试图获取计算属性的值的时候,getter 函数就会执行,返回值就是获得的计算属性的值。计算属性的值有可能依赖于 data,prop,computed 中的其他项,我们要进行一个依赖收集的操作,找出这个计算属性依赖于其他的什么,并订阅依赖项的变化(就是在依赖项的值发生变化时,通知计算属性),在计算属性收到通知后,重新计算值,并缓存,然后触发视图更新。

我做了一个简单的 demo,最后的效果是这样的

可以看到,当 a 和 b 的值改变的时候,total(a+b)也会改变,随即触发刷新视图的逻辑,我们接下来通过讲解这个 demo 来理解 computed 的大致运行过程

# 依赖收集

这里我们使用一个简单的发布 - 订阅模式,来对依赖进行收集和通知,下面是 Dep 类的定义。

class Dep {
    constructor() {
        // 初始化订阅者列表
        this.subs = new Set();
    }
    // 添加订阅者
    addSub(sub) {
        !this.subs.has(sub) && this.subs.add(sub);
    }
    // 通知所有订阅者数据变动
    notify(val) {
        this.subs.forEach((sub) => {
            sub.update(val);
        })
    }
}

那么 computed 的依赖是什么呢,先来看一个简单的例子

let extend = {
    data : {
        a : 1,
        b : 2,
        obj : {
            c : 3
        }
    },
    computed : {
        total() {
            let a = this.a;
            let b = this.b;
            let c = this.obj.c;
            return a + b + c;
        }
    }
};

这里的 total 是根据 data 里的 a,b 和 c 计算出来的,所以 total 的依赖就是 data 里的 a,b,c,那么我们要做的是就是把 total 让 total 作为订阅者,订阅 a 和 b 的数据变动,这样当 a 和 b 和 c 的数据发生变化时,total 就能及时得到通知,并且更新它的值。

要是想得知 a 或 b 或 c 的数据变动,有两种办法,一个是使用 Object.defineProperty 定义属性的 set 和 get 方法,另一个是用 proxy 拦截 get 和 set 方法,这里为了方便演示(简化代码),我们使用 proxy,如果你不了解 proxy,可以看看这里

我们来看看 proxy 拦截的具体实现

function observe(obj) {
    if (typeof obj !== "object") return;
    let proxy = getProxyObj(obj);
    Object.keys(proxy).forEach((key) => {
        if (typeof proxy[key] === "object") {
            proxy[key] = observe(proxy[key]);
        }
    });
    return proxy;
}
function getProxyObj(obj) {
    let map = {};
    let proxy = new Proxy(obj, {
        get(target, key, proxy) {
            if (global[depSymbol]) {
                let dep = map[key] ? map[key] : (map[key] = new Dep());
                dep.addSub(global[depSymbol]);
            }
            return target[key];
        },
        set(target, key, value, proxy) {
            target[key] = value;
            map[key] && map[key].notify(value);
        }
    });
    return proxy;
}

我们在把一个对象传入 observe 方法之后,observe 方法会先生成一个这个对象的 proxy,然后遍历这个对象的属性,如果属性的值是一个对象,则递归调用 observe,最后代理整个对象。

代理一个对象的方法是 getProxyObj,这个方法返回的代理,拦截了访问对象属性时的 get 和 set 方法。那么 get 和 set 方法分别做了什么呢。

在 get 中,对象除了会正常返回属性的值,还会检查全局上的一个属性 global [depSymbol] 是否为空,为什么要去检查这个位置呢,其实这个位置放置的就是依赖于当前属性的值(当然不是真的值,是一个订阅者对象,但是这个订阅者对象是根据值的一些信息生成的),如果我们在计算 计算属性的值时候,提前把计算属性对应的订阅者放到这个位置,那么在计算属性计算的时候,必然会触发它所依赖的属性的 get 方法,从而被依赖的属性就能知道某个计算属性依赖于自身,从而把它添加到订阅者列表里。

在 set 方法中,除了会重新设定属性的值,还会检查有没有订阅了当前属性变化的订阅者,如果有,通知他们属性已经变化。也就是在被通知后,计算属性会重新计算它的值。

这里出现了一些陌生的变量,depSymbol 和 global,global 就是全局,任何地方都能访问,depSymbol 是个 symbol,global [depSymbol] 会作为订阅者保存的位置,他们的定义如下。

let depSymbol = Symbol("depSymbol");
let global = typeof window !== "undefined" ? window : {};

# 加工计算属性

计算属性其实是一个特殊的 getter 函数,我们在传入的计算属性,一般是一个函数,那么这个函数需要做一些特殊的处理,转化成 getter。

function initComputed(obj, bindThis) {
    if (typeof obj.computed !== "object") return;
    Object.keys(obj.computed).forEach((key) => {
        // 处理每一个计算属性
        if (typeof obj.computed[key] === "function") {
            wrapComputed(obj.computed, key, bindThis)
        }
    })
}
function wrapComputed(computedObj, key, bindThis) {
    //obj 是订阅者对象
    let obj = {};
    // 计算属性值的缓存
    let valCache = undefined;
    // 计算 计算属性的源函数
    let originFun = computedObj[key];
    // 给订阅者对象设置 update 函数
    obj.update = function () {
        // 重新计算计算属性的值
        valCache = originFun.call(bindThis);
        // 通知所有的订阅者数据更新
        // 订阅者可能是视图或者是其他值
        computedDepMap[key].notify(valCache);
    }.bind(bindThis);
    // 把计算属性定义成访问器属性
    Object.defineProperty(computedObj, key, {
        get() {
            return valCache;
        },
        set(v) {
            valCache = v;
        }
    });
    // 计算一次计算属性的值
    // 计算前先把计算属性对应的订阅者放到全局置顶位置
    // 方便被收集
    (function () {
        global[depSymbol] = obj;
        valCache = originFun.call(bindThis);
        global[depSymbol] = undefined;
    })();
}

initComputed 会遍历所有的计算属性,然后调用 wrapComputed 处理这些计算属性,wrapComputed 中用闭包保存了一个属性 valCache,这就是计算属性的缓存,然后我们新建这个计算属性对应的订阅者对象,设置 update 函数(数据变动时会执行这个函数并传入对应参数),然后把计算属性设置成访问器属性,这样就可以直接使用 vm.total 这样的方式获取值,而不是 vm.total (),最后我们把订阅者对象设置到 global [depSymbol] 上,然后执行一次计算属性的计算函数,这样计算属性的依赖就可以得知这个计算属性依赖于自身,并把这个订阅者对象添加到列表里。

# 全部代码

这是测试用的代码

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="proxy.js" defer></script>
</head>
<body>
    <div id="app">
        
    </div>
</body>
</html>

proxy,js

let depSymbol = Symbol("depSymbol");
let global = typeof window !== "undefined" ? window : {};
class Dep {
    constructor() {
        this.subs = new Set();
    }
    addSub(sub) {
        !this.subs.has(sub) && this.subs.add(sub);
    }
    notify(val) {
        this.subs.forEach((sub) => {
            sub.update(val);
        })
    }
}
// 保存依赖于某个计算属性(比如视图或者其他计算属性)Dep
let computedDepMap = {};
computedDepMap["total"] = new Dep();
computedDepMap.total.addSub({
    update(val) {
        document.getElementById("app").innerText =  `a + b + c = ${val}\na = ${proxy.a}\nb = ${proxy.b}\nc = ${proxy.obj.c}`;
    }
});
function observe(obj) {
    if (typeof obj !== "object") return;
    let proxy = getProxyObj(obj);
    Object.keys(proxy).forEach((key) => {
        if (typeof proxy[key] === "object") {
            proxy[key] = observe(proxy[key]);
        }
    });
    return proxy;
}
function getProxyObj(obj) {
    let map = {};
    let proxy = new Proxy(obj, {
        get(target, key, proxy) {
            if (global[depSymbol]) {
                let dep = map[key] ? map[key] : (map[key] = new Dep());
                dep.addSub(global[depSymbol]);
            }
            return target[key];
        },
        set(target, key, value, proxy) {
            target[key] = value;
            map[key] && map[key].notify(value);
        }
    });
    return proxy;
}
function initComputed(obj, bindThis) {
    if (typeof obj.computed !== "object") return;
    Object.keys(obj.computed).forEach((key) => {
        if (typeof obj.computed[key] === "function") {
            wrapComputed(obj.computed, key, bindThis)
        }
    })
}
function wrapComputed(computedObj, key, bindThis) {
    let obj = {};
    let valCache = undefined;
    let originFun = computedObj[key];
    obj.update = function () {
        valCache = originFun.call(bindThis);
        computedDepMap[key].notify(valCache);
    }.bind(bindThis);
    Object.defineProperty(computedObj, key, {
        get() {
            return valCache;
        },
        set(v) {
            valCache = v;
        }
    });
    (function () {
        global[depSymbol] = obj;
        valCache = originFun.call(bindThis);
        global[depSymbol] = undefined;
    })();
}
let extend = {
    data : {
        a : 1,
        b : 2,
        obj : {
            c : 3
        }
    },
    computed : {
        total() {
            let a = this.a;
            let b = this.b;
            let c = this.obj.c;
            return a + b + c;
        }
    }
};
let proxy = observe(extend.data);
initComputed(extend, proxy);
console.log(extend.computed.total);          // 6
proxy.a = 10;
proxy.b = 20;
proxy.obj.c = 30;
console.log(extend.computed.total);          // 60

这里有些地方上面没有讲到,computedDepMap 的属性对应用来保存订阅了某个计算属性的列表,比如在 vue 中, 这样的语法,就说明这里依赖于 total 这个计算属性, computedDepMap ["total"] 是一个 Dep,Dep.subs 里会保存这个依赖。至于我为什么要直接把它写出来,因为原本这部分在 vue 里是用模板编译生成 AST 来完成的,实现比较复杂,这里为了让代码简洁一点就直接写结果了。