使用TypeScript開發一個自定義的Node-js前端開發腳手架

本文將從零開始介紹如何用 Node.js​ 和 TypeScript​​ 開發腳手架。

可用版本的 github 地址:zhi-cli

npm 的地址:zhi-cli

需求來源

如果我們之前花很大力氣搭建了一個項目開發工具包,但是有了新項目想用咋辦,常規辦法就是拷貝模板文件。但是每次拷貝模版再去修改,總是麻煩的,不如來開發一個腳手架,用命令行生成新的 Node.js 項目。

什麼是腳手架?

我們都用過腳手架,像 vue-cli​ 、react-native-cli​ 、express-generator​ 等等。

腳手架提供這些的功能:

  • 快速初始化項目
  • 保證協作團隊項目的統一
  • 添加通用的組件或者配置

確定腳手架要提供什麼樣的功能?

我們的腳手架起名爲 zhi-cli​,顧名思義,這是一個 zhi​ 系列項目的生成器,主要功能是生產 zhi​ 相關項目,拆分細節,我們的功能點有以下這些。

  • 下載 zhi 模板代碼到本地。
  • 接收用戶輸入的項目名稱、描述等,用於確定目錄名稱和修改 package​ 文件。
  • 接收用戶的輸入,定製項目內容(比如對中間件的選擇)。
  • 查看 help 和 version。
  • 對創建進度和創建結果,給出反饋。

開始操作

確定了需求之後,我們開始按部就班,操作起來!

準備工作

創建 npm 項目

首先創建 npm 項目。

npm i -g pnpm
pnpm init

然後補充必要的信息,其中 main 是入口文件,bin 用於引入一個全局的命令,映射到 lib/index.js,有了 bin 字段後,我們就可以直接運行 zhi-cli​ 命令,而不需要 node lib/index.js​ 了。

// package.json
{
  "name": "zhi-cli",
  "version": "0.0.1",
  "description": "zhi application generator",
  "main": "lib/index.js",
  "type": "module",
  "bin": {
    "zhi-cli": "lib/index.js"
  },
  "repository": "terwer/zhi-cli",
  "homepage": "https://terwer.space",
  "keywords": [
    "zhi",
    "zhi-cli",
    "cli"
  ],
  "author": "terwer",
  "license": "MIT"
}

支持用 ES6 和 TypeScript 開發

安裝 typescript ​和 @types/node​。

pnpm add typescript @types/node -D

初始化 tsconfig.json

tsc --init

然後按我們工程的實際情況,修改下入口和輸出。

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "sourceMap": true,
    "outDir": "./lib",
  },
  "include": [".eslintrc.js", "src/**/*"],
  "exclude": ["node_modules", "lib/**/*"],
}

我們在 src/index.ts ​寫個 hello world,測試下 ts 編譯是否正常。

// src/index.ts
#!/usr/bin/env node --experimental-specifier-resolution=node
const msg: string = 'Hello World'
console.log(msg)

然後執行 tsc​,可以看到 lib/index.js​ 輸出了編譯後的 js 文件,而且 node --experimental-specifier-resolution=node lib/index.js 輸出正常。

➜  zhi-cli git:(main) ✗ tsc   
➜  zhi-cli git:(main) ✗ node --experimental-specifier-resolution=node lib/index.js
Hello World

也可以不編譯,安裝 ts-node,更簡潔的調試:

安裝 ts-node

pnpm add ts-node -D

調試

node --experimental-specifier-resolution=node --loader ts-node/esm src/index.ts

這一 part 完成。

引入 ESLint

安裝 ESLint 和其 ts 插件。

pnpm add eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier prettier

然後加上 .eslintrc.js ​配置。

// .eslintrc.cjs
module.exports = {
  root: true,

  env: {
    browser: true,
    node: true,
    es2021: true
  },

  parser: "@typescript-eslint/parser",

  parserOptions: {
    ecmaVersion: 12,
    sourceType: "module",
    tsconfigRootDir: __dirname,
    parser: "@typescript-eslint/parser",
    project: ["./tsconfig.json"]
  },

  plugins: ["@typescript-eslint"],

  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],

  rules: {
    // Note: you must disable the base rule as it can report incorrect errors
    "semi": "off",
    "quotes": "off",
    "@typescript-eslint/semi": ["error", "never"],
    "@typescript-eslint/quotes": ["error", "double"]
  }
}

驗收一下,package.json​ ​加上兩條命令。

// package.json
"scripts": {
    "lint": "eslint --ext .ts .",
    "lint:fix": "eslint --fix --ext .ts ."
},

運行 npm run lint:fix​,沒有異常,完成!

記得我們前面在 package.json​ 中有個 bin​ 配置,那我們直接跑 zhi-cli​ 這個命令試試。

➜  zhi-cli git:(main) zhi-cli
zsh: command not found: zhi-cli

!!!嗯?原來我們現在的 npm 包還沒發佈和安裝,沒辦法找到命令,爲了方便調試,我們需要跑一下這個命令。

npm link

重新 zhi-cli​,可以了!

爲了方便調試,我們在 package.json​ ​中再加兩個配置,用於調試和打包。

可以把這一塊封裝成 scripts​ :

// package.json
"scripts": {
  "dev": "node --experimental-specifier-resolution=node --loader ts-node/esm src/index.ts",
  "build": "tsc",
  "start": "node --experimental-specifier-resolution=node lib/index.js"
},

命令行工具開發

接下來我們開始真正的腳手架開發。

commander 處理命令

我們用到 commander 來處理命令。commander​ 是一個用於簡化 node.js​ 命令行開發的庫。

安裝 commander​。

pnpm add commander -D 

我們先從簡單的開始,接收一個輸入作爲新建工程的名稱,先不做處理直接輸出出來。

// src/index.ts
#!/usr/bin/env node --experimental-specifier-resolution=node
import { Command } from "commander"

const program = new Command()

program
    .name("zhi-cli")
    .description("zhi+TypeScript application generator")
    .version("0.0.1")

program
    .command("init <name>")
    .description("init a zhi project")
    .action((name: string) => {
        console.log("start init zhi project:", name)
    })

program.parse()

tsc ​後,運行 ​ zhi-cli init firstProject​,成功輸出 start init zhi project: firstProject​,可以了!

➜  zhi-cli git:(main) zhi-cli init firstProject

> [email protected] dev /Users/terwer/Documents/mydocs/zhi-cli
> tsc && node lib/index.js "init" "firstProject"

start init zhi project: firstProject
➜  zhi-cli git:(main) 

注意:頂部需要加上 #!/usr/bin/env node

參考:https://docs.npmjs.com/cli/v8/configuring-npm/package-json#bin

inquirer 處理交互

下面開始搞用戶交互。

爲了腳手架儘量簡單易用,我們先只運行用戶有少量的交互操作,inquirer 是簡化 node.js 命令行開發的一個庫。

我們先確定交互有哪些,思考一下,我們先確定有下面這幾個交互。

  • 輸入項目描述
  • 輸入項目作者

安裝 inquirer​。

pnpm add inquirer @types/inquirer

繼續完善一下代碼,添加交互提示。

// src/index.ts
#!/usr/bin/env node --experimental-specifier-resolution=node
import {Command} from "commander"
import inquirer from "inquirer"

const InitPrompts = [
    {
        name: "description",
        message: "please input description",
        default: "",
    },
    {
        name: "author",
        message: "please input author",
        default: "",
    }
]

const program = new Command()

program
    .name("zhi-cli")
    .description("TypeScript application generator for zhi")
    .version("0.0.1")

program
    .command("init <name>")
    .description("init a zhi project")
    .action(async (name: string) => {
        console.log("start init zhi project:", name)
        const initOptions = await inquirer.prompt(InitPrompts)
        console.log("initOptions", initOptions)
    })

program.parse()

好了,現在我們試驗一下。運行 pnpm dev init myproject​,輸出下面的結果。

➜  zhi-cli git:(main) ✗ pnpm dev init myproject

> [email protected] dev /Users/terwer/Documents/mydocs/zhi-cli
> tsc && node lib/index.js "init" "myproject"

start init zhi project: myproject
? please input description test project
? please input author terwer
initOptions { description: 'test project', author: 'terwer' }

OK,沒問題,繼續下一 part 。

git-clone 下載模板

不使用 download-git-repo ​是因爲這個庫有些依賴有安全問題,且已經不在維護。

我們使用 git-clone 這個庫來下載 git 上的模板,這個庫更小而且功能也夠用。

安裝 git-clone​。

pnpm add git-clone fs-extra @types/git-clone @types/fs-extra

新建一個 download.ts​​,加上下載模板的代碼,並在 index.ts​ ​中引用。

// src/download.ts
import gitclone from "git-clone"
import fs from "fs-extra"
import path from "path"

export const downloadTemplate = async (templateGitUrl: string, downloadPath: string) => {
    let ret
    try {
        await gitclone(templateGitUrl, downloadPath, {checkout: "main", shallow: true})
        fs.removeSync(path.join(downloadPath, ".git"))
        ret = "download success"
    } catch (error) {
        ret = error
    }
    return ret
}
// src/index.ts
#!/usr/bin/env node --experimental-specifier-resolution=node
import {Command} from "commander"
import inquirer from "inquirer"
import {downloadTemplate} from "./download"

const templateGitUrl = "https://github.com/terwer/zhi-log"
let downloadPath = null

const InitPrompts = [
    {
        name: "description",
        message: "please input description",
        default: "",
    },
    {
        name: "author",
        message: "please input author",
        default: "",
    }
]

const program = new Command()

program
    .name("zhi-cli")
    .description("TypeScript application generator for zhi")
    .version("0.0.1")

program
    .command("init <name>")
    .description("init a zhi project")
    .action(async (name: string) => {
        console.log("start init zhi project:", name)
        const initOptions = await inquirer.prompt(InitPrompts)
        console.log("initOptions", initOptions)

        try {
            downloadPath = `./${name}`
            await downloadTemplate(templateGitUrl, downloadPath)
        } catch (error) {
            console.error(error)
        }
    })

program.parse()

注意!!下載完模板,要刪除 .git​ 目錄。

運行 npm run dev init myproject​​,發現 myproject 目錄被創建了,而且下載了 github 倉庫的內容。

又搞定一個,繼續繼續!!

handlebars 語義化模板

繼續完善,接下來我們要用輸入的名稱和描述、作者等文本,替換模板的對應字段。

在替換前,我們需要修改模板的 package.json​,添加一些插槽,方便後面替換。

// 模板倉庫的package.json
{
  "name": "{{name}}",
  "version": "1.0.0",
  "description": "{{description}}",
  "author": "{{author}}"
}

下面開始修改 package.json​。

安裝 handlebars​。

pnpm add handlebars -D

開始修改 package.json​。

// src/modify.ts
import fs from "fs-extra"
import path from "path"
import handlebars from "handlebars"

export const modifyPackageJson = function (downloadPath: string, options: any) {
  console.log("modifying package.json……")
  const packagePath = path.join(downloadPath, "package.json")
  if (fs.existsSync(packagePath)) {
    const content = fs.readFileSync(packagePath).toString()
    const template = handlebars.compile(content)

    const param = {
      name: options.name,
      description: options.description,
      author: options.author,
    }

    const result = template(param)
    fs.writeFileSync(packagePath, result)
    console.log("modify package.json complete")
  } else {
    throw new Error("no package.json")
  }
}
// src/index.ts
#!/usr/bin/env node --experimental-specifier-resolution=node
import { Command } from "commander"
import inquirer from "inquirer"
import { downloadTemplate } from "./download"
import { modifyPackageJson } from "./modify"

const templateGitUrl = "https://github.com/terwer/zhi-log"
let downloadPath = null

const InitPrompts = [
  {
    name: "description",
    message: "please input description",
    default: "",
  },
  {
    name: "author",
    message: "please input author",
    default: "",
  },
]

const program = new Command()

program
  .name("zhi-cli")
  .description("TypeScript application generator for zhi")
  .version("0.0.1")

program
  .command("init <name>")
  .description("init a zhi project")
  .action(async (name: string) => {
    console.log("start init zhi project:", name)
    const initOptions = await inquirer.prompt(InitPrompts)
    console.log("initOptions", initOptions)

    try {
      downloadPath = `./${name}`
      await downloadTemplate(templateGitUrl, downloadPath)
      await modifyPackageJson(downloadPath, { name, ...initOptions })
    } catch (error) {
      console.error(error)
    }
  })

program.parse()

ora 命令行美化

功能部分已經完成了,但是現在的提示比較簡陋。

我們來升級一下。

安裝 ora​。

pnpm add ora

我們美化下輸出。

// src/download.ts
import gitclone from "git-clone/promise"
import fs from "fs-extra"
import path from "path"
import ora from "ora"

export const downloadTemplate = (
  templateGitUrl: string,
  downloadPath: string
) => {
  const loading = ora("download template")
  return new Promise((resolve, reject) => {
    loading.start("start download template")

    gitclone(templateGitUrl, downloadPath, {
      checkout: "master",
      shallow: true,
    })
      .then((r) => {
        fs.removeSync(path.join(downloadPath, ".git"))
        loading.succeed("download success")
        loading.stop()

        resolve("download success")
      })
      .catch((error) => {
        loading.stop()
        loading.fail("download fail")

        reject(error)
      })
  })
}

// src/modify.ts
import fs from "fs-extra"
import path from "path"
import handlebars from "handlebars"
import ora from "ora"

const log = ora("modify")

export const modifyPackageJson = function (downloadPath: string, options: any) {
  const packagePath = path.join(downloadPath, "package.json")
  log.start("start modifying package.json")
  if (fs.existsSync(packagePath)) {
    const content = fs.readFileSync(packagePath).toString()
    const template = handlebars.compile(content)

    const param = {
      name: options.name,
      description: options.description,
      author: options.author,
    }

    const result = template(param)
    fs.writeFileSync(packagePath, result)
    log.stop()
    log.succeed("modify package.json complate")
  } else {
    log.stop()
    log.fail("modify package.json fail")
    throw new Error("no package.json")
  }
}

再運行下,這次有了 loading 動畫,美觀多了。

支持傳入指定分支

// src/index.ts
#!/usr/bin/env node --experimental-specifier-resolution=node
import { Command } from "commander"
import inquirer from "inquirer"
import { downloadTemplate } from "./download"
import { modifyPackageJson } from "./modify"

const templateGitUrl = "https://github.com/terwer/zhi-ts-template"
let downloadPath = null

const InitPrompts = [
  {
    name: "description",
    message: "please input description",
    default: "",
  },
  {
    name: "author",
    message: "please input author",
    default: "",
  },
]

const program = new Command()

program
  .name("zhi-cli")
  .description("TypeScript application generator for zhi")
  .version("0.0.1")

program
  .command("init <name> <branch>")
  .description("init a zhi project")
  .action(async (name: string, branch: string) => {
    console.log("start init zhi project:", name)
    const b = branch ?? "main"
    console.log("current branch:", b)
    const initOptions = await inquirer.prompt(InitPrompts)
    console.log("initOptions", initOptions)

    try {
      downloadPath = `./${name}`
      await downloadTemplate(templateGitUrl, downloadPath,b)
      modifyPackageJson(downloadPath, { name, ...initOptions })
      console.log("project created.")
    } catch (error) {
      console.error(error)
    }
  })

program.parse()
// src/download.ts
import gitclone from "git-clone/promise"
import fs from "fs-extra"
import path from "path"
import ora from "ora"

export const downloadTemplate = (
  templateGitUrl: string,
  downloadPath: string,
  branch: string
) => {
  const loading = ora("download template")
  return new Promise((resolve, reject) => {
    loading.start("start download template")

    gitclone(templateGitUrl, downloadPath, {
      checkout: branch,
      shallow: true,
    })
      .then((r) => {
        fs.removeSync(path.join(downloadPath, ".git"))
        loading.succeed("download success")
        loading.stop()

        resolve("download success")
      })
      .catch((error) => {
        loading.stop()
        loading.fail("download fail")

        reject(error)
      })
  })
}

命令

## default
zhi-cli init my-project main

## ts-cli
zhi-cli init my-project ts-cli

## ts-vite-lib
zhi-cli init my-project ts-vite-lib

## ts-vite-vue
zhi-cli init my-project ts-vite-vue

## ts-vite-react
zhi-cli init my-project ts-vite-react

總結

本文實現了最簡單的一個 zhi 生成器組件,實現的理念是,腳手架和模板都儘可能的簡單。

文章更新歷史
2022-03-08 feat:初稿

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章