为什么要开发一个脚手架?
因为有一个自己的脚手架真的是一件很酷的事情😉
# ❓ 什么是脚手架
在开发之前,先说一下脚手架是什么,它有什么功能?
简单来说,脚手架就是一个通过选择几个选项快速搭建项目基础代码的工具
在我看来,脚手架 最重要 的功能就是帮助你快速的把项目的基础架子搭好,比如项目依赖,模板,构建工具等等,让你不用从零开始自己手动配置一个项目,提升你的开发效率;同时,对于团队开发,可以保证团队项目管理的统一,比如文件目录架构,命名方式等等
说的更直接一点,脚手架省去了你在创建项目时不断 ctrl + c 和 ctrl + v的不专业操作😂
# 准备工作
首先,要问一下自己,你想实现一个什么样效果的一个脚手架呢?
我是想由简单到复杂,先实现如下的功能效果
- 用户输入命令,比如
xxx create <your projectName>,开始创建项目 - 脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能,比如要不要配置
eslint这些 - 用户选择你自己需要的功能
 - 脚手架根据游湖的选择创建
package.json文件,并添加对应的依赖项 - 脚手架根据用户的选择渲染项目模板,生成文件(例如 
index.html、main.js、App.vue这些文件) - 执行命令安装依赖 —— 比如
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,帮你解决一个问题,为什么我们输入命令后,文件会立即执行?
直接拿一个例子来分析
- 首先建立一个
index.js文件作为入口文件,我们要通过我们的命令来运行该文件 - 命令行输入
yarn install y创建package.json 
只需要这两个文件,就能解决你的困惑 😅
- 在
index.js的第一行写上这行代码 —— 这行代码就可以让我们的index.js自动执行了 
#!/usr/bin/env node
这行代码叫做shebang或者hashbang,虽然可以写绝对路径但因为兼容性问题还是推荐上述这种写法
注意不要写成user,虽然usr是user的缩写,但是写成user会报错嗷 ~ 💢
- 要让终端能识别我们的命令,我们就要在 package.json 中配置上我们的命令
 
{
	...
  "bin": {
    "seb": "index.js"
  }
  ...
}
- 命令行输入
npm link 
这一步的作用是让package.json里的这个bin和真正环境变量的bin链接,这样就能让我这个seb作为终端命令配置到环境变量中,现在你可以在电脑的任何地方使用seb了 —— 软链接
- 随便在
index.js里边写一点代码 
console.log("dys up up");
- 然后直接在控制台输入我们的命令
seb,就可以输出index.js中的内容了~ 

希望通过这样一个例子,你可以对命令跟文件的关系有一个新的认识,下面我们就开始脚手架的开发,这次是真的开始了!💥
需要注意的是,我们所有的内容都是CJS规范,但是有的库的高版本默认只支持EJS规范了,比如inquirer, chalk这些,所以我们应该安装这些库的较低版本
如果你安装了高版本,就得基于EJS规范来编写了,我亲身经历,有的地方写起来非常痛苦😢,比如动态引入文件的地方,EJS和CJS异步任务执行顺序是有区别的,所以建议大家还是安装低版本的库吧~
# 处理用户命令
脚手架的第一个功能是处理用户的命令,所以我们借助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
这样就实现了第一个功能,测试一下~

在这里有一个地方要注意,在配置mvc.js时,有时会报下面这样的错误,这是mvc.js是带BOM编码的UTF-8文件,所以导致了这个问题
删掉这一行就可以正常运行了,因为此时start命令已经指定了node

# 与用户交互
获取到用户的命令行输入(要创建的项目名称 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,控制台弹出的内容如下

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

在项目里,我们拿到的则是一个对象
{
  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这个选项时,这个问题才会接着显示在控制台上面👇


下面就开始我们的脚手架里Inquirer的配置了~
- 将对应的问题和可选值在控制台上展示出来,供用户选择
 - 当用户选择后,获取到用户具体的选项值后,再渲染模板和依赖
 
下面就开始添加seb-cli支持的功能了~
因为我目前做的都是vue的项目,同时,项目离不开webpack(vite之后会考虑的~),所以我在这里是默认提供了vue和webpack的
其他功能就可供用户选择了👇
- 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里的代码的逻辑如下:
- 创建
Creator的实例对象 - 调用 
getPromptModules()获取所有功能的交互提示语 - 再调用 
PromptModuleAPI将所有交互提示语注入到creator对象 - 通过 
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 模板
每个功能的文件其实就是两个作用
- 给
pkg注入该功能的依赖项 - 提供这个功能在我们项目中的生成的模板文件 —— 比如创建一个
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",
    },
  });
};
可以看到,在注入依赖这个环节,是利用了Generator的 extendPackage 方法来实现的👇
// 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' ] }
}
其他的功能也是这个流程,都是调用Generator的 extendPackage 来实现的💪
# 再渲染模板
拿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里的内容给注入里面
这个功能的实现就是通过上面的injectImports和injectRootOptions方法☝️
细节就不阐述了,参考源代码即可
模板代码注入的逻辑大致如下
- 使用 
vue-codemod将代码解析成语法抽象树AST - 然后将要插入的代码变成 
AST节点插入到语法抽象树中 - 最后将新的 
AST重新渲染成代码 
# 生成文件
在完成上面👇的依赖和模板代码注入之后,这些内容目前还只存在内存中,比如package.json的内容,还在pkg这个变量里面,而无论是package.json还是模板代码,最后都是要生成文件显示的,所以这一步我们就要开始生成这些文件了
写了一个生成文件的函数👇
借用了
fs的writeFileSync同步写入文件的函数来实现
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
