require()、import、import()加载模块详解(二)

接上篇 require()、import、import()加载模块详解(一)

ES6 Module 的 import

通过 import 静态地导入另一个通过 export 导出的模块。

区分于 CJS 运行时才和导入模块建立关系,ESM 在转化成中间代码时(编译阶段) import 语句就和模块建立了静态引用关系,在运行时导入和导出是不可更改的。这就意味着我们只能在顶层进行导入和导出 (比如绝不能嵌套在条件语句中),同时 import 和 export 语句不能有「具有逻辑或含有变量」的动态部分,即不能依赖于运行时计算的任何内容 (如import foo from './module' + 变量;),不然编译时就会报错。而 require 可以在运行时通过 if 判断决定导入哪个模块。

在编译期,import 语句会被内部移动至当前作用域最开头 (类似 var 和 function 的变量提升),先于其他代码执行。JS 解析器编译到 import 语句时,会生成一个接口标识符或默认导出接口对应的引用。如 import { a } from './module-a',a 指向的是export const a = xxx 接口中的 a;而 import defaultB from './module-b',defaultB 指向的是 export default b 中的 b (默认接口导入时的名称可以自定义)。到了运行期,也不会去执行完整模块,只有在调用 a / defaultB 的时候才会加载模块中相应的接口取值。

换句话说,ESM模块规则有点像Unix系统的“符号连接”,原始值变了,import 输入的值也会跟着变。导入的变量绑定其所在的模块,不会缓存值。不同脚本加载同一个模块得到的是同一个实例。因此ESM设定了不能修改导入值的只读规则。

CJS 导入的是导出值的浅拷贝副本,而ESM导入是导出值的实时只读引用。

静态型的 import 是初始化加载依赖项的最优选择, 静态模块结构 更容易从代码静态分析工具和 tree shaking 中受益。而且自动支持模块间的循环依赖。
在用 webpack、Rollup 这样的模块打包器时,证明ESM模块可以更高效地组合:

  • 加载所有模块时,import 查找变量是静态检索,比 require() 的动态检索快很多。
  • 压缩绑定的文件比压缩单独的文件效率更高。
  • 在绑定过程中,通过删除未使用的出口代码,从而节省大量空间。

在浏览器中,import 语句只能在 <script type="module"></script> 标签中使用 (<script type="module"> 拥有自己的局部作用域)。或者写在.mjs扩展名的文件里。

语法:

ESM模块有两种导出方式:命名导出(每个模块可以几个)和默认导出(每个模块一个)。可以同时使用两者,但通常最好将它们分开。

命名导出:export
// 1. 关键字标记声明
// 导出单个声明常量/变量
export const name1 = … // 用 let, var 定义变量也可,不过通常还是常量
export let name2 = …
// 导出声明函数
export function functionName() {...}
// 导出声明类
export class className {...}

// 2. 用对象列出要导出的所有内容
// name1,name2... 是事先定义好的标识符。如果在一个模块要导出多个值,同时数量不算多时推荐这样做,代码结构会比较清晰
const name1 = …
const name2 = …
export { name1, name2, …, nameN }
// 重命名导出
export { variable1 as name1, variable2 as name2, …, nameN }
  • name1… nameN、functionName、className —— 要导出的“标识符”。在其他脚本 import 时需要用这些“标识符”进行针对性的导入

直接在 export 关键字后面声明的语句叫 内联导出

export const name1 = 11
export function foo() {}
// 等效于
const name1 = 11
function foo() {}
export { name1, foo };

同时不能直接 export 一个对象,如export { name1: 1, name2: 2 }export { ... }只允许放用,分隔的标识符。因为不能通过对象强制执行静态关联,从而失去所有静态模块结构相关的优势。

默认导出:export default

实质上是个语法糖。export default 命令就是将输出内容赋值给名为 default 的 变量,导出内容可以是任意表达式 (函数或Class也在内),在导入时可以随意为这个 default 更名。因为已经声明变量 default 了,后面就不能跟变量声明语句了,这一点要和 export 区分开。

expression(表达式) 属于 satement(语句),但 expression 是可以通过 evaluation 产生结果的。也就是说这个结果不是马上产生,而是需要时才会被evaluated。
简单判断:可以被当作参数传递的就是expression,一般是放在小括号里的(expression),而 statement 一般是放在大括号里的{ statement }。expression 被放到函数体内就变成了 satement。

// 导出
// a.js 
export default «expression»;
// 等效于
const a = «expression»;
export { a as default };

// 导入时:
import b from './a.js'
// 等效于
import { default as b } from './a';

默认导出的本意是让 import 时不受限于接口名称任意命名模块,通常用于整个模块的导出,如 React 组件。Vue组件则是把组件的数据和逻辑以一个对象的形式导出。默认导出简单类型的常量意义不大,几乎不用。命名导出和默认导出混用也存在,比如一个库是单个函数,但通过该函数的属性提供了其他服务:import _, { each } from 'underscore';
为了快速区分不同模块,以及导入时命名的统一,默认导出类和函数的时候还是建议命名 (尽管可以匿名)。
同时一个js只能有一个 export default,多个并存只有最后一个生效。以下为演示故没有将多个注释掉。

个人推荐的方式有以下几种:

// 导出函数
export default function fun() {} 
// 如果是箭头函数,我写 React 组件都这样用
const funArrow = () => {}
export default funArrow

// 导出类
export default class Dog {}

// 导出对象
const foo = 'foo1'
const bar = 'bar2'
export default { foo, bar } // 实际导出的是 { foo: foo, bar: bar }
// 这里的 foo 和 bar 不是 标识符,只是键值对同名的简写,有本质区别, 注意区分

// 也可以直接将值写在对象里,Vue组件的做法
export default {
  name: 'foo',
  data: {...}
}
导入 import 类型:

默认导入:对应默认导出,导入名可以自定义

import customName from 'src/my_lib';

// src/my_lib.js
export default anyThing // 任意类型,函数、类、对象 及表达式

命名空间导入通过 * 导入完整的模块,把模块中的全部属性和方法放到一个对象中 (每个命名导出为一个属性) 进行导入。

import * as my_lib from 'src/my_lib';
console.log(my_lib) // { a, fun }
console.log(my_lib.a) // 'aaa'
my.lib.fun()

// src/my_lib.js
export const a = 'aaa'
export function fun() { ... }

命名导入,可以通过 as 重命名导出标识符:

import { name1, name2 as fun } from 'src/my_lib';
console.log(name1)
fun()

// src/my_lib.js
export const name1 = 'aaa'
export function name2() { ... }

空导入:仅加载模块,不导入任何内容。程序中的第一个此类导入将执行模块的主体。

import 'src/my_lib';

组合导入:导入顺序是固定的,默认导出必须始终在第一个。

// 将默认导入与名称空间导入相结合:
import theDefault, * as my_lib from 'src/my_lib';
// 将默认导入与命名导入结合
import theDefault, { name1, name2 } from 'src/my_lib';
  • as —— 重命名导出“标识符”。比如需要同时导入两个同名的 export 接口,用 as 重命名其中一个就可以解决冲突
  • from 后面的字符串是要导入的模块。通常是包含目标模块的.js文件的相对或绝对路径。
每次 import 都是到导出数据的实时连接。
//------ lib.js ------
export let counter = 3;
export function incCounter() {
    counter++;
}
//------ main1.js ------
import { counter, incCounter } from './lib';

// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4

// The imported value can’t be changed
counter++; // TypeError

如果通过*导入模块对象,会得到相同的结果:

//------ main2.js ------
import * as lib from './lib';

// The imported value `counter` is live
console.log(lib.counter); // 3
lib.incCounter();
console.log(lib.counter); // 4

// The imported value can’t be changed
lib.counter++; // TypeError

请注意,虽然不允许直接更改导入的值 (即重新赋值),但是可以修改它们引用的对象。例如:

//------ lib.js ------
export let obj = {};

//------ main.js ------
import { obj } from './lib';

obj.prop = 123; // OK
obj = {}; // TypeError

ES6 Module 的 import()

静态 import 命令会被JS引擎静态分析,先于其他代码执行,做不到运行时加载。而且 import 和 export 语句都必须始终位于模块的顶层,无法按需执行。为了实现类似于require的动态加载,从而提高首屏加载速度,就出现了一个import()函数方法。import()括号内接收的参数和import语句from后面的一致。
按照一定的条件或者按需加载模块的时候,动态import() 是非常有用的。

import()函数是动态按需加载,它返回一个 Promise 对象。import()是运行时执行,什么时候运行到这一句,才会加载指定的模块。因此通过 if 判断可以实现按条件import()模块 。除了模块,还可以用来加载非模块的脚本。

import()与所加载的模块没有静态连接关系,这点也与 import 语句不同 (import语句会建立静态引用)。import()类似于 Node 的require(),但区别是import()为异步加载,而require()是同步加载。

当出现以下的情况,一般就可以用动态import()代替静态 import 了:

  • 静态导入的模块很明显地降低了代码的加载速度/占用了大量系统内存并且被使用的可能性很低,或者并不需要马上使用它。
  • 被导入的模块在加载时还不存在,需要异步获取
  • 导入模块的标识符需要动态构建。(静态导入只能使用静态标识符)
  • 被导入的模块有副作用(这个副作用,可以理解为模块中会直接运行的代码),这些副作用只有在触发了某些条件才被需要时。

另外请只在必要情况下采用动态导入。静态框架能更好地初始化依赖,而且更有利于静态分析工具和tree shaking发挥作用。

import('./modules/my-module.js')
  .then(module => {
    // Do something with the module.
  });

因为是一个 promise,import() 也支持 await 关键字。

let module = await import('./modules/my-module.js');
获取模块接口

import()加载模块成功以后,这个模块会作为一个对象,当作then方法的参数。因此,可以使用对象解构赋值的语法,获取输出的命名接口

import('./modules/my-module.js')
.then(({export1, export2}) => {
  // ...
});

如上,export1export2都是my-module.jsexport导出的输出具名接口,可以直接解构获得。

如果要获取 default 默认导出,需要用default属性获取:

import('./modules/my-module.js')
.then(module => {
  console.log(module.default)
});
// 或者这样
import('./modules/my-module.js')
.then(({default: theDefault}) => {
  console.log(theDefault);
});

总结

CJS 的 require() 和 exports
  • require() 为同步导入。
  • 动态结构:导入和导出的对象可以在运行时通过变量动态生成,也可以把 require()/exports 放在 if 语句之类的代码块内实现按需加载/导出。
  • 代码执行到 require() 会先把()内的模块代码执行一遍,返回值是模块导出对象的浅拷贝副本。
  • require()进来的属性副本,可以修改和删除,简单类型不会影响被导入模块,引用类型会改变导入模块数据。但 require() 的目的主要是导入一些供使用的函数或常量,这样显然是不合理的,因此尽量不要试图修改模块源数据,并在导入时表明引入的是常量,如:const path = require('path')
  • 需要用 exports.属性 导出并仔细地规划, 才能使模块循环依赖正常工作
ESM 的 import 和 export
  • import 语句为同步导入。
  • 静态模块结构(可以利用于消除无效代码,优化,静态检查等):导入和导出的关联关系在运行时不可更改。
  • 在代码编译阶段(而非执行阶段) import 语句就和模块建立了只读静态引用关系,且代码运行到import不会执行模块的内容,而是当导出值被调用时才会真正执行对应模块。
  • 不能修改 import 进来的对象,因为import/export输出的模块是动态绑定的常量,是只读的。但修改对象引用地址的属性还是可以的。如无特殊需要请不要这么做。
  • import/export 不能嵌套在任何块级作用域或函数作用域内,必须写在模块顶层(因为 import 会先于其他任何代码执行)
  • import/export 语句不能有动态计算部分
  • 不能直接在浏览器执行,需要写在<script type="module"></script>
  • 自动支持模块之间的循环依赖关系

尽管ESM模块规范大有优势,但鉴于很多库还在广泛使用CJS,我们仍需要理解requiremodule.exports/exports。自己在日常开发中使用importexport default/export即可,webpack 会帮你做兼容处理 (可以看到webpack自身是遵循CJS的,因此会在打包过程中先把esm转成cjs) 。期待全面支持ESM的一天~

参考:es6 modules

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容