【MVVM】如何手刻 Vue 的 Text Interpolations 與 Data Binding

Lastor
Code 隨筆放置場
21 min readOct 16, 2022
MVVM Data Binding

前陣子跟一些工程師前輩閒聊,談到了無法使用 Vue / React 等框架,只能使用原生 JavaScript 的情況下,UI 交互該怎麼寫會更好?

聊著聊著就意識到,雖然曾用 Class 跟 OOP 概念,自幹過一些微型架構出來,但那些常聽到的 MVC、MVVM 與響應式這些,我僅限於略懂,還不曾實際手刻過。

於是就花了點時間研究 Vue 的 MVVM 架構,以及現在前端最常規的響應式 Data binding 是怎麼做的。

這篇將會實作類似 Vue 的文本插值 (Text Interpolations),以及數據綁定 (Data Binding)。大概會涉及到這些技術知識點:

  1. 如何將 data 編譯到 HTML 的標記上 {{ expression }}
  2. 如何使用 Array.reduce() 對 template 標記取值
  3. 如何使用 Proxy API 監聽 data 的 setter 以實現響應式數據綁定
  4. 使用訂閱/發佈模式的概念,收集需編譯的依賴文本對象
  5. 使用閉包 (Closure) 的概念,紀錄依賴對像
  6. 如何使用遞迴進行深層遍歷
  7. 如何做到「指哪打哪」的響應式

目標是實現下面這段 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'
// 更新 template
app.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,會發現已經實現了指哪打哪的效果,不會每次都執行所有的訂閱依賴了!

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。