JavaScript模块化的学习与实践

134 阅读11分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

JavaScript模块化

发展

个人把JavaScript模块化的发展分为了三个时期。

前期

最初的JavaScript仅仅用于前端页面的交互,所以其实并没有模块化的概念,我们可以直接通过在html中定义script标签并设置src来引入其他JavsScript文件。

     <script src="a.js"></script>
     <script src="b.js"></script>

这样引入script标签存在一个十分大的弊端,就是污染全局作用域,例如我在a.js和b.js都使用let定义了一个相同名字的变量,那么就会报错。

中期

IIFE 也就是立即调用的函数表达式 ,早期 IIFE 的出现是为了弥补 JS只有全局作用域(global scope)、函数作用域(function scope)而没有块级作用域的缺陷。

我们知道ES6是引入了块级作用域的概念,但是ES5没有。

早期为了实现作用域的隔离呢,只有 function 才能实现作用域隔离,因此如果要将一段代码中的变量、函数等的定义隔离出来,只能将这段代码封装到一个函数中。

后期(成熟)

由于Nodejs的出现,JavaScript也可以在后端大展拳脚了,所以必须有一个标准的模块化方案来支持JavaScript后端模块的开发,CommonJS变出现了,随后AMD、UMD和ES6 Moudle陆续出现,解决了JavaScript前后端应用模块化的问题。

CommonJS

是 node.js 制定,可运行服务端,规范是每一个文件都是一个模块,有单独的作用域、变量和方法等,并且对其他文件是不可见的,这也是独立性的体现。

特征

  • 通过module和exports对外暴露需要导出的内容,通过require导入其他模块暴露的内容
  • 文件可以被重复引用、加载。第一次加载时会被缓存,之后再引用就直接读取缓存
  • 加载某个模块时,module.exports 输出的是值的拷贝,一旦这个值被输出,模块内再发生变化不会影响已经输出的值。

module对象

Node内部提供一个Module构建函数。所有模块都是Module的实例。

 function Module(id, parent) {
   this.id = id;
   this.exports = {};
   this.parent = parent;
   // ...
 }

常用的属性含义:

 module.id 模块的识别符,通常是带有绝对路径的模块文件名。
 module.filename 模块的文件名,带有绝对路径。
 module.loaded 返回一个布尔值,表示模块是否已经完成加载。
 module.parent 返回一个对象,表示调用该模块的模块。
 module.children 返回一个数组,表示该模块要用到的其他模块。
 module.exports 表示模块对外输出的值。

示例:

a.js

 let a = 1;
 ​
 function sayHi() {
   console.log("Hello World!");
 }
 ​
 module.exports = {
   a,
   sayHi,
 };
 ​

main.js

 let moduleA = require("./a.js");
 ​
 console.log(moduleA);//{ a: 1, sayHi: [Function: sayHi] }

exports变量

为了方便,Node为每个模块提供一个exports变量,指向module.exports。

 exports.area = function (r) {
   return Math.PI * r * r;
 };
 ​
 exports.circumference = function (r) {
   return 2 * Math.PI * r;
 };

注意,不能直接将exports变量指向一个值,因为这样等于切断了exportsmodule.exports的联系,因为最后导出的仍然还是module.exports。

如果你觉得,exportsmodule.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports

require(XXX)加载规则

(1)如果参数字符串以“/”开头,则表示加载的是一个位于绝对路径的模块文件。比如,require('/home/marco/foo.js')将加载/home/marco/foo.js

(2)如果参数字符串以“./”开头,则表示加载的是一个位于相对路径(跟当前执行脚本的位置相比)的模块文件。比如,require('./circle')将加载当前脚本同一目录的circle.js

(3)如果参数字符串不以“./“或”/“开头,则表示加载的是一个默认提供的核心模块(位于Node的系统安装目录中),或者一个位于各级node_modules目录的已安装模块(全局安装或局部安装)。

举例来说,脚本/home/user/projects/foo.js执行了require('bar.js')命令,Node会依次搜索以下文件。

 /usr/local/lib/node/bar.js
 /home/user/projects/node_modules/bar.js
 /home/user/node_modules/bar.js
 /home/node_modules/bar.js
 /node_modules/bar.js

这样设计的目的是,使得不同的模块可以将所依赖的模块本地化。

(4)如果参数字符串不以“./“或”/“开头,而且是一个路径,比如require('example-module/path/to/file'),则将先找到example-module的位置,然后再以它为参数,找到后续路径。

(5)如果指定的模块文件没有发现,Node会尝试为文件名添加.js.json.node后,再去搜索。.js件会以文本格式的JavaScript脚本文件解析,.json文件会以JSON格式的文本文件解析,.node文件会以编译后的二进制文件解析。

(6)如果想得到require命令加载的确切文件名,使用require.resolve()方法。

CommonJS缺点

  • 它的模块加载器由 Node.js 提供,依赖了 Node.js 本身的功能实现。如果 CommonJS 模块直接放到浏览器中无法执行。
  • CommonJS 约定以同步的方式进行模块加载,运行时加载,这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏览器要等待响应返回后才能继续解析模块。即,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。

AMD

CommonJS的主要问题是运行时同步加载,这使得它并不适合浏览器端的模块加载。

AMD全称为Asynchronous Module Definition,即异步模块定义规范,是开源社区早期为浏览器提供的,它采用同时并发加载所依赖的模块,当所有依赖模块都加载完成之后,再执行当前模块的回调函数。这种加载方式和浏览器环境的性能需求刚好吻合。

由于没有得到浏览器的原生支持,AMD 规范仍然需要由第三方的 loader 来实现。不过 AMD 规范使用起来稍显复杂,代码阅读和书写都比较困难。因此,现在AMD用的并不多,感兴趣的可以自行查询资料。

UMD

UMD 的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJSAMD,还是非模块化的环境(当时 ES6 Module 还未被提出)。

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

ES6 Moudle

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

 // CommonJS模块
 let { stat, exists, readfile } = require('fs');
 ​
 // 等同于
 let _fs = require('fs');
 let stat = _fs.stat;
 let exists = _fs.exists;
 let readfile = _fs.readfile;

上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

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

 // ES6模块
 import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

所以用一句话描述CommonJS和ES6 Moudle的区别就是:CommonJs是运行时加载模块,ES6 Moudle是编译时加载模块。

由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。

模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

export命令

 // profile.js
 export var firstName = 'Michael';
 export var lastName = 'Jackson';
 export var year = 1958;

等价于

 // profile.js
 var firstName = 'Michael';
 var lastName = 'Jackson';
 var year = 1958;
 ​
 export { firstName, lastName, year };

export命令除了输出变量,还可以输出函数或类(class)。

 export function multiply(x, y) {
   return x * y;
 };
 ​
 export class Person {
     //……
 }

通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。

 function v1() { ... }
 function v2() { ... }
 ​
 export {
   v1 as streamV1,
   v2 as streamV2
 };

需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

 // 报错
 export 1;
 ​
 // 报错
 var m = 1;
 export m;
 ​
 // 报错
 function f() {}
 export f;

正确写法

 // 写法一
 export var m = 1;
 ​
 // 写法二
 var m = 1;
 export {m};
 ​
 // 写法三
 var n = 1;
 export {n as m};
 ​
 // 正确
 export function f() {};
 ​
 // 正确
 function f() {}
 export {f};

另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值,这与CommonJS值是值拷贝的特点正好相反。

 export var foo = 'bar';
 setTimeout(() => foo = 'baz', 500);

上面代码输出变量foo,值为bar,500 毫秒之后变成baz

最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,import`命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。

 //错误写法
 function foo() {
   export default 'bar' // SyntaxError
 }
 foo()

import命令

使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。

 // main.js
 import { firstName, lastName, year } from './profile.js';
 ​
 function setName(element) {
   element.textContent = firstName + ' ' + lastName;
 }

上面代码的import命令,用于加载profile.js文件,并从中输入变量。import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。

如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。

 import { lastName as surname } from './profile.js';

import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。

 import {a} from './xxx.js'
 ​
 a = {}; // Syntax Error : 'a' is read-only;

上面代码中,脚本加载了变量a,对其重新赋值就会报错,因为a是一个只读的接口。但是,如果a是一个对象,改写a的属性是允许的。

 import {a} from './xxx.js'
 ​
 a.foo = 'hello'; // 合法操作

import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。若只给出vue,axios这样的包名,则会自动到node_modules中加载。

整体加载

除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

 import * as circle from './circle';
 ​
 console.log('圆面积:' + circle.area(4));
 console.log('圆周长:' + circle.circumference(14));

注意,模块整体加载所在的那个对象(上例是circle),应该是可以静态分析的,所以不允许运行时改变。

exprot default

从前面的例子可以看出,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。

为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

例如我们在使用 axios时,可以直接自定义名称,并且不需要使用花括号:

 import axios from "axios";
 axios.get('/users')
   .then(res => {
     console.log(res.data);
   });

如果我们不想使用axios来命令,我们想使用http:

 import http from "axios";
 http.get('/users')
   .then(res => {
     console.log(res.data);
   });|

这使得我们使用第三方模块时,更加方便,只需要了解模块的功能,而不需要关系模块的具体导出。