浅曦Vue源码-15-挂载阶段-$mount(3)

304 阅读1分钟

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

一、前情回顾 & 背景

上一篇小作文中,我们讨论了 $mount 方法中的模板编译的大致流程,包含 compileToFunctions 方法的获取流程、createCompiler 方法中声明的 compile 方法,在 createCompiler 方法被调用的时候时传入的 baseCompile。最后了解了一下经过编译得到的 AST 对象以及经过 generate 生成的 render 函数体代码字符串;

本篇小作文的重点将放到 AST 对象生成的编译过程;

二、获取 astparse 方法

方法位置:src/compiler/parser/index.js -> function parse

方法参数:

  1. template:模板字符串
  2. options:平台特有的编译配置选项,所谓平台就是 web 或者 weexweexVue 的跨端项目,类似 RN

方法作用:该方法在 baseCompile 中调用,用于将模板编译成 AST 对象。

再看 parse 源码之前,先来看下在 baseCompile 中的调用

// 这一段是 baseCompile 函数中对 parse 的调用
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 调用 parse 方法获取 ast 对象,ast 对象是用于描述模板中节点关系和节点信息的对象
  const ast = parse(template.trim(), options)
  // ....
})

3.1 parse 方法

  1. 根据 options 判断是否为 pre 标签、必须使用 props 绑定的属性、是否为平台保留标签、是否是组件
  2. options.modules 下的 class、model、style 三个模块中的方法处理 class、style、v-model
  3. 调用 parseHTML 方法,传入 start/end/char/comment 的回调用于处于 HTML 节点
export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  warn = options.warn || baseWarn

  // 判断是否为 pre 标签
  platformIsPreTag = options.isPreTag || no

  // 必须用 props 绑定的属性
  platformMustUseProp = options.mustUseProp || no

  // 获取标签的命名空间
  platformGetTagNamespace = options.getTagNamespace || no

  // 判断是否是平台保留标签 html/svg
  const isReservedTag = options.isReservedTag || no

  // 判断一个元素是否为组件: 有几个标准,el.component 为 true 或 用了 :is 
  maybeComponent = (el: ASTElement) => !!(
    el.component ||
    el.attrsMap[':is'] ||
    el.attrsMap['v-bind:is'] ||
    !(el.attrsMap.is ? isReservedTag(el.attrsMap.is) : isReservedTag(el.tag))
  )
  // 以下三行是处根据 options.modules 下的 class、model、style 三个模块中的
  // transformNode 处理 class
  // preTransformNode 处理 style
  // postTransformNode 处理 v-model
  transforms = pluckModuleFunction(options.modules, 'transformNode')
  preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')
  postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')

  // 界定符:比如 {{}}
  delimiters = options.delimiters

  const stack = []

  // 保留空格
  const preserveWhitespace = options.preserveWhitespace !== false
  const whitespaceOption = options.whitespace

  // 声明根节点 root
  // 经过处理的节点都会按照层级挂载到 root 下,最后 return 的就是一个 root,一个树形结构
  let root

  // 当前元素的父元素
  let currentParent
  let inVPre = false
  let inPre = false
  let warned = false

  function warnOnce (msg, range) {
  
  }

  function closeElement (element) {
    
  }

  function trimEndingWhitespace (el) {
  
  }

  function checkRootConstraints (el) {
  
  }
  // 调用 parseHTML 
  // 第二个参数中的 start、end、chars... 就是处理各个节点的方法
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    outputSourceRange: options.outputSourceRange,
 
    start (tag, attrs, unary, start, end) {
     
    },


    end (tag, start, end) {
    
    },
    chars (text: string, start: number, end: number) {
      
    },


    comment (text: string, start, end) {
     
    }
  })

  // 返回生成的 ast 对象
  return root
}

3.2 parseHTML 方法

方法位置:src/compiler/parser/html-parser.js -> function parseHTML

方法参数:

  1. htmlhtml 字符串
  2. options:解析 html 模板时所需配置项,这个配置项中包含处理不同类型模板的方法:
    • 2.1 options.start 方法,处理 html 开始标签
    • 2.2 opitons.end 方法,处理 html 结束标签
    • 2.3 options.chars 方法,处理 html 中的普通文本
    • 2.4 options.comment 方法,处理 html 中的注释

方法作用:

parseHTML 方法的核心就是遍历 html 模板字符串,用接收到 otpiosn 参数中对应方法我们写 html 模板的过程是一个有嵌套的过程,给人的感觉也是一个有深度的树形结构。但是 parseHTML 解析 html 模板的时候并非如此,它认为 html 模板是一个一维的字符串,那他如何解决深度的问题呢?

<div id="someID"><span></span></div> 结构为例:

  1. parseHTML 是一个 while(html模板不为空)while 循环,另外有一个记录当前遍历到的字符位置的表示符 index,初始值为 0
  2. 接着从 html 模板中找到 < 第一次出现的位置,即 indexOf,在 html 中有很多种语法都会命中 < 开头,所以接着就是找到 < 小于号出现的位置后就看是判断本次是以下情形中的哪一种,然后调用对应 options 上的处理方法,处理后就 continue 进入下轮循环:
    • 2.2 HTML注释(<!-- -->),调用 options.comment 方法处理注释

    • 2.2 条件注释(<!-- [if IE]-->),越过

    • 2.3 DOCTYPE 文档声明(<!DOCTYPE html>),越过

    • 2.4 如果前面 2.1 - 2.3 都没有匹配成功,接着就判断小于号后面是否是开始或者结束标签并调用 parseEndTag 或者 parseStartTag 方法处理开始、结束标签,

    • 2.5 如果前面 2.1 - 2.4 都没匹配成功,说明 < 小于号就是个普通文本了,例如: "< 这就是个小于号",调用 options.chars 方法处理文本;

    • 2.6 处理这些 < 小于号后面内容的各种情形时,还要处理这些注释、文档声明、开始/结束标签、带 < 的文本的内容长度有多少,然后就让 index 前进多少,目的就在于从 index 开始重新截取(html=html.substring(index)),将截取后的模板作为 html ,进入下一轮循环。

    • 2.7 如果 < 出现位置 等于 0 ,说明从索引 0 开始就是普通字符,所以通过 while 循环向后查找,一直找到下一个 < 出现的位置为止,期间所有的都算作普通字符处理

export function parseHTML (html, options) {
  const stack = []
  const expectHTML = options.expectHTML

  // 是否为自闭和标签
  const isUnaryTag = options.isUnaryTag || no

  // 是否只有开始标签
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no

  // 这个是个标识符,用于记录遍历的起点,每次匹配到内容并且处理后将会从 index 重新截取模板,
  // 截取后的模板作为下一轮的处理内容
  let index = 0
  let last, lastTag
  // html = "<div id=\"app\">\n\t{{ msg }}\n\t<some-com :some-key=\"forProp\"></some-com>\n\t<div>someComputed = {{someComputed}}</div>\n\t<div class=\"static-div\">静态节点</div>\n</div>"
  while (html) {
    last = html
    // 确保这些模板 html 不是 script 、style、textarea 这样的纯文本元素
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 找到 < 在 html 模板中第一次出现的位置赋值给 textEnd 变量
      let textEnd = html.indexOf('<')

      // textEnd === 0 说明当前 html 模板是以 < 开头的字符串
      if (textEnd === 0) {
      
        // 处理 HTML 注释语法:<!-- xx -->
        if (comment.test(html)) {
          // HTML 注释语法的结束标识 --> 出现的索引,在开始和结束索引之间就是 HTML 注释的内容
          const commentEnd = html.indexOf('-->')

          if (commentEnd >= 0) {
            if (options.shouldKeepComment) {
              // 如果 options 有要求保留注释内容时调用 options.comment 方法处理
              options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            }
            // 使 index 前进到注释 --> 结束以后的一个位置
            // 并且 advance 会给 html 这个变量重新赋值,当 while(html) 下次循环是就是这个注释之后的内容了
            advance(commentEnd + 3)
            continue // while 循序下一次循环
          }
        }

        // 处理条件注释语法:<!-- [if IE]>
        if (conditionalComment.test(html)) {
          // 找到条件注释结束索引位置
          const conditionalEnd = html.indexOf(']>')

          if (conditionalEnd >= 0) {
            // 使 index 变量前进,重写截取 html 变量
            advance(conditionalEnd + 2)
            continue
          }
        }

        // 处理文档声明 Doctype,<!DOCTYPE html>
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          advance(doctypeMatch[0].length)
          continue
        }

        
        // 如果 while 循环能走到这里说明前面的注释、条件注释、文档声明都没有匹配到,
        // 因为一旦匹配到了就 continue 了
        // 处理结束标签,比如 </div>
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length) // 这里要 index 前进结束标签的长度
          parseEndTag(endTagMatch[1], curIndex, index)
          continue // 下一轮循环
        }

       
        // 处理开始标签,比如 <div id="app">
        // startTagMatch = { tagName: 'div', attrs: ["id=\"app\"", "id", "="], start: 0, end: 14 }
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // 走到这里,说明匹配到了HTML的标识标签,调用 options.start 方法处理开始标签
          // 处理开始标签的核心就是这个 handleStartTag
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }

      let text, rest, next
      if (textEnd >= 0) {
        // 走到这里说明虽然在 html 中匹配到了 < 但是上面的注释、条件注释、文档声明、开始结束标签都不是
        // 那他是个啥?就是个普通的文本了: <我是小于号
        // 从 < 第一次出现的位置 textEnd 开始截取剩下的 html 模板赋值给 rest
        // 后面的这个 while 循环就是在查找下一个 < 出现的位置,这两个 < 之间的内容作为普通文本
        rest = html.slice(textEnd)
        while (
          !endTag.test(rest) && // 不是结束标签
          !startTagOpen.test(rest) && // 不是开始标签
          !comment.test(rest) && // 不是注释
          !conditionalComment.test(rest) // 不是条件注释
        ) {
          
          // 上述条件成立表示 < 后面为纯文本,然后在 rest 中查找下一个 < 出现的索引
          next = rest.indexOf('<', 1)
          // 小于 0 表示 rest 后面没有 <,退出循环
          if (next < 0) break

          // 能走到这里说明后续字符串中找到了 <,索引位置 textEnd 累加 next,表示向后移动
          textEnd += next

          // 从新找到的 < 位置截取 html,赋值给 rest 表示接着找,
          // 让 while 循环接续,知道不满足条件时,
          // 即找到了结束标签、开始标签、注释或条件注释的某一种情况
          rest = html.slice(textEnd)
        }

        // 当上面的 while() 结束了,此时 html 从 0 到 textEnd 中间都是普通文本
        text = html.substring(0, textEnd)
      }

      // 如果 textEnd 到这里还小于0,说明压根就没有 <,html 就是一段纯纯的文本
      if (textEnd < 0) {
        text = html
      }

      // 使 index 前进 text.length,并更新 html 模板的值
      if (text) {
        advance(text.length)
      }

      // 处理 text 中的文本
      if (options.chars && text) {
        // 处理文本就是创建文本的的 ast 节点,然后把 ast 放到 parent 节点上
        options.chars(text, index - text.length, index)
      }
    } else {
      // 处理 script、style、textarea 标签的闭合标签
      parseEndTag(stackedTag, index - endTagLength, index)
    }

    // 到这里如果 html 和 last 一样,说明都是文本,根本没有标签之类的
    // 此外,如果 stack 数组中还有内容,则说明标签没闭合要提示
    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`, { start: index + html.length })
      }
      break
    }
  }

  // 清理剩下的标签
  parseEndTag()


  function advance (n) {
  
  }
  
  function parseStartTag () {
  
  }


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

三、总结

本篇小作文的重点是 parseHTML 的主流程:pareHTML 执行时设置标识符 index,相当于是个指针,它记录了当前需要处理的模板的起始位置,以 < 小于号作为标志判断注释、条件注释、开始标签、结束标签普通文本,调用相应的方法来处理的对应的情景;

parseHTML 方法是 $mount 方法的核心,是整个 Vue 的最复杂的过程,这一篇小作文也不是 $mount 的终章,下一篇会继续未尽的细节部分。

下一篇我们继续讨论 parseHTML 执行中所需要的具体方法如 advanceparseStartTagparseEndTag ...