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

僅在更新的時候執行建置

一個非常常見的需求是,我希望實際上的建置動作是只有在兩種情況中會被執行:

  1. 這是我第一次執行建置動作(例如當我剛剛把 git 抓下來的時候);
  2. 自從我上次執行過建置之後,對應的原始碼檔案有發生過更新。

除此之外的情況中,我希望建置動作會直接略過。為了達到這樣的效果,很多人會使用 gulp.watch() 方法來監視檔案編輯,一旦原始碼檔案有儲存發生就自動重新執行建置;但是我覺得類似這種的自動監看建置功能並不是真的很好用,原因是因為我常常會同時編輯好幾個檔案、而在編輯的過程中暫時先對其中某些檔案按下存檔,此時因為只有部份的檔案有更新,在這種狀態下編譯常常是會出錯的,所以開啟了這種監看功能就會變成我要很小心地確保所有的檔案都編輯完成了之後再按下「全部儲存」以確保編譯錯誤的訊息不會跳出來騷擾我,而這樣做在實務上真的很煩。

所以,我寧可變成我每次要開始試跑之前才執行全部的建置(設定的方法我等一下講),而在建置的過程中自動略過可以被略過的步驟。

要比較原始檔跟目標檔案之間的新舊,至少可以分成三種策略:

  • 一對一比較:每一個來源檔案都個別地跟對應的目標檔案單獨比較,並且個別地挑出較新的來處理。這種策略主要是在複製靜態資源的時候會使用。
  • 多對一比較:來源檔案裡面有任何一個比指定的單一目標檔案更新就全部加以處理、否則就全部不處理。這種策略主要是在編譯 TypeScript 的時候會使用。
  • 多對多比較:來源檔案裡面有任何一個比指定的目標檔案全體都還要來得新就全部加以處理、否則也是全部不處理。這主要是用來處理多檔案編譯成多檔案的特殊使用情境。

不管是哪一種策略,當目標檔案不存在的時候(亦即前述的第一種情況)都是會視為是需要被處理的。而因應這三種不同的目的,有兩套外掛可以視情況來選用:

  • gulp-newer:可以處理一對一和多對一策略。
  • gulp-if-any-newer:我現在主要用它來處理多對多策略,不過它也可以處理多對一策略(只是語法比較長)。

TypeScript 建置

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

  1. 在編譯完成之後常常還要在所有程式碼外面包一層 template,例如 IIFE 或是 UMD 等等。
  2. 然後常常又需要順便用 Terser 2 來 minify 程式碼。

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

  • 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-wrap-js:用來把程式碼外面包 template 用的。前者只是簡單的文字插入,所以並不支援 gulp-sourcemap 但是可以使用最新的 ECMAScript 語法;後者則是有支援 gulp-sourcemap,但是卻因為很久沒有更新 3、而有一些新的 ECMAScript 語法會讓它出錯。因為這樣的差異,我兩者都會看情況使用。

更新:後來我發現 gulp-wrap-js 有一個還有人在維護的 fork 叫作 @makeomatic/gulp-wrap-js,這個 fork 就可以既支援 sourcemap 又能支援最新 ECMAScript 語法了,所以後來我都用這個就好。

偵錯前自動建置

再來比較複雜的就是設定每次當我在 VS Code 裡面要執行偵錯(即按下 F5)的時候要在開啟偵錯瀏覽器之前自動在背景中執行建置。這有兩個部份;首先我在 .vscode/launch.json 裡面加入一個如下的組態:

{
    "type": "pwa-chrome",
    "request": "launch",
    "name": "Launch in Chrome",
    "preLaunchTask": "Gulp build", // 重點在這一行
    "internalConsoleOptions": "neverOpen",
    "file": "${workspaceFolder}/debug/index.htm",
    "sourceMaps": true,
    "runtimeArgs": ["--allow-file-access-from-files"]
}

然後我要去定義「Gulp build」是一個什麼樣的工作,這要在 .vscode/tasks.json 裡面加入這一大串咒語(假定我要執行的 gulp 工作是 default):

{
    "label": "Gulp build",
    "type": "gulp",
    "task": "default",
    "isBackground": true,
    "presentation": {
        "echo": true,
        "reveal": "never",
        "revealProblems": "onProblem",
        "focus": false,
        "panel": "dedicated",
        "showReuseMessage": true
    },
    "problemMatcher": {
        "fileLocation": "relative",
        "pattern": {
            "regexp": "^([^\\s].*)\\((\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$",
            "file": 1,
            "location": 2,
            "severity": 3,
            "code": 4,
            "message": 5
        },
        "background": {
            "activeOnStart": true,
            "beginsPattern": "^\\[.{8}\\]\\sStarting\\s\\'default",
            "endsPattern": "^\\[.{8}\\]\\sFinished\\s\\'default"
        }
    },
    "group": {
        "kind": "build",
        "isDefault": true
    }
}

這邊我不仔細解釋這一大串咒語的細節,但是這裡面優化了很多細節,包括會在背景中執行而不跳出終端視窗(除非有錯誤發生)、自動 parse 執行時可能產生的錯誤訊息、正確偵測工作的開始和結束等等。

CSS 建置

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

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

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

關於自訂的 CSS 的部份就是這樣,那函式庫的 CSS 呢?對於想要優化網站載入效能的人來說,一定會有一個這樣的需求:把函式庫的龐大 CSS 裡面實際上用不到的規則去掉,留下需要的就好。這特別是對於像 Bootstrap 或 Font Awesome 這類的 CSS 尤其效果很顯著。

針對這種需求,gulp-purgecss 剛好就是在做這件事的;你可以指定一些要比較的原始碼檔案(可能是 html、js、或是 vue 檔案等等),它會去看 CSS 裡面定義的 class 是否真的有出現在那些原始碼之中,沒有的話就把規則去掉並產出較小的新檔案(並同時 minify)。它基於的 purgecss 套件具有很高度的自訂性,可以很精確地把真的有用到的規則篩選出來。

不過在用了這個機制之後,要作自動差異建置就多了一道學問:此時我除了當要比較的 CSS 檔案發生更新時當然要重新建置之外,被比較的原始碼檔案有更新的時候也要(然而這些檔案卻又不算在被處理的檔案之列)。幸好這個功能 gulp-newer 本身也有選項可以支援:

let newer = require('gulp-newer');
let purge = require('gulp-purgecss');

// 其中 compare 是要比較的原始碼檔案的 glob 字串
gulp.task('css', () =>
    gulp.src('style.css')
        .pipe(newer({
            dest: 'dist/style.css',
            extra: compare
        }))
        .pipe(purge({
            content: [compare],
            safelist: [/backdrop/], // Bootstrap Modal
            fontFace: true, // Font Awesome
            variables: true // Bootstrap
        }))
        .pipe(gulp.dest('dist'))
);

需要注意的是,根據文件中的說法,使用了 gulp-newer 的 extra 選項之後,就會自動採用本質上等同於是多對多的模式來比對,所以要注意一下 gulp.srcdest 的設定是否符合期望。

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 函數 4 就達到我理想的編譯結果了。

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

部署上傳

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

let ftp = require('vinyl-ftp');
let flog = require('fancy-log');

gulp.task('upload', () => {
    let conn = ftp.create({
        ... // 設定參考 vinyl-ftp 的文件
        log: flog // 建議引入 log,這樣比較看得清楚上傳進度
    });

    // 其中 remote_path 是遠端路徑
    return gulp.src("...") // 要上傳的檔案
        .pipe(conn.newer(remote_path))
        .pipe(conn.dest(remote_path));
});

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

vinyl-ftp 也有提供 clean() 方法可以把本地端已經沒有的檔案在遠端刪除對應的檔案,其用法是這樣的:

gulp.task('clean', () => {
    let conn = ftp.create({
        ... // 設定跟前面一樣
    });
    return conn.clean(remote_path + "/**/*.*", local_path);
});

這邊注意到我在 glob 的後面寫了 *.*,這是因為這個 clean() 有一個小 bug 是,假如我們把本地的整個子資料夾刪除掉,那它有可能會在遠端上先把對應的子資料夾刪除掉之後、卻又繼續試圖刪除裡面的檔案、然後就跳出檔案不存在的錯誤。我上面這樣的寫法可以讓執行的時候只刪檔案就好而不要刪目錄,雖然這樣可能會導致遠端留下一些空的目錄,但至少就不會發生錯誤了。

建置變種

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

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

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

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

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

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

函式庫的部份,原本在 public 資料夾裡面是未經 minify 以及 minify 兩種版本都有,但是在建置的時候會分開來複製到 distdebug 資料夾之中;判斷的根據就是「把 .js 換成 .min.js」之後的檔案是否存在。這會用到令一個外掛 gulp-filter,具體的寫法大致是這樣的:

let fs = require('fs');
let filter = require('gulp-filter');

...
.pipe(filter(file => {
    // 選取具有 min 版本的 .js 檔案
    if(file.extname != ".js") return true;
    return fs.existsSync(file.path.replace(/js$/, "min.js"));
}))

先詢問再執行工作

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

在 Node.js 裡面要問這種確認問題的時候,通常都會使用 inquirer 這個寫得非常完整的套件來進行;而在 Gulp 裡面要正確和它串接,我最後研究出來的辦法是這樣。首先我自訂一個模組並存檔為 gulp/utils/seriesIf.js

let gulp = require('gulp');

module.exports = function(predicate, ...tasks) {
    return (async () => {
        let result = await predicate();
        if(result) await new Promise(cb => gulp.series(...tasks)(cb));
    })();
}

然後再用如下的語法宣告我的工作:

let inquirer = require('inquirer');
let seriesIf = require('./gulp/utils/seriesIf');

gulp.task('deployPub', () => seriesIf(
    async () => {
        let answers = await inquirer.prompt([{
            type: 'confirm',
            message: '確定發布到正式版?',
            name: 'ok'
        }])
        return answers.ok;
    },
    // 底下這些依序是我要執行的 task
    'build',
    'update',
    'uploadPub'
));

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

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

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

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

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

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

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

最後修改日期: 2021/04/23

留言

撰寫回覆或留言

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