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

前端模块化知识整理

时间:2023-03-13 13:58:07 科技观察

1.背景作为前端开发,我们对模块化并不陌生。我们平时接触到的nodejs中的ES6import和require有什么区别?我们也听说过CommonJS、CMD、AMD、ES6模块系统,这些之间有什么联系?本文将对这些问题进行总结,让大家对模块化有一个清晰的认识。2、为什么需要模块化?1.OriginJS一开始没有模块化的概念,就是把普通的脚本语言放在script标签里,做一些简单的验证,代码量比较少。随着ajax的出现,前端可以请求数据,做更多的事情,逻辑也越来越复杂,就会出现很多问题。1.1全局变量冲突由于大家的代码都在同一个作用域内,不同人定义的变量名可能会重复,导致覆盖。变量数=1;//一个人声明...varnum=2;//别人声明1.2依赖管理麻烦比如我们引入了3个js文件,它们直接相互依赖,我们需要按照依赖关系从上到下排序。如果文件有十多个。我们需要先梳理依赖关系,然后手动按顺序引入,这样会让后续的代码更难维护。2.早期解决方案对于上面提到的问题,其实都有一些相应的解决方案。2.1命名空间命名空间是将一组实体、变量、函数和对象封装在一个空间中的行为。这里是模块化思想的雏形,通过简单的命名空间对“块”进行切分,体现了分离与内聚的思想。著名案例“YUI2”。//示例:constcar={name:'car',start:()=>{console.log('start')},stop:()=>{console.log('stop')}}以上示例说明可能存在问题。比如我们修改了汽车的名字,原来的名字也会随之改变。car.name='Test'console.log(car)//{name:'111',start:?,stop:?}2.2闭包再次改进模块化解决方案,使用闭包解决污染问题,更纯粹的内聚moduleA=function(){varname='car';return{start:function(c){返回名称+'开始';};}}()在上面的例子中,函数内部的变量对整个世界都是隐藏的,达到了封装的目的。但是模块名是全局暴露的,仍然存在命名冲突的问题。以下效果基于IIFE和闭包://moduleA.js(function(global){varname='car';functionstart(){};global.moduleA={name,start};})(window)上面表达式中的变量名不能从外部直接访问。综上所述,那么模块化可以解决哪些问题:解决命名污染、全局污染、变量冲突等问题内聚私有性,外部无法访问变量如何导入其他模块,如何将接口暴露给othermodulestoimportothermodules可能存在循环引用问题3.主流模块化解决方案1.CommonJS可以点击CommonJS规范查看相关介绍。1)每个文件都是一个有自己作用域的模块。文件中定义的变量、函数和类都是私有的,对其他文件不可见。2)CommonJS规范规定,在每个模块内,模块变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是一个外部接口。加载模块实际上是加载模块的module.exports属性。3)require方法用于加载模块。1.1加载模块varexample=require('./example.js');varconfig=require('config.js');varhttp=require('http');1.2对外暴露模块module.exports.example=function(){...}module.exports=function(x){console.log(x)}1.3Node.js的模块化说到CommonJS,不得不提Node.js,Node.js的出现让我们使用JavaScript是用来编写服务器端代码的,而Node应用程序是由模块组成的,使用CommonJS模块规范。当然也不是完全按照CommonJS。它进行了权衡并添加了一些自己的功能。1)Node内部提供了一个Module构造函数。所有的模块都是Module的实例,在每个模块内部,都有一个代表当前模块的模块对象。包含以下属性:module.id模块的标识符,通常是带有绝对路径的模块文件名。module.filename模块的文件名,带有绝对路径。module.loaded返回一个布尔值,指示模块是否已完成加载。module.parent返回一个代表调用该模块的模块的对象。module.children返回一个数组,表示该模块将使用的其他模块。module.exports表示模块导出的值。2)Node使用CommonJS模块规范,内置require命令用于加载模块文件。3)第一次加载模块时,Node会缓存该模块。稍后加载模块时,直接从缓存中获取模块的module.exports属性。所有缓存的模块都存储在require.cache中。//a.jsvarname='Lucy'exports.name=name//b.jsvara=require('a.js')console.log(a.name)//"Lucy"a.name="hello";varb=require('./a.js')console.log(b.name)//第一次加载后修改了"hello"的name值,第二次加载时打印的name为最后一次已修改,证明已从缓存中读取。如果你想删除一个模块的缓存,你可以这样做:deleterequire.cache[moduleName];4)CommonJS模块的加载机制是输入是输出值的副本。也就是说,一旦输出了一个值,模块内部的变化就不会影响这个值。看看下面的例子。//a.jsvarcounter=3exports.counter=counterexports.addCounter=function(a){counter++}//b.jsvara=require('a.js')console.log(a.counter)//3a.addCounter()console.log(a.age)//3这个例子说明加载a.js模块后,模块内部的变化不会影响a.counter。这是因为a.counter是原始值,将被缓存。除非写成函数,否则可以获得内部变化的值。2、前端模块化前面提到的CommonJS规范是基于node的,所以CommonJS都是服务端实现的。为什么?因为CommonJS规范加载模块是同步的,也就是说只有加载完成后,才能进行下面的操作。由于Node.js主要用于服务端编程,模块文件一般已经存在于本地硬盘上,所以加载速度更快,而且不需要考虑异步加载的方式,所以CommonJS规范更适用。如果是浏览器环境,需要从服务器端加载模块。使用CommonJS,需要等待模块下载并运行后才能使用,这样会阻塞后面代码的执行。这时就必须采用异步方式,所以浏览器端一般采用AMD规范,解决异步加载的问题。2.1AMD(AsynchronousModuleDefinition)和RequireJSAMD是异步加载模块的规范。RequireJS是一个实用程序库。主要用于客户端模块管理。它允许将客户端的代码分成模块进行异步或动态加载,从而提高代码的性能和可维护性。其模块管理符合AMD规范。2.1.1模块定义1)独立模块(不需要依赖任何其他模块)//独立模块定义define({method1:function(){}method2:function(){}});//ordefine(function(){return{method1:function(){},method2:function(){},}});2)非独立模块(需要依赖其他模块)define(['module1','module2'],function(m1,m2){return{method:function(){m1.methodA();m2.methodB();}};});definemethod:第一个参数是一个数组,其members是当前模块所依赖的模块第二个参数是一个函数,当前面数组的所有成员都已成功加载时将被调用。它的参数和数组的成员一一对应,这个函数必须返回一个对象供其他模块调用。2.1.2模块调用require方法用于调用模块。它的参数类似于define方法。require(['a','b'],function(a,b){a.doSomething();});define和require这两种定义模块和调用模块的方法统称为AMD模式。它的模块定义方式非常清晰,不污染全局环境,可以清晰的展现依赖关系。2.1.3require.js的config方法require方法本身也是一个对象,它有一个config方法,用来配置require.js的运行参数。require.config({paths:{jquery:['//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js','lib/jquery']}});参数对象包含:paths指定每个模块的位置baseUrl指定本地模块位置的基本目标shim用于帮助require.js加载非AMD标准库。2.1.3CommonJS和AMD比较CommonJS一般用在node等服务器端,AMD一般用在浏览器环境,允许异步加载模块,可以按需动态加载CommonJS和AMD都是在加载时runtime2.1.4运行简单来说,CommonJS和AMD都只能在运行时确定一些东西,所以是在运行时加载。例如下面的例子://CommonJSmodulelet{stat,exists,readFile}=require('fs');//等价于let_fs=require('fs');letstat=_fs.stat;let存在=_fs.exists;让readfile=_fs.readfile;上面的代码其实是整体加载fs模块,生成一个_fs对象,然后从这个对象中读取三个方法。因为这个对象只能在运行时获取,所以就变成了运行时加载。这是AMD的示例://AMDdefine('a',function(){console.log('aloaded')return{run:function(){console.log('aexecuted')}}})define('b',function(){console.log('bloaded')return{run:function(){console.log('bexecuted')}}})//runrequire(['a','b'],function(a,b){console.log('mainexecution')a.run()b.run()})//运行结果://aloaded//bloaded//mainexecuted//aexecution//bexecution我们可以看到在执行的时候,先加载了a和b,然后从main开始执行。所以在require一个模块的时候,会先加载这个模块,然后返回一个对象,这个对象会被整体加载,也就是我们常说的前置依赖。2.2CMD(CommonModuleDefinition)和SeaJS在Sea.js中,所有的JavaScript模块都遵循CMD(CommonModuleDefinition)模块定义规范。Sea.js和RequireJS有什么区别?这是官方的区别。RequireJS遵循AMD(AsynchronousModuleDefinition)规范,Sea.js遵循CMD(CommonModuleDefinition)规范。规范的不同导致两者的API不同。Sea.js更接近CommonJSModules/1.1和NodeModules规范。下面简单对比一下AMD和CMD:AMD在定义一个模块的时候,指定了所有的依赖。依赖模块加载完成后,会执行一个回调,并将参数传递给这个回调方法:define(['module1','module2'],function(m1,m2){...});CMD规范中的一个模块就是一个文件,模块更接近于Node对CommonJS规范的定义:define(factory);//工厂可以是函数或对象或字符串。当factory是一个function时,表示它是模块的构造方法。通过执行该构造方法,可以得到模块提供的接口。工厂方法执行时,默认会传入三个参数:require、exports和module:define(function(require,exports,module){//模块代码});其中,require是一个接受模块ID作为唯一参数的方法,用于获取其他模块提供的接口。当需要依赖某个模块时,可以随时调用require()importdefine(function(require,exports){//获取模块a的接口vara=require('./a');//调用模块a的方法a.doSomething();});下面来演示CMD的执行define('a',function(require,exports,module){console.log('aload')exports.run=function(){console.log('aexecute')}})define('b',function(require,exports,module){console.log('bload')exports.run=function(){console.log('bexecute')}})define('main',function(require,exports,module){console.log('mainexecution')vara=require('a')a.run()varb=require('b')b.run()})//mainexecution//aload//aexecution//bload//bexecution看到执行结果,真正需要使用(依赖)模块时才执行模块。感觉这跟我们的认知是一样的,毕竟执行顺序我也是这么想的,但是看之前AMD的执行结果,都是先加载完a和b之后才开始执行main.因此,相对于AMD的前置依赖、提前执行,CMD提倡的是近依赖、延迟执行。2.3UMD(UniversalModuleDefinition)UniversalModuleSpecification可以看出,兼容模式其实是兼容了几种常见的模块定义方式。(function(global,factory){typeofexports==='object'&&typeofmodule!=='undefined'?factory(require('lodash'))//node,commonJS:typeofdefine==='function'&&define.amd?define(['lodash'],factory)//amdcmd:(global=typeofglobalThis!=='undefined'?globalThis:global||self,factory(global.lodash));}(this,(function(lodash){'usestrict';...})));2.4ES6模块模块功能主要由两个命令组成:export和import。export命令用于指定模块的对外接口,import命令用于导入其他模块提供的功能。2.4.1模块导出模块是一个独立的文件。文件内部的所有变量都无法从外部获得。如果想让外部能够读取模块内部的一个变量(函数或类),就必须使用export关键字来输出变量(函数或类)。1)导出变量和函数//a.js//导出变量exportvarname='Michael';exportvaryear=2010;//或者//也可以这样导出varname='Michael';export{name,year};复制代码//导出函数exportfunctionmultiply(x,y){returnx*y;};2)as的使用通常export输出的变量是原名,但是可以使用as关键字重命名。functionv1(){...}functionv2(){...}export{v1作为streamV1,v2作为streamV2,v2作为streamLatestVersion};2.4.2模块介绍1)使用export命令定义模块对外接口后,其他JS文件可以通过import命令加载该模块。//一般用法import{name,year}from'./a.js';//asusageimport{nameasuserName}from'./a.js';注意:import命令有提升作用,会提升到整个模块的头部,最先执行。下面的代码不会报错,因为import是在调用foo之前执行的。这种行为的本质是在代码运行之前,在编译阶段(稍后将在比较CommonJs时讨论)执行import命令。foo();import{foo}from'my_module';2)整体模块加载//user.jsexportname='lili';exportage=18;//一一加载import{age,name}from'./用户。js';//整体加载import*asuserfrom'./user.js';console.log(user.name);console.log(user.age);3)exportdefaultcommandexportdefault命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,所以exportdefault命令只能使用一次。所以import命令后面不需要用大括号,因为它只能对应exportdefault命令。导出默认函数foo(){//输出//...}从'foo'导入foo;//input注意:正是因为exportdefault命令实际上只输出了一个名为default的变量,所以后面不能跟变量声明语句。//正确的vara=1;出口默认一个;//错误//`exportdefaulta`的意思是将变量`a`的值赋值给变量`default`。//所以这种写法会报错。exportdefaultvara=1;2.4.3ES6模块、CommonJS和AMD模块的区别1)编译时和运行时加载ES6模块的设计思路是尽可能静态化,让模块的依赖关系可以在编译时确定,以及输入和输出变量。所以ES6是在编译时加载的。CommonJS和AMD模块都只能在运行时确定这些东西。例如,CommonJS模块是对象,必须在输入时查找对象属性。//CommonJS模块let{stat,exists,readFile}=require('fs');//等价于let_fs=require('fs');让stat=_fs.stat;让exists=_fs.exists;letreadfile=_fs.readfile;//----------------//ES6模块导入{stat,exists,readFile}from'fs';CommonJS和ES6模块加载的区别:CommonJS本质是将fs模块作为一个整体进行加载(即加载fs的所有方法),生成一个对象(_fs),然后从这个对象中读取3个方法。这种加载称为“运行时加载”,因为这个对象只能在运行时获取,导致编译时没办法做“静态优化”。ES6模块本质上是加载fs模块中的3个方法,其他方法不加载。这种加载称为“编译时加载”或静态加载,即ES6可以在编译时完成模块加载,比CommonJS模块加载效率更高。2)值拷贝和引用拷贝前面1.3Node.js模块化提到CommonJS是值拷贝。模块加载并输出一个值后,模块内部的变化不会影响这个值。因为这个值是原始类型值,所以它会被缓存。ES6模块的工作方式与CommonJS不同。JS引擎静态分析脚本时,遇到模块加载命令import时会生成一个只读引用。当脚本真正执行时,根据只读引用去加载模块获取值。也就是说,ES6的导入有点像Unix系统的“符号链接”。当原值改变时,import加载的值也会改变。因此,ES6模块是动态引用,不缓存值。模块中的变量绑定到它们所在的模块。//a.jsexportletcounter=3;exportfunctionaddCounter(){counter++;}//b.jsimport{counter,addCounter}from'./a';console.log(counter);//3addCounter();控制台.log(计数器);//4ES6模块输入的变量counter是活跃的,充分体现了所在模块a.js内部的变化。