Vue3 白屏排查实录:Transition 遇上多根模板,控制台几乎不报错
这是最近在一个后台管理系统里遇到的真实故障。它最折磨人的地方不是报错,而是什么都不报 :接口全 200,权限正常,侧边栏和顶栏都还在,只有 <router-view> 的内容区一片空白。刷新一下恢复,再点两下菜单又白。
更诡异的是,一旦经过某个页面,之后切到任何页面都是白的。本文完整复盘从现象、误判、最小复现,到 Vue 源码级根因、修复方案和编译期防复发脚本的全过程。
环境:Vue 3.5.13 + Vue Router 4.5 + Element Plus 2.13.7,布局是常见的「侧边栏 + 顶栏 + 标签页 + 路由内容区」后台模板。
一、现象:内容区白屏,但整个框架还在
故障表现可以精确描述为五点:
- 顶栏、侧边栏、标签页正常渲染,只有路由内容区空白;
- 浏览器控制台没有异常堆栈,接口请求全部 200;
- 按 F5 强刷后恢复正常;
- 恢复正常后,再切换到别的菜单,又变白;
- 不是所有页面都触发,通常是「访问过某个特定页面之后」开始出现。
一开始怀疑过接口超时、权限指令、keep-alive 缓存、路由守卫死循环,逐一排除后都不成立。因为布局还在、接口正常,说明 Vue 应用没有崩,问题范围被压缩到「路由组件的渲染 / 切换」这一层。
二、关键线索:一条被当成噪音的 warning
在开发环境里,控制台其实给过提示,只是它看起来太像「动画没生效」这种无害警告:
text
[Vue warn]: Component inside <Transition> renders non-element root node
that cannot be animated.
翻译过来是:<Transition> 里面的组件渲染出了非单一元素根节点,无法执行动画。
很多人看到「cannot be animated」就放过了,觉得最多是切换动画失效。但在 mode="out-in" 下,它不是「动画失效」这么简单,而是会直接把内容渲染卡死。
顺手说一句:生产构建里这条 warning 会被完全剥离。所以线上表现就是「静默白屏」,这也是它难排查的核心原因之一。
三、布局代码:这是后台模板里的标准写法
出问题的布局结构非常常见,几乎所有 RuoYi 系后台项目都能看到类似代码:
vue
<!-- AppMain.vue(简化版) -->
<router-view v-slot="{ Component, route }">
<transition mode="out-in">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
这段代码做了三件事:
- 用
<transition mode="out-in">做「旧页面先离开、新页面再进入」的切换动画; - 用
<keep-alive>缓存页面; - 用
route.path作为 key,强制不同路由使用不同组件实例。
问题不在布局,而在于被它包住的页面组件长什么样。
四、最小复现:多根组件 + out-in
把问题剥离成一个最小例子,只需要两个页面组件:
vue
<!-- PageA.vue:注意有两个根节点 -->
<template>
<div class="a1">A1</div>
<div class="a2">A2</div>
</template>
vue
<!-- PageB.vue:同样两个根节点 -->
<template>
<div class="b1">B1</div>
<div class="b2">B2</div>
</template>
外层用和后台布局一样的结构:
vue
<template>
<div>
<button @click="page = page === 'PageA' ? 'PageB' : 'PageA'">
切换
</button>
<span>{{ page }}</span>
<transition name="fade" mode="out-in">
<keep-alive>
<component :is="page" :key="page" />
</keep-alive>
</transition>
</div>
</template>
点击一次「切换」后,现象是:
- 按钮和当前页面名(
PageB)还在,因为它们位于<transition>外面; <transition>里的B1、B2全部消失;- 再点回
PageA,A1、A2也不出现; - 只有刷新页面才能恢复。
实测结果(同一份代码分别用开发构建和生产构建运行):
| 页面组件根节点 | 开发构建 | 生产构建 |
|---|---|---|
| 单根元素 | ✅ 正常 | ✅ 正常 |
| 多根元素 | ❌ 空白 + warning | ❌ 空白,无任何日志 |
| 多根元素 + KeepAlive | ❌ 空白 | ❌ 空白 |
多根但外层包一个 div |
✅ 正常 | ✅ 正常 |
单根 + <template> 内顶部注释 |
❌ 空白 | ✅ 正常(注释被剥离) |
最后一行是个隐藏彩蛋,后面单独说。

五、源码级根因:isLeaving 状态死锁
要理解为什么「动画失效」会变成「渲染卡死」,需要看 Vue 3 的 BaseTransition 实现。核心只有几行:
js
// runtime-core/src/components/BaseTransition.ts(节选)
if (state.isLeaving) {
return emptyPlaceholder(child)
}
// mode === 'out-in' 分支
state.isLeaving = true
leavingHooks.afterLeave = () => {
state.isLeaving = false
// 触发重新渲染
}
return emptyPlaceholder(child)
逻辑很直白:
out-in模式下,旧组件开始离开时,把isLeaving置为true;- 在旧组件的
afterLeave回调里,再把isLeaving置回false; - 只要
isLeaving为true,新组件就不会被渲染,而是返回一个空占位。
也就是说,整个切换流程依赖 afterLeave 一定被调用。
那多根组件为什么会让 afterLeave 永远不触发?继续看卸载逻辑:
js
// runtime-core/src/renderer.ts(节选)
const remove = (vnode) => {
const { type, el, anchor, transition } = vnode
if (type === Fragment) {
removeFragment(el, anchor) // 直接删 DOM,完全不碰 transition
return
}
const performRemove = () => {
hostRemove(el)
if (transition && !transition.persisted && transition.afterLeave) {
transition.afterLeave() // 只有元素节点才会走到这里
}
}
// ...
}
关键就在 type === Fragment 这个分支:直接 removeFragment 删除 DOM,然后 return,根本不会执行 transition.leave,更不会调用 afterLeave。
完整链路串起来:
text
切换路由
→ BaseTransition 进入 out-in 分支
→ state.isLeaving = true
→ 给旧页面组件挂上 leave 过渡钩子
→ 旧页面组件根节点是 Fragment(多根)
→ 卸载时命中 type === Fragment 分支
→ removeFragment 直接删 DOM,afterLeave 永远不会执行
→ state.isLeaving 永远为 true
→ 此后每次渲染都 return emptyPlaceholder
→ 内容区持续空白,直到刷新重建 Transition 实例
这也解释了两个现象:
- 为什么是「经过某个页面之后」才开始白:那个页面的根节点是 Fragment,离开它时触发死锁;
- 为什么刷新能恢复 :整页刷新会重建布局和
Transition实例,isLeaving回到初始的false。
六、开发环境会警告,生产环境是静默的
Vue 在开发环境下会给出一条明确的警告,位置在组件根节点处理逻辑里:
js
if (vnode.transition) {
if (!isElementRoot(root)) {
warn(
'Component inside <Transition> renders non-element root node ' +
'that cannot be animated.'
)
}
setTransitionHooks(root, vnode.transition)
}
但这整段检查被 __DEV__ 包裹,生产构建时会被 tree-shaking 掉。于是形成一个非常危险的信息差:
- 开发环境:有 warning,但容易被当成「动画警告」忽略;
- 生产环境:warning 消失,但白屏照旧发生,且没有任何日志。
所以这类 warning 不能按「不影响功能」处理,尤其当它的文案里出现 cannot be animated、fragment 这类关键词时,最好直接当成阻断级问题。
七、修复:让页面模板回到单根
修复方式很简单:给页面模板包一个唯一的根元素。
vue
<!-- 修复前:主内容和弹窗、抽屉并列,都是根节点 -->
<template>
<div class="page-main">...</div>
<el-dialog v-model="visible" append-to-body>...</el-dialog>
<el-drawer v-model="drawerVisible">...</el-drawer>
</template>
vue
<!-- 修复后:所有内容收进一个根 div -->
<template>
<div>
<div class="page-main">...</div>
<el-dialog v-model="visible" append-to-body>...</el-dialog>
<el-drawer v-model="drawerVisible">...</el-drawer>
</div>
</template>
两个容易踩的点:
append-to-body不改变 vnode 的根位置 。它只是把 DOM 传送到body,在组件树里它仍然是页面模板的一个根节点,所以必须一起收进去;- 收单根不会影响弹窗的挂载行为 。
append-to-body、teleport该怎么工作还怎么工作,变化只是模板的根节点变成了一个普通div。
修复后,多根场景的空白现象消失,切换动画也恢复正常。
八、隐藏坑:模板顶部的注释,在开发环境也算一个根节点
这是排查过程中额外发现的,值得单独提醒。
很多项目有「文件首行加中文注释」的规范。如果注释写在 <template> 外面,没问题:
vue
<!-- 页面说明:这样写是安全的 -->
<template>
<div>...</div>
</template>
但如果写在 <template> 里面、元素之前:
vue
<template>
<!-- 页面说明:这样写在开发环境会出问题 -->
<div>...</div>
</template>
开发构建默认保留模板注释,注释会和 div 一起构成 Fragment,同样触发上面的死锁;而生产构建会剥离注释,所以打包后又正常。这类「开发白、打包正常」的问题最容易被误判成环境问题,实际是模板根节点问题。
结论:页面说明注释一律放在 <template> 之外。
九、防复发:编译期扫描脚本
靠人工 Review 很容易漏,尤其是弹窗、抽屉这种「看起来不属于页面主体」的组件。可以在构建前跑一个扫描脚本,直接基于 @vue/compiler-sfc 检查每个 .vue 的模板根节点。
js
// scripts/check-vue-root.mjs
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { parse, compileTemplate } from '@vue/compiler-sfc'
const target = process.argv[2] || 'src/views'
const SKIP = new Set(['node_modules', 'dist', '.git'])
function walk(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue
const full = join(dir, entry.name)
entry.isDirectory()
? walk(full, out)
: entry.name.endsWith('.vue') && out.push(full)
}
return out
}
function rootReturnsFragment(source, filename, comments) {
const { code } = compileTemplate({
source,
filename,
id: 'root-check',
compilerOptions: { comments }
})
// 只看 render 函数根节点的首个 return
// 不能全文匹配 Fragment,否则 v-for 生成的 Fragment 会大量误报
const renderIndex = code.search(/function render\s*\(/)
if (renderIndex < 0) return false
const returnIndex = code.indexOf('return (', renderIndex)
if (returnIndex < 0) return false
return /_createElementBlock\(\s*(?:_Fragment|Fragment)\b/.test(
code.slice(returnIndex, returnIndex + 160)
)
}
const errors = []
const warnings = []
for (const file of walk(target)) {
const { descriptor, errors: parseErrors } = parse(
readFileSync(file, 'utf8'),
{ filename: file }
)
if (parseErrors.length || !descriptor.template) continue
const template = descriptor.template.content
// 保留注释编译:发现 Fragment
if (!rootReturnsFragment(template, file, true)) continue
// 去掉注释再编译:仍是 Fragment = 真多根;否则只是根注释
if (rootReturnsFragment(template, file, false)) {
errors.push(file)
} else {
warnings.push(file)
}
}
const rel = (f) => relative(process.cwd(), f).replace(/\\/g, '/')
console.log(`扫描 ${target}`)
console.log(`🔴 多根元素(dev/prod 都会白屏): ${errors.length}`)
errors.forEach((f) => console.log(' ' + rel(f)))
console.log(`🟡 模板根注释(仅 dev 白屏): ${warnings.length}`)
warnings.forEach((f) => console.log(' ' + rel(f)))
process.exitCode = errors.length > 0 ? 1 : 0
用法:
json
{
"scripts": {
"check:root": "node scripts/check-vue-root.mjs src/views"
}
}
bash
npm run check:root
几点说明:
- 只检查
render函数根节点的首个return。初版脚本做过全文正则,结果把v-for内部生成的 Fragment 全部误报,根本没法用; - 扫描范围建议限定在路由页面目录 (如
src/views)。普通子组件如果不会进入<Transition>,多根并不是错误; - 脚本返回非零退出码,可直接接入 CI 或 pre-commit,卡住多根模板。
如果项目使用开发环境排查,也可以把这条警告挂到全局处理里,避免它淹没在几百条 warning 中:
ts
// main.ts(仅开发环境)
app.config.warnHandler = (msg) => {
if (msg.includes('non-element root node')) {
console.error('[RootNodeFatal]', msg)
}
}
十、一张表快速判断是不是这个坑
| 特征 | 是否符合 |
|---|---|
| 内容区空白,但顶栏、侧边栏、标签页正常 | ✅ |
| 接口正常,无异常堆栈 | ✅ |
| 刷新恢复,再次切换又白 | ✅ |
| 访问过某个特定页面后才开始出现 | ✅ |
开发环境有 non-element root node warning |
✅ |
| 生产环境完全无日志 | ✅ |
如果命中 4 条以上,优先检查最近改动过的页面模板根节点。
十一、结论
- 路由页面模板必须是单根 ,这是
<Transition>的硬约束,不是风格偏好; - 弹窗、抽屉、消息组件也是根节点 ,
append-to-body不改变 vnode 位置,要一起收进根元素; - 开发环境的
cannot be animated警告要当阻断问题处理,生产环境它会消失,但故障仍在; - 多根 +
mode="out-in"的后果是状态死锁 ,不是动画失效:afterLeave不触发,isLeaving永远是true,之后所有路由切换都渲染空占位; - 模板顶部注释在开发环境也算根节点 ,页面注释请放在
<template>外面; - 用编译期扫描脚本兜底,比人眼 Review 可靠得多。
这类问题的排查成本,主要不在修复,而在于它「不报错、能刷新恢复、换个页面又复现」的伪装。希望这篇复盘能帮你少走一次弯路。