代码拆分

随着 AJAX 应用的开发,其 JavaScript 部分往往会越来越大。最终,代码本身通常会大到足以使仅仅下载和安装它就显著增加应用的启动时间。

为了帮助解决这个问题,GWT 提供了 Dead-for-now (DFN) 代码拆分。本文讨论了 DFN 代码拆分是什么,如何在应用中开始使用它,以及如何改进使用它的应用。

  1. 局限性
  2. 如何使用它
  3. 代码拆分开发工具
  4. 指定初始加载顺序
  5. 片段合并
  6. 常见编码模式

局限性

代码拆分仅在某些链接器中受支持。默认的 iframe 链接器受支持,但跨站点链接器尚不支持。如果您已将您的应用更改为使用非默认链接器,请检查该链接器是否支持代码拆分。

如何使用它

要拆分代码,只需在您希望程序能够暂停下载更多代码的地方插入对 GWT.runAsync 方法的调用。这些位置称为拆分点

调用 GWT.runAsync 就像调用注册任何其他事件处理程序一样。唯一的区别是所处理的事件有些不同寻常。它不是鼠标点击事件或按键事件,而是指执行继续所需的代码已下载。

例如,以下是如何使用 GWT 的初始、未拆分的 Hello 示例。

public class Hello implements EntryPoint {
  public void onModuleLoad() {
    Button b = new Button("Click me", new ClickHandler() {
      public void onClick(ClickEvent event) {
        Window.alert("Hello, AJAX");
      }
    });

    RootPanel.get().add(b);
  }
}

假设您想将 Window.alert 调用拆分到单独的代码下载中。以下代码实现了这一点。

public class Hello implements EntryPoint {
  public void onModuleLoad() {
    Button b = new Button("Click me", new ClickHandler() {
      public void onClick(ClickEvent event) {
        GWT.runAsync(new RunAsyncCallback() {
          public void onFailure(Throwable caught) {
            Window.alert("Code download failed");
          }

          public void onSuccess() {
            Window.alert("Hello, AJAX");
          }
        });
      }
    });

    RootPanel.get().add(b);
  }
}

在以前调用 Window.alert 的地方,现在有一个对 GWT.runAsync 的调用。GWT.runAsync 的参数是一个回调对象,一旦必要的代码下载完成,该对象将被调用。就像 GUI 事件的事件处理程序一样,runAsync 回调通常是一个匿名内部类。

该类必须实现 RunAsyncCallback,这是一个声明了两个方法的接口。第一个方法是 onFailure,如果任何代码下载失败,则调用该方法。第二个方法是 onSuccess,如果代码成功到达,则调用该方法。在本例中,onSuccess 方法包含对 Window.alert 的调用。

使用此修改后的版本,最初下载的代码不包括字符串 "Hello, AJAX" 也不包括实现 Window.alert 所需的任何代码。一旦按钮被点击,就会到达对 GWT.runAsync 的调用,然后该代码将开始下载。假设它成功下载,onSuccess 方法将被调用;由于必要的代码已下载,因此该调用将成功。如果下载代码失败,则将调用 onFailure

要查看编译的差异,请尝试编译这两个版本并检查输出。第一个版本将生成 cache.html 文件,其中都包含字符串 "Hello, AJAX"。因此,当应用启动时,该字符串将立即下载。然而,第二个版本不会在 cache.html 文件中包含此字符串。相反,此字符串将位于 deferredjs 目录下的 cache.js 文件中。在第二个版本中,该字符串直到到达对 runAsync 的调用时才会加载。

这个字符串对于代码大小来说不是什么大问题。实际上,runAsync 运行时支持的开销可能会超过节省。但是,您不限于拆分单个字符串字面量。您可以将任意代码放在 runAsync 拆分点之后,这可能会导致应用的初始下载大小得到极大的改进。

代码拆分开发工具

现在您已经了解了 GWT 提供的基本代码拆分机制。当您第一次尝试拆分自己的代码时,您可能无法像您希望的那样拆分出很多代码。您会尝试拆分某个主要子系统,但会发现某个无法通过拆分点访问的子系统中存在一个无关的引用。该引用足以将子系统的很大一部分拉入初始下载中。

由于存在此挑战,有效的代码拆分需要迭代。您必须尝试一种方法,看看它是否有效,然后进行修改使其更好地工作。本节介绍了 GWT 为迭代以实现更好的代码拆分提供的几个工具。


图 1:代码拆分生成的片段

代码拆分的结果

在继续之前,了解代码拆分器将您的代码分成哪些片段非常重要。这样,您就可以检查拆分情况并努力改进它。图 1 显示了这些片段的示意图以及它们可以加载的顺序。

一个非常重要的片段是初始下载。对于 iframe 链接器,它被发出为一个以 cache.html 结尾的文件名。当应用启动时,初始下载片段会被加载。此片段包含运行应用所需的所有代码,但不包括任何拆分点之后的代码。当您开始改进代码拆分时,您应该首先尝试减少初始下载片段的大小。减少此片段会导致应用快速启动。

除了此初始片段之外,还会生成许多其他代码片段。对于 iframe 链接器,它们位于名为 deferredjs 的目录下,并且它们的文件名都以 cache.js 结尾。程序中的每个拆分点都将有一个关联的代码片段。此外,还有一个用于与任何特定拆分点无关的代码的剩余代码片段。在图 1 中,剩余片段为编号 6。

与拆分点关联的代码片段有两种类型。最常见的是排他片段。排他片段包含仅在激活该拆分点后才需要的代码。在图 1 中,拆分点 1、3 和 5 都有一个排他片段。不太常见的是,拆分点会得到一个初始片段。如果拆分点是初始加载顺序的一部分,就会发生这种情况,如下所述。在图 1 中,初始加载顺序是拆分点 2 然后是拆分点 4。

与排他片段不同,初始片段不依赖于剩余片段中的任何内容,因此它可以在剩余片段之前加载。但是,初始片段只能在其在初始加载顺序中的指定位置加载;排他片段的好处是它们可以按任何顺序加载。

编译报告

现在您知道了 GWT 通常如何拆分代码,您会想知道它如何拆分您的代码。有几个工具可以做到这一点,它们都包含在编译报告中。

要获得您的应用的编译报告,只需使用 -compileReport 选项编译您的应用。然后,您的应用应该有一个名为 compileReport 的输出目录。在该目录中打开 index.html 以查看您的应用的编译报告。

总体大小

在编译报告中首先要查看的是应用的总体大小细分。编译报告通过四种不同的方式细分您的应用大小:按 Java 包、按代码类型、按字面量类型(对于与字面量关联的代码)以及按字符串类型(对于与字符串字面量关联的代码)。

通过查看这些总体大小,您可以了解哪些代码部分值得在拆分时更多地关注。对于这个问题,您可能会看到某些内容比应有的要大;在这种情况下,您可能会在该部分工作并缩小应用的总预拆分大小。

片段细分

由于您正在进行代码拆分,因此您接下来需要查看应用的拆分方式。点击任何代码子集以查看该片段中代码的大小细分。总程序选项描述了程序中的所有代码。其他选项都对应于各个代码片段。

依赖关系

在某个时候,您会尝试将某些内容从初始下载片段中移出,但 GWT 编译器会将其保留在那里。有时您可以快速找出原因,但有时却一点也不明显。找出原因的方法是查看编译报告中报告的依赖关系。

最常见的例子是您希望某些内容被排除在初始下载之外,但它却被包含在内。要找出原因,请通过初始下载代码子集浏览到该项目。单击该项目后,您可以查看从应用的主入口点开始的一系列依赖关系。这就是 GWT 认为该项目必须位于初始下载中的依赖关系链。尝试重新排列代码以断开该链中的一个链接。

一个不太常见的例子是,您期望某个项目是某个分割点的独占项目,但实际上它只包含在剩余片段中。在这种情况下,请通过总程序代码子集浏览到该项目。然后您将看到一个页面,描述该项目的代码最终在哪里。如果该项目不是任何分割点的独占项目,那么将显示所有分割点的列表。如果点击其中任何一个,您将看到不包含所选分割点的项目的依赖关系链。要获取特定分割点的独占项目,请选择一个分割点,单击它,然后断开出现的依赖关系链中的链接。

指定初始加载顺序

默认情况下,每个分割点都会获得一个独占片段,而不是一个初始片段。这为您的应用程序提供了最大灵活性,可以根据需要到达分割点的顺序。但是,这意味着第一个到达的分割点必须支付显著的延迟,因为它必须等待剩余片段加载完毕,才能加载其自己的代码。

如果您知道应用程序中哪个分割点会先到达,可以通过指定初始加载顺序来提高应用程序的性能。为此,您需要为您的runAsync调用命名,然后在您的模块文件中指定这些名称的列表。

要为runAsync调用命名,请将类字面量作为对runAsync的调用的第一个参数添加,如下所示

GWT.runAsync(SomeClass.class, new RunAsyncCallback() {
    // ... callback class's body ...
  }

此第一个参数必须是类字面量,并且它被忽略,除了用作调用的名称。可以使用任何类字面量。常见的做法是使用调用出现的封闭类。

命名调用后,可以使用以下类似行指定初始加载顺序

<extend-configuration-property name="compiler.splitpoint.initial.sequence"
    value="com.yourcompany.yourprogram.SomeClass"/>

该行的value部分指定一个分割点。它被解释为一个完全限定的类名,必须与在一个runAsync调用中使用的字面量匹配。

对于某些应用程序,您不仅知道第一个到达的分割点,还知道第二个,甚至可能知道第三个。您可以通过在配置属性中添加更多行来继续扩展初始加载顺序。例如,以下是指定三个分割点的初始加载顺序的模块代码。

<extend-configuration-property name="compiler.splitpoint.initial.sequence"
    value="com.yourcompany.yourprogram.SomeClass"/>
  <extend-configuration-property name="compiler.splitpoint.initial.sequence"
    value="com.yourcompany.yourprogram.AnotherClassClass"/>
  <extend-configuration-property name="compiler.splitpoint.initial.sequence"
    value="com.yourcompany.yourprogram.YetAnotherClass"/>

指定初始加载顺序的缺点是,如果分割点以与指定顺序不同的顺序到达,那么在运行代码之前将会有比以前更大的延迟。例如,如果初始序列中的第三个分割点实际上是第一个到达的,那么该分割点的代码将不会加载,直到前两个分割点的代码加载完毕。更糟糕的是,如果某个非初始分割点实际上是第一个到达的,那么整个初始加载序列的所有代码以及剩余片段都必须加载完毕,才能加载请求的分割点的代码。因此,如果分割点在运行时可能以不同的顺序到达,请在将任何内容放入初始加载序列之前仔细考虑。

片段合并

在大规模应用程序中,如果大量使用代码分割,剩余片段可能会变得太大。有一种称为片段合并的优化可以帮助解决这个问题。请参阅GWT 编译器中的片段合并

常见的编码模式

GWT 的代码分割是新的,因此使用它的最佳惯例和模式仍在起步阶段。即便如此,这里还是有一些看起来很有希望的编码模式。请将其记在您的编码工具箱中。

异步提供程序

您经常会将代码的一部分视为它自己的连贯功能模块,并且您希望该功能与 GWT 独占片段相关联。这样,它的代码将不会下载,直到它第一次需要时,但一旦该下载完成,整个模块将可用。

一个有助于实现此目标的编码模式是将一个类与模块关联起来,然后确保模块中的所有代码只能通过调用该类的实例方法来访问。然后,您可以安排程序中该类的唯一实例在runAsync内。

总体模式如下所示。

public class Module {
  // public APIs
  public doSomething() { /* ... */ }
  public somethingElse() { /*  ... */ }

  // the module instance; instantiate it behind a runAsync
  private static Module instance = null;

  // A callback for using the module instance once it's loaded
  public interface ModuleClient {
    void onSuccess(Module instance);
    void onUnavailable();
  }

  /**
   *  Access the module's instance.  The callback
   *  runs asynchronously, once the necessary
   *  code has downloaded.
   */
  public static void createAsync(final ModuleClient client) {
    GWT.runAsync(new RunAsyncCallback() {
      public void onFailure(Throwable err) {
        client.onUnavailable();
      }

      public void onSuccess() {
        if (instance == null) {
          instance = new Module();
        }
        client.onSuccess(instance);
      }
    });
  }
}

无论何时从可能在模块之前加载的代码中访问模块,请通过静态 Module.createAsync 方法进行访问。该方法是一个异步提供程序:它提供 Module 的实例,但这可能需要一些时间。

使用说明:对于肯定在模块之后加载的任何代码,请将模块的实例存储在某个地方以方便使用。然后,可以通过该实例直接访问,而不会影响代码分割。

预取

GWT 的代码分割器没有对预取进行任何特殊支持。除了剩余片段之外,代码在第一次被请求时下载。即便如此,您也可以安排自己的应用程序在您选择的位置显式预取代码。如果您知道应用程序中可能很少有网络活动的时刻,您可能希望安排预取代码。这样,一旦代码真正需要,它就会可用。

强制预取的方法是简单地以其回调实际上不做任何事的方式调用runAsync。当应用程序稍后真正调用该runAsync时,其代码将可用。调用runAsync使其什么也不做的具体方法将取决于具体情况。也就是说,一个常见的通用技术是扩展在调用runAsync周围已在范围内的方法参数的含义。如果该参数为 null,则runAsync回调提前退出,不做任何事。

例如,假设您正在实现一个在线地址簿。您可能在显示有关该联系人的信息之前有一个分割点。一个可预取的方式来包装该代码如下

public void showContact(final String contactId) {
  GWT.runAsync(new RunAsyncCallback() {
      public void onFailure(Throwable caught) {
        cb.onFailure(caught);
      }

      public void onSuccess() {
        if (contactId == null) {
          // do nothing: just a prefetch
          return;
        }

        // Show contact contactId...
      }
  });
}

在这里,如果showContact被调用并带有一个实际的联系 ID,那么回调会显示有关该联系人的信息。但是,如果它被调用并带有一个null,那么相同的代码将被下载,但回调实际上不会做任何事。