node - 模块化

519 阅读16分钟

模块化开发最终的目的是将程序划分成一个个小的结构

在这个结构中编写属于自己的逻辑代码,有自己的作用域 (即结构和结构之间是相互独立的),定义变量名词时不会影响到其他的结构

这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用, 也可以通过某种方式,导入另外结构中的变量、函数、对象等

这个结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程

早期模块化

早期文件都是通过script元素在页面中进行引入的,js文件并没有自己的独立作用域,为了保证文件和文件之间相互独立

在编写JS文件的时候,会使用IIFE创建一个函数作用域

moduleA.js

const moduleA = (function() {
  let name = 'Klaus'
  let age = 24

  return {
    name,
    age
  }
})()

moduleB.js

const moduleB = (function() {
  let name = 'Steven'
  let age = 32

  return {
    name,
    age
  }
})()

index.js

console.log(moduleA.name)
console.log(moduleB.name)

虽然这么做实现了简单的模块化, 但是依旧存在如下问题:

  1. 这个规范并不是统一的,每个人都可以有自己的具体实现

    比如导出变量的时候,即可以使用return方式来导出

    也可以使用将moduleA挂载到globalThis上的方式来导出

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

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

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

所以我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码

这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性

为此社区就推出了一系列规范,比如Common.js, AMD. CMD

知道ES6中,ECMAScript提出了ESModule,至此JS才拥有了官方模块化

CJS

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

对于CJS,具体的实现平台有如下几个地方:

  1. Node是CommonJS在服务器端一个具有代表性的实现
  2. Browserify是CommonJS在浏览器中的一种实现;
  3. webpack打包工具具备对CommonJS的支持和转换

Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:

  1. 在Node中每一个js文件都是一个单独的模块
  2. 模块中包括CommonJS规范的核心变量:exports、module.exports、require
  3. 可以使用这些变量来方便的进行模块化开发

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

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

exports

exports本质就是是一个普通对象(默认值为空对象),我们可以在这个对象中添加很多个属性,添加的属性会导出

module

let name = 'Klaus'
let age = 23

exports.name = name
exports.age = age

index.js

// require方法会返回对应模块的导出对象,所以可以直接解构
// CJS的本质其实就是导出对象的引用赋值
const { name, age } = require('./moduleA')

console.log(name, age) // => Klaus 23

module.exports

let name = 'Klaus'
let age = 23

// require 和 exports 才是 CJS规范中定义的导入和导出方式
// 但是在Node实现中,因为每一个node的JS文件,本质就是一个模块,是Module类的实例对象
// 所以node在进行模块化导出的时候,实际使用的其实是module.exports对象
// 只不过为了符合对应的规范,所以添加上一个和module.exports相同引用的对象,即exports
console.log(module.exports === exports) // => true

// 使用module.exports进行导出才是最为常见的导出方式
// 但此时相当于将module.exports指向了新对象,因此原本的exports所指向的对象将失去意义
module.exports = {
  name,
  age
}
// 在node中,每一个模块中的代码都会被解析成一个函数后再进行执行
// 所以在node模块中是存在this的,默认情况下,this就是exports所指向的那个对象
console.log(this === exports) // => true

module.exports = {
  name: 'Klaus'
}

console.log(this === exports) // => true
console.log(this === module.exports) // => false

require

require本质就是是一个普通函数,可以帮助我们引入一个文件(模块)中导出的对象

require(X)

X的查找规则

  1. X是一个Node核心模块,比如path、http

    • 直接返回核心模块,并且停止查找
  2. X是以 ./ 或 ../ 或 /(根目录)开头的

    • 将X当做一个文件在对应的目录下查找

    1. 如果有后缀名,按照后缀名的格式查找对应的文件
    2. 如果没有后缀名,会按照如下顺序, 直接查找文件X
      • 查找X.js文件

      • 查找X.json文件

      • 查找X.node文件

    • 没有找到对应的文件,将X作为一个目录, 查找目录下面的index文件

      1. 查找X/index.js文件

      2. 查找X/index.json文件

      3. 查找X/index.node文件

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

  3. 直接是一个X(没有路径),并且X不是一个核心模块

    • 会在当前目录的node_modules中进行查找对应模块
    • 去当前目录的上一层的node_modules中查找对应模块
    • 依次类推
    • 最终在顶层的node_modules中查找对应模块
    • 在顶层的node_modules中依旧找不到对应模块,则报错: not found
// 通过打印 module.paths 可以查看第三方模块在node中的查找顺序
console.log(module.paths)
/*
  =>
    [
      '/Users/klaus/Desktop/demo/node_modules',
      '/Users/klaus/Desktop/node_modules',
      '/Users/klaus/node_modules',
      '/Users/node_modules',
      '/node_modules'
    ]
*/

模块加载过程

  1. 模块在被第一次引入时,模块中的js代码会被运行一次

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

    • 每个模块对象module都有一个属性:loaded
    • loaded属性的默认值为false,当一个模块被加载过以后对应的值就会被转变为true
    • 并将执行结果进行缓存
  3. 如果有循环引入,也就是模块之间的依赖关系图变成图结构的时候

    模块在查找的过程中,会使用深度优先搜索(DFS, depth first search)来进行查找

    而不是使用广度优先搜索(BFS, breadth first search)来进行查找

image.png

上图中,对应模块的加载顺序: main -> aaa -> ccc -> ddd -> eee ->bbb

CJS存在的问题

CommonJS加载模块是同步的

  • 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行
  • 这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快
  • 但是在浏览器中使用CJS的时候,浏览器加载js文件需要先从服务器将文件下载下来,之后再加载运行
  • 那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作

所以在浏览器中,我们通常不使用CommonJS规范

在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD,

而目前,最为常用的在浏览器端实现模块化的方式是使用ES6提供的ESModule

ESM

JavaScript没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD等

直到ES6,ECMAScript才提出了官方的模块化解决方案,ESModule,简称ESM

ES Module和CommonJS的模块化有一些不同之处:

  1. 一方面它使用了import和export关键字
  2. 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式
  3. 采用ES Module将自动采用严格模式:use strict

ES Module模块采用export和import关键字来实现模块化:

  1. export负责将模块内的内容导出
  2. import负责从其他模块导入内容

基本使用

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>

  <!--
    当script标签上加上 type="module" 的时候,对应的引入的js脚本就会被作为一个模块来进行解析
    此时,必须使用服务的方式来运行, 浏览器原生执行JS模块化脚本的时候,file协议是无法正常解析的
  -->
  <script src="./moduleA.js" type="module"></script>
  <script src="./moduleB.js" type="module"></script>
</body>
</html>

moduleA.js

const name = 'Klaus'
const age = 23

function sayHello() {
  console.log('moduleA say hello')
}

// ESM使用export关键字进行导出
// 注意 export关键字后面跟着的不是对象, 而是标识符列表(类似于 {标识符1, 标识符2, 标识符3, ...})
// 所以下面导出的写法并不是对象的增强语法
export {
  name,
  age,
  sayHello
}

/*
   这么写是会报错的
   export {
      name: name,
      age: age,
      sayHello: sayHello
    }
*/

moduleB.js

// ESM使用import关键字来导入
// 同样 导入的并不是普通对象,所以下面语法并不是对象的解构
import {
  name,
  age,
  sayHello
} from './moduleA.js' // 注意: 如果是浏览器原生执行模块化,对应的模块文件后缀不可以省略

console.log(name, age)
sayHello()

export

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

// 导出方式一: 将所有需要导出的标识符,放到export后面的 {}中
export {
  name,
  age,
  sayHello
}
// 导出方式二: 导出时通过as关键字给标识符起一个别名
export {
  // 此时导出的只有fname, 没有name
  // 相当于在导出的标识符列表中,定义了个key为fname,值为name变量对应值的标识符
  name as fname,
  age,
  sayHello
}
// 导出方式三: 在语句声明的前面直接加上export关键字
// 使用这种方式导出的时候,export关键字后面跟着的是任何的合法的声明语句
// 这也就意味着使用这种方式进行变量的导出是无法起别名的
export const name = 'Klaus'
export const age = 23

export function sayHello() {
  console.log('moduleA say hello')
}

import

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

// 导入方式一: import {标识符列表} from '模块‘
// 注意:这里的{}也不是一个对象,里面只是存放导入的标识符列表内容
import {
  name,
  age,
  sayHello
} from './moduleA.js' 
// 导入方式二: 导入时使用as关键字给标识符起别名
import {
  name as fname,
  age,
  sayHello
} from './moduleA.js'
// 通过 * 将模块功能放到一个模块功能对象(a module object)上
// 也就是说这里的moduleA是一个对象类型数据,可以使用点语法或中括号语法去使用其中的属性和方法
import * as moduleA from './moduleA.js'

console.log(moduleA.name)
console.log(moduleA.age)
console.log(moduleA.sayHello())

export 和 import 结合使用

目录结构

|- utils # 模拟有很多模块文件的文件夹
	|- format.js
	|- util.js
|- index.js # 所有模块的入口文件
|- index.html

最开始的使用

foramt.js

export function formatDate() {
  console.log('formatDate')
}

export function formatMoney() {
  console.log('formatMoney')
}

util.js

export function utilA() {
  console.log('utilA')
}

export function utilB() {
  console.log('utilB')
}

index.js

// 使用这种方式导入比较繁琐
// 1. 需要明确知道那些方法和变量是来自于对应的那个文件
// 2. 如果使用的模块比较多的话,会存在大量的导入模块代码
// 3. 如果别的地方也需要使用这些模块,那么别的地方也需要重复编写大量的导入模块代码
import { formatDate, formatMoney } from './utils/format.js'
import { utilA, utilB } from './utils/util.js'

formatDate()
formatMoney()
utilA()
utilB()

为了解决上述问题,我们一般会在有多个模块的文件夹(在这里就是utils)下,新建一个入口文件(一般名为index.js)

入口文件中并不会编写对应的逻辑代码,而是专门用于统一的导出对应文件夹中所有可用的模块

由此,我们只需要引入对应的入口文件即可,因为入口文件进行了统一的暴露,我们就不在需要明确知道那个方法或

那么变量是从对应文件夹下的那个模块导出的了

并且,因为所有的模块都通过入口文件来进行导出,那么我们在多处使用的时候,就只需要从对应的入口文件中导出即可,并不需要去重复编写那些重复的导入代码,提高了可扩展性和可维护性

utils/index.js

// 统一导入
import { formatDate, formatMoney } from './format.js'
import { utilA, utilB } from './util.js'

// 统一导出
export {
  formatDate,
  formatMoney,
  utilA,
  utilB
}

使用

// 有了统一导出对应变量和方法的入口文件后
// 只需要从对应的入口文件中导出所需要的变量和方法即可
import {
  formatDate,
  formatMoney,
  utilA,
  utilB
} from './utils/index.js'

formatDate()
formatMoney()
utilA()
utilB()
// 既然对于统一暴露的入口文件,我们只是想进行进行统一的导出
// 并不会在该文件中使用对应导入的方法和变量
// 那么我们就可以使用如下写法简化导入和导出的方式
// 这种写法是对使用import导入后,再使用export导出的一种语法糖写法
export { formatDate, formatMoney } from './format.js'
export { utilA, utilB } from './util.js'

// 导入并直接导出后,该变量或方法相对于只是在该文件中进行了中转,并不会在该入口文件中存在
// 如果需要使用对应的模块和方法 需要另行单独进行导入
// formatDate() -> error
// 如果我们使用导入后立即导出的方式中转模块
// 在这个过程中,我们需要中转的是模块中所有的属性和方法
// 那么我们可以将代码使用星号进行简化
// 此时导出的只有具名导出,并没有默认导出
export * from './format.js'
export * from './util.js'
// 当然在此过程中,我们可以为我们导出的模块起别名
// 此时导出的内容会被转换为模块对象,而这个模块对象会继续被导出
// 此时我们就可以同时中转导出具名导出和默认导出
export * as formatUtil from './format.js'
export * from './util.js'

/*
  =>
    对应的导入方式:
      import {
        formatUtil,
        utilA,
        utilB
      } from './utils/index.js'
*/

default

之前的导出方式中,所有导出的函数或方法都是有自己具体的名字的,这种导出方式被称之为具名导出(named exports)

在具名导出中:

  1. 在导出export时指定了名字
  2. 在导入import时需要知道具体的名字

但有的时候,我们对应的文件名其实已经代表了我们导出内容的主要功能,

或者我们导出的时候不希望指定具体名称,而是在导入的时候,由使用这自主决定需要使用的名称时

我们就可以使用默认导出(default export)

一个JS模块中 最多只能有一个 默认导出

在默认导出中:

  1. 默认导出export时可以不需要指定名字
  2. 在导入时不需要使用 {},并且可以自己来指定名字
  3. 默认导出方便我们和现有的CommonJS等规范相互操作

导出

function foo() {
  console.log('默认导出')
}

export default foo
// 上述代码也可以被简化为
export default function() {
  console.log('默认导出')
}

导入

// 对于默认导出 在引入对应模块的时候
// 导入名称可以由使用者自主决定
import fooFn from './foo.js'

fooFn()

具名导出和默认导出可以同时存在

可以同时存在多个具名导出,但最多只能存在一个默认导出

导出

export default function() {
  console.log('默认导出')
}

export function foo() {
  console.log('具名导出')
}

导入

import fooFn, { foo } from './foo.js'

fooFn()
foo()

将具名导出 转换为 默认导出

方式一

format.js

export function formatMoney() {
  console.log('formatMoney')
}

export function formatDate() {
  console.log('formatDate')
}

入口文件

export * as default from './format.js'

使用

import formatUtils from './format/index.js'

formatUtils.bar()
formatUtils.baz()

方式二

导出

export function baz() {
  console.log('baz')
}

export function bar() {
  console.log('bar')
}

导入

import * as utils from './foo.js'

utils.bar()
utils.baz()

import()

通过import加载一个模块,是不可以在其放到逻辑代码中

这是因为浏览器在ESM的时候,会在解析代码之前,就从入口文件进行静态分析,找到所有使用的模块,构建依赖关系图 并异步下载对应模块

这个时候js代码没有任何的运行,所以无法根据代码的执行结果来判断对应模块是否需要被引入

同样,引入的模块路径也必须是确定的,不可以是变量,表达式或其它需要在JS运行时才会得到结果的路径

let name = 233

// import声明语句 也就是使用import关键字导入模块,必须被写在模块顶层
// 之前可以存在其它逻辑代码,但是一般性会将引入模块的语句放到这个顶层的最前边进行执行
// 也就是 推荐 在顶层首行引入模块 之前不应该有任何的逻辑语句(注释除外)
import { bar, baz } from './foo.js'

bar()
baz()

但是某些情况下,我们确确实实希望动态的来加载某一个模块, 这个时候我们需要使用 import() 函数来动态加载

因为ESM加载模块是异步的,所以import函数返回一个Promise,可以通过then获取结果

let isFlag = true

if (isFlag) {
  // import().then中返回的结果是一个普通对象
  // 而不是像import关键字那样的标识符列表
  import('./foo.js').then(res => {
    res.bar()
    res.baz()
    res.default() // => 默认导出的内容会被挂载到属性default上
  })
}

import.meta

import.meta是一个给JavaScript模块暴露特定上下文的元数据属性的对象

它包含了这个模块的信息,比如说这个模块的URL

是ES11(ES2020)中新增的特性

// 注意: 只能输出import.meta, 不能直接输出import
// 因为import是一个关键字
// console.log(import) // error
console.log(import.meta) // => {url: 'http://127.0.0.1:5500/index.js'}

ESM解析流程

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

阶段说明
构建(Construction)根据地址查找js文件,并且下载,将其解析成模块记录(Module Record)
实例化(Instantiation)对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址
运行(Evaluation)运行代码,计算值,并且将值填充到内存地址中

image.png

构建阶段

image.png

实例化阶段和运行阶段

image.png

在node中使用ESM

{
  "name": "ts-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  // 在项目的package.json中设置type为module的时候
  // 就可以在项目中使用ESM进行模块化编写,即使该项目是一个node项目
  // 默认值是commonjs
  
  // 1. 当设置type为module的时候,引入模块文件的时候不可以省略后缀名
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}