如何设计一个应用框架

框架

谈到设计框架,很多人觉得大而空,有那个精力和能力吗,这不是为了造轮子而造轮子吗,纯纯 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,如果你更熟悉这个路由库的话。

所以应用框架也是为了解决某种场景下的问题而存在的,不可避免的问题就是,为了集成某些功能,框架往往会把功能封装到内部,再导出给外部使用,这样你就会失去了解这个功能的机会,一旦出现了问题,要么依赖文档依赖社区,要么就需要自己去查阅源码。

所以选择应用框架一定要慎重,尽量选择那些成熟的框架,因为他们踩坑少,遇到问题更容易解决。当然你可以自己去设计一个应用框架,这样你或者是团队就是最了解这个框架的,自然也不会有上述的问题。

需要用到应用框架吗

回到源头上来,你是否真的需要应用框架?问一问自己以下这些问题?

  1. 是不是企业级的项目?
  2. 项目技术栈是否复杂?
  3. 项目的依赖是不是经常需要升级或者更新?
  4. 是不是存在多个这样的项目?

第一点,应用框架通常用于企业级项目,如果是个人项目,用不用在于自己,这个不是问题。

余下几点,以我自己公司的业务为例,我们是微前端 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。

逻辑设计

逻辑设计阶段,表示具体要实现哪些功能,将需求阶段的设想都转化为具体的功能。

  1. 配置文件设计

配置文件 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[]; // 插件配置,暂时这么设置
}
  1. 代码规范设计

首先最简单的就是代码规范的设计,将 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 也可以这么设计。

  1. 脚手架设计

然后就是脚手架的建立,这个也很简单,网上有很多例子。我们的目标就是确定好项目模板的规范,执行命令,拿到参数,解析参数,根据参数产出对应的模板。这个脚手架的包名,可以以 create-* 命名,这样就可以在不用下载包的情况下来创建模板。

考虑到以后的扩展,可以预设两个模版:极简模版、业务模版。极简模版可以用来快速创建测试项目,业务模版则是业务中需要使用的。

其次,脚手架添加 --template 命令,用于指定使用哪个模版。还需要有 --git 命令,用于初始化 git 仓库。当然这些也可以通过交互式的问答命令来实现,这里推荐使用 propmts 这个库来实现。

一个完整的脚手架的命令可能是这样的:

sh 复制代码
npm create [scafold name] [project name] --template [template name] --git
  1. 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 包就遵循了单一指责原则,便于后期维护。

  1. 插件包设计

最后我们还是需要有一个 plugins 插件包,用来将常用的技术栈封装成插件,比如使用 qiankun 的话,可以将子应用导出的生命周期方法放到插件中执行,这样业务开发就不需要每次去写这些样板代码了。

到这里,一整个框架雏形就已经出来了。我画了一个简单的示意图:

整体的流程就是 kernel 载入配置文件,解析参数,初始化进程,执行 CoreService,运行插件和预设,拿到最终的配置项以后传递给 bundler,启动开发服务器或者是打包构建等命令。最后 utils 包就是提供公共方法给其他包来使用。

发布/部署设计

整体的逻辑设计已经完成,但是还缺少了最后一步,发布流程。可能会有人觉得发布不就是 npm publish 不就完事了,但是我们目前有6个包,每次都需要更新版本号,然后再 npm publish,这太效率太低了,还是交给 Node 脚本来做。

这个脚本可以按照以下流程来做:

解释一下就是,使用 Node 读取 packages 目录下的所有包,依次检查版本号,更新版本号,打 tag,提交推送到仓库。这个时候,有两个选择,可以利用各种 pipeline 工具来进行 build 所有包,然后 publish,也可以直接在脚本中做这些。当然会更推荐使用 pipeline 构建,防止有的时候没有 build 就直接发布了。

这里所有的包的版本号都保持一致,因为实际上它们都是属于应用框架的一部分,因此它们的包名可以是@workspace/packageName 这种形式,但是脚手架的包名不能是这种形式,原因上面也提到过,就不再赘述了。

后话

这样一套简陋的应用框架设计就出来了,有了解的同学可能也看出来了,这其实就是应用框架 Umi 的一个设计思路,它的微内核的架构设计,非常适合我们在它的基础上封装出一套属于自己公司业务的框架。

个人浅见,希望对各位有所启发。

相关推荐
yngsqq1 分钟前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing36 分钟前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风38 分钟前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟1 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾1 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧1 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm1 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7012 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm2 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue