开篇 📚
在当前的招聘市场中,很多职位要求熟悉模块化开发,面试时通常会问你对模块化的理解,以及有哪些常见的模块化方案,各自的优缺点。接着,面试官可能会引出Tree Shaking 的概念,探讨其必要的前提条件。Vue 3 的组合式 API 便是为了更小的包体积(还会完成版vue/运行时vue) ,并支持 Tree Shaking。此外,ESM(ECMAScript 模块化)的出现推动了打包工具如 Vite 的诞生,使得开发者能够更加高效地管理项目依赖。结合自定义组件库 和 CLI 工具 的打包方式,开发者不仅要支持 ESM ,还要兼容 CJS(CommonJS) ,并剖析elementUI的打包构建方式 ,来更细致的体验打包流程。ESM 的广泛应用,也促进了 Hooks 的普及,Vue 2 从 mixin 向 Hooks 过渡,Pinia 的流行也与 ESM 模块化密切相关。再加上 HTTP/2 对于多请求的支持,模块化的作用将会越来越显著。
模块化方案的概念和作用 👊
-
模块化 就是 CommonJS、AMD、CMD、UMD、ES6 Module 这些方案
-
将代码拆成多个独立,可复用的模块,快的内部数据与实现是私有的,只是向外暴露一些接口与外部模块进行通信
-
主要是提高代码的可维护性和可扩展性,不同的模块化方案在不同的环境中有不同的应用
模块化方案的基本使用 👊
-
比如 CJS 是主要面向服务端,适用于
同步加载
,使用module.exports 和 require 来进行模块的加载 -
AMD 和 CMD 模块化方案,主要是在浏览器端的模块化方案,解决了
commonJS 模块在浏览器端无法实现异步加载的问题
,他们主要是通过define 和 require 来实现模块的导入导出 -
UMD 是一种
javascript通用模块定义规范
,让你的模块能在javascript所有运行环境中发挥作用。主要就是一个立即执行函数
, 通过判断一些标识
,来针对不同模块做不同的处理。
举个🌰
- 浏览器环境 :如果没有加载模块加载器(如 RequireJS),UMD 模块会将自身暴露为全局变量(通常是
window
或global
),从而可以在浏览器中通过全局对象使用这个模块。 - CommonJS 环境(Node.js) :如果模块加载环境是 Node.js 或类似的 CommonJS 环境,UMD 会使用
module.exports
或exports
机制来暴露模块。 - AMD 环境 :如果模块加载器是 AMD(如 RequireJS),UMD 会通过
define
方法来定义模块。
代码提示 🔔
所以多模块打包的最终结果,就是一个立即执行函数
js
(function(root, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
console.log('是commonjs模块规范,nodejs环境')
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
console.log('是AMD模块规范,如require.js')
define(factory)
} else if (typeof define === 'function' && define.cmd) {
console.log('是CMD模块规范,如sea.js')
define(function(require, exports, module) {
module.exports = factory()
})
} else {
console.log('没有模块环境,直接挂载在全局对象上')
root.umdModule = factory();
}
}(this, function() {
return {
name: '我是一个umd模块'
}
}))
- ESM 是 ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,。我们的入口文件index.html,需要制定type="module",他才会当成ESM 模块处理,对于node 环境想用ESM 可以使用mjs 的后缀,或者在package.json 中 配置 type="module", 即可,他的一个好处就是可以做
tree-shaking
什么是tree-shaking 、 ESM 模块为什么可以实现tree-shaking 、 在webpack中如何配置,可以实现 tree-shaking、有副作用的函数,如何tree-shaking
不是必须使用
.mjs
扩展名 ,你可以在.js
文件中使用 ESM 语法,前提是确保你的运行环境或构建工具正确配置了对 ESM 的支持, 比如 type="nodule"
Tree Shaking 是一种优化技术,旨在删除代码中未使用的部分,减少最终输出的文件大小。ESM(ECMAScript Modules)模块格式能够实现 Tree Shaking 主要因为以下几个原因:
-
静态结构 : ESM 模块(
.mjs
文件)具有静态的导入导出结构。这意味着,模块的依赖关系可以在编译时被完全分析,而不需要运行时的解析。与 CommonJS 模块的动态require()
调用不同,ESM 的import
和export
语法是静态的,可以在构建时解析模块之间的依赖关系。例如,ESM 的
import { something } from './module';
语法是静态的,构建工具(如 Webpack、Rollup)可以在编译时知道哪些导入是未被使用的,并将它们删除。 -
无副作用(Side Eddect Free) 模块:模块的导入不会引发任何额外的运行行为
js
// 有副作用的模块:导入时直接执行代码
console.log('This module runs on import'); // 导入时会输出
globalThis.someGlobalVar = 42; // 修改全局变量
js
// 无副作用的模块:只定义导出内容
export const a = 10;
export function add(x, y) {
return x + y;
}
那ESM 模块可以实现动态导入吗 ?🤔
尽管 ESM 本身强调静态导入,但它也提供了 动态导入 的功能。这使得你能够在运行时动态地导入模块,而不是在编译时决定依赖关系。
动态导入是通过 import()
函数来实现的,import()
是一个 异步操作 ,它返回一个 Promise
,可以在运行时按需加载模块。这使得动态导入非常适合按需加载和懒加载(lazy loading)。
示例:
javascript
// 动态导入示例
async function loadModule() {
const module = await import('./module.js');
module.foo(); // 使用动态导入的模块
}
在webpack中如何配置,可以配置实现 tree-shaking ?, webpack处理tree-shaking 的流程是怎么样的 🤔
ES6 模块与 CommonJS() 模块的差异,以及hooks的前提条件是什么? 🤔
- CommonJS 模块输出的是一个
值的拷贝
,ES6 模块输出的是值的引用
; - CommonJS 模块是
运行时加载
,ES6 模块是编译时输出接口
;
CommonJS Code
js
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
+++++++++++++++++++++++++++
// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;
console.log(counter); // 3
incCounter();
console.log(counter); // 3
ESM Code(实时更新)
js
// lib.js
export let counter = 3;
export function incCounter() {
counter++;
}
+++++++++++++++++++++++++++
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
个人理解 🤔
-
CommonJS 导出的是一个对象,所以只能是将导出的对象克隆,不然里面的依赖关系捋不清 ,(当然像vue2定义数据也是一个
对象
,所以需要额外的遍历所有属性,做响应式, 后来不是也变成组合式API
。个人理解,也是一种模块化思想由CommonJS 向 ESM 的一个转变
) -
ESM 导出的方式更加灵活,模块化范围更小,用小的片段来组合大的功能。而不是大的功能来拆分小的片段,而且一定程度做到了职责单一,既能做tree-shaking , 又能很好的做数据实时更新(因为
小片段的编码,数据状态的变化更容易追踪,消耗的性能也更小
)
几种模块化方案的对比 🍊
最主要的是,他是静态分析:ES6 模块在编译时就已经确定了模块的依赖关系,这个时候就可以支持树摇优化(Tree Shaking),可以剔除未使用的代码,减少打包后的代码体积。
特性 | CommonJS | AMD | CMD | UMD | ES6 Module |
---|---|---|---|---|---|
导入方式 | require |
require / define |
require / define |
define |
import |
导出方式 | module.exports |
define |
module.exports |
自定义 | export / export default |
同步/异步加载 | 同步 | 异步 | 异步 | 异步 | 异步 (动态导入支持) |
适用环境 | Node.js | 浏览器 | 浏览器 | 浏览器 / Node.js / 全局 | 浏览器 / Node.js |
依赖声明方式 | 文件级别 | 提前声明依赖 | 运行时声明依赖 | 兼容多种方式 | 静态分析 |
跨环境支持 | 仅 Node.js | 仅浏览器 | 仅浏览器 | 浏览器 / Node.js / 全局 | 浏览器 / Node.js |
升华 🍎
自研组件库打包,如何支持多模块打包 🚀
- element-UI 主要供Vue 使用,使用Rollup 用于生产环境的库打包,vite用于开发环境的快速启动和热更新,esbuild 加速构建过程,Gulp 处理任务自动化和文件操作
markdown
- clean:运行清理任务。
- createOutput:创建输出目录。
并行运行以下任务:
- buildModules:构建模块。
- buildFullBundle:构建完整的包。
- generateTypesDefinitions:生成类型定义文件。
- buildHelper:构建辅助工具。
- 构建主题样式并复制样式文件。
- 并行运行 copyTypesDefinitions 和 copyFiles 任务。
- 导出所有来自 ./src 的内容。)
一些三方库,如果是给浏览器使用基本都是打三个包,mjs,cjs,umd,让库或者模块在js的所有环境都可以运行。比如我们的自研组件库是为项目服务的,通过roullp 进行打包,输出三种格式的包供使用者选择
在项目根目录下创建 rollup.config.js
文件,并根据需求配置不同的输出格式。以下是一个典型的配置示例,支持 UMD , ESM (mjs) 和 CommonJS (cjs) 格式。
js
import path from 'path';
import { terser } from 'rollup-plugin-terser'; // 用于压缩代码
import resolve from '@rollup/plugin-node-resolve'; // 解析node_modules中的依赖
import commonjs from '@rollup/plugin-commonjs'; // 转换CommonJS模块为ES模块
import babel from '@rollup/plugin-babel'; // Babel插件,用于转译ES6+代码
import pkg from './package.json'; // 获取package.json的内容
export default {
input: 'src/index.js', // 入口文件
external: ['react', 'react-dom'], // 这里是你希望在UMD中排除的外部依赖,例如 React 和 ReactDOM
plugins: [
resolve(), // 启用 node_modules 模块解析
commonjs(), // 转换CommonJS模块
babel({
exclude: 'node_modules/**', // 不转译node_modules中的文件
babelHelpers: 'bundled', // 使用Rollup内建的babel-helper
}),
terser() // 压缩代码,适用于生产环境
],
output: [
{
file: path.resolve('dist/index.umd.js'), // 输出UMD格式
format: 'umd', // 模块格式
name: 'MyLibrary', // UMD格式需要暴露一个全局变量的名字
globals: {
react: 'React', // 外部依赖的全局变量
'react-dom': 'ReactDOM'
},
sourcemap: true // 开启源映射文件
},
{
file: path.resolve('dist/index.esm.js'), // 输出ES模块(mjs)
format: 'esm', // 模块格式
sourcemap: true // 开启源映射文件
},
{
file: path.resolve('dist/index.cjs.js'), // 输出CommonJS格式
format: 'cjs', // 模块格式
sourcemap: true // 开启源映射文件
}
]
};
vite 的 出现和 ESM module 的标准离不开 🚀
因为模块化,最终的归宿也是dist, 我们对于项目的加载快慢,大部分原因取决于你dist 的大小 ,而 ES module 的模块,浏览器会把他当成一个js 去下载,根本就不需要打包了 ,这样就会大大增加项目的加载速度,像之前大的一些包(echarts,xlsx),我们也会做成外链的形式,也是这个意思,不让他存在于包中,而是用到的时候去加载(赖加载)
理论上,由于http2真正实现了多路复用(二进制分帧,可以交错传输),所以有多少请求,都可以处理掉,但是只是针对业务而言,如果一个三方库也有几百个请求,还是要做一个均衡,所以vite 中有一个预构建的概念(主要是CommonJS 和 UMD 兼容性 和 性能 vite 依赖预构建)
vue3的hooks 和 Pina 的 出现和 ESM module 的标准离不开 🚀
vue2 时代,要做到逻辑复用 Mixins,他的风格是 CommonJS 风格,数据来源不清晰,可能导致命名冲突,ts 支持差。 而hooks 本质是一个函数,没有this依赖,代码清晰,都在一个完整的函数中,可以支持tree-shaking。 再一个是ESM模块 中输出的是模块的引用,所以不用依赖this实例,可以直接修改hooks 中的数据
http2 和 ESM, 加快页面访问速度 🚀
-
在HTTP/1.1 时代,由于是基于
文本的串行请求-响应模型
导致请求之间存在严格的依赖关系,存在队头头阻塞的问题
,小文件多了会影响性能,所以我们用Webpack/Vite 打包合并所有的JS 文件,一般是一个大bundle.js,当然有一些优化策略:同一个域名开启6~8个TCP 连接,域名分片(让不同资源走不同TCP连接) -
而在HTTP2中,通过更换传输数据模式,由
文本
改为二进制
,二进制比较原始,也更快
,而且通过二进制分帧
,可以对数据进行随意组合
,所以可以并行处理请求
。可以允许更多小文件,更好的支持ESM 一个文件就是一个js 请求的方式
以上描述,其实就是多路复用
:通过将每个请求和响应分解为多个小帧,并允许这些帧在同一个 TCP 连接上交错传输,从而实现了多个请求和响应的并行处理
基于以上特性,Vite + HTTP/2 就是前端最优方案
番外知识补充 🍇
1. 什么是编译时,什么运行时 ? 🤔
1️⃣ 运行时(Runtime)与编译时(Compile-time)
编译时(Compile-time)
-
定义 :编译时是指程序在被执行之前,由编译器进行转换、分析和优化的阶段。此时,
代码还没有被执行
。 -
过程:在编译时,源代码(例如 JavaScript、TypeScript 或其他语言的代码)会被编译成机器码(在某些情况下为中间代码或字节码),并准备好执行。编译器在这一过程中可以进行静态分析、优化(例如消除冗余、代码压缩)等工作。
-
举例:对于 JavaScript 项目,构建工具(如 Webpack、Rollup、Vite)会在编译时进行模块打包、压缩、Tree Shaking 等操作。
运行时(Runtime)
- 定义:运行时是指程序开始执行后,代码实际运行并与外部环境交互的阶段。它包括程序的执行、内存管理、事件处理等。
- 过程:在运行时,程序的代码已经被加载到内存中,并开始实际的执行。此时,程序可能会根据输入、外部事件或用户操作来进行动态的行为,如 DOM 操作、网络请求、动态模块加载等。
- 举例 :JavaScript 中的事件循环、动态导入(
import()
)、API 调用等都属于运行时操作。
2️⃣ 编译时与运行时的区别
维度 | 编译时 | 运行时 |
---|---|---|
定义 | 编译器或构建工具在程序执行前进行的操作 | 程序实际执行的阶段 |
工作内容 | 静态分析、优化、代码转换 | 执行代码、动态加载、事件处理等 |
示例 | Webpack 打包、TypeScript 编译、CSS 压缩 | JavaScript 执行、API 请求、事件监听 |
时机 | 程序开始运行之前 | 程序开始运行后 |
3️⃣ vue 为什么要区分运行时版本和完整版本
- 因为完整版很大,不适合生产环境,所以建议在构建阶段就完成模版编译,这就是vite 和vue-loader 存在的意义
- 手动 调用 @vue/compiler-sfc,需要自己管理编译流程,HMR,CSS 处理,复杂度高
运行时版本不包含模版编译器,而是采用vue-loader / vite 来编译.vue文件
js
import { createApp } from 'vue'; // 运行时版本
import App from './App.vue';
createApp(App).mount('#app');
App.vue 的 template 会在构建时编译为 render 函数,因此不需要浏览器端编译器。
4️⃣ 总结
-
编译时:Tree Shaking 发生在编译阶段,构建工具会静态分析代码并移除未使用的部分。只有在编译时,构建工具能够准确地识别哪些代码是多余的。
-
运行时:运行时是代码执行的阶段,这时的代码已经被加载到内存中,并开始执行。Tree Shaking 无法在运行时进行,因为它依赖于静态分析,无法在代码执行后动态地移除不需要的代码。
比如我在编译时,对文件进行压缩,代码丑化,在运行时为什么还可以识别到正确的代码并执行? 🤔
-
JavaScript 引擎在运行时根据语法规则和执行上下文来识别和执行代码,压缩或丑化仅仅改变了代码的外观,而不会影响其行为
-
在我们的
监控平台
中,会使用Webpack / Rollup / Vite 结合源映射(Source Maps)技术,在压缩和丑化过程中生成映射文件,以便开发人员在调试时能看到原始代码(尽管用户看到的是压缩或丑化后的代码)。
总结 🚀
前端模块化的发展,使代码组织更加清晰,解决了变量污染、依赖管理困难等问题。从早期的 IIFE(立即执行函数) 到 CommonJS、AMD/CMD,再到现代 ESM(ES Modules),模块化方案不断演进。
ESM 的普及推动了打包工具(如 Vite)的发展,使 Tree Shaking 成为可能,提升了代码的可维护性和性能。同时,Vue 3 通过组合式 API(Composition API)支持更精细的 Tree Shaking,并与 Pinia 一起拥抱 ESM。模块化不仅影响了框架设计,也得益于 HTTP/2 的多路复用能力,进一步优化了前端加载效率。