模块化

一、CommonJS

CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为 ServerJS,后来为了
体现它的广泛性,修改为 CommonJS,平时我们也会简称为 CJS

1. 模块导出

1.1 exports 导出

exports 是一个对象,我们可以在这个对象中添加需要导出的多个属性。

// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']

exports.name = name;
exports.age = age;
exports.songs = songs;

我们可以在另外一个 js 文件中导入:

// main.js
var bar = require('./bar');

上面的代码表示 bar.js 文件里的 name、age、songs 被导出,然后被导入到 main.js 文件中的 bar 变量。实际上是 bar 变量等于 exports 这个对象,也就是 require通过 node 文件导入查找方式,最终找到了 bar.js 文件中的 exports 这个对象,并将该对象赋值给 bar

通过上面的解释我们可以知道,barexports 是指向同一个对象的,所以,我们修改 exports 对象属性的值,相应的,bar 对象属性的值也要跟着改变。看下面代码:

// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']

exports.name = name;
exports.age = age;
exports.songs = songs;

setTimeout(() => {
  exports.name = '张学友'
}, 1000)
// main.js
var bar = require('./bar');
console.log(bar.name);

setTimeout(() => {
  console.log(bar.name);
}, 2000)

输出结果:

'xzy'
'张学友'

1.2 module.exports 导出

我们在使用 Node 时,有时会使用 exports 导出,有时又会使用 module.exports 导出,那么这两者有什么区别吗?

区别就是每个模块(js 文件)导出的对象实际上是 module.exports 所指的对象,也就是 require 导入的是 module.exports 对象,而不是 exports 对象, exports 仅仅只是默认指向了 module.exports对象而已。

exports = module.exports;

所以,如果我们手动修改了 exports 的指向,那么导出 exports.xxx = xxx 是无效的。看下面代码:

// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']

exports = {}; // 修改 exports 的指向

exports.name = name;
exports.age = age;
exports.songs = songs;
// main.js
var bar = require('./bar');

console.log(bar);
// {}

上面代码可以看出,模块导出的对象不是 exports

接下来,再看另外一组代码:

// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']

module.exports = {
  name: '张学友'
}
exports.name = name;
exports.age = age;
exports.songs = songs;
// main.js
var bar = require('./bar');

console.log(bar);
// { name: '张学友' }

可以看出,即使我们修改了 module.exports 的指向(赋值一个新对象),在 main.js 导入的仍然是 module.exports 所指的那个对象。这就是说明,模块真正导出的是 module.exports 所指对象,而 exports默认指向了 module.exports,这样我们修改 exports 对象的属性便会直接修改了 module.exports 所指的对象。此外,exports 会受到我们手动修改其指向而产生非预期的导入结果。

因此,在某些必要的情况下,我们推荐使用 module.exports 来添加我们需要导出的内容。

注意: Node 源码里是先创建 module.exports = {} ,然后再将 exports 指向 module.exports ,并非是先创建 exports = {} ,然后再将 exports 赋值给 module.exports

CommonJS 中是没有 module.exports 的概念的,但是为了实现模块的导出,Node 中使用的是 Module 的类,每一个模块都是 Module 的一个实例,也就是 module

所以在 Node 中真正用于导出的其实根本不是 exports,而是 module.exports,因为 module 才是导出的真正实现者。

2. 模块导入

Node 的模块导入仅有 require 这一种方法。

require 是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。

var mod = require(x); // x 是字符串

这里,我们需要知道 require 的导入查找规则,分为两种:x 是一个路径和 x 不是一个路径。

2.1 x 是一个路径

x 是一个路径时,即以 ./ 或 ../ 或 /(根目录)开头的,这里也分两种情况:

  • 如果 x 有后缀名,按照后缀名的格式查找对应的文件,如果没有找到,就报错:Cannot find module x

  • 如果 x 没有后缀名,会按照如下顺序进行查找:

    1. 直接查找文件 x

    2. 查找 x.js 文件

    3. 查找 x.json 文件

    4. 查找 x.node 文件

    5. 没有找到对应的文件,将 x 作为一个目录,查找 x 直接目录下面的 index 文件

    6. 查找 x/index.js 文件

    7. 查找 x/index.json 文件

    8. 查找 x/index.node 文件

    9. 如果找不到,报错 Cannot find module x

2.2 x 不是一个路径

x 不是一个路径,就是一个不含 /\ 的字符串,也分两种情况:

  • 如果 x 是一个核心模块,直接返回核心模块,并且停止查找
  • 如果 x 不是一个核心模块,直接报错:Cannot find module x

3. 模块的加载过程

3.1 模块在被第一次引入时,模块中的 js 代码会被运行一次

当我们在一个模块中导入另外一个模块时,这个被导入的模块的 js 代码会被执行一次。

3.2 模块被多次引入时,会缓存,最终只加载(运行)一次

因为每个模块对象 module 都有一个属性:loaded,为false表示还没有加载,为true表示已经加载。当被引入一次后, loaded 被置为 ture ,表示已经被加载过一次了,所以之后多次引入不会引起代码的重新运行。

3.3 如果有循环引入,那么加载顺序是什么?

如果出现下图模块的引用关系,那么加载顺序是什么呢?

这是一个图结构,图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search),Node 采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb。

4. CommonJS 规范、AMD 规范 和 CMD规范

4.1 CommonJS 规范 的缺点

CommonJS加载模块是同步的,同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行。这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快。但是如果将它应用于浏览器呢?浏览器加载 js 文件需要先从服务器将文件下载下来,之后在加载运行,这样需要等很久才能渲染出来页面,非常影响用户对页面的第一印象,并且还会影响用户的交互体验。

所以在浏览器中,我们通常不使用CommonJS规范。

当然在webpack中使用CommonJS是另外一回事,因为它会将我们的代码转成浏览器可以直接执行的代码。

在早期为了可以在浏览器中使用模块化,通常会采用 AMD 规范或 CMD 规范,但是目前一方面现代的浏览器已经支持 ES Modules,另一方面借助于 webpack 等工具可以实现对 CommonJS 或者 ES Module 代码的转换。所以,AMD 规范和 CMD 已经使用非常少了。

4.2 AMD 规范

AMD 是 Asynchronous Module Definition(异步模块定义)的缩写,AMD 规范主要是应用于浏览器的一种模块化规范,它采用的是异步加载模块,事实上 AMD 规范还要早于 CommonJS,但是 CommonJS 目前依然在被使用,而 AMD 规范使用的较少了

由于规范只是定义代码应该如何去编写,只有有了具体的实现才能被应用

所以,AMD 规范实现的比较常用的库是 require.js 和 curl.js 。

4.3 CMD 规范

CMD 是Common Module Definition(通用模块定义)的缩写,CMD 规范也是应用于浏览器的一种模块化规范,它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来,但是目前 CMD 规范使用也是非常少了。

CMD 规范也有自己比较优秀的实现方案:SeaJS。

二、ES Module

JavaScript 没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD。所以 ES6 推出了 ES Module 模块化。

ES Module和CommonJS的模块化有一些不同之处:

  • 一方面它使用了 importexport 关键字

  • 另一方面它采用解析期的静态分析,并且也加入了动态引用的方式

注意:采用 ES Module将自动采用严格模式:use strict

1. 导出方式

1.1 export

// bar.js
export var name = '张学友';
export var age = 18;

在语句声明的前面直接加上export关键字。

1.2 export

将所有需要导出的标识符,放到export后面的 {}中

// bar.js
var name = '张学友';
var age = 18;

export {
  name,
  age
}

这里的 {} 里面不是 ES6 的对象字面量的增强写法,{} 也不是表示一个对象的,它就是一个单纯的花括号,里面只是存放导出的标识符列表内容,所以:export {name: name},是错误的写法。

我们使用最多的是这种方式,还有下面的 export default

1.3 导出时给标识符起一个别名

// bar.js
var name = '张学友';
var age = 18;

export {
  name as myname,
  age as myage
}

在前面的第二种方法中,我们可以为导出的变量添加别名,这样在导入的时候就能减少命名数不足的冲突了。不过,在导入的时候,需要导入对应的别名而不是原来的变量名。

1.4 export default

前面三种方式的导出功能都是有名字的导出。在 export 导出时指定了名字,在 import 导入时需要知道导出的具体名字。因此,我们在导入时必须去查看导出的变量名,这样就会产生一种不便。所以,官方给出来一种默认导出方式 export default

默认导出时可以不需要指定名字,在导入时不需要也不能使用 {},并且可以自己来指定名字,同时它也方便我们和现有的CommonJS等规范相互操作。

// bar.js
var name = '张学友';
var age = 18;

export default {
  name,
  age
}
// export name;

// export function(){}
// index.js
import obj from './bar.js'
console.log(obj);
// {name: "张学友", age: 18}

上面代码中,export default 后面可以跟一个任何类型的值,此时跟在其后面的 {} 是一个普通对象{name, age} ,和前面方式中的{}不一样。导入时,我们可以自定义导入的变量名,这里的obj 就是 {name, age} 对象了。

注意:在一个模块中,只能有一个默认导出default export,而前面三种方式是可以有无限多个导出的。

2. 导入方式

import 关键字相当于变量声明关键字,后面跟着的变量名是不能被声明过的。

2.1 import xxx from xxx

// index.js
import { name, age } from './bar.js'

这里需要注意,from 后面跟着的路径必须添加后缀名.js ,因为我们是直接使用在浏览器上的,除非有使用 webpack 等工具。其次,{} 也不是一个对象,里面只是存放导入的标识符列表内容。最后,导入的变量名需要与导出的变量名保持一致。

2.2 导入时给标识符起别名

// index.js
import { name as uname, age as uage } from './bar.js'
console.log(uname, uage);
// '张学友' 18

我们在导入的时候,为导入的变量名起了别名,那么在后面代码中必须使用别名,而不能使用原来的变量名。

我们也可以同时在导出和导入时都起别名,看下面代码:

// bar.js
var name = '张学友';
var age = 18;

export {
  name as myname,
  age as myage
}
// index.js
import { myname as uname, myage as uage } from './bar.js'
console.log(uname, uage);
// '张学友' 18

2.3 以 * 通配符导入

通过 * 将导出的变量到一个模块功能对象(a module object)上。

// bar.js
var name = '张学友';
var age = 18;

export {
  name,
  age
}
// index.js
import * as foo from './bar.js'
console.log(foo);
console.log(foo.name, foo.age);

在这种方法下,foo 被定义为一个模块功能对象,这个模块功能对象包含着被导出的所有变量属性:

2.4 以 export default 导出的导入方式

如果导出方式是 export default ,那么导入的时候,导入变量名我们可以自定义。虽然 import 是相当于变量声明关键字,我们可以自定义变量名,但是不能写成解构赋值的形式。看下面代码:

// bar.js
var name = '张学友';
var age = 18;

export default [
  name,
  age
]
// index.js
import [name, age] from './bar.js'
console.log(name, age);

// 写成结构赋值的形式,代码运行不通过

即使是写成对象形式的结构赋值,也是不可以的。

// bar.js
var name = '张学友';
var age = 18;

export default {
  name,
  age
}
// index.js
import {name, age} from './bar.js'
console.log(name, age);

// 写成结构赋值的形式,代码运行不通过

3. ES Module 在浏览器和 Node 中的使用

3.1 在浏览器中使用

如果我们想要在浏览器中使用 ES Module,那么我们必须做到下面两步。

第一步,当前项目必须在服务器环境使用。

这是因为受到 CORS 安全策略限制而无法在本地使用,这是 MDN 的官方解释:

如果你尝试用本地文件加载HTML 文件 (i.e. with a file:// URL), 由于 JavaScript 模块的安全性要求,你会遇到CORS 错误。你需要通过服务器来做你的测试。

所以,我们可以使用 VS Code 中的 Live Server 插件来部署一个 Web 服务器,是我们的 .HTML 文件在线上环境打开。

第二步,必须在 script 标签内添加 type="module" 属性,且 script 标签内部不得写任何代码。

// index.html
<body>
  <script src="./index.js" type="module"></script>
</body>

添加了 type="module" 后,index.js 就变成了一个模块,就可以使用 import 关键字了。不然会报如下错误:

Uncaught SyntaxError: Cannot use import statement outside a module

这里只需要引入主要的 js 文件(入口函数)即可,不需要引入与 index.js 有依赖关系的其它 js 文件,比如我在 index.js 文件导入了 bar.js 文件,那么是不需要引入 bar.js 文件的,有与 index.js 存在依赖的文件均是模块文件。

3.2 在 Node 中使用

方式一:在package.json中配置 type: module

方式二:文件以 .mjs 结尾,表示使用的是 ES Module

将所有的 js 文件的后缀名改为 .mjs

3.3 CommonJS 和 ES Module 交互使用

所谓 CommonJS 和 ES Module 交互使用,就是依赖的各模块不仅有 CommonJS 模块,也有 ES Module 模块。比如有以 .js 为后缀名的 ES Module 模块,也有以 .mjs 为后缀名的CommonJS 模块,互为依赖。

不过:

  • 通常情况下,CommonJS 不能加载 ES Module
    • require 来加载 export
    • 因为 CommonJS 是同步加载的,但是ES Module必须经过静态分析等,无法在这个时候执行 JavaScript代码
    • 但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持
    • Node 当中是不支持的
  • 多数情况下,ES Module 可以加载 CommonJS
    • import 来加载 module.exports
    • ES Module在加载CommonJS时,会将其 module.exports 导出的内容作为 default 导出方式来使用
    • 这个依然需要看具体的实现,比如 webpack 中是支持的、Node 最新版本也是支持的

4. ES Module 和 CommonsJS 的加载过程的区别

4.1 CommonsJS 的加载过程

  • CommonJS模块加载 js文件的过程是在运行时加载的,并且是同步的

    • 运行时加载意味着 JS 引擎是在执行 js 代码的过程中加载模块的
    • 同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行
  • CommonJS 通过 module.exports 导出的是一个引用对象

    • 不仅可以在导入的地方修改属性,也可以在导出的地方修改属性

4.2 ES Module加载过程

  • 与 CommonJS 一样,模块在被第一次引入时,模块中的 js 代码会且仅被运行一次

  • ES Module 加载 js 文件的过程是编译(解析)时加载的,并且是异步的

    • 编译时(解析)时加载,意味着 import不能和运行时相关的代码放在一起使用
    • 异步的就意味着 JS 引擎在遇到 import 时会去获取这个 js 文件,但是这个获取的过程是异步的,并不会阻塞主线程继续执行。如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的 js 文件和代码不会阻塞它们的执行
  • ES Module 通过 export 导出的是变量本身的引用(包括基本变量和复杂变量)

    • 由于是在解析阶段加载模块的,所以导出的只能是变量的引用,而不是变量的值,所以才会有任何变量(基本变量和复杂变量)的引用。
    • 上面的引用会被放在模块环境记录(module environment record)中,模块环境记录会和变量进行绑定(binding),并且这个绑定是实时的
    • 由于是在导出阶段将变量的引用绑定在模块环境记录,所以我们可以在导出文件中修改导出变量的值,但不能在导入文件中修改导出变量的值(可以理解为 const 定义的常量。当然,可以修改导出的是一个对象里面的属性值)

    导出引用被绑定在模块环境记录中,并且是实时的:

    // bar.js
    var name = '张学友';
    var obj = {
      songs: '李香兰'
    }
    
    export {
      name,
      obj
    }
    
    setTimeout(() => {
      name = '刘德华'
    }, 1000)
    
    // index.js
    import { name, obj } from './bar.js'
    console.log(name, obj);
    // 张学友 {songs: "李香兰"}
    
    setTimeout(() => {
      console.log(name, obj);
      // 刘德华 {songs: "李香兰"}
    }, 2000)
    

    只能在导出文件中修改导出变量的值,不能在导入文件修改导出变量的值

    // bar.js
    var name = '张学友';
    var obj = {
      songs: '李香兰'
    }
    
    export {
      name,
      obj
    }
    
    setTimeout(() => {
      console.log(obj);
      // 张学友 {songs: "遥远的她"}
    }, 1000);
    
    // index.js
    import { name, obj } from './bar.js'
    console.log(name, obj);
    // 张学友 {songs: "李香兰"}
    
    try {
      name = '刘德华'
    } catch (error) {
      console.log(error);
      // TypeError: Assignment to constant variable.
    }
    try {
      obj.songs = '遥远的她'
    } catch (error) {
      console.log(error);
    }
    console.log(name, obj);
    // 张学友 {songs: "遥远的她"}
    

5. 补充

5.1 export和 import 结合使用

export {x} from xxx 这个方式是从 xxx 导入 x,然后又将其导出。看下面代码:

// foo.js
export { name, age } from './bar.js'

// 等于
import { name, age } from './bar.js'
export { name, age };

那些需要导入name, age 变量的文件,完全可以从bar.js 导入,因此这个方式感觉多此一举,但是这个方式既然存在,那么就有它存在的道理,那么这个方式有什么用呢?

在开发和封装一个功能库时,通常我们将希望暴露的所有接口放到一个文件中,这样方便指定统一的接口规范,也方便程序员调用和阅读。这个时候,我们就可以使用 export和 import 结合使用了。

5.2 import()

我们是不能在一个逻辑代码里面来通过 import 加载模块的,如下面代码会报语法错误:

if(true) {
  import {name, age} from './bar.js';
}

这是因为 JS 代码在被解析阶段时就必须知道各个模块之间的依赖关系,而进入逻辑代码里加载模块是已经在 JS 代码执行阶段了,故报语法错误。

所以,如果我们确实想动态地加载模块,就可以使用 import() 了,因为 import() 是一个函数,是在 JS 代码执行阶段执行的。

imports() 是一个异步加载函数,并且返回一个 promise

if (true) {
  import('./bar.js').then((res) => {
    console.log(res);
  }).catch((err) => {
    console.log(err);
  })
}

若加载成功, res 就是一个模块功能对象,与上面 2.3 ”以 * 通配符导入“ 的模块功能对象一样;若加载失败,err 是其加载失败的原因。

 posted on 2021-06-27 21:05  kly99  阅读(80)  评论(0)    收藏  举报