写过 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 的处理、指令的处理等等,若是想了解其中的细节能够直接阅读源码。但愿你们在阅读这篇文章后有所收获。