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 的日子该到头了。

延伸阅读