面试官:说说前端模块化方案有哪些?
我: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 带来的关键能力之一,也是前端工程化中体积优化的核心手段
简要步骤
- 源码
- 解析 import/export(静态分析)
- 构建模块依赖图(Module Graph)
- 注意这个是打包工具的,不是 ES Module 原理里面的模块记录、模块映射
- 标记哪些 export 被使用
- 遍历图,删除未引用的导出(要求无副作用且按需引入)
- 输出精简后的 bundle
核心其实就是静态分析,分析出来了模块之间的依赖关系,然后把不需要的删除了