前言:最近关注了vue,想对比一下和react的实现的区别,方便以后项目做选型。

打算了解对比一下基本实现,才能写出更符合 框架设计思想高效率的代码,出了问题才能更好的排错。

这个版本并没有实现四则运算,和生命周期以及数组的方法劫持,只是做了简单的响应式分析,后续会做一版虚拟dom的分析

vue响应式模拟

准备工作

  • 数据驱动
  • 响应式核心
  • 发布订阅和观察者
数据驱动
  • 数据响应式:数据模型仅仅是普通的js对象,而当我们修改数据时,视图会进行对应的更新,避免了繁琐的dom操作,提高开发效率
  • 双向绑定:数据改变,视图改变;视图改变,数据也随之改变,使用v-model在表单元素上创建双向数据绑定
  • 数据驱动是vue最独特特性之一:开发过程仅需要关注数据本身,不需要关心数据是如何渲染到视图的
数据响应式

vue2是用的Object.defineProperty来进行对对象的get,set进行劫持来实现的双向绑定类似:

let data ={msg:'hello'}
let vm={}
Object.defineProperty(vm,'msg',{
    enumerable:true,
    configurable:true,
    get(){
        return data.msg
    }
    set(newValue){
        if(newValue === data.msg){
            return 
        }
        data.msg=newValue
        document.querySelector('#app').textContent=data.msg
    }
})

vm.msg='HelloWorld'
console.log(vm.msg)

vue3使用的是代理对象,直接监听对象,不监听属性,性能由浏览器优化,

let data={msg:'hello',count:0}
let vm = new Proxy(data,{
    get(target,key){
        return target[key]
    }
    set(target,key,newValue){
        if(target[key] === newValue){
            return 
        }
        target[key] = newValue
        document.querySelector('#app').textContent=target[key]
    }
})

贴个图来展示下俩的区别
image.png

  • Object.defineProperty 只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。
  • Object.defineProperty不能监听数组。是通过重写数据的那7个可以改变数据的方法来对数组进行监听的。
  • Object.defineProperty 也不能对 es6 新产生的 Map,Set 这些数据结构做出监听。

Proxy不会直接侵入对象去做劫持,而是直接对对象整体进行包装一层。

不过我们目前实现的简易版vue是基于vue2去做的。

发布/订阅模式和观察者模式

发布/订阅模式

  • 订阅者
  • 发布者
  • 信号中心

说明:我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern)
vue中的自定义事件就是一个发布订阅模式$emit,$on.

//eventBus.js
//事件中心
let eventHub=newVue()
//ComponentA.vue
//发布者
addTodo:function(){//发布消息(事件)
    eventHub.$emit('add-todo',{text:this.newTodoText})
    this.newTodoText=''
 }
//ComponentB.vue//订阅者
created:function(){//订阅消息(事件)
    eventHub.$on('add-todo',this.addTodo)
}

我们模拟一个vue的发布订阅,如下

class EventEmitter{
    constructor(){
        this.subs={}
    }
    $on(eventType,handler){//订阅
        this.subs[eventType] = this.subs[eventType]||[]
        this.subs[eventType].push(handler)
    }
    $emit(eventType){//发布
        if(this.subs[eventType]){
            this.subs[eventType].forEach(handler=>{
                handler()
            })
        }
    }
}

var bus= new EventEmitter()
//注册事件
bus.$on('click',function(){
    console.log('click')
})
bus.$on('click',function(){
    console.log('click1')
})
//触发事件
bus.$emit('click')
观察者模式
  • 观察者watcher:update当事件发生时更新
  • 目标(发布者)-Dep

    • subs数组:储存所有观察者
    • addsub添加观察者
    • notify事件发生时通知观察者
  • 没有事件中心
class Dep{
    constructor(){
        this.subs=[]
    }
    addsub(sub){
        if(sub&&sub.update){
           this.subs.push(sub)
        }
    }
    notify(){
        this.subs.forEach(sub=>{sub.update()})    
    }
}
class Watcher{
    update(){
        console.log('更新六')
    }
}
let dep= new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
总结

观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。

发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。

从使用层面上讲:

  • 观察者模式,多用于单个应用内部
  • 发布订阅模式,则更多的是一种跨应用的模式(cross-application pattern),比如我们常用的消息中间件
    image.png

然后我们分析一下vue中的实例化及更新流程,如图
image.png

  • vue:把传入的data注入vue实例,并且转换为set/get
  • Observer:能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知 Dep,Dep再调用观察者更新
  • Compiler:解析每个元素中的指令/插值表达式,并替换成相应的数据
  • Dep:添加观察者(watcher),当数据变化通知所有观察者
  • Watcher:数据变化更新视图

然后我们一个一个来实现对应的类

vue

功能:

  • 负责接收初始化的参数(选项)
  • 负责把data中的属性注入到vue实例中,转换为getter/setter
  • 负责调用observer监听data中所有属性变化,进行劫持
  • 分则调用compiler解析指令/插值表达式

结构:
image.png
代码实现如下:

class Vue {
  constructor(options) {
    // 1 通过实例属性来保存传进来得配置及数据
    options = options || {}
    this.$options = options
    this.$data = options.data || {}
    // $el存储真实dom对象方便后续更新元素使用
    this.$el = typeof options.el ? document.querySelector(options.el) : options.el

    // 2 把data中得数据转换为get和set注入到vue实例中
    this._proxyData(this.$data)

    // 3  调用observer进行数据劫持,把data转换为get,set,监听数据变化,
    // 初始化dep发布者准备收集观察者watcher依赖
    new Observer(this.$data)

    //4 调用compiler对象,开始编译,解析指令和差值表达式,
    // 首次解析渲染时,初始化watcher观察者,并通过获取data数据时得get方法,放到dep发布者数组中,
    // 方便后续值变化时,发布者进行依次通知对应得观察者
    new Compiler(this)
  }
  _proxyData (data) {
    // 转换传入得data所有属性转换为get set放到vue实例上
    // 通过keys获取当前对象上可枚举得属性
    Object.keys(data).forEach(key => {
      Object.defineProperty(this, key, { // 把data中每个属性取出转换成get set属性放到vue实例中
        enumerable: true,// 属性可枚举  
        configurable: true,// 可删除
        get () {  //后续把data对象逐级也转换为了get和set,
          // 然后获取实际属性时包括层级较深得属性时,也会触发data对象上对应得get和set方法
          return data[key]
        },
        set (value) {// 和上面得get一样
          if (value === data[key]) {
            return
          }
          data[key] = value
        }
      })
    })
  }
}

Observer

功能:

  • 负责把data选项中的属性转换为响应式数据
  • data中的某个属性也是对象,把该属性也转换为响应式数据
  • 数据变化发送通知

结构:
image.png

代码:

class Observer {
  constructor(data) {
    this.walk(data) //遍历所有得属性
  }
  walk (data) {
    if (!data || typeof data === 'string') {
      //如果传入得data不是一个对象,或者它是一个字符串退出(要递归调用,所以判断下是字符串)
      //因为这里做得是一个简易版得,所以不考虑数组,实际上当属性值是数组,数组变化的时候,跟踪不到变化。
      //因为数组虽然是对象,但是Object.defineProperty不支持数组,所以vue改写了数组的所有方法,
      //当调用数组方法的时候,就调动变动事件。但是不能通过属性或者索引控制数组,比如length,index。
      //如果是数组,只能通过数组方法修改数组。如,控制台vm.arr--发现视图并不会变化,vm.arr.push(4)就能变化
      return
    }
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key]) //进行数据劫持
    })

  }
  defineReactive (obj, key, value) {
    //这个方法在这里传入data[key]这个值是为了get中返回使用
    //如果不传入,在get中通过data[key],获取使用得话,会循环调用get方法死循环了。
    //这里通过闭包把value留住
    let that = this
    let dep = new Dep() //创建发布者,进行依赖watcher观察者收集,和发送通知watcher更新
    //每个属性节点都有一个发布者,来进行监听
    this.walk(value) // 判断当前data[key]得值是不是对象,是得话,继续进行它内部属性得劫持
    Object.defineProperty(obj, key, {
      enumerable: true,//可枚举
      configurable: true,//可删除
      get () {
        //获取闭包内部得value
        Dep.target && dep.subs.push(Dep.target)//收集依赖,watcher中初始化时会设置值并调用对应get
        return value
      },
      set (newValue) {
        if (newValue === value) {
          return
        }
        //赋值给闭包内部得value,不通过data[key]赋值,防止死循环
        value = newValue
        //查看它newValue是不是对象,如果是的话,对新的这个对象进行数据劫持
        that.walk(newValue)
        dep.notify() //值变化,发布者通知储存得观察者watch进行dom更新
      }
    })
  }
}

这里在使用defineReactive进行劫持的时候,我们把value传入这个方法中在get/set中使用,形成闭包,避免get或者set的时候通过obj[key]获取或修改数据造成死循环,所有的get/set中都是操作的闭包中的value这个变量。
在这个方法中针对每一个属性节点都有一个Dep发布者对象,因为每个节点属性都有可能展示在界面上,界面上多个地方都有可能使用这个属性(也就是说每个节点都有可能被观察者观察,观察者可能是多个),所以我们在这个属性劫持的方法里来对Dep对象添加观察者以及通知观察者改变dom

Compiler编译器

功能:

  • 负责编译模板,解析指令/插值表达式
  • 负责页面的首次渲染,初始化watcher观察者
  • 当数据观察者变化后重新渲染视图

结构:
image.png
代码实现:

class Compiler {
  constructor(vm) {
    this.el = vm.$el //dom容器元素
    this.vm = vm //vue得实例
    this.compile(this.el)//开始进行指令和差值表达式编译
  }
  compile (el) {//编译模板,处理文本节点和元素节点
    let childNodes = el.childNodes //获取当前dom元素得所有子节点(是节点,不是元素,节点包含了空格换行等)
    Array.from(childNodes).forEach(node => { //类数组转换数组对象,然后依次对内部节点进行处理
      if (this.isTextNode(node)) {//处理文本节点
        this.compileText(node)
      } else if (this.isElementNode(node)) {//处理元素节点
        this.compileElement(node)
      }
      if (node.childNodes && node.childNodes.length) {//当前节点还有子集,继续递归调用来编译它的子集节点
        this.compile(node)
      }
    })

  }
  compileText (node) {//文本节点得时候,处理差值表达式{{msg}}
    // https://www.cnblogs.com/yalong/p/14101587.html
    let reg = /\{\{(.+?)\}\}/  //使用()分组,通过正则匹配内部任意字符多次出现,然后懒惰匹配
    let value = node.textContent;
    if (reg.test(value)) { //匹配上了
      //RegExp这个对象会在我们调用了正则表达式的方法后, 自动将最近一次的结果保存在里面, 
      //所以如果我们在使用正则表达式时, 有用到分组, 那么就可以直接在调用完以后直接使用RegExp.$xx来
      //使用捕获到的分组内容
      //获取分组0位置,也就是{{}}里面得字符串,然后除去空格(里面可能右空格)
      let key = RegExp.$1.trim()
      node.textContent = value.replace(reg, getVmDataValue.call(this, key))

      //创建观察者watch,当数据改变时更新视图
      new Watcher(this.vm, key, function (newValue) {
        node.textContent = newValue
      })
    }
  }
  compileElement (node) {//编译元素节点上得指令 v- 开头
    Array.from(node.attributes).forEach(attr => {
      let attrName = attr.name
      if (this.isDirective(attrName)) { //判断属性是否是指令
        attrName = attrName.substr(2) //v-text 转换为 text 
        let key = attr.value //获取指令得值 ,后续通过它获取对应vue实例得数据
        this.updata(node, key, attrName)
      }
    })

  }
  updata (node, key, attrName) {
    //减少if判断所以通过动态取值方式,获取对应得更新方法。
    let updateFn = this[attrName + 'Updater']
    //进行更新,绑定this,后面要用,然后传入node节点,对应值和key,创建观察者要用
    updateFn && updateFn.call(this, node, getVmDataValue.call(this, key), key)
  }

  textUpdater (node, value, key) {//v-text处理
    node.textContent = value
    new Watcher(this.vm, key, function (newValue) {
      node.textContent = newValue
    })
  }
  modelUpdater (node, value, key) {//v-model  input类型赋值为node.value
    node.value = value
    new Watcher(this.vm, key, function (newValue) {
      node.value = newValue
    })
    node.addEventListener('input', () => {//进行双向数据绑定
      let keys = key.split('.')
      if (keys.length === 1) {
        this.vm[keys[0]] = node.value
      } else {
        let obj = this.vm
        for (let i = 0, length = keys.length; i < length; i++) {
          if (i < length - 1) {
            obj = obj[keys[i]]
          } else {
            obj[keys[i]] = node.value
          }
        }
      }
    })
  }
  isDirective (attrName) {//通过es6方法查看属性是否是v-开头
    return attrName.startsWith("v-")
  }
  isTextNode (node) { //nodeType 为3得是文本节点
    return node.nodeType === 3
  }
  isElementNode (node) {
    return node.nodeType === 1 //1得为元素节点
  }
}

Dep(Dependency)

发布者:

  • 收集依赖添加观察者watcher
  • 通知所有观察者

结构:
image.png

这个类在observer中进行数据劫持时使用,在每个属性节点劫持时进行实例化,在get中收集观察者,在set中通知观察者更新dom
代码:

class Dep { //发布者对象,储存观察者,等待对应劫持数据更行了,通知储存得观察者,进行dom更新
  constructor() {
    //储存所有观察者
    this.subs = []
  }
  addSub (sub) {//添加
    if (sub && sub.update) { //观察者一定是有一个update方法得,用来更新dom
      this.subs.push(sub)
    }
  }
  notify(){ //对所有观察者进行通知更新
    this.subs.forEach(sub=>{
      sub.update()
    })
  }
}

Watcher观察者

功能:

  • 当数据变化触发依赖,dep通知所有的watcher实例更新视图
  • 自身实例化的时候往dep对象中添加自己

结构:
image.png

代码:

class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm //vue实例
    this.key = key //指令和文本节点中得值   也就是vue实例中data里得key
    this.cb = cb //回调函数,更新视图dom用

    //把watcher对象记录到Dep类得静态属性target
    Dep.target = this //方便获取vue中data数据时调用get时让对应的Dep把当前得观察者储存到它内部subs数组里
    this.oldValue = getVmDataValue.call(this, key) //获取vue data中得数据,这会触发get时,dep.target会为true
    //就会添加到 对应dep发布者实例数组中了
    Dep.target = null //只第一次渲染dom 初始化watcher得时候添加就够了,防止重复添加,再把它置为null
  }
  update () {
    let newValue = getVmDataValue.call(this, this.key)
    if (this.oldValue === newValue) {//相同值
      return;
    }
    this.cb(newValue)
  }
}

这个watcher对象的实例化是在compiler编译首次渲染进行差值或指令解析的时候进行的,实例化时会把dep的target设置为自身,然后获取data中的值方便更新时比对,触发data中的get添加到对应dep实例中,
等待data改变时dep调用对应的watcher来进行dom更新。

还有一个公共方法的操作:

function getVmDataValue (key) { //获取vue 实例data数据,查看是否取得是 深层级对象里得属性
  let keys = key.split('.')
  let vmValue = keys.length === 1 ? this.vm[keys[0]] : keys.reduce(function (obj, current) {
    return obj[current];
  }, this.vm)
  return vmValue
}

最后是我的html界面代码,贴出来:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <h1> 差值表达式:</h1>
    <h3>{{ person.name }}</h3>
    <h3> {{ count}}</h3>
    <h1>
      v-text
    </h1>
    <div v-text="msg"></div>
    <h1>v-model</h1>
    <input type="text" v-model="person.name"></input>
    <input type="text" v-model="count">

  </div>
  <script src="./js/baseVue.js"></script>
  <script src="./js/dep.js"></script>
  <script src="./js/watcher.js"></script>
  <script src="./js/compiler.js"></script>
  <script src="./js/observer.js"></script>
  <script src="./js/vue.js"></script>
  <!-- <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> -->
  <script>
    var vm = new Vue({
      el:'#app',
      data:{
        msg:'Hello word',
        count:10,
        person:{
          name:'lp'
        }
      }
    })
    console.log(vm)
  </script>
</body>
</html>

这样一个最简易版的mini-vue数据相应就完成了。

总结一下,来个图看着说明:
image.png

  1. 首先创建vue实例,实例中进行2,3操作
  2. 进行observer进行数据劫持

    • 每个节点初始化Dep发布者
    • 在get中判断是否添加watcher观察者对象
    • 在set中dep通知watcher更新dom
  3. 最后解析指令Compiler

    • 在第一次解析差值表达式或指令时,初始化watcher观察者
    • 初始化观察者时会改变dep的target并触发data的get方法,从而添加 到对应属性节点的dep发布者对象中
    • 初始化watcher观察者时会添加更改dom的回调,方便dep通知它时更新dom
    • 对对应指令进行input双向数据绑定

这样到这里我们一个最简易版的mini-vue数据响应式就算完成了,后续会有一篇虚拟dom的分析。

该内容借鉴于拉钩大前端训练营


Charon
57 声望16 粉丝

世界核平