# 前言
如果你是一名纯前端开发,很少或者根本没有写过 nodejs,那么从编写 vite 插件入门 nodejs,会带给你很丝滑的体验。
这篇文章将从我个人的经验总结出编写 vite 插件的入门技巧和思维,希望对大家有所帮助。
# 如何入门 vite 插件?
先有想法,再去学习。如果你遇到了一些问题,无法通过纯前端代码实现,或许这时候就可以想想,能不能用 vite 插件实现。有了想法之后,再去 vite/rollup 文档上看应该使用插件的哪些钩子。我认为这样是比较好的实践方式。
# vite 插件的本质
既然我们想写一个 vite 插件,那么应该对其有个基本的认知。
我认为:「vite 插件用于增强代码。」
# 代码增强
代码增强也就是通过 vite 的能力,对项目代码做魔改,以实现功能。
以下我通过 3 个例子来说明什么是代码增强。
# 动态注入代码
举个例,假设我项目启动后,在浏览器控制台中,打印出项目的版本号、构建时间、构建环境。
构建时间、构建环境在前端中都无法获取,只能在 vite 环境中获取到,所以就可以写 vite 插件来实现。
想法有了,那现在需要知道,在什么钩子中能获取到我们需要的数据,查阅 vite 官方文档,有个 configResolved
钩子能获取到构建环境,那么插件已经有个雏形了
import path from 'path' | |
import { defineConfig, PluginOption, ResolvedConfig } from 'vite' | |
import { createRequire } from 'module' | |
import dayjs from 'dayjs' | |
import tz from 'dayjs/plugin/timezone' | |
import utc from 'dayjs/plugin/utc' | |
dayjs.extend(tz) | |
dayjs.extend(utc) | |
function viteLogTime() { | |
let config: ResolvedConfig | |
let version: string | |
const currentTime = dayjs().tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm:ss") | |
return { | |
name: 'vite-log-time', | |
configResolved(_config) { | |
config = _config | |
const require = createRequire(import.meta.url) | |
version = require(path.resolve(config.root, 'package.json')).version | |
} | |
} | |
} |
到这一步,就拿到了我们所需要的信息,下一步,就是思考如何把信息打印到浏览器控制台上
让我们站在纯前端的视角来思考这个问题,要把信息打印到控制台,很简单吧?在代码中加 console.log
呗!
没错,就是这么简单,切换到 vite 插件的视角来解决这个问题,同样也是给代码中加 console
,但是怎么加,加在哪里?
查阅 vite 文档后,发现有两个钩子,可以对项目代码进行魔改,一个叫 transform
,一个叫 transformIndexHtml
。那我们就用这两个钩子来试试
如果使用 transform
,那需要确定要魔改的文件,在这里我们就找入口文件,确保了唯一性。
import path from 'path' | |
import { defineConfig, PluginOption, ResolvedConfig } from 'vite' | |
import { createRequire } from 'module' | |
import dayjs from 'dayjs' | |
import tz from 'dayjs/plugin/timezone' | |
import utc from 'dayjs/plugin/utc' | |
dayjs.extend(tz) | |
dayjs.extend(utc) | |
function viteLogTime() { | |
let config: ResolvedConfig | |
let version: string | |
const currentTime = dayjs().tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm:ss") | |
return { | |
name: 'vite-log-time', | |
configResolved(_config) { | |
config = _config | |
const require = createRequire(import.meta.url) | |
version = require(path.resolve(config.root, 'package.json')).version | |
}, | |
transform(code, id) { | |
if(id.endsWith('src/main.tsx')) { | |
const info = { | |
mode: config.mode, | |
currentTime, | |
version, | |
} | |
return { | |
code: ` | |
console.log('构建信息:', '${JSON.stringify(info)}') | |
${code} | |
`, | |
map: null | |
} | |
} | |
}, | |
} | |
} |
启动项目后,就可以看到控制台打印了信息!
构建信息:{"mode": "development", "currentTime": "2025-02-14 11:54:48", "version":...} |
还有个 transformIndexHtml
钩子,咱们也试试
import path from 'path' | |
import { defineConfig, PluginOption, ResolvedConfig } from 'vite' | |
import { createRequire } from 'module' | |
import dayjs from 'dayjs' | |
import tz from 'dayjs/plugin/timezone' | |
import utc from 'dayjs/plugin/utc' | |
dayjs.extend(tz) | |
dayjs.extend(utc) | |
function viteLogTime() { | |
let config: ResolvedConfig | |
let version: string | |
const currentTime = dayjs().tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm:ss") | |
return { | |
name: 'vite-log-time', | |
configResolved(_config) { | |
config = _config | |
const require = createRequire(import.meta.url) | |
version = require(path.resolve(config.root, 'package.json')).version | |
}, | |
transformIndexHtml() { | |
const info = { | |
mode: config.mode, | |
currentTime, | |
version, | |
} | |
return [ | |
{ | |
tag: 'script', | |
attrs: { | |
type: 'module', | |
}, | |
children: `console.log('构建信息:', '${JSON.stringify(info)}')`, | |
}, | |
] | |
} | |
} | |
} |
这样也能打印构建信息。
这是一个非常简单的例子,但也能从中看出,vite 插件可以通过魔改注入代码,来解决纯前端无法实现的点。
# 文件操作
在 node 环境中,我们可以大展身手,开发最离不开的就是一个个代码文件,接下来我举个例,通过 vite 环境操作文件来提升开发效率
TikTok 被 ban 的时候,小红书融入了大量的歪果仁,那时候小红书迫切的需求就是国际化。随着业务扩展,国际化越来越流行,我目前也主要是负责的国际化项目。
国际化,无非就是两个字:翻译。说白了,也就是把本土语言,翻译成其他国家语言,放在语言文件中。
import zhCommon from "./zh/common.json" | |
import zhHome from "./zh/home.json" | |
import enCommon from "./en/common.json" | |
import enHome from "./en/home.json" | |
const languages = ["en", "zh"] as const | |
const resources = { | |
en: { | |
common: enCommon, | |
home: enHome | |
}, | |
zh: { | |
common: zhCommon, | |
home: zhHome | |
}, | |
} |
国际化资源一般是统一管理,所以也避免不了不断地导入语言 json 文件,每次新增翻译文件时,会做大量的重复工作。于是我想能不能做个 vite 插件来管理所有的国际化资源文件,让开发者从翻译文件中解脱!
核心思路就是 收集指定目录中的语言文件,然后通过虚拟文件的方式让前端可导入。
这里就简单写点伪代码,完整代码在这里:github [1]
export function i18nAlly(options?: I18nAllyOptions): PluginOption { | |
// 一个可以收集用户指定目录下的所有翻译文件的探测器实例 | |
const localeDetector = new LocaleDetector(options) | |
let server: ViteDevServer | |
return { | |
name: 'vite:plugin-i18n-ally', | |
enforce: 'pre', | |
async config() { | |
// 初始化翻译文件探测器 | |
await localeDetector.init() | |
}, | |
// 此 hook 可用于虚拟文件,参考 vite 官方文档 | |
async resolveId(id: string, importer: string) { | |
const { virtualModules, resolvedIds } = localeDetector.localeModules | |
if (id in virtualModules) { | |
return VirtualModule.resolve(id) // 例如:\0/@i18n-ally/virtual:i18n-ally-en | |
} | |
return null | |
}, | |
async load(id) { | |
const { virtualModules, resolvedIds, modules, modulesWithNamespace } = localeDetector.localeModules | |
if (id.startsWith(VirtualModule.resolvedPrefix)) { | |
const idNoPrefix = id.slice(VirtualModule.resolvedPrefix.length) | |
const resolvedId = idNoPrefix in virtualModules ? idNoPrefix : resolvedIds.get(idNoPrefix) | |
// e.g. \0/@i18n-ally/virtual:i18n-ally-en | |
// 如果是翻译虚拟文件,则返回探测到的文件内容 | |
if (resolvedId) { | |
const module = virtualModules[resolvedId] | |
return typeof module === 'string' ? module : `export default ${JSON.stringify(module)}` | |
} | |
} | |
return null | |
}, | |
} as PluginOption | |
} |
然后在前端,通过导入虚拟文件,就可以获取到翻译资源了。
这个例子相对比较复杂一点,但核心就是告诉新手朋友们,你们也在遇到类似的大量重复工作时,也可以尝试通过 vite 插件来提升开发效率、减少维护负担
# 文件路由
通常是在一个上层框架才会集成文件路由,比如 nextjs、remix、nuxt 这些。文件系统路由相比配置式路由来说,也是减少了大量重复开发工作,而且使项目结构更加清晰,很大程度上增强了项目的维护性,如果你们既使用 nextjs/remix 这种 ssr 框架,又有普通的 vite 单页面项目,那么统一的文件系统路由,也能对其项目之间的开发习惯。
我是从 react-router 6.4 引入 data api 之后,开始尝试在单页面项目中引入文件系统路由。为什么呢?如果你熟悉 react-router 的话,会发现想要利用好 data-api,最好的实践就是像 remix 那样,在路由文件中导出 data api。如果你不了解的话,也无所谓,接下来也是单纯对文件系统路由做分析思考以及提出解决方案。
文件路由,实际上就是在 node 层,收集到所有的路由文件,然后组装成前端路由库所需要的数据格式,交由前端路由。
插件相对也是比较复杂,完整代码在此处:github [2]
贴一下伪代码:
import type * as Vite from 'vite' | |
function remixFlatRoutes(options: Options = {}): Vite.PluginOption { | |
return [ | |
{ | |
name: 'vite-plugin-remix-flat-routes', | |
// 前端通过虚拟文件导入组装好的路由结构 | |
async resolveId(id) { | |
if (id === 'virtual:route') { | |
return '\0virtual:route' | |
} | |
return null | |
}, | |
async load(id) { | |
switch (id) { | |
case '\0virtual:route': { | |
// 遍历项目文件,找到路由文件,并进行组装 | |
const routes = findRoutes() | |
const { routesString, componentsString } = await routeUtil.stringifyRoutes(routes) | |
return { | |
code: `import React from 'react'; | |
${componentsString} | |
export const routes = ${routesString}; | |
`, | |
map: null, | |
} | |
} | |
default: | |
break | |
} | |
return null | |
}, | |
}, | |
] | |
} |
这个例子的核心也是通过 nodejs 解析到前端所需要的数据,然后通过虚拟文件的形式暴露给前端,让前端拥有了更强大的运行时能力。
# 总结
上文讲到的,其实都是业务相关的插件,当然 vite 也有很多构建时插件,比如代码 zip 压缩、代码分析等,这些我认为不适合在入门时学习,因为大部分前端开发其实都是在跟业务打交道。
在我写了不少的 vite 插件后,我总结了以下经验
- 先知道自己想做什么插件,然后再去实践
- 如果你刚接触 vite 插件,或许你最头疼的是什么时候用什么钩子,其实当你知道你需要解决什么问题的时候,再去翻文档,或者查 AI,很快就能得到答案
- 在 vite 插件中,前端代码不过是 “字符串”,随便你怎么添加删除修改都可以,不要害怕代码改了就会出问题
- 多看入门级插件代码,比如 vite-plugin-html, vite-plugin-legacy,这些库或许可以让你明白什么钩子在什么场景下使用
以下是我写的一些 vite 插件,可供学习参考:
- vite-plugin-i18n-ally [3]。自动懒加载 i18n 资源
- vite-plugin-remix-flat-routes [4]。remix-flat-routes 风格的文件系统路由
- vite-plugin-public-typescript [5]。注入 TypeScript 到 HTML 中
- vite-plugin-prerelease [6]。动态切换至预发布 / 正式环境
- vite-plugin-istanbul-widget [7]。前端代码覆盖率上报工具
转自掘金社区