当前位置: 首页 > 科技观察

目前前端模块化的背景

时间:2023-03-18 11:46:01 科技观察

是众所周知的。早期的JavaScript本身不支持模块化。直到2015年,TC39发布了ES6,其中之一就是ES模块(为了方便,后面简称为ESM)。但在ES6规范提出之前,已经有一些模块化的解决方案,比如CommonJS(在Node.js中)和AMD。ESM与这些规范的共同点是它们都支持导入和导出语法,但它们行为的关键字存在一些差异。CommonJS//add.jsconstadd=(a,b)=>a+bmodule.exports=add//index.jsconstadd=require('./add')add(1,5)AMD//add.jsdefine(function(){constadd=(a,b)=>a+breturnadd})//index.jsrequire(['./add'],function(add){add(1,5)})ESM//add.jsconstadd=(a,b)=>a+bexportdefaultadd//index.jsimportaddfrom'./add'add(1,5)JavaScript模块化的背景在上一章已经介绍过(《前端模块化的前世》),不再赘述在这里重复。但是ESM的出现不同于其他规范,因为这是JavaScript官方推出的模块化解决方案。与CommonJS和AMD方案相比,ESM使用完全静态的方式来加载模块。ESM规范模块导出模块导出只有一个关键字:export。最简单的方法是直接在声明的变量前面加上export关键字。exportconstname='Shenfq'可以在const、let、var前直接加export,也可以在function或class前直接加export。exportfunctiongetName(){returnname}exportclassLogger{log(...args){console.log(...args)}}上面的export方法也可以简写成大括号。constname='Shenfq'functiongetName(){returnname}classLogger{log(...args){console.log(...args)}}export{name,getName,Logger}最后一个语法也是我们经常用到的,导出默认模块。constname='Shenfq'exportdefaultnamemoduleimport模块的导入使用import,配合from关键字。//main.jsimportnamefrom'./module.js'//module.jsconstname='Shenfq'exportdefaultname这种直接导入的方式必须在module.js中使用exportdefault,也就是导入语法,default模块是由默认。如果要导入其他模块,则必须使用对象扩展语法。//main.jsimport{name,getName}from'./module.js'//module.jsexportconstname='Shenfq'exportconstgetName=()=>name如果模块文件同时导出默认模块和其他模块,导入时,或同时导入两者。//main.jsimportname,{getName}from'./module.js'//module.jsconstname='Shenfq'exportconstgetName=()=>nameexportdefaultname当然ESM也提供了重命名导入模块名称的语法。//main.jsimport*asmodfrom'./module.js'letname=''name=mod.namename=mod.getName()//module.jsexportconstname='Shenfq'exportconstgetName=()=>name上面的写法是相当于给模块导出的对象重新赋值://main.jsimport{name,getName}from'./module.js'constmod={name,getName}也可以重命名个别变量://main.jsimport{name,getNameasgetModName}同时导入和导出。如果有两个模块a和b,则同时导入模块c,但这两个模块还需要导入模块d。如果模块a和b在导入c之后再导入c,导入d也是一样的,只是有点麻烦。我们可以直接在模块c中导入模块d,然后暴露模块d。模块关系//module_c.jsimport{name,getName}from'./module_d.js'export{name,getName}这么写好像有点麻烦,这里ESM提供了import和export结合的语法。export{name,getName}from'./module_d.js'以上是ESM规范的一些基本语法。想了解更多,可以看阮老师的《ES6 入门》。ESM和CommonJS的区别首先肯定是语法上的区别,前面已经简单介绍过,一个使用import/export语法,一个使用require/module语法。ESM和CommonJS的另一个显着区别是ESM导入模块的变量都是强绑定的。一旦导出模块的变量发生变化,对应的导入模块的变量也会随之变化,而CommonJS中的导入模块都是传值传递,引用传递类似于函数中的参数传递(基本类型都是值传递,相当于复制变量,非基本类型[对象,数组]按引用传递)。下面看详细案例:CommonJS//a.jsconstmod=require('./b')setTimeout(()=>{console.log(mod)},1000)//b.jsletmod='firstvalue'setTimeout(()=>{mod='secondvalue'},500)module.exports=mod$nodea.jsfirstvalueESM//a.mjsimport{mod}from'./b.mjs'setTimeout(()=>{console.log(mod)},1000)//b.mjsexportletmod='firstvalue'setTimeout(()=>{mod='secondvalue'},500)$node--experimental-modulesa.mjs#(node:99615)ExperimentalWarning:TheESMmoduleloaderisexperimental.secondvalue另外,CommonJS的模块实现其实是给每个模块文件都包裹了一层函数,让每个模块获取require/module,__filename/__dirname变量。以上面的a.js为例。在实际执行过程中,a.js运行代码如下://a.js(function(exports,require,module,__filename,__dirname){constmod=require('./b')setTimeout(()=>{console.log(mod)},1000)});而ESM模块是通过import/export关键字实现的,没有对应的功能包,所以在ESM模块中,需要使用import.meta变量来获取__filename/__dirname。import.meta是由ECMAScript实现的包含模块元数据的特定对象。主要用来存放模块的url,而node只支持加载本地模块,所以url使用file:协议。importurlfrom'url'importpathfrom'path'//import.meta:{url:file:///Users/dev/mjs/a.mjs}const__filename=url.fileURLToPath(import.meta.url)const__dirname=path.dirname(__filename)加载原理步骤:构造(construction):下载所有文件并解析成模块记录。Instantiation(实例化):将所有导出的变量放入内存中的指定位置(但尚未计算)。然后,让导出和导入都指向内存中的指定位置。这称为“链接”。Evaluation(求值):执行代码,获取变量的值并放入内存的相应位置。模块记录了所有的模块化开发都是从一个入口文件开始的。不管是Node.js还是浏览器,都会根据这个入口文件进行搜索,一步步找到所有其他的依赖文件。//Node.js:main.mjsimportLogfrom'./log.mjs'值得注意的是那,我们在刚拿到入口文件的时候,并不知道它依赖了哪些模块,所以必须要经过js引擎的静态分析,得到一条模块记录,里面包含了文件的依赖关系。所以一开始获取的js文件不会被执行,而是将该文件转换为模块记录(modulerecords)。所有导入的模块都记录在模块记录的importEntries字段中,更多与模块记录相关的字段可以在tc39.es中找到。Modulerecord模块构建获取到modulerecord后,会下载所有的依赖,将依赖文件重新转换为modulerecord,直到没有依赖文件为止。这个过程称为“构建”。模块构建包括以下三个步骤:模块识别(解析依赖模块url,找到真正的下载路径);文件下载(从指定的url下载,或者从文件系统加载);转换为模块记录。ESM规范中对如何将模块文件转换成模块记录有详细的说明,但是ESM规范中并没有相应的说明在构建步骤中如何下载和获取这些依赖的模块文件。因为如何下载文件,服务端和客户端有不同的实现规范。比如在浏览器中,如何下载一个文件就属于HTML规范(浏览器的模块加载使用的是script标签)。虽然下载根本不是ESM现有规范的一部分,但是import语句中也有引用模块的url地址,而Node和浏览器对于这个地址需要如何翻译存在一些差异。简单的说,在Node中可以直接导入node_modules中的模块,但是在浏览器中不能直接导入,因为浏览器无法正确的找到服务器上的node_modules目录在哪里。幸运的是,有一个名为import-maps的提案,主要用于解决浏览器无法直接导入模块标识符的问题。但是,在该提案完全实施之前,浏览器仍然只能使用url进行模块导入。将下载的模块转换为模块记录然后缓存在模块映射中,遇到从不同文件获取相同的依赖,将直接在模块映射缓存中获取。//log.jsconstlog=console.logexportdefaultlog//file.jsexport{readFileSyncasread,writeFileSyncaswrite}from'fs'模块实例获取所有依赖文件并构建模块映射,然后找到所有模块记录并取出所有exports然后,所有变量被一一映射到内存中,对应关系保存在“模块环境记录”(moduleenvironmentrecord)中。当然,当前内存中的变量是没有值的,只是初始化了对应关系。初始化导出变量和内存的对应关系后,会立即设置模块导入和内存的对应关系,保证同一个变量的导入和导出指向同一个内存区域,保证所有的导入都能找到对应的出口。模块连接由于导入和导出指向同一个内存区域,一旦导出值改变,导入值也会改变。与CommonJS不同的是,CommonJS的所有取值都是基于复制的。连接上import和export变量后,我们需要将对应的值放入内存中,然后我们就进入求值步骤了。模块评估评估步骤比较简单,运行代码,将计算出的值填入之前记录的内存地址即可。至此,就可以愉快的使用ESM模块化了。ESM的进展因为ESM出现的比较晚,服务端有CommonJS的方案,客户端有webpack打包工具,所以ESM的推广不得不说是非常艰难的。对于客户端,我们先来看看客户端的支持情况。这里建议您直接去CanIUse查看。下图是2019/11的截图。可以使用目前主流浏览器已经支持ESM,只需要在script标签中传入指定的type="module"即可。另外,我们知道在Node.js中,使用ESM有时需要使用.mjs后缀,但是浏览器并不关心关于文件后缀,只有http响应头的MIME类型是正确的(Content-Type:text/javascript)。同时当type="module"时,默认开启defer来加载脚本。这里补充一张defer和async的区别图。img我们知道,当浏览器不支持script时,会提供noscript标签进行降级处理,modularity也提供了类似的标签。这样我们就可以针对支持的浏览器ESM浏览器直接使用模块化方案加载文件,不支持的浏览器仍然使用webpack打包的版本。预加载我们知道的浏览器链接标签可用于预加载资源。比如我需要预加载main.js文件:预加载单个文件。前面我们说了,一个模块化文件下载下来之后,需要转化为一个模块记录,进行模块实例化、模块求值等操作,所以我们得想办法告诉浏览器这个文件是一个模块化文件,所以浏览器提供了一个新的rel类型,专门用于模块化文件的预加载。虽然主流浏览器已经支持ESM,但根据chrome统计,使用的页面只有1%。截图时间为2019/11。统计服务器浏览器可以通过script标签指定当前脚本是否作为一个模块对待,但是Node.js中并没有明确的方式来指明是否使用ESM,Node.js本身已经有了标准的CommonJS模块化计划。即使开启了ESM,如何判断当前入口文件导入的模块是使用ESM还是CommonJS呢?为了解决以上问题,节点社区开始出ESM的相关草案,可以在github上找到。2017年发布的Node.js8.5.0启用了对ESM的实验性支持。启动程序时,添加--experimental-modules开启对ESM的支持,将.mjs后缀的文件解析为ESM。早期的预期是Node.js12会达到LTS状态并正式发布,然后预期并没有实现。直到最近的13.2.0版本才正式支持ESM,也就是取消了--experimental-modules启动参数。具体请参考Node.js13.2.0官方文档。.mjs后缀社区有两种完全不同的态度。支持方认为通过文件后缀区分类型是最简单明了的方式,社区已经有类似案例,比如React组件使用.jsx,ts文件使用.ts;js后缀存在了这么多年,视觉上很难接受一个.mjs也是一个js文件,而且现有的很多工具都是使用.js后缀来识别js文件的。如果引入.mjs方案,需要对大量工具进行修改,才能有效适配ESM。所以除了用.mjs后缀指定ESM外,还可以使用pkg.json文件的type属性。如果type属性为module,则表示当前模块应该使用ESM来解析模块,否则使用CommonJS来解析模块。{"type":"module"//module|commonjs(default)}当然有些本地文件没有pkg.json,但是你不想用.mjs后缀。这时候只需要在命令行中添加一个启动参数——-input-type=module。同时input-type还支持commonjs参数指定使用CommonJS(--input-type=commonjs)。综上所述,在Node.js中,以下三种情况会启用ESM模块加载方式:文件后缀为.mjs;pkg.json中的type字段指定为module;添加启动参数--input-type=module。同理,CommonJS模块加载方式也有三种情况会被启用:文件后缀为.cjs;pkg.json中的type字段指定为commonjs;添加启动参数--input-type=commonjs。虽然13.2版本移除了--experimental-modules启动参数,但根据文档,在Node.js中使用ESM仍然是一个实验性特性。《Stability:1-Experimental不过我相信等到Node.js14LTS版本发布后,ESM支持应该可以进入稳定阶段了。这里还有一份Node.js对ESM的全部计划清单。当文章发表,Node.js14已经正式发布。参考nodejs/modulesModulespecifiers:What'snewwithESmodules?IllustratedESModules(ESmodules:Acartoondeep-dive)二维码关注。转载本文请联系更厉害的前端?。