一点点前端模块化的总结 | 掘金技术征文

1,248 阅读12分钟

前言

前端模块化是一个老生常谈的话题。在写这篇文章之前,自己摸索翻阅了不少文章,发现总结的都很好,想着趁着这个机会分享下自己的心得,也算是对掌握的知识做一个回顾,同时也希望,能够让正在看的你对前端模块化有个全新的认识和理解。

💭本文首发掘金: 一点点前端模块化的总结

本文主要涉及的知识点:

IIFE

CommonJS

AMD/CMD/UMD

ES6CommonJS 差异性

module.exports/exportsexport default/export 差异性

什么是模块化

一句话: 为了让业务代码解耦逐步将项目划分成子模块的过程

举个栗子:

假设有一个 xxx 管理平台,里面涉及到一个 登录功能,此时我们可以把它当做一个 大模块

又因为 登录功能 里面包括了登录框,登录账号的类型区分,登录验证...等小部件

这些小部件都为 登录功能 提供服务,所以我们又可以把这些小部件看作是 登录功能 这个 大模块 底下的 子模块

我们把这种大模块逐步拆分成各个子模块的过程叫做模块化

为什么要用模块化

从历史的发展角度来看,前端越来越复杂,对编码效率、维护成本等要求都越来越高了,现在有了模块化,我们就可以针对性的去解决这些问题

文件拆分

要做模块化,第一步想到的就是从文件入手,我们将大文件拆成不同的小文件,但是这样就有问题:

优点: 模块各自独立

缺点: 只能一个个通过 script 标签去引入,变量可能会冲突,作用域问题无法优雅的解决,扩展性低,复用性差

解决文件拆分所带来的问题 IIFE

立即调用函数表达式(英文:immediately-invoked function expression,缩写:IIFE)

立即调用函数表达式可以令其函数中声明的变量绕过JavaScript的变量置顶声明规则,还可以避免新的变量被解释成全域变量或函数名占用全域变量名的情况 -- 摘自维基百科

有了 IIFE ,我们就可以安全地拼接或组合所有拆分过的文件

但是文件之间如果有相互依赖关系呢?就比如文件B 用到了 文件A 里的某个 function,这种依赖关系又该如何表示呢?

Node.js 和模块化规范的出现

为什么会出现模块化规范,其实一方面是为了解决刚刚提到的模块之间的依赖性问题,另一方面,是为了约束大家都以同一种编码方式去编写模块(遵循相同的规范去编码)

ES6 还没有出现之前,有些社区制定了一系列的模块加载方案,典型的有 CommonJSAMD/CMD/UMD

CommonJS

CommonJS 引入了 require 机制,它允许你在当前文件中加载和使用某个模块。导入需要的每个模块,这一开箱即用的功能,帮助我们解决了作用域和模块依赖的问题。

Node.js 是 CommonJS 规范的主要实践者

特点:

  • CommonJS 用同步的方式加载模块
  • 一个单独的文件就是一个模块
  • 模块内的 exports 用于暴露, require 用于引入
  • 用于服务端模块

缺点:

  • 同步的加载方式不适用于浏览器环境,同步意味着阻塞,阻塞就会导致浏览器卡死
  • 不能并行加载多个模块
// count.js

var msg = 'a + b = ';
function sum(a, b) {
  var result = a + b
  console.log(msg + result)
}

// 暴露
module.exports = {
  sum: sum
}
// main.js

// 引入
var count = require('./count')
count.sum(1, 2) // a + b = 3

AMD

AMD(Asynchronous Module Definition)是为浏览器环境设计的

从字面意思上来看,它是一种异步加载模块规范,能够有效解决 CommonJS 只能同步加载的缺陷

所有依赖这个模块的语句,都定义在一个回调函数中,等到全部加载完成之后,这个回调函数才会执行

RequireJS 就是实现了该规范的典型类库,实际上 AMD 也是其在推广过程中的规范化产出

特点:

  • 适合在浏览器环境中异步加载模块
  • 推崇依赖前置、提前执行(也就是在定义模块的时候,就得声明出所有的需要依赖到的模块)

缺点:

  • 由于依赖前置的问题,必须提前加载好所有的依赖。对于那些在回调函数中没有使用过依赖的模块,还是会被加载

CMD

CMD 规范与 AMD 规范很类似,不同点在于:AMD 推崇依赖前置、提前执行,而 CMD 推崇依赖就近、延迟执行。

说白了就是何时需要用到,何时才会加载

特点:

  • 依赖就近,延迟执行
  • CMD是按需加载依赖,在用到那个模块再去require

CMD 与 AMD 的区别

· AMD CMD
依赖处理方式 依赖前置,提前执行 依赖就近,延迟执行
加载方式 异步 异步
模块加载方式 所有依赖全部加载执行完毕后,才会进入到回调函数执行主逻辑
值得注意的是依赖的书写顺序并不一定是其执行的顺序,但是主逻辑一定是在所有依赖都执行完了才会执行(可以理解成 Promise.all 方法)
加载完某个依赖后不会立刻执行。而是在遇到 require 语句后才会执行对应的module,module 的书写顺序与执行书序是一致的

// AMD
define(['a', 'b'], function(a, b) {
    a.todo()

    // 虽然 b.todo() 没有执行到,但 b模块 还是提前加载执行完毕了
    if (false) {
        b.todo()
    } 
})

// CMD
define(function(require, exports, module) {
    var a = require('./a') //需要时再执行
    a.todo()

    if (true) {
        var b = require('./b')
        b.todo()
    }
})

UMD

UMD (Universal Module Definition) ,它是为了让模块同时兼容 AMD 和 CommonJS 规范而出现的,多用于模块定义的跨平台解决方案,和一些需要同时支持浏览器端和服务端引用的第三方库所使用

首先 UMD 会判断是否支持 AMD (define),若支持则使用 AMD 规范

否则判断是否支持 CommonJS (exports),若支持则使用 CommonJS 规范

若都不支持,则暴露给 Window

看下面的代码,实际上就是个 IIFE

factory 是依赖加载完毕后执行的回调函数,每当执行该函数后,就回返回一个模块。此处返回的就是 { a, b, c }

;(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    
    // AMD
    define(['jquery', 'lodash'], factory)
    
  } else if (typeof exports === 'object') {

    // CommonJS
    module.exports = factory(require('jquery'), require('lodash'))
    
  } else {

    // 浏览器全局变量 (root 即 window)
    root.returnExports = factory(root.jQuery, root._)

  }
}(this, function ($, _) {
  function a(){}
  function b(){}
  function c(){}

  return {
    a: a,
    b: b,
    c: c
  }
}));

ES6

ECMAScript6 在语言标准的层面上,增加了模块体系定义,完全可以取代我们之前提到的 CommonJSAMDCMDUMD 规范。

成为了浏览器端和服务端通用的一套模块解决方案,也是目前最流行的一套方案

ES6 模块的模块化使得在编译阶段时就能静态的处理模块的依赖关系,以及导入和导出的变量

而像 CommonJS,都只能在运行时确定这些东西,怎么理解呢,请看下面代码:

// 导出
module.exports = {
  a, b, c, d, e, f
}
// 导入
const { a, b, c } = require('xxx')

表面上是以为是按需引入,实质上是先整体加载了 xxx 模块,随后赋给了一个对象 xxx,

接着再从这个 xxx 对象上面读取了 3 个属性 a, b, c

这种加载其实就是“运行时加载”,因为只有运行时才能得到这个对象,从而导致没有办法在编译时做“按需引入的静态优化”

如果不好理解的话,看下面的代码应该就好理解了

// 下面的代码等同于: const { a, b, c } = require('xxx')
const xxx = require('xxx')
const a = xxx.a
const b = xxx.b
const c = xxx.c

而有了 ES6 就完全不一样了:

// 导出
export const a = 1
export const b = 2
export const c = 3
export const d = 4
export const e = 4
// 导入
import { a, b, c } from 'xxx'

实质上就是从 xxx 模块下加载了 a, b, c 三个属性,而 d 和 f 完全不会被加载到

这种加载就是 “编译时加载”,即 ES6 可以在编译时就完成模块的按需加载,效率要比 CommonJS 模块的加载方式高

ES6 和 CommonJS 之间的差异是什么呢?

ES6 和 CommonJS 之间的差异

CommonJS 模块导出的是一个值的拷贝,ES6 模块导出的是值的引用

CommonJS 模块导出的是值的拷贝,也就是说,一旦再导出后的文件改变这个值,那么其原本模块内部的变化就影响不到这个值了

ES6 模块的运行机制与 CommonJS 不一样。

JavaScript 引擎对脚本静态分析的时候,遇到 import,就会生成一个只读引用。

当脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。

换句话说,ES6 的 import 的原始值变了,import 加载的值也会跟着变。

而 CommonJS 不会,因此,ES6 模块可以看做是是动态引用的,并且不会缓存值,模块里面的变量就绑定其所在的模块

CommonJS 模块是运行时加载,ES6 模块是编译时加载

运行时加载: CommonJS 模块就是对象;

即在导入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取属性/方法,这种加载方式称为“运行时加载”

编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码

而 import 时则采用静态命令的形式,即在 import 可以指定加载某个导出的值,而不是加载整个模块,这种加载方式称为“编译时加载”

module.exports , exportsexport, export default 的区别

都说到这里了,也稍微提下 module.exports , exportsexport, export default 的区别

首先我们知道:

module.exports , exports 属于 CommonJS 规范

export, export default 属于 ES6 规范

module.exports 和 exports

在 node 环境下测试:

// 输出:{}
console.log(exports)

// 输出:Module { id: '.', exports: {}, ...}
console.log(module)

可以发现 module.exportsexports 都是 {}

实际上,这两个对象指向的都是同一个地址,且 exportsmodule.exports 的全局引用

你可以这样理解,每一个 js 文件在创建后,都会有下面的过程:

var module = new Module(...)
var exports = module.exports

所以默认情况下 exportsmodule.exports 指向的都是同一个对象

但是用 require 取值的时候其实生效的是 module.exports

也就是说,像下面这样写,一点问题没有:

// 正确写法
module.exports = {
  num: 100
}

但是如果这样写的话,require 就取不到值了,不信的可以试试:

// 错误写法
exports = {
  num: 100
}

为什么 require 取不到,可以看官方给出的解释代码:

function require(/* ... */) {
  const module = { exports: {} };
  ((module, exports) => {
    // Module code here. In this example, define a function.
    function someFunc() {}
    exports = someFunc;
    // At this point, exports is no longer a shortcut to module.exports, and
    // this module will still export an empty default object.
    module.exports = someFunc;
    // At this point, the module will now export someFunc, instead of the
    // default object.
  })(module, module.exports);
  return module.exports;
}

我们可以看到,exports 是先于 module.exports 实现的,最终返回的也是 module.exports

如果修改了 exports 值本身,那么 module.exports 的值依然指向原有的 someFunc

所以这就是用一个新对象赋给 exports ,而 require 取不到的真正原因

具体细节可以看官方文档的描述: Node.js exports shortcut

所以,建议的写法是直接用一个对象赋给 module.exports,把那些需要暴露出去的属性都放到这个对象里

module.exports = {
  num1: 100,
  num2: 200
}

或者直接一个一个地把属性挂载到 exports 上,切忌用一个新对象赋给 exports

exports.num1 = 100
exports.num2 = 200

export 和 export default

使用 export default 时

// animal.js
export default {
  dog: '汪汪',
  cat: '喵喵'
}
// main.js
import animal from './animal'
console.log(animal) // { dog: '汪汪', cat: '喵喵' }

// main.js
import { dog, cat } from './animal'
console.log(dog, cat) // undefined undefined

使用 export 时

// animal.js
export const dog = '汪汪'
export const cat = '喵喵'
// main.js
import animal from './animal'
console.log(animal) // undefined

// main.js
import { dog, cat } from './animal'
console.log(dog, cat) // 汪汪 喵喵

// main.js
import * as animal from './foo'
console.log(animal) // Object [Module] { dog: [Getter], cat: [Getter] }
console.log(animal.dog) // 汪汪

由以上例子可以看出:

export default 只能通过 import xxx form 'xxx' 来获取属性,而解构赋值是获取不到的

export 可以通过解构赋值和指定新对象来获取属性,而 import xxx form 'xxx' 是获取不到的

export, export default 与 module.exports

现在我们知道了 module.exportsexport default 是两种不同的规范,一个 CommonJS,一个 ES6

但是在开发的是时候我们一般会用 ES6, 如果你用到 Babel 的话,你可能会发现在用 Babel 编译后的结果中,ES6 被转成了 CommonJS 语法

Babel 在线转换

我们可以测下他们之间是如何转换的,先看导出

模块导出

// animal.js
export default {
  dog: '汪汪',
  cat: '喵喵'
}

打包后的结果:

// __esModule 表示这是一个 ESM 模块
Object.defineProperty(exports, "__esModule", {
  value: true
});

// void 0 其实 javascript 中的一个函数,返回值是 undefined
exports["default"] = void 0;
var _default = {
  dog: '汪汪',
  cat: '喵喵'
};
exports["default"] = _default; // 等同于 module.exports.default = _default

可以看出导出时,export default 相当于把对象添加到了 module.exports 中,且对象的 keydefault

模块导入

我们再看下导入:

// main.js
import animal from './animal'
console.log(animal)
console.log(animal.dog)

打包后的结果:

var _animal = _interopRequireDefault(require("./animal"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

console.log(_animal["default"]);
console.log(_animal["default"].dog);

可以看出,import 会先判断导出的模块是否来自 ES6,如果是则直接获取对象

如果本身就来自 CommonJS ,则直接在外面包一层 default 作为对象的 key

那么最后不管来自 CommonJS 还是 ES6 的代码,都会在你用到的时候做一层 .["default"]语法糖转换

结论

使用 export default 导出时,最好用 import 来导入,否则就会看到这种写法: const animal = require('./animal').default

若使用 import 导入,则导出既可以写成 module.exports,也可以写成 export default 或 export

都看到这了,客官,点个赞?

参考文章

ES6 模块与 CommonJS 模块的差异

IIFE's - Immediately invoked function expressions