pnpm dev 的背后(vite构建)

pnpm dev (执行启动命令)

执行 pnpm dev 时,pnpm 包管理器会查找 package.jsonscripts 下的 dev 命令并执行它(通常是 vite / vite serve

1
2
3
"scripts": {
"dev": "vite",
},

这实际上调用了 Vite 的二进制文件 node_modules/.bin/vite(这个会引连到 Vite 的 CLI 模块 vite/src/node/cli.ts

   接着

   CLI 模块使用 [cac](https://github.com/cacjs/cac) 库来解析命令行参数(如 --port--open

   并根据命令(serve, build, preview

1
2
3
4
5
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},

   创建相应的 Vite 开发服务器或执行构建

确定 vite 启动时模式(mode)

Vite 在启动时,会首先确定当前的运行模式,默认为 development(开发)

你也可以通过命令行手动指定模式

1
2
vite --mode production
vite --mode test

运行模式决定了 Vite 加载哪些 .env 环境变量文件

加载.env文件

随后,按照以下顺序查找和加载环境变量文件

  1. .env.${mode}.local (如 .env.development.local)(最高优先级,且不会提交到 git [.gitignore])

  2. .env.${mode}(如 .env.d

  3. .env.local

  4. .env (全局默认配置)

root / env.d.ts

1
2
3
declare const PROD: boolean;
declare const DEV: boolean;
declare const TEST: boolean;

所有变量都会被注入到 Node.js 的 process.env 中**(process 是 Node.js 的全局对象,所以仅在服务端生效)

插播:Node.js 的 process.env 是个啥

  就是一个普通的 JS 对象,而 env 它是 process 的一个属性,专门存环境变量

  它大概是这样的👇

1
console.log(process.env);
1
2
3
4
5
6
7
{
NODE_ENV: 'development',
PORT: '3000',
DATABASE_URL: 'postgresql://localhost:5432/mydb',
VITE_API_URL: 'https://dev-api.example.com',
SECRET_KEY: 'abcdefg123456'
}

  你可以把它看成 NodeJS 的备忘录,存着环境,端口,接口,密钥等数据

好了现在回归正题

比如我在某文件需要知道这些参数来判断当前的运行环境👇(我在build脚本文件用的最多)

1
2
3
const isProd = process.env.NODE_ENV === 'production' // 生产环境
const isDev = process.env.NODE_ENV === 'development' // 开发环境
const isTest = process.env.NODE_ENV === 'test' // 测试环境
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
plugins: [
terser({ // 压缩JS的插件
compress: {
sequences: isProd, // 生产环境下删除连续语句
arguments: isProd, // 生产环境下删除函数参数
drop_console: isProd && ['log'], // 生产环境下删除 console.log 语句
drop_debugger: isProd, // 生产环境下删除 debugger 语句
passes: isProd ? 4 : 1, // 生产环境下进行 4 次压缩,开发环境下进行 1 次压缩
global_defs: { // 定义全局变量,用于在压缩代码时进行条件编译
'@DEV': JSON.stringify(isDev),
'@PROD': JSON.stringify(isProd),
'@TEST': JSON.stringify(isTest),
},
},
......
],

但是!这些变量不能暴露给浏览器,不然任何访问你网站的人F12就能看到数据库密码、API 秘钥等……

过滤出 VITE_ 开头的变量

只有以 VITE_ 为前缀的变量才会通过 import.meta.env 暴露给你的客户端源码 (看上面的例子,比如是 VITE_API_URL

  (Vite 内部会对此做筛选,类似于以下这样)

1
2
3
4
5
6
const clientEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('VITE_')) {
clientEnv[key] = value // 只保留 VITE_ 开头的
}
}

  然后,Vite 把这个 clientEnv 对象注入到前端代码中,变成

1
2
import.meta.env.VITE_API_URL  // 可访问
import.meta.env.DB_PASSWORD // undefined

注入import.meta.env到客户端

import.meta.env 就是由 process.env 筛出来的,所以和 process.env 一样,import.meta.env 作为客户端的备忘录

对比 process.env import.meta.env
运行环境 Node.js(服务端) 浏览器(客户端)
使用位置 Vite 服务器、构建脚本 Vue/React 组件
值定义在 .env 文件 + 操作系统 Vite 筛选并注入
变量范围 所有变量 只能访问 VITE_ 开头的

配置解析与合并

回归主线,处理完环境变量,下一步就是合并配置

Vite 会合并&处理以下的配置生成最终的构建配置对象

命令行(CLI)

项目设置的命令行参数(优先级最高)

例如 vite --port 5000 --open --host,Vite 会把这些建议解析成一个对象:

1
2
3
4
5
const cliOptions = {
port: 5000,
open: true,
host: true
}

defineConfig

Vite 读取并执行 vite.config.ts,获取导出的 defineConfig

1
2
3
4
5
6
7
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
open: true
}
})

结果也就是

1
2
3
4
5
6
7
const configFromFile = {
plugins: [vuePlugin],
server: {
port: 3000,
open: false
}
}

合并

Vite 会把两者合并CLI 优先级更高

1
2
3
4
5
6
7
8
const mergedConfig = {
plugins: [vuePlugin],
server: {
port: 5000,
open: true,
host: true
}
}

config 钩子

在最终配置确定前,插件可以通过插件的 config 钩子修改配置(和 axios 的二次封装是一个道理,会合并你的配置)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 自定义插件
const myPlugin = {
name: 'my-plugin',
config(config, env) { // config 钩子;config 已经是当前合并后的配置;env 就是之前说的备忘录(process.env)
console.log('当前 mode:', env.mode) // 'development'

return { // 修改配置(二次封装)
server: {
...config.server, // 展开已有 server 配置
proxy: { // 新增 proxy
'/api': 'http://localhost:8080'
}
},
define: { // 新增全局常量
__MY_APP_VERSION__: JSON.stringify('1.0.0')
}
}
}
}

配置结果是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const afterPluginConfig = {
plugins: [vuePlugin],
server: {
port: 5000,
open: true,
host: true,
proxy: {
'/api': 'http://localhost:8080'
}
},
define: {
__MY_APP_VERSION__: '"1.0.0"'
}
}

并且这个最终的配置对象会被冻结(frozen),防止后续被修改

configResolved 钩子

在配置确定后,插件可以根据插件的 configResolved 钩子读取存储最终解析的配置(只读)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const myPlugin = {
name: 'my-plugin',
config(config, env) {
console.log('当前 mode:', env.mode)

return {
server: {
...config.server,
proxy: {
'/api': 'http://localhost:8080'
}
},
define: {
__MY_APP_VERSION__: JSON.stringify('1.0.0')
}
}
}
configResolved(resolvedConfig) { // configResolved 钩子(只读);resolvedConfig 就是最终确定的配置
const { server, define, plugins } = resolvedConfig // 解构赋值

if (server.port === 5000) {
console.log('服务器在 5000 端口启动')
}

if (define.__MY_APP_VERSION__) {
this.version = define.__MY_APP_VERSION__
}

// 不能修改
// resolvedConfig.server.port = 6000 // 无效
}
}

插件系统初始化与构建

Vite 的功能几乎都由插件提供(包括内置插件)

而这些插件就会涉及到排序先行的问题,vite内部规定了什么插件应该优先执行解析

插件排序

排序逻辑

插件会根据 enforce 属性排序执行

  • enforce: 'pre':在 Vite 核心插件之前调用【代码转换插件,别名处理插件 …

    1
    2
    3
    4
    5
    6
    7
    import react from '@vitejs/plugin-react' // 作用:解析别名、代码转换、重写导入路径

    export default defineConfig({
    plugins: [
    react() // 内部 enforce: 'pre'
    ]
    })

      > .jsx 文件需要先转成 .js 文件 Vite 核心才能识别,react 要确保在 .jsx 文件被解析前先处理 JSX 语法

  • enforce**(默认):在 Vite 核心插件之后调用【大多数插件 …

    1
    2
    3
    4
    5
    6
    7
    import vue from '@vitejs/plugin-vue' // 作用:把<template>编译成render函数、提取<script>、注入HMR代码

    export default defineConfig({
    plugins: [
    vue() // 默认顺序,无 enforce
    ]
    })

      > 因为它处理的是已经被分好的单 .vue 文件子请求,必须在核心插件之后

  • enforce: 'post':在 Vite 构建插件之后调用【打包后处理插件,生成报告插件 …

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { visualizer } from 'rollup-plugin-visualizer' // 作用:打包后处理(压缩、混淆等)、生成报告

    export default defineConfig({
    plugins: [
    vue(),
    visualizer({
    open: true
    }) // 默认 enforce: 'post'
    ]
    })

      > visualizer 插件需要等所有代码打包完成后,才能分析最终的 bundle 大小

这一块由于加了细节写的有点乱,可以先看看下面的 总结

指定模式

插件还可以通过 apply 指定只在特定模式下生效

1
apply: 'serve'     // 只在开发模式(dev)启用
1
2
3
4
5
6
7
8
9
10
const mockPlugin = {
name: 'vite-plugin-mock',
apply: 'serve', // 只在 pnpm dev 时生效
configureServer(server) {
// 在开发服务器上注册 mock 接口
server.middlewares.use('/api/users', (req, res) => {
res.end(JSON.stringify([{ id: 1, name: 'John' }]))
})
}
}
1
apply: 'build'     // 只在构建模式启用
1
2
3
4
5
6
7
8
const compressPlugin = {
name: 'vite-plugin-compress',
apply: 'build', // 只在 pnpm build 时生效
closeBundle() {
// 构建完成后,对 dist 目录进行 gzip 压缩
execSync('gzip -r dist/')
}
}

总结

类型 enforce 典型用途 真实例子
预处理 'pre' 路径解析、语法转换 @vitejs/plugin-react
普通处理 默认 组件编译、资源处理 @vitejs/plugin-vue
后处理 'post' 打包分析、压缩 rollup-plugin-visualizer
模式控制 apply: 'serve' 开发专用功能 Mock 插件
模式控制 apply: 'build' 构建优化 Gzip 压缩插件

依赖预构建

Vite 的开发服务器基于 ESModule,实现了真正的按需编译,但很多 npm 包还是 CommonJS(CJS)格式

为了解决兼容性和性能问题,vite 会进行依赖预构建,主要是为了优化👇

  • 兼容性:将 CommonJS 或 UMD 格式的依赖项转换为 ESM 格式,以便在浏览器中通过 <script type="module"> 正常加载

  • 性能:将有许多内部模块的 ESM 依赖项(如 lodash-es)转换为单个模块,减少浏览器需要发出的 HTTP 请求数

预构建后的依赖会被缓存到 node_modules/.vite 目录下

这是 vite 启动快的一个原因,它只处理你自己写的代码,依赖项已经“预打包”好了

建立开发服务器

Vite 使用 Connect 作为底层框架,创建一个开发服务器

启用功能:

  • 监听指定端口(如 localhost:3000

  • 处理所有静态资源请求(HTML、JS、CSS、图片等)

  • 启动 WebSocket,用于 HMR(热更新)

    1
    2
    3
    4
    5
    // 伪代码
    const server = connect()
    server.use(serveStatic())
    server.use(handleModuleRequest) // 处理 .vue, .ts 等
    createWebSocketServer() // 用于 HMR

注入中间件

什么是中间件

中间件就是在 浏览器vite 请求文件过程中的 ”中间检查站

  一个中间件就是一个函数,类似于这种👇

1
2
3
4
5
6
7
8
function myMiddleware(req, res, next) { // 请求对象,相应对象,放行函数
if (req.url === '/hello') { // 不调用 next(),相当于直接拦截
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('Hello from middleware!')
} else {
next() // 放行,继续后续处理
}
}

Vite 的开发服务器底层基于 Connect(一个轻量级 Node.js 框架),而 Connect 的核心就是中间件机制

它的结构就像这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
浏览器请求

[插件注册的中间件 1] → Mock 接口拦截 /api/*

[插件注册的中间件 2] → React JSX 转换

[插件注册的中间件 3] → 静态资源重定向

……(其他插件中间件)

[Vite 核心中间件]
├── 1. HTML 处理中间件
├── 2. 静态资源服务中间件
├── 3. 模块请求处理中间件(核心!)
├── 4. HMR WebSocket 中间件
└── 5. 404 处理中间件

返回响应给浏览器

Vite 的操作

Vite 的规则是 先注册的插件先执行

1
2
3
4
5
6
7
8
9
// vite.config.ts
export default defineConfig({
plugins: [
mockPlugin(),
reactPlugin(),
legacyPlugin(),
myCustomPlugin()
]
})

插件可以通过 configureServer 钩子**(插件开发者已定义好)向 Vite 的服务器注册自己的中间件

比如 mock 插件

效果:当你在浏览器访问 /api/users,根本不会去请求后端,而是直接返回 mock 数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const mockPlugin = {
name: 'vite-plugin-mock',
configureServer(server) { // 向 Vite 的服务器注册 server
// 有人请求了 /api/users
server.middlewares.use('/api/users', (req, res, next) => { // middlewares 是 Connect 的中间件容器
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
)
// 没有 next(),请求被拦截
})

// 或是记录日志,不做操作
server.middlewares.use('/api/*', (req, res, next) => {
console.log('拦截所有 /api/ 开头的请求')
next() // 放行,交给下一个处理
})
}
}

⚠️ 如果你在中间件里 return 了响应(比如 mock 数据),后面的中间件(包括 Vite 核心)就不会执行

请求处理(核心)

当浏览器请求一个文件(如 App.vue)时,Vite 不是简单地返回文件内容,而是通过 插件系统动态转换,最终返回一段可执行的 JS 模块。而这个过程会产生对子资源的额外请求,这是理解 Vite 处理 .vue 文件的关键

请求拦截

浏览器发起:****GET /src/App.vue

Vite 的开发服务器拦截该请求,不直接读取文件返回,而是 开启模块解析和转换流程

插件流水线执行(核心)

Vite 通过 Rollup 风格的插件接口,依次调用注册的插件钩子,顺序如下:

resolveId(specifier)

解析模块依赖关系时(如 import './App.vue'

调用插件的 resolveId 钩子,确定 './App.vue' 对应的真实路径(如 /project/src/App.vue

1
2
// Vite 内部调用
const resolved = await pluginContainer.resolveId('./App.vue')

load(id)

模块路径解析后

需要读取原始内容,调用插件的 load 钩子,获取文件原始字符串

1
2
3
// Vite 内部调用
const code = await pluginContainer.load('/project/src/App.vue')
// 返回:'<script>...<\/script><template>...<\/template>'

transform(code, id)

当对加载的原始内容进行转换时

codeid 传给所有插件的 transform 钩子,按顺序执行转换

Vite不自己转换 .vue 文件,这里是把 code 交给 @vitejs/plugin-vue 插件处理

注:@vitejs/plugin-vuetransform 阶段会自动注入 HMR 支持

1
2
3
4
5
6
7
// Vite 内部调用
let result = code // 你的代码
for (const plugin of plugins) {
if (plugin.transform) {
result = await plugin.transform(result, id)
}
}

@vitejs/plugin-vue 插件发现 id.vue 文件时,就会介入处理

处理子资源请求

vue 文件内部还有分块,也就是 templatescriptstyle

浏览器继续发起请求:****GET /src/App.vue?type=template

Vite 再次拦截这个请求,并重复之前的流程

resolveId(specifier)

插件(@vitejs/plugin-vue)识别到 ?type=template,将其标记为一个虚拟模块,返回原样或特殊标识load(id)

load(id)

可能返回空字符串,或不调用(某些插件直接在 transform 处理)

transform(code, id)

@vitejs/plugin-vue 插件发现特殊标识id,就知道这是模板请求

会使用 @vue/compiler-dom 将模板编译为 render 函数(这是vue插件的功能)

1
2
3
4
5
6
// 返回给浏览器的内容
export const render = function render(_ctx, _cache) {
return (_openBlock(), _createBlock("div", null, [
_createTextVNode("Count: " + _toDisplayString(_ctx.count), 1)
]))
}

流程总结

1
2
3
4
5
6
7
8
9
10
11
12
13
浏览器
↓ GET /src/App.vue
Vite 服务器
↓ 调用 resolveId → load → transform
@vitejs/plugin-vue 转换:返回 JS,包含 import './?type=template'
↓ 浏览器解析 JS,发现新 import
浏览器
↓ GET /src/App.vue?type=template
Vite 服务器
↓ 再次 resolveId / transform
@vitejs/plugin-vue:提取模板,编译为 render 函数
↓ 返回 render 函数代码
浏览器:组件加载完成

热模块更新

Vite 最大的特点就是拥有极快的热更新(HMR),Vite的热更新本质是 在运行时动态替换模块代码

更新主要包含以下过程

服务器感知

1
2
3
4
5
6
const watcher = chokidar.watch(root, { ... }) // Vite 使用 chokidar 监听文件

watcher.on('change', async (file) => { // file: /project/src/App.vue
const modules = moduleGraph.getModulesByFile(file) // moduleGraph 是 Vite 内存中的“依赖图”,记录着谁 import 了谁;现在modules代表所有依赖这个file文件的模块
updateModules(file, modules) // 触发更新
})

WebSocket 更新

Vite 服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function updateModules(file, modules) {
const updates = []
for (const mod of modules) {
updates.push({
type: 'update', // 更新类型
path: mod.url, // 如 /src/App.vue
timestamp: Date.now()
})
}

ws.send({ // 通过 WebSocket 广播
type: 'update',
updates
})
}

浏览器端的 vite/client 会监听这个消息

浏览器(客户端)接收

vite/client 注入的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'update') {
data.updates.forEach((update) => {
// 发起新的 HTTP 请求,带时间戳防止缓存
fetchUpdate(update.path, update.timestamp)
})
}
}

async function fetchUpdate(path, timestamp) {
// 请求新的模块内容
const response = await fetch(`${path}?t=${timestamp}`)
const code = await response.text()

// 创建一个模块对象
const mod = {
id: path,
code,
getters: [] // 用于 HMR accept
}

// 调用 hot.accept
const { hot } = import.meta
hot.accept(path, (newMod) => {
// 这里是真正的“热替换”发生的地方
console.log('模块已更新:', path)
})
}

热替换(局部更新)

这是通过框架(Vue/React)的渲染机制实现的

如果接触过框架源码就可以理解,就是框架内部根据DOM差异实现热替换的diff算法

所以,Vite 只负责送新模块,而局部更新的实现是源于框架渲染机制

总结

详情
pnpm dev 查找 package.jsonscripts 下的 dev 命令并执行
执行 vite 命令 调用 Vite 的二进制文件 node_modules/.bin/vite(引连到 Vite 的 CLI 模块 vite/src/node/cli.ts
确定 mode 按顺序查找和加载 .env,注入变量到 process.env,过滤变量到 import.meta.env
合并插件配置 合并 CLI指令 & vite.config.ts配置,通过configconfigResolved 自定义插件
初始化插件系统 按照 prenormalpost 排序插件(可通过 apply: 'serve'/'build' 指定模式 )
启动服务器 创建开发服务器(Koa/Connect 实例)
注册中间件 根据 vite.config.ts/plugin 的顺序,调用所有插件的 configureServer 钩子
浏览器请求文件 按需 transform(.vue/.ts → JS)
热更新 向浏览器推送新模块,并利用框架机制热替换DOM