前言
這是本人的學習的記錄,因為最近在準備面試,很多情況下會被提問到:請簡述 mvvm
?
一般情況下我可能這麼答:mvvm
是檢視和邏輯的一個分離,是model view view-model
的縮寫,通過虛擬dom的方式實現雙向資料繫結(我隨便答得)
那麼問題來了,你知道 mvvm
是怎麼實現的?
回答: mvvm
主要通過 Object
的 defineProperty
屬性,重寫 data
的 set
和get
函式來實現。 ok,回答得60分,那麼你知道具體實現過程麼?想想看,就算他沒問到而你答了出來是不是更好?前提下,一定要手擼一下簡單的mvvm
才會對它有印象~
話不多說,接下來是參考自張仁陽老師的教學視訊而作,採用的是ES6語法,其中也包含了我個人的理解,如果能幫助到您,我將十分高興。如有錯誤之處,請各位大佬指正出來,不勝感激~~~
在實現之前,請先了解基本的mvvm
的編譯過程以及使用
-
編譯的流程圖
-
整體分析
可以發現new MVVM()
後的編譯過程主體分為兩個部分:
- 一部分是模板的編譯
Compile
- 編譯元素和文字,最終渲染到頁面中
- 其中標籤中有模板指令的標籤才執行編譯 例如
<div>我很帥</div>
不執行編譯
- 一部分是資料劫持
Observer
Dep
釋出訂閱,將所有需要通知變化的data
新增到一個陣列中Watcher
如果資料發生改變,在Object
的defineProperty
的set
函式中呼叫Watcher
的update
方法
明確本文需要實現的目標
- 實現模板編譯的過程 完成
Vue
例項中的屬性可以正確繫結在標籤中,並且渲染在頁面中- 工作:指令的解析,正則替換
{{}}
- 將節點的內容
node.textContent
或者input
的value
編譯出來
- 工作:指令的解析,正則替換
- 完成資料的雙向繫結
- 工作:通過
observe
類劫持資料變化 - 新增發布與訂閱:
Object.defineProperty
在get
鉤子中addSub
,set
鉤子中通知變化dep.notify()
dep.notify()
呼叫的是Watcher
的update
方法,也就是說需要在input
變化時呼叫更新
- 工作:通過
先明確我們的目標是:檢視的渲染和雙向的資料繫結以及通知變化!步驟:先從怎麼使用Vue入手一步步解析,從入口類Vue到編譯compile 目標【實現檢視渲染】,在此之前還有observe對資料進行劫持後再呼叫檢視的更新,watcher 類監聽變化到最後通知所有檢視的更新等等。
分解 Vue 例項
如何入手?首先從怎麼使用Vue
開始。讓我們一步步解析Vue
的使用:
let vm = new Vue({
el: '#app'
data: {
message: 'hello world'
}
})
複製程式碼
上面程式碼可以看出使用Vue
,我們是先new
一個Vue
例項,傳一個物件引數,包含 el
和 data
。
ok,以上得到了資訊,接下來讓我們實現目標1:將Vue
例項的data
編譯到頁面中
實現 Complie 編譯模板的過程
先看看頁面的使用:index.html
<div id="app">
<input type="text" v-model="jsonText.text">
<div>{{message}}</div>
{{jsonText.text}}
</div>
<script src="./watcher.js"></script>
<script src="./observer.js"></script>
<script src="./compile.js"></script>
<script src="./vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
message: 'gershonv',
jsonText:{
text: 'hello Vue'
}
}
})
</script>
複製程式碼
第一步當然是新增
Vue
類作為一個入口檔案。
vue 類-入口檔案的新增
新建一個vue.js
檔案,其程式碼如下
建構函式中定義$el
和$data
,因為後面的編譯要使用到
class Vue {
constructor(options) {
this.$el = options.el; // 掛載
this.$data = options.data;
// 如果有要編譯的模板就開始編譯
if (this.$el) {
// 用資料和元素進行編譯
new Compile(this.$el, this)
}
}
}
複製程式碼
- 這裡暫時未新增資料劫持
obeserve
,實現目標1暫時未用到,後續再新增 - 編譯需要
el
和相關資料,上面程式碼執行後會有編譯,所以我們新建一個執行編譯的類的檔案
這裡在入口檔案
vue.js
中new
了一個Compile
例項,所以接下來新建compile.js
Compile 類-模板編譯的新增
Compile
需要做什麼?
我們知道頁面中操作dom
會消耗效能,所以可以把dom
移入記憶體處理:
- 先把真實的
dom
移入到記憶體中 (在記憶體中操作dom
速度比較快)- 怎麼放在記憶體中?可以利用文件碎片
fragment
- 怎麼放在記憶體中?可以利用文件碎片
- 編譯
compile(fragment){}
- 提取想要的元素節點和文字節點
v-model
{{}}
,然後進行相關操作。
- 提取想要的元素節點和文字節點
- 把編譯好的
fragment
塞回頁面裡去
class Compile {
constructor(el, vm) {
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;
}
/* 核心方法 */
node2fragment(el) { // 將el的內容全部放入記憶體中
// 文件碎片
let fragment = document.createDocumentFragment();
while (el.firstChild) { // 移動DOM到文件碎片中
fragment.appendChild(firstChild)
}
return fragment;
}
compile(fragment) {
}
}
複製程式碼
補充:將el
中的內容移入文件碎片fragment
中是一個進出棧的過程。el 的子元素被移到fragment
【出棧】後,el
下一個子元素會變成firstChild
。
編譯的過程就是把我們的資料渲染好,表現在檢視中
編譯過程 compile(fragment)
- 第一步:獲取元素的節點,提取其中的指令或者模板
{{}}
- 首先需要遍歷節點,用到了遞迴方法,因為有節點巢狀的關係,
isElementNode
代表是節點元素,也是遞迴的終止的判斷條件。
- 首先需要遍歷節點,用到了遞迴方法,因為有節點巢狀的關係,
- 第二步:分類編譯指令的方法
compileElement
和 編譯文字{{}}
的方法compileElement
對v-model
、v-text
等指令的解析compileText
編譯文字節點{{}}
class 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
- 取出元素的屬性
node.attributes
先判斷是否包含指令 - 判斷指令型別(
v-html v-text v-model...
) 呼叫不一樣的資料更新方法- 這裡提取了編譯的工具物件
CompileUtil
- 呼叫方法:
CompileUtil[type](node, this.vm, expr)
CompileUtil.型別(節點,例項,v-XX 繫結的屬性值)
- 這裡提取了編譯的工具物件
class Compile{
// ...
// 判斷是否是指令 ==> compileElement 中遞迴標籤屬性中使用
isDirective(name) {
return name.includes('v-')
}
compileElement(node) {
// v-model 編譯
let attrs = node.attributes; // 取出當前節點的屬性
Array.from(attrs).forEach(attr => {
let attrName = attr.name;
// 判斷屬性名是否包含 v-
if (this.isDirective(attrName)) {
// 取到對應的值,放到節點中
let expr = attr.value;
// v-model v-html v-text...
let [, type] = attrName.split('-')
CompileUtil[type](node, this.vm, expr);
}
})
}
compileText(node) {
// 編譯 {{}}
let expr = node.textContent; //取文字中的內容
let reg = /\{\{([^}]+)\}\}/g;
if (reg.test(expr)) {
CompileUtil['text'](node, this.vm, expr)
}
}
// compile(fragment){...}
}
CompileUtil = {
getVal(vm, expr) { // 獲取例項上對應的資料
expr = expr.split('.'); // 處理 jsonText.text 的情況
return expr.reduce((prev, next) => {
return prev[next] // 譬如 vm.$data.jsonText.text、vm.$data.message
}, vm.$data)
},
getTextVal(vm, expr) { // 獲取文字編譯後的結果
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
return this.getVal(vm, arguments[1])
})
},
text(node, vm, expr) { // 文字處理 引數 [節點, vm 例項, 指令的屬性值]
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(vm, expr)
updateFn && updateFn(node, value)
},
model(node, vm, expr) { // 輸入框處理
let updateFn = this.updater['modelUpdater'];
updateFn && updateFn(node, this.getVal(vm, expr))
},
updater: {
// 文字更新
textUpdater(node, value) {
node.textContent = value
},
// 輸入框更新
modelUpdater(node, value) {
node.value = value;
}
}
}
複製程式碼
到現在為止 就完成了資料的繫結,也就是說new Vue
例項中的 data
已經可以正確顯示在頁面中了,現在要解決的就是如何實現雙向繫結
結合開篇的vue
編譯過程的圖可以知道我們還少一個observe
資料劫持,Dep
通知變化,新增Watcher
監聽變化, 以及最終重寫data
屬性
實現雙向繫結
Observer 類-觀察者的新增
- 在
vue.js
中劫持資料
class Vue{
//...
if(this.$el){
new Observer(this.$data); // 資料劫持
new Compile(this.$el, this); // 用資料和元素進行編譯
}
}
複製程式碼
- 新建
observer.js
檔案
程式碼步驟:
- 構造器中新增直接進行
observe
- 判斷
data
是否存在, 是否是個物件(new Vue 時可能不寫data
屬性) - 將資料一一劫持,獲取
data
中的key
和value
- 判斷
class Observer {
constructor(data) {
this.observe(data)
}
observe(data) {
// 要對這個資料將原有的屬性改成 set 和 get 的形式
if (!data || typeof data !== 'object') {
return
}
// 將資料一一劫持
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
}
}
})
}
}
複製程式碼
雖然有了
observer
,但是並未關聯,以及通知變化。下面就新增Watcher
類
Watcher 類的新增
新建watcher.js
檔案
- 觀察者的目的就是給需要變化的那個元素增加一個觀察者,當資料變化後執行對應的方法
先回憶下watch
的用法:this.$watch(vm, 'a', function(){...})
我們在新增發布訂閱者時需要傳入引數有: vm例項,v-XX繫結的屬性, cb回撥函式
(getVal
方法拷貝了之前 CompileUtil
的方法,其實可以提取出來的...)
class Watcher {
// 觀察者的目的就是給需要變化的那個元素增加一個觀察者,當資料變化後執行對應的方法
// this.$watch(vm, 'a', function(){...})
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) => { //vm.$data.a
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
class Compile{
//...
}
CompileUtil = {
//...
text(node, vm, expr) { // 文字處理 引數 [節點, vm 例項, 指令的屬性值]
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(vm, expr)
updateFn && updateFn(node, value)
expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
new Watcher(vm, arguments[1], () => {
// 如果資料變化了,文字節點需要重新獲取依賴的屬性更新文字中的內容
updateFn && updateFn(node, this.getTextVal(vm, expr))
})
})
},
//...
model(node, vm, expr) { // 輸入框處理
let updateFn = this.updater['modelUpdater'];
// 這裡應該加一個監控,資料變化了,應該呼叫watch 的callback
new Watcher(vm, expr, (newValue) => {
// 當值變化後會呼叫cb 將newValue傳遞過來()
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))
},
//...
}
複製程式碼
實現了監聽後發現變化並沒有通知到所有指令繫結的模板或是{{}}
,所以我們需要Dep
監控、例項的釋出訂閱屬性的一個類,我們可以新增到observer.js
中
Dep 類的新增
注意 第一次編譯的時候不會呼叫Watcher
,dep.target
不存在,new Watcher
的時候target
才有值
有點繞,看下面程式碼:
class Watcher {
constructor(vm, expr, cb) {
//...
this.value = this.get()
}
get(){
Dep.target = this;
let value = this.getVal(this.vm, this.expr);
Dep.target = null;
return value
}
//...
}
// compile.js
CompileUtil = {
model(node, vm, expr) { // 輸入框處理
//...
new Watcher(vm, expr, (newValue) => {
// 當值變化後會呼叫cb 將newValue傳遞過來()
updateFn && updateFn(node, this.getVal(vm, expr))
});
}
}
複製程式碼
class Observer{
//...
defineReactive(obj, key, value){
let that = this;
let dep = new Dep(); // 每個變化的資料 都會對應一個陣列,這個陣列存放所有更新的操作
Object.defineProperty(obj, key, {
//...
get(){
Dep.target && dep.addSub(Dep.target)
//...
}
set(newValue){
if (newValue !== value) {
// 這裡的this不是例項
that.observe(newValue) // 如果是物件繼續劫持
value = newValue;
dep.notify(); //通知所有人更新了
}
}
})
}
}
class Dep {
constructor() {
// 訂閱的陣列
this.subs = []
}
addSub(watcher) {
this.subs.push(watcher)
}
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
複製程式碼
以上程式碼 就完成了釋出訂閱者模式,簡單的實現。。也就是說雙向繫結的目標2已經完成了
結語
板門弄斧了,本人無意譁眾取寵,這只是一篇我的學習記錄的文章。想分享出來,這樣才有進步。
如果這篇文章幫助到您,我將十分高興。有問題可以提issue
,有錯誤之處也希望大家能提出來,非常感激。
具體原始碼我放在了我的github了,有需要的自取。 原始碼連結