之前的章節,我們按照流程介紹了vue
的初始化、虛擬Dom
生成、虛擬Dom
轉為真實Dom
、深入理解響應式以及diff
演算法等這些核心概念,對它內部的實現做了分析,這些都是偏底層的原理。接下來我們將介紹日常開發中經常使用的API
的原理,進一步豐富對vue
的認識,它們主要包括以下:
響應式相關
API
:this.$watch
、this.$set
、this.$delete
事件相關
API
:this.$on
、this.$off
、this.$once
、this.$emit
生命週期相關
API
:this.$mount
、this.$forceUpdate
、this.$destroy
全域性
API
:Vue.extend
、Vue.nextTick
、Vue.set
、Vue.delete
、Vue.component
、Vue.use
、Vue.mixin
、Vue.compile
、Vue.version
、Vue.directive
、Vue.filter
這一章節主要分析computed
和watch
屬性,對於接觸vue
不久的朋友可能會對computed
和watch
有疑惑,什麼時候使用哪個屬性留有存疑,接下來我們將從內部實現的角度出發,徹底搞懂它們分別適用的場景。
-
this.$watch
這個API
是我們之前介紹響應式時的Watcher
類的一種封裝,也就是三種watcher
中的user-watcher
,監聽屬性經常會被這樣使用到:
export default {
watch: {
name(newName) {...}
}
}
複製程式碼
其實它只是this.$watch
這個API
的一種封裝:
export default {
created() {
this.$watch('name', newName => {...})
}
}
複製程式碼
監聽屬性初始化
為什麼這麼說,我們首先來看下初始化時watch
屬性都做了什麼:
function initState(vm) { // 初始化所有狀態時
vm._watchers = [] // 當前例項watcher集合
const opts = vm.$options // 合併後的屬性
... // 其他狀態初始化
if(opts.watch) { // 如果有定義watch屬性
initWatch(vm, opts.watch) // 執行初始化方法
}
}
---------------------------------------------------------
function initWatch (vm, watch) { // 初始化方法
for (const key in watch) { // 遍歷watch內多個監聽屬性
const handler = watch[key] // 每一個監聽屬性的值
if (Array.isArray(handler)) { // 如果該項的值為陣列
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i]) // 將每一項使用watcher包裝
}
} else {
createWatcher(vm, key, handler) // 不是陣列直接使用watcher
}
}
}
---------------------------------------------------------
function createWatcher (vm, expOrFn, handler, options) {
if (isPlainObject(handler)) { // 如果是物件,引數移位
options = handler
handler = handler.handler
}
if (typeof handler === 'string') { // 如果是字串,表示為方法名
handler = vm[handler] // 獲取methods內的方法
}
return vm.$watch(expOrFn, handler, options) // 封裝
}
複製程式碼
以上對監聽屬性的多種不同的使用方式,都做了處理。使用示例在官網上均可找到:watch示例,這裡就不做過多的介紹了。可以看到最後是呼叫了vm.$watch
方法。
監聽屬性實現原理
所以我們來看下$watch
的內部實現:
Vue.prototype.$watch = function(expOrFn, cb, options = {}) {
const vm = this
if (isPlainObject(cb)) { // 如果cb是物件,當手動建立監聽屬性時
return createWatcher(vm, expOrFn, cb, options)
}
options.user = true // user-watcher的標誌位,傳入Watcher類中
const watcher = new Watcher(vm, expOrFn, cb, options) // 例項化user-watcher
if (options.immediate) { // 立即執行
cb.call(vm, watcher.value) // 以當前值立即執行一次回撥函式
} // watcher.value為例項化後返回的值
return function unwatchFn () { // 返回一個函式,執行取消監聽
watcher.teardown()
}
}
---------------------------------------------------------------
export default {
data() {
return {
name: 'cc'
}
},
created() {
this.unwatch = this.$watch('name', newName => {...})
this.unwatch() // 取消監聽
}
}
複製程式碼
雖然watch
內部是使用this.$watch
,但是我們也是可以手動呼叫this.$watch
來建立監聽屬性的,所以第二個引數cb
會出現是物件的情況。接下來設定一個標記位options.user
為true
,表明這是一個user-watcher
。再給watch
設定了immediate
屬性後,會將例項化後得到的值傳入回撥,並立即執行一次回撥函式,這也是immediate的實現原理。最後的返回值是一個方法,執行後可以取消對該監聽屬性的監聽。接下來我們看看user-watcher
是如何定義的:
class Watcher {
constructor(vm, expOrFn, cb, options) {
this.vm = vm
vm._watchers.push(this) // 新增到當前例項的watchers內
if(options) {
this.deep = !!options.deep // 是否深度監聽
this.user = !!options.user // 是否是user-wathcer
this.sync = !!options.sync // 是否同步更新
}
this.active = true // // 派發更新的標誌位
this.cb = cb // 回撥函式
if (typeof expOrFn === 'function') { // 如果expOrFn是函式
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn) // 如果是字串物件路徑形式,返回閉包函式
}
...
}
}
複製程式碼
當是user-watcher
時,Watcher
內部是以上方式例項化的,通常情況下我們是使用字串的形式建立監聽屬性,所以首先來看下parsePath
方法是幹什麼的:
const bailRE = /[^\w.$]/ // 得是物件路徑形式,如info.name
function parsePath (path) {
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
}
}
複製程式碼
parsePath
方法最終返回一個閉包方法,此時Watcher
類中的this.getter
就是一個函式了,再執行this.get()
方法時會將this.vm
傳入到閉包內,補全Watcher
其他的邏輯:
class Watcher {
constructor(vm, expOrFn, cb, options) {
...
this.getter = parsePath(expOrFn) // 返回的方法
this.value = this.get() // 執行get
}
get() {
pushTarget(this) // 將當前user-watcher例項賦值給Dep.target,讀取時收集它
let value = this.getter.call(this.vm, this.vm) // 將vm例項傳給閉包,進行讀取操作
if (this.deep) { // 如果有定義deep屬性
traverse(value) // 進行深度監聽
}
popTarget()
return value // 返回閉包讀取到的值,引數immediate使用的就是這裡的值
}
...
}
複製程式碼
因為之前初始化已經將狀態已經全部都代理到了this
下,所以讀取this
下的屬性即可,比如:
export default {
data() { // data的初始化先與watch
return {
info: {
name: 'cc'
}
}
},
created() {
this.$watch('info.name', newName => {...}) // 何況手動建立
}
}
複製程式碼
首先讀取this
下的info
屬性,然後讀取info
下的name
屬性。大家注意,這裡我們使用了讀取這個動詞,所以會執行之前包裝data
響應式資料的get
方法進行依賴收集,將依賴收集到讀取到的屬性的dep
裡,不過收集的是user-watcher
,get
方法最後返回閉包讀取到的值。
之後就是當info.name
屬性被重新賦值時,走派發更新的流程,我們這裡把和render-watcher
不同之處做單獨的說明,派發更新會執行Watcher
內的update
方法內:
class Watcher {
constructor(vm, expOrFn, cb, options) {
...
}
update() { // 執行派發更新
if(this.sync) { // 如果有設定sync為true
this.run() // 不走nextTick佇列,直接執行
} else {
queueWatcher(this) // 否則加入佇列,非同步執行run()
}
}
run() {
if (this.active) {
this.getAndInvoke(this.cb) // 傳入回撥函式
}
}
getAndInvoke(cb) {
const value = this.get() // 重新求值
if(value !== this.value || isObject(value) || this.deep) {
const oldValue = this.value // 快取之前的值
this.value = value // 新值
if(this.user) { // 如果是user-watcher
cb.call(this.vm, value, oldValue) // 在回撥內傳入新值和舊值
}
}
}
}
複製程式碼
其實這裡的sync
屬性已經沒在官網做說明了,不過我們看到原始碼中還是保留了相關程式碼。接下來我們看到為什麼watch
的回撥內可以得到新值和舊值的原理,因為cb.call(this.vm, value, oldValue)
這句程式碼的原因,內部將新值和舊值傳給了回撥函式。
watch監聽屬性示例:
<template>
<div>{{name}}</div>
</template>
export default { // App元件
data() {
return {
name: 'cc'
}
},
watch: {
name(newName, oldName) {...} // 派發新值和舊值給回撥
},
mounted() {
setTimeout(() => {
this.name = 'ww' // 觸發name的set
}, 1000)
}
}
複製程式碼
監聽屬性的
deep
深度監聽原理
之前的get
方法內有說明,如果有deep
屬性,則執行traverse
方法:
const seenObjects = new Set() // 不重複新增
function traverse (val) {
_traverse(val, seenObjects)
seenObjects.clear()
}
function _traverse (val, seen) {
let i, keys
const isA = Array.isArray(val) // val是否是陣列
if ((!isA && !isObject(val)) // 如果不是array和object
|| Object.isFrozen(val) // 或者是已經凍結物件
|| val instanceof VNode) { // 或者是VNode例項
return // 再見
}
if (val.__ob__) { // 只有object和array才有__ob__屬性
const depId = val.__ob__.dep.id // 手動依賴收集器的id
if (seen.has(depId)) { // 已經有收集過
return // 再見
}
seen.add(depId) // 沒有被收集,新增
}
if (isA) { // 是array
i = val.length
while (i--) {
_traverse(val[i], seen) // 遞迴觸發每一項的get進行依賴收集
}
}
else { // 是object
keys = Object.keys(val)
i = keys.length
while (i--) {
_traverse(val[keys[i]], seen) // 遞迴觸發子屬性的get進行依賴收集
}
}
}
複製程式碼
看著還挺複雜,簡單來說deep
的實現原理就是遞迴的觸發陣列或物件的get
進行依賴收集,因為只有陣列和物件才有__ob__
屬性,也就是我們第七章說明的手動依賴管理器,將它們的依賴收集到Observer
類裡的dep
內,完成deep
深度監聽。
watch
總結:這裡說明了為什麼watch
和this.$watch
的實現是一致的,以及簡單解釋它的原理就是為需要觀察的資料建立並收集user-watcher
,當資料改變時通知到user-watcher
將新值和舊值傳遞給使用者自己定義的回撥函式。最後分析了定義watch
時會被使用到的三個引數:sync
、immediate
、deep
它們的實現原理。簡單說明它們的實現原理就是:sync
是不將watcher
加入到nextTick
佇列而同步的更新、immediate
是立即以得到的值執行一次回撥函式、deep
是遞迴的對它的子值進行依賴收集。
-
this.$set
這個API
已經在第七章的最後做了具體分析,大家可以前往this.$set實現原理查閱。
-
this.$delete
這個API
也已經在第七章的最後做了具體分析,大家可以前往this.$delete實現原理查閱。
-
computed計算屬性
計算屬性不是API
,但它是Watcher
類的最後也是最複雜的一種例項化的使用,還是很有必要分析的。(vue
版本2.6.10)其實主要就是分析計算屬性為何可以做到當它的依賴項發生改變時才會進行重新的計算,否則當前資料是被快取的。計算屬性的值可以是物件,這個物件需要傳入get
和set
方法,這種並不常用,所以這裡的分析還是介紹常用的函式形式,它們之間是大同小異的,不過可以減少認知負擔,聚焦核心原理實現。
export default {
computed: {
newName: { // 不分析這種了~
get() {...}, // 內部會採用get屬性為計算屬性的值
set() {...}
}
}
}
複製程式碼
計算屬性初始化
function initState(vm) { // 初始化所有狀態時
vm._watchers = [] // 當前例項watcher集合
const opts = vm.$options // 合併後的屬性
... // 其他狀態初始化
if(opts.computed) { // 如果有定義計算屬性
initComputed(vm, opts.computed) // 進行初始化
}
...
}
---------------------------------------------------------------------------
function initComputed(vm, computed) {
const watchers = vm._computedWatchers = Object.create(null) // 建立一個純淨物件
for(const key in computed) {
const getter = computed[key] // computed每項對應的回撥函式
watchers[key] = new Watcher(vm, getter, noop, {lazy: true}) // 例項化computed-watcher
...
}
}
複製程式碼
計算屬性實現原理
這裡還是按照慣例,將定義的computed
屬性的每一項使用Watcher
類進行例項化,不過這裡是按照computed-watcher
的形式,來看下如何例項化的:
class Watcher{
constructor(vm, expOrFn, cb, options) {
this.vm = vm
this._watchers.push(this)
if(options) {
this.lazy = !!options.lazy // 表示是computed
}
this.dirty = this.lazy // dirty為標記位,表示是否對computed計算
this.getter = expOrFn // computed的回撥函式
this.value = undefined
}
}
複製程式碼
這裡就點到為止,例項化已經結束了。並沒有和之前render-watcher
以及user-watcher
那般,執行get
方法,這是為什麼?我們接著分析為何如此,補全之前初始化computed
的方法:
function initComputed(vm, computed) {
...
for(const key in computed) {
const getter = computed[key] // // computed每項對應的回撥函式
...
if (!(key in vm)) {
defineComputed(vm, key, getter)
}
... key不能和data裡的屬性重名
... key不能和props裡的屬性重名
}
}
複製程式碼
這裡的App
元件在執行extend
建立子元件的建構函式時,已經將key
掛載到vm
的原型中了,不過之前也是執行的defineComputed
方法,所以不妨礙我們看它做了什麼:
function defineComputed(target, key) {
...
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get: createComputedGetter(key),
set: noop
})
}
複製程式碼
這個方法的作用就是讓computed
成為一個響應式資料,並定義它的get
屬性,也就是說當頁面執行渲染訪問到computed
時,才會觸發get
然後執行createComputedGetter
方法,所以之前的點到為止再這裡會續上,看下get
方法是怎麼定義的:
function createComputedGetter (key) { // 高階函式
return function () { // 返回函式
const watcher = this._computedWatchers && this._computedWatchers[key]
// 原來this還可以這樣用,得到key對應的computed-watcher
if (watcher) {
if (watcher.dirty) { // 在例項化watcher時為true,表示需要計算
watcher.evaluate() // 進行計算屬性的求值
}
if (Dep.target) { // 當前的watcher,這裡是頁面渲染觸發的這個方法,所以為render-watcher
watcher.depend() // 收集當前watcher
}
return watcher.value // 返回求到的值或之前快取的值
}
}
}
------------------------------------------------------------------------------------
class Watcher {
...
evaluate () {
this.value = this.get() // 計算屬性求值
this.dirty = false // 表示計算屬性已經計算,不需要再計算
}
depend () {
let i = this.deps.length // deps內是計算屬性內能訪問到的響應式資料的dep的陣列集合
while (i--) {
this.deps[i].depend() // 讓每個dep收集當前的render-watcher
}
}
}
複製程式碼
這裡的變數watcher
就是之前computed
對應的computed-watcher
例項,接下來會執行Watcher
類專門為計算屬性定義的兩個方法,在執行evaluate
方法進行求值的過程中又會觸發computed
內可以訪問到的響應式資料的get
,它們會將當前的computed-watcher
作為依賴收集到自己的dep
裡,計算完畢之後將dirty
置為false
,表示已經計算過了。
然後執行depend
讓計算屬性內的響應式資料訂閱當前的render-watcher
,所以computed
內的響應式資料會收集computed-watcher
和render-watcher
兩個watcher
,當computed
內的狀態發生變更觸發set
後,首先通知computed
需要進行重新計算,然後通知到檢視執行渲染,再渲染中會訪問到computed
計算後的值,最後渲染到頁面。
Ps: 計算屬性內的值須是響應式資料才能觸發重新計算。
當computed
內的響應式資料變更後觸發的通知:
class Watcher {
...
update() { // 當computed內的響應式資料觸發set後
if(this.lazy) {
this.diray = true // 通知computed需要重新計算了
}
...
}
}
複製程式碼
最後還是以一個示例結合流程圖來幫大家理清楚這裡的邏輯:
export default {
data() {
return {
manName: "cc",
womanName: "ww"
};
},
computed: {
newName() {
return this.manName + ":" + this.womanName;
}
},
methods: {
changeName() {
this.manName = "ss";
}
}
};
複製程式碼
watch
總結:為什麼計算屬性有快取功能?因為當計算屬性經過計算後,內部的標誌位會表明已經計算過了,再次訪問時會直接讀取計算後的值;為什麼計算屬性內的響應式資料發生變更後,計算屬性會重新計算?因為內部的響應式資料會收集computed-watcher
,變更後通知計算屬性要進行計算,也會通知頁面重新渲染,渲染時會讀取到重新計算後的值。
最後按照慣例我們還是以一道vue
可能會被問到的面試題作為本章的結束~
面試官微笑而又不失禮貌的問道:
- 請問
computed
屬性和watch
屬性分別什麼場景使用?
懟回去:
- 當模板中的某個值需要通過一個或多個資料計算得到時,就可以使用計算屬性,還有計算屬性的函式不接受引數;監聽屬性主要是監聽某個值發生變化後,對新值去進行邏輯處理。
順手點個贊或關注唄,找起來也方便~
參考:
分享一個元件庫給大家,可能會用的上 ~ ↓