Stories

Detail Return Return

Taro 源碼揭秘:10. Taro 到底是怎樣轉換成小程序文件的? - Stories Detail

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 項目轉換成小程序項目。如下圖所示:

taro-webpack

本文我們來分析其實現和原理。

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 函數的執行過程如下圖所示:
有個大概印象即可,後文繼續看具體代碼實現。

TaroMiniPlugin webpack 插件 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詳細講述過。

before-run

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

after-run

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+人)第一的專欄,寫有幾十篇源碼文章。

user avatar alibabawenyujishu Avatar smalike Avatar yinzhixiaxue Avatar nihaojob Avatar aqiongbei Avatar linx Avatar huichangkudelingdai Avatar xiaoxxuejishu Avatar zero_dev Avatar yqyx36 Avatar wmbuke Avatar weidewei Avatar
Favorites 156 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.