如何设计一个plugin机制

648 阅读3分钟

「这是我参与11月更文挑战的第12天,活动详情查看:2021最后一次更文挑战

支持插件机制的框架或库有很多,如 webpack、rollup、vue、markdown-it、koa、redux 等,今天来研究一下这些框架是如何支持 plugin 的。

koa 的中间件

koa 是一个轻量的node服务端框架,源码见 koajs/koa仓库。 它自带的功能是比较弱的,但是你可以通过添加各种中间件的方式如 koa-json,koa-router,koa-multer等扩展它作为后端的功能。

我们给 koa 应用中间件的方法很简单:

import json from 'koa-json'
import logger from 'koa-logger'
const app = new Koa()
app.use(json()).use(logger()).listen(3000)

以上几行代码就依次应用了koa-jsonkoa-logger中间件,用户请求后端时,会依次被koa-jsonkoa-logger处理。 实际上 koa 做的事情很简单,它的 use 方法定义如下:

class Application extends Emitter {
// ....
 use (fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
  debug('use %s', fn._name || fn.name || '-')
  this.middleware.push(fn)
  return this
 }
}

每次调用 use 方法,都会将函数push到 this.middleware 数组中。 在调用listen方法的时候就会通过 http模块监听端口,在监听回调中会将 this.mdiddleware 组合成Promise 任务队列,比如当this.middleware = [A, B, C, D]时,组合结果就是A.then(B).then(C).then(D),然后执行任务队列,每个任务都有机会处理ctx对象,当所有任务(即用户use的中间件函数)都执行完毕后,调用respond函数自动处理剩下的任务(根据 ctx.status、ctx.method等处理最终返回结果,并响应给用户)。

对于 koa2 的组合中间件的函数定义如下

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
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

可以看出每个中间件都会被包裹成Promise,如果在中间件中调用了 next 方法,则会调度下一个中间件,如果没有下一个中间件就返回 Promise.resolve()。

定义一个 koa-json 中间件的代码如下:

function(opts){
  var opts = opts || {};
  var param = opts.param;
  var pretty = null == opts.pretty ? true : opts.pretty;
  var spaces = opts.spaces || 2;

  return function filter(ctx, next){
    return next().then(() => {
      var body = ctx.body;
      // unsupported body type
      var stream = body
        && typeof body.pipe === 'function'
        && body._readableState
        && body._readableState.objectMode;
      var json = isJSON(body);
      if (!json && !stream) return;

      // query
      var hasParam = param && hasOwnProperty.call(ctx.query, param);
      var prettify = pretty || hasParam;

      // always stringify object streams
      if (stream) {
        ctx.response.type = 'json';
        var stringify = Stringify();
        if (prettify) stringify.space = spaces;
        ctx.body = body.pipe(stringify);
        return;
      }

      // prettify JSON responses
      if (json && prettify) {
        return ctx.body = JSON.stringify(body, null, spaces);
      }
    });
  }
};

可以总结出开发一个 koa2-中间件的方式如下:

  1. 编写一个高阶函数,传入option 对象,返回的函数入参是ctx 和 next,在返回函数中编写业务逻辑,在适当的时机调用next函数;
  2. 发布到npm仓库,命名一般以 koa-为前缀 一个中间件的形式如下:
export function middleware(options) {
    return (ctx, next) => {
        // ...
    }
}

使用中间件的形式如下:

const options = {/**/}
const app = new Koa()
app.use(middleware(options))

markdown-it 插件

markdown-it 是一个markdown的解析器,用于将markdown文本转换成 html标签,它同样存在插件机制,允许我们自制插件或使用第三方插件来扩展markdown语法和功能。

markdown-it 源码:markdown-it

markdown-it 文档:Ruler | markdown-it 中文文档 (docschina.org)

markdown-it 插件的package名一般是以 markdown-it-为前缀,如markdown-it-table-of-contents。 markdown-it 使用插件也是通过 use 函数

const MarkdownIt = require("markdown-it");
const md = new MarkdownIt();
md.use(require("markdown-it-table-of-contents"));

markdown-it-table-of-contents模块导出的也是一个函数,函数大概是这个这个样子的

function(md, options) {
    // ...
}

markdown-it-table-of-contents 是用于解析markdown文本中的 [[toc]]符号后生成目录,在这个插件函数中,我们需要几种能力:

  1. 获取当前解析器已经解析到的位置,这里称之为游标
  2. 创建新token后移动游标或者解析失败
  3. 创建解析规则
  4. 确定解析规则的使用时机,在某个规则之前或之后

总结

设计一个 plugin 机制的步骤可以总结如下:

  1. 确定暴露给插件的内容,即允许用户定制的问题域
  2. 确定plugin之间的时序关系,各个plugin的执行顺序可以是按照定义plugin的顺序,也可以是满足某个plugin必须在另一个plugin之前或之后的拓扑序
  3. 确定函数名和入参定义
  4. 提供开发和应用一个新插件的开发范式
  5. 编码实现