一个多月没写博,最近一个月的 Github 提交数少了很多,总之就是各种状态不好。微博上看到上个月是水逆,看来我也是受灾群众了。😝

单页应用的一个特点就是即时响应,对发生变化数据实现 UI 的快速变更。实现的基础技术不外乎 AJAX 和 WebSocket,前者负责数据的获取和更新,后者负责变更数据的客户端同步。其中要解决的最主要的问题还是数据同步。

可以把这个问题拆分为两个具体问题:

  1. 数据共享:多个视图引用的数据能在发生变化后,即时响应变化。
  2. 数据同步:多终端访问的数据能在一个客户端发生变化后,即时响应变化。

发布订阅模式

在旧的项目中是使用了发布订阅模式解决这些问题。不管是 AJAX 请求的返回数据还是 WebSocket 的推送数据,统一向全局发布消息,每个需要这些数据的视图去订阅对应的消息使视图变化。

缺点是:一个视图为了响应变化需要写很多订阅并更新视图数据的硬编码,涉及数据越多,逻辑也越复杂。

数据流

对于 Vue,首先它是一个 MVVM 框架。

Model <----> ViewModel <----> View

一目了然的关系,Model 的变化影响到 ViewModel 的变化再触发 View 更新。那么反过来呢,View 更改 ViewModel 再更改 Model?

对于更新数据而言,更改 ViewModel 真是多此一举了。因为我们只需要改变 Model 数据自然就会按照Model > ViewModel > View的路径同步过来了。这也就是为什么 Vue 后来抛弃了双向绑定,而仅仅支持表单组件的双向绑定。对于双向绑定而言,表单算得上是最佳实践场景了。

在开发实践中,最常见的还是单向数据流。

Model --> ViewModel --> View --> Model

单向数据流告诉我们这样两样事:

  1. 不直接绑定 Model,而是使用由 1~N 个 Model 聚合的 ViewModel。
  2. View 的变化永远去修改变更值对应的 Model。
Data Flow
Data Flow

解决数据问题的答案已经呼之欲出了。

多个视图引用的数据在发生变化后,如何响应变化?

保证多个 View 绑定的 ViewModel 中共同数据来自同一个 Model

多终端访问的数据在一个客户端发生变化后,如何响应变化?

首先多终端数据同步来源于 WebSocket 数据推送,要保证收到数据推送时去更改直接对应的 Model,而不是 ViewModel。

Vue中的解决方案

不只是要思想上解决问题,而且要代入到编程语言、框架等开发技术中实现。

Model的存放

Model 作为原始数据,一般为 AJAX GET 得到的数据,应该位于整个 Vue 项目结构的最上层。对于 Model 的存放位置,也有不同的选择。

非共享Model

不需要共享的 Model 可以放到视图组件的data中。但仍然避免 View 直接绑定 Model,即使该 View 的 ViewModel 不再需要额外的 Model 聚合。因为最终影响 View 呈现的不只是来自服务器的 Model 数据,还有视图状态ViewState

来个🌰:一个简单的列表组件,负责渲染展示数据和关键字过滤功能。输入的过滤关键字和列表数据都作为 data 存放。

export default {
data() {
return {
filterVal: '',
list: []
}
},
created() {
Ajax.getData().then(data => {
this.list = data
})
},
methods: {
filter() {
this.list = this.list.filter(item => item.name === this.filterVal)
}
}
}

试想一下,如果 View 直接绑定了以上代码中的list,那么在filter函数执行一次后,虽然 View 更新了,但同时list也被改变,不再是一个原始数据了,下一次执行filter函数将是从上一次的结果集中过滤。
很尴尬,总不能重新请求数据吧,那样还搞什么 SPA。

现在我们有了新的发现:ViewModelModelViewState的双重影响。

ViewModel = 一个或多个 Model 组合 + 影响 View 展示的 ViewState
即: viewModel = models.fn(viewState)

Vue 中有没有好的方法可以很好的描述这个表达式呢?那就是计算属性computed

export default {
data() {
return {
filterVal: '',
list: []
}
},
computed: {
viewList() {
return this.filterVal
? this.list.filter(item => item.name === this.filterVal)
: this.list
}
},
created() {
Ajax.getData().then(data => {
this.list = data
})
},
}

改写代码后,View 绑定计算属性viewList,有过滤关键字就返回过滤结果,否则返回原始数据。这才称得上是 数据驱动

共享Model

如果一个 View 中存在多处共享的 Model,那么毫不犹豫的使用 Vuex 吧。

对于复杂单页应用,可以考虑分模块管理,避免全局状态过于庞大。即使是共享的 Model 也是分属不同的业务模块和共享级别。
比如文档数据,可能只有/document起始路径下的视图需要共享。那么从节省内存的角度考虑,只有进入该路由时才去装载对应的 Vuex 模块。幸运的是 Vuex 提供的模块动态装载的 API。

对于共享级别高的数据,比如用户相关的数据,可以直接绑定到 Vuex 模块中。

store
| actions.js
| index.js
| mutations.js
+---global
| user.js
+---partial
| foo.js
| bar.js

分模块管理后,马上就会遇到跨模块调用数据的问题。一个 View 中需要的数据往往是全局状态和模块状态数据的聚合,可以使用getter解决这个问题。

export default {
// ...
getters: {
viewData (state, getters, rootState) {
return state.data + rootState.data
}
}
}

如果一个 View 是需要多个模块状态的数据呢?

export default {
// ...
getters: {
viewData (state, getters) {
return state.data + getters.partialData
}
}
}

虽然不能直接访问到其他模块的 state,但是getteractionmutation都注册在全局命名空间,访问不受限制。

计算属性 vs Getter

Getter 与组件的计算属性拥有相同的作用,其中引用的任何 state 或者 getter 变化都会触发这个 getter 重新计算。

那么问题来了:什么时候我应当使用计算属性?什么时候使用 Getter?

这里其实是有一个 数据前置 原则:能放到上层的就不放到下层

  • 需要聚合多个 state 或 getter 时,使用 getter。如果有多个视图需要同样的数据组合就可以实现 getter 的复用。
  • 需要聚合的数据中包含 ViewState 时,使用 computed。因为在 store 中无法访问 ViewState。

至此我们已经保证了应用内的任何一个共享数据最终都来源于某个全局状态或某个模块的状态。

Model的更新

Model 的更新有两种,一种是本地触发的更新,另一种是其他客户端更新再由服务器推送的更新。

可以这样表示:

Model = 本地原始数据 + 本地更新数据 + 推送数据

我们似乎又回到了那个列表组件类似的问题上。要不把 3 种数据都设为 state,由 3 种数据组合的 getter 来表示 Model?
现在来比较一下。另外有一个前提是 Vuex 只允许提交 mutation 来更改 state。

单State

对于一个 state 的更新不外乎是增、删、改、查四种情况,所以至少对应有 4 个 action 和 4 个 mutation,直接对表示源数据的 state 进行更改。

export default {
state: {
data: []
},
mutations: {
init(state, payload) {
state.data = payload
},
add(state, payload) {
state.data.push(payload)
},
delete(state, payload) {
state.data.splice(state.data.findIndex(item => item.id === payload), 1)
},
update(state, payload) {
Object.assign(state.data.find(item => item.id === payload.id), payload)
}
},
actions: {
fetch({ commit }) {
Api.getData().then(data => {
commit('init', data)
})
},
add({ commit }, item) {
Api.add(item).then(data => {
commit('add', item)
})
},
delete({ commit }, id) {
Api.delete(id).then(data => {
commit('delete', id)
})
},
update({ commit }, item) {
Api.update(item).then(data => {
commit('update', item)
})
}
}
}

多State

如果把一个 Model 拆成多个 state,本地更新数据和推送数据统一为变更数据,对应到增、删、改、查四种情况,那就需要 4 个 state,即:originDataaddDatadeleteDataupdateData
mutation 和 action 到不会有什么变化,增、删、改原本就是分开写的,只是各自对应到不同的 state 上,最终的 Model 由一个 getter 来表示。

export default {
state: {
originData: [],
addData: [],
deleteData: [],
updateData: []
},
getters: {
data(state) {
return state.originData.concat(state.addData) // add
.map(item => Object.assign(item,
state.updateData.find(uItem => uItem.id === item.id))) // update
.filter(item => !state.deleteData.find(id => id === item.id)) // delete
}
},
mutations: {
init(state, payload) {
state.originData = payload
},
add(state, payload) {
state.addData.push(payload)
},
delete(state, payload) {
state.deleteData.push(payload)
},
update(state, payload) {
state.updateData.push(payload)
}
},
actions: {
// 略...
}
}

这么一大串方法链看起来很酷对不对,但是性能呢?任何一个 state 的变更都将引起这个复杂的 getter 重新执行 5 个循环操作。

知乎上有个相关问题的讨论:JavaScript 函数式编程存在性能问题么?
其中提到的解决办法是惰性计算。相关的函数库有:lazy.js,或者使用 lodash 中的 _.chain 函数。

还有一种办法是统一为K, V数据结构,这样一个混合函数就搞定了Object.assign(originData, addData, updateData, deleteData)

对比而言,我认为多 state 的方式更符合数据驱动及响应式编程思维,但需要有好的办法去解决复杂的循环操作这个问题,单 state 的方式就是面向大众了,两者都可以解决问题。甚至于全面使用响应式编程,使用 RxJS 替代 Vuex。

数据同步

前面提到过了,不管是本地更新数据还是服务端推送数据,可以统一为增、删、改三种接口。不管是本地更新还是推送数据,根据数据同步类型走同一个数据更改函数。

这在 Vuex 中很容易实现。利于 Vuex 的插件功能,可以在接受推送后提交到对应的 mutation。前提是要和后端约好数据格式,更方便的映射到对应的 mutationType,比如:{ 数据名,同步类型,同步数据 }

export default store => {
socket.on('data', data => {
const { name, type, data } = data
store.commit(type + name, data)
})
}

这样就实现了本地增、删、改与推送数据增、删、改的无差异化。

推荐