第 9 章: 模块化

本章讨论程序的组织过程。在小型程序中,组织很少会成为问题。然而,随着程序的增长,它可能会达到一个尺寸,其结构和解释变得难以跟踪。很容易,这样的程序开始看起来像一碗意大利面,一个无定形的块,其中所有东西似乎都与其他东西相连。

在构建程序时,我们做两件事。我们将它分成更小的部分,称为 模块,每个模块都有特定的作用,我们指定这些部分之间的关系。

第 8 章 中,在开发一个玻璃容器时,我们使用了在 第 6 章 中描述的一些函数。本章还定义了一些与玻璃容器无关的新概念,例如 cloneDictionary 类型。所有这些东西都被杂乱地添加到环境中。将此程序拆分为模块的一种方法是

当一个模块 依赖于另一个模块时,它会使用来自该模块的函数或变量,并且只有在加载了这个模块后才能工作。

最好确保依赖关系永远不会形成循环。循环依赖不仅会造成实际问题(如果模块 AB 相互依赖,应该先加载哪个?),它还会使模块之间的关系不那么直观,并且会导致我之前提到的意大利面的模块化版本。


大多数现代编程语言都内置了某种模块系统。JavaScript 没有。我们又一次不得不自己发明点什么。最明显的方法是从将每个模块放在不同的文件中开始。这使得很清楚哪些代码属于哪个模块。

浏览器在网页的 HTML 中找到带有 src 属性的 <script> 标签时会加载 JavaScript 文件。扩展名 .js 通常用于包含 JavaScript 代码的文件。在控制台中,load 函数提供了加载文件的快捷方式。

load("FunctionalTools.js");

在某些情况下,以错误的顺序给出加载命令会导致错误。如果一个模块试图创建一个 Dictionary 对象,但 Dictionary 模块尚未加载,它将无法找到构造函数,并将失败。

人们会认为这很容易解决。只需在模块文件的顶部放置一些对 load 的调用,以加载它所依赖的所有模块。不幸的是,由于浏览器的运作方式,调用 load 不会立即导致加载给定的文件。该文件将在当前文件执行完成后 之后 加载。这通常为时已晚。

在大多数情况下,实际的解决方案是手动管理依赖关系:将 HTML 文档中的 script 标签按正确顺序放置。


有两种方法可以(部分)自动化依赖关系管理。第一种是保留一个单独的文件,其中包含有关模块之间依赖关系的信息。这可以首先加载,并用于确定加载文件的顺序。第二种方法是不使用 script 标签(load 在内部创建并添加了这样的标签),而是直接获取文件的内容(参见 第 14 章),然后使用 eval 函数执行它。这使得脚本加载变得即时,因此更容易处理。

eval,'evaluate' 的缩写,是一个有趣的函数。您向它提供一个字符串值,它将执行字符串的内容作为 JavaScript 代码。

eval("print(\"I am a string inside a string!\");");

您可以想象 eval 可以用来做一些有趣的事情。代码可以构建新代码并运行它。然而,在大多数情况下,可以用 eval 的创造性使用方法解决的问题也可以用匿名函数的创造性使用方法解决,而后者不太可能导致奇怪的问题。

当在函数内调用 eval 时,所有新变量都将变为该函数的局部变量。因此,当 load 的一个变体在内部使用 eval 时,加载 Dictionary 模块将在 load 函数内部创建一个 Dictionary 构造函数,该构造函数将在函数返回后立即丢失。有办法解决这个问题,但它们相当笨拙。


让我们快速回顾一下依赖关系管理的第一个变体。它需要一个用于依赖关系信息的特殊文件,它可能看起来像这样

var dependencies =
  {"ObjectTools.js": ["FunctionalTools.js"],
   "Dictionary.js":  ["ObjectTools.js"],
   "TestModule.js":  ["FunctionalTools.js", "Dictionary.js"]};

dependencies 对象包含每个依赖于其他文件的属性。属性的值是文件名数组。请注意,我们不能在这里使用 Dictionary 对象,因为我们无法确定 Dictionary 模块是否已加载。因为此对象中的所有属性都将以 ".js" 结尾,所以它们不太可能干扰像 __proto__hasOwnProperty 这样的隐藏属性,普通对象可以正常工作。

依赖关系管理器必须做两件事。首先,它必须确保文件按正确的顺序加载,方法是在加载文件本身之前加载文件的依赖项。其次,它必须确保没有文件被加载两次。加载同一个文件两次可能会导致问题,而且绝对是浪费时间。

var loadedFiles = {};

function require(file) {
  if (dependencies[file]) {
    var files = dependencies[file];
    for (var i = 0; i < files.length; i++)
      require(files[i]);
  }
  if (!loadedFiles[file]) {
    loadedFiles[file] = true;
    load(file);
  }
}

require 函数现在可用于加载文件及其所有依赖项。请注意它如何递归调用自身以处理依赖关系(以及该依赖关系的可能依赖关系)。

require("TestModule.js");
test();

将程序构建为一组不错的、小型模块通常意味着程序将使用许多不同的文件。当为网络编程时,在页面上拥有大量小型 JavaScript 文件往往会使页面加载速度变慢。但这并不一定是个问题。您可以将程序编写并测试为多个小文件,并在将程序“发布”到网络时将它们全部放入一个大的文件中。


就像对象类型一样,模块也有一个接口。在简单的函数集合模块(如 FunctionalTools)中,接口通常包含在模块中定义的所有函数。在其他情况下,模块的接口仅是其内部定义的函数的一小部分。例如,我们来自 第 6 章 的手稿到 HTML 系统只需要一个单一函数 renderFile 的接口。(构建 HTML 的子系统将是一个单独的模块。)

对于仅定义一种类型的对象的模块,例如 Dictionary,对象的接口与模块的接口相同。


在 JavaScript 中,“顶层”变量都存放在一个地方。在浏览器中,这个地方是一个对象,可以在 window 这个名称下找到。这个名称有点奇怪,environmenttop 会更有意义,但由于浏览器将 JavaScript 环境与窗口(或“帧”)相关联,因此有人决定 window 是一个合乎逻辑的名称。

show(window);
show(window.print == print);
show(window.window.window.window.window);

如第三行所示,window 这个名称仅仅是这个环境对象的属性,指向它自身。


当大量代码加载到环境中时,它将使用许多顶层变量名。当代码量超过你真正可以跟踪的范围时,很容易意外地使用一个已经被用于其他用途的名称。这将破坏使用原始值的代码。顶层变量的激增被称为 命名空间污染,它可能在 JavaScript 中是一个相当严重的问题——语言在您重新定义现有变量时不会警告您。

没有办法完全消除这个问题,但可以通过尽可能减少污染来大大减少这个问题。一方面,模块不应该使用顶层变量来存储不属于其外部接口的值。


当然,在模块中根本无法定义任何内部函数和变量是不切实际的。幸运的是,有一个技巧可以解决这个问题。我们将模块的所有代码都写在一个函数中,然后最后将属于模块接口的变量添加到 window 对象中。因为它们是在同一个父函数中创建的,所以模块的所有函数都可以互相看到,但模块外部的代码看不到。

function buildMonthNameModule() {
  var names = ["January", "February", "March", "April",
               "May", "June", "July", "August", "September",
               "October", "November", "December"];
  function getMonthName(number) {
    return names[number];
  }
  function getMonthNumber(name) {
    for (var number = 0; number < names.length; number++) {
      if (names[number] == name)
        return number;
    }
  }

  window.getMonthName = getMonthName;
  window.getMonthNumber = getMonthNumber;
}
buildMonthNameModule();

show(getMonthName(11));

这构建了一个非常简单的模块,用于在月份名称及其编号之间进行转换(如 Date 中使用,其中 1 月为 0)。但请注意,buildMonthNameModule 仍然是一个顶层变量,它不属于模块的接口。此外,我们必须重复三次接口函数的名称。哎哟。


第一个问题可以通过使模块函数匿名并直接调用它来解决。要做到这一点,我们必须在函数值周围添加一对括号,否则 JavaScript 会认为它是一个普通的函数定义,不能直接调用。

第二个问题可以用辅助函数provide解决,它可以接受一个包含要导出到window对象的值的对象。

function provide(values) {
  forEachIn(values, function(name, value) {
    window[name] = value;
  });
}

使用它,我们可以这样写一个模块

(function() {
  var names = ["Sunday", "Monday", "Tuesday", "Wednesday",
               "Thursday", "Friday", "Saturday"];
  provide({
    getDayName: function(number) {
      return names[number];
    },
    getDayNumber: function(name) {
      for (var number = 0; number < names.length; number++) {
        if (names[number] == name)
          return number;
      }
    }
  });
})();

show(getDayNumber("Wednesday"));

我不建议一开始就以这种方式编写模块。当您仍在处理一段代码时,使用我们迄今为止使用的简单方法并把所有内容放在顶级更容易。这样,您可以在浏览器中检查模块的内部值并测试它们。一旦模块或多或少地完成,将其包装在一个函数中并不困难。


在某些情况下,一个模块会导出如此多的变量,以至于将它们全部放在顶级环境中是一个糟糕的主意。在这种情况下,您可以像标准的Math对象一样,将模块表示为一个单一的对象,其属性是它导出的函数和值。例如…

var HTML = {
  tag: function(name, content, properties) {
    return {name: name, properties: properties, content: content};
  },
  link: function(target, text) {
    return HTML.tag("a", [text], {href: target});
  }
  /* ... many more HTML-producing functions ... */
};

当您经常需要此类模块的内容,以至于不断键入HTML变得很麻烦时,您始终可以使用provide将其移入顶级环境。

provide(HTML);
show(link("http://download.oracle.com/docs/cd/E19957-01/816-6408-10/object.htm",
          "This is how objects work."));

您甚至可以将函数和对象方法结合起来,将模块的内部变量放在一个函数中,并让此函数返回一个包含其外部接口的对象。


当向标准原型(例如ArrayObject)添加方法时,会发生类似于命名空间污染的问题。如果两个模块决定向Array.prototype添加一个map方法,您可能会遇到问题。如果这两个版本的map具有完全相同的效果,那么事情将继续运行,但这仅仅是运气而已。


为模块或对象类型设计接口是编程中比较微妙的方面之一。一方面,您不想暴露太多细节。它们只会使用模块时妨碍您。另一方面,您不想过于简单和通用,因为这可能会使模块无法在复杂或特殊的情况下使用。

有时解决方案是提供两个接口,一个详细的“低级”接口用于复杂事物,另一个简单的“高级”接口用于简单情况。第二个接口通常可以使用第一个接口提供的工具非常容易地构建。

在其他情况下,您只需要找到一个正确的想法来作为您接口的基础。将此与我们在第 8 章中看到的各种继承方法进行比较。通过使原型成为中心概念,而不是构造函数,我们设法使某些事情变得更加简单。

不幸的是,学习良好接口设计价值的最佳方法是使用不良接口。一旦您厌倦了它们,您就会想出一种方法来改进它们,并在过程中学到很多东西。不要假设糟糕的接口是“就是这样”。修复它,或者将其包装在一个更好的新接口中(我们将在第 12 章中看到一个示例)。


有些函数需要很多参数。有时这意味着它们设计得很糟糕,并且可以通过将它们拆分为几个更简单的函数来轻松解决。但在其他情况下,没有办法避免它。通常,这些参数中的一些具有合理的“默认”值。例如,我们可以编写另一个扩展版本的range

function range(start, end, stepSize, length) {
  if (stepSize == undefined)
    stepSize = 1;
  if (end == undefined)
    end = start + stepSize * (length - 1);

  var result = [];
  for (; start <= end; start += stepSize)
    result.push(start);
  return result;
}

show(range(0, undefined, 4, 5));

记住哪个参数放在哪里可能会很困难,更不用说每次使用length参数时都要传递undefined作为第二个参数的麻烦。我们可以通过将参数包装在一个对象中,使向此函数传递参数更加全面。

function defaultTo(object, values) {
  forEachIn(values, function(name, value) {
    if (!object.hasOwnProperty(name))
      object[name] = value;
  });
}

function range(args) {
  defaultTo(args, {start: 0, stepSize: 1});
  if (args.end == undefined)
    args.end = args.start + args.stepSize * (args.length - 1);

  var result = [];
  for (; args.start <= args.end; args.start += args.stepSize)
    result.push(args.start);
  return result;
}

show(range({stepSize: 4, length: 5}));

defaultTo函数可用于向对象添加默认值。它将第二个参数的属性复制到第一个参数中,跳过已具有值的属性。


可以在多个程序中使用的模块或模块组通常称为库。对于许多编程语言,都有大量高质量库可用。这意味着程序员不必总是从头开始,这可以使他们更有效率。对于 JavaScript,不幸的是,可用的库数量并不多。

但最近这种情况似乎正在改善。有一些很好的库,其中包含“基本”工具,例如mapclone。其他语言往往将这些明显有用的东西作为内置标准功能提供,但在 JavaScript 中,您必须自己构建一个集合,或者使用库。建议使用库:它工作量更少,库中的代码通常比您自己编写的代码经过了更彻底的测试。

涵盖了这些基础知识,还有(除其他外)“轻量级”库prototypemootoolsjQueryMochiKit。还有一些更大的“框架”可用,它们的作用远不止提供一组基本工具。 YUI(由雅虎提供)和Dojo似乎是该类型中最受欢迎的框架。所有这些都可以免费下载和使用。我个人最喜欢的是 MochiKit,但这主要是一个品味问题。当您认真对待 JavaScript 编程时,快速浏览一下每个框架的文档,了解它们的运作方式和提供的内容是一个好主意。

对于任何非琐碎的 JavaScript 程序,基本工具包几乎是不可或缺的,而工具包又有很多种,这对库编写者来说是一个难题。您要么必须让您的库依赖于其中一个工具包,要么自己编写基本工具并将其包含在库中。第一个选项使库难以被使用不同工具包的人使用,而第二个选项会向库添加大量非必要代码。这种困境可能是 JavaScript 中相对较少高质量、广泛使用的库的原因之一。未来,ECMAScript 的新版本和浏览器的变化可能会使工具包变得不那么必要,从而(部分)解决这个问题。