RT,小程序上的虚拟试衣项目已经上线啦,消失了一个月都是在忙前忙后这一个项目,把项目中遇见的一些小程序的坑、wepy 框架的坑都记录一下。

项目简介

虚拟试衣小程序项目是一个微信环境内的电商平台,与普通电商项目技术方面的最大区别在于虚拟形象的实现,虚拟形象的实现依赖于 Canvas 的绘制,在预研的时候就发现了小程序提供的 Canvas 缺少 API - canvasContext.globalCompositeOperation

在后面我们开发团队有向小程序技术团队提出过补充 globalCompositeOperation 的需求,并且小程序技术团队很快提供了该 API,但是该 API 的实现仍有一定的不完整,如下图:

还是要为小程序技术团队打 Call, 他们的响应和回馈都是非常迅速。

除了 API 不完整的限制,小程序的 Canvas 在 Scroll-View 和 Swiper-View 两种场景下都可能引发性能问题,所以官方也不推荐在这种场景下使用 Canvas。因此整个项目采用 H5 + Native 的架构实现。以下就会记录在这一开发过程中遇见的问题:

基础架构

项目是使用 wepy + redux 的框架实现,在解决通用数据的存储与访问的问题上,redux 给予了很好的帮助。但是除了数据,每个页面都会有一部分重复的逻辑需要实现,列如:用户登录校验、手机号绑定校验、获取购物车数据与刷新等等行为。

这里我采用了基类的实现方式,如下:

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
// pages/base.js
@connect({
auth: ({ user: { auth } }) => auth,
hasLogin: ({ user: { auth } }) => !!auth && !!(auth.expire_time && new Date(auth.expire_time) > Date.now()),
userMember: ({ user: { userMember } }) => userMember,
box: ({ box: { box } }) => [null, null, null, null, null]
.map((item, index) => box[index] ? box[index] : item),
// ...
}, {
setUserAuth,
setUserMemberInfo,
setBox,
// ...
})
export default class BasePage extends wepy.page {
async onLoad () {
// 登录校验
if (!this.hasLogin) {
await this.login()
}
// 会员信息
if (isEmpty(this.userMember)) {
await this.fetchUserMemberStatus()
}
// 盒子信息
if (!this.notNeedLoadBox) {
this.loadBox()
}
}
login () {
// 微信 openId 静默登录
}
loadBox () {
// 购物信息
}
fetchUserMemberStatus () {
// 会员信息
}
ec () {
// 神策埋点
}
}

然后让后续的页面通过继承该基类,省去许多重复性工作,比如用户的数据、购物车数据、登录的校验等等,小程序页面实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// pages/boxPreview.js
export default class BoxPreview extends basePage {
data = {
notNeedLoadBox: true
//...
}
computed = {
...this.computed
// ...
}
onLoad () {
super.onLoad()
}
methods = {
...this.methods
// ...
}
}

这里可以看到我有许多合并操作,这里是为了让在 basePage 基类中注入的 redux 的 state 和 actions 能够在小程序页面中正常访问而做的。
如果希望在 onLoad 的钩子函数里面做更多页面的逻辑,需要调用 super.onLoad(), 确保基类中的钩子函数能够正常触发,但是没有什么好办法来解决异步的问题,比如页面中某个逻辑依赖 user 的数据,但是我无法通过 promise 来保证代码的同步执行,所以这里只能考虑用 watch 来实现。
然后在可以通过在 data 中定义一些变量来控制基类中的执行逻辑,比如当前页面其实不需要加载购物车数据,那么我们在 data 中定义 notNeedLoadBox 来控制基类的行为。

这里我们考虑不采用基类的方式也是可以的,通过 mixins 也能够达到同样的逻辑,之所以用基类的方式,是因为我发现 wepy 在 redux 的 actions 上有 bug。mixins 的方式无法访问到注入的 actions。

以上就是项目的基本架构实现,整体上还是挺方便的,可以减少许多重复的逻辑和代码。

webview

如之前所说,因为虚拟试衣形象的问题,我们必须将一部分代码放到 H5 上面来实现。所以整个项目的代码其实是分为两部分的,H5 上面的代码是放到另一个项目上面的,框架和架构都是正常的 vue spa 项目。

spa 路由的问题

这里 vue-router 其实是没有问题的,问题出在小程序的回退按钮上面:

如果用户打开小程序看到的第一个页面是 webview 实现的 H5 页面,那么接下来在 H5 内的 pushState 行为都不会产生回退按钮。如下图:

当前是首页的商品列表页面,左上角是没有回退按钮的,此时我浏览列表后进入一个商品的详情页面,也是 H5 方式实现,对于我而言就是一次 H5 的 pushState 行为,问题就在这里,SPA 的路由跳转对于小程序而言它不是小程序路由的改变,依然不会有回退按钮的实现。(不过搞人的是,如果之前就已经有回退按钮的话,点击回退却会触发 H5 内的路由回退)。

对于这个问题我的解决办法比较呆,就是将每个 H5 页面都用一个 webiview 是装载,也就是说每次路由跳转都是走的小程序的路由跳转。

webview 再次唤起的问题

如果当前页面的 webview 触发了路由跳转到另外一个页面,在回退回来后页面是完全被缓存了的,H5 内的数据无法被更新。也就是说当 webiview 内的 H5 页面再次进入 viewport 时,vue 的任何钩子函数都不会被执行(created、mounted), 不仅仅是 vue 的钩子函数,wwindow 的 pageShow 的 API 也不会被执行,同理在 H5 页面进入后台时,(beforeDestroyed、destoryed) 也不会被执行,pageHide 也不会被执行。

这样就很坑爹了,因为在其他页面如果用户也操纵了购物车内的数据, 那么回退到 H5 页面就发现购物车数据被还原了。

这里我的解决办法是通过销毁 webview,强制页面重新创建 webview。

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<web-view wx:if="{{ needWebView }}" />
</template>
<script>
onHide () {
this.needWebView = false
}
onShow () {
this.needWebView = true
}
</script>

除了销毁和重新创建 WebView 之外也可以通过修改 url 实现页面的强制刷新,比如 webview 的 url 第一次访问时 www.baidu.com?step=1, 那么第二次访问我们将路由改成 www.baidu.com?step=2 也会让页面在下次出现的时机刷新。

两种方式都可行,UI渲染的开销上第一种方案会更大,第二种方案会弄脏 url。

分享

如果项目的每个页面都需要分享功能,那么我们可以通过在基类 basePage 里面注册一个默认的分享配置,在单独需要特殊分享的面去重写父类的 onShareAppMessage。

onShareAppMessage 是不能定义成异步的,通过设置 async 关键字会导致小程序无法识别该函数,所以这里面只能是同步代码。

scroll-view

在小程序里面如果想实现滚动条有两种方式:

  • css 的 overflow: scroll;
  • 小程序的原生组件:scroll-view;

第一种方式实测发现滚动性能差劲,会有明显的卡顿感,所以弃用;
第二种方式滚动的流畅度要高于第一种,但是会有莫名其妙的样式问题,如下图:

这里底部地区应该有明显的几十 rpx 的下坠,我们只能够通过用 margin 将页面顶起来。

渐变渲染

小程序里面使用 css 的 linear-gradient 一定要加 webkit 的前缀,否则渐变的样式将无效。

wepy

开发过程中也发现 wepy 框架存在不少问题,比如如果遇见 this.xxx = yyy 的赋值行为已经执行,但是页面 UI 并没有跟随状态同步,要执行 this.$apply() 来强制同步 UI 和状态。猜想这个问题应该是因为 wepy 的数据检查机制是依赖于脏检查实现的。

组件数据传递上如果期望子组件中的状态和父组件保持同步,需要添加 sync 关键字,否则会默认只下发一次,不会跟随变动而变动,如下:

1
2
3
<BoxCart
:box.sync="box"
/>

这些问题在 wepy 的文档中都有提及,但是需要特别注意,不然很容易就不理解页面的 UI 状态为什么和预期差别那么大。

总结

以上就是开发过程中,我遇见的比较棘手的一些问题以及解决方案。在后续的迭代开发中其他问题也会在这里及时补充。