想知道前端模块化,CommonJS,ES Module

为什么要模块化

如果一个程序只有一个html文件,style标签里面写css,script标签里面写js代码,程序也是可以跑起来的。

html 复制代码
  <head>
    <style>
      /* 写css */
    </style>
  </head>
  <body>
    <!-- 写dom -->
    <script>
      // 写js
    </script>
  </body>

一开始前端没太复杂,只是做一些简单的表单验证和动画,把js代码都写到script标签这样做也是可以的,后面前端发展很快,代码量激增,如果还是代码都写在一个文件里面,维护麻烦,增加开发人员心智负担,要找一个变量,找一个函数,还得把这个文件上下滚来滚去,而且也会有性能问题,文件大了,资源下载速度势必变慢,白屏时间就长了。

所以要拆分文件,这里面除了不同开发人员写的文件,还有引入第三方库的文件,这又会产生其他问题,如有一个a.jsb.js文件,不小心都定义了同一个变量或者函数,就会出现命名冲突。如果b.js依赖a.js的变量或者函数,依赖关系也看不出来,可读性差

js 复制代码
// a.js
var name1 = "aaa";
function logName() {
  console.log("aaa");
}

// b.js
var name1 = "bbb";//覆盖
logName();
js 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./a.js"></script>
    <script src="./b.js"></script>
  </body>
</html>

什么是模块化

一个功能模块,一个文件就是一个模块,里面是相同功能的代码,有自己的内部实现,也可以提供接口暴露某些功能给外部使用

早期模块化方案:IIFE

js 复制代码
// moduleA.js  模块A
var moduleA = (() => {
  var aNumber = 1;
  function getANumber() {
    return aNumber;
  }
  return {
    aNumber,
    getANumber,
  };
})();

// moduleB.js 模块B
var moduleB = (() => {
  function getNumberFromA() {
    console.log(moduleA.getANumber());
  }
  return {
    getNumberFromA,
  };
})();

// moduleC.js  模块C
(() => {
  moduleB.getNumberFromA();
})();
html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>IIFE</title>
  </head>
  <body>
    <script src="./moduleA.js"></script>
    <script src="./moduleB.js"></script>
    <script src="./moduleC.js"></script>
  </body>
</html>

这种做法可以在一定程度上解决命名冲突问题,但也有缺点:

1、因为有依赖关系,script标签顺序必须要把被依赖的模块文件写在前面,而且也看不出依赖关系,如果文件出现♻️依赖,都不知道该把哪个文件的标签引用写在前面

2、用到一个模块就引入一个脚本,项目大了,会定义非常多的模块,浏览器会发出很多请求

3、多人开发,也有可能出现模块名冲突的问题

总之,IIFE不能很好解决命名冲突和代码量大难管理的问题,所以标准的模块化方案呼之欲出

社区标准:CommonJS

ES6(2015)推出ES Module模块化方案前,社区涌现了AMD,CMD,CommonJS等方案,如今,最流行的社区的模块化方案是在Node中实现的CommonJS

CommonJS是一个社区规范,最开始是在浏览器以外的环境使用,叫ServerJS,后面为了体现它的广泛性,改为CommonJS,也简称CJS。

node实现了CJS规范,浏览器可以通过 Browserify 来书写CJS格式的代码,webpack基于node运行,实现了对CJS的支持和转换。所以接下来的内容是CJS在node中的实现

在Node中引入模块,要经历:路径分析、文件定位、编译执行3个阶段。

在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块

核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。

文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

require查找规则

1、优先从缓存加载

不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。不同之处在于核心模块的缓存检查先于文件模块的缓存检查

2、路径分析

require()接受一个标识符作为参数,这个标识符为以下几种:

① 核心模块,如http、fs、path等。

② 以...开始的相对路径文件模块。

③ 以/开始的绝对路径文件模块

④ 非路径形式的文件模块,第三方库,如koa,axios模块。

核心模块:优先级仅次于缓存加载;路径形式的文件模块:require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块;非路径形式的文件模块:可能是一个文件也可能是包,查找最慢。

3、模块路径

Node在定位文件模块的具体文件时要先生成一个模块路径,是一个路径数组,

它的生成规则是:

① 当前文件目录下的node_modules目录。

② 父目录下的node_modules目录。

③ 父目录的父目录下的node_modules目录。

④ 沿路径向上逐级递归,直到根目录下的node_modules目录。

在加载的过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件为止。可以看出,当前文件的路径越深,模块查找耗时会越多,这是自定义模块的加载速度是最慢的原因。

4、定位文件

从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。但在文件的定位过程中,还有一些细节需要注意,这主要包括文件扩展名的分析、目录的处理。

文件扩展名分析

如果require(X)传入的参数没有扩展名,Node会按照.js, .json, .node的顺序补全扩展名。

目录和包分析

require()通过分析文件扩展名之后,可能没有查找到对应文件,但却得到一个目录,此时Node会将目录当做一个包来处理。

首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤;

首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤;

如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js、index.json、index.node

如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

5、模块查找总结

认识Module

Node 定义了一个构造函数 Module,所有的模块都是 Module 的实例

js 复制代码
// node/lib/internal/modules/cjs/loader.js
// 简化源码

function Module(id = "", parent) {
  this.id = id;
  this.path = path.dirname(id);
  setOwnProperty(this, "exports", {});
  moduleParentCache.set(this, parent);
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

// 缓存
Module._cache = { __proto__: null }
js 复制代码
// a.js
const abc = "abc";
exports.abc = abc;
console.log("a:", module);

// a: Module {
//   id: 'd:\\源码\\alianxi\\algorithm\\a.js',
//   path: 'd:\\源码\\alianxi\\algorithm',
//   exports: { abc: 'abc' },
//   filename: 'd:\\源码\\alianxi\\algorithm\\a.js',
//   loaded: false,
//   children: [],
//   paths: [
//     'd:\\源码\\alianxi\\algorithm\\node_modules',
//     'd:\\源码\\alianxi\\node_modules',
//     'd:\\源码\\node_modules',
//     'd:\\node_modules'
//   ]
// }

// b.js
const a = require("./a");
console.log("b:",module);

// b: Module {
//   id: '.',
//   path: 'd:\\源码\\alianxi\\algorithm',
//   exports: {},
//   filename: 'd:\\源码\\alianxi\\algorithm\\b.js',
//   loaded: false,
//   children: [
//     Module {
//       id: 'd:\\源码\\alianxi\\algorithm\\a.js',
//       path: 'd:\\源码\\alianxi\\algorithm',
//       exports: [Object],
//       filename: 'd:\\源码\\alianxi\\algorithm\\a.js',
//       loaded: true, //被引入加载过了
//       children: [],
//       paths: [Array]
//     }
//   ],
//   paths: [
//     'd:\\源码\\alianxi\\algorithm\\node_modules',
//     'd:\\源码\\alianxi\\node_modules',
//     'd:\\源码\\node_modules',
//     'd:\\node_modules'
//   ]
// }

require、exports、module、__filename、__dirname 从何而来

编译和执行是引入文件模块的最后一个阶段,每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。

每个模块文件中存在着require、exports、module、__filename、__dirname这5个变量,但是它们在模块文件中并没有定义,从何而来?

在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function (exports, require, module, __filename, __dirname) {,在尾部添加了\n}),这样每个模块文件之间都进行了作用域隔离

js 复制代码
// node/lib/internal/modules/cjs/loader.js 简化源码

const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});',
];

let wrapperProxy = new Proxy(wrapper, {
  __proto__: null,
  defineProperty(target, property, descriptor) {
    return ObjectDefineProperty(target, property, descriptor);
  },
});

ObjectDefineProperty(Module, 'wrapper', {
  __proto__: null,
  get() {
    return wrapperProxy;
  },
});

let wrap = function(script) { 
  return Module.wrapper[0] + script + Module.wrapper[1];
};

最后,wrap会被处理成一个function对象,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径__filename和文件目录__dirname作为参数传给这个function

exports和module.exports区别

exports是一个对象,可以往这个对象添加属性,它会被导出

js 复制代码
// a.js

let a = 1;

setTimeout(() => {
  exports.a = 2;
}, 1000);

exports.a = a;
js 复制代码
// b.js

// moduleA就是导出的对象exports,是同一个引用
const moduleA = require("./a");

console.log(moduleA.a);

setTimeout(() => {
  console.log(moduleA.a);
}, 2000);

平时更多是使用module.exports来批量导出

js 复制代码
let a = 1;
let b = "b";
let c = true;
function d() {
  console.log(d);
}

/** 使用exports导出 */
// exports.a = a;
// exports.b = b;
// exports.c = c;
// exports.d = d;

// 使用 module.exports导出 方式一
// 跟使用exports导出一样的
// module.exports.a = a;
// module.exports.b = b;
// module.exports.c = c;
// module.exports.d = d;

// 使用 module.exports导出 方式二
module.exports = { a, b, c, d };

module.exports和exports指向同一个引用地址,但是CJS中,模块真正导出的对象是module.exportsrequire真正引入的是module.exports这个对象

js 复制代码
// a.js
let a = 1;
let b = "b";
let c = true;
function d() {
  console.log(d);
}
// exports被重新赋值了一个引用地址,不再指向module.exports
// 最终被导出的对象module.exports里面没有属性,什么都没有导出
exports = { a, b, c, d };

// b.js
// moduleA指向的是module.exports对象
const moduleA = require("./a");
console.log("模块a:", moduleA);

模块执行、循环引用

不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。编译和执行是引入文件模块的最后一个阶段,每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。

模块在被第一次引入时,模块中的 js 代码会被执行一次,每个模块对象有一个 loaded 属性,加载了 loaded 就变为 true 了,后面的引入,就会直接返回缓存中该模块的module.exports。即使循环引用,也不会出现无限循环

Node加载模块是深度优先遍历

js 复制代码
// a.js
console.log("a-0");
require("./b");
require("./c");
console.log("a-1");

// b.js
console.log("b-0");
require("./c");
require("./d");
console.log("b-1");

// c.js
console.log("c-0");
require("./d");
require("./e");
console.log("c-1");

// d.js
console.log("d-0");
require("./e");
require("./f");
console.log("d-1");

// e.js
console.log("e-0");
require("./f");
require("./a");
console.log("e-1");

// f.js
console.log("f-0");
require("./b");
require("./a");
console.log("f-1");

CommonJS 为什么不适合浏览器

CommonJS 模块是同步加载的:

同步意味着需要等模块加载完毕后,后面的逻辑才会执行, 这个在服务端问题不大,因为服务器加载的是本地 JS 文件,等待时间就是硬盘的读取时间,速度会比较快,当在浏览器端,加载的模块在资源服务器,网速如果不行,浏览器会处于一个较长时间的"假死"状态

官方标准:ES Module

ES Module的语法可以查看阮一峰老师的 《ECMAScript 6 入门》 Module的语法

浏览器加载 ES6 模块

浏览器加载 ES6 模块,也使用<script>标签,但是要加入type="module"属性:

<script defer>

  • 下载时不会阻塞 HTML 解析;

  • 在DOM 解析之后执行;

  • defer保证多个脚本执行之间的相对顺序(如果它们都有src);

  • 阻止DOMContentLoaded事件;

  • 将 script 放在 body 的末尾与 script defer 具有相同的效果,但 script defer 使浏览器有机会提前下载和解析

<script async>

  • 下载时不会阻塞 HTML 解析;

  • 不等待 HTML 解析完成;可能会中断 DOM 构建(特别是当它从浏览器的缓存中获取服务时);

  • 无序执行;尽快执行;async不保证脚本执行之间的相对顺序

  • 阻止load事件(但不阻止DOMContentLoaded事件)

<script type='module'>

  • 等同于defer
  • 保证所有模块脚本(没有async的type='module')相对顺序
  • 仅执行一次 ,即使具有相同内容的脚本src被加载多次

<script type=module async>

  • 不保证脚本执行相对顺序
  • 不等待 HTML 解析完成;可能会中断 DOM 构建
  • defer 对模块脚本没有影响

循环加载

有时候可能在项目中不小心写了一个循环加载,但没有正确取到值报错。

通过import引入的是一个动态只读引用,等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。只要保证取值的时候有值就不会报错

js 复制代码
// a.js
import { b } from "./b.js";
console.log(b);
export let a = "a";

// b.js
import { a } from "./a.js";
console.log(a);
export let b = "b";

<script type="module" src="./a.js"></script>
js 复制代码
// 修改一下 a.js
// 让变量a在编译时赋值undefined
export var a = "a";

与 CommonJS差异

输出值不同

CommonJS输出的值是被复制后的基本数据类型,或者浅拷贝后的引用类型地址,所以输出的是一个新的值。模块内部数据改变不影响这个值

js 复制代码
// b.js
let a = "a";
let b = { c: "c" };
module.exports = {
  a,
  b,
};
setTimeout(() => {
  a = 1;
  b = 2;
}, 1000);

// a.js
const moduleB = require("./b");
console.log(moduleB);
setTimeout(() => {
  console.log(moduleB);
}, 2000);
js 复制代码
// b.js
let a = "a";
let b = { c: "c" };
module.exports = {
  a,
  b,
};
setTimeout(() => {
  console.log("moduleB:", a, b);
}, 2000);

// a.js
const moduleB = require("./b");
setTimeout(() => {
  moduleB.a = 1;
  moduleB.b = 2;
}, 1000);

但引用数据类型是浅拷贝,修改引用数据类型的属性,会改变这个输出值

js 复制代码
// b.js
let b = { c: "c" };
module.exports = { b };
setTimeout(() => {
  b.c = 1;
}, 1000);

// a.js
const moduleB = require("./b");
console.log(moduleB);
setTimeout(() => {
  console.log(moduleB);
}, 2000);

ES6 模块不一样,通过import引入的是一个动态只读引用,等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值.

动态 是因为ES6 模块不缓存运行结果,原始值发生变化,import加载的值也会发生变化。不论是基本数据类型还是复杂数据类型。

js 复制代码
// b.js
export let b = { c: "c" };
export let d = "111";
setTimeout(() => {
  b = 2;
  d = '222'
}, 1000);

// a.js
import { b, d } from "./b.js";
console.log(b, d);
setTimeout(() => {
  console.log(b, d);
}, 2000);

只读,即不允许修改引入变量的值,import的变量是只读的,不论是基本数据类型还是复杂数据类型

js 复制代码
// b.js
export let b = { c: "c" };
export let d = "111";

// a.js
import { b, d } from "./b.js";
d = 2;

CJS运行时加载,ES6编译输出接口

CommonJS 导出的module.exports是一个对象,该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

require()同步加载,import()异步加载

import是在编译时解析,缺点是只能放在模块顶层,如果放在if语句,只有运行时才能知道,所以不能动态加载,优点是可以用来做Tree-shaking

import()可以动态加载模块,可以在条件语句中使用,而且是异步

参考

《深入浅出Node.js》第二章 模块机制

require() 源码解读

《ECMAScript 6 入门》

gist.github.com/jakub-g/385...

html.spec.whatwg.org/multipage/s...

相关推荐
前端Hardy1 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
李老头探索10 分钟前
Java面试之Java中实现多线程有几种方法
java·开发语言·面试
web Rookie31 分钟前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust39 分钟前
css:基础
前端·css
帅帅哥的兜兜39 分钟前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
工业甲酰苯胺41 分钟前
C# 单例模式的多种实现
javascript·单例模式·c#
yi碗汤园42 分钟前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称42 分钟前
购物车-多元素组合动画css
前端·css
编程一生1 小时前
回调数据丢了?
运维·服务器·前端
丶21361 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web