使用ES6實現MVVM的雙向繫結
實現Vue資料雙向繫結的一些心得
2018.07.24 更新
今天面試的時候遇到的問題:如何使用ES6實現資料的雙向繫結?不使用Object.defineProperty()有沒有更好的方法?
參考ES6中的Proxy解釋
建立專案
本文github地址:https://github.com/csuZipple/Vue_study
由於專案採用ES6語法進行開發,我們需要構建一個基於webpack的專案
1. npm init -y
2. npm install -D webpack webpack-command
然後建立程式碼結構
module.exports = {
entry: "./app.js",
mode:"development",
output: {
filename: "./bundle.js",
},
watch: true
};
資料雙向繫結原理
參考連結:https://github.com/DMQ/mvvm
vue.js
是採用資料劫持結合釋出者-訂閱者模式的方式,通過Object.defineProperty()
來劫持各個屬性的setter,getter,在資料變動時釋出訊息給訂閱者,觸發相應的監聽回撥。
總的來說,就以下幾個步驟:
1. 實現一個數據監聽器Observer,能夠對資料物件的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者
2. 實現一個指令解析器Compile,對每個元素節點的指令進行掃描和解析,根據指令模板替換資料,以及繫結相應的更新函式
3. 實現一個Watcher,作為連線Observer和Compile的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應回撥函式,從而更新檢視
4. mvvm入口函式,整合以上三者
模組介紹
Observer
遍歷data中的所有屬性,同時通過遞迴的方式遍歷這些屬性的子屬性,通過Object.defineProperty()將這些屬性都定義為訪問器屬性,訪問器屬性自帶get和set方法,通過get和set方法來監聽資料變化。
Dep
我們需要訊息訂閱器來收集所有的訂閱者,也就是在這裡維護一個列表,當屬性的get方法被觸發時,需要判斷是否要新增訂閱者,如果需要就在列表中增加一個訂閱者,當set方法被觸發時就通知列表中所有的訂閱者來做出響應。
Watcher
因為我們是在get函式中判斷是否要新增訂閱者的,要想把一個訂閱者新增到列表中我們就需要在初始化這個訂閱者時觸發get函式,我們可以在Dep.target上快取下訂閱者,新增成功後再將其去掉就可以了
Compile
compile負責初始化時的編譯解析,遍歷每一個結點,看哪些結點需要訂閱者,也負責後續為訂閱者繫結更新函式。
實現Observer
class Observer {
constructor(obj){
this.data = obj;
if (!this.data || typeof this.data !== 'object') return;
Object.keys(this.data).forEach(key=>{
console.log(key);
this._listen(key,this.data[key]);//使用箭頭函式繫結this
})
}
_listen(key, val){
new Observer(val);
Object.defineProperty(this.data,key,{
enumerable:true,
configurable:false,
/* set:function (newval) {
console.log("the key "+key+" set new value:",newval);
val = newval;
},
get:function () {
return val;
}*///以上設定特權函式的方法也是可以的,下面採用es6的寫法
set(newval){
val = newval;
},
get(){
return val;
}
});
}
}
export default Observer;
就上面的思路中提到的釋出-訂閱者模式而言,上面的程式碼還需要定義一個物件來收集所有的訂閱者。我們在_listen
方法第二行可以初始化一個物件let dep = new Dep();
當接收到set請求的時候,通知dep所有的訂閱者呼叫自己的update方法.
那麼在什麼時候為dep新增訂閱者呢?我們給訂閱者起了一個名字叫Watcher,而Watcher中有一個update方法會呼叫data的屬性,也就是會觸發Observer中的get方法。同時Observer在get方法中新增訂閱者。從架構層次上來講,新增訂閱者應該是在編譯節點的時候新增。
//Observer get方法片段
if (Dep.target) dep.listen(Dep.target);
//Watcher 程式碼片段
constructor(node,name,vm){
this.node = node;
this.name = name;
this.vm = vm;
Dep.target = this;
this.update();//觸發observer,新增訂閱
Dep.target = null;
}
update(){
console.log("watch name ",this.name);
this.node.nodeValue = this.vm[this.name];//觸發data中key的get方法
}
//Compiler中的compile方法片段
if(REG.test(node.nodeValue)){
let name = RegExp.$1;//返回第一個匹配的子串
new Watcher(node,name.trim(),this.vm);
}
注意:上面的程式碼必須是Dep
實現Dep
class Dep {
constructor(){
this.subs=[];//維護的訂閱者列表
}
listen(sub){
this.subs.push(sub);
console.log("dep新增sub成功,當前維護的subs length="+this.subs.length);
}
notify(){
console.log("檢測到屬性修改,model修改觸發view修改");
this.subs.forEach(function (sub, index, array) {
console.log(sub);
sub.update();
})
}
}
Dep.prototype.target=null;//表示當前物件是否已監聽,原型鏈上的物件是不共享的
export default Dep;
實現Watcher
實際上Watcher就是一個訂閱者的類,裡面包含了node,name(屬性),和vm(this,也就是mvvm物件的上下文)。通過Dep.target快取當前Watcher的上下文,而update方法會觸發Observer的get方法,在其中會首先判斷Dep.target是否為null。如果不是null表示當前watcher還沒有被Dep新增到sub列表中。
import Dep from "./dep"
class Watcher {
constructor(node,name,vm){
this.node = node;
this.name = name;
this.vm = vm;
Dep.target = this;
this.update();//觸發observer,新增訂閱
Dep.target = null;
}
update(){
console.log("watch name ",this.name);
this.node.nodeValue = this.vm[this.name];//觸發data中key的get方法
}
}
export default Watcher;
實現compiler
而在compiler中,由於需要操作dom節點。這裡我們採用fragment來進行優化,提升效能。同時從繫結的根節點出發,判斷節點是否需要新增訂閱。
import Watcher from "./watcher"
const REG = /\{\{(.*)\}\}/;
class Compiler {
constructor(el,vm){
this.el = document.querySelector(el);
this.vm = vm;
this.frag = this.createFragment();//為了提高dom操作的效能
this.el.appendChild(this.frag);
}
createFragment() {
let frag = document.createDocumentFragment();
let child;
while(child = this.el.firstChild){
this.compile(child);
frag.appendChild(child);
}
return frag;
}
compile(node){
switch(node.nodeType){
case 1://node
let attr = node.attributes;
let self = this;
if (attr.hasOwnProperty("v-model")){
let name = attr["v-model"].nodeValue;
node.addEventListener("input",function (e) {
self.vm[name] = e.target.value;//觸發set事件 呼叫update
});
node.value = this.vm[name];
}
break;
case 3://element
if(REG.test(node.nodeValue)){
let name = RegExp.$1;//返回第一個匹配的子串,也就是獲取到文字標籤{{message}}中的message
new Watcher(node,name.trim(),this.vm);
}
break;
default:
console.log("compile node error .default nodeType.")
}
}
}
export default Compiler;
實現index
index就是mvvm框架的入口檔案了,在這裡初始化引數,設定根節點,監聽的資料物件,以及其他一些設定。像vue的引數是這樣的,
new Vue({
el: '#app',
data: {
message: "hello myVue"
}
});
我們模擬實現一下這樣的功能。
import Observer from './Observer'
import Compiler from './Compiler'
class Mvvm {
constructor(options){
this.$options = options;
this.$el = options.el;
this.data = options.data;
Object.keys(this.data).forEach(key=>{
this.listen(key);
});
//對data屬性進行監聽
new Observer(this.data);
new Compiler(this.$el,this);
}
listen(key) {
//監聽options的屬性,方便通過options.name==>options.data.name
let self = this;
Object.defineProperty(self,key,{
get(){
return self.data[key];
},
set(newval){
self.data[key] = newval;//此處觸發data的set方法
}
})
}
}
export default Mvvm;
在上面的程式碼中可以看到我們對傳進來的options引數通過listen方法實現了代理,比如options.name==>options.data.name。所以在Watcher中可以直接通過this.vm[name]來呼叫options中data的值。
做到這裡其實已經基本上完成了mvvm框架的資料雙向綁定了,哦,對了,還有html結構如下:
<div id="app">
<input type="text" v-model="message">
{{ message }}
</div>
程式碼參考連結:https://blog.csdn.net/ns2250225/article/details/79534656