使用nodejs编写cli(命令行)实现公共框架一键安装

在前端开发工作中,我们经常会使用到webpack-cli、Vue-cli、create-react-app等cli工具去搭建项目框架,但有时候这些工具并不能完全满足我们的需求。
公司可能会在不同时期启动多个项目,而这些项目极大可能具有相同的部分(如登录页面、公共框架等),如果只依赖已有的这些cli工具去搭建项目框架,那么每次启动一个新项目我们就要把这些相同的部分书写一遍。如果我们拥有自己的cli工具,里面包含一些自定义的模块和功能,这样每次就能使用自己的cli工具搭建项目了。
下面我们就来介绍如何使用nodejs编写cli,来实现一键创建基于create-react-app/vue-cli搭建的包含我们需要的自定义模块的公共框架,本文中的例子是使用create-react-app的。

思路

先来介绍一下思路,也就是实现步骤。

  1. 搭建符合需求的自定义框架,作为模板文件存放到远程仓库。
  2. 初始化cli项目。
  3. 创建交互式命令。
  4. 创建项目目录(即项目文件夹)和模板文件临时目录。
  5. 获取远程仓库模板文件代码,存放到临时目录。
  6. 把临时目录里的模板文件代码copy到项目中。
  7. 删除模板文件中包含定制信息。
  8. 重新写入部分文件。
  9. 将cli项目发布至 npm。

下面就一步一步来实现吧。

搭建符合需求的自定义框架,作为模板文件存放到远程仓库

项目的开始我们需要搭建一套符合需求的自定义框架,也就是我们所要开发的绝大部分项目都要使用到的功能和模块(我们称之为项目的公共部分)。这里我们选用create-react-app作为脚手架,自定义框架中包含登录模块,业务模块的左侧菜单、顶部用户信息、面包屑等功能,这些不是本文的重点,所以不在这里多做阐述。远程仓库我选用的是github,感兴趣的可以去查看具体代码front-react-frame

模板目录

初始化cli项目

创建一个新项目create-react-frame并上传到github,在这个项目中使用 Node.js 的 esm 模块,注意需要把 Node.js 升至 12.0.0 以上版本以支持 esm 模块。
esm 模块的配置方式为在package.json文件中添加"type": "module"。cli项目目录如下:

cli项目目录

node官方对于 package.jsontype字段含义的解释:

  • 如果最近package.json文件包含字段“type”,其值为“module”,则以.js结尾或没有任何扩展名的文件将作为ES模块进行加载。
  • 其中最近package.json文件的意思是在当前文件夹下、该文件夹的父文件夹等中查找package.json,直到根目录。
  • 如果最近package.json文件没有type字段,或者包含"type": "commonjs",则无扩展名和.js结尾的文件将被视为commonjs模块进行加载。
  • 无论package.json中的type字段为何值,.mjs结尾的文件都按照ES模块来处理,.cjs结尾的文件都按照commonjs模块来处理。
    这里的ES模块和上面所说的esm模块同义,叫法不同而已。

创建交互式命令

我们之所以使用命令行工具,目的之一就是想让用户在创建项目框架时输入一些值,用于修改模板文件中的配置,自动生成完全符合我们需求的个性化项目框架,所以要在cli项目中创建交互式命令。
这里我们使用 inquirer 文档,安装 inquirer。

npm i inquirer

需要哪些交互式命令根据自己情况而定,我这里定义了五种:

  • 创建的目录名称(必填)
  • 开发环境接口服务代理(选填),不填则为默认内容
  • 项目名称(选填),不填则为默认内容
  • 用户信息接口地址(选填),不填则为默认内容
  • 菜单接口地址(选填),不填则为默认内容

以创建的目录名称为例在这里简单介绍下,其他的都比较类似,就不再做重复阐述了。

  1. 在初始化cli项目章节提到的目录结构 bin/questions 中创建 createDir.js 文件,内容如下:
// 要创建的目录名称
export default () => ({
  type: 'input',
  name: 'createDir',
  message: '请输入要创建的目录名称:',
  validate(val) {
    if (val) return true;
    return '请输入要创建的目录名称';
  },
});
  1. 创建交互问答入口文件 bin/questions/index.js
/**
 * 交互式命令行
 * 交互问答入口文件
 */
import inquirer from 'inquirer';
import createDir from './createDir.js';
import developUrl from './developUrl.js';
import projectName from './projectName.js';
import currentUser from './currentUser.js';
import userMenu from './userMenu.js';

export default () =>
  inquirer.prompt([
    createDir(), // 要创建的目录名称
    developUrl(),
    projectName(),
    currentUser(),
    userMenu(),
  ]);
  1. 这样就完成了交互式命令的创建,直接在脚手架入口文件
    bin/index.js 中引用即可。在 bin/index.js 文件顶部声明执行环境,添加#!/usr/bin/env node或者#!/usr/bin/node,这是告诉系统,下面这个脚本,使用nodejs来执行。
    #!/usr/bin/env node的意思是让系统自己去找node的执行程序。
    #!/usr/bin/node的意思是,明确告诉系统,node的执行程序在路径为/usr/bin/node
#!/usr/bin/env node
// CLI执行入口文件
import questions from './questions/index.js';

// 交互命令行输入的值
const config = await questions();

创建项目目录(即项目文件夹)和模板文件临时目录

上面的章节中用户已经输入了要创建的目录名称(createDir),那么接下来就是要在当前位置创建一个项目目录(即项目文件夹)和模板文件临时目录。

import fs from 'fs';
import chalk from 'chalk'; // chalk 美化输出

// 创建的项目路径
const projectPath = `./${config.createDir}`;
// 存放模板文件的目录路径
const templatesDirRootPath = `${projectPath}/templatesModulesDir`;

// 1. 先创建目标目录和模板文件临时目录
console.log(chalk.green(`根据createDir创建文件夹 -> ${projectPath}`));
fs.mkdirSync(projectPath);
fs.mkdirSync(templatesDirRootPath);
  • 这里为了输出效果的美观引入了chalk ,它同样需要安装
npm install chalk
  • fs是nodejs的核心模块之一,感兴趣的可以自己去学习一下。

获取远程仓库模板文件代码,存放到临时目录

接下来就是把存放在远程仓库中的自定义框架作为模板文件,使用git拉取下来,存放到临时目录。
这里我们使用 execa 子进程管理工具,依然是需要先安装。

npm i execa
import { execaSync, execa } from 'execa';
import ora from 'ora';

// 2. 获取远程仓库模板文件代码,存放到临时目录
let getGitRemoteResult = {}; // 拉取远程仓库结果
const getGitRemote = () => {
  const spinners = [ora('读取中...')];
  spinners[0].start();

  try {
    getGitRemoteResult = execaSync(
      `git`,
      [
        'clone',
        '-b',
        'master',
        'https://github.com/1045757307/front-react-frame.git',
      ],
      {
        cwd: templatesDirRootPath,
      }
    );
  } catch (err) {
    fs.rmdirSync(projectPath);
    console.error(err);
  }

  // console.log(chalk.blue('getGitRemoteResult:'), getGitRemoteResult);
  if (
    getGitRemoteResult.failed === true ||
    getGitRemoteResult.failed === undefined ||
    getGitRemoteResult.failed === null
  ) {
    spinners[0].fail('读取远程仓库失败!');
  } else {
    spinners[0].succeed('读取远程仓库成功!');
  }
};

getGitRemote();

上面拉取远程仓库代码子进程使用了 ora 来实现 loading 效果。

把临时目录里的模板文件代码copy到项目中

这里使用 fs-extra 复制文件,fs-extra需要单独安装

npm i fs-extra
import fse from 'fs-extra';

// 3. 把临时目录里的模板文件代码copy到项目中
const fsCopy = async () => {
  const spinners = [ora('创建模块中...')];
  spinners[0].start();
  try {
    console.log(chalk.blue('copy模板文件代码'));
    await fse.copy(
      `${templatesDirRootPath}/front-react-frame`,
      `${projectPath}`,
      (err) => {
        if (err) {
          console.error(err);
        } else {
          delDir();
          spinners[0].succeed('创建模块成功!');
          rewrite();
        }
      }
    );
  } catch (err) {
    fs.rmdirSync(projectPath);
    console.error(err);
  }
};

fsCopy();

删除模板文件中包含定制信息

projectPath这个路径下(也就是我们的项目目录)可能存在一些我们在开发中不需要的文件,至少现在就知道临时目录是不需要的,所以我们接下来的工作就是删除他们。

// 4. 删除模板文件中包含定制信息
const delDir = async () => {
  console.log(chalk.red('删除模板文件中包含定制信息'));

  // 删除模板文件临时目录
  await execa(`rm`, ['-rf', `${templatesDirRootPath}`], { cwd: './' });
  // 删除.git文件夹
  await execa(`rm`, ['-rf', `${projectPath}/.git`], { cwd: './' });
  // 删除package-lock.json
  await execa(`rm`, ['-rf', `${projectPath}/package-lock.json`], { cwd: './' });
};

重新写入部分文件

我们从远程仓库拉取的模板文件中肯定有某些地方是需要单独配置的,而在前面的章节中,用户通过交互式命令输入了很多配置项,那么下面就是这些配置项真正发挥作用的时候了。
我们要根据这些配置项重写部分文件,在这个项目中我重写了package.json.envindex.htmlBasicLayout.jsxMain/index.jsx等文件。
想要重写这些文件需要将对应的代码文件写入到我们的 CLI 项目的 ejs 模板中,在这里以package.json为例,其他重写的文件于此类似,详细可参考源码。
bin/templates目录下创建package文件夹以及package/index.jspackage.ejs文件。
bin/template/package/package.ejs

{
    "name": "<%= createDir %>",
    "version": "0.1.0",
    "private": true,
    "scripts": {
            ...
    },
    "devDependencies": {
            ...
        }
        ...
}

bin/template/package/index.js

import ejs from 'ejs';
import fs from 'fs';
import prettier from 'prettier';
import { getRootPath } from '../../utils/index.js';

export default ({ createDir }) => {
  const file = fs.readFileSync(getRootPath('template/package/package.ejs'));
  const code = ejs.render(file.toString(), { createDir });
  // 格式化
  return prettier.format(code, { parser: 'json' });
};
  • 这里通过ejs 文档来实现模板化文件内容。
  • 为了使代码更好阅读,我们可以用很多方式来格式化,比如手动使用IDE格式化、或者 通过 ESLint、stylelint格式化,又或者使用 Prettier,这里我们使用prettier来实现格式化代码。
    所以需要安装ejsprettier
npm install ejs prettier

我们定义了一个通用方法获取绝对路径,通用方法统一存放到bin/utils文件夹下。
bin/utils/index.js

// 主要是获取脚手架项目的物理路径函数
import path from 'path';
import { fileURLToPath } from 'url';
import process from 'process';

// 获取绝对路径
export const getRootPath = (pathUrl) => {
  const __dirname = fileURLToPath(import.meta.url);
  return path.resolve(__dirname, `../../${pathUrl}`);
};

以上准备工作完成以后,就可以在bin/index.js引入这些 ejs 模板,来修改远程仓库自定义项目中的某些配置了。

import packageJson from './template/package/index.js';
import envFile from './template/env/index.js';
import indexHtml from './template/indexHtml/index.js';
import BasicLayout from './template/BasicLayout/index.js';
import mainCompontent from './template/mainCompontent/index.js';

// 5. 重新写入部分文件
const rewrite = async () => {
  console.log(chalk.green('重新写入部分文件'));

  await execa(`rm`, ['-rf', `${projectPath}/package.json`], { cwd: './' });
  fs.writeFileSync(`${projectPath}/package.json`, packageJson(config));

  await execa(`rm`, ['-rf', `${projectPath}/.env`], { cwd: './' });
  fs.writeFileSync(`${projectPath}/.env`, envFile(config));

  await execa(`rm`, ['-rf', `${projectPath}/public/index.html`], { cwd: './' });
  fs.writeFileSync(`${projectPath}/public/index.html`, indexHtml(config));

  await execa(
    `rm`,
    ['-rf', `${projectPath}/src/common/components/Layout/BasicLayout.jsx`],
    { cwd: './' }
  );
  fs.writeFileSync(
    `${projectPath}/src/common/components/Layout/BasicLayout.jsx`,
    BasicLayout(config)
  );

  await execa(
    `rm`,
    ['-rf', `${projectPath}/src/common/components/Main/index.jsx`],
    { cwd: './' }
  );
  fs.writeFileSync(
    `${projectPath}/src/common/components/Main/index.jsx`,
    mainCompontent(config)
  );
};

将cli项目发布至 npm

到这里已经基本上完成了用nodejs编写cli工具的全部工作,接下来我们只需要在package.json文件里加上bin字段,就可以把它发布出去,以达到一键安装的目的。

"bin": {
    "create-react-frame-z": "bin/index.js"
  },

bin字段里面写上这个命令行的名字,也就是create-react-frame-z,它告诉npm里面的js脚本可以通过命令行的方式执行,以create-react-frame-z的命令调用,当然命令行的名字你想写什么都是你的自由。

  • 如果在测试阶段,可以在 cli 根目录执行 npm link 将 cli 模块链接到全局npm模块中,与 npm i -g 类似,但是他可以调试代码,因为 npm link 的形式可以理解为模块链接或者快捷方式。
  • 在正式阶段就要通过 npm publish 将 cli 项目发布到 npm , 通过 npm i -g 的形式安装到全局,然后执行 create-react-frame-z 即可执行我们的cli工具,这里我们展开来说下。
  1. 在 cli 项目根目录执行 npm init 来补充完整 package.json 的字段,根据提示填写即可。

    npm init

  2. 写好README.md,这是一个给大家描述你的包的markdown文件,会展示在你的npm包介绍首页。

  3. 如果你已经有npm账号,那么就用npm命令登陆一下,如果还没有npm 账号,需要去npm官网注册一个。


    npm login
  4. 登录完成以后就是执行npm publish把我们的cli项目发布出去。

    npm publish

    如果发布出现错误,请先去检查npm上是否有跟你同名的包,所以这里建议npm publish之前先去npm 官网搜索一下你要发布的包名,如果能搜到你就要换一个名字了,如果搜索结果为空,那么你很幸运,暂时还没有跟你同名的包。
    如果不是首次发布,version版本没有改变也会导致发布失败。一般情况下,一旦你要修改你已经发布后的代码,然后又要执行发布操作,务必到package.json里面,把version改一下,比如从1.0.0改为1.0.1,然后在执行npm publish,这样就可以成功发布了。


以上就是这篇文章的所有内容了,相信按照以上的步骤依次执行,你也可以编写自己的命令行工具,实现自定义框架的一键安装。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容