ClojureScript

增强代码拆分和加载

2017 年 7 月 10 日
David Nolen

这是抢先预览系列的第一篇文章。

随着客户端应用程序规模的增长,优化加载逻辑屏幕的时间变得越来越重要。网络请求应该尽量减少,同时加载的代码应该限制在绝对必要的范围,以生成一个正常工作的屏幕。虽然像 Webpack 这样的工具在 JavaScript 主流中普及了这种优化技术,但 Google Closure Compiler 和 Library 在 Google Closure Modules 的形式下多年来一直支持相同的优化策略。

Google Closure Modules 还提供了一些相对于 Webpack 或 Rollup 这样的工具的独特优势,我们将在本文的技术部分进行介绍。简而言之,我们以最佳方式将所有源代码分配给模块,然后 Google Closure Compiler 使用死代码消除(树形抖动)和跨模块代码移动来生成真正最佳的拆分。

虽然 ClojureScript 一直以来都提供了与该功能的基本集成,但下一个版本将提供对代码拆分和这些拆分的异步加载的极大增强和全面支持。

术语

如果您熟悉 Webpack 术语,在以下描述中请注意,此处的 **模块** 指的是 **代码拆分** 或 **块**。

**入口点** 指的是表示应用程序逻辑入口点的源文件(登录、用户、管理员等)。

增强代码拆分

不再需要手动优化源代码的模块分配。所有源代码将根据应用程序的依赖关系图以最佳方式分配给模块。如果您有一个包含大量手动分配的模块,您现在应该删除这些分配。如果您使用的是命名空间通配符匹配,现在也不再需要它了。有关我们如何将输入分配给特定模块的详细信息,请参阅下面的技术描述。

具体来说,以下现在是一种反模式

{:modules
  {:vendor {:output-to "..."
            :entries '#{cljsjs.react reagent.* re-frame.*}}
   :main   {:output-to "..."
            :entries '#{myapp.core}
            :depends-on [:vendor]}}

以前您必须手动将源代码(在本例中为 re-frame)及其依赖项固定到模块。现在只需要

{:modules
  {:vendor {:output-to "..."
            :entries '#{re-frame.core}
   :main   {:output-to "..."
            :entries '#{myapp.core}
            :depends-on [:vendor]}}

另一个重大增强是,:modules 现在在所有优化设置下都能正常工作。通过在所有编译模式下统一 :modules 行为,我们消除了开发和生产之间构建配置的一些偶然复杂性。

cljs.loader

模块拆分的异步加载现在通过引入 cljs.loader 命名空间进行了标准化。如果应用程序中的任何入口点需要由于用户操作而调用另一个模块的加载,您现在可以使用 cljs.loader

cljs.loader 提供了一个共享的 Google Closure ModuleManager 单例,它会自动初始化为您的 :modules 图,而不管优化级别如何。

以下是一个简单的 cljs.loader 功能示例

(ns views.user
 (:require [cljs.loader :as loader]
           [goog.dom :as gdom]
           [goog.events :as events])
 (:import [goog.events EventType]))

(events/listen (gdom/getElement "admin") EventType.CLICK
  (fn [e]
    (loader/load :admin
      (fn [e]
        ((resolve 'views.admin/init!))))))

(loader/set-loaded! :user)

请注意,此示例显示了如何在不使编译器抱怨此代码拆分中不存在的功能的情况下跨模块边界调用。这得益于最近将静态 resolve 包含到标准库中。

有关增强 :modules 功能的完整演练,请参阅新指南

技术描述

以下重点介绍了增强模块功能的一些有趣的技术细节。

模块分配

本节简要描述了用于自动将每个源文件分配给模块的算法。

假设一个简化的模块描述,例如

{:modules {:module-a {:entries '#{foo.core}}
           :module-b {:entries '#{bar.core}}}

这将转换为包含隐式基本模块 :cljs-base 的模块描述。

{:modules {:cljs-base {:entries []}
           :module-a  {:entries '#{foo.core}
                       :depends-on [:cljs-base]}
           :module-b  {:entries '#{bar.core}
                       :depends-on [:cljs-base]}}

然后我们将计算图中每个模块的深度

{:modules {:cljs-base {:entries [] :depth 0}
           :module-a  {:entries '#{foo.core} :depth 1
                       :depends-on [:cljs-base]}
           :module-b  {:entries '#{bar.core} :depth 1
                       :depends-on [:cljs-base]}}

然后,我们使用它来计算从所有依赖的输入到一组可能的模块分配的映射。例如,我们找到 foo.core 的所有依赖项,并假设它们将进入 :module-a - 甚至 cljs.core,标准库。

但当然 :module-b 也会将 cljs.core 分配给自己。所以 cljs.core 模块分配为 [:module-a :module-b]。但是我们只能选择一个。为了选择,我们首先找到所有公共父模块。找到后,我们选择具有最大 :depth 值的模块。

最后,任何孤儿都将被分配给 :cljs-base

熟悉 Webpack 的读者会注意到,这种方法将拆分和拆分加载视为两个独立的关注点。因此,拆分定义不需要编辑源代码或引入额外的插件。

跨模块代码移动

自动模块分配将代码向上推,以生成符合用户期望的代码拆分。但是,如果我们只是这样,我们将错过一个巨大的机会。除了死代码消除之外,Google Closure Complier 还使用另一种有用的优化 - **跨模块代码移动**。没有副作用的单个程序值(包括函数和方法)可以向下移动模块图。

像 Clojure 这样的函数式编程语言非常适合这种优化,而 ClojureScript 编译器在很多情况下会小心地生成代码以利用此功能。

在实践中,这意味着如果 :cljs-base 中的某个函数及其依赖项仅在 :module-a 中使用,它们将全部移回 :module-a

结论

虽然 Google 在 2010 年出版的 Closure: The Definitive Guide 中记录了这些功能,但我们认为它们仍然代表了最先进的技术。请在下一个版本中尝试这些增强功能!