从零实现属于自己的前端脚手架2023-03-21 02:03:04

前端脚手架
是什么?
网络文库对脚手架的定义:

为了保证各施工过程顺利进行而搭设的工作平台

对于前端开发,前端脚手架是伴随前端工程化发展而产生的,通过选择几个选项快速搭建项目基础代码的工具。它可以有效避免我们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是名为create-的 npm 包 ( 例如 create-vite ),执行npm init 将会被转换为相应的npm exec操作,即会使用npm exec命令来运行create-包中对应命令create-(package.json的bin字段指定),例如:
npm init vite my-project

等同于

npm exec create-vite my-project
复制代码
我们的脚手架最终的使用形式应该如下:
npm init @ikun/project my-project
复制代码
模板
在根目录下创建templates文件夹,用于存放我们的工程模板,供脚手架执行的时候拷贝到创建的工程文件夹中;这里我们将上面创建的页面工程模板和组件工程模板拷贝进来。

可执行文件
bin属性的官方解释
许多软件包都具有一个或多个要安装到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传入参数是绝对路径,我们需要先判断文件夹是否存在,存在的话,再清除文件夹内的文件和文件夹。
/**

/**

renderTemplate

调用renderTemplate传入参数1: 脚手架工程内置的模板路径,传入参数2: 创建的工程目录路径
//复制模板
function renderTemplate(src, dest) {
const stats = fs.statSync(src);

/**

// 合并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生成组件文档。期待一波~~~
若本文有哪里不对,请批评指正。

« »