JS中处理模块化的机制CommonJS与ESModule

638 阅读9分钟

什么是模块化

事实上模块化开发最终的目的是将程序划分成一个个小的结构,这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构,这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用也可以通过某种方式,导入另外结构中的变量、函数、对象等。上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程。

模块化的历史

在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的,这个时候我们只需要讲JavaScript代码写到<script>标签中即可。并没有必要放到多个文件中来编写;甚至流行:通常来说 JavaScript 程序的长度只有一行。但是随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了,ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染,SPA的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现,包括Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤。所以,模块化已经是JavaScript一个非常迫切的需求。

但是JavaScript本身,直到ES6(2015)才推出了自己的模块化方案,在此之前,为了让JavaScript支持模块化,涌现出了很多不同的模块化规范:AMDCMDCommonJS等。

没有模块化带来的问题

最典型的就是一个,命名冲突的问题。在早期我们一般使用立即函数调用表达式(IIFE)来解决问题,因为函数可以解决作用域这个问题。IIFE(Immediately Invoked Function Expression)

文件夹Kobe下index.js

var moduleB = (function() {
  var name = "why"
  var isFlag = false

  return {
    name: name,
    isFlag: isFlag
  }
})()

文件夹why下index.js

var moduleA = (function() {
  var name = "why"
  var age = 18
  var isFlag = true

  return {
    name: name,
    isFlag: isFlag
  }
})()

文件夹why下why.js

(function() {
  if (moduleA.isFlag) {
    console.log("我的名字是" + moduleA.name)
  }
})()

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 src="./why/index.js"></script>
  <script src="./kobe/index.js"></script>
  <script src="./why/why.js"></script>
</body>
</html>

但是,我们其实带来了新的问题

  • 第一,我必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用
  • 第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写;
  • 第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况

所以我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。所以我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码,这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性,JS社区为了解决上面的问题,涌现出了一系列好用的规范,由于AMD与CMD都不更新了,本文仅介绍CommonJS。

commonJS规范和Node的关系

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

  • Node是CommonJS在服务器端一个具有代表性的实现
  • webpack打包工具具备对CommonJS的支持和转换

所以,Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发。 在Node中每一个js文件都是一个单独的模块,这个模块中包括CommonJS规范的核心变量:exports、module.exports、require,我们可以使用这些变量来方便的进行模块化开发

前面我们提到过模块化的核心是导出和导入,Node对其进行了实现

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

CommonJS的基本使用

why.js 负责导出

const name = "why"
const age = 18

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

module.exports = {
  name,
  age,
  sum  //或者可以module.exports.sum  = sum 这种
}

main.js 在导入的时候我们有好几种做法。

  • 做法一:直接解构 module.exports的对象
const {name,age,sum} = require("./why.js") 
console.log(name);
console.log(age);
console.log(sum(10,20));

  • 做法二:定义一个对象,让它赋值为导出对象。 这里的赋值相当于引用赋值。(也就是说它们执行的其实是一个对象)
const why = require("./why.js") //这里的require里的东西就是module.exports 返回的那个对象
console.log(why.name);
console.log(why.age);
console.log(why.sum(10,20));

require exports 内存地址都一样

我们要注意,这里导出方案里的module.exports 和 导入的require("./why.js")它们都是一个对象。 举例,比如 whytest.js

const info = {
  name:'why',
  age:18,
  foo(){
    console.log('foo函数');
  }
}

module.exports = info //将地址传过去

maintest.js

const {name,age,foo} = require('./whytest.js')

这里其实require('./whytest.js')其实指向的就是info对象了。

在这里info , module.exports 和 require 三个其实在内存中指向的是同一个对象。

关于exports详解

我们常见的有两种导出方式

首先第一种是module.exports = {} (后面跟对象)

在whytest.js文件中

const name = "why"
const age = 18
function sum(num1,num2){
  return num1 + num2
}

module.exports = {
  age,
  name,
}

第二种exports.xxx = xxx,这种方式基本被抛弃了。只有在很早的代码汇总才能见到,他这是为了严格符合commonjs规范设计的。

在whytest.js文件中

const name = "why"
const age = 18
function sum(num1,num2){
  return num1 + num2
}

// module.exports = {
//   age,
//   name,
// }

//第二种导出方式(这种方式基本已经被抛弃了,除了很早的代码),他这是为了符合commonjs规范设计的
exports.name = name
exports.age = age
exports.sum = sum

//这种操作,在代码中也有可能碰到
// module.exports = {}
// exports = module.exports

下面是几种错误

//所以你这种操作一定是不行的,要想导出一定得有module.exports
// exports = {
//   name,
//   age
// }


// exports.name = name 
// exports.age = age
// exports.age = age
// module.exports = {

// } //这样也是不行的,这个时候这个对象变空了,绝对不行!

require的细节

我们现在已经知道,require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。 require的查找规则可以查看这里:nodejs.org/dist/latest…

这里我们对常见的情况做一些总结:导入格式如下require(xxx)

  • 情况一,xxx是Node的核心模块比如http、path,这种情况直接返回核心模块并且停止查找
  • 情况二,xxx是路径比如require('./abc'),这种情况下,它会先查找abc,如果有后缀名直接按后缀名找到对应的文件。如果没有后缀名查找顺序是这样的:1st .js 2nd .json 3rd .node 如果没有以上的文件,它会把这个xxx当成是文件夹,并在此文件夹下寻找对应的index文件,还是按照js -> json -> node这样寻找。这个时候还没找到就会报错not found
  • 情况三:xxx不是路径也不是核心模块,这个时候就会直接在nodemodule里找xxx文件夹下的index.js文件了(xxx/index.js) 比如 const axios = require('axios')就是在node module文件夹下找到axio下的index.js文件。

image.png

image.png

模块加载顺序以及细节

  • 结论一:模块在被第一次引用时,模块中的js会执行一次。
  • 结论二:模块在被多次引用时,会缓存,最终只加载运行一次。
  • 结论三,如果有循环引用,那么加载顺序是什么?这种形成了一种数据结构,图结构,node采用的是深度优先算法。(深度优先可以理解,通过一个一直追溯,追溯到了再回去,开始第二个)

ESModule模块化

JavaScript没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD等, 所以在ES推出自己的模块化系统时,大家也是兴奋异常。

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

  • 一方面它使用了import和export关键字;
  • 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式

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

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

补充一点,采用ES Module将自动采用严格模式:use strict

ESModule的基本使用

foo.js(负责导出)

export const name = "why"
export const age = 18

main.js(负责导入)

import {name,age} from './foo.js'

console.log(name);
console.log(age);

index.html中(在node中不支持ESModule语法),这个地方主义script标签里type一定要是module,并且直接打开这个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>
  
  <!-- 必须要加type = module才能 识别模块化的语法 -->
  <!-- 这里用liveserver 可以 打开 https http都行,但是ftp,文件url都不可以 使用会报错 -->
  <!-- webstorm 打开网页的时候默认也开启了本地服务 -->
  <script src="./main.js" type="module"></script>
</body>
</html>

ESModule的其他使用

export的基本使用

  • export 用法一:export 声明语句

foo.js

//1.第一种方式:export 声明语句
export const name = "why"
export const age = 18

export function foo(){
  console.log('foo function');
}

export class Person{

}
  • export 用法二:export 导出和 声明分开

foo.js

//2. 第二种:export 导出 和 声明分开
const name = "why"
const age = 18
function foo(){
  console.log("foo function");
}

//这个{}是固定语法,它不是对象,不要乱用对象的语法
export {
  name, //name : '123‘ //不允许的
  age,
  foo 
}
  • export 用法三:在第二种上进行优化,导出的时候起别名。

//3.第三种方式:在第二种上进行优化,也就是导出时起别名
export {
  name as fName,
  age as fAge,
  foo as fFoo
}

第三种用的其实不多,因为一般只有导入的时候才会起别名。

import的基本使用

  • import 用法一:普通导入main.js
//1. 导入的方式一: 普通的导入
import {name,age} from './foo.js'
  • import 用法二:起别名 (导入的时候用到的会多)main.js
import {name as FName, age as fAge ,foo as fFoo} from './foo'
  • import 用法三:将导出的所有内容放到一个标识符中。使用as。这里用foo作为标识。main.js
import * as foo from './foo.js'  
console.log(foo.name);
console.log(foo.age);

默认导出

工具类封装

在实际开发中,我们会将工具类放到一个utils文件夹下,并且会建一个index.js负责统一导出(暴露)。

utils/formate.js下

function timeFormat(){
  return "2222-12-12"
}

function priceFormat(){
  return "222.22"
}

export{
  timeFormat,
  priceFormat
}

utils/math.js下

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

function sub(num1,num2){
  return num1 - num2
}

export {
  add,
  sub
}

utils/index.js下

//在utils文件中统一通过index.js暴露出去


//1.导出方式一:
// import {add,sub} from './math.js'
// import {priceFormate} from './formate.js'

// export {
//   add,
//   sub,
//   priceFormate
// }

//2.导出方式二:这个阅读性强一点
export {add,sub} from './math.js'
export {priceFormat} from './formate.js'

//3.导出方式三: 通配符全部导出,第三方库,会经常用,全导出去。
// export * from './math.js'
// export * from './formate.js'

default值

foo.js

const name = "why"
const age = 18

const foo = "foo value"

//1.默认导出方式一:
export {
  name as FName,
  age,
  // foo as default
}


//2.默认导出的方式二:常见
//最终要的我们就是默认导出
export default foo
//注意:默认导出只能有一个!!!

main.js

// import {name,age} from './foo.js'
// import * as foo from './foo.js'

//导入语句:导入默认的导出
import why from './foo.js'

console.log(why); 

import函数

import函数返回的结果是一个Promise,这种异步的代码是不会足阻塞后面代码的运行的,而默认的导入是同步的。

// import {name,age,foo} from './foo.js'

//import函数返回的结果是一个Promise
//异步的是不会阻塞后面的运行的
import("./foo.js").then(res => {
  console.log("res:",res); //res就是那个模块
})

//默认导入的是同步的一个过程
console.log("后续的代码都是不会运行的~");

//es11新增的特性
//meta属性本身也是一个对象:{url:"当前模块所在的路径"}
console.log(import.meta); //meta那个词,元宇宙,可能是下一个互联网的风口

//第一代传统互联网
//第二代移动互联网
//第三代可能就是元宇宙

注意commonjs和es6在webpack情况下可以共存。

采用ESModule导入的时候,不能乱修改导入的值,不然会报错。