掌握 JS 模块化开发:细说 ES Module 模块化规范

2,031 阅读20分钟

什么是 ES 模块(ES Module)

ES 模块是在语言标准层面上实现的模块化方案。

个人认为,ES 模块的出现意味着 JavaScript 模块化方案的成熟,服务器和浏览器的模块化方案因此得到统一。

ES 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。而 CommonJS 模块只能在运行时确定这些东西。 ES6 的 Tree Shaking (摇树优化)就是借助 ES6 的模块化能力,通过分析模块之间的依赖关系,来判断哪些代码没有被引用,进而删除对应代码。

ES 模块在编译时就完成模块加载,CommonJS 模块在运行时才加载,因此,ES 模块的加载效率要比 CommonJS 模块的高。

模块定义

跟 CommonJS 模块化规范一样,一个模块就是一个独立的文件。该文件内部所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用 export 关键字导出该变量。

有关 CommonJS 模块化规范的更多细节,可查看笔者的另一篇文章:认识 CommonJS 模块化规范

在浏览器中,需要把 type="module" 放到 <script> 标签中,来声明这个脚本是一个模块:

<script type="module" src="main.js"></script>

<script> 标签加上 type="module" 后,该脚本会变成异步加载的,不会阻塞浏览器的渲染,然后等到整个页面渲染完,再执行模块脚本,等同于打开了 <script> 标签的defer属性。

<script type="module" src="main.js"></script>
<!-- 等同于 -->
<script type="module" src="main.js" defer></script>

如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行

<script> 标签的 async 属性也可以打开,这时只要加载完成,浏览器渲染引擎就会中断渲染,立即执行该模块脚本。执行完成后,再恢复渲染。

<script type="module" src="main.js" async></script>

async 属性打开后,<script type="module"> (模块脚本)就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块,即哪个脚本先加载完成,就会先执行哪个脚本

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

<script type="module">
  import main from "./main.js";

  // other code
</script>

下图展示了浏览器对普通 <script> 标签、打开了 deferasync 属性的 <script> 标签、带有 type="module"<script> 标签、打开了 async 属性的 type="module"<script> 标签的加载方式的区别:

41.png

👆 此图摘自 JavaScript modules

  • 对于普通的 <script> 标签,浏览器会同步加载该脚本,同步加载会阻塞浏览器的渲染,该脚本加载完成后,会立即执行该脚本,等待该脚本执行完后,浏览器才会继续向下渲染。

  • 对于打开了 defer 属性的 <script> 标签,浏览器会异步加载该脚本,异步加载不会阻塞浏览器的渲染,该脚本加载完成后,不会立即执行,而是等到整个页面渲染完成,才会执行。

  • 对于打开了 async 属性的 <script> 标签,浏览器会异步加载该脚本,异步加载不会阻塞浏览器的渲染,与 defer 属性不同的是,该脚本在加载完成后会立即执行,执行该脚本的过程中会阻塞浏览器的渲染。另外,如果有多个 defer 脚本,会按照他们在页面出现的顺序执行脚本,而多个 async 脚本是不能保证执行顺序的,而是哪个模块先加载完成就先执行哪个模块。

  • 对于带有 type="module"<script> 标签,浏览器也是会异步加载的,异步加载不会阻塞浏览器的渲染,即等到整个页面渲染完,再执行模块脚本,等同于打开了 <script> 标签的 defer 属性。

  • 带有 type="module"<script> 标签也是能打开 async 属性的,这时脚本也是异步加载的,不会阻塞浏览器的渲染,但是模块的执行时机相对于 defer 提前了,会在脚本加载完成后立即执行,同样,在执行模块脚本的时候会阻塞浏览器的渲染,而且使用了 async 属性的模块也不能保证按照在页面出现的顺序执行,具体看网络状况,哪个模块先加载完成,就先执行哪个模块。

还有只能在模块内部使用 importexport 语句,不是普通脚本文件。如下面例子:

定义 add 模块,导出 add 函数,add 函数仅用于实现两数相加

// add.js

export function add(a, b) {
  return a + b;
}

在 main 模块中使用 import 引入 add 函数

// main.js

import { add } from "./add.js";
console.log("add  ", add(1, 2));

在 index.html 页面中加载 main 模块,由于 <script> 标签没有指定 type="module" ,会报错

<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>模块</title>
    <script src="main.js"></script>
  </head>
  <body></body>
</html>

具体报错为

40.png

对于 node.js 环境也是一样的,不能在非 ES 模块的环境下使用 importexport 语句。如下面例子:

// add.js

export function add(a, b) {
  return a + b;
}

在 main 模块中引入 add 函数,并执行 add 函数

// main.js

import { add } from "./add.js";

console.log("add  ", add(1, 2));

在终端运行 main 模块,发现报错了

42.png

具体原因是因为,对于 .js 文件,node.js 默认会当作 CommonJS 模块,在 CommonJS 模块中使用 ES 模块的语法,当然要报错啦。

具体的解决方案有两种,

  • 在项目根目录下创建 package.json 文件,并指定 "type": "module"

43.png

  • 使用 .mjs 拓展名的文件

44.png

对于这两种情况,node.js 都会当作 ES 模块来处理。

需要注意的是,在浏览器中使用 ES 模块不建议使用 .mjs 文件后缀,因为部分文件服务器不能保证正确地识别 .mjs 文件的 MIME typetext/javascript,从而导致浏览器拒绝执行相应的 JavaScript 代码。

模块导出

在 ES 模块中,使用 export 命令导出模块的对外接口:

export var name = "Tom";
export var age = "18";

还可以在 export 命令后使用大括号一次性导出多个变量。

var name = "Tom";
var age = "18";

export { name, age };

使用 export 导出的是值的引用,外部通过该引用取得模块内部实时的值。这一点与 CommonJS 规范完全不同。CommonJS 模块导出的是值的拷贝(浅拷贝),无法得到模块内部实时的值。

通常情况下,export 导出的变量就是本来的名字,但是可以使用 as 关键字重命名。

var name = "Tom";
var age = "18";

export { name as myName, name as otherName, age as myAge };

同一个接口重命名后,可以用不同的名字导出多次。

同时,export 语句不能放到块级作用域或条件语句中。

❌ 错误写法一:

function getName() {
  export var name = "Tom";
}

❌ 错误写法二:

if (condition) {
  export var name = "Tom";
}

还可以使用 export default 命令做模块的默认导出。

export default function () {
  console.log("foo");
}

export default 命令后面也可以接大括号一次性默认导出多个接口。

var name = "Tom";
var age = "18";

export default {
  name,
  age,
};

注意,默认导出不能使用 as 关键字重命名接口,否则会报错:

// m1.js

var name = 'Tom'
var age = '18'

export default {
  name as myName,
  age
}
// m2.js

import my from "./m1.js";

console.log("my ", my);

运行上面代码,会报语法错误:

45.png

一个模块只能使用一次默认导出,本质上,export default 就是输出一个叫 default 是变量或方法,然后系统允许你为他取任意名字,所以,下面的写法也是有效的。

function add(a, b) {
  return 3 + 3;
}
export { add as default };

// 等同于
// export default add;

同样地,因为 export default 命令的本质是将后面的值,赋给 default 变量,所以可以直接将一个值写在 export default 之后。

// 正确
export default 3;

// 报错
export 3;

上面代码中,后一句报错是因为没有指定对外的接口,而前一句指定对外接口为 default

模块导入

在 ES 模块化方案中,使用 import 命令导入模块。

// add.js

export function add(a, b) {
  return a + b;
}

在 main 模块中,通过 import 导入 add 函数

// main.js

import { add } from "./add.js";

console.log("add  ", add(1, 2));

import 命令可以使用 as 关键字为导入的变量重命名

// main.js

import { add as newAdd } from "./add.js";

console.log("newAdd  ", newAdd(1, 2));

import 命令导入的变量都是只读的,类似于 const 声明的变量:

// book.js

export var name = "《三体》";

在 main 模块中导入 name 变量并重新赋值

// main.js

import { name } from "./book.js";

name = "《明朝那些事》";

在浏览器中运行,发现报错了:

46.png

如果导入的是对象,改写该对象的属性是可以的,因为改写对象的属性对浏览器来说并未改变对象的值。对象是引用类型,对浏览器来说,改变了该对象原有的引用地址才是真正的改变了该对象的值。

// book.js

export var book = {
  name: "《三体》",
  author: "刘慈欣",
};
// main.js

import { book } from "./book.js";

// 合法操作
book.name = "《明朝那些事》";
book.author = "当年明月";

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

add(3, 3);

import { add } from "./add.js";

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

由于 import 是静态执行,所以不能使用表达式、变量和条件语句,这些只有在运行时才能得到结果的语法结构,这一点 export 命令也是一样的。因为在运行时才能得到结果的语法结构中没法做静态优化,违背了 ES 模块的设计思想。

// 报错
import { 'a' + 'dd' } from "./add.js";

// 报错
let module = "./add.js";
import { add } from module

// 报错
if (type === 1) {
  import { add } from './add.js';
} else {
  import { minus } from './minus.js';
}

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

import 语句会执行所加载的模块,就像浏览器的 <script> 标签一样,浏览器会自动执行 <script> 标签加载的脚本文件。

import "lodash";

上面代码仅仅执行 lodash 模块。没有导入任何值。

import 语句是单例模式的,多次重复同一个模块,该模块代码只会执行一次。

import "lodash";
import "lodash";

上面代码加载了两次 lodash ,但 lodash 模块的代码只会执行一次。

// math.js

function add(a, b) {
  return a + b;
}

function minus(a, b) {
  return a - b;
}

export { add, minus };
import { add } from "./math.js";
import { minus } from "./math.js";

// 等同于
import { add, minus } from "./math.js";

还可以使用星号(*)指定一个对象来整体导入一个模块。该模块所有导出值都加载在这个对象上面。

import * as m from "./math.js";

console.log("加法:" + m.add(3, 3));
console.log("减法:" + m.minus(5, 3));

上面代码将 math 模块中所有导出值都加载在 m 对象上面了。

注意,模块整体加载所在的那个对象(上例是 m),应该是可以静态分析的,所以不允许运行时改变。这种不允许被运行时修改的特性有助于增强模块代码的安全性(可以防止他人在运行时注入恶意代码),也有助于查错,下面的写法是不允许的:

import * as m from "./math.js";

m.title = "四则运算";

m.multi = function (a, b) {
  return a * b;
};

在浏览器中运行上面的代码发现报错了:

47.png

模块导出与导入的复合写法

如果在一个模块之中,先导入后导出同一个模块,import 语句与 export 语句可以写到一起。

export { add, minus } from "./math.js";

// 可以简单理解为
import { add, minus } from "./math.js";
export { add, minus };

上面代码中,exportimport 语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,addminus 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 add minus

在转发的同时对导出的接口重命名也可以

// 接口重命名
export { add as myAdd } from "./math.js";

当然也可以使用星号(*),一次性转发整个模块

// 整体转发
export * from "./math.js";

转发默认接口也可以,不过前提是该模块本身要有默认导出,否则转发默认接口会报错。

export { default } from "someModule";

由于 math 模块本身没有默认导出,所以在 calc 模块中默认转发 math 模块的默认接口报错了:

// math.js

function add(a, b) {
  return a + b;
}

function minus(a, b) {
  return a - b;
}

export { add, minus };
// calc.js

export { default } from "./math.js";
// main.js

import c from "./calc.js";
console.log("c  ", c);

48.png

具名接口也可以重命名为默认接口:

export { add as default } from "./math.js";

在 ES2020 之前,模块的整体导入没有对应的复合写法

import * as m from "./math.js";

ES2020 补上了这个写法

export * as m from "./math.js";

// 等同于
import * as m from "./math.js";
export { m };

模块的整体导入的转发与模块的整体转发的区别是,模块整体导入的转发会将模块导出的接口都挂载在一个对象上,然后导出该对象。而模块的整体转发是原封不动的转发。

// 模块整体转发
export * from "./math.js";

// 等同于
export { add, minus } from "./math.js";

模块动态导入(import()

静态 import 命令(即前文所说的模块导入方式)会被 JavaScript 引擎静态分析,先于模块内的其他语句执行,因此无法做到运行时加载。

const type = "add";

if (type === "add") {
  import { add } from "./math.js";
} else {
  import { minus } from "./math.js";
}

49.png

JavaScript 引擎处理 import 语句是在编译时,这时不会去分析或执行 if 语句或其他表达式,所以 import 语句放在 if 或其他代码块中毫无意义,因此会报语法错误。也就是说 importexport 命令只能在模块的顶层,不能在代码块之中(比如,在 if 代码块之中,或在函数之中)。

这样的设计,有利于编译器提高效率,但也导致无法在运行时加载模块,在语法层面上,模块的按需加载也就无法实现,然后在 ES2020 中引入了 import() 方法支持模块的动态导入,就是支持了在运行时加载模块,我们可以借助动态 import() 的语法在 ES 模块中引入 CommonJS 模块的代码。

动态 import() 比起静态 import 具有一定的性能优势,因为动态 import() 可以实现按需加载、代码分割,避免加载页面的时候一次性加载大量 js 文件,造成加载白屏时间过长的情况。

Vue 中使用 import() 实现按需加载(懒加载)和代码分割

import() 可用于实现异步组件。Webpack/Vite 遇到动态导入的组件,会将其打包成单独的文件( 在 webpack 中叫 chunk) ,从而实现了代码分割,避免单个 JS 文件的体积过大。再结合 v-if 语句,就可实现组件的按需加载,减少页面首次加载的时间。

下面是用 vue-cli(版本为:5.0.8) 创建的一个 Vue2 的演示例子

ImportDemo 组件的逻辑很简单,就是展示文本:演示123

<!-- components/ImportDemo.vue -->
<template>
  <div>演示123</div>
</template>

<script>
  export default {
    name: "ImportDemo",
  };
</script>

<style scoped></style>

在 App 组件中使用 import() 动态导入 ImportDemo 组件,并且使用 v-if 语句控制 ImportDemo 显示隐藏

<!-- App.vue -->
<template>
  <div id="app">
    <button @click="handleClick">按钮</button>
    <ImportDemo v-if="show" />
  </div>
</template>

<script>
  export default {
    name: "App",
    components: {
      ImportDemo: () => import("./components/ImportDemo.vue"),
    },
    data() {
      return {
        show: false,
      };
    },
    methods: {
      handleClick() {
        this.show = !this.show;
      },
    },
  };
</script>

<style></style>

然后在终端中运行 npm run serve 命令,发现页面在首次加载时,只加载了两个 js 文件:

50.png

点击页面上的按钮后,ImportDemo 组件会在页面上显示出来,浏览器 network 面板上会多一个 js 文件,该 js 文件就是 ImportDemo 组件,Webpack 已经将其打包成了单独的 chunk 。

51.png

还可以使用 Webpack 的魔法注释为动态导入的组件自定义其打包后输出的文件名。即使是不同的组件,使用魔法注释定义的文件名如果相同,则这两个组件会打包到一起,可以利用魔法注释灵活的控制动态导入的组件输出的文件。

另外定义一个 ImportDemo1 的组件:

<!-- components/ImportDemo1.vue -->
<template>
  <div>演示666</div>
</template>

<script>
  export default {
    name: "ImportDemo1",
  };
</script>

<style scoped></style>

在 App 组件中动态导入 ImportDemo 和 ImportDemo1 组件,并用魔法注释定义他们打包后的文件为 importTest 。由于两个组件用魔法注释定义的名字相同,所以他们会输出为同一个文件。

<!-- App.vue -->
<template>
  <div id="app">
    <button @click="handleClick">按钮</button>
    <ImportDemo v-if="show" />
    <ImportDemo1 v-if="show" />
  </div>
</template>

<script>
  export default {
    name: "App",
    components: {
      ImportDemo: () =>
        import(
          /* webpackChunkName: 'importTest' */ "./components/ImportDemo.vue"
        ),
      ImportDemo1: () =>
        import(
          /* webpackChunkName: 'importTest' */ "./components/ImportDemo1.vue"
        ),
    },
    data() {
      return {
        show: false,
      };
    },
    methods: {
      handleClick() {
        this.show = !this.show;
      },
    },
  };
</script>

<style></style>

在终端运行 npm run build 命令,可看到打包后的结果为:

52.png

ImportDemo 组件和 ImportDemo1 组件都打包为了 importTest 文件。

上面演示的是 Webpack + Vue2 的例子,其实 Vite + Vue3 中也一样,只是在 Vue3 中不能像 Vue2 一样直接在 components 选项中使用动态导入组件:

<script>
  export default {
    name: "App",
    components: {
      // Vue3 不支持这种方式
      ImportDemo: () => import("./components/ImportDemo.vue"),
    },
  };
</script>

Vue3 需要使用 defineAsyncComponent 显示声明为异步组件才能使用动态导入的语法:

<script setup>
  const ImportDemo = defineAsyncComponent(() =>
    import("./components/ImportDemo.vue")
  );
</script>

下面看看具体的例子,这个例子由 Vite 5.2.11 创建

定义 ImportDemo 组件,逻辑很简单就是展示一段文本

<!-- components/ImportDemo.vue -->
<script setup></script>

<template>
  <div>演示123</div>
</template>

<style scoped></style>

在 App 组件中使用动态导入语法(import()),定义异步组件,结合 v-if 语句实现代码分割和按需加载。

<!-- App.vue -->
<script setup>
  import { defineAsyncComponent, ref } from "vue";

  const ImportDemo = defineAsyncComponent(() =>
    import("./components/ImportDemo.vue")
  );
  const show = ref(false);
  const handleClick = () => {
    show.value = !show.value;
  };
</script>

<template>
  <div>
    <button @click="handleClick">按钮</button>
    <ImportDemo v-if="show" />
  </div>
</template>

<style scoped></style>

在终端运行 npm run dev 命令,发现页面在首次加载时,加载了 6 个 js 文件

53.png

同样,点击页面上的按钮后,ImportDemo 组件会在页面上显示出来,浏览器 network 面板上会多一个 js 文件,该文件就是 ImportDemo 组件,Vite 做了代码分割,将该组件分离为单个文件。

54.png

在终端运行 npm run build 命令,发现动态导入的组件被分割在单独的 js 文件中了

55.png

与 Webpack 的区别是,Vite 不支持魔法注释,无法通过注释的方式定义打包后输出的文件名,并将不同的组件打包到同一个文件中。

上面的例子都是动态导入组件(异步组件)的例子,其实动态导入的 js 文件,打包后,都会被 Webpack/Vite 分割为单独的文件。

看下面的例子,定义 util 模块:

// components/util.js

function add(a, b) {
  return a + b;
}

export { add };

在 App.vue 文件中动态导入 util 模块

<!-- App.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'
const ImportDemo = defineAsyncComponent(() => import('./components/ImportDemo.vue'))
import('./components/util.js').then(res => {
  const { add } = res
  console.log('加法:', add(2, 3))
})
</script>

<template>
  <div>
    <ImportDemo />
  </div>
</template>

<style scoped>
</style>

👆 上述例子使用 Vite 5.2.11 创建

在终端运行 npm run build 命令,发现 util 模块被分割在单独的 js 文件中:

56.png

上面是 Vue3 的例子,其实 Vue2 中也一样:

在 App.vue 中动态导入 util 模块,并用 Webpack 魔法注释定义打包后该模块的文件名为 util

<!-- App.vue -->
<template>
  <div id="app">
    import 演示
  </div>
</template>

<script>

export default {
  name: 'App',
  mounted() {
    this.addTest()
  },
  methods: {
    addTest() {
      import(/* webpackChunkName: 'util' */ './components/util.js').then(res => {
        const { add } = res
        console.log('加法:', add(2, 3))
      })      
    }
  }
}
</script>

<style>
</style>

👆 上述例子使用 vue-cli 5.0.8 创建

在终端运行 npm run build 命令,发现 util 模块也被分割为单独的 js 文件

57.png

所以使用动态 import() 可以实现模块按需加载与代码分割,可提升页面性能。

React 中使用 import() 实现按需加载(懒加载)和代码分割

React 和 Vue 是类似的,动态导入在 React 中也可用于实现异步组件。异步组件的实现原理都是一样的,就是以异步的方式加载组件,只是语法上的差异。

React 中动态导入(import())可以和 lazy 一起使用实现异步组件,然后再结合条件判断语句,就可实现组件按需加载。在 React 中也是一样,Webpack/Vite 遇到异步组件,也会将其打包成单独的文件,从而实现代码分割,避免单个 JS 文件的体积过大。

需要注意的是 lazy 要和 Suspense 一起使用,或者使用 startTransition 降低相关更新优先级,否则页面会报错

58.png

下面的代码没有使用 Suspense ,也没使用 startTransition,就报错了,报错如上图所示。

// App.js
import { lazy, useState } from "react";

const ImportTest1 = lazy(() => import("./ImportTest1.js"));

function App() {
  const [visible, setState] = useState(false);
  const handleClick = () => {
    setState(!visible);
  };
  return (
    <div className="App">
      <button onClick={handleClick}>按钮</button>
      是否显示:{String(visible)}
      {visible && <ImportTest1 />}
    </div>
  );
}

export default App;
// ImportTest1.js
function ImportTest1() {
  return <div>import123</div>;
}

export default ImportTest1;

👆 上述例子使用 create-react-app 5.0.1 创建

Suspense 的作用是允许在子组件完成加载前展示后备方案,例如在组件加载过程中,展示 loading 组件。由于动态导入的组件依赖于网络请求,并且使用了 lazy 包裹,属于懒加载(按需加载)的组件,本质上也叫异步组件,因此使用 Suspense 后,可避免由于异步加载过程中产生错误,而导致页面奔溃的问题。

使用 Suspense 改造上述例子,便不会报错了:

59.png

React 官方文档也提到了,使用了 lazy 后,组件是按需加载的,你需要指定在组件加载过程中,页面应该展示的内容,此时 Suspense 便派上用场。

60.png

startTransition 能降低更新优先级,使更新不阻塞 UI 渲染。由于是动态加载的组件,组件在加载过程中会发起网络请求,既然是网络请求,那组件的加载肯定存在延时,无论延迟有多小,所以组件在展示到页面上之前,肯定存在一个“过渡”的时间,所以使用 startTransition 能够告诉 React 这个更新需要一个“过渡”的时间,React 此时会认为这个更新的优先级不高,从而避免这个更新阻塞页面的渲染。

使用 startTransition 改造上述例子,也不会报错:

61.png

在终端运行 npm run build 命令,发现动态导入的组件已经被单独分割到一个 chunk 文件中:

62.png

如果觉得不够清晰,可以用 webpack 的魔法注释语法,为动态导入的组件打包后的 chunk 重命名:

63.png

重新在终端运行打包命令,可发现动态导入的组件打包后输出在单独的文件中,并且文件名为用魔法注释定义的文件名:

64.png

上面演示的例子是使用 create-react-app (打包工具为 Webpack)创建的,其实使用 Vite 也一样,只是 Vite 不支持魔法注释。

65.png

👆 上图的例子是使用 Vite 5.2.0 创建的,在终端运行打包命令,可发现动态导入的组件被单独分割到一个文件中。

需要注意的是,在 React 中,动态导入的组件需要用 lazy 包裹,否则页面会报错。

上面的例子都是动态导入组件(异步组件)的例子,其实动态导入的 js 文件,打包后,都会被 Webpack/Vite 分割为单独的文件,和 Vue 中的例子一样,这里便不再赘述了。

总结

ES 模块是在语言标准层面上实现的模块化方案。语法上,使用 export 实现模块导出,import 实现模块导入。

ES 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。ES6 的 Tee Shaking (摇树优化)就是借助 ES 的模块化能力实现的。通过 ES 模块的静态分析,分析出模块之间的依赖关系,来判断哪些代码没有被引用,进而删除对应的代码。

ES 模块的加载是编译时加载,CommonJS 的在运行时加载,加载效率会比 CommonJS 模块高。

ES 模块导出的是值的引用,外部通过该引用能取得模块内部实时的值,即当引用的模块内部的值改变时,外部能够得到模块内部最新的值。

ES 模块不仅支持静态导入,也支持动态导入import()),使用动态导入,可以实现模块的按需加载,Webpack 、Vite 遇到动态导入的模块,在打包时,也会将该模块的代码单独分割到一个文件中,实现代码分割,避免一次性加载过大的文件,提升页面的加载性能。动态导入的能力也使得 ES 模块能够引入 CommonJS 模块的内容。

参考

  1. Module 的语法

  2. JavaScript modules

  3. 能不能说说 React 18 的 startTransition 作用?

  4. Webpack 中的 module、chunk、bundle 究竟是什么?