到目前為止我已經很多次提到我習慣用 Gulp 來進行自動化建置的工作了。Gulp 非常適合像我這樣有高度自訂需求的進階開發者來完全控制我的自動化流程,從編譯、minify、變種建置、部署上傳等等的動作我都有很多不同的 Gulp script 來負責。底下我針對 BPS 中遇到的幾種自動化需求來介紹我有在使用的各種 Gulp 外掛和技巧(其中關於建置 Service Worker 和建置自訂圖示的部份可以參考這個系列文章的第二篇第三篇)。

TypeScript 建置

TypeScript 內建的編譯機制是利用其 tsc 命令列工具來進行,而它也有輸出 source map 1 的功能,可以滿足最陽春的需求,但是我常遇到的需求還包括:

  1. 我希望每次我在執行偵錯之前都會自動編譯,但是同時希望如果原始碼沒有更新的話可以直接跳過這個編譯動作。
  2. 在編譯完成之後常常還要在所有程式碼外面包一層 template,例如 IIFE 或是 UMD 等等。
  3. 然後常常又需要順便用 Terser 2 來 minify 程式碼。

要做到這些需求,我們會用到的 Gulp 外掛有:

  • gulp-if-any-newer:對於傳入的一堆檔案,如果裡面有任何一個比目標(資料夾或檔案)更新,就讓全部的檔案通過,否則就全部不通過;用這個就可以做到「沒有更新就不執行編譯」3
  • gulp-sourcemaps: 當我要用 Gulp 來編譯 TypeScript 但仍然需要輸出 source map 的時候,就需要用這個來產生。特別是如果我還經過了 gulp-terser 等其它外掛的時候,它仍然能夠生成出正確的 source map(前提是該外掛必須支援 gulp-sourcemap)。它也可以用來把一個 .js 檔案拷貝到另一處而更新 source map 的相對路徑。
  • gulp-terser:顧名思義就是 Gulp 用的 Terser 外掛。
  • gulp-typescript:執行編譯 TypeScript 的工作。
  • gulp-wrap 和 gulp-wrapjs:用來把程式碼外面包 template 用的。前者只是簡單的文字插入,所以並不支援 gulp-sourcemap 但是可以使用最新的 ECMAScript 語法;後者則是有支援 gulp-sourcemap,但是卻因為很久沒有更新 4、而有一些新的 ECMAScript 語法會讓它出錯。因為這樣的差異,我兩者都會看情況使用。

CSS 建置

雖然我整個網站自訂的 CSS 樣式表加起來規模仍然遠不及 Bootstrap 的十分之一,但是全部寫在一個檔案中也是不太好管理,所以我在原始碼裡面是分類放在若干個檔案裡面、並且會適度加上註解,最後要建置的時候再全部串連成一個檔案然後加以 minify。這會用到:

  • gulp-concat:把輸入的若干檔案串接成一個檔案。
  • gulp-clean-css:針對 CSS 的 minify 外掛。

在 BPS 裡面我並沒有編譯 SASS 的需求,但是對應的外掛 gulp-sass 也是存在的。

Vue.js 建置

針對 Vue 方面,我的需求就又更是特殊了。

目前為止我尚未升級至 Vue 3,因為我暫時沒有空閒仔細研究各種 Vue 2 外掛轉移至 Vue 3 之後的相容性如何、以及怎麼建置比較好等等的問題,所以 BPS 目前仍然是用 Vue 2 來負責 UI 的部份。為了讓 Vue 2 也能夠順利跟 TypeScript 整合,我採用了 vue-property-decorator(下簡稱 VPD)這個模組配合 Vetur 這個 VS Code 模組來達到理想的開發體驗。但是,我要的只是 VPD 的開發體驗,我並不喜歡 VPD 內建的建置方式:利用 Browserify 把程式碼組成拖泥帶水的模組式結構。對我的專案來說我根本不需要模組化,我只要在同一個 scope 底下作基本的 Vue.component() 宣告就夠了。因此,我自己寫了一個 vue-property-decorator-transpiler 模組是專門用來把 VPD 的語法轉譯回 component 宣告語法的,然後再配合 vue-template-compiler 模組去把我的 .vue 檔案中的 template 編譯成 Vue 的 render 函數 5 就達到我理想的編譯結果了。

我一樣自己寫了一個 Gulp 的外掛來整合上述功能並直接處理 .vue 檔案流,完成之後一樣用 gulp-concat 串接、gulp-terser 作 minify 等等。不過因為這個外掛估計太過於針對我個人的需求量身打造,所以我並沒有把它打包發布到 NPM 上面去 6。假如真的有讀者有興趣,跟我說一聲我再去放。

部署上傳

要用 Gulp 來作 FTP 上傳的話,可以使用 vinyl-ftp 這麼模組,它可以很方便地跟遠端比較檔案的日期並且只上傳本地中有被更新過的檔案。此外它支援 stream 的模式,所以即使使用的資源裡面有較大的檔案也可以高效率地執行。

對於開源的專案來說,我們當然不能夠把 FTP 上傳用到的設定公開,此時我們可以把設定寫在一個獨立的 .json 檔案之中,要用的時候用 require() 函數把它讀取進來,然後在 .gitignore 檔案裡面把那個 .json 檔案加進去,這樣就不擔心 FTP 帳號密碼曝光了。

建置變種

從工作上的實務經驗我們會知道一個很常見的需求是,同一套程式必須要編譯成若干個稍微有一點小差異的版本、以便部署到不同的環境或者用於不同的用途,這樣的不同建置版本稱為建置變種(build variant)。舉例來說,BPS 目前一共有三個變種:

  • 偵錯版:直接在本地透過 file:// 協定執行(我連本地伺服器都懶得打開),而因此裡面去掉了一些 HTTPS 相關的功能(以免在 console 中跑出礙眼的錯誤訊息),例如並沒有加上 PWA 的 manifest 7 等等。而因為是在本地執行,瀏覽器不允許直接用 fetch 取得本地資源,所以有加上一段利用 XMLHttpRequest 寫的替代程式碼來解這部份的需求。此外,偵錯版的檔案都沒有經過 minify,且引用的若干函式庫也是沒有經過 minify 的版本,以方便偵錯和效能分析上的用途。
  • 開發版:上傳到一個不同於正式版的網址之上,以便我能夠測試一些尚未穩定的新功能在手機上的執行情況。這個版本不會加上 GA,以免內部測試的流量被計算進去。
  • 正式版:上傳到正式版的網址之上,具有全部的功能。開發版和正式版使用的檔案和函式庫都是經過 minify 且不包含 source map 的(畢竟我並沒有把原始碼也上傳上去,有 source map 也沒有用)。

現在比較麻煩的地方在於,這三個建置變種所使用到的資源絕大部分都是一模一樣的,只有少數檔案是有差異的。如果我把整個資料夾都建置三份,那也顯得很囉唆,因此我最後是採用如下的作法。

首先,我把正式版的建置結果以及所有的共用資源都放在 dist 資料夾裡面。開發版跟正式版只有一個差別就是要把 index.htm 檔案裡面的 GA 拿掉,這部份我可以在上傳的時候利用 gulp-if 模組把 index.htm 篩選出來、然後再用 gulp-replace 去換掉裡面的內容並且上傳就好了 8。換句話說,開發版的檔案只存在於雲端,我在本地是沒有一份等同的檔案存在的。

再來,偵錯版之中跟正式版不一樣的檔案放在另外一個 debug 資料夾裡面,其中也包含了偵錯版的 index.htm 檔案作為執行偵錯的入口。這個檔案跟正式版比起來除了移除掉若干沒用的東西之外,還額外加入了 <base href="..."> 標籤來把所有引用的資源重新導向 dist 資料夾之中,除了少數偵錯版自己專屬的資源要把對應的路徑替換過來之外。

因此,首先我有一個所謂「index.htm 的原始碼」檔案位於 src 資料夾中的某處,其內容原則上是以正式版為準。當我要建置開發版的時候,我就讓它通過一個我自己寫的 Gulp 外掛來執行一大堆字串替換動作讓結果變成偵錯版(或者讀者也可以連續使用 gulp-replace 若干次來達到一樣的效果;我把那些要做的替換全部整理成一個外掛是為了增進效能);而當我要建置正式版的時候,就只是單純讓檔案通過 gulp-html-minifier-terser 而已(這個外掛顧名思義就是整合了 html-minifier 和 terser 的 Gulp 外掛)。

先詢問再執行工作

在所有我定義的 Gulp 工作當中,部署 BPS 正式版的工作「deployPub」是最關鍵重要的,如果不小心按到而錯誤地把還沒準備好的版本發布了上去那當然不是件好事,所以為了預防這個問題,我希望每次在執行這個工作的時候都能先再次跟我確認真的要部署。

在 Node.js 裡面要問這種確認問題的時候,通常都會使用 inquirer 這個寫得非常完整的套件來進行;而在 Gulp 裡面要正確和它串接,我最後研究出來應該是要用如下的結構:

let inquirer = require('inquirer');

gulp.task('deployPub', async done => {
    // 進行詢問
    let answers = await inquirer.prompt([{
        type: 'confirm',
        message: '請記得在發布之前更新版本號並加入更新 log。確定發布到正式版?',
        name: 'ok'
    }]);

    // 如果回答為 Yes 則等候工作執行完畢
    if(answers.ok) {
        await new Promise(cb => gulp.series(
            'buildAll',
            'update',
            'uploadPub'
        )(cb));
    }

    // 最後確實執行 callback 來知會 Gulp 已經完成
    done();
});

  1. 所謂的 source map 就是把最後輸出的 JavaScript 或 CSS 檔案對應回到原本原始碼位置上的一種輔助檔案;有了 source map 的話,在偵錯工具(例如瀏覽器的 console)中就可以看得到目前執行的程式碼(或 CSS 樣式)是對應在原本的原始碼中的哪裡,甚至可以讓我直接在 TypeScript 原始碼中下中斷點,非常方便。 

  2. Terser 是我目前所知最好的 JavaScript minifier,因為它支援最新的 ECMAScript 語法並且有夠多的自訂空間。 

  3. 因為我往往都是利用 TypeScript 的 outFile 選項來編譯成單一檔案,所以對我來說編譯都是要嘛全編、要嘛全部不編。 

  4. 我敲了作者很久但是他都沒有理我。 

  5. 這樣做有兩方面好處:首先我不需要載入完整版的 Vue,我只要 Vue runtime 就夠了,檔案大小會小一些,其次在網頁載入的時候也不用經過把 template 轉換成 render 函數的過程,網頁的啟動也會變快。 

  6. 假如搜尋一下會發現 NPM 上面確實有一個 gulp-vue-property-decorator-transpiler 模組而且也確實是我寫的沒錯,但是那個是針對舊版的 VPDT 寫的,執行上跟我在這篇中說的不太一樣。 

  7. 早期我的辦法是用 JavaScript 判斷當前是否為 HTTPS 協定然後再用 document.write() 方法寫上 PWA manifest,但是這樣做的話拿去一些例如 PageSpeed Insights 的工具分析的時候又會被唸,所以我的強迫症逼得我不得不採用建置變種來解決此問題。 

  8. gulp-if 和 gulp-replace 都是支援 stream 模式的 Gulp 外掛,所以搭配 vinyl-ftp 的 stream 模式使用也沒有問題。 

最後修改日期: 2021/03/05

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。