先说结论:这次看起来像两个问题,其实最后都指向同一个根因。

真正的根因是:详情页用了 Vant 的 showToast,它会把 Vant 的全局 reset CSS 带进来。

进入详情页后,这份 Vant CSS 会留在当前 SPA 会话里。再返回首页时,首页已经处在“加载过 Vant reset”的环境里,所以和“直接刷新进入首页”的样式不一样。

最后临时处理是:先注释掉详情页复制代码成功/失败时的 showToast,页面就不抖了。

现象

本地 pnpm dev 看起来没问题。
但部署到 Vercel 后,直接刷新博客首页,会看到页面布局短暂跳动一下。

一开始看到的现象有两类,看起来像两个不同的问题:

现象 1:刷新首页会抖一下

  • 本地不明显
  • Vercel 上刷新 /blog 更容易看到
  • 关掉文章列表后就不抖

现象 2:详情页返回首页后,首页样式和刷新进入时不一样

  • 从 /blog 点进详情页
  • 在详情页点“全部文章”返回
  • 左侧标签栏的 li 高度从大约 34px 变成 30px

一开始很容易怀疑是 SSR 和客户端水合不一致。

先从最容易造成水合差异的地方排:

  1. 关掉日签,因为它依赖日期。
  2. 关掉日历,因为它也依赖当前日期。
  3. 关掉左侧标签和归档侧栏。
  4. 关掉文章列表。

结果发现:
文章列表一关,刷新就不抖了。

于是继续缩小范围:

  • 换成纯文本标题列表:不抖
  • 换回卡片组件:会抖
  • 不用组件,直接在页面里写同样的卡片 DOM:还会抖
  • 去掉卡片外框、阴影、padding:还是会抖
  • 把标题里的 NuxtLink 换成普通 span:不抖
  • 把 span 换成普通 <a>:不抖
  • 换回 NuxtLink,但加 no-prefetch:不抖

这一步很容易让人以为:问题就是 NuxtLink 的预取。

NuxtLink 默认会为页面跳转做优化。
当链接进入视口附近时,Nuxt 可能提前预取目标页面需要的资源,这样用户点击时会更快。

这本来是好事。

但博客首页有一个特点:首屏一下子出现很多文章链接、标签链接、归档链接。
页面刚刷新时,这些链接同时进入视口,预取逻辑也会跟着启动。

在本地开发环境里,资源都在本机,加载很快,不一定看得出来。
到了 Vercel 线上,网络、缓存、资源 chunk 加载顺序都更真实,页面首屏渲染后又触发一批预取请求,就可能造成短暂的布局变化或视觉抖动。

所以这个问题不是“NuxtLink 不能用”,而是:

首屏批量链接不一定都适合默认预取。

当时我也按这个方向处理过:给列表页这些链接加 no-prefetch。
但后面继续验证发现,把 no-prefetch 去掉,只要注释掉详情页的 showToast,页面也不会抖。

所以 NuxtLink 预取不是最终根因。
它更像是排查过程里的一个干扰项:预取会改变资源加载时机,但真正造成样式变化的是被加载进来的那份全局 CSS。

真正根因:Vant reset 改了全局基础样式

继续查第二个现象:刷新进入首页和“详情页返回首页”不是同一个样式状态。

固定复现路径是:

  1. 刷新进入 /blog
  2. 点击一篇文章进入详情页
  3. 在详情页点击“全部文章”返回
  4. 首页左侧标签栏高度和刚刷新进入时不一样

更细一点看,左侧标签栏的 li 高度会从 34px 变成 30px。

继续查资源差异,发现详情页里复制代码按钮用了 Vant 的 showToast:

await navigator.clipboard.writeText(codeEl.textContent ?? '')
showToast('已复制')

项目里配置了 @vant/nuxt:

modules: ['@vant/nuxt', '@nuxt/content']

所以 showToast 会被自动导入。详情页用到它以后,构建出来的详情页资源里就会包含 Vant Toast 相关 CSS。

Vant 的样式里有一些全局 reset,大概像这样:

body {
  font-family: var(--van-base-font);
  margin: 0;
}

a {
  text-decoration: none;
}

ol,
ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

这些 CSS 一旦在 SPA 会话里加载进来,返回首页时不会自动卸载。

于是就出现了两个状态:

  • 直接刷新进入首页:还没加载 Vant reset
  • 从详情页返回首页:已经加载过 Vant reset

左侧标签链接本身没有写死 line-height,所以当 body 字体从浏览器默认字体变成 Vant 的 --van-base-font 后,行盒高度跟着变了。
这就是为什么同一个标签,刷新时看着是 34px,返回后变成 30px。

这个发现也解释了前面的首页刷新抖动:
只要详情页相关资源被提前加载,或者进入过详情页,Vant reset 就可能改变当前会话里的全局基础样式。看起来像是列表、卡片、NuxtLink 在抖,实际是底层的字体和 reset 变了。

最后怎么改

最终先做最小改动:注释掉详情页复制代码里的 Vant Toast。

await navigator.clipboard.writeText(codeEl.textContent ?? '')
// showToast('已复制')

这样详情页就不会因为复制提示把 Vant 全局 reset 带进来,返回首页时左侧标签栏高度也不会再变。

后面如果还想保留“复制成功”提示,更适合自己写一个很轻的本地 toast,样式放在详情页自己的 scoped style 里,不再引入 Vant。

这次学到什么

这次最容易误判的地方是:no-prefetch 确实能改变现象,所以它看起来像修复。
但它只是改变了资源加载时机,没有解释为什么样式会变。

真正要看的不是“哪个操作触发了变化”,而是“变化发生后多了什么资源”。

这次多出来的是 Vant 的全局 CSS reset。
它不是只影响详情页,而是影响整个 SPA 会话。

所以以后遇到这种“刷新进入”和“从别的页面返回”样式不一致的问题,要优先查:

  • 返回之前的页面有没有加载新的 CSS chunk
  • 新 CSS 里有没有 body、html、a、ul 这种全局选择器
  • 有没有组件库的 reset/base style 被按需带进来
  • 当前页面有没有依赖默认字体、默认行高、默认列表样式

这比单纯盯着当前页面的模板和 scoped style 更容易找到根因。