可能一些同学会认为前端比较简单而不需要架构,或者因为前端交互细节杂而乱难以统一抽象,所以没办法进行架构设计。这个理解是片面的,虽然一些前端项目是没有仔细考虑架构就堆起来的,但这不代表不需要架构设计。任何业务程序都可以通过代码堆砌的方式实现功能,但背后的可维护性、可拓展性自然也就千差万别了。
为什么前端项目也要考虑架构设计?有如下几点原因:
- 从必要性看,前后端应用都跑在计算机上,计算机从硬件到操作系统,再到上层库都是有清晰架构设计与分层的,应用程序作为最上层的一环也是嵌入在整个大架构图里的。
- 从可行性看,交互虽然多而杂,但这不构成不需要架构设计的理由。对计算机基础设计来说,也面临着多种多样的输入设备与输出设备,进而产生的标准输入输出的抽象,那么前端也应当如此。
- 从广义角度看,大部分通用的约定与模型早已沉淀下来了,如编程语言,前端框架本身就是业务架构的一部分,用 React 哪怕写个 “Hello World” 也使用了数据驱动的设计理念。
从必要性看,虽然操作系统和各类基础库屏蔽了底层实现,让业务可以仅关心业务逻辑,大大解放了生产力,但一款应用必然是底层操作系统与业务层代码协同才能运行的,从应用程序往下有一套逻辑井然的架构分层设计,如果到了业务层没有很好的架构设计,技术抽象是一团乱麻,很难想象这样形成的整体运行环境是健康的。
业务模块的架构设计应当类似计算机基础的架构设计,从需求分析出发,设计有哪些业务子模块,并定义这些子模块的职责与子模块之间的关系。子模块的设计取决于业务的特性,子模块间的分层取决于业务的拓展能力。
比如一个绘图软件设计时只要需要组件子系统与布局子系统,它们之间互相独立,也能无缝结合。对于 BI 软件来说,就增加了筛选联动与通用数据查询的概念,因此对应的也会增加筛选联动模型、数据模型、图形语法这几个子模块,并按照其作用关系上下分层:
<img width=400 src="https://s1.ax1x.com/2022/08/21/vyrS0K.png">
如果分层清晰而准确,可以看出这两个业务上层具有相同的抽象,即最上层都是组件与布局的结合,而筛选联动与数据查询,以及从数据模型映射到图元关系的映射功能都属于附加项,这些项移除了也不影响系统的运行。如果不这么设计,可能就理不清系统之间的相似点与差异点,导致功能耦合,要维护一个大系统可能要时刻关系各模块之间的相互影响,这样的系统即不清晰,也不够可拓展,关键是要维护它的理解成本也高。
从可行性看,前端的特点在于用户输入的触点非常多,但这不妨碍我们抽象标准输入接口,比如用户点击按钮或者输入框是输入,那键盘快捷键也是一种输入方式,URL 参数也是一种输入方式,在业务前置的表单配置也是一种输入方式,如果输入方式很多,对标准输入的抽象就变得重要,使业务代码的实际复杂度不至于真的膨胀到用户使用的复杂度那么高。
不止输入触点多,前端系统的功能组合也非常多,比如图形绘制软件,画布可以放任意数量的组件,每个组件有任意多的配置,组件之间还可以相互影响。这种系统属于开放式系统,用户很容易试出开发者都未曾想到过的功能组合,有些时候开发者都惊叹这些新的组合竟然能一起工作!用户会感叹软件能力的强大,但开发者不能真的把这些功能组合一一尝试来解决冲突,必须通过合理的分层抽象来保证功能组合的稳定性。
其实这种挑战也是计算机面临的问题,如何设计一个通用架构的计算机,使上面可以运行任何开发者软件,且软件之间可以相互独立,也可以相互调用,系统还不容易产生 BUG。从这个角度来看,计算机的底层架构设计对前端架构设计是有参考意义的,大体上来说,计算机通过硬件、操作系统、软件这个三个分层解决了要计算一切的难题。
冯·诺依曼体系就解决了硬件层面的问题。为了保证软件层的可拓展性,通过 CPU、存储、输入输出设备的抽象解决了计算、存储、拓展的三个基本能力。再细分来看,CPU 也仅仅支持了三个基本能力:数学计算、条件控制、子函数。这使得计算机底层设计既是稳定的,设计因素也是可枚举的,同时拥有了强大的拓展能力。
操作系统也一样,它不需要知道软件具体是怎么执行的,只需要给软件提供一个安全的运行环境,使软件不会受到其他软件的干扰;提供一些基本范式统一软件的行为,比如多窗口系统,防止软件同时在一块区域绘图而相互影响;提供一些基础的系统调用封装给上层的语言进行二次封装,而考虑到这些系统调用封装可能会随着需求而拓展,进而采用动态链接库的方式实现,等等。操作系统为了让自身功能稳定与可枚举,对自己与软件定义了清晰的边界,无论软件怎么拓展,操作系统不需要拓展。
回到前端业务,想要保障一个复杂的绘图软件代码清晰与好的可维护性,一样需要从最底层稳定的模块开始网上,一步步构建模块间依赖关系,只有这样,模块内逻辑才能可枚举,模块与模块间才敢大胆的组合,各自设计各自的拓展点,使整个系统最终拥有强大的拓展能力,但细看每个子模块又都是简单清晰、可枚举可测试的代码逻辑。
以 BI 系统举例,划分为组件、筛选、布局、数据模型四个子系统的话:
- 对组件系统来说,任何组件实现都可接入,这就使这个 BI 系统不仅可以展示报表,也可以展示普通的按钮,甚至表单,可以搭建任意数据产品,或者可以搭建任意的网站,能力拓展到哪完全由业务决定。
- 对筛选系统来说,任何组件之间都能关联,不一定是筛选器到图表,也可以是图表到图表,这样就支持了图表联动。不仅是 BI 联动场景,即便是做一个表单联动都可以复用这个筛选能力,使整个系统实现统一而简单。
- 对布局系统来说,不关心布局内的组件是什么,有哪些关联能力,只要做好布局就行。这样画布系统容易拓展为任何场景,比如生产效率工具、仪表盘、ppt 或者大屏,而对其他系统无影响。
- 对数据模型系统来说,其承担了数据配置到 sql 查询,最后映射到图形通道展示的过程,它本身是对组件系统中,统计图表类型的抽象实现,因此虽然逻辑复杂,但也不影响其他子系统的设计。
从广义角度看,前端业务代码早就处于一系列架构分层中,也就是编程语言与前端框架。编程语言与前端框架会自带一些设计模式,以减少混用代码范式带来的沟通成本,其实架构设计本身也要解决代码一致性问题,所以这些内容都是架构设计的一环。
前端框架带来的数据驱动特性本身就很大程度上解决了前端代码在复杂应用下可维护问题,大大降低了过程代码带来的复杂度。React 或 Vue 框架本身也起到了类似操作系统的操作,即定义上层组件(软件规格)的规格,为组件渲染和事件响应抹平浏览器差异(硬件差异),并提供组件渲染调度功能(软件调度)。同时也提供了组件间变量传递(进程通信),让组件与组件间通信符合统一的接口。
但是没有必要把每个组件都类比到进程来设计,也就是说,组件与组件之间不用都通过通信方式工作。比较合适的类比粒度是模块,把一个大模块抽象为组件,模块与模块间互相不依赖,用数据通信来交流。小粒度组件就做成状态无关的元件,注意相似功能的组件接口尽量保持一致,这样就能体验到类似多态的好处。
所以话说回来,遵循前端框架的代码规范不是一件可有可无的事情,业务架构设计从编程语言和前端框架时就已经开始了,如果一个组件不遵循框架的最佳实践,就无法参与到更上层的业务架构规划里,最终可能导致项目混乱,或者无架构可言。所以重视架构设计从代码规范就要开始。
所以前端架构设计是必要的,那怎么做好前端架构设计呢?这个话题太过于庞大,本次就从操作系统借鉴一些灵感,先谈一谈对分层与抽象的理解。
没有绝对的分层
分层是架构设计的重点,但一个模块在分层的位置可能会随着业务迭代而变化,类比到操作系统举两个例子:
语音输入现在由各个软件自行提供,背后的语音识别与 NLP 能力可能来自各大公司的 AI 中台,或者一些提供 AI 能力的云服务。但语音输入能力成熟后,很可能会成为操作系统内置能力,因为语音输入与键盘输入都属于标准输入,只是语音输入难度更大,操作系统短期难以内置,所以目前发展在各个上层应用里。
Go 语言的协程实现在编程语言层,但其对标的线程实现在操作系统层,协程运行在用户态,而线程运行在内核态。但如果哪天操作系统提供了更高效的线程,内存占用也采用动态递增的逻辑,说不定协程就不那么必要了。
按理说语音输入属于标准输入的一部分,应该实现在操作系统的通用输入层,协程也属于多任务处理的一部分,应该实现在操作系统多任务处理层,但它们都被是现在了更上层,有的在编程语言层,有的在业务服务层。之所以产生了这些意外,是因为通用输入输出层与多任务处理层的需求并没有想象中那么稳定,随着技术的迭代,需要对其拓展时,因为内置在底层不方便拓展,只能在更上层实现了。
当然我们也要注意到的是,即便这些拓展点实现在更上层,但对软件工程师来说并没有特别大的侵入性影响,比如 goroutine,程序员并不接触操作系统提供的 API,所以编程语言层对操作系统能力的拓展对程序员是透明的;语音输入就有一点影响了,如果由操作系统来实现,可能就变成与键盘输出保持一致的事件结构了,但由业务层实现就有无数种 API 格式了,业务流程可能也更加复杂,比如增加鉴权。
从计算机操作系统的例子我们可以学习到两点:
- 站在分层合理性视角对输入做进一步的抽象与整合。比如将语音识别封装到标准的输入事件,让其逻辑上成为标准输入层。
- 业务架构的设计必然也会遇到分层不满足业务拓展性的场景。
业务分层与硬件、操作系统不同的是,业务分层中,几乎所有层都方便修改与拓展,因此如果遇到分层不合理的设计,最好将其移动到应该归属的层。操作系统与硬件层不方便随意拓展的原因是版本更新的频率和软件更新的频率不匹配。
同时,也要意识到分层需要一个演进过程,等新模块稳定后再移动到其归属所在层可能更好,因为从上层挪到底层意味着更多被模块共享使用,就像我们不会轻易把软件层某个包提供的函数内置到编程语言一样,也不会随意把编程语言实现的函数内置到操作系统内置的系统调用。
在前端领域的一个例子是,如果一个搭建平台项目中已经有了一套组件元信息描述,最好先让其在业务代码里跑一段时间,观察一下元信息定义的属性哪些有缺失,哪些是不必要的,等业务稳定一段时间后,再把这套元信息运行时代码抽成一个通用包提供给本业务,甚至其他业务使用。但即便这个能力沉淀到了通用包,也不代表它就是永远不能被迭代的,操作系统的多任务管理都有协程来挑战,何况前端一个抽象包的能力呢?所以要慎重抽象,但抽象后也要敢于质疑挑战。
没有绝对的抽象
抽象粒度永远是架构设计的难题。
计算机把一切都理解为数据。计算结果是数据,执行程序的代码也是数据,所以 CPU 只要专注于对数据的计算,再加上存储与输入输出,就可以完成一切工作。想一想这样抽象的伟大之处:所有程序最终对计算机来说都是这三个概念,CPU 在计算时无需关心任何业务含义,这也使得它可以计算任何业务。
另一个有争议的抽象是 Unix 一切皆文件的抽象,该抽象使文件、进程、线程、socket 等管理都抽象为文件的 API,且都拥有特定的 “文件路径”,比如你甚至可以通过 /proc
访问到进程文件夹,ls
可以看到所有运行的进程。当然进程不是文件,这只是说明了 Unix 的一种抽象哲学,即 “文件” 本身就是一种抽象,开发和可以用理解文件的方式理解一切事物,这带来了巨大的理解成本降低,也使许多代码模式可以不关心具体资源类型。但这样做的争议点在于,并不是一切资源都适合抽象成文件,比如输入输出中的显示器,它作为一个呈现五彩缤纷像素点的载体,实在难以用文件系统来统一描述。
计算机设计与操作系统设计已经给了我们很明显的启发,即一切能抽象的都要尽可能的抽象,如此才能提高系统各模块内的稳定性。但从如 Unix 一切皆文件的抽象来看,有时候的技术抽象难免被当时的业务需求所局限,当输入输出设备的种类增加后,这种极致的抽象未必能永远合适。但永远要相信抽象,因为假若所有资源都可以被文件抽象所描述,且使用起来没有不便捷的地方,为什么还要造其他的抽象概念呢?如无必要勿增实体。
比如 BI 场景的筛选、联动、下钻场景是否都能抽象为组件与组件间的联动关系呢?如果一套标准联动设计可以解决这三个场景,那自然不需要为某个具体场景单独引入概念。从原始场景来看,无论筛选、联动还是下钻场景都是修改组件的取数参数以改变查询条件,我们就可以抽象出一种组件间联动的规范,使其可以驱动取数参数的变化,但未来需求可能引入更多的可能性,如在筛选时触发一些额外的追加分析查询,此时之前的抽象就收到了挑战,我们需要权衡维持统一性的收益与通用接口不适用于特殊场景带来成本之间的平衡。
抽象的方式是无数的,哪种更好取决于业务如何变化,不用过于纠结完美的抽象,就连 Unix 一切皆文件的最基础抽象都备受争议,业务抽象的稳定性肯定会更差,也更需要随着需求变化而调整。
总结
我们从计算机与操作系统的架构设计出发,探讨了前端架构设计的必要性,并从分层与抽象两个角度分析了架构设计时的考量,希望你在架构设计遇到拿捏不定的问题时,可以向下借助计算机的架构设计获得一些灵感或支持。
讨论地址是:精读《对前端架构的理解 - 分层与抽象》· Issue #436 · dt-fe/weekly
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)