设计实现一个让你爱莫释手的"万能”脚手架
前言:持续极致化提升开发体验和开发效率是前端进阶和架构演进的必修课之一,主要是把诸多大前端领域中的从0到1的复杂的工程化实践抽离出来,接入自动化流程,脚手架就是其中一种方式。
常用脚手架的类型:
- Vue/React 框架类脚手架
- Webpack/Vite/Rollup等构建配置类脚手架
- 混合脚手架,比如大家熟悉的Vue-cli/Create-react-app/Vite-cli等等
命令行工具是脚手架的雏形,首先实现一个命令行工具
先来看几个开发命令行工具的关键依赖。
inquirer、enquirer、prompts:可以处理复杂的用户输入,完成命令行输入交互。
chalk、kleur:使终端可以输出彩色信息文案。
ora:可以让命令行出现好看的 Spinners。
boxen:可以在命令行中画出 Boxes 区块。
listr:可以在命令行中画出进度列表。
meow、arg:可以进行基础的命令行参数解析。
commander、yargs:可以进行更加复杂的命令行参数解析。
启动命令行项目
mkdir create-project
cd create-project
npm init --yes
进入create-project
文件中,创建src
目录及src/cli.js
文件,cli.js
文件内容如下:
export function cli(args) {
console.log(args);
}
为了使我们的命令行可以在终端执行,我们新建bin/
目录,并在其下创建一个create-project
js文件
#!/usr/bin/env node require = require('esm')(module /*, options*/); require('../src/cli').cli(process.argv);
esm
模块的作用是让我们可以使用import关键字,支持 ESM 模块规范
先安装,执行npm install esm
此时 package.json 内容如下:
{
"name": "@lucas/create-project",
"version": "1.0.0",
"description": "A CLI to bootstrap my new projects",
"main": "src/index.js",
"bin": {
"@lucas/create-project": "bin/create-project",
"create-project": "bin/create-project"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cli",
"create-project"
],
"author": "YOUR_AUTHOR",
"license": "MIT",
"dependencies": {
"esm": "^3.1.0"
}
}
bin
字段,我们注册了两个可用命令:一个是带有 npm 命名 scope 的,一个是常规的create-project
命令。
为了调试方便,我们使用npm link
命令进行调试,在终端中项目目录下执行上述命令可以在全局范围内添加一个软链到当前项目中,也就是注册全局指令create-project和@lucas/create-project。执行:
create-project --yes
得到如下的输出
[ '/usr/local/Cellar/node/11.6.0/bin/node', '/Users/dkundel/dev/create-project/bin/create-project', '--yes' ]
该输出,就对应了下面的代码中的process.argv
。
解析处理命令行输入
首先设计命令行支持的几个选项,如下。
[template]
:支持默认的几种模板类型,用户可以通过 select 进行选择。--git
:等同于git init
去创建一个新的 Git 项目。--install
:支持自动下载项目依赖。--yes
:跳过命令行交互,直接使用默认配置。
使用inquirer
使得命令行支持用户交互,同时使用arg
来解析命令行参数,
npm install inquirer arg
接下来编写命令行参数解析逻辑,在cli.js
中添加:
import arg from 'arg';
// 解析命令行参数为 options
function parseArgumentsIntoOptions(rawArgs) {
// 使用 arg 进行解析
const args = arg(
{
'--git': Boolean,
'--yes': Boolean,
'--install': Boolean,
'-g': '--git',
'-y': '--yes',
'-i': '--install',
},
{
argv: rawArgs.slice(2),
}
);
return {
skipPrompts: args['--yes'] || false,
git: args['--git'] || false,
template: args._[0],
runInstall: args['--install'] || false,
}
}
export function cli(args) {
// 获取命令行配置
let options = parseArgumentsIntoOptions(args);
console.log(options);
}
接下来,我们实现使用默认配置和交互式配置选择逻辑,如下:
import arg from "arg"
import inquirer from "inquirer"
import { createProject } from "./main.js"
// export function cli(args) {
// console.log("test-your-bin")
// console.log(args)
// }
// 解析命令行参数为 options
function parseArgumentsIntoOptions(rawArgs) {
// 使用 arg 进行解析
const args = arg(
{
"--git": Boolean,
"--yes": Boolean,
"--install": Boolean,
"--remote": Boolean,
"-g": "--git",
"-y": "--yes",
"-i": "--install",
"-r": "remote", // 使用此参数以选择从远程模板仓库下载模板, 否则从本地模板目录下载模板
},
{
argv: rawArgs.slice(2),
}
)
return {
skipPrompts: args["--yes"] || false,
git: args["--git"] || false,
template: args._[0],
runInstall: args["--install"] || false,
isRemote: args["--remote"] || false,
}
}
async function promptForMissingOptions(options) {
// 启用默认模板,根据options.isRemote参数确认默认模板值及启用不同选择项
const defaultTemplateLocal = "Vite-Vue3-JavaScript"
const defaultTemplateRomote = "webpack"
const defaultTemplate = !options.isRemote
? defaultTemplateLocal
: defaultTemplateRomote
// 使用默认模板则直接返回
if (options.skipPrompts) {
return {
...options,
template: options.template || defaultTemplate,
}
}
// 准备交互式问题
const questions = []
if (!options.template) {
questions.push({
type: "list",
name: "template",
message: "Please choose which project template to use",
// 根据options.isRemote参数分别切入远程模板下载流程/本地模板下载流程
choices: !options.isRemote
? ["Vite-Vue3-JavaScript", "Vite-Vue3-TypeScript"]
: ["webpack", "rollup"],
default: defaultTemplate,
})
}
if (!options.git) {
questions.push({
type: "confirm",
name: "git",
message: "Initialize a git repository?",
default: false,
})
}
// 使用 inquirer 进行交互式查询,并获取用户答案选项
const answers = await inquirer.prompt(questions)
return {
...options,
template: options.template || answers.template,
git: options.git || answers.git,
}
}
export async function cli(args) {
// 获取命令行配置选项/options
let options = parseArgumentsIntoOptions(args)
// 交互式进一步获取配置选项
options = await promptForMissingOptions(options)
// 下载模板
await createProject(options)
console.log(options)
}
其中,使用 --remote 命令行参数以选择从远程模板仓库下载模板, 否则从本地模板目录下载模板
这样一来,我们就可以获取到类似:
{ skipPrompts: false, git: false, template: 'JavaScript', runInstall: false }
相关的配置了。
下面我们需要完成下载模板的逻辑,我们事先准备好两种名为Vite-Vue3-JavaScript
和Vite-Vue3-TypeScript
的模板,并将相关的模板存储在项目的根目录中。当然你在实际开发应用中,可以内置更多的模板。
我们使用ncp
包实现跨平台递归拷贝文件,使用chalk
做个性化输出。安装相关依赖如下:
npm install ncp chalk
import chalk from "chalk"
import fs from "fs"
import ncp from "ncp"
import path from "path"
import { promisify } from "util"
const access = promisify(fs.access)
const copy = promisify(ncp)
// 递归拷贝文件
async function copyTemplateFiles(options) {
return copy(options.templateDirectory, options.targetDirectory, {
clobber: false,
})
}
export async function createProject(options) {
options = {
...options,
targetDirectory: options.targetDirectory || process.cwd(),
}
// 如果从本地下载模板, 检查模板是否存在
if (!options.isRemote) {
const templateDir = path.resolve(
__dirname,
"../templates",
options.template.toLowerCase()
)
options.templateDirectory = templateDir
try {
// 判断模板是否存在
// fs.constants.R_OK -- 文件可被调用进程读取
await access(templateDir, fs.constants.R_OK)
} catch (err) {
// 模板不存在
console.error("%s Invalid template name", chalk.red.bold("ERROR"))
// node.js中使用process.exit(integer)来中断进程, integer非0
process.exit(1)
}
}
// 拷贝模板
await copyTemplateFiles(options)
console.log("%s Project ready", chalk.green.bold("DONE"))
return true
}
上述代码我们通过fs.constants.R_OK
判断对应模板是否存在。此时cli.js
关键内容为:
import arg from 'arg';
import inquirer from 'inquirer';
import { createProject } from './main';
function parseArgumentsIntoOptions(rawArgs) {
// ...
}
async function promptForMissingOptions(options) {
// ...
}
export async function cli(args) {
// 获取命令行配置选项/options
let options = parseArgumentsIntoOptions(args)
// 交互式进一步获取配置选项
options = await promptForMissingOptions(options)
// 下载模板
await createProject(options)
console.log(options)
}
接下来,我们需要完成git
的初始化以及依赖安装工作,这时候需要用到以下内容。
execa
:允许开发中使用类似git
的外部命令。pkg-install
:用于编程式安装依赖包。listr
:给出当前进度 progress。
执行安装依赖:
npm install execa pkg-install listr
更新main.js
为:
import chalk from "chalk"
import fs from "fs"
import ncp from "ncp"
import path from "path"
import { promisify } from "util"
import execa from "execa"
import Listr from "listr"
import { projectInstall } from "pkg-install"
const download = require("download-git-repo")
// 访问文件
const access = promisify(fs.access)
// 跨平台递归拷贝文件
const copy = promisify(ncp)
// 拷贝模板(递归拷贝文件)
async function copyTemplateFiles(options) {
return copy(options.templateDirectory, options.targetDirectory, {
clobber: false,
})
}
function downloadTemplate(options) {
return new Promise((resolve, reject) => {
const APP_TYPE_REPOSITORY_MAP = {
webpack: "alienzhou/webpack-kickoff-template",
rollup: "alienzhou/rollup-kickoff-template",
}
const template = APP_TYPE_REPOSITORY_MAP[options.template]
download(template, options.targetDirectory, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
// 从远程模板仓库下载模板
async function downloadTemplateFiles(options) {
return await downloadTemplate(options)
}
// 初始化 git
async function initGit(options) {
// 执行 git init
const result = await execa("git", ["init"], {
// 在选项中指定的目录中进行git初始化
cwd: options.targetDirectory,
})
if (result.failed) {
return Promise.reject(new Error("Failed to initialize git"))
}
// return true
return
}
// 创建项目
export async function createProject(options) {
options = {
...options,
targetDirectory: options.targetDirectory || process.cwd(),
}
// 如果从本地下载模板, 检查模板是否存在
if (!options.isRemote) {
const templateDir = path.resolve(
__dirname,
"../templates",
options.template.toLowerCase()
)
options.templateDirectory = templateDir
try {
// 判断模板是否存在
// fs.constants.R_OK -- 文件可被调用进程读取
await access(templateDir, fs.constants.R_OK)
} catch (err) {
// 模板不存在
console.error("%s Invalid template name", chalk.red.bold("ERROR"))
// node.js中使用process.exit(integer)来中断进程, integer非0
process.exit(1)
}
}
// 声明 tasks
const tasks = new Listr([
{
title: "Copy project files",
// 拷贝模板, 根据options.isRemote参数选择从本地下载模板或者从远程模板仓库下载模板
task: () =>
!options.isRemote
? copyTemplateFiles(options)
: downloadTemplateFiles(options),
},
{
title: "Initialize git",
// 初始化git
task: () => initGit(options),
enabled: () => options.git,
},
{
title: "Install dependencies",
task: () =>
// 编程式安装依赖
projectInstall({
cwd: options.targetDirectory,
}),
// 手动安装依赖
skip: () =>
!options.runInstall
? "Pass --install to automatically install dependencies"
: undefined,
},
])
// 并行执行 tasks
await tasks.run()
console.log("%s Project ready", chalk.green.bold("DONE"))
return true
}
这样一来,我们的命令行就大功告成了。以上的代码逻辑实现了从远程下载模板和本地下载模板两种路径, 源码在我的create-project代码仓库上面, 大家有兴趣的可以clone下来体验下。
其中,使用远程模板并自动安装依赖的指令为:
create-project --remote --install
在电脑上的任意位置新建一个要快速初始化的项目文件夹,给它起个名字,比如叫test-remote, 在命令行工具中打开此文件夹,运行上面的命令。
对应的用户界面为:
使用本地模板并自动安装依赖的指令为:
create-project --install
对应的用户界面为:
这样,一个精简的脚手架工具就完成了。脚手架工具的本质是下载特定模板,大多数情况下,我们可以将我们平时经常用到的高度定制化的模板单独维护到 GitHub 仓库当中,在创建一个项目时,我们使用 download-git-repo来下载远程模板。
其中,我们可以集成统一的eslint规范配置、babel规范配置等配置文件,让我们从繁琐的工程化配置中脱离出来,提高开发效率和开发体验。包含但不限于以下方面:
-
通用的Webpack配置(vue cli 3x 以上是vue.config.js)
-
统一的Eslint 校验规则:如Airbnb、eslint-plugin-vue等(eslintConfig)
-
统一的单元测试框架配置:单元测试覆盖率、测试的目录等
-
统一的Dockerfile和jenkinsfile (用来打包成镜像和部署流水线定义)
-
统一babel的配置(.babelrc或babel.config.js)
-
统一的常量配置(缓存字段等等)
-
不同环境的配置文件(development、test、production)
以上其实也是在企业级应用中前端基建的重要组成部分。 关于 eslint,这里也同步推荐一个非常好用的脚手架工具eslint-config-standard。这是一个github明星工程,目前已经有2.5k以上的star了。全局安装之后可以根据提示生成一个适合你当前应用的eslint配置文件到你的项目中去,相当于是一个eslint最佳实践了,当然你也可以根据实际的需求再做微调。单元测试框架使用jest框架就好了,CICD(持续集成、持续交付和持续部署)可以参考这篇文章 -- 前端项目自动化部署——超详细教程(Jenkins、Github Actions)。
设计与实现一个万能脚手架
对比大家熟悉的vue-cli
、create-react-app
、@tarojs/cli
、umi
、vite-cli
等,我们做的显然还不够,我们还需要从可伸缩性、用户友好性方面考虑:
- 如何使模板支持版本管理
- 模板如何进行扩展
- 如何进行版本检查和更新
- 如何自定义构建
-
模板支持版本管理:可以使用 npm 维护模板,这样借助 npm 的版本管理,我们可以天然地支持不同版本的模板。当然在脚手架的设计中,要加入对版本的选择和处理。
-
模板如何进行扩展:可以借助中心化手段,集成开发者力量,提供模板市场
-
版本检查可以使用 npm view @common/create-project version/versions来进行版本检查,并根据环境版本,提示用户更新。
-
自定义构建是一个老大难问题,不同项目的构建需求是不同的。不同构建脚本可以考虑单独抽象,并提供可插拔式封装。比如jslib-base这个库的设计,也是一个“万能脚手架”。
总的来说,对于一个企业级脚手架来说,路漫漫其修远兮,需要持续打磨和优化,不断提升企业生产效率和开发体验。
具体来看,使用脚手架初始化一个项目的过程,本质上是根据用户输入信息下载模板并进行模板填充。比如,如果开发者选择使用 TypeScript 以及英语环境构建项目,并使用 rollup 进行构建。那么核心流程中在初始化 rollup.config.js 文件时,我们读取 rollup.js.tmpl,并将相关信息(比如对 TypeScript 的编译)填写到模板中。
类似的情况还有初始化 .eslintrc.ts.json、package.json、CHANGELOG.en.md、README.en.md,以及 doc.en.md 等。
所有这些文件的生成过程都需要可插拔,更理想的是,这些插件是一个独立的运行时。因此我们可以将每一个脚手架文件(即模板文件)的初始化视作一个独立的应用,由命令行统一指挥调度。
比如 jslib-base 这个库对于 rollup 构建的处理,支持开发者传入 option,由命令行处理函数,结合不同的配置版本进行自定义分配。具体代码如下:
const path = require("path")
const util = require("@js-lib/util")
function init(cmdPath, name, option) {
// type 为 js 和 ts 两种
const type = option.type
// module 分为:umd/esm/commonjs
const module = (option.module = option.module.reduce(
(prev, name) => ((prev[name] = name), prev),
{}
))
// rollup 基本配置
util.copyTmpl(
path.resolve(_dirname, `./template/${type}/rollup.js.tmpl`),
path.resolve(cmdPath, name, "config/rollup.js"),
option
)
// umd 模式
if (module.umd) {
util.copyFile(
path.resolve(_dirname, `./template/${type}/rollup.config.aio.js`),
path.resolve(cmdPath, name, "config/rollup.config.aio.js")
)
}
// esm 模式
if (module.esm) {
util.copyFile(
path.resolve(_dirname, `./template/${type}/rollup.config.esm.js`),
path.resolve(cmdPath, name, "config/rollup.config.esm.js")
)
}
// commonjs 模式
if (module.commonjs) {
util.copyFile(
path.resolve(_dirname, `./template/${type}/rollup.config.js`),
path.resolve(cmdPath, name, "config/rollup.config.js")
)
}
}
如上代码,根据用户输入,使用了不同版本的 rollup 构建内容。
相信你了解了这些内容,对于实现一个自己的 create-react-app、vue-cli 会更有心得和启发。更多细节可以参看这篇 脚手架架构设计和框架搭建
使用脚手架框架
如果你想更快地定制一个自己的脚手架,还可以选择使用脚手架框架。
主要介绍两款脚手架框架:
-
Yeoman,上面有上万个已经定义好的脚手架,如果适合你的应用的,可以直接选择使用,或者按照Yeoman的规范,自己制作一个脚手架,总体来说,是比较简单方便的。具体可以参考 🛠如何快速开发一个自己的项目脚手架? 和 原理解析:如何实现自己的脚手架工具? 这两篇文章的介绍。
-
Lerna ,主要用于解决和管理应用中包含多个软件包(package)的情形。
总结:我们从开发一个命令行工具入手,分析了实现一个脚手架的方方面面。实现一个企业级脚手架需要不断打磨和优化,不断增强用户体验和可操作性。更重要的是,对构建逻辑的抽象和封装,根据业务需求,不断扩展命令和模板。 我们需要把重复劳动抽离出来,时间应该放在更有价值和意义,更有挑战的事情上!
最后,如果对你有所帮助,顺手点个赞呗(^▽^)
转载自:https://juejin.cn/post/7241184271317811261