框架
谈到设计框架,很多人觉得大而空,有那个精力和能力吗,这不是为了造轮子而造轮子吗,纯纯 KPI 项目。是的,但是你别急,先听听我说的有没有道理。
首先得理解框架的本质。
框架,百度百科解释:分为框子和架子,框子指定其约束性;架子则指定其支撑性。是一个基本概念上的结构,用于去解决或者处理复杂的问题。
库是局部性的,黑盒性质,库导出一系列的方法,供调用者使用。
两者对比,库只是导出方法来解决问题,而框架则是存在约束性和支撑性。当然两者都是为了解决问题,不同之处在于框架解决的问题会更加复杂,需要依靠约束性和支撑性来解决。
比如 Vue.js 是一个渐进式框架 ,而 React 最新的文档上面,React 是一个库 。而以 React 为基础,像是 Remix、Next 这种则可以称之为框架。
什么是应用框架
框架知道了,那应用框架又是什么?
其实应用框架也是框架的一种,顾名思义,应用框架就是为了打造一个完整应用而生的框架。对于前端来说,应用框架可能包含了以下这些功能:
- 脚手架
- 开发服务器
- 打包构建工具
- 服务端渲染
- 代码规范格式化工具
- 请求代理
- 路由
- 权限
- 状态管理
- 国际化
- 等等...
看起来应用框架好像比框架更加简单一些,不会有那些虚拟 DOM、Diff 算法、并发渲染优先级之类的复杂概念。甚至于你平时将常用的 Webpack 配置封装到你的项目中,那也可以算是一种"应用框架"。当然也有复杂的应用框架,比如 Next.js,它号称提供生产环境所需的所有功能以及最佳的开发体验。Next.js 称自己为 React 框架,个人觉得它也可以是应用框架,它包含了完备的前端开发功能。
应用框架的优劣势
这么说起来,应用框架包含了完整的功能,这不比那些只有部分功能的框架要强吗,那为啥不是所有的公司都用呢?
任何技术都是为了解决实际问题,但是肯定不存在一种技术能解决所有问题。应用框架虽然包含完整功能,但是这个完整也是相对性的。比如 Next.js,它更适用于 SSR,你 CSR 使用它就不合适了。再比如号称替代 Next.js 的 Remix,它更适合做全栈开发的项目,它内置了 React-Router,如果你更熟悉这个路由库的话。
所以应用框架也是为了解决某种场景下的问题而存在的,不可避免的问题就是,为了集成某些功能,框架往往会把功能封装到内部,再导出给外部使用,这样你就会失去了解这个功能的机会,一旦出现了问题,要么依赖文档依赖社区,要么就需要自己去查阅源码。
所以选择应用框架一定要慎重,尽量选择那些成熟的框架,因为他们踩坑少,遇到问题更容易解决。当然你可以自己去设计一个应用框架,这样你或者是团队就是最了解这个框架的,自然也不会有上述的问题。
需要用到应用框架吗
回到源头上来,你是否真的需要应用框架?问一问自己以下这些问题?
- 是不是企业级的项目?
- 项目技术栈是否复杂?
- 项目的依赖是不是经常需要升级或者更新?
- 是不是存在多个这样的项目?
第一点,应用框架通常用于企业级项目,如果是个人项目,用不用在于自己,这个不是问题。
余下几点,以我自己公司的业务为例,我们是微前端 qiankun 的架构,微应用采用的都是 Create-React-App 那一套开发的,虽然非常方便,一键开发和打包。但是每次如果有新的微应用,那么得去老项目上copy一整套的东西,并且这些文件往往分散在好几个地方,不花费点功夫,一时半会真不好找到。
然后因为以前老的微应用,采用的 antd 版本是3.x,后面的微应用用了 4.x,两个版本写法很不一样,当有新的需求涉及了两个微应用,就需要有两种写法,看两个版本的文档,这开发效率太低了。
最后随着项目规模越来越大,微应用启动的速度也越来越慢,可能需要几分钟才能打开。还有就是我们需要添加工具库,比如 ahooks,那所有的项目都需要执行一遍 npm install 的命令。
那为什么要自己设计呢?市面上就没有开源的应用框架满足需求吗?
有,但不能完全满足需求。随着项目规模不断膨胀,需求的不断增长,开源的应用框架功能肯定是越来越无法满足需求的。
如何设计
前面花了大半的篇幅来说明应用框架,以及为什么要设计一个应用框架。磨刀不误砍柴工,你得完全清楚你是否需要这样做,这样做的目的和目标是什么?
接下来我结合自身的经验,来说一说如何设计,或者也可以说封装,一个自己的应用框架。当然因为业务或者是前端知识的缺乏,肯定会存在很多不足之处,我也只是抛砖引玉,给大家提供一个解决问题的思路而已。
注意:以下的设计思路不止用于框架设计,任何解决现实问题的思路,都可以去套用这种思路。
确定目标
第一步就是确定目标,应用框架想要达到什么样的目标,这是我们后续开发的指导。一般而言,目标都是基于需求背景来提出的,以上面提到的一些开发痛点问题为例,可以总结得到以下目标:
- 统一代码规范、提交规范、项目结构规范。
- React + TS 的项目开箱即用。
- 收敛项目的配置。
- 保证项目的灵活性和扩展性。
拆解需求
确定好目标以后,需要根据目标来确定好需求,如果需求比较多,那就需要对需求进行拆解,一步一步去完成需求,而不是一股脑的完成所有的需求。拆分需求更利于对整体需求的把控。
根据以上的目标,通常我们需要完成以下几个需求:
- 代码规范的制定,ESlint + StyleLint + Prettier。
- 项目模板的制定-通过脚手架来创建项目模板。
- 开箱即用的 React + TS 环境,包括开发服务器、打包、测试。
- 插件系统,用于扩展应用的技术栈-保证项目的灵活性和扩展性。
需求分析
需求已经拆解出来,接下来要做的就是对需求进行分析,从而得出该采用什么样的方式来组织这些需求,使之成为一个完整的框架功能。并且采用哪些技术栈能完成我们的需求。
首先为了保证项目的灵活性和扩展性,肯定需要采取 monorepo 的架构,每个包只负责一个功能,这样无论后期增加、修改还是删除,并不会影响其他的包,这也体现了单一职责原则。并且这种架构,使框架的颗粒度更细,也有助于编写单元测试,来测试每个包的功能。
另外对于技术栈来说,以上需求可能需要的一些库,包括代码规范工具,eslint、stylelint、prettier,命令行交互库,prompts,commands等,一套完整的环境,包括 webpack、webpack-dev-server、webpack 的 loaders 和 plugins。测试则可能需要 jest,React 单元测试则需要 @testing-library 的一些库。
还有的重中之重就是插件系统,这里不用费力去想一套完美无缺的系统,可以直接参考 webpack 或者是 rolup 的插件系统。所谓插件就是在框架运行时,去做一些操作,或者修改一些配置,来达到修改最终执行代码的目的。这里可以考虑使用 webpack 插件的核心库 tapable,利用它提供的发布订阅模式,在特定时机执行插件,来修改最终执行代码结果。
最后关于收敛配置,前端通常的做法就是将配置都提取到一个配置文件中去,比如 Vite 的 vite.config.js,或者是 next.config.js。我们也可以将所有配置放入框架的配置文件中,执行的时候去读取配置文件。
这里为了方便描述,我们将设计的框架就叫做 kernel(内核),那么配置文件就是 kernel.config.js。
逻辑设计
逻辑设计阶段,表示具体要实现哪些功能,将需求阶段的设想都转化为具体的功能。
- 配置文件设计
配置文件 kernel.config.js 以开发环境和生产环境配置为主,加上一些常用的配置项,比如 publicPath 或者是 alias,具体可以结合自己业务需要来设计,最后不要忘记了插件的配置项。这里给一个简单的配置项接口:
typescript
interface UserConfig {
publiPath?: string; // 资源公共路径配置
chainWebpack?: { (config: Configuration): Configuration }; // webpack 配置
alias?: Record<string, string>; // 路径别名设置
port?: number; // 开发环境下的服务器端口
host?: string; // 开发环境下的主机名
proxy?: Record<string, any>; // 开发环境下的代理配置
lessOptions?: Record<string, any>; // 用于 less 的配置项
plugins?: any[]; // 插件配置,暂时这么设置
}
- 代码规范设计
首先最简单的就是代码规范的设计,将 eslint、stylelint 以及 prettier 的配置确定好以后,新建一个 eslint-config-kernel 的包,后期就可以引用这个配置了。
这个包主要包含了三个配置文件:.eslintrc.js
、.stylelintrc.js
以及 .prettierc
。记得包名需要按照 eslint-config-xxx 的形式来写,这样在项目中配置 eslint 的时候,可以直接继承该配置,而 stylelint 则可以直接引入该包:
js
module.exports = require('eslint-config-externel/.stylelintrc.js');
当然你可以把 stylelint 单独拿出来再设计一个包,如果你们对 CSS 的要求比较高的话。同理 prettier 也可以这么设计。
- 脚手架设计
然后就是脚手架的建立,这个也很简单,网上有很多例子。我们的目标就是确定好项目模板的规范,执行命令,拿到参数,解析参数,根据参数产出对应的模板。这个脚手架的包名,可以以 create-* 命名,这样就可以在不用下载包的情况下来创建模板。
考虑到以后的扩展,可以预设两个模版:极简模版、业务模版。极简模版可以用来快速创建测试项目,业务模版则是业务中需要使用的。
其次,脚手架添加 --template
命令,用于指定使用哪个模版。还需要有 --git
命令,用于初始化 git 仓库。当然这些也可以通过交互式的问答命令来实现,这里推荐使用 propmts 这个库来实现。
一个完整的脚手架的命令可能是这样的:
sh
npm create [scafold name] [project name] --template [template name] --git
- React 运行环境设计
接着就是构建 React 运行环境。
项目如果采用是 webpack,那这个包就以 webpack 为核心,webpack 的配置就使用你们常用的配置就可以了。问题是如果使用的不是 webpack,可能是 vite 呢?
所以最好的做法,就是将环境这块的技术栈抽出来成为一个单独的包,这样不论你使用的是 webpack、vite 或者是其他的工具,只需要替换这个包就可以实现了,增加了框架的灵活性。
如果成为一个单独的包,那么就需要一个统一的接口,比如,该包需要导出 dev 和 build 的 API,用于给其他包使用该方法。以 webpack 为例,传入参数的接口如下:
typescript
interface Params {
env: 'development' | 'production'; // 区分环境
cwd: string; // 设置工作目录
chainWebpack?: { (config: Configuration): Configuration }; // 用于修改 webpack 的配置
babelPreset?: any; // 设置 babel 插件,用于 antd、lodash 按需加载
entry?: Record<string, string>; // 将入口文件配置抽离出来,方便直接设置
cache?: boolean; // 对于 webpack5,开启缓存设置
port?: number; // 开发环境下的服务器端口
host?: string; // 开发环境下的主机名
}
然后需要设计一个包,也就是对外暴露的构建命令包 kernel。它需要完成一系列的命令:
- kernel dev
- kernel build
- kernel test
- kernel lint
- kernel verify-commit
- kernel version
- kernel help
命令还是比较多的,如果将这些命令全部都放入 kernel 包中实现,不可避免的这个包体积会非常巨大,并且日后还有其他命令的话,则这个包还会膨胀,日后维护肯定是一个大问题。那该如何做呢?
我们可以考虑将以上的所有命令都当作插件注入到 kernel 包中,kernel 本身只负责解析参数,然后执行这些插件来装载命令。所以 kernel 包看起来可能是这样的:
typescript
class Service {
/**
* 初始化
* @param env 环境变量 development production test
* @param cwd 项目的工作目录
* @param configFiles 指定配置文件路径
* @param plugins 指定插件
*/
constructor(opts: {
env: string;
cwd: string;
configFiles?: any;
plugins?: any[];
}) {};
/**
* 运行命令
* @param name 运行命令的名称
* @param args 命令的其他参数
*/
run(opts: { name: string; args?: any }) {};
}
这样 kernel 负责解析命令参数,启动进程的工作,命令具体的实现就交给插件来做。但是还有一个问题,就是如果需要新增插件的话,则需要往 Service 这个类中再传入一个插件。我们就是因为不想改动这个包,才把插件提取出来的,现在还是要改,肯定不是我们想要的结果。
解决方法也很简单,将这些命令都合并到一起,形成预设
。就类似 babel 中的预设概念,大白话就是一个插件中返回了其他插件,这个插件就是预设。这么做 kernel 只需要引入一个预设,以后插件修改,我们也只需要更新预设这个包就可以了。
于是,又拆分出来一个预设 presets 包。至此,kernel 包包含的内容减少了很多。
这里漏掉了一个细节,就是插件的执行机制还耦合在 kernel 中,既要负责解析参数,初始化,又要负责插件的运行逻辑,这显然是不合理的。所以可以将插件的逻辑拆分出来为单独的一个包 core,来专门负责整个运行机制,它的类型可能是这样的:
typescript
class CoreService {
appData: {}; // 用于存储应用的信息,比如版本,依赖这些,用于终端输出信息
/**
* 构造函数
* @param framework 用于自定义输出到终端的框架名称
* @param env 省略
* @param cwd 省略
* @param plugins 插件
* @param presets 预设
*/
constructor(opts: { framework?: string; env: string; cwd: string; plugins?: any[]; presets?: [] }) {};
/**
* 命令执行方法
* @param opts name为执行的命令,args 为执行命令的参数
*/
run (opts: { name: string; args?: any }) {};
/**
* 解析预设,拿到预设中的所有插件
* @param presets 预设
*/
initPresets (opts: { presets: string[] }) {};
/**
* 解析插件,执行插件
* @param plugin 插件路径
*/
initPlugins(opts: { plugin: string[] }) {}
}
这样 kernel 包中的 Service 类可以继承 CoreService:
ts
class Service extends CoreService {
run2(opts: { name: string; args?: any }) {
this.run();
};
}
这样 kernel 包就遵循了单一指责原则,便于后期维护。
- 插件包设计
最后我们还是需要有一个 plugins 插件包,用来将常用的技术栈封装成插件,比如使用 qiankun 的话,可以将子应用导出的生命周期方法放到插件中执行,这样业务开发就不需要每次去写这些样板代码了。
到这里,一整个框架雏形就已经出来了。我画了一个简单的示意图:
整体的流程就是 kernel 载入配置文件,解析参数,初始化进程,执行 CoreService,运行插件和预设,拿到最终的配置项以后传递给 bundler,启动开发服务器或者是打包构建等命令。最后 utils 包就是提供公共方法给其他包来使用。
发布/部署设计
整体的逻辑设计已经完成,但是还缺少了最后一步,发布流程。可能会有人觉得发布不就是 npm publish
不就完事了,但是我们目前有6个包,每次都需要更新版本号,然后再 npm publish,这太效率太低了,还是交给 Node 脚本来做。
这个脚本可以按照以下流程来做:
解释一下就是,使用 Node 读取 packages 目录下的所有包,依次检查版本号,更新版本号,打 tag,提交推送到仓库。这个时候,有两个选择,可以利用各种 pipeline 工具来进行 build 所有包,然后 publish,也可以直接在脚本中做这些。当然会更推荐使用 pipeline 构建,防止有的时候没有 build 就直接发布了。
这里所有的包的版本号都保持一致,因为实际上它们都是属于应用框架的一部分,因此它们的包名可以是@workspace/packageName 这种形式,但是脚手架的包名不能是这种形式,原因上面也提到过,就不再赘述了。
后话
这样一套简陋的应用框架设计就出来了,有了解的同学可能也看出来了,这其实就是应用框架 Umi 的一个设计思路,它的微内核的架构设计,非常适合我们在它的基础上封装出一套属于自己公司业务的框架。
个人浅见,希望对各位有所启发。