前端模块化

文章介绍了模块化的概念,从文件划分、命名空间到IIFE的演变,接着详细讨论了CommonJS规范,包括模块的加载机制、exports和require的使用,以及模块的加载流程。同时提到了ESM(ES6模块)与CommonJS的差异,如静态化、提前加载和动态取值。
摘要由CSDN通过智能技术生成

什么是模块

  • 将一个复杂的程序依据一定的规则封装成几个块,并进行组合在一起
  • 块的内部数据和实现是私有的,只是向外部暴露一些接口与外部其他模块通信

模块化演变过程

文件划分方式

最原始的模块系统,具体做法是每个功能及其相关的状态数据存在不同的文件当中,约定每个文件就是一个独立的模块.使用时将模块引入到对应的 html 文件当中,每个 script 标签对应一个模块,在代码当中直接调用模块中的全局成员

缺点:

  • 污染全局作用域
  • 命名冲突问题
  • 模块成员之间看不出直接关系

命名空间方式

每个模块只暴露一个全局对象,所有的成员都挂载在这个对象上面

缺点

  • 仍然没有私有空间,数据依旧可以在外部被修改

IIFE
将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口,当模块依赖另一个模块时,通过引入依赖就好,可以保证模块的独立性,还使得模块之间的依赖关系变得明显
在这里插入图片描述

模块化规范

CommonJS

Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类等都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。

//基本使用
//导出
function add (x,y) {
	return x+y
}
module.exports = {
	add,
}

//导入
const {add} = require(./add.js)
console.log(add(1,1)) // 2

特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次,但是只会在第一次加载时运行一次,然后运行结果就被缓存了。想要让模块再次运行,必须清掉缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。

module属性

  • 任意 JS 文件就是一个模块,可以直接使用 module 属性
  • id:返回模块标识符,一般是一个绝对路径
  • filename:返回文件模块的绝对路径
  • loaded:返回布尔值,标识模块是否完成加载
  • parent:返回对象,存放调用当前模块的模块
  • children:返回数组,存放当前模块调用的其他模块
  • exports:返回当前模块需要暴露的内容
  • paths:返回数组,存放不同目录下的 node_modules 位置

在这里插入图片描述
为什么任意JS文件就是一个模块?

来看一下精简过后的源码

function Module(id = '', parent) {
	// 模块文件的绝对路径
	this.id = id;
	// 模块所在的目录
	this.path = path.dirname(id);
	// 导出内容
	this.exports = {};
	// 将该模块添加到父模块的children数组中
	updateChildren(parent, this, false);
	this.filename = null;
	// 是否加载完成
	this.loaded = false;
	// 引用的子模块,即在当前模块中通过require加载的模块
	this.children = [];
}

// 用于包裹模块代码
Module.wrap = function (script) {
	return (
		'(function (exports, require, module, __filename, __dirname) {' +
		script +
		'\n})'
	);
};

// 模块缓存
Module._cache = Object.create(null);

Module._load = function (request, parent) {
	// Module._resolveFilename 用于将 require 的模块地址解析成能定位到模块的绝对路径
	const filename = Module._resolveFilename(request, parent);

	const cachedModule = Module._cache[filename];
	// 命中缓存直接返回
	if (cachedModule) {
		return cachedModule.exports;
	}

	// 未命中缓存,则创建一个新的模块实例
	const module = new Module(filename, parent);

	// 将该模块记录到模块缓存中
	Module._cache[filename] = module;

	// 获取到模块代码内容
	const content = fs.readFileSync(filename, 'utf8');

	// 将参数传入并执行模块代码,可以理解为eval,但源码中的实现并不是eval,而是更安全的执行方式
	runInThisContext(Module.wrap(content))(
		module.exports, // 对应包裹函数中接收的 exports 形参
		module.require, // 对应包裹函数中接收的 require 形参
		module, // 对应包裹函数中接收的 module 形参
		filename, // 对应包裹函数中接收的 __filename 形参
		module.path // 对应包裹函数中接收的 __dirname 形参
	);

	// 将该模块标识为加载完成
	module.loaded = true;

	// 最后返回执行完该模块内代码后的模块导出内容
	return module.exports;
};

// 模块原型上的require方法
Module.prototype.require = function (id) {
	// ...
	return Module._load(id, this);
};

可以看到,在 _load 方法中,module 是通过 new Module() 实例化得到的一个 CommonJS 模块实例,它包含了之前提到过那些属性,我们看到 filename 属性,最后是不是 xx.js ?所以才说任意 JS 文件就是一个模块。

为啥可以直接使用属性?

准确来说,是可以直接使用:

  • exports,等同于 module.exports,作为 module.exports 的快捷访问;
  • require,通常为模块原型上的 require 方法,用于加载其他模块;
  • module,当前模块对象,通过 new Module() 实例化创建的;
  • __filename,当前模块的绝对路径文件名;
  • __dirname,当前模块所在目录的绝对路径;

看源码可得知是因为在模块加载过程中 CommonJS 模块系统帮我们包裹了一层匿名函数,并在执行该函数时传入了上述5个参数,所以我们编写的代码实际上是在该函数内部执行的,我们使用的module、exports、require等其实都是该函数的形参,并不是什么全局变量。通过上面的源码我们还可以知道,除了module、module.exports、exports和require,还有__filename和__dirname这两个变量我们也是可以直接在模块代码中使用的。也正是因为模块代码是放在一个匿名函数内部执行的,所以每个模块都有单独的作用域,不会污染全局变量。

注意:正如上文提到过的,任意 JS 文件可以直接使用 module 属性,那是不是可以直接用 exports 导出呢?答案是可以的,module.exports === exports,但是值得注意的是 exports 不能使用赋值的方式进行导出,因为 exports 是指向 module.exports 对象的引用,而且最终导出的结果以 module.exports 为准,所以直接赋值的话会改变 exports 的指向,所以会拿到一个空对象。使用 exports 的话应该是 exports.xx = xxx 的形式

require属性

  • 基本功能是读入并且执行一个模块文件
  • resolve:返回模块文件绝对路径
  • extensions:依据不同后缀名执行解析操作
  • main:返回主模块对象

加载流程

  • 路径分析:依据标识符确定模块位置
  • 文件定位:确定目标模块中具体的文件及文件类型
  • 编译执行:采用对应的方式完成文件的编译执行

路径分析中,将模块标识解析(通过Module._resolveFilename())成能定位到模块文件的一个 filename 字符串,依据不同的标识符分为路径标识符跟非路径标识符(即核心模块或第三方模块,核心模块如 fs、http、path 等,第三方模块如 koa 等)

完成了路径分析后,就要开始文件定位,通常情况如上所说,有三种:

  1. 加载相对路径或绝对路径文件模块,如 ./xxx.xxx
  2. 加载核心模块,如 fs、http
  3. 加载第三方模块,如 koa

第一种情况下,如果我们传入的标识符不带扩展名,像 require(m1),就会按照 .js -> .json -> .node 的顺序来补足扩展名去寻找,如果以上三者都没查到,就会尝试将其当作是目录来处理,查找 package.json 文件,看看是否指定了main字段,按照上述的三个顺序去补足查找,如果补足查找没找到,或者不存在 package.json 文件,就会将 index 作为目标模块中的具体文件名称去补足查找,如果找不到,则根据 module 属性中的 paths 逐级向上重复步骤查找,直到找到或者找不到时抛出错误。

在第二种情况下,则会判断Module_cache[‘fs’]是否命中缓存,如果缓存命中,则返回该缓存模块的module.exports,require结束;若未命中缓存则加载该原生模块,然后返回其module.exports,require结束。

在第三种情况下,则模块系统将从当前目录下的node_modules目录中开始,查找是否存在该第三方模块标识符对应的文件或者目录,如果找不到,则继续往上一级的node_modules目录找,一直到根目录下的node_modules目录,如果根目录下的node_modules中也没找到,则抛出MODULE_NOT_FOUND的错误。

ESM

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

  • 提前加载并执行模块文件
  • 在预处理阶段分析依赖关系,在执行阶段执行模块(深度优先遍历,执行顺序先子后父)
  • ES6模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块

与 CommonJS 的差异

  • CommonJS 的导出是拷贝一份变量出来,ES6 Module 导出的是变量是引用类型的,不能随意更改
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
  • CommonJS 只能导出单个值,ES6 Module 导出的值数量不限制

结语

其实还有 AMD、CMD 这两个模块化规范可以说,不过就前端开发而言,commonJS 跟 ESM 就完全足够了,感兴趣的可以去自己去搜一下,文章可能会有错漏的地方,烦请不吝指出,共同进步。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值