1.前言
最近在梳理vue回應式的原理,有一些心得,值得寫一篇博客出來看看,
其實之前也嘗試過了解vue回應式的原理,畢竟現在面試看你用的是vue的話,基本上都會問你幾句vue回應式的原理,以往學習這塊就是看看別人寫的文章,或者翻翻原始碼,這個程序中發現相當一部分文章看完之后一句話總結就是—— vue通過 Object.defineProperty 或者 Proxy API 攔截了資料的getter/setter,再在getter/setter里面做資料回應的相關邏輯,除此之外就一無所知了,與此同時又發現直接看原始碼的話,所花費的時間跟識訓比太小了,當然,看原始碼困難的原因還是現在的水平不夠,沒到看原始碼的那一步,
后來我在知乎上看到 一年內的前端看不懂前端框架原始碼怎么辦?這個問題,以及尤雨溪對 維護一個大型開源專案是怎樣的體驗? 這個問題的回答,思考總結了這么幾點啟示:
- 注意資料結構、演算法、設計模式等基礎能力,這些基礎能力的不足,往往導致我們理解不了原始碼作者的想法,同時,提升這些基礎能力也可以提高我們的上限,
- 從某些功能點入手,不要從入口檔案開始看,比如vue,我對其回應式原理比較感興趣,我就專門去看回應式相關的原始碼和文章,
- 不要試圖弄清原始碼的每一行代碼,首要的是掌握其思想,原始碼的細節非常多,陷入其中往往會對閱讀原始碼造成極大的困難,最重要的是學習原始碼為了實作某些功能而采用了什么方法,沒有必要了解原始碼每一行都干了什么,
- 看一百遍,不如做一遍,有時候光看原始碼和文章,不一定能有多少識訓,或者看了就忘了,其實按照自己的理解或者想法動手實作一遍會更好,具體實作細節不一定要和原始碼對齊,功能上也可以簡陋一點,我相信自己做一遍,能幫助我們更為高效的理解原始碼,
Vue回應式的關鍵在于資料劫持和發布訂閱模式,資料劫持的目的是在于實作發布訂閱,通過發布訂閱模式,在資料發生變更時通知到各“訂閱者”,也就是Vue中的各種模板語法、計算屬性、偵聽器等等,下面就先介紹與發布訂閱相關的幾個概念,然后通過自己實作一個簡單版Vue的方式介紹一下Vue2.0和Vue3.0回應式的簡單模型,注意只是簡單模型,但應該可以幫你應付大部分的面試,
本篇文章所用到的代碼都放到了GitHub上
2.觀察者模式
2.1 概念
觀察者模式是一種通知機制,讓發送通知的一方(被觀察者)和接收通知的一方(觀察者)能彼此分離,互不影響,
觀察者模式和發布訂閱模式是有區別的,具體后面會詳細介紹,
2.2 示例
這里通過一個簡單的例子展示觀察者模式,具體的業務邏輯是在商店有新商品上架時通知到顧客,為了方便,這個例子可以直接通過node運行,不需要關注ui的實作,
// 被觀察者
module.exports = class Store {
constructor() {
// 商品串列
this.products = new Set()
// 觀察者串列
this.observers = []
}
// 注冊觀察者
addObserver(watcher) {
this.observers.push(watcher)
}
// 有新商品時通知觀察者
addProduct(name) {
if (this.products.has(name)) return
this.products.add(name)
// 遍歷觀察者串列,呼叫觀察者對應處理方法
this.observers.forEach((watcher) => watcher.onPublished(name))
}
}
// 觀察者
module.exports = class watcher {
constructor(name) {
this.name = name
}
onPublished(product) {
console.log(`觀察者${this.name}觀察到商品${product}上架`)
}
}
// 在商店有新品上架時,通知顧客
const Store = require('./store')
const Watcher = require('./watcher')
const supermarket = new Store()
const watchA = new Watcher('A')
const watchB = new Watcher('B')
supermarket.addObserver(watchA)
supermarket.addObserver(watchB)
supermarket.addProduct('香蕉')
setTimeout(() => {
supermarket.addProduct('蘋果')
}, 3000)
觀察者模式有個明顯的特征,那就是被觀察者維護了一份觀察者串列,也就是說被觀察者知道有哪些觀察者在觀察自己,在需要進行通知的時候,遍歷串列,通知觀察者們,
3.發布訂閱模式
3.1 與觀察者模式的區別
參考上面觀察者模式的例子,在觀察者模式中,被觀察者主動收集觀察者,
而在發布訂閱模式中,發布者不需要主動收集訂閱者,訂閱者訂閱發布平臺,發布者將變更推給發布平臺,由發布平臺告知訂閱者,
3.2 打個比方
我在 GitHub 上看到一個很形象的比喻:
發布-訂閱模式就好像報社, 郵局和個人的關系,報紙的訂閱和分發是由郵局來完成的,報社只負責將報紙發送給郵局,
觀察者模式就好像 個體奶農和個人的關系,奶農負責統計有多少人訂了產品,所以個人都會有一個相同拿牛奶的方法,奶農有新奶了就負責呼叫這個方法,
3.3 示例
根據上面的比喻,我們實作一個簡單的讀者通過郵局訂閱報紙的專案
// 發布者,也就是報社
module.exports = class Publisher {
constructor() {
this.subscribers = []
}
addSubscriber(subscriber) {
this.subscribers.push(subscriber)
}
publish(value) {
this.subscribers.forEach((subscribe) =>{
subscribe.update(value)
})
}
}
// 訂閱者,也就是讀者
module.exports = class Subscriber {
constructor(cb) {
this.cb = cb
}
update(val) {
this.cb(val)
}
// 訂閱者主動訂閱發布平臺
subscribe(publisher) {
publisher.addSubscriber(this)
}
}
// 發布平臺,也就是郵局
module.exports = class Publisher {
constructor() {
this.subscribers = []
}
addSubscriber(subscriber) {
this.subscribers.push(subscriber)
}
publish(value) {
this.subscribers.forEach((subscribe) =>{
subscribe.update(value)
})
}
}
// 通過郵局訂閱報紙
const Producer = require('./producer')
const Publisher = require('./publisher')
const Subscriber = require('./subscriber')
// 創建一個報社
const newspaper = new Producer()
// 創建兩個郵局用于分發報紙
const postOfficeA = new Publisher()
const postOfficeB = new Publisher()
// 創建三個讀者,通過不同的報社訂閱報紙
const readerA = new Subscriber((value) => {
console.log(`讀者A收到${value}`)
})
const readerB = new Subscriber((value) => {
console.log(`讀者B收到${value}`)
})
const readerC = new Subscriber((value) => {
console.log(`讀者C收到${value}`)
})
readerA.subscribe(postOfficeA)
readerA.subscribe(postOfficeB)
readerB.subscribe(postOfficeB)
readerC.subscribe(postOfficeB)
newspaper.addPublisher(postOfficeA)
newspaper.addPublisher(postOfficeB)
newspaper.publish('新青年')
從上面的代碼可以看出,發布訂閱模式與觀察者模式最大的不同在于多了一個發布平臺,除此之外,發布者不用主動收集訂閱者,而是指定發布平臺,同時,訂閱者也需要在發布平臺中注冊自己,也就是把自己添加到發布平臺內維護的一份訂閱者串列中,這里發布平臺并沒有主動收集訂閱者,而是訂閱者呼叫發布平臺對應方法,將自己注冊到發布平臺中,發布者變更時首先通知發布平臺,發布平臺再遍歷自己的訂閱者串列,將變更告知訂閱者們,
4.簡單模型
4.1 發布訂閱邏輯
在了解了觀察者模式,以及發布訂閱模式之后,我們就可以理解Vue回應式的基本模型了,在Vue的官網上有這么一張圖:

這張圖大概說明了發布訂閱的相關邏輯,通過自己的理解提煉出了三個角色:
- 發布者
Vue中宣告的data - 訂閱者
Vue中的模板語法、計算屬性、偵聽器等等都是訂閱者,訂閱者通常都有類似于“回呼函式”的機制,在變更的回呼函式中消費data
提供一個方法處理發布者變更 - 發布平臺
data中的每一個屬性都有對應的發布平臺
發布平臺收集使用到該屬性的訂閱者
發布者發送變化時,通知訂閱者屬性發生變更
三個角色聯動的關鍵問題有三個:
- 如何為data每一個屬性創建發布平臺:
通過遍歷data的key,為每一個key創建發布平臺 - 發布平臺如何收集訂閱者:
1.訂閱者在創建時,會呼叫其變更回呼函式,觸發所用到屬性的getter,
2.發布平臺維護一個單例 Dep.depTarget,訂閱者呼叫回呼函式前將Dep.depTarget指向自己,回呼完成后將Dep.depTarget置為null,
3.發布平臺事先通過Object.defineProperty/Proxy API劫持get方法,在屬性被get時將Dep.depTarget添加到該屬性發布平臺的訂閱者串列中,這個程序會判斷Dep.depTarget是否為空、所指向的訂閱者是否已經在串列中等等, - 發布平臺如何獲取發布者發送變化:
發布平臺事先通過Object.defineProperty/Proxy API劫持set方法,在set方法中呼叫該屬性發布平臺訂閱者串列中各訂閱者的處理方法
4.2 Vue2.0回應式的簡單模型
正如前言所說,自己實作一遍,能夠更好的理解原始碼,這里就簡單的通過 Object.defineProperty 實作監聽資料更新進而重繪頁面,
首先創建一個html檔案和js檔案
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>輕量級vue2.0實作</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.js"></script>
</body>
</html>
// index.js
import Vue from './src/vue.js'
const App = new Vue({
el: '#app',
data() {
return {
name: '特朗普',
info: {
message: '沒有人比他更懂',
},
}
},
render(createElement) {
return createElement(
'div',
[
createElement('span', `${this._data.name} 說: ${this._data.info.message}`)
]
)
}
})
setTimeout(() => {
App._data.name = '川寶'
}, 2000)
setTimeout(() => {
App._data.info.message = 'MAGA!!!!'
}, 4000)
在index.js中我們創建了一個自己實作的Vue實體,將其掛在到id為app的節點上,接下來實作 ./src/vue.js中的代碼
// ./src/vue.js
// 引入處理資料劫持的函式
import observe from './observer.js'
// 引入觀察者
import Watcher from './watcher.js'
class Vue {
constructor(options) {
this.$options = options
this._data = options.data()
this.render = options.render
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 資料劫持
observe(this._data)
// 創建一個訂閱者,訂閱_data的變更
// 訂閱者收到變更通知時重新渲染組件
new Watcher(this._data, ()=> {
this.$mount()
})
}
// 這里就是創建html節點
createElement(tagName, children) {
let element = document.createElement(tagName)
if (Object.prototype.toString.call(children) === '[object Array]') {
children.forEach((child) => {
element.appendChild(child)
})
} else {
element.textContent = children
}
return element
}
// 創建并掛載節點
$mount() {
const elements = this.render(this.createElement)
this.$el.innerHTML = ''
this.$el.appendChild(elements)
}
}
export default Vue
接下來就是關鍵的處理資料劫持的方法
// ./src/observer.js
import Dep from './dep.js'
const typeTo = (val) => Object.prototype.toString.call(val)
// 重寫屬性get/set方法
function defineReactive(obj, key, val) {
// 每個物件的屬性都有一個 Dep 作為該屬性變更的發布平臺
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// get時發布平臺收集訂閱者
get() {
console.log(`get ${key}`)
if (Dep.depTarget && Dep.depTarget.id >= 0) {
console.log(`當前訂閱者id: ${Dep.depTarget.id}`)
}
dep.addSub(Dep.depTarget)
return val
},
// set時發布平臺dep通知訂閱者
set(newValue) {
console.log(`set ${key}`)
if (newValue === val) return
val = newValue
dep.notify()
},
})
}
function walk(obj) {
Object.keys(obj).forEach((key) => {
// 如果值是物件,繼續處理物件內部的欄位
if(typeTo(obj[key]) === '[object Object]'){
walk(obj[key])
}
// 處理屬性本身
defineReactive(obj, key, obj[key])
})
}
// observe用于劫持資料
function observe(obj) {
if(typeTo(obj) !== '[object Object]') {
return null
}
walk(obj)
}
export default observe
在./src/observer.js 中引入了dep.js,這便是發布訂閱模型中的發布平臺對應的類,在observer.js 中通過遍歷物件的key,為每個key創建發布平臺,當屬性get被觸發時,證明有訂閱者在使用該屬性,此時Dep.depTarget會指向使用該屬性的訂閱者,將其添加到發布平臺的訂閱者串列中,當屬性被設值時,觸發set,發布平臺遍歷訂閱者串列,通知訂閱者,
在看./src/dep.js的實作
// ./src/dep.js
// 發布訂閱模型中的發布平臺
class Dep{
constructor() {
// 訂閱者串列
this.subs = []
}
addSub(sub) {
// 如果訂閱者不在訂閱者串列,就把它添加進來
if(sub && (this.subs.indexOf(sub) === -1)) {
this.subs.push(sub)
}
}
notify() {
console.log('通知變更', this.subs.length)
this.subs.length > 0 && this.subs.forEach((sub) => {
sub.update()
})
}
}
Dep.depTarget = null
export default Dep
這部分代碼比較易懂,剩下的就是./src/watcher.js的實作了
// ./src/watcher.js
import Dep from './dep.js'
// 通過id區分不同的訂閱者
let id = 0
class Watcher{
// 在訂閱者創建時,將單例Dep.depTarget指向當前訂閱者
constructor(value, cb) {
this.cb = cb
// 創建時呼叫get方法的目的主要是通過呼叫cb觸發所用到的屬性的get
// 進而在對應屬性的發布平臺中添加該訂閱者
this.get()
// this.val指向vue._data
this.val = value
// 通過id可以確定是否為同一個訂閱者
this.id = ++id
}
get() {
Dep.depTarget = this
this.cb()
// 發布平臺收集完訂閱者后重置單例
Dep.depTarget = null
}
// 訂閱者在更新的時候呼叫this.cb()觸發所用到屬性的get
// 如果該訂閱者不在對應屬性發布平臺的訂閱者串列中,則會被添加進串列
update() {
this.get()
console.log('val value', this.val.name, this.val.info.message)
}
}
export default Watcher
現在,這個簡易版的Vue2.0就可以在瀏覽器里看到效果了

4.2 Vue3.0回應式的簡單模型
在簡易版的實作中,3.0和2.0的不同主要體現在 ./src/observer.js 用Proxy API做資料劫持,由于Proxy API回傳一個Proxy物件,因此index.js和./src/vue.js某些地方的寫法也有所不同
// ./src/observer.js
// 使用proxy api做資料劫持
import Dep from './dep.js'
const typeTo = (val) => Object.prototype.toString.call(val)
// 重寫屬性get/set方法
function defineReactive(obj) {
// 每個物件的屬性都有一個 Dep 作為該屬性變更的發布平臺
let dep = new Dep()
if (typeTo(obj) !== '[object Object]') {
return null
}
return new Proxy(obj, {
get(target, key) {
console.log('觸發get', target, key)
dep.addSub(Dep.depTarget)
return target[key]
},
set(target, key, value, receiver) {
console.log('觸發set', target, key, value)
let newValue = Reflect.set(target, key, value, receiver)
dep.notify()
return true
}
})
}
function walk(obj) {
const res = {}
Object.keys(obj).forEach((key) => {
if (typeTo(obj[key]) === '[object Object]') {
// 如果值是物件,繼續處理物件內部的欄位
res[key] = walk(obj[key])
} else {
// 如果不是物件,則賦值
res[key] = obj[key]
}
})
// 記得處理該屬性本身
return defineReactive(res)
}
// observe用于劫持資料
function observe(obj) {
if(typeTo(obj) !== '[object Object]') {
return null
}
return walk(obj)
}
export default observe
// ./src/vue.js
import observe from './observer.js'
import Watcher from './watcher.js'
class Vue {
constructor(options) {
this.$options = options
this._data = options.data()
this.render = options.render
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
// 這里和2.0不同
this.$data = observe(this._data)
// 創建一個訂閱者,訂閱_data的變更
// 訂閱者收到變更通知時重新渲染組件
new Watcher(this.$data, ()=> {
this.$mount()
})
}
createElement(tagName, children) {
let element = document.createElement(tagName)
if (Object.prototype.toString.call(children) === '[object Array]') {
children.forEach((child) => {
element.appendChild(child)
})
} else {
element.textContent = children
}
return element
}
$mount() {
const elements = this.render(this.createElement)
this.$el.innerHTML = ''
this.$el.appendChild(elements)
}
}
export default Vue
// index.js
import Vue from './src/vue.js'
const App = new Vue({
el: '#app',
data() {
return {
name: '特朗普',
info: {
message: '沒有人比他更懂',
},
}
},
render(createElement) {
return createElement(
'div',
[
createElement('span', `${this.$data.name} 說: ${this.$data.info.message}`)
]
)
}
})
setTimeout(() => {
// 通過$data屬性操作Proxy API回傳的物件
App.$data.name = '川寶'
}, 2000)
setTimeout(() => {
App.$data.info.message = 'MAGA!!!!'
}, 4000)
最終呈現的結果和2.0一樣
5.結束
這里只展示了Vue回應式最簡單的模型,肯定在細節和功能上與原始碼有很大的差異,但通過對發布訂閱模式的了解,以及自己分別實作Vue2.0和3.0的簡單模型,下次面試官再問的時候心里就不慌了,
6.參考
【vue系列】從發布訂閱模式解讀,到vue回應式原理實作(包含vue3.0)
觀察者
介紹下觀察者模式和訂閱-發布模式的區別,各自適用于什么場景
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/282881.html
標籤:其他
下一篇:C語言編程規范(個人整理)
