Skip to content

详解 Vite 预构建流程

预构建这个名词容易让人不清楚到底发生在什么阶段,我们先抛下这个词的字面意思

预构建 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 库做 预构建

ts
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.includeoptimizeDeps.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 or bun.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

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')
})
html
<!DOCTYPE html>
<html>
<head><title>esbuild pre bundle</title></head>
<body>
  <script type="module" src="./index.js"></script>
</body>
</html>
js
// 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'

js
// 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

html
<!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的扫描深层依赖清单

js
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}
}
js
/**
 * 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个文件

js
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)
}
js
await doBuild(await scanImports())

手动修改引用为 import { throttle } from './dist/lodash-es.js'(不会命中 收集依赖清单的逻辑,因此不会重复打包)

请求路径映射

bare import 请求会被vite路由拦截重写为访问预构建目录的相应资源

这一步不难实现 因为已经收集到了: key为 bare import, value为完整路径 的依赖清单

难的是要给这个访问设置 qurey hash 并且能更新

缓存及更新逻辑

TODO: 使用 Vite 过程中遇到的 CommonJS 兼容问题

参考资料