webpack 打包的代碼怎麼在瀏覽器跑起來的?看不懂算我輸

說點什麼

最近在作一個工程化強相關的項目-微前端,涉及到了基座項目和子項目加載,並存的問題;之前對webpack一直停留在配置,也就是常說的入門級。此次項目推進,本身不得不邁過門檻,往裏面多看一點。javascript

本文主要講webpack構建後的文件,是怎麼在瀏覽器運行起來的,這可讓咱們更清楚明白webpack的構建原理。

文章中的代碼基本只含核心部分,若是想看所有代碼和webpack配置,能夠關注工程,本身拷貝下來運行: demo地址html

在讀本文前,須要知道webpack的基礎概念,知道chunk 和 module的區別前端

本文將按部就班,來解析webpack打包後的代碼是怎麼在瀏覽器跑起來的。將從如下三個步驟揭開黑盒:java

  • 單文件打包,從IIFE提及;
  • 多文件之間,怎麼判斷依賴的加載狀態;
  • 按需加載的背後,黑盒中究竟有什麼黑魔法;

原文連接:webpack 打包的代碼怎麼在瀏覽器跑起來的?看不懂算我輸node

從最簡單的提及:單文件怎麼跑起來的

最簡單的打包場景是什麼呢,就是打包出來html文件只引用一個js文件,項目就能夠跑起來,舉個🌰:webpack

// 入口文件:index.js
import sayHello from './utils/hello';
import { util } from './utils/util';

console.log('hello word:', sayHello());
console.log('hello util:', util);

// 關聯模塊:utils/util.js
export const util = 'hello utils';

// 關聯模塊:utils/hello.js
import { util } from './util';

console.log('hello util:', util);

const hello = 'Hello';

export default function sayHello() {
  console.log('the output is:');
  return hello;
};

入門級的代碼,簡單來說就是入口文件依賴了兩個模塊: util 與 hello,而後模塊hello,又依賴了util,最後運行html文件,能夠在控制檯看到console打印。打包後的代碼長什麼樣呢,看下面,刪除了一些干擾代碼,只保留了核心部分,加了註釋,但仍是較長,須要耐心:git

(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: {}
    };
    // 上面說的加載,其實就是執行模塊,把模塊的導出掛載到exports對象上;
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 標識模塊已加載過
    module.l = true;
    // Return the exports of the module
    return module.exports;
  }
  // 暴露入口輸入模塊;
  __webpack_require__.m = modules;
  // 暴露已經加載過的模塊;
  __webpack_require__.c = installedModules;
  // 模塊導出定義方法
  // eg: export const hello = 'Hello world';
  // 獲得: exprots.hello = 'Hello world';
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter
      });
    }
  };

  // __webpack_public_path__
  __webpack_require__.p = '';
  // 從入口文件開始啓動
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })({
  "./webpack/src/index.js":
  /*! no exports provided */
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    var _utils_hello__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/hello */ "./webpack/src/utils/hello.js");
    var _utils_util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils/util */ "./webpack/src/utils/util.js");
    console.log('hello word:', Object(_utils_hello__WEBPACK_IMPORTED_MODULE_0__["default"])());
    console.log('hello util:', _utils_util__WEBPACK_IMPORTED_MODULE_1__["util"]);
  }),
  "./webpack/src/utils/hello.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, "default", function() { return sayHello; });
    var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util */ "./webpack/src/utils/util.js");

    console.log('hello util:', _util__WEBPACK_IMPORTED_MODULE_0__["util"]);
    var hello = 'Hello';
    function sayHello() {
      console.log('the output is:');
      return hello;
    }
  }),

  "./webpack/src/utils/util.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, "util", function() { return util; });
    var util = 'hello utils';
  })
});

咋眼一看上面的打包結果,其實就是一個IIFE(當即執行函數),這個函數就是webpack的啓動代碼,裏面包含了一些變量方法聲明;而輸入是一個對象,這個對象描述的就是咱們代碼中編寫的文件,文件路徑爲對面key,value就是文件中定義的代碼,但這個代碼是被一個函數包裹的:github

/**
 * module:               就是當前模塊
 * __webpack_exports__: 就是當前模塊的導出,即module.exports
 * __webpack_require__:  webpack加載器對象,提供了依賴加載,模塊定義等能力
**/
function(module, __webpack_exports__, __webpack_require__) {
  // 文件定義的代碼
}

加載的原理,在上面代碼中已經作過註釋了,耐心點,一分鐘就明白了,仍是加個圖吧,在vscode中用drawio插件畫的,感覺一下: web

20200516121057

除了上面的加載過程,再說一個細節,就是webpack怎麼分辨依賴包是ESM仍是CommonJs模塊,仍是看打包代碼吧,上面輸入模塊在開頭都會執行__webpack_require__.r(__webpack_exports__), 省略了這個方法的定義,這裏補充一下,解析看代碼註釋:json

// 定義模塊類型是__esModule, 保證模塊能被其餘模塊正確導入,  
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {
        value: 'Module'
      });
    }
    // 模塊上定義__esModule屬性, __webpack_require__.n方法會用到
    // 對於ES6 MOdule,import a from 'a'; 獲取到的是:a[default];
    // 對於cmd, import a from 'a';獲取到的是整個module
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
  // esModule 獲取的是module中的default,而commonJs獲取的是所有module
  __webpack_require__.n = function (module) {
    var getter = module && module.__esModule ?
      function getDefault() {
        return module['default'];
      } :
      function getModuleExports() {
        return module;
      };
    // 爲何要在這個方法上定義一個 a 屬性? 看打包後的代碼, 好比:在引用三方時
    // 使用import m from 'm', 而後調用m.func();
    // 打出來的代碼都是,獲取模塊m後,最後執行時是: m.a.func();
    __webpack_require__.d(getter, 'a', getter);
    return getter;
  };

最多見的:多文件引入的怎麼執行

看完最簡單的,如今來看一個最多見的,引入splitChunks,多chunk構建,執行流程有什麼改變。咱們經常會將一些外部依賴打成一個js包,項目本身的資源打成一個js包;

仍是剛剛的節奏,先看打包前的代碼:

// 入口文件:index.js
+ import moment from 'moment';
+ import cookie from 'js-cookie';
import sayHello from './utils/hello';
import { util } from './utils/util';

console.log('hello word:', sayHello());
console.log('hello util:', util);
+ console.log('time', moment().format('YYYY-MM-DD'));
+ cookie.set('page', 'index');
// 關聯模塊:utils/util.js
+ import moment from 'moment';
export const util = 'hello utils';

export function format() {
  return moment().format('YYYY-MM-DD');
}

// 關聯模塊:utils/hello.js
// 沒變,和上面同樣

從上面代碼能夠看出,咱們引入了moment與js-cookie兩個外部JS包,並採用分包機制,將依賴node_modules中的包打成了一個單獨的,下面是多chunk打包後的html文件截圖:

20200516132542

再看看async.js 包長什麼樣:

// 僞代碼,隱藏了 moment 和 js-cookie 的代碼細節
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["async"],{
  "./node_modules/js-cookie/src/js.cookie.js": (function(module, exports, __webpack_require__) {}),
  "./node_modules/moment/moment.js": (function(module, exports, __webpack_require__) {})
})

咋同樣看,這個代碼甚是簡單,就是一個數組push操做,push的元素是一個數組[["async"],{}], 先提早說一下,數組第一個元素數組,是這個文件包含的chunk name, 第二個元素對象,其實就和第一節簡單文件打包的輸入同樣,是模塊名和包裝後的模塊代碼;

再看一下index.js 的變化:

(function(modules) { // webpackBootstrap
     // 新增
     function webpackJsonpCallback(data) {
         return checkDeferredModules();
   };
   
     function checkDeferredModules() {
     }

     // 緩存加載過的模塊
     var installedModules = {};
  // 存儲 chunk 的加載狀態
     // undefined = chunk not loaded, null = chunk preloaded/prefetched
     // Promise = chunk loading, 0 = chunk loaded
     var installedChunks = {
         "index": 0
     };
     var deferredModules = [];
     // on error function for async loading
     __webpack_require__.oe = function(err) { console.error(err); throw err; };

  // 加載的關鍵
     var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
     var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
     jsonpArray.push = webpackJsonpCallback;
     jsonpArray = jsonpArray.slice();
     for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
     var parentJsonpFunction = oldJsonpFunction;

  // 從入口文件開始啓動
  - return __webpack_require__(__webpack_require__.s = "./src/index.js");
     // 將入口加入依賴延遲加載的隊列
     + deferredModules.push(["./webpack/src/index.js","async"]);
     // 檢查可執行的入口
     + return checkDeferredModules();
 })
 ({
   // 省略;
 })

從上面的代碼看,支持多chunk執行,webpack 的bootstrap,仍是作了不少工做的,我這大概列一下:

  • 新增了checkDeferredModules,用於依賴chunk檢查是否已準備好;
  • 新增webpackJsonp全局數組,用於文件間的通訊與模塊存儲;通訊是經過攔截push操做完成的;
  • 新增webpackJsonpCallback,做爲攔截push的代理操做,也是整個實現的核心;
  • 修改了入口文件執行方式,依賴deferredModules實現;

這裏面文章不少,咱們來一一破解:

webpackJsonp push 攔截

// 檢查window["webpackJsonp"]數組是否已聲明,若是未聲明的話,聲明一個;
 var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
 // 對webpackJsonp原生的push操做作緩存
  var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
 // 使用開頭定義的webpackJsonpCallback做爲代碼,即代碼中執行indow["webpackJsonp"].push時會觸發這個操做
  jsonpArray.push = webpackJsonpCallback;
 // 這不操做,其實就是jsonpArray開始是window["webpackJsonp"]的快捷操做,如今咱們對她的操做已完,就斷開了這個引用,但值仍是要,用於後面遍歷
 jsonpArray = jsonpArray.slice();
 // 這一步,其實要知道他的場景,才知道他的意義,若是光看代碼,以爲這個數組剛聲明,遍歷有什麼用;
 // 其實這裏是在依賴的chunk 先加載完的狀況,但攔截代理當時還沒生效;因此手動遍歷一次,讓已加載的模塊再走一次代理操做;
  for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
 // 這個操做就是個賦值語句,意義不大;
    var parentJsonpFunction = oldJsonpFunction;

直接寫上面註釋了,webpackJsonpCallback在後面會解密。

代理 webpackJsonpCallback 幹了什麼

function webpackJsonpCallback(data) {
         var chunkIds = data[0];
         var moreModules = data[1];
         var executeModules = data[2];

         // add "moreModules" to the modules object,
         var moduleId, chunkId, i = 0, resolves = [];
         for(;i < chunkIds.length; i++) {
      chunkId = chunkIds[i];
             // 下一節再講
             installedChunks[chunkId] = 0;
      
         }
         for(moduleId in moreModules) {
             if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
        // 將其餘chunk中的模塊加入到主chunk中;
                 modules[moduleId] = moreModules[moduleId];
             }
     }
    // 這裏纔是原始的push操做
         if(parentJsonpFunction) parentJsonpFunction(data);
         while(resolves.length) {
      // 下一節再講
         }
         // 這一句在這裏沒什麼用
         deferredModules.push.apply(deferredModules, executeModules || []);

         // run deferred modules when all chunks ready
         return checkDeferredModules();
   };

還記得前面push的數據是什麼格式嗎:

window["webpackJsonp"].push([["async"], moreModules])

攔截了push操做後,其實就作了三件事:

  • 將數組第二個變量 moreModules 加入到index.js 當即執行函數的輸入變量modules中;
  • 將這個chunk的加載狀態置成已完成;
  • 而後checkDeferredModules,就是看這個依賴加載後,是否有模塊在等這個依賴執行;

checkDeferredModules 幹了什麼

function checkDeferredModules() {
         var result;
         for(var i = 0; i < deferredModules.length; i++) {
             var deferredModule = deferredModules[i];
             var fulfilled = true;
             for(var j = 1; j < deferredModule.length; j++) {
        // depId, 即指依賴的chunk的ID,,對於入口‘./webpack/src/index.js’這個deferredModule,depId就是‘async’,等async模塊加載後就能夠執行了
                 var depId = deferredModule[j];
                 if(installedChunks[depId] !== 0) fulfilled = false;
             }
             if(fulfilled) {
         // 執行過了,就把這個延遲執行項移除;
         deferredModules.splice(i--, 1);
         // 執行./webpack/src/index.js模塊
                 result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
             }
         }
         return result;
     }

還記得入口文件的執行替換成了: deferredModules.push(["./webpack/src/index.js","async"]), 而後執行checkDeferredModules。
這個函數,就是檢查哪些chunk安裝了,但有些module執行,須要依賴某些
chunk,等依賴的chunk加載了,再執行這個module。上面的那一句代碼就是./webpack/src/index.js這個模塊執行依賴async這個chunk。

小總結

到這裏,彷佛多chunk打包,文件的執行流程就算理清楚了,若是你能想明白在html中下面兩種方式,都不會致使文件執行失敗,你就真的明白了:

<!-- 依賴項在前加載 -->
<script type="text/javascript" src="async.bundle_9b9adb70.js"></script>
<script type="text/javascript" src="index.4f7fc812.js"></script>

<!-- 或依賴項在後加載 -->
<script type="text/javascript" src="index.4f7fc812.js"></script>
<script type="text/javascript" src="async.bundle_9b9adb70.js"></script>

按需加載:動態加載過程解析

等多包加載理清後,再看按需加載,就沒有那麼複雜了,由於不少實現是在多包加載的基礎上完成的,爲了讓理論更清晰,我添加了兩處按需加載,仍是那個節奏:

// 入口文件,index.js, 只列出新增代碼
let count = 0;

const clickButton = document.createElement('button');

const name = document.createTextNode("CLICK ME");

clickButton.appendChild(name);

document.body.appendChild(clickButton);

clickButton.addEventListener('click', () => {
  count++;
  import('./utils/math').then(modules => {
    console.log('modules', modules);
  });

  if (count > 2) {
    import('./utils/fire').then(({ default: fire }) => {
      fire();
    });
  }
})

// utils/fire
export default function fire() {
  console.log('you are fired');
}

// utils/math
export default function add(a, b) {
  return a + b;
}

代碼很簡單,就是在頁面添加了一個按鈕,當按鈕被點擊時,按需加載utils/math模塊,並打印輸出的模塊;當點擊次數大於兩次時,按需加載utils/fire模塊,並調用其中暴露出的fire函數。相對於上一次,會多打出兩個js 文件:0.bundle_29180b93.js 與 1.bundle_42bc336c.js,這裏就列其中一個的代碼:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],{
"./webpack/src/utils/math.js":
  (function(module, __webpack_exports__, __webpack_require__) {})
}]);

格式與上面的async chunk 格式如出一轍。

而後再來看index.js 打包完,新增了哪些:

(function(modules) { 
  // script url 計算方法。下面的兩個hash 是否似曾相識,對,就是兩個按需加載文件的hash值
  // 傳入0,返回的就是0.bundle_29180b93.js這個文件名
     function jsonpScriptSrc(chunkId) {
         return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle_" + {"0":"29180b93","1":"42bc336c"}[chunkId] + ".js"
  }
  // 按需加載script 方法
  __webpack_require__.e = function requireEnsure(chunkId) {
         // 後面詳講
     }; 
 })({
  "./webpack/src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
    // 只列出按需加載utils/fire.js的代碼
    __webpack_require__.e(/*! import() */ 0)
    .then(__webpack_require__.bind(null, "./webpack/src/utils/fire.js"))
    .then(function (_ref) {
        var fire = _ref["default"];
        fire();
    });
  }
})

在上一節的接觸上,只加了不多的代碼,主要涉及到兩個方法jsonpScriptSrcrequireEnsure,前者在註釋裏已經寫得很清楚了,後者其實就是動態建立script標籤,動態加載須要的js文件,並返回一個Promise,來看一下代碼:

__webpack_require__.e = function requireEnsure(chunkId) {
  var promises = [];
    var installedChunkData = installedChunks[chunkId];
  // 0 意爲着已加載.
  if(installedChunkData !== 0) {
      // a Promise means "currently loading": 意外着,已經在加載中
      // 須要把加載那個promise:(即下面new的promise)加入到當前的依賴項中;
    if(installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // setup Promise in chunk cache:new 一個promise
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
        });
      // 這裏將promise自己記錄到installedChunkData,就是以防上面多個chunk同時依賴一個script的時候
      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);
      }
      script.src = jsonpScriptSrc(chunkId);

      // 下面的代碼都是錯誤處理
      var error = new Error();
      onScriptComplete = function (event) {
          // 錯誤處理
      };
      var timeout = setTimeout(function(){
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);
      script.onerror = script.onload = onScriptComplete;
      // 添加script到body
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
};

相對來講requireEnsure的代碼實現並無多麼特別,都是一些常規操做,但沒有用經常使用的onload回調,而改用promise來處理,仍是比較巧妙的。模塊是否已經加裝好,仍是利用前面的webpackJsonp的push代理來完成。

如今再來補充上面一節說留着下一節講的代碼:

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];
  var executeModules = data[2];

  var moduleId, chunkId, i = 0, resolves = [];
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
      // installedChunks[chunkId] 在這裏加載時,仍是一個數組,元素分別是[resolve, reject, promise],這裏取的是resolve回調;
      resolves.push(installedChunks[chunkId][0]);
    }
    installedChunks[chunkId] = 0;
    // moreModules 注入忽略
    while(resolves.length) {
      // 這裏resolve時,那麼promise.all 就完成了
             resolves.shift()();
    }
  }
}

因此上面的代碼作的,仍是利用了這個代理,在chunk加載完成時,來把剛剛產生的promise resolved 掉,這樣按需加載的then就繼續往下執行了,很是曲折的一個發佈訂閱。

總結

自此,對webpack打包後的代碼執行過程就分析完了,由簡入難,若是多一點耐心,仍是比較容易就看懂的。畢竟wbepack的高深,是隱藏在webpack自身的插件系統中的,打出來的代碼基本是ES5級別的,只是用了一些巧妙的方法,好比push的攔截代理。

若是有什麼不清楚的,推薦clone項目,本身打包分析一下代碼:demo地址: webpack項目

相關文章
相關標籤/搜索