# Vue-响应式原理

🐴

# 前言

Vue2.x已经用来好几年了,这里再次温习一下Vue2.x的响应式原理。

# 原理

Vue2.x响应式是使用了Object.defineProperty劫持数据 + 发布订阅者模式

Vue2.x的响应式大致可以描述成这样:

  1. 创建一个观察者Observer,在初始化时,将Vue data中的数据添加getset方法。来实现对数据变化的监听。
  2. 实现订阅者Watcher, Watcher具有更新函数,用来通知更新视图
  3. 实现一个订阅器Dep,用来存储订阅者。该订阅器具有添加订阅者addSub方法和通知订阅者notify方法。
  4. 实现一个解析器Complie,该方法主要作用就是更新视图,将视图节点与数据进行绑定(ObserverWatcher进行关联)。
  5. Vue实例的实现,初识化数据,执行各个方法,将data数据代理到Vue实例上。

new Vue()时,首先初识化data中的数据,调用观察者Observer,给data数据添加getset方法。然后将数据代理绑定到Vue实例上。

初始化视图时调用Complie, 遍历DOM元素,根据正则匹配{{}}中的数据,替换真正的data中的值,并创建订阅者Watcher。当读取data中的值时,就会调用该值的get方法。此时将订阅者添加到订阅器Dep中。至此响应式的基础大致过程完成。

# 实现

根据上面提到的流程我们要是实现基础的响应式,需要实现下面方法

  • 观察者Observer
  • 订阅者Watche
  • 订阅器Dep
  • 解析器Complie
  • Vue构造函数

下面我们简单实现一下上述过程

# 监听器Observer

class Observer{
  // 初始化
  init(data){
    if(!data || typeof data !== 'object'){
       return;
    }
    Object.keys(data).forEach(key=>{
      this.defineReactive(data,key,data[key])
    })
  }
  // 为所有属性添加getter setter 方法
  defineReactive(data,key,val){

    // 递归添加get set
    this.init(data[key])

    const dep = new Dep();
    Object.defineProperty(data,key,{
      enumerable: true, // 可以枚举
      configurable: true, // 可以设置
      set(newValue){
        // 新值和旧值是否相等
        if(val === newValue) return;

        val = newValue;
        dep.notify(); // 通知订阅者,数据有变化

      },
      get(){
        Dep.target && dep.addSub(Dep.target); // 存储订阅者
        return val;
      }
    })
  }
}

上面我们实现了监听器Observer的主要功能。可以测试一下:

const Obj = {
  a:1,
  b:2
}

const Observer = new Observer();

Observer.init(Obj);
Obj.a;

# 订阅者Wathcer

class Watcher{
  // vm vue实例
  // exper vue模板如{{a}} {{obj.b}}
  // callback 更新视图得回调函数,
  constructor(vm,expr,callback){
    this.vm = vm;
    this.expr = expr;
    this.callback = callback;
    this.value = this.getVal();

    return this;
  }  
  
  update(){ // 更新函数
    this.run();
  }
   
  run(){ // 执行
    const value = this.getDataVal();
    const oldValue = this.value;
    if(value !== oldValue){
      this.value = value;
      this.callback.call(this.vm);
    }
  }
  getVal(){
     Dep.target = this;

     // 这里会调用 get 方法,将订阅者添加到 订阅容器中
     const  value = this.getDataVal();
     Dep.target = null;

     return value;
  }
  // 获取data中的值
  getDataVal(){
    const valAry = this.expr.split(".");
    return valAry.reduce((prev,next)=>{
       return prev[next]
    },this.vm.$data)
  }
}

订阅者Watcher中,getVal方法主要的作用是,当Watcher创建时,立即读取data数据中的数据,调用getVal方法,该方法将Dep.target变量设置成当前Watcher实例, 然后调用getDataVal获取data中对应得值, 此时会触发所读取值得get方法。在get方法中判断Dep.target存在时,调用订阅器得addSub方法,将订阅者添加到订阅器中。下面是订阅器得代码

# 订阅器

订阅器代码很少,但是很重要。

class Dep{
  constructor(){
    this.subs = [] // 用于存储订阅者
  }

  addSub(sub){ // 添加订阅者
    this.subs.push(sub);
  }

  notify(){ // 通知订阅者
    this.subs.forEach(sub=>{
      sub.update();
    })
  }
}

# 解析器Compile

解析器Complie的主要作用就是解析Vue模板,连接视图和数据,为订阅者Watcher和观察者 Observer搭建一个桥梁。

class Complie{
  constructor(vm,el){
     this.vm = vm;
     this.el = el;
     this.fragment = this.nodeToFragment();
     this.init();
  }

  init(){
    this.complieEle(this.fragment);
    this.el.appendChild(this.fragment);
  }

  // 遍历Dom元素,将其添加到Dom片段中
  nodeToFragment(){ 
    const fragment = document.createDocumentFragment();
    let child = this.el.firstChild;
    while(child){
      fragment.appendChild(child);
      child = this.el.firstChild;
    }

    return fragment;
  }

  // 递归编译元素
  complieEle(el){
     // 获取el 子元素集合
     const childNodesList = el.childNodes;
     const reg = /\{\{([^}]+)\}\}/g;
     
     [...childNodesList].forEach(node=>{
        const text = node.textContent;
        // 判断是否符合{{}}指令
        if(this.isTextNode(node) && reg.test(text)){ // 文本节点
          CompileUtil.text(this.vm,node,text);
        }else if(this.isElementNode(node)){ // 元素节点
          // 获取dom节点所有的属性
          const attrs = node.attributes; 
          Array.from(attrs).forEach(attr=>{
            const attrName = attr.name;
            
            // 判断是否为 v- 指令 如 v-model v-html v-text等
            if(this.isDirective(attrName)){
               const attrVal = attr.value;
               const [,type] = attrName.split("-");
               CompileUtil[type](this.vm,node,attrVal);
            }
          })
       
          // 对子节点进行递归
          if(node.childNodes && node.childNodes.length){
            this.complieEle(node)
          }
        }
        
     })
  }

  // 是否是指令
  isDirective(attr){
     return attr.indexOf("v-") === 0;
  }

  // 处理模板 {{}} 数据
  compileText(node,key){
     const text = this.vm.$data[key];

     // 初始化视图
     this.updateView(node,text);

     // 创建订阅者 并绑定回调函数
     new Watcher(this.vm,key,(value)=>{
       this.updateView(node,value)
     })
  }

  // 更新视图
  updateView(node,value){
    console.log(111,node.textContent)
    // node.textContent = typeof value === 'undefined' ? '' : value;
  }
 

  // 判断是否为文本节点textNode
  isTextNode(node){
   return node.nodeType === 3;
  }

  // 判断是否为元素节点
  isElementNode(node){
    return node.nodeType === 1;
   }

}

Compile初识化时,将遍历DOM节点,使用createDocumentFragment创建节点碎片。然后我们通过complieEle方法递归遍历元素,将元素中得指令,以及{{}}进行解析。解析到Vue得响应式数据时创建订阅者Watcher,并传入响应得更新回调函数。

另外我们将常用得解析方法封装到了CompileUtil中,代码如下:

// 解析器常用方法
const CompileUtil = {
   RegText:/\{\{([^}]+)\}\}/g,
   getVal(vm,expr){
     /**
      * 得到data中对应的数据,exper 可能为 obj.a.b 形式
      * 需要拿到data中的数据
      * {
      *   obj:{
      *      a:{b:1}
      *   }
      * }
      */
     const valAry = expr.split(".");
     return valAry.reduce((prev,next)=>{
        return prev[next]
     },vm)
   },
   setVal(vm,expr,val){
    const valAry = expr.split(".");
    valAry.reduce((prev,next,index)=>{
      if((index + 1) === valAry.length){
        prev[next] = val;
      }
      return prev[next]
    },vm)
   },
  //处理模板 {{}} 数据
   text(vm,node,exper){ // 编译text
     const val = exper.replace(this.RegText,(...arg)=>{
          // arg 为匹配的数据信息数组
          // 创建观察者
          new Watcher(vm,arg[1],()=>{
              const val = exper.replace(this.RegText,(...arg)=>{
                return this.getVal(vm,arg[1]);
              })
            
              this.updater.textUpdater(node,val)
          })

        // 创建订阅者 并绑定回调函数
        return this.getVal(vm,arg[1]);
     })

   
     this.updater.textUpdater(node,val)
   },
   // 处理v-model数据
   model(vm,node,exper){
      const val = this.getVal(vm,exper);
      
      // 创建订阅者
      new Watcher(vm,exper,()=>{
        this.updater.modelUpdater(node,this.getVal(vm,exper))
      })
      this.updater.modelUpdater(node,val)
      this.formChange(vm,node,exper)
   },

   formChange(vm,node,exper){ // 表单input事件
     
     node.addEventListener("input",(e)=>{
       const value = e.target.value;
       this.setVal(vm,exper,value)
       
     })
   },

   updater:{
     textUpdater(node,value){
       node.textContent =  value;
     },
     modelUpdater(node,value){
       node.value = typeof value === 'undefined' ? '' : value;
     }
   }
}

# Vue构造函数

class NVue{
  constructor(option){
    const {data} =  option;
    this.option = option;
    this.$data = typeof data === 'object'?data:data();
    this.$el = null;
    this.init()

    return this;
  }
  init(){

    // 初始化数据
    this.initState()

    // 代理数据到vue实例上
    this.proxyData(this.$data)

    // 渲染页面
    this.$el = document.querySelector(this.option.el);
    new Complie(this,this.$el)
  }

  // 初始化数据
  initState(){
    this.initData();
  }

  // 初始化data数据
  initData(){
    const Obs = new Observer();
    Obs.init(this.$data)
  }
  
  /**
   *@msg 代理,将数据绑定到Vue实例上 
   */
  proxyData(data){
    Object.keys(data).forEach(key=>{
      Object.defineProperty(this,key,{
         enumerable: true, // 可以枚举
         configurable: true, // 可以设置
         set(newValue){
           data[key] = newValue
         },
         get(){
            return data[key] 
         }
      })
    })
  }
}

# 使用

<body>
  <div id="main">
      <input v-model="a" type="text">
      <h1>hello {{a}} {{obj.b}}</h1>
  </div>
</body>

<script src="./index.js"></script>

<script>
  let main = document.getElementById("main");
    
  const vueObj = new NVue({
    el:"#main",
    data(){
      return {
        a:"小明",
        obj:{
          b:"小红",
        }
      }
    },
  })

  console.log(vueObj)
  setTimeout(()=>{
    vueObj.obj.b = "1111"
  },2000)

</script>

# Vue2.X响应式缺点

Vue2.x的响应式主要依赖于Object.definedProprety实现的。然而Object.definedProprety具有一下弊端

  • 不支持IE8
  • 无法监听到对象属性的动态添加和删除
  • 无法监听到数组下标和length长度的变化

当然上边问题,vue2.x 通过 $set方法进行的补充修复

参考文献

最近更新时间: 7/2/2021, 11:27:27 AM