webpack 模块打包原理

112 阅读10分钟

为什么需要webpack

前端发展迅速,承担业务越来越重,js代码日益复杂。为了简化开发的复杂度,前端社区涌现了很多好的实践方法

  • 模块化,把复杂的程序拆分为小的模块。
  • js编译,使用TypeScript拓展开发语言,在各大引擎对js新特性支撑不够情况下,使用es6等新语法。
  • css编译,scss、less等css预编译处理器
  • ......

这些方法确实大大的提高了我们的开发效率,但是利用它们开发的文件往往需要进行额外的处理才能让浏览器识别,而手动处理又是非常繁琐的,需要webpack类工具进行自动化处理。

webpack介绍

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

一、webpack核心概念:
  1. Entry:入口起点(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。

  2. Output:output 指定所创建bundles的输出地址,默认为 ./dist。

  3. Loader:loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,webpack默认仅支持js文件。

  4. Plugins:plugins(插件)可以用于执行范围更广的任务。插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量等。

  5. Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。

  6. Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

  7. Bundle:打包块,由多个不同的模块生成,bundles 包含了早已经过加载和编译的最终源文件版本。

二、module、chunk、bundle的区别

module,chunk 和 bundle 其实就是同一份逻辑代码在不同转换场景下的取了三个名字: 我们直接写出来的是 module,webpack 处理时是 chunk,最后生成浏览器可以直接运行的 bundle。

image.png

2.1 module

非连续的功能块,将一个个js、css拆分成一个个小模块。对于webpack来说,项目源码中所有资源(包括 JS、CSS、Image、Font 等等)都属于 module 模块。可以配置指定的 Loader 去处理这些文件。

webpack可识别模块规范

image.png

2.2 chunk

chunk用于webpack内部管理bundling的过程中,有三种产生方式。通常,chunks直接与bundles相对应,然而,有一些配置可以使其不是一对一的关系。

产生chunk的三种途径

  1. entry入口
  2. 异步加载模块
  3. 代码分割(code spliting),配置splitChunks。
2.3 bundle

由多个不同的模块生成,bundles 包含了早已经过加载和编译的最终源文件版本。

通常我们会弄混这两个概念,以为Chunk就是Bundle,Bundle就是我们最终输出的一个或多个打包文件。

确实,大多数情况下,一个Chunk会生产一个Bundle。但有时候也不完全是一对一的关系,比如我们配置MiniCssExtractPlugin

plugins: [ 
    // 用 MiniCssExtractPlugin 抽离出 css 文件,以 link 标签的形式引入样式文件 
    new MiniCssExtractPlugin({ 
        filename: 'index.bundle.css' // 输出的 css 文件名为 index.css 
    }), 
]

打包的结果如下

image.png

webpack模块打包

我们知道js模块规范有很多,那webpack是如何去解析不同的模块呢?

webpack根据webpack.config.js中的入口文件,在入口文件里识别模块依赖,不管这里的模块依赖是用CommonJS写的,还是ES6 Module规范写的,webpack会自动进行分析,并通过转换、编译代码,打包成最终的文件。

最终文件中的模块实现是基于webpack自己实现的webpack_require(es5代码),所以打包后的文件可以跑在浏览器上。

所以在webapck环境下,你可以只使用ES6 模块语法书写代码(通常我们都是这么做的),也可以使用CommonJS模块语法,甚至可以两者混合使用。因为从webpack2开始,内置了对ES6、CommonJS、AMD 模块化语句的支持,webpack会对各种模块进行语法分析,并做转换编译

我们举个例子来分析下打包后的源码文件

// webpack.config.js
const path = require('path');

module.exports = {
    mode: 'development',
  // JavaScript 执行入口文件
  entry: './src/main.js',
  output: {
    // 把所有依赖的模块合并输出到一个 bundle.js 文件
    filename: 'bundle.js',
    // 输出文件都放到 dist 目录下
    path: path.resolve(__dirname, './dist'),
  }
};
// src/add
export default function(num1, num2) {
    let { name } = { name: 'total: '} // 这里特意使用了ES6语法
    return name + ( a + b )
}

// src/main.js
import Add from './add'
console.log(Add, Add(1, 2))

打包后精简的bundle.js文件如下:

// modules是存放所有模块的数组,数组中每个元素存储{ 模块路径: 模块导出代码函数 }
(function(modules) {
// 模块缓存作用,已加载的模块可以不用再重新读取,提升性能
var installedModules = {};

// 关键函数,加载模块代码
// 形式有点像Node的CommonJS模块,但这里是可跑在浏览器上的es5代码
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.l = true; // 标记为已加载

  // 返回加载的模块,调用方直接调用即可
  return module.exports;
}

// __webpack_require__对象下的r函数
// 在module.exports上定义__esModule为true,表明是一个模块对象
__webpack_require__.r = function(exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
};

// 启动入口模块main.js
return __webpack_require__(__webpack_require__.s = "./src/main.js");
})
({
  // add模块
  "./src/add.js": (function(module, __webpack_exports__, __webpack_require__) {
    // 在module.exports上定义__esModule为true
    __webpack_require__.r(__webpack_exports__);
    // 直接把add模块内容,赋给module.exports.default对象上
    __webpack_exports__["default"] = (function(num1, num2) {
      let { name } = { name: 'total,'}
      return name + ( num1 + num2 )
    });
  }),

  // 入口模块
  "./src/main.js": (function(module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__)
    // 拿到add模块的定义
    // _add__WEBPACK_IMPORTED_MODULE_0__ = module.exports,有点类似require
    var _add__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/add.js");
    // add模块内容: _add__WEBPACK_IMPORTED_MODULE_0__["default"]
    console.log(_add__WEBPACK_IMPORTED_MODULE_0__["default"], Object(_add__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2))
  })
});

以上核心代码中,能让打包后的代码直接跑在浏览器中,是因为webpack通过__webpack_require__ 函数模拟了模块的加载(类似于node中的require语法),把定义的模块内容挂载到module.exports上。

webpack模块异步加载

以上webpack把所有模块打包到主文件中,所以模块加载方式都是同步方式。但在开发应用过程中,按需加载(也叫懒加载)也是经常使用的优化技巧之一。按需加载,通俗讲就是代码执行到异步模块(模块内容在另外一个js文件中),通过网络请求即时加载对应的异步模块代码,再继续接下去的流程。那webpack在执行代码时,是如何判断哪些代码是异步模块呢?webpack又是如何加载异步模块呢?

webpack有个require.ensure (opens new window)api语法来标记为异步加载模块,最新的webpack4推荐使用新的import() (opens new window)api(需要配合@babel/plugin-syntax-dynamic-import插件)。因为require.ensure是通过回调函数执行接下来的流程,而import()返回promise,这意味着可以使用最新的ES8 async/await语法,使得可以像书写同步代码一样,执行异步流程。

现在我们从webpack打包后的源码来看下,webpack是如何实现异步模块加载的。修改入口文件main.js,引入异步模块async.js:

// main.js
import Add from './add'
console.log(Add, Add(1, 2), 123)

// 按需加载
// 方式1: require.ensure
// require.ensure([], function(require){
//     var asyncModule = require('./async')
//     console.log(asyncModule.default, 234)
// })

// 方式2: webpack4新的import语法
// 需要加@babel/plugin-syntax-dynamic-import插件
let asyncModuleWarp = async () => await import('./async')
console.log(asyncModuleWarp().default, 234)
// async.js
export default function() {
    return 'hello, aysnc module'
}

以上代码打包会生成两个chunk文件,分别是主文件main.bundle.js以及异步模块文件0.bundle.js。同样,为方便读者快速理解,精简保留主流程代码。

// 0.bundle.js

// 异步模块
// window["webpackJsonp"]是连接多个chunk文件的桥梁
// window["webpackJsonp"].push = 主chunk文件.webpackJsonpCallback
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  [0], // 异步模块标识chunkId,可判断异步代码是否加载成功
  // 跟同步模块一样,存放了{模块路径:模块内容}
  {
  "./src/async.js": (function(module, __webpack_exports__, __webpack_require__) {
      __webpack_require__.r(__webpack_exports__);
      __webpack_exports__["default"] = (function () {
        return 'hello, aysnc module';
      });
    })
  }
]);

以上知道,异步模块打包后的文件中保存着异步模块源代码,同时为了区分不同的异步模块,还保存着该异步模块对应的标识:chunkId。以上代码主动调用window["webpackJsonp"].push函数,该函数是连接异步模块与主模块的关键函数,该函数定义在主文件中,实际上window["webpackJsonp"].push = webpackJsonpCallback,详细源码咱们看看主文件打包后的代码:

// main.bundle.js

(function(modules) {
// 获取到异步chunk代码后的回调函数
// 连接两个模块文件的关键函数
function webpackJsonpCallback(data) {
  var chunkIds = data[0]; //data[0]存放了异步模块对应的chunkId
  var moreModules = data[1]; // data[1]存放了异步模块代码

  // 标记异步模块已加载成功
  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;
  }

  // 把异步模块代码都存放到modules中
  // 此时万事俱备,异步代码都已经同步加载到主模块中
  for(moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId];
  }

  // 重点:执行resolve() = installedChunks[chunkId][0]()返回promise
  while(resolves.length) {
    resolves.shift()();
  }
};

// 记录哪些chunk已加载完成
var installedChunks = {
  "main": 0
};

// __webpack_require__依然是同步读取模块代码作用
function __webpack_require__(moduleId) {
  ...
}

// 加载异步模块
__webpack_require__.e = function requireEnsure(chunkId) {
  // 创建promise
  // 把resolve保存到installedChunks[chunkId]中,等待代码加载好再执行resolve()以返回promise
  var promise = new Promise(function(resolve, reject) {
    installedChunks[chunkId] = [resolve, reject];
  });

  // 通过往head头部插入script标签异步加载到chunk代码
  var script = document.createElement('script');
  script.charset = 'utf-8';
  script.timeout = 120;
  script.src = __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle.js"
  var onScriptComplete = function (event) {
    var chunk = installedChunks[chunkId];
  };
  script.onerror = script.onload = onScriptComplete;
  document.head.appendChild(script);

  return promise;
};

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// 关键代码: window["webpackJsonp"].push = webpackJsonpCallback
jsonpArray.push = webpackJsonpCallback;

// 入口执行
return __webpack_require__(__webpack_require__.s = "./src/main.js");
})
({
"./src/add.js": (function(module, __webpack_exports__, __webpack_require__) {...}),

"./src/main.js": (function(module, exports, __webpack_require__) {
  // 同步方式
  var Add = __webpack_require__("./src/add.js").default;
  console.log(Add, Add(1, 2), 123);

  // 异步方式
  var asyncModuleWarp =function () {
    var _ref = _asyncToGenerator( regeneratorRuntime.mark(function _callee() {
      return regeneratorRuntime.wrap(function _callee$(_context) {
        // 执行到异步代码时,会去执行__webpack_require__.e方法
        // __webpack_require__.e其返回promise,表示异步代码都已经加载到主模块了
        // 接下来像同步一样,直接加载模块
        return __webpack_require__.e(0)
              .then(__webpack_require__.bind(null, "./src/async.js"))
      }, _callee);
    }));

    return function asyncModuleWarp() {
      return _ref.apply(this, arguments);
    };
  }();
  console.log(asyncModuleWarp().default, 234)
})
});

从上面源码可以知道,webpack实现模块的异步加载有点像jsonp的流程。在主js文件中通过在head中构建script标签方式,异步加载模块信息;再使用回调函数webpackJsonpCallback,把异步的模块源码同步到主文件中,所以后续操作异步模块可以像同步模块一样。 源码具体实现流程:

  1. 遇到异步模块时,使用__webpack_require__.e函数去把异步代码加载进来。该函数会在html的head中动态增加script标签,src指向指定的异步模块存放的文件。
  2. 加载的异步模块文件会执行webpackJsonpCallback函数,把异步模块加载到主文件中。
  3. 所以后续可以像同步模块一样,直接使用__webpack_require__("./src/async.js")加载异步模块。

总结

  1. webpack对于ES模块/CommonJS模块的实现,是基于自己实现的webpack_require,所以代码能跑在浏览器中。
  2. 从 webpack2 开始,已经内置了对 ES6、CommonJS、AMD 模块化语句的支持。但不包括新的ES6语法转为ES5代码,这部分工作还是留给了babel及其插件。
  3. 在webpack中可以同时使用ES6模块和CommonJS模块。因为 module.exports很像export default,所以ES6模块可以很方便兼容 CommonJS:import XXX from 'commonjs-module'。反过来CommonJS兼容ES6模块,需要额外加上default:require('es-module').default。
  4. webpack异步加载模块实现流程跟jsonp基本一致。