Node学习-模块化(node模块加载机制)
一、JavaScript模块化
什么是模块化
模块化开发最终的目的是将程序划分成一个个小的结构;
这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构;
这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用;
也可以通过某种方式,导入另外结构中的变量、函数、对象等;
上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程
早期的JavaScript
在网页开发的早期,
Brendan Eich
开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:
这个时候我们只需要讲JavaScript代码写到script标签中即可;
并没有必要放到多个文件中来编写;甚至流行:通常来说 JavaScript 程序的长度只有一行。
但是随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了:
ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染;
SPA的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现;
包括Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤;
所以,模块化已经是JavaScript一个非常迫切的需求:
但是JavaScript本身,直到ES6(2015)才推出了自己的模块化方案;
在此之前,为了让JavaScript支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS等;
在我们的课程中,我将详细讲解JavaScript的模块化,尤其是CommonJS和ES6的模块化。
没有模块化带来很多的问题
命名冲突
show me bug
新建三个文件:
bar.js
//小明编写的文件var name = '小明'console.log(name); 复制代码
foo.js
//小丽编写的文件var name = '小丽'console.log(name); 复制代码
baz.js
//小明编写的文件console.log(name); 复制代码
小明想在baz.js文件中打印自己定义的name,但是小丽的文件是后引入的会覆盖小明定义的name。这就是命名冲突
index.html
<!DOCTYPE html> <html> <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"> <title>Document</title> </head> <body> <script src="./bar.js"></script> <script src="./foo.js"></script> <script src="./baz.js"></script> </body> </html> 复制代码
打印台输出:
小明
小丽
小丽
怎么解决? --- IIFE(立即执行函数)
bar.js
//小明编写的文件 let ModuleBar = (function() { var name = '小明' var age = 20 return{ name,age } })() 复制代码
foo.js
//小丽编写的文件 let ModuleFoo = (function() { var name = '小丽' var age = 21 return { name,age } })() 复制代码
baz.js
//小明编写的文件 console.log(ModuleBar.name,ModuleBar.age); //小明 20 复制代码
原理:函数有自己的单独作用域。
记得要return,不然拿不到相关属性值。
IIFE的缺点
必须记得每一个模块中返回对象的命名(ModuleFoo,ModuleBar),才能在其他模块使用过程中正确的使用;
代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写;
在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况;
所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。
我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码;
这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性;
JavaScript社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们就学习具有代表性的一些规范
二、Common.js与Node
Common.js是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现广泛性,修改为CommonJS,简称CJS。
Node是CommonJS在服务器端一个具有代表性的实现;
Browserify是CommonJS在浏览器中的一种实现;
webpack打包工具具备对CommonJS的支持和转换;
所以,Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:
在Node中每一个js文件都是一个单独的模块;
这个模块中包括CommonJS规范的核心变量:exports、module.exports、require;
我们可以使用这些变量来方便的进行模块化开发;
前面我们提到过模块化的核心是导出和导入,Node中对其进行了实现:
exports和module.exports可以负责对模块中的内容进行导出;
require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;
案例入手
新建两个文件
main.js:程序执行入口文件,进行导入
//在Node中,每个js文件就是一个模块console.log(name); 复制代码
bar.js:负责导出自己的内容
//在Node中,每个js文件就是一个模块 let name = 'XY' let age = 20 let message = 'My name is XY' function say(name) { console.log('Hello' + name); } 复制代码
此时我去执行node main.js肯定是不可以的,因为我没有进行导入导出
(因为图片引入总是报错,所以尽量不引入图片说明问题)
node main.js的结果:
ReferenceError: name is not defined at Object.<anonymous> (E:\project\vs project\Node学习\03-JavaScript-Module\02-Common.js\main.js:2:13) at Module._compile (internal/modules/cjs/loader.js:1015:30) at Object.Module._extensions..js (internal/modules/cjs/loader.js:1035:10) at Module.load (internal/modules/cjs/loader.js:879:32) at Function.Module._load (internal/modules/cjs/loader.js:724:14) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12) at internal/main/run_main_module.js:17:47 复制代码
这时候就需要exports、require(module.exports下面会说)
首先要在bar.js文件中进行导出
exports.name = name exports.age = age exports.say = say //导出的属性名可以自定义 // exports.myname = name 复制代码
在main.js使用require引入:
//在Node中,每个js文件就是一个模块 const bar = require('./bar.js') console.log(bar.name); console.log(bar.age); bar.say('jack') 复制代码
node main.js:
XY 20 Hellojack 复制代码
这就是导入导出。
但是exports、require的原理?
实际上,exports是一个对象,我们可以在这个对象中添加多个属性,添加的属性会导出:
exports.name = name exports.age = age exports.say = say //name age say 就是exports对象上的属性 exports.name = name exports.age = age exports.say = say console.log(exports); E:\project\vs project\Node学习\03-JavaScript-Module\02-Common.js>node bar.js { name: 'XY', age: 20, say: [Function: say] } 复制代码
那require呢?
main中的bar变量等于exports对象;
也就是require通过各种查找方式,最终找到了exports这个对象;
并且将这个exports对象赋值给了bar变量;
bar变量就是exports对象了;
const bar = require('./bar.js') console.log(bar);
E:\project\vs project\Node学习\03-JavaScript-Module\02-Common.js>node main.js { name: 'XY', age: 20, say: [Function: say] }
可以看到,打印结果和exports是一样的
这里涉及到一个知识点
对象的引用赋值
let obj ={name:'kobe',age:18} let info = obj 复制代码
let info = obj 内部发生了什么?
首先obj是个对象类型,存储在堆当中,栈中保存着堆的地址(老八股文了)
在赋值给info时,只是把地址赋给了info,这样obj,info都指向了同一个内存地址,其中一个修改属性值时,另一个也会变化。
info.name = 'XY' console.log(obj.name) //XY 复制代码
同理exports,require也是这样
所以,bar对象是exports对象的浅拷贝(引用赋值)
浅拷贝的本质就是一种引用的赋值而已
这回懂了吧
module.exports
Node中我们经常导出东西的时候,又是通过module.exports导出的
module.exports和exports有什么关系或者区别呢?
通过维基百科中对CommonJS规范的解析:
CommonJS中是没有module.exports的概念的;
为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module
所以在Node中真正用于导出的其实根本不是exports,而是module.exports;
因为module才是导出的真正实现者
但是,为什么exports也可以导出呢?
这是因为module对象的exports属性是exports对象的一个引用
也就是说 module.exports = exports = main中的bar
三者指向同一地址。
node底层导出实际是以module.exports为基准的,不是exports
来看:
//在Node中,每个js文件就是一个模块 let name = 'XY'; let age = 20; let message = 'My name is XY'; function say(name) { console.log('Hello' + name); } //导出的属性名可以自定义 // exports.myname = name exports.name = name; exports.age = age; exports.say = say; module.exports = { name: 'aaa', age: 20, }; E:\project\vs project\Node学习\03-JavaScript-Module\02-Common.js>node main.js aaa 20 复制代码
可以看到,拿到的是module.exports导出的东西。底层是以module.exports为基准.
关于对象引用的引申:
bar.js:
//在Node中,每个js文件就是一个模块 let name = 'XY'; let age = 20; function say(name) { console.log('Hello' + name); } setTimeout(() => { name = '111' },1000) module.exports = { name: name, age: age, }; 复制代码
main.js:
//在Node中,每个js文件就是一个模块 const bar = require('./bar.js') console.log(bar.name); console.log(bar.age); setTimeout(() => { console.log(bar.name); },2000) 复制代码
node main.js
E:\project\vs project\Node学习\03-JavaScript-Module\02-Common.js>node main.js XY 20 XY 复制代码
可以看到,延迟后打印的名字是XY
为什么?
再来看个例子:
bar.js
//在Node中,每个js文件就是一个模块 let info = { name: '222' }; let age = 20; let message = 'My name is XY'; function say(name) { console.log('Hello' + name); } setTimeout(() => { info.name = '111'; }, 1000); module.exports = { info:info, age: age, }; 复制代码
main.js
//在Node中,每个js文件就是一个模块 const bar = require('./bar.js') console.log(bar.info.name); console.log(bar.age); setTimeout(() => { console.log(bar.info.name); },2000) 复制代码
node main.js
E:\project\vs project\Node学习\03-JavaScript-Module\02-Common.js>node main.js 222 20 111 复制代码
可以看到延迟后名字被修改了
为什么?
解释:
当let name = 'XY';这是值类型,赋值的是完整的值,不是地址,复制后双方修改互不影响
当let info = { name: '222' };这是引用数据类型,修改数据会影响双方,所以两个结果不同。
require()
require是一个函数,可以帮助我们引入一个文件(模块)中导入的对象
那么,require的查找规则是怎么样的呢?
总结:require('X')
情况一:X是一个核心模块,比如path、http直接返回核心模块,并且停止查找
情况二:X是以 ./ 或 ../ 或 /(根目录)开头的
1> 查找X/index.js文件
2> 查找X/index.json文件
3> 查找X/index.node文件
1.如果有后缀名,按照后缀名的格式查找对应的文件
2.如果没有后缀名,会按照如下顺序:
1> 直接查找文件X
2> 查找X.js文件
3> 查找X.json文件
4> 查找X.node文件
第一步:将X当做一个文件在对应的目录下查找;
第二步:没有找到对应的文件,将X作为一个目录,查找目录下面的index文件
如果没有找到,那么报错:not found
情况三:直接是一个X(没有路径),并且X不是一个核心模块
在 /Users/coderwhy/Desktop/Node/TestCode/04_learn_node/05_javascript-module/02_commonjs/main.js 中编写 require('why’)
查找规则:
如果上面的路径中都没有找到,那么报错:not found
模块加载规则
1、模块在被第一次引入时,模块中的js代码会被运行一次
2、模块被多次引入时,会缓存,最终只加载(运行)一次
为什么只会加载运行一次呢?
这是因为每个模块对象module都有一个属性:loaded。 为false表示还没有加载,为true表示已经加载;
3、如果有循环引入,那么加载顺序是什么?
如果出现下图模块的引用关系,那么加载顺序是什么呢?
这个其实是一种数据结构:图结构;
图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
Node采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb
CommonJS规范缺点
CommonJS加载模块是同步的:
同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行;
这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快;
如果将它应用于浏览器呢?
浏览器加载js文件需要先从服务器将文件下载下来,之后在加载运行;
那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作;
所以在浏览器中,我们通常不使用CommonJS规范:
当然在webpack中使用CommonJS是另外一回事;因为它会将我们的代码转成浏览器可以直接执行的代码;
在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD:
但是目前一方面现代的浏览器已经支持ES Modules,另一方面借助于webpack等工具可以实现对CommonJS或者ES Module代码的转换;
AMD和CMD已经使用非常少了,所以这里我们进行简单的演练;
AMD
AMD主要是应用于浏览器的一种模块化规范:
AMD是Asynchronous Module Definition(异步模块定义)的缩写;
它采用的是异步加载模块;
事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的较少了;
我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:
AMD实现的比较常用的库是require.js和curl.js;
实际演练:
下载require.js github.com/requirejs/r…
定义HTML的script标签引入require.js和定义入口文件:data-main属性的作用是在加载完src的文件后会加载执行该文件
<script src="./lib/require.js" data-main="./index.js"></script> 复制代码
index.js
(function () { require.config({ baseUrl: '', path: { foo: './modules/foo', bar: '/module/bar', }, }); require(['foo'], function (foo) {}); }); 复制代码
foo.js
define((['bar'], function (bar) { console.log(bar.name); console.log(bar.age); })); 复制代码
bar.js
define(function () { const name = 'aaa'; const age = 20; return { name, age, }; }); 复制代码
CMD
CMD规范也是应用于浏览器的一种模块化规范:
CMD 是Common Module Definition(通用模块定义)的缩写;
它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来;
但是目前CMD使用也非常少了;
CMD也有自己比较优秀的实现方案:
SeaJS
实际演练:
index.html
<script src="./lib/sea.js"></script> <script> sea.js.use('./index.js') </script> 复制代码
index.js
define(function (require, exports, module) { const foo = require('./module/foo'); }); 复制代码
foo.js
define(function (require, exports, module) { const bar = require('./bar'); console.log(bar.name); console.log(bar.age); }); 复制代码
bar.js
define(function (require, exports, module) { const name = 'aaa'; const age = 20; }); module.exports = { name, age, }; 复制代码
AMD CMD可自行了解
三、ES Module
JavaScript没有模块化一直是它的痛点,所以才会产生前面学习的社区规范:CommonJS、AMD、CMD等,所以在ES6推出自己的模块化系统
ES Module和CommonJS的模块化有一些不同之处:
一方面它使用了import和export关键字;
另一方面它采用编译期的静态分析,并且也加入了动态引用的方式;
ES Module模块采用export和import关键字来实现模块化:
export负责将模块内的内容导出;
import负责从其他模块导入内容;
了解:采用ES Module将自动采用严格模式:use strict
注意:
如果你通过本地加载Html 文件 (比如一个 file:// 路径的文件), 你将会遇到 CORS 错误,因为Javascript 模块安全性需要。需要通过一个服务器来测试
index.html
<script src="./index.js" type="module"></script> 复制代码
index.js
console.log(1); 复制代码
浏览器报错:
可以通过vscode插件Live Server来解决
就可以正常运行了
export
export关键字将一个模块中的变量,函数,类等导出
方式如下:
1、在语句声明前加上export关键字
export let name = 'aaa' 复制代码
2、将所以需要导出的标识,放到export后面的{}中
这里的{}里面不是ES6的对象字面量的增强写法,{}也不是表示的一个对象
所以export:{name:name},是错误的写法
let name = 'bbb'; let age = 20; function say(data) { console.log('Hello ' + data); }
export { name, age, say };
3、导出时给标识符起一个别名
let name = 'bbb'; let age = 20; function say(data) { console.log('Hello ' + data); } export { name as fName, age as fAge, say as fSay }; 复制代码
import
import关键字负责从另一个模块中导入内容
方式如下:
1、import {标识符列表} from '模块'
import {name,say} from './foo.js' 复制代码
2、导入时给标识符起别名
import {name as Name,say as Say} from './foo.js' 复制代码
3、通过*将模块功能放到一个模块功能对象上
import * as index from './foo.js' console.log(index.name); index.say('hhh') 复制代码
export和import结合使用
export {name} from './foo.js' 复制代码
为什么这么做:
在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中;
这样方便指定统一的接口规范,也方便阅读;
这个时候,我们就可以使用export和import结合使用;
default
前面学习的导出方法都是有名字的导出 export{name}
在导出export时指定了名字;
在导入import时需要知道具体的名字;
还有一种导出叫做默认导出(default export)
默认导出export时可以不需要指定名字;
在导入时不需要使用 {},并且可以自己来指定名字;
它也方便我们和现有的CommonJS等规范相互操作;
//默认导出 export default function say(data) { console.log('Hello ' + data); }
//导入。可以自定义名字 import say1 from './foo.js'; say1('hhh');
注意:在一个模块中,只能有一个默认导出(default export);
import函数
通过import加载一个模块,是不可以将其放到逻辑代码中的:
let flag = true if(flag) { import {name} from './foo.js'; } 复制代码
这是因为ES Module在被JS引擎解析时,就必须知道它的依赖关系;
由于这个时候js代码没有任何的运行,所以无法在进行类似于if判断中根据代码的执行情况;
甚至下面的这种写法也是错误的:因为我们必须到运行时能确定path的值;
解析的时候只是let flag 没进行赋值,所以if(flag)不是if(true) 那么也就确定不了依赖关系
但是某些情况下,我们希望动态的加载某一个模块,这个时候就需要impoort函数
let flag = true; if (flag) { import('./foo.js').then((res) => { console.log(res, res.name, res.age); //Module {Symbol(Symbol.toStringTag): 'Module'} 'bbb' 20 }); } 复制代码
因为import函数返回的是一个promise对象,所以可以.then()
CommonJS加载过程
CommonJS模块加载js文件的过程是运行时加载的,并且是同步的:
let flag = true if(flag) { const foo = require('./foo') console.log("if语句继续执行") }
运行时加载意味着是js引擎在执行js代码的过程中加载 模块;
同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行;
CommonJS导出的是一个对象
导出的是一个对象意味着可以将这个对象的引用在其他模块中赋值给其他变量;
但是最终他们指向的都是同一个对象,那么一个变量修改了对象的属性,所有的地方都会被修改;
ES6 Module加载过程
ES Module加载js文件的过程是编译(解析)时加载的,并且是异步的:
编译时(解析)时加载,意味着import不能和运行时相关的内容放在一起使用:
比如from后面的路径需要动态获取;
比如不能将import放到if等语句的代码块中;
所以我们有时候也称ES Module是静态解析的,而不是动态或者运行时解析的;
异步的意味着:JS引擎在遇到import时会去获取这个js文件,但是这个获取的过程是异步的,并不会阻塞主线程继续执行;
也就是说设置了 type=module 的代码,相当于在script标签上也加上了 async 属性;
如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的js文件和代码不会阻塞它们的执行;
ES Module通过export导出的是变量本身的引用:
export在导出一个变量时,js引擎会解析这个语法,并且创建模块环境记录(module environment record);
模块环境记录会和变量进行 绑定(binding),并且这个绑定是实时的;
而在导入的地方,我们是可以实时的获取到绑定的最新值的;
所以,如果在导出的模块中修改了变化,那么导入的地方可以实时获取最新的变量;
foo.js
let name = 'bbb'; let age = 20; setTimeout(() => { name = 'dddd' },1000) export { name, age }; 复制代码
index.js
import {name,age} from './foo.js'; console.log(name); setTimeout(() => { console.log(name); },2000) 复制代码
打印结果
bbb 2s后: dddd 复制代码
注意:在导入的地方不可以修改变量,因为它只是被绑定到了这个变量上(其实是一个常量)
index.js
setTimeout(() => { name = 'gggg' console.log(name); },2000) 复制代码
思考:如果foo.js中导出的是一个对象,那么index.js中是否可以修改对象中的属性呢?
foo.js
let obj = {name:'xxx'} let age = 20; export { obj, age }; 复制代码
index.js
import {obj,age} from './foo.js'; console.log(obj.name); setTimeout(() => { obj.name = 'lll' console.log(obj.name); },2000) 复制代码
打印结果:
xxx 2s后: lll 复制代码
画图解析
如果foo.js导出的是个对象比如obj
那么是引用类型,那么导入的一方指向的也是同一内存空间,双方都可以修改属性
如果导出的不是对象,如let name = 'xxx' let age = 20那么导入方就不允许修改
因为会有一个模块环境记录
bindings:实时绑定 Const name = name Const age = age 复制代码
第一个name是维护的常量,导入的一方指向的就是这个常量,所以不允许修改的,第二个name是foo中的引用也就是xxx
当我在foo中进行修改的时候,比如 name = 'dddd'
那么实际上是把Const name = name = 'xxx'删除掉,然后重新记录Const name = name = 'dddd'
Node对ES Module的支持
正常情况下,我们在node里去使用ES语法是不支持的
foo.js
let name = 'xxx'; let age = 20; export { name, age }; 复制代码
main.js
import { name, age } from './foo.js'; console.log(name); console.log(age); 复制代码
node main.js
E:\project\vs project\Node学习\03-JavaScript-Module\06-Node_ES6_Module>node main.js (node:14364) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. E:\project\vs project\Node学习\03-JavaScript-Module\06-Node_ES6_Module\main.js:1 import { name, age } from './foo.js'; ^^^^^^ SyntaxError: Cannot use import statement outside a module at wrapSafe (internal/modules/cjs/loader.js:931:16) at Module._compile (internal/modules/cjs/loader.js:979:27) at Object.Module._extensions..js (internal/modules/cjs/loader.js:1035:10) at Module.load (internal/modules/cjs/loader.js:879:32) at Function.Module._load (internal/modules/cjs/loader.js:724:14) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12) at internal/main/run_main_module.js:17:47 复制代码
在最新的Current版本(v14.13.1)中,支持es module我们需要进行如下操作:
方式一:在package.json中配置 type: module(后续学习)
方式二:文件以 .mjs 结尾,表示使用的是ES Module;
这里暂时选择以 .mjs 结尾的方式来演练:
将两个文件都修改为.mjs后缀,注意引入文件那里把.js改为.mjs
node main.mjs
E:\project\vs project\Node学习\03-JavaScript-Module\06-Node_ES6_Module>node main.mjs (node:16344) ExperimentalWarning: The ESM module loader is experimental. xxx 20 复制代码
CommonJS和ES Module的交互
结论一:通常情况下,CommonJS不能加载ES Module
因为CommonJS是同步加载的,但是ES Module必须经过静态分析等,无法在这个时候执行JavaScript代码;
但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持;
Node当中是不支持的;
结论二:多数情况下,ES Module可以加载CommonJS
ES Module在加载CommonJS时,会将其module.exports导出的内容作为default导出方式来使用;
这个依然需要看具体的实现,比如webpack中是支持的、Node最新的Current版本也是支持的;
但是在最新的LTS版本中就不支持
作者:HoldInLine
链接:https://juejin.cn/post/7035498106909098021