深入浅出前端模块化

138 阅读13分钟

1. AMD&&CMD 异步模块

本质上为了解决 commonJS 同步阻塞加载执行模块

  1. 多个 js 文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
  2. js 加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长

CMD

SeaJS 要解决的问题和 requireJS 一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同

// AMD
require(['a''b'], function(math) {
 a.doSomething();  
 b.doSomething();
});

// CMD
define(function(requireexportsmodule) {  
    var a = require('./a')  
    a.doSomething();  
    ...
    var b = require('./b')   // 依赖可以就近书写  
    b.doSomething();
})
  1. AMD 推崇依赖前置,在定义模块的时候就要声明其依赖的模块,AMD 在加载模块完成后就会执行该模块,所有模块都加载执行完后会进入 require 的回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。
  2. CMD 推崇就近依赖,CMD 加载完某个依赖模块后并不执行,只是下载,遇到 require 语句的时候才执行对应的模块。这样模块的执行顺序和书写顺序是完全一致的。

AMD 和 CMD 最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同

这也是很多人说 AMD 用户体验好,因为没有延迟,依赖模块提前执行了,CMD 性能好(按需加载),因为只有用户需要的时候才执行的原因

2. UMD

UMD(Universal Module Definition - 通用模块定义)模式,该模式主要用来解决 CommonJS 模式和 AMD 模式代码不能通用的问题,并同时还支持老式的全局变量规范。

// bundle.js
(function (global, factory) {
	typeof exports === "object" && typeof module !== "undefined"
		? (module.exports = factory())
		: typeof define === "function" && define.amd
		? define(factory)
		: ((global = global || self), (global.myBundle = factory()));
})(this, function () {
	"use strict";

	var main = () => {
		return "hello world";
	};

	return main;
});
  1. 判断module是否为一个对象,并且是否存在module.exports来判断是否为CommonJS规范
  2. 判断define为函数,并且是否存在define.amd,来判断是否为 AMD 规范,
  3. 如果以上两种都没有,设定为原始的代码规范。

3. CommonJS

早期 JavaScript 开发很容易存在全局污染依赖管理混乱问题

比如如何让先加载的模块调用后加载的模块

场景

  1. Node  是 CommonJS 在服务器端一个具有代表性的实现;
  2. webpack  打包工具对 CommonJS 的支持和转换;也就是前端应用也可以在编译之前,尽情使用 CommonJS 进行开发。

使用

  • commonjs 中每一个 js 文件都是一个单独的模块,我们可以称之为 module;
  • 该模块中,包含 CommonJS 规范的核心变量: exports、module.exports、require;
  • exports 和 module.exports 可以负责对模块中的内容进行导出;
  • require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;

实现原理

利用 wrapper 函数注入 require,export,module

第一步利用 wrapper 函数包装模块为新的函数字符串
function wrapper(script) {
	return (
		"(function (exports, require, module, __filename, __dirname) {" +
		script +
		"\n})"
	);
}

const modulefunction = wrapper(`
  const sayName = require('./hello.js')
    module.exports = function say(){
        return {
            name:sayName(),
            author:'dd'
        }
    }
`);
第二步利用runInThisContext可理解为 eval,执行 modulefunction
runInThisContext(modulefunction)(
	module.exports,
	require,
	module,
	__filename,
	__dirname
);
  • 模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行 modulefunction ,传入requireexportsmodule 等参数。最终我们写的 nodejs 文件就这么执行了。

首先从上述得知每个模块文件上存在 moduleexportsrequire三个变量,然而这三个变量是没有被定义的,但是我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们。在 nodejs 中还存在 __filename__dirname 变量。

如上每一个变量代表什么意思呢:

  • module 记录当前模块信息。
  • require 引入模块的方法。
  • exports 当前模块导出的属性

require 模块的引入和处理

CommonJS 模块同步加载并执行模块文件,CommonJS 模块在执行阶段分析模块依赖,采用深度优先后序遍历(depth-first traversal)

// a.js
const getMes = require("./b");
console.log("我是 a 文件");
exports.say = function () {
	const message = getMes();
	console.log(message);
};

// b.js
const say = require("./a");
const object = {
	name: "《b文件》",
	author: "Tommy",
};
console.log("我是 b 文件");
module.exports = function () {
	return object;
};

// index.js
const a = require("./a");
const b = require("./b");

console.log("node 入口文件");

// output
// 我是文件b
// 我是文件a
// node入口文件
  • main.js  和  a.js  模块都引用了  b.js  模块,但是  b.js  模块只执行了一次。
  • a.js  模块 和  b.js  模块互相引用,但是没有造成循环引用的情况。
  • 执行顺序是父 -> 子 -> 父; 参考下面 require 源码
require("./a.js"); // Module._cache[id] = module cache 模块a
// a.js 里面又去 第一行 require b.js
// 如果这个时候 b 又去循环引用 a.js
// return cachedModule.exports 返回a的 cache 只是空对象 因为a这时没有真正loaded

首先为了弄清楚上述两个问题。我们要明白两个感念,那就是 moduleModule

module :在 Node 中每一个 js 文件都是一个 module ,module 上保存了 exports 等信息之外,还有一个 loaded 表示该模块是否被加载。

  • false 表示还没有加载;
  • true 表示已经加载

Module :以 nodejs 为例,整个系统运行之后,会用 Module 缓存每一个模块加载的信息。

require 的源码

runInThisContext(modulefunction)(
	module.exports,
	require,
	module,
	__filename,
	__dirname
);
 // id 为路径标识符__dirname
function require(id) {
   /* 查找  Module 上有没有已经加载的 js  对象*/
   const  cachedModule = Module._cache[id]

   /* 如果已经加载了那么直接取走缓存的 exports 对象  */
  if(cachedModule){
    return cachedModule.exports
  }ry

  /* 创建当前模块的 module  */
  const module = { exports: {} ,loaded: false , ...}

  /* 将 module 缓存到  Module 的缓存属性中,路径标识符作为 id */
  /* a.js require b.js, b.js 又require a.js拿到 cachedModule.exports的 {} */
  Module._cache[id] = module
  
  /* 先创建再加载文件 */
  runInThisContext(wrapper('module.exports = "123"'))(module.exports, require, module, __filename, __dirname)
  /* 加载完成 *//
  module.loaded = true
  /* 返回值 */
  return module.exports
}

require 避免重复加载

将 module 缓存到 Module 上如果另外一个模块再次引用 a ,那么会直接读取缓存值 module ,所以无需再次执行模块。

require 避免循环引用

也是利用 Module 缓存,如果模块 loaded 了返回 loaded 的模块,否则返回创建了的临时空模块,先创建module再加载文件解决a.js 没有laoded但被b.js引用的问题

require 动态加载

console.log("我是 a 文件");
exports.say = function () {
	const getMes = require("./b");
	const message = getMes();
	console.log(message);
};

require 本质上就是一个函数,那么函数可以在任意上下文中执行,来自由地加载其他模块的属性方法。

exports 和 module.exports

module.exports 本质上就是 exports ,我们用 module.exports 来实现如上的导出。

为什么 exports={} 直接赋值一个对象就不可以呢,只能通过exports.xx

答 :exports 本质上是 module.exports; 传递给函数runInThisContext型参数,直接赋值相当于修改了函数参数指向的对象 ,wrapper 函数的参数不再指向 module.exports 而是 {}

 既然有了  exports,为何又出了  module.exports ?

答: 如上我们知道 exports 会被初始化成一个对象,也就是我们只能在对象上绑定属性,但是我们可以通过 module.exports 自定义导出出对象外的其他类型元素

 CommonJS 真的输出的是值的拷贝嘛?

答:commonjs的导出是值拷贝这句话是错误的,commonjs导出的是module.exports,commonjs的导入就是变量赋值。当module.exports的值是字符串、数字等原始类型时,赋值是值拷贝才会产生导出值改变不会导致导入值改变的现象,如果是引用类型其实是同一个引用

ES Modules

Common.js 不同的是 ,CommonJS  模块同步加载并执行模块文件,ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历

对于 ES 模块,这分三个步骤进行。

  1. 构建——查找、下载并将所有文件解析为模块记录。
  2. 实例化——在内存中找到用于存放所有导出值的盒子(但暂时不要填充值)。然后使导出和导入都指向内存中的这些盒子。这称为链接。
  3. 执行——运行代码,用变量的实际值填充框。
JavaScript 的执行过程

接下来我们要讲解 ESM 的模块导入,为了方便理解 ESM 的模块导入,这里需要补充一个知识点 —— JavaScript 的执行过程

JavaScript 执行过程分为两个阶段:

  • 编译阶段
  • 执行阶段
编译阶段

在编译阶段 JS 引擎主要做了三件事:

  • 词法分析
  • 语法分析
  • 字节码生成

这里不详情讲这三件事的具体细节,感兴趣的读者可以阅读 the-super-tiny-compiler 这个仓库,它通过几百行的代码实现了一个微形编译器,并详细讲了这三个过程的具体细节。

执行阶段

在执行阶段,会分情况创建各种类型的执行上下文,例如:全局执行上下文 (只有一个)、函数执行上下文。而执行上下文的创建分为两个阶段:

  • 创建阶段
  • 执行阶段

在创建阶段会做如下事情:

  • 绑定 this
  • 为函数和变量分配内存空间
  • 初始化相关变量为 undefined

我们日常提到的 变量提升 和 函数提升 就是在 创建阶段 做的,所以下面的写法并不会报错:

console.log(msg);
add(1, 2);

var msg = "hello";
function add(a, b) {
	return a + b;
}

JavaScript 常见错误类型

1. RangeError
function a() {
	b();
}
function b() {
	a();
}
a();

// out:
// RangeError: Maximum call stack size exceeded
2. ReferenceError
hello;

// out:
// ReferenceError: hello is not defined
3.SyntaxError
console.log(1));

// out:
// console.log(1));
//               ^
// SyntaxError: Unexpected token ')'

4. TypeError
var a = 1;
a();

// out:
// TypeError: a is not a function

上面的各种 Error 类型中,SyntaxError  最为特殊,因为它是  编译阶段  抛出来的错误,如果发生语法错误,JS 代码一行都不会执行。而其他类型的异常都是  执行阶段  的错误,就算报错,也会执行异常之前的脚本。

什么叫  编译时输出接口? 什么叫  运行时加载?

ESM 之所以被称为  编译时输出接口,是因为它的模块解析是发生在  编译阶段

也就是说,import  和  export  这些关键字是在编译阶段就做了模块解析,这些关键字的使用如果不符合语法规范,在编译阶段就会抛出语法错误。

console.log("hello world"); // 根本不会打印到不了执行阶段

if (true) {
	import { resolve } from "path";
}

// out:
//   import { resolve } from 'path';
//          ^
// SyntaxError: Unexpected token '{'

与此对应的 CommonJS,它的模块解析发生在  执行阶段,因为  require  和  module  本质上就是个函数或者对象,只有在  执行阶段  运行时,这些函数或者对象才会被实例化。因此被称为  运行时加载

import  和  export  的用法很像导入一个对象或者导出一个对象,但这和对象完全没有关系。他们的用法是 ECMAScript 语言层面的设计的,并且“恰巧”的对象的使用类似。

所以在编译阶段,import  模块中引入的值就指向了  export  中导出的值。如果读者了解 linux,这就有点像 linux 中的硬链接,指向同一个 inode。或者拿栈和堆来比喻,这就像两个指针指向了同一个栈。

ES Modules 如何解决循环依赖

它其实和前面提到的 CommonJS 的 Module._load 函数做的事情有些类似

为不同module创建上下文,分配内存,创建变量(export的总是变量)解决

  • 检查缓存,如果缓存存在且已经加载,则直接从缓存模块中提取相应的值,不做下面的处理
  • 如果缓存不存在,新建一个 Module 实例
  • 将这个 Module 实例放到缓存中
  • 通过这个 Module 实例来加载文件
  • 加载文件后到全局执行上下文时,会有创建阶段和执行阶段,在创建阶段做函数和变量提升(var 先提升定义,后赋值),接着执行代码。
  • 返回这个 Module 实例的 exports

创建阶段 就去在缓存中 去初始化好变量和内存空间,但没有执行,被循环引用也没问题 拿到undefined

Tree Shaking

ES Modules 之所以能 Tree-shaking 主要为以下四个原因(摘自尤雨溪在知乎的回答):

  1. import 只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面。
  2. import 的模块名只能是字符串常量。
  3. 不管 import 的语句出现的位置在哪里,在模块初始化的时候所有的 import 都必须已经导入完成。
  4. import bindingimmutable 的,类似 const。比如说你不能 import { a } from ‘./a’ 然后给 a 赋值个其他什么东西。
副作用
// effect.js
console.log(unused());
export function unused() {
	console.log(1);
}

// index.js
import { unused } from "./effect";
console.log(42);

此例子中 console.log(unused()); 就是副作用。在 index.js 中并不需要这一句 console.log。而 rollup 并不知道这个全局的函数去除是否安全。因此在打包地时候你可以显示地指定treeshake.moduleSideEffects 为 false,可以显示地告诉 rollup 外部依赖项没有其他副作用。

动态导出和静态编译区别?

动态导出,只有当模块运行后,才能知道导出的模块是什么。

var test = "hello";
module.exports = {
	[test + "1"]: "world",
};

静态编译, 在编译阶段就能知道导出什么模块。

export function hello() {
	return "world";
}

关于 ES6 模块编译时执行会导致有以下两个特点:

  1. import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行。
  2. export 命令会有变量声明提前的效果。

CommonJS VS ES Module

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。(js编译时)
  • CommonJS 模块的require()是同步加载,ES6 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段(编译时)。

兼容性与混合使用

原生支持情况

  • 浏览器: 所有主流现代浏览器(Chrome, Firefox, Safari, Edge)都已原生支持ESM。

  • Node.js:

    • Node.js v13.2.0开始正式支持ESM(需开启flag)。

    • v14.x及以后版本,ESM的支持已非常稳定,无需flag。

    • NodeJS 有了这样的规矩

      • .mjs 必定是 esm 模块
      • .cjs 必定是 cjs 模块
      • .js 则由离他最近的 pkg.type 决定

1. 在ESM中导入CJS模块

这相对简单。ESM的import语句可以直接导入CJS模块。

// main.mjs
import _ from 'lodash'; // lodash是CJS模块
import myCjsModule from './legacy.cjs';

console.log(_.chunk([1, 2, 3, 4], 2));
myCjsModule.doSomething();

注意:CJS模块的module.exports会被当作ESM的export default来处理。

2. 在CJS中导入ESM模块

这是更具挑战性的场景,因为require()是同步的,而ESM是异步加载的。不能使用 require() 直接导入ESM模块

解决方案:使用动态import()函数。import()返回一个Promise。

// main.js (CJS)
async function loadEsmModule() {
  try {
    const { someExport } = await import('./my-module.mjs');
    someExport();
  } catch (err) {
    console.error('Failed to load ESM module:', err);
  }
}

loadEsmModule();

前端💩史学:.esm.js

这段过渡期,有些工具支持了 .mjs,有一些还只认 .js.esm.js 也是 .js)。为了兼容尽可能多的工具,很多包开发者就会选择这么写:

{
    "main": "index.js", // cjs
    "module": "index.esm.js" // esm,前面说过这个 module 也是共识产物,没纳入 npm
}

到后来有了 pkg.exports,但为了兼容起见,还是有人会保留 .esm.js

{
    "main": "index.js",
    "module": "index.esm.js",
    "exports": {
        ".": {
            "import": "./dist/index.mjs",
            "module": "./dist/index.esm.js",
            "require": "./dist/index.js"
        }
    }
}