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

下一篇传送门:从零搭建一个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后,开发时还有几个注意点。

  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相关文件等,这些文件都按照业界规范来就好了,可以参考我的工程

脚手架工具的工作流程大致如下。
《从零搭建一个node脚手架工具(一)》
命令行的处理上会用到commanderinquirer等第三方库,另外我选择使用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中丰富脚手架的操作了。

下一篇传送门:从零搭建一个node脚手架工具(二)

    原文作者:Jovi
    原文地址: https://segmentfault.com/a/1190000019791588
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞