经过对 Vue2.0 源码阅读,想写一写本身的理解,能力有限故从尤大佬2016.4.11第一次提交开始读,准备陆续写:javascript
其中包含本身的理解和源码的分析,尽可能通俗易懂!因为是2.0的最先提交,因此和最新版本有不少差别、bug,后续将陆续补充,敬请谅解!包含中文注释的Vue源码已上传...html
AST和虚拟节点vnode有什么关系?
它们结构很类似,AST其实算得上是vnode的前身,AST通过一系列的指令解析、数据渲染就会变成vnode!这边的AST其实只是简单的html解析。vue
举个🌰,咱们先看看输入和输出,java
<div class="container"> <span :class="{active: isActive}">{{msg}}</span> <ul> <li v-for="item in list">{{item + $index}}</li> </ul> <button @click="handle">change msg</button> </div>
很明显的看到,输出的AST语法树是个对象,只拿了咱们须要的节点标签(tag)和属性(attribute),固然还有树形结构依赖关系(parent&children)。node
难点就在于,字符串的解析以及父子节点关系的构建。经过阅读源码,html字符串的解析主要用到了HTMLParser函数,该函数经过循环:git
拿上面的例子来讲,第一次循环拿到开始标签<div class="container">
,第二次拿到文本节点\n
,第三次拿到开始标签<span :class="{active: isActive}">
,第四次拿到文本{{msg}}
...固然每次取到以后会对字符串进行处理,后续会详说。github
另外关于父子节点关系的创建,主要用到了栈的后进先出的原理:每次匹配到开始标签会入栈,同时将其设为当前父节点;匹配到结束标签会出栈,并将栈末元素设为当前父节点。正则表达式
Vue2.0 有关模版字符串转AST语法树的代码全在html-parser.js中,由于里面夹杂不少兼容的处理(浏览器兼容,XHTML兼容等等),因此拿个简化版的parser.js来解析一下,你能够把代码复制下来丢控制台回车一下看看效果。segmentfault
先看一下 parse()
函数,参数html为模版字符串,返回值为AST语法树:数组
function parse (html) { let root // AST根节点 let currentParent // 当前父节点 let stack = [] // 节点栈 HTMLParser(html, { // 处理开始标签 start (tag, attrs, unary) { let element = { tag, attrs, // [{name: 'class', value: 'xx'}, ...] => [{class: 'xx'}, ...] attrsMap: attrs.reduce((cumulated, { name, value }) => { cumulated[name] = value || true; return cumulated; }, {}), parent: currentParent, children: [] } // 初始化根节点 if (!root) { root = element } // 有父节点,就把当前节点推入children数组 if (currentParent) { currentParent.children.push(element) } // 不是自闭合标签 // 进入当前节点内部遍历,故currentParent设为自身 if (!unary) { currentParent = element stack.push(element) } }, // 处理结束标签 end () { // 出栈,从新赋值父节点 stack.length -= 1 currentParent = stack[stack.length - 1] }, // 处理文本节点 chars (text) { text = currentParent.tag === 'pre' ? text : text.trim() ? text : ' ' currentParent.children.push(text) } }) return root }
该方法内部建立变量后,主要调用了HTMLParser()
函数,参数为模版字符串和一个对象(包含处理开始标签、结束标签、文本的回调)。先看一下每次匹配开始标签会怎么处理,start()
函数:
tag
(标签名),attrs
(标签属性,形如[{name: 'class', value: 'container'}, ...]
),unary
(是不是自闭合标签);tag
,attrs
,attrsMap
(就是将attrs
转成[{class: 'container'}, ...]
),parent
(根节点该属性为undefined
),children
;匹配到结束标签的处理就比较简单了,出栈并将栈末元素设为父节点;匹配到文本节点时,简单处理一下就推入children数组。
到这大概了解到,HTMLParser
这个函数要作的事情就是:遇到开始标签,把标签名、标签属性和是不是自闭合标签拿到,而后调用一下start()
;遇到结束标签了,就调用end()
,都不用你传参;遇到文本节点了,就把文本节点做为参数,调用一下chars()
!
那接下来看一下HTMLParser()
函数具体是怎么实现的,先看一下正则,已经被我改简单不少了...
// 开始标签头 const startTagOpen = /^<([\w\-]+)/, // 开始标签尾 startTagClose = /^\s*(\/?)>/, // 标签属性 attribute = /^\s*([^\s"'<>\/=]+)(?:\s*((?:=))\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/, // 结束标签 endTag = /^<\/([\w\-]+)>/;
还有前面一直提到的自闭合标签,就没结束标签的那类:
var empty = makeMap('area,base,basefont,br,col,embed,frame,hr,img,' + 'input,isindex,keygen,link,meta,param,source,track,wbr'); function makeMap (values) { values = values.split(/,/); var map = {}; values.forEach(function (value) { map[value] = 1; }); return function (value) { return map[value.toLowerCase()] === 1; }; } // empty('input'); => true
以及常常会用到的截取html字符串的函数 advance()
,参数为须要截取的长度:
function advance (n) { index += n; // index用于记录剩余字符串在原字符串中的位置 html = html.substring(n); }
前面定义的种种,都将HTMLParser
函数中用到,看一下该函数的结构:
function HTMLParser (html, handler) { var tagStack = []; // 标签栈 var index = 0; while (html) { // html 是经过 getOuterHTML 并删除了先后空格,因此第一次textEnd确定为0 var textEnd = html.indexOf('<'); if (textEnd === 0) { // 匹配开始标签 var startTagMatch = parseStartTag(); if (startTagMatch) { handleStartTag(startTagMatch); continue; } // 匹配结束标签 var endTagMatch = html.match(endTag); if (endTagMatch) { var curIndex = index; advance(endTagMatch[0].length); parseEndTag(endTagMatch[1], curIndex, index); continue; } } // 处理文本节点 ... } // 这边还有一些函数的定义... }
咱们来详细说一下这个方法,拿到html以后,就进入while循环,跳出循环的条件是把html榨干。循环开始时,找到 <
在html中的位置,为0表示是匹配到了开始标签或者结束标签(这边暂时不考虑注释、Doctype标签等等),不为0则表示有文本节点。先看看 <
下标为0时,parseStartTag()
函数怎么解析开始标签的:
function parseStartTag () { var start = html.match(startTagOpen); if (start) { var match = { tagName: start[1], attrs: [], start: index }; advance(start[0].length); var end, attr; // 未结束且匹配到标签属性 while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { advance(attr[0].length); match.attrs.push(attr); // 添加属性 } if (end) { advance(end[0].length); match.end = index; return match; } } }
看到parseStartTag()
函数刚开始,拿开始标签头的正则表达式startTagOpen
去匹配,若匹配成功则截取html。举个🌰,<div class="container"></div>
,匹配成功并截取后,余下class="container"></div>
。随后拿匹配标签属性的正则表达式attribute
,依次取出并将匹配结果放入attrs
数组,直到匹配到开始标签尾(startTagClose
)。继续拿上面的🌰,这步走完只剩下</div>
,最终也将返回match
对象。让咱们看看它什么样:
到这开始标签的匹配工做完成大半了,标签名和标签属性都拿到了,但标签属性仍是正则匹配结果,须要进一步处理,以及判断一下是否是自闭合标签。立刻看一下handleStartTag()
函数是怎么处理的:
function handleStartTag (match) { var tagName = match.tagName; var unary = empty(tagName); var attrs = match.attrs.map(attr => { return { name: attr[1], value: attr[3] || attr[4] || attr[5] || '' }; }); // 不是自闭标签 if (!unary) { tagStack.push({ tag: tagName, attrs: attrs}); } if (handler.start) { handler.start(tagName, attrs, unary, match.start, match.end); } }
handleStartTag()
函数就是将上面返回的match结果,拿到标签名,判断是不是自闭合标签,再将属性结果处理成{name: 'class', value: 'container'}
形式,而后不是自闭合标签就把标签信息推入标签栈中(这一步是用于后续匹配结束标签作铺垫),最后调用传入的start
回调,至此开始标签匹配结束。
随后进入结束标签的匹配环节,这边比较简单。首先是用正则去匹配形如</xxx>
的结束标签(匹配完后截取原html),而后拿到标签名去标签栈末开始找位置(下标),找到后把该位置到栈末所有出栈,再调用传入的end
回调。以前一直没懂为何要作这个操做?除了自闭合标签,一个元素节点的开始结束标签是成对存在的啊。这边举个例子:拿一段有问题的html字符串 <ul><li>1</ul>
,故意少写了 li
的闭合标签,那栈刚开始推入ul
,再推入li
,匹配到ul
的结束标签后把栈中的li
和ul
都出栈,这就是没问题的!看一下函数内具体啥样:
function parseEndTag (tagName, start, end) { var pos; if (start == null) start = index; if (end == null) end = index; if (tagName) { var needle = tagName.toLowerCase(); // 找到结束标签在标签栈的位置 for (pos = tagStack.length - 1; pos >= 0; pos--) { if (tagStack[pos].tag.toLowerCase() === needle) { break; } } } if (pos >= 0) { for (var i = tagStack.length - 1; i >= pos; i--) { if (handler.end) { handler.end(tagStack[i].tag, start, end); } } tagStack.length = pos; // 标签栈出栈 } } }
最后看一下文本节点的处理方法啦~
var text, rest, next; if (textEnd >= 0) { rest = html.slice(textEnd); while ( !endTag.test(rest) && !startTagOpen.test(rest) ) { // 处理小于号等其余文本 next = rest.indexOf('<', 1); if (next < 0) break; textEnd += next; rest = html.slice(textEnd); } text = html.substring(0, textEnd); advance(textEnd); } if (textEnd < 0) { text = html; html = ''; } if (handler.chars) { handler.chars(text); }
先看看三个变量都是干啥的,text
(String)用于存储文本节点信息,rest
(String)是去除文本节点后剩余html,next
(Number)是rest中第二个 <
的位置。可能会有点疑问,这边实际上是为了防止咱们找到的<
不是标签的开始标志,也有多是小于号等等!也举个例子,<div>{{age<18?'adult':'nonage'}}</div>
,匹配完开始标签后剩余{{age<18?'adult':'nonage'}}</div>
,这时候找到<
下标不为0,拿到{{age
屁颠屁颠就进入下次循环!因此为了防止这种状况发生,咱们须要看看剩余部分<18?'adult':'nonage'}}</div>
是否知足开始标签,不知足就找下一个<
,最终找到{{age<18?'adult':'nonage'}}
才是咱们要的文本节点!完事~
写了贼久终于写完了...这是我对Vue中AST语法树创建的见解,必定存在不少问题,但愿各位及时指出 (┬_┬),后续会抓紧时间把其余几篇也写出来祸害各位的!看到这就点个赞呗~ 嘿嘿嘿