新聞中心
Vue 3 發(fā)布在即,本來想著直接看看 Vue 3 的模板編譯,但是我打開 Vue 3 源碼的時(shí)候,發(fā)現(xiàn)我好像連 Vue 2 是怎么編譯模板的都不知道。從小魯迅就告訴我們,不能一口吃成一個(gè)胖子,那我只能回頭看看 Vue 2 的模板編譯源碼,至于 Vue 3 就留到正式發(fā)布的時(shí)候再看。

創(chuàng)新互聯(lián)公司 - 雅安移動(dòng)機(jī)房,四川服務(wù)器租用,成都服務(wù)器租用,四川網(wǎng)通托管,綿陽服務(wù)器托管,德陽服務(wù)器托管,遂寧服務(wù)器托管,綿陽服務(wù)器托管,四川云主機(jī),成都云主機(jī),西南云主機(jī),雅安移動(dòng)機(jī)房,西南服務(wù)器托管,四川/成都大帶寬,成都機(jī)柜租用,四川老牌IDC服務(wù)商
Vue 的版本
很多人使用 Vue 的時(shí)候,都是直接通過 vue-cli 生成的模板代碼,并不知道 Vue 其實(shí)提供了兩個(gè)構(gòu)建版本。
- vue.js:完整版本,包含了模板編譯的能力;
- vue.runtime.js:運(yùn)行時(shí)版本,不提供模板編譯能力,需要通過 vue-loader 進(jìn)行提前編譯。
Vue不同構(gòu)建版本
完整版與運(yùn)行時(shí)版區(qū)別
簡(jiǎn)單來說,就是如果你用了 vue-loader ,就可以使用 vue.runtime.min.js,將模板編譯的過程交過 vue-loader,如果你是在瀏覽器中直接通過 script 標(biāo)簽引入 Vue,需要使用 vue.min.js,運(yùn)行的時(shí)候編譯模板。
編譯入口
- 了解了 Vue 的版本,我們看看 Vue 完整版的入口文件(src/platforms/web/entry-runtime-with-compiler.js)。
- // 省略了部分代碼,只保留了關(guān)鍵部分
- import { compileToFunctions } from './compiler/index'
- const mount = Vue.prototype.$mount
- Vue.prototype.$mount = function (el) {
- const options = this.$options
- // 如果沒有 render 方法,則進(jìn)行 template 編譯
- if (!options.render) {
- let template = options.template
- if (template) {
- // 調(diào)用 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 方法從何而來。
- import { baseOptions } from './options'
- import { createCompiler } from 'compiler/index'
- // 通過 createCompiler 方法生成編譯函數(shù)
- const { compile, compileToFunctions } = createCompiler(baseOptions)
- export { compile, compileToFunctions }
后續(xù)的主要邏輯都在 compiler 模塊中,這一塊有些繞,因?yàn)楸疚牟皇亲鲈创a分析,就不貼整段源碼了。簡(jiǎn)單看看這一段的邏輯是怎么樣的。
- export function createCompiler(baseOptions) {
- const baseCompile = (template, options) => {
- // 解析 html,轉(zhuǎn)化為 ast
- const ast = parse(template.trim(), options)
- // 優(yōu)化 ast,標(biāo)記靜態(tài)節(jié)點(diǎn)
- optimize(ast, options)
- // 將 ast 轉(zhuǎn)化為可執(zhí)行代碼
- const code = generate(ast, options)
- return {
- ast,
- render: code.render,
- staticRenderFns: code.staticRenderFns
- }
- }
- const compile = (template, options) => {
- const tips = []
- const errors = []
- // 收集編譯過程中的錯(cuò)誤信息
- 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 方法內(nèi),主要分為三個(gè)步驟:
模板編譯,將模板代碼轉(zhuǎn)化為 AST;
優(yōu)化 AST,方便后續(xù)虛擬 DOM 更新;
生成代碼,將 AST 轉(zhuǎn)化為可執(zhí)行的代碼;
- const baseCompile = (template, options) => {
- // 解析 html,轉(zhuǎn)化為 ast
- const ast = parse(template.trim(), options)
- // 優(yōu)化 ast,標(biāo)記靜態(tài)節(jié)點(diǎn)
- optimize(ast, options)
- // 將 ast 轉(zhuǎn)化為可執(zhí)行代碼
- const code = generate(ast, options)
- return {
- ast,
- render: code.render,
- staticRenderFns: code.staticRenderFns
- }
- }
parse
AST
首先看到 parse 方法,該方法的主要作用就是解析 HTML,并轉(zhuǎn)化為 AST(抽象語法樹),接觸過 ESLint、Babel 的同學(xué)肯定對(duì) AST 不陌生,我們可以先看看經(jīng)過 parse 之后的 AST 長(zhǎng)什么樣。
下面是一段普普通通的 Vue 模板:
- new Vue({
- el: '#app',
- template: `
{{message}}
- `,
- data: {
- name: 'shenfq',
- message: 'Hello Vue!'
- },
- methods: {
- showName() {
- alert(this.name)
- }
- }
- })
經(jīng)過 parse 之后的 AST:
Template AST
AST 為一個(gè)樹形結(jié)構(gòu)的對(duì)象,每一層表示一個(gè)節(jié)點(diǎn),第一層就是 div(tag: "div")。div 的子節(jié)點(diǎn)都在 children 屬性中,分別是 h2 標(biāo)簽、空行、button 標(biāo)簽。我們還可以注意到有一個(gè)用來標(biāo)記節(jié)點(diǎn)類型的屬性:type,這里 div 的 type 為 1,表示是一個(gè)元素節(jié)點(diǎn),type 一共有三種類型:
元素節(jié)點(diǎn);
表達(dá)式;
文本;
在 h2 和 button 標(biāo)簽之間的空行就是 type 為 3 的文本節(jié)點(diǎn),而 h2 標(biāo)簽下就是一個(gè)表達(dá)式節(jié)點(diǎn)。
解析HTML
parse 的整體邏輯較為復(fù)雜,我們可以先簡(jiǎn)化一下代碼,看看 parse 的流程。
- import { parseHTML } from './html-parser'
- export function parse(template, options) {
- let root
- parseHTML(template, {
- // some options...
- start() {}, // 解析到標(biāo)簽位置開始的回調(diào)
- end() {}, // 解析到標(biāo)簽位置結(jié)束的回調(diào)
- chars() {}, // 解析到文本時(shí)的回調(diào)
- comment() {} // 解析到注釋時(shí)的回調(diào)
- })
- return root
- }
可以看到 parse 主要通過 parseHTML 進(jìn)行工作,這個(gè) parseHTML 本身來自于開源庫:simple html parser,只不過經(jīng)過了 Vue 團(tuán)隊(duì)的一些修改,修復(fù)了相關(guān) issue。
HTML parser
下面我們一起來理一理 parseHTML 的邏輯。
- export function parseHTML(html, options) {
- let index = 0
- let last,lastTag
- const stack = []
- while(html) {
- last = html
- let textEnd = html.indexOf('<')
- // "<" 字符在當(dāng)前 html 字符串開始位置
- if (textEnd === 0) {
- // 1、匹配到注釋:
- if (/^
- const commentEnd = html.indexOf('-->')
- if (commentEnd >= 0) {
- // 調(diào)用 options.comment 回調(diào),傳入注釋內(nèi)容
- options.comment(html.substring(4, commentEnd))
- // 裁切掉注釋部分
- advance(commentEnd + 3)
- continue
- }
- }
- // 2、匹配到條件注釋:
- if (/^
- // ... 邏輯與匹配到注釋類似
- }
- // 3、匹配到 Doctype:
- const doctypeMatch = html.match(/^]+>/i)
- if (doctypeMatch) {
- // ... 邏輯與匹配到注釋類似
- }
- // 4、匹配到結(jié)束標(biāo)簽:
上述代碼為簡(jiǎn)化后的 parseHTML,while 循環(huán)中每次截取一段 html 文本,然后通過正則判斷文本的類型進(jìn)行處理,這就類似于編譯原理中常用的有限狀態(tài)機(jī)。每次拿到 "<" 字符前后的文本,"<" 字符前的就當(dāng)做文本處理,"<" 字符后的通過正則判斷,可推算出有限的幾種狀態(tài)。
其他的邏輯處理都不復(fù)雜,主要是開始標(biāo)簽與結(jié)束標(biāo)簽,我們先看看關(guān)于開始標(biāo)簽與結(jié)束標(biāo)簽相關(guān)的正則。
- const ncname = '[a-zA-Z_][\\w\\-\\.]*'
- const qnameCapture = `((?:${ncname}\\:)?${ncname})`
- const startTagOpen = new RegExp(`^<${qnameCapture}`)
這段正則看起來很長(zhǎng),但是理清之后也不是很難。這里推薦一個(gè)正則可視化工具。我們到工具上看看startTagOpen:
startTagOpen
這里比較疑惑的點(diǎn)就是為什么 tagName 會(huì)存在 :,這個(gè)是 XML 的 命名空間,現(xiàn)在已經(jīng)很少使用了,我們可以直接忽略,所以我們簡(jiǎn)化一下這個(gè)正則:
- const ncname = '[a-zA-Z_][\\w\\-\\.]*'
- const startTagOpen = new RegExp(`^<${ncname}`)
- const startTagClose = /^\s*(\/?)>/
- const endTag = new RegExp(`^<\\/${ncname}[^>]*>`)
startTagOpen
endTag
除了上面關(guān)于標(biāo)簽開始和結(jié)束的正則,還有一段用來提取標(biāo)簽屬性的正則,真的是又臭又長(zhǎng)。
- const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
把正則放到工具上就一目了然了,以 = 為分界,前面為屬性的名字,后面為屬性的值。
attribute
理清正則后可以更加方便我們看后面的代碼。
- while(html) {
- last = html
- let textEnd = html.indexOf('<')
- // "<" 字符在當(dāng)前 html 字符串開始位置
- if (textEnd === 0) {
- // some code ...
- // 4、匹配到標(biāo)簽結(jié)束位置:
在解析開始標(biāo)簽的時(shí)候,如果該標(biāo)簽不是單標(biāo)簽,會(huì)將該標(biāo)簽放入到一個(gè)堆棧當(dāng)中,每次閉合標(biāo)簽的時(shí)候,會(huì)從棧頂向下查找同名標(biāo)簽,直到找到同名標(biāo)簽,這個(gè)操作會(huì)閉合同名標(biāo)簽上面的所有標(biāo)簽。接下來我們舉個(gè)例子:
test
在解析了 div 和 h2 的開始標(biāo)簽后,棧內(nèi)就存在了兩個(gè)元素。h2 閉合后,就會(huì)將 h2 出棧。然后會(huì)解析兩個(gè)未閉合的 p 標(biāo)簽,此時(shí),棧內(nèi)存在三個(gè)元素(div、p、p)。如果這個(gè)時(shí)候,解析了 div 的閉合標(biāo)簽,除了將 div 閉合外,div 內(nèi)兩個(gè)未閉合的 p 標(biāo)簽也會(huì)跟隨閉合,此時(shí)棧被清空。
為了便于理解,特地錄制了一個(gè)動(dòng)圖,如下:
入棧與出棧
理清了 parseHTML 的邏輯后,我們回到調(diào)用 parseHTML 的位置,調(diào)用該方法的時(shí)候,一共會(huì)傳入四個(gè)回調(diào),分別對(duì)應(yīng)標(biāo)簽的開始和結(jié)束、文本、注釋。
- parseHTML(template, {
- // some options...
- // 解析到標(biāo)簽位置開始的回調(diào)
- start(tag, attrs, unary) {},
- // 解析到標(biāo)簽位置結(jié)束的回調(diào)
- end(tag) {},
- // 解析到文本時(shí)的回調(diào)
- chars(text: string) {},
- // 解析到注釋時(shí)的回調(diào)
- comment(text: string) {}
- })
處理開始標(biāo)簽
首先看解析到開始標(biāo)簽時(shí),會(huì)生成一個(gè) AST 節(jié)點(diǎn),然后處理標(biāo)簽上的屬性,最后將 AST 節(jié)點(diǎn)放入樹形結(jié)構(gòu)中。
- 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, // 節(jié)點(diǎn)類型
- tag, // 節(jié)點(diǎn)名稱
- attrsMap, // 節(jié)點(diǎn)屬性映射
- attrsList, // 節(jié)點(diǎn)屬性數(shù)組
- parent, // 父節(jié)點(diǎn)
- children: [], // 子節(jié)點(diǎn)
- }
- }
- const stack = []
- let root // 根節(jié)點(diǎn)
- let currentParent // 暫存當(dāng)前的父節(jié)點(diǎn)
- parseHTML(template, {
- // some options...
- // 解析到標(biāo)簽位置開始的回調(diào)
- start(tag, attrs, unary) {
- // 創(chuàng)建 AST 節(jié)點(diǎn)
- let element = createASTElement(tag, attrs, currentParent)
- // 處理指令: v-for v-if v-once
- processFor(element)
- processIf(element)
- processOnce(element)
- processElement(element, options)
- // 處理 AST 樹
- // 根節(jié)點(diǎn)不存在,則設(shè)置該元素為根節(jié)點(diǎn)
- if (!root) {
- root = element
- checkRootConstraints(root)
- }
- // 存在父節(jié)點(diǎn)
- if (currentParent) {
- // 將該元素推入父節(jié)點(diǎn)的子節(jié)點(diǎn)中
- currentParent.children.push(element)
- element.parent = currentParent
- }
- if (!unary) {
- // 非單標(biāo)簽需要入棧,且切換當(dāng)前父元素的位置
- currentParent = element
- stack.push(element)
- }
- }
- })
處理結(jié)束標(biāo)簽
標(biāo)簽結(jié)束的邏輯就比較簡(jiǎn)單了,只需要去除棧內(nèi)最后一個(gè)未閉合標(biāo)簽,進(jìn)行閉合即可。
- parseHTML(template, {
- // some options...
- // 解析到標(biāo)簽位置結(jié)束的回調(diào)
- 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()
- }
- // 出棧,重置當(dāng)前的父節(jié)點(diǎn)
- stack.length -= 1
- currentParent = stack[stack.length - 1]
- }
- })
處理文本
處理完標(biāo)簽后,還需要對(duì)標(biāo)簽內(nèi)的文本進(jìn)行處理。文本的處理分兩種情況,一種是帶表達(dá)式的文本,還一種就是純靜態(tài)的文本。
- parseHTML(template, {
- // some options...
- // 解析到文本時(shí)的回調(diào)
- chars(text) {
- if (!currentParent) {
- // 文本節(jié)點(diǎn)外如果沒有父節(jié)點(diǎn)則不處理
- return
- }
- const children = currentParent.children
- text = text.trim()
- if (text) {
- // parseText 用來解析表達(dá)式
- // delimiters 表示表達(dá)式標(biāo)識(shí)符,默認(rèn)為 ['{{', '}}']
- const res = parseText(text, delimiters))
- if (res) {
- // 表達(dá)式
- children.push({
- type: 2,
- expression: res.expression,
- tokens: res.tokens,
- text
- })
- } else {
- // 靜態(tài)文本
- children.push({
- type: 3,
- text
- })
- }
- }
- }
- })
下面我們看看 parseText 如何解析表達(dá)式。
- // 構(gòu)造匹配表達(dá)式的正則
- const buildRegex = delimiters => {
- const open = delimiters[0]
- const close = delimiters[1]
- return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
- }
- function parseText (text, delimiters){
- // delimiters 默認(rèn)為 {{ }}
- const tagRE = buildRegex(delimiters || ['{{', '}}'])
- // 未匹配到表達(dá)式,直接返回
- if (!tagRE.test(text)) {
- return
- }
- const tokens = []
- const rawTokens = []
- let lastIndex = tagRE.lastIndex = 0
- let match, index, tokenValue
- while ((match = tagRE.exec(text))) {
- // 表達(dá)式開始的位置
- index = match.index
- // 提取表達(dá)式開始位置前面的靜態(tài)字符,放入 token 中
- if (index > lastIndex) {
- rawTokens.push(tokenValue = text.slice(lastIndex, index))
- tokens.push(JSON.stringify(tokenValue))
- }
- // 提取表達(dá)式內(nèi)部的內(nèi)容,使用 _s() 方法包裹
- const exp = match[1].trim()
- tokens.push(`_s(${exp})`)
- rawTokens.push({ '@binding': exp })
- lastIndex = index + match[0].length
- }
- // 表達(dá)式后面還有其他靜態(tài)字符,放入 token 中
- if (lastIndex < text.length) {
- rawTokens.push(tokenValue = text.slice(lastIndex))
- tokens.push(JSON.stringify(tokenValue))
- }
- return {
- expression: tokens.join('+'),
- tokens: rawTokens
- }
- }
首先通過一段正則來提取表達(dá)式:
提取表達(dá)式
看代碼可能有點(diǎn)難,我們直接看例子,這里有一個(gè)包含表達(dá)式的文本。
是否登錄:{{isLogin ? '是' : '否'}}
運(yùn)行結(jié)果
解析文本
optimize
通過上述一些列處理,我們就得到了 Vue 模板的 AST。由于 Vue 是響應(yīng)式設(shè)計(jì),所以拿到 AST 之后還需要進(jìn)行一系列優(yōu)化,確保靜態(tài)的數(shù)據(jù)不會(huì)進(jìn)入虛擬 DOM 的更新階段,以此來優(yōu)化性能。
- export function optimize (root, options) {
- if (!root) return
- // 標(biāo)記靜態(tài)節(jié)點(diǎn)
- markStatic(root)
- }
簡(jiǎn)單來說,就是把所以靜態(tài)節(jié)點(diǎn)的 static 屬性設(shè)置為 true。
- function isStatic (node) {
- if (node.type === 2) { // 表達(dá)式,返回 false
- return false
- }
- if (node.type === 3) { // 靜態(tài)文本,返回 true
- return true
- }
- // 此處省略了部分條件
- return !!(
- !node.hasBindings && // 沒有動(dòng)態(tài)綁定
- !node.if && !node.for && // 沒有 v-if/v-for
- !isBuiltInTag(node.tag) && // 不是內(nèi)置組件 slot/component
- !isDirectChildOfTemplateFor(node) && // 不在 template for 循環(huán)內(nèi)
- Object.keys(node).every(isStaticKey) // 非靜態(tài)節(jié)點(diǎn)
- )
- }
- function markStatic (node) {
- node.static = isStatic(node)
- if (node.type === 1) {
- // 如果是元素節(jié)點(diǎn),需要遍歷所有子節(jié)點(diǎn)
- for (let i = 0, l = node.children.length; i < l; i++) {
- const child = node.children[i]
- markStatic(child)
- if (!child.static) {
- // 如果有一個(gè)子節(jié)點(diǎn)不是靜態(tài)節(jié)點(diǎn),則該節(jié)點(diǎn)也必須是動(dòng)態(tài)的
- node.static = false
- }
- }
- }
- }
generate
得到優(yōu)化的 AST 之后,就需要將 AST 轉(zhuǎn)化為 render 方法。還是用之前的模板,先看看生成的代碼長(zhǎng)什么樣:
{{message}}
- {
- 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 對(duì)應(yīng)的是虛擬 DOM 中的 createElement 方法。其他的下劃線方法在 core/instance/render-helpers 中都有定義,每個(gè)方法具體做了什么不做展開。
render-helpers`
具體轉(zhuǎn)化方法就是一些簡(jiǎn)單的字符拼接,下面是簡(jiǎn)化了邏輯的部分,不做過多講述。
- export function generate(ast, options) {
- const state = new CodegenState(options)
-  
網(wǎng)頁題目:Vue模板編譯原理
標(biāo)題網(wǎng)址:http://m.fisionsoft.com.cn/article/dhhoedj.html


咨詢
建站咨詢
