Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何使用 JS 实现一个 HTML 解析器 #1239

Open
moegirlwangge opened this issue Apr 23, 2022 · 1 comment
Open

如何使用 JS 实现一个 HTML 解析器 #1239

moegirlwangge opened this issue Apr 23, 2022 · 1 comment

Comments

@moegirlwangge
Copy link
Member

https://mp.weixin.qq.com/s?__biz=MzI5NjM5NDQxMg==&mid=2247495537&idx=1&sn=c8d0ef67964f583317add7d1cf4501e3

@github-actions
Copy link

如何使用 JS 实现一个 HTML 解析器 by 淘系前端团队

浏览器底层有一块非常重要的事情就是 HTML 解析器,HTML 解析器的工作是把 HTML 字符串解析为树,树上的每个节点是一个 Node,很多同学都好奇是怎么实现的,这篇文章就用 JS 来实现一个简单的 HTML 解析器。

下面的代码改造自 node-html-parser


原理讲解

1、效果

我们需要实现一个 parse 方法,并且传入 HTML 字符串,返回一个树结构:

const root = parse(`<div id="test" class="container" c="b"><div class="text-block"><span id="xxx">Hello World</span></div><img src="xx.jpg" /></div>`);
console.log(root);
// [{"tagName":"","children":[{"tagName":"div","attrs":{"id":"test","class":"container"},"rawAttrs":"id=\"test\" class=\"container\" c=\"b\"","type":"element","range":[0,128],"children":[{"tagName":"div","attrs":{"class":"text-block"},"rawAttrs":"class=\"text-block\"","type":"element","range":[39,102],"children":[{"tagName":"span","attrs":{"id":"xxx"},"rawAttrs":"id=\"xxx\"","type":"element","range":[63,96],"children":[{"type":"text","range":[78,89],"value":"Hello World"}]}]},{"tagName":"img","attrs":{},"rawAttrs":"src=\"xx.jpg\" ","type":"element","range":[102,122],"children":[]}]}]}]


2、核心原理

  1. 用正则匹配出 <tag class="tag" aa=""></tag>
  2. 通过先进后出(栈)的方式匹配标签对(<tag></tag>


3、初始化

首先我们需要初始化一些简单的变量和方法备用:

// 初始化 2 种 Node 类型
// HTML [nodeType](https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType) 会比较多,这里为了让大家明白核心原理,省去了一些不重要的
const nodeType = {
 TEXT'text',
 ELEMENT'element',
};
// 最外层增加一个模拟的根节点标签
const frameflag = 'rootnode';
// 计算一个完整标签的范围,eg. [0, 50]
const createRange = (startPos, endPos) => {
 // 因为最外层模拟了 <rootnode>,所以需要将这部分长度减掉
 const frameFlagOffset = frameflag.length + 2;
  return [startPos - frameFlagOffset, endPos - frameFlagOffset]
};

// 找到数组的最后一项
function arrBack(arr{
 return arr[arr.length - 1];
}

function parse(data{
  // 最外层模拟的节点
 const root = {
  tagName'',
  children: [],
 };

  // 设置 root 为父节点
 let currentParent = root;
  // 栈管理
 const stack = [root];
 let lastTextPos = -1;

  // 将模拟的根节点和需要解析的 html 拼接
 data = `<${frameflag}>${data}</${frameflag}>`;

  // ...开始遍历/解析

  // 通过处理,将 stack 返回就是最终的结果
  return statck;
}


4、遍历解析/提取 HTML 标签字符串

我们用一个例子来说明,给出一个 HTML 片段:

<div id="test" class="container" c="b">
  <div class="text-block">
    <span id="xxx">Hello World</span>
  </div>
  <img src="xx.jpg" />
</div>

对于这个片段,我们需要依次解析出下面的字符串:

<div id="test" class="container" c="b">
<div class="text-block">
<span id="xxx">
</span>
</div>
<img src="xx.jpg" />
</div>

再说解析之前,我们来学习下 RegExp.prototype.exec() 的使用方法,已经会的可以跳过

exec() 方法会搜索匹配指定的字符串,返回一个数组或 null,如果正则设置了 global,会逐条的遍历所有匹配结果,每次匹配到都会将匹配的字符串末尾位置记录在 lastIndex 属性中,看下下面 Demo

const regex = /foo/g;
const str = 'table football, foosball';
let matchArray;

while ((matchArray = regex.exec(str)) !== null) {
  console.log(`Found ${matchArray[0]}. Next starts at ${regex.lastIndex}.`);
  // expected output: "Found foo. Next starts at 9."
  // expected output: "Found foo. Next starts at 19."
}

那么我们就可以利用 regex.exec 特性将需要的字符串依次匹配出来:

// 参考标签文档:https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
const kMarkupPattern = /<(\/?)([a-zA-Z][-.:0-9_a-zA-Z]*)((?:\s+[^>]*?(?:(?:'[^']*')|(?:"[^"]*"))?)*)\s*(\/?)>/g;
while ((match = kMarkupPattern.exec(data))) {
  /**
    * matchText: 匹配的字符  eg. <span id="xxx">
    * leadingSlash: 是否为闭合标签 eg. /
    * tagName: 标签名 eg. span
    * attributes: 属性 eg. id="xxx"
    * closingSlash: 是否为自闭合 eg. /
    */

  let { 0: matchText, 1: leadingSlash, 2: tagName, 3: attributes, 4: closingSlash } = match;
  // 本次匹配到的字符串
  const matchLength = matchText.length;
  // 本次匹配的起始位置
  const tagStartPos = kMarkupPattern.lastIndex - matchLength;
  // 本次匹配的末尾位置
  const tagEndPos = kMarkupPattern.lastIndex;

  if (lastTextPos > -1) {
    // 处理文本,eg. hello world
    // 上次匹配的末尾位置 + 本次匹配的字符长度 小于 本次匹配的末尾位置就说明中间有 text,这个稍微想下其实还是比较好理解的
    // 如果没有 text,lastTextPos + matchLength 都会等于 tagEndPos
    if (lastTextPos + matchLength < tagEndPos) {
      // 上次匹配的末尾位置到本次匹配的起始位置
      const text = data.substring(lastTextPos, tagStartPos);
      currentParent.children.push({
        type: nodeType.TEXT,
        range: createRange(lastTextPos, tagStartPos),
        value: text,
      });
    }
  }

  // 记录上次匹配的位置
  lastTextPos = kMarkupPattern.lastIndex;

  // 如果匹配到的标签是模拟标签,就跳过
  if (tagName === frameflag) continue;

  // ...处理 nodeType 为 element 逻辑
}


5、处理开标签(eg. <div>

接下来我们开始处理开标签的逻辑(比如 <div><img />),开标签包含了闭合标签和非闭合标签,直接看代码:

if (!leadingSlash) {
  const attrs = {};
  // 解析 id、class 属性,并且挂到 attrs 对象下
  const kAttributePattern = /(?:^|\s)(id|class)\s*=\s*((?:'[^']*')|(?:"[^"]*")|\S+)/gi;
  for (let attMatch; (attMatch = kAttributePattern.exec(attributes));) {
    const { 1: key, 2: val } = attMatch;
    // 属性值是否带引号
    const isQuoted = val[0] === `'` || val[0] === `"`;
    attrs[key.toLowerCase()] = isQuoted ? val.slice(1, val.length - 1) : val;
  }

  const currentNode = {
    tagName,
    attrs,
    rawAttrs: attributes.slice(1),
    type: nodeType.ELEMENT,
    // 这里的 range 不一定是正确的 range,需要匹配到闭标签以后更新
    range: createRange(tagStartPos, tagEndPos),
    children: [],
  };
  // 将当前节点信息放入到 currentParent 的 children 中
  currentParent.children.push(currentNode);
  // 重置 currentParent 节点为当前节点
  currentParent = currentNode;
  // 将每个节点依次塞到栈中,然后在后面的闭标签中以栈的方式释放
  stack.push(currentParent);
}

这里 stack 非常重要,利用了栈的先进后出原理一一匹配到对应的开闭标签


6、处理闭标签和自闭合标签(eg. </div><img />

上面处理开标签过程中将标签放入栈中以后,我们还需要匹配到闭标签后更新 range 并且将之从栈(stack)中踢出:

  
// 自闭合元素
const kSelfClosingElements = {
 areatrue,
    imgtrue,
    // ...省略了部分标签
};
if (leadingSlash || closingSlash || kSelfClosingElements[tagName]) {
  // 开闭标签名是否匹配,比如有可能写成 <div></div1>,这种就需要异常处理
  if (currentParent.tagName === tagName) {
    // 更新 range,之前处理开标签算出的 range 是不包含闭标签的
    currentParent.range[1] = createRange(-1Math.max(lastTextPos, tagEndPos))[1];
    // 将处理完的开闭标签踢出
    stack.pop();
    // 将 stack 的最后一个节点赋值给 currentParent
    currentParent = arrBack(stack);
  } else {
    // <div></div1>,异常直接从栈中踢出,不更新 range
    stack.pop();
    currentParent = arrBack(stack);
  }
}


最后

上述讲解了如何用 JS 实现一个基本的 HTML 解析器,但还有一些代码没有处理,比如省略了 script、style 等标签的处理(nodeType 不全),而且上面的节点我都用普通 Object 来替换,但其实每个 nodeType 对应的对象都会继承自 Node,分别会有 ElementHTMLElementTextComment 等,有兴趣的同学可以基于 W3C 标准实现真正的 HTML 解析器。






@github-actions github-actions bot changed the title archive_request 如何使用 JS 实现一个 HTML 解析器 Apr 23, 2022
@moegirlwangge moegirlwangge transferred this issue from another repository Apr 23, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant