JS模块化
模块化
我们需要先知道为什么会出现模块化这个概念。
模块化是软件开发中复杂度的指数级增长和工程化的必然需求,其核心目的就是为了实现代码的复用、解耦、提高可维护性,通过模块化,可以实现不同人员同时开发不同的模块,最后将其进行整合,从而提高开发效率。
在最初的时候,JS是运行在浏览器中的,我们通过script标签引入外部js文件,或直接在script标签内部直接编写js代码,虽然它们在物理上是独立的,但它们在浏览器上运行时还是共享同一个全局作用域,这就导致了如果我们直接在外部js文件编写全局代码,再通过script引入,很可能会导致变量名覆盖,如果是let、const声明的变量,虽然不会覆盖,但通过报错来调整还是太过耗时和麻烦,对于外部JS文件,更多的还是引用别人编写好的工具文件,在没有文档的情况下,我们需要查看源码才知道暴露给我们的到底有哪些变量是我们可以使用的,同时,如果一个JS文件引用了其他的JS文件的变量,那么先把哪个JS文件引入也成为了一个问题,总结起来主要就是:
-
可能会污染全局变量,或者变量名冲突。
-
使用起来麻烦,阅读性较差。
-
依赖管理容易失控。
-
引申起来还会有协同开发冲突等问题。
虽然可以通过在外部js文件中使用立即执行函数的作用域来解决变量名冲突的问题,但如果我们想获取IIFE的返回值呢?仍然会存在上述问题。
随着Node.js这个JS运行时环境的出现,实现了通过JS开发服务端的应用,而服务端通常需要处理文件操作、数据库交互、网络通信的等复杂任务,这时候必须要分模块管理,而在这之前,ECMA并没有对JS的模块化有过明确的规定,Node.js引入了社区的CommonJS规范,并基于此规范实现了自己的模块系统。(CommonJS是一套由开发者社区提出的旨在解决JS在非浏览器环境(如服务端)中的模块化问题,强调同步加载模块,由于在服务端,资源一般都是本地文件,对于本地文件的读取速度还是很快的,所以同步就合适了)。
CommonJS
CommonJS中关于导入和导出的规范为require
和exports
。
1 | // test.js |
由于通过exports.属性名
一个一个导出太过麻烦,CommonJS又提供了module.exports
统一导出的方式。
1 | module.exports = { |
Node.js关于导出的内部原理其实就是导出一个对象,exports和通过require引入的自定义名称指向的就是一个对象,Node.js实现导出的时候,内部实现了一个操作module.exports = exports
,所以在模块初始化的时候,module.exports和exports其实是等价的,但其实通过require引入的其实是moudle.exports指向的对象,所以如果使用module.exports={}
的方式导出,exports和module.exports指向的就不是同一个对象了,这是再对exports操作就没有意义了,因为导入和导出的是同一个对象,所以通过对对导入的变量操作和在导出处进行操作,数据修改是双向的。
经过上文描述,也就可以解释CommonJS的导出规范为exports
,并不包括module.exports
,但可以使用moudle.exports操作,因为它们在初始化时指向的是同一个对象,也简介是符合CommonJS规范的。
AMD、CMD、UMD
AMD、CMD、UMD都是在ESModule标准化之前使用的社区模块化规范,现在已经很少有人用了,但UMD目前还是一种兼容性的流行方案,这里对它们就不过多了解了,只介绍一下它们的特点吧。
关于AMD和CMD,它们都是应用于浏览器环境下的模块化方案,由于CommonJS强调同步加载模块,这对于浏览器来说是非常致命的,因为浏览器JS解析单线程会因为JS代码而发生阻塞,所以出现了AMD和CMD方案,它们可以实现异步加载模块,代表的库分别为RequireJS
和SeaJS
。
而UMD可以根据条件判断适配多种环境(AMD,CommonJS,全局变量),实现了跨环境支持,既同一套代码可运行在浏览器、Node.js和AMD中,适合开发兼容多种环境的JS库,也是保证兼容旧环境的最佳方案。代表库有JQuery
和Lodash早期版本
。
ESModule
ESModule是ECMA发布的标准模块化规范,使用import
和export
关键字实现模块化。
ESModule实现导出:
方式一:具名导出。
1 | export const name = 'test'; |
方式二:默认导出,一个模块文件只能有一个默认导出export default
1 | export default function foo(params) {} |
ESModule实现导入:
如果只是在普通浏览器环境下,对于导入的文件名路径是要写全的,否则找不到,我们可能在工程化开始时见过不用写后缀名什么的,那是由于webpack或vite等打包工具进行了一些处理。
方式一:具名导入。
1 | import {name} from './test.js' |
方式二:默认导入。
1 | import name from './test.js' |
方式三:导入整个模块。
1 | import * as test from './test.js' |
特殊情况
情况一:默认导入可以和具名导入一起使用。
1 | import name, {foo} from './test.js' |
情况二:导入和导出结合使用。
场景:将各个模块的内容交由一个文件统一处理,仅适用于具名导入导出,如Pinia的模块化处理。
1 | export * from './test.js' |
注意点:
注意一:关于ESModule的具名导入和导出,操作的{}
并不是个对象,里面的变量并不是对象增强和结构,只是一种静态语法,并不能在里面写对象的字面量那种写法。
注意二:ESMdoule中的变量不能进行赋值操作的,不能进行双向更改,是一种单项数据流的模式。
上述的ESModule通过import的导入方式都是静态导入,是不可以放到逻辑代码中的,这与ESModule的解析流程有关,后面再说,如果想实现动态导入,可以使用import()
函数,该函数返回的是一个Promise对象。
1 | import('./test.js').then(res => {}) |
在工程化开发中,尤其是Vite中,我们还经常用到import meta
,import.meta是一个给JS模块保禄特定上下文的对象。它包含了这个模块的信息,比如说这个模块的URL。
1 | console.log(import.meta) // {url: 'http://127.0.0.1:5500/1.js', resolve: ƒ} |