1. 程式人生 > >手摸手帶你理解Vue響應式原理

手摸手帶你理解Vue響應式原理

## 前言 響應式原理作為 `Vue` 的核心,使用資料劫持實現資料驅動檢視。在面試中是經常考查的知識點,也是面試加分項。 本文將會循序漸進的解析響應式原理的工作流程,主要以下面結構進行: 1. 分析主要成員,瞭解它們有助於理解流程 2. 將流程拆分,理解其中的作用 3. 結合以上的點,理解整體流程 文章稍長,但大部分是程式碼實現,還請耐心觀看。為了方便理解原理,文中的程式碼會進行簡化,如果可以請對照原始碼學習。 ## 主要成員 響應式原理中,`Observe`、`Watcher`、`Dep`這三個類是構成完整原理的主要成員。 * `Observe`,響應式原理的入口,根據資料型別處理觀測邏輯 * `Watcher`,用於執行更新渲染,元件會擁有一個渲染`Watcher`,我們常說的收集依賴,就是收集 `Watcher` * `Dep`,依賴收集器,屬性都會有一個`Dep`,方便發生變化時能夠找到對應的依賴觸發更新 下面來看看這些類的實現,包含哪些主要屬性和方法。 ### Observe:我會對資料進行觀測 > 溫馨提示:程式碼裡的序號對應程式碼塊下面序號的講解 ```js // 原始碼位置:/src/core/observer/index.js class Observe { constructor(data) { this.dep = new Dep() // 1 def(data, '__ob__', this) if (Array.isArray(data)) { // 2 protoAugment(data, arrayMethods) // 3 this.observeArray(data) } else { // 4 this.walk(data) } } walk(data) { Object.keys(data).forEach(key => { defineReactive(data, key, data[key]) }) } observeArray(data) { data.forEach(item => { observe(item) }) } } ``` 1. 為觀測的屬性新增 `__ob__` 屬性,它的值等於 `this`,即當前 `Observe` 的例項 2. 為陣列新增重寫的陣列方法,比如:`push`、`unshift`、`splice` 等方法,重寫目的是在呼叫這些方法時,進行更新渲染 3. 觀測陣列內的資料,`observe` 內部會呼叫 `new Observe`,形成遞迴觀測 4. 觀測物件資料,`defineReactive` 為資料定義 `get` 和 `set` ,即資料劫持 ### Dep:我會為資料收集依賴 ```js // 原始碼位置:/src/core/observer/dep.js let id = 0 class Dep{ constructor() { this.id = ++id // dep 唯一標識 this.subs = [] // 儲存 Watcher } // 1 depend() { Dep.target.addDep(this) } // 2 addSub(watcher) { this.subs.push(watcher) } // 3 notify() { this.subs.forEach(watcher => watcher.update()) } } // 4 Dep.target = null export function pushTarget(watcher) { Dep.target = watcher } export function popTarget(){ Dep.target = null } export default Dep ``` 1. 資料收集依賴的主要方法,`Dep.target` 是一個 `watcher` 例項 2. 新增 `watcher` 到陣列中,也就是新增依賴 3. 屬性在變化時會呼叫 `notify` 方法,通知每一個依賴進行更新 4. `Dep.target` 用來記錄 `watcher` 例項,是全域性唯一的,主要作用是為了在收集依賴的過程中找到相應的 `watcher` `pushTarget` 和 `popTarget` 這兩個方法顯而易見是用來設定 `Dep.target`的。`Dep.target` 也是一個關鍵點,這個概念可能初次檢視原始碼會有些難以理解,在後面的流程中,會詳細講解它的作用,需要注意這部分的內容。 ### Watcher:我會觸發檢視更新 ```js // 原始碼位置:/src/core/observer/watcher.js let id = 0 export class Watcher { constructor(vm, exprOrFn, cb, options){ this.id = ++id // watcher 唯一標識 this.vm = vm this.cb = cb this.options = options // 1 this.getter = exprOrFn this.deps = [] this.depIds = new Set() this.get() } run() { this.get() } get() { pushTarget(this) this.getter() popTarget(this) } // 2 addDep(dep) { // 防止重複新增 dep if (!this.depIds.has(dep.id)) { this.depIds.add(dep.id) this.deps.push(dep) dep.addSub(this) } } // 3 update() { queueWatcher(this) } } ``` 1. `this.getter` 儲存的是更新檢視的函式 2. `watcher` 儲存 `dep`,同時 `dep` 也儲存 `watcher`,進行雙向記錄 3. 觸發更新,`queueWatcher` 是為了進行非同步更新,非同步更新會呼叫 `run` 方法進行更新頁面 ## 響應式原理流程 對於以上這些成員具有的功能,我們都有大概的瞭解。下面結合它們,來看看這些功能是如何在響應式原理流程中工作的。 ### 資料觀測 資料在初始化時會通過 `observe` 方法來建立 `Observe` 類 ```js // 原始碼位置:/src/core/observer/index.js export function observe(data) { // 1 if (!isObject(data)) { return } let ob; // 2 if (data.hasOwnProperty('__ob__') && data.__ob__ instanceof Observe) { ob = data.__ob__ } else { // 3 ob = new Observe(data) } return ob } ``` 在初始化時,`observe` 拿到的 `data` 就是我們在 `data` 函式內返回的物件。 1. `observe` 函式只對 `object` 型別資料進行觀測 2. 觀測過的資料都會被新增上 `__ob__` 屬性,通過判斷該屬性是否存在,防止重複觀測 3. 建立 `Observe` 類,開始處理觀測邏輯 #### 物件觀測 進入 `Observe` 內部,由於初始化的資料是一個物件,所以會呼叫 `walk` 方法: ```js walk(data) { Object.keys(data).forEach(key => { defineReactive(data, key, data[key]) }) } ``` `defineReactive` 方法內部使用 `Object.defineProperty` 對資料進行劫持,是實現響應式原理最核心的地方。 ```js function defineReactive(obj, key, value) { // 1 let childOb = observe(value) // 2 const dep = new Dep() Object.defineProperty(obj, key, { get() { if (Dep.target) { // 3 dep.depend() if (childOb) { childOb.dep.depend() } } return value }, set(newVal) { if (newVal === value) { return } value = newVal // 4 childOb = observe(newVal) // 5 dep.notify() return value } }) } ``` 1. 由於值可能是物件型別,這裡需要呼叫 `observe` 進行遞迴觀測 2. 這裡的 `dep` 就是上面講到的每一個屬性都會有一個 `dep`,它是作為一個閉包的存在,負責收集依賴和通知更新 3. 在初始化時,`Dep.target` 是元件的渲染 `watcher`,這裡 `dep.depend` 收集的依賴就是這個 `watcher`,`childOb.dep.depend` 主要是為陣列收集依賴 4. 設定的新值可能是物件型別,需要對新值進行觀測 5. 值發生改變,`dep.notify` 通知 `watcher` 更新,這是我們改變資料後能夠實時更新頁面的觸發點 通過 `Object.defineProperty` 對屬性定義後,屬性的獲取觸發 `get` 回撥,屬性的設定觸發 `set` 回撥,實現響應式更新。 通過上面的邏輯,也能得出為什麼 `Vue3.0` 要使用 `Proxy` 代替 `Object.defineProperty` 了。`Object.defineProperty` 只能對單個屬性進行定義,如果屬性是物件型別,還需要遞迴去觀測,會很消耗效能。而 `Proxy` 是代理整個物件,只要屬性發生變化就會觸發回撥。 #### 陣列觀測 對於陣列型別觀測,會呼叫 `observeArray` 方法: ```js observeArray(data) { data.forEach(item => { observe(item) }) } ``` 與物件不同,它執行 `observe` 對陣列內的物件型別進行觀測,並沒有對陣列的每一項進行 `Object.defineProperty` 的定義,也就是說陣列內的項是沒有 `dep` 的。 所以,我們通過陣列索引對項進行修改時,是不會觸發更新的。但可以通過 `this.$set` 來修改觸發更新。那麼問題來了,為什麼 `Vue` 要這樣設計? 結合實際場景,陣列中通常會存放多項資料,比如列表資料。這樣觀測起來會消耗效能。還有一點原因,一般修改陣列元素很少會直接通過索引將整個元素替換掉。例如: ```js export default { data() { return { list: [ {id: 1, name: 'Jack'}, {id: 2, name: 'Mike'} ] } }, cretaed() { // 如果想要修改 name 的值,一般是這樣使用 this.list[0].name = 'JOJO' // 而不是以下這樣 // this.list[0] = {id:1, name: 'JOJO'} // 當然你可以這樣更新 // this.$set(this.list, '0', {id:1, name: 'JOJO'}) } } ``` ### 陣列方法重寫 當陣列元素新增或刪除,檢視會隨之更新。這並不是理所當然的,而是 `Vue` 內部重寫了陣列的方法,呼叫這些方法時,陣列會更新檢測,觸發檢視更新。這些方法包括: * push() * pop() * shift() * unshift() * splice() * sort() * reverse() 回到 `Observe` 的類中,當觀測的資料型別為陣列時,會呼叫 `protoAugment` 方法。 ```js if (Array.isArray(data)) { protoAugment(data, arrayMethods) // 觀察陣列 this.observeArray(data) } else { // 觀察物件 this.walk(data) } ``` 這個方法裡把陣列原型替換為 `arrayMethods` ,當呼叫改變陣列的方法時,優先使用重寫後的方法。 ```js function protoAugment(data, arrayMethods) { data.__proto__ = arrayMethods } ``` 接下來看看 `arrayMethods` 是如何實現的: ```js // 原始碼位置:/src/core/observer/array.js // 1 let arrayProto = Array.prototype // 2 export let arrayMethods = Object.create(arrayProto) let methods = [ 'push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice' ] methods.forEach(method => { arrayMethods[method] = function(...args) { // 3 let res = arrayProto[method].apply(this, args) let ob = this.__ob__ let inserted = '' switch(method){ case 'push': case 'unshift': inserted = args break; case 'splice': inserted = args.slice(2) break; } // 4 inserted && ob.observeArray(inserted) // 5 ob.dep.notify() return res } }) ``` 1. 將陣列的原型儲存起來,因為重寫的陣列方法裡,還是需要呼叫原生陣列方法的 2. `arrayMethods` 是一個物件,用於儲存重寫的方法,這裡使用 `Object.create(arrayProto)` 建立物件是為了使用者在呼叫非重寫方法時,能夠繼承使用原生的方法 3. 呼叫原生方法,儲存返回值,用於設定重寫函式的返回值 4. `inserted` 儲存新增的值,若 `inserted` 存在,對新值進行觀測 5. `ob.dep.notify` 觸發檢視更新 ### 依賴收集 依賴收集是檢視更新的前提,也是響應式原理中至關重要的環節。 #### 虛擬碼流程 為了方便理解,這裡寫一段虛擬碼,大概瞭解依賴收集的流程: ```js // data 資料 let data = { name: 'joe' } // 渲染watcher let watcher = { run() { dep.tagret = watcher document.write(data.name) } } // dep let dep = [] // 儲存依賴 dep.tagret = null // 記錄 watcher // 資料劫持 Object.defineProperty(data, 'name', { get(){ // 收集依賴 dep.push(dep.tagret) }, set(newVal){ data.name = newVal dep.forEach(watcher => { watcher.run() }) } }) ``` 初始化: 1. 首先會對 `name` 屬性定義 `get` 和 `set` 2. 然後初始化會執行一次 `watcher.run` 渲染頁面 3. 這時候獲取 `data.name`,觸發 `get` 函式收集依賴。 更新: 修改 `data.name`,觸發 `set` 函式,呼叫 `run` 更新檢視。 #### 真正流程 下面來看看真正的依賴收集流程是如何進行的。 ```js function defineReactive(obj, key, value) { let childOb = observe(value) const dep = new Dep() Object.defineProperty(obj, key, { get() { if (Dep.target) { dep.depend() // 收集依賴 if (childOb) { childOb.dep.depend() } } return value }, set(newVal) { if (newVal === value) { return } value = newVal childOb = observe(newVal) dep.notify() return value } }) } ``` 首先初始化資料,呼叫 `defineReactive` 函式對資料進行劫持。 ```js export class Watcher { constructor(vm, exprOrFn, cb, options){ this.getter = exprOrFn this.get() } get() { pushTarget(this) this.getter() popTarget(this) } } ``` 初始化將 `watcher` 掛載到 `Dep.target`,`this.getter` 開始渲染頁面。渲染頁面需要對資料取值,觸發 `get` 回撥,`dep.depend` 收集依賴。 ```js class Dep{ constructor() { this.id = id++ this.subs = [] } depend() { Dep.target.addDep(this) } } ``` `Dep.target` 為 `watcher`,呼叫 `addDep` 方法,並傳入 `dep` 例項。 ```js export class Watcher { constructor(vm, exprOrFn, cb, options){ this.deps = [] this.depIds = new Set() } addDep(dep) { if (!this.depIds.has(dep.id)) { this.depIds.add(dep.id) this.deps.push(dep) dep.addSub(this) } } } ``` `addDep` 中新增完 `dep` 後,呼叫 `dep.addSub` 並傳入當前 `watcher` 例項。 ```js class Dep{ constructor() { this.id = id++ this.subs = [] } addSub(watcher) { this.subs.push(watcher) } } ``` 將傳入的 `watcher` 收集起來,至此依賴收集流程完畢。 補充一點,通常頁面上會繫結很多屬性變數,渲染會對屬性取值,此時每個屬性收集的依賴都是同一個 `watcher`,即元件的渲染 `watcher`。 ### 陣列的依賴收集 ```js methods.forEach(method => { arrayMethods[method] = function(...args) { let res = arrayProto[method].apply(this, args) let ob = this.__ob__ let inserted = '' switch(method){ case 'push': case 'unshift': inserted = args break; case 'splice': inserted = args.slice(2) break; } // 對新增的值觀測 inserted && ob.observeArray(inserted) // 更新檢視 ob.dep.notify() return res } }) ``` 還記得重寫的方法裡,會呼叫 `ob.dep.notify` 更新檢視,`__ob__` 是我們在 `Observe` 為觀測資料定義的標識,值為 `Observe` 例項。那麼 `ob.dep` 的依賴是在哪裡收集的? ```js function defineReactive(obj, key, value) { // 1 let childOb = observe(value) const dep = new Dep() Object.defineProperty(obj, key, { get() { if (Dep.target) { dep.depend() // 2 if (childOb) { childOb.dep.depend() } } return value }, set(newVal) { if (newVal === value) { return } value = newVal childOb = observe(newVal) dep.notify() return value } }) } ``` 1. `observe` 函式返回值為 `Observe` 例項 2. `childOb.dep.depend` 執行,為 `Observe` 例項的 `dep` 新增依賴 所以在陣列更新時,`ob.dep` 內已經收集到依賴了。 ## 整體流程 下面捋一遍初始化流程和更新流程,如果你是初次看原始碼,不知道從哪裡看起,也可以參照以下的順序。由於原始碼實現比較多,下面展示的原始碼會稍微刪減一些程式碼 ### 初始化流程 入口檔案: ```js // 原始碼位置:/src/core/instance/index.js import { initMixin } from './init' import { stateMixin } from './state' import { renderMixin } from './render' import { eventsMixin } from './events' import { lifecycleMixin } from './lifecycle' import { warn } from '../util/index' function Vue (options) { this._init(options) } initMixin(Vue) stateMixin(Vue) eventsMixin(Vue) lifecycleMixin(Vue) renderMixin(Vue) export default Vue ``` `_init`: ```js // 原始碼位置:/src/core/instance/init.js export function initMixin (Vue: Class) { Vue.prototype._init = function (options?: Object) { const vm: Component = this // a uid vm._uid = uid++ // merge options if (options && options._isComponent) { // optimize internal component instantiation // since dynamic options merging is pretty slow, and none of the // internal component options needs special treatment. initInternalComponent(vm, options) } else { // mergeOptions 對 mixin 選項和傳入的 options 選項進行合併 // 這裡的 $options 可以理解為 new Vue 時傳入的物件 vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm ) } // expose real self vm._self = vm initLifecycle(vm) initEvents(vm) initRender(vm) callHook(vm, 'beforeCreate') initInjections(vm) // resolve injections before data/props // 初始化資料 initState(vm) initProvide(vm) // resolve provide after data/props callHook(vm, 'created') if (vm.$options.el) { // 初始化渲染頁面 掛載元件 vm.$mount(vm.$options.el) } } } ``` 上面主要關注兩個函式,`initState` 初始化資料,`vm.$mount(vm.$options.el)` 初始化渲染頁面。 先進入 `initState`: ```js // 原始碼位置:/src/core/instance/state.js export function initState (vm: Component) { vm._watchers = [] const opts = vm.$options if (opts.props) initProps(vm, opts.props) if (opts.methods) initMethods(vm, opts.methods) if (opts.data) { // data 初始化 initData(vm) } else { observe(vm._data = {}, true /* asRootData */) } if (opts.computed) initComputed(vm, opts.computed) if (opts.watch && opts.watch !== nativeWatch) { initWatch(vm, opts.watch) } } function initData (vm: Component) { let data = vm.$options.data // data 為函式時,執行 data 函式,取出返回值 data = vm._data = typeof data === 'function' ? getData(data, vm) : 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)) { process.env.NODE_ENV !== 'production' && warn( `The data property "${key}" is already declared as a prop. ` + `Use prop default value instead.`, vm ) } else if (!isReserved(key)) { proxy(vm, `_data`, key) } } // observe data // 這裡就開始走觀測資料的邏輯了 observe(data, true /* asRootData */) } ``` `observe` 內部流程在上面已經講過,這裡再簡單過一遍: 1. `new Observe` 觀測資料 2. `defineReactive` 對資料進行劫持 `initState` 邏輯執行完畢,回到開頭,接下來執行 `vm.$mount(vm.$options.el)` 渲染頁面: `$mount`: ```js // 原始碼位置:/src/platforms/web/runtime/index.js Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && inBrowser ? query(el) : undefined return mountComponent(this, el, hydrating) } ``` `mountComponent`: ```js // 原始碼位置:/src/core/instance/lifecycle.js export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el callHook(vm, 'beforeMount') let updateComponent /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && config.performance && mark) { updateComponent = () => { const name = vm._name const id = vm._uid const startTag = `vue-perf-start:${id}` const endTag = `vue-perf-end:${id}` mark(startTag) const vnode = vm._render() mark(endTag) measure(`vue ${name} render`, startTag, endTag) mark(startTag) vm._update(vnode, hydrating) mark(endTag) measure(`vue ${name} patch`, startTag, endTag) } } else { // 資料改變時 會呼叫此方法 updateComponent = () => { // vm._render() 返回 vnode,這裡面會就對 data 資料進行取值 // vm._update 將 vnode 轉為真實dom,渲染到頁面上 vm._update(vm._render(), hydrating) } } // 執行 Watcher,這個就是上面所說的渲染wacther new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false // manually mounted instance, call mounted on self // mounted is called for render-created child components in its inserted hook if (vm.$vnode == null) { vm._isMounted = true callHook(vm, 'mounted') } return vm } ``` `Watcher`: ```js // 原始碼位置:/src/core/observer/watcher.js let uid = 0 export default class Watcher { constructor(vm, exprOrFn, cb, options){ this.id = ++id this.vm = vm this.cb = cb this.options = options // exprOrFn 就是上面傳入的 updateComponent this.getter = exprOrFn this.deps = [] this.depIds = new Set() this.get() } get() { // 1. pushTarget 將當前 watcher 記錄到 Dep.target,Dep.target 是全域性唯一的 pushTarget(this) let value const vm = this.vm try { // 2. 呼叫 this.getter 相當於會執行 vm._render 函式,對例項上的屬性取值, //由此觸發 Object.defineProperty 的 get 方法,在 get 方法內進行依賴收集(dep.depend),這裡依賴收集就需要用到 Dep.target 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) } // 3. popTarget 將 Dep.target 置空 popTarget() this.cleanupDeps() } return value } } ``` 至此初始化流程完畢,初始化流程的主要工作是資料劫持、渲染頁面和收集依賴。 ### 更新流程 資料發生變化,觸發 `set` ,執行 `dep.notify` ```js // 原始碼位置:/src/core/observer/dep.js let uid = 0 /** * A dep is an observable that can have multiple * directives subscribing to it. */ export default class Dep { 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() if (process.env.NODE_ENV !== 'production' && !config.async) { // subs aren't sorted in scheduler if not running async // we need to sort them now to make sure they fire in correct // order subs.sort((a, b) => a.id - b.id) } for (let i = 0, l = subs.length; i < l; i++) { // 執行 watcher 的 update 方法 subs[i].update() } } } ``` `wathcer.update`: ```js // 原始碼位置:/src/core/observer/watcher.js /** * Subscriber interface. * Will be called when a dependency changes. */ update () { /* istanbul ignore else */ if (this.lazy) { // 計算屬性更新 this.dirty = true } else if (this.sync) { // 同步更新 this.run() } else { // 一般的資料都會進行非同步更新 queueWatcher(this) } } ``` `queueWatcher`: ```js // 原始碼位置:/src/core/observer/scheduler.js // 用於儲存 watcher const queue: Array = [] // 用於 watcher 去重 let has: { [key: number]: ?true } = {} /** * Flush both queues and run the watchers. */ function flushSchedulerQueue () { let watcher, id // 對 watcher 排序 queue.sort((a, b) => a.id - b.id) // do not cache length because more watchers might be pushed // as we run existing watchers for (index = 0; index < queue.length; index++) { watcher = queue[index] id = watcher.id has[id] = null // run方法更新檢視 watcher.run() } } /** * Push a watcher into the watcher queue. * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. */ export function queueWatcher (watcher: Watcher) { const id = watcher.id if (has[id] == null) { has[id] = true // watcher 加入陣列 queue.push(watcher) // 非同步更新 nextTick(flushSchedulerQueue) } } ``` `nextTick`: ```js // 原始碼位置:/src/core/util/next-tick.js const callbacks = [] let pending = false function flushCallbacks () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 // 遍歷回撥函式執行 for (let i = 0; i < copies.length; i++) { copies[i]() } } let timerFunc if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() timerFunc = () => { p.then(flushCallbacks) } } export function nextTick (cb?: Function, ctx?: Object) { let _resolve // 將回調函式加入陣列 callbacks.push(() => { if (cb) { cb.call(ctx) } }) if (!pending) { pending = true // 遍歷回撥函式執行 timerFunc() } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } } ``` 這一步是為了使用微任務將回調函式非同步執行,也就是上面的`p.then`。最終,會呼叫 `watcher.run` 更新頁面。 至此更新流程完畢。 ## 寫在最後 如果沒有接觸過原始碼的同學,我相信看完可能還是會有點懵的,這很正常。建議對照原始碼再自己多看幾遍就能知道流程了。對於有基礎的同學就當做是複習了。 想要變強,學會看原始碼是必經之路。在這過程中,不僅能學習框架的設計思想,還能培養自己的邏輯思維。萬事開頭難,遲早都要邁出這一步,不如就從今天開始。 簡化後的程式碼我已放在 [github](https://github.com/ChanWahFung/vue-source-demo),有需要的可以看看。