webpack輸出文件分析以及編寫一個loader

webpack構建流程

webpack是時下最流行的前端打包構建工具,本質上是一個模塊打包器,經過從入口文件開始遞歸的分析尋找模塊之間的依賴,最終輸出一個或多個bundle文件。javascript

webpack的構建是一個串行的流程,從啓動到結束,會依次執行如下流程:前端

  1. 初始化配置java

    從配置文件和命令行中讀取參數併合並參數,生成最終的配置項,而且執行配置文件中的插件實例化語句,生成Compiler傳入plugin的apply方法,爲webpack事件流掛上自定義鉤子;node

  2. 開始編譯react

    生成compiler示例,執行compiler.run開始編譯;webpack

  3. 肯定入口文件git

    從配置項中讀取全部的入口文件;es6

  4. 編譯模塊github

    從入口文件開始編譯,使用對應的loader編譯模塊,而且遞歸的編譯當前模塊所依賴的模塊,在全部的模塊都編譯完成後,獲得全部模塊的最終內容和模塊之間的依賴關係,最後將全部模塊的 require 語句替換爲 __webpack_require__ 來模擬模塊化操做;web

  5. 資源輸出

    根據入口和模塊的依賴關係,組裝成一個個包含多個模塊的chunk,而後將chunk轉換成一個單獨的文件加入輸出列表;

  6. 生成文件

    將生成的內容根據配置生成文件,輸出到指定的位置。

webpack的核心對象是Compile,負責文件的監聽和啓動編譯,繼承自Tapable[github.com/webpack/tap…],使得Compile實例具有了註冊和調用插件的功能。

在webpack執行構建流程時,webpack會在特定的時機廣播對應的事件,插件在監聽到事件後,會執行特定的邏輯來修改模塊的內容。

經過下面這個流程圖咱們可以對webpack的構建流程有個更直觀的印象:

webpack運行流程圖

webpack輸出文件分析

下面,咱們將經過分析webpack輸出的bundle文件,瞭解bundle文件是如何在瀏覽器中運行的。

單文件分析

首先建立 src/index.js ,執行一個最簡單的js語句:

console.log('hello world')
複製代碼

建立 webpack.config.js, 配置以下:

const path = require('path')

  module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist')
    }
  }
複製代碼

本例中使用的webpack版本爲4.35.3,此處爲了更好的分析輸出的bundle文件,將mode設置爲'none',此時webpack不會默認啓用任何插件。

mode有三個可選值,分別是'none'、'production'、'development',默認值爲'production',默認開啓如下插件:

  • FlagDependencyUsagePlugin:編譯時標記依賴;

  • FlagIncludedChunksPlugin:標記子chunks,防止屢次加載依賴;

  • ModuleConcatenationPlugin:做用域提高(scope hosting),預編譯功能,提高或者預編譯全部模塊到一個閉包中,提高代碼在瀏覽器中的執行速度;

  • NoEmitOnErrorsPlugin:在輸出階段時,遇到編譯錯誤跳過;

  • OccurrenceOrderPlugin:給常用的ids更短的值;

  • SideEffectsFlagPlugin:識別 package.json 或者 module.rules 的 sideEffects 標誌(純的 ES2015 模塊),安全地刪除未用到的 export 導出;

  • TerserPlugin:壓縮代碼

    mode值爲'development'時,默認開啓如下插件:

  • NamedChunksPlugin:以名稱固化chunkId;

  • NamedModulesPlugin:以名稱固化moduleId

    執行webpack構建命令:

$ webpack
複製代碼

輸出到dist文件夾中的 main.js 文件內容以下:

(function(modules) { // webpackBootstrap
// 模塊緩存
var installedModules = {};

// 模塊加載函數
function __webpack_require__(moduleId) {

	// 若是加載過該模塊,則直接從緩存中讀取
	if(installedModules[moduleId]) {
		return installedModules[moduleId].exports;
	}
	// 建立新模塊並將其緩存起來
	var module = installedModules[moduleId] = {
		i: moduleId,
		l: false,
		exports: {}
	};

	// 執行模塊函數,設置module.exports
	modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

	// 將module標記爲已加載
	module.l = true;

	// 返回設置好的module.exports
	return module.exports;
}


// 指向modules
__webpack_require__.m = modules;

// 指向緩存
__webpack_require__.c = installedModules;

// 定義exports的get方式
__webpack_require__.d = function(exports, name, getter) {
	if(!__webpack_require__.o(exports, name)) {
		Object.defineProperty(exports, name, { enumerable: true, get: getter });
	}
};

// 設置es6模塊標記
__webpack_require__.r = function(exports) {
	if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
		Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
	}
	Object.defineProperty(exports, '__esModule', { value: true });
};

// create a fake namespace object
// mode & 1: value is a module id, require it
// mode & 2: merge all properties of value into the ns
// mode & 4: return value when already ns object
// mode & 8|1: behave like require
__webpack_require__.t = function(value, mode) {
	if(mode & 1) value = __webpack_require__(value);
	if(mode & 8) return value;
	if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
	var ns = Object.create(null);
	__webpack_require__.r(ns);
	Object.defineProperty(ns, 'default', { enumerable: true, value: value });
	if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
	return ns;
};

// 兼容commonjs和es6模塊
__webpack_require__.n = function(module) {
	var getter = module && module.__esModule ?
		function getDefault() { return module['default']; } :
		function getModuleExports() { return module; };
	__webpack_require__.d(getter, 'a', getter);
	return getter;
};

// Object.prototype.hasOwnProperty的封裝
__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

// webpack配置的publicpath
__webpack_require__.p = "";


// 加載模塊並返回
return __webpack_require__(__webpack_require__.s = 0);
 })
/************************************************************************/
 ([
/* 0 */
/***/ (function(module, exports) {

console.log('hello world')

/***/ })
 ]);
複製代碼

能夠看到輸出的代碼是個IIFE(當即執行函數),能夠簡化以下:

(function(modules) {
  var installedModules = {};

  // webpack require語句
  // 加載模塊
  function __webpack_require__(moduleId) {}

  return __webpack_require__(0)
})([
  function(module, exports) {
    console.log('hello world')
  }
])
複製代碼

簡化後代碼中的 __webpack_require__ 函數起到的就是加載模塊的功能,IIFE函數接收的參數是個數組,第0項內容即是 src/index.js 中的代碼語句,經過 __webpack_require__ 函數加載並執行模塊,最終在瀏覽器控制檯輸出 hello world

接下來咱們經過代碼分析下 __webpack_reuqire__ 函數內部是如何工做的

function __weboack_require__(moduleId) {
  // 若是已經加載過該模塊,則從緩存中直接讀取
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }

  // 若是沒有加載過該模塊,則建立一個新的module存入緩存中
  var module = installedModules[moduleId] = {
  	i: moduleId, // module id
  	l: false, // 是否已加載 false
  	exports: {} // 模塊導出
  };

  // 執行該module
  // call方法第一個參數爲modules.exports,是爲了module內部的this指向該模塊
  // 而後傳入三個參數,分別爲module, module.exports, __webpack_require__模塊加載函數
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  // 設置module爲已加載
  module.l = true;

  // 最終返回module.exports
  return module.exports;
  }
}
複製代碼

能夠看到 __webpack_require__ 函數接收一個模塊id,經過執行該模塊,最終返回該模塊的exports,並將模塊緩存在內存中。若是再次加載該模塊, 則直接從緩存中讀取。 modules[modulesId] 的內容是IIFE參數的第0項,即:

function(module, exports) {
  console.log('hello world')
}
複製代碼

在導出的IIFE中,除了 __webpack_require__ 函數,還在 __webpack_require__ 下掛載了不少屬性.

  • __webpack_require__.m : 掛載全部的modules;
  • __webpack_require__.c : 掛載已緩存的modules;
  • __webpack_require__.d : 定義exports的getter;
  • __webpack_require__.r : 將module設置爲es6模塊;
  • __webpack_require__.t : 根據不一樣的場景返回對應處理後的模塊或值;
  • __webpack_require__.n : 返回getter,內部區分是否爲es6模塊;
  • __webpack_require__.o : Object.prototype.hasOwnProperty功能封裝;
  • __webpack_require__.p : output配置項中的publicPath屬性;

多文件引用分析

在前面的例子中,webpack打包的bundle中只包含一個很是簡單的入口文件,並不存在模塊之間的引用。

下面咱們修改下 src/index.js 中的代碼,引用一個ES6模塊 src/math.js 進來:

// math.js
const add = function (a, b) {
  return a + b
}

export default add
複製代碼
// index.js
import add from './math'

console.log(add(1, 2))
複製代碼

從新執行webpack打包命令,能夠看到輸出的IIFE中的參數已經變成了兩項:

([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);


console.log(Object(_math__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2))


/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
__webpack_require__.r(__webpack_exports__);
const add = function (a, b) {
  return a + b
}

/* harmony default export */ __webpack_exports__["default"] = (add);


/***/ })
 ]);
複製代碼

數組第1項中定義了 math.js 模塊,而且經過執行 __webpack_require__.r(__webpack_exports__) 使得webpack可以識別出該模塊是個ES6模塊,最後將 __webpack_exports__default 屬性值設置爲函數 add

數組第0項是 index.js 打包後輸出的模塊,語句 var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1) 的功能便是將模塊 math.js 導出的 add 函數引進來, __webpack_require__(1) 返回 module.exports,其中 1 是由webpack在打包時生成的chunkId,最後經過 console.log(Object(_math__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2)) 執行 index.js 中的語句。

webpack經過將本來獨立的一個個模塊存放到IIFE的參數中來加載,從而達到只進行一次網絡請求即可執行全部模塊,避免了經過屢次網絡加載各個模塊形成的加載時間過長的問題。而且在IIFE函數內部,webpack也對模塊的加載作了進一步優化,經過將已經加載過的模塊緩存起來存在內存中,第二次加載相同模塊時便直接從內存中取出。

異步加載分析

上面兩個例子都是同步加載模塊並執行,可是在實際項目中爲了提升頁面的加載速度,每每對首屏初始化時暫時用不到的模塊進行異步加載,好比從首頁跳轉後的路由模塊等。接下來咱們將經過異步加載的方式來加載 math.js 模塊並執行其導出的 add 函數。

import('./math').then((add) => {
  console.log(add(1, 2))
})
複製代碼

從新打包後,輸出 main.js1.js1.js 是須要異步加載的文件。

先分析入口文件 main.js ,能夠看到相對於同步加載方式的代碼輸出,文件中多了 __webpack_require__.ewebpackJsonpCallback 函數,IIFE中的參數也只有一個:

/***/ (function(module, exports, __webpack_require__) {


__webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, 1)).then((add) => {
  console.log(add(1, 2))
})


/***/ })
複製代碼

該模塊經過 __webpack_require__.e(1) 的方式加載模塊1的文件,加載成功後再經過執行 __webpack_require__.bind(null, 1) 返回模塊1,而後執行該模塊導出的 add 函數。

__webpack_require__.e 的做用即是加載須要異步加載的模塊,函數的內容以下:

__webpack_require__.e = function requireEnsure(chunkId) {
  var promises = [];

  var installedChunkData = installedChunks[chunkId];
  if (installedChunkData !== 0) { // 若是爲0則表明已經加載過該模塊

    // installedChunkData 不爲空且不爲0表示該 Chunk 正在網絡加載中
    // 直接返回promise對象
    if (installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // 該chunk從未被加載過,返回數組包含三項,分別是resolve,reject和建立的promise對象
      var promise = new Promise(function (resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);

      // 建立script標籤,加載模塊
      var script = document.createElement('script');
      var onScriptComplete;

      script.charset = 'utf-8';
      script.timeout = 120;
      if (__webpack_require__.nc) {
        script.setAttribute("nonce", __webpack_require__.nc);
      }

      // jsonpScriptSrc的做用是返回根據配置的publicPath和chunkId生成的文件路徑
      script.src = jsonpScriptSrc(chunkId);

      // 建立一個Error實例,用於在加載錯誤時catch
      var error = new Error();
      onScriptComplete = function (event) {
        // 防止內存泄漏
        script.onerror = script.onload = null;
        clearTimeout(timeout);
        var chunk = installedChunks[chunkId];

        if (chunk !== 0) {
          if (chunk) {
            // chunk加載失敗,拋出錯誤
            var errorType = event && (event.type === 'load' ? 'missing' : event.type);
            var realSrc = event && event.target && event.target.src;
            error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
            error.name = 'ChunkLoadError';
            error.type = errorType;
            error.request = realSrc;
            chunk[1](error);
          }
          installedChunks[chunkId] = undefined;
        }
      };

      // 異步加載最長等待時間120s
      var timeout = setTimeout(function () {
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);
      script.onerror = script.onload = onScriptComplete;

      // 將建立的script標籤插入dom中
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
};
複製代碼

函數內部先判斷是否加載過該模塊,若是沒有加載過,則建立一個script標籤,script的路徑是經過內部的 jsonpScriptSrc 函數根據webpack的配置生成最終的src路徑返回獲得。函數最終返回一個 Promise 對象,js文件加載失敗時則會執行 reject將錯誤拋出。

math.js 輸出的bundle 1.js 的內容很簡單,代碼以下:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],[
/* 0 */,
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
__webpack_require__.r(__webpack_exports__);
const add = function (a, b) {
  return a + b
}

/* harmony default export */ __webpack_exports__["default"] = (add);


/***/ })
]]);
複製代碼

能夠看到該bundle的做用就是向 window['webpackJsonp'] 數組中push了一個新的數組,其中第一項 [1] 是webpack生成的chunkId,第二項是 math.js 轉換後的模塊具體內容。

與此同時,在 main.js 中IIFE的後部分,對掛載在全局的 window['webpackJsonp'] 數組的push方法進行了重寫,指向了在前面定義過的 webpackJsonpCallback 函數:

function webpackJsonpCallback(data) {
	var chunkIds = data[0];
	var moreModules = data[1];
	// 將data第1項模塊添加到modules中,
	// 而後將對應的chunkId標記爲已加載
	var moduleId, chunkId, i = 0, resolves = [];
	for(;i < chunkIds.length; i++) {
		chunkId = chunkIds[i];
		if(installedChunks[chunkId]) {
			resolves.push(installedChunks[chunkId][0]);
		}
		installedChunks[chunkId] = 0;
	}

	// 將傳進來的moreModules數組中的每個模塊依次添加到IIFE中緩存的modules中
	for(moduleId in moreModules) {
		if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
			modules[moduleId] = moreModules[moduleId];
		}
	}

	// parentJsonpFunction爲window['webpackJsonp']中原聲的數組push方法
	// 執行parentJsonpFunction將data真正的添加到window['webpackJsonp']數組中去
	if(parentJsonpFunction) parentJsonpFunction(data);

	// 將前面建立的promise執行resolve
	while(resolves.length) {
		resolves.shift()();
	}
};
複製代碼

經過分析 webpackJsonpCallback 函數的內容,能夠看到該函數的主要做用是將傳入的chunkid標記爲已加載,並將傳入的模塊掛在到緩存模塊的 modules 對象上,最終執行 __webpack_require__.e 函數返回的promise對象的resolve方法表明該異步加載的模塊已經加載完成,此時,在 __webpack_require__.e(1).then() 中即可以經過同步加載模塊的方式加載該模塊啦。

從新梳理一下入口主文件加載異步模塊的大概流程:

  1. 執行 __webpack_require__.e 加載異步模塊;
  2. 建立chunkid對應的script標籤加載腳本,並返回promise;
  3. 若是加載失敗,reject掉promise;若是加載成功,異步chunk當即執行 window[webpackJsonp] 的push方法,將模塊標記爲已加載,並resolve掉相應的promise;
  4. 成功後可在 __webpack_require__.e().then 中以同步的方式加載模塊。

輸出文件總結

在webpack輸出的文件中,經過IIFE的形式將全部模塊做爲參數都傳遞進來,用 __webpack_require__ 模擬import或者require語句,而後從入口模塊開始依次遞歸的執行加載模塊,須要異步加載的模塊,經過在dom上插入一個新的script標籤加載。而且內部對模塊加載作了緩存處理優化。

在實際的項目中,輸出的bundle內容會遠比本文中的demo複雜的多,而且會有chunkId設置,公共chunk抽取,代碼壓縮混淆等優化,可是能夠經過這個最基本的demo,熟悉webpack輸出的文件在運行時的工做流程,便於咱們在調試時更好的分析。

編寫一個簡單的loader

在編寫一個loader以前,先簡單介紹下webpack loader的做用。在webpack中,能夠將loader理解爲一個轉換器,經過處理文件的輸入,返回一個新的結果,最終交給webpack進行下一步的處理。

一個loader就是一個nodejs模塊,它的基本結構以下:

// 能夠經過loader-utils這個包獲取該loader的配置項options
const loaderUtils = require('loader-utils')

// 導出一個函數,source爲webpack傳遞給loader的文件源內容
module.exports = function(source) {
  // 獲取該loader的配置項
  const options = loaderUtils.getOptions(this)

  // 一些轉換處理,最終返回處理後的結果。
  return source
}

複製代碼

在平時配置webpack loader的時候,都是使用經過npm安裝的loader,爲了加載本地的loader,通常有兩種方式,第一種是經過npm link的方式將loader關聯到項目的node_modules下,還有一種方式是經過配置wepack的resolveLoader.modules配置項,告訴webpack經過何種形式尋找loader。第一種方式須要配置相關的 package.json ,在本例中使用第二種方式配置。

module.exports = {
  resolveLoader: {
    // 假設本地編寫的loader在loaders文件夾下
    modules: ['node_modules', './loaders/']
  }
}
複製代碼

下面咱們編寫一個loader,用於刪除代碼中的註釋。命名爲remove-comment-loader:

module.exports = function(source) {
  // 匹配js中的註釋內容
  const reg = new RegExp(/(\/\/.*)|(\/\*[\s\S]*?\*\/)/g)

  // 刪除註釋
  return source.replace(reg, '')
}

複製代碼

而後修改webpack.config.js:

const path = require('path')

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'remove-comment-loader' // 當匹配到js文件時,使用咱們編寫的remove-comment-loader
      }
    ]
  },
  output: {
    path: path.resolve(__dirname, 'dist')
  },
  resolveLoader: {
    modules: ['node_modules', './loaders/'] // 配置加載本地loader
  }
}
複製代碼

而後在入口文件代碼中加上一些註釋,從新打包查看輸出文件,就能看到代碼中的註釋已經被刪除了。

本文中的demo代碼參見;github.com/duwenbin031…

在此處順便向你們推薦下民生科技公司Firefly移動金融開發平臺中的前端打包構建工具apollo-build。apollo-build包含開發調試、打包、測試、 和打包dll的功能,而且提供了很是好用的前端接口Mock功能,命令行體驗和create-react-app一致。咱們封裝了webpack中的大部分經常使用功能並在內部作了不少優化,從中提取出了最經常使用的配置項,即便不熟悉webpack的配置也能快速上手,而且也支持經過 webpack.config.js 的方式作高階的修改,歡迎訪問民生科技官網瞭解。

參考


《深刻淺出webpack》 - 吳浩麟

Webpack揭祕——走向高階前端的必經之路

做者介紹

杜文斌 民生科技有限公司 用戶體驗技術部 Firefly移動金融開發平臺前端開發工程師

相關文章
相關標籤/搜索