面试官:你真的了解CommonJs和EsModule吗?

本文从前端模块化的背景出发,详细阐述了CommonJs和EsModule的常用语法、加载原理以及它们之间的异同。

1. 前端模块化的背景

随着Web技术的发展以及互联网的普及,网页承载的功能越来越丰富,前端(即JavaScript)代码承载的逻辑也越来越复杂,衍生出了命名冲突、依赖管理等一系列问题,导致代码难以维护。

  1. 命名冲突

在script标签顶层作用域声明的变量(或函数)是全局共享的,不同script标签内不能声明相同名称的变量(或函数)

HTML 复制代码
<!DOCTYPE html>
<html>
  <head>
    <script>
      const a = 1
    </script>
    <script>
      // SyntaxError: Identifier 'a' has already been declared
      function a(){}
    </script>
  </head>
  <body>这是一行文字</body>
</html>
  1. 依赖管理

正常情况下,浏览器就是按script标签的排列顺序去执行JavaScript的

也就是说,排在后面的JavaScript文件能够调用排在前面的JavaScript文件所声明的变量或函数,反之却不能

HTML 复制代码
<!DOCTYPE html>
<html>
  <head>
    <script src='./index.js' />
    <!-- a.js能够调用index.js声明的变量或函数,但不能调用b.js声明的变量或函数 -->
    <script src='./a.js' />
    <script src='./b.js' />
  </head>
  <body>这是一行文字</body>
</html>

因此,需要引入模块化的方案去解决上述问题。

模块化的核心在于:将代码拆分为多个文件(即模块)

  • 每个模块履行自己的职责,在自己的作用域内编写代码、不影响其他模块
  • 每个模块可以导出变量,同时也可以从其他模块中导入变量
  • 所有模块按照某种规范进行组装,从而完成整个系统所需的功能

2. CommonJs

CommonJs是NodeJs环境下运行JavaScript的一种模块化规范,基本语法为:

  • 通过require进行导入

  • 通过exports或者module.exports进行导出

2.1 实现原理

首先明确几个核心变量的含义:

  • require:引入的模块
  • exports:当前模块导出的值
  • module:记录当前模块的所有信息
  • __dirname:NodeJs提供的、用于指向当前文件所在目录的绝对路径
  • __filename:NodeJs提供的、用于执行当前文件的绝对路径

加载文件的过程,其实就是:用runInThisContext执行一个moudleFunction的过程

  1. moudleFunction接收上述核心变量作为入参,在函数体内执行文件的实际内容(script)
TypeScript 复制代码
function moduleFunction(exports, require, module, __filename, __dirname) {
  script
}
  1. 编译文件时,生成一个包装函数wrapper,接收script为参数,返回一个字符串
TypeScript 复制代码
function wrapper(script) {
  return `(function (exports, require, module, __filename, __dirname){
   ${script}
  })`
}
  1. 加载文件时,把文件实际的内容传入wrapper中,最后调用NodeJs提供的runInThisContext方法去执行
TypeScript 复制代码
// js文件实际的内容
const a = 1;
const b = 2;
console.log(a, b);

// 运行时的伪代码
const moudleFunction = wrapper(`
  const a = 1;
  const b = 2;
  console.log(a, b);
`)

runInThisContext(moduleFunction)(module.exports, require, module, __filename, __dirname)

2.2 加载原理

2.2.1 导入

  1. 基本语法

Commonjs允许在任意的上下文中,调用require来加载其他模块

TypeScript 复制代码
const getMessage = require('./b.js') // OK
function getMessageByFunc() {
  const getMessage = require('./b.js') // Ok
  return getMessage
}
  1. require查找文件的原理:核心模块 > 文件模块 > 第三方模块
TypeScript 复制代码
const fs = require('fs') // 核心模块
const getMessage = require('./b.js') // 文件模块
const crypto = require('crypto-js') // 第三方模块

当require方法执行的时候,CommonJs会通过传入require的这个唯一参数(标识符)去找到对应的模块,优先级从高到第排列如下:

(1)核心模块:NodeJs提供的系统核心模块,例如fs、querystring等

(2)文件模块:以./或../或/开头的参数,会被当做文件模块进行处理,CommonJs会通过这个路径找到对应的模块

(3)第三方模块:它的查找遵循以下原则:

  • 在当前目录下的node_modules目录下查找,如果找不到就一直向上(父目录)递归

  • 在查找过程中,会找package.json下main指向的文件,如果没有package.json,则会查找名为index的文件

  1. require加载文件的原理:先加入缓存,再加载模块

运行时会用Module缓存每个模块加载的信息,具体方式为:

(1)执行require(id)时,查找Module._cache[id]是否有缓存

(2)如果有,就直接返回缓存的内容

(3)否则,将其加入到Module._cache中进行缓存

(4)最后,调用runInThisContext加载该模块

2.2.2 导出

exports和module.exports持有的是同一个引用地址

在同一个文件中,最好只用其中一个,否则可能会造成覆盖

  1. exports:把需要导出的js值绑定到exports对象上
TypeScript 复制代码
const object = {
  age: 99,
  address: 'sz'
}
const name = 'qq'
exports.name = name
exports.object = object

// 实际导出的对象
// {
//  name: 'qq',
//  object: {
//    age: 99,
//    address: 'sz'
//  }
// }

❗用exports = {}重新赋值exports是无效的,因为exports是作为参数传入函数moudleFunction中的

TypeScript 复制代码
function moduleFunction(exports, require, module, __filename, __dirname){
  script
}

在函数中对参数进行重新赋值,是不会修改原来的引用的,而是在函数自身的作用域内创建了一份新的引用

例如:如果我们想在函数体内修改参数值

❌ 对参数进行重新赋值

TypeScript 复制代码
let age = 1
function func(object) {
  age = 99
}
func(age)
console.log(age)
// 打印出age值为1

✅ 修改参数的属性

TypeScript 复制代码
let object = { age: 1 }
function func(object) {
  object.age = 99
}
func(object)
console.log(object)
// 打印出object.age值为99
  1. module.exports:允许我们为其赋值为一个任意的js值,例如:对象、函数等

导出一个对象

TypeScript 复制代码
const object = {
  age: 99,
  address: 'sz'
}
const name = 'qq'
module.exports = {
  name,
  object
}

导出一个函数

TypeScript 复制代码
const name = 'qq'
module.exports = function () {
  return name
}

2.2.3 流程分析

我们有a、b、index三个文件如下

a.js

TypeScript 复制代码
const getObject = require('./b')
console.log('a 文件加载')
exports.say = function() {
  const object = getObject()
  console.log(object)
}

b.js

TypeScript 复制代码
const say = require('./a')
const object = {
  name: 'b',
  age: 99
}
console.log('b 文件加载')
module.exports = function() {
  return object
}

index.js

TypeScript 复制代码
const a = require('./a')
const b = require('./b')
console.log('index文件加载')

运行命令node index.js后打印结果如下:

  • b文件加载
  • a文件加载
  • index文件加载

具体加载流程如下:

(1)执行index.js的第1行,通过require引入a.js

(2)因为Module中没有a.js的缓存,所以先将其加入缓存,再执行a.js

(3)执行a.js的第1行,通过require引入b.js

(4)因为Module中没有b.js的缓存,所以先将其加入缓存,再执行b.js

(5)执行b.js的第1行,通过require引入a.js

(6)因为Module中已经有a.js的缓存,所以不会再执行a.js

(7)执行b.js剩余内容,打印b 文件加载

(8)回到a.js文件,执行a.js剩余内容,打印a文件加载

(9)回到index.js文件,执行index.js的第2行,通过require引入b.js

(10)因为Module中已经有b.js的缓存,所以不会再执行b.js

(11)执行index.js的第3行,打印index文件加载

需要注意的是:在上述第(5)和第(6)步时,a.js还没有对say函数进行导出,导致b.js的第1行实际上是无法获取到say函数的

我们可以在b.js的第1行后面加一行打印来验证一下:

TypeScript 复制代码
// b.js
const say = require('./a')
console.log('打印a模块', say)
const object = {
  name: 'b',
  age: 99
}
console.log('b 文件加载')
module.exports = function() {
  return object
}

执行node index.js后打印结果如下:

  • 打印a模块,{}
  • b文件加载
  • a文件加载
  • index文件加载

要在b.js中获取到a.js导出的say函数,可以通过Promise或者setTimeout的方式进行异步获取

TypeScript 复制代码
// b.js
const say = require('./a')
const object = {
  name: 'b',
  age: 99
}
console.log('b 文件加载')
setTimeout(() => {
  console.log('打印a模块', say) // { say: [Function (anonymous)] }
})
module.exports = function() {
  return object
}

3. EsModule

从ES6开始,JavaScript衍生出了自己的模块化规范EsModule,基本语法为:

  • 通过import进行导入
  • 通过export进行导出

3.1 静态导入

导出可分为:默认导出和命名导出

导入也可以分为:静态导入和动态导入(所有的静态导入都会被提升到文件顶部)

  1. 默认导出
TypeScript 复制代码
export default function App() {}

可以用任意名称进行导入

TypeScript 复制代码
import App from 'xxx'
// 或进行重命名
import AnyName from 'xxx'
  1. 命名导出
TypeScript 复制代码
export interface AppProps {
  name: string
}
export const App = () => {}

导入时需要使用解构

TypeScript 复制代码
import { App, AppProps } from 'xxx'

也可以用重命名的方式

TypeScript 复制代码
import { App as AnyName } from 'xxx'
  1. 既有默认导出,也有命名导出
TypeScript 复制代码
export interface AppProps {
  name: string
}
export default function App() {}

导入时可以用解构的方式

TypeScript 复制代码
import App, { AppProps } from 'xxx'

也可以用重命名的方式

TypeScript 复制代码
import App, * as AnyName from 'xxx'

type T = AnyName.AppProps
  1. 重定向导出

文件App.ts

TypeScript 复制代码
export interface AppProps {
  name: string
}

文件index.ts

TypeScript 复制代码
export { AppProps } from './App'
  1. 只加载模块,不导入变量
TypeScript 复制代码
import './App'

3.2 动态导入

针对不需要被包含在首次渲染的模块,可以考虑使用import函数进行动态导入

动态导入的更多知识可以移步 ➡️ 前端组件实现动态导入的几种方案

3.3 加载原理

使用EsModule加载JavaScript会经历以下几个阶段:

  1. 构造:找到并下载所有模块,将每个模块解析成Module Record
  2. 实例化:为所有导出变量开辟对应的内存,然后使导入和导出都指向对应的内存
  3. 求值:将所有导出变量的实际值放入内存中

详见 Es modules: a-cartoon-deep-dive

3.3.1 构造

这个阶段的工作是:找到并下载所有模块,将每个模块解析成Module Record

目的是:生成一份名为Module Map的映射,以url为key,每个url对应一份Module Record

每份Module Record记录了当前模块导入和导出的信息:

  • 导入信息:当前模块依赖的其他模块、当前模块导入的变量
  • 导出信息:当前模块导出的变量

这样做是为了对模块信息进行缓存:当某个模块a被多个模块依赖时,我们能在Module Map中找到下载模块a的url,从而使得模块a最终只被下载一次

3.3.2 实例化

这个阶段的工作是:为所有模块导出的变量开辟对应的内存

目的是:为每份Module Record生成一份对应的Module Environment Record,用于记录该模块导入和导出的变量所在的内存地址

3.3.3 求值

这个阶段的工作是:运行每个模块顶层作用域中的代码,计算出所有导出变量的实际值,将其放入对应的内存中

4. 对比

4.1 node加载方式

运行node filename命令的方式,取决于文件扩展名以及package.json等配置

详见 NodeJs: 确定模块系统

以下情况使用CommonJs进行加载 以下情况使用EsModule进行加载
默认情况 文件后缀为.mjs
文件后缀为.cjs 最近的package.json声明了type='module'
最近的package.json声明了type='commonjs'

4.2 加载机制

对于CommonJs而言,导入导出都发生在JavaScript运行时

对于EsModule而言:

  • 静态的导入在JavaScript运行前就已经完成 (这个特性使其能够支持Tree Shaking

  • 动态的导入发生在JavaScript运行时

4.3 导出值

  1. 导出值的个数不同
  • CommonJs只允许导出单个值,本质上就是module.exports属性
  • EsModule允许导出多个值
  1. 导出值的绑定方式不同

使用CommonJs加载时:

  • 导出的非对象类型的值:是一份快照
  • 导出的对象类型值:是一份引用

a.js文件

TypeScript 复制代码
let count = 1
function plus() {
  count++
}
function getCount() {
  return count
}
module.exports= {
  count,
  plus,
  getCount
}

index.js文件

TypeScript 复制代码
const { count, plus, getCount } = require('./a')
plus()
// 打印 1
console.log(count)
// 打印 2
console.log(getCount())

运行命令node index.js,根据打印结果可以看出:

  • 从a.js引入的count变量与a.js内的count变量并不是同一份引用
  • 如果需要从a.js获取到最新的count值,可以在a.js导出一个getCount函数去返回count(因为a文件内的getCount函数总是能访问到最新的count值)

使用EsModule加载时,导出的值(无论是否为对象)是一份引用

a.js

TypeScript 复制代码
export let count = 1
export function plus() {
  count++
}
export function getCount() {
  return count
}

index.js文件

TypeScript 复制代码
import { count,plus,getCount } from './a.js'
plus()
// 打印 2
console.log(count)
// 打印 2
console.log(getCount())

运行命令node index.js,根据打印结果可以看出:

  • 从a.js引入的count变量与a.js内的count变量是同一份引用
  1. 都不允许修改导入的值

使用CommonJs的require以及使用EsModule的import导入的值,都可以看作被const修饰,无法被修改

TypeScript 复制代码
import { num } from './a'
// Error
num = 100

4.4 循环依赖

循环依赖指的是:a文件的执行依赖于b文件,而b文件的执行依赖于a文件

  1. CommonJs

a.js

TypeScript 复制代码
const getObject = require('./b')
exports.say  = function() {
  console.log('say')
}

b.js

TypeScript 复制代码
const say = require('./a')
say()
const object = { name: 'b'}
module.exports = function() {
  return object
}

运行命令node a.js会抛出错误TypeError: say is not a function

让我们分析一下加载过程:

(1)执行a.js的第1行,通过require引入b.js

(2)因为Module中没有b.js的缓存,所以先将其加入缓存,再执行b.js

(3)执行b.js的第1行,通过require引入a.js

(4)因为Module中已经有a.js的缓存,所以不会再执行a.js

(5)执行b.js的第2行,调用say函数,抛出错误,程序终止

根本原因在于:CommonJs是在JavaScript运行时进行导入导出的

直接原因在于:当a.js的第1行还没有执行结束,也就是说a.js还没有对say函数进行导出时,b.js的第1行获取到的say变量实际是一个空对象

在CommonJs中,循环依赖并不会导致require语句运行时报错

但很有可能导致获取到的导入值不符合预期,从而引发后续代码的运行错误

  1. EsModule

我们把上述两个文件改写成EsModule的语法如下:

a.js

TypeScript 复制代码
import { getObject } from './b.js'
export function say() {
  console.log('say')
}

b.js

TypeScript 复制代码
import { say } from "./a.js";
say()
const object = {name: 'b'}
export function getObject() {
  return object
}

运行命令node a.js会正常打印出say

让我们分析一下加载过程:

(1)构造:为a.js和b.js分别生成对应的Module Record

(2)实例化:为所有导出的变量(say和getObject)开辟内存,并生成Module Environment Record记录对应的内存地址

(3)求值&运行:对所有导出的变量(say和getObject)进行求值,将其放入对应的内存地址中,最后运行函数体外的代码,即b.js的第2行,打印出say

EsModule不会有循环依赖的问题,因为import和export的动作被提前了

4.5 互操作

详见 NodeJs: 与CommonJs的互操作性

  1. import语句可以引入esmodule模块或者commonjs模块
  2. require语句不能引入esmodule模块

参考资料

相关推荐
susu1083018911几秒前
前端css样式覆盖
前端·css
学习路上的小刘2 分钟前
vue h5 蓝牙连接 webBluetooth API
前端·javascript·vue.js
&白帝&2 分钟前
vue3常用的组件间通信
前端·javascript·vue.js
小白小白从不日白13 分钟前
react 组件通讯
前端·react.js
罗_三金23 分钟前
前端框架对比和选择?
javascript·前端框架·vue·react·angular
Redstone Monstrosity30 分钟前
字节二面
前端·面试
东方翱翔37 分钟前
CSS的三种基本选择器
前端·css
Fan_web1 小时前
JavaScript高级——闭包应用-自定义js模块
开发语言·前端·javascript·css·html
yanglamei19621 小时前
基于GIKT深度知识追踪模型的习题推荐系统源代码+数据库+使用说明,后端采用flask,前端采用vue
前端·数据库·flask
千穹凌帝1 小时前
SpinalHDL之结构(二)
开发语言·前端·fpga开发