为何复杂

复杂单页应用的特点:

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

想象一下这样的场景:

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

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

核心实现

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

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

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

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

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

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

模块即服务

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

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

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

数据即业务

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

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

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

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

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

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

降低数据操作复杂性

由于数据模块中一般存储了一种数据模型的集合(数据),那么在模块内的删、改类操作时都需要对原数据集进行循环遍历。
比如说渲染一个树形菜单,一般后台后台返回的是多叉树结构,我们会通过类似于根据id值去递归遍历之类的操作
我们之后对一些模块尝试了扁平化数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 原数据
[
{
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 这种深层次对象修改,又需要多一层循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 打平后
{
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,解放重复编码