全面解析 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

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

相关推荐
不说别的就是很菜26 分钟前
【前端面试】前端工程化篇
前端·面试·职场和发展
绝无仅有42 分钟前
大厂某里电商平台的面试及技术问题解析
后端·面试·架构
绝无仅有1 小时前
某里电商大厂 MySQL 面试题解析
后端·面试·架构
hygge9991 小时前
JVM 内存结构、堆细分、对象生命周期、内存模型全解析
java·开发语言·jvm·经验分享·面试
hygge9991 小时前
类加载机制、生命周期、类加载器层次、JVM的类加载方式
java·开发语言·jvm·经验分享·面试
程序员爱钓鱼1 小时前
Python 编程实战 · 实用工具与库 — Flask 基础入门
后端·python·面试
程序员爱钓鱼1 小时前
Python编程实战 - Python实用工具与库 - 文件批量处理脚本
后端·python·面试
ThreeAu.11 小时前
测开高频面试题集锦 | 项目测试& 接口测试&自动化
面试·自动化·测试开发工程师
han_12 小时前
前端高频面试题之Vue(高级篇)
前端·vue.js·面试
不说别的就是很菜13 小时前
【前端面试】CSS篇
前端·css·面试