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

🧩什么是模块

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

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

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

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

在早期的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)
相关推荐
雪中何以赠君别3 小时前
AMD、CMD 和 ES6 Module 的区别与演进
前端·javascript
禹曦a3 小时前
JavaScript性能优化实战指南
开发语言·javascript·性能优化
专注VB编程开发20年3 小时前
rust语言-对象多级访问
服务器·前端·rust
徐_三岁3 小时前
关于npm的钩子函数
前端·npm·node.js
代码小学僧3 小时前
🎉 在 Tailwind 中愉快的使用 Antd Design 色彩
前端·css·react.js
ssshooter3 小时前
复习 CSS Flex 和 Grid 布局
前端·css·html
青鱼入云4 小时前
java面试中经常会问到的mysql问题有哪些(基础版)
java·mysql·面试
_请输入用户名4 小时前
EventEmitter 是广播,Tapable 是流水线:聊聊它们的本质区别
前端·设计模式
爱学习的茄子4 小时前
React Fiber:让大型应用告别卡顿的性能革命
前端·react.js·面试