編寫自己的 TypeScript CLI

破曉L發表於2022-02-09

TL;DR

  • 您可以輕鬆編寫 CLI,它比你想象的要簡單;
  • 我們一起編寫 CLI 以生成 Lighthouse 效能報告;
  • 你將看到如何配置 TypeScript、EsLint 和 Prettier;
  • 你會看到如何使用一些很優秀的庫,比如 chalkcommander
  • 你將看到如何產生多個程式;
  • 你會看到如何在 GitHub Actions 中使用你的 CLI。

實際用例

Lighthouse 是用於深入瞭解網頁效能的最流行的開發工具之一,它提供了一個CLI 和 Node 模組,因此我們可以以程式設計方式執行它。但是,如果您在同一個網頁上多次執行 LIghthouse,您會發現它的分數會有所不同,那是因為存在已知的可變性。影響 Lighthouse 可變性的因素有很多,處理差異的推薦策略之一是多次執行 Lighthouse。

在本文中,我們將使用 CLI 來實施此策略,實施將涵蓋:

  • 執行多個 Lighthouse 分析;
  • 彙總資料並計算中位數。

專案的檔案結構

這是配置工具後的檔案結構。

my-script
├── .eslintrc.js
├── .prettierrc.json
├── package.json
├── tsconfig.json
├── bin
└── src
    ├── utils.ts
    └── index.ts

配置工具

我們將使用 Yarn 作為這個專案的包管理器,如果您願意,也可以使用 NPM。

我們將建立一個名為 my-script 的目錄:

$ mkdir my-script && cd my-script

在專案根目錄中,我們使用 Yarn 建立一個 package.json

$ yarn init

配置 TypeScript

安裝 TypeScriptNodeJS 的型別,執行:

$ yarn add --dev typescript @types/node

在我們配置 TypeScript 時,可以使用 tsc 初始化一個 tsconfig.json

$ npx tsc --init

為了編譯 TypeScript 程式碼並將結果輸出到 /bin 目錄下,我們需要在 tsconfig.jsoncompilerOptions 中指定 outDir

// tsconfig.json
{
  "compilerOptions": {
+    "outDir": "./bin"
    /* rest of the default options */
  }
}

然後,讓我們測試一下。

在專案根目錄下,執行以下命令,這將在 /src 目錄下中建立 index.ts 檔案:

$ mkdir src && touch src/index.ts

index.ts 中,我們編寫一個簡單的 console.log 並執行 TypeScript 編譯器,以檢視編譯後的檔案是否在 /bin 目錄中。

// src/index.ts
console.log('Hello from my-script')

新增一個用 tsc 編譯 TypeScript 程式碼的指令碼。

// package.json

+ "scripts": {
+   "tsc": "tsc"
+ },

然後執行:

$ yarn tsc

你將在 /bin 目下看到一個 index.js 檔案。

然後我們在專案根目錄下執行 /bin 目錄:

$ node bin
# Hello from my-script

配置 ESLint

首先我們需要在專案中安裝 ESLint

$ yarn add --dev eslint

EsLint 是一個非常強大的 linter,但它不支援 TypeScript,所以我們需要安裝一個 TypeScript 解析器

$ yarn add --dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

我們還安裝了 @typescript-eslint/eslint-plugin,這是因為我們需要它來擴充套件針對 TypeScript 特有功能的 ESLint 規則。

配置 ESLint,我們需要在專案根目錄下建立一個 .eslintrc.js 檔案:

$ touch .eslintrc.js

.eslintrc.js 中,我們可以進行如下配置:

// .eslintrc.js
module.exports = {
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  extends: ['plugin:@typescript-eslint/recommended']
}

讓我們進一步瞭解下這個配置:我們首先使用 @typescript-eslint/parser 來讓 ESLint 能夠理解 TypeScript 語法,然後我們應用 @typescript-eslint/eslint-plugin 外掛來擴充套件這些規則,最後,我們啟用了@typescript-eslint/eslint-plugin 中所有推薦的規則。

如果您有興趣瞭解更多關於配置的資訊,您可以檢視官方文件 以瞭解更多細節。

我們現在可以在 package.json 中新增一個 lint 指令碼:

// package.json

{
  "scripts": {
+    "lint": "eslint '**/*.{js,ts}' --fix",
  }
}

然後去執行這個指令碼:

$ yarn lint

配置 Prettier

Prettier 是一個非常強大的格式化程式,它附帶一套規則來格式化我們的程式碼。有時這些規則可能會與 ESLInt 規則衝突,讓我們一起看下將如何配置它們。

首先安裝 Prettier ,並在專案根目錄下建立一個 .prettierrc.json 檔案,來儲存配置:

$ yarn add --dev --exact prettier && touch .prettierrc.json

您可以編輯 .prettierrc.json 並且新增您的自定義規則,你可以在官方文件中找到這些選項。

// .prettierrc.json

{
  "trailingComma": "all",
  "singleQuote": true
}

Prettier 提供了與 ESLint 的便捷整合,我們將遵循官方文件中的推薦配置

$ yarn add --dev eslint-config-prettier eslint-plugin-prettier

.eslintrc.js 中,在 extensions 陣列的最後一個位置新增這個外掛。

// eslintrc.js

module.exports = {
  extends: [
    'plugin:@typescript-eslint/recommended',
+   'plugin:prettier/recommended' 
  ]
}

最後新增的這個 Prettier 擴充套件,非常重要,它會禁用所有與格式相關的 ESLint 規則,因此衝突將回退到 Prettier。

現在我們可以在 package.json 中新增一個 prettier 指令碼:

// package.json

{
  "scripts": {
+    "prettier": "prettier --write ."
  }
}

然後去執行這個指令碼:

$ yarn prettier

配置 package.json

我們的配置已經基本完成,唯一缺少的是一種像執行命令那樣執行專案的方法。與使用 node 執行 /bin 命令不同,我們希望能夠直接呼叫命令:

# 我們想通過它的名字來直接呼叫這個命令,而不是 "node bin",像這樣:
$ my-script

我們怎麼做呢?首先,我們需要在 src/index.ts 的頂部新增一個 Shebang):

+ #!/usr/bin/env node
console.log('hello from my-script')

Shebang 是用來通知類 Unix 作業系統這是 NodeJS 可執行檔案。因此,我們可以直接呼叫指令碼,而無需呼叫 node

讓我們再次編譯:

$ yarn tsc

在一切開始之前,我們還需要做一件事,我們需要將可執行檔案的許可權分配給bin/index.js

$ chmod u+x ./bin/index.js

讓我們試一試:

# 直接執行
$ ./bin/index.js

# Hello from my-script

很好,我們快完成了,最後一件事是在命令和可執行檔案之間建立符號連結。首先,我們需要在 package.json 中指定 bin 屬性,並將命令指向 bin/index.js

// package.json
{
+  "bin": {
+    "my-script": "./bin/index.js"
+  }
}

接著,我們在專案根目錄中使用 Yarn 建立一個符號連結:

$ yarn link

# 你可以隨時取消連結: "yarn unlink my-script"

讓我們看看它是否有效:

$ my-script

# Hello from my-script

成功之後,為了使開發更方便,我們將在 package.json 新增幾個指令碼:

// package.json
{
  "scripts": {
+    "build": "yarn tsc && yarn chmod",
+    "chmod": "chmod u+x ./bin/index.js",
  }
}

現在,我們可以執行 yarn build 來編譯,並自動將可執行檔案的許可權分配給入口檔案。

編寫 CLI 來執行 Lighthouse

是時候實現我們的核心邏輯了,我們將探索幾個方便的 NPM 包來幫助我們編寫CLI,並深入瞭解 Lighthouse 的魔力。

使用 chalk 著色 console.log

$ yarn add chalk@4.1.2

確保你安裝的是 chalk 4chalk 5是純 ESM,在 TypeScript 4.6 釋出之前,我們無法將其與 TypeScript 一起使用。

chalkconsole.log 提供顏色,例如:

// src/index.ts

import chalk from 'chalk'
console.log(chalk.green('Hello from my-script'))

現在在你的專案根目錄下執行 yarn build && my-script 並檢視輸出日誌,會發現列印結果變成了綠色。

讓我們用一種更有意義的方式來使用 chalkLighthouse 的效能分數是採用顏色標記的。我們可以編寫一個實用函式,根據效能評分用顏色顯示數值。

// src/utils.ts

import chalk from 'chalk'

/**
 * Coloring display value based on Lighthouse score.
 *
 * - 0 to 0.49 (red): Poor
 * - 0.5 to 0.89 (orange): Needs Improvement
 * - 0.9 to 1 (green): Good
 */
export function draw(score: number, value: number) {
  if (score >= 0.9 && score <= 1) {
    return chalk.green(`${value} (Good)`)
  }
  if (score >= 0.5 && score < 0.9) {
    return chalk.yellow(`${value} (Needs Improvement)`)
  }
  return chalk.red(`${value} (Poor)`)
}

src/index.ts 中使用它,並嘗試使用 draw() 記錄一些內容以檢視結果。

// src/index.ts

import { draw } from './utils'
console.log(`Perf score is ${draw(0.64, 64)}`)

使用 commander 設計命令

要使我們的 CLI 具有互動性,我們需要能夠讀取使用者輸入並解析它們。commander 是定義介面的一種描述性方式,我們可以以一種非常乾淨和紀實的方式實現介面。

我們希望使用者與 CLI 互動,就是簡單地傳遞一個 URL 讓 Lighthouse 執行,我們還希望傳入一個選項來指定 Lighthouse 應該在 URL 上執行多少次,如下:

# 沒有選項
$ my-script https://dawchihliou.github.io/

# 使用選項
$ my-script https://dawchihliou.github.io/ --iteration=3

使用 commander 可以快速的實現我們的設計。

$ yarn add commander

讓我們清除 src/index.ts 然後重新開始:

#!/usr/bin/env node

import { Command } from 'commander'

async function run() {
  const program = new Command()

  program
    .argument('<url>', 'Lighthouse will run the analysis on the URL.')
    .option(
      '-i, --iteration <type>',
      'How many times Lighthouse should run the analysis per URL',
      '5'
    )
    .parse()
      
  const [url] = program.args
  const options = program.opts()
      
  console.log(`url: ${url}, iteration: ${options.iteration}`)
}
      
run()

我們首先例項化了一個 Command,然後使用例項 program 去定義:

  • 一個必需的引數:我們給它起了一個名稱 url和一個描述;
  • 一個選項:我們給它一個短標誌和一個長標誌,一個描述和一個預設值。

要使用引數和選項,我們首先解析命令並記錄變數。

現在我們可以執行命令並觀察輸出日誌。

$ yarn build

# 沒有選項
$ my-script https://dawchihliou.github.io/

# url: https://dawchihliou.github.io/, iteration: 5

# 使用選項
$ my-script https://dawchihliou.github.io/ --iteration=3
# 或者
$ my-script https://dawchihliou.github.io/ -i 3

# url: https://dawchihliou.github.io/, iteration: 3

很酷吧?!另一個很酷的特性是,commander 會自動生成一個 help 來列印幫助資訊。

$ my-script --help

在單獨的作業系統程式中執行多個 Lighthouse 分析

我們在上一節中學習瞭如何解析使用者輸入,是時候深入瞭解 CLI 的核心了。

執行多個 Lighthouse 的建議是在單獨的程式中執行它們,以消除干擾的風險。cross-spawn 是用於生成程式的跨平臺解決方案,我們將使用它來同步生成新程式來執行 Lighthouse。

要安裝 cross-spawn

$ yarn add cross-spawn 
$ yarn add --dev @types/cross-spawn

# 安裝 lighthouse
$ yarn add lighthouse

讓我們編輯 src/index.ts

#!/usr/bin/env node

import { Command } from 'commander'
import spawn from 'cross-spawn'

const lighthouse = require.resolve('lighthouse/lighthouse-cli')

async function run() {
  const program = new Command()

  program
    .argument('<url>', 'Lighthouse will run the analysis on the URL.')
    .option(
      '-i, --iteration <type>',
      'How many times Lighthouse should run the analysis per URL',
      '5'
    )
    .parse()
      
  const [url] = program.args
  const options = program.opts()
      
  console.log(
    `? Running Lighthouse for ${url}. It will take a while, please wait...`
  )
  
  const results = []

  for (let i = 0; i < options.iteration; i++) {
    const { status, stdout } = spawn.sync(
      process.execPath, [
      lighthouse,
      url,
      '--output=json',
      '--chromeFlags=--headless',
      '--only-categories=performance',
    ])

    if (status !== 0) {
      continue
    }

    results.push(JSON.parse(stdout.toString()))
  }
}
      
run()

在上面的程式碼中,根據使用者輸入,多次生成新程式。在每個過程中,使用無頭Chrome 執行 Lighthouse 效能分析,並收集 JSON 資料。該 result 變數將以字串的形式儲存一組獨立的效能資料,下一步是彙總資料並計算最可靠的效能分數。

如果您實現了上面的程式碼,您將看到一個關於 requirelinting 錯誤,是因為 require.resolve 解析模組的路徑而不是模組本身。在本文中,我們將允許編譯 .eslintrc.js 中的 @typescript-eslint/no-var-requires 規則。

// .eslintrc.js
module.exports = {
+  rules: {
+    // allow require
+    '@typescript-eslint/no-var-requires': 0,
+  },
}

計算可靠的 Lighthouse 分數

一種策略是通過計算中位數來彙總報告,Lighthouse 提供了一個內部功能computeMedianRun,讓我們使用它。

#!/usr/bin/env node

import chalk from 'chalk';
import { Command } from 'commander'
import spawn from 'cross-spawn'
import {draw} from './utils'

const lighthouse = require.resolve('lighthouse/lighthouse-cli')

// For simplicity, we use require here because lighthouse doesn't provide type declaration.
const {
  computeMedianRun,
} = require('lighthouse/lighthouse-core/lib/median-run.js')

async function run() {
  const program = new Command()

  program
    .argument('<url>', 'Lighthouse will run the analysis on the URL.')
    .option(
      '-i, --iteration <type>',
      'How many times Lighthouse should run the analysis per URL',
      '5'
    )
    .parse()
      
  const [url] = program.args
  const options = program.opts()
      
  console.log(
    `? Running Lighthouse for ${url}. It will take a while, please wait...`
  )
  
  const results = []

  for (let i = 0; i < options.iteration; i++) {
    const { status, stdout } = spawn.sync(
      process.execPath, [
      lighthouse,
      url,
      '--output=json',
      '--chromeFlags=--headless',
      '--only-categories=performance',
    ])

    if (status !== 0) {
      continue
    }

    results.push(JSON.parse(stdout.toString()))
  }
                                         
  const median = computeMedianRun(results)
                                         
  console.log(`\n${chalk.green('✔')} Report is ready for ${median.finalUrl}`)
  console.log(
    `? Median performance score: ${draw(
      median.categories.performance.score,
      median.categories.performance.score * 100
    )}`
  )
  
  const primaryMatrices = [
    'first-contentful-paint',
    'interactive',
    'speed-index',
    'total-blocking-time',
    'largest-contentful-paint',
    'cumulative-layout-shift',
  ];

  primaryMatrices.map((matrix) => {
    const { title, displayValue, score } = median.audits[matrix];
    console.log(`? Median ${title}: ${draw(score, displayValue)}`);
  });
}
      
run()

在底層,computeMedianRun 返回最接近第一次 Contentful Paint 的中位數和 Time to Interactive 的中位數的分數。這是因為它們表示頁面初始化生命週期中的最早和最新時刻,這是一種確定中位數的更可靠的方法,而不是簡單的從單個測量中找到中位數的方法。

現在再試一次命令,看看結果如何。

$ yarn build && my-script https://dawchihliou.github.io --iteration=3

在 GitHub Actions 中使用 CLI

我們的實現已經完成,讓我們在自動化的工作流中使用 CLI,這樣我們就可以在CD/CI 管道中對效能進行基準測試。

首先,讓我們在 NPM 上釋出這個包(假設)。

我釋出了一個 NPM 包 dx-scripts,其中包含了 my-script 的生產版本,我們將用 dx-script 編寫 GitHub Actions 工作流來演示我們的 CLI 應用程式。

在 NPM 上釋出(示例)

我們需要在 packgage.json 中新增一個 files 屬性,來發布 /bin 目錄。

// package.json

{
+  "files": ["bin"],
}

然後簡單的執行:

$ yarn publish

現在包就在 NPM 上了(假設)!

編寫工作流

讓我們討論一下工作流,我們希望工作流:

  • 當有更新時執行一個 pull 請求;
  • 針對功能分支預覽 URL 執行 Lighthouse 效能分析;
  • 用分析報告通知 pull 請求;

因此,在工作流成功完成後,您將看到來自 GitHub Action Bot 的評論與您的 Lighthouse 分數。

為了專注於 CLI 的應用,我將在工作流中對功能分支預覽 URL 進行硬編碼。

在應用程式儲存庫中,安裝 dx-scripts

$ yarn add --dev dx-script

新增一個 lighthouse-dev-ci.yaml 到 GitHub 工作流目錄中:

# .github/workflows/lighthouse-dev-ci.yaml

name: Lighthouse Dev CI
on: pull_request
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    env:
      # You can substitute the harcoded preview url with your preview url
      preview_url: https://dawchihliou.github.io/
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: '16.x'
      - name: Install dependencies
        run: yarn
      # You can add your steps here to create a preview
      - name: Run Lighthouse
        id: lighthouse
        shell: bash
        run: |
          lighthouse=$(npx dx-scripts lighthouse $preview_url)
          lighthouse="${lighthouse//'%'/'%25'}"
          lighthouse="${lighthouse//$'\n'/'%0A'}"
          lighthouse="${lighthouse//$'\r'/'%0D'}"
          echo "::set-output name=lighthouse_report::$lighthouse"
      - name: Notify PR
        uses: wow-actions/auto-comment@v1
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          pullRequestSynchronize: |
            ? @{{ author }},
            Here is your Lighthouse performance overview?
            ```
            ${{ steps.lighthouse.outputs.lighthouse_report }}
            ```

在 “Run Lighthouse” 步驟中,我們執行 dx-script Lighthouse CLI,替換特殊字元以列印多行輸出,並將輸出設定在一個變數 lighthouse_report 中。在 “Notify PR” 步驟中,我們用 “Run Lighthouse” 步驟的輸出寫了一條評論,並使用 wow-actions/auto-comment 操作來發布評論。

總結

寫一個 CLI 還不錯吧?讓我們來看看我們已經涵蓋的所有內容:

  • 配置 TypeScript;
  • 配置 ESLint;
  • 配置 Prettier;
  • 在本地執行您的命令;
  • 用著色日誌 chalk;
  • 定義你的命令 commander
  • spawning processes;
  • 執行 Lighthouse CLI;
  • 使用 Lighthouse 的內部庫計算平均效能分數;
  • 將您的命令釋出為 npm 包;
  • 將您的命令應用於 GitHub Action 工作流程。

資源

相關文章