浅曦Vue源码-16-挂载阶段-$mount(4)

449 阅读1分钟

「这是我参与2022首次更文挑战的第17天,活动详情查看:2022首次更文挑战」。

一、前情回顾 & 背景

我们正在讲述 new Vue 过程即 this._init() 方法的最后一步 $mount 实施挂载,在这个过程中会将模板解析成 AST 对象,然后再将 AST 转换成 render/staticRenderFns 渲染函数。

而将模板编译成 ast 的是 parse 方法,而 parse 内部是调用 parseHTML 方法解析 html 模板,以 < 为标识,将模板分成注释、条件注释、文档声明、开始/闭合标签、普通文本类型,然后调用 parseHTML 接收到的 options 中的 comment/start/end/chars 方法处理;

上一篇小作文中主要讲解了 parse 主流程,以及 parse 的一个分支流程—— parseHTML 的主流程,本篇小作文将继续完成 parseHTML 流程中的具体方法;

export function parseHTML (html, options) {
  // 现在这个代码中的代码只是个示意,大致方法的调用位置
  let index = 0
  let last, lastTag
  while (html) {
     
    options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
    advance(commentEnd + 3)
      
    parseEndTag(endTagMatch[1], curIndex, index)

    const startTagMatch = parseStartTag()
    handleStartTag(startTagMatch)

    options.chars(text, index - text.length, index)
  
    parseEndTag(stackedTag, index - endTagLength, index)
 
    options.chars && options.chars(html)
  }

  parseEndTag()
  
  
  function advance (n) { }

  function parseStartTag () {}

  function handleStartTag (match) {}

  function parseEndTag (tagName, start, end) {}
}

二、advance

方法位置:parseHTML

方法参数:nindex 要前进的长度数字

方法作用:

  1. parseHTML 中维护了 index 变量,每次累加 nindex 其实是个指针,用于记录下次需要处理的位置,这个位置是一个 html 模板字符串的索引;
  2. 维护 html 变量,html 变量最初是 parseHTML 方法接收的模板字符串,随着 while 循环的处理 index 再逐渐增长,而 html 就需要变为剩下的未经处理的字符串,这个过程 html 变为原来模板的子字符串,慢慢缩短长度,到最后就把所有 html 都处理过了;
function advance (n) {
  index += n
  html = html.substring(n)
}

三、parseStartTag

方法位置:parseHTML 方法内

方法作用:解析开始标签,以 <div id="app">为例,得到描述这个开始标签的 match 对象,所谓 match 对象是一个包含由 tagName 属性描述开始标签的标签名,attrs 属性描述的开始标签的行内属性集合等属性的对象。主要分为以下几步:

  1. 调用 html.match 方法,传入 startTagOpen 正则匹配出开始标签信息,如图

image.png

  1. 声明 match 对象,其中包含 tagNameattrsstart 属性,tagName 就是当前开始标签的标签名,attrs 就是开始标签后面的行内属性,start 就是 parseHTMLindex 的值;

  2. 调用 advance 前进 start[0].lengthstart[0] 是包含 div,是 match 方法返回的第一项

  3. while 循环,处理从开始标签开始处理开始标签后面的的行内属性,比如 id="app",把匹配到属性塞到 match.attrs

  4. 4while 循环过程中会匹配开始标签的结束,匹配到结束时,为 match 对象添加 end 属性,调用 advance前进结束的长度,最后返回match` 对象

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 循环,终止条件为:匹配到结束开始标签的结束;没有匹配到是动态参数属性或普通属性
    // 开始标签内的各个属性,并将这些属性放到 match.attrs 数组中
    while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
     
      attr.start = index
      advance(attr[0].length)
      attr.end = index
      match.attrs.push(attr)
    }

    // 开始标签的对应结束,比如 <div id="app"> 的 > 或者自闭和标签的 />
    if (end) {
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

match 对象长这样子:

image.png

总结一下:

前面说 while 循环是靠 index 作为指针,后面匹配到注释、条件注释、开始标签等调用对应方法处理,处理完都会维护 index 递增,维护 html 逐渐缩短。从 parseStartTag 方法可以看出一点痕迹,从开始标签开始解析,把有用信息从 html 字符串上提取出来,然后让 index 前进让 html 变短(这就是 advance 方法的作用),然后匹配 > 或者自闭和的 /> 匹配到结尾,开始标签就算结束了。而 index 前进了 <div id="app"> 这个字符串的长度,而 html 已经不再包含 <div id="app"> 字符串了。

四、handleStartTag 方法

方法位置:parseHTML 方法内部

方法参数:match,上一步中 parseStartTag 返回的 match 对象

方法作用:

  1. 解析上一步 parseStartTag 返回的 match 对象,处理 match.attrs,把 match 中的 atts 形如 ['id="app"', 'id', '=', 'app', undefined, undefined] 变成 { name: 'id', value: 'app' } 这种形式;
  2. 如果不是自闭和标签,把标签的信息放入 stack 中,以便将来处理到它的闭合标签时弹出 stack,以此完成 html 标签的成对儿问题处理。这种处理方法和一个经典面试题多个括号闭合问题是如出一辙的,其实这也是另一个能够用 while 这个一维的循环处理 HTML 这种树形结构的关键点;而自闭和标签,也就是 unary 一元标签,就直接处理标签上的信息;pushstack 的对象如下,等下 parseEnd 的时候要用到
stack.push({ 
  tag: tagName, 
  lowerCasedTag: tagName.toLowerCase(), 
  attrs: attrs, 
  start: match.start,
  end: match.end 
})
  1. 接下来调用 options.start 方法,传入 tagName,attrs,match.start,match.end

image.png

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)
    }
  }

  // 一元标签,即自闭和标签,比如 \<img />
  const unary = isUnaryTag(tagName) || !!unarySlash

  // 处理 match.attrs,得到 attrs[i] = [{name: attrName, value: attrVal, star: xx, end: xx }, ...]
  // 未经过处理的attrs 长这样:
  // attrs = [['id="app"', 'id', '=', 'app', undefined, undefined], ...]
  const l = match.attrs.length
  const attrs = new Array(l)
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // arg[3] = 'app', args[4]= undefined, args[5]=undefined
    const value = args[3] || args[4] || args[5] || ''
    const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
      ? options.shouldDecodeNewlinesForHref // 如果是 a 标签,要处理 href 的值进行编码
      : options.shouldDecodeNewlines
      
      // 将 atrrs[i] 改写成 { name, value } 形式
    attrs[i] = {
      name: args[1],
      value: decodeAttr(value, shouldDecodeNewlines)
    }

    // 记录非生产环境,记录属性的开始和结束索引
    if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
      attrs[i].start = args.start + args[0].match(/^\s*/).length
      attrs[i].end = args.end
    }
  }

  // 如果不是自闭和标签,将标签信息放到 stack 数组中,当处理到结束标签时出栈
  if (!unary) {
    // 将标签信息入栈,{ tag, lowerCasedTag, attrs, start, end }
    stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
    lastTag = tagName // 保存当前标签名到 lastTag
  }

  if (options.start) {
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

五、parseEndTag

方法位置:parseHTML 内部方法

方法参数:

  1. tagnName:闭合标签名
  2. start:开始索引
  3. end:结束索引

方法作用:

  1. 定义 pos 遍历,如果传递了闭合标签名 tagName,就从 stack 栈中倒着查找,一直找到 stack 栈中的 tagName 属性和传入的 tagName 属性相同的项,它的索引就是 pos 的值,理论上不存在未闭合标签时,栈顶的项就是收到的闭合标签 tagName 对应的开始标签数据;

  2. 处理 stack,不存在未闭合的元素时,栈顶就是对应的开始标签。此时找到闭合标签的 stack 中对应的项,让其出栈,始终保持 stack 中的最后一项是下一次 html 模板匹配到结束标签时对应的开始标签对应的信息;

function parseEndTag (tagName, start, end) {
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    // If no tag name is provided, clean shop
    pos = 0
  }

  // 如果再上面的查找闭合标签对应开始标签的 for 循序中一直没有找到它对应的开始标签,
  // pos 会因为一直递减导致小于 0 而退出循环,
  // 所以 pos 就会 < 0,说明没有找到开始标签,则存在未闭合的标签
  if (pos >= 0) {
    // 说明找到了对应的闭合标签
    for (let i = stack.length - 1; i >= pos; i--) {
      if (process.env.NODE_ENV !== 'production' &&
        (i > pos || !tagName) &&
        options.warn
      ) {
        // 异常情况,原则上栈顶就是当前 tagName 的闭合元素
        // 如果在 pos 的前面还有一些元素,说明这些元素也是没有闭合标签
        options.warn(
          `tag <${stack[i].tag}> has no matching end tag.`,
          { start: stack[i].start, end: stack[i].end }
        )
      }
      
      if (options.end) {
        // 调用 options.end 方法处理结束标签
        options.end(stack[i].tag, start, end)
      }
    }

    // 相当于出栈,pos 正常情况下就应该是 stack.length - 1,即栈顶的项,
    // 当前项已经处理过了,通过修改数组 length 属性达删除某一项
    stack.length = pos

    // 给 lastTag 重新赋值,记录 stack 数组待处理的一个开始标签名字
    // 正常情况下也是下一个闭合标签的名字才对
    lastTag = pos && stack[pos - 1].tag
  } else if (lowerCasedTagName === 'br') {
    // 处理 <br /> 标签情景
    if (options.start) {
      options.start(tagName, [], true, start, end)
    }
  } else if (lowerCasedTagName === 'p') {
    // p 标签情况
    if (options.start) {
      //  处理 <p> 标签
      options.start(tagName, [], false, start, end)
    }
    if (options.end) {
      // 处理 </p> 标签
      options.end(tagName, start, end)
    }
  }
}

六、总结

本篇小作文讲述 parseHTML 过程中用到的工具方法:

  1. advance: 维护 indexhtmlindex 记录在原 html 中的处理位置,html 逐渐缩短成没处理过的部分;
  2. parseStartTag:用正则匹配出开始标签中的 tagNameattrs 以及 开始标签的结束部分如 > 或者 />,并且入栈 stack 当前开始标签的信息;
  3. handleStartTag:进一步处理 parsetStartTag 得到的 match 对象,转换 attrs 数组;
  4. parseEndTag:当匹配到结束标签时,维护 stack,使 stack 对应当前结束标签的开始标签出栈;