前端界有两个“教派”,一个叫 Vue,一个叫 React。Vue 的成员看不起 React,React 成员鄙视 Vue,他们认为手中的“教义”就是真理,可以消灭世界一切苦难。
但正如没有绝对的真理,也没有绝对完美的系统框架,我们需要一双明辨是非的眼睛去解析所面对的难题,带我们找到正确的方法,解决所面对的困难。我们需要抱着怀疑的眼光去看待现代前端开发框架,它们真的能解决我们的问题吗?答案是肯定的,也是否定的。框架并不能独立的发挥作用,其中开发者是一个很大的变量,而开发者这个最大的变量才是最终影响问题是否能够被解决的重要因素。
本文从对现代前端框架的“崇拜”现象,引出了前端开发面临的过于强调工具本身,忽视了开发者怎么写好代码才是影响代码质量的本质问题,最后给出了一种我认为可解决业务型前端项目的代码架构方案(也可以说是一种开发思想),希望能给大家带来一些思路和帮助。
一、前端开发的困境
从我的经历来看,现代前端框架的‘崇拜’导致前端开发变得更加复杂,间接导致代码质量变差,软件的生命周期变短。
当前我们所面临的困境是技术方案有的时候用的特别顺手、高效,而有的时候却很蹩脚、处处碰壁?有的人用着轻松欢快,而有的人用着烦躁不已?有的项目引入了新工具开发效率一下子就上去了,有的项目反而变得越来越复杂难以迭代?
究其原因是因为我们一直在追求一种足够简单、足够高效、足够快、足够应对复杂变化的业务场景的完美的技术方案。这已经成为了一种前端界的主流意识,每一种新框架、工具的出现都打着比前者更出众,更牛的口号,给我们一种感觉,好像用了它,那么一切问题就迎刃而解。但这样的“神器”真的有吗?我是持怀疑态度的,软件开发是一个复杂的系统过程,这是一个开放式的问题,想用一种 “封闭式” 的方案来解决这个问题是违背真理的。
现代前端开发框架就面临着这个问题,我们有点 “小题大作” 了,妄想用框架来解决一切问题忽略其本质解决的是视图层渲染的问题。随着 React/Vue 等数据驱动 UI 框架的流行,它好像成为了我们手中的一个 “神器”,好像用它可以解决开发中的一切问题。而事实上也是这样的,如果发现有的问题没有解决好,那么就围绕着这些框架扩展新工具就好,所有我们有了 React 全家桶、有了 Vue 全家桶。这样还不够,我们还有众多的工具库可自由组合使用,分不清的数据状态管理库、多种路由跳转方案、丰富的组件集合、各种开发调试工具、二次封装的全栈框架等。看似情况越来越好,一切欣欣向荣,幸福的彼岸就在眼前。事实是我们的开发者一直在负重前行,我们的系统变得很复杂(复杂到个人乃至一个团队都没法去掌控),我们不敢想五年后是否还能运行起来?
工具可以带来提效,可以让工作变得更精细,但影响成品质量的至关重要因素是使用的方式而不是工具本身。对于前端框架,我们要认识到其本身是用来解决视图层问题的,当我们妄想用框架来解决所有问题的时候,就已经走进了误区,忽略了本质问题。
二、这是“现代前端开发框架”的锅?
前文我表达了“从我的经历来看,现代前端框架的‘崇拜’导致前端开发变得更加复杂,间接导致代码质量变差,软件的生命周期变短。” 这里并不是说是现代前端框架造成了这一系列的问题,事实上我非常认可前端框架给前端开发带来的变革,它解决了我们开发界面过程中繁琐的 DOM 操作问题;带来了便捷的组件式的代码复用共享机制;这是一种正向的技术进步。问题的本质是社区带来的 “崇拜” 文化,这是运行机制带来的问题,前端框架只是其中某个重要的节点,我们不能因为工具没有使用好就怪工具本身。
1. 为什么好的东西会变“坏”?
有一个比较普适的做事原则是 “小事做大,大事做小”。而在前端框架这一块犯了一个错误,过于强调 “小事做大”,也可以说是 “小题大作” 了,大和小是相对的,我们现在妄想用 React/Vue 这一套方案解决整个前端开发的问题,这就是好的东西会变 “坏” 的原因。
我在比较早期的时候就接触了 React/Vue 这一类的框架,老实说当时是很惊艳的,那时的它们有很明确的定位和解决问题的边界,它们是很纯粹的视图层工具库(那时还没有提到框架的概念),简洁优雅。但随着它们的不断迭代和社区的推波助澜,逐渐的走向了一条不确定的道路,越来越多的衍生工具,越来越多的概念。我承认这些东西有其优秀的地方,但好的东西并不是万能的东西,需要适合的方法用在合适的地方,否则就变成了“恶”。
2. 框架的快速发展和开发人员缓慢吸收矛盾
软件开发是一个复杂的系统构建过程,这里开发人员永远是最大的变量,前端界面对的问题就是技术方案短时间内的急速发展,1% 的创造者理论提出者,10% 的参与人员将方案推向市场,而大部分的开发者处于一个被动接受和使用的角度。这个过程会导致方案推广出现信息丢失、带来误差、引出变种方案。同时也可能会给于框架维护者带来负向的反馈,带来缺陷。
技术 “宗教” 或者说过于对某一项技术的 “崇拜” 会加速新技术推向市场的过程,推广的过程中会出现曲解、模糊。这其中有的是无意的,有的则是故意的。这些在现代前端框架中就出现了,因为这个不健康的成长现象导致了框架的使用被推到极端,而开发者们很可能还没有建立起敏锐的技术敏感度和判断能力,导致出现整个前端看似蓬勃发展,但大家却痛苦不堪的现象。
三、破除困境的方法?
1. 好的东西可能会变 “坏”,坏的东西也可能会变 “好”
好心可能会办坏事,坏事也可能带来出乎意料的好处。这其中发挥作用的因素之一就是做事的人这个大 “变量”。为了让事情办好,我们需要培养起一个技术能力高的团队,开发者们都有意识的去关注代码质量,关心技术架构,思考写出更好的代码。这是一个最原始最有效的办法,当然这也是最难,成本最高的方案。
2. 建立拥有正向反馈能力的运行机制?
机制是一个很强大的东西,甚至能弥补影响因素带来的负面问题。对于一个复杂系统来说,机制的作用是要大于单个因素的,机制可以调节单个因素造成的误差,但单个因素却很难影响整个运行机制。
怎么建立良好的运行机制我还没有思考出好的办法,有想法的同学可以一起交流。虽然还没有找到建立的方式,但我认为这是一条正确的路,值得去探索。
3. 有效训练,代码是“写”出来的?
做为一个开发者,我发现有两件事情基本上每天都会做,一不停地写代码、二是持续性的学习。按理来说,每天都在写代码,按照一万小时定律,我们的编码水平应该会不断提升才对,但现实是好像有一条边界,当我达到那条边界后就停滞不前。
有变化的持续性行为才可能会出现由量变到质变的突破,长期的无效练习不然短期的有效训练。
看了许多书籍、文章,听了很多分享、讲座,评了很多方案,用了很多新技术。但都会有一种浅尝辄止的感觉,难道勤学不能带来成长?
- 再多的信息也不等于知识,经由我们自己的思维转换并以自己的理解方式吸收的才是自己的知识,将知识应用结合到日常工作中的才算是个人的能力
- 人的思考能力是有限的,而现实现象是一个复杂多维度的系统,远超个人所能掌控的极限。正确的做法应该是由小到大,深入挖掘单个维度的现象,最后通过有序的方式组合起来就是一个复杂多维度的系统。
四、我们需要合适的代码架构方案?
我设计过许多底层框架工具,如果要说其中最困难的地方在哪,那就是框架设计者必须考虑到框架本身和使用的开发者间的对立关系(框架和开发者是一个资源争夺的关系,他们争夺的是软件中的计算量,或者简单的说是代码量)。处理好这种关系,让它达到一个动态平衡的过程是设计者要解决的本质问题。
这里我将介绍一种架构方案,该方案在前端框架的基础上进行了延伸,有效的解决了业务型(业务逻辑比较重)前端类需求的复杂度问题。
该方案目前还是理论设计和验证阶段,还没有在真实业务中落地,大家可以带着辩证的眼光来看待。
若认可该方案,可以互相交流;若不喜,请勿喷我。
三是一个很好的数字,我认为一个架构方案能够解决好三个系统性问题就可以算是一个优秀的方案,而这里介绍的方案则围绕如下三个原则而来。
- 分层
- 组合
- 单向依赖
在大的层面借助分层的思想将事情分类隔离开,每一层直接通过接口联结工作,降低整个系统的复杂度。比如客户端就不用关心后端接口的具体实现,只要接口如预期工作就好。
在每一层的实现中则可以通过模块组合的形式最终得到完整的功能。因为前端具有高频变化的特性,所以更建议使用组合而不是面向对象继承的方式来组织代码逻辑,继承的模式应该更适用于稳固的结构。
分层分模块之后,需要通过线连接起来才能得到一个完整的应用,单向依赖可以避免得到一个无序的网状结构导致逻辑难以理顺。
在思考上面提到的三个原则的过程中,我发现现存的 MVP 架构模型就很好的做到了,基于 MVP 架构做一点优化就得到了我认为好的一种架构设计。关键的问题还是我们怎么和自己的开发思维、业务特性结合起来的问题。
除了架构原则方面,我们再分析一下代码编写本质的东西,什么原因会导致代码质量变差?以及如何去解决?
1. 什么原因会导致前端代码变差?
前端开发中一个比较核心的问题是视图的开发,这个问题被框架有效的解决了,当不用去直接操作 DOM 后,开发体验得到了极大的提高。但存在的问题或者说带来的一个误导是视图的开发就是全部,所以我们变成了组件式编程,带来的后果就是所有的代码联系过于紧密。
我认为以视图为中心的代码组织模式会随着迭代的进行导致代码越来越混乱,而且逻辑代码与视图层耦合会导致所有的代码如同一碗面条一样杂乱不开。
- 视图是变化频率比较高的,以视图为中心的代码组织方式就如同以视图作为房子的底座去建房,导致的结果就是每次视图变化了你就需要费很大的劲去调整房子的其它部件以适应底座的变化,所谓牵一发而动全身正是如此。
- 以视图作为代码主体还有一个问题就是视图不能完整的反应业务需求,有部分的业务逻辑是与视图无关的。随着业务逻辑的比重越大就会出现头重脚轻的现象。
- 数据状态管理工具可以解决业务逻辑变重的问题吗?我认为是否定的,数据状态管理工具并没有脱离视图框架的本质,只是将组件内的局部状态管理模式转换为了全局状态管理的模式。类似于有了一个中心的仓库放置闲置的物品,并不会降低管理物品的难度,关键的地方应该在于一套科学合理的闲置物品分类管理方案,将杂乱无章的巨型仓库变成物美一样的大型超市。
基于上述分析,我认为的一个解决之道就是要将视图层独立出来,视图与逻辑隔离,实现动静分离。
2. 前端的主要关注点是什么?
前面说了以视图为中心的前端代码组织方式存在弊端,那么好的方式应该是什么呢?回归业务的本质,以业务为中心,视图只是业务流程的界面表现方式。UI 是一个单纯的东西,我觉得不能算是业务逻辑,前端中的业务逻辑应该是业务流程与界面的联结表现,这里有一个主辅的区别。
做为前端的你是否也曾有过疑问,有时候前后端的边界不是很清晰,有的功能可以端上做,也可以在服务端做。客户端和服务端的对立也影响着客户端技术的发展,从而保留着服务端的影子。
思考一个问题,如果前端摒除了视图部分,那么前后端的代码还会有什么区别呢?这么想着将后端那一套长期稳定的架构带到前端也不是不可能。MVC/MVP 架构是在后端开发中比较成熟的方案,我认为应用到前端也是可行的, 关键是要和前端现有的技术方案结合起来。
3. React Hook/Vue Composition API 带来的开发思维转变?
React Hook 不仅仅是对 Class 组件的替代,Vue Composition API 也不仅仅是 Options API 的语法升级,我觉得是更本质的编程思维上的改变。
以如下的 swr 示例代码为例:
functionuseUser(id) { const { data, error } =useSWR(`/api/user/${id}`, fetcher) return { user: data, isLoading: !error&&!data, isError: error, } }
从上面我们可以看到 useSWR
的引入就如同引入一个普通的 util
函数一样,这里考虑的不是组件初始化的时候做什么?组件刷新的时候做什么?而是纯粹的思考数据的变化流程,即变成计算的问题。这其实也是一种视图与逻辑层分离的思想,将业务逻辑聚焦到了 useSWR
hook 中(包含了请求、加载中间态、错误太的处理)。但可惜的是目前 hook 并不完美,还存在如不可在条件式语句中使用、重复渲染的问题。Vue Composition API 的目前也还没有成为主流方式,一切都还在摸索中。社区上已经有众多基于此的数据状态更新方案,我相信,未来会出现更多基于此的前端代码架构方案。
4. 以业务逻辑为核心的架构方案 - (A)
该方案暂以 A 命名,A 方案的适用场景是中型的页面为主,拆分出来的子模型、子逻辑模块在不超过 20 个,看起来不多,其实已经满足了大部分页面的开发需求。
如果与上述场景不匹配在需要基于 A 方案调整,主要在于每一层内部的有序组合方案问题,以及各层间通信方式的优化。
如果发现数据层逻辑层过大可考虑直接上 DDD。面对复杂场景,与没有架构指导相比,采用了非理想的架构方案情况也会更好。
4.1. 基于 MVP 模型的演进架构介绍
- 各部分之间的通信,都是双向的。
- View 与 Model 不发生联系,都通过 Presenter 传递。
- View 非常薄,不部署任何业务逻辑,称为"被动视图"(Passive View),即没有任何主动性,而 Presenter非常厚,所有逻辑都部署在那里。
我们可以拓展为如下分层架构(保留 MVP 的优点):
- 基于 MVP 的分层架构(理解好分层的边界是分层方案是否有效的重要因素)
- 数据层(Model)- 业务逻辑依赖的数据实体(可参考 DDD 实体的定义)
- 逻辑层(Presenter)- 处理业务逻辑
- 视图层(View)- 处理前端的界面交互
- 每一层基于组合的方式构成完整功能
- 数据层由多个子模型组合而成
- 逻辑层由多个子逻辑模块组合而成(由组合成的主逻辑模块与其它层交互)
- 视图层由多个组件组合而成
- 单一的依赖关系降低耦合度,流程更清晰
4.2. A 方案的一些弊端分析
首先是分层架构带来的判断力需求,真正实践的时候就会发现边界变得模糊,不知道代码该写在哪一层,这是极有可能发生的。对于这种情况,我认为可以按照先求 “完成”,再求“完美” 的方式来走,用实践来积累正确合理的适合自己团队的方式。
其次是过渡抽象,这也是大多数方案存在的,有时候显得明明可以一步到位的事情非要循规蹈矩。
把大象装到冰箱需要几步:
- 打开冰箱
- 大象走进冰箱
- 关闭冰箱
也可以一步到位,大象自己走进冰箱。
从上述的例子来看,没什么毛病,但现实系统会更复杂,可能完整的步骤多余 10 步、涉及多个参与对象、参与对象间可能有交互,那么按照严格的指导手册一步步来就很有必要。最后就又变成了业务场景具体分析的问题,与架构方案是否匹配。如果评判标准难以统一,评判成本过高,即没法轻易的下结论是否适合用?好像不用也行?用也 OK?如果没有明确的原因说明不必要采用,那么我建议是直接使用好了,否则无需采用。
另一个负面影响则是框架成本(研发成本、维护成本、推广成本、业务接入成本)问题,这是避免不了的。
五、总结
- 现代前端开发框架的出现引领了前端界的变革,但我们不能过于“崇拜”它,它主要解决的是复杂交互开发的问题,在用于开发业务逻辑复杂的需求上并不完美,可以将其当做纯视图层的解决方案。
- 没有绝对完美的技术方案,这里面涉及太多的影响因素,开发者是其中的一个大的 “变量”,具备良好架构思维的开发者编写出的代码也许就是好的代码,本身就是一种技术架构方案的反馈。而技术方案的设计产出则是追求的一种普适、易用的方案。
- 设计普适的通用技术方案要由小到大,再由大到小;追求解决核心问题而不是全量的问题,平衡好和开发者间的对立关系;可接受范围内的负面成本并不影响整个方案的决策。
六、示例
下面是一个偏真实的代码架构拆分示例:
如上述示例图,我们需要获取商品数据然后以列表的形式展示到页面上,按照 A 方案,我们可以对该功能做如下的拆解。
1. 常规写法伪代码:
exportdefaultclassPortalPageextendsComponent { registerModels () { return [ { namespace: 'PortalGoodModel', state: { pageNum: 1, pageSize: 10, hasNextPage: true, goodsData: [] }, reducers: { updateGoodsData (state, payload) { // 更新数据操作 } } } ] } state (state) { return { state, // 是否有数据,纯 UI 内部状态noResultPageVisible: state.PortalGoodModel.goodsData.length } } ready () { this.getPageData(); } asyncgetInfo () { constlocationInfo=awaitthis.dispatch({type: 'PortalLocationModel/updateInfo'}) returnlocationInfo; } asyncgetPageData (type) { const { adCode, userAdCode, longitude, gpsValid } =awaitthis.getInfo(); if (!(adCode&&userAdCode&&longitude) ||!gpsValid) { // 定位失败if (type==='onErrorRetry') { utils.toast('请开启定位'); this.dispatch({ type: "PortalLocationModel/updateLocationStatus", value: 'error' }) } } else { this.dispatch({ type: "PortalLocationModel/updateLocationStatus", value: 'success' }); constrequestParams=utils.getRequestParams({ pageData: this.$page.props, location, }); constres=awaitfetch(requestParams); if (res.code!==1) { // 请求错误处理 } else { constresult=this.processResult(res); this.dispatch({ type: 'PortalGoodModel/updateGoodsData', result }); } } } }
观察上面的代码我们会发现 getPageData
中柔和了数据模型操作、业务逻辑、视图的代码。这里已经是简化的,真实的业务会有更多逻辑以非线性的结构交叉在一起,复杂度会在不知不觉中提升。
2. A 架构拆分伪代码
2.1 模型层示例
构建如下的商品模型,模型层与逻辑层的本质区别就是模型层是更原子的数据操作,可类比后端的数据库,或者领域模型中的实体抽象定义。
逻辑层会依赖模型层已构成完整的业务逻辑,而模型层是独立的,不会依赖逻辑层的任何东西。
这里使用 dva 做为建模工具,你可以用其它的状态工具或者纯 js 对象都可以
exportdefault { namespace: 'PortalGoodModel', state: { pageNum: 1, pageSize: 10, hasNextPage: true, goodsData: [] }, reducers: { updateGoodsData (state, payload) { // 更新数据操作 } } }
这里值得说明的是,前端开发的同学可能更适应交互模型的思维(与界面的接近度高),所以这里并不强制建立一个独立的子模型,与代码物理上的隔离相比,我觉得逻辑上的隔离更有必要。
2.2 逻辑层示例
逻辑层与视图层的边界判断标准是该状态是否在业务逻辑中需要,若需要则应该在模型层建模,在逻辑层编写依赖逻辑,视图层转换为渲染需要的 UI State
- 提供
getPageData
接口给视图层使用 - 拆分出获取地理位置和获取商品数据的子逻辑模块
- 子逻辑模块之间、逻辑层与视图层之间以接口的形式通信
如下为核心逻辑模块,该模块负责与视图间的通信交互:
// 该 presenter 会与视图关联,提供给视图使用的接口// PortalPresenter.jsexportdefaultclassPortalPresenter { getPageData (obj) { // 获取地理位置信息是 PortalLocationPresenter 做的,而拿到地理信息后// 该怎么去用则是另一个模块的事情this.$PortalLocationPresenter.onLocation({ success: () => { this.$PortalGoodsPresenter.fetchData(obj) } }) } }
地理信息处理模块:
// PortalLocationPresenter.jsexportdefaultclassPortalLocationPresenter { asyncgetInfo () { constlocationInfo=awaitthis.dispatch({type: 'PortalLocationModel/updateInfo'}) returnlocationInfo; } asynconLocation (obj) { const { adCode, userAdCode, longitude, gpsValid } =awaitthis.getInfo(); // 定位失败if (!(adCode&&userAdCode&&longitude) ||!gpsValid) { this.dispatch({ type: "PortalLocationModel/updateLocationStatus", value: 'error' }) obj?.fail(); } else { this.dispatch({ type: "PortalLocationModel/updateLocationStatus", value: 'success' }) obj?.success(); } } }
如下为处理商品数据逻辑的子模块:
exportdefaultclassPortalGoodsPresenter { name='PortalGoodsPresenter'asyncfetchData (obj) { // 请求参数处理constrequestParams=utils.getRequestParams({ pageData: this.$page.props, location, }); constres=awaitfetch(requestParams); if (res.code!==1) { obj?.fail() } else { constresult=this.processResult(res); this.dispatch({ type: 'PortalGoodModel/updateGoodsData', result }); obj?.success(); } } }
2.3 视图层示例
- 做纯 UI 的渲染操作,回归
ui = fn(state)
的本质 - 视图层使用逻辑层提供的获取数据接口去拿到数据
- 获取异步数据过程中涉及的 UI 操作依然在视图层处理
- 如定位失败的 toast 提示
- 如是否显示空数据的 UI (该 UI 逻辑层永远也用不到)
{ state (state) { return { state, // 是否有数据,纯 UI 内部状态noResultPageVisible: state.PortalGoodModel.goodsData.length } } ready () { this.getPageData(); }, getPageData (info= {}) { this.$presenter.getPageData({ type: info.type, // 获取数据类型fail (errorInfo) { if (errorInfo.type==='locationError') { // from 代表调用类型if (info.from==='onErrorRetry') { utils.toast('请开启定位'); } } } }); } }
拆分后的代码会更聚焦,每一层做的事情隔离开,依赖关系也会更清晰。