资讯专栏INFORMATION COLUMN

webpack组织模块的原理 - 基础篇

leiyi / 2357人阅读

摘要:每一个模块的源代码都会被组织在一个立即执行的函数里。接下来看的生成代码可以看到,的源代码中关于引入的模块的部分做了修改,因为无论是,或是风格的,都无法被解释器直接执行,它需要依赖模块管理系统,把这些抽象的关键词具体化。

现在前端用Webpack打包JS和其它文件已经是主流了,加上Node的流行,使得前端的工程方式和后端越来越像。所有的东西都模块化,最后统一编译。Webpack因为版本的不断更新以及各种各样纷繁复杂的配置选项,在使用中出现一些迷之错误常常让人无所适从。所以了解一下Webpack究竟是怎么组织编译模块的,生成的代码到底是怎么执行的,还是很有好处的,否则它就永远是个黑箱。当然了我是前端小白,最近也是刚开始研究Webpack的原理,在这里做一点记录。

编译模块

编译两个字听起来就很黑科技,加上生成的代码往往是一大坨不知所云的东西,所以常常会让人却步,但其实里面的核心原理并没有什么难。所谓的Webpack的编译,其实只是Webpack在分析了你的源代码后,对其作出一定的修改,然后把所有源代码统一组织在一个文件里而已。最后生成一个大的bundle JS文件,被浏览器或者其它Javascript引擎执行并返回结果。

在这里用一个简单的案例来说明Webpack打包模块的原理。例如我们有一个模块mA.js

var aa = 1;

function inc() {
  aa++;
}

module.exports = {
  aa: aa,
  inc: inc
}

我随便定义了一个变量aa和一个函数inc,然后export出来,这里是用CommonJS的写法。

然后再定义一个app.js,作为main文件,仍然是CommonJS风格:

var mA = require("./mA.js");

console.log("mA.aa =" + mA.aa);
mA.inc();

现在我们有了两个模块,使用Webpack来打包,入口文件是app.js,依赖于模块mA.js,Webpack要做几件事情:

从入口模块app.js开始,分析所有模块的依赖关系,把所有用到的模块都读取进来。

每一个模块的源代码都会被组织在一个立即执行的函数里。

改写模块代码中和requireexport相关的语法,以及它们对应的引用变量。

在最后生成的bundle文件里建立一套模块管理系统,能够在runtime动态加载用到的模块。

我们可以看一下上面这个例子,Webpack打包出来的结果。最后的bundle文件总的来说是一个大的立即执行的函数,组织层次比较复杂,大量的命名也比较晦涩,所以我在这里做了一定改写和修饰,把它整理得尽量简单易懂。

首先是把所有用到的模块都罗列出来,以它们的文件名(一般是完整路径)为ID,建立一张表:

var modules = {
  "./mA.js": generated_mA,
  "./app.js": generated_app
}

关键是上面的generated_xxx是什么?它是一个函数,它把每个模块的源代码包裹在里面,使之成为一个局部的作用域,从而不会暴露内部的变量,实际上就把每个模块都变成一个执行函数。它的定义一般是这样:

function generated_module(module, exports, webpack_require) {
   // 模块的具体代码。
   // ...
}

在这里模块的具体代码是指生成代码,Webpack称之为generated code。例如mA,经过改写得到这样的结果:

function generated_mA(module, exports, webpack_require) {
  var aa = 1;
  
  function inc() {
    aa++;
  }

  module.exports = {
    aa: aa,
    inc: inc
  }
}

乍一看似乎和源代码一模一样。的确,mA没有require或者import其它模块,export用的也是传统的CommonJS风格,所以生成代码没有任何改动。不过值得注意的是最后的module.exports = ...,这里的module就是外面传进来的参数module,这实际上是在告诉我们,运行这个函数,模块mA的源代码就会被执行,并且最后需要export的内容就会被保存到外部,到这里就标志着mA加载完成,而那个外部的东西实际上就后面要说的模块管理系统。

接下来看app.js的生成代码:

function generated_app(module, exports, webpack_require) {
  var mA_imported_module = webpack_require("./mA.js");
  
  console.log("mA.aa =" + mA_imported_module["aa"]);
  mA_imported_module["inc"]();
}

可以看到,app.js的源代码中关于引入的模块mA的部分做了修改,因为无论是require/exports,或是ES6风格的import/export,都无法被JavaScript解释器直接执行,它需要依赖模块管理系统,把这些抽象的关键词具体化。也就是说,webpack_require就是require的具体实现,它能够动态地载入模块mA,并且将结果返回给app

到这里你脑海里可能已经初步逐渐构建出了一个模块管理系统的想法,一切的关键就是webpack_require,我们来看一下它的实现:

// 加载完毕的所有模块。
var installedModules = {};

function webpack_require(moduleId) {
  // 如果模块已经加载过了,直接从Cache中读取。
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }

  // 创建新模块并添加到installedModules。
  var module = installedModules[moduleId] = {
    id: moduleId,
    exports: {}
  };
  
  // 加载模块,即运行模块的生成代码,
  modules[moduleId].call(
    module.exports, module, module.exports, webpack_require);
  
  return module.exports;
}

注意倒数第二句里的modules就是我们之前定义过的所有模块的generated code:

var modules = {
  "./mA.js": generated_mA,
  "./app.js": generated_app
}

webpack_require的逻辑写得很清楚,首先检查模块是否已经加载,如果是则直接从Cache中返回模块的exports结果。如果是全新的模块,那么就建立相应的数据结构module,并且运行这个模块的generated code,这个函数传入的正是我们建立的module对象,以及它的exports域,这实际上就是CommonJS里exportsmodule的由来。当运行完这个函数,模块就被加载完成了,需要export的结果保存到了module对象中。

所以我们看到所谓的模块管理系统,原理其实非常简单,只要耐心将它们抽丝剥茧理清楚了,根本没有什么深奥的东西,就是由这三个部分组成:

// 所有模块的生成代码
var modules;
// 所有已经加载的模块,作为缓存表
var installedModules;
// 加载模块的函数
function webpack_require(moduleId);

当然以上一切代码,在整个编译后的bundle文件中,都被包在一个大的立即执行的匿名函数中,最后我们需要执行的就是这么一句话:

return webpack_require("./app.js");

即加载入口模块app.js,当运行它时,就会运行generated_app;而它需要载入模块mA,于是就会运行webpack_require("./mA.js"),进而运行generated_mA。也就是说,所有的依赖的模块就是这样动态地、递归地在runtime完成加载,并被放入InstalledModules缓存。

Webpack真正生成的代码和我上面整理的结构略有不同,它大致是这样:

(function(modules) {
  var installedModules = {};
  
  function webpack_require(moduleId) {
     // ...
  }

  return webpack_require("./app.js");
}) ({
  "./mA.js": generated_mA,
  "./app.js": generated_app
});

可以看到它是直接把modules作为立即执行函数的参数传进去的而不是另外定义的,当然这和我的写法没什么本质不同,我做这样的改写是为了解释起来更清楚。

ES6的importexport

以上的例子里都是用传统的CommonJS的写法,现在更通用的ES6风格是用importexport关键词,它们看上去似乎只是语法糖,但实际上根据ES6的标准,它们和CommonJS在关于模块加载的使用和行为上会有一些微妙的不同。例如当CommonJS输出原始类型(非对象)变量时,输出的是这个变量的拷贝,这样一旦模块加载后,再去修改这个内部变量的值,是不会影响到输出的变量的;而ES6输出的则是引用,这样无论模块内部出现什么修改,都会反映在已经加载的模块上。关于ES6和CommonJS在模块管理上的区别,如果你还不熟悉的话,建议先读一下阮一峰大神的这篇文章。

对于Webpack或者其它模块管理系统而言,要实现ES6特性的import/export,本质上还是和require/exports类似的,也就是说仍然使用module.export这一套机制,但是情况会变得比较复杂,因为可能存在CommonJS和ES6模块之间的相互引用。为了保持兼容,并且符合ES6的相应标准,Webpack在生成相应语句的generated code时,就要做很多特殊处理,关于这一块内容很多,深究起来可以多带带写一篇文章,在这里我只是把我理解的部分写出来。

export原始类型变量

对于CommonJS而言,export的是很直接的,因为源代码里module.exports输出什么,生成代码里的输出也原样不变,例如我们之前定义的模块mA.js

var aa = 1;
function inc() {
  aa++;
}

function get_aa() {
  return aa;
}

module.exports = {
  aa: aa,
  inc: inc,
  get_aa: get_aa;
}

生成代码里,module.exports也会像源代码里这样写,注意这里输出的时候,aa作为一个原始类型,输出到module.exports里的是一个拷贝,这样一旦模块mA加载后,再去调用inc(),修改的是模块内部的aa,而不会影响输出后的aa:

var mA = require("./mA.js");

console.log("mA.aa = " + mA.aa);  // 输出1
mA.inc();
console.log("mA.aa = " + mA.aa);  // 仍然是1

// 这里会输出2,因为get_aa()拿到的是模块内部的aa原始引用。
console.log("mA.get_aa() = " + mA.get_aa());

然而ES6就完全不是这么一回事儿了,假如上面的模块mA,我们用ES6输出:

export {aa, inc, get_aa}

然后在别的模块里加载mA

import {aa, inc} from "./mA.js"

console.log("aa = " + aa);  // 输出1
inc();
console.log("aa = " + aa);  // 输出2

这里不管mA输出的是什么类型的数据,输出的都是它的引用,当别的模块载入mA时,得到的也是mA模块内部变量的引用。要实现这个规则,mAgenerated code就不能简单地直接给module.exports设置aa这个原始变量类型了,而是像上面的get_aa那样,给它定义getter。例如当我们export aa,Webpack会生成类似于这样的代码:

var aa = 1;

defineGetter(module.exports, “aa”, function(){ return aa; });

defineGetter的定义如下:

function defineGetter(exports, name, getter) {
  if (!Object.prototype.hasOwnProperty.call(exports, name)) {
    Object.defineProperty(exports, name, {
      configurable: false,
      enumerable: true,
      get: getter,
    });
  }
}

这样就实现了我们需要的引用功能,也就是说,在module.exports上,我们并不是定义aa这个原始类型,而是定义aa的getter,使之指向其原模块内部aa的引用。

不过对于export default,当输出原始类型时,它又回到了拷贝,而不是getter引用的方式,即对于这样的输出:

export default aa;

Webpack会生成这样的代码:

module.exports["default"] = aa;

我还没完全弄清楚这样做是否符合ES6标准,懂的童鞋可以留下评论。

当然话说回来,模块中直接输出aa这样的原始类型的变量还是挺少见的,但并非不可能。源代码一旦有这样的行为,ES6和CommonJS就会表现出完全不同的特性,所以Webpack也必须实现这种区别。

__esModule

Webpack对ES6模块输出的另一个特殊处理是__esModule,例如是我们定义ES6模块mB.js:

let x = 3;

let printX = () => {
  console.log("x = " + x);
}

export {printX}
export default x

它使用了ES6的export,那么Webpack在mB的generated code会加上一句话:

function generated_mB(module, exports, webpack_require) {
  Object.defineProperty(module.exports, "__esModule", {value: true});
  // mB的具体代码
  // ....
}

也就是说,它给mB的export标注了一个__esModule,说明它是ES6风格的export。为什么要这样做?因为当别人引用一个模块时,它并不知道这个模块是以CommonJS还是ES6风格输出的,所以__esModule的意义就在于告诉别人,这是一个ES6模块。关于它的具体作用我们继续看下面的import部分。

import

这是一种比较简单的import方式:

import {aa} from "./mA.js"
// 基本等价于
var aa = require("./mA.js")["aa"]

但是当别人这样引用时:

import m from "./m.js"

情况会稍微复杂一点,它需要载入模块mexport default部分,而模块m可能并非是由ES6的export来写的,也可能根本没有export default。这样在其它模块中,当一个依赖模块以类似import m from "./m.js"这样的方式加载时,必须首先判断得到的是不是一个ES6 export出来的模块。如果是,则返回它的default,如果不是,则返回整个export对象。例如上面的mA是传统CommonJS的,mB是ES6风格的:

// mA is CommonJS module
import mA from "./mA.js"
console.log(mA);

// mB is ES6 module
import mB from "./mB.js"
console.log(mB);

这就用到了之前export部分的__esModule了。我们定义get_export_default函数:

function get_export_default(module) {
  return module && module.__esModule? module["default"] : module;
}

这样generated code运行后在mAmB上会得到不同的结果:

var mA_imported_module = webpack_require("./mA.js");
// 打印完整的 mA_imported_module
console.log(get_export_default(mA_imported_module));

var mB_imported_module = webpack_require("./mB.js");
// 打印 mB_imported_module["default"]
console.log(get_export_default(mB_imported_module));

以上就是在ES6的import/export上,Webpack需要做很多特殊处理的地方。我的分析还并不完整,建议感兴趣的童鞋自己去敲一下并且阅读编译后的代码,仔细比较CommonJS和ES6的不同。不过就实现而言,它们并没有本质区别,而且Webpack最后生成的generated code也还是基于CommonJS的module/exports这一套机制来实现模块的加载的。

模块管理系统

以上就是Webpack如何打包组织模块,实现runtime模块加载的解读,其实它的原理并不难,核心的思想就是建立模块的管理系统,而这样的做法也是具有普遍性的,如果你读过Node.js的Module部分的源代码,就会发现其实用的是类似的方法。这里有一篇文章可以参考。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/93258.html

相关文章

  • Angular开山

    摘要:环境搭建今天给大家介绍种环境搭建的方法。官方的地址步骤安装种子文件没有的,可以自己下来,然后打开,执行。使用版本为版本。存放表单相关内置组件与指令。存放网络请求相关的服务等。等待加载完毕即可。从而实现了页面的显示 1:环境搭建 今天给大家介绍4种环境搭建的方法。 一:Angular-cli的安装 官方指导文档:www.angular.cn/guide/quickstart 请使用cn...

    Edison 评论0 收藏0
  • webpack组织模块原理 - external模块

    摘要:所以通常情况下当你的库需要依赖到例如,这样的通用模块时,我们可以不将它打包进,而是在的配置中声明这就是在告诉请不要将这个模块注入编译后的文件里,对于我源代码里出现的任何这个模块的语句,请将它保留。 这篇文章讨论Webpack打包library时经常需要用到的一个选项external,它用于避免将一些很通用的模块打包进你发布的library里,而是选择把它们声明成external的模块,...

    Lavender 评论0 收藏0
  • Webpack系列-第一基础杂记

    摘要:系列文章系列第一篇基础杂记系列第二篇插件机制杂记系列第三篇流程杂记前言公司的前端项目基本都是用来做工程化的,而虽然只是一个工具,但内部涉及到非常多的知识,之前一直靠来解决问题,之知其然不知其所以然,希望这次能整理一下相关的知识点。 系列文章 Webpack系列-第一篇基础杂记 Webpack系列-第二篇插件机制杂记 Webpack系列-第三篇流程杂记 前言 公司的前端项目基本都是用...

    Batkid 评论0 收藏0
  • 前端每周清单半年盘点之 JavaScript

    摘要:前端每周清单专注前端领域内容,以对外文资料的搜集为主,帮助开发者了解一周前端热点分为新闻热点开发教程工程实践深度阅读开源项目巅峰人生等栏目。背后的故事本文是对于年之间世界发生的大事件的详细介绍,阐述了从提出到角力到流产的前世今生。 前端每周清单专注前端领域内容,以对外文资料的搜集为主,帮助开发者了解一周前端热点;分为新闻热点、开发教程、工程实践、深度阅读、开源项目、巅峰人生等栏目。欢迎...

    Vixb 评论0 收藏0
  • webpack组织模块原理 - 打包Library

    摘要:所以你编译后的文件实际上应当只输出,这就需要在配置里用来控制这样上面的模块加载函数会在返回值后面加一个,这样就只返回的部分。 之前一篇文章分析了Webpack打包JS模块的基本原理,所介绍的案例是最常见的一种情况,即多个JS模块和一个入口模块,打包成一个bundle文件,可以直接被浏览器或者其它JavaScript引擎执行,相当于直接编译生成一个完整的可执行的文件。不过还有一种很常见的...

    legendmohe 评论0 收藏0

发表评论

0条评论

leiyi

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<