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来完成的,实现比较复杂,这里为了让代码简洁一点就直接写结果了。