LOADING

模块化

2023/3/12

为什么需要模块化

模块化解决的问题

  • 全局变量污染
  • 依赖混乱 (复杂的依赖关系无法处理)
  • 代码文件难以细分

模块化出现后,我们就可以把臃肿的代码细分到各个小文件中,便于后期维护管理,也可以提高代码复用率

通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数

CommonJS

global 是 node 的全局变量(类似于 window)

node 天生支持 CommonJS 模块化标准

node 规定:

  1. 每个模块文件上存在 module,require,exports, **filename , **dirname 五个变量,这五个变量我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们
  • module 记录当前模块信息。
  • require 引入模块的方法。
  • exports 当前模块导出的属性
  • __filename 当前文件的绝对路径
  • __dirname 当前文件的绝对路径(目录)
  1. node 中的每个 js 文件都是一个 commonJS 模块,通过 node 命令运行的模块,叫做入口模块
  2. 模块中的所有全局定义的变量、函数,都不会污染到其他模块
  3. 模块可以暴露(导出)一些内容给其他模块使用,需要暴露什么内容,就在模块中给 module.exports 赋值一个模块可以导入其他模块,使用函数require("要导入的模块路径")即可完成,该函数返回目标模块的导出结果

导入模块时,可以省略.js

导入模块时,必须以./../开头(导入 node_module 包的时候引入采用无前缀)

  1. 一个模块在被导入时会运行一次,然后它的导出结果会被 node 缓存起来,后续对该模块导入时,不会重新运行,直接使用缓存结果

使用

common.js 可以导出同步函数,也可以导出异步函数

//math.js
console.log("math run");
function isOdd(n) {
  return n % 2 !== 0;
}

function sum(a, b) {
  return a + b;
}
//导出
module.exports = {
  isOdd,
  sum,
};

//index.js
//引入(.js可省略,也可以不省略)
const math = require("./math"); // 返回 { isOdd: fn,  sum: fn }
console.log(math.sum(1, 2));

原理

require 文件加载流程

首先我们看一下 nodejs 中对标识符的处理原则。

  • 首先像 fs ,http ,path 等标识符,会被作为 nodejs 的核心模块
  • ./../ 作为相对路径的文件模块/ 作为绝对路径的文件模块
  • 非路径形式也非核心模块的模块,将作为自定义模块

核心模块的处理:

核心模块的优先级仅次于缓存加载,在 Node 源码编译中,已被编译成二进制代码,所以加载核心模块,加载过程中速度最快。

路径形式的文件模块处理:

./..// 开始的标识符,会被当作文件模块处理。require() 方法会将路径转换成真实路径,并以真实路径作为索引,将编译后的结果缓存起来,第二次加载的时候会更快。至于怎么缓存的?我们稍后会讲到。

自定义模块处理: 自定义模块,一般指的是非核心的模块,它可能是一个文件或者一个包,它的查找会遵循以下原则:

  • 在当前目录下的 node_modules 目录查找。
  • 如果没有,在父级目录的 node_modules 查找,如果没有在父级目录的父级目录的 node_modules 中查找。
  • 沿着路径向上递归,直到根目录下的 node_modules 目录。
  • 在查找过程中,会找 package.json 下 main 属性指向的文件,如果没有 package.json ,在 node 环境下会以此查找 index.js ,index.json ,index.node。

require 模块引入与处理

CommonJS 模块同步加载并执行模块文件,CommonJS 模块在执行阶段分析模块依赖

有以下特点:

  1. 采用深度优先遍历(depth-first traversal),执行顺序是父 -> 子 -> 父
  2. 不会循环引用(依赖)
  3. 每个模块只会被加载一次(有缓存)
//main.js
const a = require('./a')
const b = require('./b')

console.log('node 入口文件')

//a.js
const getMes = require('./b')
console.log('我是 a 文件')


//b.js
const say = require('./a')
console.log('我是 b 文件')


输出
我是 b 文件
我是 a 文件
node 入口文件

commonjs 实现原理

commonjs 每个模块实际上都是在一个函数环境下执行的

在编译的过程中,实际 Commonjs 对 js 的代码块进行了首尾包装,它被包装之后的样子如下:

(function (exports, require, module, __filename, __dirname) {
  const sayName = require("./hello.js");
  module.exports = function say() {
    return {
      name: sayName(),
      author: "我不是外星人",
    };
  };
});

在 Commonjs 规范下模块中,会形成一个包装函数,我们写的代码将作为包装函数的执行上下文,使用的 <font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">require</font><font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">exports</font><font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">module</font> 本质上是通过形参的方式传递到包装函数中的。

我们写的所有 commonJs 代码都会在函数内运行,当我们在模块中打印函数特有的 arguments 的时候,我们可以拿到参数

将首尾包装抽离成一个函数

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:'我不是外星人'
        }
    }
`);

在执行过程中;在模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行 module function ,传入requireexportsmodule 等参数。最终我们写的 nodejs 文件就这么执行了。

runInThisContext(modulefunction)(
  module.exports,
  require,
  module,
  __filename,
  __dirname
);

require 的源码

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

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

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

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

从上面我们总结出一次 require 大致流程是这样的;

  • require 会接收一个参数——文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,加下来会从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。
  • 如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件,加载完文件,将 loaded 属性设置为 true ,然后返回 module.exports 对象。借此完成模块加载流程。
  • 模块导出就是 return 这个变量的其实跟 a = b 赋值一样, 基本类型导出的是值, 引用类型导出的是引用地址。
  • exports 和 module.exports 持有相同引用,因为最后导出的是 module.exports, 所以对 exports 进行赋值会导致 exports 操作的不再是 module.exports 的引用。

require 避免重复加载

从上面我们可以直接得出,require 如何避免重复加载的,首先加载之后的文件的 module 会被缓存到 Module 上,比如一个模块已经 require 引入了 a 模块,如果另外一个模块再次引用 a ,那么会直接读取缓存值 module ,所以无需再次执行模块。

对应 demo 片段中,首先 main.js 引用了 a.jsa.js 中 require 了 b.js 此时 b.js 的 module 放入缓存 Module 中,接下来 main.js 再次引用 b.js ,那么直接走的缓存逻辑。所以 b.js 只会执行一次,也就是在 a.js 引入的时候。

require 避免循环引用

那么接下来这个循环引用问题,也就很容易解决了。为了让大家更清晰明白,那么我们接下来一起分析整个流程。

  • ① 首先执行 node main.js ,那么开始执行第一行 require(a.js)
  • ② 那么首先判断 a.js 有没有缓存,因为没有缓存,先加入缓存,然后执行文件 a.js (需要注意 是先加入缓存, 后执行模块内容);
  • ③ a.js 中执行第一行,引用 b.js。
  • ④ 那么判断 b.js 有没有缓存,因为没有缓存,所以加入缓存,然后执行 b.js 文件。
  • ⑤ b.js 执行第一行,再一次循环引用 require(a.js) 此时的 a.js 已经加入缓存,直接读取值。接下来打印 console.log('我是 b 文件'),导出方法。
  • ⑥ b.js 执行完毕,回到 a.js 文件,打印 console.log('我是 a 文件'),导出方法。
  • ⑦ 最后回到 main.js,打印 console.log('node 入口文件') 完成这个流程。

module.exports缺陷

当导出一些函数等非对象属性的时候,也有一些风险,就比如循环引用的情况下。对象会保留相同的内存地址,就算一些属性是后绑定的,也能间接通过异步形式访问到。但是如果 module.exports 为一个非对象其他属性类型,在循环引用的时候,就容易造成属性丢失的情况发生了。

动态加载

require 可以在任意的上下文,动态加载模块。

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

this,exports,module.exports

刚开始 this,exports,module.exports 变量指向同一个东西,都是同一个空对象

最终导出的是module.exports

循环依赖

//index.js
var a = require("./a");
console.log("入口模块引用a模块:", a);

// a.js
var b = require("./b");
console.log("a模块引用b模块:", b);
exports.a = "修改值-a模块内变量";

// b.js
var a = require("./a");
console.log("b模块引用a模块", a);
exports.b = "原始值-b模块内变量";

输出结果如下

这种 AB 模块间的互相引用,本应是个死循环,但是实际并没有,因为 CommonJS 做了特殊处理——模块缓存。

上面就是对循环引用的处理过程,循环引用无非是要解决两个问题,怎么避免死循环以及输出的值是什么。CommonJS 通过模块缓存来解决:每一个模块都先加入缓存再执行,每次遇到 require 都先检查缓存,这样就不会出现死循环;借助缓存,输出的值也很简单就能找到了

总结

解决了变量污染,文件依赖等问题,commonjs 是动态导入(代码发生在运行时)

ES Module

浏览器中的使用ES Module

在script标签中加入type类型,这个模块就是启动模块
<script src="./index.js" type="module"></script>

index.js

var a = 1;
console.log(a);

//此时浏览器window.a也不会有值,不会污染全局变量

使用

/** 定义模块 math.js **/
var basicNum = 0;
var add = function (a, b) {
  return a + b;
};
export { basicNum, add };

/** 引用模块 **/
import { basicNum, add } from "./math";
function test(ele) {
  ele.textContent = add(99 + basicNum);
}

导出

ES Module 分为两种导出方式:

  • 具名导出(普通导出),可以导出多个
  • 默认导出,只能导出一个

一个模块可以同时存在两种导出方式,最终会合并为一个「对象」导出

ES Module 的导出类似导出一个对象

export const a = 1; // 具名,常用
export function b() {} // 具名,常用
export const c = () => {}  // 具名,常用
const d = 2;
export { d } // 具名(具名导出一个d的方式,因为d在前面已经定义了,所以必须采用这样的
方式)
const k = 10
export { k as temp } // 具名(改变导出变量的key)

//默认导出只能导一次,没有覆盖关系(重复默认导出会报错)
// export default 3 // 默认,常用(默认导出不需要名字,只需要一个值)
// export default function() {} // 默认,常用
// const e = 4;
// export { e as default } // 默认

const f = 4, g = 5, h = 6
export { f, g, h as default} // 基本 + 默认

// 以上代码将导出下面的对象
/*
{
    a: 1,
    b: fn,
    c: fn,
    d: 2,
    temp: 10,
    f: 4,
    g: 5,
    default: 6
}
*/

混合导出

可以使用exportexport default同时使用并且互不影响,只需要在导入时地方注意,如果文件里有混合导入,则必须先导入默认导出的,再导入单个导入的值。

export const name = "蛙人"
export const age = 24

export default {
    fn() {},
    msg: "hello 蛙人"
}

导出导入代码必须为顶级代码,即不可放到代码块中

导入或者导出的代码不能放入判断,循环,函数体内

ES6 的模块不是对象,import命令会被 JavaScript 引擎静态分析,在编译时就引入模块代码,而不是在代码运行时加载,所以无法实现条件加载。

if (true) {
    import xxx from 'XXX' // 报错
}

导入

导入(import)一个模块,相当于是运行一个模块,得到模块导出结果

导入可以针对性的导入,不是说导出多少个一定要用完

针对具名导出和默认导出,有不同的导入语法

// 仅运行一次该模块,不导入任何内容(会缓存)
import "模块路径"

//这句话可以不写在顶级代码里,动态加载
//跟仅运行一次该模块就差一个() 有括号就是动态加载
import("模块路径") // 动态导入,返回一个Promise,完成时的数据为模块对象

// 常用,导入属性 a、b,放到变量a、b中。a->a, b->b
import { a, b } from "模块路径" (这里要对应导出的名字)

// 常用,导入属性 default,放入变量c中。default->c
import c from "模块路径"

// 常用,default->c,a->a, b->b
import c, { a, b } from "模块路径"

// 常用,将模块对象放入到变量obj中(将具名和默认的变量全部导出)
import * as obj from "模块路径"

// 导入属性a、b,放到变量temp1、temp2 中
import {a as temp1, b as temp2} from "模块路径"

// 导入属性default,放入变量a中,default是关键字,不能作为变量名,必须定义别名
import {default as a} from "模块路径"

//导入属性default、b,放入变量a、b中
import {default as a, b} from "模块路径"
// 以上均为静态导入

动态加载

在 JavaScript 中,可以使用 ES6 模块的动态加载特性来实现模块的动态加载。这通常是通过import()函数实现的,它返回一个 Promise 对象。

下面是一个使用import()动态加载模块的例子:

// 假设我们有一个module.js文件,内容如下:
// module.js
export function hello() {
  return "Hello, World!";
}

// 我们可以在另一个脚本中动态加载这个模块:
// main.js
let modulePath = "./module.js"; // 模块的路径

// 使用import()函数动态加载模块
import(modulePath)
  .then((module) => {
    // 使用模块中导出的功能
    console.log(module.hello());
  })
  .catch((err) => {
    // 处理加载模块时的错误
    console.error(err);
  });

在上面的代码中,import()函数用于动态加载./module.js模块。加载成功后,会输出Hello, World!。如果加载失败,会捕获到错误并打印出来。这种方式可以用于按需加载模块,或者根据不同的条件动态加载不同的模块。

原理

动态加载是异步的,后续再发 ajax 请求,一开始打包中不会有

特性

静态语法

ES6 module 的引入和导出是静态的,<font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">import</font> 会自动提升到代码的顶层 ,<font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">import</font> , <font style="color:rgb(255, 80, 44);background-color:rgb(255, 245, 245);">export</font> 不能放在块级作用域或条件语句中。

也可以这样 import(“模块路径”) 动态依赖,返回一个 Promise,完成时的数据为模块对象(异步)

执行特性

所有的加载模块都会被提前

CommonJS 模块同步加载并执行模块文件,ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历,执行顺序是子 -> 父。

//main.js
console.log('main.js开始执行')
import say from './a'
import say1 from './b'
console.log('main.js执行完毕')

//a.js
import b from './b'
console.log('a模块加载')
export default function say (){
    console.log('hello , world')
}

//b.js
console.log('b模块加载')
export default function sayhello(){
    console.log('hello,world')
}

执行结果

b 模块加载

a 模块加载

main.js 开始执行

main.js 执行完毕

说明了es module 的加载是静态,并且每个模块也只会被加载一次,因为b 模块加载,a 模块加载被提前了

导出绑定

export导出的值是值的引用,并且内部有映射关系,这是export关键字的作用。而且导入的值,不能进行修改(只读状态)。

// index.js
export let num = 0;
export function add() {
    ++num
}


import { num, add } from "./index.js"
console.log(num) // 0
add()
console.log(num) // 1
num = 10 // 抛出错误


//但可以这样修改
import { num , addNumber } from './a'
console.log(num) // num = 1
addNumber()
console.log(num) // num = 2

总结

使用 import 被导入的模块运行在严格模式下。

使用 import 被导入的变量是只读的,可以理解默认为 const 装饰,无法被赋值

使用 import 被导入的变量是与原变量绑定/引用的,可以理解为 import 导入的变量无论是否为基本类型都是引用传递。

循环依赖

esm 的 import 命令是在编译阶段就执行,优先于自身内容执行。

esm 并不关心是否存在循环引用,只是生成一个指向被加载模块的引用,代码未执行时,这个引用的值就是 undefined。

总结

Es Module也是解决了变量污染问题,依赖顺序问题,Es Module语法也是更加灵活,导出值也都是导出的引用,导出变量是可读状态,这加强了代码可读性。

ES6 模块与 CommonJS 模块的差异

共同点

不会污染全局变量

保证依赖顺序

可以细分与复用代码

不同点

  1. 动态与静态

Es Module:静态,不可以动态加载语句,只能声明在该文件的最顶部,在代码运行之前,就需要分析出所有的依赖关系,这使得 Es Module 可以进行树摇优化(tree shaking)

也可以动态依赖(异步)

CommonJs:代码发生在运行时,动态依赖(需要代码运行后才能确定依赖),动态依赖是同步执行的(比如 I/O 操作需要一年就等一年)

CommonJs 导出值是拷贝,可以修改导出的值,这在代码出错时,不好排查引起变量污染

const getMes = require("./b");
console.log("我是 a 文件");
module.exports = { b: 2 };

const express = require("express");
const a = require("./a");
const b = require("./b");
a.b = 3; //可以修改
console.log(a, "a");

Es Module 导出的是引用值,并且值都是只读的,不能修改

import data, { add } from "./3.js";
data = 2; //不能修改,会报错

符号绑定

import data, { add } from "./3.js";
console.log(data); //4
add();
console.log(data); //6

let e = 4;
function add() {
  e = e + 2;
  return e;
}

export { e as default, add }; // 默认

在这种情况下,导入 data 数据发生了变化,因为 Es Module 中,e 和 data 这两个数据使用的是同一块内存空间,这种情况叫做符号绑定(一块内存但他有多个符号,e 和 data 变量都指向他)这种情况在 js 语言中绝无仅有

注意:只有导入的时候是符号绑定,后续任何的操作(赋值)都会开辟新的内存空间

所以具名导出一定是要是常量,如果导出的是对象,那他就是可变的(引用类型),如果是默认导出,那他本身就是一个对象,就是可变的

正常逻辑

let a = 1;
let b = a;
a = 2; // 这时候修改a 不会影响b 因为a,b用的是两块内存空间,但是在es module中用的是同一块内存空间

3.标准不同

CommonJS,简称 CMJ,这是一个社区规范,出现时间较早,使用函数实现,目前仅 node 环境支持

ES Module,简称 ESM,这是随着 ES6 发布的官方模块化标准,使用语法实现,目前浏览器和新版本 node 环境均支持

4.顶层 this

commonjs 的 this 指向当前模块导出对象

ES Module 是 undefined

5.隔离变量的方式

CommonJS 是通过函数

ES Module 是官方语法

AMD

amd 是专门为浏览器所设计的

AMD 规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。这里介绍用 require.js 实现 AMD 规范的模块化:用 require.config()指定引用路径等,用 define()定义模块,用 require()加载模块。

1.加载模块

首先我们需要引入 require.js 文件和一个入口文件 main.js。main.js 中配置 require.config()并规定项目中用到的基础模块。

/** 网页中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>

/** main.js 入口文件/主模块 **/
// 首先用config()指定各模块路径和引用名
require.config({
  baseUrl: "js/lib",
  paths: {
    "jquery": "jquery.min",  //实际路径为js/lib/jquery.min.js
    "underscore": "underscore.min",
  }
});
// 执行基本操作
require(["jquery","underscore"],function($,_){
  // some code here
});

2.定义模块

// name和deps都是非必选的参数,而callback可以是一个对象,或者是具有返回值的函数
define([name], [deps], callback)

存在依赖的模块

假设你要写一个依赖 jquery 的模块,那么你需要在 define 方法中声明依赖。

define(['jquery'], function($) {
    function setColor(select, color) {
        $(select).css('color', color)
    }
    return {
        setColor: setColor
    }
})

另一种方法

define(function(require, exports, module) {
    var $ = require('jquery')
    function setColor(select, color) {
        $(select).css('color', color)
    }
    return {
        setColor: setColor
    }
})

3.使用模块

require(['simple', 'jquery', 'funcModule', 'depModule'], function(simple, $, funcModule, depModule) {
    console.log(simple)
    console.log($)
    $('.word').css({
        fontSize: '24px',
        color: 'blue'
    })
    var result = funcModule.add(1,2)
    console.log(result)

    depModule.setColor('.word', 'yellow')
})