vue響應(yīng)式原理
vue框架 中最核心的就是 vue的響應(yīng)式 ,通過對vue中data數(shù)據(jù)的變更實現(xiàn)頁面效果的重新渲染。但在實際開發(fā)中經(jīng)常會有人發(fā)現(xiàn)明明更改了對應(yīng)數(shù)據(jù)的值,但是vue卻沒有重新渲染。明明說好了是響應(yīng)式的,但為什么有的數(shù)據(jù)可以通過響應(yīng)式實現(xiàn),而有的只能通過vue.set方法實現(xiàn)
官方文檔的流程圖
在此基礎(chǔ)上,我們根據(jù)源碼更細一步劃分出watcher和data之間的部分,即Dep和observer。
總體架構(gòu)簡介
在Vue源碼內(nèi),Dep類作為依賴,Watcher類則用來收集依賴和通知依賴重新求值。對于在實例化時傳入的數(shù)據(jù),使用工廠函數(shù)defineReactive令其響應(yīng)式。而在實例后再通過Vue.set/vm.$set添加的響應(yīng)式數(shù)據(jù),則需要借助Observer類來使其成為響應(yīng)式數(shù)據(jù),最后也是通過defineReactive實現(xiàn)響應(yīng)式。
對于每個響應(yīng)式數(shù)據(jù),會有兩個Dep實例,第一個是在defineReactive中的閉包遍歷,用途顯而易見。而第二個Dep則在響應(yīng)式數(shù)組的__ob__屬性值中,這個值是Observer實例,其實例屬性dep是Dep實例,在執(zhí)行Vue.set/vm.$set添加響應(yīng)式數(shù)據(jù)后,會通知依賴更新。
在講defineReactive之前,先講一下這些輔助類的實現(xiàn)和用處。
Dep
我們都知道,Vue響應(yīng)式的實現(xiàn),會在getter中收集響應(yīng)式數(shù)據(jù)的依賴,在setter中通知依賴數(shù)據(jù)更新,重新計算數(shù)據(jù)然后來更新視圖。在Vue內(nèi)部,使用Dep實例表示依賴,讓我們看一下Dep類是怎么定義的。
Dep有兩個實例屬性,一個靜態(tài)屬性。靜態(tài)屬性target是Watcher實例,功能是重新求值和通知視圖更新,下文我們會講到。實例屬性id是Dep實例的唯一標識,無需多說;屬性subs是Watcher實例數(shù)組,用于收集Watcher實例,當依賴更新時,這些Watcher實例就會重新求值。
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
?
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`會在收集依賴的時候調(diào)用,實際上執(zhí)行了Watcher的實例方法`addDep`,在`addDep`內(nèi)除了調(diào)用dep實例的`addSup`方法外,還做了避免重復(fù)收集Watcher實例的工作。這個方法會在Vue為響應(yīng)式數(shù)據(jù)設(shè)置的自定義getter中執(zhí)行。
`notify`方法則遍歷`subs`,執(zhí)行Watcher實例方法update來重新求值。這個方法會在Vue為響應(yīng)式數(shù)據(jù)設(shè)置的自定義setter中執(zhí)行。
有人可能有疑問,`target`是靜態(tài)屬性,那不是每個實例的target都一樣的?實際上,重新求值的操作在Watcher實例方法`get`內(nèi)實現(xiàn)。在get方法內(nèi),會先調(diào)用`pushTarget`來更新`Dep.target`,使其指向當前Watcher實例,之前的``Dep.target`會被保存`targetStack`末尾(相當于入棧操作),完成操作后會執(zhí)行`popTarget`函數(shù),從`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類會重新求值,并可能觸發(fā)重渲染。
```constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
// 與渲染相關(guān)的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()
}
}```
構(gòu)造函數(shù)接受五個參數(shù),`vm`是掛載的Component實例;`expOrFn`是觀察的屬性,當是字符串時表示屬性名,是函數(shù)時會被當成屬性的get方法;`cb`是屬性更新后執(zhí)行的回調(diào)函數(shù);`options`是配置項;`isRenderWatcher`表示當前實例是否與渲染相關(guān)。
在構(gòu)造函數(shù)內(nèi),先將實例屬性`vm`指向傳入的Component實例vm,如果當前Watcher實例與渲染相關(guān),會將其保存在`vm._watcher`中。接著將當前實例添加到`vm._watchers`中,同時根據(jù)傳入的配置項`options`初始化實例屬性。實例屬性`getter`是監(jiān)聽屬性的getter函數(shù),如果`expOrFn`是函數(shù),直接賦值,否則會調(diào)用`parsePath`來獲取屬性的getter。
`parsePath`內(nèi)部會先使用正則來判斷屬性名,如果有除數(shù)字、字母、`.`和`$`以外的字符時視為非法屬性名,直接返回,所以屬性只能是以`.`分隔的屬性。如果屬性名合法,則`parsePath`返回一個閉包函數(shù),調(diào)用時會傳入`vm`,即`obj`是`vm`的引用,這個閉包函數(shù)最終的目的是從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
}
}```
初始化完成之后,如果不是計算屬性相關(guān)的Watcher實例,會調(diào)用實例方法`get`求值。
### get方法
執(zhí)行g(shù)etter方法求值,完成依賴收集的過程。
方法開始時,執(zhí)行`pushTarget(this)`,將`Dep.target`指向當前Watcher實例。然后執(zhí)行`getter`收集依賴,最后將`Dep.target`復(fù)原,并執(zhí)行`cleanDeps`遍歷`deps`。在每次求值之后,都會調(diào)用`cleanupDeps`方法重置依賴,具體如何重置,稍后再講。
實際上,`Dep.target`指向的實例是即將要收集的目標。
`getter`的執(zhí)行,除了會獲取值外,還會觸發(fā)在`defineReactive`中為屬性設(shè)置的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數(shù)組里去。
`addDep`接受一個Dep實例作為參數(shù),如果 `dep.id` 沒有在集合 `newDepIds` 之中,則添加。如果不在集合 `depIds` 中,則將當前實例添加到 `dep.subs` 中。 簡單來說,這里的操作會避免重復(fù)收集依賴,這也是不直接調(diào)用`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`中,在響應(yīng)式屬性調(diào)用`setter`時,執(zhí)行`notify`方法,通知Watcher實例重新求值。
Watcher實例將Dep實例保存在集合`newDeps`,目的是避免重復(fù)收集依賴,同時會執(zhí)行Dep實例方法`addDep`,將當前Watcher實例添加到Dep實例屬性`subs`中。
### cleanupDeps
對于Watcher來說,每次求值的依賴并不一定與上一次的相同,在每次執(zhí)行`get`之后,都會調(diào)用`cleanupDeps`來重置收集的依賴。Watcher有四個實例屬性用于記錄依賴,分別是`newDeps/newDepIds`與`deps/depIds`。`newDeps`與`deps`是保存依賴的數(shù)組,`newDepIds`與`depIds`是保存依賴Id的集合。記錄上一次求值依賴的屬性是`deps/depIds`,記錄下一次求值依賴的屬性是`newDeps/newDepIds`(執(zhí)行`cleanupDeps`時已經(jīng)調(diào)用過`getter`重新求值了,所以說是上一次求值,下一次指的是下一次調(diào)用`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`,如果此次求值的依賴在下一次求值中并不存在,則需要調(diào)用`removeSub`方法,從`subs`數(shù)組中移除當前Watcher實例。
接著交換`newDeps/newDepIds`與`deps/depIds`,并清空交換后的`newDeps/newDepIds`。
### update
Dep類的`notify`方法用于通知觀察者重新求值,該方法內(nèi)部實際是遍歷`subs`數(shù)組,執(zhí)行Watcher的`update`方法。
update 方法定義如下。當實例與計算屬性相關(guān)時,xxx。如果不是計算屬性相關(guān)時,判斷是否需要同步觸發(fā),同步觸發(fā)時調(diào)用`run`,否則執(zhí)行`queueWatcher(this)`,交由調(diào)度模塊統(tǒng)一調(diào)度。
``` 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的數(shù)據(jù)響應(yīng)式實現(xiàn)主要分成2個部分:
1. 把數(shù)據(jù)轉(zhuǎn)化為getter和setter
2. 建立watcher并收集依賴
第一部分是上圖中`data`、`observer`、`dep`之間聯(lián)系的建立過程,第二部分是`watcher`、`dep`的關(guān)系建立
### 響應(yīng)式原理整合
在生成vue實例時,為對傳入的data進行遍歷,使用`Object.defineProperty`把這些屬性轉(zhuǎn)為`getter/setter`.
`Object.defineProperty` 是 ES5 中一個無法 shim 的特性,這也就是 Vue 不支持 IE8 以及更低版本瀏覽器的原因。
每個vue實例都有一個watcher實例,它會在實例渲染時記錄這些屬性,并在setter觸發(fā)時重新渲染。
`Vue 無法檢測到對象屬性的添加或刪除`
`Vue 不允許動態(tài)添加根級別的響應(yīng)式屬性。但是,可以使用 Vue.set(object, propertyName, value)方法向嵌套對象添加響應(yīng)式屬性。`
### 聲明響應(yīng)式屬性
由于 Vue 不允許動態(tài)添加根級響應(yīng)式屬性,所以你必須在初始化實例前聲明所有根級響應(yīng)式屬性,哪怕只是一個空值。
如果你未在 data 選項中聲明 message,Vue 將警告你渲染函數(shù)正在試圖訪問不存在的屬性。
### 異步更新隊列
vue更新dom時是異步執(zhí)行的
數(shù)據(jù)變化、更新是在主線程中同步執(zhí)行的;在偵聽到數(shù)據(jù)變化時,watcher將數(shù)據(jù)變更存儲到異步隊列中,當本次數(shù)據(jù)變化,即主線成任務(wù)執(zhí)行完畢,異步隊列中的任務(wù)才會被執(zhí)行(已去重)。
如果你在js中更新數(shù)據(jù)后立即去操作DOM,這時候DOM還未更新;vue提供了nextTick接口來處理這樣的情況,它的參數(shù)是一個回調(diào)函數(shù),會在本次DOM更新完成后被調(diào)用。
使用方法:
* 1.在組件內(nèi)使用 vm.$nextTick() 實例方法特別方便,因為它不需要全局 Vue,并且回調(diào)函數(shù)中的 this 將自動綁定到當前的 Vue 實例上:
```Vue.component('example', {
template: '<span>{{ message }}</span>',
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) // => '已更新'
}
}```