阅读 3762

从简单的例子看 webpack 模块加载机制及思考原理

webpack 自己实现了一套模块机制,无论是 CommonJS 模块的 require 语法还是 ES6 模块的 import 语法,都能够被解析并转换成指定环境的可运行代码,以 web 为例看看 webpack 如何来实现模块机制。

示例代码

index.js

import foo from './foo'
import bar from './bar'

console.log('run => index.js')
console.log(`log => foo.name: ${foo.name}`)
console.log(`log => bar.name: ${bar.name}`)

export default {
  name: 'index'
}
复制代码

foo.js

import bar from './bar'

console.log('run => foo.js')
console.log(`log => bar.name: ${bar.name}`)

export default {
  name: 'foo'
}
复制代码

bar.js

console.log('run => bar.js')

export default {
  name: 'bar'
}
复制代码

webpack.config.js

const path = require('path')

module.exports = {
  entry: './src/index',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
}
复制代码

webpack 打包后的代码 bundle.js 概览

(function(modules) {
  // webpackBootstrap
})([
  /* 0 */
  (function(module, __webpack_exports__, __webpack_require__) {
    // bar.js
  }),
  /* 1 */
  (function(module, __webpack_exports__, __webpack_require__) {
    // index.js
  }),
  /* 2 */
  (function(module, __webpack_exports__, __webpack_require__) {
    // foo.js
  })
]);
复制代码

将打包之后的代码简化后非常清晰,就是一个立即调用函数表达式(IIFE:Immediately-Invoked Function Expression)。执行函数 webpackBootstrap 有一个形参 modules,对应的实参是一个数组,数组包含多个函数代码块,每个函数代码块都表示一个模块,拥有 module __webpack_exports__ __webpack_require__ 三个形参。通过注释不难发现,每个模块对应的就是一个文件,并且拥有一个 id 值,根据 id 值大小顺序添加进 modules 数组。

webpackBootstrap 启动函数的执行

代码的执行框架搭建好了,那启动函数 webpackBootstrap 内究竟做了什么让模块之间联系提来?模块的三个形参到底是什么?

先瞧瞧被简化后的 webpackBootstrap 函数核心代码:

(function(modules) { // webpackBootstrap
  // The module cache
  var installedModules = {};

  function __webpack_require__(moduleId) {
    // Check if module is in cache
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    // Flag the module as loaded
    module.l = true;

    // Return the exports of the module
    return module.exports;
  }
})([...])
...

// Load entry module and return exports
return __webpack_require__(__webpack_require__.s = 1);
复制代码

没有删掉注释,建议仔细瞧瞧,很容易就能明白模块是如何正确加载并运行起来的。

首先定义了一个 installedModules 对象,它的作用是用来缓存已经加载过的模块,然后声明 __webpack_require__ 函数,似曾相识对吧,没错,它就是模块的第三个形参对应的实参。最后返回 __webpack_require__(1) 执行结果。

那再看看 __webpack_require__ 函数,顾名思义,它就是用来加载模块的函数,接收一个 moduleId 的形参,也就是模块的 id 值。

首先判断 moduleId 对应的模块是否已被缓存,也就是在 installedModules 对象中能不能找到属性 moduleId 对应的值,如果找到了,则直接返回模块的输出 exports。从这里可以发现,无论被多少个模块所依赖的模块都只会被加载一次,结果相同,因为返回的是同一个对象的引用地址,所以如果某个模块修改了对象内的属性值,则会被同步反应到其它依赖此模块的对象。

继续,当模块没有被加载过的情况下,定义一个模块对象,同步加入 installedModules 对象缓存起来,模块对象包含三个属性,i 表示模块 id 值,l 表示是否被加载,exports 表示模块的输出结果对象。

接着就是模块执行的调用函数,通过 moduleId 从 modules 内找到模块的函数代码块,使用 call 方法绑定函数内的 this 指向 module.exports,传入三个实参 module module.exports __webpack_require__,与模块函数的形参一一对应。

当模块函数执行完成并返回结果之后,模块标识为已加载状态,最后返回模块的输出对象。

模块函数的执行

目前浏览器还没有完全支持 ES6 模块语法,所以模块内的 import 语法会如何处理?以 foo.js 为例来瞧瞧模块函数代码块内的代码:

/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__bar__ = __webpack_require__(0);


console.log('run => foo.js')
console.log(`log => bar.name: ${__WEBPACK_IMPORTED_MODULE_0__bar__["a" /* default */].name}`)

/* harmony default export */ __webpack_exports__["a"] = ({
  name: 'foo'
});
复制代码

原来 import 语法被转换了,模块名称变为一个变量名称,值是使用 __webpack_require__ 函数根据依赖模块 id 值获取的输出结果,并且模块函数内的所有依赖模块名称都被转换成对应的变量名称,模块的输出结果被绑定在 __webpack_exports__ 对象中,这里 module.exports === __webpack_exports__,等于就是模块的输出。

总结

webpack 的模块机制包含三大要点:

  1. modules 保存所有模块
  2. __webpack_require__ 函数加载模块
  3. installedModules 对象缓存模块

通过以上全部分析,webpack 实现模块机制的原理我也思考出了一个大概的轮廓,下面是我个人理解的描述,如有错误,还望有大佬能够指点、指教,不胜感激。

  1. 使用 acorn 将代码解析成 AST(抽象语法树)

    从入口(entry)模块开始,以及后续各个依赖模块。

  2. 分析 AST 根据关键词 import``require 加载并确定模块之间的依赖关系、标识 id 值以及其它

    Dependency Graph

  3. 生成输出内容的 AST 并将模块的 AST 根据 id 值顺序插入 modules 的 AST

    输出内容是打包后输出的文件内容,模块 AST 会被包裹之后插入(原本的模块代码会被包裹进函数内)。

  4. 修改 AST 将各模块引入依赖模块的语法进行转换并将模块内所有的依赖标识进行对应替换

    import 语法转换和模块标识替换,上文有描述。

  5. 输出结果

以上只是大概轮廓,还有很多细节没有涉及到,比如 helper 代码、转换规则、模块类型等等。经过此番思考,对模块的加载也算是有了较为深刻的理解,在此之上,也能够对 webpack 更多其它特性进行探索,比如 code-splitting、tree-shaking、scope hoisting 等等,路还很长,一步一个脚印。

文章分类
前端