ClojureScript

增強的程式碼分割與載入

2017 年 7 月 10 日
David Nolen

這是「搶先看」系列文章的第一篇。

隨著用戶端應用程式規模的擴大,最佳化載入邏輯畫面的時間變得非常重要。網路請求應盡可能減少,同時載入的程式碼應限制為產生可運作畫面絕對必要的程式碼。雖然像 Webpack 這樣的工具已在 JavaScript 主流中普及了這種最佳化技術,但 Google Closure Compiler 和 Library 多年來一直以 Google Closure 模組的形式支援相同的最佳化策略。

Google Closure 模組還提供了一些優於 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 Compiler 還採用了另一個有用的最佳化方式 - 跨模組程式碼移動。不含副作用的個別程式值(包括函式和方法)可以向下移動回模組圖形。

像 Clojure 這樣的函數式程式設計語言非常適合這種最佳化,而 ClojureScript 編譯器在許多情況下都會仔細產生程式碼,以利用這種功能。

實際上,這表示如果 :cljs-base 中存在的某些函式及其依賴項僅在 :module-a 中使用,則它們將全部移回 :module-a

結論

雖然 Google 在 2010 年出版的 Closure: The Definitive Guide 中記錄了這些功能,但我們認為它們仍然代表了最先進的技術。請在下一個版本中嘗試這些增強功能!