微信小程序云开发总结

2,583 阅读6分钟

开发背景

本人前端开发,工作以开发WEB中台项目为主,对于微信相关,之前接手过几个公众号h5和小程序,微信的api以及框架还算有所了解,但并不算有足够的经验,踩的坑还不够多。

近期公司要开发一个活动组织的微信小程序,基于前面的条件和开发时间并不是很充裕,和后端小伙伴沟通后,发现云开发非常符合我们目前的状况,于是准备撸起袖子,尝试一把!

优势

云开发介绍可以移步至此 微信云开发,这里简单说一下我们运用到的一些功能或者说是云开发便利我们开发的地方。

  1. 无需搭建服务器,快速构建小程序、公众号

    • 由于开发时间的关系,我们没有用到云开发的数据库(在时间以及对云开发框架熟悉并且业务不复杂的情况下,是可以运用到云开发的数据库,完全脱离后端,由前端小伙伴独自完成项目的开发),仍然是由后端小伙伴进行业务开发,然后构建容器,将其拖管至云开发
  2. 免登录、免鉴权调用微信开放服务

    • 云开发可以通过云函数直接调用微信开放api,免去了登录、鉴权等一些列复杂的前置操作,这一步大大的降低了开发的成本
  3. 云存储

    • 云开发提供了一块存储空间,提供了上传文件到云端、带权限管理的云端下载能力,前端开发可以在小程序端和云函数端通过 API 使用云存储功能。也就是说文件存储这一块功能可以由前端独自完成,只需将云存储返回的fileId传给后端写入到数据库即可

快速构建

官方文档都写的很清楚啦,这里就不赘述了。

云开发相关

云能力初始化

在小程序端开始使用云能力前,需要调用wx.cloud.init方法对云能力进行初始化

onLaunch: function () {
    if (!wx.cloud) {
      console.error('请使用 2.2.3 或以上的基础库以使用云能力')
    } else {
      wx.cloud.init({
        //   env 参数说明:
        //   env 参数决定接下来小程序发起的云开发调用(wx.cloud.xxx)会默认请求到哪个云环境的资源
        //   此处请填入环境 ID, 环境 ID 可打开云控制台查看
        //   如不填则使用默认环境(第一个创建的环境)
        env: envId,
        traceUser: true,
      })
    }
  }

调用云托管(后端接口调用)

后端服务是构建容器后直接托管至云开发的,所以小程序端调用云托管服务wx.cloud.callContainer即可调用到后端接口了,这里对调用方法做了简单封装。

request.js

import { envId, serverId } from './config'

const cloudRequest = async ({
  path,    // 请求地址
  method,  // 请求方法
  data     // 请求数据
}) => {
  try {
    const res = await wx.cloud.callContainer({
      config: {
        env: envId, // 环境id必填,不能为空
      },
      data,
      path,
      method,
      header: {
        'X-WX-SERVICE': serverId, // 填入服务名称(微信云托管 - 服务管理 - 服务列表 - 服务名称)
        'Cookie': wx.getStorageSync('cookies') || ''
      },
    })
    if (res.statusCode === 302) {
      // 登录失效,捕获302 header location
      const location = res.header.location.match(/tcloudbase.com(.*)/)[1]
      cloudRequest({
        path: location,
        method: 'GET'
      })
    } else {
      if (res.data.code === 0) {
        // 请求成功
        return res.header['set-cookie'] ? res : res.data
      } else if (res.data.code === 103) {
        // 登录失效,重定向至登录页面
        wx.navigateTo({
          url: '/pages/login/login',
        })
        setTimeout(() => {
          wx.showToast({
            icon: 'error',
            title: res.data.message,
            duration: 1500
          })
        }, 500)
      } else {
        // 接口报错、没有接口权限,统一提示错误信息
        wx.showToast({
          icon: 'none',
          title: res.data.message || 'error',
          duration: 3000
        })
        return res
      }
    }
  } catch (error) {
    // 捕获错误
    wx.showToast({
      icon: 'none',
      title: error.errMsg,
      duration: 3000
    })
    wx.navigateTo({
      url: '/pages/login/login',
    })
  }
}

export {
  cloudRequest
}

  1. 登录失效重定向: 在web中,用户请求接口,如果session失效的话,后端会发起重定向请求,然后根据重定向请求的错误code,前端再跳转至login页面,但是在小程序中,这种情况下重定向的请求不会自动发起,只是当前请求接口会返回302的状态码,这里我卡了很久,google也找不到类似问题。后来反复查看response里的内容,发现header里的location字段就是后端要发起的重定向请求地址,于是我便通过捕获302,匹配到location字段后面的地址之后,手动去发起重定向请求,最后再捕获后端返回的错误码103进行后续的操作

image.png

image.png

  1. cookie 小程序中是没有cookie一说的,所以需要去捕获接口中的cookie信息,然后手动将cookie信息保存至storage中,在每次接口请求的时候,再将storage中的cookie信息带上。
if (res.header&&res.header['set-cookie'] && res.header['set-cookie'].length > 0) {
  wx.setStorage({
     key: 'cookies',
     data: res.header['set-cookie'].split(';')[0]
  })
}
const res = await wx.cloud.callContainer({
      config: {
        env: envId, // 环境id必填,不能为空
      },
      data,
      path,
      method,
      header: {
        'X-WX-SERVICE': serverId, // 填入服务名称(微信云托管 - 服务管理 - 服务列表 - 服务名称)
        'Cookie': wx.getStorageSync('cookies') || ''
      },
    })

云存储

首先在云开发控制台-存储,建好需要用到的文件夹

image.png

我们的业务中只涉及到图片的上传,所以封装了一个图片上传的组件。

下面用到了腾讯云的图像处理功能(对上传的图片进行压缩),所以需要在腾讯云控制台-云开发CloudBase-扩展应用中,安装图像处理功能

image.png

picture-upload.wxml

<view class="picture-upload-comp">
  <block wx:for="{{fileList}}" wx:key="url">
    <view class="image-item">
      <image src="{{item.url}}" mode="heightFix" />
      <view wx:if="{{item.loading}}" class="loading-wrap">
        <van-loading />
      </view>
      <text wx:if="{{!item.loading && (type==='edit'||type==='leader')}}" class="fas fa-window-close" catchtap="onDelete" data-item="{{item}}"></text>
      <view wx:if="{{!item.loading && (type==='edit'||type==='leader')}}" class="bg"></view>
    </view>
  </block>
  <van-uploader wx:if="{{fileList.length<9 && (type==='edit'||type==='leader')}}" max-count="{{9-fileList.length}}" bind:after-read="afterRead" accept="image" multiple>
    <view class="upload-btn">
      <view class="line-top"></view>
      <view class="line-right"></view>
      <view class="line-bottom"></view>
      <view class="line-left"></view>
      <view class="line-vertical"></view>
      <view class="line-across"></view>
    </view>
  </van-uploader>
  <block wx:if="{{type==='view'}}">
    <view wx:for="{{fileList.length<=9&&fileList.length>0?fileList.length<=3?(3-fileList.length):3-fileList.length%3:0}}" wx:key="url" class="image-item"></view>
  </block>
  <block wx:else>
    <view wx:for="{{fileList.length<9&&fileList.length>0?(fileList.length+1)%3>0?(3-(fileList.length+1)%3):0:0}}" wx:key="url" class="image-item"></view>
  </block>
</view>

picture-upload.js

afterRead(event) {
      const {
        file
      } = event.detail
      file.forEach(item => {
        const {
          url
        } = item;
        const fileList = this.data.fileList
        // 文件名使用时间戳,保证其唯一性
        const filename = moment().valueOf() + '.png'
        // 先将图片展示出来,并loading
        fileList.push({
          filename,
          url,
          loading: true
        })
        // 更新fileList
        this.triggerEvent('onUpdate', {
          data: fileList
        })
        // 调用云开发上传方法,cloudPath即在存储中所建的文件夹路径
        wx.cloud.uploadFile({
          cloudPath: `${this.data.cloudPath}/` + filename, // 上传至云端的路径
          filePath: url, // 小程序临时文件路径
          success: res => {
            const fileList = this.data.fileList
            const {
              fileID
            } = res
            // 通过filename匹配在fileList中的数据,对其进行更新
            const file = fileList.filter(item => fileID.includes(item.filename))[0]
            file.loading = false
            file.fileId = fileID
            this.triggerEvent('onUpdate', {
              data: fileList
            })
            // 调用云函数imageCompression方法,对图片进行压缩处理
            wx.cloud.callFunction({
              name: 'quickstartFunctions',
              config: {
                env: this.data.envId
              },
              data: {
                type: 'imageCompression',
                fileID: `${this.data.cloudPath}/` + filename,
                cloudPath: `${this.data.cloudPath}/` + filename, // 上传至云端的路径
              }
            }).then((resp) => {
              console.log('compression调用成功')
            }).catch((e) => {
              console.log('compression调用失败')
            })
          },
          fail: res => {
            console.log(res)
            const {
              errCode,
              errMsg
            } = res
            wx.showToast({
              icon: 'error',
              title: `上传失败:${errCode}${errMsg}`,
            })
          }
        })
      })
    },

云函数imageCompression方法

/*图像压缩处理
1. tcb init doc:https://docs.cloudbase.net/api-reference/server/node/initialization
2. 图像处理文档:https://cloud.tencent.com/document/product/460/6929
*/
const extCi = require("@cloudbase/extension-ci");
const tcb = require("tcb-admin-node");

// 云函数下指定环境为当前的执行环境
tcb.init({
  env: tcb.getCurrentEnv()
})
tcb.registerExtension(extCi);

exports.main = async (event, context) => {
  const { cloudPath, fileID } = event
  try {
    const opts = {
      rules: [
        {
          // 处理结果的文件路径,如以’/’开头,则存入指定文件夹中,否则,存入原图文件存储的同目录
          fileid: fileID,
          // 处理样式参数,宽度500 高度自适应 品质50
          rule: "imageView2/2/width/500/q/50" 
        }
      ]
    };
    const res = await tcb.invokeExtension("CloudInfinite", {
      action: "ImageProcess",
      // 图像在云存储中的路径,与tcb.uploadFile中一致
      cloudPath, 
      // 该字段可选,有值,表示上传时处理图像;为空,则处理已经上传的图像
      // fileContent,
      operations: opts
    });
    return res
  } catch (err) {
    console.log(err)
    return res
  }
}

云函数

  1. 获取openid 创建云函数,在需要的地方调用云函数即可获取openid,简单方便,不需要再去做一些列复杂的操作了
const cloud = require('wx-server-sdk')

cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV
})

// 获取openId云函数入口函数
exports.main = async (event, context) => {
  // 获取基础信息
  const wxContext = cloud.getWXContext()

  return {
    openid: wxContext.OPENID,
    appid: wxContext.APPID,
    unionid: wxContext.UNIONID,
  }
}
  1. 获取小程序二维码(分享到朋友圈,生成分享图片时需要用到) 业务中有分享到朋友圈的需要,但是目前微信小程序分享朋友圈还处于beta版本,且只对Android开放 image.png

image.png

于是我们的解决方案是,生成分享图片后保存到用户相册,这种解决方案目前来说也是比较主流的。

这里参考了GavinJay的文章-------微信小程序之生成图片分享朋友圈

把坑说在前面,canvas的坑就是写法更新了,新的文档地址又不够明显,粗心的小伙伴很容易迷路

新写法文档地址

举个简单的 🌰

old:ctx.setFillStyle('red)
new:ctx.fillStyle='red'

image.png


言归正传,继续说分享朋友圈的功能

首先通过云函数获取小程序二维码

getMiniProgramCode.js

const cloud = require('wx-server-sdk')
import moment from '../../../miniprogram/miniprogram_npm/moment/index'

cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV
})

// 获取小程序二维码云函数入口函数
exports.main = async (event, context) => {
  // 获取小程序二维码的buffer
  const resp = await cloud.openapi.wxacode.get({
    path: event.path // 扫描二维码跳转的地址
  })
  const {
    buffer
  } = resp
  // 将图片上传云存储空间
  const upload = await cloud.uploadFile({
    cloudPath: `code/code${moment().unix()}.png`,
    fileContent: buffer
  })
  // 通过fileID获换取真实链接
  const temp = await cloud.getTempFileURL({
    fileList: [upload.fileID]
  })

  return temp.fileList[0].tempFileURL
}

获取到小程序二维码之后,通过canvas绘制出分享图片

share.wxml

<!-- 图片分享 -->
  <van-overlay show="{{ showShare }}" bind:click="onClickHideOverlay">
    <view class="share-image">
      <canvas type="2d" id="canvas"></canvas>
      <van-button type="default" catchtap="savePhoto">保存到相册</van-button>
    </view>
  </van-overlay>

share.js

  // 分享到朋友圈
  handleShareFriendCircle() {
    this.setData({
      showShareSelect: false
    })
    wx.showLoading({
      title: '生成分享图片...',
    })
    wx.cloud.callFunction({
      name: 'quickstartFunctions',
      config: {
        env: this.data.envId
      },
      data: {
        type: 'getMiniProgramCode',
        path: `/pages/activity/detail/activity-detail?id=${this.data.currentDataInfo.id}`
      }
    }).then((resp) => {
      this.setData({
        showShare: true,
        currentCode: resp.result
      })
      setTimeout(() => {
        // 通过 SelectorQuery 获取 Canvas 节点
        wx.createSelectorQuery()
          .select('#canvas')
          .fields({
            node: true,
            size: true,
          })
          .exec(this.init.bind(this))
      }, 500)
    }).catch((e) => {})
  }

绘制canvas方法

  // 初始化canvas
  init(res) {
    const width = res[0].width
    const height = res[0].height

    const canvas = res[0].node
    this._canvas = canvas
    const ctx = canvas.getContext('2d')

    const dpr = wx.getSystemInfoSync().pixelRatio
    canvas.width = width * dpr
    canvas.height = height * dpr
    ctx.scale(dpr, dpr)

    // 换取分享主图的真实链接
    wx.cloud.getTempFileURL({
      fileList: [this.data.currentDataInfo.headImage],
      success: res => {
        // get temp file URL
        if (res.fileList.length === 0) return
        
        // 生成主图节点
        const img = canvas.createImage()
        img.onload = () => {
          this._img = img
        }
        img.src = res.fileList[0].tempFileURL
        
        // 生成二维码图片节点
        const code = canvas.createImage()
        code.onload = () => {
          this._code = code
        }
        code.src = this.data.currentCode
        
        setTimeout(() => {
          // 开始绘制
          this.draw(ctx)
        }, 1000)
      },
      fail: err => {
        // handle error
      }
    })
  },

  // 绘制分享图片
  draw(ctx) {
  
    // 清除上一次此矩形区域内绘制的内容
    ctx.clearRect(0, 0, 300, 300)
    
    const {
      title,
      courseName,
      startTime,
      endTime,
      minNumber,
      maxNumber,
      content,
    } = this.data.currentDataInfo
    
    if (!this._img || !this._code) return
    
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, 300, 300);
    ctx.save()
    // 绘制主图
    ctx.drawImage(this._img, 0, 0, 300, 150)
    // 绘制二维码
    ctx.drawImage(this._code, 200, 155, 72, 72)
    // 标题
    ctx.textAlign = 'left'
    ctx.font = '18px sans-serif'
    ctx.fillStyle = '#333333'
    ctx.fillText(title.length > 8 ? title.substring(0, 7) : title, 16, 190)
    ctx.save()
    // 课程
    ctx.font = '12px sans-serif'
    ctx.fillStyle = '#666'
    ctx.fillText(`课程:${courseName}`, 16, 222)
    ctx.save()
    // 时间
    ctx.font = '12px sans-serif'
    ctx.fillStyle = '#666'
    ctx.fillText(`时间:${formatTimeMin(moment(startTime))} ~ ${formatTimeMin(moment(endTime))}`, 16, 244)
    ctx.save()
    // 人数
    ctx.font = '12px sans-serif'
    ctx.fillStyle = '#666'
    ctx.fillText(`人数:${minNumber} ~ ${maxNumber}`, 16, 266)
    ctx.save()
    // 内容
    ctx.font = '12px sans-serif'
    ctx.fillStyle = '#666'
    ctx.fillText(`描述:${content.length>20?content.substring(0,19)+'...':content}`, 16, 288)
    ctx.save()
    ctx.restore()
    
    // 将绘制好的图片转换成真实链接
    wx.canvasToTempFilePath({
      canvas: this._canvas,
      success: (res) => {
        wx.hideLoading()
        this.setData({
          tempFilePath: res.tempFilePath
        })
        
        // 保存至相册
        wx.saveImageToPhotosAlbum({
          filePath: res.tempFilePath,
          success: () => {
            wx.showModal({
              title: '保存图片成功',
              content: '图片已经保存到相册,快去分享吧!',
              showCancel: false,
              success: () => {
                this.setData({
                  showShare: false,
                  currentCode: '',
                  currentDataInfo: {},
                  tempFilePath: ''
                })
              },
            })
          }
        })
      }
    })
  },

最终效果

image.png

image.png

其他

  1. vant UI px单位转换rpx
/* 
* gulpfile.js
* vant ui框架是以px为单位的,这里将其px单位转换为rpx单位
*/


var gulp = require('gulp');
var postcss = require('gulp-postcss');
var pxtounits =  require('postcss-px2units');

gulp.task('vant-css', function () {
 return gulp.src(['miniprogram/miniprogram_npm/@vant/weapp/**/*.wxss','!miniprogram_npm/@vant/weapp/common/index.wxss'])
   .pipe(postcss([pxtounits({
     multiple: 2,
     targetUnits: 'rpx'
   })]))
   .pipe(gulp.dest('miniprogram/miniprogram_npm/@vant/weapp/'));
});

结尾

写给自己,最近发现日子过的浑浑噩噩,做项目从来不做总结,买了一堆书,也都是在家里吃灰,但是从各种平台可以看到比自己优秀又比自己努力的人,实属惭愧。这篇文章更多的不是分享,而是给自己敲响警钟,时刻保持学习的心态,不要让懒惰吞噬自己!