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















































































