作者简介 导演 蚂蚁金服·数据体验技术团队
蚂蚁金服数据平台前端团队主要负责多个数据相关的 PC Web 单页面应用程序,业务复杂度类比 Excel 等桌面应用,业务前端代码量在几万行~几十万行,随着产品不断完善,破百万指日可待。管理好 10 万行级甚至百万行级代码的前端应用,是我们团队的核心挑战之一。
接下来的系列文章,我会尝试从以下几个角度介绍我们团队应对挑战的方法:
- 前端架构
- 质量保障
- 性能优化
- 团队前端开发流程
- 人员素养
团队的架构方案是多个产品经历一年的持续迭代,不断摸索出来的一套适合本团队数据产品业务场景的架构方案,架构方案中还存在尚未解决的痛点和有争议的部分需要持续优化,不保证这套架构适合您的产品。
先介绍下我们团队的产品特点:
- ToB 产品,业务复杂度高、业务理解门槛高;
- 前端代码量巨大(数据分析产品从零开始经历 8 个月迭代业务代码 8 万行,仅实现了产品长期规划需求的 20%)
架构的目的是管理复杂度,将复杂问题分而治之、有效管理,我们的具体方法如下:
这里的“页面级”粒度指一个路由映射的组件
划分原则:
- 纵向:通过业务功能(可根据视图模块判断)划分
- 横向:通过 Model-View-Controller 三种不同职能划分
继续细分粒度,然后将可复用模块或组件抽离到公共区域
数据模型根据职责分成两类:
- Domain Model 领域模型
- App State Modal 应用状态模型
领域模型是业务数据,往往要持久化到数据库或 localStorage 中,属于可跨模块复用的公共数据,如:
- Users 用户信息
- Datasets 数据集信息
- Reports 报表信息
领域模型作为公共数据,建议统一存放在一个叫做Domain Model Layer的架构独立分层中(前端业界一般对这层的命名为 ORM 层)。
下沉到 Domain Model Layer(领域模型层)有诸多利处:
- 跨模块数据同步问题不复存在,例如:之前 Users 对象在 A 和 B 两个业务模块中单独存储,A 模块变更 Users 对象后,需将 Users 变更同步到 B 模块中,如不同步,A、B 模块在界面上呈现的 User 信息不一致,下沉到领域模型层统一管理后,问题不复存在;
- 除领域模型复用外,还可复用领域模型相关的 CRUD Reducer,例如:之前 Users 对象对应的 Create Read Update Delete 方法可能在 A 和 B 两个业务模块各维护一套,下沉到领域模型层统一管理后,减少了代码重复问题;
- 自然承担了部分跨模块通信职责,之前数据同步相关的跨模块通信代码没有了存在的必要性;
应用状态模型是与视图相关的状态数据,如:
- 当前页面选中了列表的第 n 行 currentSelectedRow: someId
- 窗口是否处于打开状态 isModalShow: false
- 某种视图元素是否在拖拽中 isDragging: true
这些数据与具体的视图模块或业务功能强相关,建议存放在业务模块的 Model 中。
组件根据职责划分为两类:
- Container Component 容器型组件
- Presentational Component 展示型组件
容器型组件是与 store 直连的组件,为展示型组件或其它容器组件提供数据和行为,尽量避免在其中做一些界面渲染相关的事情。
展示型组件独立于应用的其它部分内容,不关心数据的加载和变更,保持职责单一,仅做视图呈现和最基本交互行为,通过props
接收数据和回调函数输出结果,保证接收的数据为组件数据依赖的最小集。
一个有成百上千展示型组件的复杂系统,如果展示型组件粒度切分能很好的遵循高内聚低耦合和职责单一原则的话,可以沉淀出很多可复用的通用业务组件。
- 所有的 HTTP 请求放在一起统一管理;
- 日志服务、本地存储服务、错误监控、Mock 服务等统一存放在公共服务层;
按照上面三点合并同类项后,业务架构图变更为
模块粒度逐渐细化,会带来更多的跨模块通信诉求,为避免模块间相互耦合、确保架构长期干净可维护,我们规定:
- 不允许在一个模块内部直接调用其他模块的 Dispatch 方法(写操作、变更其他模块的 state)
- 不允许在一个模块内部直接读取其他模块的 state 方法(读操作)
我们建议将跨模块通信的逻辑代码放在父模块中,或者在一个叫做Mediator层中单独维护。
最终得到我们团队完整的业务逻辑架构图:
刚刚从空间维度讲了架构管理的方案,现在从时间维度说说应用的数据流转 --- Redux 单向数据流。
Redux 架构的设计核心是单向数据流,应用中所有的数据都应该遵循相同的生命周期,确保应用状态的可预测性。
- 用户操作行为:click drag input ...
- 服务端返回数据后续的行为
每个 Action 都会对应一个数据处理函数,即 Reducer。特别强调,Reducer 必须是纯函数(pure function),这个规定带来一个非常大的好处,数据处理层代码变的非常容易写单元测试。
纯函数的特征是入参相同的情况下,返回值恒等,举个栗子🌰:
纯函数:
function add(a, b) {
return a + b;
}
非纯函数:
function now() {
let now = new Date();
return now;
}
函数中如果包含 Math.random
,new Date()
, 异步请求等内容,且影响到最终结果的返回,即为非纯函数。
Store 数据存放的地方,store 保存从进入页面开始所有 Action 操作生成的数据状态(state),每次 Action 引发的数据变更都必须生成一个新的 state 对象,且确保旧的 state 对象不被修改。这样做可以保证 应用的状态的可预测、可追溯,也方便设计 Redo/Undo 功能。
我们团队使用轻量级的 immutable 方案immutability-helper
,相比完全拷贝一份(deep clone)性能更优、存储空间利用率更高。
immutability-helper
的 API 不够友好,我们写了一个库immutability-helper-x增强它的易用性。
immutability-helper API 风格:
import update from 'immutability-helper';
const newData = update(myData, {
x: {
y: {
z: { $set: 7 }
}
},
});
immutability-helper-x API 风格:
import update from 'immutability-helper-x';
const newData = update.$set(myData, 'x.y.z', 7);
React/Redux 是一种典型的数据驱动的开发框架(Data-Driven-Development),在开发中,我们可以将更多的精力集中在数据(领域模型+状态模型)的操作和流转上,再也不用被各种繁琐的 DOM 操作代码困扰,当 Store 变更时,React/Redux 框架会帮助我们自动的统一渲染视图。
监听 Store 变更刷新视图的功能是由 react-redux 完成的:
- <Provider> 组件通过
context
属性向后代<connect>组件提供(provide)store 对象; - <connect> 是一个高阶组件,作用是将 store 与 view 层组件连接起来(这里重复提一句,redux 官方将<connect>直接连接的组件定义为 container component),<connect>向开发者开放了几个回调函数钩子(mapStateToProps, mapDispatchToProps...)用于自定义注入 container component 的 props 的姿势;
- react-redux 监听 redux store 的变更,store 改变后通知每一个 connect 组件刷新自己和后代组件,为了减少不必要的刷新提升性能,connect 实现了 shouldComponentUpdate 方法,如果 props 不变的话,不刷新 connect 包裹的 container component;
严格遵循架构规范和单向数据流规范,可以保证我们的前端应用在比较粗的粒度上的可维护性和扩展性,对于更细的粒度的代码,我们组织童鞋学习和分享《设计模式》 和 《重构 - 改善既有代码的设计》,持续打磨和优化自己的代码,未来团队会持续输出这方面的系列文章。
本篇先聊前端通用架构,具体模块的业务架构、架构遵循的原则、团队架构组的架构评审流程等内容会在接下来的系列文章中阐述。感兴趣的同学关注专栏或者发送简历至 'tao.qit####alibaba-inc.com'.replace('####', '@'),欢迎有志之士加入~