学习vue2.5源码之第六篇——compiler中的parser

开始之前

本篇讲述的是compiler的第一步——parse,就是将模板template转化为AST的这一步,在开始之前先做一些准备工作,方便等一下我们学习的时候可以参考

首先是上一篇提到的在web平台上的baseOptions,我们再次拿出来,因为里面的属性可能会在之后的内容多次出现。

{
  expectHTML: true, // 是否期望HTML,不知道是啥反正web中的是true
  modules, // klass和style,对模板中类和样式的解析
  directives, // v-model、v-html、v-text
  isPreTag, // v-pre标签
  isUnaryTag, // 单标签,比如img、input、iframe
  mustUseProp, // 需要使用props绑定的属性,比如value、selected等
  canBeLeftOpenTag, // 可以不闭合的标签,比如tr、td等
  isReservedTag, // 是否是保留标签,html标签和SVG标签
  getTagNamespace, // 命名空间,svg和math
  staticKeys: genStaticKeys(modules) // staticClass,staticStyle。
}

然后我们这部分主要学习src/compiler/parser/index.jssrc/compiler/parser/html-parser.jssrc/compiler/parser/text-parser.js,里面有一些正则表达式我们先提前在这里展示一下,方便我们之后的学习~ emmm。。。其实我一看到正则就晕,但是还是坚持把它翻译完了,假如有错误之处请及时指出,谢谢~

src/compiler/parser/index.js

// @和v-on 事件绑定
const onRE = /^@|^v-on:/

// v-xxx, @, : 事件/数据绑定
const dirRE = /^v-|^@|^:/

// v-for 中的属性值,如 (item, index) of items
const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/

// v-for 中的前部分,如 (item, index)
const forIteratorRE = /\((\{[^}]*\}|[^,]*),([^,]*)(?:,([^,]*))?\)/

// : 绑定属性
const argRE = /:(.*)$/

// 以:或者v-bind: 开头的属性  
const bindRE = /^:|^v-bind:/

// .修饰符
const modifierRE = /\.[^.]+/g

src/compiler/parser/html-parser.js

// 标准命名规范
const ncname = '[a-zA-Z_][\\w\\-\\.]*'

// 捕获整体内容
const qnameCapture = `((?:${ncname}\\:)?${ncname})`

// 开始标签开头
const startTagOpen = new RegExp(`^<${qnameCapture}`)

// 开始标签结尾
const startTagClose = /^\s*(\/?)>/

// 结束标签
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)

// DOCTYPE
const doctype = /^<!DOCTYPE [^>]+>/i

// 注释 <!--
const comment = /^<!--/

// <![CDATA
const conditionalComment = /^<!\[/

src/compiler/parser/text-parser.js

// 默认分隔符{{ }}
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g

// 判断特殊字符作为分隔符
const regexEscapeRE = /[-.*+?^${}()|[\]\/\\]/g

我们来举个栗子吧

有的同学(像我一样)可能一开始看到每个文件都几百几百行的马上被吓跪了,别怕!慢慢看慢慢理解,一定可以的,而且会吸收得越来越快!为了照顾小白们(我自己),我就举个最简单的栗子来看看template是怎么一步步转化成AST的吧~

const vue = new Vue({
    el: '#app',
    template: `
    <div class='div'>
        我是最普通的文本
        <p>{{msg}}</p>
        <a :href='src'>这是我的简书链接</a>
    </div>`,
    data: {
        msg: '我是eason',
        src: 'http://www.jianshu.com/u/2a330ce15dd5'
    }
})

src/compiler/parser/index.js

我们先看入口函数parse,一开始是定义一些属性,还有对传入选项中一些属性进行保存和访问,由于我们的例子比较简单,这些可以先略过。

接着是一个超长函数parseHTML,认真一下原来它是来自html-parser.js,一长串的是传入的参数,参数中除了一些标签之外还有几个重要的函数,start,end,chars 和 comment,他们的功能分别是对元素开始时的处理,对元素结束时的处理,对文本的处理,对注释的处理。具体实现的话我们结合html-parser.js中调用时再进行分析。

src/compiler/parser/html-parser.js

直接看函数parseHTML,定义了一个栈用来存放元素,还有一些标签(先忽略,用到再看),接下来就是一个while(html)的循环操作,大家应该能猜到这操作就是不断地处理模板,每一部分处理完后切割剩余的进入循环继续处理。

为了方便理解,我在各个条件判断中console.log出一些信息,可以直观的看出我这个小栗子的整个解析步骤。你们可以配合着代码看噢。


看回代码,我是在textend的那几个判断里console.log的,细心的同学发现了我们一直都在一个大条件中执行if (!lastTag || !isPlainTextElement(lastTag)),只有当模板是script,style,textarea的时候才会进入else循环,我们暂时不讨论那种情况哈。

当我们脑子开始清晰了,知道大概的流程后,我们讨论一下主要的一些函数。

其中先提一下多次出现的advance函数,它是负责将索引index向前移动,指向未处理模板的开始位置,进行模板切割。

function advance (n) {
    index += n
    html = html.substring(n)
}

然后我们进入textend === 0的第一部分看看

// 过滤掉注释<!-- -->,从‘-->’后开始读取
if (comment.test(html)) {
    const commentEnd = html.indexOf('-->')

    if (commentEnd >= 0) {
    if (options.shouldKeepComment) {
        options.comment(html.substring(4, commentEnd))
    }
    // 3 是 ‘-->’的长度
    advance(commentEnd + 3)
    continue
    }
}

// 过滤<![   ]>注释的内容
if (conditionalComment.test(html)) {
    const conditionalEnd = html.indexOf(']>')

    if (conditionalEnd >= 0) {
    advance(conditionalEnd + 2)
    continue
    }
}

// 过滤Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
    advance(doctypeMatch[0].length)
    continue
}

// 结束标签处理
const endTagMatch = html.match(endTag)
if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
}

// 开始标签处理
const startTagMatch = parseStartTag()
if (startTagMatch) {
    // 处理startTagMatch
    handleStartTag(startTagMatch)
    if (shouldIgnoreFirstNewline(lastTag, html)) {
    advance(1)
    }
    continue
}

这段函数主要是过滤作用,对注释和Doctype进行过滤,endTagMatch的判断到后面再讲,接着是startTagMatch,我们看看函数parseStartTag

  function parseStartTag () {
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
      advance(start[0].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
      }
    }
  }

一开始我们讲到了正则startTagOpen是开始标签的开头,我们匹配到了div标签,接着是定义了一个变量match,我们输出一下看看结果:

match = {
    tagName: "div",
    attrs: [" class='div'", "class", "=", undefined, "div", undefined, index: 0, input: " class='div'>↵                我是最普通的<文本>↵         …   <a :href='src'>这是我的简书链接</a>↵            </div>"],
    start: 0,
    unarySlash: "",
    end: 17}

这个函数的大致操作就是读取开始标签中的属性值并用一个数组存起来,然后索引值跳到标签之后,处理完成。

然后回到原函数中,handleStartTag对刚才返回的结果startTagMatch进行处理,继续看看函数handleStartTag

function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    if (expectHTML) {
      if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
        parseEndTag(lastTag)
      }
      if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
        parseEndTag(tagName)
      }
    }

    const unary = isUnaryTag(tagName) || !!unarySlash

    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
        if (args[3] === '') { delete args[3] }
        if (args[4] === '') { delete args[4] }
        if (args[5] === '') { delete args[5] }
      }
      const value = args[3] || args[4] || args[5] || ''
      const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
        ? options.shouldDecodeNewlinesForHref
        : options.shouldDecodeNewlines
      attrs[i] = {
        name: args[1],
        value: decodeAttr(value, shouldDecodeNewlines)
      }
    }
    
    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)
    }
}

它的大致流程就是先循环遍历match中的属性数组attrs进行格式封装,接着是if(!unary)对于非单标签元素的话,将他们封装后再入栈,然后是进行执行start操作。此时输出一下attrs和stack你们看看

attrs = [
    {
        name: 'class',
        value: 'div'
    }
]
stack = [
    {
        tag: 'div',
        lowerCasedTag: 'div',
        attrs
    }
]

start操作是作为参数传入的,这次我们分析一下start。由于代码过长我就不贴代码了,我先说说整体的流程,然后你们对着源码一起看。

start整体流程:

  • 定义初始的抽象语法树AST
{
  type: 1, // type = 1 为dom节点
  tag, // 标签名
  attrsList: attrs, // 属性数组
  attrsMap: makeAttrsMap(attrs), // 属性映射
  parent, // 父节点
  children: [] // 子节点节点
}

start函数就是慢慢地给这个AST进行装饰,添加更多的属性和标志

  • 对AST进行预处理

preTransforms预处理操作是将动态类型绑定v-model展开成v-if-else链,在我们的小栗子中可以忽略。

  • 对vue的指令进行处理v-pre、v-if、v-for、v-once、slot、key、ref

对于指令的处理我们到时候另开篇章讲述~

  • 对根节点进行处理

对根节点进行约束处理,不能是slottemplatev-for这些多元素标签,但是允许用v-ifv-else-ifv-else

  • 元素父子关系的绑定

绑定父子关系,也就是设置parentchildren的值。

  • 将当前AST入栈

假如不是个单标签元素就讲这个AST入栈

  • 对AST进行后处理

不知道是啥,,找都找不到

看完start,我们看看结束标签的处理parseEndTag

  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) {
          options.end(stack[i].tag, start, end)
        }
      }
      stack.length = pos
      lastTag = pos && stack[pos - 1].tag
    } else if (lowerCasedTagName === 'br') {
      if (options.start) {
        options.start(tagName, [], true, start, end)
      }
    } else if (lowerCasedTagName === 'p') {
      if (options.start) {
        options.start(tagName, [], false, start, end)
      }
      if (options.end) {
        options.end(tagName, start, end)
      }
    }
  }

大致操作就是为结束标签寻找对应的开始标签,进行出栈操作,执行options.end(tagName, start, end),我们看看end函数的出栈操作

// 出栈
    end () {
      // 取出stack中的最后一个元素
      const element = stack[stack.length - 1]
      // 取出该元素的最后一个子元素
      const lastNode = element.children[element.children.length - 1]
      // 如果最后一个子元素是纯文本' '则删除,这是因为我们的模板一般都会缩进,都会有换行,所以这里是清除换行等添加的内容
      if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
        element.children.pop()
      }
      // stack长度减一
      stack.length -= 1
      // currentParent变为栈中最后一个元素
      currentParent = stack[stack.length - 1]
      // 处理v-pre或pre的结束标签
      endPre(element)
    },

挺简单的,都在注释里解释清楚了。
对于栈的处理,我也console.table出来了,在我们的栗子中,就是
div
div + p
div
div + a
div
这是个不断出栈入栈取出元素进行处理的过程~

再说说一些我们还没说到的细节,先是chars函数。

chars (text: string) {
  const children = currentParent.children
  text = inPre || text.trim()
    ? isTextTag(currentParent) ? text : decodeHTMLCached(text)
    : preserveWhitespace && children.length ? ' ' : ''
  if (text) {
    let expression
    if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
      children.push({
        type: 2,
        expression,
        text
      })
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      children.push({
        type: 3,
        text
      })
    }
  }
}

chars函数主要是对文本进行解析,分两种情况,一种是文本需要解析type = 2,例如栗子中{{msg}},这就要执行parseText函数,一种是纯文本type = 3。看看parseText,在text-parser.js文件中。

export function parseText (
  text: string,
  delimiters?: [string, string]
): string | void {
  // 分隔符正则
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index
  while ((match = tagRE.exec(text))) {
    index = match.index
    // 存入文本
    if (index > lastIndex) {
      tokens.push(JSON.stringify(text.slice(lastIndex, index)))
    }
    // 存入解析文本以 _s(xxxx) 的形式
    const exp = parseFilters(match[1].trim())
    tokens.push(`_s(${exp})`)
    lastIndex = index + match[0].length
  }
  // 存入尾部文本
  if (lastIndex < text.length) {
    tokens.push(JSON.stringify(text.slice(lastIndex)))
  }
  // 用 + 拼接所有文本
  return tokens.join('+')
}

注释里说得很明白啦,需要解析的文本转换为_s(xxxx)的形式,然后用+符合拼接起来。

最后是comment函数。

comment (text: string) {
  currentParent.children.push({
    type: 3,
    text,
    isComment: true
  })
}

也就是添加注释类型的子节点。

最后输出给你们看看这棵树的大致结构,由于太长了就不具体展开啦你们可以自己弄一个试试:


parser相关的知识我们基本都过了一遍,我建议大家还是自己写个小demo,在需要的地方或者你觉得不懂的地方进行console,可以从输出的东西更直观地理解这个解析过程~~

下一篇文章中我们会继续讲解compiler的优化过程optimize和生成render代码的过程~ 若有理解错误之处请随时提出,谢谢!

谢谢你们这么好看还来看我的文章~ 一起努力一起成长~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,045评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,114评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,120评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,902评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,828评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,132评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,590评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,258评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,408评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,335评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,385评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,068评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,660评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,747评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,967评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,406评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,970评论 2 341

推荐阅读更多精彩内容