Skip to content

记一次 VitePress 博客"最近更新"排序失效的完整调试过程

一个看似简单的"按时间排序"需求,背后藏着三个连环坑。

背景

博客首页有一个 TodayPosts 组件,用来展示最近更新的文章列表,按最后修改时间倒序排列。数据由 posts.data.mts(VitePress Data Loader)负责读取并排序。

现象:我明明刚改了一篇文章并提交推送,但首页列表里它并没有排到最前面,所有文章的时间都显示"近期更新",顺序完全乱掉。


坑一:fs.statSync().mtime —— 在 CI 环境中完全不可靠

最初的实现使用文件系统的最后修改时间:

ts
// ❌ 原始代码
import fs from 'node:fs'

const stat = fs.statSync(srcPath)
timestamp = stat.mtime.getTime()

本地开发时,保存文件就会更新 mtime,看起来没问题。

但是,GitHub Actions 执行 actions/checkoutgit clone 时,所有文件的 mtime 都会被重置为 checkout 那一刻的时间,导致所有文章时间戳相同,排序完全随机。

修复思路

改用 git log 获取每个文件最后一次 commit 的时间,这个信息存在 git 历史里,不受 clone 影响:

ts
// ✅ 第一次修复:execSync 调用 git log
import { execSync } from 'node:child_process'

const result = execSync(
  `git log -1 --format="%ct" -- "${srcPath}"`,
  { encoding: 'utf-8' }
).trim()
timestamp = parseInt(result, 10) * 1000

坑二:Windows cmd.exe%ct 当环境变量吞掉了

修复部署后,所有文章依然显示"近期更新"。问题没有解决。

通过调试,发现 git log 返回了空字符串。原因是:

在 Windows 的 cmd.exe 中,%ct% 会被识别为环境变量并展开execSync 默认通过 shell 执行命令,于是 --format="%ct" 里的 %ct 被 cmd 尝试展开为环境变量,找不到就变成了空字符串,git 收到的 format 参数变成了 "",自然返回空输出。

修复思路

改用 spawnSync,以参数数组的形式传递给 git,完全绕过 shell,%ct 就只是普通字符串:

ts
// ✅ 第二次修复:spawnSync 绕过 shell
import { spawnSync } from 'node:child_process'
import path from 'node:path'

const result = spawnSync(
  'git',
  ['log', '-1', '--format=%ct', '--', srcPath],  // 数组传参,不经过 shell
  { encoding: 'utf-8', cwd: path.dirname(srcPath) }
)
const output = result.stdout?.trim()
timestamp = parseInt(output, 10) * 1000

坑三:srcPath 始终是 undefined——根本原因

即使改成了 spawnSync,问题仍然存在。加入 console.log 调试后,终于看到了真相:

[DEBUG] NO srcPath for url=/vpn/telegram.html
[DEBUG] NO srcPath for url=/CasualTalk/看山是山.html
[DEBUG] NO srcPath for url=/KnowledgeBase/运维/CICD.html
... (所有文件都一样)

srcPath 对每一个文件都是 undefined

回头查阅 VitePress 文档,发现 createContentLoader 返回的 ContentData 类型中,srcPath 根本不是一个有保证的标准字段,在默认配置下它不会被填充。

所以前两次修复的代码都卡在了 if (srcPath) 这个判断上,永远走不进去,timestamp 永远是 0,全部显示"近期更新"。这才是真正的根源。


最终解决方案

不依赖 srcPath,改为url 推导出文件的相对路径,再用 process.cwd()(即 git 仓库根目录)作为 cwd 运行 git log

ts
// ✅ 最终方案:从 URL 推导路径,绕开 srcPath
import { createContentLoader } from 'vitepress'
import { spawnSync } from 'node:child_process'

// /vpn/telegram.html  → docs/vpn/telegram.md
// /vpn/              → docs/vpn/index.md
function urlToRelativePath(url: string): string {
  if (url.endsWith('/')) {
    return 'docs' + url + 'index.md'
  }
  return 'docs' + url.replace(/\.html$/, '.md')
}

function getGitTimestamp(relativePath: string): number {
  const result = spawnSync(
    'git',
    ['log', '-1', '--format=%ct', '--', relativePath],
    {
      encoding: 'utf-8',
      cwd: process.cwd(), // git 仓库根目录,本地和 CI 一致
    }
  )
  const output = result.stdout?.trim()
  return output ? parseInt(output, 10) * 1000 : 0
}

export default createContentLoader('**/*.md', {
  transform(raw) {
    return raw
      .filter(({ url }) => url !== '/' && !url.includes('api-examples'))
      .map(({ url, frontmatter }) => {
        const relativePath = urlToRelativePath(url)
        const timestamp = getGitTimestamp(relativePath)
        return {
          title: frontmatter.title || '无标题',
          url,
          time: timestamp,
          dateString: timestamp > 0
            ? new Date(timestamp).toLocaleDateString('zh-CN', {
                year: 'numeric', month: '2-digit', day: '2-digit',
                hour: '2-digit', minute: '2-digit'
              })
            : '近期更新'
        }
      })
      .sort((a, b) => b.time - a.time)
      .slice(0, 10)
  }
})

注意事项

CI 必须拉取完整 git 历史

git log 依赖完整的提交历史。如果 fetch-depth 不为 0,浅克隆会导致大量文件找不到历史,返回空结果。

yaml
# .github/workflows/deploy.yml
- name: checkout
  uses: actions/checkout@v4
  with:
    fetch-depth: 0  # ← 必须,否则 git log 取不到历史

排序基于 commit 时间,不是保存时间

修改文件后,必须 git commit 才会更新排序,仅仅保存文件不会有效果。

本地调试需重启 dev server

VitePress 的 data loader 只在服务器启动时运行一次,修改 md 文件或 data loader 后需要重启 npm run docs:dev 才能看到更新后的排序。


调试思路复盘

遇到"排序不对"这类问题时,直觉容易让人猜代码逻辑,但猜测往往浪费时间。这次用了两步定位法:

第一步:用独立脚本验证工具本身

把怀疑的核心调用抽出来,写一个单独的 debug-git.mjs,在 Node.js 里直接运行:

js
// debug-git.mjs
import { spawnSync } from 'node:child_process'
import path from 'node:path'

const srcPath = String.raw`G:\node-pro\sean_web\docs\vpn\telegram.md`

const result = spawnSync(
  'git',
  ['log', '-1', '--format=%ct', '--', srcPath],
  { encoding: 'utf-8', cwd: path.dirname(srcPath) }
)
console.log('stdout:', JSON.stringify(result.stdout))  // "1776393358\n"
console.log('stderr:', JSON.stringify(result.stderr))
console.log('status:', result.status)
console.log('error:', result.error)
bash
node debug-git.mjs

输出 "1776393358\n",证明 git 命令本身没有问题,问题一定出在 VitePress 内部。

第二步:在 data loader 里加 console.log

直接在 posts.data.mtstransform 里打印每个文件的关键变量:

ts
console.log(`[DEBUG] url=${url} | srcPath=${srcPath} | stdout=${JSON.stringify(result.stdout)}`)

重启 dev server 后,终端里立刻看到:

[DEBUG] NO srcPath for url=/vpn/telegram.html
[DEBUG] NO srcPath for url=/CasualTalk/看山是山.html
...

所有文件的 srcPath 都是 undefined 根本原因暴露。

两步的价值

步骤验证了什么
独立脚本 debug-git.mjs工具没问题spawnSync + git log 能正常返回时间戳
console.log in data loader参数有问题srcPath 在 VitePress 运行时始终为 undefined

前者排除外部依赖,后者定位内部状态。两步缺一,都会在错误的地方继续猜测。

核心教训:在加日志之前,不要猜测。 先用最小化的独立脚本验证工具,再用日志观察运行时状态。


总结:三个坑对照表

现象原因修复
1CI 部署后排序错乱fs.statSync().mtime 在 git clone 时被重置改用 git log 获取 commit 时间
2所有文章显示"近期更新"Windows cmd.exe 吞掉 %ct 变量改用 spawnSync 数组传参绕过 shell
3所有文章显示"近期更新"srcPath 始终为 undefinedurl 推导文件路径,不依赖 srcPath

调试过程的核心教训:在加日志之前,不要猜测。最终靠 console.log 打出 [DEBUG] NO srcPath for url=... 才找到根本原因,前两次修复都是在治标。