【Blog】rax插件的编写和原理

1,493 阅读4分钟

起因

由于想分析打包的js构成,正好Rax的配置项里有analyzer plugin来分析打包文件的选项,在build.json里增加"analyzer": true就可以开启。一开始用的没什么问题,但之后在某些rax工程里开启这一选项却报错了!

Error: listen EADDRINUSE: address already in use 127.0.0.1:8888
    at Server.setupListenHandle [as _listen2] (net.js:1301:14)
    at listenInCluster (net.js:1349:12)
    at doListen (net.js:1488:7)
    at processTicksAndRejections (internal/process/task_queues.js:81:21)

端口占用了,原因是这些工程build-plugin-rax-apptargets包含了多个。Rax支持编译到Web/Weex/Kraken/Ssr/小程序等目标代码,不同的target会生成自己特有的webpackConfig,其中用不同的Driver来做到适应不同环境。因此当多个webpack运行时,analyzerPlugin也运行了多个,而端口都是默认的8888,没有端口检测,自动用新的端口,自然就报错了。

解决思路

Rax的构建脚本@alib/build-scripts通过读取build.json,来动态生成webpackConfig。而生成config这一过程是通过webpack-chain这一工具来实现。

为了解决这个端口占用问题,其实只需要修改analyzerPlugin的端口设置就行了。

通过在@alib/build-scripts/lib/commands/start.js中修改配置来进行测试:

configArr.forEach((v, index) => v.chainConfig.plugin('BundleAnalyzerPlugin').tap(
  args => [...args, {analyzerPort: 8888 + index}]
))

正常运行,并同时生成了多个analyzer Server。但是这种方式直接是在node_modules里修改,肯定是不行的。

有两种思路来处理,一种是写一个插件来动态修改已有的analyzerPlugin配置项,一种是抛弃Rax集成的analyzerPlugin,自己在插件里引入analyzerPlugin并进行配置项设置。

插件编写

修改已有的配置项

Rax插件需要 export 一个函数,函数会接收到两个参数,第一个是 build-scripts 提供的 pluginAPI,第二个是用户传给插件的自定义参数。

在src下新建一个fixAnalyzerPlugin,获取webpackConfig并修改analyzer配置:

module.exports = (api, options = {}) => {
    const { log, onGetWebpackConfig, getValue, context } = api;
    const targets = getValue('targets');
    const {analyzer = false} = context.userConfig
    let i = 0
    if (analyzer) {
        onGetWebpackConfig((config) => {
            log.info("reSet BundleAnalyzerPlugin", targets[i])
            config.plugin('BundleAnalyzerPlugin').tap(args => [...args, {
                analyzerPort: 8888 + i
            }])
            i++
        });
    }
};

代码很简单,onGetWebpackConfigpluginAPI提供的方法之前,此外还有contextonHook等。把这个插件配置到build.jsonpluginsBundleAnalyzerPlugin不会再报端口错误了。build.json:

{
  "analyzer": true,
  "plugins": [
    [
      "build-plugin-rax-app",
      {
        "targets": [
          "web",
          "weex"
        ]
      }
    ],
    "@ali/build-plugin-rax-app-def",
    "./fixAnalyzerPlugin"
  ]
}

自己引入analyzerPlugin

上述pluginAPI中还提供registerUserConfig方法,可以注册 build.json 中的顶层配置字段,因此我们新加一个配置项analyzer2,设置为true,并新加一个newAnalyzerPlugin

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = (api, options = {}) => {
    const { registerUserConfig } = api;
    let i = 0;

    registerUserConfig({
        name: 'analyzer2',
        validation: 'boolean',
        configWebpack: (config, value, context) => {
            if (value) {
                // reference: https://www.npmjs.com/package/webpack-bundle-analyzer
                config.plugin('BundleAnalyzerPlugin')
                  .use(BundleAnalyzerPlugin, [{analyzerPort: 8888+i}]);
                i++
            }
        }
    });
};

现在build.json改为了:

{
  "devServer": {
    "port": 9999
  },
  "analyzer2": true,
  "plugins": [
    [
      "build-plugin-rax-app",
      {
        "targets": ["web", "weex"]
      }
    ],
    "@ali/build-plugin-rax-app-def",
    "./newAnalyzerPlugin"
  ]
}

插件原理

解决这个端口占用问题固然是一个目的,但更多的是为了更好的熟悉rax的构建原理,并实践一些自定义的能力。通过plugin这种方式,我们可以集成各种已有的webpackPlugin到Rax的构建中。那么这种插件机制是怎么实现的呢?

核心无疑是 build-scripts ,其中core/Context.js封装了一个class Context,初始化时this.resolvePlugins方法来获取this.plugins:

this.plugins = this.resolvePlugins(builtInPlugins);
this.resolvePlugins = (builtInPlugins) => {
    const userPlugins = [...builtInPlugins, ...(this.userConfig.plugins || [])]
      .map((pluginInfo) => {
        let fn;
        // 从build.json获取插件信息
        const plugins = Array.isArray(pluginInfo) ? pluginInfo : [pluginInfo, undefined];
        // plugin 文件路径
        const pluginPath = require.resolve(plugins[0], { paths: [this.rootDir] }); 
        // 插件设置项
        const options = plugins[1];
        // 插件module.exports的函数
        fn = require(pluginPath);
        return {
            name: plugins[0],
            pluginPath,
            fn: fn.default || fn || (() => { }),
            options,
        };
      });
    return userPlugins;
};

之后会在setUp中运行执行this.runPlugins,运行注册的插件,生成webpackChain配置:

this.runPlugins = async () => {
    for (const pluginInfo of this.plugins) {
      const { fn, options } = pluginInfo;
      const pluginContext = _.pick(this, PLUGIN_CONTEXT_KEY);
      // pluginAPI 即上述提供了一系列方法的api
      const pluginAPI = {
        //script 统一的 log 工具
        log,
        // 包含运行时的各种环境信息
        context: pluginContext,
        // 注册多 webpack 任务
        registerTask: this.registerTask,
        // 获取全部 webpack 任务
        getAllTask: this.getAllTask,
        // 获取全部 Rax Plugin
        getAllPlugin: this.getAllPlugin,
        // 获取webpack配置,可以用 webpack-chain 形式修改 webpack 配置
        onGetWebpackConfig: this.onGetWebpackConfig,
        // 获取Jest测试配置
        onGetJestConfig: this.onGetJestConfig,
        // 用 onHook 监听命令运行时事件
        onHook: this.onHook,
        // 用来在context中注册变量,以供插件之间的通信
        setValue: this.setValue,
        // 用来获取context中注册的变量
        getValue: this.getValue,
        // 注册 build.json 中的顶层配置字段
        registerUserConfig: this.registerUserConfig,
        // 注册各命令上支持的 cli 参数
        registerCliOption: this.registerCliOption,
        // 注册自定义方法
        registerMethod: this.registerMethod,
        // 运行注册的自定义方法
        applyMethod: this.applyMethod,
        // 修改build.json 中的配置
        modifyUserConfig: this.modifyUserConfig,
      };
      // 运行插件,传入pluginAPI, options,用pluginAPI中的方法修改相应配置
      await fn(pluginAPI, options);
    }
};

从这里看,上面我们写的插件作用原理就很明显了。

除此之外,onHook还提供了一系列生命周期,startbuild的生命周期略有不同,主要是在commands下的build.jsstart.js中在相应阶段运行context实例applyHook方法,来执行注册好的事件队列。而事件队列就是插件用onHook注册的。

// 执行
// start.js
const context = new Context(...);
const { applyHook } = context;
await applyHook(`before.start.load`);

// Context.js
// 实际执行方法
this.applyHook = async (key, opts = {}) => {
    const hooks = this.eventHooks[key] || [];
    for (const fn of hooks) {
        await fn(opts);
    }
};
//注册
this.onHook = (key, fn) => {
    if (!Array.isArray(this.eventHooks[key])) {
        this.eventHooks[key] = [];
    }
    this.eventHooks[key].push(fn);
};

看到这,感觉build-scripts整个插件机制和生命周期都有种似曾相识的感觉。没错,和webpack中的插件和生命周期很像,估计是有所参考吧。

npm包

暂时发了个npm包解决一下这个问题:

tnpm i -D @ali/fix-analyzer-plugin

build.json中plugins加入

"plugins": [
   ...
    "@ali/fix-analyzer-plugin"
  ]