- 如何实现小程序的强制更新
大家都知道小程序提交审核发布以后是不会马上更新版本的,用户需要下次使用才会更新到新的版本,这就是冷更新。 那么如果要做到及时生效怎么办呢?这时候就要做处理了,将下面的代码添加到app.js,提交审核,发布就会生效了 [代码]onLaunch: [代码][代码]function[代码] [代码](options) {[代码] [代码] [代码][代码]this[代码][代码].autoUpdate()[代码] [代码] [代码][代码]},[代码] [代码] [代码][代码]autoUpdate: [代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]var[代码] [代码]self = [代码][代码]this[代码] [代码] [代码][代码]// 获取小程序更新机制兼容[代码] [代码] [代码][代码]if[代码] [代码](wx.canIUse([代码][代码]'getUpdateManager'[代码][代码])) {[代码] [代码] [代码][代码]const updateManager = wx.getUpdateManager()[代码] [代码] [代码][代码]//1. 检查小程序是否有新版本发布[代码] [代码] [代码][代码]updateManager.onCheckForUpdate([代码][代码]function[代码] [代码](res) {[代码] [代码] [代码][代码]// 请求完新版本信息的回调[代码] [代码] [代码][代码]if[代码] [代码](res.hasUpdate) {[代码] [代码] [代码][代码]//检测到新版本,需要更新,给出提示[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'更新提示'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'检测到新版本,是否下载新版本并重启小程序?'[代码][代码],[代码] [代码] [代码][代码]success: [代码][代码]function[代码] [代码](res) {[代码] [代码] [代码][代码]if[代码] [代码](res.confirm) {[代码] [代码] [代码][代码]//2. 用户确定下载更新小程序,小程序下载及更新静默进行[代码] [代码] [代码][代码]self.downLoadAndUpdate(updateManager)[代码] [代码] [代码][代码]} [代码][代码]else[代码] [代码]if[代码] [代码](res.cancel) {[代码] [代码] [代码][代码]//用户点击取消按钮的处理,如果需要强制更新,则给出二次弹窗,如果不需要,则这里的代码都可以删掉了[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'温馨提示'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'本次版本更新涉及到新的功能添加,旧版本可能无法正常访问哦'[代码][代码],[代码] [代码] [代码][代码]showCancel: [代码][代码]false[代码][代码],[代码][代码]//隐藏取消按钮[代码] [代码] [代码][代码]confirmText: [代码][代码]"确定更新"[代码][代码],[代码][代码]//只保留确定更新按钮[代码] [代码] [代码][代码]success: [代码][代码]function[代码] [代码](res) {[代码] [代码] [代码][代码]if[代码] [代码](res.confirm) {[代码] [代码] [代码][代码]//下载新版本,并重新应用[代码] [代码] [代码][代码]self.downLoadAndUpdate(updateManager)[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码] [代码] [代码][代码]// 如果希望用户在最新版本的客户端上体验您的小程序,可以这样子提示[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'提示'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。'[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]},[代码] [代码] [代码][代码]/**[代码] [代码] [代码][代码]* 下载小程序新版本并重启应用[代码] [代码] [代码][代码]*/[代码] [代码] [代码][代码]downLoadAndUpdate: [代码][代码]function[代码] [代码](updateManager) {[代码] [代码] [代码][代码]var[代码] [代码]self = [代码][代码]this[代码] [代码] [代码][代码]wx.showLoading();[代码] [代码] [代码][代码]//静默下载更新小程序新版本[代码] [代码] [代码][代码]updateManager.onUpdateReady([代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]wx.hideLoading()[代码] [代码] [代码][代码]//新的版本已经下载好,调用 applyUpdate 应用新版本并重启[代码] [代码] [代码][代码]updateManager.applyUpdate()[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]updateManager.onUpdateFailed([代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]// 新的版本下载失败[代码] [代码] [代码][代码]wx.showModal({[代码] [代码] [代码][代码]title: [代码][代码]'已经有新版本了哟'[代码][代码],[代码] [代码] [代码][代码]content: [代码][代码]'新版本已经上线啦,请您删除当前小程序,重新搜索打开哟'[代码][代码],[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]})[代码] [代码] [代码][代码]},[代码]
2019-06-07 - 微信小程序开发经验+, 可能需要了解的一些骚操作
扩展Page / App? 自定义navigationBar? 冗余授权代码? local/session/expire缓存管理混乱? … 总结了一些开发微信小程序过程中遇到问题的解决方式/经验分享,另外共享几个通用组件. star+✨ https://github.com/JoweiBlog/wechat-miniprogram-dev
2019-05-22 - 初试小程序接入three.js
看着小程序下的canvas日渐完善,特别是2.7.0库下新增了WebGL,终于可以摆脱原来用wx.createCanvasContext创建的2d上下文(不知为何在使用魔改后three.js中的CanvasRenderer渲染画面就是很慢,捕获JavaScript Profiler看着就是慢在draw方法上)。 不过理想很丰满,现实很骨感,想要在小程序上用three.js依然要来个大改造。让我们开始吧 官方文档里提供了一段如何获取WebGL Context的代码: [代码]Page({[代码][代码] [代码][代码]onReady() {[代码][代码] [代码][代码]const query = wx.createSelectorQuery()[代码][代码] [代码][代码]query.select([代码][代码]'#myCanvas'[代码][代码]).node().exec((res) => {[代码][代码] [代码][代码]const canvas = res[0].node[代码][代码] [代码][代码]const gl = canvas.getContext([代码][代码]'webgl'[代码][代码])[代码][代码] [代码][代码]console.log(gl)[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码]})[代码]我们就从这里入手 首先先写个wxml: [代码]<[代码][代码]canvas[代码] [代码]type[代码][代码]=[代码][代码]"webgl"[代码] [代码]id[代码][代码]=[代码][代码]"webgl"[代码] [代码]width[代码][代码]=[代码][代码]"{{canvasWidth||(320*2)}}"[代码] [代码]height[代码][代码]=[代码][代码]"{{canvasHeight||(504*2)}}"[代码] [代码]style[代码][代码]=[代码][代码]'width:{{canvasStyleWidth||"320px"}};height:{{canvasStyleHeight||"504px"}};'[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]'onTouchStart'[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]'onTouchMove'[代码] [代码]bindtouchend[代码][代码]=[代码][代码]'onTouchEnd'[代码][代码]></[代码][代码]canvas[代码][代码]>[代码]其中width和height是设置画布大小的,style中的width和height是设置画布的实际渲染大小的 然后js: [代码]onReady:[代码][代码]function[代码][代码](){[代码][代码] [代码][代码]var[代码] [代码]self = [代码][代码]this[代码][代码];[代码][代码] [代码][代码]var[代码] [代码]query = wx.createSelectorQuery().select([代码][代码]'#webgl'[代码][代码]).node().exec((res) => {[代码][代码] [代码][代码]var[代码] [代码]canvas = res[0].node;[代码][代码] [代码][代码]requestAnimationFrame = canvas.requestAnimationFrame;[代码][代码] [代码][代码]canvas.width = canvas._width;[代码][代码] [代码][代码]canvas.height = canvas._height;[代码][代码] [代码][代码]canvas.style = {};[代码][代码] [代码][代码]canvas.style.width = canvas.width;[代码][代码] [代码][代码]canvas.style.height = canvas.height;[代码][代码] [代码][代码]self.init(canvas);[代码][代码] [代码][代码]self.animate();[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码]先模拟dom构造一个canvas对象,然后传入init方法中,我们在这里创建场景、相机、渲染器等 [代码]init: [代码][代码]function[代码] [代码](canvas) {[代码][代码]...[代码][代码] [代码][代码]camera = [代码][代码]new[代码] [代码]THREE.PerspectiveCamera(20, canvas.width / canvas.height, 1, 10000);[代码][代码] [代码][代码]scene = [代码][代码]new[代码] [代码]THREE.Scene();[代码][代码]...[代码][代码] [代码][代码]renderer = [代码][代码]new[代码] [代码]THREE.WebGLRenderer({ canvas: canvas, antialias: [代码][代码]true[代码] [代码]});[代码][代码] [代码][代码]}[代码]这样一个最基础的三维场景就搭好了,然后继续执行animate方法,开始渲染场景 [代码]animate:[代码][代码]function[代码][代码]() {[代码][代码] [代码][代码]requestAnimationFrame([代码][代码]this[代码][代码].animate);[代码][代码] [代码][代码]this[代码][代码].render();[代码][代码] [代码][代码]}[代码]接下来尝试跑一下three.js提供的例子 webgl_geometry_colors : [图片] 锯齿问题比较严重,暂时没找到解决办法,但总体来说还是可以的,至少场景渲染出来了 由于暂时没想到如何改造CanvasTexture,我把例子中的 [代码]var[代码] [代码]canvas = document.createElement( [代码][代码]'canvas'[代码] [代码]);[代码][代码]canvas.width = 128;[代码][代码]canvas.height = 128;[代码][代码]var[代码] [代码]context = canvas.getContext( [代码][代码]'2d'[代码] [代码]);[代码][代码]var[代码] [代码]gradient = context.createRadialGradient( canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width / 2 );[代码][代码]gradient.addColorStop( 0.1, [代码][代码]'rgba(210,210,210,1)'[代码] [代码]);[代码][代码]gradient.addColorStop( 1, [代码][代码]'rgba(255,255,255,1)'[代码] [代码]);[代码][代码]context.fillStyle = gradient;[代码][代码]context.fillRect( 0, 0, canvas.width, canvas.height );[代码][代码]var[代码] [代码]shadowTexture = [代码][代码]new[代码] [代码]THREE.CanvasTexture( canvas );[代码]替换成 webgl_geometries 例子中的TextureLoader [代码]var[代码] [代码]shadowTexture = [代码][代码]new[代码] [代码]THREE.TextureLoader().load(canvas,[代码][代码]'../../textures/UV_Grid_Sm.jpg'[代码][代码]);[代码]可能有人会发现load方法中传入的参数多了一个canvas,因为小程序提供的api没法直接创建Image对象,仅有一个Canvas.createImage()方法可以创建Image对象。因此我们还需要改造一下TextureLoader中的load方法,先看一下原版中的load方法: [代码]Object.assign( TextureLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]var[代码] [代码]texture = [代码][代码]new[代码] [代码]Texture();[代码] [代码] [代码][代码]var[代码] [代码]loader = [代码][代码]new[代码] [代码]ImageLoader( [代码][代码]this[代码][代码].manager );[代码][代码] [代码][代码]loader.setCrossOrigin( [代码][代码]this[代码][代码].crossOrigin );[代码][代码] [代码][代码]loader.setPath( [代码][代码]this[代码][代码].path );[代码] [代码] [代码][代码]loader.load( url, [代码][代码]function[代码] [代码]( image ) {[代码]其中实际调用了ImageLoader来加载图片,在看看ImageLoader: [代码]Object.assign( ImageLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]if[代码] [代码]( url === undefined ) url = [代码][代码]''[代码][代码];[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].path !== undefined ) url = [代码][代码]this[代码][代码].path + url;[代码] [代码] [代码][代码]url = [代码][代码]this[代码][代码].manager.resolveURL( url );[代码] [代码] [代码][代码]var[代码] [代码]scope = [代码][代码]this[代码][代码];[代码] [代码] [代码][代码]var[代码] [代码]cached = Cache.get( url );[代码] [代码] [代码][代码]if[代码] [代码]( cached !== undefined ) {[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]setTimeout( [代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( cached );[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}, 0 );[代码] [代码] [代码][代码]return[代码] [代码]cached;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]var[代码] [代码]image = document.createElementNS( [代码][代码]'http://www.w3.org/1999/xhtml'[代码][代码], [代码][代码]'img'[代码] [代码]);[代码] [代码] [代码][代码]function[代码] [代码]onImageLoad() {[代码] [代码] [代码][代码]image.removeEventListener( [代码][代码]'load'[代码][代码], onImageLoad, [代码][代码]false[代码] [代码]);[代码][代码] [代码][代码]image.removeEventListener( [代码][代码]'error'[代码][代码], onImageError, [代码][代码]false[代码] [代码]);[代码] [代码] [代码][代码]Cache.add( url, [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]function[代码] [代码]onImageError( event ) {[代码] [代码] [代码][代码]image.removeEventListener( [代码][代码]'load'[代码][代码], onImageLoad, [代码][代码]false[代码] [代码]);[代码][代码] [代码][代码]image.removeEventListener( [代码][代码]'error'[代码][代码], onImageError, [代码][代码]false[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( onError ) onError( event );[代码] [代码] [代码][代码]scope.manager.itemError( url );[代码][代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]image.addEventListener( [代码][代码]'load'[代码][代码], onImageLoad, [代码][代码]false[代码] [代码]);[代码][代码] [代码][代码]image.addEventListener( [代码][代码]'error'[代码][代码], onImageError, [代码][代码]false[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( url.substr( 0, 5 ) !== [代码][代码]'data:'[代码] [代码]) {[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].crossOrigin !== undefined ) image.crossOrigin = [代码][代码]this[代码][代码].crossOrigin;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]image.src = url;[代码] [代码] [代码][代码]return[代码] [代码]image;[代码] [代码] [代码][代码]},[代码]document.createElementNS这种东西肯定是没法存在的,没办法,把canvas传进来用createImage方法创建Image对象,改造后: [代码]Object.assign( ImageLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( canvas,url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]if[代码] [代码]( url === undefined ) url = [代码][代码]''[代码][代码];[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].path !== undefined ) url = [代码][代码]this[代码][代码].path + url;[代码] [代码] [代码][代码]url = [代码][代码]this[代码][代码].manager.resolveURL( url );[代码] [代码] [代码][代码]var[代码] [代码]scope = [代码][代码]this[代码][代码];[代码] [代码] [代码][代码]var[代码] [代码]cached = Cache.get( url );[代码] [代码] [代码][代码]if[代码] [代码]( cached !== undefined ) {[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]setTimeout( [代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( cached );[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}, 0 );[代码] [代码] [代码][代码]return[代码] [代码]cached;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]//var image = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'img' );[代码][代码] [代码][代码]console.log([代码][代码]this[代码][代码], canvas);[代码][代码] [代码][代码]var[代码] [代码]image = canvas.createImage();[代码] [代码] [代码][代码]function[代码] [代码]onImageLoad() {[代码] [代码] [代码][代码]//image.removeEventListener( 'load', onImageLoad, false );[代码][代码] [代码][代码]//image.removeEventListener( 'error', onImageError, false );[代码][代码] [代码][代码]image.onload = [代码][代码]function[代码] [代码]() { };[代码][代码] [代码][代码]image.onerror = [代码][代码]function[代码] [代码]() { };[代码] [代码] [代码][代码]Cache.add( url, [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]function[代码] [代码]onImageError( event ) {[代码] [代码] [代码][代码]//image.removeEventListener( 'load', onImageLoad, false );[代码][代码] [代码][代码]//image.removeEventListener( 'error', onImageError, false );[代码][代码] [代码][代码]image.onload = [代码][代码]function[代码] [代码]() { };[代码][代码] [代码][代码]image.onerror = [代码][代码]function[代码] [代码]() { };[代码] [代码] [代码][代码]if[代码] [代码]( onError ) onError( event );[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码][代码] [代码][代码]scope.manager.itemError( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]//image.addEventListener( 'load', onImageLoad, false );[代码][代码] [代码][代码]//image.addEventListener( 'error', onImageError, false );[代码][代码] [代码][代码]image.onload = onImageLoad;[代码][代码] [代码][代码]image.onerror = onImageError;[代码] [代码] [代码][代码]if[代码] [代码]( url.substr( 0, 5 ) !== [代码][代码]'data:'[代码] [代码]) {[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].crossOrigin !== undefined ) image.crossOrigin = [代码][代码]this[代码][代码].crossOrigin;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]image.src = url;[代码] [代码] [代码][代码]return[代码] [代码]image;[代码] [代码] [代码][代码]},[代码]然后TextureLoader的load方法也改一下传参顺序: [代码]Object.assign( TextureLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( canvas,url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]var[代码] [代码]texture = [代码][代码]new[代码] [代码]Texture();[代码] [代码] [代码][代码]var[代码] [代码]loader = [代码][代码]new[代码] [代码]ImageLoader( [代码][代码]this[代码][代码].manager );[代码][代码] [代码][代码]loader.setCrossOrigin( [代码][代码]this[代码][代码].crossOrigin );[代码][代码] [代码][代码]loader.setPath( [代码][代码]this[代码][代码].path );[代码] [代码] [代码][代码]loader.load( canvas,url, [代码][代码]function[代码] [代码]( image ) {[代码]OK! 这个例子代码我放在https://github.com/leo9960/xcx_threejs,大家可以接着研究一下。潜力还是比较大的,比如我拿它搞了个全景展示 [图片] ---------------------------------------------------------------------- 2019.5.26 新上传了全景展示的范例,基于panolens.js,欢迎围观
2019-05-26 - 借助云开发实现小程序模版消息推送(不用搭建服务器就可以实现微信消息推送)
上一节给大家将了借助云开发实现小程序支付功能,那么我们就要想了,能不能借助云开发实现小程序消息推送功能呢? 还别说,云开发还真能实现推送的功能。 一直关注我的同学肯定知道老师之前也写过借助java后台实现小程序消息推送的文章。 我们借助java后台虽然也能轻松的实现消息推送。但是呢?用java开发后台推送,必须要搭建服务器,学习java代码,部署java代码当然你就是做java开发的,或者学习过java,这没什么。 但是作为小程序开发人员来说,用java显得太重了。 传送门: 《借助小程序云开发实现小程序支付功能(含源码)》 《5行代码实现微信小程序模版消息推送 (含推送后台和小程序源码)》 下面就来教大家如何借助云开发实现小程序模版消息的推送功能。 老规矩,先看效果图 [图片] 下面来讲实现步骤 一,定义推送的云函数 由于我们的云推送功能只能在云函数里调用,所以我们这里必须要在云函数里实现推送功能。 1,首先我们定义一个云函数push0524。 如果你还不知道如何使用云开发,如何定义云函数,去翻下老师之前的文章。有写的。 [图片] 把完整的代码贴给大家 [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() // 云函数入口函数 exports.main = async(event, context) => { console.log(event) return sendTemplateMessage(event) } //小程序模版消息推送 async function sendTemplateMessage(event) { const { OPENID } = cloud.getWXContext() // 接下来将新增模板、发送模板消息、然后删除模板 // 注意:新增模板然后再删除并不是建议的做法,此处只是为了演示,模板 ID 应在添加后保存起来后续使用 const addResult = await cloud.openapi.templateMessage.addTemplate({ id: 'AT0002', keywordIdList: [3, 4, 5] }) const templateId = addResult.templateId //新增的模版id const sendResult = await cloud.openapi.templateMessage.send({ touser: OPENID, templateId, formId: event.formId, page: 'pages/index/index', data: { keyword1: { value: '云开发实现推送', }, keyword2: { value: '2019 年 5 月 24 日', }, keyword3: { value: '编程小石头', }, } }) //删除模版id await cloud.openapi.templateMessage.deleteTemplate({ templateId, }) return sendResult } [代码] 上面代码所实现的就是 1,创建模版,拿到模版id 2,使用模版ID,填充模版消息,发送模版 3,删除模版。 我们正常开发时,模版都是在小程序后台获取到的。这里是为例演示方便。所以正常开发时,只需要实现第二步就行了。 推送的关键代码就是这个方法: cloud.openapi.templateMessage.send 通常我们定义完push0524云函数以后,如果直接调用的话,会报错误的。 [图片] 来看下这个错误,看到红色框里的permission就知道,肯定是权限的问题。所以我们在定义完云函数以后,要在push0524云函数下面添加权限配置页面。如下图 [图片] 重要的就是这个: “templateMessage.send”, 推送权限。因为推送是云开发给我们提供的,我们这里调用时,必须配置相关权限,才能使用的。 到这里我们的推送功能就实现了。下面我们来验证下。 二,验证云开发推送 验证其实很简单,和我们之前的《5行代码实现微信小程序模版消息推送 (含推送后台和小程序源码)》 类似。只不过一个是在java后台推送,一个是在小城里推送。下面我们简单写个小程序里验证推送的demo。 功能很简单 1,获取formid,因为推送必须有formid的 2,点击调用push0524实现推送 [图片] 简单的贴下代码 [图片] [图片] 需要注意的一点:我们测试时,必须要真机测试。因为模拟器没法获取到formid的。 [图片] 我们在推送成功的success回调中打印下log。如果log中出现,send:ok字样,就代表我们推送成功了。来看下推送成功的效果。 微信聊天列表接收到了消息提醒 [图片] 消息内容 [图片] 到这里我们就用云开发实现完整的消息推送功能了。是不是很简单。 有任何关于编程的问题都可以加老师微信 2501902696(备注小程序)也可以找老师索要完整源码。 编程小石头码农一枚,非著名全栈开发人员。分享自己的一些经验,学习心得,希望后来人少走弯路,少填坑 视频讲解地址:https://edu.csdn.net/course/detail/24770
2019-06-11 - 【U计划】弹幕biubiu小程序开发经验分享
弹幕biubiu小程序开发经验分享 Hello,大家好~我们是来自清华大学软件学院大三的弹幕弹幕团队,我是团队的Leader&Developer。我们团队开发的小程序叫作“弹幕biubiu”,主要应用场景是各类晚会现场。你可能已经发现了,我们的团队名和小程序名是不一样的,这是因为我们在确定了团队名称之后,发现这个名字已经被其他的小程序占用了,所以我们只能将小程序换成另一个名字。这也提醒了大家,在开发小程序的时候,一定要先确认自己起的名字没有被使用哦~ 我们的小程序是从去年10月开始开发的,直至今年3月基本完成。之后在今年4月的清华大学软件学院学生节上,我们的小程序作为观众弹幕互动平台被使用,并取得了广泛好评。这一方面说明我们小程序的实用性,另一方面也说明了弹幕互动及其衍生方向依然有很大的发展空间。 [图片] 我们团队的开发选用了敏捷开发的方式,并采用Scrum框架(下图源自清华大学软件学院刘强老师的软件工程课件)。本文将介绍我们团队在开发过程中所做的一些主要工作,希望能够给大家一些启发与帮助。[图片] 1.立项 万事开头难,开发过程中最困难的地方,往往就是在最开始的地方。一个团队中不缺技术人员,而缺少设计人员,也就是“有思想的人”。而且好的想法一定是来源于生活的,如果不仔细观察生活,只是天马行空地构想,是无法获得好的项目主题的。 我们团队在计划开发一个小程序之后,就开始讨论主题。首先我们确定了我们小程序的大致方向。我们发现在每次举办院系学生节时,都需要科协同学用一天的时间去部署弹幕墙,这样效率较低,而且也常常出现弹幕墙宕机的情况,所以我们决定开发一个学生节小程序。之后我们团队通过头脑风暴,将自己设想为学生节举办方与学生节观众,讨论我们可能需要哪些功能,不需要哪些功能等等,从而将项目目标进一步细化。 在这里我们并不能只单纯地讨论,我们需要一个记录者,将所有人的想法记录下来并进行归整,之后再由每个成员进行修改完善,得到我们开发的第一份文档——产品规划文档(弹幕biubiu的产品规划文档)。如果大家对产品规划文档形式不太清楚的话,可以参照上述我们的文档。在文档中我们对于产品的定位、产品的特性以及产品的路线都有了一个明确的描述。 当我们有了产品规划文档之后,立项的过程还没有结束。因为这个文档只是根据自己团队的想法写成的,但是真正的用户会有成千甚至上万人,并不一定每个人都和团队内的成员想法相同。除此之外,市场上可能已经有一些类似的产品。所以我们必须要进行一个调查,明确其他可能的用户的需求以及现有市场上的产品提供的功能。这样的调查有助于我们跳出团队内固有的思维模式,催生出一些新的点子,同时也能避免无用功。在调查结束之后,我们获得了更多的用户需求。我们需要对需求进一步整理细化,写出用户故事(弹幕biubiu的需求获取与用户故事文档),并画出用户故事地图。 [图片] 到此为止,我们基本完成了所有立项工作。这时整个团队应当对自己的项目开发目标有了明确清晰的认知。 2.设计 当明确需求之后,我们就要开始设计工作,也就是进入到敏捷开发的迭代周期中了。设计主要分为两部分: 系统设计 原型设计 系统设计中最重要的工具是UML,即统一建模语言。你可能会用到其中的类图、活动图、用例图等。 2.1 系统设计 系统设计目的是确立技术开发过程中的总纲,这也是开发过程中极为重要的一步,整个开发过程都是围绕系统设计文档展开的。 我们团队开发的小程序使用的是MVC模式,即模型(model)-视图(view)-控制器(controller)。这种模式满足了高内聚、低耦合的程序结构,便于团队开发与管理。 系统设计要求我们首先对于整体系统结构有一个清晰的构想。我们的弹幕小程序的系统架构图如下所示,可以看到这里我们将系统清晰地分为了数层。 [图片] 之后我们就需要对系统的结构进行细化,主要包括两部分内容: 数据存储结构。也就是数据库的设计。我们用一个数据库来管理数据,那么我们需要构造哪些表,每个表中需要保存那些数据等等,都是我们需要考虑的问题。我们团队采用了Mysql+Redis两种数据库相结合的存储模式,保证了数据的读写效率。 前后端接口设计。接口设计可以方便团队前后端开发的分离,提高开发效率。我们团队采用的是Restful的API接口规范,大家可以自行查阅了解该规范的内容。 因为不同的小程序会有不同的系统,所以我这里的设计思路也仅供大家参考,主要的还是要开发团队自己思索并设计,得到最适合自己的系统结构。当然合适的系统架构不意味着在项目的最初就要将所有细节想得十分透彻。一个好的系统架构主要有两方面的特征:稳定的框架与可扩展性。稳定的框架保证了开发过程中无需对代码进行很大程度的调整重构;可扩展性保证了开发人员可以很轻松地将后续的内容添加进系统,而不会影响系统整体的特性。 2.2 原型设计 原型设计,也就是UI设计。我们团队使用的工具是墨刀。墨刀有着丰富的素材库,并且可以设计控件行为,方便团队成员理解交互过程。 [图片] 原型设计往往会是团队中讨论最激烈的环节,因为每个人的审美是不同的,更何况团队中基本都是理工生(sigh…)。我们在原型设计时也进行了多次讨论与修改,才最终确定其样式。 3.开发 3.1 团队管理 自组织团队是敏捷开发的基础,团队被授权自己管理工作进程,并决定如何完成工作。团队成员在开发的过程中需要各司其职,扮演好自己的角色。但是根据著名的“20%定律”,每个团队中总会有20%的成员是free rider,所以这就需要团队的领导者对团队进行良好管理。 在开发的初期,一个团队需要制定自己团队的开发章程,包括每周的开会时间、开会地点、每个成员负责开发的模块、团建安排等,并在之后的开发过程中严格按照章程的规定管理团队。 其次,团队在每次例会时,需要每名成员汇报之前任务的开发进度,对开发过程中遇到的问题进行讨论思考,并确定下一阶段的开发任务。每次的例会都需要指定一名成员记录会议内容,方便团队日后查看。 团队管理中,最主要的就是任务安排的部分。任务安排主要包括两点:明确分工与时间规划。 明确分工是要让大家清楚自己具体是要做什么,其重点在于“细化”。举个例子,如果你和我说,“你去做一个主办方管理网站”,我肯定一头雾水无从下手;但是如果你和我说:“你去实现一个主办方登录的功能,主办方输入用户名和密码就可以跳转到活动列表页面”,那我就可以很快地完成这一个任务。所以明确分工是团队成员,尤其是组织者,需要重点注意的。 时间规划则是让大家有一个紧迫感。做时间规划最好的方法是,组织者先定一个大概的时间,然后所有团队成员进行协商,定出每个人都满意或至少不反对的时间安排。因为团队成员都会有惰性,就算最好的团队也不例外,所以一个明确的时间规划可以让每个人有计划有安排地完成任务。从这个意义上来讲,时间规划也是调动成员热情的不错的方案。 3.2 代码管理与持续集成 我们团队使用git进行代码版本的控制管理,在开发过程中维护了三个代码库,分别对应于系统后端、微信小程序以及弹幕主墙应用程序。我们也利用Github上的Issues、Projects、Wiki等功能辅助我们进行开发。由于我们团队尚未开源,所以这里也不方便向大家展示代码库的具体细节。 此外,我们团队使用Travis CI辅助我们进行代码的持续集成与自动部署,感兴趣的话大家可以自主学习一下它的使用方法。 3.3 文档管理 通过上文我们也发现了,在开发过程中我们会写很多的文档,所以合理的文档管理也是开发中的重点问题。我们团队使用的在线文档工具是石墨文档。石墨文档有三个优点: 有清晰简洁的界面与丰富的功能 支持多成员在线编辑 可以很方便地导出为word文档与pdf文档 我们团队还维护了一个产品文档目录,这样每次要修改或查阅文档时,都有一个很便捷的入口。 [图片] 4.测试 测试主要有三部分:单元测试、功能测试与性能测试。 单元测试。单元测试,就是对软件中最小可测试单元进行检查和验证。不同软件的单元测试是不同的,比如我们在开发后端时,使用的是Python中的Django框架,这一框架是自带单元测试模块的,所以我们只需在test.py中实现所有测试样例即可。单元测试保证了软件最基本的正确性,最佳的模式是开发者在开发的过程中就将单元测试样例写好。(现在微信小程序还没有单元测试的模块,希望之后小程序团队可以在这方面给出接口。) 功能测试。功能测试就是要测试软件系统的各个功能能否正常执行。功能测试的辅助软件有很多,但最简单也最方便的就是人工手动测试,也就是开发者模拟用户的使用场景测试一遍自己的软件系统。 性能测试。软件性能也是评判一个软件好坏的重要依据。就以我们的弹幕小程序为例,虽然现在的学生节晚会只有三百多人,但是如果要拓展到所有晚会时,就不得不考虑高并发的情况。所以我们团队使用Jmeter对于发送弹幕的功能进行了性能测试,并在测试之后通过图片压缩等方式提高了我们小程序的性能。 除此之外,测试环节还包括安全性测试、易用性测试、兼容性测试等。测试过程中大家需要尤其注意的地方是:一定要将测试场景与测试样例想全面。越是严密的测试,软件系统也就相应越理想。 5.分析与维护 在开发与测试结束之后,小程序也就被正式投入使用了。因为用户行为是多种多样的,所以这个时候不出意外会出现一些奇奇怪怪的bug。作为开发者一定要给出一个用户反馈的途径,并且根据用户反馈的问题,制定下一个迭代周期的任务。这样循环往复,直至软件达到预期。下图为我们小程序为用户提供的反馈接口: [图片] 6.总结 以上就是我在开发过程中的一些经验与体会,希望能够给大家一些帮助与启示。弹幕biubiu小程序的开发,对于我来说是一个特别宝贵的经历。在这个过程中我学到了很多新的知识,接触到了很多新的事物,也发现了其他同学很多的优点。同时也很感谢刘强与刘璘两位老师对我们团队的支持与指导,在这里也推荐一下两位老师在学堂在线上的软件工程课程,如果大家感兴趣的话可以去了解学习一下,相信会给你们很大的帮助。 附言 如果大家对我们的小程序感兴趣的话,也可以使用一下呀~ 使用说明 主办方管理网站 应用程序下载链接(也可在主办方管理网站中下载) 小程序二维码 [图片] 大家有什么问题或者建议的话,也欢迎随时与我交流~ 我的Github地址是:https://github.com/JL-Cheng 我的邮箱是:chengjl16@163.com
2019-05-23 - 开启websocket服务端口,调试微信websocket接口方法
使用node环境,在vscode 工具中,创建 app.js 文件 ,代码如下,记得安装 npm install websocket 和 npm install http 模块 . 开启 websocket接口服务后,就可以去封装 官方提供的 wx.sendSocketMessage 等接口了。 [代码]const http = require("http"); const WebSocketServer = require("websocket").server; const httpServer = http.createServer((request, response) => { console.log("[" + new Date() + "] Received request for " + request.url); response.writeHead(404); response.end(); }); const wsServer = new WebSocketServer({ httpServer, autoAcceptConnections: true }); wsServer.on("connect", connection => { connection .on("message", message => { if (message.type === "utf8") { console.log(">> message content from client: " + message.utf8Data); connection.sendUTF(message.utf8Data); // 输出内容返回给前端接口调用 } }) .on("close", (reasonCode, description) => { console.log( "[" + new Date() + "] Peer " + connection.remoteAddress + " disconnected." ); }); }); httpServer.listen(8080, () => { console.log("[" + new Date() + "] Serveris listening on port 8080"); }); #小程序页面示例代码,请参考文档 const socketOpen = false const socketMsgQueue = [] wx.connectSocket({ // url: 'test.php', url :“ws://localhost:8080/” }) wx.onSocketOpen(function (res) { socketOpen = true for (let i = 0; i < socketMsgQueue.length; i++) { sendSocketMessage(socketMsgQueue[i]) } socketMsgQueue = [] }) function sendSocketMessage(msg) { if (socketOpen) { wx.sendSocketMessage({ data: msg }) } else { socketMsgQueue.push(msg) } }[代码]
2019-04-18 - 通过授权登录介绍小程序原生开发如何引入async/await、状态管理等工具
登陆和授权是小程序开发会遇到的第一个问题,这里把相关业务逻辑、工具代码抽取出来,展示我们如何引入的一些包使得原生微信小程序内也可以使用 async/await、fetch、localStorage、状态管理、GraphQL 等等特性,希望对大家有所帮助。 前端 目录结构 [代码]├── app.js ├── app.json ├── app.wxss ├── common │ └── api │ └── index.js ├── config.js ├── pages │ └── index │ ├── api │ │ └── index.js │ ├── img │ │ ├── btn.png │ │ └── bg.jpg │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss ├── project.config.json ├── store │ ├── action.js │ └── index.js ├── utils │ └── index.js └── vendor ├── event-emitter.js ├── fetch.js ├── fetchql.js ├── http.js ├── promisify.js ├── regenerator.js ├── storage.js └── store.js [代码] 业务代码 app.js [代码]import store from './store/index' const { loginInfo } = store.state App({ store, onLaunch() { // 打开小程序即登陆,无需用户授权可获得 openID if(!loginInfo) store.dispatch('login') }, }) [代码] store/index.js [代码]import Store from '../vendor/store' import localStorage from '../vendor/storage' import actions from './action' const loginInfo = localStorage.getItem('loginInfo') export default new Store({ state: { // 在全局状态中维护登陆信息 loginInfo, }, actions, }) [代码] store/action.js [代码]import regeneratorRuntime from '../vendor/regenerator'; import wx from '../vendor/promisify'; import localStorage from '../vendor/storage' import api from '../common/api/index'; export default { async login({ state }, payload) { const { code } = await wx.loginAsync(); const { authSetting } = await wx.getSettingAsync() // 如果用户曾授权,直接可以拿到 encryptedData const { encryptedData, iv } = authSetting['scope.userInfo'] ? await wx.getUserInfoAsync({ withCredentials: true }) : {}; // 如果用户未曾授权,也可以拿到 openID const { token, userInfo } = await api.login({ code, encryptedData, iv }); // 为接口统一配置 Token getApp().gql.requestObject.headers['Authorization'] = `Bearer ${token}`; // 本地缓存登陆信息 localStorage.setItem('loginInfo', { token, userInfo } ) return { loginInfo: { token, userInfo } } } } [代码] common/api/index.js [代码]import regeneratorRuntime from '../../vendor/regenerator.js' export default { /** * 登录接口 * 如果只有 code,只返回 token,如果有 encryptedData, iv,同时返回用户的昵称和头像 * @param {*} param0 */ async login({ code, encryptedData, iv }) { const query = `query login($code: String!, $encryptedData: String, $iv: String){ login(code:$code, encryptedData:$encryptedData, iv:$iv, appid:$appid){ token userInfo { nickName avatarUrl } } }` const { login: { token, userInfo } } = await getApp().query({ query, variables: { code, encryptedData, iv } }) return { token, userInfo } }, } [代码] pages/index/index.js [代码]import regeneratorRuntime from '../../vendor/regenerator.js' const app = getApp() Page({ data: {}, onLoad(options) { // 将用户登录信息注入到当前页面的 data 中,并且当数据在全局范围内被更新时,都会自动刷新本页面 app.store.mapState(['loginInfo'], this) }, async login({ detail: { errMsg } }) { if (errMsg === 'getUserInfo:fail auth deny') return app.store.dispatch('login') // 继续处理业务 }, }) [代码] pages/index/index.wxml [代码]<view class="container"> <form report-submit="true" bindsubmit="saveFormId"> <button form-type="submit" open-type="getUserInfo" bindgetuserinfo="login">登录</button> </form> </view> [代码] 工具代码 事件处理 vendor/event-emitter.js [代码]const id_Identifier = '__id__'; function randomId() { return Math.random().toString(36).substr(2, 16); } function findIndexById(id) { return this.findIndex(item => item[id_Identifier] === id); } export default class EventEmitter { constructor() { this.events = {} } /** * listen on a event * @param event * @param listener */ on(event, listener) { let { events } = this; let container = events[event] || []; let id = randomId(); let index; listener[id_Identifier] = id; container.push(listener); return () => { index = findIndexById.call(container, id); index >= 0 && container.splice(index, 1); } }; /** * remove all listen of an event * @param event */ off (event) { this.events[event] = []; }; /** * clear all event listen */ clear () { this.events = {}; }; /** * listen on a event once, if it been trigger, it will cancel the listner * @param event * @param listener */ once (event, listener) { let { events } = this; let container = events[event] || []; let id = randomId(); let index; let callback = () => { index = findIndexById.call(container, id); index >= 0 && container.splice(index, 1); listener.apply(this, arguments); }; callback[id_Identifier] = id; container.push(callback); }; /** * emit event */ emit () { const { events } = this; const argv = [].slice.call(arguments); const event = argv.shift(); ((events['*'] || []).concat(events[event] || [])).map(listener => self.emitting(event, argv, listener)); }; /** * define emitting * @param event * @param dataArray * @param listener */ emitting (event, dataArray, listener) { listener.apply(this, dataArray); }; } [代码] 封装 wx.request() 接口 vendor/http.js [代码]import EventEmitter from './event-emitter.js'; const DEFAULT_CONFIG = { maxConcurrent: 10, timeout: 0, header: {}, dataType: 'json' }; class Http extends EventEmitter { constructor(config = DEFAULT_CONFIG) { super(); this.config = config; this.ctx = wx; this.queue = []; this.runningTask = 0; this.maxConcurrent = DEFAULT_CONFIG.maxConcurrent; this.maxConcurrent = config.maxConcurrent; this.requestInterceptor = () => true; this.responseInterceptor = () => true; } create(config = DEFAULT_CONFIG) { return new Http(config); } next() { const queue = this.queue; if (!queue.length || this.runningTask >= this.maxConcurrent) return; const entity = queue.shift(); const config = entity.config; const { requestInterceptor, responseInterceptor } = this; if (requestInterceptor.call(this, config) !== true) { let response = { data: null, errMsg: `Request Interceptor: Request can\'t pass the Interceptor`, statusCode: 0, header: {} }; entity.reject(response); return; } this.emit('request', config); this.runningTask = this.runningTask + 1; let timer = null; let aborted = false; let finished = false; const callBack = { success: (res) => { if (aborted) return; finished = true; timer && clearTimeout(timer); entity.response = res; this.emit('success', config, res); responseInterceptor.call(this, config, res) !== true ? entity.reject(res) : entity.resolve(res); }, fail: (res) => { if (aborted) return; finished = true; timer && clearTimeout(timer); entity.response = res; this.emit('fail', config, res); responseInterceptor.call(this, config, res) !== true ? entity.reject(res) : entity.resolve(res); }, complete: () => { if (aborted) return; this.emit('complete', config, entity.response); this.next(); this.runningTask = this.runningTask - 1; } }; const requestConfig = Object.assign(config, callBack); const task = this.ctx.request(requestConfig); if (this.config.timeout > 0) { timer = setTimeout(() => { if (!finished) { aborted = true; task && task.abort(); this.next(); } }, this.config.timeout); } } request(method, url, data, header, dataType = 'json') { const config = { method, url, data, header: { ...header, ...this.config.header }, dataType: dataType || this.config.dataType }; return new Promise((resolve, reject) => { const entity = { config, resolve, reject, response: null }; this.queue.push(entity); this.next(); }); } head(url, data, header, dataType) { return this.request('HEAD', url, data, header, dataType); } options(url, data, header, dataType) { return this.request('OPTIONS', url, data, header, dataType); } get(url, data, header, dataType) { return this.request('GET', url, data, header, dataType); } post(url, data, header, dataType) { return this.request('POST', url, data, header, dataType); } put(url, data, header, dataType) { return this.request('PUT', url, data, header, dataType); } ['delete'](url, data, header, dataType) { return this.request('DELETE', url, data, header, dataType); } trace(url, data, header, dataType) { return this.request('TRACE', url, data, header, dataType); } connect(url, data, header, dataType) { return this.request('CONNECT', url, data, header, dataType); } setRequestInterceptor(interceptor) { this.requestInterceptor = interceptor; return this; } setResponseInterceptor(interceptor) { this.responseInterceptor = interceptor; return this; } clean() { this.queue = []; } } export default new Http(); [代码] 兼容 fetch 标准 vendor/fetch.js [代码]import http from './http'; const httpClient = http.create({ maxConcurrent: 10, timeout: 0, header: {}, dataType: 'json' }); function generateResponse(res) { let header = res.header || {}; let config = res.config || {}; return { ok: ((res.statusCode / 200) | 0) === 1, // 200-299 status: res.statusCode, statusText: res.errMsg, url: config.url, clone: () => generateResponse(res), text: () => Promise.resolve( typeof res.data === 'string' ? res.data : JSON.stringify(res.data) ), json: () => { if (typeof res.data === 'object') return Promise.resolve(res.data); let json = {}; try { json = JSON.parse(res.data); } catch (err) { console.error(err); } return json; }, blob: () => Promise.resolve(new Blob([res.data])), headers: { keys: () => Object.keys(header), entries: () => { let all = []; for (let key in header) { if (header.hasOwnProperty(key)) { all.push([key, header[key]]); } } return all; }, get: n => header[n.toLowerCase()], has: n => n.toLowerCase() in header } }; } export default (typeof fetch === 'function' ? fetch.bind() : function(url, options) { options = options || {}; return httpClient .request(options.method || 'get', url, options.body, options.headers) .then(res => Promise.resolve(generateResponse(res))) .catch(res => Promise.reject(generateResponse(res))); }); [代码] GraphQL客户端 vendor/fetchql.js [代码]import fetch from './fetch'; // https://github.com/gucheen/fetchql /** Class to realize fetch interceptors */ class FetchInterceptor { constructor() { this.interceptors = []; /* global fetch */ this.fetch = (...args) => this.interceptorWrapper(fetch, ...args); } /** * add new interceptors * @param {(Object|Object[])} interceptors */ addInterceptors(interceptors) { const removeIndex = []; if (Array.isArray(interceptors)) { interceptors.map((interceptor) => { removeIndex.push(this.interceptors.length); return this.interceptors.push(interceptor); }); } else if (interceptors instanceof Object) { removeIndex.push(this.interceptors.length); this.interceptors.push(interceptors); } this.updateInterceptors(); return () => this.removeInterceptors(removeIndex); } /** * remove interceptors by indexes * @param {number[]} indexes */ removeInterceptors(indexes) { if (Array.isArray(indexes)) { indexes.map(index => this.interceptors.splice(index, 1)); this.updateInterceptors(); } } /** * @private */ updateInterceptors() { this.reversedInterceptors = this.interceptors .reduce((array, interceptor) => [interceptor].concat(array), []); } /** * remove all interceptors */ clearInterceptors() { this.interceptors = []; this.updateInterceptors(); } /** * @private */ interceptorWrapper(fetch, ...args) { let promise = Promise.resolve(args); this.reversedInterceptors.forEach(({ request, requestError }) => { if (request || requestError) { promise = promise.then(() => request(...args), requestError); } }); promise = promise.then(() => fetch(...args)); this.reversedInterceptors.forEach(({ response, responseError }) => { if (response || responseError) { promise = promise.then(response, responseError); } }); return promise; } } /** * GraphQL client with fetch api. * @extends FetchInterceptor */ class FetchQL extends FetchInterceptor { /** * Create a FetchQL instance. * @param {Object} options * @param {String} options.url - the server address of GraphQL * @param {(Object|Object[])=} options.interceptors * @param {{}=} options.headers - request headers * @param {FetchQL~requestQueueChanged=} options.onStart - callback function of a new request queue * @param {FetchQL~requestQueueChanged=} options.onEnd - callback function of request queue finished * @param {Boolean=} options.omitEmptyVariables - remove null props(null or '') from the variables * @param {Object=} options.requestOptions - addition options to fetch request(refer to fetch api) */ constructor({ url, interceptors, headers, onStart, onEnd, omitEmptyVariables = false, requestOptions = {}, }) { super(); this.requestObject = Object.assign( {}, { method: 'POST', headers: Object.assign({}, { Accept: 'application/json', 'Content-Type': 'application/json', }, headers), credentials: 'same-origin', }, requestOptions, ); this.url = url; this.omitEmptyVariables = omitEmptyVariables; // marker for request queue this.requestQueueLength = 0; // using for caching enums' type this.EnumMap = {}; this.callbacks = { onStart, onEnd, }; this.addInterceptors(interceptors); } /** * operate a query * @param {Object} options * @param {String} options.operationName * @param {String} options.query * @param {Object=} options.variables * @param {Object=} options.opts - addition options(will not be passed to server) * @param {Boolean=} options.opts.omitEmptyVariables - remove null props(null or '') from the variables * @param {Object=} options.requestOptions - addition options to fetch request(refer to fetch api) * @returns {Promise} * @memberOf FetchQL */ query({ operationName, query, variables, opts = {}, requestOptions = {}, }) { const options = Object.assign({}, this.requestObject, requestOptions); let vars; if (this.omitEmptyVariables || opts.omitEmptyVariables) { vars = this.doOmitEmptyVariables(variables); } else { vars = variables; } const body = { operationName, query, variables: vars, }; options.body = JSON.stringify(body); this.onStart(); return this.fetch(this.url, options) .then((res) => { if (res.ok) { return res.json(); } // return an custom error stack if request error return { errors: [{ message: res.statusText, stack: res, }], }; }) .then(({ data, errors }) => ( new Promise((resolve, reject) => { this.onEnd(); // if data in response is 'null' if (!data) { return reject(errors || [{}]); } // if all properties of data is 'null' const allDataKeyEmpty = Object.keys(data).every(key => !data[key]); if (allDataKeyEmpty) { return reject(errors); } return resolve({ data, errors }); }) )); } /** * get current server address * @returns {String} * @memberOf FetchQL */ getUrl() { return this.url; } /** * setting a new server address * @param {String} url * @memberOf FetchQL */ setUrl(url) { this.url = url; } /** * get information of enum type * @param {String[]} EnumNameList - array of enums' name * @returns {Promise} * @memberOf FetchQL */ getEnumTypes(EnumNameList) { const fullData = {}; // check cache status const unCachedEnumList = EnumNameList.filter((element) => { if (this.EnumMap[element]) { // enum has been cached fullData[element] = this.EnumMap[element]; return false; } return true; }); // immediately return the data if all enums have been cached if (!unCachedEnumList.length) { return new Promise((resolve) => { resolve({ data: fullData }); }); } // build query string for uncached enums const EnumTypeQuery = unCachedEnumList.map(type => ( `${type}: __type(name: "${type}") { ...EnumFragment }` )); const query = ` query { ${EnumTypeQuery.join('\n')} } fragment EnumFragment on __Type { kind description enumValues { name description } }`; const options = Object.assign({}, this.requestObject); options.body = JSON.stringify({ query }); this.onStart(); return this.fetch(this.url, options) .then((res) => { if (res.ok) { return res.json(); } // return an custom error stack if request error return { errors: [{ message: res.statusText, stack: res, }], }; }) .then(({ data, errors }) => ( new Promise((resolve, reject) => { this.onEnd(); // if data in response is 'null' and have any errors if (!data) { return reject(errors || [{ message: 'Do not get any data.' }]); } // if all properties of data is 'null' const allDataKeyEmpty = Object.keys(data).every(key => !data[key]); if (allDataKeyEmpty && errors && errors.length) { return reject(errors); } // merge enums' data const passData = Object.assign(fullData, data); // cache new enums' data Object.keys(data).map((key) => { this.EnumMap[key] = data[key]; return key; }); return resolve({ data: passData, errors }); }) )); } /** * calling on a request starting * if the request belong to a new queue, call the 'onStart' method */ onStart() { this.requestQueueLength++; if (this.requestQueueLength > 1 || !this.callbacks.onStart) { return; } this.callbacks.onStart(this.requestQueueLength); } /** * calling on a request ending * if current queue finished, calling the 'onEnd' method */ onEnd() { this.requestQueueLength--; if (this.requestQueueLength || !this.callbacks.onEnd) { return; } this.callbacks.onEnd(this.requestQueueLength); } /** * Callback of requests queue changes.(e.g. new queue or queue finished) * @callback FetchQL~requestQueueChanged * @param {number} queueLength - length of current request queue */ /** * remove empty props(null or '') from object * @param {Object} input * @returns {Object} * @memberOf FetchQL * @private */ doOmitEmptyVariables(input) { const nonEmptyObj = {}; Object.keys(input).map(key => { const value = input[key]; if ((typeof value === 'string' && value.length === 0) || value === null || value === undefined) { return key; } else if (value instanceof Object) { nonEmptyObj[key] = this.doOmitEmptyVariables(value); } else { nonEmptyObj[key] = value; } return key; }); return nonEmptyObj; } } export default FetchQL; [代码] 将wx的异步接口封装成Promise vendor/promisify.js [代码]function promisify(wx) { let wxx = { ...wx }; for (let attr in wxx) { if (!wxx.hasOwnProperty(attr) || typeof wxx[attr] != 'function') continue; // skip over the sync method if (/sync$/i.test(attr)) continue; wxx[attr + 'Async'] = function asyncFunction(argv = {}) { return new Promise(function (resolve, reject) { wxx[attr].call(wxx, { ...argv, ...{ success: res => resolve(res), fail: err => reject(err) } }); }); }; } return wxx; } export default promisify(typeof wx === 'object' ? wx : {}); [代码] localstorage vendor/storage.js [代码]class Storage { constructor(wx) { this.wx = wx; } static get timestamp() { return new Date() / 1000; } static __isExpired(entity) { if (!entity) return true; return Storage.timestamp - (entity.timestamp + entity.expiration) >= 0; } static get __info() { let info = {}; try { info = this.wx.getStorageInfoSync() || info; } catch (err) { console.error(err); } return info; } setItem(key, value, expiration) { const entity = { timestamp: Storage.timestamp, expiration, key, value }; this.wx.setStorageSync(key, JSON.stringify(entity)); return this; } getItem(key) { let entity; try { entity = this.wx.getStorageSync(key); if (entity) { entity = JSON.parse(entity); } else { return null; } } catch (err) { console.error(err); return null; } // 没有设置过期时间, 则直接返回值 if (!entity.expiration) return entity.value; // 已过期 if (Storage.__isExpired(entity)) { this.remove(key); return null; } else { return entity.value; } } removeItem(key) { try { this.wx.removeStorageSync(key); } catch (err) { console.error(err); } return this; } clear() { try { this.wx.clearStorageSync(); } catch (err) { console.error(err); } return this; } get info() { let info = {}; try { info = this.wx.getStorageInfoSync(); } catch (err) { console.error(err); } return info || {}; } get length() { return (this.info.keys || []).length; } } export default new Storage(wx); [代码] 状态管理 vendor/store.js [代码]module.exports = class Store { constructor({ state, actions }) { this.state = state || {} this.actions = actions || {} this.ctxs = [] } // 派发action, 统一返回promise action可以直接返回state dispatch(type, payload) { const update = res => { if (typeof res !== 'object') return this.setState(res) this.ctxs.map(ctx => ctx.setData(res)) return res } if (typeof this.actions[type] !== 'function') return const res = this.actions[type](this, payload) return res.constructor.toString().match(/function\s*([^(]*)/)[1] === 'Promise' ? res.then(update) : new Promise(resolve => resolve(update(res))) } // 修改state的方法 setState(data) { this.state = { ...this.state, ...data } } // 根据keys获取state getState(keys) { return keys.reduce((acc, key) => ({ ...acc, ...{ [key]: this.state[key] } }), {}) } // 映射state到实例中,可在onload或onshow中调用 mapState(keys, ctx) { if (!ctx || typeof ctx.setData !== 'function') return ctx.setData(this.getState(keys)) this.ctxs.push(ctx) } } [代码] 兼容 async/await vendor/regenerator.js [代码]/** * Copyright (c) 2014-present, Facebook, Inc. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ var regeneratorRuntime = (function (exports) { "use strict"; var Op = Object.prototype; var hasOwn = Op.hasOwnProperty; var undefined; // More compressible than void 0. var $Symbol = typeof Symbol === "function" ? Symbol : {}; var iteratorSymbol = $Symbol.iterator || "@@iterator"; var asyncIteratorSymbol = $Symbol.asyncIterator || "@@asyncIterator"; var toStringTagSymbol = $Symbol.toStringTag || "@@toStringTag"; function wrap(innerFn, outerFn, self, tryLocsList) { // If outerFn provided and outerFn.prototype is a Generator, then outerFn.prototype instanceof Generator. var protoGenerator = outerFn && outerFn.prototype instanceof Generator ? outerFn : Generator; var generator = Object.create(protoGenerator.prototype); var context = new Context(tryLocsList || []); // The ._invoke method unifies the implementations of the .next, // .throw, and .return methods. generator._invoke = makeInvokeMethod(innerFn, self, context); return generator; } exports.wrap = wrap; // Try/catch helper to minimize deoptimizations. Returns a completion // record like context.tryEntries[i].completion. This interface could // have been (and was previously) designed to take a closure to be // invoked without arguments, but in all the cases we care about we // already have an existing method we want to call, so there's no need // to create a new function object. We can even get away with assuming // the method takes exactly one argument, since that happens to be true // in every case, so we don't have to touch the arguments object. The // only additional allocation required is the completion record, which // has a stable shape and so hopefully should be cheap to allocate. function tryCatch(fn, obj, arg) { try { return { type: "normal", arg: fn.call(obj, arg) }; } catch (err) { return { type: "throw", arg: err }; } } var GenStateSuspendedStart = "suspendedStart"; var GenStateSuspendedYield = "suspendedYield"; var GenStateExecuting = "executing"; var GenStateCompleted = "completed"; // Returning this object from the innerFn has the same effect as // breaking out of the dispatch switch statement. var ContinueSentinel = {}; // Dummy constructor functions that we use as the .constructor and // .constructor.prototype properties for functions that return Generator // objects. For full spec compliance, you may wish to configure your // minifier not to mangle the names of these two functions. function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} // This is a polyfill for %IteratorPrototype% for environments that // don't natively support it. var IteratorPrototype = {}; IteratorPrototype[iteratorSymbol] = function () { return this; }; var getProto = Object.getPrototypeOf; var NativeIteratorPrototype = getProto && getProto(getProto(values([]))); if (NativeIteratorPrototype && NativeIteratorPrototype !== Op && hasOwn.call(NativeIteratorPrototype, iteratorSymbol)) { // This environment has a native %IteratorPrototype%; use it instead // of the polyfill. IteratorPrototype = NativeIteratorPrototype; } var Gp = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(IteratorPrototype); GeneratorFunction.prototype = Gp.constructor = GeneratorFunctionPrototype; GeneratorFunctionPrototype.constructor = GeneratorFunction; GeneratorFunctionPrototype[toStringTagSymbol] = GeneratorFunction.displayName = "GeneratorFunction"; // Helper for defining the .next, .throw, and .return methods of the // Iterator interface in terms of a single ._invoke method. function defineIteratorMethods(prototype) { ["next", "throw", "return"].forEach(function(method) { prototype[method] = function(arg) { return this._invoke(method, arg); }; }); } exports.isGeneratorFunction = function(genFun) { var ctor = typeof genFun === "function" && genFun.constructor; return ctor ? ctor === GeneratorFunction || // For the native GeneratorFunction constructor, the best we can // do is to check its .name property. (ctor.displayName || ctor.name) === "GeneratorFunction" : false; }; exports.mark = function(genFun) { if (Object.setPrototypeOf) { Object.setPrototypeOf(genFun, GeneratorFunctionPrototype); } else { genFun.__proto__ = GeneratorFunctionPrototype; if (!(toStringTagSymbol in genFun)) { genFun[toStringTagSymbol] = "GeneratorFunction"; } } genFun.prototype = Object.create(Gp); return genFun; }; // Within the body of any async function, `await x` is transformed to // `yield regeneratorRuntime.awrap(x)`, so that the runtime can test // `hasOwn.call(value, "__await")` to determine if the yielded value is // meant to be awaited. exports.awrap = function(arg) { return { __await: arg }; }; function AsyncIterator(generator) { function invoke(method, arg, resolve, reject) { var record = tryCatch(generator[method], generator, arg); if (record.type === "throw") { reject(record.arg); } else { var result = record.arg; var value = result.value; if (value && typeof value === "object" && hasOwn.call(value, "__await")) { return Promise.resolve(value.__await).then(function(value) { invoke("next", value, resolve, reject); }, function(err) { invoke("throw", err, resolve, reject); }); } return Promise.resolve(value).then(function(unwrapped) { // When a yielded Promise is resolved, its final value becomes // the .value of the Promise<{value,done}> result for the // current iteration. result.value = unwrapped; resolve(result); }, function(error) { // If a rejected Promise was yielded, throw the rejection back // into the async generator function so it can be handled there. return invoke("throw", error, resolve, reject); }); } } var previousPromise; function enqueue(method, arg) { function callInvokeWithMethodAndArg() { return new Promise(function(resolve, reject) { invoke(method, arg, resolve, reject); }); } return previousPromise = // If enqueue has been called before, then we want to wait until // all previous Promises have been resolved before calling invoke, // so that results are always delivered in the correct order. If // enqueue has not been called before, then it is important to // call invoke immediately, without waiting on a callback to fire, // so that the async generator function has the opportunity to do // any necessary setup in a predictable way. This predictability // is why the Promise constructor synchronously invokes its // executor callback, and why async functions synchronously // execute code before the first await. Since we implement simple // async functions in terms of async generators, it is especially // important to get this right, even though it requires care. previousPromise ? previousPromise.then( callInvokeWithMethodAndArg, // Avoid propagating failures to Promises returned by later // invocations of the iterator. callInvokeWithMethodAndArg ) : callInvokeWithMethodAndArg(); } // Define the unified helper method that is used to implement .next, // .throw, and .return (see defineIteratorMethods). this._invoke = enqueue; } defineIteratorMethods(AsyncIterator.prototype); AsyncIterator.prototype[asyncIteratorSymbol] = function () { return this; }; exports.AsyncIterator = AsyncIterator; // Note that simple async functions are implemented on top of // AsyncIterator objects; they just return a Promise for the value of // the final result produced by the iterator. exports.async = function(innerFn, outerFn, self, tryLocsList) { var iter = new AsyncIterator( wrap(innerFn, outerFn, self, tryLocsList) ); return exports.isGeneratorFunction(outerFn) ? iter // If outerFn is a generator, return the full iterator. : iter.next().then(function(result) { return result.done ? result.value : iter.next(); }); }; function makeInvokeMethod(innerFn, self, context) { var state = GenStateSuspendedStart; return function invoke(method, arg) { if (state === GenStateExecuting) { throw new Error("Generator is already running"); } if (state === GenStateCompleted) { if (method === "throw") { throw arg; } // Be forgiving, per 25.3.3.3.3 of the spec: // https://people.mozilla.org/~jorendorff/es6-draft.html#sec-generatorresume return doneResult(); } context.method = method; context.arg = arg; while (true) { var delegate = context.delegate; if (delegate) { var delegateResult = maybeInvokeDelegate(delegate, context); if (delegateResult) { if (delegateResult === ContinueSentinel) continue; return delegateResult; } } if (context.method === "next") { // Setting context._sent for legacy support of Babel's // function.sent implementation. context.sent = context._sent = context.arg; } else if (context.method === "throw") { if (state === GenStateSuspendedStart) { state = GenStateCompleted; throw context.arg; } context.dispatchException(context.arg); } else if (context.method === "return") { context.abrupt("return", context.arg); } state = GenStateExecuting; var record = tryCatch(innerFn, self, context); if (record.type === "normal") { // If an exception is thrown from innerFn, we leave state === // GenStateExecuting and loop back for another invocation. state = context.done ? GenStateCompleted : GenStateSuspendedYield; if (record.arg === ContinueSentinel) { continue; } return { value: record.arg, done: context.done }; } else if (record.type === "throw") { state = GenStateCompleted; // Dispatch the exception by looping back around to the // context.dispatchException(context.arg) call above. context.method = "throw"; context.arg = record.arg; } } }; } // Call delegate.iterator[context.method](context.arg) and handle the // result, either by returning a { value, done } result from the // delegate iterator, or by modifying context.method and context.arg, // setting context.delegate to null, and returning the ContinueSentinel. function maybeInvokeDelegate(delegate, context) { var method = delegate.iterator[context.method]; if (method === undefined) { // A .throw or .return when the delegate iterator has no .throw // method always terminates the yield* loop. context.delegate = null; if (context.method === "throw") { // Note: ["return"] must be used for ES3 parsing compatibility. if (delegate.iterator["return"]) { // If the delegate iterator has a return method, give it a // chance to clean up. context.method = "return"; context.arg = undefined; maybeInvokeDelegate(delegate, context); if (context.method === "throw") { // If maybeInvokeDelegate(context) changed context.method from // "return" to "throw", let that override the TypeError below. return ContinueSentinel; } } context.method = "throw"; context.arg = new TypeError( "The iterator does not provide a 'throw' method"); } return ContinueSentinel; } var record = tryCatch(method, delegate.iterator, context.arg); if (record.type === "throw") { context.method = "throw"; context.arg = record.arg; context.delegate = null; return ContinueSentinel; } var info = record.arg; if (! info) { context.method = "throw"; context.arg = new TypeError("iterator result is not an object"); context.delegate = null; return ContinueSentinel; } if (info.done) { // Assign the result of the finished delegate to the temporary // variable specified by delegate.resultName (see delegateYield). context[delegate.resultName] = info.value; // Resume execution at the desired location (see delegateYield). context.next = delegate.nextLoc; // If context.method was "throw" but the delegate handled the // exception, let the outer generator proceed normally. If // context.method was "next", forget context.arg since it has been // "consumed" by the delegate iterator. If context.method was // "return", allow the original .return call to continue in the // outer generator. if (context.method !== "return") { context.method = "next"; context.arg = undefined; } } else { // Re-yield the result returned by the delegate method. return info; } // The delegate iterator is finished, so forget it and continue with // the outer generator. context.delegate = null; return ContinueSentinel; } // Define Generator.prototype.{next,throw,return} in terms of the // unified ._invoke helper method. defineIteratorMethods(Gp); Gp[toStringTagSymbol] = "Generator"; // A Generator should always return itself as the iterator object when the // @@iterator function is called on it. Some browsers' implementations of the // iterator prototype chain incorrectly implement this, causing the Generator // object to not be returned from this call. This ensures that doesn't happen. // See https://github.com/facebook/regenerator/issues/274 for more details. Gp[iteratorSymbol] = function() { return this; }; Gp.toString = function() { return "[object Generator]"; }; function pushTryEntry(locs) { var entry = { tryLoc: locs[0] }; if (1 in locs) { entry.catchLoc = locs[1]; } if (2 in locs) { entry.finallyLoc = locs[2]; entry.afterLoc = locs[3]; } this.tryEntries.push(entry); } function resetTryEntry(entry) { var record = entry.completion || {}; record.type = "normal"; delete record.arg; entry.completion = record; } function Context(tryLocsList) { // The root entry object (effectively a try statement without a catch // or a finally block) gives us a place to store values thrown from // locations where there is no enclosing try statement. this.tryEntries = [{ tryLoc: "root" }]; tryLocsList.forEach(pushTryEntry, this); this.reset(true); } exports.keys = function(object) { var keys = []; for (var key in object) { keys.push(key); } keys.reverse(); // Rather than returning an object with a next method, we keep // things simple and return the next function itself. return function next() { while (keys.length) { var key = keys.pop(); if (key in object) { next.value = key; next.done = false; return next; } } // To avoid creating an additional object, we just hang the .value // and .done properties off the next function object itself. This // also ensures that the minifier will not anonymize the function. next.done = true; return next; }; }; function values(iterable) { if (iterable) { var iteratorMethod = iterable[iteratorSymbol]; if (iteratorMethod) { return iteratorMethod.call(iterable); } if (typeof iterable.next === "function") { return iterable; } if (!isNaN(iterable.length)) { var i = -1, next = function next() { while (++i < iterable.length) { if (hasOwn.call(iterable, i)) { next.value = iterable[i]; next.done = false; return next; } } next.value = undefined; next.done = true; return next; }; return next.next = next; } } // Return an iterator with no values. return { next: doneResult }; } exports.values = values; function doneResult() { return { value: undefined, done: true }; } Context.prototype = { constructor: Context, reset: function(skipTempReset) { this.prev = 0; this.next = 0; // Resetting context._sent for legacy support of Babel's // function.sent implementation. this.sent = this._sent = undefined; this.done = false; this.delegate = null; this.method = "next"; this.arg = undefined; this.tryEntries.forEach(resetTryEntry); if (!skipTempReset) { for (var name in this) { // Not sure about the optimal order of these conditions: if (name.charAt(0) === "t" && hasOwn.call(this, name) && !isNaN(+name.slice(1))) { this[name] = undefined; } } } }, stop: function() { this.done = true; var rootEntry = this.tryEntries[0]; var rootRecord = rootEntry.completion; if (rootRecord.type === "throw") { throw rootRecord.arg; } return this.rval; }, dispatchException: function(exception) { if (this.done) { throw exception; } var context = this; function handle(loc, caught) { record.type = "throw"; record.arg = exception; context.next = loc; if (caught) { // If the dispatched exception was caught by a catch block, // then let that catch block handle the exception normally. context.method = "next"; context.arg = undefined; } return !! caught; } for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; var record = entry.completion; if (entry.tryLoc === "root") { // Exception thrown outside of any try block that could handle // it, so set the completion value of the entire function to // throw the exception. return handle("end"); } if (entry.tryLoc <= this.prev) { var hasCatch = hasOwn.call(entry, "catchLoc"); var hasFinally = hasOwn.call(entry, "finallyLoc"); if (hasCatch && hasFinally) { if (this.prev < entry.catchLoc) { return handle(entry.catchLoc, true); } else if (this.prev < entry.finallyLoc) { return handle(entry.finallyLoc); } } else if (hasCatch) { if (this.prev < entry.catchLoc) { return handle(entry.catchLoc, true); } } else if (hasFinally) { if (this.prev < entry.finallyLoc) { return handle(entry.finallyLoc); } } else { throw new Error("try statement without catch or finally"); } } } }, abrupt: function(type, arg) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc <= this.prev && hasOwn.call(entry, "finallyLoc") && this.prev < entry.finallyLoc) { var finallyEntry = entry; break; } } if (finallyEntry && (type === "break" || type === "continue") && finallyEntry.tryLoc <= arg && arg <= finallyEntry.finallyLoc) { // Ignore the finally entry if control is not jumping to a // location outside the try/catch block. finallyEntry = null; } var record = finallyEntry ? finallyEntry.completion : {}; record.type = type; record.arg = arg; if (finallyEntry) { this.method = "next"; this.next = finallyEntry.finallyLoc; return ContinueSentinel; } return this.complete(record); }, complete: function(record, afterLoc) { if (record.type === "throw") { throw record.arg; } if (record.type === "break" || record.type === "continue") { this.next = record.arg; } else if (record.type === "return") { this.rval = this.arg = record.arg; this.method = "return"; this.next = "end"; } else if (record.type === "normal" && afterLoc) { this.next = afterLoc; } return ContinueSentinel; }, finish: function(finallyLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.finallyLoc === finallyLoc) { this.complete(entry.completion, entry.afterLoc); resetTryEntry(entry); return ContinueSentinel; } } }, "catch": function(tryLoc) { for (var i = this.tryEntries.length - 1; i >= 0; --i) { var entry = this.tryEntries[i]; if (entry.tryLoc === tryLoc) { var record = entry.completion; if (record.type === "throw") { var thrown = record.arg; resetTryEntry(entry); } return thrown; } } // The context.catch method must only be called with a location // argument that corresponds to a known catch block. throw new Error("illegal catch attempt"); }, delegateYield: function(iterable, resultName, nextLoc) { this.delegate = { iterator: values(iterable), resultName: resultName, nextLoc: nextLoc }; if (this.method === "next") { // Deliberately forget the last sent value so that we don't // accidentally pass it on to the delegate. this.arg = undefined; } return ContinueSentinel; } }; // Regardless of whether this script is executing as a CommonJS module // or not, return the runtime object so that we can declare the variable // regeneratorRuntime in the outer scope, which allows this module to be // injected easily by `bin/regenerator --include-runtime script.js`. return exports; }( // If this script is executing as a CommonJS module, use module.exports // as the regeneratorRuntime namespace. Otherwise create a new empty // object. Either way, the resulting object will be used to initialize // the regeneratorRuntime variable at the top of this file. typeof module === "object" ? module.exports : {} )); [代码] 后端 [代码]const typeDefs = gql` # schema 下面是根类型,约定是 RootQuery 和 RootMutation schema { query: Query } # 定义具体的 Query 的结构 type Query { # 登陆接口 login(code: String!, encryptedData: String, iv: String): Login } type Login { token: String! userInfo: UserInfo } type UserInfo { nickName: String gender: String avatarUrl: String } `; const resolvers = { Query: { async login(parent, { code, encryptedData, iv }) { const { sessionKey, openId, unionId } = await wxService.code2Session(code); const userInfo = encryptedData && iv ? wxService.decryptData(sessionKey, encryptedData, iv) : { openId, unionId }; if (userInfo.nickName) { userService.createOrUpdateWxUser(userInfo); } const token = await userService.generateJwtToken(userInfo); return { token, userInfo }; }, }, }; [代码]
2019-04-21 - 富文本组件体验小程序
简介 上周发布的 新富文本显示组件 收获了许多关注,为方便大家了解和体验,[代码]demo[代码] 小程序上线啦 [图片] 大家可以在这里查看介绍和示例或者进行自定义的测试,发现任何问题都欢迎反馈哦 立即体验 [图片] GitHub链接 Github链接
2020-12-27 - 有赞前端质量保障体系
前言 最近一年多一直在做前端的一些测试,从小程序到店铺装修,基本都是纯前端的工作,刚开始从后端测试转为前端测试的时候,对前端东西茫然无感,而且团队内没有人做过纯前端的测试工作,只能一边踩坑一边总结经验,然后将容易出现问题的点形成体系、不断总结摸索,最终形成了目前的一套前端测试解决方案。在此,将有赞的前端质量保障体系进行总结,希望和大家一起交流。 先来全局看下有赞前端的技术架构和针对每个不同的层次,主要做了哪些保障质量的事情: [图片] [图片] 有赞的 Node 技术架构分为业务层、基础框架层、通用组件和基础服务层,我们日常比较关注的是基础框架、通用组件和业务层代码。Node 业务层做了两件事情,一是提供页面渲染的 client 层,用于和 C 端用户交互,包括样式、行为 js 等;二是提供数据服务的 server 层,用于组装后台提供的各种接口,完成面向 C 端的接口封装。 对于每个不同的层,我们都做了一些事情来保障质量,包括: 针对整个业务层的 UI 自动化、核心接口|页面拨测; 针对 client 层的 sentry 报警; 针对 server 层的接口测试、业务报警; 针对基础框架和通用组件的单元测试; 针对通用组件变更的版本变更报警; 针对线上发布的流程规范、用例维护等。 下面就来分别讲一下这几个维度的质量保障工作。 一、UI 自动化 很多人会认为,UI 自动化维护成本高、性价比低,但是为什么在有赞的前端质量保证体系中放在了最前面呢? 前端重用户交互,单纯的接口测试、单元测试不能真实反映用户的操作路径,并且从以往的经验中总结得出,因为各种不可控因素导致的发布 A 功能而 B 功能无法使用,特别是核心简单场景的不可用时有出现,所以每次发布一个应用前,都会将此应用提供的核心功能执行一遍,那随着业务的不断积累,需要回归的测试场景也越来越多,导致回归的工作量巨大。为了降低人力成本,我们亟需通过自动化手段释放劳动力,所以将核心流程回归的 UI 自动化提到了最核心地位。 当然,UI 自动化的最大痛点确实是维护成本,为降低维护成本,我们将页面分为组件维度、页面维度,并提供统一的包来处理公用组件、特殊页面的通用逻辑,封装通用方法等,例如初始化浏览器信息、环境选择、登录、多网点切换、点击、输入、获取元素内容等等,业务回归用例只需要关注自己的用例操作步骤即可。 1、框架选择 – puppeteer[1],它是由 Chrome 维护的 Node 库,基于 DevTools 协议来驱动 chrome 或者 chromium 浏览器运行,支持 headless 和 non-headless 两种方式。官网提供了非常丰富的文档,简单易学。 UI 自动化框架有很多种,包括 selenium、phantom;对比后发现 puppeteer 比较轻量,只需要增加一个 npm 包即可使用;它是基于事件驱动的方式,比 selenium 的等待轮询更稳当、性能更佳;另外,它是 chrome 原生支持,能提供所有 chrome 支持的 api,同时我们的业务场景只需要覆盖 chrome,所以它是最好的选择。 – mocha[2] + mochawesome[3],mocha 是比较主流的测试框架,支持 beforeEach、before、afterEach、after 等钩子函数,assert 断言,测试套件,用例编排等。 mochawesome 是 mocha 测试框架的第三方插件,支持生成漂亮的 html/css 报告。 js 测试框架同样有很多可以选择,mocha、ava、Jtest 等等,选择 mocha 是因为它更灵活,很多配置可以结合第三方库,比如 report 就是结合了 mochawesome 来生成好看的 html 报告;断言可以用 powser-assert 替代。 2、脚本编写 封装基础库 封装 pc 端、h5 端浏览器的初始化过程 封装 pc 端、h5 端登录统一处理 封装页面模型和组件模型 封装上传组件、日期组件、select 组件等的统一操作方法 封装 input、click、hover、tap、scrollTo、hover、isElementShow、isElementExist、getElementVariable 等方法 提供根据 “html 标签>>页面文字” 形式获取页面元素及操作方法的统一支持 封装 baseTest,增加用例开始、结束后的统一操作 封装 assert,增加断言日志记录 业务用例 安装基础库 编排业务用例 3、执行逻辑 分环境执行 增加预上线环境代码变更触发、线上环境自动执行 监控源码变更 增加 gitlab webhook,监控开发源码合并 master 时自动在预上线环境执行 增加 gitlab webhook,监控测试用例变更时自动在生产环境执行 每日定时执行 增加 crontab,每日定时执行线上环境 [图片] [图片] [图片] [图片] 二、接口测试 接口测试主要针对于 Node 的 server 层,根据我们的开发规范,Node 不做复杂的业务逻辑,但是需要将服务化应用提供 dubbo 接口进行一次转换,或将多个 dubbo 接口组合起来,提供一个可供 h5/小程序渲染数据的 http 接口,转化过程就带来了各种数据的获取、组合、转换,形成了新的端到端接口。这个时候单单靠服务化接口的自动化已经不能保障对上层接口的全覆盖,所以我们针对 Node 接口也进行自动化测试。为了使用测试内部统一的测试框架,我们通过 java 去请求 Node 提供的 http 接口,那么当用例都写好之后,该如何评判接口测试的质量?是否完全覆盖了全部业务逻辑呢?此时就需要一个行之有效的方法来获取到测试的覆盖情况,以检查有哪些场景是接口测试中未覆盖的,做到更好的查漏补缺。 – istanbul[4] 是业界比较易用的 js 覆盖率工具,它利用模块加载的钩子计算语句、行、方法和分支覆盖率,以便在执行测试用例时透明的增加覆盖率。它支持所有类型的 js 覆盖率,包括单元测试、服务端功能测试以及浏览器测试。 但是,我们的接口用例写在 Java 代码中,通过 Http 请求的方式到达 Node 服务器,非 js 单测,也非浏览器功能测试,如何才能获取到 Node 接口的覆盖率呢? 解决办法是增加 cover 参数:–handle-sigint,通过增加 --handle-sigint 参数启动服务,当服务接收到一个 SIGINT 信号(linux 中 SIGINT 关联了 Ctrl+C),会通知 istanbul 生成覆盖率。这个命令非常适合我们,并且因此形成了我们接口覆盖率的一个模型: [代码]1. istanbule --handle-sigint 启动服务 2. 执行测试用例 3. 发送 SIGINT结束istanbule,得到覆盖率 [代码] 最终,解决了我们的 Node 接口覆盖率问题,并通过 jenkins 持续集成来自动构建 [图片] [图片] [图片] 当然,在获取覆盖率的时候有需求文件是不需要统计的,可以通过在根路径下增加 .istanbule.yml 文件的方式,来排除或者指定需要统计覆盖率的文件 [代码]verbose: false instrumentation: root: . extensions: - .js default-excludes: true excludes:['**/common/**','**/app/constants/**','**/lib/**'] embed-source: false variable: __coverage__ compact: true preserve-comments: false complete-copy: false save-baseline: false baseline-file: ./coverage/coverage-baseline.json include-all-sources: false include-pid: false es-modules: false reporting: print: summary reports: - lcov dir: ./coverage watermarks: statements: [50, 80] lines: [50, 80] functions: [50, 80] branches: [50, 80] report-config: clover: {file: clover.xml} cobertura: {file: cobertura-coverage.xml} json: {file: coverage-final.json} json-summary: {file: coverage-summary.json} lcovonly: {file: lcov.info} teamcity: {file: null, blockName: Code Coverage Summary} text: {file: null, maxCols: 0} text-lcov: {file: lcov.info} text-summary: {file: null} hooks: hook-run-in-context: false post-require-hook: null handle-sigint: false check: global: statements: 0 lines: 0 branches: 0 functions: 0 excludes: [] each: statements: 0 lines: 0 branches: 0 functions: 0 excludes: [] [代码] 三、单元测试 单元测试在测试分层中处于金字塔最底层的位置,单元测试做的比较到位的情况下,能过滤掉大部分的问题,并且提早发现 bug,也可以降低 bug 成本。推行一段时间的单测后发现,在有赞的 Node 框架中,业务层的 server 端只做接口组装,client 端面向浏览器,都不太适合做单元测试,所以我们只针对基础框架和通用组件进行单测,保障基础服务可以通过单测排除大部分的问题。比如基础框架中店铺通用信息服务,单测检查店铺信息获取;比如页面级商品组件,单测检查商品组件渲染的 html 是否和原来一致。 单测方案试行了两个框架: Jest[5] ava[6] 比较推荐的是 Jest 方案,它支持 Matchers 方式断言;支持 Snapshot Testing,可测试组件类代码渲染的 html 是否正确;支持多种 mock,包括 mock 方法实现、mock 定时器、mock 依赖的 module 等;支持 istanbule,可以方便的获取覆盖率。 总之,前端的单测方案也越来越成熟,需要前端开发人员更加关注 js 单测,将 bug 扼杀在摇篮中。 四、基础库变更报警 上面我们已经对基础服务和基础组件进行了单元测试,但是单测也不能完全保证基础库的变更完全没有问题,伴随着业务层引入新版本的基础库,bug 会进一步带入到业务层,最终影响 C 端用户的正常使用。那如何保障每次业务层引入新版本的基础库之后能做到全面的回归?如何让业务测试同学对基础库变更更加敏感呢?针对这种情况,我们着手做了一个基础库版本变更的小工具。实现思路如下: [代码]1. 对比一次 master 代码的提交或 merge 请求,判断 package.json 中是否有特定基础库版本变更 2. 将对应基础库的前后两个版本的代码对比发送到测试负责人 3. 根据 changelog 判断此次回归的用例范围 4. 增加 gitlab webhook,只有合并到合并发布分支或者 master 分支的代码才触发检查 [代码] 这个小工具的引入能及时通知测试人员针对什么需求改动了基础组件,以及这次基础组件的升级主要影响了哪些方面,这样能避免相对黑盒的测试。 第一版实现了最简功能,后续再深挖需求,可以做到前端代码变更的精准测试。 [图片] 五、sentry 报警 在刚接触前端测试的时候,js 的报错没有任何追踪,对于排查问题和定位问题有很大困扰。因此我们着手引入了 sentry 报警监控,用于监控线上环境 js 的运行情况。 – sentry[7] 是一款开源的错误追踪工具,它可以帮助开发者实时监控和修复崩溃。 开始我们接入的方式比较简单粗暴,直接全局接入,带来的问题是报警信息非常庞大,全局上报后 info、warn 信息都会打出来。 更改后,使用 sentry 的姿势是: sentry 的全局信息上报,并进行筛选 错误类型: TypeError 或者 ReferenceError 错误出现用户 > 1k 错误出现在 js 文件中 出现错误的店铺 > 2家 增加核心业务异常流程的主动上报 最终将筛选后的错误信息通过邮件的形式发送给告警接收人,在固定的时间集中修复。 [图片] [图片] 六、业务报警 除了 sentry 监控报警,Node 接口层的业务报警同样是必不可少的一部分,它能及时发现 Node 提供的接口中存在的业务异常。这部分是开发和运维同学做的,包括在 Node 框架底层接入日志系统;在业务层正确的上报错误级别、错误内容、错误堆栈信息;在日志系统增加合理的告警策略,超过阈值之后短信、电话告警,以便于及时发现问题、排查问题。 业务告警是最能快速反应生产环境问题的一环,如果某次发布之后发生告警,我们第一时间选择回滚,以保证线上的稳定性。 七、约定规范 除了上述的一些测试和告警手段之外,我们也做了一些流程规范、用例维护等基础建设,包括: 发布规范 多个日常分支合并发布 限制发布时间 规范发布流程 整理自测核心检查要点 基线用例库 不同业务 P0 核心用例定期更新 项目用例定期更新到业务回归用例库 线上问题场景及时更新到回归用例库 目前有赞的前端测试套路基本就是这样,当然有些平时的努力没有完全展开,例如接口测试中增加返回值结构体对比;增加线上接口或页面的拨测[8];给开发进行自测用例设计培训等等。也还有很多新功能探索中,如接入流量对比引擎,将线上流量导到预上线环境,在代码上线前进行对比测试;增加UI自动化的截图对比;探索小程序的UI自动化等等。 参考链接 [1] https://github.com/GoogleChrome/puppeteer [2] https://www.npmjs.com/package/mocha [3] https://www.npmjs.com/package/mochawesome [4] https://github.com/gotwarlost/istanbul [5] https://github.com/facebook/jest [6] https://github.com/avajs/ava [7] https://docs.sentry.io [8] https://tech.youzan.com/youzan-online-active-testing/
2019-04-25 - 小程序转支付宝小程序工具:wx2my
背景目前市面上有很多微信小程序,同时开发者开发完微信小程序后,希望可以同时发布到支付宝小程序平台上,可惜微信小程序并不能直接发布到支付宝平台上,两个平台小程序不兼容。因此开发者需要对微信小程序代码进行修改,调整成支付宝小程序代码。 庆幸的事两种小程序代码有很多相似之处,手动修改比较繁琐,因此小程序助手孕育而生。达到自动把微信小程序代码转换成支付宝小程序。不过由于两种小程序功能和api等的不一致,转换后生成的支付宝小程序并不能直接运行起来,还需要进行代码检查,手动的修改无法转换的部分。 地址 vscode插件: wx2my(微信小程序转支付宝小程序) cli命令工具: wx2my npm地址 使用文档: wx2my 语雀地址 目标 快速转换微信小程序为支付宝小程序,达到快速转换,降低转换成本,这样可以早点下班。 视频教程[视频] 能力 文件名转换app文件名转换: 微信小程序 --> wx2my --> 支付宝小程序 app.json app.json app.js app.js app.wxss app.acss page页面、自定义组件文件名转换: 微信小程序 --> wx2my --> 微信小程序 index.json index.json index.js index.js index.wxml index.axml index.wxss index.acss 其他类型文件名转换: 微信小程序 --> wx2my --> 支付宝小程序 parse.wxs parse.sjs 其他类型文件(图片、视频等) 直接复制,不转换 文件内容转换app.json 转换 app.json文件为整个小程序配置文件,不过微信小程序app.json和支付宝小程序在app.json配置文件支持的能力不完全一致,部分一致的但名称不一致的配置,转换工具会分析并转换出来。 转换方式: navigationBarTitleText --> defaultTitle enablePullDownRefresh --> pullRefresh navigationBarBackgroundColor --> titleBarColor ...等 其中微信小程序支持,支付宝小程序不支持的,需要开发者自己手动修改,如:networkTimeout、functionalPages、workers等 全局组件转换 微信小程序支持全局组件,即在app.json中添加usingComponents字段,这样在小程序内的页面或自定义组件中可以直接使用全局组件而无需再声明。 转换方式: 转换工具会分析小程序中所有页面和组件,找到那些使用了全局组件的页面和组件,并把全局组件声明在页面或组件的json文件中,当做普通组件引用和使用。同时把全局组件的声明删除。 wxml文件转换 转换逻辑是以wx:xxx开头的,替换为a:xxx方式。 a. 事件相关的转换,微信中 bindeventname 或 bind:eventname 转换为 onEventname, 如下: 转换前: <page bindtap="showName" bind:input = "actionName" catchchange="catchchange"bindtouchend="onTouchEnd"></page> 转换后: <page onTap="showName" onInput = "actionName" catchChange="catchchange" onTouchEnd="onTouchEnd"></page> b: 循环语句转换, 如下: 转换前: <view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName" wx:key="unique">{{idx}}: {{itemName.message}}</view> 转换后: <view a:for="{{array}}" a:for-index="idx" a:for-item="itemName" a:key="unique"> {{idx}}: {{itemName.message}}</view> c: wxs代码转换,微信小程序中的wxs功能对应支付宝小程序中的sjs功能,微信wxml中支持引用外部wxs文件和内联wxs代码,支付宝中只支持引用外部文件方式使用sjs,不支持内联sjs代码。 转换方式:转换工具分享所有wxml文件,找到wxs内联代码,提取wxs的内联代码,生成sjs文件,并使用外部引用的方式引入sjs文件,如下: 转换前: <wxs src="../wxs/utils.wxs" module="utils" /><wxs src="../wxs/utils.wxs" module="utils"> </wxs><wxs module="parse"> module.exports.getMax = getMax;</wxs> 转换后: <import-sjs from="../wxs/utils.sjs" name="utils" /><import-sjs from="../wxs/utils.sjs" name="utils"/><import-sjs from="./parse.sjs" name="sjsTest" />并在同级目录下创建了 [代码]parse.sjs[代码] 文件,并转换wxs的CommonJS为ESM parse.sjs文件内容: export default { getMax }; d: 无法替换完成的,在转换后的支付宝小程序的代码中,插入注释代码,提醒开发者并需要开发者手动检查修改。如下: 转换前: <cover-image class="img" src="/path/to/icon_play" bindload="bindload" binderror="binderror" aria-role="xxx"aria-label="xxx"/> 转换后: <cover-image class="img" src="/path/to/icon_play" bindload="bindload" binderror="binderror" aria-role="xxx"aria-label="xxx"/><!-- WX2MY: 属性bindload、binderror、aria-role、aria-label不被支持,请调整 --> 出现这种情况,开发者可以手动的搜索 [代码]WX2MY:[代码] 关键字,查找需要修改的代码 js文件转换 转换工具对api相关的调用转换使用了桥接文件 [代码]wx2my.js[代码] ,在所有js文件顶部引入wx2my.js文件,对api的调用,使用桥接函数,桥接函数对api参数不一致的地方在函数内部进行处理,如下: 转换前: wx.request(opts) 转换后: wx2my.request(opts) [代码]wx[代码] 转换为 [代码]wx2my[代码] ,其中wx2my为前进函数对外的方法 桥接函数中 [代码]request[代码] 的方法如下: [图片]
2019-04-17 - 如何用小程序做社区团购?「妙食」团队分享宝贵经验。
面对炙手可热的社区团购,商家该如何借势微信生态,成功地切入这一新兴赛道?「妙食」或许能够给你一些启发~ 面对炙手可热的社区团购,商家该如何借势微信生态,成功地切入这一新兴赛道?「妙食」或许能够给你一些启发~ 作者丨Range [图片] 2018年下半年,社区团购悄然井喷。如今半年多时间过去,其势头也丝毫未减。 这一零售新模式以社区为节点,通过招募社区团长,然后以微信群及小程序建立销售渠道,批量发货,线下自提。从流量获取、商品损耗、物流等方面极大地降低了履约成本,吸引了一众玩家入场。 在这些玩家中,有以「你我您」、「食享会」、「邻邻壹」为代表的头部平台,但更多的还是看到零售新风口、希望入局或转型的中小商家。 不过,看到风口不等于能抓住风口。想抓住社区团购这一风口,商家就必须解决如何布局社区,如何招募管理团长,如何选品、打造“人无我有”的产品竞争力等一系列从0到1的问题。 为此,晓程序速报(ID:xcxsubao)采访了社区团购平台「妙食」的创始人张斌,并且就以上问题展开对话。下面是本次采访的内容,或许对你们会有启发。 找准差异化定位再发力 晓程序速报:您是什么时候开始注意社区团购这条赛道的? 张斌:大概是从去年9、10月份的时候。 晓程序速报:是哪些原因让您决定入局该赛道? 张斌:我不是说看到了赛道再去进入,而是因为我这边有一些自己的核心资源和竞争力。我当时在想,我已经有了供应链,但是缺最后一公里。那怎么能够快速的拿到订单,而这时候刚好看到了整个社区经济。 [图片] 晓程序速报:「妙食」团队现在多大? 张斌:「妙食」刚刚开始做,目前团队内勤4个,外勤(即团长,目前主要面向宝妈人群招募,下称团妈)10个。 晓程序速报:当时为什么会选择小程序作为平台的销售工具? 张斌:小程序有着天然的流量,而且开发维护成本比较小,适合创业团队。 [图片] 晓程序速报:现在很多社区团购平台,都是从二三线城市先开始,但「妙食」是直接从北京开始。相比二三线城市,您觉得从北京开始有哪些困难? 张斌:从北京开始最大的困难是成本问题,包括配送、房租、人员成本等等。另外北京这边的团长对工资预期会高一些。 如果你在二三线的城市,一个月挣3000块钱,对于一些社区团长来说可能就已经很满足了。但如果在北京,可能要能挣到八九千块钱,他们才会觉得这件事情可以投入。但相对应的,北京这边消费能力也强,反正各有利弊。 实际上,社区团购分为两种,一种叫做社群团购,一种叫做社区团购。前者更注重于带货出货,后者则更注重于提供服务。 [图片] 而越一线城市的人们,生活质量越高,他们对服务品质的依赖就越强。如果这时候有社区团购平台能够提供高品质服务,他们是会产生信任的,那么由此建立起来的行业壁垒相对来说是比较高的。 因此,「妙食」的社区团购不只是纯粹的卖货,我们会要求团妈更注重于服务。所谓服务的话,比如说把工艺、插花,甚至学校等等相关的生活话题,都引入到我们的整个销售体制中,这也是我们对团妈的一个要求。 当团妈在某个小区形成整个的扩展之后,我们的要求是不允许有第二家平台在这个小区内能够存活。尽管要做到这一点比较难,但我们目前还是实现的很好,至少我们打开的小区中,其他外围平台基本上都很难进入。 晓程序速报:那在社区地点的选择上会有哪些考量? 张斌:社区地点是根据前置仓来决定的。 不过由于我们现在还是发展初期,要综合考虑成本等各方面因素。因此我们目前选择的社区会比较集中,而且是离家近的,一方面工作方便,另一方面自己也更熟悉;然后在这些区域里,二三十个小区中间配一个前置仓。 未来随着业务发展,以及想去做这件事的人(团长)越来越多,那么我们可能会在北京的几大核心区域,同时去配置前置仓,进而扩大社区服务范围。 [图片] (“妙食”的前置仓) 晓程序速报:前置仓有了,那物流配送问题是如何解决的? 张斌:传统的社区拼团模式,更多是去团妈家自取,但是这种自取的方式,服务体验会非常差。 因为现在人都很懒,那么对服务的要求就比较高,而且北京地区团妈的要求还会更高点,他们会觉得在自己小区一天送一二百单是不能接受的,就算你给他钱他也不能接受,因为他们可能有自己的家庭,自己的孩子。 因此,我们更多采用的是自有人员配送,以及与达达配送或者蜂鸟配送结合的这种方式。这相当于把团妈的时间解放出来,让他们去更好地完成销售和服务的任务。 团长是关键,但不是核心 晓程序速报:你们是如何招聘团妈的? 张斌:我们的团妈,一般都是来自推荐。所以我们设有伯乐奖,推荐的团妈如果通过了考核,我们会奖励推荐人5000块钱。 但这种开拓团妈的方式可能会比较慢。它不像现在很多社区团购,是直接找便利店,然后去扫这个市场。这样的方式虽然很快,但它的结果往往都不是很好。 我们需要的是这个小区的真实业主,并且这个业主在这个小区会有相当的人脉,有足够的时间,同时又是一个对生活品质有要求的人。 我们也不是特别喜欢那种商业化特别强的人去做这件事情,因此我们可能不会去找这个区域排名第一排名第二的团妈,我们是希望找到一些真正想去做这件事情的人,他是能够跟我们一起成长,一起创造奇迹的。 [图片] 晓程序速报:在团妈正式卖货前,会有相关培训吗? 张斌:我们会有一些专门的培训,比如包括如何去解答用户的问题,如何去处理用户的投诉,如何用更好的方式去销售,而不是生硬地去卖货,等等。 我们会有一个团妈大家庭的群,公司会统一在群里对他们做运营、文案等方面的指导,让团妈达到一个标准化的水平。同时,公司也会定期跟团妈们进行面对面的交流,大家一起沟通交流有什么好的方法可以去提升销售技巧。 也就是说,我们一开始会带着他们往前走,当他们达到一定熟练程度后,他们则会反哺公司,给公司提供更好的建议。所有的人都是相互协作的关系。 此外,由于现在开拓一些新的小区存在很多困难,那么我们已有的团妈都可以是其他团妈的帮手,去帮他们去完成整个新社区的建设(包括拉微信群等);同时公司也会提供一些线下地推活动的支持,去帮助他们去处理破冰点的问题,而后面的话就要靠他们自己。 [图片] 晓程序速报:当社区微信群建成后,团妈是如何卖货的? 张斌:团妈需要在「妙食」小程序申请分销商功能,成为分销商后,团妈就会拥有自己的专属推广商城。 然后团妈就可以在小区微信群分享小程序商品链接,以及卖货文案,引导小区用户在小程序下单。 [图片] 晓程序速报:那对团妈会有哪些考核? 张斌:我们对团妈会有一个非常详细的考核标准。 考核标准中包含了浮动收入,但是团妈要想拿到浮动收入,甚至是高的浮动收入,那么就必须满足我们的一系列用户指标,包括群人数、复购率、新客率、包括客诉,等等。 晓程序速报:团妈是你们的关键,那您怎么看待目前社区团购竞争中出现的“挖团长”现象? 张斌:到目前为止,我们觉得这不是一个非常大的问题。所有的团妈和公司品牌是根深蒂固在这个社区的,团妈走了之后,公司的品牌、公司的平台,公司的群还在。 如果从传统带货的角度去看社区团购的话,团妈走了生意就没了。但我们主打的是社区服务,是公司服务,小区用户他们认同的不是团妈,而是公司。 [图片] 品质与服务为王 晓程序速报:据了解,您这边组织了一支专业的美食小分队去做选品,具体是怎么进行的? 张斌:对于生鲜选品,我们首先是选品种,然后再选这一品种中的优质品种。 比如说枇杷,有云霄枇杷,有攀枝花枇杷,有蒙自枇杷,但我们不会去做云霄枇杷和攀枝花的枇杷,我们只会做蒙自枇杷。因为我们知道蒙自枇杷虽然价格高一点,但它是最好的。 又比如说红薯,有海南桥头黄金薯,天目香薯,山东烟薯25号、蜂蜜罐等等。虽然品种很多,但我们会根据整个口感会选出三种最好的,一种是适合烤的,一种适合蒸的,还有一种在这两种之间,它集成了两种优点,但又没有其他两种那么突出。那么我们就会只做这三种产品,其他所有的产品我们都不谈,因为这三种产品我认为是在保守里面是比较值的。 其实在选品的时候,首先是要选对了,你要买卖的这个东西一定是最优质的东西。正确的时间,正确的品种,正确的产地,剩下的就是价格。实际上这个逻辑是非常简单的。 [图片] 晓程序速报:从消费者的角度来看,他们其实有很多选择,那么怎么才能让他们选择你们「妙食」,并且通过社区团购这种方式来购买? 张斌:我们的产品价格在市场上可能不是最低的,但是我们的产品一定是优质的。并且我们对用户承诺,当你在我们这里购物,如果认为不满意或者认为不好吃,或者我说甜,但你觉得不甜,我们都是可以退货的。 我们是一个注重品质和服务的公司,我们相信:一个邻居对我们公司产生的价值,不在于说某一单我们给他进行了赔偿,而是在于他在我们平台能够产生长期订单。其实你可以认为我们不是做生意的,而是更注重于打造新型邻里关系,以及新型社区服务理念的公司。 由于我们在选品和服务上形成很好的搭配,因此目前用户反应还是非常良好的。 晓程序速报:在「妙食商城」小程序的商品分类中,有“直选蔬菜”这一类目,不过目前栏目下尚未有商品陈列,这是否意味着「妙食」接下来会重点运营这一品类? [图片] 张斌:蔬菜最大的特点是高频,但它的问题也很明显,比如蔬菜的损耗会很大,实际人工成本会很高,实际上它是一个纯靠量的产品。 同时,由于蔬菜的品质就是其新鲜度,它并不会由于品质不同带来太大的口感差异,这决定了它的进入门槛很低。门槛低就导致大家很难“做出花来”,但是我们想做一些跟别人不一样的东西。 比如蔬菜的选择,我们现在可能会挑这种健康实在的产品,比如跟沙拉相关的,或者是平时大家看到的比较有新意的菜式相关的。 「妙食」规划与“社区团购”展望 晓程序速报:「妙食商城」小程序有提到会员体系,它目前的发展情况如何? 张斌:小程序里的确设有会员功能,不过还没有上线,我们目前还在通过一些尝试去验证这一块东西,因为我们是想把会员与用户更紧密地绑定在一起。 会员该怎么做才不会招致用户太多反感?凭用户要买这个会员?凭什么我不是会员,就不能享受会员这么多的优惠? 我认为,在邻里关系的环境中不应该有这么多的套路,不是去钻营怎么能让用户按照我们的规则去做,而是去思考我们能为用户解决哪些问题。 [图片] 晓程序速报:「妙食」接下来的规划是怎样的? 张斌:我们接下来在北京至少要开五个前置仓,服务一百个小区。等北京做的相对成熟后,我们可能会考虑去外省拓展业务,最近也有在对接外省的一些集团。 未来我们有可能会与这种已经存在的、有着大量线下资源的第三方进行合作,它们可以充当我们的前置仓,承担起我们未来整个城市级别的服务。 晓程序速报:现在社区团购依旧很热,很多中小商家也在观望、等待入局。那么对于这些商家,您有哪些建议? 张斌:我觉得整个社区团购最核心的有两点,一个是供应链,一个是最后一公里。 之前很多人都在说,社区团购最根本的是供应链,只有拿到最低的价格,才能够把地基打好,才能把整个故事和商业模式运行得顺畅。但实际上随着市场愈发拥挤,目前供应链的这个成本已经在逐步提高了。 [图片] 这些新兴的中小公司,与大公司的供应链比起来,实际上存在非常大的差距,这个差距可能很难弥补,是属于先天不足。 那么对于中小企业,尤其是真正想在社区团购这条赛道上继续走下去的中小企业来说,可能要做的事情就是花大力气去做好最后一公里,拿到大量的订单后再去补充自己的供应链资源。 晓程序速报:社区团购作为新兴的商业模式,逐渐在生鲜、玩具、美业等多个领域得到应用。社区团购发展迅猛,离不开微信生态。“小程序+微信群”在社区团购中扮演了团长招募、服务触达、分销管理等多个角色,起到不可或缺的作用。 未来,小程序与微信群之间将联系更加紧密,社区团购或迎来更大的发展空间。
2019-04-17 - 小程序canvas的那些事
背景 业务场景需要在小程序内生成活动的分享海报,图片中的某些数据需动态展示。可行的方案有️二: 服务端合成:直接返回给前端图片URL 客户端合成:客户端利用canvas绘制 在当前业务场景下,使用客户端合成会优于服务端合成,避免造成不必要的服务器CPU浪费。 下面主要谈谈**客户端(canvas)**合成的过程。 实现思路 小程序端发起请求,获取需动态展示的数据; 利用canvas绘制画布; 导出图片保存到相册。 小技巧&那些坑 理想很丰满,现实很骨感。 实现思路很简单,然而,在实现过程中,发现会趟一些坑,也有一些小技巧,遂记录下来,以供参考。 promise化 画布的绘制依赖系统信息(自适应和优化图片清晰度)和动态数据。故画布需要在所有前置条件都准备完成时,方可绘制。为了提高代码优雅度和维护性,建议用promise化,避免回调地狱(Callback Hell)。 [代码] let promise1 = new Promise((resolve, reject) => { this.getData(resolve, reject) }); let promise2 = new Promise((resolve, reject) => { this.getSystemInfo(resolve, reject) }); Promise.all([promise1, promise2]).then(() => { this.drawCanvas() }).catch(err => { console.log(err) }); [代码] 自适应 1、为了在各个机型下保持大小自适应,需要计算出缩放比: [代码] getSystemInfo(resolve, reject) { try { const res = wx.getSystemInfoSync() //缓存系统信息 systemInfo = res //这里视觉基于iPone6(375*667)设计,2x图视觉,可以填写750,1x图视觉,可以填写375 zoom = res.windowWidth / 750 * 1 resolve() } catch (e) { // Do something when catch error reject("获取机型失败") } } [代码] 2、绘制时进行按缩放比进行缩放,如: [代码]ctx.drawImage(imgUrl, x * zoom, y * zoom, w * zoom, h * zoom) [代码] 绘制网络图片 经测试,绘制CDN图片需要先将图片下载到本地,在进行绘制: [代码]wx.downloadFile({ url: imgUrl, success: res => { if (res.statusCode === 200) { ctx.drawImage(res.tempFilePath, 326 * zoom, 176 * zoom, 14 * zoom, 14 * zoom) } } }) [代码] 绘制base64图片 因为业务上某些原因,依赖的图片数据,后端只能以base64格式返回给前端,而小程序在真机上无法直接绘制(开发工具OK)。 解决思路(存在兼容性问题,fileManager**基础库 1.9.9 **开始支持): 1、调用fileManager.writeFile存储base64到本地; 2、绘制本地图片。 实现代码如下: [代码]// 先获得一个文件实例 fileManager = wx.getFileSystemManager() // 把图片base64格式转存到本地,用于canvas绘制 fileManager.writeFile({ filePath: `${wx.env.USER_DATA_PATH}/qrcode.png`, data: self.data.qrcode, encoding: 'base64', success: () => { //此处需先调用wx.getImageInfo,方可绘制成功 wx.getImageInfo({ src: `${wx.env.USER_DATA_PATH}/qrcode.png`, success: () => { //绘制二维码 ctx.drawImage(`${wx.env.USER_DATA_PATH}/qrcode.png`, 207 * zoom, 313 * zoom, 148 * zoom, 148 * zoom) ctx.draw() } }) } }) [代码] 保存到本地相册 wx.saveImageToPhotosAlbum这个API需用户授权,故开发者需做好拒绝授权的兼容。此处实现对拒绝授权的场景进行引导。 [代码]canvas2Img(e) { wx.getSetting({ success(res) { if (res.authSetting['scope.writePhotosAlbum'] === undefined) { //呼起授权界面 wx.authorize({ scope: 'scope.writePhotosAlbum', success() { save() } }) } else if (res.authSetting['scope.writePhotosAlbum'] === false) { //引导拒绝过授权的用户授权 wx.showModal({ title: '温馨提示', content: '需要您授权保存到相册的权限', success: res => { if (res.confirm) { wx.openSetting({ success(res) { if (res.authSetting['scope.writePhotosAlbum']) { save() } } }) } } }) } else { save() } } }) function save() { wx.canvasToTempFilePath({ x: 0, y: 0, width: 562*zoom, height: 792*zoom, destWidth: 562*zoom*systemInfo.pixelRatio, destHeight: 792*zoom*systemInfo.pixelRatio, fileType: 'png', quality: 1, canvasId: 'shareImg', success: res => { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => { wx.showModal({ content: '保存成功', showCancel: false, confirmText: '确认' }) }, fail: res => { if (res.errMsg !== 'saveImageToPhotosAlbum:fail cancel') { wx.showModal({ content: '保存到相册失败', showCancel: false }) } } }) }, fail: () => { wx.showModal({ content: '保存到相册失败', showCancel: false }) } }) } [代码] 导出清晰图片 wx.canvasToTempFilePath有destWidth(输出的图片的宽度)和destHeight(输出的图片的高度)属性。若此处以canvas的宽高去填写的话,在高像素手机下,导出的图片会模糊。 原因:destWidth和destHeight单位是物理像素(pixel),canvas绘制的时候用的是逻辑像素(物理像素=逻辑像素 * density),所以这里如果只是使用canvas中的width和height(逻辑像素)作为输出图片的长宽的话,生成的图片width和height实际上是缩放了到canvas的 1 / density大小了,所以就显得比较模糊了。 这里应该乘以设备像素比,实现如下: [代码]wx.canvasToTempFilePath({ x: 0, y: 0, width: 562*zoom, height: 792*zoom, destWidth: 562*zoom*systemInfo.pixelRatio, destHeight: 792*zoom*systemInfo.pixelRatio, fileType: 'png', quality: 1, canvasId: 'shareImg' )} [代码] 特殊字体的绘制 研究发现,目前小程序canvas无法支持设置特殊字体,而业务生成的海报,又期望以特殊字体去呈现,最终取了个折中方案——保留数字部分的特殊样式。 实现方式为:把0-9这10个数字单独切图,用ctx.drawImage API,以图片形式去绘制。 [代码]drawNum(num, x, y, w, h) { return new Promise(function (resolve, reject) { //这里存储0-9的图片CDN链接 let numMap = [] wx.downloadFile({ url: numMap[num], success: res => { if (res.statusCode === 200) { ctx.drawImage(res.tempFilePath, x * zoom, y * zoom, w * zoom, h * zoom) resolve() } }, fail: () => { reject() } }) }) } [代码] 安卓机型图片绘制锯齿化问题 测试发现,同样的绘制方案,在安卓下,调用ctx.drawImage方法,图片会出现锯齿问题。测试还发现,原像素越高,锯齿化程度降低(但业务上使用太大像素的素材也不合理),这里需要客户端底层进行优化,目前没有找到合适的解决方案。 总结 个人觉得,目前小程序canvas就底层能力上相比web还有一些不足。所以应注意两点: 提前从业务出发,考虑当前实现的可行性,以便采取更优方案(如特殊字体,像素要求等); 若绘制canvas导出图片是个高频场景,可参考html2canvas进行封装,以便提高效能(SelectorQuery节点查询需1.9.90以上)。 ps:之前有想过利用web-view方式,在传统网页去绘制,然后通过web-view和小程序的通信来实现的方式。时间原因,并未尝试,感兴趣同学可以尝试下。
2019-03-07 - 【技巧】利用canvas生成朋友圈分享海报
大家好,上次给大家讲了函数防抖和函数节流https://developers.weixin.qq.com/community/develop/doc/0002c892fb80a8326bf70f56d5bc04 今天给大家分享一下利用canvas生成朋友圈分享海报 由于小程序的限制,我们不能很方便地在微信内直接分享小程序到朋友圈,所以普遍的做法是生成一张带有小程序分享码的分享海报,再将海报保存到手机相册,有两种方法可以生成分享海报,第一种是让后台生成然后返回图片链接,这一种方法比较简单,只需要传后台所需要的参数就行了,今天给大家介绍的是第二种方法,用canvas生成分享海报。 首先先来看下效果: [图片] 主要步骤: 1. 把海报样式用标签先写好,方便画图时可以比对 2. 用canvas进行画图,canvas要注意定好宽高 3. canvas利用wx.canvasToTempFilePath这个api将canvas转化为图片 4. 将转化好的图片链接放入image标签里 5. 再利用wx.saveImageToPhotosAlbum保存图片 这里有几个坑点需要注意下: 1. 用canvas进行画图的时候要注意画出来的图的大小一定要是你用标签写好那个样式的两倍大小,比如你的海报大小是400*600的大小,那你用canvas画的时候大小就要是800*1200,宽高可以写在样式里,如果你画出来的图跟你海报图是一样的大小的话生成的图片是会很模糊的,所以才需要放大两倍。 2. 画图的时候要注意尺寸的转化,如果你是用rpx做单位的话,就要对单位进行转化,因为canvas提供的方法都是经px为单位的,所以这一点要注意一下,px转rpx的公式是w/750*z*2,w是手机屏幕宽度screenWidth,可以通过wx.getSystemInfo获取,z是你需要画图的单位,2就是乘以两倍大小。 3. 图片来源问题,因为canvas不支持网络图片画图,所以你的图片要么是固定的,如果不是固定的,那就要用wx.downloadFile下载后得到一个临时路径才行 4. 小程序码问题,小程序需要后台请求接口后返回一个二进制的图片,因为二进制图片canvas也是不支持的,所以也是要用wx.downloadFile下载后得到一个临时路径,或者可以叫后台直接返回一个小程序码的路径给你 5. 这里保存的时候是有个授权提醒的,如果拒绝的话再次点击就没有反应了,所以这里我做了一个判断是否有授权的,如果没有就弹窗提醒,确认的话会打开设置页面,确认授权后再次返回就行了,这里有个坑注意下,就是之前拒绝后再进入设置页面确认授权返回页面时保存图片会不成功,官方还没解决,我是加了个setTimeOut处理的,详情可以看这里https://developers.weixin.qq.com/community/develop/doc/000c46600780f0fa68d7eac345a400 代码实现: [图片] 这里图片我先用的是网上的链接,实际项目中是后台返回的数据,这个可以自行处理,这里只是为了演示方便,生成临时路径的方法我这里是分别定义了一个方法,其实可以合成一个方法的,只是生成小程序码时如果要传入参数要注意一下。 绘图方法是drawImg,这里截一部分,详细的可以看代码片段 [图片] 不足: 由于在实际项目中返回的图片宽高是不固定的,但是canvas画出来的又需要固定宽高,所以分享图会有图片变形的问题,使用drawImage里的参数也不能解决,如果各位有比较好的方案可以一起讨论一下。 代码片段: https://developers.weixin.qq.com/s/3pcsjDmS7M5Y 系甘先,得闲饮茶
2019-01-23 - hooks 在微信小程序中的试验
PS:首先,这不是一个成熟的东西,只是一个实现极其简单的玩具而已。 前言 前段时间 react hooks 特性刷得沸沸扬扬的,看起来挺有意思的,估计不少其他框架也会逐步跟进,所以也来尝试一下能不能用在小程序上。 react hooks 允许你在函数式组件中使用 state,用一段官方的简单例子概括如下: [代码]import { useState } from 'react'; function Example() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); } [代码] 函数式组件本身非常简洁,不维护生命周期和状态,是一个可以让性能得以优化的使用方式。但是在之前这种方式只能用于纯展示组件或者高阶组件等,它很难实现一些交互行为。但是在 hooks 出现之后,你就可以为所欲为了。 这里有一份官方的文档,不明围观群众有兴趣的可以点进去了解一下:https://reactjs.org/docs/hooks-intro.html。 hooks 的使用目前有两个限制: 只能在函数式组件内或其他自定义 hooks 内使用,不允许在循环、条件或普通 js 函数中调用 hooks。 只能在顶层调用 hooks 。 这个限制和 hooks 的实现方式有关,下面小程序 hooks 也会有同样限制,原因应该也是类似的。为了能让开发者更好的使用 hooks,react 官方也提供了一套 eslint 插件来协助我们开发:https://reactjs.org/docs/hooks-rules.html#eslint-plugin。 下面就来介绍下在小程序中的尝试~ 函数式组件 小程序没有提供函数式组件,这倒是很好理解,小程序的架构是双线程运行模式,逻辑层执行 js 代码,视图层负责渲染。那么声明在逻辑层的自定义组件要渲染在视图层必须保证来两个线程都存在自定义组件实例并一一对应,这样的架构已经成熟,目前对函数式组件并没有强烈的需求。在基础库不大改的情况下,就算提供了函数式组件也只是提供了另一种新写法而已,本质上的实现没有区别也不能提升什么性能。 不过也不排除以后小程序会提供一种只负责渲染不维护生命周期不做任何逻辑的特殊组件来优化渲染性能,这种的话本质上就和函数式组件类似了,不过函数式组件较为极端的是在理论上是有办法做到无实例的,这个在小程序中怕是有点困难。 言归正传,小程序没有提供函数式组件,那么就强行封装出一个写法好了,假设我们有一个自定义组件,它的 js 和 wxml 内容分别是这样的: [代码]// component.js const {useState, useEffect, FunctionalComponent} = require('miniprogram-hooks') FunctionalComponent(function() { const [count, setCount] = useState(1) useEffect(() => { console.log('count update: ', count) }, [count]) const [title, setTitle] = useState('click') return { count, title, setCount, setTitle, } }) [代码] [代码]<!-- component.wxml --> <view>{{count}}</view> <button bindtap="setCount" data-arg="{{count + 1}}">{{title}}</button> <button bindtap="setTitle" data-arg="{{title + '(' + count + ')'}}">update btn text</button> [代码] 一个很奇葩的例子,但是能看明白就行。小程序里视图和逻辑分离,不像 react 可以将视图和逻辑写到一起,那么小程序里的函数式组件里想返回一串渲染逻辑就不太科学了,这里就改成返回要用于渲染的 state 和方法。 PS:wxml 里不支持 bindtap=“setCount(count + 1)” 这种写法,所以参数就走 dataset 的方式传入了。 FunctionComponent 函数其实就相当于封装了小程序原有的 Component 构造器,它的实现类似这样: [代码]function FunctionalComponent(func) { func = typeof func === 'function' ? func : function () {} // 定义自定义组件 return Component({ attached() { this._$state = {} this._$effect = {} this._$func = () => { currentCompInst = this // 记录当前的自定义组件实例 callIndex = 0 // 初始化调用序号 const newDef = func.call(null) || {} currentCompInst = null const {data, methods} = splitDef(newDef) // 拆分 state 和方法 // 设置 methods Object.keys(methods).forEach(key => { this[key] = methods[key] }) // 设置 data this.setData(data) } this._$func() }, detached() { this._$state = null this._$effect = null this._$func = null } }) } [代码] 实现很简单,就是在 attached 的时候跑一下传入的函数,拿到 state 和方法后设置到自定义组件实例上就行。其中 currentCompInst 和 callIndex 在 useState 和 useEffect 的实现上会用到,下面来介绍。 useState 和 useEffect 这里的一个难点是,useState 是没有指定变量名的。初次渲染还好,二次渲染的话要找回这个变量就要费一段代码了。 PS:后续的实现除了参考了 react 的 hooks 外,也参考了 vue-hooks 的尝试,有兴趣的同学也可以去观摩一下。 这里上面提到的 currentCompInst 和 callIndex,将上一次的变量存储在 currentCompInst 中,用 callIndex 记录调用 useState 和 useEffect 的顺序,这样就可以在二次渲染的时候通过顺序找回上一次使用的变量: [代码]function useState(initValue) { if (!currentCompInst) throw new Error('component instance not found!') const index = callIndex++ const compInst = currentCompInst if (compInst._$state[index] === undefined) compInst._$state[index] = initValue const updater = function (evt) { let value = evt // wxml 事件回调 if (typeof evt === 'object' && evt.target && evt.currentTarget) { const dataset = evt.currentTarget.dataset value = dataset && dataset.arg } // 存入缓存 compInst._$state[index] = value compInst._$func() } updater._isUpdater = true return [compInst._$state[index], updater] } [代码] useEffect 的实现逻辑也类似,这里就不再贴代码了。小程序本身没有提供 render 函数,调 FunctionalComponent 声明函数式组件传入的函数就作为 render 函数来用。每次调 setXXX 方法——也就是上面代码中返回的 updater 的时候,找到原本存储这个 state 的地方存储进去,然后再次执行 render 函数,进行组件的渲染。 到这里应该就明白了,对于 hooks 使用为什么会有一开始的那两条限制。如果在一些条件、循环等语句内使用 hooks,就无法确保 state 的顺序,再二次渲染时就不一定能找回对应的 state。 尾声 完整的代码在 https://github.com/wechat-miniprogram/miniprogram-hooks,不过这终究只是个试验性质的尝试,并不推荐拿来实战,写在这里是为与大家共享~
2019-02-27 - 跨界 - Omi 发布多端统一框架 Omip 打通小程序与 Web
Omip 今天,Omi 不仅仅可以开发桌面 Web、移动 H5,还可以直接开发小程序!直接开发小程序!直接开发小程序! → omijs.org → Github 地址 Omi 简介 Omi 框架是腾讯研发的下一代前端框架, 基于 Web Components 规范设计的组件化框架,可以开发 PC Web、移动端 H5,也可以直接使用 Omi 开发小程序。Omi 服务于腾讯的 H5 页面, PC 网站以及腾讯内部的一些管理系统和小程序等。自去年年底开源以来,该项目共获得 Star 数 7000+,贡献者 40+。Omi 借助京东 O2Team 优秀的 taro 多端统一框架,以及 Omi 开发团队和社区贡献者近期的共同努力,使 Omi 打通了小程序与 Web。细心的用户会发现,Omi 的 slogan 从 下一代 Web 框架 变更为 下一代前端框架, 因为 Omip 的加入,Omi 生于 Web 却能脱离 Web。 [图片] 同样的语法,同样的书写格式,运行在不同的平台、不同的环境,除了一些平台特有的API,几乎不用任何改动! 老的 Omi 项目做一些极其微小的改动(平台特性相关)就能跑在安卓/IOS的小程序里。 Learn Once, Write Anywhere Write Once, Run Anywhere [图片] Omip 特性 一次学习,多处开发,一次开发,多处运行 使用 JSX,表达能力和编程体验大于模板 支持使用 npm/yarn 安装管理第三方依赖 支持使用 ES6+ 支持使用 CSS 预编译器 小程序 API 优化,异步 API Promise 化 超轻量的依赖包,顺从小程序标签和组件的设计 快速开始 [代码]npm i omi-cli -g omi init-p my-app cd my-app npm start //开发小程序 npm run dev:h5 //开发 h5 npm run build:h5 //发布 h5 [代码] 把小程序目录设置到 dist 目录就可以愉快地调试了! node 版本要求 >= 8 也支持一条命令 [代码]npx omi-cli init-p my-app[代码] (npm v5.2.0+) 当然也支持 TypeScript: [代码]omi init-p-ts my-app [代码] TypeScript 的其他命令和上面一样,也支持小程序和 h5 SPA 开发。 Omip 多端示例 [图片] [图片] Omi 其他 [图片] [图片] 社区化发展,欢迎加入并贡献社区 目前 Omi 的贡献者遍布国内外各大公司(中国、韩国、美国、土耳其),Omi 共接受了快 40 位贡献者的文档和代码提交,核心贡献者共 11 名。欢迎有想法有能力有激情的开发者加入贡献者行列并最终能够进入 Omi Team。 你可以从这几个方面贡献: 1.翻译文档,目前有中文、英文和韩文,欢迎其他语言版本的翻译加入 2.提交补丁代码优化 Omi 3.积极参与 Issue 的讨论,如答疑解惑、提供想法或报告无法解决的错误 4.贡献案例,可以是管理后台、PC 网站、移动端 H5等等 5.完善文档,可以反复修正文档,让其更易懂,上手更快 6.扩展 Omi 生态,编写 Omi 自定义组件 7.分享与 Omi 的故事 8.写 Omi 相关的 blog 我们非常欢迎开发者们为腾讯开源贡献一份力量,相应也将给予贡献者激励以表认可与感谢。参见腾讯贡献者激励计划 Omi 交流群 欢迎加入Omi交流群,群聊号码:256426170,也可扫码加入: [图片] 感谢 感谢京东 O2Team taro 项目 感谢京东 O2Team taro 团队 Github https://github.com/Tencent/omi
2019-03-20