S08-02 Node-模块化
[TOC]
概述
JS 设计缺陷
模块化开发:那么,到底什么是模块化开发呢?
- 事实上模块化开发最终的目的是将程序划分成一个个小的结构;
- 这个结构中编写属于自己的逻辑代码,有自己的作用域,定义变量名称时不会影响到其他的结构;
- 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用;
- 也可以通过某种方式,导入另外结构中的变量、函数、对象等;
上面说提到的结构,就是模块;
按照这种结构划分开发程序的过程,就是模块化开发的过程;
JS 设计缺陷:
无论你多么喜欢 JavaScript,以及它现在发展的有多好,我们都需要承认在Brendan Eich用了 10 天写出 JavaScript 的时候,它都有很多的缺陷:
- 比如 var 定义的变量作用域问题;
- 比如 JavaScript 的面向对象并不能像常规面向对象语言一样使用 class;
- 比如 JavaScript 没有模块化的问题;
Brendan Eich本人也多次承认过 JavaScript 设计之初的缺陷,但是随着 JavaScript 的发展以及标准化,存在的缺陷问题基本都得到了完善。
- JavaScript 目前已经得到了快速的发展,无论是 web、移动端、小程序端、服务器端、桌面应用都被广泛的使用;
JS 发展历史:
早期:仅作为脚本语言,实现简单的表单验证和动画
在网页开发的早期,Brendan Eich开发 JavaScript 仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:
- 这个时候我们只需要将 JavaScript 代码写到
<script>
标签中即可; - 并没有必要放到多个文件中来编写;
html<button id="btn">按钮</button> <script> document.getElementById("btn").onclick = function() { console.log("按钮被点击了"); } </script>
- 这个时候我们只需要将 JavaScript 代码写到
出现 AJAX:前后端分离
但是随着前端和 JavaScript 的快速发展,JavaScript 代码变得越来越复杂了:
前后端开发分离,意味着后端返回数据后,我们需要通过 JavaScript 进行前端页面的渲染;
出现 SPA:前端更加复杂:前端路由/状态管理等
前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过 JavaScript 来实现;
出现 Node:JS 进行后端开发
JavaScript 编写复杂的后端程序,没有模块化是致命的硬伤:
- 此时模块化已经是 JavaScript 一个非常迫切的需求:
- 但是 JavaScript 本身,直到 ES6(2015)才推出了自己的模块化方案;
- 在此之前,为了让 JavaScript 支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS 等;
在这个章节,我们将详细学习 JavaScript 的模块化,尤其是 CommonJS 和 ES6 的模块化。
没有模块化的问题
没有模块化的问题:
我们先来简单体会一下没有模块化代码的问题。
我们知道,对于一个大型的前端项目,通常是多人开发的(即使一个人开发,也会将代码划分到多个文件夹中):
- 我们假设有两个人:小明和小丽同时在开发一个项目,并且会将自己的 JavaScript 代码放在一个单独的 js 文件中。
小明开发了 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 文件:
if (flag) {
console.log("使用了aaa的flag");
}
问题来了:小明发现 ccc 中的 flag 值不对
- 对于聪明的你,当然一眼就看出来,是小丽将 flag 赋值为了 false;
- 但是如果每个文件都有上千甚至更多的代码,而且有上百个文件,你可以一眼看出来 flag 在哪个地方被修改了吗?
备注:引用路径如下:
<script src="./aaa.js"></script>
<script src="./bbb.js"></script>
<script src="./ccc.js"></script>
所以,没有模块化对于一个大型项目来说是灾难性的。
解决方案:IIFE:
当然,我们有办法可以解决上面的问题:立即调用函数表达式(IIFE)
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");
}
})();
命名冲突的问题,有没有解决呢?解决了。
但是,我们其实带来了新的问题:
- 第一,我必须记得给每一个模块中返回的对象命名,才能在其他模块使用过程中正确的使用;
- 第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写;
- 第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况;
所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。
- 我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码;
- 这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性;
JavaScript 社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们就学习具有代表性的一些规范。
CommonJS 规范
CommonJS 和 Node
CommonJS:
我们需要知道CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS,平时我们也会简称为CJS。
- Node:是 CommonJS 在服务器端一个具有代表性的实现;
- Browserify:是 CommonJS 在浏览器中的一种实现;
- webpack:打包工具具备对 CommonJS 的支持和转换(后面我会讲到);
Node 实现 CommonJS:
所以,Node 中对 CommonJS 进行了支持和实现,让我们在开发 node 的过程中可以方便的进行模块化开发:
- 文件模块:在 Node 中每一个 js 文件都是一个单独的模块;
- 导出:
exports
和module.exports
可以负责对模块中的内容进行导出; - 导入:
require()
函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;
我们可以使用这些变量来方便的进行模块化开发;
导出/导入
exports/require()
在 main 中使用 bar 中定义的变量:
在 main 中使用 bar 中定义的变量:我们来看一下两个文件:
js// bar.js const name = 'coderwhy'; const age = 18; function sayHello(name) { console.log("Hello " + name); }
js// main.js console.log(name); console.log(age); sayHello('kobe');
报错:上面的代码会报错:
在 node 中每一个文件都是一个独立的模块,有自己的作用域;
那么,就意味着别的模块 main 中不能随便访问另外一个模块 bar 中的内容;
bar 需要导出自己想要暴露的变量、函数、对象等等;
main 从 bar 中导入自己想要使用的变量、函数、对象等等;
导出和导入
导出 / 导入:
exports 是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会被导出。
exports
导出:bar 中导出内容:jsexports.name = name; exports.age = age; exports.sayHello = sayHello;
require()
导入:main 中导入内容:jsconst bar = require('./bar');
导出 / 导入的本质:
exports 本质是一个对象:该对象中保存着被导出的变量。
require() 本质是在获取文件模块中的 exports 对象:
require() 后 main 中的 bar 变量等于 exports 对象 :二者指向内存中的同一个对象
js// main 中的 bar 变量等于 exports 对象 const bar = require('./bar');
所以,我可以编写下面的代码:
jsconst 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 是同一个对象
结论:所以,bar 对象是 exports 对象的浅拷贝。浅拷贝的本质就是一种引用的赋值而已。
module.exports@
module:是一个全局对象,代表当前模块。它包含了模块的元信息、导出内容、依赖关系等。每个文件都是一个模块,并且都有一个 module
对象。
基本属性:
- module.id:
string
,模块标识符。 - module.filename:
string
,模块的绝对路径。 - module.path:
string
, 模块所在目录。 - module.loaded:
boolean
,模块是否加载完成。 - module.parent:
Module
,父模块。 - module.children:
array
,子模块数组。 - module.paths:
array
,模块查找路径。 - module.exports:
{}|Function|Class
,模块的导出内容。
module.exports:是 Node.js 模块系统中真正决定模块导出内容的对象。当其他模块通过 require()
导入该模块时,得到的就是 module.exports
的值。
基本概念:
module.exports
是模块导出的唯一出口- 初始值为空对象
{}
require()
返回的就是module.exports
:module.exports
和 exports
的关系
Node 中真正用于导出的是
module.exports
:如果修改了 module.exports 对象:
新的对象会取代 exports 对象作为导出,require 导入的对象是新的对象。
原先的 exports 对象不再有导出作用。
exports
是module.exports
的一个引用(语法糖):在每个模块的最开始,Node.js 会自动执行这样一行代码:
js// Node.js 在模块包装器中自动执行 const exports = module.exports;
这意味着:
exports
是module.exports
的一个引用(别名)- 它们指向同一个内存地址
- 初始时都是一个空对象
{}
内存图解:
初始状态:
添加属性后:
重新赋值 exports 后:
重新赋值 module.exports 后:
exports 存在的原因:
CommonJS 规范需要:
CommonJS 规范中只有 exports,并没有 module.exports 的概念,为了实现 CommonJS 规范的需要就有了 exports
语法糖:
exports 本质上是一个语法糖,让代码更简洁。
错误用法:
给 exports 重新赋值:
- 给 exports 重新赋值后 exports 和 module.exports 不再指向同一个对象。
- 而 Node 中真正用于导出的是 module.exports
- 此时 exports 会丢失导出的功能
- require() 返回的是 module.exports 对象,而非 exports 对象。
js// case3-exports-reassignment.js console.log('初始状态:'); console.log('exports === module.exports:', exports === module.exports); // true // 给 exports 重新赋值 exports = { newProperty: '新属性', newMethod: function() { return '新方法'; } }; console.log('给 exports 重新赋值后:'); console.log('exports === module.exports:', exports === module.exports); // false console.log('module.exports:', module.exports); // {} - 仍然是空对象 console.log('exports:', exports); // { newProperty: '新属性', newMethod: [Function] }
js// 导入测试 const result = require('./case3-exports-reassignment'); console.log('导入结果:', result); // {} - 空对象!(指向 module.exports)
require() 查找规则@
require():是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。
require() 查找规则:
文档:https://nodejs.org/docs/latest/api/modules.html#modules_all_together
那么,require 的查找规则是怎么样的呢?这里我总结比较常见的查找规则:
导入格式如下:require(X)
情况一:X 是一个 Node 内置模块:比如 path、http
jsrequire('path')
- 直接返回内置模块,并且停止查找
情况二:X 是一个文件路径:如以
./
或../
或/
(根目录)开头jsrequire('./utils/format')
第一步:将 X 作为文件在对应的目录下查找:
如果有后缀名,按照后缀名的格式查找对应的文件
如果没有后缀名,会按照如下顺序:
- 1> 直接查找文件 X
- 2> 查找 X.js 文件
- 3> 查找 X.json 文件
- 4> 查找 X.node 文件
第二步:没有找到对应的文件,将 X 作为目录查找
- 查找目录下面的 index 文件
- 1> 查找 X/index.js 文件
- 2> 查找 X/index.json 文件
- 3> 查找 X/index.node 文件
- 查找目录下面的 index 文件
如果没有找到,那么报错:
not found
情况三:X 是第三方库:直接是一个 X(没有路径),并且 X 不是一个核心模块
js// /Users/coderwhy/Desktop/Node/TestCode/04_learn_node/05_javascript-module/02_commonjs/main.js require('lodash')
查找顺序:
如果上面的路径中都没有找到,那么报错:
not found
模块加载顺序
模块加载顺序:
这里我们研究一下模块的加载顺序问题。
结论一:模块在被第一次引入时,模块中的 js 代码会被运行一次
js// aaa.js const name = 'coderwhy'; console.log("Hello aaa"); setTimeout(() => { console.log("setTimeout"); }, 1000);
js// main.js const aaa = require('./aaa'); // aaa.js 中的代码在引入时会被运行一次
结论二:模块被多次引入时,会缓存,最终只加载(运行)一次
原因:这是因为每个模块对象 module 都有一个属性:
module.loaded
。- 为 false 表示还没有加载,
- 为 true 表示已经加载;
js// ccc.js console.log('ccc被加载');
js// aaa.js const ccc = require("./ccc"); // 第一次引入
js// bbb.js const ccc = require("./ccc"); // 第二次引入 // ccc 中的代码只会运行一次。
js// main.js const aaa = require('./aaa'); const bbb = require('./bbb');
结论三:如果有循环引入,加载顺序为深度优先算法
如果出现下面模块的引用关系,那么加载顺序是什么呢?
- 这个其实是一种图结构;图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
- Node 采用的是深度优先算法:
main -> aaa -> ccc -> ddd -> eee ->bbb
Node 的源码解析@
Module 类
Module.prototype.require 函数
Module._load 函数
AMD 和 CMD 规范
CommonJS 规范缺点
CommonJS 规范的缺点:CommonJS 加载模块是同步的
- 同步的问题:同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行。
- 服务器没影响:因为服务器加载的 js 文件都是本地文件,加载速度非常快;
- 浏览器有影响:
- 因为浏览器加载的 js 文件需要先从服务器将文件下载下来,之后再加载运行。
- 下载速度慢会造成后续的 js 代码都无法正常运行,即使是一些简单的 DOM 操作;
- 所以在浏览器中,我们通常不使用 CommonJS 规范。
- webpack 中使用 CommonJS:
- webpack 会将我们的代码转化成浏览器可以直接执行的代码;
- 解决方案:
- 早期:为了可以在浏览器中使用模块化,通常会采用 AMD 或 CMD。
- 目前:
- 现代的浏览器已经支持 ES Module。
- 还可以借助于 webpack 等工具可以实现对 CommonJS 或者 ES Module 代码的转换;
- AMD 和 CMD 已经使用非常少了,所以这里我们进行简单的演练;
AMD 规范
AMD 概述
AMD(Asynchronous Module Definition,异步模块定义):采用异步加载模块,是应用于浏览器的一种模块化规范。
使用情况:AMD 的规范早于 CommonJS,但 CommonJS 目前依然在被使用,而 AMD 使用的较少了;
实现库:规范只是定义代码应该如何去编写,只有有了具体的实现才能被应用。
require.js
这里我们以 require.js 为例讲解:
第一步:下载 require.js
下载地址:https://github.com/requirejs/requirejs/blob/master/require.js
第二步:引入 require.js 和定义入口文件
- src:引入 require.js
- data-main:定义入口文件,加载完 src 的文件后会加载并执行该文件。
html<script src="./lib/require.js" data-main="./index.js"></script>
第三步:文件目录结构
txt// 文件目录结构 ├── index.html ├── index.js ├── lib │ └── require.js └── modules ├── bar.js └── foo.js
第四步:引入并使用模块
js// index.js ;(function () { // 1. Require.js 配置 require.config({ baseUrl: '', paths: { foo: './modules/foo', bar: './modules/bar' } }) // 2. 开始加载执行foo模块的代码 require(['foo'], function (foo) {}) })()
第五步:定义模块
不依赖其他模块:直接使用
define(function)
js// modules/bar.js define(function () { const name = 'coderwhy' const age = 18 const sayHello = function (name) { console.log('Hello ' + name) } // 通过 return 向外暴露属性和方法 return { name, age, sayHello } })
依赖其他模块:使用
define(['dep1',...],function() {})
js// modules/foo.js define(['bar'], function (bar) { console.log(bar.name) console.log(bar.age) bar.sayHello('kobe') })
CMD 规范
CMD 概述
CMD(Common Module Definition,通用模块定义):采用异步加载模块,是应用于浏览器的一种模块化规范。它将 CommonJS 的优点吸收了过来。
使用情况:目前 CMD 使用也非常少了。
实现库:
seajs
我们一起看一下 seajs 如何使用:
第一步:下载 seajs
第二步:引入 sea.js 和使用主入口文件
seajs
是指定主入口文件的html<script src="./lib/sea.js"></script> <script> seajs.use('./index.js'); </script>
- 第三步:编写如下目录和代码
代码目录结构
sh├── index.html ├── index.js ├── lib │ └── sea.js └── modules ├── bar.js └── foo.js
在 index 模块中引入其他模块
js// index.js define(function (require, exports, module) { const foo = require('./modules/foo') })
定义子模块
js// foo.js define(function (require, exports, module) { // 引入 bar 模块 const bar = require('./bar') console.log(bar.name) console.log(bar.age) bar.sayHello('韩梅梅') })
js// bar.js define(function (require, exports, module) { const name = 'lilei' const age = 20 const sayHello = function (name) { console.log('你好 ' + name) } // 导出 module.exports = { name, age, sayHello } })
ES Module
认识 ES Module
JS 早期的痛点:没有模块化
JavaScript 没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD 等,所以在 ES 推出自己的模块化系统时,大家也是兴奋异常。
ESM(ECMAScript Modules):是 JS 的官方模块系统,在现代浏览器和 Node.js 中都有良好支持。它使用 import
和 export
语句来管理代码的导入和导出,让 JS 代码可以模块化组织。
ES Module 对比 CommonJS:
ES Module和CommonJS的模块化有一些不同之处:
- 导入和导出:ESM 使用
import
和export
关键字。- export:负责将模块内的内容导出;
- import:负责从其他模块导入内容;
- 静态类型检测:ESM 采用编译期静态类型检测,并且也加入了动态引用的方式。
- 严格模式:ESM 自动采用严格模式
use strict
ES Module 的使用
基本导出导入
基本导出导入:
声明模块类型:
在 HTML 中使用 ESM 需要在
<script>
标签中设置type="module"
属性。告诉浏览器该脚本是一个 ES 模块。html<!-- 使用 type="module" 标识模块脚本 --> <script type="module" src="js/app.js">></script>
导出模块:
在模块文件中可以通过
export
关键字导出变量、函数或类。js// js/app.js const name = 'tom' const age = 17 const sayHello = () => { console.log('sayHello') } // 导出 export { name, age, sayHello }
导入模块:
可以通过
import
关键字导入其他模块导出的变量、函数或类。注意:路径部分在没有 webpack 时,需要加上
.js
后缀名。js// main.js import { name, age, sayHello } from './js/app.js' // 后续使用导入的变量
CORS 要求
CORS 要求:出于安全性需要,模块脚本遵循 CORS 策略,不能通过 file://
协议直接访问,需要开启服务器运行模块脚本。
引入文件模块:index 中引入两个 js 文件作为模块:
html<script src="./modules/foo.js" type="module"></script> <script src="main.js" type="module"></script>
file://
协议打开报 CORS 错误:如果直接在浏览器中运行代码,会报如下错误:错误原因:出于安全性需要,模块脚本遵循 CORS 策略,不能通过
file://
协议直接访问,需要开启服务器运行模块脚本。Live Server 打开:在 VSCode 中可以通过 Live Server 插件将代码运行在一个本地服务中。
export 导出
export:关键字将一个模块中的变量、函数、类等导出。
export 导出方式:
我们希望将其他中内容全部导出,它可以有如下的方式:
方式一:基本导出(命名导出)
将所有需要导出的标识符,放到 export 后面的
{}
中注意:
export {name: name}
,是错误的写法,export是关键字,不是一个对象。- 各种导出方式可以混合使用
jsconst name = 'coderwhy'; const age = 18; let message = "my name is why"; function sayHello(name) { console.log("Hello " + name); } export { name, age, message, sayHello }
方式二:命名导出
可以通过在
export
后跟随语句声明的方式导出jsexport const PI = 3.14159; export function add(a, b) { return a + b; } export function multiply(a, b) { return a * b; }
方式三:默认导出
每个模块只能有一个默认导出,通常用于导出主要功能。
js// logger.js - 默认导出示例 export default function log(message) { console.log(`[LOG]: ${message}`); }
js// 或者使用这种语法 function log(message) { console.log(`[LOG]: ${message}`); } export default log;
方式四:导出起别名
导出时可以通过
as
给标识符起别名jsexport { name as fName, age as fAge, message as fMessage, sayHello as fSayHello }
import 导入
import:关键字负责从另外一个模块中导入内容。
import 导入方式:
导入内容的方式也有多种:
方式一:导入命名导出
使用花括号
{}
按名称导入特定的导出。自定义名称需要通过as
起别名。jsimport { PI, add } from './math.js'
方式二:导入默认导出
默认导出不需要使用花括号,可以自定义名称。
js// 导入默认导出 import log from './logger.js'; // 可以任意命名默认导入 import myLogger from './logger.js';
方式三:导入所有导出
可以通过
import * as
导入所有导出作为单个命名空间对象。js// 导入所有导出作为命名空间 import * as MathUtils from './math.js' console.log(MathUtils.PI) // 3.14159 console.log(MathUtils.add(10, 5)) // 15 console.log(MathUtils.multiply(4, 7)) // 28
方式四:导入起别名
导入时可以通过
as
给标识符起别名js// 使用重命名语法 import { default as main, helperFunction as helper } from './utils.js';
方式五:空导入(仅执行模块)
如果只需要执行目标模块的代码(例如模块有初始化副作用,如注册事件、修改全局变量),但不需要导入任何内容,可以使用空导入。
js// 执行 module.js 中的代码,但不导入任何值 import './module.js';
核心特性:
静态分析:
普通 import 是静态语法(动态导入除外),必须放在模块顶层(不能在
if
、函数等代码块内),浏览器 / 引擎会在编译阶段解析依赖,实现 tree-shaking(摇树优化,剔除未使用的代码)。路径规则:
导入本地模块时:
路径必须以
./
(当前目录)、../
(父目录)或绝对路径开头(如/src/module.js
)。导入第三方模块时:
直接写包名(如
import React from 'react'
),由环境(Node.js 或构建工具)解析。
只读性:
导入的变量是只读的,不能修改(类似
const
)。jsimport { PI } from './utils.js'; PI = 3; // 报错:Assignment to constant variable
模块缓存:
同一模块被多次导入时,只会执行一次,后续导入直接复用第一次的执行结果(缓存机制)。
浏览器使用:
在浏览器中使用 ESM 时,
<script>
标签需添加type="module"
:html<script type="module"> import { add } from './utils.js'; console.log(add(1, 2)); </script>
export from
export from:是一种简洁的语法,用于将一个模块的导出成员 “转发” 到另一个模块,避免了 “先导入再导出” 的冗余代码。
核心作用:
- 模块聚合:将多个模块的导出集中到一个入口模块。
- 间接导出:透传其他模块的成员。
基本语法:
export { 成员1, 成员2 as 新名称 } from '目标模块路径';
// 等价于:
import { 成员1, 成员2 } from '目标模块路径';
export { 成员1, 成员2 as 新名称 };
优点:
不污染当前模块作用域:
不同于
import + export
,export from
只是 “透传” 导出,不会在当前模块中创建变量。重命名冲突:
如果转发的成员存在命名冲突,可以用
as
重命名。
常见用法场景:
转发命名导出(单个或多个):
如果需要将模块 A 的命名导出成员透传到当前模块,再对外导出,可以直接用
export from
转发。js// 转发 math.js 的 add 和 subtract(直接使用原名称) export { add, subtract } from './math.js'; // 转发时重命名(将 subtract 改为 minus) export { subtract as minus } from './math.js';
转发所有命名导出:
使用
export * from '模块路径'
可以转发目标模块所有命名导出(但不包括默认导出)。js// 转发 math.js 中所有命名导出(add、subtract) export * from './math.js';
转发默认导出:
默认导出需要显式指定
default
关键字转发,因为export * from
不会处理默认导出。js// 转发 greet.js 的默认导出(保留原默认导出特性) export { default } from './greet.js'; // 转发时重命名为 hello(转为命名导出) export { default as hello } from './greet.js';
示例:模块聚合
export from
最常见的用途是模块聚合:将多个分散的模块导出集中到一个入口文件(如 index.js
),简化其他模块的导入路径,方便指定统一的接口规范,也方便阅读。
假设有以下模块结构:
plaintextsrc/ ├── modules/ │ ├── math.js // 包含 add、subtract │ ├── greet.js // 包含默认导出 sayHello │ └── format.js // 包含 formatDate └── index.js // 聚合模块
在
index.js
中聚合所有导出:js// 聚合 math.js 的所有命名导出 export * from './modules/math.js'; // 聚合 greet.js 的默认导出(重命名为 greet) export { default as greet } from './modules/greet.js'; // 聚合 format.js 的 formatDate export { formatDate } from './modules/format.js';
其他模块只需从
index.js
导入即可,无需记住复杂路径:js// app.js import { add, greet, formatDate } from './src/index.js';
default 默认导出
默认导出:用于模块对外暴露指定的 “主要” 功能或默认返回值,使用 export default
关键字。
基本语法:
直接导出(声明时导出)
可以在定义成员的同时直接进行默认导出,适用于函数、类、对象等:
js// 导出函数(作为模块默认功能) export default function(a, b) { return a + b; } // 导出类 export default class User { constructor(name) { this.name = name; } } // 导出对象 export default { version: "1.0.0", author: "Alice" }; // 导出基本类型值 export default "Hello, ESM";
先声明后导出(单独导出)
也可以先定义成员,再通过
export default
单独导出(更适合逻辑复杂的场景):js// 先定义函数 function calculateTotal(prices) { return prices.reduce((sum, price) => sum + price, 0); } // 再默认导出(整个模块的核心功能) export default calculateTotal;
核心特点:
唯一性:每个模块只能有一个默认导出,重复使用
export default
会报错。js// 错误示例:多个默认导出 export default 1; export default 2; // SyntaxError: Duplicate export default
不强制名称:默认导出的成员可以没有显式名称(如匿名函数、匿名类),因为导入时会自定义名称。
js// 导出匿名函数(常见写法) export default function(a, b) { return a * b; }
本质是特殊的命名导出:默认导出本质上是 “名为
default
的命名导出”,因此可以通过命名导出的形式书写。js// 以下两种写法完全等价 export default sum; export { sum as default };
注意事项:
注意变量声明语句:
不要在默认导出中使用变量声明语句(如
export default const a = 1
是错误的),应直接导出值或已声明的变量。js// 错误写法 export default const message = "hello"; // SyntaxError // 正确写法 const message = "hello"; export default message;
导入默认导出时命名建议:
导入默认导出时,名称可以任意自定义,但建议使用与模块功能相关的语义化名称,提高代码可读性。
动态导入中的默认导出:
动态导入(
import()
)中,默认导出同样通过模块对象的default
属性访问:jsimport('./module.js').then(module => { console.log(module.default(2, 3)); // 访问默认导出 });
默认导出的导入方式:
导入默认导出的成员时,语法与命名导出不同,特点是:
- 无需使用大括号
{}
包裹 - 可以自定义任意名称(无需与导出时的名称匹配)
基本语法:
基本用法
js// 模块 file.js 中默认导出一个函数 export default function(a, b) { return a + b; } // 导入时自定义名称为 add import add from './file.js';
同时导入默认导出和命名导出:
如果模块同时有默认导出和命名导出,导入时默认成员在前,命名成员用大括号包裹:
js// module.js export default function multiply(a, b) { return a * b; } // 默认导出 export const pi = 3.14; // 命名导出 // 导入时:默认成员 + 命名成员 import multiply, { pi } from './module.js';
整体导入中的默认导出:
使用
import * as 模块名
整体导入时,默认导出的成员会被放在模块对象的default
属性中:jsimport * as MyModule from './module.js'; // 访问默认导出:通过 .default 属性 console.log(MyModule.default(2, 3)); // 6(调用默认导出的 multiply 函数) // 访问命名导出:直接通过属性名 console.log(MyModule.pi); // 3.14
import()
静态 :import
的问题
静态
import
:是 “编译时解析”,必须写在模块顶层,不能在if
、函数等代码块内。js// 静态 import 不允许这样写(语法错误) if (condition) { import { a } from './a.js'; // 报错 }
这是因为 ESM 在被 JS 引擎解析时,就必须知道它的依赖关系。
而此时 js 代码没有任何的运行,所以无法在进行类似于 if 判断中根据代码的执行情况。
甚至下面的这种写法也是错误的:因为我们必须到运行时能确定 path 的值。
jsconst path = './modules/foo.js'; import sub from path; // 模块路径必须是写死的,不能通过解析变量获得
import()
:是 “运行时执行”,可以在任何代码位置(如条件判断、事件回调、函数内部)调用,实现按需加载。js// 动态 import 允许在条件中使用 if (condition) { const module = await import('./a.js'); // 合法 }
import():(modulePath)
,动态导入(Dynamic Import),是一种函数式的模块加载语法,与静态 import
语句不同,允许在运行时动态加载模块。
modulePath:
string
,模块路径。返回:
promise:
Promise
,会 resolve 为模块的导出对象(包含该模块的所有导出内容)。
应用场景:
路由懒加载:
在 SPA 中,针对不同路由加载对应的组件,减少首页加载时间。
js// 路由配置示例(伪代码) const routes = [ { path: '/home', component: () => import('./Home.js') // 访问 /home 时才加载 Home 组件 }, { path: '/profile', component: () => import('./Profile.js') // 访问 /profile 时才加载 } ];
条件加载:
根据环境、用户设备或业务逻辑加载不同模块:
jsasync function loadFeature() { if (isMobile) { // 移动端加载轻量版模块 const module = await import('./mobile-feature.js'); } else { // 桌面端加载完整版模块 const module = await import('./desktop-feature.js'); } }
import.meta
import.meta:object
,ES2020,只读,元属性(meta property),用于向当前模块暴露与模块自身相关的元数据(metadata),例如模块的 URL、路径等信息。
核心特性:
模块特有:
import.meta 仅在 ESM 模块内部可用。
环境相关:
import.meta 包含的具体属性由运行环境决定,不同环境会扩展不同的属性,但有一个通用核心属性 url。
import.meta.url
import.meta.url:string
,通用属性,返回当前模块的文件绝对路径。格式:file:///path/to/currentModule.js
示例:
获取当前模块的 URL
js// currentModule.js console.log(import.meta.url); // 输出:当前模块的绝对 URL,file:///path/to/currentModule.js
动态加载相对路径的资源
jsconst imageUrl = new URL('image.png', import.meta.url); console.log(imageUrl.href); // 输出:file:///path/to/currentModule/image.png
使用 import.meta.url 动态导入模块
jsconst modulePath = `${import.meta.url}/moduleB.js`; import(modulePath).then((module) => { console.log(module); }).catch((err) => { console.error('Failed to load module:', err); });
结合 fileURLToPath() 设置@别名
jsexport default defineConfig({ resolve: { alias: { // 说明:fileURLToPath() 返回的是常用的系统文件路径格式:C:\dev\my_module.ts '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, })
import.meta.scriptElement
import.meta.scriptElement:Element
,浏览器script环境特有,用于在JS模块环境中获取当前 script元素的引用。
示例:import.meta.scriptElement
<!-- 必须是在 type="module" 的 script 标签下 -->
<script type="module" data-foo="abc">
import.meta.scriptElement.dataset.foo // "abc"
</script>
import.meta.env
import.meta.env:any
,vite/webpack/rollup环境特有,用于访问在构建过程中注入的环境变量。
示例:
环境模式切换
jsif (import.meta.env.MODE === 'development') { // 开发模式下的操作 } else if (import.meta.env.MODE === 'production') { // 生产模式下的操作 }
访问自定义环境变量
js// 1. 在.env中自定义变量 VITE_API_URL = https://api.example.com VITE_APP_VERSION = 1.0.0 // 2. 在代码中访问 console.log(import.meta.env.VITE_API_URL); // 输出:https://api.example.com console.log(import.meta.env.VITE_APP_VERSION); // 输出:1.0.0
import.meta.glob
import.meta.glob():(patterns,options?)
,vite特有,通过 glob 模式字符串匹配文件路径,返回一个包含匹配结果的对象。
patterns:
string|array
,glob 模式字符串或模式数组,用于指定需要匹配的文件路径。options?:
object
,配置匹配和导入行为的选项,常用属性如下:- eager:
boolean
,默认:false
,是否在编译时立即加载模块,而非动态导入函数。 - import:
string
,指定导入模块中的特定导出项。 - exclude:
string[]
,默认:[]
,排除符合模式的文件路径(优先级高于 patterns)。 - query:
object
,为导入的模块路径添加查询参数。 - resolve:
string
,自定义模块解析后的路径格式。
- eager:
返回:
result:
object
,返回一个对象,其结构取决于options.eager
的值:{ [filePath]: () => Promise<Module> }
:当eager: false
(默认)- 键:匹配到的文件的相对路径(如
./utils/format.js
); - 值:动态导入函数,调用后返回 Promise,解析为模块对象(包含
default
和命名导出)。
js// 匹配所有 .js 模块(默认懒加载) const modules = import.meta.glob('./utils/*.js'); // 模块结构: // { // './utils/format.js': () => import('./utils/format.js'), // './utils/validate.js': () => import('./utils/validate.js') // } async function useModules() { // 使用时需手动调用动态导入函数 const formatModule = await modules['./utils/format.js'](); formatModule.format(); // 调用模块中的方法 }
- 键:匹配到的文件的相对路径(如
{ [filePath]: Module }
,当eager: true
- 键:匹配到的文件的相对路径;
- 值:模块对象(已加载完成,可直接访问导出内容)。
js// 立即加载所有 .vue 组件 const components = import.meta.glob('./components/*.vue', { eager: true }); // 模块结构: // { // './components/Button.vue': { default: Button, ... }, // './components/Input.vue': { default: Input, ... } // } // 直接使用模块(无需异步调用) const Button = components['./components/Button.vue'].default;
模块指定的导出对象
,当指定options.import
若指定了
import: 'xxx'
,返回值的 value 会直接指向模块的xxx
导出(而非完整模块对象):js// 只导入默认导出(eager 模式) const pages = import.meta.glob('./pages/*.js', { eager: true, import: 'default' // 只取 default 导出 }); // 模块结构: // { // './pages/Home.js': HomeComponent, // 直接是 default 导出的值 // './pages/About.js': AboutComponent // } // 直接使用默认导出 const Home = pages['./pages/Home.js'];
核心特性:
类似方法:
webpack中类似的方法
require.context()
。非标准特性:
import.meta.glob
是 Vite、Rollup 等构建工具的扩展,并非 ESM 标准,不同工具的实现可能存在差异(如 Webpack 需通过插件实现类似功能)。编译时执行:
匹配和导入逻辑在构建阶段完成,运行时不会动态扫描文件(因此新增文件需重新构建)。
路径规则:
glob 模式中的路径是相对于当前模块文件的相对路径,且必须以
./
或../
开头(不能使用裸模块名)。Tree-shaking:
默认懒加载模式(
eager: false
)下,未被调用的模块导入函数会被构建工具剔除(支持 tree-shaking)。
使用场景:
路由自动注册:批量导入页面组件,生成路由配置:
js// 1. 懒加载所有页面组件 const pages = import.meta.glob('./pages/*.vue'); // 2. 生成路由配置 const routes = Object.entries(pages).map(([path, component]) => ({ path: path.replace('./pages/', '/').replace('.vue', ''), component }));
插件 / 工具批量加载:一次性导入多个工具函数模块:
js// 立即加载所有工具模块 const utils = import.meta.glob('./utils/*.js', { eager: true }); // 统一暴露工具方法 export const tools = Object.values(utils).reduce((obj, mod) => ({ ...obj, ...mod }), {});
ESM 解析流程@
文章地址:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
ESM 的解析流程:
ESM 的解析过程可以分为三个阶段:
阶段一:构建(Construction)
从入口文件出发, 根据 import 一层层解析, 每个模块都会生成一个模块记录 Module Record
- 生成依赖关系树:首先会先查找相关依赖, 形成一个依赖关系树 AST。
- 异步加载模块资源:其次还需要加载 import 对应模块的资源, 而为了尽量不阻塞线程, 这一步实际上又是异步进行的! 不同于 Commonjs 这里加载的资源大多可能是外部的资源, 所以速度肯定没有读取本地资源来得快, 所以就整成异步加载了!!
- 缓存模块资源:当然这里其实也是存在缓存的, 一旦模块记录 Module Record 被创建, 它会被记录在模块映射 Module Map 中。被记录后, 如果再有对相同 URL 的请求, 将直接采用模块映射 Module Map 中 URL 对应的模块记录 Module Record
阶段二:实例化(Instantiation)
- 开辟内存,存储依赖关系图:所谓实例化就是在内存中开辟出一块空间来, 存储所有 exprot 出来的数据, 同时将 export 和 import 涉及到的变量都指向对应的内存快中, 这一步又被称之为链接(linking)!
- 注意: 这里只是开辟了一批内存用于存储变量, 但是这里并没有执行代码, 所以实际上变量的值还未填充到内存中, 只是开辟内存并建立起一个完整的依赖关系图
阶段三:求值(Evaluation)
上面一步划分了一块内存区域用来存储数据, 但实际上并没执行模块内的代码, 也就是没有实际往内存中填充数据, 而求值这一步目前就是执行所有模块的最外层代码, 往内存里填充相关的数据。
ES Module 的原理【
ES Module 和 CommonJS 的区别
CommonJS 模块加载 js 文件的过程是运行时加载的,并且是同步的:
- 运行时加载意味着是 js 引擎在执行 js 代码的过程中加载 模块;
- 同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行;
console.log("main代码执行");
const flag = true;
if (flag) {
// 同步加载foo文件,并且执行一次内部的代码
const foo = require('./foo');
console.log("if语句继续执行");
}
CommonJS 通过 module.exports 导出的是一个对象:
- 导出的是一个对象意味着可以将这个对象的引用在其他模块中赋值给其他变量;
- 但是最终他们指向的都是同一个对象,那么一个变量修改了对象的属性,所有的地方都会被修改;
ES Module 加载 js 文件的过程是编译(解析)时加载的,并且是异步的:
编译时(解析)时加载,意味着 import 不能和运行时相关的内容放在一起使用:
- 比如 from 后面的路径需要动态获取;
- 比如不能将 import 放到 if 等语句的代码块中;
- 所以我们有时候也称 ES Module 是静态解析的,而不是动态或者运行时解析的;
异步的意味着:JS 引擎在遇到
import
时会去获取这个 js 文件,但是这个获取的过程是异步的,并不会阻塞主线程继续执行;- 也就是说设置了
type=module
的代码,相当于在 script 标签上也加上了async
属性; - 如果我们后面有普通的 script 标签以及对应的代码,那么 ES Module 对应的 js 文件和代码不会阻塞它们的执行;
- 也就是说设置了
<script src="main.js" type="module"></script>
<!-- 这个js文件的代码不会被阻塞执行 -->
<script src="index.js"></script>
ES Module 通过 export 导出的是变量本身的引用:
- export 在导出一个变量时,js 引擎会解析这个语法,并且创建模块环境记录(module environment record);
- 模块环境记录会和变量进行
绑定
(binding),并且这个绑定是实时的; - 而在导入的地方,我们是可以实时的获取到绑定的最新值的;
export 和 import 绑定的过程
所以我们下面的代码是成立的:
bar.js 文件中修改
let name = 'coderwhy';
setTimeout(() => {
name = "湖人总冠军";
}, 1000);
setTimeout(() => {
console.log(name);
}, 2000);
export {
name
}
main.js 文件中获取
import { name } from './modules/bar.js';
console.log(name);
// bar中修改, main中验证
setTimeout(() => {
console.log(name);
}, 2000);
但是,下面的代码是不成立的:main.js 中修改
import { name } from './modules/bar.js';
console.log(name);
// main中修改, bar中验证
setTimeout(() => {
name = 'kobe';
}, 1000);
导入的变量不可以被修改
思考:如果 bar.js 中导出的是一个对象,那么 main.js 中是否可以修改对象中的属性呢?
- 答案是可以的,因为他们指向同一块内存空间;(自己编写代码验证,这里不再给出)
Node 中支持 ES Module
在 Current 版本中
在最新的 Current 版本(v14.13.1)中,支持 es module 我们需要进行如下操作:
- 方式一:在 package.json 中配置
type: module
(后续再学习,我们现在还没有讲到 package.json 文件的作用) - 方式二:文件以
.mjs
结尾,表示使用的是 ES Module;
这里我们暂时选择以 .mjs
结尾的方式来演练:
bar.mjs
const name = 'coderwhy';
export {
name
}
main.mjs
import { name } from './modules/bar.mjs';
console.log(name);
在 LTS 版本中
在最新的 LST 版本(v12.19.0)中,我们也是可以正常运行的,但是会报一个警告:
lts 版本的警告
ES Module 和 CommonJS 的交互
CommonJS 加载 ES Module
结论:通常情况下,CommonJS 不能加载 ES Module
- 因为 CommonJS 是同步加载的,但是 ES Module 必须经过静态分析等,无法在这个时候执行 JavaScript 代码;
- 但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持;
- Node 当中是不支持的;
ES Module 加载 CommonJS
结论:多数情况下,ES Module 可以加载 CommonJS
- ES Module 在加载 CommonJS 时,会将其 module.exports 导出的内容作为 default 导出方式来使用;
- 这个依然需要看具体的实现,比如 webpack 中是支持的、Node 最新的 Current 版本也是支持的;
- 但是在最新的 LTS 版本中就不支持;
321 | 3213 | 3123 |
---|---|---|
133 | 313 this is a test | 313 |
313 | 313 | 313 |
312 | 131 | 313 |
foo.js
const address = 'foo的address';
module.exports = {
address
}
main.js
import foo from './modules/foo.js';
console.log(foo.address);