从前端模块化历史到大厂面试题

🧩什么是模块

将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起。块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信。

简单来说也就是,模块就是把一段独立、可复用的逻辑封装起来,并且可以导入导出。

就像我们平时玩的乐高或者拼图,每个乐高小块就是一个模块。你可以单独制造、保存它,然后在不同的拼搭里拿来用。它自己有形状(接口),别人只需要知道怎么拼接,不用管内部细节。

原始阶段:全局变量、函数阶段

在早期的JavaScript开发中,JavaScript没有内置的模块系统,通常使用全局变量、函数来组织代码

js 复制代码
// a.js
function add(x, y) {
  return x + y;
}
var sum = add(1, 2);
console.log(sum);

// b.js
function add() {}

存在的问题

随着文件越来越多,你可能在另一个文件里又写了同名的变量或函数:

js 复制代码
function add(x, y) {
    return x - y;
  }

函数add的逻辑完全被改变了,而且这些覆盖并不会报错,开发者难以发现哪里出现了bug------这就是所谓的全局污染。

另外,依赖关系也完全靠人记住加载顺序,比如:

js 复制代码
<script src="./util.js"></script>
<script src="./main.js"></script>

如果你不小心把顺序写反了:

js 复制代码
<script src="./main.js"></script>
<script src="./util.js"></script>

main.js 想用 util.js 里的方法时,就会直接报错。 总结

  • 容易出现命名冲突以及代码复杂性的问题
  • 模块成员之间看不出直接关系

命名空间(namespace)

针对全局变量、函数这种方式存在代码污染和命名冲突的问题,引入了命名空间的概念,通过将相关的函数、变量和对象放在命名空间中,实现了代码的封装和组织

js 复制代码
var MyApp = {
  score: 100,
  add: function (x, y) {
    return x + y + this.score;
  },
};

var sum = MyApp.add(1, 2);

这样至少不会出现冲突了,但是新的问题也随之出现...

存在的问题

对象里的东西全都暴露在外面,数据不安全(外部可以直接修改模块内部的数据),无法按需导出

如果我只想暴露 add 方法,我的 score 属性也不得不暴露,并且外部还可以直接修改 score 属性

js 复制代码
MyApp.score = 1;

造成了数据的不安全

立即执行函数 IIFE

接着大家想到:干脆用函数作用域,把内部变量关起来,只暴露需要的...那么为了解决命名空间无法按需导出、数据不安全问题,使用闭包特性 将代码包装在一个匿名函数中,创建私有作用域 ,通过返回对象或函数来暴露需要用到的公共接口,避免污染全局命名空间,并立即执行这个函数,这种方式叫立即执行函数

用法

js 复制代码
(function (x) {
  console.log(x);
})(1);

例子

js 复制代码
var MyApp = (function () {
  var score = 100;
  //暴露
  return {
    add: function (x, y) {
      return x + y + score;
    },
  };
})();

var sum = MyApp.add(1, 2);
console.log(sum);

无法修改 score 的值,可以按需导出。

存在的问题

如果当前这个模块依赖另一个模块该怎么办?

IIFE模式增强:引入依赖

答案是 ------ 把依赖作为参数传进去

假设我们有一个工具模块 math.js

js 复制代码
// math.js
var MathModule = (function () {
  function add(x, y) {
    return x + y;
  }
  function sub(x, y) {
    return x - y;
  }

  return { add, sub };
})();

然后我们写一个业务模块 main.js,它依赖 MathModule

js 复制代码
// main.js
var MyApp = (function (math) {
  var score = 100;

  return {
    calc: function (x, y) {
      return math.add(x, y) + score;
    },
  };
})(MathModule);

console.log(MyApp.calc(1, 2)); // 输出 103

但这仍存在以下几个问题:

  • 虽然能解决依赖问题,但随着依赖模块越来越多,参数会越来越臃肿。

  • 加载顺序依旧要手动控制:必须先加载 math.js,再加载 main.js,否则 MathModule 还没定义就会报错。

不过到这里,现代模块化的雏形基本确立。

CommonJS

CommonJS是为服务器端 开发提供了一种同步加载模块的方式,这种模块机制非常适合服务器端环境,因为文件系统的 IO 操作是同步的,解决模块化和依赖管理的问题

用法

  • 导出用 module.exports/exports
  • 导入用 require

例子

js 复制代码
// add.js
function add(x, y) {
  return x + y;
}
module.exports = add;
// main.js
const add = require('./add.js');
const sum = add(1, 2);
console.log(sum);

每个文件都是独立作用域,导入导出清晰。

存在的问题

CommonJS同步加载 的,在服务端没问题,但浏览器不是文件系统,网络环境下同步加载会很慢,用户可能要等脚本全下完才能操作页面。

AMD

CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。开发中对于异步加载的需求越来越多,RequireJS 推出了 AMD 规范,允许在代码运行时异步加载模块,通过definerequire来定义和引用模块,解决了模块依赖管理和异步加载的问题。

例子

math.js

js 复制代码
// 定义名称,依赖项,导出模块
define('math', [], function () {
  return {
    add: function (a, b) {
      return a + b;
    },
  };
});

main.js

js 复制代码
// 加载完成后将math返回的对象以参数传递给回调函数
require(['math'], function (utils) {
  console.log(utils.add(1, 2));
});

index.html

html 复制代码
<script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js"></script>
<script src="./main.js"></script>

存在的问题

AMD 的异步加载思想非常适合浏览器(能按需拉取),但写起来很繁琐,每个模块都要声明依赖和回调,模块一多,代码就变得很臃肿,可读性大大下降。而且如果引入了多余的依赖,没有进行区分是否调用,都会进行加载。

CMD

CMD(通用模块定义)是由 SeaJS 提出和实现的一种模块化规范。SeaJS 是一个遵循 CMD 规范的 JavaScript 模块加载器,可用于浏览器端的模块化开发。

CMD的特点是:

  1. 推崇依赖就近 原则,仅在需要使用某个模块的时候再去require
  2. 模块加载是异步的,但定义的模块会延迟执行,直到需要时才执行
  3. 通过 define 定义模块,require加载模块,易于使用

相比于强调依赖前置的AMD,CMD规范允许就近定义依赖,更加灵活。

用法

js 复制代码
//定义没有依赖的模块
define(function(require, exports, module){
  exports.xxx = value
  module.exports = value
})
js 复制代码
//定义有依赖的模块
define(function(require, exports, module){
  //引入依赖模块(同步)
  var module2 = require('./module2')// 用到时再引入
  //引入依赖模块(异步)
    require.async('./module3', function (m3) {
    })
  //暴露模块
  exports.xxx = value
})

SeaJS用法文档Sea.js - A Module Loader for the Web

存在的问题

CMD 倡导在模块内部按需 require,写起来灵活、符合懒加载场景,但这种"运行时才确定依赖"的风格,会让静态工具更难做优化。如果希望更好的构建时优化,后面提到的ESM会是更好的选择。

UMD

UMD 是一种通用的模块定义规范,是AMD和CommonJS的一个糅合,旨在解决不同模块加载器和环境之间的兼容性问题。它的设计目标是使同一个模块可以在多种环境下使用,例如AMD是浏览器优先,异步加载;CommonJS是服务器优先,同步加载。

js 复制代码
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define([], factory);
  } else if (typeof module === 'object' && module.exports) {
    // CommonJS
    module.exports = factory();
  } else {
    // 全局变量
    root.myModule = factory();
  }
})(this, function () {
  return { add: (x, y) => x + y };
});

实现原理

UMD 的实现原理

  • 先检测当前环境是否支持 AMD 规范
    • 如果支持则采用 AMD 方式加载模块
    • 如果不支持,再检测是否支持 CommonJS 规范
      • 如果支持则采用 CommonJS 方式导出模块
      • 如果两者都不支持,再将模块暴露为一个全局变量。这样一来,无论在什么环境下,都能够正确地加载和使用 UMD 模块。

存在的问题

UMD 的出发点是好的,既能在 AMD、CJS、也能在浏览器全局下运行。它适合做对外发布的库,但作为源码风格并不优雅,且容易让现代构建获得不到最大化优化。

如果在做库发布,常见做法是源码写 ESM,然后通过构建链输出多种格式(ESM + CJS + UMD),兼顾现代开发和老环境用户。

新的时代答案:ESM

随着 ES6 发布,JS 原生支持了模块化,引入importexport关键字来定义和引用模块。ESM 提供了一种静态分析的模块加载方式,使得代码更易于优化和打包。

用法

导入导出

js 复制代码
// add.js导出
// 变量导出
export const PI = 3.1415;
// 命名导出,一个模块可以具有多个命名导出
export function add(x, y) {
  return x + y;
}
//默认导出,一个模块最多只有一个默认导出
export default function sqrt(x) { return Math.sqrt(x); }

// main.js导入
import { add } from './add.js';
console.log(add(1, 2));

动态导入
ESM 模块支持通过 import() 函数动态地导入模块。这对于条件加载模块、按需加载和代码拆分 非常有用。import() 返回一个 Promise 对象,使得可以在异步操作中使用。

js 复制代码
button.addEventListener('click', async () => {
  const { add } = await import('./math.js') // 在点击时才加载
  console.log(add(2, 3))
})

处理循环依赖

以下有两个模块,a.jsb.js,它们互相依赖:

js 复制代码
// a.js
import { b } from './b.js';
console.log('a.js', b);
export const a = 'A from a.js';

// b.js
import { a } from './a.js';
console.log('b.js', a);
export const b = 'B from b.js';

// console.log('b.js', a);
// ReferenceError: Cannot access 'a' before initialization

讲完了前端模块化的历史,相信聪明的你对前端模块化有了一个更全面的了解了吧~接下来趁热打铁看一道大厂面试题

CJS 和 ESM 区别是什么

  1. 用法不同

    • ES module 使用import/export关键字实现模块的导入和导出。
    • CJS 采用requiremodule.exports实现模块的导入和导出
  2. 加载方式不同

    • 编译时加载:ES6 模块不是对象,而是通过export显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为"编译时加载"
    • 运行时加载:CommonJS模块就是对象,即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,也就是"运行时加载"
  3. 导入和导出特性不同

    • ES module 支持异步导入,动态导入和命名导入等特性,可以根据需要动态导入导出,模块里面的变量绑定其所在的模块
    • CommonJs 只支持同步导入导出
  4. 循环依赖处理方式不同

    • ES module 采用链接+求值 的两阶段机制,在编译阶段 建立好导出变量和导入变量的绑定关系制造活绑定(live binding),通过使用模块间的依赖地图来解决死循环问题,标记进入过的模块为"获取中",所以循环引用时不会再次进入;但要注意变量在真正赋值前被读取很可能遇到TDZ(Temporal Dead Zone也就是我们熟知的暂时性死区),这种情况会导致报错/得到undefined
    • CJS 通过第一次被require时就会执行并缓存其 exports 对象。这样在循环引用中,CJS 就会提供一个部分导出对象(partial exports),从而打破无限循环,但可能导致运行时拿到不完整对象。如下,a 文件引用 b,b 文件引用 a
    css 复制代码
    main.js
       └──> a.js
             └──> b.js
                    └──> a.js (cached partial exports)
  5. 兼容性不同

    • ES module 需要在支持 ES6 的浏览器或者 Node.js 版本才能使用
    • 而 CJS 的兼容性会更好
  6. CommonJs 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用

    • CommonJs 模块输出的是值的拷贝(浅拷贝),也就是说,一旦输出一个值,模块内部的变化就影响不到这个值
    js 复制代码
    // cjs_snapshot.js
    let x = 1;
    module.exports = { x };  // 把当时的 x (1) 赋给 obj.x
    x = 2;
    
    // main.cjs
    const mod = require('./cjs_snapshot.js');
    console.log(mod.x); // 1 (不是 2)
    • ES6 模块的运行机制与 CommonJS 不一样,JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行的时候,再根据这个只读引用,到被加载到那个模块里面去取值。原始值变了,import 加载的值也会跟着变。因此,ES6 是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
    js 复制代码
    // counter.mjs
    export let count = 0;
    export function inc() { count++; }
    
    // main.mjs
    import { count, inc } from './counter.mjs';
    console.log(count); // 0
    inc();
    console.log(count); // 1  (live binding)
相关推荐
东东2337 分钟前
前端开发中如何取消Promise操作
前端·javascript·promise
掘金安东尼12 分钟前
官方:什么是 Vite+?
前端·javascript·vue.js
柒崽13 分钟前
ios移动端浏览器,vh高度和页面实际高度不匹配的解决方案
前端
渣哥29 分钟前
你以为 Bean 只是 new 出来?Spring BeanFactory 背后的秘密让人惊讶
javascript·后端·面试
烛阴38 分钟前
为什么游戏开发者都爱 Lua?零基础快速上手指南
前端·lua
大猫会长1 小时前
tailwindcss出现could not determine executable to run
前端·tailwindcss
Moonbit1 小时前
MoonBit Pearls Vol.10:prettyprinter:使用函数组合解决结构化数据打印问题
前端·后端·程序员
533_1 小时前
[css] border 渐变
前端·css
云中雾丽1 小时前
flutter的dart语言和JavaScript的消息循环机制的异同
前端
地方地方1 小时前
Vue依赖注入:provide/inject 问题解析与最佳实践
前端·javascript·面试