1、es6模块特性
ES6 模块跟 CommonJS 模块不同,主要有以下两个方面:
- ES6 模块输出的是值的引用,输出接口动态绑定,而 CommonJS 输出的是值的拷贝
- ES6 模块编译时执行,而 CommonJS 模块总是在运行时加载
特性1:输出值的引用
也就是说,在commonjs中,在不同的模块中引入同一个值,是不会相互影响的。而在es6中,是会相互影响的。
// a.js
import { foo } from './b';
console.log(foo);
setTimeout(() => {
console.log(foo);
import('./b').then(({ foo }) => {
console.log(foo);
});
}, 1000);
// b.js
export let foo = 1;
setTimeout(() => {
foo = 2;
}, 500);
// 执行:babel-node a.js
// 执行结果:
// 1
// 2
// 2
特性2:静态编译
所谓静态编译:es6模块系统的对外接口只是一种静态定义,为编译时加载,遇到模块加载命令import,就会生成一个只读引用。等脚本真正执行时,再根据这个只读引用,到被加载的那个模块内取值。由于ESM编译时就能确定模块的依赖关系,因此能够只包含要运行的代码,可以显著减少文件体积,降低浏览器压力。
ES6 模块编译时执行会导致有以下两个特点:
- import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。
- export 命令会有变量声明提前的效果。
特性3:模块不会重复执行
无论是 ES6 模块还是 CommonJS 模块,当你重复引入某个相同的模块时,模块只会执行一次。
// a.js
import './b';
import './b';
// b.js
console.log('只会执行一次');
// 执行结果:
// 只会执行一次
特性4:循环依赖
ES6 不会再去执行重复加载的模块,又由于 ES6 动态输出绑定的特性,能保证 ES6 在任何时候都能获取其它模块当前的最新值。
编译时执行:
注意这里有一个自己之前不知道的点:当我们从某个js里import的时候,也会执行这个js文件里的代码,比如js文件里有立即执行的代码,就像下边这个例子里的console.log()
// a.js
console.log('a starting')
import {foo} from './b';
console.log('in b, foo:', foo);
export const bar = 2;
console.log('a done');
// b.js
console.log('b starting');
import {bar} from './a';
export const foo = 'foo';
console.log('in a, bar:', bar);
setTimeout(() => {
console.log('in a, setTimeout bar:', bar);
})
console.log('b done');
// babel-node a.js
// 执行结果:
// b starting
// in a, bar: undefined
// b done
// a starting
// in b, foo: foo
// a done
// in a, setTimeout bar: 2
特性5:动态import
ES6 模块在编译时就会静态分析,优先于模块内的其他内容执行,所以导致了我们无法写出像下面这样的代码:
if(some condition) {
import a from './a';
}else {
import b from './b';
}
// or
import a from (str + 'b');
因为编译时静态分析,导致了我们无法在条件语句或者拼接字符串模块,因为这些都是需要在运行时才能确定的结果在 ES6 模块是不被允许的,所以 动态引入import()
应运而生。动态 import()
为我们提供了以异步方式使用 ES 模块的特性,可以 根据我们的需求动态或有条件地加载它们。
😶我们先来看下它的用法:
- 动态的
import()
提供一个基于 Promise 的 API - 动态的
import()
可以在脚本的任何地方使用 import()
接受字符串文字,你可以根据你的需要构造说明符
// a.js
const str = './b';
const flag = true;
if(flag) {
import('./b').then(({foo}) => {
console.log(foo);
})
}
import(str).then(({foo}) => {
console.log(foo);
})
// b.js
export const foo = 'foo';
// babel-node a.js
// 执行结果
// foo
// foo
2、模块常见用法
export
export 导出方式有两种,命名导出
和默认导出
。
- 命名导出还是默认导出都是导出模块中内容的一种方式,可以混合使用。
- 个人理解:默认导出其实是导出了
default
别名变量。 - 一个模块只能有一个默认导出
- 不同的导出方式也对应了不同的导入方式
// 命名行内导出
export const baz = 'baz';
export const foo = 'foo', bar = 'bar';
export function foo() {}
export function* foo() {}
export class Foo {}
// 命名子句导出
export { foo };
export { foo, bar };
export { foo as myFoo, bar };
// 默认导出
export default 'foo';
export default 123;
export default /[a-z]*/;
export default { foo: 'foo' };
export { foo, bar as default };
export default foo
export default function() {}
export default function foo() {}
export default function*() {}
export default class {}
import
- 导入对模块而言是只读的,相当于const 变量
- import导入的值是无法直接修改的,但可以修改导入对象的属性。
import foo, * as Foo './foo.js';
foo = 'foo'; // 错误
Foo.foo = 'foo'; // 错误
foo.bar = 'bar'; // 允许
- 对应不同的导出方式,需要使用不同的导入方式:
// export.js
const foo = 'foo', bar = 'bar', baz = 'baz';
export { foo, bar, baz }
export default foo
// import .js
import * as Foo from './foo.js'; // 导入命名导出内容
import { foo, bar, baz as myBaz } from './foo.js'; // 导入命名导出内
// 导入默认导出的内容
import { default as foo } from './foo.js';//导入默认导出,与下边等效
import foo from './foo.js';//导入默认导出,与上边边等效
- 如果模块同时使用了命名导出和默认导出,可以这样来在import中同时导入
import foo, { bar, baz } from './foo.js';
import { default as foo, bar, baz } from './foo.js';
import foo, * as Foo from './foo.js';
模块转移导出
// 导出foo.js所有命名导出
export * from './foo.js';
// 将foo.js中默认导出修改为命名导出
export {default as foo} from './foo.js'
在组件库或函数库中,我们经常能看到模块转移导出,将需要导出到外部的内容有一个统一的出口,这时要注意导出名称是否会重名等问题
3、 浏览器加载 es6 模块规范实现
- 模块加载机制:
- 浏览器加载ES6模块时,使用
<script>
标签,但需要加入type="module"
属性。这样浏览器就知道这是一个ES6模块。 - 对于带有
type="module"
的<script>
标签,浏览器会异步加载模块,不会造成浏览器堵塞,即等到整个页面渲染完毕后再执行模块脚本,这等同于打开了<script>
标签的defer
属性。
- 浏览器加载ES6模块时,使用
- 模块加载的三个阶段:
- 构建阶段:加载并解析模块代码,生成模块记录。
- 实例化阶段:模块将导出指向内存,此时的变量仅是声明,然后其他模块的导入也指向同一位置,这个过程称为“连接”。
- 执行阶段:运行代码,变量的值将填充内存。
- 动态导入:
- 使用
import()
函数可以动态地导入模块,这种方式返回一个Promise对象,允许按需加载模块。 - 关于ES6模块加载的三个阶段和异步加载,我们可以更详细地探讨:
- 使用
模块加载的三个阶段
- 解析(Parsing):
- 在这个阶段,浏览器会解析模块代码,识别出所有的
import
和export
语句。这个过程是静态的,发生在代码执行之前。 - 解析完成后,浏览器会构建一个依赖图,确定哪些模块需要被加载以及它们的加载顺序。
- 在这个阶段,浏览器会解析模块代码,识别出所有的
- 链接(Linking):
- 链接阶段涉及到两个主要步骤:模块的加载(Fetching)和模块的实例化(Instantiation)。
- 加载:浏览器通过网络请求获取被
import
的模块文件。 - 实例化:在模块文件被加载后,浏览器会创建模块的实例。这个阶段会设置模块的导出和导入的绑定。对于
import
的模块,浏览器会查找对应的export
并建立引用关系。这个过程是同步的,但是加载(Fetching)是异步的。
- 执行(Evaluation):
- 在所有依赖模块都链接完成后,浏览器会执行模块代码。这个阶段是动态的,涉及到实际的代码执行。
- 执行模块时,会按照模块内部的代码逻辑执行,包括变量赋值、函数调用等操作。
异步加载
ES6模块的加载是异步的,这意味着模块的加载和执行不会阻塞浏览器的渲染过程。以下是异步加载的一些关键点:
- 非阻塞加载:
- 带有
type="module"
的<script>
标签会异步加载模块,这意味着浏览器会继续解析和渲染页面,而不会等待模块加载完成。
- 带有
- 按需加载:
- 可以使用动态
import()
语法按需加载模块。这种方式允许在代码执行过程中根据需要加载模块,而不是在页面加载时一次性加载所有模块。
- 可以使用动态
- 执行顺序:
- 尽管模块是异步加载的,但是它们的执行顺序是按照它们在HTML文档中出现的顺序来的。这是因为浏览器在解析阶段就已经确定了模块的依赖关系和加载顺序。
- 错误处理:
- 如果模块加载失败,会抛出错误,并且不会执行该模块或其依赖模块的代码。
- 顶层等待(Top-level await):
- 在模块中可以使用
await
关键字,即使在模块的顶层。这允许异步操作在模块代码执行之前完成,例如,可以等待异步加载的模块或数据。
- 在模块中可以使用
- Promises:
- 动态
import()
返回一个Promise对象,这意味着你可以使用.then()
和.catch()
来处理模块加载的成功和失败情况。
- 动态
通过这种方式,ES6模块提供了一种有效的代码组织和加载机制,使得JavaScript应用可以更加模块化和可维护。
Q:有一个问题不理解,比如说a.js依赖了b.js,浏览器会先加载a.js,后边在加载b.js,那是不是如果b.js浏览器还没有加载到,a.js代码并不会执行
是的,你理解得对。在ES6模块系统中,如果a.js
依赖了b.js
,浏览器会首先加载a.js
,然后在解析过程中发现a.js
中有一个对b.js
的import
语句,浏览器会暂停a.js
的进一步处理,转而加载b.js
。只有当b.js
被完全加载和解析后,浏览器才会继续处理a.js
中的代码。
具体流程如下:
- 加载
a.js
:- 浏览器开始加载
a.js
文件。 - 在加载过程中,浏览器解析
a.js
的代码。
- 浏览器开始加载
- 发现依赖:
- 当浏览器解析到
a.js
中的import
语句时,它会发现a.js
依赖了b.js
。 - 浏览器会暂停对
a.js
的进一步处理,并开始加载b.js
。
- 当浏览器解析到
- 加载
b.js
:- 浏览器加载
b.js
文件。 - 在
b.js
加载和解析完成后,浏览器会继续处理a.js
。
- 浏览器加载
- 继续处理
a.js
:- 一旦
b.js
加载完成,浏览器会继续处理a.js
中的剩余代码。 - 这个过程确保了在执行
a.js
中的代码时,所有依赖的模块(如b.js
)都已经可用。
- 一旦
重要点:
- 同步依赖解析:ES6模块的依赖解析是同步的,这意味着在执行
a.js
之前,所有它依赖的模块(如b.js
)都必须已经加载和解析完成。 - 异步加载:虽然依赖解析是同步的,但模块文件的加载本身是异步的。浏览器会并行加载所有模块文件,但执行顺序会按照依赖关系来保证。
- 执行顺序:模块的执行顺序是由它们的依赖关系决定的,而不是它们在HTML中出现的顺序。浏览器会确保一个模块的所有依赖都已加载和执行后,才会执行该模块。
这种机制确保了模块化代码的正确执行顺序,避免了因依赖未加载而导致的错误。