简单模拟Vue响应式原理

模拟Vue响应式原理

Vue响应式原理模拟的目的

  • 了解响应式原理
  • 从原理层面解决实际项目中的问题
  • 学习Vue响应式原理,为学习Vue源码做铺垫

准备工作(前置知识):

  • 数据驱动
  • 响应式核心原理
  • 发布订阅模式和观察者模式

数据驱动

Vue中经常出现3个词:数据响应式、双向绑定、数据驱动。

数据响应式

数据响应式中的“数据”,指的是数据模型。

基于Vue开发时,数据模型就是普通的 JavaScript 对象。

数据响应式的核心是:当修改数据时,试图会自动进行更新,避免了繁琐的 DOM 操作,提高开发效率。

对比JQuery,JQuery的使用就是进行DOM操作。

双向绑定

双向绑定指的是:当数据发生改变,视图会跟着改变;当视图发生改变,数据也随之改变。

双向绑定的概念中,包含了数据响应式。

因为双向绑定包含视图变化,所以它针对的是可以和用户进行交互的表单元素。

可以使用v-model在表单元素上创建双向数据绑定。

数据驱动

数据驱动就是一种开发的过程。

它指的是:开发过程中只需要关注数据本身(即业务本身),不需要关心数据是如何渲染到视图(DOM)上的。

它是 MVVM框架 (如Vue) 最独特的特性之一,因为主流的MVVM框架内部已经实现了 数据响应式 和 双向绑定。

数据响应式核心原理

Vue 2.x 和 Vue 3.0 实现数据响应式的方式不同。

Vue 2.x - Object.defineProperty

Vue2.x 的响应式原理基于ES5的 Object.defineProperty 实现的

官方文档

当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项, Vue会遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。 Object.defineProperty 是 ES5 中一个无法 shim(降级处理)的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。

MDN - Object.defineProperty

shim指的是可以使用es[x]-shim使低版本浏览器可以使用es[x]的新特性。

一些特性无法shim,例如ES5的Object.defineProperty和ES6的Proxy

数据劫持

数据劫持:访问或修改对象的某个属性时,除了执行基本的数据获取和修改操作之外,还基于数据的操作行为,以数据为基础去执行额外的操作。

vue的数据劫持:当访问或设置 vue 实例的成员的时候,做一些干预操作。

例如修改 vue 实例成员的值,将新的值渲染到DOM,整个DOM操作不希望在赋值的时候手动去做,所以需要使用数据劫持。

具体通过Object.defineProperty方法向vue实例对象中添加具有get/set描述符的成员属性。

语法:Object.definePorperty(obj, prop, descriptor)

当访问属性时调用get(getter访问器)方法,当修改属性值时,调用set(setter设置器)方法。

<input type="text" oninput="inputHandle(event)" />
<div id="app">
  hello
div>
<script>
  // 表单输入事件,用于测试修改vm的属性,是否实现双向绑定
  function inputHandle(e) {
    vm.msg = e.target.value // 触发set
    console.log(vm.msg) // 触发get
  }

  // 模拟 Vue 中的 data 选项
  let data = {
    msg: 'hello'
  }

  // 模拟 Vue 实例
  let vm = {}

  // 数据劫持:当访问或设置 vm 中的成员的时候,做一些干预操作
  Object.defineProperty(vm, 'msg', {
    // 可枚举(可遍历)
    enumerable: true,
    // 可配置(可以delete删除,可以通过 defineProperty 重新定义)
    configurable: true,
    // 访问器:当获取值时执行
    get () {
      console.log('get: ', data.msg)
      return data.msg
    },
    // 设置器:当设置值时执行
    set (newValue) {
      console.log('set: ', newValue)
      if (newValue === data.msg) {
        return
      }
      // 更新数据的值
      data.msg = newValue
      // 数据更改,更新 DOM 的值
      document.querySelector('#app').textContent = data.msg
    }
  })
script>

多个属性的对象

当一个对象拥有多个属性,使用Object.defineProperty实现对这个对象的数据劫持,需要遍历对象中的每一个属性,为它们添加getter/setter。

可通过Object.keys获取所有属性,然后遍历。

msg:<input type="text" oninput="inputHandle(event, 'msg')" />
count:<input type="text" oninput="inputHandle(event, 'count')" />
<div id="app">
  <span class="msg">hellospan>
  <span class="count">10span>
div>
<script>
  // 表单输入事件,用于测试修改vm的属性,是否实现双向绑定
  function inputHandle(e, key) {
    vm[key] = e.target.value // 触发set
    console.log(vm[key]) // 触发get
  }

  // 模拟 Vue 中的 data 选项
  let data = {
    msg: 'hello',
    count: 10
  }

  // 模拟 Vue 实例
  let vm = {}

  // 遍历 data 对象的所有属性
  Object.keys(data).forEach(key => {
    Object.defineProperty(vm, key, {
      enumerable: true,
      configurable: true,
      get () {
        console.log('get: ', key, data[key])
        return data[key]
      },
      // 设置器:当设置值时执行
      set (newValue) {
        console.log('set: ', key, newValue)
        if (newValue === data[key]) {
          return
        }
        // 更新数据的值
        data[key] = newValue
        // 数据更改,更新 DOM 的值
        document.querySelector(`.${key}`).textContent = data[key]
      }
    })
  })
script>

Vue 3.0 - Proxy

Vue 3.0的响应式(数据劫持)是基于ES6新增的Proxy(代理对象)实现的。

MDN - Proxy

Proxy 监听的是对象,而非属性。

因此在把多个属性转化成getter或setter时,不需要循环遍历对象的全部属性。

Proxy是ES6新增,且不能被polyfill磨平,无法shim,所以IE不支持,性能比Object.defineProperty高,速度快。

Proxy是一个类,通过new创建一个代理对象。

new Proxy(target, handler)

访问和修改,操作的都是代理对象。

Proxy构造函数接收两个参数:

  • target:被代理的对象
  • handler:包含一系列拦截器(函数)的对象

拦截器:执行代理行为的函数,Proxy有13种拦截器。

当访问代理对象的属性时,执行get拦截器。

当修改代理对象的属性时,执行set拦截器。

msg:<input type="text" oninput="inputHandle(event, 'msg')" />
count:<input type="text" oninput="inputHandle(event, 'count')" />
<div id="app">
  <span class="msg">hellospan>
  <span class="count">10span>
div>
<script>
  // 表单输入事件,用于测试修改vm的属性,是否实现双向绑定
  function inputHandle(e, key) {
    vm[key] = e.target.value // 触发set
    console.log(vm[key]) // 触发get
  }

  // 模拟 Vue 中的 data 选项
  let data = {
    msg: 'hello',
    count: 10
  }

  // 模拟 Vue 实例
  let vm = new Proxy(data, {
    // 拦截器:执行代理行为的函数

    // 访问代理对象(vm)的属性时执行
    get (target, key) {
      console.log('get: ', key, target[key])
      return target[key]
    },
    // 修改代理对象(vm)的属性时执行
    set (target, key, newValue) {
      console.log('set: ', key, newValue)
      if (newValue === target[key]) {
        return
      }
      // 更新数据的值
      target[key] = newValue
      // 数据更改,更新 DOM 的值
      document.querySelector(`.${key}`).textContent = newValue
    }
  })
script>

可以看到,Proxy设置数据劫持,比Object.defineProperty简洁的多,并且由于Proxy监听的是整个对象,所以对每个属性的访问修改,都会触发相应的拦截器,省去了遍历的工作。

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

发布/订阅模式 和 观察者模式 是两种设计模式

在Vue中有各自的应用场景。

两种模式的本质是相同的,它们经常被混为一谈,但是二者是有区别的。

Wrong:观察者模式还有一个名字叫发布-订阅模式。

发布/订阅模式

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

我们假定,存在一个“信号中心”。

某个任务执行完成,就向信号中心“发布”(publish)一个信号。

其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。

这就叫做“发布/订阅模式”(publish-subscribe pattern)

举例:

老婆喜欢看BL网站的小说,作者更新的慢,每天都要打开网页查看是否更新了。

有一天网站升级了一个功能,可以让用户订阅小说的更新。

当作者发布新章节后,网站会给订阅了这个小说的用户发送短信提醒。

这样老婆只需要当收到短信后追更即可,剩下更多的时间留给刷微博、逛淘宝。

这个例子中,老婆就是【订阅者】,小说作者就是【发布者】,BL网站就是【信号中心】。


Vue 中的自定义事件 以及 node 中的事件机制 都是基于发布/订阅模式.

Vue 自定义事件

官方文档参考自定义事件如何使用:$dispatch 和 $broadcast 替换

  1. 创建一个Vue实例
  2. 通过$on方法注册(订阅)自定义事件
    1. 同一个事件可以注册多个处理函数
  3. 通过调用这个实例的$emit方法触发(发布)事件
  4. 通过$off方法取消注册(订阅)事件
var vm = new Vue()
// 注册/订阅事件
vm.$on('dataChange', () => {
  console.log('dosomething')
})
vm.$on('dataChange', () => {
  console.log('dosomething2')
})
// 触发/发布事件
vm.$emit('dataChange')

兄弟组件通信过程

通过Vue兄弟组件的通信过程,更清晰的认识 订阅者、发布者、信号中心(事件中心)。

// eventBus.js
// 事件中心 / 信息中心
let eventHub = new Vue()

// ComponentA.vue
// 发布者
methods: {
  // 发布一条待办消息
  addTodo: function () {
    // 发布消息(事件)
    eventHub.$emit('add-todo', { text: this.newTodoText })
    this.newTodoText = ''
  }
}

// ComponentB.vue
// 发布者
created: function () {
  // 订阅消息(事件)
  eventHub.$on('add-todo', this.renderTodoText)
},
methods: {
  // 把消息渲染到界面中
  renderTodoText(newTodoText) {
    // ...
  }
}

模拟 Vue 自定义事件(发布订阅)的实现

首先分析Vue自定义事件如何实现:

  1. 创建Vue实例:vm
  2. $on注册事件(订阅消息)
    1. $on仅仅注册事件,事件处理函数并不立即执行
    2. 所以vm中需要定义内部的变量,用于存储注册的事件成以及事件处理函数。
      1. 注册的时候可以注册多个事件名称,也可以给同一个事件注册多个事件处理函数。
      2. 存储事件的时候,要记录所有的事件名称,以及对应的处理函数,即键值对的形式。
      3. 例如:{ 'click': [fn1, fn2], 'change': [fn3] }
  3. $emit触发事件(发布消息)
    1. $emit接收的第一个参数是事件的名称
    2. 内部通过事件的名称,去存储事件的对象中寻找对应的事件处理函数,依次执行。
// 事件触发器
class EventEmitter {
  constructor () {
    // subs 存储事件及处理函数
    // { 'click': [fn1, fn2], 'change': [fn3] }
    // this.subs = {}

    // 使用Object.create(null)创建的对象没有原型属性
    // 因为subs只需要存储键值对形式的数据,不需要原型
    // 使用Object.create(null)可以提高性能
    this.subs = Object.create(null)
  }

  // 注册事件
  $on (eventType, handler) {
    this.subs[eventType] = this.subs[eventType] || []
    this.subs[eventType].push(handler)
  }

  // 触发事件
  $emit (eventType, ...args) {
    if (this.subs[eventType]) {
      this.subs[eventType].forEach(handler => {
        handler.apply(this, args)
      })
    }
  }
}

// 测试

// 创建一个事件中心
let em = new EventEmitter()

// 订阅
em.$on('click', msg => {
  console.log('click1', msg)
})
em.$on('click', msg => {
  console.log('click2', msg)
})

// 发布
em.$emit('click', '触发事件')
// click1 触发事件
// click2 触发事件

观察者模式

观察者模式 和 发布订阅模式 的区别是:

  1. 没有事件中心
  2. 只有发布者和订阅者
  3. 并且发布者需要知道订阅者的存在

观察者模式:定义对象间一种一对多的依赖(Dependency)关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新。

  • 观察者(订阅者) - Watcher
    • 所有的订阅者自身都有一个update方法
    • update():当事件发生时,具体要做的事情
      • 在Vue响应式中,当数据放生变化时,会调用订阅者的update方法。
      • Vue中订阅者的update方法内部就是更新视图
    • 观察者模式中,订阅者的update方法是由发布者调用的
  • 目标(发布者)- Dep(Dependency缩写:依赖)
    • 当事件发生的时候,是由发布者去通知所有订阅者
    • 发布者包含以下属性:
      • subs[]:存储所有订阅者的数组
        • 所有依赖该事件的订阅者,都需要添加到subs数组中
      • addSub():用于添加观察者的方法
      • notify():当事件发生,调用所有观察者的 update() 方法
    • 命名Dep原因:Vue响应式机制中,内部使用的“Dep”单词。

下面模拟一个观察者模式(未考虑update传参)

// 发布者 - 目标
// Vue 响应式机制中内部使用的“Dep”命名
class Dep {
  constructor () {
    // 记录所有的订阅者
    this.subs = []
  }
  // 添加订阅者
  addSub (sub) {
    // 确保这是一个拥有update方法的订阅者对象
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 发布通知
  notify () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

// 订阅者 - 观察者
class Wathcer {
  // 当事件发生时,由发布者调用update方法
  // update内部可以更新视图或做一些其他操作
  update () {
    console.log('update')
  }
}

// 测试
let dep = new Dep()
let watcher = new Wathcer()
let watcher2 = new Wathcer()

dep.addSub(watcher)
dep.addSub(watcher2)

dep.notify()

观察者模式 与 发布订阅模式 的区别

简单模拟Vue响应式原理_第1张图片

观察者模式

当目标对象数据发生变化(事件发生)时,目标对象(发布者)会调用它的notify方法。

notify方法会通知所有的观察者(订阅者),调用观察者(订阅者)的update方法,处理各自的业务。

所以如果对目标对象的变化有兴趣,就要调用目标对象的addSub方法,把自己订阅到目标对象里。

目标对象内部记录了所有的观察者。

目标对象(发布者)和观察者(订阅者)之间存在相互依赖的关系。

发布订阅模式

发布订阅模式中多了一个**“事件中心”**。

通过**“事件中心”**隔离了 发布者 和 订阅者。

结合兄弟组件的传值来理解,假设发布者 和 订阅者 分别是两个不相关的组件(发布者:组件A;订阅者:组件B)。

组件A的作用是添加待办事项,组件B的作用是把新增的待办事项渲染到页面。

当组件A中新增了一个待办事项,会发布一个事件(命名为add)。

此时会调用 事件中心 的 $emit 方法,触发add事件。

$emit 方法中会找到事件中心中注册的add事件对应的处理函数并执行。

而事件处理函数是由组件B提供的。

组件B想要知道add事件是否发生了变化,就需要通过$on方法订阅事件中心的add事件。

事件中心的作用是 隔离订阅者和发布者,去除它们之间的依赖

总结

  • 观察者模式 是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。
  • 发布/订阅模式 由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在,减少二者依赖关系,这样会变得更灵活。

模拟 Vue 的响应式原理

这里模拟一个最小版本的Vue。

整体分析

准备工作:

  • 回顾 Vue 的基本结构,以及要模拟实现的功能
  • 打印 Vue 实例观察要模拟Vue中的哪些成员
  • 整理要模拟的最小版本的Vue的整体结构
<div id="app">
  <h1>插值表达式h1>
  <h3>{{ msg }}h3>
  <h3>{{ count }}h3>

  <h1>指令h1>
  <h2>v-texth2>
  <div v-text="msg">div>
  <h2>v-modelh2>
  <input type="text" v-model="msg">
  <input type="text" v-model="count">
div>

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js">script>
<script>
  let vm = new Vue({
    el: '#app',
    data: {
      msg: 'Hello Vue',
      count: 20
    }
  })
script>

回顾 Vue 的基本结构

  1. 首先调用了Vue的构造函数
    1. 该构造函数接收一个对象参数
    2. 对象中设置了 el 和 data
      1. el:设置了一个选择器
      2. data:使用的一些数据
  2. 然后在模板中,通过插值表达式、v-text、v-model进行绑定数据。

以上是要实现的功能。

打印 Vue 实例观察

data

打印发现Vue实例中除了包含 msg 和 count 外,还包含它们对应的 getter(get msg、get count) 和 setter(set msg 、set count)。

这是通过Object.definePorperty设置了get和set的效果,打印它们的描述符:

// msg的描述符
{
  configurable: true,
  enumerable: true,
  get: proxyGetter,
  set: proxySetter
}

所以Vue构造函数内部需要把data中的成员转换成getter和setter 注入到Vue实例上。

这样做的目的是,在其他地方使用的时候,可以直接通过this.msgthis.count使用。

$data

接着看到data中的成员被记录到了$data属性中,并且也传换成了getter和setter。

// $data.msg的描述符
{
  configurable: true,
  enumerable: true,
  get: reactiveGetter,
  set: reactiveSetter
}

$data中的setter是真正监视数据变化的地方

$options

$options可以简单认为把构造函数的参数记录到了这个属性中。

_data

_data和$data指向的是同一个对象。

下划线_开头的是私有成员,$开头的是公共成员。

这里只需要模拟$data即可。

$el

$el对应选项中的el设置的DOM对象。

设置el选项的时候,可以是一个选择器,也可以是一个DOM对象。

如果是一个选择器,Vue构造函数内部会把这个选择器转换成相应的DOM对象。

总结要实现的属性

最小版本的Vue中要模拟vm(Vue实例)中的成员:

  • $data
  • $el
  • $options
  • 把data中的成员注入到vm中

整体结构

模拟的最小版本的Vue由下面5个类组成:

简单模拟Vue响应式原理_第2张图片

  • Vue
    • 创建一个Vue实例
    • 把 data 中的成员转换成 getter/setter,并注入到 Vue 实例
    • Vue 内部会调用 Observer 和 Compiler
  • Observer:作用是数据劫持
    • 能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知Dep
  • Compiler:
    • 解析每个元素中的指令/插值表达式,并替换成相应的数据
  • Dep:观察者模式中的目标
    • 添加观察者
    • 当数据发生变化的时候,通知所有的观察者
  • Watcher:观察者模式中的观察者
    • 内部有update方法,用于更新视图

Vue 类

功能:

  • 构造函数接收初始化的对象参数
  • 负责把 data 中的属性转换成getter/setter,注入到Vue实例
  • 负责调用 observer 对象监听 data 中所有属性的变化
    • 当属性变化的时候,更新视图
  • 负责调用 compiler 解析 指令/插值表达式

结构:

简单模拟Vue响应式原理_第3张图片

  • _proxyData():私有成员
    • 把data中的属性转换成getter/setter,注入到Vue实例

代码:

// js/vue.js
class Vue {
  constructor (options) {
    // 1. 通过属性保存选项的数据
    this.$options = options || {}
    this.$data = options.data || {}
    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

    // 2. 把 data 中的成员转换成 getter/setter 注入到Vue实例中
    this._proxyData(this.$data)

    // 3. 调用 observer 对象,监听数据的变化

    // 4. 调用 compiler 对象,解析指令和插值表达式
  }

  _proxyData (data) {
    // 遍历 data 中的所有属性
    // 注意遍历回调内部需要使用vue实例,所以这里使用箭头函数,使this指向vue实例
    Object.keys(data).forEach(key => {
      // 把 data 中的属性注入到 Vue 实例中
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get () {
          return data[key]
        },
        set (newValue) {
          if (data[key] === newValue) {
            return
          }
          data[key] = newValue
        }
      })
    })
  }
}

<div id="app">
  <h1>插值表达式h1>
  <h3>{{ msg }}h3>
  <h3>{{ count }}h3>

  <h1>指令h1>
  <h2>v-texth2>
  <div v-text="msg">div>
  <h2>v-modelh2>
  <input type="text" v-model="msg">
  <input type="text" v-model="count">
div>

<script src="./js/vue.js">script>
<script>
  let vm = new Vue({
    el: '#app',
    data: {
      msg: 'Hello Vue',
      count: 20
    }
  })

  console.log(vm)

script>

Observer 类

功能(数据劫持):

  • 负责把 data 选项中的属性转换成响应式数据(getter/setter)
  • 如果data 中的某个属性也是对象,把该属性转换成响应式数据
  • 数据变化发送通知

结构:

简单模拟Vue响应式原理_第4张图片

Observer类中有两个方法(方法名与Vue源码中一致):

  • walk
    • 遍历data中的所有属性,调用defineReactive
  • defineReactive
    • 定义响应式数据,通过调用defineProperty将属性转换成getter/setter

代码:

// js/observer.js
class Observer {
  constructor (data) {
    this.walk(data)
  }

  walk (data) {
    // 1. 判断data是否是空值或对象
    if (!data || typeof data !== 'object') {
      return
    }

    // 2. 遍历data对象的所有属性
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key])
    })
  }

  defineReactive(obj, key, val) {
    // 注意获取和设置属性的值,使用的是参数 val
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get () {
        // return obj[key] // 死递归
        return val
      },
      set (newValue) {
        if (val === newValue) {
          return
        }
        val = newValue
        // 发送通知
      }
    })
  }
}
// js/vue.js
class Vue {
  constructor (options) {
    // ...

    // 3. 调用 observer 对象,监听数据的变化
    new Observer(this.$data)

    // 4. 调用 compiler 对象,解析指令和插值表达式
  }
  // ...
}


<script src="./js/observer.js">script>
<script src="./js/vue.js">script>
<script>
  let vm = new Vue({
    el: '#app',
    data: {
      msg: 'Hello Vue',
      count: 20
    }
  })
  console.log(vm.msg)
script>

问题答疑

为什么向defineReactive()传递一个val参数,并在getter/setter中使用它,而不是使用obj[key]?

这是因为当访问vm.msg时:

  1. 会首先触发Vue类中_proxyData方法转化的msg属性的getter方法。
  2. getter方法最后returndata[key],其中data指向的this.$data
  3. 此时就又会调用Observer类中,defineReactive方法转化this.$data的属性时,定义的getter方法。
  4. 而假如这个方法返回的是obj[key],此时obj同样指向的this.$data,就又会触发这个getter方法。
  5. 如此就会造成 死递归,所以这里需要使用一个val变量存储this.$data.msg的值。

defineReactive()方法中接收的value参数为什么没有在方法执行完后释放?

因为defineReactive方法内部转化obj的属性时,设置了getter/setter方法,这些方法内部使用了val,这样就行成了闭包,扩展了val的作用域,所以val不会被释放。

继续完善

问题1:

当前定义的Observer只会将data中的属性转化成响应式数据(getter/setter)。

当data中的属性的值也是一个对象时,这个对象中的属性并没有被转换成响应式数据(getter/setter)。

所以需要修改一下defineReactive,使data中的对象类型的属性,内部也是响应式的。

只需要在一开始,调用一个walk,walk内部会判断如果属性是对象,就执行遍历转化。

问题2:

如果将data中的属性,重新赋值为一个对象,该对象内部的属性也应该是响应式的。

所以需要在触发this.$data中属性的setter方法时,调用walk方法转化新值,它会判断新的值是否是对象,如果是则转化。

问题总结:

  1. 如果data中的某个属性是对象,把这个对象内部的属性转化成响应式数据。
  2. 当data的当前属性重新赋值为一个新对象时,该对象内部的属性也要转化成响应式数据。
// js/observer.js
defineReactive(obj, key, val) {
  let that = this

  // 如果 val 是对象,把 val 内部的属性转换成响应式数据
  this.walk(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get () {
      // return obj[key] // 死递归
      return val
    },
    set (newValue) {
      if (val === newValue) {
        return
      }
      val = newValue
      that.walk(newValue)
      // 发送通知
    }
  })
}

<script>
let vm = new Vue({
  el: '#app',
  data: {
    msg: 'Hello Vue',
    count: 20,
    person: {
      name: 'Tom',
      info: {
        age: 18
      }
    }
  }
})
console.log(vm.person)
vm.msg = { test: 'Yeah' }
console.log(vm)
script>

Compiler 类

功能(操作DOM):

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

结构:

当前模拟直接操作DOM,没有使用虚拟DOM。

简单模拟Vue响应式原理_第5张图片

属性:

  • el:Vue构造函数传入的 el 表示的DOM对象
  • vm:Vue的实例,后续方法需要用到Vue实例中的数据,所以在此记录下来方便调用。

方法:

一系列DOM操作的方法。

  • compile(el)
    • el:DOM对象
    • 方法内部遍历DOM对象的多有节点,并判断:
      • 文本节点(isTextNode):解析插值表达式(compileText)
      • 元素节点(isElementNode):解析指令(compileElement)
  • isTextNode(node):判断是否是文本节点
  • isElementNode(node):判断是否是元素节点
  • compileText(node):解析插值表达式
  • compileElement(node):解析指令,内部调用isDirective判断指令
  • isDirective(attrName):判断属性是否是指令

DOM操作:

  • node.childNodes - 获取当前节点子节点
    • 它是一个伪数组,可以用Array.from转换成数组进行遍历
  • node.nodeType - 判断节点类型
    • 1 - 元素节点
    • 2 - 属性节点(node.attributes中存在)
    • 3 - 文本节点
  • node.textContent - 节点的文本
  • node.nodeValue - 节点的值
    • 文本节点的值 同 textContent
    • 元素节点的值 是 undefined 或 null
    • 属性节点的值 是 属性的值
  • node.attributes - 获取节点的所有属性
    • 它是一个伪数组,可以用Array.from转换成数组进行遍历
    • 属性节点的name:属性的名称
    • 属性节点的value:属性的值

代码:

// js/compiler.js
class Compiler {
  constructor (vm) {
    this.el = vm.$el
    this.vm = vm
    this.compiler(this.el)
  }
  // 编译模板,处理文本节点和元素节点
  compiler (el) {
    let childNodes = el.childNodes
    // childNodes是一个伪数组,通过Array.from将其转化为数组
    Array.from(childNodes).forEach(node => {
      if (this.isElementNode(node)) {
        // 处理元素节点
        this.compileElement(node)
      }else if (this.isTextNode(node)) {
        // 处理文本节点
        this.compileText(node)
      }
      // 判断如果有node有子节点,递归调用compiler编译子节点
      if (node.childNodes && node.childNodes.length > 0) {
        this.compiler(node)
      }
    })
  }
  // 编译元素节点,处理指令
  compileElement (node) {
    // 遍历所有的属性节点
    Array.from(node.attributes).forEach(attr => {
      // 判断是否是指令
      let attrName = attr.name
      if (this.isDirective(attrName)) {
        // v-text --> text
        attrName = attrName.substr(2)
        let key = attr.value
        this.update(node, key, attrName)
      }
    })
  }
  update (node, key, attrName) {
    let updateFn = this[attrName + 'Updater']
    updateFn && updateFn(node, this.vm[key])
  }
  // 处理v-text指令
  textUpdater (node, value) {
    // 更新节点文本
    node.textContent = value
  }
  // 处理v-model指令
  modelUpdater (node, value) {
    // 更新表单元素的值
    node.value = value
  }
  // 编译文本节点,处理指令
  compileText (node) {
    // console.log(node)
    // console.dir会将内容以对象形式打印
    // console.dir(node)

    // {{  msg }}
    let reg = /\{\{(.+?)\}\}/
    let value = node.textContent
    if (reg.test(value)) {
      let key = RegExp.$1.trim()
      node.textContent = value.replace(reg, this.vm[key])
    }
  }
  // 判断元素属性是否是指令
  isDirective (attrName) {
    return attrName.startsWith('v-')
  }
  // 判断节点是否是元素节点
  isElementNode (node) {
    return node.nodeType === 1
  }
  // 判断节点是否是文本节点
  isTextNode (node) {
    return node.nodeType === 3
  }
}
// js/vue.js
constructor (options) {
  // ...

  // 4. 调用 compiler 对象,解析指令和插值表达式
  new Compiler(this)
}

Dep(Dependency) 类

简单模拟Vue响应式原理_第6张图片

Dep:目标 / 依赖 / 发布者

功能:

Dep类的作用是在getter方法中收集依赖。

每个响应式的属性,最终都会创建一个对应的Dep对象。

它负责收集所有依赖于该属性的地方。

所有依赖该属性的位置,都会创建一个Watacher对象。

所以Dep收集的就是依赖于该属性的Watcher对象。

setter方法中会通知依赖。

当属性发生变化,会调用Dep对象的notify发送通知,进而调用Watcher对象的update方法。

总结,Dep的作用就是:

  1. 在getter中收集依赖 - 添加观察者
  2. 在setter中通知依赖 - 通知观察者

结构:

简单模拟Vue响应式原理_第7张图片

  • subs - 存储所有Watcher的数组
  • addSub - 添加观察者Watcher
  • notify - 通知观察者

代码:

// js/dep.js
class Dep {
  constructor () {
    // 存储所有的观察者
    this.subs = []
  }
  // 添加观察者
  addSub (sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 发送通知
  notify () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// js/observer.js
defineReactive(obj, key, val) {
  let that = this
  // 负责收集依赖,并发送通知
  let dep = new Dep()

  this.walk(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get () {
      // 收集依赖
      // Dep.target指向的是一个观察者,在实例化Watcher对象时定义这个静态属性
      Dep.target && dep.addSub(Dep.target)

      return val
    },
    set (newValue) {
      if (val === newValue) {
        return
      }
      val = newValue
      that.walk(newValue)
      // 发送通知
      dep.notify()
    }
  })
}

Dep类中并没有定义 target 这个静态属性,这个属性是在 Watcher类中定义的,它用来向dep对象的subs中添加观察者对象。

Watcher 类

简单模拟Vue响应式原理_第8张图片

在Data属性的getter方法中,通过Dep对象收集依赖。

在Data属性的setter方法中,通过Dep对象触发依赖。

所以Data中的每个属性都要创建一个对应的Dep对象。

在收集依赖的时候,把依赖该数据的所有Watcher(观察者对象)添加到Dep对象的subs数组中。

在setter方法中,触发依赖(发送通知),会调用Dep的notify方法,通知所有关联的Watcher对象。

Watcher对象负责更新对应的视图。

功能:

  • 更新视图
    • 当数据发生变化触发依赖,dep 通知所有的Watcher 实例更新视图。
  • 实例化一个Watcher对象时,内部将自己添加到 dep 对象的subs数组中
    • 通过将自身记录到Dep的target静态属性中
    • 然后再访问data的属性,触发getter方法
    • getter方法中判断Dep.target是否记录了一个Watcher对象,如果记录了就添加到属性对应的dep对象的依赖列表(subs)中。
    • 最后重置这个属性为null,防止重复添加

结构:

简单模拟Vue响应式原理_第9张图片

  • update - 更新视图
    • 不同的Watcher对象更新视图所作的事情是不一样的
    • 所以需要一个存储如何更新视图的变量cb
  • cb - callback 回调函数
    • 当创建一个Watcher对象的时候,需要传入一个回调函数
    • 这个函数中用于指明如何更新视图。
  • key - data中的属性名,更新视图需要数据,通过key获取数据
  • vm - Vue实例,用于获取数据
  • oldValue - 记录数据变化之前的值
    • update触发时,内部可以获取数据最新的值
    • 对比新旧值,如果数据发生变化,调用cb更新视图

代码:

// js/watcher.js
class Watcher {
  constructor (vm, key, cb) {
    // vue实例
    this.vm = vm
    // data中的属性名称
    this.key = key
    // 回调函数,负责更新视图
    this.cb = cb

    // 把 watcher对象记录到Dep类的静态属性target中
    Dep.target = this
    // 触发get方法,在get方法中会调用addSub
    this.oldValue = vm[key]
    // 添加完后重置target,防止重复添加
    Dep.target = null
  }
  // 当数据发生变化的时候,更新视图
  update () {
    // 调用update时数据已经发生变化,直接获取就是最新的值
    let newValue = this.vm[this.key]
    if (newValue === this.oldValue) {
      return
    }
    this.cb(newValue)
  }
}

在何处创建watcher对象

Watcher的作用之一是,当数据改变的时候更新视图。

数据改变发送通知,是在Observer中的setter方法中通过调用dep对象的notify方法实现。

notify方法中会遍历所有的watcher对象,调用它们的update方法。

update内部是通过调用cb回调函数来更新视图的。

cb函数是在Watcher构造函数中传递的(创建watcher对象时)。

更新视图其实就是操作DOM,而所有的DOM操作都在Compiler中。

在Complier中找到把数据渲染到DOM的位置,即:

  • compileText - 处理插值表达式的位置
  • compileElement(textUpdater、modelUpdater) - 处理指令的位置

这3个方法都是最终把数据更新到DOM元素上。

这3个方法都是在页面首次加载的时候执行的。

指令和插值表达式都是依赖于数据的,而所有视图中依赖数据的位置,都应该创建一个watcher对象。

当数据发生改变的时候,dep对象会通知所有的watcher对象,重新渲染视图。

所以要在这3个方法中创建watcher对象。

调整代码:

// js/compiler.js
update (node, key, attrName) {
  let updateFn = this[attrName + 'Updater']
  updateFn && updateFn.call(this, node, this.vm[key], key)
}
// 处理v-text指令
textUpdater (node, value, key) {
  // 更新节点文本
  node.textContent = value
  new Watcher(this.vm, key, newValue => {
    node.textContent = newValue
  })
}
// 处理v-model指令
modelUpdater (node, value, key) {
  // 更新表单元素的值
  node.value = value
  new Watcher(this.vm, key, newValue => {
    node.value = newValue
  })
}
// 编译文本节点,处理指令
compileText (node) {
  let reg = /\{\{(.+?)\}\}/
  let value = node.textContent
  if (reg.test(value)) {
    let key = RegExp.$1.trim()
    node.textContent = value.replace(reg, this.vm[key])

    // 创建watcher对象,当数据改变更新视图
    new Watcher(this.vm, key, newValue => {
      node.textContent = newValue
    })
  }
}

调整的位置:

  1. 在3个方法先创建watcher对象,并传入vue实例、属性名、更新视图用的回调方法
  2. 调整处理元素节点的Updater方法
    1. 增加属性名参数 key
    2. 改变指向为当前compiler对象,用于Updater内部通过this获取vue实例


<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>
  let vm = new Vue({
    el: '#app',
    data: {
      msg: 'Hello Vue',
      count: 20,
      person: {
        name: 'Tom',
        info: {
          age: 18
        }
      }
    }
  })
  console.log(vm)

  setTimeout(() => {
    vm.msg = '变更后的msg'
  }, 1000)

script>

双向绑定

以上代码实现了,改变vue的数据改变时更新视图。

但是更新表单元素的值,并没有更新绑定的vue中的数据,即双向绑定。

双向绑定机制包括两点:

  1. 数据发生变化,更新视图(数据响应式,已实现)
  2. 视图发生变化,更新数据

实现方法:

当文本框内容发生变化时,触发一个事件(Vue中使用的是input事件)。

当input事件发生的时候,要把文本框的值取出来,重新赋给绑定的vm的属性。

也就是给包含v-model指令的文本框元素绑定input事件。

// js/compiler.js
// 处理v-model指令
modelUpdater (node, value, key) {
  // 更新表单元素的值
  node.value = value
  new Watcher(this.vm, key, newValue => {
    node.value = newValue
  })
  // 双向绑定
  node.addEventListener('input', () => {
    this.vm[key] = node.value
  })
}

以上就实现了双向绑定:

  1. 当文本框的内容发生变化,会触发input事件。
  2. input事件处理函数中,把文本框的值取出来,并重新赋值给vm[key]
  3. 当给vm[key]赋值时,又会触发响应式机制。
  4. 响应式机制的工作就是触发setter方法,setter中向所有依赖这个属性的watcher发送通知。
  5. watcher的update被调用,更新视图中使用了这个属性的所有节点。

至此模拟Vue的响应式原理的代码就结束了。

代码只是为了了解响应式机制和双向绑定的原理,所以只模拟了最简单的内容。

调试

开发人员工具 - Sources-调试快捷键:

  • F8 - 直接跳到下一个断点
  • F10 - 单步执行,遇到子函数并不进去,将子函数执行完并将其作为一个单步
  • F11 - 单步执行,遇到子函数就进去继续单步执行。
  • F9 - 似乎等效于F11
  • Shift + F11 - 直接跳出当前函数,返回父函数

添加断点:

  • 点击代码行号

删除断点:

  • 删除单个断点:点击断点
  • 删除全部断点:Breakpoints中删除

查看断点:

  • 右侧查看Breakpoints
    • 复选框:可以禁用/开启断点
    • 点击断点下的代码快速跳到断点处代码
    • 删除全部断点:右键-remove all breakpoints

新增属性是否是响应式

上面代码的实现中已知,当给一个vm中data的属性重新赋值为一个对象时,会触发这个属性的setter方法。

setter方法内部调用walk方法将新赋予的这个对象及对象下的属性转化为响应式。

但如果给vm添加一个新的属性时,这个属性并没有转化为响应式。

因为将vm中data的属性转化为响应式是在new初始化vue实例的时候,所以新增属性并不会被转化。

Vue官方文档 【检测变化的注意事项】提供了如果将新增的属性转化成响应式数据的方法。

对于已经创建的实例,Vue 不允许动态添加根级别的响应式 property。

但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套(下一级)对象添加响应式 property。

// 静态访问方法
Vue.set(vm.someObject, 'b', 2)
// or
// 实例方法
this.$set(this.someObject,'b',2)

可以推测到,Vue.set方法内部使用了Object.defineProperty将属性b转换成了getter/setter。

总结

通过下图回顾整体流程:

简单模拟Vue响应式原理_第10张图片

  1. 创建Vue对象,构造函数中做的事情:
    1. 记录options传过来的选项
    2. 调用proxyData将data中的属性转化为getter/setter,并注入到实例中
    3. 创建Observer,作用:数据劫持
      1. 它将data中的属性转化为getter/setter
        1. 数据变化的时候(setter),调用dep对象的notify通知变化
          1. notify内部发送通知:调用watcher的update
            1. watcher的update内部实现更新视图
    4. 创建Compiler,作用:解析模板
      1. 页面首次加载时,调用compiler方法,在具体更新视图的方法中的工作:
        1. 更新视图
        2. 创建watcher
          1. 订阅数据变化
            1. watcher实例化时会将自己添加到dep对象的subs(依赖/订阅者)列表中
          2. 绑定更新函数 cb
            1. 在update方法中被调用
  • 当页面首次加载时,通过compiler更新视图
  • 当数据发生变化时,通过watcher更新视图

你可能感兴趣的:(vue)