Vue数据响应化原理之盘点看似路人皆知实则发人深省的优雅用法

目录

 通过索引添加/替换/删除数组元素

使用拦截器与覆盖原型链方式更改目标数据原型

不考虑顺序的遍历

灵活使用全局或静态变量达到公共存储目的

一个可全局修改局部使用的开关变量

深度观测实现的优雅

将自己主动加入到目标订阅列表中


Vue如今可说是极大炙手可热的前端基础框架之一,其渐进式的设计让使用者无论是在现有的系统上集成开发还是使用Vue全家桶开发一个大型项目都非常便捷。Vue区别于React的一个很大的特性便是通过数据响应化处理能够更细粒度(理论上,Vue可以观察到几乎所有状态的改变,因此Vue数据观察的粒度是可以根据需要随意调整的)观察每一个状态的改变从而触发更新视图。相比React和Angular的“拉”式变化侦测来说,个人比较倾向于Vue的这种“推”式的变化侦测,因为它更主动、更细致。而Vue在实现这样一个优秀的数据响应系统时,使用了大量的看似路人皆知实则发人深省的优雅的使用方式和技巧,这些方式和技巧其实无论我们是否使用Vue都可以借鉴使用,可以极大的提升代码的易读性、简洁性、以及性能。那么,我们来看看都有哪些奇淫技巧吧!

  •  通过索引添加/替换/删除数组元素

    • 具体实现
/**
 * 可通过此方法进行新增、删除、替换数组元素
 * @param arr           [Array] 待处理数组
 * @param index         [number] 待处理目标元素索引
 * @param newItem       [any] 新增或替换的元素,删除时可传入任意值,删除时实际并不会使用这个参数化
 * @param remove        [true|false] 当前是否是删除操作,由于数组中也有可能需要加入undefined、null、false等值,因此光靠newItem不能判断是否需要删除一个元素,因此新增此字段,
 * @returns {*}
 */
export const dueArrItemByIndex = (arr, index, newItem,remove=false)=>{
    // 由于本方法既可以处理新增,又可以处理修改,当新增元素的时候,数组长度会增加,如果是修改元素值则不变,因此,我们取目标索引值与当前数组长度两者之间的最大值设置为数组新的长度。
    // 注意:splice方法,如果要在数组最后面添加一个元素,不需要修改数组的长度,splice会自动修改,如:
    //
    // var arr = [1,2,3];arr.splice(3,1,4);console.log(arr); // 输出:[1, 2, 3, 4]
    //
    // 但如果不是直接在数组最后面加,而是数组后几位,那么,如果不修改数组length的话,是不能正常添加的。
    //
    // 不修改长度:(结果是错误的,因为我们传的index是4,是想要在索引为4也就是第5位元素上插入数字4,但结果只是在第4位插入了,其索引实际只是3,并非预期答案)
    //
    // var arr = [1,2,3];arr.splice(4,1,4);console.log(arr); // 输出:[1, 2, 3, 4]
    //
    // 修改数组长度:
    // 
    // var arr = [1,2,3];arr.length=4;arr.splice(4,1,4);console.log(arr); // 输出:[1, 2, 3, empty, 4]
    //
    // 从上面的例子我们可以看出,使用splice方法添加元素,如果是直接紧接着最后一个元素添加的话,数组的长度是会自动加一,无需我们额外处理的,但是我们实际使用的时候,并不一定会紧接着最后一个元素添加,index传入的只要是合法的数组索引就可以的,所以,我们需要修正一下length用来兼容这种情况。
    // 那么,我们要怎么确定length的长度呢,从上面的例子可以看出,当且仅当index>=arr.length时才是新增元素,其他情况则视为是修改。
    // * 那么在新增元素的时候,其实我们只要让数组长度等于index就可以了(正如上面所说,当我们使用splice再最后添加元素时,数组长度会自动加1,如上面最后一个例子,我们刚开始将数组的长度修正为4,执行splice之后数组长度自动加1后数组长度自动变为5,而这个长度5便是我们预期要的数组长度)
    // * 当我们在修改元素时,数组长度其实不需要变化的,还是arr.length
    // ## 综上所述,兼容新增与修改的情况,那么数组的长度应为:arr.length = Math.max(arr.length,index);
    arr.length = Math.max(index, arr.length);
    
    // 如果不是删除的情况,则吧带新增或修改的元素加入到参数数组中
    let args = remove?[index,1]:[index,1, newItem];
    
    arr.splice.apply(arr, args);
    return arr;
};
  • 好在哪里
    • 代码简洁:从上面的实例中可以看出,去除注释代码和空白,其实我们实际的业务代码仅仅只有3行,但却实现了数组的新增、修改、删除的逻辑
    • 调用方式统一:可以通过同样的方式实现不同的数组操作(新增:dueArrItemByIndex([1,2,3],4,4);修改:dueArrItemByIndex([1,2,3],1,'aaa');删除:dueArrItemByIndex([1,2,3],1,null,true);)
  • 应用场景
    • 在一些到底是需要修改还是新增不太明确的时候,比如说Vue中的$set,如果操作对象是数组,那么,除非你事先去看一下要操作的数组索引到底存不存在,要不然你光看索引4,你并不知道到底是要为数组在索引4中新增一个元素还是修改索引4的数组元素。此时便可以使用这个方法统一处理,无论是新增还是修改都可以统一处理
  • 使用拦截器与覆盖原型链方式更改目标数据原型

    • 具体实现
      • // Array.js 定义一些针对数组响应化时需要用到的辅助数据以及定义了
        // 数组原型,在对数组方法打补丁的时候,需要用到数组原型方法用于实现原本的数组操作
        export const arrayProto = Array.prototype;
        
        /**
         * 需要打补丁的数组方法,即会改变数组的方法
         * @type {string[]}
         */
        export const needPatchArrayMethods = [
            "push",
            "pop",
            "unshift",
            "shift",
            "sort",
            "reverse",
            "splice"
        ];
        
        
        // 根据数组原型创建一个新的基础数组对象,避免为数组方法打补丁的时候污染原始数组
        export const arrayMethods = Object.create(arrayProto);
        
        
        // 实现数组拦截器,通过这个拦截器实现拦截数组操作方法操作
        needPatchArrayMethods.forEach(method=>{
            // 从数组原型中将原始方法取出
            const originalMethod = arrayProto[method];
        
            def(arrayMethods,method,function mutator(...args){
                
                // 在这里我们可以在数组调用原生方法时,做一下拦截操作,如发送通知或格式化数据等等
        
                // 调用数组原始方法实现数组操作
                const res = originalMethod.apply(this,args);
                return res;
            });
        });
        
        /**
         * 定义不可枚举的属性
         * @param obj
         * @param key
         * @param value
         * @param enumerable 能否枚举
         */
        export const def = function (obj,key,value,enumerable) {
            if(typeof obj === "object"){
                Object.defineProperty(obj,key,{
                    value: value,
                    configurable: true,
                    enumerable: !!enumerable,
                    writable: true
                });
            }
        };
        
        /**
         * 将拦截器方法直接覆盖到目标对象的原型链上__proto__
         * @param obj
         * @param target
         * @returns {*}
         */
        export const patchToProto = (obj,target) => obj.__proto__ = target;
        
        /**
         * 直接在目标对象上定义不可枚举的属性
         * @param obj
         * @param arrayMethods
         * @param keys
         * @returns {*}
         */
        export const copyArgument = (obj,arrayMethods,keys) => keys.forEach(key=>def(obj,key,arrayMethods[key]));
        
        /**
         * 判断当前浏览器是否支持__proto__若支持,这直接将目标方法覆盖到__proto__上,否则,直接将方法定义在目标对象上
         * @param obj
         * @param src
         * @param keys
         * @returns {*}
         */
        export const defProtoOrArgument = (obj,src,keys=Object.getOwnPropertyNames(src)) => hasProto ? patchToProto(obj,src) : copyArgument(obj,src,keys);
        
        // 测试代码,测试将拦截方法挂载到目标数组的__prop__上
        let arr = [1,2,3];
        defProtoOrArgument(arr,arrayMethods);
        // 调用数组原生方法会进入到拦截器,可以在拦截器中做一些预处理
        arr.push(4);
        arr.unshift(0);
        arr.sort((a,b)=>a-b)
        
        扫描二维码关注公众号,回复: 9813058 查看本文章
  • 好在哪里
    • 实现了原生方法拦截:可以在向数组中添加元素是做一些前期的校验与规格化或者是发送通知等
    • 避免了全局污染: 没有直接修改数组的原型链,而是对需要监控的数组方法的原型链进行加工,避免了全局数组对象的污染
  • 应用场景
    • 任何原生方法的拦截:应用这种拦截器的思想,我们可以对我们想拦截的任意对象使用拦截器,然后通过拦截器实现一些通用的处理
  • 不考虑顺序的遍历

    • 具体实现
      • let arr = [1,2,3,4,5,6];
        
        let i = arr.length;
        while(i--){
            console.log(arr[i]);
        }
    • 好在哪里
      • 简洁性:在不关心数组先后顺序的情况下,采用这种方式循环迭代数组,比for更加简介明了
    • 应用场景
      • 在仅需要循环迭代数组中所有元素用于操作时可以使用此方法
  • 灵活使用全局或静态变量达到公共存储目的

    • 具体实现
      • function Dep() {
            this.subs = [];
        }
        // 巧妙之处便在与Vue灵活的使用了Dep.target这个静态属性,我们每new一次Watcher,便会把当前Watcher放到这里暂存,随后我们
        // 会立即去读一下数据,这样就会触发getter,将watcher加入到dep的订阅列表当中,当下一次再new Watcher()时,新的watcher实例
        // 又会覆盖调Dep.target,等待触发getter后将自己加入到dep的订阅列表
        Dep.target = null;
        Dep.prototype.addSub = function(sub) {
            this.subs.push(sub);
        };
        Dep.prototype.depend = function() {
            // 3、这里又通过Dep.target中存储的watcher对象将Dep当前的实例加入到Watcher的依赖列表中
            Dep.target && Dep.target.addDep(this);
        };
        
        function Watcher() {
            // 1、当watcher实例化时,将Watcher自身的实例放在上面
            Dep.target = this;
            this.deps = [];
        }
        Watcher.prototype.addDep = function(dep) {
            if (this.deps.indexOf(dep) < 0) {
                this.deps.push(dep);
                // 4、将watcher加入到依赖列表中的同时,watcher同时又将自己的实例注册到Dep的订阅列表里面,这样就形成了双向调用链
                dep.addSub(this);
            }
        };
        
        function defineRealive(data, key, val) {
            let dep = new Dep();
            Object.defineProperty(data, key, {
                get() {
                    // 2、当数据触发getter时,调用dep的depend方法
                    dep.depend();
                    return val;
                },
                set(value) {
                    val = value;
                }
            });
        }
        
        let data = {
            name: "kiner",
            age: 20,
            from: 'china'
        };
        
        // 数据响应化处理
        for (let key in data) {
            defineRealive(data, key, data[key]);
        }
        
        // 这个watcher只有一个依赖
        let watcher = new Watcher();
        console.log(data.name);
        
        // 这个watcher有两个依赖
        let watcher2 = new Watcher();
        console.log(data.age, data.from);
        
        console.log(watcher, watcher2);

    • 好在哪里
      • 存取方便:采用Dep的静态变量的方式存取Watcher实例,极大得简化了我们要想办法到处获取Watcher实例的逻辑,我们只需要固定从这里面查找最新的Watcher即可
    • 应用场景
      • 适合应用与一些多对多的数据结构设计,如:在大学里,我们上公共选修课时,一个学生对应着多个教室(每门公选教室不同),而每个教室同时有对应着多个学生。如果要设计一个为签到的小程序,并统计每个学生的出勤率和每个公选课的出勤率,便可以采用这种方式
  • 一个可全局修改局部使用的开关变量

    • 具体实现
      • // 总开关,若总开关关了,就算是你打开灯的开关也没办法开灯
        export let turnOn = false;
        export const toggleTurn = () => {
          turnOn = !turnOn;
        };
        
        class Light {
          constructor(color) {
            this.color = color;
            this.myTurnOn = false;
          }
          // 灯的开关
          turnLightOn() {
            this.myTurnOn = true;
            if (turnOn && this.myTurnOn) {
              console.log(`把${this.color}色的灯打开了`);
            }
          }
          turnLightOff() {
            this.myTurnOn = false;
          }
        }
        
        let lightRed = new Light("红");
        let lightOrange = new Light("橙");
        
        // 先打开总开关
        toggleTurn();
        // 打开两盏灯
        lightRed.turnLightOn();
        lightOrange.turnLightOn();
        // 关上两盏灯
        lightRed.turnLightOff();
        lightOrange.turnLightOff();
        // 关上总开关
        toggleTurn();
        
        // 打开两盏灯
        lightRed.turnLightOn();
        lightOrange.turnLightOn();
        
    • 好在哪里
      • 全局控制局部功能:通过暴露出去的全局开关方法可以控制当前类的一些具体实现逻辑
    • 应用场景
      • 如日志管理,我们也可以加一个全局开关,只有这个全局开关打开,我们才向控制台输出日志,否则就不输出
  • 深度观测实现的优雅

    • 具体实现
      • // Traverse.js 通过traverse递归访问指定对象,通过触发getter的方式实现依赖收集
        import {isA,isObject,hasOb} from "../shared/utils.js";
        
        // 用于存储依赖id
        const depIds = new Set();
        
        // 通过这个方法访问一下给定目标对象的子对象,从而触发依赖通知
        export const traverse = (val) => {
            _traverse(val,depIds);
            depIds.clear();
        };
        
        function _traverse(val,depIds){
            let len,keys;
            // 所传对象如果类型不是非冻结对象或数组,就直接终止
            if((!isA(val) && !isObject(val)) || Object.isFrozen(val)){
                return;
            }
            // 判断当前对象是否已经是响应化对象
            if(hasOb(val)){
                const  depId = val.__ob__.dep.id;
                if(depIds.has(depId)){//已经访问过了,直接终止
                    return;
                }
                //若未访问过,则将依赖id加入到depIds中
                depIds.add(depId);
            }
        
            if(isA(val)){//如果是数组,则循环访问其子项并递归访问
                len = val.length;
                while (len--) _traverse(val,depIds);
            }else{//循环对象下的所有属性并递归访问
                keys = Object.keys(val);
                len = keys.length;
                while (len--) _traverse(val,depIds);
            }
        }
    • 好在哪里
      • 读取便可观测:从上面的代码,有些朋友肯定看起来很奇怪了,上面的代码除了一些类型判断,重复数据筛选之外,只做了一件事,就是循环将属性值拿出来,然后再递归调用当前方法,貌似什么数据观测的逻辑都没干呀,他是怎么实现深度数据观测的。其实,只要熟悉Vue数据观测原理的朋友应该已经想到了,在Vue中,想要将一个数据进行观测,最简单的方法就是读取一下这个属性,然后就会触发getter,getter中便有依赖收集的逻辑。上面的代码看似只是循环属性,递归调用,但其实在无形中已经通过读取属性的方式触发了getter从而实现的依赖收集了。这里之所以要循环和递归,仅仅只是为了实现深度观测中的“深度”二字
    • 应用场景 
      • 在Vue的源码中大量使用了这种方式进行依赖收集,不仅减少了很多的代码量,还是代码看起来更加优雅、整洁。我们再实际开发中,凡是有用到getter的,其实都可以采用类似的方法实现
  • 将自己主动加入到目标订阅列表中

    • 具体实现
      • function Dep() {
            this.subs = [];
        }
        // 巧妙之处便在与Vue灵活的使用了Dep.target这个静态属性,我们每new一次Watcher,便会把当前Watcher放到这里暂存,随后我们
        // 会立即去读一下数据,这样就会触发getter,将watcher加入到dep的订阅列表当中,当下一次再new Watcher()时,新的watcher实例
        // 又会覆盖调Dep.target,等待触发getter后将自己加入到dep的订阅列表
        Dep.target = null;
        Dep.prototype.addSub = function(sub) {
            this.subs.push(sub);
        };
        Dep.prototype.depend = function() {
            // 3、这里又通过Dep.target中存储的watcher对象将Dep当前的实例加入到Watcher的依赖列表中
            Dep.target && Dep.target.addDep(this);
        };
        
        function Watcher() {
            // 1、当watcher实例化时,将Watcher自身的实例放在上面
            Dep.target = this;
            this.deps = [];
        }
        Watcher.prototype.addDep = function(dep) {
            if (this.deps.indexOf(dep) < 0) {
                this.deps.push(dep);
                // 4、将watcher加入到依赖列表中的同时,watcher同时又将自己的实例注册到Dep的订阅列表里面,这样就形成了双向调用链
                dep.addSub(this);
            }
        };
        
        function defineRealive(data, key, val) {
            let dep = new Dep();
            Object.defineProperty(data, key, {
                get() {
                    // 2、当数据触发getter时,调用dep的depend方法
                    dep.depend();
                    return val;
                },
                set(value) {
                    val = value;
                }
            });
        }
        
        let data = {
            name: "kiner",
            age: 20,
            from: 'china'
        };
        
        // 数据响应化处理
        for (let key in data) {
            defineRealive(data, key, data[key]);
        }
        
        // 这个watcher只有一个依赖
        let watcher = new Watcher();
        console.log(data.name);
        
        // 这个watcher有两个依赖
        let watcher2 = new Watcher();
        console.log(data.age, data.from);
        
        console.log(watcher, watcher2);
    • 好在哪里
      • 自己最了解自己:通过将自身加入到目标的订阅列表,而不是通过目标来收集自己,从代码逻辑层面来说更容易实现与理解。倘若需要别人来收集自己,他们收集的时候,还好关心我收集的对象是否初始化了,我收集的对象是否已经完成了某些前期工作等等。采用将自身加入目标的订阅列表便没有这个问题,因为我肯定是在我自己准备好了我才把自己加入到你那边的对吧。
    • 应用场景
      • 订阅消息
发布了32 篇原创文章 · 获赞 16 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/u010651383/article/details/104192287