一、背景
隨著專案越來越大,編譯的耗時也在默默地不斷增加。無論是開發階段還是生產整合,編譯耗時都成為了一個不容小覷的痛點。
在經歷了近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編譯打包速度,二是提供了一種解決方案,解決分包過大導致無法使用微信開發者工具進行二維碼預覽的問題。