源码共读13:Taro源码揭秘 之 揭开整个框架的插件系统的秘密

321 阅读5分钟

Taro Github

本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。

这是学习源码整体架构系列,链接: juejin.cn/post/737836…


2024.08.27 更新:initPluginCtx 初始化插件 ctx
2024.08.30 更新:registerPlugin 注册插件 resolvePlugins 解析插件 等


  1. 揭开整个框架的入口-taro init 初始化项目的秘密
  2. 揭开整个框架的插件系统的秘密
  3. 每次创建新的 taro 项目(taro init)的背后原理是什么
  4. Taro 4.0 已正式发布 - 每次 npm run dev:weapp 开发小程序,build 编译打包是如何实现的?

前言:

  1. 如何合并预设插件集合和插件(CLI、用户项目(config/index.ts)、全局插件/User/用户名/.taro-gobal-config)
  2. 插件是如何注册的
  3. 插件是如何调用的
  4. 等等

项目git地址,环境准备、如何调试--传送门(揭开整个框架的入口-taro init 初始化项目的秘密)

上篇回顾:

CLI最终执行Kernel(内核)中的run函数,其中this.initPresetsAndPlugins初始化预设插件集合和插件本文讲解。

1. new Kernel构造函数

interface IKernelOptions {
        appPath: string;
        config: Config;
        presets?: PluginItem[];
        plugins?: PluginItem[];
}

export default class Kernel extends EventEmitter {
        constructor(options: IKernelOptions) {
                super();
                this.debugger =
                        process.env.DEBUG === "Taro:Kernel"
                                ? helper.createDebug("Taro:Kernel")
                                : function () {};
                this.appPath = options.appPath || process.cwd();
                this.optsPresets = options.presets;
                this.optsPlugins = options.plugins;
                this.config = options.config;
                this.hooks = new Map();
                this.methods = new Map();
                this.commands = new Map();
                this.platforms = new Map();
                this.initHelper();
                this.initConfig();
                this.initPaths();
                this.initRunnerUtils();
        }
        async run(args: string | { name: string; opts?: any }) {
                // 省略若干代码
                this.debugger("initPresetsAndPlugins");
                this.initPresetsAndPlugins();
                console.log('initPresetsAndPlugins', this);

                await this.applyPlugins("onReady");
                // 省略若干代码
        }
        // initPresetsAndPlugins
        initPresetsAndPlugins() {
                // 初始化插件集和插件
        }
}

执行完initPresetsAndPlugins之后,这里以init为例。

使用调试器终端,输入$node ./packages/taro-cli/bin/taro init taro-init-debug命令调试。我们可以在packages/taro-service/dist/Kernel.js打印this出来。

{
  _events: {
  },
  _eventsCount: 0,
  _maxListeners: undefined,
  debugger: function () { },
  appPath: "...",
  optsPresets: [],
  optsPlugins: [],
  config: {
    appPath: "...",
    disableGlobalConfig: false,
    initialConfig: {
      env: {
      },
    },
    initialGlobalConfig: {
    },
    isInitSuccess: false,
    configPath: "...",
  },
  hooks: {
  },
  methods: {
  },
  commands: {
  },
  platforms: {
  },
  helper: {},
  initialConfig: {
    env: {
    },
  },
  initialGlobalConfig: {
  },
  paths: {},
  cliCommandsPath: "/Users/suhaoson/Documents/development/suhaoson/source_code/taro/packages/taro-cli/dist/presets/commands",
  cliCommands: [],
  runOpts: { },
  plugins: {
  },
  extraPlugins: {
  },
  globalExtraPlugins: {
  },
}

本文主要学习initPresetsAndPlugins具体实现:

Taro文档 - 使用插件和编写插件

// packages/taro-service/src/Kernel.ts
// 省略部分代码
initPresetsAndPlugins () {
    const initialConfig = this.initialConfig
    const initialGlobalConfig = this.initialGlobalConfig
    const cliAndProjectConfigPresets = mergePlugins(this.optsPresets || [], initialConfig.presets || [])()
    const cliAndProjectPlugins = mergePlugins(this.optsPlugins || [], initialConfig.plugins || [])()
    const globalPlugins = convertPluginsToObject(initialGlobalConfig.plugins || [])()
    const globalPresets = convertPluginsToObject(initialGlobalConfig.presets || [])()
    this.debugger('initPresetsAndPlugins', cliAndProjectConfigPresets, cliAndProjectPlugins)
    this.debugger('globalPresetsAndPlugins', globalPlugins, globalPresets)
    process.env.NODE_ENV !== 'test' &&
    helper.createSwcRegister({
      only: [
        ...Object.keys(cliAndProjectConfigPresets),
        ...Object.keys(cliAndProjectPlugins),
        ...Object.keys(globalPresets),
        ...Object.keys(globalPlugins)
      ]
    })
    this.plugins = new Map()
    this.extraPlugins = {}
    this.globalExtraPlugins = {}
    this.resolvePresets(cliAndProjectConfigPresets, globalPresets)
    this.resolvePlugins(cliAndProjectPlugins, globalPlugins)
  }
  // 省略部分代码...

接下来,看CLI调用new Kernel的地方

// packages/taro-cli/src/cli.ts
const kernel = new Kernel({
        appPath,
        presets: [
          path.resolve(__dirname, '.', 'presets', 'index.js')
        ],
        config,
        plugins: []
      })
      kernel.optsPlugins ||= []

      // 将自定义的 变量 添加到 config.env 中,实现 definePlugin 字段定义
      const initialConfig = kernel.config?.initialConfig
      if (initialConfig) {
        initialConfig.env = patchEnv(initialConfig, expandEnv)
      }
      if (command === 'doctor') {
        kernel.optsPlugins.push('@tarojs/plugin-doctor')
      } else if (commandPlugins.includes(targetPlugin)) {
        // 针对不同的内置命令注册对应的命令插件
        kernel.optsPlugins.push(path.resolve(commandsPath, targetPlugin))
      }

这里以init为例,那么 kernel.optsPlugins注入的就是common/init.js插件

// packages/taro-cli/src/presets/commands/init.ts
import type { IPluginContext } from '@tarojs/service'

export default (ctx: IPluginContext) => {
  ctx.registerCommand({
    name: 'init',
    optionsMap: {
    //   省略部分代码
    },
    async fn (opts) {
    //   省略部分代码
    }
  })
}

传入参数presets预设插件集合如下:

import * as path from 'node:path'

export default () => {
  return {
    plugins: [
      // hooks
      path.resolve(__dirname, 'hooks', 'build.js'),
      path.resolve(__dirname, 'hooks', 'create.js'),
      // 兼容其他平台小程序插件
      path.resolve(__dirname, 'files', 'writeFileToDist.js'),
      path.resolve(__dirname, 'files', 'generateProjectConfig.js'),
      path.resolve(__dirname, 'files', 'generateFrameworkInfo.js')
    ]
  }
}

其中hooks/build.js:

import * as hooks from '../constant'

import type { IPluginContext } from '@tarojs/service'

export default (ctx: IPluginContext) => {
  [
    hooks.MODIFY_APP_CONFIG,
    hooks.MODIFY_WEBPACK_CHAIN,
    hooks.MODIFY_VITE_CONFIG,
    hooks.MODIFY_BUILD_ASSETS,
    hooks.MODIFY_MINI_CONFIGS,
    hooks.MODIFY_COMPONENT_CONFIG,
    hooks.ON_COMPILER_MAKE,
    hooks.ON_PARSE_CREATE_ELEMENT,
    hooks.ON_BUILD_START,
    hooks.ON_BUILD_FINISH,
    hooks.ON_BUILD_COMPLETE,
    hooks.MODIFY_RUNNER_OPTS
  ].forEach(methodName => {
    ctx.registerMethod(methodName)
  })
}

使用 ctx.registerMethod 注册方法。其中 ctx 就是 Kernal 实例对象。

使用 swc 的方法之一是通过 require 钩子。require 钩子会将自身绑定到 noderequire 并自动动态编译文件。不过现在更推荐 @swc-node/register

源码实现如下,存入到 methods Map 中。后面我们会再次遇到它

  registerMethod (...args) {
    const { name, fn } = processArgs(args)
    const methods = this.ctx.methods.get(name) || []
    methods.push(fn || function (fn: Func) {
      this.register({
        name,
        fn
      })
    }.bind(this))
    this.ctx.methods.set(name, methods)
  }

2.initPresetsAndPlugins 初始化预设插件集合和插件

// packages/taro-service/dist/Kernel.js
// 省略部分代码
initPresetsAndPlugins() {
        const initialConfig = this.initialConfig;
        const initialGlobalConfig = this.initialGlobalConfig;
        const cliAndProjectConfigPresets = (0, utils_1.mergePlugins)(this.optsPresets || [], initialConfig.presets || [])();
        const cliAndProjectPlugins = (0, utils_1.mergePlugins)(this.optsPlugins || [], initialConfig.plugins || [])();
        const globalPlugins = (0, utils_1.convertPluginsToObject)(initialGlobalConfig.plugins || [])();
        const globalPresets = (0, utils_1.convertPluginsToObject)(initialGlobalConfig.presets || [])();
        this.debugger('initPresetsAndPlugins', cliAndProjectConfigPresets, cliAndProjectPlugins);
        this.debugger('globalPresetsAndPlugins', globalPlugins, globalPresets);
        process.env.NODE_ENV !== 'test' &&
            helper.createSwcRegister({
                only: [
                    ...Object.keys(cliAndProjectConfigPresets),
                    ...Object.keys(cliAndProjectPlugins),
                    ...Object.keys(globalPresets),
                    ...Object.keys(globalPlugins)
                ]
            });
        this.plugins = new Map();
        this.extraPlugins = {};
        this.globalExtraPlugins = {};
        this.resolvePresets(cliAndProjectConfigPresets, globalPresets);
        this.resolvePlugins(cliAndProjectPlugins, globalPlugins);
    }
    // 省略部分代码...

initPresetsAndPlugins做了以下几件事情:

  1. mergePlugin 合并预设插件集合和插件
  2. convertPluginsToObject转换全局配置里的插件集合和插件为对象
  3. 非测试环境,createSwcRegister 使用了 @swc/register 来编译 ts 等转换成 commonjs。可以直接用 require 读取文件。
  4. resolvePresets解析预设插件集合和resolvePlugins解析插件
3.1 工具函数mergePlugin和convertPluginsToObject
const isNpmPkg = name => !(/^(.|/)/.test(name));
exports.isNpmPkg = isNpmPkg;
function getPluginPath(pluginPath) {
    if ((0, exports.isNpmPkg)(pluginPath) || path.isAbsolute(pluginPath))
        return pluginPath;
    throw new Error('plugin 和 preset 配置必须为绝对路径或者包名');
}
exports.getPluginPath = getPluginPath;
function convertPluginsToObject(items) {
    return () => {
        const obj = {};
        if (Array.isArray(items)) {
            items.forEach(item => {
                if (typeof item === 'string') {
                    const name = getPluginPath(item);
                    obj[name] = null;
                }
                else if (Array.isArray(item)) {
                    const name = getPluginPath(item[0]);
                    obj[name] = item[1];
                }
            });
        }
        return obj;
    };
}
function mergePlugins(dist, src) {
    return () => {
        const srcObj = convertPluginsToObject(src)();
        const distObj = convertPluginsToObject(dist)();
        return (0, lodash_1.merge)(distObj, srcObj);
    };
}

接下来,再看看resolvePresets。

3.resolvePresets解析预设插件集合

resolvePresets(cliAndProjectPresets, globalPresets) {
        const resolvedCliAndProjectPresets = (0, utils_1.resolvePresetsOrPlugins)(this.appPath, cliAndProjectPresets, constants_1.PluginType.Preset);
        while (resolvedCliAndProjectPresets.length) {
            this.initPreset(resolvedCliAndProjectPresets.shift());
        }
        const globalConfigRootPath = path.join(helper.getUserHomeDir(), helper.TARO_GLOBAL_CONFIG_DIR);
        const resolvedGlobalPresets = (0, utils_1.resolvePresetsOrPlugins)(globalConfigRootPath, globalPresets, constants_1.PluginType.Plugin, true);
        while (resolvedGlobalPresets.length) {
            this.initPreset(resolvedGlobalPresets.shift(), true);
        }
    }

resolvePresets 这个函数主要做了以下几件事情:

  1. resolvedCliAndProjectPresets解析cli和项目配置的预设插件集合
  2. resolvedGlobalPresets解析全局的预设插件集合

globalConfigRootPath的路径是/Users/用户名/.taro-global-config

默认是没有配置预设插件集合的:

// packages/taro-cli/templates/global-config/index.json
{
  "plugins": [],
  "presets": []
}
3.1 工具函数resolvePresetsOrPlugins

resolvePresetsOrPlugins执行后得到的resolvedCliAndProjectPresets

function resolvePresetsOrPlugins(root, args, type, skipError) {
    var _a, _b;
    // 全局的插件引入报错,不抛出 Error 影响主流程,而是通过 log 提醒然后把插件 filter 掉,保证主流程不变
    const resolvedPresetsOrPlugins = [];
    const presetsOrPluginsNames = Object.keys(args) || [];
    for (let i = 0; i < presetsOrPluginsNames.length; i++) {
        const item = presetsOrPluginsNames[i];
        let fPath;
        try {
            fPath = resolve.sync(item, {
                basedir: root,
                extensions: ['.js', '.ts']
            });
        }
        catch (err) {
            if ((_a = args[item]) === null || _a === void 0 ? void 0 : _a.backup) {
                // 如果项目中没有,可以使用 CLI 中的插件
                fPath = (_b = args[item]) === null || _b === void 0 ? void 0 : _b.backup;
            }
            else if (skipError) {
                // 如果跳过报错,那么 log 提醒,并且不使用该插件
                console.log(helper_1.chalk.yellow(`找不到插件依赖 "${item}",请先在项目中安装,项目路径:${root}`));
                continue;
            }
            else {
                console.log(helper_1.chalk.red(`找不到插件依赖 "${item}",请先在项目中安装,项目路径:${root}`));
                process.exit(1);
            }
        }
        const resolvedItem = {
            id: fPath,
            path: fPath,
            type,
            opts: args[item] || {},
            apply() {
                try {
                    return (0, helper_1.getModuleDefaultExport)(require(fPath));
                }
                catch (error) {
                    console.error(error);
                    // 全局的插件运行报错,不抛出 Error 影响主流程,而是通过 log 提醒然后把插件 filter 掉,保证主流程不变
                    if (skipError) {
                        console.error(`插件依赖 "${item}" 加载失败,请检查插件配置`);
                    }
                    else {
                        throw new Error(`插件依赖 "${item}" 加载失败,请检查插件配置`);
                    }
                }
            }
        };
        resolvedPresetsOrPlugins.push(resolvedItem);
    }
    return resolvedPresetsOrPlugins;
}

实际主要就是执行resolve.sync获取路径

const resolvedPresetsOrPlugins = [];
const resolvedItem = {
        id: fPath,
        path: fPath,
        type,
        opts: args[item] || {},
        apply() {
                // 插件内容 require()
                try{
                        return getModuleDefaultExport(require(fPath));
                } catch(e){
                        // 省略代码...
                }
        }
}
resolvedPresetsOrPlugins.push(resolvedItem);

组成类似的数组对象返回。

4.initPreset初始化预设插件集合

initPreset(preset, isGlobalConfigPreset) {
        this.debugger('initPreset', preset);
        const { id, path, opts, apply } = preset;
        const pluginCtx = this.initPluginCtx({ id, path, ctx: this });
        const { presets, plugins } = apply()(pluginCtx, opts) || {};
        this.registerPlugin(preset);
        if (Array.isArray(presets)) {
            const _presets = (0, utils_1.resolvePresetsOrPlugins)(this.appPath, (0, utils_1.convertPluginsToObject)(presets)(), constants_1.PluginType.Preset, isGlobalConfigPreset);
            while (_presets.length) {
                this.initPreset(_presets.shift(), isGlobalConfigPreset);
            }
        }
        if (Array.isArray(plugins)) {
            isGlobalConfigPreset
                ? (this.globalExtraPlugins = (0, lodash_1.merge)(this.globalExtraPlugins, (0, utils_1.convertPluginsToObject)(plugins)()))
                : (this.extraPlugins = (0, lodash_1.merge)(this.extraPlugins, (0, utils_1.convertPluginsToObject)(plugins)()));
        }
    }

initPreset这个方法做了以下几件事情:

  1. initPluginCtx 初始化插件
  2. 执行插件,获得预设插件集合和插件
  3. 使用registerPlugin注册插件,后续可以使用required调用
  4. 如果预设插件集合是数组,递归调用
  5. 如果插件集合是数组,判断是全局插件就合并到全局额外的插件globalExtraPlugins中,否则就合并到额外的插件extraPlugins,后续统一处理插件。

5. initPluginCtx 初始化插件 ctx

initPluginCtx({ id, path, ctx }) {
        const pluginCtx = new Plugin_1.default({ id, path, ctx });
        const internalMethods = ['onReady', 'onStart'];
        const kernelApis = [
            'appPath',
            'plugins',
            'platforms',
            'paths',
            'helper',
            'runOpts',
            'initialConfig',
            'applyPlugins',
            'applyCliCommandPlugin'
        ];
        internalMethods.forEach(name => {
            if (!this.methods.has(name)) {
                pluginCtx.registerMethod(name);
            }
        });
        return new Proxy(pluginCtx, {
            get: (target, name) => {
                if (this.methods.has(name)) {
                    const method = this.methods.get(name);
                    if (Array.isArray(method)) {
                        return (...arg) => {
                            method.forEach(item => {
                                item.apply(this, arg);
                            });
                        };
                    }
                    return method;
                }
                if (kernelApis.includes(name)) {
                    return typeof this[name] === 'function' ? this[name].bind(this) : this[name];
                }
                return target[name];
            }
        });
    }

initPluginCtx函数主要做了以下几件事情?

  1. new Plugin生成插件的pluginCtx
  2. internalMethods内部方法['onReady', 'onStart']注册到pluginCtx上
  3. 将this.methods的数组绑定到Kernel实例对象的this上
  4. kernelApis的方法,在代理绑定中,this指向Kernel ctx上

在Proxy代理之后,可以直接使用ctx[methodName]直接调用方法。

5.1 new Pugin({ id, path, ctx })
export default class Plugin {
  id: string
  path: string
  ctx: Kernel
  optsSchema: Func

  constructor (opts) {
    this.id = opts.id
    this.path = opts.path
    this.ctx = opts.ctx
  }
  // 具体方法实现再拆分分解
}

5.1.1 register 注册 hook

register (hook: IHook) {
    if (typeof hook.name !== 'string') {
      throw new Error(`插件 ${this.id} 中注册 hook 失败, hook.name 必须是 string 类型`)
    }
    if (typeof hook.fn !== 'function') {
      throw new Error(`插件 ${this.id} 中注册 hook 失败, hook.fn 必须是 function 类型`)
    }
    const hooks = this.ctx.hooks.get(hook.name) || []
    hook.plugin = this.id
    this.ctx.hooks.set(hook.name, hooks.concat(hook))
}

判断hook.namehook.fn,最后存入hooks map中。可以通过Kernel ctx.applyPlugins触发插件。

传送门:

5.1.2 registerCommand注册方法

registerCommand (command: ICommand) {
    if (this.ctx.commands.has(command.name)) {
      throw new Error(`命令 ${command.name} 已存在`)
    }
    this.ctx.commands.set(command.name, command)
    this.register(command)
}

存入comands Map中,然后通过this.register(command)存入hooks中。便于在ctx.applyPlugins()使用。

5.1.3 registerPlatform 注册平台

registerPlatform (platform: IPlatform) {
    if (this.ctx.platforms.has(platform.name)) {
      throw new Error(`适配平台 ${platform.name} 已存在`)
    }
    addPlatforms(platform.name)
    this.ctx.platforms.set(platform.name, platform)
    this.register(platform)
}

同样存入platforms中,同时存入hooks中。

5.1.4 registerMethod 注册方法

registerMethod (...args) {
    const { name, fn } = processArgs(args)
    const methods = this.ctx.methods.get(name) || []
    methods.push(fn || function (fn: Func) {
      this.register({
        name,
        fn
      })
    }.bind(this))
    this.ctx.methods.set(name, methods)
}

上文有提及。如果没有函数,就存入hooks中。

// processArgs函数主要作用统一不同的传参形式,最终都是返回`name`和`fn`
function processArgs (args) {
  let name, fn
  if (!args.length) {
    throw new Error('参数为空')
  } else if (args.length === 1) {
    if (typeof args[0] === 'string') {
      name = args[0]
    } else {
      name = args[0].name
      fn = args[0].fn
    }
  } else {
    name = args[0]
    fn = args[1]
  }
  return { name, fn }
}

5.1.5 addPluginOptsSchema 添加插件的参数 Schema

 addPluginOptsSchema (schema) {
    this.optsSchema = schema
 }

传入插件的参数Schema。为插件加入参数验证。

6. registerPlugin注册插件

registerPlugin (plugin: IPlugin) {
    this.debugger('registerPlugin', plugin)
    if (this.plugins.has(plugin.id)) {
      throw new Error(`插件 ${plugin.id} 已被注册`)
    }
    this.plugins.set(plugin.id, plugin)
}

registerPlugin方法主要做了如下事情:

  1. 注册插件到plugins Map中。

最终插件如下:

7.resolvePlugins解析插件

解析插件和解析预设插件类似

resolvePlugins (cliAndProjectPlugins: IPluginsObject, globalPlugins: IPluginsObject) {
    cliAndProjectPlugins = merge(this.extraPlugins, cliAndProjectPlugins)
    const resolvedCliAndProjectPlugins = resolvePresetsOrPlugins(this.appPath, cliAndProjectPlugins, PluginType.Plugin)

    globalPlugins = merge(this.globalExtraPlugins, globalPlugins)
    const globalConfigRootPath = path.join(helper.getUserHomeDir(), helper.TARO_GLOBAL_CONFIG_DIR)
    const resolvedGlobalPlugins = resolvePresetsOrPlugins(globalConfigRootPath, globalPlugins, PluginType.Plugin, true)

    const resolvedPlugins = resolvedCliAndProjectPlugins.concat(resolvedGlobalPlugins)

    while (resolvedPlugins.length) {
      this.initPlugin(resolvedPlugins.shift()!)
    }

    this.extraPlugins = {}
    this.globalExtraPlugins = {}
}

resolvePlugins这个方法做了以下几件事情: 1. 合并预设插件集合中的插件,cli和项目的配置插件

  1. resolvedCliAndProjectPlugins解析cli和项目的配置插件
  2. 合并全局预设插件集合中的插件、全局配置的插件
  3. 最后遍历所有解析后的插件,依次调用initPlugin方法初始化插件

8. initPlugin 初始化插件

  initPlugin (plugin: IPlugin) {
    const { id, path, opts, apply } = plugin
    const pluginCtx = this.initPluginCtx({ id, path, ctx: this })
    this.debugger('initPlugin', plugin)
    this.registerPlugin(plugin)
    apply()(pluginCtx, opts)
    this.checkPluginOpts(pluginCtx, opts)
  }

initPlugin方法做了以下几件事情:

  1. initPluginCtx初始化插件
  2. 注册插件
  3. 执行插件,传入pluginCtx对象,参数opts
  4. 校验插件的参数是否符合要求

9. checkPluginOpts 校验插件的参数

checkPluginOpts (pluginCtx, opts) {
    if (typeof pluginCtx.optsSchema !== 'function') {
      return
    }
    this.debugger('checkPluginOpts', pluginCtx)
    const joi = require('joi')
    const schema = pluginCtx.optsSchema(joi)
    if (!joi.isSchema(schema)) {
      throw new Error(`插件${pluginCtx.id}中设置参数检查 schema 有误,请检查!`)
    }
    const { error } = schema.validate(opts)
    if (error) {
      error.message = `插件${pluginCtx.id}获得的参数不符合要求,请检查!`
      throw error
    }
}

checkPluginOpts这个方法做了一件事情:

  1. 使用joi最强大的JavaScript模式描述语言和数据验证器。校验插件参数schema。

整个项目中,debug调试之后,发现只有taro-plugin-mini-ci这个插件添加了参数校验。

// 参数验证,支持传入配置对象、返回配置对象的异步函数
  ctx.addPluginOptsSchema((joi) => {
    return joi.alternatives().try(
      joi.function().required(),
      joi
        .object()
        .keys({
          /** 微信小程序上传配置 */
          weapp: joi.object({
            appid: joi.string().required(),
            privateKeyPath: joi.string().required(),
            type: joi.string().valid('miniProgram', 'miniProgramPlugin', 'miniGame', 'miniGamePlugin'),
            ignores: joi.array().items(joi.string().required()),
            robot: joi.number(),
            setting: joi.object()
          }),
          /** 字节跳动小程序上传配置 */
          tt: joi.object({
            email: joi.string().required(),
            password: joi.string().required()
          }),
          /** 阿里小程序上传配置 */
          alipay: joi.alternatives().try(
            joi.object({
              appid: joi.string().required(),
              toolId: joi.string().required(),
              privateKeyPath: joi.string().required(),
              clientType: joi.string().valid('alipay', 'ampe', 'amap', 'genie', 'alios', 'uc', 'quark', 'health', 'koubei', 'alipayiot', 'cainiao', 'alihealth'),
              deleteVersion: joi.string().regex(/^\d+.\d+.\d+$/)
            }),
            joi.object({
              appid: joi.string().required(),
              toolId: joi.string().required(),
              privateKey: joi.string().required(),
              clientType: joi.string().valid('alipay', 'ampe', 'amap', 'genie', 'alios', 'uc', 'quark', 'health', 'koubei', 'alipayiot', 'cainiao', 'alihealth'),
              deleteVersion: joi.string().regex(/^\d+.\d+.\d+$/)
            }),

          ),
          /** 钉钉小程序配置 */
          dd: joi.object({
            token: joi.string().required(),
            appid: joi.string().required(),
            devToolsInstallPath: joi.string(),
            projectType: joi.string().valid(
              'dingtalk-personal',
              'dingtalk-biz-isv',
              'dingtalk-biz',
              'dingtalk-biz-custom',
              'dingtalk-biz-worktab-plugin')
          }),
          /** 百度小程序上传配置 */
          swan: joi.object({
            token: joi.string().required(),
            minSwanVersion: joi.string()
          }),
          jd: joi.object({
            privateKey: joi.string().required(),
            robot: joi.number(),
            ignores: joi.array().items(joi.string()),
          }),
          version: joi.string(),
          desc: joi.string(),
          projectPath: joi.string()
        })
        .required()
    )
 })

10. applyCliCommandPlugin暴露taro cli 内部命令插件

applyCliCommandPlugin (commandNames: string[] = []) {
    const existsCliCommand: string[] = []
    for (let i = 0; i < commandNames.length; i++) {
      const commandName = commandNames[i]
      const commandFilePath = path.resolve(this.cliCommandsPath, `${commandName}.js`)
      if (this.cliCommands.includes(commandName)) existsCliCommand.push(commandFilePath)
    }
    const commandPlugins = convertPluginsToObject(existsCliCommand || [])()
    helper.createSwcRegister({ only: [...Object.keys(commandPlugins)] })
    const resolvedCommandPlugins = resolvePresetsOrPlugins(this.appPath, commandPlugins, PluginType.Plugin)
    while (resolvedCommandPlugins.length) {
      this.initPlugin(resolvedCommandPlugins.shift()!)
    }
} 

applyCliCommandPlugin这个方法允许开发者为Taro cli添加自定义命令。

例如官方示例:

ctx.registerMethod

总结

  1. 初始化预设插件和插件集合
  2. 使用工具函数mergePlugin 合并预设插件集合和插件
  3. registerPlugin方法注册插件
  4. resolvePlugins方法解析
  5. initPlugin初始化插件并执行插件

command/init.js为例。

使用调试器终端,输入$node ./packages/taro-cli/bin/taro init taro-init-debug命令调试。

import type { IPluginContext } from '@tarojs/service'

export default (ctx: IPluginContext) => {
  ctx.registerCommand({
    name: 'init',
    optionsMap: {
      '--name [name]': '项目名称',
      '--description [description]': '项目介绍',
      '--typescript': '使用TypeScript',
      '--npm [npm]': '包管理工具',
      '--template-source [templateSource]': '项目模板源',
      '--clone [clone]': '拉取远程模板时使用git clone',
      '--template [template]': '项目模板',
      '--css [css]': 'CSS预处理器(sass/less/stylus/none)',
      '-h, --help': 'output usage information'
    },
    async fn (opts) {
      // init project
      const { appPath } = ctx.paths
      const { options } = opts
      const { projectName, templateSource, clone, template, description, typescript, css, npm, framework, compiler, hideDefaultTemplate, sourceRoot } = options
      const Project = require('../../create/project').default
      const project = new Project({...options})

      project.create()
    }
  })
}

非测试环境,createSwcRegister 使用了 @swc/register 来编译 ts 等转换成 commonjs。可以直接用 require 读取文件。

工具函数resolvePresetsOrPlugins中可以使用require

export function resolvePresetsOrPlugins (root: string, args: IPluginsObject, type: PluginType, skipError?: boolean): IPlugin[] { 
  const resolvedPresetsOrPlugins: IPlugin[] = []
  // 省略...
  const resolvedItem = {
    id: fPath,
    path: fPath,
    type,
    opts: args[item] || {},
    apply () {
      // 插件内容 required
      try {
        return getModuleDefaultExport(require(fPath))
      } catch (error) {
      }
    }
  }
  resolvedPresetsOrPlugins.push(resolvedItem)
  return resolvedPresetsOrPlugins
}

plugins初始化。

  initPlugin (plugin: IPlugin) {
    const { id, path, opts, apply } = plugin
    const pluginCtx = this.initPluginCtx({ id, path, ctx: this })
    this.debugger('initPlugin', plugin)
    this.registerPlugin(plugin)
    apply()(pluginCtx, opts)
    this.checkPluginOpts(pluginCtx, opts)
  }

apply执行插件,传入pluginCtx(包含registerregisterCommandregisterMethods等内置方法的对象),和参数opts

这三个方法最终都会调用Plugin实例对象上的register方法,把方法存入到Kernel实例对象中的hooks属性中。

  register (hook: IHook) {
    const hooks = this.ctx.hooks.get(hook.name) || []
    hook.plugin = this.id
    this.ctx.hooks.set(hook.name, hooks.concat(hook))
  }

再通过kernel实例对象的run函数里的applyPlugins方法。

再从Kernel的实例对象中,取出相应的hooks,使用tapable的AsyncSeriesWaterfallHook钩子串联起来,依次执行hook.fn方法。

image.png

已完结

此文章为2024年08月Day1源码共读,生活在阴沟里,也要记得仰望星空。