您好,登錄后才能下訂單哦!
開發(fā)者的javascript造詣取決于對(duì)【動(dòng)態(tài)】和【異步】這兩個(gè)詞的理解水平。
MVVM
模型是前端單頁面應(yīng)用中非常重要的模型之一,也是Single Page Application
的底層思想,如果你也因?yàn)樽约簩W(xué)習(xí)的速度拼不過開發(fā)框架版本迭代的速度,或許也應(yīng)該從更高的抽象層次去理解現(xiàn)代前端開發(fā),因?yàn)槠鋵?shí)最核心的經(jīng)典思想幾乎都是不怎么變的。關(guān)于MVVM
的文章已經(jīng)非常多了,本文不再贅述。
筆者之前聽過一種很形象的描述覺得有必要提一下,Model
可以想象成HTML代碼
,ViewModel
可以想象成瀏覽器
,而View
可以想象成我們最終看到的頁面, 那么各個(gè)層次所扮演的角色和所需要處理的邏輯就比較清晰了。
數(shù)據(jù)綁定,就是將視圖層表現(xiàn)和模型層的數(shù)據(jù)綁定在一起,關(guān)于MVVM
中的數(shù)據(jù)綁定,涉及兩個(gè)基本概念單向數(shù)據(jù)綁定和雙向數(shù)據(jù)綁定,其實(shí)兩者并沒有絕對(duì)的優(yōu)劣,只是適用場(chǎng)景不同,現(xiàn)×××發(fā)框架都是同時(shí)支持兩種形式的。
雙向數(shù)據(jù)綁定由Angularjs1.x
發(fā)展起來,在表單等用戶體驗(yàn)高度依賴于即時(shí)反饋的場(chǎng)景中非常便利,但并不是所有場(chǎng)景下都適用的,Angularjs
中也可以通過ng-bind=":expr"
的形式來實(shí)現(xiàn)單向綁定;在Flux
數(shù)據(jù)流架構(gòu)的影響下,更加易于追蹤和管理的單向數(shù)據(jù)流思想出現(xiàn)了,各主流框架也進(jìn)行了實(shí)現(xiàn)(例如redux
,vuex
),在單向數(shù)據(jù)綁定的框架中,開發(fā)者仍然可以在需要的地方監(jiān)聽變化來手動(dòng)實(shí)現(xiàn)雙向綁定。
關(guān)于Angularjs1.x
中如何通過臟檢查機(jī)制來實(shí)現(xiàn)雙向數(shù)據(jù)綁定和管理,可以參見《構(gòu)建自己的AngularJS,第一部分:Scope和Digest》一文,講述得非常詳細(xì)。
Vue2.0
版本中的雙向數(shù)據(jù)綁定,很多開發(fā)者都知道是通過劫持屬性的get/set
方法來實(shí)現(xiàn)的,上圖已經(jīng)展示了雙向數(shù)據(jù)綁定的代碼框架,分析源碼的文章也非常多,許多文章都將重點(diǎn)放在了發(fā)布訂閱模式的實(shí)現(xiàn)上,筆者自己閱讀時(shí)有兩大困擾點(diǎn):
第一,即使通過defineProperty
劫持了屬性的get/set
方法,不知道數(shù)據(jù)模型和頁面之間又是如何聯(lián)系起來的。(很多文章都是順帶一提而沒有詳述,實(shí)際上這部分對(duì)于整體理解MVVM
數(shù)據(jù)流非常重要)
第二,Vue2.0
在實(shí)現(xiàn)發(fā)布訂閱模式的時(shí)候,使用了一個(gè)Dep
類作為訂閱器來管理發(fā)布訂閱行為,從代碼的角度講這樣做是很好的實(shí)踐,它可以將訂閱者管理(例如避免重復(fù)訂閱)這種與業(yè)務(wù)無關(guān)的代碼解耦出來,符合單一職責(zé)的開發(fā)原則。但這樣做對(duì)于理清代碼邏輯而言會(huì)造成困擾,讓發(fā)布-訂閱相關(guān)的代碼段變得模糊,實(shí)際上將Dep
類與發(fā)布者類合并在一起,綁定原理會(huì)更加清晰,而在代碼迭代中,考慮到更多更復(fù)雜的情況時(shí),即使你是框架的設(shè)計(jì)者,也會(huì)很自然地選擇將Dep
抽象成一個(gè)獨(dú)立的類。
如果你也在閱讀博文的時(shí)候出現(xiàn)同樣的困惑,強(qiáng)烈建議讀完本篇后自己動(dòng)手實(shí)現(xiàn)一個(gè)
MVVM
的雙向綁定,你會(huì)發(fā)現(xiàn)很多時(shí)候你不理解一些代碼,是因?yàn)槟悴恢雷髡呙鎸?duì)了怎樣的實(shí)際問題。
ps:下文提及的觀察者類和發(fā)布者類是指同一個(gè)類。
我們先來寫幾個(gè)包含自定義指令的標(biāo)簽:
<div id="app" class="container">
<input type="text" d-model="myname">
<br>
輸入的是:<span d-bind="myname"></span>
<br>
<button d-click="alarm()">廣播報(bào)警</button>
</div>
<script>
var options = {
el:'app',
data:{
myname:'僵尸'
},
methods:{
alarm:function (node,event) {
window.alert(`一大波【${this.data.myname}】正在靠近!`);
}
}
}
//初始化
var vm = new Dash(options);
</script>
需要實(shí)現(xiàn)的功能就如同你在所有框架中見到的那樣:<input>標(biāo)簽的值通過d-model
指令和數(shù)據(jù)模型中的myname進(jìn)行雙向綁定,<span>標(biāo)簽的值通過d-bind
指令從myname單向獲取,<button>標(biāo)簽的點(diǎn)擊響應(yīng)通過d-click
綁定數(shù)據(jù)模型中的alarm()方法。初始化所用到的方法已經(jīng)提供好了,假如我們要在一個(gè)叫做Dash的MVVM
框架中實(shí)現(xiàn)數(shù)據(jù)綁定,那么第一步要做的,是模板解析。
DOM標(biāo)簽自身是一個(gè)樹形結(jié)構(gòu),所以需要從最外層的<div>為起點(diǎn)以遞歸的方式來進(jìn)行解析。
compiler.js——模板解析器類
/**
* 模板編譯器
*/
class Compiler{
constructor(){
this.strategy = new Strategy();//封裝的策略類,下一節(jié)描述
this.strategyKeys = Object.keys(this.strategy);
}
/**
*編譯方法
*@params vm Dash類的實(shí)例(即VisualModel實(shí)例)
*@params node 待編譯的DOM節(jié)點(diǎn)
*/
compile(vm, node){
if (node.nodeType === 3) {//解析文本節(jié)點(diǎn)
this.compileTextNode(vm, node);
}else{
this.compileNormalNode(vm, node);
}
}
/**
*編譯文本節(jié)點(diǎn),此處僅實(shí)現(xiàn)一個(gè)空方法,實(shí)際開發(fā)中可能是字符串轉(zhuǎn)義過濾方法
*/
compileTextNode(vm, node){}
/**
*編譯DOM節(jié)點(diǎn),遍歷策略類中支持的自定義指令,如果發(fā)現(xiàn)某個(gè)指令dir
*則以this.Strategy[str]的方式取得對(duì)應(yīng)的處理函數(shù)并執(zhí)行。
*/
compileNormalNode(vm, node){
this.strategyKeys.forEach(key=>{
let expr = node.getAttribute(key);
if (expr) {
this.strategy[key].call(vm, node, expr);
}
});
//遞歸處理當(dāng)前DOM標(biāo)簽的子節(jié)點(diǎn)
let childs = node.childNodes;
if (childs.length > 0) {
childs.forEach(subNode => this.compile(vm, subNode));
}
}
}
//為方便理解,此處直接在全局生成一個(gè)編譯器單例,實(shí)際開發(fā)中請(qǐng)掛載至適當(dāng)?shù)拿臻g下。
window.Compiler = new Compiler();
我們使用策略模式實(shí)現(xiàn)一個(gè)單例的策略類Strategy
,將所有指令所對(duì)應(yīng)的解析方法封裝起來并傳入解析器,當(dāng)解析器遞歸解析每一個(gè)標(biāo)簽時(shí),如果遇到可以識(shí)別的指令,就從策略類中直接取出對(duì)應(yīng)的處理方法對(duì)當(dāng)前節(jié)點(diǎn)進(jìn)行處理即可,這樣Strategy
類只需要實(shí)現(xiàn)一個(gè)Strategy.register( customDirective, options)
方法就可以暴露出未來用以添加自定義指令的接口。(細(xì)節(jié)可參考附件中的代碼)
strategy.js——指令解析策略類
//策略類的基本結(jié)構(gòu)
class Strategy{
constructor(){
let strategy = {
'd-bind':function(){//...},
'd-model':function(){//...},
'd-click':function(){//...}
}
return strategy;
}
//注冊(cè)新的指令
register(customDir,options){
...
}
}
模板解析的工作就比較清晰了,相當(dāng)于帶著一本《解析指南》去遍歷處理DOM樹,不難看出,實(shí)際上綁定的工作就是在策略對(duì)應(yīng)的方法里來實(shí)現(xiàn)的,在MVVM
結(jié)構(gòu)種,這一步被稱為“依賴收集”。
以最基本的d-bind
指令為例,通過使用strategy['d-bind']
方法處理節(jié)點(diǎn)后,被處理的節(jié)點(diǎn)應(yīng)該具備感知數(shù)據(jù)模型變化的能力。以上面的模板為例,當(dāng)this.data.myname
發(fā)生變化時(shí),就需要將被處理節(jié)點(diǎn)的內(nèi)容改為對(duì)應(yīng)的值。此處就需要用到發(fā)布-訂閱模式。為了實(shí)現(xiàn)這個(gè)方法,需要一個(gè)觀察者類Observer
,它的功能是觀察數(shù)據(jù)模型的變化(通過數(shù)據(jù)劫持實(shí)現(xiàn)),管理訂閱者(維護(hù)一個(gè)回調(diào)隊(duì)列管理訂閱者添加的回調(diào)方法), 變化發(fā)生時(shí)通知訂閱者(依次調(diào)用訂閱者注冊(cè)的回調(diào)方法),同時(shí)將提供回調(diào)方法并執(zhí)行視圖更新行為的邏輯抽象為一個(gè)訂閱者類Subscriber
,訂閱者實(shí)例擁有一個(gè)update
方法,當(dāng)該方法被觀察者(同時(shí)也是發(fā)布者)調(diào)用時(shí),就會(huì)刷新對(duì)應(yīng)節(jié)點(diǎn)的視圖,很明顯,subscriber
實(shí)例需要被添加至指定的觀察者類的回調(diào)隊(duì)列中才能夠生效。
//發(fā)布訂閱模式的偽代碼
//...
'd-bind':function(node, expr){
//實(shí)例化訂閱者類
let sub = new Subscriber(node, 'myname',function(){
//更新視圖
node.innerHTML = VM.data['myname'];
});
//當(dāng)觀察者實(shí)例化時(shí),需要將這個(gè)sub實(shí)例的update方法添加進(jìn)
},
//...
subscriber.js——訂閱者類
class Subscriber{
constructor(vm, exp, callback){
this.vm = vm;
this.exp = exp;
this.callback = callback;
this.value = this.vm.data[this.exp];
}
/**
* 提供給發(fā)布者調(diào)用的方法
*/
update(){
return this.run();
}
/**
* 更新視圖時(shí)的實(shí)際執(zhí)行函數(shù)
*/
run(){
let currentVal = this.vm.data[this.exp];
if (this.value !== currentVal) {
this.value = currentVal;
this.callback.call(this.vm, this.value);
}
}
}
在生成一個(gè)subscriber
實(shí)例后,還要實(shí)現(xiàn)一個(gè)observer
實(shí)例,然后才能夠通過調(diào)用observer.addSub(sub)
方法來將訂閱者添加進(jìn)觀察者的回調(diào)隊(duì)列中。先來看一下Observer
這個(gè)類的定義:
observer.js——觀察者類
/**
* 發(fā)布者類,同時(shí)為一個(gè)觀察者
* 功能包括:
* 1.觀察視圖模型上數(shù)據(jù)的變化
* 2.變化出現(xiàn)時(shí)發(fā)布變化消息給訂閱者
*/
class Observer{
constructor(data){
this.data = data;
this.subQueue = {};//訂閱者M(jìn)ap
this.traverse();
}
//遍歷數(shù)據(jù)集中各個(gè)屬性并添加觀察器具
traverse(){
Object.keys(this.data).forEach(key=>{
defineReactive(this.data, key, this.data[key], this);
});
}
notify(key){
this.subQueue[key].forEach(fn=>fn.update());
}
}
//修改對(duì)象屬性的get/set方法實(shí)現(xiàn)數(shù)據(jù)劫持
function defineReactive(obj, key, val, observer) {
//當(dāng)鍵的值仍然是一個(gè)對(duì)象時(shí),遞歸處理,observe方法定義在dash.js中
let childOb = observe(val);
//數(shù)據(jù)劫持
Object.defineProperty(obj, key, {
enumerable:true,
configurable:true,
get:()=>{
if (window.curSubscriber) {
if (observer.subQueue[key] === undefined) {observer.subQueue[key] = []};
observer.subQueue[key].push(window.curSubscriber);
}
return val;
},
set:(newVal)=>{
if (val === newVal) return;
val = newVal;
//監(jiān)聽新值
childOb = observe(newVal);
//通知所有訂閱者
observer.notify(key);
}
})
}
觀察者類實(shí)例化時(shí),傳入一個(gè)待觀察的數(shù)據(jù)對(duì)象,構(gòu)造器調(diào)用遍歷方法來改寫數(shù)據(jù)集中每一個(gè)鍵的get/set
方法,在讀取某個(gè)鍵的值時(shí),將訂閱者監(jiān)聽器(細(xì)節(jié)下一節(jié)講)添加進(jìn)回調(diào)隊(duì)列,當(dāng)set
改變數(shù)據(jù)集中某個(gè)鍵的值時(shí),調(diào)用觀察者的notify( )
方法找到對(duì)應(yīng)鍵的回調(diào)隊(duì)列并以此觸發(fā)。
上面的代碼可以應(yīng)付一般情況,但存在一些明顯的問題就是集中式的回調(diào)隊(duì)列管理,subQueue
實(shí)際上是一個(gè)HashMap結(jié)構(gòu):
subQueue = {
'myname':[fn1, fn2, fn3],
'otherAttr':[fn11,fn12, fn13],
//...
}
不難看出這種管理回調(diào)的方式存在很多問題,遇到嵌套或重名結(jié)構(gòu)就會(huì)出現(xiàn)覆蓋,這個(gè)時(shí)候就不難理解Vue2.0
源碼中的做法了,在進(jìn)行數(shù)據(jù)劫持時(shí)生成一個(gè)Dep
實(shí)例,實(shí)例中維護(hù)一個(gè)回調(diào)隊(duì)列用來管理發(fā)布訂閱,當(dāng)數(shù)據(jù)模型中的屬性被set
修改時(shí),調(diào)用dep.notify( )
方法來依次調(diào)用訂閱者添加的回調(diào),當(dāng)屬性被讀取而觸發(fā)get
方法時(shí),向dep
實(shí)例中添加訂閱者的回調(diào)函數(shù)即可。
截止目前為止,還有最后一個(gè)問題需要處理,就是訂閱者實(shí)例sub
和發(fā)布訂閱管理器實(shí)例dep
存在于兩個(gè)不同的作用域里,那么要怎么通過調(diào)用dep.addSub(sub)
來實(shí)現(xiàn)訂閱動(dòng)作呢?換個(gè)問法或許你就發(fā)現(xiàn)這個(gè)問題其實(shí)并不難回答,在SPA
框架中,兄弟組件之間如何通信呢?通常都是借助數(shù)據(jù)上浮(公用數(shù)據(jù)提升到共同的父級(jí)組件中)或者EventBus
來實(shí)現(xiàn)的。
這里的做法是一致的,在策略類中某個(gè)指令對(duì)應(yīng)的處理方法中,當(dāng)我們準(zhǔn)備從數(shù)據(jù)模型this.data
中讀取對(duì)應(yīng)的初值前,先將訂閱者實(shí)例sub
掛載到一個(gè)更高的層級(jí)(附件的demo中簡單粗暴地掛載到全局,Vue2.0
源碼中掛載到Dep.target
),然后再去讀取this.data[expr]
,這個(gè)時(shí)候在expr
屬性被劫持的get
方法中,不僅可以訪問到屬于自己的訂閱管理器dep
實(shí)例,也可以通過Dep.target
訪問到當(dāng)前節(jié)點(diǎn)所對(duì)應(yīng)的訂閱者實(shí)例,那么完成對(duì)應(yīng)的訂閱邏輯就易如反掌了。
了解了上述細(xì)節(jié),我們整理一下思路,整體看一下數(shù)據(jù)綁定所經(jīng)歷的各個(gè)環(huán)節(jié):
有關(guān)上面示例中d-model
和d-click
指令綁定的實(shí)現(xiàn),本文不再贅述,筆者提供了包含詳細(xì)注釋的完整Demo,有需要的讀者可以直接從附件中取用,最后Demo也會(huì)存放在我的github倉庫。
了解了上述細(xì)節(jié),可以閱讀《vue的雙向綁定原理及實(shí)現(xiàn)》來看看 Vue2.0
的源代碼中是如何更加規(guī)范地實(shí)現(xiàn)雙向數(shù)據(jù)綁定的。
基于劫持的數(shù)據(jù)綁定方法是無法感知數(shù)組方法的,Vue2.0
中使用了Hack的方法來實(shí)現(xiàn)對(duì)于數(shù)組元素的感知,其基本原理依舊是通過代理模式實(shí)現(xiàn),在此直接給出源碼Vue源碼鏈接:
//Vue2.0中有關(guān)數(shù)組方法
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 以下幾個(gè)函數(shù)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
// 獲得原生函數(shù)
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// 調(diào)用原生函數(shù)
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// 觸發(fā)更新
ob.dep.notify()
return result
})
})
大致的思路是為Array.prototype
上幾個(gè)原生方法設(shè)置了訪問代理,并將訂閱管理器的消息發(fā)布方法混入其中,實(shí)現(xiàn)了對(duì)特定數(shù)組方法的監(jiān)控。
Vue
官方已經(jīng)確認(rèn)3.0版本重構(gòu)數(shù)據(jù)綁定代碼,改為Proxy
實(shí)現(xiàn)。
Proxy
對(duì)象是ES6
引入的原生化的代理對(duì)象,和基于defineProperty
實(shí)現(xiàn)數(shù)據(jù)劫持在思路上其實(shí)并沒有什么本質(zhì)區(qū)別,都是使用經(jīng)典的“代理模式”來實(shí)現(xiàn)的,只是原生支持的Proxy
編寫起來更簡潔,整個(gè)天然支持對(duì)數(shù)組變化的感知能力。Proxy
和Reclect
對(duì)象基本是成對(duì)出現(xiàn)使用的,屬于元編程范疇,可以從語言層面改變?cè)刑匦裕?code>Proxy可以攔截對(duì)象的數(shù)十種方法,比手動(dòng)實(shí)現(xiàn)的代理模式要清晰很多,也要方便很多。
基本實(shí)現(xiàn)如下:
//使用Proxy代理數(shù)據(jù)模型對(duì)象
let watchVmData = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver){
getLogger(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver){
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};
//使用Proxy代理
let data = { myname : 1 };
let value;
let vmproxy = watchVmData(obj, (v) => {
value = v;
},(target, property)=>{
console.log(`Get ${property} = ${target[property]}`);
});
數(shù)據(jù)綁定只是MVVM
模型中的冰山一角,如果你自己動(dòng)手實(shí)現(xiàn)了上面提及的Demo,一定會(huì)發(fā)現(xiàn)很多明顯的問題,例如訂閱者刷新函數(shù)是直接修改DOM的,稍有開發(fā)經(jīng)驗(yàn)的前端工程師都會(huì)想到需要將變化收集起來,盡可能將高性能消耗的DOM操作合并在一起處理來提升效率,這就引出了一系列我們常常聽到的Virtual-DOM(虛擬DOM樹)
和Life-Cycle-Hook(生命周期鉤子)
等等知識(shí)點(diǎn),如果你對(duì)三大框架的底層原理感興趣,可以繼續(xù)探索,那是一件非常有意思的事情。
通過原理的學(xué)習(xí)就會(huì)發(fā)現(xiàn)學(xué)習(xí)【設(shè)計(jì)模式】的重要性,很多時(shí)候別人用設(shè)計(jì)模式的術(shù)語交流并不是在裝X,而是它真的代表了一些久經(jīng)驗(yàn)證的思想,僅僅是數(shù)據(jù)綁定這樣一個(gè)小小的知識(shí)點(diǎn),就包含了類模式
,代理模式
,原型模式
,策略模式
,發(fā)布訂閱模式
的運(yùn)用,代碼的實(shí)現(xiàn)中也涉及到了單一職責(zé),開放封閉等等開發(fā)原則的考量,框架編寫是一件非??简?yàn)基本功的事情,在基礎(chǔ)面前,技巧只能是浮云。
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。