1、前言
脚手架大家一定都不陌生,比如我们经常使用的 vue-cli、create-react-app,它可以帮助我们快速的初始化一个项目,无需从零配置,极大的方便我们的开发。到这里你可能会疑惑,既然市面上有成熟的脚手架,为什么需要写一个属于自己的脚手架呢。因为公共脚手架虽然强大,但并不能满足我们的实际开发需求。
例如项目中已有的沉淀,项目架构、接口请求的统一处理、换肤、业务组件、eslint配置等,这些想要用到新项目中,只能通过复制粘贴,会存在以下弊端:
重复性劳动,繁琐且浪费时间
已有项目沉淀分散在各处,很容易有所遗漏
项目间的配置差异很可能会被忽略
人工操作永远都有可能犯错,建新项目时,总要花时间去排错
如果我们自己开发一套脚手架,定制自己的模板,复制粘贴的人工流程就会转换为 cli 的自动化流程, 还可以通过维护不同的模板以适应不同业务需求。既然要开发一套脚手架,站在巨人肩膀上显然省事多了,我们先来看看业界知名脚手架Vue CLI是如何实现的。
Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统,提供:
通过 @vue/cli 实现的交互式的项目脚手架。
通过 @vue/cli + @vue/cli-service-global 实现的零配置原型开发。
一个运行时依赖 (@vue/cli-service),该依赖:
可升级;
基于 webpack 构建,并带有合理的默认配置;
可以通过项目内的配置文件进行配置;
可以通过插件进行扩展。
一个丰富的官方插件集合,集成了前端生态中最好的工具。
一套完全图形化的创建和管理 Vue.js 项目的用户界面。
以mac为例,使用命令 where vue,就可以查到 vue 命令所在位置,找到所在位置后,查看目录即可分析源码。
找到源码目录中的package.json,我们会看到如下代码:
可以看到bin字段指定了可执行文件的命令名以及可执行文件的路径,npm安装一个依赖时,如果该依赖的package.json中指定了bin的信息,那么同时会创建一个全局的软连接指向该命令所对应的可执行文件。详细可查看npm的官方文档:package.json中bin的使用说明。
包名 | 用途 |
commander | 完整的 node.js 命令行解决方案, Commander 负责将参数解析为选项和命令参数 |
shelljs | 用来执行shell命令 |
inquirer | 通用交互式命令行用户界面的集合 |
semver | 语义化版本控制 |
chalk | 设置终端字符串样式 |
html文件是一个会被 html-webpack-plugin 处理的模板。在构建过程中,资源链接会被自动注入。另外,Vue CLI 也会自动注入 resource hint (preload/prefetch、manifest 和图标链接 (当用到 PWA 插件时) 以及构建过程中处理的 JavaScript 和 CSS 文件的资源链接。
Vue CLI生成项目支持 PostCSS、CSS Modules 和包含 Sass、Less、Stylus 在内的预处理器,你可以在创建项目的时候选择预处理器。
Vue CLI基于 webpack 构建,并带有合理的默认配置,可以通过项目内的配置文件进行配置,还可以通过插件进行扩展。
模式是 Vue CLI 项目中一个重要的概念。默认情况下,一个 Vue CLI 项目有三个模式:
development 模式用于 vue-cli-service servetest 模式用于 vue-cli-service test:unitproduction 模式用于 vue-cli-service build 和 vue-cli-service test:e2e你可以通过传递 --mode 选项参数为命令行覆写默认的模式。
当你运行 vue-cli-service build 时,你可以通过 --target 选项指定不同的构建目标。它允许你将相同的源代码根据不同的用例生成不同的构建。
通过以上对Vue CLI 的分析,我们就对脚手架工具提供的构建集成能力有了一个大概的了解。这有助于我们在使用具体工具时快速定位问题的边界,当我们自己设计脚手架的时候,我们也可以参照和借鉴,可以适用于我们业务的有:
通过命令行与用户交互
根据用户的选择生成对应的文件,实现的零配置原型开发
需要做出修改的部分有:
基于 vite 构建,并带有合理的默认配置;
预定义业务模板,根据用户选择生成
业务模板基础支持:
HTML 和静态资源处理
内置css预处理器
内置vite配置,可以直接修改vite配置文件
内置test、pre、pro三种模式,并生成对应的配置文件
按照上述总结,让我们一步一步编写自己的脚手架吧,首先是通过命令行与用户交互,那么我们需要有一个可执行命令的名字,也是脚手架的名字,这里我们就叫做dt-fe-cli
我们的脚手架叫做dt-fe-cli,创建dt-fe-cli文件夹,执行npm init -y初始化仓库,生成package.json文件。
在dt-fe-cli文件夹下创建bin文件夹,并在里面创建cli.mjs文件,此文件作为我们脚手架的入口,需要将其配置到package.json的bin字段。
复制
{"name": "@auto/dt-fe-cli","version": "0.0.1","bin": {"dt-fe-cli": "bin/cli.mjs"}}
1.
2.
3.
4.
5.
6.
7.
这样我们脚手架的入口就有了,继续编写脚手架的功能吧
dt-fe-cli 作为全局命令,同时提供了很多指令。
dt-fe-cli --version可以查看 dt-fe-cli 版本
dt-fe-cli --help可以查看帮助文档
dt-fe-cli create xxx可以创建一个项目 ...
create接受一个项目名作为参数,这里还提供了额外选项-f, --force,此选项代表如果本地已经存在同名文件夹,是否覆写。命令行解决方案需要依赖第三方库commander
复制
import create from '../lib/create.mjs'program .command('create <app-name>') .description('create a new project powered by dt-fe-cli') .option("-f, --force", "overwrite target directory if it exists") .action((projectName, options) => {create(projectName, options) })
1.
2.
3.
4.
5.
6.
7.
8.
9.
执行create命令后,如何创建项目呢,我们公司的项目都是托管在内部gitlab上面的,所以直接使用git clone去拉取模板项目,这里需要依赖第三方库shelljs,那么这里就需要首先判断git是否存在,不存在提示并退出。之前我们还写了一个额外选项,用来表示如果本地已经存在同名文件夹,是否覆写。若没有此选项,还需要交互式的询问,这里需要依赖第三方库inquirer。
create.mjs:
复制
import chalk from 'chalk'import fse from 'fs-extra'import shelljs from 'shelljs'import path from 'path'import inquirer from 'inquirer'async function create(projectName, options) { const targetDirectory = path.join(process.cwd(), projectName) try {// 判断是否存在git,不存在则提示并退出 if (!shelljs.which('git')) { console(chalk.red('Sorry, dt-fe-cli requires git')); return}const isExist = await fse.pathExists(targetDirectory)// 判断目录下是否存在同名文件夹 if (isExist) { if (options.force) {await fse.remove(targetDirectory); } else {const { isOverwrite } = await new inquirer.prompt([ {name: "isOverwrite", // 与返回值对应 type: "list",message: "Target directory already exists. Pick an action:",choices: [ { name: "Overwrite", value: true }, { name: "Cancel", value: false },], },]);// 移除同名文件夹 if (isOverwrite) { console.log('remove existing directory...') await fse.remove(targetDirectory);} else { return;} }}// 项目类型 const { projectType } = await new inquirer.prompt([ {name: "projectType", // 与返回值对应 type: "list",message: "Please select project type:",choices: [ { name: "pc", value: 'pc' }, { name: "h5", value: 'h5' },], },]);const PROJECT_MAP = { pc: 'pc.git', h5: 'h5.git'}// 安装依赖项目 shelljs.exec(`git clone ${PROJECT_MAP[projectType]} ${projectName}`, async (code, stdout, stderr) => { if (code === 0) {progress.start()try { // 删除原有.git await fse.remove(path.join(process.cwd(), projectName, '.git'))} catch (error) { console.log(error)}progress.succeed()console.log(`\r\nSuccessfully created project ${chalk.cyan(projectName)}`);console.log(`\r\n cd ${chalk.cyan(projectName)}`);console.log(" git init");console.log(" pnpm install");console.log(" pnpm dev"); }}) } catch (error) {console.log(error); }}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
执行 create 命令后,创建项目会去 gitlab 拉取代码下载我们自定义的模版,目前我使用的模版均由 Vite3 创建,Vite3 需要 Node.js 版本 14.18+,16+。所以在使用脚手架时,可以先检查一下当前 Node.js 版本是否符合,不符合则抛出异常。当前依赖的 Node.js 版本需要将其配置到package.json的engines字段,判断当前 Node.js 版本是否符合需要依赖第三方库semver
package.json:
复制
{"engines": {"node": ">= 14.18.0"},}
1.
2.
3.
4.
5.
cli.mjs:
复制
import { readFile } from 'fs/promises'import semverSatisfies from 'semver/functions/satisfies.js'const { engines: { node: requiredVersion }, version } = JSON.parse( await readFile(new URL('../package.json', import.meta.url) ))function checkNodeVersion (wanted, id) { if (!semverSatisfies(process.version, wanted, { includePrerelease: true })) {console.log(chalk.red( 'You are using Node ' + process.version + ', but this version of ' + id + ' requires Node ' + wanted + '.\nPlease upgrade your Node version.'))process.exit(1) }}checkNodeVersion(requiredVersion, 'dt-fe-cli')
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
到这里,脚手架的基本功能就已经开发完毕了,剩下的就是我们的项目模板了
使用 Vite3 构建,Vite3 天然支持引入 .ts 文件
复制
const { execSync } = require('child_process');const { loadEnv } = require('vite')const env = process.argv[2]const { VITE_BASE_URL } = loadEnv(env, process.cwd(), '')const prefix = `${env}${VITE_BASE_URL}` execSync(`vite build --mode ${env} --base=https://cdn.com/${prefix}`);uploadCDN({ Dir: `dist/assets`, Prefix: prefix})
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
复制
npm install husky --save-devnpm pkg set scripts.prepare="husky install"npm run prepare npx husky add .husky/pre-commit "npm run lint"git add .husky/pre-commit
1.
2.
3.
4.
5.
ESLint通用配置的部分这里就不再赘述了,这里介绍一下我们业务里面自定义的ESLint插件。eslint校验大家都很熟悉,市面上也有很多eslint插件,但随着项目不断迭代发展,我们团队的编码规范使用现有的eslint插件已经无法满足了,需要自己创建插件,并融入到cli的模板当中。
创建插件
开始创建插件的最简单方法是使用 Yeoman 生成器。生成器将指导您设置插件的骨架
复制
npm i -g yo generator-eslint yo eslint:plugin
1.
2.
以上命令会生成如下目录
复制
. ├── README.md├── lib │ ├── index.js│ └── rules ├── package.json└── tests └── lib └── rules
1.
2.
3.
4.
5.
6.
7.
8.
9.
插件可以在 ESLint 中使用的额外规则。为此,插件必须导出一个包含规则 ID 到规则的键值映射的规则对象,举个简单的例子,我们想创建一条不允许使用console.log的规则
创建规则
复制
yo eslint:rule
1.
此命令会在lib/rules文件夹下创建一个新的js文件,一个规则对应一个可导出的 node 模块
复制
"use strict";//-------------------------------------------------------// Rule Definition//-------------------------------------------------------/** @type {import('eslint').Rule.RuleModule} */module.exports = { meta: {type: "suggestion",docs: { description: "disallow unnecessary semicolons", recommended: true, url: "https://eslint.org/docs/rules/no-extra-semi"},fixable: "code",schema: [] // no options }, create(context) {return { // callback functions}; }};
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
上面这段代码是一个规则的源码文件的基本格式,一个规则的源文件输出一个对象,它由 meta 和 create 两部分组成。
meta(对象)包含规则的元数据,如规则类型、文档、可接受参数的schema等等
create (function) 返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree 定义的 AST) 时,用来访问节点的方法。
核心其实在于create方法,我们若想知道如何编写create方法,首先要明白其原理,那就是 ESLint 是如何分析我们所编写的代码呢?相信大家对此也都有所了解,没错,就是AST (Abstract Syntax Tree(抽象语法树))
插件原理
ESLint 解析器将代码转换为 ESLint 可以评估的抽象语法树。默认情况下,ESLint 使用内置的 Espree 解析器,它与标准的 JavaScript 运行时和版本兼容,然后去拦截检测是否符合我们规定的书写方式,最后让其展示报错、警告或正常通过。ESLint 的核心就是规则(rules),而定义规则的核心就是利用 AST 来做校验,那就让我们看一下代码 AST 中会表现为什么样子。
上图可以看出,console.log对应 AST 中type为ExpressionStatement(表达式语句),表达式类型为CallExpression(调用表达式),被调用者类型为MemberExpression(成员表达式),被调用对象名为console,属性名为log,根据上述信息,我们就可以来完善create方法了
编写规则
复制
"use strict";//-------------------------------------------------------// Rule Definition//-------------------------------------------------------/** @type {import('eslint').Rule.RuleModule} */module.exports = { meta: {type: "suggestion",fixable: "code",schema: [], // no options }, create(context) {return { // key 是 selector 'CallExpression MemberExpression': (node) => {const { property, object } = node;// 如果在 AST 中匹配到了console.log,就用 context.report() 来发布警告或错误 if (object.name === 'console' && property.name === 'log') { context.report({node,message: 'console.log is forbidden.' });} }}; }};
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
至此,包含一条规则(禁止使用console.log)的 ESLint 插件就编写完成了,接下来将此项目发布到npm平台就可以在项目模板中下载使用了
本文介绍了如何从零编写一个我们自己的脚手架,并且可以根据不同业务场景区分模版,把业务已有的积累沉淀进去,以上便是本次分享的全部内容,希望对你有所帮助 ^_^
马春键
主机厂事业部,技术部
2021年加入汽车之家,目前任职于主机厂事业部-技术部-数科技术及系统团队-前端开发组,主要负责数科前端业务,前端前沿技术探索等工作