如何设计一个应用框架

框架

谈到设计框架,很多人觉得大而空,有那个精力和能力吗,这不是为了造轮子而造轮子吗,纯纯 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 的一个设计思路,它的微内核的架构设计,非常适合我们在它的基础上封装出一套属于自己公司业务的框架。

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

相关推荐
腾讯TNTWeb前端团队4 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰8 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪8 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪8 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy9 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom9 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom9 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom9 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom9 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom10 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试