Tenloy's Blog

前端各种模块化方案总结

Word count: 18.1kReading time: 73 min
2021/06/06 Share

一、模块化

1.1 什么是模块化

那么,到底什么是模块化开发呢?

模块:1、在通信、计算机、数据处理控制系统的电路中,可以组合和更换的硬件单元。2、大型软件系统中的一个具有独立功能的部分。

  • 现实生活中模块化的例子:模块化计算机(cpu、内存、显卡、风扇、硬盘、光驱等等模块)、谷歌模块化手机、模块化房屋
  • 代码模块化例子:日期模块、数学计算模块、日志模块等,所有这些模块共同组成了程序软件系统

模块化:

  • 模块化开发就是将程序划分成一个个(互相依赖的)小文件/模块来开发,然后将小模块组合起来
  • 这个模块中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构;
  • 这个模块可以将自己希望暴露的变量、函数、对象等导出给其结构使用;
  • 也可以通过某种方式,导入另外模块中的变量、函数、对象等;

模块化的好处:

  1. 防止命名冲突
  2. 代码复用(非模块化开发时,代码重用时,引入 js 文件的数目可能少了或者引入的顺序不对,会导致一些问题)
  3. 高维护性(模块之间有高耦合低内聚的特点)

1.2 JavaScript设计缺陷

无论你多么喜欢JavaScript,以及它现在发展的有多好,我们都需要承认在Brendan Eich用了10天写出JavaScript的时候,它都有很多的缺陷:

  • 比如var定义的变量作用域问题;
  • 比如JavaScript的面向对象并不能像常规面向对象语言一样使用class;
  • 比如JavaScript没有模块化的问题;

Brendan Eich本人也多次承认过JavaScript设计之初的缺陷,但是随着JavaScript的发展以及标准化,存在的缺陷问题基本都得到了完善。

  • JavaScript目前已经得到了快速的发展,无论是web、移动端、小程序端、服务器端、桌面应用都被广泛的使用;

在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:

  • 这个时候我们只需要讲JavaScript代码写到<script>标签中即可;
  • 并没有必要放到多个文件中来编写;
1
2
3
4
5
6
7
<button id="btn">按钮</button>

<script>
document.getElementById("btn").onclick = function() {
console.log("按钮被点击了");
}
</script>

但是随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了:

  • ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染;
  • SPA的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现;
  • 包括Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤;

所以,模块化已经是JavaScript一个非常迫切的需求。

1.3 没有模块化的JavaScript

1.3.1 技术方案

演变过程:

  • 全局函数
    • ”污染”了全局变量,无法保证不与其它模块发生变量名冲突
    • 没有模块的划分,只能人为的认为它们属于一个模块,但是程序并不能区分哪些函数是同一个模块
  • 将函数封装到对象命名空间下
    • 从代码级别可以明显的区分出哪些函数属于同一个模块
    • 从某种程度上解决了变量命名冲突的问题,但是并不能从根本上解决命名冲突
    • 会暴露所有的模块成员,内部状态可以被外部改写,不安全
    • 命名空间越来越长
  • 立即函数调用表达式(IIFE,Immediately Invoked Function Expression)
    • 将模块封装为立即执行函数形式,将公有方法,通过在函数内部返回值的形式向外暴露
    • 会有人强调职责单一性,不要与程序的其它部分直接交互。比如当使用到第三方依赖时,通过向匿名函数注入依赖项的形式,来保证模块的独立性,还使模块之间的依赖关系变得明显
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      var calculator=(function(){
      var add=function(v1,v2){
      return v1+v2;
      }
      return { add:add }
      })()

      var calculator=(function(cal,$){
      cal.add2=function(){
      var v1=$('#v1').val();
      var v2= $('#v2').val();
      return (v1-0)+(v2-0);
      }
      return cal;
      })(window.calculator||{},window.$)
      //在这告诉我要jquery
      //依赖注入
      //很牵强的解决文件依赖问题的方法
  • IIFE也是有很大缺陷的,见下方代码举例

1.3.2 问题举例

我们先来简单体会一下没有模块化代码的问题。

我们知道,对于一个大型的前端项目,通常是多人开发的(即使一个人开发,也会将代码划分到多个文件夹中):

  • 我们假设有两个人:小明和小丽同时在开发一个项目,并且会将自己的JavaScript代码放在一个单独的js文件中。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 小明开发了aaa.js文件,代码如下(当然真实代码会复杂的多):
var flag = true;

if (flag) {
console.log("aaa的flag为true")
}

// 小丽开发了bbb.js文件,代码如下:
var flag = false;

if (!flag) {
console.log("bbb使用了flag为false");
}

很明显出现了一个问题:

  • 大家都喜欢使用flag来存储一个boolean类型的值;
  • 但是一个人赋值了true,一个人赋值了false;
  • 如果之后都不再使用,那么也没有关系;

但是,小明又开发了ccc.js文件:

1
2
3
if (flag) {
console.log("使用了aaa的flag");
}

问题来了:小明发现ccc中的flag值不对

  • 对于聪明的你,当然一眼就看出来,是小丽将flag赋值为了false;
  • 但是如果每个文件都有上千甚至更多的代码,而且有上百个文件,你可以一眼看出来flag在哪个地方被修改了吗?

备注:引用路径如下:

1
2
3
<script src="./aaa.js"></script>
<script src="./bbb.js"></script>
<script src="./ccc.js"></script>

所以,没有模块化对于一个大型项目来说是灾难性的。

1.3.3 IIFE的缺陷

使用IIFE解决上面的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// aaa.js
const moduleA = (function () {
var flag = true;

if (flag) {
console.log("aaa的flag为true")
}

return { flag: flag }
})();

// bbb.js
const moduleB = (function () {
var flag = false;

if (!flag) {
console.log("bbb使用了flag为false");
}
})();

// ccc.js
const moduleC = (function() {
const flag = moduleA.flag;
if (flag) {
console.log("使用了aaa的flag");
}
})();

命名冲突的问题,有没有解决呢?解决了。

但是,我们其实带来了新的问题:

  • 第一,我必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用;
  • 第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写;
  • 第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况;

所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。

  • 我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码;
  • 这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性;

1.4 JavaScript中模块化方案

历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比如 Ruby 的require、Python 的import,甚至就连 CSS 都有@import。直到ES6(2015)才推出了自己的模块化方案,在此之前,社区制定了一些模块加载方案,最主要的有:

先有规范,后有实现:

二、CommonJS规范

2.1 CommonJS和Node

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

  • Node是CommonJS在服务器端一个具有代表性的实现;
  • Browserify是CommonJS在浏览器中的一种实现;
  • webpack打包工具具备对CommonJS的支持和转换(后面会讲到);

所以,Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:

2.2 Node模块化语法

2.2.1 模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// bar.js
const name = 'coderwhy';
const age = 18;
function sayHello(name) { console.log("Hello " + name); }

// main.js
console.log(name, age);
sayHello('kobe');

/*
上面的代码会报错:
- 那么,就意味着别的模块main中不能随便访问另外一个模块bar中的内容;
- bar需要 导出 自己想要暴露的变量、函数、对象等;main从bar中 导入 自己想要使用的变量、函数、对象等数据之后,才能使用;
*/

在node中每一个文件都是一个独立的模块,有自己的作用域。在一个模块内变量、函数、对象都属于这个模块,对外是封闭的。

为了实现模块的导出,Node中使用的是Module的类(提供了一个Module构造函数),每一个模块都是Module的一个实例,也就是module;

每个模块(文件)中都包括CommonJS规范的核心变量:exports、module、require;

  • module:是一个全局对象,代表当前模块。里面保存了模块的信息路径、父子结构信息、曝露出的对象信息。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    module.id            // 带有绝对路径的模块文件名
    module.filename // 模块的文件名,带有绝对路径
    module.loaded // 表示模块是否已经完成加载
    module.parent // 返回一个对象,表示调用该模块的模块。
    module.children // 返回一个数组,表示该模块要用到的其他模块。
    module.exports // 模块对外输出的值。需要打破模块封装性曝露的方法和属性,都要挂载到module.exports上。其它文件加载该模块,实际上就是读取module.exports属性

    // 在 /Users/computer/Desktop/ccc/lib.js 文件中 console.log(module);
    Module {
    id: '.',
    path: '/Users/computer/Desktop/ccc',
    exports: { name: 'test' },
    parent: null,
    filename: '/Users/computer/Desktop/ccc/main.js',
    loaded: false,
    children: [
    Module {...}
    ],
    paths: [ //查找路径
    '/Users/computer/Desktop/ccc/node_modules',
    '/Users/computer/Desktop/node_modules',
    '/Users/computer/node_modules',
    '/Users/node_modules',
    '/node_modules'
    ]
    }
  • exports是module.exports的引用。一起负责对模块中的内容进行导出;
  • require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;

在Node.js中,模块分为两类:

  • 第一类,系统核心模块(原生模块),node自带。用名称直接可以加载。
    • fs(file system):与文件系统交互
    • http:提供http服务器功能
    • os:提供了与操作系统相关的实用方法和属性
    • path:处理文件路径
    • querystring:解析url查询字符串
    • url:解析url
    • util:提供一系列实用小工具
    • Buffer
    • 等等很多,见官方文档
    • 核心模块的源码都在Node的lib子目录中。为了提高运行速度,它们安装的时候都会被编译成二进制文件
  • 第二类,文件模块,也称自定义模块。用路径加载。

有一种特殊的文件模块 — 包,被管理在node_modules文件夹中的包,也可以直接用名字加载。

2.2.2 exports导出

强调:exports是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出

1
2
3
4
5
6
7
// bar.js 导出内容
exports.name = name;
exports.age = age;
exports.sayHello = sayHello;

// main.js 导入内容
const bar = require('./bar');

上面这行代码意味着什么呢?

  • 意味着main中的bar变量等于exports对象;
1
main中的bar = bar中的exports

所以,我可以编写下面的代码:

1
2
3
4
5
6
7
8
9
10
const bar = require('./bar');

const name = bar.name;
const age = bar.age;
const sayHello = bar.sayHello;

console.log(name);
console.log(age);

sayHello('kobe');

模块之间的引用关系:

图片

为了进一步论证,bar和exports是同一个对象:

  • 所以,bar对象是exports对象的浅拷贝;
  • 浅拷贝的本质就是一种引用的赋值而已;

定时器修改对象:

图片

2.2.3 module.exports

但是Node中我们经常导出东西的时候,又是通过module.exports导出的:

  • module.exports和exports有什么关系或者区别呢?

我们追根溯源,通过维基百科中对CommonJS规范的解析:

  • CommonJS中是没有module.exports的概念的;
  • 但是为了实现模块的导出,Node中使用的是Module的类(提供了一个Module构造函数),每一个模块都是Module的一个实例,也就是module;
  • module才是导出的真正实现者;
  • 所以在Node中真正用于导出的其实根本不是exports,而是module.exports。只是为了实现CommonJS的规范,也为了使用方便,Node为每个模块提供了一个exports对象,让其对module.exports有一个引用而已。
  • 相当于在每个模块头部,有这样一行命令:var exports = module.exports;
图片

不能直接给exports、module.exports赋值,这样等于切断了exports和module.exports的联系。最终输出的结果只会是module.exports的值。比如代码这样修改了:

图片 moduleexports

2.2.4 require

1. require的加载原理

前面已经说过,CommonJS 的一个模块,就是一个脚本文件。

  • CommonJS是同步加载。模块加载的顺序,按照其在代码中出现的顺序

  • require命令第一次加载模块时,会执行整个模块(脚本文件)中的js代码,返回该模块的module.exports接口数据。会在内存生成一个该模块对应的module对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // aaa.js
    const name = 'coderwhy';
    console.log("Hello aaa");

    setTimeout(() => {
    console.log("setTimeout");
    }, 1000);

    // main.js
    const aaa = require('./aaa'); // aaa.js中的代码在引入时会被运行一次

    生成的对象:

    1
    2
    3
    4
    5
    6
    {
    id: '...', // 模块名
    exports: { ... }, // 模块输出的各个接口
    loaded: true, // 是一个布尔值,为false表示还没有加载,为true表示已经加载完毕。这是保证每个模块只加载、运行一次的关键。
    ...
    }
  • 以后需要用到这个模块的时候,就会到exports属性上面取值。

  • 模块被多次引入时(多次执行require命令),CommonJS 模块只会在第一次加载时运行一次,以后再加载,会去缓存中取出第一次加载时生成的module对象并返回module.exports。除非手动清除系统缓存。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // main.js
    const aaa = require('./aaa');
    const bbb = require('./bbb');

    // aaa.js
    const ccc = require("./ccc");

    // bbb.js
    const ccc = require("./ccc");

    // ccc.js
    console.log('ccc被加载'); // ccc中的代码只会运行一次。
2. require的查找规则

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

那么,require的查找规则是怎么样的呢?官方文档

这里我总结比较常见的查找规则:导入格式如下:require(X)

  • 情况一:X是一个核心模块,比如path、http。直接返回核心模块,并且停止查找
    • 加载核心模块。传入名字,不需要传入路径。因为Node.js已经将核心模块的文件代码编译到了二进制的可执行文件中了。在加载的过程中,原生的核心模块的优先级是是最高的。
  • 情况二:X是以./..//(根目录)开头的
    • 在Linux或者MAc的操作系统中,/表示系统的根路径。在Windows中,/表示当前文件模块所属的根磁盘路径
    • 第一步:将X当做一个文件在对应的目录下查找;
      • 如果有后缀名,按照后缀名的格式查找对应的文件
      • 如果没有后缀名,会按照如下顺序:
        1. 直接查找文件X
        2. 查找X.js文件:当做JavaScript脚本文件解析
        3. 查找X.json文件:以JSON格式解析。
          • 如果是加载json文件模块,最好加上后缀.json,能稍微的提高一点加载的速度。
          • json文件Node.js也是通过fs读文件的形式读取出来的,然后通过JSON.parse()转换成一个对象
        4. 查找X.node文件:以编译后的二进制文件解析。.node文件通常是c/c++写的一些扩展模块
    • 第二步:没有找到对应的文件,将X作为一个目录。查找目录下面的index文件
      1. 查找X/index.js文件
      2. 查找X/index.json文件
      3. 查找X/index.node文件
    • 如果没有找到,那么报错:not found
  • 情况三:直接是一个X(没有路径),并且X不是一个核心模块
    • 比如在/Users/coderwhy/Desktop/Node/TestCode/04_learn_node/05_javascript-module/02_commonjs/main.js中编写 require('why')
    • 查找顺序:从当前 package 的 node_modules 里面找,找不到就到当前 package 目录上层 node_modules 里面取… 一直找到全局 node_modules 目录。图片
    • 这样找到的往往是文件夹,所以接下来就是处理一个文件目录作为 Node 模块的情况。如果文件目录下有 package.json,就根据它的 main 字段找到 js 文件。如果没有 package.json,那就默认取文件夹下的 index.js
      • 由于 webpack browsersify 等模块打包工具是兼容 node 的模块系统的,自然也会进行同样的处理流程。不同的是,它们支持更灵活的配置。比如在 webpack 里面,可以通过 alias 和 external 字段配置,实现对默认 import 逻辑的自定义。
    • 如果上面的路径中都没有找到,那么报错:not found

流程图:

Alt text
  • Node.js会通过同步阻塞的方式看这个路径是否存在。依次尝试,直到找到为止,如果找不到,报错
  • 优先从缓存加载:common.js规范:载后,再次加载时,去缓存中取module.exports 参考文献
3. require的加载顺序

如果有多个模块的引入,那么加载顺序是什么?

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

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

多个模块的引入关系:

multiMoudlesinclude

2.3 Node的源码解析

Module类

图片

Module.prototype.require函数

图片

Module._load函数

图片

三、ES6 Module

3.1 认识ES6 Module

3.1.1 ES6 Module的优势

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西,导致完全没办法在编译时做“静态优化”。

由于 ES6 模块是编译时加载:

  • 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高;
  • 使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。

除了静态加载带来的各种好处,ES6 模块还有以下好处。

  • 不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
  • 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

3.1.2 自动启动严格模式

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";

3.1.3 浏览器中加载ES6 Module

1. 加载普通js文件

HTML 网页中,浏览器通过<script>标签加载 JavaScript 脚本。

1
2
3
4
5
<!-- 页面内嵌的脚本 -->
<script type="application/javascript"> // code </script>

<!-- 外部脚本 -->
<script type="application/javascript" src="path/to/myModule.js"> //code... </script>

上面代码中,由于浏览器脚本的默认语言是 JavaScript,因此type="application/javascript"可以省略。

默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。

如果脚本体积很大,下载和执行的时间就会很长,因此造成浏览器堵塞,用户会感觉到浏览器“卡死”了,没有任何响应。这显然是很不好的体验,所以浏览器允许脚本异步加载。

下面就是两种异步加载的语法。

1
2
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

上面代码中,<script>标签打开deferasync属性,脚本就会异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令。

deferasync的区别是:

  • defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;
  • async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。
  • 一句话,defer是“渲染完再执行”,async是“下载完就执行”。
  • 另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。
2. 加载ES6 Module

浏览器内嵌、外链 ES6 模块代码,也使用<script>标签,但是都要加入type="module"属性。

type属性设为module,所以浏览器知道这是一个 ES6 模块。浏览器对于带有type="module"<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

1
2
3
4
5
6
7
8
9
10
11
12
<script type="module" src="./foo.js"></script>

<!-- 等同于下面代码。如果网页有多个 <script type="module">,它们会按照在页面出现的顺序依次执行。 -->
<script type="module" src="./foo.js" defer></script>

<!--
<script>标签的async属性也可以打开:
这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。
同样的:一旦使用了此属性,<script type="module">就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块。
-->
<script type="module" src="./foo.js" async></script>

ES6 模块也允许内嵌在网页中,语法行为与加载外部脚本完全一致。

1
2
3
4
5
<script type="module">
import utils from "./utils.js";

// other code
</script>

对于外部的模块脚本(上例是foo.js),有几点需要注意。

  • 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
  • 模块脚本自动采用严格模式,不管有没有声明use strict
  • 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
  • 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
  • 同一个模块如果加载多次,将只执行一次。

下面是一个示例模块。

1
2
3
4
5
6
import utils from 'https://example.com/js/utils.js';

const x = 1;

console.log(x === window.x); //false
console.log(this === undefined); // true

利用顶层的this等于undefined这个语法点,可以侦测当前代码是否在 ES6 模块之中。

1
const isNotModuleScript = this !== undefined;

3.1.4 本地浏览的报错

代码结构如下(个人习惯)

1
2
3
4
├── index.html
├── main.js
└── modules
└── foo.js

index.html中引入两个js文件作为模块:

1
2
<script src="./modules/foo.js" type="module"></script>
<script src="main.js" type="module"></script>

如果直接在浏览器中运行代码,会报如下错误:

图片

这个在MDN上面有给出解释:

我这里使用的VSCode,VSCode中有一个插件:Live Server

  • 通过插件运行,可以将我们的代码运行在一个本地服务中;
图片

3.2 ES6 Module的语法

模块功能主要由两个命令构成:exportimport

  • export命令用于规定模块的对外接口
  • import命令用于输入其他模块提供的功能。

3.2.1 模块与CommonJS模块的区别

1. 相同点

与CommonJS的相同点:一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。

2. 导出的不同

CommonJS通过module.exports导出的是一个对象,是module.exports属性浅拷贝后导出:

  • 该对象只有在脚本运行完才会生成。
  • 导出的是一个对象意味着可以将这个对象的引用在导入模块中赋值给其他变量;但是最终他们指向的都是同一个对象,那么一个变量修改了对象的属性,所有的地方都会被修改;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 导出
var counter = 3;
var obj = {count: 3}
function incCounter() {
counter++;
obj.count++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
obj: obj
};

// 导入
var mod = require('./lib');

console.log(mod.counter, mod.obj.count); // 3 3
mod.incCounter();
console.log(mod.counter, mod.obj.count); // 3 4

ES Module通过export导出的不是对象,是一个个导出变量/函数/类本身的引用:

说法1:

  • 它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
  • 换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。(由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错)
  • 所以,import命令叫做“连接” binding 其实更合适。

说法2:

  • export在导出一个变量时,js引擎会解析这个语法,并且创建模块环境记录(module environment record);
  • 模块环境记录会和变量进行 绑定(binding),并且这个绑定是实时的;
  • 而在导入的地方,我们是可以实时的获取到绑定的最新值的;

export和import绑定的过程:

图片

还是举上面的例子。

1
2
3
4
5
6
7
8
9
10
11
// lib.js
export let counter = 3;
export function incCounter() {
counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

上面代码说明,ES6 模块输入的变量counter是活的,完全反应其所在模块lib.js内部的变化。

3. 导入的不同
1
2
3
4
5
6
7
8
// 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 个方法。

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

上面代码实质只是从fs模块加载 3 个方法,其他方法不加载。

3.2.2 export

export关键字将一个模块中的变量、函数、类等导出;

1. export <decl>

方式一:分别导出。在语句声明的前面直接加上export关键字:

1
2
3
4
5
6
7
8
9
10
export const name = 'coderwhy';
export const age = 18;
export let message = "my name is why";

export function sayHello(name) {
console.log("Hello " + name);
}

// export需要指定对外暴露的接口,所以不能直接输出一个值
// export 40; //error
2. export {}

方式二:统一导出。将所有需要导出的标识符,放到export后面的 {}中。它与上一种写法是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些数据。

  • 注意:这里的 {}里面不是ES6的对象字面量的增强写法,{}也不是表示一个对象的;
  • 所以:export {name: name},是错误的写法;
1
2
3
4
5
6
7
8
9
10
11
12
const name = 'coderwhy';
const age = 18;

function sayHello(name) {
console.log("Hello " + name);
}

export {
name,
age,
sayHello
}
3. export {<> as <>}

方式三:通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字在导出时给标识符起一个别名:export {<> as <>}

1
2
3
4
5
6
export {
name as fName,
age as fAge,
sayHello as fSayHello1,
sayHello as fSayHello2, // 重命名后,sayHello可以用不同的名字输出两次。
}
4. export导出的是标识符的地址

export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

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

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

这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。

5. export导出同一个实例
1
2
3
4
5
function C() {
this.sum = 0;
}

export let c = new C();

不同的模块中,加载这个模块,得到的都是同一个实例。对c修改,其他模块导入的数据也会改变

6. export书写位置

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

1
2
3
4
function foo() {
export default 'bar' // SyntaxError
}
foo()
7. export书写次数

一个模块中:export <decl>export {}export {<> as <>}都是可以出现0-n次的

3.2.3 import

import关键字负责从另外一个模块中导入内容。

import语句会执行所加载的模块。如果同一个模块被加载多次,那么模块里的代码只执行一次。

导入内容的方式也有多种:

1. import {} from ''

方式一:选择导入。import {标识符列表} from '模块'

注意:

  • 大括号里面的变量名,必须与被导入模块对外接口的名称相同。
  • 这里的{}也不是一个对象,里面只是存放导入的标识符列表内容;
1
2
3
4
5
import { name, age, sayHello } from './modules/foo.js';

console.log(name)
console.log(age);
sayHello("Kobe");
1
2
3
4
import { name } from './modules/foo.js';
import { age } from './modules/foo.js';
// 等同于
import { name, age } from './modules/foo.js';

上面代码中,虽然nameage在两个语句中加载,但是它们对应的是同一个foo.js模块。也就是说,import语句是 Singleton 模式。

1. import ''的含义

import语句会执行所加载的模块,因此可以有下面的写法。

1
import 'lodash'; 

上面代码仅仅执行lodash模块,但是不导入任何值。

同样的,如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

1
2
import 'lodash';
import 'lodash'; // 代码加载了两次`lodash`,但是只会执行一次。
2. import {<> as <>} from ''

方式二:导入时给标识符起别名: import {<> as <>} from ''

1
import { name as wName, age as wAge, sayHello as wSayHello } from './modules/foo.js';
3. import * as <> from ''

方式三:整体导入。将模块功能放到一个模块功能对象(a module object)上,用*指定: import * as <> from ''

1
2
3
4
5
6
7
8
import * as foo from './modules/foo.js';

console.log(foo.name);
console.log(foo.age);
foo.sayHello("Kobe");

// foo.n = "add"; // Type Error: object is not extensible
// foo.f = function () {};

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

4. import导入为只读
1
2
import { name } from './modules/foo.js';
name = "mod"; // Syntax Error : 'name' is read-only;

name是只读的。但是,如果name是一个对象,改写其属性是允许的,并且其他模块也可以读到改写后的值。不过,这种写法很难查错,建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。

5. import from后的路径

import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,后缀名不能省略

如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。

1
import { myMethod } from 'util';

上面代码中,util是模块文件名,由于不带有路径,必须通过配置,告诉引擎怎么取到这个模块。

6. import命令的提升

注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。

1
2
foo();
import { foo } from 'my_module';

上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。

目前阶段,通过 Babel 转码,CommonJS 模块的require命令和 ES6 模块的import命令,可以写在同一个模块里面,但是最好不要这样做。因为import在静态解析阶段执行,所以它是一个模块之中最早执行的。下面的代码可能不会得到预期结果。

1
2
3
require('core-js/modules/es6.symbol');
require('core-js/modules/es6.promise');
import React from 'React';
7. import中不能使用表达式和变量

由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}

上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是没法得到值的。

3.2.4 export default

1. 概述

前面我们学习的导出功能都是有名字的导出(named exports):

  • 在导出export时指定了名字;
  • 在导入import时需要知道具体的名字;

还有一种导出叫做默认导出(default export)

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

也是可以导出变量、函数、类的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 导出格式1
export default function sub(num1, num2) {
return num1 - num2;
}

// 导出格式2:用在非匿名函数前
export default function() {}

// 导出格式3:用在函数变量前
function sub() { console.log('sub'); }
export default sub;

// 函数名`sub`,在模块外部是无效的。加载的时候,视同匿名函数加载。


// 导入格式1:常用及推荐
import sub from './modules/foo.js';
console.log(sub(20, 30));

// 导入格式2
import * as m from './modules/foo.js';
console.log(m.default.sub(20, 30));

// 导入格式3
import {default as m} from './modules/foo.js';
console.log(m.sub(20, 30));
3. export default的本质

本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。

1
2
3
4
5
6
7
8
// modules.js
function add(x, y) {
return x * y;
}
export {add as default}; // 等同于 export default add;

// app.js
import { default as foo } from 'modules'; // 等同于 import foo from 'modules';

正是因为export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 正确
export var a = 1;

// 正确
var a = 1;
export default a; // 含义是将变量`a`的值赋给变量`default`。所以,下面的写法会报错。

// 错误
// export default var a = 1;

// 同样地,因为`export default`命令的本质是将后面的值,赋给`default`变量,所以可以直接将一个值写在`export default`之后。
// 正确
export default 42;
// 报错。报错是因为没有指定对外的接口,而前一句指定对外接口为default。
// export 42; // export后面得跟声明,或者{标识符}
4. export default与export

注意:在一个模块中,export default是可以与export同时使用的:

  • export default用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。
  • export是没有限制的。export <decl>export {}export {<> as <>}都是可以出现0-n次的
1
2
3
4
5
6
7
8
9
10
// 导出
export default function sub(num1, num2) {
return num1 - num2;
}
export var name = "module1";

// 导入 在一条`import`语句中,同时输入默认接口和其他接口
import m, {name} from './modules/foo.js'; //m.sub、name
import * as m from './modules/foo.js'; // m.default.sub、m.name
import {default as m, name} from './modules/foo.js'; // m.sub、name

3.2.5 export和import结合

1
2
3
4
5
6
7
8
9
10
// bar.js 导出一个sum函数
export const sum = function(num1, num2) {
return num1 + num2;
}

// foo.js做一个中转

// main.js直接从foo中导入
import { sum } from './modules/foo.js';
console.log(sum(20, 30));

如果从一个模块中导入的内容,我们希望再直接导出出去,这个时候可以使用export和import的结合,写成一行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// foo.js 导入,但是只是做一个中转
export { sum } from './bar.js';

// 接口改名
export { sum as barSum } from './bar.js'; // 甚至在foo.js中导出时,我们可以变化它的名字

// 整体导入和导出
export * from './bar.js';
// 相当于实现了模块之间的继承。注意,`export *`命令会忽略后面模块的`default`接口。

// 默认接口
export { default } from 'foo';

// 具名接口改为默认接口的写法如下:
export { es6 as default } from './someModule';
// 等同于
import { es6 } from './someModule';
export default es6;

// 默认接口也可以改名为具名接口:
export { default as es6 } from './someModule';

// ES2020 之前,有一种`import`语句,没有对应的复合写法。[ES2020](https://github.com/tc39/proposal-export-ns-from)补上了这个写法。
export * as ns from "mod";
// 等同于
import * as ns from "mod";
export {ns};

// 需要注意的是,写成一行以后,`sum`实际上并没有被导入当前模块,只是相当于对外转发了这个接口,导致当前模块不能直接使用`sum`。

为什么要这样做呢?

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

3.2.6 import()

1. import()的背景

前面介绍过,import命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行。所以,importexport命令只能在模块的顶层,是不可以在其放到逻辑代码中(比如在if代码块之中,或在函数之中)的。下面的代码会报错:

1
2
3
if (true) {
import sub from './modules/foo.js';
}

引擎处理import语句是在编译时,这时不会去分析或执行if语句,所以import语句放在if代码块之中毫无意义,因此会报句法错误,而不是执行时错误。

这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载就不可能实现。如果import命令要取代 Node 的require方法,这就形成了一个障碍。因为require是运行时加载模块,import命令无法取代require的动态加载功能。

1
2
3
const path = './' + fileName;
const myModual = require(path);
// 上面的语句就是动态加载,`require`到底加载哪一个模块,只有运行时才知道。`import`命令做不到这一点。

ES2020提案 引入import()函数,支持动态加载模块。

1
import(specifier)

上面代码中,import函数的参数specifier,指定所要加载的模块的位置。import命令能够接受什么参数,import()函数就能接受什么参数,两者区别主要是后者为动态加载。

2. 语法

import()返回一个 Promise 对象。下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
.then(module => { // 加载模块成功以后,这个模块会作为一个对象,当作`then`方法的参数.
//.then({export1, export2} => { // 可以使用对象解构赋值的语法,获取输出接口。
//.then({default: theDefault} => { // 如果是default,那么需要解构重命名

module.loadPageInto(main); // module.default来使用默认导出
})
.catch(err => {
main.textContent = err.message;
});

// 如果想同时加载多个模块,可以采用下面的写法。
Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
])
.then(([module1, module2, module3]) => {
···
});

// 返回值是Promise对象,所以也可以用在async函数中
async function main() {
const myModule = await import('./myModule.js');
const {export1, export2} = await import('./myModule.js');
const [module1, module2, module3] =
await Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
]);
}
main();

import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于 Node 的require方法,区别主要是前者是异步加载,后者是同步加载。

3. 适用场合
  • 按需加载。

    import()可以在需要的时候,再加载某个模块。比如放在click事件的监听函数之中,只有用户点击了按钮,才会加载这个模块。

  • 条件加载

    import()可以放在if代码块,根据不同的情况,加载不同的模块。

  • 动态的模块路径

    import()允许模块路径动态生成。

    1
    import(f()).then(...);  // 根据函数`f`的返回结果,加载不同的模块。

3.2.7 应用: 公共头文件

介绍const命令的时候说过,const声明的常量只在当前代码块有效。如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// constants.js 模块
export const A = 1;
export const B = 3;
export const C = 4;

// test1.js 模块
import * as constants from './constants';
console.log(constants.A); // 1
console.log(constants.B); // 3

// test2.js 模块
import {A, B} from './constants';
console.log(A); // 1
console.log(B); // 3

如果要使用的常量非常多,可以建一个专门的constants目录,将各种常量写在不同的文件里面,保存在该目录下。

1
2
3
4
5
6
7
8
9
// constants/db.js
export const db = {
url: 'http://my.couchdbserver.local:5984',
admin_username: 'admin',
admin_password: 'admin password'
};

// constants/user.js
export const users = ['root', 'admin', 'staff', 'ceo', 'chief', 'moderator'];

然后,将这些文件输出的常量,合并在index.js里面。

1
2
3
// constants/index.js
export {db} from './db';
export {users} from './users';

使用的时候,直接加载index.js就可以了。

1
2
// script.js
import {db, users} from './constants/index.js';

3.2.8 与CommonJS模块化的差异

  • CommonJS 模块输出的是一个值的拷贝(module.exports的浅拷贝),ES6 模块输出的是值的引用。
  • CommonJS 模块是运行时加载,ES6 模块是编译(解析)时加载。
    • 运行时加载意味着是js引擎在 执行js代码的过程中 加载模块;所以require可以与变量、表达式等运行时代码结合使用
    • 编译时(解析)时加载,意味着import不能和运行时相关的内容放在一起使用:
      • 比如from后面的路径需要动态获取;
      • 比如不能将import放到if等语句的代码块中;
      • 所以我们有时候也称ES Module是静态解析的,而不是动态或者运行时解析的;
  • CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。
    • 同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行;
    • 异步的意味着:不会阻塞主线程继续执行;
      • JS引擎在遇到import时会去获取这个js文件的过程是异步的
      • 设置了 type=module 的script标签,相当于加上了 async 属性;
      • 如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的js文件和代码不会阻塞它们的执行;

CommonJS代码:

1
2
3
4
5
6
7
8
console.log("main代码执行");

const flag = true;
if (flag) {
// 同步加载foo文件,并且执行一次内部的代码
const foo = require('./foo');
console.log("if语句继续执行");
}

ES Module代码:

1
2
3
<script src="main.js" type="module"></script>
<!-- 这个js文件的代码不会被阻塞执行 -->
<script src="index.js"></script>

四、CommonJS模块与ES6模块的混编

4.1 CommonJS模块加载ES6模块

通常情况下,CommonJS不能加载ES Module

  • 因为CommonJS是同步加载的,但是ES Module必须经过静态分析等,无法在这个时候执行JavaScript代码;
  • 但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持;

可以使用import()这个方法加载

1
2
3
(async () => {
await import('./my-app.mjs');
})();

上面代码可以在 CommonJS 模块中运行。

require()不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层await命令,导致无法被同步加载。

4.2 ES6模块加载CommonJS模块

多数情况下,ES Module可以加载CommonJS,但是只能整体加载,不能只加载单一的输出项。

  • ES Module在加载CommonJS时,会将其module.exports导出的内容作为default导出方式来使用;
  • 这个依然需要看具体的实现,比如webpack中是支持的、Node最新的Current(v14.13.1)版本也是支持的;
1
2
3
4
5
6
7
8
9
10
// foo.js
const address = 'foo的address';

module.exports = {
address
}

// main.js
import foo from './modules/foo.js';
console.log(foo.address);

还有一种变通的加载方法,就是使用 Node.js 内置的module.createRequire()方法。

1
2
3
4
5
6
7
8
9
10
// cjs.cjs
module.exports = 'cjs';

// esm.mjs
import { createRequire } from 'module';

const require = createRequire(import.meta.url);

const cjs = require('./cjs.cjs');
cjs === 'cjs'; // true

上面代码中,ES6 模块通过module.createRequire()方法可以加载 CommonJS 模块。但是,这种写法等于将 ES6 和 CommonJS 混在一起了,所以不建议使用。

4.3 使模块同时支持两种模块化导入

一个模块同时要支持 CommonJS 和 ES6 两种格式,也很容易。

如果原始模块是 ES6 格式,那么需要给出一个整体输出接口,比如export default obj,使得 CommonJS 可以用import()进行加载。

如果原始模块是 CommonJS 格式,那么可以加一个包装层。

1
2
import cjsModule from '../index.js';
export const foo = cjsModule.foo;

上面代码先整体输入 CommonJS 模块,然后再根据需要输出具名接口。

你可以把这个文件的后缀名改为.mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的package.json文件,指明{ type: "module" }

如果是Node.js中,还有一种做法是在package.json文件的exports字段,指明两种格式模块各自的加载入口。

1
2
3
4
"exports":{
"require": "./index.js"
"import": "./esm/wrapper.js"
}

上面代码指定require()import,加载该模块会自动切换到不一样的入口文件。

五、Node.js开发中的模块化

5.1 Node中支持 ES6 Module

JavaScript 现在常用的有两种模块。

  • ES6 模块,简称 ESM;
  • CommonJS 模块,简称 CJS。

CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。语法上面,两者最明显的差异是,CommonJS 模块使用require()module.exports,ES6 模块使用importexport

从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持,需要进行以下操作:

  • 方式一:文件以 .mjs 结尾,表示使用的是ES Module;
  • 方式二:在package.json中配置字段 type: module,一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。
    • 如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs
  • 如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

在之前的版本(比如v12.19.0)中,也是可以正常运行的,但是会报一个警告:

图片

Node.js 遇到 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"

总结为一句话:

  • .mjs文件总是以 ES6 模块加载
  • .cjs文件总是以 CommonJS 模块加载
  • .js文件的加载取决于package.json里面type字段的设置。

注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import

5.2 Node.js包模块的入口文件设置

5.2.1 package.json 的 main 字段

package.json文件有两个字段可以指定模块的入口文件:mainexports。比较简单的模块,可以只使用main字段,指定模块加载的入口文件。

举例:指定入口文件,格式为ESM
1
2
3
4
5
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}

上面代码指定项目的入口脚本为./src/index.js,它的格式为 ES6 模块。如果没有type字段,index.js就会被解释为 CommonJS 模块。

然后,import命令就可以加载这个模块。

1
2
3
4
// ./my-app.mjs

import { something } from 'es-module-package';
// 实际加载的是 ./node_modules/es-module-package/src/index.js

上面代码中,运行该脚本以后,Node.js 就会到./node_modules目录下面,寻找es-module-package模块,然后根据该模块package.jsonmain字段去执行入口文件。

这时,如果用 CommonJS 模块的require()命令去加载es-module-package模块会报错,因为 CommonJS 模块不能处理export命令。

5.2.2 package.json 的 exports 字段

exports字段的优先级高于main字段。它有多种用法。

1. 给脚本或子目录起别名

package.json文件的exports字段可以指定脚本或子目录的别名。

1
2
3
4
5
6
7
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js", //给脚本文件 src/submodule.js 起别名
"./features/": "./src/features/"// 给子目录 ./src/features/ 起别名
}
}

通过别名加载:

1
2
3
4
5
import submodule from 'es-module-package/submodule';
// 加载 ./node_modules/es-module-package/src/submodule.js

import feature from 'es-module-package/features/x.js';
// 加载 ./node_modules/es-module-package/src/features/x.js

如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。

1
2
3
4
5
// 报错
import submodule from 'es-module-package/private-module.js';

// 不报错
import submodule from './node_modules/es-module-package/private-module.js';
2. main 的别名.

exports字段的别名如果是. 就代表了是模块的主入口,优先级高于main字段,并且可以直接简写成exports字段的值。

1
2
3
4
5
6
7
8
9
10
{
"exports": {
".": "./main.js"
}
}

// 等同于
{
"exports": "./main.js"
}

由于exports字段只有支持 ES6 的 Node.js 才认识,所以可以用来兼容旧版本的 Node.js。

1
2
3
4
5
6
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}

上面代码中,老版本的 Node.js (不支持 ES6 模块)的入口文件是main-legacy.cjs,新版本的 Node.js 的入口文件是main-modern.cjs

3. 条件加载

利用.这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。目前,这个功能需要在 Node.js 运行的时候,打开--experimental-conditional-exports标志。

1
2
3
4
5
6
7
8
9
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs", // 别名`.`的`require`条件指定`require()`命令的入口文件(即 CommonJS 的入口)
"default": "./main.js" // 别名`.`的`default`条件指定其他情况的入口(即 ES6 的入口)。
}
}
}

上面的写法可以简写如下

1
2
3
4
5
6
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}

注意,如果同时还有其他别名,就不能采用简写,否则或报错。

1
2
3
4
5
6
7
8
{
// 报错
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}

5.3 Node.js原生模块完全支持ES6 Module

Node.js 的内置模块可以整体加载,也可以加载指定的输出项。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 整体加载
import EventEmitter from 'events';
const e = new EventEmitter();

// 加载指定的输出项
import { readFile } from 'fs';
readFile('./foo.txt', (err, source) => {
if (err) {
console.error(err);
} else {
console.log(source);
}
});

5.4 加载路径

ES6 模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。import命令和package.json文件的main字段如果省略脚本的后缀名,会报错。

1
2
// ES6 模块中将报错
import { something } from './index';

为了与浏览器的import加载规则相同,Node.js 的.mjs文件支持 URL 路径。

1
import './foo.mjs?query=1'; // 加载 ./foo 传入参数 ?query=1

上面代码中,脚本路径带有参数?query=1,Node 会按 URL 规则解读。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。由于这个原因,只要文件名中含有:%#?等特殊字符,最好对这些字符进行转义。

目前,Node.js 的import命令只支持加载本地模块(file:协议)和data:协议,不支持加载远程模块。另外,脚本路径只支持相对路径,不支持绝对路径(即以///开头的路径)。

5.5 内部变量

ES6 模块应该是通用的,同一个模块不用修改,就可以用在浏览器环境和服务器环境。为了达到这个目标,Node.js 规定 ES6 模块之中不能使用 CommonJS 模块的特有的一些内部变量。

首先,就是this关键字。ES6 模块之中,顶层的this指向undefined;CommonJS 模块的顶层this指向当前模块,这是两者的一个重大差异。

其次,以下这些顶层变量在 ES6 模块之中都是不存在的。

  • arguments
  • require
  • module
  • exports
  • __filename
  • __dirname

六、循环加载

“循环加载”(circular dependency)指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。

1
2
3
4
5
// a.js
var b = require('b');

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

通常,“循环加载”表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现。

但是实际上,这是很难避免的,尤其是依赖关系复杂的大项目,很容易出现a依赖bb依赖cc又依赖a这样的情况。这意味着,模块加载机制必须考虑“循环加载”的情况。

对于 JavaScript 语言来说,目前最常见的两种模块格式 CommonJS 和 ES6,处理“循环加载”的方法是不一样的,返回的结果也不一样。

6.1 CommonJS 模块的循环加载

CommonJS 模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

让我们来看,Node 官方文档里面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// a.js
exports.done = false; // 先输出一个`done`变量

var b = require('./b.js'); // 然后加载另一个脚本文件b.js。注意,此时代码就停在这里,等待`b.js`执行完毕,再往下执行。

console.log('在 a.js 之中,b.done = %j', b.done); // b.js执行完毕,返回来a.js接着往下执行,直到执行完毕。
exports.done = true;
console.log('a.js 执行完毕');

// b.js
exports.done = false;

/*
执行到这一行,会去加载a.js,这时,就发生了“循环加载”。系统会去a.js模块对应对象的exports属性取值,可是因为a.js还没有执行完,从exports属性只能取回已经执行的部分,而不是最后的值。
此时:a.js已经执行的部分,只有一行:exports.done = false; 即对于b.js来说,它从a.js只输入一个变量done=false 。
*/
var a = require('./a.js');

console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
// b.js接着往下执行,等到全部执行完毕,再把执行权交还给a.js。

我们写一个脚本main.js,验证这个过程。

1
2
3
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

执行main.js,运行结果如下:

1
2
3
4
5
6
7
$ node main.js

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

上面的代码证明了两件事:

  1. b.js之中,a.js没有执行完毕,只执行了第一行。
  2. main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行exports.done = true;

总之,CommonJS 输入的是被输出值的拷贝,不是引用。

另外,由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。所以,输入变量的时候,必须非常小心。

1
2
3
4
5
6
7
8
9
10
var a = require('a'); // 安全的写法
var foo = require('a').foo; // 危险的写法

exports.good = function (arg) {
return a.foo('good', arg); // 使用的是 a.foo 的最新值
};

exports.bad = function (arg) {
return foo('bad', arg); // 使用的是一个部分加载时的值
};

上面代码中,如果发生循环加载,require('a').foo的值很可能后面会被改写,改用require('a')会更保险一点。

6.2 ES6 模块的循环加载

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

请看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

上面代码中,a.mjs加载b.mjsb.mjs又加载a.mjs,构成循环加载。执行a.mjs,结果如下。

1
2
3
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined

上面代码中,执行a.mjs以后会报错,foo变量未定义,这是为什么?

让我们一行行来看,ES6 循环加载是怎么处理的:

  • 首先,执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.mjs
  • 接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。
  • 执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' } // const foo = () => 'foo'; 仍然会执行报错。函数表达式,就不具有提升作用
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};

这时再执行a.mjs就可以得到预期结果。

1
2
3
4
5
$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

这是因为函数具有提升作用,在执行import {bar} from './b'时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。

这也意味着,如果把函数foo改写成函数表达式,也会报错。

6.3 代码示例

我们再来看 ES6 模块加载器SystemJS给出的一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
// even.js
import { odd } from './odd'
export var counter = 0;
export function even(n) {
counter++;
return n === 0 || odd(n - 1);
}

// odd.js
import { even } from './even';
export function odd(n) {
return n !== 0 && even(n - 1);
}

上面代码中,even.js里面的函数even有一个参数n,只要不等于 0,就会减去 1,传入加载的odd()odd.js也会做类似操作。

运行上面这段代码,结果如下。

1
2
3
4
5
6
7
8
9
10
$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17

上面代码中,参数n从 10 变为 0 的过程中,even()一共会执行 6 次,所以变量counter等于 6。第二次调用even()时,参数n从 20 变为 0,even()一共会执行 11 次,加上前面的 6 次,所以变量counter等于 17。

这个例子要是改写成 CommonJS,就根本无法执行,会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// even.js
var odd = require('./odd');
var counter = 0;
exports.counter = counter;
exports.even = function (n) {
counter++;
return n == 0 || odd(n - 1);
}

// odd.js
var even = require('./even').even;
module.exports = function (n) {
return n != 0 && even(n - 1);
}

上面代码中,even.js加载odd.js,而odd.js又去加载even.js,形成“循环加载”。这时,执行引擎就会输出even.js已经执行的部分(不存在任何结果),所以在odd.js之中,变量even等于undefined,等到后面调用even(n - 1)就会报错。

1
2
3
4
$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function

七、了解:AMD和CMD规范

7.1. CommonJS规范缺点

CommonJS加载模块是同步的:

  • 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行;
  • 这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快;

如果将它应用于浏览器呢?

  • 浏览器加载js文件需要先从服务器将文件下载下来,之后在加载运行;
  • 那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作;

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

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

在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD:

  • 但是目前一方面现代的浏览器已经支持ES Modules,另一方面借助于webpack等工具可以实现对CommonJS或者ES Module代码的转换;
  • AMD和CMD已经使用非常少了,所以这里我们进行简单的演练;

7.2. AMD规范

7.2.1 AMD与Require.js

AMD主要是应用于浏览器的一种模块化规范:

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

我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:

  • AMD实现的比较常用的库是require.js和curl.js;

7.2.2 Require.js的使用

第一步:下载require.js

第二步:定义HTML的script标签引入require.js和定义入口文件:

  • data-main属性的作用是在加载完src的文件后会加载执行该文件
1
<script src="./lib/require.js" data-main="./index.js"></script>

第三步:编写如下目录和代码(个人习惯)

1
2
3
4
5
6
7
├── index.html
├── index.js
├── lib
│ └── require.js
└── modules
├── bar.js
└── foo.js

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function() {
require.config({
baseUrl: '',
paths: {
foo: './modules/foo',
bar: './modules/bar'
}
})

// 开始加载执行foo模块的代码
require(['foo'], function(foo) {

})
})();

modules/bar.js

  • 如果一个模块不依赖其他,那么直接使用define(function)即可
1
2
3
4
5
6
7
8
9
10
11
12
13
define(function() {
const name = "coderwhy";
const age = 18;
const sayHello = function(name) {
console.log("Hello " + name);
}

return {
name,
age,
sayHello
}
})

modules/foo.js

1
2
3
4
5
define(['bar'], function(bar) {
console.log(bar.name);
console.log(bar.age);
bar.sayHello('kobe');
})

7.3 CMD规范

7.3.1 CMD与SeaJS

CMD规范也是应用于浏览器的一种模块化规范:

  • CMD 是Common Module Definition(通用模块定义)的缩写;
  • 它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来;
  • 但是目前CMD使用也非常少了;

CMD也有自己比较优秀的实现方案:

  • SeaJS

7.3.2 SeaJS的使用

1. 下载SeaJS
2. 引入sea.js和启动模块
  • seajs是指定主入口文件的,也称为启动模块
1
2
3
4
5
6
7
8
9
10
11
12
13
<script src="./lib/sea.js"></script> <!--在调用 seajs 之前,必须先引入 sea.js 文件-->
<script>
seajs.use('./index.js');
/*
通过 seajs.use() 函数可以启动模块
- ('模块id' [,callback]) 加载一个模块,并执行回调函数
- (['模块1', '模块2'] [,callback]) 加载多个模块,并执行回调函数
- callback 参数是可选的。格式为:function( 模块对象 ){ 业务代码 };

- seajs.use 理论上只用于加载启动,不应该出现在 define 中的模块代码里
- seajs.use 和 DOM ready 事件没有任何关系。要想保证 文档结构加载完毕再执行你的 js 代码,一定要在seajs.use内部通过 window.onload 或者 $(function(){})
*/
</script>
3. 编写如下目录和代码(个人习惯)
1
2
3
4
5
6
7
├── index.html
├── index.js
├── lib
│ └── sea.js
└── modules
├── bar.js
└── foo.js
4. 定义模块define

在CMD规范中,一个模块就是一个js文件。

module是一个对象,存储了模块的元信息,具体如下:

  • module.id——模块的ID。
  • module.dependencies——一个数组,存储了此模块依赖的所有模块的ID列表。
  • module.exports——与exports指向同一个对象。
  • module.uri

define 是一个全局函数,用来定义模块:define( factory )

  • 对象{}:这种方式,外部会直接获取到该对象
  • 字符串"": 同上
  • 函数:define(function(require, exports, module){ 模块代码 }); 为了减少出错,定义函数的时候直接把这三个参数写上
5. 导出接口exports和module.exports
  • 功能:通过给 exports或module.exports动态的挂载变量、函数或对象,外部会获取到该接口
  • exports 等价于 module.exports。exports能做什么,module.exports就能做什么
  • 可以通过多次给exports 挂载属性向外暴露
  • 不能直接给 exports 赋值
  • 如果想暴露单个变量、函数或对象可以通过直接给 module.exports 赋值 即可
6. 依赖模块require
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
模块标识/模块id
- 模块标识就是一个`字符串`,用来`标识模块`
- 模块标识 可以不包含后缀名.js
- 以 ./或 ../ 开头的相对路径模块,相对于 require 所在模块的路径
- 不以 ./ 或 ../ 开头的顶级标识,会相对于模块的基础路径解析(配置项中的base)
- 绝对路径如http://127.0.0.1:8080/js/a.js、/js/a.js
*/
requeire('模块id')
/*
1.用于根据一个模块id加载/依赖该模块
2.参数必须是一个字符串
3.该方法会得到 要加载的模块中的 module.exports 对象
*/
  • 只能在模块环境define中使用,define(factory)的构造方法第一个参数必须命名为 require
  • 不要重命名require函数或者在任何作用域中给 require 重新赋值
  • 在一个模块系统中,require 加载过的模块会被缓存
  • 默认 require 是同步加载模块的

require.async

SeaJS会在html页面打开时通过静态分析一次性记载所有需要的js文件,如果想要某个js文件在用到时才下载,可以使用require.async:

1
2
3
require.async('/path/to/module/file', function(m) {
//code of callback...
});

这样只有在用到这个模块时,对应的js文件才会被下载,也就实现了JavaScript代码的按需加载。

SeaJS高级配置
  • alias:别名配置
  • paths:路径配置
  • vars:变量配置
  • map:映射配置
  • preload:预加载项
  • debug:调试模式
  • base:基础路径
  • charset:文件编码
代码示例

index.js

1
2
3
define(function(require, exports, module) {
const foo = require('./modules/foo');
})

bar.js

1
2
3
4
5
6
7
8
9
10
11
12
13
define(function(require, exports, module) {
const name = 'lilei';
const age = 20;
const sayHello = function(name) {
console.log("你好 " + name);
}

module.exports = {
name,
age,
sayHello
}
})

foo.js

1
2
3
4
5
6
7
define(function(require, exports, module) {
const bar = require('./bar');

console.log(bar.name);
console.log(bar.age);
bar.sayHello("韩梅梅");
})

八、参考链接

Author:Tenloy

原文链接:https://tenloy.github.io/2021/06/06/Web-Module.html

发表日期:2021.06.06 , 11:09 AM

更新日期:2024.04.07 , 8:02 PM

版权声明:本文采用Crative Commons 4.0 许可协议进行许可

CATALOG
  1. 一、模块化
    1. 1.1 什么是模块化
    2. 1.2 JavaScript设计缺陷
    3. 1.3 没有模块化的JavaScript
      1. 1.3.1 技术方案
      2. 1.3.2 问题举例
      3. 1.3.3 IIFE的缺陷
    4. 1.4 JavaScript中模块化方案
  2. 二、CommonJS规范
    1. 2.1 CommonJS和Node
    2. 2.2 Node模块化语法
      1. 2.2.1 模块
      2. 2.2.2 exports导出
      3. 2.2.3 module.exports
      4. 2.2.4 require
        1. 1. require的加载原理
        2. 2. require的查找规则
        3. 3. require的加载顺序
    3. 2.3 Node的源码解析
  3. 三、ES6 Module
    1. 3.1 认识ES6 Module
      1. 3.1.1 ES6 Module的优势
      2. 3.1.2 自动启动严格模式
      3. 3.1.3 浏览器中加载ES6 Module
        1. 1. 加载普通js文件
        2. 2. 加载ES6 Module
      4. 3.1.4 本地浏览的报错
    2. 3.2 ES6 Module的语法
      1. 3.2.1 模块与CommonJS模块的区别
        1. 1. 相同点
        2. 2. 导出的不同
        3. 3. 导入的不同
      2. 3.2.2 export
        1. 1. export <decl>
        2. 2. export &#123;&#125;
        3. 3. export &#123;<> as <>&#125;
        4. 4. export导出的是标识符的地址
        5. 5. export导出同一个实例
        6. 6. export书写位置
        7. 7. export书写次数
      3. 3.2.3 import
        1. 1. import &#123;&#125; from ''
        2. 1. import ''的含义
        3. 2. import &#123;<> as <>&#125; from ''
        4. 3. import * as <> from ''
        5. 4. import导入为只读
        6. 5. import from后的路径
        7. 6. import命令的提升
        8. 7. import中不能使用表达式和变量
      4. 3.2.4 export default
        1. 1. 概述
        2. 2. 导出与导入格式
        3. 3. export default的本质
        4. 4. export default与export
      5. 3.2.5 export和import结合
      6. 3.2.6 import()
        1. 1. import()的背景
        2. 2. 语法
        3. 3. 适用场合
      7. 3.2.7 应用: 公共头文件
      8. 3.2.8 与CommonJS模块化的差异
  4. 四、CommonJS模块与ES6模块的混编
    1. 4.1 CommonJS模块加载ES6模块
    2. 4.2 ES6模块加载CommonJS模块
    3. 4.3 使模块同时支持两种模块化导入
  5. 五、Node.js开发中的模块化
    1. 5.1 Node中支持 ES6 Module
    2. 5.2 Node.js包模块的入口文件设置
      1. 5.2.1 package.json 的 main 字段
        1. 举例:指定入口文件,格式为ESM
      2. 5.2.2 package.json 的 exports 字段
        1. 1. 给脚本或子目录起别名
        2. 2. main 的别名.
        3. 3. 条件加载
    3. 5.3 Node.js原生模块完全支持ES6 Module
    4. 5.4 加载路径
    5. 5.5 内部变量
  6. 六、循环加载
    1. 6.1 CommonJS 模块的循环加载
    2. 6.2 ES6 模块的循环加载
    3. 6.3 代码示例
  7. 七、了解:AMD和CMD规范
    1. 7.1. CommonJS规范缺点
    2. 7.2. AMD规范
      1. 7.2.1 AMD与Require.js
      2. 7.2.2 Require.js的使用
    3. 7.3 CMD规范
      1. 7.3.1 CMD与SeaJS
      2. 7.3.2 SeaJS的使用
        1. 1. 下载SeaJS
        2. 2. 引入sea.js和启动模块
        3. 3. 编写如下目录和代码(个人习惯)
        4. 4. 定义模块define
        5. 5. 导出接口exports和module.exports
        6. 6. 依赖模块require
        7. SeaJS高级配置
        8. 代码示例
  8. 八、参考链接