如何使用 JS 实现一个 HTML 解析器
浏览器底层有一块非常重要的事情就是 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、核心原理
- 用正则匹配出
<tag class="tag" aa="">
、</tag>
- 通过先进后出(栈)的方式匹配标签对(
<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,分别会有 Element
、 HTMLElement
、 Text
、 Comment
等,有兴趣的同学可以基于 W3C 标准实现真正的 HTML 解析器。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论