當今前端天下以 Angular、React、vue 三足鼎立的局面,你不選擇一個陣營基本上無法立足於前端,甚至是兩個或者三個陣營都要選擇,大勢所趨。
所以我們要時刻保持好奇心,擁抱變化,只有在不斷的變化中你才能利於不敗之地,保守只能等死。
最近在學習 Vue,一直以來對它的雙向繫結只能算了解並不深入,最近幾天打算深入學習下,通過幾天的學習查閱資料,算是對它的原理有所認識,所以自己動手寫了一個雙向繫結的例子,下面我們一步步看如何實現的。
看完這篇文章之後我相信你會對 Vue 的雙向繫結原理有一個清楚的認識。也能幫助我們更好的認識 Vue。
先看效果圖
//程式碼:
<div id="app">
<input v-model="name" type="text">
<h1>{{name}}</h1>
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/index.js"></script>
<script>
const vm = new Mvue({
el: "#app",
data: {
name: "我是摩登"
}
});
</script>
複製程式碼
資料繫結
在正式開始之前我們先來說說資料繫結的事情,資料繫結我的理解就是讓資料M(model)展示到 檢視V(view)上。我們常見的架構模式有 MVC、MVP、MVVM模式,目前前端框架基本上都是採用 MVVM 模式實現雙向繫結,Vue 自然也不例外。但是各個框架實現雙向繫結的方法略有所不同,目前大概有三種實現方式。
- 釋出訂閱模式
- Angular 的髒查機制
- 資料劫持
而 Vue 則採用的是資料劫持與釋出訂閱相結合的方式實現雙向繫結,資料劫持主要通過 Object.defineProperty
來實現。
Object.defineProperty
這篇文章我們不詳細討論 Object.defineProperty
的用法,我們主要看看它的訪問器屬性 get 與 set。我們來看看通過它設定的物件屬性之後有何變化。
var people = {
name: "Modeng",
age: 18
}
people.age; //18
people.age = 20;
複製程式碼
上述程式碼就是普通的獲取/設定物件的屬性,看不到什麼奇怪的變化。
var modeng = {}
var age;
Object.defineProperty(modeng, 'age', {
get: function () {
console.log("獲取年齡");
return age;
},
set: function (newVal) {
console.log("設定年齡");
age = newVal;
}
});
modeng.age = 18;
console.log(modeng.age);
複製程式碼
你會發現通過上述操作之後,我們訪問 age 屬性時會自動執行 get 函式,設定 age 屬性時,會自動執行 set 函式,這就給我們的雙向繫結提供了非常大的方便。
分析
我們知道 MVVM 模式在於資料與檢視的保持同步,意思是說資料改變時會自動更新檢視,檢視發生變化時會更新資料。
所以我們需要做的就是如何檢測到資料的變化然後通知我們去更新檢視,如何檢測到檢視的變化然後去更新資料。檢測檢視這個比較簡單,無非就是我們利用事件的監聽即可。
那麼如何才能知道資料屬性發生變化呢?這個就是利用我們上面說到的 Object.defineProperty
當我們的屬性發生變化時,它會自動觸發 set 函式從而能夠通知我們去更新檢視。
實現
通過上面的描述與分析我們知道 Vue 是通過資料劫持結合釋出訂閱模式來實現雙向繫結的。我們也知道資料劫持是通過 Object.defineProperty
方法,當我們知道這些之後,我們就需要一個監聽器 Observer 來監聽屬性的變化。得知屬性發生變化之後我們需要一個 Watcher 訂閱者來更新檢視,我們還需要一個 compile 指令解析器,用於解析我們的節點元素的指令與初始化檢視。所以我們需要如下:
- Observer 監聽器:用來監聽屬性的變化通知訂閱者
- Watcher 訂閱者:收到屬性的變化,然後更新檢視
- Compile 解析器:解析指令,初始化模版,繫結訂閱者
順著這條思路我們一步一步去實現。
監聽器 Observer
監聽器的作用就是去監聽資料的每一個屬性,我們上面也說了使用 Object.defineProperty
方法,當我們監聽到屬性發生變化之後我們需要通知 Watcher 訂閱者執行更新函式去更新檢視,在這個過程中我們可能會有很多個訂閱者 Watcher 所以我們要建立一個容器 Dep 去做一個統一的管理。
function defineReactive(data, key, value) {
//遞迴呼叫,監聽所有屬性
observer(value);
var dep = new Dep();
Object.defineProperty(data, key, {
get: function () {
if (Dep.target) {
dep.addSub(Dep.target);
}
return value;
},
set: function (newVal) {
if (value !== newVal) {
value = newVal;
dep.notify(); //通知訂閱器
}
}
});
}
function observer(data) {
if (!data || typeof data !== "object") {
return;
}
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key]);
});
}
function Dep() {
this.subs = [];
}
Dep.prototype.addSub = function (sub) {
this.subs.push(sub);
}
Dep.prototype.notify = function () {
console.log('屬性變化通知 Watcher 執行更新檢視函式');
this.subs.forEach(sub => {
sub.update();
})
}
Dep.target = null;
複製程式碼
以上我們就建立了一個監聽器 Observer,我們現在可以嘗試一下給一個物件新增監聽然後改變屬性會有何變化。
var modeng = {
age: 18
}
observer(modeng);
modeng.age = 20;
複製程式碼
我們可以看到瀏覽器控制檯列印出 “屬性變化通知 Watcher 執行更新檢視函式” 說明我們實現的監聽器沒毛病,既然監聽器有了,我們就可以通知屬性變化了,那肯定是需要 Watcher 的時候了。
訂閱者 Watcher
Watcher 主要是接受屬性變化的通知,然後去執行更新函式去更新檢視,所以我們做的主要是有兩步:
- 把 Watcher 新增到 Dep 容器中,這裡我們用到了 監聽器的 get 函式
- 接收到通知,執行更新函式。
function Watcher(vm, prop, callback) {
this.vm = vm;
this.prop = prop;
this.callback = callback;
this.value = this.get();
}
Watcher.prototype = {
update: function () {
const value = this.vm.$data[this.prop];
const oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.callback(value);
}
},
get: function () {
Dep.target = this; //儲存訂閱器
const value = this.vm.$data[this.prop]; //因為屬性被監聽,這一步會執行監聽器裡的 get方法
Dep.target = null;
return value;
}
}
複製程式碼
這一步我們把 Watcher 也給弄了出來,到這一步我們已經實現了一個簡單的雙向繫結了,我們可以嘗試把兩者結合起來看下效果。
function Mvue(options, prop) {
this.$options = options;
this.$data = options.data;
this.$prop = prop;
this.$el = document.querySelector(options.el);
this.init();
}
Mvue.prototype.init = function () {
observer(this.$data);
this.$el.textContent = this.$data[this.$prop];
new Watcher(this, this.$prop, value => {
this.$el.textContent = value;
});
}
複製程式碼
這裡我們嘗試利用一個例項來把資料與需要監聽的屬性傳遞進來,通過監聽器監聽資料,然後新增屬性訂閱,繫結更新函式。
<div id="app">{{name}}</div>
const vm = new Mvue({
el: "#app",
data: {
name: "我是摩登"
}
}, "name");
複製程式碼
我們可以看到資料已經正常的顯示在頁面上,那麼我們在通過控制檯去修改資料,發生變化後檢視也會跟著修改。
到這一步我們我們基本上已經實現了一個簡單的雙向繫結,但是不難發現我們這裡的屬性都是寫死的,也沒有指令模板的解析,所以下一步我們來實現一個模板解析器。
Compile 解析器
Compile 的主要作用一個是用來解析指令初始化模板,一個是用來新增新增訂閱者,繫結更新函式。
因為在解析 DOM 節點的過程中我們會頻繁的操作 DOM, 所以我們利用文件片段(DocumentFragment)來幫助我們去解析 DOM 優化效能。
function Compile(vm) {
this.vm = vm;
this.el = vm.$el;
this.fragment = null;
this.init();
}
Compile.prototype = {
init: function () {
this.fragment = this.nodeFragment(this.el);
},
nodeFragment: function (el) {
const fragment = document.createDocumentFragment();
let child = el.firstChild;
//將子節點,全部移動文件片段裡
while (child) {
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
}
複製程式碼
然後我們就需要對整個節點和指令進行處理編譯,根據不同的節點去呼叫不同的渲染函式,繫結更新函式,編譯完成之後,再把 DOM 片段新增到頁面中。
Compile.prototype = {
compileNode: function (fragment) {
let childNodes = fragment.childNodes;
[...childNodes].forEach(node => {
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (this.isElementNode(node)) {
this.compile(node); //渲染指令模板
} else if (this.isTextNode(node) && reg.test(text)) {
let prop = RegExp.$1;
this.compileText(node, prop); //渲染{{}} 模板
}
//遞迴編譯子節點
if (node.childNodes && node.childNodes.length) {
this.compileNode(node);
}
});
},
compile: function (node) {
let nodeAttrs = node.attributes;
[...nodeAttrs].forEach(attr => {
let name = attr.name;
if (this.isDirective(name)) {
let value = attr.value;
if (name === "v-model") {
this.compileModel(node, value);
}
node.removeAttribute(name);
}
});
},
//省略。。。
}
複製程式碼
因為程式碼比較長如果全部貼出來會影響閱讀,我們主要是講整個過程實現的思路,文章結束我會把原始碼發出來,有興趣的可以去檢視全部程式碼。
到這裡我們的整個的模板編譯也已經完成,不過這裡我們並沒有實現過多的指令,我們只是簡單的實現了 v-model
指令,本意是通過這篇文章讓大家熟悉與認識 Vue 的雙向繫結原理,並不是去創造一個新的 MVVM 例項。所以並沒有考慮很多細節與設計。
現在我們實現了 Observer、Watcher、Compile,接下來就是把三者給組織起來,成為一個完整的 MVVM。
建立 Mvue
這裡我們建立一個 Mvue 的類(建構函式)用來承載 Observer、Watcher、Compile 三者。
function Mvue(options) {
this.$options = options;
this.$data = options.data;
this.$el = document.querySelector(options.el);
this.init();
}
Mvue.prototype.init = function () {
observer(this.$data);
new Compile(this);
}
複製程式碼
然後我們就去測試一下結果,看看我們實現的 Mvue 是不是真的可以執行。
<div id="app">
<h1>{{name}}</h1>
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/index.js"></script>
<script>
const vm = new Mvue({
el: "#app",
data: {
name: "完全沒問題,看起來是不是很酷!"
}
});
</script>
複製程式碼
我們嘗試去修改資料,也完全沒問題,但是有個問題就是我們修改資料時時通過 vm.$data.name
去修改資料,而不是想 Vue 中直接用 vm.name
就可以去修改,那這個是怎麼做到的呢?其實很簡單,Vue 做了一步資料代理操作。
資料代理
我們來改造下 Mvue 新增資料代理功能,我們也是利用 Object.defineProperty
方法進行一步中間的轉換操作,間接的去訪問。
function Mvue(options) {
this.$options = options;
this.$data = options.data;
this.$el = document.querySelector(options.el);
//資料代理
Object.keys(this.$data).forEach(key => {
this.proxyData(key);
});
this.init();
}
Mvue.prototype.init = function () {
observer(this.$data);
new Compile(this);
}
Mvue.prototype.proxyData = function (key) {
Object.defineProperty(this, key, {
get: function () {
return this.$data[key]
},
set: function (value) {
this.$data[key] = value;
}
});
}
複製程式碼
到這裡我們就可以像 Vue 一樣去修改我們的屬性了,非常完美。完全自己動手實現,你也來試試把,體驗下自己動手寫程式碼的樂趣。
總結
- 本文主要是對 Vue 雙向繫結原理的學習與實現。
- 主要是對整個思路的學習,並沒有考慮到太多的實現與設計的細節,所以還存在很多問題,並不完美。
- 原始碼地址,整個過程的全部程式碼,希望對你有所幫助。
- 如果你覺得本文對你有幫助,歡迎轉發,點贊。