大家好,我是阿浪
本文是我自己的源码拆解笔记和思考,代码都是简化示意,不是逐行照搬。目的是让刚开始学框架源码的朋友,也能看懂一个 Web 框架到底由哪些"零件"组成。
很多人一听到"读框架源码",第一反应就是: 别了吧,肯定一大堆抽象类、泛型、设计模式,我连入口都找不到。
我以前也这么想。但这次把 OpenClaw 这套 TypeScript Web 框架拆下来,我最大的感受是: 框架没那么神秘,它更像一套搭得很讲究的积木。你只要先找到"地基",再顺着请求走一遍,很多模块自己就串起来了。
这篇文章我不打算一上来就甩一堆源码。咱们按小白的视角,一块一块看:
- 一个框架的项目结构为什么要这么分?
- 应用是怎么启动的?
- 中间件为什么叫"洋葱模型"?
- 插件系统到底插在哪?
- 路由怎么把
/users/:id变成参数? - 状态管理为什么要搞订阅和选择器?
- 构建、测试、性能优化这些工程化东西,为什么也是框架的一部分?
你慢慢看,最后会发现:框架的核心不是"写了很多代码",而是"把边界划清楚了"。
一、先别急着看代码,先看它怎么分层的
OpenClaw 用的是 Monorepo,也就是把多个包放在一个大仓库里管理。你可以把它想成一个大院子,里面有几个房间:
core:框架核心,应用、中间件、插件、生命周期都在这;- 其他包:可能是工具、适配器、测试辅助之类;
- 根目录统一管理依赖、构建、测试。
我自己的理解是:Monorepo 不是为了"看起来高级",而是为了改一个底层包时,上层包能立刻跟着验证。 如果每个包都拆成独立仓库,改一个类型定义,你可能要发版、升级、再联调,来回折腾。放在一个院子里,至少"串门"方便。
构建上它用了 Turborepo。这个东西你可以理解成: 只给脏衣服洗,干净的衣服不重复洗。 哪个包变了,就构建哪个包;没变的包,直接拿缓存。
简化后的配置大概长这样:
json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"cache": true
}
}
}
这里 ^build 的意思是:我依赖的包先构建,我再构建。 小白可以记一句话:Turborepo 管的是"谁先做、谁不用重复做"。
二、应用启动:其实就是一条流水线
框架的入口通常是一个 Application 类。你不用把它想得太玄,它主要干几件事:
- 注册插件;
- 注册中间件;
- 启动前钩子;
- 组合中间件;
- 监听端口;
- 启动后钩子。
简化一下,大概是这样:
ts
class Application {
private middlewares: Middleware[] = []
private plugins: Plugin[] = []
use(plugin: Plugin) {
this.plugins.push(plugin)
plugin.install(this)
return this
}
middleware(fn: Middleware) {
this.middlewares.push(fn)
return this
}
async start(port: number) {
for (const plugin of this.plugins) {
await plugin.beforeStart?.(this)
}
const handler = compose(this.middlewares)
createServer((req, res) => {
handler({ req, res })
}).listen(port)
for (const plugin of this.plugins) {
await plugin.afterStart?.(this)
}
}
}
这段代码不复杂,但它体现了一个很重要的思想: 启动不是"一把梭",而是有顺序的生命周期。
我自己的看法是:生命周期钩子是框架给插件留的"时间窗口"。 数据库插件要在服务监听前连上数据库,日志插件可能要在启动后打一条"服务已启动"。如果没有这些钩子,插件就只能到处乱插,最后核心代码会被污染。
三、中间件:为什么叫"洋葱模型"?
中间件是 Web 框架里最值得小白先搞懂的东西。
你可以把一次请求想成一个快递包裹。 包裹从最外层进来,经过一层层流水线,每一层都可以:
- 看看包裹信息;
- 加个标签;
- 记录时间;
- 判断要不要直接拦截;
- 或者放行给下一层。
等包裹处理完,再一层层往回走。 这就是"洋葱模型":进去的时候一层层进,出来的时候一层层出。
简化后的组合函数大概这样:
ts
type Middleware = (ctx: any, next: () => Promise<void>) => Promise<void>
function compose(middlewares: Middleware[]) {
return function (ctx: any) {
let index = -1
function dispatch(i: number): Promise<void> {
if (i <= index) {
return Promise.reject(new Error('next() 被重复调用了'))
}
index = i
const fn = middlewares[i]
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
这段代码里有两个点很关键:
第一,next() 本质上就是"调用下一层"。 第二,它防了 next() 重复调用。这个防护看起来小,但很实用。因为中间件写多了,真有人会在一个分支里调两次 next(),最后请求链路就乱了。
我自己的理解是:中间件不是越多越好。 每加一层,请求链路就多一次函数调用,也多一次出错机会。日志、错误处理、鉴权、限流这些适合放中间件;业务逻辑就别硬塞进去了,不然洋葱会变成"千层饼"。
四、插件系统:给框架装"外设"
插件系统我最喜欢用一个比喻:插座。
框架核心提供标准插座,插件就是外设。 数据库插件、日志插件、配置插件,只要遵守 install 这个约定,就能把自己的能力挂到应用上。
简化接口:
ts
interface Plugin {
name: string
install(app: Application): void
beforeStart?(app: Application): Promise<void> | void
afterStart?(app: Application): Promise<void> | void
}
一个数据库插件可以简化成:
ts
const dbPlugin: Plugin = {
name: 'db',
install(app) {
app.db = createPool()
},
async beforeStart(app) {
await app.db.connect()
},
}
app.use(dbPlugin)
我自己的想法是:插件系统的价值,不是"能装很多东西",而是"核心不用知道太多东西"。 如果每个功能都写进核心,核心会越来越胖。插件把"可选能力"挪出去,核心只保留最稳定的约定,这样框架才容易长大。
但插件也不能乱写。我的建议是: 插件尽量只依赖应用暴露的接口,不要偷偷改核心内部状态。 不然插件一多,排查问题会非常痛苦。
五、路由:门牌号 + 正则匹配
路由说白了就是:给不同的 URL 分配不同的处理函数。
/users/:id 这种写法,:id 是动态参数。框架需要把路径转成正则,匹配时把参数抠出来。
简化实现:
ts
function pathToRegex(path: string) {
const pattern = path.replace(/:[^/]+/g, '([^/]+)')
return new RegExp(`^${pattern}$`)
}
const re = pathToRegex('/users/:id')
const match = '/users/42'.match(re)
console.log(match?.[1]) // 42
这段代码很短,但小白要理解一个点: 路由匹配的本质是"字符串规则 + 参数提取"。
嵌套路由也不神秘。 比如 /api 下面挂一个子路由,本质上就是把子路由的处理链挂到父路由下面。你可以理解成: 先找到小区大门,再找楼栋门牌。
我自己的看法是:路由正则适合入门理解,但真实框架要考虑性能、优先级、静态路径和动态路径的冲突。 所以看源码时别只盯着"能不能匹配",还要看它怎么组织路由表、怎么处理嵌套、怎么避免每次都从头扫一遍。
六、状态管理:一个小广播站
OpenClaw 里的 Store 用了发布订阅模式。 你可以把它想成小区广播:状态一变,订阅过的人都能收到通知。
简化代码:
ts
class Store<T> {
private state: T
private listeners = new Set<(s: T) => void>()
constructor(initial: T) {
this.state = initial
}
getState() {
return this.state
}
setState(next: T) {
if (Object.is(next, this.state)) return
this.state = next
this.listeners.forEach(fn => fn(this.state))
}
subscribe(fn: (s: T) => void) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
}
这里有个细节:状态没变就不通知。 别小看这一行,它能省掉很多无意义的更新。
还有 createSelector,它的作用是缓存计算结果。 上一次的状态和结果都记着,如果状态没变,就直接返回上次结果。
我自己的理解是:状态管理最难的不是"存",而是"什么时候通知、通知谁、要不要重算"。 Store 负责广播,Selector 负责算得省一点。两者配合,才不会一改状态就全量重算。
七、工程化:框架的"后勤部队"
很多小白看源码只看运行时,忽略了构建和类型配置。 但我拆完这套东西后,反而觉得工程化特别重要。
TypeScript 配置里常见几个东西:
ts
{
"compilerOptions": {
"strict": true,
"composite": true,
"declaration": true
}
}
strict:把类型检查拧紧,早发现问题;composite:支持项目引用;declaration:生成类型声明文件。
Project References 你可以理解成"图书馆目录": 每个包有自己的目录,编译时不用把所有书都翻一遍,只翻需要的部分。
我自己的看法是:框架的工程质量,很大一部分藏在这些"看不见"的配置里。 运行时写得再漂亮,如果构建慢、类型乱、包之间互相绕,维护起来一样难受。
八、性能优化:别为了优化而优化
这类框架常见的优化点有三个:
1. 缓存中间件
以 URL 为 key,把 200 响应缓存起来,命中就直接返回。
简化示意:
ts
const cache = new Map<string, string>()
const cacheMiddleware: Middleware = async (ctx, next) => {
if (cache.has(ctx.req.url)) {
ctx.res.end(cache.get(ctx.req.url))
return
}
await next()
if (ctx.res.statusCode === 200) {
cache.set(ctx.req.url, ctx.res.body)
}
}
但我要泼一盆冷水:缓存中间件不是万能药。 用户相关页面、登录态页面、实时数据页面,都不能随便缓存。缓存 key 怎么设计、过期怎么做、内存会不会涨,都是坑。
2. 路由懒加载
用动态 import() 按需加载控制器。 启动时不用把所有模块都解析一遍,第一次访问再加载。
3. 数据库连接池
连接池就是"复用连接",不是每次请求都新建数据库连接。 这个对性能影响很直接,但也别把池子开太大,数据库那边也扛不住。
我自己的理解是:性能优化一定要看场景。 先测量,再优化;先找瓶颈,再动手。上来就加缓存,很容易把 bug 也缓存进去。
九、设计模式:别为了模式而模式
这套框架里能看到几种常见模式,但我更想说的是:模式是结果,不是目的。
| 模式 | 在框架里的感觉 | 我的理解 |
|---|---|---|
| 工厂模式 | createApplication() 统一创建应用 |
把初始化细节收口,别让调用方到处 new |
| 观察者模式 | Store 订阅 | 状态变了通知谁,解耦 |
| 责任链模式 | 中间件链 | 请求一层层过,每层只管自己的事 |
| 策略模式 | 路由匹配策略 | 匹配规则可替换,别写死 |
我自己的看法是:看源码时不要急着背"这里用了什么模式"。 先问:它解决了什么问题?如果不用这个模式,代码会变成什么样?这样理解才扎实。
十、测试:先测"骨架",再测"血肉"
小白写测试容易一上来就测业务。 但框架源码更适合先测骨架:
- 路由能不能正确匹配?
- 参数能不能正确提取?
- 中间件顺序对不对?
next()重复调用会不会报错?- 插件生命周期有没有按顺序执行?
路由测试简化示意:
ts
const re = pathToRegex('/users/:id')
const match = '/users/42'.match(re)
expect(match?.[1]).toBe('42')
集成测试则可以用请求工具跑完整链路: 发一个请求,看状态码、响应体、中间件有没有生效。
我自己的理解是:测试不是给老师看的,是给你自己改代码时兜底的。 框架这种基础设施,最怕改 A 坏 B。测试就是那张安全网。
十一、最后说说我的整体理解
拆完 OpenClaw 这套架构,我最大的收获不是记住了多少类名,而是理解了几个"框架设计原则":
第一,核心要小,边界要清。 应用、中间件、插件、路由、状态,各管各的。核心不替插件干活,插件也不乱改核心。
第二,请求链路要可预测。 中间件洋葱模型、生命周期钩子、路由匹配,都是让"请求下一步去哪"变得可预测。可预测,才好排查问题。
第三,工程化不是附属品。 Monorepo、Turborepo、TypeScript 项目引用、测试缓存,这些看起来不写业务,但它们决定了一个框架能不能长期维护。
第四,性能优化要克制。 缓存、懒加载、连接池都很好,但都要看场景。别为了"看起来快"引入新问题。
如果你也是小白,我建议你读这类源码时别从第一行硬啃。 你可以按这个顺序走:
- 先找入口:应用怎么创建、怎么启动;
- 再跟一次请求:中间件怎么进、怎么出;
- 再看扩展点:插件怎么装、路由怎么挂;
- 最后看工程化:怎么构建、怎么测试、怎么优化。
你会发现,框架源码不是一座迷宫,而是一张有路标的地图。 先看骨架,再看血肉;先跑通一条请求,再研究每个零件。 这样读,小白也能读得进去。
一句话总结: OpenClaw 这类框架真正值得学的,不是某一行业务代码,而是它怎么把"启动、请求、扩展、状态、构建、测试"这些事拆开,再用清晰的边界重新拼起来。看懂这套拼法,你再看别的框架,也会轻松很多。

扫一扫关注阿浪微信公众号