...

彻底搞清楚JS的模块化import/export/require...问题

彻底搞清楚JavaScript的模块化问题

在写这篇文章之前,请让我自己的脑袋放空一下。忘记什么是JavaScript,忘记什么是编程,把自己当成一个是什么都不会的白痴来看这篇文章。

1. 谈谈历史

我们无论讨论什么技术问题,都要开始从历史谈起,因为技术是为了解决问题的,问题出现都是有历史背景的,抛开这些历史背景去谈技术问题,很容易搞不清逻辑,时间线搞不明白就记的不深刻,记得不深刻就是学了就忘记。

历史背景

首先JS为什么要有模块?因为JavaScript的创始人在创立这个代码的时候其实并没有予以厚望,只是想在浏览器验证一下输入框检查这种很弱智的事情。所以创造语言的时候也就没有多想。

<script src="jsForDemoHtml.js"></script>   

最初也就简单的引入1个JS,几十行的代码。就这样的而已,事实上没有考虑模块。没有考虑到你以后会写几千行,甚至几十万行的问题。

所以如果出现了下面的问题,比如不同的文件会有相同的变量名!

但是下面这种不同模块(也就是不同文件)的冲突问题就不能解决了,因为会覆盖变量!!

// a.js  
var foo = "ok"  

// b.js  
var foo = "ng"  

// 谁知道你最后用的是a.js的变量?还是b.js的变量呢?  

解决历史问题

后来为了解决命名冲突等问题,产生了一些解决的方法

  • IIFE (Immediately Invoked Function Expression) 函数立即执行表达式 → 通过匿名函数的来解决

因为函数是有作用域的。

function(){}() // 这样写前面不会被解析成一段代码块  

(function(){})() // 要加上括号  

// 为了避免一些解析上不会被看成一段完整代码的问题还要加上;  
;(function(){  
    var foo = "okk"  
    console.log(foo);  
})()  

// 这样你就不能随便的在外面拿到你内部的变量(数据)了  
console.log(foo);  

但是这样如何让别的文件也来用你内部的作用域呢?

通过给 IIFE 赋值,然后在 IIFE 内部返回你需要暴露的变量。

// a.js  
var moduleBar = (function () {  
 var foo = "okk"  
 var bar = "bar"  

 // 这样就返回去了  
  return {  
    foo,  
    bar  
  }  
})()  

// 以后谁想用a.js的变量,直接就可以这样获取  
// b.js   
moduleBar.foo  

但是上面有一个麻烦

  • 谁用你的变量还要跑到你的地盘打开你的文件看看你咋命名的?
  • 大量的匿名函数IIFE 太脏!

大家想着,每个公司如果都有一个类似于↑那种moduleBar的规范的话,那么就会造成混乱。

比如你的公司用的是moduleBar,万一我写的也正好冲突了呢?!

所以大家都受不了了!就开始想别的方法!! 然你后CommoJS的规范就出来了!

2. CommonJS模块化规范诞生

注意!!!这只是一个规范!!!这不是一个语法,只是一个规范。

规范是什么意思呢?就是我给你说了一个大方向,你自己去实现。

国家给了你一个政策,各个地方都因地制宜制定不同的细则的感觉!

比如模块化的规范在NodeJS里就是CommonJS,在ES6里就是ES module。

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

既然CommonJS只是一个规范,那么NodeJS是怎么实现这种规范的呢?

模块化最重要的就是解决导入和导出!

  • 导入 require() 永远记住,这是一个函数!!

  • 导出 → 比较难记忆

  • exports → 本质是对象

  • module.exports

require 和 exports 的本质

我来直接说一下导出本质吧!!

require() 本质是函数,返回对象

exports 本质就是一个对象,可以在里面添加属性,属性就是你想暴露的玩意儿

首先一个模块想导出谁,直接给对象exports添加属性

// module.js  
const foo = "abc"  
const bar = "def"  
// exports是一个对象  
// 其实就是给exports这个对象赋值一个属性,属性名可以随便取  
exports.foo = foo; // exports.newfoo = foo; 也可以的  

再来看导入

const moduleName = require("module.js")  
// 其实每次 require 都会返回一个对象  

// 也就是 require 通过各种查找方式,最终找到了 exports 这个对象;  
// 并且将这个 exports 对象赋值给了 moduleName 变量;  
// moduleName变量就是 exports 对象了;  

// 所以就可以直接写  
moduleName.foo // 就可以拿到了  

// 所以按照es6语法也就可以写成  
const { foo } = moduleName // 解构赋值  

其实内存表现就是这样的 所以修改 exports.foo 里面改变的话,那么moduleName.foo 也会改变!

并且因为赋值顺序是

const bar = require("我就是在找exports在哪里,找啊找")  
// 最后把找到的exports的地址给 bar 就行   
// 也就是本质  
const bar = 0x100 // exports的地址  

image-20220330000954939

module.exports 和 exports 有什么关系

那么 module.exports 和 exports 有什么关系或者区别呢

我们追根溯源,通过维基百科中对CommonJS规范的解析:

CommonJS中是没有module.exports的概念的

但是为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是 module

所以在Node中真正用于导出的其实根本不是exports,而是module.exports; 因为module才是导出的真正实现者;

  • new Module() 一个js就是一个 module 实例
  • 所有的文件都有一个module全局对象,不信可以在 console.log(module)来看看
  • 真正导出的人就是 module.exports
  • 源码里面可以看看 module.exports = exports

其实就是exports只是个桥梁,只是个中间调节者,最后的大BOSS还是 module.exports

image-20220330003809366

那么这俩有啥区别呢

module.exports = exports  
// exports 有对象的时候就用exports  
// 但是一旦module.exports自己有了啥,但就以module.exports为准  
module.exports = {  
  foo:"aaa"  
}  

以上所有都是CJS,而不是ES规范,CJS规范本身是有缺点的。

那是什么缺点呢?

3. CommonJS规范的缺点

  • CommonJS加载模块是同步的
  • 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行
  • 这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快
  • 如果将它应用于浏览器呢
  • 浏览器加载js文件需要先从服务器将文件下载下来,之后在加载运行
  • 那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作
  • 所以在浏览器中,我们通常不使用CommonJS规范

当然在webpack中使用CommonJS是另外一回事,因为它能帮我们把代码转换成浏览器可以直接执行的代码

4. ES module 横空出世

为了解决CJS规范的一些问题,ES6就横空出世了!!!

  • 采用关键字import还有export而不是函数require()或者对象module.exports
  • 采用严格模式 use strict

import export 是啥

首先自己要知道一个地方就是 这俩是特殊的关键字 不是对象更不是函数

import {  

}  

export {  

}  
// 上面看起来像是一个对象,但其实没有 export = {} 本质就是一个关键字   
// 导出的是里面变量的引用  

导出 export

  • 导出(2个类)
  • 有名字的导出 named export
  • 默认导出 default export
//  named export 导出方式三种  

// 1.方式一:  
export const foo = 'foo';  
export const bar = 'bar';  
export const hoge = (foo) => {console.log(foo)}  

// 2.方式二: {}中统一导出  
// {}大括号, 但是不是一个对象  
// {放置要导出的变量的引用列表}  
const foo = 'foo';  
const bar = 'bar';  
const hoge = (foo) => {console.log(foo)}  
export {  
    foo,  
    bar,  
    hoge  
}  

// 3.方式三: {} 导出时, 可以给变量起 别名  
export {  
    foo as Cfoo,  
    bar as Cbar,  
    hoge as Choge  
}  

默认导出 default export

  • 一个文件只能有一个 default export
//  named export 导出方式三种  
export default const foo = () => {}  

导入 import

// 1. 方式一  
// import {标识符列表} from '模块';  
// 这里的{}也不是一个对象,里面只是存放导入的标识符列表内容;  
import { foo, bar, hoge} from "./module.js"  

// 2. 方式二  
// 导入时给标识符起别名  
import { foo as Cfoo, bar, hoge} from "./module.js"  
// Q:如果在导出的时候已经给取别名了咋办?  
// A: 可以在别名的基础上在命名一次 但一定要用export 别名的那个命名  
import { Cfoo as CDfoo, bar, hoge} from "./module.js"  

// 3. 方式二  

关于import其实她还是个函数,用来解决什么问题呢?

先想象一个场景,比如

// 当flag为true的时候才导入某个模块  
let flag = true  
if (flag) {  
  import time from "./module.js" // NG  
  // 但是require()就可以 因为require是函数,运行阶段才执行  
  // 但是因为ES module不认require() ※ 除非你用的是webpack的模块化打包工具  
}  

NodeJS在解析阶段其实就会解析import time from "./module.js",所以直接像上面这么写,是会报错的。但require()是一个函数,解析阶段并不会执行。

所以上面import错误的原因就是一段解析阶段的代码你放到了运行阶段去执行!!

那么怎么办呢?

import() 闪亮登场!!

// 当flag为true的时候才导入某个模块  
let flag = true  
if (flag) {  
  // 因为是异步加载 非同步 有个返回值 返回值是promise  
  const promise = import("./module.js")   
      .then((res)=>{  
      console.log(res)  
    })  
      .catch()  
}  

于是接下来验证大概是这样的

// 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" />  
    <script src="demo.js" type="module"></script>  
    <title>Document</title>  
  </head>  
  <body></body>  
</html>  

// demo.js  
let flag = true;  
if (flag) {  
  const promise = import('./module.js')  
    .then((res) => {  
      console.log('print in res');  
      console.log(res.obj);  
    })  
    .catch((err) => {  
      console.log(err);  
    });  
}  

// module.js  
const obj = {  
  name: 'chin',  
  age: 199,  
};  
let foo = 'iiii';  
export { obj, foo };  

5. 关于ES导出的内存图

下面假如是 main.js 需要引入 a.js

// a.js  
let name = 'chin';  
let age = 18;  

setTimeout(()=>{  
    name = "nnnn";  
})  

export {  
    name,  
    age  
}  

这个时候有个main.js 需要导入

// main.js  
import { name, age } from './a.js';  

console.log(name);  

那么内存的表现是什么样子的?其实

1. export在导出一个变量时js引擎会解析这个语法  
2. 并且创建 模块环境记录(module environment record)  
3. 模块环境记录会和变量进行绑定(binding)  
4. 这个绑定是实时的,而在导入的地方,我们是可以实时的获取到绑定的最新值的  

image-20220330204254099

6. CommonJS和ES Module能互通吗

require和export混用

方式一

通常情况下,CommonJS不能加载ES Module  
  因为CommonJS是同步加载的,但是ES Module必须经过静态分析等,无法在这个时候执行JavaScript代码;  
  但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持  
  Node当中是不支持的  
// a.js  
const name = "chin"  
export {  
    name  
}  
// main.js 导入文件a  
require("a.js") // 这样可以不?  

方式二

多数情况下ES Module可以加载 CommonJS  
  ES Module在加载CommonJS时会将其module.exports导出的内容作为default导出方式来使用  
  这个依然需要看具体的实现比如webpack中是支持的Node最新的Current版本也是支持的  
// a.js  
const age = 10;  
module.exports = {  
  age  
}  

// main.js → 默认用的是default形式  
import foo from "./a.js"  

总结

其实也写了这么多了,如果实在觉得看着挺麻烦的。可以直接看下面这个。

  • CommonJS规范用的是require(),exports组合
  • require()本质函数。需要谁,导入谁,返回值是对象。
  • exports本质对象。想暴露哪个变量,就给exports增加属性就行
  • module.exports 是NodeJS实现的exports,module.exports = exports ,module.exports每个文件都有,本质是一个实例,所以最后还是以module.exports为准
  • ES module用的是import,export组合
  • import 本质是一个关键字。特殊情况下也可以当函数import()
  • export 本质是关键字。看起来装的样子是对象,因为可以export{},但是仔细一看,人家对象可是 export = {}才对啊,所以这玩意儿就是个关键字。

如果有一天我又混淆了,一定要先分清,自己是两大阵营的哪一块,然后再来看一遍这篇文章吧。

共有评论(2)

...
EuDs63

感谢,这篇文章写得很棒

2024-03-08
...
hokyo  博主   回复了 EuDs63

Good!

2024-03-12
登陆即可评论哦