記得初學Vue源碼的時候,在defineReactive
、Observer
、Dep
、Watcher
等等內部設計源碼之間跳來跳去,發現再也繞不出來了。Vue發展了好久,不少fix和feature的增長讓內部源碼愈來愈龐大,太多的邊界狀況和優化設計掩蓋了本來精簡的代碼設計,讓新手閱讀源碼變得愈來愈困難,可是面試的時候,Vue的響應式原理幾乎成了Vue技術棧的公司面試中高級前端必問的點之一。前端
這篇文章經過本身實現一個響應式系統,儘可能還原和Vue內部源碼一樣結構,可是剔除掉和渲染、優化等等相關的代碼,來最低成本的學習Vue的響應式原理。vue
源碼地址:
github.com/sl1673495/v…react
預覽地址:
sl1673495.github.io/vue-reactiv…git
Vue最經常使用的就是響應式的data了,經過在vue中定義github
new Vue({
data() {
return {
msg: 'Hello World'
}
}
})
複製代碼
在data發生改變的時候,視圖也會更新,在這篇文章裏我把對data部分的處理單獨提取成一個api:reactive
,下面來一塊兒實現這個api。面試
要實現的效果:api
const data = reactive({
msg: 'Hello World',
})
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製代碼
在data.msg發生改變的時候,咱們須要這個app節點的innerHTML同步更新,這裏新增長了一個概念Watcher
,這也是Vue源碼內部的一個設計,想要實現響應式的系統,這個Watcher
是必不可缺的。bash
在實現這兩個api以前,咱們先來理清他們之間的關係,reactive這個api定義了一個響應式的數據,其實你們都知道響應式的數據就是在它的某個屬性(好比例中的data.msg
)被讀取的時候,記錄下來這時候是誰在讀取他,讀取他的這個函數確定依賴它。 在本例中,下面這段函數,由於讀取了data.msg
而且展現在頁面上,因此能夠說這段渲染函數
依賴了data.msg
。數據結構
// 渲染函數
document.getElementById('app').innerHTML = `msg is ${data.msg}`
複製代碼
這也就解釋清了,爲何咱們須要用new Watcher
來傳入這段渲染函數,咱們已經能夠分析出來Watcher
是幫咱們記錄下來這段渲染函數
依賴的關鍵。app
在js引擎執行渲染函數
的途中,忽然讀到了data.msg
,data
已經被定義成了響應式數據,讀取data.msg
時所觸發的get函數已經被咱們劫持,這個get函數中咱們去記錄下data.msg
被這個渲染函數
所依賴,而後再返回data.msg
的值。
這樣下次data.msg
發生變化的時候,Watcher
內部所作的一些邏輯就會通知到渲染函數
去從新執行。這不就是響應式的原理嘛。
下面開始實現代碼
import Dep from './dep'
import { isObject } from '../utils'
// 將對象定義爲響應式
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
函數中對每一個key進行get和set的劫持,Dep
是一個新的概念,它主要用來作上面所說的dep.depend()
去收集當前正在運行的渲染函數和dep.notify()
觸發渲染函數從新執行。
能夠把dep
當作一個收集依賴的小筐,每當運行渲染函數讀取到data的某個key的時候,就把這個渲染函數丟到這個key本身的小筐中,在這個key的值發生改變的時候,去key的筐中找到全部的渲染函數再執行一遍。
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())
}
}
// 正在運行的watcher
Dep.target = null
複製代碼
這個類很簡單,利用Set去作存儲,在depend的時候把Dep.target加入到deps集合裏,在notify的時候遍歷deps,觸發每一個watcher的update。
沒錯Dep.target這個概念也是Vue中所引入的,它是一個掛在Dep類上的全局變量,js是單線程運行的,因此在渲染函數如:
document.getElementById('app').innerHTML = `msg is ${data.msg}`
複製代碼
運行以前,先把全局的Dep.target設置爲存儲了這個渲染函數的watcher
,也就是:
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製代碼
這樣在運行途中data.msg就能夠經過Dep.target找到當前是哪一個渲染函數的watcher
正在運行,這樣也就能夠把自身對應的依賴所收集起來了。
這裏劃重點:Dep.target必定是一個Watcher
的實例。
又由於渲染函數能夠是嵌套運行的,好比在Vue中每一個組件
都會有本身用來存放渲染函數的一個watcher,那麼在下面這種組件嵌套組件的狀況下:
// Parent組件
<template>
<div>
<Son組件 />
</div>
</template>
複製代碼
watcher的運行路徑就是: 開始 -> ParentWatcher -> SonWatcher -> ParentWatcher -> 結束。
是否是特別像函數運行中的入棧出棧,沒錯,Vue內部就是用了棧的數據結構來記錄watcher的運行軌跡。
// 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
的具體實現了
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函數就是
() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
}
複製代碼
在構造函數中,記錄下getter函數,而且執行了一遍get
get() {
pushTarget(this)
this.value = this.getter()
popTarget()
return this.value
}
複製代碼
在這個函數中,this
就是這個watcher實例,在執行get的開頭先把這個存儲了渲染函數的watcher設置爲當前的Dep.target,而後執行this.getter()也就是渲染函數
在執行渲染函數的途中讀取到了data.msg
,就觸發了defineReactive
函數中劫持的get:
Object.defineProperty(data, key, {
get() {
dep.depend()
return val
}
})
複製代碼
這時候的dep.depend
函數:
depend() {
if (Dep.target) {
this.deps.add(Dep.target)
}
}
複製代碼
所收集到的Dep.target
,就是在get函數開頭中pushTarget(this)
所收集的
new Watcher(() => {
document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製代碼
這個watcher實例了。
此時咱們假如執行了這樣一段賦值代碼:
data.msg = 'ssh'
複製代碼
就會運行到劫持的set函數裏:
Object.defineProperty(data, key, {
set(newVal) {
val = newVal
dep.notify()
}
})
複製代碼
此時在控制檯中打印出dep這個變量,它內部的deps屬性果真存儲了一個Watcher的實例。
運行了dep.notify
之後,就會觸發這個watcher的update方法,也就會再去從新執行一遍渲染函數了,這個時候視圖就刷新了。
在實現了reactive這個基礎api之後,就要開始實現computed這個api了,這個api的用法是這樣:
const data = reactive({
number: 1
})
const numberPlusOne = computed(() => data.number + 1)
// 渲染函數watcher
new Watcher(() => {
document.getElementById('app2').innerHTML = ` computed: 1 + number 是 ${numberPlusOne.value} `
})
複製代碼
vue內部是把computed屬性定義在vm實例上的,這裏咱們沒有實例,因此就用一個對象來存儲computed的返回值,用.value
來拿computed的真實值。
這裏computed傳入的其實仍是一個函數,這裏咱們回想一下Watcher的本質,其實就是存儲了一個須要在特定時機觸發的函數
,在Vue內部,每一個computed屬性也有本身的一個對應的watcher
實例,下文中叫它computedWatcher
先看渲染函數:
// 渲染函數watcher
new Watcher(() => {
document.getElementById('app2').innerHTML = ` computed: 1 + number 是 ${numberPlusOne.value} `
})
複製代碼
這段渲染函數執行過程當中,讀取到numberPlusOne的值的時候
首先會把Dep.target設置爲numberPlusOne所對應的computedWatcher
computedWatcher
的特殊之處在於
computedWatcher
實例上有屬於本身的dep,它能夠收集別的watcher
做爲本身的依賴。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()
}
}
}
複製代碼
其實computed實現的本質就是,computed在讀取value以前,Dep.target確定此時是正在運行的渲染函數的watcher
。
先把當前正在運行的渲染函數的watcher
做爲依賴收集到computedWatcher
內部的dep筐子裏。
把自身computedWatcher
設置爲 全局Dep.target,而後開始求值:
求值函數會在運行
() => data.number + 1
複製代碼
的途中遇到data.number的讀取,這時又會觸發'number'這個key的劫持get函數,這時全局的Dep.target是computedWatcher
,data.number的dep依賴筐子裏丟進去了computedWatcher
。
此時的依賴關係是 data.number的dep筐子裏裝着computedWatcher
,computedWatcher
的dep筐子裏裝着渲染watcher
。
此時若是更新data.number的話,會一級一級往上觸發更新。會觸發computedWatcher
的update
,咱們確定會對被設置爲computed
特性的watcher作特殊的處理,這個watcher的筐子裏裝着渲染watcher
,因此只須要觸發 this.dep.notify(),就會觸發渲染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
}
複製代碼
這裏的邏輯比較繞,若是沒理清楚的話能夠把代碼下載下來一步步斷點調試,data.number
被劫持的set觸發之後,能夠看一下number的dep到底存了什麼。
watch的使用方式是這樣的:
watch(
() => data.msg,
(newVal, oldVal) => {
console.log('newVal: ', newVal)
console.log('old: ', oldVal)
}
)
複製代碼
傳入的第一個參數是個函數,裏面須要讀取到響應式的屬性,確保依賴能被收集到,這樣下次這個響應式的屬性發生改變後,就會打印出對飲的新值和舊值。
分析一下watch的實現原理,這裏依然是利用Watcher類去實現,咱們把用於watch的watcher叫作watchWatcher
,傳入的getter函數也就是() => data.msg
,Watcher
在執行它以前仍是同樣會把自身(也就是watchWatcher
)設爲Dep.target
,這時讀到data.msg,就會把watchWatcher
丟進data.msg
的依賴筐子裏。
若是data.msg更新了,則就會觸發watchWatcher
的update
方法
直接上代碼:
// watch
import Watcher from './watcher'
export default function watch(getter, callback) {
new Watcher(getter, { watch: true, callback })
}
複製代碼
沒錯又是直接用了getter,只是此次傳入的選項是{ watch: true, callback }
,接下來看看Watcher內部進行了什麼處理:
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()
}
}
}
複製代碼
首先是構造函數中,對watch選項和callback進行了保存,其餘沒變。
而後在update
方法中。
update() {
if (this.computed) {
...
} else if (this.watch) {
const oldValue = this.value
this.get()
this.callback(oldValue, this.value)
} else {
...
}
}
複製代碼
在調用this.get
去更新值以前,先把舊值保存起來,而後把新值和舊值一塊兒經過調用callback函數交給外部,就這麼簡單。
咱們僅僅是改動寥寥幾行代碼,就輕鬆實現了很是重要的api:watch
。
有了精妙的Watcher和Dep的設計,Vue內部的響應式api實現的很是簡單,不得再也不次感嘆一下尤大真是厲害啊!