Vue 2.x 响应式原理深度剖析

560 阅读13分钟

前言

前端技术层出不穷,Vue3.0 出来都已经几个月了,在面对这些层出不穷的库或者框架时,我们在熟练掌握其使用后也要对其原理层面的知识有一定深度的了解。这不仅对我们在面试求职时锦上添花,还会让我们对框架本身的使用和其拥有的独特的思想有更深层次的认识。

本文会先从 Vue 独特的思想到 2.x 中的响应式原理的基本实现以及它的优缺点来彻底搞清 Vue 中的响应式原理。

之后也会更新出剖析 Vue3.x 中的响应式原理和 2.x3.x 对比的文章,可以关注查看后续的更新。

响应式思想

使用过 Vue 的同学肯定知道它是使用了 MVVM 的模式,主要是分离视图模型,本质上是 MVC 的改进版。

可以看到上图中,视图层与数据层分离,中间通过框架来进行通讯完成响应式。

  • 当视图更改时框架内部监测到后影响到数据的更改。
  • 相应的当数据发生变化时,视图也会响应式的自动更改。

如果你了解 React,那么你可以将 MVCMVVM 做出一个对比,就可以对双向绑定有更深层次的认识。

了解了简单的 MVVM 模式后,我们就来探究一下在 Vue 中是如何实现中间的通讯层的。

Vue 2.x

先来看一张在 Vue2.x 中的基础架构图:

其实在这张图中,很多的名词我们都耳熟能详,或者肯定会有一定的了解,我先来简单的解释一下,后面会对图中所有的模块逐一分析实现。

  • Observer:观察者,通过对 MVVM 模式的了解,我们知道要对视图或者数据进行观察监测,监听它们的变化之后做出一系列操作。
  • Dep:依赖收集器,收集模板中所有对数据有依赖的地方,当视图或者数据变化时,就去通知依赖收集器中的依赖做出相应的更新。
  • Watcher:监听器,在依赖收集器中包含着一个个的监听器,监听器会在模板编译的过程中自动将自己添加到依赖收集器中。
  • Compile:编译,编译指令 v-xxx 以及模板语法 {{ }}

在对图中的名词做出解释后,我们会进入每一个模块来深度的剖析它们的实现原理。

new Vue()

我们新建一个 Vue.js 文件并在自己的 index.html 文件中引入,我们采用模块化的开发:

<!-- index.html -->
<body>
<div id="app"></div>
<script type="module">
   import Vue from './Vue.js'
   const vm = new Vue({
      el: '#app',
      data: {
         name: 'seven',
         info: {
            age: 20
         }
      }
   })
   <!-- 这里是为了在浏览器控制台中使用 vm 变量来调试 -->
   window.vm = vm
</script>
</body>

这是一段简单的 demo,我们在 Vue.js 中新建 Vue 类:

// Vue.js
export default class Vue {
   constructor(options) {
      this.$options = options
      this.$el = typeof options.el === 'string'
         ? document.querySelector(options.el)
         : options.el
      this.$data = typeof options.data === 'function'
         ? options.data()
         : options.data
   }
}

可以看到上面的代码只是简单的给实例对象 vm 上挂载了 $options$el$data 属性,分别拿到 new 时的参数和 DOM 元素,以及数据。 打印一下 vm

当然这里初始化 Vue 类的时候的判断并不严谨,你可以加一些 warning 或者 error 信息避免别人使用的时候传参错误。

数据代理

我们在实现响应式之前先来实现 Vue 中的数据代理,我们在使用 Vue 的时候,或者 data 中的数据直接是 this.name 或者 this.info,并不会使用 this.$data.name 尽管这样是可行的,但是会让我们的每次取值都变得很麻烦。所以在 Vue 中为我们实现了数据的代理。我们来看一下内部的实现:

// Vue.js
export default class Vue {
   constructor(options) {
      this.$options = options
      this.$el = typeof options.el === 'string'
         ? document.querySelector(options.el)
         : options.el
      this.$data = typeof options.data === 'function'
         ? options.data()
         : options.data

      // 数据代理
      this.proxyData()
   }

   proxyData() {
   	  // 循环遍历 this.$data 上的属性
      for (const key in this.$data) {
         // 使用 Object.defineProperty Api 给 this 上扩展这些属性
         Object.defineProperty(this, key, {
            enumerable: true,
            configurable: false,
            // 取值时取 this.$data 中对应的值
            get() {
               return this.$data[key]
            },
            // 设置值时直接设置 this.$data 中的值
            set(newValue) {
               if (newValue !== this.$data[key]) {
                  this.$data[key] = newValue
               }
            }
         })
      }
   }
}

此时我们在浏览器中测试一下:

这样我们就已经实现了数据代理,this.$data 中的数据可以直接使用 this 来获取和修改。

Observer 数据观测

我们先来实现一个观察者,该类用来将数据变成响应式的,在 Vue 的构造函数中我们 new Observer ,这其实是一个 建造者模式 的体现。

关于建造者模式,更多详情你可以翻阅其他资料进行了解,简单来说建造者模式就是为了创建一个复杂的对象时使用的设计模式。

Vue.js 中我们引入外部的 Observer.js 文件,并且在构造函数中实例化它:

// Vue.js
import Observer from './Observer.js'

export default class Vue {
   constructor(options) {
      // ...
      // 数据代理
      this.proxyData()
      // 数据观测
      new Observer(this.$data)
   }
   // ...
}

接着我们就可以创建 Observer.js 文件:

// Observer.js
export default class Observer {
   constructor(data) {
      this.observe(data)
   }
   // 将数据变成响应式的
   observe(data) {
      
   }
}

Observer 类中我们要对 data 数据进行观测,也就是将它简称响应式的数据,逻辑封装在 observe 函数中。

我们知道 Vue2.x 的响应式实现时依赖了 Object.defineProperty 这一 API,接下来就让我们具体来看一下是怎么实现的吧:

// Observer.js
export default class Observer {
   constructor(data) {
      this.observe(data)
   }

   observe(data) {
      // 循环遍历数据
      for (const key in data) {
         const value = data[key]
         defineReactive(data, key, value)
      }
   }
}

function defineReactive(obj, key, value) {
   // 深度检测,如果该属性值还是一个对象,也要将其变成响应式的
   if (value && typeof value === 'object') {
      new Observer(value)
   }
   // 将数据的每一个属性都变成 getter 和 setter 的形式,实现侦测
   Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
         return value
      },
      set(newValue) {
         if (newValue !== value) {
            value = newValue
            // 该属性被赋予的新值也要对其进行变化侦测
            if (value && typeof value === 'object') {
               new Observer(value)
            }
         }
      }
   })
}

上面的代码就是一个 Observer 类的基本实现,目的就是为了将 data 中的所有的属性都变成 gettersetter 的形式从而讲其变成响应式的。目前为止,我们的工作还差很多,需要实现了后面的操作后才能看到效果。

到这里,我们可以先来暂停一下来分析一下 Observer 类中的核心代码。根据分析,在我们 new Vue() 传入 data 数据的时候,会默认自动的进行 new Observer 操作,此时会直接对 data 数据进行深层次的响应式转化,会很浪费性能。

意思也就是说,当我们的初始化 data 数据是一个嵌套层次很深的对象类型的数据时,默认一上来就会进行递归操作讲所有的 key 都变成 gettersetter 形式,这会造成性能的浪费。但是这是不可避免的。但是在 Vue3.0 中由于没有使用 Object.defineProperty 这一特性,所以初始化数据的时候没有进行递归操作,性能得到了大幅度提升。后续我们会讲到。

除了对初始化数据时的性能不好的分析外,我们还应该分析到对于对象类型的数据来说,新增和删除属性时,是不能被侦测到的。知道了原理后,这也很好解释,因为 for ... in ... 循环遍历不到新添加的属性,Object.defineProperty 也不能监测到删除的操作。所以对于对象类型的数据来说,新增和删除属性是不能被监测到的。

虽然因为 Object.defineProperty 这一 API 的缺点我们无法侦测对象数据的新增属性和删除属性的操作,但是 Vue2.x 中为我们实现好了 $set 方法来弥补这一缺失。有关 $set 特性的实现这里不再讲解。

特别提醒
知道了这些原理后,我们在以后的开发中遇到为对象新增删除属性的操作时就要使用 `$set` 来实现。

对于数组的变化侦测,我打算放到最后讲解,我们先就对象来走完所有的响应式流程。

Dep 依赖收集

上面我们已经将数据变成了 gettersetter 的形式,我们需要知道:

  • 在访问对象的属性时会调用 getter 方法,例如:访问 vm.name
  • 在设置对象的属性时会调用 setter 方法,例如:设置 vm.name = 'juejin'

我们将数据变成这种形式的目的就是为了获取使用了数据的地方以及拦截修改数据的操作。 我们称前者获取使用了数据的地方为依赖收集,后者拦截修改数据的操作为数据劫持

那么依赖收集的目的是什么呢?

我们将页面中使用了数据的地方进行收集,等待拦截到数据修改的操作后我们通知这些使用了数据的地方更新数据,这就是响应式原理的核心。

所以接下来我们来实现一下依赖收集这一模块,同样我们需要写一个类,在需要收集依赖的地方实例化这个类,新建一个 Dep.js 文件,到这里我们可以再来看一看之前的那张图:

可以看到 Dep 应该具有添加依赖通知所有依赖的方法:

// Dep.js
export default class Dep {
   constructor() {
      // 使用数据储存所有的依赖
      this.deps = []
   }
   
   // 向数组中添加依赖的方法
   addDep(dep) {
      this.deps.push(dep)
   }
   
   // 通知所有依赖,让依赖触发它们的更新方法
   notify() {
      this.deps.forEach(w => w.update())
   }
}

到这里,我们的 Dep 类就写好了,只是还没有使用它,使用它之前我们要弄明白 deps 中储存的依赖项究竟是什么?其实就是一个 watcher

Watcher 监听器

我们先来介绍一下一个 watcher 的基本使用:

new Watcher(vm, key, (oldValue, newValue) => {
   // ...
})

你可能已经发现了这个跟我们平时在 Vue 中使用的 watch 特别的相似。其实,watch 就是它的封装。

接下来就让我们来实现一下 Watcher 类,这里先注意之前提到过的一点:watcher 会自动将自己添加到依赖收集器中

// Watcher.js
// 导入 Dep ,为了往 Dep 类上挂载静态属性
import Dep from "./Dep.js"

export default class Watcher {
   constructor(vm, expr, callback) {
      this.vm = vm
      this.expr = expr
      this.callback = callback
      // 获取老值
      this.oldValue = this.getValue()
   }

   getValue() {
      // 获取老值前给 Dep 类上添加静态属性 target 指向自身实例对象
      Dep.target = this
      const oldValue = getVal(this.expr, this.vm)
      // 取值完后删除静态属性
      Dep.target = null
      return oldValue
   }

   update() {
      // 更新时获取新值并触发回调函数
      const newValue = getVal(this.expr, this.vm)
      this.callback.call(this.vm, newValue, this.oldValue)
   }
}

// 取值的辅助函数,由于 expr 可能会传值 info.age 等,此时需要解析
function getVal(expr, vm) {
   return expr.split('.').reduce((data, currentVal) => {
      return data[currentVal]
   }, vm.$data)
}

可以看到 Watcher 类中有一个惊人的操作,就是在 getValue 方法中。每当我们 new Watcher 的时候,会自动调用这个方法,并且给 Dep 添加静态属性 target 指向实例对象本身,获取之后将其移除。

可能你看到这里后觉得这并没有什么稀奇的,还会觉得多此一举。如果是这样,我来提醒你一点,你忽略了中间的取值操作:this.vm[key]

这是在取值,取值不就会触发 getter 方法吗,那在取值的这一步操作中就刚好可以获取到 Dep.target 的值了,并且取值后它就不存在了,这是为了防止同一依赖被收集多次,造成重复的更新。

顺着这个思路我们就应该回到 Observer 类中来使用依赖收集器来添加依赖了。我们需要在 getter 中收集依赖,在 setter 中通知依赖更新。

// Observer.js
import Dep from "./Dep.js"

export default class Observer {
   constructor(data) {
      this.observe(data)
   }

   observe(data) {
      for (const key in data) {
         const value = data[key]
         defineReactive(data, key, value)
      }
   }
}

function defineReactive(obj, key, value) {
   if (value && typeof value === 'object') {
      new Observer(value)
   }
   // 创建依赖收集器
   const dep = new Dep()
   Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
         // 收集依赖
         Dep.target && dep.addDep(Dep.target)
         return value
      },
      set(newValue) {
         if (newValue !== value) {
            value = newValue
            // 通知依赖
            dep.notify()
            if (value && typeof value === 'object') {
               new Observer(value)
            }
         }
      }
   })
}

目前为止,对于对象类型的数据来说,图中的上半部分就已经完成了,也就是完成了响应式的核心代码。但是此时我们并不能看到任何的结果,甚至我们还没有使用 Watcher 类,那么现在就让我们完成 Compile 编译模块的书写,以便检验我们响应式系统的实现。

Compile

分析 Compile 模块,我们需要在数据劫持后进行模块的编译,否则编译之后的视图不再依赖数据,所以 new Compile 的操作需要放到 new Observer 之后。

在进行编译的时候我们有两个大的方向:

  • 需要对指令 v-xxx 进行编译
  • 需要对模板语法 {{ }} 进行编译 对于这两步我们都需要获取到 vm.$el 从根元素开始遍历所有的节点,获取每一个节点中的属性和文本内容,如果需要解析则进行解析。

知道这个主导方向之后,我们先来实现 Compile 的基本骨架,在这以前我们需要模拟一些指令和模板语法在页面中以及在 Vue.js 中使用 Compile

// index.html
<body>
<div id="app">
   <input type="text" v-model="name">
   <h1>{{ name }} -- {{ info.age }}</h1>
   <h2 v-text="text"></h2>
   <div v-html="htmlText"></div>
   <a v-bind:href="link">百度</a>
   <button v-on:click="clickHandler">按钮</button>
</div>
<script type="module">
   import Vue from './Vue.js'
   const vm = new Vue({
      el: '#app',
      data: {
         name: 'seven',
         info: {
            age: 20
         },
         text: '<h2>v-text 显示的内容</h2>',
         htmlText: '<h2>v-html 显示的内容</h2>',
         link: 'http://www.baidu.com'
      },
      methods: {
         clickHandler() {
            console.log('click')
         }
      }
   })
   window.vm = vm
</script>
</body>

可以看到,我们将会解析 v-model{{ }}v-textv-htmlv-bindv-on 这些指令与模板。可以在浏览器中看到现在的展示:

接着在 Vue.js 中:

// Vue.js
import Compile from './Compile.js'
import Observer from './Observer.js'

export default class Vue {
   constructor(options) {
      // ...
      // 数据代理
      this.proxyData()
      // 数据观测 / 数据劫持
      new Observer(this.$data)
      // 模板编译
      new Compile(this.$el, this)
   }
}

这时我们就可以创建 Compile.js 文件并且实现 Compile 类了:

// Compile.js
export default class Compile {
   constructor(el, vm) {
      this.el = el
      this.vm = vm
      // 将 DOM 添加到文档碎片中进行批量操作
      const fragment = this.nodeToFragment()
      // 编译文档碎片
      this.compile(fragment)
      // 将编译后的文档碎片添加回 DOM 中
      this.el.appendChild(fragment)
   }

   nodeToFragment() {
      const fragment = document.createDocumentFragment()
      while(this.el.firstChild) {
         fragment.appendChild(this.el.firstChild)
      }
      return fragment
   }

   compile(fragment) {
      
   }
}

这里我们采用了文档碎片的方式将 DOM 储存起来,然后批量编译后追加回原来的容器中。这样做的好处就是避免大量的操作 DOM 引起性能力浪费,而是一次性的操作。

之后的编译逻辑就在 compile 函数中展开了,我们需要遍历所有的节点,找出需要进行编译的节点,并且区分是编译模板还是指令:

// Compile.js
export default class Compile {
   // ...

   compile(fragment) {
      // 遍历孩子节点
      Array.from(fragment.childNodes).forEach(child => {
         // 根据孩子节点的 nodeType 区分是元素节点还是文本节点
         // 从而区分需要编译指令还是模板,再调用对应的方法分开编译
         if (child.nodeType === 1) {
            this.compileElement(child)
         } else if (child.nodeType === 3) {
            this.compileText(child)
         }
         // 递归遍历所有的子节点
         if (child.childNodes && child.childNodes.length > 0) {
            this.compile(child)
         }
      })
   }

   compileElement(node) {

   }

   compileText(node) {
      
   }
}

上面的代码中我们使用了 Array.from 来将 childNodes 这一类数组转化为数组来使用 forEach 方法,你也可以用其他方法。其实在 chrome 等浏览器中并不需要这么做,元素的子节点列表是有继承 forEach 这一方法的,我们可以直接使用,这里只是为了保证兼容性,以及照顾不懂的同学。

到这里之后我们就只需要分开分别实现 compileElement 方法和 compileText 方法来分别解析指令和模板就可以了。

我们先来解析 {{ }} 语法:

{{ }} 模板语法解析

// Compile.js
export default class Compile {
   // ...
   
   compileText(node) {
      const reg = /\{\{(.+?)\}\}/g
      const content = node.textContent
      if (reg.test(content)) {
         Utils.text(node, content, this.vm)
      }
   }
}

const Utils = {
   getVal(expr, vm) {
      return expr.split('.').reduce((data, currentVal) => {
         return data[currentVal]
      }, vm.$data)
   },
   text(node, expr, vm) {
      const fn = this.Updater.textUpdater
      let value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
         const key = args[1].trim()
         return this.getVal(key, vm)
      })
      fn(node, value)
   },
   Updater: {
      textUpdater(node, value) {
         node.textContent = value
      }
   }
}

分析一下上面的 compileText 做了哪些事:

  • 编写一个匹配 {{ }} 的正则
  • 获取元素的文本内容,如果文本内容中有 {{ }} 就交给 Utils.text 来执行
  • Utils.text 方法会提取所有的 {{ }} 并将中间的内容
  • textUpdater 获取到中间表达式对应的数据值后将内容替换

经过这几步后我们可以在页面中看到:

可以发现所有的 {{ }} 可以被正确解析了。但是这里有一个问题,我们视图在控制台中修改 vm.name 属性值:vm.name = 'juejin',这并不会引起视图的更新。

这时因为我们还没有为这里的所有 {{ }} 添加监听器 watcher,当我们添加添加监听器后,数据变化后监听器会自动执行 update 方法,之后调用回调函数,我们可以在回调函数中更新视图:

// Compile.js
import Watcher from "./Watcher.js"

export default class Compile {
   // ...
   
   compileText(node) {
      const reg = /\{\{(.+?)\}\}/g
      const content = node.textContent
      if (reg.test(content)) {
         Utils.text(node, content, this.vm)
      }
   }
}

const Utils = {
   getContentValue(vm, expr) {
      const value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
         return this.getVal(args[1].trim(), vm)
      })
      return value
   },
   text(node, expr, vm) {
      const fn = this.Updater.textUpdater
      let value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
         const key = args[1].trim()
         new Watcher(vm, key, () => {
            fn(node, this.getContentValue(vm, expr))
         })
         return this.getVal(key, vm)
      })
      fn(node, value)
   },
   getVal(expr, vm) {
      return expr.split('.').reduce((data, currentVal) => {
         return data[currentVal]
      }, vm.$data)
   },
   Updater: {
      textUpdater(node, value) {
         node.textContent = value
      }
   }
}

上面的代码我们在 Utils.text 方法中新增了一个监听器,每当 {{ }} 中的数据发生改变,都会重新渲染视图。

这时,我们再来验证一下:

可以看到对于 {{ }} 的数据驱动视图的变化已经生效了,这完全得益于我们之前实现好的响应式系统。

接下来,我们将要实现一个个指令的编译。对于复杂情形,比如修饰符 .stop 等这里就不再实现,基础结构搭建好后,这些都是可以进行扩展的。

v-xxx 指令编译

实现了模板的编译后我们其实就知道了编译的大致流程,对于模板的编译就是获取到表达式后,用数据替换表达式。

同样的,对于指令的编译,也是在这一基础上添加了一个解析是哪一条指令的操作。我们先来区分每一条指令对应的类型,以及获取它依赖的值:

// Compile.js
export default class Compile {
   // ...
   // 编译元素标签
   compileElement(node) {
      // 获取所有的属性并且遍历
      const attrs = Array.from(node.attributes)
      attrs.forEach(attr => {
         // 如果含有指令属性就进行解析
         if (attr.name.startsWith('v-')) {
            const name = attr.name.split('v-')[1]
            const [directive, methodName] = name.split(':')
            const value = attr.value
            // 每一种指令对应一种解析方法
            Utils[directive](node, value, this.vm, methodName)
            // 最后标签上面丑陋的 v-xxx 属性
            const removedDirective = methodName ? `v-${directive}:${methodName}` : ('v-' + directive)
            node.removeAttribute(removedDirective)
         }
      })
   }
}
const Utils = {
   model(node, expr, vm) {

   },
   html(node, expr, vm) {

   },
   bind(node, expr, vm) {

   },
   on(node, expr, vm) {

   },
   text(node, expr, vm) {
      const fn = this.Updater.textUpdater
      let value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
         const key = args[1].trim()
         new Watcher(vm, key, () => {
            fn(node, this.getContentValue(vm, expr))
         })
         return this.getVal(key, vm)
      })
      fn(node, value)
   }
}

通过上述代码,我们已经区分开了这些指令,并且对于不同的指令我们都有单独的方法来处理。这里可以发现对于 v-text 指令的处理和 {{ }} 的处理使用了同一个方法,所以我们要修改一下 text 方法,让他可以适配 v-text 指令:

v-text

// Compile.js
text(node, expr, vm) {
   const fn = this.Updater.textUpdater
   let value
   // 如果是解析 {{ }} 按之前的逻辑
   if (expr.indexOf('{{') !== -1) {
      value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
         const key = args[1].trim()
         new Watcher(vm, key, () => {
            fn(node, this.getContentValue(vm, expr))
         })
         return this.getVal(key, vm)
      })
   } else {
      // 否则是解析 v-text ,此时直接取值并对值进行侦测
      value = this.getVal(expr, vm)
      new Watcher(vm, expr, newVal => {
         fn(node, newVal)
      })
   }
   fn(node, value)
}

可以在页面中看到:

这只是简单的文本展示,并没有识别标签,可以识别标签的指令应该是 v-html,我们来实现一下它:

v-html

分析 v-html 的作用其实就是将表达式对应的值替换为元素节点的 innerHTML 值:

// Compile.js
const Utils = {
   html(node, expr, vm) {
      const fn = this.Updater.htmlUpdater
      const value = this.getVal(expr, vm)
      // 添加监听器,侦测数据变化
      new Watcher(vm, expr, newValue => {
         fn(node, newValue)
      })
      fn(node, value)
   },
   Updater: {
      htmlUpdater(node, value) {
         node.innerHTML = value
      }
   }
}

这时可以在页面中看到 <h2> 标签已经被解析出来了:

此时,当我们修改值得时候页面得视图会自动做出变化,可以测试一下:

可以看到我们的 v-textv-html 指令都没有问题。

接着我们先实现以下较为简单的 v-bind 指令从而实现页面中的 <a> 标签的跳转功能:

v-bind

// Compile.js
const Utils = {
   bind(node, expr, vm, attr) {
      const fn = this.Updater.bindUpdater
      const value = this.getVal(expr, vm)
      new Watcher(vm, expr, newVal => {
         fn(node, attr, newVal)
      })
      fn(node, attr, value)
   },
   Updater: {
      bindUpdater(node, attr, value) {
         node.setAttribute(attr, value)
      }
   }
}

可以到页面中发现我们的 <a></a> 标签已经拥有了跳转功能,并且标签上的属性也显示正常:

v-model

接下来应该就是 Vue 中的重头戏了,表单数据的双向绑定 v-model 的实现。我们先来对它进行一些分析:

  • 根据数据显示 input 中的值
  • 当用户在输入框中输入值时改变数据的值

我们先来实现第一步,根据数据显示 input 中的值:

// Compile.js
const Utils = {
   model(node, expr, vm) {
      const fn = this.Updater.modelUpdater
      const value = this.getVal(expr, vm)
      new Watcher(vm, expr, newVal => {
         fn(node, newVal)
      })
      fn(node, value)
   },
   Updater: {
      modelUpdater(node, value) {
         node.value = value
      }
   }
}

我们来测试一下显示与其响应式:

可以看到一切都正如所料,数据的变化可以驱动试图的变化。

接下来我们来实现当用户进行输入的时候,数据同步的发生变化,所以我们要监听用户的表单的 input 事件:

// Compile.js
const Utils = {
   model(node, expr, vm) {
      const fn = this.Updater.modelUpdater
      const value = this.getVal(expr, vm)
      new Watcher(vm, expr, newVal => {
         fn(node, newVal)
      })
      fn(node, value)
      // 监听用户的输入事件,同时给 data 中的数据设置值
      node.addEventListener('input', (e) => {
         this.setVal(expr, vm, e.target.value)
      })
   },
   setVal(expr, vm, value) {
      expr.split('.').reduce((data, currentVal, index, arr) => {
         if (index === arr.length - 1) {
            return data[currentVal] = value
         }
         return data[currentVal]
      }, vm.$data)
   },
   Updater: {
      modelUpdater(node, value) {
         node.value = value
      }
   }
}

上面的代码中设置值的操作使用到了高阶函数 reducecallback 中的四个参数,如果不了解,你可以前往 MDN 文档查看。

这样我们就完成了第二步监听用户输入,视图影响数据的操作,我们来验证一下:

可以看到视图与数据的双向绑定就这样实现好了。

v-on

最后我们来实现以下 v-on 指令吧!

v-on 指令做了一件事:

  • DOM 元素绑定事件

但是绑定的事件我们先要代理到实例对象 vm 上才能获取到,我们先来进行数据代理的操作:

// Vue.js
export default class Vue {
   constructor(options) {
   	  // ...
      // 事件的代理
      this.proxyMethods()
      // ...
   }

   proxyMethods() {
      const methods = this.$options.methods = this.$options.methods || {}
      for (const methodName in methods) {
         Object.defineProperty(this, methodName, {
            enumerable: true,
            configurable: true,
            get() {
               return methods[methodName]
            },
            set(newMethod) {
               methods[methodName] = newMethod
            }
         })
      }
   }
}

此时我们可以通过 vm.clickHandler 获取到用户定义的方法了,就可以实现 v-on 指令:

// Compile.js
const Utils = {
   on(node, expr, vm, methodName) {
      const fn = this.Updater.onUpdater
      const method = vm[expr]
      fn(node, methodName, method)
   },
   Updater: {
      onUpdater(node, methodType, method) {
         node.addEventListener(methodType, method)
      }
   }
}

这个时候我们的按钮的点击事件就已经添加好了,我们可以验证一下:

总结

我们从 0 开始实现了一版 Vue2.x 中的响应式系统,深入其原理。逐一实现了数据代理响应式系统的搭建模板编译等模块。

我们分析了响应式系统中的优缺点,对不能监测到的数据变化进行了分析,主要有:

  • 对象属性的添加
  • 对象属性的删除

其实对于数组元素来说,也有两个操作不能进行检测:

  • 利用索引直接设置一个数组项
  • 修改数组的长度 length

对于数组的响应式实现近期也会抽空加入本文,尽请期待。

最后再来附上一开始的 MVVM 模式的图片,相信此时你会对它由更加深入的理解:

还有 Vue2.x 中响应式系统的基础架构图,此时通过代码的书写相信你肯定可以理清图中的所有脉络: