對於Vue.js技術棧,咱們的第一想法有可能就是容易上手,對於新手比較友好。確實如此,筆者剛剛入手的時候,以爲比較容易,並且在使用的過程當中,也感受到了它的強大。html
最近在準備面試,只知道Vue.js的使用是遠遠不夠的,因此開始剖析Vue.js的源碼。下面一步一步講解其原理以及實現。vue
先給出源碼的github地址。node
官方介紹:Vue.js是一套用於構建用戶界面的漸進式框架。那麼「漸進式」要如何理解呢?git
Vue的核心的功能,是一個視圖模板引擎,但這不是說Vue就不能成爲一個框架。以下圖所示,這裏包含了Vue的全部部件,在聲明式渲染(視圖模板引擎)的基礎上,咱們能夠經過添加組件系統、客戶端路由、大規模狀態管理來構建一個完整的框架。更重要的是,這些功能相互獨立,你能夠在覈心功能的基礎上任意選用其餘的部件,不必定要所有整合在一塊兒。能夠看到,所說的「漸進式」,其實就是Vue的使用方式,同時也體現了Vue的設計的理念github
漸進式表明的含義是:沒有多作職責以外的事。面試
Vue.js只提供了 vue-cli 生態中最核心的組件系統 和 雙向數據綁定(也叫數據驅動)。vue-cli
注意:由於該功能的實現用到了ES6的語法以及一些日常不常使用的知識,第三部分主要講解了用到的知識點,能夠先了解了基礎知識知識以後再來看這一部分的實現。編程
咱們都知道Vue.js的兩個核心就是 組件系統和 數據驅動(雙向數據綁定),因此接下來咱們就 雙向數據綁定進行講解以及實現。數組
如上圖所示:總體實現分爲四步:爲了更好地理解實現過程,貼出一張更加詳細的流程圖:瀏覽器
下面的講解主要分爲三部分:模板編譯(Compiler)、 數據劫持(Observer)、觀察者(Watcher)。
咱們都知道,在使用Vue.js的時候咱們都須要 new Vue({})
,此時的Vue即爲一個類,大(花)括號裏面傳遞的內容即爲Vue的屬性和方法。要想實現雙向數據綁定,須要的最基本的元素即爲 el
和 data
,有了可編譯的模板和數據,咱們才能夠進行接下來的模板編譯以及數據劫持,最終經過觀察者來實時監測數據的變化進而來不斷的更新視圖。因此 Vue
類的做用能夠理解爲一個橋樑,將模板編譯,數據劫持鏈接起來。
由於下面的代碼以及講解內容都爲筆者本身實現的功能爲例,因此Vue類更名爲了MVVM,基本功能是同樣的(下面的代碼不完整,主要目的是爲了講解編寫的主要流程和主要功能)。
class Compile {
//vm-->MVVM中傳入的第二個參數就是MVVM的實例,即new MVVM()
constructor(el, vm) {
//傳入的多是 #app或者document.getElementById('app'),因此須要進行判斷
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
//防止用戶輸入的既不是「#el」字符串也不是document節點
if (this.el) {
//若是這個元素可以獲取到,咱們纔開始編譯
//1.先把真實的DOM移入到內存中(優化性能) -->使用節點碎片 fragment
let fragment = this.nodeToFragment(this.el);
//2.編譯=>提取想要的元素節點(v-model)和文本節點{{}}
this.compile(fragment)
//3.把編譯好的fragment在放回到頁面中
this.el.appendChild(fragment)
}
}
複製代碼
在判斷擁有可編譯模板以後,接下來就要分別進行下面三步:
直接操做DOM節點是很是損耗性能的,更況且對於一個真實的頁面或者一個項目,DOM層會有不少節點以及嵌套的節點,因此,若是咱們直接操做DOM,可想而知性能會變得不好。在這裏咱們要藉助 fragment
節點碎片,來減小由於直接大量的操做DOM而形成的性能問題。(這個過程能夠簡單的理解爲將DOM節點都移入到內存中,在內存中對DOM節點進行一系列的操做,這樣就會提升性能)
nodeToFragment(el) { //須要將el中的內容所有放入到內存中
//文檔碎片,不是真正的DOM,是內存中的節點
let fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = el.firstChild) {
//將el中的真實節點一個一個的移入到文檔碎片中(el.firstChild指文檔中的第一個節點,這一個節點裏面可能嵌套不少個節點,可是都不要緊,都會一次取走)
fragment.appendChild(firstChild);
}
return fragment; // 內存中的節點
}
複製代碼
上面的一段代碼就是將DOM節點移入到內存中的過程,在執行完上面一段代碼以後,打開瀏覽器的控制檯你會發現以前的節點都已經消息了(存入到了內存中)。
compile(fragment) {
//須要遞歸
let childNodes = fragment.childNodes; //只拿到第一層(父級),拿不到嵌套層的
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
//這裏的須要編譯元素
this.compileElement(node);
//是元素節點,還須要繼續深刻的檢查(若是是元素節點,有可能節點裏面會嵌套節點,因此要使用遞歸)
this.compile(node) //由於外層是箭頭函數,因此this始終指向Compile實例
} else {
//是文本節點
//這裏須要編譯文本
this.compileText(node)
}
})
}
compileElement(node) {
//編譯帶v-model、v-text等的(取節點的屬性)
let attrs = node.attributes; //取出當前節點的屬性
Array.from(attrs).forEach(attr => {
//判斷屬性名字是否是包含v-
let attrName = attr.name;
if (this.isDirective(attrName)) {
//取到對應的值(即從data中取到message(示例)),放到節點中
let expr = attr.value;
let [, type] = attrName.split('-') //解構賦值
//node this.vm.$data expr //這裏可能有v-model或v-text 還有可能有v-html(這裏只處理前兩種)
CompileUtil[type](node, this.vm, expr)
}
})
}
compileText(node) {
//編譯帶{{}}
let expr = node.textContent; //取文本中的內容
let reg = /\{\{([^}]+)\}\}/g;
if (reg.test(expr)) {
//node this.vm.$data expr
CompileUtil['text'](node, this.vm, expr)
}
}
複製代碼
上面的三個函數最終的結果是拿到了最終須要編譯的元素節點,最後就是要將傳入的 data
中對應的數據顯示在模板上。
//文本更新
textUpdater(node, value) {
node.textContent = value
},
//輸入框更新
modelUpdater(node, value) {
node.value = value
}
複製代碼
將數據顯示在節點上以後,咱們發現頁面上並無顯示任何數據,並且元素節點也不存在,那是由於上面的一系列操做都是咱們在內存中進行的,最後咱們須要將編譯好的 fragment
放回到頁面中。
this.el.appendChild(fragment)
複製代碼
至此,模板編譯部分就結束了了,這時候咱們就會發現咱們在data中定義的數據已經徹底渲染在頁面上了。
接下來,咱們繼續實現 數據的劫持
顧名思義,數據劫持就是對 data
中的每個屬性值進行監測,只要數據變化了,就要作出相應的事情(這裏就是更新視圖)。話很少說,先貼代碼,在說明其中的幾個注意點
class Observer {
constructor(data) {
this.observer(data)
}
observer(data) {
//要對這個data數據原有的屬性改爲set和get的形式
if (!data || typeof data !== 'object') { //若是數據不存在或者不是對象
return;
}
//要將數據一一劫持,先獲取到data的key和value
Object.keys(data).forEach(key => { //該方法是將對象先轉換成數組,再循環
//劫持(定義一個函數,數據響應式)
this.defineReactive(data, key, data[key]);
//深度遞歸劫持,這裏的遞歸只會爲初始的data中的數據進行劫持(添加set和get方法),若是在defineReactive函數中使用set新增長則不會進行劫持
this.observer(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) {
console.log(this, 'this'); //這個this指向的是被修改的值
//可是這裏的this不是Observer的實例,因此須要在最初保存一下當前this指向
that.observer(newValue); //若是是對象繼續劫持
value = newValue;
}
}
})
}
/**
* 以上就實現了數據劫持
*/
}
複製代碼
數據劫持部分比較簡單,主要使用了 Object.defineProperty()
,下面列出一個須要注意的地方:
get
和 set
以前,須要對data進行判斷,排除不是對象和數據不存在的狀況set
方法時,對數據也進行劫持(由於此時的this指向的是被修改的值,因此須要在方法最初保存一下當前的this值)核心的模板編譯和數據劫持已經完成,兩個部分也均可以實現本身的職能,可是如何將二者關聯起來,達到最終雙向綁定的效果呢?
下面就是結合二者的 Watcher
的主場了!!!
建立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) => { //vm.$data.a....
return prev[next];
}, vm.$data)
}
get() {
Dep.target = this; //將當前watcher實例放入到tartget中
let value = this.getVal(this.vm, this.expr);
Dep.target = null;
return value;
}
//對外暴露的方法
update() {
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if (newValue !== oldValue) {
this.cb(newValue); //對應watch的callback
}
}
}
複製代碼
在這裏還要插入一個知識,發佈-訂閱模式:
//observer.js
/**
* 發佈訂閱
*/
class Dep {
constructor() {
//訂閱的數組
this.subs = [];
}
//添加訂閱者
addSub(watcher) {
this.subs.push(watcher);
}
//通知
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
複製代碼
發佈訂閱在這裏的做用:由於 Watcher
是來觀察數據變化的,即訂閱者。由於一個數據可能在模板的多處使用,因此一個數據會有多個監測者。便可以理解爲對於一個數據有多個訂閱者,那麼當一個數據變化時,就能夠一次通知即可實現全部訂閱者都知道這個消息的結果。(也就是一個數據變化,模板中使用這個數據的值都發生了改變)
上面的一段話是我的現階段的理解,若是有誤,但願能夠提出來,共同改進和努力~~~
結合上面的兩個功能,就能夠將整個數據雙向綁定聯絡起來:
當在模板編譯中 建立 Watcher
實例時,這行代碼 Dep.target = this; //將當前watcher實例放入到tartget中
就會將監聽這個數據變化的訂閱者防盜訂閱者數組中,注意,由於Dep中沒有target這個屬性,因此在使用完以後,記得釋放該沒有必要的內存空間 Dep.target = null;
,經過這一步,咱們就先將全部訂閱者都放入到了訂閱者的數組中。
// compile.js
//這裏應該加一個監控,數據變化了,應該調用這個watch的callback
new Watcher(vm, expr, (newValue) => {
//當值變化後,會調用cb將新值傳遞過來()
updateFn && updateFn(node, this.getVal(vm, expr))
})
複製代碼
//observer.js
//定義響應式
defineReactive(obj, key, value) {
//在獲取某個值的時候,能夠在獲取或更改值的時候,作一些處理
let that = this;
console.log(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) { //當給data屬性中設置值的時候,更改獲取的屬性的值
if (newValue !== value) {
console.log(this, 'this'); //這個this指向的是被修改的值
//可是這裏的this不是Observer的實例,因此須要在最初保存一下當前this指向
that.observer(newValue); //若是是對象繼續劫持
value = newValue;
dep.notify(); //通知全部人數據更新了
}
}
})
}
複製代碼
在數據劫持的部分定義一個數組Dep.target && dep.addSub(Dep.target);
,存放須要更新的訂閱者。
在獲取值的時候,將這些訂閱者都放到上面定義的數組中,Dep.target && dep.addSub(Dep.target);
在改變值的時候,就會調用 dep.notify(); //通知全部人數據更新了
,間接調用 watcher.update()
來更新數據。
到此爲止,雙向數據綁定已經基本實現,下面還有兩點簡單的內容。
爲輸入框添加點擊事件
//爲節點添加點擊事件
node.addEventListener('input', e => {
let newValue = e.target.value;
this.setVal(vm, expr, newValue);
})
複製代碼
添加代理
當咱們訪問實例上的數據時,咱們都要經過 this.$data.message
才能訪問到,由於咱們的數據是 $data
裏面的,若是咱們想要實現 this.message
就能訪問到數據,這時候就須要使用一層代理。
proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(newValue) {
data[key] = newValue
}
})
})
}
複製代碼
最終效果
剖析Vue.js的源碼過程當中發現,Vue.js的底層使用了不少咱們平時代碼實現不怎麼用到的知識,這裏做爲一個入門簡單的羅列一下。
問題一:傳統JS類的定義
JS定義類的的傳統方法:是經過構造函數,定義並生成新對象,prototype
屬性使您有能力向對象添加屬性和方法。
案例:
//Person.js
function Person(x,y){
this.x = x;
this.y = y;
}
Person.prototype.toString = function (){
return (this.x + "的年齡是" +this.y+"歲");
}
export {Person};
//index.js
import {Person} from './Person';
let person = new Person('張三',12);
console.log(person.toString()); /張三的年齡是12歲
複製代碼
問題二:ES6中類的定義
ES6引入了Class(類)這個概念,做爲對象的模板,經過class
關鍵字,能夠定義類。 基本上,ES6的Class
能夠看做只是一個語法糖,它的絕大部分功能,ES5均可以作到,新的Class
寫法只是讓對象原型的寫法更加清晰、更像面向對象編程的語法而已。 上面的代碼用ES6的「類」改寫,就是下面這樣:
//Person.js
class Person{
// 構造
constructor(x,y){
this.x = x;
this.y = y;
}
toString(){
return (this.x + "的年齡是" +this.y+"歲");
}
}
export {Person};
//index.js
import {Person} from './Person';
let person = new Person('張三',12);
console.log(person.toString()); /張三的年齡是12歲
複製代碼
面代碼定義了一個「Class類」,能夠看到裏面有一個constructor
方法,這就是構造方法,而this
關鍵字則表明實例對象。 也就是說,ES5的構造函數Person
,對應ES6的Person
類的構造方法。 Person
類除了構造方法,還定義了一個toString
方法。
注意,定義「類」的方法的時候,前面不須要加上function
這個關鍵字,直接把函數定義放進去了就能夠了。 另外,方法之間不須要逗號分隔,加了會報錯。
一個類必須有constructor方法,若是沒有顯式定義,一個默認的constructor方法會被添加。因此即便你沒有添加構造函數,也是有默認的構造函數的。
在瀏覽器中,咱們一般用innerHTML()
或者appendChild()
向頁面中插入DOM節點,例如:
for(var i=0;i<5;i++){
var op = document.createElement("span");
var oText = document.createTextNode(i);
op.appendChild(oText);
document.body.appendChild(op);
}
複製代碼
可是,若是當咱們要向document
中添加大量數據時(好比1w條),若是像上面的代碼同樣,逐條添加節點,這個過程就可能會十分緩慢。 固然,你也能夠建個新的節點,好比說div
,先將oP
添加到div
上,而後再將div
添加到body
中,但這樣要在body
中多添加一個<div></div>
.但文檔碎片不會產生這種節點。
var oDiv = document.createElement("div");
for(var i=0;i<10000;i++){
var op = document.createElement("span");
var oText = document.createTextNode(i);
op.appendChild(oText);
oDiv.appendChild(op);
}
document.body.appendChild(oDiv);
複製代碼
爲了解決這個問題,JS引入了createDocumentFragment()
方法,它的做用是建立一個文檔碎片,把要插入的新節點先附加在它上面,而後再一次性添加到document
中。 代碼以下:
//先建立文檔碎片
var oFragmeng = document.createDocumentFragment();
for(var i=0;i<10000;i++){
var op = document.createElement("span");
var oText = document.createTextNode(i);
op.appendChild(oText);
//先附加在文檔碎片中
oFragmeng.appendChild(op);
}
//最後一次性添加到document中
document.body.appendChild(oFragmeng);
複製代碼
按照必定的模式從數組或者對象中取值,對變量進行賦值的過程稱爲解構。
上面的代碼表示,能夠從數組中取值,按照位置的對應關係對變量賦值。一、ES6中的 Array.from()
方法 Array.from
方法
用於將兩類對象轉爲真正的數組: 相似數組的對象
(array-like object)
和 可遍歷(iterable)
的對象(包括 ES6 新增的數據結構Set
和Map
)。
下面是一個相似數組的對象,Array.from
將它轉爲真正的數組:
let arrayLike = {
'0': 'a',
'1': 'b',
'2': 'c',
length: 3
};
複製代碼
// ES5的寫法
var arr1 = [].slice.call(arrayLike); // ['a', 'b', 'c']
複製代碼
// ES6的寫法
let arr2 = Array.from(arrayLike); // ['a', 'b', 'c']
複製代碼
實際應用中,常見的相似數組的對象是 DOM 操做返回的 NodeList 集合,以及函數內部的
arguments
對象。Array.from
均可以將它們轉爲真正的數組。
/ NodeList對象
let ps = document.querySelectorAll('p');
Array.from(ps).filter(p => {
return p.textContent.length > 100;
});
// arguments對象
function foo() {
var args = Array.from(arguments);
// ...
}
複製代碼
上面代碼中,querySelectorAll
方法返回的是一個相似數組的對象,能夠將這個對象轉爲真正的數組,再使用filter
方法。
更多參考
二、ES5中的 Array.reduce()
方法
reduce()
方法接收一個函數做爲累加器(accumulator),數組中的每一個值(從左到右)開始合併,最終爲一個值。
參數 | 描述 |
---|---|
callback | 執行數組中每一個值的函數,包含四個參數 |
previousValue | 上一次調用回調返回的值,或者是提供的初始值(initialValue) |
currentValue | 數組中當前被處理的元素 |
index | 當前元素在數組中的索引 |
array | 調用 reduce 的數組 |
initialValue | 做爲第一次調用 callback 的第一個參數。 |
詳細描述
reduce
爲數組中的每個元素依次執行回調函數,不包括數組中被刪除或從未被賦值的元素,接受四個參數:初始值(或者上一次回調函數的返回值),當前元素值,當前索引,調用 reduce 的數組。
回調函數第一次執行時,previousValue
和currentValue
能夠是一個值,若是 initialValue
在調用 reduce
時被提供,那麼第一個 previousValue
等於initialValue
,而且currentValue
等於數組中的第一個值;若是initialValue
未被提供,那麼previousValue
等於數組中的第一個值,currentValue
等於數組中的第二個值。
若是數組爲空而且沒有提供initialValue
, 會拋出TypeError
。若是數組僅有一個元素(不管位置如何)而且沒有提供initialValue
, 或者有提供initialValue
可是數組爲空,那麼此惟一值將被返回而且callback
不會被執行。
使用示例:
var total = [0, 1, 2, 3].reduce(function(a, b) {
return a + b;
});
console.log(total);//6
var total = [0, 1, 2, 3].reduce(function(a, b) {
return a + b;
},10);
console.log(total);//16
複製代碼
問題一:Obj.keys()的使用
Object.keys()方法返回一個由一個給定對象的自身可枚舉屬性組成的數組。
使用示例:
var person = {
firstName: "aaaaaa",
lastName: "bbbbbb",
others: "ccccc"
};
Object.keys(person).forEach(function(data) {
console.log('person', data, ':', person[data]);
});
//console.log:
//person firstName : aaaaaa
//person lastName : bbbbbb
//person others : ccccc
複製代碼
問題二:
Obj.defineProperty()
的使用
Object.defineProperty()
方法會直接在一個對象上定義一個新屬性,或者修改一個對象的現有屬性, 並返回這個對象。
語法:
Object.defineProperty(obj, prop, descriptor)
複製代碼
參數說明:
obj:必需。目標對象
prop:必需。需定義或修改的屬性的名字
descriptor:必需。目標屬性所擁有的特性
複製代碼
返回值:
傳入函數的對象。即第一個參數obj
複製代碼
給對象的屬性添加特性描述,目前提供兩種形式:數據描述和存取器描述。
1、數據描述
當修改或定義對象的某個屬性的時候,給這個屬性添加一些特性:
var obj = {
test:"hello"
}
//對象已有的屬性添加特性描述
Object.defineProperty(obj,"test",{
configurable:true | false,
enumerable:true | false,
value:任意類型的值,
writable:true | false
});
//對象新添加的屬性的特性描述
Object.defineProperty(obj,"newKey",{
configurable:true | false,
enumerable:true | false,
value:任意類型的值,
writable:true | false
});
複製代碼
設置的特性總結:
value: 設置屬性的值
writable: 值是否能夠重寫。true | false
enumerable: 目標屬性是否能夠被枚舉。true | false
configurable: 目標屬性是否能夠被刪除或是否能夠再次修改特性 true | false
複製代碼
注意:
除了能夠給新定義的屬性設置特性,也能夠給已有的屬性設置特性
一旦使用Object.defineProperty給對象添加屬性,那麼若是不設置屬性的特性,那麼configurable、enumerable、writable這些值都爲默認的false。
2、存取器描述
當使用存取器描述屬性的特性的時候,容許設置如下特性屬性:
var obj = {};
Object.defineProperty(obj,"newKey",{
get:function (){} | undefined,
set:function (value){} | undefined
configurable: true | false
enumerable: true | false
});
複製代碼
注意:當使用了getter或setter方法,不容許使用writable和value這兩個屬性
getter/setter 當設置或獲取對象的某個屬性的值的時候,能夠提供getter/setter方法。
getter 是一種得到屬性值的方法
setter是一種設置屬性值的方法。 在特性中使用get/set屬性來定義對應的方法。
var obj = {};
var initValue = 'hello';
Object.defineProperty(obj,"newKey",{
get:function (){
//當獲取值的時候觸發的函數
return initValue;
},
set:function (value){
//當設置值的時候觸發的函數,設置的新值經過參數value拿到
initValue = value;
}
});
//獲取值
console.log( obj.newKey ); //hello
//設置值
obj.newKey = 'change value';
console.log( obj.newKey ); //change value
複製代碼
注意:get或set不是必須成對出現,任寫其一就能夠。若是不設置方法,則get和set的默認值爲undefined。configurable和enumerable同上面的用法。
問題一:發佈-訂閱模式,又稱爲 觀察者模式
觀察者模式概念解讀
觀察者模式又叫發佈訂閱模式(Publish/Subscribe),它定義了一種一對多的關係,讓多個觀察者對象同時監聽某一個主題對象,這個主題對象的狀態發生變化時就會通知全部的觀察者對象,使得它們可以自動更新本身。
觀察者模式做用和注意事項
模式做用:
一、支持簡單的廣播通訊,自動通知全部已經訂閱過的對象。
二、頁面載入後目標對象很容易與觀察者存在一種動態關聯,增長了靈活性
三、目標對象與觀察者之間的抽象耦合關係可以單獨擴展以及重用。
注意事項:
監聽要在觸發以前。
到此爲止,雙向數據綁定的原理以及實現的思路已經基本完成,同時也講解了功能實現中須要的基本知識,但願讀者能夠從中收穫知識,也歡迎提出不一樣的意見,虛心採納~~~