vite-plugin-pwa 源码分析:主入口与插件结构
为什么要拆成四个插件
vite-plugin-pwa 帮我们做的事情说起来简单:生成 manifest.webmanifest、生成 Service Worker、把注册 Service Worker 的代码注入到 index.html 里。但这几件事发生的时机差别很大——注入 HTML 要在 HTML 转换阶段,生成 SW 文件要等构建产物出来之后,开发环境下又完全是另一套逻辑(没有构建产物,得临时造一个 SW)。
Vite 的插件是一组带钩子的对象,一个插件里可以写多个钩子,但每个钩子的 enforce 和 apply 只能设一次。要让不同的工作在不同阶段、不同环境下生效,最干净的做法就是拆成多个插件,各自声明自己的时机,然后一起返回。所以主入口 VitePWA 返回的是一个数组而不是单个对象——Vite 支持插件返回数组,会自动展平。
这种「一个入口函数返回多个插件」的写法在 Vite 生态里很常见,读源码时看到数组就该想到:这几个插件是按阶段分工的,找问题要先判断自己关心的行为属于哪个阶段。
index.ts
主入口只有几行,做了三件事:创建共享上下文、创建对外暴露的 API、返回插件数组。
export function VitePWA(userOptions: Partial<VitePWAOptions> = {}): Plugin[] {
const ctx = createContext(userOptions);
const api = createAPI(ctx);
return [
MainPlugin(ctx, api),
InfoPlugin(ctx, api),
BuildPlugin(ctx),
DevPlugin(ctx),
];
}
首先在主入口中会发现 createContext 以及 createAPI,以及 return 出去一个数组,这个数组中包含了四个 pwa 中的插件
四个插件的分工大致是:MainPlugin 负责解析配置和生成 manifest 这类主流程工作,InfoPlugin 提供虚拟模块让业务代码能 import 到注册逻辑,BuildPlugin 只在构建时干活、负责调 workbox 生成 Service Worker,DevPlugin 只在开发环境生效。注意这四个插件都拿到了同一个 ctx 对象——这是它们之间传递状态的唯一通道。
createContext
可以看出来 createContext 的作用就是把用户传入的配置与下面 vite-pwa 定义的其他配置合并然后一起 return 出去了
export function createContext(
userOptions: Partial<VitePWAOptions>,
): PWAPluginContext {
return {
userOptions,
options: undefined!,
viteConfig: undefined!,
useImportRegister: false,
devEnvironment: false,
};
}
这里有个细节值得注意:options 和 viteConfig 都被赋成了 undefined!,后面那个感叹号是 TypeScript 的非空断言,意思是「我知道现在是空的,但你按非空类型处理」。这说明创建上下文时这两个值还拿不到——viteConfig 要等 Vite 的 configResolved 钩子触发才有,options 是在拿到 Vite 配置之后才能和用户配置合并出最终结果。
所以 createContext 严格来说不是「合并配置」,而是先把这个共享对象的壳建出来,字段留空。真正的合并发生在 MainPlugin 的 configResolved 钩子里,那时候才把 options 填上。四个插件持有同一个对象引用,一个插件写进去,其他插件就都能读到——这是插件间共享状态的常规做法,也解释了为什么各个插件里都要先 const { options } = ctx 取一次,而不能在模块顶层缓存。
DevPlugin
分析下 DevPlugin,根据名字观察可以了解到,这个插件作用于 dev 开发环境,传入的 ctx 就是使用 createContext 合并后的用户以及项目配置。
transformIndexHtml
transformIndexHtml 是 Vite 提供的钩子,专门用来改 index.html。它可以写成一个函数,也可以写成带 enforce 和 transform 的对象形式——后者能控制执行顺序:enforce: 'pre' 在 Vite 内部处理之前跑,'post' 在之后跑。
这里用 post 是必要的。Vite 会在自己的阶段往 HTML 里注入模块脚本、处理资源路径,PWA 的注册代码必须等这些都做完再插进去,否则可能被后续处理覆盖掉,或者插入的位置不对。
{
transformIndexHtml: {
enforce: ('post', async);
transform(html);
{
const { options } = ctx;
if (options.disable || !options.manifest || !options.devOptions.enabled)
return html;
html = injectServiceWorker(html, options, true);
return html.replace(
'</body>',
`${generateRegisterDevSW()}
</body>`,
);
}
}
}
transform 里的逻辑分三段看。
开头那个 if 是三道短路判断,任意一条成立就原样返回 HTML 不做处理:options.disable 是用户显式关掉了插件;!options.manifest 是没配 manifest,那 PWA 无从谈起;!options.devOptions.enabled 是没开开发环境支持。第三条是默认行为——vite-plugin-pwa 在 dev 下默认不启用 Service Worker,因为 SW 的缓存会和 Vite 的热更新打架,改了代码页面却拿到缓存的旧资源,调试起来很折磨。想在开发环境验证 PWA 行为,得手动打开 devOptions.enabled。
中间 injectServiceWorker(html, options, true) 负责往 <head> 里插 manifest 的 <link> 和相关的 meta 标签。第三个参数是 true,表示当前是开发模式,函数内部会据此走不同的路径,比如引用的 SW 文件名和路径与构建产物不同。
最后用字符串替换把注册脚本塞在 </body> 前面。generateRegisterDevSW() 生成的是开发环境专用的注册代码,和生产环境那套不是同一份。放在 body 末尾是为了不阻塞页面渲染,SW 注册本身也不需要抢在 DOM 之前完成。
这里直接用 html.replace 做字符串替换而不是解析 DOM,是 Vite 插件里常见的取舍:index.html 结构简单可控,字符串操作足够且快。但也意味着如果模板里没有 </body>,这段注入会静默失效。

还没有留言,来发表第一条吧~