Vue 源码分析 - 模板编译 - 2(模板解析) (🔥🔥超全解析详细到每一行!!!)

299 阅读1分钟

前言

Vue.js 是一个渐进式 JavaScript 框架,提供了简单易用的模板语法,帮助开发者以声明式的方式构建用户界面。Vue 的模板编译原理是其核心之一,它将模板字符串编译成渲染函数,并在运行时高效地更新DOM。本文将深入探讨Vue.js模板编译的原理和过程编译的过程就是解析出render函数的过程

  1. 入口分析:寻找真正的编译入口
  2. 解析阶段:将模板字符串解析为抽象语法树(AST)
  3. 优化阶段:遍历AST,标记静态节点以便后续优化
  4. 生成阶段:将优化后的AST生成渲染函数(render function)

太沉重的篇幅不易于我们理解,这一章我们主要讲解模板编译的解析阶段 ~

流程讲解

首先我们在项目初始化都会调用 Vue 原型上的 $mount 方法对其进行挂载

或者传入配置项,自动执行 $mount

new Vue({
  ...
}).$mount('#app');

我们紧接着看 $mount 方法,他的主要作用就是将传入的 DOM 元素或模板编译为渲染函数

var mount = Vue.prototype.$mount;

Vue.prototype.$mount = function (el, hydrating) {
  el = el && query(el);

  var options = this.$options;
  
  if (!options.render) {
    var template = options.template;
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template);
          /* istanbul ignore if */
          if (!template) {
            warn(
              ("Template element not found or is empty: " + (options.template)),
              this
            );
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML;
      } else {
        {
          warn('invalid template option:' + template, this);
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el);
    }
    if (template) {
      /* istanbul ignore if */
      if (config.performance && mark) {
        mark('compile');
      }

      var ref = compileToFunctions(template, {
        outputSourceRange: "development" !== 'production',
        shouldDecodeNewlines: shouldDecodeNewlines,
        shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this);
      var render = ref.render;
      var staticRenderFns = ref.staticRenderFns;
      options.render = render;
      options.staticRenderFns = staticRenderFns;
    }
  }
  return mount.call(this, el, hydrating)
};

解析HTML

这一步的目的是将HTML转化为AST语法树

<div>
  <span>AST</span>
  <span>{{ name }}</span>
</div>

这里就开始解析模板了,但是如何获取到到达这个编译入口不是本节的重点,之后会单独写一篇小文章来解释

// src\compiler\parser\html-parser.js

function parseHTML (html, options) {
  var stack = [];
  var expectHTML = options.expectHTML;
  var isUnaryTag$$1 = options.isUnaryTag || no;
  var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;
  var index = 0;
  var last, lastTag;
  while (html) {
    last = html;
    if (!lastTag || !isPlainTextElement(lastTag)) {
      var textEnd = html.indexOf('<');
      /* 1. 处理标签 */
      if (textEnd === 0) {
        
        /* 1.1 处理注释节点 */

        /* 1.2 处理条件注释节点 */

        /* 1.3 处理文档类型声明节点 */

        /* 1.4 处理闭合标签 */

        /* 1.5 处理开始标签 */
      }

    /* 2. 处理文本 */
      
    } else { // 处理特殊标签内的文本节点
      var endTagLength = 0;
      var stackedTag = lastTag.toLowerCase();
      var reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\s\S]*?)(</' + stackedTag + '[^>]*>)', 'i'));
      var rest$1 = html.replace(reStackedTag, function (all, text, endTag) {
        endTagLength = endTag.length;
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(/<!--([\s\S]*?)-->/g, '$1') // #7298
            .replace(/<![CDATA[([\s\S]*?)]]>/g, '$1');
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1);
        }
        if (options.chars) {
          options.chars(text);
        }
        return ''
      });
      index += html.length - rest$1.length;
      html = rest$1;
      parseEndTag(stackedTag, index - endTagLength, index);
    }

    if (html === last) {
      options.chars && options.chars(html);
      if (!stack.length && options.warn) {
        options.warn(("Mal-formatted tag at end of template: "" + html + """), { start: index + html.length });
      }
      break
    }
  }

  parseEndTag();

}

处理过程中的方法是先处理,然后再将处理好的代码进行截取删除,继续对下面的代码进行处理

parseHTML 的处理逻辑逻辑整体来说它的逻辑就是循环解析 template,利用正则匹配,对于不同情况分别进行不同的处理,直到整个 template 被解析完毕。在匹配的过程中会利用 advance 函数不断前进整个模板字符串,直到字符串末尾处

advance 方法的作用

会匹配过程中不断调用 advance 函数前进整个模板字符串(对匹配完成的字符串进行截取删除),类似于指针的作用,直到字符串末尾,详细过程如下图:

function advance (n) {
  index += n;
  html = html.substring(n); // 截取剩余未处理的模板字符
}

调用advance函数,指针向前移动

advance(4);

1. 处理标签

template 的第一个字符是 <template 会出现以下几种情况,重点为解析开始标签和结束标签

  • <!-- 开头的注释:处理方法是,找到注释的结尾,将注释截取出来,移动指针,并将注释当做当前父节点的一个子元素存储到 children
  • <![ 开头的 条件注释:如果是条件注释,会直接移动指针,不做任何其他操作
  • <!DOCTYPE 开头的 doctype:如果是 doctype,会直接移动指针,不做任何其他操作
  • < 开头的开始标签
  • < 开头的结束标签
1.1. 处理注释节点

当通过正则 comment 匹配注释节点时,首先查看配置项中属性 options.shouldKeepComment(是否保留注释节点),当保留注释节点时,执行配置项中对注释节点处理的方法 options.comment(逻辑为将注释节点存储到其父节点的 children 属性中),然后通过 advance 方法前进跳过注释节点

var comment = /^<!--/; // 匹配注释的起始部分( <!-- )

if (comment.test(html)) {
  var commentEnd = html.indexOf('-->');

  if (commentEnd >= 0) {
    if (options.shouldKeepComment) { // 保留注释节点
      options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3);
    }
    advance(commentEnd + 3); // 删除注释节点
    continue
  }
}
1.2. 处理条件注释节点

这段逻辑大致等同于处理注释节点,通过 advance 方法前进跳过条件注释节点

var conditionalComment = /^<![/; // 匹配条件注释的起始部分( <![ )

if (conditionalComment.test(html)) {
  var conditionalEnd = html.indexOf(']>');

  if (conditionalEnd >= 0) {
    advance(conditionalEnd + 2); // 删除条件注释节点
    continue
  }
}
1.3. 匹配到文档类型声明节点

文档类型声明节点即为 <!DOCTYPE> 标签,这段逻辑大致等同于处理注释节点,通过 advance 方法前进跳过文档类型声明节点

var doctype = /^<!DOCTYPE [^>]+>/i; // 匹配 <!DOCTYPE>

var doctypeMatch = html.match(doctype);
if (doctypeMatch) {
  advance(doctypeMatch[0].length); // 删除文档类型声明节点
  continue
}
1.4. 匹配到闭合标签(重点)
  1. 首先通过正则endTag匹配到闭合标签,然后前进到闭合标签结尾,然后执行parseEndTag方法对闭合标签进行解析
var endTag = new RegExp(("^<\/" + qnameCapture + "[^>]*>")); // 匹配闭合标签

var endTagMatch = html.match(endTag);
if (endTagMatch) {
  var curIndex = index;
  advance(endTagMatch[0].length); // 前进到闭合标签结尾
  parseEndTag(endTagMatch[1], curIndex, index); // 解析闭合标签
  continue
}
  1. parseEndTag
  • 找到第一个和当前endTag匹配的元素
  • 对错误匹配情况进行警告,并在最后调用了options.end回调函数,该回调将在回调章节详细讲解
  • 将与闭合标签对应的开始标签从栈中移除, 并对p标签、br标签特殊处理
    1. 原因是浏览器会将</P>解析为<p></p>, 将</br>解析为<br>,因此当在此函数中匹配到p标签则进行闭合标签的处理,匹配到br标签进行开始标签的处理
function parseEndTag (tagName, start, end) {
  var pos, lowerCasedTagName;
  if (start == null) { start = index; }
  if (end == null) { end = index; }

  // 1. 找到栈中第一个和当前endTag匹配的元素
  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase();
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    pos = 0;
  }

  // 2. 对错误匹配情况进行警告, 并调用options.end回调函数
  if (pos >= 0) {
    for (var i = stack.length - 1; i >= pos; i--) {
      if (i > pos || !tagName && options.warn) {
        options.warn(
          ("tag <" + (stack[i].tag) + "> has no matching end tag."),
          { start: stack[i].start, end: stack[i].end }
        );
      }
      if (options.end) {
        options.end(stack[i].tag, start, end);
      }
    }

    // 3. 将与闭合标签对应的开始标签从栈中移除, 并对p标签、br标签特殊处理
    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);
    }
  }
}
1.5. 匹配到开始标签(重点)
var startTagMatch = parseStartTag();
if (startTagMatch) {
  handleStartTag(startTagMatch);
  if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
    advance(1);
  }
  continue
}
  1. 首先通过 parseStartTag 解析开始标签并返回相关信息(开始标签中包含标签名和相关的属性,都会在 parseStartTag 函数中一并进行处理)
  • 首先通过正则表达式 startTagOpen 匹配开始标签的开始(<div),然后定义 match 对象并删除开始标签的开始
  • 然后循环匹配开始标签中的属性并添加到 match.attrs 中,直到匹配到开始标签的闭合符
  • 最后当匹配到闭合符,获取一元斜线符,前进到闭合符尾,并把当前索引赋值给 match.end
function parseStartTag () {
  // 1. 匹配开始标签, 定义match对象
  var start = html.match(startTagOpen);
  if (start) {
    var match = {
      tagName: start[1],
      attrs: [], // 属性
      start: index
    };
    advance(start[0].length); // 删除开始标签的开始( <div )
    var end, attr;
    
    // 2. 循环匹配开始标签中的属性并添加到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);
    }
    
    // 3. 当匹配到闭合符, 获取一元斜线符, 前进到闭合符尾
    if (end) {
      match.unarySlash = end[1]; // 获取一元斜线符
      advance(end[0].length); // 删除开始标签的闭合符( > )
      match.end = index;
      return match
    }
  }
}
  1. 通过handleStartTag开始处理match
  • 首先是对一元标签的一些判断
  • 对属性match.attrs遍历做了一些处理(取到正确的值)
  • 然后将非一元标签压入栈stack中,最后调用了options.start回调函数,该回调将在回调章节详细讲解
function handleStartTag (match) {
  var tagName = match.tagName;
  // 1. 对一元标签的判断
  var unarySlash = match.unarySlash; // 是否为一元标签

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

  var unary = isUnaryTag$$1(tagName) || !!unarySlash; // 判断是否为一元标签

  // 2. 对属性的遍历处理
  var l = match.attrs.length;
  var attrs = new Array(l);
  for (var i = 0; i < l; i++) {
    var args = match.attrs[i];
    var value = args[3] || args[4] || args[5] || '';
    var shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
      ? options.shouldDecodeNewlinesForHref
      : options.shouldDecodeNewlines;
    attrs[i] = {
      name: args[1],
      value: decodeAttr(value, shouldDecodeNewlines)
    };
    if (options.outputSourceRange) {
      attrs[i].start = args.start + args[0].match(/^\s*/).length;
      attrs[i].end = args.end;
    }
  }

  // 3. 将非一元标签压入栈中, 并调用options.start回调函数
  if (!unary) {
    stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end });
    lastTag = tagName;
  }

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

!!!!!对于对开始标签和闭合标签的栈操作,可以用下图来阐释原理!!!!!

<div id="app">
  <sapn>Vue!</sapn>
</div>
  1. 匹配到开始节点,将<div>压入栈中(一元标签不压入栈中)

  1. 依旧匹配到开始标签,将<span>压入栈中

  1. 匹配到闭合标签,与栈中最后一个标签进行比对后将栈中最后一个标签<span>弹出

  1. 依旧匹配到闭合标签,与栈中最后一个标签进行比对后将栈中最后一个标签<div>弹出

2. 处理文本

var text = (void 0), rest = (void 0), next = (void 0);
if (textEnd >= 0) {
  rest = html.slice(textEnd);
  while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
  ) {
    // < in plain text, be forgiving and treat it as text
    next = rest.indexOf('<', 1);
    if (next < 0) { break }
    textEnd += next;
    rest = html.slice(textEnd);
  }
  text = html.substring(0, textEnd);
}

if (textEnd < 0) {
  text = html;
}

if (text) {
  advance(text.length);
}

if (options.chars && text) {
  options.chars(text, index - text.length, index);
}

回调函数

处理开始标签

匹配到开始标签的处理,最后会执行options.start回调函数。该回调函数主要就做 3 件事情,

  • 创建AST元素
  • 处理AST元素
  • 管理AST语法树
start: function start (tag, attrs, unary, start$1, end) {
  
  /* 1. 创建AST元素 */

  /* 2. 处理AST元素 */

  /* 3. 管理AST语法树 */
  
}
  1. 创建AST元素

这一步操作主要是处理了svg在IE浏览器下的bug以及创建AST元素

var ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag);

if (isIE && ns === 'svg') {
  attrs = guardIESVGBug(attrs);
}

var element = createASTElement(tag, attrs, currentParent);
if (ns) {
  element.ns = ns;
}
  1. 处理AST元素

代码片段中的错误检查代码不影响主逻辑,就删除了

{
  if (options.outputSourceRange) {
    element.start = start$1;
    element.end = end;
    element.rawAttrsMap = element.attrsList.reduce(function (cumulated, attr) {
      cumulated[attr.name] = attr;
      return cumulated
    }, {});
  }
}

for (var i = 0; i < preTransforms.length; i++) {
  element = preTransforms[i](element, options) || element;
}

if (!inVPre) { // 是否存在v-pre属性
  processPre(element);
  if (element.pre) {
    inVPre = true;
  }
}
if (platformIsPreTag(element.tag)) { // 是否是<pre>标签
  inPre = true;
}
if (inVPre) {
  processRawAttrs(element); // 处理原生属性
} else if (!element.processed) {
  processFor(element); // 处理v-for标签
  processIf(element); // 处理v-if标签
  processOnce(element); // 处理v-once标签
}

这里对 v-forv-ifv-once的处理,会单独在一个章节讲解

  1. 管理AST语法树

我们在处理开始标签的时候为每一个标签创建了一个 AST 元素,在不断解析模板创建 AST 元素的时候,我们也要为它们建立父子关系,就像 DOM 元素的父子关系那样

if (!root) {
  root = element;
  {
    checkRootConstraints(root);
  }
}

if (!unary) {
  currentParent = element;
  stack.push(element);
} else {
  closeElement(element);
}
处理闭合标签
end: function end (tag, start, end$1) {
  var element = stack[stack.length - 1];
  // pop stack
  stack.length -= 1;
  currentParent = stack[stack.length - 1];
  if (options.outputSourceRange) {
    element.end = end$1;
  }
  closeElement(element);
}
处理文本
chars: function chars (text, start, end) {
  
  ...
  
  var children = currentParent.children;
  if (inPre || text.trim()) {
    text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
  } else if (!children.length) {
    // remove the whitespace-only node right after an opening tag
    text = '';
  } else if (whitespaceOption) {
    if (whitespaceOption === 'condense') {
      // in condense mode, remove the whitespace node if it contains
      // line break, otherwise condense to a single space
      text = lineBreakRE.test(text) ? '' : ' ';
    } else {
      text = ' ';
    }
  } else {
    text = preserveWhitespace ? ' ' : '';
  }
  if (text) {
    if (!inPre && whitespaceOption === 'condense') {
      // condense consecutive whitespaces into single space
      text = text.replace(whitespaceRE$1, ' ');
    }
    var res;
    var child;
    if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
      child = {
        type: 2,
        expression: res.expression,
        tokens: res.tokens,
        text: text
      };
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      child = {
        type: 3,
        text: text
      };
    }
    if (child) {
      if (options.outputSourceRange) {
        child.start = start;
        child.end = end;
      }
      children.push(child);
    }
  }
}