傲娇大少之---【JS的模块化发展历程】

liao一下JS的模块化

昨天重新看了一遍《阿甘正传》,突然嚎啕大哭。
突然不想写毒鸡汤了,突然想做更好的自己,突然不想在乎别人的目光,突然想做好自己该做的事。

JS的模块化历程见证了前端语言的发展,我们现在肯定是直接使用最新的模块化规范了,但是对于JS模块化的发展历程,知道一些,肯定是没有坏处的。所以我打算搞个长文,打算自己啥时候you闲情逸致了,可以读一下,吼吼~

JS模块化出现的必然性

Netscape开发了JavaScript其实当时只是为了实现form表单的验证功能。随着前端的发展,JavaScript不断的优化,直到2006年,ajax的概念被提出,前端拥有了主动向服务端发送请求并操作返回数据的能力。前端的业务逻辑越来越多,代码也越来越多。(关于JavaScript的发展史,有兴趣的同学,看下边的拓展1。)

这时一个html文件的JS代码之多,多到根本装不下。所以当时不得不把代码抽离成一个个JS文件,然后在html中引入这些JS文件。

像我最开始接触前端开发的时候,那个项目就是这样的。一堆堆的JS文件引进来(甚至还有一堆堆的css文件),像下面这个例子一样:

<html>
	<head>
		<script src="jquery.js"></script>
	  	<script src="jquery_ui.js"></script>
	  	<script src="main.js"></script>
	  	<script src="other1.js"></script>
	  	<script src="other2.js"></script>
	  	<script src="other3.js"></script>
	  	...
	</head>
	<body>
	</body>
</html>

我记得我们当时项目里的html里面最少引入了15个js文件……汗!

随着前端业务逻辑的增加,一个项目可能需要多个前端开发工程师,每个开发人员负责一个模块。但是这样,就不可避免的会出现一些比较严重的问题:

  • 全局变量冲突,不同的开发人员定义同名的全局变量,变量值会被覆盖
  • 方法名称冲突,不同的开发人员定义同名的方法,调动时方法会被覆盖
  • 依赖关系冲突,比如上面的jquery-ui.js文件是基于jQuery的插件,所以引入的时候必须放在jquery.js的引入之后

出错后,查找问题又非常的麻烦,只能用肉眼去查找自己的变量值到到底被哪个文件给覆盖了,或者一个文件同事依赖了三个文件,那三个文件之间又各自有依赖关系……等等,烦不胜烦!不知道逼疯了多少小可爱程序员。

所以嘛,在这种情况下,JS的模块的调用,就必须要有规范了,于是就有人提出了模块化的概念。

什么是JS模块化

模块化其实是一种引入模块的规范,一种约束。将每个js文件看作是一个模块,每个模块通过固定的方式引入,并且通过固定的方式向外暴露指定的内容。以达到规范的去管理模块,规范的调用模块,按需加载对应模块。

JS模块化的发展历程

在这里插入图片描述
咱们来详细说一下,在JavaScript的成长过程中的经历吧!

由于模块化的概念的得到了广大开发者的响应。于是很多模块化的方案应运而生。

咱们用一个最简单的例子解释一下模块化的发展历程哈,(比如我需要引入三个js文件a.js,b.js,c.js),在没有模块化的概念的时候,是这样的:

a.js文件 >>

var a = 0; // ES5没有let,没有块级作用域,都是用var声明变量。
setTimeout(() => {
    console.log('a.js文件的输出:' + a);
}, 1000);

b.js文件 >>

var a = 5; // ES5没有let,没有块级作用域,都是用var声明变量。
console.log('b.js文件的输出:' + a);
var b = a;

c.js >>

console.log('c.js文件的输出:' + b);

index.html文件 >>

<html>
    <head>
        <script src="a.js"></script>
        <script src="b.js"></script>
        <script src="c.js"></script>
    </head>
    <body>
    </body>
</html>

执行一下:
在这里插入图片描述
可以发现a.js文件的全局变量值被b.js文件给覆盖了,而且c.js文件是依赖于b.js文件的。这是如果我们把b.js和c.js文件的调用位置调换一下:

index.html文件 >>

<html>
    <head>
        <script src="a.js"></script>
        <script src="c.js"></script>
        <script src="b.js"></script>
    </head>
    <body>
    </body>
</html>

执行后:
在这里插入图片描述
这个例子很简单明了的说明了当时模块化需要解决的严峻问题:

  • 变量命名空间的问题(包括变量和函数)
  • 文件依赖关系的问题

IIFE

模块化萌芽时期,有人提出了IIFE(Immediately Invoked Function Expression),立即调用的函数表达式,也叫自执行函数。IIFE的写法如下:(现在我们周围应该还是有一些大神这么写前端插件的)

(function(){
	// my special code
})();

IIFE的特点:隔离作用域。可以弥补JS在命名空间方面的缺陷:JS只有全局作用域、函数作用域,从ES6开始才有块级作用域(let声明方式)。在自执行函数内部定义的变量的作用域只在函数内部。

当时我们致力于写各种js插件的时候,基本都是通过这种自执行函数的方式写的。

因为自执行函数即无需调用,就可以执行的函数,所以如果我们引入的每个JS文件内容都是使用IIFE包裹的,每个模块需要的全局变量都在函数的内部声明定义,那么就可以解决同名全局变量的问题了。

话不多说,用IIFE来重写上面的例子:(这个例子比较简单,正常我们开发过程中使用的是IIFE + 闭包的方式来实现)

a.js >>

(function(){ // 这是个匿名函数
    var a = 0; // ES5没有let,没有块级作用域,当时都是用var声明变量。
    setTimeout(() => {
        console.log('a.js文件的输出:' + a);
    }, 1000);
})();

b.js >>

var b = (function(){
	var a = 5; // ES6之前没有let,变量都用var声明,没有块级作用域。
	console.log('b.js文件的输出:' + a);
	return a;
})();

c.js >>

(function(){
	console.log('c.js文件的输出:' + b);
})(b);

index.html >>

<html>
    <head>
        <script src="a.js"></script>
        <script src="b.js"></script>
        <script src="c.js"></script>
    </head>
    <body>
    </body>
</html>

执行一下:
在这里插入图片描述

可以看到,我在a.js和b.js文件中都定义了变量a,但他们之间是不会互相影响的。
不过因为c.js文件还是依赖于b.js文件的,所以如果我们调换b和c文件的调用顺序,还是会报错!

总结一下IIFE的优缺点:

优点:

  • 隔离作用域,避免了变量重名干扰,并且最少的暴露变量,避免全局污染。
  • 模块外部不能轻易的修改闭包内部的变量,程序的稳定性增加。
  • 模块与外部的连接通过IIFE传参,语义化更好,清晰地知道有哪些依赖。

缺点:

  • 各个模块的依赖关系仍然要通过script引入的顺序来保证。
  • 虽然减少了暴露变量,但仍然有全局变量,还是存在被覆盖的风险。(如上例b.js文件)

LAB.js
IIFE虽然解决了命名空间的问题,但是对于文件依赖的问题还没有解决。这时一系列的类似于LABJS的插件就横空出世了,作用是动态并行加载脚本文件以及管理加载脚本文件的执行顺序。

LAB.js的示例:

$LAB.script("a.js")
    .script("b.js")
    .wait(function () { // 等待所有文件引入后才执行
		console.log(a + b);
	});

这种感觉像什么呢,就是为了解决文件之间的依赖关系,而设计的插件。说白了就是没有从根本上解决问题,而且,我们去区分文件之间的依赖关系的时候,多么的麻烦……

IIFE和LAB.js虽然没有完全改善模块化的问题,但是在模块化的萌芽时期,给后来人提供了非常好的是思路和方向!革命历史上每一个带血的脚印都不是白走的。

规范命名空间

由于IIFE还是暴露了一些全局变量,于是Yahoo的YUI早期参照Java的变量的声明方式去为插件规范命名空间,比如a.js插件是为了实现确认弹出框功能,那么就给这个插件的全局变量增加命名空间:

例如:

Dialog.mInput.a = '123';
Dialog.mInput.a.method();

可以说是可以解决一些问题的,但是这个规范性比较强,定义的命名空间要非常严格,才不会重复。而且使用起来比较麻烦。一堆堆重复的代码,真的是……

CommonJs

2009年Nodejs发布了,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。其中Commonjs是作为Node中模块化规范以及原生模块面世的。Node中提出的Commonjs规范具有以下特点:

  • 原生Module对象,每个文件都是一个Module实例
  • 文件内通过require对象引入指定模块
  • 所有文件加载均是同步完成
  • 通过module关键字暴露内容
  • 每个模块加载一次之后就会被缓存
  • 模块编译本质上是沙箱编译
  • 由于使用了Node的api,只能在服务端环境上运行

基本上CommonJS发布之后,就成了Node里面标准的模块化管理工具。同时Node还推出了npm包管理工具,npm平台上的包均满足CommonJS规范,随着Node与npm的发展,CommonJS影响力也越来越大,并且促进了后面模块化工具的发展,具有里程碑意义的模块化工具。

我们知道Nodejs是服务器端的语言,所以其实并不适用于浏览器的网页开发,但是对于JS的模块化来说提供了一个非常好的思路。

大体说一下CommonJS规范的实现:

根据CommonJs规范,每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见,CommonJS规范加载模块是同步的。

a.js文件:

var x = 5;
var addX = function (val) {
  return val + x;
};

如上x和addX都为私有变量,无法作为全局变量使用。

如果想在多个文件分享变量,必须定义为global对象的属性。如下:

global.x = 5;

但是这种写法肯定是不推荐的,CommonJS规范规定,每个模块内部都有一个module变量,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。

改写一下:
a.js文件:

var x = 5;
var addX = function (val) {
  return val + x;
};
module.exports.x = x;
module.exports.addX = addX;

然后在b.js文件中通过require的方式加载a.js模块:
b.js文件:

var moduleA = require('./a.js');

console.log(moduleA.x); // 5
console.log(moduleA.addX(1)); // 6

在之前我们如果我们引入的模块之间存在依赖关系,比如b模块依赖于a,那我们使用b模块的时候,需要先引入a模块。但是使用require加载模块,可以完全的避免模块之间的依赖关系,就是即使b模块依赖于a,我们在加载a模块的时候也无需关注b模块,只需要加载a即可。

所以CommonJS可以说是完全解决了我们上面提过的模块化遇到的难题。

  • 各模块间变量作用域完全隔离
  • 不存在先后加载的文件之间的依赖关系问题。

AMD

CommonJS虽好,但是我们都知道,Nodejs是运行在服务器端的JavaScript。所以我们要将CommonJS的理念使用到我们浏览器端的JavaScript上,所以同一年,AMD规范出现了。

ADM(Asynchronous Module Definition)异步模块定义。

我们发现,ADM与CommonJS还是不一样的,它叫做异步模块定义,所以它的理念虽然与CommonJS一致,但是它在CommonJS上做了改进,CommonJS是同步加载模块,而ADM为了提高效率, 加载模块是异步的。

ADM规范定义了两个API:

define([depends], callback);  
require([module], callback);

顾名思义,define接口用来定义并暴露一个模块,require接口用来加载模块。

require方法肯定不是JavaScript的原生方法,所以ADM规范是通过JavaScript库来实现的,主流的实现ADM规范的JavaScript库有: require.js和curl.js。require.js使用的更多一些,我们也拿require.js来讲。感兴趣的同学可以去网上下载一个require.js文件看看效果,还是挺有意思的。

还是用我们最简单的例子看效果:
a.js文件:(我们在a文件中引入b模块)

require(['b'], function(b){
    b(123); // 回调函数
});

b.js文件:(定义一个模块b,b模块又是依赖于c模块的)

define('b', ['c'], function (c) {
    return function (p){
        console.log(p + c.name);
    }
});

c.js文件:(单纯的定义一个c模块)

define('c', function () {
    return {
        name: 'CCCCC'
    };
});

index.html文件:(引入require.js文件(大家自行下载)和a.js文件)

<!DOCTYPE html>
<html>
    <head>
        <script src="require.js" data-main="./a" async="async" defer></script>
    </head>
    <body>
    </body>
</html>

执行结果:
在这里插入图片描述
所有的变量都是包裹的,不会出现变量冲突的问题,加载模块的时候也无需关心模块间的依赖关系。搞定了。

UMD

UMD规范是CommonJS和AMD两种规范相抗衡的基础上,将二者合并之下的产物。UMD规范同时兼容AMD和CommonJS规范。

UMD核心思想是,如果在 commonjs 环境(存在 module.exports,不存在 define),将函数执行结果交给 module.exports 实现 Commonjs,否则用AMD环境的define,实现AMD。

所以一个UMD的基本结构:

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD规范
        define(['b'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // CommonJs
        module.exports = factory(require('b'));
    } else {
        // 两者都不是
        root.returnExports = factory(root.b);
    }
}(this, function (b) {
    return {};
}));

怎么说呢,这个UMD规范就是个自执行函数,然后内部去判断使用的规范是CommonJS的,还是AMD的。就是既支持require(),也支持require.js。不过个人感觉这个…不好说。

CMD

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

CMD和AMD目的是一样的,只是在AMD的基础上进行了优化,CMD也需要引入一个JavaScript库用来加载模块—Sea.js。
Sea.js加载模块框架是阿里的前淘宝UED,现支付宝前端工程师玉伯编写的。

Sea.js推崇一个模块一个文件。

基本语法:

define(string?, obj?, factory?)
// 使用define方法定义模块,参数可以是字符串,对象,或者函数
// 参数为对象、字符串时,表示模块的接口就是该对象、字符串。比如:define({name: 'kk', age: 20});
// 参数为函数时,表示是模块的构造方法。执行该构造方法,可以得到模块向外提供的接口。
// 方法在执行时,默认会传入三个参数:require、exports 和 module:
// define(function(require, exports, module) {……});

这里我们不写的那么细了,还是用我们上边的那个例子,简单的用一下,感兴趣的童鞋可以自己去查Sea.js的详细用法。

我们现在使用CMD规范重写上边的例子。

a.js文件:

define(function(require, exports , module) {
    var b = require("b");
    exports.doSomething = function() {
        console.log("in a");
    };
});

b.js文件:

define(function(require, exports, module) {
    var c = require("c");
    exports.doSomething = function() {
        console.log("in b");
    };
});

c.js文件:

define(function(require, exports, module) {
    var a = require("a");
    exports.doSomething = function() {
        console.log("in c");
    };
});

index.html文件:(引入sea.js文件(大家自行下载)和a模块,这个例子我没有试,大家感兴趣的可以自己写一下)

seajs.use("a" , function(a){
    a.doSomething();
});

但Sea.js和require.js代码逻辑高度重合,所以Sea.js没有require.js的使用度高。

ES6 Modules规范

2015年ES6的发布,JavaScript终于拥有了自己的模块化规范。再也不用引入各种JavaScript库了。

现在的前端工程师应该都是使用ES6的模块化规范的,大家也比较熟悉了。

特点:

  • 在编译时就能确定模块的依赖关系,以及输入和输出的变量,在编译的时候加载模块。相比CommonJS等在运行时加载的方式,效率更高;
  • 每一个模块只加载一次, 并只执行一次,重复加载同一文件,直接从内存中读取;
  • 每个模块都有自己的上下文,每一个模块内声明的变量都是局部变量,不会污染全局作用域。
  • 可以只加载引入的模块,而不加载模块文件本身;
  • ES6 的模块自动开启严格模式,不管你有没有在模块头部加上 use strict。
  • 模块中可以导入和导出各种类型的变量,如函数,对象,字符串,数字,布尔值,类等。

ES6模块化语法:

  1. improt
    import 命令会提升到整个模块的头部,首先执行。
  • 只读属性:不允许在加载模块的脚本里面,改写接口的引用指向,即可以改写 import 变量类型为对象的属性值,不能改写 import 变量类型为基本类型的值。
  • 单例模式:多次重复执行同一句 import 语句,那么只会执行一次,而不会执行多次。import 同一模块,声明不同接口引用,会声明对应变量,但只执行一次 import 。
  • 静态执行特性:import 是静态执行,所以不能使用表达式和变量。

例如:

import {a} from "./xxx.js";
import {a, b, c} from "./xxx.js";

import {a as aFirst} from "./a.js";
import {a as aSecond} from "./b.js"; // 两个模块出现同名变量,可以使用as重新定义变量名

以上方法都是正确可行的。

  1. export
    导出有两种方式,export {}和export default;
  • 单纯的export {},导出的变量,对象,函数必须要有名称。
  • export default 中的 default 是对应的导出接口变量。
  • 通过 export 方式导出,在导入时要加{ },export default 则不需要。
  • export default 向外暴露的成员,可以使用任意变量来接收。

例子:

let a = 1;
let b = 2;
let c = 3;
export {a, b, c};

还是上边那个最简单的例子:
a.js文件:

import {b, c} from 'b.js';
function a () {
	console.log(c);
	console.log(b.name);
}
export default;

b.js文件:

import {c as ccc} from 'c.js';
let b = {
	name: ccc.name
};
let c = 1;
export {b, c};

c.js文件:

let c = {
	name: 'ediot',
	mind: 'happy'
};
export {c};

index.html文件:

<html>
    <head>
        <script type="module">
        import aModle from './a.js';
        window.onload = function () {
            console.log(123);
            aModle.a();
        };
    </script>
    </head>
    <body>
    </body>
</html>

主: script标签需要设置type="module"属性,这样浏览器就会知道这是一个ES6模块了。但是如果我们本地运行的话,还是会报错的:
在这里插入图片描述
没错,它报跨域了,因为我们直接运行的本地html文件,用的协议是file:///,所以我们的代码没有部署到服务器上,也就是说,要先部署服务器才能验证对错,我们在实际开发中,基本都会连接服务器开发,所以感兴趣的小伙伴可以在自己的项目中测试一下export和import的用法就行。

es6的模块化,不但解决了我们模块化历程面临的各个难题,而且语义清晰,用法简单,是各大前端开发工程师居家旅行必备良品啊。
在这里插入图片描述
累了,感觉码的字数快赶上本少的毕业论文了。

拓展

JavaScript的诞生(摘抄自《JavaScript高级程序设计》)

JavaScript诞生于1995年,它的主要目的是处理以前由服务器端语言负责的一些输入验证操作。在JavaScript问世以前,必须把表单数据发送到服务器端才能确定用户是否填写某个必填域,是否输入了无效的值等。所以当时用户面临的情况就是好不容易填写完了表单,然后点击提交按钮,等了30秒钟,服务器返回说有个必填字段未填,或者某个字段输入无效,体验非常的差。

当时走在技术革新最前沿的Netscape公司,决定着手开发一种客户端语言,用来处理这种简单的验证,当时就职于Netscape公司的布兰登·艾奇(Brendan Eich),开始着手为计划于1995年2月份发布的Netscape Navigator2开发一种名为LiveScript的脚本语言。为了赶在发布日期前完成,Netscape和Sun公司建立了一个开发联盟。在Netscape Navigator2正式发布前夕,Netscape为了搭上没提热炒Java的顺风车,临时把LiveScript改名为JavaScript。

而如今的JavaScript早就不限制于表单的数据验证,而是具备了与浏览器窗口及其内容等几乎所有方面完美交互的能力。今天的JavaScript已经成为一门功能全面的变成语言,能够处理复杂的计算和交互,拥有了闭包,匿名函数,甚至元编程等特性。所有的浏览器都依赖着它。

JavaScript从一个简单的输入验证器发展成为一门强大的编程语言,完全出乎人们的意料。应该说,它是一门简单而又复杂的语言,学会它只需片刻功夫,但是要真正掌握它,可能需要数年的时间。

其实JavaScript的年龄不算大,也不算是特别的完善,每个前端小可爱都有责任去让它变的更好,革命尚未成功,同志仍需努力。

在这里插入图片描述

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值