【MVVM】如何手刻 Vue 的 Text Interpolations 與 Data Binding
前陣子跟一些工程師前輩閒聊,談到了無法使用 Vue / React 等框架,只能使用原生 JavaScript 的情況下,UI 交互該怎麼寫會更好?
聊著聊著就意識到,雖然曾用 Class 跟 OOP 概念,自幹過一些微型架構出來,但那些常聽到的 MVC、MVVM 與響應式這些,我僅限於略懂,還不曾實際手刻過。
於是就花了點時間研究 Vue 的 MVVM 架構,以及現在前端最常規的響應式 Data binding 是怎麼做的。
這篇將會實作類似 Vue 的文本插值 (Text Interpolations),以及數據綁定 (Data Binding)。大概會涉及到這些技術知識點:
- 如何將 data 編譯到 HTML 的標記上
{{ expression }}
- 如何使用
Array.reduce()
對 template 標記取值 - 如何使用 Proxy API 監聽 data 的 setter 以實現響應式數據綁定
- 使用訂閱/發佈模式的概念,收集需編譯的依賴文本對象
- 使用閉包 (Closure) 的概念,紀錄依賴對像
- 如何使用遞迴進行深層遍歷
- 如何做到「指哪打哪」的響應式
目標是實現下面這段 Vue 格式的文本數據綁定。
// index.html
<div id="app">
<h2>Title {{ title }}</h2>
<div>{{ user.name }}</div>
<ul>
<li>{{ user.name }}</li>
<li>{{ user.profile.age }}</li>
</ul>
</div><script type="module">
import { createVM } from './view-model.esm.js'window.app = createVM({
title: 'User',
user: {
name: 'Tom',
profile: {
age: 18,
},
}
}).mount('#app')
</script>
先附上已完成的 demo 及 GitHub。
可以試著直接在 devtool 去對 app.data.title
等狀態做更新,就能看到畫面上 UI 也跟著響應式變化。
那就直接開始吧!!
起手式
先把希望的接口樣式確定下來,template 的部分跟上面那段一樣,只是 script 部分是舊版 Vue2 格式的寫法。
// index.html
<div id="app">
<h2>Title {{ title }}</h2>
<div>{{ user.name }}</div>
<ul>
<li>{{ user.name }}</li>
<li>{{ user.profile.age }}</li>
</ul>
</div><script type="module">
import { VM } from './view-model.esm.js'window.app = new VM({
el: '#app',
data: {
title: 'User',
user: {
name: 'Tom',
profile: {
age: 18,
},
}
}
})
</script>
這邊掛到 window.app
上面,是因為使用了 type=”module”
,這樣在 devtool 會呼不到。為了開發方便,所以掛到 window 上,讓我們之後可以在 devtool 訪問 app
。
data 是三層巢狀的 object,template 在 ul > li
的部分也是巢狀結構,處理來複雜度也會高一層,後面會陸續提到。
然後來做 view-model.esm.js
的起手,先把 Class 外殼給做出來,然後 ESM 導出。
// view-model.esm.js
class VM {
constructor(options) {
this.data = options.data
console.log(this.data)
}
}export { VM }
確認 VM 初始化成功,data 有被 log 出來後,來美化一下初始化的呼叫方式,比照新版的 React 跟 Vue 將 mount
用 Function Chaining 的方式,讓他看起來前衛一點。(這似乎是一種叫做 Builder Pattern 的設計模式)
// index.html
<script type="module">
import { createVM } from './view-model.esm.js'window.app = createVM({
title: 'User',
user: {
name: 'Tom',
profile: {
age: 18,
},
}
}).mount('#app')
</script>
修改 view-model.esm.js
追加一層糖,以符合新的初始化格式。
// view-model.esm.js
class VM {
constructor(data) {
this.data = data
console.log(this.data)
} mount(selector) {
console.log(selector)
return this
}
}function createVM(data = {}) {
return new VM(data)
}export { createVM }
存檔刷新後,可透過上面埋的 console.log 確認 Class VM 是否成功被建出來。
Compile HTML
接著來做第一步,把我們宣的 data 編譯到 html 模板上,將花括號包起來的表達式替換成實際 data 值。
class VM {
// ...
mount(selector) {
this.#compile(selector)
return this
} #compile(selector) {
const elem = document.querySelector(selector)
if (!elem) throw new Error(`Cannot found Element ${selector}`) // 建立虛擬 DOM 片段
const frag = document.createDocumentFragment()
frag.append(elem) // 掃描 DOM 替換 data
this.#bindDataToHTML(frag) // 塞回 body
document.body.prepend(frag)
} #bindDataToHTML(frag) {
// ...
}
}
這邊過了一層 DocumentFragment,把被 mount 的 DOM 挪到 JS 上。這樣就可以把 DOM 操作一口氣做完之後,再放回 Document Tree,讓瀏覽器只渲染一次。
#bindDataToHTML()
這裡面將會是單純的迴圈遍歷,由於 template 會有巢狀結構出現,例如最初我們寫在 html 上的 ul > li
,所以得做深層遍歷,因此把這塊 code 單獨拉成一個 function 以便後續做遞迴。
接著來寫 #bindDataToHTML()
。
#bindDataToHTML(frag) {
frag.childNodes.forEach((node) => {
// 處理 text node
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent
const reg = new RegExp(/\{\{(.*?)\}\}/, 'g')
if (!text || !reg.test(text)) return void 0 // 替換花括號 text 為 data
node.textContent = text.replace(reg, (_, exp) => {
return exp.split('.')
.reduce((data, key) => data[key.trim()], this.data)
})
} // 繼續往子層遞迴
if (node.hasChildNodes()) {
this.#bindDataToHTML(node)
}
})
}
抓 childNodes
而不是 children
是因為後者只會取 Element。但我們的目標是 Text Node,所以要用 childNodes
才能取到。
利用正規表達式來判斷 text 是否包含雙花括號,並配合 replace 就能篩選出 {{ exp }}
內的指令。花括號在正規裡面有其他含意,得加上 \
逃脫字元來標記他是 string,扣掉逃脫的斜線之後,會比較好看懂 /{{(.*?)}}/g
。
(.*?)
小括號裡面的部分是匹配任意字元重複 n 次,搭配 replace 使用時,callback 的第二參數會拿到小括號裡面的匹配到的字段。
最後再用 reduce 將 data 當作初始值,就能成功地不依靠 eval()
來安全的將 string 裡面的表達式轉換成可執行的 code 來取值。reduce 時要注意,做個 trim()
去除空白,這樣才能在花括號中加上空白排版 {{ exp }}
。
由於是這樣的概念,所以 Vue 沒辦法跟 React 一樣,在模板表達式上直接寫 console.log()
之類的 code 來執行,它會變成 data.console.log
取值。
Text Node 處理完之後,後面再加上一個有無 childNodes 的檢查,如果有就做遞迴。如此就完成了 template 的文本插值以及深層遍歷。
存檔刷新之後,應該就能看到 html 上被花括號標記的部分已經成功換成了 data。
單層 Data Binding
接著來做響應式的數據綁定,我們可以看到最初宣告的 data 是有巢狀的。
data {
title: 'User',
user: {
name: 'Tom',
profile: {
age: 18,
},
}
}
巢狀會比較麻煩,所以先來做第一層 data
的數據綁定。在此之前,來簡單說明一下響應式的原理。
前端的響應式狀態,其實就是一種監聽器的概念,跟 onClick
一樣,只是變成針對 data 的 onSet
。所以會有一點類似下面這種感覺。當 data 被賦值的時候,執行一個 callback 來重新渲染 template。
data.addEventListener('set', (newVal) => {
// re-render DOM
})
JS 有原生的 API 可以自定義 Object 的 setter,藉此達到監聽 onSet
的效果。Vue2 時代使用 Object.defineProperty()
來實現,而 Vue3 則改用 Proxy
API。
前者是以單一 prop 為單位來做定義,這樣要把所有的 prop 都掛上,就得跑迴圈。但後者是以整包 Object 為單位,只要掛一次 Proxy 就能套用到所有 prop 上 (僅限第一層),且 Proxy
的功能也更強大。
讓我們先把第一層的 data 給掛上 Proxy。
class VM {
constructor(data) {
this.data = this.#setProxy(data)
} #setProxy(obj) {
return new Proxy(obj, {
set: (target, prop, newValue) => {
const isSuccess = Reflect.set(target, prop, newValue)
// 觸發更新 html
if (isSuccess) { this.#trigger() }
return isSuccess
}
})
} // ... #trigger() {
console.log('更新 template')
}
}
setter 裡面很單純,就是賦值之後去觸發更新 template。為了讓 setProxy
功能聚焦在掛代理上面,所以把更新 template 的部分拆成 #trigger()
,之後再來完成他。
由於 setter 裡面有用到 this,所以注意一下這邊是用 prop 的方式宣告,而不是 method。
Reflect 是一個比較神奇的東西,很常搭配 Proxy 一起使用,但我也還沒搞清楚這具體有啥區別。可以將他當成是一包擁有許多原生 Object methods 的玩意,可以透過他來執行物件操作。
這邊相比直接賦值,Reflect 會返回布林。剛好 Proxy 的 setter 規定要返回一個布林,所以就可以使用 Reflect 來寫,看起來會比較高級 (?)。
// 用賦值來寫的話
set: (target, prop, newValue) => {
target[prop] = newValue
this.#trigger()
return true
}
現在已經對 data 本身掛了一層代理,可以在 devtool 上查看 app.data
,可以看到回傳的是一個 Proxy 物件。試著對第一層的 data.title
再賦值看看。應該可以觸發我們在 #trigger()
裡面寫上的 console.log()
。
app.data.title = '123'
// 更新 templateapp.data.user = '321'
// 更新 template
但是對第二層 data.user
底下的 prop 再賦值,就不會觸發我們掛在第一層上面的 Proxy setter。
// not reactive
app.data.user.name = "John"
對 onSet
掛上監聽之後,回到 #bindDataToHtml()
的地方,裡面實際對 textContent 做 replace 替換的 code 區塊,就是 #trigger()
要去再執行的 code 片段。
// #trigger() 中要執行的片段
node.textContent = text.replace(reg, (_, exp) => {
// ...
})
為了讓這段 code 可以被重複調用,將其打包成 function 拆出來。新增一個 #replace()
方法,將原本的 code 搬過去,然後做些調整。
#bindDataToHTML(frag) {
frag.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
// ...
if (!text || !reg.test(text)) return void 0 // 生成, 更新 template 之依賴 func
const dep = this.#replace(node, text, reg)
// 首次渲染
dep()
} // ...
})
}#replace(node, text, reg) {
// 閉包, 返回一個新函式
return () => {
// 替換花括號 text 為 data
node.textContent = text.replace(reg, (_, exp) => {
return exp.split('.')
.reduce((data, key) => data[key.trim()], this.data)
})
}
}
原本進行 replace 的地方搬到 #replace()
後,利用閉包的概念把當下對 childNodes 遍歷時拿到的 node、text 等內容給儲存起來。這樣 #trigger()
在呼叫的時候才能知道,到底是依賴哪個 node 做替換,而原本依賴的 text 文本內容又是甚麼。
透過閉包就可以在遍歷的當下,將這些必要資訊保存起來,生成一個新的函式。就能知道哪些 template 是被依賴的 (dependencies)、要刷新的目標。
接著,我們只要把這些 dep func 放到一個 Array 裡面,作為「被訂閱的依賴」保存起來,然後 onSet
觸發 #trigger()
的時候,把該陣列 each 執行一輪,就能達到響應式更新 template 的效果。
而這其實就是訂閱發佈模式 (Publish-Subscribe pattern) 的概念。
先來把儲存依賴的 Array 給宣出來。
class VM {
#subscriptions = [] constructor(data) {
// ...
}
}
然後回到方才 #bindDataToHTML()
生成 dep 的地方,將 dep 給訂閱蒐集起來。並讓 #trigger()
去執行那些被訂閱的依賴項。
#bindDataToHTML(frag) {
// ... // 生成, 更新 template 之依賴 func
const dep = this.#replace(node, text, reg)
// 首次渲染
dep() // 訂閱
this.#subscribe(dep) // ...
}#subscribe(dep) {
this.#subscriptions.push(dep)
}#trigger() {
this.#subscriptions.forEach((dep) => dep())
}
這樣第一層的 data binding 就完成了,儲存刷新頁面後可以來試一下。去修改一下 data.title
應該能看到頁面上的 title 也被響應式更新了!
// became reactive
app.data.title = 'Hello'
但是第二層 data.user
內的物件,仍然不是響應式,我們接著來實現深層掛 Proxy。
深層 Data Binding
要做深層掛 Proxy 的手段,跟前面深層遍歷 template 差不多,都可以利用遞迴來實現。
一步一步來,先看一下 data 格式。
data {
title: 'User',
user: {
name: 'Tom',
profile: {
age: 18,
},
}
}
來把第二層的 data.user
的代理給掛起來。
constructor(data) {
this.data = this.#initData(data)
}#initData(data) {
// 遍歷單層所有 object prop
for (const key in data) {
if (typeof data[key] === 'object') {
data[key] = this.#setProxy(data[key])
}
} return this.#setProxy(data)
}
原本是將 data 直接丟給 #setProxy()
處理。現在中間多安了插一層 #initData()
,裡面就是去遍歷 data 第一層的所有 value,如果是 object 就掛代理,是 primitive 就不動作。這樣 data.title
就維持原樣為 string,而 data.user
就會被掛上代理。
現在去 devtool 查看,應該就能看到 data.user
也已經是 Proxy 了,且修改 data.user.name
時,也會直接擁有響應式效果,因為 data binding 的邏輯我們前面都已經處理好了。
// became reactive
app.data.user.name = 'John'
接下來以此為基礎,將 #initData()
做成遞迴,這樣才能深度遍歷每一層,自動將剩下的第三層 data.user.profile
也掛上 Proxy。
#initData(data) {
// 不是 object 就返回原值
if (typeof data !== 'object') return data // 遍歷單層所有 object prop
for (const key in data) {
// 遞迴重複, 深層遍歷
data[key] = this.#initData(data[key])
} return this.#setProxy(data)
}
完成遞迴之後,第三層的 data.user.profile
也成功掛上 Proxy。現在去修改他的屬性,也會變成響應式了。
// became reactive
app.data.user.profile.age = 20
「指哪打哪」的響應式
雖然完成了類 Vue 文本插值的 data binding,成功實現了響應式。但如果去 #replace()
打個 console.log 的話……
#replace(node, text, reg) {
return () => {
console.log(text)
// ...
}
}
就會發現,每修改一個 data,所有的訂閱都被執行一次。
app.data.title = 'Hello'// Title {{ title }}
// {{ user.name }}
// {{ user.name }}
// {{ user.profile.age }}
如果想要做到「指哪打哪」,只更新與被賦值的 data 有關的依賴的話,我目前想到的作法,是給每一個訂閱都打一個 flag,就能根據這個 flag 知道每次數據改變時,對應的是哪些訂閱。
// 將訂閱改為 object, 方便用 key 呼叫
#subscription = {}#bindDataToHTML(frag) {
// ... // 訂閱時, 追加 flag
const expString = reg.exec(text)[1]
this.#subscribe(expString.trim(), dep) // ...
}#subscribe(key, dep) {
// 不存在就給預設, 空陣列
this.#subscription[key] ||= []
this.#subscription[key].push(dep)
}#trigger(key) {
// 改單字, 去掉訂閱的 s
this.#subscription[key].forEach((dep) => dep())
}
由於要改用 flag 也就是 key,來呼叫個別對應的訂閱,所以將訂閱從 array 改成 object,順便把變數名也改一下,去掉 s。
在 #bindDataToHTML()
加入訂閱的地方,多加一行用正規取出文本的 exp 當作 key 使用。修改 #subscribe()
訂閱的註冊方式,由於每個 key 各自也可能會有多個依賴,像當前的 user.name
就綁了兩條 DOM。所以每個 key 底下應該都會是一個依賴 Array。
// 訂閱依賴的長相
#subscription {
keyA: [dep1, dep2, ...],
keyB: [...],
// ...
}
修改完成後刷新,直接在 devtool 打 app
出來看一下,確認 #subscription
的 key 是否都成功掛上了當初在模板上輸入的 exp。
#subscription {
title: [ƒ]
user.name: (2) [ƒ, ƒ]
user.profile.age: [ƒ]
}
訂閱的部分改完後,就來改發佈的部分。上面 #trigger()
已經調整過,加入了一個呼叫特定訂閱用的參數 key
。來修改源頭的地方,也就是 Proxy 的 setter,將 key
給餵進去。
#initData(data, previousKey = '') {
if (typeof data !== 'object') return data // 遍歷單層所有 object prop
for (const key in data) {
// accumulate key
const accKey = previousKey ? `${previousKey}.${key}` : key
data[key] = this.#initData(data[key], accKey)
} return this.#setProxy(data, previousKey)
}#setProxy(obj, key) {
return new Proxy(obj, {
set: (target, prop, newValue) => {
const isSuccess = Reflect.set(target, prop, newValue)
// 觸發更新 html
const subscriptionKey = key ? `${key}.${prop}` : prop
if (isSuccess) { this.#trigger(subscriptionKey) }
return isSuccess
}
})
}
在 #initData()
的參數上加入 previousKey
,用類似於 reduce 的概念,對每一圈的 data key 做字串累加。
最後進到 #setProxy
的 key 只會有兩種可能,一個是第一層的,不用累加的,第二個是第 n 層的,需要把 setter 拿到的 prop
再累加進去,就能獲得最終的 key 字串,也就是我們寫在模板上,被訂閱的那些 exp。
這邊跑遞迴去做 reduce 的工作,邏輯會比較繞,可以多打幾個 console.log 做觀察。最初我在想這段的時候也是想了很久才成功寫出來。
存檔刷新之後,再去試著修改 data,會發現已經實現了指哪打哪的效果,不會每次都執行所有的訂閱依賴了!