全面解析 ES Module 模块化

面试官:说说前端模块化方案有哪些?

我:ES Module 静态分析,可以做treeShaking,吧啦吧啦

面试官:很好,还有其他方法吗?

我:没了,现在 ESM 早一统天下了,我就会这个,你爱过不过吧

面试官:???

虽然这个对话有点倒反天罡,但是在经历过模块化的大浪淘沙之后,esm 和 commonjs 作为模块工程化的双子星已经有一统天下之势,esm 在前端嘎嘎乱杀,commonjs 在后端嘎嘎乱杀;尤其在 node 支持 esm 之后,可以说 esm 做到全栈乱杀一点不会过;其余的 amd、umd 之流,可以说现今主要的出现地点是在面试题中

基础使用

导出方式

export 或者 export default 导出,其中 export default 唯一

typescript 复制代码
// demo.tsx 作为导出案例
import React from 'react';
const ModuleComponent = () => {
  return (
    <div>module</div>
  );
};
export const incr = () => num_a++;
export let num_a = 1;
export default ModuleComponent;

导入方式

  • ES Module 支持多种导出方式
typescript 复制代码
import ModuleComponent from './demo'; // 默认导入
import { num_a } from './demo'; // 命名导入
import * as Demo from './demo'; // 全部导入
import './demo'; // 只执行,不导入值
  • 默认导入对应 default 导出,命名导入对应命名导出
  • 重命名问题
    • 默认导入不可重命名
    • 全部导入必须重命名
    • 命名导入可重命名也可不重命名
    • 只执行没名字

核心原理

ES Module 的核心可以分为三个:静态分析、活引用、单次执行;

如果还有点其他,那就是补充了个动态导入

静态分析

  • 整个 ES Module 的核心中的核心,也是这点让 ES Module 归属于静态模块
  • 由于此,静态分析,构建工具(如 Webpack、Vite)和浏览器可以在编译时分析依赖关系,更好实现 Tree Shaking 优化
  • 导入导出要求:import 和 export 必须在顶层,不能写在 if 或函数里,不然没法分析
typescript 复制代码
if (true) {
  // 报错:An import declaration can only be used at the top level of a namespace or module.
  import ModuleComponent from './demo';
}

不过模块可以动态导入,甚至动态路径

typescript 复制代码
// 动态导入
const module = await import('./demo');

// 动态导入 + 动态路径
const path = 'demo';
const module = await import(`./${path}`);

但是不能静态导入 + 动态路径

typescript 复制代码
// 报错:String literal expected.
const module = await import(`./${path}`);

因为 ES Module 会在构建出模块图后才会给模块内部赋值(这个步骤在模块化中叫做计算),这样才能满足静态分析的能力,所以在获取这个模块路径时 path 变量还没赋值;

而动态导入时是一个运行时加载器,作为 ES Module 机制的补充

活引用(Live Bindings)

  • import 导入的是一个活引用,而不是值的拷贝
  • 也叫动态绑定或者只读绑定,导入数据只能在导出中变更,无法在导入代码中直接更改
  • 活引用导致自动传播机制,更新无需手动同步,因为压根也不要同步,做到不运行任何代码也能链接所有代码
  • 活引用减轻了循环引用的难点,但是仍然不推荐循环引用
typescript 复制代码
// index.tsx
import ModuleComponent, { num_a, incr } from './demo';

console.log(num_a);
incr();
console.log(num_a);

// 输出结果
1
2

// 不能在 index.tsx 中
// 报错:Cannot assign to num_a because it is an import.
num_a = 2;

单例/单次执行

即使从多个地方 import 模块代码,代码也只会执行一次,之后返回的是模块缓存的导出值

typescript 复制代码
// index1.tsx
import { num_a, incr } from './demo';
incr();
console.log(num_a);

// index2.tsx
import { num_a, incr } from './demo';
incr();
console.log(num_a);

// 输出结果
2
3

现代前端的基础

  • ES Module 是现代前端架构的基础
  • 具体的说,ES Module 中单例 + 静态分析 + 活引用使当代前端架构(状态管理、DI、插件系统、热更新)等成为可能
  • 状态管理中:store 数据可以在重新渲染时不被初始化的原因在于 store.ts 只执行一次,这次执行会将其中的 状态用闭包的形式保存,使其成为所有组件共享的私有状态;同时活绑定让所有状态的变更在注册组件中可以及时获知,这就是 zustand / pinia / redux 的基础原理(当然组件代码获知变更后,还需要通知框架进行变更, 比如在 react18 中是通过 useSyncExternalStore 做到的)
  • 插件系统中:存在一个插件中心可以 register 和 run,执行一个自动注册代码,会将所有插件注册到插件中心中,由于插件中心只执行一次,所有插件中心也可以成为一个私有状态

常见插件中心 + 插件执行的代码

typescript 复制代码
// core.ts ------ 插件中心(单例)
// plugins 只执行一次,成为私有状态
const plugins = [];

export const registerPlugin = (name, plugin) => {
  plugins.push(plugin);
};

export const runPlugins = (hook) => {
  plugins.forEach(p => p[hook]?.());
};

// run.ts ------ 执行代码
import './plugins/logger'; // 执行,自动注册,调用registerPlugin
import { runPlugins } from '@/core';
runPlugins('buildStart'); // 触发所有插件

值得一说,使用 ES Module 来存储私有状态本身不依赖于闭包,插件中心中就没有闭包,但是状态管理中往往会使用闭包来私有化,这主要是方便状态管理的其他逻辑

浏览器原生支持

typescript 复制代码
<script type="module">
  import { add } from './utils.js'; // 必须写完整文件名
</script>

ES Module 有很好的浏览器支持,只需要符合一部分特定条件即可

  • 必须使用 type="module"
  • 路径必须带 .js 扩展名(不能省略)
  • 默认 defer,异步加载不阻塞
  • CORS 要求:跨域需服务器支持

值得说的是 import 导入的字符串(模块说明符),用于告知模块 loader 怎么查找模块,这个查找方法和具体环境有关,如果是浏览器的话只接受 URL(相对或者绝对路径都行),不能裸导入

typescript 复制代码
// 典型'裸导入'
import React from 'react';

写代码时经常可以看到裸导入,这是因为构建工具做了重写,'react' 被构建工具翻译成了浏览器能理解的 URL

模块内部分析

可以尝试通过导入整个模块来查看 ES Module 的原理

typescript 复制代码
// index.tsx
import * as Demo from './demo';
console.log(Object.keys(Demo), typeof Demo);

// 输出结果
 ['default', 'incr', 'num_a'] 'object'

输出结果是个对象,存在 default 和 num_a 属性;

如果进一步打印出不可枚举变量呢

typescript 复制代码
// index.tsx
console.log(Object.getOwnPropertyNames(Demo));

// 输出结果
['__esModule', 'default', 'incr', 'num_a']

会发现结果多出一个 __esModule,该属性用于表示当前模块是否属于 ES Module

如果再进一步打印出 default 呢

typescript 复制代码
// index.tsx
console.log(Demo.default);

// 输出结果
()=>{
    return _react.default.createElement("div", null, "module");
}

熟悉 react 源码的人会发现这个其实就是 react 创造虚拟 dom 的方法(经过了 ES Module 语法的编译)

同样,如果将这个方法直接插入 tsx 中也可以渲染组件

typescript 复制代码
// 以下导入效果相同
import * as Demo from './demo';
<Demo.default>

 import ModuleComponent from './demo';
<ModuleComponent>

这也解释了为什么一个模块只有一个 default,因为其他 export 数据有变量名区分,但是 export default 的变量名就是 default,这也意味着模块 default 唯一

typescript 复制代码
// demo.tsx
import React from 'react';
const ModuleComponent1 = () => {
  return (
    <div>module</div>
  );
};
const ModuleComponent2 = () => {
  return (
    <div>module</div>
  );
};
// 以下会报错:A module cannot have multiple default exports.
export default ModuleComponent1;
export default ModuleComponent2;

模块可以以对象的形式导出,不等于说模块就是对象,这只是使用全部导出方式时模块做的封装,创建了一个模块命名空间对象,真实的模块会在执行后缓存在 Module 的内部记录和映射中,模块其本身并不是对象,模块是一个具有独立作用域和执行逻辑的代码单元

真实的模块内容在 js 引擎中完成,不能直接访问,前端不可控

如果想进一步了解 ES Module 到底发生了什么,可以查看 esm原理(推荐有一定基础后学习)

如果采用动态导入的方式完成,获取的就是个包含对象的 Promise

typescript 复制代码
// 动态获取对象
const demo = await import('./demo');

// 下面代码和静态很像,但是其实只是动态获取后解构赋值
const { num_a, incr } = await import('./demo');

摇树优化(Tree Shaking)

ES Module 带来了很多现代前端的基建能力,除了上面已经提及的状态管理、插件系统等,还有个最为重要(面试官最喜欢考的)的就是 Tree Shaking;Tree Shaking 是 ESM 带来的关键能力之一,也是前端工程化中体积优化的核心手段

简要步骤

  1. 源码
  2. 解析 import/export(静态分析)
  3. 构建模块依赖图(Module Graph)
    • 注意这个是打包工具的,不是 ES Module 原理里面的模块记录、模块映射
  4. 标记哪些 export 被使用
  5. 遍历图,删除未引用的导出(要求无副作用且按需引入)
  6. 输出精简后的 bundle

核心其实就是静态分析,分析出来了模块之间的依赖关系,然后把不需要的删除了

相关推荐
Dignity_呱13 分钟前
为什么一定要有微任务,直接一个宏任务不行吗
前端·javascript·面试
库森学长18 分钟前
面试官:集群模式下,如何解决本地缓存的数据更新问题?
后端·面试
UrbanJazzerati23 分钟前
PowerShell 自动化实战:自动化为 Git Staged 内容添加 Issue 注释标记 (2)
后端·面试·shell
前端小白199536 分钟前
面试取经:网络篇-断点续传
前端·面试
小高00737 分钟前
💥前端开发 2025 生存指南:调试不靠 console.log 靠什么?
前端·javascript·面试
小高00741 分钟前
面试官:说说 Webpack 和 Vite 的区别
前端·javascript·面试
种子q_q1 小时前
Java中的代理模式
java·后端·面试
掘金安东尼2 小时前
独立开发/自由职业/远程工作:这年头不上班也能过挺好!!
前端·面试·github
愿天堂没有C++2 小时前
剑指offer第2版——面试题2:实现单例
c++·设计模式·面试