我的脚手架seb-cli —— 开发记录

2023/7/20

为什么要开发一个脚手架?

因为有一个自己的脚手架真的是一件很酷的事情😉

# ❓ 什么是脚手架

在开发之前,先说一下脚手架是什么,它有什么功能?

简单来说,脚手架就是一个通过选择几个选项快速搭建项目基础代码的工具

在我看来,脚手架 最重要 的功能就是帮助你快速的把项目的基础架子搭好,比如项目依赖,模板,构建工具等等,让你不用从零开始自己手动配置一个项目,提升你的开发效率;同时,对于团队开发,可以保证团队项目管理的统一,比如文件目录架构,命名方式等等

说的更直接一点,脚手架省去了你在创建项目时不断 ctrl + cctrl + v的不专业操作😂

# 准备工作

首先,要问一下自己,你想实现一个什么样效果的一个脚手架呢?

我是想由简单到复杂,先实现如下的功能效果

  1. 用户输入命令,比如xxx create <your projectName>,开始创建项目
  2. 脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能,比如要不要配置eslint这些
  3. 用户选择你自己需要的功能
  4. 脚手架根据游湖的选择创建package.json文件,并添加对应的依赖项
  5. 脚手架根据用户的选择渲染项目模板,生成文件(例如 index.html、main.js、App.vue这些文件)
  6. 执行命令安装依赖 —— 比如yarn i

用到了一些插件👇

  • commander: 自定义命名行命令
  • inquirer: 终端提示
  • download-git-repo: 克隆模板和github仓库
  • ora: loading的可视化
  • chalk: 彩色终端字体

# 项目目录

为我们的脚手架建立一个文件夹,这里我就叫seb-cli了,关于每个文件的作用可以参考注释~

├─.vscode
├─bin
│  ├─mvc.js # mvc 全局命令
├─lib
│  ├─generator # 各个功能的模板
│  │  ├─babel # babel 模板
│  │  ├─linter # eslint 模板
│  │  ├─router # vue-router 模板
│  │  ├─vue # vue 模板
│  │  ├─vuex # vuex 模板
│  │  └─webpack # webpack 模板
│  ├─promptModules # 各个模块的交互提示语
│  └─utils # 一系列工具函数
│  ├─create.js # create 命令处理函数
│  ├─Creator.js # 处理交互提示
│  ├─Generator.js # 渲染模板
│  ├─PromptModuleAPI.js # 将各个功能的提示语注入 Creator
└─scripts # commit message 验证脚本 和项目无关 不需关注

# 开始开发

# 最后一次开发前的准备

ps: 我保证这是最后一次准备了!,其实是一个tips,帮你解决一个问题,为什么我们输入命令后,文件会立即执行?

直接拿一个例子来分析

  1. 首先建立一个index.js文件作为入口文件,我们要通过我们的命令来运行该文件
  2. 命令行输入yarn install y 创建package.json

只需要这两个文件,就能解决你的困惑 😅

  1. index.js第一行写上这行代码 —— 这行代码就可以让我们的index.js自动执行了
#!/usr/bin/env node

这行代码叫做shebang或者hashbang,虽然可以写绝对路径但因为兼容性问题还是推荐上述这种写法

注意不要写成user,虽然usruser的缩写,但是写成user会报错嗷 ~ 💢

  1. 要让终端能识别我们的命令,我们就要在 package.json 中配置上我们的命令
{
	...
  "bin": {
    "seb": "index.js"
  }
  ...
}
  1. 命令行输入npm link

这一步的作用是让package.json里的这个bin和真正环境变量的bin链接,这样就能让我这个seb作为终端命令配置到环境变量中,现在你可以在电脑的任何地方使用seb了 —— 软链接

  1. 随便在index.js里边写一点代码
console.log("dys up up")
  1. 然后直接在控制台输入我们的命令seb,就可以输出index.js中的内容了~

1

希望通过这样一个例子,你可以对命令跟文件的关系有一个新的认识,下面我们就开始脚手架的开发,这次是真的开始了!💥

需要注意的是,我们所有的内容都是CJS规范,但是有的库的高版本默认只支持EJS规范了,比如inquirer, chalk这些,所以我们应该安装这些库的较低版本

如果你安装了高版本,就得基于EJS规范来编写了,我亲身经历,有的地方写起来非常痛苦😢,比如动态引入文件的地方,EJSCJS异步任务执行顺序是有区别的,所以建议大家还是安装低版本的库吧~

# 处理用户命令

脚手架的第一个功能是处理用户的命令,所以我们借助commander这个库,来帮助我们提取出用户在命令行输入的内容,并交给我们的脚手架

直接上代码分析,先安装,在bin文件夹的mvc.js编写下面的代码

/bin/mvc.js
const program = require("commander");
const create = require("../lib/create");

program
  .version("0.1.0") // 设置脚手架的版本
  .command("create <name>") // 命令名称
  .description("create your project") // 命令描述
  .action((name) => {
    create(name);	// name就是命令后边输入的参数
  });

program.parse(); // 解析控制台输入的字符串

现在我们就通过commander注册了一个命令 —— create,并配置了命令的基本信息,比如版本和描述

然后的操作就可以参考我们最后一次准备的工作(这时就体现它的重要性了😄)

配置package.json如下

"bin": {
  "seb": "./bin/mvc.js"
},

这里就相当于用seb命令来代替执行 —— node ./bin/mvc.js

比如我们在控制台输入seb create demo,实际上就是在执行mvc.js这个文件,commander解析到命令create和参数demo,然后可以在action的回调里获取到这个参数,比如上面代码里面的name的值就是demo

这样就实现了第一个功能,测试一下~

2

在这里有一个地方要注意,在配置mvc.js时,有时会报下面这样的错误,这是mvc.js是带BOM编码的UTF-8文件,所以导致了这个问题

删掉这一行就可以正常运行了,因为此时start命令已经指定了node

3

# 与用户交互

获取到用户的命令行输入(要创建的项目名称 demo)之后,接下来脚手架要做的工作就是询问用户,要不要配置一些东西,比如eslint, webpack这些工具

这时就要用到Inquirer这个库(yarn add Inquirer),主要功能就是在控制台弹出一些问题和选项,让用户选择,然后脚手架根据用户的选择来进行之后的操作

首先看一下Inquirer是怎么实现交互的 —— 下面的代码不是我们脚手架的代码,只是帮助你来理解一些Inquirer的一些配置

// test.js
const inquirer = require("inquirer");

const prompts = [
  {
    name: "features", // 选项名称
    message: "Check the features needed for your project:", // 选项提示语
    pageSize: 10,
    type: "checkbox", // 选项类型 另外还有 confirm list 等
    choices: [
      // 具体的选项
      {
        name: "Babel", // 选项名称
        value: "babel", // 如果选择该选项,我们拿到的值
        short: "Babel",
        description:
          "Transpile modern JavaScript to older versions (for compatibility)",
        link: "https://babeljs.io/",
        checked: true,
      },
      {
        name: "Router",
        value: "router",
        description: "Structure the app with dynamic pages",
        link: "https://router.vuejs.org/",
      },
    ],
  },
];

inquirer.prompt(prompts);

运行这个文件 —— node test.js,控制台弹出的内容如下

4

首先看问题,内容就是我们配置的message,同时由于我们配置了问题是checkbox,所以是多选,我们可以同时选择两个选项,如果我们同时选择两个选项的话,控制台会显示下面的结果👇

5

在项目里,我们拿到的则是一个对象

{
  features: ["babel", "router"];
}

其中 features 是上面配置的 name 属性。features 数组中的值就是每个选项中的 value

同时,Inquirer还可以提供具有相关性的问题,也就是当上一个问题选择了指定的选项时,下一个问题才会显示出来。例如下面的代码:

// const inquirer = require("inquirer");
const inquirer = require("inquirer");
const chalk = require("chalk");

const prompts = [
  {
    name: "features", // 选项名称
    message: "Check the features needed for your project:", // 选项提示语
    pageSize: 10,
    type: "checkbox", // 选项类型 另外还有 confirm list 等
    choices: [
      // 具体的选项
      {
        name: "Babel", // 选项名称
        value: "babel", // 选项在我们代码中对应的值
        short: "Babel",
        description:
          "Transpile modern JavaScript to older versions (for compatibility)",
        link: "https://babeljs.io/",
        checked: true,
      },
      {
        name: "Router",
        value: "router",
        description: "Structure the app with dynamic pages",
        link: "https://router.vuejs.org/",
      },
    ],
  },
  {
    name: "historyMode",
    when: (answers) => answers.features.includes("router"),
    type: "confirm",
    message: `Use history mode for router? ${chalk.yellow(
      `(Requires proper server setup for index fallback in production)`
    )}`,
    description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
    link: "https://router.vuejs.org/guide/essentials/history-mode.html",
  },
];

inquirer.prompt(prompts);

重点就是when这个属性,它是一个函数,只有当他返回为真时,这个问题才会显示,这里只有当我们选择Router这个选项时,这个问题才会接着显示在控制台上面👇

6

7

下面就开始我们的脚手架里Inquirer的配置了~

  • 将对应的问题和可选值在控制台上展示出来,供用户选择
  • 当用户选择后,获取到用户具体的选项值后,再渲染模板和依赖

下面就开始添加seb-cli支持的功能了~

因为我目前做的都是vue的项目,同时,项目离不开webpackvite之后会考虑的~),所以我在这里是默认提供了vuewebpack

其他功能就可供用户选择了👇

  • vue-router
  • vuex
  • babel
  • eslint

关于这四个功能的文件(以后想要添加其他的功能也是放到这里),我把它们放到了lib/promptModules目录下

文件里边的内容跟我们刚才给的Inquirer示例是一样的,只是我们现在只需要choices属性里的部分了,比如babel.js👇

module.exports = (api) => {
    api.injectFeature({
        name: 'Babel',	// 选项名
        value: 'babel',	// 值
        short: 'Babel',
        description: 'Transpile modern JavaScript to older versions (for compatibility)',	// 选项功能描述
        link: 'https://babeljs.io/',	// 链接
        checked: true,		// 默认选择
    });
};

此时Babel的功能选项就配置完了,其实就是一个问题,问一下用户用不用babel,下一步开始把这个选项注入到我们的问题中

因为现在就是一个命令在操控 —— seb create,所以我们要添加create的内容了 —— 你可以把create当作是一个函数,它来帮我们创建这些问题,然后把这些功能的交互提示语都给整合到一起👇

create.js里的代码的逻辑如下:

  1. 创建Creator 的实例对象
  2. 调用 getPromptModules() 获取所有功能的交互提示语
  3. 再调用 PromptModuleAPI将所有交互提示语注入到 creator 对象
  4. 通过 const answers = await inquirer.prompt(creator.getFinalPrompts()) 在控制台弹出交互语句,并将用户选择结果赋值给 answers 变量。

在实现create.js之前,我们先分析一下其中用到的类

第一步创建了 creator对象,所以我们要声明一个Creator的类,这个类其实很简单,我们根据它的功能来分析即可,它要接收功能的所有交互提示语,然后通过调用它的get方法来将这些交互语句返回 ,就是这么简单,所以我们先把这个类给实现👇

// Creator.js
module.exports = class Creator {
  constuctor() {
    // 存储所有功能对象 —— choices用来存储每个功能的属性
    this.featruePrompt = {
      name: "features",
      message: "请选择你要添加到你项目的功能~:",
      pageSize: 10,
      type: "checkbox",
      choices: [],
    };
    this.injectPrompts = []; // 用来存储每个功能注入的提示语
  }

  // 获取所有已经注入的功能交互提示语
  getFinalPrompts() {
    // 处理没有when的情况,如果输入的功能对象中没有when属性
    // 就给它绑定一个when属性,它的值是一个返回true的回调函数
    this.injectPrompts.forEach((prompt) => {
      const originWhen = prompt.when || (() => {});
      prompt.when = (answer) => originWhen(answer);
    });

    // 将处理过的交互提示语和基本属性组合
    const prompt = [this.featruePrompt, ...this.injectPrompts];
    return prompt;
  }
}

然后就是PromptModuleAPI,也很简单,它的作用就是接收一个Creator实例,然后把功能对象的基本属性和它的交互提示语注入到Creator实例里👇

module.exports = class PromptModuleAPI {
  // 接收一个Creator对象
  constructor(creator) {
    this.creator = creator;
  }

  // 注入功能属性
  injectFeature(feature) {
    this.creator.featurePrompt.choices.push(feature);
  }

  // 注入功能交互提示语
  injectPrompt(prompt) {
    this.creator.injectPrompt.push(prompt);
  }
}

现在开始写create.js里的内容,其实很简单,这样我们的思路也就更清晰了~

// create.js
const Creator = require("./Creator.js");
const PromptModuleAPI = require("./PromptModuleAPI.js");
const inquirer = require("inquirer");
const creator = new Creator();
const clearConsole = require("./utils/clearConsole.js");

// 这个函数用来导入功能文件 —— 获取所有功能的交互提示语
const getPromptModules = () => {
  let allModules = ["babel", "router", "vuex", "eslint"];
  // 导出功能模块接口的函数
  return allModules.map((item) => require(`./promptModules/${item}.js`));
};

// 获取每个功能的交互提示语和属性
const promptModules = getPromptModules();

// 把creator封装成PromptModuleAPI对象
const promptAPI = new PromptModuleAPI(creator);

// 把每个功能的交互提示语注入到creator对象
promptModules.forEach((myModule) => myModule(promptAPI));

// 清空控制台
clearConsole();

// 拿到用户选择后的结果
// const answers = await inquirer.prompt(creator.getFinalPrompts());
inquirer.prompt(creator.getFinalPrompts());

如果用户选择了所有供选择的功能,我们来打印answer看一下👇

{
  // 现在添加的功能
  features: [ 'babel', 'router', 'vuex', 'linter' ],
  // vue-router路由是否使用 history 模式
  historyMode: true,
  // eslint 检验代码的默认规则,可以被覆盖
  eslintConfig: 'standard',
  // 什么时候进行eslint校验,保存和提交时
  lintOn: [ 'save', 'commit' ]
}

# 项目模板

现在我们已经拿到用户的选择, 想一下我们在使用vue-cli或者其他脚手架时,在我们选完我们要的功能后,安装之后,选的内容最后是不是出现在package.json文件里了,所以我们这一步就要来生成和渲染对应的package.json文件了

先看如何生成package.json

先定义一个 pkg 变量来表示 package.json 文件,并设定一些默认值。

// create.js
const pkg = {
    name,
    version: '0.1.0',
    dependencies: {},
    devDependencies: {},
}

关于每个功能对应的模板(包括package.json和要渲染的内容),都放在了lib/generator 目录下:

├─lib
│  ├─generator # 各个功能的模板
│  │  ├─babel # babel 模板
│  │  ├─linter # eslint 模板
│  │  ├─router # vue-router 模板
│  │  ├─vue # vue 模板
│  │  ├─vuex # vuex 模板
│  │  └─webpack # webpack 模板

每个功能的文件其实就是两个作用

  1. pkg注入该功能的依赖项
  2. 提供这个功能在我们项目中的生成的模板文件 —— 比如创建一个vue项目,会自动生成vue的一套模板文件

# 先分析注入依赖

既然我们现在是要生成内容,所以我们定义了一个Generator类来帮我们干这个活

// Generator.js
class Generator{
	....
}

babel这个功能举例,babel的代码如下(babel是一个将代码向后兼容的一个工具,所以它没有渲染模板这个部分😋)

// /babel/index.js
module.exports = (generator) => {
  generator.extendPackage({
    babel: {
      presets: ["@babel/preset-env"],
    },
    dependencies: {
      "core-js": "^3.8.3",
    },
    devDependencies: {
      "@babel/core": "^7.12.13",
      "@babel/preset-env": "^7.12.13",
      "babel-loader": "^8.2.2",
    },
  });
};

可以看到,在注入依赖这个环节,是利用了GeneratorextendPackage 方法来实现的👇

// Generator.js
  extendPackage(fields) {
    const pkg = this.pkg;
    for (const key in fields) {
      // 拿到传入功能模板key的值
      const value = fields[key];
      // 拿到现有的pkg对应key的值
      const existing = pkg[key];
      // 如果是这三个属性
      // 就要添加当前的value到现有对象上 —— Object.assign
      // 不是这三个属性就直接覆盖修改
      if (
        isObject(value) &&
        (key === "dependencies" ||
          key === "devDependencies" ||
          key === "scripts")
      ) {
        pkg[key] = Object.assign(existing || {}, value);
      } else {
        pkg[key] = value;
      }
    }
  }

注入babel的依赖之后,此时的pkg就变成了👇

pkg:  {
  name: '',
  version: '0.1.0',
  dependencies: { 'core-js': '^3.8.3' }
,
  devDependencies: {
    '@babel/core': '^7.12.13',
    '@babel/preset-env': '^7.12.13',
    'babel-loader': '^8.2.2'
  },
  babel: { presets: [ '@babel/preset-en
v' ] }
}

其他的功能也是这个流程,都是调用GeneratorextendPackage 来实现的💪

# 再渲染模板

vuex这个功能模块举例👇

// vuex/index.js
module.exports = (generator) => {
  // 调用generator的injectImports方法
  // 给项目的入口文件 -> "src/man.js" 注入代码 -> "import store from './store"
  generator.injectImports(generator.entryFile, `import store from './store'`);

  // 给项目的入口文件 -> "src/man.js" 的 new Vue() 注入选项 store
  generator.injectRootOptions(generator.entryFile, `store`);

  // 注入依赖
  generator.extendPackage({
    dependencies: {
      vuex: "^3.6.2",
    },
  });

  // 渲染功能文件中的template,也就是模板内容
  generator.render("./template", {});
};

vuex这个功能模块需要渲染模板代码,它的模板代码就放在vuex/template中👇

// template/index.js
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {},
});

在使用脚手架创建vue项目时,如果你没有添加vuex这个功能,那src/main.js的内容是下面这个样子(应该非常熟悉吧😏)

import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;

new Vue({
  render: (h) => h(App),
}).$mount("#app");

如果你选了vuex这个功能,那脚手架就会把vuex/template/index.js里的内容给注入里面

这个功能的实现就是通过上面的injectImportsinjectRootOptions方法☝️

细节就不阐述了,参考源代码即可

模板代码注入的逻辑大致如下

  • 使用 vue-codemod 将代码解析成语法抽象树 AST
  • 然后将要插入的代码变成 AST 节点插入到语法抽象树中
  • 最后将新的 AST 重新渲染成代码

# 生成文件

在完成上面👇的依赖和模板代码注入之后,这些内容目前还只存在内存中,比如package.json的内容,还在pkg这个变量里面,而无论是package.json还是模板代码,最后都是要生成文件显示的,所以这一步我们就要开始生成这些文件

写了一个生成文件的函数👇

借用了fswriteFileSync同步写入文件的函数来实现

const fs = require('fs-extra')
const path = require('path')

module.exports = async function writeFileTree(dir, files) {
	// 遍历每个功能文件
    Object.keys(files).forEach((name) => {
        const filePath = path.join(dir, name)
        // ensureDir()的同步版本
        // 确保目录存在,如果目录结构不存在,则由该函数创建
        fs.ensureDirSync(path.dirname(filePath))
        fs.writeFileSync(filePath, files[name])
    })
}

☝️逻辑如下:

  • 遍历所有渲染好的模板文件,按顺序生成
  • 在生成文件前,先判断一下父目录是不是存在,如果不存在,就先生成父目录
  • 生成并写入文件

比如现在一个模板文件要写到 src/test.js,第一次写入时,由于还没有 src 目录。所以会先生成 src 目录,再生成 test.js 文件

生成文件的代码肯定也是Generator这个类的函数方法了,生成文件功能(generate)代码如下:

  async generate() {
  	// 生成 package.json
    // 从 package.json 中提取文件 —— 比如babel的内容,提取成babel.config.js
    this.extractConfigFiles();
    
    // 解析模板文件内容
    await this.resolveFiles();
    // 将 package.json 中的字段排序
    this.sortPkg();
    this.files["package.json"] = JSON.stringify(this.pkg, null, 2) + "\n";
    
    // 将package.json和所有模板文件写入到用户要创建的目录
    await writeFileTree(this.context, this.files);
  }

# 下载依赖

在我们生成文件之后,模板文件的工作就结束了,但是我们的依赖此时并没有安装,只是存在了package.json里的字段中,用户还得自己安,我们既然是个脚手架,就要尽可能的减少用户的操作,所以,在create时,我们直接帮用户把依赖也安装了~

写了一个下载依赖的函数👇

利用了 execa (opens new window) 这个库 —— 利用它来调用子进程执行命令

const execa = require('execa')

module.exports = function executeCommand(command, cwd) {
    return new Promise((resolve, reject) => {
        const child = execa(command, [], {
            cwd,
            stdio: ['inherit', 'pipe', 'inherit'],
        })

        child.stdout.on('data', buffer => {
            process.stdout.write(buffer)
        })

        child.on('close', code => {
            if (code !== 0) {
                reject(new Error(`command failed: ${command}`))
                return
            }

            resolve()
        })
    })
}

create.js 里调用

// create.js 文件
console.log('\n正在下载依赖...\n')
// 下载依赖
await executeCommand('yarn add', path.join(process.cwd(), name))
console.log('\n依赖下载完成! 你可以执行下列命令开始开发:\n')
console.log(`cd ${name}`)
console.log(`yarn dev`

逻辑很简单,这里为了让用户看到下载依赖的过程,使用下面的代码将子进程的输出传给主进程,也就是输出到控制台👇

child.stdout.on('data', buffer => {
    process.stdout.write(buffer)
})

报错: unable to resolve dependency tree

原因: 依赖之间版本不对应 —— 比如vue3要对应vuex4

How to love
Lil Wayne