彻底理解前端模块化

模块化是一种处理复杂系统分解成为更好的可管理模块的方式,模块化开发最终的目的是将程序划分成一个个小的结构

  • 这个结构中编写属于自己的逻辑代码,有自己的作用域,定义变量名词时不会影响到其他的结构
  • 可以将自己希望暴露的变量、函数、对象等导出给其结构使用
  • 可以通过某种方式,导入另外结构中的变量、函数、对象等

引入

历史

  • 在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的,只需要将JavaScript代码写到<script>标签即可,没有必要放到多个文件中来编写

  • 随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了

    • ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染

    • SPA的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现

    • 包括Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤

  • 所以,模块化已经是JavaScript一个非常迫切的需求:

    • 但是JavaScript本身,直到ES6(2015)才推出了自己的模块化方案

    • 在此之前,为了让JavaScript支持模块化,社区出了很多不同的模块化规范:AMD、CMD、CommonJS

问题

早期没有模块化带来了很多的问题:比如命名冲突的问题,我们是使用 立即函数调用表达式(IIFE 来解决的,但也会有其他问题:

  • 必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用

  • 代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写

  • 在没有合适的规范情况下,每个人都可能会任意命名、甚至出现模块名称相同的情况

需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码,JavaScript社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们一一学习

CommonJS

CommonJS 是一种模块系统规范,主要用于在服务器端环境(如 Node.js)中管理模块。它提供了模块的定义、加载、导出机制,允许开发者在不同模块之间共享代码。Node.js 中,CommonJS 是默认的模块系统,虽然现在 Node.js 也支持 ECMAScript 模块,但 CommonJS 仍然广泛使用

  • 最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它 的广泛性,修改为CommonJS,平时也会简称为CJS

  • NodeCommonJS在服务器端一个具有代表性的实现,Node中对CommonJS进行了支持和实现

  • Browserify库是CommonJS在浏览器中的一种实现

  • webpack打包工具具备对CommonJS的支持和转换

  • Node中每一个js文件都是一个单独的模块

  • 这个模块中包括CommonJS规范的核心变量:exports、module.exports、require,可以使用这些变量来方便的进行模块化开发

    • exportsmodule.exports可以负责对模块中的内容进行导出

    • require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容

exports导出

exports是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出

// a.js
function add(num1, num2) {
  return num1 + num2;
}
const message = "hello world";
console.log(exports); // {}
exports.add = add;
exports.message = message;


// main.js
// const { add, message } = require("./a"); // 可以拿到文件中导出的exports对象,相当于引用赋值
// console.log(add(10, 30)); // 40
// console.log(message); // hello world

const a = require("./a");
console.log(a.add(10, 30)); // 40
console.log(a.message); // hello world

上面代码原理:
在这里插入图片描述

exports是一个对象,在内存中就会有个对象地址比如是0x100,那么exports就指向这个引用地址

当执行const a = require("./a")require就会找到a模块导出的exports,把exports的引用地址赋值给aaexports指向了同一个对象

也就意味着你在main.js两秒后修改了message的值,两秒后在a.js中获取时会变成你新改的值

module.exports导出

// b.js
function add(num1, num2) {
  return num1 + num2;
}
const message = "hello world";

// 方式一
// module.exports.add = add;
// module.exports.message = message;
// console.log(module.exports === exports); // true

// 方式二:开发中常用,module.exports赋值新对象更灵活方便
module.exports = {
  add,
  message,
};

// main.js
const b = require("./b");
console.log(b.add(10, 20)); // 30
console.log(b.message); // hello world

上面代码原理:

在这里插入图片描述

module.exportsexports有什么关系呢?

  • exportsmodule.exports 的简写,起初它们都指向同一个对象的引用地址

  • module.exports = exports = main.js中引入的变量

我们开发中常用的是module.exports,而且他俩还相等,那有exports还有什么意义那?

  • CommonJS中是没有module.exports的概念的,但Node要实现commonjs标准所以有exports,并且让exports = module.exports

  • 但为了实现模块的导出,Node中使用的是Module的类,每⼀个模块都是Module的⼀个实例也就是module

  • 所以Node中真正⽤于导出的其实不是exports,使用require导入时查找的本质也不是exports,而是module.exports

  • 也就是说module.exports可以通过赋值为一个新对象导出,但exports不行,因为改变了exports的引用没有用,node中找的还是module.exports

require导入

我们已经知道,require是⼀个函数,可以帮助我们引⼊⼀个⽂件(模块)中导出的对象

require的查找规则是怎么样的呢?导⼊格式如下:require(X)

  • X是⼀个Node核⼼内置模块,⽐如path、http:直接返回核⼼模块,并且停⽌查找

    console.log("path:", require("path"));
    console.log("http:", require("http"));
    

    在这里插入图片描述

  • X是以 ./..//(根⽬录)开头的

    • 第⼀步:将X当做⼀个⽂件在对应的⽬录下查找

      1. 直接查找⽂件X

      2. 查找X.js⽂件

      3. 查找X.json⽂件

      4. 查找X.node⽂件

    • 第⼆步:没有找到对应的⽂件,将X作为⼀个⽬录:查找⽬录下⾯的index⽂件

      1. 查找X/index.js⽂件

      2. 查找X/index.json⽂件

      3. 查找X/index.node⽂件

    • 如果没有找到,那么报错:not found

    在这里插入图片描述

  • 直接是⼀个X(没有路径),并且X不是⼀个核⼼模块

    • 我们可以看到它是会报错的
      在这里插入图片描述

    • 引入的hello我们可以在目录下建个node_modules里面再建个hello文件夹并包含index.js入口文件,这时require就可以找到了,这也是npm install 依赖 下载依赖的原理,那么axios我们就可以用npn install 下载
      在这里插入图片描述

    • 那么它的查找规律就是会先在当前目录的node_modules文件夹(必须有入口文件)中寻找

    • 没有找到的话,会再到上一级目录的node_modules文件夹中寻找,直到找到根目录还没有就会报错

加载过程

  • 模块在被第一次引入时,模块中的js代码会被运行一次,这个我们在上面的演习中就能发现

  • 模块被多次引入时,会缓存,最终只加载(运行)一次

    • 这是因为每个模块对象module都有一个属性loaded记录是否被加载过,默认为false
  • 如果有循环引入,那么加载顺序是什么?

    • 这个其实是一种数据结构:图结构

    • 图结构在遍历的过程中,有深度优先搜索DFS, depth first search)和广度优先搜索BFS, breadth first search

    • Node采用的是深度优先算法(在一层里面走到底)main -> aaa -> ccc -> ddd -> eee ->bbb
      在这里插入图片描述

缺点

  • CommonJS加载模块是同步的

    • 意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运⾏

    • 在服务器不会有什么问题,因为服务器加载的js⽂件都是本地⽂件,加载速度⾮常快

  • 如果将它⽤于浏览器呢?

    • 浏览器加载js⽂件需要先从服务器将⽂件下载下来,之后再加载运⾏

    • 那么采⽤同步的就意味着后续的js代码都⽆法正常运⾏,即使是⼀些简单的DOM操作

    • 在浏览器中,我们通常不使⽤CommonJS规范,在webpack中使⽤CommonJS是另外⼀回事

  • 在早期为了可以在浏览器中使⽤模块化,通常会采⽤AMDCMD

    • ⽬前⼀⽅⾯现代的浏览器已经支持ES Modules

    • 另⼀⽅⾯借助于webpack等⼯具可以实现对CommonJS或者ES Module代码的转换

    • AMDCMD已经使⽤⾮常少了

AMD规范(基本不用)

AMD主要是⽤于浏览器的⼀种模块化规范

  • AMD是Asynchronous Module Definition`(异步模块定义)的缩写,⽤的是异步加载模块

  • 事实上 AMD的规范还要早于CommonJS,但是CommonJS⽬前依然在被使⽤,⽽AMD使⽤的较少了

  • 规范只是定义代码的应该如何去编写,需有了具体的实现才能被应⽤,AMD实现的⽐较常⽤的库是require.jscurl.js

require.js使⽤

  • 下载require.js:下载地址:https://github.com/requirejs/requirejs 找到其中的require.js⽂件

  • 定义HTMLscript标签引⼊require.js和定义⼊⼝⽂件:

    • <script src="./lib/require.js" data-main="./index.js"></script>

    • data-main属性的作⽤是在加载完src的⽂件后会加载执⾏该⽂件

CMD规范(基本不用)

CMD规范也是⽤于浏览器的⼀种模块化规范:

  • CMDCommon Module Definition(通⽤模块定义)的缩写

  • ⽤的也是异步加载模块,但是它将CommonJS的优点吸收了过来,但是⽬前CMD使⽤也⾮常少了

  • CMD也有⾃⼰⽐较优秀的实现⽅案:SeaJS

SeaJS的使⽤

  • 下载SeaJS:下载地址:https://github.com/seajs/seajs 找到dist⽂件夹下的sea.js

  • 引⼊sea.js和使⽤主⼊⼝⽂件seajs是指定主⼊⼝⽂件的

ES Module

JavaScript没有模块化⼀直是它的痛点,所以才会产⽣我们前⾯学习的社区规范:CommonJS、AMD、CMD等,所以在 ECMA 推出⾃⼰的模块化系统时,⼤家也是很兴奋

  • ES Module模块采用 export负责将模块内的内容导出import负责从其他模块导入内容来实现模块化

  • ES Module模块允许编译器在编译时进行静态分析,也加入了动态引用的方式

  • 使用ES Module将自动采用严格模式:use strict

简单使用

  • 在浏览器中,ES Modules 通过 <script type="module"> 标签引入,来声明这个脚本是一个模块

    <!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="./main.js" type="module"></script>
      </body>
    </html>
    

    在这里插入图片描述

    但浏览器打开本地文件会报错,这个在MDN上面有给出解释:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules#其他模块与标准脚本的不同

    需要注意本地测试:如果你通过本地加载 HTML 文件(比如一个 file:// 路径的文件),你将会遇到 CORS 错误,因为 JavaScript 模块安全性需要。你需要通过一个服务器来测试

    这里使用的VSCode插件:Live Server,可以执行并打印main.js代码

  • Node.js 中使用 ES Modules(ESM)可以通过以下几种方式实现:

    • 如果你的模块文件使用 .mjs 扩展名,Node.js 会将其识别为 ES Module
    // example.mjs
    export const greeting = "Hello, World!";
    export function sayHello() {
      console.log(greeting);
    }
    
    // main.mjs
    import { sayHello } from './example.mjs';
    sayHello(); // 输出: Hello, World!
    
    • 如果你希望在整个项目中使用 ES Modules,而不仅仅是单个文件,可以在 package.json 文件中添加 "type": "module",所有 .js 文件都将被视为 ES Modules
    // package.json
    {
      "name": "my-project",
      "version": "1.0.0",
      "type": "module"
    }
    
    // example.js
    export const greeting = "Hello, World!";
    export function sayHello() {
      console.log(greeting);
    }
    
    // main.js
    import { sayHello } from './example.js';
    sayHello(); // 输出: Hello, World!
    

export关键字

export关键字将一个模块中的变量、函数、类等导出

  • 方式一:想导出谁就在语句声明的前面直接加上export关键字

  • 方式二:想导出谁则将需要导出的标识符,放到export后面的 {}

    • 注意:这里的 {} 里面不是ES6的对象字面量的增强写法,{} 也不是表示一个对象export { message: message } 是错误的写法;
  • 方式三:在方式二导出时给标识符起一个别名

// 方式一
export const message1 = "hello world1";
export function add1(num1, num2) {
  return num1 + num2;
}
export class Person1 {
  constructor(name) {
    this.name = name;
  }
}

// 方式二
const message2 = "hello world2";
function add2(num1, num2) {
  return num1 + num2;
}
class Person2 {
  constructor(name) {
    this.name = name;
  }
}
export { message2, add2, Person2 };

// 方式三
const message3 = "hello world3";
function add3(num1, num2) {
  return num1 + num2;
}
class Person3 {
  constructor(name) {
    this.name = name;
  }
}
export { message3, add3 as add0, Person3 as Person0 };

import关键字

import关键字负责从另外一个模块中导入内容

  • 方式一:import { 标识符列表 } from '模块'

    • 注意:这里的 {} 也不是一个对象,里面只是存放导入的标识符列表内容
  • 方式二:通过as关键字在导入时给标识符起别名

  • 方式三:通过 * as 自己名字 将模块功能放到一个模块功能对象上

// 结合export中的代码学习
import {
  message1, // 方式一
  message2,
  message3,
  add0 as add3, // 方式二
  add1,
  add2,
  Person0 as Person3,
  Person1,
  Person2,
} from "./a.js";

import * as a from "./a.js"; // 方式三

console.log(
  message1,
  message2,
  message3,
  add1,
  add2,
  add3,
  Person1,
  Person2,
  Person3,

  a.message1,
  a.message2,
  a.message3,
  a.add1,
  a.add2,
  a.add0,
  a.Person1,
  a.Person2,
  a.Person0
);

export和import结合

在开发和封装一个功能库时,通常希望将暴露的所有接口放到一个文件中,这样方便指定统一的接口规范也方便阅读,这个时候就可以使用exportimport结合使用

/* util/index 通常是不编写逻辑的,在这里统一导入并导出 */

// 方式一
import {
  message1,
  message2,
  message3,
  add0 as add3,
  add1,
  add2,
  Person0 as Person3,
  Person1,
  Person2,
} from "./a.js";
import { getData } from "./b.js";

export {
  message1,
  message2,
  message3,
  add3,
  add1,
  add2,
  Person3,
  Person1,
  Person2,
  getData,
};

// 方式二:结合
export {
  message1,
  message2,
  message3,
  add0 as add3,
  add1,
  add2,
  Person0 as Person3,
  Person1,
  Person2,
} from "./a.js";
export { getData } from "./b.js";

// 方式三:建议当有相应的文档时再这样写
export * from "./a.js";
export * from "./b.js";

default⽤法

前面学习的导出都是有名字的导出(named exports):在导出export时指定了名字,在导入import时需要知道具体的名字,文件只有一个想要导出的内容并且文件名已经概括时就可以使用默认导出(default export

  • 默认导出:在一个模块中,只能有一个默认导出

    • 默认导出export时可以不指定名字

    • 在导入时不需要使用 {},并且可以自己来指定名字

    • 也方便我们和现有的CommonJS等规范相互操作

    /* validMobile.js */
    // 方式一
    // function validMobile(str) {
    //   const reg = /^1[3-9]\d{9}$/;
    //   return reg.test(str);
    // }
    // export default validMobile;
    
    // 方式二
    export default (str) => {
      const reg = /^1[3-9]\d{9}$/;
      return reg.test(str);
    };
    
    /* main.js */
    import validMobile from "./validMobile.js";
    console.log(validMobile("12345678910")); // false
    

import函数

import静态的,这意味着在编译或打包阶段,模块依赖关系就已经确定了JavaScript 引擎需要在脚本开始执行之前分析所有的模块和依赖项,以便优化打包、代码分割、死代码消除等操作。如果需要根据不同的条件,动态来选择加载模块,这个时候我们需要使⽤ import() 函数

  • import()是异步的,返回的是个promise

  • 结合 export default 使用时,要用.default取值

/* a.js */
export function add(num1, num2) {
  return num1 + num2;
}

/* validMobile.js */
export default (str) => {
  const reg = /^1[3-9]\d{9}$/;
  return reg.test(str);
};

/* main.js */
import validMobile from "./validMobile.js";
console.log(validMobile("12345678910")); // false
if (validMobile("13626068779")) {
  // 结合 export
  import("./a.js").then((a) => {
    console.log(a.add(10, 20)); // 30
  });
} else {
  // 结合 export default
  import("./validMobile.js").then((v) => {
    console.log(v.default("13626068779")); // true
  });
}

import meta

import.meta是⼀个给JavaScript模块暴露特定上下⽂的元数据属性的对象,它包含了这个模块的信息,⽐如说这个模块的URL

在这里插入图片描述

  • url模块的完整 URL,包括查询参数和/或哈希(位于?或之后#)。在浏览器中,这是获取脚本的 URL(对于外部脚本)或包含文档的 URL(对于内联脚本)。在 Node.js 中,这是文件路径(包括file://协议)

  • resolve:使用当前模块的 URL 作为基础,将模块说明符解析为 URL

ES Module的解析

ES Module是如何被浏览器解析并且让模块之间可以相互引⽤的呢?

  • 可以看下这篇文章:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

  • ES Module的解析过程可以划分为三个阶段

    • 阶段⼀:构建(Construction),根据地址查找js⽂件,并且下载,将其解析成模块记录(Module Record

      1. 当浏览器(或 JavaScript 引擎)遇到 <script type="module">import 语句时,它会识别这是一个 ESM 模块

      2. 解析器会识别所有的 import 语句,并在构建依赖图的同时请求这些模块文件的下载

      3. 依赖图构建的过程中,浏览器会为每个模块分配一个唯一的模块记录,这个模块记录保留了模块的状态和依赖关系

      4. 模块环境记录可参考学习这篇文章:https://blog.csdn.net/qq_45730399/article/details/141196562?spm=1001.2014.3001.5501

    在这里插入图片描述

    • 阶段⼆:实例化(Instantiation),对模块记录进⾏实例化,并且分配内存空间,解析模块的导⼊和导出语句,把模块指向对应的内存地址

      1. 在所有依赖项(模块)都下载完成后,浏览器会执行模块链接。在此阶段模块的依赖关系会被处理,导入的模块和导出的符号都会被绑定起来

      2. 在这个过程中,浏览器不会执行模块的代码,只是检查模块之间的依赖关系,并将导入和导出的值关联起来

    • 阶段三:运⾏(Evaluation),运⾏代码,计算值,并且将值填充到内存地址中

      1. 关联完成后才会开始逐个执行模块的代码,模块执行后,模块的状态会更新,表明它已经执行完毕,之后如果再次请求该模块,执行的结果会直接从缓存中获取

    在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值