前言
在我们日常前端开发中,插件这个词可以说是再熟悉不过了,出现的频率相当之高。
不论是古早的 JQuery, Backbone, KnockOut 还是现今流行的 React, Vue3, Axios, Webpack 亦或是 Koa, Express 中都有插件的身影,可谓无所不在,无孔不入。
那插件到底有什么神奇之处呢,本文就挑选几个使用最频繁的框架,对比其插件系统的实现,来彻底弄明白这个神器,以帮助我们构建出鲁棒性更强的代码,或是更好的和面试官谈笑风生。
这四种插件系统,分别是在 Redux
,Koa
,Webpack
和 Axios
中。
Redux 中的插件系统
闲言少叙,言归正传,我们先来回顾下 Redux
是如何践行单项数据流模型的(数据的流向和插件的构建息息相关)。
数据模型还是比较清晰的,
- view 中的某些操作如点击、滚动会触发 dispatch 函数(自于 redux,入参为 action)。
- dispatch 会将 action 传入到 reducer 中,reducer 则根据传入的 action 更新 store。
- store 更新后触发 view 组件的更新,页面上即显示最新的数据。
而 Redux
中的插件就是在 dispatch(action)
阶段前后做文章,先看下 Redux
官方文档是怎么描述插件的,
Redux middleware provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. People use Redux middleware for logging, crash reporting, talking to an asynchronous API, routing, and more.
Redux 通过中间件机制在 dispatching action 之间,引入了第三方扩展点的能力。通过扩展点可以实现统一的日志打印、崩溃上报、异步调用等诸多功能。
到这里,思考 1
秒中,要实现这样的功能我们会怎么做?
问题拆解
我们对这个功能进行一下拆分,拆解成多个子问题
- 如何做到不侵入业务代码完成相关功能?
- 多个插件如何进行有效整合?
- 碰到异步情况如何解决?
如何做到不侵入业务代码完成相关功能?
设想一个实际场景,日志打印功能,该功能需要打印 dispatch(action)
前后 store
状态的变化。那这个问题如果用侵入方案来解决,想必大家应该都知道该怎么做,无非就是在调用dispatch(action)
代码前后调用console.log
。
那如何做到不侵入调用 dispatch(action)
的业务代码呢?
大胆假设是否可以对 dispatch
函数进行重写?其实是可以当,我们先来看dispatch
的源码,
export function createStore(reducer, preloadedState, enhancer) {
function dispatch(action) {
...
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
...
}
}
这里就是 redux
中 dispatch
函数的关键部分,其中 currentState = currentReducer(currentState, action)
,就是调用 reducer
函数传入当前 state
和 action
然后返回新的 state
。所以假如我们能在这个地方前后打上日志,不就解决了侵入业务代码的问题么。但直接改 redux
的代码显然也是不现实的。
我们再来看一段源码,
export function createStore(reducer, preloadedState, enhancer) {
...
const store = {
dispatch: dispatch as Dispatch<A>,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
return store
...
}
dispatch
方法其实在调用 createStore
后导出了,这样在我们调用完 createStore
时就可以在业务层面拿到 dispatch
方法了,也就意味着可以在业务层面对 dispatch
进行包装了。
通常我们在项目中是会调用 createStore
来创建全局状态的,此时我们可以进行一下修改,如下代码,
const store = createStore(...);
+const dispatch = store.dispatch;
+
+store.dispatch = (action) => {
+ console.log(`before dispatch`, action, store.getState())
+ dispatch(action);
+ console.log(`after dispatch`, action, store.getState())
+}
这样我们就在 dispatch(action)
前后完成了日志的打印,做到了不侵入业务代码完成相关功能。到这里其实代码就能用了,根据又不是不能用原则
,理论上到这里大家就可以下课了。
多个插件如何进行有效整合?
首先出现在脑海的,一种简单粗暴的方式就是,
// 第一个插件
let dispatch = store.dispatch;
store.dispatch = (action) => {
console.log(`before dispatch 0`, action, store.getState())
dispatch(action);
console.log(`after dispatch 0`, action, store.getState())
}
// 第二个插件
dispatch = store.dispatch;
store.dispatch = (action) => {
console.log(`before dispatch 1`, action, store.getState())
dispatch(action);
console.log(`after dispatch 1`, action, store.getState())
}
// 第三个插件
...
这样的话,碰到小项目,插件不多的情况下勉强能用,但是一旦项目有点规模这种做法就容易产生意大利面代码了
,面吃多了久而久之就成了屎山
。所以需要一定的模式规范,来约束多插件的使用场景。
分析下暴力使用多插件场景,像不像高阶函数层层包裹。
那么现在我们的问题就转化为如何将层层包裹的高阶函数转化为符合人体工学
的编程方式。函数一层层的执行其实就是执行完一个再执行下一个,这种 One by One
的方式是不是很像数组的执行顺序,通过 for
循环依次拿到数组中的数据,如果数据是函数,那就去执行。很清晰得到如下代码,
// logger1, logger2 为插件函数
const plugins = [dispatch, logger1, logger2];
const action = { type:"action" }
for(let i = 0; i < plugins.length; i++) {
const fn = plugins[i];
fn(action);
}
但是这样只是执行了函数,并没有将函数串联起来,也没法获取执行结果。要怎么办呢,这样修改试试看,
const dispatch = (action) => { console.log(action); return "res"; }
const plugins = [dispatch, logger1, logger2];
const action = { type:"action" }
let res = null;
for(let i = 0; i < plugins.length; i++) {
const fn = plugins[i];
res = fn(action, res);
}
// 而在插件内部,需要对执行结果进行返回,如:
function logger1(action, res) {
console.log("logger1", action, res);
return res;
}
function logger2(action, res) {
console.log("logger2", action, res);
return res;
}
这样就在每个插件中拿到了action
和 最终执行的结果,这样只是拿到了处理结果,如果想要在下个函数处理前做一些预操作,需要进一步改造。需要将插件改造成接收 action
和下一个插件函数的形式。这样我们在插件内部调用下一个插件了。具体做法如下,
const dispatch = (action) => { console.log(action); return "res"; }
const plugins = [dispatch, logger1, logger2];
let i = 0;
const action = { type:"action" }
const next = (action) => {
const reservedPlugins = plugins.reserve();
const fn = plugins[i++];
return fn(action, next);
}
next(action);
function logger1(action, next) {
console.log("before logger1");
const res = next(action);
console.log("after logger1", res);
return res;
}
function logger2(action, next) {
console.log("before logger2");
const res = next(action);
console.log("after logger2", res);
return res;
}
到此,我们就将各个插件函数串联起来了,并且能完美的在函数调用前后进行一些操作。而其中有一行 const reservedPlugins = plugins.reserve();
比较关键,如果这里不进行反转操作的话,会先执行原生的dispatch
这样就无法串联起来了。这也是为什么 redux
中的 compose
函数中会用 reduce
对插件数组进行反转。
异步情况如何解决?
到目前离 redux
中的最终形态还有一步之遥,我们和异步情况一并解决。
由于插件是链式调用的,所以我们可以在任意插件中对链式调用进行打断。利用这个特性,我们可以做到异步操作。简单实现如下,我们以 redux-thunk
的做法为例
// redux 插件
function thunk(action, next) {
if(typeof action === "function") {
return action(dispatch)
}
return next(action);
}
// 请求函数
function fetch(dispatch) {
new Promise((resolve,reject)=>{
setTimeout(()=>{
const payload = { data: { users: [] } }
dispatch(action, payload)
}, 2000)
})
}
大体思路就是构建一个 thunk
插件,插件内会判断传入的 action
类型,如果是 function
则意味着需要被执行。此外,action
需要接受 dispatch
函数,用于在函数执行完毕后调用,以便于能对 store
提进行修改。
然后,假如我们需要在插件中拿到上一个插件的执行结果,并且也需要获得最后 reducer
执行的结果。此时我们的代码还做不到。一种做法就是在插件中传入原始 store
这种,
function logger1(store, action, next) {
console.log("before logger1");
const res = next(action);
console.log("after logger1", res, store.getStore());
return res;
}
而另一种就是改造成高阶函数的形式,
const logger1 = store => next => action => {
console.log("before logger1");
const res = next(action);
console.log("after logger1", res, store.getStore());
return res;
}
不论哪种形式都需要和 redux
里的 createStore
配合使用,redux
在这里采用了高阶函数的形式来处理,也就是第二种。该形式符合函数为一等公民
的原则,且redux
中大量使用了柯里化函数,所以与其他模块在开发模式上也保持了一致。但本质上这两种形式并没有太多的优劣之分。
Koa 中的插件系统
Koa
可以说是面试最高频的类型之一,Koa
算是较早的把洋葱模型引入到前端生态里来的框架了,影响了诸多后来的框架包括Redux
,我们来探究下 Koa
中是如何实现插件系统的。
得益于 NodeJS
中 HTTP
模块的用法,
import http from 'node:http';
// Create a local server to receive data from
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: 'Hello World!',
}));
});
server.listen(8000);
createServer
方法接受一个函数,该函数有两个入参,req
对象参数代表请求,res
对象参数代表响应。这两个对象上均有多个属性、方法可以使用。具体有哪些我们本文不赘述,只需要了解有了这两个就可以方便的处理请求和响应。所有的业务逻辑都是围绕这两个参数来进行的。那问题就转变成了,如何更好的组织代码
。Koa
采用了洋葱模型,插件的形式来组织。有一张经典图:
这张图比较形象的展示了,Koa
的执行模型,就像洋葱一样一层层剥开。
再附一张偏向代码实现的图:
从上到下看,在每个函数的内部都会执行下一个函数,那么就天然的把函数分成了两部分,下个函数执行前和下个函数执行后。每个函数都会接受 req
和 res
参数。这样就把整个链路串起来了。
同样的我们可以先想一下,如何实现这套系统,和 redux
插件不同的是,这里入参只有 req
和 res
,听起来会简单许多。
如何实现?
Koa
同样也是采用了数组将所有插件保存起来。
class Application extends Emitter {
constructor (options) {
super()
...
this.middleware=[]
...
}
use (fn) {
...
this.middleware.push(fn)
return this
}
}
很清晰,声明一个 middleware
成员变量和一个 use
成员函数,调用 use
函数即可把插件存入 middleware
中。
插件数组创建完了,那怎么用呢。
...
const compose = require('koa-compose')
constructor (options) {
super()
...
this.compose = options.compose || compose
...
}
...
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)
}
...
callback () {
const fn = this.compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
if (!this.ctxStorage) {
return this.handleRequest(ctx, fn)
}
return this.ctxStorage.run(ctx, async () => {
return await this.handleRequest(ctx, fn)
})
}
return handleRequest
}
...
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}
...
这里相关的代码比较多,我们一个个来看。
- 首先是
listen
函数,此函数就是启动HTTPServer
的入口,调用该函数就意外着要启动了。注意到,该函数调用了this.callback
方法。 this.callback
方法里引入了this.compose
方法,这个方法引入了一个外部模块我们待会儿再看。只需要知道这个方法调用后返回了一个可执行函数fn
。接着this.callback
函数返回了一个新函数handleRequest
,而handleRequest
接受两个参数req
和res
,这就和HTTPServer
对起来了。- 然后我们看
this.callback
函数中的handleRequest
是如何实现的,该函数首先对req
和res
进行了包装统一成一个变量ctx
,然后调用this.handleRequest
把ctx
和compose
方法返回的fn
传了进去。 - 然后对于
this.handleRequest
函数,直接看最后一行,刚刚传入的fn
这里转换为了实参fnMiddleware
并且是一个能够返回Promise
的函数。由于fn
是来自于this.compose
方法,接下来我们来看this.compose
怎么做的。 - 从构造函数中可以看到
this.compose
来自于第三方库。这个库就是专门用来处理中间件合并的。
Compose 库
这个库代码较少,直接看源码,
function compose (middleware) {
...
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
一经调用就返回了一个新函数,新函数接受两个参数context
和next
,这里我们就知道在上文handleRequest
成员函数中调用fn
传入的ctx
就是指这边的context
。- 接下来的代码实现的比较“巧妙”,直接返回了
dispatch(0)
,而dispatch
函数是在return
后定义的,这里隐含了一个知识点函数提升
,不了解JavaScript
的小伙伴可能看到这里会一脸懵逼。而dispatch
函数的关键两行在这里,
let fn = middleware[i]
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
这两行是什么意思呢,我们先回顾下Koa
插件一般是怎么写的,
const logger = async (ctx, next) => {
console.log(`before logger`, ctx);
await next();
console.log(`after logger`, ctx);
}
这里的 next
就是上面代码里的 dispatch.bind(null, i + 1)
,每次调用next
其实就是调用了dispatch(i+1)
,在dispatch
又能通过插件数组和索引值拿到要执行的函数,巧妙地将插件串联了起来。
最后我们用一张流程图来回顾一下核心调用流程,
未完待续
本篇我们介绍了两种插件系统的实现方式,其实这两种实现本质最核心的部分是一样的,即通过数组的形式对插件进行组织,通过一个辅助函数next
将插件链接起来。从而实现了插件的层层链式调用,而在调用过程中对我们需要的数据进行传递,这样当插件函数链执行完,就拿到了最终的结果。在我们日常开发中如果碰到需要开发插件系统场景的话可以尝试仿照实现一波。
下篇将介绍Webpack
和Axios
中的插件系统是如何实现的。