前端模块化,究竟在说什么?

模块化,其实在日常开发中到处都在用。但我对这个词,其实没有一个系统的、全面的理解。这篇文章,就参考各种文章稍微整理一下~

一、什么是模块化

什么是模块

  • 将一个复杂的程序,依据一定的规则(规范)封装成几个块(文件),并组合在一起,用来实现特定的功能
  • 块的内部数据/实现是私有的,只通过向外暴露的一些接口(方法)与外部通信
  • 简单来说,模块,就是实现特定功能的一组方法

模块的组成

  • 数据(内部的属性)
  • 操作数据的行为(内部的函数)

什么是模块化

将 JavaScript 程序,拆分为可按需导入的单独模块的机制。

二、模块化的发展历史

原始写法

将不同函数及记录状态的变量放在一起,算一个模块。使用时,直接调用某个函数即可。

var count = 1
function add () {
    count++
}
function minus () {
    count--
}
复制代码

【缺点】:

  1. 污染了全局变量(无法保证与其他模块不发生变量名冲突);
  2. 模块之间看不出直接关联

对象写法

将模块作为一个对象,模块的所有成员放在对象里。使用时,通过调用对象的属性。

var module1 = {
    _count: 0; // _ 是约定俗成的用来声明私有属性的前缀
    add: function () {
        _count++
    },
    minus: function () {
        _count--
    }
}
复制代码

命名空间

上面示例的方法,在 ES5 中叫:命名空间。
命名空间下,又可以声明子命名空间。
下面看一个示例:

// 若全局空间中,已有同名对象,则不覆盖该对象;否则,创建一个新的命名空间
var MYNAMESPACE = MYNAMESPACE || {} 
复制代码
<!doctype html>
<html lang="en">
    <meta charset="UTF-8">
    <title>命名空间的用法</title>
    <script src="1.js"></script>
    <script src="2.js"></script>
    <script src="3.js"></script>
<head>
<body>
<script>
    sayMyName() // 'Jane Doe'
    Aubrey.sayMyName() // 'Aubrey'
    Gabriel.sayMyName() // 'Gabriel'
</script>
</body>
</head>
</html>
复制代码
// 1.js
function sayMyName () {
    console.log('Jane Doe')
}
复制代码
// 2.js
var Aubrey = Aubrey || {}
Aubrey.sayMyName = function () {
    console.log('Aubrey')
}
复制代码
// 3.js
var Gabriel = Gabriel || {}
Gabriel.sayMyName = function () {
    console.log('Gabriel')
}
复制代码

【优点】:

  1. 对象写法及命名空间,避免了全局变量污染
  2. 同时易于组织代码模块的逻辑性、扩展性、可读性及可维护性。

【缺点】:

  1. 暴露了所有模块成员,内部状态(如:上面示例中的_count)可以被外部改写

立即执行函数(Immediately-Invoked Function Expression, IIFE)

通过 IIFE,可以隐藏模块的私有成员。
下面示例的 add() 和 _count,minus() 和 _count 形成了两个闭包。

var module1 = (function () {
    var _count = 1
    var add = function () {
        _count++
    }
    var minus = function () {
        _count--
    }
    return {
        add,
        minus
    }
})()
复制代码

上面的示例中,变量 _count 在 module1 外部无法再被访问,只能通过 module1.add 和 module1.minus 访问。

// 放大模式(argumentation):用于将一个模块拆分成几个部分,或者需要继承另一个模块
var module1 = (function (mod) {
    mod.subMode = function () {}
    return mod
})(module1)
复制代码
// 宽放大模式(loose argumentation):在浏览器环境中,无法确保模块的哪个部分会先加载完,为了避免第一个执行的部分加载一个不存在的对象
var module1 = (function (mod) {
    mod.subMode = function () {}
    return mod
})(window.module1 || {})
复制代码
// 引入依赖:模块内需要调用全局变量时,为了保持模块的独立性,使用显式传参方式
var module1 = (function (mod, $) {
    mod.subMode = function () {
        $('#id").onclick = function () {}
    }
    return mod
})(window.module1 || {}, jQuery)
复制代码

【优点】:

  1. 避免命名冲突
  2. 隐藏私有变量
  3. 保持了模块的独立性,使模块之间的依赖关系变得清晰
  4. 高可复用性、维护性
  5. 模块可按需加载

三、模块的规范

JS 中的模块规范主要有这几种:CommonJS、AMD、CMD、UMD、ES6 Modules

(一)CommonJS

参考文章:CommonJS规范(阮一峰)
Node 模块是遵循的 CommonJS 规范。

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

CommonJS 模块的特点:

  1. 所有代码都运行在模块作用域,不会污染全局作用域;
  2. 模块可以多次加载,但只会在第一次加载时运行一次,然后运行结果就被缓存了,后续加载,直接读取缓存结果。如果要重新运行模块,需要清除缓存。
  3. 模块的加载顺序,按照其在代码中出现的顺序

CommonJS 模块的语法:

const fn1 = function (value) {
}
const fn2 = function (value) {
}
const fn3 = function (value) {
}
// 写法一:
module.exports.fn1 = fn1
module.exports.fn2 = fn2
module.exports.fn3 = fn3
// 写法二:
module.exports = {
    fn1,
    fn2: fn2,
    fn3
}
// 写法三:在 Node 中,可以省略 module,exports 指向 module.exports
exports.fn1 = fn1
exports.fn2 = fn2
exports.fn3 = fn3
// 写法四:在 Node 中,可以省略 module,exports 指向 module.exports
exports = {
    fn1,
    fn2,
    fn3
}
复制代码

引入util.js

// 使用方法一:
let util = require('util')
util.fn1()

// 使用方法二:
let { fn1, fn2 } = require('util')
fn1()
fn2()
复制代码

上面的代码实质加载的是整个 util.js,尽管我们没有使用 fn3。CommonJS 的这种模块加载方式,叫“运行时加载”。

CommonJS 的缺点:

  1. 运行时加载,依赖无法静态优化
  2. 模块加载同步,只有模块加载完,才能执行后面的操作

AMD(Asynchronous Module Definition:异步模块定义)

AMD 采用异步方式加载模块,所有依赖这个模块的语句,都定义在一个回调函数中。

AMD 语法:

  • 定义暴露模块: define([依赖模块名], function () { return 模块对象 })
  • 引入模块:require([‘moduleA’, ‘moduleB’, ‘moduleC’], function (moduleA, moduleB, moduleC) {} )

AMD 示例:

// index.js
require(['moduleA', 'moduleB'], function(moduleA, moduleB) {
    console.log(moduleB);
});
// moduleA.js
define(function(require) {
    var m = require('moduleB');
    setTimeout(() => console.log(m), 1000);
});
// moduleB.js
define(function(require) {
    var m = new Date().getTime();
    return m;
});
复制代码

要使用 AMD 规范,需要先添加一个符合 AMD 规范的加载器脚本在页面中,比如 require.jscurl.js

<html>
<!-- 此处必须加载 require.js 之类的 AMD 模块化库之后才可以继续加载模块-->
<script src="/require.js"></script>
<!-- 只需要加载⼊⼝模块即可 -->
<script src="/index.js"></script>
</html>
复制代码

require.js 优点:

  1. 实现 JS 文件的异步加载,避免网页失去响应
  2. 管理模块之前的依赖性(加载顺序),便于代码的编写和维护

CMD (Common Module Definition:通用模块定义)

在 AMD 规范中,在 require 的第一个参数中,就要引入所有依赖。
在 CMD 规范中,依赖是就近,用的时候再 require 的。
可以通过 引入 Sea.js 使用 CMD 规范。

CMD 语法:

//定义没有依赖的模块 
define(function(require, exports, module){ 
    exports.xxx = value 
    module.exports = value
})

//定义有依赖的模块 
define(function(require, exports, module){ 
    //引入依赖模块(同步) 
    var module2 = require('./module2') 
    //引入依赖模块(异步) 
    require.async('./module3', function (m3) { }) 
    //暴露模块 
    exports.xxx = value 
})

// 引入和使用模块
define(function (require) { 
    var m1 = require('./module1') 
    var m4 = require('./module4')
    m1.show() 
    m4.show() 
})
复制代码

UMD(Universal Module Definition)

UMD是AMD和CommonJS的糅合。AMD模块以浏览器第一的原则发展,异步加载模块。CommonJS模块以服务器第一原则发展,选择同步加载,它的模块无需包装(unwrapped modules)。这迫使人们又想出另一个更通用的模式UMD (Universal Module Definition)。希望解决跨平台的解决方案。

UMD先判断是否支持Node.js的模块(exports)是否存在,存在则使用Node.js模块模式。在判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。

(function (window, factory) {
    if (typeof exports === 'object') {  
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
        define(factory);
    } else {
        window.eventUtil = factory();
    }
})(this, function () {
    //module ...
});
复制代码

ES6 Modules

ES6 的模块,不是对象,而是通过 export 命令显式指定输出代码,再通过 import 命令输入。

// 只加载了显式声明的 3 个方法,没有加载 fs 模块的其他方法 
import { stat, exists, readFile } from 'fs';
复制代码

ES6 的这种加载,称为“编译时加载”,或者“静态加载”。即,ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。但这也导致了没办法引用 ES6 模块本身,因为它不是对象。

ES6 模块的优点:

  1. 编译时加载,使静态分析成为可能。比如,引入宏、类型检测等功能
  2. 不再需要 UMD 模块格式
  3. 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性
  4. 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供

ES6 模块注意点:
ES6 中,模块自动启动严格模式(’use strict’),严格模式主要有以下限制:

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用with语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀 0 表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
  • eval不会在它的外层作用域引入变量
  • evalarguments不能被重新赋值
  • arguments不会自动反映函数参数的变化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全局对象
  • 不能使用fn.callerfn.arguments获取函数调用的堆栈
  • 增加了保留字(比如protectedstaticinterface

尤其是:ES6 模块中,顶层的 this 指向 undefined。

export 命令

  1. ES6 中,一个模块就是一个独立的文件。文件内部的除了使用 export 命令输出的变量外,所有变量,外部无法获取。
// util.js
// 写法一:
export const firstName = 'Aurey'
export const fn1 = function (value) {} 
export function fn2 (value) {}

// 写法二(推荐):
const firstName = 'Aubrey'
const fn1 = function (value) {}

export { 
    firstName,
    fn1 as function1 // 可以使用 as 关键字重命名
}
复制代码
  1. ES6 中,export 语句输出的接口,与其对应的值是动态绑定关系。(即,输出的值会动态更新)
  2. ES6 中,export 语句可以处于模块顶层的任何位置,但不能在块级作用域内

import 命令

  1. import 命令也可以使用 as 关键字重命名输入变量
import { firstName, lastName as surname } from './profile.js';
复制代码
  1. import 命令输入的变量都是只读的。

但是对象除外,对象的属性是可以更改的。但不建议修改,因为很难查错
3. import 命令具有提升效果,会将代码提升至整个模块的顶部,首先执行
4. 由于 import 是静态执行,所以不能使用表达式和变量
5. import 语句是 Singleton 模式,只会执行一次相同模块加载

模块的整体加载

import * as moduleName from './module1'
复制代码

export default 命令

export default 命令,用来为模块指定默认输出。default 其实是输出的变量的名称。
一个模块只能有一个默认输出,一个模块中 export default 命令只能使用一次。

// export-default.js
export default function () { // 也可以给匿名函数,添加名称(但这个名称只在模块内部有效)
  console.log('foo');
}  
// 也可以写成
function foo () {
    console.log('foo');
}
export { foo as default };

// import-default.js
import customName from './export-default'; // 注意 import 后面不使用 {}
customName(); // 'foo'
// 也可以写成
import { default as add } from './export-default';

// export default 也可以输出类
export default class {}
复制代码

import() 动态异步加载

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => { // module 是对象,可以解构赋值
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
  });
复制代码

import () 可以配合 Promise、async/await 语句使用。

// 配合 Promise.all,实现同时依赖多个模块的情况
Promise.all([
    import('./module1.js'),
    import('./module2.js'),
    import('./module3.js'),
])
.then(([module1, module2, module3]) => {})

// 也可以在 async 函数中使用
async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();
复制代码

更多关于 ES6 Modules 的内容,请参考ES6 Modules

总结

JS 模块化规范主要有:

  1. CommonJS:module.exports/require,用于服务端,同步加载,Node 遵循此规范;用在浏览器会导致代码阻塞
  2. AMD:define/require, 用于浏览器,异步加载,使用回调函数方式实现,依赖前置,需要先声明(require)所有需要引入的模块
  3. CMD:define,用于浏览器,异步加载,使用回调函数方式实现,依赖就近,使用前引入依赖模块即可
  4. UMD:先判断是否支持 Node 的 模块引入方式(CommonJS),有就使用,没有再判断是否支持 AMD 模块规范,有就使用
  5. ES6 Modules:export/import,静态加载模块,按需加载,浏览器支持,加载速度快

模块化带来的好处:

  • 避免命名冲突
  • 代码分离,按需加载
  • 更高可复用性
  • 更高可维护性

参考文章

  1. JS命名空间的使用
  2. JavaScript modules 模块(MDN)
  3. 模块(一):CommonJS,AMD,CMD,UMD
  4. ES6 Module 的语法(阮一峰)
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享