今天我來研究一下vue源碼,從中也有很多收穫。css
來看一個官方文檔的示例vue
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
複製代碼
Vue的起步會new一個Vue實例,由實例來生成和更新HTML,呈如今瀏覽器上。根實例上,又會有不少子組件,子組件也是繼承自Vue類的實例。因此Vue類是核心。node
看以前先熟悉一下源碼目錄。es6
src
├── compiler # 編譯相關
├── core # 核心代碼
├── platforms # 不一樣平臺的支持
├── server # 服務端渲染
├── sfc # .vue 文件解析
├── shared # 共享代碼
複製代碼
Vue類的代碼,就在core文件夾的instanc文件夾下。web
查看src/core/instance/index.js文件。算法
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
複製代碼
這裏導出的是Vue類。既然是導出一個類,卻沒有使用es6語法class,仍是使用es5的構造函數方式,這樣作的緣由是Vue類內的代碼太多了,須要做拆分,將不一樣邏輯的代碼放在不一樣文件中。如這裏把初始化邏輯拆分到init.js文件,而後initMixin(Vue)將初始化的方法掛在到Vue類上。api
當new Vue時,執行構造函數,this._init(options)被調用。這個_init方法就在initMixin(Vue)被掛在Vue類上的。也就是在實例化以前,對於Vue類的建造,已經完成。瀏覽器
我來寫個僞代碼,initMixin、stateMixin、eventsMixin等拆分出去等代碼,合併到一塊兒,用class呈現一個Vue類的樣子。bash
export default class Vue {
constructor(options){
this._init(options);
}
// init相關
// 源碼中是用Vue.prototype._init語法,將方法掛到類上
_init(){ ...初始化邏輯 }
// state相關
get $data(){ ... }
set $data(){ ... }
get $props(){ ... }
set $props(){ ... }
$set() { ... }
$delete(){ ... }
$watch(){ ... }
// event相關
$on(){ ... }
$once(){ ... }
$off(){ ... }
$emit(){ ... }
// lifecycleMixin相關
_update(){ ... }
$forceUpdate(){ ... }
$destroy(){ ... }
// render相關
$nextTick(){ ... }
_render(){ ... }
// 掛載相關
$mount() { ... }
}
複製代碼
首先看到是一種規範,下劃線開頭的方法,是私有方法。emit/$nextTick等,就是調用vue實例的方法。app
下面的代碼也都是僞代碼,是爲說明源碼的邏輯。重點是梳理源碼的思路和運行流程。
平常寫項目時,new Vue(options:最主要的是傳入模版和數據),而後vue實例就幫咱們渲染出了界面。
上面分析到new Vue中調用了_init方法。來看它的邏輯。
_init(options) {
// 1,加強vm(vue實例,也就是this)。就是往vm對象上添加屬性。
// 如vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) 給vm添加一個$createElement方法,用於建立VNode的。
// 如將咱們聲明的data/props/computed裏的字段代理到vm上。這是咱們在項目裏寫代碼時,data中定義a,在其餘地方就能夠用this.a能夠訪問到的緣由。
// 而且將字段都變成get/set形式屬性。以便隨後進行依賴收集和派發更新的操做。
...
// 2,vue實例加強完畢後,它得幹活了,生成虛擬VNode,而後更新DOM。
// 乾的活都在下面這句代碼中。$options是_init中加強時獲得的,$mount是在上面定義Vue類時定義好的。
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
複製代碼
這裏沒貼出_init全部代碼,細節能夠你們本身去看,思路上_init作了兩件事,增長vue實例和調用$mount生成DOM。
接下來看看$mount方法執行中發生的事。
$mount() {
// 主要邏輯以下
// 1 定義一個回調
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 2 利用Watcher執行回調
new Watcher(vm, updateComponent,...)
}
複製代碼
掛載中會實例化一個Watcher,Watcher的做用,一是初始化時調用updateComponent,二是數據發生變化時,執行updateComponent,也就是爲了實現響應式。
updateComponent中,_render生成VNode,虛擬節點樹。而後_update使用VNode,生成和更新DOM。
看_render中的邏輯。
_render(){
// 主要邏輯以下
var vnode = render(vm.$createElement);
return vnode;
}
// render方法就是
// <div id="app">
// {{ message }}
// </div>
// 這樣的模版通過編譯後的js語法。
render: function (createElement) {
return createElement('div', {
attrs: {
id: 'app'
},
}, this.message)
}
複製代碼
render最終會生成一個根vnode,根vnode節點裏又有不少內置/自定義組件節點,是一個虛擬節點樹。
_update 方法的做用是把 VNode 渲染成真實的 DOM。
_update(vnode){
// 主要邏輯以下
if(初始化) {
// vm.$el存放的是DOM節點的id,這樣patch就能夠將生成的dom內容添加到根dom上了。
patch(vm.$el,vnode);
} else {
// 更新
patch(prevVnode, vnode)
}
}
複製代碼
_update方法是經過調用patch方法實現生成DOM的,分爲初始化的狀況和數據變化時更新的狀況。咱們先來看初始化時的狀況。
// 平臺相關的patch,在web上或是移動端上,patch方法確定是不一樣的,由於涉及到操做DOM了,不一樣平臺的DOM api確定不同
patch(){
// 初始化主要邏輯
createElm(vnode)
}
createElm(vnode) {
// 1,生成此節點的DOM對象
vnode.elm = nodeOps.createElement(tag, vnode);
// 2. 去遍歷子節點
createChildren(vnode, vnode.children)
// 3. 將生成的DOM節點插入到界面中
insert(vnode)
}
// 這裏看到遞歸調用createElm,最終生成全部DOM
createChildren(vnode, children, insertedVnodeQueue) {
for (let i = 0; i < children.length; ++i) {
createElm(children[i])
}
}
insert(vnode){
// 將生成的DOM節點插入到界面中,使用DOM api
}
複製代碼
拋開數據更新流程和組件化相關邏輯,初始化的流程研究完成。
上面分析的是最簡單狀況下,只有一個根Vue的初始化流程,如今增長一個因素,根Vue下,還有不少子組件,也是一個個Vue。
值得注意的是,平常咱們寫的組件,是一個由template + js對象 + css的.vue文件,由vue-loader轉化成一個js對象,這個對象只是子組件的配置對象,並非繼承自Vue類的實例。下面咱們就來看一下,源碼是怎樣使用咱們寫的配置對象(.vue文件生成)的。
根組件new Vue -> _init -> $mount -> _render —> _update。
子組件的邏輯從根組件的_render開始:
_render(){
// 主要邏輯以下
var vnode = render(vm.$createElement);
return vnode;
}
render: function (createElement) {
return createElement('div', {
attrs: {
id: 'app',
children:[
createElement('BlogList', children, this.blogList)
]
},
}, this.message);
}
createElement(tag, children, data) {
let vnode;
// div,span這種
if(平臺內置標籤) {
// 建立普通的vnode
vnode = new VNode(tag, children, data);
} else if (是自定義組件) {
// 建立自定義組件的vnode
vnode = createComponent(Ctor, data, children, tag)
}
}
複製代碼
_render的職責沒有變,就是爲了得到vnode。但在createElement這一層,若是遇到組件節點,會生成一個組件的vnode,添加到最終的虛擬node樹上。
再看createComponent方法,生成的是怎樣的組件VNode:
createComponent(Ctor){
// 1,利用咱們的配置對象,建立子組件Vue類
Ctor = baseCtor.extend(Ctor) // 入參的Ctor就是咱們編寫的子組件.vue文件的內容。返回值是繼承了Vue類的子類,它不但有基類的功能,還合併了.vue文件中定義的成員。
// 2,將自組件類做爲參數傳遞給vnode
const vnode = new VNode(Ctor);
return vnode;
}
複製代碼
這裏值得注意的是,咱們的Vue子組件只是在此處組裝成了類,可是並無實例化它。實例化還要等到_update時。
從_render()拿到vnode後,接下里就看一下_update:
_update(vnode){
// 初始化時的邏輯
patch(vm.$el,vnode);
}
patch(){
// 初始化主要邏輯
createElm(vnode)
}
createElm(vnode) {
// 添加處理自定義組件邏輯
if(createComponent(vnode)){
return;
}
// 1,生成此節點的DOM對象
vnode.elm = nodeOps.createElement(tag, vnode);
// 2. 去遍歷子節點
createChildren(vnode, vnode.children)
// 3. 將生成的DOM節點插入到界面中
insert(vnode)
}
createComponent(vnode){
if(自定義組件的vnode){
// 1, 從vnode中拿到組件的類。
const Ctor = vnode.componentOptions.Ctor; // 這個在_render過程當中拼裝的
new Ctor(options); // Ctor就是Vue的子類,因此這句能夠當作是new Vue(options)
return true;
}
return false;
}
複製代碼
new Ctor(options)能夠當作是new Vue(options)。輪迴開始了,又開始_init -> _init -> $mount -> _render —> _update。子組件也會走一遍這樣的流程。
子組件走完這樣的流程,那麼它已經操做完DOM了。控制權又回到父組件。父組件繼續_update。
咱們將組件的因素添加到源碼流程分析以後,只是添加了一個輪迴,子組件也是走這麼一套流程。
研究過初始化流程以後,接下來該研究數據變動時,更新界面的邏輯了。咱們也管這叫響應式。
首先看圖,數據變化是在哪裏影響到源碼運行的:
是在 mount方法以下:
$mount() {
// 主要邏輯以下
// 1 定義一個回調
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 2 利用Watcher執行回調
new Watcher(vm, updateComponent,...)
}
複製代碼
這時Watcher起做用了,數據變化,Watcher從新調用updateComponent,而後->_render-> _update。
值得一提的時,若是是子組件的數據變化時,只會觸發子組件的Watcher,更新範圍就小不少。因此平常寫業務代碼時,若是某項數據,只跟這個子組件有關,就不要寫在父組件或根組件上,這樣就不會影響性能了。
數據更新還有兩個問題:
平常寫代碼,會有以下寫法
export default {
data() {
return {
blogList:[],
}
}
methods:{
onSearch(){
this.blogList = await api.fetchBlogList();
}
}
}
複製代碼
給blogList賦值,DOM就是響應更新。緣由是Vue幫助咱們將blogList轉換成了響應式對象。這發生在_init中,加強Vue實例階段,調用$mount以前。
_init(options) {
// 1,加強vm
...
initState(vm)
...
// 2,調用$mount
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
initState(vm) {
// 將data內的字段都變成響應式對象
const keys = Object.keys(vm.data)
for (let i = 0; i < keys.length; i++) {
defineReactive(vm, keys[i])
}
}
defineReactive(obj,key){
Object.defineProperty(obj, key, {
get:function(){ ... },
set:function(newVal){ ... }
})
}
複製代碼
將data變成響應式對象以後。在_render()時,要讀取data生成vnode樹,因此 會觸發data的get函數:
const dep = new Dep()
get:function(){
dep.depend();
return value;
}
set:function(newVal){
val = newVal
dep.notify();
}
複製代碼
這裏有一個dep對象。在get的時候,作了一個依賴收集(dep.depend())的操做,而後在set的時候,也就是咱們調用this.blogList = 數據; 時,作了一次派發更新。
我來畫一下 get/set和dep,watcher對象的關係:
在數據初次render被get的時候,dep記錄一下這個數據,當數據被set的時候,就觸發dep關聯的watcher,watcher觸發_render -> _update,開始了界面的更新。
對於_render()方法,初始化和更新沒有區別,都是要生成新的vnode。不一樣的地方在於_update()。
_update(vnode){
// 主要邏輯以下
// 在vm上拿到上次的vnode樹
const prevVnode = vm._vnode;
vm._vnode = vnode;
if(初始化) {
patch(vm.$el,vnode);
} else {
// 更新,對比先後兩次的vnode樹
patch(prevVnode, vnode)
}
}
複製代碼
對比先後兩次的vnode樹,就是常說的diff流程。咱們來看看
patch(prevVnode, vnode) {
// 1. 若是新舊vnode不一樣 key、tag、data都不一樣
// 建立新節點,在節點的父節點下添加新節點,刪除舊節點
createElm(); // 建立新節點,不是指建立vnode,而是用新vnode建立DOM
vnode.parent.insert() // 將建立的DOM,添加到父節點下
destroyElm(); // 刪除舊的DOM
// 2, 若是新舊vnode相同,相同是指 key、tag、data相同,但不表明它們的子節點,也都相同
prepatch(oldVnode,vnode); // 更新vnode對應的vue實例的一些屬性
const oldCh = oldVnode.children // 舊vnode的子節點
const ch = vnode.children // 新vnode的子節點
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) // oldCh 與 ch 都存在且不相同時,使用 updateChildren 函數來更新子節點
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 若是隻有 ch 存在,表示舊節點不須要了。若是舊的節點是文本節點則先將節點的文本清除,而後經過 addVnodes 將 ch 批量插入到新節點 elm 下。
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 若是隻有 oldCh 存在,表示更新的是空節點,則須要將舊的節點經過 removeVnodes 所有清除。
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '') // 當只有舊節點是文本節點的時候,則清除其節點文本內容。
}
}
複製代碼
這段邏輯開始複雜了,總結來講就是新舊節點相同的更新流程是去獲取它們的 children,根據不一樣狀況作不一樣的更新邏輯。
其中最複雜的是updateChildren,就是是相同vnode節點,還都有一堆子節點。這地方網上有不少分析,還多爲圖解。這裏列兩個我搜到的掘金上的文章,我就不重複敘述了:
圖文詳解 vue diff 核心方法 updateChildren
到此,經過源碼大體梳理了Vue的運行流程。最後貼一張分析的總體流程圖 :