资讯详情

资讯详情

手写W3C规范的HTML语法检查器:解析器原理与工程实践

我是干前端开发的时候被浏览器惯坏了的典型。页面标签忘了闭合、属性值没加引号、p里塞了h1浏览器全都默默帮你“修正”Console 里连个报错都懒得给。可一旦到了自动化测试、爬虫解析、CMS 内容校验这些场景这种容错就成了灾难。所以我自己手写了一个遵循 W3C 规范的 HTML 语法检查器不依赖现成的htmlhint从词法分析开始一步步搓过程踩了不少坑也把 HTML 规范和解析器的那点事摸了个透。这篇文章把完整思路和实现细节整理出来适合想深入理解 HTML 解析原理的开发者也适合手头有 HTML 校验需求、但不想被现成工具的黑盒逻辑牵着走的人。我会尽量用“说人话”的方式解释 token 化、解析树、规范校验这些概念并附上可直接参考的代码思路和避坑心得。1. 需求拆解与整体设计1.1 为什么不用现成的检查工具市面上已经有htmlhint、tidy、Nu Html Checker等工具它们都做得很好。但我想搓一个自己的检查器不是为了重复造轮子而是有几层实际考量。第一现成工具往往把解析和校验绑定在一起你想要自定义一条规则比如“强制图片必须带alt”、”a禁止包含div“要么写插件要么提交 PR改动链路太长。从零实现的话解析树长什么样、规则怎么注册完全自己掌控。第二很多场景需要的是“轻量可嵌入”的校验能力。比如我做过一个文档编辑器用户粘贴 HTML 时要实时提示错误把整个tidy塞进前端显然太重。自己实现一个核心仅几 KB 的检查器按需加载更方便。第三现成工具对规范的处理策略是黑盒。接了 W3C 的 HTML 标准之后你会发现规范里大量篇幅在讲“错误处理”因为浏览器必须对坏文档有确定行为。检查器则需要区分“严格报错”和“容忍修复”这个粒度现成工具未必满足。自己写的好处是你能准确控制“报告哪些错误、以什么严重级别报告、如何处理可自动修复的问题”。坏处也很直接解析器的边界情况非常多后面我会重点讲。1.2 检查器需要覆盖的规范范围W3C 的 HTML 规范现在由 WHATWG 维护复杂到离谱完整实现一个遵循规范又能通过全部测试的解析器工作量相当于一个中型项目。我这版检查器做了合理的范围裁剪优先覆盖高频且影响面大的几类规则文档结构DOCTYPE是否存在、html、head、body是否唯一文档层级是否合法。标签闭合检查是否有未闭合的标签、交叉闭合的错误、自闭合标签误用。嵌套合法性块级元素与内联元素的嵌套规则比如p里不能嵌套块级元素ul的子元素只能是li等。属性合法性属性名是否合法、属性值是否缺引号、布尔属性是否正确、某些标签的必需属性是否缺失例如img的src和alt。字符引用实体是否有分号结尾、命名实体是否存在、后跟非法字符的处理。编码与元信息meta charset是否存在、lang属性是否缺失等。严格意义上一套“完整验证”需要参照规范中的“元素内容模型content models”这是最绕的部分。比如select内部只能包含option、optgroup、hr和脚本元素但这属于“可选增强规则”我放在了独立规则层没写死在解析器里。1.3 技术选型与整体架构我用 Node.js 实现主要因为它的生态和字符串处理能力方便测试。整体架构分三层词法分析器Tokenizer把 HTML 字符串切成 Token比如开始标签、结束标签、文本、注释、DOCTYPE、字符引用。解析器Parser消费 Token 流构建一棵简化的 DOM 树。这棵树不需要像真实浏览器那样挂载很多属性和方法只要能表达元素之间的父子关系、属性集合、文本内容即可。校验规则引擎Validator遍历 DOM 树按注册的规则逐项检查收集报告对象。这样分层的好处是解析和校验解耦。我可以先用解析器生成树然后在上面跑不同的规则集合甚至支持用户自定义规则。协议上我只暴露两个接口// 解析 function parse(htmlText, options) - { rootNode, errors } // 校验 function validate(htmlText, ruleConfig) - { report }这个架构跟浏览器内部的分层思路一致虽然实现简化了但思维模型是相通的。你后面看规范的“tokenization”“tree construction”章节时会发现似曾相识。2. HTML 解析器核心实现2.1 词法分析从字符流到 Token词法分析是整个检查器的地基。HTML 不像 JSON 那样语法干净它包含多种状态标签名、属性名、属性值带引号/不带/空、注释、CDATA、处理指令、DOCTYPE、文本内容等。规范里用了庞大的状态机来描述我实现时做了裁剪但核心状态不能省。我定义 Token 结构如下// 开始标签示例 { type: startTag, tagName: div, selfClosing: false, attrs: [ { name: class, value: container }, { name: id, value: main } ], loc: { start: 10, end: 45 } // 用于定位报错位置 } // 文本示例 { type: text, value: Hello, world, loc: { start: 50, end: 64 } }扫描时的核心逻辑就是“以为边界切换模式”。具体来说遇到尝试读取是否匹配/结束标签、!--注释、!DOCTYPE文档类型、![CDATA[等。如果是后面跟字母、/、!或?之外的字符那么按文本处理 只是文本中的普通字符。读取标签名时跳过空白然后进入属性的循环读取。属性读取是整个 Tokenizer 最容易出错的地方。我踩的第一个坑就是属性值可以用单引号、双引号也可以完全不写引号在 HTML 下允许。不写引号时属性值一直读到空白符或为止。这个必须按规范模拟否则遇到classfoo onclickbar这种写法会拆错。一个简单的 Tokenizer 骨架如下省略了注释和 DOCTYPE 分支function tokenize(html) { const tokens []; let i 0; while (i html.length) { if (html[i] ) { // 尝试匹配结束标签 if (html[i 1] /) { const end html.indexOf(, i); const tagName html.slice(i 2, end).trim(); tokens.push({ type: endTag, tagName, loc: { start: i, end: end 1 } }); i end 1; } else if (html[i 1] /[a-zA-Z]/.test(html[i 1])) { // 开始标签解析 let j i 1; let tagNameMatch html.slice(j).match(/^[^\s/]/); if (tagNameMatch) { const tagName tagNameMatch[0]; j tagName.length; const attrs []; let selfClosing false; // 循环解析属性 while (j html.length) { // 跳过空白 while (/\s/.test(html[j])) j; if (html[j] ) { j; break; } if (html[j] / html[j 1] ) { selfClosing true; j 2; break; } // 属性名 let attrName ; while (j html.length !/[\s/]/.test(html[j])) { attrName html[j]; } // 跳过空白 while (/\s/.test(html[j])) j; let attrValue ; if (html[j] ) { j; while (/\s/.test(html[j])) j; const quote html[j]; if (quote || quote ) { j; while (j html.length html[j] ! quote) { attrValue html[j]; } j; // 跳过闭合引号 } else { while (j html.length !/[\s]/.test(html[j])) { attrValue html[j]; } } } attrs.push({ name: attrName, value: attrValue }); } tokens.push({ type: startTag, tagName, attrs, selfClosing, loc: { start: i, end: j } }); i j; continue; } } // 其他分支略 } else { let next html.indexOf(, i); if (next -1) next html.length; tokens.push({ type: text, value: html.slice(i, next), loc: { start: i, end: next } }); i next; } } return tokens; }这段代码还极其简陋比如没处理注释、没有对属性名大小写归一化但已经能跑通简单场景。真正的生产级 Tokenizer 还需要处理出现在被引号包裹的属性值里的情况上面的代码把当作普通字符在解析属性值时因为只看引号所以天然能扛住。但十几种状态写下来确实需要耐心。2.2 构建 DOM 树的关键算法Token 流拿到后下一步是建树。建树的核心就是维护一个“当前打开的节点栈”。规则很简单遇到开始标签并且不自闭合就创建一个元素节点挂到当前栈顶节点的子节点列表中然后压入栈。遇到文本节点直接作为当前栈顶节点的子节点。遇到结束标签从栈中查找最近一个同名元素把它和它之后的所有元素都弹出来挂到合适的位置如果找不到就记录“未闭合”或“多余结束标签”错误。但真实 HTML 规范里“从栈中查找同名元素”之后还有一个**“如果中间隔着未闭合的子元素则自动闭合子元素”**的隐含修复过程。比如divspan/div浏览器会把span自动闭合最后 DOM 结构是div内包含一个span栈里span自动弹出。我采用的策略是function buildTree(tokens) { const root { type: root, children: [] }; const stack [root]; for (const token of tokens) { if (token.type startTag) { if (token.selfClosing) { stack[stack.length - 1].children.push({ type: element, tagName: token.tagName, attrs: token.attrs, children: [], selfClosing: true }); } else { const el { type: element, tagName: token.tagName, attrs: token.attrs, children: [] }; stack[stack.length - 1].children.push(el); stack.push(el); } } else if (token.type endTag) { // 栈中查找 const idx stack.map(n n.tagName).lastIndexOf(token.tagName); if (idx -1) { emitError(unmatched-end-tag, token); } else { // 将 idx 之后的节点全部弹出并保持父子关系 const popped stack.splice(idx, stack.length - idx); // 弹出来的最后一个节点是目标节点其之后的兄弟节点需要挂到目标下 const target popped[0]; if (popped.length 1) { target.children target.children.concat(popped.slice(1)); } // 栈顶可能变了但目标已闭合 } } else if (token.type text) { stack[stack.length - 1].children.push({ type: text, value: token.value }); } } // 栈中剩余未闭合的节点 if (stack.length 1) { for (let i 1; i stack.length; i) { emitError(unclosed-tag, stack[i]); } } return root; }这里要特别提一下lastIndexOf而不是indexOf的原因。因为规范采用的是“先找到最近匹配”比如divspandiv/div外面的div先匹配不会错误地去闭合内层div。用栈从尾到头找是对的。另一件容易忽略的事是隐式闭合标签。很多标签即便没有显式写/p在遇到下一个p或者块级元素时也应该自动闭合。规范里有“end tag omission”的概念例如li、p、tr等。如果严格检查未显式闭合的p也可以视为错误但浏览器会隐式闭合。我的策略是在 Tokenizer 阶段不做隐式闭合保持原始信息在解析树阶段可以做隐式的容错但校验规则单独判断“是否缺少显式结束标签”。这样规则的可配置性更强。2.3 修正常见解析错误解析 HTML 时最烦人的是各种非法输入。我处理了几类高频错误场景并选择了“容忍报告”的策略。第一类是结束标签跨层级匹配。比如bi/b/i规范上/b会触发自动闭合i然后/i成为多余标签。我的解析器在遇到/b时栈里找到b位置把b之后的i也弹出并报告一个 “element closed by wrong end tag” 的警告。这样 DOM 树不会崩但报告里明确提示了问题。第二类是未转义的特殊字符。比如纯文本中出现3这是很多老代码常犯的。规范在“data state”下如果遇到但后续字符不是合法标签开始就当作文本处理。所以我看到3会输出文本但校验层可以追加一条“疑似未转义小于号”的提示。第三类是备注了self-closing却用于非空元素的标签例如div/。在 HTML 中只有 void 元素如br、img、input的起始标签允许自闭合斜杠普通元素写div/并不会自闭合浏览器会把/当成属性名。我的 Tokenizer 里做了特殊处理如果标签是普通元素且带selfClosing会生成一个“void-element-required”错误同时按浏览器规则忽略自闭合继续解析。修正策略的核心思想是解析器永远不要抛异常收集错误继续前进。因为你检查器的职责是诊断而不是阻止渲染。只要 token 流不崩溃都能继续检查下去。3. 按 W3C 规范实现完整验证3.1 标签闭合与嵌套规则校验标签闭合检查通常分两个层面。第一层是“配对闭合”即在解析树阶段已经能发现未闭合标签和多余结束标签。第二层是“规范闭合”比如p可以省略/p在浏览器里合法但严格校验时你需要报告“missing-end-tag”。我在规则里维护了一个“需显式闭合元素”集合主要包括非自动闭合的非空元素div、span、section、a、button、td等这些标签必须显式出现对应的结束标签。规范允许省略结束标签的p、li、dt、dd、tr等我会标记为“auto-closed”信息级别而不是错误。判断方式是在解析树遍历时看每个 element 节点是否有“endTagPresent”标记。我最初的设计是在 Token 解析时记录每个元素的闭合信息后面再单独遍历。嵌套规则的校验更有意思。规范把元素按内容模型分为元数据内容、流式内容、标题内容、短语内容、嵌入内容、交互内容等。但做校验时不需要完全背下内容模型我采用一张“禁止嵌套表”“允许元素例外表”的组合。比如a内部不能嵌套a。p内部不能包含div、p、section等块级元素。ul的直接子元素只能是li、script、template且li必须直接挂在一个ul或ol下。table内部必须依次为caption(可选)、colgroup、thead、tbody、tfoot、tr结构。实现嵌套校验前我先想着“要把解析树遍历得多深”。最初我直接递归遍历树每到一个元素就检查父元素然后检查兄弟上下文。后来发现有些规则需要“祖先上下文”比如form不能嵌套form但不能只检查父级要向上查找是否已有form祖先。于是我额外维护了近祖先检查函数function findAncestor(root, tagName, limit) { let cur root.parent; while (cur) { if (cur.tagName tagName) return cur; cur cur.parent; } return null; }还有一个容易漏掉的是template标签。它的内部内容不参与正常文档流但template本身可以出现在很多位置。我在解析时默认把template的内容解析为独立子树嵌套规则校验时对template内部重新走一套规则。处理起来会复杂一些但能减少很多误报。3.2 属性合法性与必需属性检查属性检查这块最基础的是检查属性名是否合法。HTML 属性名不能包含空格、/、、等特殊字符且不能以数字开头虽然自定义属性可以用>const attrNameRegex /^[a-zA-Z_:][a-zA-Z0-9:._-]*$/;这个正则允许了:和.能覆盖旧式的 XML 风格属性实际 W3C HTML5 规定的属性名其实只允许小写 ASCII 字母和连字符但我这里放宽容错等规则层再去判断是否是自定义属性。属性值方面需要检查几类问题缺少引号。规范里 HTML 允许不带引号但如果你把检查器用于“推荐规范代码风格”可以加一条“attr-value-quotes”建议。引号不配对。比如classa classb在 HTML 里重复属性名按规范第一个优先后面等价于不存在。这个要报告 warning。布尔属性。disabled、checked、required、readonly等布尔属性不应该有属性值规范里是“无值属性”如果你写disabledfalse浏览器照样识别为 disabled。所以检查器可以提示“boolean attribute must not have a value”。必需属性。这部分按元素分别定义比如img的srca的hrefmeta的content等。我把它们组织成一个表const requiredAttrs { img: [src, alt], a: [href], script: [src], // 注意内联脚本不需要 src source: [src, srcset], };对于alt是否必须规范并不是强制所有图片都要有alt装饰性图片可空所以我在规则配置里给了allowEmptyAlt选项。属性校验中还有个坑是事件处理器属性。比如onclick的属性值通常包含字符如果没转义成amp;会提前终止属性值。这不属于校验错误但是 Tokenizer 解析时如果不注意会导致属性被截断。我在 Tokenizer 里遇到时会先判断是否形如实体引用如果是合法实体保留原样让校验层去判断是否缺失分号如果非法则原样输出并报告。3.3 DOCTYPE、编码、meta 等文档级检查一个完整的 HTML 文档检查必然包括开头部分。W3C 规范要求必须在第一行给出!DOCTYPE html我把它定义为“毁灭级”错误severity 为 error因为缺失 DOCTYPE 会让浏览器进入 quirks 模式破坏渲染。我的检查逻辑是读取 token 流第一个非空 token如果它不是 DOCTYPE token报告 missing-doctype。DOCTYPE token 的大小写规范允许写!doctype html、!DOCTYPE HTML等但为了统一我会忽略大小写只检查是否存在“html”关键字。检查meta charset是否在head中显式声明。HTML5 规范要求charset出现在前 1024 字节内我这里简化成“只要 head 中存在则通过如果完全丢失则报错”。编码检查其实牵扯更广。页面声明的字符集和实际字节编码不一致会导致乱码。检查器无法判断字节流但可以检查meta charset的内容是否在合法值列表里UTF-8、latin1、GBK 等。对于非UTF-8的声明我给出 warning因为现代开发默认应该统一使用 UTF-8。此外文档级检查还包括html标签和lang属性。lang缺失虽然不影响结构但影响无障碍和翻译工具所以我把它作为 a11y 规则的一部分。还有一个经常被忽略的点是head中不允许出现文本内容或非元数据元素。如果你在head里写了个div浏览器会默默把它挪到body里但严格检查应该报错。我的方案是遍历head的直接子元素如果不是meta、link、title、style、script、base、template之一就提示 misplaced-element-in-head。3.4 ARIA 与无障碍相关检查W3C 规范中跟无障碍相关的主要是 ARIA 规范。虽然它不直接属于 HTML 语法但检查器支持 ARIA 规则会显得更完整。我加了几个实用的检查role属性值必须存在于 ARIA 角色表中。比如rolebutton合法rolebtn非法。aria-*属性必须与其role所支持的状态匹配。这个比较复杂我简化成“检查是否属于已定义的 ARIA 属性表以及属性名是否拼写正确”。tabindex的值建议在-1、0或大于等于0的整数范围内不能是其他字符串。若存在rolebutton则应同时提供一个键盘可访问的替代方式比如tabindex0和keydown处理但这类深度逻辑我仅输出 info 级别提示。ARIA 规则如果严格做需要维护多张表。我当时的做法是导入一个精简的 ARIA 定义 JSON包含角色名、角色的子级关系、可用的aria-*属性。即使在检查器中用不到全量也对学习有很大的帮助。4. 实操过程与测试4.1 开发环境搭建与依赖我做了最简配置只用了 Node.js 自带模块没有引入 npm 依赖。项目结构如下mini-html-lint/ src/ tokenizer.js parser.js validator.js rules/ structure.js attributes.js nesting.js a11y.js report.js test/ samples/ bad.html good.html weird.html lint.js执行入口lint.js做的事情非常简单读取文件内容调用parse得到 DOM 树然后跑默认规则输出报告。样式方面我决定用 JSON 输出方便自动化工具进一步处理。const { parse } require(./src/parser); const { validate } require(./src/validator); const report require(./src/report); const html require(fs).readFileSync(process.argv[2], utf8); const ast parse(html); const result validate(ast, { rules: recommended }); console.log(report.format(result));开发时我建议每一步都配一个最小测试文件。比如测试 Tokenizer 时我创建了test/tokenizer.test.js用断言检查 token 数量和类型。在小型项目里手写简单断言函数就够用了不必挂重型测试框架。4.2 编写检查规则的实现要点规则引擎的设计是每个规则是一个函数接收“遍历上下文”返回错误对象数组。上下文里主要包括当前节点、父节点、兄弟节点、祖先路径、规则配置。我的建议是规则函数保持单一职责。以“禁止嵌套a”为例function noNestedAnchor(ctx) { const errors []; ctx.node.children.forEach(child { if (child.type element child.tagName a) { errors.push({ rule: no-nested-anchor, severity: error, message: Nested a element is not allowed, loc: child.loc, fix: Replace inner anchor with span }); } }); return errors; }这里ctx.node是当前遍历到的元素。遍历器采用深度优先每到一个元素节点先执行所有规则再递归子节点。这样的好处是规则内部不需要自己写递归。为了方便定位错误我在 Token 里存了loc对象然后把它传给每个创建的节点。如果你用的是现成的解析库通常也有position信息但自己写时最容易漏的就是这个位置信息。没有loc报告就只是一句没用的“出错了”使用者根本不知道错在哪一行。4.3 用真实页面样例验证效果我拿自己写过的几个历史页面来测试效果非常直观。一段常见的错误写法!DOCTYPE html html headtitleTest/title/head body pHello divworld/div img srccat.jpg a hrefx.htmla hrefy.htmlgo/a /body /html检查结果[error] p element cannot contain block-level div [error] img element must have alt attribute [error] a element cannot be nested inside a [warning] missing explicit closing tag for p这个样例覆盖了三类典型问题。第一类严格嵌套错误第二类必需属性缺失第三类是嵌套链接。我还准备了一组“规范正确但风格欠佳”的样例比如p classfooHello这里classfoo在 HTML 规范里是允许的。我的检查器默认只给 info不开 waring。这点很重要检查器不能把个人风格偏好当错误否则误报太多使用者就不再信任报告了。所以我给每条规则都配了severity默认配置遵守 W3C 规范而不是随大流的“代码风格指南”。5. 常见问题与排查经验5.1 字符实体和编码问题字符实体是我最早没做好的地方。一开始我在 Tokenizer 里直接用正则/([a-zA-Z0-9]);/g去提取实体结果遇到notit;这种就出错。后来才明白实际上解析时必须从一个开始回到规范中的“character reference state”逐字符匹配最长有效实体名而不是先用正则扫描。举例notit;分离出来其实是not后面跟着it;这是老坑。正则贪婪匹配会试图匹配notit失败后回溯容易产出错误的结果。规范的做法是“查找最长有效实体名”也就是在实体表中匹配最长前缀比如not是合法实体则读到noti时匹配不到更长实体就停止后面剩下的文本继续作为原文。我实现时的简化方案是定义一张实体映射表包括amp、lt、gt、quot、apos和上百个命名实体然后从后第 1 个字符开始不断延长字符串查表直到查不到为止。查不到时再判断是否要以“最后有效前缀分号”作为对照。整个过程逻辑不复杂但边界方式很多。排查这类问题的最好方法是写出一个独立的“实体提取”小脚本对已知合法和非法字符串做断言测试。建议把以下用例作为基准nbsp; - 正确 nbsp - 报错 missing semicolon foo; - 报错 unknown entity 42 - 不报错? 不是合法实体起点对于42这类HTML 规范里后跟数字量不能形成命名实体但可以形成数字字符引用。我后续又补了数字引用检查#123;合法#x1F600;合法但范围超出 Unicode 的非法。5.2 自闭合标签的特殊处理自闭合标签容易踩的坑不止div/一种。还有像script/、textarea/这些理论上不能用自闭合但浏览器会特殊处理。更有意思的是svg、math是 XML 集成点它们内部的自闭合规则跟 HTML 不一致。我的处理方式是在解析器中维护“void 元素集合”const voidElements new Set([ area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr ]);Token 化的时候如果遇到一个不在这个集合中的标签带/ 我立刻生成一条错误同时把标签当作普通开始标签处理。这样做既不影响解析树又会报告问题。另一个特殊场景是自定义元素。比如my-component/这类 Web Component 写法开发者习惯了自闭合但 HTML 解析器并不认。按规范my-component/中的/会被当成属性名。我没让检查器报错只发一条 info“custom-element self-closing syntax is invalid in HTML, use closing tag”。因为 Web Component 框架如某些前端框架在编译期会转换掉所以不适合当作硬错误。5.3 性能优化与正则陷阱解析较大 HTML 文件时比如几百 KB如果每次都用indexOf找下一个性能会很差。我在实际优化中发现瓶颈主要集中在两个点一是html.split()这类粗暴切割。这样做很快但一旦属性值里出现就会出错。所以还是要用逐字符扫描。二是正则/[^]/匹配文本。虽然快但遇到和的边界情况会产生回溯。最终我把 Tokenizer 改成显式状态机性能反而比初期正则版本好。原因是正则引擎在长文本上做了大量回溯而状态机是线性扫描。优化后处理一个 1 MB 的 HTML 文件大约 90 ms不含规则校验。在后面跑 20 多条规则时总耗时约 200 ms。如果你也追求高性能建议用Buffer或Uint8Array遍历字节而非字符串索引。字符串索引在底层仍会做 UTF-16 编码转换但 JS 引擎已经优化到足够快实测差别并不大。5.4 检查器误报与容错策略手写检查器最怕的就是“乱报错”。一个实际案例用户写了一段伪代码或模板语法div classcontainer {% if user %} pHi/p {% endif %} /div我的 Tokenizer 会把{% if user %}识别成什么遇到%不是合法标签开始所以{%会作为文本输出倒是没有报错。但如果是p模板中嵌入php?等类似内容就会有问题。一些模板语言使用svelte:head或者{#if}这类语法需要额外配置“忽略模板表达式”。我的解决方式是增加一个“忽略区域”配置允许通过正则或标记对预处理后的 HTML 移除模板部分。但更彻底的做法是让 Tokenizer 支持“模板原始文本状态”比如textarea、script、style内的内容不进行任何标签解析直接当纯文本处理。这个能力很重要否则你在检查script中的 JS 代码时如果代码里出现了a b就会被解析成开始标签开始。实现这个只需要在 Tokenizer 中维护一个“当前处于 raw text 的元素名”状态// 进入 script 标签后将所有内容原样保存直到遇到 /script if (tagName script || tagName style || tagName textarea) { const closeTag /${tagName}; const endIdx html.indexOf(closeTag, i); // 把这段内容作为 text token然后跳过到结束标签 }另外对动态生成的 HTML 片段做检查时比如你只给一段tabletrtd没有html、head、body检查器会报一堆“missing-doctype”错误。为避免这种情况我加入了“片段模式”lintHtml(html, { fragment: true });在片段模式下自动跳过文档级规则。这个设计能大幅降低误报也让检查器可以安全地用在实时编辑器的局部校验中。写在最后的个人心得手搓检查器的过程比预期痛苦得多尤其是把 W3C 规范的边角料翻完之后我才意识到浏览器工程师有多不容易。最大的收获不是代码本身而是彻底改变了看待 HTML 的方式它不是一个简单的 XML 变种而是有自己容错逻辑的健壮格式。回到实际使用上我现在已经把这套检查器用在一个内部 CMS 的内容发布场景里。每次编辑保存前扫描一次把明显的问题挡在发布之前出问题的概率小了很多。如果你也在做类似的需求我的建议是先做最基础的三件事闭合检查、必需属性检查、嵌套禁区检查。这三样覆盖了日常 80% 的问题。如果继续扩下去下个方向可以接入 W3C 官方的 Nu Validator API或者把规则引擎做成可插拔的 npm 包。但对你而言最值得的还是亲手实现一遍解析器那种“原来标签还能这么解析”的酸爽只有自己写出来才懂。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →