从零搭建一个node脚手架工具一

44次阅读

共计 6085 个字符,预计需要花费 16 分钟才能阅读完成。

前言

在实际的开发中,我们总会遇到各种各样的脚手架工具,大到从零开始搭建一个工程结构的 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 后,开发时还有几个注意点。

  1. 首先,既然决定用 typescript 了,就要充分利用到它的优势,写类型的时候不要什么时候都用 any,尽量多用自己定义的 interface,不然这就是个累赘。考虑到复用性,常用的类型可以定义在 decelation 文件里,方便引用。
  2. 在 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 的结构,主要包括:

  1. bin 文件夹下的入口文件
  2. 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
  1. 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
  1. .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 <cmd>')
  .alias('ex')
  .description('execute the given remote cmd')
  .option("-e, --exec_mode <mode>", "Which exec mode to use")
  .action(function(cmd, options){<!-- do something -->});

如果有多个 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<void>;
}

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 中丰富脚手架的操作了。

正文完
 0