理解模块加载器 — require.js

create on in module_loader with 0 comment and 518 view

接触前端模块化有一段时间了,从开始的AMD,CMD规范,到vue,angular等前端框架,以及webpack,rollup等打包工具,都是与模块化息息相关的,可以说,它们都是前端模块化的产物.

虽然一直以来在项目中使用它们,但感觉对其了解的还不够深入,更多的只是停留在怎么使用以及怎么配置上。
所以,为了对vue框架或者webpack等工具有更进一步的了解,打算从对模块化的实现开始出发。

小试牛刀

要掌握模块化的来龙去脉,需要从最简单的开始,require.js是一个最纯粹的模块加载器,它有利于我对模块化概念的理解,所以从它开始入手。

模块加载器

模块,即是将各个功能进行划分,进行相应的封装,使各功能进行解构,提高复用率,提升开发效率,便于后期的扩展和维护。
关于模块加载器,其实可以将其分成两部分,一个是模块,另一个是加载。“模块”即是功能,它可以是一个代码块,也可以是一个文件,每个模块有自己的职责;“加载”则是加载模块并跟踪各模块的状态,处理模块之间的依赖关系,最后执行模块。

总的来说,模块加载器主要做的几件事是寻找模块下载模块处理模块依赖解析模块

实现

require.js是一个异步模块加载器,是AMD规范的实现。
它的几个核心思想是:异步加载依赖预执行Commonjs Module

大致的思想有了,那么该怎么进行开始第一步的实现呢?

根据老办法,首先从API出发,去分析require.js源码的执行顺序。可是这样做之后,发现还是没能明白整体的一个架构。因为require.js源码的编码形式在很多地方是面向过程的,所以整理起来不太容易。经过我一段时间的摸索,后来留意到,require.js源码中定义了两种原型,一个叫Module,另一个叫Context。
从面向对象的角度来看,可以知道,第一个需要定义的是模块类(Module),用于实例化模块,第二个是Context类,用于实例化模块加载的环境。以下是我定义的类的一些数据和方法

  • Context类:
config: {} // 配置 name: "" // Context 名称 globDeps: [] // 已定义的依赖 depQueue: [] // 依赖下载队列 depMap: {} // 下载中的依赖关系 configure() // 配置加载 init() // 初始化 require() // 实例化模块 completeLoad() // 模块加载完成 checkCycle() // 检查依赖循环 onScriptLoad() // 文件下载完成
  • Module类
name: '', // 模块名称 src: '', // 模块路径 deps: [], // 模块的依赖列表 beDeps: [], // 被依赖的模块 depsCount: 0, // 未加载的依赖数量 status: 1, // 依赖加载状态 cb: () => {}, // 加载成功的回调 error: () => {}, // 加载失败的回调 result: undefined, // 模块的回调返回值 init() // 初始化 loadDepModule() // 下载依赖 fetch() // 下载模块 analyzeDep() // 分析依赖 onStatus() // 监听状态 exec() // 执行回调

顺着API的思路,开始进行实现,其中遇到有几个较为棘手的问题。

问题

requrie和define的区别是什么?

使用过require.js的人都知道,require和define有着极其相似的接口功能,那么问题来了,既然它们这么相似,为什么还要区分require和define呢?只使用require或者define不就可以了吗?它们两个的主要区别是什么?
面对这些问题,我先从require.js文档中寻找requrie和define在使用上的差异,然后分析了它们源码的不同,最后得到结论。

在阐明这两个方法的区别前,需要说明一下系统模块自定义模块

系统模块: require.js根据自身实现的需要而定义的模块,它们通常作为自定义模块的父级模块,模块名称为 @r0,@r1…
自定义模块: 用户定义的功能模块,模块名称使用自定义或者文件名

  • require方法
    require方法它有两种作用:

    1. 获取已经加载的自定义模块的返回值
    2. 定义一个系统模块, 它可以将自定义模块作为自身的依赖,这些依赖加载完成后,系统模块执行自身的回调。
  • define方法
    用于定义自定义模块,用户可自定义模块名称,该模块可以引用其它自定义模块作为依赖。自定义模块一定有父级模块(自定义或系统模块)。

define定义的模块如何知道该模块的名称(路径)

在require.js中,若没有自命名,那么默认条件下,路径即是模块的名称,它作为该模块的唯一标识。
实例:

// index.html require(['a.js'], function(a){ console.log(a); })
// a.js define(function(){ return 'im a.js'; })

问题是,当a.js执行define方法时,怎么知道自己的名称是index.html中定义的"a.js"呢?

通过观察require.js,可以发现,它在引用a.js的script标签上,拥有一个属性data-requiremodule,该属性其实就是用来存储a.js模块的名称的。
所以,每当define方法执行时,先创建一个未命名的模块,将该模块加入到队列depQueue中,当define方法执行完毕后,js标签会触发load事件,在事件回调函数中,通过event对象获取标签信息,将标签中的属性data-requiremodule值作为队列depQueue中未命名模块的名称。核心代码如下:

const define = (name, deps, cb) => { ... // 将模块信息加入队列 context.depQueue.push({name, deps, cb}) }
// Context onScriptLoad (evt) { if (evt.type === 'load') { const data = getScriptData(evt) this.completeLoad(data.name) } } completeLoad (moduleName) { let args, found while (this.depQueue.length > 0) { args = this.depQueue.shift() if (args.name === null) { args.name = moduleName found = true } ... this.require(args) } ... }

如果处理模块之间的依赖关系?当模块加载完成后,怎么通知其它模块?

每个模块实例,都会存储自己的依赖模块和被依赖的模块,每当依赖模块发生变动时,其数据会作出更新。

  • 依赖模块 deps

当执行require或者define函数后,deps直接设为参数中的值。

  • 被依赖模块 beDeps

require或define函数执行时,依赖的beDeps数据要追加当前模块

  • 加载中的依赖数量 depCount

使用计数法来监听模块的所有依赖是否都加载完成

这里采取了订阅者模式,监听模块的status(状态)和depCount(正在加载的依赖数量)。

当当前模块status值为2(加载完成)时,通知依赖该模块的模块,使其depCount减少。当当前模块的depCount值为0时,获取其依赖的result(返回值),作为参数传入回调函数中,回调执行完毕后,将当前模块的status设置为2(加载完成)。

使用Object.defineProperties很容易做到这一点。

Object.defineProperties(this, { status: { get () { return status }, set (newStatus) { status = newStatus if (newStatus === 2) { // 此模块的依赖已加载完成,执行回调函数 this.result = this.exec(...arg) // 通知依赖此模块的模块,此模块已完成加载 each(this.beDeps, beDepName => { context.globDeps[beDepName].depsCount-- }) } }, }, })
Object.defineProperties(this, { depsCount: { configurable: true, get () { return depsCount }, set (newDepsCount) { depsCount = newDepsCount if (depsCount === 0) { this.status = 2 } }, }, })

处理模块之间循环依赖

在进行开发时,我们应当避免循环依赖,因为循环依赖有时会带来意想不到的错误。然而,我们并不能完全保证循环依赖不会产生,所以如何处理循环依赖是模块加载器必须要做的事情。
require.js的做法是,若产生了循环依赖,为了保证模块继续执行下去,则未加载的模块的返回值为 undefined。

可能你已经注意到第三个问题的实现存在一个问题,当两个模块相互依赖后,模块的status和depCount会造成死锁,即一个模块的status在等待另一个模块的depCount变为0,另一个模块的depCount在等这个模块的status变为2。所以,在检查依赖的时候,需要作一些调整。

若发现当前模块的依赖处于未加载状态,则查询这个依赖是否与自己产生了依赖循环,若发现依赖循环,则先将其标记为已完成加载。

// 检查循环依赖 checkCycle (child, parent) { const deps = context.globDeps[child].deps return deps.some(dep => { if (dep === parent) { return true } // 若child的依赖已经加载但状态为1,则需要检查该依赖模块的依赖包含parent值 if (context.globDeps[dep] && context.globDeps[dep].status === 1) { return this.checkCycle(deps, parent) } }) }
// 下载依赖 loadDepModule () { this.deps.forEach(depSrc => { ... // 此依赖的模块已经创建 if (context.globDeps[depSrc]) { const moduleExist = context.globDeps[depSrc] if (moduleExist.status === 1) { ... // 深度查询依赖,检查是否存在依赖循环 const checkRes = context.checkCycle(depSrc, this.name) if (checkRes) { console.log(`出现了依赖循环...`) this.depsCount-- } } return } ... }) }

这样处理过后,即和requrie.js实现一样了。
若要获取循环依赖模块的值,则可以在会回调中使用require再次引用:

// b.js (a.js依赖b.js) define(['a.js'], function(a){ console.log(a); // undefined // 再次引入a.js require(['a.js'], function(a){ console.log(a); // 'im a.js' }) })

最后

至此,一个简单的模块加载器最核心的问题已经解决了,完整源码可参考 github funModule

要完善一个模块加载器,其实还有很多工作要做,例如有:事件的兼容性,文件路径处理,CommonJS Module语法(module export…),错误处理,网络监听,第三方库适配…等等。
下文中,将会研究webpack中的模块处理方式,据说,那是一个有趣的模块加载器。

😁😂😃😄😅😆😇😈😉😐😑😒😓😔😕😖😗😘😙😠😡😢😣😤😥😦😧😨😩😰😱😲😳😴😵😶😷😸😹🙀🙁🙂🙃🙄🙅🙆🙇🙈
🙂