Skip to content

Vue源码——模版编译(四) #34

Open
@coderInwind

Description

@coderInwind

前言

在上文中我们了解到模板编译器的创建,在编译器中执行解析操作的核心其实就是作为参数传入的 baseCompiler 函数,vue 围绕着这个函数做了层层包装,如合并 options 上 modules、directives,挂载 warn 函数等,将要使用的数据处理成想要的样式,然后进行解析:

const ast = parse(template.trim(), options);

那么它到底是如何将我们的模板字符串解析成 ast 抽象树的呢?我们来看看 parse 函数怎么做的

parse函数

函数整理了一些后续需要使用的变量,然后声明了一些要用到的函数,除此之外,就只调用了一个 parseHTML 函数

warn = options.warn || baseWarn;
  // 标签是否为pre标签
  platformIsPreTag = options.isPreTag || no;
  // 是否使用了元素的原生属性绑定
  platformMustUseProp = options.mustUseProp || no;
  // 获取命名空间元素(svg,math)
  platformGetTagNamespace = options.getTagNamespace || no;
  // 是否为平台保留标签,即原始的html而不是组件
  const isReservedTag = options.isReservedTag || no;
  // 是否为动态组件
  maybeComponent = (el: ASTElement) =>
    !!(
      el.component ||
      el.attrsMap[":is"] ||
      el.attrsMap["v-bind:is"] ||
      !(el.attrsMap.is ? isReservedTag(el.attrsMap.is) : isReservedTag(el.tag))
    );

模块

传送门

  transforms = pluckModuleFunction(options.modules, "transformNode");
  preTransforms = pluckModuleFunction(options.modules, "preTransformNode");
  postTransforms = pluckModuleFunction(options.modules, "postTransformNode");

解析函数parseHTML

在上文的铺垫之后,我们调用 parseHTML 对template标签内容进行解析(套娃套了那么久真不容易,终于开始了,作者狂喜):

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(...){...},
    // 解析到结束标签时被调用
    end(...){...},
    // 解析到文本时被调用
    chars(...){...},
    // 解析到注释时被调用
    comment(...){...})

我们可以看到,除了一些解析所要用到的options配置之外,我们还传入了四个钩子函数,这四个函数分别会在 parse 函数解析template到相应位置的时候调用,我们先逐一分析:

1、comment——解析注释调用

// 传入参数分别是:注释文本、注释开始处、注释结束处
    comment(text: string, start, end) {
      // 如果有父节点
      if (currentParent) {
        // 创建ast对象
        const child: ASTText = {
          type: 3,
          text,
          isComment: true,
        };
        if (
          process.env.NODE_ENV !== "production" &&
          options.outputSourceRange
        ) {
          // 开始结束位置
          child.start = start;
          child.end = end;
        }
        // 加入父节点的child
        currentParent.children.push(child);
      }
    },

2、end——解析结束标签调用

end(tag, start, end) {
  // 获取栈顶一项
  const element = stack[stack.length - 1];
  // 移除栈顶一项
  stack.length -= 1;
  // 移除后的栈顶
  currentParent = stack[stack.length - 1];
  if (process.env.NODE_ENV !== "production" && options.outputSourceRange) {
    element.end = end;
  }
   // 做进一步处理
  closeElement(element);

3、chars——解析文本调用

// 解析到文本时调用
chars(text: string, start: number, end: number) {
  if (!currentParent) {
    if (process.env.NODE_ENV !== "production") {
      // 若解析出的文本和用户传入模板字符串一样
      if (text === template) {
      // 表示没有根元素,抛出错误
        warnOnce(
          "Component template requires a root element, rather than just text.",
          { start }
        );
      } else if ((text = text.trim())) {
        warnOnce(`text "${text}" outside root element will be ignored.`, {
          start,
        });
      }
    }
    return;
  }
  // 修复IE浏览器 textarea标签 placeholder 的 bug
  if (
    isIE &&
    currentParent.tag === "textarea" &&
    currentParent.attrsMap.placeholder === text
  ) {
    return;
  }

  const children = currentParent.children;
  if (inPre || text.trim()) {
    // 对于不是style和script中的字符串
    // vue 使用了一个包(he) 对其中的Unicode字符转码
    // 并对结果进行缓存
    text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
  } else if (!children.length) {
    // 没有对其children添加过内容,表面这个标签中是空的(或许存在着几个空格),
    text = "";
  } else if (whitespaceOption) {
    // 空白字符是否要压缩(这个字段默认是undfined)
    if (whitespaceOption === "condense") {
      text = lineBreakRE.test(text) ? "" : " ";
    } else {
      text = " ";
    }
  } else {
    // 是否保留元素前的空白(这个字段已经被vue弃用)
    text = preserveWhitespace ? " " : "";
  }
  if (text) {
    if (!inPre && whitespaceOption === "condense") {
      // 将连续空格压缩成单个空格
      text = text.replace(whitespaceRE, " ");
    }
    let res;
    let child: ?ASTNode;
    // 构建 ast 对象
    if (!inVPre && text !== " " && (res = parseText(text, delimiters))) {
      child = {
        type: 2,
        expression: res.expression,
        tokens: res.tokens,
        text,
      };
    } else if (
      text !== " " ||
      !children.length || 
      children[children.length - 1].text !== " "
    ) {
      child = {
        type: 3,
        text,
      };
    }
    if (child) {
      if (
        process.env.NODE_ENV !== "production" &&
        options.outputSourceRange
      ) {
        child.start = start;
        child.end = end;
      }
      children.push(child);
    }
  }
},

4、start——解析开始标签调用

start(tag, attrs, unary, start, end) {
  // math svg标签中只有指定标签会被使用
  const ns =
    (currentParent && currentParent.ns) || platformGetTagNamespace(tag);
  // fix ie 的bug
  if (isIE && ns === "svg") {
    attrs = guardIESVGBug(attrs);
  }
  // 构建一个 ast抽象树对象
  let element: ASTElement = createASTElement(tag, attrs, currentParent);
  
  if (ns) {
    element.ns = ns;
  }
  if (process.env.NODE_ENV !== "production") {
    if (options.outputSourceRange) {
      element.start = start;
      element.end = end;
      // 将attr数组处理成一个 map
      element.rawAttrsMap = element.attrsList.reduce((cumulated, attr) => {
        cumulated[attr.name] = attr;
        return cumulated;
      }, {});
    }
    
    attrs.forEach((attr) => {
      // 匹配属性中匹配非法字符,抛出错误
      if (invalidAttributeRE.test(attr.name)) {
        warn(
          `Invalid dynamic argument expression: attribute names cannot contain ` +
            `spaces, quotes, <, >, / or =.`,
          {
            start: attr.start + attr.name.indexOf(`[`),
            end: attr.start + attr.name.length,
          }
        );
      }
    });
  }
  // 如果是非法标签,如:script、style
  if (isForbiddenTag(element) && !isServerRendering()) {
    element.forbidden = true;
    process.env.NODE_ENV !== "production" &&
      warn(
        "Templates should only be responsible for mapping the state to the " +
          "UI. Avoid placing tags with side-effects in your templates, such as " +
          `<${tag}>` +
          ", as they will not be parsed.",
        { start: element.start }
      );
  }
  // apply pre-transforms
  for (let i = 0; i < preTransforms.length; i++) {
    element = preTransforms[i](element, options) || element;
  }
  // 是否在 v-pre的标签内
  if (!inVPre) {
    processPre(element);
    if (element.pre) {
      inVPre = true;
    }
  }
  // 是否在pre标签内
  if (platformIsPreTag(element.tag)) {
    inPre = true;
  }
  // 在v-pre的作用范围内
  // 进一步处理属性和指令
  if (inVPre) {
    processRawAttrs(element);
  } else if (!element.processed) {
    processFor(element);
    processIf(element);
    processOnce(element);
  }

  if (!root) {
    root = element;
    if (process.env.NODE_ENV !== "production") {
      // 如果将 slot 或者 template 做为根节点,
      // 如果在根节点上使用v-for
      // 抛出错误
      checkRootConstraints(root);
    }
  }
  // 是否自闭合
  if (!unary) {
    currentParent = element;
    //将标签名加入到栈
    stack.push(element);
  } else {
    //关闭标签处理
    closeElement(element);
  }
},

解析流程

模板的解析会按顺序对四个部分进行解析:

  • 注释:如<!-- 我是注释 -->或是条件注释<!--[if IE]>或是<!DOCTYPE ...>
  • 结束标签:如</div>
  • 开始标签:如<div>
  • 文本:如hello world
    这几部分各有各的特点,vue 通过正则将它们匹配出来,然后分别做不同的处理;
export function parseHTML(html, options) {
  const stack = [];
  const expectHTML = options.expectHTML;
  const isUnaryTag = options.isUnaryTag || no;
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no;
  let index = 0;
  let last, lastTag;

  while (html) {
  // 保存最近一次处理的模板
  last = html;
  // 确保我们不在纯文本内容元素中,script/style/textarea
  if (!lastTag || !isPlainTextElement(lastTag)) {
    let textEnd = html.indexOf("<");
    
    if (textEnd === 0) {
    // 从这里开始进入编译的主流程
    }
  }
}

流程主要是在 while 循环中使用正则匹配解析 template 字符串,而声明的正则表达式都是以^开头,也就是说只从头往后匹配解析,解析完成之后在原 template 上对已经做过解析的部分进行切除,当 template 被我们掏空的时候循环就结束了。
值得一提的是在函数的开头声明了一个 stack 数组,这里 vue 参考了栈解构先入先出的特点,在解析到开始标签的时候将标签压入 stack 顶,在匹配到结束标签时再将顶部相同开始标签删除,从而保证了标签不会相互交错:

1、注释

// 通过正则匹配注释
if (comment.test(html)) {
  // 找到结束位置
  const commentEnd = html.indexOf("-->");
  if (commentEnd >= 0) {
    // 是否保留注释
    if (options.shouldKeepComment) {
      // 调用注释解析钩子
      options.comment(
        html.substring(4, commentEnd),
        index,
        index + 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;
}

2、结束标签

正则匹配结束标签 例如:</span>,值得注意的是正则匹配条件中有以 </开头,所以一般情况下第一次匹配的结果总是 null

const endTagMatch = html.match(endTag);
if (endTagMatch) {
  const curIndex = index;
  // 将指针移到结束标签后
  advance(endTagMatch[0].length);
  // 三个参数分别为标签名,没移动之前的游标,移动之后的指针位置
  parseEndTag(endTagMatch[1], curIndex, index);
  continue;
}

在移动游标之后调用 parseEndTag 进行清栈

function parseEndTag(tagName, start, end) {
    let pos, lowerCasedTagName;
    // 可选参数
    if (start == null) start = index;
    if (end == null) end = index;

    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase();
      // 查找栈中最近的打开的标签并记录位置为pos
      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 (
          process.env.NODE_ENV !== "production" &&
          (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);
        }
      }

      // 清除栈中只开不合标签
      stack.length = pos;
      // 重新赋值栈顶标签名,如果栈是空的,则为0
      lastTag = pos && stack[pos - 1].tag;
    } 
    // 如果pos被减到-1了,表示stack栈中没找到这个标签,
    // 也就是说用户只写了个闭合标签,那么根据html的规则
    // 除了</br>和</p>,其他的都不解析
    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);
      }
    }
  }

3、开始标签

// 正则匹配开始标签
const startTagMatch = parseStartTag();
if (startTagMatch) {
  handleStartTag(startTagMatch);
  if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
    advance(1);
  }
  continue;
}

首先进行正则匹配,其中包括匹配vue的指令、html标签属性、移动游标,然后将得到了属性整理到 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;
      // 匹配第一个反括号,赋值给end表示结束位置 &&
      // 匹配动态的属性值如 v-band、v-modle、v- ||,此属性也有可能是:class这种
      // 匹配属性如class,style
      while (
        !(end = html.match(startTagClose)) &&
        (attr = html.match(dynamicArgAttribute) || html.match(attribute))
      ) {
        attr.start = index;
        // 移动游标到属性后
        advance(attr[0].length);
        attr.end = index;
        // 将属性存入match
        match.attrs.push(attr);
      }

      if (end) {
        // end[1]取到自闭合标签的斜杠
        match.unarySlash = end[1];
        // 移动游标到 > 后
        advance(end[0].length);
        match.end = index;
        // 返回处理后的信息
        return match;
      }
    }
  }

当匹配的内容不是空值时,进行一些容错操作,将标签压入 stack 栈,调用钩子:

function handleStartTag(match) {
    // 匹配到的标签名
    const tagName = match.tagName;
    const unarySlash = match.unarySlash;
    // 这个字段不知道是什么意思,默认是true
    if (expectHTML) {
      // lastTag 栈顶的元素,也就是最近压入栈的开始标签
      // 我们知道 p 标签中一般写含内元素,如果写 div 这种块级元素,那么html会直接将此元素解析到p标签后
      // isNonPhrasingTag表示块级元素,如果p中有块级元素
      if (lastTag === "p" && isNonPhrasingTag(tagName)) {
        // 解析栈顶的标签,对它进行闭合
        parseEndTag(lastTag);
      }
      // 如果你有相应的标签没有闭合,那么vue会帮你,但只支持一些标签
      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];
      // 这三个地方都可能有值,因为这里可能有三种情况 " ",(不带引号),"' '"
      // 感兴趣的可以研究一下此处的正则
      const value = args[3] || args[4] || args[5] || "";
      // 是否需要对href中的换行符进行转义
      // 为了兼容浏览器的操作,我现在使用的chrome是不用转义其他浏览器我没试
      const shouldDecodeNewlines =
        tagName === "a" && args[1] === "href"
          ? options.shouldDecodeNewlinesForHref
          : options.shouldDecodeNewlines;

      attrs[i] = {
        name: args[1],
        // 对用户输入所包含的特殊字符或标签进行编码或过滤,防止 xss 攻击
        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;
      }
    }
    // 不是自闭合标签
    if (!unary) {
      // 加入stack栈
      stack.push({
        tag: tagName,
        lowerCasedTag: tagName.toLowerCase(),
        attrs: attrs,
        start: match.start,
        end: match.end, 
      });
      lastTag = tagName;
    }
     // 调用start钩子
    if (options.start) {
      options.start(tagName, attrs, unary, match.start, match.end);
    }
  }

4、文本

前几种状况都是以模板字符串是<开头的条件下进行匹配的,如果不以这个开头,那么它就一定是文本,如下:

let text, rest, next;
if (textEnd >= 0) {
  // 截取文本
  rest = html.slice(textEnd);
  // 正则匹配:若文本开头存在 结束标签、打开的开始标签、注释、条件注释
  while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
  ) {
    // 判断是否为纯文本
    next = rest.indexOf("<", 1);
    // 是:跳出循环
    if (next < 0) break;
    textEnd += next;
    // 不是:切除文本使 < 处于开头
    rest = html.slice(textEnd);
  }
  // 获取文本
  text = html.substring(0, textEnd);
}
  // 没有在模板字符串中找到 <,即 textEnd 为 -1
if (textEnd < 0) {
  text = html;
}

if (text) {
  // 移动指针
  advance(text.length);
}
if (options.chars && text) {
  // 调用文本钩子
  options.chars(text, index - text.length, index);
}

总结

编译器解析的过程细看还是十分复杂的,因为要考虑很多情况和边界问题,但总体的流程还是十分的清晰的,无非就是在 while 循环里用正则匹配模板字符串,解析相应的内容然后调用相应的回调函数,利用回调函数不断的对 ast 进行构建,随后将被解析过的一小段 template 移除继续进行后面的循环,直到模板字串符被我们处理完。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions