大家好, 浏览器底层有一块非常重要的事情就是 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":[]}]}]}]
<tag class="tag" aa="">
、</tag>
<tag></tag>
)首先我们需要初始化一些简单的变量和方法备用:
// 初始化 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;
}
我们用一个例子来说明,给出一个 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 逻辑
}
<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
非常重要,利用了栈的先进后出原理一一匹配到对应的开闭标签
</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 解析器。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/Gl4Sd2c7tqWf1WCTtDDhEA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。