全局样式泄漏排查:PptLayout 的黑底为何污染了 PPT 列表页


一、现象:列表页下半截变成了黑色

/ppt/ 列表页上线后出现了一个诡异的样式问题:页面上半部分是正常的浅灰渐变 + 白色卡片,但往下滚动,渐变结束后突然变成纯黑背景,白色卡片浮在黑底上,看起来像是样式乱了。

更奇怪的是,这个页面的样式代码里根本没有写过任何黑色背景。

二、线索:黑底样式只可能来自一个地方

全站只有放映页是黑底,样式定义在 src/layouts/PptLayout.astro:

<style is:global>
  html,
  body {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
    overflow: hidden;
    background-color: #000;
  }
  .reveal {
    width: 100vw !important;
    height: 100vh !important;
  }
</style>

但列表页 src/pages/ppt/index.astro 根本没有渲染 PptLayout,它只渲染了 Header、Footer 和卡片列表。黑底样式是怎么跑过来的?

三、根因:eager glob 把组件(含全局样式)一起打进了列表页

列表页为了自动提取每套 PPT 的 meta,用了 eager 模式的 glob:

const pptModules = import.meta.glob('../../ppts/*.astro', { eager: true });

而每一套 PPT(src/ppts/*.astro)的 frontmatter 里都 import PptLayout。eager import 会执行这些模块,于是 PptLayout 的 <style is:global> 也被 Vite 打进了列表页的 CSS 包。构建产物证实了这一点:dist/ppt/index.html 引用了 PptLayout.*.css。

这就解释了所有症状:

  1. 下半截黑底:global.css 的 body 渐变只有顶部 600px(background-size: 100% 600px),600px 之后露出的是 body 底色——正常是白色,但被泄漏的 background-color: #000 改成了黑色。
  2. 页面无法正常滚动:泄漏的 overflow: hidden 同样作用在 body 上。

四、排除:reveal.js 自带的全局规则 harmless

在修之前还排查了一个嫌疑人:PptLayout import 的 reveal.js/reveal.css 里也有裸 html/body 选择器,比如:

html:not(.print-pdf) body { margin: 0; padding: 0; overflow: visible }
html:not(.print-pdf) .reveal { background: #fff; font-size: 20pt }

逐条核对后确认无害:前者在列表页命中的声明(margin: 0; overflow: visible)与全局样式一致,属于默认值;后者只匹配 .reveal 元素,而列表页没有该元素。真正的凶手只有我们自己写的那段 html, body 黑底。

五、修复:用 body:has() 把作用域收敛到 deck 自身

裸 html/body 选择器的问题在于:只要 CSS 文件被加载,规则就对全页生效,与组件是否渲染无关。修复思路是让选择器与组件的实际渲染挂钩:

<div
  class={`ppt-deck reveal ${className}`}
  ...
>
<style is:global>
  /* 仅在实际渲染了 deck 的页面生效,避免被 ppt 列表页的 eager glob 连带打包污染 */
  body:has(.ppt-deck) {
    margin: 0;
    padding: 0;
    overflow: hidden;
    background-color: #000;
  }
  .ppt-deck {
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    background-color: #000;
  }
  .ppt-deck.reveal {
    width: 100vw !important;
    height: 100vh !important;
  }
</style>

CSS 文件依然会被打进列表页(eager glob 的机制决定了这点无法避免),但它的选择器在列表页没有任何匹配元素,自然就失效了。而放映页渲染了 .ppt-deck,黑底全屏效果原样保留。

六、验证:检查构建产物而非只看浏览器

npm run build 通过后,做了三层确认:

  1. dist/ppt/index.html 中 background-color:#000 出现 0 次;
  2. 打包出的 PptLayout.*.css 里已没有裸 html/body 黑底规则,只剩 body:has(.ppt-deck);
  3. 放映页(如 profit_by_indu)产物中仍包含 ppt-deck 容器与对应样式,黑底放映效果不受影响。

七、经验总结

  1. is:global + 裸元素选择器是高危组合:在 Astro 里,is:global 的 html/body 样式一旦被任何页面间接 import,就会污染该页面。能用类选择器就不要碰元素选择器。
  2. eager glob 会连带打包样式:import.meta.glob(..., { eager: true }) 不只是读数据,模块的副作用(含全局 CSS)会一起进入产物。用它读 meta 时,要默认所有被 glob 模块的全局样式都会泄漏。
  3. body:has() 是收敛全局样式的利器:既保留了全屏场景下必须控制 body 的能力,又把生效条件绑定到组件实际渲染的 DOM 上,浏览器支持度如今也足够好。
  4. 用构建产物验证样式问题:浏览器里看效果只能确认表象,grep 产物 CSS 才能确认泄漏是否真正消除。