來看看目前最火的MVVM
- 今天面試又被問到什麼是MVVM?
- 光靠說理論已經糊弄不過去了?
- 什麼!MVVM的實現不止一種啊?
- 往下看~ 親手帶你剖析MVVM原理!
怕你走開文章末尾有驚喜的!偷偷的告訴你這篇文章是有配套視訊的哦!
先來總結下MVVM的實現方式
- 傳統的MVC中通過釋出訂閱來進行資料和檢視的繫結監聽
- angular1.x中通過髒值檢測來實現MVVM模式
- 目前主流Vue的模式:資料劫持 Object.defineProperty、釋出訂閱
- ES6中的新特性Proxy和Reflect
談談現代版的框架
直接從主流的說起!
vue的特點不必多說(簡單易用)。修改資料方便不需要記憶api方法,這都歸功於Object.defineProperty,它可以在資料的設定和獲取時增加我們自己的功能!(像牆一樣)
總結下實現MVVM都要掌握哪些!
- 模板編譯(Compile)
- 資料劫持(Observer)
- 釋出的訂閱(Dep)
- 觀察者(Watcher)
MVVM模式就要將這些板塊進行整合,實現模板和資料的繫結!
看看我畫圖的功底,有個印象就好!
先簡單來說說MVVM
- 資料就是簡單的javascript物件,需要將資料繫結到模板上
- 監聽檢視的變化,檢視變化後通知資料更新,資料更新會再次導致檢視的變化!
Vue基礎案例
看段大眾程式碼,接下來我們就基於這段程式碼搞一下MVVM的實現
<div id="app">
<!-- 雙向資料繫結 靠的是表單 -->
<input type="text" v-model="message.a">
<div>我很帥</div>
{{message.a}} {{b}}
</div>
<script src="watcher.js"></script>
<script src="observer.js"></script>
<script src="compile.js"></script>
<script src="MVVM.JS"></script>
<script>
// 我們的資料一般都掛載在vm上
let vm = new MVVM({
el:'#app',
data:{
message:{a:'珠峰架構'},
b:'珠峰MVVM'
}
})
</script>
複製程式碼
這裡我們用了自己的MVVM庫,這個庫是用來整合所有板塊的!
MVVM構建
對es6還陌生的同學,在這我在給大家附個經典es6視訊~ 直接用ES6來打造我們的MVVM
class MVVM{
constructor(options){
// 一上來 先把可用的東西掛載在例項上
this.$el = options.el;
this.$data = options.data;
// 如果有要編譯的模板我就開始編譯
if(this.$el){
// 用資料和元素進行編譯
new Compile(this.$el, this);
}
}
}
複製程式碼
模板編譯
MVVM中呼叫了Compile類來編譯我們的頁面,開始來實現模板編譯
先來個基礎的架子
class Compile {
constructor(el, vm) {
// 看看傳遞的元素是不是DOM,不是DOM我就來獲取一下~
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
// 如果這個元素能獲取到 我們才開始編譯
// 1.先把這些真實的DOM移入到記憶體中 fragment (效能優化)
let fragment = this.node2fragment(this.el);
// 2.編譯 => 提取想要的元素節點 v-model 和文字節點 {{}}
this.compile(fragment);
// 3.把編譯號的fragment在塞回到頁面裡去
this.el.appendChild(fragment);
}
}
/* 專門寫一些輔助的方法 */
isElementNode(node) {
return node.nodeType === 1;
}
/* 核心的方法 */
compileElement(node) {}
compileText(node) {}
compile(fragment) {}
node2fragment(el) {}
}
複製程式碼
接下來一個個的方法來搞
node2fragment
node2fragment(el) { // 需要將el中的內容全部放到記憶體中
// 文件碎片 記憶體中的dom節點
let fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
// appendChild具有移動性
}
return fragment; // 記憶體中的節點
}
複製程式碼
compile
compile(fragment) {
// 需要遞迴 每次拿子元素
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
// 是元素節點,還需要繼續深入的檢查
// 這裡需要編譯元素
this.compileElement(node);
this.compile(node)
} else {
// 文字節點
// 這裡需要編譯文字
this.compileText(node);
}
});
}
複製程式碼
我們在弄出兩個方法compileElement,compileText來專門處理對應的邏輯
compileElement&compileText
/*輔助的方法*/
// 是不是指令
isDirective(name) {
return name.includes('v-');
}
----------------------------
compileElement(node) {
// 帶v-model v-text
let attrs = node.attributes; // 取出當前節點的屬性
Array.from(attrs).forEach(attr => {
// 判斷屬性名字是不是包含v-model
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 取到對應的值放到節點中
let expr = attr.value;
let [, type] = attrName.split('-'); //
// 呼叫對應的編譯方法 編譯哪個節點,用資料替換掉表示式
CompileUtil[type](node, this.vm, expr);
}
})
}
compileText(node) {
let expr = node.textContent; // 取文字中的內容
let reg = /\{\{([^}]+)\}\}/g; // {{a}} {{b}} {{c}}
if (reg.test(expr)) {
// 呼叫編譯文字的方法 編譯哪個節點,用資料替換掉表示式
CompileUtil['text'](node, this.vm, expr);
}
}
複製程式碼
CompileUtil
我們要實現一個專門用來配合Complie類的工具物件
先只處理文字和輸入框的情況
CompileUtil = {
text(node, vm, expr) { // 文字處理
let updateFn = this.updater['textUpdater'];
// 用處理好的節點和內容進行編譯
updateFn && updateFn(node, value)
},
model(node, vm, expr) { // 輸入框處理
let updateFn = this.updater['modelUpdater'];
// 用處理好的節點和內容進行編譯
updateFn && updateFn(node, value);
},
updater: {
// 文字更新
textUpdater(node, value) {
node.textContent = value
},
// 輸入框更新
modelUpdater(node, value) {
node.value = value;
}
}
}
複製程式碼
實現text方法
text(node, vm, expr) { // 文字處理
let updateFn = this.updater['textUpdater'];
// 文字比較特殊 expr可能是'{{message.a}} {{b}}'
// 呼叫getTextVal方法去取到對應的結果
let value = this.getTextVal(vm, expr);
updateFn && updateFn(node, value)
},
getTextVal(vm, expr) { // 獲取編譯文字後的結果
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
// 依次去去資料對應的值
return this.getVal(vm, arguments[1]);
})
},
getVal(vm, expr) { // 獲取例項上對應的資料
expr = expr.split('.'); // {{message.a}} [message,a] 實現依次取值
// vm.$data.message => vm.$data.message.a
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
}
複製程式碼
實現Model方法
model(node, vm, expr) { // 輸入框處理
let updateFn = this.updater['modelUpdater'];
// 這裡應該加一個監控 資料變化了 應該呼叫這個watch的callback
updateFn && updateFn(node, this.getVal(vm, expr));
}
複製程式碼
看下編譯後的效果^_^
資料劫持
我們一直說Object.defineProperty有劫持功能我們就看看這個是怎樣劫持的
預設情況下定義屬性給屬性設定的操作是這樣的
let school = {name:''}
school.name = 'zfpx'; // 當我給屬性設定時希望做一些操作
console.log(school.name); // 當我獲取屬性時也希望對應有寫操作
複製程式碼
這時候Object.defineProperty登場
let school = {name:''}
let val;
Object.defineProperty(school, 'name', {
enumerable: true, // 可列舉,
configurable: true, // 可配置
get() {
// todo
return val;
},
set(newVal) {
// todo
val = newVal
}
});
school.name = '珠峰培訓';
console.log(school.name);
複製程式碼
這樣我們可以在設定值和獲取值時做我們想要做的操作了
接下來我們就來寫下一個類Observer
// 在MVVM加上Observe的邏輯
if(this.$el){
// 資料劫持 就是把對想的所有屬性 改成get和set方法
new Observer(this.$data);
// 用資料和元素進行編譯
new Compile(this.$el, this);
}
--------------------------------------
class Observer{
constructor(data){
this.observe(data);
}
observe(data){
// 要對這個data資料將原有的屬性改成set和get的形式
// defineProperty針對的是物件
if(!data || typeof data !== 'object'){
return;
}
// 要將資料 一一劫持 先獲取取到data的key和value
Object.keys(data).forEach(key=>{
// 定義響應式變化
this.defineReactive(data,key,data[key]);
this.observe(data[key]);// 深度遞迴劫持
});
}
// 定義響應式
defineReactive(obj,key,value){
// 在獲取某個值的適合 想彈個框
let that = this;
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
get(){ // 當取值時呼叫的方法
return value;
},
set(newValue){ // 當給data屬性中設定值的適合 更改獲取的屬性的值
if(newValue!=value){
// 這裡的this不是例項
that.observe(newValue);// 如果是設定的是物件繼續劫持
value = newValue;
}
}
});
}
}
複製程式碼
來再看看效果^_^
Watcher實現
觀察者的目的就是給需要變化的那個元素增加一個觀察者,用新值和老值進行比對,如果資料變化就執行對應的方法
class Watcher{ // 因為要獲取老值 所以需要 "資料" 和 "表示式"
constructor(vm,expr,cb){
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 先獲取一下老的值 保留起來
this.value = this.get();
}
// 老套路獲取值的方法,這裡先不進行封裝
getVal(vm, expr) {
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
}
get(){
let value = this.getVal(this.vm,this.expr);
return value;
}
// 對外暴露的方法,如果值改變就可以呼叫這個方法來更新
update(){
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if(newValue != oldValue){
this.cb(newValue); // 對應watch的callback
}
}
}
複製程式碼
在哪裡使用watcher?答案肯定是compile呀,給需要重新編譯的DOM增加watcher
text(node, vm, expr) { // 文字處理
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(vm, expr);
+ expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
+ new Watcher(vm, arguments[1],(newValue)=>{
+ // 如果資料變化了,文字節點需要重新獲取依賴的屬性更新文字中的內容
+ updateFn && updateFn(node,this.getTextVal(vm,expr));
+ });
+ })
updateFn && updateFn(node, value)
},
model(node, vm, expr) { // 輸入框處理
let updateFn = this.updater['modelUpdater'];
+ new Watcher(vm,expr,(newValue)=>{
+ // 當值變化後會呼叫cb 將新的值傳遞過來
+ updateFn && updateFn(node, newValue);
+ });
updateFn && updateFn(node, this.getVal(vm, expr));
}
複製程式碼
釋出訂閱
如何將檢視和資料關聯起來呢?就是將每個資料和對應的watcher關聯起來。當資料變化時讓對應的watcher執行update方法即可!再想想在哪做操作呢?就是我們的set和get!
Dep實現
class Dep{
constructor(){
// 訂閱的陣列
this.subs = []
}
addSub(watcher){
this.subs.push(watcher);
}
notify(){
this.subs.forEach(watcher=>watcher.update());
}
}
複製程式碼
關聯dep和watcher
watcher中有個重要的邏輯就是this.get();每個watcher被例項化時都會獲取資料從而會呼叫當前屬性的get方法
// watcher中的get方法
get(){
// 在取值前先將watcher儲存到Dep上
Dep.target = this;
let value = this.getVal(this.vm,this.expr); // 會呼叫屬性對應的get方法
Dep.target = null;
return value;
}
// 更新Observer中的defineReactive
defineReactive(obj,key,value){
let that = this;
+ let dep = new Dep(); // 每個變化的資料 都會對應一個陣列,這個陣列是存放所有更新的操作
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
get(){ // 當取值時呼叫的方法
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newValue){
if(newValue!=value){
that.observe(newValue);
value = newValue;
dep.notify(); // 通知所有人 資料更新了
}
}
});
}
複製程式碼
到此資料和檢視就關聯起來了!^_^
監聽輸入事件
setVal(vm,expr,value){
expr = expr.split('.');
return expr.reduce((prev,next,currentIndex)=>{
if(currentIndex === expr.length-1){
return prev[next] = value;
}
return prev[next];
},vm.$data);
},
model(node, vm, expr) {
let updateFn = this.updater['modelUpdater'];
new Watcher(vm,expr,(newValue)=>{
// 當值變化後會呼叫cb 將新的值傳遞過來 ()
updateFn && updateFn(node, this.getVal(vm, expr));
});
+ node.addEventListener('input',(e)=>{
+ let newValue = e.target.value;
+ // 監聽輸入事件將輸入的內容設定到對應資料上
+ this.setVal(vm,expr,newValue)
+ });
updateFn && updateFn(node, this.getVal(vm, expr));
}
複製程式碼
代理資料
class MVVM{
constructor(options){
this.$el = options.el;
this.$data = options.data;
if(this.$el){
new Observer(this.$data);
// 將資料代理到例項上直接操作例項即可,不需要通過vm.$data來進行操作
this.proxyData(this.$data);
new Compile(this.$el, this);
}
}
proxyData(data){
Object.keys(data).forEach(key=>{
Object.defineProperty(this,key,{
get(){
return data[key]
},
set(newValue){
data[key] = newValue
}
})
})
}
}
複製程式碼
看看最終效果!
結語
MVVM相關視訊:
關注我們珠峰培訓架構課程: QQ群號:464438594