通过seajs研究前端模块化-seajs学习心得

本篇文章主要是我在学习seajs过程中,模仿seajs实现过程中的一些心得和体会。我在网上通过学习视频学习前端模块化时,当时的老师正好使用seajs来讲解前端的CommonJS模块化规范并讲解了seajs的源码,教我们seajs是如何CommonJS模块化规范来实现浏览器端的js模块化,学完之后,我则根据上课时,老师给我们讲解的思路,自己尝试着按照seajs的实现CommonJS规范的方式实现了一个模块加载器,seajs整个模块根据我的理解,总共有以下主要关注的功能:


1.路径解析工具
2.event事件触发生命周期钩子函数(用于插件编写)
3.使用script进行模块加载
4.Module构造函数(重点)
5.外部接口暴露API:define seajs对象 等
6.解析define定义的模块中的所有依赖模块
7.配置config。

而我在学习seajs时的关注点在4,5两个方面,也就是CommonJS规范这一块的实现上。因为如果让我同时关注太过的地方,以我目前的能力还做不到,所以只能隐藏其他细节。所以,下面我在述说时会同时隐藏seajs的路径解析,事件钩子,config配置以及script脚本加载的功能。这些功能的实现我会一句话带过。而我在下文中提到的'seajs的实现'之类的类似语句,仅指实现的4,5这两部分功能的seajs。(ps:以下内容假设您已了解CommonJS规范,使用过seajs,如果没有,可以自行搜索相关资料)首先先大致了解一下seajs他在实现CommonJS模块化规范时,代码的执行流程,用以下这张图说明一下吧:

图片出处:max老师
图片出处:腾讯课堂动脑学院-max老师


这张图是课堂老师在讲解并实现seajs时,绘制的代码执行流程,而我自行实现的seajs也是按照类似的流程在编写,下面来详细讲解一下我的根据上图实现seajs的过程。首先,整个seajs主要分为以下几个方法和全局变量:

// 缓存Module对象,以uri为键,一个Module对象为值。
cacheMod
// 存储一些sea的数据,这里的主要用途后续会介绍
seaData
// 模块生命周期状态
status
// 模块入口,使用它开启模块加载
seajs.use
// 全局方法,用于在每个js文件中定义一个模块,主要是为了调用Module.use方法
define
// 模块构造函数,表明一个模块对象
Module
   const Module = function (uri) {
      this.uri = uri;
      // 该模块的依赖模块,比如:'./a.js'
      this.deps = [];
      // 依赖模块的Uris缓存
      this.depsUris = null;
      this.status = 0;
      this.exports = {};
      // 虚拟根目录,如果模块的uri为:'www.example.com/static/js/a.js'
      // 则refUri为:www.example.com/static/js/  他用来定位依赖模块的绝对路径
      // 比如依赖模块为 './b.js' 则通过refUir可以解析到b模块的绝对路径为:
      // www.example.com/static/js/b.js
      this.refUri = '';
      // 存储该模块所依赖的所有依赖的模块的绝对路径。并以boolean标识
      // 是否进行了模块加载判断处理
      this._waitings = {};
      this.parentModUris = {};
      // 依赖模块加载数量的计数器,单他为0时,表明我这个模块的所有依赖模块都已经加载完毕,
      // 可以加载此模块的loadEnd方法。
      this.counter = 0;
      this.isLoop = false;
   };
// Module构造函数中的一些方法
Module.use,Module.get
// 模块对象的原型方法,后面会介绍用途(mod代表一个Module对象)
mod.fatch,mod.save,mod.load,mod.loadEnd,mod.exec,mod.resolve
// 加载一个脚本模块
seajs.loadScript
// 循环递归判断是否产生了循环依赖(这一个方法是自行增加)
seajs.isLoopRequire
// 解析define中的所有依赖
seajs.getRequireModule

首先,我们在引入seajs库时,使用seajs.use方法来定义入口模块,整个模块加载从这里开始,

seajs.use(['./a.js'], (a) => {
   // TODO
});

调用此方法后,其实seajs.use的主要作用在于:为这个匿名的主模块生成一个唯一的模块标识符。其实就是这个模块绝对路径,源码中使用uri表示。

此时进入到Module.use方法,此方法内部通过调用Module.get(uri)方法,使用模块标识符uri来从缓存中获取Module对象,在查找时,如果此uri模块存在,则返回,否则,以此uri创建一个Module对象,存入缓存,然后给主模块扩展一个callback方法,当主模块的所有依赖加载完毕后,会执行这个callback方法。(只有使用seajs.use方法加载的主模块才会存有callback方法,依赖模块并没有)
执行主模块的load方法,开始加载主模块的所有依赖模块:mod.load();

暂停一下,先了解一下模块生命周期的含义及作用。模块的生命周期状态和含义:
 

const status = {
   // 开始启动一个模块的加载,对应mod.fatch()
   FETCH: 1,
   // 保存define定义改模块时的依赖信息,factory函数之类的信息至cacheMod缓存。
   // (已经加载script脚本)对应mod.save()
   SAVE: 2,
   // 开始加载模块依赖:对应mod.load()
   LOAD: 3,
   // 模块依赖加载完毕,可以执行此模块来获取接口对象,对应mod.loadEnd()
   LOADEND: 4,
   // 正在执行模块,即获取模块的接口对象。对应mod.exec()
   EXECING: 5,
   // 接口模块获取完成。对应mod.exec()
   EXECEND: 6,
};

1.首先,模块在开始生命周期时,肯定使用过Module.get()方法,创建模块并缓存至cacheMod对象中了此时,Module对象的status为0。

2.模块在进入FETCH,更改模块状态为FETCH,并调用seajs.loadScript方法,使用script标签的方式加载此模块,并在script标签的onload事件的回调中执行mod.save方法。

3.调用mod.save方法,此时模块status为SAVE,保存这个js文件中使用define方法定义模块时,传入的factory函数,(此处有一个疑问:如何获取到define方法传入的factory函数,稍后会解释)然后调用Module.getRequireModule方法,其内部使用factory函数的toString方法,利用正则解析获取这个模块的依赖,并存储至mod.deps属性中。然后调用mod.load()方法,加载这个模块的依赖模块。

4.调用mod.load方法,此时模块的status为LOAD。遍历该模块的依赖模块,如果依赖的模块还未进行加载,即状态小于FATCH,则开始此依赖模块的生命周期,进入依赖模块的mod.fatch方法中,开始这个模块的生命周期。当此模块的所有依赖加载完毕,或者,当此模块没有依赖模块,则执行模块的loadEnd方法(此处的核心是根据依赖模块的生命周期处于不同的状态进行不同的操作,用于解决模块的重复加载模块的循环依赖

5.调用mod.loadEnd方法,此时模块的status为LOADEND,此模块加载完成,也表明此模块的所有依赖已经加载完,并且,可以执行此模块获取此模块暴露的API接口的状态了。(此处的核心在于,当我这个模块加载完毕时,需要来'告知所有依赖了我这个模块的模块,提醒他们,我加载好了'。那如何告知?下面会解释)

6.执行mod.exec方法,此时模块的status为EXECING,然后获取到此模块在调用define定义此模块时,传入的factory函数,并执行他来获取此模块暴露的API接口,主要在于执行factory方法时传入的require方法.

他的代码如下:

/* 他的主要做的事在于,根据调用require方法时,传入的ids,比如 require('./b.js')中的
* './b.js',根据他和这个模块refUri获取到该模块的uri,然后执行此模块的exec方法
* 来获取到b.js暴露的接口API。
*/
const require = function (ids) {
   const uri = seajs.resolve(ids, refUri);
   const requireMod = Module.get(uri);
   return requireMod.exec();
};

当一个模块执行完exec时,获取到该模块暴露的API接口,然后将此模块的status设置为EXECEND。然后将模块暴露的API接口缓存至mod.exports属性中,并返回出去。当下一次再执行时,如果此模块的status为EXECEND,则直接返回此模块的exports属性(解决模块的重复执行)

至此,模块的生命周期已经执行完毕。之后,如果还有模块依赖此接口,则可以直接获取此模块暴露的API接口。

此时回到我们一开始调用seajs.use,然后调用Module.use后,开始执行主模块的load方法那里(可以看到,使用seajs.use加载的那个主模块是没有FATCH和SAVE状态的,直接进入到LOAD状态,加载load方法):

模块的load方法主要做了以下几件事:

1.判断模块是否已经执行过进入LOAD状态,是则直接返回

2.更改模块状态为LOAD

3.获取该模块的所有依赖模块(Module对象)并遍历,判断模块生命周期,如果该依赖模块未加载,即状态小于FATCH,则执行该依赖模块的fatch方法,正式开始此依赖模块的生命周期。然后进行递归加载依赖模块。

上诉流程中有几个问题是需要解决的:

1..save方法中,如何获取到define方法传入的factory函数

2.调用模块的load方法时,如何根据模块的生命周期解决模块的重复加载和模块的循环依赖

3.在模块加载完毕时,loadEnd方法如何配合load方法,来通知所有依赖了我这个模块的模块,告知他们我已经加载完毕?

第1条,需要用到一个全局的变量,seaData对象。然后,define函数在执行时,会把他接收的factory函数存入至seaData对象的一个属性中,然后模块的save方法从这个seaData对象中获取。这里需要解释一下:在使用script标签加载js时,当js加载完毕,执行js里面的代码后,会触发并执行(我猜测是立即执行)这个标签绑定的onload事件的函数。也就是:a.ja加载完毕后执行了a.js中的define方法后,存储了factory函数,就可以立即调用此a.js模块的save()方法(未验证,可能有误),保存factory函数,所以不会产生a模块拿到的可能是b模块的factory。

先说第三条吧:

这里使用的是一个哨兵对象,类似一个计数器。每一个模块都有一个count属性,他表明还有多少依赖模块没有加载。在load执行时,判断当count属性为0时,则此模块可以直接执行loadEnd方法。然后每一个模块都有一个parentModUris和_waitings属性:parentModUris是一个数组,存放的是所有依赖了我这个模块的模块uri,姑且称之为父模块。_waitings是一个对象,键为依赖模块的uri,值为boolean,标识我是否对某个依赖模块进行了加载完成的判断(看不懂请看后面的举例)。而loadEnd方法在执行时,会遍历parentModUris中的所有父元素,如果父模块的count不为0,并且父模块中_waitings对象中的这个uri模块的属性不为true,则把父模块的count属性减1然后判断,如果父元素的count为0了,那么执行父模块的loadEnd方法。_waitings有什么用呢?举个例子吧:

a模块依赖b和c模块,假设都是第一次加载,那么,执行a.load()时:其各模块中的值为:

a.count = 2,a.parentModUris = [];a._waitings = {a: false, b:false};

b.count = 0,b.parentModUris = [a],b._waitings = {};

c.count = 0,c.parentModUris = [a],c._waitings = {};

a依赖于b,c,则a的_waitings存储的就是为b和c两个键b,c的parentModUris属性都为[a],因为他们两个都被a所依赖。

假设当b加载完执行load时,因为b没有依赖,则b.count为0,则执行b.loadEnd方法,方法内部会根据parentModUris获取到a模块,发现a模块中的_waitings[b] 为false,则把_waitings[b]设置为true,将a模块的count减一,然后判断count是否为0,这时候a减1后为1不为0所以,不做任何操作

然后当c加载完执行load时,重复上述步骤,不过当a再减1后为0,会执行a.loadEnd(),这样当我们处于依赖树最底层的模块加载完时,会逐级向上通知,直到通知到主模块这里。然后执行主模块的callback方法。

那么最后一个问题:如何解决重复加载和循环依赖,那就要依靠模块的生命周期了

重复加载好办,在load方法中判断如果依赖的模块的status小于FATCH,才加载此模块的fatch方法如果模块已经处于LOADEND状态,则表明此模块已经加载好了,不需要加载了,那么:重新设置_waitings,并且count 减1。如果依赖模块还处于LOAD中,那么会有一个问题:你需要知道此模块是属于重复加载还是循环依赖?

比如

a=>[b, c]

b=>[d]

c=>[e, b]

a加载b,c依赖,b加载完后进入LOAD,假设d很大,则b处于LOAD状态,而c加载完后进入LOAD,发现依赖b,而b的状态为LOAD,此时,上诉情况就是是处于重复加载了。按理说这种情况就可以不用管的,因为你通过parentModUris和_waitings属性注入了你需要依赖这个模块的信息,当那个模块加载好了后,会自动找到你这个模块的。

但是,看下面的循环依赖:

a=>[b]

b=>[c]

c=>[a]

也就是a依赖b,b依赖c,c又依赖a,这种情况我们按照代码逻辑梳理一下:

a依赖b,加载b成功后,b发现自己依赖c,而c中又依赖了a,因为按照代码逻辑,当一个模块的依赖没有加载完时,模块是无法进入LOADEND状态,那么如果不处理,会导致所有循环模块一直处于LOAD状态,主模块也无法执行。所以需要区分当依赖模块处于LOAD状态时,他到底是进入了循环依赖还是产生了重复加载。

在模块中,我引入了一个isLoop属性,用于记录此模块是否产生了循环依赖,并且调用seajs.isLoopRequire方法来判断是否此模块是否产生了循环依赖。

如何判断?如果我这个模块产生了循环依赖,那么我这个模块所有的 子孙 依赖中,一定会出现我这个模块,就通过这个来判断(需要用到递归,代码省略...)。这样就能区分出当依赖模块的状态为LOAD时,到底是循环依赖还是重复加载问题了,然后进行相应的操作。

 

至此,大致就算完成了一个在浏览器上遵循CommonJS规范的模块加载器的实现。当然,其中seajs中的比如路径解析,生命周期钩子函数,config配置等等,都没有实现,对了,路径解析是照抄的,不是自己实现的。实现的仅仅是模块的加载这一部分。

重点是seajs源码中是如何实现?上述思路是老师在课堂上教给我们的思路,而seajs源码中和上诉的实现方式有何不同呢?我们一起来瞅瞅:

在我研究了seajs的源码后,发现其实seajs中我认为最大的不同在于:模块的加载完毕后的通知和对重复加载和循环依赖的处理上面。我们如何通知父模块,提醒他们我加载好了呢?看下面例子:

main=>[a]=>[b]

main依赖a,a依赖b,b没有依赖

我们的思路是:main加载a,发现a依赖于b,b因为没有依赖,在b加载完毕后,通知a,a发现仅仅依赖的b加载完毕了,则a也属于加载完毕了,然后再通知main,main又发现自己仅有的依赖a也加载完毕了,那么就可以执行主逻辑代码了。也就是,我们在模块加载完毕后,会从依赖的最后一层一层往上传递消息。而原始的seajs源码中又是怎么做的呢?(就不说代码了,直接说原理)

他是一种类似分发的原理:

比如main主模块引入了a模块,main是否算加载好了是不是只需要看a模块是否加载好了?

而a又引入了b,c,那么a是否算加载好了,是不是就看b,c是否加载好了?

也就是说a依赖b,c,那么只要b,c加载好了,a是不是就算是加载好了呢?

那么:main模块是否ok,取决于a,而a是否ok,取决于b,c,那么是否就是说:main模块是否ok,则取决于b,c是否ok?

那main模块的加载成功的判断就直接由最底层的模块决定。而不需要中间模块进行确定,例子:

main=>[a]

a=>[b, c]

b=>[e, f]

main是主模块,他引入了a模块

a模块引入了b,c模块

b模块引入了e,f模块

main加载a,当a模块ok,则main模块ok

a依赖于b,c,当b,c模块ok,则a模块ok,则main模块ok

而b,c模块中,b依赖于e,f,当e,f模块ok,则b模块ok,b模块ok,以及c模块ok,则a模块ok,a模块ok,则main模块ok

所以:总结下来就是当e,f,c模块ok时,main模块ok。

这就是原版seajs库所使用的如何判断模块加载完毕的原理。他通过一种类似分发的想法,把main这个主模块的可执行判断由层层模块的反馈变为最底层依赖模块的反馈,忽略了中间的依赖模块,降低了复杂度。那他是如何解决重复加载和循环依赖的呢?

main的主模块会有一个history属性,用于这个记录main主模块加载过的子孙依赖模块。如果一个模块已经存在history的记录中,或者,他的状态大于等于LOADEND了,那么就不会管这个模块了,因为表明此模块要么已经加载ok了,要么之前已经执行过一次了。那么,不管是遇到重复依赖还是循环加载,都可以很好的解决。比如:

a=>[b, c]

b=>[d]

c=>[e, b]

main依赖于[b,c],b依赖于d,则

main依赖[d,c],而c依赖于e,b(注意:这里d已经在main的依赖判断列表中了,也可以说history属性中已经有d模块了)

如果b已经在history中了,或者加载完毕了,为LOADEND了,那么可以忽略这个b了,因为就算你再进行依赖分析,发现b依赖于d,d也已经在依赖列表中了,所以没必要再执行一遍。这样就不会重复加载,也不会循环依赖。(具体实现就不写了,看seajs源码中的pass方法即可)

总结:

解决问题的思路的不同,解决同样问题的难易程度也不一样,在解决循环依赖和重复加载上面,seajs的解决方法通过一种巧妙的方式简单的解决了这些问题,而代码也更加简洁,值得我们深入的研究学习。当然,除了核心的模块加载之外,seajs还有路径解析,依赖分析,config配置等等可以研究的东西,相信,在你研究这些东西之后,一定会有所收获。

附上自己模仿seajs编写的模块加载实现的源码,供大家学习交流:https://download.csdn.net/download/qq_33024515/10860416

猜你喜欢

转载自blog.csdn.net/qq_33024515/article/details/85053370