Taro 源码揭秘:10. Taro 到底是怎样转换成小程序文件的?

1. 前言

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

截至目前(2024-12-26),目前最新是 4.0.8,官方4.0正式版本的介绍文章暂未发布。官方之前发过Taro 4.0 Beta 发布:支持开发鸿蒙应用、小程序编译模式、Vite 编译等

计划写一个 Taro 源码揭秘系列,博客地址:ruochuan12.github.io/taro 可以加入书签,持续关注若川

前面 4 篇文章都是讲述编译相关的,CLI、插件机制、初始化项目、编译构建流程。 第 5-7 篇讲述的是运行时相关的 Events、API、request 等。 第 10 篇接着继续追随第 4 篇和第 8、9 篇的脚步,分析 TaroMiniPlugin webpack 的插件实现。

关于克隆项目、环境准备、如何调试代码等,参考第一篇文章-准备工作、调试。后续文章基本不再过多赘述。

学完本文,你将学到:

bash 复制代码
1. Taro 到底是怎样转换成小程序的?
2. 熟悉 webpack 核心库 tapable 事件机制
3. 对 webpack 自定义插件和 compiler 钩子等有比较深刻的认识
4. 对 webpack 自定义 loader 等有比较深刻的认识
等等

我们先来看 TaroMiniPlugin 结构

ts 复制代码
// packages/taro-webpack5-runner/src/plugins/MiniPlugin.ts
export default class TaroMiniPlugin {
	constructor(options: ITaroMiniPluginOptions) {
		this.options = {};
	}
	// 插件入口
	apply(compiler) {}
}

在 webpack.config.js 配置 TaroMiniPlugin。

ts 复制代码
// webpack.config.js
export default {
	entry: {},
	output: {},
	plugins: [
		new TaroMiniPlugin({
			// 配置项
		}),
	],
};

我们来看文档:webpack 自定义插件

创建插件

webpack 插件由以下组成:

  • 一个 JavaScript 命名函数或 JavaScript 类。
  • 在插件函数的 prototype 上定义一个 apply 方法。
  • 指定一个绑定到 webpack 自身的事件钩子。
  • 处理 webpack 内部实例的特定数据。
  • 功能完成后调用 webpack 提供的回调。

我们再来看下 webpack 源码中对于插件的处理。就能够更清晰的理解文档的意思。

ts 复制代码
// lib/webpack.js
// https://github.com/webpack/webpack/blob/main/lib/webpack.js#L75-L84
if (Array.isArray(options.plugins)) {
	for (const plugin of options.plugins) {
		if (typeof plugin === "function") {
			plugin.call(compiler, compiler);
		} else if (plugin) {
			plugin.apply(compiler);
		}
	}
}

TaroMiniPlugin 插件的主要作用就是把 Taro 项目转换成小程序项目。如下图所示:

本文我们来分析其实现和原理。

2. 插件属性

ts 复制代码
export default class TaroMiniPlugin {
	/** 插件配置选项 */
	options: IOptions;
	// Webpack 编译上下文
	context: string;
	/** app 入口文件路径 */
	appEntry: string;
	/** app config 配置内容 */
	appConfig: AppConfig;
	/** app、页面、组件的配置集合 */
	filesConfig: IMiniFilesConfig = {};
	//  是否处于 watch 模式
	isWatch = false;
	/** 页面列表 */
	pages = new Set<IComponent>();
	// 组件集合
	components = new Set<IComponent>();
	/** 新的混合原生编译模式 newBlended 模式下,需要单独编译成原生代码的 component 的Map */
	nativeComponents = new Map<string, IComponent>();
	/** tabbar icon 图片路径列表 */
	tabBarIcons = new Set<string>();
	//  预渲染页面集合
	prerenderPages = new Set<string>();
	// 依赖集合
	dependencies = new Map<string, TaroSingleEntryDependency>();
	// 加载块插件实例。
	loadChunksPlugin: TaroLoadChunksPlugin;
	// 主题位置
	themeLocation: string;
	// 页面 loader 名称
	pageLoaderName = "@tarojs/taro-loader/lib/page";
	// 独立包集合
	independentPackages = new Map<string, IndependentPackage>();
}

Taro 项目 - 入口文件

ts 复制代码
// src/app.ts
import { PropsWithChildren } from "react";
import { useLaunch } from "@tarojs/taro";
import "./app.less";

function App({ children }: PropsWithChildren<any>) {
	useLaunch(() => {
		console.log("App launched.");
	});
	// children 是将要会渲染的页面
	return children;
}

export default App;

Taro 项目 - 入口配置

ts 复制代码
// src/app.config.ts
export default defineAppConfig({
	pages: ["pages/index/index"],
	window: {
		backgroundTextStyle: "light",
		navigationBarBackgroundColor: "#fff",
		navigationBarTitleText: "WeChat",
		navigationBarTextStyle: "black",
	},
});

3. 插件入口 apply 函数

我们来看插件入口 apply 函数的流程。

ts 复制代码
export default class TaroMiniPlugin {
	// 插件入口
	apply(compiler: Compiler) {
		this.context = compiler.context;
		this.appEntry = this.getAppEntry(compiler);

		const {
			commonChunks,
			combination,
			framework,
			isBuildPlugin,
			newBlended,
		} = this.options;

		// 省略若干代码...

		/** build mode */
		compiler.hooks.run.tapAsync();

		/** watch mode */
		compiler.hooks.watchRun.tapAsync();

		/** compilation.addEntry */
		compiler.hooks.make.tapAsync();

		compiler.hooks.compilation.tap();

		compiler.hooks.afterEmit.tapAsync();

		new TaroNormalModulesPlugin(onParseCreateElement).apply(compiler);

		newBlended && this.addLoadChunksPlugin(compiler);
	}
}

tapable 事件机制

tap 是监听注册事件、call 是执行事件

和第5篇类似 5. 高手都在用的发布订阅机制 Events 在 Taro 中是如何实现的?

  • compiler.hooks.run.tapAsync(); 开始编译
  • compiler.hooks.watchRun.tapAsync(); 开始编译(监听模式)
  • compiler.hooks.make.tapAsync(); 从 entry 开始递归的分析依赖,对每个依赖模块进行 build
  • compiler.hooks.compilation.tap();
  • compiler.hooks.afterEmit.tapAsync(); 输出文件到目录(之后)

插件入口 apply 函数的执行过程如下图所示: 有个大概印象即可,后文继续看具体代码实现。

graph TD A[TaroMiniPlugin webpack 插件 apply 方法] --> B[初始化上下文和入口文件] B --> C{解构配置选项} C --> D[注册 run 钩子] C --> E[注册 watchRun 钩子] C --> F[注册 make 钩子] C --> G[注册 compilation 钩子] C --> H[注册 afterEmit 钩子] C --> I[应用 TaroNormalModulesPlugin] C --> J{newBlended 模式} J --> K[添加加载块插件] D --> L[执行 run 方法] L --> M[应用 TaroLoadChunksPlugin] E --> N[获取已修改文件] N --> O{文件有变更} O --> P[设置 isWatch 为 true] E --> Q[执行 run 方法] Q --> R{loadChunksPlugin 是否存在} R --> S[应用 TaroLoadChunksPlugin] F --> T[处理依赖项] T --> U[编译独立页面] T --> V[添加依赖项到 compilation] V --> W[调用 onCompilerMake 回调] G --> X[设置依赖工厂] G --> Y[插入 Taro Loader] G --> Z[注册 processAssets 钩子] Z --> AA[生成独立分包文件] Z --> AB[生成小程序文件] Z --> AC[优化小程序文件] Z --> AD[调用 modifyBuildAssets 回调] H --> AE[添加 tabbar 文件到依赖中] style A fill:#f9f,stroke:#333,stroke-width:4px style B fill:#bbf,stroke:#333,stroke-width:2px style C fill:#bbf,stroke:#333,stroke-width:2px style D fill:#bbf,stroke:#333,stroke-width:2px style E fill:#bbf,stroke:#333,stroke-width:2px style F fill:#bbf,stroke:#333,stroke-width:2px style G fill:#bbf,stroke:#333,stroke-width:2px style H fill:#bbf,stroke:#333,stroke-width:2px style I fill:#bbf,stroke:#333,stroke-width:2px style J fill:#bbf,stroke:#333,stroke-width:2px style K fill:#bbf,stroke:#333,stroke-width:2px style L fill:#bbf,stroke:#333,stroke-width:2px style M fill:#bbf,stroke:#333,stroke-width:2px style N fill:#bbf,stroke:#333,stroke-width:2px style O fill:#bbf,stroke:#333,stroke-width:2px style P fill:#bbf,stroke:#333,stroke-width:2px style Q fill:#bbf,stroke:#333,stroke-width:2px style R fill:#bbf,stroke:#333,stroke-width:2px style S fill:#bbf,stroke:#333,stroke-width:2px style T fill:#bbf,stroke:#333,stroke-width:2px style U fill:#bbf,stroke:#333,stroke-width:2px style V fill:#bbf,stroke:#333,stroke-width:2px style W fill:#bbf,stroke:#333,stroke-width:2px style X fill:#bbf,stroke:#333,stroke-width:2px style Y fill:#bbf,stroke:#333,stroke-width:2px style Z fill:#bbf,stroke:#333,stroke-width:2px style AA fill:#bbf,stroke:#333,stroke-width:2px style AB fill:#bbf,stroke:#333,stroke-width:2px style AC fill:#bbf,stroke:#333,stroke-width:2px style AD fill:#bbf,stroke:#333,stroke-width:2px style AE fill:#bbf,stroke:#333,stroke-width:2px

4. 注册 compiler.hooks.run 钩子

ts 复制代码
const PLUGIN_NAME = 'TaroMiniPlugin'
ts 复制代码
/** build mode */
compiler.hooks.run.tapAsync(
	PLUGIN_NAME,
	this.tryAsync<Compiler>(async (compiler) => {
		await this.run(compiler);
		new TaroLoadChunksPlugin({
			commonChunks: commonChunks,
			isBuildPlugin,
			addChunkPages,
			pages: this.pages,
			framework: framework,
		}).apply(compiler);
	})
);

tapAsync 当我们用 tapAsync 方法来绑定插件时,必须调用函数的最后一个参数 callback 指定的回调函数。

所以封装了一个 tryAsync 方法。

4.1 tryAsync 函数 - 自动驱动 tapAsync

ts 复制代码
/**
 * 自动驱动 tapAsync
 */
tryAsync<T extends Compiler | Compilation> (fn: (target: T) => Promise<any>) {
    return async (arg: T, callback: any) => {
      try {
        await fn(arg)
        callback()
      } catch (err) {
        callback(err)
      }
    }
}

调试源码。本文就不赘述了,分别是第 1 篇 taro init第 4 篇 npm run dev:weapp详细讲述过。

4.2 run 函数 - 分析 app 入口文件,搜集页面、组件信息

ts 复制代码
/**
 * 分析 app 入口文件,搜集页面、组件信息,
 * 往 this.dependencies 中添加资源模块
 */
async run (compiler: Compiler) {
	if (this.options.isBuildPlugin) {
		this.getPluginFiles()
		this.getConfigFiles(compiler)
	} else {
		this.appConfig = await this.getAppConfig()
		this.getPages()
		this.getPagesConfig()
		this.getDarkMode()
		this.getConfigFiles(compiler)
		this.addEntries()
	}
}

5. 注册 compiler.hooks.watchRun 钩子

ts 复制代码
/** watch mode */
compiler.hooks.watchRun.tapAsync(
	PLUGIN_NAME,
	this.tryAsync<Compiler>(async (compiler) => {
		const changedFiles = this.getChangedFiles(compiler);
		if (changedFiles && changedFiles?.size > 0) {
			this.isWatch = true;
		}
		await this.run(compiler);
		if (!this.loadChunksPlugin) {
			this.loadChunksPlugin = new TaroLoadChunksPlugin({
				commonChunks: commonChunks,
				isBuildPlugin,
				addChunkPages,
				pages: this.pages,
				framework: framework,
			});
			this.loadChunksPlugin.apply(compiler);
		}
	})
);

6. 注册 compiler.hooks.make 钩子

ts 复制代码
/** compilation.addEntry */
compiler.hooks.make.tapAsync(
	PLUGIN_NAME,
	this.tryAsync<Compilation>(async (compilation) => {
		const dependencies = this.dependencies;
		const promises: Promise<null>[] = [];
		this.compileIndependentPages(
			compiler,
			compilation,
			dependencies,
			promises
		);
		dependencies.forEach((dep) => {
			promises.push(
				new Promise<null>((resolve, reject) => {
					compilation.addEntry(
						this.options.sourceDir,
						dep,
						{
							name: dep.name,
							...dep.options,
						},
						(err) => (err ? reject(err) : resolve(null))
					);
				})
			);
		});
		await Promise.all(promises);
		await onCompilerMake?.(compilation, compiler, this);
	})
);

遍历收集好的页面 dependencies 页面依赖,addEntry 添加入口,也就是说是多入口编译文件。调用开发者传入的 onCompilerMake 钩子函数。

7. 注册 compiler.hooks.compilation 钩子

ts 复制代码
compiler.hooks.compilation.tap(
	PLUGIN_NAME,
	(compilation, { normalModuleFactory }) => {
		/** For Webpack compilation get factory from compilation.dependencyFactories by denpendence's constructor */
		compilation.dependencyFactories.set(
			EntryDependency,
			normalModuleFactory
		);
		compilation.dependencyFactories.set(
			TaroSingleEntryDependency as any,
			normalModuleFactory
		);

		/**
		 * webpack NormalModule 在 runLoaders 真正解析资源的前一刻,
		 * 往 NormalModule.loaders 中插入对应的 Taro Loader
		 */
		compiler.webpack.NormalModule.getCompilationHooks(
			compilation
		).loader.tap(
			PLUGIN_NAME,
			(_loaderContext, module: /** TaroNormalModule */ any) => {
				// 拆开放在下方讲述
			}
		);

		const {
			PROCESS_ASSETS_STAGE_ADDITIONAL,
			PROCESS_ASSETS_STAGE_OPTIMIZE,
			PROCESS_ASSETS_STAGE_REPORT,
		} = compiler.webpack.Compilation;

		// 拆开放在下方讲述
		compilation.hooks.processAssets.tapAsync();
	}
);

7.1 compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap

ts 复制代码
/**
 * webpack NormalModule 在 runLoaders 真正解析资源的前一刻,
 * 往 NormalModule.loaders 中插入对应的 Taro Loader
 */
compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap(
	PLUGIN_NAME,
	(_loaderContext, module: /** TaroNormalModule */ any) => {
		const { framework, loaderMeta, pxTransformConfig } = this.options;

		if (module.miniType === META_TYPE.ENTRY) {
			const loaderName = "@tarojs/taro-loader";
			if (!isLoaderExist(module.loaders, loaderName)) {
				module.loaders.unshift({
					loader: loaderName,
					options: {
						// 省略参数 ...
					},
				});
			}
		} else if (module.miniType === META_TYPE.PAGE) {
			let isIndependent = false;
			this.independentPackages.forEach(({ pages }) => {
				if (pages.includes(module.resource)) {
					isIndependent = true;
				}
			});
			const isNewBlended = this.nativeComponents.has(module.name);
			const loaderName =
				isNewBlended || isBuildPlugin
					? "@tarojs/taro-loader/lib/native-component"
					: isIndependent
					? "@tarojs/taro-loader/lib/independentPage"
					: this.pageLoaderName;

			if (!isLoaderExist(module.loaders, loaderName)) {
				module.loaders.unshift({
					loader: loaderName,
					options: {
						// 省略参数 ...
					},
				});
			}
		} else if (module.miniType === META_TYPE.COMPONENT) {
			const loaderName = isBuildPlugin
				? "@tarojs/taro-loader/lib/native-component"
				: "@tarojs/taro-loader/lib/component";
			if (!isLoaderExist(module.loaders, loaderName)) {
				module.loaders.unshift({
					loader: loaderName,
					options: {
						// 省略参数 ...
					},
				});
			}
		}
	}
);

webpack NormalModule 在 runLoaders 真正解析资源的前一刻, 往 NormalModule.loaders 中插入对应的 Taro Loader

  • 入口文件使用 @tarojs/taro-loader
  • 页面使用 @tarojs/taro-loader/lib/page
  • 原生组件使用 @tarojs/taro-loader/lib/native-component
  • 组件使用 @tarojs/taro-loader/lib/component
  • 独立分包使用 @tarojs/taro-loader/lib/independentPage

7.2 注册 compilation.hooks.processAssets 钩子

ts 复制代码
const {
	PROCESS_ASSETS_STAGE_ADDITIONAL,
	PROCESS_ASSETS_STAGE_OPTIMIZE,
	PROCESS_ASSETS_STAGE_REPORT,
} = compiler.webpack.Compilation;
compilation.hooks.processAssets.tapAsync(
	{
		name: PLUGIN_NAME,
		stage: PROCESS_ASSETS_STAGE_ADDITIONAL,
	},
	this.tryAsync<any>(async () => {
		// 如果是子编译器,证明是编译独立分包,进行单独的处理
		if ((compilation as any).__tag === CHILD_COMPILER_TAG) {
			await this.generateIndependentMiniFiles(compilation, compiler);
		} else {
			await this.generateMiniFiles(compilation, compiler);
		}
	})
);
compilation.hooks.processAssets.tapAsync(
	{
		name: PLUGIN_NAME,
		// 删除 assets 的相关操作放在触发时机较后的 Stage,避免过早删除出现的一些问题,#13988
		// Stage 触发顺序:https://webpack.js.org/api/compilation-hooks/#list-of-asset-processing-stages
		stage: PROCESS_ASSETS_STAGE_OPTIMIZE,
	},
	this.tryAsync<any>(async () => {
		await this.optimizeMiniFiles(compilation, compiler);
	})
);

compilation.hooks.processAssets.tapAsync(
	{
		name: PLUGIN_NAME,
		// 该 stage 是最后执行的,确保 taro 暴露给用户的钩子 modifyBuildAssets 在内部处理完 assets 之后再调用
		stage: PROCESS_ASSETS_STAGE_REPORT,
	},
	this.tryAsync<any>(async () => {
		if (typeof modifyBuildAssets === "function") {
			await modifyBuildAssets(compilation.assets, this);
		}
	})
);
  • 在 PROCESS_ASSETS_STAGE_ADDITIONAL 阶段,如果是子编译器,证明是编译独立分包,进行单独的处理,否则生成小程序文件
  • 在 PROCESS_ASSETS_STAGE_OPTIMIZE 阶段,优化小程序文件
  • 在 PROCESS_ASSETS_STAGE_REPORT 阶段,调用开发者传入的自定义的钩子 modifyBuildAssets 函数 修改编译产物

8. 注册 compiler.hooks.afterEmit 钩子

ts 复制代码
compiler.hooks.afterEmit.tapAsync(
	PLUGIN_NAME,
	this.tryAsync<Compilation>(async (compilation) => {
		await this.addTarBarFilesToDependencies(compilation);
	})
);

生成文件之后,添加 tabbar 文件到依赖中。

9. 总结

最后我们来总结一下,TaroMiniPlugin 是 webpack 插件。

本文我们主要是通过调试源码,分析了插件入口 apply 函数。

其主要实现是读取入口文件、入口配置,把页面、页面配置和组件等收集起来。 然后交给 webpack 处理(对应的 taro-loader)。 最后输出对应平台的小程序文件(template、css、json 等)。

我们学习了 webpack 插件的编写和 tapable 的作用。知道了 TaroMiniPlugin 原理。

启发:Taro 是非常知名的跨端框架,我们在使用它,享受它带来便利的同时,有余力也可以多为其做出一些贡献。比如帮忙解答一些 issue 或者提 pr 修改 bug 等。 在这个过程,我们会不断学习,促使我们去解决问题,带来的好处则是不断拓展知识深度和知识广度。


如果看完有收获,欢迎点赞、评论、分享、收藏支持。你的支持和肯定,是我写作的动力。也欢迎提建议和交流讨论

作者:常以若川 为名混迹于江湖。所知甚少,唯善学。若川的博客github blog,可以点个 star 鼓励下持续创作。

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

相关推荐
WeiXiao_Hyy39 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡1 小时前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone1 小时前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农2 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king2 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳2 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵3 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星3 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js