大神眼中的webpack構建工具:對編譯原理的分析

我雖然不是大神,但這是我自己對webpack構建工具編譯過程和編譯結果的分析的理解。

webpack的安裝和使用

webpack概念:本質上,webpack 是一個現代 JavaScript 應用程序的靜態模塊打包器。它通過一個開發時態的入口模塊爲起點,分析出所有的依賴關係,然後經過一系列的過程(壓縮、合併),最終生成運行時態的文件。

webpack官網:https://www.webpackjs.com/

webpack的安裝

webpack通過npm安裝,它提供了兩個包:

  • webpack:核心包,包含了webpack構建過程中要用到的所有api
  • webpack-cli:提供一個簡單的cli命令,它調用了webpack核心包的api,來完成構建過程

webpack也是有兩種安裝方式,全局安裝本地安裝,全局安裝可以全局使用webpack命令,但是如果你有多個項目,對應不同的webpack版本的話,全局安裝就顯得捉襟見肘。所以我們通常使用本地安裝,每個項目都是用自己的webpack版本進行構建。我們可以通過以下命令安裝webpack和webpack-cli。僅開發環境使用(-D

npm install -D webpack webpack-cli

webpack的使用

  • 打包
npx webpack

在這裏插入圖片描述

  • Hash:總chunk的哈希值
  • Version:webpack的版本號
  • Time:構建消耗時間
  • Built at:打包的時間和模塊信息
  • EntryPoint:入口文件

注意

  1. 默認生產環境下進行打包
  2. 默認情況下,webpack會以./src/index.js作爲入口文件分析依賴關係,打包到./dist/main.js文件中
  3. 因爲本地安裝,所以需要加npx 執行。
  • 開發環境下打包
npx webpack --mode=development

注意: 通過--mode選項可以控制webpack的打包結果的運行環境

  • 生產環境下打包
npx webpack --mode=production
  • 在腳本中配置 package.json
"scripts": {
    "build":"webpack --mode=production",
    "dev":"webpack --mode=development"
  }

到這裏,基本就是就是wepack所有的指令了,下面要介紹的就是webpack的各種配置和打包原理了。


在正式學習webpack之前,我認爲弄清楚以下幾個概念是非常重要的

  • 入口(entry):指示 webpack 應該使用哪個模塊,來作爲構建其內部依賴圖的開始。

  • 出口(output):告訴 webpack 在哪裏輸出它所創建的 、bundles,以及如何命名這些文件。等等

  • loader:loader本質上是一個函數,它的作用是將某個源碼字符串轉換成另一個源碼字符串返回。當然在轉換過程做了一系列的處理操作

  • 插件(plugins):loader是用來轉換代碼的,而插件用來執行範圍更廣的任務。插件的範圍包括,從打包優化和壓縮,一直到重新定義環境中的變量。

我會在下面依次具體介紹這四個重要的概念,爲了更方便的理解,我先要介紹一下sourece map源碼地圖、webpack的編譯結果編譯過程

source map 源碼地圖

在前端工程化中,我們大部分時候不回直接運行源碼,而是運行對多個源碼文件進行合併、壓縮等操作後轉換的代碼。這樣就會出現一個問題,當代碼出現錯誤時,我們不清楚具體是哪個代碼發生了錯誤。爲了方便調試錯誤代碼,就出現了source map

當然這種source map只是爲了調試方便,所以我們僅僅是在開發模式上使用。

下面是瀏覽器處理source map的原理。
在這裏插入圖片描述

在webpack中使用source map

具體配置看官方文檔:https://www.webpackjs.com/configuration/devtool/

通過devtool配置源碼地圖,在開發環境下,默認是eval。這也是下面的編譯結果中,爲什麼放在eval中執行

module.exports = {
    mode:"producetion",
    devtool:"eval"
}

webpack編譯結果

我是將index.js文件作爲入口,在index.js中導入a.js文件

/*-----------index.js----------------*/
console.log("moudle index")
const a =require("./a.js")
console.log(a)
/*--------------a.js-----------------*/
console.log("module a")
module.exports = {
    a:1
}

當我們運行npx webpack --mode=development,我們看看結果是什麼吧。以下是輸出目錄dist/main.js內容,我將其他一些特殊處理的結果,和一些註釋去掉,最後生成的代碼。

 (function(modules) { // webpackBootstrap
 	// The module cache
 	var installedModules = {};
     
 	function __webpack_require__(moduleId) {
 		if(installedModules[moduleId]) {
 			return installedModules[moduleId].exports;
 		}
 		var module = installedModules[moduleId] = {
 			i: moduleId,
 			l: false,
 			exports: {}
 		};
 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
        
 		return module.exports;
 	}
 	// Load entry module and return exports
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
 ({
  "./src/a.js":(function(module, exports) {
      eval("console.log(\"module a\")\r\nmodule.exports = {\r\n    a:1\r\n}\n\n//# sourceURL=webpack:///./src/a.js?");
  }),

  "./src/index.js":(function(module, exports, __webpack_require__) {
      eval("console.log(\"moudle index\")\r\nconst a =__webpack_require__(/*! ./a.js */ \"./src/a.js\")\r\nconsole.log(a)\n\n//# sourceURL=webpack:///./src/index.js?");
  })
 });

如果你能看的懂,在下佩服,可以直接略過。下面我來仿寫一下輸出的結果,並對其中進行解釋。先附代碼

(function(modules){
    let moudleExports = {} //用於緩存模塊的導出結果
	//require函數相當於是運行一個模塊,得到模塊導出結果
    function require(moudleId){
        if(moudleExports[moudleId]){//moduleId就是模塊的路徑
            //檢查是否有緩存,有緩存直接返回導出結果
            return moudleExports[moudleId]
        }
        let func = modules[moudleId]//得到該模塊對應的函數
        let moudule = { //創建導出對象,與commonjs模塊化require函數中的導出規範一致
            exports:{}
        }
        func(moudule,moudule.exports,require); //運行模塊
        let result = moudule.exports //得到模塊導出的結果
        moudleExports[moudleId] = result //加入緩存
        return result //返回模塊的導出結果
    }
    return require("./src/index.js") //執行入口模塊
})({
    //該對象保存了所有的模塊,以及模塊對應的代碼
    "./src/a.js":function(module,exports){
        console.log("module a")
        module.exports = {
            a:1
        }
    },
    "./src/index.js":function(module,exports,require){
        console.log("moudle index")
        const a =require("./src/a.js")
        console.log(a)
    }
})

首先我們可以清楚的看到,這是一個立即執行函數,傳入的參數是一個對象。

(function(modules){
	...
})(傳入的參數)

首先我們看一下傳入的參數,參數對象其實就是一個個模塊。對象的是導入模塊的路徑名,並且是基於該工程下的相對路徑,而對象的是一個函數,傳入了3個參數,與模塊化中的CMD幾乎相同(不多解釋了,不懂的去看模塊化知識)。函數的內容是項目應模塊中的代碼。在這裏有一點需要注意,webpack處理的函數中的代碼放在了eval中運行,就是因爲默認情況下,開發環境啓用eval模式的源碼地圖。

參數解釋完了,我們再來看立即執行函數中的內容,moudleExports這個變量是用來緩存模塊的導出結果,如果有多個模塊同時依賴一個相同的模塊時,我們不需要再去讀取內容,直接使用緩存中已經有的導出結果即可。接下來就是執行require函數了(webpack中進行了特殊處理__webpack_require__),這個函數是運行一個模塊,並且得到這個模塊的導出結果(具體的代碼解釋每一行都有註釋)。

這就是webpack打包後的文件的結果分析。

webpack編譯過程

在介紹編譯過程中可能會涉及到一些概念,在這裏先解釋一下

涉及概念

  • module:模塊,分割的代碼單元,webpack中的模塊可以是任何內容的文件,不僅限於JS

  • chunk:webpack內部構建模塊的塊,一個chunk中包含多個模塊,這些模塊是從入口模塊通過依賴分析得來的

  • bundle:chunk構建好模塊後會生成chunk的資源清單,清單中的每一項就是一個bundle,可以認爲bundle就是最終生成的文件

  • hash:最終的資源清單所有內容聯合生成的hash值

  • chunkhash:chunk生成的資源清單內容聯合生成的hash值

  • chunkname:chunk的名稱,如果沒有配置則使用main

  • id:通常指chunk的唯一編號,如果在開發環境下構建,和chunkname相同;如果是生產環境下構建,則使用一個從0開始的數字進行編號

webpack 的作用是將源代碼編譯(構建、打包)成最終代碼
在這裏插入圖片描述

整個過程大致分爲三個步驟:初始化編譯輸出
在這裏插入圖片描述

初始化

在這個階段,webpack會將CLI參數配置文件默認配置進行融合,形成一個最終的配置對象。對配置的處理過程是依託一個第三方庫yargs完成的。

編譯

編譯這一階段有四個步驟:創建chunk構建所有依賴模塊產生chunk assets合併chunk assets

  1. 創建chunk

chunk是webpack在內部構建過程中的一個概念,譯爲,它表示通過某個入口找到的所有依賴的統稱。

根據入口模塊(默認爲./src/index.js)創建一個chunk。

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

  • name:默認爲main
  • id:唯一編號,開發環境和name相同,生產環境是一個數字,從0開始
  1. 構建所有依賴模塊
    在這裏插入圖片描述

很重要的解釋:webpack在找到入口文件後,首先會查看模塊文件,檢查chunk模塊記錄中是否已經記錄過該模塊,如果記錄過則直接結束。如果未記錄過,則讀取文件的內容,對內容進行語法分析,形成抽象語法樹(AST),記錄抽象樹中的依賴(require函數),保存到dependencies中,接下來替換依賴函數,最後保存轉換後的模塊代碼,(替換依賴函數和保存轉換後的模塊化代碼就是我在編譯結果分析的步驟),記錄在chunk的模塊記錄中。再根據dependencies中保存的依賴依次遞歸加載模塊,循環這個過程,直到所有模塊都讀取完成。

  1. 產生chunk assets

在第二步完成後,webpack會根據chunk中的模塊記錄和配置生成一個資源列表,即chunk assets,列表中包含了模塊id模塊轉換後的代碼,資源列表可以理解爲是生成到最終文件的文件名和文件內容
在這裏插入圖片描述

解釋:chunk hash是根據所有chunk assets的內容生成的一個hash字符串

  1. 合併chunk assets

因爲一個項目中可能有多個文件入口,也可能有多個chunk,這個步驟是將多個chunk assets合併到一起,併產生一個總的hash
在這裏插入圖片描述

輸出

此步驟非常簡單,webpack將利用node中的fs模塊(文件處理模塊),根據編譯產生的總的assets,生成相應的文件。

總過程示意圖
在這裏插入圖片描述


webpack.config.js配置文件

默認情況下,webpack會讀取webpack.config.js文件作爲配置文件,但也可以通過CLI參數--config來指定某個配置文件。我們的入口、出口loader和plugins都是在配置文件中配置的。因爲讀取配置文件是在node中運行,所以配置文件中的代碼,必須是有效的node代碼,否則會報錯。

基本配置:

/*--------------webpack.config.js---------*/
module.exports = {
    mode:"producetion",   	 //mode:打包模式,生產環境(producetion)還是開發環境(development)
    entry:"./src/index.js",  //entry:入口文件
    output:{    			 //output:出口文件
        filename:"main.js"
    }
}   

在node中有一個path模塊,通常是用來組裝路徑,會根據不同的操作系統修改/\

const path = require("path")
const result = path.resolve(__dirname,"src")
//__dirname:所有情況下,都表示當前運行的js文件所在的目錄,他是一個絕對路徑

具體看node內置模塊path的官網: https://nodejs.org/dist/latest-v12.x/docs/api/path.html

1、入口

入口文件通過entry來配置。一個工程可能有一個入口文件,也可能有多個配置文件。

入口配置的是chunk,屬性名是chunk的名稱。可以寫相對位置

/*--------------webpack.config.js---------*/
module.exports = {
    entry:"./src/index.js",  //entry:入口文件
}

多個入口文件

/*--------------webpack.config.js---------*/
module.exports = {
    entry:{
        main: './main.js'
        app:'./app.js'
    }
}

2、出口

出口文件通過output來配置,同樣的可能有多個出口文件。出口文件的寫法比入口文件就多得多了。

  • 靜態名稱
/*--------------webpack.config.js---------*/
const path = require("path")
module.exports = {
    output:{//出口配置
        path:path.resolve(__dirname,"target"),//輸出資源放置的文件夾,默認是dist
        //直接寫靜態寫法,如main.js等等
        filename:"main.js"//配置資源的文件名,合併模塊後的的js代碼的文件的規則
    }
}  
  • 帶哈希的名稱
/*--------------webpack.config.js---------*/
const path = require("path")
module.exports = {
    entry:{
        index:"./src/index.js"
    },
   	output:{//出口配置
        path:path.resolve(__dirname,"target"),//輸出資源放置的文件夾,默認是dist
        /**
         * name:入口文件的屬性名,佔位符
         * hash:總資源的哈希值 
         * chunkhash:chunk的哈希值
         */
        filename:"[name].[hash:5].js"  // eg: index.ea656.js
    }
}

3、loader

webpack做的事情,僅僅是分析出各種模塊的依賴關係,然後形成資源列表,最終打包生成到指定的文件中。更多的功能需要藉助webpack loaderswebpack plugins完成。

loader本質上是一個函數,它的作用是將某個源碼字符串轉換成另一個源碼字符串返回。
在這裏插入圖片描述
在webpack編譯過程中,loader是在讀取文件內容之後,對文件內容進行語法樹分析之前,對文件內容進行操作的。如圖所示:
在這裏插入圖片描述

處理loaders流程:
在這裏插入圖片描述

首先會判斷當前模塊是否符合loaders規定的規則,如果符合則讀取規則中對應的loaders,並把它加入loaders數組中。如果不符合,則loaders是一個空數組。接下來代碼會依次安裝loaders數組中的規則被轉換。類似於一個棧操作,第一個匹配的規則被最後執行,最後匹配的規則被先執行。

loader配置:

module.exports = {
    module: { //針對模塊的配置,目前版本只有兩個配置,rules、noParse
        rules: [ //模塊匹配規則,可以存在多個規則
            { //每個規則是一個對象
                test: /\.js$/, //匹配的模塊正則
                use: 
                [ //匹配到了之後,使用哪些加載器
                    {  //每個加載器都是一個對象
                        loader: "模塊路徑", //loader模塊的路徑,該字符串會被放置到require中
                        options: 
                        { 
                            //向對應loader傳遞的額外參數
                            name:"lkx"
                        }
                    }
                ]
            }
        ]
    }
}
//--------是不是看起來很複雜,去掉註釋和格式化以後-------------
module.exports = {
    module: {
        rules: [{
            test: /\.js$/,
            use: [{
                loader: "模塊路徑",
                options: {...}
            }]
        }]
    }
}

簡化後的配置

module.exports = {
    module: { //針對模塊的配置,目前版本只有兩個配置,rules、noParse
        rules: [ //模塊匹配規則,可以存在多個規則
            { //每個規則是一個對象
                test: /\.js$/, //匹配的模塊正則
                use: ["模塊路徑1", "模塊路徑2"]//loader模塊的路徑,該字符串會被放置到require中
            }
        ]
    }
}

一般來說,loader是不需要自己寫的。爲了裝逼我們自己寫一個loader。

這個loader的作用就是把文件中的變量 a = 1中的變量兩個字轉換成var

//----------------webpack.config.js-------------------
module.exports = {
    mode:"development",
    module:{
        //模塊的匹配規則
        rules:[//從後往前看,先看規則2再看規則1
            //規則1
            {
                test:/index\.js$/,//匹配的正則表達式
                use:[//匹配到了之後,使用哪些加載器
                    {
                        //每個加載器都是一個對象
                        loader:"./loaders/test-loader.js",//加載器路徑
                        options:{
                            // 參數,在loader函數中上下文this中
                            //可以通過loader-utils第三方庫完成
                            changeVar:"變量"
                        }
                    }
                ]
            }
            //規則2
        ],
        // noParse:[],//是否不要解析某個模塊
    }
}
//----------------test-loader.js-------------------
const loaderUtils = require("loader-utils")//專門處理loader參數的插件庫
module.exports = function(sourceCode){
    console.log("執行了test-loader")
    const options = loaderUtils.getOptions(this)
    const reg = new RegExp(options.changeVar,"g")
    return sourceCode.replace(reg,"var")
}
//----------------index.js-------------------
變量 a = 1

我們通常用的都是第三方的loader,舉個例子:使用file-loader,當然我們需要先安裝file-loader

npm i -D file-loader

webpack.config.js中配置

module:{
    rules:[
        {
            test:/\.(png)|(jpg)$/,
            use:[{
                loader:"file-loader",
                options:{
                    name: '[name].[ext]',
                }
            }]
        }
    ]
}

4、plugins

loader的只能用來轉換代碼,一些在初始化或者輸出時需要進行特殊處理的功能就無法實現。這時候就有了plugin

在這裏插入圖片描述

plugin的本質是一個帶有apply方法的對象。

class MyPlugin{
    apply(compiler){
		...
    }
}

要將插件應用到webpack,需要把插件對象配置到webpack的plugins數組中,如下:

module.exports = {
    plugins:[
        new MyPlugin()
    ]
}

apply函數會在初始化階段,創建好Compiler對象後運行。整個webpack打包期間只有一個compiler對象,後續完成打包工作的是compiler對象內部創建的compilation

compiler對象提供了大量的鉤子函數(hooks),plugin的開發者可以註冊這些鉤子函數,參與webpack編譯和生成。

舉例說明鉤子函數的使用:

class MyPlugin{
    apply(compiler){
        compiler.hooks.事件名稱.事件類型(name, function(compilation){
            //事件處理函數
        })
    }
}
//-----------偷一個官方的例子給大家看看-----------------------
class ConsoleLogOnBuildWebpackPlugin {
    apply(compiler) {
        compiler.hooks.run.tap(pluginName, compilation => {
            console.log("webpack 構建過程開始!");
        });
    }
}

事件名稱:即要監聽的事件名,即鉤子名

詳細的鉤子看官方文檔:https://www.webpackjs.com/api/compiler-hooks

事件類型:這一部分使用的是 Tapable API,這個小型的庫是一個專門用於鉤子函數監聽的庫。

它提供了一些事件類型:

  • tap:註冊一個同步的鉤子函數,函數運行完畢則表示事件處理結束
  • tapAsync:註冊一個基於回調的異步的鉤子函數,函數通過調用一個回調錶示事件處理結束
  • tapPromise:註冊一個基於Promise的異步的鉤子函數,函數通過返回的Promise進入已決狀態表示事件處理結束

舉一個第三方的插件的例子:使用clean-webpack-plugin
安裝clean-webpack-plugin插件

npm i -D clean-webpack-plugin

webpack.config.js中配置

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
    plugins: [
        new CleanWebpackPlugin()
    ]
}

webpack的大部分知識點到這裏就結束了。下面時整理的一些零散的配置


區分環境

有些時候,我們需要針對生產環境和開發環境分別書寫webpack配置。webpack允許配置不僅可以是一個對象,還可以是一個函數。在開始構建時,webpack如果發現配置是一個函數,會調用該函數,將函數返回的對象作爲配置內容,因此,開發者可以根據不同的環境返回不同的對象。

module.exports = env => {
    if(env.prod){ //生產環境
       return {
        	//配置內容
    	}
    }else{//開發環境
        return {
        	//配置內容
    	}
    }
}

在執行webpack命令時傳入參數

npx webpack --env.prod #  env: {prod:true}
npx webpack --env.prod=true #  env: {prod:true}

resolve解析中配置

resolve的相關配置主要用於控制模塊解析過程,這裏主要說extensionsalias

extensions

當解析模塊時,遇到無具體後綴的導入語句,例如require("test"),會依次測試它的後綴名。可以簡寫後綴名

extensions: [".js", ".json"]  //默認值

alias

在配置中添加別名,比如文件路徑。在大型系統中,源碼結構往往比較深和複雜,別名配置可以讓我們更加方便的導入依賴。

alias: {
  "@": path.resolve(__dirname, 'src'),
  "_": __dirname
}
//require("@/abc.js"),webpack會將其看作是:require(src的絕對路徑+"/abc.js")

externals

防止將某些 import 的包打包到 bundle 中,而是在運行時再去從外部獲取這些擴展依賴。

這比較適用於一些第三方庫來自於外部CDN的情況,這樣一來,即可以在頁面中使用CDN,又讓bundle的體積變得更小,還不影響源碼的編寫。

舉例

externals: {
    jquery: "$",
    lodash: "_"
}

如果外部已經用cdn引入了jqueryloadsh這兩個模塊,不需要再用webpack進行打包,則通過externals配置,直接將jquery代碼改爲導出一個$,將lodash導出爲_

//-----------index.js-----------
require("jquery")
require("lodash")
//-----------bundle.js----------
(function(){
    ...
})({
    "./src/index.js": function(module, exports, __webpack_require__){
        __webpack_require__("jquery")
        __webpack_require__("lodash")
    },
    "jquery": function(module, exports){
        module.exports = $;
        //如果不用externals,這裏是大量的jquery的源碼
    },
    "lodash": function(module, exports){
        module.exports = _;
        //如果不用externals,這裏是大量的loadsh的源碼
    },
})

stats

stats控制的是構建過程中控制檯的輸出內容

具體看官方文檔:https://www.webpackjs.com/configuration/stats/

stats: {
    colors: true, //輸出控制檯顏色
    modules: false, //構建模塊信息
    hash: false, //compilation 的哈希值
    builtAt: false //添加構建日期和構建時間信息
}

備註:解釋圖片非本人制作,侵刪

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