起因
每次 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 + HMRvite-plugin-pwa:service worker 注入vite-plugin-svgr:SVG → React componentunocss/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 端点。
踩过的坑
-
transform返回 string 不带 source map:sourcemap 丢失 → 调试
时 stack trace 指向编译后代码。永远 return{ code, map }。 -
path.resolve 在 Windows 反斜杠:跨 OS 用
path.posix或
path.normalize。 -
hook 异步未 await:plugin 完成前 build 已经结束 → 文件没生成。
asynchook 必须 await 完。 -
dev 模式 plugin 改动 cache 没失效:vite hot reload 时改 plugin
要重启 dev server。或者--force让 vite 不用 cache。 -
plugin 间冲突:A 改 .md 转 JS,B 也改 .md 加 frontmatter parser,
顺序错就乱。明确 order + 测试。
登录后参与评论。