从零实现属于自己的前端脚手架
前端脚手架
是什么?
网络文库对脚手架的定义:
为了保证各施工过程顺利进行而搭设的工作平台
对于前端开发,前端脚手架是伴随前端工程化发展而产生的,通过选择几个选项快速搭建项目基础代码的工具。它可以有效避免我们ctrl + c/v
。
为什么
目前前端常见的脚手架: Vue CLI、Create-React-App、vite等等。
这些都是社区通用的脚手架解决方案。
假如我们需要定制化的脚手架,例如企业内部的脚手架,那社区通用脚手架很难满足我们的需求;例如:
- 内置公司内部工具依赖包
- 定制化
npm run
命令
所以,有必要了解脚手架的实现。
怎么办
接下来,我们以Vue框架为例,从零搭建属于自己的脚手架。
任务
- 解析命令行参数
- 提供可视化选项
- 提供多种模板: 页面工程、组件工程
命令行参数解析工具 minimist
minimist
是一个用来解析命令行选项的库。
轻巧美观人性化的命令行交互库prompts
const prompts = require('prompts');
(async () => {
const result = await prompts([
{
name: 'age',
type: 'text',
message: '今年贵庚?',
initial: '99'
},
{
name: 'name',
type: 'text',
message: '尊姓大名?',
initial: '鸡鸡鸡坤'
},
])
})();
定制化模板
提供多种模板,通过命令行交互界面,让用户自定义初始化项目,是非常有必要的。通常前端切图仔工程分为两种: 页面工程、组件工程。 前端工程一般都是 JS框架 + UI框架 + 工具 + 打包构建工具,例如:
- Vue + ElementUI + Axios... + webpack(vite)
- React + Antd + Axios... + webpack(vite)
接下来我以vue为例,来创建页面工程模板和组件工程模板(为了省去webpack的配置,我用VueCLI来创建工程模板)
页面工程模板
使用VueCLI创建页面工程,配置好.browserslistrc
、和相关的环境变量文件env
、env.*
等各种定制化配置。
"scripts": {
"dev": "vue-cli-service serve",
"build:dev": "vue-cli-service build --mode develop --no-module",
"build:test": "vue-cli-service build --mode release --no-module",
"build:pro": "vue-cli-service build --mode production --no-module",
"build:report": "vue-cli-service build --mode production --no-module --report",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint"
},
组件工程模板
同样使用VueCLI创建工程,但是需要改造一下目录结构和打包方式(这里不赘述)
组装
有了命令行解析、命令行交互和工程模板,接下来我们就将它们组装起来,做成脚手架。
初始化脚手架工程
npm init -y
初始化package.json
{
"name": "@ikun/create-project",
"version": "0.0.1",
"description": "鸡鸡鸡! 搞一个自己的脚手架工程",
"bin": {
"create-project": "index.js"
},
"scripts": {
"dev": "node index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"kolorist": "^1.6.0",
"minimist": "^1.2.6",
"prompts": "^2.4.2"
},
"devDependencies": {
"@types/node": "^18.7.18"
}
}
工程名称
我们的工程名:@ikun/create-project
,取create前缀是有讲究的:
后续使用脚手架时,我们希望和vite、create-react-app类似。
npm init react-app my-project
# or
npm init vite my-project
# or
npm create vite my-project
create
其实是init
的别名
npm init
命令除了可以用来创建 package.json 文件,还可以用来执行一个包的命令;它后面还可以接一个 <initializer>
参数。
参数initializer
是名为create-<initializer>
的 npm 包 ( 例如 create-vite ),执行npm init <initializer>
将会被转换为相应的npm exec
操作,即会使用npm exec
命令来运行create-<initializer>
包中对应命令create-<initializer>
(package.json
的bin
字段指定),例如:
npm init vite my-project
# 等同于
npm exec create-vite my-project
我们的脚手架最终的使用形式应该如下:
npm init @ikun/project my-project
模板
在根目录下创建templates
文件夹,用于存放我们的工程模板,供脚手架执行的时候拷贝到创建的工程文件夹中;这里我们将上面创建的页面工程模板和组件工程模板拷贝进来。
可执行文件
许多软件包都具有一个或多个要安装到PATH
中的可执行文件。
bin
字段是命令名到本地文件名的映射。在安装时npm
会将文件符号链接到prefix/bin
以进行全局安装或./node_modules/.bin/
本地安装。
当我们使用npm
或者yarn
命令安装包时,如果该包的package.json
文件有bin
字段,就会在node_modules
文件夹下面的.bin
目录中复制了bin
字段链接的执行文件。我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。
在根目录下创建index.js
可执行文件。其功能是集成minimist
、prompts
, 然后生成工程模板。
注意: 可执行文件需要在文件第一行开头写下 #!/usr/bin/env node(不赘述,实现原理我也不懂,看解释)
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
// 命令行参数解析
const minimist = require("minimist");
// 命令行交互
const prompts = require("prompts");
我们分析一下 npm init @ikun/project my-project --force
的流程
- 执行
@ikun/create-project的index.js
- 解析参数:
my-project
、force
; 文件夹名: my-project 、 是否覆盖已有文件夹:force - 显示命令行交互:
-
(1) 工程名称
-
(2)是否覆盖已有的文件夹
-
(3)定义packageName
-
(4)选择工程模板
- 生成工程目录文件
上述我们已经完成了第一步,创建了可执行文件index.js; 接下来需要在文件内实现解析参数:
// index.js
// ...省略代码
async function init() {
// --------- 解析参数start ----------
const argv = minimist(process.argv.slice(2), {
// 一些配置别名,本文档不涉及
alias: {
typescript: ["ts"],
"with-tests": ["tests"],
router: ["vue-router"]
},
boolean: true
});
// 获取要创建的文件夹名称
let targetDir = argv._[0];
// 不存在的话,默认'vue-project'
const defaultProjectName = !targetDir ? "vue-project" : targetDir;
// 是否强制覆盖当前重名的文件夹
const forceOverwrite = argv.force;
// --------- 解析参数 end ----------
}
获取了命令行参数之后,我们需要进行第3步,显示命令行交互界面了:
// ...省略代码
// 更改命令行文字颜色的插件
const { red, green, bold } = require("kolorist");
function getOption(name) {
const options = {
projectName: {
name: "projectName",
type: targetDir ? null : "text",
message: "工程名称:",
initial: defaultProjectName,
onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},
//是否覆盖已有的文件夹
shouldOverwrite: {
name: "shouldOverwrite",
// 判断目录是否为空, canSkipEmptying(下面实现)
type: () => (canSkipEmptying(targetDir) || forceOverwrite ? null : "confirm"),
message: () => {
const dirForPrompt =
targetDir === "." ? "Current directory" : `Target directory "${targetDir}"`;
return `${dirForPrompt} 已存在。 是否删除?`;
}
},
packageName: {
name: "packageName",
// isValidPackageName 判断package.name名称是否符合规范 (下面实现)
type: () => (isValidPackageName(targetDir) ? null : "text"),
message: "package name:",
// 默认值: 将文件夹名称转为可用的package.name; toValidPackageName(下面实现)
initial: () => toValidPackageName(targetDir),
validate: (dir) => isValidPackageName(dir) || "无效的package name"
},
projectType: {
type: "select",
name: "projectType",
message: "选择工程类型",
choices: [
{
title: "组件工程",
description: "以npm包/[微组件](篇幅有限,后续再开一篇新文件讲解微组件)的方式提供给业务侧使用",
value: "component"
},
{
title: "vue2单页工程",
description: "vue2单页应用",
value: "page"
}
],
initial: 0
}
};
return options[name]
}
asnyc function init() {
// --------- 解析参数start ----------
// ... 省略代码
// --------- 解析参数 end ----------
// --------- 命令行交互 start ---------
// result 用于存放用户的交互结果
let result = {};
try {
result = await prompts([
getOption('projectName'), // 交互命令的工程名称配置
getOption('shouldOverwrite'), // 是否覆盖的配置
getOption('packageName'), // packageName的配置
getOption('projectType'), // 工程类型的配置
],
{
onCancel: () => {
throw new Error(red("✖") + " 操作已推出");
}
})
} catch(cancelled) {
console.log(cancelled.message);
process.exit(1);
}
// --------- 命令行交互 end ---------
}
上面我们通过prompts创建了命令行交互界面,名提供了四个交互选项:
- 输入工程名称
- (非必要显示项)是否覆盖已有目录
- 输入packageName
- 选择创建的工程类型
其中我们会用到校验package.name
的方法isValidPackageName
和工程名转package.name
的方法toValidPackageName
。
// index.js
// 简单实现, 若想完整校验,可使用validate-npm-package-name库来检测
function isValidPackageName(projectName) {
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName);
}
function toValidPackageName(projectName) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/^[._]/, "")
.replace(/[^a-z0-9-~]+/g, "-");
}
另外还有一个canSkipEmptying
方法, 判断工程目录是否为空:
// index.js
function canSkipEmptying(dir) {
if (!fs.existsSync(dir)) {
return true;
}
const files = fs.readdirSync(dir);
if (files.length === 0) {
return true;
}
if (files.length === 1 && files[0] === ".git") {
return true;
}
return false;
}
完成上面的代码之后,我们就可以拿到用户最终想要生成模板的参数对象result
了,接下来我们实现第4步,生成工程文件:
// index.js
// ... 省略代码
async function init() {
// ... 省略代码
const {
projectName,
packageName = projectName ?? defaultProjectName,
shouldOverwrite = argv.force,
projectType = "component"
} = result;
const cwd = process.cwd();
// 获取要创建工程的绝对路径
const root = path.join(cwd, targetDir);
// 这里是真正判断是否要覆盖文件夹
if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root); // emptyDir清空文件夹后面实现
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root);
}
// 提示一下
console.log(`\n正在搭建工程 ${root}...`);
const pkg = { name: "@ikun/" + packageName, version: "0.0.0" };
fs.writeFileSync(path.resolve(root, "package.json"), JSON.stringify(pkg, null, 2));
// 生成对应模板
const templateRoot = path.resolve(__dirname, "templates");
const render = function render(templateName) {
// templateDir 是脚手架工程中的模板路径
const templateDir = path.resolve(templateRoot, templateName);
// 将脚手架工程中的模板复制到创建的工程目录中
renderTemplate(templateDir, root); // 生成模板文件夹及文件操作,下面实现
};
// 生成结束,良好地提示一下用户该怎么启动工程
const templateName = projectType;
render(templateName);
console.log(`\nDone. Now run:\n`);
if (root !== cwd) {
console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`);
console.log(` ${bold(green(`npm i`))}`);
console.log(` ${bold(green(`npm run dev`))}`);
}
}
// 执行初始化方法
init().catch((e) => {
console.error(e);
});
上面这段代码,就是将模板拷贝到目标目录上,其中我们调用了emptyDir
来清空目标目录,调用了renderTemplate
来将模板拷贝到目标目录上。接下来我们就来看下emptyDir
和renderTemplate
是如何实现的。
emptyDir
调用emptyDir
传入参数是绝对路径,我们需要先判断文件夹是否存在,存在的话,再清除文件夹内的文件和文件夹。
/**
* 清空文件夹
* @param dir 目标文件夹路径
*/
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return;
}
// 处理目录下的文件和文件夹
postOrderDirectoryTraverse(
dir,
(dir) => fs.rmdirSync(dir),
(file) => fs.unlinkSync(file)
);
}
/**
* 处理目录下的文件和文件夹
* @param dir 路径
* @param dirCallback 处理文件夹的操作
* @param fileCallback 处理文件的操作
*/
function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
for (const filename of fs.readdirSync(dir)) {
if (filename === ".git") {
continue;
}
// 文件/文件夹路径
const fullpath = path.resolve(dir, filename);
if (fs.lstatSync(fullpath).isDirectory()) {
// 若为文件夹,递归处理
postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback);
// 对文件夹进行操作
dirCallback(fullpath);
continue;
}
// 对文件进行操作
fileCallback(fullpath);
}
}
renderTemplate
调用renderTemplate
传入参数1: 脚手架工程内置的模板路径,传入参数2: 创建的工程目录路径
//复制模板
function renderTemplate(src, dest) {
const stats = fs.statSync(src);
/**
* 若是文件夹,且不是modules目录,则创建文件夹
* 再遍历文件夹下的内容,再递归处理文件和文件夹
*/
if (stats.isDirectory()) {
if (path.basename(src) === "node_modules") {
return;
}
fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(src)) {
renderTemplate(path.resolve(src, file), path.resolve(dest, file));
}
return;
}
const filename = path.basename(src);
/**
* 若是package.json文件已存在,则合并
*/
if (filename === "package.json" && fs.existsSync(dest)) {
// merge instead of overwriting
const existing = JSON.parse(fs.readFileSync(dest, "utf8"));
const newPackage = JSON.parse(fs.readFileSync(src, "utf8"));
const pkg = sortDependencies(deepMerge(existing, newPackage));
fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + "\n");
return;
}
// 有些文件会被git识别,需要特殊处理,例如.gitignore
if (filename.startsWith("_")) {
// rename `_file` to `.file`
dest = path.resolve(path.dirname(dest), filename.replace(/^_/, "."));
}
fs.copyFileSync(src, dest);
}
// 合并package文件的逻辑,不赘述,可以按照自己想要的方式实现,也可以不合并,直接覆盖
const isObject = (val) => val && typeof val === "object";
const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]));
function deepMerge(target, obj) {
for (const key of Object.keys(obj)) {
const oldVal = target[key];
const newVal = obj[key];
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
target[key] = mergeArrayWithDedupe(oldVal, newVal);
} else if (isObject(oldVal) && isObject(newVal)) {
target[key] = deepMerge(oldVal, newVal);
} else {
target[key] = newVal;
}
}
return target;
}
function sortDependencies(packageJson) {
const sorted = {};
const depTypes = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
for (const depType of depTypes) {
if (packageJson[depType]) {
sorted[depType] = {};
Object.keys(packageJson[depType])
.sort()
.forEach((name) => {
sorted[depType][name] = packageJson[depType][name];
});
}
}
return {
...packageJson,
...sorted
};
}
至此,一个脚手架基本完成,其实是缝合怪~~ , 让我们看看效果
没有输入工程名称的时候:
node .
当有输入工程名称的时候
node . my-project
当启用强制覆盖的时候
结语
以上算是一个简单脚手架该做的事,总结一下:处理参数 => 用户交互结果 => 拷贝对应内容
针对模板内容,篇幅原因,本文只是简单提及,后续可扩展例如多页配置、内置指令、利用githooks结合standard-version进行push的时候生成CHANGELOG
和发布npm包版本自动化、包括组件库模板预览README.md
、通过README.md
生成类似ElementUI组件文档等等。
觉得有用的切图哥哥们,请给只因弟弟一个赞~谢谢;
下一篇应该会讲如何将利用组件README.md
生成组件文档。期待一波~~~
若本文有哪里不对,请批评指正。
转载自:https://juejin.cn/post/7212175872091045943