Taro編譯打包優化實踐

xiangzhihong發表於2021-12-02

一、背景

隨著專案越來越大,編譯的耗時也在默默地不斷增加。無論是開發階段還是生產整合,編譯耗時都成為了一個不容小覷的痛點。

在經歷了近5年的持續開發迭代後,我們的專案也在不久前由原來的微信原生小程式開發方式遷移至Taro。Taro 是一套使用React 語法的多端開發解決方案,使用 Taro,我們可以只書寫一套程式碼,再通過 Taro 的編譯工具,將原始碼分別編譯出可以在不同端(微信/百度/支付寶/位元組跳動/QQ/京東小程式、快應用、H5、React-Native 等)執行的程式碼。所以,攜程的很多小程式也使用Taro進行開發。

不過,由於業務比較多,專案編譯後程式碼接近12M。在日常開發階段執行構建命令,只是編譯打包新的開發相關部分檔案就需要耗時近1分鐘。在生產環境下執行構建命令,編譯打包專案中所有檔案,則長達10分鐘。此外,隨著基建部分、單個複雜頁面功能越來越多,程式碼量也越來越大,會導致主包或者一些分包的大小超過2M,這將使得微信開發者工具的二維碼預覽功能無法使用,開發體驗非常糟糕。

針對上述問題,我們嘗試優化Taro編譯打包工作。為了優化Taro的編譯打包,我們需要了解Taro內建的Webpack的配置,然後使用webpack-chain提供的方法鏈式修改配置。接下來,我們還需要解決分包過大無法進行二維碼預覽的問題。

二、 Taro內建的Webpack配置

我們知道Taro編譯打包的工作是由webpack來完成的,既然想要優化打包速度,首先要知道Taro是如何呼叫webpack進行打包的,同時也要了解其內建的webpack配置是怎樣的。

通過閱讀Taro原始碼後可以知道,Taro是在@tarojs/mini-runner/dist/index.js檔案中,呼叫了webpack進行打包,可以自行去檢視相關的程式碼。
在這裡插入圖片描述
我們著重關注該檔案中的build函式,程式碼如下。

function build(appPath, config) {
    return __awaiter(this, void 0, void 0, function* () {
        const mode = config.mode;
        /** process config.sass options */
        const newConfig = yield chain_1.makeConfig(config);
        /** initialized chain */
        const webpackChain = build_conf_1.default(appPath, mode, newConfig);
        /** customized chain */
        yield customizeChain(webpackChain, newConfig.modifyWebpackChain, newConfig.webpackChain);
        if (typeof newConfig.onWebpackChainReady === 'function') {
            newConfig.onWebpackChainReady(webpackChain);
        }
        /** webpack config */
        const webpackConfig = webpackChain.toConfig();
        return new Promise((resolve, reject) => {
            const compiler = webpack(webpackConfig);
            const onBuildFinish = newConfig.onBuildFinish;
            let prerender;
            const onFinish = function (error, stats) {
                if (typeof onBuildFinish !== 'function')
                    return;
                onBuildFinish({
                    error,
                    stats,
                    isWatch: newConfig.isWatch
                });
            };
            const callback = (err, stats) => __awaiter(this, void 0, void 0, function* () {
                if (err || stats.hasErrors()) {
                    const error = err !== null && err !== void 0 ? err : stats.toJson().errors;
                    logHelper_1.printBuildError(error);
                    onFinish(error, null);
                    return reject(error);
                }
                if (!lodash_1.isEmpty(newConfig.prerender)) {
                    prerender = prerender !== null && prerender !== void 0 ? prerender : new prerender_1.Prerender(newConfig, webpackConfig, stats, config.template.Adapter);
                    yield prerender.render();
                }
                onFinish(null, stats);
                resolve(stats);
            });
            if (newConfig.isWatch) {
                logHelper_1.bindDevLogger(compiler);
                compiler.watch({
                    aggregateTimeout: 300,
                    poll: undefined
                }, callback);
            }
            else {
                logHelper_1.bindProdLogger(compiler);
                compiler.run(callback);
            }
        });
    });
}

可以看到,該函式接受兩個引數,appPath和config,appPath是當前專案的目錄,引數config就是我們編寫的Taro配置。在呼叫webpack前,Taro會處理webpackConfig,包括將Taro內建的webpack配置,以及將使用者在Taro配置檔案中的webpackChain配置進去。

定位到了webpack位置,那麼讓我們來看看Taro最終生成的webpack配置的相關程式碼。

const webpack = (options, callback) => {
    const webpackOptionsValidationErrors = validateSchema(
        webpackOptionsSchema,
        options
    );
    if (webpackOptionsValidationErrors.length) {
        throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
    }
    let compiler;
    if (Array.isArray(options)) {
        compiler = new MultiCompiler(
            Array.from(options).map(options => webpack(options))
        );
    } else if (typeof options === "object") {
        options = new WebpackOptionsDefaulter().process(options);

        compiler = new Compiler(options.context);
        compiler.options = options;
        new NodeEnvironmentPlugin({
            infrastructureLogging: options.infrastructureLogging
        }).apply(compiler);
        if (options.plugins && Array.isArray(options.plugins)) {
            for (const plugin of options.plugins) {
                if (typeof plugin === "function") {
                    plugin.call(compiler, compiler);
                } else {
                    plugin.apply(compiler);
                }
            }
        }
        compiler.hooks.environment.call();
        compiler.hooks.afterEnvironment.call();
        compiler.options = new WebpackOptionsApply().process(options, compiler);
    } else {
        throw new Error("Invalid argument: options");
    }
    if (callback) {
        if (typeof callback !== "function") {
            throw new Error("Invalid argument: callback");
        }
        if (
            options.watch === true ||
            (Array.isArray(options) && options.some(o => o.watch))
        ) {
            const watchOptions = Array.isArray(options)
                ? options.map(o => o.watchOptions || {})
                : options.watchOptions || {};
            return compiler.watch(watchOptions, callback);
        }
        compiler.run(callback);
    }
    return compiler;
};

需要注意的是在開發和生產環境下,內建的webpack配置是有差別的,比如在生產環境下,才會呼叫terser-webpack-plugin進行檔案壓縮處理。我們用的是vscode程式碼編輯器,在呼叫webpack位置前,debugger打斷點,同時使用console命令輸出變數webpackConfig,即最終生成的webpack配置。在vscode自帶的命令列工具DEBUG CONSOLE,可以非常方便的點選展開物件屬性,檢視Taro生成的webpack配置。這裡展示下,在development環境下,Taro內建的webpack配置,如下圖。
在這裡插入圖片描述
這些都是常見的webpack配置,我們主要關注兩部分的內容,一是module中配置的rules,配置各種loader來處理匹配的對應的檔案,例如常見的處理scss檔案和jsx檔案。二是plugins中配置的TaroMiniPlugin外掛,該外掛是Taro內建的,主要負責了將程式碼編譯打包成小程式程式碼的工作。

現在,我們瞭解了Taro中的webpack配置以及他們的一個工作過程,接下來該考慮的是如何去修改優化該配置,來幫助我們優化編譯打包的速度。需要注意的是,Taro打包用到了webpack-chain機制。webpack配置本質是一個物件,建立修改比較麻煩,webpack-chain就是提供鏈式的 API 來建立和修改webpack 配置。API的 Key 部分可以由使用者指定的名稱引用,這有助於 跨專案修改配置方式 的標準化。

webpack-chain本身提供了很多的例子,可以參考:https://github.com/Yatoo2018/webpack-chain/tree/zh-cmn-Hans

三、優化Webpack打包配置

經過前文的介紹,我們已經瞭解了Taro生成的webpack配置,也掌握了修改這些配置的方法,接下來就是考慮怎麼修改webpack配置才能優化編譯打包速度。為此,我們引入了speed-measure-webpack-plugin,該外掛可以統計出編譯打包過程中,plugin和loader的耗時情況,可以幫助我們明確優化方向。
在這裡插入圖片描述
將speed-measure-webpack-plugin配置好後,再次執行構建命令,輸出結果如下圖所示。
在這裡插入圖片描述
可以看到,總共3分鐘的編譯時間,TaroMiniPlugin就佔了2分多鐘,耗時還是很嚴重的。TaroMiniPlugin是Taro內建的webpack外掛,Taro的絕大多數編譯打包工作都是配置在這裡的進行的,例如獲取配置內容、處理分包和tabbar、讀取小程式配置的頁面新增dependencies陣列中進行後續處理、生成小程式相關檔案等。次之耗時嚴重的就是TerserPlugin,該外掛主要進行壓縮檔案工作。

而在loaders耗時統計中,babel-loader耗時兩分半,sass-loader耗時兩分鐘,這兩者耗時最為嚴重。這兩者也是導致TaroMiniPlugin耗時如此嚴重的主要原因。因為該外掛,會將小程式頁面、元件等檔案,通過webpack的compilation.addEntry新增到入口檔案中,後續會執行webpack中一個完整的compliation階段,在這個過程中會呼叫配置好的loader進行處理。當然也會呼叫babel-loader和scss-loader進行處理 js檔案或者scss檔案,這就嚴重拖慢了TaroMiniPlugin速度,導致統計出來該外掛耗時嚴重。

因此,優化Webpack的打包主要就在這兩loader,也就相當於優化了TaroMiniPlugin。而在優化方案上,我們選取了兩種常見的優化策略:多核和快取。

3.1 多核

對於多核,我們這裡採用webpack官方推薦的thread-loader,可以將非常消耗資源的 loaders 轉存到worker pool。根據上述耗時統計,可以知道babel-loader是最耗時的loader,因此將thread-loader放置在babel-loader之前,這樣babel-loader就會在一個單獨的worker pool中執行,從而提高編譯效率。

清楚了優化方法,接下來就需要考慮的是如何配置到webpack中。這裡我們利用Taro外掛化機制提供的modifyWebpackChain鉤子,採用webpack-chain提供的方法鏈式修改webpack配置即可。

具體做法是,首先想辦法刪除Taro中內建的babel-loader,我們可以回頭檢視Taro內建的webpack配置,發現處理babel-loader的那條具名規則為'script',如下圖,然後使用webpack-chain語法規則刪除該條具名規則即可。

在這裡插入圖片描述

最後,通過webpack-chain提供的merge方法,重新配置處理js檔案的babel-loader,同時在babel-loader之前引入thread-loader就可以了,如下所示。


ctx.modifyWebpackChain(args => {
  const chain = args.chain
  chain.module.rules.delete('script') // 刪除Taro中配置的babel-loader
  chain.merge({ // 重新配置babel-loader
    module: {
      rule: {
        script: {
          test: /\.[tj]sx?$/i,
          use: {
            threadLoader: {
              loader: 'thread-loader', // 多核構建
            },
            babelLoader: {
              loader: 'babel-loader',
              options: {
                cacheDirectory: true, // 開啟babel-loader快取
              },
            },
          },
        },
      },
    }
  })
})

當然,這裡我們引入thread-loader只是為了處理babel-loader,大家也可以用它去處理css-loader等其他的耗時loader。

3.2 快取

除了開啟多執行緒,為了優化打包速度,還需要對快取進行優化。快取優化策略也是針對這兩部分進行,一是使用cache-loader快取用於處理scss檔案的loaders,二是babel-loader,設定引數cacheDirectory為true,開啟babel-loader快取。

在使用cache-loader快取時,額外注意的是,需要將cache-loader放置在css-loader之前,mini-css-extract-plugin之後。實踐中發現,放置在mini-css-extract-plugin/loader之前,是無法有效快取生成的檔案。

和前面的做法類似,首先我們需要檢視Taro內建的webpack配置的快取的策略,然後使用webpack-chain語法,定位到對應的位置,最後呼叫before方法插入到css-loader之前。
在這裡插入圖片描述
通過webpack-chain方法,將cache-loader放置在css-loader之前,mini-css-extract-plugin之後,程式碼如下:

chain.module.rule('scss').oneOf('0').use('cacheLoader').loader('cache-loader').before('1')
chain.module.rule('scss').oneOf('1').use('cacheLoader').loader('cache-loader').before('1')

注意: 快取預設是儲存在node_moduls/.cache中,如下圖。因此在使用執行編譯打包命令時,需要注意當前的打包環境是否能夠將快取保留下來,否則快取配置無法帶來速度優化效果。

在這裡插入圖片描述
值得一提的是,看上圖我們可以發現,terser-webpack-plugin也是開啟了快取的。我們再回頭看下,下圖是Taro中配置的引數。我們可以發現cache和parallel都為true,說明它們也是分別是開啟了快取以及並行編譯的。
在這裡插入圖片描述

3.3 taro-plugin-compiler-optimization外掛

有了上面的優化方案之後,我們於是著手寫優化外掛。總的來說,本外掛是利用了Taro外掛化機制暴露出來的modifyWebpackChain鉤子,採用webpack-chain方法,鏈式修改webpack配置。將多核和快取優化策略配置到Taro的webpack中,來提升編譯打包速度。

外掛的安裝地址如下:
GitHub:https://github.com/CANntyield/taro-plugin-compiler-optimization
Npm:https://www.npmjs.com/package/taro-plugin-compiler-optimization

首先,在專案中安裝外掛:

npm install --save-dev thread-loader cache-loader taro-plugin-compiler-optimization

然後,在taro的config.js中新增如下指令碼:

// 將其配置到taro config.js中的plugins中
// 根目錄/config/index.js
plugins: ['taro-plugin-compiler-optimization']

最後,我們再執行一下打包任務,發現總耗時已經縮短至56.9s,TaroMiniPlugin、babel-loader還有css-loader耗時有著明顯的縮短,而配置了快取的TerserPlugin也從22.8s縮短至13.9s,優化效果還是很顯著的。

在這裡插入圖片描述

四、壓縮資原始檔

微信開發者工具中,如果想要在真機上除錯小程式,通常是需要進行二維碼預覽的。由於微信限制,打包出來的檔案,主包、分包檔案不能超過2M,否則進行二維碼預覽無法成功。但是隨著專案越來越大,主包檔案超過2M是沒辦法的事情,尤其是通過babel-loader處理後的檔案,更是會包含了非常多的註釋、過長的變數名等,導致檔案過大。行業內最根本的解決方法是分包,因為微信小程式已經將總包大小調整到了10M。不過本文不討論如何分包,這裡主要討論如何調整包的大小。

我們在執行build構建命令時,啟用terser-webpack-plugin壓縮檔案,將主包檔案縮小至2M以下。不過,問題也是很明顯的,那就是每次都需要花費大量的時間用於構建打包工作,效率實在是太低了。而且這種情況下,不會監聽檔案變化,進行模組熱替換工作,這種工作效率非常低下。

因此,我們的策略是在開發環境下配置webpack,呼叫terser-webpack-plugin進行壓縮。同時配置外掛引數,壓縮指定檔案,而不是全部壓縮。開啟微信開發者工具,點開程式碼依賴分析,如下圖。
在這裡插入圖片描述
從圖中可以看到,主包檔案已經超過了2M。其中common.js、taro.js、vendors.js、app.js四個檔案明顯較大,並且每個Taro專案編譯打包後必然生成這四個檔案。pages資料夾也高達1.41M,該資料夾是我們配置的tabBar頁面,因此該資料夾大小直接受到tabBar頁面複雜度的影響。除此之外,其他檔案都比較小,可以暫時不考慮進行處理。

首先,執行以下命令安裝terser-webpack-plugin。

npm install -D terser-webpack-plugin@3.0.5

需要注意的是,terser-webpack-plugin最新版本已經是v5了,這個版本是根據webpack5進行優化的,但是不支援webpack4,因此需要自己額外指定版本,才能使用。這裡我選擇的是3.0.5,跟Taro中使用的terser-webpack-plugin是同一個版本。其中,傳入的引數配置也是跟Taro一樣,我們要做的是,將需要進行壓縮的檔案路徑新增到test陣列中即可,其中已經預設配置了common.js、taro.js、vendors.js、app.js、pages/homoe/index.js檔案。

在這裡插入圖片描述
同樣的,我們需要在Taro配置檔案plugins中引入該Taro外掛,建議在config/dev.js配置檔案中引入,只會在開發環境下才會使用到。

// config/dev.js
plugins: [
    path.resolve(__dirname, 'plugins/minifyMainPackage.js'),
]

最後我們來看看壓縮後主包的大小,可以發現已經減少至1.42M了,相對於此前的3.45M,壓縮了50%左右,可以解決大部分無法進行二維碼預覽打包的場景了。
在這裡插入圖片描述
不過,目前,微信小程式已經支援分包Lee,不過主包還是不能超過2M,上面的方式針對的是主包太大的解決方案。本文主要解決了兩個問題:一是用於優化Taro編譯打包速度,二是提供了一種解決方案,解決分包過大導致無法使用微信開發者工具進行二維碼預覽的問題。

相關文章