本文从前端模块化的背景出发,详细阐述了CommonJs和EsModule的常用语法、加载原理以及它们之间的异同。
1. 前端模块化的背景
随着Web技术的发展以及互联网的普及,网页承载的功能越来越丰富,前端(即JavaScript)代码承载的逻辑也越来越复杂,衍生出了命名冲突、依赖管理等一系列问题,导致代码难以维护。
- 命名冲突
在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>
- 依赖管理
正常情况下,浏览器就是按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的过程
- moudleFunction接收上述核心变量作为入参,在函数体内执行文件的实际内容(script)
TypeScript
function moduleFunction(exports, require, module, __filename, __dirname) {
script
}
- 编译文件时,生成一个包装函数wrapper,接收script为参数,返回一个字符串
TypeScript
function wrapper(script) {
return `(function (exports, require, module, __filename, __dirname){
${script}
})`
}
- 加载文件时,把文件实际的内容传入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 导入
- 基本语法
Commonjs允许在任意的上下文中,调用require来加载其他模块
TypeScript
const getMessage = require('./b.js') // OK
function getMessageByFunc() {
const getMessage = require('./b.js') // Ok
return getMessage
}
- 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的文件
- require加载文件的原理:先加入缓存,再加载模块
运行时会用Module缓存每个模块加载的信息,具体方式为:
(1)执行require(id)时,查找Module._cache[id]是否有缓存
(2)如果有,就直接返回缓存的内容
(3)否则,将其加入到Module._cache中进行缓存
(4)最后,调用runInThisContext加载该模块
2.2.2 导出
exports和module.exports持有的是同一个引用地址
在同一个文件中,最好只用其中一个,否则可能会造成覆盖
- 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
- 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 静态导入
导出可分为:默认导出和命名导出
导入也可以分为:静态导入和动态导入(所有的静态导入都会被提升到文件顶部)
- 默认导出
TypeScript
export default function App() {}
可以用任意名称进行导入
TypeScript
import App from 'xxx'
// 或进行重命名
import AnyName from 'xxx'
- 命名导出
TypeScript
export interface AppProps {
name: string
}
export const App = () => {}
导入时需要使用解构
TypeScript
import { App, AppProps } from 'xxx'
也可以用重命名的方式
TypeScript
import { App as AnyName } from 'xxx'
- 既有默认导出,也有命名导出
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
- 重定向导出
文件App.ts
TypeScript
export interface AppProps {
name: string
}
文件index.ts
TypeScript
export { AppProps } from './App'
- 只加载模块,不导入变量
TypeScript
import './App'
3.2 动态导入
针对不需要被包含在首次渲染的模块,可以考虑使用import函数进行动态导入
动态导入的更多知识可以移步 ➡️ 前端组件实现动态导入的几种方案
3.3 加载原理
使用EsModule加载JavaScript会经历以下几个阶段:
- 构造:找到并下载所有模块,将每个模块解析成Module Record
- 实例化:为所有导出变量开辟对应的内存,然后使导入和导出都指向对应的内存
- 求值:将所有导出变量的实际值放入内存中
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等配置
以下情况使用CommonJs进行加载 | 以下情况使用EsModule进行加载 |
---|---|
默认情况 | 文件后缀为.mjs |
文件后缀为.cjs | 最近的package.json声明了type='module' |
最近的package.json声明了type='commonjs' |
4.2 加载机制
对于CommonJs而言,导入导出都发生在JavaScript运行时
对于EsModule而言:
-
静态的导入在JavaScript运行前就已经完成 (这个特性使其能够支持Tree Shaking)
-
动态的导入发生在JavaScript运行时
4.3 导出值
- 导出值的个数不同
- CommonJs只允许导出单个值,本质上就是module.exports属性
- EsModule允许导出多个值
- 导出值的绑定方式不同
使用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变量是同一份引用
- 都不允许修改导入的值
使用CommonJs的require以及使用EsModule的import导入的值,都可以看作被const修饰,无法被修改
TypeScript
import { num } from './a'
// Error
num = 100
4.4 循环依赖
循环依赖指的是:a文件的执行依赖于b文件,而b文件的执行依赖于a文件
- 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语句运行时报错
但很有可能导致获取到的导入值不符合预期,从而引发后续代码的运行错误
- 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 互操作
- import语句可以引入esmodule模块或者commonjs模块
- require语句不能引入esmodule模块