浅谈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的不同之处

相关推荐
小_太_阳20 分钟前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
智慧老师29 分钟前
Spring基础分析13-Spring Security框架
java·后端·spring
alikami1 小时前
【若依】用 post 请求传 json 格式的数据下载文件
前端·javascript·json
wakangda1 小时前
React Native 集成原生Android功能
javascript·react native·react.js
吃杠碰小鸡1 小时前
lodash常用函数
前端·javascript
丰云1 小时前
一个简单封装的的nodejs缓存对象
缓存·node.js
emoji1111111 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
泰伦闲鱼2 小时前
nestjs:GET REQUEST 缓存问题
服务器·前端·缓存·node.js·nestjs
一个处女座的程序猿O(∩_∩)O2 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
搬码后生仔2 小时前
asp.net core webapi项目中 在生产环境中 进不去swagger
chrome·后端·asp.net