Webpack 源码分析1 (模块加载原理)

380 阅读8分钟

webpack 是一个模块打包器,在它看来,每一个文件都是一个模块。

无论你开发使用的是 CommonJS 规范还是 ES6 模块规范,打包后的文件都统一使用 webpack 自定义的模块规范来管理、加载模块。本文将从一个简单的示例开始,来讲解 webpack 模块加载原理。

准备工作:

1. 首先初始化一个package.json文件出来,然后我们分析是用的 webpack4和webpack-cli3版本,如下:

{
  "name": "webpack",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "webpack": "4",
    "webpack-cli": "3"
  },
  "dependencies": {
    "html-webpack-plugin": "4"
  }
}

2.然后准备一下webpack配置文件新建 webpack.config.js文件,内容如下:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
  entry: "./src/index.js",
  devtool: "none",
  mode: "development",
  output: {
    filename: "build.js",
    path: path.resolve("dist"),
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./src/index.html",
    }),
  ],
};

3. 最后准备一下webpack.config.js中用到html和js文件,如下:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">加载</button>
</body>
</html>

index.js

const { time } = require("./utils");
import page, { p1 } from "./page1";
const test = "加载了";
time("home");
console.log(page,p1,test);

const btn = document.getElementById('btn')
btn.addEventListener('click',function(){
    import(/* webpackChunkName: "async" */'./async').then((snyc)=>{
        console.log(snyc)
    })
},false)

page1.js

import utils from './utils'
const page = "这是page";
export const p1 = "1";
utils.time('cha')
export default page;

utils.js

function time(type) {
  console.log(type, new Date());
}

module.exports = {
  time,
};

snyc.js

const value = '异步文件'
function add(){
    consople.log('ssss')
}

module.exports = {
    value,
    add
};

// export {
//     value
// }

文件夹结构:
image.png

以上代码逻辑如下:

index.js

  1. 通过require关键字加载utils模块commonJS规范导出的内容
  2. 通过import关键字加载page1模块esModule规范导出的内容
  3. 通过btn按钮点击使用import()懒加载,async模块commonJS规范导出的内容

page1.js

  1. 通过import关键字加载utils模块commonJS规范导出的内容
  2. esModule规范导出内容

剩下的utils.js和async.js都是commonJS规范做的导出.

知道这几个文件都做了哪些事之后,我们运行npx webpack 来把他们打包看看产出之后的一个结果,然后我们针对它产出的结果build.js和async.build.js 来进行分析。

产出的index.html

产出的index.html没有什么可看的,就多了一个把build.js加载到html里

image.png

产出的build.js

(function(modules){
    // webpackBootstrap
    // 14 webpackJsonpCallback 的实现   实现:合并懒加载模块 并把模块promise状态改为成功态
    function webpackJsonpCallback(data){
        // 01 获取需要被加载的模块ids webpackChunkName
        var chunkIds = data[0];
        // 02 懒加载模块的依赖关系对象
        var moreModules = data[1];
        // 03 循环判断chunkIds里对应的模块内容是否已经完成了加载
        var moduleId,
        chunkId,
        i = 0,
        resolves = [];
        for (; i < chunkIds.length; i++) {
            chunkId = chunkIds[i]; //当前的模块的webpackChunkName
            if (
            //先从已下载的chunks中installedChunks 看下有没有有这个属性,看看它是不是要被加载的
            Object.prototype.hasOwnProperty.call(installedChunks, chunkId) &&
            installedChunks[chunkId] //再看看它 是不是0  0代表已经加载过了
            ) {
                resolves.push(installedChunks[chunkId][0]); // 把installedChunks[chunkId] 存储的数组 [resolve,reject,promise]  第一个决议resolve函数  存起来
            }
            installedChunks[chunkId] = 0; // 到这里 当前模块状态 加载完成 改为成功态
        }
        // 懒加载过来的模块合并到modules 上
        for (moduleId in moreModules) {
            if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
              modules[moduleId] = moreModules[moduleId];
            }
        }
        // 如果chunkId 这个模块也被其他模块动态加载了  它会被用到
        if (parentJsonpFunction) parentJsonpFunction(data);

        while (resolves.length) {
          // 把resolve里面存的 进行决议  这会 __webpack_require__.e 的promise.all 对应的promise决议完成  paomise.all全部完成之后
          // t函数执行  取出对应模块  并进行包装     t函数被当作then的函数传入  
          // 也就是说它执行完成之后的返回值   会继续被包装成promise  then下去  这样我们import().then 就可以接到它返回的模块内容了
          resolves.shift()();
        }
    }

    // 15 定义installedChunks用于标识某个chunkid对应的chunk是否完成了加载
    var installedChunks = {
        // 0已加载  promise 正在加载    undefined未加载    null块已预加载/预取
        main: 0, //主入口 因为没有用webpackChunkName 名称定义  所以默认为main
    };

    // 01 定义缓存对象
    var installedModules = {};

    // 02 定义内部自己的 __webpack_require__  import 和 require都会被转成它 用来导入模块内容
    function __webpack_require__(moduleId){
        // 判断缓存中是否有加载的模块 有的话直接返回它的exports
        if(installedModules[moduleId]){
            return installedModules[moduleId].exports
        }

        // 如果缓存不存在 定义对象并写入缓存
        var module = installedModules[moduleId] = {
            i:moduleId, // 就是拼接地址
            l:false,  //l是否加载了
            exports:{} // exports 是模块最后导出内容
        }

        //调用moduleId对应的函数执行  加载内容到module.exports 中
        modules[moduleId].call(module.exports,module,module.exports,__webpack_require__)
        //修改当前模块为已加载
        module.l = true
        //把拿回来的内容返回出去
        return module.exports
    }

 
    // 03 定义m属性用于保存modules
    __webpack_require__.m = modules;

    // 04 定义c属性用于保存cache
    __webpack_require__.c = installedModules;  // 缓存模块

    // 05 定义o方法 用于判断对象身上是否存在指定的属性
    __webpack_require__.o = function(object,property){
        return Object.prototype.hasOwnProperty(object,property)
    }

    // 06 定义d方法用于在对象的身上添加指定的属性
    __webpack_require__.d = function(exports,name,getter){
        if(!__webpack_require__.o(name)){ // 如果exports没有name属性  防止重复添加相同得值   
             // 给他添加上这个属性 并且把它改为可枚举得  并且只传入get访问器函数时  只能获取该属性   其他模块导入时不可以修改 因为没有set 
            Object.defineProperty(exports,name,{enumerable:true,get:getter}) // 访问器由模块传入
        }
    }

    // 07 标记为是一个__esModule  并且再支持es6时添加 获取类型时时一个module    commonjs规范走不到这里
    __webpack_require__.r = function(exports){
        if(typeof Symbol !== 'undefined' && Symbol.toStringTag){ // 支持es6时
            // 通过object.prototype.toString.call(exports) 可以得到一个Module    标记一下exports是一个模块
            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
        }
        // 直接添加__esModule属性为true 标记
        Object.defineProperty(exports, '__esModule', { value: true });
    }

    // 08 定义n方法用于设置具体的getter   目前看在esmodule模块中  使用import语法 来导入commonjs模块时  会用到它  
    __webpack_require__.n = function(module){
         // 最后通过xxx.a 来获取对应的默认导出的内容  
        var getter = module && module.__esModule  //如果是esmodule返回default  commonjs规范下直接返回  抹平两种导入得差别
        ? function getDefault() {
            return module["default"];
          }
        : function getModuleExports() {
            return module;
        };
        __webpack_require__.d(getter, "a", getter);
        return getter;
    }

    function jsonpScriptSrc(chunkId) {
        return __webpack_require__.p + "" + chunkId + ".build.js";
    }

    // 16 实现e方法 使用 jsonp 来懒加载模块 并设置超时时间 返回promise 一旦promsie决议 代表模块加载完成
    __webpack_require__.e = function requireEnsure(chunkId) {
        var promises = [];
    
        // JSONP chunk loading for javascript
    
        var installedChunkData = installedChunks[chunkId];
        if (installedChunkData !== 0) {
          // 模块未加载
          // 0 means "already installed".
    
          // a Promise means "currently loading".
          if (installedChunkData) {
            // 有值下面赋值的数组 可能是promise 加载中 后续会给installedChunkData[2] 赋值
            promises.push(installedChunkData[2]); // 把这个promise装起来  等加载完成之后执行then   可能这个模块还没加载完毕 点击函数又点击一次
          } else {
            // setup Promise in chunk cache
            var promise = new Promise(function (resolve, reject) {
              installedChunkData = installedChunks[chunkId] = [resolve, reject];
            });
            promises.push((installedChunkData[2] = promise)); // 把这个promise装起来   installedChunks[chunkId][2] 位置把这个promsie存起来
    
            //创建jsonp加载文件
            // start chunk loading
            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指定地址  chunkId就是webpackChunkName
            script.src = jsonpScriptSrc(chunkId);
    
            // create error before stack unwound to get useful stacktrace later
            var error = new Error();
            onScriptComplete = function (event) {
              // avoid mem leaks in IE.
              script.onerror = script.onload = null;
              clearTimeout(timeout);
              var chunk = installedChunks[chunkId];
              if (chunk !== 0) {
                if (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); // reject函数 抛出错误
                }
                installedChunks[chunkId] = undefined; // 模块标记为未加载
              }
            };
            var timeout = setTimeout(function () {
              onScriptComplete({ type: "timeout", target: script });
            }, 120000); // 创建一个懒加载模块的超时时间 到时间未加载成功报错
            script.onerror = script.onload = onScriptComplete; // 都赋值  onload成功之后  模块状态为0 不会报错
            document.head.appendChild(script); //开始加载
          }
        }
        return Promise.all(promises);
      };

    // mode & 1: value 是一个模块 id,需要它
    // mode & 2: 将 value 的所有属性合并到 ns 中
    // mode & 4: 已经是 ns 对象时返回值
    // mode & 8|1:表现得 require
    // 17 定义t方法 用于加载指定value的模块内容,之后对内容进行处理返回
    __webpack_require__.t = function(value,mode){
        // 01加载value对应的模块内容(value 一般就是模块id)
        // 加载之后的内容重新赋值给value变量
        if(mode & 1){
            value = __webpack_require__(value)
        }
        if(mode & 8){
            return value
        }
        if((mode & 4) && typeof value === 'object' && value && value.__esModule){
            return value
        }
        // 如果8和4都没有成立 则需要定义ns 来通过default属性返回内容
        var ns = Object.create(null)
        __webpack_require__.r(ns) // 标记为esm
        Object.defineProperty(ns, 'default', { enumerable: true, value: value });
        // 下面判断表示如果返回的value是一个对象  那需要给他依次添加getter 到ns上 
        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;
    }

    // 09 定义p属性 用于保存资源访问路径
    __webpack_require__.p = ''

    // 11 定义变量存放数组
    var jsonpArray = (window["webpackJsonp"] = window["webpackJsonp"] || []); // 首次执行 赋值空数组
    // 12 保存原来的push方法
    var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 保存一份原生的push方法  后续可能会用
    // 13 重写push方法 
    jsonpArray.push = webpackJsonpCallback; // 重写jsonpArray.push 相当于也重写了 window["webpackJsonp"].push
    jsonpArray = jsonpArray.slice();
    for (var i = 0; i < jsonpArray.length; i++)
      webpackJsonpCallback(jsonpArray[i]);
    var parentJsonpFunction = oldJsonpFunction;

    // 10 调用__webpack_require__ 方法执行模块导入与加载操作
    return __webpack_require__(__webpack_require__.s = './src/index.js')
})({
    "./src/index.js": function (
      module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony import */ var _page1__WEBPACK_IMPORTED_MODULE_0__ =
        __webpack_require__(/*! ./page1 */ "./src/page1.js");
      const { time } = __webpack_require__(/*! ./utils */ "./src/utils.js");
  
      const test = "加载了";
      time("home");
      console.log(
        _page1__WEBPACK_IMPORTED_MODULE_0__["default"],
        _page1__WEBPACK_IMPORTED_MODULE_0__["p1"],
        test
      );
  
      const btn = document.getElementById("btn");
      btn.addEventListener(
        "click",
        function () {
          __webpack_require__
            .e(/*! import() | async */ "async")
            .then(
              __webpack_require__.t.bind(null, /*! ./async */ "./src/async.js", 7)
            )
            .then((snyc) => {
              console.log(snyc);
            });
        },
        false
      );
    },
  
    "./src/page1.js": function (
      module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony export (binding) */ __webpack_require__.d(
        __webpack_exports__,
        "p1",
        function () {
          return p1;
        }
      );
      /* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ =
        __webpack_require__(/*! ./utils */ "./src/utils.js");
      /* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0___default =
        /*#__PURE__*/ __webpack_require__.n(_utils__WEBPACK_IMPORTED_MODULE_0__);
  
      const page = "这是page";
      const p1 = "1";
      _utils__WEBPACK_IMPORTED_MODULE_0___default.a.time("cha");
      /* harmony default export */ __webpack_exports__["default"] = page;
    },
  
    "./src/utils.js": function (module, exports) {
      function time(type) {
        console.log(type, new Date());
      }
  
      module.exports = {
        time,
      };
    },
  });

我们能看到这一块代码 是重点,我们把它拆开来看:

1.匿名函数自调,传入模块定义

(function(modules){

})({
    "./src/index.js": function (
      module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony import */ var _page1__WEBPACK_IMPORTED_MODULE_0__ =
        __webpack_require__(/*! ./page1 */ "./src/page1.js");
      const { time } = __webpack_require__(/*! ./utils */ "./src/utils.js");
  
      const test = "加载了";
      time("home");
      console.log(
        _page1__WEBPACK_IMPORTED_MODULE_0__["default"],
        _page1__WEBPACK_IMPORTED_MODULE_0__["p1"],
        test
      );
  
      const btn = document.getElementById("btn");
      btn.addEventListener(
        "click",
        function () {
          __webpack_require__
            .e(/*! import() | async */ "async")
            .then(
              __webpack_require__.t.bind(null, /*! ./async */ "./src/async.js", 7)
            )
            .then((snyc) => {
              console.log(snyc);
            });
        },
        false
      );
    },
  
    "./src/page1.js": function (
      module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony export (binding) */ __webpack_require__.d(
        __webpack_exports__,
        "p1",
        function () {
          return p1;
        }
      );
      /* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ =
        __webpack_require__(/*! ./utils */ "./src/utils.js");
      /* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0___default =
        /*#__PURE__*/ __webpack_require__.n(_utils__WEBPACK_IMPORTED_MODULE_0__);
  
      const page = "这是page";
      const p1 = "1";
      _utils__WEBPACK_IMPORTED_MODULE_0___default.a.time("cha");
      /* harmony default export */ __webpack_exports__["default"] = page;
    },
  
    "./src/utils.js": function (module, exports) {
      function time(type) {
        console.log(type, new Date());
      }
  
      module.exports = {
        time,
      };
    },
  });

这样看,我们抛开匿名函数里webpack自己定义的函数及webpackBootstrap来看匿名函数调用时传递的参数modulesmodules就是我们的模块定义,数据结构就是键值对的对象:

  • 键就是我们的模块标识 公共资源访问路径+模块路径
  • 值是一个函数,函数里的内容就是我们原始模块里的内容,在这里是用的函数作用域做的隔离。

2.然后我们来看匿名函数里定义的内容,1-10

build.js 的匿名函数中 有我标识的01 - 17的注释步骤顺序,我们按这个来看,首先我们先来看01 - 10,11 - 17的是懒加载的内容,我们最后结合另一个chunk文件async.build.js再讲,步骤如下

  1. 定义模块缓存对象,加载过的模块会被缓存起来
  2. 定义内部自己的__webpack_require__ 原模块里的import 和 require都会被转成它 用来导入模块内容
  3. 定义__webpack_require__.m属性用于保存modules
  4. 定义__webpack_require__.c属性用于保存cache
  5. 定义__webpack_require__.o方法 用于判断对象身上是否存在指定的属性
  6. 定义__webpack_require__.d方法用于在对象的身上添加指定的属性
  7. 定义__webpack_require__.r方法 标记当前模块是一个__esModule,并且在支持es6时添加,获取类型时是一个module注:主要是为了处理混合使用 ES6 module 和 CommonJS 的情况
  8. 定义__webpack_require__.n方法用于设置获取cjs还是esm的getter,使用 CommonJS module.export = test2 导出函数,导入使用 ES6 module import test2 from './test2 会结合注释7、8使用
  9. 定义__webpack_require__.p属性 用于保存资源访问公共前缀路径
  10. 调用__webpack_require__ 方法执行模块导入与加载操作

到这里我们能看到__webpack_require__(__webpack_require__.s = './src/index.js') 通过它我们开始了从index.js 开始的入口载入.

我们来看 __webpack_require__的实现:

  • 判断缓存中是否有加载的模块 有的话直接返回它的exports
  • 如果缓存不存在 定义module对象并写入缓存及module
  • 调用moduleId对应的函数执行 加载内容到module.exports 中
  • 修改当前模块为已加载
  • 把拿回来的内容返回出去 return module.exports

我们看到了上述的第三步,会调用对应函数执行,按入口来讲也就是说会调用./src/index.js对应的函数

./src/index.js 对应的函数:

  • 通过__webpack_require__.r标记为esModule,因为它是esm规范导出的
  • 通过__webpack_require__加载 ./src/page1.js  ./src/utils.js

我们看__webpack_require__实现知道,导入会执行对应模块标识的函数,最后对应模块导出的内容都会被挂载到__webpack_require__内部的 module.exports对象上,那也就是说上述操作又会依次触发./src/page1.js  ./src/utils.js 对应的函数.

./src/page1.js 对应的函数:

  • 通过__webpack_require__.r来标记当前模块是一个esModule
  • 通过__webpack_require__.d 来给module.exports添加p1属性,及getter访问器
  • 通过__webpack_require__来加载./src/utils.js模块 (在esModule规范中导入了utils的commonJS规范导出的内容)
  • 通过__webpack_require__.n获取默认的导出(触发到上面所说的结合注释7、8,n的主要作用就是抹平两种导入的差异)
  • 调用导入的utils.time函数和导出默认default内容

./src/utils.js 对应的函数

该函数用的默认的符合webpack的commonJS规范导出,没有做任何处理。

截至到这里不同规范的加载流程我们就都看到了,接下来我们来讲懒加载模块,也就是11-17,如下:

  1. 定义jsonpArray = window["webpackJsonp"]数组 用来存储加载过的jsonp模块
  2. 保存window["webpackJsonp"]原来的push方法
  3. 重写jsonpArray.push 相当于也重写了 window["webpackJsonp"].push,重写为webpackJsonpCallback
  4. webpackJsonpCallback实现:合并懒加载模块到modules,并把懒加载模块promise状态改为成功态
  5. 定义installedChunks对象用于标识某个chunkid对应的chunk是否完成了加载(0已加载、数组[resolve,reject,promise]正在加载、undefined未加载、null块已预加载/预取)
  6. 实现__webpack_require__.e方法 使用 jsonp 来懒加载模块,并设置超时时间,返回promise,一旦promsie决议,代表模块加载完成。
  7. 实现__webpack_require__.t方法,t方法主要是根据模块标识取出对应的模块内容,根据mode对模块进行加工返回。(使用import()导入commonJS模块时,会用它加工)

然后我们看下async.build.js的内容:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  ["async"],
  {
    "./src/async.js": function (module, exports) {
      const value = "异步文件";
      function add() {
        consople.log("ssss");
      }

      module.exports = {
        value,
        add,
      };
    },
  },
]);

// [
//   [ ['async'],{... 模块对象} ]
// ]

看到这里我们也可以想下如果我们自己来实现的话,要怎么实现,如下:

  1. 首先使用 __webpack_require__.e() 下载动态资源,返回promise
  2. 然后下载完之后会添加到head里,这回会执行下载的async.build.js内容,它里面执行了window["webpackJsonp"].push()这个函数,而这个函数正好被我们重写过webpackJsonpCallback
  3. 那我们只需要在webpackJsonpCallback里面把 它push的这个懒加载模块合并到modules上,并且把这个installedChunks对应的懒加载模块改为下载完成,并且把__webpack_require__.e()返回的promise改为完成态,让他继续往下走就可以了。

然后我们再来看它的官方实现:

__webpack_require__.e
  1. 先查看该模块 ID 对应缓存的值是否为 0,0 代表已经加载成功了,第一次取值为 undefined
  2. 如果不为 0 并且不是 undefined 代表已经是加载中的状态。然后将这个加载中的 Promise 推入 promises 数组。
  3. 如果不为 0 并且是 undefined 就新建一个installedChunkData = installedChunks[chunkId] = [resolve,reject,promise]用于加载需要动态导入的模块,然后把 Promise推入 promises 数组。
  4. 生成一个 script 标签,URL 使用 jsonpScriptSrc(chunkId) 生成,即需要动态导入模块的 URL。
  5. 为这个 script 标签设置一个 2 分钟的超时时间,并设置一个 onScriptComplete() 函数,用于处理超时错误
  6. 然后添加到页面中 document.head.appendChild(script),开始加载模块。
  7. 返回 promises 数组

继续下一步当 JS 文件下载完成后,会自动执行文件内容。也就是说下载完 async.bundle.js 后,会执行 window["webpackJsonp"].push()

webpackJsonpCallback

对这个模块 ID 对应的 Promise 执行 resolve(),合并懒加载模块,同时将缓存对象中的值置为 0,表示已经加载完成了。这个函数还是挺好理解的。

然后按模块中btn点击加载完成之后的操作,来看:

    btn.addEventListener(
        "click",
        function () {
            __webpack_require__.e(/*! import() | async */ "async").then(
                __webpack_require__.t.bind(null, /*! ./async */ "./src/async.js", 7)  
            ).then((snyc) => {
                console.log(snyc);
            });
        },
        false
      );

执行完__webpack_require__.e之后意味着懒加载模块已经下载合并到modules中了,然后因为async.build.js时commonJS规范导出,所以需要__webpack_require__.t二次加工之后返回,最后这个then也就是我们原始模块中写的then处理了。

到这里基础的模块加载原理就分析完了,如果哪里还有不懂的建议把这个代码下载下来,打下断点调试一下,还是比较简单的。