Vue中的计算属性,在开发中经常会用到,但是理解它似乎不是那么容易,这篇文章会用尽量简单的代码介绍计算属性的运行过程。
首先讲讲计算属性大概的思路,计算属性其实是一个getter函数,这个getter函数通过Object.defineProperty来定义,当每次试图获取计算属性的值的时候,getter函数就会执行,返回值就是获得的计算属性的值。计算属性的值有可能依赖于data,prop,computed中的其他项,我们要进行一个依赖收集的操作,找出这个计算属性依赖于其他的什么,并订阅依赖项的变化(就是在依赖项的值发生变化时,通知计算属性),在计算属性收到通知后,重新计算值,并缓存,然后触发视图更新。
我做了一个简单的demo,最后的效果是这样的
可以看到,当a和b的值改变的时候,total(a+b)也会改变,随即触发刷新视图的逻辑,我们接下来通过讲解这个demo来理解computed的大致运行过程
依赖收集 这里我们使用一个简单的发布-订阅模式,来对依赖进行收集和通知,下面是Dep类的定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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的依赖是什么呢,先来看一个简单的例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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拦截的具体实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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]会作为订阅者保存的位置,他们的定义如下。
1 2 let depSymbol = Symbol ("depSymbol" );let global = typeof window !== "undefined" ? window : {};
加工计算属性 计算属性其实是一个特殊的getter函数,我们在传入的计算属性,一般是一个函数,那么这个函数需要做一些特殊的处理,转化成getter。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 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 ; })(); }
initComputed会遍历所有的计算属性,然后调用wrapComputed处理这些计算属性,wrapComputed中用闭包保存了一个属性valCache,这就是计算属性的缓存,然后我们新建这个计算属性对应的订阅者对象,设置update函数(数据变动时会执行这个函数并传入对应参数),然后把计算属性设置成访问器属性,这样就可以直接使用vm.total这样的方式获取值,而不是vm.total(),最后我们把订阅者对象设置到global[depSymbol]上,然后执行一次计算属性的计算函数,这样计算属性的依赖就可以得知这个计算属性依赖于自身,并把这个订阅者对象添加到列表里。
全部代码 这是测试用的代码
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 <!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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 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); }) } } 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 ); proxy.a = 10 ; proxy.b = 20 ; proxy.obj .c = 30 ; console .log (extend.computed .total );
这里有些地方上面没有讲到,computedDepMap的属性对应用来保存订阅了某个计算属性的列表,比如在vue中,这样的语法,就说明这里依赖于total这个计算属性, computedDepMap[“total”]是一个Dep,Dep.subs里会保存这个依赖。至于我为什么要直接把它写出来,因为原本这部分在vue里是用模板编译生成AST来完成的,实现比较复杂,这里为了让代码简洁一点就直接写结果了。