關於vue的內部原理其實有很多個重要的部分,變化偵測,模板編譯,virtualDOM,整體運行流程等。
之前寫過一篇《深入淺出 - vue變化偵測原理》 講了關於變化偵測的實現原理。
那今天主要把 模板編譯這部分的實現原理單獨拿出來講一講。
本文我可能不會在文章中說太多細節部分的處理,我會把 vue 對模板編譯這部分的整體原理講清楚,主要是讓讀者讀完文章后對模板編譯的整體實現原理有一個清晰的思路和理解。
關於 Vue 編譯原理這塊的整體邏輯主要分三個部分,也可以說是分三步,這三個部分是有前后關系的:
-
第一步是將 模板字符串 轉換成 element ASTs(解析器)
-
第二步是對 AST 進行靜態節點標記,主要用來做虛擬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 函數代碼。