Taro 源码揭秘 - 2. 揭开整个架构的插件系统的秘密

1. 前言

大家好,我是若川,欢迎关注我的公众号:若川视野。我倾力持续组织了 3 年多每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(5.8k+人)第一的专栏,写有几十篇源码文章。

截止目前(2024-06-14),taro 正式版是 3.6.31Taro 4.0 Beta 发布:支持开发鸿蒙应用、小程序编译模式、Vite 编译等。文章提到将于 2024 年第二季度,发布 4.x。所以我们直接学习 4.x4.x 最新版本是 4.0.0-beta.83

计划写一个 taro 源码揭秘系列,欢迎持续关注。初步计划有如下文章:

学完本文,你将学到:

bash 复制代码
1. 如何合并预设插件集合和插件(CLI、用户项目(config/index.ts)、全局插件`/Users/用户名/.taro-global-config`)
2. 插件是如何注册的
3. 插件如何调用的
等等

关于如何调试代码等,参考第一篇文章。后续文章基本不再赘述。

Taro 源码揭秘 - 1. 揭开整个架构的入口 CLI => taro init 初始化项目的秘密

上一篇文章中提到 CLI 最终执行的是 Kernel (内核) 中的 run 函数,其中 this.initPresetsAndPlugins 初始化预设插件集合和插件没讲述。

我们先来回顾一下上文中的 Kernal 构造函数。文章中基本是先放源码,源码中不做过多解释。源码后面再做简单讲述。

2. new Kernal 构造函数

ts 复制代码
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 为例。 使用 JavaScript 调试终端,输入 node ./packages/taro-cli/bin/taro init taro-init-debug 调试。 我们可以在 packages/taro-service/dist/Kernel.js 打印 this 出来。

如下图所示:

本文我们主要是学习 initPresetsAndPlugins 具体实现。

我们继续来分析这个函数的具体实现,也就是说来看 Taro 的插件机制是如何实现的。可以打开Taro文档 - 使用插件和编写插件 配合看可能效果更佳。

本文讲述的函数源码位置是 packages/taro-service/src/Kernel.ts。 基本包含在下图中。

我们来看 CLI 调用 new Kernal 的地方,源码所在位置 packages/taro-cli/src/cli.ts

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

传入的参数 presets 预设插件集合如下图所示:

其中 hooks/build.js 如下图所示:

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

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

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

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);
 }

这个方法主要做了如下几件事:

  1. mergePlugins 合并预设插件集合和插件
  2. convertPluginsToObject 转换全局配置里的插件集和插件为对象
  3. 非测试环境,createSwcRegister 使用了 @swc/register 来编译 ts 等转换成 commonjs。可以直接用 require 读取文件。
  4. resolvePresets 解析预设插件集合和 resolvePlugins 解析插件

3.1 工具函数 mergePlugins、convertPluginsToObject

ts 复制代码
export const isNpmPkg: (name: string) => boolean = (name) =>
	!/^(\.|\/)/.test(name);

export function getPluginPath(pluginPath: string) {
	if (isNpmPkg(pluginPath) || path.isAbsolute(pluginPath)) return pluginPath;
	throw new Error("plugin 和 preset 配置必须为绝对路径或者包名");
}

export function convertPluginsToObject(
	items: PluginItem[]
): () => IPluginsObject {
	return () => {
		const obj: IPluginsObject = {};
		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;
	};
}
ts 复制代码
export function mergePlugins(dist: PluginItem[], src: PluginItem[]) {
	return () => {
		const srcObj = convertPluginsToObject(src)();
		const distObj = convertPluginsToObject(dist)();
		return merge(distObj, srcObj);
	};
}

我们来看解析插件集合 resolvePresets

4. resolvePresets 解析预设插件集合

ts 复制代码
 resolvePresets(
  cliAndProjectPresets: IPluginsObject,
  globalPresets: IPluginsObject
 ) {
  const resolvedCliAndProjectPresets = resolvePresetsOrPlugins(
   this.appPath,
   cliAndProjectPresets,
   PluginType.Preset
  );
  while (resolvedCliAndProjectPresets.length) {
   this.initPreset(resolvedCliAndProjectPresets.shift()!);
  }

  const globalConfigRootPath = path.join(
   helper.getUserHomeDir(),
   helper.TARO_GLOBAL_CONFIG_DIR
  );
  const resolvedGlobalPresets = resolvePresetsOrPlugins(
   globalConfigRootPath,
   globalPresets,
   PluginType.Plugin,
   true
  );
  while (resolvedGlobalPresets.length) {
   this.initPreset(resolvedGlobalPresets.shift()!, true);
  }
 }

这个方法主要做了如下几件事:

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

其中主要有两个函数,我们分开讲述 resolvePresetsOrPlugins initPreset

执行后 resolvePresetsOrPlugins 函数得到的 resolvedCliAndProjectPresets 如图所示:

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

全局 .taro-global-config/index.json 我们默认是没有配置预设插件集合的。

json 复制代码
// .taro-global-config/index.json
{
	presets: [],
	plugins: []
}

我们接着具体断点调试来看 resolvePresetsOrPlugins

4.1 resolvePresetsOrPlugins 解析插件集或者插件

ts 复制代码
// getModuleDefaultExport
export function resolvePresetsOrPlugins(
	root: string,
	args: IPluginsObject,
	type: PluginType,
	skipError?: boolean
): IPlugin[] {
	// 全局的插件引入报错,不抛出 Error 影响主流程,而是通过 log 提醒然后把插件 filter 掉,保证主流程不变
	const resolvedPresetsOrPlugins: IPlugin[] = [];
	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 (args[item]?.backup) {
				// 如果项目中没有,可以使用 CLI 中的插件
				fPath = args[item]?.backup;
			} else if (skipError) {
				// 如果跳过报错,那么 log 提醒,并且不使用该插件
				console.log(
					chalk.yellow(
						`找不到插件依赖 "${item}",请先在项目中安装,项目路径:${root}`
					)
				);
				continue;
			} else {
				console.log(
					chalk.red(
						`找不到插件依赖 "${item}",请先在项目中安装,项目路径:${root}`
					)
				);
				process.exit(1);
			}
		}
		const resolvedItem = {
			id: fPath,
			path: fPath,
			type,
			opts: args[item] || {},
			apply() {
				try {
					return 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 获取路径。

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

组成这样的数组对象返回。

我们接着来看 this.initPreset 的具体实现。

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

ts 复制代码
initPreset(preset: IPreset, isGlobalConfigPreset?: boolean) {
  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 = resolvePresetsOrPlugins(
    this.appPath,
    convertPluginsToObject(presets)(),
    PluginType.Preset,
    isGlobalConfigPreset
   );
   while (_presets.length) {
    this.initPreset(_presets.shift()!, isGlobalConfigPreset);
   }
  }
  if (Array.isArray(plugins)) {
   isGlobalConfigPreset
    ? (this.globalExtraPlugins = merge(
      this.globalExtraPlugins,
      convertPluginsToObject(plugins)()
      ))
    : (this.extraPlugins = merge(
      this.extraPlugins,
      convertPluginsToObject(plugins)()
      ));
  }
 }

这个方法主要做了如下几件事:

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

我们分别来看 initPluginCtxregisterPlugin 的实现。

6. initPluginCtx 初始化插件 ctx

ts 复制代码
 initPluginCtx({
  id,
  path,
  ctx,
 }: {
  id: string;
  path: string;
  ctx: Kernel;
 }) {
  const pluginCtx = new Plugin({ id, path, ctx });
  const internalMethods = ["onReady", "onStart"];
  const kernelApis = [
   "appPath",
   "plugins",
   "platforms",
   "paths",
   "helper",
   "runOpts",
   "runnerUtils",
   "initialConfig",
   "applyPlugins",
   "applyCliCommandPlugin",
  ];
  internalMethods.forEach((name) => {
   if (!this.methods.has(name)) {
    pluginCtx.registerMethod(name);
   }
  });
  return new Proxy(pluginCtx, {
   get: (target, name: string) => {
    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];
   },
  });
 }

这个方法主要做了如下几件事:

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

因为 new Proxy 代理后,可以直接使用 ctx.methodName 直接调用相应方法。

我们接着来看,class Plugin 的具体实现。

6.1 new Plugin({ id, path, ctx })

ts 复制代码
import { addPlatforms } from "@tarojs/helper";

import type { Func } from "@tarojs/taro/types/compile";
import type Kernel from "./Kernel";
import type { ICommand, IHook, IPlatform } from "./utils/types";

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;
	}
	//  拆分到下部分
}

6.1.1 register 注册 hook

ts 复制代码
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))
}

判断下 namefn,最后存入 hooks 的值。可以通过 this.applyPlugins()触发插件

第一篇文章 kernal.applyPlugins 触发插件,有提到,此处就不在赘述。

6.1.2 registerCommand 注册方法

ts 复制代码
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)
}

存入到 commands Map 中。而且再通过 this.register(command) 存入到 hooks 中。便于在 this.applyPlugins() 使用。

commandhooks 如下图所示:

6.1.3 registerPlatform 注册平台

ts 复制代码
  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 中。

6.1.4 registerMethod 注册方法

ts 复制代码
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 中。

ts 复制代码
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 };
}

processArgs 函数主要就是统一下不同传参形式,最终都是返回 namefn

6.1.5 addPluginOptsSchema 添加插件的参数 Schema

ts 复制代码
addPluginOptsSchema (schema) {
	this.optsSchema = schema
}

我们接着来看,注册插件函数。

7. registerPlugin 注册插件

ts 复制代码
 registerPlugin(plugin: IPlugin) {
  this.debugger("registerPlugin", plugin);
  if (this.plugins.has(plugin.id)) {
   throw new Error(`插件 ${plugin.id} 已被注册`);
  }
  this.plugins.set(plugin.id, plugin);
 }

这个方法主要做了如下几件事:

  1. 注册插件到 plugins Map 中。

最终的插件 plugins 如图所示:

8. resolvePlugins 解析插件

解析插件和解析预设插件集合类似。

ts 复制代码
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 = {};
 }

这个方法主要做了如下几件事:

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

9. initPlugin 初始化插件

ts 复制代码
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);
 }

这个方法主要做了如下几件事:

  1. initPluginCtx 初始化插件的 ctx
  2. 注册插件
  3. 校验插件的参数

10. checkPluginOpts 校验插件的参数

ts 复制代码
 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;
  }
 }

这个方法主要做了如下几件事:

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

Kernal 实例对象中还有一个方法,顺变提一下。

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

ts 复制代码
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()!);
	}
}

12. 总结

我们学了

bash 复制代码
1. 如何合并预设插件集合和插件(CLI、用户项目(config/index.ts)、全局插件`/Users/用户名/.taro-global-config`)
2. 插件是如何注册的
3. 插件如何调用的
等等

强烈建议读者朋友们,空闲时自己看着文章,多尝试调试源码。单看文章,可能觉得看懂了,但自己调试可能会发现更多细节,收获更多。


如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力

最后可以持续关注我@若川,欢迎关注我的公众号:若川视野。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(5.8k+人)第一的专栏,写有几十篇源码文章。

我倾力持续组织了 3 年多每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与

相关推荐
天下无贼!1 小时前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr1 小时前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林1 小时前
npm发布插件超级简单版
前端·npm·node.js
我码玄黄1 小时前
THREE.js:网页上的3D世界构建者
开发语言·javascript·3d
罔闻_spider1 小时前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔1 小时前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠2 小时前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
小晗同学2 小时前
Vue 实现高级穿梭框 Transfer 封装
javascript·vue.js·elementui
盏灯2 小时前
前端开发,场景题:讲一下如何实现 ✍电子签名、🎨你画我猜?
前端
WeiShuai2 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript