随着业务需求的不断累加、小程序追求快速产出。
在人手不足且开发周期较短的情况下,我们需要找到一个最大化开发效率的方法。
而高效率的开发离不开规范化、工程化、组件化。
为此整理写下总结,细数小程序中的坑与实践。
介绍我们对小程序高效率开发的思考与探索。
布局方案
导航栏
TabBar
BasicPage
用户系统
登录方案
初始化登录
鉴权
优化及 Bug 追踪
日志收集
数据分析
常用优化方案
preLoad
独立分包加载
布局方案
我们首先思考的是,在小程序中如何快速且高还原产出页面。
为此我们封装了一套页面组件。
导航栏
目前小程序有如下两种导航栏:常规、自定义导航栏
常规 | 自定义导航栏 | 自定义导航栏 |
常规布局下,顶部导航栏部分直接使用小程序提供导航栏。
自定义导航栏布局下,我们可以完全控制导航栏样式,赋予导航栏更多交互及 UI 设计上的可能。如上图所示,Readhub 在导航栏中加入了设置按钮,喜茶在个人页中标题渐隐及沉浸式导航栏效果。
可根据具体业务选择具体布局方案,在我们小程序中,我们选择了全部使用自定义导航栏的方式并对其进行了一定封装。
在确定使用自定义导航栏方案后,我们对导航栏进行了拆解
拆解后,我们发现可以将自定义导航栏分为两个部分:StatusBar 及 NavigationBar 。
通过查阅微信 API ,我们分别通过 wx.getSystemInfoSync
及 wx.getMenuButtonBoundingClientRect
获取到 StatusBarHeight 及 MenuButton 的布局信息。
由拆解图可知
1 NavigationBarPaddingTop = MenuButtonTop - StatusBarHeight
3 NavigationBarPaddingBottom = NavigationBarPaddingTop
5 NavigationBar = StatusBarHeight + NavigationBarPaddingTop + NavigationBarPaddingBottom + MenuButtonHeight
得到上述数据后,结果简单封装, 我们得到如下方案
StatusBar 部分, 我们使用 PaddingTop 填充。
可在此基础上可再进一步封装一些通用 NavigationBar 组件。
我们封装了一些常用 NavigationBar 组件, 如下所示:
自定义 TabBar
目前小程序 TabBar 中也存在两种方案。
常规 TabBar :微信提供方案,可修改 icon 、 文字及其对应选中状态。
自定义 TabBar :小程序基础库 2.5.0 开始支持。可通过其实现异形 TabBar 或各种自定义样式。
普通TabBar | 异形TabBar | 仅图标TabBar |
在我们小程序中,我们选择全部使用自定义 TabBar 来实现业务。
由于小程序基础库 2.5.0 之后官方才开始支持自定义 TabBar 。我们此处不直接选择使用 custom-tab-bar 方案。选择结合 custom-tab-bar 、 自定义组件及 wx.hideTabBar
的方案实现。
具体方案为放置空节点 custom-tab-bar 文件。在页面中按需引入自定义 TabBar 组件。在页面初始化完成后调用 wx.hideTabBar
隐藏原 TabBar 。
这样做的好处在于,在基础库 2.5.0 及更高版本时正常显示,在低版本时以最小代价兼容。
普通 | 异形TabBar |
在 iPhone X 系列下的底部安全区兼容方案如下
1@mixin media-style() { 2 .tab { 3 padding-bottom: 84px; 4 } 5} 6// 适配iPhone X系列下巴 7@media screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) { 8 @include media-style(); 9}1011@media only screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio:3) {12 @include media-style();13}1415@media only screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio:2) {16 @include media-style();17}18// 下面代码只为适配iPhone X在微信调试模拟器中为724px19@media screen and (device-width: 375px) and (device-height: 724px) and (-webkit-device-pixel-ratio: 3) {20 @include media-style();21}
推荐如无特殊需求,建议直接使用微信提供方案,在自定义 TabBar 方案中 安卓手机下拉刷新时, TabBar 会被拉出可视区域。需自定义下拉刷新组件解决
方案整合 BasicPage
以上方案在线上运行一段时间后稳定后。对自定义导航栏及自定义 TabBar 方案进行了整合。封装了 BasicPage 组件。
以我们线上典型页面为例,我们可以将页面分为两大类。
三段式结构 | 无 TabBar |
基于以上分析结合线上需求,我们对此基础组件进行封装。
Taro 框架伪代码,可根据各自使用框架进行封装,思路一致
1class BasicPage extends Taro.Component { 2 3 state = { 4 menuButtonHeight: 32, 5 menuButtonTop: 48, 6 statusBarHeight: 44, 7 }; 8 9 componentDidMount() {10 // ...获取并设置 menuButtonHeight 、 menuButtonTop 、 statusBarHeight11 }1213 render() {14 return (15 <View className='basic-page'>16 {17 this.props.header && <View className={`basic-page-header${this.props.fixed ? ' fixed' : ''}`} style={{18 paddingTop: `${this.state.statusBarHeight}px`,19 height: `${(this.state.menuButtonTop - this.state.statusBarHeight) * 2 + this.state.menuButtonHeight}px`,20 }}21 >22 {this.props.renderHeader}23 </View>24 }25 <View className={`basic-page-body${this.props.tab ? ' tab' : ''}`}>26 {this.props.renderBody}27 </View>28 {this.props.tab && <TabBar active={this.props.tabActive} />}29 </View>30 );31 }32}3334BasicPage.defaultProps = {35 fixed: false, // header 是否浮动36 tab: false,37 header: false,38 tabActive: 'template',39};40
使用中会经常用到 自定义 TabBar 、 自定义 NavigationBar 布局数据。再封装一个工具类获取。
1import Taro from "@tarojs/taro"; 2 3function rpx2px(rpx, windowWidth) { 4 return rpx / 750 * windowWidth; 5} 6 7export default class customConfig { 8 9 static fetchAllConfig() {10 const menuButton = Taro.getMenuButtonBoundingClientRect();11 const systemInfo = Taro.getSystemInfoSync();1213 const statusBarHeight = systemInfo.statusBarHeight;14 const headerHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height;15 const footerHeight = systemInfo.model.indexOf('iPhone X') === -116 ?17 rpx2px(100, systemInfo.windowWidth)18 :19 rpx2px(168, systemInfo.windowWidth); // 50 8420 const bodyHeight = systemInfo.windowHeight - statusBarHeight - headerHeight - footerHeight;21 const noTabBodyHeight = systemInfo.windowHeight - statusBarHeight - headerHeight;2223 let data = {24 source: {25 menu: menuButton,26 system: systemInfo,27 },28 height: {29 statusBar: statusBarHeight,30 header: headerHeight,31 body: bodyHeight,32 noTabBody: noTabBodyHeight,33 footer: footerHeight,34 },35 };36 Taro.setStorageSync('customConfig', data);37 return data;38 }3940 static get config() {41 let storageInfoSync = Taro.getStorageSync('customConfig');42 if(!storageInfoSync) {43 storageInfoSync = this.fetchAllConfig();44 }45 return storageInfoSync;46 }47}
到此,我们完成对基础页面组件的封装。目前线上运行小程序所有页面都基于该组件进行开发。
开发新页面时只需要引用该组件即可。
1<BasicPage header tab tabActive='index' 2 renderHeader={ 3 <View 4 className='my-index-header' 5 > 6 <Text>Title</Text> 7 </View> 8 } 9 renderBody={10 <View className='my-index-header'>11 Body12 </View>13 }14/>
用户系统
在一个应用中,用户系统是至关重要的。我们通过数个小程序的开发,整理了一套我们目前正在使用的用户系统实践。
登录、获取用户信息
登录流程 | 获取用户信息 |
如上图所示,我们将小程序登录及获取用户信息拆分为两部分。
主要有如下考虑:
降低用户使用门槛,可先让用户体验部分功能。后续分享或互动时提示授权完善用户信息
保证始终持有用户登录态,方便程序处理。如把用户登录及完善用户信息放置一起,在未授权时无法获取自定义登录态。判断变得复杂且无法提前收集 formId
同一开发者账号下,多小程序互通时,如有一小程序用户授权过,可通过返回 unionid 直接同步信息,无需再授权,提升用户体验。
处理注意点
授权获取用户信息时,如果服务端未记录用户 sessionKey ,在 Button type = getUserInfo 回调事件中使用 wx.login
方法获取 code 的话,会导致 sessionKey 变化。从而导致 getUserInfo 时使用 sessionKey 与新 sessionKey 不匹配。从而导致解密用户信息失败。
解决方案有如下两种:
Button type = getUserInfo 回调事件中使用
wx.login
方法后,再次调用wx.getUserInfo
方法重新获取加密用户信息。服务端记录 sessionKey ,Button type = getUserInfo 回调后无需调用
wx.login
,直接提交供服务端处理。
第一种方案适合简单改造旧项目、快速开发,但强烈建议使用服务端处理方式解决。
完善用户信息时,解密用户信息部分请查看官方文档,这里不叙述具体流程https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html
unionid 机制
另外,在登录流程中服务端向微信换取 sessionKey 过程中,如果满足一定条件,会直接返回 unionid 。同开发者账号下多个小程序时可用 unionid 做用户信息同步,无需再授权。提升用户体验。
unionid 机制: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html
小程序初始化及页面初始化处理
在日常开发中,我们通常会把登录获取 token 操作放置在小程序初始化中即 app.js 定义的 onLaunch
中。而该生命周期与页面初始化生命周期为同步进行。
此时,如果在页面初始化中,需要携带用户登录态请求接口获取信息时,可能出现如下情况
因为小程序初始化及页面初始化是同步进行的。若页面初始化时,小程序初始化中登录请求仍未完成。会导致未携带 token 或其他鉴权信息,鉴权失败。
最开始我们通过在组件中挂载一个特殊事件 componentDidInit
,待小程序初始化登录请求后获取当前页面实例进行调用。但该方案对代码侵入性太强,最终我们选择维护一个登录请求队列。
用上队列的原因在于,在产品需求上经常会有先跳入首页,再从首页跳入二级页的需求,这样能让用户回退一次后,仍然能回到首页。但会导致在不同页面中近乎同时调用 login
方法。
在第一种方案中,解决该问题需要获得所有页面实例进行调用。而引入队列后只需要轮询消费队列中函数执行即可。上述流程可解决此问题。伪代码如下:
代码仅供理解思路
1let loginDoing = false; 2const loginEvent = []; 3 4const userProfile = observable({ 5 user: { 6 avatar: '', 7 isCompleted: false, 8 nickname: '', 9 uid: 0,10 token: '',11 },12 async loginProcess() {13 if(this.user.token) {14 return this.user;15 }16 loginDoing = true;17 let code;18 try {19 const codeResult = await Taro.login();20 if(codeResult.errMsg !== 'login:ok') {21 throw new Error('Taro.login 失败');22 }23 code = codeResult.code;24 } catch (e) {25 loginDoing = false;26 throw e;27 }28 const result = await post(URL().user.login, {29 code,30 });31 let user = {32 ...result.user,33 token: result.token,34 };35 this.user = user;36 loginDoing = false;37 setTimeout(() => {38 let length = loginEvent.length;39 for(let i = 0; i < length; i++) {40 loginEvent.pop()(user);41 }42 });43 return user;44 },45 login() {46 if(loginDoing) {47 return new Promise((resolve) => {48 loginEvent.push(resolve);49 });50 } else {51 return this.loginProcess()52 }53 },54});
鉴权
业务需求中,通常存在某些操作需要 【 用户授权完善信息 】 后才能继续进行,早期项目中都是各自页面中写鉴权代码。因而会涉及大量重复代码,也不利于快速开发。为此我们封装了一套鉴权方案。
BasePage
通过所有页面基础一个基类 BasePage 。在 BasePage 中写入鉴权逻辑来实现。配合在主页面中使用 AuthorizationModal 组件实现鉴权。
代码仅供理解思路
1 export default class BasePage extends Component { 2 3 state = { 4 // 鉴权相关 5 showAuthorizationModal: false, 6 }; 7 8 /** 9 * 鉴权相关10 */11 // 授权成功事件12 authSuccessEvent() {13 }1415 // 取消授权事件16 authFailEvent() {17 }1819 async checkAuthorization() {20 // 当前是否有已验证21 let globalData = getGlobalData(STORAGE_KEY.VERIFY);22 if(globalData) {23 return {24 isNew: false,25 };26 } else {27 Taro.showLoading({28 title: '检查授权中...',29 mask: true,30 showTicketModal: false,31 });32 // 如果本地不存在时,先请求接口33 // 未登录过,或新机器34 // 请求token及授权状态35 let res;36 try {37 res = await Taro.login();38 } catch() {39 Toast.fail('登录失败~');40 Taro.hideLoading();41 throw new Error('Taro.login 失败');42 }43 // 请求授权接口44 const result = {};45 if(result.errno === 0) {46 resolve({47 isNew: false,48 });49 } else {50 // 未授权过51 // 弹窗提示授权52 this.setState({53 showAuthorizationModal: true,54 });55 this.authSuccessEvent = () => {56 this.setState({57 showAuthorizationModal: false,58 });59 resolve({60 isNew: true,61 });62 };63 this.authFailEvent = () => {64 this.setState({65 showAuthorizationModal: false,66 });67 reject();68 };69 }70 }71 }72}
页面继承该基类
1 class LaunchIndex extends BasePage {}
在页面中置入组件
1 {this.state.showAuthorizationModal &&2 <AuthorizationModal onSuccess={this.authSuccessEvent} onFail={this.authFailEvent}/>}3
接下来,我们只需要在需要鉴权的操作中如下使用即可
1this.checkAuthorization()2 .then((res) => {3 // 授权成功逻辑4 console.log('是否新用户', res.isNew);5 })6 .catch(() => {7 // 授权失败逻辑8 })
该方案好处在于,授权由状态驱动,只需在代码中调用 checkAuthorization 方法即可。
AuthorizationView
后来,由于第一种方案过于重,对页面代码侵入性较强。为此我们又封装了一套较轻的组件。
大部分逻辑中,需要用户主动点击时才进行鉴权,我们基于此思路封装了 AuthorizationView 。对外暴露 onAgree 、 onDeny 方法实现对部分区域的点击鉴权操作。
代码仅供理解思路
1 class AuthorizationView extends Taro.Component { 2 3 state = { 4 showLoginPanel: false, 5 }; 6 7 /** 8 * 登录 9 */10 click() {11 const { userProfile: { user, }, } = this.props;12 if(user.isCompleted) {13 this.props.onAgree(user);14 } else {15 // 显示登录框16 this.setState({17 showLoginPanel: true,18 });19 }20 }2122 /**23 * 授权登录24 * @param e25 */26 async bindGetUserInfo(e) {27 if(e.detail.errMsg === 'getUserInfo:ok') {28 const { userProfile, } = this.props;29 const userResult = await userProfile.login(true);30 this.setState({31 showLoginPanel: false,32 });33 this.props.onAgree(userResult);34 } else {35 this.props.onDeny();36 }37 }3839 cancel() {40 this.setState({41 showLoginPanel: false,42 });43 }4445 render() {46 return (47 <Block>48 <View onClick={this.click}>{this.props.children}</View>49 {50 this.state.showLoginPanel && <View className='login-panel'>51 <View className='login-panel-main'>52 <View className='login-panel-main-title'>您还未登录</View>53 <View className='login-panel-main-subtitle'>请先登录再进行操作</View>54 <Image className='login-panel-main-image' src='https://p0.ssl.qhimg.com/t01a1e495cc2be1e651.png' />55 <View className='login-panel-main-footer'>56 <View className='login-panel-main-footer-button cancel' onClick={this.cancel.bind(this)}>暂不登录</View>57 <Button className='btn-reset' openType='getUserInfo' onGetUserInfo={this.bindGetUserInfo}>58 <View className='login-panel-main-footer-button confirm'>立即登录</View>59 </Button>60 </View>61 </View>62 </View>63 }64 </Block>65 );66 }67}6869AuthorizationView.defaultProps = {70 onAgree: () => {71 },72 onDeny: () => {73 },74};7576export default AuthorizationView;77
代码中只需要使用该组件包裹子组件即可使用
1 <AuthorizationView onAgree={this.onAgree.bind(this)} onDeny={this.onDeny.bind(this)}>2 <View>生成海报</View>3</AuthorizationView>4
以上两种方案都有在线上业务中使用,具体选型看业务决定
优化及Bug追踪
在维护阶段,我们会更加关注于用户反馈 bug 时如何复现场景及数据分析。
日志收集
在小程序基础库版本 2.1.0 后,微信提供了一套日志相关接口:LogManager 。
在用户反馈时,通过该接口记录的日志会同步上传至微信后台,可下载查看追踪 Bug。
我们通过简单的对其封装,实现一套日志收集机制。
1 const _logger = Taro.getLogManager({ level: 0, }); 2 3const Logger = { 4 debug(...args) { 5 _logger.debug(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 📝`, ...args); 6 console.debug(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 📝`, ...args); 7 }, 8 info(...args) { 9 _logger.info(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 🍺`, ...args);10 console.info(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 🍺`, ...args);11 },12 warn(...args) {13 _logger.warn(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ⚠️`, ...args);14 console.warn(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ⚠️`, ...args);15 },16 error(...args) {17 _logger.warn(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} [ Error ] ❌️`, ...args);18 console.error(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} [ Error ] ❌️`, ...args);19 },20};2122export default Logger;
在使用时,最好按照一定规范进行使用,方便后续查找。例如
1 Logger.error('[ MyIndex ] 获取用户信息失败', e);2 Logger.debug('[ LaunchIndex ] init response', info);
实时日志分析:小程序基础库 2.7.1 之后还提供了 实时日志分析功能。https://developers.weixin.qq.com/miniprogram/dev/api/base/debug/wx.getRealtimeLogManager.html
数据分析
在产品迭代过程中,我们一般会依照上面模型进行迭代。
数据获取 → 数据分析 → 数据应用 → 数据反馈
在小程序中,数据获取的方案主要有
小程序后台自定义分析
小程序本身提供的数据平台。
优点在于能随时不发版本添加数据打点位置。能满足大部分需求。
主要依靠产品后台自行添加数据打点项目。
第三方数据平台
这里以阿拉丁自定义数据分析为例。依靠第三方平台提供 API 进行打点。
阿拉丁
……
自有数据分析平台
一般大厂都会有自己自有数据分析平台,联系数据组拓展即可
推荐使用小程序后台自定义分析进行打点。各数据平台打点大同小异,能不发版本添加数据打点才是大杀器。
阿拉丁数据平台打点封装,代码仅供理解思路
1import Taro from '@tarojs/taro'; 2 3export default class Monitor { 4 static sendEvent(moduleName, eventName, options) { 5 let aldstat = Taro.getApp().aldstat; 6 if(aldstat) { 7 aldstat.sendEvent(`[ ${moduleName} ] ${eventName}`, options); 8 } 9 }10}1112Monitor.sendEvent('LaunchIndex', '返回', {13 id: this.state.id,14});1516Monitor.sendEvent('LaunchIndex', '点击制作', {17 id: this.state.id,18});
小程序自定义分析API方式依葫芦画瓢封装即可。
需要注意的是封装时要有逻辑、有规则的封装,方便后面筛选具体页面具体操作。
常用优化方案
preLoad
在微信小程序中,页面路由跳转时 ( 例如调用 wx.navigateTo
、wx.redirectTo
或 wx.switchTab
) ,到页面触发 componentWillMount
会有一定延时。因此一些网络请求可以提前到跳转前一刻请求。而后在触发 componentWillMount
后取得该请求实例。
目前各框架均提供了预加载请求实现。原生开发可自行拓展,思路一致。以下以 Taro 为例。代码仅供理解思路。
1export default class Preload extends BasePage { 2 componentWillMount() { 3 let initData; 4 // 兼容直接进入的场景 5 if(this.$preloadData) { 6 initData = this.$preloadData; 7 } else { 8 initData = request(URL().user.defaultAddress, { 9 token: getGlobalData(STORAGE_KEY.ACCESS_TOKEN),10 });11 }12 initData13 .then((initInfo) => {14 })15 .catch(() => {16 });17 }1819 componentWillPreload (params) {20 return request(URL().user.defaultAddress, {21 token: getGlobalData(STORAGE_KEY.ACCESS_TOKEN),22 });23 }24}
独立分包加载
https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages.html
除上面列出尝试外。我们还做了以下工作:
通用分享图解决方案
小程序云开发应用
自定义下拉刷新组件 RefreshView
Protobuf
图片裁剪组件
还有一些你可能遇不到的坑
原生组件使用问题
Video 、 innerAudioContext
由于不是必要部分,篇幅有限,不在此一一列举
价值
在对小程序进行上述实践后,我们已经能够基于该实践快速开发复制小程序。我们最近一个小程序 【嘟嘟卡点相册】 仅开发5天后就上线了。
纸上得来终觉浅,绝知此事要躬行。
文章内容基本囊括了开发维护阶段可能会用到的点及我们对此作出的应对方案。供参考。
本文仅为抛砖引玉, 软件开发没有银弹,好的方案一定是与业务息息相关的。欢迎交流。
面向未来
小程序脚手架 CLI