Vue 原始碼實作: Data Binding 雙向資料系結(使用 Object.defineProperty 實作)
文章目錄
- Vue 原始碼實作: Data Binding 雙向資料系結(使用 Object.defineProperty 實作)
- 簡介
- 參考
- 正文
- 實作目標
- 實作架構
- 具體實作
- 專案結構 & 靜態內容初始化
- 核心代碼
- 物件間互動順序 & 圖
- input 輸入
- 修改 `$data` 屬性
- 結語
簡介
本篇要來干大事了(bushi,大名鼎鼎的 Vue 作為一個最潮的 MVVM 框架,實作了雙向資料系結和虛擬 dom 等多項復雜技術,本篇將要嘗試實作 Vue2 所使用的方法(借助 Object.defineProperty)來實作雙向資料系結,走起
參考
| vue 雙向資料系結的實作學習(二)- 監聽器的實作 | https://www.cnblogs.com/adouwt/p/10039900.html |
正文
實作目標
首先我們先來明確我們的實作目標:v-model 實作雙向資料系結,也就是修改系結變數時同時更新輸入框的值;或是輸入框輸入時更新系結變數的值,圖示如下

我們的目標就是將左邊的 input 輸入框與右側 data 中的 name 變數系結起來(透過形如 <input v-model="name"> 的形式)
那么接下來我們要做的就是創建一個管理中心(MVVM)來幫我們完成這件事情:

實作架構
在開始上代碼之前,我們先來談談抽象的實作架構,前面我們提到我們的實作目標就是創建一個 MVVM 來幫我們完成雙向系結的作業,而這個 MVVM 內部又可以細分成三種成員:
Compiler 模版渲染器:通過編譯特定模版代碼之后渲染成實際 domWatcher 觀察者:觀察系結變數是否被修改,并通知 Compiler 重新渲染Dispatcher 調度中心:負責決定通知哪個觀察者更新,系結變數修改時通知調度中心進而通知觀察者更新,如下圖所示

具體實作
接下來我們就來著手實作模擬 Vue2 中的雙向資料系結技術
注意:這里的實作代碼有非常多的漏洞,如:
- 未實作
模版渲染 Compiler的決議,而是直接修改目標 dom 的內容 - 實作中只系結了一個
name屬性- 所以從頭到尾只存在一個
Watcher,所以整個程序Dispatcher.target并未更動也未做區分 - 系結到實體的
$prop直接設為name,當存在多個系結屬性的時候就必須加以區分
- 所以從頭到尾只存在一個
專案的構成參考了前一篇Express 實戰: 使用 express-static 處理靜態資源的方式創建的專案
專案結構 & 靜態內容初始化
首先給出專案結構:
/data-binding-defineProperty
.
├── package.json
├── server.js // 靜態資源服務器入口
├── src/
│ ├── compiler.js // Compiler(模版渲染)
│ ├── dispatcher.js // Dispatcher(調度中心)
│ ├── favicon32.ico
│ ├── index.css // 模版樣式
│ ├── index.html // 模版代碼
│ ├── main.js // 主入口
│ ├── observe.js // 觀察方法(observe、reactive)
│ ├── vue.js // Vue (MVVM)
│ └── watcher.js // Watcher(觀察者)
└── yarn.lock
接下來給出幾個靜態檔案的初始化內容
package.json:加入
start作為啟動命令
{
"name": "js_data_binding_vue2",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.17.1",
"express-static": "^1.2.6"
}
}
server.js
const express = require('express')
const expressStatic = require('express-static')
const app = express()
app.use(expressStatic('./src'))
const port = 3000
app.listen(port, () => {
console.log(`server listen at: http://localhost:${port}`)
})
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>data-binding Vue2</title>
<link rel="icon" href="favicon32.ico">
<link rel="stylesheet" href="index.css">
</head>
<body>
<div id="app">
<input type="text" class="input" v-model="name">
<h1 class="content">{{ name }}</h1>
</div>
<script src="main.js" type="module"></script>
</body>
</html>
index.css
#app {
width: 500px;
margin: 100px auto;
text-align: center;
}
核心代碼
接下來給出具體的核心代碼實作,相關說明請看代碼注釋:
main.js:主入口
import Vue from './vue.js'
// 創建 MVVM 類,并暴露成全域變數 vm 供訪問
window.vm = new Vue({
el: '#app', // 模版選擇器,即替換目標
data: { // 資料項
name: 'default name'
}
})
vue.js:MVVM 類實作
import observe from './observe.js'
import Compiler from './compiler.js'
// MVVM 主類
export default function Vue (options) {
// 初始化各屬性
this.$options = options // 傳入引數備份
this.$el = document.querySelector(options.el) // 選到實際 dom 元素
this.$data = options.data // 資料項備份
Object.keys(this.$data).forEach(key => {
this.$prop = key // 目前只有一個系結屬性 name
})
this.init() // 初始化
}
Vue.prototype.init = function () {
// 初始化時遞回觀察 this.$data 資料項
observe(this.$data)
// 創建模版渲染物件,于自身系結(傳入 vm)
new Compiler(this)
}
observe.js:觀察函式實作
import Dispatcher from './dispatcher.js'
// 觀察(系結)資料項
export default function observe (data) {
// 只對 object 作用
if (!data || typeof data !== 'object') {
return
}
// 激活 reactive 物件的每個鍵
Object.keys(data).forEach(key => {
reactive(data, key, data[key])
})
}
// 激活函式
function reactive (data, key, value) {
// 對每個鍵創建獨有的調度中心 Dispatcher
const dp = new Dispatcher()
// 使用 Object.defineProperty 設定成訪問器屬性(getter/setter)
Object.defineProperty(data, key, {
get () {
// 訪問屬性時檢查當前訪問者是否已經訂閱該屬性
if (Dispatcher.target && !dp.subs.includes(Dispatcher.target)) {
dp.addSub(Dispatcher.target)
}
return value
},
set (newValue) {
if (value !== newValue) {
// 實際的值透過閉包系結到區域變數 value 上
value = newValue
// 每次更新就透過 Dispatcher 更新(notify 將通知所有 subs)
dp.notify()
}
}
})
// 遞回觀察
observe(value)
}
dispatcher.js:調度中心實作
export default function Dispatcher () {
// 訂閱者串列,是一個 Watcher 串列
this.subs = []
}
Dispatcher.target = null
Dispatcher.prototype.notify = function () {
// 通知更新時提醒所有觀察者更新(呼叫 sub.update())
this.subs.forEach(sub => {
sub.update()
})
}
Dispatcher.prototype.addSub = function (sub) {
// 添加訂閱
this.subs.push(sub)
}
watcher.js:觀察者實作
import Dispatcher from './dispatcher.js'
// 觀察者(訂閱者)
export default function Watcher (vm, prop, callback) {
this.vm = vm
this.$prop = prop
this.value = this.get()
this.callback = callback
}
Watcher.prototype.get = function () {
Dispatcher.target = this
const value = this.vm.$data[this.$prop]
return value
}
Watcher.prototype.update = function () {
const value = this.vm.$data[this.$prop]
const oldValue = this.value
// 觀察者更新時檢查當前保留資料(this.value 將與實際 dom 展示資料同步)
// 與 新資料(data.name 為實際系結資料)
if (oldValue !== value) {
// 不相同時則更新 this.value 并通知 Compiler 更新 dom(callback 為 Compiler 傳入的更新 dom 函式)
this.value = value
this.callback(this.value)
}
}
compiler.js:模版渲染實作(非常簡陋的實作)
import Watcher from './watcher.js'
// 模版渲染器
export default function Compiler (vm) {
this.vm = vm
this.$el = vm.$el
this.fragment = null
this.init()
}
Compiler.prototype.init = function () {
// 初始化時使用 data 的值替換 dom 展示的內容
// 這邊沒有實作模版決議,而是直接指定 data.name 并替換標簽內容(textContent)
let value = this.vm.$data.name
document.querySelector('.input').value = value
document.querySelector('.content').textContent = value
// 為觀察屬性(prop)創建相應的觀察者,并傳入能夠更新模版內容的回呼函式(callback)
// 這邊只有一個 data.name 屬性,并且回呼函式直接修改指定標簽內容
// 正常實作是需要遇上方模版決議語法配合,在虛擬 dom 上修改相應標簽
new Watcher(this.vm, this.vm.$prop, value => {
document.querySelector('.input').value = value
document.querySelector('.content').textContent = value
})
// 為輸入框添加監聽函式
document.querySelector('.input').addEventListener('input', e => {
const targetValue = e.target.value
if (value !== targetValue) {
// 輸入框的值修改時直接修改系結變數的值
this.vm.$data.name = targetValue
// 并直接更新模版內容
document.querySelector('.input').value = targetValue
document.querySelector('.content').textContent = targetValue
}
}, false) // 默認 false 為冒泡事件
}
物件間互動順序 & 圖

所謂雙向系結就是不管我們是(1)修改系結變數(2)輸入框輸入哪種操作另一種都會同步,所以我們分別從兩個路線來看物件間是如何互動的:
input 輸入
當我們在輸入框進行輸入時會產生 input 事件,并進入到 compiler.js 的
// 為輸入框添加監聽函式
document.querySelector('.input').addEventListener('input', e => {
const targetValue = e.target.value
if (value !== targetValue) {
// 輸入框的值修改時直接修改系結變數的值
this.vm.$data.name = targetValue
// 并直接更新模版內容
document.querySelector('.input').value = targetValue
document.querySelector('.content').textContent = targetValue
}
}, false) // 默認 false 為冒泡事件
這段處理函式,他會直接修改 $data.name 的值并更新模版,而修改 $data.name 則會觸發 observe.js 中定義的訪問器屬性(set 方法)
// 使用 Object.defineProperty 設定成訪問器屬性(getter/setter)
Object.defineProperty(data, key, {
get () {
// 訪問屬性時檢查當前訪問者是否已經訂閱該屬性
if (Dispatcher.target && !dp.subs.includes(Dispatcher.target)) {
dp.addSub(Dispatcher.target)
}
return value
},
set (newValue) {
if (value !== newValue) {
// 實際的值透過閉包系結到區域變數 value 上
value = newValue
// 每次更新就透過 Dispatcher 更新(notify 將通知所有 subs)
dp.notify()
}
}
})
這時候就會更新閉包中的 value 并通知相應的 Dispatcher 進行更新 notify,notify 函式則會通知所有觀察者進行更新(watcher.js)
Watcher.prototype.update = function () {
const value = this.vm.$data[this.$prop]
const oldValue = this.value
// 觀察者更新時檢查當前保留資料(this.value 將與實際 dom 展示資料同步)
// 與 新資料(data.name 為實際系結資料)
if (oldValue !== value) {
// 不相同時則更新 this.value 并通知 Compiler 更新 dom(callback 為 Compiler 傳入的更新 dom 函式)
this.value = value
this.callback(this.value)
}
}
觀察者 Watcher 就會根據 Compiler 提供的回呼函式更新模版內容
修改 $data 屬性
第二種可能是直接修改 $data.name 的值,就會經過 set -> dp.notify() -> sub.update() -> callback() 的路徑更新系結標簽的值啦,
結語
本篇模仿 Vue2 使用 Object.defineProperty 實作雙向資料系結,不過最重要的是缺少了模版決議和渲染的部分(這很重要,應該只修改 {{}} 所覆寫的范圍而不是修改整個標簽內容,同時應該為每個 {{}} 參考創建相應的觀察者 Watcher 才是正確的實作),供大家參考,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/234237.html
標籤:其他
下一篇:MyBatis學習總結
