JS模块化介绍
什么是模块化
模块化开发的目的是将程序划分成一个个小的结构
这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构
这个结构也可以将自己希望暴露的变量、函数、对象等导出给其他结构使用
也可以通过某种方式导入另外结构中的变量、函数、对象等
上面说到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程
JavaScript本身,直到ES6(2015)才推出了自己的模块化方案,在此之前为了让JavaScript支持模块化,在社区涌现出了很多不同的模块化规范:AMD、CMD、CommonJS等
没有模块化带来的问题
早期没有模块化带来了很多问题,比如命名冲突的问题等
当然对于这个问题,早期也有解决方式:立即函数调用表达式
因为早期,只有函数有自己的作用域
// index.js
var moduleA = (function() {
var name = "xxx"
var age = 18
var isFlag = true
return {
name: name,
isFlag: isFlag
}
})()
// xxx.js
(function() {
if (moduleA.isFlag) {
console.log("我的名字是" + moduleA.name)
}
})()
但是这个实现过于简单,并且没有规范,还得记得自己写的名称,代码写起来非常混乱
CommonJS
CommonJS规范和Node关系
CommonJS是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了 体现它的广泛性,修改为CommonJS,平时我们也会简称为CJS。
- Node是CommonJS在服务器端一个具有代表性的实现
- Browserify是CommonJS在浏览器中的一种实现
- webpack打包工具具备对CommonJS的支持和转换
Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:
- 在Node中每一个js文件都是一个单独的模块
- 这个模块中包括CommonJS规范的核心变量:exports、module.exports、require
模块化的核心是导出和导入,Node中对其进行了实现:
-
exports和module.exports可以负责对模块中的内容进行导出
两者都是一个对象
-
require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容
CJS基本使用
module.exports 导出,require导入
案例:
// xxx.js
const name = 'xxx'
const age = 18
function sum(num1, num2) {
return num1 + num2
}
// 导出
module.exports = {
name,
age,
sum
}
// main.js
// 使用另外一个模块导出的对象, 那么就要进行导入 require
const { name, age, sum } = require("./xxx.js")
console.log(name);
console.log(age);
console.log(sum(20, 30));
exports
第二种导出方式:
// a.js
exports.name = name
exports.age = age
exports.sum = sum
// b.js
const xxx = require("./a.js")
module.exports
在Node中我们经常导出东西的时候,是通过module.exports导出的
有module.exports不就够了吗?为什么还要有exports?
为了遵循CommonJS规范,所以才加入了exports
因为CommonJS中是没有module.exports的概念的,但是为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是 module
所以在Node中真正用于导出的其实根本不是exports,而是module.exports
源码实现类似是这样的:
module.exports = {}
exports = module.exports
引用赋值,让两者指向同一个对象,因此才可以用exports导出
但是现在基本都是用module.exports导出的(既然源码实际都是module.exports导出)
但是,注意以下代码不会进行导出
exports = {
name,
age
}
原因是:这三者实际上指向同一个对象,而node内部最终导出的是 module.exports 指向的对象
如果给exports重新赋值一个新的对象,那么它最终不会被导出
注意:以下代码也不会被导出
const name = 'xxx'
const age = 18
function sum(num1, num2) {
return num1 + num2
}
exports.name = name
exports.age = age
exports.sum = sum
module.exports = {
}
最后给module.exports指向一个新的对象,那么导出的就是这个新的对象
关于值类型的一个问题
// bar.js
let a = 'xiaoyu'
setTimeout(() => {
a.name = 'xxx'
}, 2000)
module.exports = {
name: a, // 实际上导出的就是name: 'xiaoyu',后面你再改a,跟main中已经没关系了
age: 123
}
// main.js
const bar = require("./bar")
setTimeout(() => {
console.log(bar.name)
}, 2000)
两秒钟之后依然输入 'xiaoyu'
如果a是一个对象,那么就有关系了,因为指向同一块空间,两者会有影响
require查找细节
require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象
常见的查找规则:导入格式如下:require(X)
情况一:X是一个Node核心模块/内置模块,比如,path,http
const path = require("path")
const fs = require("fs")
直接返回核心模块,并停止查找
情况二:X是以 ./ 或 ../ 或 /(根目录)开头的(/一般不会直接用根目录,都是相对路径)
const abc = require("./abc")
第一步:将X当做一个文件在对应的目录下查找
-
如果有后缀名,按照后缀名的格式查找对应的文件
-
如果没有后缀名,会按照如下顺序:
直接查找文件X --> 查找 X**.js文件 --> 查找X.json文件 --> 查找 X.node**文件
第二步:没有找到对应的文件,将X作为一个目录
-
查找目录下面的index文件
查找 X/index.js文件 --> 查找 X/index.json文件 --> 查找X/index.node文件
如果没有找到,那么报错:not found
情况三:直接是一个X(没有路径),并且X不是一个核心模块
会从当前目录开始逐层往上找node_modules文件夹
const axios = require("axios")
// console.log(module.paths)
CommonJS的模块加载过程
1.模块在被第一次引入时,模块中的js代码会被运行一次
2.模块被多次引入时,会缓存,最终只加载(运行)一次
// main.js
console.log("main.js代码开始运行")
require("./foo") // 到这里先运行.foo中代码
require("./foo") // 缓存,不再运行
require("./foo")
console.log("main.js代码后续运行")
-
因为每个模块对象module都有一个属性:loaded,记录有没有加载过
为false表示还没有加载,为true表示已经加载
3.如果有循环引入,加载顺序是什么?
这个其实是一种数据结构:图结构;
Node采用的是深度优先算法:main -> a -> c -> d -> e ->b
CommonJS的加载过程
CommonJS模块加载js文件的过程是运行时加载的,并且是同步的:
- 运行时加载意味着是js引擎在执行js代码的过程中加载模块
- 同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行
所以是可以写这种代码的
const flag = true
if (flag) {
const foo = require('./foo')
console.log("if 语句继续执行")
}
CommonJS规范缺点
CommonJS加载模块是同步的:
-
同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行;
-
这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快
但如果应用于浏览器:
- 浏览器加载js文件需要先从服务器将文件下载下来,之后再加载运行
- 那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作
所以在浏览器中,我们通常不使用CommonJS规范
- 当然在webpack中使用CommonJS是另外一回事,因为它会将我们的代码转成浏览器可以直接执行的代码
在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD
- 但是目前一方面现代的浏览器已经支持ES Modules,另一方面借助于webpack等工具可以实现对CommonJS或者ES Module代码的转换,AMD和CMD已经使用非常少了
AMD规范(了解)
AMD规范主要是应用于浏览器的一种模块化规范,采用的是异步加载模块,AMD实现的比较常用的库是require.js 和 curl.js
CMD规范(了解)
CMD规范也是应用于浏览器的一种模块化规范,采用了异步加载模块,但是它将CommonJS的优点吸收了过来,主要使用的库是 SeaJS
CMD写起来跟CommonJS很相似
ES Module
ES Module简介
ES推出的自己的模块化系统
ES Module和CommonJS的模块化有一些不同之处:
- 一方面它使用了import和export关键字
- 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式
export:将模块内的内容导出
import:从其他模块导入内容
了解:采用ES Module将自动采用严格模式:use strict
案例说明
在浏览器中使用ES6的模块化开发
注:需要加上type="module",表面该文件使用了模块化
<script src="./main.js" type="module"></script>
另外我们不能直接在本地加载html文件(比如一个file://路径的文件),会出现CORS错误,需要通过一个服务器来测试(以下使用VSCode中的插件:Live Server)
MDN上的说明:应用模块到你的HTML
export关键字
export关键字将一个模块中的变量、函数、类等导出
方式一:export声明语句
export const name = "xxx"
export const age = 18
export function bar() {}
export class Person {}
方式二:export 导出和声明分开
const name = 'xxx'
const age = 18
function bar() {}
export {
name,
age,
bar,
// height: 1.88 (不能这样写,会报错的)
}
export {} 是一种固定的语法,并不表示对象,不能当对象去设置属性什么的
方式三:在第二种导出方式的基础上,用as起别名,到时候别人导入的时候就用这个别名(少用)
export {
name as fName,
age as fAge,
bar as fBar,
}
import关键字
import关键字负责从另外一个模块中导入内容
方式一:普通的导入
import { name, age, bar } from "./foo.js"
方式二:导入时给标识符起别名
import { name as fName, age as fAge, foo as fFoo} from './foo.js'
方式三:通过 * 将模块功能放到一个模块功能对象(a module object)上
import * as foo from './foo.js'
console.log(foo);
console.log(foo.name);
export和import结合使用
场景:我们有时候希望将暴露的所有接口放到一个文件中(我们经常会建一个index.js文件)
如下:希望把utils中所有功能函数统一在index.js中导出
方式一:先引入,再导出
// index.js
import { timeFormat, priceFormat} from './format.js'
import { sum } from './math.js'
export {
timeFormat,
priceFormat,
sum
}
方式二:直接导出
export { sum, sub } from './math.js'
export { timeFormat, priceFormat } from './format.js'
方式三:统一导出(感觉这种方式阅读性没那么好)
export * from './format.js'
export * from './math.js'
default用法
方式一:
// foo.js
const name = 'xxx'
const age = 18
export {
name, // age默认导出了,name就不能再默认导出了
age as default
}
// main.js
// 可以给这个默认导出的东西起名字
import foo from './foo.js'
console.log(foo); // 18 // 默认拿到age
方式二:常见
// 导出
export default age
// 导入
import xxx from ''
注意:默认导出只能有一个
import函数
动态加载某一个模块,可以使用import() 函数,返回的是Promise
这样我们就可以根据不同的条件,动态来选择加载模块的路径
let flag = true
if (flag) {
import('./foo.js').then(res => {
console.log("res:", res.name);
})
} else {
}
console.log("后续代码");
MDN:Dynamic Imports 动态导入
标准用法的import导入的模块是静态的,会使所有被导入的模块,在加载时就被编译(无法做到按需编译,降低首页加载速度)。有些场景中,你可能希望根据条件导入模块或者按需导入模块,这时你可以使用动态导入代替静态导入
错误写法:直接通过import加载一个模块,是不可以在其放到逻辑代码中的(会报语法错误)
if (true) {
import sum from './foo.js'
}
因为ES Module在被JS引擎解析时,就必须知道它的依赖关系。由于这个时候js代码没有任何的运行,所以无法在进行类似于if判断中根据代码的执行情况
import meta(了解)
import.meta是一个给JavaScript模块暴露特定上下文的元数据属性的对象。
它包含了这个模块的信息,比如说这个模块的URL
是在ES11(ES2020)中新增的特性
ES Module的解析流程
ES Module是如何被浏览器解析并且让模块之间可以相互引用的呢?
ES Module加载过程
ES Module加载js文件的过程是编译(解析)时加载的,并且是异步的
- 编译时(解析)时加载,意味着import不能和运行时相关的内容放在一起使用
- 比如from后面的路径需要动态获取
- 比如不能将import放到if等语句的代码块中
- 所以我们有时候也称ES Module是静态解析的,而不是动态或者运行时解析的
异步加载意味着:JS引擎在遇到import时会去获取这个js文件,但是这个获取的过程是异步的,并不会阻塞主线程继续执行
- 也就是说设置了 type=module 的代码,相当于在script标签上也加上了 async 属性
- 如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的js文件和代码不会阻塞它们的执行
<script src="main.js" type="module"></script>
<!-- 这个js文件不会被阻塞执行 -->
<script src="index.js"></script>
ES Module的解析过程可以划分为三个阶段:
-
构建(Construction),根据地址查找js文件,并且下载,将其解析成模块记录(Module Record)
-
实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址
-
运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中
阶段一:构建阶段
阶段二、三:实例化阶段 - 求值阶段
- ES Module通过export导出的是变量本身的引用
- export在导出一个变量时,js引擎会解析这个语法,并且创建模块环境记录(module environment record)
- 模块环境记录会和变量进行绑定(binding),并且这个绑定是实时的
- 而在导入的地方,我们可以实时的获取到绑定的最新值的
- 所以,如果在导出的模块中修改了变化,那么导入的地方可以实时获取最新的变量
示例:
// foo.js
let number = 18
setTimeout(() => {
number = 100
}, 1000);
export {
number
}
// index.js
import { number } from "./foo.js";
console.log(number);
setTimeout(() => {
console.log(number);
}, 2000);
结果:一开始输入18,2s后输出100
- 注意:在导入的地方不可以修改变量,因为它只是被绑定到了这个变量上(其实是一个常量)
// index.js
...
number = 20 // 想修改导入的变量,报错
- 但如果导出的是一个对象,那么就可以改变,因为指向同一块内存空间
Node对ES Module的支持
在node版本(16.14.0)中,支持es module我们需要进行如下操作:
- 方式一:在package.json中配置 type: module
- 方式二:文件以 .mjs 结尾,表示使用的是ES Module
毕竟版本在不断升级,支不支持最好自己做一个测试
ES Module 和 CommonJS交叉使用
1.在浏览器中,不能,因为浏览器不支持CommonJS
2.在Node环境中,看版本
3.Webpack中,可以