利用 RequireJS 进行依赖项管理
在大多数软件开发语言中,应用程序往往由数十、数百乃至数千个文件构建而成。 但是,在 JavaScript 中,在少量文件(每个文件都包含数百或数千行代码)内开发通常是司空见惯的事情。 无论是业界专家还是初学者,掌握这些文件的范围和错综复杂性都是一项艰巨的任务。 确保代码保持干净和模块化甚至成为一个难以完成的任务。 那么,为什么这种大型、复杂的 JavaScript 文件应用如此广泛? 最普遍提及的原因是:
- JavaScript 开发一直以来都遵循这种方式发展。
- 加载多份 JavaScript 文件需要多次 HTTP 请求,从而导致加载时间延长。
- JavaScript 依赖项管理难以实施。
虽然第一个理由确实存在,但仅延续过去的老套做法最终必然会走向灭亡。 第二点是一项合法要求,并且与现在相比,第三点在过去通常是一个重大的问题。 幸运的是,有大量绝佳的信息库和标准能够帮助开发人员克服上述两大问题。 事实上,克服这些困难已然成为当务之急。 JavaScript 应用程序的可扩展性及开发工程师的理性判断均有赖于此。 本文对 RequireJS 进行了简要介绍,并阐述了用户如何利用它来帮助管理 JavaScript 项目。
通常情况下,首先需要将 JavaScript 代码从一个大文件拆分为多个小文件。 即使仅创建少数文件,也必须开始对文件彼此之间的依赖性进行跟踪,从而确保以正确的顺序加载它们。 这将很快演变成为一个长长的清单,因而后期很容易陷入困境。例如:
<script src="script3.js"></script> <script src="script1.js"></script> <script src="script13.js"></script> <script src="script7.js"></script> <script src="script6.js"></script> <script src="script12.js"></script> <script src="script4.js"></script> <script src="script11.js"></script> <script src="script5.js"></script> <script src="script9.js"></script> <script src="script8.js"></script> <script src="script10.js"></script> <script src="script2.js"></script>
您可以假设其中存在某些依赖项,但究竟存在哪些依赖项呢? 如何找出依赖项? 如果我刚刚加入团队,不会想远远地拿一根十英尺长的杆子触摸这些顺序。 有没有更好的办法呢? 有没有可能每个文件都能声明自身的依赖项,这样您就不必费力维护这份脆弱的总列表? 某种加载器有没有可能认识各份文件(参见图 1)声明的关系并按需加载这些关系,以及这些依赖项之间的依赖项等?
让我们共同怀念一下过去的时光。 几年前,刚刚开始流行在服务器上使用 JavaScript。 服务器端库和 JavaScript 引擎开始广为部署,但却并未开发标准的有效 API 开展协作及定义依赖项。 让库彼此协作需要达成妥协,并且很显然,如果 JavaScript 规模不断扩大,还需要一些通用 API。 2009 年 1 月,Kevin Dangoor 撰写了一篇博客文章,名为“服务器端 JavaScript 的需求”,简要阐述了服务器端 JavaScript 的需求。 作为满足这些需求的一种方法,他创建了一个名为 ServerJS 的 Google 群,以便志同道合的人们相互协作。 很快,该群体意识到他们的许多目标并不一定要局限于服务器,于是将群名改为 CommonJS。
CommonJS 工作的其中一个标准是 模块。模块是一个自包含的代码段(这么含糊?),这种说法定义了模块本身,那么它们还可以选择依赖哪些其他模块以便保证正常运行呢? 模块 B 可以调用模块 G 和模块 M,而模块 G 则可以调用模块 D 和模块 W。通过设定模块标准,依赖项管理变得更加简单。 它并非为保持某种必须维持秩序的隐含主列表,各模块只为定义自身依赖项。 该映射可用来确定所需的资源,以及必须采用的加载顺序。
AMD
模块概念对于服务器端开发至关重要,因为它解决了如何根据依赖项定义加载模块这个问题,但浏览器端 JavaScript 开发人员有些羡慕。 为什么这么有效的机制只能局限于服务器? 当然,浏览器需要异步而不是同步加载模块,但这并不意味着模块概念和依赖项定义无法适用。异步模块定义或 AMD 均具有与生俱来的使命。 他们从服务器端获取模块和依赖项定义 API,并将其应用至浏览器的异步模式。
RequireJS
那么,RequireJS 需要对此做些什么呢? 尽管您可以通过 AMD 定义模块及其依赖项,但却需要智能工具获取这种依赖项映射,加载模块及按顺序执行模块。 这就是 RequireJS 的作用所在。 RequireJS 与 AMD 都具有开源特性,并且都是由 James Burke精心策划的流行函数。
此时此刻,我们直接跳到某些代码,以帮助您巩固其中一些概念。 模块几乎总是在单一文件内定义。 同样,单一文件仅包含单一的模块定义。 在文件中心定义模块非常简单,如下列代码所示。 下列定义在名为 book.js 的文件内完成。
define({ title: "My Sister's Keeper", publisher: "Atria" });
此代码使用 define()
定义了书籍模块,它是一种由 RequireJS 显示的 AMD 函数。 当您调用它时,相当于从本质上说, "将我传递的内容注册为模块" 。 在这种情况下,该模块是一个以大括号开始和结束的书籍对象。 默认情况下,RequireJS 假设模块名称是一个文件路径,该文件路径遵循除扩展名外的下列基本 URL(后面将进行介绍)。 由于文件命名为 book.js,其中 book 是默认的模块名称。 当其他代码请求 RequireJS 获取 book 模块时,RequireJS 将返回上文定义的对象。 现在,您可以在名为 bookshelf.js 的新文件中创建 bookshelf 模块,以便了解如何在其内部请求书籍模块。
define([ 'book' ], function(book) { return { listBook: function() { alert(book.title); } }; });
请注意,这与书籍模块有点不同。 书籍模块不包含依赖项,因此要简单得多。 此代码将大量书架依赖项传递至define()
。 在这种情况下,唯一的依赖项是书籍。 第二个参数是回调函数。 如果尚未使用 RequireJS 注册该书籍模块(换句话说,book.js 尚未加载至该应用程序),RequireJS 将从服务器获取它。
加载了 book.js 且注册了书籍模块后,RequireJS 将执行回调函数并传入该模块(之前定义的书籍对象)作为参数。 从技术角度而言,参数名称并不重要。 您可以只是简单地命名为 function(a1337Book)
,也可以使用任何想用的名称。 在这种情况下,使参数名称与模块名称相符很有意义,因为保持一致有助于理解。
最后,从此回调函数返回的任何对象均将会通过 RequireJS 注册为书架模块。 在这种情况下,这是指带有listBook()
方法的对象,刚刚我们就是用它在书籍标题上调用 alert()
。 当加载多个模块时,RequireJS 会尽量提高效率。 例如,如果列出多个依赖项,则 RequireJS 将并行加载所有这些依赖项。
为开始使用 RequireJS 及新模块,请创建一个基本 HTML 页面。 下面就是这个页面的外观:
<!DOCTYPE html> <html> <head> <title>RequireJS Example</title> <script data-main="js/main" src="js/libs/require.js"></script> </head> <body/> </html>
毫不夸张地说,通过使用 JavaScript 操纵正文内容及加载 HTML 模板,无需向 HTML 文件添加任何其他元素即可构建大型 单页面应用程序 ,您也可以使用 RequireJS 完成此操作。 现在,只需注意 data-main
属性。 这将为 RequireJS 指出引导文件的所在位置 js 目录(假设主目录的扩展名为 js)。下面是一个 main.js 文件示例:
require([ 'bookshelf' ], function(bookshelf) { bookshelf.listBook(); });
由于您指定此文件作为 HTML 文件的 data-main
,RequireJS 将尽快加载并立即执行该文件。
您将会发现它与之前的模块定义具有某些相似之处,但此代码不调用 define()
,而是调用require()
。define()
函数(当定义依赖项时至少需要)完成以下三个步骤:
- 加载指定的依赖项
- 调用回调函数
- 将回调函数返回的值注册为模块
require()
函数仅负责完成步骤 1 和 2。main.js 文件就是引导文件。 我不需要使用 RequireJS 注册 main 模块,因为不会针对它调用任何其他模块作为依赖项。
main.js 文件列出书架模块作为依赖项。 假设尚未使用 RequireJS 注册书架模块,它将会加载 bookshelf.js。当加载了 bookshelf.js 后,将会发现该书架将书籍模块列为依赖项。 如果尚未注册书籍模块,则会加载 book.js。操作完成后,书籍和书架均会使用 RequireJS 将其各自的对象注册为模块,执行 main.js 回调函数,书架从中通过。 这时,您就可以利用书架执行想要执行的一切操作。 如果需要,您还可以列出多个模块依赖项,它们会在使用 RequireJS 加载和注册后立即传入回调函数。
我在前面曾经提到过基本 URL。 默认情况下,基本 URL 是指目录中包含引导文件的 URL。 在上面的示例中,main.js 与 book.js 和 bookshelf.js 一起位于 js 目录。这意味着基本 URL 是 /js/。 您可以不把所有 js 文件直接放在 js 目录下,可以将 book.js 和 bookshelf.js 移至 /js/model/。 main.js 文件需要更新,过程如下所示:
require([ 'model/bookshelf' ], function(bookshelf) { bookshelf.listBook(); });
现在主目录已经知道 bookshelf.js 的正确位置。同样,尽管书籍和书架位于同一目录下,但 bookshelf.js 还是会将书籍依赖项列为 model/book
。 这样 RequireJS 配置就完成了。 通常,我会在引导文件顶部执行配置。 main.js 可能如下所示:
require.config({ baseUrl: '/another/path', paths: { 'myModule': 'dirA/dirB/dirC/dirD/myModule' 'templates': '../templates', 'text': 'libs/text', } }); require([ 'bookshelf' ], function(bookshelf) { bookshelf.listBook(); });
在这种情况下,我已经将基本 URL 手动更改为另一种完全不同的样式。 就我个人而言,我从不需要执行这项配置,但它的存在证明了还有一个配置选项可供使用。 有关详细信息,请参阅 RequireJS 配置选项文档。
上方的代码也同样演示了路径的配置方法。 path 是特定目录或模块的别名。 在这种情况下,当列出整个模块的依赖项时,我们不必再键入 dirA/dirB/dirC/dirD/myModule
,现在只需键入 myModule
。 我还创建了一个路径,用于访问与 js 目录平级的 templates 目录。 最后,我已经建立一个路径,以便在整个模块中更加轻松地访问 RequireJS 文本插件。 虽然本文并未提及,但您确实可以使用此文本插件轻松地加载 HTML 模板。有关详细信息,请参阅 文本插件文档。
到目前为止,您所看到的都是对象实例,书架是对象,书籍也是对象。 事实上,模块通常就是构造函数(类似于经典语言中的类)。 在本例中,您可能希望将书籍模块创建为构造函数。 这样,书架即可创建并存储多个书籍对象。 此时,书籍模块如下所示:
define(function() { var Book = function(title, publisher) { this.title = title; this.publisher = publisher; }; return Book; });
在这里请注意,您正在将函数传入 define()
而不是对象。 当您传递函数而非普通对象时,RequireJS 将执行该函数,届时该函数返回的所有元素均将成为模块。 这里将返回书籍构造函数。 现在书架模块显示如下:
define([ 'book' ], function(Book) { var books = [ new Book('A Tale of Two Cities', 'Chapman & Hall'), new Book('The Good Earth', 'John Day') ]; return { // Notice I’ve changed listBook() to listBooks() now that // I am dealing with multiple books. listBooks: function() { for (var i = 0, ii = books.length; i < ii; i++) { alert(books[i].title); } } }; });
假设您已经将代码适当分解成粒度模块,则可能会具有数百个文件,除非进行某种优化,否则您的代码将提出数以百计的 HTTP 请求。 幸运的是,您可以利用 RequireJS 优化器解决这一问题。
一般来说,您会设置优化器在部署流程期间运行。 RequireJS 优化器将通过扫描模块及其依赖项代码发现应用程序内使用了哪些文件。 然后缩小文件(缩短代码以保证真实文件较小),并将它们连接起来(将它们粉碎再一起组成一个文件)。 最后,输出一个包含所有应用程序代码的 JavaScript 文件。 当您开展部署时,这个单一文件将代替 main.js 发挥作用。
当用户加载 index.html 时,它将反过来加载 RequireJS,然后加载 main.js 文件。 此时,main.js 文件将不仅包含常规 main.js 引导代码,而且还包含应用程序其余部分的所有小型级联代码。 这时,文件中的所有模块均将使用 RequireJS 进行注册。 当主目录开始探寻依赖项时,这些依赖项也开始寻找依赖项,RequireJS 将会意识到所有必要模块均已加载,再次放弃从服务器加载它们。
当然,该优化器自身也包含一组选项。 您可以深入几个不同的文件优化应用程序,呈现应用程序的各个部分而不是单个大文件。 您还可以使用多个不同的压缩库,从串联中排除文件,甚至缩小 CSS。
本文涉及大量基础内容,但依然还有很多依赖项管理知识需要学习。 RequireJS 网站是一个不错的起点