從 Vue 中 parseHTML 方法來看前端 html 詞法分析

Yutoti_三石發表於2021-07-20

先前我們在 從 Vue parseHTML 所用正則來學習常用正則語法 這篇文章中分析了 parseHTML 方法用到的正規表示式,在這個基礎上我們可以繼續分析 parseHTML 方法。

先來看該方法整體結構:

function parseHTML(html, options) {
  // ...
  let index = 0;
  let last, lastTag;
  while (html) {
    // ...
  }
  parseEndTag();
}

從整體結構上說就是通過從頭開始遍歷 html 元素,直至遍歷至末尾。最後再呼叫 parseEndTag 方法,解析 endtag

再來看 while 中的邏輯:

while (html) {
  last = html;
  if (!lastTag || !isPlainTextElement(lastTag)) {
    // ...
  } else {
    // ...
  }
  if (html === last) {
    // ...
    break;
  }
}

這裡的 lastTag 用來表示上一個標籤。isPlainTextElement 用來判斷標籤是否為 <script><style><textarea> 三者中其中一個。所以這裡是為了判斷當前標籤是否包含在了以上標籤之中。大多數時候我們的 Vue 應用 isPlainTextElement 的判斷都會為 false。

if (!lastTag || !isPlainTextElement(lastTag))

lastTag 或 有 lastTag 但其不為 <script><style><textarea> 三者中其中一個。

if (!lastTag || !isPlainTextElement(lastTag)) {
  let textEnd = html.indexOf('<')
  if (textEnd === 0) { /* ... */ }

  let text, rest, next
  if (textEnd >= 0) { /* ... */ }
  if (textEnd < 0) { /* ... */ }
  if (text) { /* ... */ }
  if (options.chars && text) { /* ... */ }

if (textEnd === 0)

if (textEnd === 0) {
  // 處理 comment、conditionalComment、doctype
  if (comment.test(html)) { /* ... */ }
  if (conditionalComment.test(html)) { /* ... */ }

  const doctypeMatch = html.match(doctype)
  if (doctypeMatch) { /* ... */ }

  // endTagMatch 匹配 html 中如 </div> 的字串
  const endTagMatch = html.match(endTag)
  if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    // 找到 stack 中與 tagName 匹配的最近的 stackTag,並呼叫 options.end 將 endTag 轉換為 AST
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
  }
  // startTagMatch 儲存了 startTag 的 tagName、attrs、start、end 等結果
  const startTagMatch = parseStartTag()
  if (startTagMatch) {
    // 分析 startTag 中屬性,並呼叫 options.start 將 startTag 轉換為 AST
    handleStartTag(startTagMatch)
    if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
      advance(1)
    }
    // 繼續下一迴圈
    continue
  }
}

if (textEnd >= 0)

// textEnd 記錄了 `<` 的位置
if (textEnd >= 0) {
  // rest 記錄了 html 中從 `<` 到最末尾的字串
  rest = html.slice(textEnd);
  while (
    !endTag.test(rest) && // 非 endTag: `</div>`
    !startTagOpen.test(rest) && // 非 startTagOpen: `<div `
    !comment.test(rest) && // 非 comment: `<!--`
    !conditionalComment.test(rest) // 非 conditionalComment: `<![`
  ) {
    // 下一個 `<` 的位置
    next = rest.indexOf("<", 1);
    if (next < 0) break;
    textEnd += next;
    rest = html.slice(textEnd);
  }
  // text 記錄了從 html 字串開頭到 `<` 的字串
  text = html.substring(0, textEnd);
}

剩餘邏輯

// 如 `<` 不存在
if (textEnd < 0) {
  text = html;
}

// 將 index 後移 text 長度,html 做擷取
if (text) {
  advance(text.length);
}

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

else

通常不會進入該邏輯,暫不分析。

附錄

parseEndTag

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

  // pos 儲存了 stack 中與 tagName 匹配的最近的標籤
  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;
  }

  if (pos >= 0) {
    // Close all the open elements, up the stack
    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,
        });
      }
      // 用 options.end 將 end 標籤解析為 AST
      if (options.end) {
        options.end(stack[i].tag, start, end);
      }
    }

    // 移除在 stack 中匹配位置之後的標籤
    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);
    }
  }
}

parseStartTag

用於解析 html 標籤中 <div id="mydiv" class="myClass" style="color: #ff0000" > 部分,並將結果用 match 儲存。

function parseStartTag() {
  // startTagOpen 匹配如 `<div ` 的字串
  const start = html.match(startTagOpen);
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index,
    };
    advance(start[0].length);
    let end, attr;
    // startTagClose 匹配如 ` />` 或 ` >` 的字串,dynamicArgAttribute: `v-bind:[attributeName]="url"`,attribute: `id="mydiv"`
    // 若往後匹配到 dynamicArgAttribute 或 attribute,且一直匹配不是 startTagClose,下面的 while 迴圈一直進行
    // 迴圈內將 attribute 等匹配結果用 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);
    }
    // 到達 ` />` 的位置,將 end 用 match.end 儲存
    if (end) {
      match.unarySlash = end[1];
      advance(end[0].length);
      match.end = index;
      return match;
    }
  }
}

advance

將 html 字串向後移動 n 位,得到從 n 到結尾的字串

function advance(n) {
  index += n;
  html = html.substring(n);
}

handleStartTag

用於分析 startTag 中屬性,並呼叫 options.start 將 startTag 轉換為 AST

function handleStartTag(match) {
  const tagName = match.tagName;
  const unarySlash = match.unarySlash;

  // expectHTML 來自於 baseOptions.expectHTML,初始值為 true,第一次會執行
  // 裡面邏輯暫不分析
  if (expectHTML) {
    if (lastTag === "p" && isNonPhrasingTag(tagName)) {
      parseEndTag(lastTag);
    }
    if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
      parseEndTag(tagName);
    }
  }

  // unary 用來表示標籤是否自閉合
  const unary = isUnaryTag(tagName) || !!unarySlash;

  // 下面一段用來將 match.attrs 放入 attrs 變數,供後續使用
  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] || "";
    const shouldDecodeNewlines =
      tagName === "a" && args[1] === "href"
        ? options.shouldDecodeNewlinesForHref
        : options.shouldDecodeNewlines;
    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;
    }
  }

  // 如果是非自閉合的標籤,則將標籤各個屬性 push 進 stack,並將 tagName 賦給 lastTag
  if (!unary) {
    stack.push({
      tag: tagName,
      lowerCasedTag: tagName.toLowerCase(),
      attrs: attrs,
      start: match.start,
      end: match.end,
    });
    lastTag = tagName;
  }

  // options.start 用來將開始標籤轉換為 AST
  if (options.start) {
    options.start(tagName, attrs, unary, match.start, match.end);
  }
}

相關文章