小程序备忘录

在公司开发小程序的时候总会冒出写篇总结的想法,思前想后还是觉得没太多干货作罢。
突然又想开始写点什么倒是因为最近工作变动,转向数据可视化,大概短时间内不会再接触小程序的开发,还是憋个小备忘吧。

前段时间的面试,大抵看到简历上有候选人标注过小程序开发经验的时候,我都会抛下面这串问题:

小程序的开发体验你觉得怎么样?有没有遇到什么问题?有想过哪些解决方案?

拉黑标准是:TA回了一句 「挺好的」「挺简单的」「没遇到什么问题」之类的话。
正儿八经写过点线上应用的旁友想必有很多其他的话要说。

( 鉴于在下只有三四个月小程序业务开发经验,下文若有不当之处请多指教~ 欢迎传授更舒服的开发姿势_(:з」∠)_

惯例上大纲

  • 边界
  • 原生开发体验
  • 走过的套路
  • 更科学的路子怎么走


边界

被问到过一个问题: jsbridge / React Native / Weex / Electron / 小程序等这些有着 “跨界” 概念的技术,他们与 Native 的边界在哪里。

1) jsbridge 我们一般是发起一个特定协议的网络请求,带着特定的字符串作为参数让 Native 的逻辑层捕获;或是直接调 Native 在初始化 Webview 时在 window 上安置的全局函数,这是我理解的“边界”。Native 的视图层还是由自身的逻辑代码控制,js 做的无非是触发与传参。一般的场景是一个 h5 页面内嵌到 App 中并且需要一些 Native 的功能 (比如拉起分享、App 页面跳转、调起弹层之类)。总的来说,一个基于 webview 环境下的 js 通信桥梁(bridge).

2) React NativeWeex 是将 js 写的渲染逻辑和视图层结构对应到 Native 的视图。和上面不同的是,这个 Native 是需要运行这些 js 的,有独立线程,并不是丢到 webview 中运行。Native 需要依赖这个 js 线程的计算结果做渲染。可以理解成前者的终极进化版:花式“传参”,视图层全部是 Native 组件且 js 可控。这类技术框架其实对于 Native 开发同学可玩性是很高的,比如自己写一个原生的组件( Native 代码),开放足够多的配置口子丢给 js 调用方使用。它们的边界在于 Java / OC / SwiftNativeComponent 的实现和通信机制。

3) Electron 之流本身还是浏览器那套,视图和逻辑全是 js 自身可控,只不过给 js 封了更多可触及、定制 Native 的 API…边界在其提供的模块实现里(我们基本是在配置中接触到)

而小程序视图层是一个 Webview,逻辑层(控制层)是另一个 js 线程(不在视图层的 webview 中),其内部通过类似 jsbridge 的方法触发视图层 webview 的变动(实体就是 setData 方法) & 调用原生提供的功能(分享、跳转、弹层、Toast等挂在 wx 这个对象上的方法) 因为微信对视图层和逻辑层的分割,我们失去了 hybrid 时期那种对 dom 的 “完全控制权”,失去了常用的 web api,进入一个更“黑盒”、更受限的开发环境。

茫茫多的同学踏入如此“禁欲系”的开发环境,图的是腾讯爹更好的优化和体验。( 还有流量,划掉 )

原生开发体验

在前司的工作中,由于一些历史包袱的原因,一直采用的是原生的写法,团队没有 DIY 一个听起来就挺能骗 Star 的“小程序框架”。
(最早前端没有人力开发,全部“外包”给了初学 js 的移动端同学,再加上是个商城的应用,代码量庞大且质量层次不齐,捂脸)

近几个月让前端重新接回来后,没有换用一些市面上的小程序框架。主要是业务一直不断,外加试用过社区最常用的 wepy 后感觉成本高且解决的痛点并不多、还可能引入新问题所以放弃;至于mpvue 是后话了,想法更新颖,还没有好好尝试过一通。

所以只能扯扯原生开发体验及改进的想法。

在自定义组件面世之前

小程序语法完完全全是个模板引擎 + 单向数据流(虽然我现在也觉得是),相比于常用的框架缺失了很多舒服的设计。我们对于代码的复用是类似于模板的复用,所谓的“业务组件”本质上是一堆模板代码、样式片段、js逻辑片段。业务组件大多是跨页面形式的复用倒还好,还是基础组件心里苦,同一个页面里引用了多个“模板组件”,组件实例的维护变得十分诡异(如生命周期、事件处理等). 对于事件的区分,我们采用的是模板 view 上携带 data-id 这样的自定义属性做标记,生命周期无解。

Ps.公司开源的小程序“组件库”,实际上是个 UI 库、样式库,在同性交友网站上坐拥 4k star,想想还是挺羞耻的;不过离职前已经在做自定义组件的转型。

项目里的代码基本上是这个结构(一个正常的页面入口):

1
2
3
4
5
6
// pageA.js
import Features from './features';

const data = {};
const lifeCycle = { onLoad, onShow, onHide };
Page(Object.assign({}, data, lifeCycle, ...Features));
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- pageA.wxml -->
<import src="/components/A/index.wxml" />
<import src="/components/B/index.wxml" />
<import src="/components/C/index.wxml" />

<view class="page-a">
<!-- content -->
<template is="comp-a" data="{{ data: dataForA }}"/>
<!-- content -->
<template is="comp-b" data="{{ data: dataForB }}"/>
<!-- content -->
<template is="comp-c" data="{{ data: dataForC }}"/>
</view>
1
2
3
4
5
/* pageA.wxss */
@import "/components/a/index.wxss";
@import "/components/b/index.wxss";
@import "/components/c/index.wxss";
/* other styles */

引入比较丑陋的问题可以靠打包工具解决(比如写个 loader
但是代码复用做起来就不太优雅,如果不涉及展示层问题不大,一但涉及展示层,基于模板的组件无法完成显示上的自给自足:1. template is="xxx" 这种写法无法完全区分实例(比如响应函数和 Page 上定义的函数无法一一对应,只能多对一,然后在函数中用自定义属性区分);2. 组件上的 UI 变更必须通过 setData 方法,所以必须耦合页面的上下文 this.

这两点引发了几个的问题,比如组件暴露的方法被 “隐晦地” 引入页面对象中(假设我们写成 Page(Object.assign({}, data, life, CompA))),而后在页面的某个逻辑中被 “隐晦地” 通过 this 调用了 CompA 引入的方法,当这种 “隐晦” 的调用越来越多的时候(比如说嵌套地引入,简直地狱),项目维护的成本越来越大。这个例子其实蛮类似之前 React 社区对 mixins 在工程上可维护性的讨论。

“隐晦”、“不直观”、“surprise” 意味着这是不可阅读、不可维护的垃圾代码。所以我在碰到这种情况时会在调用函数里将 this.methodOfCompA(...args) 改写成 ComA.methodOfCompA.call(this, ...args) ,明确地指出依赖方法的提供方。

还有个大问题是模板组件的生命周期,只能想办法在现有的页面生命周期上挂钩子去解决,具体方式在后面分享。

上面的代码示例忘记写 wxs 文件,在微信官方的定义中是存在于文档中的脚本,类似 html 中内嵌的 script 标签,提供一些工具函数之类的。
这个一般用来实现类似 vue computed 的语法糖。


自定义组件面世之后

解决了上面组件生命周期的痛点,终于有了一个不依赖外部上下文、自给自足、真正意义上的组件系统!(这解决了一部分工程化上的困惑)

在调试器中,我们很容易发现自定义组件是以一个 shadow dom 的形式出现的,在逼格上和普通的页面标签划清了界限。

分享下 “新时期” 遇到的痛点,毕竟吹水是没什么输出的。

魔改组件的样式

自定义组件设计之初,很像是一种矫枉过正的产物,比如说其样式完全由自身控制,不受外界影响。这降低了组件的定制性,目前的几个办法:

  1. 传参,比较差的实践,参数一般是控制逻辑或状态的,较多的 UI 参数会很丑且不可扩展(除非改组件代码)
  2. 用自定义组件的 class 语法,即定义哪些字段是外部传入的类,然后这些类会被挂到哪些位置。比前者进步大,不过也是不可扩展。
  3. 使用约定,比如在 comp.wxss 中默认引入某个约定路径的外部样式,形如 @import "/common/hackComponentStyle.wxss",然后在这里写样式影响原来的组件。

emmmm,追求可扩展,使用第三条路。但是呢,少量的约定可以快速解决团队合作与规范的问题,当约定多了意味着维护性、阅读性的下降,这是一个取舍。

组件的交互

我们在页面上下文中,只能通过 data 向组件传参,无法获取到实例,在组件开发时,我们会倾向于全部采用抛事件的模式,不会有对外直接暴露的 API. 很简单的 data 进,event 出。

但这其实限制了一种交互场景:即使用者直接调用组件提供的能力(API)

举个栗子,我有个分享各类商品图的业务组件。它的作用是在 canvas 上绘制当前商品的图片、名称、活动信息、价格和小程序码,然后在弹层上给到用户预览图,后续有保存、分享的行动按钮。

这个组件中有个调起弹层的交互流程,在内部我们会写成一个 method. 出于复用性,这个行动的触发组件是不在这个业务组件内部的,需要让使用方自行触发。
然后就诞生了个名叫 show 的字段,在其 observer 中控制这个流程(因为没有对外暴露的 API、没有诸如 refs 的语法糖),这让一个指令形式的组件或者代码复用模式变得不够直观,写起来像是一个单纯的 UI 变动。

组件的管理

按照现在小程序的框架,自定义组件和我们在这之前写的“模板组件”都面临一个问题,如何管理与复用。无法做到像正常的前端项目一样用 npm 去管理引入。比较可行的方案还需要在打包流程上做 hack, 比如将 npm 引入的文件从 node_modules 里面拖出来弄到一个特定的目录下,改写路径并在 page.json 里面 usingComponents 对象中注册。

但对于使用者而言,又需要引入一套他人规定的小程序打包方式,代价太大。

生命周期的区分**

这其实是个挺难受的点,按 home 键回到主屏、切换页面(非重定向)都会触发 onHide 生命周期函数,问题是这两种 onHide 我们区分不了。
同样的例子还发生在 app onShow onHide onLaunch 上,比如进入小程序客服、触发分享时,因为会跳出小程序进入微信自身的页面,app 上挂的生命周期函数会被走一遍,然而这和正常的打开、关闭小程序是一样的。

假设我们有个统计用户操作路径的需求。在使用过程中,小程序因为用户点了分享或者咨询客服被关掉了,这些操作结束后,跳转回来又走了小程序初始化的逻辑,路径记录被重置,这显然不能接受。

现在的手段是,当需要区分的生命周期函数触发,利用本地存储保留一部分状态,后面通过这种状态做不完善的区分。但这实在很不优雅,希望官方能给生命周期加上一个类似 场景值 的参数,区分触发来源。

Ps.或许没有近几年这些前端框架的发展,我也会满足于微信提供的这套语法。「嗯,挺好的」


走过的套路

生命周期合并

我们有一个让用户配置、装修小程序或 h5 商城的编辑器。这是所见即所得的一个“装修”工具。

在技术设计上,后端给到的 json 数据里面含有一个数组,记录了用到的组件及配置数据。我们在移动端(h5 / 小程序)上会预先注册好这些组件,根据后端返回的数组做渲染。

在自定义组件出现前,这些组件都是以模板组件的形式被引入的,组件没有可维护的实例,做不到生命周期的控制。

因为整个页面模板组件引入的模式是这样的:Page(Object.assign({}, data, lifecycle, pageMethods, ...componentList)),我的改造点是这个 Object.assign,将其改写成一个可定制的对象合并函数,大致逻辑是:将合并对象中指定的生命周期函数(比如 onShow)全部推入一个内部函数数组(比如 __onShowFunctionList),页面上的生命周期函数改写成调用其对应的内部函数数组:

1
2
3
4
// example about onShow function
this.onShow = function(options) {
this.__onShowFunctionList.forEach(func => func.apply(this, [options]));
}

所以最终我们可以在所有模板组件的逻辑里面随便写小程序页面上的生命周期函数,这些都会被挂到函数数组中,等待调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// in template component
module.exports = {
name: 'text-block',
onHide() {
this.textBlock__clearTimeout();
},
onShow() {
this.textBlock__initTimeout();
},
textBlock__clearTimeout() {
// ...
},
textBlock__initTimeout() {
// ...
}
};
1
2
3
4
5
6
7
8
9
10
11
12
// in page
import ExtendCreator from '/utils/extendCreator';
import TextBlock from '/path/to/text-block';
// configure extend function
const extend = ExtendCreator({
lifeCycle: ['onShow', 'onHide'],
exclude: ['name']
});
// configure the page
const PageObject = { data, lifeCycles, methods };
// register template component
Page(extend(PageObject, TextBlock));

大概是上面这个样子。

这个其实还可以写一个 deepAssign 的功能,主要用于 data 对象,模板组件内部可以写 data 结构。

多计时器

这个场景是页面上可能有多个营销活动,存在倒计时组件,比如说秒杀、拼团、限时折扣之类。一般每个商品的活动时间是不一样的,以为着肯定存在多计时器的情况。

之前讲 边界 的那节也有提到,我们控制小程序展示层的唯一手段就是 setData,和世面上所有框架的 setBlabla 一样,频繁的设置带来性能问题。在正常的 h5 页面中,我们是直接通过修改 dom 内容更新 UI,小程序中我们所有的计算结果需要用 setData 传递给微信,通过这个中介去修改 webview 的 UI,这其实是一个低效的方式。

就目前而言,setData 并没有做多次调用的合并优化。

有多个倒计时计时器存在的页面,低端机器容易卡顿,渲染慢,跳秒情况多。优化方案是:

  1. 将计算频率降低到每秒一次(h5 上为了防止跳秒,计算频率往往设置得比这个高);
  2. 维护计时器数组,所有的计时器计算结果完成后再执行一次 setData,规避了一部分的卡顿成因。

canvas绘制

这个其实不想多说,毕竟那些面向过程的、没有复用性和阅读性的代码…………
微信提供的阉割版 api 已经能覆盖到挺多实际使用场景。个人接触到的需求都是转 png 诞生的:
栗子1. 商品分享图
栗子2. svg 的展示问题

需要注意的是微信提供的生成临时图片路径的接口,写在 draw 的回调函数中也不见得一定能绘制成功…… 最好用 setTimeout 推迟一下。并且这种挂在 wx 对象上的 canvas api,同一个时间片中多次调用容易出问题(比如只执行了第一个),最好用 promise 串联起来。

富文本

最早项目中的富文本采用 github 上的 wxParse,但是仔细阅读源码发现这东西并不靠谱,准确的来说它脱离不开小程序不靠谱的模板设计。

坑点:

  1. wxParse 解析完 html 字符串后,由于小程序模板无法无限嵌套,它给自己设置了一个13层的嵌套上限……
  2. wxParse 将所有的 html 标签尽可能地换成了小程序支持的标签,但很多 html 标签自带的处理,小程序做不到,比如说 table 中的 rowspancolspan
  3. 未支持标签没有被完全过滤,没有做完整的 polyfill

幸好之后官方推了 rich-text 这个标签,提供了另一套思路:将 html 字符串 parse 成规定好的 json 后丢给这个标签。
这里有些细节的操作,用以解决上面的第三个坑点:

1
2
3
4
5
6
7
8
9
10
11
12
// polish html string
const content = html
// handle svg
.replace(/(<svg .*?\/svg>)/g, handleSvg)
// delete unsupported tags
.replace(/<(canvas|audio|video|iframe) .*?\/\1>/g, '');

// generate json of dom tree
const json = parseHtml(content);
// transform new html5 block elements to `div`, inline elements to `span`
// and inline specified styles to imitate user agent styles.
walkDownJson(json, htmlPolyFill);

svg 的绘制采用转成 img 标签的方式。
但出现了比较大的问题,下文详述。

SVG

小程序目前对 svg 都是支持不良的,如果图片直接使用 svg 的外链无法显示(尽管开发者工具上 ok,一到真机就白屏),同时 rich-text 也中不支持 svg 标签。

唯一一种可以在真机上绘制的手段是 Data URI Scheme,就是写成:

1
2
<!-- index.wxml -->
<image src="data:image/svg+xml;utf8,{{ encodedString }}" />
1
2
3
4
5
6
// index.js
Page({
data: {
encodedString: encodeURI(svgHtmlString)
}
});

这确实能有效,但问题又来了,这个 svg 图片的大小貌似没那么好控制。。。。
使用 svg 作为 Data URI Scheme,其大小不受标签的 widthheight 属性影响,只能用 scale 属性缩放修改尺寸。
(这基本上卡死了那些想把 svg 当成动态大小图来使用的需求)

在做富文本组件时,想了一种 “曲线救国” 的绘制手段:每次解析到 svg 标签后都做成 Data URI Scheme 形式的图片资源,绘制到 canvas 画布上,然后生成临时路径(相当于转 png 格式).. 成功后将 rich-text 中对应的 img 标签 src 改成这个临时路径。

emmmm 最终我的结果是,开发者工具上富文本组件完美地支持了 svg,一到真机又跪了。真机的 canvas 画不出 Data URI Scheme 格式下的 svg …… 所以也就无法生成 png 的临时图片路径。。。。。 绝望。。。。。

已经和微信开发人员反馈,据说有后续支持计划。


更科学的路子怎么走

  • 开发方式

之前我是一直觉得需要一个 weapp-loader 配合 webpack 做打包,这个 loader 需要帮开发者完成自动引入依赖,支持编译我们重新定义的、更科学的语法及语法糖到微信原生的写法。有了这个 loader ,再魔改一下打包工具应该就可以解决很多问题。

而后突然杀出个 mpvue,思路和这个完全不一样,前司同事们 fork 了 vue 源码。既然 js 逻辑层与视图层是被微信分开的,那么干脆架空这个不好用的逻辑层。mpvue 完全采用 vue 语法,取其双向绑定,虚拟 dom 计算的优势。vue 实例的计算结果、更新结果最后都会被映射成小程序的 data 变化,Page({ data, ...methods }) 里面的逻辑弱化成一个控制 UI 的代理。
蛮喜欢这个脑洞的,只是还没测过具体性能如何。

  • 编码技巧
  1. 所有的微信异步接口都转成 Promise 返回。比如改写 wx.requestwx.canvasToTempFilePath 等 API.
  2. 所有页面最好引用同一个基础页面结构模板,配合自己包装的 Page 函数,extend 函数。这样方便做全局修改和状态维护。
  3. 包装、代理小程序中的事件处理函数,方便埋点。
  4. 跳转、重定向函数做封装
  5. 本地记录下安全域名列表,及时发现非安全域名资源的加载
  6. 与后端协作的时候,提高接口复用性,避免依赖发版的接口改动

emmm,看了看还是老生常谈的 设计模式工程化 :)