Vue系列:Vue2 响应式原理

908 阅读6分钟

这篇文章通过简单地剖析源码的方式来理解vue的响应式原理。因为仅仅为了了解其原理,所以简单写了一下响应式大致的原理。还有很多边界情况和实际问题的处理和源码有出入,比如没有使用DOMdiff算法,没有递归劫持数据等。

本文所有代码-github,每小节都对应一个commit

vue、react都是单向数据流,vue双向绑定只是语法糖,react也可以实现双向绑定。

Vue 的双向绑定基于数据劫持+观察者模式实现。

对象内部使用 Object.defineProperty 将属性进行劫持(只会劫持已经存在的属性)。基于观察者模式,当页面使用对应属性时,每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher (依赖收集),当属性变化后会通知自己对应的 watcher 去更新(派发更新)。

整体结构

首先我们使用vue时都会新建一个vue实例

let vm = new Vue({
    el:'#app',
    data:{
        name:"涛涛"
    }
});

所以我们写一个vue类。

  1. optionseldata 挂载到实例上。私有属性 $el 对应的是App元素,私有属性 $data 对应的是传入的 data
  2. 有两个函数需要注意,一个是 observe(this.$data) 函数,用来劫持数据,所以要把 data 传入函数中
  3. 另一个函数是 nodeToFragment(this.$el,this) ,负责模板编译(渲染数据),借助 Fragment ,编译vue语法中的行内属性或小胡子语法等。
  4. 最后在劫持数据与模板编译的过程中,使用观察者模式,去依赖收集、派发更新
function Vue(option){
    // 私有属性 $el 对应的是App元素
    // 私有属性 $data对应的是传进来的data
    this.$el = document.querySelector(option.el)
    this.$data = option.data
    // 先去劫持数据
    observe(this.$data)
    nodeToFragment(this.$el,this) // 负责模板编译(渲染数据)
    // 借助Fragment,编译vue语法中的行内属性或小胡子语法等
}

我们先不管如何基于观察者模式进行数据与视图的双向绑定的,我们先看数据劫持和模板编译大概是什么样的过程。

先看数据劫持

数据劫持

对象内部使用 Object.defineProperty 将属性进行劫持。

observe 函数代码如下(这里为了简化,没有进行递归得进行数据劫持,也没有考虑数组的情况):

function observe(data){
    //  判断 data是否是一个对象
    if(({}).toString.call(data) !== '[object Object]')return ;
    let keys = Object.keys(data);// 遍历所有属性,然后对每一个属性进行劫持
    keys.forEach(key=>{
        defineReactive$$1(data,key,data[key])
    })
}
function defineReactive$$1(target,key,val){
    Object.defineProperty(target,key,{
        enumerable:true,
        get(){
            return val
        },
        set(newV){
            val = newV
        }
    })
}

image。png

image。png

模板编译

nodeToFragment 模板编译。过程:

  1. 整体过程是借助文档碎片,把文档上的所有节点的vue语法编译完,转移到文档碎片上,然后将文档碎片再转移回$el,完成模板编译的过程
    • 使用 while 循环遍历所有的节点,对节点进行操作,并将其转移到 fragment 上,原理是每转移一次,就会少一个节点,下一个节点会变成第一个,最后没有节点的时候, firstChild 会返回 null ,循环结束
  2. compile 编译函数原理:先要判断 节点的类型,看他是元素节点还是文本节点( nodeType 1 元素节点, 3 文本节点)。如果是元素节点我们要考虑是行内属性和他的子节点,文本节点直接进行小胡子语法替换即可。以 v-model 和小胡子语法的编译为例:
    • 元素节点处理的是行内属性 ,除了行内属性,还要递归遍历编译子节点。 代码如下
function nodeToFragment(el,vm){
    // 借助文档碎片,把文档上的所有节点的vue语法编译完,转移到文档碎片上,然后将文档碎片再转移回$el,完成模板编译的过程
    let fragment = document.createDocumentFragment();
    let child;
    //循环遍历所有的节点,对节点进行操作,并将其转移到fragment上
    // 每转移一次,就会少一个节点,下一个节点会变成第一个,最后没有节点的时候,firstChild会返回nul,循环结束
    while(child = el.firstChild){
        //编译node节点
        compile(child,vm)
        fragment.appendChild(child)
    }
    el.appendChild(fragment)
}
function compile(node,vm){
    // 编译 node节点.
    // 原理:先要判断 节点的类型,看他是 元素节点还是文本节点(nodeType1元素节点 3文本节点 8注释节点 9根节点)
    // 元素节点我们要考虑是 行内属性和他的子节点
    // 文本节点直接进行小胡子语法替换即可
    
    //例如编译v-model和小胡子语法的处理:
    if(node.nodeType == 1){
        //元素节点
        let attrs = node.attributes; // 获取所有的行内属性
        [...attrs].forEach(item=>{
            // 获取 v-开头的行内属性 v-model (v-model="name")
            console.dir(item)
            if(/^v-/.test(item.nodeName)){ //"v-model"
                //使用正则取到取到 v-model对应的个值
                let vName = item.nodeValue ;// "name"
                let val = vm.$data[vName] ;// 获取到了 "涛涛"  这两个字

                node.value = val;// 把 "涛涛"这两个字 放到 input框中
            }
        });
        
        // 以上处理的是行内属性 ,除了行内属性,还要递归遍历编译子节点
        [...node.childNodes].forEach(item=>{ // 递归编译 子节点
            compile(item,vm)
        })
    }else{
        // 文本节点  我们需要获取对应的文本字符串  然后把里边的小胡子转成真实Data
        let str = node.textContent;// "{{name}}"
        if(/{{(\w+)}}/.test(str)){//正则解析小胡子语法
            str = str.replace(/{{(\w+)}}/,(a,b)=>{
                // b ---> name属性
                return vm.$data[b]//返回name属性的data 值
            })
            console.log(str)
            node.textContent = str;//替换小胡子语法
        }
    }
}

image。png

此时vue模板已被成功编译

使用观察者模式添加响应式

基于观察者模式,当页面使用对应属性时,每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher (依赖收集),当属性变化后会通知自己对应的 watcher 去更新(派发更新)。

什么是观察者模式

可以看看我以前的文章js设计模式-观察者模式

  • 定义观察者 observer Watcher ):形式可以不一样,只需要观察者具备 update 方法即可,用来接收到消息的时候做出反应

  • 定义目标 SubjectDep ):目标用来管理观察者,将具有 update 方法的观察者加入到观察者列表, observerList

  • 当目标发送信息( notify )的时候,可以把消息传给观察者,观察者触发 update 方法即可

所以

    //创造一个订阅器(目标)
    class Dep{
        // 每一个属性 都应该有自己的订阅器
        constructor(){
            this.subs = [];//观察者池
        }
        addSub(sub){
            this.subs.push(sub)//添加观察者
        }
        notify(){// 负责通知事件池中的事件执行,通知观察者执行update方法
            this.subs.forEach(sub=>{
                // sub是watcher实例,在new Watch()依赖收集的的时候,被加进来的
                sub.update()
            })
        }
    }
    //创造一个订阅者(观察者),进行依赖收集
    class Watcher{
        constructor(node,key,vm){
            console.log('watcher被触发了,依赖收集,将watcher对象添加到目标池子中')
            Dep.target = this;// this就是watcher实例,当


            this.node = node;
            this.key = key;
            this.vm = vm;
            // 因为进行过数据劫持,所以会触发我们的get函数
            //这个代码触发数据的get,可以让我们把 当前的这个watcher实例(也是Dep.target)放到对应的事件池中(依赖手机)
            this.getValue();
            Dep.target = null
        }
        update(){
            console.log('修改了data,触发set,watcher的update被调用了,更新DOM')
            // 负责更新DOM
            this.getValue();// 获取新的value值
            if(this.node.nodeType == 1){
                // 只考虑input框
                this.node.value = this.value
            }else{//文本节点
                this.node.textContent = this.value
            }
        }
        getValue(){
            this.value = this.vm.$data[this.key];// 因为进行过数据劫持,所以会触发我们的get函数
        }
    }

响应式过程

所以整个流程是

  1. 编译阶段使用 watcher 进行依赖收集,把所有用到data的地方记录下来(例如小胡子语法,或者行内属性 v-model ),放到每一个data属性的目标(sub)的池子里。

  2. 当修改数据的时候,例如在input框中输入数据,触发 vm.$data[vName] = e.target.value ,或者直接修改数据 vm.$data[vName] = 'xxx' ,那么就会触发 setwatcherupdate 被调用,更新DOM

image。png

image。png

image。png

总结

Vue 的双向绑定在劫持数据模板编译的过程中,使用观察者模式,去实现的

  1. data对象内部使用 Object.defineProperty 递归地将属性进行劫持。基于观察者模式,每一个被劫持的属性都有一个目标池,用来存放收集到的watcher,就是观察者,然后设置属性的getset
  2. 当模板编译到例如v-model或者小胡子语法的时候,就会触发get,这个时候会将watcher收集到每一个属性的目标池中。这个过程就是依赖收集
  3. 当修改数据的时候,例如输入框中进行输入,或者直接修改数据,就会触发set,这个时候会将目标池中的所有watcherupdate方法调用,更新DOM,这个过程就是派发更新

本文所有代码-github,每小节都对应一个commit