Vue原始碼探究-狀態初始化
本篇程式碼位於 vue/src/core/instance/state.js
繼續隨著核心類的初始化展開探索其他的模組,這一篇來研究一下Vue的狀態初始化。這裡的狀態初始化指的就是在建立例項的時候,在配置物件裡定義的屬性、資料變數、方法等是如何進行初始處理的。由於隨後的資料更新變動都交給觀察系統來負責,所以在事先弄明白了資料繫結的原理之後,就只需要將目光集中在這一部分。
來仔細看看在核心類中首先執行的關於 state
部分的原始碼:
initState
// 定義並匯出initState函式,接收引數vm export function initState (vm: Component) { // 初始化例項的私有屬性_watchers // 這就是在觀察系統裡會使用到的儲存所有顯式監視器的物件 vm._watchers = [] // 獲取例項的配置物件 const opts = vm.$options // 如果定義了props,則初始化props if (opts.props) initProps(vm, opts.props) // 如果定義了methods,則初始化methods if (opts.methods) initMethods(vm, opts.methods) // 如果定義了data,則初始化data if (opts.data) { initData(vm) } else { // 否則初始化例項的私有屬性_data為空物件,並開啟觀察 observe(vm._data = {}, true /* asRootData */) } // 如果定義了computed,則初始化計算屬性 if (opts.computed) initComputed(vm, opts.computed) // 如果定義了watch並且不是nativeWatch,則初始化watch // nativeWatch是火狐瀏覽器下定義的物件的原型方法 if (opts.watch && opts.watch !== nativeWatch) { initWatch(vm, opts.watch) } } 複製程式碼
這段程式碼非常直白,主要用來執行配置物件裡定義的了狀態的初始化。這裡分別有 props
、 data
、 methods
、 computed
、 watch
五個配置物件,分別有各自的初始化方法。在仔細研究它們的具體實現之前,先來看一段將在各個初始化函式裡用到的輔助函式。
// 定義共享屬性定義描述符物件sharedPropertyDefinition // 描述符物件的列舉和可配置屬性都設定為true // get、set方法設定為空函式 const sharedPropertyDefinition = { enumerable: true, configurable: true, get: noop, set: noop } // 定義並匯出proxy函式,該函式用來為在目標物件上定義並代理屬性 // 接收目標物件target,路徑鍵名sourceKey,屬性鍵名三個引數 export function proxy (target: Object, sourceKey: string, key: string) { // 設定屬性描述符物件的get方法 sharedPropertyDefinition.get = function proxyGetter () { return this[sourceKey][key] } // 設定屬性描述性物件的set犯法 sharedPropertyDefinition.set = function proxySetter (val) { this[sourceKey][key] = val } // 在目標物件上定義屬性 Object.defineProperty(target, key, sharedPropertyDefinition) } 複製程式碼
proxy
函式的定義非常重要,在下面要探究的各個初始化函式中它,它會將我們在配置物件中設定的屬性全部定義到例項物件中,但是我們對這些屬性的操作是通過各部分相應的代理屬性上來執行的。 get
和 set
方法的實現非常明白的表示出這一過程,然後再將屬性定義到例項中。由這個函式作為基礎,繼續來看看其他五個狀態的初始化函式的內容。
initProps
// 定義initProps函式,接收vm,propsOptions兩個引數 function initProps (vm: Component, propsOptions: Object) { // 賦值propsData,propsData是全域性擴充套件傳入的賦值物件 // 在使用extend的時候會用到,實際開發裡運用較少 const propsData = vm.$options.propsData || {} // 定義例項的_props私有屬性,並賦值給props const props = vm._props = {} // 快取prop鍵,以便將來props更新可以使用Array而不是動態物件鍵列舉進行迭代。 // cache prop keys so that future props updates can iterate using Array // instead of dynamic object key enumeration. const keys = vm.$options._propKeys = [] // 是否是根例項 const isRoot = !vm.$parent // 對於非根例項,關閉觀察標識 // root instance props should be converted if (!isRoot) { toggleObserving(false) } // 遍歷props配置物件 for (const key in propsOptions) { // 向快取鍵值陣列中新增鍵名 keys.push(key) // 驗證prop的值,validateProp執行對初始化定義的props的型別檢查和預設賦值 // 如果有定義型別檢查,布林值沒有預設值時會被賦予false,字串預設undefined // 對propsOptions的比較也是在使用extend擴充套件時才有意義 // 具體實現可以參考 src/core/util/props.js,沒有難點這裡不詳細解釋 const value = validateProp(key, propsOptions, propsData, vm) // 非生產環境下進行檢查和提示 /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { // 進行鍵名的轉換,將駝峰式轉換成連字元式的鍵名 const hyphenatedKey = hyphenate(key) // 對與保留變數名衝突的鍵名給予提示 if (isReservedAttribute(hyphenatedKey) || config.isReservedAttr(hyphenatedKey)) { warn( `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`, vm ) } // 對屬性建立觀察,並在直接使用屬性時給予警告 defineReactive(props, key, value, () => { if (vm.$parent && !isUpdatingChildComponent) { warn( `Avoid mutating a prop directly since the value will be ` + `overwritten whenever the parent component re-renders. ` + `Instead, use a data or computed property based on the prop's ` + `value. Prop being mutated: "${key}"`, vm ) } }) } else { // 非生產環境下直接對屬性進行存取器包裝,建立依賴觀察 defineReactive(props, key, value) } // 使用Vue.extend()方法擴充套件屬性時,已經對靜態屬性進行了代理 // 這裡只需要針對例項化時的屬性執行代理操作 // static props are already proxied on the component's prototype // during Vue.extend(). We only need to proxy props defined at // instantiation here. // 當例項上沒有同名屬性時,對屬性進行代理操作 // 將對鍵名的引用指向vm._props物件中 if (!(key in vm)) { proxy(vm, `_props`, key) } } // 開啟觀察狀態標識 toggleObserving(true) } 複製程式碼
initProps
函式的最主要內容有兩點,一是對定義的資料建立觀察,二是對資料進行代理,這就是私有變數 _props
的作用,之後獲取和設定的變數都是作為 _props
的屬性被操作。
另外初始化 props
的過程中有針對 extend
方法會使用到的 propsData
屬性的初始化。具體使用是在擴充套件物件時定義一些 props,然後在建立例項的過程中傳入 propsData 配置物件,擴充套件物件裡相應的props屬性會接收 propsData 傳入的值。與在父元件傳入 props 的值類似,只是這裡要顯式的通過 propsData
配置物件來傳入值。
initData
// 定義initData函式 function initData (vm: Component) { // 獲取配置物件的data屬性 let data = vm.$options.data // 判斷data是否是函式 // 若是函式則將getData函式的返回賦值給data和例項私有屬性_data // 否則直接將data賦值給例項_data屬性,並在無data時賦值空物件 data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {} // 如果data不是物件則將data賦值為空物件 // 進一步保證data是物件型別 if (!isPlainObject(data)) { data = {} // 在非生產環境下給出警告提示 process.env.NODE_ENV !== 'production' && warn( 'data functions should return an object:\n' + 'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function', vm ) } // 例項物件代理data // proxy data on instance // 獲取所有data鍵值 const keys = Object.keys(data) // 獲取配置物件的props const props = vm.$options.props // 獲取配置物件的methods const methods = vm.$options.methods // 遍歷keys let i = keys.length while (i--) { const key = keys[i] // 非生產環境給出與methods定義的方法名衝突的警告 if (process.env.NODE_ENV !== 'production') { if (methods && hasOwn(methods, key)) { warn( `Method "${key}" has already been defined as a data property.`, vm ) } } // 檢測是否與props衝突 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 ) // 沒有與props衝突並且非保留字時,代理鍵名到例項的_data物件上 } else if (!isReserved(key)) { proxy(vm, `_data`, key) } } // 觀察資料 // observe data observe(data, true /* asRootData */) } // 定義並匯出getData函式,接受函式型別的data物件,和Vue例項物件 export function getData (data: Function, vm: Component): any { // pushTarget和popTarget是為了解決Vue依賴性檢測的缺陷可能導致冗餘依賴性的問題 // 具體可參閱 https://github.com/vuejs/vue/issues/7573 // 此操作會設定Dep.target為undefined,在初始化option時呼叫dep.depend()也不會建立依賴 // #7573 呼叫資料getter時禁用dep集合 // #7573 disable dep collection when invoking data getters pushTarget() // 嘗試在vm上呼叫data函式並返回執行結果 try { return data.call(vm, vm) } catch (e) { // 如果捕獲到錯誤則處理錯誤,並返回空物件 handleError(e, vm, `data()`) return {} } finally { popTarget() } } 複製程式碼
與 props 的處理類似, initData
函式的作用也是為了對資料建立觀察的依賴關係,並且代理資料到私有變數 _data
上,另外包括了對 data 與其他配置物件屬性的鍵名衝突的檢測。
initComputed
// 設定computedWatcherOptions物件 const computedWatcherOptions = { computed: true } // 定義initComputed函式,接受例項vm,和computed物件 function initComputed (vm: Component, computed: Object) { // $flow-disable-line // 定義watchers和例項_computedWatchers屬性,初始賦值空物件 const watchers = vm._computedWatchers = Object.create(null) // 是否是伺服器渲染,computed屬性在伺服器渲染期間只能是getter // computed properties are just getters during SSR const isSSR = isServerRendering() // 遍歷computed for (const key in computed) { // 獲取使用者定義的值 const userDef = computed[key] // 如果使用者定義的是函式則賦值給getter否則j將userDef.get方法賦值給getter const getter = typeof userDef === 'function' ? userDef : userDef.get // 非生產環境丟擲缺少計算屬性錯誤警告 if (process.env.NODE_ENV !== 'production' && getter == null) { warn( `Getter is missing for computed property "${key}".`, vm ) } // 非伺服器渲染下 if (!isSSR) { // 為計算屬性建立內部監視器 // create internal watcher for the computed property. watchers[key] = new Watcher( vm, getter || noop, noop, computedWatcherOptions ) } // 元件定義的內部計算屬性已經在元件的原型上定義好了 // 所以這裡只要關注例項初始化時使用者定義的計算屬性 // component-defined computed properties are already defined on the // component prototype. We only need to define computed properties defined // at instantiation here. // 鍵名非例項根屬性時,定義計算屬性,具體參照defineComputed函式 if (!(key in vm)) { defineComputed(vm, key, userDef) // 非生產環境下,檢測與data屬性名的衝突並給出警告 } else if (process.env.NODE_ENV !== 'production') { if (key in vm.$data) { warn(`The computed property "${key}" is already defined in data.`, vm) } else if (vm.$options.props && key in vm.$options.props) { warn(`The computed property "${key}" is already defined as a prop.`, vm) } } } } // 定義並匯出defineComputed哈數 // 接收例項target,計算屬性鍵名key,計算屬性值userDef引數 export function defineComputed ( target: any, key: string, userDef: Object | Function ) { // 在非伺服器渲染下設定快取 const shouldCache = !isServerRendering() // 計算屬性值是函式時 if (typeof userDef === 'function') { // 設定計算屬性的getter,setter為空函式 sharedPropertyDefinition.get = shouldCache ? createComputedGetter(key) : userDef sharedPropertyDefinition.set = noop } else { // 當計算屬性是物件時,設定計算屬性的getter和setter sharedPropertyDefinition.get = userDef.get ? shouldCache && userDef.cache !== false ? createComputedGetter(key) : userDef.get : noop sharedPropertyDefinition.set = userDef.set ? userDef.set : noop } // 非生產環境下,如果沒喲定義計算屬性的setter // 想設定計算屬性時給出警告 if (process.env.NODE_ENV !== 'production' && sharedPropertyDefinition.set === noop) { sharedPropertyDefinition.set = function () { warn( `Computed property "${key}" was assigned to but it has no setter.`, this ) } } // 以重新設定的屬性描述符為基礎在例項物件上定義計算屬性 Object.defineProperty(target, key, sharedPropertyDefinition) } // 定義createComputedGetter,建立計算屬性getter // 目的是在非伺服器渲染情況下建立計算屬性的觀察依賴, // 並根據其依賴屬性返回計算後的值 function createComputedGetter (key) { return function computedGetter () { const watcher = this._computedWatchers && this._computedWatchers[key] if (watcher) { watcher.depend() return watcher.evaluate() } } } 複製程式碼
計算屬性的初始化相對複雜一些,首先要對計算屬性建立觀察,然後再在例項上重新定義計算屬性,並且執行屬性代理。由於加入了伺服器渲染的功能,在定義計算屬性的時候對使用環境做判斷,是非伺服器渲染會影響到計算屬性的定義,這是由於伺服器渲染下使用框架時,計算屬性是不提供 setter 的;另外也要根據使用者定義的值是函式或者物件來對計算屬性重新定義 getter 和 setter。從這段程式碼裡可以看出一個非常重要的程式,即在獲取計算屬性的時候才去計算它的值,這正是懶載入的實現。
initMethods
// 定義initMethods方法,接受例項vm,配置屬性methods function initMethods (vm: Component, methods: Object) { // 獲取例項的props const props = vm.$options.props // 遍歷methods物件 for (const key in methods) { // 非生產環境下給出警告 if (process.env.NODE_ENV !== 'production') { // 未賦值方法警告 if (methods[key] == null) { warn( `Method "${key}" has an undefined value in the component definition. ` + `Did you reference the function correctly?`, vm ) } // 與props屬性名衝突警告 if (props && hasOwn(props, key)) { warn( `Method "${key}" has already been defined as a prop.`, vm ) } // 與保留字衝突警告 if ((key in vm) && isReserved(key)) { warn( `Method "${key}" conflicts with an existing Vue instance method. ` + `Avoid defining component methods that start with _ or $.` ) } } // 在例項上定義方法,賦值為使用者未定義函式或空函式 vm[key] = methods[key] == null ? noop : bind(methods[key], vm) } } 複製程式碼
initMethods
函式非常簡單,除了一大段在非生產環境裡報告檢查衝突的程式碼,唯一的內容就是在例項上定義相應的方法並且把上下文繫結到例項物件上,這樣即便不是使用箭頭函式,在方法內也預設用 this 指代了例項物件。
initWatch
// 定義initWatch函式,接受例項vm和配置屬性watch function initWatch (vm: Component, watch: Object) { // 遍歷watch for (const key in watch) { // 暫存屬性的值 const handler = watch[key] // 如果handler是陣列 if (Array.isArray(handler)) { // 遍歷陣列為每一個元素建立相應watcher for (let i = 0; i < handler.length; i++) { createWatcher(vm, key, handler[i]) } } else { // 竇否則handler應該是函式,直接為key建立watcher createWatcher(vm, key, handler) } } } // 定義createWatcher函式 // 接受例項vm、表示式或函式expOrFn,處理器handler,可選的options function createWatcher ( vm: Component, expOrFn: string | Function, handler: any, options?: Object ) { // 如果handler是物件 if (isPlainObject(handler)) { // 將handler賦值給options. options = handler // 重新賦值handler handler = handler.handler } // 如果handler是字串,在例項上尋找handler並賦值給handler if (typeof handler === 'string') { handler = vm[handler] } // 建立觀察並返回 return vm.$watch(expOrFn, handler, options) } 複製程式碼
initWatcher
為傳入的觀察物件建立監視器,比較簡單。值得注意的是引數的傳入型別,觀察物件 expOrFn
可以有兩種方式,一種是字串,一種是函式,在 Watcher
類中對此引數進行了檢測,而在初始化的函式裡不對它做任何處理。 handler
物件也可以接受物件或字串型別,在程式碼中對這兩種傳入方式做判斷,最終找到handler引用的函式傳入 $watch
。
stateMixin
探索完了 initState
函式之後,繼續來看看 state
混入的方法 stateMixin
,在這個函式裡會提供上面還未曾提到的 $watch
方法的具體實現:
// 定義並匯出stateMixin函式,接收引數Vue export function stateMixin (Vue: Class<Component>) { // 使用 Object.defineProperty 方法直接宣告定義物件時,flow會發生問題 // 所以必須在此程式化定義物件 // flow somehow has problems with directly declared definition object // when using Object.defineProperty, so we have to procedurally build up // the object here. // 定義dataDef物件 const dataDef = {} // 定義dataDef的get方法,返回Vue例項私有屬性_data dataDef.get = function () { return this._data } // 定義propsDef物件 const propsDef = {} // 定義propsDef的get方法,返回Vue例項私有屬性_props propsDef.get = function () { return this._props } // 非生產環境下,定義dataDef和propsDef的set方法 if (process.env.NODE_ENV !== 'production') { // dataDef的set方法接收Object型別的newData形參 dataDef.set = function (newData: Object) { // 提示避免傳入物件覆蓋屬性$data // 推薦使用巢狀的資料屬性代替 warn( 'Avoid replacing instance root $data. ' + 'Use nested data properties instead.', this ) } // 設定propsDef的set方法為只讀 propsDef.set = function () { warn(`$props is readonly.`, this) } } // 定義Vue原型物件公共屬性$data,並賦值為dataDef Object.defineProperty(Vue.prototype, '$data', dataDef) // 定義Vue原型物件公共屬性$props,並賦值為propsDef Object.defineProperty(Vue.prototype, '$props', propsDef) // 定義Vue原型物件的$set方法,並賦值為從觀察者匯入的set函式 Vue.prototype.$set = set // 定義Vue原型物件的$delete方法,並賦值為從觀察者匯入的del函式 Vue.prototype.$delete = del // 定義Vue原型物件的$watch方法 // 接收字串或函式型別的expOrFn,從命名中可看出希望為表示式或函式 // 接收任何型別的cb,這裡希望為回撥函式或者是一個物件 // 接收物件型別的options // 要求返回函式型別 Vue.prototype.$watch = function ( expOrFn: string | Function, cb: any, options?: Object ): Function { // 把例項賦值給vm變數,型別需為Component const vm: Component = this // 如果cb是純粹的物件型別 if (isPlainObject(cb)) { // 返回createWatcher函式 return createWatcher(vm, expOrFn, cb, options) } // 定義觀察目標的options,大多數情況下為undefined options = options || {} // 定義options的user屬性值為true,標識為使用者定義 options.user = true // 建立watcher例項 const watcher = new Watcher(vm, expOrFn, cb, options) // 如果options的immediate為真 if (options.immediate) { // 在vm上呼叫cb回撥函式,並傳入watcher.value作為引數 cb.call(vm, watcher.value) } // 返回unwatchFn函式 return function unwatchFn () { // 執行watcher.teardown()方法清除觀察 watcher.teardown() } } } 複製程式碼
stateMixin執行的是關於狀態觀察的一系列方法的混入,主要是三個方面:
- 定義例項props 屬性的存取器
- 定義例項的delete 方法,具體實在定義在觀察者模組中
- 定義例項的 $watch 方法
到這裡,關於狀態初始化的部分就探索完畢了,接下來要繼續研究另一個與開發過程緊密關聯的部分——虛擬節點和模板渲染。
狀態初始化是與我們在開發的時候最息息相關的部分,在建立例項物件的配置物件中,我們設定了這些屬性和方法,例項初始化的過程中對這些傳入的配置進行了很多預先的處理,這就是狀態初始化背後的邏輯。在探索到這一部分的時候才真正的感到,終於與平時的開發關聯起來了。