HTML | 10个常犯的错误

前阵子把十年的内部 Wiki 从 Confluence 搬到纯静态站,本以为只是「导出 HTML → 打包 → 上线」的一条龙服务,结果第一天就把整个知识库推倒了。罪魁祸首不是 CI/CD,而是那些「看起来没事儿」的 HTML 恶习。下面把我们踩过的十个坑一次性摊开来聊------每踩一个,我都顺手写了一个小修复脚本,最后挂到 GitHub Actions 里做实时守门员。


1. 自闭合标签忘了加 /,导致 React Hydration 崩

场景:我们在 Markdown → HTML 的流水线里用 marked,marked 默认输出 <img><input> 都不带 /。拿到 Gatsby 做 SSR 的时候,客户端 React 校验发现服务端 img 节点没闭合,差异大到直接拒绝 Hydration,页面白屏。

js 复制代码
// markdown-to-html.js
const { marked } = require('marked')
marked.use({
  renderer: {
    image(href, title, text) {
      // 🔍 加自闭合,省掉 React diff 报错
      return `<img src="${href}" alt="${text}" loading="lazy"/>`
    }
  }
})

把这段脚本放在 prepare 生命周期,CI 每次推送都会自动扫一遍。


2. alt=""" 空字符串埋了一颗 SEO 炸弹

问题:老库里 40% 的旧图缺失产品号,原本偷懒放空字符串,结果 Lighthouse accessibility 直接给了 0 分。

js 复制代码
// fix-alt.js
const { JSDOM } = require('jsdom')
const fs = require('fs')

for (const file of allHtmlFiles()) {
  const dom = new JSDOM(fs.readFileSync(file, 'utf8'))
  dom.window.document.querySelectorAll('img[alt=""]').forEach(img => {
    const filename = img.src.split('/').pop()
    // 🔍 用文件名反推一个可读替代文本
    img.alt = filename.replace(/[-_\d]/g, ' ').replace('.jpg', '')
  })
  fs.writeFileSync(file, dom.serialize())
}

跑完脚本,accessibility 分值直接飙回了 92。


3. <h1><p><h2> 的「降级嵌套」破坏目录树

老 Wiki 编辑习惯在段落里套标题,导出后变成:

html 复制代码
<p><strong><h2>Features</h2></strong></p>

不仅非法,还导致静态网站生成器里自动 TOC(table of contents)提取失败。我们写了一个 Babel AST 插件在 MDX → JSX 阶段就拦下来:

js 复制代码
// babel-plugin-smart-heading.js
module.exports = () => ({
  visitor: {
    JSXElement(path) {
      if (path.node.openingElement.name.name === 'h2') {
        const parent = path.findParent(p => p.isJSXElement())
        // 🔍 只允许顶层出现 h2
        if (parent && parent.node.openingElement.name.name !== 'div') {
          parent.replaceWith(path.node)
        }
      }
    }
  }
})

4. <br><br><br> 做「空行」被屏幕阅读器连喊三次

在「周报详情页面」出现过三次以上 <br> 的地方,我们统一收拢成可切换的段落:

Before:

html 复制代码
<p>昨日进展 <br><br><br>今天计划</p>

After:

html 复制代码
<style>
  .v-spacer { margin: 1.2em 0 }
</style>
<p>昨日进展</p>
<div class="v-spacer"></div>
<p>今天计划</p>

用 CSS margin 控制垂直距离,屏幕阅读器朗读停顿更合理,同时方便我们后期切换主题时统一调整节奏。


5. id="user" 在全站出现 400+ 次,点击目录永远跳到第一处

ID 本应独一无二,但后台富文本编辑器默认把「用户」锚点全映射成 #user。我们做了「全局唯一 ID 自动后缀」:

js 复制代码
// fix-duplicate-ids.js
const idMap = {}
function makeUnique(id) {
  idMap[id] = (idMap[id] || 0) + 1
  return idMap[id] === 1 ? id : `${id}--${idMap[id]}`
}

for (const file of allHtmlFiles()) {
  const dom = new JSDOM(fs.readFileSync(file, 'utf8'))
  dom.window.document.querySelectorAll('[id]').forEach(el => {
    el.id = makeUnique(el.id)
  })
  fs.writeFileSync(file, dom.serialize())
}

跑完脚本,锚点跳转不再跳错楼。


6. 忘写 <meta name="viewport">,移动端直接放大到 0.3x

公司手机端访问量占 62%,第一次上线后产品经理在地铁里打开网站直接崩溃:页面缩到看不清。最终把下面两行放进了模板引擎的全局头:

html 复制代码
<meta name="viewport" content="width=device-width, initial-scale=1.0">

顺带把 theme-color 写上,PWA splash screen 也一并解决。


7. 行内样式 style="color: #f40" 无法跟随暗黑模式

旧 Wiki 到处散落手写 color/background,我们想一次性迁移到 Tailwind 的 dark: 方案:

js 复制代码
// style-to-class.js
const RE_COLOR = /color:\s*#f40/g
for (const file of allHtmlFiles()) {
  let html = fs.readFileSync(file, 'utf8')
  html = html.replace(
    /<[^>]+style="[^"]*color:\s*#f40[^"]*"[^>]*>/g,
    match => match.replace(RE_COLOR, '').concat(' class="text-brand-600 dark:text-brand-400"')
  )
  fs.writeFileSync(file, html)
}

脚本跑完,后续只需改一次主题变量即可全站更新。


TypeScript 直接打进产物,但忘记改扩展名,Chrome 控制台狂吐:

python 复制代码
Resource interpreted as Stylesheet but transferred with MIME type text/typescript

CI 上补了一道关卡:

yml 复制代码
# .github/workflows/lint-assets.yml
- name: Ensure css extension for stylesheets
  run: |
    if grep -r '<link.*href.*\.ts' build/; then
      exit 1
    fi

9. 语义化缺失:div soup 让「一键朗读」读不出结构

我们把 <div class="header"> 直接转成 <header>,同理 footer、nav、aside 都规范化。这一步我们没在本地脚本里折腾,而是直接改 Markdown → HTML 的 Remark 插件:

js 复制代码
// remark-semantic-layout.js
function plugin() {
  return tree => {
    visit(tree, 'element', node => {
      if (node.properties.className?.includes('header')) node.tagName = 'header'
      if (node.properties.className?.includes('footer')) node.tagName = 'footer'
    })
  }
}

10. 表格里再塞 <ul>,屏幕阅读器直接失声

老 Wiki 最过分的是「功能清单」列里直接插 <ul>,无障碍测试直接报「不可导航」。解决思路:把列表平铺成多行列,或改成 Collapsible 摘要。我们选了后者:

html 复制代码
<details>
  <summary>功能清单(点击查看)</summary>
  <ul>
    <li>Markdown 渲染</li>
    <li>全文搜索</li>
  </ul>
</details>

自动化:一个脚本全干完

最后,我们汇总了所有修复到单一 Node 任务:

json 复制代码
// package.json
{
  "scripts": {
    "fix": "node tasks/fix-html.js"
  }
}

在 GitHub Actions 里:

yml 复制代码
- uses: actions/checkout@v4
- name: Lint & Fix HTML
  run: |
    npm ci
    npm run fix
- uses: stefanzweifel/git-auto-commit-action@v5

举一反三:三个变体场景

  1. CMS 模板市场 为 WordPress 批量修复器:把文章 HTML 导出后,运行同样的脚本,再写个 WP-CLI 命令批量回写。
  2. 邮件模板 Email 客户端对语义化极不友好,可反向把 <section><table>,脚本里加「email」开关。
  3. SSR 站点迁移 Next.js 的 getStaticProps 阶段引入 cheerio 解析并修复旧数据,直接嵌入构建流水。

我们把全部脚本挂在了 GitHub:mycorp/html-guardian ,拿下来直接 npm install && npm run fix,你的历史包袱就跟我这一样清得干干净净。祝你别再被「看似无害」的 HTML 坑到上线。

相关推荐
gnip1 分钟前
包管理工具的发展
前端
前端工作日常1 小时前
H5 实时摄像头 + 麦克风:完整可运行 Demo 与深度拆解
前端·javascript
韩沛晓1 小时前
uniapp跨域怎么解决
前端·javascript·uni-app
前端工作日常1 小时前
以 Vue 项目为例串联eslint整个流程
前端·eslint
程序员鱼皮1 小时前
太香了!我连夜给项目加上了这套 Java 监控系统
java·前端·程序员
该用户已不存在2 小时前
这几款Rust工具,开发体验直线上升
前端·后端·rust
前端雾辰2 小时前
Uniapp APP 端实现 TCP Socket 通信(ZPL 打印实战)
前端
无羡仙2 小时前
虚拟列表:怎么显示大量数据不卡
前端·react.js
云水边2 小时前
前端网络性能优化
前端
用户51681661458412 小时前
[微前端 qiankun] 加载报错:Target container with #child-container not existed while devi
前端