用户
 找回密码
 立即注册

QQ登录

只需一步,快速开始

扫一扫,登录网站

小程序社区 首页 教程 查看内容

用大型开发框架开发小程序那点事儿

Rolan 2018-11-23 00:48

最近在写支付宝小程序,支付宝小程序相比较微信小程序,更加缺少一些框架/工具/以及生态环境,借着这个时机我们一起来探讨一个问题小程序最原始的开发模式有什么弊端?为什么我们很需要一些大型框架怎么在小程序中科 ...

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

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

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

目录:

  • 原始小程序开发中面临的问题
  • 构建一个 store 初步实现数据仓库
  • LunaX 的小程序上层开发组件库
  • Lux 单 store 数据仓库实践
  • LunaX 的其他工具

小程序开发中面临的问题

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

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

全局状态管理问题

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

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 的值,如果忘记了,那么页面就不会正常展示。

//在网络请求中调用
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() 监听通知,就能实现初步的跨小程序自身框架的通信能力

//简单思路
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 进行进一步优化与扩展。

后续因为了解到 LunaX 的即将发布,是一套整体的小程序框架解决方案,以后也不打算持续优化,准备将多人账本项目着手迁移到 LunaX 上(下文会介绍)

全局状态的管理与控制

在多人账本的需求中,确实存在需要全局管理一些通用数据,并且被全局各处页面 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
      • 触发页面的响应式渲染更新
// 简单思路
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 进行进一步优化与扩展。

后续因为了解到 LunaX 的即将发布,是一套整体的小程序框架解决方案,他内部的 Lux 完全的吸收了前端 Vuex 的能力已经非常出色,所以后续就不打算进一步完善我们自研的 store ,准备将多人账本项目着手迁移到 LunaX + Lux 上(下文会介绍)

LunaX 的小程序上层开发组件库

LunaX 是一套从支付宝 h5 Hybrid 中心的提供的前端页面组件库 Luna 演进出的面向小程序的组建库。里面有着丰富的工具以及脚手架 cli 支持

LunaX 内置了一套数据仓库管理组件 Lux ,相比较我们自己初步实现的简单 store 数据管理 , Lux

有着更全面的能力支持,包括 state ,getter,mutation ,action 等仓库能力,以及 commit ,dispatch 的仓库操作,可以完全实现像 Vuex 那样以前端成熟的开发模式来进行小程序开发。

LunaX 也提供了强大的 Mock 组件,支持无论是 rpc 还是 http 还是 jsapi 的无侵入 mock 能力。支持白名单、黑名单过滤,支持网络延迟模拟等多种功能

组件库中封装了丰富的常用工具能力。包括带缓存,防重复提交,有统一交互的 rpc。封装了 Tracert,用于 SPM 埋点。封装了 clue,用于日志上报,监控报警。获取凤蝶区块数据。对 storage 增加了时间戳,解决兼容问题。等等实用功能

通过 luna-appx 的脚手架创建项目,配置好了统一的 .editorconfig, .eslintc, .gitignore,以及 ts 校验等配置。并且支持完全接入 basement



Lux 单 store 数据仓库实践

在前端开发种 Vue 与数据仓库 Vuex 是一种被广泛运用的开发框架。而 Lux 的设计初衷就是设计出一套核心 Api 与 Vuex 完全保持一致,但又可以脱离 Vue 单独在任意环境(自然包括小程序环境)使用的数据仓库 store。Lux 这种思路吸收了很多 Redux 的设计思想,就像 Redux 也可以在非 React 环境下使用一样,并且也支持中间件与插件机制。

由于 Lux 的核心 Api 与 Vuex 完全保持一致,在使用上几乎可以还原 Vuex 的开发模式,所以如果之前接触 Vuex 不多,可以先看一下官方文档: Vuex 官方文档 来了解基本概念,再参考 Lux 官方文档 来了解如何使用。

基本上数据仓库,主要就是 state 状态的概念,用来存储一切数据,而为了操作数据,衍生出了 getter , mutation , action 几种操作 , getter 用来对 state 的数据进行加工计算 , mutation 用 commit 触发来同步提交 state 变更, action 用 dispatch 触发来异步执行操作。

同时为了能将数据仓库的 state 与小程序的 axml 渲染进行 mvvm 关联,实现响应式数据刷新,Lux 提供了 connect 和 connect4c 2个方法用来 hook 小程序页面/组件的生命周期实现绑定关系,并且在 connect 的时候,可以通过传入 mapConfig 来灵活的自定义关联控制。

Lux 的基本使用模式


一个小程序页面创建好就会包含四个基本文件,.acss .axml .js .json。如上图,为了使用 Lux 专门给页面创建一个数据仓库目录 store 来重点维护所有数据相关的逻辑代码,并把业务逻辑按着 Vuex 的设计思想抽象成四大块。

// store/index.js 文件
export default {
    state: {
    	XX:xx
    },
    getters: {
    
    },
    mutaions: {
    	setXXData(commit,payload){
  			//同步 提交某个 state 更新
		}
			   
    },
    actions: {
    	requestXXRpc({commit},payload){
  			//异步 执行某些操作,在返回后再次调用 commit 提交数据更新
			xxRpcPromise.then((result)=>{
  				commit('setXXData',result)
			})
		}
    },
};

数据仓库已经创建好了,想要在 page 中进行使用就需要将 store 与 page 进行 connect 并且挂载到 page 对象上,能方便 page 对象操作仓库。

通过 mapStatesToData 与 mapGettersToData 两个配置信息,进行可选可控的绑定操作,这样就丢弃了 page 自己的 data ,而是通过数据仓库来关联 axml 数据渲染。

并且在 page.js 中一般情况下只处理 UI 响应等代码,一旦涉及到数据信息状态等内容的修改或者变更,同步的变更用 commit 提交给仓库处理,异步的任务用 dispatch 提交给仓库处理

其实绑定操作就是把数据仓库的 getter 和 states 自动添加到了 page 的 data 中,并且还可以响应式同步更新,只不过对使用者无感知

// 修改 page.js 进行绑定
import { connect } from '@alipay/lux'
import store from './store';

const options = {
  onLoad(options) {
      //触发mutation
	  this.$commit('mutation name',data)
  },
  onShow(options) {
	  //触发action
	  this.$dispatch('mutation name',data)
  },
};

const mapStatesToData = {
    //state map 代码 todo
	//但凡经过 map 过的 state 都可以直接在 axml 中使用,并且响应式同步刷新变化
	xxData: state => state.xxData,
};
const mapGettersToData = {
    //getter map 代码 todo
	//但凡经过 map 过的 getter 都可以直接在 axml 中使用,并且响应式同步刷新变化
	xxGetter: 'xxGetter',
};
const storeConfig = {
    mapGetters: mapGettersToData,
    mapState: mapStatesToData,
};

//将 store 与他的配置 storeConfig 和 page 进行绑定
Page(connect(store, {
  mapState: mapStateToData,
  mapActions: mapActionsToProps
})(options));

进行一个初始化

Lux 单 store 与多 store 的选择

其实在 Lux 官方文档 中已经有介绍这两种模式的使用差异了,这里再多废话一下

  • 多 store:一个 page 一个 store. 隔离性好, 页面间不会相互干扰
  • 单 store:整个小程序 App 只有一个 store. 交互性强, 页面间共用同一个 store 每一个 page 一个 module(子 store ), module 间可相互 dispatch/commit

这时候最关键就需要回想一下我们之前的那些个痛点了

  • store 相关的痛点
    • 全局状态管理:既然是全局,多store 肯定不满足,还是单 store 最合适
    • 跨页面跨组件通信:既然是组件页面之间交互,更需要单 store 来支持
    • computed 计算能力:哪种模式都通过 getter 支持了计算能力
  • store 无关的痛点
    • 数据 Mock 能力:LunaX 有 Mock 组件
    • 研发部署工作流问题:LunaX 有对接 basement

Lux 官方文档中对于单 store 和多 store 模式给的样例代码与使用说明都相对比较简单,整个多人账本算是一个比较复杂的一个项目,在实践中趟了很多坑,也找 LunaX 团队的同学交流,探讨过。在这详细的把整个项目的单 store 实践整理一下,也补上很多细节上容易产生的坑和问题。

Lux 的多人账本单 store 实践

单 store 最核心的就是把每一个 page 的子 store 当作一个 module 挂载到 app 的根 store 之下,而把有需要的 component 的子 store 当作一个 module 挂载到 page 的子 store下。最终所有的子 store 型成一个像树一样的整体,也就是挂载在 app 之下的根 store

component 如果没那么复杂可以考虑自己不实现子store,但可以 connect 链接到整个 store 下,从而能实现虽然自己没 store ,但可以自由的 map 别的 page,app 的 state 与 getter

构建 App 根 store

Lux 的单 store 使用方式是,单独对整个 App 构建一个 store 对象,然后用这个 store 对象通过 Provider 方法将整个 store 挂载在 App 对象上。

之后如果想给 page 或者 component 进行 connect 子store 操作,执行 connect 使用的方法和多 store 略有不同。要求使用者必须在 connect 的时候不要输入子 store 对象,而是直接输入 mapConfig,在 connect 的时候真正绑定到 page 对象上的实际上都是这个根 store。

每个 page 的子 store,不需要在页面 connect 的时候挂在 page 上,而是应该作为根 store 初始化的时候的一部分,一起放在根 store 里进行创建。

import * as Lux from '@alipay/lux';
//导入各个页面的子store
import home from './pages/home/store';
import billBookDetail from './pages/billBookDetail/store';
import createBillBook from './pages/createBillBook/store';
//更多其他 store
//创建根 store 对象
export default new Lux.Store({
  state: {
    xx:xx
  },
  getters: {
    
  },
  mutations: {
    
  },
  actions: {
    
  },
//将导入的子store 设置到根store下,成为一体
  modules: {
    home,
    createBillBook,
    billBookDetail,
	//... 更多
  }
}, {
  produce,//不可变配置,后续聊
  plugins: []//Lux 插件模块,后续聊
});

可以看到根 store 自己就是一个仓库,可以有 state 以及 getter,mutation,action。在此处可以当作全局变量的仓库,管理全局数据,也可以开放一些全局接口(action)供任意地方调用

每一个页面自己的子 store ,可以放到根 store 的 modules 字段里,形成了树状结构的第一层叶子节点。

如果业务需要有些复杂的 component 也需要有自己的子 store,那么应该放到他的上层 page 页面子 store 对象的 module 里,形成树状解构的第二层叶子节点。以此类推嵌套型的 component 操作。

最后将根 store 绑定到 App 上,操作非常简单, App(Provider(store)(appOptions)); 一行代码即可

import { luna } from './common/jsapi';
import { Provider } from '@alipay/lux';
import store from './app.store';

const appOptions = {
  onLaunch() {
    checkVersion();
    this.$dispatch('getUserInfo');
    this.$dispatch('getDeviceInfo');
  },
};
//调用 Provider 进行绑定
App(Provider(store)(appOptions));

page 与单 store 的挂载与绑定

将子 store 绑定到 page 的操作,和多 store 绑定姿势略有差异

import { connect } from '@alipay/lux';

const options = {
  onLoad(options) {},
  onShow(options) {},
};

const mapStatesToData = {};
const mapGettersToData = {};
const storeConfig = {
    mapGetters: mapGettersToData,
    mapState: mapStatesToData,
};
// 注意此处,已经不需要应用 store 并且传给 connect 了
// 只需要传 mapConfig
Page(connect(storeConfig)(options));

归根结底是上面提到的,单 store 模式下,挂载每个 page 下面的 store 依然是根 store,每个page 通过根上面,用 page 的名字就能找到自己对应的 store,用别人 page 的名字也能找到别人对应的 store ,从而能进一步实现多个 page 之间的仓库操作,无论是读取数据,还是写入数据,甚至是派发方法(dispatch action)

单 store 下的数据映射

connect 默认将 store 的所有状态 map 到了 data 上并监听所有状态变化 默认将所有的 actions map 到了 option, 通过 this.$xxxAction 调用

在 Lux 的文档中介绍着上面这句话,意思是如果完全写 mapState,Connect 也会默认将 store 中所有 state 全都 map 到 page 的 data 上。整个 store 也会照常工作。但是!在单 store 模式下,这句话需要重新解读。

单 store 是一个树型的结构,所以不同节点 store 的 state 需要从 rootState 通过节点名 pageName来查找,比如 rootState.pageName.xxData。因为所有的 page 绑定的都是根节点,所以 mapStates 的入参 state 代表的是根节点,当前 page 的名字叫 billBookDetail,通过根节点 state.billBookDetail 来映射当前子 store 数据。如果你希望当前页面可以在 axml 里直接使用其他 store 的 state 数据。也可以通过这个根 state 找到其他 store ,直接 map 后使用

const mapStatesToData = {
  //当前页面自己的数据
  showGuide: state => state.billBookDetail.showGuide,
  isTotalNumOutIn: state => state.billBookDetail.isTotalNumOutIn,
  editName: state => state.billBookDetail.editName,
  memberList: state => state.billBookDetail.memberList,					 
  //其他页面的数据,直接在当前页面中使用
  curUserInfo: state => state.home.curUserInfo,
  //根 store 的 state数据也可以使用
  deviceInfo: state => state.deviceInfo,
};

const mapGettersToData = {}

const storeConfig = {
  mapGetters: mapGettersToData,
  mapState: mapStatesToData
};

state 的映射单/多 store 有所差异,getter 的映射一样存在差异。单 store 下为了区分每个子节点 store 各自的 getter 方法,需要用 keyPath 来识别 getter 方法,keyPath 的格式是 pageName/getterName,所以在进行 map 映射的时候,应该用这种 keyPath 来映射。同样你也可以把其他 store 的getter 映射到当前 page 上

const mapStatesToData = {}

const mapGettersToData = {
  //当前 page 是 billBookDetail,keyPath 是 billBookDetail/getterName
  bookDetailForShow: 'billBookDetail/bookDetailForShow',
  memberInfoForShow: 'billBookDetail/memberForShow',
  transListForShow: 'billBookDetail/transListForShow',
  dataStatus: 'billBookDetail/dataStatus',
  canWrite: 'billBookDetail/canWrite',
  //打算使用 home 页面的 getter
  curUserForShow: 'home/curUserForShow',
  //打算使用根 store 的 getter
  maxScreenHeight: 'maxScreenHeight',
};

const storeConfig = {
  mapGetters: mapGettersToData,
  mapState: mapStatesToData
};

在 store 中跨页面跨组件通信

从上面的代码样例中可以看到,我们已经能做到初步的跨页面组件间通信了,就是当前页面/组件,可以任意使用其他 store 的 state、getter,来进行自己的渲染,并且享受响应式的页面刷新。但这还不够,我们还希望进一步进行更多的跨页面跨组件通信

由于 store.js 的文件和 page.js 的文件,js上下文不太一致。所以在 store.js 的文件中访问和操作整个单 store 的方式也和 page.js 不一样

  • state

就是存放当前子 store 数据的地方,并没有什么逻辑代码,不涉及跨组件通信

  • mutation

定义为同步修改当前仓库 state 的方法,因此所有都靠传入参数控制,只修改自己,因此不涉及跨组件通信

  • getter

是用来计算一些数据,提供给外部进行读取,是一种读操作。在 getter 中是有跨页面跨组件读数据的需求的。 pageA 页面的一个 getter 值,可以不仅由 pageA 页面的 state 数据来运算,还可以由其他任意子 store 节点的 state 和 getter 来进行数据运算

// Lux 源码中对 getter 函数的声明描述
export type Getter<S, R> = (state: S, getters: any, rootState: R, rootGetters: any) => any;

通过对 Lux 的 .ts 声明文件进行观察发现,getter 函数,参数其实包括4个对象,我们在简单 demo 中的使用上,很习惯 getter 只写一个入参,省略了其他三个。就是这三个可以做到在 getter 中访问并读取到其他子 store 的数据。所以一个最完整的 getter 写法应该是

getters: {
    minRpxHeight(state, getters, rootState, rootGetters) {
      if (rootState.deviceInfo) {
        let rate = 750 / rootState.deviceInfo.windowWidth;
        let rpxHeight = rootState.deviceInfo.windowHeight * rate;
        let statusBarHeight = rootState.deviceInfo.statusBarHeight * rate;
        let barHeight = rootState.deviceInfo.titleBarHeight * rate;
        let headZone = 28 + 44 + 26 + 100 + 100;// 顶部css计算
        let result = rpxHeight - headZone - statusBarHeight - barHeight;
        return result;
      } else {
        return 0;
      }
    },
}
  • action

是用来描述一个仓库可以被外界调用的操作行为,既可以进行读操作,也可以进行写操作。在 action 中也有跨页面跨组件通信的需求,并且这个需求更大一些,既需要支持对外部仓库的访问读取操作,又需要支持提交执行的操作。

export interface ActionContext<S, R> {
  dispatch: Dispatch;
  commit: Commit;
  state: S;
  getters: any;
  rootState: R;
  rootGetters: any;
}

type ActionHandler<S, R> = (injectee: ActionContext<S, R>, payload: any) => any;
interface ActionObject<S, R> {
  root?: boolean;
  handler: ActionHandler<S, R>;
}

export type Action<S, R> = ActionHandler<S, R> | ActionObject<S, R>;

通过对 Lux 代码中 .ts 文件的声明可以看出来,action 接受2个入参,第一个入参是一个对象 ActionContext ,第二个入参是实际传入的参数 payload。而 ActionContext 的定义又包括了6个对象,前两个用来执行提交和调用等操作,后四个用来进行访问读取操作。读取操作:state,getters,rootState,rootGetters 和 getter 中的使用一模一样就不做赘述了

重点说一下前两个 commit 和 dispatch。这两个2个方法的常规使用方法都是第一个参数为名字,第二个参数为传参。这里只用 commit 举例,dispatch 类似

在单 store 模式下,action 上下文中使用 commit 无需指定 keyPath,commit(‘memberList’,listData),即可直接提交给自己的 mutation,但如果此时希望跨仓库进行提交,可以加入第三个参数 {root:true}。然后使用 keyPath 当作名字进行 commit。因此一个完整的 action 写法应该是这样。

actions: {
    async refreshData({commit,dispatch,state,getters,rootState,rootGetters},payload) {
  	  // 获取某个网络请求 rpc promise
      let req = reqHomePage();
      req.then((result) => {
  		//数据提交给当前页面的 updateData mutation
        commit('updateData', result);
		//数据提交给另一个页面的 updateUser mutation
	    commit('user/updateUser',result.user,{root:true});
		//触发当前页面的 saveLocalStorage 另一个 action ,进行网络数据缓存 
		dispatch('saveLocalStorage',result);
		//触发另一个页面的 saveUserCache 另一个 action ,进行用户数据缓存
		dispatch('user/cacheUser',result.user,{root:true});
      }).catch(() => {
        commit('updateDataError');
      });
    },
}

在 page 中跨页面跨组件通信

上文说道,因为 page 与 store 的 js 上下文是不太一样的,所以在 page 中跨仓库通信也是略有不同。因为 page 的上下文 this 上挂载的整个单 store 的根,所以在 page 中无论是否是与自己页面的子 store 交互,还是与其他页面的子 store 交互,都必须通过名字访问 or 通过 keyPath 提交执行。

  • 通过 this.$store 访问整个单 store。在 page.js 中不止可以访问各个仓库的 state ,也可以访问 getter。都是通过各个仓库的名字来查找并访问
onLoad(options) {
  //访问home子仓库下的userName state
  this.$store.state.home.userName;
  //访问book自仓库下的billListForShow getter
  this.$store.getters.book.billListForShow
},
  • 通过 this.$commit 与 this.$dispatch ,用 keyPath 直接提交给任意子仓库 mutation or action。需要补充的是,在 page.js 中是强制 {root:false} 的,所以即便提交给自己页面的仓库也不能简写省略 keyPath。(这和在store里不同)
onShow() {
  //提交给 home 子仓库下的 userInfo mutation
  this.$commit('home/userInfo',userinfo);
  //提交给 billBookDetail 子仓库下的 refreshBookDetail action
  this.$dispatch('billBookDetail/refreshBookDetail');
},

Lux 的辅助插件

Lux 还提供了3个辅助插件,和一个 immutable js 的配置

Lux 的插件

  • logger 插件:用来在任意 state 发生变化的时候打印调试信息,包含了 preState 和 nextState


  • batch 插件:可以做到连续多次 commit 提交,可以合并成一次 commit 提交,来实现更好的性能

  • watcher 插件:可以做到更好的监听 state 变化,然后触发监听回掉,进行更细粒度的局部监听局部更新

不可变数据

为了更好的管理 store 数据仓库,只允许通过 mutation 更改 state 不允许其他任何代码方式直接操作 state ,也为了更高效的计算 state 变化的 diff,Lux 支持可以自定义不可变数据的方案。可以考虑使用 immer.js(脚手架默认集成) 也可以考虑自己实现 deepClone 来做到不可变数据

// 将插件配置到根 app.store.js 上
import * as Lux from '@alipay/lux';
import createLogger from '@alipay/lux/plugins/logger';
import createBatched from '@alipay/lux/plugins/batched';
import createWatch from '@alipay/lux/plugins/watch';

//配置默认集成的 immer 不可变数据
import { produce } from 'immer';
import debounce from 'lodash.debounce';
//构建 logger 插件
const logger = createLogger({
  predicate: m => m.type !== 'add/updateInputValue'
});
//构建 batch 插件
const batched = createBatched(debounce(notify => notify(), 10));
//构建 watcher 插件
const watcher = createWatch();

export default new Lux.Store({
  state: {},
  getters: {},
  mutations: {},
  actions: {},
  modules: {
    home,
    createBillBook,
	...
  }
}, {
  produce, // 注册使用 immer
  plugins: [
    logger,// 注册使用插件
    batched,//也可以不用
    watcher,
  ]
});

代码文件规范

多人账本项目,在全面实践 LunaX 的过程中对于代码文件的整理逐渐形成了一定的规范约束。可以参考一下借鉴一下

  • components 共用组件总目录:
    • 公用组件不实现自己的 store ,遵照原生小程序开发模式。因为引入 store 会导致要把 组件 store 引入到每一处用到子 store 节点下,极大程度的让组件在使用上变得复杂
    • 公共组件可以视需求而定,进行 mapConfig 来将根 store 绑在 component 上下文
  • pages 页面逻辑总目录:
    • pageA 页面目录: 一个页面的所有文件
      • component 页面组件:这里用于存放该页面专用的页面组件,如果组件逻辑复杂,可以有自己的子 store,文件结构同 page store
      • store 仓库目录:用于存放,子 store 主逻辑,与其他数据处理逻辑
        • index.js 文件:子 store 主逻辑
        • rpc.js 文件:用于生成 rpc promise 的逻辑,如果页面接口简单甚至没有,可以省略,写在 index.js 中
        • storage.js 文件:用于生成本地存储 promise 的逻辑,可以省略
      • pageA.js 文件: 进行 store 的 mapConfig
      • pageA.axml pageA.acss pageA.json 文件:常规小程序 page文件
    • pageB 页面目录
  • app.js 小程序主入口文件:绑定单 store 到 app
  • app.store.js 根store文件:单 store 的构建以及插件配置

LunaX 的其他工具

前面就已经提到过 LunaX 不仅由 Lux 数据仓库这个组件,还有这其他丰富组件

Mock 组件

小程序本地 mock 方案

  • mock 在本地维护,切换非常方便灵活
  • mock 支持种类丰富
    • 支付宝 or 淘宝的 rpc,mtop
    • 公开或内部的 http 接口,
    • 同步或异步的 jsapi
    • 凤蝶的区块数据
  • IDE,模拟器,真机都可以使用
  • 跟原 luna-mock 使用方法基本一致
  • 非常详细的 log 记录

需要说明的是,原文档中提到在模拟器中进行 mock 存在一定瑕疵,需要侵入一些代码才可以,现在已经不需要了,直接使用即可。使用 LunaX 构建的小程序项目,整个 mock 工具都已经配置好了

  • Rpc 的 mock 最常用,直接通过 module.exports 导出一个硬编码对象即可
  • 同步 JSApi 使用和 Rpc 一致,module.exports 导出一个硬编码对象
  • 异步 JSApi 使用起来略有差异,需要导出一个方法,把硬编码对象放到 return 结果中去
const defaultInfo = {'nickName': '我', 'avatar': '../../assets/images/head.png'};
module.exports = function (opts) {
  return {
    success: defaultInfo,
  };
};

小程序埋点

// 埋单个数据
luna.log.info('SOME_INFO', infoData)
// 埋多个数据
luna.log.info('SOME_INFO', [infoData1, infoData2])
// 埋 spm 点位
const tracert = new luna.Tracert({
  spmAPos: 'a230', // spma位,必填
  spmBPos: 'b7449', // spmb位,必填
  bizType: 'AntDevTools', // 业务类型,必填
  logLevel: 2, // 默认是2
  chInfo: luna.config.app // 渠道
});
tracert.logPv();        // 记录 pv
tracert.click('c17943.d32288');   // 点击埋点
tracert.expo('c17943.d32327');  // 曝光埋点

凤蝶区块

凤蝶区块用于一些运营活动的配置,小程序也可以通过凤蝶的 path 读取凤蝶区块,从而做到不发版改换配置效果。 getH5data(‘path’) 方法返回的是一个 promise,在 promise 执行完毕后,凤蝶区块数据通过 json 返回

网络请求 Rpc

LunaX 封装了 rpc 方法,统一处理了 Rpc 的缓存,转菊花方案,默认错误失败处理方案,防重复提交,限流展示等处理。

LunaX Rpc

鲜花
鲜花
鸡蛋
鸡蛋
分享至 : QQ空间
收藏
原作者: 折腾范儿の味精 来自: awhisper