前端模块化发展(3):2009年CommonJS(Node.js)

93 阅读33分钟

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 会:
    1. 读取 math.js 文件内容
    2. 执行这个包装函数
    3. 返回 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 的代码
})();

所以每个模块的代码,实际上都运行在一个独立的函数里:

  • exportsrequiremodule__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 时,底层发生了什么?

步骤:

  1. 读取文件内容
    Node 读到 math.js 的内容:
function add(a, b) { return a + b; }
module.exports = { add };
  1. 包装成函数
    Node 把它包进:
(function(exports, require, module, __filename, __dirname) {
  function add(a, b) { return a + b; }
  module.exports = { add };
})
  1. 执行这个函数
    执行时,Node 会准备好一个空的 module.exports = {},然后把它传进去。
    执行完后,module.exports = { add }
  2. 返回结果
    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 → 当前模块对象(里面有 exportsidloaded 等信息)
  • __filename → 当前模块的完整路径(包含文件名)
  • __dirname → 当前模块所在目录的完整路径

为什么有 exportsmodule.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 模块系统的关键机制可以概括成两步:

  1. 隔离:给每个文件包一层函数,避免全局变量污染。
  2. 通信:通过 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。


为什么不是深拷贝?

因为深拷贝会带来两个问题:

  1. 性能开销:每次 require 都复制一份,模块越大越慢。
  2. 状态不同步:有时我们需要模块维护内部状态(比如数据库连接池、缓存、计数器),如果每次 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 无法直接在服务器端运行,所以前端与后端语言是割裂的。

而这带来几个痛点:

  1. 上下文割裂:前端、后端用不同的语言,开发者切换思维成本很高。
  2. 并发性能问题:当时的服务器大多是阻塞式 I/O(一个请求没完成,下一个就卡着)。
  3. 实时性不足:聊天室、协作工具需要高并发、长连接,而传统后端实现代价大。

于是,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
流程大致如下:

  1. 启动:运行 node app.js,V8 载入并执行 JS 代码。
  2. 注册任务:代码里遇到 I/O 操作(比如读文件、发 HTTP 请求),Node 不会阻塞等待,而是交给 libuv。
  3. 事件循环(Event Loop):libuv 负责轮询底层 I/O 完成情况,并把完成后的回调加入队列。
  4. 回调执行:V8 继续执行 JS,当事件循环发现队列里有回调,就交给 V8 执行。

👉 这样,Node.js 就能高效处理成千上万的并发请求,而不是一个个顺序等待。


Node.js 的模块系统

Node.js 内置了 CommonJS 模块系统(前面我们讲过):

  • 每个文件就是一个模块
  • require 引入,用 module.exports 导出
  • 模块只执行一次,结果缓存,大家共享

后来为了支持 ES6,Node 也逐步支持了 ESM(import/export)


Node.js 的应用场景
  1. 高并发场景:即时通讯(如聊天室、协作工具)、WebSocket 服务器
  2. API 层:作为前端与后端服务之间的 BFF(Backend for Frontend)
  3. 工具链:Webpack、Vite、ESLint、Babel 等前端工具几乎都基于 Node
  4. 全栈开发:前端 + 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 的运行机制全流程

下面是一个典型的执行流程:

  1. 启动 Node.js 程序
    • V8 加载并执行 JS 代码。
    • 代码里可能有同步操作和异步操作。
  2. 执行同步代码
    • 立即执行,主线程不会等。
  3. 遇到异步任务
    • 比如 fs.readFilesetTimeouthttp.request
    • Node.js 会调用 libuv,把任务交给操作系统或线程池处理。
    • 主线程继续往下跑,不会阻塞。
  4. 事件循环(Event Loop)
    • libuv 维护一个任务队列(事件队列)。
    • 当异步任务完成后,它们的回调会被放入队列。
    • Event Loop 会不断检查队列,把回调交给 V8 执行。
  5. 回调执行
    • 回调函数在 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 个主要阶段,顺序是固定的:

  1. timers
    • 执行 setTimeoutsetInterval 的回调
    • 注意:定时器并不是“准时执行”,而是到点后被放入队列,等待调度
  2. pending callbacks
    • 执行一些系统操作的延迟回调,比如 TCP 错误的回调
  3. idle, prepare
    • 内部使用,开发者几乎用不到
  4. poll(最关键阶段)
    • 检查是否有新的 I/O 事件(如文件读取完成、HTTP 请求响应)
    • 如果有事件完成,对应回调会被加入队列
    • 如果没有任务,可能会阻塞在这里等待
  5. check
    • 执行 setImmediate 的回调
  6. 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

解释:

  1. process.nextTick 优先级最高,立刻执行。
  2. Promise.then 属于微任务,也在本轮 tick 结束前执行。
  3. setTimeout 属于 timers 阶段 → 下一个 tick 才执行。
  4. setImmediate 属于 check 阶段,排在 timers 后面。

Node.js 事件循环的特点
  1. 单线程调度,异步非阻塞 I/O
    • JS 主线程不会等 I/O,libuv 线程池或操作系统帮你干。
    • 结果回来了,就放进事件循环,等待主线程处理。
  2. 不同类型的任务,进入不同的阶段队列
    • 定时器类 → timers
    • I/O 回调 → poll
    • setImmediate → check
  3. 微任务优先于宏任务
    • 这点和浏览器类似,但 Node.js 还多了一个 process.nextTick

循环过程描述
  1. I/O 任务的调度
  • JS 主线程本身不做耗时 I/O(比如文件读取、网络请求)。
  • 这些任务会交给 libuv(Node.js 的底层 C/C++ 库)去处理:
    • 如果操作系统支持异步 I/O,就直接交给内核。
    • 如果不支持,就用线程池来模拟异步。

👉 当 I/O 完成后,回调函数 会被放入 事件循环的队列(对应阶段)

  1. 回调的执行
  • JS 主线程(由 V8 驱动)会按照事件循环的阶段来依次取出回调并执行。
  • 不同的任务,会进入不同的队列,比如:
    • setTimeouttimers 阶段
    • I/O 完成的回调 → poll 阶段
    • setImmediatecheck 阶段
  1. 微任务的优先级
  • 在每个阶段结束后,事件循环会检查 微任务队列,并清空它。
  • 微任务包括:
    • 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,会顺带安装两个特别重要的工具:

  1. npm(Node Package Manager)
    • Node.js 自带的包管理器。
    • 前端的依赖(比如 React、Vue、webpack)本质上都是 npm 包
    • 所以前端开发必须依赖 npm 来安装和管理这些包。
    • 命令:npm install vue 就是从 npm 仓库下载 vue 库到本地。
  2. npx
    • 用来执行 npm 包里的可执行命令(比如 npx webpack,无需全局安装 webpack)。
    • 它解决了“每个项目的依赖版本不同”的问题。

为什么“前端要用 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,主要用它来:

  1. 跑开发工具链(webpack、vite、babel 等都是 Node.js 写的 CLI 工具)。
  2. 下载依赖包(通过 npm/yarn/pnpm)。
  3. 本地起开发服务器(vite dev server、webpack-dev-server,其实就是一个 Node.js 小服务器)。

总结一句话
  • Node.js 作为“运行时”:支撑后端服务器,也支撑前端工具链。
  • npm 作为“包管理器”:让前端能像后端一样使用海量开源库。
  • 前端必须安装 Node.js,不是因为要写后端,而是因为:现代前端开发需要 Node.js 来运行构建工具、管理依赖、起开发服务器。

Node.js让JS 脱离浏览器,也能在操作系统里跑

前置思考

这其中包含两个问题:

  1. 为何JS无法脱离浏览器?
  2. 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++ 库)

具体来说:

  1. 嵌入 V8 引擎
    • Node.js 内部直接把 Chrome 的 V8 引擎拿过来。
    • 这样,任何 JS 代码都能在操作系统中被解释执行,而不需要浏览器。
  2. libuv 提供事件驱动 & I/O 接口
    • JS 自己不能操作文件、网络,但 Node.js 用 C/C++ 写了 libuv,封装了操作系统的 I/O 能力。
    • 比如:
      • fs.readFile → 内部调用系统 API open/read/close
      • http.createServer → 内部调用系统的网络 socket 接口
    • 然后暴露给 JS 使用。
  3. 事件循环模型
    • Node.js 设计了一个类似浏览器的事件循环(但更底层),用来协调异步任务。
    • 这样 JS 就能写高并发的服务,而不用自己处理线程管理。
  4. 模块系统 (CommonJS)
    • 浏览器里早期 JS 没有模块化机制。
    • Node.js 引入了 requiremodule.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(顶层对象)
    • 下面挂载:documentlocationfetchsetTimeout 等。
  • Node.js
    • global(顶层对象)
    • 下面挂载:processBuffersetTimeoutrequire 等。
    • 没有 windowdocument

3. 可访问的 API
  • 浏览器(前端专属 API):
    • DOM 操作document.querySelectorinnerHTML
    • BOM 操作alertlocationnavigator
    • 网络请求fetchXMLHttpRequest
    • 事件机制addEventListener
      → 偏重于“页面交互”。
  • Node.js(后端/系统 API):
    • 文件系统fs.readFilefs.writeFile
    • 网络通信http.createServernet.Socket
    • 进程控制processchild_process
    • 模块系统requiremodule.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 提供了:
    1. 一个“发动机”(V8)让代码能跑;
    2. 一个“方向盘+油门”(libuv 和系统 API 封装)让代码能控制文件、网络、进程。

前端何以依赖Node.js?

问题背景:前端越来越复杂

2000s 的前端,JS 只需要写一些交互逻辑,HTML + CSS 写完,直接浏览器打开即可。

但是进入 2010s 框架与工程化时代 后,问题来了:

  • 代码规模变大 → 需要模块化、打包成浏览器能识别的单文件。
  • 语法更新快 → 开发用 ES6+,但浏览器兼容性差,需要“编译”成 ES5。
  • 样式需求复杂 → CSS 也想写变量、嵌套、模块化,需要预处理器(Sass/Less)。
  • 开发体验 → 希望改代码自动刷新浏览器,而不是 F5。
  • 依赖生态 → 想用别人写好的库(React/Vue/Bootstrap),需要依赖管理。

👉 如果没有一个“开发运行环境”来处理这些,就没法高效开发。


Node.js 提供的能力

Node.js 本质是“让 JS 脱离浏览器,跑在操作系统上”,这就意味着:

  1. 命令行运行 JS
    • 有了 Node.js,就可以写一个 build.js 脚本,直接用 node build.js 在本地跑。
    • 所有构建工具(Webpack、Vite、Rollup)都是 JS 写的,它们就依赖 Node 来执行。
  2. 文件系统 API(fs)
    • 构建工具需要读写代码文件(比如把 .vue 文件读出来,转成 JS 模块再写入 bundle.js)。
    • 这在浏览器里做不到,因为浏览器禁止直接操作硬盘。
  3. 网络能力(http)
    • 可以跑一个本地开发服务器(localhost:3000),用于预览页面。
    • 支持 WebSocket,能做到“保存代码 → 浏览器热更新”。
  4. 模块化能力(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 .jestprettier --write,背后都是 Node.js 脚本在跑。
  • 它们帮你自动检测代码、运行测试、格式化代码。

前端项目运行流程(开发阶段)
1. 开发阶段:编写源码
  • 你写的内容
    • JS:可能用 ES6+ / TypeScript
    • CSS:可能用 Sass / Less
    • HTML:可能用模板引擎 / Vue SFC / React JSX
  • 问题:浏览器原生只认 HTML/CSS/ES5 JS,直接运行不了。

2. Node.js 参与:构建工具工作

Node.js 在这里是“施工队”,运行各种 构建工具链

  1. 读取源码(fs 模块)
    • Node.js 先把源码文件读到内存。
  2. 解析依赖(模块化分析)
    • 通过 ESM import 或 CommonJS require,画出依赖图。
  3. 编译/转译
    • Babel:把 ES6+ 转成 ES5
    • TS Compiler:把 TypeScript 转成 JS
    • Sass/Less Compiler:把 Sass 转成 CSS
    • Vue/React 编译器:把 .vue/.jsx 转成普通 JS
  4. 打包(Webpack/Vite/Rollup)
    • 把成百上千个模块打成一个或多个 bundle(浏览器能识别的 JS/CSS)。
    • Tree-shaking、代码分割、压缩、优化。
  5. 开发服务器(http 模块)

👉 结论:Node.js 是“工厂 + 发货员”,它加工你的源码,并把结果交给浏览器。


3. 浏览器运行阶段

浏览器负责的是真正的“用户看到的页面”。

  1. 加载 HTML
    • 浏览器请求 Node.js 开的本地服务器,拿到 HTML 文件。
  2. 加载依赖资源
    • HTML 里 <script src="bundle.js"> → 浏览器下载打包好的 JS。
    • <link href="style.css"> → 下载打包好的 CSS。
  3. 解析与渲染
    • 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));
}

特点:

  1. 在执行 require('./math.js') 之前,程序完全不知道会不会加载 math.js
  2. 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> 标签异步加载。
  • 同样是“执行到某处才去取模块”。

运行时加载的优点

  1. 灵活:可以按条件加载模块,比如在不同环境下加载不同实现。
  2. 动态:路径、模块名可以是变量,甚至能 run-time 拼接。

例子:

const env = process.env.NODE_ENV;
const config = require(`./config.${env}.js`);

👉 根据环境变量动态加载不同配置。


运行时加载的缺点

  1. 编译器无能为力
    • 在打包时,无法静态分析出完整依赖关系(因为路径可能是变量)。
    • 这导致优化困难,比如 Tree-shaking 不可能做。
  2. 性能差
    • 每次运行到 require 都可能触发磁盘 I/O 或网络请求(虽然有缓存)。
    • 对前端浏览器来说,要等 JS 执行到 require,再去发 HTTP 请求,用户可能要白等一段时间。
  3. 代码不可预测
    • 编译阶段工具无法知道哪些模块最终会被加载。

总结一句话

  • 运行时加载:模块在程序运行到对应代码时才会被加载(动态、灵活,但不利于优化)。
  • 代表:Node.js CommonJS、浏览器 AMD/CMD。
  • 后果:因为运行时加载带来优化瓶颈,所以 ES Module 才改走 编译时静态分析 的路线。

总结

CommonJS 是 JavaScript 的第一个真正模块化规范,主要用于 Node.js,通过 requiremodule.exports 实现模块导入导出,解决了全局污染和依赖管理问题,但在浏览器端不适合,需要新的异步模块化规范来补充。