復雜 Web 前端項目的構建工具優化實踐

pqzb5413 8年前發布 | 17K 次閱讀 Grunt 前端技術 gulp

前言:

貼吧作為中國最大規模的 UGC 產品之一,在PC和移動端上承載了數億用戶的訪問。在過去十幾年的運營中,貼吧積累了十分復雜的業務模式。在 Web 前端,一度有超過40名工程師同時開發、提交和上線,為此,貼吧建設了非常復雜和完備的開發體系。但隨著業界技術的不斷進步,貼吧的技術架構也在不斷嘗試和調整,我們在此過程中也不斷遭遇了新的挑戰,相應地也就引出了本文的內容,而它的意義遠遠不限于貼吧這一產品本身。

一、背景

項目構建,或者稱之為 編譯 ,早已經成為了 Web 前端項目在發布過程中的一個必不可少的環節。從最早的 JavaScript 與 CSS 壓縮合并,發展到今天 ES2015、ES2016、Less、Sass 等預處理語言的轉換,構建的壓力越來越大,其流程也越來越復雜,簡單的 Shell、Make、Ant 等單純的任務處理工具早已經不能很好地滿足需求,一個是效率問題,一個是可擴展性問題。因此,業界依托 NodeJS,發展出了多個優秀的開源構建工具,它們有的專注于 Web 前端項目集成化,提供簡單易用的編程接口,有的專注于柔韌性,容易擴展,已經不再僅僅局限于能處理特定的項目類型。

但是,隨著項目復雜度的提升,這些構建工具也會慢慢暴露出一些不足,主要體現在性能和效率上,這會讓原本已經由于復雜的流程導致的慢構建雪上加霜。本文以一個同構 JavaScript 應用為例,來說明典型的大型復雜前端項目構建中遇到的效率瓶頸以及解決問題的一些思路。同構 JavaScript 應用的特點是,同一份 JavaScript 源代碼既需要運行在 NodeJS 服務端,又需要運行在瀏覽器客戶端,同時還要考慮到其它靜態資源如 CSS、圖片和多媒體。

下圖是該項目構建流程的一個簡化版本,可以看到,不同來源的 JavaScript 文件都有兩個構建分支,即 流程分解 ,并且還存在不同源文件的構建 流程合并 的情況。此外,對于不需要任何構建環節的其它(遺留的)文件,比如可能的配置文件、二進制靜態資源文件、自定義文件類型等等,我們要求它們只有拷貝到目標目錄就可以了。以此為例,并考慮到性能和效率問題,我們對構建工具的最低要求包括:

  1. 支持對任意文件的任意數量、任意流程的構建:Web 前端項目的架構變幻無窮,相對應的構建流程可能是任意的,并且無法預測,事實是,我們的前端架構確實多種多樣。如上圖中,不同文件的構建流程已經不再是單純的平行序列圖,而是一個有向的拓撲圖。這就要求構建工具對流程的定制非常靈活,只要是合理的(包括但不限于不含有向環),就應該可以實現。

  2. 支持文件級別的構建增量:構建分為全量構建和增量構建,顧名思義,全量構建是完全從源文件讀取后進行構建,增量構建為僅構建最少的必要文件,是全量構建之后應對部分文件被其它進程修改的策略。相比于全量構建,增量構建在速度上有顯著的優勢,通常用于開發者本地的實時預覽和部署,提升開發效率。增量構建要求盡可能避免不必要的文件構建,同時要求對于可能受影響的所有文件,都必須連帶構建,保證實時性。

  3. 支持遺留文件的提取:構建一般是針對特定類型的文件的,這通過枚舉來實現。在項目中,往往還存在著一些難以枚舉的文件資源,這些資源可能不需要任何處理,只要保證在項目構建之后保留原樣即可,包括文件內容和文件路徑。如何需要對它們也進行構建操作,則不必挨個執行文件選擇,因為這可能非常繁瑣。

下面我們先討論現有開源構建工具在滿足以上需求時的不足之處,再想方設法予以改進。

二、現有方案

Web 前端領域常用的幾款構建工具,包括 Grunt 、 Gulp 、 Webpack 。

  1. Grunt 資歷最深,因此也發展出了非常繁榮的插件生態。它基于 glob 選擇文件集合并執行配置好的構建流程。Grunt 是基于文件構建的,因此在構建中的每一個環節都必須讀取和寫入磁盤文件,這是 Grunt 最為人詬病的地方,因為這意味你要想辦法為每一個構建環節設計文件的輸入輸出目錄,保證不與其它流程發出沖突;再一點就是讀取寫入磁盤文件過于消耗時間,導致 Grunt 在構建大型復雜項目時比較緩慢。在增量構建上,Grunt大多以“任務”為單位,這其中會包含很多不必要文件的構建。除非在必要的環節中手動設計緩存,否則 Grunt 也不顯式支持緩存。此外,Grunt 不能提取遺留文件。

  2. Gulp 與 Grunt 比較類似,定位也近乎相同,與 Grunt 的最大的不同是 Gulp 是基于流( Stream )構建的而不是文件。這個特性節省了對文件的大量磁盤讀寫操作,使得 Gulp 的構建速度有了明顯的提升。更重要的是,Gulp 可以讓構建流程配置起來更清晰簡單,支持流程分解,但不支持流程合并。Gulp 也有與 Grunt 一樣的增量構建粒度的問題,同時也不能提取遺留文件。

  3. Webpack 與其說是構建工具,倒不如說是模塊打包器,官方的說法是叫做 “Module Bundler”。它與 Gulp 和 Grunt 并沒有太多可比性,也不能很好地完成我們的任務。但 Webpack 有兩個能力是值得借鑒的,即 “loader” 中緩存單個文件構建結果的能力,以及對文件之間依賴關系的定制能力。熟悉編寫 Webpack loader 的人可能會對 addDependency 方法比較熟悉,這個方法用于聲明文件之間的依賴關系。這在增量構建時非常有用,例如 a 依賴于 b ,那么如果 b 文件需要重新構建,那么顯然, a 也必須舍棄緩存,重新構建

可見 Grunt、Gulp 和 Webpack 雖然都是 Web 前端構建常用的優秀開源工具,但在應用于復雜項目時,仍存在一些可優化之處。構建工具本身的能力、效率和性能提升將讓任意架構更加容易實現,不再讓大膽的設計受限于工具。

三、關鍵改進

基于同構 JavaScript 應用的實際構建需要,針對我們剛剛分析的現存構建工具的不足,我們實施了從以下幾點出發的改進。為便于語義表述,本文使用 TypeScript 語言編寫代碼示例,但在實際實現時一般直接由 JavaScript 語言來承擔所有任務。

1. 節點緩存

項目構建的本質是對文件變換操作的有序排列集合。變換一般是針對文件內容的,如空白壓縮、語法轉換,于是,我們將變換抽象為一個函數 transform ,它的輸入是文件的的內容,可以是二進制的 Buffer ,也可以是文本的字符串(String),輸出則是轉換后新的文件內容。用接口語法表示即如下面的代碼塊。將多個 Transformer 串聯起來就是常規的構建過程。

interface Transformer {
  transform (content: string): string;
}

顯然,對于相同 content 的值,如果 transform 的返回值一直保持相同,那么第一次以后的操作都是不必要的,因為無論重復多少遍返回都是一樣。如果熟悉 redux ,那么一定對其中的 reducer 概念印象深刻,它是一個“純函數(pure function)”,無論什么時候,同樣的輸入總是有同樣的輸出。該特性也是 Transformer 能夠實現緩存的基礎,也就是說,以 content 為索引,只要索引不變,緩存就可以一直有效,構建速度也會大幅度提升。在實際的操作中,可以取 content 的 md5 值作為實際的索引,于是一個典型的可緩存 Transformer 可以是這樣的:

class FooTransformer implements Transformer {
  transform (content: string) {
    let key = md5(content);
    if(cache.has(key)) {
      return cache.get(key);
    } else {
      const newContent = this._transform(content);
      key = md5(newContent);
      cache.set(key, newContent);
      return newContent;
    }
  }
}

然而并非所有 transform 方法都有一個入參,例如 Transformer 可能需要一個 options 構造參數,它極有可能導致在相同的 content 上產生不一致的輸出。在這情況下,不建議再使用緩存機制,如果一定要使用的話,那么 key 值的算法將會開始變得復雜起來,并且容易導致出錯。所以,明確你的 transform 到底做了什么,再使用緩存機制提升構建速度。

另外, transform 方法的入參很可能不是僅僅一個 content ,而是一個集合,這種情況下,文件名也要用來區分它們,你需要的不是一個 transform ,而是一個 transformAll ,入參則是 File 的集合 FileCollection ,而 File 對象,至少必須包括 filename 和 content 兩個屬。 Transformer 接口的定義則為:

interface File {
  filename: string;
  content: any;
}

interface FileCollection {
  [index: number]: File;
}

interface Transformer {
  transform (file: File): File;
  transformAll (files: FileCollection): FileCollection;
}

針對這兩種類型的 Transformer ,我們以一個屬性來做區分:

interface Transformer {
  isTorrential(): boolean;
}

在 isTorrential() 方法返回真時, transformAll 方法將代替 transform 被調用。顯然,由于入參變得非常多,設置緩存也變得困難重重,索性直接拋棄不要。需要指出的是, transformAll 的內部可以調用 transform 。

最后, transform 和 transformAll 可能都不是同步的,返回一個 Promise 比返回一個 String 或 File 對象更合適。

總結一下,本節分析了項目構建的實質和基本單元,在基本單元 Tranformer 中設置緩存可以有限提升構建的速度。但要清楚地知道什么時候該使用緩存,什么時候不該使用,始終讓構建行為符合預期。

2. 拓撲路徑

前面提到, 項目構建是由 Transformer 的有序集合構成的 Transformer 可以分解也可以合并,這非常像水流,因此我們形象地稱之為 Stream 。每一個 Stream 對象內部維護最多一個 Transformer 實例成員,并以 File 對象的集合作為 Stream 的有效載荷,一般地,它們往往在某種意義上是同類型的文件。

interface Stream {
  tranformer: Transformer;
}

由于流與流之間的關系構成了一個有向的拓撲圖,因此 Stream 對象需要維護與其它 Stream 之間的關系,添加 upriversdownrivers 成員以實現,分別稱為直屬“ 上游流 ”和直屬“ 下游流 ”:

interface Stream {
  uprivers: StreamCollection;
  downrivers: StreamCollection;
}

interface StreamCollection {
  [index: number]: Stream;
  push(stream: Stream):number;
}

以下圖為例,圖中序號 ① 的 downrivers 為 [②,⑤], ② 的 uprivers 為 [①,④],依此類推。

downrivers 數量大于1的流稱為“ 分解流 ”, uprivers 數量大于1的流稱為“ 合并流 ”。上圖中,① 為分解流,② 為合并流。顧名思義,分解流是一個以上構建流程具有相同上游公共部分的簡單表述,但合并流卻并不總是代表一個以上的構建流程具有相同的下游公共部分,更重要的是, 合并流的輸入是其所有上游流的輸出 。對于合并流來講,它的輸入文件極有可能有著不同的來源。舉一個現實的例子,輸入 browserify 的文件極有可能來自 node_modules 目錄內,但更多來自于你在 src 目錄編寫的源代碼。顯然,這兩種來源的文件很容易有著不同的前期構建流程,比如源代碼是由 ES2015 編寫的,但 node_moduels 則為 ES5 語法。

在構筑流的拓撲圖的時候,我們可以定義一個 connect 方法來建立它們之間的聯系:

interface Stream {
  connect(downriver: Stream): Stream;
}

class FooStream implements Stream {
  connect (downriver: Stream) {
    this.downrivers.push(downriver);
    downriver.uprivers.push(this);
    return downriver;
  }
}

那么建設上圖中的拓撲的代碼就可以是:

s1.connect(s2).connect(s6).connect(s3)
s1.connect(s5).connect(s4).connect(s2);

實際的流拓撲圖大多數是二維的,不過達到三維的復雜度也是合理的,請看下圖。先不論此圖是否反應了項目構建的一個真實流程,至少它表達了構建流程可能達到的復雜度。

下面我們就以上圖為例探討流拓撲圖的正確工作方式。首先,基于性能和功能考量,我們的目標包括,在完整的一次構建流程中:

  • 包括分解流在內的所有流,都只能運行一次。這意味著,同一個文件不會被傳入 Streamtransformer 成員一次以上;
  • 合并流的所有上游流運行完畢后,合并流才能開始運行,原因剛剛已經說明,它需要上游流的所有輸出 加和 同時作為輸入

我們把 Stream 的入口 API 方法稱為 flow

interface Stream {
  flow (files: FileCollection): Promise;
}

上一節提到, transform/transfromAll 方法的返回值應該是 Promise ,那么 flow 的返回值也同樣是 Promise

每個流在執行 flow 操作時,其實質都是在調用其內部的 transformer 成員,根據 isTorrential() 返回值的不同來決定調用它的 transform 或者 transformAll 方法。從這里可以看到,其實 Transformer 內部的緩存也可以移至 Stream 中存儲,在 Transformer 會更靈活一些。由于構建行為的任意性, Transformer 將是非常重要的擴展點,注意保持它的輕量性。

class FooStream implements Stream {
  public flow (files: FileCollection) {
    if (this.transformer.isTorrential()) {
      return this.transformer.transformAll(files);
    } else {
      return Promise.all(files.map(file => this.transformer.transform(file)));
    }
  }
}

但一個流完成后,它可能還要把自身的輸出傳遞給下游流(注意:也可以不傳遞,但仍然要通知)。因此,在 flow 中,最后還需要依次通知每一個下游流,通知的接口不妨稱之為 notify

interface Stream {
  notify (files: FileCollection): Promise;
}

注意各個下游流是 依次notify 的,如果能保證下游流之間沒有先后的依賴關系,那么它們確實可以并行,這種依賴關系可能不僅僅體現在拓撲圖上,因為拓撲圖只表達了文件傳遞方向的關系。除此之外,你還可以實現其它維度的依賴,為此,下游流以串行的方式運行會更保險一些。

注意,傳遞給下游流的數據一定是拷貝,避免下游流修改上游流的緩存。

當一個流的所有上游流都完成后,它才開始運行,所以, notify 十分有必要在適當的時機激活自身 flow

class FooStream implements Stream {
  public notify (files: FileCollection) {
    this.files.push(...files);
    if (this.isUpriversAllReady) {
      return this.flow(this.files);
    }
  }
}

這樣,我們能夠保證合并流在正確的時機去運行,也就實現了這類特殊的構建任務。回過頭來看上面的那張拓撲圖,箭頭代表了依賴關系,節點的數字就代表了執行的順序。讀者可以自行思考為什么是這樣的順序。需要注意的是,根據依賴關系定義的先后,最終的執行順序也是不同的,只要分支沒有依賴關系,下游流的順序先后就無所謂了。

總結一下,本節我們討論的是如何定義和實現 API 以支持任意的構建流程。構建的靈活性始終是本文討論的重點,因為你無法想象半年甚至三個月后你的項目會復雜到什么程度,構建系統的建設是一個成本很大的投入,并且會經常隨著項目架構的變化而變化,保證充分的可擴展性,會讓你面對快速變化時伸縮自如。相比于 Grunt 和 Gulp,我們期望以一種更高效和更直觀的方式定義自己的構建流程。

3. 依賴圖譜

一個項目中所包含的文件類型可能非常多,特別對于 Web 前端而言,HTML、JavaScript、CSS 和各種圖片是必不可少的,近年來,Less、Sass、JSX、ES2015 等前處理語法越來越多,每一種都需要特殊的構建流程。而這些流程的順序顯然不是任意的。在配置 Grunt、Gulp 任務時,我們會下意識地先去處理圖片等二進制資源,再處理 CSS 和 JavaScript,最后處理 HTML。這是因為不同種類的文件之間有著微妙的依賴關系:現代的前端構建中,對于靜態資源,都會采用文件名加時間戳的方式來規避緩存的影響,這個過程一般是該類型文件的之后一個構建步驟,如果要引用它的最終路徑,就必須等到它完全構建完畢。眼光放寬廣的話,依賴關系遠遠不止引用路徑這一種形式,如果需要的話,你可以把一些構建的結果信息存儲于內存中,作為其它類型文件構建的一種輸入。總之,不要將文件之間的依賴關系固化,HTML 依賴 JavaScript 不止有 標簽這一種形式,CSS 引用圖片也不止 background(-image) 這一種。

想達到支持任意的構建流程,定制文件之間的依賴關系是必不可少的,就像 Webpack 的 addDependency 方法一樣,你可以以任意方式實現這樣一個 API:

addDependency('a.css', 'd.png');

有了它,你可以自定義多種依賴關系,舉個例子,在任意文件中,使用 __uri() 的語法來引用其它靜態資源文件的最終線上路徑,這不是標準的引用關系,但也許你用得著它。

對于項目初次的全量構建,文件之間的依賴只要人工保證其順序就可以了,但對于之后的 watch 增量構建,依賴關系就變得極為重要。試想你是如何定義 Grunt 或 Gulp 的 watch 動作的,是不是需要配置監聽的目錄以及要執行的任務?后面的任務由于無法得知到底是哪些文件被修改了,因而不得不都要重新構建一遍。相比之下,Webpack 的增量構建就要迅速得多,幾乎是瞬時的,因為它掌握了哪些文件不需要再構建的信息—— 被修改的文件以及依賴它的所有其它文件都需要重新構建,其它都不需要再構建 。這也是一個比較抽象的拓撲圖,幸運地是,這個算法非常簡單,你只需要遞歸地收集各個拓撲圖上的節點就可以了。

總結一下,文件之間精確的依賴關系是實現高速增量構建的基石,它將增量的維度控制在了文件級別。實時修改和預覽是開發過程中的一個重要特性,尤其對于 Web 前端。如果設計合理的話,全量構建的邏輯也可以弱化為一種增量構建,這樣看來,全量和增量并沒有本質區別。

4. 全量搜索

我們希望構建工具能夠覆蓋到項目中的所有文件,而不僅僅是 Grunt 和 Gulp 中任務覆蓋的那些。這樣做有什么好處呢?答案是你的構建邏輯可以不必維護對這些文件資源細節的處理,同時還能輕易實現它們的發布。舉個例子,有一些靜態資源,包括各種格式的音頻、視頻和圖片,以及其它自定義的特殊格式,它們不需要經過任何的構建處理,只需要發布到特定的目錄下,以供 Web 瀏覽者訪問。在 Grunt 和 Gulp 中,你需要時刻注意你的選擇器是否選中了它們,否則就會被遺漏。當文件的路徑和類型不可枚舉時,維護構建的選擇器就成了一項負擔,也是一項風險,你不能保證所有文件都被選中了,特別是引用了第三方的組件,比如 node_modules

gulp.src('assets/*.{mp4,rmvb,mp3,avi,srt,png,jpg,bmp,ico,webp,zip,rar,gz,pptx,docx,xlsx}')

為了解決這個問題,我們將項目目錄下的所有文件都找出來,包括 node_modulesbower_componentsjspm_packages 等依賴,一個都不要落下。由于項目構建還是要基于文件選擇器的,畢竟同一類的資源才能走同一個構建流程。我們針對每一個選擇器來創建一個文件集合,凡是匹配該選擇器的文件都放到這個集合中:

interface MatchPair {
  files: FileCollection;
  selector: any;
  headStream: Stream;
}

我們這里使用的選擇器語法是 multimatch ,因此支持數組或字符串。

現在, 創建一個新的文件集合,把所有不匹配任何選擇器的文件放到里面 。這是一個特殊的集合,專用來收集“遺留的”文件。不論是全量還得增量,構建工具都應該始終維護這些 N+1 個文件集合的正確性。 headStream 是一個空的流,它沒有任何 transformer 成員,它的作用僅僅是銜接其它流。于是,項目構建的實質就變成了 files 通過各自配對的 headStream 。如果你愿意對遺留文件也進行構建,那么也是可行的。

總結一下,本節討論了一種選擇遺留文件的方法。這項功能對于 Grunt 和 Gulp 都不具備,但卻是工程實踐中非常貼心的,如果你不必關心遺留文件,大可棄之不管,如果需要拷貝等操作,將非常地方便。

四、附加優化與問題

1.磁盤讀取優化

顯然,讀取磁盤文件是構建中比較慢的一環,特別是全量搜索并都讀取的話。在初次的構建中,對被選中文件的讀取無可厚非,不過,要知道一個文件可以被多個選擇器命中,這可能導致一個文件被多次讀取。完全優化它其實很簡單,向所有讀取文件的流傳遞相同的 File 對象(直接從 MatchPairfiles 中取出即可),只要 content 屬性不為 null ,則不必再次讀取。當監聽到該文件被修改后,清除 content ,最先匹配這個文件的讀取流就會再次讀取磁盤,跟在它后面的則仍然不必再次讀取,也保證了文件內容的實時性。

2.內存拷貝優化

從上面的設計可以看到,我們沒有使用 NodeJS 的 Stream 對象來在各個 Transformer 之間傳遞數據(Gulp 是這樣做的),不過使用的方式是非常類似的,因為畢竟緩存是以 content 為索引的,即使是流也需要等到全部接收完后才開始處理,這和直接傳遞全部內容沒什么分別。我們在 Transformer 上設置的緩存,可能帶來很多的文件拷貝,如果文件內容過大,則內存會被快速消耗。為了優化此問題,我們設置了一個閾值 f ,文件體積超過 f ,緩存則由內存寫入臨時文件。當然,這是基于文件讀寫要比 transform 快的基礎上的。

3.依賴關系的泄露

使用 addDependency() 來聲明依賴是不收斂的,也就是說你可以在任意位置(主要是 Transformer 中)調用。我們沒有提供一個 clearDependency() 方法是因為它的行為可能是片面的——不能預測其它位置是否有相反的行為。設想下面的路徑:

  • 第一次構建,聲明 a.css 依賴于 a.png 和 b.png;
  • a.css 被修改,移除對 b.png 的依賴,第二次構建,聲明 a.css 依賴于 a.png;
  • b.png 被修改,a.css 作為依賴方,第三次構建,聲明 a.css 依賴于 a.png

可見,以后 b.png 的所有變化,都會觸發 a.css 的重新構建,雖然 a.css已經不再依賴 b.png 了,我們稱這是一種依賴關系泄露,就像內存泄露一樣。事實上,它也確實造成了輕微的內存泄露。依賴關系泄露的后果就是,帶來了不必要的重構建,如果條件合適的話,它可以像多米諾骨牌一樣蔓延開來,造成重構風暴。

如果執意要規避這個問題的話,也不是不可能,但我們并沒有采取任何手段,因為構建工具的運行周期都不會很長,沒有太大必要為了它增加那么復雜的邏輯,何況它并不影響構建的正確性。

五、總結

以上就是我們為了解決當前和以后復雜的前端構建而進行的努力,同構 JavaScript 只是一個應用場景,理論上,我們可以很好地應對任何構建任務,包括 Webpack 的所有功能。與 Grunt、Gulp 類似的是,上面的設計和改進都是非常底層的,如果想真正應用于一個具體的項目架構,可能需要比較多的配置。從這一點上看,這更像是一個構建引擎。附圖是該引擎的核心運行架構,我們利用它建設了多個 Web 前端項目,它們在架構上有著非常大的不同,特別是在初期設計時,架構十分不穩定,該引擎很好地支撐了各種差異化的構建任務。針對該引擎的不足和缺陷,我們仍在探索和改進。Grunt 、 Gulp 和 Webpack 依然是功能非常強大的工具,我們從它們身上借鑒了很多,也有許多不謀而合之處。每種工具都有其適合的場景,最重要的不是比較它們的優劣,而是找到最適合的那一個,如果必要,則可以進行適當的改進。

 

來自:http://www.infoq.com/cn/articles/constructe-tool-optimize-for-complex-web-front-end-projects

 

 本文由用戶 pqzb5413 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!