這應(yīng)該是最詳細的響應(yīng)式系統(tǒng)講解了
前言
本文從一個簡單的雙向綁定開始,逐步升級到由defineProperty和Proxy分別實現(xiàn)的響應(yīng)式系統(tǒng),注重入手思路,抓住關(guān)鍵細節(jié),希望能對你有所幫助。
一、極簡雙向綁定
首先從最簡單的雙向綁定入手:
// html <input type="text" id="input"> <span id="span"></span>
// js
let input = document.getElementById('input')
let span = document.getElementById('span')
input.addEventListener('keyup', function(e) {
span.innerHTML = e.target.value
})
以上似乎運行起來也沒毛病,但我們要的是數(shù)據(jù)驅(qū)動,而不是直接操作dom:
// 操作obj數(shù)據(jù)來驅(qū)動更新
let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')
Object.defineProperty(obj, 'text', {
configurable: true,
enumerable: true,
get() {
console.log('獲取數(shù)據(jù)了')
return obj.text
},
set(newVal) {
console.log('數(shù)據(jù)更新了')
input.value = newVal
span.innerHTML = newVal
}
})
input.addEventListener('keyup', function(e) {
obj.text = e.target.value
})
以上就是一個簡單的雙向數(shù)據(jù)綁定,但顯然是不足的,下面繼續(xù)升級。
二、以defineProperty實現(xiàn)響應(yīng)系統(tǒng)
在Vue3版本來臨前以defineProperty實現(xiàn)的數(shù)據(jù)響應(yīng),基于發(fā)布訂閱模式,其主要包含三部分:Observer、Dep、Watcher。
1. 一個思路例子
// 需要劫持的數(shù)據(jù)
let data = {
a: 1,
b: {
c: 3
}
}
// 劫持數(shù)據(jù)data
observer(data)
// 監(jiān)聽訂閱數(shù)據(jù)data的屬性
new Watch('a', () => {
alert(1)
})
new Watch('a', () => {
alert(2)
})
new Watch('b.c', () => {
alert(3)
})
以上就是一個簡單的劫持和監(jiān)聽流程,那對應(yīng)的observer和Watch該如何實現(xiàn)?
2. Observer
observer的作用就是劫持數(shù)據(jù),將數(shù)據(jù)屬性轉(zhuǎn)換為訪問器屬性,理一下實現(xiàn)思路:
①Observer需要將數(shù)據(jù)轉(zhuǎn)化為響應(yīng)式的,那它就應(yīng)該是一個函數(shù)(類),能接收參數(shù)。
②為了將數(shù)據(jù)變成響應(yīng)式,那需要使用Object.defineProperty。
③數(shù)據(jù)不止一種類型,這就需要遞歸遍歷來判斷。
// 定義一個類供傳入監(jiān)聽數(shù)據(jù)
class Observer {
constructor(data) {
let keys = Object.keys(data)
for (let i = 0; i < keys.length; i++) {
defineReactive(data, keys[i], data[keys[i]])
}
}
}
// 使用Object.defineProperty
function defineReactive (data, key, val) {
// 每次設(shè)置訪問器前都先驗證值是否為對象,實現(xiàn)遞歸每個屬性
observer(val)
// 劫持數(shù)據(jù)屬性
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get () {
return val
},
set (newVal) {
if (newVal === val) {
return
} else {
data[key] = newVal
// 新值也要劫持
observer(newVal)
}
}
})
}
// 遞歸判斷
function observer (data) {
if (Object.prototype.toString.call(data) === '[object, Object]') {
new Observer(data)
} else {
return
}
}
// 監(jiān)聽obj
observer(data)
3. Watcher
根據(jù)new Watch('a', () => {alert(1)})我們猜測Watch應(yīng)該是這樣的:
class Watch {
// 第一個參數(shù)為表達式,第二個參數(shù)為回調(diào)函數(shù)
constructor (exp, cb) {
this.exp = exp
this.cb = cb
}
}
那Watch和observer該如何關(guān)聯(lián)?想想它們之間有沒有關(guān)聯(lián)的點?似乎可以從exp下手,這是它們共有的點:
class Watch {
// 第一個參數(shù)為表達式,第二個參數(shù)為回調(diào)函數(shù)
constructor (exp, cb) {
this.exp = exp
this.cb = cb
data[exp] // 想想多了這句有什么作用
}
}
data[exp]這句話是不是表示在取某個值,如果exp為a的話,那就表示data.a,在這之前data下的屬性已經(jīng)被我們劫持為訪問器屬性了,那這就表明我們能觸發(fā)對應(yīng)屬性的get函數(shù),那這就與observer產(chǎn)生了關(guān)聯(lián),那既然如此,那在觸發(fā)get函數(shù)的時候能不能把觸發(fā)者Watch給收集起來呢?此時就得需要一個橋梁Dep來協(xié)助了。
4. Dep
思路應(yīng)該是data下的每一個屬性都有一個唯一的Dep對象,在get中收集僅針對該屬性的依賴,然后在set方法中觸發(fā)所有收集的依賴,這樣就搞定了,看如下代碼:
class Dep {
constructor () {
// 定義一個收集對應(yīng)屬性依賴的容器
this.subs = []
}
// 收集依賴的方法
addSub () {
// Dep.target是個全局變量,用于存儲當(dāng)前的一個watcher
this.subs.push(Dep.target)
}
// set方法被觸發(fā)時會通知依賴
notify () {
for (let i = 1; i < this.subs.length; i++) {
this.subs[i].cb()
}
}
}
Dep.target = null
class Watch {
constructor (exp, cb) {
this.exp = exp
this.cb = cb
// 將Watch實例賦給全局變量Dep.target,這樣get中就能拿到它了
Dep.target = this
data[exp]
}
}
此時對應(yīng)的defineReactive我們也要增加一些代碼:
function defineReactive (data, key, val) {
observer()
let dep = new Dep() // 新增:這樣每個屬性就能對應(yīng)一個Dep實例了
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get () {
dep.addSub() // 新增:get觸發(fā)時會觸發(fā)addSub來收集當(dāng)前的Dep.target,即watcher
return val
},
set (newVal) {
if (newVal === val) {
return
} else {
data[key] = newVal
observer(newVal)
dep.notify() // 新增:通知對應(yīng)的依賴
}
}
})
}
至此observer、Dep、Watch三者就形成了一個整體,分工明確。但還有一些地方需要處理,比如我們直接對被劫持過的對象添加新的屬性是監(jiān)測不到的,修改數(shù)組的元素值也是如此。這里就順便提一下Vue源碼中是如何解決這個問題的:
對于對象:Vue中提供了Vue.set和vm.$set這兩個方法供我們添加新的屬性,其原理就是先判斷該屬性是否為響應(yīng)式的,如果不是,則通過defineReactive方法將其轉(zhuǎn)為響應(yīng)式。
對于數(shù)組:直接使用下標修改值還是無效的,Vue只hack了數(shù)組中的七個方法:pop','push','shift','unshift','splice','sort','reverse',使得我們用起來依舊是響應(yīng)式的。其原理是:在我們調(diào)用數(shù)組的這七個方法時,Vue會改造這些方法,它內(nèi)部同樣也會執(zhí)行這些方法原有的邏輯,只是增加了一些邏輯:取到所增加的值,然后將其變成響應(yīng)式,然后再手動出發(fā)dep.notify()
三、以Proxy實現(xiàn)響應(yīng)系統(tǒng)
Proxy是在目標前架設(shè)一層"攔截",外界對該對象的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫,我們可以這樣認為,Proxy是Object.defineProperty的全方位加強版。
依舊是三大件:Observer、Dep、Watch,我們在之前的基礎(chǔ)再完善這三大件。
1. Dep
let uid = 0 // 新增:定義一個id
class Dep {
constructor () {
this.id = uid++ // 新增:給dep添加id,避免Watch重復(fù)訂閱
this.subs = []
}
depend() { // 新增:源碼中在觸發(fā)get時是先觸發(fā)depend方法再進行依賴收集的,這樣能將dep傳給Watch
Dep.target.addDep(this);
}
addSub () {
this.subs.push(Dep.target)
}
notify () {
for (let i = 1; i < this.subs.length; i++) {
this.subs[i].cb()
}
}
}
2. Watch
class Watch {
constructor (exp, cb) {
this.depIds = {} // 新增:儲存訂閱者的id,避免重復(fù)訂閱
this.exp = exp
this.cb = cb
Dep.target = this
data[exp]
// 新增:判斷是否訂閱過該dep,沒有則存儲該id并調(diào)用dep.addSub收集當(dāng)前watcher
addDep (dep) {
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this)
this.depIds[dep.id] = dep
}
}
// 新增:將訂閱者放入待更新隊列等待批量更新
update () {
pushQueue(this)
}
// 新增:觸發(fā)真正的更新操作
run () {
this.cb()
}
}
}
3. Observer
與Object.defineProperty監(jiān)聽屬性不同,Proxy可以監(jiān)聽(實際是代理)整個對象,因此就不需要遍歷對象的屬性依次監(jiān)聽了,但是如果對象的屬性依然是個對象,那么Proxy也無法監(jiān)聽,所以依舊使用遞歸套路即可。
function Observer (data) {
let dep = new Dep()
return new Proxy(data, {
get () {
// 如果訂閱者存在,進去depend方法
if (Dep.target) {
dep.depend()
}
// Reflect.get了解一下
return Reflect.get(data, key)
},
set (data, key, newVal) {
// 如果值未變,則直接返回,不觸發(fā)后續(xù)操作
if (Reflect.get(data, key) === newVal) {
return
} else {
// 設(shè)置新值的同時對新值判斷是否要遞歸監(jiān)聽
Reflect.set(target, key, observer(newVal))
// 當(dāng)值被觸發(fā)更改的時候,觸發(fā)Dep的通知方法
dep.notify(key)
}
}
})
}
// 遞歸監(jiān)聽
function observer (data) {
// 如果不是對象則直接返回
if (Object.prototype.toString.call(data) !== '[object, Object]') {
return data
}
// 為對象時則遞歸判斷屬性值
Object.keys(data).forEach(key => {
data[key] = observer(data[key])
})
return Observer(data)
}
// 監(jiān)聽obj
Observer(data)
至此就基本完成了三大件了,同時其不需要hack也能對數(shù)組進行監(jiān)聽。
四、觸發(fā)依賴收集與批量異步更新
完成了響應(yīng)式系統(tǒng),也順便提一下Vue源碼中是如何觸發(fā)依賴收集與批量異步更新的。
1. 觸發(fā)依賴收集
在Vue源碼中的$mount方法調(diào)用時會間接觸發(fā)了一段代碼:
vm._watcher = new Watcher(vm, () => {
vm._update(vm._render(), hydrating)
}, noop)
這使得new Watcher()會先對其傳入的參數(shù)進行求值,也就間接觸發(fā)了vm._render(),這其實就會觸發(fā)了對數(shù)據(jù)的訪問,進而觸發(fā)屬性的get方法而達到依賴的收集。
2. 批量異步更新
Vue在更新DOM時是異步執(zhí)行的。只要偵聽到數(shù)據(jù)變化,Vue將開啟一個隊列,并緩沖在同一事件循環(huán)中發(fā)生的所有數(shù)據(jù)變更。如果同一個watcher被多次觸發(fā),只會被推入到隊列中一次。這種在緩沖時去除重復(fù)數(shù)據(jù)對于避免不必要的計算和DOM操作是非常重要的。然后,在下一個的事件循環(huán)“tick”中,Vue刷新隊列并執(zhí)行實際 (已去重的) 工作。Vue在內(nèi)部對異步隊列嘗試使用原生的Promise.then、MutationObserver和setImmediate,如果執(zhí)行環(huán)境不支持,則會采用setTimeout(fn, 0)代替。
根據(jù)以上這段官方文檔,這個隊列主要是異步和去重,首先我們來整理一下思路:
- 需要有一個隊列來存儲一個事件循環(huán)中的數(shù)據(jù)變更,且要對它去重。
- 將當(dāng)前事件循環(huán)中的數(shù)據(jù)變更添加到隊列。
- 異步的去執(zhí)行這個隊列中的所有數(shù)據(jù)變更。
// 使用Set數(shù)據(jù)結(jié)構(gòu)創(chuàng)建一個隊列,這樣可自動去重
let queue = new Set()
// 在屬性出發(fā)set方法時會觸發(fā)watcher.update,繼而執(zhí)行以下方法
function pushQueue (watcher) {
// 將數(shù)據(jù)變更添加到隊列
queue.add(watcher)
// 下一個tick執(zhí)行該數(shù)據(jù)變更,所以nextTick接受的應(yīng)該是一個能執(zhí)行queue隊列的函數(shù)
nextTick('一個能遍歷執(zhí)行queue的函數(shù)')
}
// 用Promise模擬nextTick
function nextTick('一個能遍歷執(zhí)行queue的函數(shù)') {
Promise.resolve().then('一個能遍歷執(zhí)行queue的函數(shù)')
}
以上已經(jīng)有個大體的思路了,那接下來完成'一個能遍歷執(zhí)行queue的函數(shù)':
// queue是一個數(shù)組,所以直接遍歷執(zhí)行即可
function flushQueue () {
queue.forEach(watcher => {
// 觸發(fā)watcher中的run方法進行真正的更新操作
watcher.run()
})
// 執(zhí)行后清空隊列
queue = new Set()
}
還有一個問題,那就是同一個事件循環(huán)中應(yīng)該只要觸發(fā)一次nextTick即可,而不是每次添加隊列時都觸發(fā):
// 設(shè)置一個是否觸發(fā)了nextTick的標識
let waiting = false
function pushQueue (watcher) {
queue.add(watcher)
if (!waiting) {
// 保證nextTick只觸發(fā)一次
waiting = true
nextTick('一個能遍歷執(zhí)行queue的函數(shù)')
}
}
完整代碼如下:
// 定義隊列
let queue = new Set()
// 供傳入nextTick中的執(zhí)行隊列的函數(shù)
function flushQueue () {
queue.forEach(watcher => {
watcher.run()
})
queue = new Set()
}
// nextTick
function nextTick(flushQueue) {
Promise.resolve().then(flushQueue)
}
// 添加到隊列并調(diào)用nextTick
let waiting = false
function pushQueue (watcher) {
queue.add(watcher)
if (!waiting) {
waiting = true
nextTick(flushQueue)
}
}
最后
以上就是響應(yīng)式的一個大概原理,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)參考:
實現(xiàn)雙向綁定Proxy比defineproperty優(yōu)劣如何?
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
深入淺析JavaScript系列(13):This? Yes,this!
在這篇文章里,我們將討論跟執(zhí)行上下文直接相關(guān)的更多細節(jié)。討論的主題就是this關(guān)鍵字。實踐證明,這個主題很難,在不同執(zhí)行上下文中this的確定經(jīng)常會發(fā)生問題2016-01-01
JavaScript的級聯(lián)函數(shù)用法簡單示例【鏈式調(diào)用】
這篇文章主要介紹了JavaScript的級聯(lián)函數(shù)用法,結(jié)合簡單實例形式分析了javascript鏈式調(diào)用具體定義及使用方法,需要的朋友可以參考下2019-03-03
關(guān)于JS精度丟失產(chǎn)生的原因以及解決方案
在處理一些極端情況下的復(fù)雜數(shù)值計算時,我們可能會遇到這樣的情況,就是運算結(jié)果丟失精度,下面這篇文章主要給大家介紹了關(guān)于JS精度丟失產(chǎn)生的原因以及解決方案的相關(guān)資料,需要的朋友可以參考下2024-01-01
javascript刪除數(shù)組重復(fù)元素的方法匯總
這篇文章主要介紹了javascript刪除數(shù)組重復(fù)元素的方法,實例匯總了幾種常用的javascript刪除數(shù)組重復(fù)元素的技巧,需要的朋友可以參考下2015-06-06

