上一篇 Vue 原理解析(五): 虚拟Dom到真实Dom的生成过程
vue 之所以能数据驱动视图发生变更的关键就是:依赖它的响应式系统了。 响应式系统如果根据数据类型区分: 对象和数组两者的实现会有所不同。 解释响应式原理,需要从整体流程出发, 不在vue 组件化的整体流程中找到响应式原理的位置,对深刻理解响应式原理不太好。 接下来我们从整体流程出发, 试着站在巨人的肩膀上分别说明对象和数组的实现原理。
对象的响应式原理
对象响应式数据的创建
- 在数组的初始化阶段, 将对传入的状态进行初始化, 以下以data为例, 会将传入数据包装为响应式的数据。
对象示例:
main.js
new Vue({ // 根组件
render: h => h(App)
})
---------------------------------------------------
app.vue
<template>
<div>{{info.name}}</div> // 只用了info.name属性
</template>
export default { // app组件
data() {
return {
info: {
name: 'cc',
sex: 'man' // **即使是响应式数据,没被使用就不会进行依赖收集**
}
}
}
}
接下来的分析将以上面代码为例, 这种结构其实是一个嵌套组件,只不过根组件一般定义的参数比较少而已,理解这个很重要的。
在组件=new Vue() 后执行vm._init() 初始化过程中, 当执行到initState(vm)时就会对内部使用到的一些状态, 如: props, data, computed, watch, methods 分别进行初始化, 再对data 进行初始化的最后有这么一句:
function initData(vm) { //初始化data
...
observe(data) // info:{name:'cc',sex:'man'}
}
这个observer 就是将用户定义的data变成响应式的数据, 接下来看看它的创建过程:
export function observe(value) {
if(!isObject(value)) { // 不是数组或对象,再见
return
}
return new Observer(value)
}
简单理解这个observer 方法就是Observer 这个类的工厂方法, 所以还是要看下Observer 这个类的定义:
export class Observer {
constructor(value) {
this.value = value
this.walk(value) // 遍历value
}
walk(obj) {
const keys = Object.keys(obj)
for(let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]) // 只传入了两个参数
}
}
}
当执行new Observer 时, 首先将传入的对象挂载到当前this 下, 然后遍历当前对象的每一项, 执行defineReactive 这个方法, 看看它的定义:
export function defineReactive(obj, key, val) {
const dep = new Dep() // 依赖管理器
val = obj[key] // 计算出对应key的值
observe(val) // 递归包装对象的嵌套属性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
... 收集依赖
},
set(newVal) {
... 派发更新
}
})
}
这个方法的作用就是使用Object.defineProperty创建响应式数据。 首先根据传入的obj 和 key 计算出 val 具体的值; 如果val 还是对象, 那就使用observe 方法进行递归创建, 在递归的过程中使用Object.defindeProperty 将对象的每一个属性都变成响应式数据:
...
data() {
return {
info: {
name: 'cc',
sex: 'man'
}
}
}
这段代码就会有三个响应式数据:
info, info.name, info.sex
知识点: Object.defineProperty内的get 方法, 它的作用就是谁访问到当前key 的值就用 defineReactive 内的dep 将它收集起来, 也就是依赖收集的意思。 set 方法的作用就是当前key 的值被赋值了, 就通知dep内收集到的依赖项, key的值发生了变更, 视图请变更吧。
这个时候get 和 set 只是定义了, 并不会触发。 什么是依赖,我们接下来说明,首先看看下图帮大家理清响应式数据的创建过程(这里先摘用网上的一张图):
依赖收集
什么是依赖? 看下之前mountComponent的定义:
function mountComponent(vm, el) {
...
const updateComponent = function() {
vm._update(vm._render())
}
new Watcher(vm, updateComponent, noop, { // 渲染watcher
...
}, true) // true为标志,表示是否是渲染watcher
...
}
首先我们说明下这个Watcher 类, 它类似与之前的VNode 类, 根据传入的参数不同, 可以分别实例化出三种不同的Watcher 实例, 它们分别是用户watcher, 计算watcher 以及渲染watcher:
用户 (user) watcher
- 也就是用户自己定义的, 如:
new Vue({
data {
msg: 'hello Vue!'
}
created() {
this.$watch('msg', cb()) // 定义用户watcher
},
watch: {
msg() {...} // 定义用户watcher
}
})
这里的两种方式内部都是使用Watcher 这个类实例化的, 只是参数不同, 具体实现我们之后章节说明, 这里大家只是知道这个是用户watcher即可。
计算 (computed) watcher
- 顾名思义, 这个是当定义计算属性实例化出来的一种:
new Vue({
data: {
msg: 'hello'
},
computed() {
sayHi() { // 计算watcher
return this.msg + 'vue!'
}
}
})
渲染属性 (render) watcher
- 只是用做视图渲染而定义的Watcher 实例, 再组件实行vm.$mount 的最后会实例化Watcher 类, 这个时候就是以渲染watcher 的格式定义的, 收集的就是当前渲染watcher 的实例, 我们来看下它内部如何定义的:
class Watcher {
constructor(vm, expOrFn, cb, options, isRenderWatcher) {
this.vm = vm
if(isRenderWatcher) { // 是否是渲染watcher
vm._watcher = this // 当前组件下挂载vm._watcher属性
}
vm._watchers.push(this) //vm._watchers是之前初始化initState时定义的[]
this.before = options.before // 渲染watcher特有属性
this.getter = expOrFn // 第二个参数
this.get() // 实例化就会执行this.get()方法
}
get() {
pushTarget(this) // 添加
...
this.getter.call(this.vm, this.vm) // 执行vm._update(vm._render())
...
popTarget() // 移除
}
addDep(dep) {
...
dep.addSub(this) // 将当前watcher收集到dep实例中
}
}
当执行new Watcher 的时候内部会挂载一些属性, 然后执行this.get()这个方法, 首先会执行一个全局的方法pushTarget(this) , 传入当前watcher 的实例, 我们看下这个方法定义的地方:
Dep.target = null
const targetStack = [] // 组件从父到子对应的watcher实例集合
export function pushTarget (_target) { // 添加
if (Dep.target) {
targetStack.push(Dep.target) // 添加到集合内
}
Dep.target = _target // 当前的watcher实例
}
export function popTarget() { // 移除
targetStack.pop() // 移除数组最后一项
Dep.target = targetStack[targetStack.length - 1] // 赋值为数组最后一项
}
首先会定义一个Dep 类的静态属性Dep.target 为 null, 这是一个全局会用到的属性, 保存的是当前组件对应渲染watcher 的实例; targetStack 内存储的是再执行组件化的过程中每个组件对应的渲染watcher实例集合, 使用的是一个先进后出的形式来管理数组的数据, 这里可能有点不太好懂, 稍等再看到最后的流程图后自然就明白了;然后传入的watcher实例赋值给全局属性Dep.target , 再之后的依赖收集过程中就是收集的它。
watcher 的 get 这个方法然后会执行getter 这个方法, 它是new Watcher 时传入的第二个参数, 这个参数就是之前的updateComponent 变量:
function mountComponent(vm, el) {
...
const updateComponent = function() { //第二个参数
vm._update(vm._render())
}
...
}
只要一执行就会执行当前组件实例上的vm._update(vm.render()) 将 render 函数转为VNode, 这个时候如果render 函数内有使用到data 中已经转为了响应式的数据,就会触发get方法进行依赖收集, 补全之前依赖收集的逻辑:
export function defineReactive(obj, key, val) {
const dep = new Dep() // 依赖管理器
val = obj[key] // 计算出对应key的值
observe(val) // 递归的转化对象的嵌套属性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() { // 触发依赖收集
if(Dep.target) { // 之前赋值的当前watcher实例
dep.depend() // 收集起来,放入到上面的dep依赖管理器内
...
}
return val
},
set(newVal) {
... 派发更新
}
})
}
这个时候我们知道watcher 是个什么东西了, 简单理解就是数据和组件之间一个通信工具的封装, 当某个数据被组件读取时, 就将依赖数据的组件使用Dep 这个类给收集起来。
当前例子data 内的属性是只有一个渲染watcher 的, 因为没有被其它组件所使用。 但如果该属性被其它组件使用到,又会将使用它的组件收集起来。 例如作为了props传递给子组件, 再dep的数组内就会存在多个渲染watcher。 我们来看下Dep类这个依赖管理器的定义:
let uid = 0
export default class Dep {
constructor() {
this.id = uid++
this.subs = [] // 对象某个key的依赖集合
}
addSub(sub) { // 添加watcher实例到数组内
this.subs.push(sub)
}
depend() {
if(Dep.target) { // 已经被赋值为了watcher的实例
Dep.target.addDep(this) // 执行watcher的addDep方法
}
}
}
----------------------------------------------------------
class Watcher{
...
addDep(dep) { // 将当前watcher实例添加到dep内
...
dep.addSub(this) // 执行dep的addSub方法
}
}
这个Dep 类的作用就是管理属性对应watcher, 如添加/删除/通知。 至此, 依赖收集的过程就算是完成了, 还是以一张图片加深对过程的理解:
派发更新
如果只是收集依赖, 那其实是没有任何意义的, 将收集到的依赖在数据发生变化时通知并引起视图变化, 这样才有意义。 现在我们对数据重新赋值:
app.vue
export default { // app组件
...
methods: {
changeInfo() {
this.info.name = 'ww';
}
}
}
这个时候就会触发创建响应式数据时的set方法了, 我们再补全那里的逻辑:
export function defineReactive(obj, key, val) {
const dep = new Dep() // 依赖管理器
val = obj[key] // 计算出对应key的值
observe(val) // 递归转化对象的嵌套属性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
... 依赖收集
},
set(newVal) { // 派发更新
if(newVal === val) { // 相同
return
}
val = newVal // 赋值
observer(newVal) // 如果新值是对象也递归包装
dep.notify() // 通知更新
}
})
}
当赋值触发set 时, 首先会检测新值和旧值, 不能相同; 然后将新值赋值给旧值; 如果新值是对象则将它变成响应式的; 最后让对应属性的依赖管理器使用dep.notify发出更新视图的通知。来看下它的实现:
let uid = 0
class Dep{
constructor() {
this.id = uid++
this.subs = []
}
notify() { // 通知
const subs = this.subs.slice()
for(let i = 0, i < subs.length; i++) {
subs[i].update() // 挨个触发watcher的update方法
}
}
}
这里做的事情只有一件, 将收集起来的watcher 挨个遍历触发update方法:
class Watcher{
...
update() {
queueWatcher(this)
}
}
---------------------------------------------------------
const queue = []
let has = {}
function queueWatcher(watcher) {
const id = watcher.id
if(has[id] == null) { // 如果某个watcher没有被推入队列
...
has[id] = true // 已经推入
queue.push(watcher) // 推入到队列
}
...
nextTick(flushSchedulerQueue) // 下一个tick更新
}
执行update 方法时将当前watcher 实例传入到定义的queueWatcher 方法内, 这个方法的作用是把将要执行更新的watcher收集到一个队列queue之内,保证如果同一个watcher 内触发了多次更新, 只会更新一次对应的watcher ,我们举两个小实例:
export default {
data() {
return { // 都被模板引用了
num: 0,
name: 'cc',
sex: 'man'
}
},
methods: {
changeNum() { // 赋值100次
for(let i = 0; i < 100; i++) {
this.num++
}
},
changeInfo() { // 一次赋值多个属性的值
this.name = 'ww'
this.sex = 'woman'
}
}
}
这里的三个响应式属性他们收集都是同一个渲染watcher。 所以当赋值100次的情况出现时, 再将当前的渲染watcher 推入到的队列之后, 之后赋值触发的set队列内并不会添加任何渲染watcher; 当同时赋值多个属性时也是, 因为他们收集的都是同一个渲染watcher , 所以推入到队列一次之后就不会添加了。
知识点: vue 还是很聪明的, 通过上面实例大家应该看出来, 派发更新通知的粒度是组件级别, 至于组件内是哪个属性赋值了,派发更新并不关心, 而且怎么高效更新这个视图, 那是之后diff对比做的事情。
队列有了, 执行nextTick(flushSchedulerQueue) 再下一次tick时更新它, 这里的nextTick 就是我们经常使用的 this.$nextTick 方法的原始方法, 它们作用一致,实现原理之后章节说明。 看下参数flushSchedulerQueue是啥?
let index = 0
function flushSchedulerQueue() {
let watcher, id
queue.sort((a, b) => a.id - b.id) // watcher 排序
for(index = 0; index < queue.length; index++) { // 遍历队列
watcher = queue[index]
if(watcher.before) { // 渲染watcher独有属性
watcher.before() // 触发 beforeUpdate 钩子
}
id = watcher.id
has[id] = null
watcher.run() // 真正的更新方法
...
}
}
原来是个函数, 再nextTick方法的内部会执行第一个参数。 首先会将queue这个队列进行一次排序,依次是每次new Watcher 生成的 id, 以从小到大的顺序。 当前示例只是做渲染, 而且队列内只存在了一个渲染watcher, 所以是不存在顺序的。 但是如果有定义user watcher 和 computed watcher 加上 render watcher 后 , 它们之间就会存在一个执行顺序的问题了。
知识点: watcher 的执行顺序是先父后子, 然后是从computed watcher 到user watcher 最后 render watcher , 这从它们的初始化顺序就能看出来。
然后就是遍历这个队列, 因为是渲染watcher, 所有是有before 属性的, 执行传入的before方法触发beforeUpdate 钩子。 最后执行watcher.run()方法, 执行真正的派发更新方法。 我们看下run干了啥:
class Watcher {
...
run () {
if (this.active) {
this.getAndInvoke(this.cb) // 有一种要抓狂的感觉
}
}
getAndInvoke(cb) { // 渲染watcher的cb为noop空函数
const value = this.get()
... 后面是用户watcher逻辑
}
}
执行run 就是执行getAndInvoke方法, 因为是渲染watcher, 参数cb是noop空函数。 看了这么多, 其实… 就是重新执行一次 this.get()方法, 让 vm._update(vm._render())在走一遍而已。 然后生成新旧VNode , 最后进行diff比对以更新视图。
最后说下vue 基于Object.defineProperty响应式系统的一些不足。 比如:只能监听到数据的变化, 所以有时data中要是定义一堆的初始值, 因为加入了响应式系统后才能被感知到; 还有就是常规JavaScript操作对象的方式, 并不能监听到增加以及删除。如:
export default {
data() {
return {
info: {
name: 'cc'
}
}
},
methods: {
addInfo() { // 增加属性
this.info.sex = 'man'
},
delInfo() { // 删除属性
delete info.name
}
}
}
数据是被赋值了, 但是视图并不会发生变更。 vue为了解决这个问题,提供了两个API: $set 和 $delete, 它们又是怎么办到的? 原理我们之后章节分享。
最后我们以一个问题结束本章内容:
- 当前组件模板中用到的变量一定要定义在data里么?
解答:
- data 中的变量都会被代理到this下, 所以我们也可以在this下挂载属性, 只要不重名即可。 而且定义在data中的变量在vue的内部会将它包装成响应式的数据, 让它拥有变更即可驱动视图变化的能力。 但是如果这个数据不需要驱动视图, 定义在created 或者 mounted 钩子内也是可以的, 因为不会执行响应式的包装方法,对性能也是一种提升。
下一篇: Vue原理解析(七): 理解响应式原理(下)-数组