从 CommonJS 到 ES6 Module,这些模块化方案藏着哪些开发秘密? 🤔🤔🤔

开篇 📚

在当前的招聘市场中,很多职位要求熟悉模块化开发,面试时通常会问你对模块化的理解,以及有哪些常见的模块化方案,各自的优缺点。接着,面试官可能会引出Tree Shaking 的概念,探讨其必要的前提条件。Vue 3 的组合式 API 便是为了更小的包体积(还会完成版vue/运行时vue) ,并支持 Tree Shaking。此外,ESM(ECMAScript 模块化)的出现推动了打包工具如 Vite 的诞生,使得开发者能够更加高效地管理项目依赖。结合自定义组件库CLI 工具 的打包方式,开发者不仅要支持 ESM ,还要兼容 CJS(CommonJS) ,并剖析elementUI的打包构建方式 ,来更细致的体验打包流程。ESM 的广泛应用,也促进了 Hooks 的普及,Vue 2 从 mixinHooks 过渡,Pinia 的流行也与 ESM 模块化密切相关。再加上 HTTP/2 对于多请求的支持,模块化的作用将会越来越显著。

模块化方案的概念和作用 👊

  • 模块化 就是 CommonJS、AMD、CMD、UMD、ES6 Module 这些方案

  • 将代码拆成多个独立,可复用的模块,快的内部数据与实现是私有的,只是向外暴露一些接口与外部模块进行通信

  • 主要是提高代码的可维护性和可扩展性,不同的模块化方案在不同的环境中有不同的应用

模块化方案的基本使用 👊

  1. 比如 CJS 是主要面向服务端,适用于 同步加载,使用module.exports 和 require 来进行模块的加载

  2. AMD 和 CMD 模块化方案,主要是在浏览器端的模块化方案,解决了commonJS 模块在浏览器端无法实现异步加载的问题,他们主要是通过define 和 require 来实现模块的导入导出

  3. UMD 是一种javascript通用模块定义规范,让你的模块能在javascript所有运行环境中发挥作用。主要就是一个立即执行函数, 通过判断一些标识,来针对不同模块做不同的处理。

举个🌰

  • 浏览器环境 :如果没有加载模块加载器(如 RequireJS),UMD 模块会将自身暴露为全局变量(通常是 windowglobal),从而可以在浏览器中通过全局对象使用这个模块。
  • CommonJS 环境(Node.js) :如果模块加载环境是 Node.js 或类似的 CommonJS 环境,UMD 会使用 module.exportsexports 机制来暴露模块。
  • 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模块'
    }
}))
  1. 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 主要因为以下几个原因:

  1. 静态结构 : ESM 模块(.mjs 文件)具有静态的导入导出结构。这意味着,模块的依赖关系可以在编译时被完全分析,而不需要运行时的解析。与 CommonJS 模块的动态 require() 调用不同,ESM 的 importexport 语法是静态的,可以在构建时解析模块之间的依赖关系。

    例如,ESM 的 import { something } from './module'; 语法是静态的,构建工具(如 Webpack、Rollup)可以在编译时知道哪些导入是未被使用的,并将它们删除。

  2. 无副作用(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的前提条件是什么? 🤔

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  2. 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 的多路复用能力,进一步优化了前端加载效率。

相关推荐
崔璨8 分钟前
实现一个精简React -- 利用update函数,实现useState(9)
前端·react.js
鱼樱前端14 分钟前
Vue3+TS+Vant 上拉加载下拉刷新框架
前端·vue.js·ecmascript 6
宇寒风暖17 分钟前
HTML 表单详解
前端·笔记·学习·html
蒜香拿铁19 分钟前
【typescript基础篇】(第三章) 函数
前端·typescript
蒜香拿铁19 分钟前
【typescript基础篇】(第三章) 接口
前端·typescript
cheeseagent19 分钟前
Angular组件库按需引入实战指南:从踩坑到起飞
前端·npm
又写了一天BUG20 分钟前
关于在vue3中使用keep-live+component标签组合,实现对指定某些组件进行缓存或不缓存的问题
前端·javascript·vue.js
拿我格子衫来20 分钟前
图形编辑器基于Paper.js教程24:图像转gcode的重构,元素翻转,旋转
前端·javascript·图像处理·编辑器·图形渲染
arcsin124 分钟前
立春-如何初始化electron项目
前端·electron·node.js