浅谈CJS和ESM

JavaScript的发展史

JavaScript的发展壮大

在JavaScript诞生之初只是作为一个脚本语言来使用,主要是做一些简单的表单校验等,因为代码量不多,所以是直接跟html写在一个文件里面,并且用script标签包裹

javascript 复制代码
// index.html
<script>
    var name = 'shiyuq'
    var age = 18
</script>

但是随着业务越来越复杂,尤其在ajax出现后代码量飞速增长,开发者们纷纷将JavaScript代码写到单独的js文件中,与html文件解耦,如下:

javascript 复制代码
// index.html
<script src='./index.js'></script>

// index.js
var name = 'shiyuq'
var age = 18

再后来,多个开发者都将自己的js文件引入到一个html文件:

javascript 复制代码
// index.html
<script src='./index.js'></script>
<script src='./index-shiyuq1.js'></script>
<script src='./index-shiyuq2.js'></script>
​
// index.js
var name = 'shiyuq'
var age = 18
​
// index-shiyuq1.js
var name = 'faker'
var age = 28
​
// index-shiyuq2.js
var name = (name) => {
    return `hello ${name}`
}
var age = (age) => {
    return age + 1
}

不难发现,问题已经稍有眉头了,此时用哪个文件中的变量完全就取决于谁引用在最下面(在调用它之前),如果在不同文件中变量的类型还不一致,就会导致程序直接崩溃,这种从某种程度上来讲也属于全局变量污染,开发者的噩梦就此来临

模块化的出现

为了解决全局变量污染的问题,开发者开始使用命名空间的方法,如下所示:

javascript 复制代码
// index.html
<script src='./index.js'></script>
<script src='./index-shiyuq1.js'></script>
<script src='./index-shiyuq2.js'></script>
​
// index.js
app.module = {}
app.module.name = 'shiyuq'
app.module.age = 18
​
// index-shiyuq1.js
app.moduleA = {}
app.moduleA.name = 'faker'
app.moduleA.age = 28
​
// index-shiyuq2.js
app.moduleB = {}
app.moduleB.name = (name) => {
    return `hello ${name}`
}
app.moduleB.age = (age) => {
    return age + 1
}

此时,已经有隐隐约约的模块化的概念了,只不过是用命名空间来实现的。但还是有个隐性问题,index-shiyuq1.js的文件作者可以很方便的通过app.module.name来获取到模块index.js中的name,当然也可以很方便的去修改它,但是修改却让index.js毫不知情。这是不允许发生的!

接着,聪明的开发者们又想到了JavaScript的函数作用域,振臂一呼,用闭包可以解决现在的问题。

javascript 复制代码
// index.html
<script src="./index.js"></script>
<script src="./index-shiyuq1.js"></script>
<script src="./index-shiyuq2.js"></script>
​
// index.js
app.module = (function() {
    var name = 'shiyuq'
    var age = 18
    return {
        getName: () => name,
        getAge: () => age
    }
})()
​
// index-shiyuq1.js
app.moduleA = (function() {
    var name = 'faker'
    var age = 28
    return {
        getName: () => name,
        getAge: () => age
    }
})()
​
// index-shiyuq2.js
app.moduleB = (function(name, age) {
    var name = name
    var age = age
    return {
        getName: () => name,
        getAge: () => age
    }
})('hello shiyuq2', 28 + 1)

现在index-shiyuq2.js可以通过app.moduleA.getName()来获取到模块A中的名字,但是各个模块的名字都保存在各自的函数里面,无法修改,但是由于模块的加载有先后顺序,模块B可以访问模块A,但是模块A却无法访问模块B

所以模块化,不仅要处理全局变量污染,数据的保护,还要解决模块间的依赖关系

CommonJS应运而生

为了解决上面出现的一系列问题,所以需要制定模块化的规范,CommonJS就是新的规范,下面来讲解以下CommonJS大致的作用

概述

node应用是由模块组成,采用了CommonJS规范,每个文件就是一个模块,有自己的作用域,且在一个文件中定义的变量、函数和类都是私有的,对其他文件不可见

javascript 复制代码
// index.js
var age = 28
var getAge = (val) => val - 10

上面的age和getAge都是当前index.js文件私有的,但是如果你想要在多个文件中分享变量,可以使用global关键字

javascript 复制代码
global.name = 'shiyuq'

虽然但是,不推荐!

CommonJS规范规定:每个模块内部有两个变量可以使用,分别是requiremodule

require:用来加载一些模块

module:代表的是当前模块,是一个对象且上面保存了当前模块的信息。然后它上面有一个exports属性,保存着当前模块要导出的接口或者变量,使用require加载其他模块获取的值其实就是module上面的exports属性

javascript 复制代码
// a.js
var name = 'shiyuq'
var age = 18
module.exports.name = 'shiyuq'
module.exports.age = 18
​
// b.js
var a = require('a.js') // 使用require加载a模块
console.log(a.name) // shiyuq
console.log(a.age) // 18

CommonJS------exports

为了方便,nodejs在实现CommonJS规范的时候,为每个模块都提供了一个exports的私有变量,指向了module.exports,于是你可以理解为exports和module.exports都指向了一个内存地址,所以你给exports中添加属性的同时,module.exports的数据也会同步变化,相当于在每个模块开始的地方,加入了下面一行代码:

javascript 复制代码
var exports = module.exports

所以上面的代码也可以这么写:

javascript 复制代码
// a.js
var name = 'shiyuq'
var age = 18
exports.name = 'shiyuq'
exports.age = 18

warning:

由于exports是模块内部的私有局部变量,它是指向了module.exports的地址,所以你直接对他赋值是不可取的,这相当于改变了此变量的内存地址!!!

javascript 复制代码
// a.js
var name = 'shiyuq'
var age = 18
exports = name // 这个是不可以的哦,因为你想要导出的肯定是name和age两个属性,现在会导致你实际并未往module.exports上赋值

请看下面的代码:

javascript 复制代码
var module = {exports: {}}
var exports = module.exports
console.log(exports) // {}
console.log(module.exports) // {}
var name = 'shiyuq'
var age = 18
exports = name
console.log(exports) // shiyuq
console.log(module.exports) // {}

同样,如果你对module.exports重新赋值,也是需要注意的地方

javascript 复制代码
var name = 'shiyuq'
exports.name = name
console.log(module.exports) // {name: 'shiyuq'}
module.exports = 'hello shiyuq'
console.log(module.exports) // 'hello shiyuq'

建议:可以只使用一种导出的方式,建议使用module.exports,写在每个模块的结尾

CommonJS的实现

我们先了解以下CommonJS的一些模块的特点:

1:所有的代码都运行在模块的作用域,不会污染全局作用域

2:模块可以多次加载,但只会在第一次加载时运行一次,之后缓存运行结果,以后再次加载,直接读取缓存结果,想要让模块再次运行,需要清除缓存

3:模块加载的顺序,按照其在代码中出现的顺序

在了解了CommonJS的一些关键规范和特点后,我们不难发现CommonJS的主要使用的技术,离不开三个关键字,就是exportsmodulerequire

javascript 复制代码
// a.js
var name = 'shiyuq'
var age = 18
​
exports.name = name
exports.age = age
​
// b.js
var a = require('a.js')
console.log(a.name) // shiyuq
console.log(a.age) // 18
​
var name = 'jenny'
var age = 17
exports.name = name
exports.age = age
​
// c.js
var b = require('b.js')
console.log(b.name) // jenny

所以结合第一部分咱们对于Javascript的解析后,不难写出CommonJS的简易实现(使用立即执行函数),将require、exports、module三个参数传入,再把模块代码放入立即执行函数中,模块的导出值放在module.exports中,这样就实现了模块的加载,如下:

javascript 复制代码
(function(module, exports, require) {
    // b.js
    var a = require('a.js')
    console.log(a.name) // shiyuq
    console.log(a.name) // 18
    
    var name = 'jenny'
    var age = 17
    exports.name = name
    exports.age = age
})(module, module.exports, require)

知道了CommonJS的实现原理后,就很容易可以把规范的项目代码转换成浏览器支持的代码,例如咱们熟知的webpack,咱们以webpack为例,看看使用webpack构建的时候,主要做了哪些工作? javascript

javascript 复制代码
// bundle.js
(function(modules) {
    // 模块管理
})({
    'a.js': function(module, exports, require) {
        // a.js 的文件内容
    },
    'b.js': function(module, exports, require) {
        // b.js 的文件内容
    },
    'c.js': function(module, exports, require) {
        // c.js 的文件内容
    }
})

接下来,我们需要按照CommonJS的规范,实现模块管理中的内容,然后,我们知道加载过的模块会被缓存,所以我们需要一个对象来缓存加载过的模块,然后需要一个require函数来加载模块,在加载的时候需要生成一个module,并且module上需要有一个exports属性,用来接收模块导出的内容

javascript 复制代码
// bundle.js
(function(modules) {
    // 模块管理
    var cachedModules = {}
    // 加载模块的方法
    var require = function (moduleName) {
        // 如果已经有了缓存,直接返回
        if (cachedModules[moduleName]) return cachedModules[moduleName].exports
        
        // 如果没有加载,就生成一个module,并且放入到cachedModules中
        var module = {
            moduleName,
            exports: {}
        }
        cachedModules[moduleName] = module
        
        // 执行要加载的模块
        // 这里的call只是把this指向到了当前的module.exports,然后会把所有的模块中导出的内容自动加载到module的exports属性上
        modules[moduleName].call(module.exports, module, module.exports, require)
        
        // 最后返回module.exports
        return module.exports
    }
    
    return require('a.js')
})({
    'a.js': function(module, exports, require) {
        // a.js 的文件内容
    },
    'b.js': function(module, exports, require) {
        // b.js 的文件内容
    },
    'c.js': function(module, exports, require) {
        // c.js 的文件内容
    }
})

所以上面基本已经实现了CommonJS的核心规范

require文件的加载流程

咱们上面已经讲过了require文件的时候,文件会有缓存,那么咱们一般在开发的时候,会有三种类型,通常是核心模块、文件模块以及第三方模块,那么require在加载的时候,遵循什么规律呢?

核心模块:像fs、http、path等等,都会被识别为nodejs的核心模块

文件模块:像是使用./../作为相对路径的文件模块,/作为绝对路径的文件模块

第三方模块:非路径形式并且也是非核心模块的模块,被称为第三方模块,比如咱们常用的lodash、moment等等

其中核心模块的加载速度最快,因为已经被编译成二进制代码;而文件模块由于第一次加载会被缓存,所以第二次加载的时候也会很快;所以咱们需要关注的是第三方模块的加载,加载顺序如下:

  • 首先是在当前的node_modules目录查找
  • 如果没有,在父级目录的node_modules中查找,如果没有,继续向上查找
  • 沿着路径递归,直至根目录下的node_modules
  • 如果没有,提示模块未找到(module *** not found)

那既然知道了require是如何引入模块的,那么我们来看下面这个问题:循环引入

javascript 复制代码
// a.js
var b = require('b.js')
console.log('我是a文件')
exports.sayHi = () => {
    console.log(b())
}
​
// b.js
var a = require('a.js')
console.log('我是b文件')
var userInfo = {
    name: 'shiyuq',
    age: 18
}
module.exports = () => userInfo
​
// main.js
var a = require('a.js')
var b = require('b.js')
console.log('我是入口文件')

接下来大家可以在终端中输入node main.js,运行结果如下:

bash 复制代码
我是b文件
我是a文件
我是入口文件

所以从上面的运行结果,不难看出CommonJS在分析模块的加载阶段,采用的是深度优先遍历,执行的顺序是父->子->父,但是要注意的是在b.js模块还没有加载完成的时候,此时在b.js模块中是没有a模块的sayHi方法的,因为a模块还没有导出sayHi方法【如果你循环引用了,就会出现这样的问题,这在我们的工作中,已经是令人非常头疼的存在】

其他模块化方案

我们学习了CommonJS的模块化规范,知道了它的模块加载机制是同步的,这在服务端是可行的,但是在浏览器端,难免会出现页面假死,阻塞后续代码执行,为了解决这个问题,所以后面又发展了一些其他的模块化规范

后端 CommonJS Node.js
前端 AMD RequireJS
CMD Sea.js
前后端 ES6 Modules ES6

因为本来对于前端没有进行深入的研究,个人看法是一开始出现的RequireJS其实就是为了解决CommonJS规范不能用于浏览器的问题,而AMD就是RequireJS在推广过程中对模块定义规范化的产出;而后面出现的sea.js,是由于有人觉得AMD规范是异步的,不够自然和垂直,所以创造了sea.js,大家可以像nodejs一样书写模块代码,随之而然形成了CMD规范

CommonJS是服务于服务端的,AMD和CMD是服务于客户端的,但是他们都有一个共同点,就是只有在代码运行后才能确定导出的内容,所以从es6开始,ES6 Module将会取代其他规范,成为两端通用的模块解决方案

ES6 Module

从ES6开始,在语言标准的层面,就实现了模块化功能,具体可以看阮一峰ES6 Module

ES6模块的设计思想是尽可能的静态化,是的编译的时候就能确定模块的依赖关系,输入和输出的变量

javascript 复制代码
// CommonJS
const {stat, exists, readFile} = require('fs')
​
// 等同于
const fs = require('fs')
const {stat, exists, readFile} = fs

实际CommonJS是去加载了整个模块,然后生成了一个对象,最后再从该对象上取到我们需要的方法,这种也叫做运行时加载,也就是在程序运行起来的时候才能得到这个对象,而ES6模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入

javascript 复制代码
// es6
import {stat, exists, readFile} from 'fs'

export和import

export:规定模块的对外接口

import:输入其他模块提供的功能

javascript 复制代码
// a.js
export var name = 'shiyuq'
export var age = 20
​
// 也可以这么写;推荐使用,因为你可以在模块的结尾很清楚的知道你导出了哪些变量
var name = 'shiyuq'
var age = 20
exports {
    name,
    age
}

你还可以重命名

javascript 复制代码
// 输出函数getName
export function getName (name) {
    return 'hello' + name
}
​
// 你还可以这样
function getAge () {}
​
export {
    getAge as getAgeNew
}

但是你需要特别注意,export命令规定必须与模块内部的变量建立一一对应的关系

javascript 复制代码
// 错误
export 1
// 错误
var name = 'shiyuq'
export name

上面实际导出的都是一个值,没有对应关系

javascript 复制代码
// 建立name和shiyuq的对应关系
export var name = 'shiyuq'
​
// 建立name和shiyuq的对应关系
var name = 'shiyuq'
export {name}
​
// 建立name和shiyuq的对应关系
var name = 'shiyuq'
export {name as nameNew}
​
// 错误
function f() {}
export f
​
// 建立f变量和函数的对应关系
export function f() {}
​
// 建立f变量和函数的对应关系
function f() {}
export {f}

需要注意的是,export可以出现在模块的任何位置,只要处于模块的顶层,如果处于块级作用域,将会报错

使用了export命令定义了模块的对外接口后,其他的模块可以通过import命令加载这个模块

javascript 复制代码
// b.js
import {name, age} from 'a.js'
​
// 重命名
import {name as nameNew, age} from 'a.js'

要注意,import命令具有提升效果,会放到整个模块的顶部,首先执行

javascript 复制代码
console.log(name)
import {name} from 'a.js'

因为import命令是编译阶段执行的,在代码运行之前

由于import是静态执行,所以不可以使用表达式和变量(他们只能在代码运行的时候才有具体的结果)

我们还可以整体加载

javascript 复制代码
import * as a from 'a.js'
console.log(a.name) // shiyuq

从上面可以看出使用import的时候,用户需要知道所要加载的变量名或者函数名,否则无法加载,所以为了方便用户,我们可以使用export default命令

javascript 复制代码
// a.js
export default function() {
    console.log('shiyuq')
}

上面是默认输出的一个匿名函数,我们可以在其他模块中这么使用

css 复制代码
import a from 'a.js'
a() // shiyuq

所以我们常常会在源码中看到以下代码

javascript 复制代码
import _ from 'lodash'
​
// 同时输入默认方法和其他变量
import _, {each} from 'lodash'
​
//对应的export语句
export default function(obj) {}
​
export function each(obj, iterator, context) {}
​
export {each as forEach}
​
// 如果你想输出默认值
export default 18
// 这里实际是有一个default变量,然后把18赋值给了变量default,所以没有报错

模块加载的实质

CommonJS模块输出的是一个值的浅拷贝,而ES6模块输出的是值的引用,它在遇到import命令时,不去执行模块,而是生成一个动态的只读引用,等真的需要用到的时候再去模块中取值,所以ES6时动态引用,并不会缓存值

javascript 复制代码
// 举个栗子
let name = 'shiyuq' // 值引用
const exports = {name: name} // 此时开辟了一个堆内存存储并且赋值给exports变量
console.log(exports.name) // shiyuq
exports.name = 'jenny'
console.log(exports.name) // jenny
console.log(name) // shiyuq
​
// 再看一个
let module = {exports: {}}
const moduleA = (function(module, exports, require) {
    let name = 'shiyuq'
    const setName = (n) => name = n
    const getName = () => name
    module.exports = {
        name,
        setName,
        getName
    }
    return module.exports
})(module, module.exports, {})
​
console.log(moduleA) // {name: 'shiyuq', getName: f, setName: f}
​
moduleA.getName() // shiyuq
moduleA.name // shiyuq
​
moduleA.setName('jenny')
moduleA.getName() // jenny
moduleA.name // shiyuq

现在大家应该了解了为什么内部的变化不会影响到外部的值了,但是这个也仅仅只是针对的原始值,如果是引用值那就会跟着变化了

但是ES6的模块运行机制和CommonJS不一样,它在遇到import时,只是生成一个动态的只读引用,有点像linux里面的软链接,如果原始值变了,import输入的值也会跟着变化

javascript 复制代码
// a.js
export let counter = 3
export function incCounter() {
    counter++
}
​
// b.js
import {counter, incCounter} from 'a.js'
console.log(counter) // 3
incCounter()
console.log(counter) // 4

可见ES6模块不会缓存运行结果,而是动态获取

ES6模块的循环加载

ES6模块是动态引用,所以那些变量并不会被缓存,而是成为一个指向被加载模块的引用,只要你作为开发者能确保它真的能取到值

javascript 复制代码
// a.js
import {bar} from 'b.js'
console.log('a.js')
console.log(bar)
export const foo = 'foo'
​
// b.js
import {foo} from 'a.js'
console.log('b.js')
console.log(foo)
export const bar = 'bar'

上面a模块中引用b,b模块中引用a,造成循环引用,现在执行node a.js查看运行结果

javascript 复制代码
b.js
ReferenceError: foo is not defined

这是因为在执行a模块的时候,其中引入了b模块,去b模块中加载,首先打印b.js,然后打印foo,但是此时a模块中并未输出foo接口,所以报错

大家也可以写一个 简单的demo,测试一下ES6打包后的代码,这样可以更加清晰的了解ES6和CommonJS的不同之处

相关推荐
aPurpleBerry7 分钟前
JS常用数组方法 reduce filter find forEach
javascript
码农派大星。14 分钟前
Spring Boot 配置文件
java·spring boot·后端
ZL不懂前端1 小时前
Content Security Policy (CSP)
前端·javascript·面试
乐闻x1 小时前
ESLint 使用教程(一):从零配置 ESLint
javascript·eslint
杜杜的man1 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu1 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s1 小时前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子1 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王1 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构