Webpack編譯速度優化實戰

當你的應用的規模還很小時,你可能不會在乎Webpack的編譯速度,無論使用3.X還是4.X版本,它都足夠快,或者說至少沒讓你等得不耐煩。但隨着業務的增多,嗖嗖嗖一下項目就有上百個組件了,也是件很簡單的事情。這時候當你再獨立編前端模塊的生產包時,或者CI工具中編整個項目的包時,如果Webpackp配置沒經過優化,那編譯速度都會慢得一塌糊塗。編譯耗時10多秒鐘的和編譯耗時一兩分鐘的體驗是迥然不同的。出於開發時的心情的考慮,加上不能讓我們前端的代碼編譯拖累整個CI的速度這兩個出發點,迫使我們必須去加快編譯速度。本文主要是探討下可做編譯速度優化的地方,對一些API使用上不會做太多講解,需要的同學可以直接翻看文檔中的介紹。筆者的Webpack版本爲4.29.6,後文中內容都基於這個版本。

 

一、已存在的針對編譯速度的優化

筆者這套Webpack架子源自CRA的eject,基於Webpack4.x,在Loader和Plugin的選擇和設計上已是最佳實踐方案,基本上無需改動什麼。其原有的對編譯的優化配置在於這三處:

1. 通過terser-webpack-plugin的parallel和cache配置來並行處理並緩存之前的編譯結果。terser-webpack-plugin是之前UglifyPlugin的一個替代品,因爲UglifyPlugin已經沒有繼續維護了,從Webpack4.x起,已經推薦使用terser-webpack-plugin來進行代碼壓縮、混淆,以及Dead Code Elimination以實現Tree Shaking。對於parallel從整個設置的名稱大家就會知道它有什麼用,沒錯,就是並行,而cache也就是緩存該插件的處理結果,在下一次的編譯中對於內容未改變的文件可以直接複用上一次編譯的結果。

2. 通過babel-loader的cache配置來緩存babel的編譯結果。

3. 通過IgnorePlugin設置對moment的整個locale本地化文件夾導入的正則匹配,來防止將所有的本地化文件進行打包。如果你確實需要某國語言,僅手動導入那國的語言包即可。

在項目逐漸變大的過程中,生產包的編譯時間也從十幾秒增長到了一分多鐘,這是讓人受不了的,這就迫使着筆者必須進行額外的優化以加快編譯速度,爲編包節省時間。下面的段落就講解下筆者做的幾個額外優化。

 

二、多線程(進程)支持

從上個段落的terser-webpack-plugin的parallel設置中,我們可以得到這個啓發:啓用多進程來模擬多線程,並行處理資源的編譯。於是筆者引入了HappyPack,筆者之前的那套老架子也用了它,但之前沒寫東西來介紹那套架子,這裏就一併說了。關於HappyPack,經常玩Webpack的同學應該不會陌生,網上也有一些關於其原理的介紹文章,也寫得很不錯。HappyPack的工作原理大致就是在Webpack和Loader之間多加了一層,改成了Webpack並不是直接去和某個Loader進行工作,而是Webpack test到了需要編譯的某個類型的資源模塊後,將該資源的處理任務交給了HappyPack,再由HappyPack再起內部進行線程調度,分配一個線程調用處理該類型資源的Loader來處理這個資源,完成後上報處理結果,最後HappyPack把處理結果返回給Webpack,最後由Webpack輸出到目的路徑。將都在一個線程內的工作,分配到了不同的線程中並行處理。

使用方法如下:

首先引入HappyPack並創建線程池:

const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({size: require('os').cpus().length - 1});

替換之前的Loader爲HappyPack的插件:

{
    test: /\.(js|mjs|jsx|ts|tsx)$/,
    include: paths.appSrc,
    use: ['happypack/loader?id=babel-application-js'],
},

將原Loader中的配置,移動到對應插件中:

new HappyPack({
    id: 'babel-application-js',
    threadPool: happyThreadPool,
    verbose: true,
    loaders: [
        {
            loader: require.resolve('babel-loader'),
            options: {
                ...省略
            },
        },
    ],
}),

大致使用方式如上所示,HappyPack的配置講解文章有很多,不會配的同學可以自己搜索,本文這裏只是順帶說說而已。

HappyPack老早也沒有維護了,它對url-loader的處理是有問題的,會導致經過url-loader處理的圖片都無效,筆者之前也去提過一個Issue,有別的開發者也發現過這個問題。總之,用的時候一定要測試一下。

對於多線程的優勢,我們舉個例子:
比如我們有四個任務,命名爲A、B、C、D。

任務A:耗時5秒

任務B:耗時7秒

任務C:耗時4秒

任務D:耗時6秒

單線程串行處理的總耗時大約在22秒。

改成多線程並行處理後,總耗時大約在7秒,也就是那個最耗時的任務B的執行時長,僅僅通過配置多線程處理我們就能得到大幅的編譯速度提升。

寫到這裏,大家是不是覺得編譯速度優化就可以到此結束了?哈哈,當然不是,上面這個例子在實際的項目中根本不具有廣泛的代表性,筆者實際項目的情況是這樣的:

我們有四個任務,命名爲A、B、C、D。

任務A:耗時5秒

任務B:耗時60秒

任務C:耗時4秒

任務D:耗時6秒

單線程串行處理的總耗時大約在75秒。

改成多線程並行處理後,總耗時大約在60秒,從75秒優化到60秒,確實有速度上的提升,但是因爲任務B的耗時太長了,導致整個項目的編譯速度並沒有發生本質上的變化。事實上筆者之前那套Webpack3.X的架子就是因爲這個問題導致編譯速度慢,所以,只靠引入多線程就想解決大項目編譯速度慢的問題是不現實的。

那我們還有什麼辦法嗎?當然有,我們還是可以從TerserPlugin得到靈感,那就是依靠緩存:在下一次的編譯中能夠複用上一次的結果而不執行編譯永遠是最快的。

至少存在有這三種方式,可以讓我們在執行構建時不進行某些文件的編譯,從最本質上提升前端項目整體的構建速度:

1. 類似於terser-webpack-plugin的cache那種方式,這個插件的cache默認生成在node_modules/.cache/terser-plugin文件下,通過SHA或者base64編碼之前的文件處理結果,並保存文件映射關係,方便下一次處理文件時可以查看之前同文件(同內容)是否有可用緩存。其他Webpack平臺的工具也有類似功能,但緩存方式不一定相同。

2. 通過externals配置在編譯的時候直接忽略掉外部庫的依賴,不對它們進行編譯,而是在運行的時候,通過<script>標籤直接從CDN服務器下載這些庫的生產環境文件。

3. 將某些可以庫文件編譯以後保存起來,每次編譯的時候直接跳過它們,但在最終編譯後的代碼中能夠引用到它們,這就是Webpack DLLPlugin所做的工作,DLL借鑑至Windows動態鏈接庫的概念。

後面的段落將針對這幾種方式做講解。

 

三、Loader的Cache

除了段落一中提到的terser-webpack-plugin和babel-loader支持cache外,Webpack還直接另外提供了一種可以用來緩存前序Loader處理結果的Loader,它就是cache-loader。通常我們可以將耗時的Loader都通過cache-laoder來緩存編譯結果。比如我們打生產環境的包,對於Less文件的緩存你可以這樣使用它:

{
    test: /\.less$/,
    use: [
        {
            loader: MiniCssExtractPlugin.loader,
            options: {
                ...省略
            },
        },
        {
            loader: 'cache-loader',
            options: {
                cacheDirectory: paths.appPackCacheCSS,
            }
        },
        { 
            loader: require.resolve('css-loader'),
            options: {
                ...省略
            },
        },
        {
            loader: require.resolve('postcss-loader'),
            options: {
                ...省略
            }
        }
    ]
}

Loader的執行順序是從下至上,因此通過上述配置,我們可以通過cache-laoder緩存postcss-loader和css-loader的編譯結果。

但我們不能用cache-loader去緩存mini-css-extract-plugin的結果,因爲它的作用是要從前序Loader編譯成的含有樣式字符串的JS文件中把樣式字符串單獨抽出來打成獨立的CSS文件,而緩存這些獨立CSS文件並不是cache-loader的工作。

但如果是要緩存開發環境的Less編譯結果,cache-loader可以緩存style-loader的結果,因爲style-loader並沒有從JS文件中單獨抽出樣式代碼,只是在編譯後的代碼中添加了一些額外代碼,讓編譯後的代碼在運行時,能夠創建包含樣式的<style>標籤並放入<head>標籤內,這樣的性能不是太好,所以基本上只有開發環境採用這種方式。

在對樣式文件配置cache-loader的時候,一定要記住上述這兩點,要不然會出現樣式無法正確編譯的問題。

除了對樣式文件的編譯結果進行緩存外,對其他類型的文件(除了會打包成獨立的文件外)的編譯結果進行緩存也是可以的。比如url-laoder,只要大小沒有達到limitation的圖片都會被打成base64,大於limitation的文件會打成單獨的圖片類文件,就不能被cache-loader緩存了,如果遇到了這種情況,資源請求會404,這是在使用cache-loader時需要注意的。

當然,通過使用緩存能得到顯著編譯速度提升的,依舊是那些耗時的Loader,如果對某些類型的文件編譯並不耗時,或者說文件本身數量太少,都可以先不必做緩存,因爲即便做了緩存,編譯速度的提升也不明顯。

最後筆者將所有Loader和Plugin的cache默認目錄從node_modules/.cache/移到了項目根目錄的build_pack_cache/目錄(生產環境)和dev_pack_cache目錄(開發環境),通過NODE_ENV自動區分。這麼做是因爲筆者的CI工程每次會刪除之前的node_modules文件夾,並從node_modules.tar.gz解壓一個新的node_modules文件夾,所以將緩存放在node_modules/.cache/目錄裏面會無效,筆者也不想去動CI的代碼。通過這個改動,對cache文件的管理更直觀一些,也能避免node_modules的體積一直增大。如果想清除緩存,直接刪掉對應目錄即可。當然了,這兩個個目錄是不需要被Git跟蹤的,所以需要在.gitignore中添加上。CI環境中如果沒有對應的緩存目錄,相關Loader會自動創建。而且,因爲開發環境和生產環境編譯出的資源是不同的,在開發環境下對資源的編譯往往都沒有做壓縮和混淆處理等,爲了有效地緩存不同環境下的編譯結果,需要區分開緩存目錄。

 

四、外部擴展externals

按照Webpack官方的說法:我們的項目如果想用一個庫,但我們又不想Webpack對它進行編譯(因爲它的源碼很可能已是經過編譯和優化的生產包,可以直接使用)。並且我們可能通過window全局方式來訪問它,或者通過各種模塊化的方式來訪問它,那麼我們就可以把它配置進extenals裏。

比如我要使用jquery可以這樣配置:

externals: {
    jquery: 'jQuery'
}

我就可以這樣使用了,就像我們直接引入一個在node_modules中的包一樣:

import $ from 'jquery';

$('.div').hide();

這樣做能有效的前提就是我們在HTML文件中在上述代碼執行以前就已經通過了<script>標籤從CDN下載了我們需要的依賴庫了,externals配置會自動在承載我們應用的html文件中加入:

<script src="https://code.jquery.com/jquery-1.1.14.js">

externals還支持其他靈活的配置語法,比如我只想訪問庫中的某些方法,我們甚至可以把這些方法附加到window對象上:

externals : {
    subtract : {
        root: ["math", "subtract"]
    }
}

我就可以通過 window.math.subtract 來訪問subtract方法了。

對於其他配置方式如果有興趣的話可以自行查看文檔。

但是,筆者的項目並沒有這麼做,因爲在它最終交付給客戶後,應該是處於一個內網環境(或者一個被防火牆嚴重限制的環境)中,極大可能無法訪問任何互聯網資源,因此通過<script>腳本請求CDN資源的方式將失效,前置依賴無法正常下載就會導致整個應用奔潰。

 

五、DllPlugin

在上個段落中的結尾處,提到了筆者的項目在交付用戶後會面臨的網絡困境,所以筆者必須選擇另外一個方式來實現類似於externals配置能夠提供的功能。那就是Webpack DLLPlugin以及它的好搭檔DLLReferencePlugin。筆者有關DLLPlugin的使用都是在構建生產包的時候使用。

要使用DLLPlugiin,我們需要單獨開一個webpack配置,暫且將其命名爲webpack.dll.config.js,以便和主Webpack的配置文件webpack.config.js進行區分。內容如下:

'use strict';
process.env.NODE_ENV = 'production';
const webpack = require('webpack');
const path = require('path');
const {dll} = require('./dll');
const DllPlugin = require('webpack/lib/DllPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const getClientEnvironment = require('./env');
const paths = require('./paths');
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';

module.exports = function (webpackEnv = 'production') {
    const isEnvDevelopment = webpackEnv === 'development';
    const isEnvProduction = webpackEnv === 'production';

    const publicPath = isEnvProduction ? paths.servedPath : isEnvDevelopment && '/';

    const publicUrl = isEnvProduction ? publicPath.slice(0, -1) : isEnvDevelopment && '';
    const env = getClientEnvironment(publicUrl);

    return {
        mode: isEnvProduction ?
            'production' :
            isEnvDevelopment && 'development',
        devtool: isEnvProduction ?
            'source-map' :
            isEnvDevelopment && 'cheap-module-source-map',
        entry: dll,
        output: {
            path: isEnvProduction ? paths.appBuildDll : undefined,
            filename: '[name].dll.js',
            library: '[name]_dll_[hash]'
        },
        optimization: {
            minimize: isEnvProduction,
            minimizer: [
                ...省略
            ]
        },
        plugins: [
            new webpack.DefinePlugin(env.stringified),
            new DllPlugin({
                context: path.resolve(__dirname),
                path: path.resolve(paths.appBuildDll, '[name].manifest.json'),
                name: '[name]_dll_[hash]',
            }),
        ],
    };
};

爲了方便DLL的管理,我們還單獨開了個dll.js文件來管理webpack.dll.config.js的入口entry,我們把所有需要DLLPlugin處理的庫都記錄在這個文件中:

const dll = {
    core: [
        'react',
        '@hot-loader/react-dom',
        'react-router-dom',
        'prop-types',
        'antd/lib/badge',
        'antd/lib/button',
        'antd/lib/checkbox',
        'antd/lib/col',
        ...省略
    ],
    tool: [
        'js-cookie',
        'crypto-js/md5',
        'ramda/src/curry',
        'ramda/src/equals',
    ],
    shim: [
        'whatwg-fetch',
        'ric-shim'
    ],
    widget: [
        'cecharts',
    ],
};

module.exports = {
    dll,
    dllNames: Object.keys(dll),
};

對於要把哪些庫放入DLL中,請根據自己項目的情況來定,對於一些特別大的庫,又沒法做模塊分割和不支持Tree Shaking的,比如Echarts,建議先去官網按項目所需功能定製一套,不要直接使用整個Echarts庫,否則會白白消耗許多的下載時間,JS預處理的時間也會增長,減弱首屏性能。

然後我們在webpack.config.js的plugins配置中加入DLLReferencePlguin來對DLLPlugin處理的庫進行映射,好讓編譯後的代碼能夠從window對象中找到它們所依賴的庫:

{
    ...省略

    plugins: [
        ...省略
        
        // 這裏的...用於延展開數組,因爲我們的DLL有多個,每個單獨的DLL輸出都需要有一個DLLReferencePlguin與之對應,去獲取DLLPlugin輸出的manifest.json庫映射文件。
     // dev環境下暫不採用DLLPlugin優化。
...(isEnvProduction ? dllNames.map(dllName => new DllReferencePlugin({ context: path.resolve(__dirname), manifest: path.resolve(__dirname, '..', `build/static/dll/${dllName}.manifest.json`) })) : [] ), ...省略 ] ... }

我們還需要在承載我們應用的index.html模板中加入<script>,從webpack.dll.config.js裏配置的output輸出文件夾中前置引用這些DLL庫。對於這個工作DLLPlguin和它的搭檔不會幫我們做這件事情,而已有的html-webpack-plugin也不能幫助我們去做這件事情,因爲我們沒法通過它往index.html模板加入特定內容,但它有個增強版的兄弟script-ext-html-webpack-plugin可以幫我們做這件事情,筆者之前也用過這個插件內聯JS到index.html中。但筆者懶得再往node_modules中加依賴包了,另闢了一個蹊徑:

CRA這套架子已經使用了DefinePlugin來在編譯時創建全局變量,最常用的就是創建process環境變量,讓我們的代碼可以分辨是開發還是生產環境,既然已有這樣的設計,何不繼續使用,讓DLLPlugn編譯的獨立JS文件名暴露在某個全局變量下,並在index.html模板中循環這個變量數組,循環創建<script>標籤不就行了,在上面提到的dll.js文件中最後導出的 dllNames 就是這個數組。

然後我們改造一下index.html:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title></title>
        <% if (process.env.NODE_ENV === "production") { %>
            <% process.env.DLL_NAMES.forEach(function (dllName){ %>
                <script src="/static/dll/<%= dllName %>.dll.js"></script>
            <% }) %>
        <% } %>
    </head>
    <body>
        <noscript>Please allow your browser to run JavaScript scripts.</noscript>
        <div id="root"></div>
    </body>
</html>

最後我們改造一下build.js腳本,加入打包DLL的步驟:

function buildDll (previousFileSizes){
    let allDllExisted = dllNames.every(dllName =>
        fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.dll.js`)) &&
        fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.manifest.json`))
    );
    if (allDllExisted){
        console.log(chalk.cyan('Dll is already existed, will run production build directly...\n'));
        return Promise.resolve();
    } else {
        console.log(chalk.cyan('Dll missing or incomplete, first starts compiling dll...\n'));
        const dllCompiler = webpack(dllConfig);
        return new Promise((resolve, reject) => {
            dllCompiler.run((err, stats) => {
                ...省略
            })
        });
    }
}

 

checkBrowsers(paths.appPath, isInteractive)
    .then(() => {
        // Start dll webpack build.
        return buildDll();
    })
    .then(() => {
        // First, read the current file sizes in build directory.
        // This lets us display how much they changed later.
        return measureFileSizesBeforeBuild(paths.appBuild);
    })
    .then(previousFileSizes => {
        // Remove folders contains hash files, but leave static/dll folder.
        fs.emptyDirSync(paths.appBuildCSS);
        fs.emptyDirSync(paths.appBuildJS);
        fs.emptyDirSync(paths.appBuildMedia);
        // Merge with the public folder
        copyPublicFolder();
        // Start the primary webpack build
        return build(previousFileSizes);
    })
    .then(({stats, previousFileSizes, warnings}) => {
        ... 省略
    })
    ... 省略

大致邏輯就是如果xxx.dll.js文件存在且對應的xxx.manifest.json也存在,那麼就不重新編譯DLL,如果缺失任意一個,就重新編譯。DLL的編譯過程走完後再進行主包的編譯。由於我們將DLLPlugin編譯出的文件也放入build文件夾中,所以之前每次開始編譯主包都需要清空整個build文件夾的操作也修改爲了僅僅清空除了放置有dll.js和manifest.json的目錄。

如果我們的底層依賴庫確實發生了變化,需要我們更新DLL,按照之前的檢測邏輯,我們只需要刪除整個某個dll.js文件即可,或者直接刪除掉整個build文件夾。

哈哈,到此所有的有關DLL的配置就完成了,大功告成。

本段落開始時有提到過DLLPlugin的使用都是在生產環境下。因爲開發環境下的打包情況很特殊而且複雜:

在開發環境下整個應用是通過webpack-dev-server來打包並起一個Express服務來serve的。Express服務在內部掛載了webpack-dev-middleware作爲中間件,webpack-dev-middleware可以實現serve由Webpack compiler編譯出的所有資源、將所有的資源打入內存文件系統中的功能,並能結合dev-sever實現監聽源文件改動並提供HRM。webpack-dev-server接收了一個Webpack compiler和一個有關HTTP配置的config作爲實例化時的參數。這個compiler會在webpack-dev-middleware中執行,用監聽方式啓動compiler後,compiler的outputFileSystem會被webpack-dev-middleware替換成內存文件系統,其執行後打包出來的東西都沒有實際落盤,而是存放在了這個內存文件系統中,而ouputFileSystem本身是在Node.js的fs模塊基礎上封裝的。將編譯結果存放進內存中是webpack-dev-middleware內部最奇妙的地方。

當我們的在開發環境的前端界面中發起一個靜態資源請求,請求到達dev-server後,經過路由的判斷,這個靜態資源都會被重定向到內存文件系統中去獲取資源,資源在內存中是二進制格式,以返回流的形式來響應請求,並且在response的時候會爲content-type加上對應的MIME類型,瀏覽器拿到數據流後再根據response header中content-type的值就能正確解析服務器返回的資源了。事實上就算將文件資源落盤,也必須先把文件從磁盤讀到內存中,再以流的形式返回給客戶端,這樣一來會多一個從磁盤中將文件讀進內存的步驟,反而還沒有直接操作內存快。內存文件系統在npm start起得進程被幹掉後,就被回收了,下一次再起進程的時候,會創建一個全新的內存文件系統。

所有,由於開發環境打包的特殊性,怎麼在開發環境使用DLLPlugin還需要再研究下。因此筆者只是在開發環境使用了多線程和各種緩存。由於開發環境下,編譯的工作量少於生產環境,並且對所有資源的讀寫都是走內存的,因此速度很快,每次檢測到源文件變動並進行重編譯的過程也會很快。所以開發環境的編譯速度在目前來看還可以接受,暫時不需要優化。

這裏順帶說一句,筆者之前在看有關DLLPlugin的文檔和文章時,也注意到了一個現象,就是很多人都說DLLPlugin過時了,externals使用起來更方便,配置更簡單,甚至在CRA、vue-cli這些最佳實踐的腳手架中都已經沒再繼續使用DLLPlugin了,因爲Webpack4.x的編譯速度已經足夠快了。筆者的體會就是:我這個項目就是基於Webpack4.X的,項目規模變大以後,沒有覺得4.X有多麼地快。筆者的項目在交付客戶後也極大可能不能訪問互聯網,所以externals配置對筆者的項目來說沒有用,只能通過使用DLLPlugin提高生產包的編譯速度。我想這也是爲什麼Webpack到了4.X版本依然沒有去掉DLLPlugin的原因,因爲不是所有的前端項目都一定是互聯網項目。還是那句話:實踐出真知,不要人云亦云。

 

六、編譯速度提升了多少?

筆者開發機4和8線程,單核基礎頻率2.2GHz。所有測試都基於已對Echarts進行功能定製。

1. 最原始的CRA腳手架編譯筆者這個項目的速度。

初次打包,無任何CRA原始配置的緩存,這和最初在CI上進行構建的情況完全一樣,因爲每次node_moduels都要刪除重來,無法緩存任何結果:

大概1分10多秒。

有緩存以後:

如果不定製Echarts的話,直接引入整個Echarts,在沒緩存的時候大概會多5秒時間。

 

2. 引入dll後。

初次打包,並且無任何DLL文件和CRA原始配置的緩存:

先編譯DLL,再編譯主包,整個過程耗時直接變成了57秒。

有DLL文件和緩存後:

降到27秒多了。

 

3.最後我們把多線程和cache-loader上了:

無任何DLL文件、CRA原始配置的緩存以及cache-loader的緩存時:

大概在接近60秒左右。

有DLL文件和所有緩存後:

最終,耗時已經下降至17秒左右了。在CI的服務器上執行,速度還會更快些。

在打包速度上,比最原始的1分10多秒耗時已經有本質上的提升了。這纔是我們想要的。

 

七、總結

至此,我們已經將所有底層依賴DLL化了,幾乎所有能做緩存的東西都緩存了,並支持多線程編譯。當項目穩定後,無論項目規模多大,小幅度的修改將始終保持在這個編譯速度,耗時不會有太大的變化。

據說在Webpack5.X帶來了全新的編譯性能體驗,很期待使用它的時候。談及到此,筆者只覺得有淡淡的憂傷,那就是前端技術、工具、框架、開發理念這些的更新速度實在是太快了。就拿Webpack這套構建平臺來說,當Webpack5.X普及後,Webpack4.X這套優化可能也就過時了,又需要重新學習了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章