面向已掌握 PHP、Go、早期(ES5 之前)JavaScript 的开发者
本文不从"变量是什么""函数是什么"讲起,而是假设你已经具备成熟的后端工程经验。每个新概念都会尽量与 PHP 或 Go 中你熟悉的对应物做类比,帮你用最短路径完成知识迁移。目标是让你能够独立设计、编写、测试、部署一个生产级的 TypeScript + Node.js 服务端应用。
目录
- 心智模型:Node.js 到底是什么
- 环境搭建与版本管理
- 现代 JavaScript 速通(ES5 → ES2024)
- TypeScript 核心
- 模块系统:CommonJS vs ESM
- 包管理与工程化
- 服务端开发实战
- 数据持久化
- 异步编程深入
- 测试
- 部署与运维
- 安全基线
- 进阶学习路径
- 附录:PHP / Go / Node 速查表
0. 心智模型
先建立一个准确的类比,后面所有内容都建立在这个模型上。
| 维度 | PHP(传统 PHP-FPM) | Go | Node.js |
|---|---|---|---|
| 执行方式 | 每个请求由 worker 进程处理,请求结束后大部分状态销毁 | 编译为原生二进制,进程常驻,goroutine 并发 | 进程常驻,单线程事件循环 + 系统级异步 I/O |
| 并发模型 | 多进程/多线程(由 FPM 管理) | 多核并行,goroutine + channel,由调度器抢占 | 单线程跑你的 JS 代码,I/O 交给 libuv 线程池/内核异步接口,通过事件循环回调结果 |
| 一次请求阻塞会怎样 | 只影响这一个 worker | 只影响这个 goroutine | 会阻塞整个进程的所有请求(这是 Node 新手最常踩的坑) |
| 类型系统 | 弱类型,PHP8 起可选类型声明 | 强类型,编译期检查 | JS 本身无类型;TypeScript 是编译期类型层,编译后类型信息完全擦除 |
| 部署产物 | 源码 + PHP 解释器 | 单个静态二进制 | 源码/编译后 JS + Node 运行时(除非用 node --compile-cache 或打包成单文件可执行文件) |
关键心智转变:在 Go 里你用 goroutine 换取并行;在 Node 里你没有并行(除非显式用 Worker Threads),你换取的是"单线程但从不因等待 I/O 而闲置"。所以 Node 服务端编程的核心纪律是:永远不要在主线程写同步阻塞代码(比如 fs.readFileSync 处理用户请求、CPU 密集的循环计算),否则会卡住所有并发请求——这在 PHP-FPM 的多进程模型下是不存在的问题,在 Go 里也因为有真并行和抢占式调度而不那么致命。
TypeScript 的定位则更接近 PHP 的 declare(strict_types=1) + 类型声明的极致版本,或者说是"给 JS 装上 Go 编译器的类型检查器,但编译产物依然是普通 JS"。TS 不改变运行时行为,只在你写代码和编译时帮你抓错误;运行时看到的就是纯 JS,类型信息全部被擦除(除了少数如 enum、装饰器元数据等会生成运行时代码的特性)。
1. 环境搭建与版本管理
1.1 Node.js 版本现状(写作时点参考)
Node.js 采用"偶数版本进入 LTS,长期支持"的策略(注:从 Node 27 起发布节奏将改为每年一个大版本、且每个版本都进入 LTS,但目前主力版本仍遵循旧规则):
- 生产环境:建议使用当前的 Active LTS(写作时为 Node 24.x)
- 尝鲜/新特性:当前 Current 版本(写作时为 Node 26.x),带来了 V8 引擎升级、
Temporal日期时间 API、以及原生 TypeScript 类型剥离(type stripping)——也就是说较新版本的 Node 可以不经任何构建步骤直接运行.ts文件(前提是代码不依赖enum/namespace等需要真正转译而非"擦除"的语法,或显式加参数放开限制)。
版本更新很快,实际选型前建议查一下 Node.js 官方 Release 页面 确认当前 LTS。
1.2 版本管理工具
不要用系统包管理器直接装 Node(类比:你不会用 apt install php 然后被锁死在一个 PHP 版本上)。推荐:
# 推荐:fnm(Rust 写的,比 nvm 快很多)
curl -fsSL https://fnm.vercel.app/install | bash
fnm install --lts
fnm use --lts
# 或者传统的 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install --lts
nvm use --lts
项目根目录放一个 .node-version 或 .nvmrc 文件锁定团队统一版本,类似 Go 的 go.mod 里的 go 1.22 声明。
1.3 安装 TypeScript
npm install -g typescript # 全局装一个方便临时用
tsc --version
但在项目里,TypeScript 应该作为 devDependencies 本地安装(后面会讲为什么),不要依赖全局版本。
2. 现代 JavaScript 速通
你熟悉 ES5 之前的写法(var、function 声明、原型链手写继承、回调地狱)。这里列出到 ES2024 为止你必须掌握的语法,因为几乎所有 TS/Node 代码都建立在这些之上。
本节每个代码示例都补充了对应的运行输出(以注释形式标注在代码后面),并在关键的 ES5/ES6 语法糖后面展开讲了"为什么会这样",方便直接照着跑一遍加深理解。
2.1 变量声明:var → let/const
// 旧写法:var 是函数作用域,会变量提升,容易踩坑
for (var i = 0; i < 3; i++) {
setTimeout(function() { console.log(i); }, 0);
}
// 输出:
// 3
// 3
// 3
// 现代写法:let/const 是块级作用域(类似 Go 的 {} 作用域规则)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出:
// 0
// 1
// 2
为什么两段代码输出不一样?这是新手最容易懵的地方,展开讲一下:
setTimeout的回调函数不会立即执行,而是被丢进任务队列,等当前这一轮同步代码(也就是整个for循环)全部跑完之后,事件循环才会依次取出来执行。- 用
var声明的i,在整个函数(或全局)范围内只有一份存储空间。循环体本身跑得很快,三次setTimeout调用瞬间就都完成了(只是把回调"排队",没有真的等待),但此时i早已经在循环里被连续自增到3(循环条件i < 3不成立时才退出,此时i正好是3)。等到事件循环终于开始执行那三个排队的回调时,它们读取的都是同一个i,而这个i此刻的值已经是3了。所以三次打印全是3。 - 用
let声明的i,规范规定:for 循环的每一次迭代都会创建一个全新的、独立的绑定(可以理解成每轮循环都悄悄新开了一个只在这一轮可见的i,并把上一轮的值复制过来)。所以第一轮循环创建的i=0绑定和它对应的回调函数形成了一个"专属"闭包,第二轮是i=1的专属绑定,以此类推。等事件循环执行这三个回调时,各自读到的是各自那一份独立的i,于是依次打印0、1、2。
用 PHP/Go 的类比:这就好像 var 的场景里,三个回调函数都共享同一个"全局变量的引用",而 let 的场景里,每次循环都相当于按值复制了一份局部变量给当前这次迭代专用——概念上接近 Go 里"在循环体内部重新声明一个局部变量 i := i 再传给闭包"这种手动防坑写法,只不过 JS 的 let 是语言帮你自动做了这件事。
规则:默认用 const,需要重新赋值才用 let,永远不要用 var。
2.2 箭头函数与 this
// 旧写法需要手动绑定 this
function Timer() {
this.seconds = 0;
var self = this; // 关键一步:提前把 this 存到一个普通变量里
setInterval(function() {
self.seconds++; // 这里不能直接写 this.seconds++
console.log(self.seconds);
}, 1000);
}
new Timer();
// 输出(每隔 1 秒打印一次,会持续打印下去):
// 1
// 2
// 3
// ...
// 箭头函数不绑定自己的 this,继承外层 this(类似闭包捕获,但更彻底)
class Timer {
seconds = 0;
start() {
setInterval(() => {
this.seconds++; // this 始终指向 Timer 实例,不需要 self/that 这种权宜写法
console.log(this.seconds);
}, 1000);
}
}
new Timer().start();
// 输出(同样每隔 1 秒打印一次):
// 1
// 2
// 3
// ...
为什么旧写法一定要 var self = this? 普通 function 有一条规则:它的 this 是在调用时决定的,不是在定义时决定的。setInterval(function() {...}, 1000) 传进去的这个回调函数,最终是被 setInterval 内部调用执行的(可以类比成"由浏览器/Node 运行时这个'调用方'来决定回调里 this 是谁"),而不是由 Timer 调用的,所以函数体内的 this 并不会指向 Timer 实例,而是指向全局对象(非严格模式下)或者 undefined(严格模式下)。为了绕开这个问题,旧写法只能在外层先用一个普通变量 self(或者常见的写法是 that)把外层正确的 this "抄"下来,回调里改用 self 而不是 this。
箭头函数则完全不同的规则:箭头函数没有自己的 this,它写在哪里,this 就固定继承那个词法作用域(也就是代码书写位置)外层的 this,跟"谁调用它"完全无关。所以上面 class Timer 例子里,箭头函数是写在 start() 方法内部的,start() 被调用时 this 就是 Timer 实例,箭头函数直接复用这份 this,不需要任何额外绑定技巧。这也是为什么现代 JS/TS 代码里,只要涉及回调函数需要访问外层 this,基本清一色用箭头函数,var self = this 这种写法已经很少见了,只有读旧代码时才会遇到。
2.3 解构、展开、模板字符串
const user = { name: 'Alice', role: 'admin' }; // 注意:user 对象里没有 age 字段
const list = [1, 2, 3, 4];
// 解构赋值(有点像 Go 的多返回值接收,但作用于对象/数组结构)
const { name, age = 18 } = user;
// 按"字段名"从对象里取值,取出来赋给同名的新变量 name、age。
// age = 18 是"默认值"写法:仅当 user.age 是 undefined(该字段不存在或值就是 undefined)时才会用 18 兜底。
console.log(name, age); // 输出:Alice 18
const [first, ...rest] = list;
// 按"位置"从数组里取值:first 拿到第 0 项,...rest 用展开语法把"剩下的所有项"收集成一个新数组。
console.log(first, rest); // 输出:1 [ 2, 3, 4 ]
// 展开运算符(类似 Go 的 slice... 但可用于对象合并,PHP 里没有直接等价物)
const defaults = { theme: 'light', pageSize: 20 };
const overrides = { pageSize: 50 };
const merged = { ...defaults, ...overrides };
// 把 defaults 的字段逐个"摊开"复制进新对象,再把 overrides 的字段摊开复制进来;
// 后面摊开的字段会覆盖前面同名字段,所以这里 pageSize 最终是 50,theme 保留 defaults 的 'light'。
console.log(merged); // 输出:{ theme: 'light', pageSize: 50 }
const arr1 = [1, 2];
const arr2 = [3, 4];
const combined = [...arr1, ...arr2];
// 数组展开同理:把两个数组的元素依次摊开放进一个新数组,等价于 PHP 的 array_merge,
// 或 Go 里先 make 一个新 slice 再把两个 slice append 进去。
console.log(combined); // 输出:[ 1, 2, 3, 4 ]
// 模板字符串(等价于 PHP 的 "Hello $name" 或 Go 的 fmt.Sprintf)
const msg = `Hello ${name}, you are ${age} years old`;
// 反引号 ` 包裹的字符串里,${表达式} 会被求值并转成字符串后插入原位置,
// 可以写任意 JS 表达式(不仅仅是变量名),比如 `${age + 1}`;也天然支持换行,不需要像普通字符串那样写 \n 拼接。
console.log(msg); // 输出:Hello Alice, you are 18 years old
2.4 Promise 与 async/await —— 这是全书最重要的一节
PHP 里你写的是同步阻塞代码:$result = $db->query(...) 会卡住直到拿到结果。Go 里你用 <-channel 或直接同步调用(因为 goroutine 让"阻塞"变得廉价)。Node 里绝大多数 I/O API 是非阻塞的,早期用回调(callback hell),现在统一用 Promise + async/await。
下面假设 a.txt 内容是 hello,b.txt 内容是 world(fs.readFile/fs.promises.readFile 默认返回的是二进制 Buffer,不加编码参数时 console.log 会打印 Buffer 的字节表示,为了示例直观这里假设已经用 .toString() 转成了字符串):
// 回调地狱(早期 Node 写法,你可能在旧代码里见过)
fs.readFile('a.txt', (err, dataA) => {
if (err) throw err;
fs.readFile('b.txt', (err, dataB) => {
if (err) throw err;
console.log(dataA.toString(), dataB.toString());
});
});
// 输出:
// hello world
// Promise 写法
fs.promises.readFile('a.txt')
.then(dataA => fs.promises.readFile('b.txt').then(dataB => [dataA, dataB]))
.then(([a, b]) => console.log(a.toString(), b.toString()))
.catch(err => console.error(err));
// 输出:
// hello world
// async/await(推荐,几乎等价于"看起来同步、实际非阻塞")
async function readBoth() {
try {
const dataA = await fs.promises.readFile('a.txt');
const dataB = await fs.promises.readFile('b.txt');
console.log(dataA.toString(), dataB.toString());
} catch (err) {
console.error(err);
}
}
readBoth();
// 输出:
// hello world
三段代码功能完全等价(读两个文件、拿到内容后一起打印),但可读性和错误处理体验依次变好——这也是 JS 异步写法这十年演进的主线。回调写法每嵌套一层就多一层缩进和一次 if (err) 判断,多个异步操作串起来会形成"向右缩进的金字塔",这就是"回调地狱"这个名字的由来。Promise 写法把嵌套变成了链式 .then(),错误统一在末尾 .catch() 里处理一次,不用每层都判断 err。async/await 写法则是给 Promise 链套了一层"语法糖"外壳,让异步代码在视觉上和同步代码几乎一样(一行一行往下写,try/catch 包一下),但底层跑起来仍然是非阻塞的——await 只是暂停"当前这个 async 函数"的往下执行,并不会卡住整个 Node 进程。
再补充一个容易被"讲得太快"忽略的细节:console.log 这一行并不是在 fs.readFile 调用那一行立刻执行的,而是要等文件真正读取完成(这是一次真实的磁盘 I/O,由 libuv 在后台完成)之后,对应的回调/then/await 之后的代码才会被推入事件循环去执行。如果你在 fs.readFile(...) 后面紧接着写一行 console.log('已发起读取'),你会看到这行反而先打印出来——这正是"非阻塞"的直观体现,也是从 PHP 那种"写在前面的代码一定先执行完"的思维习惯里最需要打破的一点。
await 的效果:暂停当前 async 函数的执行,把线程让给事件循环去处理其他请求,Promise resolve 后再恢复。这跟 Go 里 <-ch 阻塞当前 goroutine(但不阻塞其他 goroutine)在"局部阻塞、全局不阻塞"这一点上是相似的直觉,但底层机制完全不同:Go 是真正的协程调度,Node 是单线程事件循环 + 回调排队。
并发执行多个 Promise(类比 Go 的 sync.WaitGroup 或 errgroup):
async function fetchA() { return 1; }
async function fetchB() { return 2; }
async function fetchC() { return 3; }
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
console.log(a, b, c);
// 输出:
// 1 2 3
Promise.all 会同时发起 fetchA()、fetchB()、fetchC() 三个调用(而不是等第一个完全结束再开始第二个),然后等它们全部完成后,按传入时的顺序把结果收集成一个数组返回——这跟串行写三个 await fetchA(); await fetchB(); await fetchC();(总耗时是三者相加)相比,总耗时约等于三者中最慢的那一个,是关键的性能优化点。但要注意:只要其中任意一个 Promise reject,Promise.all 会立刻整体 reject(其他还没完成的调用不会被取消,但你拿不到它们的结果了)。
Promise.allSettled([...]):不管每个 Promise 成功还是失败都会等它们全部"落定",返回的数组里每一项是{ status: 'fulfilled', value }或{ status: 'rejected', reason },适合"我要知道每一个任务各自的结果,即使某些失败了也不影响看其他的"这种场景。Promise.race([...]):只要传入的 Promise 里任意一个率先完成(无论成功还是失败),就立刻用那一个的结果/错误决定整体结果,常用于给一个慢请求加超时(把它和一个setTimeoutreject 的 Promise 一起 race)。
2.5 类语法(ES6 class 是原型链的语法糖)
class Animal {
#privateField = 0; // 真正的私有字段(# 前缀),比 PHP 的 private 更严格,编译期+运行期都无法从外部访问
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
static create(name) {
return new Animal(name);
}
}
class Dog extends Animal {
speak() {
return `${super.speak()}, specifically a bark`;
}
}
const generic = Animal.create('Generic Animal'); // 静态方法:不需要先 new,直接 Animal.create(...) 调用
console.log(generic.speak()); // 输出:Generic Animal makes a sound
const dog = new Dog('Rex');
console.log(dog.speak()); // 输出:Rex makes a sound, specifically a bark
console.log(dog.name); // 输出:Rex —— 普通字段,外部可以直接读
console.log(dog.#privateField); // 抛出 SyntaxError:Private field '#privateField' must be declared in an enclosing class —— 外部代码里连引用这个名字都不合法,不是"读到 undefined"这种运行时错误,而是直接语法层面就通不过
几个容易被一带而过的点,展开说明:
#privateField:这不是约定(像 PHP 里名字前加下划线_field表示"建议不要碰但技术上仍可访问"),而是语言强制的私有性。只有在类自己的方法内部才能读写this.#privateField,外部代码、甚至子类Dog里都无法访问父类Animal的#privateField——这一点比 PHP 的private(同一个类内部可访问,反射还能强行破解)更彻底,比较接近"完全物理隔离"的私有。static create(name):静态方法挂在类本身上,不是挂在实例上,调用方式是Animal.create(...)而不是new Animal(...).create(...)。常用来做"工厂方法",把new关键字封装起来,或者放一些跟具体实例无关、但逻辑上属于这个类的工具函数——类似 Go 里的包级函数,或 PHP 的public static function。extends与super.speak():class Dog extends Animal建立原型链继承(Dog的实例同时也是Animal的实例,dog instanceof Animal为true)。子类重写了speak()方法后,如果还想复用父类原本的逻辑而不是完全重写,用super.speak()显式调用父类版本的同名方法,再在结果基础上拼接自己的部分——这跟 PHP 的parent::speak()、Go 里"手动调用嵌入结构体的方法再补充逻辑"是同一个思路。
2.6 其他必知特性
// 可选链:避免 PHP 里常见的 null 检查嵌套
const userA = { address: { city: 'Singapore' } };
const userB = { address: null };
const userC = {};
console.log(userA?.address?.city); // 输出:Singapore
console.log(userB?.address?.city); // 输出:undefined —— address 是 null,链条在这里"短路",不会报错
console.log(userC?.address?.city); // 输出:undefined —— address 字段根本不存在,同样安全短路
// ?? 是空值合并,仅在左值是 null/undefined 时才取右值
const city = userB?.address?.city ?? '未知';
console.log(city); // 输出:未知
// 对比 ||(逻辑或):只要左值是"假值"(false、0、''、null、undefined、NaN 中的任意一个)就会取右值
const count = 0;
console.log(count || 10); // 输出:10 —— 0 被当成假值,可能不是你想要的结果
console.log(count ?? 10); // 输出:0 —— ?? 只关心是不是 null/undefined,0 本身是合法值,会被保留
// 解构 + 默认参数
function createUser({ name, role = 'user' } = {}) {
return `${name}(${role})`;
}
console.log(createUser({ name: 'Alice' })); // 输出:Alice(user) —— role 未传,用默认值 'user'
console.log(createUser({ name: 'Bob', role: 'admin' })); // 输出:Bob(admin)
console.log(createUser()); // 输出:undefined(user) —— 关键点见下方说明
// Map/Set:比普通对象更适合做字典/去重(类似 Go 的 map[K]V 和用 map[K]struct{} 模拟 set)
const cache = new Map();
cache.set('a', 1);
cache.set('b', 2);
console.log(cache.get('a'), cache.size); // 输出:1 2
const seen = new Set([1, 2, 2, 3]); // 传入数组初始化,重复的 2 会被自动去重
console.log(seen.has(2), seen.size); // 输出:true 3
// 生成器(async/await 的底层原理之一,日常写业务代码用得少,但框架源码常见)
function* range(n) {
for (let i = 0; i < n; i++) yield i;
}
console.log([...range(3)]); // 输出:[ 0, 1, 2 ]
补充几点讲得比较快、容易漏掉的细节:
- 可选链
?.的"短路"行为:只要链条中前面某一节是null或undefined,整个表达式立刻停止求值并返回undefined,不会因为后面还有.city这样的属性访问而抛出"Cannot read properties of null"这类经典 JS 报错。这跟 PHP 需要写isset($user['address']['city']) ? $user['address']['city'] : '未知'或者一层层is_null判断相比,可读性提升很明显。 ??和||的关键区别(这一点非常容易踩坑):||只要左值是任何"假值"(false、0、''、null、undefined、NaN)就会返回右值;??只在左值严格是null或undefined时才返回右值。所以像"用户输入的数量是 0""字符串就是空字符串"这种"合法但恰好是假值"的场景,用||会被错误地替换掉,必须用??才安全。- 解构默认参数里
= {}这个写法:function createUser({ name, role = 'user' } = {})里有两层默认值——role = 'user'是"对象里 role 字段缺失时的默认值";而参数本身的= {}是"整个参数都没传(调用createUser()不传任何实参)时,先给一个空对象兜底,再去解构"。如果去掉最外层的= {},调用createUser()时会因为对undefined做解构而直接抛出TypeError: Cannot destructure property 'name' of 'undefined'。这也是为什么示例里createUser()能正常跑通并输出undefined(user)(name没有默认值,所以是undefined;role有默认值,所以是'user')而不是报错。 - Map 与普通对象
{}的选择:普通对象的 key 只能是字符串或 Symbol,且它本身继承了Object.prototype上的一堆方法,容易和业务字段混淆;Map的 key 可以是任意类型(对象、数字都行),有明确的.size属性,插入顺序有保证,遍历性能也更稳定,因此"当字典用"更推荐Map。Set同理,专门用来处理"去重"和"存在性判断"这两类需求,比"拿对象的 key 模拟 set"(PHP/前端老代码常见写法)更直观。 - 生成器
function*:调用range(3)本身不会立刻执行函数体,而是返回一个"迭代器"对象;每次外部代码要下一个值(比如用...展开、或用for...of遍历、或手动调用.next())时,函数体才会执行到下一个yield处暂停并把值交出来,下次再从暂停的地方继续跑。这种"按需暂停/恢复执行"的能力,正是async/await底层能够实现"暂停当前函数、把控制权交还事件循环"的语言基础之一(虽然现代 JS 引擎对async/await有专门优化,不完全等价于生成器的直接实现,但概念上是同源的)。
3. TypeScript 核心
3.1 基本类型与类型注解
let username: string = 'alice';
let age: number = 30;
let isActive: boolean = true;
let tags: string[] = ['a', 'b'];
let tuple: [string, number] = ['id', 1]; // 固定长度、固定每位类型,Go 没有直接等价物(有点像具名 struct 但更轻量)
function greet(name: string): string {
return `Hello, ${name}`;
}
大多数场景不需要写类型注解——TS 有很强的类型推断,const x = 1 会自动推断为 number。显式注解通常只用于:函数参数、函数返回值(公共 API 建议显式写)、无法推断的场景。
3.2 interface vs type
这两者是 TS 里最容易让 Go/PHP 背景开发者困惑的地方。
// interface:更接近 Go 的 interface,但 TS 里更常用来描述"对象的形状"(结构化类型/鸭子类型)
interface User {
id: number;
name: string;
email?: string; // 可选属性
readonly createdAt: Date; // 只读,类似 Go struct 字段无法在外部重新赋值(约定层面)
}
// type:类型别名,能力更广,可以给任意类型起名,包括联合类型、原始类型
type ID = string | number;
type Status = 'pending' | 'active' | 'closed'; // 字符串字面量联合类型,比 Go 的 iota 枚举更贴近业务语义,比 PHP 8.1 enum 更灵活
关键区别与选择建议:
- interface 可以被 extends 继承,也可以被同名 interface 声明合并(多次声明会自动合并字段),type 不行。
- type 能表达联合类型(A | B)、条件类型等 interface 无法表达的能力。
- 团队约定:定义对象/类的形状用 interface,定义联合类型、函数类型、工具类型用 type。这不是强制规则,但是社区主流实践。
结构化类型(重要!):TS 是结构类型系统(structural typing),类似 Go(Go 的 interface 也是隐式实现,"鸭子类型"),而不是 PHP/Java 那种名义类型系统(nominal typing,必须显式 implements):
interface Point { x: number; y: number; }
function printPoint(p: Point) { console.log(p.x, p.y); }
// 这个对象没有显式声明"实现了 Point",但只要形状匹配就能传
printPoint({ x: 1, y: 2, z: 3 }); // 注意:多余字段在"字面量直接传参"时会报错(excess property check),但存进变量再传就不会报错,这是 TS 的一个已知怪癖
3.3 联合类型、交叉类型、类型收窄
type Result = { success: true; data: string } | { success: false; error: string };
function handle(r: Result) {
if (r.success) {
console.log(r.data); // TS 知道这里 r.success === true,自动收窄类型,r.error 不存在
} else {
console.log(r.error);
}
}
// 交叉类型:合并多个类型的字段(类似 PHP trait 组合,但是类型层面)
type Timestamped = { createdAt: Date };
type Identifiable = { id: string };
type Entity = Timestamped & Identifiable;
类型守卫(type guard):
function isString(val: unknown): val is string {
return typeof val === 'string';
}
function process(val: unknown) {
if (isString(val)) {
val.toUpperCase(); // 这里 TS 已知 val 是 string
}
}
3.4 泛型
Go 1.18+ 才有泛型,你应该已经熟悉这个概念了;TS 的泛型语法更接近 Java/C#:
function firstOf<T>(arr: T[]): T | undefined {
return arr[0];
}
interface Repository<T> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> { /* ... */ return null; }
async save(entity: User): Promise<void> { /* ... */ }
}
// 泛型约束(类似 Go 的类型约束 interface)
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
3.5 类与访问修饰符
class BankAccount {
private balance: number; // 编译期检查,运行时其实还能访问(真正私有要用 #balance)
protected readonly ownerId: string;
public accountNumber: string;
constructor(ownerId: string, accountNumber: string) {
this.balance = 0;
this.ownerId = ownerId;
this.accountNumber = accountNumber;
}
// 构造函数参数属性简写(TS 特有语法糖,省去手动赋值)
// constructor(private ownerId: string, public accountNumber: string) {}
deposit(amount: number): void {
if (amount <= 0) throw new Error('金额必须为正');
this.balance += amount;
}
}
abstract class Shape {
abstract area(): number; // 类似 Go 的接口方法必须实现,PHP 的 abstract
describe(): string {
return `面积是 ${this.area()}`;
}
}
3.6 实用工具类型(内置,无需引入)
interface User { id: string; name: string; email: string; age: number; }
type PartialUser = Partial<User>; // 所有字段变可选,常用于 PATCH 更新
type UserPreview = Pick<User, 'id' | 'name'>; // 只挑选部分字段
type UserWithoutId = Omit<User, 'id'>; // 排除部分字段
type ReadonlyUser = Readonly<User>; // 所有字段只读
type UserRecord = Record<string, User>; // 类似 Go 的 map[string]User
自己写映射类型(进阶,理解即可,不必强记):
type Nullable<T> = { [K in keyof T]: T[K] | null };
type ExtractStringKeys<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T];
3.7 枚举:能不用就不用
// TS 的 enum 会生成额外的运行时 JS 代码(不像其他类型标注那样纯编译期擦除)
enum Status { Pending, Active, Closed }
// 更推荐:字符串字面量联合类型,零运行时开销,且和 JSON 序列化更自然
type Status = 'pending' | 'active' | 'closed';
const STATUS = { PENDING: 'pending', ACTIVE: 'active', CLOSED: 'closed' } as const;
3.8 tsconfig.json 详解
{
"compilerOptions": {
"target": "ES2022", // 编译产物的 JS 版本,对应你的 Node 运行时最低版本
"module": "NodeNext", // 让 TS 按 Node 的模块解析规则处理 import/require
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true, // 强烈建议开启,等价于同时开启下面一堆严格检查
"esModuleInterop": true, // 让 CommonJS 包能用 import 默认导入语法
"skipLibCheck": true, // 跳过 node_modules 里 .d.ts 的类型检查,加快编译
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true, // 生成 .d.ts,如果你的代码会被其他包依赖
"sourceMap": true, // 方便调试时映射回 TS 源码
"isolatedModules": true // 保证每个文件可以独立转译(配合 esbuild/swc 等单文件转译器)
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
strict: true 展开后包含:noImplicitAny(禁止隐式 any)、strictNullChecks(null/undefined 必须显式处理,这是最有价值的一项)、strictFunctionTypes、strictBindCallApply 等。新项目务必开启 strict,相当于 PHP 的 declare(strict_types=1) 加强十倍。
3.9 编译与运行方式对比
| 方式 | 场景 | 说明 |
|---|---|---|
tsc |
生产构建 | 纯类型检查 + 编译,输出 .js,编译慢但最标准 |
tsx |
开发时直接运行/热重载 | 底层用 esbuild,只做类型擦除不做类型检查,飞快 |
ts-node |
老项目常见 | 类似 tsx 但更老、更慢,新项目不再首选 |
| Node 原生类型剥离 | Node 24+(--experimental-strip-types,更新版本已默认开启) |
直接 node app.ts 运行,零依赖,但不支持 enum、namespace 等需要真转译的语法,且不做类型检查 |
生产环境典型流程仍然是:tsc 编译成 dist/*.js → 用 node dist/index.js 运行,把类型检查放在 CI 阶段(tsc --noEmit)而不是运行时。
4. 模块系统
这是从 PHP(require/use 命名空间)或 Go(import "package/path",包名固定)迁移过来最容易踩坑的地方,因为 Node 生态里同时存在两套模块系统。
4.1 CommonJS(老系统,你可能在旧代码/教程里常见)
// math.js
function add(a, b) { return a + b; }
module.exports = { add }; // module.exports 就是这个文件对外暴露的"公共接口",类似 PHP 里一个类的 public 方法集合
// main.js
const { add } = require('./math');
console.log(add(2, 3)); // 输出:5
require('./math') 执行时会同步读取并立即执行 math.js 这个文件(第一次 require 时会被缓存,之后重复 require 同一个模块直接拿缓存结果,不会重复执行),拿到它 module.exports 上挂的对象后,再用解构语法 { add } 取出 add 这个函数。这跟 PHP 的 require/include 语义类似,都是"运行时把另一个文件的内容拉进来执行",区别是 CommonJS 有清晰的模块边界(math.js 内部声明的变量默认不会污染 main.js 的作用域,只有显式挂在 module.exports 上的东西才能被外部拿到)。
同步加载、运行时按需 require,是 Node 最初唯一支持的方式。
4.2 ES Modules(现代标准,与浏览器一致)
// math.ts
export function add(a: number, b: number): number { return a + b; }
export default class Calculator { /* ... */ }
// main.ts
import Calculator, { add } from './math.js'; // 注意:即使源码是 .ts,运行时 import 路径按 ESM 规范要写 .js 后缀
4.3 如何选择
在 package.json 里声明:
{ "type": "module" }
设置后,.js 文件默认按 ESM 解析;不设置则默认按 CommonJS 解析(.mjs/.cjs 后缀可以强制指定,不受此设置影响)。新项目一律选 ESM("type": "module" + tsconfig 里 "module": "NodeNext"),理由:这是标准方向、与浏览器/前端工具链统一、顶层 await 等新特性只有 ESM 支持。只有在维护老项目或依赖大量仅支持 CommonJS 的老库时才用 CommonJS。
5. 包管理与工程化
5.1 包管理器选择
| 工具 | 类比 | 特点 |
|---|---|---|
| npm | Composer / go mod | 官方自带,生态最广,速度一般 |
| pnpm | — | 磁盘复用(硬链接),速度快,严格的依赖隔离(不允许"幽灵依赖"),推荐新项目使用 |
| yarn | — | 曾经的性能优势已被 pnpm/npm 追平,选型自由 |
npm init -y # 生成 package.json,类似 composer init / go mod init
npm install express # 生产依赖
npm install -D typescript @types/node vitest # 开发依赖,-D 等价 --save-dev
@types/xxx 系列包(DefinitelyTyped 项目)是给纯 JS 编写的第三方库补充的 TS 类型声明,很多库自带类型(package.json 里有 types 字段)就不需要额外装。
5.2 项目目录结构建议
my-service/
├── src/
│ ├── index.ts # 入口
│ ├── config/ # 配置与环境变量校验
│ ├── routes/ # 路由定义
│ ├── controllers/ # 处理 HTTP 请求,调用 service
│ ├── services/ # 业务逻辑(对应 PHP 的 Service 层,Go 的 usecase/service 包)
│ ├── repositories/ # 数据访问层
│ ├── middlewares/
│ ├── types/ # 共享类型定义
│ └── utils/
├── tests/
├── prisma/ (若用 Prisma)
├── .env
├── .env.example
├── tsconfig.json
├── package.json
├── .eslintrc.cjs / eslint.config.js
└── Dockerfile
这个分层与典型的 Go 项目(handler → service → repository)或 Laravel(Controller → Service → Repository/Model)几乎一一对应,迁移心智负担很小。
5.3 ESLint + Prettier
npm install -D eslint @eslint/js typescript-eslint prettier eslint-config-prettier
// eslint.config.js(ESLint 9+ 的新扁平配置格式)
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{ rules: { '@typescript-eslint/no-unused-vars': 'warn' } }
);
说明:这是一个配置文件,本身不会被你直接 node 运行、也不产生 console.log 式的输出——它是给 eslint 这个命令行工具读取的规则清单。执行 npx eslint src/ 之后,ESLint 会读取这份配置,按 eslint.configs.recommended 和 tseslint.configs.recommended 里预设的规则,加上你自定义覆盖的 no-unused-vars: 'warn',去逐个检查 src/ 下的文件,终端里看到的实际"输出"是类似下面这种格式的检查报告:
src/index.ts
12:7 warning 'unusedVar' is defined but never used @typescript-eslint/no-unused-vars
✖ 1 problem (0 errors, 1 warning)
Prettier 只管格式化(缩进、引号、分号),ESLint 管代码质量(未使用变量、潜在 bug)。两者职责要分开,避免规则冲突。
6. 服务端开发实战
6.1 从原生 http 模块理解底层
import { createServer } from 'node:http';
const server = createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
res.writeHead(404);
res.end();
});
server.listen(3000, () => console.log('Server on :3000'));
理解这一层有助于理解框架在做什么——本质上所有框架都是在这个基础上包了一层路由匹配 + 中间件链,思路类似 Go 里 net/http 之上的 gin/echo,或者 PHP 里 Swoole/ReactPHP 之上的框架。
6.2 框架选型
| 框架 | 定位 | 类比 |
|---|---|---|
| Express | 老牌、生态最大、API 简单但性能一般,TS 支持是"外挂"的(@types/express) |
类似 PHP 的 Slim |
| Fastify | 高性能、原生 TS 友好、内置 JSON Schema 校验 | 类似 Go 的 gin/echo 的性能定位 |
| Koa | Express 原班人马做的下一代,极简核心 + 中间件洋葱模型,本身不带路由 | 类似 Go 的 net/http + 自选中间件的"轻框架"哲学 |
| NestJS | 装饰器 + 依赖注入 + 模块化,架构高度类似 Angular/Spring | 类似 PHP 的 Symfony/Laravel(约定多、开箱即用多) |
新项目、注重性能与类型安全:推荐 Fastify。团队偏好强架构约束、大型企业应用:推荐 NestJS。
6.3 用 Fastify + TypeScript 写一个 REST API
// src/index.ts
import Fastify from 'fastify';
import { z } from 'zod';
const app = Fastify({ logger: true });
// 内存"数据库"示例
const users = new Map<string, { id: string; name: string; email: string }>();
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post('/users', async (request, reply) => {
const parseResult = CreateUserSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.status(400).send({ error: parseResult.error.flatten() });
}
const id = crypto.randomUUID();
const user = { id, ...parseResult.data };
users.set(id, user);
return reply.status(201).send(user);
});
app.get('/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = users.get(id);
if (!user) return reply.status(404).send({ error: 'User not found' });
return user;
});
// 全局错误处理(类似 PHP 的异常处理中间件、Go 的 recover 中间件)
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
reply.status(500).send({ error: 'Internal Server Error' });
});
app.listen({ port: 3000, host: '0.0.0.0' })
.then(() => console.log('Server running on :3000'))
.catch((err) => { console.error(err); process.exit(1); });
6.4 请求校验:zod
zod 是目前最主流的运行时校验库,同时从 schema 推断出 TS 类型,一份定义、两处收益(这点比 PHP 里手写 Validator::make([...]) 规则和 DTO 类分开写要省事很多):
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
});
type User = z.infer<typeof UserSchema>; // 自动得到 TS 类型,无需手写 interface
const result = UserSchema.safeParse(rawInput);
if (!result.success) {
console.log(result.error.issues);
}
6.5 分层架构与依赖注入
// repositories/user.repository.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
}
export class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
}
// services/user.service.ts
export class UserService {
constructor(private repo: UserRepository) {} // 构造函数注入,对应 Go 里传接口进构造函数、PHP 里 Laravel 的容器自动解析
async getUser(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
}
}
Node 生态没有像 Laravel 那样内置的服务容器(NestJS 有自己的 DI 容器,走装饰器路线)。手写项目里最常见的做法就是像上面这样手动构造函数注入,简单直接,不依赖额外框架魔法——这跟 Go 里"面向接口编程、手动组装依赖"的哲学完全一致。
7. 数据持久化
7.1 ORM/查询构建器选型
| 工具 | 特点 | 类比 |
|---|---|---|
| Prisma | Schema 文件定义模型 → 自动生成完全类型安全的 client,DX 最好 | 有点像 Go 的 sqlc(但反过来,是先写 schema 再生成代码) |
| Drizzle | 更贴近 SQL、零运行时开销、类型从 schema 定义直接推导,不需要代码生成步骤 | 更接近手写 SQL + 强类型包装 |
| TypeORM | 装饰器 + Active Record/Data Mapper,历史悠久但类型安全性不如前两者 | 类似 PHP 的 Doctrine |
原生 pg/mysql2 |
直接写 SQL | 类似 Go 的 database/sql |
新项目推荐 Prisma(团队协作、迁移管理成熟)或 Drizzle(追求性能和 SQL 掌控力)。
7.2 Prisma 快速上手
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
name String
email String @unique
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(uuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
}
npx prisma migrate dev --name init # 生成并执行迁移,类似 Laravel 的 migrate,Go 里的 golang-migrate
npx prisma generate # 生成类型安全的 client
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const user = await prisma.user.create({
data: { name: 'Alice', email: 'alice@example.com' },
});
const usersWithPosts = await prisma.user.findMany({
include: { posts: true }, // 返回值类型会自动包含 posts 字段,IDE 能精确推断
});
8. 异步编程深入
8.1 错误处理最佳实践
// 自定义错误类型(类似 Go 的自定义 error 类型 + errors.Is/As)
class AppError extends Error {
constructor(message: string, public statusCode: number, public code: string) {
super(message);
this.name = 'AppError';
}
}
class NotFoundError extends AppError {
constructor(message: string) { super(message, 404, 'NOT_FOUND'); }
}
// async 函数里未捕获的异常会变成 rejected Promise,务必包裹 try/catch 或用框架的错误处理机制
async function handler(id: string) {
try {
const user = await findUser(id);
if (!user) throw new NotFoundError(`user ${id}`);
return user;
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError('Internal error', 500, 'INTERNAL');
}
}
重要陷阱:忘记 await 会导致错误被"吞掉"(Promise 变成游离状态),也会导致代码提前继续执行而拿不到结果。养成"async 函数里的 Promise 调用几乎总要 await"的纪律,或者显式 void somePromise() 明确表示"我知道我没等它"。
8.2 EventEmitter(观察者模式的内置实现)
import { EventEmitter } from 'node:events';
class OrderService extends EventEmitter {
createOrder(data: OrderInput) {
const order = { id: crypto.randomUUID(), ...data };
this.emit('order.created', order); // 类似 PHP 的 Laravel Event::dispatch,Go 里手写的 pub/sub
return order;
}
}
const orders = new OrderService();
orders.on('order.created', (order) => sendConfirmationEmail(order));
orders.on('order.created', (order) => updateInventory(order));
8.3 Stream(处理大文件/大数据量,避免一次性读入内存)
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
await pipeline(
createReadStream('input.txt'),
createGzip(),
createWriteStream('output.txt.gz')
);
理念上类似 Go 的 io.Reader/io.Writer 接口 + io.Copy。
8.4 Worker Threads(真正的多线程,CPU 密集任务专用)
// worker.ts
import { parentPort, workerData } from 'node:worker_threads';
const result = heavyComputation(workerData);
parentPort?.postMessage(result);
// main.ts
import { Worker } from 'node:worker_threads';
const worker = new Worker('./worker.js', { workerData: input });
worker.on('message', (result) => console.log(result));
这才是 Node 里跟 Go goroutine 概念上最接近的东西——但代价高得多:每个 Worker 是一个独立的 V8 实例,通信需要序列化(结构化克隆),不像 goroutine 那样轻量、共享内存。日常业务代码(数据库查询、HTTP 调用)不需要 Worker Threads,因为这些本身就是非阻塞 I/O;只有真正 CPU 密集的计算(图像处理、大规模数据运算)才需要考虑。
9. 测试
9.1 Vitest(推荐,比 Jest 更快、配置更简单,API 基本兼容)
npm install -D vitest
// user.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { UserService } from './user.service';
describe('UserService', () => {
it('抛出 NotFoundError 当用户不存在', async () => {
const mockRepo = { findById: vi.fn().mockResolvedValue(null) };
const service = new UserService(mockRepo as any);
await expect(service.getUser('unknown-id')).rejects.toThrow('not found');
});
it('正常返回用户', async () => {
const mockUser = { id: '1', name: 'Alice' };
const mockRepo = { findById: vi.fn().mockResolvedValue(mockUser) };
const service = new UserService(mockRepo as any);
const result = await service.getUser('1');
expect(result).toEqual(mockUser);
});
});
// package.json
{ "scripts": { "test": "vitest run", "test:watch": "vitest" } }
依赖倒置(面向 UserRepository 接口编程而非具体实现)让单测里可以用简单对象/mock 替换真实数据库,跟 Go 里"面向接口写测试、传入 mock struct"的思路完全一致。
10. 部署与运维
10.1 环境变量:类型安全地读取
// config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
});
export const env = EnvSchema.parse(process.env); // 启动时立刻校验,缺失/格式错误直接 crash,而不是运行到一半才报错
这比 PHP 里直接 $_ENV['DATABASE_URL'](拼错键名要运行时才发现)或 Go 里手写一堆 os.Getenv + 校验要省事得多。
10.2 日志:pino
import pino from 'pino';
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
logger.info({ userId: '123' }, 'user logged in'); // 结构化日志,直接输出 JSON,便于日志系统采集
10.3 Dockerfile(多阶段构建)
# ---- 构建阶段 ----
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # tsc 编译到 dist/
# ---- 运行阶段 ----
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
两阶段构建的目的与 Go 的多阶段 Docker 构建(编译阶段用完整工具链、运行阶段只留二进制)一致:最终镜像不包含 devDependencies 和源码,体积更小、攻击面更小。区别是 Go 最终镜像可以小到几 MB(甚至 scratch 基础镜像),Node 因为需要完整运行时,镜像通常几十到上百 MB。
10.4 进程管理
- 容器化部署(K8s/ECS 等):不需要 PM2,让容器编排层负责重启、扩缩容,容器内直接
CMD ["node", "dist/index.js"]。 - 裸机/单机部署:用
pm2做进程守护、日志、简单的集群模式(pm2 start dist/index.js -i max会利用 Node 的cluster模块 fork 多个进程,绕开单线程限制,压榨多核 CPU——这是 Node 服务在单机上逼近 Go 多核利用率的常见手段)。
10.5 性能与内存排查
--inspect配合 Chrome DevTools 做 CPU Profiling / 内存快照,类似 Go 的pprof。- 常见内存泄漏源:全局
Map/数组只增不减、忘记clearInterval/removeListener、闭包意外持有大对象。 - 事件循环阻塞排查:
node --prof,或用perf_hooks里的monitorEventLoopDelay()监控事件循环延迟,一旦发现延迟飙升说明有同步阻塞代码需要揪出来。
11. 安全基线
import helmet from '@fastify/helmet'; // 或 Express 版本的 helmet
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
app.register(helmet); // 设置一系列安全相关的 HTTP 响应头
app.register(cors, { origin: ['https://your-frontend.com'] });
app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
必须遵守的基本纪律:
1. 所有外部输入必须校验(zod schema),不要信任 req.body/req.query——这跟 PHP/Go 的原则完全一样。
2. 参数化查询,Prisma/Drizzle 默认就是参数化的,防注入;如果手写 SQL 一定要用占位符,绝不拼接字符串。
3. 密码用 bcrypt/argon2 哈希,永远不要自己发明加密方案。
4. JWT 用于无状态鉴权时注意设置合理过期时间、避免把敏感信息塞进 payload(payload 只是 base64 编码,不是加密)。
5. 依赖安全:定期 npm audit,锁定 package-lock.json 进版本库。
12. 进阶学习路径
- 打好地基:完整读一遍 Node.js 官方文档 的 Events、Streams、Async Hooks 部分;TypeScript 官方手册 通读一遍(不用死记,知道有什么能力即可)。
- 深入事件循环:读 Node 官方关于 Event Loop, Timers, and process.nextTick 的说明。
- 精读一个框架源码:选 Fastify 或 Express,读懂中间件/插件系统的实现,会极大加深对 Node 异步模型的理解。
- TypeScript 高级类型:MDN 之外,type-challenges 这个开源题库是练习高级类型体操的好资源。
- 架构层面:了解 Clean Architecture / Hexagonal Architecture 在 Node 项目里的落地方式,Nest.js 官方文档的架构章节是很好的参照(即使你最终不用 Nest)。
- 持续关注生态:TypeScript 团队正在把编译器用 Go 重写(
typescript-go,为了编译速度),Node 也在持续加强原生 TS 支持力度,这块建议直接跟踪 TypeScript 官方博客 和 Node.js 官方博客 获取一手信息,避免二手转述失真或过时。
13. 附录:PHP / Go / Node 速查表
| 概念 | PHP | Go | Node.js/TypeScript |
|---|---|---|---|
| 包管理 | Composer (composer.json) |
go.mod |
package.json |
| 依赖锁定 | composer.lock |
go.sum |
package-lock.json / pnpm-lock.yaml |
| 入口文件 | index.php |
main.go |
index.ts / main.ts |
| 接口 | interface(名义类型) |
interface(结构化类型) |
interface(结构化类型,与 Go 更像) |
| 空值处理 | ?Type + is_null() |
零值 + nil |
strictNullChecks + ?./?? |
| 并发单位 | worker 进程 | goroutine | 事件循环回调 / Promise |
| 真并行 | 多进程 | 多核 goroutine | Worker Threads(较重量) |
| 错误处理 | try/catch 异常 |
多返回值 (result, error) |
try/catch + Promise rejection |
| 私有字段 | private |
小写字段名(包级私有) | private 关键字(仅编译期)或 #field(运行期真私有) |
| 环境变量 | $_ENV / .env (dotenv 库) |
os.Getenv |
process.env (+ dotenv/zod 校验) |
| 单测框架 | PHPUnit | testing 标准库 |
Vitest / Jest |
| ORM | Eloquent / Doctrine | GORM / sqlc | Prisma / Drizzle |
| 静态类型检查时机 | 运行时(部分可选类型) | 编译期 | 编译期(tsc),运行时类型已擦除 |
结语
从 PHP/Go 迁移到 Node.js + TypeScript,最大的思维转变有两处:一是从"多进程/真并行"切换到"单线程事件循环",纪律核心是绝不写阻塞主线程的同步代码;二是接受 TypeScript 只是编译期的安全网,运行时看到的仍然是普通 JS,这意味着"类型正确"不等于"运行时数据一定正确"——外部输入(HTTP body、数据库返回、第三方 API)永远需要 zod 这类运行时校验兜底,这一点在 Go 的强类型编译期检查思维下容易被忽略,但在 Node/TS 里是必须刻意补上的一课。