1KB javascript覆盖状态管理、跨页通讯、插件开发和云数据库开发
Package | 介绍 |
---|---|
westore | 小程序演示项目 |
westore-cloud | 小程序 + 腾讯云演示项目(隐形云☁️) |
westore-plugin | 小程序插件开发演示项目 |
westore-proxy | 小程序底层使用 Proxy 演示项目 |
westore-test | 测试 westore API 的小程序 |
|
小程序开发 Web 的项目 |
众所周知,小程序通过页面或组件各自的 setData 再加上各种父子、祖孙、姐弟、姑姑与堂兄等等组件间的通讯会把程序搞成一团浆糊,如果再加上跨页面之间的组件通讯,会让程序非常难维护和调试。虽然市面上出现了许多技术栈编译转小程序的技术,但是我觉没有戳中小程序的痛点。小程序不管从组件化、开发、调试、发布、灰度、回滚、上报、统计、监控和最近的云能力都非常完善,小程序的工程化简直就是前端的典范。而开发者工具也在持续更新,可以想象的未来,组件布局的话未必需要写代码了。而且据统计,开发小程序使用最多的技术栈是使用小程序本身的开发工具和语法,所以最大的痛点只剩下状态管理和跨页通讯。Westore 的方案:
非纯组件的话,可以直接省去 triggerEvent 的过程,直接修改 store.data 并且 update,形成缩减版单向数据流。
受 Omi 框架 的启发,且专门为小程序开发的 JSON Diff 库,所以有了 westore 全局状态管理和跨页通讯框架让一切尽在掌握中,且受高性能 JSON Diff 库的利好,长列表滚动加载显示变得轻松可驾驭。总结下来有如下特性和优势:
总结下小程序的痛点:
所以没使用 westore 的时候经常可以看到这样的代码:
使用完 westore 之后:
上面两种方式也可以混合使用。
可以看到,westore 不仅支持直接赋值,而且 this.update 兼容了 this.setData 的语法,但性能大大优于 this.setData,再举个例子:
this.store.data.motto = 'Hello Westore'
this.store.data.b.arr.push({ name: 'ccc' })
this.update()
等同于
this.update({
motto:'Hello Westore',
[`b.arr[${this.store.data.b.arr.length}]`]:{name:'ccc'}
})
和小程序的setData不同的是回调的方式,小程序的回调为setData的第二个入参,但是update则直接返回一个Promise,并且返回的数据内有更新的数据内容。例如:
this.setData({
motto: 'Hello Westore'
}, () => {
console.log('the motto has been set')
})
被改进为
this.store.data.mottto = 'Hello Westore'
this.update().then(diff => {
console.log('the motto has been set', diff)
})
这里需要特别强调,虽然 this.update 可以兼容小程序的 this.setData 的方式传参,但是更加智能,this.update 会先 Diff 然后 setData。原理:
Westore API 只有六个, 大道至简:
纯组件使用小程序自带的 Component,或使用 create({ pure: true })
。create的方式可以使用 update 方法,Component 方式不行。
export default {
data: {
motto: 'Hello World',
userInfo: {},
hasUserInfo: false,
canIUse: wx.canIUse('button.open-type.getUserInfo'),
logs: [],
b: {
arr: [{ name: '数值项目1' }] ,
//深层节点也支持函数属性
fnTest:function(){
return this.motto.split('').reverse().join('')
}
},
firstName: 'dnt',
lastName: 'zhang',
fullName: function () {
return this.firstName + this.lastName
},
pureProp: 'pureProp',
globalPropTest: 'abc', //更改我会刷新所有页面,不需要在组件和页面声明data依赖
ccc: { ddd: 1 } //更改我会刷新所有页面,不需要在组件和页面声明data依赖
},
globalData: ['globalPropTest', 'ccc.ddd'],
logMotto: function () {
console.log(this.data.motto)
},
//默认 false,为 true 会无脑更新所有实例
//updateAll: true
}
页面和组件上同样需要声明依赖的 data,这样 westore 会按需局部更新。如 Page 的 data:
data: {
motto: null,
userInfo: null,
hasUserInfo: null,
canIUse: null,
b: { arr: [ ] },
firstName: null,
lastName: null,
fullName: null,
pureProp: null,
//privateProp 你也可以定义 store.data 没有的属性,该属性的变更只能通过 this.setData 进行更新视图
privateProp: '私有数据',
xxxx: '私有数据2'
}
页面和组件上声明的 data 的值会被 store 上的值覆盖掉。所以页面和组件默认值在 store.data 上标记,而不是在组件和页面的 data。纯组件在组件内部的 data 定义默认值。所以归纳一下:
比起原生小程序增强的功能是提供了 data 函数属性,比如上面的 fullName,在小程序 WXML 直接绑定:
<view>{{fullName}}</view>
import store from '../../store'
import create from '../../utils/create'
const app = getApp()
create(store, {
//只是用来给 westore 生成依赖 path 局部更新
data: {
motto: null,
userInfo: null,
hasUserInfo: null,
canIUse: null,
b: { arr: [ ] },
firstName: null,
lastName: null,
fullName: null,
pureProp: null
},
onLoad: function () {
if (app.globalData.userInfo) {
this.store.data.userInfo = app.globalData.userInfo
this.store.data.hasUserInfo = true
this.update()
} else if (this.data.canIUse) {
app.userInfoReadyCallback = res => {
this.store.data.userInfo = res.userInfo
this.store.data.hasUserInfo = true
this.update()
}
} else {
wx.getUserInfo({
success: res => {
app.globalData.userInfo = res.userInfo
this.store.data.userInfo = res.userInfo
this.store.data.hasUserInfo = true
this.update()
}
})
}
}
})
创建 Page 只需传入两个参数,store 从根节点注入,所有子组件都能通过 this.store 访问。
<view class="container">
<view class="userinfo">
<button wx:if="{{!hasUserInfo && canIUse}}" open-type="getUserInfo" bindgetuserinfo="getUserInfo"> 获取头像昵称 </button>
<block wx:else>
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view>
<view class="usermotto">
<text class="user-motto">{{motto}}</text>
</view>
<view>{{fullName}}</view>
<hello></hello>
</view>
和以前的写法没有差别,直接把 store.data
作为绑定数据源。
data 的函数属性也可以直接绑定,但别忘了要在页面上声明相应的函数属性依赖。
this.store.data.any_prop_you_want_to_change = 'any_thing_you_want_change_to'
this.update()
import create from '../../utils/create'
create({
ready: function () {
//you can use this.store here
},
methods: {
//you can use this.store here
}
})
和创建 Page 不一样的是,创建组件只需传入一个参数,不需要传入 store,因为已经从根节点注入了。
this.store.data.any_prop_you_want_to_change = 'any_thing_you_want_change_to'
this.update()
拿官方模板示例的 log 页面作为例子:
this.setData({
logs: (wx.getStorageSync('logs') || []).map(log => {
return util.formatTime(new Date(log))
})
}, () => {
console.log('setData完成了')
})
使用 westore 后:
this.store.data.logs = (wx.getStorageSync('logs') || []).map(log => {
return util.formatTime(new Date(log))
})
this.update().then(diff => {
console.log('setData完成了')
console.log('更新内容为', diff)
})
看似一条语句变成了两条语句,但是 this.update 调用的 setData 是 diff 后的,所以传递的数据更少。
使用 westore 你不用关心跨页数据同步,你只需要专注 this.store.data 便可,修改完在任意地方调用 update 便可:
this.update()
这里说的组件便是自定义组件,使用原生小程序的开发格式如下:
Component({
properties: { },
data: { },
methods: { }
})
使用 Westore 之后:
import create from '../../utils/create'
create({
properties: { },
data: { },
methods: { }
})
看着差别不大,但是区别:
export default {
data: {
firstName: 'dnt',
lastName: 'zhang',
fullName:function(){
return this.firstName + this.lastName
}
}
}
绑定到视图:
<view>{{fullName}}</view>
有一些组件区别于业务组件,叫纯组件。如 tip、alert、dialog、pager、日历等,与业务数据无直接耦合关系。 组件的显示状态由传入的 props 决定,与外界的通讯通过内部 triggerEvent 暴露的回调。 triggerEvent 的回调函数可以改变全局状态,实现单向数据流同步所有状态给其他兄弟、堂兄、姑姑等组件或者其他页面。
Westore里可以使用 create({ pure: true })
创建纯组件(当然也可以直接使用 Component),比如 :
import create from '../../utils/create'
create({
pure : true,
properties: {
text: {
type: String,
value: '',
observer(newValue, oldValue) { }
}
},
data: {
privateData: 'privateData'
},
ready: function () {
console.log(this.properties.text)
},
methods: {
onTap: function(){
this.store.data.privateData = '成功修改 privateData'
this.update()
this.triggerEvent('random', {rd:'成功发起单向数据流' + Math.floor( Math.random()*1000)})
}
}
})
需要注意的是,加上 pure : true
之后就是纯组件,组件的 data 不会被合并到全局的 store.data 上。
组件区分业务组件和纯组件,他们的区别如下:
大型项目一定会包含纯组件、业务组件。通过纯组件,可以很好理解单向数据流:
console.log(getApp().globalData.store.data)
不排除小程序被做大得可能,接触的最大的小程序有 60+ 的页面,所以怎么管理?这里给出了两个最佳实践方案。
export default {
data: {
commonA: 'a',
commonB: 'b',
pageA: {
a: 1
xx: 'xxx'
},
pageB: {
b: 2,
c: 3
}
},
xxx: function () {
console.log(this.data)
}
}
a.js
export default {
data: {
a: 1
xx: 'xxx'
},
aMethod: function (num) {
this.data.a += num
}
}
b.js
export default {
data: {
b: 2,
c: 3
},
bMethod: function () {
}
}
store.js
import a from 'a.js'
import b from 'b.js'
export default {
data: {
commonNum: 1,
commonB: 'b',
pageA: a.data
pageB: b.data
},
xxx: function () {
//you can call the methods of a or b and can pass args to them
console.log(a.aMethod(commonNum))
},
xx: function(){
}
}
当然,也可以不用按照页面拆分文件或模块,也可以按照领域来拆分,这个很自由,视情况而定。
解决元素组件状态不同步的问题,比如 switch 需要通过绑定 bindtap 去记录状态,不然无法 diff 出更改:
<switch bindtap='switchTap' checked="{{value}}"></switch>
switchTap() {
this.store.data.value = !this.store.data.value
this.update()
},
restore() {
this.store.data.value = true
this.update()
}
原本是用户交互状态不影响 data 的,需要特别注意同步 data 到 store.data,不然无法 diff 出 patch,多谢 @i7soft。
--------------- ------------------- -----------------------
| this.update | → | json diff | → | setData()-setData()...| → 之后就是黑盒(小程序官方实现,但是 dom/apply diff 肯定是少不了)
--------------- ------------------- -----------------------
虽然和 Omi 一样同为 this.update 但是却有着本质的区别。Omi 的如下:
--------------- ------------------- ---------------- ------------------------------
| this.update | → | setState | → | jsx rerender | → | vdom diff → apply diff... |
--------------- ------------------- ---------------- ------------------------------
都是数据驱动视图,但本质不同,原因:
先看一下我为 westore 专门定制开发的 JSON Diff 库 的能力:
diff({
a: 1, b: 2, c: "str", d: { e: [2, { a: 4 }, 5] }, f: true, h: [1], g: { a: [1, 2], j: 111 }
}, {
a: [], b: "aa", c: 3, d: { e: [3, { a: 3 }] }, f: false, h: [1, 2], g: { a: [1, 1, 1], i: "delete" }, k: 'del'
})
Diff 的结果是:
{ "a": 1, "b": 2, "c": "str", "d.e[0]": 2, "d.e[1].a": 4, "d.e[2]": 5, "f": true, "h": [1], "g.a": [1, 2], "g.j": 111, "g.i": null, "k": null }
Diff 原理:
export default function diff(current, pre) {
const result = {}
syncKeys(current, pre)
_diff(current, pre, '', result)
return result
}
同步上一轮 state.data 的 key 主要是为了检测 array 中删除的元素或者 obj 中删除的 key。
setData 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。在介绍常见的错误用法前,先简单介绍一下 setData 背后的工作原理。setData 函数用于将数据从逻辑层发送到视图层(异步),同时改变对应的 this.data 的值(同步)。
其中 key 可以以数据路径的形式给出,支持改变数组中的某一项或对象的某个属性,如 array[2].message,a.b.c.d,并且不需要在 this.data 中预先定义。比如:
this.setData({
'array[0].text':'changed data'
})
所以 diff 的结果可以直接传递给 setData
,也就是 this.update
。
小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。
而 evaluateJavascript 的执行会受很多方面的影响,数据到达视图层并不是实时的。
常见的 setData 操作错误:
上面是官方截取的内容。使用 webstore 的 this.update 本质是先 diff,再执行一连串的 setData,所以可以保证传递的数据每次维持在最小。既然可以使得传递数据最小,所以第一点和第三点虽有违反但可以商榷。
名称 | 描述 |
---|---|
onLoad | 监听页面加载 |
onShow | 监听页面显示 |
onReady | 监听页面初次渲染完成 |
onHide | 监听页面隐藏 |
onUnload | 监听页面卸载 |
名称 | 描述 |
---|---|
created | 在组件实例进入页面节点树时执行,注意此时不能调用 setData |
attached | 在组件实例进入页面节点树时执行 |
ready | 在组件布局完成后执行,此时可以获取节点信息(使用 SelectorQuery ) |
moved | 在组件实例被移动到节点树另一个位置时执行 |
detached | 在组件实例被从页面节点树移除时执行 |
这里区分在页面中的 update 和 组件中的 update。页面中的 update 在 onLoad 事件中进行实例收集。
const onLoad = option.onLoad
option.onLoad = function () {
this.store = store
rewriteUpdate(this)
store.instances[this.route] = []
store.instances[this.route].push(this)
onLoad && onLoad.call(this)
}
Page(option)
组件中的 update 在 ready 事件中进行行实例收集:
const ready = store.ready
store.ready = function () {
this.page = getCurrentPages()[getCurrentPages().length - 1]
this.store = this.page.store
this.setData.call(this, this.store.data)
rewriteUpdate(this)
this.store.instances[this.page.route].push(this)
ready && ready.call(this)
}
Component(store)
rewriteUpdate 的实现如下:
function rewriteUpdate(ctx) {
ctx.update = (patch) => {
let needDiff = false
let diffResult = patch
if (patch) {
for (let key in patch) {
updateByPath(ctx.store.data, key, patch[key])
if (typeof patch[key] === 'object') {
needDiff = true
}
}
} else {
needDiff = true
}
if (needDiff) {
diffResult = diff(ctx.store.data, originData)
}
for (let key in ctx.store.instances) {
ctx.store.instances[key].forEach(ins => {
ins.setData.call(ins, diffResult)
})
}
ctx.store.onChange && ctx.store.onChange(diffResult)
for (let key in diffResult) {
updateByPath(originData, key, diffResult[key])
}
}
}
westore 会收集所有页面和组件的实例,在开发者执行 this.update 的时候遍历所有实例进行 setData。
function defineFnProp(data) {
Object.keys(data).forEach(key => {
const fn = data[key]
if (typeof fn == 'function') {
fnMapping[key] = fn
Object.defineProperty(globalStore.data, key, {
enumerable: true,
get: () => {
return fnMapping[key].call(globalStore.data)
},
set: (value) => {
fnMapping[key] = value
}
})
}
})
}
通过 defineProperty 重写了属性的 get 和 set,fnMapping 存放所有 key 和函数的映射。这里一定要设置 enumerable 为 true,因为 diff 的时候需要遍历。
MIT @dntzhang