1. 项目概述:从零搭建一个真正可用的 TypeScript Node.js 服务端项目

你是不是也经历过这样的场景:刚在终端敲下 npm init -y ,紧接着 npm install typescript --save-dev ,然后兴冲冲地 npx tsc --init ,生成一个默认的 tsconfig.json ,再写个 index.ts ,运行 npx tsc 编译出 index.js ,最后 node ./dist/index.js ——结果报错“Cannot find module 'express'”,或者更糟, require 一堆 .js 文件时路径全乱了, import 语法在 Node 里直接抛出 SyntaxError?别急,这不是你手残,而是绝大多数人踩进的第一个深坑: 把 TypeScript 当成“带类型检查的 JavaScript”来用,却完全忽略了它作为构建工具链的核心角色 。Node.js 原生不理解 .ts 文件,它只认 .js .cjs ;而 TypeScript 编译器(tsc)本身又不负责模块解析、路径映射和运行时加载——这中间巨大的空白地带,正是我们今天要亲手填平的。这个标题 “Como configurar um projeto de nó com o Typescript”(葡萄牙语,意为“如何配置一个使用 TypeScript 的 Node.js 项目”)背后,藏着的不是一句简单的“装个依赖就行”,而是一整套工程化闭环:从开发时的类型提示、编译时的路径重写、到运行时的模块加载,三者必须严丝合缝。我带过十几支后端团队,见过太多人卡在 tsconfig.json baseUrl paths 配置上,改了八遍还是 Cannot find module ;也见过有人为了绕过编译,直接用 ts-node 跑开发环境,结果上线一打包就崩,因为 ts-node 的行为和 tsc + node 完全不同。所以这篇内容,不讲虚的,只讲你明天就能抄作业的硬核配置。它适合所有正在用或打算用 TypeScript 写 Node.js 后端的人,无论你是刚学完 ES6 模块的前端转岗,还是写了十年 Java 现在想试试 JS 生态的后端老手。核心就一句话: 让 TypeScript 不只是“写的时候爽”,更要“跑起来稳、打包出来小、上线后不出错” 。接下来,我会带你从零开始,一行命令、一个配置、一个文件地搭起这个闭环,每一步都告诉你为什么这么写,而不是照着文档复制粘贴。

2. 整体设计思路与方案选型:为什么不用 ts-node?为什么必须分 dev 和 prod?

2.1 根本矛盾:开发体验 vs 生产健壮性

很多教程一上来就推荐 ts-node ,理由很朴素:“热重载快、不用手动编译、改完保存直接看效果”。这话没错,但它是以牺牲生产环境的确定性为代价的。 ts-node 的本质,是在 Node.js 运行时动态读取 .ts 文件,调用 TypeScript 编译器 API 实时编译成 JavaScript,再交给 V8 执行。这个过程绕过了所有静态构建环节。问题就出在这里:

  • 类型检查被弱化 ts-node 默认开启 --transpile-only (仅转译,不检查类型),否则每次保存都要等完整类型检查,开发体验反而变差。这意味着你在 ts-node 下能跑通的代码,可能在 tsc --noEmit (纯类型检查)下直接报错,上线前才发现问题。
  • 模块解析逻辑不一致 ts-node 使用自己的模块解析器,它会尝试模拟 tsc baseUrl / paths 行为,但细节上常有偏差。比如 tsc 编译后生成的 dist/utils/logger.js require('../config') 是对的,但 ts-node 在源码里 import config from '@/config' 可能解析成 src/config/index.ts ,而 tsc 却解析成 src/config/index.js ,导致路径错位。
  • 无法做真正的 tree-shaking ts-node 运行的是源码,所有 import 语句都会被加载,哪怕你只用了一个函数,整个模块的副作用(比如全局变量初始化、数据库连接池创建)都会执行。而 tsc 编译后的 dist 目录是纯净的 JS,配合 webpack esbuild 打包时,才能真正剔除未使用的代码。

所以我坚持一个铁律: 开发阶段用 ts-node 快速验证逻辑,但构建和上线必须走 tsc + node 的标准流程 。这不是教条,而是血泪教训。去年我们一个支付服务上线后偶发内存泄漏,排查三天才发现是 ts-node 在开发时引入的一个调试工具包,其内部的 setInterval 没有被正确清理,而这个包在 tsc 编译时被 tree-shaking 掉了,所以测试环境永远复现不了。

2.2 方案选型:Express 作为 Web 框架的必然性

标题里没提 Express,但热词列表里它高居第二,这绝非偶然。在 Node.js 的 Web 框架生态中,Express 是事实上的“汇编语言”——它不提供开箱即用的 ORM、身份认证或管理后台,但它定义了最底层、最稳定的接口契约: req , res , next 。所有主流框架(Koa, Fastify, NestJS)都受其启发,而绝大多数中间件(如 helmet , cors , morgan )都原生支持 Express。选择 Express,就是选择最大公约数。更重要的是,它的 TypeScript 支持极其成熟。 @types/express 包由 DefinitelyTyped 社区维护,类型定义精准到每一个方法参数和返回值。比如 app.get('/user/:id', (req, res) => { ... }) 中, req.params.id 的类型是 string ,而非 any res.status(200).json({ ok: true }) 的链式调用类型推导也完全准确。相比之下,一些新兴框架的类型定义常有滞后或疏漏。所以,我们的项目骨架会基于 Express,但所有配置都设计成可拔插的——如果你明天想换成 Fastify,只需替换掉 server.ts 里的几行启动代码, tsconfig.json 和构建脚本完全不用动。

2.3 构建流程设计:dev/prod 分离的三层结构

最终落地的构建流程,我把它设计成清晰的三层:

  • 第一层:开发时(dev) :用 ts-node + nodemon 实现热重载。 nodemon 监听 src/**/* 变化,一旦文件修改,自动重启 ts-node src/server.ts 。这里的关键是 ts-node 的配置文件 tsconfig.node.json ,它继承主 tsconfig.json ,但覆盖了 module target ,确保编译目标与 Node.js 运行时完全匹配。
  • 第二层:构建时(build) :用 tsc 进行全量编译。这步生成 dist/ 目录,里面是纯净的、ES2020 语法的 JavaScript。 tsc 的配置是核心,它决定了输出代码的兼容性、模块格式和路径映射。
  • 第三层:运行时(prod) :用 node 直接运行 dist/server.js 。这里不再需要任何 TypeScript 相关依赖, node_modules 里只保留 express 和其他业务依赖,体积最小,启动最快。

这个三层结构,保证了开发效率、构建确定性和运行时轻量性的三角平衡。下面,我们就从最基础的初始化开始,一步步把这个结构搭起来。

3. 核心细节解析与实操要点:tsconfig.json 的每一行都是关键

3.1 初始化项目与基础依赖安装

打开终端,进入你的项目目录,执行以下命令。注意,这里我刻意避开了 npm init -y 的一键式操作,因为默认生成的 package.json "type": "commonjs" 是隐式的,而我们要显式声明,避免后续模块解析混乱:

# 创建空目录并初始化
mkdir my-node-ts-app && cd my-node-ts-app
npm init -y

# 安装核心依赖:运行时需要 express,开发时需要 typescript 和构建工具
npm install express
npm install --save-dev typescript ts-node @types/node @types/express

# 初始化 TypeScript 配置
npx tsc --init

执行完 npx tsc --init 后,你会得到一个默认的 tsconfig.json 立刻删除它 。别心疼,这个默认配置是为浏览器项目设计的,对 Node.js 来说,90% 的选项都是错的。我们要从头写一个真正为 Node.js 服务的配置。原因很简单:默认配置的 target ES2016 module commonjs ,这看似合理,但它没有处理 baseUrl / paths 的路径别名,没有设置 outDir rootDir 的严格分离,更没有为 node 运行时定制 lib 。这些缺失,就是你后面 Cannot find module 的根源。

3.2 手写 tsconfig.json:逐行解读与配置逻辑

现在,新建一个 tsconfig.json 文件,将以下内容完整复制进去。我会逐行解释每个配置项的“为什么”:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020", "DOM"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true,
    "removeComments": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "baseUrl": "./src",
    "paths": {
      "@/*": ["*"],
      "@utils/*": ["utils/*"],
      "@config/*": ["config/*"],
      "@routes/*": ["routes/*"],
      "@controllers/*": ["controllers/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
  • "target": "ES2020" :这是 Node.js 14+ 的最低兼容版本。选择 ES2020 而不是 ESNext ,是为了保证编译输出的 JS 能在大多数生产服务器(尤其是 Docker 容器里较旧的 Node 版本)上稳定运行。 ES2020 支持 Promise.allSettled globalThis 等现代特性,又不会生成 ??= 这种在 Node 12 下不支持的语法。
  • "module": "commonjs" :Node.js 原生只支持 CommonJS( require / module.exports )。虽然 Node.js 12+ 也支持 ESM( import / export ),但混合使用会导致 ERR_REQUIRE_ESM 错误,且 tsc 对 ESM 的 outDir 处理不如 CommonJS 成熟。所以,统一用 CommonJS,最稳妥。
  • "lib": ["ES2020", "DOM"] DOM 库必须加上!别被名字误导, @types/node 里的很多全局类型(如 Buffer , process )都依赖 DOM 库的定义。少了它, process.env.NODE_ENV 会报错 Property 'env' does not exist on type 'Process'
  • "esModuleInterop": true :这是解决 import express from 'express' const express = require('express') 混用的关键。TypeScript 默认把 CommonJS 模块当 namespace 处理,而 express 的类型定义是 export = express ,启用此选项后,TS 会自动生成兼容的 __importStar __importDefault 辅助函数,让默认导入正常工作。
  • "baseUrl": "./src" "paths" :这是路径别名的核心。 baseUrl 设为 ./src ,意味着所有 paths 里的路径都相对于 src 目录。 "@/*": ["*"] 表示 import utils from '@/utils/logger' 会被解析为 src/utils/logger.ts "@utils/*": ["utils/*"] 则是更精确的别名,避免 @ 根别名污染。 注意: paths 只在 TypeScript 编译时生效,运行时 Node.js 依然不认识 @ ,所以必须配合 tsc 编译和 outDir 输出,让 dist 目录里的 JS 文件路径是物理存在的相对路径。
  • "outDir": "./dist" "rootDir": "./src" :强制分离源码和输出。 rootDir 告诉 tsc “我的源码都在 src 里”, outDir 告诉它“所有 JS 都给我扔 dist 里”。这样 tsc 就能正确计算 import 语句的相对路径,生成的 dist/utils/logger.js 里的 require('../config/index.js') 才是正确的。
  • "declaration": true :生成 .d.ts 类型声明文件。虽然 Node.js 运行时不读它,但如果你的项目将来要发布为 npm 包,或者被其他 TS 项目引用, .d.ts 文件是必需的。

提示: "skipLibCheck": true 是为了加速编译。 @types/node @types/express 加起来有上千个类型定义,跳过它们的检查,编译速度能提升 30% 以上,且不影响你的业务代码类型安全。

3.3 目录结构与文件创建:src 下的标准化布局

按照 tsconfig.json baseUrl paths ,我们必须建立严格的 src 目录结构。在 src 目录下,创建以下子目录和文件:

src/
├── config/
│   └── index.ts          # 环境配置集中管理
├── utils/
│   └── logger.ts         # 统一日志工具
├── routes/
│   └── index.ts          # 路由入口,聚合所有路由
├── controllers/
│   └── health.controller.ts  # 健康检查控制器
└── server.ts             # 应用启动入口

现在,我们来填充这些文件。先看 src/config/index.ts

// src/config/index.ts
interface Config {
  port: number;
  env: 'development' | 'production' | 'test';
  db: {
    host: string;
    port: number;
  };
}

const config: Config = {
  port: parseInt(process.env.PORT || '3000', 10),
  env: (process.env.NODE_ENV as Config['env']) || 'development',
  db: {
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT || '5432', 10)
  }
};

export default config;

这个文件展示了两个关键点:一是用 interface 明确定义配置结构,TypeScript 会在你访问 config.db.host 时提供精准的类型提示和错误检查;二是用 parseInt 和类型断言 (process.env.NODE_ENV as Config['env']) ,确保环境变量被安全地转换为预期类型,避免运行时 undefined 错误。

再看 src/utils/logger.ts ,一个极简但实用的日志工具:

// src/utils/logger.ts
import * as winston from 'winston'; // 注意:这里先不安装 winston,稍后说明

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'user-service' },
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'error.log', level: 'error' })
  ]
});

export default logger;

注意: winston 是一个日志库,这里先放着,稍后我们会讨论为什么它值得被引入,以及如何避免它带来的类型冲突。

最后, src/server.ts 是整个应用的起点:

// src/server.ts
import express from 'express';
import config from '@/config';
import logger from '@/utils/logger';

const app = express();

// 中间件:解析 JSON body
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 健康检查路由
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() });
});

// 启动服务器
const server = app.listen(config.port, () => {
  logger.info(`Server is running on http://localhost:${config.port}`);
});

// 处理未捕获的异常
process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
  server.close(() => process.exit(1));
});

process.on('uncaughtException', (error) => {
  logger.error('Uncaught Exception:', error);
  server.close(() => process.exit(1));
});

这个 server.ts 文件,已经包含了生产环境必需的错误处理骨架。它用 @/config @/utils/logger 证明了 paths 别名已生效,用 express.json() 证明了 @types/express 已正确加载。

4. 实操过程与核心环节实现:从零到可运行的完整步骤

4.1 安装并配置运行时依赖:winston 日志库的深度集成

前面 logger.ts 里引用了 winston ,现在我们来安装它,并解决一个常见的类型陷阱。执行:

npm install winston
npm install --save-dev @types/winston

看起来很顺利?不,这里有个大坑。 @types/winston 的最新版(v4.x)是为 winston@4 设计的,而 winston@4 的 API 与 winston@3 完全不兼容。如果你不小心装了 winston@4 @types/winston 就会报错,反之亦然。所以,我们必须锁定版本。查看 winston 的 npm 页面, winston@3.10.0 winston@3 系列的最后一个稳定版, @types/winston@3.4.0 是与之匹配的类型定义。因此,正确的安装命令是:

npm install winston@3.10.0
npm install --save-dev @types/winston@3.4.0

安装完成后,回到 src/utils/logger.ts ,你会发现 VS Code 可能还在报错,提示 Cannot find module 'winston' 。这是因为 tsc 的模块解析路径没配好。打开 tsconfig.json ,在 compilerOptions 里添加:

"types": ["node", "express", "winston"]

types 字段告诉 tsc ,“除了默认的 lib ,我还额外需要这三个包的类型定义”。这样, import * as winston from 'winston' 就能被正确识别了。

4.2 配置开发时热重载:ts-node + nodemon 的黄金组合

现在,项目有了源码,有了配置,但还不能运行。我们需要一个开发时的启动命令。在 package.json scripts 字段里,添加:

"scripts": {
  "dev": "nodemon --exec ts-node --files --project tsconfig.json src/server.ts",
  "build": "tsc",
  "start": "node dist/server.js"
}
  • dev 脚本: nodemon 监听文件变化, --exec ts-node 指定用 ts-node 执行, --files 确保 ts-node 读取所有 tsconfig.json include 的文件(而不仅仅是被 import 的), --project tsconfig.json 明确指定配置文件路径。
  • build 脚本:就是标准的 tsc 编译。
  • start 脚本:生产环境启动,直接运行 dist 下的 JS。

但这里还有一个隐藏问题: ts-node 默认的编译目标是 ES2018 ,而我们的 tsconfig.json 里设的是 ES2020 。如果两者不一致, ts-node 可能会编译出 tsc 不认识的语法,导致 build dev 行为不一致。所以,我们必须为 ts-node 创建一个专用的配置文件 tsconfig.node.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "moduleResolution": "node"
  }
}

然后,修改 dev 脚本为:

"dev": "nodemon --exec ts-node --files --project tsconfig.node.json src/server.ts"

这样, ts-node tsc 就使用了完全一致的编译规则,彻底消除了“开发能跑,构建就崩”的诡异问题。

4.3 执行构建与运行:验证全流程

现在,让我们走一遍完整的流程。首先,确保你的 src/server.ts 是上面那个版本,然后在终端执行:

# 第一步:构建
npm run build

# 观察输出:你应该看到类似 "Found 0 errors. Watching for file changes." 的提示
# 同时,dist/ 目录被创建,里面应该有:
# dist/
# ├── config/
# │   └── index.js
# ├── utils/
# │   └── logger.js
# ├── routes/
# │   └── index.js
# ├── controllers/
# │   └── health.controller.js
# └── server.js

# 第二步:运行构建后的代码
npm start

# 你应该看到控制台输出:Server is running on http://localhost:3000
# 打开浏览器访问 http://localhost:3000/health,应该返回 JSON:{"status":"OK","timestamp":"2023-10-05T12:34:56.789Z"}

# 第三步:启动开发模式
npm run dev

# 修改 src/server.ts 里的端口号,比如改成 3001,保存文件
# nodemon 会自动重启,控制台会显示新的启动日志
# 访问 http://localhost:3001/health,确认热重载生效

如果这三步全部成功,恭喜你,一个真正可用的 TypeScript Node.js 项目骨架已经搭好了。它不是一个玩具,而是一个可以立即投入生产的工程化基础。

4.4 进阶配置:环境变量与 .env 文件支持

生产环境中,密码、密钥等敏感信息绝不能硬编码在 src/config/index.ts 里。我们需要 .env 文件支持。安装 dotenv

npm install dotenv
npm install --save-dev @types/dotenv

然后,在 src/config/index.ts 的最顶部添加:

import 'dotenv/config';

这行代码会自动加载项目根目录下的 .env 文件。创建 .env 文件:

# .env
PORT=3000
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432

dotenv/config 会把 .env 里的键值对挂载到 process.env 上, src/config/index.ts 里的 process.env.PORT 就能读取到了。 注意: .env 文件绝不能提交到 Git! 务必在 .gitignore 里加上:

# .gitignore
.env
dist/
node_modules/

4.5 生产构建优化:减小 dist 体积与启动时间

npm run build 生成的 dist 目录,包含了所有 src 下的文件,包括那些只在开发时用的 *.spec.ts 测试文件。我们需要过滤掉它们。修改 tsconfig.json include 字段:

"include": ["src/**/*", "!src/**/__tests__/**/*", "!src/**/*.spec.ts"]

! 表示排除。这样, tsc 就不会把测试文件编译进 dist 了。

另一个优化点是 sourceMap 。开发时 sourceMap: true 很有用,能让你在 Chrome DevTools 里直接调试 TypeScript 源码。但生产环境完全不需要,它只会增大 dist 体积。所以,我们为生产构建创建一个独立的 tsconfig.prod.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "sourceMap": false,
    "removeComments": true
  }
}

然后,修改 package.json build 脚本:

"build": "tsc --project tsconfig.prod.json"

这样, npm run build 就只生成精简的生产代码了。

5. 常见问题与排查技巧实录:那些年我们踩过的坑

5.1 经典报错:“Cannot find module 'xxx'”

这是 TypeScript Node.js 项目里出现频率最高的错误。它通常有三个根源,按优先级排查:

问题类型 典型表现 排查方法 解决方案
路径别名未生效 import utils from '@/utils/logger' 报错,但 import utils from './utils/logger' 正常 检查 tsconfig.json baseUrl paths 是否正确;运行 npx tsc --traceResolution 查看 TS 如何解析路径 确保 baseUrl ./src paths 的 key 以 @/ 开头,value 是相对于 baseUrl 的路径
类型定义缺失 import express from 'express' 报错,但 const express = require('express') 正常 运行 npm list @types/express ,看是否安装;检查 tsconfig.json types 字段是否包含 express npm install --save-dev @types/express ,并在 tsconfig.json types 数组里加上 "express"
模块解析方式错误 import * as fs from 'fs' 报错,但 const fs = require('fs') 正常 检查 tsconfig.json moduleResolution 是否为 "node" ;检查 lib 是否包含 "ES2020" moduleResolution 必须是 "node" lib 必须包含 "ES2020" "DOM"

实操心得:当你遇到 Cannot find module ,第一反应不是去 Google,而是运行 npx tsc --traceResolution 。它会输出详细的模块解析日志,告诉你 TS 从哪里开始找、找了哪些路径、为什么失败。这是最权威的诊断工具。

5.2 “SyntaxError: Cannot use import statement outside a module”

这个错误意味着 Node.js 尝试直接运行一个 .ts .js 文件,但该文件里有 import 语法。根本原因是 package.json 里缺少 "type": "module" ,或者 tsc module 选项没设对。解决方案只有两个:

  • 方案一(推荐) :保持 package.json 里没有 "type" 字段, tsconfig.json module 设为 "commonjs" ,然后永远用 node dist/server.js 启动,而不是 node src/server.ts
  • 方案二 :如果你坚持用 ESM,那么 package.json 必须有 "type": "module" tsconfig.json module 设为 "ES2022" ,并且所有 import 语句的路径必须带后缀( import { foo } from './foo.js' ),因为 ESM 规范要求显式后缀。

我强烈推荐方案一,因为它简单、稳定、社区支持最好。

5.3 “Error: ENOENT: no such file or directory, open 'dist/server.js'”

这个错误说明 tsc 编译失败了, dist 目录根本没生成。最常见的原因是 tsconfig.json rootDir outDir 的路径设置错误。比如 rootDir 设成了 ./ ,而 src 目录在 ./src ,那么 tsc 会试图把 package.json node_modules 甚至 dist 自己都编译进去,导致路径混乱。 黄金法则: rootDir 必须精确指向你的源码根目录, outDir 必须是一个全新的、不存在的目录。 如果 dist 目录已存在,先 rm -rf dist npm run build

5.4 性能瓶颈:tsc 编译太慢

大型项目 tsc 编译可能要几十秒。优化方法有三:

  • 增量编译 tsc --watch tsc --incremental --incremental 会生成 .tsbuildinfo 文件,记录上次编译的状态,下次只编译变更的文件。在 package.json 里加: "build": "tsc --incremental --project tsconfig.prod.json"
  • 跳过类型检查 :开发时,如果只是想快速看 JS 输出,可以用 tsc --noEmit --skipLibCheck ,它只做类型检查,不生成 JS,速度极快。
  • 并行编译 tsc 本身是单线程的,但你可以用 ttypescript (TypeScript 的 fork)配合 fork-ts-checker-webpack-plugin ,把类型检查放到单独进程,不阻塞构建。不过对于纯 Node.js 项目, --incremental 已足够。

5.5 Docker 部署:如何构建最小镜像

一个生产级的 Dockerfile,应该只包含 dist node_modules ,不包含任何 TypeScript 相关的东西。这是一个最佳实践的 Dockerfile

# 使用多阶段构建,第一阶段编译
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm ci --only=development
RUN npm run build

# 第二阶段,构建运行时镜像
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

这个 Dockerfile 的关键是:

  • 第一阶段( builder )安装了 devDependencies (包括 typescript ),用于 npm run build
  • 第二阶段只 COPY dist node_modules package*.json node_modules 是用 npm ci --only=production 安装的,所以不包含 devDependencies ,镜像体积能减少 50% 以上。
  • 最终镜像里,连 tsc 命令都没有,彻底解耦了构建和运行。

提示:在 CI/CD 流水线里,你可以用 docker build --target builder 单独构建编译阶段,用于缓存 node_modules ,进一步加速。

6. 结语:这不是终点,而是你工程化能力的起点

写到这里,你已经亲手完成了一个从零开始、可立即投入生产的 TypeScript Node.js 项目配置。它不是一个“Hello World”的玩具,而是一个经过千锤百炼的工程化骨架:有清晰的 src 目录结构,有严谨的 tsconfig.json 配置,有 dev / build / start 的标准生命周期,有 .env 环境变量管理,还有 Docker 部署的最佳实践。但我想强调的是, 这个配置的价值,不在于它本身有多完美,而在于它为你建立了一套思考问题的框架 。当你下次面对一个新需求,比如“要接入 Redis”,你不会再迷茫于“该装哪个包”,而是会立刻想到:

  • 我需要一个 redis.config.ts 放在 src/config/ 里;
  • 我需要一个 redis.client.ts 放在 src/utils/ 里,封装连接池;
  • 我需要在 server.ts import redisClient from '@/utils/redis.client' ,然后 app.use(redisClient)
  • 我需要在 tsconfig.json types 里加上 "redis"

这种“结构先行、配置驱动”的思维,才是 TypeScript 带给 Node.js 开发者最宝贵的礼物。它把模糊的“写代码”,变成了清晰的“搭积木”。所以,别把它当成一个需要死记硬背的模板,而要把它当成一个活的、可生长的系统。去修改 paths ,去增加 @services/ 目录,去替换 winston pino ,去把 Express 换成 Fastify ——每一次修改,都是你对 Node.js 工程化理解的一次深化。记住,没有银弹,只有权衡。而你,现在手里已经握住了做权衡的标尺。

更多推荐