预构建这个名词容易让人不清楚到底发生在什么阶段,我们先抛下这个词的字面意思
预构建 do what
- 仅发生在 dev 阶段,ViteDevServer 提供的功能
- 利用 esbuild 转化 CommonJS/UMD --> ESM
- 难点在于转化后的代码要支持
import xx {xx} from 'xx'
即:不能简单的把module.exports
替换成export default
- 在 build 阶段,则由 rollup 的 @rollup/plugin-commonjs 插件做转化
- 👆 rollup 的宗旨就是利用 ESM 的 tree-shaking 实现最小化打包后代码,因此本身就必须有完善的转化 plugin
- 难点在于转化后的代码要支持
- 整合内部依赖到1个文件(🤔也是用esbuild吗?)如
import { debounce } from 'lodash-es'
会发出 600 多个模块请求- 🤔 怎么确定哪些模块属于一个模块,node_modules的依赖还好,如果是业务代码的公共函数依赖也会被整合起来吗?
- 缓存预构建的结果,更新时机是:依赖关系发生变化(新引入资源或删除引入资源或修改引入资源路径)
- 👆如果 业务代码 也会整合起来的 HMR 没办法做吧,因为缓存住了这个预构建的结果,依赖关系也没变(只是变了内容)
- TODO: 建立一个原生js的vite项目,引入loadsh和深层模块的业务代码,查看浏览器 networker
Monorepo 场景
bare import 不是 node_modules 不会变化的依赖,而是正在开发中的源码 此时希望的是这个依赖不要经过 预编译 而是和其他业务代码一样
特殊情况下,Monorepo 的依赖不是ESM (基本不会吧。。。),可以手动配置让 vite 对这个 Monorepo 库做 预构建
export default defineConfig({
optimizeDeps: {
include: ['monorepo-dep'],
},
build: {
commonjsOptions: {
include: [/monorepo-dep/, /node_modules/],
},
},
})
那么此时 vite 就会把 monorepo-dep 当作 node_modules 的依赖处理,并且缓存 而 node_modules 依赖的缓存想要重新 预构建 需要packages.json依赖信息发生变化时才会触发 而 monorepo-dep 还在开发中,因此想要生效就只能重启 vite 服务,并且开启强制 focus 模式 pnpm dev --focus
预构建时机
- 预构建发生编译时
- TODO:待确认 编译时扫描所有代码的 import 语句分析出需要预构建的模块,并执行
- 当 import 语句,是在运行时访问到路由才生成的时候,源码中没有这个 import,则在启动编译时不会 预构建 到
- 👆 自动import unplugin-auto-import github 的插件是这种情况吗?
- 但是运行时,能监测到生成的 import 语句并触发重新 构建
- 用
optimizeDeps.include
或optimizeDeps.exclude
配置,手动指定启动编译时需要预编译的模块(即使源码中没有import) - exclude 排除则用于运行时触发重新 构建,如这个模块是 ESM 且依赖模块不多(预构建主要作用发挥不了)时,可以排除掉,省去这次重新 构建
- 运行时只负责监听是否需要重新 构建 - 🤔 运行时触发重新构建相当于重启 vite 吗?页面不会 reload ?
重新构建 !== 重新预构建 re-bundle !== re-run pre-bundle
重新预构建时机
vite 自动触发
也就是 更新预构建缓存的时机
- Package manager lockfile content, e.g.
package-lock.json
,yarn.lock
,pnpm-lock.yaml
orbun.lockb
- Patches folder modification time 🤔 补丁文件夹???
- Relevant fields in your
vite.config.js
, if present. NODE_ENV
value.
手动触发
pnpm dev --force
- 删除
node_modules/.vite/
目录,后重启 vite
缓存带来的特殊操作
因为 node_modules 视为不变化的模块,经过 预构建 之后除了在项目目录 nodejs 环境有缓存之外 viteDevServer 为这个资源做了浏览器 HTTP 强缓存和 url-query hash,只有触发 re-run pre-bundle 才会更新到这些浏览器强缓存的资源 url-query hash 如果希望直接修改 node_modules 中的代码来调试项目(此时不会触发自动 re-run pre-bundle) 因此需要手动重启 vite 触发 focus,并清除浏览器缓存(🤔 清除浏览器缓存是因为此时的hash不会变?生成hash的规则是什么?package.json 吗)
相关配置项
vite官方文档config - Dep Optimization Options
vite在启动 listen
的时候,先执行 1. esbuild的扫描深层依赖清单 2. esbuild对深度模块整合成1个文件,才会启动服务
所以虽然说 vite
说dev阶段都是懒加载启动没有打包步骤,其实是错的 既然 esbuild
可以实现打包,那 build阶段 为什么不干脆用 esbuild
,而是用 rollup
官方文档也解释了 esbuild
目前打包功能还不完善,不像 rollup
基本什么都能打包
创建项目
👇 esbuild_pre_bundle/serve.js
import Koa from 'koa'
import koaStatic from 'koa-static'
import { fileURLToPath, URL } from 'node:url' // 用于配置目录别名
const app = new Koa()
// 静态资源
app.use(koaStatic(fileURLToPath(new URL('.', import.meta.url))))
app.listen(3001, () => {
console.log('build success')
})
<!DOCTYPE html>
<html>
<head><title>esbuild pre bundle</title></head>
<body>
<script type="module" src="./index.js"></script>
</body>
</html>
// index.js
import { a } from './src/a.js'
console.log(a)
// a.js
export * from './a_1.js'
// a_1.js
export * from './a_1_1.js'
// a_1_1.js
export const a = 'a_1_1'
// import { a } from './src/a.js'
import { throttle } from 'lodash-es'
console.log(throttle)
Uncaught TypeError: Failed to resolve module specifier "lodash-es". Relative references must start with either "/", "./", or "../". 👆 esm 不支持 bare improt
<!DOCTYPE html>
<html>
<head><title>esbuild pre bundle</title></head>
<body>
<script type="importmap">
{
"imports": {
"lodash-es": "/node_modules/lodash-es/lodash.js"
}
}
</script>
<script type="module" src="./index.js"></script>
</body>
</html>
Vanilla Vite
不会合并成一个文件
lodash-es 会合并成一个文件
esbuild的扫描深层依赖清单
async function scanImports() {
// 确认入口,这里写死不支持配置,也不支持多入口
const entry = './index.js'
// 创建 esbuildScanPlugin 插件
const depImports = {} // key为 bare import, value 为 absolute url
const plugin = createEsbuildScanPlugin(depImports)
await build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry], // 传入入口
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [plugin], // Vite 支持配置其他插件
// outfile: 'dist.js',
allowOverwrite: true,
})
return {depImports}
}
/**
* esbuild 的 plugin 也是定义一个包含 name 和 setup 函数的对象
* setup函数会被 esbuild 注入一个 build 对象参数,往这里面挂载东西就能自定义 esbuild 的处理逻辑
*/
function createEsbuildScanPlugin(depImports) {
return {
name: 'dep-scan',
setup(build) {
build.onResolve({ filter: /^[\w@][^:]/ }, async ({ path, importer }) => {
// 获取 第三方模块的绝对路径
// const resolved = await resolve(path, importer)
const afterUrl = fileURLToPath(new URL('./node_modules/lodash-es/lodash.js', import.meta.url))
console.log('dep-scan check this', path,afterUrl)
const resolved = path === 'lodash-es' ? afterUrl : path
// ERROR: Plugin "dep-scan" returned a non-absolute path: lodash-es (set a namespace if this is not a file path)
// 只对 node_modules 目录下的模块用 esbuild 处理
if (resolved && resolved.includes('node_modules')) {
// 记录 pre-bundle 清单
// 🤔 TODO: 如果只是为了记录清单给后面的esbuild 转译, 为什么还要设置 true,转译步骤本身就会输出相应的文件吧
// 扫描这一步主要是处理其他后缀文件的吧,整合打包主要还是靠后面一步
depImports[path] = resolved
// 这里只是为了返回 external: true 选项
return {
path, // 模块路径
external: true, // 入口文件外的属于 node_modules 的模块设置为 true
}
}
})
},
}
}
esbuild对深度模块整合成1个文件
async function doBuild({depImports}) {
// esubild 的同一个 api build 参数加上 metafile: true 可以得到 res.metafile
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(depImports),
bundle: true, // 这里为 true,可以将有许多内部模块的 ESM 依赖关系转换为单个模块
format: 'esm',
// target: config.build.target || undefined,
// external: config.optimizeDeps?.exclude, // 配置项 排除预编译
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: fileURLToPath(new URL('./dist', import.meta.url)),
platform: 'browser',
ignoreAnnotations: true,
metafile: true,
// define, // 环境变量转真实值字符串
plugins: [],
})
// console.log('res.metafile', result.metafile)
}
await doBuild(await scanImports())
手动修改引用为 import { throttle } from './dist/lodash-es.js'
(不会命中 收集依赖清单的逻辑,因此不会重复打包)
请求路径映射
bare import 请求会被vite路由拦截重写为访问预构建目录的相应资源
这一步不难实现 因为已经收集到了: key为 bare import, value为完整路径 的依赖清单
难的是要给这个访问设置 qurey hash 并且能更新
缓存及更新逻辑
TODO: 使用 Vite 过程中遇到的 CommonJS 兼容问题