为什么要开发一个脚手架?
因为有一个自己的脚手架真的是一件很酷的事情😉
# ❓ 什么是脚手架
在开发之前,先说一下脚手架是什么,它有什么功能?
简单来说,脚手架就是一个通过选择几个选项快速搭建项目基础代码的工具
在我看来,脚手架 最重要 的功能就是帮助你快速的把项目的基础架子搭好,比如项目依赖,模板,构建工具等等,让你不用从零开始自己手动配置一个项目,提升你的开发效率;同时,对于团队开发,可以保证团队项目管理的统一,比如文件目录架构,命名方式等等
说的更直接一点,脚手架省去了你在创建项目时不断 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