寫過 Vue 的同窗確定體驗過, .vue
這種單文件組件有多麼方便。可是咱們也知道,Vue 底層是經過虛擬 DOM 來進行渲染的,那麼 .vue
文件的模板究竟是怎麼轉換成虛擬 DOM 的呢?這一塊對我來講一直是個黑盒,以前也沒有深刻研究過,今天打算一探究竟。html
Vue 3 發佈在即,原本想着直接看看 Vue 3 的模板編譯,可是我打開 Vue 3 源碼的時候,發現我好像連 Vue 2 是怎麼編譯模板的都不知道。從小魯迅就告訴咱們,不能一口吃成一個胖子,那我只能回頭看看 Vue 2 的模板編譯源碼,至於 Vue 3 就留到正式發佈的時候再看。vue
不少人使用 Vue 的時候,都是直接經過 vue-cli 生成的模板代碼,並不知道 Vue 其實提供了兩個構建版本。node
vue.js
: 完整版本,包含了模板編譯的能力;vue.runtime.js
: 運行時版本,不提供模板編譯能力,須要經過 vue-loader 進行提早編譯。
簡單來講,就是若是你用了 vue-loader ,就可使用 vue.runtime.min.js
,將模板編譯的過程交過 vue-loader,若是你是在瀏覽器中直接經過 script
標籤引入 Vue,須要使用 vue.min.js
,運行的時候編譯模板。web
瞭解了 Vue 的版本,咱們看看 Vue 完整版的入口文件(src/platforms/web/entry-runtime-with-compiler.js
)。vue-cli
// 省略了部分代碼,只保留了關鍵部分 import { compileToFunctions } from './compiler/index' const mount = Vue.prototype.$mount Vue.prototype.$mount = function (el) { const options = this.$options // 若是沒有 render 方法,則進行 template 編譯 if (!options.render) { let template = options.template if (template) { // 調用 compileToFunctions,編譯 template,獲得 render 方法 const { render, staticRenderFns } = compileToFunctions(template, { shouldDecodeNewlines, shouldDecodeNewlinesForHref, delimiters: options.delimiters, comments: options.comments }, this) // 這裏的 render 方法就是生成生成虛擬 DOM 的方法 options.render = render } } return mount.call(this, el, hydrating) }
再看看 ./compiler/index
文件的 compileToFunctions
方法從何而來。express
import { baseOptions } from './options' import { createCompiler } from 'compiler/index' // 經過 createCompiler 方法生成編譯函數 const { compile, compileToFunctions } = createCompiler(baseOptions) export { compile, compileToFunctions }
後續的主要邏輯都在 compiler
模塊中,這一塊有些繞,由於本文不是作源碼分析,就不貼整段源碼了。簡單看看這一段的邏輯是怎麼樣的。數組
export function createCompiler(baseOptions) { const baseCompile = (template, options) => { // 解析 html,轉化爲 ast const ast = parse(template.trim(), options) // 優化 ast,標記靜態節點 optimize(ast, options) // 將 ast 轉化爲可執行代碼 const code = generate(ast, options) return { ast, render: code.render, staticRenderFns: code.staticRenderFns } } const compile = (template, options) => { const tips = [] const errors = [] // 收集編譯過程當中的錯誤信息 options.warn = (msg, tip) => { (tip ? tips : errors).push(msg) } // 編譯 const compiled = baseCompile(template, options) compiled.errors = errors compiled.tips = tips return compiled } const createCompileToFunctionFn = () => { // 編譯緩存 const cache = Object.create(null) return (template, options, vm) => { // 已編譯模板直接走緩存 if (cache[template]) { return cache[template] } const compiled = compile(template, options) return (cache[key] = compiled) } } return { compile, compileToFunctions: createCompileToFunctionFn(compile) } }
能夠看到主要的編譯邏輯基本都在 baseCompile
方法內,主要分爲三個步驟:瀏覽器
const baseCompile = (template, options) => { // 解析 html,轉化爲 ast const ast = parse(template.trim(), options) // 優化 ast,標記靜態節點 optimize(ast, options) // 將 ast 轉化爲可執行代碼 const code = generate(ast, options) return { ast, render: code.render, staticRenderFns: code.staticRenderFns } }
首先看到 parse 方法,該方法的主要做用就是解析 HTML,並轉化爲 AST(抽象語法樹),接觸過 ESLint、Babel 的同窗確定對 AST 不陌生,咱們能夠先看看通過 parse 以後的 AST 長什麼樣。緩存
下面是一段普普統統的 Vue 模板:app
new Vue({ el: '#app', template: ` <div> <h2 v-if="message">{{message}}</h2> <button @click="showName">showName</button> </div> `, data: { name: 'shenfq', message: 'Hello Vue!' }, methods: { showName() { alert(this.name) } } })
通過 parse 以後的 AST:
AST 爲一個樹形結構的對象,每一層表示一個節點,第一層就是 div
(tag: "div"
)。div
的子節點都在 children 屬性中,分別是 h2
標籤、空行、button
標籤。咱們還能夠注意到有一個用來標記節點類型的屬性:type,這裏 div
的 type 爲 1,表示是一個元素節點,type 一共有三種類型:
在 h2
和 button
標籤之間的空行就是 type 爲 3 的文本節點,而 h2
標籤下就是一個表達式節點。
parse 的總體邏輯較爲複雜,咱們能夠先簡化一下代碼,看看 parse 的流程。
import { parseHTML } from './html-parser' export function parse(template, options) { let root parseHTML(template, { // some options... start() {}, // 解析到標籤位置開始的回調 end() {}, // 解析到標籤位置結束的回調 chars() {}, // 解析到文本時的回調 comment() {} // 解析到註釋時的回調 }) return root }
能夠看到 parse 主要經過 parseHTML 進行工做,這個 parseHTML 自己來自於開源庫:simple html parser,只不過通過了 Vue 團隊的一些修改,修復了相關 issue。
下面咱們一塊兒來理一理 parseHTML 的邏輯。
export function parseHTML(html, options) { let index = 0 let last,lastTag const stack = [] while(html) { last = html let textEnd = html.indexOf('<') // "<" 字符在當前 html 字符串開始位置 if (textEnd === 0) { // 一、匹配到註釋: <!-- --> if (/^<!\--/.test(html)) { const commentEnd = html.indexOf('-->') if (commentEnd >= 0) { // 調用 options.comment 回調,傳入註釋內容 options.comment(html.substring(4, commentEnd)) // 裁切掉註釋部分 advance(commentEnd + 3) continue } } // 二、匹配到條件註釋: <![if !IE]> <![endif]> if (/^<!\[/.test(html)) { // ... 邏輯與匹配到註釋相似 } // 三、匹配到 Doctype: <!DOCTYPE html> const doctypeMatch = html.match(/^<!DOCTYPE [^>]+>/i) if (doctypeMatch) { // ... 邏輯與匹配到註釋相似 } // 四、匹配到結束標籤: </div> const endTagMatch = html.match(endTag) if (endTagMatch) {} // 五、匹配到開始標籤: <div> const startTagMatch = parseStartTag() if (startTagMatch) {} } // "<" 字符在當前 html 字符串中間位置 let text, rest, next if (textEnd > 0) { // 提取中間字符 rest = html.slice(textEnd) // 這一部分當成文本處理 text = html.substring(0, textEnd) advance(textEnd) } // "<" 字符在當前 html 字符串中不存在 if (textEnd < 0) { text = html html = '' } // 若是存在 text 文本 // 調用 options.chars 回調,傳入 text 文本 if (options.chars && text) { // 字符相關回調 options.chars(text) } } // 向前推動,裁切 html function advance(n) { index += n html = html.substring(n) } }
上述代碼爲簡化後的 parseHTML,while
循環中每次截取一段 html 文本,而後經過正則判斷文本的類型進行處理,這就相似於編譯原理中經常使用的有限狀態機。每次拿到 "<"
字符先後的文本,"<"
字符前的就當作文本處理,"<"
字符後的經過正則判斷,可推算出有限的幾種狀態。
其餘的邏輯處理都不復雜,主要是開始標籤與結束標籤,咱們先看看關於開始標籤與結束標籤相關的正則。
const ncname = '[a-zA-Z_][\\w\\-\\.]*' const qnameCapture = `((?:${ncname}\\:)?${ncname})` const startTagOpen = new RegExp(`^<${qnameCapture}`)
這段正則看起來很長,可是理清以後也不是很難。這裏推薦一個正則可視化工具。咱們到工具上看看startTagOpen:
這裏比較疑惑的點就是爲何 tagName 會存在 :
,這個是 XML 的 命名空間,如今已經不多使用了,咱們能夠直接忽略,因此咱們簡化一下這個正則:
const ncname = '[a-zA-Z_][\\w\\-\\.]*' const startTagOpen = new RegExp(`^<${ncname}`) const startTagClose = /^\s*(\/?)>/ const endTag = new RegExp(`^<\\/${ncname}[^>]*>`)
除了上面關於標籤開始和結束的正則,還有一段用來提取標籤屬性的正則,真的是又臭又長。
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
把正則放到工具上就一目瞭然了,以 =
爲分界,前面爲屬性的名字,後面爲屬性的值。
理清正則後能夠更加方便咱們看後面的代碼。
while(html) { last = html let textEnd = html.indexOf('<') // "<" 字符在當前 html 字符串開始位置 if (textEnd === 0) { // some code ... // 四、匹配到標籤結束位置: </div> const endTagMatch = html.match(endTag) if (endTagMatch) { const curIndex = index advance(endTagMatch[0].length) parseEndTag(endTagMatch[1], curIndex, index) continue } // 五、匹配到標籤開始位置: <div> const startTagMatch = parseStartTag() if (startTagMatch) { handleStartTag(startTagMatch) continue } } } // 向前推動,裁切 html function advance(n) { index += n html = html.substring(n) } // 判斷是否標籤開始位置,若是是,則提取標籤名以及相關屬性 function parseStartTag () { // 提取 <xxx const start = html.match(startTagOpen) if (start) { const [fullStr, tag] = start const match = { attrs: [], start: index, tagName: tag, } advance(fullStr.length) let end, attr // 遞歸提取屬性,直到出現 ">" 或 "/>" 字符 while ( !(end = html.match(startTagClose)) && (attr = html.match(attribute)) ) { advance(attr[0].length) match.attrs.push(attr) } if (end) { // 若是是 "/>" 表示單標籤 match.unarySlash = end[1] advance(end[0].length) match.end = index return match } } } // 處理開始標籤 function handleStartTag (match) { const tagName = match.tagName const unary = match.unarySlash const len = match.attrs.length const attrs = new Array(len) for (let i = 0; i < l; i++) { const args = match.attrs[i] // 這裏的 三、四、5 分別對應三種不一樣複製屬性的方式 // 3: attr="xxx" 雙引號 // 4: attr='xxx' 單引號 // 5: attr=xxx 省略引號 const value = args[3] || args[4] || args[5] || '' attrs[i] = { name: args[1], value } } if (!unary) { // 非單標籤,入棧 stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs }) lastTag = tagName } if (options.start) { // 開始標籤的回調 options.start(tagName, attrs, unary, match.start, match.end) } } // 處理閉合標籤 function parseEndTag (tagName, start, end) { let pos, lowerCasedTagName if (start == null) start = index if (end == null) end = index if (tagName) { lowerCasedTagName = tagName.toLowerCase() } // 在棧內查找相同類型的未閉合標籤 if (tagName) { for (pos = stack.length - 1; pos >= 0; pos--) { if (stack[pos].lowerCasedTag === lowerCasedTagName) { break } } } else { pos = 0 } if (pos >= 0) { // 關閉該標籤內的未閉合標籤,更新堆棧 for (let i = stack.length - 1; i >= pos; i--) { if (options.end) { // end 回調 options.end(stack[i].tag, start, end) } } // 堆棧中刪除已關閉標籤 stack.length = pos lastTag = pos && stack[pos - 1].tag } }
在解析開始標籤的時候,若是該標籤不是單標籤,會將該標籤放入到一個堆棧當中,每次閉合標籤的時候,會從棧頂向下查找同名標籤,直到找到同名標籤,這個操做會閉合同名標籤上面的全部標籤。接下來咱們舉個例子:
<div> <h2>test</h2> <p> <p> </div>
在解析了 div 和 h2 的開始標籤後,棧內就存在了兩個元素。h2 閉合後,就會將 h2 出棧。而後會解析兩個未閉合的 p 標籤,此時,棧內存在三個元素(div、p、p)。若是這個時候,解析了 div 的閉合標籤,除了將 div 閉合外,div 內兩個未閉合的 p 標籤也會跟隨閉合,此時棧被清空。
爲了便於理解,特意錄製了一個動圖,以下:
理清了 parseHTML 的邏輯後,咱們回到調用 parseHTML 的位置,調用該方法的時候,一共會傳入四個回調,分別對應標籤的開始和結束、文本、註釋。
parseHTML(template, { // some options... // 解析到標籤位置開始的回調 start(tag, attrs, unary) {}, // 解析到標籤位置結束的回調 end(tag) {}, // 解析到文本時的回調 chars(text: string) {}, // 解析到註釋時的回調 comment(text: string) {} })
首先看解析到開始標籤時,會生成一個 AST 節點,而後處理標籤上的屬性,最後將 AST 節點放入樹形結構中。
function makeAttrsMap(attrs) { const map = {} for (let i = 0, l = attrs.length; i < l; i++) { const { name, value } = attrs[i] map[name] = value } return map } function createASTElement(tag, attrs, parent) { const attrsList = attrs const attrsMap = makeAttrsMap(attrsList) return { type: 1, // 節點類型 tag, // 節點名稱 attrsMap, // 節點屬性映射 attrsList, // 節點屬性數組 parent, // 父節點 children: [], // 子節點 } } const stack = [] let root // 根節點 let currentParent // 暫存當前的父節點 parseHTML(template, { // some options... // 解析到標籤位置開始的回調 start(tag, attrs, unary) { // 建立 AST 節點 let element = createASTElement(tag, attrs, currentParent) // 處理指令: v-for v-if v-once processFor(element) processIf(element) processOnce(element) processElement(element, options) // 處理 AST 樹 // 根節點不存在,則設置該元素爲根節點 if (!root) { root = element checkRootConstraints(root) } // 存在父節點 if (currentParent) { // 將該元素推入父節點的子節點中 currentParent.children.push(element) element.parent = currentParent } if (!unary) { // 非單標籤須要入棧,且切換當前父元素的位置 currentParent = element stack.push(element) } } })
標籤結束的邏輯就比較簡單了,只須要去除棧內最後一個未閉合標籤,進行閉合便可。
parseHTML(template, { // some options... // 解析到標籤位置結束的回調 end() { const element = stack[stack.length - 1] const lastNode = element.children[element.children.length - 1] // 處理尾部空格的狀況 if (lastNode && lastNode.type === 3 && lastNode.text === ' ') { element.children.pop() } // 出棧,重置當前的父節點 stack.length -= 1 currentParent = stack[stack.length - 1] } })
處理完標籤後,還須要對標籤內的文本進行處理。文本的處理分兩種狀況,一種是帶表達式的文本,還一種就是純靜態的文本。
parseHTML(template, { // some options... // 解析到文本時的回調 chars(text) { if (!currentParent) { // 文本節點外若是沒有父節點則不處理 return } const children = currentParent.children text = text.trim() if (text) { // parseText 用來解析表達式 // delimiters 表示表達式標識符,默認爲 ['{{', '}}'] const res = parseText(text, delimiters)) if (res) { // 表達式 children.push({ type: 2, expression: res.expression, tokens: res.tokens, text }) } else { // 靜態文本 children.push({ type: 3, text }) } } } })
下面咱們看看 parseText 如何解析表達式。
// 構造匹配表達式的正則 const buildRegex = delimiters => { const open = delimiters[0] const close = delimiters[1] return new RegExp(open + '((?:.|\\n)+?)' + close, 'g') } function parseText (text, delimiters){ // delimiters 默認爲 {{ }} const tagRE = buildRegex(delimiters || ['{{', '}}']) // 未匹配到表達式,直接返回 if (!tagRE.test(text)) { return } const tokens = [] const rawTokens = [] let lastIndex = tagRE.lastIndex = 0 let match, index, tokenValue while ((match = tagRE.exec(text))) { // 表達式開始的位置 index = match.index // 提取表達式開始位置前面的靜態字符,放入 token 中 if (index > lastIndex) { rawTokens.push(tokenValue = text.slice(lastIndex, index)) tokens.push(JSON.stringify(tokenValue)) } // 提取表達式內部的內容,使用 _s() 方法包裹 const exp = match[1].trim() tokens.push(`_s(${exp})`) rawTokens.push({ '@binding': exp }) lastIndex = index + match[0].length } // 表達式後面還有其餘靜態字符,放入 token 中 if (lastIndex < text.length) { rawTokens.push(tokenValue = text.slice(lastIndex)) tokens.push(JSON.stringify(tokenValue)) } return { expression: tokens.join('+'), tokens: rawTokens } }
首先經過一段正則來提取表達式:
看代碼可能有點難,咱們直接看例子,這裏有一個包含表達式的文本。
<div>是否登陸:{{isLogin ? '是' : '否'}}</div>
經過上述一些列處理,咱們就獲得了 Vue 模板的 AST。因爲 Vue 是響應式設計,因此拿到 AST 以後還須要進行一系列優化,確保靜態的數據不會進入虛擬 DOM 的更新階段,以此來優化性能。
export function optimize (root, options) { if (!root) return // 標記靜態節點 markStatic(root) }
簡單來講,就是把因此靜態節點的 static 屬性設置爲 true。
function isStatic (node) { if (node.type === 2) { // 表達式,返回 false return false } if (node.type === 3) { // 靜態文本,返回 true return true } // 此處省略了部分條件 return !!( !node.hasBindings && // 沒有動態綁定 !node.if && !node.for && // 沒有 v-if/v-for !isBuiltInTag(node.tag) && // 不是內置組件 slot/component !isDirectChildOfTemplateFor(node) && // 不在 template for 循環內 Object.keys(node).every(isStaticKey) // 非靜態節點 ) } function markStatic (node) { node.static = isStatic(node) if (node.type === 1) { // 若是是元素節點,須要遍歷全部子節點 for (let i = 0, l = node.children.length; i < l; i++) { const child = node.children[i] markStatic(child) if (!child.static) { // 若是有一個子節點不是靜態節點,則該節點也必須是動態的 node.static = false } } } }
獲得優化的 AST 以後,就須要將 AST 轉化爲 render 方法。仍是用以前的模板,先看看生成的代碼長什麼樣:
<div> <h2 v-if="message">{{message}}</h2> <button @click="showName">showName</button> </div>
{ render: "with(this){return _c('div',[(message)?_c('h2',[_v(_s(message))]):_e(),_v(" "),_c('button',{on:{"click":showName}},[_v("showName")])])}" }
將生成的代碼展開:
with (this) { return _c( 'div', [ (message) ? _c('h2', [_v(_s(message))]) : _e(), _v(' '), _c('button', { on: { click: showName } }, [_v('showName')]) ]) ; }
看到這裏一堆的下劃線確定很懵逼,這裏的 _c
對應的是虛擬 DOM 中的 createElement
方法。其餘的下劃線方法在 core/instance/render-helpers
中都有定義,每一個方法具體作了什麼不作展開。
具體轉化方法就是一些簡單的字符拼接,下面是簡化了邏輯的部分,不作過多講述。
export function generate(ast, options) { const state = new CodegenState(options) const code = ast ? genElement(ast, state) : '_c("div")' return { render: `with(this){return ${code}}`, staticRenderFns: state.staticRenderFns } } export function genElement (el, state) { let code const data = genData(el, state) const children = genChildren(el, state, true) code = `_c('${el.tag}'${ data ? `,${data}` : '' // data }${ children ? `,${children}` : '' // children })` return code }
理清了 Vue 模板編譯的整個過程,重點都放在瞭解析 HTML 生成 AST 的部分。本文只是大體講述了主要流程,其中省略了特別多的細節,好比:對 template/slot 的處理、指令的處理等等,若是想了解其中的細節能夠直接閱讀源碼。但願你們在閱讀這篇文章後有所收穫。