版本:3.7.0
核心文件:
index.js(约 270 行)
一、项目概览
connect 是一个极简的 Node.js HTTP 中间件框架,也是 Express.js 的前身。
核心设计思想 :将 HTTP 请求依次传递给一个中间件栈(stack),每个中间件决定是处理请求还是调用 next() 传递给下一个。
依赖关系
| 依赖包 | 作用 |
|---|---|
debug |
开发调试日志 |
finalhandler |
兜底的 404/500 响应处理器 |
parseurl |
带缓存的 URL 解析(性能优化) |
utils-merge |
将对象属性混入另一个对象 |
二、整体架构
数据流全景
scss
http.createServer(app)
│
▼ req, res
app(req, res, next) ← app 本质是一个函数
│
▼
app.handle(req, res)
│
┌────▼────────────────────────────────────┐
│ stack = [ │ │ { route: '/', handle: fn1 }, │ │ { route: '/api', handle: fn2 }, │ │ { route: '/', handle: errorFn }, │ ← 4 个参数 = 错误中间件 │ ] │
└─────────────────────────────────────────┘
│
next() → 路径匹配? → call(handle) → 中间件执行
↑ │
└──────── 调用 next() ───────────────┘
│ 栈走完
▼
finalhandler (404/500)
核心函数一览
| 函数 | 类型 | 作用 |
|---|---|---|
createServer() |
public | 工厂函数,创建 app 实例 |
proto.use() |
public | 注册中间件,存入 stack |
proto.handle() |
private | 请求分发引擎,驱动 next 循环 |
proto.listen() |
public | 语法糖,启动 HTTP 服务器 |
call() |
private | 区分普通/错误中间件并调用 |
getProtohost() |
private | 提取完整 URL 的协议+主机部分 |
logerror() |
private | 非 test 环境下打印错误堆栈 |
三、createServer() ------ 工厂函数
ini
function createServer() {
function app(req, res, next){ app.handle(req, res, next); }
merge(app, proto);
merge(app, EventEmitter.prototype);
app.route = '/';
app.stack = [];
return app;
}
关键设计点
app是一个函数 :签名为(req, res, next),因此:
- 可以直接传给
http.createServer(app)作为请求回调 - 也可以作为子中间件挂载到其他 app 里(
parentApp.use('/sub', app))
- 函数也是对象 :通过
merge(app, proto)把方法混入函数对象,让app同时拥有use()、handle()、listen()方法 - EventEmitter :通过
merge(app, EventEmitter.prototype)赋予事件发射能力,测试中可以app.on('foo', cb)/app.emit('foo') app.stack = []:核心数据结构,存储所有中间件,每个元素格式为{ route: string, handle: function }
四、proto.use() ------ 注册中间件
ini
proto.use = function use(route, fn) {
var handle = fn;
var path = route;
// route 省略时默认 '/'
if (typeof route !== 'string') {
handle = route;
path = '/';
}
// 包装子 connect app
if (typeof handle.handle === 'function') {
var server = handle;
server.route = path;
handle = function (req, res, next) {
server.handle(req, res, next);
};
}
// 包装原生 http.Server
if (handle instanceof http.Server) {
handle = handle.listeners('request')[0];
}
// 去掉末尾 /
if (path[path.length - 1] === '/') {
path = path.slice(0, -1);
}
this.stack.push({ route: path, handle: handle });
return this; // 支持链式调用
};
支持三种中间件类型
| 传入类型 | 处理方式 |
|---|---|
普通函数 function(req, res, next) |
直接存入 stack |
connect app(有 .handle 方法) |
包一层函数,把父 next 传进去 |
原生 http.Server |
取其 request 事件的第一个监听器 |
子 app 挂载的关键
ini
// 包装时更新子 app 的 route 属性
server.route = path;
// 包装函数传入外层的 next ← 关键!
handle = function (req, res, next) {
server.handle(req, res, next);
};
子 app 走完自己的 stack 时,会通过这个 next 回到父 app 继续执行。子 app 完全不需要知道父 app 的存在。
五、proto.handle() ------ 请求分发引擎
完整代码解析
ini
proto.handle = function handle(req, res, out) {
var index = 0;
var protohost = getProtohost(req.url) || ''; // 处理绝对 URL
var removed = ''; // 被裁掉的路径前缀(挂载时用)
var slashAdded = false; // 是否人工补了前导 /
var stack = this.stack;
// 兜底处理器:如果 out 未提供,使用 finalhandler(处理 404/500)
var done = out || finalhandler(req, res, {
env: env,
onerror: logerror
});
// 保存原始 URL,只赋值一次
req.originalUrl = req.originalUrl || req.url;
function next(err) {
// ① 还原被补的前导 /
if (slashAdded) {
req.url = req.url.substr(1);
slashAdded = false;
}
// ② 还原被裁掉的路径前缀
if (removed.length !== 0) {
req.url = protohost + removed + req.url.substr(protohost.length);
removed = '';
}
// ③ 取下一个中间件
var layer = stack[index++];
// ④ 栈走完,异步调用兜底处理器
if (!layer) {
defer(done, err);
return;
}
// ⑤ 路径匹配检查(见下方详解)
var path = parseUrl(req).pathname || '/';
var route = layer.route;
if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
return next(err);
}
var c = path.length > route.length && path[route.length];
if (c && c !== '/' && c !== '.') {
return next(err);
}
// ⑥ URL 裁剪(挂载子路径时)
if (route.length !== 0 && route !== '/') {
removed = route;
req.url = protohost + req.url.substr(protohost.length + removed.length);
if (!protohost && req.url[0] !== '/') {
req.url = '/' + req.url;
slashAdded = true;
}
}
// ⑦ 调用中间件
call(layer.handle, route, err, req, res, next);
}
next(); // 启动分发
};
六、路径匹配逻辑(详解)
两条规则
lua
// 规则1:前缀匹配(大小写不敏感)
if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
return next(err);
}
// 规则2:匹配结束位置必须是边界字符 '/' 或 '.',或者正好到末尾
var c = path.length > route.length && path[route.length];
if (c && c !== '/' && c !== '.') {
return next(err);
}
测试用例对照
| 请求路径 | 挂载路由 | 结果 | 原因 |
|---|---|---|---|
/blog |
/blog |
✅ 匹配 | 前缀完全相等 |
/blog/post/1 |
/blog |
✅ 匹配 | 边界字符是 / |
/blog.json |
/blog |
✅ 匹配 | 边界字符是 . |
/BLog |
/blog |
✅ 匹配 | toLowerCase() 大小写不敏感 |
/blog-o-rama |
/blog |
❌ 不匹配 | 边界字符是 -,非 / 或 . |
/blog-o-rama/article |
/blog |
❌ 不匹配 | 同上 |
规则2 的意义 :防止 /blog 路由错误地匹配 /blog-o-rama,即路径组件必须完整匹配。
七、URL 重写与还原(挂载子路径)
三个关键变量的作用
| 变量 | 类型 | 作用 |
|---|---|---|
protohost |
string | 协议+主机(如 http://example.com),绝对 URL 场景下使用 |
removed |
string | 被裁掉的路径前缀,next() 时还原 |
slashAdded |
boolean | 是否人工补了前导 /,next() 时还原 |
完整流程示例
普通相对路径:
ini
app.use('/blog', middleware)
请求:GET /blog/post/1
进入中间件前:
req.url = '/blog/post/1'
裁剪(removed = '/blog'):
req.url = '/post/1' ← 子中间件看到的是相对路径
middleware 内部调用 next():
还原 removed:
req.url = '/blog/post/1' ← 下一个中间件看到完整路径
req.originalUrl 始终是 '/blog/post/1'(不变)
绝对 URL(FQDN)场景:
ini
请求:GET http://example.com/blog/post/1
protohost = 'http://example.com'
裁剪:
req.url = 'http://example.com' + '/post/1'
= 'http://example.com/post/1' ← 协议+主机保留,只裁路径部分
补 / 场景:
ini
app.use('/blog', middleware)
请求:GET /blog (匹配后路径变为空字符串)
裁剪后:req.url = ''
补 /: req.url = '/' slashAdded = true
next() 时还原:req.url = ''(去掉补的 /)
再还原 removed:req.url = '/blog'
req.originalUrl 的保护机制
ini
req.originalUrl = req.originalUrl || req.url;
使用 || 而非 =,保证只在第一次进入时赋值,之后任何 URL 重写都不会改变它。
八、错误处理流程
两种错误触发方式
vbnet
// 方式1:同步抛出(call() 里 try/catch 捕获)
app.use(function(req, res, next) {
throw new Error('boom!');
});
// 方式2:主动传递
app.use(function(req, res, next) {
next(new Error('boom!'));
});
call() 函数 ------ 区分中间件类型
ini
function call(handle, route, err, req, res, next) {
var arity = handle.length; // 函数参数个数是判断依据
var error = err;
var hasError = Boolean(err);
try {
if (hasError && arity === 4) {
handle(err, req, res, next); // 错误中间件
return;
} else if (!hasError && arity < 4) {
handle(req, res, next); // 普通中间件
return;
}
// arity === 4 但无错误 → 跳过
// arity < 4 但有错误 → 跳过
// arity > 4 → 永远跳过(两个条件都不满足)
} catch (e) {
error = e; // 同步异常转为错误传播
}
next(error);
}
错误传播路径图
scss
next(err) 触发
│
▼ 遍历 stack
普通中间件 (arity < 4) → 有 err 时全部跳过
│
▼
错误中间件 (arity === 4) → 接收并处理 err
│
├─ 调用 next() → 错误清除,继续走普通中间件
└─ 调用 next(err) → 错误继续向后传递(可以链式多个错误中间件)
│
▼ 所有中间件走完
finalhandler → 输出 500 响应
关键行为总结
| 场景 | 行为 |
|---|---|
| 有错误 + 普通中间件 (arity < 4) | 跳过 |
| 无错误 + 错误中间件 (arity === 4) | 跳过 |
| 任意 + arity > 4 的函数 | 永远跳过(隐蔽的"坑") |
错误中间件调用 next() |
错误消除,进入普通中间件 |
错误中间件调用 next(err) |
错误继续传递到下一个错误中间件 |
错误中间件顺序的影响
php
// 此错误中间件在产生错误的中间件之前,不会被触发
app.use(function(err, req, res, next) { res.end('fail'); }); // ← 先注册
app.use(function(req, res, next) { next(new Error('boom!')); });
app.use(function(err, req, res, next) { res.end('pass'); }); // ← 会被触发
错误只会传递给产生错误的中间件之后注册的错误中间件。
九、defer 的作用
javascript
var defer = typeof setImmediate === 'function'
? setImmediate
: function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) }
用于 stack 走完时异步调用兜底处理器:
scss
if (!layer) {
defer(done, err); // 异步,不是同步调用
return;
}
为什么必须异步?
若直接同步调用 done(err),在复杂嵌套场景(如多层子 app)中可能导致当前调用栈未完全清空就触发兜底逻辑,引发难以追踪的状态问题。
setImmediate 把 done 推迟到下一个事件循环迭代,确保当前调用栈完全清空。
十、子 app 嵌套挂载(详解)
挂载时的包装
ini
if (typeof handle.handle === 'function') {
var server = handle;
server.route = path; // 更新子 app 的 route 属性
handle = function (req, res, next) {
server.handle(req, res, next); // 传入外层(父)next ← 关键
};
}
嵌套调用链
scss
父 app.stack = [mw1, subApp('/blog'), mw3]
请求 GET /blog/post/1:
1. mw1 执行 → 调用 next()
2. 进入 subApp 包装函数
→ subApp.handle(req, res, 父next)
→ req.url 被裁为 '/post/1'
subApp.stack = [subMw1, subMw2]
subMw1 执行 → 调用 next()
subMw2 执行 → 调用 next()
subApp stack 走完 → defer(父next, err)
→ req.url 还原为 '/blog/post/1'
3. mw3 执行(回到父 app)
route 属性的作用
java
assert.equal(app.route, '/'); // 根 app
assert.equal(blog.route, '/blog'); // 挂载在 /blog
assert.equal(admin.route, '/admin'); // 挂载在 /admin(相对于 blog)
route 记录的是挂载点的绝对路径,主要用于调试和日志。
十一、设计哲学总结
connect 的全部复杂性集中在约 100 行的 handle / next / call 三函数中,体现了以下经典设计决策:
| 设计目标 | 实现方式 |
|---|---|
| 通过参数个数区分中间件类型 | call() 检查 Function.length(arity) |
| 路径透明(子中间件只看相对路径) | 进入前裁剪 URL,next() 时还原 |
| 不丢失原始请求路径 | req.originalUrl 首次赋值后不变 |
| 同步异常自动转为错误传播 | call() 里 try/catch 捕获后调用 next(err) |
| 子 app 可以串联回父 app | use() 包装时捕获并传入外层 next |
| 兜底处理不影响当前调用栈 | defer(setImmediate)异步调用 done |
十二、快速验证
bash
# 运行所有测试
npm test
# 运行单个测试文件
npx mocha --require test/support/env test/mounting.js
npx mocha --require test/support/env test/fqdn.js
测试文件说明
| 文件 | 覆盖内容 |
|---|---|
test/server.js |
基本功能、404/500 处理、EventEmitter |
test/mounting.js |
路径匹配、URL 裁剪、子 app 挂载、错误处理 |
test/fqdn.js |
绝对 URL(FQDN)场景 |
test/app.listen.js |
proto.listen() 方法 |