40、Vue 数据响应式原理

前言

Vue.js 的核心包括一套“响应式系统”。

“响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。

举个简单的例子,对于模板:

<div id="root">{{ name }}</div>

创建一个 Vue 组件:

var vm = new Vue({
  el: '#root',
  data: {
    name: 'luobo'
  }
})

代码执行后,页面上对应位置会显示:luobo。

如果想改变显示的名字,只需要执行:

vm.name = 'tang'

这样页面上就会显示修改后的名字了,并不需要去手动修改 DOM 更新数据。

接下来,我们就一起深入了解 Vue 的数据响应式原理,搞清楚响应式的实现机制。

基本概念

Vue 的响应式,核心机制是 观察者模式

数据是被观察的一方,发生改变时,通知所有的观察者,这样观察者可以做出响应,比如,重新渲染然后更新视图。

我们把依赖数据的观察者称为 watcher,那么这种关系可以表示为:

data -> watcher

数据可以有多个观察者,怎么记录这种依赖关系呢?

Vue 通过在 data 和 watcher 间创建一个 dep 对象,来记录这种依赖关系:

data - dep -> watcher

dep 的结构很简单,除了唯一标识属性 id,另一个属性就是用于记录所有观察者的 subs:

  • id - number
  • subs - [Watcher]

再来看 watcher。

Vue 中 watcher 的观察对象,确切来说是一个求值表达式,或者函数。这个表达式或者函数,在一个 Vue 实例的上下文中求值或执行。这个过程中,使用到数据,也就是 watcher 所依赖的数据。用于记录依赖关系的属性是 deps,对应的是由 dep 对象组成的数组,对应所有依赖的数据。而表达式或函数,最终会作为求值函数记录到 getter 属性,每次求值得到的结果记录在 value 属性:

  • vm - VueComponent
  • deps - [Dep]
  • getter - function
  • value - *

另外,还有一个重要的属性 cb,记录回调函数,当 getter 返回的值与当前 value 不同时被调用:

  • cb - function

我们通过示例来整理下 data、dep、watcher 的关系:

var vm = new Vue({
  data: {
    name: 'luobo',
    age: 18
  }
})

var userInfo = function () {
  return this.name + ' - ' + this.age
}

var onUserInfoChange = function (userInfo) {
  console.log(userInfo)
}

vm.$watch(userInfo, onUserInfoChange)

上面代码首先创建了一个新的 Vue 实例对象 vm,包含两个数据字段:name、age。对于这两个字段,Vue 会分别创建对应的 dep 对象,用于记录依赖该数据的 watcher。

然后定义了一个求值函数 userInfo,注意,这个函数会在对应的 Vue 示例上下文中执行,也就是说,执行时的 this 对应的就是 vm。

回调函数 onUserInfoChange 只是打印出新的 watcher 得到的新的值,由 userInfo 执行后生成。

通过 vm.$watch(userInfo, onUserInfoChange),将 vm、getter、cb 集成在一起创建了新的 watcher。创建成功后,watcher 在内部已经记录了依赖关系,watcher.deps 中记录了 vm 的 name、age 对应的 dep 对象(因为 userInfo 中使用了这两个数据)。

接下来,我们修改数据:

vm.name = 'tang'

执行后,控制台会输出:

tang - 18

同样,如果修改 age 的值,也会最终触发 onUserInfoChange 打印出新的结果。

用个简单的图来整理下上面的关系:

vm.name -- dep1
vm.age  -- dep2
watcher.deps --> [dep1, dep2]

修改 vm.name 后,dep1 通知相关的 watcher,然后 watcher 执行 getter,得到新的 value,再将新的 value 传给 cb:

vm.name -> dep1 -> watcher -> getter -> value -> cb

可能你也注意到了,上面例子中的 userInfo,貌似就是计算属性的作用嘛:

var vm = new Vue({
  data: {
    name: 'luobo',
    age: 18
  },
  computed: {
    userInfo() {
      return this.name + ' - ' + this.age
    }
  }
})

其实,计算属性在内部也是基于 watcher 实现的,每个计算属性对应一个 watcher,其 getter 也就是计算属性的声明函数。
不过,计算属性对应的 watcher 与直接通过 vm.$watch() 创建的 watcher 略有不同,毕竟如果没有地方使用到这个计算属性,数据改变时都重新进行计算会有点浪费,这个在本文后面会讲到。

上面描述了 data、dep、watcher 的关系,但是问题来了,这种依赖关系是如何建立的呢?数据改变后,又是如何通知 watcher 的呢?

接下来我们深入 Vue 源码,搞清楚这两个问题。

建立依赖关系

Vue 源码版本 [2.5.13,文中摘录的部分代码为便于分析进行了简化或改写。

响应式的核心逻辑,都在 Vue 项目的 “vue/src/core/observer” 目录下面。

我们还是先顺着前面示例代码来捋一遍,首先是 Vue 实例化过程:

var vm = new Vue(/* ... */)

跟将传入的 data 进行响应式初始化相关的代码,在 “vue/src/core/instance/state.js” 文件中:

// new Vue() -> ... -> initState() -> initData()
observe(data)

函数 observe() 的目的是让传入的整个对象成为响应式的,它会遍历对象的所有属性,然后执行:

// observe() -> new Observer() -> observer.walk()
defineReactive(obj, key, value)

defineReactive() 就是用于定义响应式数据的核心函数。它主要做的事情包括:

  • 新建一个 dep 对象,与当前数据对应
  • 通过 Object.defineProperty() 重新定义对象属性,配置属性的 set、get,从而数据被获取、设置时可以执行 Vue 的代码

OK,先到这里,关于 Vue 实例化告一段落。

需要要注意的是,传入 Vue 的 data 的所有属性,会被代理到新创建的 Vue 实例对象上,这样通过 vm.name 进行操作的其实就是 data.name,这也是借助 Object.defineProperty() 实现的。

再来看 watcher 的创建过程:

vm.$watch(userInfo, onUserInfoChange)

上述代码执行后,会调用:

// Vue.prototype.$watch()
new Watcher(vm, expOrFn, cb, options)

也就是:

new Watcher(vm, userInfo, onUserInfoChange, {/* 略 */})

在 watcher 对象创建过程中,除了记录 vm、getter、cb 以及初始化各种属性外,最重要的就是调用了传入的 getter 函数:

// new Watcher() -> watcher.get()
value = this.getter.call(vm, vm)

在 getter 函数的执行过程中,获取读取需要的数据,于是触发了前面通过 defineReactive() 配置的 get 方法:

if (Dep.target) {
  dep.depend()
}

这是做什么呢?

回到 watcher.get() 方法,在执行 getter 函数的前后,分别有如下代码:

pushTarget(this)
// ... 
value = this.getter.call(vm, vm)
// ...
popTarget()

pushTarget() 将当前 watcher 设置为 Dep.target,这样在执行到 vm.name 进一步执行对应的 get 方法时,Dep.target 的值就是这里的 watcher,然后通过 dep.depend() 就建立了依赖关系。

dep.depend() 执行的逻辑就比较好推测了,将 watcher(通过 Dep.target 引用到)记录到 dep.subs 中,将 dep 记录到 watcher.deps 中 —— 依赖关系建立了!

然后来看建立的依赖关系是如何使用的。

数据变更同步

继续前面的例子,执行如下代码时:

vm.name = 'tang'

会触发通过 defineReactive() 配置的 set 方法,如果数据改变,那么:

// defineReactive() -> set()
dep.notify()

通过 dep 对象来通知所有的依赖方法,于是 dep 遍历内部的 subs 执行:

// dep.notify()
watcher.update()

这样 watcher 就被通知到了,知道了数据改变,从而继续后续的处理。这里先不展开。

到这里,基本就搞清楚响应式的基本机制了,整理一下:

  • 通过 Object.defineProperty() 替换配置对象属性的 set、get 方法,实现“拦截”
  • watcher 在执行 getter 函数时触发数据的 get 方法,从而建立依赖关系
  • 写入数据时触发 set 方法,从而借助 dep 发布通知,进而 watcher 进行更新

这样再看 Vue 官方的图就比较好理解了:

image

图片来源:https://cn.vuejs.org/v2/guide/reactivity.html
上图中左侧是以组件渲染(render)作为 getter 函数来演示响应式过程的,这其实就是 RenderWatcher 这种特殊类型 watcher 的作用机制,后面还会再讲。

计算属性

本文前面提到过计算属性,在 Vue 中也是作为 watcher 进行处理的。计算属性(ComputedWatcher)特殊的地方在于,它其实没有 cb(空函数),只有 getter,并且它的值只在被使用时才计算并缓存。

什么意思呢?

首先,ComputedWatcher 在创建时,不会立即执行 getter(lazy 选项值为 false),这样一开始 ComputedWatcher 并没有和使用到的数据建立依赖关系。

计算属性在被“get”时,首先执行预先定义的 ComputedGetter 函数,这里有一段特殊逻辑:

function computedGetter () {
  if (watcher.dirty) {
    watcher.evaluate()
  }
  if (Dep.target) {
    watcher.depend()
  }
  return watcher.value
}

首先判断 watcher 是不是 dirty 状态,什么意思呢?

计算属性对应的 watcher 初始创建的时候,并没有执行 getter,这个时候就会设置 dirty 为 true,这样当前获取计算属性的值的时候,会执行 getter 得到 value,然后标记 dirty 为 false。这样后续再获取计算属性的值,不需要再计算(执行 getter),直接就能返回缓存的 value。

另外,计算属性的 watcher 在执行 watcher.evaluate() 是,进一步调用 watcher.get(),从而进行依赖收集。而依赖的数据在改变后,会通知计算属性的 watcher,但是 watcher 只是标记自身为 dirty,而不计算。这样的好处是可以减小开销,只在有地方需要计算属性的值时才执行计算。

如果依赖的数据发生变更,计算属性只是标记 dirty 为 true,会不会有问题呢?

解决这个问题的是上面代码的这一部分:

if (Dep.target) {
  watcher.depend()
}

也就是说,如果当前有在收集依赖的 watcher,那么当前计算属性的 watcher 会间接地通过 watcher.depend() 将依赖关系“继承”给这个 watcher(watcher.depend() 内部是对每个 watcher.deps 记录的 dep 执行 dep.depend() 从而让依赖数据与当前的 watcher 建立依赖关系)。

所以,依赖数据改变,依赖计算属性的 watcher 会直接得到通知,再来获取计算属性的值的时候,计算属性才进行计算求值。

所以,依赖计算属性的 watcher 可以视为依赖 watcher 的 watcher。这样的 watcher 在 Vue 中最常见不过,那就是 RenderWatcher。

RenderWatcher 及异步更新

相信读过前文,你应该对 Vue 响应式原理有基本的认识。那么 Vue 是如何将其运用到视图更新中的呢?答案就是这里要讲的 RenderWatcher。

RenderWatcher 首先是 watcher,只不过和计算属性对应的 ComputedWatcher 类似,它也有些特殊的行为。

RenderWatcher 的创建,在函数 mountComponent 中:

// Vue.prototype.$mount() -> mountComponent()
let updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

核心代码就在这里了。这个 watcher 就是 Vue 实例对象唯一的 RenderWatcher,在 watcher 构造函数中,会记录到 vm._watcher 上(普通 watcher 只会记录到 vm._watchers 数组中)。

这个 watcher 也会在创建的最后执行 watcher.get(),也就是执行 getter 收集依赖的过程。而在这里,getter 就是 updateComponent,也就是说,执行了渲染+更新 DOM!并且,这个过程中使用到的数据也被收集了依赖关系。

那么,理所当然地,在 render() 中使用到数据,发生改变,自然会通知到 RenderWatcher,从而最终更新视图!

不过,这里会有个疑问:如果进行多次数据修改,那么岂不是要频繁执行 DOM 更新?

这里就涉及到 RenderWatcher 的特殊功能了:异步更新

结合前面内容,我们知道数据更新后,依赖该数据的 watcher 会执行 watcher.update(),这个在前文中没有展开,现在我们来看下这个方法:

if (this.lazy) {
  this.dirty = true
} else if (this.sync) {
  this.run()
} else {
  queueWatcher(this)
}

第一种情况,lazy 为 true,也就是计算属性,上一节已经提到过,只是标记 dirty 为 true,并不立即计算,不再赘述。sync 为 true 的情况,这里也不管,不过看起来也很简单,就是立即执行计算嘛。

最后的情况,就是这里 RenderWatcher 的场景,并不立即执行,也不是像计算属性那样标记为 dirty 就完了,而是放到了一个队列中。

这个队列是干什么的呢?

相关代码在 observer/scheduler.js中,简单来说,就是实现了异步更新。

理解其实现,首先要对浏览器的事件循环(Event Loop)机制有一定了解。如果你对事件循环机制不是很了解,可以看下面这篇文章:

JavaScript 运行机制详解:再谈Event Loop - 阮一峰

事件循环机制其实有点复杂,但只有理解事件循环,才能对这里 Vue 异步更新的方案有深入的认识。

基于事件循环机制,RenderWatcher 将其 getter,也就是 updateComponent 函数异步执行,并且,多次触发
RenderWatcher 的 update(),最终也只会执行一次 updateComponent,这样也就解决了性能问题。

不过,随之而来的新问题是,修改完数据,不能直接反应到 DOM 上,而是要等异步更新执行过后才可以,这也是为什么 Vue 提供了 nextTick() 接口,并且要求开发者将对 DOM 的操作放到 nextTick() 回调中执行的原因。

Vuex、Vue-Router

再来看 Vue 套装中的 Vuex、Vue-Router,它们也是基于 Vue 的响应式机制实现功能。

先来看 Vuex,代码版本 v3.0.1

Vuex

在应用了 Vuex 的应用中,所有组件都可以通过 this.$store 来引用到全局的 store,并且在使用了 store 的数据后,还能在数据改变后得到同步,这其实就是响应式的应用了。

this.$store = options.store || options.parent.$store

这样在每个组件的 beforeCreate 时,会执行 $store 属性的初始化。

而 store 数据的响应式处理,则是通过实例化一个 Vue 对象实现:

// new Store() -> resetStoreVM()
store._vm = new Vue({
  data: {
    $$state: state
  },
  computed // 对应 store.getters
})

结合前文的介绍,这里就很好理解了。因为 state 以及处理为响应式数据,而 getters 也创建为计算属性,所以对这些数据的使用,就建立依赖关系,从而可以响应数据改变了。

Vue-Router

Vue-Router 中,比较重要的数据是 $route,即当前的页面路由数据,在路由改变的时候,需要替换展示不同组件(router-view 组件实现)。

vm.$route 实践上是来自 Vue.prototype,但其对应的值,最终对应到的是 router.history.current

结合前面的分析,这里的 history.current 肯定得是响应式数据,所以,来找下对其进行初始化的地方,其实是在全局 mixin 的 beforeCreate 这里:

// beforeCreate
Vue.util.defineReactive(this, '_route', this._router.history.current)

这样 this._route 就是响应式的了,那么如果页面路由改变,又是如何修改这里的 _route 的呢?

答案在 VueRouter 的 init() 这里:

history.listen(route => {
  this.apps.forEach((app) => {
    app._route = route
  })
})

一个 router 对象可能和多个 vue 实例对象(这里叫作 app)关联,每次路由改变会通知所有的实例对象。

再来看使用 vm.$route 的地方,也就是 VueRouter 的两个组件:

  • <router-link>
  • <router-view>

两个组件都是在 render() 中,与 $route 建立了依赖关系,根据 route 的值进行渲染。这里具体过程就不展开了。

实践:watch-it

了解了以上这么多,也想自己试试,把 Vue 响应式相关的核心逻辑剥离出来,做一个单纯的数据响应式的库。由于只关注数据,所以在剥离过程中,将与 Vue 组件/实例对象相关的部分都移除了,包括 watcher.vm 也不再需要,这样 watcher.getter 计算时不再指定上下文对象。

感兴趣,想直接看代码的,可以前往watch-it

watch-it 只包括数据响应式相关的功能,暴露了4个接口:

  • defineReactive(obj, key, val):为对象配置一个响应式数据属性
  • observe(obj):将一个数据对象配置为响应式,内部对所有的属性执行 defineReactive
  • defineComputed(target, key, userDef):为对象配置一个计算属性,内部创建了 watcher
  • watch(fn, cb, options):监听求值函数中数据改变,变化时调用 cb,内部创建了 watcher

来看一个使用示例:

const { observe, watch } = require('@luobotang/watch-it')

const data = {
  name: 'luobo',
  age: 18
}

observe(data)

const userInfo = function() {
  return data.name + ' - ' + data.age
}

watch(userInfo, (value) => console.log(value))

这样,当数据修改时,通过会打印出新的 userInfo 的值。

总结

OK,以上就是有关 Vue 响应式原理的全部了,当然,只是我的理解和实践。

在梳理和写下这些内容的过程中,我收获很多,也希望内容能够对你有所帮助。

水平有限,错漏难免,欢迎指出。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349