Vue的data、computed、watch源碼淺談
導(dǎo)讀
記得初學(xué)Vue源碼的時候,在defineReactive、Observer、Dep、Watcher等等內(nèi)部設(shè)計(jì)源碼之間跳來跳去,發(fā)現(xiàn)再也繞不出來了。Vue發(fā)展了很久,很多fix和feature的增加讓內(nèi)部源碼越來越龐大,太多的邊界情況和優(yōu)化設(shè)計(jì)掩蓋了原本精簡的代碼設(shè)計(jì),讓新手閱讀源碼變得越來越困難,但是面試的時候,Vue的響應(yīng)式原理幾乎成了Vue技術(shù)棧的公司面試中高級前端必問的點(diǎn)之一。
這篇文章通過自己實(shí)現(xiàn)一個響應(yīng)式系統(tǒng),盡量還原和Vue內(nèi)部源碼同樣結(jié)構(gòu),但是剔除掉和渲染、優(yōu)化等等相關(guān)的代碼,來最低成本的學(xué)習(xí)Vue的響應(yīng)式原理。
預(yù)覽
源碼地址(ts):
https://github.com/sl1673495/vue-reactive
源碼地址(js)
https://github.com/sl1673495/vue-reactive/tree/js-version
預(yù)覽地址:
https://sl1673495.github.io/vue-reactive/
reactive
Vue最常用的就是響應(yīng)式的data了,通過在vue中定義
new Vue({
data() {
return {
msg: 'Hello World'
}
}
})
在data發(fā)生改變的時候,視圖也會更新,在這篇文章里我把對data部分的處理單獨(dú)提取成一個api:reactive,下面來一起實(shí)現(xiàn)這個api。
要實(shí)現(xiàn)的效果:
const data = reactive({
msg: 'Hello World',
})
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
在data.msg發(fā)生改變的時候,我們需要這個app節(jié)點(diǎn)的innerHTML同步更新,這里新增加了一個概念Watcher,這也是Vue源碼內(nèi)部的一個設(shè)計(jì),想要實(shí)現(xiàn)響應(yīng)式的系統(tǒng),這個Watcher是必不可缺的。
在實(shí)現(xiàn)這兩個api之前,我們先來理清他們之間的關(guān)系,reactive這個api定義了一個響應(yīng)式的數(shù)據(jù),其實(shí)大家都知道響應(yīng)式的數(shù)據(jù)就是在它的某個屬性(比如例中的data.msg)被讀取的時候,記錄下來這時候是誰在讀取他,讀取他的這個函數(shù)肯定依賴它。
在本例中,下面這段函數(shù),因?yàn)樽x取了data.msg并且展示在頁面上,所以可以說這段渲染函數(shù)依賴了data.msg。
// 渲染函數(shù)
document.getElementById('app').innerHTML = `msg is ${data.msg}`
這也就解釋清了,為什么我們需要用new Watcher來傳入這段渲染函數(shù),我們已經(jīng)可以分析出來Watcher是幫我們記錄下來這段渲染函數(shù)依賴的關(guān)鍵。
在js引擎執(zhí)行渲染函數(shù)的途中,突然讀到了data.msg,data已經(jīng)被定義成了響應(yīng)式數(shù)據(jù),讀取data.msg時所觸發(fā)的get函數(shù)已經(jīng)被我們劫持,這個get函數(shù)中我們?nèi)ビ涗浵耫ata.msg被這個渲染函數(shù)所依賴,然后再返回data.msg的值。
這樣下次data.msg發(fā)生變化的時候,Watcher內(nèi)部所做的一些邏輯就會通知到渲染函數(shù)去重新執(zhí)行。這不就是響應(yīng)式的原理嘛。
下面開始實(shí)現(xiàn)代碼
import Dep from './dep'
import { isObject } from '../utils'
// 將對象定義為響應(yīng)式
export default function reactive(data) {
if (isObject(data)) {
Object.keys(data).forEach(key => {
defineReactive(data, key)
})
}
return data
}
function defineReactive(data, key) {
let val = data[key]
// 收集依賴
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
dep.depend()
return val
},
set(newVal) {
val = newVal
dep.notify()
}
})
if (isObject(val)) {
reactive(val)
}
}
代碼很簡單,就是去遍歷data的key,在defineReactive函數(shù)中對每個key進(jìn)行g(shù)et和set的劫持,Dep是一個新的概念,它主要用來做上面所說的dep.depend()去收集當(dāng)前正在運(yùn)行的渲染函數(shù)和dep.notify() 觸發(fā)渲染函數(shù)重新執(zhí)行。
可以把dep看成一個收集依賴的小筐,每當(dāng)運(yùn)行渲染函數(shù)讀取到data的某個key的時候,就把這個渲染函數(shù)丟到這個key自己的小筐中,在這個key的值發(fā)生改變的時候,去key的筐中找到所有的渲染函數(shù)再執(zhí)行一遍。
Dep
export default class Dep {
constructor() {
this.deps = new Set()
}
depend() {
if (Dep.target) {
this.deps.add(Dep.target)
}
}
notify() {
this.deps.forEach(watcher => watcher.update())
}
}
// 正在運(yùn)行的watcher
Dep.target = null
這個類很簡單,利用Set去做存儲,在depend的時候把Dep.target加入到deps集合里,在notify的時候遍歷deps,觸發(fā)每個watcher的update。
沒錯Dep.target這個概念也是Vue中所引入的,它是一個掛在Dep類上的全局變量,js是單線程運(yùn)行的,所以在渲染函數(shù)如:
document.getElementById('app').innerHTML = `msg is ${data.msg}`
運(yùn)行之前,先把全局的Dep.target設(shè)置為存儲了這個渲染函數(shù)的watcher,也就是:
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
這樣在運(yùn)行途中data.msg就可以通過Dep.target找到當(dāng)前是哪個渲染函數(shù)的watcher正在運(yùn)行,這樣也就可以把自身對應(yīng)的依賴所收集起來了。
這里劃重點(diǎn):Dep.target一定是一個Watcher的實(shí)例。
又因?yàn)殇秩竞瘮?shù)可以是嵌套運(yùn)行的,比如在Vue中每個組件都會有自己用來存放渲染函數(shù)的一個watcher,那么在下面這種組件嵌套組件的情況下:
// Parent組件 <template> <div> <Son組件 /> </div> </template>
watcher的運(yùn)行路徑就是: 開始 -> ParentWatcher -> SonWatcher -> ParentWatcher -> 結(jié)束。
是不是特別像函數(shù)運(yùn)行中的入棧出棧,沒錯,Vue內(nèi)部就是用了棧的數(shù)據(jù)結(jié)構(gòu)來記錄watcher的運(yùn)行軌跡。
// watcher棧
const targetStack = []
// 將上一個watcher推到棧里,更新Dep.target為傳入的_target變量。
export function pushTarget(_target) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}
// 取回上一個watcher作為Dep.target,并且棧里要彈出上一個watcher。
export function popTarget() {
Dep.target = targetStack.pop()
}
有了這些輔助的工具,就可以來看看Watcher的具體實(shí)現(xiàn)了
import Dep, { pushTarget, popTarget } from './dep'
export default class Watcher {
constructor(getter) {
this.getter = getter
this.get()
}
get() {
pushTarget(this)
this.value = this.getter()
popTarget()
return this.value
}
update() {
this.get()
}
}
回顧一下開頭示例中Watcher的使用。
const data = reactive({
msg: 'Hello World',
})
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
傳入的getter函數(shù)就是
() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
}
在構(gòu)造函數(shù)中,記錄下getter函數(shù),并且執(zhí)行了一遍get
get() {
pushTarget(this)
this.value = this.getter()
popTarget()
return this.value
}
在這個函數(shù)中,this就是這個watcher實(shí)例,在執(zhí)行g(shù)et的開頭先把這個存儲了渲染函數(shù)的watcher設(shè)置為當(dāng)前的Dep.target,然后執(zhí)行this.getter()也就是渲染函數(shù)
在執(zhí)行渲染函數(shù)的途中讀取到了data.msg,就觸發(fā)了defineReactive函數(shù)中劫持的get:
Object.defineProperty(data, key, {
get() {
dep.depend()
return val
}
})
這時候的dep.depend函數(shù):
depend() {
if (Dep.target) {
this.deps.add(Dep.target)
}
}
所收集到的Dep.target,就是在get函數(shù)開頭中pushTarget(this)所收集的
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
這個watcher實(shí)例了。
此時我們假如執(zhí)行了這樣一段賦值代碼:
data.msg = 'ssh'
就會運(yùn)行到劫持的set函數(shù)里:
Object.defineProperty(data, key, {
set(newVal) {
val = newVal
dep.notify()
}
})
此時在控制臺中打印出dep這個變量,它內(nèi)部的deps屬性果然存儲了一個Watcher的實(shí)例。

運(yùn)行了dep.notify以后,就會觸發(fā)這個watcher的update方法,也就會再去重新執(zhí)行一遍渲染函數(shù)了,這個時候視圖就刷新了。
computed
在實(shí)現(xiàn)了reactive這個基礎(chǔ)api以后,就要開始實(shí)現(xiàn)computed這個api了,這個api的用法是這樣:
const data = reactive({
number: 1
})
const numberPlusOne = computed(() => data.number + 1)
// 渲染函數(shù)watcher
new Watcher(() => {
document.getElementById('app2').innerHTML = `
computed: 1 + number 是 ${numberPlusOne.value}
`
})
vue內(nèi)部是把computed屬性定義在vm實(shí)例上的,這里我們沒有實(shí)例,所以就用一個對象來存儲computed的返回值,用.value來拿computed的真實(shí)值。
這里computed傳入的其實(shí)還是一個函數(shù),這里我們回想一下Watcher的本質(zhì),其實(shí)就是存儲了一個需要在特定時機(jī)觸發(fā)的函數(shù),在Vue內(nèi)部,每個computed屬性也有自己的一個對應(yīng)的watcher實(shí)例,下文中叫它c(diǎn)omputedWatcher
先看渲染函數(shù):
// 渲染函數(shù)watcher
new Watcher(() => {
document.getElementById('app2').innerHTML = `
computed: 1 + number 是 ${numberPlusOne.value}
`
})
這段渲染函數(shù)執(zhí)行過程中,讀取到numberPlusOne的值的時候
首先會把Dep.target設(shè)置為numberPlusOne所對應(yīng)的computedWatcher
computedWatcher的特殊之處在于
- 渲染watcher只能作為依賴被收集到其他的dep筐子里,而computedWatcher實(shí)例上有屬于自己的dep,它可以收集別的watcher作為自己的依賴。
- 惰性求值,初始化的時候先不去運(yùn)行g(shù)etter。
export default class Watcher {
constructor(getter, options = {}) {
const { computed } = options
this.getter = getter
this.computed = computed
if (computed) {
this.dep = new Dep()
} else {
this.get()
}
}
}
其實(shí)computed實(shí)現(xiàn)的本質(zhì)就是,computed在讀取value之前,Dep.target肯定此時是正在運(yùn)行的渲染函數(shù)的watcher。
先把當(dāng)前正在運(yùn)行的渲染函數(shù)的watcher作為依賴收集到computedWatcher內(nèi)部的dep筐子里。
把自身computedWatcher設(shè)置為 全局Dep.target,然后開始求值:
求值函數(shù)會在運(yùn)行() => data.number + 1的途中遇到data.number的讀取,這時又會觸發(fā)'number'這個key的劫持get函數(shù),這時全局的Dep.target是computedWatcher,data.number的dep依賴筐子里丟進(jìn)去了computedWatcher。
此時的依賴關(guān)系是 data.number的dep筐子里裝著computedWatcher,computedWatcher的dep筐子里裝著渲染watcher。
此時如果更新data.number的話,會一級一級往上觸發(fā)更新。會觸發(fā)computedWatcher的update,我們肯定會對被設(shè)置為computed特性的watcher做特殊的處理,這個watcher的筐子里裝著渲染watcher,所以只需要觸發(fā) this.dep.notify(),就會觸發(fā)渲染watcher的update方法,從而更新視圖。
下面來改造代碼:
// Watcher
import Dep, { pushTarget, popTarget } from './dep'
export default class Watcher {
constructor(getter, options = {}) {
const { computed } = options
this.getter = getter
this.computed = computed
if (computed) {
this.dep = new Dep()
} else {
this.get()
}
}
get() {
pushTarget(this)
this.value = this.getter()
popTarget()
return this.value
}
// 僅為computed使用
depend() {
this.dep.depend()
}
update() {
if (this.computed) {
this.get()
this.dep.notify()
} else {
this.get()
}
}
}
computed初始化:
// computed
import Watcher from './watcher'
export default function computed(getter) {
let def = {}
const computedWatcher = new Watcher(getter, { computed: true })
Object.defineProperty(def, 'value', {
get() {
// 先讓computedWatcher收集渲染watcher作為自己的依賴。
computedWatcher.depend()
return computedWatcher.get()
}
})
return def
}
這里的邏輯比較繞,如果沒理清楚的話可以把代碼下載下來一步步斷點(diǎn)調(diào)試,data.number被劫持的set觸發(fā)以后,可以看一下number的dep到底存了什么。

watch
watch的使用方式是這樣的:
watch(
() => data.msg,
(newVal, oldVal) => {
console.log('newVal: ', newVal)
console.log('old: ', oldVal)
}
)
傳入的第一個參數(shù)是個函數(shù),里面需要讀取到響應(yīng)式的屬性,確保依賴能被收集到,這樣下次這個響應(yīng)式的屬性發(fā)生改變后,就會打印出對飲的新值和舊值。
分析一下watch的實(shí)現(xiàn)原理,這里依然是利用Watcher類去實(shí)現(xiàn),我們把用于watch的watcher叫做watchWatcher,傳入的getter函數(shù)也就是() => data.msg,Watcher在執(zhí)行它之前還是一樣會把自身(也就是watchWatcher)設(shè)為Dep.target,這時讀到data.msg,就會把watchWatcher丟進(jìn)data.msg的依賴筐子里。
如果data.msg更新了,則就會觸發(fā)watchWatcher的update方法
直接上代碼:
// watch
import Watcher from './watcher'
export default function watch(getter, callback) {
new Watcher(getter, { watch: true, callback })
}
沒錯又是直接用了getter,只是這次傳入的選項(xiàng)是{ watch: true, callback },接下來看看Watcher內(nèi)部進(jìn)行了什么處理:
export default class Watcher {
constructor(getter, options = {}) {
const { computed, watch, callback } = options
this.getter = getter
this.computed = computed
this.watch = watch
this.callback = callback
this.value = undefined
if (computed) {
this.dep = new Dep()
} else {
this.get()
}
}
}
首先是構(gòu)造函數(shù)中,對watch選項(xiàng)和callback進(jìn)行了保存,其他沒變。
然后在update方法中。
update() {
if (this.computed) {
...
} else if (this.watch) {
const oldValue = this.value
this.get()
this.callback(oldValue, this.value)
} else {
...
}
}
在調(diào)用this.get去更新值之前,先把舊值保存起來,然后把新值和舊值一起通過調(diào)用callback函數(shù)交給外部,就這么簡單。
我們僅僅是改動寥寥幾行代碼,就輕松實(shí)現(xiàn)了非常重要的api:watch。
總結(jié)。
有了精妙的Watcher和Dep的設(shè)計(jì),Vue內(nèi)部的響應(yīng)式api實(shí)現(xiàn)的非常簡單,不得不再次感嘆一下尤大真是厲害?。?br />
到此這篇關(guān)于Vue的data、computed、watch源碼淺談的文章就介紹到這了,更多相關(guān)Vue data、computed、watch源碼內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
使用vue3-print-nb實(shí)現(xiàn)打印pdf分頁代碼示例
這篇文章主要介紹了使用vue3-print-nb實(shí)現(xiàn)打印pdf分頁的相關(guān)資料,這種方法不僅適用于Vue3項(xiàng)目,也可以在其他前端框架中實(shí)現(xiàn)類似的打印分頁功能,需要的朋友可以參考下2024-10-10
vue?element-plus圖片預(yù)覽實(shí)現(xiàn)方法
這篇文章主要給大家介紹了關(guān)于vue?element-plus圖片預(yù)覽實(shí)現(xiàn)的相關(guān)資料,最近的項(xiàng)目中有圖片預(yù)覽的場景,也是踩了一些坑,在這里總結(jié)一下,需要的朋友可以參考下2023-07-07
Vue實(shí)現(xiàn)實(shí)時更新sessionStorage數(shù)據(jù)的示例代碼
這篇文章主要為大家詳細(xì)介紹了Vue如何實(shí)現(xiàn)實(shí)時更新sessionStorage數(shù)據(jù),文中的示例代碼講解詳細(xì),具有一定的參考價值,需要的可以參考一下2023-06-06
Vue3中Slot插槽透傳,二次封裝Arco的table組件詳解
這篇文章主要介紹了Vue3中Slot插槽透傳,二次封裝Arco的table組件,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2025-04-04

