一、什么是前端工程化?
前端工程化就是通过各种工具和技术,提升前端效率、降低成本的过程。这句话有两个含义:
-
前端工程化的内容:各种工具和技术
-
前端工程化的作用:通过使用工具,提升开发效率,降低成本
二、为啥需要自建脚手架工具
有同学可能就有疑问了,现在不是有现成的脚手架工具吗?为啥还要来玩这种骚操作,用来装逼吗?
我想诚实的跟你说,不排除有装逼的成分在,但也确实能解决实际问题,提升效率,譬如:公司搭建好了一个项目雏形,集成了公司特定的业务交互、逻辑,想把它作为前端项目的模板。如果你想到的是ctrl+c ctrl+v那就low了哦,通过脚手架来处理不仅方便快捷,还不容易出错。咋样?是不是找到卷的意义了!
我们看上图脚手架工具分专用脚手架和通用脚手架,文章都会一一详细介绍!
三、搭建自定义脚手架
专用脚手架主要是为了满足特殊需求,比如vue、react的脚手架,他们有些独特的配置需求,通用的脚手架无法满足需求,所以他们建立了自己的脚手架。下面且听我慢慢讲解关于自定义脚手架的知识
3.1 原理
脚手架的原理其实很简单,主要是利用node以及node搭建的工具来实现,无非就是两步:
- 收集用户自定义需求
- 根据用户的需求将模板项目写入目标的目录
3.2 回顾一下我们用vue-cli是怎么用的
# 安装
npm install -g @vue/cli
# or
yarn global add @vue/cli
# 创建项目
vue create hello-world
以上步骤就是我们使用vue-cli的大致流程了,我们也大致会按照这个流程来,但不会跟vue-cli完全一样!
3.3 开始搭建自定义脚手架
目标:实现
vtmp-cli
3.3.1 项目初始化
mkdir my-vtmp-cli
cd my-vtmp-cli
npm init # 生成 package.json 文件
3.3.2 新建程序入口 bin/index.js
#! /usr/bin/env node
console.log("hello world")
3.3.3 package.json设置bin入口
{
"name": "my-vtmp-cli",
"version": "1.0.0",
"description": "",
"main": "cli.js",
"bin": {
// 手动添加入口文件为 index.js
"vtmp-cli": "bin/index.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
3.3.4 项目目录结构
my-vtmp-cli
├─ bin
└─index.js
└─ package.json
3.3.5 npm link 链接到全局
npm link
执行完成
测试命令
到这里最初的脚手架雏形就搭好了!!!
3.4 搭建执行命令
bin/index.js
#! /usr/bin/env node
const program = require("commander");
const pkg = require('../package.json')
program
.version(`${pkg.version}`)
.command("create <app-name>")
.option("-f, --force", "overwrite target directory if it exist") // 是否强制创建,当文件夹已经存在
.description("create a new project")
.action((name, options) => {
console.log(name, options)
});
program.parse(process.argv);
注意:每次更改代码后,都需要重新link
命令执行完在action回调内打印命令参数
3.5 询问用户需求
接着上一步搭建好的command,在执行回调内调用inquirer用户询问
# bin/index.js
...
program
.version(`${pkg.version}`)
.command("create <app-name>")
.option("-f, --force", "overwrite target directory if it exist") // 是否强制创建,当文件夹已经存在
.description("create a new project")
.action((name, options) => {
console.log(name, options)
// 在 create.js 中执行创建任务
createtor(name, options);
});
...
输出目标目录的前期校验
// 创建文件 lib/create.js
const path = require('path')
const fs = require("fs-extra");
const chalk = require("chalk");
const Generator = require('./build')
// 询问用户输入
const inquirer = require('inquirer')
// 暴露出去的方法 name options 是command传过来的参数
module.exports = async (name, options) => {
const cwd = process.cwd();
// 输出文件的目标目录
const destDir = path.join(cwd, name);
// 判断目标目标是否已经存在
if (fs.existsSync(destDir)) {
// 是否强制创建,force 代表是否强制执行
if (options.force) {
await fs.remove(destDir);
} else {
// 询问用户是否确定要覆盖
let { action } = await inquirer.prompt([
{
name: "action",
type: "list",
message: "Target directory already exists Pick an action:",
choices: [
{
name: "Overwrite",
value: "overwrite",
},
{
name: "Cancel",
value: false,
},
],
},
]);
if (!action) {
return;
} else if (action === "overwrite") {
// 移除已存在的目录
console.log(chalk.hex("#ff8800").bold(`\r\nRemoving...`));
await fs.remove(destDir);
console.log(chalk.hex("#ff8800").bold(`\r\ninquirer start...`));
}
}
}
// 以上解决了目标目录是否存在的问题,如果存在就移除
// 接下来真正的进入用户询问阶段
// 将项目名、输出目标目录往下传
const createtor = new Generator(name, destDir);
createtor.create()
};
将模板读取并写入用户的目录
// 创建文件 lib/build.js
const ejs = require("ejs");
const path = require("path");
const fs = require("fs-extra"); // 文件操作相关
const chalk = require("chalk"); // 日志美化
const inquirer = require("inquirer");
const ora = require("ora"); // loading样式
const spawn = require("cross-spawn"); // 命令行执行工具
class Generator {
constructor(name, destDir) {
this.name = name;
this.destDir = destDir;
}
// 核心代码
_copyFile(tmplDir, destDir, res, rootPath, callback) {
fs.readdir(tmplDir, (err, files) => {
if (err) throw err;
let count = 0;
// 计数法来判断是否遍历完成
const checkEnd = function () {
++count == files.length && callback();
};
files.forEach((file) => {
const filePath = path.join(tmplDir, file);
// 当前路径的状态
const stat = fs.statSync(filePath);
// 文件类型
if (stat.isFile()) {
// 相对路径
const relativePath = path.relative(rootPath, filePath);
// 输出的绝对路径
const outputAbsolutePath = path.join(destDir, relativePath);
// 这里有个注意的点,如果是当前文件需要接受用户传递的参数,就用`ejs`模板引擎来处理
if (
relativePath === "package.json" ||
relativePath === "index.html"
) {
ejs
.renderFile(filePath, res)
.then((response) => {
const outputDir = path.dirname(outputAbsolutePath);
if (!fs.existsSync(outputDir)) {
// 判断文件夹是否存在,不存在就创建
fs.ensureDirSync(outputDir);
// console.log(chalk.cyan(`made directories, starting with ${made}`));
}
// 文件输出
fs.writeFileSync(outputAbsolutePath, response);
// console.log(chalk.hex("#67c23a").bold(`create ---> ${outputAbsolutePath}`));
})
.catch((err) => {
console.log(chalk.red(err));
});
} else {
// 直接执行 读取 写入 操作
fs.readFile(filePath, "utf-8", (err, resp) => {
if (err) throw err;
const outputDir = path.dirname(outputAbsolutePath);
if (!fs.existsSync(outputDir)) {
// 判断文件夹是否存在,不存在就创建
fs.ensureDirSync(outputDir);
// console.log(chalk.cyan(`made directories, starting with ${made}`));
}
fs.writeFileSync(outputAbsolutePath, resp);
// console.log(chalk.hex("#67c23a").bold(`create ---> ${outputAbsolutePath}`));
});
}
// 执行计数方法
checkEnd();
} else {
// 文件夹类型递归
this._copyFile(filePath, destDir, res, rootPath, checkEnd);
}
});
});
}
async create() {
const name = this.name;
const destDir = this.destDir;
// 创建目标目录
await fs.ensureDirSync(destDir);
// inquirer 询问
inquirer
.prompt([
{
type: "input",
name: "namespace",
message: "Please input your project namespace, such as @tencent:",
default: "",
},
{
type: "input",
name: "description",
message: "Please input project description:",
default: "vue3 project template",
},
{
type: "input",
name: "author",
message: "Author's Name",
default: "",
},
{
type: "input",
name: "email",
message: "Author's Email",
default: "",
},
{
type: "input",
name: "license",
message: "License",
default: "MIT",
},
])
.then((res) => {
// inquirer 执行完之后的回调
const params = {
fullName: res.namespace ? `${res.namespace}/${name}` : name,
name,
...res,
};
// 模板所在的目录
const tmplDir = path.join(__dirname, "../templates");
const rootPath = tmplDir;
console.log(chalk.hex("#ff8800").bold(`\r\ncreate file start...`));
// 开始复制文件
const timeStart = new Date().getTime();
const spinner = ora("Downloading...");
const installing = ora("Installing...");
spinner.start()
// 模板下载
this._copyFile(tmplDir, destDir, params, rootPath, async() => {
// 模板下载回调
spinner.succeed(chalk.hex('#67c23a').bold(`template download finished! 耗时${new Date().getTime() - timeStart}ms`));
// 开始安装依赖
const istamp = new Date();
installing.start()
// 执行依赖安装
const result = spawn("npm", ["install"], { cwd: destDir });
// 打印安装依赖的日志
result.stdout.on("data", (buffer) => {
process.stdout.write(chalk.hex("#67c23a").bold(buffer));
});
// 监听依赖安装状态
result.on('close', (code) => {
if (code !== 0) {
console.log(chalk.red('Error occurred while installing dependencies!'));
process.exit(1);
} else {
console.log(chalk.hex("#67c23a").bold(`\r\nInstall finished 耗时${new Date() - istamp}ms`));
}
installing.stop()
console.log(chalk.hex("#ff8800").bold(`\r\ncd ${name}`));
console.log(chalk.hex("#ff8800").bold("\rnpm run dev"));
})
});
});
}
}
module.exports = Generator;
项目目录结构
搭建完成后执行的日志输出
四、脚手架相关工具库
| 名称 | 简介 |
|---|---|
| commander | 命令行自定义指令 |
| inquirer | 命令行询问用户问题,记录回答结果 |
| chalk | 控制台输出内容样式美化 |
| ora | 控制台 loading 样式 |
| figlet | 控制台打印 logo |
| easy-table | 控制台输出表格 |
| download-git-repo | 下载远程模版 |
| fs-extra | 系统fs模块的扩展,提供了更多便利的 API,并继承了fs模块的 API |
| cross-spawn | 支持跨平台调用系统上的命令 |
五、利用通用脚手架实现自己的脚手架 Yeoman
Yeoman是一个通用脚手架系统,允许创建任何类型的应用程序.它允许快速开始新项目并简化现有项目的维护.Yeoman与语言无关.它可以使用任何语言(Web,Java,Python,C#等)生成项目.Yeoman本身不做任何决定.每个决定都是由生成器决定的,生成器基本上是Yeoman环境中的插件.有很多公开可用的生成器,它很容易创建新的生成器以匹配任何工作流程.Yeoman始终是满足您脚手架需求的正确选择
5.1 准备工作
- 安装nodejs
yo是Yeoman的命令行工具包,需要进行全局安装:
npm install -g yo
yo --version // 查看版本
- 安装
Yeoman生成器generator
npm install -g generator-generator
5.2 初始化generator项目目录
文件夹名必须为generator-name,name是你创建的generator的名字.以generator-app-vuets为例.
注:文件夹名称必须以generator- 为前缀,否则执行 yo xxx 初始化项目时会无法找到你的项目模块 具体操作
yo generator
# 之后跟着工具的询问填写自己的信息就OK
执行示例
初始化完成输出的项目结构
5.3 index.js文件编写
yeoman 提供了一个基本生成器,你可以扩展它以实现自己的行为.这个基础生成器将帮你减轻大部分工作量. 在生成器的 index.js 文件中,以下是扩展基本生成器的方法:
var Generator = require("yeoman-generator");
module.exports = class extends Generator {}
yeoman 生命周期函数执行顺序如下:
initializing- 初始化函数prompting- 接收用户输入阶段configuring- 保存配置信息和文件default- 执行自定义函数writing- 生成项目目录结构阶段conflicts- 统一处理冲突,如要生成的文件已经存在是否覆盖等处理install- 安装依赖阶段end- 生成器结束阶段 我们常用的就是initializing,prompting,default,writing,install这四种生命周期函数. 重点完成三个方法的定制,分别是:prompting,writing,install
prompting方法: 主要是来完成和用户交互的,交互的用户输入信息都放在prompts数组中:
name: 用户输入项的标识,在获取用户输入值的时候会用到message: 是给用户的提示信息type: 非必填,默认是text,即让用户输入文本;confirm是选择输入“Yes/No"default: 非必填,用户输入的默认值 writing方法: 需要重点关注四个方法:this.templatePath:返回template目录下文件的地址this.destinationPath:指定加工完成后文件的存放地址,一般是项目目录this.fs.copy:把文件从一个目录复制到另一个目录,一般是从template目录复制到你所指定的项目目录,用于固定文件和可选文件(根据用户选择)this.fs.copyTpl:和上面的函数作用一样,不过会事先经过模板引擎的处理,一般用来根据用户输入处理加工文件
install方法:
install() {
//this.installDependencies(); // 安装npm依赖和bower依赖
//this.bowerInstall(); // 只安装bower依赖
this.npmInstall(); // 只安装npm组件
}
5.4 脚手架调试
执行npm link
npm link
调试执行 yo 或 yo + generator包名
5.5 发布npm
npm publish
当你遇到困难时不要灰心,按照这个自检清单逐一排查:
1.检查是否登录了npm (npm whoami)
2.你是否有多个账号记混了,退出重新登录
3.你注册号时所填的邮箱是否被验证了,如果没有刷新页面点击顶部通知栏重发验证邮件
4.检查你的包名是否已被占用
5.检查你的版本号是否是用过的版本号
6.检查你的npm源是否是npm官方源(npm config list)
六、一个小而美的脚手架工具 plop
Plop是一个小而美的脚手架工具,它主要用于创建项目中特定类型的文件,Plop主要集成在项目中使用,帮助我们快速生成一定规范的初始模板文件。比如我们去书写Vue的组件,如果每次都是手动去写组件的初始内容,会比较繁琐,有可能会出错,且每个人写的规范可能都不一样。这时候我们就可以用Plop来创建规范的Vue组件的初始内容,方便快捷,易于管理且规范。
plop:https://www.npmjs.com/package/plop
6.1 安装
yarn add plop --dev
安装完成后在项目根目录下创建 plopfile.js 文件
const promptDirectory = require('inquirer-directory')
const pageGenerator = require('./template/page/prompt')
const apisGenerator = require('./template/apis/prompt')
module.exports = function (plop) {
plop.setPrompt('directory', promptDirectory)
plop.setGenerator('page', pageGenerator)
plop.setGenerator('apis', apisGenerator)
}
6.2 创建模板文件
位置 /template/page/
index.hbs
<template>
<section>
</section>
</template>
<script>
export default {
name: '{{ name }}',
data() {
return {}
},
methods: {
}
}
</script>
prompt.js
const path = require('path')
const notEmpty = (name) => {
return (v) => {
if (!v || v.trim === '') {
return `${name} is required`
} else {
return true
}
}
}
module.exports = {
description: 'generate vue template',
prompts: [
{
type: 'directory',
name: 'from',
message: 'Please select the file storage address',
basePath: path.join(__dirname, '../../src/views')
},
{
type: 'input',
name: 'fileName',
message: 'file name',
// 表单校验
validate: notEmpty('fileName')
},
{
type: 'input',
name: 'name',
message: 'name',
validate: notEmpty('name')
},
// 当存在多选的情况可以使用checkbox类型
//{
// type: 'checkbox',
// name: 'types',
// message: 'api types',
// choices: () => {
// return ['create', 'update', 'get', 'delete', 'check', 'fetchList', 'fetchPage'].map((type) => ({
// name: type,
// value: type,
// checked: true
// }))
// }
//}
],
actions: (data) => {
const { fileName, name, from } = data
const filePath = path.join('src/views', from, fileName + '.vue')
const actions = [
{
type: 'add',
path: filePath,
templateFile: 'template/page/index.hbs',
data: { name }
}
]
return actions
}
}
6.3 示例
module.exports = plop => {
// setGenerator可以设置一个生成器,每个生成器都可用于生成特定的文件
// 接收两个参数,生成器的名称和配置选项
plop.setGenerator('component', {
// 生成器的描述
description: 'create a component',
// 发起命令行询问
prompts: [{
// 类型
type: 'input',
// 接收变量的参数
name: 'name',
// 询问提示信息
message: 'component name',
// 默认值
default: 'MyComponent'
}],
// 完成命令行后执行的操作,每个对象都是动作对象
actions: [{
// 动作类型
type: 'add',
// 生成文件的输出路径
path: 'src/components/{{name}}/{{name}}.vue',
// template 模板的文件路径,目录下的文件遵循hbs的语法规则
templateFile: 'plop-templates/component.vue.hbs'
}]
})
}
6.4 setGenerator
plop对象下有一个setGenerator方法,这个方法用于创建一个生成器,主要就是帮助我们去生成特定的文件,setGenerator接收两个参数name和option
- name:是该生成器的名称,也是执行该生成器的命令
- option:是一个对象,是生成器的配置选项
执行生成器
下面该示例就是执行我们创建的生成器component
option、description
生成器的描述信息
6.5 prompts
将来生成器工作时发起的询问,它是一个数组,每个对象都是一次询问
prompts: [{
// 类型
type: 'input',
// 用于接收用户输入参数的变量名,将来也会使用到hbs模板中
name: 'name',
// 询问提示信息
message: 'component name',
// 默认值
default: 'MyComponent'
}],
6.6 actions
这是命令行询问结束后执行的操作,它同样是一个对象数组,每个对象都是一个动作对象。
actions: [{
// 动作类型
type: 'add',
// 生成文件的输出路径
path: 'src/components/{{name}}/{{name}}.vue',
// template 一般放置再根目录的plop-templates目录下,下面的文件遵循hbs的语法规则
templateFile: 'plop-templates/component.vue.hbs'
}]