本文從一個簡單的雙向綁定
開始,逐步升級到由defineProperty
和Proxy
分別實現的響應式系統,注重入手思路,抓住關鍵細節,但願能對你有所幫助。html
首先從最簡單的雙向綁定入手:數組
// 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
})
複製代碼
以上彷佛運行起來也沒毛病,但咱們要的是數據驅動,而不是直接操做dom
:數據結構
// 操做obj數據來驅動更新
let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')
Object.defineProperty(obj, 'text', {
configurable: true,
enumerable: true,
get() {
console.log('獲取數據了')
},
set(newVal) {
console.log('數據更新了')
input.value = newVal
span.innerHTML = newVal
}
})
input.addEventListener('keyup', function(e) {
obj.text = e.target.value
})
複製代碼
以上就是一個簡單的雙向數據綁定,但顯然是不足的,下面繼續升級。dom
在Vue3版原本臨前以defineProperty
實現的數據響應,基於發佈訂閱模式,其主要包含三部分:Observer、Dep、Watcher
。異步
// 須要劫持的數據
let data = {
a: 1,
b: {
c: 3
}
}
// 劫持數據data
observer(data)
// 監聽訂閱數據data的屬性
new Watch('a', () => {
alert(1)
})
new Watch('a', () => {
alert(2)
})
new Watch('b.c', () => {
alert(3)
})
複製代碼
以上就是一個簡單的劫持和監聽流程,那對應的observer
和Watch
該如何實現?函數
observer
的做用就是劫持數據,將數據屬性轉換爲訪問器屬性,理一下實現思路:oop
Observer
須要將數據轉化爲響應式的,那它就應該是一個函數(類),能接收參數。Object.defineProperty
。// 定義一個類供傳入監聽數據
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) {
// 每次設置訪問器前都先驗證值是否爲對象,實現遞歸每一個屬性
observer(val)
// 劫持數據屬性
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
}
}
// 監聽obj
observer(data)
複製代碼
根據new Watch('a', () => {alert(1)})
咱們猜想Watch
應該是這樣的:post
class Watch {
// 第一個參數爲表達式,第二個參數爲回調函數
constructor (exp, cb) {
this.exp = exp
this.cb = cb
}
}
複製代碼
那Watch
和observer
該如何關聯?想一想它們之間有沒有關聯的點?彷佛能夠從exp
下手,這是它們共有的點:學習
class Watch {
// 第一個參數爲表達式,第二個參數爲回調函數
constructor (exp, cb) {
this.exp = exp
this.cb = cb
data[exp] // 想一想多了這句有什麼做用
}
}
複製代碼
data[exp]
這句話是否是表示在取某個值,若是exp
爲a
的話,那就表示data.a
,在這以前data
下的屬性已經被咱們劫持爲訪問器屬性了,那這就代表咱們能觸發對應屬性的get
函數,那這就與observer
產生了關聯,那既然如此,那在觸發get
函數的時候能不能把觸發者Watch
給收集起來呢?此時就得須要一個橋樑Dep
來協助了。ui
思路應該是
data
下的每個屬性都有一個惟一的Dep
對象,在get
中收集僅針對該屬性的依賴,而後在set
方法中觸發全部收集的依賴,這樣就搞定了,看以下代碼:
class Dep {
constructor () {
// 定義一個收集對應屬性依賴的容器
this.subs = []
}
// 收集依賴的方法
addSub () {
// Dep.target是個全局變量,用於存儲當前的一個watcher
this.subs.push(Dep.target)
}
// set方法被觸發時會通知依賴
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]
}
}
複製代碼
此時對應的defineReactive
咱們也要增長一些代碼:
function defineReactive (data, key, val) {
observer()
let dep = new Dep() // 新增:這樣每一個屬性就能對應一個Dep實例了
Object.defineProperty(data, key, {
configurable: true,
enumerable: true,
get () {
dep.addSub() // 新增:get觸發時會觸發addSub來收集當前的Dep.target,即watcher
return val
},
set (newVal) {
if (newVal === val) {
return
} else {
data[key] = newVal
observer(newVal)
dep.notify() // 新增:通知對應的依賴
}
}
})
}
複製代碼
至此observer、Dep、Watch
三者就造成了一個總體,分工明確。但還有一些地方須要處理,好比咱們直接對被劫持過的對象添加新的屬性是監測不到的,修改數組的元素值也是如此。這裏就順便提一下Vue
源碼中是如何解決這個問題的:
對於對象:
Vue
中提供了Vue.set
和vm.$set
這兩個方法供咱們添加新的屬性,其原理就是先判斷該屬性是否爲響應式的,若是不是,則經過defineReactive
方法將其轉爲響應式。
對於數組:直接使用下標修改值仍是無效的,
Vue
只hack
了數組中的七個方法:pop','push','shift','unshift','splice','sort','reverse'
,使得咱們用起來依舊是響應式的。其原理是:在咱們調用數組的這七個方法時,Vue
會改造這些方法,它內部一樣也會執行這些方法原有的邏輯,只是增長了一些邏輯:取到所增長的值,而後將其變成響應式,而後再手動出發dep.notify()
Proxy
是在目標前架設一層"攔截"
,外界對該對象的訪問,都必須先經過這層攔截,所以提供了一種機制,能夠對外界的訪問進行過濾和改寫,咱們能夠這樣認爲,Proxy
是Object.defineProperty
的全方位增強版。
依舊是三大件:Observer、Dep、Watch
,咱們在以前的基礎再完善這三大件。
let uid = 0 // 新增:定義一個id
class Dep {
constructor () {
this.id = uid++ // 新增:給dep添加id,避免Watch重複訂閱
this.subs = []
}
depend() { // 新增:源碼中在觸發get時是先觸發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()
}
}
}
複製代碼
class Watch {
constructor (exp, cb) {
this.depIds = {} // 新增:儲存訂閱者的id,避免重複訂閱
this.exp = exp
this.cb = cb
Dep.target = this
data[exp]
// 新增:判斷是否訂閱過該dep,沒有則存儲該id並調用dep.addSub收集當前watcher
addDep (dep) {
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this)
this.depIds[dep.id] = dep
}
}
// 新增:將訂閱者放入待更新隊列等待批量更新
update () {
pushQueue(this)
}
// 新增:觸發真正的更新操做
run () {
this.cb()
}
}
}
複製代碼
與Object.defineProperty
監聽屬性不一樣,Proxy
能夠監聽(實際是代理)整個對象,所以就不須要遍歷對象的屬性依次監聽了,可是若是對象的屬性依然是個對象,那麼Proxy
也沒法監聽,因此依舊使用遞歸套路便可。
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) {
// 若是值未變,則直接返回,不觸發後續操做
if (Reflect.get(data, key) === newVal) {
return
} else {
// 設置新值的同時對新值判斷是否要遞歸監聽
Reflect.set(target, key, observer(newVal))
// 當值被觸發更改的時候,觸發Dep的通知方法
dep.notify(key)
}
}
})
}
// 遞歸監聽
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)
}
// 監聽obj
Observer(data)
複製代碼
至此就基本完成了三大件了,同時其不須要hack
也能對數組進行監聽。
完成了響應式系統,也順便提一下Vue
源碼中是如何觸發依賴收集與批量異步更新的。
在Vue
源碼中的$mount
方法調用時會間接觸發了一段代碼:
vm._watcher = new Watcher(vm, () => {
vm._update(vm._render(), hydrating)
}, noop)
複製代碼
這使得new Watcher()
會先對其傳入的參數進行求值,也就間接觸發了vm._render()
,這其實就會觸發了對數據的訪問,進而觸發屬性的get
方法而達到依賴的收集。
Vue
在更新DOM
時是異步執行的。只要偵聽到數據變化,Vue
將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據變動。若是同一個watcher
被屢次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免沒必要要的計算和DOM
操做是很是重要的。而後,在下一個的事件循環「tick」
中,Vue
刷新隊列並執行實際 (已去重的) 工做。Vue
在內部對異步隊列嘗試使用原生的Promise.then、MutationObserver
和setImmediate
,若是執行環境不支持,則會採用setTimeout(fn, 0)
代替。
根據以上這段官方文檔,這個隊列主要是異步
和去重
,首先咱們來整理一下思路:
// 使用Set數據結構建立一個隊列,這樣可自動去重
let queue = new Set()
// 在屬性出發set方法時會觸發watcher.update,繼而執行如下方法
function pushQueue (watcher) {
// 將數據變動添加到隊列
queue.add(watcher)
// 下一個tick執行該數據變動,因此nextTick接受的應該是一個能執行queue隊列的函數
nextTick('一個能遍歷執行queue的函數')
}
// 用Promise模擬nextTick
function nextTick('一個能遍歷執行queue的函數') {
Promise.resolve().then('一個能遍歷執行queue的函數')
}
複製代碼
以上已經有個大致的思路了,那接下來完成'一個能遍歷執行queue的函數'
:
// queue是一個數組,因此直接遍歷執行便可
function flushQueue () {
queue.forEach(watcher => {
// 觸發watcher中的run方法進行真正的更新操做
watcher.run()
})
// 執行後清空隊列
queue = new Set()
}
複製代碼
還有一個問題,那就是同一個事件循環中應該只要觸發一次nextTick
便可,而不是每次添加隊列時都觸發:
// 設置一個是否觸發了nextTick的標識
let waiting = false
function pushQueue (watcher) {
queue.add(watcher)
if (!waiting) {
// 保證nextTick只觸發一次
waiting = true
nextTick('一個能遍歷執行queue的函數')
}
}
複製代碼
完整代碼以下:
// 定義隊列
let queue = new Set()
// 供傳入nextTick中的執行隊列的函數
function flushQueue () {
queue.forEach(watcher => {
watcher.run()
})
queue = new Set()
}
// nextTick
function nextTick(flushQueue) {
Promise.resolve().then(flushQueue)
}
// 添加到隊列並調用nextTick
let waiting = false
function pushQueue (watcher) {
queue.add(watcher)
if (!waiting) {
waiting = true
nextTick(flushQueue)
}
}
複製代碼
以上就是響應式的一個大概原理,固然還有不少細節沒說,感興趣的能夠去擼一擼源碼,若是以爲有所幫助,歡迎關注點個贊!
相關參考: