用了Vue好久了,最近決定系統性的看看Vue的源碼,相信看源碼的同窗不在少數,可是看的時候卻發現挺有難度,Vue雖然足夠精簡,可是怎麼說如今也有10k行的代碼量了,深刻進去逐行查看的時候感受內容龐雜而且搞不懂代碼的目的,同時網上的深刻去仔細闡述Vue的compile/link/ expression parse/依賴訂閱和收集/batcher的文章卻很少,我本身讀源碼時,深感在這些環節可供參考的資料稀缺。網上較多的文章都在講getter/setter、Mutation Observer和LRU緩存。因此我趁着寒假詳細的閱讀了Vue構建整個響應式過程的代碼,基本包括數據observe到模板解析、transclude、compile、link、指令的bind、update、dom批處理更新、數組diff等等環節,並用這篇文章詳細的介紹出來,但願能幫到想學習Vue源碼或者想參與Vue維護、提交pr的同窗。javascript
Vue源碼詳解系列文章和配套的我整理的Vue源碼註釋版已經在git上開項:Vue源碼註釋版及詳解,歡迎你們在git上查看,並配合註釋版源碼使用。訂閱文章更新請watch。
註釋版源碼主要註釋了本文中涉及的部分,依然有不少沒有涉及,我我的精力有限,歡迎你們提pr,若是您喜歡,多謝您的star~html
本文介紹的源碼版本是當前(17年2月23日)1.x版本的最新版v1.0.26,2.x版本的源碼我先學學虛擬dom以後再進行。vue
Vue源碼構造實例的過程就一行this._init(options)
,用你的參數對象去執行init初始化函數。init函數中先進行了大量的參數初始化操做this.xxx = blabla
,而後剩下這麼幾行代碼(後文全部的英文註釋是尤雨溪所寫,中文是我添加的,英文註釋極其精確、簡潔,請勿忽略)java
this._data = {} // call init hook this._callHook('init') // initialize data observation and scope inheritance. this._initState() // setup event system and option events. this._initEvents() // call created hook this._callHook('created') // if `el` option is passed, start compilation. if (options.el) { this.$mount(options.el) }
基本就是觸發init鉤子,初始化一些狀態,初始化event,而後觸發created鉤子,最後掛載到具體的元素上面去。_initState()
方法中包含了數據的初始化操做,也就是讓數據變成響應式的,讓Vue可以監聽到數據的變更。而this.$mount()
方法則承載了絕大部分的代碼量,負責模板的嵌入、編譯、link、指令和watcher的生成、批處理的執行等等。node
嗯,是的,雖然這個observe數據的部分已經被不少文章說爛了,可是我並不僅是講getter/setter,這裏應該會有你沒看過的部分,好比Vue是如何解決"getter/setter沒法監聽屬性的添加和刪除"的。react
熟悉Vue的同窗都瞭解Vue的響應式特性,對於data對象的幾乎任何更改咱們都可以監聽到。這是MVVM的基礎,基本思路就是遍歷每個屬性,而後使用Object.defineProperty將這個屬性設置爲響應式的(即我能監聽到他的改動)。git
先說遍歷,很簡單,以下10行左右代碼就足夠遍歷一個對象了:github
function touch (obj) { if (typeof obj === 'object') if (Array.isArray(obj)) { for (let i = 0,l = obj.length; i < l; i++) { touch(obj[i]) } } else { let keys = Object.keys(obj) for (let key of keys) touch(obj[key]) } console.log(obj) }
遇到普通數據屬性,直接處理,遇到對象,遍歷屬性以後遞歸進去處理屬性,遇到數組,遞歸進去處理數組元素(console.log
)。正則表達式
遍歷完就處處理了,也就是Object.defineProperty部分了,對於一個對象,咱們能夠用這個來改寫它屬性的getter/setter,這樣,當你改屬性的值我就有辦法監聽到。可是對於數組就有問題了。算法
你也許想到能夠遍歷當前存在的下標,而後執行Object.defineProperty。這種處理方法先不說性能問題,不少時候咱們操做數組是採用push、pop、splice、unshift等方法來操做的,光是push你就沒辦法監聽,更不要說pop後你設置的getter/setter就直接沒了。
因此,Vue的方法是,改寫數組的push、pop等8個方法,讓他們在執行以後通知我數組更新了(這種方法帶來的後果就是你不能直接修改數組的長度或者經過下標去修改數組。參見官網)。這樣改進以後我就不須要對數組元素進行響應式處理,只是遇到數組的時候把數組的方法變異便可。因而在用戶使用數組的push、pop等方法會改變數組自己的方法時,能夠監聽到數組變更。
此外,當數組內部元素是對象時,設置getter/setter是能夠監聽對象的,因此對於數組元素仍是要遍歷一下的。若是不是對象,好比a[0]是字符串、數字?那就沒辦法了,可是vue爲數組提供了$set和$remove,方便咱們能夠經過下標去響應式的改動數組元素,這裏後文再說。
咱們先說說怎麼「變異」數組的push等方法,而且找出數組元素中的對象,讓對象響應式。咱們結合個人註釋版源碼來看一下。
Vue.prototype._initData = function () { // 初始化數據,其實一方面把data的內容代理到vm實例上, // 另外一方面改造data,變成reactive的 // 即get時觸發依賴收集(將訂閱者加入Dep實例的subs數組中),set時notify訂閱者 var dataFn = this.$options.data var data = this._data = dataFn ? dataFn() : {} var props = this._props // proxy data on instance var keys = Object.keys(data) var i, key i = keys.length while (i--) { key = keys[i] // 將data屬性的內容代理到vm上面去,使得vm訪問指定屬性便可拿到_data內的同名屬性 // 實現vm.prop === vm._data.prop, // 這樣當前vm的後代實例就能直接經過原型鏈查找到父代的屬性 // 好比v-for指令會爲數組的每個元素建立一個scope,這個scope就繼承自vm或上級數組元素的scope, // 這樣就能夠在v-for的做用域中訪問父級的數據 this._proxy(key) } // observe data //重點來了 observe(data, this) }
<p class="tip">(註釋裏的依賴收集、Dep什麼的你們看不懂不要緊,請跳過,後面會細說)
</p>
代碼中間作了_proxy操做,註釋裏我已經寫明緣由。_proxy操做也很簡單想了解的話你們本身查看源碼便可。
代理完了以後就開始observe這個data:
export function observe (value, vm) { if (!value || typeof value !== 'object') { // 保證只有對象會進入到這個函數 return } var ob if ( //若是這個數據身上已經有ob實例了,那observe過了,就直接返回那個ob實例 hasOwn(value, '__ob__') && value.__ob__ instanceof Observer ) { ob = value.__ob__ } else if ( shouldConvert && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ) { // 是對象(包括數組)的話就深刻進去遍歷屬性,observe每一個屬性 ob = new Observer(value) } if (ob && vm) { // 把vm加入到ob的vms數組當中,由於有的時候咱們會對數據手動執行$set/$delete操做, // 那麼就要提示vm實例這個行爲的發生(讓vm代理這個新$set的數據,和更新界面) ob.addVm(vm) } return ob }
代碼的執行過程通常都是進入到那個else if
裏,執行new Observer(value),至於shouldConvert和後續的幾個判斷則是爲了防止value不是單純的對象而是Regexp或者函數之類的,或者是vm實例再或者是不可擴展的,shouldConvert則是某些特殊狀況下爲false,它的解釋參見源碼裏尤雨溪的註釋。
那好,如今就進入到拿當前的data對象去new Observer(value),如今你可能會疑惑,遞歸遍歷的過程不是應該是純命令式的、面向過程的嗎?怎麼代碼跑着跑着跑出來一句new一個對象了,嗯先不用管,咱們先理清代碼執行過程,先帶着這個疑問。同時,咱們注意到代碼最後return了ob,結合代碼,咱們能夠理解爲若是return的是undifned,那麼說明傳進來的value不是對象,反之return除了一個ob,則說明這個value是對象或數組,他能夠添加或刪除屬性,這一點咱們先記着,這個東西后面有用。
咱們先看看Observer構造函數:
/** * Observer class that are attached to each observed * object. Once attached, the observer converts target * object's property keys into getter/setters that * collect dependencies and dispatches updates. * * @param {Array|Object} value * @constructor */ function Observer (value) { this.value = value this.dep = new Dep() def(value, '__ob__', this) //value的__ob__屬性指向這個Ob實例 if (isArray(value)) { var augment = hasProto ? protoAugment : copyAugment augment(value, arrayMethods, arrayKeys) this.observeArray(value) } else { // 若是是對象則使用walk遍歷每一個屬性 this.walk(value) } }
上述代碼中,若是遇到數組data中的數組實例增長了一些「變異」的push、pop等方法,這些方法會在數組本來的push、pop方法執行後發出消息,代表發生了改動。聽起來這好像能夠用繼承的方式實現: 繼承數組而後在這個子類的原型上附加上變異的方法。
可是你須要知道的是在es5及更低版本的js裏,沒法完美繼承數組,主要緣由是Array.call(this)時,Array根本不是像通常的構造函數那樣對你傳進去this進行改造,而是直接返回一個新的數組。因此通常的繼承方式就無法實現了。參見這篇文章,因此出現了新建一個iframe,而後直接拿那個iframe裏的數組的原型進行修改,添加自定義方法,諸如此類的hack方法,在此按下不表。
可是若是當前瀏覽器裏存在__proto__
這個非標準屬性的話(大部分都有),那又能夠有方法繼承,就是建立一個繼承自Array.prototype的Object: Object.create(Array.prototype)
,在這個繼承了數組原生方法的對象上添加方法或者覆蓋原有方法,而後建立一個數組,把這個數組的__proto__
指向這個對象,這樣這個數組的響應式的length屬性又得以保留,又得到了新的方法,並且無侵入,不會改變原本的數組原型。
Vue就是基於這個思想,先判斷__proto__
能不能用(hasProto),若是能用,則把那個一個繼承自Array.prototype的而且添加了變異方法的Object (arrayMethods),設置爲當前數組的__proto__
,完成改造,若是__proto__
不能用,那麼就只能遍歷arrayMethods就一個個的把變異方法def到數組實例上面去,這種方法效率不高,因此優先使用改造__proto__
的那個方法。
源碼裏後面那句this.observeArray很是簡單,for遍歷傳進去的value,而後對每一個元素執行observe,處理以前說的數組的元素爲對象或者數組的狀況。好了,對於數組的討論先打住,至於數組的變異方法怎麼通知我他進行了更改之類的咱們不說了,咱們先說清楚對象的狀況,對象說清楚了,再去看源碼就一目瞭然了。
對於對象,上面的代碼執行this.walk(value),他「遊走」對象的每一個屬性,對屬性和屬性值執行defineReactive函數。
function Dep () { this.id = uid++ this.subs = [] } Dep.prototype.depend = function () { Dep.target.addDep(this) } Dep.prototype.notify = function () { // stablize the subscriber list first var subs = toArray(this.subs) for (var i = 0, l = subs.length; i < l; i++) { subs[i].update() } } function defineReactive (obj, key, val) { // 生成一個新的Dep實例,這個實例會被閉包到getter和setter中 var dep = new Dep() var property = Object.getOwnPropertyDescriptor(obj, key) if (property && property.configurable === false) { return } // cater for pre-defined getter/setters var getter = property && property.get var setter = property && property.set // 對屬性的值繼續執行observe,若是屬性的值是一個對象,那麼則又遞歸進去對他的屬性執行defineReactive // 保證遍歷到全部層次的屬性 var childOb = observe(val) Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { var value = getter ? getter.call(obj) : val // 只有在有Dep.target時才說明是Vue內部依賴收集過程觸發的getter // 那麼這個時候就須要執行dep.depend(),將watcher(Dep.target的實際值)添加到dep的subs數組中 // 對於其餘時候,好比dom事件回調函數中訪問這個變量致使觸發的getter並不須要執行依賴收集,直接返回value便可 if (Dep.target) { dep.depend() if (childOb) { //若是value是對象,那就讓生成的Observer實例當中的dep也收集依賴 childOb.dep.depend() } if (isArray(value)) { for (var e, i = 0, l = value.length; i < l; i++) { e = value[i] //若是數組元素也是對象,那麼他們observe過程也生成了ob實例,那麼就讓ob的dep也收集依賴 e && e.__ob__ && e.__ob__.dep.depend() } } } return value }, set: function reactiveSetter (newVal) { var value = getter ? getter.call(obj) : val if (newVal === value) { return } if (setter) { setter.call(obj, newVal) } else { val = newVal } // observe這個新set的值 childOb = observe(newVal) // 通知訂閱我這個dep的watcher們:我更新了 dep.notify() } }) }
咱們來講說這個Dep,Dep類的定義極其簡單,一個id,一個數組,他就是一個很基本的發佈者-觀察者模式的實現,做爲一個發佈者,他的subs屬性用來存放了訂閱他的觀察者,也就是後面咱們會說到的watcher。
defineProperty是用來將對象的屬性轉化爲響應式的getter/setter的,defineProperty函數執行過程當中新建了一個Dep,閉包在了屬性的getter和setter中,所以每一個屬性都有一個惟一的Dep與其對應,咱們暫且能夠把屬性和他對應的Dep理解爲一體的。
Dep實際上是dependence依賴的縮寫,我以前一直沒能理解依賴、依賴收集是什麼,其實對於咱們的一個模板{{a+b}},咱們會說他的依賴有a和b,其實就是依賴了data的a和b屬性,更精確的說是依賴了a屬性中閉包的dep實例和b屬性中閉包的那個dep實例。
詳細來講:咱們的這個{{a+b}}在dom裏最終會被"a+b"表達式的真實值所取代,因此存在一個求出這個「a+b」的表達式的過程,求值的過程就會天然的分別觸發a和b的getter,而在getter中,咱們看到執行了dep.depend(),這個函數實際上回作dep.addSub(Dep.target),即在dep的訂閱者數組中存放了Dep.target,讓Dep.target訂閱dep。
那Dep.target是什麼?他就是咱們後面介紹的Watcher實例,爲何要放在Dep.target裏呢?是由於getter函數並不能傳參,dep能夠經過閉包的形式放進去,那watcher可就不行了,watcher內部存放了a+b這個表達式,也是由watcher計算a+b的值,在計算前他會把本身放在一個公開的地方(Dep.target),而後計算a+b,從而觸發表達式中全部遇到的依賴的getter,這些getter執行過程當中會把Dep.target加到本身的訂閱列表中。等整個表達式計算成功,Dep.target又恢復爲null.這樣就成功的讓watcher分發到了對應的依賴的訂閱者列表中,訂閱到了本身的全部依賴。
咱們能夠看到這是極其精妙的一筆!在一個表達式的求值過程當中隱式的完成依賴訂閱。
上面完成的是訂閱的過程,而上面setter代碼裏的dep.notify
就負責完成數據變更時通知訂閱者的功能。並且數據變化時,後文會說明只有依賴他的那些dom會精確更新,不會出現一些介紹mvvm的文章裏雖然實現了訂閱更新可是從新計算整個視圖的狀況。
因而一整個對象訂閱、notify的過程就結束了。
如今咱們明白了Dep的做用和收集訂閱依賴的過程,可是對於watcher是什麼確定仍是雲裏霧裏的,先別急。咱們先解決以前的疑問:爲何命令式的監聽過程當中出現了個new Observer()?並且構造函數第一行就建立了一個dep(這個dep不是defineReactive裏的那個閉包dep,注意區分),在defineReactive函數的getter中還執行了childOb.dep.depend(),去完成了這個dep的watcher添加?
咱們考慮一下這樣的狀況,好比個人data:{a:{b:true}},這個時候,若是頁面有dom上有個指令:class="a"
,而我想響應式的刪除data.a的b屬性,此時我就沒有辦法了,由於defineReactive中的getter/setter都不會執行(他們甚至還會在delete a.b時被清空),閉包裏的那個dep就沒法通知對應的watcher。
這就是getter和setter存在的缺陷:只能監聽到屬性的更改,不能監聽到屬性的刪除與添加。
Vue的解決辦法是提供了響應式的api: vm.$set/vm.$delete/ Vue.set/ Vue.delete /數組的$set/數組的$remove。
具體方法是爲全部的對象和數組(只有這倆哥們纔可能delete和新建屬性),也建立一個dep,也完成收集依賴的過程。咱們回到源碼defineReactive再看一遍,在執行defineReactive(data,'a',{b:true})時,他首先創造了那個閉包在getter/setter中的dep,而後var childOb = observe(val)
,val是{b:true},那就會爲這個對象new Observer(val),並放在val.__ob__
上,而這個ob實例上存放了一個Dep實例。如今咱們看到,有兩個Dep實例,一個是閉包裏的dep,一個是爲{b:true}建立的ob上的這個dep。而:class="a"
生成的watcher的求值過程當中會觸發到a的getter,那就會執行:
dep.depend() if (childOb) { //若是value是對象,那就讓生成的Observer實例當中的dep也收集依賴 childOb.dep.depend() }
這一步,:class="a"
的watcher既會訂閱閉包dep,也會訂閱ob的dep。
當咱們執行Vue.delete(this.a,'b'),內部會執行del函數,他會找到要刪除屬性的那個對象,也是{b:true},它的__ob__
屬性存放了ob,如今先刪除屬性,而後執行ob.dep.notify,通知全部依賴這個對象的watcher從新計算,這個時候屬性已經刪除了,從新計算的值(爲空)就會刷新到頁面上,完成dom響應式更新。參見此處源碼。
不只對於屬性的刪除這樣,屬性的的添加也是相似的,都是爲了彌補getter和setter存在的缺陷,都會找到這個dep執行notify。不過data的頂級屬性略有不一樣,涉及到digest,此處不表。
同時咱們再回到以前遍歷數組的代碼,咱們數組的響應化代碼甚至都裏沒有getter/setter,他連那個閉包的dep都沒有,代碼只是變異了一下push/pop方法。他有的只是那個childOb上的dep,因此數組的響應式過程都是notify的這個dep,無論是數組的變異方法),仍是數組的$set/$remove裏咱們都會看到是在這個dep上觸發notify,通知訂閱了整個數組的watcher進行更新。因此你知道這個dep的重要性了把。固然這也就有問題了,我一個watcher訂閱整個數組,當數組的元素有改動我就會收到消息,但我不知道變更的是哪一個,難道我要用整個數組從新構造一下dom?因此這就是數組diff算法的使用場景了。
至於Observer,這個額外的實例上存放了一個dep,這個dep配合Observer的addVm、removeVm、vms等屬性來一塊兒搞定data的頂級屬性的新增或者刪除,至於爲何不直接在數據上存放dep,而是搞個Observer,並把dep定義在上面,我以爲是Observer的那些方法和vms等屬性,並非全部的dep都應該具備的,做爲dep的實例屬性是不該該的,因此就抽象了個Observer這麼個東東吧,順便把walk、convert之類的函數變成方法掛在Observer上了,抽象出個專門用來observe的類而已,這部分純屬我的臆測。
介紹完響應式的部分,算是開了個頭了,後面的內容不少,可是層層遞進,最終完成響應式精確訂閱和批處理更新的整個過程,過程比較流程,內容耦合度也高,因此咱們先來給後文的概覽,介紹一下大致過程。
咱們最開始的代碼裏提到了Vue處理完數據和event以後就到了$mount,而$mount就是在this._compile後觸發編譯完成的鉤子而已,因此核心就是Vue.prototype._compile。
_compile
包含了Vue構建的三個階段,transclude,compile,link。而link階段實際上是放在linkAndCapture裏執行的,這裏又包含了watcher的生成,指令的bind、update等操做。
我先簡單講講什麼是指令,雖然Vue文檔裏說的指令是v-if,v-for等這種HTML的attribute,其實在Vue內部,只要是被Vue處理的dom上的東西都是指令,好比dom內容裏的{{a}}
,最終會轉換成一個v-text的指令和一個textNode,而一個子組件<component><component>
也會生成指令,還有slot,或者是你本身在元素上寫的attribute好比hello={{you}}
也會被編譯爲一個v-bind指令。咱們看到,基本只要是涉及dom的(不是響應式的也包含在內,只要是vue提供的功能),無論是dom標籤,仍是dom屬性、內容,都會被處理爲指令。因此不要有指令就是attribute的慣性思惟。
回過頭來,_compile部分大體分爲以下幾個部分
transclude
transclude的意思是內嵌,這個步驟會把你template裏給出的模板轉換成一段dom,而後抽取出你el選項指定的dom裏的內容(即子元素,由於模板裏可能有slot),把這段模板dom嵌入到el裏面去,固然,若是replace爲true,那他就是直接替換el,而不是內嵌。咱們大概明白transclude這個名字的意義了,但其實更關鍵的是把template轉換爲dom的過程(如`<p>{{a}}<p>`字符串轉爲真正的段落元素),這裏爲後面的編譯準備好了dom。
compile
compile的的過程具體就是**遍歷模板解析出模板裏的指令**。更精確的說是解析後生成了指令描述對象。 同時,compile函數是一個高階函數,他執行完成以後的返回值是另外一個函數:link,因此compile函數的第一個階段是編譯,返回出去的這個函數完成另外一個階段:link。
link
compile階段將指令解析成爲指令描述對象(descriptor),閉包在了link函數裏,link函數會把descriptor傳入Directive構造函數,建立出真正的指令實例。此外link函數是做爲參數傳入linkAndCaptrue中的,後者負責執行link,同時取出這些新生成的指令,先按照指令的預置的優先級從高到低排好順序,而後遍歷指令執行指令的_bind方法,這個方法會爲指令建立watcher,並計算表達式的值,完成前面描述的依賴收集。並最後執行對應指令的bind和update方法,使指令生效、界面更新。 此外link函數最終的返回值是unlink函數,負責在vm卸載時取消對應的dom到數據的綁定。
是時候回過頭來看看Vue官網這張經典的圖了,之前我剛學Vue時也是對於Watcher,Directive之類的概念雲裏霧裏。可是如今你們看這圖是否是很清晰明瞭?
模板中每一個指令/數據綁定都有一個對應的 watcher 對象,在計算過程當中它把屬性記錄爲依賴。以後當依賴的 setter 被調用時,會觸發 watcher 從新計算 ,也就會致使它的關聯指令更新 DOM。 --Vue官網
上代碼:
Vue.prototype._compile = function (el) { var options = this.$options // transclude and init element // transclude can potentially replace original // so we need to keep reference; this step also injects // the template and caches the original attributes // on the container node and replacer node. var original = el el = transclude(el, options) // 在el這個dom上掛一些參數,並觸發'beforeCompile'鉤子,爲compile作準備 this._initElement(el) // handle v-pre on root node (#2026) // v-pre指令的話就什麼都不用作了。 if (el.nodeType === 1 && getAttr(el, 'v-pre') !== null) { return } // root is always compiled per-instance, because // container attrs and props can be different every time. var contextOptions = this._context && this._context.$options var rootLinker = compileRoot(el, options, contextOptions) // resolve slot distribution // 具體是將各個slot存儲到vm._slotContents的對應屬性裏面去, // 而後後面的compile階段會把slot解析爲指令而後進行處理 resolveSlots(this, options._content) // compile and link the rest var contentLinkFn var ctor = this.constructor // component compilation can be cached // as long as it's not using inline-template // 這裏是組件的狀況才進入的,你們先忽略此段代碼 if (options._linkerCachable) { contentLinkFn = ctor.linker if (!contentLinkFn) { contentLinkFn = ctor.linker = compile(el, options) } } // link phase // make sure to link root with prop scope! var rootUnlinkFn = rootLinker(this, el, this._scope) // compile和link一併作了 var contentUnlinkFn = contentLinkFn ? contentLinkFn(this, el) : compile(el, options)(this, el) // register composite unlink function // to be called during instance destruction this._unlinkFn = function () { rootUnlinkFn() // passing destroying: true to avoid searching and // splicing the directives contentUnlinkFn(true) } // finally replace original if (options.replace) { replace(original, el) } this._isCompiled = true this._callHook('compiled') }
尤雨溪的註釋已經極盡詳細,上面的代碼很清晰(若是你用過angular,那你會感受很熟悉,angular裏也是有transclude,compile和link的,雖然實際差異很大)。咱們在具體進入各部分代碼前先說說爲何dom的編譯要分紅compile和link兩個phase。
在組件的多個實例、v-for數組等場合,咱們會出現同一個段模板要綁定不一樣的數據而後分發到dom裏面去的需求。這也是mvvm性能考量的主要場景:大數據量的重複渲染生成。而重複渲染的模板是一致的,不一致的是他們須要綁定的數據,所以compile階段找出指令的過程是不用重複計算的,只須要link函數(和裏面閉包的指令),而模板生成的dom使用原生的cloneNode方法便可複製出一份新的dom。如今,複製出的新dom+ link+具體的數據便可完成渲染,因此分離compile、並緩存link使得Vue在渲染時避免大量重複的性能消耗。
這裏你們能夠考慮一下,我給你一個空的documentFragment和一段html字符串,讓你把html生成dom放進fragment裏,你應該怎麼作?innerHTML?documentFragment但是沒有innerHtml的哦。那先建個div再innerHTML?那萬一個人html字符串的是tr元素呢?tr並不能直接放進div裏哦,那直接用outerHTML?沒有parent Node的元素是不能設置outerHTML的哈(parent是fragment也不行),那我先用正則提取第一個標籤,先createElement這個標籤而後在寫他的innerHTML總能夠了吧?並不行,我沒告訴你我給你的這段HTML最外層就一個元素啊,萬一是個片斷實例呢(也就是包含多個頂級元素,如<p>1<p><p>2<p>
),因此我才說給你一個fragment當容器,讓你把dom裝進去。
上面這個例子說明了實際轉換dom過程當中,可能遇到的一個小坑,只是想說明字符串轉dom並非看起來那麼一行innerHTML的事。
/** * Process an element or a DocumentFragment based on a * instance option object. This allows us to transclude * a template node/fragment before the instance is created, * so the processed fragment can then be cloned and reused * in v-for. * * @param {Element} el * @param {Object} options * @return {Element|DocumentFragment} */ export function transclude (el, options) { // extract container attributes to pass them down // to compiler, because they need to be compiled in // parent scope. we are mutating the options object here // assuming the same object will be used for compile // right after this. if (options) { options._containerAttrs = extractAttrs(el) } // for template tags, what we want is its content as // a documentFragment (for fragment instances) if (isTemplate(el)) { el = parseTemplate(el) } if (options) { // 若是當前是component,而且沒有模板,只有一個殼 // 那麼只須要處理內容的嵌入 if (options._asComponent && !options.template) { options.template = '<slot></slot>' } if (options.template) { //基本都會進入到這裏 options._content = extractContent(el) el = transcludeTemplate(el, options) } } if (isFragment(el)) { // anchors for fragment instance // passing in `persist: true` to avoid them being // discarded by IE during template cloning prepend(createAnchor('v-start', true), el) el.appendChild(createAnchor('v-end', true)) } return el }
咱們看上面的代碼,先options._containerAttrs = extractAttrs(el)
,這樣就把el元素上的全部attributes抽取出來存放在了選項對象的_containerAttrs屬性上。由於咱們前面說過,這些屬性是vm實際掛載的根元素上的,若是vm是一個組件之類的,那麼他們應該是在父組件的做用於編譯/link的,因此須要預先提取出來,由於若是replace爲true,el元素會被模板元素替換,可是他上面的屬性是會編譯link後merge到模板元素上面去。
而後進入到那個兩層的if裏, extractContent(el),將el的內容(子元素和文本節點)抽取出來,由於若是模板裏有slot,那麼他們要分發到對應的slot裏。
而後就到el = transcludeTemplate(el, options)
:
/** * Process the template option. * If the replace option is true this will swap the $el. * * @param {Element} el * @param {Object} options * @return {Element|DocumentFragment} */ function transcludeTemplate (el, options) { var template = options.template var frag = parseTemplate(template, true) if (frag) { // 對於非片斷實例狀況且replace爲true的狀況下,frag的第一個子節點就是最終el元素的替代者 var replacer = frag.firstChild var tag = replacer.tagName && replacer.tagName.toLowerCase() if (options.replace) { /* istanbul ignore if */ if (el === document.body) { process.env.NODE_ENV !== 'production' && warn( 'You are mounting an instance with a template to ' + '<body>. This will replace <body> entirely. You ' + 'should probably use `replace: false` here.' ) } // there are many cases where the instance must // become a fragment instance: basically anything that // can create more than 1 root nodes. if ( // multi-children template frag.childNodes.length > 1 || // non-element template replacer.nodeType !== 1 || // single nested component tag === 'component' || resolveAsset(options, 'components', tag) || hasBindAttr(replacer, 'is') || // element directive resolveAsset(options, 'elementDirectives', tag) || // for block replacer.hasAttribute('v-for') || // if block replacer.hasAttribute('v-if') ) { return frag } else { // 抽取replacer自帶的屬性,他們將在自身做用域下編譯 options._replacerAttrs = extractAttrs(replacer) // 把el的全部屬性都轉移到replace上面去,由於咱們後面將不會再處理el直至他最後被replacer替換 mergeAttrs(el, replacer) return replacer } } else { el.appendChild(frag) return el } } else { process.env.NODE_ENV !== 'production' && warn( 'Invalid template option: ' + template ) } }
首先執行解析parseTemplate(template, true)
,獲得一段存放在documentFragment裏的真實dom,而後就判斷是否須要replace。(若replace爲false)以後判斷是不是片斷實例,官網已經講述哪幾種狀況對應片斷實例,而代碼裏那幾個判斷就是對應的處理。若不是,那就進入後續的狀況,我已經註釋代碼做用,就再也不贅述。咱們來講說parseTemplate,由於vue支持template選項寫#app
這樣的HTML選擇符,也支持直接存放模板字符串、document fragment、dom元素等等,因此針對各類狀況做了區分,若是是一個已經好的dom那幾乎不用處理,不然大部分狀況下都是執行stringToFragment:
function stringToFragment (templateString, raw) { // 緩存機制 // try a cache hit first var cacheKey = raw ? templateString : templateString.trim() var hit = templateCache.get(cacheKey) if (hit) { return hit } //這三個正則分別是/<([\w:-]+)/ 和/&#?\w+?;/和/<!--/ var frag = document.createDocumentFragment() var tagMatch = templateString.match(tagRE) var entityMatch = entityRE.test(templateString) var commentMatch = commentRE.test(templateString) if (!tagMatch && !entityMatch && !commentMatch) { // 若是沒有tag 或者沒有html字符實體(如 ) 或者 沒有註釋 // text only, return a single text node. frag.appendChild( document.createTextNode(templateString) ) } else { // 這裏如前面的函數簽名所說,使用了jQuery 和 component/domify中所使用的生成元素的策略 // 咱們要將模板變成實際的dom元素,一個簡單的方法的是建立一個div document.createElement('div') // 而後再設置這個div的innerHtml爲咱們的模板, // (不直接建立一個模板的根元素是由於模板多是片斷實例,也就會生成多個dom元素) // (而設置這個div的outerHtml也不行哈,不能設置沒有父元素的outerHtml) // 可是許多特殊元素只能再固定的父元素下存在,不能直接存在於div下,好比tbody,tr,th,td,legend等等等等 // 那麼怎麼辦? 因此就有了下面這個先獲取第一個標籤,而後按照map的裏預先設置的內容,給模板設置設置好父元素, // 把模板嵌入到合適的父元素下,而後再層層進入父元素獲取真正的模板元素. var tag = tagMatch && tagMatch[1] var wrap = map[tag] || map.efault var depth = wrap[0] var prefix = wrap[1] var suffix = wrap[2] var node = document.createElement('div') node.innerHTML = prefix + templateString + suffix // 這裏是不斷深刻,進入正確的dom, // 好比你標籤是tr,那麼我會爲包上table和tbody元素 // 那麼我拿到你的時候應該剝開外層的兩個元素,讓node指到tr while (depth--) { node = node.lastChild } var child /* eslint-disable no-cond-assign */ // 用while循環把全部的子節點都提取了,由於多是片斷實例 while (child = node.firstChild) { /* eslint-enable no-cond-assign */ frag.appendChild(child) } } if (!raw) { trimNode(frag) } templateCache.put(cacheKey, frag) return frag }
這個部分的代碼就是用來處理我一開始介紹transclude提到的那個把html字符串轉換爲真正dom的問題。原理在代碼的註釋裏已經說得很清楚了,好比<tr>a</tr>
這段dom,那麼代碼裏的tag就匹配上了'tr',map對象是預先寫好的一個對象,map['tr']存放的內容就是這麼個數組[2, '<table><tbody>', '</tbody></table>']
,2
表示真正的元素在2層dom裏。剩下的兩段字符串是用於添加在你的HTML字符串兩端(prefix + templateString + suffix),如今innerHTML就設置爲了'<table><tbody><tr>a</tr></tbody></table>'
,不會出現問題了。
如今transclude以後,字符串已經變成了dom。後續的就依據此dom,遍歷dom樹,提取其中的指令,那若是Vue一開始就沒有把字符串轉成dom,而是直接解析字符串,提取其中的指令的話,其實工程量是很是大的。一方面要本身構建dom結構,一方面還要解析dom的attribute和內容,而這三者在Vue容許實現自定義組件、自定義指令、自定義prop的狀況下給直接分析純字符串帶來了很大難度。因此,實先構造爲dom是頗有必要的。
compile階段執行的compileRoot函數就是編譯咱們在transclude階段說過的,咱們分別提取到了el頂級元素的屬性和模板的頂級元素的屬性,若是是component,那就須要把二者分開編譯生成兩個link。主要就是對屬性編譯,後續內容會細說屬性編譯,因此在此處不細說了,註釋版源碼在此。後面的resolveSlots出於篇幅考慮,也再也不介紹,若有需求,請查看註釋版源碼。
咱們來講說compile函數,他對元素執行compileNode,對其childNodes執行compileNodeList:
export function compile (el, options, partial) { // link function for the node itself. var nodeLinkFn = partial || !options._asComponent ? compileNode(el, options) : null // link function for the childNodes // 若是nodeLinkFn.terminal爲true,說明nodeLinkFn接管了整個元素和其子元素的編譯過程,那也就不用編譯el.childNodes var childLinkFn = !(nodeLinkFn && nodeLinkFn.terminal) && !isScript(el) && el.hasChildNodes() ? compileNodeList(el.childNodes, options) : null return function compositeLinkFn (vm, el, host, scope, frag) { // cache childNodes before linking parent, fix #657 var childNodes = toArray(el.childNodes) // link // 任何link都是包裹在linkAndCapture中執行的,詳見linkAndCapture函數 var dirs = linkAndCapture(function compositeLinkCapturer () { if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag) if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag) }, vm) return makeUnlinkFn(vm, dirs) } }
上面的代碼中,咱們看到了一個terminal屬性,詳見官網說明,其實就是終端指令這麼個東東,好比v-if 由於元素是否存在和是否須要編譯得視v-if的值而定(這個元素最終都不存在那就確定不用浪費時間去編譯他...- -),因此這個terminal指令接管了他和他的子元素的編譯過程,由他來控制什麼時候進行本身和後代的編譯和link。
compile函數就是執行了compileNode和compileNodeList兩個編譯操做,他們分別編譯了元素自己和元素的childNodes,而後將返回的兩個link放在一個「組合link」函數裏返回出去,link函數的內容我下節再說。
咱們回頭看看compileNode具體是怎麼作的。至於compileNodeList實際上是對應於多個元素狀況下,對每一個元素執行compileNode、對其childNodes遞歸執行compileNodeList,本質上就是遍歷元素遞歸對每一個元素執行compileNode。
function compileNode (node, options) { var type = node.nodeType if (type === 1 && !isScript(node)) { return compileElement(node, options) } else if (type === 3 && node.data.trim()) { return compileTextNode(node, options) } else { return null } }
能夠看到很簡單,compileNode就是判斷了下node是元素節點仍是文本節點,那咱們分別看一下元素和文本節點是怎麼編譯的。
function compileElement (el, options) { if (el.tagName === 'TEXTAREA') { // textarea元素是把tag中間的內容當作了他的value,這和input什麼的不太同樣 // 所以你們寫模板的時候一般是這樣寫: <textarea>{{hello}}</textarea> // 可是template轉換成dom以後,這個內容跑到了textarea元素的value屬性上,tag中間的內容是空的, // 所以遇到textarea的時候須要單獨編譯一下它的value var tokens = parseText(el.value) if (tokens) { el.setAttribute(':value', tokensToExp(tokens)) el.value = '' } } var linkFn var hasAttrs = el.hasAttributes() var attrs = hasAttrs && toArray(el.attributes) // check terminal directives (for & if) if (hasAttrs) { linkFn = checkTerminalDirectives(el, attrs, options) } // check element directives if (!linkFn) { linkFn = checkElementDirectives(el, options) } // check component if (!linkFn) { linkFn = checkComponent(el, options) } // normal directives if (!linkFn && hasAttrs) { // 通常會進入到這裏 linkFn = compileDirectives(attrs, options) } return linkFn }
代碼過程當中檢測該元素是否有Terminal指令、是不是元素指令和component,這些狀況下他們會接管元素及後代元素的編譯過程。而通常狀況下會執行compileDirectives,也就是編譯元素上的屬性。
我先說一下哪些屬性須要處理的:
一種是有插值的,插值其實就是咱們很熟悉的{{a}}
這樣的形式好比id="item-{{ id }}"
,另外vue還支持html插值:{{{a}}}
和單次插值{{* a}}
。在屬性裏的插值,好比test="{{a}}"
其實等價於v-bind:test="a"
。
另外一種則是v-model="a"
這樣的vue指令,其不須要在value裏寫插值。
compileDirectives代碼較長,不便貼出。代碼主要是首先對屬性的value執行parseText
,檢測value中是否有插值的狀況,如有則返回插值的處理結果:token數組。若是沒返回token,那麼在檢測屬性的name是不是Vue的提供的指令好比v-if
、transition
或者@xxxx
、:xxxx
之類。
總之上述兩種狀況無論是那種出現了,就會對屬性作進一步處理,好比拿屬性的name執行parseModifiers,提取出屬性中可能存在的修飾符,諸如此類,這些過程主要是使用正則表達式進行所需值的提取。
最終會生成這麼一個指令描述對象,以v-bind:href.literal="mylink"
爲例:
{ arg:"href", attr:"v-bind:href.literal", def:Object,// v-bind指令的定義 expression:"mylink", // 表達式,若是是插值的話,那主要用到的是下面的interp字段 filters:undefined hasOneTime:undefined interp:undefined,// 存放插值token modifiers:Object, // literal修飾符的定義 name:"bind" //指令類型 raw:"mylink" //未處理前的原始屬性值 }
這就是指令描述對象,他包含了指令構造過程和執行過程的全部信息。對象中的def
屬性存放了指令定義對象。由於vue提供了大量的指令,而且也容許自定義指令,寫過自定義指令的同窗確定清楚要定義的指令bind、updaate等方法。指令大運行過程都是一致的,不一樣就在於這些bind、update、優先級等細節,所以若是爲這二三十個指令實現一個單獨的類並根據指令描述對象手動調用對應的構造函數是不可取的。Vue是定義了一個統一的指令類Directive,在建立時Directive實例時,會把上述def
屬性存放的具體指令的定義對象拷貝到this上,從而完成具體的指令的建立過程。
回過頭來講一說解析插值的parseText的具體執行過程,其核心過程就是這麼幾句代碼(爲方便理解,改了一下原版的),代碼的註釋已經解釋清楚代碼執行過程。
// 僅用於匹配html插值 var htmlRE = /{{{.+?}}}/ // 用於匹配插值模板,多是兩個花括號,也多是三個花括號 var tagRE = /{{(.+?)}}|{{{(.+?)}}}/ var lastIndex = 0 var match, index, html, value, first, oneTime /* eslint-disable no-cond-assign */ // 反覆執行匹配操做,直至全部的插值都匹配完 while (match = tagRE.exec(text)) { // 當前匹配的起始位置 index = match.index // push text token if (index > lastIndex) { // 若是index比lastIndex要大,說明當前匹配的起始位置和上次的結束位置中間存在空隙, // 好比'{{a}} to {{b}}',這個空隙就是中間的純字符串部分' to ' tokens.push({ value: text.slice(lastIndex, index) }) } // tag token html = htmlRE.test(match[0]) // 若是用於匹配{{{xxx}}}的htmlRE匹配上了,則應該從第一個捕獲結果中取出value,反之則爲match[2] value = html ? match[1] : match[2] first = value.charCodeAt(0) // 有value的第一個字符是否爲* 判斷是不是單次插值 oneTime = first === 42 // * value = oneTime ? value.slice(1) : value tokens.push({ tag: true, // 是插值仍是普通字符串 value: value.trim(), // 普通字符串或者插值表達式 html: html, // 是否爲html插值 oneTime: oneTime // 是否爲單次插值 }) // lastIndex記錄爲本次匹配結束位置的後一位. // 注意index + match[0].length到達的是後一位 lastIndex = index + match[0].length } if (lastIndex < text.length) { // 若是上次匹配結束位置的後一位以後還存在空間,則應該是還有純字符串 tokens.push({ value: text.slice(lastIndex) }) }
代碼的執行結果就是把插值字符串轉換成了一個token數組,每一個token其實就是一個簡單對象,裏面的四個屬性記錄了對應的插值信息。這些token最終會存放在前述指令描述對象的interp字段裏(interp爲Interpolation簡寫)。
說完了怎麼處理element,那就看看另外一種狀況:textNode。
function compileTextNode (node, options) { // skip marked text nodes if (node._skip) { return removeText } var tokens = parseText(node.wholeText) // 沒有token就意味着沒有插值, // 沒有插值那麼內容不須要任何更改,也不會是響應式的數據 if (!tokens) { return null } // mark adjacent text nodes as skipped, // because we are using node.wholeText to compile // all adjacent text nodes together. This fixes // issues in IE where sometimes it splits up a single // text node into multiple ones. var next = node.nextSibling while (next && next.nodeType === 3) { next._skip = true next = next.nextSibling } var frag = document.createDocumentFragment() var el, token for (var i = 0, l = tokens.length; i < l; i++) { token = tokens[i] // '{{a}} vue {{b}}'這樣一段插值獲得的token中 // token[1]就是' vue ',tag爲false, // 直接用' vue ' createTextNode便可 el = token.tag ? processTextToken(token, options) : document.createTextNode(token.value) frag.appendChild(el) } return makeTextNodeLinkFn(tokens, frag, options) } /** * Process a single text token. * * @param {Object} token * @param {Object} options * @return {Node} */ function processTextToken (token, options) { var el if (token.oneTime) { el = document.createTextNode(token.value) } else { if (token.html) { // 這個comment元素造成一個錨點的做用,告訴vue哪一個地方應該插入v-html生成的內容 el = document.createComment('v-html') setTokenType('html') } else { // IE will clean up empty textNodes during // frag.cloneNode(true), so we have to give it // something here... el = document.createTextNode(' ') setTokenType('text') } } function setTokenType (type) { if (token.descriptor) return // parseDirective實際上是解析出filters, // 好比 'msg | uppercase' // 就會生成{expression:'msg',filters:[過濾器名稱和參數]} var parsed = parseDirective(token.value) token.descriptor = { name: type, def: publicDirectives[type], expression: parsed.expression, filters: parsed.filters } } return el }
對於文本節點,咱們只須要處理他的wholeText裏面出現插值的狀況,因此須要parseText解析他的value,若是沒有插值,那就原樣保持不動。接着新建一個fragment,最後對生成的tokens進行處理,處理過程遇到tag爲false的就說明不是插值是純字符串,那就直接document.createTextNode(token.value)
(這種狀況不會生成指令描述符,使得產生指令描述符並生成指令的狀況只有純插值的狀況)。遇到插值token則建立對應元素,並在token的descriptor屬性存放對應的指令描述符。這個指令描述符相比以前的指令描述符簡單了不少,那是由於textNode只會對應v-bind、v-text和v-html三種指令,他們基本只須要expression便可。最終處理token過程當中生成的元素都會添加到fragment裏。這個fragment在link階段link完畢後會替換掉模板dom裏的對應節點,完成界面更新。
compile結束後就到了link階段。前文說了全部的link函數都是被linkAndCapture包裹着執行的。那就先看看linkAndCapture:
// link函數的執行過程會生成新的Directive實例,push到_directives數組中 // 而這些_directives並無創建對應的watcher,watcher也沒有收集依賴, // 一切都還處於初始階段,所以capture階段須要找到這些新添加的directive, // 依次執行_bind,在_bind裏會進行watcher生成,執行指令的bind和update,完成響應式構建 function linkAndCapture (linker, vm) { // 先記錄下數組裏原先有多少元素,他們都是已經執行過_bind的,咱們只_bind新添加的directive var originalDirCount = vm._directives.length linker() // slice出新添加的指令們 var dirs = vm._directives.slice(originalDirCount) // 對指令進行優先級排序,使得後面指令的bind過程是按優先級從高到低進行的 dirs.sort(directiveComparator) for (var i = 0, l = dirs.length; i < l; i++) { dirs[i]._bind() } return dirs }
linkAndCapture的做用很清晰:排序而後遍歷執行_bind()。註釋很清楚了。咱們直接看link階段。咱們以前說了幾種complie方法,可是他們的link都很相近,基本就是使用指令描述對象建立指令就完畢了。爲了緩解你的好奇心,咱們仍是舉個例子:看看compileDirective生成的link長啥樣:
// makeNodeLinkFn就是compileDirective最後執行而且return出去返回值的函數 // 它讓link函數閉包住編譯階段生成好的指令描述對象(他們還不是Directive實例,雖然變量名叫作directives) function makeNodeLinkFn (directives) { return function nodeLinkFn (vm, el, host, scope, frag) { // reverse apply because it's sorted low to high var i = directives.length while (i--) { vm._bindDir(directives[i], el, host, scope, frag) } } } // 這就是vm._bindDir Vue.prototype._bindDir = function (descriptor, node, host, scope, frag) { this._directives.push( new Directive(descriptor, this, node, host, scope, frag) ) }
咱們能夠看到,這麼一段link函數是很靈活的,他的5個參數(vm, el, host, scope, frag)
對應着vm實例、dom分發的宿主環境(slot中的相關內容,你們先忽略)、v-for狀況下的數組做用域scope、document fragment(包含el的那個fragment)。只要你傳給我合適的參數,我就能夠還給你一段響應式的dom。咱們以前說的大數據量的v-for狀況下,新dom(el)+ link+具體的數據(scope)實現就是基於此。
回到link函數自己,其功能就是將指令描述符new爲Directive實例,存放至this._directives數組。而Directive構造函數就是把傳入的參數、指令構造函數的屬性賦值到this上而已,整個構造函數就是this.xxx = xxx的模式,因此咱們就不說它了。
關鍵在於linkAndCapture函數中在指令生成、排序以後執行了指令的_bind函數。
Directive.prototype._bind = function () { var name = this.name var descriptor = this.descriptor // remove attribute if ( // 只要不是cloak指令那就從dom的attribute裏移除 // 是cloak指令可是已經編譯和link完成了的話,那也仍是能夠移除的 (name !== 'cloak' || this.vm._isCompiled) && this.el && this.el.removeAttribute ) { var attr = descriptor.attr || ('v-' + name) this.el.removeAttribute(attr) } // copy def properties // 不採用原型鏈繼承,而是直接extend定義對象到this上,來擴展Directive實例 var def = descriptor.def if (typeof def === 'function') { this.update = def } else { extend(this, def) } // setup directive params // 獲取指令的參數, 對於一些指令, 指令的元素上可能存在其餘的attr來做爲指令運行的參數 // 好比v-for指令,那麼元素上的attr: track-by="..." 就是參數 // 好比組件指令,那麼元素上可能寫了transition-mode="out-in", 諸如此類 this._setupParams() // initial bind if (this.bind) { this.bind() } this._bound = true if (this.literal) { this.update && this.update(descriptor.raw) } else if ( // 下面這些判斷是由於許多指令好比slot component之類的並非響應式的, // 他們只須要在bind裏處理好dom的分發和編譯/link便可而後他們的使命就結束了,生成watcher和收集依賴等步驟根本沒有 // 因此根本不用執行下面的處理 (this.expression || this.modifiers) && (this.update || this.twoWay) && !this._checkStatement() ) { // wrapped updater for context var dir = this if (this.update) { // 處理一下本來的update函數,加入lock判斷 this._update = function (val, oldVal) { if (!dir._locked) { dir.update(val, oldVal) } } } else { this._update = noop } // 綁定好 預處理 和 後處理 函數的this,由於他們即將做爲屬性放入一個參數對象當中,不綁定的話this會變 var preProcess = this._preProcess ? bind(this._preProcess, this) : null var postProcess = this._postProcess ? bind(this._postProcess, this) : null var watcher = this._watcher = new Watcher( this.vm, this.expression, this._update, // callback { filters: this.filters, twoWay: this.twoWay,//twoWay指令和deep指令請參見官網自定義指令章節 deep: this.deep, //twoWay指令和deep指令請參見官網自定義指令章節 preProcess: preProcess, postProcess: postProcess, scope: this._scope } ) // v-model with inital inline value need to sync back to // model instead of update to DOM on init. They would // set the afterBind hook to indicate that. if (this.afterBind) { this.afterBind() } else if (this.update) { this.update(watcher.value) } } }
這個函數其實也很簡單,主要先執行指令的bind方法(注意和_bind區分)。每一個指令的bind和update方法都不相同,他們都是定義在各個指令本身的定義對象(def)上的,在_bind代碼的開頭將他們拷貝到實例上:extend(this, def)。而後就是new了watcher,而後將watcher計算獲得的value update到界面上(this.update(wtacher.value)
),此處用到的update即剛剛說的指令構造對象上的update。
那咱們先看看bind作了什麼,每一個指令的bind都是不同的,你們能夠隨便找一個指令定義對象看看他的bind方法。如Vue官網所說:只調用一次,在指令第一次綁定到元素上時調用,bind方法大都很簡單,例如v-on的bind階段幾乎什麼都不作。咱們此處隨便舉兩個簡單的例子吧:v-bind和v-text:
// v-bind指令的指令定義對象 [有刪節] export default { ... bind () { var attr = this.arg var tag = this.el.tagName // handle interpolation bindings const descriptor = this.descriptor const tokens = descriptor.interp if (tokens) { // handle interpolations with one-time tokens if (descriptor.hasOneTime) { // 對於單次插值的狀況 // 在tokensToExp內部使用$eval將表達式'a '+val+' c'轉換爲'"a " + "text" + " c"',以此結果爲新表達式 // $eval過程當中未設置Dep.target,於是不會訂閱任何依賴, // 然後續Watcher.get在計算這個新的純字符串表達式過程當中雖然設置了target但必然不會觸發任何getter,也不會訂閱任何依賴 // 單次插值由此完成 this.expression = tokensToExp(tokens, this._scope || this.vm) } } }, .... } // v-text指令的執行定義對象 export default { bind () { this.attr = this.el.nodeType === 3 ? 'data' : 'textContent' }, update (value) { this.el[this.attr] = _toString(value) } }
兩個指令的bind函數都足夠簡單,v-text甚至只是根據當前是文本節點仍是元素節點預先爲update階段設置好修改data
仍是textContent
。
指令的bind階段完成後_bind方法繼續執行到建立Watcher。那咱們又再去看看Watcher構造函數:
export default function Watcher (vm, expOrFn, cb, options) { // mix in options if (options) { extend(this, options) } var isFn = typeof expOrFn === 'function' this.vm = vm vm._watchers.push(this) this.expression = expOrFn // 把回調放在this上, 在完成了一輪的數據變更以後,在批處理最後階段執行cb, cb通常是dom操做 this.cb = cb this.id = ++uid // uid for batching this.active = true // lazy watcher主要應用在計算屬性裏,我在註釋版源碼裏進行了解釋,這裏你們先跳過 this.dirty = this.lazy // for lazy watchers // 用deps存儲當前的依賴,而新一輪的依賴收集過程當中收集到的依賴則會放到newDeps中 // 之因此要用一個新的數組存放新的依賴是由於當依賴變更以後, // 好比由依賴a和b變成依賴a和c // 那麼須要把原先的依賴訂閱清除掉,也就是從b的subs數組中移除當前watcher,由於我已經不想監聽b的變更 // 因此我須要比對deps和newDeps,找出那些再也不依賴的dep,而後dep.removeSub(當前watcher),這一步在afterGet中完成 this.deps = [] this.newDeps = [] // 這兩個set是用來提高比對過程的效率,不用set的話,判斷deps中的一個dep是否在newDeps中的複雜度是O(n) // 改用set來判斷的話,就是O(1) this.depIds = new Set() this.newDepIds = new Set() this.prevError = null // for async error stacks // parse expression for getter/setter if (isFn) { // 對於計算屬性而言就會進入這裏,咱們先忽略 this.getter = expOrFn this.setter = undefined } else { // 把expression解析爲一個對象,對象的get/set屬性存放了獲取/設置的函數 // 好比hello解析的get函數爲function(scope) {return scope.hello;} var res = parseExpression(expOrFn, this.twoWay) this.getter = res.get // 好比scope.a = {b: {c: 0}} 而expression爲a.b.c // 執行res.set(scope, 123)能使scope.a變成{b: {c: 123}} this.setter = res.set } // 執行get(),既拿到表達式的值,又完成第一輪的依賴收集,使得watcher訂閱到相關的依賴 // 若是是lazy則不在此處計算初值 this.value = this.lazy ? undefined : this.get() // state for avoiding false triggers for deep and Array // watchers during vm._digest() this.queued = this.shallow = false }
代碼不難,首先咱們又看到了熟悉的dep相關的屬性,他們就是用來存放咱們一開始在observe章節講到的dep。在此處存放dep主要是依賴的屬性值變更以後,咱們須要清除原來的依賴,再也不監聽他的變化。
接下來代碼對錶達式執行parseExpression(expOrFn, this.twoWay),twoWay通常爲false,咱們先忽略他去看看parseExpression作了什麼:
export function parseExpression (exp, needSet) { exp = exp.trim() // try cache // 緩存機制 var hit = expressionCache.get(exp) if (hit) { if (needSet && !hit.set) { hit.set = compileSetter(hit.exp) } return hit } var res = { exp: exp } res.get = isSimplePath(exp) && exp.indexOf('[') < 0 // optimized super simple getter ? makeGetterFn('scope.' + exp) // dynamic getter // 若是不是簡單Path, 也就是語句了,那麼就要對這個字符串作一些額外的處理了, // 主要是在變量前加上'scope.' : compileGetter(exp) if (needSet) { res.set = compileSetter(exp) } expressionCache.put(exp, res) return res } const pathTestRE = // pathTestRE太長了,其就是就是檢測是不是a或者a['xxx']或者a.xx.xx.xx這種表達式 const literalValueRE = /^(?:true|false|null|undefined|Infinity|NaN)$/ function isSimplePath (exp) { // 檢查是不是 a['b'] 或者 a.b.c 這樣的 // 或者是true false null 這種字面量 // 再或者就是Math.max這樣, // 對於a=true和a/=2和hello()這種就不是simple path return pathTestRE.test(exp) && // don't treat literal values as paths !literalValueRE.test(exp) && // Math constants e.g. Math.PI, Math.E etc. exp.slice(0, 5) !== 'Math.' } function makeGetterFn (body) { return new Function('scope', 'return ' + body + ';') }
先計算你傳入的表達式的get函數,isSimplePath(exp)用於判斷你傳入的表達式是不是「簡單表達式」(見代碼註釋),由於Vue支持你在v-on等指令裏寫v-on:click="a/=2"
等等這樣的指令,也就是寫一個statement,這樣就明顯不是"簡單表達式"了。若是是簡單表達式那很簡單,直接makeGetterFn('scope.' + exp),好比v-bind:id="myId"
,就會獲得function(scope){return scope.myId},這就是表達式的getter了。若是是非簡單表達式好比a && b() || c()
那就會獲得function(scope){return scope.a && scope.b() || scope.c()},相比上述結果就是在每一個變量前增長了一個「scope.」
,這個操做是用正則表達式提取變量部分加上「scope.」
後完成的。後續的setter對應於twoWay指令中要將數據寫回vm的狀況,在此不表(此處分析path的過程就是@勾三股四大神那篇很是出名的博客裏path解析狀態機涉及的部分)。
如今咱們明白vue是怎麼把一個表達式字符串變成一個能夠計算的函數了。回到以前的Watcher構造函數代碼,這個get函數存放在了this.getter屬性上,而後進行了this.get(),開始進行咱們期待已久的依賴收集部分和表達式求值部分!
Watcher.prototype.beforeGet = function () { Dep.target = this } Watcher.prototype.get = function () { this.beforeGet() // v-for狀況下,this.scope有值,是對應的數組元素,其繼承自this.vm var scope = this.scope || this.vm var value try { // 執行getter,這一步很精妙,表面上看是求出指令的初始值, // 其實也完成了初始的依賴收集操做,即:讓當前的Watcher訂閱到對應的依賴(Dep) // 好比a+b這樣的expression實際是依賴兩個a和b變量,this.getter的求值過程當中 // 會依次觸發a 和 b的getter,在observer/index.js:defineReactive函數中,咱們定義好了他們的getter // 他們的getter會將Dep.target也就是當前Watcher加入到本身的subs(訂閱者數組)裏 value = this.getter.call(scope, scope) } catch (e) { // 輸出相關warn信息 } // "touch" every property so they are all tracked as // dependencies for deep watching // deep指令的處理,相似於我在文章開頭寫的那個遍歷全部屬性的touch函數,你們請跳過此處 if (this.deep) { traverse(value) } if (this.preProcess) { value = this.preProcess(value) } if (this.filters) { // 如有過濾器則對value執行過濾器,請跳過 value = scope._applyFilters(value, null, this.filters, false) } if (this.postProcess) { value = this.postProcess(value) } this.afterGet() return value } // 新一輪的依賴收集後,依賴被收集到this.newDepIds和this.newDeps裏 // this.deps存儲的上一輪的的依賴此時將會被遍歷, 找出其中再也不依賴的dep,將本身從dep的subs列表中清除 // 再也不訂閱那些不依賴的dep Watcher.prototype.afterGet = function () { Dep.target = null var i = this.deps.length while (i--) { var dep = this.deps[i] if (!this.newDepIds.has(dep.id)) { dep.removeSub(this) } } // 清除訂閱完成,this.depIds和this.newDepIds交換後清空this.newDepIds var tmp = this.depIds this.depIds = this.newDepIds this.newDepIds = tmp this.newDepIds.clear() // 同上,清空數組 tmp = this.deps this.deps = this.newDeps this.newDeps = tmp this.newDeps.length = 0 }
這部分代碼的原理,我在observe數據部分其實就已經完整的劇透了,watcher在計算getter以前先把本身公開放置到Dep.target上,而後執行getter,getter會依次觸發各個響應式數據的getter,你們把這個watcher加入到本身的dep.subs數組中。完成依賴訂閱,同時getter計算結束,也獲得了表達式的值。
wait,watcher加入到dep.subs數組的過程當中好像還有其餘操做。咱們回過頭看看:響應式數據的getter被觸發的函數裏寫了用dep.depend()
來收集依賴:
Dep.prototype.depend = function () { Dep.target.addDep(this) } // 實際執行的是watcher.addDep Watcher.prototype.addDep = function (dep) { var id = dep.id // 若是newDepIds裏已經有了這個Dep的id, 說明這一輪的依賴收集過程已經完成過這個依賴的處理了 // 好比a + b + a這樣的表達式,第二個a在get時就不必在收集一次了 if (!this.newDepIds.has(id)) { this.newDepIds.add(id) this.newDeps.push(dep) if (!this.depIds.has(id)) { // 若是連depIds裏都沒有,說明以前就沒有收集過這個依賴,依賴的訂閱者裏面沒有我這個Watcher, // 因此加進去 // 通常發生在有新依賴時,第一次依賴收集時固然會老是進入這裏 dep.addSub(this) } } }
依賴收集的過程當中,首先是判斷是否已經處理過這個依賴:newDepIds中是否有這個dep的id了。而後再在depIds裏判斷。若是連depIds裏都沒有,說明以前就沒有收集過這個依賴,依賴的訂閱者裏面也沒有我這個Watcher。那麼趕忙訂閱這個依賴dep.addSub(this)。這個過程保證了這一輪的依賴都會被newDepIds準確記錄,而且若是有此前沒有訂閱過的依賴,那麼我須要訂閱他。
由於並不僅是這樣的初始狀態會用watcher.get去計算表達式的值。每一次我這個watcher被notify有數據變更時,也會去get一次,訂閱新的依賴,依賴也會被收集到this.newDepIds裏,收集完成後,我須要對比哪些舊依賴沒有在this.newDepIds裏,這些再也不須要訂閱的依賴,我須要把我從它的subs數組中移除,避免他更新後錯誤的notify我。
watcher構造完畢,成功收集依賴,並計算獲得表達式的值。回到指令的_bind函數,最後一步:this.update(watcher.value)
。
這裏執行的是指令構造對象的update方法。咱們舉個例子,看看v-bind函數的update[爲便於理解,有改動]:
// bind指令的指令構造對象 export default { ... update (value) { var attr = this.arg const el = this.el const interp = this.descriptor.interp if (this.modifiers.camel) { // 將綁定的attribute名字轉回駝峯命名,svg的屬性綁定時可能會用到 attr = camelize(attr) } // 對於value|checked|selected等attribute,不只僅要setAttribute把dom上的attribute值修改了 // 還要在el上修改el['value']/el['checked']等值爲對應的值 if ( !interp && attrWithPropsRE.test(attr) && //attrWithPropsRE爲/^(?:value|checked|selected|muted)$/ attr in el ) { var attrValue = attr === 'value' ? value == null // IE9 will set input.value to "null" for null... ? '' : value : value if (el[attr] !== attrValue) { el[attr] = attrValue } } // set model props // vue支持設置checkbox/radio/option等的true-value,false-value,value等設置, // 如<input type="radio" v-model="pick" v-bind:value="a"> // 若是bind的是此類屬性,那麼則把value放到元素的對應的指定屬性上,供v-model提取 var modelProp = modelProps[attr] if (!interp && modelProp) { el[modelProp] = value // update v-model if present var model = el.__v_model if (model) { // 若是這個元素綁定了一個model,那麼就提示model,這個input組件value有更新 model.listener() } } // do not set value attribute for textarea if (attr === 'value' && el.tagName === 'TEXTAREA') { el.removeAttribute(attr) return } // update attribute // 若是是隻接受true false 的"枚舉型"的屬性 if (enumeratedAttrRE.test(attr)) { // enumeratedAttrRE爲/^(?:draggable|contenteditable|spellcheck)$/ el.setAttribute(attr, value ? 'true' : 'false') } else if (value != null && value !== false) { if (attr === 'class') { // handle edge case #1960: // class interpolation should not overwrite Vue transition class if (el.__v_trans) { value += ' ' + el.__v_trans.id + '-transition' } setClass(el, value) } else if (xlinkRE.test(attr)) { // /^xlink:/ el.setAttributeNS(xlinkNS, attr, value === true ? '' : value) } else { //核心就是這裏了 el.setAttribute(attr, value === true ? '' : value) } } else { el.removeAttribute(attr) } } }
update中要處理的邊界狀況較多,可是核心仍是比較簡單的:el.setAttribute(attr, value === true ? '' : value)
,就是這麼一句。
好了,如今整個link過程就完畢了,全部的指令都已創建了對應的watcher,而watcher也已訂閱了數據變更。在_compile函數最後replace(original, el)
後,就直接append到頁面裏了。將咱們預約設計的內容呈現到dom裏了。
那最後咱們來說一講若是數據有更新的話,是如何更新到dom裏的。雖然具體的dom操做是執行指令的update函數,剛剛的這個例子也已經舉例介紹了v-bind指令的update過程。可是在update前,Vue引入了批處理機制,來提高dom操做性能。因此咱們來看看數據變更,依賴觸發notify以後發生的事情。
Dep.prototype.notify = function () { // stablize the subscriber list first var subs = toArray(this.subs) for (var i = 0, l = subs.length; i < l; i++) { subs[i].update() } }
數據變更時觸發的notify遍歷了全部的watcher,執行器update方法。(刪節了shallow update的內容,想了解請看註釋)
Watcher.prototype.update = function (shallow) { if (this.lazy) { // lazy模式下,標記下當前是髒的就能夠了,這是計算屬性相關的東西,你們先跳過 this.dirty = true } else if (this.sync || !config.async) { // 若是你關閉async模式,即關閉批處理機制,那麼全部的數據變更會當即更新到dom上 this.run() } else { // 標記這個watcher已經加入批處理隊列 this.queued = true pushWatcher(this) } }
咱們先忽略lazy和同步模式,真正執行的就是將這個被notify的watcher加入到隊列裏:
export function pushWatcher (watcher) { const id = watcher.id // 若是已經有這個watcher了,就不用加入隊列了,這樣無論一個數據更新多少次,Vue都只更新一次dom if (has[id] == null) { // push watcher into appropriate queue // 選擇合適的隊列,對於用戶使用$watch方法或者watch選項觀察數據的watcher,則要放到userQueue中 // 由於他們的回調在執行過程當中可能又觸發了其餘watcher的更新,因此要分兩個隊列存放 const q = watcher.user ? userQueue : queue // has[id]記錄這個watcher在隊列中的下標 // 主要是判斷是否出現了循環更新:你更新我後我更新你,沒完沒了了 has[id] = q.length q.push(watcher) // queue the flush if (!waiting) { //waiting這個flag用於標記是否已經把flushBatcherQueue加入到nextTick任務隊列當中了 waiting = true nextTick(flushBatcherQueue) } } }
pushWatcher把watcher放入隊列裏以後,又把負責清空隊列的flushBatcherQueue放到本輪事件循環結束後執行,nextTick就是vm.$nextTick,利用了MutationObserver,註釋裏講述了原理,這裏跳過:
function flushBatcherQueue () { runBatcherQueue(queue) runBatcherQueue(userQueue) // user watchers triggered more watchers, // keep flushing until it depletes // userQueue在執行時可能又會往指令queue里加入新任務(用戶可能又更改了數據使得dom須要更新) if (queue.length) { return flushBatcherQueue() } // 重設batcher狀態,手動重置has,隊列等等 resetBatcherState() }
runBatcherQueue就是對傳入的watcher隊列進行遍歷,對每一個watcher執行其run方法。
Watcher.prototype.run = function () { if (this.active) { var value = this.get() // 若是兩次數據不相同,則不只要執行上面的 求值、訂閱依賴 ,還要執行下面的 指令update、更新dom // 若是是相同的,那麼則要考慮是否爲Deep watchers and watchers on Object/Arrays // 由於雖然對象引用相同,可是可能內層屬性有變更, // 可是又存在一種特殊狀況,若是是對象引用相同,但爲淺層更新(this.shallow爲true), // 則必定不多是內層屬性變更的這種狀況(由於他們只是_digest引發的watcher"無辜"update),因此不用執行後續操做 if ( value !== this.value || // Deep watchers and watchers on Object/Arrays should fire even // when the value is the same, because the value may // have mutated; but only do so if this is a // non-shallow update (caused by a vm digest). ((isObject(value) || this.deep) && !this.shallow) ) { // set new value var oldValue = this.value this.value = value } else { // this.cb就是watcher構造過程當中傳入的那個參數,其基本就是指令的update方法 this.cb.call(this.vm, value, oldValue) } } this.queued = this.shallow = false } }
能夠看到run其實就是先執行了一次this.get(),求出了表達式的最新值,並訂閱了可能出現的新依賴,而後執行了this.cb。this.cb是watcher構造函數中傳入的第三個形參。
咱們回憶一下指令的_bind函數中在用watcher構造函數創造新的watcher的時候傳入的參數:
//指令的_bind方法 // 處理一下本來的update函數,加入lock判斷 this._update = function (val, oldVal) { if (!dir._locked) { dir.update(val, oldVal) } } var watcher = this._watcher = new Watcher( this.vm, this.expression, this._update, // callback { filters: this.filters, twoWay: this.twoWay, deep: this.deep, preProcess: preProcess, postProcess: postProcess, scope: this._scope } )
很簡單了,其實就是加入了_locked判斷後的指令的update方法(通常指令都是未鎖住的)。而咱們以前就已經舉例講述過指令的update方法。他完成的就是dom更新的具體操做。
好了,其實批處理就是個很好理解的東西,我把收到notify的watcher存放到一個數組裏,在本輪事件循環結束後遍歷數組,取出來一個個執行run方法,也即求出新值,訂閱新依賴,而後執行對應指令的update的方法,將新值更新做用到dom裏。
我已經介紹完了Vue的大致流程,Vue爲全部須要綁定到數據的指令都創建了一個watcher,watcher跟指令一一對應,watcher最終又精確的依賴到數據上,即便是數組內嵌對象這樣的複雜狀況。因此在小量數據更新時,能夠作到極其精確、微量的dom更新。
可是這種方式也有其弊端,在大量數組渲染時,一方面須要遍歷數據defineReactive,一方面須要將數組元素轉爲scope(一個既裝載了數組元素的內容,又繼承了其父級vm實例的對象),另外一方面全部須要響應式訂閱的dom也確定是O(n)規模,所以必需要創建O(n)個watcher,執行每一個watcher的依賴訂閱和求值過程。
上述3個O(n)步驟決定了Vue在啓動階段的性能開銷不小,同時,在大數據量的數組替換狀況下,新數組的defineReactive,依賴的退訂、重訂過程,和watcher的對應dom更新也都是O(n)級別。雖然最重的確定是dom更新部分,但其實前二者也依然會有必定的性能開銷。而基於髒檢查的Angular而言,其不會有那麼多的watcher產生變更,也不會有上述前兩個過程,所以會有必定的性能優點。
爲了知足大量數組變更的性能需求,track-by的提出就顯得頗有必要,最大可能的重用原來的數據和依賴,只執行O(data change)級別的defineReactive、依賴的退訂、重訂、dom更新,因此合理優化和複用狀況,Vue就具備了很高的性能。咱們熟悉了源碼以後能夠從內部層面進行分析,而不是對於各個框架的性能瞭解停留在他們的宣傳層面。
後續應該還有3篇左右的文章用來介紹網上資料較少的內容:
計算屬性部分,即lazy watcher相關內容
Vue.set和delele中用到的vm._digest(), 即shallow update相關東西
v-for指令的實現,涉及diff算法
這篇文章很是長(比我本科的畢業論文都長?),很是感謝你能看完。Vue源碼較長,由於做者提供的功能很是多,因此要處理的edge case就不少,而要想深刻了解Vue,源碼閱讀是繞不開的一座大山。源碼閱讀過程當中不少時候不是看不懂js,而是搞不懂做者這麼寫的目的,我本身模擬多種狀況,調試、分析了不少次,消耗較多精力,但願能幫到一樣在閱讀源碼的你。
原發於個人我的博客:Chuck Liu的我的博客