本文深入解析 JavaScript 中的洋葱模型原理,以 Koa 的 compose 源码为例,剖析其通过闭包、递归和 Promise 链实现中间件调用的核心机制。针对实际开发中多场景弹窗需按优先级执行的痛点,提出一种基于优先级排序的中间件调度方案,通过自定义 Scheduler 类管理中间件栈,实现业务逻辑解耦与灵活扩展,帮助开发者在复杂交互场景中更高效地处理异步流程。
前言
首先通过一个实际业务场景引入话题:在编辑页面中,若用户点击退出登录,系统需先提示存在未保存内容并询问是否保存;用户完成保存操作后,再提示是否确认退出。
具体处理流程如下:

由于退出登录功能由其他同事维护,沟通后对方“善意”地将需求承接过去,并告知可通过特定方法获取当前编辑状态。随后我继续处理其他事务,对方开始编写代码。
const handlerLogout = async () => {
if (window.location.href === 'xxx') {
if (getEditState() === 'xxx') {
await editConfirm()
}
}
await logoutConfirm();
}
功能上线后,新需求随即到来:在 VIP 充值页面退出时,需先展示 VIP 充值广告,用户关闭广告后再提示是否退出登录。
新的处理流程如下:

类似场景再次出现,经沟通后,那位同事略显无奈地继续编写代码,而我则继续处理其他事务。
const pages = {
editPage: async () => {
if (getEditState() === 'xxx') {
await editConfirm()
}
},
vipPage: async () => {
if (getUserVipState() === 'xxx') {
await vipConfirm()
}
}
}
const handlerLogout = async () => {
const curPage = getPage();
await pages[curPage];
await logoutConfirm();
}
随后功能再次上线,需求接踵而至:同一场景下存在多个弹窗业务,且优先级不同。若第一个弹窗不满足弹出条件,则尝试第二个,依此类推。面对无尽的需求,维护者开始思考如何简化逻辑:如果退出登录模块只关注退出逻辑,而各类弹窗由业务方自行处理,是否就能减轻负担?这种思路的核心在于触发时经过多层中间件处理,最终执行核心逻辑,这与洋葱模型的理念不谋而合。
洋葱模型
提到洋葱模型,Koa 框架的实现简洁而优雅,主要依赖 koa-compose 模块。该模块核心代码仅十几行,却巧妙运用了高阶函数、闭包、递归及尾调用优化等技术,堪称经典。简单来说,koa-compose 暴露的 compose 方法接收中间件数组,并返回一个 Promise 函数。源码如下
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}
源码中 compose 主要执行三个步骤:
- 第一步:对输入参数进行校验。
- 第二步:返回一个函数,利用闭包保存 middleware 数组和 index 索引值。
- 第三步:执行时调用 dispatch(0),默认从第一个中间件开始执行。
dispatch 函数的作用(即 next 函数)如下:
- 第一步:通过
i <= index来避免在同一个中间件中连续next调用 - 第二步:设置 index 的值为当前中间件位置的值,并且拿到当前中间件函数
- 第三步:判断当前是否还有中间件,没有返回
Promise.resolve() - 第四步:返回
Promise.resolve并把当前中间件执行结果做为返回,且传入context和next(dispatch)方法。这里利用尾调优化,避免了fn重新创建新的栈帧,同时提升了速度和节省了内存(大佬就是大佬)
我们可以通过其测试用例了解到执行的过程,有条件的读者可以通过下载源码进行断点调试,更能理解每一步的过程
it('should work', async () => {
const arr = []
const stack = []
stack.push(async (context, next) => {
arr.push(1) // 步骤1
await wait(1) // 步骤2
await next() // 步骤3
await wait(1) // 步骤14
arr.push(6) // 步骤15
})
stack.push(async (context, next) => {
arr.push(2) // 步骤4
await wait(1) // 步骤5
await next() // 步骤6
await wait(1) // 步骤12
arr.push(5) // 步骤13
})
stack.push(async (context, next) => {
arr.push(3) // 步骤7
await wait(1) // 步骤8
await next() // 步骤9
await wait(1) // 步骤10
arr.push(4) // 步骤11
})
await compose(stack)({})
expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
})
compose 接收一个参数,该参数是一个 Promise 数组,注入中间件后返回了一个执行函数并执行。此时会按照上诉我标记的步骤进行执行。配置 koa 文档中的 gif 示例和流程图更好理解。通过不断的递归加上 Promise 链式调用完成了整个中间件的执行


实践
在理解洋葱模型设计后,针对前述业务诉求,期望 stack.push 这部分内容由业务方自行注入,而退出登录模块只需执行 compose(stack)({}) 即可。额外需求是对弹窗进行优先级处理,即并非先进入先执行,而是根据优先级排序。对此对 middleware 定义进行改造,新增 level 字段表示优先级,后续据此排序,优先级越高,level 值越高。
type Middleware<T = unknown> = {
level: number;
middleware: (context: T | undefined, next: () => Promise<any>) => void;
};
因为我们需要提供给业务方一个接口来添加中间件,这里使用类来实现,通过暴露出 add 和 remove 方法对中间件进行添加和删除,利用 add 方法在添加时利用 level 对中间件进行排序,使用 stack 来保存已经排序好的中间件。dispatch 通过 CV 大法实现
class Scheduler<T> {
stack: Middleware<T>[] = [];
add(middleware: Middleware<T>) {
const index = this.stack.findIndex((it) => it.level <= middleware.level);
this.stack.splice(index === -1 ? this.stack.length : index, 0, middleware);
return () => {
this.remove(middleware);
};
}
remove(middleware: Middleware<T>) {
const index = this.stack.findIndex((it) => it === middleware);
index > -1 && this.stack.splice(index, 1);
}
dispatch(context?: T) {
// eslint-disable-next-line
const that = this;
let index = -1;
return mutate(0);
function mutate(i: number): Promise<void> {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
const fn = that.stack[i];
if (index === that.stack.length) return Promise.resolve();
try {
return Promise.resolve(fn.middleware(context, mutate.bind(null, i + 1)));
} catch (error) {
return Promise.reject(error);
}
}
}
}
export default Scheduler;
然后修改业务中的处理,之后再加类似需求就可以摸鱼了。
// 暴露一个logoutScheduler方法
export const logoutScheduler = new Scheduler();
const handleLogout = () => {
logoutScheduler.dispatch().then(() => {
logoutConfirm();
})
}
// 编辑页面
logoutScheduler.add({
level: 2,
middleware: async (_, next) => {
if (getEditState() === 'xxx') {
await editConfirm()
}
await next();
}
})
// vip页面
logoutScheduler.add({
level: 2,
middleware: async (_, next) => {
if (getUserVipState() === 'xxx') {
await vipConfirm()
}
await next();
}
})
总结
一个好的设计能在实际开发中更好的去解耦业务,而好的设计需要我们去阅读那些优秀的源码去学习和理解才能为我们所用。
以上就是 JavaScript 设计模式之洋葱模型原理及实践应用的详细内容,更多关于 JavaScript 设计模式洋葱模型的资料请关注本站其它相关文章!