javaScript怎麼實現雙向資料繫結
前言
談起當前前端最熱門的 js 框架,必少不了 Vue、React、Angular,對於大多數人來說,我們更多的是在使用框架,對於框架解決痛點背後使用的基本原理往往關注不多,近期在研讀 Vue.js 原始碼,也在寫原始碼解讀的系列文章。
和多數原始碼解讀的文章不同的是,我會嘗試從一個初級前端的角度入手,由淺入深去講解原始碼實現思路和基本的語法知識,通過一些基礎事例一步步去實現一些小功能。
本場 Chat 是系列 Chat 的開篇,我會首先講解一下資料雙向繫結的基本原理,介紹對比一下三大框架的不同實現方式,同時會一步步完成一個簡單的 mvvm 示例。
讀原始碼不是目的,只是一種學習的方式,目的是在讀原始碼的過程中提升自己,學習基本原理,拓展編碼的思維方式。
模板引擎實現原理
對於頁面渲染,一般分為伺服器端渲染和瀏覽器端渲染。一般來說伺服器端吐 html 頁面的方式渲染速度更快、更利於SEO,但是瀏覽器端渲染更利於提高開發效率和減少維護成本,是一種相關舒服的前後端協作模式,後端提供介面,前端做檢視和互動邏輯。
前端通過Ajax請求資料然後拼接html字串或者使用js模板引擎、資料驅動的框架如Vue進行頁面渲染。
在 ES6 和 Vue 這類框架出現以前,前端繫結資料的方式是動態拼接html字串和js模板引擎。
模板引擎起到資料和檢視分離的作用,模板對應檢視,關注如何展示資料,在模板外頭準備的資料, 關注那些資料可以被展示。
模板引擎的工作原理可以簡單地分成兩個步驟:模板解析 / 編譯(Parse / Compile)和資料渲染(Render)兩部分組成,當今主流的前端模板有三種方式:
-
String-based templating (基於字串的parse和compile過程)
-
Dom-based templating (基於Dom的link或compile過程)
-
Living templating (基於字串的parse 和 基於dom的compile過程)
String-based templating
基於字串的模板引擎,本質上依然是字串拼接的形式,只是一般的庫做了封裝和優化,提供了更多方便的語法簡化了我們的工作。基本原理如下:
典型的庫:
-
art-template( https://github.com/janl/mustache.js )
-
mustache.js( https://github.com/janl/mustache.js )
-
doT( https://github.com/olado/doT )
之前的一篇文章中我介紹了 js 模板引擎的實現思路,感興趣的朋友可以看看這裡:JavaScript進階學習(一)—— 基於正則表示式的簡單 js 模板引擎實現。
這篇文章中我們利用正則表示式實現了一個簡單的 js 模板引擎,利用正則匹配查找出模板中{{}}
之間的內容,然後替換為模型中的資料,從而實現檢視的渲染。
var template = function(tpl, data) {
var re = /{{(.+?)}}/g,
cursor = 0,
reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(.*)?/g,
code = 'var r=[];\n';
// 解析html
function parsehtml(line) {
// 單雙引號轉義,換行符替換為空格,去掉前後的空格
line = line.replace(/('|")/g, '\\$1').replace(/\n/g, ' ').replace(/(^\s+)|(\s+$)/g,"");
code +='r.push("' + line + '");\n';
}
// 解析js程式碼
function parsejs(line) {
// 去掉前後的空格
line = line.replace(/(^\s+)|(\s+$)/g,"");
code += line.match(reExp)? line + '\n' : 'r.push(' + 'this.' + line + ');\n';
}
// 編譯模板
while((match = re.exec(tpl))!== null) {
// 開始標籤 {{ 前的內容和結束標籤 }} 後的內容
parsehtml(tpl.slice(cursor, match.index));
// 開始標籤 {{ 和 結束標籤 }} 之間的內容
parsejs(match[1]);
// 每一次匹配完成移動指標
cursor = match.index + match[0].length;
}
// 最後一次匹配完的內容
parsehtml(tpl.substr(cursor, tpl.length - cursor));
code += 'return r.join("");';
return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
}
原始碼:http://jsrun.net/yaYKp/embedded/all/light/
現在ES6支援了模板字串,我們可以用比較簡單的程式碼就可以實現類似的功能:
const template = data => `
<p>name: ${data.name}</p>
<p>age: ${data.profile.age}</p>
<ul>
${data.skills.map(skill => `
<li>${skill}</li>
`).join('')}
</ul>`
const data = {
name: 'zhaomenghuan',
profile: { age: 24 },
skills: ['html5', 'javascript', 'android']
}
document.body.innerHTML = template(data)
Dom-based templating
Dom-based templating 則是從 DOM 的角度去實現資料的渲染,我們通過遍歷 DOM 樹,提取屬性與 DOM 內容,然後將資料寫入到 DOM 樹中,從而實現頁面渲染。一個簡單的例子如下:
function MVVM(opt) {
this.dom = document.querySelector(opt.el);
this.data = opt.data || {};
this.renderDom(this.dom);
}
MVVM.prototype = {
init: {
sTag: '{{',
eTag: '}}'
},
render: function (node) {
var self = this;
var sTag = self.init.sTag;
var eTag = self.init.eTag;
var matchs = node.textContent.split(sTag);
if (matchs.length){
var ret = '';
for (var i = 0; i < matchs.length; i++) {
var match = matchs[i].split(eTag);
if (match.length == 1) {
ret += matchs[i];
} else {
ret = self.data[match[0]];
}
node.textContent = ret;
}
}
},
renderDom: function(dom) {
var self = this;
var attrs = dom.attributes;
var nodes = dom.childNodes;
Array.prototype.forEach.call(attrs, function(item) {
self.render(item);
});
Array.prototype.forEach.call(nodes, function(item) {
if (item.nodeType === 1) {
return self.renderDom(item);
}
self.render(item);
});
}
}
var app = new MVVM({
el: '#app',
data: {
name: 'zhaomenghuan',
age: '24',
color: 'red'
}
});
原始碼:http://jsrun.net/faYKp/embedded/all/light/
頁面渲染的函式 renderDom 是直接遍歷 DOM 樹,而不是遍歷 html 字串。遍歷 DOM 樹節點屬性(attributes)和子節點(childNodes),然後呼叫渲染函式 render。
當 DOM 樹子節點的型別是元素時,遞迴呼叫遍歷 DOM 樹的方法。根據 DOM 樹節點型別一直遍歷子節點,直到文字節點。
render 的函式作用是提取{{}}
中的關鍵詞,然後使用資料模型中的資料進行替換。我們通過
textContent 獲取 Node 節點的 nodeValue,然後使用字串的split方法對 nodeValue 進行分割,提取{{}}
中的關鍵詞然後替換為資料模型中的值。
DOM 的相關基礎
注:元素型別對應NodeType
元素型別 | NodeType |
---|---|
元素 | 1 |
屬性 | 2 |
文字 | 3 |
註釋 | 8 |
文件 | 9 |
childNodes 屬性返回包含被選節點的子節點的 NodeList。childNodes包含的不僅僅只有 html 節點,所有屬性,文字、註釋等節點都包含在 childNodes 裡面。children 只返回元素如 input, span, script, div 等,不會返回 TextNode,註釋。
資料雙向繫結實現原理
js 模板引擎可以認為是一個基於 MVC 的結構,我們通過建立模板作為檢視,然後通過引擎函式作為控制器實現資料和檢視的繫結,從而實現實現資料在頁面渲染,但是當資料模型發生變化時,檢視不能自動更新;
當檢視資料發生變化時,模型資料不能實現更新,這個時候雙向資料繫結應運而生。
檢測檢視資料更新實現資料繫結的方法有很多種,目前主要分為三個流派,Angular 使用的是髒檢查,只在特定的事件下才會觸發檢視重新整理,Vue 使用的是 Getter/Setter 機制,而React則是通過 Virtual DOM 演算法檢查 DOM 的變動的重新整理機制。
本文限於篇幅和內容在此只探討一下 Vue.js 資料繫結的實現,對於 angular 和 react 後續再做說明,讀者也可以自行閱讀原始碼。
Vue 監聽資料變化的機制是把一個普通 JavaScript 物件傳給 Vue 例項的 data 選項,Vue 將遍歷此物件所有的屬性,並使用 Object.defineProperty 把這些屬性全部轉為 getter/setter。
Vue 2.x 對 Virtual DOM 進行了支援,這部分內容後續我們再做探討。
引子
為了更好的理解Vue中檢視和資料更新的機制,我們先看一個簡單的例子:
var o = {
a: 0
}
Object.defineProperty(o, "b", {
get: function () {
return this.a + 1;
},
set: function (value) {
this.a = value / 2;
}
});
console.log(o.a); // "0"
console.log(o.b); // "1"
// 更新o.a
o.a = 5;
console.log(o.a); // "5"
console.log(o.b); // "6"
// 更新o.b
o.b = 10;
console.log(o.a); // "5"
console.log(o.b); // "6"
這裡我們可以看出物件o的b屬性的值依賴於a屬性的值,同時b屬性值的變化又可以改變a屬性的值,這個過程相關的屬性值的變化都會影響其他相關的值進行更新。
反過來我們看看如果不使用 Object.defineProperty() 方法,上述的問題通過直接給物件屬性賦值的方法實現,程式碼如下:
var o = {
a: 0
}
o.b = o.a + 1;
console.log(o.a); // "0"
console.log(o.b); // "1"
// 更新o.a
o.a = 5;
o.b = o.a + 1;
console.log(o.a); // "5"
console.log(o.b); // "6"
// 更新o.b
o.b = 10;
o.a = o.b / 2;
o.b = o.a + 1;
console.log(o.a); // "5"
console.log(o.b); // "6"
很顯然使用Object.defineProperty()
方法可以更方便的監聽一個物件的變化。當我們的檢視和資料任何一方發生變化的時候,我們希望能夠通知對方也更新,這就是所謂的資料雙向繫結。
既然明白這個道理我們就可以看看Vue原始碼中相關的處理細節。
Object.defineProperty( )
Object.defineProperty( ) 方法可以直接在一個物件上定義一個新屬性,或者修改一個已經存在的屬性, 並返回這個物件。
語法:Object.defineProperty(obj, prop, descriptor)
引數:
-
obj:需要定義屬性的物件。
-
prop:需被定義或修改的屬性名。
-
descriptor:需被定義或修改的屬性的描述符。
返回值:返回傳入函式的物件,即第一個引數obj.
該方法重點是描述,物件裡目前存在的屬性描述符有兩種主要形式:資料描述符和存取描述符。資料描述符是一個擁有可寫或不可寫值的屬性。
存取描述符是由一對 getter-setter 函式功能來描述的屬性。描述符必須是兩種形式之一;不能同時是兩者。
資料描述符和存取描述符均具有以下可選鍵值:
-
configurable:當且僅當該屬性的 configurable 為 true 時,該屬性才能夠被改變,也能夠被刪除。預設為 false。
-
enumerable:當且僅當該屬性的 enumerable 為 true 時,該屬性才能夠出現在物件的列舉屬性中。預設為 false。
資料描述符同時具有以下可選鍵值:
-
value:該屬性對應的值。可以是任何有效的 JavaScript 值(數值,物件,函式等)。預設為 undefined。
-
writable:當且僅當僅當該屬性的writable為 true 時,該屬性才能被賦值運算子改變。預設為 false。
存取描述符同時具有以下可選鍵值:
-
get:一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。該方法返回值被用作屬性值。預設為undefined。
-
set:一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。該方法將接受唯一引數,並將該引數的新值分配給該屬性。預設為undefined。
我們可以通過 Object.defineProperty( ) 方法精確新增或修改物件的屬性。比如,直接賦值建立的屬性預設情況是可以列舉的,但是我們可以通過 Object.defineProperty( ) 方法設定 enumerable 屬性為 false 為不可列舉。
var obj = {
a: 0,
b: 1
}
for (var prop in obj) {
console.log(`obj.${prop} = ${obj[prop]}`);
}
結果:
"obj.a = 0"
"obj.b = 1"
我們通過 Object.defineProperty( ) 修改如下:
var obj = {
a: 0,
b: 1
}
Object.defineProperty(obj, 'b', {
enumerable: false
})
for (var prop in obj) {
console.log(`obj.${prop} = ${obj[prop]}`);
}
結果:
"obj.a = 0"
這裡需要說明的是我們使用Object.defineProperty()預設情況下是enumerable屬性為false,例如:
var obj = {
a: 0
}
Object.defineProperty(obj, 'b', {
value: 1
})
for (var prop in obj) {
console.log(`obj.${prop} = ${obj[prop]}`);
}
結果:
"obj.a = 0"
其他描述屬性使用方法類似,不做贅述。Vue原始碼core/util/lang.js
S中定義了這樣一個方法:
/**
* Define a property.
*/
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
Object.getOwnPropertyDescriptor( )
Object.getOwnPropertyDescriptor() 返回指定物件上一個自有屬性對應的屬性描述符。(自有屬性指的是直接賦予該物件的屬性,不需要從原型鏈上進行查詢的屬性)
語法:Object.getOwnPropertyDescriptor(obj, prop)
引數:
-
obj:在該物件上檢視屬性。
-
prop:一個屬性名稱,該屬性的屬性描述符將被返回。
返回值:如果指定的屬性存在於物件上,則返回其屬性描述符(property descriptor),否則返回 undefined。可以訪問“屬性描述符”內容,例如前面的例子:
var o = {
a: 0
}
Object.defineProperty(o, "b", {
get: function () {
return this.a + 1;
},
set: function (value) {
this.a = value / 2;
}
});
var des = Object.getOwnPropertyDescriptor(o,'b');
console.log(des);
console.log(des.get);
Vue原始碼分析
本次我們主要分析一下Vue 資料繫結的原始碼,這裡我直接將 Vue.js 1.0.28 版本的程式碼稍作刪減拿過來進行,2.x 的程式碼基於 flow 靜態型別檢查器書寫的,程式碼除了編碼風格在整體結構上基本沒有太大改動,所以依然基於 1.x 進行分析,對於存在差異的部分加以說明。
監聽物件變動
// 觀察者建構函式
function Observer (value) {
this.value = value
this.walk(value)
}
// 遞迴呼叫,為物件繫結getter/setter
Observer.prototype.walk = function (obj) {
var keys = Object.keys(obj)
for (var i = 0, l = keys.length; i < l; i++) {
this.convert(keys[i], obj[keys[i]])
}
}
// 將屬性轉換為getter/setter
Observer.prototype.convert = function (key, val) {
defineReactive(this.value, key, val)
}
// 建立資料觀察者例項
function observe (value) {
// 當值不存在或者不是物件型別時,不需要繼續深入監聽
if (!value || typeof value !== 'object') {
return
}
return new Observer(value)
}
// 定義物件屬性的getter/setter
function defineReactive (obj, key, val) {
var property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// 儲存物件屬性預先定義的getter/setter
var getter = property && property.get
var setter = property && property.set
var childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val
console.log("訪問:"+key)
return value
},
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val
if (newVal === value) {
return
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
// 對新值進行監聽
childOb = observe(newVal)
console.log('更新:' + key + ' = ' + newVal)
}
})
}
定義一個物件作為資料模型,並監聽這個物件。
let data = {
user: {
name: 'zhaomenghuan',
age: '24'
},
address: {
city: 'beijing'
}
}
observe(data)
console.log(data.user.name)
// 訪問:user
// 訪問:name
data.user.name = 'ZHAO MENGHUAN'
// 訪問:user
// 更新:name = ZHAO MENGHUAN
效果如下:
監聽陣列變動
上面我們通過 Object.defineProperty 把物件的屬性全部轉為 getter/setter 從而實現監聽物件的變動,但是對於陣列物件無法通過 Object.defineProperty 實現監聽。Vue 包含一組觀察陣列的變異方法,所以它們也將會觸發檢視更新。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
// 陣列的變異方法
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
// 快取陣列原始方法
var original = arrayProto[method]
def(arrayMethods, method, function mutator () {
var i = arguments.length
var args = new Array(i)
while (i--) {
args[i] = arguments[i]
}
console.log('陣列變動')
return original.apply(this, args)
})
})
Vue.js 1.x 在 Array.prototype 原型物件上添加了$set
和 $remove
方法,在2.X後移除了,使用全域性
API Vue.set
和 Vue.delete
代替了,後續我們再分析。
定義一個數組作為資料模型,並對這個陣列呼叫變異的七個方法實現監聽。
let skills = ['JavaScript', 'Node.js', 'html5']
// 原型指標指向具有變異方法的陣列物件
skills.__proto__ = arrayMethods
skills.push('java')
// 陣列變動
skills.pop()
// 陣列變動
效果如下:
我們將需要監聽的陣列的原型指標指向我們定義的陣列物件,這樣我們的陣列在呼叫上面七個陣列的變異方法時,能夠監聽到變動從而實現對陣列進行跟蹤。
對於__proto__
屬性,在ES2015中正式被加入到規範中,標準明確規定,只有瀏覽器必須部署這個屬性,其他執行環境不一定需要部署,所以
Vue 是先進行了判斷,當__proto__
屬性存在時將原型指標__proto__
指向具有變異方法的陣列物件,不存在時直接將具有變異方法掛在需要追蹤的物件上。
我們可以在上面Observer觀察者建構函式中新增對陣列的監聽,原始碼如下:
const hasProto = '__proto__' in {}
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
// 觀察者建構函式
function Observer (value) {
this.value = value
if (Array.isArray(value)) {
var augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}
// 觀察陣列的每一項
Observer.prototype.observeArray = function (items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
// 將目標物件/陣列的原型指標__proto__指向src
function protoAugment (target, src) {
target.__proto__ = src
}
// 將具有變異方法掛在需要追蹤的物件上
function copyAugment (target, src, keys) {
for (var i = 0, l = keys.length; i < l; i++) {
var key = keys[i]
def(target, key, src[key])
}
}
原型鏈
對於不瞭解原型鏈的朋友可以看一下我這裡畫的一個基本關係圖:
-
原型物件是建構函式的 prototype 屬性,是所有例項化物件共享屬性和方法的原型物件。
-
例項化物件通過 new 建構函式得到,都繼承了原型物件的屬性和方法。
-
原型物件中有個隱式的 constructor,指向了建構函式本身。
Object.create
Object.create 使用指定的原型物件和其屬性建立了一個新的物件。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
這一步是通過 Object.create 建立了一個原型物件為Array.prototype的空物件。然後通過 Object.defineProperty 方法對這個物件定義幾個變異的陣列方法。
有些新手可能會直接修改 Array.prototype 上的方法,這是很危險的行為,這樣在引入的時候會全域性影響 Array 物件的方法,而使用 Object.create 實質上是完全了一份拷貝,新生成的 arrayMethods 物件的原型指標__proto__
指向了
Array.prototype,修改 arrayMethods 物件不會影響 Array.prototype。
基於這種原理,我們通常會使用 Object.create 實現類式繼承。
// 實現繼承
var extend = function(Child, Parent) {
// 拷貝Parent原型物件
Child.prototype = Object.create(Parent.prototype);
// 將Child建構函式賦值給Child的原型物件
Child.prototype.constructor = Child;
}
// 例項
var Parent = function () {
this.name = 'Parent';
}
Parent.prototype.getName = function () {
return this.name;
}
var Child = function () {
this.name = 'Child';
}
extend(Child, Parent);
var child = new Child();
console.log(child.getName())
釋出-訂閱模式
在上面一部分我們通過 Object.defineProperty 把物件的屬性全部轉為 getter/setter 以及 陣列變異方法實現了對資料模型變動的監聽,在資料變動的時候,我們通過 console.log 打印出來提示了。
但是對於框架而言,我們相關的邏輯如果直接寫在那些地方,自然是不夠優雅和靈活的,這