Vite 的 build 命令
我们直接来看 build 命令的源码
// build cli .command('build [root]', 'build for production') .option('--target <target>', `[string] transpile target (default: 'modules')`) .option('--outDir <dir>', `[string] output directory (default: dist)`) .option('-w, --watch', `[boolean] rebuilds when modules have changed on disk`) .action(async (root: string, options: BuildOptions & GlobalCLIOptions) => { // build 命令会执行的内容 }) •
cli.command
定义了 build 命令
options
函数定义了一些 build 命令的参数,声明的 option 参数,会出现在函数参数 options 中
我们来看看 build
命令实际执行的内容:
const { build } = await import('./build') // 处理 options 参数 const buildOptions: BuildOptions = cleanOptions(options) try { await build({ root, base: options.base, mode: options.mode, configFile: options.config, logLevel: options.logLevel, clearScreen: options.clearScreen, optimizeDeps: { force: options.force }, build: buildOptions, }) } catch (e) { createLogger(options.logLevel).error( colors.red(`error during build:\n${e.stack}`), { error: e }, ) process.exit(1) } finally { stopProfiler((message) => createLogger(options.logLevel).info(message)) }
因此,build
命令实际上就是执行 build
函数
build
函数如下:
let parallelCallCounts = 0 const parallelBuilds: RollupBuild[] = [] export async function build( inlineConfig: InlineConfig = {}, ): Promise<RollupOutput | RollupOutput[] | RollupWatcher> { parallelCallCounts++ try { return await doBuild(inlineConfig) } finally { parallelCallCounts-- if (parallelCallCounts <= 0) { await Promise.all(parallelBuilds.map((bundle) => bundle.close())) parallelBuilds.length = 0 } } }
实际上这里调用了 doBuild
函数。doBuild
函数中则是真正的执行构建了。
这里的并行处理的代码,是历史遗留逻辑,如今已经是没有用了。这部分在该 pull request 已经被删除,但截至发文该改动未被合入到 master
执行构建
在 doBuild
函数中,Vite 利用 Rollup 的 JS API 执行构建。
Rollup JS API 的使用分为两部分:
- 打包阶段:调用
rollup
函数,传入 input 配置,会得到bundle
对象,此时不会生成代码。 - 生成阶段:有以下两种方式
- 调用
bundle.generate
,传入 output 配置,得到构建后的代码。 - 调用
bundle.write
,传入 output 配置,根据 output 配置,将构建后代码写入到磁盘。
async function build() { // create a bundle let bundle = await rollup(inputOptions); // 从 bundle 生成代码并返回,拿到的是字符串,可以进行进一步的处理 const { output } = await bundle.generate(outputOptions); // 或直接从 bundle 生成代码并写入到磁盘,直接生成文件 await bundle.write(outputOptions) // closes the bundle await bundle.close(); }
同样的,Vite 通过 Rollup JS API 生成代码,需要生成 input 和 output 配置,总的流程如下:
标准化 Vite 配置
async function doBuild( inlineConfig: InlineConfig = {}, ): Promise<RollupOutput | RollupOutput[] | RollupWatcher> { // 生成一份标准化后的配置 const config = await resolveConfig( inlineConfig, 'build', 'production', 'production', ) // 省略其他逻辑 }
doBuild
的第一步,就是标准化 Vite 的配置,这里用的是 resolveConfig
函数,它会读取项目目录的 Vite 配置文件(如 vite.config.ts
),并跟 Vite 的一些内容配置进行合并,最终返回。
它的行为与 Vite dev
完全一致。如果对 Vite 的配置解析感兴趣,可以参考我写过的文章《五千字剖析 vite 是如何对配置文件进行解析的》,在该文章中,详细叙述过这个完成的流程。其主要有以下几步:
- 读取配置文件,为了兼容 TS 格式的配置文件,Vite 还会对配置文件进行编译再读取
- 处理插件,对插件进行排序,加入 Vite 内置插件等
- 读取环境变量文件,读取
.env
等文件
Rollup input 配置
Vite 生成的 rollup 配置如下:
const rollupOptions: RollupOptions = { context: 'globalThis', // vite 配置文件的 build.rollupOptions 对象 ...options.rollupOptions, input, plugins, external, }
我们用 Vite 仓库中自带的示例项目打个断点看看:
可以看到,Rollup 配置中主要有这么几个配置:
input
:打包的入口,从配置中计算出来,默认是index.html
,因此我们配置中即使没有填入口,Vite 也能正确的执行构建
const input = // 如果设置了 build.lib 对象,则对 build.lib 进行处理,需要支持多入口构建 libOptions ? options.rollupOptions?.input || (typeof libOptions.entry === 'string' ? resolve(libOptions.entry) : Array.isArray(libOptions.entry) ? libOptions.entry.map(resolve) : Object.fromEntries( Object.entries(libOptions.entry).map(([alias, file]) => [ alias, resolve(file), ]), )) // 没有设置 build.lib : typeof options.ssr === 'string' ? resolve(options.ssr) // 什么也不填,就会走到这里,默认入口是当前目录的 index.html : options.rollupOptions?.input || resolve('index.html')
plugin
:打包用到的插件
plugin
中,加入了很多 Vite 的内置插件。Vite 的很多开箱即用的能力,都是由这些插件提供的(Rollup 本身没有内置这些能力),例如:
- alias 别名
- CSS、less、sass 等处理
- CommonJs 处理(Rollup 本身不能处理,是通过插件支持 CommonJs 的)
- HTML 入口的处理
- ……
由于篇幅优先,这些插件就不一一介绍了。
在 vite build 与 vite dev 两种模式下,使用的插件都是相同的,Vite 在开发模式下,模仿 Rollup 仿造出了一套拥有相同的 API 的插件架构,使得插件在两种模式下都能正常使用,保证了两种模式下 Vite 有相同的行为。更多细节可以查看文章《Vite 是如何兼容 Rollup 插件生态的》
Rollup output 配置
Rollup 输出产物的代码如下:
const generate = (output: OutputOptions = {}) => { // 调用 bundle.write 或 bundle.generate return bundle[options.write ? 'write' : 'generate'](output) } if (options.write) { // 如果需要写入磁盘,就先准备输出目录,确保目录存在,并且清空目录 prepareOutDir(outDirs, options.emptyOutDir, config) } const res = [] // normalizedOutputs 为多个输出配置,因为可能一次构建,会输出多份代码 // 常见于构建 lib,需要分别输出 umd、esm 等多种格式的产物 for (const output of normalizedOutputs) { res.push(await generate(output)) } return Array.isArray(outputs) ? res : res[0]
同样的,我们还是打个断点看看:
output 参数中,定义了产物输出目录、产物 js 版本、名称格式等,因此,我们可以看到有以下的构建产物。
output
配置的生成
// 标准化 output 配置,从 vite 配置中生成 const outputs = resolveBuildOutputs( options.rollupOptions?.output, libOptions, config.logger, ) const normalizedOutputs: OutputOptions[] = [] if (Array.isArray(outputs)) { // 处理多入口的情况 for (const resolvedOutput of outputs) { normalizedOutputs.push(buildOutputOptions(resolvedOutput)) } } else { normalizedOutputs.push(buildOutputOptions(outputs)) }
buildOutputOptions
函数如下,主要是包装完整的 output 构建配置(大概看一下就行了):
const buildOutputOptions = (output: OutputOptions = {}): OutputOptions => { const ssrNodeBuild = ssr && config.ssr.target === 'node' const ssrWorkerBuild = ssr && config.ssr.target === 'webworker' const cjsSsrBuild = ssr && config.ssr.format === 'cjs' const format = output.format || (cjsSsrBuild ? 'cjs' : 'es') const jsExt = ssrNodeBuild || libOptions ? resolveOutputJsExtension(format, getPkgJson(config.root)?.type) : 'js' return { dir: outDir, format, exports: cjsSsrBuild ? 'named' : 'auto', sourcemap: options.sourcemap, name: libOptions ? libOptions.name : undefined, // vite 默认生成 es2015,因此默认是不支持传统老的浏览器 generatedCode: 'es2015', entryFileNames: ssr ? `[name].${jsExt}` : libOptions ? ({ name }) => resolveLibFilename(libOptions, format, name, config.root, jsExt) : path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`), chunkFileNames: libOptions ? `[name]-[hash].${jsExt}` : path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`), assetFileNames: libOptions ? `[name].[ext]` : path.posix.join(options.assetsDir, `[name]-[hash].[ext]`), inlineDynamicImports: output.format === 'umd' || output.format === 'iife' || (ssrWorkerBuild && (typeof input === 'string' || Object.keys(input).length === 1)), ...output, } }
至此,整个完整的 Rollup 配置就出来了。
总结
Vite build 的代码量其实非常的少,因为在 build 阶段,Vite 是利用 Rollup 去完成构建,整个过程只需要调用 Rollup 提供的 JS API 即可,整个过程中,Vite 的工作只是在做配置的转换,把 Vite 的配置转换成 Rollup 的 input 和 output 配置。
Vite 通过在 dev
模式时,模拟出一套与 Rollup 相同的插件架构,通过 dev
和 build
模式使用同一套插件,从而使两个模式下有相同的构建行为。