模板编译compiler

3,054 阅读4分钟

所谓模板,是我们写在template标签中的内容。

模板编译,则是将这一部分内容处理成render函数代码字符串的过程。

image-20210104163604941

模版编译整体逻辑主要分三部分:

  • 将模板字符串解析成AST树(抽象语法树)
  • 对AST进行静态节点标记,用于虚拟DOM的渲染优化
  • 将AST转换成render函数代码字符串

下面详细介绍一下这三个阶段

  1. 模板解析

    先来看一个🌰,有个基本的认识,了解解析之后的AST结构大概是什么样子的。

    // 模板
    <div>
      <p>{{name}}</p>
    </div>
    
    // 解析之后的AST是一个js对象,对象的属性代表标签的关键有效信息
    {
      tag: "div"
      type: 1,
      staticRoot: false, // 优化之后才有的
      static: false, // 优化之后才有的
      plain: true,
      parent: undefined,
      attrsList: [],
      attrsMap: {},
      children: [
        {
          tag: "p"
          type: 1,
          staticRoot: false,
          static: false,
          plain: true,
          parent: {tag: "div", ...},
          attrsList: [],
          attrsMap: {},
          children: [{
              type: 2,
              text: "{{name}}",
              static: false,
              expression: "_s(name)"
          }]
        }
      ]
    }
    

    我们根据源码来看一下解析过程的实现。

    /**
     * Convert HTML string to AST.
     * 将HTML模板字符串转化为AST
     */
    export function parse(template, options) {
       // ...
    	// 核心代码  
      parseHTML(template, {
        warn,
        expectHTML: options.expectHTML,
        isUnaryTag: options.isUnaryTag,
        canBeLeftOpenTag: options.canBeLeftOpenTag,
        shouldDecodeNewlines: options.shouldDecodeNewlines,
        shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
        shouldKeepComment: options.comments,
        // 当解析到开始标签时,调用该函数
        /** 
        * tag:标签名
        * attrs:标签属性
        * unary: 是否是自闭合标签
        */
        start (tag, attrs, unary) {
    			// 将解析得到的标签推入栈
        },
        // 当解析到结束标签时,调用该函数
        end () {
    			// 将解析得到的结束标签对应的开始标签推出栈
        },
        // 当解析到文本时,调用该函数。区分是动态文本或者是纯静态文本
        chars (text) {
    
        },
        // 当解析到注释时,调用该函数
        comment (text) {
    
        }
      })
      return root
    }
    

    parseHTML 的主要逻辑是使用while循环template模板字符串,使用正则依次取标签名,属性,文本,闭合标签,并用advance移动游标,匹配下一个字符串的类型以做对应的处理,直至结尾。

    // parseHTML的主要代码
    export function parseHTML (html, options) {
      let last, lastTag
      while (html) {
        last = html
        if (!lastTag || !isPlainTextElement(lastTag)) {
          let textEnd = html.indexOf('<')
          
          // 注释、条件注释、Doctype、开始标签、结束标签
          if (textEnd === 0) {
            // Comment:
            if (matchComment) {
              advance(commentLength)
              continue
            }
            
            // 条件注释
            if (matchConditionalComment) {
              advance(conditionalCommentLength)
              continue
            }
    
            // Doctype:
            const doctypeMatch = html.match(doctype)
            if (doctypeMatch) {
              advance(doctypeMatch[0].length)
              continue
            }
    
            // End tag:
            const endTagMatch = html.match(endTag)
            if (endTagMatch) {
              const curIndex = index
              advance(endTagMatch[0].length)
              parseEndTag(endTagMatch[1], curIndex, index)
              continue
            }
    
            // Start tag:
            const startTagMatch = parseStartTag() 
            if (startTagMatch) {
              handleStartTag(startTagMatch)
              continue
            }
          } else {
            // 文本
          }
        }
      }
    }
    
    

    开始标签的处理parseStartTag、handleStartTag

    parseStartTag:匹配<标签名 属性 >

    handleStartTag:

    用正则匹配出开始标签(且非自闭合标签)后,还要维护一个用来保证构建的AST节点层级与真正DOM层级一致的stack。stack中的最后一项一定是当前正在解析的节点。解析到开始标签,会把这个节点push到stack中,再匹配到子元素的开始标签,会把子元素节点push到stack中,而stack会把当前节点push到付节点的children中,并把当前节点的parent设置为父节点。

    advance函数是用来移动解析游标的,解析完一部分就把游标向后移动一部分,确保不会重复解析。

    匹配属性直至匹配到开始标签的结尾标记>.

    结束标签的处理

    • 使用正则匹配到结束标签,把当前标签从stack中pop出来。

    • 删除结束标签后的空格

    parseHtml 两类解析器

    • parseText
    • parseFilters
  2. 优化阶段

    经过了第一阶段,我们已经得到了解析好的AST树。在优化阶段主要做了两件事:

    • AST中找出所有静态节点并打上标记;
    • AST中找出所有静态根节点并打上标记;

    静态跟节点:子节点全是静态节点的节点。

    那么怎么区分节点是否是静态节点呢?

    在我们得到的AST中有一个type属性,1代表元素节点,2代表包含变量的动态文本节点,3代表不包含变量的纯文本节点。

    循环判断所有节点,如果当前节点是元素节点,则需要递归判断所有子节点。

    为什么要标记静态节点?有什么好处?

    • 每次重新渲染的时候不需要为静态节点创建新节点

    • 在 Virtual DOM 中 patching 的过程可以被跳过

  3. 代码生成

    前文例子中生成的render 如下:

    {
      render: `with(this){return _c('div',[_c('p',[_v(_s(name))])])}`
    }
    
    // 格式化
    with(this){
      return _c( // _c对应createElement,创建一个元素(标签名, 属性对象(可选), children)
        'div',
        [
          _c(
            'p',
            [
              _v(_s(name)) // _v 是创建一个文本节点;_s 是返回参数中的字符串
            ]
          )
        ]
      )
    }