过去的一年都扎在 SPA 应用开发里了,直到现在偶尔还会回顾和思考关于数据管理方面还有哪些可以改善的地方。过年后在找工作的期间,发现有些面试官都理解不了我描述的项目经历中的项目复杂度,也许是所在城市(郑州)的原因,水太浅。现在对过去的一些经验做一下总结,也期望与相关领域的开发者一起交流。

为何复杂

复杂单页应用的特点:

  • 无刷新体验,全靠 Ajax 请求或 WebSocket 推送更新数据
  • 一种数据显示在多个视图区块
  • 存在使用率高的热数据,可随时调出并保持数据新鲜

想象一下这样的场景:

视图右上角显眼处显示了当前用户相关的头像、名字等用户信息。进入一个项目模块,显示了所有的有权限或公开项目,每个项目块上都展示该项目的管理者和参与者(1~N 个用户信息)。点击项目进去项目下的任务列表,每个任务块上都展示了该任务的负责人和参与人。

假设该用户修改的用户头像,则该用户 UI 右上角头像需要更新,其次是项目列表或任务列表中,所有包含该用户头像展示的地方需要更新。最后是其他在线用户的 UI 上的项目或任务列表中存在该用户信息,也需要更新。

核心实现

做一个复杂的单页应用一定需要后端的支持和配合,前端对接口和推送的数据结构要有话语权,如果做不到这一点后续的各种实现会非常麻烦。

由于项目使用了基于 Vue 的技术栈来开发,早期我们就根据 Vue 的特点制定了数据管理的核心思想:

  • API 只针对单一数据模型返回数据,所有视图的数据聚合、过滤等由前端完成
  • 前端按模块存储数据,由视图层拼装业务数据

整体下来,所有的数据从请求到视图渲染之前都是单一模型的数据,只到视图层渲染时才根据业务去组合需要的数据。

这样做的好处是:不管一个数据在 UI 上有多少个区块显示,但最终的数据来源都存在于前端的某个唯一的存储模块内。这样当这个数据发生修改时,只需要在这个存储模块内修改了这条数据,所有的区块视图 都会得到更新。

不管是请求数据还是推送数据,都只是把数据扔到前端的存储模块内。只要某个视图存在对某一条数据的引用,那么到需要更新的时候自然会更新。

模块即服务

模块即服务,这个概念是我们在开发过程中逐步发现的一个趋势。

所谓的 模块,在项目中具体的代表是 Vuex 中的一个 stroe 模块。

举例来说,一个 task模块 既存储了当前所有的任务数据,也包含了对任务数据的所有操作。而任务数据在整个应用中的表现形式不止于任务列表一种。可能在 A 路由中表现为任务列表,B 路由中表现为某个用户参与的所有任务。但是归根结底两种表现形式背后需要的数据结构是类似的,某些功能也可能类似(比如分类、过滤等操作)。那么这个模块就得到复用,除了请求数据的接口不同,但请求完成后都把数据放到 task 模块中,不管最终表现为哪种视图都引用 task 模块的数据去组成业务数据。

数据即业务

根据前面所述,如果一个视图引用 task 模块的数据去组成业务数据,那么之后必然要对后续 task 相关的业务操作得到响应。

所有的业务操作回归到数据上,都属于增、删、改操作。所以视图模型必须从数据本身来描述业务。数据模块中增加、删除、修改一条数据,必须正确的反馈到视图模型中。

我们大量使用了 Vue 中的 计算属性 来实现数据即业务。

就拿 当前用户创建的所有任务 这个业务来说,计算属性可以表示为:

this.$store.state.taskModule.taskList.filter(item => item.creator === this.loginUserId)

后续推送了 task 相关的数据就会添加到任务模块中,对 task 的增、删、改操作也是去操作任务模块里的数据。最终对于视图来说,只要数据满足计算属性的描述,那么视图就得到更新。

降低数据操作复杂性

由于数据模块中一般存储了一种数据模型的集合(数据),那么在模块内的删、改类操作时都需要对原数据集进行循环遍历。

我们之后对一些模块尝试了 扁平化数据结构

// 原数据
;[
{
id: 't1',
name: 'aaa',
creator: { userName: 'sfs', userId: 'u1' },
tags: [
{
id: 't1',
name: 'tag1'
},
{
id: 't2',
name: 'tag2'
}
]
},
{
id: 't2',
name: 'bbb',
creator: { userName: 'sfs', userId: 'u1' },
tags: [
{
id: 't2',
name: 'tag2'
},
{
id: 't3',
name: 'tag3'
}
]
}
// ...
]

从上面的数据结构,可以想象,修改一条任务的属性都需要进行循环查找才可修改,而如果是像 tags -> t2 这种深层次对象修改,又需要多一层循环。

// 打平后
{
taskList: {
t1: {
id: 't1',
name: 'aaa',
creator: 'u1',
tags: ['t1', 't2']
},
t2: {
id: 't1',
name: 'bbb',
creator: 'u1',
tags: ['t2', 't3']
}
},
taskIds: ['t1', 't2'],
userList: {
u1: { userName: 'sfs', userId: 'u1' }
},
userIds: ['u1'],
tagList: {
t1: {
id: 't1',
name: 'tag1'
},
t2: {
id: 't2',
name: 'tag2'
},
t3: {
id: 't3',
name: 'tag3'
}
},
tagIds: ['t1', 't2', 't3']
}
  • 数据打平为一层,对象关联通过 id 引用来描述
  • 每一种数据都单独拆分出来数据集和 id 集合两种形式,一种用来取值,一种用于顺序描述
  • 给定 1 个 ID,就可以很快获取到对应的值
  • 修改时减少循环遍历,但增加、删除时需要在两种数据形式上作修改

数据操作这一块可以继续抽象,像一些 ORM 框架一样,形成声明式 Model,解放重复编码。