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

前端模块化的前世

时间:2023-03-15 18:27:24 科技观察

前言随着前端项目规模越来越大,组件化前端框架、前端路由等技术的发展,模块化已经成为现代前端的必备技能工程师。无论什么语言发展到一定程度,其工程化能力和可维护性势必也随之发展。模块化在任何编程领域都非常普遍。模块化的意义在于增加可重用性,用尽可能少的代码实现个性化的需求。前端三剑客之一的CSS早在2.1版本就提出了@import来实现模块化,而JavaScript直到ES6才有了官方的模块化解决方案:ESModule(import,export)。虽然早期的JavaScript语言规范不支持模块化,但这并没有阻止JavaScript的发展。没有官方模块化标准的开发人员开始自己创建规范并实施规范。在CommonJS出现的十年前,前端还没有现在这么火,模块化只是简单的使用闭包实现命名空间。2009年对于JavaScript来说无疑是重要的一年,有了新的JavaScript引擎(v8),成熟的库(jQuery、YUI、Dojo),ES5也在提案中,但JavaScript仍然只出现在浏览器中。早在2007年,AppJet就提供了创建和托管服务器端JavaScript应用程序的服务。后来Aptana也提供了一个可以在服务器端运行Javascript的环境,叫做Jaxer。在网上也可以找到关于AppJet和Jaxer的博客,甚至Jaxer项目还在github上。Jaxer但这些东西都没有发展起来,Javascript也不是传统服务器端脚本语言(PHP、Python、Ruby)的替代品。虽然它有很多缺点,但是并不妨碍很多人使用它。后来有人开始思考JavaScript还需要在服务器端跑什么。于是在2009年1月,Mozilla工程师KevinDangoor发起了CommonJS提案,号召JavaScript爱好者联合起来,为运行在服务器端的JavaScript编写相关规范。一周后,有224名参与者。“[这]不是技术问题,而是人们聚在一起并决定向前迈进并开始共同打造更大更酷的东西的问题。”CommonJS标准封装了JavaScript在服务器上运行所需要的基本能力,例如:模块化、IO操作、二进制字符串、进程管理、Web网关接口(JSGI)。但影响最为深远的是CommonJS的模块化方案,这是JavaScript社区第一次在系统上取得的成果,不仅支持依赖管理,还支持作用域隔离和模块识别。后来node.js诞生的时候直接采用了CommonJS模块化规范,还带来了npm(NodePackageManager,现在已经是全球最大的模块仓库)。CommonJS在服务端表现很好,很多人都想把CommonJS移植到客户端(也就是我们所说的浏览器)来实现。因为CommonJS的模块加载是同步的,服务器端直接从磁盘或者内存读取耗时几乎可以忽略不计,但是如果还是在浏览器端同步加载,对用户体验是极其不友好的。在模块加载过程中,必然会向服务器请求其他模块代码,在网络请求过程中。导致长白屏。因此,一些派别逐渐从CommonJS中分裂出来。在这些派系的发展过程中,出现了一些业界熟悉的解决方案AMD、CMD、打包工具(Component/Browserify/Webpack)。AMD规范:RequireJSRequireJSlogoRequireJS是AMD规范的代表作。之所以能代表AMD规范,是因为RequireJS的作者(JamesBurke)是AMD规范的提出者。同时作者还开发了amdefine,可以让你在nodeCanonical库中使用AMD。AMD规范是由CommonJS的Modules/Transport/C提案发展而来的,毫无疑问,Modules/Transport/C提案的发起人是JamesBurke。JamesBurke指出了浏览器中CommonJS规范的一些缺点:缺乏模块封装:CommonJS规范中的每个模块都是一个文件。这意味着每个文件只有一个模块。这在服务端是可行的,但是在浏览器中不太友好,需要尽可能少的请求。以同步方式加载依赖:虽然以同步方式加载可以让代码更容易理解,但是在浏览器中使用同步加载会导致长时间白屏,影响用户体验。CommonJS规范使用一个名为export的对象来暴露模块,需要导出的变量附加在export上,但对象不能直接赋值。如果需要导出构造函数,则需要使用module.export,这可能会造成混淆。AMD规范定义了一个defineglobal方法来定义和加载模块。当然,RequireJS也扩展了require全局方法,用于后面加载模块。这种方式解决了在浏览器中使用CommonJS规范的不足。定义(id?,依赖项?,工厂);使用匿名函数封装模块,通过函数返回值定义模块,更符合JavaScript的语法。这样就避免了对exports变量的依赖,避免了一个文件只能暴露一个模块的问题。提前列出依赖项并在浏览器中异步加载它们,允许模块开箱即用。define("foo",["logger"],function(logger){logger.debug("startingfoo'sdefinition")return{name:"foo"}})指定一个模块ID(名称),供模块唯一标识定义模块。此外,AMD的模块名称规范是CommonJS模块名称规范的超集。define("foo",function(){return{name:'foo'}})RequireJS原理在讨论原理之前,我们可以先看看RequireJS的基本用法。模块信息配置:require.config({paths:{jquery:'https://code.jquery.com/jquery-3.4.1.js'}})依赖模块加载调用:require(['jquery'],function($){$('#app').html('loaded')})模块定义:if(typeofdefine==="function"&&define.amd){define("jquery",[],function(){returnjQuery;});}我们首先使用config方法配置jquery模块的路径,然后调用require方法加载jquery模块,然后在回调中调用加载的$对象。在这个过程中jquery会通过define方法暴露出我们需要的$对象。了解了基本的使用流程后,我们继续深入研究RequireJS的原理。模块信息配置模块信息的配置其实很简单,几行代码就可以实现。定义一个全局对象,然后使用Object.assign进行对象扩展。//配置信息constcfg={paths:{}}//全局require方法req=require=()=>{}//扩展配置req.config=config=>{Object.assign(cfg,config)}依赖模块加载和调用require方法的逻辑非常简单。简单的参数验证后,调用getModule方法实例化Module,getModule会缓存实例化的模块。因为require方法在制作模块实例的时候没有模块名,所以这里生成了一个匿名模块。模块类,我们可以理解为一个模块加载器,主要作用是加载依赖,加载依赖后,调用回调函数,将依赖的模块一个一个作为参数返回给回调函数。//全局require方法req=require=(deps,callback)=>{if(!deps&&!callback){return}if(!deps){deps=[]}if(typeofdeps==='function'){callback=depsdeps=[]}constmod=getModule()mod.init(deps,callback)}letreqCounter=0constregistry={}//注册模块//模块加载器的工厂方法constgetModule=name=>{if(!name){//如果模块名不存在,表示为匿名模块,自动构造模块名name=`@mod_${++reqCounter}`}letmod=registry[name]if(!mod){mod=registry[name]=newModule(name)}returnmod}模块加载器是整个模块加载的核心,主要包括enable方法和check方法。模块加载器实例化后,首先会调用init方法进行初始化,初始化时传入模块的依赖和回调。//模块加载器classModule{constructor(name){this.name=namethis.depCount=0this.depMaps=[]this.depExports=[]this.definedFn=()=>{}}init(deps,callback){this.deps=depsthis.callback=callback//判断是否存在依赖if(deps.length===0){this.check()}else{this.enable()}}}enable方法主要用于模块dependencies加载,该方法主要逻辑如下:1.遍历所有依赖模块;2.记录加载模块数(this.depCount++),用于判断是否所有依赖模块都加载完毕;3、实例化依赖模块Loader的模块,并绑定definedFn方法;》definedFn方法会在依赖模块加载完成后调用,主要作用是获取依赖模块的内容,并将depCount减1,最后调用check方法(该方法会判断depCount是否小于1,从而定义依赖全部加载);4.最后通过依赖模块名,获取依赖模块在配置中的路径并加载模块.classModule{...//启用模块并执行dependentloadingenable(){//遍历依赖this.deps.forEach((name,i)=>{//记录加载模块数this.depCount++//实例化依赖模块的模块加载器,绑定回调加载的模块constmod=getModule(name)mod.definedFn=exports=>{this.depCount--this.depExports[i]=exportsthis.check()}//获取配置中依赖模块的路径,并加载模块consturl=cfg.paths[name]loadModule(name,url)});}...}loadModule的主要功能是通过url加载一个js文件并绑定一个onload事件。onload会重新获取依赖模块的实例化模块加载器,并调用init方法。//缓存加载模块constdefMap={}//依赖加载constloadModule=(name,url)=>{consthead=document.getElementsByTagName('head')[0]constnode=document.createElement('script')node.type='text/javascript'node.async=true//设置一个data属性,方便加载依赖后获取模块名node.setAttribute('data-module',name)node.addEventListener('load',onScriptLoad,false)node.src=urlhead.appendChild(node)returnnode}//节点绑定的onload事件函数constonScriptLoad=evt=>{constnode=evt.currentTargetnode.removeEventListener('load',onScriptLoad,false)//获取模块名称constname=node.getAttribute('data-module')constmod=getModule(name)constdef=defMap[name]mod.init(def.deps,def.callback)}看前面的例子因为只有一个依赖(jQuery),并且jQuery模块没有其他依赖,所以init方法会直接调用check方法。这里也可以想一想,如果是有依赖的模块,后续流程是怎样的呢?define("jquery",[]/*无其他依赖*/,function(){returnjQuery;});check方法主要用于依赖检测,依赖加载完成后调用回调。//模块加载器classModule{...//检查依赖是否被加载check(){letexports=this.exports//如果依赖个数小于1,则说明所有依赖都被加载了if(this.depCount<1){//调用回调并获取模块内容exports=this.callback.apply(null,this.depExports)this.exports=exports//激活定义的回调this.definedFn(exports)}}。..}最后再通过definedFn回到依赖模块,即调用require方法实例化的匿名模块加载器,将依赖模块暴露的内容存储在depExports中,然后调用匿名模块的check方法加载器调用回调。mod.definedFn=exports=>{this.depCount--this.depExports[i]=exportsthis.check()}模块定义中还有一个问题,依赖模块加载后回调中如何获取依赖模块依赖和回调呢?constdef=defMap[name]mod.init(def.deps,def.callback)答案是通过全局定义的define方法,会把模块的依赖和回调存储在一个全局变量中,后面按需获取即可.constdefMap={}//缓存加载的模块define=(name,deps,callback)=>{defMap[name]={name,deps,callback}}RequireJS原理总结最后可以发现RequireJS的核心在于在模块加载器中的实现,无论是通过require加载依赖,还是使用define定义模块,都离不开模块加载器。有兴趣的可以在我的github上查看精简版RequrieJS的完整代码。CMD规范:sea.jssea.jslogoCMD规范由国内开发者宇博提出。虽然在国际上的知名度远不及AMD,但在国内也算是与AMD齐头并进。与AMD的异步加载相比,CMD更倾向于懒加载,CMD的规范更接近CommonJS。它只需要在CommonJS之外添加一个函数调用包装器。define(function(require,exports,module){require("./a").doSomething()require("./b").doSomething()})作为CMD规范的实现sea.js也实现了一些东西类似于RequireJS的api:seajs.use('main',function(main){main.doSomething()})sea.js的加载方式和RequireJS一样,都是通过在头标签。但是加载顺序有一定的区别。为了弄清楚两者的区别,我们直接看一段代码:RequireJS://RequireJSdefine('a',function(){console.log('aload')return{run:function(){console.log('arun')}}})define('b',function(){console.log('bload')return{run:function(){console.log('brun')}}})require(['a','b'],function(a,b){console.log('mainrun')a.run()b.run()})requirejsresultsea.js://sea.jsdefine('a',function(require,exports,module){console.log('aload')exports.run=function(){console.log('arun')}})define('b',function(require,exports,module){console.log('bload')exports.run=function(){console.log('brun')}})define('main',function(require,exports,module){console.log('mainrun')vara=require('a')a.run()varb=require('b')b.run()})seajs.use('main')sea.js结果可以看到sea.js的模块是懒加载,只有在require的地方,模块才会真正运行。另一方面,RequireJS会先运行所有依赖,然后在获取所有暴露的依赖结果后执行回调。正是由于懒加载机制,sea.js提供了seajs.use方法来运行定义的模块。所有定义的回调函数都不会立即执行,而是会缓存所有回调函数,只有在使用和需要的模块回调时才会执行。sea.js的原理下面简单说明一下sea.js的懒加载逻辑。在调用define方法时,只需将模块放入一个全局对象中进行缓存即可。constseajs={}constcache=seajs.cache={}define=(id,factory)=>{consturi=id2uri(id)constdeps=parseDependencies(factory.toString())constmod=cache[uri]||(cache[uri]=newModule(uri))mod.deps=depsmod.factory=factory}classModule{constructor(uri,deps){this.status=0this.uri=urithis.deps=deps}}这里有一个类似于RequireJS的Module模块装载机。后面运行的seajs.use会从缓存中取出对应的模块并加载。》注意:这部分代码只是简单介绍了use方法的逻辑,不能直接运行letcid=0seajs.use=(ids,callback)=>{constdeps=isArray(ids)?ids:[ids]deps。forEach(async(dep,i)=>{constmod=cache[dep]mod.load()})}另外,sea.js的依赖都是在工厂里声明的,模块调用的时候,sea.js会将factory转成字符串,然后匹配require('xxx')中的所有xxx来存储依赖,前面代码中的parseDependencies方法就是这样做的。早期的sea.js是直接通过正则化匹配的:constparseDependencies=(code)=>{constREQUIRE_RE=/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/gconstSLASH_RE=/\\\\/gconstret=[]code.replace(SLASH_RE,'').replace(REQUIRE_RE,函数(_,__,id){if(id){ret.push(id)}})returnret}不过后来发现regex存在各种bug,regex过长不利于维护,所以sea.js后期放弃了这种方式,转而使用statemachineforlexicalanalysis通过解析的方式获取require依赖,详细代码可以查看sea.js相关的子项目:require.sea.js原理总结其实sea.js的代码逻辑大体是类似于RequireJS,通过创建脚本标签来加载模块,并且都实现了一个模块记录器来管理依赖,主要区别在于lazysea.js的加载机制,以及在使用上,sea.js的所有依赖都没有提前声明,而是在sea.js内部通过正则化或者词法分析的方式,将依赖于手动提取。有兴趣的可以在我的github上查看sea.js简化版的完整代码。综上所述,ES6的模块化规范日趋完善,其静态思想也为后期的打包工具提供了便利,可以友好的支持treeshaking。了解这些过时的模块化解决方案似乎有点乏味,但历史不能忘记。我们应该多了解一下这些东西的背景和前人的解决方法。思考,而不是抱怨新事物变化的速度太快。本文转载自微信公众号《更神奇的前端》,可通过以下二维码关注。转载本文请联系更牛逼的前端公众号。