前言前端有很多库。开发这些库的作者会尽量覆盖大家在业务中的奇葩需求,但总有意想不到的,所以一个优秀的库需要提供一种机制,让开发者可以在插件中间介入一些环节,从而满足自己的一些需求。本文将从koa、axios、vuex、redux的实现,教你如何编写自己的插件机制。对于初学者:本文将帮助您弄清楚那些神秘的插件和拦截器是什么。给老手:给自己写的开源框架加上拦截器或插件机制,让它更强大!axios首先我们模拟一个简单的axios,不涉及请求的逻辑,只是简单的返回一个Promise,可以通过config.xml中的error参数控制Promise的状态。axios的拦截器机制用流程图来表示,其实是这样的:({...config,result:config.result});}};如果传入的配置中有错误参数,它将返回一个被拒绝的承诺,否则它将返回一个已解决的承诺。我们简单看一下axios官方提供的拦截器示例:axios.interceptors.request.use(function(config){//dosomethingbeforesendtherequestreturnconfig;},function(error){//dosomethingabouttherequesterrorWhatreturnPromise.reject(error);});//添加响应拦截器axios.interceptors.response.use(function(response){//用响应数据做点什么returnresponse;},function(error){//Yes做一些事情来响应错误returnPromise.reject(error);});可以看出无论是请求还是响应拦截器,都会接受两个函数作为参数,一个用于处理正常进程,一个用于处理失败进程。这让你想起什么?是的,promise.then也接受这两个参数。axios内部使用了promise的机制,将use传入的两个函数作为拦截器,每个拦截器有resolved和rejected两个方法。//将axios.interceptors.response.use(func1,func2)//在内部存储为{resolved:func1,rejected:func2}然后简单地实现它。这里我们简化一下,转为axios.interceptor.request.useaxios.useRequestInterceptor的简单实现://首先构造一个对象存储拦截器axios.interceptors={request:[],response:[]};//注册请求拦截器axios.useRequestInterceptor=(resolved,rejected)=>{axios.interceptors.request.push({resolved,rejected});};//注册响应拦截器axios.useResponseInterceptor=(resolved,rejected)=>{axios.interceptors.response.push({resolved,rejected});};//运行拦截器axios.run=config=>{constchain=[{resolved:axios,rejected:undefined}];//将请求拦截器推到头部arrayaxios.interceptors.request.forEach(interceptor=>{chain.unshift(interceptor);});//将响应拦截器推到数组末尾axios.interceptors.response.forEach(interceptor=>{chain.push(interceptor);});//把config包装成一个promiseletpromise=Promise.resolve(config);//暴力while循环解决后顾之忧//利用promise.then的能力递归y执行所有拦截器while(chain.length){const{resolved,rejected}=chain.shift();promisepromise=promise.then(resolved,rejected);}//最后暴露给用户的是响应拦截器之后被处理承诺回报承诺;};从axios.run函数看runtime机制,首先构造一个chain作为promise链,构造一个普通的request,也就是我们的请求参数axios,作为拦截器结构,然后requestUnshift将拦截器放到最上面链,并将响应的拦截器推送到链的末尾。以这样一段调用代码为例://请求拦截器1axios.useRequestInterceptor(resolved1,rejected1);//请求拦截器2axios.useRequestInterceptor(resolved2,rejected2);//响应拦截器1axios.useResponseInterceptor(resolved1,rejected1);//响应拦截器axios.useResponseInterceptor(resolved2,rejected2);这样构建的promise链是这样一个链式结构:[Request拦截器2,//↓config请求拦截器1,//↓configaxios请求核心方法,//↓response响应拦截器1,//↓response响应拦截器//↓回复]至于为什么requestInterceptor的顺序是反的,仔细看代码就知道XD有了这个chain之后,只需要一段短代码:letpromise=Promise.resolve(config);while(chain.length){const{resolved,rejected}=chain.shift();promisepromise=承诺。then(resolved,rejected);}returnpromise;promise将从上到下执行链。以这样一段测试代码为例:axios.useRequestInterceptor(config=>{return{...config,extraParams1:"extraParams1"};});axios.useRequestInterceptor(config=>{return{...config,extraParams2:"extraParams2"};});axios.useResponseInterceptor(resp=>{const{extraParams1,extraParams2,result:{code,message}}=resp;return`${extraParams1}${extraParams2}${message}`;},error=>{console.log("error",error);});(1)Successfulcallsoutputresult1undersuccessfulcalls:extraParams1extraParams2message1(asyncfunction(){constresult=awaitaxios.run({message:"message1"});console.log("result1:",result);})();(2)调用失败(asyncfunction(){constresult=awaitaxios.run({error:true});console.log("result3:",result);})();在failedcall下,进入response拦截器的rejected分支:先打印出拦截器定义的错误日志:error{error:'errorinaxios'}然后由于Failedinterceptorerror=>{console.log('error',error)},没有返回任何东西,打印出result3:undefined可见axios的拦截器非常灵活,可以在request阶段config任意修改,还可以在response中对response做各种处理阶段,这也是因为用户对请求数据的需求是非常灵活的,没有必要去干涉用户自由度vuexvuex提供了一个api,可以在action调用前后插入一些逻辑:https://vuex.vuejs.org/zh/api/#subscribeactionstore.subscribeAction({before:(action,state)=>{console.log(`beforeaction${action.type}`);},after:(action,state)=>{console.log(`afteraction${action.type}`);}});其实这有点像AOP(aspect-orientedprogramming)的编程思想。store.dispatch({type:'add'})调用时会在执行前后打印日志beforeactionaddaddafteractionadd简单实现:import{Actions,ActionSubscribers,ActionSubscriber,ActionArguments}from"./vuex.type";classVuex{state={};action={};_actionSubscribers=[];constructor({state,action}){this.state=state;this.action=action;this._actionSubscribers=[];}dispatch(action){//action预监听this._actionSubscribers.forEach(sub=>sub.before(action,this.state));const{type,payload}=action;//执行actionthis.action[type](this.state,payload).then(()=>{//actionpostlistenerthis._actionSubscribers.forEach(sub=>sub.after(action,this.state));});}subscribeAction(subscriber){//推送监听器放入数组中store.subscribeAction({before:(action,state)=>{console.log(`beforeaction${action.type},beforecountis${state.count}`);},after:(action,state)=>{console.log(`afteraction${action.type},aftercountis${state.count}`);}});store.dispatch({type:"add",payload:2});这时候控制台会打印出如下内容:beforeactionadd,beforecountis0afteractionadd,aftercountis2容易实现当然,Vuex在实现插件功能的时候,是选择性的对外暴露类型payload和state,没有提供进一步的修改能力。这也是框架内的取舍。当然我们可以直接修改state,但是在Vuex内部得到warning是难免的,因为在Vuex中,所有的state修改都应该通过mutation来完成,但是Vuex并没有选择暴露commit,这也限制了插入。redux要想理解redux中的中间件机制,首先需要理解一个方法:composefunctioncompose(...funcs:Function[]){returnfuncs.reduce((a,b)=>(...args:any)=>a(b(...args)));}简单理解就是compose(fn1,fn2,fn3)(...args)=>fn1(fn2(fn3(...args)))是一个高阶聚合函数,相当于先执行fn3,然后把结果传给fn2执行,再把结果传给fn1执行。有了这些前置知识,就可以轻松实现redux的中间件机制了。虽然redux源码里面写的很少,柯里化了各种高阶函数,但是经过茧化之后,redux中间件机制可以一句话解释:用高阶函数包裹dispatch方法,最后返回一个强化派发以logMiddleware为例。这个中间件接受原来的reduxdispatchandreturnsconsttypeLogMiddleware=dispatch=>{//返回一个结构相同的dispatch,接受同样的参数//只是把原来的dispatch包裹在里面而已。return({type,...args})=>{console.log(`typeis${type}`);returndispatch({type,...args});};};有了这个想法,来实现这个mini-redux:functioncompose(...funcs){returnfuncs.reduce((a,b)=>(...args)=>a(b(...args)));}functioncreateStore(reducer,middlewares){letcurrentState;functiondispatch(action){currentState=reducer(currentState,action);}functiongetState(){returncurrentState;}//初始化一个随机dispatch,要求外部返回初始状态typedoesnotmatch//在这次分派之后,currentState有一个值。dispatch({type:"INIT"});lettenhancedDispatch=dispatch;//如果第二个参数传入middlewaresif(middlewares){//使用compose将中间件包装成一个函数//让disenhancedDispatch=compose(...middlewares)(dispatch);}return{dispatch:enhancedDispatch,getState};}然后写两个中间件//使用constotherDummyMiddleware=dispatch=>{//返回一个新的dispatchreturnaction=>{console.log(`typeindummyis${type}`);returndispatch(action);};};//这个dispatch实际上是由otherDummyMiddleware执行并返回otherDummyDispatchconsttypeLogMiddleware=dispatch=>{//返回一个新的dispatchreturn({type,...args})=>{console.log(`typeis${type}`);returndispatch({type,...args});};};//中间件是从右到左执行的。constcounterStore=createStore(counterReducer,[typeLogMiddleware,otherDummyMiddleware]);console.log(counterStore.getState().count);counterStore.dispatch({type:"add",payload:2});console.log(counterStore.getState().count);//Output://0//typeisadd//typeindummyisadd//2koakoa的洋葱模型想必大家都听过。这种灵活的中间件机制也让koa变得非常强大,本文也将实现一个简单的onion中间件机制。参考这张图对应的(umi-requestmiddlewaremechanism)洋葱圈,每一个洋葱圈都是一个中间件,可以控制请求的进入和响应的返回。有点类似于redux的中间件机制。它本质上是高级函数的嵌套。外部中间件嵌套内部中间件。这种机制的好处是可以自己控制中间件(外层的中间件可以影响内层的请求和响应阶段,内层的中间件只能影响外层的响应阶段)首先我们编写Koa类classKoa{constructor(){this.middlewares=[];}use(middleware){this.middlewares.push(middleware);}start({req}){constcomposed=composeMiddlewares(this.middlewares);constctx={req,res:undefined};returncomposed(ctx);}}这里使用只是将中间件推入中间件队列。核心就是如何把这些中间件组合起来。再看composeMiddlewares方法:functioncomposeMiddlewares(middlewares){returnfunctionwrapMiddlewares(ctx){//记录当前运行的中间件markletindex=-1;functiondispatch(i){//index向后移动iindex=i;//找到对应的中间件存入数组constfn=middlewares[i];//最后一个中间件调用next会报错if(!fn){returnPromise.resolve();}returnPromise.resolve(fn(//继续传ctxctx,//next方法,允许进入下一个中间件。()=>dispatch(i+1)));}//开始运行第一个中间件returndispatch(0);};}简单来说,dispatch(n)对应第n个中间件的执行,dispatch(n)也有执行dispatch(n+1)的权力,所以在实际运行时,中间件并不是运行在同一层,而是一个嵌套的高阶函数:dispatch(0)包含dispatch(1),dispatch(1)containsdispatch(2)在这种模式下,我们很容易想到trycatch的机制,可以捕获函数内部继续调用的函数和函数的所有错误。那么我们的第一个中间件可以是错误处理中间件://最外层控制全局错误app.use(async(ctx,next)=>{try{//这里的next包含了第二层和第三层的操作层awaitnext();}catch(error){console.log(`[koaerror]:${error.message}`);}});在这个错误处理中间件中,我们将nextRun包裹在trycatch中,调用next之后,会进入到第二层的中间件://第二层日志中间件app.use(async(ctx,next)=>{const{req}=ctx;console.log(`reqis${JSON.stringify(req)}`);awaitnext();//next之后可以得到第三层写入ctx的数据console.log(`resis${JSON.stringify(ctx.res)}`);});第二层中间件接下来的调用之后,进入第三层,业务逻辑处理中间件//第三层核心服务中间件//这一层一般在实际场景中用来构建真正需要返回的数据,写入ctxapp.use(async(ctx,next)=>{const{req}=ctx;console.log(`calculatingtheresof${req}...`);constres={code:200,result:`req${req}success`};//写入ctxctx.res=res;awaitnext();});在本层将res写入ctx后,函数出栈,并会在awaitnext()console.log(`reqis${JSON.stringify(req)}`);awaitnext()后回到第二层中间件;//<-回到这里console.log(`resis${JSON.stringify(ctx.res)}`);这时候日志中间件就可以拿到ctx.res的值了。如果要测试错误处理中间件,在最后加上这个中间件//用来测试全局错误中间件//注释掉这个中间件服务才能正常响应app.use(async(ctx,next)=>{thrownewError("oops!错误!”);});最后调用启动函数:app.start({req:"ssh"});控制台打印出结果:reqis"ssh"calculatingtheresofssh...resis{"code":200,"result":"reqsshsuccess"}总结(1)axios将用户注册的每个拦截器构造成promise接受的参数。然后,在运行时将所有拦截器以promise链的形式放入。实施。在发送到服务器之前,config已经是请求拦截器处理的结果。服务器响应后,响应会经过响应拦截器,最终用户得到处理后的结果。(2)vuex的实现最简单,就是提供了两个回调函数,在合适的时候由vuex调用(个人感觉大部分库提供这样的机制就够了)。(3)redux的源码是最复杂的。它的中间件机制本质上是利用高阶函数不断的打包再打包dispatch,形成套娃。本文的实现是简化了n倍的结果,但是复杂的实现也是为了很多的取舍和考虑。Dan已经掌握了闭包和高阶函数的使用,但是外行人看源码有点秃……(4)Koa的onion模型实现的很精致,和redux差不多,但是个人感觉在源代码理解和使用方面优于redux中间件。中间件机制其实和框架没有强关联。request库也可以加入koaonion中间件机制(如umi-request)。不同的框架可能适用于不同的中间件机制。这仍然取决于您要解决的框架。什么问题,要给用户什么样的自由。希望大家在阅读本文后,能够对前端库中的中间件机制有更深入的了解,进而为自己的前端库添加合适的中间件能力。本文写的代码整理在这个仓库:https://github.com/sl1673495/tiny-middlewares代码是用ts写的,js版本的代码在js文件夹下。您可以根据自己的需要对其进行自定义。看。
