简单实现一下vue响应式

388 阅读13分钟

使用vue有一段时间了,面试中也经常被问到一些关于vue框架原理性的问题,因为总感觉自己回答的不是蛮好,所以去了解一下vue的一些原理,简单的去实现了属于自己的vue(第一次写文章,写的不好的地方,还请多多包涵)

目录结构

我们先来看一下目录结构

  • index.html :创建一些元素标签,引入上面4个js文件,并且实例化vue对象
  • compile.js :能够对模版中的指令和插值表达式进行解析,并且赋予不同的操作
  • observe.js :能够对数据对象的所有属性进行监听
  • watcher.js :将Compile的解析结果,与Observer所观察的对象连接起来,建立关系,在Observer观察到对象数据变化时,接收通知,同时更新DOM
  • vue.js :创建一个公共的入口对象,接收初始化的配置并且协调上面三个模块,也就是vue

这里放一张网上找的图,来帮助理解实现的思路

下面我们一起来实现一下各个模块吧

index.html

index.html文件中,我们定义了2个插值表达式{{msg}}{{car.brand}},以及vue中的v-htmlv-modelv-on指令,并且还引入了一些js文件,实例化了vm对象,就像使用vue一样!

  <div id="app">
    <p v-text="msg"></p>
    <p>{{msg}}</p>
    <p>{{car.brand}}</p>
    <p v-html="msg"></p>
    <input type="text" v-model="msg">
    <button v-on:click="clickFn">按钮</button>
  </div>
  <script src="./src/watcher.js"></script>
  <script src="./src/observe.js"></script>
  <script src="./src/compile.js"></script>
  <script src="./src/vue.js"></script>
  <script>
    const vm = new Vue({
      el: '#app',
      data: {
        msg: 'hello vue111',
        car: {
          brand: '丰田'
        }
      },
      methods: {
        clickFn() {
          // 在vue的methods中this应该指向当前实例
          this.msg = '有个好消息'
          this.car.brand = '奔驰'
        }
      }
    })

  </script>

vue.js

接下来我们来实现一下vue.js模块,我们在构造函数中设置了3属性:el$data$methods,分别表示vm对象中的el、data、methods。为了便于维护,我们把模板编译相关的代码封装在了compile.js文件中

在创建Compile对象时,我们会传递2个参数:this.$elthis,分别是vm实例的el属性、vm实例

class Vue {
    constructor(options = {}){
        //给vue实例添加一些属性
        this.$el = options.el
        this.$data = options.data
        this.$methods = option.methods
        
        if(this.$el) {
            //负责编辑模板的内容
            let c = new Compile(this.$el, this )
        }
    }
}

compile.js

然后我们来看一下compile模块怎么实现

同样,我们创建Compile类,在构造函数中接收2个参数: el 和 vm

class Compile {
    constructor(el, vm) {
        // el: new vue传递的选择器
        this.el = typeof el === "string" ? document.querySelector(el) : el
        // vm: new 的vue实例
        this.vm = vm
    }
}

文档碎片 DocumentFragment

在模板编译时,要涉及到很多元素标签的编译,就会产生大量的重绘和重排,非常耗性能,幸好DOM为我们提供了文档碎片DocumentFragment,来让我们能够在内存中完成重绘和重排,最后再一次性的添加到页面中,这样就节省了很多性能

DocumentFragment 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。在DOM树中,文档片段被其所有的子元素所代替。

我们可以通过document.createDocumentFragment()来创建文档碎片

let fragment = document.createDocumentFragment();

好了,了解了文档碎片之后,我们继续来实现我们的compile模块, 我们可以把编译过程大致分为3个步骤:

  • 利用文档碎片DocumentFragment, 把el中所有的子节点都放入到内存中
  • 在内容中编译文档碎片
  • 把文档碎片一次性添加到页面

代码如下

class Compile {
    constructor(el, vm) {
        // el: new vue传递的选择器
        this.el = typeof el === "string" ? document.querySelector(el) : el
        // vm: new 的vue实例
        this.vm = vm
        
        //编译模板
        if(this.el) {
            //1. 把el中所有的子节点都放入到内存中
            let fragment = this.node2fragment(this.el)
             //2. 在内存中编译fragment
            this.compile(fragment)
             //3. 把fragment一次性的添加到页面
            this.el.appendChild(fragment)
        }
    }
}

为了便于维护,我们封装了node2fragmentcompile方法,下面我们来具体实现一下这2个方法

node2fragment() 方法代码比较简单,就是一些DOM操作,其中toArray是我们自己封装的方法,用于将伪数组转换成数组

node2fragment(node) {
    let fragment = document.createDocumentFragment()
    //获取el下所有的子节点
    let childNodes = node.childNodes
    this.toArray(childNodes).forEach(node => {
        //将el所有的子节点添加到frament中
        fragment.appendChild(node)
    })
    return fragment
}

然后我们再实现一下compile() 方法,在这个方法里面,我们会遍历子节点,逐个解析,解析时我们考虑到了3种情况:

  • 遇到元素节点,需要解析指令
  • 遇到文本节点,需要解析插值表达式
  • 当前节点还有子节点,需要递归解析
  compile(fragment) {
    let childNodes = fragment.childNodes
    this.toArray(childNodes).forEach(node => {
      // 编译子节点
      if (this.isElementNode(node)) {
        // 如果是元素, 需要解析指令
        this.compileElement(node)
      }

      if (this.isTextNode(node)) {
        // 如果是文本节点, 需要解析插值表达式
        this.compileText(node)
      }

      // 如果当前节点还有子节点,需要递归的解析
      if (node.childNodes && node.childNodes.length > 0) {
        this.compile(node)
      }
    })
  }

上面代码判断中用到的一些条件判断的方法,我们也进行了封装

  /* 工具方法 */
  toArray(likeArray) {
    return [].slice.call(likeArray)
  }
  isElementNode(node) {
    //nodeType: 节点的类型  1:元素节点  3:文本节点
    return node.nodeType === 1
  }
  isTextNode(node) {
    return node.nodeType === 3
  }
}

弄清楚条件判断后,我们再来实现一下compileElement(node)this.compileText(node)方法

先来实现compileElement(node)方法,在这个房里里面,我们会获取元素节点的属性,并解析v-开头的指令。解析指令时,我们考虑了2种情况:

  • v-on:开头的用于注册事件的指令,例如v-on:click
  • v-textv-modol之类的指令
  // 解析html标签
  compileElement(node) {
    // 1. 获取到当前节点下所有的属性
    let attributes = node.attributes
    this.toArray(attributes).forEach(attr => {
      // 2. 解析vue的指令(所以以v-开头的属性)
      let attrName = attr.name
      if (this.isDirective(attrName)) {
        //获取属性名
        let type = attrName.slice(2)
        //获取属性值
        let expr = attr.value
        
        //解析指令v-开头的指令
        //1.v-on:click指令,即给元素注册事件
        //2.v-html、v-text之类的指令
      }
    })
  }
//工具方法
 isDirective(attrName) {
    return attrName.startsWith("v-")
  }

指令的解析我们封装在了一个对象中CompileUtil中,这里其实用到了设计模式中的策略模式

策略模式指的是定义一系列的算法,把它们一个个封装起来。将不变的部分和变化的部分隔开是每个设计模式的主题,策略模式也不例外,策略模式的目的就是将算法的使用与算法的实现分离开来

CompileUtil中我们封装了处理v-textv-htmlv-modelv-on:指令 其中有一点要注意,为了处理复杂类型的数据,我们又封装了getVMValue方法,如下图所示:

let CompileUtil = {
    //处理v-text
     text(node, vm, expr) {
    node.textContent = this.getVMValue(vm, expr)
    // 通过watcher对象,监听expr的数据的变化,一旦变化了,执行回调函数
  },
  //处理v-html
  html(node, vm, expr) {
    node.innerHTML = this.getVMValue(vm, expr)
  },
  //处理v-model
  model(node, vm, expr) {
    let self = this
    node.value = this.getVMValue(vm, expr)
    // 实现双向的数据绑定, 给node注册input事件,当当前元素的value值发生改变,修改对应的数据
    node.addEventListener("input", function() {
      self.setVMValue(vm, expr, this.value)
    })
  },
  //处理v-on:
  eventHandler(node, vm, type, expr) {
    // 给当前元素注册事件即可
    let eventType = type.split(":")[1]
    let fn = vm.$methods && vm.$methods[expr]
    if (eventType && fn) {
      node.addEventListener(eventType, fn.bind(vm))
    }
  },
  // 这个方法用于获取VM中的数据
  getVMValue(vm, expr) {
    // 获取到data中的数据
    let data = vm.$data
    expr.split(".").forEach(key => {
      data = data[key]
    })
    return data
  },
   setVMValue(vm, expr, value) {
    let data = vm.$data
    let arr = expr.split(".")

    arr.forEach((key, index) => {
      // 如果index是最后一个
      if (index < arr.length - 1) {
        data = data[key]
      } else {
        data[key] = value
      }
    })
  }
}

封装完这些指令处理方法后,我们再来完善compileElement(node)方法

  // 解析html标签
  compileElement(node) {
    // 1. 获取到当前节点下所有的属性
    let attributes = node.attributes
    
    this.toArray(attributes).forEach(attr => {
      let attrName = attr.name
      if (this.isDirective(attrName)) {
        let type = attrName.slice(2)
        let expr = attr.value
        
        //解析v-on指令
        if (this.isEventDirective(type)) {
          CompileUtil["eventHandler"](node, this.vm, type, expr)
        } else {
            //解析v-text、v-html、v-model
          CompileUtil[type] && CompileUtil[type](node, this.vm, expr)
        }
      }
    })
  }

好了,处理元素节点的compileElement(node)方法,我们已经大致实现,接下来,我们再来实现一下处理文本节点的compileText(node)方法

为了便于维护,我们把实现方法mustache依然封装在CompileTil对象中

 // 解析文本节点
  compileText(node) {
    CompileUtil.mustache(node, this.vm)
  }
  
  let CompileUtil = {
  mustache(node, vm) {
    let txt = node.textContent
    let reg = /\{\{(.+)\}\}/
    if (reg.test(txt)) {
      let expr = RegExp.$1
      node.textContent = txt.replace(reg, this.getVMValue(vm, expr))
    }
  },
    // 这个方法用于获取VM中的数据
  getVMValue(vm, expr) {
    // 获取到data中的数据
    let data = vm.$data
    expr.split(".").forEach(key => {
      data = data[key]
    })
    return data
  },

}

致辞我们大致完成了编译模块Compile.js

Observe.js

为了实现响应式,如果vue实例中的data里的数据发生改变时,我们就需要通知html里面的内容及时更新过来,在Observe.js模块中,我们主要劫持了vue实例中的data里面所有的数据,方便我们在获取或者设置data中数据的时候,实现我们的逻辑

在实现data里面的数据劫持之前,我们先讨论一个前置知识Object.defineProperty()

下面代码中,我们通过Object.defineProperty()方法劫持了obj对象的likeGame属性的getter和setter

在获取obj对象的likeGame属性时,会执行get()方法,改变likeGame属性时,会执行set方法

    let obj = {
      likeGame: "英雄联盟"
    }

    let temp = obj['likeGame']  //1、获取`obj`对象的`likeGame`属
    temo.likeGame = "地下城"    //2、改变`likeGame`属性
    
    Object.defineProperty(obj, 'likeGame', {
      configurable: true, // 表示属性可以配置
      enumerable: true, // 表示这个属性可以遍历
      get() {
        // 每次获取对象的这个属性的时候,就会被这个get方法给劫持到
        console.log('获取了obj对象的likeGame属性,执行了get方法')
        return temp
      },
      // 每次设置这个对象的属性的时候,就会被set方法劫持到
      set(newValue) {
        console.log('设置了obj对象的likeGame属性,执行了set方法')
        temp = newValue
      }
    })
    
    
    //最终打印
    //获取了obj对象的likeGame属性,执行了get方法    1
    //设置了obj对象的likeGame属性,执行了set方法    2
        

了解了Object.defineProperty后,我们继续来实现observe.js模块

我们在构造函数里面接收传过来的参数data,即vue实例中的data数据,并且通过调用walk()方法来完成data数据劫持

为了给data对象的key设置getter和setter,我们把数据劫持代码封装在defineReactive()方法中,该方法会在下面实现

class Observer {
  constructor(data) {
    this.data = data
    this.walk(data)
  }

  /* 核心方法 */
  /* 遍历data中所有的数据,都添加上getter和setter */
  walk(data) {
    if (!data || typeof data != "object") {
      return
    }

    Object.keys(data).forEach(key => {
      // 给data对象的key设置getter和setter
      this.defineReactive(data, key, data[key])
      //递归
      this.walk(data[key])
    })
  }

注意到,上面代码中用到了递归this.walk(data[key]),我们去掉这一行代码会怎么样呢

假如,data是下面的代码结构,我们去掉this.walk(data[key])这一行代码,就只能劫持data对象的like属性,没法劫持到like里面的animal属性,所以这里用到了递归

data = {
    like : { animal : dog}
}

defineReactive()方法代码如下,目前这个方法里只是劫持到了data里面的数据,还没有写一些响应式的逻辑,不着急,我们后面再来实现

  defineReactive(obj, key, value) {
    let that = this
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        return value
      },
      set(newValue) {
        if (value === newValue) {
          return
        }
        value = newValue
      }
    })
  }

接下来,我们还要再vue.js模块的构造函数中创建Observe对象,这样页面加载时,就监视了vue实例的data属性

class Vue {
    // 给vue实例增加属性
    this.$el = options.el
    this.$data = options.data
    this.$methods = options.methods
    
    // 监视data中的数据(加上这一行代码)
    new Observer(this.$data)

    if (this.$el) {
      // compile负责解析模板的内容
      let c = new Compile(this.$el, this)
    }
}

watcher.js

在实现了编译模块compile.js和数据劫持模块observe.js后,我们接下来要做的就是把这2个模块关联起来,为了实现这样的效果,当vue实例中的data数据发生改变时,通知视图层去同步更新内容,来看看watcher.js模块里面的代码

watcher.js模块里面的update()方法用来更新视图

class Watcher {
  // vm: 当前的vue实例
  // expr: data中数据的名字
  // 一旦数据发生了改变,需要调用cb
  constructor(vm, expr, callBack) {
    this.vm = vm
    this.expr = expr
    this.callBack = callBack
    
    // this表示的就是新创建的watcher对象
    // 存储到Dep.target属性上
    Dep.target = this
    // 清空Dep.target
    Dep.target = null
  }
  }

  update() {
    let oldValue = this.oldValue
    let newValue = this.getVMValue(this.vm, this.expr)
      this.callBack(newValue, oldValue)
   
  }

  //用于获取vm中的数据
  getVMValue(vm, expr) {
    // 获取到data中的数据
    let data = vm.$data
    expr.split(".").forEach(key => {
      data = data[key]
    })
    return data
  }
}

假如vue实例中的data有一个属性为name,那么页面中可能就有很多地方都要用到name,例如

  <p v-text="name"></p>
  <p>{{name}}</p>
  <p v-html="name"></p>

因此我们就需要给data中每个属性都要创建一个容器来管理这些需要依赖name属性的元素标签,其实也叫订阅者

我们在watcher.js模块在创建Dep类来管理这些订阅者

在Dep中,通过addSub()方法添加订阅者,通过notify方法通知所有的订阅者去更新视图

/* dep对象用于管理所有的订阅者和通知这些订阅者 */
class Dep {
  constructor() {
    // 用于管理订阅者的容器
    this.subs = []
  }

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

  // 通知
  notify() {
    // 遍历所有的订阅者,调用watcher的update方法
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

视图层,每一个用到了v-开头的指令的元素,实际上就是一个订阅者,因此在Compile.js中解析指令时,每一次解析指令,我们都要创建一个订阅者对象

所有我们再来修改下Compile.js模块中解析v-开头的指令的逻辑,而我们把这部分逻辑封装在了CompileUtil中,直接修改CompileUtil中的代码即可 可以看到我们在每个方法里面都new了一个Watcher对象,既订阅者

let CompileUtil = {
  mustache(node, vm) {
    let txt = node.textContent
    let reg = /\{\{(.+)\}\}/
    if (reg.test(txt)) {
      let expr = RegExp.$1
      node.textContent = txt.replace(reg, this.getVMValue(vm, expr))
      
      new Watcher(vm, expr, newValue => {
        node.textContent = txt.replace(reg, newValue)
      })
    }
  },
  // 处理v-text指令
  text(node, vm, expr) {
    node.textContent = this.getVMValue(vm, expr)
    
    // 通过watcher对象,监听expr的数据的变化,一旦变化了,执行回调函数
    new Watcher(vm, expr, (newValue, oldValue) => {
      node.textContent = newValue
    })
  },
  html(node, vm, expr) {
    node.innerHTML = this.getVMValue(vm, expr)
    
    new Watcher(vm, expr, newValue => {
      node.innerHTML = newValue
    })
  },
  model(node, vm, expr) {
    let self = this
    node.value = this.getVMValue(vm, expr)
    // 实现双向的数据绑定, 给node注册input事件,当当前元素的value值发生改变,修改对应的数据
    node.addEventListener("input", function() {
      self.setVMValue(vm, expr, this.value)
    })
    
    new Watcher(vm, expr, newValue => {
      node.value = newValue
    })
  }
}

接下来我们再来管理这些订阅者,就是给data里面每个属性都创建一个容器来管理这些订阅者

observe.js模块的defineReactive(),我们劫持了data里面的每个属性,一次我们在劫持每个属性时,就创建一个Dep实例来管理解析指令时创建的订阅者,我们来完善defineReactive()方法

defineReactive()方法开头我们实例化了Dep对象,然后在get()里面添加订阅者,在set方法里面发布通知,让所有的订阅者更新内容

  // data中的每一个数据都应该维护一个dep对象
  // dep保存了所有的订阅了该数据的订阅者
  defineReactive(obj, key, value) {
    let that = this
    let dep = new Dep()     //增加的代码
    
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 如果Dep.target中有watcher对象,存储到订阅者数组中
        Dep.target && dep.addSub(Dep.target)    //增加的代码
        return value
      },
      set(newValue) {
        if (value === newValue) {
          return
        }
        value = newValue
        
        // 如果newValue是一个对象,也应该对她进行劫持
        that.walk(newValue)     //增加的代码
        
        // 发布通知,让所有的订阅者更新内容
        dep.notify()    //增加的代码
      }
    })
  }

好了基本上已经差不多了,我们最后再做一个优化,用过vue的都知道,我们可以直接用vue实例方法来点出data数据和方法,但是在我们的Demo里面只能通过vm.$data.namevm.$method.getName()来访问vm的data数据和方法

let attr = vm.name
vm.getName()

我们可以把vm里面的datamethod代理到vm上面来,下面再来完善一下vue.js模块里面的代码

实现原理说白了就是利用Object.defineProperty()来劫持vm的属性和方法

例如我们要访问vm实例里name属性,会这样写 :vm.name

通过proxy()里面定义的get()方法,可以知道,最后获取到的其实是vm.$data.namemethod也是同理

/* 定义一个类,用于创建vue实例 */
class Vue {
  constructor(options = {}) {
    // 给vue实例增加属性
    this.$el = options.el
    this.$data = options.data
    this.$methods = options.methods

    // 监视data中的数据
    new Observer(this.$data)

    // 把data中所有的数据代理到了vm上
    this.proxy(this.$data)
    // 把methods中所有的数据代理到了vm上
    this.proxy(this.$methods)
    // 如果指定了el参数,对el进行解析
    
    if (this.$el) {
      // compile负责解析模板的内容
      // 需要:模板和数据
      let c = new Compile(this.$el, this)
    }
  }

  proxy(data) {
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key]
        },
        set(newValue) {
          if (data[key] == newValue) {
            return
          }
          data[key] = newValue
        }
      })
    })
  }
}

结束语

vue数据绑定原理,在面试中也是很容易被问到的,自己尝试着实现一下,以后面试再被问到vue原理之类的问题时,或许可以回答的更细致一点