前言: 三月四月是招聘旺季,相信很多面試前端崗的同窗都有被問到vue的原理是什麼吧?本文就以最簡單的方式教你如何實現vue框架的基本功能。爲了減小你們的學習成本,我就以最簡單的方式教你們擼一個vue框架。javascript
但願準備閱讀本文的你最好具有如下技能:html
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框架的基本功能吧!vue
咱們都知道,vue是基於MVVM設計模式的漸進式框架。那麼在JavaScript中,咱們該如何實現一個MVVM框架呢? 主流的實現MVVM框架的思路有三種:java
發佈者-訂閱者模式,通常經過pub和sub的方式實現數據和視圖的綁定。node
Angular.js是經過髒值監測的方式對比數據是否有變動,來決定是否更新視圖。相似於經過定時器輪尋監測數據是否發生了額改變。面試
Vue.js是採用數據劫持結合發佈者-訂閱者模式的方式。在vue2.6以前,是經過Object.defineProperty() 來劫持各個屬性的setter和getter方法,在數據變更時發佈消息給訂閱者,觸發相應的回調。這也是IE8如下的瀏覽器不支持vue的根本緣由。正則表達式
上述流程以下圖所示:設計模式
把邏輯捋順清楚後,咱們會發現,其實咱們要在這個入口文件作的事情很簡單:數組
/** * 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主要作的事情是解析指令(屬性節點)與插值表達式(文本節點),將模板中的變量替換成數據,而後初始化渲染頁面視圖,並將每一個指令對應的節點綁定更新函數,添加監聽數據的訂閱者,一旦數據有變更,收到通知,更新視圖。
由於遍歷解析的過程有屢次操做dom節點,這會引起頁面的迴流與重繪的問題,爲了提升性能和效率,咱們最好是在內存中解析指令和插值表達式,所以咱們須要遍歷掛載點下的全部內容,把它存儲到DocumentFragments中。
DocumentFragments 是DOM節點。它們不是主DOM樹的一部分。一般的用例是建立文檔片斷,將元素附加到文檔片斷,而後將文檔片斷附加到DOM樹。由於文檔片斷存在於內存中,並不在DOM樹中,因此將子元素插入到文檔片斷時不會引發頁面迴流(對元素位置和幾何上的計算)。所以,使用文檔片斷一般會帶來更好的性能。
因此咱們須要一個node2fragment()
方法來處理上述邏輯。
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裏面的節點: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)
}
})
}
}
複製代碼
接下來咱們要作的就只剩下解析指令,並把解析後的結果通知給視圖了。
當數據發生改變時,經過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()
方法進行數據的獲取與修改。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模塊中,咱們要作的事情也很少,就是提供一個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的做用就是將Compile解析的結果和Observer觀察的對象關聯起來,創建關係,當Observer觀察的數據發生變化是,接收通知(dep.notify
)告訴Watcher,Watcher在經過Compile更新DOM。這裏面涉及一個發佈者-訂閱者模式的思想。
Watcher是鏈接Compile和Observer的橋樑。
咱們在Watcher的構造函數中,須要傳遞三個參數:
vm
:vue實例expr
:vm.$data中數據的名字(key)callback
:當數據發生改變時,所執行的回調函數注意,爲了獲取深層數據對象,這裏咱們須要引用以前聲明的getVMData()
方法。
constructor(vm,expr,callback){
this.vm = vm
this.expr = expr
this.callback = callback
//
this.oldValue = this.getVMData(vm,expr)
//
}
複製代碼
咱們應該在什麼狀況更新頁面呢?
咱們應該在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)
}
}
複製代碼
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。
this.oldValue = this.getVMData(vm, expr)
方法會在一次進入Observer中的get方法,而後程序執行完畢。因此咱們也就不難發現添加訂閱者的時機,代碼以下:
// 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.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框架的基本功能,因此在細節上的處理和代碼質量上確定會犧牲不少,還請你們見諒。
文中不免會有一些不嚴謹的地方,歡迎你們指正,有興趣的話你們能夠一塊兒交流下