• 基于vue2.0原理-自己实现MVVM框架之computed计算属性


    基于上一篇data的双向绑定,这一篇来聊聊computed的实现原理及自己实现计算属性。

    一、先聊下Computed的用法

    写一个最简单的小demo,展示用户的名字和年龄,代码如下:

    <body>
      <div id="app">
        <input type="text" v-model="name"><br/>
        <input type="text" v-model="age"><br/>
        {{NameAge}}
      div>
      <script>
        var vm = new MYVM({
          el: '#app',
          data: {
            name: 'James',
            age:18
          },
          computed:{
            NameAge(){
              return this.$data.name+" "+this.$data.age;
            }
          },
        })
      script>
    body>
    

    运行结果:

    iShot_2022-07-16_17.38.21

    从代码和运行效果可以看出,计算属性NameAge依赖于data的name属性和age属性。

    特点:

    1、计算属性是响应式的

    2、依赖其它响应式属性或计算属性,当依赖的属性有变化时重新计算属性

    3、计算结果有缓存,组件使用同一个计算属性,只会计算一次,提高效率

    4、不支持异步

    适用场景:

    当一个属性受多个属性影响时就需要用到computed

    例如:购物车计算价格
    只要购买数量,购买价格,优惠券,折扣券等任意一个发生变化,总价都会自动跟踪变化。

    二、原理分析

    1、 computed 属性解析

    每个 computed 属性都会生成对应的观察者(Watcher 实例),观察者存在 values 属性和 get 方法。computed 属性的 getter 函数会在 get 方法中调用,并将返回值赋值给 value。初始设置 dirty 和 lazy 的值为 true,lazy 为 true 不会立即 get 方法(懒执行),而是会在读取 computed 值时执行。

    function initComputed(vm, computed) {    
    		// 存放computed的观察者
        var watchers = vm._computedWatchers = Object.create(null);    
        //遍历computed属性
        for (var key in computed) {        
            //获取属性值,值可能是函数或对象
            var userDef = computed[key];        
            //当值是一个函数的时候,把函数赋值给getter;当值是对象的时候把get赋值给getter
            var getter = typeof userDef === 'function' ? userDef: userDef.get;      
          
            // 每个 computed 都创建一个 watcher
            // 创建watcher实例 用来存储计算值,判断是否需要重新计算
            watchers[key] = new Watcher(vm, getter, { 
                 lazy: true 
            });        
    
            // 判断是否有重名的属性
            if (! (key in vm)) {
                defineComputed(vm, key, userDef);
            }
        }
    }
    

    代码中省略不需要关心的代码,在initComputed中,Vue做了这些事情:

    1. 为每一个computed建立了watcher。

    2. 收集所有computed的watcher,并绑定在Vue实例的_computedWatchers 上。

    3. defineComputed 处理每一个computed。

    2、将computed属性添加到组件实例上

    function defineComputed(target, key, userDef) {    
        // 设置 set 为默认值,避免 computed 并没有设置 set
        var set = function(){}      
        //  如果用户设置了set,就使用用户的set
        if (userDef.set) set = userDef.set   
        Object.defineProperty(target, key, {        
            // 包装get 函数,主要用于判断计算缓存结果是否有效
            get:createComputedGetter(key),        
            set:set
    
        });
    }
    
    // 重定义的getter函数
    function createComputedGetter(key) {
        return function computedGetter() {
            var watcher = this._computedWatchers && this._computedWatchers[key];
            if (watcher) {
                if (watcher.dirty) {
                    // true,懒执行
                    watcher.evaluate(); // 执行watcher方法后设置dirty为false
                }
                if (Dep.target) {
                    watcher.depend();
                }
                return watcher.value; //返回观察者的value值
            }
        };
    }
    
    1. 使用 Object.defineProperty 为实例上computed 属性建立get、set方法。

    2. set 函数默认是空函数,如果用户设置,则使用用户设置。

    3. createComputedGetter 包装返回 get 函数。

    3、页面初始化时

    页面初始化时,会读取computed属性值,触发重新定义的getter,由于观察者的dirty值为true,将会调用原始的getter函数,当getter方法读取data数据时会触发原始的get方法(数据劫持中的get方法),将computed对应的watcher添加到data依赖收集器(dep)中。观察者的get方法执行完后,更新观察者的value,并将dirty置为false,表示value值已更新,之后执行观察者的depend方法,将上层观察者也添加到getter函数中data的依赖收集器(dep)中,最后返回computed的value值;

    image-20220726174919681

    4、当 computed 属性 getter 函数依赖的 data 值改变时

    将会根据之前依赖收集的观察者,依次调用观察者的 update 方法,先调用 computed 观察者的 update 方法,由于 lazy 为 true,将会设置观察者的 dirty 为 true,表示 computed 属性 getter 函数依赖的 data 值发生变化,但不调用观察者的 get 方法更新 value 值。再调用包含页面更新方法的观察者的 update 方法,在更新页面时会读取 computed 属性值,触发重定义的 getter 函数,此时由于 computed 属性的观察者 dirty 为 true,调用该观察者的 get 方法,更新 value 值,并返回,完成页面的渲染。

    image-20220727135348649

    5、核心流程

    1. 首次读取 computed 属性值时,dirty 值初始为 true
    2. 根据getter计算属性值,并保存在观察者value上并设置dirty为false
    3. 之后再读取 computed 属性值时,dirty 值为 false,不调用 getter 重新计算值,直接返回观察者中的value
    4. 当 computed 属性getter依赖的data发生变化时,再次设置dirty为true,通知页面更新,重新计算属性值

    三、自定义实现

    基于上一篇文章实现的自定义框架,增加computed属性的解析和绑定。

    1、首先在index.html定义并使用计算属性

    <body>
      <div id="app">
        <span v-text="name">span>
        <input type="text" v-model="age">
        <input type="text" v-model="name">
        {{name}}<br/>
    		{{fullName}}<br/>
        {{fullName}}<br/>
        {{fullName}}<br/>
        {{fullName}}<br/>
        {{fullNameAge}}<br/>
        {{fullNameAge}}<br/>
      div>
      <script>
        var vm = new MYVM({
          el: '#app',
          data: {
            name: 'James',
            age:18
          },
          //定义计算属性
          computed:{
            fullName(){
              return this.$data.name+" Li";
            },
            fullNameAge(){
              return this.$computed.fullName+" "+this.$data.age;
            }
          },
        })
      script>
    body>
    html>
    

    定义了两个计算属性fullName和fullNameAge,并在模板中进行了调用。

    2、MYVM.js中增加对计算属性的解析和处理

    function MYVM(options){
         //属性初始化
         this.$vm=this;
         this.$el=options.el;
         this.$data=options.data;
         //获取computed属性
         this.$computed=options.computed;
         //定义管理computed观察者的属性
         this.$computedWatcherManage={};
         
         //视图必须存在
         if(this.$el){
            //添加属性观察对象(实现数据挟持)
            new Observer(this.$data)
            new ObserverComputed(this.$computed,this.$vm);
    
            // //创建模板编译器,来解析视图
            this.$compiler = new TemplateCompiler(this.$el, this.$vm)
        }
    }
    

    增加$computed属性用来存储计算属性,$computedWatcherManage用来管理计算属性的Watcher,ObserverComputed用来劫持计算属性和生成对应的watcher。

    3、ObserverComputed创建computed的Watcher实例,劫持computed属性

    //数据解析,完成对数据属性的劫持
    function ObserverComputed(computed,vm){
        this.vm=vm;
        //判断computed是否有效且computed必须是对象
        if(!computed || typeof computed !=='object' ){
            return
        }else{
            var keys=Object.keys(computed)
            keys.forEach((key)=>{
                this.defineReactive(computed,key)
            })
        }
    }
    ObserverComputed.prototype.defineReactive=function(obj,key){
        //获取计算属性对应的方法
        let fun=obj[key];
        let vm=this.vm;
        //创建计算属性的Watcher,存入到$computedWatcherManage
        vm.$computedWatcherManage[key]= new ComputedWatcher(vm, key, fun);
        let watcher= vm.$computedWatcherManage[key];
    
        Object.defineProperty(obj,key,{
            //是否可遍历
            enumerable: true,
            //是否可删除
            configurable: false,
    
            //get方法
            get(){
                //判断是否需要重新计算属性
                //dirty 是否使用缓存
                //$computedWatcherManage.dep 是否是创建Watcher收集依赖时执行
                if(watcher.dirty || vm.$computedWatcherManage.dep==true){
                    let val=fun.call(vm)
                    return val
                }else{
                    //返回Watcher缓存的值
                    return watcher.value
                }
                
            },
        })
    }
    

    vm.$computedWatcherManage[key]= new ComputedWatcher(vm, key, fun);创建Watcher实例

    其它的注释都比较细致,不细说了哈

    4、ComputedWatcher 缓存value,管理页面订阅者,更新页面

    //声明一个订阅者
    //vm 全局vm对象
    //expr 属性名
    //fun 属性对应的计算方法
    function ComputedWatcher(vm, expr,fun) {
        //初始化属性
        this.vm = vm;
        this.expr = expr;
        this.fun=fun;
        //计算computed属性的值,进行缓存
        this.value=this.get();
        //是否使用缓存
        this.dirty=false;
        //管理模板编译后的订阅者
        this.calls=[];
      }
      //执行computed属性对应的方法,并进行依赖收集
      ComputedWatcher.prototype.get=function(){
            //设置全局Dep的target为当前订阅者
            Dep.target = this;
            //获取属性的当前值,获取时会执行属性的get方法,get方法会判断target是否为空,不为空就添加订阅者
            this.vm.$computedWatcherManage.dep=true
            var value = this.fun.call(this.vm)
            //清空全局
            Dep.target = null;
            this.vm.$computedWatcherManage.dep=false
            return value;
      }
      
      //添加模板编译后的订阅者
      ComputedWatcher.prototype.addCall=function(call){
        this.calls.push(call)
      }
    
      //更新模板
      ComputedWatcher.prototype.update=function(){
            this.dirty=true
            //获取新值
            var newValue = this.vm.$computed[this.expr]
            //获取老值
            var old = this.value;
            //判断后
            if (newValue !== old) {
                this.value=newValue;
                this.calls.forEach(item=>{
                    item(this.value)
                })
            }
            this.dirty=false
      }
    

    ComputedWatcher核心功能:

    1、计算computed属性的值,进行缓存

    2、执行computed的get方法时进行依赖收集,ComputedWatcher作为监听者被添加到data属性或其它computed属性的依赖管理数组中

    3、模板解析识别出计算属性后,调用addCall向ComputedWatcher添加监听者

    4、update方法获执行computed计算方法调用,遍历执行依赖数组的函数更新视图

    5、TemplateCompiler解析模板函数的修改

    // 创建模板编译工具
    function TemplateCompiler(el,vm){
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        if (this.el) {
            //将对应范围的html放入内存fragment
            var fragment = this.node2Fragment(this.el)
            //编译模板
            this.compile(fragment)
            //将数据放回页面
            this.el.appendChild(fragment)
          }
    }
    
    //是否是元素节点
    TemplateCompiler.prototype.isElementNode=function(node){
        return node.nodeType===1
    }
    
    //是否是文本节点
    TemplateCompiler.prototype.isTextNode=function(node){
        return node.nodeType===3
    }
    
    //转成数组
    TemplateCompiler.prototype.toArray=function(arr){
        return [].slice.call(arr)
    }
    
    //判断是否是指令属性
    TemplateCompiler.prototype.isDirective=function(directiveName){
        return directiveName.indexOf('v-') >= 0;
    }
    
    //读取dom到内存
    TemplateCompiler.prototype.node2Fragment=function(node){
        var fragment=document.createDocumentFragment();
        var child;
        //while(child=node.firstChild)这行代码,每次运行会把firstChild从node中取出,指导取出来是null就终止循环
        while(child=node.firstChild){
            fragment.appendChild(child)
        }
        return fragment;
    }
    
    //编译模板
    TemplateCompiler.prototype.compile=function(fragment){
        var childNodes = fragment.childNodes;
        var arr = this.toArray(childNodes);
        arr.forEach(node => {
            //判断是否是元素节点
            if(this.isElementNode(node)){
                this.compileElement(node);
            }else{
                //定义文本表达式验证规则
                var textReg = /\{\{(.+)\}\}/;
                var expr = node.textContent;
                if (textReg.test(expr)) {
                    expr = RegExp.$1;
                    //调用方法编译
                    this.compileText(node, expr)
                }
            }
        });
    }
    
    //解析元素节点
    TemplateCompiler.prototype.compileElement=function(node){
        var arrs=node.attributes;
        this.toArray(arrs).forEach(attr => {
            var attrName=attr.name;
            if(this.isDirective(attrName)){
                //获取v-text的text
                var type = attrName.split('-')[1]
                var expr = attr.value;
                CompilerUtils[type] && CompilerUtils[type](node, this.vm, expr)
            }  
        });
    }
    
     //解析文本节点
     TemplateCompiler.prototype.compileText=function(node,expr){
        CompilerUtils.text(node, this.vm, expr)
    }
    
    CompilerUtils = {    
        /*******解析v-model指令时候只执行一次,但是里面的更新数据方法会执行n多次*********/
        model(node, vm, expr) {
            if(vm.$data[expr]){
                var updateFn = this.updater.modelUpdater;
                updateFn && updateFn(node, vm.$data[expr])
    
                /*第n+1次 */
                new Watcher(vm, expr, (newValue) => {
                    //发出订阅时候,按照之前的规则,对节点进行更新
                    updateFn && updateFn(node, newValue)
                })
    
                //视图到模型(观察者模式)
                node.addEventListener('input', (e) => {
                //获取新值放到模型
                var newValue = e.target.value;
                vm.$data[expr] = newValue;
                })
            }
        },
    
        /*******解析v-text指令时候只执行一次,但是里面的更新数据方法会执行n多次*********/
        text(node, vm, expr) {
            //判断是否是data属性
            if(vm.$data[expr]){
                /*第一次*/
                var updateFn = this.updater.textUpdater;
                updateFn && updateFn(node, vm.$data[expr])
    
                /*第n+1次 */
                new Watcher(vm, expr, (newValue) => {
                    //发出订阅时候,按照之前的规则,对节点进行更新
                    updateFn && updateFn(node, newValue)
                })
            }
            //认为是计算属性
            else{
                this.textComputed(node,vm,expr)
            }
        },
    
        //新增text computed属性的解析方法
        textComputed(node, vm, expr) {
            var updateFn = this.updater.textUpdater;
            //获取当前属性的监听者
            let watcher=vm.$computedWatcherManage[expr];
    
            //第一次
            updateFn(node,vm.$computed[expr]);
    
            //添加更新View的回调方法
            watcher.addCall((value)=>{
                updateFn(node, value);
            })
        },
    
        updater: {
            //v-text数据回填
            textUpdater(node, value) {
              node.textContent = value;
            },
            //v-model数据回填
            modelUpdater(node, value) {
              node.value = value;
            }
        }
    }
    

    这个函数主要做了2点修改:

    1、修改text方法,如果data里不包含该属性,当做计算属性处理

    2、新增textComputed方法,把该节点的更新函数添加到watcher的依赖数组

    6、为该框架增加一个简易的计算属性就完成了,下面看下运行效果:

    iShot_2022-08-11_10.13.25

    初始化的时候会输出:fullName 1 fullNameAge 1 fullName 1

    先解释fullName 1为什么输出2次?

    fullName和fullNameAge都是计算属性。

    fullNameAge依赖于fullName,fullName依赖与data的属性name

    Index.html中有输出了四个fullName计算属性,实际fullName计算属性只执行了一次计算,把值缓存了下来,剩余3个直接取缓存的值。输出第二个fullName 1是因为fullNameAge依赖与fullName,需要把fullNameAge的监听者添加到data的属性name的依赖数组中,这样name属性有更新的时候会执行到fullNameAge的监听函数。

    ok,自己实现的这部门还有改进空间,有能力的朋友帮忙改进哈!不明白的朋友可以加好友一起交流。

  • 相关阅读:
    06_玩转Docker容器:80分钟一口气学完docker+k8s!带你掌握docker+k8s所有核心知识点,全程干货,无废话!
    有什么好的开源自动化测试框架可以推荐?
    MySQL的执行器是怎么工作的
    python项目实战——银行取款机系统(四)
    Java JDK 安装
    说一下 toRef、toRefs,以及他们的区别
    SpringBoot 配置文件解析-基础篇
    EM聚类(下):用EM算法对王者荣耀英雄进行划分
    【【萌新的SOC大学习之hello_world】】
    Python模块:基本概念、2种导入方法(import与from...import)和使用
  • 原文地址:https://www.cnblogs.com/lisong/p/16575173.html