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

发布于 2023-08-18 12:51:06 字数 8190 浏览 31 评论 0

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

原理讲解

1、效果

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

const root = parse(`<div id="test" class="container" c="b">
<div class="text-block"><span>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>
* 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 解析器。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

時窥

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

qq_E2Iff7

文章 0 评论 0

Archangel

文章 0 评论 0

freedog

文章 0 评论 0

Hunk

文章 0 评论 0

18819270189

文章 0 评论 0

wenkai

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文