通过查看vue源码,可以知道Vue源码中使用了虚拟DOM(Virtual Dom),虚拟DOM构建经历 template编译成AST语法树 -> 再转换为render函数 最终返回一个VNode(VNode就是Vue的虚拟DOM节点) 。
本文通过对Vue源码中的AST转化部分进行简单提取,返回静态的AST结构(不考虑兼容性及属性的具体解析)。并最终根据一个实例的template转化为最终的AST结构。什么是AST
在Vue的mount过程中,template会被编译成AST语法树,AST是指抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式。
代码分析
首先、定义一个简单的html DOM结构、其中包括比较常见的标签、文本以及注释,用来生成AST结构。
很粗
很简单,我就是一程序员姓名:{ {name}},年龄:{ {age}}, 请联系我吧
对于转成AST,则需要先获取template,对于这部分内容,做一个简单的分析,具体的请自行查看Vue源码。
具体目录请参考: '/src/platforms/web/entry-runtime-with-compiler'从vue官网中知道,vue提供了两个版本,完整版和只包含运行时版,差别是完整版包含编译器,就是将template模板编译成AST,再转化为render函数的过程,因此只包含运行时版必须提供render函数。注意:此处处理比较简单,只是为了获取template,以便用于生成AST。function Vue (options) { // 如果没有提供render函数,则处理template,否则直接使用render函数 if (!options.render) { let template = options.template; // 如果提供了template模板 if (template) { // template: '#template', // template: ' ', if (typeof template === 'string') { // 如果为'#template' if (template.charAt(0) === '#') { let tpl = query(template); template = tpl ? tpl.innerHTML : ''; } // 否则不做处理,如:' ' } else if (template.nodeType) { // 如果模板为DOM节点,如:template: document.querySelector('#template') // 比如: template = template.innerHTML; } } else if (options.el) { // 如果没有模板,则使用el template = getOuterHTML(query(options.el)); } if (template) { // 将template模板编译成AST(此处省略一系列函数、参数处理过程,具体见下图及源码) let ast = null; ast = parse(template, options); console.log(ast) } } }
可以看出:在options中,vue默认先使用render函数,如果没有提供render函数,则会使用template模板,最后再使用el,通过解析模板编译AST,最终转化为render。其中函数如下:
function query (el) { if (typeof el === 'string') { var selected = document.querySelector(el); if (!selected) { console.error('Cannot find element: ' + el); } return selected; } return el; } function getOuterHTML (el) { if (el.outerHTML) { return el.outerHTML; } else { var dom = document.createElement('div'); dom.appendChild(el.cloneNode(true)); return dom.innerHTML; } }
对于定义组件模板形式,可以参考下这篇
说了这么多,也不废话了,下面重点介绍template编译成AST的过程。
根据源码,先定义一些基本工具方法,以及对相关html标签进行分类处理等。// script、style、textarea标签 function isPlainTextElement (tag) { let tags = { script: true, style: true, textarea: true } return tags[tag] } // script、style标签 function isForbiddenTag (tag) { let tags = { script: true, style: true } return tags[tag] } // 自闭和标签 function isUnaryTag (tag) { let strs = `area,base,br,col,embed,frame,hr,img,input,isindex,keygen,link,meta,param,source,track,wbr`; let tags = makeMap(strs); return tags[tag]; } // 结束标签可以省略"/" function canBeLeftOpenTag (tag) { let strs = `colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source`; let tags = makeMap(strs); return tags[tag]; } // 段落标签 function isNonPhrasingTag (tag) { let strs = `address,article,aside,base,blockquote,body,caption,col,colgroup,dd,details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,title,tr,track`; let tags = makeMap(strs); return tags[tag]; } // 结构:如 # { # script: true, # style: true # } function makeMap(strs) { let tags = strs.split(','); let o = {} for (let i = 0; i < tags.length; i++) { o[tags[i]] = true; } return o; }
定义正则如下:
// 匹配属性 const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ const ncname = '[a-zA-Z_][\\w\\-\\.]*' const qnameCapture = `((?:${ncname}\\:)?${ncname})` // 匹配开始标签开始部分 const startTagOpen = new RegExp(`^<${qnameCapture}`) // 匹配开始标签结束部分 const startTagClose = /^\s*(\/?)>/ // 匹配结束标签 const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配注释 const comment = /^
定义标签结构:
function createASTElement (tag, attrs, parent) { // attrs: # [ # { # name: 'id', # value: 'app' # }, # { # name: 'class', # value: 'demo' # } # ] let attrsMap = {} for (let i = 0, len = attrs.length; i < len; i++) { attrsMap[attrs[i].name] = attrs[i].value; } // attrsMap: # { # id: 'app', # class: 'demo' # } return { type: 1, tag, attrsList: attrs, attrsMap: attrsMap, parent, children: [] } }
主要的parse具体代码如下:
function parse (template, options) { let root; // 最终返回的AST let currentParent; // 设置当前标签的父节点 let stack = []; // 维护一个栈,保存解析过程中的开始标签,用于匹配结束标签 // 解析模板的具体实现 parseHTML(template, { expectHTML: true, shouldKeepComment: options.comments, // 是否保存注释 delimiters: options.delimiters, // 自定义的分隔符 start (tag, attrs, unary) {( // 处理开始标签,解析的开始标签入栈,设置children以及parent等(其中的属性解析请查看源码) let element = createASTElement(tag, attrs, currentParent); // 如果tag为script/style标签,设置属性,返回的AST中不含该标签元素结构 if (isForbiddenTag(tag)) { element.forbidden = true; console.error('Templates should only be responsible for mapping the state to the ' + 'UI. Avoid placing tags with side-effects in your templates, such as ' + "<" + tag + ">" + ', as they will not be parsed.') } // 设置根元素节点 if (!root) { root = element; } // 设置元素的父节点,将当前元素的添加到父节点的children中 if (currentParent && !element.forbidden) { currentParent.children.push(element); element.parent = currentParent; } // 如果不是自闭和标签(没有对应的结束标签),则需要将当前tag入栈,用于匹配结束标签时,调用end方法匹配最近的标签,同时设置父节点为当前元素 if (!unary) { currentParent = element; stack.push(element); } }, end () { // 将匹配结束的标签出栈,修改父节点为之前上一个元素 let element = stack.pop(); currentParent = stack[stack.length - 1]; }, chars (text) { // 保存文本 if (!currentParent) { console.error('Component template requires a root element, rather than just text.'); } else { const children = currentParent.children; if (text) { let res; // 如果文本节点包含表达式 if (res = parseText(text, opt.delimiters)) { children.push({ type: 2, expression: res.expression, tokens: res.tokens, text }) } else { children.push({ type: 3, text }) } } } }, comment (text) { // 保存注释 if (currentParent) { currentParent.children.push({ type: 3, text, isComment: true }) } } }) return root; }
从上面的可以看出:在parse函数中,主要用来解析template模板,形成AST结构,生成一个最终的root根元素,并返回。
而对于标签、文本、注释type也是不同的。其中:标签:type为1含有表达式文本:type为2不含表达式文本:type为3注释: type为3,同时isComment为true同时,options参数对象上添加了start、end、chars和comment四个方法,用来处理当匹配到开始标签、结束标签、文本以及注释时,匹配对应的开始标签,设置相应的currentParent以及parent等,生成成AST。
当调用parseHTML后,会在处理标签的不同情况下,调用对应的这四个方法。
在start中:每次处理开始标签时,会设置一个root节点(只会设置一次),当标签并且不是自闭合标签时(没有对应的结束标签),加入stack中,并将当前元素设置为currentParent,一层层往内匹配,最终的currentParent为最内层的元素标签,并将当前元素保存到为currentParent的children中及parent为currentParent。
在end中:在stack中找到最近的相同标签(栈中的最后一个),设置为currentParent,并出栈,一层层往外匹配。形如: html:<div><p></p></div> stack:['div', 'p'] pop: p => pop: div而对于chars和comment,则分别是保存文本以及注释到对应的currentParent的children中。其中parseHTML:
// 定义几个全局变量 let stack = []; // 保存开始标签tag,和上面类似 let lastTag; // 保存前一个标签,类似于currentParent let index = 0; // template开始解析索引 let html; // 剩余的template模板 let opt; // 保存对options的引用,方便调用start、end、chars、comment方法 function parseHTML (template, options) { html = template; opt = options; // 不断循环解析html,直到为"" while(html) { // 如果标签tag不是script/style/textarea if (!lastTag || !isPlainTextElement(lastTag)) { // 刚开始或tag不为script/style/textarea let textEnd = html.indexOf('<'); if (textEnd === 0) { // html以"<"开始 // 处理html注释 if (html.match(comment)) { let commentEnd = html.indexOf('-->'); if (commentEnd >= 0) { if (opt.shouldKeepComment && opt.comment) { // 保存注释内容 opt.comment(html.substring(4, commentEnd)) } // 调整index以及html advance(commentEnd + 3); continue; } } // 处理 html条件注释, 如 // 处理html声明Doctype // 处理开始标签startTaga const startTagMatch = parseStartTag(); if (startTagMatch) { handleStartTag(startTagMatch); continue; } // 匹配结束标签endTag const endTagMatch = html.match(endTag); if (endTagMatch) { // 调整index以及html advance(endTagMatch[0].length); // 处理结束标签 parseEndTag(endTagMatch[1]); continue; } } let text; if (textEnd > 0) { // html为纯文本,需要考虑文本中含有"<"的情况,此处省略,请自行查看源码 text = html.slice(0, textEnd); // 调整index以及html advance(textEnd); } if (textEnd < 0) { // htlml以文本开始 text = html; html = ''; } // 保存文本内容 if (opt.chars) { opt.chars(text); } } else { // tag为script/style/textarea let stackedTag = lastTag.toLowerCase(); let tagReg = new RegExp('([\\s\\S]*?)( ]*>)', 'i'); // 简单处理下,详情请查看源码 let match = html.match(tagReg); if (match) { let text = match[1]; if (opt.chars) { // 保存script/style/textarea中的内容 opt.chars(text); } // 调整index以及html advance(text.length + match[2].length); // 处理结束标签// parseEndTag(stackedTag); } } } }
定义advance:
// 修改模板不断解析后的位置,以及截取模板字符串,保留未解析的template function advance (n) { index += n; html = html.substring(n) }
在parseHTML中,可以看到:通过不断循环,修改当前未知的索引index以及不断截取html模板,并分情况处理、解析,直到最后剩下空字符串为止。
其中的advance负责修改index以及截取剩余html模板字符串。下面主要看看解析开始标签和结束标签:function parseStartTag () { let start = html.match(startTagOpen); if (start) { // 结构:[" // 调整index以及html advance(end[0].length) match.end = index; return match; } } }
在parseStartTag中,将开始标签处理成特定的结构,包括标签名、所有的属性名,开始位置、结束位置及是否是自闭和标签。
结构如:{ tagName, attrs, start, end, unarySlash}function handleStartTag(match) { const tagName = match.tagName; const unarySlash = match.unarySlash; if (opt.expectHTML) { if (lastTag === 'p' && isNonPhrasingTag(tagName)) { // 如果p标签包含了段落标签,如div、h1、h2等 // 形如: // 与parseEndTag中tagName为p时相对应,处理,添加// 处理结果:
parseEndTag(lastTag); } if (canBeLeftOpenTag(tagName) && lastTag === tagName) { // 如果标签闭合标签可以省略"/" // 形如:
将开始标签处理成特定结构后,再通过handleStartTag,将attrs进一步处理,成name、value结构形式。
结构如:attrs: [ {name: 'id',value: 'app'
}
]保持和之前处理一致,非自闭和标签时,从外标签往内标签,一层层入栈,需要保存到stack中,并设置lastTag为当前标签。function parseEndTag (tagName) { let pos = 0; // 匹配stack中开始标签中,最近的匹配标签位置 if (tagName) { tagName = tagName.toLowerCase(); for (pos = stack.length - 1; pos >= 0; pos--) { if (stack[pos].lowerCasedTag === tagName) { break; } } } // 如果可以匹配成功 if (pos >= 0) { let i = stack.length - 1; if (i > pos || !tagName) { console.error(`tag <${stack[i - 1].tag}> has no matching end tag.`) } // 如果匹配正确: pos === i if (opt.end) { opt.end(); } // 将匹配成功的开始标签出栈,并修改lastTag为之前的标签 stack.length = pos; lastTag = pos && stack[stack.length - 1].tagName; } else if (tagName === 'br') { // 处理: if (opt.start) { opt.start(tagName, [], true) } } else if (tagName === 'p') { // 处理上面说的情况: if (opt.start) { opt.start(tagName, [], false); } if (opt.end) { opt.end(); } } }
parseEndTag中,处理结束标签时,需要一层层往外,在stack中找到当前标签最近的相同标签,获取stack中的位置,如果标签匹配正确,一般为stack中的最后一个(否则缺少结束标签),如果匹配成功,将栈中的匹配标签出栈,并重新设置lastTag为栈中的最后一个。
注意:需要特殊处理br或p标签,标签在stack中找不到对应的匹配标签,需要单独保存到AST结构中,而</p>标签主要是为了处理特殊情况,和之前开始标签中处理相关,此时会多一个</p>标签,在stack中最近的标签不是p,也需要单独保存到AST结构中。差点忘了还有一个parseText函数。
其中parseText:function parseText (text, delimiters) { let open; let close; let resDelimiters; // 处理自定义的分隔符 if (delimiters) { open = delimiters[0].replace(regexEscapeRE, '\\$&'); close = delimiters[1].replace(regexEscapeRE, '\\$&'); resDelimiters = new RegExp(open + '((?:.|\\n)+?)' + close, 'g'); } const tagRE = delimiters ? resDelimiters : defaultTagRE; // 没有匹配,文本中不含表达式,返回 if (!tagRE.test(text)) { return; } const tokens = [] const rawTokens = []; let lastIndex = tagRE.lastIndex = 0; let index; let match; // 循环匹配本文中的表达式 while(match = tagRE.exec(text)) { index = match.index; if (index > lastIndex) { let value = text.slice(lastIndex, index); tokens.push(JSON.stringify(value)); rawTokens.push(value) } // 此处需要处理过滤器,暂不处理,请查看源码 let exp = match[1].trim(); tokens.push(`_s(${exp})`); rawTokens.push({'@binding': exp}) lastIndex = index + match[0].length; } if (lastIndex < text.length) { let value = text.slice(lastIndex); tokens.push(JSON.stringify(value)); rawTokens.push(value); } return { expression: tokens.join('+'), tokens: rawTokens } }
最后,附上以上原理简略分析图:
解析流程如下:分析过程:tagName stack1 lastTag currentParent stack2 root children parent 操作 div div [div] div div [div] div div:[p] null 入栈 comment 注释 ---> 保存到currentParent.children中 p p [div,p] p p [div,p] div p:[b] div 入栈 b b [div,p,b] b b [div,p,b] div b:[text] p 入栈 /b b [div,p] p p [div,p] div --- --- 出栈 /p p [div] div div [div] div --- --- 出栈 text 文本 ---> 经过处理后,保存到currentParent.children中 h1 h1 [div,h1] h1 h1 [div,h1] div h1:[text] div 入栈 text 文本 ---> 经过处理后,保存到currentParent.children中 /h1 h1 [div] div div [div] div --- --- 出栈 /div div [] null null [] div --- --- 出栈最终:root = div:[p,h1]很粗
很简单,我就是一程序员姓名:{ {name}},年龄:{ {age}}, 请联系我吧
最终AST结构如下:
以上是我根据vue源码分析,抽出来的简单的template转化AST,文中若有什么不对的地方请大家帮忙指正,本人最近也一直在学习Vue的源码,希望能够拿出来与大家一起分享经验,接下来会继续更新后续的源码,如果觉得有需要可以相互交流。