跳至主要内容

Node.js + TypeScript 服务端开发指南

面向已掌握 PHP、Go、早期(ES5 之前)JavaScript 的开发者

本文不从"变量是什么""函数是什么"讲起,而是假设你已经具备成熟的后端工程经验。每个新概念都会尽量与 PHP 或 Go 中你熟悉的对应物做类比,帮你用最短路径完成知识迁移。目标是让你能够独立设计、编写、测试、部署一个生产级的 TypeScript + Node.js 服务端应用。


目录

  1. 心智模型:Node.js 到底是什么
  2. 环境搭建与版本管理
  3. 现代 JavaScript 速通(ES5 → ES2024)
  4. TypeScript 核心
  5. 模块系统:CommonJS vs ESM
  6. 包管理与工程化
  7. 服务端开发实战
  8. 数据持久化
  9. 异步编程深入
  10. 测试
  11. 部署与运维
  12. 安全基线
  13. 进阶学习路径
  14. 附录: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 之前的写法(varfunction 声明、原型链手写继承、回调地狱)。这里列出到 ES2024 为止你必须掌握的语法,因为几乎所有 TS/Node 代码都建立在这些之上。

本节每个代码示例都补充了对应的运行输出(以注释形式标注在代码后面),并在关键的 ES5/ES6 语法糖后面展开讲了"为什么会这样",方便直接照着跑一遍加深理解。

2.1 变量声明:varlet/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

为什么两段代码输出不一样?这是新手最容易懵的地方,展开讲一下:

  1. setTimeout 的回调函数不会立即执行,而是被丢进任务队列,等当前这一轮同步代码(也就是整个 for 循环)全部跑完之后,事件循环才会依次取出来执行。
  2. var 声明的 i,在整个函数(或全局)范围内只有一份存储空间。循环体本身跑得很快,三次 setTimeout 调用瞬间就都完成了(只是把回调"排队",没有真的等待),但此时 i 早已经在循环里被连续自增到 3(循环条件 i < 3 不成立时才退出,此时 i 正好是 3)。等到事件循环终于开始执行那三个排队的回调时,它们读取的都是同一个 i,而这个 i 此刻的值已经是 3 了。所以三次打印全是 3
  3. let 声明的 i,规范规定:for 循环的每一次迭代都会创建一个全新的、独立的绑定(可以理解成每轮循环都悄悄新开了一个只在这一轮可见的 i,并把上一轮的值复制过来)。所以第一轮循环创建的 i=0 绑定和它对应的回调函数形成了一个"专属"闭包,第二轮是 i=1 的专属绑定,以此类推。等事件循环执行这三个回调时,各自读到的是各自那一份独立的 i,于是依次打印 012

用 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 内容是 hellob.txt 内容是 worldfs.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() 里处理一次,不用每层都判断 errasync/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.WaitGrouperrgroup):

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 里任意一个率先完成(无论成功还是失败),就立刻用那一个的结果/错误决定整体结果,常用于给一个慢请求加超时(把它和一个 setTimeout reject 的 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
  • extendssuper.speak()class Dog extends Animal 建立原型链继承(Dog 的实例同时也是 Animal 的实例,dog instanceof Animaltrue)。子类重写了 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 ]

补充几点讲得比较快、容易漏掉的细节:

  • 可选链 ?. 的"短路"行为:只要链条中前面某一节是 nullundefined,整个表达式立刻停止求值并返回 undefined,不会因为后面还有 .city 这样的属性访问而抛出"Cannot read properties of null"这类经典 JS 报错。这跟 PHP 需要写 isset($user['address']['city']) ? $user['address']['city'] : '未知' 或者一层层 is_null 判断相比,可读性提升很明显。
  • ??|| 的关键区别(这一点非常容易踩坑):|| 只要左值是任何"假值"false0''nullundefinedNaN)就会返回右值;?? 只在左值严格是 nullundefined 时才返回右值。所以像"用户输入的数量是 0""字符串就是空字符串"这种"合法但恰好是假值"的场景,用 || 会被错误地替换掉,必须用 ?? 才安全。
  • 解构默认参数里 = {} 这个写法function createUser({ name, role = 'user' } = {}) 里有两层默认值——role = 'user' 是"对象里 role 字段缺失时的默认值";而参数本身的 = {} 是"整个参数都没传(调用 createUser() 不传任何实参)时,先给一个空对象兜底,再去解构"。如果去掉最外层的 = {},调用 createUser() 时会因为对 undefined 做解构而直接抛出 TypeError: Cannot destructure property 'name' of 'undefined'。这也是为什么示例里 createUser() 能正常跑通并输出 undefined(user)name 没有默认值,所以是 undefinedrole 有默认值,所以是 'user')而不是报错。
  • Map 与普通对象 {} 的选择:普通对象的 key 只能是字符串或 Symbol,且它本身继承了 Object.prototype 上的一堆方法,容易和业务字段混淆;Map 的 key 可以是任意类型(对象、数字都行),有明确的 .size 属性,插入顺序有保证,遍历性能也更稳定,因此"当字典用"更推荐 MapSet 同理,专门用来处理"去重"和"存在性判断"这两类需求,比"拿对象的 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)、strictNullChecksnull/undefined 必须显式处理,这是最有价值的一项)、strictFunctionTypesstrictBindCallApply 等。新项目务必开启 strict,相当于 PHP 的 declare(strict_types=1) 加强十倍。

3.9 编译与运行方式对比

方式 场景 说明
tsc 生产构建 纯类型检查 + 编译,输出 .js,编译慢但最标准
tsx 开发时直接运行/热重载 底层用 esbuild,只做类型擦除不做类型检查,飞快
ts-node 老项目常见 类似 tsx 但更老、更慢,新项目不再首选
Node 原生类型剥离 Node 24+(--experimental-strip-types,更新版本已默认开启) 直接 node app.ts 运行,零依赖,但不支持 enumnamespace 等需要真转译的语法,且不做类型检查

生产环境典型流程仍然是: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.recommendedtseslint.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. 进阶学习路径

  1. 打好地基:完整读一遍 Node.js 官方文档 的 Events、Streams、Async Hooks 部分;TypeScript 官方手册 通读一遍(不用死记,知道有什么能力即可)。
  2. 深入事件循环:读 Node 官方关于 Event Loop, Timers, and process.nextTick 的说明。
  3. 精读一个框架源码:选 Fastify 或 Express,读懂中间件/插件系统的实现,会极大加深对 Node 异步模型的理解。
  4. TypeScript 高级类型:MDN 之外,type-challenges 这个开源题库是练习高级类型体操的好资源。
  5. 架构层面:了解 Clean Architecture / Hexagonal Architecture 在 Node 项目里的落地方式,Nest.js 官方文档的架构章节是很好的参照(即使你最终不用 Nest)。
  6. 持续关注生态: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 里是必须刻意补上的一课。

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。