小程序开发中的数据仓库与数据流思考

最近在写支付宝小程序,支付宝小程序相比较微信小程序,更加缺少一些框架/工具/以及生态环境,借着这个时机我们一起来探讨一个问题

  • 小程序最原始的开发模式有什么弊端?
    • 为什么我们很需要一些大型框架
  • 怎么在小程序中科学的运用大型开发框架的设计思想?
    • 如何在缺乏现成框架的情况下,学习思想,初步自己灵活运用起来
    • 如何在已有现成框架的情况下,深度解读框架设计思想

这其实是一篇循序渐进的小程序实践记录。对于支付宝小程序:从一开始缺乏整体框架,深感不便。到决定自己融合框架思想自行实现。再到公司内有更优更全面的整体框架后追求的最佳实践。

目录:

  • 原始小程序开发中面临的问题
  • 构建一个 store 初步实现数据仓库

小程序开发中面临的问题

原始的小程序开发模式下,天然具备了页面的 data 数据与 xml 渲染 mvvm 能力,同时也维护好了整个 app 与页面 page 的生命周期,在实际开发过程中已经比没有主流框架支持下的前端页面开发要便捷的多。但相比于前端广泛使用的 Vue 开发框架,以及蚂蚁内部对 Vue 进一步封装出来的 Kylin 框架来说,小程序的原始开发模式还是非常原始,存在着非常多的弊端与开发效率问题,逐一举例:

  • 全局状态管理
  • 跨页面跨组件通信
  • computed 计算能力
  • 数据 Mock 能力
  • 研发部署工作流问题

全局状态管理问题

在原始的小程序开发模式下,全局的状态只能挂在 app.js 内,可以考虑给 app 对象加一个 globalData 的属性,用来存放和管理全局变量,并且可以在任意代码通过 app 进行访问。

1
2
3
4
5
6
7
8
9
10
App({
globalData: {
userName:'hehe'
},
onLaunch(options) {},
})

// 在页面访问全局状态
const app = getApp();
let userName = app.globalData.userName

但是小程序的开发中其实是有一种 mvvm 的响应式设计思维融入其中的,页面上的数据可以做到 setData 的时候响应式去改变界面中的渲染内容,但仅限 page 页面内的 data 数据,我能不能让 globalData 也做到这样的响应式。能让我 app 的每个页面,每个组件,但凡需要展示 UserName 的情况下,只需要再 axml 中使用全局 globalData.userName ,就能做到任何时候有任何人操作修改了 globalData.userName ,其他的页面(包括已经展示出来的页面),都能响应式的变更渲染内容?汇总一下我们面临的痛点

  • 希望在页面/组件的 axml 中,能够可以直接使用全局 globalData 数据进行渲染
  • 希望 globalData 在发生变化的时候,能够响应式的通知所有用到的页面/组件,更新对应渲染元素

跨页面跨组件通信

说完了所有组件对全局状态的痛点,我们再聊聊页面/组件间的通信,小程序原始开发框架中最头疼的莫过于跨页面跨组件进行通信,几乎是完全隔离的,有限的通信手段也非常的不易用,这里举一些例子

  • 跨页面数据传递问题

page A 向 page B 传递数据有且只有一个方法,将数据拼接成 url 的 query 然后通过 navigateTo 传递给下一个页面,在 page B 的 onLoad 方法中读取 options 入参。

痛点1: 还有没有别的办法?有,很山寨的让 page A 在 app 的 globalData 上挂一个全局变量,在 page B 的 onLoad 时机读取这个全局变量,这种方法实在太low了,全局变量太多非常的不易维护,并且 app 对象所有人都可以操作,也会存在风险。

痛点2: 如果我要传递大量数据,嵌套型数据怎么办?比如我要传递的是一个 object 对象,里面不仅有很多 key value 还有一个 key 的 value 是一个数组,数组里面依然是各种对象,这种情况下怎么传递?各种 key value 还算可以通过拼 url 的方式,那不知道长度的 Array 数组如何拼接 url ?整个 object 对象,用 JSON.stringify 变成字符串,然后经过 urlencode 后拼接进入 url ? 太麻烦了。

  • 深层组件嵌套数据传递问题

page 页面内含有 component A ,这个组件包含引用了 component B ,B 又包含引用了 Component C,这种 page -> component A -> component B -> component C 的界面嵌套层级,如果 component C 希望访问 page 才有的数据该怎么做?在原始的小程序开发方案中,只能通过 component 的 props 一层层透传下去,同一个数据在3个组件中都得写一份并且传递给下一个组件,这个过程耦合了4个页面/组件,而只有 C 才会使用到。

就好像对于全局状态管理的诉求一样,我们希望在组件 C 中有更方便更解耦的方式来访问跨组件乃至跨页面的数据,并且能够符合小程序 mvvm 的响应式设计思想,一旦访问的数据发生变化,组件 C 也能自动触发元素渲染变更。

痛点1: 希望能够在组件 C 中直接访问其他组件/其他页面的 data 数据
痛点2: 希望能够将组件 C 的 axml 中的其他组件/其他页面 data 的渲染元素,能够响应式的自动根据原数据变化触发渲染更新

  • 跨页面(主要是跨页面,跨组件理论也需要支持) 函数调用

在老的前端多页应用开发模式下,2个页面之间是几乎不存在相互调用的问题的,如果 page B 页面执行了某些操作需要在 page B 页面关闭后跳转刷新 page A 页面。一般都会把数据提交给服务器,然后在 page A 页面加载的时候,再从服务器拉取这些数据,而这些数据有可能不见得需要落库存db,有可能只不过是前端中转的一些数据而已,通过服务器就太浪费了。于是前端有 shared worker 可以实现这种页面之间通信。也可以使用单页应用 SPA 的开发模式,用成熟的 Vue 等框架进行组件间调用和通信。

但是到了原始的小程序开发模式里,所有 page 之间想要进行调用通信就变得很难。虽然小程序本质上所有页面是运行在同一个 JSContext 的 JS 虚拟机上下文中,本质上完全应该可以进行相互通信,但小程序的框架层面并没有开发对应的 page 之间的通信 api,必须自己想办法。

痛点1: 仿照前端网页开发的方案,把这些数据提交给服务器中转存储?在新页面展现的时候从服务器拉取?说实话这样可以,但这些没必要的网络通信无形中也在浪费着用户的流量与服务器的压力

痛点2: 利用 globalData 全局暂存临时对象,在 navigateTo 跳转到下一个页面之前,把当前页面的 this 对象挂在全局,当作临时对象暂存,在新页面 onLoad 的时候从全局变量中补货这个临时对象,自己持有,需要的时候直接调用暂存页面 page 的方法。这种临时变量的方案没啥可说的,能不用就别用了。

computed 计算能力

习惯了前端页面使用 vue 开发的同学应该都会对 vue 的 computed 与 kylin 的 getter 有所了解,他能够很方便的对数据进行加工再返回页面进行渲染。而在小程序的原始开发模式下,是缺乏这种能力的。

我们终端团队之前没参与过 kylin 开发的同学可能不太了解,那么举几个最简单的例子:业务需求中存在着用户之间的交易行为,大量的地方都在展示着金钱,而金钱的展示需要进行一定的格式化,比如无论是否整数还是小数都得转化为保留2位的格式化比如998.00然后再进行展示。但是服务端下发的数据都是 number 类型,page 中存储的也应该是 number 类型方便后续的计算。

在小程序的里没有提供相关的计算能力于是只能这么写,再网络返回的数据回掉中同时 set 2个数据,这样就要求任何时候操作 money 的时候,都要同步维护 moneyForShow 的值,如果忘记了,那么页面就不会正常展示。

1
2
3
4
5
6
7
8
//在网络请求中调用
this.setData{
money: result.money;
moneyForShow: utils.money2show(result.money)
}

//在axml中使用
<text>{{moneyForShow}}</text>

还是希望能有类似 Vuex 中 computed 的能力,在 page 的 data 中只维护一个值 money ,而定义一个 moneyForShow 的 getter 函数,在 axml 中直接写 moneyForShow 这个 getter,就能正常的渲染,并且还能保证响应式的数据同步,每当 money 发生变化,通过 moneyForShow 这个 getter 渲染的元素也能自动刷新。

数据 Mock 能力

小程序框架提供了 HttpRequest/Rpc/Mtop 等网络通信的能力,但 Rpc/Mtop 这两种网络请求能力是必须依托在支付宝钱包客户端内才能生效的 jsbridge 能力(大家申请的内部小程序都是 web 小程序,某种程度上讲就是 nebula 容器内核,所有 jsbridge 理论都能直接使用)。但是小程序官方提供的 IDE 开发环境并不是钱包环境,调用 Rpc/Mtop 的请求的时候会直接失败。换句话说在没有 mock 能力的支持下,我们平日里开发的小程序根本不可能在官方 IDE 环境中正常开发调试。官方提供的另外一种命令行 appx + hpm 模拟器的开发模式可以一定程度的解决这个问题。

就好比在开发 kylin 离线 h5 应用的时候,在 chrome 浏览器里也是无法发起 rpc 的,只能通过 hpm 模拟器在支付宝 app 中运行,但 kylin 框架是提供了完善的 mock 方案了

痛点1: 在缺乏架构层面的 mock 解决方案的情况下,想要进行 mock 开发(或者希望在官方 IDE 中进行调试),每个业务只能自行把 json 数据硬编码到临时测试代码里,然后侵入业务逻辑的进行修改返回,这种侵入业务代码的 mock 方式并不优雅。

痛点2: 不仅仅网络请求需要 mock ,有一些 jsapi ,甚至小程序的 api 也需要 mock ,举个例子,getAuthUserInfo 这个 api 是用来获取用户授权后的用户信息的,但因为 appx + hpm 模拟器的开发模式下,用户授权环节环境差异,这个 api 一定会返回失败,所以在这个环境下,这个小程序 api 也许要 mock 能力

研发部署工作流问题

小程序官方推荐的 IDE 研发工作流是一套独立在前端 basement 平台之外的工作流。有着自己的正式环境+发布平台,开发环境+发布平台。更详细的工作流可以参见 小程序环境部署

  • 开发期
    • 用官方 IDE 连开发环境进行开发
      • 用 IDE 模拟器模拟
      • 用 IDE 打包上传生成二维码 + 真机扫码进行调试
    • 不用官方 IDE 用其他编辑器进行开发
      • 用 appx run web ios + hpm 模拟器进行模拟
      • 用 appx run qrcode 生成二维码 + 真机扫码进行调试
  • 测试期
    • 用官方 IDE 连开发环境进行打包
    • 打稳定包上传开发环境发布平台
    • 用开发环境发布平台生成稳定二维码
    • 提供二维码给测试
  • 发布期
    • 用官方 IDE 连正式环境进行打包
    • 用稳定包上传正式环境发布平台
    • 在发布平台进行预发验收
    • 在发布平台进行提交审核

痛点: 这里面有一个最关键的问题是,官方 IDE 的工作流都是基于打包人员本地代码的!并不是通过编译打包平台直接捞取仓库主干里那些经过 codereview 后的代码。一旦打包人员进行打包上传的时候,使用的不是仓库中的最新的正确代码,或者打包人员本地调试的时候有略微改动,忘记了就直接打了稳定包进行发布,这种情况将无法保证发布代码的质量!

构建一个 store 初步实现数据仓库

在初期调研准备的时候,参考了微信小程序的一些实战经验。尤其是关于页面组件间通信/关于全局状态管理这块,都有不少成熟的解决方案,比如使用很广泛的基于微信小程序的上层框架 wepy 。但是在支付宝小程序中缺乏这种整体的框架级解决方案,所以我们需要自己来实现一个功能相对简单,能暂时满足基础通信需求的“山寨方案”。同时因为时间问题这个山寨方案支持能力也非常有限,也并不能很好的满足上面的所有痛点,只是解决了最关键的两个问题

EventBus来实现跨页面跨组件通信

在原始的小程序开发过程中,对跨组件跨页面进行通信有着严格的限制。因为整个小程序的任何页面任何 js 代码都是运行在同一个 JSContext JS上下文中,也就是小程序的 Service Worker 环境中,所以本质上他们是完全可以进行通信只不过是受小程序约束所致。

如果我们自己实现一个全局的 eventBus 并挂在 app 对象上,让各个需要发起通信的地方调用 app.event.emit() 发出通知,让需要接收通信的地方调用 app.event.on() 监听通知,就能实现初步的跨小程序自身框架的通信能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//简单思路
class Observer {
constructor() {
//初始化 callback map 字典
}
on(eventName, callback) {
//将传入 callback 添加到 eventBus 对象的 key 为 eventName 的数组中
//添加对 eventName 的监听
}
emit(eventName, param) {
//遍历 eventBus 对象的 key 为 eventName 的数组
//依次调用数组中存放的 callback 传入参数 param
//发出 eventName 的消息
}
clear(eventName) {
//清理掉 eventBus 对象的 key 为 eventName 的数组中所有值
//移除对 eventName 所有监听
}
off(eventName, callback) {
//清理掉 eventBus 对象的 key 为 eventName 的数组中的 callback这个值
//移除对 eventName 的具体某个监听
}
}
export default Observer;

但这种模式存在着一定的弊端,因为 eventBus 的通知模式是一种一对多的调用模式,并不适合设计出能支持返回值的 eventBus ,所以如果需要跨页面跨组件通信,获取一定的返回数据,则需要通过2条消息,一去,一回来实现。eventBus 虽然具备一定的弊端,但却是自己实现响应式 mvvm 的核心。如果想要构建出超过小程序页面 page 自身的 data & axml 的 mvvm 能力,那么至少需要在构建起这么一套 eventBus。

但受限于业务的时间非常紧迫,在确定能满足了一期业务的需求的情况下,并没有深入对 eventBus 进行进一步优化与扩展。

全局状态的管理与控制

在业务的需求中,确实存在需要全局管理一些通用数据,并且被全局各处页面 axml 使用通用数据的情况,比如 UserName / UserAvatar / WindowHeight / DeviceInfo 等。又因为这些数据大多来自异步的 JSApi 所以存在 app 初始化后数据并未准备好,异步请求回来后必需响应式的同步刷新所有可能出现并渲染出来的元素。

所以我们的思路就是将 app 下面的 globaData 设计为一个数据仓库,进行统一的维护和管理,想要操作这个仓库里的数据必须通过指定的方法 app.store.commit(key,payload) 来执行,不能通过别的方式。当执行 commit 的时候会通过 eventBus 发出一个 key 变更的通知,来通知各个页面进行数据变更。同时需要 Hook 每个 Page 的生命周期,在 Page OnLoad 的时候,自动的帮助页面开发者添加上 eventBus 的 key 变更的监听,每当 commit 全局发出了通知,监听就会自动生效,将新的 globaData,执行 setData 写入当前 page,从而触发 axml 的页面渲染刷新。

  • app.store 提供 commit 能力,进行数据仓库的统一提交管理
    • 每当触发 commit 全局发送对应 key 数据变更的通知
  • app.store 提供 hook 页面的能力,在 OnLoad 时机进行自动化处理
    • 将需要的全局仓库里面的数据的 key 通过 setData 写入当前 page
    • 对需要的 key 监听其数据仓库变化通知
      • 当任意地方触发 commit 发出了对应 key 数据变更的通知从而触发监听
      • 将通知带来 key 与 新value 通过 setData 写入当前 page
      • 触发页面的响应式渲染更新
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// 简单思路
class Store extends Observer {
constructor() {
super();
this.app = null;
}
// hook app 的创建,将store自己,自动挂载在 app 对象上,便于随时随地调用
createApp(options) {
const {
onLaunch
} = options;
const store = this;
options.onLaunch = function (...params) {
store.app = this;
if (typeof onLaunch === 'function') {
onLaunch.apply(this, params);
}
}
return options;
}
// 当调用 commit 的时候,更新 globalData 的值,同时 emit 发出通知
commit(action, payload) {
this.app.globalData[action] = payload;
this.emit(action, payload);
}
// hook page 的生命周期,将用户需要的 globalData key 设置到 page 的 data 之中
// 同时设置监听,监听来自 commit 的 key 变化通知,更新 page 的 data
createPage(options) {
const {
globalData = [],
watch = {},
onLoad,
onUnload
} = options;
const store = this;
const globalDataWatcher = {};
const watcher = {};
// 劫持onLoad 绑定监听
options.onLoad = function (...params) {

store[bindWatcher](globalData, watch, globalDataWatcher, watcher, this);
if (typeof onLoad === 'function') {
onLoad.apply(this, params);
}
}
// 劫持onUnload 解绑监听
options.onUnload = function () {
store[unbindWatcher](watcher, globalDataWatcher);
if (typeof onUnload === 'function') {
onUnload.apply(this);
}
}
delete options.globalData;
delete options.watch;
return options;
}
// hook component 的生命周期,功能作用类似 page
createComponent(options) {
// 具体实现参考 page
}
// 绑定监听的具体操作
[bindWatcher](globalData, watch, globalDataWatcher, watcher, instance) {
const instanceData = {};
let that = this;
globalData.forEach((prop)=>{
instanceData[prop] = that.app.globalData[prop];
globalDataWatcher[prop] = payload => {
instance.setData({
[prop]: payload
})
}
that.on(prop, globalDataWatcher[prop]);
})

for (let prop in watch) {
watcher[prop] = payload => {
watch[prop].call(instance, payload);
}
this.on(prop, watcher[prop])
}
instance.setData(instanceData);
}
// 解绑监听的具体操作
[unbindWatcher](watcher, globalDataWatcher) {
// 页面卸载前 解绑对应的回调 释放内存
for (let prop in watcher) {
this.off(prop, watcher[prop]);
}
for (let prop in globalDataWatcher) {
this.off(prop, globalDataWatcher[prop])
}
}
}
const store = new Store();
export default store;

这种思路其实还是存在弊端的,因为只解决了所有页面/组件,对于 global 数据仓库里的依赖,以及响应式渲染。如果想进一步解决,page A 对 page B 的 data 响应式数据依赖,乃至 component C 对 page A 的 data 响应式数据依赖,则需要进一步加强数据仓库 store 的管理范围,不仅仅维护 globalData 的数据, 还要将每个页面或者每个组件,都抽象出一个 store 子仓库,统一被 global store 进行管理,这样 app 的 store ,page 的 store ,component 的 store,相互之间通过父子关系构成了一个以 global store 为根的树型解构,从而实现所有页面与所有组件间的数据管理。而每个子仓库的数据字段为了避免重名不好识别,通过 pagename-keyname 或者 pagename-componentname-keyname 来当作 keypath 进行区分管理。

  • 不只构建一个 global store,为每个 page 每个 component 分别构建一个store
  • 以 global store 为根,以界面嵌套关系为层级,将每个 store 关联起来,形成一个 store 树
  • 整个树统一进行管理,统一进行 commit 提交后数据更新以及 event 发送
  • 以 keypath 作为不同 store 节点下的数据字段区分
  • 响应式的实现跨页面跨组件的数据更新以及界面变更渲染

但受限于业务的时间非常紧迫,在确定能满足了一期业务的需求的情况下,并没有深入对 store 进行进一步优化与扩展。
其实在这个思路下进行扩展,就会形成诸如 wepy 一样的类 vue 的小程序开发框架,在深入的话其实更应该直接去学习 wepy 的源码来深入理解数据仓库框架的设计