模块化开发
JavaScript弊端:文件依赖(a.js->b.js->c.js,相互依赖,但是很难直接看出来,分析完后才知道要一个一个引入)和命名冲突(在相互依赖的js中声明同样的变量名导致的错误)
模块化开发:一个功能一个模块,多模块组合完整应用,抽离一个模块不会影响其他功能的运行,降低程序的耦合性,方便代码复用(虽然开发效率低一点)
1.CommonJS
commonJS的提出:
为了弥补JavaScript没有标准的缺陷,希望JS’能在任何和地方运行,CommonJS对模块的定义十分简单
注意,一般认为Node.js的模块系统使用了CommonJS的规范,实际上并不完全正确,Node.js使用了轻微修改版本的CommonJS,因为Node.JS主要在服务器环境下使用,所以不需要考虑网络延迟问题
CommonJS特点
node.js规定一个JavaScript文件就是一个模块,模块内部定义的变量和函数默认情况下在外部无法得到(除了console.log的输出内容),因为在node的定义中,每个js文件的js代码都是独立运行在一个函数中,而不是全局作用域。
当node执行模块中代码,它首先在代码顶部添加:
function(exports, require, module, __filename, __dirname){}将代码包含进去,此时由于在外部包裹了一层function,就会形成一个作用域,避免了全局污染
模块内部使用exports 对象进行成员导出,使用require方法导入其他模块(注意ES6不使用require而是使用import)
CommonJS规范是在代码运行时同步阻塞性地加载模块,在执行代码过程中遇到require(X)时会停下来等待,直到新的模块加载完成之后再继续执行接下去的代码。虽说是同步阻塞性,但这一步实际上非常快,和浏览器上阻塞性下载、解析、执行
js文件不是一个级别,硬盘上读文件比网络请求快得多。但是:模块请求仍然会造成浏览器 JS 解析过程的阻塞,导致页面加载速度变慢
在模块代码被运行前就已经写入了
cache,同一个模块被多次require时只会执行一次,重复的require得到的是相同的exports引用。const a1 = require('./moduleA'); const a2 = require('./moduleB'); console.log(a1 === a2); // true
出现文件互相引用的情况下,会去缓存查看之前是否写入过,若写入过,被引用下个文件形成循环引用之前的到处变量变得”可视”;否则被统计为 “undefined”
// main.js
const a = require('./a');
console.log('in main, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
// a.js
exports.a1 = true;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.a2 = true;
// b.js
const a = require('./a.js');
console.log('in b, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
in b, a.a1 = true, a.a2 = undefined
in a, b.done = undefined
in main, a.a1 = true, a.a2 = true进入 b 模块前, 这时候 a2 代码还没执行。
进入 b 模块,require a.js 时发现缓存上已经存在了,获取 a 模块上的 exports 。打印 a1, a2 分别是 true,和 undefined。
实践:
exports
//a.js
let version = 1.0;
const sayHi = name => `你好, ${name}`;
//向模块外导出数据
exports.version = version;
exports.sayHi = sayHi;
require,如果直接使用模块名来引入,没有添加路径,它会首先在当前目录的node_modules中寻找是否含有该模块,没有则一直往上一级寻找var math = require("math")
(require加载规则:优先从缓存加载,即之前加载过的模块,不再加载)
模块中的路径标识是相对于当前文件模块,不受到node命令所处路径影响,想查看路径影响可跳转阅读 9. Path
//b.js
//b.js导入a.js, ./b.js是b.js的路径,路径模块必须加'./', 如果只加了'/',就当作此盘根目录下路径,也就是绝对路径处理
let a = require('./a.js');
console.log(a.version);
console.log(a.sayHi('xx')); //使用a.js的方法
模块导出的另一种方式(和exports差不多):
//a.js
module.exports.version = version;
module.exports.sayHi = sayHi;
//b.js
let a = require('./a.js'); //b.js导入a.js, ./b.js是b.js的目录
console.log(a.version);
console.log(a.sayHi('xx')); //使用a.js的方法
module代表当前模块本身
exports是module.exports的别名(地址引用关系),它们俩指向同一块内存空间,导出对象(当exports和module.exports对象指向的不是同一个对象时)最终以module.exports为准,即想要直接以对象的方式进行全部修改,只能以module.exports进行声明,比如 module.exports = { name: 'allen',}
用module.exports来改动的话,是改对象,更改对象里的值
用exports以对象方式来改动的话,是改变量,更改了地址
最后return的是 module.exports ,所以给exports重新赋值不管用
__filename 当前模块的完整路径
__dirname 当前模块所在文件夹完整路径(所属目录的绝对路径)
注意:如果a加载了b ,b又加载了a,(即a require b,b require a)说明思路有问题
模块查找过程
从 Y 路径运行 require(X)
1. 如果 X 是内置模块(比如 require('http'))
a. 返回该模块。
b. 不再继续执行。
2. 如果 X 是以 '/' 开头、
a. 设置 Y 为 '/'
3. 如果 X 是以 './' 或 '/' 或 '../' 开头
a. 依次尝试加载文件,如果找到则不再执行
- (Y + X)
- (Y + X).js
- (Y + X).json
- (Y + X).node
b. 依次尝试加载目录,如果找到则不再执行
- (Y + X + package.json 中的 main 字段).js
- (Y + X + package.json 中的 main 字段).json
- (Y + X + package.json 中的 main 字段).node
c. 抛出 "not found"
4. 遍历 module paths 查找,如果找到则不再执行
5. 抛出 "not found"模块分类
核心模块 由node引擎提供 核心模块的标识就是模块的名字(如node提供的文件模块fs)
文件模块 由用户自己定义
第三方模块(可以通过npm下载,它的查找方式是先找到当前目录下node_modules/xx/package.json文件,查看其中main属性,记录了js文件的入口,当main没有指定,默认执行node_modules/xx/目录下的index.js)(模块查找机制:如果以上任何条件不成立,则会进入上一级目录中的node_modules)
2.ES6的模块化ESM
常见的模块化规范:CommonJS、AMD、CMD、ES6的Modules
ES6模块化 : export导出 import 导入
在 style 里导入使用 @import
首先将模块化的js文件引入时需要添加类型 module :<script src="js地址" type="module"></script>
然后再所需模块内进行导出 / 导入(有模块代码自动进入严格模式)
//方式1、2的导出实际上是一致的
// 导出方式1
let name = 'allen';
export {
name
}
// 导出方式2
// 定义的时候导出,先声明再导出必须使用方式1
export var num = 1000;
export function sum() { }
// 导出方式3,这种方法只能导出一个,使用一次default导出一个
export default adress
// 导出方式4,从本文件导出types文件下所导出的所有东西的东西
export * from './types'
因为命名导出和默认导出不会冲突,所有ES6支持一个模块中同时定义这两种导出
const foo = 'foo';
const bar = 'bar';
export { bar };
export default foo;
四种导入方法
// 1.导入单个num(对应导出方式1、2、4)
import { num } from "./aaa.js";
// 2.导入默认的值(default),能够自己命名(对应导出方式3)
import add from "./aaa.js";
// 3.统一全部导入(对应导出方式1、2、4)
import * as aaa from "./aaa";
console.log(aaa.num);
//1.下载后module的引用比较特殊
import Vue from 'vue'
//当然也可以导入图片(在webpack中使用asset/resource之后)
import imgSrc from "../asset/3.jpg";
const img = document.createElement("img");
img.src = imgSrc;
document.body.appendChild(img);
还可以动态导入:
const { origin: originPath, path } = await import(`./path${assetId}.json`);
//...
不构建直接引用NPM包
当 ES Module 最开始作为一种新的 JS 模块化方案被引入的候,import 语句中需要指定相对路径或绝对路径。
import dayjs from "https://cdn.skypack.dev/dayjs@1.10.7"; // ES modules
console.log(dayjs("2022-08-12").format("YYYY-MM-DD"));
而我们现在所熟悉的直接引用包名实际上是通过打包工具的构建完成的
import dayjs from "dayjs"
而我们如何不需要通过打包工具的构建直接使用这种方式呢?
我们可以通过 HTML 中的 <script type="importmap"> 标签来指定一个 Import maps(我们可以把它当成一个映射表)
<script type="importmap">
{
"imports": {
"dayjs": "https://cdn.skypack.dev/dayjs@1.10.7",
}
}
</script>
我们将写好的importmap放在文档中第一个 <script type="module"> 标签之前
然后可以直接写
<script type="module">
import dayjs from 'dayjs';
console.log(dayjs)
</script>
另外,importmap 中声明的包并不一定意味着它一定会被浏览器加载。页面上的脚本没有使用到的任何模块都不会被浏览器加载,即便你在 importmap 中声明了它。
我们甚至可以写一个映射表,在里面的importmap中指定我们的映射表(不过据说性能比较差)
<script type="importmap" src="importmap.json"></script>
这项技术目前在 Chrome 和 Edge 浏览器 89 及更高版本提供了全面支持,但 Firefox、Safari 和一些移动浏览器还没有支持。我们可以通过下面的代码来判断浏览器的支持情况:
if (HTMLScriptElement.supports && HTMLScriptElement.supports('importmap')) {
// import maps is supported
}
ES6引入的特点
1.首先下载、查找 + 构建关系图
ES6 模块会在程序开始前先根据模块关系查找到所有模块,生成一个无环关系图,并将所有模块实例都创建好,这种方式天然地避免了循环引用的问题,当然也有模块加载缓存,重复 import 同一个模块,只会执行一次代码。
2.内存腾出空间,然后使 import 和 export 指向内存中的这些空间,这个过程也叫连接。
3.ES6和ComonJS
1.ES6 模块中不存在 require, module.exports, __filename 等变量,CommonJS 中也不能使用 import。两种规范是不兼容的,一般来说平日里写的 ES6 模块代码最终都会经由 Babel, Typescript 等工具处理成 CommonJS 代码。
ES6 模块和 CommonJS 模块有很大差异,不能直接混着写(实际上好像是可以,只是会产生很多麻烦)
2.通过对模块内变量的修改(非引用值的修改),CommonJS导出的变量不变,但是ES6会改变(其实我们也可以解释为ES6 模块输出的是值的引用,CommonJS模块输出的是值的拷贝)
但是!值得注意的是导出对模块而言是只读的,在使用 * 执行批量导出时,赋值给别名的命名导出就好像使用 Object.freeze() 冻结过一样,直接修改导出的值是不可能的,但是可以修改导出的对象的属性
// counter.js,Commonjs
let count = 1;
function increment () {
count++;
}
module.exports = {
count,
increment
}
// main.js
const counter = require('counter.cjs');
counter.increment();
console.log(counter.count); // 1
// counter.mjs,es6
export let count = 1;
export function increment () {
count++;
}
// main.mjs
import { increment, count } from './counter.mjs'
increment();
console.log(count); // 2
3.CommonJS 可以在运行时使用变量进行 require
4.require 会将完整的 exports 对象引入,import 可以只 import 部分必要的内容,这也是为什么使用 Tree Shaking 时必须使用 ES6 模块 的写法。
5.模块加载方式不同
ESM和Commonjs的趋势
其实有一个讨论帖,关于pureEsm 的概念: github
其实是主张仅提供esm产物的npm包,推动社区的发展。但是实质上需要面临一个问题:nodejs执行esm没问题,但是CommonJS中执行esm包是不行的,其根本原因在于 require 是同步加载的,而 ES 模块本身具有异步加载的特性,因此两者天然互斥,即我们无法 require 一个 ES 模块。
所以对于底层的库最好同时带commonjs格式和esm格式
而上层的库可以逐渐往pureEsm方向走
4.UMD
原理
实现一个 UMD 模块,就要考虑现有的主流 javascript 模块规范了,如 CommonJS, AMD, CMD 等。那么如何才能同时满足这几种规范呢
首先要想到,模块最终是要导出一个对象,函数,或者变量。 而不同的模块规范,关于模块导出这部分的定义是完全不一样的。 因此,我们需要一种过渡机制。
(当然你也可以理解为umd是作为一个适配CommonJS、amd、cmd的一个过渡的自动适配函数)
#实现
(function(root, factory) {
if (typeof exports === "object" && typeof module === "object") {
// CommonJS规范 node 环境 判断是否支持 module.exports 支持 require 这种方法
module.exports = factory(require);
} else if (typeof define === "function" && define.md) {
// AMD 如果环境中有define函数,并且define函数具备amd属性,则可以判断当前环境满足AMD规范
console.log("是AMD模块规范,如require.js");
define(factory());
} else if (typeof exports === "object") {
// 不支持 module 但是支持 exports 的情况下使用 exports导出 是CommonJS 规范
exports["jiang-hooks"] = factory();
} else {
// 直接挂载在全局对象上
root.umdModule = factory();
}
})(this, function() {
return {
name: "Umd模块",
};
});
参考链接