简单说说计算属性的原理

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}}这样的语法,就说明这里依赖于total这个计算属性, computedDepMap["total"]是一个Dep,Dep.subs里会保存这个依赖。至于我为什么要直接把它写出来,因为原本这部分在vue里是用模板编译生成AST来完成的,实现比较复杂,这里为了让代码简洁一点就直接写结果了。

点赞

发表评论

昵称和uid可以选填一个,填邮箱必填(留言回复后将会发邮件给你)
tips:输入uid可以快速获得你的昵称和头像