Vue 数据响应式原理

Vue.js 的核心包括一套“响应式系统”。“响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。

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

{{ name }}

创建一个 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:

1.id - number

2.subs - [Watcher]

再来看 watcher。

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

1.vm - VueComponent

2.deps - [Dep]

3.getter - function

4.value - *

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

1.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 源码版本 v2.5.13,文中摘录的部分代码为便于分析进行了简化或改写。

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

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

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

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

observer/state.js#L149

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

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

observer/index.js#L64

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

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

1.新建一个 dep 对象,与当前数据对应

2.通过 Object.defineProperty() 重新定义对象属性,配置属性的 set、get,从而数据被获取、设置时可以执行 Vue 的代码

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

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

再来看 watcher 的创建过程:

vm.$watch(userInfo, onUserInfoChange)

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

instance/state.js#L346

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

也就是:

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

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

observer/watcher.js#L103

// 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 就被通知到了,知道了数据改变,从而继续后续的处理。这里先不展开。

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

1.通过 Object.defineProperty() 替换配置对象属性的 set、get 方法,实现“拦截”

2.watcher 在执行 getter 函数时触发数据的 get 方法,从而建立依赖关系

3.写入数据时触发 set 方法,从而借助 dep 发布通知,进而 watcher 进行更新

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

图片来源:https://vuejs.org/v2/guide/reactivity.html

上图中左侧是以组件渲染(render)作为 getter 函数来演示响应式过程的,这其实就是 RenderWatcher 这种特殊类型 watcher 的作用机制,后面还会再讲。

计算属性

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

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

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

instance/state.js#L238

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(),这个在前文中没有展开,现在我们来看下这个方法:

observer/watcher.js#L161

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 运行机制详解:

事件循环机制其实有点复杂,但只有理解事件循环,才能对这里 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 的实现,这个其实是通过全局 mixin 实现,代码在:

src/mixin.js#L26

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

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

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

src/store.js#L251

// 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 这里:

v2.8.1/src/install.js#L27

// 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 的两个组件:

两个组件都是在 render() 中,与 $route 建立了依赖关系,根据 route 的值进行渲染。这里具体过程就不展开了,感兴趣可以看下相关源码(v2.8.1/src/components),原理方面在 RenderWatcher 一节已经介绍过。

实践:watch-it

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

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

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

1.defineReactive(obj, key, val):为对象配置一个响应式数据属性

2.observe(obj):将一个数据对象配置为响应式,内部对所有的属性执行 defineReactive

3.defineComputed(target, key, userDef):为对象配置一个计算属性,内部创建了 watcher

4.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 的值。

Vue 数据响应式原理的更多相关文章

  1. 一探 Vue 数据响应式原理

    一探 Vue 数据响应式原理 本文写于 2020 年 8 月 5 日 相信在很多新人第一次使用 Vue 这种框架的时候,就会被其修改数据便自动更新视图的操作所震撼. Vue 的文档中也这么写道: Vu ...

  2. vue.js响应式原理解析与实现

    vue.js响应式原理解析与实现 从很久之前就已经接触过了angularjs了,当时就已经了解到,angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很 ...

  3. vue深入响应式原理

    vue深入响应式原理 深入响应式原理 — Vue.jshttps://cn.vuejs.org/v2/guide/reactivity.html 注意:这里说的响应式不是bootsharp那种前端UI ...

  4. 深入解析vue.js响应式原理与实现

    vue.js响应式原理解析与实现.angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新并且重新渲染页面.vue.js ...

  5. Vue的响应式原理

    Vue的响应式原理 一.响应式的底层实现 1.Vue与MVVM Vue是一个 MVVM框架,其各层的对应关系如下 View层:在Vue中是绑定dom对象的HTML ViewModel层:在Vue中是实 ...

  6. Vue.js响应式原理

      写在前面 因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出. 文章的原地址:answershuto/learnV ...

  7. Vue的响应式原理---(v-model中的双向绑定原理)

    Vue响应式原理 不要认为数据发生改变,界面跟着更新是理所当然. 具体代码实现:https://gitee.com/ahaMOMO/Vue-Responsive-Principle.git 看下图: ...

  8. vue系列---响应式原理实现及Observer源码解析(一)

    _ 阅读目录 一. 什么是响应式? 二:如何侦测数据的变化? 2.1 Object.defineProperty() 侦测对象属性值变化 2.2 如何侦测数组的索引值的变化 2.3 如何监听数组内容的 ...

  9. Vue.js 响应式原理

    1. Vue2.x 基于 Object.defineProperty 方法实现响应式(Vue3 将采用 Proxy) Object.defineProperty(obj, prop, descript ...

随机推荐

  1. OpenCV 学习笔记 05 人脸检测和识别

    本节将介绍 Haar 级联分类器,通过对比分析相邻图像区域来判断给定图像或子图像与已知对象是否匹配. 本章将考虑如何将多个  Haar 级联分类器构成一个层次结构,即一个分类器能识别整体区域(如人脸) ...

  2. 记一次数据库参数compatible降级[转]

    转:http://dbzone.iteye.com/blog/1042455 众所周知,Oracle参数compatible 主要用于启用Oracle针对某一版本的新特性.但此参数设置时,只能往上调, ...

  3. 深入理解Fsync

    1 介绍 数据库系统从诞生那天开始,就面对一个很棘手的问题,fsync的性能问题.组提交(group commit)就是为了解决fsync的问题.最近,遇到一个业务反映MySQL创建分区表很慢,仔细分 ...

  4. 使用 .toLocaleString() 轻松实现多国语言价格数字格式化

    用代码对数字进行格式化,显然不是逢三位加逗号这么简单.比如印度在数字分位符号上的处理,就堪称业界奇葩: 印度的数字读法用“拉克”(十万)和“克若尔”(千万),数字标法用不对称的数位分离,即小数点左侧首 ...

  5. Error:java: invalid source release 无效的源发行版: 8

    原因:这是由于jdk的版本与项目的要求不一致造成的,如果是maven项目,首先查看一下pom.xml,以我的项目为例: 从其中可以看出要求的编译插件为1.8版本,而我本机上安装的jdk为1.7版本,因 ...

  6. 利用堆实现堆排序&优先队列

    数据结构之(二叉)堆一文在末尾提到"利用堆能够实现:堆排序.优先队列.".本文代码实现之. 1.堆排序 如果要实现非递减排序.则须要用要大顶堆. 此处设计到三个大顶堆的操作:(1) ...

  7. HDOJ 1393 Weird Clock(明确题意就简单了)

    Problem Description A weird clock marked from 0 to 59 has only a minute hand. It won't move until a ...

  8. 为什么watch机制不是银弹?

    几乎所有构建系统都选择使用watch机制来解决开发过程中需要反复生成构建后文件的问题,但在watch机制下,长期以来我们必须忍受修改完代码,保存完代码必须喝口茶才能刷新看看效果的问题.在这里我们尝试探 ...

  9. 【Git】简单使用

    [Git & Github] 首先不能混淆两者的概念.git是一个类似于svn的版本管理工具.其可以在本地建立起针对一个项目的众多维度的版本管理体系,提升了开发的效率. 相对的,我们如果想要和 ...

  10. js中如何把字符串转化为对象、数组示例代码

    很明显是一个对象,但如何把文本转为对象呢.使用eval();注意一定要加括号,否则会转换失败 把文本转化为对象 var test='{ colkey: "col", colsinf ...