2.2 构建脚本:npm scripts 与打包
上节定好了目录,但构建这件事还是手动:改完代码,打开打包工具点 Build,再把 dist 里的旧文件删掉、新文件拖上传工具。创业页第一周我重复了二十多次,每次都怀疑人生。本节的目标是把这套流水线写成 npm scripts:一行命令、处处一致、任何一台干净机器都能跑。这也是第五篇 CI/CD 自动部署 能跑起来的前置条件。
2.2.1 为什么先用 npm scripts
团队只有两个人,引入 Jenkins 太重,GitHub Actions 又还不到接的时候。npm scripts 的好处是零额外依赖、随 package.json 天然版本化、新人 clone 下来看一眼 scripts 字段就知道项目怎么跑。我们先满足三件事:
- 清得干净:每次构建前清空 dist;
- 编译到位:TS 转 JS,前端资源打包压缩;
- 可组合:命令之间能串行拼装。
2.2.2 脚本清单
{
"scripts": {
"clean": "node scripts/clean.ts",
"copy:public": "node scripts/copy-public.ts",
"bundle": "vite build",
"images": "node scripts/images.ts",
"verify": "npm run clean && npm run bundle && npm run images",
"build": "npm run verify && npm run copy:public",
"release": "npm version patch && npm run build"
}
}
每条脚本的职责与实测耗时:
| 命令 | 作用 | 产物 | 耗时参考 |
|---|---|---|---|
| clean | 清空 dist 目录 | 空目录 | 0.2 秒 |
| copy:public | 拷贝 public 原样资源 | dist/favicon.ico 等 | 0.5 秒 |
| bundle | Vite 前端打包压缩 | dist/assets/* | 8 秒 |
| images | 图片压缩转 WebP | dist/assets/*.webp | 5 秒 |
| build | 上述串行全跑 | 可部署的 dist | 约 15 秒 |
构建顺序有讲究:clean 打头避免旧产物残留;bundle 居中产出主体资源;images 压的是构建引用到的图,放在 bundle 之后;copy:public 收尾,防止被中间步骤污染。images 脚本本身在 2.4 图片处理 展开。
2.2.3 clean 脚本:rm 的安全写法
手动 rm -rf dist 总怕路径写错删错盘。脚本里用一段保守且幂等的逻辑:
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
const dist = resolve(process.cwd(), "dist");
await rm(dist, { recursive: true, force: true });
console.log("已清空", dist);
recursive 加 force 的组合连目录不存在都不报错,重复执行结果一致。基于 process.cwd() 而不是脚本文件位置解析路径,使得在任意子目录执行 npm run clean 都安全。所有脚本统一用 node 直接跑 TS,Node 22 内置类型剥离,团队不需要额外装 ts-node。
2.2.4 环境变量:cross-env 与 dotenv
Windows 下设置环境变量是 set 写法,Mac 和 Linux 是 KEY=VAL 前缀,脚本里直接写一定有人踩。引入 cross-env 一劳永逸:
"build": "cross-env NODE_ENV=production vite build"
读取侧用 dotenv 从 .env 注入:
import "dotenv/config";
const apiBase = process.env.API_BASE ?? "https://default.example.com";
注意 dotenv 只在 Node 脚本侧加载,前端代码里的变量必须经 Vite 的 import.meta.env 打包注入,两条链路别混。部署到 网站托管 后,环境变量在控制台的项目设置里配,不落盘、不入库。
2.2.5 打包:Vite 改两个参数就够
创业页这个规模,vite build 开箱即用,唯一要改的是 base 与 outDir:
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
build: { outDir: "dist", target: "es2020", sourcemap: false },
});
相对路径让产物部署到 网站托管的任意子路径 都能直接打开,省掉后面 5.1 做 CI 时不少麻烦。sourcemap 线上关掉,包体小一半,真要排障时临时开一次即可。
2.2.6 本地预览:一个能刷新的静态服务器
构建产物要在部署前本地验证一遍。不装任何依赖,用 Node 内置能力写一个支持无缓存刷新的预览服务器:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { join, extname } from "node:path";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css",
".js": "text/javascript",
".webp": "image/webp",
};
createServer(async (req, res) => {
const url = req.url === "/" ? "/index.html" : req.url;
const file = join(process.cwd(), "dist", decodeURIComponent(url));
try {
const buf = await readFile(file);
res.setHeader("Content-Type", MIME[extname(file)] ?? "application/octet-stream");
res.setHeader("Cache-Control", "no-store");
res.end(buf);
} catch {
res.statusCode = 404;
res.end("not found");
}
}).listen(4173, () => console.log("预览: http://localhost:4173"));
Cache-Control 设为 no-store 很关键:预览时浏览器永远拿最新文件,否则改了图刷不出来,还以为是构建脚本的 bug,白排查半小时。这条经验同样适用于第三篇接 托管与 CDN 时的缓存策略思考。
2.2.7 踩坑记录
坑一:平台差异。早期脚本里用 && 串联多条命令,Mac 上正常,个别 Windows 环境下行为不稳。后来关键链路统一交给 npm-run-all 的串行模式,跨平台省心;再后来 Node 版本统一到 22,npm 内部统一走 sh 执行,&& 才稳定下来。
坑二:版本号手改漏改。每次发布手改 version 容易漏改,npm version patch 会自动改版本号并打 git tag,CI 监测 tag 触发部署,链路完整且留痕。
坑三:CI 装依赖慢。CI 上 npm install 常常解析新版本,耗时 95 秒;换成 npm ci 后降到 40 秒,因为它严格按 lockfile 安装、不做版本解析。前提是 lockfile 必须提交,这点在 2.1 已经约定。
小结
构建脚本的本质是把重复操作固化成一行命令:clean 打头、bundle 居中、images 与 copy 收尾,cross-env 抹平平台差异。当 npm run build 能在任何一台干净机器上 15 秒跑完,第三篇把 dist 推上云才有底气。下一节 2.3 模板与框架 聊多页内容怎么做——手工复制 header 的日子该到头了。
延伸阅读
- Node.js 网站托管实战:全书目录与章节索引。
- AI 工具链实战:用 AI 辅助生成构建脚本模板的思路。
