微信小程序开发深入解读

7,741 阅读18分钟

下面结合开发文档以及个人开发经验对微信小程序关键部分进行解读(不是入门教程,具体入门读者可以看官网),希望看完的读者对微信小程序有大概的认识或者有所启发。

本文同步于个人博客 www.imhjm.com/article/597…

官方开发文档 mp.weixin.qq.com/debug/wxado…
官方开发者社区 developers.weixin.qq.com/

运行环境

微信小程序运行在三端:iOS、Android 和 用于调试的开发者工具。
三端的脚本执行环境聚以及用于渲染非原生组件的环境是各不相同的:

  • 在 iOS 上,小程序的 javascript 代码是运行在 JavaScriptCore 中,是由 WKWebView 来渲染的,环境有 iOS8、iOS9、iOS10
  • 在 Android 上,小程序的 javascript 代码是通过 X5 JSCore来解析,是由 X5 基于 Mobile Chrome 53 内核来渲染的
  • 在 开发工具上, 小程序的 javascript 代码是运行在 nwjs 中,是由 Chrome Webview 来渲染的

引用:mp.weixin.qq.com/debug/wxado…

正由于脚本执行环境的不同,所以真机与开发者工具有些表现还是差异挺大的,特别表现在原生组件方面(后面会讲到部分原生组件注意点),iOS以及Android都需要多加测试才能保证程序没有问题。
同时因为是在JsCore中执行,JsCore没有窗口对象,所以没有window、document等等(所以很多外部生态插件/库无法直接使用,需要稍作修改)

生命周期

小程序全局有App、Page内置的全局变量,用于注册小程序以及注册页面

App实例生命周期

  • onLaunch
    监听小程序初始化,当小程序初始化完成时,会触发 onLaunch(全局只触发一次)
  • onShow
    监听小程序显示 当小程序启动,或从后台进入前台显示,会触发 onShow
  • onHide
    监听小程序隐藏 当小程序从前台进入后台,会触发 onHide

前台、后台定义: 当用户点击左上角关闭,或者按了设备 Home 键离开微信,小程序并没有直接销毁,而是进入了后台;当再次进入微信或再次打开小程序,又会从后台进入前台。需要注意的是:只有当小程序进入后台一定时间,或者系统资源占用过高,才会被真正的销毁。

Page实例生命周期

具体读者可以看文档中的「Page实例生命周期」,左边是视图线程,右边是逻辑层线程
可以看到View Thread分四个阶段

  • Start
  • Inited
  • Ready
  • End

AppSevice Thread也分四个阶段

  • Start
  • Created
  • Active (Alive)
  • End

我们从图中可以简单地分析出「Page实例生命周期」

  • View Thread以及AppSevice Thread进入Start
  • AppSevice Thread调用Page方法传入配置Created后,调用onLoad(监听页面加载)以及onShow(监听页面显示)方法,AppSevice Thread等待View Thread的通知
  • View Thread进入初始化阶段(Inited)后,通知(Notify)AppSevice Thread已经初始化好了,然后AppSevice Thread传入App实例中的初始化数据,AppSevice Thread等待View Thread的下一次通知
  • View Thread收到初始化的数据之后,第一次渲染页面(First Render),进入Ready阶段,渲染完毕通知AppSevice Thread,AppSevice Thread调用onReady方法,进入Active阶段
  • 在AppSevice Thread Active阶段,会调用一些setData的方法,就是传递数据给View Thread中的渲染器(Rerender),进行视图更新
  • 当小程序切到后台或者当前Page跳转(具体看后面路由部分或文档)调用onHide方法,进入Alive阶段,再切回来前台调用onShow进入Active阶段
  • 最后Page销毁,调用onUnload方法,页面卸载

从上面声明周期的分析,我们可以得到以下几个结论:

  • onLoad只调用一次,onShow页面显示多次调用
  • First Render是Page传入的data数据进行Render,在onLoad阶段进行setData其实也是在进入Active阶段发送视图更新的(也就是在OnReady后),所以,假如在onLoad阶段setData跟Intial Data不一样的数据,是可以看到页面闪烁了一下的

Page实例生命周期
Page实例生命周期

数据驱动(响应的数据绑定)

从生命周期也可以看出微信小程序跟vue等框架类似,是数据驱动视图更新,在逻辑层修改数据,视图层响应数据更新

双括号绑定数据

<view> {{ message }} </view>

Page({
  data: {
    message: 'Hello MINA!'
  }
})

如上使用双括号,便实现数据与视图绑定

数据单向流动

微信小程序同样是数据单向流动,而不是双向绑定,比如你传入它基础组件的某些数据,并不能同步到你的data中,而是调用某些监听函数去获取(比如scroll-view中scroll-top,你能通过视图传入data更新滚动位置,但是你在滚动的时候,并不能双向绑定去获取scroll-top,而是需要监听bindscroll去获取)

条件渲染&&列表渲染

条件渲染以及列表渲染作为数据驱动视图的重要部分,值得一提

1.条件渲染的wx:if以及hidden

  • wx:if会产生局部渲染,销毁条件块(或者重新渲染)
  • hidden就是直接控制display block/none了

所以官网给出的结论是

一般来说,wx:if 有更高的切换消耗而 hidden 有更高的初始渲染消耗。因此,如果需要频繁切换的情景下,用 hidden 更好,如果在运行时条件不大可能改变则 wx:if 较好。

2.列表渲染

<view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName" wx:key="*this">
  {{idx}}: {{itemName.message}}
</view>

这里其他for,index,item这些循环渲染基本的东西就不具体说了,谈谈这个wx:key

假如我们更新array数组,预期来说视图重新渲染,但是我们假如只是在array中push更多的元素,我们的想法应该是重新排序,不去重复创建视图原来已经有的元素,这里为了标识item,我们就可以用wx:key,有助于提升渲染的效率,并且能够保持状态(如<input/> 中的输入内容,<switch/> 的选中状态)

路由管理

小程序的路由管理部分均由框架处理,开发者只需调用API即可,但是还是有一些地方需要注意

文档: mp.weixin.qq.com/debug/wxado…

小程序的路由管理是用一个页面栈来维护,通过出栈以及入栈加载不同页面,可以用getCurrentPages()获取一个栈数组

下面这个表格根据官网两个表格整合而成,注意区分各种触发时机以及页面栈的表现

路由方式 触发时机 页面栈表现 路由前页面 路由后页面调用方法
初始化 小程序打开的第一个页面 新页面入栈 onLoad, onSHow
打开新页面 调用 API wx.navigateTo 或使用组件 <navigator open-type="navigateTo"/> 新页面入栈 onHide onLoad, onShow
页面重定向 调用 API wx.redirectTo 或使用组件 <navigator open-type="redirectTo"/> 当前页面出栈,新页面入栈 onUnload onLoad, onShow
页面返回 调用 API wx.navigateBack 或使用组件<navigator open-type="navigateBack">或用户按左上角返回按钮 页面不断出栈,直到目标返回页,新页面入栈 onUnload onShow
Tab 切换 调用 API wx.switchTab 或使用组件 <navigator open-type="switchTab"/> 或用户切换 Tab 页面全部出栈,只留下新的 Tab 页面 具体看官网
重启动 调用 API wx.reLaunch 或使用组件 <navigator open-type="reLaunch"/> 页面全部出栈,只留下新的页面 onUnload onLoad, onShow

注意区分页面重定向(redirectTo)以及打开新页面(navigateTo),因为小程序限制了也页面栈最多只有5个元素,所以当你深度达到5个,再调用navigateTo想让新页面再入栈就会报错,所以官方建议是

避免多层级的交互方式,或者使用wx.redirectTo

模块化&&组件/模板

js模块化

小程序默认使用CommonJs规范
使用module.exports(exports)以及require来实现模块化
当然也可以ES6转ES5使用import/export,小程序开发工具带有babel es6转es5设置,勾选即可
猜测最后也是使用webpack打包文件

这里简单说下模块化需要注意的吧,首先module.exports = exports, module就是一个对象{},exports就是对它的一个key的引用,所以需要区分下module.exports = xxx, 以及export.xxx = yyy;

还得注意区分ES6和commonjs的差异,前者模块静态编译,后者运行加载,所以表现上有很多不同,ES6可以在编译时处理依赖关系,并且输出的值为引用,对循环引用支持比较好,不同的是commonjs模块是运行加载,输出值为拷贝

这部分就不多说了,具体可以看 es6.ruanyifeng.com/#docs/modul…

不过这里的require加载机制不同于nodejs,加了一些限制,比如不能用绝对路径,也不支持node_modules,所以如果要使用node_modules的内容需要手动拷贝到目录里

WXML模板

wxml通过template可以实现复用
通过is属性动态决定渲染哪个模版

<template name="odd">
  <view> odd </view>
</template>
<template name="even">
  <view> even </view>
</template>

<block wx:for="{{[1, 2, 3, 4, 5]}}">
    <template is="{{item % 2 == 0 ? 'even' : 'odd'}}"/>
</block>

并且有自己的作用域,只能使用传入的data(这点跟组件很相似)

mp.weixin.qq.com/debug/wxado…

WXSS @import

使用@import语句可以导入外联样式表,@import后跟需要导入的外联样式表的相对路径

/** common.wxss **/
.small-p {
  padding:5px;
}
/** app.wxss **/
@import "common.wxss";
.middle-p {
  padding:15px;
}

上述三个模块化的东西可以构成类似组件一样的部分,但是引入方面太不方便,wxml/wxss/js都得引入一份,并且js耦合程度过高,需在Page中引入“组件”太多的方法去调用,也没有自己的数据作用域,data都是在Page里,弊端还是比较明显,像组件但不是组件。

组件

小程序自己提供了一系列的基础组件,这些就是真正的组件了,但是小程序没有提供自定义组件的方式
这部分也不多说了,内容也挺多的,很多细节,具体看官方文档,后面也会讲到某些个人实战时遇到的一些经验

文档:mp.weixin.qq.com/debug/wxado…

错误监控

错误监控对于应用的稳定性至关重要,这部分也特地拿出来讲下

通常应用可以使用ravenjs使用window.onerror捕获错误,处理error.stack,然后接入sentry上报,当然在微信小程序也可以,但是需要做一些配置改动

在微信小程序该怎么做呢?
没有了window.onerror, 微信小程序可以在App传入onerror进行捕获错误,使用小程序的wx.request上报,并且可以附加小程序的systemInfo一起上报,获得更多错误信息,更好地修复bug

小程序上一段时间加了一个运维中心,可以在公众平台中设置

埋点/数据上报

数据上报也是一个好应用中不可或缺的部分,去了解用户如何使用应用,了解怎么去更好地优化以及增加功能。

小程序自带数据上报接口

官方教程: mp.weixin.qq.com/debug/wxado…

有两种上报方式,一种是使用API接口wx.reportAnalytics在代码中上报,一种是在微信公众平台直接配置事件,根据id/class和page来指定事件(比如点击事件等等)

  • 前者优点是数据粒度可以很细,缺点就是需要写在代码里,上线成本比较高
  • 后者优点是直接在微信公众平台发布事件即可,上线/删除事件成本较低,缺点是可定制数据的能力比较弱,只能使用当前Page里的data,并且需要有id/class

零散经验之谈&&开发相关问题

上面微信小程序基本的也讲了挺多了,下面开始讲一些零散的开发细节和遇到的问题以及解决方案

如何设计一个微信小程序的开发结构

如果不考虑引入像wepy这种组件化框架或者引入状态管理方案,觉得采用以下开发结构也是一个良好的选择

 |---model---------------跟业务逻辑相关的,跟数据交互的model
     |---xxx.js
 |---utils----------------可从业务逻辑中抽离处可复用工具
     |---xxx.js
 |---pages---------------微信小程序的各个page
         |---xxx
                 |---xxx.wxml
                 |---xxx.wxss
                 |---xxx.json
                 |---xxx.js
 |---components-----------可从page中抽离出的组件,有利于复用以及维护
         |---xxx
                 |---xxx.wxml
                 |---xxx.wxss
                 |---xxx.js
 |---static----------------静态资源文件
 |---app.js
 |---app.json
 |---app.wxss

 其他eslint、git相关等等就不放上去了

这个整体目录并不复杂,但是这样分层,每部分的职责就可以很清晰了,有利于代码维护以及复用
(稍微区分下model和utils,model即是一些跟后台交互数据的操作,可以依赖utils,utils是从业务逻辑中抽离出的可复用的工具库,但不可以依赖于model)

如何更好地调用接口

wx.request

由于微信小程序wx.request有并发10个的限制,并且之前如果超出并发数就会报错从而中断了超出的请求,当时整体使用自己封装的request,支持超出并发数放入队列中,当有新的请求complete再检查队列,不空则取出原先的请求retry,并且加上了超时处理,代码大概如下

let RequestQ = {
  retry: [],
  emitRequest (obj) {
    if (!obj || typeof obj !== 'object') {
      return;
    }
    let oldFail = obj.fail;
    let oldComplete = obj.complete;
    let oldSuccess = obj.success;
    let timeId;
    obj.timeout = obj.timeout || 10000;

    // 假如有timeout开启定时器
    if (obj.timeout) {
      timeId = setTimeout(() => {
        obj.over = true;
        oldFail && oldFail.apply(obj, [{ isTimeout: true }]);
        oldComplete && oldComplete.apply(obj, [{}]);
      }, obj.timeout);
    }
    obj.success = (...args) => {
      obj.end = +new Date();
      // 在队列中或者由于超时结束的直接return
      if (obj.inRetry || obj.over) {
        return;
      }
      oldSuccess && oldSuccess.apply(obj, args);
    };
    obj.fail = (...args) => {
      if (obj.over) {
        return;
      }
      if (Array.from(args)[0].errMsg === 'request:fail exceed max task count' && !obj.inRetry) {
        // 并发数超出则进入队列,不触发fail与complete
        obj.inRetry = true;
        this.retry.push(obj);
      } else {
        oldFail && oldFail.apply(obj, args);
      }
    };
    obj.complete = (...args) => {
      if (obj.inRetry || obj.over) {
        return;
      }
      clearTimeout(timeId);
      if (this.retry.length) {
        // complete完成,检查队列有则拿出来执行
        let newObj = this.retry.shift();
        newObj.inRetry = false;
        this.emitRequest(newObj);
      }
      oldComplete && oldComplete.apply(obj, args);

    };
    wx.request(obj);
  },
};

function request (obj) {
  RequestQ.emitRequest(obj);
}

不过小程序也在持续地完善,基础库在1.4.0更新了request, 队列处理也帮我们做好了

U 更新 API request 超过并发限制做队列处理
U 更新 API request 返回 requestTask 支持 abort 操作

这里还得说个wx.request的注意点,微信小程序默认情况下dataType为'json',会尝试对响应的数据做一次JSON.parse,所以假如返回一张base64图等等数据,在真机上就会出现错误(这个错误还挺难找的)

pomise化

将接口promise化可以减少回调,代码看起来也会更加清晰
记得要引入promise-polyfill,在某些机型中微信小程序对promise的支持并不好,可以使用自己的promise

具体怎么编写promise化的接口就不详细说了,在success方法 resolve, 在error方法reject, 无论什么情况均返回promise
这里引一段网上的promisify

// 链接:http://www.jianshu.com/p/4433d46e6235
// 用Promise封装小程序的其他API
export const promisify = (api) => {
    return (options, ...params) => {
        return new Promise((resolve, reject) => {
            api(Object.assign({}, options, { success: resolve, fail: reject }), ...params);
        });
    }
}

小程序尺寸单位rpx产生的微小的缝隙

官网介绍小程序这个rpx有句

注意: 在较小的屏幕上不可避免的会有一些毛刺,请在开发时尽量避免这种情况。

遇到一个这样的问题,使用了
padding-bottom: 0rpx;
却发现padding-bottom有个微小的缝隙,只要将0rpx改成0即可

注意有时还会出现多个元素并排使用rpx,毛刺误差累积起来可能会产生比较大的影响,假如出现这种情况,可以使用白分比来替代解决

刷新方案(加载方案)

  • 下拉刷新
  • 触顶加载
  • 无限加载load more
  • 刷新按钮

下拉加载实现

  • page自带的事件监听,.json中配置enablePullDownRefresh,并且监听onPullDownRefresh,使用stopPullDownRefresh
    // index.json
    {
    "enablePullDownRefresh": true
    }
  • 监听手势事件模拟实现(这个相对复杂,并且实现出来性能以及兼容清况也未知)

触顶加载

  • 使用scroll-view的bindscrolltoupper方法

无限加载loadmore

  • 直接使用page的onReachBottom监听
  • 使用scroll-view的bindscrolltolower方法

刷新按钮

  • 因为小程序没有当前页面的刷新方式,可以使用position fixed做一个按钮,z-index设层级高一点即可

swiper-view实现频道滑动切换

为了实现跟原生应用接近的体验,采用手势左右滑动来实现频道切换

先讲讲swiper-view如何实现滑动的呢?


从上图swiper-item可以看到其实就是改变translate去实现的
swiper-item绝对定位,并加入will-change:auto提升为合成层,在实现动画translate时让页面不发生重绘,在GPU完成

注意到一个absolute,所以swiper-item内部的内容是无法把外部给撑开的,所以无法实现自适应,必须自己指定高度

我们的需求是要实现上面预留导航栏,全屏滑动,css上就可以这样

page {
  box-sizing: border-box;
  -webkit-box-sizing: border-box;
  height: 100%;
    /* 预留顶部导航栏 */
  padding-top: 89rpx;  
}
.swiper-container {
    height: 100%;
}

假如你还想在里面放入可滚动的列表项,毫无疑问得使用scroll-view,而不是view(overflow:auto)了,不然reachBottom的触发就会出问题,因为本来就只有一屏了

加入scroll-view的话,Page下拉加载是跟scroll-view相冲突的,所以要么抛弃下拉加载,要么只能使用触顶加载

scroll-view注意点

scroll-view有一个地方很容易让人忽视,就是你在绑定scrolltoupper以及bindscrolltolower方法,你会困惑为何并不是滑到顶部和底部再触发事件,而是接近的时候才触发,其实仔细看文档你会发现

属性名 类型 默认值 说明
upper-threshold Number 50 距顶部/左边多远时(单位px),触发 scrolltoupper 事件
lower-threshold Number 50 距底部/右边多远时(单位px),触发 scrolltolower 事件

它的默认值是50,所以距离50px就会触发,所以如果要真正地触顶(底),可以先设置它们为0

video组件

开发时用到video组件,遇到一些问题也拿出来讲下
首先开发者需要记住的一个很重要的点

map、canvas、video、textarea 是由客户端创建的原生组件,原生组件的层级是最高的,所以页面中的其他组件无论设置 z-index 为多少,都无法盖在原生组件上。 原生组件暂时还无法放在 scroll-view 上,也无法对原生组件设置 css 动画。

其次video组件是没办法跟着屏幕滚动的,假如你放了一个video组件fixed在顶部,它也是无法跟着屏幕滚动的,开发者工具可以实现,但是真机滚动后是会出现黑影的,视频还是一直定位在原来的位置(这个也体现了本文开头的环境的区别),要解决这个问题就只能是不能全屏滚动,用页面的一部分scroll-view滚动即可,让视频不用滚动

还有一个就是video组件其实你用wx:if去控制渲染隐藏是有问题的,当你多次切换,会发现在某些机型上发热严重,抓包发现之前创建的video实例并没有真正地随着wx:if销毁,还在请求数据,所以,假如需要控制渲染隐藏video组件的时候,可以尝试使用hidden属性配合wx.createVideoContext控制暂停来解决问题

小程序性能调优

近期官网也出来了一个优化建议,开发者务必要看看

mp.weixin.qq.com/debug/wxado…

大体上就是

  • 不要频繁地去setData,能合成一个setData尽量合成一个
  • 不需要视图更新的data不要使用setData
  • setData数据不要过大(当数据量过大时会增加脚本的编译执行时间,占用 WebView JS 线程)
  • 由于用户使用小程序是从CDN下载,并且目前小程序打包是会将工程下所有文件都打入代码包内(这个还是需要小程序那边优化,按需会好点),所以目前你代码包多放东西,意味着用户得多下资源,多耗费流量,首次打开速度也会变慢

如何看文档

不得不吐槽小程序的文档搜索功能实在是太差了,基本是无法使用的,建议直接当前页面command+F去搜索,看文档必须注意看文档中的tip,这样就可以躲过很多坑

最后

谢谢阅读~
欢迎follow我哈哈github.com/BUPT-HJM
欢迎继续观光我的新博客~(老博客近期可能迁移)

欢迎关注