【进阶第 10 期】 模块化

729 阅读15分钟

前言

尽管很多开发者每天都在使用 JavaScript,却不知道这背后发生了什么。该篇文章是本系列文章的第一篇,旨在深入探讨 JavaScript 及浏览器工作原理,将前端相关如网络、页面渲染、浏览器安全、javascript执行机制等知识点串联起来,帮助更多开发者找到自己定位(查漏补缺),达到提升自己技能同时,游刃有余解决工作中难题(这些难题往往就是你对某些概念理解不够深刻)。谈到代码的编写,称职的作家会把他的书分章节和段落;好的程序员会把他的代码分成模块。

提到模块化,我们第一印象可能想到的就是 ES6 的模块系统,import、export default,在一些老的项目或者nodejs 项目中看到使用commonjs规范的 require、module.exports。甚至有时候也会常常看到两者互用的场景。日常使用没有问题,但其中的关联与区别知其然不知其所以然,使用起来也糊里糊涂。比如:

  1. 为什么有的地方使用 require 去引用一个模块时需要加上 default? 如require(‘xx’).default
  2. 经常在各大UI组件引用的文档上会看到说明import { button } from 'xx-ui' 这样会引入所有组件内容,需要添加额外的 babel 配置,比如 babel-plugin-component?babel 在模块化的场景中充当了什么角色?
  3. 为什么可以使用 es6 的 import 去引用 commonjs 规范定义的模块,或者反过来也可以又是为什么?
  4. 我们在浏览一些 npm 下载下来的 UI 组件模块时(比如说 element-ui 的 lib 文件下),看到的都是 webpack 编译好的 js 文件,可以使用 import 或 require 再去引用。但是我们平时编译好的 js 是无法再被其他模块 import 的,这是为什么?
  5. 查看 es6 新功能里还有 tree-shaking 功能,怎么才能使用这个功能?

如果你对这些问题都了然于心,那么可以关掉本文了,如果有疑问,这篇文章就是为你准备的,但本文并不着急回答这几个问题(有点皮,抱歉)!因为想要解答上面这些疑惑,还得需要一些开胃菜,不然一口吃不了一个胖子:

  • 1、模块化是什么?
  • 2、为什么要模块化?
  • 3、有那些模块化规范或标准?

什么是模块化?

模块化最初被定义为一种对传统软件工程中的类提供私有和公共封装的方法。同样的,在JavaScript中,也需要这样一种方式:可以将一个单一的对象中包含公共/私有的方法和变量,从而从全局范围中屏蔽特定的部分。这样就可以减少我们的变量名称与在页面中其他脚本区域定义的变量名称冲突的可能性。

在ES6 module系统没有出来之前,JavaScript的模块化是通过使用闭包的方式来将私有信息,状态组织结构封装起来。提供了一种将公有和私有方法,变量封装混合在一起的方式,通过这种方式防止内部信息泄露到全局中,从而避免了和其它开发者接口发生冲图的可能性。在这种模式下只有公有的API 会返回,其它将全部保留在闭包的私有空间中。

好处

  1. 避免命名冲突(减少命名空间污染)
  2. 更好的分离代码
  3. 按需加载
  4. 更高复用性
  5. 高可维护性

目前有哪些模块化规范或标准

随着前端复杂度提高,为了能够提高项目代码的可读性可扩展性可复用性等。javaScript模块化这个概念便被提出来,,前端社区也不断地出现各种规范来定义并实现前端模块化,直到ES6对其进行了规范并标准化。让我们一起来回顾下javascript模块化历程

[第一阶段]:无模块化

在前端还处于刀耕火种的年代,我们的的应用却越来越复杂,随之js文件越来越庞大,再到后来不再是一个js文件就可以解决的了,关于模块化的思考也越来越多,不曾停止。直到最后,单纯一个文件即一个模块是最简单直接有效模块化的方式,但同时也带来了许多问题。

1. 全局function模式

  • 思路:将不同的功能封装成不同的全局函数
  • 问题: 污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系

2. 命名空间(namespace)模式 :

  • 思路:通过简单对象封装,一个模块对应一个对象
  • 问题: 数据不安全(外部可以直接修改模块内部的数据)

3. IIFE模式:匿名函数自调用

  • 思路:利用闭包原理,调用一次的函数即可达到作用域隔离、命名冲突、减少内存占用问题
  • 优点:数据是私有的, 外部只能通过暴露的方法操作
  • 问题: 如果当前这个模块依赖另一个模块怎么办?

IIFE(Immediately Invoked Function Expression),翻译过来就是立即调用的函数表达式。它出现目的就是为了解决调用一次的函数达到作用域隔离、命名冲突、减少内存占用问题。

语法

(function(){
    // ....
})()

来个例子说明下IIFE如何利用闭包实现作用域隔离,解决命名冲突的:

var testModule = (function(window,undefined){
    var _private = 'foo';
    var counter = 0;
    var incrementCounter = function(){
        console.log(++counter)
    };
    return {
        incrementCounter:incrementCounter,
        resetCounter: function () {
            console.log( "counter value prior to reset: " + counter );
            counter = 0;
        }
}
})();
testModule.incrementCounter()
testModule.incrementCounter()
resetCounter.resetCounter()
testModule.incrementCounter()

4. IIFE增强:

  • 思路:通过传入参数方式,解决模块依赖问题
  • 优点:相比传统的IIFE更加灵活,可以传入依赖
  • 问题:当业务变得越来越复杂,所需要模块就会越多,模块管理维护成本过高,一个js文件过大,同时也不容易维护。
var var m1 = (function(m1){
    
})();

var m2 = (function(m1){
    // 使用模块m1暴露的接口
    ...
})(m1);

5. 多文件通过script加载

  • 思路:将不同业务模块拆分到不同js文件中。
  • 优点:减小单个文件大小,便于维护管理。
  • 问题:请求过多(浏览器同源策略限制等),依赖关系模糊,不清楚其中关系,容易导致各个依赖加载顺序出错。

来个例子说明下,清晰易懂:

  <script src="jquery.js"></script>
  <script src="jquery.select2.js"></script>
  <script src="main.js"></script>
  <script src="other1.js"></script>
  <script src="other2.js"></script>
  <script src="other3.js"></script>

简单的将所有的js文件统统放在一起。但是这些文件的顺序还不能出错,比如jquery需要先引入,才能引入jquery插件,才能在其他的文件中使用jquery。有以下缺陷:

  • 容易导致全局变量命名冲突(由不同人维护的文件中出现相同变量名)
  • 加载顺序不能出错,比如jquery需要先引入,才能引入jquery插件,才能在其他的文件中使用jquery
  • js直接依赖关系不明确(比如:main.js需要使用jquery,但单纯看上面代码,无法辨别)

[第二阶段]: CommonJS规范

实现方案

  • 服务端实现(nodejs)
  • 浏览器实现(借助Browserify)

原理

  • 1、每个文件可当做一个模块。
  • 2、模块加载是运行时同步加载的

使用说明书

// 引入模块:1、第三方模块 xxx 为模块名 2、自定义模块 xxx 为文件路径
var jquery = require('xxx')

// 导出模块
exports.xxx = value
module.exports = value

问题:不适合浏览器环境
对于服务器端而言,所有模块放在本地硬盘,可以同步加载完成,等待时间就是硬盘读取的时间,但是对于浏览器端,模块放在服务器端,等待时间取决于网速的快慢,可能要等待很长时间,浏览器处于“假死”状态。因此这并不适合浏览器环境因此就有了AMD 、CMD方案

[第三阶段]: AMD规范

既然浏览器端同步加载行不同,异步加载方案就成为了唯一的选择。

1. 原理

通过检查模块依赖关系,向DOM动态中插入script方式完成模块引入,并注册onload事件,在回调中执行具体操作

2. 代表: requirejs

requirejs的执行流程

  • require函数检查依赖的模块,根据配置文件,获取js文件的实际路径
  • 根据js文件实际路径,在DOM中插入script节点,并绑定onload事件来获取该模块加载完成的通知。
  • 当所有依赖script全部加载完成后,调用回调函数

3. 使用说明书

// 引入requirejs,指定配置入口文件
<script data-main="js/main" src="js/require.js"></script>

// 模块定义的语法:define(id?, dependencies?, factory)
define(function(){
   return 模块名
})

// 引入模块的语法
require(['module1', 'module2'], function(m1, m2){
   使用m1/m2
})

4. AMD vs commonjs

  • AMD规范则是非同步加载模块,允许指定回调函数。
  • CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作

规范文档

[第四阶段]: CMD规范

1. 代表seajs

2. 使用说明书

// 定义模块
// 文件 ./module1
define(function (require, exports, module) {
    // m1 实现
     exports.m1 = m1
});

// 使用模块
define(function(require, exports, module){
    var a = require('a'); // 依赖就近
    a.doSomething();  // 延迟执行
    ...
    var b= require('b');
    b.doSomething();
    ...
};

3. AMD vs CMD

  • AMD 推崇依赖前置、提前执行。
  • CMD推崇依赖就近、延迟执行。

规范文挡

[第五阶段]: ES6 规范

浏览器和服务器通用的模块解决方案,但目前暂时无法直接运行在大部分JavaScript运行环境下,必须通过工具(babel)转换成标准的ES5才能正常运行。

1.使用说明书

// 导出模块 export
export 
export default

// 引入模块 import
import { cloneDeep } from 'lodash'

2.ES6 vs commonjs

    1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
    1. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
    1. CommonJs 是单个值导出,ES6 Module可以导出多个
    1. CommonJs 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层
    1. CommonJs 的 this 是当前模块,ES6 Module的 this 是 undefined

[第六阶段]:UMD规范

该模式主要用来解决CommonJS模式和AMD模式代码不能通用的问题,并同时还支持老式的全局变量规范。其本质就是根据当前全局对象中的值判断目前处于何种模块环境

(function(root, factory) {
    if (typeof module === 'object' && typeof module.exports === 'object') {
        console.log('是commonjs模块规范,nodejs环境')
        var depModule = require('./umd-module-depended')
        module.exports = factory(depModule);
    } else if (typeof define === 'function' && define.amd) {
        console.log('是AMD模块规范,如require.js')
        define(['depModule'], factory)
    } else if (typeof define === 'function' && define.cmd) {
        console.log('是CMD模块规范,如sea.js')
        define(function(require, exports, module) {
            var depModule = require('depModule')
            module.exports = factory(depModule)
        })
    } else {
        console.log('没有模块环境,直接挂载在全局对象上')
        root.umdModule = factory(root.depModule);
    }
}(this, function(depModule) {
    console.log('我调用了依赖模块', depModule)
    // ...省略了一些代码,去代码仓库看吧
    return {
        name: '我自己是一个umd模块'
    }
}))

webpack如何实现模块化?

我们都知道,浏览器是无法识别commonjs规范的模块和es6 module的。将这些规范的模块转化为浏览器认识的语句就是webpack做的最基本事情。为了方便理解webpack实现这些规范的兼容,我们先来列举下日常公众中我们是如何引入第三方类库的?

[一]、引入第三方类库的方式

回想一下,当我们引入别人开发的类库时有几种方式?下面假设我们引入一个demo方法:

1. 传统方式:script标签

<script src="demo.js"></script>
<script>demo();</script>

2. commonjs

const demo = require('demo');
demo();

3. AMD

define(['demo'], function(demo) {
	demo();
});

4. ES6 module

import demo from 'demo';
demo();

[二]、webpack输出模块方式

webpack输出模块方式主要有以下几种:output.library & output.libraryTarget & output.libraryExport

为了实现上述几种引入第三方类库的方式,webpack 提供了这几个字段来满足不同模块化方案输出

  • output.library:支持string或者object,它的值被如何使用会根据output.libraryTarget的取值不同而不同

  • output.libraryTarget:支持string,作用是控制 webpack 打包的内容是如何暴露的,默认为var

  • output.libraryExport: 默认导出的属性

    暴露方式
    暴露一个变量var、assign
    通过对象属性暴露this、window、global、commonjs
    模块定义系统commonjs2、amd、umd

由于篇幅原因,这里就不对webpack这些配置展开,后续有时间专门写一篇webpack文章来讲解。所以这里使用umd(libraryTarget: "umd", 这个选项会在尝试在库使用前检查当前使用的模块定义系统,使其和CommonJS、AMD兼容或者暴露为全局变量)配置打包生成一个demo来说明webpack是如何兼容各个模块系统的。

举个例子来说明,最通俗易懂。当前使用的webpack版本为4.46.0,创建两个文件:index.js(入口文件),a.js(模块)

// a.js
var a= 'this is a'

export default a

// 入口文件 index.js
import a from './a'
console.log(a);
export default a

查看打包后输出的源码:

(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();
	else if(typeof define === 'function' && define.amd)
		define([], factory);
	else if(typeof exports === 'object')
		exports["MyLibrary"] = factory();
	else
		root["MyLibrary"] = factory();
})(typeof self !== 'undefined' ? self : this, function() {
return  (function(modules) { // webpackBootstrap
 	// The module cache
 	var installedModules = {};
 	// The require function
 	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;
 	}


 	// expose the modules object (__webpack_modules__)
 	__webpack_require__.m = modules;

 	// expose the module cache
 	__webpack_require__.c = installedModules;

 	// define getter function for harmony exports
 	__webpack_require__.d = function(exports, name, getter) {
 		if(!__webpack_require__.o(exports, name)) {
 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
 		}
 	};

 	// define __esModule on exports
 	__webpack_require__.r = function(exports) {
 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
 		}
 		Object.defineProperty(exports, '__esModule', { value: true });
 	};

 	// create a fake namespace object
 	// mode & 1: value is a module id, require it
 	// mode & 2: merge all properties of value into the ns
 	// mode & 4: return value when already ns object
 	// mode & 8|1: behave like require
 	__webpack_require__.t = function(value, mode) {
 		if(mode & 1) value = __webpack_require__(value);
 		if(mode & 8) return value;
 		if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
 		var ns = Object.create(null);
 		__webpack_require__.r(ns);
 		Object.defineProperty(ns, 'default', { enumerable: true, value: value });
 		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;
 	};

 	// getDefaultExport function for compatibility with non-harmony modules
 	__webpack_require__.n = function(module) {
 		var getter = module && module.__esModule ?
 			function getDefault() { return module['default']; } :
 			function getModuleExports() { return module; };
 		__webpack_require__.d(getter, 'a', getter);
 		return getter;
 	};

 	// Object.prototype.hasOwnProperty.call
 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

 	// __webpack_public_path__
 	__webpack_require__.p = "";


 	// Load entry module and return exports
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
 ({
  "./src/a.js":
 (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\nvar a= 'this is a'\r\n\r\n/* harmony default export */ __webpack_exports__[\"default\"] = (a);\n\n//# sourceURL=webpack://MyLibrary/./src/a.js?");
 }),
   "./src/index.js":
   (function(module, __webpack_exports__, __webpack_require__) {
      eval("__webpack_require__.r(__webpack_exports__);\nvar _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./src/a.js\");\n\r\nconsole.log(_a__WEBPACK_IMPORTED_MODULE_0__[\"default\"]);\r\n __webpack_exports__[\"default\"] = (_a__WEBPACK_IMPORTED_MODULE_0__[\"default\"]);");
   })
 });
});

[三]、webpack模块系统检查

(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();
	else if(typeof define === 'function' && define.amd)
		define([], factory);
	else if(typeof exports === 'object')
		exports["MyLibrary"] = factory();
	else
		root["MyLibrary"] = factory();
})(typeof self !== 'undefined' ? self : this, function() {解析完的模块部分})

可以看到模块部分被作为factory参数传入了webpackUniversalModuleDefinition中,

    1. 如果检测到module.exports有定义,那么模块赋值给module.exports;
    1. 如果检测到amd的模块系统有定义,赋值给define的模块系统;
    1. 如果检测到exports定义,那么模块赋值给exports;
    1. 最后如果上述模块系统都未检测到,赋值给webpack.output.library定义的全局变量。浏览器可以通过window.library拿到解析好的模块。

[四]、webpack模块解析部分

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

 	// The require function
 	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;
 	}


 	// expose the modules object (__webpack_modules__)
 	__webpack_require__.m = modules;

 	// expose the module cache
 	__webpack_require__.c = installedModules;

 	// define getter function for harmony exports
 	__webpack_require__.d = function(exports, name, getter) {
 		if(!__webpack_require__.o(exports, name)) {
 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
 		}
 	};

 	// define __esModule on exports
 	__webpack_require__.r = function(exports) {
 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
 		}
 		Object.defineProperty(exports, '__esModule', { value: true });
 	};


 	__webpack_require__.t = function(value, mode) {
 		if(mode & 1) value = __webpack_require__(value);
 		if(mode & 8) return value;
 		if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
 		var ns = Object.create(null);
 		__webpack_require__.r(ns);
 		Object.defineProperty(ns, 'default', { enumerable: true, value: value });
 		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;
 	};

 	// getDefaultExport function for compatibility with non-harmony modules
 	__webpack_require__.n = function(module) {
 		var getter = module && module.__esModule ?
 			function getDefault() { return module['default']; } :
 			function getModuleExports() { return module; };
 		__webpack_require__.d(getter, 'a', getter);
 		return getter;
 	};

 	// Object.prototype.hasOwnProperty.call
 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

 	// __webpack_public_path__
 	__webpack_require__.p = "";

 	// Load entry module and return exports 
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
    })({'路径名1'function(){模块1},路径名2function(){模块2},...})
}
  1. 定义了installedModules ,这个变量被用来缓存已加载的模块。
  2. 定义了__webpack_require__ 这个函数,函数参数为模块的id。这个函数用来实现模块的require。
  3. webpack_require 函数首先会检查是否缓存了已加载的模块,如果有则直接返回缓存模块的exports。
  4. 然后调用模块函数,也就是前面webpack对我们的模块的包装函数,将module、module.exports和__webpack_require__作为参数传入。注意这里做了一个动态绑定,将模块函数的调用对象绑定为module.exports,这是为了保证在模块中的this指向当前模块。
  5. 调用完成后,模块标记为已加载。
  6. 返回模块exports的内容。
  7. 利用前面定义的__webpack_require__ 函数,require指定路径名或模块名,也就是入口模块
  8. 如果没有缓存,也就是第一次加载,则首先初始化模块,并将模块进行缓存

[五]、webpack模块实现部分

      eval("__webpack_require__.r(__webpack_exports__);\nvar _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./src/a.js\");\n\r\nconsole.log(_a__WEBPACK_IMPORTED_MODULE_0__[\"default\"]);\r\n __webpack_exports__[\"default\"] = (_a__WEBPACK_IMPORTED_MODULE_0__[\"default\"]);");

主动调用__webpack_require__.r,检查是否ES6模块,若果是es6模块转化成的commonjs模块,则修改es__esModule=true

  1. 如果传入模块是es6模块转化成的commonjs模块,即__esModule=true,那么返回的是该模块的default属性的值,
  2. 如果传入的模块原来就是commonjs模块,返回模块本身

思考

打开vue 官网,可以看到vue 使用webpack构建针对不同模块化方案输出了不同构建版本

输出的版本中出现几个关键词需要我们引起注意的:runtime,common,esm,browser 各自代表什么意思?