随笔:可扩展的前端架构方式

随着时间的推移,软件产品的业务功能和增强体验的特性会越来越多。为了避免过度设计,软件架构一般只会满足产品未来几个月的需求。长远来看,软件架构也是需要不断调整的。在调整的过程中,如果架构资产不被推翻并且可以延续,可以节约非常大的成本,也保证了调整的平滑和质量的可靠。

  1. 增量

通过增量的方式应用架构变更是非常稳妥的。这需要之前架构有比较清晰的层级设计。clean architecture 描述了一种简单的分层设计方法。

从技术细节上讲,小到采用的网络请求库,大到 api gateway,都需要有”中间件“机制。例如 axios 有 interceptor 和 transformer 机制来检测和篡改请求和返回,express.js 有 middleware 机制来篡改任意请求和返回。

我设计的前端微服务化方案主要采用的增量的方式。增加了一个微服务网关,它用于代理各个前端服务,并智能地将各个服务注入到 HMTL 文档中。最大限度地保留了原有的架构,将方案的设计和引入的风险集中在增量部分。

  1. 分治

理论上,业务功能可以无上限地复杂。如果没有分治的架构,业务代码从简单清晰到复杂得不可维护只是时间的问题。表单是前端主要承载业务的地方,随着 edge case 不断增多、表单联动项不断增加、校验规则不断细化,表单往往为成为 bug 的重灾区。

表单常见的问题就是”牵一发动全身“,修改了一个表单项,导致相关的联动和校验都出问题。我对 antd v4 的表单方案非常推崇,利用 dependencies 可以很清晰地表示表单项与表单上下文的依赖关系,使得表单项可以将依赖信息封装在自身的定义中,成为完全封闭的组件。表单项之间的依赖处理完全由表单实例来进行调度。这样就将表单的复杂逻辑分而治之了,从根本上解决了表单扩展性的问题。

redux 是个分治的反例,尽管 store 可以通过命名空间来进行分割,但是开发者仍愿意随意访问 store 上的状态 —— 因为实在太方便了。redux 强调的时候状态的集中管理,view = f(state)。简单明了的原理在实际生产中却有些难以落地,原因在于 redux 不鼓励应用中存在多个 state store,所有模块都需要依赖同一个 store,并对外暴露 state 定义的细节。一旦模块之间开始依赖 redux 进行状态共享,就好比打开了潘多拉的魔盒,不同层次的、不同领域的模块将在 state store 上”粗糙地“交织在一起。

在数据请求层面,react-refetch 是 redux 很好的替代品。私有的数据状态,是
“分治”必要的一步。

  1. 领域

“分治”主要强调的是业务水平的分割,领域则更强调垂直的分割。

需要时刻警惕”全能“的解决方案,什么都做往往代表什么都做不好。前端的通用解决方案从下往上可以分为

  • 模块方案
  • 视图方案
  • 状态方案
  • 数据请求方案
  • BFF 方案

在 GraphQL 的落地方案中,我将 BFF 方案细化为

  • 前端数据结构
  • GraphQL Server
  • 后端数据结构

后端采用的微服务架构,对前端暴露出非常多 endpoint,不同 endpoint 的 api 风格有一定差异。对前端最致命的是,后端有较多非结构化数据和“啰嗦的”数据结构定义。

为此我定义了前端数据结构。它是后端数据”标准化“后的版本,同时增加了类型之间的关联,便于使用 GraphQL 进行关联查询。

”标准化“常见的例子有

  • object.metadata.annotations['abc.com/isHealty'] 简化为 object.isHealty
  • object.createdAt object.creationTime 统一为 object.createdAt
  • object.status = { cpu: '100%', memory: '50%' } 简化为 object.cpuStatus object.memoryStatus

这样做的好处有

  • 抵御后端的变化
  • 方便前端进行数据处理,例如 _.find(objects, { isHealty: true })_.find(objects, o => _.get(o, "metadata.annotations['abc.com/isHealty']")) 简单得多
  1. 向内回溯

垂直方向良好分割的前端应用,可以将很多通信和数据共享”回溯“到内部的层次。redux 之于 react 就是一层状态管理层,利用 redux 来实现 react 组件间通信则是”向内回溯“的应用。

这里我举另外一个获取数据的例子,若组件 A 和 B 都需要获取 user-data,组件层面的实现可能是分别请求两次 user-data,或者组件互相优先获取对方已取得的 user-data。前者会有额外的请求开销,后者过于复杂。若有数据获取层,A B 组件可以分别请求 user-data,数据获取层可以提供缓存功能,将一个请求发送出去,另一个请求直接返回上一个请求的缓存。即便取消数据获取层,或者关闭其缓存功能,应用仍能回退到发出多个请求的”低性能模式“继续工作。

实际上 apollo-client 正在推进这种数据共享模式。利用可配置生命时长的缓存,组件可尽情做查询,而不必担心产生过多请求的问题。