REACT · Vol.VI · LESSON 40 · 性能优化与部署
Vite 构建原理(对比 Maven/Gradle)
浏览器不认识你写的那坨代码
上一篇结尾留了个问题:dist 里那些带 hash 的 chunk 是哪来的。回答之前,先回答更根本的——前端为什么需要「构建」。第 2 篇说过 JSX 只是语法糖;你交付的源码,浏览器一个字都不认识:
- JSX:浏览器只认 JS,
<li>{todo.title}</li>这种「HTML 混在 JS 里」的写法必须先转成函数调用; - TypeScript:类型标注要整体擦除,浏览器不认识
interface; - npm 依赖:几万个模块、多种格式(ESM / CommonJS 混杂),不可能原样发给浏览器。
type Todo = { id: number; title: string; done: boolean };
// 你写的源码(.tsx = TS + JSX,浏览器双不认识)
function TodoItem({ todo }: { todo: Todo }) {
return <li className={todo.done ? "done" : ""}>{todo.title}</li>;
}
// 构建产物(示意):类型擦除 + JSX 变函数调用
import { jsx } from "react/jsx-runtime";
function TodoItem({ todo }) {
return jsx("li", {
className: todo.done ? "done" : "",
children: todo.title,
});
}
Java 世界「源码不可运行」天经地义:javac 编译、打包,才轮到 JVM 执行。JS 反过来——「浏览器即下即跑」持续了二十多年,所以「前端需要构建链」至今被低估。前端构建链 ≈ javac(转译)+ 打包插件(bundle),只是产物是 JS 文件而非字节码。
dev 链路:快在「不打包」
理解 Vite 的钥匙是一句话:dev 和 build 是两条完全不同的链路。先看 dev——npm run dev 后,dev server 在 5173 端口干的事是「不打包,按需编译」:
- 浏览器请求 index.html,里面只有一行
<script type="module" src="/src/main.tsx">; - 浏览器接着请求 main.tsx,Vite 用 esbuild 把 TSX 转成 JS——只转译、不打包,import 语句原样保留;
- 浏览器看到 import,继续请求下一个模块……整棵模块树按需、逐个编译返回。
// 浏览器请求 /src/App.tsx 拿到的(示意):import 原样保留,
// 交给浏览器继续按需请求下一个模块
import { jsx } from "/node_modules/.vite/deps/react_jsx-runtime.js";
import TodoList from "/src/components/TodoList.tsx";
export default function App() {
return jsx(TodoList, {});
}
业务代码可以按需,node_modules 不行——上万个模块混着 CommonJS,浏览器发上千个请求会直接瘫痪。所以 Vite 启动时用 esbuild(Go 写的,快一个量级)把依赖预打包成少数几个 ESM 文件缓存到 node_modules/.vite,之后直接命中。对比 webpack dev server 就明白 Vite 为什么快:webpack「先打包再服务」,启动要把整个应用连同依赖 bundle 完才敢监听端口,十万行的项目启动等一分钟不稀奇;Vite「先服务,用到谁编译谁」,改一行只失效那一个模块——按需 vs 全量,全部秘密。
spring-boot-devtools 的卖点是「改代码免重启」,Vite 更进一步:只重编译那一个模块并热替换(HMR)。共同哲学:开发期别干发布期才需要干的事。
build 链路:前端的 mvn package
npm run build 切到第二条链路,主力换成 Rollup:解析依赖图 → tree-shaking 剪枝 → 按第 39 篇的配置切 chunk → 压缩混淆(默认也是 esbuild)→ 写进 dist/。把构建体系和 Maven/Gradle 对齐:
| Maven / Gradle | 前端对应物 | 说明 |
|---|---|---|
| pom.xml / build.gradle | package.json + vite.config.ts | 依赖声明在 package.json,构建逻辑在 vite.config.ts |
| 依赖解析 → ~/.m2/repository | npm install → node_modules | 本地依赖仓库;package-lock.json 锁版本(类比 BOM) |
| spring-boot-devtools 热部署 | dev server + HMR | 改代码不重启,热替换到浏览器 |
| mvn package | npm run build | 触发全量打包 |
| target/ | dist/ | 产物目录,都不进版本库 |
| maven-shade-plugin / assembly | Rollup | 把散装模块合成可部署的整包 |
| 多环境 profile | .env.[mode] 系列文件 | dev/test/prod 各一套变量,下一站细讲 |
表中有个容易误会的差异:Maven 的 .m2 全机共享一份 junit.jar;npm 默认每项目一份 node_modules——因为「不同项目锁不同版本」的诉求太强,pnpm 用全局存储 + 硬链接找回「共享」,思路反而和 Maven 一致。「前端分两条链路、Maven 不分」也是错觉:IDE 增量编译 vs mvn clean package,Java 只是把 dev 链路藏进了 IDE。
vite.config.ts:前端的 build.gradle
多数配置开箱即用,但有三处几乎每个真实项目都要动:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// @ 指向 src:import 不再写 ../../../,挪目录不再是梦魇
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
// dev 接口转发:/api 打到本地后端,绕开浏览器同源策略
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
// rewrite: (p) => p.replace(/^\/api/, ""), // 后端不带 /api 前缀时改写
},
},
},
build: {
rollupOptions: {
output: {
// 手动分包:把大而稳的第三方依赖固定进独立 chunk(呼应第 39 篇)
manualChunks: {
react: ["react", "react-dom", "react-router-dom"],
echarts: ["echarts"],
},
},
},
},
});
三处各有讲究:alias @ 让深层 import 从 ../../../components/Button 变成 @/components/Button,挪目录不再牵一发动全身;server.proxy 把 /api 请求在 dev server 内部转发给 8080——页面在 5173、后端在 8080,浏览器直接打 8080 就是跨域,而代理是服务器对服务器转发,不经过同源策略,CORS 不是被修复了,是压根没发生;manualChunks 把发布频率极低的第三方依赖单独成 chunk,业务代码天天变,依赖 chunk 的 hash 纹丝不动,缓存长期命中。
server.proxy 等价于开发版的 location /api { proxy_pass http://backend; } 或网关路由转发。上线后这层职责交还给 Nginx(第 42 篇);跨域问题十有八九,最终解法都是「让请求同源」,前后端一致。
环境变量与带 hash 的产物
最后两块拼图:环境变量和产物缓存。前端也有自己的 profile 机制:
# .env.development —— npm run dev 时生效(类比 application-dev.yml)
VITE_API_BASE=/api
VITE_USE_MOCK=true
# .env.production —— npm run build 时生效(类比 application-prod.yml)
VITE_API_BASE=https://api.example.com
VITE_USE_MOCK=false
// 只有 VITE_ 前缀的变量会被注入客户端代码 —— 一张安全白名单
const apiBase = import.meta.env.VITE_API_BASE;
export async function fetchTodos() {
// 第 32 篇的取数函数,现在 API 地址跟着环境走了
const res = await fetch(`${apiBase}/todos`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
关键点有三:import.meta.env 是构建期静态替换——打包时写死进产物,改 .env 要重启 dev server;多环境打包用 vite build --mode staging 读 .env.staging,等价于 spring.profiles.active=staging;安全红线是前端产物是公开的,用户右键就能看到所有注入变量——密钥绝不进 .env。
再看产物,build 完的 dist 长这样:
dist/
├── index.html # 入口:引用下列资源
└── assets/
├── react-9xQmT1cL.js # manualChunks 切出的依赖 chunk
└── Dashboard-Cv8dP4zN.js # 第 39 篇 lazy 切出的路由 chunk
hash 由文件内容算出:内容不变 → hash 不变 → URL 不变,缓存继续命中;改一行 → hash 变 → URL 变,旧缓存自然作废(cache busting)。于是 Nginx 敢对 assets 配一年强缓存,而 index.html 只能短缓存——它是引用所有资源的「活地图」,配错了,用户拿着旧 HTML 去找已删除的旧 chunk,直接白屏。本地验证产物用 vite preview,别拿 dev server 冒充生产。
| 文件 | 缓存策略 | 原因 |
|---|---|---|
| index.html | 不缓存,或 max-age=60 | 引用所有带 hash 资源的「活地图」,必须最新 |
| /assets/*(带 hash) | max-age=31536000, immutable | 内容变即换名,可放心缓存一年 |
Maven 仓库里同一个 GAV 坐标永远对应同一个 artifact,想改内容就发新版本号——没人覆盖已发布的 1.2.0。前端把这套纪律自动化了:「版本号」就是内容 hash,发布即产生新坐标。CDN 这类分布式缓存敢放开手脚,前提正是坐标不可变。
- 浏览器不认识 TSX:JSX 转函数调用、类型擦除、依赖打包,决定了前端必须有构建链——同 .java 必须 javac。
- dev 与 build 是两条链路:dev 用原生 ESM 按需转译 + esbuild 预构建依赖,快在「不打包」;build 用 Rollup 全量打包、剪枝、分包、压缩。
- 概念对齐 Maven:package.json + vite.config.ts ≈ pom.xml,dev server + HMR ≈ devtools 热部署,vite build ≈ mvn package,dist ≈ target。
- server.proxy 是开发期反代(Nginx/网关的前端版),alias 解放相对路径,manualChunks 固定依赖 chunk 呼应第 39 篇。
- import.meta.env 构建期静态替换、VITE_ 前缀白名单(密钥绝不进前端);内容 hash + index.html 短缓存 = cache busting。
本章回顾
到这一篇,前端产物的来龙去脉你完全脱敏了:源码 → dev/build 两条链路 → 带 hash 的 dist。但「构建产物小」不等于「用户真的用得快」——性能不能靠感觉,得靠测量。下一篇第 41 篇请出 React Profiler 和 Lighthouse,像看 APM 一样把性能变成看得见的数字。
Comments · 评论