本文不會拉出Vue的源碼出來剖析一番,也不會掛一大段代碼去籠統地講,儘可能會從邏輯角度一步步來梳理html
若是你跟以前的我同樣,據說過MVVM,也對Virtual Dom有所耳聞,可是說不出個大概
那麼但願這篇文章能對你有所幫助前端
都說前端框架運用了MVVM的思想,那麼MVVM是什麼vue
M:Model(數據)node
V:View(視圖)git
VM:ViewModelgithub
其中VM就是解放生產力的核心
它讓咱們再也不須要去手動操做Dom更新視圖,一切都是自動完成的,咱們只需專一於數據邏輯以及頁面呈現web
既然要自動更新,那麼這個中間人VM至少作了三件事正則表達式
1 監聽到了數據變化算法
2 通知視圖編程
3 視圖執行更新
Vue目前用到的是Object.defineProperty()這個方法
未來Vue3.0會換成Proxy,不過是有點相似的,因此不用擔憂到時候須要從新學,理解了思想以後一切都很快
Object.defineProperty()接收三個參數
一、實例
二、屬性
三、屬性描述符
好比說咱們如今想要監聽data對象身上的a屬性
data:{a:val,b:2, c:3}
var val=1
Object.defineProperty(data, key, {
writable: true, // 可枚舉
enumerable: true, // 可枚舉
configurable: false, // 不能再define
get: function () {
dep.bind()
return val;
},
set: function (newVal) {
if (newVal === val) return;
console.log('監聽到值變化了: ', val, '==>', newVal);
val = newVal;
dep.notify()
}
});
複製代碼
咱們把原來單純的一個值,拆分紅一個getter和一個setter
這樣不管值被獲取仍是重寫,咱們都能知道
上面只是console了一下,咱們實際須要去通知視圖
因而咱們給每個屬性都設一個傳聲筒dep,由他來負責通知
同時,咱們也得讓它知道到底去通知誰
新建一個類Dep,在每一次調用Object.defineProperty()的時候,順便new一個dep實例出來
它身上設定兩個方法,bind()和notify()
視圖第一次渲染會調用屬性的get去取值,咱們就能夠用bind()讓dep綁定要通知的對象
而修改數據的時候,就觸發dep.notity()去通知
咱們先無論這兩個方法具體是怎麼實現的
定義一個defineProperty()方法,把骨架搭出來
defineValue(data, key, val) {
var dep = new Dep() //給這個屬性創建一個傳聲筒
Object.defineProperty(data, key, {
..............
get: function () {
dep.bind()
return val;
},
set: function (newVal) {
if (newVal === val) return;
val = newVal;
dep.notify()
}
});
}
複製代碼
如今把目光轉移到視圖這一邊,假設如今html長這樣
<div>
<p>{{ a }}</p>
<p>{{ b }}</p>
<p>{{ c }}</p>
</div>
<div>
<p>{{ c }}</p>
</div>
複製代碼
這串html在關心a,b,c三個數據
但不一樣p標籤關心的數據不同
若是數據c變了,咱們確定但願c的傳聲筒只去通知後面兩個p標籤,別的p標籤不用知道
因此在視圖這邊,也須要給每個引用到數據的地方,設立一個經紀人watcher,區分開來,這樣dep就知道去通知誰了
如今咱們假設每一個watcher身上都有一個update()方法
因此dep的notify方法,就是調用全部與dep相關的watcher身上的update()
話講到這裏,忽然就在原來的v和m的基礎上,多出來兩個角色dep和watcher,感受愈來愈亂了
等等,dep和watcher,那這兩我的就是VM的真身嗎
沒錯,他們一塊兒組成了VM核心
而這個模式就是大名鼎鼎的「發佈-訂閱模式」
-- 想一想Vue是怎麼建立實例的
var app=new Vue({
el:'#app',
data:{
a:1,
b:2,
c:3,
d:{
e:4
}
}
})
複製代碼
咱們模仿Vue,建立一個Mvvm類,獲取用戶傳進來的參數,並把data賦給自身的$data屬性
class Mvvm {
constructor(options = {}) {
//將全部屬性掛載到$options
this.$options = options;
// 將data數據取出來賦給$data
this.$data = this.$options.data;
// 數據劫持
this.observe(this.$data);
//數據代理
this.proxyData(this.$data)
//編譯頁面
this.$compiler = new Compiler(this, this.$options.el || document.body)
}
observe(){}
defineProperty()
proxyData(){}
}
複製代碼
在Mvvm實例的初始化中,賦值完以後,依次作了三件事
數據劫持,數據代理,編譯頁面
數據劫持就是咱們上面定義過的defineProperty,不過這裏須要補充一個嵌套劫持,讓對象屬性中的屬性也能被監聽
重複部分就省略了,重點關注實現嵌套的代碼
observe(data) {
if (!data || typeof data !== 'object') return;
Object.keys(data).forEach(key => {
this.defineValue(data, key, data[key]);
});
}
defineValue(data, key, val) {
...............
_this.observe(val); // 監聽子屬性
Object.defineProperty(data, key, {
................
set: function (newVal) {
....................
_this.observe(val) //對新值進行監聽,由於它多是個對象
dep.notify()
}
});
複製代碼
在vue中,咱們獲取一個數據不是經過vm.data.a這樣的形式的,是直接vm.a進行讀寫,因此咱們還要進行一下數據代理
至關於把$data給鏡像過來,暴露給用戶
proxyData(data) {
//由於只是爲了省略$data,因此只須要遍歷第一層,不用深度遍歷
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
configurable: false,
enumerable: true,
get: function () {
return this.$data[key]
},
set: function (newVal) {
this.$data[key] = newVal
}
}
)
})
}
複製代碼
給咱們一段html,咱們須要分析出裏面哪些地方引用了數據,哪些地方用到了指令,進行第一次的數據更新渲染
同時還有就是給用到數據的地方分配watcher
實際操做Dom是很慢的,因此咱們這裏用到了fragment(文檔碎片),把Dom都拷到這裏面進行操做
固然這個實際上是vue1.x時候使用的,不過對咱們理解mvvm很是有幫助
定義一個轉移Dom到fragment的方法
node2fragemt(el) {
var fragment = document.createDocumentFragment()
var child
while (child = el.firstChild) {
fragment.appendChild(child)
}
return fragment
}
複製代碼
由於節點不能有兩個父親,因此調用appendChild的時候,就至關於把Dom節點搶了過來
操做完了之後,再把fragment丟給真實Dom便可
定義一些判斷節點的方法
//是不是節點
isElement(node) {
return node.nodeType == 1;
}
//是不是指令
isDirective(node) {
return node.substring(0, 2) === 'v-';
}
//是不是事件指令
isEventDirevtive(dir) {
return dir.indexOf('on') === 0;
}
//是不是文本節點
isTextElement(node) {
return node.nodeType == 3
}
複製代碼
而後定義Compiler類
class Compiler {
constructor(vm, el) {
this.$vm = vm
this.$el = this.isElement(el) ? el : document.querySelector(el)
if (this.$el) {
this.$fragment = this.node2fragemt(this.$el)
this.compile(this.$fragment)
this.$el.appendChild(this.$fragment)
}
}
複製代碼
compiler類的核心是compile函數
分類去解析節點
compile(el) {
var nodes = el.childNodes
Array.from(nodes).forEach(node => {
if (this.isElement(node)) {
//普通節點
....................................
}
else if (this.isTextElement(node)) {
//文本節點
...................................
}
//先進行上面的解析,若是發現node還有子節點,就遞歸地進行子節點的解析
if (node.childNodes && node.childNodes.length)
this.compile(node)
})
}
複製代碼
遞歸解析每一個節點,分爲兩類處理,一個是普通節點,一個是文本節點
普通節點上可能有指令,文本節點上可能有{{}}
經過attribute屬性取到指令,先判斷一下格式是否正確,是不是v-開頭的
而後這裏面又分爲事件指令(on:click之類)或者普通指令(v-text,v-model,v-html)
本文先只介紹普通指令
var attrs = node.attributes
Array.from(attrs).forEach(attr => {
if (!this.isDirective(attr.name)) return; //若是不是以v-開頭的指令,直接返回不處理
var exp = attr.value.trim() //是string類型,因此還要去除一下兩邊的空格
var dir = attr.name //例如v-text
if (this.isEventDirevtive(dir)) {
//若是是事件處理函數
} else {
//普通指令
updateFn[dir] && updateFn[dir](node, this.getVal(exp), this.$vm, exp)
new Watcher(this.$vm, exp, (value) => {
updateFn[dir] && updateFn[dir](node, value, this.$vm, exp);
});
}
})
複製代碼
能夠看到在普通指令中,用dir去取到了一個方法進行執行,同時新建了一個Watcher,它的回調函數也是這個方法
Watcher先放一放,咱們看看在執行什麼方法
//指令函數
var updateFn = {
"v-text": function (node, val) {
node.textContent = val === undefined ? '' : val
},
"v-html": function (node, val) {
node.innerHTML = val === undefined ? '' : val
},
"v-model": function (node, val, vm, exps) {
node.value = val === undefined ? '' : val
node.addEventListener('input', e => {
exp = exps.split('.')
var len = exp.length
if (len == 1) {
return vm[exp] = e.target.value
}
var data = vm
for (let i = 0; i < len - 1; i++) {
data = data[exp[i]]
console.log(exp[i])
}
data[exp[len - 1]] = e.target.value
})
}
}
複製代碼
前兩個很好理解,就是純粹去用取到的數據值更新節點的值
而v-model,也就是咱們大名鼎鼎的雙向綁定,其實很簡單
它就只是比上面兩個多一個監聽事件,去更新實例上的值罷了
處理完普通節點,來處理咱們的文本節點
其實就是處理一個模板語法{{}}
咱們想要把其中的變量取出來,因此就要用到正則表達式的捕獲組,用括號去捕捉
而後用RegExp.$1去取到捕獲的值
不過若是處理相似{{a}},{{b}}這種的話,捕獲的時候,後一個會覆蓋前一個,RegExp.$1就只能取到b
因此咱們先用match把他們拆分出來,再分別捕獲
var exps = node.textContent.match(/\{\{.*?\}\}/g) //先進行拆分
if (!exps) return;
Array.from(exps).forEach(item => {
item.match(/\{\{(.*?)\}\}/g)
this.compileText(node, item, RegExp.$1.trim()) //經過正則的括號進行捕獲,trim()用來去除空格
})
複製代碼
compileText其實就是把{{}}替換成值
compileText(node, exp, content) {
var val = this.getVal(content)
if (val === undefined) val = "";
var text = node.textContent //保留一份原來的格式以供更新
node.textContent = node.textContent.replace(exp, val)
new Watcher(this.$vm, content, (value) => {
if (value === undefined) value = "";
node.textContent = text.replace(exp, value)
});
}
複製代碼
這裏也建立了一個watcher 因此在編譯的時候,每一個用到數據的地方,都建立了一個wacther
再來回顧一下這張圖
如今還剩下的就是,watcher和dep的互相關聯以及watcher執行update()更新視圖了首先解開Dep的面紗
還記得咱們以前賣了個關子,沒有說dep.bind()是怎麼實現的
下面咱們就來看看dep.bind()在幹嗎
class Dep {
constructor() {
this.subs = new Set(); //爲了保證不重複添加
}
bind() {
//註冊當前活躍的用戶爲訂閱者,並讓對方添加本身
this.subs.add(Dep.target)
Dep.target.addDep(this)
}
}
複製代碼
每一個dep實例都有一個subs,保存本身要通知的那些watcher,爲了避免重複,使用了Set結構、 在第一次渲染視圖的時候,有向實例拿過數據,就在那時已經觸發了實例屬性的get方法,進而觸發了bind函數了
那這個Dep.target又是哪來的?
答案就是在wathcer實例初始化的時候
Wacher類
class Watcher {
constructor(vm, exp, cb) {
this.$vm = vm
this.$cb = cb
this.$deps = new Set()
if (typeof exp === 'function') {
this.getter = exp
}
else {
this.getter = this.createGetter(exp)
}
this.$value = this.runGetter()
}
addDep(dep) {
this.$deps.add(dep)
}
runGetter() {
if (!this.getter) return;
Dep.target = this
var value = this.getter.call(this.$vm, this.$vm);
Dep.target = null
return value;
}
createGetter(exp) {
var exps = exp.split('.')
return function (vm) {
var val = vm
exps.forEach(key => {
val = val[key]
});
return val;
}
}
複製代碼
}
在watcher初始化的時候,根據exp(也就是咱們以前編譯時辛辛苦苦取到的變量)的類型建立了一個getter方法
若是exp是函數,getter就是直接運行它,不然就是去實例身上取值,爲了能經過相似「a.b.c」這樣的字符串取到值,咱們用了代碼中那個層層遍歷遞進的方法
有了getter以後,咱們就在初始化的時候執行一下,把值保存下來,以便未來作比對
同時制定Dep.target爲本身
因此整個運行的順序是
最後一步,當數據更新的時候,要讓dep去通知watcher執行update
還記得屬性set()裏面的dep.notify()方法麼
class Dep{
...............
notify() {
//通知全部訂閱者執行更新函數
this.subs.forEach(item => {
item.update()
})
}
}
複製代碼
很簡單,就是通知全部綁定的watcher去執行update
那麼watcher的update()長啥樣呢
class Wacther{
................
update() {
var oldVal = this.$value
var newVal = this.runGetter()
if (newVal === oldVal) return;
this.$value = newVal
this.$cb.call(this.$vm, newVal, oldVal)
}
}
複製代碼
就是調用getter去獲取數據最新的值,而後調用以前保存的回調函數更新視圖
這裏你們可能有個疑問,爲何dep的notify不帶上新的值做爲參數告訴watcher,而讓watcher再本身取一次?
由於其實dep不知道手下的每一個watcher究竟在觀察什麼,好比dep管理着a,而a={b:'dsd',c:'dsads'},這個watcher可能只是在關心a.b變化了沒,另外一個watcher在關心a.c,可是dep並不知道
因此Dep不作傳值,只是在數據變化的時候通知全部相關訂閱者,本身去看看數據變成啥樣了
至此,咱們的Mvvm就告一段落了,其實還有很多功能,好比computed,watch,事件指令
不過搞懂核心的東西,就足夠了,之後再能夠繼續完善不過,如今這些東西,都只是Vue.1x
除了咱們以前提到的,Vue3.0會把Object.defineProperty()換成proxy之外
渲染和更新如今用的也不是fragment了,而是下面這一位
大名鼎鼎的Virtual Dom 並不是React發明,但由React發揚光大
曾經的Vue用的是咱們上文介紹的這種依賴收集,而後局部更新的方法,但從Vue2.x開始,使用的也是Virtual Dom了
如何把數據的更新投射到視圖上,三大框架曾經各抒己見
Angular使用的是髒檢查,當咱們觸發了某些事件(定時,異步請求,事件觸發等),執行完事件以後,Angular會遍歷全部「註冊」過的值,判斷是否和以前的一致
因此它的更新複雜度穩定在O(watcher count) + 必要的DOM更新 O(DOM change)
Vue曾經使用的是咱們本文講解的依賴收集,每一次更新數據會針對新數據從新收集一次依賴
因此複雜度爲 O(data change) + 必要 DOM 更新 O(DOM change)
React用的的Virtual Dom,它的本質其實相似離線Dom,每次操做以後,與原來的Virtual\ Dom進行diff比對,把patch投射到真實Dom上,在diff比對上,原本兩棵Dom樹的比對會達到O(n^3)的複雜度,可是React團隊用了取巧的方法,考慮到web應用中不多會出現跨層移動Dom節點,因此只進行兩棵樹的同層比對,強行把複雜度下降到了O(n)
因此最後總的複雜度是O(template size) + 必要的 DOM 更新 O(DOM change)
不少地方都會大肆宣揚Virtual Dom的快速,確實Virtual Dom很快,但是那也得看跟誰比
若是是跟很粗暴地把整棵真實Dom樹直接更新了,也就是直接設置node.innerHTML相比,確定Virtual Dom要快多了
由於操做Dom是很耗時間的,而Virtual Dom實際上是JS對象,操做JS可快多了
但是實際上沒有人會一有數據更新,就把整棵真實的Dom樹給更新了的
一來,若是你去手動優化,精準地更新Dom,那確定要比Virtual Dom快
固然框架是要追求普適性的,不過即便上面那兩個一樣具備普適性的方法,髒檢查和依賴收集相比,那也是互有勝場
借用尤大大的回答 www.zhihu.com/question/31…
既然在速度上互有勝場,那Virtual Dom必定還有更增強大的優勢,能促使Vue去使用它
仍是看尤大大本身的講解,能夠看下這段視頻
www.bilibili.com/video/av621…
Vue在改用Virtual Dom後,流程又是怎麼走的呢 借用這篇文章的圖片 github.com/wangfupeng1…
能夠看到新出來兩個概念,AST和Render
與html轉爲Dom樹的過程相似,Vue會先把模板給解析成抽象語法樹,而後優化AST,找到其中靜態和動態的部分,在優化方面Vue3.0會有更好的突破,目前還不是很完善,總之目標就是肯定哪些部分是可能會變化的,哪些是靜態的不會變的,最後生成一個render函數
若是是在開發環境下,會在運行時把模板根據上面的步驟解析爲render函數,而若是是生產環境,這一步是發生在編譯打包階段的,因此最後的文件中直接就是render函數
那這個render函數就是用來返回Virtrual Dom的
若是是初次渲染,就會把這個Virtual Dom樹直接投射生成真實Dom
若是是數據更新,就相似本文介紹的那個流程,觸發通知,執行update函數,觸發render函數的從新執行,生成一個新的Virtual Dom樹,與原來的進行diff比對,並把最小差別patch到真實Dom上進行局部從新渲染
這個渲染是異步的,也就是說一次渲染會集合多個數據的變化
在渲染的過程當中,有一個很重要的概念,就是就地複用
數據的角度來講,一個列表的數據變了,那就應該銷燬實例從新建立
可是Virtual Dom是基於Dom進行變更檢查的,若是最終的渲染結果沒有變化,就不該該有這種額外的勞動
好比若是數據項的順序被改變,Vue 將不會移動 DOM 元素來匹配數據項的順序, 而是簡單複用此處每一個元素
不過這種機制在沒有key的狀況下會形成一些問題,好比按刪除按鈕,但被刪除的元素不是你想指定的那個
因此要爲每一項肯定一個惟一的id,從Virtual Dom的角度也能更好的作恰當的優化
再來借用一次那張流程圖
若是以前的東西沒有徹底看懂,能夠再來對着這張圖整理一下總流程但願本文對於你們理清Vue的運做原理有所幫助
尤大大對於Virtual Dom的介紹
www.bilibili.com/video/av621…
Mvvm視頻教程
www.bilibili.com/video/av240…
尤大大講解Vue源碼
www.bilibili.com/video/av514…
Object.defineProperty()和proxy的區別
www.fly63.com/article/det…
數據雙向綁定系列教程
www.chuchur.com/article/vue…
收藏好這篇,別再只說「數據劫持」了
juejin.im/post/5af198…
很差意思!耽誤你的十分鐘,讓MVVM原理還給你
juejin.im/post/5abdd6…
DOM和Virtual DOM之間的區別
www.jianshu.com/p/620b0435d…
vue核心之虛擬DOM(vdom)
www.jianshu.com/p/af0b39860…
虛擬DOM與DIFF算法學習
segmentfault.com/a/119000001…
爲何虛擬DOM更優勝一籌
www.cnblogs.com/rubylouvre/…
談談Vue/React中的虛擬DOM(vDOM)與Key值
juejin.im/post/5cff1b…
尤大大關於Virtual DOM的知乎回答
www.zhihu.com/question/31…
Vue源碼學習筆記
jiongks.name/blog/vue-co…
Vue技術揭祕
ustbhuangyi.github.io/vue-analysi…
Vue源碼分析
github.com/liutao/vue2…
入口文件開始,分析Vue源碼實現
juejin.im/post/5adead…
Vue源碼學習
hcysun.me/2017/03/03/…
快速瞭解 Vue2 MVVM
github.com/wangfupeng1…
其中最推薦的是看最後這一篇,我的認爲是講的最清楚的,流程圖也是借用這位做者的
這是個人我的網站,記錄下前端學習的點滴,歡迎你們參觀
www.ssevenk.com