本文將從零開始介紹如何用 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
,沒有異常,完成!
npm link 本地調試
記得我們前面在 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:初稿