Vue3白屏排查实录-Transition与多根模板

Vue3 白屏排查实录:Transition 遇上多根模板,控制台几乎不报错

这是最近在一个后台管理系统里遇到的真实故障。它最折磨人的地方不是报错,而是什么都不报 :接口全 200,权限正常,侧边栏和顶栏都还在,只有 <router-view> 的内容区一片空白。刷新一下恢复,再点两下菜单又白。

更诡异的是,一旦经过某个页面,之后切到任何页面都是白的。本文完整复盘从现象、误判、最小复现,到 Vue 源码级根因、修复方案和编译期防复发脚本的全过程。

环境:Vue 3.5.13 + Vue Router 4.5 + Element Plus 2.13.7,布局是常见的「侧边栏 + 顶栏 + 标签页 + 路由内容区」后台模板。

一、现象:内容区白屏,但整个框架还在

故障表现可以精确描述为五点:

  1. 顶栏、侧边栏、标签页正常渲染,只有路由内容区空白;
  2. 浏览器控制台没有异常堆栈,接口请求全部 200;
  3. 按 F5 强刷后恢复正常;
  4. 恢复正常后,再切换到别的菜单,又变白;
  5. 不是所有页面都触发,通常是「访问过某个特定页面之后」开始出现。

一开始怀疑过接口超时、权限指令、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)

逻辑很直白:

  1. out-in 模式下,旧组件开始离开时,把 isLeaving 置为 true;
  2. 在旧组件的 afterLeave 回调里,再把 isLeaving 置回 false;
  3. 只要 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>

两个容易踩的点:

  1. append-to-body 不改变 vnode 的根位置 。它只是把 DOM 传送到 body,在组件树里它仍然是页面模板的一个根节点,所以必须一起收进去;
  2. 收单根不会影响弹窗的挂载行为 。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 条以上,优先检查最近改动过的页面模板根节点。

十一、结论

  1. 路由页面模板必须是单根 ,这是 <Transition> 的硬约束,不是风格偏好;
  2. 弹窗、抽屉、消息组件也是根节点 ,append-to-body 不改变 vnode 位置,要一起收进根元素;
  3. 开发环境的 cannot be animated 警告要当阻断问题处理,生产环境它会消失,但故障仍在;
  4. 多根 + mode="out-in" 的后果是状态死锁 ,不是动画失效:afterLeave 不触发,isLeaving 永远是 true,之后所有路由切换都渲染空占位;
  5. 模板顶部注释在开发环境也算根节点 ,页面注释请放在 <template> 外面;
  6. 用编译期扫描脚本兜底,比人眼 Review 可靠得多。

这类问题的排查成本,主要不在修复,而在于它「不报错、能刷新恢复、换个页面又复现」的伪装。希望这篇复盘能帮你少走一次弯路。

参考

相关推荐
不老刘1 小时前
异构双 Agent 协同:构建无人值守的长任务自动化研发流水线
运维·人工智能·自动化·ai编程·1024程序员节
lusklusklusk1 小时前
Python使用pymysql连接Mysql数据库操作笔记:连接、游标与 execute、executemany——含手动管理与 with 自动管理两种写法
1024程序员节
迷途呀1 小时前
强化学习概念
人工智能·深度学习·机器学习·强化学习·1024程序员节
技术小甜甜1 小时前
【Cursor】Skill 被标黄色警告,怎么改成合格的 SKILL.md
开发语言·javascript·ai编程·cursor·1024程序员节·ai agent·skills
syounger1 小时前
SAP季度营收首次突破100亿欧元:云增长强劲,AI贡献仍需进一步量化
1024程序员节
qz_Serene1 小时前
练习算法题:双指针 -cpp
c++·算法·leetcode·1024程序员节
Wanda12141 小时前
机器人与机电一体化3D数字孪生机器-Day6
unity·机器人·数字孪生·1024程序员节
山顶夕景1 小时前
【VideoLLM】Agentic Video Understanding视频理解综述
agent·强化学习·1024程序员节·视频理解·agentic·多模态理解·grpo
可乐_不渴1 小时前
【Linux】 项目自动化构建工具makefile&& 版本管理Git&&第一个程序
linux·git·自动化·1024程序员节