來源html
tinycompilevue
關於vue的內部原理其實有不少個重要的部分,變化偵測,模板編譯,virtualDOM,總體運行流程等。 node
以前寫過一篇《深刻淺出 - vue變化偵測原理》 講了關於變化偵測的實現原理。git
那今天主要把 模板編譯這部分的實現原理單獨拿出來說一講。github
本文我可能不會在文章中說太多細節部分的處理,我會把 vue 對模板編譯這部分的總體原理講清楚,主要是讓讀者讀完文章後對模板編譯的總體實現原理有一個清晰的思路和理解。express
關於 Vue 編譯原理這塊的總體邏輯主要分三個部分,也能夠說是分三步,這三個部分是有先後關係的:瀏覽器
-
第一步是將 模板字符串 轉換成 element ASTs(解析器)安全
-
第二步是對 AST 進行靜態節點標記,主要用來作虛擬DOM的渲染優化(優化器)dom
-
第三步是 使用 element ASTs 生成 render 函數代碼字符串(代碼生成器)函數
解析器
解析器主要乾的事是將 模板字符串 轉換成 element ASTs,例如:
< div> < p>{{name}}</ p></ div>
上面這樣一個簡單的 模板 轉換成 element AST 後是這樣的:
{ tag :"div"type :1, staticRoot :false, static :false, plain :true, parent :undefined, attrsList :[], attrsMap :{}, children :[ { tag :"p"type :1, staticRoot :false, static :false, plain :true, parent :{tag :"div", ...}, attrsList :[], attrsMap :{}, children :[{ type :2, text :"{{name}}", static :false, expression :"_s(name)"}] } ]}
咱們先用這個簡單的例子來講明這個解析器的內部究竟發生了什麼。
這段模板字符串會扔到 while 中去循環,而後 一段一段的截取,把截取到的 每一小段字符串進行解析,直到最後截沒了,也就解析完了。
上面這個簡單的模板截取的過程是這樣的:
< div> < p>{{name}}</ p></ div> < p>{{name}}</ p></ div> < p>{{name}}</ p></ div> {{name}}</ p></ div> </ p></ div> </ div> </ div>
那是根據什麼截的呢?換句話說截取字符串有什麼規則麼?
固然有
只要判斷模板字符串是否是以 < 開頭咱們就能夠知道咱們接下來要截取的這一小段字符串是 標籤 仍是 文本。
舉個 🌰 :
<div></div> 這樣的一段字符串是以 < 開頭的,那麼咱們經過正則把 <div> 這一部分 match 出來,就能夠拿到這樣的數據:
{ tagName :'div', attrs :[], unarySlash :'', start :0, end :5}
好奇如何用正則解析出 tagName 和 attrs 等信息的同窗能夠看下面這個demo代碼:
constncname='[a-zA-Z_][w-.]*'constqnameCapture=`((?:${ncname}:)?${ncname})`conststartTagOpen=newRegExp( `^<${qnameCapture}`) conststartTagClose=/^s*(/?)>/lethtml =`<div></div>`letindex =0conststart=html. match(startTagOpen) constmatch={ tagName :start[ 1], attrs :[], start :0}html =html. substring(start[ 0]. length)index +=start[ 0]. lengthletend, attr while( !(end =html. match(startTagClose)) &&(attr =html. match(attribute))) { html =html. substring(attr[ 0]. length) index +=attr[ 0]. lengthmatch. attrs. push(attr)} if(end) { match. unarySlash=end[ 1] html =html. substring(end[ 0]. length) index +=end[ 0]. lengthmatch. end=index} console. log(match) Stack
用正則把 開始標籤 中包含的數據(attrs, tagName 等)解析出來以後還要作一個很重要的事,就是要維護一個 stack。
那這個 stack 是用來幹什麼的呢?
這個 stack 是用來記錄一個層級關係的,用來記錄DOM的深度。
更準確的說,當解析到一個 開始標籤 或者 文本,不管是什麼, stack 中的最後一項,永遠是當前正在被解析的節點的 parentNode 父節點。
經過 stack 解析器就能夠把當前解析到的節點 push 到 父節點的 children 中。
也能夠把當前正在解析的節點的 parent 屬性設置爲 父節點。
事實上也確實是這麼作的。
但並非只要解析到一個標籤的開始部分就把當前標籤 push 到 stack 中。
由於在 HTML 中有一種 自閉和標籤,好比 input。
<input /> 這種 自閉和的標籤 是不須要 push 到 stack 中的,由於 input 並不存在子節點。
因此當解析到一個標籤的開始時,要判斷當前被解析的標籤是不是自閉和標籤,若是不是自閉和標籤才 push 到 stack 中。
if( !unary) { currentParent =element stack. push(element)}
如今有了 DOM 的層級關係,也能夠解析出DOM的 開始標籤,這樣每解析一個 開始標籤 就生成一個 ASTElement (存儲當前標籤的attrs,tagName 等信息的object)
而且把當前的 ASTElement push 到 parentNode 的 children 中,同時給當前 ASTElement 的 parent屬性設置爲 stack 中的最後一項
currentParent. children. push(element) element. parent=currentParent < 開頭的幾種狀況
但並非全部以 < 開頭的字符串都是 開始標籤,以 < 開頭的字符串有如下幾種狀況:
-
開始標籤 <div>
-
結束標籤 </div>
-
HTML註釋 <!-- 我是註釋 -->
-
Doctype <!DOCTYPE html>
-
條件註釋(Downlevel-revealed conditional comment)
固然咱們解析器在解析的過程當中遇到的最多的是 開始標籤 結束標籤 和 註釋
截取文本
咱們繼續上面的例子解析,div 的 開始標籤 解析以後剩餘的模板字符串是下面的樣子:
< p>{{name}}</ p></ div>
這一次咱們在解析發現 模板字符串 不是以 < 開頭了。
那麼若是模板字符串不是以 < 開頭的怎麼處理呢??
其實若是字符串不是以 < 開頭可能會出現這麼幾種狀況:
我是text < div></ div>
或者:
我是text </ p>
不管是哪一種狀況都會將標籤前面的文本部分解析出來,截取這段文本其實並不難,看下面的例子:
//能夠直接將本 demo 放到瀏覽器 console 中去執行consthtml='我是text </p>'lettextEnd =html. indexOf( '<') consttext=html. substring( 0, textEnd) console. log(text)
固然 vue 對文本的截取不僅是這麼簡單,vue對文本的截取作了很安全的處理,若是 < 是文本的一部分,那上面 DEMO 中截取的內容就不是咱們想要的,例如這樣的:
a < b </ p>
若是是這樣的文本,上面的 demo 確定就掛了,截取出的文本就會遺漏一部分,而 vue 對這部分是進行了處理的,看下面的代碼:
lettextEnd =html. indexOf( '<') lettext, rest, next if(textEnd >=0) { rest =html. slice(textEnd) //剩餘部分的 HTML 不符合標籤的格式那確定就是文本//而且仍是以 < 開頭的文本while( !endTag. test(rest) &&!startTagOpen. test(rest) &&!comment. test(rest) &&!conditionalComment. test(rest) ) { //< in plain text, be forgiving and treat it as textnext =rest. indexOf( '<', 1) if(next <0) breaktextEnd +=next rest =html. slice(textEnd) } text =html. substring( 0, textEnd) html =html. substring( 0, textEnd)}
這段代碼的邏輯是若是文本截取完以後,剩餘的 模板字符串 開頭不符合標籤的格式規則,那麼確定就是有沒截取完的文本
這個時候只須要循環把 textEnd 累加,直到剩餘的 模板字符串 符合標籤的規則以後在一次性把 text 從 模板字符串 中截取出來就行了。
繼續上面的例子,當前剩餘的 模板字符串 是這個樣子的:
< p>{{name}}</ p></ div>
截取以後剩餘的 模板字符串 是這個樣子的:
< p>{{name}}</ p></ div>
被截取出來的文本是這樣的:
"n"
截取以後就須要對文本進行解析,不過在解析文本以前須要進行預處理,也就是先簡單加工一下文本,vue 是這樣作的:
constchildren=currentParent. childrentext =inPre ||text. trim() ?isTextTag(currentParent) ?text :decodeHTMLCached(text) //only preserve whitespace if its not right after a starting tag:preserveWhitespace &&children. length?'':''
這段代碼的意思是:
-
若是文本不爲空,判斷父標籤是否是或style,
-
若是是則什麼都無論,
-
若是不是須要 decode 一下編碼,使用github上的 he 這個類庫的 decodeHTML 方法
-
若是文本爲空,判斷有沒有兄弟節點,也就是 parent.children.length 是否是爲 0
-
若是大於0 返回 ' '
-
若是爲 0 返回 ''
結果發現這一次的 text 正好命中最後的那個 '',因此這一次就什麼都不用作繼續下一輪解析就好
繼續上面的例子,如今的 模板字符串 變是這個樣子:
< p>{{name}}</ p></ div>
接着解析 <p>,解析流程和上面的 <div> 同樣就不說了,直接繼續:
{{name}}</ p></ div>
經過上面寫的文本的截取方式這一次截取出來的文本是這個樣子的 "{{name}}"
解析文本
其實解析文本節點並不難,只須要將文本節點 push 到 currentParent.children.push(ast) 就好了。
可是帶變量的文本和不帶變量的純文本是不一樣的處理方式。
帶變量的文本是指 Hello {{ name }} 這個 name 就是變量。
不帶變量的文本是這樣的 Hello Berwin 這種沒有訪問數據的純文本。
純文本比較簡單,直接將 文本節點的ast push 到 parent 節點的 children 中就好了,例如:
children. push({ type :3, text :'我是純文本'})
而帶變量的文本要多一個解析文本變量的操做:
constexpression=parseText(text, delimiters) //對變量解析 {{name}} => _s(name)children. push({ type :2, expression, text})
上面例子中 "{{name}}" 是一個帶變量的文本,通過 parseText 解析後 expression 是 _s(name),因此最後 push 到 currentParent.children 中的節點是這個樣子的:
{ expression :"_s(name)", text :"{{name}}", type :2} 結束標籤的處理
如今文本解析完以後,剩餘的 模板字符串 變成了這個樣子:
</ p></ div>
這一次仍是用上面說的辦法,html.indexOf('<') === 0,發現是 < 開頭的,而後用正則去 match 發現符合 結束標籤的格式,把它截取出來。
而且還要作一個處理是用當前標籤名在 stack 從後往前找,將找到的 stack 中的位置日後的全部標籤所有刪除(意思是,已經解析到當前的結束標籤,那麼它的子集確定都是解析過的,試想一下當前標籤都關閉了,它的子集確定也都關閉了,因此須要把當前標籤位置日後從 stack中都清掉)
結束標籤不須要解析,只須要將 stack 中的當前標籤刪掉就好。
雖然不用解析,但 vue 仍是作了一個優化處理,children 中的最後一項若是是空格 " ",則刪除最後這一項:
if(lastNode &&lastNode. type===3&&lastNode. text===''&&!inPre) { element. children. pop()}
由於最後這一項空格是沒有用的,舉個例子:
< ul> < li></ li></ ul>
上面例子中解析成 element ASTs以後 ul 的結束標籤 </ul> 和 li 的結束標籤 </li> 之間有一個空格,這個空格也屬於文本節點在 ul 的 children 中,這個空格是沒有用的,把這個空格刪掉每次渲染dom都會少渲染一個文本節點,能夠節省必定的性能開銷。
如今剩餘的 模板字符串 已經很少了,是下面的樣子:
</ div>
而後解析文本,就是一個其實就是一個空格的文本節點。
而後再一次解析結束標籤 </div>
</ div>
解析完畢退出 while 循環。
解析完以後拿到的 element ASTs 就是文章開頭寫的那樣。
總結一下
其實這樣一個模板解析器的原理不是特別難,主要就是兩部份內容,一部分是 截取 字符串,一部分是對截取以後的字符串作 解析
每截取一段標籤的開頭就 push 到 stack中,解析到標籤的結束就 pop 出來,當全部的字符串都截沒了也就解析完了。
上文中的例子是比較簡單的,不涉及一些循環啊,什麼的,註釋的處理這些也都沒有涉及到,但其實這篇文章中想表達的內容也不是來扣細節的,若是扣細節可能要寫一本小書纔夠,一篇文章的字數可能只夠把一個大致的邏輯給你們講清楚,但願同窗們見諒,若是對細節感興趣能夠在下面評論,我們一塊兒討論共同窗習進步~
優化器
優化器的目標是找出那些靜態節點並打上標記,而靜態節點指的是 DOM 不須要發生變化的節點,例如:
< p>我是靜態節點,我不須要發生變化</ p>
標記靜態節點有兩個好處:
-
每次從新渲染的時候不須要爲靜態節點建立新節點
-
在 Virtual DOM 中 patching 的過程能夠被跳過
優化器的實現原理主要分兩步:
-
第一步:用遞歸的方式將全部節點添加 static 屬性,標識是否是靜態節點
-
第二步:標記全部靜態根節點
什麼是靜態根節點? 答:子節點全是靜態節點的節點就是靜態根節點,例如:
< ul> < li>我是靜態節點,我不須要發生變化</ li> < li>我是靜態節點2,我不須要發生變化</ li> < li>我是靜態節點3,我不須要發生變化</ li></ ul>
ul 就是靜態根節點。
如何將全部節點標記 static 屬性?
vue 判斷一個節點是否是靜態節點的作法其實並不難:
-
先根據自身是否是靜態節點作一個標記 node.static = isStatic(node)
-
而後在循環 children,若是 children 中出現了哪怕一個節點不是靜態節點,在將當前節點的標記修改爲 false: node.static = false。
如何判斷一個節點是否是靜態節點?
也就是說 isStatic 這個函數是如何判斷靜態節點的?
functionisStatic( node:ASTNode): boolean { if( node. type===2) { //expressionreturnfalse} if( node. type===3) { //textreturntrue} return!!( node. pre||( !node. hasBindings&&//no dynamic bindings!node. if&&!node. for&&//not v-if or v-for or v-else!isBuiltInTag( node. tag) &&//not a built-inisPlatformReservedTag( node. tag) &&//not a component!isDirectChildOfTemplateFor(node) &&Object. keys(node). every(isStaticKey) ))}
先解釋一下,在上文講的解析器中將 模板字符串 解析成 AST 的時候,會根據不一樣的文本類型設置一個 type:
type | 說明 |
---|---|
1 | 元素節點 |
2 | 帶變量的動態文本節點 |
3 | 不帶變量的純文本節點 |
因此上面 isStatic 中的邏輯很明顯,若是 type === 2 那確定不是 靜態節點 返回 false,若是 type === 3 那就是靜態節點,返回 true。
那若是 type === 1,就有點複雜了,元素節點判斷是否是靜態節點的條件不少,我們先一個個看。
首先若是 node.pre 爲 true 直接認爲當前節點是靜態節點,關於 node.pre 是什麼 請狠狠的點擊我。
其次 node.hasBindings 不能爲 true。
node.hasBindings 屬性是在解析器轉換 AST 時設置的,若是當前節點的 attrs 中,有 v-、@、:開頭的 attr,就會把 node.hasBindings 設置爲 true。
constdirRE=/^v-|^@|^:/if( dirRE. test(attr)) { //mark element as dynamicel. hasBindings=true}
而且元素節點不能有 if 和 for屬性。
node.if 和 node.for 也是在解析器轉換 AST 時設置的。
在解析的時候發現節點使用了 v-if,就會在解析的時候給當前節點設置一個 if 屬性。
就是說元素節點不能使用 v-if v-for v-else 等指令。
而且元素節點不能是 slot 和 component。
而且元素節點不能是組件。
例如:
< List></ List>
不能是上面這樣的自定義組件
而且元素節點的父級節點不能是帶 v-for 的 template,查看詳情 請狠狠的點擊我。
而且元素節點上不能出現額外的屬性。
額外的屬性指的是不能出現 type
tag attrsList attrsMap plain parent children attrs staticClass staticStyle 這幾個屬性以外的其餘屬性,若是出現其餘屬性則認爲當前節點不是靜態節點。
只有符合上面全部條件的節點纔會被認爲是靜態節點。
如何標記全部節點?
上面講如何判斷單個節點是不是靜態節點,AST 是一棵樹,咱們如何把全部的節點都打上標記(static)呢?
還有一個問題是,判斷 元素節點是否是靜態節點不能光看它自身是否是靜態節點,若是它的子節點不是靜態節點,那就算它自身符合上面講的靜態節點的條件,它也不是靜態節點。
因此在 vue 中有這樣一行代碼:
for( leti =0, l =node. children. length; i <l; i ++) { constchild=node. children[i] markStatic(child) if( !child. static) { node. static=false}}
markStatic 能夠給節點標記,規則上面剛講過,vue.js 經過循環 children 打標記,而後每一個不一樣的子節點又會走相同的邏輯去循環它的 children 這樣遞歸下來全部的節點都會被打上標記。
而後在循環中判斷,若是某個子節點不是 靜態節點,那麼講當前節點的標記改成 false。
這樣一圈下來以後 AST 上的全部節點都被準確的打上了標記。
如何標記靜態根節點?
標記靜態根節點其實也是遞歸的過程。
vue 中的實現大概是這樣的:
functionmarkStaticRoots( node:ASTNode, isInFor:boolean) { if( node. type===1) { //For a node to qualify as a static root, it should have children that//are not just static text. Otherwise the cost of hoisting out will//outweigh the benefits and it's better off to just always render it fresh.if( node. static&&node. children. length&&!( node. children. length===1&&node. children[ 0]. type===3)) { node. staticRoot=truereturn} else{ node. staticRoot=false} if( node. children) { for( leti =0, l =node. children. length; i <l; i ++) { markStaticRoots( node. children[i], isInFor ||!!node. for) } } }}
這段代碼其實就一個意思:
當前節點是靜態節點,而且有子節點,而且子節點不是單個靜態文本節點這種狀況會將當前節點標記爲根靜態節點。
額,,可能有點繞口,從新解釋下。
上面咱們標記 靜態節點的時候有一段邏輯是隻有全部 子節點都是 靜態節點,當前節點纔是真正的 靜態節點。
因此這裏咱們若是發現一個節點是 靜態節點,那就能證實它的全部 子節點也都是靜態節點,而咱們要標記的是 靜態根節點,因此若是一個靜態節點只包含了一個文本節點那就不會被標記爲 靜態根節點。
其實這麼作也是爲了性能考慮,vue 在註釋中也說了,若是把一個只包含靜態文本的節點標記爲根節點,那麼它的成本會超過收益~
總結一下
總體邏輯其實就是遞歸 AST 這顆樹,而後將 靜態節點和 靜態根節點找到並打上標記。
代碼生成器
代碼生成器的做用是使用 element ASTs 生成 render 函數代碼字符串。
使用本文開頭舉的例子中的模板生成後的 AST 來生成 render 後是這樣的:
{ render :`with(this){return _c('div',[_c('p',[_v(_s(name))])])}`}
格式化後是這樣的:
with( this){ return_c( 'div', [ _c( 'p', [ _v( _s(name)) ] ) ] )}
生成後的代碼字符串中看到了有幾個函數調用 _c,_v,_s。
_c 對應的是 ,它的做用是建立一個元素。
-
第一個參數是一個HTML標籤名
-
第二個參數是元素上使用的屬性所對應的數據對象,可選項
-
第三個參數是 children
例如:
一個簡單的模板:
< ptitle= "Berwin"@ click= "c">1</ p>
生成後的代碼字符串是:
`with(this){return _c('p',{attrs:{"title":"Berwin"},on:{"click":c}},[_v("1")])}`
格式化後:
with( this){ return_c( 'p', { attrs :{ "title":"Berwin"}, on :{ "click":c} }, [ _v( "1")] )}
關於 想了解更多請狠狠的點擊我。
_v 的意思是建立一個文本節點。
_s 是返回參數中的字符串。
代碼生成器的整體邏輯其實就是使用 element ASTs 去遞歸,而後拼出這樣的 _c('div',[_c('p',[_v(_s(name))])]) 字符串。
那如何拼這個字符串呢??
請看下面的代碼:
functiongenElement( el:ASTElement, state:CodegenState) { constdata=el. plain?undefined:genData(el, state) constchildren=el. inlineTemplate?null:genChildren(el, state, true) letcode =`_c('${el.tag}'${data ?`,${data}`:''//data}${children ?`,${children}`:''//children})`returncode}
由於 _c 的參數須要 tagName、data 和 children。
因此上面這段代碼的主要邏輯就是用 genData 和 genChildren 獲取 data 和 children,而後拼到 _c中去,拼完後把拼好的 "_c(tagName, data, children)" 返回。
因此咱們如今比較關心的兩個問題:
-
data 如何生成的(genData 的實現邏輯)?
-
children 如何生成的(genChildren 的實現邏輯)?
咱們先看 genData 是怎樣的實現邏輯:
functiongenData( el:ASTElement, state:CodegenState): string { letdata ='{'//keyif( el. key) { data +=`key:${el.key},`} //refif( el. ref) { data +=`ref:${el.ref},`} if( el. refInFor) { data +=`refInFor:true,`} //preif( el. pre) { data +=`pre:true,`} //... 相似的還有不少種狀況data =data. replace( /,$/, '') +'}'returndata}
能夠看到,就是根據 AST 上當前節點上都有什麼屬性,而後針對不一樣的屬性作一些不一樣的處理,最後拼出一個字符串~
而後咱們在看看 genChildren 是怎樣的實現的:
functiongenChildren( el:ASTElement, state:CodegenState): string | void { constchildren=el. childrenif( children. length) { return`[${children.map(c=>genNode(c, state)).join(',')}]`}} functiongenNode( node:ASTNode, state:CodegenState): string { if( node. type===1) { returngenElement(node, state) } if( node. type===3&&node. isComment) { returngenComment(node) } else{ returngenText(node) }}
從上面代碼中能夠看出,生成 children 的過程其實就是循環 AST 中當前節點的 children,而後把每一項在從新按不一樣的節點類型去執行 genElement genComment genText。若是 genElement 中又有 children 在循環生成,如此反覆遞歸,最後一圈跑完以後能拿到一個完整的 render 函數代碼字符串,就是相似下面這個樣子。
"_c('div',[_c('p',[_v(_s(name))])])"
最後把生成的 code 裝到 with 裏。
exportfunctiongenerate( ast:ASTElement|void, options:CompilerOptions): CodegenResult { conststate=newCodegenState(options) //若是ast爲空,則建立一個空divconstcode=ast ?genElement(ast, state) :'_c("div")'return{ render :`with(this){return ${code}}`}}
關於代碼生成器的部分到這裏就說完了,其實源碼中遠不止這麼簡單,不少細節我都沒有去說,我只說了一個大致的流程,對具體細節感興趣的同窗能夠本身去看源碼瞭解詳情。
總結
本篇文章咱們說了 vue 對模板編譯的總體流程分爲三個部分:解析器(parser),優化器(optimizer)和代碼生成器(code generator)。
解析器(parser)的做用是將 模板字符串 轉換成 element ASTs。
優化器(optimizer)的做用是找出那些靜態節點和靜態根節點並打上標記。
代碼生成器(code generator)的做用是使用 element ASTs 生成 render函數代碼(generate render function code from element ASTs)。
用一張圖來表示:
解析器(parser)的原理是一小段一小段的去截取字符串,而後維護一個 stack 用來保存DOM深度,每截取到一段標籤的開始就 push 到 stack 中,當全部字符串都截取完以後也就解析出了一個完整的 AST。
優化器(optimizer)的原理是用遞歸的方式將全部節點打標記,表示是不是一個 靜態節點,而後再次遞歸一遍把 靜態根節點 也標記出來。
代碼生成器(code generator)的原理也是經過遞歸去拼一個函數執行代碼的字符串,遞歸的過程根據不一樣的節點類型調用不一樣的生成方法,若是發現是一顆元素節點就拼一個 _c(tagName, data, children) 的函數調用字符串,而後 data 和 children 也是使用 AST 中的屬性去拼字符串。
若是 children 中還有 children 則遞歸去拼。
最後拼出一個完整的 render 函數代碼。