引子
想给工具库的http请求方法加上abort方法,就去参考了一下axios的源码,顺便看了一下axios的拦截器实现,加上之前对redux,express,koa-compose的中间有一点了解,于是有了这篇 主动思考 我们根据使用过的中间件先思考一下,实现一些简单的中间件,再对比社区的一些中间件实现,进一步思考。
同步
const fns = [fn1, fn2, fn3];
for(var fn of fns){
fn()
}
异步
假设如下代码:异步乘2
const double = (val) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(val * 2);
console.log(val * 2);
}, 1000);
});
};
const fn1 = double;
const fn2 = double;
const fn3 = double;
const fns = [fn1, fn2, fn3];
我们只要一直.then()下去就能保证顺序执行,目标代码如下:
fn1(2)
.then((result) => {
return fn2(result);
})
.then((result) => {
return fn3(result);
})
.then((result) => {
console.log(result);
return result;
});
为了方便使用,我们需要写一个生成器,能够生成如上的代码。也很简单
function compose(fns) {
return (val) => {
let p = Promise.resolve(val);
// 循环调用.then()
for (let fn of fns) {
// 确保fn()返回promise
p = p.then((res) => Promise.resolve(fn(res)));
}
return p;
};
}
const c = compose(fns);
参考社区
Axios
// 使用中间件
InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected,
synchronous: options ? options.synchronous : false,
runWhen: options ? options.runWhen : null
});
return this.handlers.length - 1;
};
添加中间件
- fulfilled:成功回调
- rejected:失败回调
- synchronous:是否同步执行request中间件
- runWhen:运行时判断是否将添加该中间件
module.exports = function dispatchRequest(config) {
var adapter = config.adapter || defaults.adapter;
return adapter(config).then(function onAdapterResolution(response) {
return response;
}, function onAdapterRejection(reason) {
return Promise.reject(reason);
});
};
- dispatchRequest:实际的请求函数
- adapter:适配器,适配node和browser端的请求。
Axios.prototype.request = function request(config) {
// filter out skipped interceptors
var requestInterceptorChain = [];
var synchronousRequestInterceptors = true;
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
return;
}
synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
var responseInterceptorChain = [];
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
var promise;
// 如果不是同步拦截器,request和response拦截器都是用promise.then调用
if (!synchronousRequestInterceptors) {
var chain = [dispatchRequest, undefined];
Array.prototype.unshift.apply(chain, requestInterceptorChain);
chain.concat(responseInterceptorChain);
promise = Promise.resolve(config);
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
}
// 如果是同步拦截器,request先同步调用,再promise.then调用请求和响应拦截器
var newConfig = config;
// 构建prmise链的关键代码
while (requestInterceptorChain.length) {
var onFulfilled = requestInterceptorChain.shift();
var onRejected = requestInterceptorChain.shift();
try {
newConfig = onFulfilled(newConfig);
} catch (error) {
onRejected(error);
break;
}
}
try {
promise = dispatchRequest(newConfig);
} catch (error) {
return Promise.reject(error);
}
// 构建promise链的关键代码
while (responseInterceptorChain.length) {
promise = promise.then(responseInterceptorChain.shift(), responseInterceptorChain.shift());
}
return promise;
};
跟我们主动思考中的异步方法是一种思路,实现简单,容易理解:
- 定义request拦截器和response拦截器,手动插在
dispatchRequest前后。 - 根据request拦截器是否有同步的
- 如果request拦截器都是异步的,通过while循环,构建一条
promise.then(fulfilled, rejected).then(fulfilled, rejected)的链式调用。 - 如果request拦截器有一个是同步的,就会先通过while循环执行完request拦截器,拿到新的配置,再通过while循环构建一条
promise.then(fulfilled, rejected).then(fulfilled, rejected)的链式调用。
Redux
compose源码如下
function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
举个🌰:
// 中间件1
function add(next) {
return (num) => {
next(num + 1);
};
}
// 中间件2
function multiple(next) {
return (num) => {
setTimeout(() => {
next(num * 2);
}, 2000);
};
}
// 目标函数
function printf(num){
console.log(num)
}
const compute = compose(add, multiple)(printf)
compute(1)
拆解一下:
const compute = (...args) => {
return ((next) => {
return (num) => {
next(num + 1);
};
})(
((next) => {
return (num) => {
setTimeout(() => {
next(num * 2);
}, 2000);
};
})(...args)
);
};
简化一下结构如下
(...args) =>
(function m1(next) {
// ...
})(
(function m2(next) {
// ...
})(
(function printf(next) {
// ...
})(...args)
)
);
使用Array.prototype.reduce方法,将第下一个中间件作为上一个中间件的参数传进去,也就是将
- 组合:
compose([add, multiple])(printf)转换成add(mulitple(printf)),步骤拆解如下: - 将
printf作为mutiple的next参数,先执行mutiple(next),(num)=>{setTimeout(()=>{console.log(num*2)})} - 将返回结果作为
add的next参数,(num) => setTimeOut(()=>console.log((num+1)*2)) add(next)最终返回一个函数compute- 执行:最终调用
compute()时按add,multiple,printf执行
简单来说,反向装载,正向执行。且具有特点
- 组织灵活:既可以添加一层包裹函数公用参数,也可以next传递参数
- 静态生成:compose过程即生成函数的过程
其实这玩意叫pipe:具体可以参考JavaScript中的pipe
为什么定义redux中间件一定要返回一个函数,而koa-compose,expres不用?
Express
express的中间件弯弯绕绕,app.handle -> router.handle -> (layer.handle_request -> (route.dispatch -> layer.handle_request)...)...
Layer是存储path和handle(一个或多个中间件)关系的一个对象
- 一个:指
app.use的全局中间件,此时handle为自己定义的middleware函数 - 多个:指
router.get()的情况,handle为route.dispatch,然后通过递归调用自己定义的middleware函数
核心是一个两层的递归,简化一下代码如下:(并不一样)
function express() {
var funcs = []; // 待执行的函数数组
var app = function (req, res) {
var i = 0;
function next() {
var task = funcs[i++]; // 取出函数数组里的下一个函数
if (!task) {
// 如果函数不存在,return
return;
}
task(req, res, next); // 否则,执行下一个函数,注意这里没有return
}
next();
};
/**
* use方法就是把函数添加到函数数组中
* @param task
*/
app.use = function (task) {
funcs.push(task);
};
return app; // 返回实例
}
- 数组存储:使用数组存储全部的中间件函数。
- 函数都是中间件:把
app.get,app.use,router.get中的函数都当成了中间件。 - 执行器:通过调用next去执行下一个中间件。
- next:
()=>task[0]() - next:
()=>task[1]() - next:
()=>task[2]()第一次执行next,其实是执行task[0],接着在task[0]中执行next其实是执行task[1]...,一直到最后一个task
- next:
express和Redux有什么不同?
Koa-compose
这里用koa-compose做举例:
import compose from 'koa-compose';
async m1(ctx, next){
console.log('first before');
await next();
console.log('first after');
}
async m1(ctx , next){
console.log('second before');
await next();
console.log('second after');
}
const c = compose([m1, m2])
c(null, ()=>{
console.log("done")
})
// 运行结果
/**
* first before
* second before
* done
* second after
* first after
* /
源码如下:
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!')
}
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
// 终止条件,返回promise
if (!fn) return Promise.resolve()
try {
// 确保fn()返回promise,并修改中间件的next参数
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
解析源代码:
- 终止返回:如果!fn,返回
Promise.resolve() - 递归返回:
Promise.resolve(fn(context, dispatch.bind(null, i + 1))); - 洋葱心:
i === middleware.length时fn=next - 通过不断赋值中间件的next函数(非闭包中的外部目标函数next),next=m1,next=m2,最终next=next实现正向调用,另外await后面的代码相当于在promise.then中调用。
把上面的🌰拆解一下,经过koa-compose转化后执行的代码如下:
Promise.resolve(m1(context, () => {
return Promise.resolve(m2(ctx, () => {
return Promise.resolve(next(ctx, () => {
return Promise.resolve();
}));
}));
}));
有点抽象,将m1,m2,next展开简化如下(省略了传参):
Promise.resolve(
(async () => {
console.log('first before');
await Promise.resolve(
(async () => {
console.log('second before');
await Promise.resolve(
(() => {
console.log('done');
})()
)
console.log('second after')
})()
)
console.log('first after')
})()
)
- koa和express的实现思路是一样的,为什么express不是洋葱模型?
- 为什么一定要返回promise.resolve()?
解答疑问
-
为什么定义redux中间件一定要返回一个函数,而koa-compose,expres不用? 答:因为redux多了一个组合的过程,组合时需要将下一个中间件的执行结果作为上一个中间件的next参数也就是说中间件执行结果不是函数,传给上一个中间件就会报错:next is not a function
-
express和Redux有什么不同? 答:next介入方式不同,可以理解为redux是静态生成,在compose(...middlewares)(doSth)时已经确定next是什么,因而确定了执行顺序,而express是执行动态确定next是什么
-
koa和express有什么不同?为什么express不是洋葱模型? 答:因为expres调用task(res, req, next)时没有return,最终调用不是await promise,而是await undefind,所以没有形成洋葱模型。
-
为什么一定要返回promise.resolve()? 答:compose的结果一定要返回一个promise情况,防止只有请求中间件且为同步函数时报错。我们先去掉promise.resolve(),分别跑一下koa-compose、koa的测试用例:
- koa-compose的测试用例
报错:next function 应该返回 promise
- koa的测试用例
it('should merge properties', () => {
app1.use((ctx, next) => {
assert.equal(ctx.msg, 'hello');
ctx.status = 204;
});
return request(app1.listen())
.get('/')
.expect(204);
});
报了handleRequest的错误,看一下handleRequest的代码:
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
从这里也可以看出koa洋葱模型的最后一步,在中间件执行完之后再处理响应。而express在最后一个中间件中就处理了响应。
总结
四种中间件的区别
- axios最简单,直接promise链式调用
- redux语法需要返回函数,写的时候不是很方便,但是思路特殊,利用reduce,静态生成,将下一个中间的执行结果作为上一个中间件的next参数。
- express和koa-compose思路相同,都是利用递归(执行器)动态执行,从第一个中间件执行到最后一个中间件,区别是koa-compose通过return一个promise实现洋葱模型。
中间件的实现,本质都是使用了这两个跟执行顺序有关的东西:
- 函数调用栈:通过next介入执行顺序
- promise.then:通过promise.then实现异步顺序调用或洋葱模型。