vue響應式原理學習(二)— Observer的實現

jkCaptain發表於2018-12-16

之前我的一篇文章vue響應式原理學習(一)講述了vue資料響應式原理的一些簡單知識。 眾所周知,Vuedata屬性,是預設深度監聽的,這次我們再深度分析下,Observer觀察者的原始碼實現。

先寫個深拷貝熱熱身

既然data屬性是被深度監聽,那我們就首先自己實現一個簡單的深拷貝,理解下思路。

深拷貝的原理有點像遞迴, 其實就是遇到引用型別,呼叫自身函式再次解析。

function deepCopy(source) {
    // 型別校驗,如果不是引用型別 或 全等於null,直接返回
    if (source === null || typeof source !== 'object') {
        return source;
    }

    let isArray = Array.isArray(source),
        result = isArray ? [] : {};
        
    // 遍歷屬性
    if (isArray) {
        for(let i = 0, len = source.length; i < len; i++) {
            let val = source[i];
            // typeof [] === 'object', typeof {} === 'object'
            // 考慮到 typeof null === 'object' 的情況, 所以要加個判斷
            if (val && typeof val === 'object') {
                result[i] = deepCopy(val);
            } else {
                result[i] = val;
            }
        }
        // 簡寫 
        // result = source.map(item => {
        //     return (item && typeof item === 'object') ? deepCopy(item) : item
        // });
    } else {
        const keys = Object.keys(source);
        for(let i = 0, len = keys.length; i < len; i++) {
            let key = keys[i],
                val = source[key];
            if (val && typeof val === 'object') {
                result[key] = deepCopy(val);
            } else {
                result[key] = val;
            }
        }
        // 簡寫
        // keys.forEach((key) => {
        //     let val = source[key];   
        //     result[key] = (val && typeof val === 'object') ? deepCopy(val) : val;         
        // });
    }
    
    return result;
}
複製程式碼

為什麼是簡單的深拷貝,因為沒考慮 RegExp, Date, 原型鏈,DOM/BOM物件等等。要寫好一個深拷貝,不簡單。

有的同學可能會問,為什麼不直接一個 for in 解決。如下:

function deepCopy(source) {
    let result = Array.isArray(source) ? [] : {};
    
    // 遍歷物件
    for(let key in source) {
        let val = source[key];
        result[key] = (val && typeof val === 'object') ? deepCopy(val) : val;
    }

    return result;
}
複製程式碼

其實 for in有一個痛點就是原型鏈上的非內建方法也會被遍歷。例如開發者自己在物件的 prototype上擴充套件的方法。

又有的同學可能會說,加 hasOwnProperty 解決呀。如果是 Object 型別,確實可以解決,但如何是 Array 的話,就獲取不到陣列的索引啦。

說到 for in,再加個注意項,就是 for in 也是可以 continue 的,而陣列的 forEach 方法不可以。因為 forEach的內部實現是在一個for迴圈中依次執行你傳入的函式。

分析 Vue 的 Observer

這裡我主要是為程式碼新增註釋,建議看官們最好開啟原始碼來看。

程式碼來源:Vue專案下的 src/core/observer/index.js

Vue 將 Observer 封裝成了一個 class

Observer
export class Observer {
    value: any;
    dep: Dep;
    vmCount: number; // number of vms that has this object as root $data

    constructor(value: any) {
        this.value = value
        this.dep = new Dep()
        this.vmCount = 0
        // 每觀察一個物件,就在物件上新增 __ob__ 屬性,值為當前 Observer 例項
        // 當然,前提是 value 本身是一個陣列或物件,而非基礎資料型別,如數字,字串等。
        def(value, '__ob__', this)   
        
        // 如果是陣列
        if (Array.isArray(value)) {
            // 這兩行程式碼後面再講解
            // 這裡程式碼的作用是 為陣列的操作函式賦能
            // 也就是,當我們使用 push pop splice 等陣列的api時,也可以觸發資料響應,更新檢視。
            const augment = hasProto ? protoAugment : copyAugment
            augment(value, arrayMethods, arrayKeys)
            
            // 遍歷陣列並觀察
            this.observeArray(value)
        } else {
            // 遍歷物件並觀察
            // 這裡會有存在 value 不是 Object 的情況,
            // 不過沒事,Object.keys的引數為數字,字串時 會 返回一個空陣列。
            this.walk(value)
        }
    }

    // 遍歷物件並觀察
    walk(obj: Object) {
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            // 觀察物件,defineReactive 函式內部呼叫了 observe 方法, 
            // observe 內部 呼叫了 Observer 建構函式
            defineReactive(obj, keys[i])
        }
    }

    // 遍歷陣列並觀察
    observeArray(items: Array<any>) {
        for (let i = 0, l = items.length; i < l; i++) {
            // 觀察物件,observe 內部 呼叫了 Observer 建構函式
            observe(items[i])
        }
    }
}

function protoAugment(target, src: Object, keys: any) {
    target.__proto__ = src
}

function copyAugment(target: Object, src: Object, keys: Array<string>) {
    for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key])
    }
}
複製程式碼

上面的程式碼中,細心的同學可能對observedefdefineReactive這些函式不明所以,接下來說說這幾個函式

observe 函式

用來呼叫 Observer建構函式

export function observe(value: any, asRootData: ?boolean): Observer | void {
    // 如果不是物件,或者是VNode例項,直接返回。
    if (!isObject(value) || value instanceof VNode) {
        return
    }
    // 定義一個 變數,用來儲存 Observer 例項
    let ob: Observer | void
    // 如果物件已經被觀察過,Vue會自動給物件加上一個 __ob__ 屬性,避免重複觀察
    // 如果物件上已經有 __ob__屬性,表示已經被觀察過,就直接返回 __ob__
    if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.__ob__
    } else if (
        shouldObserve &&       // 是否應該觀察
        !isServerRendering() &&  // 非服務端渲染
        (Array.isArray(value) || isPlainObject(value)) &&     // 是陣列或者Object物件
        Object.isExtensible(value) &&     // 物件是否可擴充套件,也就是是否可向物件新增新屬性
        !value._isVue // 非 Vue 例項
    ) {
        ob = new Observer(value) 
    }
    if (asRootData && ob) {  // 暫時還不清楚,不過我們可以先忽略它
        ob.vmCount++
    }  
    return ob  // 返回 Observer 例項
}
複製程式碼

可以發現 observe 函式,只是 返回 一個 Observer 例項,只是多了些許判斷。為了方便理解,我們完全可以把程式碼縮減:

// 這就清晰多了
function observe(value) {
    let ob;
    if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.___ob___
    } else {
        ob = new Observer(value) 
    }
    return ob;
}
複製程式碼
def 函式

其實就是 Object.defineProperty 的封裝

export function def(obj: Object, key: string, val: any, enumerable?: boolean) {
    Object.defineProperty(obj, key, {
        value: val,
        // 預設不可列舉,也就意味著正常情況,Vue幫我們在物件上新增的 __ob__屬性,是遍歷不到的
        enumerable: !!enumerable,  
        writable: true,
        configurable: true
    })
}
複製程式碼
defineReactive 函式

defineReactive函式的功能較多,主要是用來 初始化時收集依賴改變屬性時觸發依賴

export function defineReactive(
    obj: Object,     // 被觀察物件
    key: string,     // 物件的屬性
    val: any,        // 使用者給屬性賦值
    customSetter?: ?Function,   // 使用者額外自定義的 set
    shallow?: boolean           // 是否深度觀察
) {
    // 用於收集依賴
    const dep = new Dep()

    // 如果不可修改,直接返回
    const property = Object.getOwnPropertyDescriptor(obj, key)
    if (property && property.configurable === false) {
        return
    }
    
    
    // 如果使用者自己 未在物件上定義get 或 已在物件上定義set,且使用者沒有傳入 val 引數
    // 則先計算物件的初始值,賦值給 val 引數
    const getter = property && property.get
    const setter = property && property.set
    if ((!getter || setter) && arguments.length === 2) {
        val = obj[key]
    }

    // !shallow 表示 深度觀察,shallow 不為 true 的情況下,表示預設深度觀察
    // 如果是深度觀察,執行 observe 方法觀察物件
    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

            // 判斷值是否改變
            // (newVal !== newVal && value !== value) 用來判斷 NaN !== NaN 的情況
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            
            // 非生產環境,觸發使用者額外自定義的 setter
            if (process.env.NODE_ENV !== 'production' && customSetter) {
                customSetter()
            }
            
            // 觸發物件原有的 setter,如果沒有的話,用新值(newVal)覆蓋舊值(val)
            if (setter) {
                setter.call(obj, newVal)
            } else {
                val = newVal
            }

            // 如果是深度觀察,屬性被更改後,重新觀察
            childOb = !shallow && observe(newVal)
            
            // 觸發依賴。收集依賴和觸發依賴是個比較大的流程,日後再說
            dep.notify()
        }
    })
}
複製程式碼
入口在哪

說了這麼多,那Vue觀察物件的初始化入口在哪裡呢,當然是在初始化Vue例項的地方了,也就是 new Vue 的時候。

程式碼來源:Vue專案下src/core/instance/index.js

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)    // 這個方法 定義在 initMixin 函式內
}

// 就是這裡,initMixin 函式會在 Vue 的 prototype 上擴充套件一個 _init 方法
// 我們 new Vue 的時候就是執行的 this._init(options) 方法
initMixin(Vue)  

stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
複製程式碼

initMixin 函式在 Vue.prototype 上擴充套件一個 _init 方法,_init方法會有一個initState函式進行資料初始化

initState(vm)   // vm 為當前 Vue 例項,Vue 會將我們傳入的 data 屬性賦值給 vm._data 
複製程式碼

initState 函式會在內部執行一段程式碼,觀察 vm例項上的data屬性

程式碼來源:Vue專案下 src/core/instance/state.js。無用的程式碼我先註釋掉了,只保留初始化 data 的程式碼。

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)
    
    // 如果傳入了 data 屬性
    // 這裡的 data 就是我們 new Vue 時傳入的 data 屬性
    if (opts.data) {    
        // initData 內部會將 我們傳入的 data屬性 規範化。
        // 如果傳入的 data 不是函式,則直接 observe(data)
        // 如果傳入的 data 是函式,會先執行函式,將 返回值 賦值給 data,覆蓋原有的值,再observe(data)。
        // 這也就是為什麼我們寫元件時 data 可以傳入一個函式
        initData(vm)    
    } else {
        // 如果沒傳入 data 屬性,觀察一個空物件
        observe(vm._data = {}, true /* asRootData */)
    }
    
    // if (opts.computed) initComputed(vm, opts.computed)
    // if (opts.watch && opts.watch !== nativeWatch) {
    //     initWatch(vm, opts.watch)
    // }
}
複製程式碼
總結

我們 new Vue 的時候 Vue 對我們傳入的 data 屬性到底做了什麼操作?

  1. 如果我們傳入的 data 是一個函式,會先執行函式得到返回值。並賦值覆蓋 data。如果傳入的是物件,則不做操作。
  2. 執行 observe(data)
    • observe 內部會執行 new Observer(data)
    • new Observer(data) 會在 data物件 上擴充套件一個不可列舉的屬性 __ob__,這個屬性有大作用。
    • 如果 data 是個陣列
      • 執行 observeArray(data)。這個方法會遍歷data物件,並對每一個陣列項執行observe之後的流程參考第2步
    • 如果 data 是物件
      • 執行 walk(data)。這個方法會遍歷data物件,並對每一個屬性執行 defineReactive
      • defineReactive 內部會對傳入的物件屬性執行 observe之後的流程參考第2步

篇幅和精力有限,關於 protoAugmentcopyAugment的作用,defineReactive 內如何收集依賴與觸發依賴的實現,日後再說。

文章內容如果有錯誤之處,還請指出。

參考:

JavaScript 如何完整實現深度Clone物件

Vue 技術內幕

相關文章