1. 前言
大家好,我是若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。
截至目前(2024-12-26),目前最新是 4.0.8,官方4.0正式版本的介紹文章暫未發佈。官方之前發過Taro 4.0 Beta 發佈:支持開發鴻蒙應用、小程序編譯模式、Vite 編譯等。
計劃寫一個 Taro 源碼揭秘系列,博客地址:https://ruochuan12.github.io/taro 可以加入書籤,持續關注若川。
- [x] 1. 揭開整個架構的入口 CLI => taro init 初始化項目的秘密
- [x] 2. 揭開整個架構的插件系統的秘密
- [x] 3. 每次創建新的 taro 項目(taro init)的背後原理是什麼
- [x] 4. 每次 npm run dev:weapp 開發小程序,build 編譯打包是如何實現的?
- [x] 5. 高手都在用的發佈訂閲機制 Events 在 Taro 中是如何實現的?
- [x] 6. 為什麼通過 Taro.xxx 能調用各個小程序平台的 API,如何設計實現的?
- [x] 7. Taro.request 和請求響應攔截器是如何實現的
- [x] 8. Taro 是如何使用 webpack 打包構建小程序的?
- [x] 9. Taro 是如何生成 webpack 配置進行構建小程序的?
- [x] 10. Taro 到底是怎樣轉換成小程序文件的?
- [ ] 等等
前面 4 篇文章都是講述編譯相關的,CLI、插件機制、初始化項目、編譯構建流程。
第 5-7 篇講述的是運行時相關的 Events、API、request 等。
第 10 篇接着繼續追隨第 4 篇和第 8、9 篇的腳步,分析 TaroMiniPlugin webpack 的插件實現。
關於克隆項目、環境準備、如何調試代碼等,參考第一篇文章-準備工作、調試。後續文章基本不再過多贅述。
學完本文,你將學到:
1. Taro 到底是怎樣轉換成小程序的?
2. 熟悉 webpack 核心庫 tapable 事件機制
3. 對 webpack 自定義插件和 compiler 鈎子等有比較深刻的認識
4. 對 webpack 自定義 loader 等有比較深刻的認識
等等
我們先來看 TaroMiniPlugin 結構
// packages/taro-webpack5-runner/src/plugins/MiniPlugin.ts
export default class TaroMiniPlugin {
constructor(options: ITaroMiniPluginOptions) {
this.options = {};
}
// 插件入口
apply(compiler) {}
}
在 webpack.config.js 配置 TaroMiniPlugin。
// webpack.config.js
export default {
entry: {},
output: {},
plugins: [
new TaroMiniPlugin({
// 配置項
}),
],
};
我們來看文檔:webpack 自定義插件
創建插件
webpack 插件由以下組成:
- 一個 JavaScript 命名函數或 JavaScript 類。
- 在插件函數的 prototype 上定義一個 apply 方法。
- 指定一個綁定到 webpack 自身的事件鈎子。
- 處理 webpack 內部實例的特定數據。
- 功能完成後調用 webpack 提供的回調。
我們再來看下 webpack 源碼中對於插件的處理。就能夠更清晰的理解文檔的意思。
// 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. 插件屬性
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 項目 - 入口文件
// 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 項目 - 入口配置
// src/app.config.ts
export default defineAppConfig({
pages: ["pages/index/index"],
window: {
backgroundTextStyle: "light",
navigationBarBackgroundColor: "#fff",
navigationBarTitleText: "WeChat",
navigationBarTextStyle: "black",
},
});
3. 插件入口 apply 函數
我們來看插件入口 apply 函數的流程。
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 函數的執行過程如下圖所示:
有個大概印象即可,後文繼續看具體代碼實現。
4. 註冊 compiler.hooks.run 鈎子
const PLUGIN_NAME = 'TaroMiniPlugin'
/** 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
/**
* 自動驅動 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 入口文件,蒐集頁面、組件信息
/**
* 分析 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 鈎子
/** 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 鈎子
/** 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 鈎子
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
/**
* 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 鈎子
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 鈎子
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 鼓勵下持續創作。
最後可以持續關注我@若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。