面试官:用 JS 解析一下这个 HTML 内容

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

大家好,我是考拉,浏览器底层有一块非常重要的事情就是 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 = {
 area: true,
    img: true,
    // ...省略了部分标签
};
if (leadingSlash || closingSlash || kSelfClosingElements[tagName]) {
  // 开闭标签名是否匹配,比如有可能写成 <div></div1>,这种就需要异常处理
  if (currentParent.tagName === tagName) {
    // 更新 range,之前处理开标签算出的 range 是不包含闭标签的
    currentParent.range[1] = createRange(-1, Math.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 解析器。


Node 社群

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

2ba6d4d339ae2f8c6cc2d00930418036.jpeg

如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:

1. 点个「在看」,让更多人也能看到这篇文章

2. 订阅官方博客 www.inode.club 让我们一起成长

点赞和在看就是最大的支持

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值