开始前先打个广子~
面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
Tongxx_yj
。
背景
什么是脚手架?
尽管目前社区上已有很多优秀的脚手架工具,手搓脚手架的相关文章也层出不穷,但我今天想要带来的是一个全新架构设计的脚手架:Create-Neat
(或称 cn)
Github:github.com/xun082/crea... (同学!点点 Star 呀!)
全新在哪里?我们通过了一套协议架构 来保证了插件 、框架 与构建工具 三个维度的自由组合,打破了指数型复杂度 带来的研发壁垒
我将会以这套架构设计为核心,介绍 cn 脚手架的实现,通过这篇文章,你将会了解到:
- 基本脚手架的实现方式
- 脚手架的技术壁垒
- 新型脚手架设计介绍
基本脚手架的实现方式
这一模块我并不会花太多的笔墨,只是讲解一下核心的实现逻辑,在以往的文章中,我也有做过相关的介绍:juejin.cn/post/736578...
基本定义与流程
在介绍基本脚手架的实现方式之前,我们先对核心概念做一个基本定义:
-
插件 :项目生成中可扩展的相关能力
-
构建工具:大家应该都知道,常见的有 webpack、vite......
-
框架:也很简单,Vue、React......
-
生成器 :我们将脚手架的逻辑内核称为生成器,这一步骤涉及到核心文件、配置的生成,我们简称为 Generator
-
预设:用户对预期项目的选项集合
在对齐基本定义之后,我们再来看一个流程图:
上图就是一个比较传统的脚手架的逻辑实现,核心分为四个阶段:
- 命令行交互:与用户"对话",获取用户的选项
- 初始化处理:基于用户选项进行相关的初始化操作,如基本文件的创建 、选项相关依赖注入等
- 生成器执行:基于用户选项进行核心文件的生成,比如用户选了 xx 插件,便会在生成器中调用对应的配置方法,将 xx 插件的相关配置、文件注入到目标文件夹中
- 收尾处理:最后,项目会因为配置的更新注入了新的依赖,而需要再安装一次依赖,与此同时需要我们输出一些总结信息,告知用户相关的结果
生成器逻辑简述
先说核心逻辑:在生成器中,Generator 会基于预设去读取对应插件、构建工具、框架所在的文件路径,并:
- 调用内部默认导出的一个方法
- 读取内部的一个 template 文件内容,加工处理后转移至产物文件
再回答几个问题:
-
对应的文件是什么:假设我们是一个 monorepo 的仓库结构,上述的逻辑都在一个 core 的子仓中,那像插件、构建工具、框架都会被放到一个个独立的子仓中,方便独立管理、发布
-
对应的文件有什么用 :存放了一些核心配置,分为:
- template 模版文件夹:存放了独立文件配置,如:.eslintrc、webpack.config.js
- 默认导出方法:通过执行这个方法,可以执行一些特殊操作,比如给 package.json 做配置更改
-
插件、构建工具、框架之间会有影响吗 :会!比如我们有一个
babel
插件,我们就需要在webpack
的配置文件里面注入一些内容 -
影响是如何实现的 :我们核心是通过一套模版渲染引擎(比如 EJS)实现,比如:
js// apps/webpack/template/webpack.config.js <% if (preset.babel === true) { %> { test: /.(js|jsx)$/, exclude: /node_modules/, use: { loader: 'babel-loader' } } <% } %> // apps/core/Generator.js const ejs = require('ejs'); const fs = require('fs'); // 读取模板文件 const template = fs.readFileSync('../webpack/template/webpack.config.js', 'utf8'); // 假设用户选择了 babel const preset = { babel: true }; // 渲染模板 const webpackConfigJs = ejs.render(template, data);
到这一步,全流程的基本实现都已经讲清楚了,,如果对更多的细节感兴趣,可以转战juejin.cn/post/736578... 进行深造~
脚手架的技术壁垒
壁垒在哪里
目前的脚手架存在什么问题?我们就聊一个最严重的:不够灵活
为什么说不够灵活?我们可以看看 vue-cli 都支持什么插件、构建工具以及框架
插件:11 个(pwa、babel、eslint、vuex......)
构建工具:1 个(webpack)
框架:2 个(vue2、vue3)【实际上就算一个框架】
在上一部分中,我们已经看到有些时候三者之间会有影响情况,如果用一个具体的图片来呈现,那将会是这样的一个效果:
实现效果也很简单,我们还是通过 EJS 一把唆:
- webpack 里面配置 EJS 语法实现11个插件、1个框架的内容注入
- vue2/3 里面配置 EJS 语法实现11个插件的内容注入
那如果面对这样的情况呢?
- webpack 里面配置 EJS 语法实现11个插件、2个框架的内容注入
- vite 里面配置 EJS 语法实现11个插件、2个框架的内容注入
- rollup 里面配置 EJS 语法实现11个插件、2个框架的内容注入
- vue 里面配置 EJS 语法实现11个插件的内容注入
- react 里面配置 EJS 语法实现11个插件的内容注入
我们看看复杂度的比对:
- 前者:11 + 1 + 11 = 23
- 后者:11 + 2 + 11 + 2 + 11 + 2 + 11 + 11 = 61
我们把这个指标定义为:
开发的复杂度 A = (插件 + 框架) * 构建工具 + 插件 * 框架
与此同时,我们再定义一个指标:项目的丰富度 B :插件 + 框架 + 构建工具
我们比对两个场景的数据差异:
指标 | 前者 | 后者 |
---|---|---|
A | 23 | 61 |
B | 12 | 16 |
可以看到 B 的差异很小,但是后者的 A 却大了很多很多
我们再来个夸张的,20 个插件 3 个框架 6 个构建工具
- A = (20 + 3)* 6 + 20 * 3 = 198
- B = 20 + 3 + 6 = 29
综合得出一个规律:项目丰富度的小幅提升会带来框架的较大幅度提升
结合这个规律,是否能解释目前脚手架的技术壁垒呢?
------ 支持的越多,复杂度就越高(超级难维护!)
事实上,这个规律还是不严谨的。在实际的开发过程中,插件与插件之间甚至也会产生影响,我们还需要给插件分成多类,来实现更好的维护,但这种情况下,整体的复杂度就越来越趋于指数性增长
额外的,这对开发者的需求迭代也是一个恐怖的挑战,比如:小明要让脚手架一个 vite 构建工具,他要面对的则是十几个插件、以及构建工具的影响......
壁垒导致的现状
为了避免这种问题,目前的脚手架生态大部分都局限于一种构建工具、一种框架
当然对于框架,我倒觉得没有什么毛病 ------ 大部分提供脚手架的都是对应框架方,也没必要为其他框架服务~
但如果我们想要一个比较理想的脚手架,支持:
- 基本支持目前社区上绝大多数的内容
- 有多种构建工具选择
这是一个非常困难的事情,那目前社区上有相关实现吗?有的兄弟,有的
那就是 yeoman
:相关学习参考 yowebapp.github.io/learning/
他是怎么解决这个问题的呢?
yeoman
提供了一套可以直接让我们定制化开发 Generator 的方法,通过发布模板至 npm 以调用
虽然完美解决了上述的问题,但最大的问题就是研发成本很高 !
那我们有没有办法解决这个研发成本问题呢?这就要谈及我们的新架构设计了!
新型脚手架设计介绍
核心目标
我们的核心目标:插件、框架、构建工具的影响,避免排列组合配置的情况出现
想法雏形
我们观察模版引擎渲染的实现,会发现一个规律:
xx 对 yy 的影响,会把影响内容放在 yy 的 template 文件里
举个例子:Babel 对 webpack 的影响,会在 webpack 的 template 文件里注入 EJS 语法
这种情况下,有什么问题?
------ 就算我们想通过一些抽象的方式来实现 webpack、vite 的 Babel 配置统一,我们还是得在两个不同的 template 文件里面注入 EJS 语法
那有什么解决方案呢?
------ 是不是可以把 xx 对 yy 的影响内容配置在 xx 里面,而不是通过模版引擎渲染来实现?
实现思路
基于上述的思路,我们看看怎么实现
目前最核心的,就是实现一套 "xx 影响 yy 内容" 的语法,在插件、框架 中配置相关的语法,经调用处理后,形成直接的配置影响,比如:
js
/**
* 框架受插件的影响处理
* @param {object} pluginAffects 插件带来的影响集合
* @param {string} template 当前框架
*/
const templateConfigGenerator = (pluginAffects, template) => {};
/**
* 构建工具受框架的影响处理
* @param {object} templateAffects 框架带来的影响集合
* @param {string} buildTool 当前构建工具
*/
const buildToolConfigGenerator = (templateAffects, buildTool) => {};
基于这个目标,我们拆解成两个要解决的方向:
xxxAffects
怎么写(或者说如何去描述这个结构体)- 函数内部怎么处理
xxxAffects
抽象协议的概念
这两个问题如同我们规范了一套网络协议:
A 发送网络请求 ------> B 接收网络请求 ------> B 提取请求头 ------> B 基于请求头做 xx 处理
我们也可以把 xxxAffects
的描述和处理定义为一套抽象协议,通过相关的处理方式去解读协议、处理协议,最终对目标文件产生影响
开发这套协议,我们需要对协议的特性来规定统一的认知:
-
特征:
- 协议分为协议和协议处理器两部分 ,对应的
xxxAffects
和xxxAffects
的处理函数 - 协议只在插件、框架中定义
- 协议分为协议和协议处理器两部分 ,对应的
-
性质:
- 通用性: 每一个协议都是一种操作的描述,能够适用于不同的插件、框架
- 抽象性: 我们应该将具体操作的实现细节抽象成配置项,便于扩展和维护
- 自由性: 面对一些复杂特化的场景,支持灵活的定制行为
初步探索
我们先拿一个场景讲明白,比如插件对框架的影响,我们需要考虑到:
-
插件对框架影响什么内容,比如入口文件、 变量注入与消费等
-
插件的影响应该是抽象的,不能具体基于某个框架做一个配置,对另一个也做一个配置,因为框架是不一样的,语法可能不同,但是影响的主题是一致的 ,协议描述这个抽象的事,协议处理器来处理这个抽象的事
让我们针对插件对 Vue 和 React 的影响进行详细分析,并设计一个抽象协议,确保它能够适应不同框架的特性而不依赖于具体实现,下面是一个简单的示意:
场景分析
我们结合实际场景,探索一下插件对框架的影响内容:
- 入口文件 (import) : 影响内容的入口文件配置,比如 less、sass 文件的引入
- 内容导出(export): 影响内容的导出,例如注入全局组件、导出高阶组件
HOC
- 环境变量注入: 插件可以影响框架的环境变量,特别是在开发和构建工具之间传递信息
- 业务代码更改: 插件可以改变框架的业务代码,比如给 react 返回的 jsx 内容包裹一层
<Router></Router>
- ...... 可能还有更多的协议,但前四条基本能 cover 全部的场景了
抽象协议的结构体设计
我们将设计一个抽象协议,描述插件对框架、构建工具的影响,比如以插件为基本单元:
js
const pluginImpactProtocol = {
pluginName: "scss", // 插件名称
version: "xxx", // 插件版本
affects: {
template: {
description: "影响框架入口文件配置",
changes: [
entry_file: {
description: "入口文件引入全局 scss 文件",
content: "import './styles/main.scss'", // 全局样式文件路径
}
],
priority: 1, // 优先级
},
buildTool: {
description: "影响构建工具配置",
changes: [
// ......
],
priority: 2,
},
},
};
这个结构体中,描述了 scss 插件对 template(框架)、buildTool(构建工具) 的影响,其中 template 中,我们通过 changes 数组,描述了影响操作:如 entry_file,描述了往入口文件插入一个内容:import './styles/main.scss'
那么 entry_file 就是一个协议,他的结构体描述了影响内容
那我们为什么会以插件为基本单元,通过 changes 数组传入一个个协议呢?
这要回归到我们的最基础设计思路:降低业务复杂度
回归我们的目标:对比 EJS 模版渲染,我们把 A 对 B 的影响,从写在 B 中,转移至了 A 中,实现了谁做的谁负责到底。
这种行为也保证了我们避免了背景中描述的问题,十分合理~
不过协议只做到了描述,我们怎么实现对内容的影响呢?这就需要实现对协议的处理了
协议处理器设计
接下来,我们需要设计一个协议处理器,用于根据协议生成具体的配置,比如我们写一个 entry_file
协议的处理器
js
const srcDir = path.resolve(__dirname, 'src'); // src 目录路径
const templateChanges = pluginImpactProtocol.affects.template.changes;
// 处理入口文件
if (templateChanges.entry_file) {
const entryFilePath = path.join(srcDir, "index.js"); // 假设入口文件为 index.js
let entryContent = fs.readFileSync(entryFilePath, "utf-8");
entryContent += templateChanges.entry_file.content;
// 文件重写,实现插入
// todo: 具体如何实现,其实很灵活,甚至可以借助 AST 进行
fs.writeFileSync(entryFilePath, entryContent, "utf-8");
}
可以理解为,我们在插件中注册了一个"回调函数",在脚手架运行内核逻辑时,会对一个个插件进行处理。此时我们会遍历脚手架中绑定的"回调函数"并执行
走到这一步,全流程就已经被打通了,那接下来需要面对的问题就是:
- 抽象所有场景可能存在的协议内容,制定协议
- 基于不同的协议,开发不同的协议处理器
插件的类型划分
在做协议内容的例举之前,我们先来仔细研究研究插件的类型
为什么需要明确插件的类型,而不去讨论框架和构建工具的划分?
- 框架、构建工具的扩展性是有限的,且相对同质化的
- 插件则是天马行空,存在很多方向,我们需要一个统一的划分原则,进行有效归类
大体上插件分为三类
- 第一类:基础设施类插件
这类插件的作用通常是提供基础的工具支持,不会直接影响框架的功能或构建流程的结构。它们通常只会修改配置文件或提供构建/开发时的基本支持,更多是作为开发环境的支撑工具
-
常见插件: Babel、ESLint、Prettier、Husky、SWC
-
特点:
- 无框架依赖性: 不直接与框架绑定
- 配置驱动: 通过配置文件来管理
- 功能局限性: 仅在构建流程或开发时发挥作用
- 第二类:通用框架影响类插件
这类插件通常影响的是特定开发文件的导入方式或配置 ,且这种影响不依赖于特定框架,它可以适用于多种框架(例如 Vue、React 等)
-
常见插件: SCSS、TypeScript
-
特点:
- 通过配置或代码注入的方式改变开发文件(如 JS、CSS)行为
- 不依赖于特定框架
- 第三类:特化框架影响类插件
这类插件需要在开发文件中直接引入并且影响框架本身的结构,例如,修改应用的路由结构、UI 组件等。
-
常见插件: Pinia、Mobx、VueRouter
-
特点:
- 通过修改框架的结构、引入特定的组件或模块来增强框架功能
- 需要在框架中明确指定或配置
- 第四类:构建工具扩展型插件
这类插件主要影响构建工具(如 Webpack、Vite 等),用于扩展或定制构建流程,通常涉及文件处理、模块解析、构建优化等
-
常见插件: PostCSS、Babel
-
特点:
- 影响 构建过程,包括文件打包、代码拆分等
- 通常在构建配置中定义和使用
当然,某个具体的插件可能会包含一类或几类特征,需要视具体情况而定
协议开发
明确好相关设计后,我们来大概设计一两个协议试试效果
【通用协议】ENTRY_FILE
协议描述
用于 xx 给 yy 添加入口文件,比如 scss 插件,需要在 vue 框架的 template/src/index.vue 中注入一个 import 语句
协议的注册
我们以 scss 为例,在 scss 的默认导出方法中注册协议:
js
const protocol = require("@/core/src/configs/protocol.ts");
const pluginToTemplateProtocol = protocol.pluginToTemplateProtocol;
/**
* scss 的默认导出方法,会被生成器调用
* @param generatorAPI 生成器暴露的一些 API,用于对产物内容进行操作
*/
module.exports = (generatorAPI) => {
// extendPackage 用于在 package.json 中注入依赖
generatorAPI.extendPackage({
devDependencies: {
scss: "^1.81.0", // todo: 暂时的版本
},
});
// protocolGenerate 用于执行指定的协议处理器
generatorAPI.protocolGenerate({
// 注册 ENTRY_FILE 协议
[pluginToTemplateProtocol.ENTRY_FILE]: {
// 入口文件引入全局 scss 文件
params: {
content: "import './styles/main.scss'", // 全局样式文件路径
},
priority: 1, // 优先级
},
});
};
在上述的逻辑中,Generator
执行到 scss 的导出方法后,就会执行 protocolGenerate
对应的逻辑,大致实现如下:
ts
protocolGenerate(protocols) {
// 统一定义协议所需参数
const props: ProtocolProps = {
preset: this.generator.getPreset(), // 用户所选的预设
buildToolConfigAst: this.generator.buildToolConfigAst, // 构建工具配置文件语法树
};
let api: ProtocolAPI = undefined;
// 此处会遍历调用的各个协议,并将 Generator 的数据(例如用户preset)传入协议处理器中去
// [xx]To[yy]API 内部注册了相关的协议处理函数
for (const protocol in protocols) {
if (protocol in pluginToTemplateProtocol) {
api = new PluginToTemplateAPI(protocols, props, protocol);
} else if (protocol in pluginToBuildToolProtocol) {
api = new PluginToBuildToolAPI(protocols, props, protocol);
} else if (protocol in templateToBuildToolProtocol) {
api = new TemplateToBuildToolAPI(protocols, props, protocol);
}
// 调用 [xx]To[yy]API 的协议处理函数
api.generator();
}
}
在对应的实例中,调用的实际逻辑如下:
js
// [xx]To[yy]API 内部的 generator
generator() {
const protocol = this.protocol;
const protocols = this.protocols;
// 执行目标协议处理器
this[protocol](protocols[protocol]); // 等价于 this.ENTRY_FILE(protocols['ENTRY_FILE'])
}
ENTRY_FILE() {
// ......
}
协议处理器
这一步就需要我们来完善 ENTRY_FILE
的实现,大致如下:
ts
ENTRY_FILE(params) {
const srcDir = path.resolve(import.meta.dirname, "src"); // src 目录路径
const content = params.content;
// 处理入口文件
if (content) {
const entryFilePath = path.join(srcDir, "index.js"); // 假设入口文件为 index.js
let entryContent = fs.readFileSync(entryFilePath, "utf-8");
entryContent += content;
// 文件重写,实现插入
fs.writeFileSync(entryFilePath, entryContent, "utf-8");
}
}
到这一步我们就顺利地实现了协议的注册和处理,最终实现了 scss 对 vue 的影响,整体流程如下:
【插件对框架】SLOT_INJECT
顾名思义:为插槽注入内容
当我们面对插件对框架的影响,如 mobx、react-router 等,会对框架的内容进行比较复杂的更改,这个时候用 EJS 或 AST 操作都有一定的缺陷:
-
EJS:EJS 的语法面对比较复杂的场景会显得非常复杂,导致可读性、可维护性非常差
-
比如:
html<% if (preset.reactRouter === true) { %> <Router history={history}> <% } %> <div>......</div> <% if (preset.reactRouter === true) { %> </Router> <% } %>
-
更复杂的:
html<% if (preset.reactRouter === true) { %> <Router history={history}> <% } %> <% if (preset.mobx === true) { %> <Provider store={store}> <% } %> <div>......</div> <% if (preset.mobx === true) { %> </Provider> <% } %> <% if (preset.reactRouter === true) { %> </Router> <% } %>
-
-
AST:实现成本高,且不好抽象(比如实现 xx 对 yy 影响时,插入指定的位置需要结合上下文判断,实现逻辑比较特化)
基于这种背景,我们需要设计一种协议来实现更加灵活的插入,且保证"工作重心"仍然维持在我们的插件中!
我们可以结合 EJS 的优势来设计一个插槽的体系:在模版文件中注册一个个插槽,通过 id 等唯一标识区分位置,并在协议传参中带入 id、content实现内容注入,实现如下:
插槽注册
我们通过一套自定义语法来描述传参:/* slot: <slot-name> */,在命中相关的 slot-name
之后,便会注入指定的内容,比如:
js
/* slot: router-start-slot */
<div>......</div>
/* slot: router-end-slot */
协议传参
我们在插件中定义相关的协议
js
module.exports = (generatorAPI) => {
generatorAPI.protocolGenerate({
[pluginToTemplateProtocol.SLOT_CONTENT_PROTOCOL]: {
params: {
slotConfig: [
{
url: "src/App",
slotName: "router-start-slot",
slotContent: "<Router>",
},
{
url: "src/App",
slotName: "router-end-slot",
slotContent: "</Router>",
},
],
},
},
});
};
通过 slotName
锁定插槽后,插入对应的 slotContent,实现最终的效果:
html
<Router>
<div>......</div>
</Router>
如果再复杂点,也不会影响可读性,相比之前的 EJS 描述要好了很多!
js
/* slot: router-start-slot */
/* slot: mobx-start-slot */
<div>......</div>
/* slot: mobx-end-slot */
/* slot: router-end-slot */
当然由于不同插件有着不同的特点,会有越来越多的协议需要实现,但在实现一定程度后,所有的插件都能基本被已有的协议覆盖,最终实现了有限的协议满足了几乎无限的插件!至此,脚手架在灵活性上的壁垒也基本得到了解决
协议开发深入
当然,协议的开发不可能仅此而已,实际的复杂度与功能抽象的要求使得很多方案难以实现,这时我们就需要借助一些强力的方案,比如:回调函数、AST 等
回调函数
当我们很难通过协议传参来配合协议处理器时,我们可以将回调函数作为参数,在协议处理器中执行,比如:
js
module.exports = (generatorAPI) => {
generatorAPI.protocolGenerate({
[pluginToTemplateProtocol.XXX_PROTOCOL]: {
params: {
func: (xx) => {
// 具体回调内容
}
},Ï
},
});
};
AST
AST 对于内容的修改是非常细粒度的,在我们的场景中,AST 可以实现在协议的处理器中,对于一些位置明确 、结构规范稳定 的协议是非常适用的
但面对上述两种情况以外时,我们应该尽可能地避免使用 AST,原因是:
- 位置不明确会导致 AST 难以定位上下文,使得难度给到了协议的传参规范中,但这往往是不可靠的
- 结构不规范、稳定,会让 AST 的处理复杂度大大提升,可能需要非常多的判断语句
面对这种场景,我们应该更多地去考虑拆分协议~
开发规范
保证协议顺利实现的同时,我们还需要约束一定的规范,保证我们的协议质量,下面是一个合理的协议开发结构:
最后
目前协议的开发还在推进过程中,因此并没有合入主分支,感兴趣的同学可以在 feat/generator-upgrade 分支了解源码
对于后续的发展方向,基本会围绕在几个主题:
- 协议的扩展
- 插件、构建工具的持续扩展
- 可视化
- ......
非常欢迎大家参与贡献 🎉