在最初 js 被设计用来做一些表单校验的简单功能,当初的 js 只是用来作为页面展示的一个补充。后来随着 web 的发展,相当一部分业务逻辑前置到了前端进行处理,js 的地位越来越重要,文件也越来越庞大,为了将大的功能模块进行拆分成一个一个小的组成部分,但是拆分成小的 js 文件又带来了新的挑战,由于 js 的加载和执行顺序在引入的时候就已经决定了,这样就需要充分考虑到各变量的作用范围以及各变量之间的依赖关系。
就像上面这样,a.js 会最先被执行,这样如果在 b.js 中存在着与 a.js 同名的变量,就会发生覆盖。同时如果在 c.js 中有使用到 a.js 声明的变量就决定了 a.js必须在 c.js 上面被引入。这样就存在这一种耦合,为了解决这一类问题 js 的模块化应运而生。
commonjs
commonjs 随着 nodejs 的诞生而面世,主要是用来解决服务端模块化的问题,commonjs 对模块做了如下规定
一个 js 文件就是一个模块,里面定义的变量都是私有的,对其他文件不可见
模块内的需要被导出的变量可以通过 exports 对象上的属性进行导出
使用 require 方法导入其他模块的变量
所有的模块加载都是同步的
同一个模块可以被多次加载,但是只有第一次被加载时会执行模块内容,然后会缓存模块
node 中的 commonjs 模块
node 中一个文件就是一个模块,各模块之间的变量是无法互相访问到的。
// a.jsconsta =1;
// b.jsconstb =2;console.log(a);// ReferenceError: a is not defined
在 b.js 中无法访问到变量 a,如果需要使用 a 需要先导入模块
// b.jsconsta =require('./a.js');console.log(a)// {}
这里还是无法访问到 a 变量是因为模块 a 中没有导出对应的变量
// a.jsconsta =1exports.a= a// b.jsconsta =require('./a.js');console.log(a);// 1
node 模块中的 module 对象
module 是 node 中的一个内置对象,是 Module 类的一个实例, module 对象上有几个重要属性
module.id 模块的标识符。 通常是完全解析后的文件名
module.loaded 模块是否已经加载完成,或正在加载中
exports.x=1;console.log(module.loaded)// false 还没有加载完成setTimeout(() =>{console.log(module.loaded)// true},0)
module.exports 当前模块对外输出的接口,其他文件导入当前模块实际上就是在读取当前模块的 module.exports 变量
除了 module.exports 之外,node 中还提供了一个内置变量 exports,它是 module.exports 的一个引用(可以理解成是一个快捷方式),看一下 exports 和 module.exports 的关系
/**
* 实际的引用关系
* module.exports = {}
* exports = module.exports
*//**
* 一
* 这样做的实际结果就是让
* module.exports = {x: 1}
*/exports.x=1;// {x: 1}/**
* 二
* 同上
*/module.exports.x=1;// {x: 1}/**
* 三
* 虽然最终导出的内容与上面两种做法是
* 一样的,但是这种做法改变了
* module.exports 的原始引用,导
* 致了 exports 与 module.exports 的
* 联系断掉了,如果再使用 exports.y = 2
* 是没有效果的
*/module.exports= {x:1};// {x: 1}exports.y=2;// 无效/**
* 四
* 与上面类似,改变了 exports 的引用
*/exports= {x:1};// 无效module.exports.y=2;// 2
node 模块中的 require 方法
require 是 node 模块中的内置方法,该方法用于导入模块,其函数签名如下:
interfacerequire {/**
* id 模块的名称或路径
*/(id:string):any}
require 方法上有几个比较重要的属性和方法
require.main 是 Module 的一个实例,表示当前 node 进程启动的入口模块
require.resolve 是一个方法,用来查询指定的模块的路径,如果存在会返回模块的路径(如果是原生模块,则只会返回原生模块的名称,例如 http),不存在则会报出错误,与 require 不同的是这个方法只会查找对应的模块路径,不会执行模块中的代码,其函数签名如下
interfaceRequireResolve{/**
* request 指定要查找的模块路径
* options.paths 从 paths 指定的路径中进行查找
*/(request: string,options: {paths: string[]}): string}
// /home/user/a.jsconsole.log(require.resolve('.b'));// /home/user/b.jsconsole.log(require.resolve('http'));// httpconsole.log(require.resolve('./index', {paths: ['/home/local/']}));// /home/local/index.js
require.resolve 方法与 require 解析文件路径的方式是一样的(后面会做介绍具体的解析过程),会优先查看是否是原生模块、然后会查看是否具有缓存、然后才是更具不同的文件扩展名进行查找
require.cache 是一个对象,被引入的模块将被缓存在这个对象中,可以手动进行删除
require 本身的用法
require 可以通过传入 string 类型的 id 作为入参,id 可以是一个文件路径或者是一个模块名称,路径可以是一个相对路径(以 ./ 或者 ../ 开头)或者是一个绝对路径(以 / 开头)。相对路径的方式比较简单,会以当前文件的 __dirname 作为基础路径计算出绝对路径,无论是相对路径还是绝对路径都可以是文件或者文件夹。
i. 文件加载规则
LOAD_AS_FILE(X)LOAD_AS_FILE1.是否存在 X 文件,是则优先加载 X2.否则会加载 X.js3.否则会加载 X.json4.否则会加载 X.node
ii. 文件夹加载规则
LOAD_AS_DIRECTORY(X)LOAD_AS_DIRECTORY1.是否存在`X/package.json`,是则继续 a. `package.json` 是否有 `main` 字段,无则执行 2,是则执行 b
b. 加载 `(X + main)` 文件,规则: `LOAD_AS_FILE(X + main)` ,无则继续执行 c
c. 加载 `(X + main)/index`,规则: `LOAD_AS_FILE((X + main)/index)`,无则抛出错误
2. 否则会执行去查找 `X/index`,规则: `LOAD_AS_FILE(X/index)`
iii. 模块名称加载规则
id 作为模块名称会遵守如下优先级规则进行模块查找:
加载内置模块
加载当前目录下 node_modules 文件夹中的模块
加载父级目录下 node_modules 文件夹中的模块,一直到最顶层
模块缓存
模块在第一次被加载之后会缓存,多次调用同一个模块只会让模块执行一次。
// a.jsmodule.exports= {name:'张三'}// b.jsrequire('./a.js')// {name: '张三'}require('./a.js').age=18require('./a.js')// {name: '张三', age: 18}
最后一个 require('./a.js') 会输出 {name: '张三', age: 18} 则说明 a.js 模块只执行了一次,返回的还是最早被缓存的对象。如果要强制重新执行被引用的模块代码,可以通过删除缓存的方式
// a.jsmodule.exports= {name:'张三'}// b.jsrequire('./a.js')// {name: '张三'}require('./a.js').age=18require('./a.js')// {name: '张三', age: 18}deleterequire.cache[require.resolve('./a')]require('./a.js')// {name: '张三'}
上面的例子还能说明模块的缓存是基于文件路径进行的,只要在被加载时路径不一致同一个模块也会执行两次
循环依赖
要说弄清楚这个问题需要先了解 node 中模块加载机制,在 commonjs 模块体系中 require 加载的是一个对象的副本,实际也就是 module.exports 所指向的变量,所以除非是存在引用类型的变量否则模块内部的变化是影响不到外部的。举个例子说明这个:
// b.jsletcount =1letcountObj = {count:10}module.exports= { count, countObj,setCount(newVal) { count = newVal },setCountObj(newVal) { countObj.count= newVal }}// a.jsconstmoduleB =require('./b.js')console.log(moduleB.count)// 1moduleB.setCount(2)console.log(moduleB.count)// 1console.log(moduleB.countObj.count)// 10moduleB.setCountObj(20)console.log(moduleB.countObj.count)// 20
上面的例子说明了 require 的结果实际是 module.exports 的一个副本,按照这样的思路循环加载的情况下,也就会读取已经存在 module.exports 上的属性,如果还存在部分属性未挂在到 module.exports 上则会读取不到。
// a.jsconsole.log('a 开始');exports.done=false;constb =require('./b.js');console.log('在 a 中,b.done = %j', b.done);exports.done=true;console.log('a 结束');// b.jsconsole.log('b 开始');exports.done=false;consta =require('./a.js');console.log('在 b 中,a.done = %j', a.done);exports.done=true;console.log('b 结束');// main.jsconsole.log('main 开始');consta =require('./a.js');constb =require('./b.js');console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);
当 main.js 加载 a.js 时, a.js 又加载 b.js。 此时, b.js 会尝试去加载 a.js。 为了防止无限的循环,会返回一个 a.js 的 exports 对象的 未完成的副本 给 b.js 模块。 然后 b.js 完成加载,并将 exports 对象提供给 a.js 模块。
当 main.js 加载这两个模块时,它们都已经完成加载。 因此,该程序的输出会是:
main 开始a 开始b 开始在 b 中,a.done =falseb 结束在 a 中,b.done =truea 结束在 main 中,a.done=true,b.done=true