02 TypeScript 工程的生产构建

1. 当前工程的特点

当前 tsconfig.json 采用:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "esnext",
    "esModuleInterop": true,
    "strict": true
  }
}

target: "esnext" 会尽量保留较新的 JavaScript 语法。生产构建时必须确保它与服务器实际 Node.js 版本兼容;固定生产 Node 版本后,应根据项目实际语法和 API 选择并验证 target,而不是默认所有服务器都支持最新输出。

开发方式主要是:

ts-node server.ts

但当前配置没有启用 rootDiroutDirpackage.json 也没有独立的 build 脚本。教程给出的是推荐目标,不会直接修改当前工程。

2. “编译”和“打包”不是一回事

tsc 编译

server.ts       → dist/server.js
routes/user.ts  → dist/routes/user.js

通常仍然保留多个 JavaScript 文件,也不会把 Express、Sequelize、mysql2 合并进去。

esbuild/webpack 打包

可能把多个源码文件甚至部分依赖合并成较少文件,但会增加配置和原生依赖处理问题。当前工程包含 argon2 等原生模块,新人阶段没有必要先走打包路线。

本教程选择:

tsc 编译 + 生产服务器安装 dependencies

3. 推荐增加独立构建配置

可以保留用于开发和类型检查的 tsconfig.json,再创建 tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "./dist",
    "noEmit": false,
    "declaration": false,
    "declarationMap": false,
    "sourceMap": true
  },
  "include": [
    "server.ts",
    "routes/**/*.ts",
    "controllers/**/*.ts",
    "services/**/*.ts",
    "models/**/*.ts",
    "middlewares/**/*.ts",
    "types/**/*.d.ts"
  ],
  "exclude": [
    "dist",
    "node_modules",
    "docs",
    "exercises"
  ]
}

实际 include 应与项目源码目录一致。当前工程的 TypeScript 文件分布较散,后期可以再学习迁移到 src/,首次上线不必同时进行大规模目录重构。

4. 推荐的 npm scripts

可以将开发、类型检查、构建和生产启动分开:

{
  "scripts": {
    "dev": "nodemon --exec ts-node server.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/server.js"
  }
}

它们分别表示:

npm run dev       开发环境运行 TypeScript
npm run typecheck 只检查类型,不生成文件
npm run build     生成生产 JavaScript
npm start         运行已经生成的 JavaScript

当前项目的 start 使用 nodemon --exec ts-node,更像开发命令。正式调整时需要同步确认 nodemonts-node 是否已声明为开发依赖,不能依赖服务器“碰巧全局安装”。

5. 构建后为什么仍要安装依赖

编译后的文件仍可能包含:

const express_1 = require("express");
const sequelize_1 = require("sequelize");

Node.js 运行时仍要从 node_modules 找到它们。因此只上传 dist/ 通常不够。

生产服务器可以安装运行依赖:

npm ci --omit=dev

它会根据 package-lock.json 安装 dependencies,跳过 devDependencies

6. 在服务器构建时的依赖顺序

typescript 位于 devDependencies,所以如果服务器负责执行 npm run build,需要先安装开发依赖:

npm ci
npm run typecheck
npm run build
npm prune --omit=dev

含义:

npm ci             安装锁文件中的完整依赖
npm run build      使用 TypeScript 编译器
npm prune --omit=dev 删除生产运行不需要的开发依赖

更成熟的流程可以在 CI 构建,再把构建产物和生产依赖部署到服务器,但新人第一次上线先理解上述流程即可。

7. 原生依赖不能随意跨平台复制

当前项目包含 argon2。这类包可能带有平台相关二进制文件。

不要简单地把 Windows 上的整个 node_modules 复制到 Linux:

Windows node_modules
  ≠ 可直接在 Linux 使用的 node_modules

应在目标 Linux 或与目标环境一致的构建环境中执行:

npm ci --omit=dev

Node 主版本也应与构建/运行预期一致。

8. PM2 可以直接运行 TypeScript 吗

技术上可以,例如:

pm2 start server.ts --interpreter ts-node

但它要求生产环境安装 ts-nodetypescript,并让编译配置问题发生在服务启动阶段。首次生产部署更推荐:

npm run build
pm2 start dist/server.js --name nloop-api

直接运行 TypeScript 更适合学习、开发服务器或内部低风险工具,不是本教程的默认生产方案。

9. .env 和构建产物

.env 不会因为运行 tsc 自动进入 dist。通常由运行时从工作目录加载:

import "dotenv/config";

因此 PM2 的 cwd 很重要:

cwd: "/var/www/nloop/current"

生产 .env

10. 上线前先脱离 PM2 验证

先运行:

npm run build
NODE_ENV=production node dist/server.js

再从另一个终端测试:

curl -i http://127.0.0.1:8080/health

如果这里失败,问题属于构建、环境变量或应用本身,不要先怀疑 PM2 和 Nginx。

复盘题

  1. tsc 为什么通常不等于打包?
  2. 只上传 dist 为什么可能出现 Cannot find module 'express'
  3. 为什么不建议复制 Windows 的 node_modules 到 Linux?
  4. 服务器上执行构建时,为什么不能一开始就只安装生产依赖?
  5. 为什么建议先用 node dist/server.js 验证,再交给 PM2?

官方参考