webpack三兩事:淺入深出-原理解析構建優化

image-20210127111317254

基礎知識回顧

  • 入口(entry)css

    module.exports = {
      entry: './path/to/my/entry/file.js'
    };
    //或者
    module.exports = {
      entry: {
        main: './path/to/my/entry/file.js'
      }
    };
  • 輸出(output)html

    module.exports = {
      output: {
        filename:'[name][chunkhash:8].js',
        path:path.resolve(__dirname,'dist')
      }
    };
  • loader
    預處理loader前端

    • css-loader 處理css中路徑引用等問題
    • style-loader 動態把樣式寫入css
    • sass-loader scss編譯器
    • less-loader less編譯器
    • postcss-loader scss再處理

處理js loadernode

  • babel-loader
  • jsx-loader
  • ts-loader

圖片處理loaderreact

    • url-loader
    • 插件(plugin)
      plugins裏面放的是插件,插件的做用在於提升開發效率,可以解放雙手,讓咱們去作更多有意義的事情。一些很low的事就通通交給插件去完成。jquery

      const webpackConfig = {
          plugins: [
              //清除文件
              new CleanWebpackPlugin(),
              //css單獨打包
              new MiniCssExtractPlugin({
                  filename: "[name].css",
                  chunkFilename: "[name].css"
              }),
              // 引入熱更新插件
              new webpack.HotModuleReplacementPlugin() 
          ]
      }
    • 模式(mode)webpack

      • production 生產環境
    • development 開發環境es6

      • 提高了構建速度
      • 默認爲開發環境,不須要專門配置
      • 提供壓縮功能,不須要藉助插件
      • 提供SouceMap,不須要專門配置
    • 瀏覽器兼容性(browser compatibility)
    • 環境(environment)

    項目中詳細配置web

    構建過程

    Webpack 處理應用程序時,它會遞歸地構建一個依賴關係圖(dependency graph),其中包含應用程序須要的每一個模塊,而後將全部模塊打包成一個或多個 bundleajax

    其實就是:Webpack 是一個 JS 代碼打包器。

    至於圖片、CSS、Less、TS等其餘文件,就須要 Webpack 配合 loader 或者 plugin 功能來實現。

    構建流程

    1. 根據配置,識別入口文件;
    2. 逐層識別模塊依賴(包括 Commonjs、AMD、或 ES6 的 import 等,都會被識別和分析);
    3. Webpack 主要工做內容就是分析代碼,轉換代碼,編譯代碼,最後輸出代碼;
    4. 輸出最後打包後的代碼。

    webpack構建的三個階段:

    1. 初始化階段
    2. 編譯階段
    3. 輸出階段

    初始化

    • 初始化參數: 從配置文件和 Shell 語句中讀取與合併參數,得出最終的參數。這個過程當中還會執行配置文件中的插件實例化語句 new Plugin()。
    • 初始化默認參數配置: new WebpackOptionsDefaulter().process(options)
    • 實例化Compiler對象:用上一步獲得的參數初始化Compiler實例,Compiler負責文件監聽和啓動編譯。Compiler實例中包含了完整的Webpack配置,全局只有一個Compiler實例。
    • 加載插件: 依次調用插件的apply方法,讓插件能夠監聽後續的全部事件節點。同時給插件傳入compiler實例的引用,以方便插件經過compiler調用Webpack提供的API。
    • 處理入口: 讀取配置的Entrys,爲每一個Entry實例化一個對應的EntryPlugin,爲後面該Entry的遞歸解析工做作準備

    編譯

    一、生成chunk

    chunk是webpack內部運行時的概念;一個chunk是對依賴圖的部分進行封裝的結果(`Chunkthe class is the encapsulation for parts of your dependency graph`);能夠經過多個entry-point來生成一個chunk
    chunk能夠分爲三類;

    • entry chunk

      • 包含webpack runtime code而且是最早執行的chunk
    • initial chunk

      • 包含同步加載進來的module且不包含runtime code的chunk
      • 在entry chunk執行後再執行的
    • normal chunk

      • 使用require.ensureSystem.importimport()異步加載進來的module,會被放到normal chunk中

    每一個chunk都至少有一個屬性:

    • name: 默認爲main
    • id: 惟一的編號,開發環境和name相同,生產環境是一個數字,從0開始

    二、構建依賴模塊

    var compiler = webpack(options);

    屏幕快照 2020-07-15 15.52.36.png

    從入口文件index.js開始分析,檢查右側表格中的記錄,若是有記錄就結束。沒有記錄就繼續讀取文件內容,讀取完文件內容後,開始進行抽象樹語法分析,將代碼字符串轉換成一個對象的描述文件。並將其中的依賴保存在dependencies數組中

    dependencies:["./src/a.js"]

    保存完之後,替換依賴函數

    console.log("index.js");
    _webpack_reuqire("./src/a.js");

    將轉換後的代碼字符串保存在右側的表格中

    模塊id 轉換後的代碼
    ./src/index.js console.log("index.js");_webpack_reuqire("./src/a.js");

    由於dependencies中有數據,開始遞歸解析dependencies中的數據。取出.src/a.js

    // .src/a.js
    console.log("a.js");
    require("b")

    查看右側表格,發現沒有a.js,開始讀取文件內容,生成ast抽象語法樹,將依賴記錄在數組中

    dependencies: ["./src/b.js"]

    而後替換函數依賴

    console.log("a.js");
    _webpack_require("./src/b.js");
    module.exports = "a"

    將轉換後的代碼記錄在右側的表格中

    模塊id 轉換後的代碼
    ./src/index.js console.log("index.js");_webpack_reuqire("./src/a.js");
    ./src/a.js console.log("a.js");_webpack_require("./src/b.js");module.exports = "a"

    而後繼續取出來dependencies的內容./src/b.js

    console.log("b.js");
    module.exports = "b";

    發現右側表格中沒有b.js這個文件,就繼續讀取文件內容,進行ast抽象語法樹分析,發現沒有依賴項,就不須要往數組中放東西,也不須要替換依賴項,將代碼字符串存在表格中

    模塊id 轉換後的代碼
    ./src/index.js console.log("index.js");_webpack_reuqire("./src/a.js");
    ./src/a.js console.log("a.js");_webpack_require("./src/b.js");module.exports = "a"
    ./src/b.js console.log("b.js");module.exports = "b";

    而後遞歸回去,發現index下產生的數組是空,整個文件依賴就解析完畢

    三、產生chunk assets

    在第二步完成之後,chunk中會產生一個模塊列表,列表中包含了模塊id模塊轉換後的代碼

    接下來,webpack會根據配置爲chunk生成一個資源列表,即chunk assets,資源列表能夠理解爲是生成到最終文件的文件名和文件內容

    • 爲何叫資源列表呢?
    • 由於有可能配置devtool生成的除了./dist/main.js還有./dist/main.js.map

    即:文件名:./dist/main.js

    文件內容:

    (function(){
    
    })({
        "./src/index.js": function(){
            //是不是eval能夠根據devtool來設置,有不少種方式
            eval("console.log(\"index module\");\nvar a = __webpack_require__(/*! ./a */ \"./src/a.js\"); \na.abc();\nconsole.log(a);\n\n\n//# sourceURL=webpack:///./src/index.js?")
        }
    })

    屏幕快照 2020-07-15 17.04.09.png

    chunk hash: 是根據全部的chunk assets的內容生成的一個hash字符串
    hash: 一種算法,具備不少分類。特色是將一個任意長度的字符串轉換成一個固定長度的字符串,並且能夠保證原始內容不變

    就是將咱們上面生成的文件內容,所有聯合起來,而後生成一個固定長度的哈希值連接

    簡圖: 屏幕快照 2020-07-15 17.13.36.png

    多個chunk assets就是一個bundle(一捆)

    四、合併chunk assets

    將多個chunk的assets合併到一塊兒,併產生一個總的hash 屏幕快照 2020-07-15 17.16.11.png

    輸出

    webpack將利用node中的fs模塊(文件處理模塊),根據編譯產生的總的assets,生成相應的文件

    屏幕快照 2020-07-15 17.22.03.png

    涉及術語

    1. module: 模塊,分割的代碼單元,webpack中的模塊能夠是任何內容的文件,不只限於JS
    2. chunk: webpack內部構建模塊的塊,一個chunk中包含多個模塊,這些模塊是從入口模塊經過依賴分析得來的
    3. bundle:chunk構建好模塊後會生成chunk的資源清單,清單中的每一項就是一個bundle,能夠認爲bundle就是最終生成的文件
    4. hash:最終的資源清單全部內容聯合生成的hash值
    5. chunkhash: chunk生成的資源清單內容聯合生成的hash值
    6. chunkname:chunk的名稱,若是沒有配置則使用main
    7. id: 一般指chunk的惟一編號,若是在開發環境下構建,和chunkname相同;若是是生產環境下構建,則使用一個從0開始的數字進行編號

    HMR熱更新原理

    簡介

    Hot Module Replacement(如下簡稱:HMR 模塊熱替換)是 Webpack 提供的一個很是有用的功能,它容許在 JavaScript 運行時更新各類模塊,而無需徹底刷新

    當咱們修改代碼並保存後,Webpack 將對代碼從新打包,HMR 會在應用程序運行過程當中替換、添加或刪除模塊,而無需從新加載整個頁面。
    HMR 主要經過如下幾種方式,來顯著加快開發速度:

    • 保留在徹底從新加載頁面時丟失的應用程序狀態;
    • 只更新變動內容,以節省寶貴的開發時間;
    • 調整樣式更加快速 - 幾乎至關於在瀏覽器調試器中更改樣式。

    服務啓動

    webpack-dev-server:不是一個插件,而是一個web服務器

    服務啓動流程

    image-20210128141442540

    webpack-dev-server源碼解析

    //啓動服務的具體方法
    function startDevServer(config, options) {
      const log = createLogger(options);
      //聲明全局webpack實例
      let compiler;
    
      try {
        compiler = webpack(config);
      } catch (err) {
        if (err instanceof webpack.WebpackOptionsValidationError) {
          log.error(colors.error(options.stats.colors, err.message));
          // eslint-disable-next-line no-process-exit
          process.exit(1);
        }
    
        throw err;
      }
    
      try {
        //建立server服務
        server = new Server(compiler, options, log);
        serverData.server = server;
      } catch (err) {
        if (err.name === 'ValidationError') {
          log.error(colors.error(options.stats.colors, err.message));
          // eslint-disable-next-line no-process-exit
          process.exit(1);
        }
        throw err;
      }
      if (options.socket) {
        //設置服務監聽
        server.listeningApp.on('error', (e) => {
          if (e.code === 'EADDRINUSE') {
            //使用socket創建長鏈接
            //初始化socket
            const clientSocket = new net.Socket();
    
            clientSocket.on('error', (err) => {
              if (err.code === 'ECONNREFUSED') {
                // No other server listening on this socket so it can be safely removed
                fs.unlinkSync(options.socket);
    
                server.listen(options.socket, options.host, (error) => {
                  if (error) {
                    throw error;
                  }
                });
              }
            });
    
            clientSocket.connect({ path: options.socket }, () => {
              throw new Error('This socket is already used');
            });
          }
        });
    
        server.listen(options.socket, options.host, (err) => {
          if (err) {
            throw err;
          }
    
          // chmod 666 (rw rw rw)
          const READ_WRITE = 438;
    
          fs.chmod(options.socket, READ_WRITE, (err) => {
            if (err) {
              throw err;
            }
          });
        });
      } else {
        server.listen(options.port, options.host, (err) => {
          if (err) {
            throw err;
          }
        });
      }
    }
    //啓動webpack-dev-server服務器
    processOptions(config, argv, (config, options) => {
      startDevServer(config, options);
    });

    server.js源碼解析

    class Server {
      constructor(compiler, options = {}, _log) {
        ......
        //構造函數初始化服務
      }
        //建立初始化express應用
      setupApp() {
        this.app = new express();
      }
         // 綁定監聽事件
      setupHooks() {
        //當監聽到一次webpack編譯結束,就會調用_sendStats方法經過websocket給瀏覽器發送通知,
        //ok和hash事件,這樣瀏覽器就能夠拿到最新的hash值了,作檢查更新邏輯
        const addHooks = (compiler) => {
          const { compile, invalid, done } = compiler.hooks;
          compile.tap('webpack-dev-server', invalidPlugin);
          invalid.tap('webpack-dev-server', invalidPlugin);
          // 監聽webpack的done鉤子,tapable提供的監聽方法
          done.tap('webpack-dev-server', (stats) => {
            this._sendStats(this.sockets, this.getStats(stats));
            this._stats = stats;
          });
        };
            ......
      }
    
        //使用webpack-dev-middleware中間件,返回生成的bundle文件
      setupDevMiddleware() {
        // middleware for serving webpack bundle
        this.middleware = webpackDevMiddleware(
          this.compiler,
          Object.assign({}, this.options, { logLevel: this.log.options.level })
        );
      }
        ......
      //建立http服務,並啓動服務
      createServer() { ... }
        //建立socket服務器創建長鏈接
      createSocketServer() {
        ......
      }
      //使用socket在服務器和瀏覽器直接創建一個websocket長鏈接
      listen(port, hostname, fn){ ... }
      // 經過websoket給客戶端發消息
      _sendStats(sockets, stats, force) {
        ......
        this.sockWrite(sockets, 'hash', stats.hash);
        if (stats.errors.length > 0) {
          this.sockWrite(sockets, 'errors', stats.errors);
        } else if (stats.warnings.length > 0) {
          this.sockWrite(sockets, 'warnings', stats.warnings);
        } else {
          this.sockWrite(sockets, 'ok');
        }
      }

    client/index.js源碼解析

    var onSocketMessage = { 
      hash: function hash(_hash) {
            // 更新currentHash值
            status.currentHash = _hash;
        },
        ok: function ok() {
            sendMessage('Ok');
            // 進行更新檢查等操做
            reloadApp(options, status);
        },
    }
    // 鏈接服務地址socketUrl,?http://localhost:8080,本地服務地址
    socket(socketUrl, onSocketMessage);

    熱更新

    熱更新流程

    webpackHMR流程

    1. 文件系統發生變化
    2. 當監聽到文件發生變化時,webpack 使用HotModuleReplacementPlugin編譯文件,並將代碼保存在內存中(webpack-dev-middleware)。
    3. 同時,webpack-dev-server經過編譯器compiler得到文件的編譯狀況。
    4. 在compiler的 done 鉤子函數(生命週期)裏調用_sendStats發送向client發送hash值,在client保存下來。
    5. client接收到ok或warning消息後調用reloadApp發佈客戶端檢查更新事件webpackHotUpdate。
    6. webpack/hot監聽到webpackHotUpdate事件,調用check方法進行hash值對比以及檢查各modules是否須要更新。
    7. 調用JsonpMainTemplate.runtime的hotDownloadManifest方法向server端發送ajax請求
    8. 服務端返回hot-update.json(manifest)文件,該文件包含全部要更新模塊的hash值和chunk名。
    9. JsonpRuntime根據返回的json值使用jsonp請求具體的代碼塊
    10. jsonp返回最新的chunk代碼,並直接執行。
    11. HotModulePlugin 將會對新舊模塊進行對比,決定是否更新模塊,檢查模塊之間的依賴關係,更新模塊的同時更新模塊間的依賴引用。
    12. HMR runtime自己並不會處理代碼修改,它會將不一樣文件交給對應的loader runtime處理
    13. 更新代碼
    14. 若是更新失敗,則直接刷新

    webpaserver端源碼

    在項目初始化時,服務端與客戶端已經開啓了長鏈接服務,當webpack對文件編譯產生變化時,服務端會及時通知客戶端。

    class Server {
      ...
      setupHooks() {
        //添加webpack的done事件回調
        const addHooks = (compiler) => {
          const { compile, invalid, done } = compiler.hooks;
          //通知正在客戶端編譯  
          compile.tap(\'webpack-dev-server\', invalidPlugin);
          done.tap(\'webpack-dev-server\', (stats) => {
            //編譯完成向客戶端發送消息
            this._sendStats(this.sockets, this.getStats(stats)); 
            this._stats = stats;
          });
        };
        addHooks(this.compiler);
      } 
      _sendStats(sockets, stats, force) {
        if (...) { //無變化則return
          return this.sockWrite(sockets, \'still-ok\');
        }
        //若是有變化,則發送hash值
        this.sockWrite(sockets, \'hash\', stats.hash);
        
        if (stats.errors.length > 0) {
          this.sockWrite(sockets, \'errors\', stats.errors);
        } else {//沒有報錯發送ok
          this.sockWrite(sockets, \'ok\');
        }
      }
      ...
      //使用sockjs在瀏覽器端和服務端之間創建一個 websocket 長鏈接
      listen(port, hostname, fn) {...}
    }

    這裏仍然是Server.js中的代碼,我詳細的寫展現了setupHooks中的代碼,setupHooks 調用 webpack api 監聽 compile的 done 事件,當編譯完成,執行done鉤子,調用_sendStats,在_sendStats方法中若是文件變化則發送hash。最後發送ok,客戶端在接受到OK後會執行reload。

    client端源碼

    客戶端socket接受到hash後保存起來,隨後接受到ok執行reload命令。

    //Client/index.js
    var onSocketMessage = {
      ...
      hash: function hash(_hash) {
        //將hash保存到全局currentHash中
        status.currentHash = _hash; 
      },
      ok: function ok() {
        ...
          //執行更新的reloadApp函數
        reloadApp(options, status); 
      },
      ...
    };
    socket(socketUrl, onSocketMessage);
    //Client/utils/reloadApp.js
    function reloadApp(_ref, _ref2) {
      if (hot) {
        //hotEmitter是events類,webpack-dev-server發佈webpackHotUpdate給webapck
        var hotEmitter = require(\'webpack/hot/emitter\');
        hotEmitter.emit(\'webpackHotUpdate\', currentHash);
    
        if (typeof self !== \'undefined\' && self.window) {
          // broadcast update to window
          self.postMessage("webpackHotUpdate".concat(currentHash), \'*\');
        }
      } 
    }

    客戶端接收到ok指令後,執行reloadApp函數。reloadApp函數中,hotEmitter實際上是events模塊的實例,即在全局實現發佈訂閱模式,hotEmitter發佈了webpackHotUpdate事件,同時webpack訂閱這個指令。

    在這裏之後,瀏覽器端進入webpack的代碼,webpack-dev-server在客戶端的部分完成。

    訂閱webpackHotUpdate事件的代碼在webpack/hot/dev-server.js中:

    if (module.hot) {
        var lastHash;
        var check = function check() {
            module.hot
                .check(true)
                .then(function (updatedModules) {
                    //檢查全部要更新的模塊,若是沒有模塊要更新那麼回調函數就是null
                    if (!updatedModules) {
                        window.location.reload();
                        return;
                    }
                    if (!upToDate()) {//若是還有更新
                        check();
                    }
                })
        };
        var hotEmitter = require("./emitter");
        hotEmitter.on("webpackHotUpdate", function (currentHash) {
            lastHash = currentHash;
            check(); //調用check方法
        });
    }

    module爲全局對象,module.hot的代碼在HMR runtime中,module.hot.check對應hotCheck方法:

    hotCheck = () => { //module.hot.check方法
        return hotDownloadManifest.then((update) => { 
            //保存全局的熱更新信息
            hotAvailableFilesMap = update.c;
            hotUpdateNewHash = update.h;
            /*globals chunkId */
            hotEnsureUpdateChunk(chunkId)
        })
    }
    hotDownloadManifest(){ //ajax請求模塊manifest
        return new Promise(...);
    }
    hotEnsureUpdateChunk(){ //檢測模塊
        if (!hotAvailableFilesMap[chunkId]) {...} else 
        {
            hotRequestedFilesMap[chunkId] = true;
            hotDownloadUpdateChunk();
        }
    }
    hotDownloadUpdateChunk(){} //jsonp格式請求代碼模塊chunk
    
    //chunk是js代碼塊,格式是webpackHotUpdate("main", {...}),收到後直接執行,window全局中有對應方法
    window["webpackHotUpdate"]=function webpackHotUpdateCallback(){
        hotAddUpdateChunk()
    }
    hotAddUpdateChunk(){//動態的更新代碼模塊
        for (var moduleId in moreModules) {
            //記錄全局的熱更新模塊
            hotUpdate[moduleId] = moreModules[moduleId];
        }
        hotUpdateDownloaded()
    }
    hotUpdateDownloaded(){ //執行hotApply模塊
        hotApply()
    }
    hotApply(){
        //將代碼更新到modules中
    }

    主要包含了兩個請求,在hotDownloadManifest中客戶端請求了ajax的manifest,他的格式爲 {"h":"bbff25e45ca71af784d0","c":{"main":true}} 包含了要更新模塊的hash值和chunk名;另外一個hotDownloadUpdateChunk經過jsonp方法請求更新的代碼塊,
    hotDownloadUpdateChunk獲取更新的代碼
    獲取到的代碼塊能夠直接執行,webpack已經在window中註冊了webpackHotUpdate方法,執行後調用hotApply熱模塊替換方法。

    function hotApply(options) {
        function getAffectedStuff(updateModuleId) {
            ...
            return { //返回過時的模塊和依賴
                type: "accepted",
                moduleId: updateModuleId,
                outdatedModules: outdatedModules,
                outdatedDependencies: outdatedDependencies
            };
        }
        ...
                    result = getAffectedStuff(moduleId);
        ...
            {
                switch (result.type) {
                    case "self-declined":
                        ...
                        break;
                    case "accepted"://對結果進行標記及處理
                        if (options.onAccepted) options.onAccepted(result);
                        doApply = true; 
                        break;
                    case "disposed":
                        ...
                        break;
                    default:
                        ...
                }
        ...
        while (queue.length > 0) {
            moduleId = queue.pop();
            ...
            delete installedModules[moduleId];//刪除過時的模塊和依賴
            delete outdatedDependencies[moduleId];
        }
        ...
        for (moduleId in appliedUpdate) { 
            if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
                //新的模塊添加到modules中
                modules[moduleId] = appliedUpdate[moduleId];
            }
        }
        ...
    }

    模塊熱替換主要分三個部分,首先是找出 outdatedModules 和 outdatedDependencies;而後從緩存中刪除這些;最後,將新的模塊添加到 modules 中,當下次調用 webpack_require (webpack 重寫的 require 方法)方法的時候,就是獲取到了新的模塊代碼了。

    若是在熱更新過程當中出現錯誤,熱更新將回退到刷新瀏覽器。

    當用新的模塊代碼替換老的模塊後,可是咱們的業務代碼並不能知道代碼已經發生變化,也就是說,當入口文件修改後,咱們須要在入口文件中調用 HMR 的 accept 方法

    // index.js
    if(module.hot) {
        module.hot.accept(\'./main.js\', function() {
            render()
        })
    }

    更新的代碼每次在下面這個循環中執行, cb(moduleOutdatedDependencies)
    就是module.hot.accept的內容,從而實現對代碼的渲染

    function hotApply(options) {
        ...
        for (moduleId in outdatedDependencies) {
            ...
            moduleOutdatedDependencies = outdatedDependencies[moduleId];
            var callbacks = [];
            for (i = 0; i < moduleOutdatedDependencies.length; i++) {
                dependency = moduleOutdatedDependencies[i];
                cb = module.hot._acceptedDependencies[dependency];
                callbacks.push(cb); //獲取全部的模塊
            }
            for (i = 0; i < callbacks.length; i++) {
                cb = callbacks[i];
                cb(moduleOutdatedDependencies);//執行代碼模塊
            }
            ...
        }
        ...
    }

    手寫webpack構建工具

    手寫webpack流程

    image-20210128153623321

    AST

    AST(Abstract Syntax Tree)

    抽象語法樹,源代碼語法結構的一種抽象表示

    • 以樹狀的形式表現編程語言的語法結構
    • 樹上的每一個節點都表示源代碼中的一種結構

    image-20210128190259694

    AST生成過程

    抽象語法樹的生成主要依靠的是Javascript Parser(js解析器)

    • 詞法分析(Lexical Analysis)
    • 語法分析(Parse Analysis)

    image-20210128190704960

    在手寫webpack中使用

    經過Visitor完成依賴的收集

    訪問者(visitor)是一個用於 AST 遍歷的跨語言模式,定義 了用於在一個樹狀結構中獲取具體節點的方法

    image-20210128190855377

    樹的寬度優先搜索(BFS)算法思想

    應用於循環分析依賴

    image-20210128190936336

    樹的寬度優先搜索(BFS)算法思想

    循環分析結果

    image-20210128191032396

    打包結果爲一個IIFE

    image-20210128191102224

    打包結果分析

    image-20210128191133160

    結果運行分析

    image-20210128191208190

    webpack構建優化

    背景

    現在前端工程化的概念早已經深刻人心,選擇一款合適的編譯和資源管理工具已經成爲了全部前端工程中的標配,而在諸多的構建工具中,webpack以其豐富的功能和靈活的配置而深受業內吹捧,逐步取代了grunt和gulp成爲大多數前端工程實踐中的首選,React,Vue,Angular等諸多知名項目也都相繼選用其做爲官方構建工具,極受業內追捧。可是,隨者工程開發的複雜程度和代碼規模不斷地增長,webpack暴露出來的各類性能問題也愈發明顯,極大的影響着開發過程當中的體驗。

    問題概括

    歷經了多個web項目的實戰檢驗,咱們對webapck在構建中逐步暴露出來的性能問題概括主要有以下幾個方面:

    代碼全量構建速度過慢,即便是很小的改動,也要等待長時間才能查看到更新與編譯後的結果(引入HMR熱更新後有明顯改進);
    隨着項目業務的複雜度增長,工程模塊的體積也會急劇增大,構建後的模塊一般要以M爲單位計算;
    多個項目之間共用基礎資源存在重複打包,基礎庫代碼複用率不高;
    node的單進程實如今耗cpu計算型loader中表現不佳;
    針對以上的問題,咱們來看看怎樣利用webpack現有的一些機制和第三方擴展插件來逐個擊破。

    慢在何處

    做爲工程師,咱們一直鼓勵要理性思考,用數據和事實說話,「我以爲很慢」,「太卡了」,「太大了」之類的表述不免顯得太籠統和太抽象,那麼咱們不妨從以下幾個方面來着手進行分析:

    從項目結構着手,代碼組織是否合理,依賴使用是否合理;
    從webpack自身提供的優化手段着手,看看哪些api未作優化配置;
    從webpack自身的不足着手,作有針對性的擴展優化,進一步提高效率;
    在這裏咱們推薦使用一個wepback的可視化資源分析工具:webpack-bundle-analyzer,在webpack構建的時候會自動幫你計算出各個模塊在你的項目工程中的依賴與分佈狀況,方便作更精確的資源依賴和引用的分析。

    從上圖中咱們不難發現大多數的工程項目中,依賴庫的體積永遠是大頭,一般體積能夠佔據整個工程項目的7-9成,並且在每次開發過程當中也會從新讀取和編譯對應的依賴資源,這實際上是很大的的資源開銷浪費,並且對編譯結果影響微乎其微,畢竟在實際業務開發中,咱們不多會去主動修改第三方庫中的源碼,改進方案以下:

    方案1、合理配置 CommonsChunkPlugin

    webpack的資源入口一般是以entry爲單元進行編譯提取,那麼當多entry共存的時候,CommonsChunkPlugin的做用就會發揮出來,對全部依賴的chunk進行公共部分的提取,可是在這裏可能不少人會誤認爲抽取公共部分指的是能抽取某個代碼片斷,其實並不是如此,它是以module爲單位進行提取。

    假設咱們的頁面中存在entry1,entry2,entry3三個入口,這些入口中可能都會引用如utils,loadash,fetch等這些通用模塊,那麼就能夠考慮對這部分的共用部分機提取。一般提取方式有以下四種實現:

    一、傳入字符串參數,由chunkplugin自動計算提取

    new webpack.optimize.CommonsChunkPlugin('common.js')

    這種作法默認會把全部入口節點的公共代碼提取出來, 生成一個common.js

    二、有選擇的提取公共代碼

    new webpack.optimize.CommonsChunkPlugin('common.js',['entry1','entry2']);

    只提取entry1節點和entry2中的共用部分模塊, 生成一個common.js

    三、將entry下全部的模塊的公共部分(可指定引用次數)提取到一個通用的chunk中

    new webpack.optimize.CommonsChunkPlugin({
        name: 'vendors',
        minChunks: function (module, count) {
           return (
              module.resource &&
              /\.js$/.test(module.resource) &&
              module.resource.indexOf(
                path.join(__dirname, '../node_modules')
              ) === 0
           )
        }
    });

    提取全部node_modules中的模塊至vendors中,也能夠指定minChunks中的最小引用數;

    四、抽取enry中的一些lib抽取到vendors中

    entry = {
        vendors: ['fetch', 'loadash']
    };
    new webpack.optimize.CommonsChunkPlugin({
        name: "vendors",
        minChunks: Infinity
    });

    添加一個entry名叫爲vendors,並把vendors設置爲所須要的資源庫,CommonsChunk會自動提取指定庫至vendors中。

    方案2、經過 externals 配置來提取經常使用庫

    在實際項目開發過程當中,咱們並不須要實時調試各類庫的源碼,這時候就能夠考慮使用external選項了。

    簡單來講external就是把咱們的依賴資源聲明爲一個外部依賴,而後經過script外鏈腳本引入。這也是咱們早期頁面開發中資源引入的一種翻版,只是經過配置後能夠告知webapck遇到此類變量名時就能夠不用解析和編譯至模塊的內部文件中,而改用從外部變量中讀取,這樣能極大的提高編譯速度,同時也能更好的利用CDN來實現緩存。

    external的配置相對比較簡單,只須要完成以下三步:

    一、在頁面中加入須要引入的lib地址,以下:

    <head>
    <script src="//cdn.bootcss.com/jquery.min.js"></script>
    <script src="//cdn.bootcss.com/underscore.min.js"></script>
    <script src="/static/common/react.min.js"></script>
    <script src="/static/common/react-dom.js"></script>
    <script src="/static/common/react-router.js"></script>
    <script src="/static/common/immutable.js"></script>
    </head>

    二、在webapck.config.js中加入external配置項:

    module.export = {
        externals: {
            'react-router': {
                amd: 'react-router',
                root: 'ReactRouter',
                commonjs: 'react-router',
                commonjs2: 'react-router'
            },
            react: {
                amd: 'react',
                root: 'React',
                commonjs: 'react',
                commonjs2: 'react'
            },
            'react-dom': {
                amd: 'react-dom',
                root: 'ReactDOM',
                commonjs: 'react-dom',
                commonjs2: 'react-dom'
            }
        }
    }

    這裏要提到的一個細節是:此類文件在配置前,構建這些資源包時須要採用amd/commonjs/cmd相關的模塊化進行兼容封裝,即打包好的庫已是umd模式包裝過的,如在node_modules/react-router中咱們能夠看到umd/ReactRouter.js之類的文件,只有這樣webpack中的requireimport * from 'xxxx'才能正確讀到該類包的引用,在這類js的頭部通常也能看到以下字樣:

    if (typeof exports === 'object' && typeof module === 'object') {

    module.exports = factory(require("react"));

    } else if (typeof define === 'function' && define.amd) {

    define(["react"], factory);

    } else if (typeof exports === 'object') {

    exports["ReactRouter"] = factory(require("react"));

    } else {

    root["ReactRouter"] = factory(root["React"]);

    }

    三、很是重要的是必定要在output選項中加入以下一句話:

    output: {
      libraryTarget: 'umd'
    }

    因爲經過external提取過的js模塊是不會被記錄到webapckchunk信息中,經過libraryTarget可告知咱們構建出來的業務模塊,當讀到了externals中的key時,須要以umd的方式去獲取資源名,不然會有出現找不到module的狀況。

    經過配置後,咱們能夠看到對應的資源信息已經能夠在瀏覽器的source map中讀到了。

    對應的資源也能夠直接由頁面外鏈載入,有效地減少了資源包的體積。

    方案3、利用 DllPlugin 和 DllReferencePlugin 預編譯資源模塊

    咱們的項目依賴中一般會引用大量的npm包,而這些包在正常的開發過程當中並不會進行修改,可是在每一次構建過程當中卻須要反覆的將其解析,如何來規避此類損耗呢?這兩個插件就是幹這個用的。

    簡單來講DllPlugin的做用是預先編譯一些模塊,而DllReferencePlugin則是把這些預先編譯好的模塊引用起來。這邊須要注意的是DllPlugin必需要在DllReferencePlugin執行前先執行一次,dll這個概念應該也是借鑑了windows程序開發中的dll文件的設計理念。

    相對於externals,dllPlugin有以下幾點優點:

    • dll預編譯出來的模塊能夠做爲靜態資源連接庫可被重複使用,尤爲適合多個項目之間的資源共享,如同一個站點pc和手機版等;
    • dll資源能有效地解決資源循環依賴的問題,部分依賴庫如:react-addons-css-transition-group這種原先從react核心庫中抽取的資源包,整個代碼只有一句話:
    module.exports = require('react/lib/ReactCSSTransitionGroup');

    卻由於從新指向了react/lib中,這也會致使在經過externals引入的資源只能識別react,尋址解析react/lib則會出現沒法被正確索引的狀況。

    • 因爲externals的配置項須要對每一個依賴庫進行逐個定製,因此每次增長一個組件都須要手動修改,略微繁瑣,而經過dllPlugin則能徹底經過配置讀取,減小維護的成本;

    一、配置dllPlugin對應資源表並編譯文件

    那麼externals該如何使用呢,其實只須要增長一個配置文件:webpack.dll.config.js

    const webpack = require('webpack');
    const path = require('path');
    const isDebug = process.env.NODE_ENV === 'development';
    const outputPath = isDebug ? path.join(__dirname, '../common/debug') : path.join(__dirname, '../common/dist');
    const fileName = '[name].js';
    
    // 資源依賴包,提早編譯
    const lib = [
      'react',
      'react-dom',
      'react-router',
      'history',
      'react-addons-pure-render-mixin',
      'react-addons-css-transition-group',
      'redux',
      'react-redux',
      'react-router-redux',
      'redux-actions',
      'redux-thunk',
      'immutable',
      'whatwg-fetch',
      'byted-people-react-select',
      'byted-people-reqwest'
    ];
    
    const plugin = [
      new webpack.DllPlugin({
        /**
         * path
         * 定義 manifest 文件生成的位置
         * [name]的部分由entry的名字替換
         */
        path: path.join(outputPath, 'manifest.json'),
        /**
         * name
         * dll bundle 輸出到那個全局變量上
         * 和 output.library 同樣便可。
         */
        name: '[name]',
        context: __dirname
      }),
      new webpack.optimize.OccurenceOrderPlugin()
    ];
    
    if (!isDebug) {
      plugin.push(
        new webpack.DefinePlugin({
          'process.env.NODE_ENV': JSON.stringify('production')
        }),
        new webpack.optimize.UglifyJsPlugin({
          mangle: {
            except: ['$', 'exports', 'require']
          },
          compress: { warnings: false },
          output: { comments: false }
        })
      )
    }
    
    module.exports = {
      devtool: '#source-map',
      entry: {
        lib: lib
      },
      output: {
        path: outputPath,
        filename: fileName,
        /**
         * output.library
         * 將會定義爲 window.${output.library}
         * 在此次的例子中,將會定義爲`window.vendor_library`
         */
        library: '[name]',
        libraryTarget: 'umd',
        umdNamedDefine: true
      },
      plugins: plugin
    };

    而後執行命令:

    $ NODE_ENV=development webpack --config  webpack.dll.lib.js --progress
    $ NODE_ENV=production webpack --config  webpack.dll.lib.js --progress

    便可分別編譯出支持調試版和生產環境中lib靜態資源庫,在構建出來的文件中咱們也能夠看到會自動生成以下資源:

    common
    ├── debug
     │   ├── lib.js
     │   ├── lib.js.map
     │   └── manifest.json
    └── dist
        ├── lib.js
        ├── lib.js.map
        └── manifest.json

    文件說明:

    lib.js能夠做爲編譯好的靜態資源文件直接在頁面中經過src連接引入,與externals的資源引入方式同樣,生產與開發環境能夠經過相似charles之類的代理轉發工具來作路由替換;
    manifest.json中保存了webpack中的預編譯信息,這樣等於提早拿到了依賴庫中的chunk信息,在實際開發過程當中就無須要進行重複編譯;

    二、dllPlugin的靜態資源引入

    lib.js和manifest.json存在一一對應的關係,因此咱們在調用的過程也許遵循這個原則,如當前處於開發階段,對應咱們能夠引入common/debug文件夾下的lib.js和manifest.json,切換到生產環境的時候則須要引入common/dist下的資源進行對應操做,這裏考慮到手動切換和維護的成本,咱們推薦使用add-asset-html-webpack-plugin進行依賴資源的注入,可獲得以下結果:

    <head>
    <script src="/static/common/lib.js"></script>
    </head>
    在webpack.config.js文件中增長以下代碼:
    
    const isDebug = (process.env.NODE_ENV === 'development');
    const libPath = isDebug ? '../dll/lib/manifest.json' : 
    '../dll/dist/lib/manifest.json';
    
    // 將mainfest.json添加到webpack的構建中
    
    module.export = {
      plugins: [
           new webpack.DllReferencePlugin({
           context: __dirname,
           manifest: require(libPath),
          })
      ]
    }

    配置完成後咱們能發現對應的資源包已經完成了純業務模塊的提取

    多個工程之間若是須要使用共同的lib資源,也只須要引入對應的lib.js和manifest.js便可,plugin配置中也支持多個webpack.DllReferencePlugin同時引入使用,以下:

    module.export = {
      plugins: [
         new webpack.DllReferencePlugin({
            context: __dirname,
            manifest: require(libPath),
          }),
          new webpack.DllReferencePlugin({
            context: __dirname,
            manifest: require(ChartsPath),
          })
      ]

    方案4、使用 Happypack 加速你的代碼構建

    以上介紹均爲針對webpack中的chunk計算和編譯內容的優化與改進,對資源的實際體積改進上也較爲明顯,那麼除此以外,咱們可否針對資源的編譯過程和速度優化上作些嘗試呢?

    衆所周知,webpack中爲了方便各類資源和類型的加載,設計了以loader加載器的形式讀取資源,可是受限於node的編程模型影響,全部的loader雖然以async的形式來併發調用,可是仍是運行在單個 node的進程以及在同一個事件循環中,這就直接致使了當咱們須要同時讀取多個loader文件資源時,好比babel-loader須要transform各類jsx,es6的資源文件。在這種同步計算同時須要大量耗費cpu運算的過程當中,node的單進程模型就無優點了,那麼happypack就針對解決此類問題而生。

    開啓happypack的線程池

    happypack的處理思路是將原有的webpack對loader的執行過程從單一進程的形式擴展多進程模式,本來的流程保持不變,這樣能夠在不修改原有配置的基礎上來完成對編譯過程的優化,具體配置以下:

    const HappyPack = require('happypack');
     const os = require('os')
     const HappyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length}); // 啓動線程池});
    
    module:{
        rules: [
          {
            test: /\.(js|jsx)$/,
            // use: ['babel-loader?cacheDirectory'],
            use: 'happypack/loader?id=jsx',
            exclude: /^node_modules$/
          }
        ]
      },
      plugins:[
        new HappyPack({
         id: 'jsx',
         cache: true,
         threadPool: HappyThreadPool,
         loaders: ['babel-loader']
       })
      ]

    咱們能夠看到經過在loader中配置直接指向happypack提供的loader,對於文件實際匹配的處理 loader,則是經過配置在plugin屬性來傳遞說明,這裏happypack提供的loader與plugin的銜接匹配,則是經過id=happybabel來完成。配置完成後,laoder的工做模式就轉變成了以下所示:

    happypack在編譯過程當中除了利用多進程的模式加速編譯,還同時開啓了cache計算,能充分利用緩存讀取構建文件,對構建的速度提高也是很是明顯的,通過測試,最終的構建速度提高以下:

    優化前:

    優化後:

    關於happyoack的更多介紹能夠查看:

    [happypack]()

    [happypack 原理解析]()

    方案5、加強 uglifyPlugin

    uglifyJS憑藉基於node開發,壓縮比例高,使用方便等諸多優勢已經成爲了js壓縮工具中的首選,可是咱們在webpack的構建中觀察發現,當webpack build進度走到80%先後時,會發生很長一段時間的停滯,經測試對比發現這一過程正是uglfiyJS在對咱們的output中的bunlde部分進行壓縮耗時過長致使,針對這塊咱們可使用webpack-uglify-parallel來提高壓縮速度。

    從插件源碼中能夠看到,webpack-uglify-parallel的是實現原理是採用了多核並行壓縮的方式來提高咱們的壓縮速度。

    plugin.nextWorker().send({
        input: input,
        inputSourceMap: inputSourceMap,
        file: file,
        options: options
    });
    
    plugin._queue_len++;
                    
    if (!plugin._queue_len) {
        callback();
    }               
    
    if (this.workers.length < this.maxWorkers) {
        var worker = fork(__dirname + '/lib/worker');
        worker.on('message', this.onWorkerMessage.bind(this));
        worker.on('error', this.onWorkerError.bind(this));
        this.workers.push(worker);
    }
    
    this._next_worker++;
    return this.workers[this._next_worker % this.maxWorkers];

    使用配置也很是簡單,只須要將咱們原來webpack中自帶的uglifyPlugin配置:

    new webpack.optimize.UglifyJsPlugin({
       exclude:/\.min\.js$/
       mangle:true,
       compress: { warnings: false },
       output: { comments: false }
    })
    修改爲以下代碼便可:
    
    const os = require('os');
        const UglifyJsParallelPlugin = require('webpack-uglify-parallel');
        
        new UglifyJsParallelPlugin({
          workers: os.cpus().length,
          mangle: true,
          compressor: {
            warnings: false,
            drop_console: true,
            drop_debugger: true
           }
        })

    目前webpack官方也維護了一個支持多核壓縮的UglifyJs插件:uglifyjs-webpack-plugin,使用方式相似,優點在於徹底兼容webpack.optimize.UglifyJsPlugin中的配置,能夠經過uglifyOptions寫入,所以也作爲推薦使用,參考配置以下:

    const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
      new UglifyJsPlugin({
        uglifyOptions: {
          ie8: false,
          ecma: 8,
          mangle: true,
          output: { comments: false },
          compress: { warnings: false }
        },
        sourceMap: false,
        cache: true,
        parallel: os.cpus().length * 2
      })

    方案6、Tree-shaking & Scope Hoisting

    wepback在2.X和3.X中從rolluo中借鑑了tree-shaking和Scope Hoisting,利用es6的module特性,利用AST對全部引用的模塊和方法作了靜態分析,從而能有效地剔除項目中的沒有引用到的方法,並將相關方法調用概括到了獨立的webpack_module中,對打包構建的體積優化也較爲明顯,可是前提是全部的模塊寫法必須使用ES6 Module進行實現,具體配置參考以下:

    // .babelrc: 經過配置減小沒有引用到的方法
      {
        "presets": [
          ["env", {
            "targets": {
              "browsers": ["last 2 versions", "safari >= 7"]
            }
          }],
          // https://www.zhihu.com/question/41922432
          ["es2015", {"modules": false}]  // tree-shaking
        ]
      }
    
      // webpack.config: Scope Hoisting
      {
        plugins:[
          // https://zhuanlan.zhihu.com/p/27980441
          new webpack.optimize.ModuleConcatenationPlugin()
        ]
      }

    適用場景

    在實際的開發過程當中,可靈活地選擇適合自身業務場景的優化手段。

    優化手段 開發環境 生產環境
    CommonsChunk
    externals
    DllPlugin
    Happypack
    uglify-parallel
    相關文章
    相關標籤/搜索