webpack系列--淺析webpack的原理

saucxs發表於2019-06-14

一、前言

現在隨著前端開發的複雜度和規模越來越大,鷹不能拋開工程化來獨立開發,比如:react的jsx程式碼必須編譯後才能在瀏覽器中使用,比如sass和less程式碼瀏覽器是不支援的。如果摒棄這些開發框架,開發效率會大幅下降。

在眾多前端工程化工具中,webpack脫穎而出成為了當今最流行的前端構建工具。

 

二、webpack的原理

知其然知其所以然。

1、核心概念

(1)entry:一個可執行模組或者庫的入口。

(2)chunk:多個檔案組成一個程式碼塊。可以將可執行的模組和他所依賴的模組組合成一個chunk,這是打包。

(3)loader:檔案轉換器。例如把es6轉為es5,scss轉為css等

(4)plugin:擴充套件webpack功能的外掛。在webpack構建的生命週期節點上加入擴充套件hook,新增功能。

 

2、webpack構建流程(原理)

從啟動構建到輸出結果一系列過程:

(1)初始化引數:解析webpack配置引數,合併shell傳入和webpack.config.js檔案配置的引數,形成最後的配置結果。

(2)開始編譯:上一步得到的引數初始化compiler物件,註冊所有配置的外掛,外掛監聽webpack構建生命週期的事件節點,做出相應的反應,執行物件的 run 方法開始執行編譯。

(3)確定入口:從配置的entry入口,開始解析檔案構建AST語法樹,找出依賴,遞迴下去。

(4)編譯模組:遞迴中根據檔案型別和loader配置,呼叫所有配置的loader對檔案進行轉換,再找出該模組依賴的模組,再遞迴本步驟直到所有入口依賴的檔案都經過了本步驟的處理。

(5)完成模組編譯並輸出:遞迴完事後,得到每個檔案結果,包含每個模組以及他們之間的依賴關係,根據entry配置生成程式碼塊chunk。

(6)輸出完成:輸出所有的chunk到檔案系統。

注意:在構建生命週期中有一系列外掛在做合適的時機做合適事情,比如UglifyPlugin會在loader轉換遞迴完對結果使用UglifyJs壓縮覆蓋之前的結果。

 

三、業務場景和對應解決方案

1、單頁應用

一個單頁應用需要配置一個entry指明執行入口,web-webpack-plugin裡的WebPlugin可以自動的完成這些工作:webpack會為entry生成一個包含這個入口的所有依賴檔案的chunk,但是還需要一個html來載入chunk生成的js,如果還提取出css需要HTML檔案中引入提取的css。

一個簡單的webpack配置檔案栗子

const { WebPlugin } = require('web-webpack-plugin');
module.exports = {
  entry: {
    app: './src/doc/index.js',
    home: './src/doc/home.js'
  },
  plugins: [
    // 一個WebPlugin對應生成一個html檔案
    new WebPlugin({
      //輸出的html檔名稱
      filename: 'index.html',
      //這個html依賴的`entry`
      requires: ['app','home'],
    }),
  ],
};

說明:require: ['app', 'home']指明這個html依賴哪些entry,entry生成的js和css會自動注入到html中。

還支援配置這些資源注入方式,支援如下屬性:

(1)_dist只有在生產環境中才引入的資源;

(2)_dev只有在開發環境中才引入的資源;

(3)_inline把資源的內容潛入到html中;

(4)_ie只有IE瀏覽器才需要引入的資源。

這些屬性可以通過在js裡配置,看個簡單例子:

new WebPlugin({
    filename: 'index.html',
    requires: {
         app:{
              _dist:true,
              _inline:false,
         }
    },
}),

這些屬性還可以在模板中設定,使用模板好處就是可以靈活的控制資源的注入點。

new WebPlugin({
      filename: 'index.html',
      template: './template.html',
}),

//template模板
<!DOCTYPE html>
<html lang="zh-cn">
<head>
    <link rel="stylesheet" href="app?_inline">
    <script src="ie-polyfill?_ie"></script>
</head>
<body>
<div id="react-body"></div>
<script src="app"></script>
</body>
</html>

WebPlugin外掛借鑑了fis3的思想,補足了webpack缺失的以HTML為入口的功能。想了解WebPlugin的更多功能,見文件

 

2、一個專案管理多個單頁面

一個專案中會包含多個單頁應用,雖然多個單頁面應用可以合成一個,但是這樣做會導致使用者沒有訪問的部分也載入了,如果專案中有很多的單頁應用。為每一個單頁應用配置一個entry和WebPlugin?如果又新增,又要新增webpack配置,這樣做麻煩,這時候有一個外掛web-webpack-plugin裡的AutoWebPlugin方法可以解決這些問題。

module.exports = {
    plugins: [
        // 所有頁面的入口目錄
        new AutoWebPlugin('./src/'),
    ]
};

分析:1、AutoWebPlugin會把./src/目錄下所有每個資料夾作為一個單頁頁面的入口,自動為所有的頁面入口配置一個WebPlugin輸出對應的html。

2、要新增一個頁面就在./src/下新建一個資料夾包含這個單頁應用所依賴的程式碼,AutoWebPlugin自動生成一個名叫資料夾名稱的html檔案。

 

3、程式碼分隔優化

一個好的程式碼分割對瀏覽器首屏效果提升很大。

最常見的react體系:

(1)先抽出基礎庫react react-dom redux react-redux到一個單獨的檔案而不是和其它檔案放在一起打包為一個檔案,這樣做的好處是隻要你不升級他們的版本這個檔案永遠不會被重新整理。如果你把這些基礎庫和業務程式碼打包在一個檔案裡每次改動業務程式碼都會導致檔案hash值變化從而導致快取失效瀏覽器重複下載這些包含基礎庫的程式碼。所以把基礎庫打包成一個檔案

// vender.js 檔案抽離基礎庫到單獨的一個檔案裡防止跟隨業務程式碼被重新整理
// 所有頁面都依賴的第三方庫
// react基礎
import 'react';
import 'react-dom';
import 'react-redux';
// redux基礎
import 'redux';
import 'redux-thunk';
// webpack配置
{
  entry: {
    vendor: './path/to/vendor.js',
  },
}

(2)通過CommonsChunkPlugin可以提取出多個程式碼塊都依賴的程式碼形成一個單獨的chunk。在應用有多個頁面的場景下提取出所有頁面公共的程式碼減少單個頁面的程式碼,在不同頁面之間切換時所有頁面公共的程式碼之前被載入過而不必重新載入。所以通過CommonsChunkPlugin可以提取出多個程式碼塊都依賴的程式碼形成一個單獨的chunk。

 

4、構建服務端渲染

服務端渲染的程式碼要執行在nodejs環境,和瀏覽器不同的是,服務端渲染程式碼需要採用commonjs規範同時不應該包含除js之外的檔案比如css。

webpack配置如下:

module.exports = {
  target: 'node',
  entry: {
    'server_render': './src/server_render',
  },
  output: {
    filename: './dist/server/[name].js',
    libraryTarget: 'commonjs2',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
      },
      {
        test: /\.(scss|css|pdf)$/,
        loader: 'ignore-loader',
      },
    ]
  },
};

分析一下:

(1)target: 'node'指明構建出程式碼要執行在node環境中。

(2)libraryTarget: 'commonjs2' 指明輸出的程式碼要是commonjs規範。

(3){test: /\.(scss|css|pdf)$/,loader: 'ignore-loader'} 是為了防止不能在node裡執行服務端渲染也用不上的檔案被打包進去。

 

5、fis3遷移到webpack

fis3和webpack有很多相似地方也有不同的地方,相似地方:都採用commonjs規範,不同地方:匯入css這些非js資源的方式。

fis3通過@require './index.scss',而webpack是通過require('./index.scss')。

如果想把fis3平滑遷移到webpack,可以使用comment-require-loader。

比如:你想在webpack構建是使用採用了fis3方式的imui模組

loaders:[{
     test: /\.js$/,
     loaders: ['comment-require-loader'],
     include: [path.resolve(__dirname, 'node_modules/imui'),]
}]

 

四、自定義webpack擴充套件

如果你在社群找不到你的應用場景的解決方案,那就需要自己動手了寫loader或者plugin了。
在你編寫自定義webpack擴充套件前你需要想明白到底是要做一個loader還是plugin呢?可以這樣判斷:

如果你的擴充套件是想對一個個單獨的檔案進行轉換那麼就編寫loader剩下的都是plugin。

其中對檔案進行轉換可以是像:

1、babel-loader把es6轉為es5;

2、file-loader把檔案替換成對應的url;

3、raw-loader注入文字檔案內容到程式碼中。

1、編寫webpack loader

編寫loader非常簡單,以comment-require-loader為例:

module.exports = function (content) {
    return replace(content);
};

loader的入口需要匯出一個函式,這個函式要乾的事情就是轉換一個檔案的內容。
函式接收的引數content是一個檔案在轉換前的字串形式內容,需要返回一個新的字串形式內容作為轉換後的結果,所有通過模組化倒入的檔案都會經過loader。從這裡可以看出loader只能處理一個個單獨的檔案而不能處理程式碼塊。可以參考官方文件

 

2、編寫webpack plugin

plugin應用場景廣泛,所以稍微複雜點。以end-webpack-plugin為例:

class EndWebpackPlugin {

    constructor(doneCallback, failCallback) {
        this.doneCallback = doneCallback;
        this.failCallback = failCallback;
    }

    apply(compiler) {
        // 監聽webpack生命週期裡的事件,做相應的處理
        compiler.plugin('done', (stats) => {
            this.doneCallback(stats);
        });
        compiler.plugin('failed', (err) => {
            this.failCallback(err);
        });
    }
}

module.exports = EndWebpackPlugin;

loader的入口需要匯出一個class,在new EndWebpackPlugin()的時候通過建構函式傳入這個外掛需要的引數,在webpack啟動的時候會先例項化plugin,再呼叫plugin的apply方法,外掛在apply函式裡監聽webpack生命週期裡的事件,做相應的處理。

webpack plugin的兩個核心概念:

(1)compiler:從webpack啟動到退出只存在一個Compiler,compiler存放著webpack的配置。

(2)compilation:由於webpack的監聽檔案變化自動編譯機制,compilation代表一次編譯。

Compiler 和 Compilation 都會廣播一系列事件。webpack生命週期裡有非常多的事件

以上只是一個最簡單的demo,更復雜的可以檢視 how to write a plugin或參考web-webpack-plugin

 

五、總結

webpack其實比較簡單,用一句話概括本質:

webpack是一個打包模組化js的工具,可以通過loader轉換檔案,通過plugin擴充套件功能。

如果webpack讓你感到複雜,一定是各種loader和plugin的原因。

 

六、一些問題

1、webpack與grunt、gulp的不同?

三者都是前端構建工具,grunt和gulp在早期比較流行,現在webpack相對來說比較主流,不過一些輕量化的任務還是會用gulp來處理,比如單獨打包CSS檔案等。

grunt和gulp是基於任務和流(Task、Stream)的。類似jQuery,找到一個(或一類)檔案,對其做一系列鏈式操作,更新流上的資料, 整條鏈式操作構成了一個任務,多個任務就構成了整個web的構建流程。

webpack是基於入口的。webpack會自動地遞迴解析入口所需要載入的所有資原始檔,然後用不同的Loader來處理不同的檔案,用Plugin來擴充套件webpack功能。

總結:(1)從構建思路來說:gulp和grunt需要開發者將整個前端構建過程拆分成多個`Task`,併合理控制所有`Task`的呼叫關係 webpack需要開發者找到入口,並需要清楚對於不同的資源應該使用什麼Loader做何種解析和加工;

(2)對於知識背景:gulp更像後端開發者的思路,需要對於整個流程瞭如指掌 webpack更傾向於前端開發者的思路。

 

2、 與webpack類似的工具還有哪些?談談你為什麼最終選擇(或放棄)使用webpack?

同樣是基於入口的打包工具還有以下幾個主流的:webpack,rollup,parcel。

從應用場景上來看:(1)webpack適合大型複雜的前端站點構建;(2)rollup適合基礎庫的打包,比如vue,react;(3)parcel適用於簡單的實驗室專案,但是打包出錯很難除錯。

 

3、有哪些常見的Loader?他們是解決什麼問題的?

(1)babel-loader:把es6轉成es5;

(2)css-loader:載入css,支援模組化,壓縮,檔案匯入等特性;

(3)style-loader:把css程式碼注入到js中,通過dom操作去載入css;

(4)eslint-loader:通過Eslint檢查js程式碼;

(5)image-loader:載入並且壓縮圖片晚間;

(6)file-loader:檔案輸出到一個資料夾中,在程式碼中通過相對url去引用輸出的檔案;

(7)url-loader:和file-loader類似,檔案很小的時候可以base64方式吧檔案內容注入到程式碼中。

(8)source-map-loader:載入額外的source map檔案,方便除錯。

 

4、有哪些常見的Plugin?他們是解決什麼問題的?

(1)uglifyjs-webpack-plugin:通過UglifyJS去壓縮js程式碼;

(2)commons-chunk-plugin:提取公共程式碼;

(3)define-plugin:定義環境變數。

 

5、loader和plugin的不同

作用不同:(1)loader讓webpack有載入和解析非js的能力;(2)plugin可以擴充套件webpack功能,在webpack執行週期中會廣播很多事件,Plugin可以監聽一些事件,通過webpack的api改變結果。

用法不同:(1)loader在module.rule中配置。型別為陣列,每一項都是Object;(2)plugin是單獨配置的,型別為陣列,每一項都是plugin例項,引數通過建構函式傳入。

 

6、webpack的構建流程是什麼?從讀取配置到輸出檔案這個過程儘量說全

Webpack 的執行流程是一個序列的過程,從啟動到結束會依次執行以下流程:

(1)初始化引數:從配置檔案和 Shell 語句中讀取與合併引數,得出最終的引數;

(2)開始編譯:用上一步得到的引數初始化 Compiler 物件,載入所有配置的外掛,執行物件的 run 方法開始執行編譯;

(3)確定入口:根據配置中的 entry 找出所有的入口檔案;

(4)編譯模組:從入口檔案出發,呼叫所有配置的 Loader 對模組進行翻譯,再找出該模組依賴的模組,再遞迴本步驟直到所有入口依賴的檔案都經過了本步驟的處理;

(5)完成模組編譯:在經過第4步使用 Loader 翻譯完所有模組後,得到了每個模組被翻譯後的最終內容以及它們之間的依賴關係;

(6)輸出資源:根據入口和模組之間的依賴關係,組裝成一個個包含多個模組的 Chunk,再把每個 Chunk 轉換成一個單獨的檔案加入到輸出列表,這步是可以修改輸出內容的最後機會;

(7)輸出完成:在確定好輸出內容後,根據配置確定輸出的路徑和檔名,把檔案內容寫入到檔案系統。

在以上過程中,Webpack 會在特定的時間點廣播出特定的事件,外掛在監聽到感興趣的事件後會執行特定的邏輯,並且外掛可以呼叫 Webpack 提供的 API 改變 Webpack 的執行結果。

 

7、是否寫過Loader和Plugin?描述一下編寫loader或plugin的思路?

編寫Loader時要遵循單一原則,每個Loader只做一種"轉義"工作。 每個Loader的拿到的是原始檔內容(source),可以通過返回值的方式將處理後的內容輸出,也可以呼叫this.callback()方法,將內容返回給webpack。 還可以通過 this.async()生成一個callback函式,再用這個callback將處理後的內容輸出出去。

Plugin的編寫就靈活了許多。 webpack在執行的生命週期中會廣播出許多事件,Plugin 可以監聽這些事件,在合適的時機通過 Webpack 提供的 API 改變輸出結果。

 

8、webpack的熱更新是如何做到的?說明其原理?

webpack的熱更新又稱熱替換(Hot Module Replacement),縮寫為HMR。 這個機制可以做到不用重新整理瀏覽器而將新變更的模組替換掉舊的模組

原理:

分析:

(1)第一步,在 webpack 的 watch 模式下,檔案系統中某一個檔案發生修改,webpack 監聽到檔案變化,根據配置檔案對模組重新編譯打包,並將打包後的程式碼通過簡單的 JavaScript 物件儲存在記憶體中。

(2)第二步是 webpack-dev-server 和 webpack 之間的介面互動,而在這一步,主要是 dev-server 的中介軟體 webpack-dev-middleware 和 webpack 之間的互動,webpack-dev-middleware 呼叫 webpack 暴露的 API對程式碼變化進行監控,並且告訴 webpack,將程式碼打包到記憶體中。

(3)第三步是 webpack-dev-server 對檔案變化的一個監控,這一步不同於第一步,並不是監控程式碼變化重新打包。當我們在配置檔案中配置了devServer.watchContentBase 為 true 的時候,Server 會監聽這些配置資料夾中靜態檔案的變化,變化後會通知瀏覽器端對應用進行 live reload。注意,這兒是瀏覽器重新整理,和 HMR 是兩個概念。

(4)第四步也是 webpack-dev-server 程式碼的工作,該步驟主要是通過 sockjs(webpack-dev-server 的依賴)在瀏覽器端和服務端之間建立一個 websocket 長連線,將 webpack 編譯打包的各個階段的狀態資訊告知瀏覽器端,同時也包括第三步中 Server 監聽靜態檔案變化的資訊。瀏覽器端根據這些 socket 訊息進行不同的操作。當然服務端傳遞的最主要資訊還是新模組的 hash 值,後面的步驟根據這一 hash 值來進行模組熱替換。

(5)webpack-dev-server/client 端並不能夠請求更新的程式碼,也不會執行熱更模組操作,而把這些工作又交回給了 webpack,webpack/hot/dev-server 的工作就是根據 webpack-dev-server/client 傳給它的資訊以及 dev-server 的配置決定是重新整理瀏覽器呢還是進行模組熱更新。當然如果僅僅是重新整理瀏覽器,也就沒有後面那些步驟了。

(6)HotModuleReplacement.runtime 是客戶端 HMR 的中樞,它接收到上一步傳遞給他的新模組的 hash 值,它通過 JsonpMainTemplate.runtime 向 server 端傳送 Ajax 請求,服務端返回一個 json,該 json 包含了所有要更新的模組的 hash 值,獲取到更新列表後,該模組再次通過 jsonp 請求,獲取到最新的模組程式碼。這就是上圖中 7、8、9 步驟。

(7)而第 10 步是決定 HMR 成功與否的關鍵步驟,在該步驟中,HotModulePlugin 將會對新舊模組進行對比,決定是否更新模組,在決定更新模組後,檢查模組之間的依賴關係,更新模組的同時更新模組間的依賴引用。

(8)最後一步,當 HMR 失敗後,回退到 live reload 操作,也就是進行瀏覽器重新整理來獲取最新打包程式碼。

 

9、如何利用webpack來優化前端效能?(提高效能和體驗)

用webpack優化前端效能是指優化webpack的輸出結果,讓打包的最終結果在瀏覽器執行快速高效。

(1)壓縮程式碼。刪除多餘的程式碼、註釋、簡化程式碼的寫法等等方式。可以利用webpack的UglifyJsPlugin和ParallelUglifyPlugin來壓縮JS檔案, 利用cssnano(css-loader?minimize)來壓縮css。使用webpack4,打包專案使用production模式,會自動開啟程式碼壓縮。

(2)利用CDN加速。在構建過程中,將引用的靜態資源路徑修改為CDN上對應的路徑。可以利用webpack對於output引數和各loader的publicPath引數來修改資源路徑

(3)刪除死程式碼(Tree Shaking)。將程式碼中永遠不會走到的片段刪除掉。可以通過在啟動webpack時追加引數--optimize-minimize來實現或者使用es6模組開啟刪除死程式碼

(4)優化圖片,對於小圖可以使用 base64 的方式寫入檔案中

(5)按照路由拆分程式碼,實現按需載入,提取公共程式碼。

(6)給打包出來的檔名新增雜湊,實現瀏覽器快取檔案

 

10、如何提高webpack的構建速度?

(1)多入口的情況下,使用commonsChunkPlugin來提取公共程式碼;

(2)通過externals配置來提取常用庫;

(3)使用happypack實現多執行緒加速編譯;

(4)使用webpack-uglify-parallel來提升uglifyPlugin的壓縮速度。原理上webpack-uglify-parallel採用多核並行壓縮來提升壓縮速度;

(5)使用tree-shaking和scope hoisting來剔除多餘程式碼。

 

11、怎麼配置單頁應用?怎麼配置多頁應用?

單頁應用可以理解為webpack的標準模式,直接在entry中指定單頁應用的入口即可。

多頁應用的話,可以使用webpack的 AutoWebPlugin來完成簡單自動化的構建,但是前提是專案的目錄結構必須遵守他預設的規範。

 

12、npm打包時需要注意哪些?如何利用webpack來更好的構建?

NPM模組需要注意以下問題:

(1)要支援CommonJS模組化規範,所以要求打包後的最後結果也遵守該規則

(2)Npm模組使用者的環境是不確定的,很有可能並不支援ES6,所以打包的最後結果應該是採用ES5編寫的。並且如果ES5是經過轉換的,請最好連同SourceMap一同上傳。

(3)Npm包大小應該是儘量小(有些倉庫會限制包大小)

(4)釋出的模組不能將依賴的模組也一同打包,應該讓使用者選擇性的去自行安裝。這樣可以避免模組應用者再次打包時出現底層模組被重複打包的情況。

(5)UI元件類的模組應該將依賴的其它資原始檔,例如.css檔案也需要包含在釋出的模組裡。

基於以上需要注意的問題,我們可以對於webpack配置做以下擴充套件和優化:

(1)CommonJS模組化規範的解決方案: 設定output.libraryTarget='commonjs2'使輸出的程式碼符合CommonJS2 模組化規範,以供給其它模組匯入使用;

(2)輸出ES5程式碼的解決方案:使用babel-loader把 ES6 程式碼轉換成 ES5 的程式碼。再通過開啟devtool: 'source-map'輸出SourceMap以釋出除錯。

(3)Npm包大小盡量小的解決方案:Babel 在把 ES6 程式碼轉換成 ES5 程式碼時會注入一些輔助函式,最終導致每個輸出的檔案中都包含這段輔助函式的程式碼,造成了程式碼的冗餘。解決方法是修改.babelrc檔案,為其加入transform-runtime外掛

(4)不能將依賴模組打包到NPM模組中的解決方案:使用externals配置項來告訴webpack哪些模組不需要打包

(5)對於依賴的資原始檔打包的解決方案:通過css-loader和extract-text-webpack-plugin來實現,配置如下:

 

13、如何在vue專案中實現按需載入?

經常會引入現成的UI元件庫如ElementUI、iView等,但是他們的體積和他們所提供的功能一樣,是很龐大的。

不過很多元件庫已經提供了現成的解決方案,如Element出品的babel-plugin-component和AntDesign出品的babel-plugin-import 安裝以上外掛後,在.babelrc配置中或babel-loader的引數中進行設定,即可實現元件按需載入了

單頁應用的按需載入 現在很多前端專案都是通過單頁應用的方式開發的,但是隨著業務的不斷擴充套件,會面臨一個嚴峻的問題——首次載入的程式碼量會越來越多,影響使用者的體驗。

 

七、參考

1、https://github.com/webpack/docs/wiki/how-to-write-a-plugin

2、https://webpack.js.org/api/compiler-hooks/

3、https://webpack.js.org/concepts/loaders

4、https://webpack.js.org/concepts/plugins

5、手把手教你擼一個簡易的 webpack

 

 【謝謝關注和閱讀,後續新的文章首發:sau交流學習社群:https://www.mwcxs.top/

相關文章