【前端詞典】Vue響應式原理其實很好懂

NO IMAGE

前言

這是十篇 Vue 系列文章的第三篇,這篇文章我們講講 Vue 最核心的功能之一 —— 響應式原理。

如何理解響應式

可以這樣理解:當一個狀態改變之後,與這個狀態相關的事務也立即隨之改變,從前端來看就是數據狀態改變後相關 DOM 也隨之改變。數據模型僅僅是普通的 JavaScript 對象。而當你修改它們時,視圖會進行更新。

拋個問題

我們先看看我們在 Vue 中常見的寫法:

<div id="app" @click="changeNum">
{{ num }}
</div>
var app = new Vue({
el: '#app',
data: {
num: 1
},
methods: {
changeNum() {
this.num = 2
}
}
})

這種寫法很常見,不過你考慮過當為什麼執行 this.num = 2 後視圖為什麼會更新呢?通過這篇文章我力爭把這個點講清楚。

如果不使用 Vue,我們應該怎麼實現?

我的第一想法是像下面這樣實現:

let data = {
num: 1
};
Object.defineProperty(data, 'num',{
set: function( newVal ){
document.getElementById('app').value = newVal;
}
});
input.addEventListener('input', function(){
data.num = 2;
});

這樣可以粗略的實現點擊元素,自動更新視圖。

這裡我們需要通過 Object.defineProperty 來操作對象的訪問器屬性。監聽到數據變化的時候,操作相關 DOM。

而這裡用到了一個常見模式 —— 發佈/訂閱模式。

我畫了一個大概的流程圖,用來說明觀察者模式和發佈/訂閱模式。如下:

【前端詞典】Vue響應式原理其實很好懂

仔細的同學會發現,我這個粗略的過程和使用 Vue 的不同的地方就是需要我自己操作 DOM 重新渲染。

如果我們使用 Vue 的話,這一步就是 Vue 內部的代碼來處理的。這也是我們為什麼在使用 Vue 的時候無需手動操作 DOM 的原因。

關於 Object.defineProperty 我在上一篇文章已經提及,這裡就不再複述。

Vue 是如何實現響應式的

我們知道對象可以通過 Object.defineProperty 操作其訪問器屬性,即對象擁有了 gettersetter 方法。這就是實現響應式的基石。

先看一張很直觀的流程圖:

【前端詞典】Vue響應式原理其實很好懂

initData 方法

在 Vue 的初始化的時候,其 _init() 方法會調用執行 initState(vm) 方法。initState 方法主要是對 propsmethodsdatacomputedwathcer 等屬性做了初始化操作。

這裡我們就對 data 初始化的過程做一個比較詳細的分析。

function initData (vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
if (!isPlainObject(data)) {
......
}
// proxy data on instance
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
const key = keys[i]
...... // 省略部分兼容代碼,但不影響理解
if (props && hasOwn(props, key)) {
......
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
// observe data
observe(data, true /* asRootData */)
}

initData初始化 data 的主要過程也是做兩件事:

  1. 通過 proxy 把每一個值 vm._data.[key] 都代理到 vm.[key] 上;
  2. 調用 observe 方法觀測整個 data 的變化,把 data 也變成響應式(可觀察),可以通過 vm._data.[key] 訪問到定義 data 返回函數中對應的屬性。

數據劫持 — Observe

通過這個方法將 data 下面的所有屬性變成響應式(可觀察)。

// 給對象的屬性添加 getter 和 setter,用於依賴收集和發佈更新
export class Observer {
value: any;
dep: Dep;  
vmCount: number; 
constructor (value: any) {
this.value = value
// 實例化 Dep 對象
this.dep = new Dep()
this.vmCount = 0
// 把自身實例添加到數據對象 value 的 __ob__ 屬性上
def(value, '__ob__', this)
// value 是否為數組的不同調用
if (Array.isArray(value)) {
const augment = hasProto ? protoAugment : copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}
// 取出所有屬性遍歷
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}

def 函數內封裝了 Object.defineProperty ,所以你 console.log(data) ,會發現多了一個 __ob__ 的屬性。

defineReactive 方法遍歷所有屬性

// 定義一個響應式對象的具體實現
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
..... // 省略部分兼容代碼,但不影響理解
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
// 進行依賴收集
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
..... // 省略部分兼容代碼,但不影響理解
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
// 對新的值進行監聽
childOb = !shallow && observe(newVal)
// 通知所有訂閱者,內部調用 watcher 的 update 方法 
dep.notify()
}
})
}

defineReactive 方法最開始初始化 Dep 對象的實例,然後通過對子對象遞歸調用observe 方法,使所有子屬性也能變成響應式的對象。並且在 Object.definePropertygettersetter 方法中調用 dep 的相關方法。

即:

  1. getter 方法完成的工作就是依賴收集 —— dep.depend()
  2. setter 方法完成的工作就是發佈更新 —— dep.notify()

我們發現這裡都和 Dep 對象有著不可忽略的關係。接下來我們就看看 Dep 對象。這個 Dep

調度中心作用的 Dep

前文中我們提到發佈/訂閱模式,在發佈者和訂閱者之前有一個調度中心。這裡的 Dep 扮演的角色就是調度中心,主要的作用就是:

  1. 收集訂閱者 Watcher 並添加到觀察者列表 subs
  2. 接收發布者的事件
  3. 通知訂閱者目標更新,讓訂閱者執行自己的 update 方法

詳細代碼如下:

// Dep 構造函數
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
// 向 dep 的觀察者列表 subs 添加 Watcher
addSub (sub: Watcher) {
this.subs.push(sub)
}
// 從 dep 的觀察者列表 subs 移除 Watcher
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
// 進行依賴收集
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// 通知所有訂閱者,內部調用 watcher 的 update 方法
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
// 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()
}

Dep 可以理解成是對 Watcher 的一種管理,Dep 和 Watcher 是緊密相關的。所以我們必須看一看 Watcher 的實現。

訂閱者 —— Watcher

Watcher 中定義了許多原型方法,這裡我只粗略的講 updateget 這三個方法。

  // 為了方便理解,部分兼容代碼已被我省去
get () {
// 設置需要處理的觀察者
pushTarget(this)
const vm = this.vm
let value = this.getter.call(vm, vm)
// deep 是否為 true 的處理邏輯
if (this.deep) {
traverse(value)
}
// 將 Dep.target 指向棧頂的觀察者,並將他從待處理的觀察者隊列中移除
popTarget()
// 執行依賴清空動作
this.cleanupDeps()
return value
}
update () {
if (this.computed) {
...
} else if (this.sync) { 
// 標記為同步
this.run()
} else {      
// 一般都是走這裡,即異步批量更新:nextTick
queueWatcher(this)
}
}

Vue 的響應式過程大概就是這樣了。感興趣的可以看看源碼。

最後我們在通過這個流程圖來複習一遍:

【前端詞典】Vue響應式原理其實很好懂

Vue 相關文章輸出計劃

最近總有朋友問我 Vue 相關的問題,因此接下來我會輸出 10 篇 Vue 相關的文章,希望對大家有一定的幫助。我會保持在 7 到 10 天更新一篇。

  1. 【前端詞典】Vuex 注入 Vue 生命週期的過程(完成)
  2. 【前端詞典】學習 Vue 源碼的必要知識儲備(完成)
  3. 【前端詞典】淺析 Vue 響應式原理(完成)
  4. 【前端詞典】新老 VNode 進行 patch 的過程
  5. 【前端詞典】如何開發功能組件並上傳 npm
  6. 【前端詞典】從這幾個方面優化你的 Vue 項目
  7. 【前端詞典】從 Vue-Router 設計講前端路由發展
  8. 【前端詞典】在項目中如何正確的使用 Webpack
  9. 【前端詞典】Vue 服務端渲染
  10. 【前端詞典】Axios 與 Fetch 該如何選擇

建議你關注我的公眾號,第一時間就可以接收最新的文章。

【前端詞典】Vue響應式原理其實很好懂

如果你想加群交流,也可以添加有點智能的機器人,自動拉你進群:

【前端詞典】Vue響應式原理其實很好懂

相關文章

[譯]如何寫出漂亮的JavaScript代碼

【譯】10種JavaScript最常見的錯誤

【前端詞典】一文讀懂單頁應用和多頁應用的區別

【前端詞典】你或許可以這樣優化ifelse結構