4
头图

TL;DR

  • You can easily write a CLI, it's simpler than you think;
  • Let's write a CLI together to generate Lighthouse performance reports;
  • You will see how to configure TypeScript, EsLint and Prettier;
  • You'll see how to use some great libraries like chalk and commander ;
  • You will see how to spawn multiple processes;
  • You'll see how to use your CLI in GitHub Actions.

Practical use case

Lighthouse is one of the most popular development tools for gaining insight into web page performance, it provides a CLI and Node module so we can run it programmatically. However, if you run LIghthouse multiple times on the same web page, you'll see that its score will be different, that's because of the known variability . There are many factors that affect the variability of Lighthouse, and one of the recommended strategies for dealing with variance is to run Lighthouse multiple times.

In this article, we will use the CLI to implement this strategy, the implementation will cover:

  • run multiple Lighthouse analyses;
  • Summarize the data and calculate the median.

Project file structure

This is the file structure after configuring the tool.

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

Configuration Tool

We'll be using Yarn as the package manager for this project, or NPM if you prefer.

We will create a directory called my-script :

$ mkdir my-script && cd my-script

In the project root, we create a package.json using Yarn:

$ yarn init

Configure TypeScript

To install types for TypeScript and NodeJS , run:

$ yarn add --dev typescript @types/node

When we configure TypeScript, we can initialize a tsconfig.json tsc

$ npx tsc --init

In order to compile the TypeScript code and output the result to the /bin directory, we need to specify outDir in tsconfig.json of compilerOptions .

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

Then, let's test it out.

In the project root directory, run the following command, which will create index.ts file in the /src directory:

$ mkdir src && touch src/index.ts

In index.ts , we write a simple console.log and run the TypeScript compiler to see if the compiled file is in the /bin directory.

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

Add a script that compiles TypeScript code with tsc .

// package.json

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

Then run:

$ yarn tsc

You will see a /bin file under the index.js directory.

Then we execute the /bin directory in the project root directory:

$ node bin
# Hello from my-script

Configure ESLint

First we need to install ESLint in the project.

$ yarn add --dev eslint

EsLint is a very powerful linter , but it does not support TypeScript, so we need to install a TypeScript parser :

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

We also installed @typescript-eslint/eslint-plugin because we need it to extend ESLint rules for TypeScript-specific functionality.

To configure ESLint, we need to create a .eslintrc.js file in the project root directory:

$ touch .eslintrc.js

In .eslintrc.js , we can configure as follows:

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

Let's take a closer look at this configuration: we first use @typescript-eslint/parser to make ESLint understand the TypeScript syntax, then we apply @typescript-eslint/eslint-plugin plugin to extend these rules, and finally, we enable all the recommended rules in @typescript-eslint/eslint-plugin .

If you are interested in learning more about configuration, you can check official document for more details.

We can now add a package.json script to lint :

// package.json

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

Then go run this script:

$ yarn lint

Configure Prettier

Prettier is a very powerful formatter that comes with a set of rules to format our code. Sometimes these rules can conflict with ESLInt rules, let's see how to configure them.

First install Prettier and create a .prettierrc.json file in the project root directory to save the configuration:

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

You can edit .prettierrc.json and add your custom rules, you can find these options in official document .

// .prettierrc.json

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

Prettier provides easy integration with ESLint, we will follow the recommended configuration in the official documentation.

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

In .eslintrc.js , add this plugin at the last position of the extensions array.

// eslintrc.js

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

This Prettier extension added last, is very important, it disables all format-related ESLint rules, so conflicts will fall back to Prettier.

Now we can add a package.json script to prettier :

// package.json

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

Then go run this script:

$ yarn prettier

configure package.json

Our configuration is pretty much done, the only thing missing is a way to execute the project like a command. Instead of executing the /bin command with node , we want to be able to call the command directly:

# 我们想通过它的名字来直接调用这个命令,而不是 "node bin",像这样:
$ my-script

How do we do it? First, we need to add a Shebang src/index.ts on top of ):

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

Shebang is used to inform Unix-like operating systems that this is a NodeJS executable. So we can call the script directly without calling node .

Let's compile again:

$ yarn tsc

Before everything starts, we need to do one more thing, we need to assign the permissions of the executable to bin/index.js :

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

Let's try it out:

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

# Hello from my-script

Great, we're almost done, the last thing is to create a symlink between the command and the executable. First, we need to specify the bin attribute in package.json and point the command to bin/index.js .

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

Next, we use Yarn to create a symbolic link in the project root directory:

$ yarn link

# 你可以随时取消链接: "yarn unlink my-script"

Let's see if it works:

$ my-script

# Hello from my-script

After success, in order to make development more convenient, we will add several scripts in package.json :

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

Now, we can run yarn build to compile and automatically assign the executable's permissions to the entry file.

Write a CLI to run Lighthouse

When it's time to implement our core logic, we'll explore several handy NPM packages to help us write our CLI, and dive into the magic of Lighthouse.

Color console.log with chalk

$ yarn add chalk@4.1.2

Make sure you have chalk 4 installed, chalk 5 is pure ESM, we can't use it with TypeScript until TypeScript 4.6 is released.

chalk gives color to console.log , for example:

// src/index.ts

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

Now run yarn build && my-script in your project root directory and check the output log, you will find that the printout turns green.

Let's use chalk in a more meaningful way, Lighthouse's performance score is color-coded . We can write a utility function to display values with colors based on performance scores.

// 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)`)
}

Use it in src/index.ts and try logging something with draw() to see the results.

// src/index.ts

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

Use commander design command

To make our CLI interactive, we need to be able to read user input and parse it. commander is a descriptive way to define an interface, and we can implement the interface in a very clean and documentary way.

We want the user to interact with the CLI by simply passing in a URL for Lighthouse to run, and we also want to pass in an option to specify how many times Lighthouse should run on the URL, as follows:

# 没有选项
$ my-script https://dawchihliou.github.io/

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

Using commander can quickly implement our design.

$ yarn add commander

Let's clear src/index.ts and start over:

#!/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()

We first instantiate a Command and then use the instance program to define:

  • A required parameter : we gave it a name url and a description;
  • A option : we give it a short and a long flag, a description and a default value.

To use arguments and options, we first parse the command and log the variables.

Now we can run the command and watch the output log.

$ 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

Cool, right? ! Another cool feature is that commander will automatically generate a help to print help information.

$ my-script --help

Run multiple Lighthouse analyses in separate OS processes

We learned how to parse user input in the previous section, and it's time to dive into the core of the CLI.

The recommendation for running multiple Lighthouses is to run them in separate processes to eliminate the risk of interference. cross-spawn is a cross-platform solution for spawning processes that we will use to spawn new processes synchronously to run Lighthouse.

To install cross-spawn :

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

# 安装 lighthouse
$ yarn add lighthouse

Let's edit 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()

In the above code, based on user input, new processes are spawned multiple times. During each process, Lighthouse profiling was run using headless Chrome and JSON data was collected. The result variable will hold a separate set of performance data in the form of strings, the next step is to aggregate the data and calculate the most reliable performance score.

If you implement the code above, you will see a linting error about require because require.resolve resolves the path to the module and not the module itself. In this article, we will allow .eslintrc.js rules in @typescript-eslint/no-var-requires to be compiled.

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

Calculate reliable Lighthouse scores

One strategy is to aggregate the report by calculating the median, Lighthouse provides an internal function computeMedianRun , let's use it.

#!/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()

Under the hood, computeMedianRun returns the score closest to the median of the first Contentful Paint and the median of Time to Interactive. This is because they represent the earliest and latest moments in the page initialization life cycle, which is a more reliable way of determining the median than simply finding the median from a single measurement.

Now try the command again and see how it turns out.

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

Using the CLI with GitHub Actions

With our implementation complete, let's use the CLI in an automated workflow so we can benchmark performance in our CD/CI pipeline.

First, let's publish the package (hypothetically) on NPM.

I've released an NPM package dx-scripts that contains the production version of my-script , and we'll use dx-script to write a GitHub Actions workflow to demonstrate our CLI application.

Publish on NPM (example)

We need to add a packgage.json attribute to files to publish /bin directory.

// package.json

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

Then simply run:

$ yarn publish

Now the package is on NPM (hypothetically)!

Write a workflow

Let's discuss the workflow, we want the workflow to:

  • run a pull request when there is an update;
  • Run Lighthouse profiling against the feature branch preview URL;
  • Notify pull requests with analysis reports;

So after the workflow completes successfully, you will see comments from the GitHub Action Bot along with your Lighthouse score.

To focus on the application of the CLI, I will hardcode the feature branch preview URL in the workflow.

In the application repository, install dx-scripts :

$ yarn add --dev dx-script

Add a lighthouse-dev-ci.yaml to the GitHub workflow directory:

# .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 }}
            ```

In the "Run Lighthouse" step, we run dx-script Lighthouse CLI, replace special characters to print multiple lines of output, and set the output in a variable lighthouse_report . In the "Notify PR" step, we wrote a comment with the output of the "Run Lighthouse" step and posted the comment using the wow-actions/auto-comment action.

Summarize

Wouldn't it be nice to write a CLI? Let's take a look at everything we've covered:

  • Configure TypeScript;
  • configure ESLint;
  • Configure Prettier;
  • Execute your command locally;
  • log with coloring chalk;
  • define your command commander ;
  • spawning processes;
  • Execute the Lighthouse CLI;
  • Calculate the average performance score using Lighthouse's internal library;
  • publish your command as an npm package;
  • Apply your commands to the GitHub Action workflow.

resource


破晓L
2.1k 声望3.6k 粉丝

智慧之子 总以智慧为是