导出和导入
ES6
关键字:import、export
// 导出
// es6-module.js
export const myVar = '导出变量';
export function myFunction() {
console.log('导出函数');
}
export default function() {
console.log('默认导出');
}
// 导入
// es6-test.js
// 导入单个或多个变量或函数
import { myVar, myFunction } from './es6-module.js';
// 导入默认变量或函数
import defaultFunction from './es6-module.js'
console.log(myVar)
myFunction()
defaultFunction()
因为一个 js 文件中只能有一个 export default,所以 import default 的时候不需要加大括号
CommonJS
关键字:require()、module.exports
// 导出
// commonJS-module.js
const myVar = '导出变量';
function myFunction() {
console.log('导出函数');
}
module.exports = { myVar, myFunction };
// 导入
// commonJS-test.js
// 导入单个或多个变量或函数
const { myVar, myFunction } = require('./commonJS-module.js');
console.log(myVar);
myFunction();
注意:
CommonJS 模块只能在 Node.js 环境中执行,不能直接允许在浏览器环境。虽然我们在开发的时候会使用部分 CommonJS 的相关代码,但是在构建和打包过程中会利用Webpack、Vite 或 Rollup 等工具将 CommonJS 模块转换为浏览器兼容的 ES6 模块。
加载方式
编译时加载和运行时加载
编译时加载
编译时加载是指在代码编译(或转译)阶段就确定并加载所需的资源或模块。在这个过程中,编译器或构建工具会分析代码中的依赖关系,并将这些依赖在编译时就整合到最终的输出文件中。
-
优点:
-
可以在编译阶段就进行依赖管理和优化,例如通过代码拆分、树摇(tree shaking)等技术来减少最终打包文件的大小。
-
编译时加载通常意味着代码在运行时能够更快地获取到所需的资源,因为所有依赖资源都已经在编译阶段准备好了。
-
-
缺点:
-
需要一个构建步骤来预处理代码
-
如果依赖关系在编译后发生变化(例如,动态引入的模块),则可能需要重新编译整个项目。
-
运行时加载
运行时加载是指在代码执行过程中,根据实际需要动态地加载所需的资源或模块。这通常通过某种形式的动态加载机制(如 require 函数、import() 函数等)来实现。
-
优点:
-
可以根据需要动态地加载和卸载资源,从而节省内存和带宽。
-
可以在不重新编译整个项目的情况下更新或替换部分代码。
-
-
缺点:
- 如果加载的资源过多或过大,可能会影响应用的启动时间和性能。
在实际开发中,编译时加载和运行时加载通常会结合使用。例如,在构建大型应用时,可能会使用编译时加载来预加载大部分核心资源,而使用运行时加载来动态加载一些非核心或按需加载的资源。
ES6
编译时加载
-
ES6 模块是在编译时加载的。这意味着,在代码执行之前,模块的依赖关系就已经被确定,并且只加载需要的模块部分。
-
由于模块是在编译时加载的,因此可以进行静态优化,提高代码的执行效率。
JavaScript 是一种解释型、直译式脚本语言,浏览器会直接解释源代码并运行,因此 JavaScript 不需要编译,那为什么说 ES6 模块是在编译时加载的呢?
当 JavaScript 代码包含 ES6 模块时,通常需要使用构建工具(如 Webpack、Rollup 等)或现代 JavaScript 引擎(如 V8 引擎在 Chrome 浏览器中的实现)的模块加载器来处理这些模块。这些工具或引擎在加载和执行模块之前,会首先解析 import 和 export 语句,确定需要加载哪些模块,并生成一个模块加载计划。这个计划包括了模块的加载顺序和依赖关系,确保了模块在执行之前已经被正确加载和解析。
这个过程之所以被称为“编译时加载”,是因为它发生在代码执行之前的阶段,并且涉及到模块依赖关系的静态分析和解析。
什么是静态优化?
静态优化发生在程序编译时,因此可以对整个程序进行全局分析,识别潜在的性能问题。如模块合并、树摇、常量折叠、代码拆分等。
异步加载(在模块解析阶段)
- 虽然 ES6 模块的
import语句在语法上是同步的(即在编写代码时看起来像是同步加载),但实际上在模块解析阶段,ES6 模块系统具有异步加载的能力。它有一个独立的模块依赖解析阶段,可以在代码执行前完成模块的加载和解析。
静态接口
-
ES6 模块不是对象,而是通过
export命令显式指定输出的代码。这些输出在编译时就已经确定,形成一个静态接口。 -
在导入模块时,只加载指定的方法或属性,其他部分不会被加载。
// es6-module.js
function A() {
//...
}
function B() {
//...
}
function C() {
//...
}
export function D() {
//...
}
export default {
A, B, C
}
// test.js
import test from ./es6-module.js
test.A()
假设开启了树摇,B C D 在编译时会被移除吗?
答案:B 和 C 被包含在默认导出的对象中,虽然并未被直接使用,但是由于它们被包含在一个被使用的对象中,树摇机制不会单独移除它们,因为默认导出的对象是一个整体。D 会被移除。
动态只读引用
-
ES6 模块总是以引用的形式传递导出的数据,无论是基本类型还是复杂类型。这意味着无论何时何地修改了某个已导出的值,所有使用该值的地方都将看到最新的状态变化。
-
不过需要注意的是,ES6 模块提供的引用是只读的,不允许直接修改导入的内容
// es6-module.js
export let count = 0;
// test.js
import { count } from "./es6-module";
let num = count++; // 只读报错:Assignment to constant variable.
console.log("num:", num);
console.log("count:", count);
正确写法:
// es6-module.js
export let count = 0;
export function add() {
count++
}
// test1.js
import { count, add } from "./es6-module";
console.log("count1:", count); // 0
add()
console.log("count2:", count); // 1
假设还有个 test2.js 文件
// test2.js
import { count, add } from "./es6-module";
console.log("count3:", count); // 1
对于 ES6 模块而言,它保持了对导出内容的真实引用,这使得 ES6 模块能够在多个地方共享同一个实例的同时保证一致性。因此可以认为 ES6 模块是引用传递。
CommonJS
运行时加载
-
CommonJS 模块是在运行时加载的。这意味着,当模块被
require函数引入时,模块的代码才会被执行,并且模块对象是在代码执行后才生成的。 -
由于模块是在运行时加载的,因此无法在编译时进行静态优化。
同步加载
- CommonJS 模块使用
require函数进行同步加载。这意味着,在引入模块时,程序会等待模块加载完成后再继续执行。
模块对象
-
CommonJS 模块实际上是一个对象,模块的属性和方法都是这个对象的成员。
-
当模块被加载时,它的所有方法和属性都会被加载到内存中,形成一个完整的对象。
缓存机制
- CommonJS 模块在第一次加载后会被缓存起来,后续再引入相同的模块时,会直接返回缓存中的模块对象,而不会重新执行模块代码。
// commonJS-module.js
console.log('commonJS-module.js 加载...')
let count = 0;
function add() {
count++
}
function getCount() {
return count
}
/*
创建一个 module.exports 对象,
该对象包含 count(初始化为0),add 函数和 getCount 函数
*/
module.exports = { count, add, getCount };
// test.js
// 获取 module.exports 对象的一个引用,并赋值给 test 变量
const test = require('./commonJS-module');
console.log("count1", test.getCount()); // 0
console.log("count2", test.count); // 0
test.add();
console.log("count3", test.getCount()); // 1
/*
尽管 count 在模块内部被修改了,
但直接通过 test.count 访问的值仍然是 0,
因为 test.count 的值在初始化时就已经确定,
并不会因为 add 函数的调用而更新。
*/
console.log("count4", test.count); // 0
/*
由于模块已经被缓存,Node.js 不会重新执行 commonJS-module.js。
相反,它返回同一个 module.exports 对象的另一个引用。
因此,test 和 test2 引用的是同一个对象。
*/
const test2 = require('./commonJS-module');
console.log("count5", test.count); // 0
console.log("count6", test.getCount()); // 1
/*
直接修改 test2.count 的值,
这实际上是修改了 module.exports.coun t属性的值
(但请注意,这不会影响 getCount 函数内部使用的 count 变量)
*/
test2.count++
// test2.count 现在是 1,因为我们刚刚增加了它
console.log("count7", test2.count); // 1
/*
尽管 module.exports.count(即 test2.count)被修改为 1,
但 getCount 函数仍然返回模块内部维护的 count 值(1),
这个值是通过 add 函数增加的,
而不是通过直接修改 module.exports.count。
*/
console.log("count8", test2.getCount()); // 1
可以看到:"commonJS-module.js 加载..." 只被打印了一次,count5 值是 1。
这是因为 CommonJS 模块在第一次加载后会被缓存,当 require('./commonJS-module.js') 第二次被调用时,Node.js 不会重新加载和执行 commonJS-module.js 文件,而是直接返回缓存中的模块对象。
对象引用
ES6
上面已经说了,ES6 模块是引用传递,即
-
无论何时何地修改了某个已导出的值,所有使用该值的地方都将看到最新的状态变化。(导出的值只读的,其实无法修改)
-
能够在多个地方共享同一个实例的同时保证一致性
CommonJS
-
对于基本数据类型,是值传递
-
对于引用数据类型,传递的是指向该对象内存地址的引用。即
-
如果你在一个模块中导出了一个对象,并且另一个模块引入了这个对象,那么两个模块实际上是共享同一个对象实例的。
-
这意味着如果一个模块修改了这个对象的内容,所有持有该对象引用的地方都会看到这些变化。
-
这里更像是引用传递,但严格意义上讲,CommonJS 仍然实现了值传递,只是传递的是对象的引用而不是对象本身
-
-
特殊情况:module.exports 被重新赋值
-
无论是被重新赋值为基本数据类型还是引用数据类型,任何之前已经通过
require()获取到该模块的代码将不会受到新赋值的影响,因为它们持有的是对旧对象的引用。 -
只有新的
require()调用才会得到更新后的module.exports
-
// 导出基本数据类型
module.exports = 1;
// 导出引用数据类型
module.exports = { message: 'Hello' };
// 重新赋值
module.exports = { message: 'Hello' };
setTimeout(() => {
module.exports = { message: 'Hi' }
}, 1000);
循环依赖处理
ES6
编译时会进行循环依赖处理,即将模块中的循环依赖转换成静态的拓扑结构。这使得 ES6 模块能够更好地处理循环依赖的情况。
CommonJS
无法很好地处理循环依赖。在循环依赖的情况下,CommonJS 模块只输出已经执行的部分,还未执行的部分不会输出。这可能会导致一些不可预见的问题。