前端網(wǎng)紅框架的插件機制全梳理(axios、koa、redux、vuex)
前言
前端中的庫很多,開發(fā)這些庫的作者會盡可能的覆蓋到大家在業(yè)務中千奇百怪的需求,但是總有無法預料到的,所以優(yōu)秀的庫就需要提供一種機制,讓開發(fā)者可以干預插件中間的一些環(huán)節(jié),從而完成自己的一些需求。
本文將從koa、axios、vuex和redux的實現(xiàn)來教你怎么編寫屬于自己的插件機制。
對于新手來說:
本文能讓你搞明白神秘的插件和攔截器到底是什么東西。對于老手來說:
在你寫的開源框架中也加入攔截器或者插件機制,讓它變得更加強大吧!
axios
首先我們模擬一個簡單的 axios,這里不涉及請求的邏輯,只是簡單的返回一個 Promise,可以通過 config 中的 error 參數(shù)控制 Promise 的狀態(tài)。
axios 的攔截器機制用流程圖來表示其實就是這樣的:
流程圖const?axios?=?config?=>?{
??if?(config.error)?{
????return?Promise.reject({
??????error:?"error?in?axios"
????});
??}?else?{
????return?Promise.resolve({
??????...config,
??????result:?config.result
????});
??}
};
如果傳入的 config 中有 error 參數(shù),就返回一個 rejected 的 promise,反之則返回 resolved 的 promise。
先簡單看一下 axios 官方提供的攔截器示例:
axios.interceptors.request.use(
??function(config)?{
????//?在發(fā)送請求之前做些什么
????return?config;
??},
??function(error)?{
????//?對請求錯誤做些什么
????return?Promise.reject(error);
??}
);
//?添加響應攔截器
axios.interceptors.response.use(
??function(response)?{
????//?對響應數(shù)據(jù)做點什么
????return?response;
??},
??function(error)?{
????//?對響應錯誤做點什么
????return?Promise.reject(error);
??}
);
可以看出,不管是 request 還是 response 的攔截器,都會接受兩個函數(shù)作為參數(shù),一個是用來處理正常流程,一個是處理失敗流程,這讓人想到了什么?
沒錯,promise.then接受的同樣也是這兩個參數(shù)。
axios 內(nèi)部正是利用了 promise 的這個機制,把 use 傳入的兩個函數(shù)作為一個intercetpor,每一個intercetpor都有resolved和rejected兩個方法。
//?把
axios.interceptors.response.use(func1,?func2)
//?在內(nèi)部存儲為
{
????resolved:?func1,
????rejected:?func2
}
接下來簡單實現(xiàn)一下,這里我們簡化一下,把axios.interceptor.request.use轉為axios.useRequestInterceptor來簡單實現(xiàn):
//?先構造一個對象?存放攔截器
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?=>?{
??const?chain?=?[
????{
??????resolved:?axios,
??????rejected:?undefined
????}
??];
??//?把請求攔截器往數(shù)組頭部推
??axios.interceptors.request.forEach(interceptor?=>?{
????chain.unshift(interceptor);
??});
??//?把響應攔截器往數(shù)組尾部推
??axios.interceptors.response.forEach(interceptor?=>?{
????chain.push(interceptor);
??});
??//?把config也包裝成一個promise
??let?promise?=?Promise.resolve(config);
??//?暴力while循環(huán)解憂愁
??//?利用promise.then的能力遞歸執(zhí)行所有的攔截器
??while?(chain.length)?{
????const?{?resolved,?rejected?}?=?chain.shift();
????promise?=?promise.then(resolved,?rejected);
??}
??//?最后暴露給用戶的就是響應攔截器處理過后的promise
??return?promise;
};
從axios.run這個函數(shù)看運行時的機制,首先構造一個chain作為 promise 鏈,并且把正常的請求也就是我們的請求參數(shù) axios 也構造為一個攔截器的結構,接下來
- 把 request 的 interceptor 給 unshift 到
chain頂部 - 把 response 的 interceptor 給 push 到
chain尾部
以這樣一段調(diào)用代碼為例:
//?請求攔截器1
axios.useRequestInterceptor(resolved1,?rejected1);
//?請求攔截器2
axios.useRequestInterceptor(resolved2,?rejected2);
//?響應攔截器1
axios.useResponseInterceptor(resolved1,?rejected1);
//?響應攔截器
axios.useResponseInterceptor(resolved2,?rejected2);
這樣子構造出來的 promise 鏈就是這樣的chain結構:
[
????請求攔截器2,//?↓config
????請求攔截器1,//?↓config
????axios請求核心方法,?//?↓response
????響應攔截器1,?//?↓response
????響應攔截器//?↓response
]
至于為什么 requestInterceptor 的順序是反過來的,仔細看看代碼就知道 XD。
有了這個chain之后,只需要一句簡短的代碼:
let?promise?=?Promise.resolve(config);
while?(chain.length)?{
??const?{?resolved,?rejected?}?=?chain.shift();
??promise?=?promise.then(resolved,?rejected);
}
return?promise;
promise 就會把這個鏈從上而下的執(zhí)行了。
以這樣的一段測試代碼為例:
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);
??}
);
- 成功的調(diào)用
在成功的調(diào)用下輸出 result1: extraParams1 extraParams2 message1
(async?function()?{
??const?result?=?await?axios.run({
????message:?"message1"
??});
??console.log("result1:?",?result);
})();
- 失敗的調(diào)用
(async?function()?{
??const?result?=?await?axios.run({
????error:?true
??});
??console.log("result3:?",?result);
})();
在失敗的調(diào)用下,則進入響應攔截器的 rejected 分支:
首先打印出攔截器定義的錯誤日志:error { error: 'error in axios' }
然后由于失敗的攔截器
error?=>?{
??console.log('error',?error)
},
沒有返回任何東西,打印出result3: undefined
可以看出,axios 的攔截器是非常靈活的,可以在請求階段任意的修改 config,也可以在響應階段對 response 做各種處理,這也是因為用戶對于請求數(shù)據(jù)的需求就是非常靈活的,沒有必要干涉用戶的自由度。
vuex
vuex 提供了一個 api 用來在 action 被調(diào)用前后插入一些邏輯:
https://vuex.vuejs.org/zh/api/#subscribeaction
store.subscribeAction({
??before:?(action,?state)?=>?{
????console.log(`before?action?${action.type}`);
??},
??after:?(action,?state)?=>?{
????console.log(`after?action?${action.type}`);
??}
});
其實這有點像 AOP(面向切面編程)的編程思想。
在調(diào)用store.dispatch({ type: 'add' })的時候,會在執(zhí)行前后打印出日志
before?action?add
add
after?action?add
來簡單實現(xiàn)一下:
import?{
??Actions,
??ActionSubscribers,
??ActionSubscriber,
??ActionArguments
}?from?"./vuex.type";
class?Vuex?{
??state?=?{};
??action?=?{};
??_actionSubscribers?=?[];
??constructor({?state,?action?})?{
????this.state?=?state;
????this.action?=?action;
????this._actionSubscribers?=?[];
??}
??dispatch(action)?{
????//?action前置監(jiān)聽器
????this._actionSubscribers.forEach(sub?=>?sub.before(action,?this.state));
????const?{?type,?payload?}?=?action;
????//?執(zhí)行action
????this.action[type](this.state,?payload).then(()?=>?{
??????//?action后置監(jiān)聽器
??????this._actionSubscribers.forEach(sub?=>?sub.after(action,?this.state));
????});
??}
??subscribeAction(subscriber)?{
????//?把監(jiān)聽者推進數(shù)組
????this._actionSubscribers.push(subscriber);
??}
}
const?store?=?new?Vuex({
??state:?{
????count:?0
??},
??action:?{
????async?add(state,?payload)?{
??????state.count?+=?payload;
????}
??}
});
store.subscribeAction({
??before:?(action,?state)?=>?{
????console.log(`before?action?${action.type},?before?count?is?${state.count}`);
??},
??after:?(action,?state)?=>?{
????console.log(`after?action?${action.type},??after?count?is?${state.count}`);
??}
});
store.dispatch({
??type:?"add",
??payload:?2
});
此時控制臺會打印如下內(nèi)容:
before?action?add,?before?count?is?0
after?action?add,?after?count?is?2
輕松實現(xiàn)了日志功能。
當然 Vuex 在實現(xiàn)插件功能的時候,選擇性的將 type payload 和 state 暴露給外部,而不再提供進一步的修改能力,這也是框架內(nèi)部的一種權衡,當然我們可以對 state 進行直接修改,但是不可避免的會得到 Vuex 內(nèi)部的警告,因為在 Vuex 中,所有 state 的修改都應該通過 mutations 來進行,但是 Vuex 沒有選擇把 commit 也暴露出來,這也約束了插件的能力。
redux
想要理解 redux 中的中間件機制,需要先理解一個方法:compose
function?compose(...funcs:?Function[])?{
??return?funcs.reduce((a,?b)?=>?(...args:?any)?=>?a(b(...args)));
}
簡單理解的話,就是compose(fn1, fn2, fn3) (...args) = > fn1(fn2(fn3(...args)))
它是一種高階聚合函數(shù),相當于把 fn3 先執(zhí)行,然后把結果傳給 fn2 再執(zhí)行,再把結果交給 fn1 去執(zhí)行。
有了這個前置知識,就可以很輕易的實現(xiàn) redux 的中間件機制了。
雖然 redux 源碼里寫的很少,各種高階函數(shù)各種柯里化,但是抽絲剝繭以后,redux 中間件的機制可以用一句話來解釋:
把 dispatch 這個方法不斷用高階函數(shù)包裝,最后返回一個強化過后的 dispatch
以 logMiddleware 為例,這個 middleware 接受原始的 redux dispatch,返回的是
const?typeLogMiddleware?=?dispatch?=>?{
??//?返回的其實還是一個結構相同的dispatch,接受的參數(shù)也相同
??//?只是把原始的dispatch包在里面了而已。
??return?({?type,?...args?})?=>?{
????console.log(`type?is?${type}`);
????return?dispatch({?type,?...args?});
??};
};
有了這個思路,就來實現(xiàn)這個 mini-redux 吧:
function?compose(...funcs)?{
??return?funcs.reduce((a,?b)?=>?(...args)?=>?a(b(...args)));
}
function?createStore(reducer,?middlewares)?{
??let?currentState;
??function?dispatch(action)?{
????currentState?=?reducer(currentState,?action);
??}
??function?getState()?{
????return?currentState;
??}
??//?初始化一個隨意的dispatch,要求外部在type匹配不到的時候返回初始狀態(tài)
??//?在這個dispatch后 currentState就有值了。
??dispatch({?type:?"INIT"?});
??let?enhancedDispatch?=?dispatch;
??//?如果第二個參數(shù)傳入了middlewares
??if?(middlewares)?{
????//?用compose把middlewares包裝成一個函數(shù)
????//?讓dis
????enhancedDispatch?=?compose(...middlewares)(dispatch);
??}
??return?{
????dispatch:?enhancedDispatch,
????getState
??};
}
接著寫兩個中間件
//?使用
const?otherDummyMiddleware?=?dispatch?=>?{
??//?返回一個新的dispatch
??return?action?=>?{
????console.log(`type?in?dummy?is?${type}`);
????return?dispatch(action);
??};
};
//?這個dispatch其實是otherDummyMiddleware執(zhí)行后返回otherDummyDispatch
const?typeLogMiddleware?=?dispatch?=>?{
??//?返回一個新的dispatch
??return?({?type,?...args?})?=>?{
????console.log(`type?is?${type}`);
????return?dispatch({?type,?...args?});
??};
};
//?中間件從右往左執(zhí)行。
const?counterStore?=?createStore(counterReducer,?[
??typeLogMiddleware,
??otherDummyMiddleware
]);
console.log(counterStore.getState().count);
counterStore.dispatch({?type:?"add",?payload:?2?});
console.log(counterStore.getState().count);
//?輸出:
//?0
//?type?is?add
//?type?in?dummy?is?add
//?2
koa
koa 的洋蔥模型想必各位都聽說過,這種靈活的中間件機制也讓 koa 變得非常強大,本文也會實現(xiàn)一個簡單的洋蔥中間件機制。參考(umi-request 的中間件機制)
洋蔥圈對應這張圖來看,洋蔥的每一個圈就是一個中間件,它即可以掌管請求進入,也可以掌管響應返回。
它和 redux 的中間件機制有點類似,本質上都是高階函數(shù)的嵌套,外層的中間件嵌套著內(nèi)層的中間件,這種機制的好處是可以自己控制中間件的能力(外層的中間件可以影響內(nèi)層的請求和響應階段,內(nèi)層的中間件只能影響外層的響應階段)
首先我們寫出Koa這個類
class?Koa?{
??constructor()?{
????this.middlewares?=?[];
??}
??use(middleware)?{
????this.middlewares.push(middleware);
??}
??start({?req?})?{
????const?composed?=?composeMiddlewares(this.middlewares);
????const?ctx?=?{?req,?res:?undefined?};
????return?composed(ctx);
??}
}
這里的 use 就是簡單的把中間件推入中間件隊列中,那核心就是怎樣去把這些中間件組合起來了,下面看composeMiddlewares方法:
function?composeMiddlewares(middlewares)?{
??return?function?wrapMiddlewares(ctx)?{
????//?記錄當前運行的middleware的下標
????let?index?=?-1;
????function?dispatch(i)?{
??????//?index向后移動
??????index?=?i;
??????//?找出數(shù)組中存放的相應的中間件
??????const?fn?=?middlewares[i];
??????//?最后一個中間件調(diào)用next?也不會報錯
??????if?(!fn)?{
????????return?Promise.resolve();
??????}
??????return?Promise.resolve(
????????fn(
??????????//?繼續(xù)傳遞ctx
??????????ctx,
??????????// next方法,允許進入下一個中間件。
??????????()?=>?dispatch(i?+?1)
????????)
??????);
????}
????//?開始運行第一個中間件
????return?dispatch(0);
??};
}
簡單來說 dispatch(n)對應著第 n 個中間件的執(zhí)行,而 dispatch(n)又擁有執(zhí)行 dispatch(n + 1)的權力,
所以在真正運行的時候,中間件并不是在平級的運行,而是嵌套的高階函數(shù):
dispatch(0)包含著 dispatch(1),而 dispatch(1)又包含著 dispatch(2) 在這個模式下,我們很容易聯(lián)想到try catch的機制,它可以 catch 住函數(shù)以及函數(shù)內(nèi)部繼續(xù)調(diào)用的函數(shù)的所有error。
那么我們的第一個中間件就可以做一個錯誤處理中間件:
//?最外層?管控全局錯誤
app.use(async?(ctx,?next)?=>?{
??try?{
????//?這里的next包含了第二層以及第三層的運行
????await?next();
??}?catch?(error)?{
????console.log(`[koa?error]:?${error.message}`);
??}
});
在這個錯誤處理中間件中,我們把 next 包裹在 try catch 中運行,調(diào)用了 next 后會進入第二層的中間件:
//?第二層?日志中間件
app.use(async?(ctx,?next)?=>?{
??const?{?req?}?=?ctx;
??console.log(`req?is?${JSON.stringify(req)}`);
??await?next();
??//?next過后已經(jīng)能拿到第三層寫進ctx的數(shù)據(jù)了
??console.log(`res?is?${JSON.stringify(ctx.res)}`);
});
在第二層中間件的 next 調(diào)用后,進入第三層,業(yè)務邏輯處理中間件
//?第三層?核心服務中間件
//?在真實場景中?這一層一般用來構造真正需要返回的數(shù)據(jù)?寫入ctx中
app.use(async?(ctx,?next)?=>?{
??const?{?req?}?=?ctx;
??console.log(`calculating?the?res?of?${req}...`);
??const?res?=?{
????code:?200,
????result:?`req?${req}?success`
??};
??//?寫入ctx
??ctx.res?=?res;
??await?next();
});
在這一層把 res 寫入 ctx 后,函數(shù)出棧,又會回到第二層中間件的await next()后面
console.log(`req?is?${JSON.stringify(req)}`);
await?next();
//?<-?回到這里
console.log(`res?is?${JSON.stringify(ctx.res)}`);
這時候日志中間件就可以拿到ctx.res的值了。
想要測試錯誤處理中間件 就在最后加入這個中間件
//?用來測試全局錯誤中間件
//?注釋掉這一個中間件?服務才能正常響應
app.use(async?(ctx,?next)?=>?{
??throw?new?Error("oops!?error!");
});
最后要調(diào)用啟動函數(shù):
app.start({?req:?"ssh"?});
控制臺打印出結果:
req?is?"ssh"
calculating?the?res?of?ssh...
res?is?{"code":200,"result":"req?ssh?success"}
總結
axios把用戶注冊的每個攔截器構造成一個 promise.then 所接受的參數(shù),在運行時把所有的攔截器按照一個 promise 鏈的形式以此執(zhí)行。
- 在發(fā)送到服務端之前,config 已經(jīng)是請求攔截器處理過后的結果
- 服務器響應結果后,response 會經(jīng)過響應攔截器,最后用戶拿到的就是處理過后的結果了。
vuex的實現(xiàn)最為簡單,就是提供了兩個回調(diào)函數(shù),vuex 內(nèi)部在合適的時機去調(diào)用(我個人感覺大部分的庫提供這樣的機制也足夠了)。redux的源碼里寫的最復雜最繞,它的中間件機制本質上就是用高階函數(shù)不斷的把 dispatch 包裝再包裝,形成套娃。本文實現(xiàn)的已經(jīng)是精簡了 n 倍以后的結果了,不過復雜的實現(xiàn)也是為了很多權衡和考量,Dan 對于閉包和高階函數(shù)的運用已經(jīng)爐火純青了,只是外人去看源碼有點頭禿...koa的洋蔥模型實現(xiàn)的很精妙,和 redux 有相似之處,但是在源碼理解和使用上個人感覺更優(yōu)于 redux 的中間件。
中間件機制其實是非框架強相關的,請求庫一樣可以加入 koa 的洋蔥中間件機制(如 umi-request),不同的框架可能適合不同的中間件機制,這還是取決于你編寫的框架想要解決什么問題,想給用戶什么樣的自由度。
希望看了這篇文章的你,能對于前端庫中的中間件機制有進一步的了解,進而為你自己的前端庫加入合適的中間件能力。
本文所寫的代碼都整理在這個倉庫里了:
https://github.com/sl1673495/tiny-middlewares
代碼是使用 ts 編寫的,js 版本的代碼在 js 文件夾內(nèi),各位可以按自己的需求來看。
推薦閱讀
我的公眾號能帶來什么價值?(文末有送書規(guī)則,一定要看)
每個前端工程師都應該了解的圖片知識(長文建議收藏)
為什么現(xiàn)在面試總是面試造火箭?
