聊聊前端模块化开发
随着JavaScript开发变得越来越普遍,命名空间和依赖性变得越来越难以处理。前端开发者都以模块化的方式处理该问题。在这篇文章中,我们将探讨前端开发人员目前使用的模块化方案以及试图解决的问题。
为什么需要JavaScript模块?
模块化可以使你的代码低耦合,功能模块直接不相互影响。
- 可维护性:每个模块都是单独定义的,之间相互独立。模块尽可能的需要和外部撇清关系,方便我们独立的对其进行维护与改造。维护一个模块比在全局中修改逻辑判断要好的多。
- 命名空间:为了避免在JavaScript中的全局污染,我们通过模块化的方式利用函数作用域来构建命名空间。
- 可复用性:虽然粘贴复制很简单,但是考虑到我们之后的维护以及迭代,你会相当崩溃。
模块化的解决方案有哪些?
讲完了JavaScript模块化的好处,我们来看下有哪些解决方案来实现JavaScript的模块化。
揭示模块模式(Revealing Module)
var myRevealingModule = (function () {
var privateVar = "Ben Cherry",
publicVar = "Hey there!";
function privateFunction() {
console.log( "Name:" + privateVar );
}
function publicSetName( strName ) {
privateVar = strName;
}
function publicGetName() {
privateFunction();
}
// Reveal public pointers to
// private functions and properties
return {
setName: publicSetName,
greeting: publicVar,
getName: publicGetName
};
})();
myRevealingModule.setName( "Paul Kinlan" );
通过这种构造,我们通过使用函数有了自己的作用域或“闭包”。
这种方法的好处在于,你可以在函数内部使用局部变量,而不会意外覆盖同名全局变量,但仍然能够访问到全局变量。
优点:
- 可以在任何地方实现(没有库,不需要语言支持)。
- 可以在单个文件中定义多个模块。
缺点:
- 无法以编程方式导入模块(除非使用eval)。
- 需要手动处理依赖关系。
- 无法异步加载模块。
- 循环依赖可能很麻烦。
- 很难通过静态代码分析器进行分析。
CommonJS
CommonJS是一个旨在定义一系列规范的项目,以帮助开发服务器端JavaScript应用程序。CommonJS团队试图解决的一个领域就是模块。Node.js开发人员最初打算遵循CommonJS规范,但后来决定反对它。
在 CommonJS 的规范中,每个 JavaScript 文件就是一个独立的模块上下文(module context),在这个上下文中默认创建的属性都是私有的。也就是说,在一个文件定义的变量(还包括函数和类),都是私有的,对其他文件是不可见的。
需要注意的一点是,CommonJS以服务器优先的方式来同步载入模块,假使我们引入三个模块的话,他们会一个个地被载入。
// In circle.js
const PI = Math.PI;
exports.area = (r) => PI * r * r;
exports.circumference = (r) => 2 * PI * r;
// In some file
const circle = require('./circle.js');
console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);
Node.js的模块系统通过library的方式对CommonJS的基础上进行了模块化实现。
在Node和CommonJS的模块中,基本上有两个与模块系统交互的关键字:require和exports。
require是一个函数,可用于将接口从另一个模块导入当前范围。传递给的参数require是模块的id。在Node的实现中,它是node_modules目录中模块的名称(或者,如果它不在该目录中,则是它的路径)。
exports是一个特殊的对象:放入它的任何东西都将作为公共元素导出。
Node和CommonJS之间的一个独特区别在于module.exports对象的形式。
在Node中,module.exports是导出的真正特殊对象,而exports它只是默认绑定到的变量module.exports。
另一方面,CommonJS没有任何module.exports对象。实际意义是,在Node中,无法通过以下方式导出完全预构造的对象module.exports:
// This won't work, replacing exports entirely breaks the binding to
// modules.exports.
exports = (width) => {
return {
area: () => width * width
};
}
// This works as expected.
module.exports = (width) => {
return {
area: () => width * width
};
}
优点
- 简单:开发人员可以在不查看文档的情况下掌握概念。
- 集成了依赖管理:模块需要其他模块并按所需顺序加载。
require
可以在任何地方调用:模块可以通过编程方式加载。- 支持循环依赖。
缺点
- 同步API使其不适合某些用途(客户端)。
- 每个模块一个文件。
- 浏览器需要加载程序库或转换。
- 模块没有构造函数(Node支持)。
- 很难进行静态代码分析。
AMD
AMD诞生于一群对CommonJS的研究方向不满的开发人员。事实上,AMD在开发早期就与CommonJS分道扬镳,AMD和CommonJS之间的主要区别在于它支持异步模块加载。
//Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {};
});
// Or:
define(function (require) {
var dep1 = require('dep1'),
dep2 = require('dep2');
return function () {};
});
通过使用JavaScript的传统闭包来实现异步加载:
在请求的模块加载完成时调用函数。模块定义和导入模块由同一个函数承载:定义模块时,其依赖关系是明确的。因此,AMD加载器可以在运行时具有项目的模块依赖图。因此可以同时加载彼此不依赖的库。这对于浏览器尤其重要,因为启动时间对于良好的用户体验至关重要。
优点
- 异步加载(更好的启动时间)。
- 支持循环依赖。
- require和的兼容性exports。
- 完全整合了依赖管理。
- 如有必要,可以将模块拆分为多个文件。
- 支持构造函数。
- 插件支持(自定义加载步骤)。
缺点
- 语法稍微复杂一些。
- 除非编译,否则需要加载程序库。
- 很难分析静态代码。
除了异步加载以外,AMD的另一个优点是你可以在模块里使用对象、函数、构造函数、字符串、JSON或者别的数据类型,而CommonJS只支持对象。
UMD
统一模块定义(UMD:Universal Module Definition )就是将 AMD 和 CommonJS 合在一起的一种尝试,常见的做法是将CommonJS 语法包裹在兼容 AMD 的代码中。
(function(define) {
define(function () {
return {
sayHello: function () {
console.log('hello');
}
};
});
}(
typeof module === 'object' && module.exports && typeof define !== 'function' ?
function (factory) { module.exports = factory(); } :
define
));
该模式的核心思想在于所谓的 IIFE(Immediately Invoked Function Expression),该函数会根据环境来判断需要的参数类别
ES6模块
支持JavaScript标准化的ECMA团队决定解决模块问题,
兼容同步和异步操作模式。
//------ lib.js ------
export const sqrt = Math.sqrt;
export function square(x) {
return x * x;
}
export function diag(x, y) {
return sqrt(square(x) + square(y));
}
//------ main.js ------
import { square, diag } from 'lib';
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。
由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。除了静态加载带来的各种好处,ES6 模块还有以下好处。
- 不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
- 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
- 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。
ES6 的模块自动采用严格模式,不管有没有在模块头部加上"use strict";。
严格模式主要有以下限制。
变量必须声明后再使用
函数的参数不能有同名属性,否则报错
不能使用with语句
不能对只读属性赋值,否则报错
不能使用前缀 0 表示八进制数,否则报错
不能删除不可删除的属性,否则报错
不能删除变量delete prop,会报错,只能删除属性delete global[prop]
eval不会在它的外层作用域引入变量
eval和arguments不能被重新赋值
arguments不会自动反映函数参数的变化
不能使用arguments.callee
不能使用arguments.caller
禁止this指向全局对象
不能使用fn.caller和fn.arguments获取函数调用的堆栈
增加了保留字(比如protected、static和interface)
其中,尤其需要注意this的限制。ES6 模块之中,顶层的this指向undefined,即不应该在顶层代码使用this。
export
export语法被用来创建JavaScript模块。你可以用它来导出对象(包括函数)和原始值(primitive values)。导出有两种类型:named和default。
// named
// lib.js
export function sum(a, b) {
return a + b;
}
export function substract(a, b) {
return a - b;
}
function divide(a, b) {
return a / b;
}
export { divide };
// default
// dog.js
export default class Dog {
bark() {
console.log('bark!');
}
}
import
import语句用来导入其他模块。
整个导入
// index.js
import * as lib from './lib.js'; console.log(lib.sum(1,2)); console.log(lib.substract(3,1)); console.log(lib.divide(6,3));
导入一个或多个named导出
// index.js
import { sum, substract, divide } from './lib';
console.log(sum(1,2));
console.log(substract(3,1));
console.log(divide(6,3));
需要注意,相应的导入导出名字必须匹配。
导入一个default导出
// index.js
import Dog from './dog.js';
const dog = new Dog();
dog.bark(); // 'bark!'
注意,defualt导出在导入时,可以用任意的名字。所以我们可以这样做:
import Cat from './dog.js';
const dog = new Cat();
dog.bark(); // 'bark!'
参考
- 很全很全的JavaScript的模块讲解 https://segmentfault.com/a/1190000012464333#articleHeader9
- JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015 https://auth0.com/blog/javascript-module-systems-showdown/
- ECMAScript 6 入门http://es6.ruanyifeng.com/#docs/module