Seajs 核心机制:模块加载、预加载与构建工具
由于长期使用,笔者对 Sea.js 的源码进行过深入探究。以下是对几个关键问题的理解与解析:
问题1:seajs的require(XXX)是如何实现模块加载的?
代码逻辑较为复杂,源代码的详细梳理放在末尾,这里先概述模块加载的整体流程:
- 从 seajs.use 方法入口,启动 use 所指定模块的加载流程。
- use 到的模块在 mod 缓存中一定不存在。seajs 会创建一个新的 mod 对象,并赋予初始状态。
- 执行 mod.load 方法。
- 经过一系列逻辑后,调用 seajs.request 方法,请求模块文件。模块文件加载完成后,执行 define 方法。
- define 方法分析并提取模块的依赖模块列表,将其保存起来。缓存 factory 但不执行。
- 依赖模块继续被加载,若存在更深层依赖则递归加载,直到所有被依赖的模块均加载完成。
- 所有模块加载完毕后,执行 use 方法中的 callback。
- 模块内部从 callback 开始执行,require 方法在此过程中被触发。
问题2:为什么需要预加载?
可以看到,seajs.use 会等待所有依赖模块加载完成后才执行 callback。也就是说,业务逻辑代码在执行之前,必须事先预加载所有被依赖的模块。为何必须如此?
答案在于逻辑代码中引用其他模块的 require 方法:
var mod = require(id);
这种写法决定了 mod 的获取是一个同步过程。如果模块代码在此之前未被预加载,就只能借助异步回调来实现,那整个 seajs 的执行逻辑将完全不同。异步会使得模块执行顺序难以追踪,逻辑变得难以控制。
问题3:为什么需要构建工具?
没有构建之前,各个依赖模块是单独加载的。这会导致过多的模块请求,严重影响页面加载性能。构建工具的核心目的就是解决模块合并加载的问题。
问题4:构建前后的代码究竟有些什么区别,为什么要这么做?
构建工具具体做了什么?既然其本质是合并代码,那是否只是简单地将几个模块文件拼接成一个文件?
当然不是。如果只是简单合并几个模块到一个文件,会发现该文件根本无法正常执行。问题出在 define 方法的实现机制上。
seajs 推荐定义模块时只传入 factory 参数。回顾 define 内部逻辑:当未传入 id(等同于模块的 url)时,会通过 getCurrentScript() 获取当前正在执行的模块文件的 url 路径,然后将该路径作为键值,与模块一起缓存到 cachedMods。关键在于:seajs 内部的模块缓存机制依赖每个模块的 url 作为缓存键值。require(id) 最终也是通过 url 键在 cachedMods 中找到对应模块。这个键值不能重复、不能出错,否则模块对应关系就会混乱。若将 a、b、c 三个模块简单合并到目标文件 x 后,getCurrentScript() 只能获取到 x 的路径,三个模块的键值将无法区分,执行必然出错。
因此,要合并多个模块,必须为每个模块明确指定 uri。也就是说,define 方法必须传入 id 参数。当 id 传入时,seajs 会将 id 转换为 url 作为缓存的键值。
如果只传 id 和 factory(即 define(id, factory)),那么 deps 为 undefined,define 方法就会执行 parseDependencies(factory.toString()) 提取依赖模块,后续会走解析模块路径、线上单独加载各个模块的逻辑,合并加载就失去了意义。
所以,合并加载时,define 必须正确传入 id、deps、factory 三个参数才能正常工作。
seajs 的 CMD 模块定义方法,提倡在编写模块阶段只传 factory 一个参数,而另外两个参数在后期构建阶段生成。上面解释了为什么这两个参数在构建后是必需的。至于为什么提倡只传 factory?因为人工传入 id 和 deps 极易出错,难以维护,而构建工具可以高效且准确地生成这些参数。
附:对 seajs 主要代码逻辑的理解
说明:源代码版本是 Sea.js 2.3.0
1. define 方法做了些什么
Module.define = function (id, deps, factory) 支持三个参数,id 和 deps 可选,factory 必需。代码通过以下逻辑进行控制:

实际上 deps 是必需的,因为 seajs 必须知道每个模块依赖了什么。所以当 factory 是函数且 deps 未被主动传入时,就需要用 parseDependencies 方法来分析 factory 中的依赖模块:

parseDependencies 的核心是利用一个正则表达式,将函数体中所有 require(XXX) 的 XXX 提取出来,这些就是该模块所依赖的全部模块:

方法本身并不复杂,但正则表达式有一定讲究。分析完 deps 之后,将模块定义存入缓存:

注意,define 方法仅仅用于分析模块、存储模块,并不执行模块。
2. 真正执行模块的是 require


简单来说,require 是根据 id 在 define 定义的模块缓存中找到对应模块,并执行它,获取模块定义返回的方法:

这里有一个极为关键的点:Module.get(require.resolve(id))。require 一个模块时,先要找到该模块,Module.get 正是负责此事的:

如果 cachedMods 中没有找到,就创建一个新的 Module 并缓存:

define 和 require 单看起来不算复杂,真正复杂的是模块加载的逻辑。
3. seajs 的真正入口是 use 方法

通过 use 方法,从 ids 出发触发模块的加载与执行:

可见加载的关键在于 mod.load 方法。load 方法代码较长,主要逻辑是判断 mod 的当前状态是否为已加载或加载中:

在 Module 的初始化函数中,status 默认值为 0:

因此,未加载过的新模块在此处都会执行:mod.status = STATUS.LOADING 并进入后续加载逻辑。接下来获取模块的依赖 urls:

mod.resolve 方法:

Module.resolve 本质上是将相对路径、配置的 path、别名等转换为绝对路径,此处不再贴代码。更新模块加载状态:

加载模块的核心逻辑:

主要是 m.fetch 方法,其中的其他逻辑此处略过:

可以看到 seajs.request 最终会执行模块文件的加载:

当所有依赖模块加载完成后,执行 mod 的 onload 方法:

这里是 mod.onload() 方法:

至此,seajs 的核心逻辑基本都已涵盖。以上是对 Sea.js 核心机制的全面梳理,欢迎交流指正。
