从CommonJs开始认识前端模块化

为什么需要模块化呢?

在WEB开发的早期,为了团队协作和代码的维护,很多开发者会将js代码分开写在不同的文件里面,然后通过多个script标签来加载它们:

html 复制代码
<script src="./a.js"></script>
<script src="./b.js"></script>
<script src="./c.js"></script>

虽然每个代码块处在不同的文件中,但是最终所有JS变量还是会处在同一个全局作用域下,这时候就需要额外注意由于作用域变量提升所带来的问题:

html 复制代码
<script>
  // a.js
  var num = 1;
  setTimeout(() => console.log(num), 1000);
</script>

<script>
  // b.js
  var num = 2;
</script>

在这个例子中,我们分别加载了两个script标签,两段js都声明了num变量。 第一段js代码的本意是希望在1s后打印自己声明的num变量1,但最终运行结果却打印了第二段js代码中的num变量的结果2。虽然两段代码写在不同的文件中,但是因为运行时声明变量都在全局下,最终会产生冲突。 同时,如果代码块之间有依赖关系的话,需要额外注意脚本加载的顺序。如果文件依赖顺序有变动,就需要在html中手动变更加载标签的顺序,这非常麻烦。 要解决这样的问题,就需要将这些js文件模块化

  • 每个模块都要有自己的变量作用域,两个模块之间的内部变量不会参产生冲突;
  • 不同模块之间保留相互的导入和导出的方式方法,模块间能够相互通信。模块的执行和加载遵循一个规范,保证彼此之间的依赖关系;

一、CommonJs

NodeJs中,由于只有一个入口文件(启动文件),但是开发一个应用肯定需要多个文件来配合,所以NodeJs对模块化的需求比浏览器要更大的多。 NodeJs刚刚出来的时候,没有官方的模块化规范,所以它选择使用社区提供的CommonJS作为模块化规范。 在学习CommonJS之前,需要了解模块的导出模块的导入

模块的导出和导入

导出

首先,模块可以理解为一个JS文件,它实现了某部分功能,并且隐藏自己的内部实现。同时提供了一些接口供其他模块使用。 所以说模块有两个核心要素:隐藏暴露隐藏的是自己内部的实现。 暴露的是希望外界使用的接口。 模块化标准,都应该默认隐藏模块中的内部实现,然后通过一些语法或者api的调用来暴露接口。 暴露接口的过程就是我们说的模块的导出。

导入

当我们需要使用一个模块的时候,其实使用的是这个模块暴露的部分(也就是导出的部分),隐藏的部分是永远无法使用的。 所以,当我们通过某种语法或者api来使用一个模块时,这个过程就是模块的导入。

CommonJS规范的内容

使用exports导出模块,使用require导入模块。

具体的规范如下:

  • 如果一个JS文件中存在 exports 或者 require, 则这个JS文件是一个模块。
  • 模块内的全部代码均为隐藏代码,包括全局变量、全局函数,这些全局的内容均不应该对全局变量造成污染。
  • 如果一个模块需要暴露一些api提供给外界使用,需要通过exports导出,exports是一个空对象,你可以为这个对象添加任何需要导出的内容。
  • 如果一个模块需要导入其他的模块,需要通过require实现,它是一个函数,传入模块的路径就可以返回这个模块所导出的所有内容。

所以怎么导出呢?

javascript 复制代码
// 需要隐藏的内部实现
var count = 0;

function getNumber() {
    count++;
    return count;
}

// 暴露内容
exports.getNumber = getNumber;
exports.abc = 123;

// export是一个空对象,也可以这么暴露:
exports: {
    getNumber: getNumber;
    abc: 123
}

// 还可以这么暴露
exports.getNumber = function getNumber() {
    count++;
    return count;
}

如何导入一个模块呢? 这里我们先建立一个入口文件index.js, 这个文件将要导入我们刚刚写的 util.js:

javascript 复制代码
// require就是一个函数,返回一个模块导出的内容
// NodeJS中导入模块,使用相对路径,并且以./或者../开头
var util = require('./util.js');

console.log(util);

我们在终端执行命令node index.js后,结果是:

bash 复制代码
{ getNumber: [Function: getNumber], abc: 123 }

我们可以通过util这个对象来访问getNumber函数,但是我们不能直接获取到count这个变量,这是它内部需要隐藏的内部实现。 这种方式可以解决全局变量被污染的问题了。

NodeJS对于CommonJS规范的实现

1、为了保证高效的执行,仅加载必要的模块,NodeJS只有执行到require函数时才会加载并执行模块(会运行模块内部的代码)。

2、为了隐藏模块中的代码,NodeJS执行模块的时候,它会将模块中的代码放进一个立即执行函数中执行,以保证不污染全局变量。

javascript 复制代码
(function (){
  // 模块中的代码
})()

3、为了保证模块的顺利导出,NodeJS做了这些处理:

  • 在模块开执行前,会初始化一个值module.exports = {};
  • module.exports就是模块将要导出的内容;
  • 为了方便开发者导出内容,又声明了一个变量exports = module.exports
javascript 复制代码
(function (){
  module.exports = {};
  var exports = module.exports;
	// module.exports 和 exports指向的是同一个地方
  // 模块中的代码......
  
  return module.exports;
})()

既然这样,那我们也可以使用module.exports或者exports来暴露一个模块:

javascript 复制代码
module.exports = {
  getNumber: function(){},
  abc: 123,
}

// 也可以这么写
module.exports.abc = 123;

//使用exports
exports = {
  getNumber: function(){},
  abc: 123,
}

注意:模块最后返回的是module.exports, 而不是exports。 为什么这里要提醒呢?不都是指向同一个对象,有什么问题吗? 这里我们来看这么一种情况: 先看util.js

javascript 复制代码
// 需要隐藏的内部实现
var count = 0;

function getNumber() {
    count++;
    return count;
}

module.exports = {
    getNumber: getNumber,
    abc: 123,
}

// 注意这里!
exports.bcd = 456;

再在index.js中打印bcd,看看结果是什么?

javascript 复制代码
var util = require('./util.js');

console.log(util.bcd);

结果是: undefined 这里是为什么呢? 这里我们给util.js加点代码:

javascript 复制代码
// 需要隐藏的内部实现
var count = 0;

function getNumber() {
    count++;
    return count;
}

console.log(module.exports === exports); // true

module.exports = {
    getNumber: getNumber,
    abc: 123,
}

exports.bcd = 456;

console.log(module.exports === exports); // false

打印结果是:truefalse 所以我们知道修改exports之后,exports和module.exports就不是同一个东西了。但是我们最后暴露模块实际是返回的module.exports里的东西,而不是exports里的东西,所以在导出的对象里没有bcd。

4、为了避免反复加载同一个模块,NodeJS默认开启了模块缓存,如果模块加载过一次后,会自动使用之前的导出结果。

这里我们还是使用index.js util.js b.js来举个🌰。 它们之间的依赖关系为:

javascript 复制代码
var util1 = require("./util.js");
var util2 = require("./util.js");

require("./b.js");
javascript 复制代码
// 需要隐藏的内部实现
var count = 0;

console.log('util模块执行了!')

module.exports = {
    abc: 123,
}
javascript 复制代码
console.log('b模块执行了');

var util = require("./util.js");

console.log(util.abc);

运行index.js,最后结果:

javascript 复制代码
util模块执行了!
b模块执行了 
123
{ abc: 123 }

可以看到util模块在index.js中被require了两次,而且b.js还require了一次,但是util模块里面的代码只执行了一次。

在每一次require一个模块的时候,都要去寻找这个文件,而找文件和读取文件的过程是比较耗时的,因为文件是在磁盘中的,而不是在内存中的。 而且我们还可以证明index.js中的util1util2是一个东西:

javascript 复制代码
var util1 = require("./util.js");
var util2 = require("./util.js");

console.log(util1 === util2);
// true

require("./b.js");

二、浏览器模块化遇到的难题

CommonJS的工作原理

当我们使用require来导入一个模块的时候,Node会做这两件事:

  • 通过模块的路径找到文件,然后读取这个文件;
  • 将文件立面的代码放到一个函数执行环境中去执行,然后将执行后的module.exports的值作为require函数的返回结果;

所以,可以认为 CommonJS是同步的,必须要等到加载完文件并执行完之后才能继续向后执行。每当一个模块require一个子模块的时候,都会停止当前模块的解析直到子模块读取解析并加载。

当浏览器遇到CommonJS

1、浏览器想要加载js文件,它需要远程从服务器读取,但是网络传输的效率远远低于Node环境中读取本地文件的效率。由于CommonJS是同步的,所以导致性能不好。

2、如果需要读取js文件并把它放进一个函数环境中运行,这需要浏览器厂商来支持,但是浏览器厂商觉得CommonJS不是官方的标准,是社区的标准,所以不愿意支持。


三、新的规范出现了

其实想要浏览器支持上面说的模块化,主要就是能解决上面的那两个问题。

方法就是:

  • 远程加载JS时间消耗太大? 那就做成异步,模块加载完成后调用回调函数也可以;
  • 模块中的代码需要放到一个函数环境中执行? 那编写的时候我直接放到函数中也可以啊;

有了这种简单有效的思路,后面逐渐出现了AMDCMD规范,有效地解决了浏览器模块化的问题。

AMD规范:

全称是Asynchronous Module Definition,即异步模块加载机制,require.js实现了AMD规范。在AMD中,导入和导出模块都必须放在define函数中。

javascript 复制代码
define([要依赖的模块列表], function(导入的模块名称列表){
  // 模块内部的代码
  return 导出的内容
})

我这里简单举个例子,依赖关系:index.js => a.js、 index.js => b.js、 a.js => b.js

javascript 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script src="./require.js" data-main="./index.js"></script>
</body>
</html>

这里的data-main就是指定入口文件:index.js

javascript 复制代码
define(function() {
    // 模块内部的代码
    return {
        name: 'b模块',
        data: 'b模块的数据',
    }
});
javascript 复制代码
define([
    'b',
], function(b) {
    // 模块内部的代码 (前面模块加载完成之后,才会执行这部分的代码)
    console.log(b);
});

再来看a模块也导入b模块,index.js也导入a模块

javascript 复制代码
define([
    'b',
    'a'
], function(b,a) {
    // 模块内部的代码 (前面模块加载完成后,才会执行这部分的代码)
    console.log(b);
    console.log(a);
});

使用live-server来运行index.html,结果:

b模块的内部的代码只执行了一次,说明b模块也有缓存。 当然还有一种写法,直接传入一个函数:

javascript 复制代码
define(function(require, exports, module) {
    // 模块内部的代码
    console.log('b模块的内部代码');
    module.exports = {
        name: 'b模块',
        data: 'b模块的数据',
    }
})
javascript 复制代码
define(function(require, exports, module) {
    var a = require('a'),
        b = require('b');
    console.log(a, b);
})

看到参数我们应该能大概明白这几个是什么意思了。最终还是会将module.exports的内容导出。

CMD规范:

全称是Common Module Definition,即公共模块定义。玉伯老师开发的sea.js实现了CMD规范,在CMD中导入和导出模块的代码,都必须放在define函数中。

javascript 复制代码
define(function(reuqire, exports, module){
     // 模块内部的代码
})

咦?这不是AMD里面就有吗,为什么还需要CMD呢? 其实这跟require.js的发展有关,一开始require.js只支持这种写法:

javascript 复制代码
define([要依赖的模块列表], function(导入的模块名称列表){
  // 模块内部的代码
  return 导出的内容
})

后来出现了CMD规范,sea.js实现了,然后大受欢迎。后面require.js也实现了CMD规范,所以站在现在来看require.js也能在define中传入函数,并且函数提供reuqire, exports, module这三个参数。 如何使用呢?

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script src="./sea.js"></script>
    <script>
        seajs.use("./index");
    </script>
</body>
</html>
javascript 复制代码
define(function(require, exports, module) {
    var a = require('a'),
        b = require('b');

    // 也可以采用异步的导入方式
    var a = require.async('a', function(a) {
        console.log(a);
    })
    var b = require.async('b', function(b) {
        console.log(b);
    })
    
    console.log(a, b);
})
javascript 复制代码
define(function(require, exports, module) {
    var b = require('b');
    console.log('a模块内部的代码');
    module.exports = 'a模块的内容';
});
javascript 复制代码
define(function(require, exports, module) {
    // 模块内部的代码
    console.log('b模块内部的代码')
    module.exports = {
        name: 'b模块',
        data: 'b模块的数据', 
    }
})

启动index.html,结果:

AMD和CMD的区别?

1、对于依赖的模块,AMD 是提前执行 ,CMD 是延迟执行。不过 RequireJS 后面也改成可以延迟执行(根据写法不同,处理方式不同);

2、AMD 推崇依赖前置, CMD 推崇依赖就近:

javascript 复制代码
// AMD
define(['a', 'b'], function(a, b){
  a.doSomething();
  b.doSomething();
})

define(function(rerquire, exports, module){
  var a = require('a');
  a.doSomething();
  var b = require('b');
  b.doSomething();
})

3、它们加载模块都是异步的,但依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同:

  • AMD在加载模块完成后就会执行该模块,所有模块都加载执行完后会进入require的回调函数,执行主逻辑。这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,会看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。

  • CMD加载完某个依赖模块后并不执行,只是下载而已。在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。


四、ES6模块化

在ES6之后,JS有了语言层面的模块化导入导出关键词和语法拍匹配的ESModule规范。 它的特点就是:

  • 使用依赖预声明的方式导入模块;
    • 1、CommonJS是依赖延迟声明 优点:某些场景下可以提高效率; 缺点:无法在一开始确定模块之间的依赖关系;
    • 2、依赖预声明 优点:在一开始就可以确定模块间依赖关系; 缺点:某些场景下效率较低(后面构建工具会优化);
  • 导入导出方式灵活;
  • 路径表示规范:所有的模块路径必须以./或者../开头;

模块的引入

这部分不是模块化标准。 目前浏览器使用这种方式来引入一个ES6模块文件:

html 复制代码
<script src="入口文件" type="module" ><script/>

模块的导入和导出

分为两种:

  • 基本导入导出;
  • 默认导入导出;

基本导出

基本导出必须有名称,所以导出内容必须跟上声明表达式或者具名符号

javascript 复制代码
// 导出a,值为1
export var a = 1;
// 不能写成这样:
// var a = 1;
// export a;

// 导出函数
export function fn() {}

// 或者
var age = 18;
var name = 'sheep';

// 将age变量的名称作为导出的名称,age变量的值作为导出的值
export {age, name}

基本导入

因为是依赖预加载,因此导入任何其他模块,导入代码必须放在所有代码的最前面。

javascript 复制代码
// import {导入的名称列表} from "模块路径"
import {name, age} from "./a.js"

注意:

  • 导入时可以通过关键字as对导入的模块进行重新命名;
javascript 复制代码
import {a as a2} from "./a.js"
  • 导入的时候使用的符号是常量,不可以修改;
javascript 复制代码
import {name, age} from "./a.js"

name = "xxx";

console.log(name, age)

结果:

  • 可以使用*来导入所有的基本导出,形成一个对象;
javascript 复制代码
import * as obj from "./a.js"
// 这里的obj是别名,可以自定义

默认导出

每个模块,除了允许有多个基本导出之外,还允许有一个默认导出,只有一个所以不需要具名。

javascript 复制代码
export default 默认导出的数据
// 或者
export {默认导出的数据 as default}
javascript 复制代码
export var a = 1;

export default main = 2;
// 或者
// export default {
//     name: "sheep",
//     age:20,
// }

// 再或者
// var number = 3;

// export {number as default}

注意:基本导出和默认导入可以同时存在,但默认导出只有一个。

默认导入

javascript 复制代码
// import 接受的名称 from "模块路径"
import main from "./a.js"

由于默认导入时变量名称时自行定义的,所以没有别名一说。 如果我想同时导入某个模块的默认导出和基本导出,可以这么写:

javascript 复制代码
// import 接收默认导出的变量, {接收基本导出的变量} from "模块路径"
import main, {a} from "./a.js"

注意: 如果使用*,会将所有基本导出和默认导出聚合到一个对象中,默认导出会作为属性default存在。 示例:

javascript 复制代码
export var a = 1;
export var b = 2;

export default {
    fn: function() {},
    name: 'main',
}
javascript 复制代码
import * as data from "./a.js"

console.log("默认导出的内容", data.default);
// a模块中的a和b都在data中
console.log("导出的全部内容", data);

使用live server来查看结果:

其他细节

1、尽量导出不可变值

导出内容时,尽量保证导出的内容是不可变的。虽然导入模块后,无法更改导入内容,但是在导入的模块内部却有可能发生更改,很有可能会发生无法预料的事情。

这里注意,导入模块后,无法在导入模块的那个文件里修改模块的内容,浏览器会报错。 但是可以间接地去改变内容:

javascript 复制代码
export var name = "模块b";

export default function () {
    name = "module b";
}
javascript 复制代码
import method, {name} from "./b.js"

console.log(name);

method();

console.log(name);

通过live server查看结果:

通过这种方式就能改变模块的内容。可以通过const来声明就可以避免这种情况:

javascript 复制代码
export const name = "模块b";

export default function () {
    name = "module b";
}

2、可以使用无绑定的导入用于执行一些初始化代码

如果只是想执行模块中的一些代码,但又不需要导入它的任何内容,可以使用无绑定的导入。 这里说的绑定就值得是上面index.js中一开始导入模块的时候的methodname。 示例:

javascript 复制代码
Array.prototype.print = function() {
    console.log(this);
} 
javascript 复制代码
import "./array.js"

var arr = [1, 2, 3];
arr.print();

3、可以使用绑定再导出,来重新导出来自另一个模块的内容

有的时候,我们可能需要用一个模块封装多个模块,然后有选择的将多个模块的内容分别导出,可以使用下面的方式来完成:

javascript 复制代码
export {绑定的标识符} from "模块路径"

比如这种场景: 我们一开始可能会这么写:

javascript 复制代码
import {a, b} from "./m1.js"
import m2, {e} from "./m2.js"

export {a, b, e, m2 as default};
export const r = "m模块的r";

但是ES6模块化给我们提供一种类似语法糖的写法(更加方便简洁):

javascript 复制代码
export {a, b} from "./m1.js"
export {e, default} from "./m2.js"
export r = "m模块的r"

如果我们要把m1模块中的所有内容都导出,还可以使用*:

javascript 复制代码
export * from "./m1.js"

4、import {data} from "./moduleA"这里的括号并不代表获取结果是一个对象,虽然与ES6之后的对象结构赋值语法相似。 错误示例: 这些语法都是错误的,这里不能使用对象默认值,对象 key 为变量这些语法

javascript 复制代码
import {var1 = 1} from "./moduleA"
import {[test]: a} from "./moduleA"

五、后模块化时代

根据前面的了解,我们知道使用ESModule明显更符合JS的开发,随着web的发展,任何一个支持JS的环境,最终都将会支持ESModule的标准。但是,WEB端受限于用户使用的浏览器版本,并不能随时使用JS的最新特性。为了能让新代码也能运行在用户的低版本了浏览器上,社区里也有很多工具,它们能静态将高版本规范的代码编译为低版本的代码,比较熟知的就是babel

但是,对于模块化相关的importexport关键字,babel最终会将它编译为包含requireexports的CommonJs规范。

这就有了一个新的问题,这样带有模块化关键词的模块,编译折后还是没有办法直接运行在浏览器中,因为浏览器端并不能运行CommonJs的模块。为了能在WEB端直接使用CommonJs规范的模块,除了编译之外,我们还需要一个步骤,就是打包(bundle)

所以打包工具比如webpack/rollup,编译工具babel它们之间的区别和作用就是:

  • 打包工具主要处理的是JS不同版本间模块化的区别;
  • 编译工具主要处理的是JS版本间语义的问题;
相关推荐
AlgorithmAce1 小时前
Live2D嵌入前端页面
前端
nameofworld1 小时前
前端面试笔试(六)
前端·javascript·面试·学习方法·递归回溯
前端fighter2 小时前
js基本数据新增的Symbol到底是啥呢?
前端·javascript·面试
流着口水看上帝2 小时前
JavaScript完整原型链
开发语言·javascript·原型模式
guokanglun2 小时前
JavaScript数据类型判断之Object.prototype.toString.call() 的详解
开发语言·javascript·原型模式
GISer_Jing2 小时前
从0开始分享一个React项目:React-ant-admin
前端·react.js·前端框架
川石教育2 小时前
Vue前端开发子组件向父组件传参
前端·vue.js·前端开发·vue前端开发·vue组件传参
Embrace9242 小时前
为什么 Vue2会出现数据更新视图不更新 Vue3不会出现
javascript·vue.js·ecmascript
qq_415628172 小时前
bpmn.js显示流程图
javascript·vue.js·流程图
GISer_Jing3 小时前
Vue前端进阶面试题目(二)
前端·vue.js·面试