ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VitePress 构建报错 Element is missing end tag:定位与解决

VitePress 构建报错 Element is missing end tag:定位与解决 如果是第一次在 VitePress 构建时看到Element is missing end tag.你大概率会跟我当时一样懵本地npm run dev跑得好好的页面渲染、导航跳转样样正常结果一执行npm run build构建流程直接在某个文档页面被掐死。这个报错既没有直接告诉你坏在哪个文件也没有像编译错误那样给出明确的代码帧文档站点页面一多定位起来就只能靠猜。后来我把 VitePress 的构建链路、Vue 编译器的报错机制以及自己踩过的几个具体场景都翻了一遍才发现这类错误其实非常集中在某几类写法上。这篇文章不打算复述官方文档而是把我定位和解决这个问题的完整过程写清楚包括为什么 dev 下没事、build 下就炸以及怎么把这类报错提前挡在构建之外。如果你正在被 VitePress 构建失败折磨或者只是想知道以后怎么避免这篇文章应该能帮你省下不少时间。1. 报错现场一次构建失败到底停在了哪里1.1 我当时看到的完整报错先说现象。在 VitePress 项目里执行打包命令终端输出大概长这样vitepress v1.x.x ✓ building client server bundles... ✗ build errored in 3.42s: Error: Element is missing end tag. at parseHtml (.../vue/compiler-dom/dist/compiler-dom.cjs.js:...) at parse (...) at compile (...) at ...最气人的就是中间那一行孤零零的Error: Element is missing end tag.后面跟了一长串 node_modules 内部堆栈完全没有业务代码的提示。第一次遇到的时候我下意识以为是某个.md文件里的 Markdown 语法写错了比如少写了表格的分隔行、标题少了井号之类的。但检查下来发现纯 Markdown 语法问题一般不会触发这个报错因为 markdown-it 对语法的容忍度很高顶多渲染样式不对不会让编译器直接挂掉。这个报错的真正含义在后面的章节会拆开讲。先说结论它绝大多数时候跟 Markdown 语法无关而是你写在文档里的 HTML 标签、Vue 组件标签没有正确闭合导致 Vue 编译器在把整个页面当成一个组件模板解析时发现模板都结束了还有元素是“打开未关闭”的状态。1.2 为什么 dev 模式下发现不了很多人跟我一样第一反应都是“开发模式明明没问题”。这里有个非常关键的原因VitePress 的 dev 服务器是按需编译的。你访问了某个页面Vite 才会去编译那个页面对应的.md文件其他页面还处于未被触达的状态。如果你只看了首页和几个常用子页面根本不会触发那个出错页面的编译。而vitepress build做的是全量预渲染。它需要把 docs 目录下所有 Markdown 文件全部扫描一遍逐个编译成 Vue 组件再在 Node 端通过 SSR 渲染成静态 HTML。只要任何一个页面里的 HTML 标签不完整整个构建过程就会直接失败。这也就解释了为什么很多人会把 bug 合并到主分支、CI 构建时才炸锅——本地 dev 看什么都是好的因为出问题的页面压根没被访问过。还有一个容易被忽略的点即使 dev 模式下直接访问了出错页面Vue 编译报错有时也会以页面上的红色 overlay 形式出现但如果页面被缓存了、或者你是在构建前的某次改版中引入了问题dev 和 build 的表现就可能不一致。所以在排查这一类构建问题时别把“dev 能跑”当作“代码没问题”的依据。2. 弄懂报错机制审查对象不是 Markdown而是 Vue 模板2.1 一条 Markdown 在 VitePress 中经历的编译链路要真正理解这个报错得先知道 VitePress 是怎么把.md文件变成网页的。简单拆解一下链路第一步markdown-it 把 Markdown 语法转换成 HTML 字符串。标题、列表、引用这些 Markdown 语法在这一步已经变成了h1、ul、blockquote等 HTML 标签。第二步这个 HTML 字符串会连同你在.md文件里手写的 HTML 片段、自定义 Vue 组件标签一起塞进一个 Vue 组件模板里。第三步Vue 编译器会把这个模板解析成 AST再编译成渲染函数最终由 VitePress 在构建时执行 SSR输出成静态页面。注意第三步Vue 编译器的职责之一是做严格的 HTML 标签配平检查。它要求每个开标签都必须有一个对应的闭合标签。如果模板解析到末尾发现div开了但/div一直没出现它就会直接抛出Element is missing end tag。所以问题不在 Markdown 解析那一步而在 Vue 编译器接收了整个页面模板之后。Markdown 解析器可以允许你把标签写得不规范因为浏览器的容错性很强会自动帮你补全但 Vue 编译器要生成 JavaScript 渲染函数它必须知道整个 DOM 结构的边界在哪模棱两可的状态它无法接受。2.2 “Element is missing end tag” 的直译与真实含义这句话直译过来就是“元素缺少结束标签”。它可能指向三种非常典型的对象原生 HTML 标签比如div缺/div、section缺/section。Vue 自定义组件标签比如Card缺/Card或者是组件标签虽然是自闭合写的但编译器并不认可。特殊标签比如template、script setup、style这类对页面结构有影响的块级标签如果闭合位置错了也会对后续内容产生“吞噬”效果。这里有一个容易误入的坑很多人以为是 Markdown 的代码块没闭合比如少写了一个导致后面的内容全被当成代码。但代码块解析问题一般会表现为渲染错乱而不是Element is missing end tag因为代码块里的内容不会经过 Vue 模板解析。真正会被 Vue 编译器盯上的是“被 Vue 当成真实模板结构”的那部分内容。3. 从报错信息到未闭合标签我的完整定位过程3.1 先做的事把报错信息完整拉出来遇到这个报错第一步不是急着改代码而是想办法靠近那个出问题的文件。VitePress 在不同版本下的报错输出格式不太一样有的版本会在堆栈上方带一段File: ...的提示有的版本什么都没有。如果你用的是打包工具比如 pnpm 的打包输出日志可能还会被吞掉一部分。我一般会临时跑一次更详细的构建把 Vite 的日志等级拉高。可以在package.json里临时加一个脚本{ scripts: { build:debug: DEBUGvite:* vitepress build docs } }这里的DEBUGvite:*会让 Vite 输出更多模块转译和依赖预构建的日志。虽然日志会变得非常啰嗦但通常在中断前的那几行里能看到正在编译的文件路径。如果你用的是 Windows PowerShell可以改用环境变量设置的方式$env:DEBUGvite:* npm run build还有一种更直观的方法如果你有.vitepress/dist目录的生成残留可以看看这个目录里最后成功生成的 HTML 文件是哪个。构建是在按顺序处理页面时中断的最后一个成功文件的下一个文档嫌疑最大。不过这个方法的局限是 VitePress 在预渲染阶段往往是并发处理的不保证严格按照目录顺序所以只能作为参考不能作为唯一判断依据。3.2 文档太多时用二分法缩小范围如果上面两种方法都没拿到文件路径就只能靠人工缩小范围。我最常用的手段是二分法具体做法把docs目录下的子目录分成两份。把其中一份临时改名为docs/guide.bak然后重新执行npm run build。如果构建通过了说明问题出在被移走的那份目录里如果还是失败说明问题在剩下的目录里。继续对有问题的那份目录做同样的切分直到定位到具体的.md文件。这个方法听起来原始但在文档站点规模较大、报错又完全没有路径信息时确实是最可靠的。需要注意一点如果你在.vitepress/config.mts里配置了sidebar且配置依赖的路径比较多移除目录可能会导致配置报错。这种情况下可以只把疑似目录里的文件逐一移出而不是整个目录移动。3.3 锁定具体标签从“哪个文件”到“哪个标签”拿到具体的.md文件之后剩下的工作就是找到那个没闭合的标签。我用的是最朴素但最有效的方式用 VSCode 打开文件把 HTML 高亮开起来。未闭合的标签在高亮下通常会以明显不同的颜色呈现如果整个文件右下角区域的颜色都不正常那大概率就是问题区域。在编辑器里搜索(div|section|article|template|script|style|blockquote)(\s|)逐一定位手写的开标签。对每个候选标签用键盘快捷键“转到匹配括号”或者直接看标签名的闭合高亮确认它有没有对应的闭合标签。把光标放在开标签的位置正常的闭合标签会有匹配提示如果没有就是它了。有一次我定位到一个.md文件内容本身只有几十行但里面有这样一个片段div classcallout ::: warning 注意 这里的内容很重要 :::这段代码看着没什么问题::: warning容器有闭合但手写的外层div classcallout下面并没有对应的/div。VitePress 的容器语法最终会生成一个div但容器语法里的闭合:::和手写 HTML 标签的闭合是两回事。我下意识以为“容器已经帮忙闭合了”实际并没有。把/div补上之后构建立刻恢复正常。这就是为什么我在排查时不会只用眼睛扫而是极力依赖编辑器的标签匹配提示。人眼在高亮颜色接近的内容里非常容易漏编辑器不会。4. 最容易触发这个报错的几种写法4.1 在 Markdown 里手写块级 HTML 标签漏写闭合标签这是最常见的一类。很多人会为了样式需要在文档里手写div classcard或section classnote然后写完内容就忘了收尾。Markdown 本身没有强制要求标签闭合浏览器也能自动容错所以本地看效果不一定会发现。但 VitePress 构建时这些标签会原样进入 Vue 模板Vue 编译器可就一点情面都不讲了。它连多级嵌套都会检查。比如你在一个div里又套了一个div外层闭合了、内层没闭合照样报错。我甚至见过有人写了一个blockquote忘了闭合导致后面的所有内容都被当作引用内容处理整个页面的构建直接失败。因为blockquote也是一个块级 HTML 标签Vue 编译器会把它当作一个需要配平的元素。4.2 在 Markdown 里使用自定义 Vue 组件组件标签没有写全VitePress 一个很重要的能力是可以在文档里直接使用 Vue 组件。比如在一个.md文件中写Badge typetip / Card title示例卡片 卡片内容 /Card第一种Badge typetip /是组件自闭合写法Vue 编译器认识没问题。第二种Card是成对标签就必须确保有对应的/Card。如果不小心写成了Card但后面没有/CardVue 编译器会把组件之后的所有 Markdown 内容都当作组件插槽内容一直吞到模板结束才发现大括号不平衡最后报Element is missing end tag。这类问题通常在“从普通文档里临时插一个组件”的时候发生。比如写了一半想到要加个案例卡片就贴了个Card标签上去结果忘了贴闭合标签又继续写下面的内容。排查这类问题时可以专门搜索大写开头的标签因为自定义 Vue 组件标签通常以大写字母开头的多。4.3 .vue 组件模板里缺闭合标签这一类容易被人忽略因为很多人以为 VitePress 只处理.md文件。但 VitePress 主题里的.vue文件、你自己写的布局组件、自定义组件同样会经过 Vue 编译器编译。只要组件模板里有标签没闭合构建时一样会报Element is missing end tag。比如这个组件模板template div classwrapper slot / /template漏了/divVue 编译器在解析这个.vue文件时就会直接报错。这种情况的定位相对容易因为编译.vue文件时一般会带上文件路径报错信息里往往能看到是哪个组件。但如果你用的是比较老的 VitePress 版本或者日志被工具吞掉了依然可能只看到一句Element is missing end tag。排查项目里所有.vue文件的模板结构也是一个可执行的思路。尤其是最近新加的组件、改过的布局文件优先检查。4.4 Markdown 表格、容器和 HTML 标签混排的边界问题Markdown 表格是最容易让人放松警惕的地方。因为表格的职责是把内容按列排开很多人会在单元格里放 HTML 标签。比如| 名称 | 说明 | | --- | --- | | 示例 | div classdesc这是说明 |这里div开标签写了但/div没写。表格列本身不会帮你闭合 HTML 标签最终这些标签会原样进入 Vue 模板Vue 编译器在解析表格结构时就会发现标签不平衡。更隐蔽的情况是div闭合标签虽然写了但放在了不正确的表格列位置比如| 名称 | 说明 | | --- | --- | | 示例 | div内容/div |这一行其实是对的但如果/div和内容之间被表格管道符弄乱了或者标签跨了行就会出现解析错乱。我的经验是表格单元格里尽量只用行内元素比如code、kbd、br不要用div或section这种块级标签。如果确实需要复杂结构应该改用组件或布局代码而不是硬塞进表格里。5. 把 missing end tag 挡在 build 之前5.1 编辑器配置让问题在打字阶段就暴露这类报错最烦人的地方在于“发现晚”。与其等构建失败再去排查不如让编辑器先帮你把标签配对这件事盯住。我现在的 VSCode 环境里做了三件事安装 Vue Language Features (Volar)。它会对.vue组件模板做实时的语法检查标签没闭合会直接标红。安装 markdownlint并配置 MD033 规则禁止在 Markdown 中手写内联 HTML。这里的“禁止”可以改成“警告”它会在你手写div的时候提个醒减少漏写闭合标签的习惯。使用 VSCode 自带的 HTML 标签匹配提示。把光标放在一个 HTML 标签上对应的闭合标签会高亮。如果只有开标签高亮、闭合标签位置空白那就说明这里缺了闭合标签。这些手段不是万能药但能明显降低问题漏到 build 阶段的概率。5.2 写一个简单的标签配对检查脚本如果你经历过一次这种报错大概率会产生“以后写文档都要提心吊胆”的感觉。为了缓解这种不安全感我写了一个非常粗糙的启发式检查脚本放在项目里构建前顺手跑一下能挡掉大部分低级错误。// scripts/check-tags.mjs // 启发式 HTML 标签配对检查仅用于 VitePress Markdown 项目自查 import { readdirSync, readFileSync, statSync } from node:fs; import { extname, join } from node:path; const root process.argv[2] || docs; const skipDirs [node_modules, .vitepress, dist, .git]; const voidTags new Set([ area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr ]); const files []; function walk(dir) { for (const entry of readdirSync(dir)) { const full join(dir, entry); if (statSync(full).isDirectory()) { if (!skipDirs.includes(entry)) walk(full); } else if (extname(full) .md) { files.push(full); } } } walk(root); const tagPattern /\/?([\w-])(?:\s[^]*)?/g; for (const file of files) { const source readFileSync(file, utf-8); // 去掉代码块和注释避免误报 const content source .replace(/[\s\S]*?/g, ) .replace(/!--[\s\S]*?--/g, ); const stack []; let match; while ((match tagPattern.exec(content)) ! null) { const raw match[0]; const tag match[1]; if (voidTags.has(tag)) continue; if (raw.startsWith(/)) { const top stack.pop(); if (top ! tag) { console.error(${file}: 期望 ${top ? / top : 无闭合标签}实际出现 ${raw}); process.exitCode 1; break; } } else if (!raw.endsWith(/)) { stack.push(tag); } } if (stack.length) { console.error(${file}: 以下标签未闭合: ${stack.map((t) t ).join( )}); process.exitCode 1; } } if (!process.exitCode) { console.log(标签检查通过); }然后在package.json里加一个脚本{ scripts: { check:tags: node scripts/check-tags.mjs docs } }这个脚本的实现逻辑并不复杂它扫描docs目录下所有.md文件去掉代码块和注释然后通过正则把所有标签按“栈”的方式配对。如果某个开标签没有对应的闭合标签就会报错。它同样支持/自闭合和void标签比如br、img这类不需要闭合的标签会被直接跳过。需要说明的是这只是一个辅助工具不是严格的 HTML 解析器。比如标签属性里如果出现了符号或者存在和夹着英文文本的情况它可能会误判或漏判。但它的价值在于在构建之前用极低的成本拦掉最常见的手滑问题。我自己用下来的感受是它比人工检查可靠得多至少能把“明显没闭合”的那类问题全部筛出来。5.3 构建前检查让 CI 和 git hooks 替你兜底如果项目是多人协作光靠个人的编辑器习惯是不够的。我建议至少把构建检查放进 CI 或者 git hooks 里。最简单的方式是在 CI 流水线里增加一个阶段- run: npm install - run: npm run check:tags - run: npm run build这样每次 push 代码CI 都会先跑一次标签检查然后再跑完整构建。标签检查只需要几十毫秒能快速拦截明显问题完整构建则确保所有页面都能正常预渲染。如果你不想上 CI也可以用 husky 在本地 pre-commit 阶段执行npx husky add .husky/pre-commit npm run check:tags这样每次提交代码前都会先检查一遍如果发现未闭合标签提交直接被拦截。不过要注意check:tags只是启发式检查不能替代完整构建。对于正式发布的文档项目CI 里的npm run build这一步绝对不能省。最后再分享一个小技巧现在我写 VitePress 文档时已经养成了一个习惯尽量不在.md文件里手写块级 HTML 标签。需要提示框、警告框这种效果优先用 VitePress 自带的容器语法需要更复杂的页面布局就单独抽一个.vue组件出来。这样文档源文件看起来更干净也能从源头上减少“标签没闭合”这类问题的发生概率。如果你正在被这个报错折磨我的建议是先跑一次带 debug 日志的构建拿到具体文件后再用编辑器的标签匹配功能逐层检查。别在 dev 模式下反复刷新也别盯着 node_modules 里的堆栈发呆——问题大概率就在你自己写的某个div或Card标签上补上闭合标签世界就清净了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进