写一个自己的 Vite plugin:build 时自动生成 sitemap.xml

起因

每次 npm run build 后要手动跑 node generate-sitemap.js 生成 sitemap.xml
然后放进 dist/。容易忘 → 部署的 sitemap 是过期的。

把它做成 Vite plugin,build 时自动跑。学一次 plugin 编写 = 解锁
"任意 build-time 自动化"。

Vite plugin 基础

Vite plugin 是基于 Rollup plugin API + Vite 扩展。最小例子:

// vite-plugin-hello.ts
import type { Plugin } from 'vite'

export default function helloPlugin(): Plugin {
  return {
    name: 'hello',
    buildStart() {
      console.log('build started')
    },
    closeBundle() {
      console.log('build finished')
    },
  }
}

用:

// vite.config.ts
import { defineConfig } from 'vite'
import hello from './vite-plugin-hello'

export default defineConfig({
  plugins: [hello()],
})

buildStart / closeBundle 等是 Rollup hook。Vite 加了一些自己的
configResolved / transformIndexHtml 等)。

实战:自动生成 sitemap

需求:build 后扫描 src/routes/**/* 提取所有路由 → 生成
dist/sitemap.xml

// vite-plugin-sitemap.ts
import type { Plugin } from 'vite'
import { glob } from 'glob'
import path from 'node:path'
import fs from 'node:fs/promises'

interface Options {
  baseUrl: string
  outputDir?: string
  routesGlob?: string
}

export default function sitemapPlugin(opts: Options): Plugin {
  return {
    name: 'sitemap',
    apply: 'build',           // 只在 build 时跑(dev 不需要)

    async closeBundle() {
      const outDir = opts.outputDir ?? 'dist'
      const routes = await scanRoutes(opts.routesGlob ?? 'src/routes/**/*.{tsx,ts}')
      const xml = generateXml(opts.baseUrl, routes)
      const outPath = path.resolve(outDir, 'sitemap.xml')
      await fs.writeFile(outPath, xml, 'utf-8')
      console.log(`[sitemap] wrote ${routes.length} URLs to ${outPath}`)
    },
  }
}

async function scanRoutes(globPattern: string): Promise<string[]> {
  const files = await glob(globPattern)
  return files.map(file => {
    // src/routes/about.tsx → /about
    // src/routes/blog/[slug].tsx → /blog/:slug (skip dynamic)
    let p = file.replace(/^src\/routes/, '').replace(/\.(tsx|ts)$/, '')
    if (p.endsWith('/index')) p = p.replace(/\/index$/, '/')
    if (p.includes('[')) return null   // dynamic route skip
    return p || '/'
  }).filter(Boolean) as string[]
}

function generateXml(baseUrl: string, urls: string[]): string {
  const now = new Date().toISOString()
  const urlEntries = urls.map(u => `
  <url>
    <loc>${baseUrl}${u}</loc>
    <lastmod>${now}</lastmod>
    <changefreq>weekly</changefreq>
  </url>`).join('')

  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urlEntries}
</urlset>`
}

用:

import sitemap from './vite-plugin-sitemap'

export default defineConfig({
  plugins: [
    sitemap({ baseUrl: 'https://example.com' }),
  ],
})

npm run build 完自动看到 dist/sitemap.xml

关键 Vite hooks

apply

apply: 'build'    // 只 build 时跑
apply: 'serve'    // 只 dev server 跑
// 默认两者都跑

configResolved

configResolved(config) {
  console.log('mode:', config.mode)
  console.log('outDir:', config.build.outDir)
}

读完整 config 后调。常用于读 outDir / base / 模式自适应。

transformIndexHtml

transformIndexHtml: {
  order: 'pre',
  handler(html, ctx) {
    return html.replace('<head>',
      `<head>\n  <link rel="prefetch" href="/critical.js">`)
  },
}

修改 index.html 模板。常用于 inject 第三方 script / meta tag。

transform(修改 source 文件)

transform(code, id) {
  if (id.endsWith('.md')) {
    return {
      code: `export default ${JSON.stringify(parseMarkdown(code))}`,
      map: null,
    }
  }
}

.md 文件转成 JS module。?raw ?url 等 query 处理也在这里。

load / resolveId

resolveId(id) {
  if (id === 'virtual:my-module') return id
}

load(id) {
  if (id === 'virtual:my-module') {
    return `export const value = 42`
  }
}

虚拟模块:import { value } from 'virtual:my-module'
不存在的文件但 plugin 生成内容。

configureServer (dev only)

configureServer(server) {
  server.middlewares.use('/api/_health', (req, res) => {
    res.end('ok')
  })
}

dev server 加 middleware。例如 mock API endpoint。

handleHotUpdate

handleHotUpdate(ctx) {
  if (ctx.file.endsWith('.md')) {
    ctx.server.ws.send({ type: 'full-reload' })
    return []
  }
}

自定义 HMR 行为。.md 改了让浏览器 full reload。

更复杂:内容 transformer

把所有 .svg import 转成 React component:

import { transform } from 'esbuild'

export default function svgrPlugin(): Plugin {
  return {
    name: 'svgr-light',
    async transform(code, id) {
      if (!id.endsWith('.svg')) return
      const svg = await fs.readFile(id, 'utf-8')
      const component = `
        import React from 'react'
        export default function Svg(props) {
          return ${svg.replace('<svg', '<svg {...props}')}
        }
      `
      const result = await transform(component, { loader: 'jsx' })
      return result.code
    },
  }
}
import Logo from './logo.svg'
<Logo width="40" />

实际生产用 @svgr/rollup plugin 即可,自己写说明原理。

generateBundle

generateBundle(opts, bundle) {
  // 修改最终 bundle 中的 chunk / asset
  bundle['manifest.json'] = {
    type: 'asset',
    fileName: 'manifest.json',
    source: JSON.stringify({ ... }),
  }
}

dist/ 加额外文件(不一定是 sitemap,可以是 license 文件 /
manifest / 报告)。

Plugin 开发流程

mkdir vite-plugin-mine && cd vite-plugin-mine
npm init -y
npm i -D vite typescript

# src/index.ts
# tests/

npm publish

发到 npm 任何人能用。

或者 monorepo 里 packages/vite-plugin-mine 本地引用。

注意

Plugin 顺序

plugins: [
  pluginA(),       // 先跑
  pluginB(),
  pluginC(),       // 后跑
]

某些 hook(transform)按顺序串行。明确 plugin 间依赖。

order: 'pre' / 'post' 调整单 hook 顺序:

{
  name: 'mine',
  transform: {
    order: 'pre',
    handler(code, id) { ... }
  }
}

性能

每个文件 transform 都跑你的 plugin → 大项目 build 慢。
filter 早 return:

transform(code, id) {
  if (!id.endsWith('.special')) return
  // ...
}

或者更优雅用 filter

transform: {
  filter: { id: /\.special$/ },
  handler(code, id) { ... }
}

dev vs build

某些 hook 只在 build 跑(generateBundle / writeBundle / closeBundle)。
dev 模式没 bundle 概念。
要 dev 也响应文件改动用 configureServer + watcher。

实战 plugin 库参考

读 source 学到更多:

  • @vitejs/plugin-react:JSX transform + HMR
  • vite-plugin-pwa:service worker 注入
  • vite-plugin-svgr:SVG → React component
  • unocss/vite:原子 CSS transform

都是几百-千行代码,看完心里有数怎么写复杂 plugin。

我们的实际 plugin 例子

// 收集 build 时所有 import "use server" 函数 → 生成 server actions 清单
export default function serverActionsPlugin(): Plugin {
  const actions: string[] = []
  return {
    name: 'collect-server-actions',
    transform(code, id) {
      if (code.includes("'use server'")) {
        actions.push(id)
      }
    },
    generateBundle() {
      this.emitFile({
        type: 'asset',
        fileName: 'server-actions.json',
        source: JSON.stringify(actions, null, 2),
      })
    },
  }
}

build 后 dist/server-actions.json 给后端读 → 注册 RPC 端点。

踩过的坑

  1. transform 返回 string 不带 source map:sourcemap 丢失 → 调试
    时 stack trace 指向编译后代码。永远 return { code, map }

  2. path.resolve 在 Windows 反斜杠:跨 OS 用 path.posix
    path.normalize

  3. hook 异步未 await:plugin 完成前 build 已经结束 → 文件没生成。
    async hook 必须 await 完。

  4. dev 模式 plugin 改动 cache 没失效:vite hot reload 时改 plugin
    要重启 dev server。或者 --force 让 vite 不用 cache。

  5. plugin 间冲突:A 改 .md 转 JS,B 也改 .md 加 frontmatter parser,
    顺序错就乱。明确 order + 测试。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。