Node.js<三>——模块化开发和原理解析
require细节
我们现在已经知道,require是一个函数,可以帮助我们引入一个文件(模块)中导入的对象或数据。
require查找规则
导入格式如下:require(X)
- 情况一:X是一个核心模块,比如fs、path、http,则其会直接返回核心模块,并且停止查找
- 情况二:X是以 ./ 或 ../ 或 /(根目录)开头的
-
- 第一步
-
-
- 如果有后缀名,按照后缀名的格式查找对应的文件
- 如果没有后缀名,会按照如下顺序:
-
-
-
-
- 直接查找文件X
- 查找X.js文件
- 查找X.json文件
- 查找X.node文件
-
-
-
- 第二步:没有找到对应的文件,将X作为一个目录
-
-
- 查找目录下面的index文件
-
-
-
-
- 查找X/index.js文件
- 查找X/index.json文件
- 查找X/index.node文件
-
-
-
- 如果没有找到,那么报错: not found
- 情况三:直接是一个X(没有路径),并且X不是一个模块,比如说require('why'),因为其不是一个模块也不是以./ 或 ../ 或 /开头,所以不满足情况一和情况二
-
- 那么他就会按照module.paths中的顺序去各自目录下的node_modules文件夹下寻找why
-
- 如果上面的路径中都没有找到,那么报错 not found
模块的加载过程
模块在被第一次引入时,模块中的js代码会被运行一次
// test1.js
console.log('test1');
// test.js
require('./test1')
console.log('test');
当我们执行test.js文件时,并没有手动去执行test1.js这个文件,只是利用require函数引入了test.js文件中而已,执行的仅仅是test.js文件,但通过打印结果来看,test1.js里面的js代码的确被执行了,所以也印证了当前结论。我们会发现控制台打印的顺序是先打印test1,然后才打印test,这也说明了require函数引入文件执行的过程是同步的,require函数后续的代码会等到引入文件的js代码全部执行完之后才会 继续执行
模块被多次引入时,会缓存,最终只加载(运行)一次
我们这里测试一下,在test.js文件中两次使用require函数引入test1.js的文件,看一下test1.js中的代码会被执行几次
// test1.js
console.log('test1');
exports.age = 18
// test.js
const a = require('./test1')
const b = require('./test1')
console.log(a);
console.log(b);
通过输出结果发现,我们虽然引入了两次test1.js文件,但是它里面的代码其实只执行了一次,第二次引入test1.js的时候,其实是去缓存中拿到的结果
注意:第二个require语句并不是没有执行,而是它所获得的结果并不是通过执行一次test1.js文件所获得的,而是直接去缓存中拿到的,从变量b拿到了结果值可以知道第二句require代码是真正执行了的;
有一些复杂的情况,比如说tes t.js文件引入了test1.js和test2.js,test1.js也引入了test2.js,那么不管顺序如何,第二次引入test2.js的结果还是从缓存中读取的,即使不是在同一个文件中引入的
补充:module对象中其实是有个属性loaded的,这个属性就是用来记录当前的模块有没有被加载过的,如果当前模块已经被加载过了,那么该属性的值就会从false变为true
如果有循环引入,那么加载顺序是什么?
- 这其实是一种数据结构——图
- 图结构在遍历的过程中,有深度优先和广度优先两种遍历方式
- Node采用的是深度优先算法:所以下图中文件执行的顺序是 main -> aaa -> ccc -> ddd -> eee -> bbb,注意:已经执行过的文件他所对应的module对象中的loaded属性会变为true,所以在bbb文件中并不会重新执行ccc文件的代码
源码解读
module.exports和exports之间的关系
下面这幅图是Module类的原型上的_compile方法中,所以其中的this指向的就是Module类的实例,我们把模块需要的全局变量创建好之后,就传递给了compliedWrapper函数帮助我们执行js文件,并将全局变量传递进去,包括require、exports、module、filename和dirname,所以之所以在文件中能够访问到这些变量,其实都是传递进来的,文件能够实现模块化是因为借助函数提供了一个单独的作用域,我们在文件中书写的代码都被放到了compliedWrapper函数中
可以看到Module是一个构造函数,创建module实例时,里面会有属性被初始化,其中就包括exports属性,也就是我们之前说的module.exports,这也证实了我们之前说的赋值顺序:先是module.exports在初始化的时候被赋值空对象,然后module实例原型上的_compile方法中又会将module.exports的值赋值给变量exports,所以相当于exports的初始值也是空对象,且和module.exports是同一个引用
require方法
require也是module原型上的一个方法,但是其返回值是另一个函数的结果——Module._load,require方法只是判断了一下我们传递进去的值是不是为空,如果是空的话则直接报错
下面我们来看看_load方法,它先根据父模块的路径结合传入require的参数拼接成一个唯一标识,然后再通过这个唯一标识,根据这个标识获得到对应的filename,Module._cache放置了我们缓存中的模块,我们可以使用filename去这里面寻找对应的模块,如果这个模块存在,则需要先通过模块的loaded属性判断该模块有没有被加载过,如果已经被加载过了,则直接返回该模块下的exports属性也就是module.exports;如果该模块还没有被加载过,则需要通过函数获取到该模块下的exports属性值并返回
CommonJS规范缺点
CommonJS加载模块是同步的
- 同步意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行
- 这个在服务器中不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快
如果将它应用在浏览器呢?
- 浏览器加载js文件需要先从服务器将文件下载下来,之后在运行
- 那么采用同步就意味着后续的js代码都无法正常执行,即使是一些简单的DOM操作
所以在浏览器中,我们一般不使用CommonJS规范
- 当然在Webpack中使用CommonJS是另外一回事
- 因为它会将我们的代码转成浏览器可以执行的代码
在早期为了可以在浏览器中使用模块化,通常会采用AMD和CMD
- 但是目前一方面现代的浏览器已经支持ES Modules,另一方面借助于webpack等工具可以实现对CommonJS或者ES Module代码的转换
- AMD和CMD已经使用非常少了
AMD规范
AMD主要是应用于浏览器的一种模块化规范
- AMD的意思就是异步模块定义,其采用的是异步加载模块
- 事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的却较少了
- AMD实现的比较常用的库是require.js和curl.js
实践
我们先要将require.js代码下载至本地,data-main表示加载执行完require.js脚本文件后,立即去加载执行哪一个入口文件
我们首先要在入口文件中配置好对应的模块,这样才能正确的使用require.js,我们在入口文件中还加载了foo.js
在每一个模块中都要将我们的代码用define包裹起来,传入的函数在加载的时候是会被自动执行的,我们只需要把当前模块要导出的数据return出去即可
如果想要加载对应的模块,那么只需要将想引入的模块名称传入define函数中即可,我们就可以使用到该模块所导出的数据了
CMD规范
CMD规范也是应用于浏览器的一种模块化规范:
- CMD意思是通用模块定义,它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来
- 但是目前CMD使用也非常少了
- CMD也有自己比较优秀的实践方案,比如说SeaJS
注意:AMD和CMD都是一种规范而已,并不是一个具体的东西,具体的实现方案还要落实到对应的库中
实践
将sea.js下载到我们本地之后进行引入,但和AMD不同的是我们不需要在入口文件中配置对应的模块路径,而是可以直接通过seajs上的use方法去指定入口文件
每个模块还是需要有一个define函数,只不过它的参数是个接收三个参数(require、exports、module)的函数,因为它借鉴了CommonJS的优点,所以很多地方和CommonJS很像,包括它导出数据用的都是module.exports
我们导入文件和CommonJS是一样的,都是通过require函数,参数是我们要导入的模块路径名称
认识ES Module
JS没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD,所以在ES推出自己的模块化系统时,大家也是兴奋异常
ES Module和CommonJS的模块化有一些不同之处
- 一方面他使用了import和export关键字
- 另一方面它采用编译器的静态分析,并且也加入了动态引用的方式
ES Module模块采用export和import关键字来实现模块化
- export负责将模块内的内容导出
- import负责从其他模块导入内容
采用ES Module将自动采用严格模式:use strict
实践
如果你想要把某个js文件当成是一个模块去使用,那么必须要给script标签上的type属性值设置为module,否则解析的时候浏览器就会把它当成是一个普通的js文件而不是一个模块
<script src="./test1.js" type='module'></script>
如果你直接在浏览器中运行,那很有可能会遇到下面这个错误
这个MDN上面也有做解释:如果你尝试用本地文件加载 HTML 文件 (i.e. with a file:// URL),由于 JavaScript 模块的安全性要求,你会遇到 CORS 错误。简单来说就是不支持file协议,但是支持http、data、chrome、你需要通过服务器来做你的测试,所以你可以用vscode上面的那个插件以本地服务器运行文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=`, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
<script src="./test1.js" type="module"> </script>
<script src="./test2.js" type="module"></script>
</html>
这样我们两个文件中的变量在没有导入导出的情况下就不可以互相访问了
// test1.js
var a = 1
// test2.js
console.log(a); // Uncaught ReferenceError: a is not defined
导出导入方式
三种导出方式
- export 后面跟定义变量的语句
export const name = '李银河'
export const age = 18
export const sayHello = function (str) {
return str
}
- 大括号{}统一导出,注意:这并不是一个对象,就是一个新的语法!!!其里面放置的是要导出变量的引用列表
const name = '李银河'
const age = 18
const sayHello = function (str) {
return str
}
export {
name,
age,
sayHello
}
- 用大括号导出时,可以给变量其别名,但是导入的时候就要以别名为准了,原来的名字就不管用了
// test1.js
const name = '李银河'
const age = 18
const sayHello = function (str) {
return str
}
// 使用as关键字是可以给导出的变量起别名的
export {
name as myName,
age as myAge,
sayHello as mySayHello
}
// test2.js
import { myName, myAge, mySayHello } from './test1.js'
三种导入方式
导入时写的文件路径必须要加上后缀名,因为现在并没有脚手架,也没有webpack帮忙打包,react项目中不用加后缀名是因为webpack帮我们自动加上了,但是在没有webpack的情况下,必须要我们自己手动加上对应的后缀名才行
- import {} from '路径'
import { name, age, sayHello } from './test1.js'
- 导入变量时可以起别名
import { name as myName, age as myAge, sayHello as mySayHello } from './test1.js'
- 通过 * as 变量名 的方式导入,实际上就是将我们要导入的变量全部放入到一个对象中去充当它的属性
import * as obj from './test1.js'
console.log(obj.name); // 李银河
console.log(obj.age); // 18
console.log(obj.sayHello('你好啊!')); // 你好啊!
export和import结合使用
语法:export {} from '路径',相当于是将export与import结合在了一起,先去导入某个模块下的数据,然后再导出刚刚导入的数据
// test2.js
const name = '李银河'
const age = 18
const sayHello = function (str) {
return str
}
export {
name,
age,
sayHello
}
// test1.js
// 先从test2.js中导入数据,然后再导出
export {
name,
age,
sayHello
} from './test2.js'
// test.js
import { name, age } from './test1.js'
console.log(name, age); // 李银河 18
不知道看到这里大家会不会有个疑问?我在test.js中直接导入test2.js模块不就行了吗?为什么还要让test1.js当个中介呢?这不是多次一举吗?好像感觉没有必要,但是这种语法的出现肯定是有原因的,下面看看它的应用场景
- 在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中
- 在实际开发中,我们会在目录下面建一个index.js文件,在这个文件中将我们在其他文件中封装好的函数全都导入进来再导出,这样别人在导入你这个库某个api时就不需要具体到某个工具函数所在的文件了,直接在index.js中就可以导入你封装的所有方法了,做到了入口统一
- 这样方便指定统一的接口规范,也方便阅读
- 这个时候,我们就可以将export和import结合使用了
default用法
有名字的导出(named exports)
- 在导出export时指定了名字
- 在import导入时需要知道具体的名字,而且除了用*进行导入,用其它方法导入时都要使用{}才行 ,所以如果我们不知道某一个模块导出的名字的话,导入的时候就会很困难
默认导出(default exports)
- 默认导出export时可以不需要指定名字
- 在导入时不需要使用{},并且可以自己来指定名字
- 它也方便我们和现有的CommonJS等规范相互操作
// test2.js
export default function () {
console.log('hello');
}
// test1.js
// 我们导入时不加{}就代表要导入的是export default出来的部分
import fn from './test2.js'
fn()
export default后面跟的大括号就代表是一个对象了,它后面可以脱离变量直接跟数据
// test2.js
export default {
name: 'myName',
age: 18
}
// test1.js
import obj from './test2.js'
console.log(obj.name); // myName
console.log(obj.age); // 18
注意:在一个模块中,只能有一个默认导出。这个不难想到,因为如果一个模块可以有多个默认导出的话,那么导入的时候怎么知道是要导入的是哪一个呢?
import函数
通过import关键字加载一个模块,是不可以放在逻辑代码中的,为什么会出现这种情况呢?
- 这是因为ES Module在被JS引擎解析时,就必须知道它的依赖关系
- 由于这个时候js代码没有任何的运行,所以无法在进行类似于if语句中判断出代码的执行情况
所以下面的这种写法会报语法错误:
因为只有在运行阶段我们才知道flag是true,if语句也只有在运行的时候才知道要不要执行,所以js引擎根本就不知道怎么生成依赖关系,所以就会报一个语法错误
Uncaught SyntaxError: Unexpected identifier.An import declaration can only be used at the top level of a module
但是在某些情况下,我们确确实实希望动态的来加载某一个模块
- 如何根据不同的条件,动态来选择加载模块的路径
- 这个时候我们需要使用import()函数来动态加载
- import函数加载模块是异步的,且基于的是promise来实现的,加载成功会执行then回调,执行失败会执行catch回调
// test2.js
const name = 'asdsd'
export {
name
}
// test1.js
let flag = true
if (flag) {
import('./test2.js').then(res => {
console.log(res.name); // asdsd
}).catch(err => {
console.log(err);
})
}
CommonJS的加载过程
CommonJS模块加载js文件的过程是运行时加载的,并且是同步的
- 运行时加载意味着是js引擎在执行js代码的过程中加载模块,不需要提前知道依赖关系
- 同步就意味着这一个文件没有加载结束之前,后面的代码都不会执行
- 所以可以将导入模块的操作也就是require函数放到逻辑代码中去执行
// test2.js
module.exports.name = 'asda'
// test1.js
let flag = true
if (flag) {
const obj = require('./test2')
console.log(obj); // { name: 'asda' }
}
ES Module的加载过程
ES Module加载js文件的过程是编译(解析)时加载的,并且是异步的
- import命令具有提升效果,会提升到整个模块的头部,首先执行(是在编译阶段执行的,在代码运行之前)
- 编译(解析)时加载,意味着import不能和运行时相关的逻辑放在一起,因为你跟逻辑代码混在了一起,我在编译时文件还没运行,怎么知道到底要不要加载这个模块呢
- 比如from后面的路径需要动态获取
- 比如不能将import放到if等语句的代码块中
- 所以我们有时候也称ES Module是静态解析的,而不是动态或者运行时解析的
// test1.js
console.log('test1');
for (let i = 0; i < 2000; i++) {
console.log(2);
}
// test2.js
console.log('test2');
export const name = 'asd'
// test.js
console.log(name); // 'asd'
import './test1.js'
import { name } from './test2.js'
从上面的代码中我们可以发现,即使打印name的语句在import上面,但还是没有报错并且可以正常打印出来,这也变向证明了import命令具有提升效果,而且test1.js模块一定在test2.js模块上面执行,说明模块的引入顺序和导入文件的书写顺序一致
JS引擎在遇到import时会去加载这个js文件,但是这个加载的过程是异步的,并不会阻塞主线程继续执行
- 如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的js文件和代码不会阻塞他们的执行
// index.js
console.log('index');
// main.js
console.log('main');
- 最终打印的结果是先 index,后 main,足以证明main.js是异步加载执行的,并不会阻塞html继续解析,所以index.js文件中的打印代码才会先main.js执行
- 也就是说为script标签设置了type = module属性之后,相当于也给script标签加上了 defer 属性,为什么不是async属性呢?这里来验证一下
<script src="./test1.js" type="module"></script>
<script src="./test2.js" type="module"></script>
两个同为模块的script脚本都是异步加载且执行顺序一直都按照在html文档中出现的顺序执行,足以证明脚本上默认是defer属性。当我们强制给这两个标签加上async属性的话,打印结果顺序就和他们在html文档中的执行顺序不同了,与我们实际打印结果不符合
<script src="./test1.js" type="module" async> </script>
<script src="./test2.js" type="module" async> </script>
ES Module的加载过程
ES Module通过export导出的是变量本身的引用
- export在导出一个变量时,js引擎会解析这个语法,并且创建模块环境记录
- 模块环境记录会和变量进行绑定,并且这个绑定是实时的
- 而在导入的地方,我们是可以实时的获取到绑定的最新值的
模块环境记录其实就是一块内存空间,它会实时绑定着我们要导出的变量,在它的内部相当于是用const定义了一个和我们导出数据重名的变量,原模块中是可以对这些变量直接进行修改的,如果导出的数据都是原始值,那么修改过后在其它的模块中也可以顺利访问到最新的值
// test.js
import { name } from './test1.js'
console.log(name); // '原来的名字'
setTimeout(() => {
console.log(name); // '更改过的名字'
}, 2000)
// test1.js
let name = '原来的名字'
setTimeout(() => {
name = '更改过的名字'
}, 1000)
export {
name
}
但引用这些数据的模块如果想通过引入的变量去更改这些值,则会报错Uncaught TypeError: Assignment to constant variable
,因为引入的变量相当于是在模块环境记录中的变量,这些变量相当于是用const进行定义的,常量是不可以随意更改的
// test.js
import { name } from './test1.js'
console.log(name); // '原来的名字'
setTimeout(() => {
name = 'asd' // Uncaught TypeError: Assignment to constant variable.
}, 1000)
// test1.js
let name = '原来的名字'
setTimeout(() => {
console.log(name); // '原来的名字'
}, 2000)
export {
name
}
但如果我们导出的是个引用值的话,保存在模块环境记录中的就是对应的引用,无论是在原模块还是在引入数据的模块,都可以通过这个引用去访问并更改到对应的引用值,然后在原模块和引入的模块中都可以看到对应的改变
// test.js
import { obj } from './test1.js'
console.log(obj.name); // '原来的名字'
setTimeout(() => {
obj.name = '更改过的名字'
}, 1000)
// test1.js
let obj = {
name: '原来的名字'
}
setTimeout(() => {
console.log(obj.name); // '更改过的名字'
}, 2000)
export {
obj
}
ES Module通过export default导出变量相当于执行了一次赋值操作
如果我们通过export default导出的是原始值,那么即使在原模块中对变量进行了修改,在导入的模块中这个变量也并不会发生改变,因为我们导入的并不是一个引用,而相当于执行的是一次赋值操作,把导出数据的值赋给了导入模块中的变量name。
和用export一样的是:我们也不可以在导入的模块中直接修改变量的值,因为import导入数据相当于是用const定义了常量
// test.mjs
import name from './test1.mjs'
console.log(name); // asd
setTimeout(() => {
console.log(name); // asd
}, 2000)
// test1.mjs
let name = 'asd'
setTimeout(() => {
name = 'modify'
console.log(name); // modify
}, 1000)
export default name
但如果导出的变量是一个引用值的话,那么真正我们导出的还是这个值的引用,所以无论是在导入还是导出的模块中,都可以对这个变量的属性进行更改,而且在其他模块中也可以立即访问到更改后的值
// test.mjs
import obj from './test1.mjs'
setTimeout(() => {
console.log(obj.name); // modify
}, 2000)
// test1.mjs
let obj = {
name: 'asd'
}
console.log(obj.name); // asd
setTimeout(() => {
obj.name = 'modify'
console.log(obj.name); // // modify
}, 1000)
export default obj
Node 中的ES Module
直接在node中使用import关键字会报语法错误:Cannot use import statement outside a module
,意思就是不可以在模块外面使用import关键字,但是根据我们之前所学的,在node环境中的每个js文件不都是一个模块吗?
是模块,但他们只是CommonJS规范下的模块,并不是ES Module的,所以当我们使用import关键字的时候,该文件不会被识别成ES Module下的模块,自然就会报错
// test.js
import { name, age } from './test1.js' // SyntaxError: Cannot use import statement outside a module
console.log(name);
console.log(age);
// test1.js
const name = 'asd'
const age = 18
export {
name,
age
}
在最新的Current版本(v14.13.1)中,支持es module我们需要进行如下操作:
- 方式一:在package.json中配置type: module
- 方式二:文件以.mjs结尾,表示使用的是ES Module
Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
我们试验一下将模块的扩展名都改为.mjs后,发现确实就可以在node中使用ES Module规范了
// test.mjs
import { name, age } from './test1.js' // SyntaxError: Cannot use import statement outside a module
console.log(name); // asd
console.log(age); // 18
// test1.mjs
const name = 'asd'
const age = 18
export {
name,
age
}
注意:在以前的node版本中是不支持ES Module的,所以想要在node中使用ES Module规范,必须要确保node的版本足够高才可以
ES Module和CommonJS交互
通常情况下,CommonJS不能加载ES Module导出的模块
- 因为CommonJS是同步加载的,但是ES Module必须经过静态解析等,无法在这个时候执行JavaScript代码
- CommonJS是同步加载就意味着对应的模块文件要被下载下来,并且已经知道你导出什么东西了,之后再去执行文件的。以require函数为例,由于ES Module是异步加载的,很有可能ES Module还没有进行完静态分析甚至还没有下载完,所以这里它们是有一些矛盾的,也就是为什么CommonJS不能加载ES Module导出的模块
- 但是这个并不是绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持
- 目前node是不支持这种场景的,但以后可能就支持了。因为在node环境下面,很多文件都是本地的,node如果可以提前对所有的js文件进行解析,那么可能就可以利用require引入ES Module下的导出了。而且比如一些打包工具,例如webpack,在打包的时候就可能对js文件做了分析,当然也有能力先解析下这些文件,知道这些文件要导出什么,这样我们在js文件执行过程时,也能够通过require导入正确的值了
在一个文件中使用ES Module规范下的export进行导出,在另一个文件中可以使用CommonJS规范的require方法进行导入吗?
通过实践表明,这是不可以的,但是你可以使用import函数去导入对应的数据,因为import函数不仅被ES Module支持,就连node中的CommonJS规范都支持
// test.js
const name = require('./test1.mjs')
console.log(name); // require() of ES Module not supported.
// Instead change the require of to a dynamic import()
// which is available in all CommonJS modules.
// test1.js
const name = 'asd'
export {
name
}
多数情况下,ES Module可以加载CommonJS
- ES Module在加载CommonJS时,会将其module.exports导出的内容作为default导出方式来使用,可以用任意的变量名来导入
- 因为CommonJS模块的加载是同步的,这就可以让ES Module在静态解析的时候等待被导入的模块加载完,这样import就知道要导入的变量是什么了
- 这个依然要看具体的实现,比如webpack和node中是支持的
// test.js
// 导入的时候这里可以使用任意变量来接
import obj from './test1.js'
console.log(obj); // { name: 'asd' }
// test1.js
// 导出的时候会将要导出的内容作为export default的方式进行导出
const name = 'asd'
module.exports = {
name
}
转载自:https://juejin.cn/post/7094251942913769502