前端跨界开发指南:JavaScript工具库原理解析与实战
上QQ阅读APP看书,第一时间看更新

4.5 Require.js的核心原理

在前端开发普遍“拥抱”自动化工具的今天,编写并实现一个包管理工具也许并没有什么价值,但这样的练习对于前端开发者来说,可以有效提升编程水平和加深对代码执行细节的理解。初级开发者在工作中很少有机会跳出具体的业务逻辑来观察代码本身,而这种抽象编程的能力却是成为高级开发者的必备技能。

前文已经提到过,defer和async这两个异步相关的属性可用于解决下载脚本时主线程阻塞的问题:async异步属性可在脚本下载后立即解析,这极有可能打乱手动管理的自上而下的脚本顺序,导致系统报错;而defer异步属性则会将脚本的解析延迟到文档解析完毕后再进行,尽管其保持了手动编排的脚本顺序,但由于解析顺序的限制,排序靠后的库即使先完成下载,也需要等待排序靠前的脚本解析完成后才能解析,这无疑增加了整个工程的加载等待时间。

如何才能做到既利用async异步属性带来的非阻塞特性,又能在下载完成后立即解析,而且还能保证乱序后的脚本在解析时不会报错?下面就来看看如何使用require函数和define函数来解决这个问题。

首先,建立一个模块信息注册表,以及一个待执行工厂函数栈(栈是一种常见的数据结构,遵循先进后出的原则,原因稍后讲解),当使用require()函数加载一个有效模块时(有效模块是指在配置中声明了文件地址,或者其本身的模块名就是一个文件地址的模块),先在模块信息注册表中为这个依赖添加一条注册信息,记录它的模块名,并将标识其是否已经完成加载的属性设置为false,接着根据模块资源文件的地址发起jsonp请求以获得模块文件。此时,由于有前置依赖的关系,require函数的最后一个实参,也就是等所有依赖项都加载完成后才能运行的主函数,必须延迟执行。在JavaScript语言中,作为参数传递的函数称为“函数表达式”,此时它并不会直接运行,只有在外层函数的函数体中主动调用时,它才会运行,所以只需要将主函数压入待执行工厂函数栈,等它的依赖项都加载运行完毕后再拿出来执行即可。

假设我们请求的某个依赖项的文件全部下载到客户端了,而且浏览器已经完成了对这个文件内容的解析和运行,那么程序内部又是如何得知这一点的呢?答案是监听脚本的load事件,程序监听到某个模块加载完成后,就会触发对应脚本load事件的回调函数。在回调函数中,我们会在模块信息注册表中将这个模块注册信息中的loaded属性标记为true,标识它已经下载到客户端且完成了解析,可以使用了。

接着,程序需要检查待执行函数栈顶端的工厂函数,查看它所依赖的模块是否已经全部加载完毕,如果所依赖的模块中还存在loaded属性为false的模块,则什么也不做,如果所依赖的模块全都完成了解析,那么这个工厂方法就可以开始执行了。无论是require方法还是define方法,通过函数声明就可以知道,当工厂方法开始执行时,其依赖项的工厂函数都已经运行结束,且依赖模块的输出会作为实参传入该工厂方法中,示例代码如下:

require(['moment','lodash'],function(moment,_){
    //...工厂方法的函数体
})
define('moduleX',['moment', 'lodash'],function(moment,_){
    //...工厂方法的函数体
})

那么,依赖项是什么时候解析的呢?等我们分析完define函数的运行机制后自然就会明白。一个依赖模块的脚本下载至客户端后,浏览器就会解析该脚本,此时实际上运行的就是define函数。根据前面的讲解我们不难知道,此时模块登记表中已经拥有同名模块的id信息,且loaded属性为false,在define函数运行时,load事件还没有触发,登记表中这个模块的loaded属性依旧为false,所以即使这个模块文件已经到达客户端,也不会在检测待执行工厂函数栈时造成误判。

define函数所执行的逻辑是这样的,先查看当前这个模块是否有依赖项,如果有依赖,则处理方式与require函数一致,也就是将工厂函数压入待执行栈,然后对依赖的模块进行注册登记并获取之。如果没有依赖项,则直接执行该工厂函数,然后将工厂函数的输出结果添加到注册信息表中该模块命名空间下的exports属性上,接下来系统将触发该模块脚本的load事件,如果此时待执行栈顶的工厂函数正好只依赖该模块,那么工厂函数就会从注册信息表中找到该模块的信息,然后从exports属性上获取它执行后的输出,如果工厂函数还依赖于其他未加载的模块,则需要继续等待。但无论如何,当注册信息表中某个模块的loaded属性被设置为true时,就表示你可以从它的exports属性上获取模块的输出了(这个输出也可能是undefined,比如jQuery这种直接挂载全局命名空间的模块就没有输出),这也就保证了栈顶的工厂函数在执行时总是可以获得它需要的所有依赖模块的输出。

最后一个关键点就是,我们为什么要使用栈结构来存储待执行的工厂函数,而不能简单地使用集合呢?实际上,栈结构是为了防止间接依赖引发错误。我们来设想这样一个场景,A模块依赖于一个较小的B模块,B模块依赖于一个较大的C模块,当B模块完成下载并解析时,C模块可能还在下载,但此时如果检查A模块的依赖,就会发现它所依赖的B模块的loaded属性为true,因此,A的工厂函数将会开始执行。如果A模块在执行时所调用的B模块的方法恰好依赖于C模块,就会引发错误。如果使用栈结构,就必须每次都从栈顶进行检查,而此时,只会检查位于栈顶的B模块的依赖,如果发现C模块还没有完成下载和解析,就不再继续检查其他的模块。这样,当一个模块的工厂方法执行时,它的直接或间接依赖肯定都已经完成了解析,当然这只是一种基本的策略。

至此,对于Require.js的核心逻辑已经分析完毕,大家可自己动手实现一个简易的模块管理工具,以加深对整个过程的理解。如果需要参考代码,可以在本章配套仓库中获取,笔者已经按照前文阐述的思路实现了一个简易的模块加载库“brief-require”,其中添加了大量的注释和控制台打印信息,虽然它的功能并不完善,但足以帮助你搞清楚模块获取和加载的核心流程。“造轮子”有的时候并不是为了得到一个简陋的解决方案,而是帮助我们更好地拆分和理解所面对的问题,从而在有能力学习和研究其他流行的技术方案时,更容易明白新工具到底好在哪里,而不只是人云亦云地紧追潮流。