前言: 三月四月是招聘旺季,相信不少面試前端崗的同學都有被問到vue的原理是什麼吧?本文就以最簡單的方式教你如何實現vue框架的基本功能。為了減少大家的學習成本,我就以最簡單的方式教大家擼一個vue框架。
一、準備
希望準備閱讀本文的你最好具備以下技能:
- 熟悉ES6語法
- 瞭解HTML DOM 節點型別
- 熟悉
Object.defineProperty()
方法的使用 - 正規表示式的基本使用。(例如分組)
首先,我們按照以下程式碼建立一個HTML檔案,本文主要就是教大家如何實現以下功能。
<script src="../src/vue.js"></script>
</head>
<body>
<div id="app">
<!-- 解析插值表示式 -->
<h2>title 是 {{title}}</h2>
<!-- 解析常見指令 -->
<p v-html='msg1' title='混淆屬性1'>混淆文字1</p>
<p v-text='msg2' title='混淆屬性2'>混淆文字2</p>
<input type="text" v-model="something">
<!-- 雙向資料繫結 -->
<p>{{something}}</p>
<!-- 複雜資料型別 -->
<p>{{dad.son.name}}</p>
<p v-html='dad.son.name'></p>
<input type="text" v-model="dad.son.name">
<button v-on:click='sayHi'>sayHi</button>
<button @click='printThis'>printThis</button>
</div>
</body>
複製程式碼
let vm = new Vue({
el: '#app',
data: {
title: '手把手教你擼一個vue框架',
msg1: '<a href="#">應該被解析成a標籤</a>',
msg2: '<a href="#">不應該被解析成a標籤</a>',
something: 'placeholder',
dad: {
name: 'foo',
son: {
name: 'bar',
son: {}
}
}
},
methods: {
sayHi() {
console.log('hello world')
},
printThis() {
console.log(this)
}
},
})
複製程式碼
準備工作做好了,那我們就一起來實現vue框架的基本功能吧!
MVVM 實現思路
我們都知道,vue是基於MVVM設計模式的漸進式框架。那麼在JavaScript中,我們該如何實現一個MVVM框架呢? 主流的實現MVVM框架的思路有三種:
- backbone.js
釋出者-訂閱者模式,一般通過pub和sub的方式實現資料和檢視的繫結。
- Angular.js
Angular.js是通過髒值監測的方式對比資料是否有變更,來決定是否更新檢視。類似於通過定時器輪尋監測資料是否發生了額改變。
- Vue.js
Vue.js是採用資料劫持結合釋出者-訂閱者模式的方式。在vue2.6之前,是通過Object.defineProperty() 來劫持各個屬性的setter和getter方法,在資料變動時釋出訊息給訂閱者,觸發相應的回撥。這也是IE8以下的瀏覽器不支援vue的根本原因。
Vue實現思路
- 實現一個Compile模板解析器,能夠對模板中的指令和插值表示式進行解析,並賦予對應的操作
- 實現一個Observer資料監聽器,能夠對資料物件(data)的所有屬性進行監聽
- 實現一個Watcher 偵聽器。講Compile的解析結果,與Observer所觀察的物件連線起來,建立關係,在Observer觀察到資料物件變化時,接收通知,並更新DOM
- 建立一個公共的入口物件(Vue),接收初始化配置,並協調Compile、Observer、Watcher模組,也就是Vue。
上述流程如下圖所示:
二、Vue入口檔案
把邏輯捋順清楚後,我們會發現,其實我們要在這個入口檔案做的事情很簡單:
- 把data和methods掛載到根例項中;
- 用Observer模組監聽data所有屬性的變化
- 如果存在掛載點,則用Compile模組編譯該掛載點下的所有指令和插值表示式
/**
* vue.js (入口檔案)
* 1. 將data,methods裡面的屬性掛載根例項中
* 2. 監聽 data 屬性的變化
* 3. 編譯掛載點內的所有指令和插值表示式
*/
class Vue {
constructor(options={}){
this.$el = options.el;
this.$data = options.data;
this.$methods = options.methods;
debugger
// 將data,methods裡面的屬性掛載根例項中
this.proxy(this.$data);
this.proxy(this.$methods);
// 監聽資料
// new Observer(this.$data)
if(this.$el) {
// new Compile(this.$el,this);
}
}
proxy(data={}){
Object.keys(data).forEach(key=>{
// 這裡的this 指向vue例項
Object.defineProperty(this,key,{
enumerable: true,
configurable: true,
set(value){
if(data[key] === value) return
return value
},
get(){
return data[key]
},
})
})
}
}
複製程式碼
三、Compile模組
compile主要做的事情是解析指令(屬性節點)與插值表示式(文字節點),將模板中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視。
因為遍歷解析的過程有多次操作dom節點,這會引發頁面的迴流與重繪的問題,為了提高效能和效率,我們最好是在記憶體中解析指令和插值表示式,因此我們需要遍歷掛載點下的所有內容,把它儲存到DocumentFragments中。
DocumentFragments 是DOM節點。它們不是主DOM樹的一部分。通常的用例是建立文件片段,將元素附加到文件片段,然後將文件片段附加到DOM樹。因為文件片段存在於記憶體中,並不在DOM樹中,所以將子元素插入到文件片段時不會引起頁面迴流(對元素位置和幾何上的計算)。因此,使用文件片段通常會帶來更好的效能。
所以我們需要一個node2fragment()
方法來處理上述邏輯。
實現node2fragment,將掛載點內的所有節點儲存到DocumentFragment中
node2fragment(node) {
let fragment = document.createDocumentFragment()
// 把el中所有的子節點挨個新增到文件片段中
let childNodes = node.childNodes
// 由於childNodes是一個類陣列,所以我們要把它轉化成為一個陣列,以使用forEach方法
this.toArray(childNodes).forEach(node => {
// 把所有的位元組點新增到fragment中
fragment.appendChild(node)
})
return fragment
}
複製程式碼
this.toArray()
是我封裝的一個類方法,用於將類陣列轉化為陣列。實現方法也很簡單,我使用了開發中最常用的技巧:
toArray(classArray) {
return [].slice.call(classArray)
}
複製程式碼
解析fragment裡面的節點
接下來我們要做的事情就是解析fragment裡面的節點:compile(fragment)
。
這個方法的邏輯也很簡單,我們要遞迴遍歷fragment裡面的所有子節點,根據節點型別進行判斷,如果是文字節點則按插值表示式進行解析,如果是屬性節點則按指令進行解析。在解析屬性節點的時候,我們還要進一步判斷:是不是由v-
開頭的指令,或者是特殊字元,如@
、:
開頭的指令。
// Compile.js
class Compile {
constructor(el, vm) {
this.el = typeof el === "string" ? document.querySelector(el) : el
this.vm = vm
// 解析模板內容
if (this.el) {
// 為了避免直接在DOM中解析指令和差值表示式所引起的迴流與重繪,我們開闢一個Fragment在記憶體中進行解析
const fragment = this.node2fragment(this.el)
this.compile(fragment)
this.el.appendChild(fragment)
}
}
// 解析fragment裡面的節點
compile(fragment) {
let childNodes = fragment.childNodes
this.toArray(childNodes).forEach(node => {
// 如果是元素節點,則解析指令
if (this.isElementNode(node)) {
this.compileElementNode(node)
}
// 如果是文字節點,則解析差值表示式
if (this.isTextNode(node)) {
this.compileTextNode(node)
}
// 遞迴解析
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
}
複製程式碼
處理解析指令的邏輯:CompileUtils
接下來我們要做的就只剩下解析指令,並把解析後的結果通知給檢視了。
當資料發生改變時,通過Watcher物件監聽expr資料的變化,一旦資料發生變化,則執行回撥函式。
new Watcher(vm,expr,callback)
// 利用Watcher將解析後的結果返回給檢視.
我們可以把所有處理編譯指令和插值表示式的邏輯封裝到compileUtil
物件中進行管理。
這裡有兩個坑點大家需要注意一下:
- 如果是複雜資料的情形,例如插值表示式:
{{dad.son.name}}
或者<p v-text='dad.son.name'></p>
,我們拿到v-text
的屬性值是字串dad.son.name
,我們是無法通過vm.$data['dad.son.name']
拿到資料的,而是要通過vm.$data['dad']['son']['name']
的形式來獲取資料。因此,如果資料是複雜資料的情形,我們需要實現getVMData()
和setVMData()
方法進行資料的獲取與修改。 - 在vue中,methods裡面的方法裡面的this是指向vue例項,因此,在我們通過
v-on
指令給節點繫結方法的時候,我們需要把該方法的this指向繫結為vue例項。
// Compile.js
let CompileUtils = {
getVMData(vm, expr) {
let data = vm.$data
expr.split('.').forEach(key => {
data = data[key]
})
return data
},
setVMData(vm, expr,value) {
let data = vm.$data
let arr = expr.split('.')
arr.forEach((key,index) => {
if(index < arr.length -1) {
data = data[key]
} else {
data[key] = value
}
})
},
// 解析插值表示式
mustache(node, vm) {
let txt = node.textContent
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
let expr = RegExp.$1
node.textContent = txt.replace(reg, this.getVMData(vm, expr))
new Watcher(vm, expr, newValue => {
node.textContent = txt.replace(reg, newValue)
})
}
},
// 解析v-text
text(node, vm, expr) {
node.textContent = this.getVMData(vm, expr)
new Watcher(vm, expr, newValue => {
node.textContent = newValue
})
},
// 解析v-html
html(node, vm, expr) {
node.innerHTML = this.getVMData(vm, expr)
new Watcher(vm, expr, newValue => {
node.innerHTML = newValue
})
},
// 解析v-model
model(node, vm, expr) {
let that = this
node.value = this.getVMData(vm, expr)
node.addEventListener('input', function () {
// 下面這個寫法不能深度改變資料
// vm.$data[expr] = this.value
that.setVMData(vm,expr,this.value)
})
new Watcher(vm, expr, newValue => {
node.value = newValue
})
},
// 解析v-on
eventHandler(node, vm, eventType, expr) {
// 處理methods裡面的函式fn不存在的邏輯
// 即使沒有寫fn,也不會影響專案繼續執行
let fn = vm.$methods && vm.$methods[expr]
try {
node.addEventListener(eventType, fn.bind(vm))
} catch (error) {
console.error('丟擲這個異常表示你methods裡面沒有寫方法\n', error)
}
}
}
複製程式碼
四、Observer模組
其實在Observer模組中,我們要做的事情也不多,就是提供一個walk()
方法,遞迴劫持vm.$data
中的所有資料,攔截setter和getter。如果資料變更,則釋出通知,讓所有訂閱者更新內容,改變檢視。
需要注意的是,如果設定的值是一個物件,則我們需要保證這個物件也要是響應式的。
用程式碼來描述即:walk(aObjectValue)
。關於如何實現響應式物件,我們採用的方法是Object.defineProperty()
完整程式碼如下:
// Observer.js
class Observer {
constructor(data){
this.data = data
this.walk(data)
}
// 遍歷walk中所有的資料,劫持 set 和 get方法
walk(data) {
// 判斷data 不存在或者不是物件的情況
if(!data || typeof data !=='object') return
// 拿到data中所有的屬性
Object.keys(data).forEach(key => {
// console.log(key)
// 給data中的屬性新增 getter和 setter方法
this.defineReactive(data,key,data[key])
// 如果data[key]是物件,深度劫持
this.walk(data[key])
})
}
// 定義響應式資料
defineReactive(obj,key,value) {
let that = this
// Dep訊息容器在Watcher.js檔案中宣告,將Observer.js與Dep容器有關的程式碼註釋掉並不影響相關邏輯。
let dep = new Dep()
Object.defineProperty(obj,key,{
enumerable:true,
configurable: true,
get(){
// 如果Dep.target 中有watcher 物件,則儲存到訂閱者陣列中
Dep.target && dep.addSub(Dep.target)
return value
},
set(aValue){
if(value === aValue) return
value = aValue
// 如果設定的值是一個物件,那麼這個物件也應該是響應式的
that.walk(aValue)
// watcher.update
// 釋出通知,讓所有訂閱者更新內容
dep.notify()
}
})
}
}
複製程式碼
五、Watcher模組
Watcher的作用就是將Compile解析的結果和Observer觀察的物件關聯起來,建立關係,當Observer觀察的資料發生變化是,接收通知(dep.notify
)告訴Watcher,Watcher在通過Compile更新DOM。這裡面涉及一個釋出者-訂閱者模式的思想。
Watcher是連線Compile和Observer的橋樑。
我們在Watcher的建構函式中,需要傳遞三個引數:
vm
:vue例項expr
:vm.$data中資料的名字(key)callback
:當資料發生改變時,所執行的回撥函式
注意,為了獲取深層資料物件,這裡我們需要引用之前宣告的getVMData()
方法。
定義Watcher
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
//
this.oldValue = this.getVMData(vm,expr)
//
}
複製程式碼
暴露update()方法,用於在資料更新時更新頁面
我們應該在什麼情況更新頁面呢?
我們應該在Watcher中實現一個update方法,對新值和舊值進行比較。當資料發生改變時,執行回撥函式。
update() {
// 對比expr是否發生改變,如果改變則呼叫callback
let oldValue = this.oldValue
let newValue = this.getVMData(this.vm,this.expr)
// 變化的時候呼叫callback
if(oldValue !== newValue) {
this.callback(newValue,oldValue)
}
}
複製程式碼
關聯Watcher與Compile
以插值表示式為例:(下文也會以這個例子進行說明) 當我們在控制檯修改vm.msg
的值的時候,需要重新渲染DOM,所以我們還需要通過Watcher偵聽expr值的變化。
// compile.js
mustache(node, vm) {
let txt = node.textContent
let reg = /\{\{(.+)\}\}/
if (reg.test(txt)) {
let expr = RegExp.$1
node.textContent = txt.replace(reg, this.getVMData(vm, expr))
// 偵聽expr值的變化。當expr的值發生改變時,執行回撥函式
new Watcher(vm, expr, newValue => {
node.textContent = txt.replace(reg, newValue)
})
}
},
複製程式碼
那麼我們應該在什麼時候呼叫update方法,觸發回撥函式呢?
由於我們在上文中已經在Observer實現了響應式資料,所以在資料發生改變時,必然會觸發set方法。所以我們在觸發set方法的同時,還需要呼叫watcher.update方法,觸發回撥函式,修改頁面。
// observer.js
defineReactive(obj,key,value) {
...
set(aValue){
if(value === aValue) return
value = aValue
// 如果設定的值是一個物件,那麼這個物件也應該是響應式的
that.walk(aValue)
watcher.update
}
}
複製程式碼
那麼問題來了,我們在解析不同的指令時,new 了很多個Watcher,那麼這裡要呼叫哪個Watcher的update方法呢?如何通知所有的Watcher,告訴他資料發生了改變了呢?
所以這裡又引出了一個新的概念:釋出者-訂閱者模式。
什麼是釋出者-訂閱者模式?
釋出者-訂閱者模式也叫觀察者模式。 他定義了一種一對多的依賴關係,即當一個物件的狀態發生改變時,所有依賴於他的物件都會得到通知並自動更新,解決了主體物件與觀察者之間功能的耦合。
這裡我們用微信公眾號為例來說明這種情況。
譬如我們一個班級都訂閱了公眾號,那麼這個班級的每個人都是訂閱者(subscriber),公眾號則是釋出者(publisher)。如果某一天公眾號發現文章內容出錯了,需要修改一個錯別字(修改vm.$data中的資料),是不是要通知每一個訂閱者?總不能學委那裡的文章發生了改變,而班長的文章沒有發生改變吧。在這個過程中,釋出者不用關心誰訂閱了它,只需要給所有訂閱者推送這條更新的訊息即可(notify)。
所以這裡涉及兩個過程:
- 新增訂閱者:
addSub(watcher)
- 推送通知:
notify(){ sub.update() }
在這個過程中,充當釋出者角色的是每一個訂閱者所共同依賴的物件。
我們在Watcher中定義一個類:Dep(依賴容器)。在我們每次new一個Watcher的時候,都往Dep裡面新增訂閱者。一旦Observer的資料發生改變了,則通知Dep發起通知(notify),執行update函式更改DOM即可。
// watcher.js
// 訂閱者容器,依賴收集
class Dep {
constructor(){
// 初始化一個空陣列,用來儲存訂閱者
this.subs = []
}
// 新增訂閱者
addSub(watcher){
this.subs.push(watcher)
}
// 通知
notify() {
// 通知所有的訂閱者更改頁面
this.subs.forEach(sub => {
sub.update()
})
}
}
複製程式碼
接下來我們的思路就很明確了,就是在每次new一個Watcher的時候,將它儲存到Dep容器中。即將Dep與Watcher關聯到一起。我們可以為Dep新增一個類屬性target來儲存Watcher物件,即我們需要在Watcher的建構函式中,將this賦給Dep.target。
還是以上面這個圖為例,我們分析下解析插值表示式的流程:- 首先我們會進入Observer劫持data中的資料msg,這裡我們會進入Observer中的get方法;
- 劫持後我們會判斷el是否存在,存在的話則編譯插值表示式進入Compile;
- 如果此時劫持的資料msg發生改變,則會通過mustache中的Watcher來偵聽資料的改變;
- 在Watcher的建構函式中,通過
this.oldValue = this.getVMData(vm, expr)
方法會在一次進入Observer中的get方法,然後程式執行完畢。
所以我們也就不難發現新增訂閱者的時機,程式碼如下:
- 將Watcher新增到訂閱者陣列中,如果資料發生改變,則為所有訂閱者發起通知
// Observer.js
// 定義響應式資料
defineReactive(obj,key,value) {
// defineProperty 會改變this指向
let that = this
let dep = new Dep()
Object.defineProperty(obj,key,{
enumerable:true,
configurable: true,
get(){
// 如果Dep.target存在,即存在watcher 物件,則儲存到訂閱者陣列中
// debugger
Dep.target && dep.addSub(Dep.target)
return value
},
set(aValue){
if(value === aValue) return
value = aValue
// 如果設定的值是一個物件,那麼這個物件也應該是響應式的
that.walk(aValue)
// watcher.update
// 釋出通知,讓所有訂閱者更新內容
dep.notify()
}
})
}
複製程式碼
- 將Watcher儲存到Dep容器中後,將Dep.target置為空,以便下一次儲存Watcher
// Watcher.js
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
Dep.target = this
// debugger
this.oldValue = this.getVMData(vm,expr)
Dep.target = null
}
複製程式碼
Watcher.js完整程式碼如下:
// Watcher.js
class Watcher {
/**
*
* @param {*} vm 當前的vue例項
* @param {*} expr data中資料的名字
* @param {*} callback 一旦資料改變,則需要呼叫callback
*/
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
Dep.target = this
this.oldValue = this.getVMData(vm,expr)
Dep.target = null
}
// 對外暴露的方法,用於更新頁面
update() {
// 對比expr是否發生改變,如果改變則呼叫callback
let oldValue = this.oldValue
let newValue = this.getVMData(this.vm,this.expr)
// 變化的時候呼叫callback
if(oldValue !== newValue) {
this.callback(newValue,oldValue)
}
}
// 只是為了說明原理,這裡偷個懶,就不抽離出公共js檔案了
getVMData(vm,expr) {
let data = vm.$data
expr.split('.').forEach(key => {
data = data[key]
})
return data
}
}
class Dep {
constructor(){
this.subs = []
}
// 新增訂閱者
addSub(watcher){
this.subs.push(watcher)
}
// 通知
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
複製程式碼
至此,我們就已經實現了Vue框架的基本功能了。
本文只是通過用最簡單的方式來模擬vue框架的基本功能,所以在細節上的處理和程式碼質量上肯定會犧牲很多,還請大家見諒。
文中難免會有一些不嚴謹的地方,歡迎大家指正,有興趣的話大家可以一起交流下