NODE.JS · 18 个高频点
单线程,但不阻塞
Node 面试真正会聊的就三件事:它怎么做到用一个线程扛住并发、
什么时候它会扛不住、以及拿它做服务端渲染时会发生什么。
前两件事的答案都在事件循环里,第三件我放了三节专门讲。
01先猜再看看到代码先自己在心里跑一遍输出,再看下面的结果。
02解释"为什么"能说出机制,才算真的会,光记住现象很快会忘。
03动手跑一遍事件循环那几节,真的要开个终端跑一下,纸上看十遍不如跑一次。
01
"单线程"这三个字骗了很多人
one thread to run js, many to do io
Node 的单线程,指的是你的 JavaScript 代码只在一个线程上执行。
但底下的 I/O(读写文件、DNS、加密)跑在 libuv 的线程池和操作系统的异步机制上。
所以准确说法是:单线程执行 JS,多线程处理 I/O。
你的 JS 代码单线程,一个时刻只做一件事
→
调用 fs.readFile交给 libuv,立刻返回
→
libuv 线程池真正去读磁盘(默认 4 线程)
→
读到数据回调被放进队列
→
事件循环下一轮取出来执行你的回调
实际现象
并发请求 /heavy 和 /quick:
/quick 也要等到 /heavy 算完才返回——它不是被 I/O 卡住的,是被 CPU 计算卡住的
解法:用 worker_threads 把计算挪到别的线程,或者拆成异步分片
- 和浏览器的区别:没有
window/document,但有 fs/net/process;多了 setImmediate 和 process.nextTick;模块系统是 CommonJS(也能用 ESM)。
- Node 擅长什么:I/O 密集(网关、BFF、实时通信、SSR)。不擅长什么:CPU 密集(图像处理、大计算、加解密)。
- 为什么单线程反而快:没有线程切换和锁的开销。并发靠的是"等待 I/O 时不占线程",不是"同时算很多事"。
面试这样答
Node 的单线程是指 JavaScript 执行是单线程的,所有 JS 代码在一个线程上跑,共享一个调用栈。但 I/O 不是——文件读写、DNS 解析、加密这些交给 libuv 的线程池,网络 I/O 交给操作系统的 epoll/kqueue/IOCP,所以主线程不会被阻塞。这也是为什么 Node 适合 I/O 密集型场景,遇到 CPU 密集型任务就会阻塞事件循环,需要 worker_threads 或者拆进程来解决。
02
事件循环:一轮到底走哪几步★
six phases, in order
事件循环不是一个抽象概念,它有明确的六个阶段,每轮按顺序走一遍。
记住顺序,很多"为什么这个回调先执行"的题就不用背了。
1timers
执行到期的 setTimeout / setInterval 回调
2pending callbacks
上一轮被推迟的系统级回调(比如 TCP 连接错误)
3idle, prepare
Node 内部使用,业务代码碰不到
4poll
取新的 I/O 事件,执行 I/O 回调。大部分业务代码在这里被唤醒
5check
执行 setImmediate 的回调
6close callbacks
socket.on('close') 这类关闭回调
↻ 回到 timers,开始下一轮
两个必须记住的补充规则:
- 每个阶段之间都会清空微任务队列:不是"整轮结束才跑微任务",而是每切换一个阶段就跑一次。
- poll 阶段会阻塞:如果 poll 队列空了、没有 setImmediate、timers 也没有即将到期的定时器,事件循环就停在 poll 等 I/O 事件——这就是 Node 空闲时 CPU 占用接近 0 的原因。
面试这样答
Node 的事件循环分六个阶段:timers 处理定时器回调,pending callbacks 处理上一轮延迟的系统回调,idle/prepare 是内部用的,poll 是最核心的——它取 I/O 事件并执行回调,没活干时会阻塞等待,check 处理 setImmediate,close callbacks 处理关闭事件,然后回到 timers 进入下一轮。每个阶段之间都会清空微任务队列,所以 Promise 的回调会在阶段切换的间隙执行,而不是等到整轮结束。
03
nextTick 比 Promise 还早★
the most-asked ordering question
优先级从高到低:同步代码 → process.nextTick → Promise.then(微任务)→ 宏任务。
nextTick 队列在事件循环的每个阶段切换处、微任务之前被清空。
实际输出
1 sync —— 同步代码最先,毫无疑问
2 nextTick —— nextTick 队列优先于微任务
3 promise —— 微任务
4 timeout —— 宏任务,要等下一轮的 timers 阶段
✕ 危险用法
递归调用 process.nextTick 会饿死事件循环——因为 nextTick 队列不空,就永远不会进入下一个阶段,I/O 回调永远等不到。
✓ 它的设计意图
让回调在"当前操作彻底完成之后、I/O 之前"执行。比如在构造函数里触发事件,用 nextTick 保证监听器已经挂好。
△ 可交互:自己跑一遍顺序
浏览器里没有 process.nextTick,所以这里演示的是 同步 → 微任务 → 宏任务 这三层。Node 里 nextTick 会插在微任务之前。
console.log('1 sync');
Promise.resolve().then(() => console.log('2 micro'));
setTimeout(() => console.log('3 macro'), 0);
面试这样答
优先级是同步代码最高,然后是 process.nextTick,再是 Promise 的微任务,最后才是 setTimeout 这类宏任务。原因是 nextTick 队列挂在 Node 的 C++ 和 JS 边界上,在每个阶段切换时、进入微任务队列之前就会被清空,所以它比 Promise 还早。但 nextTick 队列如果被递归地一直塞入,就会饿死事件循环,导致 I/O 回调永远执行不到,所以不要在里面递归调用自己。
04
setTimeout 和 setImmediate,谁先?
depends on where you are
在主模块里,顺序不确定;在 I/O 回调里,setImmediate 永远先。
这不是 bug,是事件循环阶段顺序决定的。
为什么情况二稳定
I/O 回调在 poll 阶段执行。此时当前位于 poll,下一个阶段就是 check(setImmediate),
而 timers 要等下一轮才回到。
→ immediate 必然先
setTimeout(fn, 0) 在 Node 里会被修正为 1ms:小于 1 的值按 1 处理。所以它不是"立即",只是"尽快"。
- 想让回调"当前轮结束立刻执行":用
process.nextTick(最快,但会饿死 I/O)或 setImmediate(安全,让出 I/O)。
- 面试问这个,考的不是记结论:而是看你知不知道 poll 和 check 的相邻关系。
面试这样答
在主模块里执行时顺序是不确定的,因为事件循环进入 timers 阶段时,那个 1ms 的定时器是否已经到期取决于进程启动花了多久。但在 I/O 回调里就确定了:I/O 回调在 poll 阶段执行,而 check 阶段紧跟在 poll 之后,所以 setImmediate 会在同一轮里立刻执行,setTimeout 则要等下一轮回到 timers。这也是为什么在文件读取回调里推荐用 setImmediate 来做"当前轮结束后执行"。
05
libuv 什么时候用线程池
and why 4 is sometimes too few
不是所有异步操作都靠线程池。网络 I/O 用操作系统的多路复用
(Linux 上是 epoll),文件、DNS、加密、压缩才走线程池。
线程池默认 4 个线程。
走线程池
fs.* 大部分文件操作
dns.lookup
crypto.pbkdf2、randomBytes
zlib 压缩
不走线程池
- TCP / UDP 网络通信 → epoll
dns.resolve(用的 c-ares 库)
- 大部分 socket 操作
实际输出(示意)
0 120ms
1 122ms
2 125ms
3 127ms ← 第一批 4 个
4 245ms
5 248ms
6 251ms
7 253ms ← 明显是第二批,在排队
- 调大线程池:环境变量
UV_THREADPOOL_SIZE=16,必须在启动时设置(进程运行中改没用)。
- 不要随便调太大:线程切换也有成本,而且要跟 CPU 核数匹配。通常 4~8 够用。
- 这个问题在 SSR 里会放大:如果渲染过程里有大量同步文件读取(读模板、读 JSON),并发一高就会排队。
面试这样答
libuv 有两种异步手段:网络 I/O 用操作系统的多路复用机制,比如 Linux 的 epoll,一个线程就能监听大量 fd;而文件操作、DNS 的 lookup、加密和压缩这类没有系统级异步接口的操作,会交给内部线程池,默认 4 个线程。所以同时发起超过 4 个文件读取或加密计算时会排队,可以通过 UV_THREADPOOL_SIZE 调整,但要跟 CPU 核数匹配,太大反而增加切换开销。
06
Buffer:堆外的一块字节
outside the v8 heap
Buffer 是 Node 处理二进制的入口,它分配在 V8 堆之外的内存里,
所以不受 V8 堆大小限制,也不参与垃圾回收的常规统计。
关键认知
Buffer.from('你好').length 是 6,不是 2
字符串的 .length 是"字符数",Buffer 的 .length 是"字节数"
→ 计算请求体大小、限流、分片,都要用字节数
- 为什么需要它:JS 原生只有字符串,处理图片、视频、TCP 数据包、加密结果都需要字节数组。
- 堆外的好处:大文件不会撑爆 V8 堆,也不会因为 GC 造成卡顿。
allocUnsafe 为什么快:它从一块预分配的 8KB 内存池里切,省去了分配和清零。但用之前必须自己完整写入,否则会泄露内存里的旧数据。
- 拼接用
Buffer.concat:不要用 + 或者字符串拼接,那会把二进制转成字符串,中文/二进制会损坏。
面试这样答
Buffer 是 Node 提供的二进制数据容器,本质是一块定长的字节数组,分配在 V8 堆外,所以不受堆内存上限约束,也不会给 GC 造成压力。它主要用于处理网络数据包、文件流、加密结果这些非文本数据。Buffer.alloc 会清零所以安全,allocUnsafe 从内存池切一块出来不清零,性能更好但必须自己完整覆盖。要注意字符串长度和字节长度不是一回事,中文一个字通常占 3 个字节。
07
Stream:为什么大文件必须用它★
constant memory + backpressure
流的核心不是"分块读",而是内存占用恒定:
读 2GB 文件,用流也只占几十 KB。这让"处理比内存还大的数据"成为可能。
但只"分块"还不够——如果读取比写入快,数据会在内存里越堆越多。
真正让流稳住的机制叫 背压(backpressure)。
Readable产生数据
→
write() 返回 false下游缓冲区满了(超过 highWaterMark)
→
暂停读取上游 pause()
→
下游消费完触发 drain 事件
→
恢复读取上游 resume()
highWaterMark 默认 16KB:这是"缓冲区多大算满"的阈值,不是"每次读多少"。
- 用
pipeline 而不是 pipe:pipe 不会在出错时销毁其他流,容易泄漏;pipeline 会自动处理错误和清理。
- 对象模式:
{ objectMode: true } 可以让流传对象而不是 Buffer/String,用来传解析后的记录。
面试这样答
流的核心价值是内存占用恒定:它把数据分成小块按需处理,不需要一次性把全部内容读进内存,所以能处理比内存还大的文件。第二个关键是背压机制:当下游的 write 返回 false,说明缓冲区已经超过 highWaterMark,上游会暂停读取,等下游消费完触发 drain 事件再恢复,这样读取速度会自动适配写入速度,避免内存堆积。实际项目里应该用 pipeline 而不是直接 pipe,因为 pipeline 会正确处理错误和资源清理。
08
CommonJS 和 ESM 的四个不同
copy vs live binding
最本质的区别是什么时候解析和导出的是值还是引用。
require 是运行时同步取值(拿到快照),import 是编译时静态绑定(拿到活引用)。
CommonJS
- 同步加载,运行时解析
- 可以写在 if 里、可以拼路径
- 导出的是值的快照
- 不严格模式,
this 指向 exports
- 支持 tree-shaking:不支持
ESM
- 静态解析,编译时确定依赖图
- import 必须在顶层(动态用
import())
- 导出的是活绑定
- 严格模式,
this 是 undefined
- 支持 tree-shaking:支持
- 为什么 ESM 能 tree-shaking:依赖关系在编译时就确定了,打包工具能静态分析出哪些导出没被用到,直接删掉。CommonJS 是运行时 require,分析不出来。
- 什么时候用
import():需要条件加载、按需加载、拿到路径拼装的模块时。它返回 Promise。
- 在 CJS 里用 ESM:Node 22+ 支持
require(esm),但老版本只能动态 import()。
__dirname 在 ESM 里没有:要用 import.meta.url + fileURLToPath 换算。
面试这样答
主要有四个区别。加载时机上,CommonJS 是运行时同步加载,ESM 是编译时静态解析。能力上,CommonJS 可以条件加载、动态拼路径,ESM 的 import 只能写在顶层,动态导入要用 import() 函数。导出语义上,CommonJS 导出的是值的拷贝,所以模块内部后续修改不会反映到导入方;ESM 是活绑定,导入方能看到最新值。还有环境差异,ESM 默认严格模式、this 是 undefined、没有 __dirname 和 require。另外 ESM 的静态结构让 tree-shaking 成为可能。
09
require 做了什么,循环依赖为什么会拿到半个对象
cache is the key
require 四步:路径解析 → 文件定位 → 编译执行 → 写入缓存。
缓存以绝对路径为 key,第二次 require 直接返回缓存,不再执行。
循环依赖能"跑通",靠的也是这个缓存——只是拿到的是还没写完的 exports。
路径解析相对路径 → 绝对路径,处理扩展名
→
查缓存Module._cache[filename] 命中就返回
→
编译包一层函数,注入 5 个参数
→
执行运行模块代码,填充 module.exports
这就是为什么这五个变量在每个模块里都能直接用——它们是函数参数,
不是全局变量。这也是模块作用域隔离的原理。
实际输出
在 b 里,a.done = false
在 a 里,b.done = true
结论:先被执行的那个模块,在对方眼里永远是"半成品"
- 为什么不会死循环:因为 a 已经把
module.exports 放进缓存了(虽然还没填完),b 再 require a 时直接拿到缓存对象。
- 怎么避免:不要在模块顶层互相 require 后立刻使用对方;把互相依赖的部分抽成第三个模块;或者用函数延迟调用(用到时才 require)。
- 循环依赖通常不报错:这是最坑的地方——它可能只是让某个值是 undefined,等到运行时调用才炸。
面试这样答
require 的过程是路径解析、文件定位、编译执行、写入缓存。Node 会把模块代码包在一个函数里执行,注入 exports、require、module、__filename、__dirname 五个参数,这也是模块作用域隔离的原因。循环依赖时,A 执行到一半去 require B,B 又 require A,此时 A 的 module.exports 已经在缓存里了,所以 B 拿到的是 A 当时的状态——可能是个空对象或者只有部分属性。等 A 后续再往 exports 上挂东西,B 因为拿到的是引用所以能看到对象变化,但对基本类型或者已经解构出来的变量就不行。
10
错误处理:哪些能接住,哪些接不住
and why uncaughtException is not a plan
异步错误不会被外层的 try/catch 捕获。
因为回调执行时,try/catch 那个栈早就弹出了。
uncaughtException 是最后一道防线,不是解决方案。
- 为什么必须退出:抛出 uncaughtException 后,当前操作被打断,可能留下未释放的句柄、写了一半的文件、不一致的内存状态。继续服务是危险的。
- EventEmitter 的 'error' 事件是特殊的:如果没有监听器,Node 会把它当作异常抛出,直接崩进程。
- 在集群里由主进程重启:单进程崩了不可怕,可怕的是整个服务没了。所以生产环境一般配合 PM2 / k8s 做进程守护。
- 错误分类处理:可预期的业务错误(参数不对、资源不存在)应该转成 4xx 返回,不要抛到全局;只有真正意外的才进兜底。
面试这样答
Node 里的错误处理要分场景:同步代码用 try/catch,回调用 error-first 约定,Promise 用 catch,async 函数用 try/catch 或者交给上层的 catch。要注意异步回调里抛出的错误是接不住的,因为 try/catch 的调用栈已经结束了。EventEmitter 的 error 事件如果没有监听器会直接抛出导致进程退出,所以流、socket 这类对象一定要挂 error 监听。全局的 uncaughtException 只适合做日志记录和优雅退出,因为此时进程状态已经不可信,不能继续提供服务。
11
cluster、child_process、worker_threads
process vs thread, isolation vs cost
想利用多核:用 cluster 或 worker_threads。
想隔离崩溃风险:用 child_process 或 cluster。
想共享内存、低开销:只能用 worker_threads。
cluster
多进程。主进程 fork 出多个 worker,共享同一个端口。一个 worker 崩了不影响其他,但每个进程有独立内存,通信成本高。
worker_threads
真线程。每个线程有独立事件循环和 V8 实例,但可以通过 SharedArrayBuffer 共享内存,启动和通信开销都比进程小。
选型对照
cluster:HTTP 服务吃满多核,几乎零改造 | 开销:进程级
worker_threads:单次重计算、图片处理、压缩 | 开销:线程级,可共享内存
child_process:调用外部程序、跑一段不信任的代码 | 开销:进程级
PM2 的 cluster 模式底层就是 cluster 模块,不需要自己写
面试这样答
三者解决的是不同问题。cluster 是多进程模型,主进程 fork 多个 worker 共享同一个监听端口,由主进程把连接分发给 worker,适合让 HTTP 服务吃满多核,而且进程之间互相隔离,一个崩了不影响其他。worker_threads 是真线程,每个 worker 有独立的 V8 实例和事件循环,但可以通过 SharedArrayBuffer 共享内存,开销比进程小得多,适合把单次的 CPU 密集计算从主线程挪走。child_process 用于调用外部程序或需要完全隔离的场景。实际项目里如果没有特殊需求,直接用 PM2 的 cluster 模式就够了。
12
内存泄漏:Node 里就那么几种
usually a forgotten reference
JS 有 GC,所以"泄漏"从来不是忘记 free,而是意外的强引用:
有个地方一直拿着你不用的对象,GC 就不敢回收。
✕ 弱引用用错地方
WeakMap 的 key 是弱引用,对象被回收条目就消失。不能用它做缓存——你想要的"缓存命中"恰恰需要强引用。
✓ 排查顺序
先看 process.memoryUsage() 的 trend,再用 --inspect + DevTools 抓两次 heap snapshot 做 diff,看哪个构造函数在增长。
- 先确认是不是真泄漏:内存涨了然后回落是正常的(GC 没来得及跑)。要看的是"反复压测后,每次 GC 后的水位是否持续上升"。
- SSR 服务的泄漏更致命:因为服务是常驻的,每次请求泄漏一点,几天后必然 OOM。所以 SSR 里尤其要注意:模块级缓存、请求上下文挂在全局、闭包持有 req/res。
- 不要用
--max-old-space-size 掩盖问题:调大只是延后崩溃的时间点。
面试这样答
Node 的内存泄漏基本都是意外的强引用,常见有四类:挂到全局对象上的数据、没有移除的事件监听器、没有清理的定时器、以及没有容量上限的缓存。排查方法是先用 process.memoryUsage 观察长期趋势,确认是持续上升而不是正常波动,然后用 --inspect 连 Chrome DevTools,在不同时间点抓两次 heap snapshot 做对比,按 Delta 排序找出增长最快的构造函数,再顺着引用链找到持有它的地方。SSR 场景要特别注意请求上下文不能被模块级变量持有。
13
性能:先量,再改
measure, don't guess
性能问题的第一原则是不要凭直觉优化。
先用工具找到真正的瓶颈,再动手。否则很容易优化了不重要的 5%,而漏掉 80%。
1. 压测establish a baseline
autocannon / k6 / wrk
→
2. Profiling找 CPU 热点
--prof / clinic / 0x
→
3. 定位是 CPU、I/O 还是 GC
→
4. 改一处一次只改一个变量
→
5. 复测同一条件再压一遍
- 看 P99,不要看平均值:平均 20ms 但 P99 是 2s,说明有 1% 的用户体验极差。平均值会掩盖长尾。
- 先怀疑同步 API:
fs.readFileSync、crypto.pbkdf2Sync 在请求路径上就是灾难。
- 再怀疑 N+1 查询:循环里发数据库请求,是后端最常见的性能杀手。
- 然后看 GC:频繁创建大对象会让 GC 频繁触发,表现为"CPU 不高但吞吐上不去"。
- 最后才考虑 cluster:单核都没跑满就加进程,只是把浪费乘以核心数。
面试这样答
我的顺序是先压测建立基线,看吞吐和 P99 延迟,而不是看平均值。然后用 profiling 工具比如 clinic flame 或者 --prof 找 CPU 热点,确认瓶颈是在 CPU 计算、同步 I/O、GC 还是数据库等待。定位之后再改一处、复测一处,避免同时改多个变量导致无法归因。常见的优化点是去掉请求路径上的同步 API、解决循环里的 N+1 查询、加缓存、用流式处理大响应。如果是 CPU 打满再考虑 worker_threads 或 cluster,如果单核都没满就加进程没有意义。
14
安全:Node 服务端的基本盘
the checklist interviewers expect
面试问安全,通常不是要你讲得多深,而是看你有几个具体动作。
能说出五条具体措施,比背一堆名词有用。
- HTTP 头:用
helmet 一键设置 CSP、X-Frame-Options、HSTS、X-Content-Type-Options。
- SQL 注入:永远用参数化查询或 ORM,不要字符串拼 SQL。
- XSS:输出到 HTML 时转义;Rich Text 用白名单过滤(DOMPurify);设置 CSP 兜底。
- CSRF:用 SameSite Cookie + CSRF Token;纯前后端分离用 Token 放在 Header 里也能规避。
- 限流:按 IP / 用户做 rate limit,防止暴力破解和刷接口。
- 不泄露信息:生产环境关掉堆栈输出,错误响应只说"服务器错误",细节写日志。
- 依赖安全:
npm audit、lock 文件锁版本、定期升级。
SSR 场景额外要注意
服务端也有 XSS:模板拼接时没转义,服务端直接把恶意 HTML 发给所有用户
state 注入:<script>window.__STATE__ = {...}</script> 里的 JSON 要转义 <,否则能闭合标签注入脚本
面试这样答
我一般从几个层面说:传输和头部用 helmet 设置 CSP、HSTS 这些;输入层做校验和限流,限制请求体大小;数据库层坚持参数化查询或走 ORM,杜绝拼接 SQL;输出层对用户内容转义,富文本走白名单过滤;认证授权用 HttpOnly + SameSite 的 Cookie,敏感操作加 CSRF Token;错误处理上对外只返回通用信息,完整堆栈只进日志;运维层面用 npm audit 和锁定依赖版本。SSR 场景还要额外注意序列化到页面的初始 state 必须转义,否则会有注入风险。
15
CSR / SSR / SSG / ISR 到底差在哪★
where the html is built
四个概念的区别只有一个维度:HTML 是在什么时候、在哪里生成的。
在浏览器生成是 CSR,每次请求在服务端生成是 SSR,
构建时生成是 SSG,构建时生成但能后台更新是 ISR。
CSR浏览器拿到空壳 HTML
JS 下载后才开始渲染
→
SSR服务端每次请求
现场生成完整 HTML
→
SSG构建时就生成好
CDN 直接发静态文件
→
ISR构建时生成 + 过期后
后台重新生成
SSR 解决什么
- 首屏速度:不用等 JS 下载执行就能看到内容
- SEO:爬虫拿到的是完整 HTML
- 社交分享:OG 标签能被正确抓取到
SSR 的代价
- 服务端要有渲染能力,CPU 开销大
- 不能用
window/document,代码要同构
- 需要处理水合、状态序列化、缓存
- 部署变复杂(不再是纯静态)
- SSR 不等于"更快":它的 TTFB 比 SSG 慢(要现场渲染),但比 CSR 快(不用等 JS)。对交互密集的后台系统,SSR 意义不大。
- SSG 是最好的:纯静态、CDN 缓存、零服务端渲染成本。适合博客、文档、营销页。
- ISR 是折中:静态 + 定期再生,适合"内容变化不频繁但会变"的页面,比如商品详情。
- 可以混用:同一个应用里首页用 SSG、商品页用 ISR、用户中心用 SSR、后台用 CSR,这是现在主流的做法。
面试这样答
区别在于 HTML 生成的位置和时机。CSR 只返回一个空壳,所有渲染都在浏览器里由 JS 完成,首屏白屏时间长、SEO 差。SSR 是服务端在每次请求时把组件渲染成 HTML 字符串返回,用户立刻能看到内容,爬虫也能拿到完整 HTML,代价是服务端有渲染开销,而且代码要能在没有 window 的环境里跑。SSG 是构建时就生成好静态 HTML,性能最好成本最低,适合内容不常变的页面。ISR 介于两者之间,静态生成但可以设定过期时间在后台重新生成。实际项目通常是按路由混用这几种策略。
16
用 Node 手写一个 SSR★
renderToString + hydrate
SSR 的骨架非常朴素:在服务端把组件渲染成 HTML 字符串,塞进模板返回;
然后在客户端用 hydrate 接管这堆已经有内容的 DOM,
只绑定事件,不重建。
那个 .replace(/</g, '\u003c') 是为了什么
如果 initialState 里有字符串 "</script><script>alert(1)</script>",
直接 JSON.stringify 会把它原样输出,浏览器会在这里闭合 script 标签并执行注入的代码。
把 < 转成 \u003c 之后,它在 JS 字符串里仍然是 <,但不会再被 HTML 解析器识别为标签。
✕ 用 createRoot 会怎样
它会把服务端渲染好的 HTML 全部丢掉重建,用户会看到内容闪一下重绘,而且白费了 SSR 的全部收益。
✓ hydrateRoot 做的事
复用现有的 DOM 节点,只绑定事件处理器、恢复状态。前提是两边渲染结果必须一致,否则会出现水合不匹配警告并退回客户端渲染。
- 为什么数据要在服务端取一次、再传给客户端:否则客户端会再请求一次同样的数据,既慢又可能不一致(服务端渲染的是 A,客户端拿到 B,直接水合失败)。
- 服务端没有
useEffect:它只在客户端跑。所以依赖 effect 才能出现的 UI(比如懒加载组件、需要 window 的逻辑)在服务端 HTML 里是不存在的。
- 服务端渲染是同步的:
renderToString 一口气算完,不等待任何异步。所以数据必须在渲染之前准备好。
- 每个请求都要新建 store:把 store 定义在模块顶层,会导致所有用户共享同一份数据——这是 SSR 最容易犯的错。
面试这样答
SSR 的流程是在服务端用 renderToString 把组件树渲染成 HTML 字符串,同时把这个页面需要的数据序列化到 window 上,一起返回给浏览器。客户端加载 JS 后用 hydrateRoot 而不是 createRoot,因为 hydrate 会复用已有的 DOM 只绑定事件,createRoot 会把服务端的结果全部丢弃重建。要注意几个点:数据必须在渲染前准备好,因为 renderToString 是同步的;每个请求都要创建独立的 store,不能放在模块顶层共享;序列化到页面的 JSON 必须把尖括号转义,否则会有 XSS 注入风险;服务端没有 window 和 useEffect,代码要能做环境判断。
17
SSR 真正的坑:同构、流式、缓存★
where production breaks
跑通一个 SSR demo 只要半小时,把它放到生产环境却会遇到三件事:
服务端没有浏览器 API、渲染阻塞导致 TTFB 变差、
每个请求都渲染导致 CPU 打满。
坑一:同构代码里的环境判断
注意:如果某个值在服务端和客户端算出不同结果(比如用 Date.now()、
Math.random()、浏览器语言),水合就会不匹配。
这比直接报错更难查,因为它只在控制台留一条警告,界面可能"看着正常"但事件绑定错位。
坑二:同步渲染让 TTFB 变差
流式渲染的收益
传统 SSR:渲染完所有数据 → 才发第一个字节(TTFB 差)
流式 SSR:先发 head 和骨架 → 快组件先到 → 慢组件后补(TTFB 好,配合 Suspense 尤其实用)
坑三:每个请求都渲染,CPU 扛不住
- 页面缓存要有失效策略:上面那个 Map 就是前面说的"无上限缓存"泄漏。生产要用带 TTL 的方案(Redis、lru-cache)。
- 缓存要区分用户:登录态、个性化推荐的页面不能共用一份 HTML,按用户或按"匿名/登录"分桶。
- SSR 服务要单独部署:它吃 CPU,跟 API 服务混在一起会互相拖累。常见做法是 SSR 层 + API 层分开,SSR 层可以水平扩容。
- 降级方案必须有:渲染超时或出错时,直接返回 CSR 的空壳(SPA fallback),让客户端自己渲染。宁可慢,不能挂。
面试这样答
生产环境的 SSR 主要有三类问题。第一是同构问题,服务端没有 window、document、localStorage,代码里要么做环境判断,要么把浏览器相关逻辑放进 useEffect 或者用 ssr: false 动态加载;更麻烦的是两边渲染结果不一致导致水合失败,所以不能用 Date.now 这类不确定的值参与初始渲染。第二是 TTFB,renderToString 是同步的,要等整棵树算完才发第一个字节,React 18 的 renderToPipeableStream 可以配合 Suspense 先发骨架再流式补充。第三是 CPU 成本,需要做页面级、组件级或数据级缓存,并且要有降级方案——渲染失败时返回 CSR 空壳,不能让页面直接挂掉。
18
Koa 与洋葱模型★
中间件为什么能"回来"
Koa 的中间件是一组 async 函数,用 compose 串起来。await next() 之前的代码顺序执行,之后的代码逆序执行——这就是洋葱模型。Express 做不到这件事,因为它的中间件是回调,没有"回来"的时机。
执行顺序(三层中间件)app.use(async (ctx, next) => { log('A1'); await next(); log('A2'); });
app.use(async (ctx, next) => { log('B1'); await next(); log('B2'); });
app.use(async (ctx) => { log('C'); });
输出:A1 B1 C B2 A2
这就是洋葱:请求从外往里穿,响应从里往外回。
所以"统一耗时统计""统一错误捕获""统一响应包装"
这些需求才有地方写——必须在 await next() 之后。
- Express 为什么做不到:它的中间件是
(req, res, next) => {},调用 next() 之后控制权就交出去了,不会回来。想做"响应后统计耗时"只能监听 res.on('finish'),很不优雅。 - next() 只能调一次:调两次会让同一个下游执行两遍。上面 compose 里那个
index 判断就是防这个的。 - 错误捕获:Koa 里
try { await next() } catch (e) {} 能捕获所有下游的同步和异步错误,因为整条链都是 await 出来的。这比 Express 的 (err, req, res, next) 四参 handler 直观得多。 - ctx 是包装过的对象:
ctx.req 是原生 Node 请求,ctx.request 是 Koa 封装的。写代码一般用 ctx.request.body / ctx.body(后者其实是设置响应体)。 - Nest 里也有类似的层:Guard、Interceptor、Pipe 的执行顺序本质上也是这种"前后各一段"的结构,拦截器里的
next.handle() 就相当于 Koa 的 next。
面试怎么答Koa 的中间件是一组 async 函数用 compose 串起来的,await next() 之前的代码顺序执行,之后的代码逆序执行,这就是洋葱模型。它能做 Express 做不到的事,比如在响应返回后统计总耗时、统一包装响应体,因为这些代码必须写在 await next() 之后。实现关键是 compose 把下一个 dispatch 当作 next 传进去。Express 做不到是因为它的中间件是回调,next() 之后控制权交出去就不会回来。另外 next 只能调一次,否则下游会执行两遍。
19
速答清单
last 30 minutes before the interview
考前扫一遍。卡住的回去看对应那节。
QNode 是单线程的吗?
执行 JS 是单线程的,但 I/O 不是。文件、DNS、加密走 libuv 线程池,网络走操作系统的 epoll/kqueue。所以准确说是"单线程执行 JS,多线程处理 I/O"。
Q事件循环有几个阶段?
六个:timers、pending callbacks、idle/prepare、poll、check、close callbacks,然后回到 timers。每两个阶段之间会清空微任务队列。poll 最核心,没活干时会阻塞等待 I/O。
QnextTick 和 Promise 谁先?
nextTick 先。它的队列挂在 C++ 和 JS 的边界上,在进入微任务队列之前就被清空。完整优先级:同步 → nextTick → Promise → 宏任务。但递归 nextTick 会饿死事件循环。
QsetTimeout(fn, 0) 真的是 0 毫秒吗?
不是,Node 会把小于 1 的值修正为 1ms。而且它受事件循环阶段影响,在主模块里和 setImmediate 的顺序不确定,但在 I/O 回调里 setImmediate 一定先。
Q什么会阻塞事件循环?
CPU 密集计算、同步 API(readFileSync、pbkdf2Sync)、超大的 JSON.parse、正则灾难性回溯。解决方案是 worker_threads 或拆成异步分片。
QStream 的背压是怎么实现的?
下游 write 返回 false 表示缓冲区超过 highWaterMark,上游收到后 pause,等下游消费完触发 drain 事件再 resume。这样读取速度自动匹配写入速度,内存不会堆积。
QCommonJS 和 ESM 最本质的区别?
加载时机和导出语义。CJS 运行时同步加载、导出值拷贝;ESM 编译时静态解析、导出活绑定。ESM 的静态结构才让 tree-shaking 成为可能。
Q循环依赖为什么不会死循环?
因为模块在开始执行前就写进了 Module._cache。B 再 require A 时直接拿到缓存里那个还没填充完的 exports,所以拿到的是"半成品",不会重新执行。
Qcluster 和 worker_threads 怎么选?
cluster 是多进程,适合让 HTTP 服务吃满多核,进程间隔离、一个崩了不影响其他;worker_threads 是真线程,开销小、能共享内存,适合把单次 CPU 密集计算挪出主线程。想隔离崩溃用进程,想低开销用线程。
QSSR 的 HTML 是在哪生成的?
在服务端、每次请求时现场生成。这也是它和 SSG 的区别——SSG 在构建时就生成好,请求来了直接发静态文件,没有渲染开销。
Qhydrate 和 render 有什么区别?
render 会创建全新 DOM,把服务端的结果丢掉;hydrate 复用已有 DOM,只绑定事件和恢复状态。SSR 必须用 hydrate,否则白做,还会让用户看到闪烁。
Q水合不匹配是怎么产生的?
服务端和客户端首次渲染的结果不一致。常见原因:用了 Date.now / Math.random 参与渲染、根据 window 或 localStorage 决定内容、服务端和客户端数据不同步、HTML 嵌套不合法(比如 p 里套 div 被浏览器自动修正)。
QSSR 为什么容易内存泄漏?
因为服务是常驻进程,每个请求泄漏一点会不断累积。典型原因是把请求上下文(req/res/store)挂到模块级变量上,导致所有请求的数据都被保留。所以每个请求都必须创建独立的 store。