本文将从零开始介绍如何用 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 项目。
然后补充必要的信息,其中 main 是入口文件,bin 用于引入一个全局的命令,映射到 lib/index.js,有了 bin 字段后,我们就可以直接运行 zhi-cli
命令,而不需要 node lib/index.js
了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 { "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
。
1 pnpm add typescript @types/node -D
初始化 tsconfig.json
然后按我们工程的实际情况,修改下入口和输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "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 编译是否正常。
1 2 3 4 #!/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
输出正常。
1 2 3 ➜ zhi-cli git:(main) ✗ tsc ➜ zhi-cli git:(main) ✗ node --experimental-specifier-resolution=node lib/index.js Hello World
也可以不编译,安装 ts-node,更简洁的调试:
安装 ts-node
调试
1 node --experimental-specifier-resolution=node --loader ts-node/esm src/index.ts
这一 part 完成。
引入 ESLint 安装 ESLint 和其 ts 插件。
1 pnpm add eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier prettier
然后加上 .eslintrc.js
配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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 : { "semi" : "off" , "quotes" : "off" , "@typescript-eslint/semi" : ["error" , "never" ], "@typescript-eslint/quotes" : ["error" , "double" ] } }
验收一下,package.json
加上两条命令。
1 2 3 4 5 "scripts" : { "lint" : "eslint --ext .ts ." , "lint:fix" : "eslint --fix --ext .ts ." } ,
运行 npm run lint:fix
,没有异常,完成!
npm link 本地调试 记得我们前面在 package.json
中有个 bin
配置,那我们直接跑 zhi-cli
这个命令试试。
1 2 ➜ zhi-cli git:(main) zhi-cli zsh: command not found: zhi-cli
!!!嗯?原来我们现在的 npm 包还没发布和安装,没办法找到命令,为了方便调试,我们需要跑一下这个命令。
重新 zhi-cli
,可以了!
为了方便调试,我们在 package.json
中再加两个配置,用于调试和打包。
可以把这一块封装成 scripts
:
1 2 3 4 5 6 "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
。
我们先从简单的开始,接收一个输入作为新建工程的名称,先不做处理直接输出出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #!/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
,可以了!
1 2 3 4 5 6 7 ➜ zhi-cli git:(main) zhi-cli init firstProject > zhi-cli@0.0.1 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
。
1 pnpm add inquirer @types/inquirer
继续完善一下代码,添加交互提示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #!/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
,输出下面的结果。
1 2 3 4 5 6 7 8 9 ➜ zhi-cli git :(main) ✗ pnpm dev init myproject > zhi-cli@0.0 .1 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
。
1 pnpm add git-clone fs-extra @types/git-clone @types/fs-extra
新建一个 download.ts
,加上下载模板的代码,并在 index.ts
中引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 #!/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
,添加一些插槽,方便后面替换。
1 2 3 4 5 6 7 { "name" : "{{name}}" , "version" : "1.0.0" , "description" : "{{description}}" , "author" : "{{author}}" }
下面开始修改 package.json
。
安装 handlebars
。
开始修改 package.json
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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" ) } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #!/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
。
我们美化下输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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) }) }) }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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 动画,美观多了。
支持传入指定分支 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 #!/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 ()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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) }) }) }
命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 zhi-cli init my-project main zhi-cli init my-project ts-cli zhi-cli init my-project ts-vite-lib zhi-cli init my-project ts-vite-vue zhi-cli init my-project ts-vite-react
总结 本文实现了最简单的一个 zhi 生成器组件,实现的理念是,脚手架和模板都尽可能的简单。
文章更新历史 2022-03-08 feat:初稿