直接用正则替换HTML会出事,因为正则无法可靠识别嵌套标签、引号配对、注释或CDATA等边界,易导致错改、错删甚至XSS漏洞;AST能准确建模节点关系与结构,是安全重构的前提。

为什么直接用正则替换 HTML 会出事
因为 HTML 是嵌套结构,而正则无法可靠识别标签边界、属性引号配对、注释或 CDATA 区域。<div><p>hello</p></div> 看似简单,但一旦混入 <script>console.log('<div>'); 或 <!-- <div> -->,正则就大概率错删、错改甚至注入漏洞。AST 能准确建模节点类型、父子关系和属性结构,这是重构安全的前提。
用 parse5 构建可操作的 HTML AST
parse5 是目前 Node.js 生态中兼容性好、错误容忍强的 HTML 解析器,输出符合 WHATWG DOM 标准的 AST(Document / Element / Text 等节点)。它不依赖浏览器环境,适合 CLI 工具或构建流程集成。
关键点:
-
parse5.parse()返回完整文档树,适合全量重构;parse5.parseFragment()更轻量,适合处理片段(如组件模板) - 节点上的
nodeName、childNodes、attrs可直接读写,但注意:修改attrs数组需深拷贝后操作,原数组是只读引用 - 避免直接拼接字符串生成新 HTML —— 用
parse5.serialize(node)输出,它会自动处理转义、自闭合标签、命名空间等细节
批量重命名 class 属性值的实操逻辑
这是最常见的重构需求:把旧 CSS 类名 btn-primary 替换为 c-button--primary,但必须避开 class 出现在 JS 字符串或注释里的场景。
立即学习“前端免费学习笔记(深入)”;
正确做法是遍历所有 Element 节点,只处理其 attrs 中键为 class 的项:
function rewriteClassAttrs(ast) {
const walk = (node) => {
if (node.nodeType === parse5.treeAdapters.defaultTreeAdapter.ELEMENT_NODE) {
const classAttr = node.attrs.find(a => a.name === 'class');
if (classAttr && classAttr.value) {
classAttr.value = classAttr.value
.split(/\s+/)
.map(cls => cls === 'btn-primary' ? 'c-button--primary' : cls)
.join(' ');
}
}
node.childNodes?.forEach(walk);
};
walk(ast);
}注意:
- 不要用
node.getAttribute('class')—— 这是 DOM API,在parse5AST 上不存在 - 空格分割要防多空格、首尾空格,
split(/\s+/)比split(' ')更鲁棒 - 如果类名有动态拼接(如
class="btn-{{type}}"),AST 本身不执行模板逻辑,这类需配合模板引擎解析器协同处理
保留原始格式与 sourcemap 的现实约束
AST 解析 + 序列化默认会“规范化”HTML:缩进重排、属性顺序重排、引号统一为双引号。这对 diff 和协作很不友好。
目前没有通用方案完美保留原始空白和注释位置,但可缓解:
- 用
parse5.parse()的treeAdapter选项启用sourceCodeLocationInfo: true,获取每个节点的起始/结束偏移 - 序列化时不用
parse5.serialize(),改用parse5.TreeSerializer自定义输出逻辑(例如跳过文本节点的空白压缩) - 若需精确映射修改位置,建议在重构前用
source-map库记录原始字符偏移,而非依赖 AST 内置位置信息 —— 它在 fragment 模式下可能缺失
真正难的不是解析或替换,而是当一个 <div class="x y"> 被拆成两行、中间插了注释、又嵌了 <svg> 时,如何让输出既语义正确,又不激怒团队里那个坚持 prettier 配置的人。



















