vue响应式原理
vue框架 中最核心的就是 vue的响应式 ,通过对vue
中data
数据的变更实现页面效果的重新渲染。但在实际开发中经常会有人发现明明更改了对应数据的值,但是vue
却没有重新渲染。明明说好了是响应式的,但为什么有的数据可以通过响应式实现,而有的只能通过vue.set
方法实现
官方文档的流程图
在此基础上,我们根据源码更细一步划分出watcher和data之间的部分,即Dep
和observer
。
总体架构简介
在Vue源码内,Dep类作为依赖,Watcher类则用来收集依赖和通知依赖重新求值。对于在实例化时传入的数据,使用工厂函数defineReactive
令其响应式。而在实例后再通过Vue.set/vm.$set
添加的响应式数据,则需要借助Observer类来使其成为响应式数据,最后也是通过defineReactive
实现响应式。
对于每个响应式数据,会有两个Dep实例,第一个是在defineReactive
中的闭包遍历,用途显而易见。而第二个Dep则在响应式数组的__ob__
属性值中,这个值是Observer实例,其实例属性dep是Dep实例,在执行Vue.set/vm.$set
添加响应式数据后,会通知依赖更新。
在讲defineReactive
之前,先讲一下这些辅助类的实现和用处。
Dep
我们都知道,Vue响应式的实现,会在getter中收集响应式数据的依赖,在setter中通知依赖数据更新,重新计算数据然后来更新视图。在Vue内部,使用Dep实例表示依赖,让我们看一下Dep类是怎么定义的。
Dep有两个实例属性,一个静态属性。静态属性target
是Watcher
实例,功能是重新求值和通知视图更新,下文我们会讲到。实例属性id
是Dep实例的唯一标识,无需多说;属性subs
是Watcher实例数组,用于收集Watcher实例,当依赖更新时,这些Watcher实例就会重新求值。
static target: ?Watcher;
id: number;
subs: Array;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}```
方法`addSub`用于添加`Watcher`实例到`subs`中,方法`removeSub`用于从`subs`移除`Watcher`实例。
方法`depond`会在收集依赖的时候调用,实际上执行了Watcher的实例方法`addDep`,在`addDep`内除了调用dep实例的`addSup`方法外,还做了避免重复收集Watcher实例的工作。这个方法会在Vue为响应式数据设置的自定义getter中执行。
`notify`方法则遍历`subs`,执行Watcher实例方法update来重新求值。这个方法会在Vue为响应式数据设置的自定义setter中执行。
有人可能有疑问,`target`是静态属性,那不是每个实例的target都一样的?实际上,重新求值的操作在Watcher实例方法`get`内实现。在get方法内,会先调用`pushTarget`来更新`Dep.target`,使其指向当前Watcher实例,之前的``Dep.target`会被保存`targetStack`末尾(相当于入栈操作),完成操作后会执行`popTarget`函数,从`targetStack`取出最后一个元素来还原`Dep.target`(相当于出栈操作)。
```Dep.target = null
const targetStack = []
export function pushTarget (_target: ?Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}
export function popTarget () {
Dep.target = targetStack.pop()
}```
## Watcher
当依赖更新时,Watcher类会重新求值,并可能触发重渲染。
```constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
// 与渲染相关的watcher
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
// options
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.computed = !!options.computed
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.computed = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.computed // for computed watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = function () {}
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
if (this.computed) {
this.value = undefined
this.dep = new Dep()
} else {
this.value = this.get()
}
}```
构造函数接受五个参数,`vm`是挂载的Component实例;`expOrFn`是观察的属性,当是字符串时表示属性名,是函数时会被当成属性的get方法;`cb`是属性更新后执行的回调函数;`options`是配置项;`isRenderWatcher`表示当前实例是否与渲染相关。
在构造函数内,先将实例属性`vm`指向传入的Component实例vm,如果当前Watcher实例与渲染相关,会将其保存在`vm._watcher`中。接着将当前实例添加到`vm._watchers`中,同时根据传入的配置项`options`初始化实例属性。实例属性`getter`是监听属性的getter函数,如果`expOrFn`是函数,直接赋值,否则会调用`parsePath`来获取属性的getter。
`parsePath`内部会先使用正则来判断属性名,如果有除数字、字母、`.`和`$`以外的字符时视为非法属性名,直接返回,所以属性只能是以`.`分隔的属性。如果属性名合法,则`parsePath`返回一个闭包函数,调用时会传入`vm`,即`obj`是`vm`的引用,这个闭包函数最终的目的是从vm实例里获取属性。
```const bailRE = /[^\w.$]/
export function parsePath (path: string): any {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}```
初始化完成之后,如果不是计算属性相关的Watcher实例,会调用实例方法`get`求值。
### get方法
执行getter方法求值,完成依赖收集的过程。
方法开始时,执行`pushTarget(this)`,将`Dep.target`指向当前Watcher实例。然后执行`getter`收集依赖,最后将`Dep.target`复原,并执行`cleanDeps`遍历`deps`。在每次求值之后,都会调用`cleanupDeps`方法重置依赖,具体如何重置,稍后再讲。
实际上,`Dep.target`指向的实例是即将要收集的目标。
`getter`的执行,除了会获取值外,还会触发在`defineReactive`中为属性设置的getter,完成依赖的收集。
``` get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}```
### addDep
`addDep`的功能是将当前Watcher实例添加到传入的Dep实例属性subs数组里去。
`addDep`接受一个Dep实例作为参数,如果 `dep.id` 没有在集合 `newDepIds` 之中,则添加。如果不在集合 `depIds` 中,则将当前实例添加到 `dep.subs` 中。 简单来说,这里的操作会避免重复收集依赖,这也是不直接调用`dep.addSub(Dep.target)`的原因。
``` addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}```
从这里可以看出来Dep实例和Watcher实例会相互引用。Dep实例将Watcher实例保存在实例属性`subs`中,在响应式属性调用`setter`时,执行`notify`方法,通知Watcher实例重新求值。
Watcher实例将Dep实例保存在集合`newDeps`,目的是避免重复收集依赖,同时会执行Dep实例方法`addDep`,将当前Watcher实例添加到Dep实例属性`subs`中。
### cleanupDeps
对于Watcher来说,每次求值的依赖并不一定与上一次的相同,在每次执行`get`之后,都会调用`cleanupDeps`来重置收集的依赖。Watcher有四个实例属性用于记录依赖,分别是`newDeps/newDepIds`与`deps/depIds`。`newDeps`与`deps`是保存依赖的数组,`newDepIds`与`depIds`是保存依赖Id的集合。记录上一次求值依赖的属性是`deps/depIds`,记录下一次求值依赖的属性是`newDeps/newDepIds`(执行`cleanupDeps`时已经调用过`getter`重新求值了,所以说是上一次求值,下一次指的是下一次调用`get`的时候)。
``` cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
// 交换depIds和newDepIds
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
// 交换deps和newDeps
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}```
首先遍历`deps`,如果此次求值的依赖在下一次求值中并不存在,则需要调用`removeSub`方法,从`subs`数组中移除当前Watcher实例。
接着交换`newDeps/newDepIds`与`deps/depIds`,并清空交换后的`newDeps/newDepIds`。
### update
Dep类的`notify`方法用于通知观察者重新求值,该方法内部实际是遍历`subs`数组,执行Watcher的`update`方法。
update 方法定义如下。当实例与计算属性相关时,xxx。如果不是计算属性相关时,判断是否需要同步触发,同步触发时调用`run`,否则执行`queueWatcher(this)`,交由调度模块统一调度。
``` update () {
if (this.computed) {
if (this.dep.subs.length === 0) {
this.dirty = true
} else {
this.getAndInvoke(() => {
this.dep.notify()
})
}
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}```
总的来说,vue的数据响应式实现主要分成2个部分:
1. 把数据转化为getter和setter
2. 建立watcher并收集依赖
第一部分是上图中`data`、`observer`、`dep`之间联系的建立过程,第二部分是`watcher`、`dep`的关系建立
### 响应式原理整合
在生成vue实例时,为对传入的data进行遍历,使用`Object.defineProperty`把这些属性转为`getter/setter`.
`Object.defineProperty` 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
每个vue实例都有一个watcher实例,它会在实例渲染时记录这些属性,并在setter触发时重新渲染。
`Vue 无法检测到对象属性的添加或删除`
`Vue 不允许动态添加根级别的响应式属性。但是,可以使用 Vue.set(object, propertyName, value)方法向嵌套对象添加响应式属性。`
### 声明响应式属性
由于 Vue 不允许动态添加根级响应式属性,所以你必须在初始化实例前声明所有根级响应式属性,哪怕只是一个空值。
如果你未在 data 选项中声明 message,Vue 将警告你渲染函数正在试图访问不存在的属性。
### 异步更新队列
vue更新dom时是异步执行的
数据变化、更新是在主线程中同步执行的;在侦听到数据变化时,watcher将数据变更存储到异步队列中,当本次数据变化,即主线成任务执行完毕,异步队列中的任务才会被执行(已去重)。
如果你在js中更新数据后立即去操作DOM,这时候DOM还未更新;vue提供了nextTick接口来处理这样的情况,它的参数是一个回调函数,会在本次DOM更新完成后被调用。
使用方法:
* 1.在组件内使用 vm.$nextTick() 实例方法特别方便,因为它不需要全局 Vue,并且回调函数中的 this 将自动绑定到当前的 Vue 实例上:
```Vue.component('example', {
template: '{{ message }}',
data: function () {
return {
message: '未更新'
}
},
methods: {
updateMessage: function () {
this.message = '已更新'
console.log(this.$el.textContent) // => '未更新'
this.$nextTick(function () {
console.log(this.$el.textContent) // => '已更新'
})
}
}
})```
* 2.因为 `$nextTick()` 返回一个 `Promise` 对象,所以你可以使用新的 [ES2016 async/await](https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FJavaScript%2FReference%2FStatements%2Fasync_function) 语法完成相同的事情:
```methods: {
updateMessage: async function () {
this.message = '已更新'
console.log(this.$el.textContent) // => '未更新'
await this.$nextTick()
console.log(this.$el.textContent) // => '已更新'
}
}```