摘要
- 了解前端模块规范出现前的状态
- 前端规范的重要作用和发展历史
前言
作为前端开发,我们一定写过下面的代码
import { sum } from './sum';
const sqrt = function(a, b) {...}
export { sqrt };
或者是下面的代码:
const sum = require('./sum');
const sqrt = function(){...}
module.exports = {sqrt}
这是前端开发者常用的导入模块的两种方式,上述两种方式属于前端的两种不同的模块规范,很多场景下,我们开发的时候遵照仓库的规范约定开发就好了,但是了解模块规范的发展历史、模块规范的角色以及不同的模块规范的区别也是非常重要的。
以前JavaScript官方并没有支持模块,但是社区贡献了许多模块方案,其中两个最重要的就是CommonJS模块(Node.js环境)和AMD模块(浏览器环境),下面我们来看下前端模块化的发展历史,更好的理解前端模块化的现状。
前端模块化的重要角色
没有模块化,都有哪些问题
代码维护成本高
最直观来讲,模块的一个重要作用就是将我们的代码根据功能拆分成了不同的文件,并对外暴露其功能。通过加载相应的文件获取其功能,这大大降低了工程的维护成本,如果你写过一个1000多行的js文件,就会知道没有模块化的“痛不欲生”的体验,对于新手来说也极其不友好。比如下面的例子,梳理代码逻辑的成本是巨大的。
let globalConfig = {};
...// 中间经过了几百行的逻辑
const setGlobalConfig = function() {
globalConfig["appearance"] = true;
};
...//又经过了几百行的代码
globalConfig["appearance"] = false;
全局变量空间污染,容易被篡改
还是思考上面的例子,所有的变量都在一个全局的作用域,我们在定义一个新变量的时候需要思考,该变量的命名不能和其他的变量名重复,另外在修改该变量的时候需要额外的注意,避免其发生了意想不到的更改。
尽管我们可以以对象私有属性的方式维护变量,通过对象的方法访问其私有变量,但是数据仍然是不安全的,因为JavaScript没有严格的私有属性,对象属性仍然可能被外部直接修改。
const obj = {
name: "Arya",
setName: function(n){this.name = n};
getName: function(){return this.name;}
}
obj.name = "Bob"
闭包?可以解决问题吗
前端开发一定会知道闭包的概念,它的一个优点就是可以封闭属性,只能允许内部的函数作用域访问,外部无法访问和修改,可以避免污染全局变量空间。
const func = function() {
const name = "Arya";
return function() {
return name;
}
}
const getName = func();
console.log(getName());
所以我们当然也可以将变量限制在闭包作用域内,并且定义一些方法可以操作该变量,避免污染全局变量空间,避免外部函数修改该变量。并且可以将功能聚集在闭包内,降低维护的成本。这也叫做立即执行函数表达式IIFE(Immediately-invoked function expression)
比如:
// nameModule.js
(function(window){
letname = 'Arya';
const getName = function(){return name;}
const setName = function(n) { name = n; return name; }
window.nameModule = { getName, setName };
})(window)
// index.html
<script src="./nameModule.js"></script>
<script>
window.nameModule.getName();
window.nameModule.setName("Bob")
</script>
问题是,项目中很多时候模块之间是相互依赖的,IIFE的方式如果有依赖模块的话,需要将依赖的模块作为参数传递给闭包。
// nameModule.js
(function(window, $){
const changeBackgroundColor = function(color) {
$('body').css('background-color',color)
}
window.nameModule = { changeBackgroundColor };
})(window, jQuery)
// index.html
<script src="./jQuery.js"></script>
<script src="./nameModule.js"></script>
<script>
window.nameModule.changeBackgroundColor('blue');
window.nameModule.changeBackgroundColor("red")
</script>
开发者需明确包依赖顺序
这种做法严格要求引入模块的顺序,否则依赖的模块还没加载进来会导致错误。但这也带来一个问题,想象一下,我们引入了一个第三方包,这个第三方包又依赖了其他5个包,我们必须要在代码中明确它的5个依赖包的顺序
不同的第三方包依赖了同一个包的不同版本
如果其他模块和它依赖了同一个包的不同版本呢?我们要在页面中如何引入模块呢?
依赖过多,页面渲染性能问题
还有另外一个问题是,如果我们引入的依赖足够多,几十个上百个script标签,会导致页面请求过多,并且JavaScript是单线程的,可能会导致页面渲染的性能问题。
模块化的重要作用
了解了前面没有模块化,都有哪些问题,以及通过闭包实现模块化的缺点之后,我们的目标已经很清晰。
- 能够把我们的代码拆分成不同的功能模块独立成文件,并且可以被其他模块引用
- 外部不能直接访问模块内的变量,仅能通过模块暴露的方法或者变量访问
- 模块内的依赖对引用者来说是透明的
- 依赖简单好维护管理
前端模块化的演变历程
前端模块化经历了CommonJS、AMD、CMD的发展,直到现在,作为下一代的JavaScript(官方称为ECMAScript6),模块被作为重要组成部分加入其中,补充CommonJS和AMD的设计的不足。下面我们来看下模块化的发展历程和各个模块的形式以及优缺点。
CommonJS的诞生
09年,ServeJS诞生,这是CommonJS的前身,主要解决非浏览器环境的JavaScript生态系统,也就是Module/1.0规范,因为解决了非浏览器环境下的模块加载的问题,大大增加了研发效率。
AMD规范的诞生(Modules/AsynchronousDefinition)
为了将ServeJS的成功经验推广到浏览器端,社区更名CommonJS,在向浏览器发展阶段,形成了三大流派,其中的RequireJS就是在这一阶段发展起来的,也就是AMD规范。
因为 CommonJS 模块系统是同步加载的,当前浏览器环境还没有准备好同步加载模块的条件。
AMD 定义了一套 JavaScript 模块依赖异步加载标准,来解决同步加载的问题。
下面就是一个amd规范的例子
// dataService.js
define(function() {
alert("excute the dataSerice module")
let msg = "www.baidu.com";
function getMsg() {
return msg;
}
return { getMsg };
})
// alert.js
// 依赖了dataService模块
define(['dataService'], function(dataService) {
alert("excute the alert module")
let name = "Arya";
function showMsg() {
alert(dataService.getMsg() + ', ' + name);
}
return { showMsg };
})
//main.js
// 入口文件
;(function() {
// 配置每个模块的路径
require.config({
baseUrl: 'js/',
paths: {
alert: './modules/alert',
dataService: './modules/dataService'
}
})
// 依赖模块alert
require(['alert'], function(alert) {
// 传入alert模块,并调用alert模块中导出的showMsg方法
alert.showMsg();
})
})()
但是amd的一大缺点就是即使我们代码中没有实际执行模块中的方法,该模块也会被加载和执行。
我们将上面的alert.showMsg();代码注释掉,在加载页面的时候,网络仍然下载了相应的模块,并且执行了两次弹窗提醒:alert("excute the dataSerice module")和alert("excute the alert module")。
所以requirejs会在模块方法调用前就加载并执行了,这是和commonjs最大的不同。这也成为了commonjs和amd规范一直不统一的一个点。
而玉伯为了保持和commonjs的统一,提出了sea.js,就是cmd规范。
CMD的诞生
CMD规范的典型代表是sea.js,这是玉伯借鉴了RequireJS写的一套规范,并且和commonjs规范保持一致。下面这是sea.js定义的模块。在模块写法上保留了和commonjs一致的require方法、exports对象和module对象。
define(function (require, exports, module) {
//内部变量数据
var data = 'atguigu.com'
//内部函数
function show() {
console.log('module1 show() ' + data)
}
//向外暴露
exports.show = show
})
define(function (require, exports, module) {
var module2 = require('./module2')
module.exports = {
msg: 'I Will Back'
}
})
在执行的表现上也和commonjs的表现是一致的,当require('./module2')一个模块的时候,该模块才会被下载执行。也就是同步执行,这是和requirejs不同的一点。
requirejs依赖浏览器的script标签的异步,实现模块加载和执行的异步。而sea.js是同步执行的。
sea.js也提供了异步加载模块的方法require.async('./module.js')
define(function (require, exports, module) {
//引入依赖模块(异步)
require.async('./module3', function (m3) {
console.log('异步引入依赖模块3 ' + m3.API_KEY)
})
//引入依赖模块(同步)
var module2 = require('./module2')
function show() {
console.log('module4 show() ' + module2.msg)
}
exports.show = show
})
虽然模块3先被require,但是由于其是异步加载,而模块2是同步加载,所以模块2先被下载下来。
ES6的诞生
前面的集中模块化方案都是JavaScript社区提出的解决方案,在es6中模块终于被作为重要组成部分加入其中。es6的主要设计目标是希望结合commonjs和requirejs的需求。
- 和CommonJS类似,语法都比较简洁紧凑、支持循环依赖
- 和AMD类似,都支持异步加载和模块加载配置
相比于CommonJS和AMD,ES6有着更多的优势:
- 语法比CommonJS更简洁紧凑;
- 声明式语法结构允许静态分析,导入未导出的模块可以在编译阶段报错,可以利用语法分析、利用打包的tree shaking能力,缩小代码体积(静态语法检查、tree shaking等优化)
- 比CommonJS更好的支持循环依赖
ES6模块标准包含两部分:
- 声明式语法(导入和导出)
- 编程式API加速器;设置模块如何加载以及如何有条件地加载模块
和commonjs的区别
-
es6模块无法在条件语句中,导入模块。
- 这是它与CommonJS最大的区别,所以无法在运行时通过条件语句,进行动态的导入。比如下面的代码会在编译的时候抛出异常
if (true) {
import {a} from './a';
}
-
commonJS输出的是值的拷贝,es6输出的是值的引用。
- 也就是说如果CommonJS输出了一个变量a,那么这个变量将不会再改变,而ES6模块输出的变量可以通过模块内部函数进行修改。
//a.js
const a = 1;
function add() {
a += 1;
}
module.exports = {a,add};
//b.js
const { a, add } = require('./a');
console.log(a);//1
add();
console.log(a);//1
//a.js
const a = 1;
function add() {
a += 1;
}
export {a,add};
//b.js
import { a, add } from './a';
console.log(a);//1
add();
console.log(a);//2
es6的局限性
es6模块规范可以解决大多数的加载情况,但是动态导入一直是大家讨论的焦点,静态性的模块系统带来了一系列的好处,但是也限制了开发者对于项目的灵活性的掌控,由于es6是在编译阶段进行的,那么就无法支持动态化的加载模块,不过也有import()可以支持动态的导入,可以满足性能等因素的考虑。import('./modulename')返回一个promise,可以在延迟加载、条件加载或者用户操作等情况下使用import()加载模块,并且不单单可以在模块中使用,还可以在script标签中使用此方法。
if (true) {
import('./a').then({a} => {
console.log(a);
})
}
总结
在这篇文章中,我们了解了前端模块化发展的必要性和其扮演的重要作用,了解了几种重要的前端模块的发展背景和它们各自的特点,让我们对前端模块的发展历史有了更好的了解。
附录
ECMAScript Language: Scripts and Modules