前言
現在的前端面試不管你用的什麼框架,總會問你這個框架的雙向繫結機制,有的甚至要求你現場實現一個雙向繫結出來,那對於沒有好好研究過這方面知識的同學來說,當然是很難的,接下來本文用160行程式碼帶你實現一個極簡的雙向繫結機制。如果喜歡的話可以點波贊/關注,支援一下,希望大家看完本文可以有所收穫。
本文是在面試題:你能寫一個Vue的雙向資料繫結嗎?的基礎上仔細研究+改動,並新增了詳細註釋,而成的。
個人部落格瞭解一下:obkoro1.com
效果GIF:
demo地址:
codepen:仿Vue極簡雙向繫結
Github:仿Vue極簡雙向繫結
瞭解Object.defineProperty():
這個API是實現雙向繫結的核心,最主要的作用是重寫資料的get
、set
方法。
使用方式:
let obj = {
singer: "周杰倫"
};
let value = "青花瓷";
Object.defineProperty(obj, "music", {
// value: '七里香', // 設定屬性的值 下面設定了get set函式 所以這裡不能設定
configurable: false, // 是否可以刪除屬性 預設不能刪除
// writable: true, // 是否可以修改物件 下面設定了get set函式 所以這裡不能設定
enumerable: true, // music是否可以被列舉 預設是不能被列舉(遍歷)
// ☆ get,set設定時不能設定writable和value,要一對一對設定,交叉設定/同時存在 就會報錯
get() {
// 獲取obj.music的時候就會呼叫get方法
// let value = "強行設定get的返回值"; // 開啟註釋 讀取屬性永遠都是‘強行設定get的返回值’
return value;
},
set(val) {
// 將修改的值重新賦給song
value = val;
}
});
console.log(obj.music); // 青花瓷
delete obj.music; // configurable設為false 刪除無效
console.log(obj.music); // 青花瓷
obj.music = "聽媽媽的話";
console.log(obj.music); // 聽媽媽的話
for (let key in obj) {
// 預設情況下通過defineProperty定義的屬性是不能被列舉(遍歷)的
// 需要設定enumerable為true才可以 否則只能拿到singer 屬性
console.log(key); // singer, music
}
複製程式碼
示例demo:
對,這裡有個demo。
畫一下重點:
- get,set設定時不能設定writable和value, 他們是一對情侶的存在,交叉設定或同時存在,會報錯
- 通過
defineProperty
設定的屬性,預設不能刪除,不能遍歷,當然你可以通過設定更改他們。 - get、set 是函式,可以做的事情很多。
相容性:IE 9,Firefox 4, Chorme 5,Opera 11.6,Safari 5.1
更詳細的可以看一下MDN
實現思路:
mvvm系列的雙向繫結,關鍵步驟:
- 實現資料監聽器Observer,用
Object.defineProperty()
重寫資料的get、set,值更新就在set中通知訂閱者更新資料。 - 實現模板編譯Compile,深度遍歷dom樹,對每個元素節點的指令模板進行替換資料以及訂閱資料。
- 實現Watch用於連線Observer和Compile,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應回撥函式,從而更新檢視。
- mvvm入口函式,整合以上三者。
流程圖:
這部分講的很清楚,現在有點懵逼也沒關係,看完程式碼,自己copy下來玩一玩之後,回頭再看實現思路,相信會有收穫的。
具體程式碼實現:
html結構:
<div id="app">
<input type="text" v-model="name">
<h3 v-bind="name"></h3>
<input type="text" v-model="testData1">
<h3>{{ testData1 }}</h3>
<input type="text" v-model="testData2">
<h3>{{ testData2 }}</h3>
</div>
複製程式碼
看到這個模板,相信用過Vue的同學都不會陌生。
呼叫方法:
採用類Vue方式來使用雙向繫結:
window.onload = function () {
var app = new myVue({
el: '#app', // dom
data: { // 資料
testData1: '仿Vue',
testData2: '極簡雙向繫結',
name: 'OBKoro1'
}
})
}
複製程式碼
建立myVue函式:
實際上這裡是我們實現思路中的第四步,用於整合資料監聽器this._observer()
、指令解析器this._compile()
以及連線Observer和Compile的_watcherTpl的watch池。
function myVue(options = {}) { // 防止沒傳,設一個預設值
this.$options = options; // 配置掛載
this.$el = document.querySelector(options.el); // 獲取dom
this._data = options.data; // 資料掛載
this._watcherTpl = {}; // watcher池
this._observer(this._data); // 傳入資料,執行函式,重寫資料的get set
this._compile(this.$el); // 傳入dom,執行函式,編譯模板 釋出訂閱
};
複製程式碼
Watcher函式:
這是實現思路中的第三步,因為下方資料監聽器_observer()
需要用到Watcher函式,所以這裡就先講了。
像實現思路中所說的,這裡起到了連線Observer和Compile的作用:
-
在模板編譯_compile()階段釋出訂閱
-
在賦值操作的時候,更新檢視
// new Watcher() 為this._compile()釋出訂閱+ 在this._observer()中set(賦值)的時候更新檢視 function Watcher(el, vm, val, attr) { this.el = el; // 指令對應的DOM元素 this.vm = vm; // myVue例項 this.val = val; // 指令對應的值 this.attr = attr; // dom獲取值,如value獲取input的值 / innerHTML獲取dom的值 this.update(); // 更新檢視 } Watcher.prototype.update = function () { this.el[this.attr] = this.vm._data[this.val]; // 獲取data的最新值 賦值給dom 更新檢視 } 複製程式碼
沒有看錯,程式碼量就這麼多,可能需要把整個程式碼連線起來,多看幾遍才能夠理解。
實現資料監聽器_observer():
實現思路中的第一步,用Object.defineProperty()
遍歷data重寫所有屬性的get set。
然後在給物件的某個屬性賦值的時候,就會觸發set。
在set中我們可以監聽到資料的變化,然後就可以觸發watch更新檢視。
myVue.prototype._observer = function (obj) {
var _this = this;
Object.keys(obj).forEach(key => { // 遍歷資料
_this._watcherTpl[key] = { // 每個資料的訂閱池()
_directives: []
};
var value = obj[key]; // 獲取屬性值
var watcherTpl = _this._watcherTpl[key]; // 資料的訂閱池
Object.defineProperty(_this._data, key, { // 雙向繫結最重要的部分 重寫資料的set get
configurable: true, // 可以刪除
enumerable: true, // 可以遍歷
get() {
console.log(`${key}獲取值:${value}`);
return value; // 獲取值的時候 直接返回
},
set(newVal) { // 改變值的時候 觸發set
console.log(`${key}更新:${newVal}`);
if (value !== newVal) {
value = newVal;
watcherTpl._directives.forEach((item) => { // 遍歷訂閱池
item.update();
// 遍歷所有訂閱的地方(v-model+v-bind+{{}}) 觸發this._compile()中釋出的訂閱Watcher 更新檢視
});
}
}
})
});
}
複製程式碼
實現Compile 模板編譯
這裡是實現思路中的第三步,讓我們來總結一下這裡做了哪些事情:
-
首先是深度遍歷dom樹,遍歷每個節點以及子節點。
-
將模板中的變數替換成資料,初始化渲染頁面檢視。
-
把指令繫結的屬性新增到對應的訂閱池中
-
一旦資料有變動,收到通知,更新檢視。
myVue.prototype._compile = function (el) { var _this = this, nodes = el.children; // 獲取app的dom for (var i = 0, len = nodes.length; i < len; i++) { // 遍歷dom節點 var node = nodes[i]; if (node.children.length) { _this._compile(node); // 遞迴深度遍歷 dom樹 } // 如果有v-model屬性,並且元素是INPUT或者TEXTAREA,我們監聽它的input事件 if (node.hasAttribute('v-model') && (node.tagName = 'INPUT' || node.tagName == 'TEXTAREA')) { node.addEventListener('input', (function (key) { var attVal = node.getAttribute('v-model'); // 獲取v-model繫結的值 _this._watcherTpl[attVal]._directives.push(new Watcher( // 將dom替換成屬性的資料併發布訂閱 在set的時候更新資料 node, _this, attVal, 'value' )); return function () { _this._data[attVal] = nodes[key].value; // input值改變的時候 將新值賦給資料 觸發set=>set觸發watch 更新檢視 } })(i)); } if (node.hasAttribute('v-bind')) { // v-bind指令 var attrVal = node.getAttribute('v-bind'); // 繫結的data _this._watcherTpl[attrVal]._directives.push(new Watcher( // 將dom替換成屬性的資料併發布訂閱 在set的時候更新資料 node, _this, attrVal, 'innerHTML' )) } var reg = /\{\{\s*([^}]+\S)\s*\}\}/g, txt = node.textContent; // 正則匹配{{}} if (reg.test(txt)) { node.textContent = txt.replace(reg, (matched, placeholder) => { // matched匹配的文字節點包括{{}}, placeholder 是{{}}中間的屬性名 var getName = _this._watcherTpl; // 所有繫結watch的資料 getName = getName[placeholder]; // 獲取對應watch 資料的值 if (!getName._directives) { // 沒有事件池 建立事件池 getName._directives = []; } getName._directives.push(new Watcher( // 將dom替換成屬性的資料併發布訂閱 在set的時候更新資料 node, _this, placeholder, 'innerHTML' )); return placeholder.split('.').reduce((val, key) => { return _this._data[key]; // 獲取資料的值 觸發get 返回當前值 }, _this.$el); }); } } } 複製程式碼
完整程式碼&demo地址
codepen:仿Vue極簡雙向繫結
Github:仿Vue極簡雙向繫結
如果覺得還不錯的話,就給個Star⭐️鼓勵一下我吧~
結語
本文只是一個簡單的實現雙向繫結的方法,主要目的是幫助各位同學理解mvvm框架的雙向繫結機制,也並沒有很完善,這裡還是有很多缺陷,比如:沒有實現資料的深度對資料進行get
、set
等。希望看完本文,大家能有所收穫。
希望看完的朋友可以點個喜歡/關注,您的支援是對我最大的鼓勵。
個人blog and 掘金個人主頁,如需轉載,請放上原文連結並署名。碼字不易,感謝支援!本人寫文章本著交流記錄的心態,寫的不好之處,不撕逼,但是歡迎指點。
如果喜歡本文的話,歡迎關注我的訂閱號,漫漫技術路,期待未來共同學習成長。
以上2018.6.24