vue的响应式原理及从无到有用原生js实现vue的一套响应式系统

what is 响应式?

响应式作为vue的代表特点之一, 意义非凡, 响应式的含义也就是: 我们更改了js中的数据, 页面会同步进行更新, 同理, 页面中的数据发生了变化, js也会得到通知从而更改数据

来看看实例

<!-- html结构相当的简单, 一个id为app的div, 之后我们会让vue来接管该div -->
<div id='#app'></div>
const vm = new Vue({
    el: '#app',
    data: {
        msg: 'helloWorld'
    }
})

当我们在页面中直接通过控制台更改vue实例上的msg属性的时候, 其实页面也同步渲染了, 这就是响应式, 如下图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h50TuXii-1591941872056)(./响应式.gif)]

how to do 响应式?

可能很多朋友都或多或少的了解过vue的响应式是通过Object.defineProperty实现的, 响应式系统的核心原理确实是如此, 但是远不止于此, 话不多说, 我们从最基本的Object.defineProperty来看看vue优等生的完美作业之响应式系统

笔者也是自己的学习和领悟, 希望多多交流, 如有问题请及时指出

  • Object.defineProperty

    这哥们用来给一个对象进行代理, 当我们对一个对象进行代理以后, 那么对这个对象进行任何的访问都将得到监控

    在现实中, 我们可以理解为明星艺人和经纪人的关系, 当有经纪人为艺人进行代理业务以后, 以后每一个跟艺人相关的操作和业务都会被经纪人监控到, 经纪人也可以选择是否让艺人来承接这个业务或者参加某个活动等, 如果你还是不太理解, 那么我们来看看实例, 我相信你会对Object.defineProperty更加的明白

    // 我们实际上是要在用户修改某个属性的时候, 我们要得到通知, 就这么简单
    let pengyuyan = {
        name: '彭于晏',
        address: '地球的某个地方'
    }
    
    pengyuyan.address =  '台湾';
    

    像上面这样操作我们能够得到通知吗? 答案是否定的, 因为更改属性一瞬间就发生了, 我们都妹机会来捕捉他的变化, 所以我们来看看如下这样写

    let pengyuyan = {
        name: '彭于晏',
        address: '地球的某个地方'
    }
    
    let pengyuyanName = pengyuyan.name;
    Object.defineProperty(pengyuyan, 'name', {
        get() {
            console.log('有人要找彭于晏啦, 快看看是不是吴彦祖');
            return pengyuyanName;
        },
        set(newVal) {
            console.log('有人要改彭于晏的名字啦, 快点来人看看在他改名字之前我们是不是要做点什么啊');
            pengyuyanName = newVal;
        }
    })
    

    Object.defineProperty给某个对象的某个属性进行代理以后, 会给该对象的被代理属性提供一个getset方法, get用来监控之后任何操作对于被代理属性的访问, set用来监控之后任何操作对于被代理属性的修改

    于是当我们对pengyuyan对象的name的属性进行读和写的时候, 会在读和写的时候得到提示获得一定的反馈, 如下图

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oXxKbbKW-1591941872058)(./pengyuyan.gif)]

    于是乎, 我们便可以用我们的方式来模拟一下vue的响应式

    <!-- 这是一开始页面中渲染的内容, 我们要做的就是当js的数据改变的时候页面要相应的给予反馈 -->
    <div id='#app'>
    </div>
    
    const pengyuyan =  (function () {
        const app = document.getElementById('app');
        const pengyuyan = {
            name: 'pengyuyan',
            age: 18
        }
    
        function init() {
            render(pengyuyan.name);
            observer();
        }
    
        // 负责渲染的函数
        function render(value) {
            app.innerHTML = value;
        }
    
        function observer() {
            // 代理
            let pengyuyanName = pengyuyan.name;
            Object.defineProperty(pengyuyan, 'name', {
                get() {
                    return pengyuyanName;
                },
    
                set(newVal) {
                    pengyuyanName = newVal;
                    render(pengyuyanName);
                }
            })
        }
    
        init();
    
        return pengyuyan;
    }())
    

    上面的代码其实写的非常的简单, 如果你看不懂的话笔者建议你补一补js的基础知识, 上面的实现结果也非常的简单, 如下

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oj1is2LB-1591941872059)(./实现响应式.gif)]

    我们会发现, 这就把vue的响应式貌似实现了? 笔者之前也说过这确实是vue响应式的核心原理, 但要就说这就是vue的响应式, 那还远远不够, vue考虑到的东西更加的多更加的丰富, 但是有了这个作为基础, 笔者相信后面的操作不太会难得到你


    首先要知道vue的响应式系统怎么实现, 我们必须要完整的知道vue的响应式系统到底能做哪些事儿, 不能做哪些事儿

    • vue会遍历data对象中的所有属性, 使用Object.defineProperty进行追踪

    • Object.defineProperty的追踪是递归进行的, 意味这如果发生引用值嵌套引用值的情况, vue也能够很好的监测到

    • vue在某种情况下并不能监测数组和对象的改变

      • 通过数组的索引去修改数组
      • 通过数组的长度去修改数组
      • 直接对对象进行增加属性操作
      • 直接对对象进行删除属性操作
      • 数组有7个数组变异方法可供开发者直接操作数组分别是

        push, pop, unshift, shift, reverse, sort, splice

    • vue实例上提供$set$delete供开发者对数组和对象进行增加和删除操作

    • vue的响应式系统是异步的, vue只会响应最后一次数据的变更并对dom进行更新

    • 在数据变更时, vue会跟虚拟dom进行比对, 如果此次变更的值跟上一次变化的值没有区别, 则vue不会进行dom的更新

    ok, 笔者的理解基本上就这么多, 如有遗漏纯属忘记,还望海涵

    那么我们可以一步一步来用我们的方式来复刻vue的这套响应式系统

    关于接下来要写的所有的代码, 笔者尽量会以一种小白都可以看的懂的写法去实现这些功能, 所以并不直接是vue的源码, 只能理解为用比较easy的代码来让你大概知道vue响应式的核心工作原理, 同时因为vue源码中的各个功能之间的关联性极强, 涉及到的代码逻辑比较复杂, 比如观察者模式, wachter, observer和异步队列等, 笔者不会将所有的流程都会写的很清晰, 但是会在后面你看懂了笔者这份简易代码以后, 当你上github查看vue真正的开源代码中响应式这块的处理的时候一定会更加的得心应手

    1. 首先我们如何对一个对象上所有的属性进行追踪: 对一个属性追踪使用Object.defineProperty, 对多个属性追踪我们使用多个Object.defineProperty不就ok了, 在这个过程中我们唯一要注意的就是对象中如果依旧嵌套对象的情况需要使用到递归, 这里可能需要比较扎实的递归知识
    const data =  (function () {
        const data = {
            name: 'thor',
            age: 18,
            sex: 'male',
            address: {
                province: 'guangdong',
                city: 'shenzhen'
            },
            friends: [
                {
                    name: 'loki',
                    age: 19
                }
            ],
            cat: {
                name: 'lulu',
                skill: ['eat', 'sleep']
            }
        }
    
        function init() {
            observer(data, '');
        }
    
        /**
         *提供一个observer观察方法, data为需要被监控的对象 
            nameSpace: 命名空间, 他可以更加精准的帮助我们知道我们现在修改了哪个属性的值
            **/
        function observer(data, nameSpace) {
            // 循环给对象上的每一个属性进行
            for (let prop in data) {
                defineReactive(data, prop, data[prop], nameSpace);
            }
        }
    
        /**
         * defineReactive方法是真正用来监测的方法
         * data: 当前需要代理的对象, 
            prop: 需要代理对象的属性
            value: 控制该属性的value值
            **/
        function defineReactive(data, prop, value, nameSpace) {
            if (typeof data[prop] === 'object') {
                // 如果传递进来的属性是一个对象, 那么我们其实是需要递归监测的
                observer(data[prop], prop + '.');
            }
            Object.defineProperty(data, prop, {
                get() {
                    return value;
                },
                set(newVal) {
                    console.log('我在修改' + nameSpace + prop + '的值');
                    value = newVal;
                }
            })
    
        }   
    
        init();
    
        return data;
    
    }())
    

    ok, 关于多个属性的监测和递归其实是很简单的, 实现后效果如下

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7TZw8PSv-1591941872060)(./递归监测.gif)]

    1. 我们知道vue对于数据的更新是选择性的, 他只会在真正需要更新的时候才会去更新, 而如果数据经过vue的diff算法比对以后不需要更新, 则vue就会放弃本次更新

    所以我们要在代码中增加如下代码(新增的代码会打上注释)

    const data =  (function () {
        const data = {
           ...
        }
    
        function init() {
           ...
        }
    
        function observer(data, nameSpace) {
            ...
        }
    
        function defineReactive(data, prop, value, nameSpace) {
            ...
            Object.defineProperty(data, prop, {
                get() {
                    return value;
                },
                set(newVal) {
                    // 同时在这块我们需要处理一下是否需要更新
                    const o = Object.assign({}, data); // 避免拿同样的地址
                    console.log('我在修改' + nameSpace + prop + '的值');
                    value = newVal;
                    // 如果比较函数通过比较得出确实需要更新, 则执行render刷新页面, 否则不刷新
                    if(compare(o, data)) render();
                }
            })
    
        }   
    
        /**
         * 我们新提供一个compare方法用来比较是否需要重新渲染页面
        * o: 老的对象
        * n: 新的对象
        * 如果返回true则代表确实需要更改, 返回false则不需要更改
        * */
        function compare(o, n) {
            if ((o == null && n !== null) || (o !== null && n == null)) return true
            // vue内部实现这块比较的复杂, 涉及到虚拟dom和diff算法, 而且vue的 o参数 代表的虚拟dom 是一颗树形结构 
            // 笔者这里就直接用使用递归for循环来草率对比一下
            if (o === n) return false;
            for (let prop in o) {
                if (typeof o[prop] === 'object') {
                    if (o[prop].length !== n[prop].length) return true;
                    if (Object.keys(o[prop]).length !== Object.keys(n[prop]).length) return true;
                    compare(o[prop], n[prop]);
                } else {
                    if ((o[prop] !== n[prop])) return true;
                }
    
            }
            return false;
        }
    
        /**
         * 同样新提供一个render方法, 他代表刷新页面的操作
         由于刷新页面操作涉及到模板字符串的替换之类的功能
         这里只用一个打印语句做提示
         **/
        function render() {
            console.log('compare监测到两个对象不一致, 所以页面是需要重新渲染的')
        }
    
    
        init();
    
        return data;
    
    }())
    

    在我们增加了compare和render方法以后, 按需渲染的功能也能够达到了, 如下图

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2L8OaV1M-1591941872061)(./新增render和compare方法.gif)]

    1. 我们如何缓存更改, 只响应最后一次数据的变化, 且不论中途数据产生了多少次变化, 只要最后一次数据变回了原样也不会修改呢?

    没错, 答案就是异步, 如果你对事件循环(eventloop)足够清晰的话, 我们知道异步是一定会等到当前执行栈中的全部同步代码都走完才会去执行异步任务的, 异步中微任务又是快于宏任务执行, 而我们既需要只响应最后一次请求又需要尽可能快的去响应操作那么我们势必是要将更新的render函数丢进微任务的, 来看代码

    同样更改的代码会打上注释

    
      const data =  (function () {
        const data = {
           ...
        }
    
        const renderFlag = false; // 渲染锁, 如果锁一旦开启则不会提交render请求进异步队列
    
        function init() {
           ...
        }
    
        function observer(data, nameSpace) {
            ...
        }
    
        function defineReactive(data, prop, value, nameSpace) {
            ...
            Object.defineProperty(data, prop, {
                get() {
                    return value;
                },
                set(newVal) {
                    const o = Object.assign({}, data); 
                    console.log('我在修改' + nameSpace + prop + '的值');
                    value = newVal;
                    // excutorRender会在这里执行
                    excutorRender(o, data);
                }
            })
    
        }   
    
        /**
         * 我们提供一个执行render的方法, 同样这样做的初衷也是我们想在render之前做一些事情 
         **/
        function excutorRender(o, n) {
             if(renderFlag) return;
             renderFlag = true; // 开启锁
             Promise.resolve().then(() => {
                 renderFlag = true; // 关闭锁
                 // 真正对比
                 if(compare(o, n)) render();
             })
        }
    
        function compare(o, n) {
           ...
        }
    
        function render() {
            ...
        }
    
    
        init();
    
        return data;
    
    }())
    
     // 我们在这进行测试
     data.name = 'thor';
     data.name = 'nina';
     data.name = 'jack';
    
     data.address.city = 'guangzhou';
     data.address.city = 'guangzhou';
     data.address.city = 'nanjing';
    
     // 上面修改了name和city值各3次, 且最后值都发生了变化, 我们来看看render方法走了几次
    

    执行结果如下

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xu361sq3-1591941872062)(./异步加载结果1.png)]

    那么如果我们之前更改n次, 最后一次将数据改回原状, 页面会不会重新渲染呢?

    ...
    data.name = 'thor';
    data.name = 'nina';
    data.name = 'jack';
    data.name = 'thor'; // 第四次我们又将name值改回了一开始的thor
    
    data.address.city = 'guangzhou';
    data.address.city = 'guangzhou';
    data.address.city = 'nanjing';
    data.address.city = 'shenzhen'; // 城市也被我们该回了最初的shenzhen
    

    结果如下

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W6X6voTi-1591941872063)(./异步加载结果4.png)]

    很显然, 页面没有进行最后的渲染, 说明我们的目标达成了

    1. 我们的数据结构中存在数组, 但是我们一直在回避数组的更改, 这下我们来处理一下数组, 按照之前所说, 直接修改数组的长度和通过索引来修改数组是不会导致页面重新渲染的

    如果你js基础够好的话, 数组也是特殊的对象, 那么如果我们使用Object.defineProperty来监控数组的变化的话, 那么是一定支持修改数组下表来修改数组值的, 实际上vue为什么不允许呢? 本质上他是可以被修改的, 但是尤雨溪我们的尤大神说了一句特别自信的话:

    通过数组下标去修改数组导致页面重新渲染的话, 用户体验和性能不成正比

    于是乎vue就不支持直接通过数组的索引来修改数组了, 我们也走起

        const data =  (function () {
        const data = {
           ...
        }
    
        const renderFlag = false; // 渲染锁, 如果锁一旦开启则不会提交render请求进异步队列
    
        function init() {
           proxyArray(); // 我们执行代理数组方法
           ...
        }
    
        function observer(data, nameSpace) {
            ...
        }
    
        function defineReactive(data, prop, value, nameSpace) {
            // 所以这里的typeof 的结果为object就不能用在这了, 我们必须绝对判断他为对象, 所以数组进不去我们自然没办法通过索引修改
            if (data[prop] instanceof Object) {
            ...
            }
            ...
        }   
    
        function excutorRender(o, n) {
             ...
        }
    
        function compare(o, n) {
           ...
        }
    
    
        /**
         * 提供一个代理数组的方法
            * **/
        function proxyArray() {
            // 我们这里先什么都不做
        }
    
    
        function render() {
            ...
        }
    
    
        init();
    
        return data;
    
    }())
    

    这个时候我们通过数组的索引去更改数组的值则不会被感知到, 实际上vue也是通过这种方式来屏蔽用户对下标的修改, 如图

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uCl9I4eM-1591941872063)(./屏蔽数组索引.gif)]

    1. ok, 但是我们知道vue是给数组提供了7个变异方法, 当通过这种方式更改数组的时候, 数组是完全可以感知到的, 那么这都是怎么实现的呢? 如下
    ...
     let newArrProto; // 声明新的变量等下用来继承数组原型
    
     // 我们先补全proxyArray函数就好
     function proxyArray(data, prop) {
         const arrMethods = ['push', 'pop', 'unshift', 'shift', 'sort', 'reverse', 'splice'];
    
         const arrProto = Array.prototype; // 拿到数组的原型
         newArrProto = Object.create(arrProto);
    
         arrMethods.forEach(ele => {
             newArrProto[ele] = function() {
                 arrProto.call(this, ...arguments); 
                 // 我们在每次使用了数组的原型方法以后直接重新渲染页面
                 render();
             }
         })   
    
     }
    
     // 在defineReactive中, 我们还要做一些手脚
     function defineReactive(data, prop, value, nameSpace) {
         if(data[prop] instanceof Array) {
             data[prop].__proto__ = newArrProto; // 直接更改data[prop]的原型指向
         }
         // 如果你使用的是instanceof 这里记得要改为elseif, 因为数组 instanceof Object也会返回true
         // 或者你直接使用constructor来判断则不用改成else if
         else if(data[prop] instanceof Object) {
             ...
         }
     }
    
    ...
    

    经过上述操作以后, 我们其实已经可以通过数组变异方法来监控数组的变化了, 如图

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OodNMfDQ-1591941872064)(./数组变异方法.gif)]

    1. 最后一小点, $set$delete
    ...
    // 这两个方法其实都是vue原型上的方法, 经过我们之前这么长的铺垫, 这两个方法已经非常的好写了
    function set(data, prop, newVal) {
        data[prop] = newVal;
        render();
    }
    
    function delete(data, prop) {
        detele(data, prop);
        render();
    }
    
    // 就不演示了, 真的不要太easy
    ...
    

    ok, vue的响应式原理, 笔者应该是一个一个都已经写完了, 希望可以对你产生一些帮助, 这也仅仅只是原理, vue官方对于代码的控制远不是笔者可以比的, 未来在路上, on the road 一起加油。


    传送门

一、该篇博客涉及所有代码github链接:

代码链接

二、vuejs源码github链接

vue源码链接

三、掘金上笔者认为写的比较好的真正的vuejs的响应式源码剖析博客

辉卫无敌对于vue响应式源码的剖析

猜你喜欢

转载自blog.csdn.net/weixin_44238796/article/details/106714526