2009年 CommonJS(Node.js)
这一步是“前端模块化”真正走向 标准化 的开端。
问题背景:为什么出现 CommonJS?
IIFE 虽然能封装作用域,但随着项目越来越大,仍然存在严重问题:
- 依赖管理混乱:如果 A 模块要依赖 B 模块,就必须先手动在页面
<script>里写<script src="b.js">,并且确保顺序正确。 - 难以复用:代码没法轻易在不同项目之间共享。
- 无法按需加载:所有脚本都要一次性加载,浪费性能。
👉 所以,社区开始思考:能不能有一个模块系统,让每个文件独立,彼此通过明确的接口交互?
于是,CommonJS 规范 在 2009 年被提出,而第一个真正的实践者就是 Node.js。
什么是 CommonJS?
CommonJS 是一个 JavaScript 模块化规范。
它定义了一套规则:
- 一个文件就是一个模块
- 模块内部定义的变量都是私有的
- 想要暴露内容 → 用
module.exports - 想要使用别人暴露的内容 → 用
require()
👉 换句话说,它给 JavaScript 加上了“导入/导出”机制。
CommonJS 的核心语法
(1) 导出模块
// math.js
function add(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
module.exports = { add, sub };
(2) 引入模块
// app.js
const math = require('./math');
console.log(math.add(2, 3)); // 5
console.log(math.sub(5, 2)); // 3
👉 关键点:
module.exports用来 导出require()用来 引入
CommonJS 和 Node.js 的关系
CommonJS 是“规范”
- 背景:2009 年左右,JavaScript 还没有官方模块系统。
- 目标:CommonJS 组织提出一个“在非浏览器环境使用 JS 的模块规范”。
- 核心设计:
require()导入模块。module.exports/exports导出模块。- 模块加载是 同步的。
- 本质:它是一个 文档标准,而不是某个具体实现。
Node.js 是“实现”
- 背景:2009 年 Ryan Dahl 发布 Node.js,让 JS 能在操作系统里跑(基于 V8 + libuv)。
- 做法:Node.js 需要一个模块化方案来组织代码。
- 选择:Node.js 采纳并实现了 CommonJS 规范,把它变成实际可用的模块系统。
所以我们在 Node.js 里写的:
const fs = require('fs');
module.exports = { foo: 123 };
👉 其实是 Node.js 实现的 CommonJS 在起作用。
两者关系总结
- CommonJS = 一套规范(思想/标准)。
- Node.js = 一个运行时环境,它 采用并实现了 CommonJS 模块系统(在文件级别加包装函数、提供
require等)。 - 因此我们常说:
- Node.js 内置的模块系统 = CommonJS 实现。
补充
- 随着 ESM 成为标准,Node.js 也在逐步支持 ESM(
.mjs文件 /type: "module")。 - 所以现在 Node.js 里,既能用 CommonJS,也能用 ESM,但长期方向是鼓励用 ESM。
📌 直白类比:
- CommonJS 就像 蓝图(建筑设计图)。
- Node.js 就像 房子(具体施工建成的建筑)。
- Node.js 按照 CommonJS 的蓝图,建造了自己的模块化系统。
CommonJS 的原理
初步认识
Node.js 在运行时,会对模块做一层“包装”,原理大致是:
// 假设 math.js
(function(exports, require, module, __filename, __dirname) {
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
module.exports = { add, sub };
});
- 每个文件会被包装在一个函数里,形成私有作用域(解决全局污染问题)。
- 当你
require('./math')时,Node 会:- 读取
math.js文件内容 - 执行这个包装函数
- 返回
module.exports对象给调用方
- 读取
👉 所以,CommonJS 本质上是 同步加载 + 缓存机制:
- 同步:代码按顺序执行,必须等模块加载完才能继续。
- 缓存:第一次加载后会缓存,避免重复加载浪费性能。
CommonJS 的本质:
Node 通过“给每个文件套一个函数” → 保证作用域独立,
再通过“require + module.exports” → 让文件之间能互相导入导出,
运行时用“同步 + 缓存”机制保证效率和安全。
问题深入
为什么要“包装”?
在没有模块化之前,所有 JS 文件都是全局执行:
// a.js
var count = 1;
// b.js
var count = 2; // 💥 覆盖了 a.js 的 count
👉 如果把很多脚本直接放一起,全局变量就会互相污染。
解决办法:给每个文件套一层“函数作用域”,这样里面的变量就不会跑到全局去了。
Node.js 是怎么做的?
Node 在底层加载一个 JS 文件时,并不是直接执行,而是 偷偷包上一层函数,像这样:
(function(exports, require, module, __filename, __dirname) {
// 这里是你写的 math.js 的代码
})();
所以每个模块的代码,实际上都运行在一个独立的函数里:
exports、require、module、__filename、__dirname都是 Node 自动塞进去的参数。- 这就是为什么你能在每个文件里直接用这些变量。
require 的执行过程
假设你写了两个文件:
math.js
function add(a, b) { return a + b; }
module.exports = { add };
app.js
const math = require('./math');
console.log(math.add(2, 3)); // 5
当你运行 node app.js 时,底层发生了什么?
步骤:
- 读取文件内容
Node 读到math.js的内容:
function add(a, b) { return a + b; }
module.exports = { add };
- 包装成函数
Node 把它包进:
(function(exports, require, module, __filename, __dirname) {
function add(a, b) { return a + b; }
module.exports = { add };
})
- 执行这个函数
执行时,Node 会准备好一个空的module.exports = {},然后把它传进去。
执行完后,module.exports = { add }。 - 返回结果
require('./math')就会拿到module.exports,即{ add }。
所以math.add(2,3)就能用了。
为什么说是“同步加载 + 缓存”?
- 同步:
require()读文件、执行、返回结果 → 这一切必须完成,代码才会继续往下走。
const math = require('./math'); // 必须等 math.js 执行完
console.log('后面的代码才会执行');
- 缓存:同一个模块只会执行一次,第二次
require时直接读缓存。
const math1 = require('./math');
const math2 = require('./math');
console.log(math1 === math2); // true(同一个对象)
👉 这保证了性能和一致性。
包装函数
参数的由来
这 5 个参数 (exports, require, module, __filename, __dirname) 都是 Node.js 在“包装函数”里 自动注入的变量。虽然你写代码时看不见,但它们其实都在。
Node.js 在执行任何一个模块文件时,都会先做这件事:
把文件内容读出来,然后包装成类似这样的函数:
(function(exports, require, module, __filename, __dirname) {
// 这里是你写的代码
});
然后执行这个函数,并且把对应的实参传进去:
exports→ 指向module.exports(初始时两者等价)require→ Node 内置的加载函数module→ 当前模块对象(里面有exports、id、loaded等信息)__filename→ 当前模块的完整路径(包含文件名)__dirname→ 当前模块所在目录的完整路径
为什么有 exports 和 module.exports 两个?
这是 Node 给开发者的 两种写法选择,但底层其实只认 module.exports。
初始关系
一开始,Node 帮你做了:
exports = module.exports = {};
所以一开始它们指向同一个对象。
用法 1:给对象添加属性
exports.add = (a, b) => a + b;
// 相当于 module.exports.add = ...
用法 2:整体替换对象
module.exports = function(a, b) { return a + b; };
// ❌ 注意:如果写成 exports = function() {...} 是没用的
👉 因为 exports 只是一个变量,它本质上是 module.exports 的快捷引用。
如果你直接 exports = xxx,只是改了 exports 的指向,而 module.exports 还留着原来的对象。
最终返回的还是 module.exports,所以就失效了。
结论:
- 推荐用
module.exports来导出(更直观,不容易踩坑)。 exports适合只往里面挂属性。
其他几个参数的作用
require
- 是一个函数,用来加载其他模块。
- 底层会去磁盘找对应的文件,包装执行后返回
module.exports。 - 支持相对路径(
./foo)、绝对路径(/usr/lib/foo)、和内置模块(fs,http)。
module
- 当前模块的描述对象。
- 里面最重要的属性就是
module.exports。 - 你也可以用它查看一些信息:
console.log(module.id); // 模块 ID
console.log(module.filename); // 模块绝对路径
console.log(module.loaded); // 是否加载完成
__filename
- 当前文件的绝对路径,例如:
/Users/xxx/project/app.js
__dirname
- 当前文件所在目录,例如:
/Users/xxx/project
一个小实验
你可以新建一个 demo.js,写:
console.log(exports);
console.log(module.exports);
console.log(exports === module.exports); // true
exports.a = 1;
module.exports.b = 2;
console.log(module.exports); // { a: 1, b: 2 }
exports = { c: 3 };
console.log(module.exports); // 依然是 { a: 1, b: 2 }
👉 这就能清楚看到:
- 改
exports.xxx = ...会同步到module.exports。 - 但直接
exports = ...不会影响最终导出的结果。
隔离的本质——函数作用域
- 说白了,就是一个文件,一个模块,一个模块,即一个对象;
- 每个对象,内部放一堆成员变量或者成员函数,反正对象不一样,不同对象内部的成员,自然也就隔离了;
- 换句话说,不同对象就是不同的容器盒子,有了容器盒子,就不用统统散落在桌面上了,就算有一样的物件,一个放左边的盒子,一个放右边的盒子,不就能区分了吗?
没有模块化时的情况
假设我们有两个文件:
// a.js
var count = 1;
// b.js
var count = 2;
console.log(count);
如果直接在浏览器 <script> 引用,或者在 Node 里用 eval 拼到一起:
var count = 1;
var count = 2;
console.log(count); // 2
👉 结果:变量相互覆盖,全局污染,没法隔离。
Node.js 的做法:包一层函数
Node 加载模块时,并不是直接把 a.js 拼到全局执行,而是偷偷给它加了一层函数:
(function(exports, require, module, __filename, __dirname) {
// 这里是 a.js 的内容
var count = 1;
})(module.exports, require, module, "a.js的绝对路径", "a.js所在目录");
这意味着:
count现在是这个函数作用域的局部变量- 它不会泄漏到全局
- 你只能通过
module.exports暴露想要给外部用的东西
为什么函数能隔离?
因为 函数有自己的作用域,它里面的变量不会影响外部。
举个小例子:
function foo() {
var a = 10;
}
foo();
console.log(a); // ❌ 报错,a 不存在
👉 Node 模块就是把你的代码塞进一个函数里,起到同样的隔离效果。
函数 + 对象导出机制
光有“隔离”还不够,模块之间还要能 通信。
所以 Node 规定:
- 每个函数里都自动带一个对象:
module.exports - 你写代码时,如果想让外部能用,就挂到这个对象上
- 其他文件用
require()时,返回的就是这个对象
换句话说:
- “函数”解决了隔离问题
- “module.exports / require” 解决了通信问题
一个直观的对比
如果没有包装:
// math.js
var add = (a, b) => a + b;
// app.js
var add = (a, b) => a - b; // 覆盖
console.log(add(2, 3)); // -1
有包装后:
// math.js
(function(exports, require, module) {
var add = (a, b) => a + b;
module.exports = { add };
});
// app.js
(function(exports, require, module) {
var add = (a, b) => a - b;
module.exports = { add };
});
👉 两个 add 在不同的函数作用域里,不会互相污染。
应用层代码只能通过 require('./math') 拿到 { add },而不是直接访问到 math.js 的局部变量。
总结一句话
是的,本质上就是用函数作用域隔离。
Node 模块系统的关键机制可以概括成两步:
- 隔离:给每个文件包一层函数,避免全局变量污染。
- 通信:通过
module.exports暴露接口,require()返回接口对象。
CommonJS 的导出机制
先回顾 CommonJS 的核心
- 每个模块里有个唯一的
module.exports对象。 - 当你在另一个文件里
require()时,Node.js 返回的就是这个对象。 - Node.js 会缓存这个对象,避免重复执行模块。
👉 所以关键在于:
require() 返回的module.exports是指向模块中**module.exports**的引用,但该对象内的基础类型是值拷贝。
举个例子(引用传递)
// counter.js
let count = 0;
function inc() { count++; }
function get() { return count; }
module.exports = { inc, get };
// app1.js
const counter = require('./counter');
counter.inc();
console.log(counter.get()); // 1
// app2.js
const counter = require('./counter');
console.log(counter.get()); // 1 (不是 0!)
👉 可以看到:
app1.js改变了count,app2.js立刻能感知到,
说明两者拿到的是同一个module.exports,共享状态。
如果是深拷贝,app2.js 就应该还是 0。
为什么不是深拷贝?
因为深拷贝会带来两个问题:
- 性能开销:每次 require 都复制一份,模块越大越慢。
- 状态不同步:有时我们需要模块维护内部状态(比如数据库连接池、缓存、计数器),如果每次 require 都是独立副本,就无法共享。
所以 Node 采用了更合理的设计:
- 只执行一次模块代码
- 结果对象放进缓存
- 后续所有
require拿的都是这个缓存对象的引用
一个小陷阱:基础类型 vs 引用类型
// foo.js
let num = 1;
module.exports.num = num;
// app.js
const foo = require('./foo');
console.log(foo.num); // 1
foo.num = 99;
console.log(require('./foo').num); // 99 吗?❌ 是 1
为什么?
- 因为
module.exports.num = num时,num是一个 基础类型的值拷贝(1)。 - 之后修改
foo.num并不会改动foo.js内部的num变量。
👉 要想共享状态,必须导出引用:
// foo.js
let num = { value: 1 };
module.exports = num;
现在 require('./foo') 得到的就是同一个对象引用,大家改的是同一份。
当然,还有另一种方案——通过函数访问:
// counter.js
let count = 0;
function inc() { count++; }
module.exports = { get count() { return count }, inc };
为什么?因为这里用了 getter,本质还是「通过函数访问模块内部的真实变量」,所以表现得像引用。
👉 但这是我们手动写 getter 实现的,CommonJS 自身不保证这一点。
总结
require()返回的是 module.exports 的引用,不是深拷贝。- 模块只会执行一次,结果会缓存,后续 require 都拿缓存。
- 引用类型能共享状态,基础类型只能拷贝当前值
Node.js的出现
初步认识
Node.js 出现的背景:解决了什么问题?
在 2009 年,前端开发的场景是这样的:
- JavaScript 只能运行在浏览器里,用来写交互效果。
- 后端开发 用 PHP、Java、Python 等语言负责业务逻辑。
- JavaScript 无法直接在服务器端运行,所以前端与后端语言是割裂的。
而这带来几个痛点:
- 上下文割裂:前端、后端用不同的语言,开发者切换思维成本很高。
- 并发性能问题:当时的服务器大多是阻塞式 I/O(一个请求没完成,下一个就卡着)。
- 实时性不足:聊天室、协作工具需要高并发、长连接,而传统后端实现代价大。
于是,Ryan Dahl 提出了一个新思路:
👉 能不能让 JavaScript 脱离浏览器,在服务器上跑?
这就是 Node.js。
什么是 Node.js?
一句话概括:
Node.js = V8 引擎 + 异步 I/O 库(libuv)
- V8 引擎:Google 开发的高性能 JavaScript 引擎(原本用在 Chrome),让 JS 执行速度接近原生 C++。
- libuv:一个用 C/C++ 写的跨平台库,负责非阻塞 I/O、事件循环、线程池等底层能力。
Node.js 就是把两者结合:
- 用 V8 执行 JS 代码
- 用 libuv 处理文件、网络等系统调用
- 再通过 C++/JS 绑定层(bindings)把能力暴露给 JS 使用
Node.js 的运行机制(核心原理)
Node.js 的运行机制核心是 事件驱动 + 非阻塞 I/O。
流程大致如下:
- 启动:运行
node app.js,V8 载入并执行 JS 代码。 - 注册任务:代码里遇到 I/O 操作(比如读文件、发 HTTP 请求),Node 不会阻塞等待,而是交给 libuv。
- 事件循环(Event Loop):libuv 负责轮询底层 I/O 完成情况,并把完成后的回调加入队列。
- 回调执行:V8 继续执行 JS,当事件循环发现队列里有回调,就交给 V8 执行。
👉 这样,Node.js 就能高效处理成千上万的并发请求,而不是一个个顺序等待。
Node.js 的模块系统
Node.js 内置了 CommonJS 模块系统(前面我们讲过):
- 每个文件就是一个模块
- 用
require引入,用module.exports导出 - 模块只执行一次,结果缓存,大家共享
后来为了支持 ES6,Node 也逐步支持了 ESM(import/export)。
Node.js 的应用场景
- 高并发场景:即时通讯(如聊天室、协作工具)、WebSocket 服务器
- API 层:作为前端与后端服务之间的 BFF(Backend for Frontend)
- 工具链:Webpack、Vite、ESLint、Babel 等前端工具几乎都基于 Node
- 全栈开发:前端 + Node.js 后端,统一用 JavaScript
一个简单例子
// server.js
const http = require('http');
// 创建一个 HTTP 服务器
const server = http.createServer((req, res) => {
res.end('Hello Node.js');
});
// 监听端口
server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
});
运行:
node server.js
然后访问 http://localhost:3000,就能看到输出。
这里:
http模块来自 Node 内置库- 请求到来时,回调会被事件循环调度执行
- Node 用非阻塞 I/O 支撑并发
总结
- Node.js 的本质:不是一门语言,而是一个运行时环境(runtime)。
- 它解决的问题:让 JavaScript 能跑在服务器端,并用事件驱动模型高效处理 I/O。
- 核心原理:V8 负责 JS 执行,libuv 负责异步 I/O,事件循环连接两者。
- 影响:让 JS 成为全栈语言,推动了前端工程化的兴起(如 npm、Webpack、Vite 都依赖 Node)。
Node.js 的运行机制
Node.js 的设计思路
在 2009 年,传统后端服务器(PHP、Java、.NET)大多采用 阻塞式 I/O 或 多线程模型。
- 阻塞 I/O:一个请求来了,如果要读文件/查数据库,进程会停下来等。
- 多线程:一个请求一个线程,高并发时开几万个线程,内存和上下文切换的开销巨大。
Ryan Dahl 提出新模式:
👉 单线程 + 事件驱动 + 非阻塞 I/O
- 单线程避免多线程切换的性能开销。
- 事件驱动机制,让 I/O 不阻塞主线程。
- I/O 操作交给操作系统/线程池异步完成,完成后触发回调。
这样,Node.js 就能在轻量的线程资源下,处理海量并发。
Node.js 的运行机制全流程
下面是一个典型的执行流程:
- 启动 Node.js 程序
- V8 加载并执行 JS 代码。
- 代码里可能有同步操作和异步操作。
- 执行同步代码
- 立即执行,主线程不会等。
- 遇到异步任务
- 比如
fs.readFile、setTimeout、http.request - Node.js 会调用 libuv,把任务交给操作系统或线程池处理。
- 主线程继续往下跑,不会阻塞。
- 比如
- 事件循环(Event Loop)
- libuv 维护一个任务队列(事件队列)。
- 当异步任务完成后,它们的回调会被放入队列。
- Event Loop 会不断检查队列,把回调交给 V8 执行。
- 回调执行
- 回调函数在 JS 主线程里执行(不是在 I/O 线程池里)。
- 回调执行后可能再注册新的任务,循环继续。
事件循环(Event Loop)细节
事件循环是核心, 这是 Node.js 能够“单线程处理高并发”的核心所在。
为什么需要事件循环?
在浏览器和 Node.js 里,JavaScript 都是 单线程 的。
- 单线程的好处:避免多线程共享数据导致的复杂问题(比如加锁、死锁)。
- 单线程的坏处:如果某个任务执行很久(比如文件读取、网络请求),主线程就会被卡死。
👉 解决思路:让“耗时任务”交给别人去做,主线程只管调度回调。
- 浏览器里:Ajax 请求、setTimeout 都是异步,背后由浏览器内核调度。
- Node.js 里:由 libuv 提供事件循环机制,负责调度异步任务。
Node.js 事件循环的基本结构
事件循环可以想象成一个“永动机”:
while (程序未退出) {
1. 取出队列中的任务
2. 按阶段执行不同的回调
3. 检查是否有新的任务
}
每一次循环,称为一个 tick。
在一个 tick 中,Node.js 会按照不同的阶段来执行队列中的回调。
事件循环的阶段(libuv 定义)
Node.js 的事件循环分为 6 个主要阶段,顺序是固定的:
- timers
- 执行
setTimeout和setInterval的回调 - 注意:定时器并不是“准时执行”,而是到点后被放入队列,等待调度
- 执行
- pending callbacks
- 执行一些系统操作的延迟回调,比如 TCP 错误的回调
- idle, prepare
- 内部使用,开发者几乎用不到
- poll(最关键阶段)
- 检查是否有新的 I/O 事件(如文件读取完成、HTTP 请求响应)
- 如果有事件完成,对应回调会被加入队列
- 如果没有任务,可能会阻塞在这里等待
- check
- 执行
setImmediate的回调
- 执行
- close callbacks
- 执行一些“关闭事件”的回调,比如
socket.on('close')
- 执行一些“关闭事件”的回调,比如
微任务队列(Microtasks)
除了上面 6 个阶段,Node.js 还有一个 微任务队列,它的优先级很高。
微任务包括:
process.nextTick(Node.js 独有,比 Promise.then 优先级更高)Promise.then/catch/finally
👉 执行顺序:
- 在每一个阶段结束后,都会先清空 微任务队列,再进入下一个阶段。
执行顺序示例
来看一个例子:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
执行结果通常是:
nextTick
promise
timeout
immediate
解释:
process.nextTick优先级最高,立刻执行。Promise.then属于微任务,也在本轮 tick 结束前执行。setTimeout属于 timers 阶段 → 下一个 tick 才执行。setImmediate属于 check 阶段,排在 timers 后面。
Node.js 事件循环的特点
- 单线程调度,异步非阻塞 I/O
- JS 主线程不会等 I/O,libuv 线程池或操作系统帮你干。
- 结果回来了,就放进事件循环,等待主线程处理。
- 不同类型的任务,进入不同的阶段队列
- 定时器类 → timers
- I/O 回调 → poll
- setImmediate → check
- 微任务优先于宏任务
- 这点和浏览器类似,但 Node.js 还多了一个
process.nextTick。
- 这点和浏览器类似,但 Node.js 还多了一个
循环过程描述
- I/O 任务的调度
- JS 主线程本身不做耗时 I/O(比如文件读取、网络请求)。
- 这些任务会交给 libuv(Node.js 的底层 C/C++ 库)去处理:
- 如果操作系统支持异步 I/O,就直接交给内核。
- 如果不支持,就用线程池来模拟异步。
👉 当 I/O 完成后,回调函数 会被放入 事件循环的队列(对应阶段)。
- 回调的执行
- JS 主线程(由 V8 驱动)会按照事件循环的阶段来依次取出回调并执行。
- 不同的任务,会进入不同的队列,比如:
setTimeout→ timers 阶段- I/O 完成的回调 → poll 阶段
setImmediate→ check 阶段
- 微任务的优先级
- 在每个阶段结束后,事件循环会检查 微任务队列,并清空它。
- 微任务包括:
process.nextTick(Node.js 独有,优先级最高)Promise.then/catch/finally
- 这意味着:只要有微任务,它们会比宏任务(timers、poll、check 这些阶段里的回调)更早被执行。
简单讲:对于I/O任务,交给libuv处理,其中若有任务完成,则将该任务的回调函数放到事件循环队列中,按顺序交给主线程去执行,当然也有例外情况,若有微任务,比如Promise和process.nextTick(Node.js独有),会优先被主线程执行。
直观比喻
可以把 Node.js 事件循环想象成 一家餐厅:
- 主线程(JS 引擎):只有一个厨师,专门负责炒菜(执行 JS)。
- 服务员(libuv):负责点单、安排食材、叫外卖。
- 顾客下单(异步任务):厨师不用等,服务员记下来,菜到了再通知厨师。
- 订单队列(事件循环):按顺序一份份上桌。
- 插队的贵宾(微任务):无论什么时候来,厨师都要先给他们做菜。
总结
- 事件循环本质:一个任务调度机制
- 阶段:timers → pending → poll → check → close
- 微任务:process.nextTick 和 Promise.then,总是优先执行
- 好处:
- 高并发处理能力(I/O 异步化)
- 单线程避免数据竞争
关键问题解答
1. Node.js 真的是单线程吗?
- JS 执行线程:是单线程(由 V8 执行)。
- 底层 I/O:由 libuv 的线程池或操作系统内核处理(多线程)。
👉 所以 Node.js 是 “单线程处理 JS + 多线程处理 I/O”。
2. 为什么能高并发?
- 主线程不会卡在 I/O 上,I/O 交给系统异步处理。
- 事件循环 + 回调机制,使得几万个请求也能在单线程下有序调度。
3. 异步和多线程的关系?
- 异步 ≠ 多线程。
- Node.js 的“异步”是通过 事件循环 实现的,而不是靠多个 JS 线程并行。
- 但底层的确有线程池(比如
fs模块会用线程池读文件)。
举例演示
const fs = require('fs');
console.log('1. Start');
// 异步任务
fs.readFile('./test.txt', 'utf8', (err, data) => {
console.log('3. File content:', data);
});
console.log('2. End');
执行结果:
1. Start
2. End
3. File content: Hello
为什么?
fs.readFile交给 libuv 线程池处理- 主线程继续执行
console.log('2. End') - 文件读取完成后,回调进入事件循环队列
- 最终回调在事件循环中执行
总结
- Node.js 运行机制核心:
- 单线程执行 JS
- 异步 I/O 交给 libuv 和操作系统
- 事件循环负责调度回调
- 好处:高并发、轻量、实时性强
- 不足:CPU 密集型任务会阻塞主线程,不适合计算密集场景
与前端开发的关系
前置思考
前面说到,Node.js 明明是后端服务器运行环境,但为什么前端开发也要装它?而且还用它来下载包?
Node.js 本质是什么?
Node.js = 一个可以运行 JavaScript 的后端运行环境(基于 V8 引擎 + libuv)。
- 它让 JS 脱离浏览器,也能在操作系统里跑(能访问文件、网络、系统 API)。
- 所以用它可以写服务器程序(Web 服务器、工具脚本、爬虫等等)。
👉 这就是“Node.js 是用 JS 写的服务器”的含义。
为什么前端要装 Node.js?
在现代前端开发(尤其是 2010s 的“工程化时代”之后),前端不再是写几个 HTML+CSS+JS 就能完事的,而是演变成了 复杂工程:
- 模块化(ES6 import/export、打包)
- 工程化工具(webpack、vite、rollup)
- 前端框架(React/Vue/Angular)
- 自动化构建(压缩、转译、热更新、测试)
这些工具本质上都是 脚本程序,它们需要一个能执行的运行环境。
👉 Node.js 就是这个“运行环境”。
打个比喻:
- 浏览器 = JS 的“用户运行环境”
- Node.js = JS 的“开发运行环境”
前端写代码时,其实不是直接运行在浏览器里的,而是要先经过一大堆“编译/打包/压缩”的处理,这些都依赖 Node.js 执行。
Node.js 里的 npm / npx
当你装了 Node.js,会顺带安装两个特别重要的工具:
- npm(Node Package Manager)
- Node.js 自带的包管理器。
- 前端的依赖(比如 React、Vue、webpack)本质上都是 npm 包。
- 所以前端开发必须依赖 npm 来安装和管理这些包。
- 命令:
npm install vue就是从 npm 仓库下载vue库到本地。
- npx
- 用来执行 npm 包里的可执行命令(比如
npx webpack,无需全局安装 webpack)。 - 它解决了“每个项目的依赖版本不同”的问题。
- 用来执行 npm 包里的可执行命令(比如
为什么“前端要用 Node.js 下包”?
- 早期前端开发:直接
<script src="xxx.js">引入,完全不需要 Node.js。 - 现代前端开发:代码要经过 模块化 + 打包 + 转译 才能上线。
- 比如你写了
import Vue from 'vue',浏览器根本看不懂 ES6 模块 → 需要 webpack 打包 → webpack 需要 Node.js 执行。 - 再比如你写了 TypeScript/JSX → 浏览器不认识 → 需要 babel 转译 → babel 需要 Node.js 执行。
- 比如你写了
👉 所以现在的“前端工程师”,日常必须依赖 Node.js,主要用它来:
- 跑开发工具链(webpack、vite、babel 等都是 Node.js 写的 CLI 工具)。
- 下载依赖包(通过 npm/yarn/pnpm)。
- 本地起开发服务器(vite dev server、webpack-dev-server,其实就是一个 Node.js 小服务器)。
总结一句话
- Node.js 作为“运行时”:支撑后端服务器,也支撑前端工具链。
- npm 作为“包管理器”:让前端能像后端一样使用海量开源库。
- 前端必须安装 Node.js,不是因为要写后端,而是因为:现代前端开发需要 Node.js 来运行构建工具、管理依赖、起开发服务器。
Node.js让JS 脱离浏览器,也能在操作系统里跑
前置思考
这其中包含两个问题:
- 为何JS无法脱离浏览器?
- Node.js做了哪些,让JS能在操作系统里跑?
为何 JS 无法脱离浏览器?
从历史背景来看,JavaScript 最早的“生存环境”就是 浏览器。它是 Netscape 在 1995 年发明的,本意是给网页加点交互(按钮点击、表单校验等)。因此,它的运行环境依赖于浏览器:
- 解释器依赖浏览器内核
JS 并不是“自带能跑的机器语言”,它需要一个解释器(比如 Chrome 的 V8、Firefox 的 SpiderMonkey)来执行。早期 JS 只存在于浏览器内核中,所以你没法在操作系统上直接跑 JS。 - 没有系统 API
浏览器里的 JS,只能调用浏览器暴露的 API:document(DOM 操作)window(全局对象)fetch/XMLHttpRequest(网络请求)
它没有权限访问 文件系统、进程、网络套接字 等操作系统资源。
→ 所以,它只能在“浏览器沙盒”里跑,脱离浏览器就“无能为力”。
Node.js 做了哪些,让 JS 能在操作系统里跑?
Node.js 出现后,JS 第一次有了 浏览器外的运行环境。Node.js 的本质是:
JS 引擎 (V8) + 系统接口封装 (libuv + C/C++ 库)
具体来说:
- 嵌入 V8 引擎
- Node.js 内部直接把 Chrome 的 V8 引擎拿过来。
- 这样,任何 JS 代码都能在操作系统中被解释执行,而不需要浏览器。
- libuv 提供事件驱动 & I/O 接口
- JS 自己不能操作文件、网络,但 Node.js 用 C/C++ 写了 libuv,封装了操作系统的 I/O 能力。
- 比如:
fs.readFile→ 内部调用系统 APIopen/read/closehttp.createServer→ 内部调用系统的网络 socket 接口
- 然后暴露给 JS 使用。
- 事件循环模型
- Node.js 设计了一个类似浏览器的事件循环(但更底层),用来协调异步任务。
- 这样 JS 就能写高并发的服务,而不用自己处理线程管理。
- 模块系统 (CommonJS)
- 浏览器里早期 JS 没有模块化机制。
- Node.js 引入了
require和module.exports,让 JS 能像后端语言(Java/Python)那样写模块化代码。
JS 在浏览器 vs JS 在 Node.js 执行环境
1. 执行引擎
- 浏览器:
- 使用内置 JS 引擎(如 Chrome 的 V8、Firefox 的 SpiderMonkey)解释执行代码。
- JS 必须依赖浏览器才能运行。
- Node.js:
- 内置 V8 引擎,独立于浏览器。
- JS 代码可以直接在操作系统运行(命令行执行
node file.js)。
2. 全局对象
- 浏览器:
window(顶层对象)- 下面挂载:
document、location、fetch、setTimeout等。
- Node.js:
global(顶层对象)- 下面挂载:
process、Buffer、setTimeout、require等。 - 没有
window、document。
3. 可访问的 API
- 浏览器(前端专属 API):
- DOM 操作:
document.querySelector、innerHTML - BOM 操作:
alert、location、navigator - 网络请求:
fetch、XMLHttpRequest - 事件机制:
addEventListener
→ 偏重于“页面交互”。
- DOM 操作:
- Node.js(后端/系统 API):
- 文件系统:
fs.readFile、fs.writeFile - 网络通信:
http.createServer、net.Socket - 进程控制:
process、child_process - 模块系统:
require、module.exports
→ 偏重于“操作系统和服务器”。
- 文件系统:
4. 事件循环
- 浏览器:
- 事件循环由浏览器内核(如 Chrome 的 Blink + V8 + Web APIs)实现。
- 包含:宏任务(setTimeout、setInterval)+ 微任务(Promise.then、MutationObserver)。
- Node.js:
- 事件循环由 libuv 实现(C/C++ 写的跨平台库)。
- 除了宏任务和微任务,还增加了 I/O 回调、定时器、
**process.nextTick**等特殊阶段。
5. 典型应用场景
- 浏览器:
- 页面渲染、交互逻辑、动画效果、Ajax 请求。
- 面向用户交互层。
- Node.js:
- 后端服务(API 接口、WebSocket 通信)。
- 构建工具(npm、webpack、vite 都跑在 Node.js 上)。
- 操作系统任务(脚本、文件操作、自动化工具)。
总结
- 浏览器里的 JS:被限制在沙盒内,专注于页面交互。
- JS 不能脱离浏览器 → 因为它只是一门“脚本语言”,缺少独立的执行引擎和系统 API。
- Node.js 里的 JS:解锁了系统权限,可以做服务器和系统工具。
- Node.js 让 JS 能在 OS 跑 → 它给 JS 提供了:
- 一个“发动机”(V8)让代码能跑;
- 一个“方向盘+油门”(libuv 和系统 API 封装)让代码能控制文件、网络、进程。
前端何以依赖Node.js?
问题背景:前端越来越复杂
2000s 的前端,JS 只需要写一些交互逻辑,HTML + CSS 写完,直接浏览器打开即可。
但是进入 2010s 框架与工程化时代 后,问题来了:
- 代码规模变大 → 需要模块化、打包成浏览器能识别的单文件。
- 语法更新快 → 开发用 ES6+,但浏览器兼容性差,需要“编译”成 ES5。
- 样式需求复杂 → CSS 也想写变量、嵌套、模块化,需要预处理器(Sass/Less)。
- 开发体验 → 希望改代码自动刷新浏览器,而不是 F5。
- 依赖生态 → 想用别人写好的库(React/Vue/Bootstrap),需要依赖管理。
👉 如果没有一个“开发运行环境”来处理这些,就没法高效开发。
Node.js 提供的能力
Node.js 本质是“让 JS 脱离浏览器,跑在操作系统上”,这就意味着:
- 命令行运行 JS
- 有了 Node.js,就可以写一个
build.js脚本,直接用node build.js在本地跑。 - 所有构建工具(Webpack、Vite、Rollup)都是 JS 写的,它们就依赖 Node 来执行。
- 有了 Node.js,就可以写一个
- 文件系统 API(fs)
- 构建工具需要读写代码文件(比如把
.vue文件读出来,转成 JS 模块再写入bundle.js)。 - 这在浏览器里做不到,因为浏览器禁止直接操作硬盘。
- 构建工具需要读写代码文件(比如把
- 网络能力(http)
- 可以跑一个本地开发服务器(
localhost:3000),用于预览页面。 - 支持 WebSocket,能做到“保存代码 → 浏览器热更新”。
- 可以跑一个本地开发服务器(
- 模块化能力(require/import)
- Node.js 自带模块加载机制,可以加载上千个 npm 包,帮你组装工程。
结合实际开发场景来看:
① 管理依赖(npm/yarn/pnpm)
- Node.js 附带 npm(Node Package Manager)。
- 当你执行
npm install react,npm 会从远程仓库下载 React 的源码到node_modules。 - 没有 Node.js,就没有 npm → 你无法获取这些库。
② 启动开发服务器(webpack-dev-server / vite)
- 你运行
npm run dev,本质上是 Node.js 启动了一个服务器:- 读取源码
- 编译/打包
- 启动本地 HTTP 服务
- 浏览器通过
http://localhost:5173加载你的项目
- 这样一改代码,Node.js 就重新编译并推送更新给浏览器。
③ 打包与构建(webpack / vite / rollup)
- 浏览器不懂
import Vue from 'vue'这种写法,也不懂.vue、.scss文件。 - Node.js 运行的构建工具会:
- 把这些模块打包成单个
bundle.js。 - 把 Sass 转成 CSS,把 ES6 转成 ES5。
- 把这些模块打包成单个
- 最终生成浏览器能直接识别的资源文件。
④ 自动化工具(lint / test / build 脚本)
- 你写
eslint .、jest、prettier --write,背后都是 Node.js 脚本在跑。 - 它们帮你自动检测代码、运行测试、格式化代码。
前端项目运行流程(开发阶段)
1. 开发阶段:编写源码
- 你写的内容:
- JS:可能用 ES6+ / TypeScript
- CSS:可能用 Sass / Less
- HTML:可能用模板引擎 / Vue SFC / React JSX
- 问题:浏览器原生只认 HTML/CSS/ES5 JS,直接运行不了。
2. Node.js 参与:构建工具工作
Node.js 在这里是“施工队”,运行各种 构建工具链。
- 读取源码(fs 模块)
- Node.js 先把源码文件读到内存。
- 解析依赖(模块化分析)
- 通过 ESM
import或 CommonJSrequire,画出依赖图。
- 通过 ESM
- 编译/转译
- Babel:把 ES6+ 转成 ES5
- TS Compiler:把 TypeScript 转成 JS
- Sass/Less Compiler:把 Sass 转成 CSS
- Vue/React 编译器:把
.vue/.jsx转成普通 JS
- 打包(Webpack/Vite/Rollup)
- 把成百上千个模块打成一个或多个 bundle(浏览器能识别的 JS/CSS)。
- Tree-shaking、代码分割、压缩、优化。
- 开发服务器(http 模块)
- Node.js 起一个本地服务器(比如 http://localhost:5173)。
- 负责返回构建后的页面、支持热更新(HMR)。
👉 结论:Node.js 是“工厂 + 发货员”,它加工你的源码,并把结果交给浏览器。
3. 浏览器运行阶段
浏览器负责的是真正的“用户看到的页面”。
- 加载 HTML
- 浏览器请求 Node.js 开的本地服务器,拿到 HTML 文件。
- 加载依赖资源
- HTML 里
<script src="bundle.js">→ 浏览器下载打包好的 JS。 <link href="style.css">→ 下载打包好的 CSS。
- HTML 里
- 解析与渲染
- JS:执行打包好的逻辑(React/Vue 渲染、事件交互)。
- CSS:应用样式。
- DOM + CSSOM → Render Tree → 绘制到屏幕。
👉 结论:浏览器是“展示员”,它只关心最后的结果(打包产物),并把结果画出来。
总体分工对比
| 阶段 | Node.js 负责 | 浏览器 负责 |
|---|---|---|
| 源码 | 读取文件、解析依赖 | 不参与 |
| 构建 | 转译、打包、优化 | 不参与 |
| 服务器 | 提供本地 HTTP 服务 | 发送请求 |
| 运行 | 不参与(除了热更新辅助) | 解析 HTML/JS/CSS,渲染页面 |
| 用户交互 | 不参与 | 事件监听、页面更新 |
- Node.js = 运行开发阶段的工具链(依赖管理、构建打包、开发服务器、测试脚本)
- 浏览器 = 运行用户看到的页面,负责拿到成品,展示、交互。
为什么 CommonJS 先在 Node.js 成功?
因为 浏览器环境和服务器环境不同:
- Node.js(服务器端)
- 模块一般都在本地硬盘,读取速度快。
- 同步加载没问题(执行
require()时,直接从磁盘加载)。
- 浏览器(客户端)
- 模块在远程服务器,加载要发 HTTP 请求,速度慢。
- 如果用同步加载,页面会卡死。
- 所以 CommonJS 在浏览器不适用 → 浏览器后来出现了 AMD/CMD(异步加载方案)。
CommonJS 的优点
- 真正解决了 全局污染 问题
- 引入了 模块依赖管理(
require明确写出依赖) - 天然支持 代码复用,模块可以在不同项目共享
- Node.js 生态快速爆发(NPM 包管理器)
CommonJS 的局限
- 同步加载:在浏览器端表现不好。
- 运行时加载:模块必须等代码执行时才知道依赖,无法在编译阶段分析依赖关系。
- 打包问题:前端如果要用 CommonJS,就必须借助工具(如 Browserify、Webpack)把模块打包成一个文件,才能在浏览器运行。
运行时加载
什么是运行时加载?
- 定义:代码执行到某一行时,才去加载依赖模块。
- 关键词:动态、即时、执行时才知道依赖。
- 典型代表:
- Node.js 的 CommonJS(require)
- 前端的 AMD/CMD
换句话说:运行时加载不提前准备依赖,而是等你要用的时候再去拿。
CommonJS 运行时加载的例子
// main.js
if (Math.random() > 0.5) {
const math = require('./math.js'); // 运行到这里才加载
console.log(math.add(1, 2));
}
特点:
- 在执行
require('./math.js')之前,程序完全不知道会不会加载math.js。 - Node.js 做法:
- 遇到
require→ 读取磁盘文件 → 包装执行 → 返回module.exports。 - 结果会缓存,下次再
require相同路径时直接取缓存。
- 遇到
👉 因为要等“运行时”才确定,所以叫运行时加载。
AMD / CMD(前端模块化)
浏览器环境里没有 require,于是早期社区发明了 异步模块定义:
AMD(Asynchronous Module Definition):
define(['math'], function(math) {
console.log(math.add(1, 2));
});
CMD(Common Module Definition,中国 SeaJS 提倡的):
define(function(require, exports, module) {
var math = require('./math');
console.log(math.add(1, 2));
});
特点:
- 依赖的模块在浏览器运行时通过
<script>标签异步加载。 - 同样是“执行到某处才去取模块”。
运行时加载的优点
- 灵活:可以按条件加载模块,比如在不同环境下加载不同实现。
- 动态:路径、模块名可以是变量,甚至能 run-time 拼接。
例子:
const env = process.env.NODE_ENV;
const config = require(`./config.${env}.js`);
👉 根据环境变量动态加载不同配置。
运行时加载的缺点
- 编译器无能为力
- 在打包时,无法静态分析出完整依赖关系(因为路径可能是变量)。
- 这导致优化困难,比如 Tree-shaking 不可能做。
- 性能差
- 每次运行到
require都可能触发磁盘 I/O 或网络请求(虽然有缓存)。 - 对前端浏览器来说,要等 JS 执行到
require,再去发 HTTP 请求,用户可能要白等一段时间。
- 每次运行到
- 代码不可预测
- 编译阶段工具无法知道哪些模块最终会被加载。
总结一句话
- 运行时加载:模块在程序运行到对应代码时才会被加载(动态、灵活,但不利于优化)。
- 代表:Node.js CommonJS、浏览器 AMD/CMD。
- 后果:因为运行时加载带来优化瓶颈,所以 ES Module 才改走 编译时静态分析 的路线。
总结
CommonJS 是 JavaScript 的第一个真正模块化规范,主要用于 Node.js,通过 require 和 module.exports 实现模块导入导出,解决了全局污染和依赖管理问题,但在浏览器端不适合,需要新的异步模块化规范来补充。