[前端视野]前端模块化以及webpack的模块化实现机制| 8月更文挑战

684 阅读12分钟

前言

之前在[前端视野]前端工程化的前世今生这篇文章中提到了webpack的模块化实现,当时由于篇幅的原因,没有具体的展开。让我们回忆一下这个问题:webpack是怎么在不支持模块化的浏览器中实现模块化的呢?今天就带着这个问题介绍下webpack的模块化实现机制。

参考资料:阮一峰老师的模块化介绍

什么是模块化?

模块化是指解决一个复杂问题时自顶向下逐层把系统划分成若干模块的过程。把一个大的工程划分成一个个小的模块,每个模块互相独立,又可以互相依赖。把一个复杂的系统比作一辆汽车,那么发动机、底盘、车身、电气设备等就可以比作一个个模块,每个模块负责独立的功能,又相互依赖彼此。

为什么前端需要模块化?

[前端视野]前端工程化的前世今生这篇文章中,提到过这个问题。因为JavaScript是一门解释型脚本语言,一开始的作用是处理web页面的一些表单提交逻辑和一些简单的交互逻辑,那时前端并不需要模块化。而随着Ajaxnode的兴起,前端在一个web应用中承担了更多更复杂的任务。前端在业务中的比重的增加也意味着更多的代码量,所以工程化以及代码层面的模块化在前端发展中就是必须的。

最初的模块化实现

1.原始模式

function module1() {
  var status = 0;
  // ...
}

function module2() {
  var status = 0;
  // ...
}

原始模式的模块化,一个函数就是一个模块,使用的时候直接调用这个函数就行。这样的模块化实现方式,所有的函数(模块)都暴露在全局环境下,如果是多人协作开发,全局环境命名冲突就够让人头大的了。

2.对象写法

var module = {
  status: 0,
  
  module1: function() {
    var status = 0;
    // ...
  },
  
  module2: function() {
    var status = 0;
    // ...
  }
}

对象写法实现的模块化,可以通过module.status的方式来获取模块内部的元素。但是这样的模块化实现,会导致外部可以改变其内部的状态:module.status = 1

3.立即执行函数写法

var module = (function() {
  var status = 0;

  function add() {
    status++;
  }
  
  function sub() {
    status--;
  }

  function val() {
    return status;
  }

  return {
    val,
    add,
    sub
  }
})()

console.log(module.val()); // 0
module.add();
console.log(module.val()); // 1
module.sub();
console.log(module.val()); // 0

通过一个立即执行函数返回一个module对象,这种方式是通过闭包实现的,如果还不了解闭包的原理,请点击这里了解。这样,其内部的status变量就无法被外部所更改,只能通过其内部暴露出来的addsub等函数对其内部变量做修改。那么如果一个模块很大,需要拆分成好几个更小的模块,或者一个模块继承自另外一个模块时,需要怎么做呢?

4.放大模式

var module = (function() {
  var status = 0;

  function add() {
    status++;
  }
  
  function sub() {
    status--;
  }

  function val() {
    return status;
  }

  return {
    val,
    add,
    sub
  }
})()

var module2 = (function(mod){
  mod.more = function() {
    // ...
  }

  return mod;
})(module)

console.log(module2)

module2暴露除了一个module接口,接受另一个模块作为入参,可以继承另一个模块的属性和方法,并且加入自己定义的方法。控制台打印:

image.png

到这里基本介绍了最初的模块化实现机制:使用闭包来构建一个局部作用域(函数作用域),把这个局部作用域作为一个模块来实现模块化机制。但是这只是一种在没有模块化规范的情况下的特殊手段,所以后续提出了CommonJSAMD等模块化方案,并且最终在ES6中实现了esModule。先让我们先来看下这些模块化方案到底是什么吧。

CommonJS模块化方案

在2009年,nodejs问世,作为一个JavaScript的服务端环境,不同于浏览器端,是必须要有模块化的规范的。需要操作文件、与操作系统交互、与其他程序交互,这些需要作为一些独立的模块抽取出来。而node的模块化规范,是基于CommonJS规范实现的。

CommonJS模块的导入

CommonJS规范,规定了每个.js文件都是一个模块,使用require方法进行模块的导入:

const a = require('./b.js');

CommonJS模块的导出

CommonJS规范,规定每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。

const a = require('./b.js');

const status = 0;

module.exports = {
  status
}

webpack对CommonJS的实现

因为低版本的浏览器对于模块化是不支持的,所以在前端工程化的今天,我们需要通过一些工具来帮助我们转换我们的模块化代码,借此来运行在低版本的浏览器上(目前谷歌等一些高版本的浏览器已经支持了es6Module)。我们来看下webpack是如何实现CommonJS规范的。

首先,我们先执行如下命令,新起一个webpack-demo的项目。

mkdir webpack-demo && cd webpack-demo
npm init -y
npm install webpack webpack-cli --save-dev

然后在项目里创建一个src文件夹,里面新建一个index.jsmodule1.js两个文件:

index.js:

const module1 = require("./module1");

const num = module1.status + 12;

module.exports = {
  num,
};

module1.js:

const status = 0;

module.exports = {
  status,
};

然后我们再配置webpack,新创建一个webpack.config.js文件在根目录:

webpack.config.js

var path = require("path");

module.exports = {
  entry: path.join(__dirname, "./src/index.js"),
  output: {
    path: path.join(__dirname, "dist"),
    filename: "index.js",
  },
  mode: "development",
};

这里要注意配置mode: "development",因为我们需要查看打包后的具体的内容,不然webpack会帮我们压缩代码。

到目前为止一切准备就绪,我们在根目录执行webpack命令:

image.png

具体实现

可以看到生成了一个dist文件夹,里面有一个index.js的文件,让我们来看看它究竟是什么。因为打包后的代码粗略的看上去,非常的凌乱,所以我这里把大部分注释先给去掉。我们将代码分为三部分来看:

image.png

第一部分

/***********************************第一部分*************************************/
var __webpack_modules__ = {
    "./src/index.js": ( module, __unused_webpack_exports, __webpack_require__) => {
      eval(
        'const module1 = __webpack_require__(/*! ./module1 */ "./src/module1.js");\r\n\r\nconst num = module1.status + 12;\r\n\r\nmodule.exports = {\r\n  num,\r\n};\r\n\n\n//# sourceURL=webpack://webpack-demo/./src/index.js?'
      );
    },
    "./src/module1.js": (module) => {
      eval(
        "const status = 0;\r\n\r\nmodule.exports = {\r\n  status,\r\n};\r\n\n\n//# sourceURL=webpack://webpack-demo/./src/module1.js?"
      );
    },
  };

首先,我们可以看到它是一个自执行函数,能看到有一个__webpack_modules__的对象,他就是我们写的所有的模块(上面的index.jsmodule1.js)的一个集合。这个对象的键名是引用路径,值则是一个函数,这个函数有三个入参module__unused_webpack_exports__webpack_require__,函数体里面则是一个eval函数执行了我们写的代码。其实我们的js文件,被webpack加工打包了一层函数上去:

image.png

我们现在知道了:webpack会把我们写的所有的js文件打包成一个函数,这样我们的js文件就是在一个函数作用域下面的,不会污染全局环境。再在打包之后,赋值给一个__webpack_modules__对象,把所有的模块引入。那么这几个入参又是啥呢?让我们继续往下看:

第二部分

  /***********************************第二部分*************************************/
  // 缓存对象
  var __webpack_module_cache__ = {};

  // 导出函数 - 如果缓存对象中已经缓存过,则返回缓存对象中的模块。否则执行模块代码,存入缓存再返回
  function __webpack_require__(moduleId) {
    // 检查在缓存中是否存在该模块的缓存,如果有则直接返回
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // 创造一个新的module(并且存入缓存对象中)
    var module = (__webpack_module_cache__[moduleId] = {
      exports: {},
    });

    // 如果没有缓存则执行模块代码
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    // 返回模块
    return module.exports;
  }

首先是一个__webpack_module_cache__,它是一个缓存对象,具体是干啥的下面再说,让我们继续往下看。

接下来就是一个__webpack_require__函数,这个就是上面的三个入参之一的require函数。它的具体作用就是:先检查缓存对象__webpack_module_cache__中是否存在被require的模块,如果存在就直接使用缓存,如果不存在则调用一遍第一部分的__webpack_modules__集合中的此模块,并存入一份到缓存中,最后返回被require的模块的导出。 而前两个参数则是缓存对象的引用地址,我们在模块最后的module.exports会赋值到该对象上,从而被缓存对象给记录下来。

我们现在知道了:我们写的所有的模块,如果被require给调用过,就会被复制一份存入缓存,之后一直会调用缓存中的该模块。值得注意的是,如果我们的模块没有被引用过,webpack则不会把这个模块给引入到__webpack_modules__这个集合中。

第三部分

/***********************************第三部分*************************************/
var __webpack_exports__ = __webpack_require__("./src/index.js");

最后的语句就是执行一遍require函数,调用我们在配置文件中配置的入口文件,从而执行整个代码逻辑。

总结

webpack对于CommonJS的实现是基于函数作用域的,它把我们写的模块代码(js文件)给包了一层函数,通过自己的__webpack_require__方法来实现CommonJS的require。在模块被引用时,会把该模块的module.exports放入一个缓存对象中,下次再引用这个模块就直接从缓存中读取。而它一开始加载入口函数,在一开始就会把所有被用到的模块放入缓存中,这也正是webpack编译、打包时间慢的原因。因为webpack选择了一开始就把所有的模块进行加载,如果项目过大,加载的模块越多,时间也就越长。

CommonJS的实现我们已经知道了,他的require是在此模块加载完成后才会执行下面的语句,除非已经做过了缓存。所以它的导入是同步的,在浏览器环境里并不合适,所以一种异步加载的模块化规范来了。

AMD模块化方案

AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD模块的导入与导出

// 定义
define("module1", function(m) {
});

// 加载
require(["./module1"], function(module1) {
});

这边就不展开介绍AMD的模块化方案了,感兴趣的同学可以点击这里

ES6模块化方案

ES6加入了模块化方案,这应该是我们最熟悉的模块化方案了。我们先来看看ES6Module模块的导入和导出。

ES6Module的导入

import module1 from "./module1";

ES6Module的导出

function add(a, b) {
  return a + b;
}

export default {
  add,
};

webpack对ES6Module的实现

现在谷歌高版本的浏览器已经支持了此模块化方案,可以通过script标签加上type="module"来实现,具体的这里不展开了。但是还是有很多的浏览器不支持ES6的模块化方案,所以我们还是需要借助webpack等工具来实现。webpack支持多种模块化方案,可以把ES6的模块化代码转换成低版本浏览器兼容的模块化代码。让我们来看具体是怎么实现的。感兴趣的小伙伴可以看看webpack官方文档介绍

具体实现

这边我们改变一下上面例子中的两个文件,把他们改成ES6Module的写法

index.js:

import module1 from "./module1";

module1.add(1, 2);

module1.js:

function add(a, b) {
  return a + b;
}

export default {
  add,
};

然后我们再执行webpack命令对其进行打包操作,可以看到dist又重新生成了一个index.js文件。我们会发现,跟CommonJS打包生成的index.js的组成部分来看,除了一二三部分,它额外多了一部分:

/* webpack/runtime/define property getters */
// 这是一个核心函数,给导出对象module.exports扩展属性用
(() => {
	// define getter functions for harmony exports
	__webpack_require__.d = (exports, definition) => {
		for(var key in definition) {
			if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
				Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
			}
		}
	};
})();

/* webpack/runtime/hasOwnProperty shorthand */
// 此函数用于判断对象中是否存在某个属性
(() => {
	__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
})();

/* webpack/runtime/make namespace object */
// 此函数用于向es6模块的导出对象module.exports标记一个 __esModule 属性
(() => {
	// define __esModule on exports
	__webpack_require__.r = (exports) => {
		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
		}
		Object.defineProperty(exports, '__esModule', { value: true });
	};
})();

我们看多出来的部分,了解一下它的用法:

  1. __webpack_require__.d:这是一个核心函数,给导出对象module.exports扩展属性用,说白了就是给导出对象赋值用的。
  2. __webpack_require__.o:此函数用于判断对象中是否存在某个属性。
  3. __webpack_require__.r:此函数用于向es6模块的导出对象module.exports标记一个__esModule属性。

让我们结合打包后的代码来看下,我们看下编译前后的index.js

image.png

再看看编译前后的module1.js

image.png

总结

这样我们就能很清楚的知道了,这一追加部分的作用了。而webpack对于ES6Module模块化的转换也是跟CommonJS差不多的。但是,在对于值的导出的部分,ES6ModuleCommonJS还是有所区别的。

ES6Module跟CommonJS的区别

在值的导出的部分,CommonJS使用的是赋值操作,也就是拷贝了导出对象的引用地址:

image.png

这样做会产生的影响是,其模块内部改变这个值,是影响不到导出结果的。因为导出的是这个值的拷贝,两个值不是同一个!

ES6Module的导出,我们来看一个更为直观的栗子:

image.png

ES6Module的导出,并不是直接赋值,而是导出一个函数,这个函数引用的是模块内部值,这样就导致了模块内部改变其中一个值,导出模块的值也会发生改变,因为两个值都是同一个!

结语

前端模块化在各个浏览器上面的支持程度不同,而webpack提供了适配低版本浏览器模块化的解决方案。我们在工作和学习的过程中,不仅要知其然,还要知其所以然。希望我的文章对你有所帮助,如果感觉有所收获的话,麻烦您点个赞支持一下吧,感谢您的观看。