下一篇传送门:从零搭建一个node脚手架工具(二)
前言
在实际的开发中,我们总会遇到各种各样的脚手架工具,大到从零开始搭建一个工程结构的vue-cli,create-react-app,小到保存代码片的snippets,它们给我们的开发带来了许多的便利。这些脚手架工具都有各自的优点和不足,比如vue-cli只支持创建vue的项目,自定义程度低,而snippets又太过轻量级,并且不支持多人合作开发等。因此,在学习node后,我打算自己做一个符合自己需求的node脚手架工具。贴上脚手架工具的github地址,可供参考:YOSO:You only set once
typescript
我选择的开发语言是typescript。众所周知,javascript是一种动态类型的语言,这让我们在开发的时候不太注意对象的类型。这的确带来的一些便利,有时候让代码变得简洁,但是在有些时候也会带来麻烦。比如一个很久之前写的函数,如果没有好的注释,可能自己都会想不起来这个函数的输入和输出是什么。又或者重构代码的时候,给函数添加了一个参数,一不小心就容易漏了某一处调用。尤其对于大型项目来说,这反而加重了程序员的负担。
typescript是javascript的超集,支持javascript的所有语法,可以编译成纯净的javascript运行。它的一大特点就是静态类型。比起动态类型,除了要多些一些类型代码外,优点更多。
侦测错误
typescript首要的一个有点就是在编译时就可以检测到类型错误,而不用等到上线后才发现。
编程规范
typescript的另一个优点是强化了编程的规范,它提供了简便的方式来定义接口,一个系统模块可以抽象的看做一个typescript定义的接口。用带清晰接口的模块来结构化大型系统,这是一种更为抽象的设计形式。
代码可读性
类型标注对于代码可读性的提高也有很大的帮助。同时,利用typedoc等工具,还可以方便的生成文档。
决定使用typescript后,开发时还有几个注意点。
- 首先,既然决定用typescript了,就要充分利用到它的优势,写类型的时候不要什么时候都用any,尽量多用自己定义的interface,不然这就是个累赘。考虑到复用性,常用的类型可以定义在decelation文件里,方便引用。
- 在typescript中导入npm包时,有时可能会出现
Could not find a declaration file for module
的错误。这是因为大多数的javascript库是没有typescript类型定义的。为了解决这个问题,DefinitelyTyped被创造出来。这是一个高质量的typescript类型定义存储库,可以通过npm install @types/jquery --save-dev
来给一个库添加类型定义。如果在DefinitelyTyped中没有找到你要的库,那就别用import方法,改用require引用,也可以避免报错。
设计
在正式开发之前,先要做好设计。整个工程的结构我参考了nest-cli的结构,主要包括:
- bin文件夹下的入口文件
- commands文件夹,里面存放模块化的command文件,用来接收和解析输入的命令,nest-cli中的commands文件夹如下。
commands
├── abstract.command.ts
├── add.command.ts
├── command.input.ts
├── command.loader.ts
├── generate.command.ts
├── index.ts
├── info.command.ts
├── new.command.ts
└── update.command.ts
- actions文件夹,里面存放模块化的action文件,用来处理和执行命令。nest-cli中的actions文件夹如下。
actions
├── abstract.action.ts
├── add.action.ts
├── generate.action.ts
├── index.ts
├── info.action.ts
├── new.action.ts
└── update.action.ts
- .gitignore、tsconfig.json、npm相关文件等,这些文件都按照业界规范来就好了,可以参考我的工程
脚手架工具的工作流程大致如下。
命令行的处理上会用到commander,inquirer等第三方库,另外我选择使用react+ink的方式开发ui,让交互界面更好用。模板的加载上,我选择从github仓库进行加载,这需要去了解github的相关api,并且要记录用户的github仓库地址。模板引擎我选择了Mozilla的nunjucks。没有选择更加有名的handlebars、pug、ejs等模板引擎的原因是,这些模板引擎主要还是针对html语言的,为了防止xss攻击,会有很多转义的处理,如果作为脚手架工具要生成js等文件则会比较麻烦。而nunjucks默认就不会做转义的处理,在各方面都差不多的情况下,更适合我们的脚手架工具。最后,文件操作的话,node自带的fs工具就可以完成。
开始开发
项目初始化
完成设计后,就可以开始正式开发。第一步,完成一些琐碎的事情,给项目取一个名字,取名之前可以用 npm info name
看看有没有重名。新建一个git仓库用来存放项目代码,并且在本地创建对应的目录,连接到远程仓库。用 npm init
命令初始化生成package.json文件。
因为我们要用typescript来开发,所以要安装typescript。然后创建tsconfig.json文件,在tsconfig.json中设置typescript相关的配置项。这时候就可以运行tsc看一下能不能正常编译出js文件了。然后,根据我们的设计,新建我们需要的这几个文件夹,并且在bin文件夹里面新建一个入口文件,一般与输入的命令同名,我这里就叫yoso.ts。然后再package.json中添加配置项。
"scripts": {
"build":"tec"
}
"bin": {
"yoso": "bin/yoso.js"
}
要注意的是,虽然我们用typescript来开发,但是实际上最终是要编译成js来运行的,我们发的npm包也得是编译后的js代码。所以这里虽然创建的是 bin/yoso.ts
,在package.json中仍然要写yoso.js。然后进入yoso.ts文件,写上:
#!/usr/bin/env node
console.log("yoso!")
开头的#!/usr/bin/env node
不能少,这指定了node环境的路径。保存,然后就可以测试了。
调试和发包
你当然可以选择build之后运行测试,但这实在是太蠢了。要测试ts代码可以选择使用ts-node,安装 npm install -D ts-node
后,在package.json中加上script
"scripts": {
...
"start":"ts-node bin/yoso.ts"
}
然后运行npm run start
或者npm start
,如果看到输出yoso!
那就成功了。
然后就可以发个包试试了。去npm注册个账号,然后回来npm login
。发包前,先把package.json里的version改成0.0.1,以后每次发都要改版本号,0开头的代表测试版本。然后要编译ts文件,用tsc命令就行修改.npmignore文件,把ts文件忽略掉,但是要把d.ts文件包括在内。我是这样写的,仅供参考。
.idea/
.gitignore
tsconfig.json
#doc
doc/
#test
test/
# source
**/*.ts
*.ts
# definitions
!**/*.d.ts
!*.d.ts
编译完成之后,用npm publish
命令发包。为了防止发包前忘了编译,建议在script中加入npm钩子的命令
script:{
...
"prepublish": "npm run build",
"postpublish": "npm run build:clear",
"build:clear": "find ./actions ./bin ./commands ./utils ./ui ./component -type f -name '*.d.ts' -delete & find ./actions ./bin ./commands ./utils ./ui ./component -type f -not -name '*.ts*' -delete",
...
}
这样每次publish前就会自动编译,publish后也会自动清除编译出来的js文件,非常省心。
发包成功后,就可以全局安装一下npm install -g yoso
,然后运行命令yoso
,正常的话就能看到输出了。
但也不用每次为了全局调试就发一次包,可以在编译后,用npm link
命令建立全局的软链接,然后就可以全局使用yoso命令了。
模块化
然后我们可以写个模块化的命令了。这里用到了commander框架,可以先去github主页上看一下例子。我这里贴一个最简单的命令。
program
.command('exec ')
.alias('ex')
.description('execute the given remote cmd')
.option("-e, --exec_mode ", "Which exec mode to use")
.action(function(cmd, options){
});
如果有多个option,多个这样的命令,放在一起的话代码就会变得非常不清晰,不利于维护。所以要模块化的开发,我们需要把每个command都抽象出来,并且把每个执行的action也抽出来。然后在load文件中,把command和对应的action加载进来。参考nest-cli,我们可以写一个简单的init命令。首先在actions中创建一个action的抽象文件。
//abstract.action.ts
interface Input {
name: string;
value: boolean | string;
}
export abstract class AbstractAction {
public abstract async handle(
inputs?: Input[],
options?: Input[]
): Promise;
}
Input的类型可以自己定义,也可以把它写在外部的类型声明文件中。然后再在commands文件夹中创建command的抽象。
//abstract.command.ts
import { CommanderStatic } from 'commander';
import { AbstractAction } from '../actions/abstract.action';
export abstract class AbstractCommand {
constructor(protected action: AbstractAction) {}
public abstract load(program: CommanderStatic): void;
}
然后就可以写一个简单的继承这个抽象的command了,这里就叫init.command.ts。
//init.command.ts
import { Command, CommanderStatic } from "commander";
import { AbstractCommand } from "./abstract.command";
export class InitCommand extends AbstractCommand {
public load(program: CommanderStatic) {
program
.command("init [tpl] [path]")
.alias("i")
.description("Init Files From Git, example: tpl init demo src")
.action(async (tpl: string, path: string) => {
let inputs: any = { path, tpl};
await this.action.handle(inputs);
});
}
}
类型自己定义,最好不要写any。然后在actions中写一个简单的继承自abstract.action.ts的action实例,并且在action的入口文件index.ts中引入。
//init.action.ts
import { AbstractAction } from "./abstract.action";
export class InitAction extends AbstractAction {
public async handle(inputs: any) {
console.log(inputs.tpl);
console.log(inputs.path);
}
}
到这儿为止,command实例和action实例都已经写好了,接下来要做的就是加载command和对应的action。在commands中创建command.loader.ts,并且在index中引入。
//command.loader.ts
import { CommanderStatic } from "commander";
import { InitAction } from "../actions";
import { InitCommand } from "./init.command";
export class CommandLoader {
public static load(program: CommanderStatic): void {
new InitCommand(new InitAction()).load(program);
}
}
最后,修改一下之前创建的bin/yoso.ts文件。
#!/usr/bin/env node
import * as commander from 'commander';
import { CommanderStatic } from 'commander';
import { CommandLoader } from '../commands';
const bootstrap = () => {
const program: CommanderStatic = commander;
program.version(require('../package.json').version);
CommandLoader.load(program);
commander.parse(process.argv);
if (!program.args.length) {
program.outputHelp();
}
};
bootstrap();
大功告成,测试一下新的init命令npm start init tpl-path out-path
看看有没有输出tpl路径和输出路径。成功之后,就可以在action中丰富脚手架的操作了。
下一篇传送门:从零搭建一个node脚手架工具(二)