webpack構建流程分析筆記


常用三種模塊化規范

ES6 Module

import命令用於輸入其他模塊提供的功能;export命令用於規定模塊的對外接口。

在 ES6 之前,社區制定了一些模塊加載方案,最主要的有 CommonJS 和 AMD 兩種。前者用於服務器,后者用於瀏覽器。ES6 在語言標准的層面上,實現了模塊功能,而且實現得相當簡單,完全可以取代 CommonJS 和 AMD 規范,成為瀏覽器和服務器通用的模塊解決方案。

ES6 模塊的設計思想是盡量的靜態化,使得編譯時就能確定模塊的依賴關系,以及輸入和輸出的變量。CommonJS 和 AMD 模塊,都只能在運行時確定這些東西。比如,CommonJS 模塊就是對象,輸入時必須查找對象屬性。

// CommonJS模塊 let { stat, exists, readFile } = require('fs');  // 等同於 let _fs = require('fs'); let stat = _fs.stat; let exists = _fs.exists; let readfile = _fs.readfile; 

上面代碼的實質是整體加載fs模塊(即加載fs的所有方法),生成一個對象(_fs),然后再從這個對象上面讀取 3 個方法。這種加載稱為“運行時加載”,因為只有運行時才能得到這個對象,導致完全沒辦法在編譯時做“靜態優化”。

ES6 模塊不是對象,而是通過export命令顯式指定輸出的代碼,再通過import命令輸入。

// ES6模塊 import { stat, exists, readFile } from 'fs'; 

上面代碼的實質是從fs模塊加載 3 個方法,其他方法不加載。這種加載稱為“編譯時加載”或者靜態加載,即 ES6 可以在編譯時就完成模塊加載,效率要比 CommonJS 模塊的加載方式高。當然,這也導致了沒法引用 ES6 模塊本身,因為它不是對象。

由於 ES6 模塊是編譯時加載,使得靜態分析成為可能。有了它,就能進一步拓寬 JavaScript 的語法,比如引入宏(macro)和類型檢驗(type system)這些只能靠靜態分析實現的功能。

除了靜態加載帶來的各種好處,ES6 模塊還有以下好處。

  • 不再需要UMD模塊格式了,將來服務器和瀏覽器都會支持 ES6 模塊格式。目前,通過各種工具庫,其實已經做到了這一點。
  • 將來瀏覽器的新 API 就能用模塊格式提供,不再必須做成全局變量或者navigator對象的屬性。
  • 不再需要對象作為命名空間(比如Math對象),未來這些功能可以通過模塊提供。

import 與 export

// 導出 a.js /** 寫法一 **/ var name = 'sheep' function getSheep() { name = 'hourse' } export {getSheep} // 引入 b.js import {getSheep} from './a.js' /** 寫法二 **/ var name = 'sheep' export function getSheep() { name = 'hourse' } // 引入 b.js import {getSheep} from './a.js' 

import 與 export defalut

export 可以有多個,export default 僅有一個

// 導出 a.js let obj = { name: 'hello', getName: function (){ return this.name } export default obj // 引入 b.js import obj from './a.js'

Commonjs

CommonJS是同步加載(常用於服務端 如node)。

require 在 ES6(bable將import轉化為require) 和 CommonJS 中都支持

// 導出 a.js let obj = { name: 'hello', getName: function (){ return this.name } module.exports = obj // 引入 b.js let obj = require('./a.js')
module.exports = ... : '只能輸出一個,且后面的會覆蓋上面的' ,
exports. ... : ' 可以輸出多個',

 

AMD

AMD是異步加載(常用於瀏覽器)

AMD規范采用異步方式加載模塊,模塊的加載不影響它后面語句的運行。所有依賴這個模塊的語句,都定義在一個回調函數中,等到加載完成之后,這個回調函數才會運行。這里介紹用require.js實現AMD規范的模塊化:用 require.config()指定引用路徑等,用 define()定義模塊,用 require()加載模塊。
// 定義math.js模塊
define(function () {
    var basicNum = 0;
    var add = function (x, y) {
        return x + y;
    };
    return {
        add: add,
        basicNum :basicNum
    };
});
// 定義一個依賴underscore.js的模塊
define(['underscore'],function(_){
  var classify = function(list){
    _.countBy(list,function(num){
      return num > 30 ? 'old' : 'young';
    })
  };
  return {
    classify :classify
  };
})

// 引用模塊,將模塊放在[]內
require(['jquery', 'math'],function($, math){
  var sum = math.add(10,20);
  $("#sum").html(sum);
});

 

 

 

 

ES6模塊和Commonjs差異

照抄阮大神的書

  1. CommonJS模塊輸出的是一個值的復制,ES6模塊輸出的是值的引用
  2. CommonJS模塊是運行時加載,ES6模塊是編譯時輸出接口
    第二個差異是因為CommonJS加載的是一個對象,即module.export屬性,該對象只有在腳本運行結束時才會生成。而ES6模塊不是對象,它的對外接口只是一種靜態定義,在代碼靜態解析階段就會生成。
    下面重點解釋第一個差異。
    CommonJS模塊輸出的是值的復制,一旦輸出這個值,模塊內部的變化就影響不到這個值。
//lib.js  一個commonJS模塊
var counter = 3
function incCounter() {
    counter++
}
module.exports = {
    counter : counter,
    incCounter : incCounter,
}
//main.js 在這個函數里加載這個模塊
var mod = require ('./lib')
console.log(mod.counter)
mod.incCounter()
console.log(mod.counter)
3
3

上面的代碼說明,lib.js模塊加載后,它的內部變化就影響不到輸出的mod.counter 了。
這是因為mod.counter是一個原始類型,會被緩存。除非寫成一個函數,否則得不到內部變動后的值。

//lib.js 
var counter = 3
function incCounter() {
    counter++
}
module.exports = {
    get counter(){ //輸出的counter屬性實際上是個取值器函數。
        return counter
    },
    incCounter: incCounter
}
main.js
var mod = require ('./lib')
console.log(mod.counter)
mod.incCounter()
console.log(mod.counter)//現在再執行就能正確讀取內部變量counter的變動了。
3
4

ES6模塊的運行機制與CommonJS不一樣。JS引擎對腳本靜態分析的時候,遇到模塊加載命令import就會生成一個只讀引用。等到腳本真正執行的時候,再根據這個只讀引用到被加載的模塊中取值。因此,ES6模塊是動態引用,並且不會緩存值,模塊里的變量綁定其所在的模塊。

// lib.js
export let counter = 3
export function incCounter() {
    counter++
}

//main.js
import { counter, incCounter } from './lib'
console.log(counter)
incCounter()
console.log(counter)

3
4

上面的代碼說明,ES6模塊輸入的變量counter是活的,完全反應其所在模塊lib.js內部的變化。
再如:

//m1.js
export var foo = 'bar'
setTimeout(()=>foo='baz',500)
//m2.js
import {foo} from './m1.js'
console.log(foo)
setTimeout(()=>console.log(foo),500)

bar
baz

上面的代碼表明,ES6模塊不會緩存運行結果,而是動態地去被加載的模塊取值,並且變量總是綁定其所在的模塊。
由於ES6輸入的模塊變量只是一個“符號連接”,所以這個變量是只讀的,對它重新賦值會報錯。

//lib.js
export let obj = {}

//main.js
import {obj} from './lib'
obj.prop=123 //OK
obj = {} //TypeError

main.js 從 lib.js 輸入變量obj,可以對obj添加屬性,但是重新賦值就會報錯。因為變量obj指向的地址是只讀的,不能重新賦值,這就好比main.js創造了一個名為obj的const變量。

//mod.js
function C(){
   this.sum = 0
   this.add = function(){
        this.sum += 1
  }
  this.show = function(){
       console.log(this.sum)
  }
}
export let c = new C()
//x.js
import {c} from './mod'
c.add()
//y.js
import {c} from './mod'
c.show()
//main.js
import './x'
import './y'

1

這就證明了x.js和y.js加載都是C的同一實例

摘抄自:
阮一峰-ES6標准入門-第六章-Module的加載實現

 

 

 

一、模塊化的理解

1.什么是模塊?

  • 將一個復雜的程序依據一定的規則(規范)封裝成幾個塊(文件), 並進行組合在一起
  • 塊的內部數據與實現是私有的, 只是向外部暴露一些接口(方法)與外部其它模塊通信

  特定功能作用的集合,內部數據和方法私有,外部可通過接口訪問

2.模塊化的進化過程

  • 1.全局function模式 : 將不同的功能封裝成不同的全局函數
    • 編碼: 將不同的功能封裝成不同的全局函數
    • 問題: 污染全局命名空間, 容易引起命名沖突或數據不安全,而且模塊成員之間看不出直接關系
function m1(){
  //...
}
function m2(){
  //...
}
  • 2.namespace模式 : 簡單對象封裝
    • 作用: 減少了全局變量,解決命名沖突
    • 問題: 數據不安全(外部可以直接修改模塊內部的數據)
let myModule = {
  data: 'www.baidu.com',
  foo() {
    console.log(`foo() ${this.data}`)
  },
  bar() {
    console.log(`bar() ${this.data}`)
  }
}
myModule.data = 'other data' //能直接修改模塊內部的數據
myModule.foo() // foo() other data

這樣的寫法會暴露所有模塊成員,內部狀態可以被外部改寫。

  • 3.IIFE模式:匿名函數自調用(閉包)
    • 作用: 數據是私有的, 外部只能通過暴露的方法操作
    • 編碼: 將數據和行為封裝到一個函數內部, 通過給window添加屬性來向外暴露接口
    • 問題: 如果當前這個模塊依賴另一個模塊怎么辦?
// index.html文件
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
    myModule.foo()
    myModule.bar()
    console.log(myModule.data) //undefined 不能訪問模塊內部數據
    myModule.data = 'xxxx' //不是修改的模塊內部的data
    myModule.foo() //沒有改變
</script>
// module.js文件
(function(window) {
  let data = 'www.baidu.com'
  //操作數據的函數
  function foo() {
    //用於暴露有函數
    console.log(`foo() ${data}`)
  }
  function bar() {
    //用於暴露有函數
    console.log(`bar() ${data}`)
    otherFun() //內部調用
  }
  function otherFun() {
    //內部私有的函數
    console.log('otherFun()')
  }
  //暴露行為
  window.myModule = { foo, bar } //ES6寫法
//完整寫法 window,myModule={foo:foo,bar:bar} })(window)

最后得到的結果:

  • IIFE模式增強 : 引入依賴

這就是現代模塊實現的基石

// module.js文件
(function(window, $) {
  let data = 'www.baidu.com'
  //操作數據的函數
  function foo() {
    //用於暴露有函數
    console.log(`foo() ${data}`)
    $('body').css('background', 'red')
  }
  function bar() {
    //用於暴露有函數
    console.log(`bar() ${data}`)
    otherFun() //內部調用
  }
  function otherFun() {
    //內部私有的函數
    console.log('otherFun()')
  }
  //暴露行為
  window.myModule = { foo, bar }
})(window, jQuery)
 // index.html文件
  <!-- 引入的js必須有一定順序 -->
  <script type="text/javascript" src="jquery-1.10.1.js"></script>
  <script type="text/javascript" src="module.js"></script>
  <script type="text/javascript">
    myModule.foo()
  </script>

上例子通過jquery方法將頁面的背景顏色改成紅色,所以必須先引入jQuery庫,就把這個庫當作參數傳入。這樣做除了保證模塊的獨立性,還使得模塊之間的依賴關系變得明顯。

3. 模塊化的好處

  • 避免命名沖突(減少命名空間污染)
  • 更好的分離, 按需加載
  • 更高復用性
  • 高可維護性

4. 引入多個<script>后出現出現問題

  • 請求過多

首先我們要依賴多個模塊,那樣就會發送多個請求,導致請求過多

  • 依賴模糊

我們不知道他們的具體依賴關系是什么,也就是說很容易因為不了解他們之間的依賴關系導致加載先后順序出錯。

  • 難以維護

以上兩種原因就導致了很難維護,很可能出現牽一發而動全身的情況導致項目出現嚴重的問題。
模塊化固然有多個好處,然而一個頁面需要引入多個js文件,就會出現以上這些問題。而這些問題可以通過模塊化規范來解決,下面介紹開發中最流行的commonjs, AMD, ES6, CMD規范。

二、模塊化規范

1.CommonJS

目前node使用的就是CommonJs 規范

(1)概述

Node 應用由模塊組成,采用 CommonJS 模塊規范。每個文件就是一個模塊,有自己的作用域。在一個文件里面定義的變量、函數、類,都是私有的,對其他文件不可見。在服務器端,模塊的加載是運行時同步加載的;在瀏覽器端,模塊需要提前編譯打包處理。

(2)特點

  • 所有代碼都運行在模塊作用域,不會污染全局作用域。
  • 模塊可以多次加載,但是只會在第一次加載時運行一次,然后運行結果就被緩存了,以后再加載,就直接讀取緩存結果。要想讓模塊再次運行,必須清除緩存。
  • 模塊加載的順序,按照其在代碼中出現的順序。

(3)基本語法

  • 暴露模塊:module.exports = valueexports.xxx = value
  • 引入模塊:require(xxx),如果是第三方模塊,xxx為模塊名;如果是自定義模塊,xxx為模塊文件路徑

此處我們有個疑問:CommonJS暴露的模塊到底是什么? CommonJS規范規定,每個模塊內部,module變量代表當前模塊。這個變量是一個對象,它的exports屬性(即module.exports)是對外的接口。加載某個模塊,其實是加載該模塊的module.exports屬性。

// example.js
var x = 5;
var addX = function (value) {
  return value + x;
};
module.exports.x = x;
module.exports.addX = addX;

上面代碼通過module.exports輸出變量x和函數addX。

var example = require('./example.js');//如果參數字符串以“./”開頭,則表示加載的是一個位於相對路徑
console.log(example.x); // 5
console.log(example.addX(1)); // 6

require命令用於加載模塊文件。require命令的基本功能是,讀入並執行一個JavaScript文件,然后返回該模塊的exports對象。如果沒有發現指定模塊,會報錯。

(4)模塊的加載機制

注意:CommonJS模塊的加載機制是,輸入的是被輸出的值的拷貝。也就是說,一旦輸出一個值,模塊內部的變化就影響不到這個值。這點與ES6模塊化有重大差異(下文會介紹),請看下面這個例子:

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代碼輸出內部變量counter和改寫這個變量的內部方法incCounter。

// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;

console.log(counter);  // 3
incCounter();
console.log(counter); // 3

上面代碼說明,counter輸出以后,lib.js模塊內部的變化就影響不到counter了。這是因為counter是一個原始類型的值,會被緩存。除非寫成一個函數,才能得到內部變動后的值。

 

(5)服務器端實現

①下載安裝node.js

②創建項目結構

注意:用npm init 自動生成package.json時,package name(包名)不能有中文和大寫

|-modules
  |-module1.js
  |-module2.js
  |-module3.js
|-app.js
|-package.json
  {
    "name": "commonJS-node",
    "version": "1.0.0"
  }

③下載第三方模塊

npm install uniq --save // 用於數組去重

④定義模塊代碼

//module1.js
module.exports = {
  msg: 'module1',
  foo() {
    console.log(this.msg)
  }
}
//module2.js
module.exports = function() {
  console.log('module2')
}
//module3.js
exports.foo = function() {
  console.log('foo() module3')
}
exports.arr = [1, 2, 3, 3, 2]
// app.js文件
// 引入第三方庫,應該放置在最前面
let uniq = require('uniq')
let module1 = require('./modules/module1')
let module2 = require('./modules/module2')
let module3 = require('./modules/module3')

module1.foo() //module1
module2() //module2
module3.foo() //foo() module3
console.log(uniq(module3.arr)) //[ 1, 2, 3 ]

⑤通過node運行app.js

命令行輸入node app.js,運行JS文件

 

(6)瀏覽器端實現(借助Browserify)

Browserify是一個供瀏覽器環境使用的模塊打包工具,像在node環境一樣,也是通過require(‘modules‘)來組織模塊之間的引用和依賴,既可以引用npm中的模塊,也可以引用自己寫的模塊,然后打包成js文件,再在頁面中通過<script>標簽加載。

當然對於很多NodeJS模塊,比如涉及到io操作的模塊,就算通過browserify打包后肯定也無法運行在瀏覽器環境中,這種情況下就會用到為它們重寫的支持瀏覽器端的分支模塊,可以在browserify search搜索到這些模塊。

 

①創建項目結構

|-js
  |-dist //打包生成文件的目錄
  |-src //源碼所在的目錄
    |-module1.js
    |-module2.js
    |-module3.js
    |-app.js //應用主源文件
|-index.html //運行於瀏覽器上
|-package.json
  {
    "name": "browserify-test",
    "version": "1.0.0"
  }

②下載browserify

  • 全局: npm install browserify -g
  • 局部: npm install browserify --save-dev

③定義模塊代碼(同服務器端)

注意:index.html文件要運行在瀏覽器上,需要借助browserify將app.js文件打包編譯,如果直接在index.html引入app.js就會報錯!

④打包處理js

根目錄下運行browserify js/src/app.js -o js/dist/bundle.js

或者browserify js/src/app.js  >  js/dist/bundle.js

⑤頁面使用引入

在index.html文件中引入<script type="text/javascript" src="js/dist/bundle.js"></script>

 

 

 

 

2.AMD

CommonJS規范加載模塊是同步的,也就是說,只有加載完成,才能執行后面的操作。AMD規范則是非同步加載模塊,允許指定回調函數。由於Node.js主要用於服務器編程,模塊文件一般都已經存在於本地硬盤,所以加載起來比較快,不用考慮非同步加載的方式,所以CommonJS規范比較適用。但是,如果是瀏覽器環境,要從服務器端加載模塊,這時就必須采用非同步模式,因此瀏覽器端一般采用AMD規范。此外AMD規范比CommonJS規范在瀏覽器端實現要來着早。

(1)AMD規范基本語法

定義暴露模塊:

//定義沒有依賴的模塊
define(function(){
   return 模塊
})
//定義有依賴的模塊
define(['module1', 'module2'], function(m1, m2){
   return 模塊
})

引入使用模塊:

require(['module1', 'module2'], function(m1, m2){
   使用m1/m2
})

(2)未使用AMD規范與使用require.js

通過比較兩者的實現方法,來說明使用AMD規范的好處。

  • 未使用AMD規范
// dataService.js文件
(function (window) {
  let msg = 'www.baidu.com'
  function getMsg() {
    return msg.toUpperCase()
  }
  window.dataService = {getMsg}
})(window)
// alerter.js文件
(function (window, dataService) {
  let name = 'Tom'
  function showMsg() {
    alert(dataService.getMsg() + ', ' + name)
  }
  window.alerter = {showMsg}
})(window, dataService)
// main.js文件
(function (alerter) {
  alerter.showMsg()
})(alerter)
// index.html文件
<div><h1>Modular Demo 1: 未使用AMD(require.js)</h1></div>
<script type="text/javascript" src="js/modules/dataService.js"></script>
<script type="text/javascript" src="js/modules/alerter.js"></script>
<script type="text/javascript" src="js/main.js"></script>

最后得到如下結果:

這種方式缺點很明顯:首先會發送多個請求,其次引入的js文件順序不能搞錯,否則會報錯!

  • 使用require.js

RequireJS是一個工具庫,主要用於客戶端的模塊管理。它的模塊管理遵守AMD規范,RequireJS的基本思想是,通過define方法,將代碼定義為模塊;通過require方法,實現代碼的模塊加載。
接下來介紹AMD規范在瀏覽器實現的步驟:

①下載require.js, 並引入

  • 官網: http://www.requirejs.cn/
  • github : https://github.com/requirejs/requirejs

然后將require.js導入項目: js/libs/require.js

②創建項目結構

|-js
  |-libs
    |-require.js
  |-modules
    |-alerter.js
    |-dataService.js
  |-main.js
|-index.html

③定義require.js的模塊代碼

// dataService.js文件
// 定義沒有依賴的模塊
define(function() {
  let msg = 'www.baidu.com'
  function getMsg() {
    return msg.toUpperCase()
  }
  return { getMsg } // 暴露模塊
})
//alerter.js文件
// 定義有依賴的模塊
define(['dataService'], function(dataService) {
  let name = 'Tom'
  function showMsg() {
    alert(dataService.getMsg() + ', ' + name)
  }
  // 暴露模塊
  return { showMsg }
})
// main.js文件
(function() {
  require.config({
    baseUrl: 'js/', //基本路徑 出發點在根目錄下
    paths: {
      //映射: 模塊標識名: 路徑
      alerter: './modules/alerter', //此處不能寫成alerter.js,會報錯
      dataService: './modules/dataService'
    }
  })
  require(['alerter'], function(alerter) {
    alerter.showMsg()
  })
})()
// index.html文件
<!DOCTYPE html>
<html>
  <head>
    <title>Modular Demo</title>
  </head>
  <body>
    <!-- 引入require.js並指定js主文件的入口 -->
    <script data-main="js/main" src="js/libs/require.js"></script>
  </body>
</html>

④頁面引入require.js模塊:

在index.html引入 <script data-main="js/main" src="js/libs/require.js"></script>

**此外在項目中如何引入第三方庫?**只需在上面代碼的基礎稍作修改:

// alerter.js文件
define(['dataService', 'jquery'], function(dataService, $) {
  let name = 'Tom'
  function showMsg() {
    alert(dataService.getMsg() + ', ' + name)
  }
  $('body').css('background', 'green')
  // 暴露模塊
  return { showMsg }
})
// main.js文件
(function() {
  require.config({
    baseUrl: 'js/', //基本路徑 出發點在根目錄下
    paths: {
      //自定義模塊
      alerter: './modules/alerter', //此處不能寫成alerter.js,會報錯
      dataService: './modules/dataService',
      // 第三方庫模塊
      jquery: './libs/jquery-1.10.1' //注意:寫成jQuery會報錯
    }
  })
  require(['alerter'], function(alerter) {
    alerter.showMsg()
  })
})()

上例是在alerter.js文件中引入jQuery第三方庫,main.js文件也要有相應的路徑配置。
小結:通過兩者的比較,可以得出AMD模塊定義的方法非常清晰,不會污染全局環境,能夠清楚地顯示依賴關系。AMD模式可以用於瀏覽器環境,並且允許非同步加載模塊,也可以根據需要動態加載模塊。

 

 

3.CMD

CMD規范專門用於瀏覽器端,模塊的加載是異步的,模塊使用時才會加載執行。CMD規范整合了CommonJS和AMD規范的特點。在 Sea.js 中,所有 JavaScript 模塊都遵循 CMD模塊定義規范。

(1)CMD規范基本語法

定義暴露模塊:

//定義沒有依賴的模塊
define(function(require, exports, module){
  exports.xxx = value
  module.exports = value
})
//定義有依賴的模塊
define(function(require, exports, module){
  //引入依賴模塊(同步)
  var module2 = require('./module2')
  //引入依賴模塊(異步)
    require.async('./module3', function (m3) {
    })
  //暴露模塊
  exports.xxx = value
})

引入使用模塊:

define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.show()
  m4.show()
})

(2)sea.js簡單使用教程

①下載sea.js, 並引入

然后將sea.js導入項目: js/libs/sea.js

②創建項目結構

|-js
  |-libs
    |-sea.js
  |-modules
    |-module1.js
    |-module2.js
    |-module3.js
    |-module4.js
    |-main.js
|-index.html

③定義sea.js的模塊代碼

// module1.js文件
define(function (require, exports, module) {
  //內部變量數據
  var data = 'atguigu.com'
  //內部函數
  function show() {
    console.log('module1 show() ' + data)
  }
  //向外暴露
  exports.show = show
})
// module2.js文件
define(function (require, exports, module) {
  module.exports = {
    msg: 'I Will Back'
  }
})
// module3.js文件
define(function(require, exports, module) {
  const API_KEY = 'abc123'
  exports.API_KEY = API_KEY
})
// module4.js文件
define(function (require, exports, module) {
  //引入依賴模塊(同步)
  var module2 = require('./module2')
  function show() {
    console.log('module4 show() ' + module2.msg)
  }
  exports.show = show
  //引入依賴模塊(異步)
  require.async('./module3', function (m3) {
    console.log('異步引入依賴模塊3  ' + m3.API_KEY)
  })
})
// main.js文件
define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.show()
  m4.show()
})

④在index.html中引入

<script type="text/javascript" src="js/libs/sea.js"></script>
<script type="text/javascript">
  seajs.use('./js/modules/main')
</script>

最后得到結果如下:

 

4.ES6模塊化

ES6 模塊的設計思想是盡量的靜態化,使得編譯時就能確定模塊的依賴關系,以及輸入和輸出的變量。CommonJS 和 AMD 模塊,都只能在運行時確定這些東西。比如,CommonJS 模塊就是對象,輸入時必須查找對象屬性。

(1)ES6模塊化語法

export命令用於規定模塊的對外接口,import命令用於輸入其他模塊提供的功能。

/** 定義模塊 math.js **/
var basicNum = 0;
var add = function (a, b) {
    return a + b;
};
export { basicNum, add };
/** 引用模塊 **/
import { basicNum, add } from './math';
function test(ele) {
    ele.textContent = add(99 + basicNum);
}

如上例所示,使用import命令的時候,用戶需要知道所要加載的變量名或函數名,否則無法加載。為了給用戶提供方便,讓他們不用閱讀文檔就能加載模塊,就要用到export default命令,為模塊指定默認輸出。

// export-default.js
export default function () {
  console.log('foo');
}
// import-default.js
import customName from './export-default';
customName(); // 'foo'

模塊默認輸出, 其他模塊加載該模塊時,import命令可以為該匿名函數指定任意名字。

(2)ES6 模塊與 CommonJS 模塊的差異

它們有兩個重大差異:

① CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。

② CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口。

第二個差異是因為 CommonJS 加載的是一個對象(即module.exports屬性),該對象只有在腳本運行完才會生成。而 ES6 模塊不是對象,它的對外接口只是一種靜態定義,在代碼靜態解析階段就會生成。

下面重點解釋第一個差異,我們還是舉上面那個CommonJS模塊的加載機制例子:

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

ES6 模塊的運行機制與 CommonJS 不一樣。ES6 模塊是動態引用,並且不會緩存值,模塊里面的變量綁定其所在的模塊。

(3) ES6-Babel-Browserify使用教程

簡單來說就一句話:使用Babel將ES6編譯為ES5代碼,使用Browserify編譯打包js。

①定義package.json文件

 {
   "name" : "es6-babel-browserify",
   "version" : "1.0.0"
 }

②安裝babel-cli, babel-preset-es2015和browserify

  • npm install babel-cli browserify -g
  • npm install babel-preset-es2015 --save-dev
  • preset 預設(將es6轉換成es5的所有插件打包)

③定義.babelrc文件

  {
    "presets": ["es2015"]
  }

④定義模塊代碼

//module1.js文件
// 分別暴露
export function foo() {
  console.log('foo() module1')
}
export function bar() {
  console.log('bar() module1')
}
//module2.js文件
// 統一暴露
function fun1() {
  console.log('fun1() module2')
}
function fun2() {
  console.log('fun2() module2')
}
export { fun1, fun2 }
//module3.js文件
// 默認暴露 可以暴露任意數據類項,暴露什么數據,接收到就是什么數據
export default () => {
  console.log('默認暴露')
}
// app.js文件
import { foo, bar } from './module1'
import { fun1, fun2 } from './module2'
import module3 from './module3'
foo()
bar()
fun1()
fun2()
module3()

⑤ 編譯並在index.html中引入

  • 使用Babel將ES6編譯為ES5代碼(但包含CommonJS語法) : babel js/src -d js/lib
  • 使用Browserify編譯js : browserify js/lib/app.js -o js/lib/bundle.js

然后在index.html文件中引入

 <script type="text/javascript" src="js/lib/bundle.js"></script>

最后得到如下結果:

此外第三方庫(以jQuery為例)如何引入呢?
首先安裝依賴npm install jquery@1
然后在app.js文件中引入

//app.js文件
import { foo, bar } from './module1'
import { fun1, fun2 } from './module2'
import module3 from './module3'
import $ from 'jquery'

foo()
bar()
fun1()
fun2()
module3()
$('body').css('background', 'green')

三、總結

  • CommonJS規范主要用於服務端編程,加載模塊是同步的,這並不適合在瀏覽器環境,因為同步意味着阻塞加載,瀏覽器資源是異步加載的,因此有了AMD CMD解決方案。
  • AMD規范在瀏覽器環境中異步加載模塊,而且可以並行加載多個模塊。不過,AMD規范開發成本高,代碼的閱讀和書寫比較困難,模塊定義方式的語義不順暢。
  • CMD規范與AMD規范很相似,都用於瀏覽器編程,依賴就近,延遲執行,可以很容易在Node.js中運行。不過,依賴SPM 打包,模塊的加載邏輯偏重
  • ES6 在語言標准的層面上,實現了模塊功能,而且實現得相當簡單,完全可以取代 CommonJS 和 AMD 規范,成為瀏覽器和服務器通用的模塊解決方案。
 

 

 

 

 

 

 

 

工作原理概括

基本概念

在了解 Webpack 原理前,需要掌握以下幾個核心概念,以方便后面的理解:

  • Entry:入口,Webpack 執行構建的第一步將從 Entry 開始,可抽象成輸入。
  • Module:模塊,在 Webpack 里一切皆模塊,一個模塊對應着一個文件。Webpack 會從配置的 Entry 開始遞歸找出所有依賴的模塊。
  • Chunk:代碼塊,一個 Chunk 由多個模塊組合而成,用於代碼合並與分割。
  • Loader:模塊轉換器,用於把模塊原內容按照需求轉換成新內容。
  • Plugin:擴展插件,在 Webpack 構建流程中的特定時機會廣播出對應的事件,插件可以監聽這些事件的發生,在特定時機做對應的事情。

流程概括

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

  1. 初始化參數:從配置文件和 Shell 語句中讀取與合並參數,得出最終的參數;
  2. 開始編譯:用上一步得到的參數初始化 Compiler 對象,加載所有配置的插件,執行對象的 run 方法開始執行編譯;
  3. 確定入口:根據配置中的 entry 找出所有的入口文件;
  4. 編譯模塊:從入口文件出發,調用所有配置的 Loader 對模塊進行翻譯,再找出該模塊依賴的模塊,再遞歸本步驟直到所有入口依賴的文件都經過了本步驟的處理;
  5. 完成模塊編譯:在經過第4步使用 Loader 翻譯完所有模塊后,得到了每個模塊被翻譯后的最終內容以及它們之間的依賴關系;
  6. 輸出資源:根據入口和模塊之間的依賴關系,組裝成一個個包含多個模塊的 Chunk,再把每個 Chunk 轉換成一個單獨的文件加入到輸出列表,這步是可以修改輸出內容的最后機會;
  7. 輸出完成:在確定好輸出內容后,根據配置確定輸出的路徑和文件名,把文件內容寫入到文件系統。

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

流程細節

Webpack 的構建流程可以分為以下三大階段:

  1. 初始化:啟動構建,讀取與合並配置參數,加載 Plugin,實例化 Compiler。
  2. 編譯:從 Entry 發出,針對每個 Module 串行調用對應的 Loader 去翻譯文件內容,再找到該 Module 依賴的 Module,遞歸地進行編譯處理。
  3. 輸出:對編譯后的 Module 組合成 Chunk,把 Chunk 轉換成文件,輸出到文件系統。

如果只執行一次構建,以上階段將會按照順序各執行一次。但在開啟監聽模式下,流程將變為如下:

在每個大階段中又會發生很多事件,Webpack 會把這些事件廣播出來供給 Plugin 使用,下面來一一介紹。

初始化階段

事件名 解釋
初始化參數 從配置文件和 Shell 語句中讀取與合並參數,得出最終的參數。 這個過程中還會執行配置文件中的插件實例化語句 new Plugin()
實例化 Compiler 用上一步得到的參數初始化 Compiler 實例,Compiler 負責文件監聽和啟動編譯。Compiler 實例中包含了完整的 Webpack 配置,全局只有一個 Compiler 實例。
加載插件 依次調用插件的 apply 方法,讓插件可以監聽后續的所有事件節點。同時給插件傳入 compiler 實例的引用,以方便插件通過 compiler 調用 Webpack 提供的 API。
environment 開始應用 Node.js 風格的文件系統到 compiler 對象,以方便后續的文件尋找和讀取。
entry-option 讀取配置的 Entrys,為每個 Entry 實例化一個對應的 EntryPlugin,為后面該 Entry 的遞歸解析工作做准備。
after-plugins 調用完所有內置的和配置的插件的 apply 方法。
after-resolvers 根據配置初始化完 resolverresolver 負責在文件系統中尋找指定路徑的文件。
空格 空格
空格 空格
空格 空格

編譯階段

事件名 解釋
run 啟動一次新的編譯。
watch-run 和 run 類似,區別在於它是在監聽模式下啟動的編譯,在這個事件中可以獲取到是哪些文件發生了變化導致重新啟動一次新的編譯。
compile 該事件是為了告訴插件一次新的編譯將要啟動,同時會給插件帶上 compiler 對象。
compilation 當 Webpack 以開發模式運行時,每當檢測到文件變化,一次新的 Compilation 將被創建。一個 Compilation 對象包含了當前的模塊資源、編譯生成資源、變化的文件等。Compilation 對象也提供了很多事件回調供插件做擴展。
make 一個新的 Compilation 創建完畢,即將從 Entry 開始讀取文件,根據文件類型和配置的 Loader 對文件進行編譯,編譯完后再找出該文件依賴的文件,遞歸的編譯和解析。
after-compile 一次 Compilation 執行完成。
invalid 當遇到文件不存在、文件編譯錯誤等異常時會觸發該事件,該事件不會導致 Webpack 退出。
空格 空格
空格 空格

在編譯階段中,最重要的要數 compilation 事件了,因為在 compilation 階段調用了 Loader 完成了每個模塊的轉換操作,在 compilation 階段又包括很多小的事件,它們分別是:

事件名 解釋
build-module 使用對應的 Loader 去轉換一個模塊。
normal-module-loader 在用 Loader 對一個模塊轉換完后,使用 acorn 解析轉換后的內容,輸出對應的抽象語法樹(AST),以方便 Webpack 后面對代碼的分析。
program 從配置的入口模塊開始,分析其 AST,當遇到 require 等導入其它模塊語句時,便將其加入到依賴的模塊列表,同時對新找出的依賴模塊遞歸分析,最終搞清所有模塊的依賴關系。
seal 所有模塊及其依賴的模塊都通過 Loader 轉換完成后,根據依賴關系開始生成 Chunk。

輸出階段

事件名 解釋
should-emit 所有需要輸出的文件已經生成好,詢問插件哪些文件需要輸出,哪些不需要。
emit 確定好要輸出哪些文件后,執行文件輸出,可以在這里獲取和修改輸出內容。
after-emit 文件輸出完畢。
done 成功完成一次完成的編譯和輸出流程。
failed 如果在編譯和輸出流程中遇到異常導致 Webpack 退出時,就會直接跳轉到本步驟,插件可以在本事件中獲取到具體的錯誤原因。

在輸出階段已經得到了各個模塊經過轉換后的結果和其依賴關系,並且把相關模塊組合在一起形成一個個 Chunk。 在輸出階段會根據 Chunk 的類型,使用對應的模版生成最終要要輸出的文件內容。

輸出文件分析

雖然在前面的章節中你學會了如何使用 Webpack ,也大致知道其工作原理,可是你想過 Webpack 輸出的 bundle.js 是什么樣子的嗎? 為什么原來一個個的模塊文件被合並成了一個單獨的文件?為什么 bundle.js 能直接運行在瀏覽器中? 本節將解釋清楚以上問題。

先來看看由 安裝與使用 中最簡單的項目構建出的 bundle.js 文件內容,代碼如下:

<p data-height="565" data-theme-id="0" data-slug-hash="NMQzxz" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async src="https://static.codepen.io/ass...;></script>

以上看上去復雜的代碼其實是一個立即執行函數,可以簡寫為如下:

(function(modules) { // 模擬 require 語句 function __webpack_require__() { } // 執行存放所有模塊數組中的第0個模塊 __webpack_require__(0); })([/*存放所有模塊的數組*/]) 

webpack打包后生成bundle.js 能直接運行在瀏覽器中的原因在於輸出的文件中通過 __webpack_require__ 函數定義了一個可以在瀏覽器中執行的加載函數來模擬 Node.js 中的 require 語句。

(因為require只在node的commonjs規范才能執行,webpack打包后是在瀏覽器環境下,所有需要自定義__webpack_require__方法來實現require功能)

原來一個個獨立的模塊文件被合並到了一個單獨的 bundle.js 的原因在於瀏覽器不能像 Node.js 那樣快速地去本地加載一個個模塊文件,而必須通過網絡請求去加載還未得到的文件。 如果模塊數量很多,加載時間會很長,因此把所有模塊都存放在了數組中,執行一次網絡加載。

如果仔細分析 __webpack_require__ 函數的實現,你還有發現 Webpack 做了緩存優化: 執行加載過的模塊不會再執行第二次,執行結果會緩存在內存中,當某個模塊第二次被訪問時會直接去內存中讀取被緩存的返回值。

分割代碼時的輸出

例如把源碼中的 main.js 修改為如下:

把require改為使用import異步加載

// 異步加載 show.js import('./show').then((show) => { // 執行 show 函數 show('Webpack'); }); 

重新構建后會輸出兩個文件,分別是執行入口文件 bundle.js 和 異步加載文件 0.bundle.js

其中 0.bundle.js 內容如下:

// 加載在本文件(0.bundle.js)中包含的模塊 webpackJsonp( // 在其它文件中存放着的模塊的 ID [0], // 本文件所包含的模塊 [ // show.js 所對應的模塊 (function (module, exports) { function show(content) { window.document.getElementById('app').innerText = 'Hello,' + content; } module.exports = show; }) ] ); 

bundle.js 內容如下:

<p data-height="565" data-theme-id="0" data-slug-hash="yjmRyG" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async src="https://static.codepen.io/ass...;></script>

這里的 bundle.js 和上面所講的 bundle.js 非常相似,區別在於:

  • 多了一個 __webpack_require__.e 用於加載被分割出去的,需要異步加載的 Chunk 對應的文件;
  • 多了一個 webpackJsonp 函數用於從異步加載的文件中安裝模塊。(使用Jsonp異步加載js)

在使用了 CommonsChunkPlugin 去提取公共代碼時輸出的文件和使用了異步加載時輸出的文件是一樣的,都會有 __webpack_require__.e 和 webpackJsonp。 原因在於提取公共代碼和異步加載本質上都是代碼分割。

編寫 Loader

Loader 就像是一個翻譯員,能把源文件經過轉化后輸出新的結果,並且一個文件還可以鏈式的經過多個翻譯員翻譯。

以處理 SCSS 文件為例:

  • SCSS 源代碼會先交給 sass-loader 把 SCSS 轉換成 CSS;
  • 把 sass-loader 輸出的 CSS 交給 css-loader 處理,找出 CSS 中依賴的資源、壓縮 CSS 等;
  • 把 css-loader 輸出的 CSS 交給 style-loader 處理,轉換成通過腳本加載的 JavaScript 代碼;

可以看出以上的處理過程需要有順序的鏈式執行,先 sass-loader 再 css-loader 再 style-loader。 以上處理的 Webpack 相關配置如下:

<p data-height="365" data-theme-id="0" data-slug-hash="YLmbeQ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="編寫 Loader" class="codepen">See the Pen 編寫 Loader by whjin (@whjin) on CodePen.</p>
<script async src="https://static.codepen.io/ass...;></script>

Loader 的職責

由上面的例子可以看出:一個 Loader 的職責是單一的,只需要完成一種轉換。 如果一個源文件需要經歷多步轉換才能正常使用,就通過多個 Loader 去轉換。 在調用多個 Loader 去轉換一個文件時,每個 Loader 會鏈式的順序執行, 第一個 Loader 將會拿到需處理的原內容,上一個 Loader 處理后的結果會傳給下一個接着處理,最后的 Loader 將處理后的最終結果返回給 Webpack。

所以,在你開發一個 Loader 時,請保持其職責的單一性,你只需關心輸入和輸出。

Loader 基礎

由於 Webpack 是運行在 Node.js 之上的,一個 Loader 其實就是一個 Node.js 模塊,這個模塊需要導出一個函數。 這個導出的函數的工作就是獲得處理前的原內容,對原內容執行處理后,返回處理后的內容。

一個最簡單的 Loader 的源碼如下:

module.exports = function(source) { // source 為 compiler 傳遞給 Loader 的一個文件的原內容 // 該函數需要返回處理后的內容,這里簡單起見,直接把原內容返回了,相當於該 Loader 沒有做任何轉換 return source; }; 

由於 Loader 運行在 Node.js 中,你可以調用任何 Node.js 自帶的 API,或者安裝第三方模塊進行調用:

const sass = require('node-sass'); module.exports = function(source) { return sass(source); }; 

Loader 進階

以上只是個最簡單的 Loader,Webpack 還提供一些 API 供 Loader 調用,下面來一一介紹。

獲得 Loader 的 options

在最上面處理 SCSS 文件的 Webpack 配置中,給 css-loader 傳了 options 參數,以控制 css-loader。 如何在自己編寫的 Loader 中獲取到用戶傳入的 options 呢?需要這樣做:

const loaderUtils = require('loader-utils'); module.exports = function(source) { // 獲取到用戶給當前 Loader 傳入的 options const options = loaderUtils.getOptions(this); return source; }; 

返回其它結果

上面的 Loader 都只是返回了原內容轉換后的內容,但有些場景下還需要返回除了內容之外的東西。

例如以用 babel-loader 轉換 ES6 代碼為例,它還需要輸出轉換后的 ES5 代碼對應的 Source Map,以方便調試源碼。 為了把 Source Map 也一起隨着 ES5 代碼返回給 Webpack,可以這樣寫:

module.exports = function(source) { // 通過 this.callback 告訴 Webpack 返回的結果 this.callback(null, source, sourceMaps); // 當你使用 this.callback 返回內容時,該 Loader 必須返回 undefined, // 以讓 Webpack 知道該 Loader 返回的結果在 this.callback 中,而不是 return 中 return; }; 

其中的 this.callback 是 Webpack 給 Loader 注入的 API,以方便 Loader 和 Webpack 之間通信。 this.callback 的詳細使用方法如下:

this.callback( // 當無法轉換原內容時,給 Webpack 返回一個 Error err: Error | null, // 原內容轉換后的內容 content: string | Buffer, // 用於把轉換后的內容得出原內容的 Source Map,方便調試 sourceMap?: SourceMap, // 如果本次轉換為原內容生成了 AST 語法樹,可以把這個 AST 返回, // 以方便之后需要 AST 的 Loader 復用該 AST,以避免重復生成 AST,提升性能 abstractSyntaxTree?: AST ); 
Source Map 的生成很耗時,通常在開發環境下才會生成 Source Map,其它環境下不用生成,以加速構建。 為此 Webpack 為 Loader 提供了  this.sourceMap API 去告訴 Loader 當前構建環境下用戶是否需要 Source Map。 如果你編寫的 Loader 會生成 Source Map,請考慮到這點。

同步與異步

Loader 有同步和異步之分,上面介紹的 Loader 都是同步的 Loader,因為它們的轉換流程都是同步的,轉換完成后再返回結果。 但在有些場景下轉換的步驟只能是異步完成的,例如你需要通過網絡請求才能得出結果,如果采用同步的方式網絡請求就會阻塞整個構建,導致構建非常緩慢。

在轉換步驟是異步時,你可以這樣:

module.exports = function(source) { // 告訴 Webpack 本次轉換是異步的,Loader 會在 callback 中回調結果 var callback = this.async(); someAsyncOperation(source, function(err, result, sourceMaps, ast) { // 通過 callback 返回異步執行后的結果 callback(err, result, sourceMaps, ast); }); }; 

處理二進制數據

在默認的情況下,Webpack 傳給 Loader 的原內容都是 UTF-8 格式編碼的字符串。 但有些場景下 Loader 不是處理文本文件,而是處理二進制文件,例如 file-loader,就需要 Webpack 給 Loader 傳入二進制格式的數據。 為此,你需要這樣編寫 Loader:

module.exports = function(source) { // 在 exports.raw === true 時,Webpack 傳給 Loader 的 source 是 Buffer 類型的 source instanceof Buffer === true; // Loader 返回的類型也可以是 Buffer 類型的 // 在 exports.raw !== true 時,Loader 也可以返回 Buffer 類型的結果 return source; }; // 通過 exports.raw 屬性告訴 Webpack 該 Loader 是否需要二進制數據 module.exports.raw = true; 

以上代碼中最關鍵的代碼是最后一行 module.exports.raw = true;,沒有該行 Loader 只能拿到字符串。

緩存加速

在有些情況下,有些轉換操作需要大量計算非常耗時,如果每次構建都重新執行重復的轉換操作,構建將會變得非常緩慢。 為此,Webpack 會默認緩存所有 Loader 的處理結果,也就是說在需要被處理的文件或者其依賴的文件沒有發生變化時, 是不會重新調用對應的 Loader 去執行轉換操作的。

如果你想讓 Webpack 不緩存該 Loader 的處理結果,可以這樣:

module.exports = function(source) { // 關閉該 Loader 的緩存功能 this.cacheable(false); return source; }; 

其它 Loader API

除了以上提到的在 Loader 中能調用的 Webpack API 外,還存在以下常用 API:

  • this.context:當前處理文件的所在目錄,假如當前 Loader 處理的文件是 /src/main.js,則 this.context 就等於 /src
  • this.resource:當前處理文件的完整請求路徑,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:當前處理文件的路徑,例如 /src/main.js
  • this.resourceQuery:當前處理文件的 querystring
  • this.target:等於 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在處理一個文件時,如果依賴其它文件的處理結果才能得出當前文件的結果時, 就可以通過 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去獲得 request 對應文件的處理結果。
  • this.resolve:像 require 語句一樣獲得指定文件的完整路徑,使用方法為 resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:給當前處理文件添加其依賴的文件,以便再其依賴的文件發生變化時,會重新調用 Loader 處理該文件。使用方法為 addDependency(file: string)
  • this.addContextDependency:和 addDependency 類似,但 addContextDependency 是把整個目錄加入到當前正在處理文件的依賴中。使用方法為 addContextDependency(directory: string)
  • this.clearDependencies:清除當前正在處理文件的所有依賴,使用方法為 clearDependencies()
  • this.emitFile:輸出一個文件,使用方法為 emitFile(name: string, content: Buffer|string, sourceMap: {...})

加載本地 Loader

在開發 Loader 的過程中,為了測試編寫的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能會調用該 Loader。 在前面的章節中,使用的 Loader 都是通過 Npm 安裝的,要使用 Loader 時會直接使用 Loader 的名稱,代碼如下:

module.exports = { module: { rules: [ { test: /\.css/, use: ['style-loader'], }, ] }, }; 

如果還采取以上的方法去使用本地開發的 Loader 將會很麻煩,因為你需要確保編寫的 Loader 的源碼是在 node_modules目錄下。 為此你需要先把編寫的 Loader 發布到 Npm 倉庫后再安裝到本地項目使用。

解決以上問題的便捷方法有兩種,分別如下:

Npm link

Npm link 專門用於開發和調試本地 Npm 模塊,能做到在不發布模塊的情況下,把本地的一個正在開發的模塊的源碼鏈接到項目的 node_modules 目錄下,讓項目可以直接使用本地的 Npm 模塊。 由於是通過軟鏈接的方式實現的,編輯了本地的 Npm 模塊代碼,在項目中也能使用到編輯后的代碼。

完成 Npm link 的步驟如下:

  • 確保正在開發的本地 Npm 模塊(也就是正在開發的 Loader)的 package.json 已經正確配置好;
  • 在本地 Npm 模塊根目錄下執行 npm link,把本地模塊注冊到全局;
  • 在項目根目錄下執行 npm link loader-name,把第2步注冊到全局的本地 Npm 模塊鏈接到項目的 node_moduels下,其中的 loader-name 是指在第1步中的 package.json 文件中配置的模塊名稱。

鏈接好 Loader 到項目后你就可以像使用一個真正的 Npm 模塊一樣使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用於配置 Webpack 如何尋找 Loader。 默認情況下只會去 node_modules 目錄下尋找,為了讓 Webpack 加載放在本地項目中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在項目目錄中的 ./loaders/loader-name 中,則需要如下配置:


module.exports = { resolveLoader:{ // 去哪些目錄下尋找 Loader,有先后順序之分 modules: ['node_modules','./loaders/'], } } 

加上以上配置后, Webpack 會先去 node_modules 項目下尋找 Loader,如果找不到,會再去 ./loaders/ 目錄下尋找。

實戰

上面講了許多理論,接下來從實際出發,來編寫一個解決實際問題的 Loader。

該 Loader 名叫 comment-require-loader,作用是把 JavaScript 代碼中的注釋語法:

// @require '../style/index.css' 

轉換成:

require('../style/index.css'); 

該 Loader 的使用場景是去正確加載針對 Fis3 編寫的 JavaScript,這些 JavaScript 中存在通過注釋的方式加載依賴的 CSS 文件。

該 Loader 的使用方法如下:

module.exports = { module: { rules: [ { test: /\.js$/, use: ['comment-require-loader'], // 針對采用了 fis3 CSS 導入語法的 JavaScript 文件通過 comment-require-loader 去轉換 include: [path.resolve(__dirname, 'node_modules/imui')] } ] } }; 

該 Loader 的實現非常簡單,完整代碼如下:

function replace(source) { // 使用正則把 // @require '../style/index.css' 轉換成 require('../style/index.css'); return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);'); } module.exports = function (content) { return replace(content); }; 

編寫 Plugin

Webpack 通過 Plugin 機制讓其更加靈活,以適應各種應用場景。 在 Webpack 運行的生命周期中會廣播出許多事件,Plugin 可以監聽這些事件,在合適的時機通過 Webpack 提供的 API 改變輸出結果。

一個最基礎的 Plugin 的代碼是這樣的:

class BasicPlugin{ // 在構造函數中獲取用戶給該插件傳入的配置 constructor(options){ } // Webpack 會調用 BasicPlugin 實例的 apply 方法給插件實例傳入 compiler 對象 apply(compiler){ compiler.plugin('compilation',function(compilation) { }) } } // 導出 Plugin module.exports = BasicPlugin; 

在使用這個 Plugin 時,相關配置代碼如下:

const BasicPlugin = require('./BasicPlugin.js'); module.export = { plugins:[ new BasicPlugin(options), ] } 

Webpack 啟動后,在讀取配置的過程中會先執行 new BasicPlugin(options) 初始化一個 BasicPlugin 獲得其實例。 在初始化 compiler 對象后,再調用 basicPlugin.apply(compiler) 給插件實例傳入 compiler 對象。 插件實例在獲取到 compiler 對象后,就可以通過 compiler.plugin(事件名稱, 回調函數) 監聽到 Webpack 廣播出來的事件。 並且可以通過 compiler 對象去操作 Webpack。

通過以上最簡單的 Plugin 相信你大概明白了 Plugin 的工作原理,但實際開發中還有很多細節需要注意,下面來詳細介紹。

Compiler 和 Compilation

在開發 Plugin 時最常用的兩個對象就是 Compiler 和 Compilation,它們是 Plugin 和 Webpack 之間的橋梁。 Compiler 和 Compilation 的含義如下:

  • Compiler 對象包含了 Webpack 環境所有的的配置信息,包含 optionsloadersplugins 這些信息,這個對象在 Webpack 啟動時候被實例化,它是全局唯一的,可以簡單地把它理解為 Webpack 實例;
  • Compilation 對象包含了當前的模塊資源、編譯生成資源、變化的文件等。當 Webpack 以開發模式運行時,每當檢測到一個文件變化,一次新的 Compilation 將被創建。Compilation 對象也提供了很多事件回調供插件做擴展。通過 Compilation 也能讀取到 Compiler 對象。

Compiler 和 Compilation 的區別在於:Compiler 代表了整個 Webpack 從啟動到關閉的生命周期,而 Compilation 只是代表了一次新的編譯。

事件流

Webpack 就像一條生產線,要經過一系列處理流程后才能將源文件轉換成輸出結果。 這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關系,只有完成當前處理后才能交給下一個流程去處理。 插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理。

Webpack 通過 Tapable 來組織這條復雜的生產線。 Webpack 在運行過程中會廣播事件,插件只需要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運作。 Webpack 的事件流機制保證了插件的有序性,使得整個系統擴展性很好。

Webpack 的事件流機制應用了觀察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都繼承自 Tapable,可以直接在 Compiler 和 Compilation 對象上廣播和監聽事件,方法如下:

/** * 廣播出事件 * event-name 為事件名稱,注意不要和現有的事件重名 * params 為附帶的參數 */ compiler.apply('event-name',params); /** * 監聽名稱為 event-name 的事件,當 event-name 事件發生時,函數就會被執行。 * 同時函數中的 params 參數為廣播事件時附帶的參數。 */ compiler.plugin('event-name',function(params) { }); 

同理,compilation.apply 和 compilation.plugin 使用方法和上面一致。

在開發插件時,你可能會不知道該如何下手,因為你不知道該監聽哪個事件才能完成任務。

在開發插件時,還需要注意以下兩點:

  • 只要能拿到 Compiler 或 Compilation 對象,就能廣播出新的事件,所以在新開發的插件中也能廣播出事件,給其它插件監聽使用。
  • 傳給每個插件的 Compiler 和 Compilation 對象都是同一個引用。也就是說在一個插件中修改了 Compiler 或 Compilation 對象上的屬性,會影響到后面的插件。
  • 有些事件是異步的,這些異步的事件會附帶兩個參數,第二個參數為回調函數,在插件處理完任務時需要調用回調函數通知 Webpack,才會進入下一處理流程。例如:
 compiler.plugin('emit',function(compilation, callback) { // 支持處理邏輯 // 處理完畢后執行 callback 以通知 Webpack // 如果不執行 callback,運行流程將會一直卡在這不往下執行 callback(); });

常用 API

插件可以用來修改輸出文件、增加輸出文件、甚至可以提升 Webpack 性能、等等,總之插件通過調用 Webpack 提供的 API 能完成很多事情。 由於 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面來介紹一些常用的 API。

讀取輸出資源、代碼塊、模塊及其依賴

有些插件可能需要讀取 Webpack 的處理結果,例如輸出資源、代碼塊、模塊及其依賴,以便做下一步處理。

在 emit 事件發生時,代表源文件的轉換和組裝已經完成,在這里可以讀取到最終將輸出的資源、代碼塊、模塊及其依賴,並且可以修改輸出資源的內容。 插件代碼如下:

<p data-height="585" data-theme-id="0" data-slug-hash="RJwjPj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="emit" class="codepen">See the Pen emit by whjin (@whjin) on CodePen.</p>
<script async src="https://static.codepen.io/ass...;></script>

監聽文件變化

Webpack 會從配置的入口模塊出發,依次找出所有的依賴模塊,當入口模塊或者其依賴的模塊發生變化時, 就會觸發一次新的 Compilation。

在開發插件時經常需要知道是哪個文件發生變化導致了新的 Compilation,為此可以使用如下代碼:

<p data-height="255" data-theme-id="0" data-slug-hash="jKOabJ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Compilation" class="codepen">See the Pen Compilation by whjin (@whjin) on CodePen.</p>
<script async src="https://static.codepen.io/ass...;></script>

默認情況下 Webpack 只會監視入口和其依賴的模塊是否發生變化,在有些情況下項目可能需要引入新的文件,例如引入一個 HTML 文件。 由於 JavaScript 文件不會去導入 HTML 文件,Webpack 就不會監聽 HTML 文件的變化,編輯 HTML 文件時就不會重新觸發新的 Compilation。 為了監聽 HTML 文件的變化,我們需要把 HTML 文件加入到依賴列表中,為此可以使用如下代碼:

compiler.plugin('after-compile', (compilation, callback) => { // 把 HTML 文件添加到文件依賴列表,好讓 Webpack 去監聽 HTML 模塊文件,在 HTML 模版文件發生變化時重新啟動一次編譯 compilation.fileDependencies.push(filePath); callback(); }); 

修改輸出資源

有些場景下插件需要修改、增加、刪除輸出的資源,要做到這點需要監聽 emit 事件,因為發生 emit 事件時所有模塊的轉換和代碼塊對應的文件已經生成好, 需要輸出的資源即將輸出,因此 emit 事件是修改 Webpack 輸出資源的最后時機。

所有需要輸出的資源會存放在 compilation.assets 中,compilation.assets 是一個鍵值對,鍵為需要輸出的文件名稱,值為文件對應的內容。

設置 compilation.assets 的代碼如下:

compiler.plugin('emit', (compilation, callback) => { // 設置名稱為 fileName 的輸出資源 compilation.assets[fileName] = { // 返回文件內容 source: () => { // fileContent 既可以是代表文本文件的字符串,也可以是代表二進制文件的 Buffer return fileContent; }, // 返回文件大小 size: () => { return Buffer.byteLength(fileContent, 'utf8'); } }; callback(); }); 

讀取 compilation.assets 的代碼如下:


compiler.plugin('emit', (compilation, callback) => { // 讀取名稱為 fileName 的輸出資源 const asset = compilation.assets[fileName]; // 獲取輸出資源的內容 asset.source(); // 獲取輸出資源的文件大小 asset.size(); callback(); }); 

判斷 Webpack 使用了哪些插件

在開發一個插件時可能需要根據當前配置是否使用了其它某個插件而做下一步決定,因此需要讀取 Webpack 當前的插件配置情況。 以判斷當前是否使用了 ExtractTextPlugin 為例,可以使用如下代碼:

// 判斷當前配置使用使用了 ExtractTextPlugin, // compiler 參數即為 Webpack 在 apply(compiler) 中傳入的參數 function hasExtractTextPlugin(compiler) { // 當前配置所有使用的插件列表 const plugins = compiler.options.plugins; // 去 plugins 中尋找有沒有 ExtractTextPlugin 的實例 return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null; } 

實戰

下面我們舉一個實際的例子,帶你一步步去實現一個插件。

該插件的名稱取名叫 EndWebpackPlugin,作用是在 Webpack 即將退出時再附加一些額外的操作,例如在 Webpack 成功編譯和輸出了文件后執行發布操作把輸出的文件上傳到服務器。 同時該插件還能區分 Webpack 構建是否執行成功。使用該插件時方法如下:

module.exports = { plugins:[ // 在初始化 EndWebpackPlugin 時傳入了兩個參數,分別是在成功時的回調函數和失敗時的回調函數; new EndWebpackPlugin(() => { // Webpack 構建成功,並且文件輸出了后會執行到這里,在這里可以做發布文件操作 }, (err) => { // Webpack 構建失敗,err 是導致錯誤的原因 console.error(err); }) ] } 

要實現該插件,需要借助兩個事件:

  • done:在成功構建並且輸出了文件后,Webpack 即將退出時發生;
  • failed:在構建出現異常導致構建失敗,Webpack 即將退出時發生;

實現該插件非常簡單,完整代碼如下:

class EndWebpackPlugin { constructor(doneCallback, failCallback) { // 存下在構造函數中傳入的回調函數 this.doneCallback = doneCallback; this.failCallback = failCallback; } apply(compiler) { compiler.plugin('done', (stats) => { // 在 done 事件中回調 doneCallback this.doneCallback(stats); }); compiler.plugin('failed', (err) => { // 在 failed 事件中回調 failCallback this.failCallback(err); }); } } // 導出插件 module.exports = EndWebpackPlugin; 

從開發這個插件可以看出,找到合適的事件點去完成功能在開發插件時顯得尤為重要。 在 工作原理概括 中詳細介紹過 Webpack 在運行過程中廣播出常用事件,你可以從中找到你需要的事件。

調試 Webpack

在編寫 Webpack 的 Plugin 和 Loader 時,可能執行結果會和你預期的不一樣,就和你平時寫代碼遇到了奇怪的 Bug 一樣。 對於無法一眼看出問題的 Bug,通常需要調試程序源碼才能找出問題所在。

雖然可以通過 console.log 的方式完成調試,但這種方法非常不方便也不優雅,本節將教你如何斷點調試 工作原理概括 中的插件代碼。 由於 Webpack 運行在 Node.js 之上,調試 Webpack 就相對於調試 Node.js 程序。

在 Webstorm 中調試

Webstorm 集成了 Node.js 的調試工具,因此使用 Webstorm 調試 Webpack 非常簡單。

1. 設置斷點

在你認為可能出現問題的地方設下斷點,點擊編輯區代碼左側出現紅點表示設置了斷點。

2. 配置執行入口

告訴 Webstorm 如何啟動 Webpack,由於 Webpack 實際上就是一個 Node.js 應用,因此需要新建一個 Node.js 類型的執行入口。

以上配置中有三點需要注意:

  • Name 設置成了 debug webpack,就像設置了一個別名,方便記憶和區分;
  • Working directory 設置為需要調試的插件所在的項目的根目錄;
  • JavaScript file 即 Node.js 的執行入口文件,設置為 Webpack 的執行入口文件 node_modules/webpack/bin/webpack.js

3. 啟動調試

經過以上兩步,准備工作已經完成,下面啟動調試,啟動時選中前面設置的 debug webpack

4. 執行到斷點

啟動后程序就會停在斷點所在的位置,在這里你可以方便的查看變量當前的狀態,找出問題。

原理總結

Webpack 是一個龐大的 Node.js 應用,如果你閱讀過它的源碼,你會發現實現一個完整的 Webpack 需要編寫非常多的代碼。 但你無需了解所有的細節,只需了解其整體架構和部分細節即可。

對 Webpack 的使用者來說,它是一個簡單強大的工具; 對 Webpack 的開發者來說,它是一個擴展性的高系統。

Webpack 之所以能成功,在於它把復雜的實現隱藏了起來,給用戶暴露出的只是一個簡單的工具,讓用戶能快速達成目的。 同時整體架構設計合理,擴展性高,開發擴展難度不高,通過社區補足了大量缺失的功能,讓 Webpack 幾乎能勝任任何場景。

通過本章的學習,希望你不僅能學會如何編寫 Webpack 擴展,也能從中領悟到如何設計好的系統架構。

 

補充:更加具體的初始化——》編譯——》輸出階段

已知,Webpack 源碼是一個插件的架構,很多功能都是通過諸多的內置插件實現的。Webpack為此專門自己寫一個插件系統,叫 Tapable 主要提供了注冊和調用插件的功能。 一起研究之前,希望你對 tapable 有所了解~

基本架構

先通過一張大圖整體梳理一下webpack的主體流程,再細節一點的稍后再介紹

流程圖中展示了些核心任務點,簡要說明下這些任務點做了事兒:

 

  • 解析 config 與 shell 中的配置項
  • webpack 初始化過程,首先會根據第一步的 options 生成 compiler 對象,然后初始化 webpack 的內置插件及 options 配置
  • run 代表編譯的開始,會構建 compilation 對象,用於存儲這一次編譯過程的所有數據
  • make 執行真正的編譯構建過程,從入口文件開始,構建模塊,直到所有模塊創建結束
  • seal 生成 chunks,對 chunks 進行一系列的優化操作,並生成要輸出的代碼
  • seal 結束后,Compilation 實例的所有工作到此也全部結束,意味着一次構建過程已經結束
  • emit 被觸發之后,webpack 會遍歷 compilation.assets, 生成所有文件,然后觸發任務點 done,結束構建流程

構建流程

webpack准備階段

webpack啟動入口,webpack-cli/bin/cli.js

const webpack = require("webpack"); // 使用yargs來解析命令行參數並合並配置文件中的參數(options), // 然后調用lib/webpack.js實例化compile 並返回 let compiler; try { compiler = webpack(options); } catch (err) {} 復制代碼
// lib/webpack.js const webpack = (options, callback) => { // 首先會檢查配置參數是否合法 // 創建Compiler let compiler; compiler = new Compiler(options.context); compiler.options = new WebpackOptionsApply().process(options, compiler); ... if (options.watch === true || ..) { ... return compiler.watch(watchOptions, callback); } compiler.run(callback); } 復制代碼

創建Compiler

創建了 compiler 對象,compiler 可以理解為 webpack 編譯的調度中心,是一個編譯器實例,在 compiler 對象記錄了完整的 webpack 環境信息,在 webpack 的每個進程中,compiler 只會生成一次。

class Compiler extends Tapable { constructor(context) { super(); this.hooks = { beforeCompile: new AsyncSeriesHook(["params"]), compile: new SyncHook(["params"]), afterCompile: new AsyncSeriesHook(["compilation"]), make: new AsyncParallelHook(["compilation"]), entryOption: new SyncBailHook(["context", "entry"]) // 定義了很多不同類型的鈎子 }; // ... } } 復制代碼

可以看到 Compiler 對象繼承自 Tapable,初始化時定義了很多鈎子。

初始化默認插件和Options配置

WebpackOptionsApply 類中會根據配置注冊對應的插件,其中有個比較重要的插件

new EntryOptionPlugin().apply(compiler); compiler.hooks.entryOption.call(options.context, options.entry); 復制代碼

EntryOptionPlugin插件中訂閱了compiler的entryOption鈎子,並依賴SingleEntryPlugin插件

module.exports = class EntryOptionPlugin { apply(compiler) { compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => { return new SingleEntryPlugin(context, item, name); }); } }; 復制代碼

SingleEntryPlugin 插件中訂閱了 compiler 的 make 鈎子,並在回調中等待執行 addEntry,但此時 make 鈎子還並沒有被觸發哦

apply(compiler) {
    compiler.plugin("compilation", (compilation, params) => { const normalModuleFactory = params.normalModuleFactory; // 這里記錄了 SingleEntryDependency 對應的工廠對象是 NormalModuleFactory compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory); }); compiler.hooks.make.tapAsync( "SingleEntryPlugin", (compilation, callback) => { const { entry, name, context } = this; // 創建單入口依賴 const dep = SingleEntryPlugin.createDependency(entry, name); // 正式進入構建階段 compilation.addEntry(context, dep, name, callback); } ); } 復制代碼

run

初始化 compiler 后,根據 options 的 watch 判斷是否啟動了 watch,如果啟動 watch 了就調用 compiler.watch 來監控構建文件,否則啟動 compiler.run 來構建文件,compiler.run 就是我們此次編譯的入口方法,代表着要開始編譯了。

構建編譯階段

調用 compiler.run 方法來啟動構建

run(callback) {
    const onCompiled = (err, compilation) => { this.hooks.done.callAsync(stats, err => { return finalCallback(null, stats); }); }; // 執行訂閱了compiler.beforeRun鈎子插件的回調 this.hooks.beforeRun.callAsync(this, err => { // 執行訂閱了compiler.run鈎子插件的回調 this.hooks.run.callAsync(this, err => { this.compile(onCompiled); }); }); } 復制代碼

compiler.compile 開始真正執行我們的構建流程,核心代碼如下

compile(callback) {
    // 實例化核心工廠對象 const params = this.newCompilationParams(); // 執行訂閱了compiler.beforeCompile鈎子插件的回調 this.hooks.beforeCompile.callAsync(params, err => { // 執行訂閱了compiler.compile鈎子插件的回調 this.hooks.compile.call(params); // 創建此次編譯的Compilation對象 const compilation = this.newCompilation(params); // 執行訂閱了compiler.make鈎子插件的回調 this.hooks.make.callAsync(compilation, err => { compilation.finish(err => { compilation.seal(err => { this.hooks.afterCompile.callAsync(compilation, err => { return callback(null, compilation); }); }) }) }) }) } 復制代碼

compile階段,Compiler 對象會開始實例化兩個核心的工廠對象,分別是 NormalModuleFactory 和 ContextModuleFactory。工廠對象顧名思義就是用來創建實例的,它們后續用來創建 module 實例的,包括 NormalModule 以及 ContextModule 實例。

Compilation

創建此次編譯的 Compilation 對象,核心代碼如下:

newCompilation(params) {
    // 實例化Compilation對象 const compilation = new Compilation(this); this.hooks.thisCompilation.call(compilation, params); // 調用this.hooks.compilation通知感興趣的插件 this.hooks.compilation.call(compilation, params); return compilation; } 復制代碼

Compilation 對象是后續構建流程中最核心最重要的對象,它包含了一次構建過程中所有的數據。也就是說一次構建過程對應一個 Compilation 實例。在創建 Compilation 實例時會觸發鈎子 compilaiion 和 thisCompilation

在Compilation對象中:

  • modules 記錄了所有解析后的模塊
  • chunks 記錄了所有chunk
  • assets記錄了所有要生成的文件

上面這三個屬性已經包含了 Compilation 對象中大部分的信息,但目前也只是有個大致的概念,特別是 modules 中每個模塊實例到底是什么東西,並不太清楚。先不糾結,畢竟此時 Compilation 對象剛剛生成。

make

當 Compilation 實例創建完成之后,webpack 的准備階段已經完成,下一步將開始 modules 的生成階段。

this.hooks.make.callAsync() 執行訂閱了 make 鈎子的插件的回調函數。回到上文,在初始化默認插件過程中(WebpackOptionsApply類),SingleEntryPlugin 插件中訂閱了 compiler 的 make 鈎子,並在回調中等待執行 compilation.addEntry 方法。

生成modules

compilation.addEntry 方法會觸發第一批 module 的解析,即我們在 entry 中配置的入口文件 index.js。在深入 modules 的構建流程之前,我們先對模塊實例 module 的概念有個了解。

modules

 

一個依賴對象(Dependency)經過對應的工廠對象(Factory)創建之后,就能夠生成對應的模塊實例(Module)。

 

Dependency,可以理解為還未被解析成模塊實例的依賴對象。比如配置中的入口模塊,或者一個模塊依賴的其他模塊,都會先生成一個 Dependency 對象。每個 Dependency 都會有對應的工廠對象,比如我們這次debuger的代碼,入口文件 index.js 首先生成 SingleEntryDependency, 對應的工廠對象是 NormalModuleFactory。(前文說到SingleEntryPlugin插件時有放代碼,有疑惑的同學可以往前翻翻看)

// 創建單入口依賴 const dep = SingleEntryPlugin.createDependency(entry, name); // 正式進入構建階段 compilation.addEntry(context, dep, name, callback); 復制代碼

SingleEntryPlugin插件訂閱的make事件,將創建的單入口依賴傳入compilation.addEntry方法,addEntry主要執行_addModuleChain()

_addModuleChain

_addModuleChain(context, dependency, onModule, callback) {
   ...
   
   // 根據依賴查找對應的工廠函數 const Dep = /** @type {DepConstructor} */ (dependency.constructor); const moduleFactory = this.dependencyFactories.get(Dep); // 調用工廠函數NormalModuleFactory的create來生成一個空的NormalModule對象 moduleFactory.create({ dependencies: [dependency] ... }, (err, module) => { ... const afterBuild = () => { this.processModuleDependencies(module, err => { if (err) return callback(err); callback(null, module); }); }; this.buildModule(module, false, null, null, err => { ... afterBuild(); }) }) } 復制代碼

_addModuleChain中接收參數dependency傳入的入口依賴,使用對應的工廠函數NormalModuleFactory.create方法生成一個空的module對象,回調中會把此module存入compilation.modules對象和dependencies.module對象中,由於是入口文件,也會存入compilation.entries中。隨后執行buildModule進入真正的構建module內容的過程。

buildModule

buildModule方法主要執行module.build(),對應的是NormalModule.build()

// NormalModule.js build(options, compilation, resolver, fs, callback) { return this.doBuild(options, compilation, resolver, fs, err => { ... // 一會兒講 } } 復制代碼

先來看看doBuild中做了什么

doBuild(options, compilation, resolver, fs, callback) {
    ...
    runLoaders(
    	{
            resource: this.resource, // /src/index.js loaders: this.loaders, // `babel-loader` context: loaderContext, readResource: fs.readFile.bind(fs) }, (err, result) => { ... const source = result.result[0]; this._source = this.createSource( this.binary ? asBuffer(source) : asString(source), resourceBuffer, sourceMap ); } ) } 復制代碼

一句話說,doBuild 調用了相應的 loaders ,把我們的模塊轉成標准的JS模塊。這里,使用babel-loader 來編譯 index.js ,source就是 babel-loader 編譯后的代碼。

// source
"debugger; import { helloWorld } from './helloworld.js';document.write(helloWorld());”
復制代碼

同時,還會生成this._source對象,有namevalue兩個字段,name就是我們的文件路徑,value就是編譯后的JS代碼。模塊源碼最終是保存在 _source 屬性中,可以通過 _source.source() 來得到。回到剛剛的NormalModule中的build方法

build(options, compilation, resolver, fs, callback) {
    ...
    return this.doBuild(options, compilation, resolver, fs, err => { const result = this.parser.parse( this._source.source(), { current: this, module: this, compilation: compilation, options: options }, (err, result) => { } ); } } 復制代碼

經過 doBuild 之后,我們的任何模塊都被轉成了標准的JS模塊。接下來就是調用Parser.parse方法,將JS解析為AST。

// Parser.js const acorn = require("acorn"); const acornParser = acorn.Parser; static parse(code, options) { ... let ast = acornParser.parse(code, parserOptions); return ast; } 復制代碼

生成的AST結果如下:

解析成AST最大作用就是收集模塊依賴關系,webpack會遍歷AST對象,遇到不同類型的節點執行對應的函數。比如調試代碼中出現的 import { helloWorld } from './helloworld.js' 或 const xxx = require('XXX')的模塊引入語句,webpack會記錄下這些依賴項,並記錄在module.dependencies數組中。到這里,入口module的解析過程就完成了,解析后的module大家有興趣可以打印出來看下,這里我只截圖了module.dependencies數組。 每個 module 解析完成之后,都會觸發  Compilation例對象的succeedModule鈎子,訂閱這個鈎子獲取到剛解析完的 module 對象。 隨后,webpack會遍歷module.dependencies數組,遞歸解析它的依賴模塊生成module,最終我們會得到項目所依賴的所有 modules。遍歷的邏輯在 afterBuild() ->  processModuleDependencies() ->  addModuleDependencies() ->  factory.create() make階段到此結束,接下去會觸發 compilation.seal方法,進入下一個階段。

 

生成chunks

compilation.seal 方法主要生成chunks,對chunks進行一系列的優化操作,並生成要輸出的代碼。webpack 中的 chunk ,可以理解為配置在 entry 中的模塊,或者是動態引入的模塊。

chunk內部的主要屬性是_modules,用來記錄包含的所有模塊對象。所以要生成一個chunk,就先要找到它包含的所有modules。下面簡述一下chunk的生成過程:

  • 先把 entry 中對應的每個 module 都生成一個新的 chunk
  • 遍歷module.dependencies,將其依賴的模塊也加入到上一步生成的chunk中
  • 若某個module是動態引入的,為其創建一個新的chunk,接着遍歷依賴

下圖是我們此次demo生成的this.chunks,_modules中有兩個模塊,分別是入口index模塊,與其依賴helloworld模塊。

在生成chunk的過程中與過程后,webpack會對chunk和module進行一系列的優化操作,優化操作大都是由不同的插件去完成。可見 compilation.seal 方法中,有大量的鈎子執行的代碼。

 

this.hooks.optimizeModulesBasic.call(this.modules); this.hooks.optimizeModules.call(this.modules); this.hooks.optimizeModulesAdvanced.call(this.modules); this.hooks.optimizeChunksBasic.call(this.chunks, this.chunkGroups); this.hooks.optimizeChunks.call(this.chunks, this.chunkGroups); this.hooks.optimizeChunksAdvanced.call(this.chunks, this.chunkGroups); ... 復制代碼

例如,插件SplitChunksPlugin訂閱了compilation的optimizeChunksAdvanced鈎子。至此,我們的modules和chunks都生成了,該去生成文件了。

生成文件

首先需要生成最終的代碼,主要在compilation.seal 中調用了 compilation.createChunkAssets方法。

for (let i = 0; i < this.chunks.length; i++) { const chunk = this.chunks[i]; const template = chunk.hasRuntime() ? this.mainTemplate : this.chunkTemplate; const manifest = template.getRenderManifest({ ... }) ... for (const fileManifest of manifest) { source = fileManifest.render(); } ... this.emitAsset(file, source, assetInfo); } 復制代碼

createChunkAssets方法會遍歷chunks,來渲染每一個chunk生成代碼。其實,compilation對象在實例化時,同時還會實例化三個對象,分別是MainTemplateChunkTemplateModuleTemplate。這三個對象是用來渲染chunk,得到最終代碼模板的。它們之間的不同在於,MainTemplate用來渲染入口 chunk,ChunkTemplate用來渲染非入口 chunk,ModuleTemplate用來渲染 chunk 中的模塊。

這里, MainTemplate 和 ChunkTemplate 的 render 方法是用來生成不同的"包裝代碼"的,MainTemplate 對應的入口 chunk 需要帶有 webpack 的啟動代碼,所以會有一些函數的聲明和啟動。而包裝代碼中,每個模塊的代碼是通過 ModuleTemplate 來渲染的,不過同樣只是生成”包裝代碼”來封裝真正的模塊代碼,而真正的模塊代碼,是通過模塊實例的 source 方法來提供。這么說可能不是很好理解,直接看看最終生成文件中的代碼,如下:

每個chunk的源碼生成之后,會調用  emitAsset 將其存在  compilation.assets 中。當所有的 chunk 都渲染完成之后,assets 就是最終更要生成的文件列表。至此, compilation 的  seal 方法結束,也代表着  compilation 實例的所有工作到此也全部結束,意味着一次構建過程已經結束,接下來只有文件生成的步驟了。

 

emit

在 Compiler 開始生成文件前,鈎子 emit 會被執行,這是我們修改最終文件的最后一個機會,生成的在此之后,我們的文件就不能改動了。

this.hooks.emit.callAsync(compilation, err => { if (err) return callback(err); outputPath = compilation.getPath(this.outputPath); this.outputFileSystem.mkdirp(outputPath, emitFiles); }); 復制代碼

webpack 會直接遍歷 compilation.assets 生成所有文件,然后觸發鈎子done,結束構建流程。

總結

我們將webpack核心的構建流程都過了一遍,希望在閱讀完全文之后,對大家了解 webpack原理有所幫助~

本片文章代碼都是經過刪減更改處理的,都是為了能更好的理解。能力有限,如果有不正確的地方歡迎大家指正,一起交流學習。

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM