30.JS高级-错误处理方案以及JS模块化详解

671 阅读1小时+

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:coderwhy666,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列185-205集(coderwhy)
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 在本章节中,我们会系统学习异常的种类以及如何处理这些抛出的异常会更合理
  • JS模块化是前端绕不过的里程碑,真实的大型项目正是基于模块才得以发展,在没有模块化的时间中,我们都是如何解决这些问题的?社区的CommonJS、AMD、CMD的模块化规范都是怎么样的?对应都有哪些技术实现了这些规范
    • ES6新增的ES Module规范和社区已经实现的规范又有何不同,它们都是如何使用的?使用过程中会遇到哪些问题?导入文件要是有名称相同的文件,应该选择哪一个导入?导入内容要是与原有内容产生冲突怎么办?导入的内容被改变是否会影响到原有模块?
    • ESModule又是如何解析的,分为几个阶段?我们会深入该ESM规范中,深入探索其实现的原理
    • 在ESModule规范横空出世后,原有使用CommonJS等规范的项目,要如何以尽可能小的代价迁移过去,迁移与付出要如何平衡其收支,这些都是我们会学习到的

异常处理

在JavaScript的发展历史中,错误处理的进化体现了Web技术的成熟和开发者社区对更健全、更易维护代码需求的响应,在JS最初被发明时的那段时间主要用于执行浏览器中简单的脚本任务,处理错误非常基础,多依赖于简单的检查和静默失败,即代码在遇到问题时可能无任何提示地停止执行

  • 但语言是会进步的,由人的需求与渴望所改变,这个过程经历了1999年try...catch的引入,2009年标准化的Error对象,2015年的异步编程和Promise,2017年的async/await,到如今的现代框架各自封装的错误处理策略
  • 大家可以发现,在该系列文章中,我们已经领略过20年的JS发展历程,回头一看,是否会感叹时间的变迁和JS的生机呢?

函数的错误处理方案

  • 函数的参数能够接收多种多样的内容,在强类型语言(例如C语言)中可以对传入与传出都进行限制操作,但在JS中,往往是无法限制的,这时候就需要在使用传入数据时,对数据进行一次校验,以确保这是我们所需的格式
  • 假如我们有一个函数,该函数的作用时计算传入的两数之和,这就需要传入数据是一个数字类型
    • 如果传入的是一个数字,一个对象,或者干脆就是两个对象,这是没办法正确相加的,也超出了这个函数本身的能力
    • 在编程语言中,做出不符合预期的事情时,往往会产生不可预期的结果,这些都是我们需要避免的,因为计算机中有一个明确的规范,所有内容都有清晰的界限
    • 因此我们可以对传入的内容做出明确的if判断,要求传入的必须是一个数字,不符合要求则认定这是一次错误的调用,使用throw关键字抛出一个错误
/**
 * 如果我们有一个函数, 在调用这个函数时, 如果出现了错误, 那么我们应该是去修复这个错误.
 */

function sum(num1, num2) {
  // 当传入的参数的类型不正确时, 应该告知调用者一个错误
  if (typeof num1 !== "number" || typeof num2 !== "number") {
    // return undefined
    throw "parameters is error type~"
  }

  return num1 + num2
}

// 调用者(如果没有对错误进行处理, 那么程序会直接终止,后续代码将不会继续执行)
// console.log(sum({ name: "coderwhy" }, true))
console.log(sum(20, 30))
console.log("后续的代码会继续运行~")
  • 我们可能会想,我又不会故意传入不符合需求的内容,为什么需要这样做?
    • 首先人不可能100%不出错,这是给自己留余地的做法,并且在开发中我们会封装一些工具函数,封装之后给别人使用
    • 在其他人使用的过程中,可能会传递一些参数,他们不知道这个函数内部是如何实现的,对他们所传入的参数,进行验证是必要的,否则可能得到的是不想要的结果
  • 很多时候我们可能验证到不是希望得到的参数时,就会直接return:
    • 但是return存在很大的弊端:调用者不知道是因为函数内部没有正常执行,还是执行结果就是一个undefined
    • 事实上,正确的做法应该是如果没有通过某些验证,那么应该让外界知道函数内部报错了
function foo(num) {
  if (typeof num !== 'number') {
    return
  }
  return '正确结果:undefined'
}
const num = 6
console.log(foo(num));//undefined是报错还是返回的结果就是undefined?

抛出异常的throw关键字

  • 如何可以让一个函数告知外界自己内部出现了错误呢?
    • 通过throw关键字,抛出一个异常
    • throw语句用于抛出一个用户自定义的异常,当遇到throw语句时,当前的函数执行会被停止(throw后面的语句不会执行),如果我们执行代码,就会报错,拿到错误信息的时候我们可以及时的去修正代码
  • throw关键字能够抛出来很多东西,在正常情况下,我们会抛出来一个字符串类型或者对象类型的错误
    • 所有的基础类型都是可以抛出的,取决于我们的具体需求
    • 而对象类型错误,通常由两部分信息组成,错误码以及错误信息,错误信息我们知道,那错误码是什么?
    • 通用的错误码有网络状态码,例如403,404,500等,也有由公司所统一规范的错误码,每一个错误码的背后都辐射着一个含义信息,通常代表了一个系列的问题标题,能帮助我们以最快的速度定位问题的大致范围,在这个基础上去进行理解错误信息会更易于理解
function foo(type) {
  console.log("foo函数开始执行")

  if (type === 0) {
    // 1.抛出一个字符串类型(基本的数据类型)
    throw "error"

    // 2.比较常见的是抛出一个对象类型
    // throw { errorCode: -1001, errorMessage: "type不能为0~" }

    // 强调: 如果函数中已经抛出了异常, 那么后续的代码都不会继续执行了
    console.log("foo函数后续的代码")
  }

  console.log("foo函数结束执行")
}

foo(0)

console.log("后续的代码继续执行~")
  • 使用对象类型的错误抛出非常的场景,而且格式非常统一,如果我们每次抛出错误都要手写一遍这个格式,这会产生很多重复代码,不符合我们的实际需求,因此我们往往也可以使用类或者对象进行抽取复用
//抽取复用类
class HYError {
  constructor(errorCode, errorMessage) {
    this.errorCode = errorCode
    this.errorMessage = errorMessage
  }
}
//判断核心逻辑
if (type === 0) {
  // 2.比较常见的是抛出一个对象类型
  // throw { errorCode: -1001, errorMessage: "type不能为0~" }

  // 3.创建类, 并且创建这个类对应的对象
  throw new HYError(-1001, "type不能为0~")
}
  • 而在真实开发中,往往不需要我们自己创建这样一个类,默认情况下,JS已经为我们提供了一个类Error,在MDN文档中能够看到Error内置对象是存在构造函数的,说明使用Error的方式是通过new调用的方式

stack overflow社区关于浏览器中对象排序的讨论

图29-1 MDN文档下的Error

  • 并且我们是可以往这个返回错误中添加内容的,相对于自定义的错误来说,JS提供的Error类所给信息更加丰富
    • 默认直接抛出错误,返回的是一个调用栈信息,在堆栈跟踪小章节中详细说明过,对我们定位问题非常有效
// 4.提供了一个Error
const err = new Error("type不能为0")
throw err

stack overflow社区关于浏览器中对象排序的讨论

图29-2 Error返回堆栈跟踪信息

  • 在默认抛出Error错误的基础上,Error包含三个属性:
    1. messsage:创建Error对象时传入的message,存储了一个字符串,描述了错误的详情
    2. name:Error的名称,通常和类的名称一致,标识了错误的类型或名称。默认情况下,对于基本的 Error 对象,name 的值是 "Error"。但是,对于 Error 的不同派生子类,如 SyntaxErrorTypeError 等,name 属性将相应地显示为 "SyntaxError""TypeError" 等,主要用于区分错误种类,和前面抛出对象类型的errCode属性目的一样,都是为了定位问题范围
    3. stack:整个Error的错误信息,包括函数的调用栈(堆栈跟踪信息),当我们直接打印Error对象时,打印的一大段信息就是stack,是排查问题的重要信息来源,大多数定位到问题的来源都依靠于他
  • err的这些属性是能够进行赋值改变的,但我们一般不进行改动,只需要作为了解,默认情况下,Error对象的name属性值为"Error",name 属性message属性一起,通过调用Error.prototype.toString()方法,会作为最后异常信息的字符串表示
const err = new Error("type不能为0")
err.name = "coderwhy"
err.stack = "XiaoYu"
throw err

stack overflow社区关于浏览器中对象排序的讨论

图29-3 Error返回自定义信息name与stack

  • Error还有一些子类,使用方式与Error一致,都采用new调用,而这些报错信息都是在日常使用中,了解这些错误所对应的含义是非常有必要的,因为日常代码编写产生错误,所给出的错误提示标题也都是这些


表30-1 异常错误类型总结

错误类型描述
Error通用错误对象,其他错误类型的基类
SyntaxError当尝试解析语法上不合法的代码时抛出
ReferenceError当引用未定义的变量时抛出
TypeError当变量或参数不是预期类型时抛出
RangeError当值不在其允许的范围内时抛出
URIError当全局URI处理函数被用错误的方式调用时抛出
EvalError与全局函数eval()有关的错误,但在现代JavaScript中已不常见
AggregateError表示一个包含多个错误的错误对象,ES2021中新引入
// 5.Error的子类
// TypeError - 当一个值不是预期的类型时
const typeError = new TypeError("当前type类型是错误的~");
console.log("TypeError:", typeError.message);  // 输出: TypeError: 当前type类型是错误的~

// SyntaxError - 当解析代码时发生语法错误
const syntaxError = new SyntaxError("代码有语法错误");
console.log("SyntaxError:", syntaxError.message);  // 输出: SyntaxError: 代码有语法错误

// ReferenceError - 当引用不存在的变量时
const referenceError = new ReferenceError("引用了未定义的变量");
console.log("ReferenceError:", referenceError.message);  // 输出: ReferenceError: 引用了未定义的变量

// RangeError - 当值不在其允许的范围内
const rangeError = new RangeError("尝试创建长度为负的数组");
console.log("RangeError:", rangeError.message);  // 输出: RangeError: 尝试创建长度为负的数组

// URIError - 当全局URI处理函数被用错误的方式调用
const uriError = new URIError("URI格式错误");
console.log("URIError:", uriError.message);  // 输出: URIError: URI格式错误

// EvalError - 此错误类型已不常用,但可以作为示例
const evalError = new EvalError("Eval函数错误使用");
console.log("EvalError:", evalError.message);  // 输出: EvalError: Eval函数错误使用

// AggregateError - ES2021中引入,用于封装多个错误
const aggregateError = new AggregateError([
    new Error("错误一"),
    new Error("错误二")
], "多个错误被捕获");
console.log("AggregateError:", aggregateError.message);  // 输出: AggregateError: 多个错误被捕获
console.log("Contains Errors:", aggregateError.errors.map(err => err.message));  // 输出: Contains Errors: ["错误一", "错误二"]

异常的处理方式

  • 我们会发现在之前的代码中,一个函数抛出了异常,调用它的时候程序会被强制终止:
    • 这是因为如果我们在调用一个函数时,这个函数抛出了异常,但是我们并没有对这个异常进行处理,那么这个异常会继续传递到上一个函数调用中,形成一个调用栈
    • 而如果到了最顶层(全局)的代码中依然没有对这个异常的处理代码,这个时候就会报错并且终止程序的运行
  • 我们目前学习了各种异常的种类以及调用方式,但对于如何处理这些异常还没有详细讲解过,在过往中,我们有通过try...catch捕获异常,但捕获异常只有这种方式吗?try...catch的使用方式我们都掌握了吗?
  • 我们先来看一下这段代码的异常传递过程:
    • foo函数在被执行时会抛出异常,也就是我们的bar函数会拿到这个异常
    • 但是bar函数并没有对这个异常进行处理,那么这个异常就会被继续传递到调用bar函数的函数,也就是test函数
    • test函数依然没有处理,就会继续传递到我们的全局代码逻辑中
    • 依然没有被处理,这个时候程序会终止执行,后续代码都不会再执行了
function foo() {
  throw new Error('foo error message~')
}

function bar() {
  foo()
}

function test() {
  bar()
}

function demo() {
  test()
}

demo()
  • 通过结果的返回值,我们能够在Error的stack属性中拿到异常的传递情况,顶层为问题源头,底层为调用起点,体现为异常的传递过程,在这过程中可以随时对错误进行"捕获",也是我们可以对异常做出处理的时机

stack overflow社区关于浏览器中对象排序的讨论

图29-4 函数调用错误传递的堆栈跟踪

  • 通常我们有两种处理方式:

    • 第一种是不处理, 那么异常会进一步的抛出, 直到最顶层的调用,如果在最顶层也没有对这个异常进行处理, 那么我们的程序就会终止执行, 并且报错

    • 第二种就是使用try catch来捕获异常,try 语句总是以 try块开始。并且,至少存在 catch块finally块。也可以同时存在 catchfinally块,因此存在以下三种结构:

    1. try...catch
    2. try...finally
    3. try...catch...finally
  • 需要注意,trycatchfinally 块必须是块,而不是单个语句。必须是一个块意思为需要使用花括号 {} 来定义一个完整的代码块而不能省略

// TypeError - 当一个值不是预期的类型时
const typeError = new TypeError("当前type类型是错误的~");
console.log(typeError.message);  // 输出: 当前type类型是错误的~

// SyntaxError - 当解析代码时发生语法错误
try {
    eval('alert("Hello)');
} catch (e) {
    const syntaxError = new SyntaxError("代码有语法错误");
    console.log(syntaxError.message);  // 输出: 代码有语法错误
}

// ReferenceError - 当引用不存在的变量时
try {
    console.log(unknownVariable);
} catch (e) {
    const referenceError = new ReferenceError("引用了未定义的变量");
    console.log(referenceError.message);  // 输出: 引用了未定义的变量
}

// RangeError - 当值不在其允许的范围内
try {
    new Array(-1);
} catch (e) {
    const rangeError = new RangeError("尝试创建长度为负的数组");
    console.log(rangeError.message);  // 输出: 尝试创建长度为负的数组
}

// URIError - 当全局URI处理函数被用错误的方式调用
try {
    decodeURIComponent('%');
} catch (e) {
    const uriError = new URIError("URI格式错误");
    console.log(uriError.message);  // 输出: URI格式错误
}

// EvalError - 此错误类型已不常用,但可以作为示例
try {
    throw new EvalError("Eval函数错误使用");
} catch (e) {
    console.log(e.message);  // 输出: Eval函数错误使用
}

// AggregateError - ES2021中引入,用于封装多个错误
try {
    throw new AggregateError([
        new Error("错误一"),
        new Error("错误二")
    ], "多个错误被捕获");
} catch (e) {
    console.log(e.message);  // 输出: 多个错误被捕获
    console.log(e.errors.map(err => err.message));  // 输出: ["错误一", "错误二"]
}
  • 在ES10(ES2019)中,catch后面绑定的error可以省略,即使没有声明一个变量来接收错误对象,catch 块仍然可以执行,但不推荐省略,因为将无法在 catch 块中访问到错误对象的具体信息,除非确定不需要错误对象的任何信息
try {
  // 尝试执行的代码
} catch {//不绑定参数
  // 处理错误,错误对象不会绑定到任何变量
  console.error('An error occurred');
}
  • 在使用try...catch时,也有几点需要注意的事项,都是从良好实践规范的角度出发:
    1. 捕获具体的错误类型,尽量避免使用过于泛化的catch块来捕获所有类型错误,精确的范围能快速有效锁定问题范围
    2. 必要情况下多使用finally语句进行兜底操作,通常用于释放资源、关闭文件
    3. 避免在catch块中抛出错误,会导致原始错误信息丢失,除非很确定必须这样做,比如想抛出一个更具体的错误。如果在catch块中抛出新的错误,需要包含原始错误信息或通过其他方式记录它
    4. 能不嵌套try...catch就不嵌套,因为嵌套后的层级会显得复杂,难阅读和维护
    5. 传统try...catch无法捕获异步代码中的错误(例如在setTimeoutsetInterval、普通的回调函数中)。考虑使用Promise和async/await结构来处理错误
    6. 最后,try...catch会影响性能,应该在异常确实可能发生的地方使用,而不是用作常规流程控制的手段

JS模块化详解

什么是模块化

  • 到底什么是模块化、模块化开发呢?
    • 事实上模块化开发最终的目的是将程序划分成一个个小的结构
    • 这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构
    • 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用
    • 也可以通过某种方式,导入另外结构中的变量、函数、对象等
  • 上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程
  • 无论我们多么喜欢JavaScript,以及它现在发展的有多好,它都有很多的缺陷:
    • 比如var定义的变量作用域问题
    • 比如JavaScript的面向对象并不能像常规面向对象语言一样使用class
    • 比如JavaScript没有模块化的问题
  • Brendan Eich本人也多次承认过JavaScript设计之初的缺陷,但是随着JavaScript的发展以及标准化,存在的缺陷问题基本都得到了完善
  • 无论是web、移动端、小程序端、服务器端、桌面应用都被广泛的使用

JS模块化的历史

JavaScript的模块化是为了解决大规模应用开发中代码管理和维护的问题而逐渐发展起来的一种编程范式,主要作用为文件与文件之间能够联系起来,如图30-1。在JavaScript的历史中,模块化的发展可以分为几个阶段,每个阶段都对应于技术的进步和社区需求的变化

stack overflow社区关于浏览器中对象排序的讨论

图30-5 模块化使用原理

  • 在JS早期,模块化的概念并不明确,因为一开始JS仅作为一种脚本语言,代码量少,没有考虑到太多
    • 通常依靠全局变量来共享代码中的函数和变量。这种方法容易导致命名冲突和代码难以维护的问题。为了解决这些问题,开发者开始使用立即执行函数表达式(IIFE)来封装模块,以减少全局变量的污染
    • 这种模式通过创建一个匿名函数,立即执行它,并将相关的功能封装在这个函数作用域内,这种代码通常在过往源码以及现如今Babel转化后的代码中可以看见(在Babel章节已经见过),现如今已经很难在正常编写代码中看见其身影
//立即执行函数表达式(IIFE)
(function() {
    var privateVariable = 'private';
    function privateFunction() {
        console.log('Accessed private stuff');
    }
})();
  • 随着服务器端JavaScript的兴起,特别是Node.js的推广,CommonJS规范应运而生。CommonJS定义了一种模块格式,能做到在服务器端轻松地加载和封装代码。在CommonJS规范中,每个文件都被视为一个模块,可以使用require函数来加载其他模块,使用module.exports来导出模块中的内容
//能够引入我们的模块内容,运行这个当下文件相当于也会运行test文件的内容
require(导入来源)
//导入这个模块时,会得到一个包含所有导出内容的对象
module.exports = {
  导出内容
};
  • 为了在浏览器中更好地支持异步模块加载,异步模块定义(AMD)规范被提出,但从ES6引入了语言级别的模块系统(ESM规范),也就是使用importexport语句来导入和导出模块成员,将前面由社区自行产生的解决方案基本上都替代掉了,只有CommonJS规范依旧保留在Node.js的使用中
//基础导入(ESM)
import 导入内容 from 导入来源;
//基础导出(ESM)
export const 导出内容;

而这,就是JS模块化的发展历史,在这个学习过程中,我们都进行接触学习了解,随着时代的发展,项目变得越来越复杂,从前后端不分离到分离,分离后再继续细化。庞大的代码需要进行分割归类是编写实际项目迫切的需求,因此合理规划代码文件模块的分类,是一门必修课,当学会使用模块化后,将很难再直视之前的写法

在我们的课程中,将详细讲解JavaScript的模块化,尤其是CommonJSES6的模块化,这是到目前为止主流使用的方案

没有模块化带来的问题

  • 前面介绍模块化历史时,短短两三段话就概况了,但实际的发展时间是很长的,尤其在ECMA委员会推出ESM规范之前,很长的一段时间都处在没有模块化的阶段,但也让社区催生出各种临时方案
  • 早期没有模块化带来了很多的问题:比如命名冲突的问题
    • 当然,我们有办法可以解决问题,使用立即函数调用表达式(IIFE)就行,函数本身是具备作用域的,函数体的声明变量不会与全局产生冲突
  • 但是,除了命名冲突外,其实还带来了新的问题:代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写
//ModuleA模块
//使用模块名接收,但在没有合适的规范情况下,甚至出现模块名称相同的情况
var moduleA = (function() {
  //函数体内声明变量,解决命名冲突
  var name = "coderwhy"
  var age = 18
  var isFlag = true
  //导出需要的部分,并在这个过程中考虑是否需要重命名
  return {
    name: name,
    isFlag: isFlag
  }
})()
  • 我们必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用,一旦项目庞大起来,命名过多,模块命名是非常不好记忆的,会带来很大的心智负担
//ModuleB模块
(function() {
  if (moduleA.isFlag) {
    console.log("我的名字是" + moduleA.name)
  }
})()
  • 然后统统导入在html文件中,以script标签的形式,并且在这个过程中,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况
    • 我们需要知道,一个项目往往由多个开发者进行开发,对于公司来说,开发者是有可能离职,也可能更迭的
    • 因此保证一个良好的规范,让接收代码的开发者能够以更平滑的方式接手是必要的
<script src="./ModuleA.js"></script>
<script src="./ModuleB.js"></script>
//两个模块A命名冲突
<script src="./xiaoyu/ModuleA.js"></script>
  • 所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范
    • 我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码
    • 这个规范中应该包括核心功能:模块本身可以导出暴露的属性模块又可以导入自己需要的属性
    • JavaScript社区为了解决上面的问题,涌现出一系列好用的规范(非官方),接下来我们就学习具有代表性的一些规范


表30-2 模块化规范类型

规范名称类型出现背景及原因
AMD社区规范针对浏览器环境设计,支持异步模块加载,解决了浏览器中同步加载导致的阻塞问题。
CMD社区规范由中国开发者提出,与AMD类似,强调更细粒度的依赖解析和延迟执行,提高了模块加载的灵活性。
CommonJS社区规范最初为服务器端JavaScript(如Node.js)设计,支持同步加载,简化了服务器端模块化的开发。
ESM官方规范ECMAScript 2015标准中引入,为JavaScript提供原生模块系统,支持静态分析和更优化的模块加载机制。

CommonJS规范和Node关系

  • 什么是规范?规范是一种大家都遵守的行为,在手写Promise时,所遵守的Promise A+就属于一种规范
    • 规范本身是没有实现的,在计算机中准确的说是实现特定功能的规则、指导原则、或者协议
    • 只有实现了,具象化后才能够进行使用,就像规定了怎么做菜,但是想要吃,得先遵守做菜的方式把菜做出来才行,怎么做菜是教程,教程是吃不了的
  • 而CommonJS就是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS,平时我们也会简称为CJS(有些node文件会以.cjs作为后缀)
  • 那如果CommonJS规范本身没有实现,那要怎么使用?
    • Node是CommonJS在服务器端一个具有代表性的实现
    • Browserify是CommonJS在浏览器中的一种实现
    • WebpackViteRspack等打包工具具备对CommonJS的支持和转换
  • 所以,Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:
    • 在Node中每一个js文件都是一个单独的模块
    • 这个模块中包括CommonJS规范的核心变量:exports、module.exports、require,我们可以使用这些变量来方便的进行模块化开发
  • 前面我们提到过模块化的核心是导出和导入,Node中对其进行了实现:
    • exportsmodule.exports可以负责对模块中的内容进行导出
    • require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容
  • 我们可能会好奇,exportsmodule.exports有什么区别?
    • module是每个文件模块所对应的对象,exports是从当前文件的module对象中导出的属性,而这个属性同时也是一个对象
    • 我们追根溯源,通过维基百科中对CommonJS规范的解析,能够知道CommonJS中是没有module.exports的概念的,但是为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module,所以在Node中真正用于导出的其实根本不是exports,而是module.exports,因为module才是导出的真正实现者
    • exports只不过是作为module的部分一并导出,因此只需要把数据放入exports属性对象的,也都是作为导出的内容,可以被其他模块通过 require() 函数访问module的同时返回export的内容
//coderwhy.js文件
const name = "coderwhy"
const age = 18

function sum(num1, num2) {
  return num1 + num2
}

// 1.导出方案 module.exports
module.exports = {
  // aaa: "JS",
  // bbb: "xiaoyu"
  name,
  age,
  sum
}
//main.js文件
// 使用另外一个模块导出的对象, 那么就要进行导入 require
// const { aaa, bbb } = require("./why.js")
const { name, age, sum } = require("./coderwhy.js")

// console.log(aaa)
// console.log(bbb)
//导入coderwhy.js文件后,变量可以正常访问
console.log(name)
console.log(age)
console.log(sum(20, 30))
  • 使用require()函数,会有返回值,值为导入模块来源放入export属性对象的内容

Node实现CommonJS原理

  • Node是如何做到将内容导出,然后在另一个地方导入的?好像两个文件之间也没有关联,怎么建立起联系的?
    • 假设下方案例中,我们定义了一个对象,放入常量info中,然后将info常量赋值给exports进行导出
    • info对象实际存储于堆内存之中,意味着变量info只是一个内存地址,类似0x100的形式
//coderwhy.js
const info = {
  name: "coderwhy",
  age: 18,
  foo: function() {
    console.log("foo函数~")
  }
}

module.exports = info
  • 在该基础上,我们简单演示其中的内容转移过程
    • 导出内容存入到对应文件module中
    • require导入函数通过传入的地址id,锁定对应的模块文件,然后返回该模块文件的exports内容,在另一个文件通过变量接收其返回值,从而做到内容的导入导出
//main.js
function require(id) {
  // 通过id锁定对应的module
  return module.exports
}

const js = require('./coderwhy.js')
//本质等于以下内容
// const js = module.exports
  • 通过下图的演示,可以明显看到一整条脉络线最终指向于堆内存0x100,而这其实就相当于每一部分都指向于该堆内存
    • 也是为什么模块B能够使用到模块A内容的原因

stack overflow社区关于浏览器中对象排序的讨论

图30-6 模块之间的导入导出(CommonJS)

  • 而我们知道,对象是引用类型,意味着修改一处,处处都会被改动,因为每一处所传递的都只是内存地址,最终都指向同一处堆内存,堆内存的改动会辐射影响所有指向于它的内容
  • 我们做出如下实现来验证这一点:
    • 分为main.js(导入方/父文件)和coderwhy.js(导出方/子文件)两个文件
    • 在父文件中,一开始先打印数据信息,明确初始化内容。1s后在主文件修改内容,2s后在子文件中打印数据信息
    • 我们需要验证主文件的修改,确实会影响子文件,确定传递的就是引用类型
//main.js
const why = require("./coderwhy.js")
console.log(why)

setTimeout(() => {
  // console.log(why.name)
  why.name = "小余"
}, 1000)
//coderwhy.js
const info = {
  name: "why",
  age: 18,
  foo: function () {
    console.log("foo函数~")
  }
}

setTimeout(() => {
  console.log(info.name)
}, 2000)

module.exports = info
  • 其结果返回如下:
    • 一开始初始化内容没有变化,是初始化info常量所赋值的内容
    • 1s后在主文件中产生赋值修改name属性操作,2s后再子文件中进行打印name属性,属性name此时为"小余",与初始值"why"形成对比,说明主文件的改动,确实影响子文件,也说明了该导入导出的就是引用类型,所修改和展示的都是同一个对象
{ name: 'why', age: 18, foo: [Function: foo] }
小余
  • 说明所描述模块导入导出的原理是正确的,其原理我们可以通过函数foo作为中间值来举例
    • obj1、obj2、obj3指的都是同一个对象
const obj1 = {
  name:'obj1'
}

const obj2 = obj1

function foo() {
  return obj2
}

const obj3 = foo()
obj3.name = 'obj3'
console.log(obj1.name);//obj3

exports作用及原理

  • exports作为CommonJS规范的导出方式,具备多种形式导出
  • 在下方第二种导出方式也是能够正常导出的,没有使用module作为前缀
    • 但是,为什么单独使用exports也可以导出呢?难道不是module才是本体吗?
// 第二种导出方式
exports.name = name
exports.age = age
exports.sum = sum

// 这种代码不会进行导出
// exports = {
//   name,
//   age,
//   sum
// }
  • 这就需要我们了解exports导出的原理,在源码中有这样进行实现:
    • 默认module.exports是导出一个空对象,但在该基础上,会将module.exports进行一个赋值操作,这行代码看似多余,因为在模块的代码开始执行时,exportsmodule.exports 已经自动指向同一个对象
    • 主要是为了确保 exports 变量确实指向了 module.exports,事实从我们的做法中已经得以体现

stack overflow社区关于浏览器中对象排序的讨论

图30-7 Node文档对module.require的描述

// 源码
module.exports = {}
exports = module.exports
  • 这种写法是为了代码的清晰性和维护性,以确保在模块的任何地方对 exports 的修改都能反映到 module.exports 上,从而避免导出内容出现意外错误或混淆
    • 但往往也会有意外,如果这样进行使用,我们就只能一个个属性的导入进去,而不能直接传入一个对象
    • 如果单独使用exports进行导出对象,会无法生效,主要因为module.exports本身是一个空对象,不能在往对象里面继续套对象,传入内容需要满足键值对需求
  • 最终导出的一定是module.exports
// 这种代码不会进行导出
// exports = {
//   name,
//   age,
//   sum
// }
//等价于(对象套对象,错误写法):
module.exports = {
  {
    name,
    age,
    sum
}
}
  • 使用exports和使用module.exports时还需要注意,因为我们清楚这两者指的都是同一个对象
// 这种代码不会进行导出
exports.name = name
exports.age = age
exports.sum = sum

module.exports = {

}
  • 当我们使用exports赋值结束后,对module.exports赋值一个空对象,则会直接把原有代码进行覆盖,导致一开始编写的代码丢失,从源码的角度来说,相当于如下案例
//exports赋值
module.exports.name = name
module.exports.age = age
module.exports.sum = sum
//对前面内容造成覆盖
module.exports = {}
  • 因此,module.exports如果一定要和exports一同使用,前者必须先写。因为前者是覆盖,而后者可以是补充
  • 从以上几个角度,我们就掌握了以下几个疑问的答案:
    • 在三者项目引用的情况下,修改exports中的name属性到底发生了什么?
    • 在三者引用的情况下,修改了main中的bar的name属性,在bar模块中会发生什么?
    • 如果module.exports不再引用exports对象了,那么修改export还有意义吗?

stack overflow社区关于浏览器中对象排序的讨论

图30-8 exports与module.exports指向同一对象(Node源码)

require细节

  • 我们现在已经知道,require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象
  • 那么,require的查找规则是怎么样的呢?
    • 例如:两个命名一样的文件index.js和index.ts,在进行导入时,优先度是怎么样的?先考虑远近还是考虑后缀亦或者其他因素?假如远近一样或者后缀一样,先考虑哪一点?优先加载哪一个模块?
    • 这在Node文档中有详细说明:CommonJS 模块 | Node.js v22 文档 (nodejs.cn)
  • 当然,直接看文档的难度比较高,在这里总结了比较常见的查找规则:
    • 导入格式如下:require(X)
    • 最关键就在于X身上,Node是如何查找文件的呢?它有一套特定的查找规则,用于确定模块的位置和加载方式
    • 在详细介绍 require() 的查找规则时,我们需要区分三种模块类型:核心模块文件模块、和第三方模块
核心模块

Node.js 自带了一些核心模块(例如 fshttpspath 等),这些模块在 Node.js 启动时已经被编译并加载进内存。如果通过 require() 引入一个核心模块,如 require('fs'),Node.js 会直接加载它,而不需要查找文件路径。这种查找是最快的,逻辑上很像是C语言和它对应的标准库

  • 对于具体的核心模块,我们这里不进行学习,这是Node阶段的内容,只需要了解核心模块在一定程度上,是作为Node的伴生物存在,可以直接按需导入,不需要安装第三方库或者其他操作
  • 一旦X是一个Node核心模块,将直接返回核心模块,并且停止查找
// 核心模块查找 - Node.js 自带模块,直接加载
const fs = require('fs');  // 加载核心模块 'fs'
console.log('核心模块:', typeof fs.readFile);  // 输出: function
文件模块

文件模块包括本地的 .js.json.node 文件。文件模块的查找规则根据提供的路径决定查找的方式

  • 查找时会先根据查找路径区分为两个标准,分别以相对路径或者绝对路径进行查找
    • 相对路径(以 ./../ 开头):Node.js 会基于当前模块的文件位置,去找相对路径中的目标文件。例如 require('./coderwhy') 会从当前文件所在目录中查找 coderwhy.js
    • 绝对路径/(根目录)开头):直接查找对应位置的模块
  • 在路径基础上,会将X当做一个文件在对应的目录下查找:
    1. 如果有后缀名,按照后缀名的格式查找对应的文件
    2. 如果没有后缀名,会按照如下优先度顺序:查找文件XX.js文件X.json文件X.node文件,查找到一个,则直接返回并停止查找
  • 一旦通过后缀名没有找到,Node则会将其视为一个文件夹目录去进行查找
    • 查当前X文件目录下的index.js、index.json、index.node文件,查找到一个,直接返回并停止查找
    • 如果还是没有找到,那么报错:not found
// 文件模块查找 - 使用相对路径加载模块
const relativeModule = require('./myModule');  // 加载当前目录下的 myModule.js 或 myModule/index.js
console.log('相对路径模块:', relativeModule);

// 文件模块查找 - 使用绝对路径加载模块
const absoluteModule = require('/Users/user/project/myModule');  // 加载绝对路径的模块
console.log('绝对路径模块:', absoluteModule);

// 文件夹模块查找 - 当指向一个文件夹时,Node.js 会尝试加载 package.json 中的 main 或 index.js
const folderModule = require('./folderModule');  // 加载文件夹 ./folderModule/index.js 或 ./folderModule 指定的入口
console.log('文件夹模块:', folderModule);
第三方模块
  • 当我们不使用相对路径或绝对路径(例如 require('express'))来加载模块时,Node.js 会将其视为一个第三方模块,并从 node_modules 文件夹中查找

  • 查找过程会优先查找当前目录的 node_modules,如果找不到,则逐级向上查找 node_modules

    • node_modules是一个使用npm下载第三方库时,自动生成存储内容的文件夹,所有通过npm等包管理器下载的内容都在这里面(后面我们会具体学习npm等包管理工具)
    • 找到则返回,未找到模块则Node.js 会继续查找全局安装的模块(如通过 npm install -g 安装的模块),还未找到则报错:no found
  • node_modules 中找到第三方模块时,Node.js 会按照以下顺序解析:

    1. 如果有与模块同名的 .js 文件,则直接加载

    2. 如果是一个文件夹,Node.js 会查找 package.json 中的 main 字段指定的入口文件

    3. 如果没有 package.json,则会查找该目录下的 index.js

    • 很多时候,我们使用npm安装好第三方库时,可以直接导入使用,例如axios库,是因为顺序解析的第三点,查找到该目录下的index.js,从而正确导入

stack overflow社区关于浏览器中对象排序的讨论

图30-9 第三方库axios导入顺序

// node_modules 文件夹中的第三方模块查找
const lodash = require('axios');  // 加载项目中安装的第三方模块 'axios'
console.log('第三方模块 axios:', typeof axios);

// 多级 node_modules 查找 - 如果找不到模块,会逐级向上查找 node_modules 目录
// 当前文件路径为 /Users/user/project/src/index.js
// Node.js 会尝试按顺序查找:
//  /Users/user/project/src/node_modules/someModule
//  /Users/user/project/node_modules/someModule
//  /Users/user/node_modules/someModule
// 直到找到 'someModule'
const someModule = require('someModule');  // 假设 someModule 在某个上级 node_modules 中
console.log('逐级查找模块 someModule:', someModule);

模块加载过程

在模块被加载引入时,是如何执行的?引入一次和引入多次有什么区别?都会造成什么影响,如果循环引入的话,会发生什么?让我们来学习一下吧!

  • 在下方案例中,index.js是主文件,导入coderwhy.js文件,在这里,我们导入忽略后缀,让Node自行去查找
    • 目前的问题在于,coderwhy.js的文件内容没有被用到,会不会执行?如果会执行,执行顺序是什么样子的?
    • 导入但没有用到的coderwhy.js文件,其代码也会先被执行,根据结果,执行顺序是根据导入位置所决定的,在主文件依旧是根据自上而下的执行策略
  • 因此得出结论一:模块在被第一次引入时,模块中的js代码会被运行一次
//coderwhy.js
const name1 = 'coderwhy'
const name2 = '小余'
console.log('name1:', name1);
console.log('name2:', name2);

module.exports = {
  name1, name2
}
//index.js
console.log('index.js代码开始运行');
//引入但未使用
const demo = require('./coderwhy')

console.log('index.js代码运行结束');

// index.js代码开始运行
// name1: coderwhy
// name2: 小余
// index.js代码运行结束
  • 模块导入多次,会执行多次吗?根据结果反馈,只会执行一次,那为什么只会加载运行一次?
//同一模块导入三次
const demo1 = require('./coderwhy')
const demo2 = require('./coderwhy')
const demo3 = require('./coderwhy')

// name1: coderwhy
// name2: 小余
  • 因此Node.js 会缓存已经加载的模块,这样可以减少重复加载的开销。当一个模块被第一次加载时,它会被缓存在 require.cache 中,在Module实例中以布尔值形式的loaded变量进行判断是否是第一次加载,默认为false,加载一次后变为true,若为ture则接下来的 require() 调用会直接从缓存中读取这个模块,而不是重新执行代码。如果想清除缓存,可以手动删除 require.cache 中相应的条目

stack overflow社区关于浏览器中对象排序的讨论

图30-10 Node的loaded判断首次加载

// 缓存模块 - 同一个模块只会加载一次,后续的 require() 会使用缓存
//同一模块导入三次 后续不会执行,而是从缓存中获取
const axios1 = require('./coderwhy') // 加载一次模块
const axios2 = require('./coderwhy') // 直接从缓存中获取
const axios3 = require('./coderwhy') // 直接从缓存中获取

console.log('缓存模块是否相同:', axios1 === axios2);  // 输出: true
console.log('缓存模块是否相同:', axios2 === axios3);  // 输出: true
  • 在引入模块的时候,有一点是非常值得注意的:良好清晰的引用关系,那如果出现下图模块的引用关系,那么加载顺序是什么呢?
    • 这个其实是一种数据结构:图结构
    • 加载顺序是从起点main文件开始执行,从上往下逐行执行,此时在第三行先引入aaa文件,后再第四行引入bbb文件。这时候会先执行aaa文件,如果aaa文件还有引入其他文件则继续在该aaa文件基础上继续执行,直到来自aaa文件所有引用都执行引用结束,才会在main主文件中继续往后执行第四行的bbb文件
  • 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search),我们这个引入模块案例,说明了Node采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb

stack overflow社区关于浏览器中对象排序的讨论

图30-11 循环引入的加载流程

//main.js
console.log("main")
require("./aaa")
require("./bbb")
//打印执行内容如下:
// main aaa ccc ddd eee bbb

//aaa.js
console.log("aaa")
require("./ccc")

//bbb.js
console.log("bbb")
require("./ccc")
require("./eee")

//ccc.js
console.log("ccc")
require("./ddd")

//ddd.js
console.log("ddd")
require("./eee")

//eee.js
console.log("eee")
  • 深度优先搜索(DFS):从起始节点开始,一条路径走到尽头再回溯,探索尽可能深的节点
  • 广度优先搜索(BFS):从起始节点开始,逐层探索相邻节点,直到找到目标节点或遍历所有节点

深度优先与广度优先搜索

图30-12 深度优先与广度优先搜索

CommonJS规范缺点

  • CommonJS加载模块是同步的,同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行
    • 这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快,基本上不需要从其他地方下载文件过来执行,则基本没有时间差,不会因请求产生阻塞
    • 就算必须从服务器中请求其他数据下来,我们也是采用axios等网络请求,而不是CommonJS,所以不会出现这种问题
  • 如果将它应用于浏览器呢?
    • 浏览器加载js文件需要先从服务器将文件下载下来,之后再加载运行
    • 那么采用同步的就意味着需要等js文件下载后才能继续往后执行,在这段下载的时间中,后续的js代码都会被阻塞无法正常运行,即使是一些简单的DOM操作
  • 所以在浏览器中,我们通常不使用CommonJS规范,当然在webpack中使用CommonJS是另外一回事,因为它会将我们的代码转成浏览器可以直接执行的代码
  • 在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD,但是目前一方面现代的浏览器已经支持ES Modules,另一方面借助于Webpack、Vite等构建工具可以实现对CommonJS或者ES Module代码的转换
  • AMD和CMD已经使用非常少了,所以这里我们进行简单的演练

AMD/CMD规范

  • AMD主要是应用于浏览器的一种模块化规范,是Asynchronous Module Definition(异步模块定义)的缩写,采用的是异步加载模块,事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的较少了

  • 我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:

深度优先与广度优先搜索

图30-13 requirejs仓库实况

require.js的使用

  • 第一步:下载require.js,在上面对应GitHub仓库中找到其中的require.js文件进行下载
  • 第二步:定义HTML的script标签引入require.js和定义入口文件:

深度优先与广度优先搜索

图30-14 requirejs仓库需要下载的require.js文件

  • 在导入require.js时,我们需要用到data-main属性,能够做到加载完require.js后继续执行data-main属性内的文件,保证了执行的先后顺序(require.js文件一定先执行)
    • 同时require.js文件需要和源代码区分开,该文件放入lib文件夹中,而源码放入src文件夹中(项目规范)
    • lib文件夹通常用来存放库文件(libraries),“lib”这个名称来源于“library”(图书馆)的缩写,就像图书馆收藏书籍供人们借阅一样,库文件也“收藏”了代码供开发者使用。而src代表的是”source code”(源代码)的缩写,这些都是项目中约定俗成的规范,相关的还有很多,在做项目时都会接触到
//index.html
//data-main属性的作用是在加载完src的文件后会加载执行该文件
<script src="./lib/require.js" data-main="./src/main.js"></script>
  • 在完成以上步骤后,我们在src文件夹下创建三个js文件:bar.js、foo.js、main.js
    • 在main.js文件中完成配置,则可以使用require来加载foo和bar两个模块以及对应回调,该使用方式类似于Promise的all方法
    • 我们通过paths属性对模块进行注册,使用require([xxx])对需要导入的模块进行整合
//main.js
// 配置 RequireJS 的基本设置
require.config({
  // 设置基础 URL,从哪里开始寻找模块文件(有默认值为./src),若不加该属性,则配置模块不需要加前置/src
  baseUrl: '',

  // 配置模块的路径
  paths: {
    // 当 require 函数请求 'foo' 模块时,它将在 baseUrl 下的 ./src/foo 路径查找
    foo: "./src/foo",
    // 当 require 函数请求 'bar' 模块时,它将在 baseUrl 下的 ./src/bar 路径查找
    bar: "./src/bar"
  }
});

// 使用 require 函数来加载 'foo' 和 'bar' 模块
require(["foo", "bar"], function(foo) {
  // 当 'foo' 和 'bar' 模块都加载完成后,这个回调函数会被执行
  // 这里的参数列表对应于 require 函数中指定的模块数组顺序
  // 由于 'bar' 模块没有在回调函数中被使用,所以这里没有列出 'bar' 的参数

  // 在控制台打印 'foo' 模块导出的内容
  console.log("main:", foo);
});
  • 在foo和bar两个js文件中,想要实现导出,则需要通过require.js的define函数,里面传入一个回调,这是有一个固定格式的,使用define函数可以避免全局变量冲突的问题
    • 导出内容通过return返回出去,其他地方通过define的参数一数组来收集所需模块依赖进行使用(可以省略)
    • 这些都作为了解即可
//bar.js
define(["foo"], function(foo) {
  console.log("--------")
  // require(["foo"], function(foo) {
  //   console.log("bar:", foo)
  // })

  console.log("bar:", foo)
})

//foo.js
define(function() {
  const name = "coderwhy"
  const age = 18
  function sum(num1, num2) {
    return num1 + num2
  }

  return {
    name,
    age,
    sum
  }
})

CMD规范

CMD规范也是应用于浏览器的一种模块化规范,是Common Module Definition(通用模块定义)的缩写,也采用了异步加载模块,但是它将CommonJS的优点吸收了过来,但是目前CMD使用也非常少了

  • CMD也有自己比较优秀的实现方案:SeaJS
  • 使用步骤与require.js库保持一致
//index.html
<script src="./lib/sea.js"  data-main="./src/main.js"></script>
  • 使用SeaJS有一个全局对象seajs,从它身上可以使用很多方法,例如use方法,可以指定主模块入口文件
    • 在该基础上,我们可以不使用data-main属性,而是另外创建一个script标签
//index.html
<script src="./lib/sea.js"></script>
  <script>
    seajs.use("./src/main.js")
  </script>
  • 有入口文件后,在入口文件中,就不需要像require那样使用config来配置注册模块,而是可以直接define进行使用
//main.js
define(function(require, exports, module) {
  const foo = require("./foo")//导入即可使用
  console.log("main:", foo)
})
  • 在主文件的导入已经非常眼熟,而通过子文件的正常编写和导出,则可以看到几乎与CommonJS一模一样,唯一不同的地方则在于需要使用define函数进行调用
define(function(require, exports, module) {
  //正常代码编写
  const name = "why"
  const age = 18
  function sum(num1, num2) {
    return num1 + num2
  }

  //内容导出
  // exports.name = name
  // exports.age = age
  module.exports = {
    name,
    age,
    sum
  }
});

ESModule规范

  • 但不管是AMD还是更为优秀的CMD都用得很少了,因为现在存在更为优秀的官方规范,所以都慢慢淡化于视野中
  • JavaScript没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD等,所以在ES推出自己的模块化系统时,大家也是兴奋异常
  • 随着Node的不断升级迭代,总有一天,CommonJS也会退出历史舞台,只剩下ESM规范
    • 但就目前来说,CommonJS在Node中还是非常流行,与ESModule规范具备同一相同点:无需借用第三方库,可以直接使用

认识 ES Module

  • ES Module和CommonJS的模块化有一些不同之处:
    • 编译期的静态分析和动态引用后续回详细讲解
    • ES Module正常下无法使用Node在终端输出,需要在浏览器中,如果需要使用node环境输出,所有相关文件扩展名改为mjs即可
    • 需要注意,一旦使用ES Module将自动采用严格模式:use strict


表30-3 ES Module与CommonJS的区别

特性ES ModuleCommonJS
关键字importexportrequiremodule.exports
加载方式编译期静态分析 + 动态引用支持运行时动态加载
执行顺序顶层代码自动提升、静态加载按顺序执行,模块代码即时求值
模块输出采用引用方式输出,实时绑定输出为拷贝,模块加载时求值
运行环境浏览器和 Node.js 均支持主要用于 Node.js
异步加载支持原生支持动态加载(import()默认同步加载,需要异步加载时借助工具

ESModule基础使用

  • 使用ESM规范时,导入文件的后缀需要添加,不能省略,否则报错
  • 在Webpack、Vite等构建工具的帮助下,可以省略不加,但那是基于构建工具的辅助才达成的效果,就目前来说,还是先加上
  • 那以下代码能否执行?事实上是不行的
    • 报错为:Uncaught SyntaxError: Cannot use import statement outside a module
    • 这是一个语法错误,提示我们不能在模块外使用import,这是什么意思?一个JS文件不就是一个模块吗?那浏览器为什么会说我们在模块外使用了,明明在main.js模块使用了
//index.html
<script src="./main.js"></script>
//foo.js文件
export const name = "XiaoYu"
export const age = 18
//main.js文件
import { name, age } from "./foo.js"

console.log(name)
console.log(age)
  • 这是因为默认情况下,script标签引入JS文件进行加载时,只会将其当作普通JS进行解析,以前当作原生开发的JS就称为普通JS
    • 普通JS一旦执行,就会从上往下开始执行代码,一旦我们在普通JS中使用import这类关键字的时候,就会开始报错了,因为普通JS并不存在该关键字
    • 这是因为import关键字是来自ESM规范,该规范是ES6之后更新,我们在学习ES6时有进行说明,为了最大程度的兼容性,往往不能对原有结构进行破坏性更新
  • ESM规范就是基于此思想进行的扩展,我们必须在导入位置添加type属性,设置为module的特殊标注
    • 该做法则是开启ESM规范的开关,就像想使用严格模式需要在JS文件开头添加命令开启,任何效果都不是凭空产生的
    • 添加该属性后,打开浏览器刷新重新观察控制台,可以发现报错消失,内容成功打印
  • 因此我们能够认为,报错的语法错误,所说明的模块外,其实是指main.js不是一个模块(不属于模块统称为模块外),没有被指定为一个 ES Module,浏览器按照普通的 JavaScript 进行解析,而 import 关键字属于 ES Module 规范,因此会导致语法错误
  <script src="./main.js" type="module"></script>
  • 同时需要注意,打开index.html文件有两种方式:
    1. 直接在浏览器中打开文件(通过文件路径 file:// 协议打开) => 报错
    2. 使用live-server运行代码 => 正常运行

深度优先与广度优先搜索

图30-15 打开html文件的两种方式

  • 为什么一样的代码,就因为打开文件的方式不同,就会导致不能运行的情况发生?
    • 这个在MDN上面有给出解释:developer.mozilla.org/zh-CN/docs/…
    • 概述的说,这是一个Cross origin requests(跨源请求错误),直接在浏览器打开html的话,浏览器会使用 file:// 协议加载资源,会对它应用更严格的同源策略。在这种情况下,浏览器通常会禁止跨源(即使是同一个文件夹中的不同文件)的加载,因为使用 file:// 协议加载文件无法正确处理跨域请求
    • 这会导致 CORS(跨域资源共享)相关的错误,具体表现为浏览器阻止了模块的加载。这种情况下,页面可能会报类似于“跨源请求被阻止”的错误,导致脚本无法执行

深度优先与广度优先搜索

图30-16 通过文件路径 `file://` 协议打开导致的报错

  • 而live-server等类似的本地服务器工具运行时,浏览器通过 https://localhost 或者 https://127.0.0.1 的形式来访问文件则不相同
    • 所有资源(包括 HTML、JavaScript、CSS 等)都会被视为同源请求。是因为它们共享相同的协议(https)、主机名(localhost127.0.0.1)以及端口,因此不会出现跨域问题
    • 浏览器可以正确地加载 ES Modules,因为这是标准的 https 请求方式,符合浏览器的安全策略

PS:同源策略(Same-Origin Policy)是浏览器的安全机制,用来防止不同来源的网页之间相互访问和操作,防止恶意网站读取或修改用户数据。它限制了 JavaScript 代码只能与同源的资源进行交互,从而保护用户的数据安全

同源指的是三个部分相同:

  1. 协议(例如,httpshttps
  2. 主机名(例如,example.com
  3. 端口(例如,80443,在这里体现的是5503端口)

如果这三个部分完全相同,则被认为是同源,否则是跨域

ESModule的多种使用方式

  • ESM规范的核心依旧是导入导出,导入的前提是先得有内容可以导入,因此先有导出才有导入
  • 我们先从导出开始,看ESM规范中都有哪些导出方式?

导出方式1:export 声明语句导出,也被称为命名导出 (Named Exports)

  • 可以导出多个值。每个值都有其名称,导入时需要使用相同的名称
//foo文件
// 导出变量
export const name = "XiaoYu"
export const age = 18
// 导出单个函数
export function xiaoyu1(x, y) {
  return x + y;
}
// 导出另一个函数
export function xiaoyu2(x, y) {
  return x * y;
}

导出方式2:export导出和声明分开

  • 乍一看,该导出方式所导出的是一个对象,还是简写的做法,那完整的会不会是name:name的键值对结构呢?
  • 并不是,这是export特定语法,虽然和对象很像,但并不是对象,因此我们如果想要通过的重命名来实现导出名称改变是做不到的(导入导出需要使用相同命名)
// 2.第二种方式: export 导出 和 声明分开
const name = "why"
const age = 18
function foo() {
  console.log("foo function")
}

export {
  name,
  age,
  foo
}

导出方式3:在第二种导出基础上起别名

  • 因为该导出语法并不是对象,因此使用键值对操作的方式不行,若我们想要起别名,需要使用导出内容 as 别名的做法
  • 导出别名往往是为了防止多个模块之间导入产生命名冲突等问题而需要做的事情
export {
  name as fName,
  age as fAge,
  foo as fFoo
}
  • 在以上三种方式中,一二使用较为高频,三的使用很少

    • 而一和二具体要使用哪一个,则需要根据具体应用场景决定
    • 通常来说,整体代码量和导出内容较少时,使用方式1会更好,反之,使用方式2更好进行集中管理
  • 以上是基础的导入方式,那导出方式都有哪几种,也让我们来看一看吧!

导入方式1:普通导入(导入命名导出),必须配合解构使用导出时指定的名称,不使用结构的是另一种导出方式,等下说明

import { name, age } from "./foo.js"
  • 导入方式2:别名导入
    • 作用与使用方式与别名导出是一致的,与别名导出不同的地方在于别名导入使用频率较高
    • 因为在模块化的项目中,别名导出能够避免在模块A中的命名冲突,那模块B、C、D...呢?没有"千日防贼"的道理,而是根据实际情况在导入时自由决定,没有冲突则正常使用,有冲突则改别名
    • 因为导出内容原义基本上最为准确,且在多模块中能够不因别名产生含义差,能不改名当然是不改名
import { name as name1, age as age2 } from "./foo.js"
  • 导入方式3:一次性导出所有内容
    • 使用*通配标识符替代所有的导出内容,在多种语言中都有它的出现,使用该方式能够最大程度上避免命名冲突,因为只需要一个别名命名就够了,所有内容都从该别名中获取
    • 但不推荐使用,因为我们很难知道导出的到底有哪些内容,虽然有些特定编辑器例如webStorm可以有良好的代码提示,但从整体阅读性上来说,依旧不够明朗
    • 所以哪怕确定所有内容都是我们想要导出的,并且后续不会在导出模块中添加不想导出的内容,也依旧推荐详细写出导入内容,可读性更强

深度优先与广度优先搜索

图30-17 特定编辑器别名导入提示

import * as foo from "./foo.js"
console.log(foo.name)
console.log(foo.age)

export和import结合使用

  • exportimport 可以结合使用,这种结合通常用于重新导出(Re-export),即在一个模块中导入其他模块的内容,然后将其直接导出
  • 我们在foo.js和bar.js中各导出一些内容,在main.js中我们直接将foo与bar文件的内容做一个统一的导入后再导出
    • 在app.js中导入main.js文件后则可以同时使用到来自foo与bar文件的导出内容
// foo.js
export const name = "XiaoYu";
export const age = 18;

// bar.js
export const address = "Beijing";

// main.js
import { name, age } from "./foo.js";
import { address } from "./bar.js";
export { name, age, address }; // 重新导出方式1

// 或者更简洁的结合导出方式2
export { name, age } from "./foo.js";
export { address } from "./bar.js";

// 使用这些导出内容的文件
// app.js
import { name, age, address } from "./main.js";
console.log(name, age, address);
  • 那我们为什么要这么做呢?如果app.js文件需要,直接将foo与bar文件导入app.js不就好了,何必通过中转站main.js呢?
    • 这是因为在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中,这是因为通过这种聚合,模块间可以变得更加独立,代码的耦合度降低。如果底层模块的路径或名称发生改变,只需在聚合的模块中修改一次,而不需要在所有引用这个模块的地方都进行修改
    • 在复杂的项目中,如果没有聚合模块,可能需要从多个不同的文件中导入内容,这会导致大量的导入语句和很长的路径。通过将多个模块聚合到一个文件中并重新导出,可以使导入路径更加简洁,减少导入代码的复杂度,这样方便指定统一的接口规范,也方便阅读和管理
    • 在具体项目中不同的文件模块也可能归属于一个风格大类,比如不同功能的接口模块都归属于接口风格,不同的工具模块都归属于工具风格,我们将其所有接口或者工具都统一导入导出,则在导入时可以一眼确定这到底是接口还是工具,且导入时不需要想这是接口的哪一个模块,我们直接导入其统一导出的文件名,剩下想要导入的具体内容有编辑器为我们提示,使用的心智负担会有效降低
    • 这个时候,我们就可以使用export和import结合使用

深度优先与广度优先搜索

图30-18 实际项目中的统一导入导出

  • 当然,作为统一导出的文件接口,我们可能并不关心具体导出了什么,毕竟这只是作为一个统一中转站进行使用,这时候如果为了省事,可以使用*通配标识符
  • 且通常作为统一导出文件的命名都为index,这是因为在使用时,直接导入该文件夹即可,ESM规范会根据查找规则,自动找到文件夹下的index文件
//统一全部导出方式3
export * from './foo.js'
export * from './bar.js'
  • 像Webpack、Vite这些脚手架和前端的各种工具库,大多都依赖于Node,而在Node所依赖的模块化则是依赖于CommonJS,由于Node是前端必不可少的一个环节,所以CommonJS在编写Node或者使用Node工具时,仍是主流,这也是我们学习它的原因

ESModule的默认导出

  • 每个模块可以有一个默认导出(且仅能有一个)。默认导出的导入方式较为简洁
    • 命名导出(Named Export)不同,默认导出不需要指定名称,导入时也可以使用任意名称来引用它
    • 且导入默认导出内容时不需要使用解构,可以直接进行导入,非默认导出不使用解构会报错
    • 通常最主要的内容使用默认导出
// 默认导出一个函数
//foo.js
export default 导出内容
//main.js
import 任意名称导入 from "./foo.js";
  • 意思是,哪怕导出的是一个foo函数,在导入时导入的名称为bar,其实也是指foo函数
//foo.js
export default function foo() {
  console.log("Hello, foo!");
}
//main.js
import bar from "./foo.js";
bar(); // 输出: Hello, foo!

import函数

  • 使用import函数时,有几点需要注意:
    1. 在import导入内容前,后续代码是不会运行执行的需要等内容导入解析结束,拿到导入内容后才能运行后续代码。因此,正常使用import导入时都写在代码的最顶层
    2. import导入是不可以放入逻辑代码中的,这是因为ES Module在被JS引擎解析时,就必须知道它的依赖关系,由于这个时候js代码没有任何的运行,所以无法在进行类似于if判断中根据代码的执行情况
    3. 如果不希望导入内容阻塞后续代码执行且希望动态的来加载某一个模块,则需要使用import函数。不阻塞代码运行的,都为异步操作,因此使用import函数的返回值是一个promise,可以方便接收返回值
  • 由于该import函数为异步,因此在导入时的下载和解析内容,不由JS线程操作,JS线程只负责执行代码
// import函数返回的结果是一个Promise
import("./foo.js").then(res => {
  console.log("res:", res.name)
})
console.log('不影响后续代码运行')//同步代码优先执行

//不影响后续代码运行
//res: 小余
  • 同时有一点需要注意,import() 函数可以直接在node环境下使用,不需要文件扩展名为mjs,这源于该函数是动态加载,可以在代码执行时按需加载模块。这意味不需要在编译时确定模块类型,而是在运行时决定要加载哪个模块,并动态处理模块的加载,因此非强制依赖ESM规范所需环境
  • Node.js 逐步过渡到 ESM 的过程中,很多项目和库还在使用 CommonJS,而动态导入做到了可以逐步引入 ESM,并且能够与现有的 CommonJS 模块无缝集成。这对于进行渐进式迁移非常有用,在后续学习Vue框架时,对该框架自我介绍为渐进式框架也会有更深一步的理解
  • ES11后,import函数新增mate元属性,会将特定上下文的元数据暴露给 JavaScript 模块。它包含了这个模块的信息,例如这个模块的 URL
    • 如果在Node中,它是文件路径(也包括 file:// 协议部分)
    • import.meta 是一个宿主环境创建的可扩展的 null 原型对象,规范没有在对象上明确定义任何属性,但是宿主环境通常会定义url属性
    • 且由于import是保留字而不是标识符,因此import.mate.不被视为.属性访问器,而是整块内容都是特殊的表达式语法
// ES11新增的特性
// meta属性本身也是一个对象: { url: "当前模块所在的路径" }
console.log(import.meta)

ESModule解析流程

  • CommonJS本质上是对象的一种赋值,通过require函数,将数值传入另外的模块中,那ESModule又是怎么样的呢?让我们来学习一下吧

  • ES Module是如何被浏览器解析并且让模块之间可以相互引用的呢?这里有一篇写得非常好的文章:hacks.mozilla.org/2018/03/es-…

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

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

    • 查找地址需要从https或者https协议等方式进行下载js文件,将从服务器下载来的内容进行解析
    • 当前下载来的js文件会被解析为一个叫Module Record的数据结构
  • 阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址

    • 根据Module Record数据结构创建一个对应的对象,这个过程称为实例化
    • 并且专门分配一块内存空间,我们导入导出的内容都可以在该内存空间中找到
  • 阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中

    • 这意味着在第二阶段时,只会执行导入导出的内容,具体的JS代码未执行,因此阶段二内存空间所导出内容的存储内容都暂时为undefined
    • 阶段三则是执行JS代码,将其导出值由undefined进行具体赋值

深度优先与广度优先搜索

图30-19 ESModule解析三流程

阶段一:构建阶段

  • 对于构建阶段,会使用fetch从服务器中下载下来main.js文件进行解析
    • 这个解析是一个静态分析的过程,即不会运行该文件的内部代码,也是前面所说不能将import语句放入逻辑代码中的原因,最好将其放在文件的顶层
    • 那静态分析既然不运行代码,那都会做什么?首先扫描代码中的 importexport 语句,找出模块之间的依赖关系,构建工具和浏览器可以创建一个模块依赖图,这个图展示了每个模块依赖哪些其他模块,从而决定加载顺序
    • 由于这种依赖关系在静态分析阶段就被明确知道,因此浏览器可以更高效地进行模块预加载和并行加载,提高应用程序的启动速度,也同时会做检查import和export位置、语法正确性和验证模块路径等内容是否正确,若错误则抛出错误,阻止代码进一步执行,若正确则提供 Tree Shaking 支持,将未使用的导入导出从最终的构建产物中移除,减小文件大小,提高加载速度,这在实际生产项目时导入的各类组件库中是非常常见的优化技术
let isFlag = false
if(isFlag) {
  //错误做法,import语句不能放入逻辑代码中,除非是import()函数
    import {xxx} from "./foo.js"
}

深度优先与广度优先搜索

图30-20 构建阶段

阶段二和三:实例化阶段 – 求值阶段

  • 在阶段一构建结束后,会对解析后的数据结构进行实例化,实例化时,会看这个Module Record数据结构有没有导出内容
    • 在Module Record中的本地会导出(LocalExport)一个count属性,经过实例化后,Module Record会生成Module Environment Record(环境模块记录),在这个环境模块记录中会绑定(Bindings)一个count属性,在实例化阶段,这个属性就记录着值,和本地导出的count对应上
    • 这个记录值首先会先开辟内存空间,才会记录值,而由于实例化阶段还未开始执行JS代码,因此当开辟完内存空间时,会先由JS引擎给出默认值undefined
    • 并且我们可以看到下图中,两个Module Record最终都会对应绑定Module Environment Record(环境模块记录)内部,形成一个统一会合,由此方便其他Module Record进行导入条目使用(ImportEntries)
  • 当进入阶段三的求值阶段,也就是使用ImportEntries进行导入后,会从统一的模块环境记录中取值,而模块环境记录就会进一步找到内存空间中,在这个导入查找过程前会先执行一遍JS代码,已确保内存空间的值已经由undefined替代为真正的数据
    • 并且可以看到图中左右各一条红色箭头线指向内存空间,左边线导出证明是可以改变导出值的
    • 而导入值的线则在通往内存空间的路上被x截断了,无法进行改变内存空间的值
  • 无法逆向修改值,这种做法其实是合理的,在前面所学的也说明过数据的不可变性,对于导出模块来说,清楚知道自己导出的是什么,因为它足够了解自己,但对于导入模块则不一样,若不是其他模块的编写者,是很难明确这一点的,随意的逆向改动数据会造成不必要的风险
  • 但我们也清楚,实际项目中确实是存在需要父传子,子传父,或者更加复杂之间的组件通信的,由此演变出来非常丰富的使用方式,使用频率非常高。但没有关系,虽然ESM规范堵住了这条路,我们会有其他方式的,在后续会进学习相关的事件总线,并亲自来实现

深度优先与广度优先搜索

图30-21 实例化阶段 – 求值阶段

PS:(模块环境记录)不是由我们创建出来的,而是由浏览器(JS引擎)生成的,这也就是说我们下面这个不是对象

  • 为什么在拥有模块记录后,还需要主动生成一个模块环境记录?
    • 模块环境记录是 JS 引擎为管理模块的执行上下文所创建的,负责保存和管理模块中的变量、函数和导入的内容等信息
    • 相对于模块记录,模块环境记录提供运行时的动态绑定和作用域管理,做到模块能够独立执行,并能正确管理模块内部的状态和变量,从而使模块中的变量绑定实时更新作用域独立以及循环依赖的处理成为可能
    • 因此模块记录负责描述模块的静态依赖和结构,而模块环境记录则确保模块在运行时的执行环境和变量状态得以正确管理
//整个的导出内容为LocalExport,xxx为内部属性,如图中的count
export{//这个括号不是对象,而是导出的特殊语法,是为了告诉JS引擎我们想要导出哪些内容
	xxx
}

CommonJS与ESModule相互引用

  • 在讲解import函数时,我们说过在ESM规范出来之前,有大量的项目是在使用CommonJS规范的,所有的改变都是有成本的,过于剧烈的改变会带来更高昂的代价,因此,我们希望能够做到哪怕在CommonJS中,也能够引用ESModule,从而做到做到潜移默化的过渡,也被称为渐进式改变,目的是为了让迁移的代价尽可能减少,降低到可接受或者无感觉的程度
    • 那这个目标需求,是否能够实现呢?这需要规定一个前提,也就是我们在什么环境下去看待这个问题

深度优先与广度优先搜索

图30-22 CommonJS与ES Module相互引用

  • 在浏览器中,是一定不能够实现的,因为浏览器天然不支持CommonJS,在讲解CommonJS的缺点时就已经说明过,会在同源策略上出现问题
  • 在Node环境中,是有机会实现的,对Node版本会有需求(现如今流行版本均支持),同时还会有一些特殊的要求
    1. 首先是一开始认识ES Module所说明的文件扩展名,mjs扩展名用于使用ESM规范,cjs扩展名用于使用CommonJS规范,两个扩展名互相引用会导致冲突(要么都为mjs,要么都为cjs),因此不行
    2. 在正式项目中,会有一个package.json配置文件,在其中设置type字段来实现混合使用
    3. import函数,CommonJS可以解析动态的import()函数,无法解析静态的import语句,如果一定要在CommonJS中使用ESM规范,那就使用import动态函数吧


表30-4 CommonJS与ESM相互引用总结

条件CommonJS 引用 ESMESM 引用 CommonJS
导入方式使用 import() 动态导入直接使用 import 语句
文件扩展名必须为 .mjs 或在 package.json 中设置 "type": "module"必须为 .cjs 或在 package.json 未设置 "type": "module"
路径和扩展名必须明确指定路径和文件扩展名可以按项目 type 字段或文件扩展名解析
package.json 配置通过设置 "type" 字段来指定模块解析方式,混合使用 .cjs.mjs 以实现兼容性
  • 平时开发项目时,我们都会基于脚手架,而脚手架会内置构建工具,例如webpack、vite或者rspack等等,它们会对两种代码规范进行解析,因此天然支持双方的相互引用
  • PS:什么是脚手架,什么是构建工具,真正的项目又是什么样子的,如何管理的,很难通过一两句话说明白,笼统的表述是无法具象清晰的概念,需要上手真正的项目才能进一步了解,大家可以通过ChatGPT等人工智能先行了解,后续我们也许可以在出一个关于项目的系列讲解来帮助大家

后续预告

  • 在下一章节,我们会开始学习npm等包管理工具系列的学习,这是前端知识树中必不可缺的一部分,也是实际项目中,完全无法脱离的一项技术点,重要到如果不使用包管理工具,几乎无法适应现在的项目开发
  • 客观的说,包管理工具其实是属于Node技术栈的一部分,而在一般的前端开发路线中,往往是区分为HTML、CSS、JS这前端三剑客后,马上进入类似Vue、React的框架项目实战开发中
    • 如果你真的是这个路线,那很可能要碰壁了
    • 这个路线太过笼统了,中间有很多细化的技术点没有说明,例如React对JS的掌握程度要求很高,只学JS基础只会撞上南墙,在项目中发起网络请求时需要对计算机网络的网络状态码以及浏览器有所了解....
    • 也包括了实际开发中创建Vue或者React项目,都需要依靠包管理工具先下载一个基础项目模板来使用,如果不使用包管理工具,在以前也可以直接从GitHub仓库下载下来使用,但就便捷、效益等程度上,是完全无法比较的
  • 这些问题都是曾经我所碰到的问题,当时没有人替我解答,也没有GPT等人工智能的辅助,网上对于Node的教程或视频也粗浅,其中的概念也不清晰,让我走了很多弯路。我深刻意识到一个人的闭门造车,是很难顺利成长进步的,因此在这次免费的共学计划中,我们才会推出社群概念辅助大家进行沟通学习,那在下一篇章中,也一定会给大家一个关于包管理器详细的体系学习,争取一步到位