Vue响应式原理剖析(data、watch、computed)

241 阅读8分钟

能用 Vue 改写的应用,终将会用 Vue 来改写。—— 尤大大(没说过)

都 2020 年了,我想下面三句话不过分吧:

  • Vue 是一个非常优秀的前端框架
  • 每个前端开发都应该会一点 Vue
  • 不会 Vue 的前端不是优秀的前端

其实 Vue 的 API 是非常容易上手的,官方文档读一遍就能开发了,分分钟实现一个 TODO App。但只会用 Vue 其实是远远不够的,每一个 Vue 开发者都应该了解 Vue 响应式原理(datawatchcomputed),否则永远只是知其然,不知其所以然。

为了让大家彻底搞懂,本文采用渐进式的分析方式,即从零开始一行行写代码,最终完整实现一个具备响应式简版 Vue。 好了,大家把 vscode 打开,新建一个 vue.js 空文件,开始撸吧!

Vue 初始化流程

Vue 本质上就是一个构造函数,只有一行代码:

function Vue(options) {
  this._init(options)
}

到这里,你可能就懵逼了,肯定会问 this._init 方法是什么?别急,这个方法是定义原型上的,而尤大大喜欢通过下面的方式来给原型添加方法:

initMixin(Vue)
stateMixin(Vue)

目的是拆分成一个个文件方便维护,尤大大把 initMixin 方法写在了 init.js 文件中,stateMixin 方法写在了 state.js 文件中,由于我们今天只写一个文件,所以就把它们放在一起好了:

function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options
    initData(vm)
    initComputed(vm)
    initWatch(vm)
  }
}

function stateMixin(Vue) {
  Vue.prototype.$watch = function (exprOrFn, cb, options = {}) {
    const watcher = new Watcher(this, exprOrFn, cb, { ...options, user: true })
    if (options.immediate) cb.call(vm, watcher.value)
  }
}

你看,这两个 mixin 也没啥特别的,不就是给 Vue 原型加了个 _init$watch 方法嘛!先看 _init 方法吧,总共就 5 行,前面两行的作用是把用户传过来的 option 参数赋值给了实例的 $options 属性上,然后跟了仨 initXXX,不要怕,这就是尤大大撸码的风格:套娃。即把代码拆分、拆分又拆分而已。我们分别来看下:

// 初始化 data
function initData(vm) {
  let { data } = vm.$options
  data = vm._data = typeof data === 'function' ? data.call(vm) : data || {}
  for (let key in data) proxy(vm, '_data', key)
  Observer.observe(data)
}
// 初始化 computed
function initComputed(vm) {
  const { computed } = vm.$options
  const watchers = (vm._computedWatchers = {})
  for (let key in computed) {
    const userDef = computed[key] // 取出对应的值来
    const getter = typeof userDef == 'function' ? userDef : userDef.get // 兼容函数和对象
    watchers[key] = new Watcher(vm, getter.bind(vm), () => {}, { lazy: true })
    defineComputed(vm, key, userDef)
  }
}
// 初始化 watch
function initWatch(vm) {
  const { watch } = vm.$options
  for (const key in watch) {
    const handler = [].concat(watch[key])
    handler.forEach((it) => createWatcher(vm, key, it))
  }
}

你看,这三个函数就是处理用户传过来的 datacomputedwatch 的,从 vm.$options 也就是用户传过来的 options 中结构出相应的值,然后进行处理而已。这三个 initXXX 函数里面可能用到了一些其他函数,例如:

  • initData 中有 proxyObserver.observe
  • initComputed 中有 new WatcherdefineComputed
  • initWatch 中有 createWatcher

我们先看下 proxy 函数:

function proxy(target, sourceKey, key) {
  Object.defineProperty(target, key, {
    get() { return target[sourceKey][key] },
    set(val) { target[sourceKey][key] = val },
  })
}

其实就是把从某个对象中取值,代理到从其内部某个属性中取值而已。例如:proxy(vm, '_data', 'name') 就是做了下面两件事:

  • 当用户调用 vm.name 的时候,实际上是从 vm._data.name 中取的
  • 当用户调用 vm.name = 'xxx' 的时候,实际上是操作 vm._data.name = 'xxx'

剩下的一些函数都跟接下来要讲的「三剑客」有关,大家最好先把上面讲的 Vue 初始化的整个流程梳理清楚,接下来会详细介绍「三剑客」。

三剑客

Vue 响应式原理的核心只有 3 个类,DepWatcherObserve,只要弄懂了这三剑客的实现,响应式原理就弄懂了。

Dep(依赖收集者)

Dep 类是跟响应式数据相关,它有以下两个特点:

  • 每个响应式对象的属性都关联了一个 dep 实例
  • 每个响应式对象或数组本身也关联了一个 dep 实例

在 dep 的内部有个 subs 数组,存放当前 dep 实例收集到的 watcher,当 dep 关联的对象属性或对象/数组本身发生变化的时候,就会触发 notify 方法通知 watcher

class Dep {
  static target = null // 当前watcher
  static targetStack = [] // watcher栈
  static pushTarget(target) { // watcher入栈
    this.targetStack.push(target)
    Dep.target = target
  }
  static popTarget() { // watcher出栈
    this.targetStack.pop()
    Dep.target = this.targetStack[this.targetStack.length - 1]
  }
  constructor() {
    this.subs = [] // 保存watcher实例
  }
  depend() {
    if (Dep.target) Dep.target.addDep(this) // 让watcher把当前实例保存到watcher内部
  }
  addSub(sub) {
    this.subs.push(sub) // 添加watcher
  }
  notify(newVal, val) {
    this.subs.forEach((sub) => sub.update(newVal, val)) // 通知watcher
  }
}

Watcher(数据观察者)

Watcher 类用于给 Vue 实例注册观察者,例如:

new Watcher(vm, 'name', ()=>console.log('name变了')

上面就为 name 注册了一个观察者,当调用 vm.name=newName 的时候,就会执行上面的回调,打印 name变了。这里的设计非常巧妙,要结合 Dep 一起看,核心在于 get 方法,注册流程是这样的:

  • 首先把当前实例放到 Depwatcher 栈里面
  • 接下来执行 getter 方法的时候会触发响应式数据的 get 方法
  • 响应式数据关联的 Dep 实例把 watcher 收集起来

watcher 的第二个参数可以是一个路径字符串或 getter 函数,例如: a.b.c.d,那么 getter 函数会被定义为取 vm.a.b.c.d 这种深层的属性,函数会更灵活一些,例如既要观察 name 又要观察 age 的话,只能用函数了。

class Watcher {
  constructor(vm, expOrFn, cb, options = {}) {
    this.vm = vm
    if (typeof expOrFn === 'function') this.getter = expOrFn
    else this.getter = (vm) => expOrFn.split('.').reduce((it, k) => it && it[k], vm)
    this.cb = cb
    this.deps = []
    this.lazy = !!options.lazy
    this.user = !!options.user
    this.value = this.get()
  }
  get() { // 调用 getter 取值,触发 dep 收集
    Dep.pushTarget(this)
    const value = this.getter.call(this.vm, this.vm)
    Dep.popTarget()
    return value
  }
  update(newVal, val) { // 依赖变化的时候被调用(即dep.notify中调用)
    if (this.lazy) {
      this.dirty = true
    } else {
      this.cb.call(this.obj, newVal, val) // 调用 cb 函数
    }
  }
  evaluate() { // 手动取值(计算属性会用到)
    this.value = this.get()
    this.dirty = false
  }
  addDep(dep) { // 把 dep 实例添加到 deps 数组中,实现双向引用
    if (!this.deps.includes(dep)) {
      this.deps.push(dep)
      dep.addSub(this)
    }
  }
  depend() { // 手动收集依赖(计算属性会用到)
    this.deps.forEach((it) => it.depend())
  }
}

Observer(响应式缔造者)

Observer 类就是 Vue 中实现数据响应式和核心类了,它的流程为:

  • 如果数据是对象类型,就用 Object.defineProperty 拦截其所有属性,在 get 中收集,在 set 中通知
  • 如果对象的属性还是一个对象,那么就递归拦截,确保所有对象的所有属性都被拦截
  • 如果数据是数组类型,就重写 Array.prototype 原型,拦截 pushpopreverse 等方法
class Observer {
  static observe(value) { // 静态方法,用于创建observer实例实现数据响应式
    if (typeof value !== 'object' || value == null || value.__ob__) return
    return new Observer(value)
  }
  constructor(value) {
    this.value = value
    this.dep = new Dep()
    Object.defineProperty(value, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
    })
    if (Array.isArray(value)) {
      rewriteArrayMethods(value) // 重写数组方法
      this.observeArray(value)
    } else {
      this.walk(value) 
    }
  }
  walk(obj) { // 对象响应式
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      this.intercept(obj, keys[i], obj[keys[i]])
    }
  }
  intercept(data, key, val) { // 对象属性拦截
    const ob = Observer.observe(val) // 对象递归响应式
    const dep = new Dep()
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get: function () {
        dep.depend() // 把 watcher,即 Dep.target 收集到 key 属性对应的 dep 实例中
        if (ob) ob.dep.depend() // 把 watcher,即 Dep.target 收集到 observer 内部的 dep 实例中
        return val
      },
      set: function (newVal) {
        if (val === newVal) return
        const oldVal = val
        val = newVal
        Observer.observe(newVal)
        dep.notify(newVal, oldVal) // 通知观察者
      },
    })
  }
  observeArray(arr) {
    arr.forEach((it) => Observer.observe(it)) // 数组内部元素响应式
  }
}

Observer 类里面用到了下面的函数来重写数组原型上的方法:

function rewriteArrayMethods(value) {
  const arrayProto = Array.prototype
  const arrayMethods = Object.create(arrayProto)
  // 只有这几个方法会改变原数组
  const methods = 'push,pop,shift,unshift,reverse,sort,splice'.split(',')
  let oldVal = JSON.stringify(value)
  methods.forEach((method) => {
    arrayMethods[method] = function (...args) {
      const result = arrayProto[method].apply(this, args)
      let inserted,
        ob = value.__ob__
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args
          break
        case 'splice':
          inserted = args.slice(2)
      }
      if (inserted) ob.observeArray(inserted)

      const newVal = JSON.stringify(value)
      ob.dep.notify(newVal, oldVal) // 通知数组更新
      oldVal = newVal
      return result
    }
  })
  Object.setPrototypeOf(value, arrayMethods) // 重写数组原型
}

这个类是 Vue 响应式中最核心和最复杂的一个类,响应式数据和 dep 实例之间的关系就是通过 Observer 类建立起来的,一旦对象或数组变成响应式了,就会多出一个 __ob__ 属性,所以 Observer.observe 方法的首行做了处理,只要存在 __ob__ 就不需要对数据进行再次观测了。

Computed 和 watch 的响应式

computedwatch 的底层也是利用了响应式原理,其中 watch 是比较简单的,只要创建一个 Watcher 实例即可:

function createWatcher(vm, exprOrFn, handler, options) {
  if (typeof handler == 'object') {
    options = handler
    handler = handler.handler // 是一个函数
  }
  if (typeof handler == 'string') {
    handler = vm[handler] // 将实例的方法作为handler
  }
  vm.$watch(exprOrFn, handler, options)
}

其实代码就是最后一行,上面几行是为了做兼容处理而已,即兼容下面两种写法:

{
  watch: {
    name(newVal, val) {
      console.log(`name变化了:${val}->${newVal}`)
    },
    age: {
      handler(newVal, val) {
        console.log(`age变化了:${val}->${newVal}`)
      }
    }
  }
}

Computed 则稍稍复杂一点,因为它是有缓存的,是惰性求值,所以它对应的 watcher 实例上有 lazydirty 属性,其中 lazy 标识该 watcher 是计算属性的,dirty 标识依赖是否发生过变化,如果没变,下次获取计算属性的时候直接从缓存取值,如果变化了,则重新执行计算属性函数。

function defineComputed(vm, key, userDef) {
  const watcher = vm._computedWatchers && vm._computedWatchers[key]
  Object.defineProperty(vm, key, {
    get: function () {
      if (watcher) {
        if (watcher.dirty) watcher.evaluate() // 只有dirty才会去重新计算
        if (Dep.target) watcher.depend() // 手动收集依赖(收集computed里面用到的依赖)
        return watcher.value
      }
    },
    set: userDef.set || function () {},
  })
}

可以看到,计算属性的 watcher 被放到了 vm._computedWatcher 对象中,键就是计算属性名,值就是用户定义的计算函数,当获取计算属性的时候会执行到 get 方法,内部做了一个判断,只有当 dirty 的时候才执行函数,否则直接取缓存中的值,即 watcher.value

结果验证

到这里,完整的响应式原理就写完了,为了验证结果,我们创建一个 Vue 实例,里面有 datawatchcomputed 属性:

const vm = new Vue({
  data: {
    name: 'keliq',
    age: 12,
    hobbies: ['reading', 'football'],
  },
  watch: {
    name(newVal, val) {
      console.log(`name变化了:${val}->${newVal}`)
    },
    age: {
      handler(newVal, val) {
        console.log(`age变化了:${val}->${newVal}`)
      }
    }
  },
  computed: {
    reversedName() {
      console.log('-----执行反转函数-----')
      return this.name.split('').reverse().join('')
    },
  },
})

为了演示响应式原理,即数据改变,视图自动更新,我们先定义一个视图模板:

const template = `<ol>
  <li>姓名:{{name}}</li>
  <li>年龄:{{age}}</li>
  <li>兴趣爱好:{{hobbies}}</li>
</ol>`

不过还需要实现一个模板引擎才行,完整的模板引擎的实现不在本文的内容里面,感兴趣的同学可以看这篇文章,这里用字符串拼接的方式实现最简单的模板引擎,只处理 {{}} 语法:

function render(vm) {
  let tpl = 'let str="";str+=`'
  tpl += template.replace(/{{(.+)}}/g, '`+$1+`')
  tpl += '`;return str;'
  const f = new Function('vm', `with(vm) {${tpl}}`)
  return f(vm, tpl)
}

然后手动观测一下即可:

vm.$watch(render, (newVal, oldVal) => {
  console.log('渲染watcher检测到变化 ${oldVal}->${newVal}`,页面更新')
  console.log('最新渲染结果为:')
  console.log(render(vm))
})

万事俱备只欠东风,接下来我们开始响应式交互吧,先把 name 改一下试试:

vm.name = 'david'

此时输出:

name变化了:keliq->david
渲染watcher检测到变化 keliq->david,页面更新
最新渲染结果为:
<ol>
  <li>姓名:david</li>
  <li>年龄:12</li>
  <li>兴趣爱好:reading,football</li>
</ol>

可以看到用户自定义的 watcher 和 渲染 watcher 都收到了通知。接下来再更新一下数组试试:

vm.hobbies.push('eating')

发现视图也被更新了:

渲染watcher检测到变化 ["reading","football"]->["reading","football","eating"],页面更新
最新渲染结果为:
<ol>
  <li>姓名:david</li>
  <li>年龄:12</li>
  <li>兴趣爱好:reading,football,eating</li>
</ol>

最后来看计算属性的惰性求值:

console.log(vm.reversedName)
console.log(vm.reversedName)
console.log(vm.reversedName)
vm.name = 'lucy'
console.log(vm.reversedName)
console.log(vm.reversedName)
console.log(vm.reversedName)

我们先调用三次 vm.reversedName 发现只执行了以此反转函数,接下来更新了 name 之后再调用三次,发现反转函数第一次重新执行了,因为此时 dirty=true,后面两次还是用的缓存。

-----执行反转函数-----
divad
divad
divad
name变化了:david->lucy
渲染watcher检测到变化 david->lucy,页面更新
最新渲染结果为:
<ol>
  <li>姓名:lucy</li>
  <li>年龄:12</li>
  <li>兴趣爱好:reading,football,eating</li>
</ol>
-----执行反转函数-----
ycul
ycul
ycul

总结

本文从零完整的实现了 Vue 响应式原理,对尤大大高山仰止,惊叹其如此巧妙的设计,只用了 DepWatcherObserver 三个类就构建出了一个 MVVM 框架。在刚开始读源码的时候,是很懵的,读完之后有种豁然开朗的感觉,建议大家把「三剑客」代码认真阅读几遍,梳理它们之间的关系,例如:

  • depwatcher 之间是一对多还是多对多的关系?
  • data 进行递归响应式的时候,到底创建了几个 dep 实例?
  • 对于给定的 options,总共有多少个 watcher 呢?