- 小程序·云开发实战 - 迷你微博
0. 前言 本文将手把手教你如何写出迷你版微博的一行行代码,迷你版微博包含以下功能: Feed 流:关注动态、所有动态 发送图文动态 搜索用户 关注系统 点赞动态 个人主页 使用到的云开发能力: 云数据库 云存储 云函数 云调用 没错,几乎是所有的云开发能力。也就是说,读完这篇实战,你就相当于完全入门了云开发! 咳咳,当然,实际上这里只是介绍核心逻辑和重点代码片段,完整代码建议下载查看。 1. 取得授权 作为一个社交平台,首先要做的肯定是经过用户授权,获取用户信息,小程序提供了很方便的接口: [代码]<button open-type="getUserInfo" bindgetuserinfo="getUserInfo"> 进入小圈圈 </button> [代码] 这个 [代码]button[代码] 有个 [代码]open-type[代码] 属性,这个属性是专门用来使用小程序的开放能力的,而 [代码]getUserInfo[代码] 则表示 获取用户信息,可以从[代码]bindgetuserinfo[代码]回调中获取到用户信息。 于是我们可以在 wxml 里放入这个 [代码]button[代码] 后,在相应的 js 里写如下代码: [代码]Page({ ... getUserInfo: function(e) { wx.navigateTo({ url: "/pages/circle/circle" }) }, ... }) [代码] 这样在成功获取到用户信息后,我们就能跳转到迷你微博页面了。 需要注意,不能使用 [代码]wx.authorize({scope: "scope.userInfo"})[代码] 来获取读取用户信息的权限,因为它不会跳出授权弹窗。目前只能使用上面所述的方式实现。 2. 主页设计 社交平台的主页大同小异,主要由三个部分组成: Feed 流 消息 个人信息 那么很容易就能想到这样的布局(注意新建一个 Page 哦,路径:[代码]pages/circle/circle.wxml[代码]): [代码]<view class="circle-container"> <view style="display:{{currentPage === 'main' ? 'block' : 'none'}}" class="main-area" > </view> <view style="display:{{currentPage === 'msg' ? 'flex' : 'none'}}" class="msg-area" > </view> <view style="display:{{currentPage === 'me' ? 'flex' : 'none'}}" class="me-area" > </view> <view class="footer"> <view class="footer-item"> <button class="footer-btn" bindtap="onPageMainTap" style="background: {{currentPage === 'main' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'main' ? '#fff' : '#000'}}" > 首页 </button> </view> <view class="footer-item"> <button class="footer-btn" bindtap="onPageMsgTap" style="background: {{currentPage === 'msg' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'msg' ? '#fff' : '#000'}}" > 消息 </button> </view> <view class="footer-item"> <button class="footer-btn" bindtap="onPageMeTap" style="background: {{currentPage === 'me' ? '#111' : 'rgba(0,0,0,0)'}}; color: {{currentPage === 'me' ? '#fff' : '#000'}}" > 个人 </button> </view> </view> </view> [代码] 很好理解,画面主要被分为上下两个部分:上面的部分是主要内容,下面的部分是三个 Tab 组成的 Footer。重点 WXSS 实现(完整的 WXSS 可以下载源码查看): [代码].footer { box-shadow: 0 0 15rpx #ccc; display: flex; position: fixed; height: 120rpx; bottom: 0; width: 100%; flex-direction: row; justify-content: center; z-index: 100; background: #fff; } .footer-item { display: flex; justify-content: center; align-items: center; height: 100%; width: 33.33%; color: #333; } .footer-item:nth-child(2) { border-left: 3rpx solid #aaa; border-right: 3rpx solid #aaa; flex-grow: 1; } .footer-btn { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; border-radius: 0; font-size: 30rpx; } [代码] 核心逻辑是通过 [代码]position: fixed[代码] 来让 Footer 一直在下方。 读者会发现有一个 [代码]currentPage[代码] 的 data ,这个 data 的作用其实很直观:通过判断它的值是 [代码]main[代码]/[代码]msg[代码]/[代码]me[代码] 中的哪一个来决定主要内容。同时,为了让首次使用的用户知道自己在哪个 Tab,Footer 中相应的 [代码]button[代码] 也会从白底黑字黑底白字,与另外两个 Tab 形成对比。 现在我们来看看 [代码]main[代码] 部分的代码(在上面代码的基础上扩充): [代码]... <view class="main-header" style="display:{{currentPage === 'main' ? 'flex' : 'none'}};max-height:{{mainHeaderMaxHeight}}" > <view class="group-picker-wrapper"> <picker bindchange="bindGroupPickerChange" value="{{groupArrayIndex}}" range="{{groupArray}}" class="group-picker" > <button class="group-picker-inner"> {{groupArray[groupArrayIndex]}} </button> </picker> </view> <view class="search-btn-wrapper"> <button class="search-btn" bindtap="onSearchTap">搜索用户</button> </view> </view> <view class="main-area" style="display:{{currentPage === 'main' ? 'block' : 'none'}};height: {{mainAreaHeight}};margin-top:{{mainAreaMarginTop}}" > <scroll-view scroll-y class="main-area-scroll" bindscroll="onMainPageScroll"> <block wx:for="{{pageMainData}}" wx:for-index="idx" wx:for-item="itemName" wx:key="_id" > <post-item is="post-item" data="{{itemName}}" class="post-item-wrapper" /> </block> <view wx:if="{{pageMainData.length === 0}}" class="item-placeholder" >无数据</view > </scroll-view> <button class="add-poster-btn" bindtap="onAddPosterTap" hover-class="add-poster-btn-hover" style="bottom:{{addPosterBtnBottom}}" > + </button> </view> ... [代码] 这里用到了 列表渲染 和 条件渲染,还不清楚的可以点击进去学习一下。 可以看到,相比之前的代码,我添加一个 header,同时 [代码]main-area[代码] 的内部也新增了一个 [代码]scroll-view[代码](用于展示 Feed 流) 和一个 [代码]button[代码](用于编辑新迷你微博)。header 的功能很简单:左侧区域是一个 [代码]picker[代码],可以选择查看的动态类型(目前有 关注动态 和 所有动态 两种);右侧区域是一个按钮,点击后可以跳转到搜索页面,这两个功能我们先放一下,先继续看 [代码]main-area[代码] 的新增内容。 [代码]main-area[代码] 里的 [代码]scroll-view[代码] 是一个可监听滚动事件的列表,其中监听事件的实现: [代码]data: { ... addPosterBtnBottom: "190rpx", mainHeaderMaxHeight: "80rpx", mainAreaHeight: "calc(100vh - 200rpx)", mainAreaMarginTop: "80rpx", }, onMainPageScroll: function(e) { if (e.detail.deltaY < 0) { this.setData({ addPosterBtnBottom: "-190rpx", mainHeaderMaxHeight: "0", mainAreaHeight: "calc(100vh - 120rpx)", mainAreaMarginTop: "0rpx" }) } else { this.setData({ addPosterBtnBottom: "190rpx", mainHeaderMaxHeight: "80rpx", mainAreaHeight: "calc(100vh - 200rpx)", mainAreaMarginTop: "80rpx" }) } }, ... [代码] 结合 wxml 可以知道,当页面向下滑动 (deltaY < 0) 时,header 和 [代码]button[代码] 会 “突然消失”,反之它们则会 “突然出现”。为了视觉上有更好地过渡,我们可以在 WXSS 中使用 [代码]transition[代码] : [代码]... .main-area { position: relative; flex-grow: 1; overflow: auto; z-index: 1; transition: height 0.3s, margin-top 0.3s; } .main-header { position: fixed; width: 100%; height: 80rpx; background: #fff; top: 0; left: 0; display: flex; justify-content: space-around; align-items: center; z-index: 100; border-bottom: 3rpx solid #aaa; transition: max-height 0.3s; overflow: hidden; } .add-poster-btn { position: fixed; right: 60rpx; box-shadow: 5rpx 5rpx 10rpx #aaa; display: flex; justify-content: center; align-items: center; color: #333; padding-bottom: 10rpx; text-align: center; border-radius: 50%; font-size: 60rpx; width: 100rpx; height: 100rpx; transition: bottom 0.3s; background: #fff; z-index: 1; } ... [代码] 3. Feed 流 3.1 post-item 前面提到,[代码]scroll-view[代码] 的内容是 Feed 流,那么首先就要想到使用 列表渲染。而且,为了方便在个人主页复用,列表渲染中的每一个 item 都要抽象出来。这时就要使用小程序中的 Custom-Component 功能了。 新建一个名为 [代码]post-item[代码] 的 [代码]Component[代码],其中 wxml 的实现(路径:[代码]pages/circle/component/post-item/post-item.js[代码]): [代码]<view class="post-item" hover-class="post-item-hover" bindlongpress="onItemLongTap" bindtap="onItemTap" > <view class="post-title"> <view class="author" hover-class="author-hover" catchtap="onAuthorTap" >{{data.author}}</view > <view class="date">{{data.formatDate}}</view> </view> <view class="msg-wrapper"> <text class="msg">{{data.msg}}</text> </view> <view class="image-outer" wx:if="{{data.photoId !== ''}}" catchtap="onImgTap"> <image-wrapper is="image-wrapper" src="{{data.photoId}}" /> </view> </view> [代码] 可见,一个 [代码]poster-item[代码] 最主要有以下信息: 作者名 发送时间 文本内容 图片内容 其中,图片内容因为是可选的,所以使用了 条件渲染,这会在没有图片信息时不让图片显示区域占用屏幕空间。另外,图片内容主要是由 [代码]image-wrapper[代码] 组成,它也是一个 [代码]Custom-Component[代码],主要功能是: 强制长宽 1:1 裁剪显示图片 点击查看大图 未加载完成时显示 加载中 具体代码这里就不展示了,比较简单,读者可以在 [代码]component/image-wrapper[代码] 里找到。 回过头看 [代码]main-area[代码] 的其他新增部分,细心的读者会发现有这么一句: [代码]<view wx:if="{{pageMainData.length === 0}}" class="item-placeholder" >无数据</view > [代码] 这会在 Feed 流暂时没有获取到数据时给用户一个提示。 3.2 collections: poster、poster_users 展示 Feed 流的部分已经编写完毕,现在就差实际数据了。根据上一小节 [代码]poster-item[代码] 的主要信息,我们可以初步推断出一条迷你微博在 云数据库 的 collection [代码]poster[代码] 里是这样存储的: [代码]{ "username": "Tester", "date": "2019-07-22 12:00:00", "text": "Ceshiwenben", "photo": "xxx" } [代码] 先来看 [代码]username[代码]。由于社交平台一般不会限制用户的昵称,所以如果每条迷你微博都存储昵称,那将来每次用户修改一次昵称,就要遍历数据库把所有迷你微博项都改一遍,相当耗费时间,所以我们不如存储一个 [代码]userId[代码],并另外把 id 和 昵称 的对应关系存在另一个叫 [代码]poster_users[代码] 的 collection 里。 [代码]{ "userId": "xxx", "name": "Tester", ...(其他用户信息) } [代码] [代码]userId[代码] 从哪里拿呢?当然是通过之前已经授权的获取用户信息接口拿到了,详细操作之后会说到。 接下来是 [代码]date[代码],这里最好是服务器时间(因为客户端传过来的时间可能会有误差),而云开发文档里也有提供相应的接口:serverDate。这个数据可以直接被 [代码]new Date()[代码] 使用,可以理解为一个 UTC 时间。 [代码]text[代码] 即文本信息,直接存储即可。 [代码]photo[代码] 则表示附图数据,但是限于小程序 [代码]image[代码] 元素的实现,想要显示一张图片,要么提供该图片的 url,要么提供该图片在 云存储 的 id,所以这里最佳的实践是:先把图片上传到云存储里,然后把回调里的文件 id 作为数据存储。 综上所述,最后 [代码]poster[代码] 每一项的数据结构如下: [代码]{ "authorId": "xxx", "date": "utc-format-date", "text": "Ceshiwenben", "photoId": "yyy" } [代码] 确定数据结构后,我们就可以开始往 collection 添加数据了。但是,在此之前,我们还缺少一个重要步骤。 3.3 用户信息录入 与 云数据库 没错,我们还没有在 [代码]poster_users[代码] 里添加一条新用户的信息。这个步骤一般在 [代码]pages/circle/circle[代码] 页面首次加载时判断即可: [代码]getUserId: function(cb) { let that = this var value = this.data.userId || wx.getStorageSync("userId") if (value) { if (cb) { cb(value) } return value } wx.getSetting({ success(res) { if (res.authSetting["scope.userInfo"]) { wx.getUserInfo({ withCredentials: true, success: function(userData) { wx.setStorageSync("userId", userData.signature) that.setData({ userId: userData.signature }) db.collection("poster_users") .where({ userId: userData.signature }) .get() .then(searchResult => { if (searchResult.data.length === 0) { wx.showToast({ title: "新用户录入中" }) db.collection("poster_users") .add({ data: { userId: userData.signature, date: db.serverDate(), name: userData.userInfo.nickName, gender: userData.userInfo.gender } }) .then(res => { console.log(res) if (res.errMsg === "collection.add:ok") { wx.showToast({ title: "录入完成" }) if (cb) cb() } }) .catch(err => { wx.showToast({ title: "录入失败,请稍后重试", image: "/images/error.png" }) wx.navigateTo({ url: "/pages/index/index" }) }) } else { if (cb) cb() } }) } }) } else { wx.showToast({ title: "登陆失效,请重新授权登陆", image: "/images/error.png" }) wx.navigateTo({ url: "/pages/index/index" }) } } }) } [代码] 代码实现比较复杂,整体思路是这样的: 判断是否已存储了 [代码]userId[代码],如果有直接返回并调用回调函数,如果没有继续 2 通过 [代码]wx.getSetting[代码] 获取当前设置信息 如果返回里有 [代码]res.authSetting["scope.userInfo"][代码] 说明已经授权读取用户信息,继续 3,没有授权的话就跳转回首页重新授权 调用 [代码]wx.getUserInfo[代码] 获取用户信息,成功后提取出 [代码]signature[代码](这是每个微信用户的唯一签名),并调用 [代码]wx.setStorageSync[代码] 将其缓存 调用 [代码]db.collection().where().get()[代码] ,判断返回的数据是否是空数组,如果不是说明该用户已经录入(注意 [代码]where()[代码] 中的筛选条件),如果是说明该用户是新用户,继续 5 提示新用户录入中,同时调用 [代码]db.collection().add()[代码] 来添加用户信息,最后通过回调判断是否录入成功,并提示用户 不知不觉我们就使用了云开发中的 云数据库 功能,紧接着我们就要开始使用 云存储 和 云函数了! 3.4 addPoster 与 云存储 发送新的迷你微博,需要一个编辑新迷你微博的界面,路径我定为 [代码]pages/circle/add-poster/add-poster[代码]: [代码]<view class="app-poster-container"> <view class="body"> <view class="text-area-wrapper"> <textarea bindinput="bindTextInput" placeholder="在此填写" value="{{text}}" auto-focus="true" /> <view class="text-area-footer"> <text>{{remainLen}}/140</text> </view> </view> <view bindtap="onImageTap" class="image-area"> <view class="image-outer"> <image-wrapper is="image-wrapper" src="{{imageSrc}}" placeholder="选择图片上传" /> </view> </view> </view> <view class="footer"> <button class="footer-btn" bindtap="onSendTap">发送</button> </view> </view> [代码] wxml 的代码很好理解:[代码]textarea[代码] 显示编辑文本,[代码]image-wrapper[代码] 显示需要上传的图片,最下面是一个发送的 [代码]button[代码]。其中,图片编辑区域的 [代码]bindtap[代码] 事件实现: [代码]onImageTap: function() { let that = this wx.chooseImage({ count: 1, success: function(res) { const tempFilePaths = res.tempFilePaths that.setData({ imageSrc: tempFilePaths[0] }) } }) } [代码] 直接通过 [代码]wx.chooseImage[代码] 官方 API 获取本地图片的临时路径即可。而当发送按钮点击后,会有如下代码被执行: [代码]onSendTap: function() { if (this.data.text === "" && this.data.imageSrc === "") { wx.showModal({ title: "错误", content: "不能发送空内容", showCancel: false, confirmText: "好的" }) return } const that = this wx.showLoading({ title: "发送中", mask: true }) const imageSrc = this.data.imageSrc if (imageSrc !== "") { const finalPath = imageSrc.replace("//", "/").replace(":", "") wx.cloud .uploadFile({ cloudPath: finalPath, filePath: imageSrc // 文件路径 }) .then(res => { that.sendToDb(res.fileID) }) .catch(error => { that.onSendFail() }) } else { that.sendToDb() } }, sendToDb: function(fileId = "") { const that = this const posterData = { authorId: that.data.userId, msg: that.data.text, photoId: fileId, date: db.serverDate() } db.collection("poster") .add({ data: { ...posterData } }) .then(res => { wx.showToast({ title: "发送成功" }) wx.navigateBack({ delta: 1 }) }) .catch(error => { that.onSendFail() }) .finally(wx.hideLoading()) } [代码] 首先判断文本和图片内容是否都为空,如果是则不执行发送,如果不是继续 2 提示发送中,上传图片到云存储,注意需要将图片中的临时 url 的一些特殊字符组合替换一下,原因见 文件名命名限制 上传成功后,调用 [代码]db.collection().add()[代码],发送成功后退回上一页(即首页),如果失败则执行 [代码]onSendFail[代码] 函数,后者见源码,逻辑较简单这里不赘述 于是,我们就这样创建了第一条迷你微博。接下来就让它在 Feed 流中显示吧! 3.5 云函数 getMainPageData 这个函数的主要作用如前所述,就是通过处理云数据库中的数据,将最终数据返回给客户端,后者将数据可视化给用户。我们先做一个初步版本,因为现在 [代码]poster_users[代码] 中只有一条数据,所以仅先展示自己的迷你微博。[代码]getMainPageData[代码] 云函数代码如下: [代码]// 云函数入口文件 const cloud = require("wx-server-sdk") cloud.init() const db = cloud.database() // 云函数入口函数 exports.main = async (event, context, cb) => { // 通过 event 获取入参 const userId = event.userId let followingResult let users // idNameMap 负责存储 userId 和 name 的映射关系 let idNameMap = {} let followingIds = [] // 获取用户信息 followingResult = await db .collection("poster_users") .where({ userId: userId }) .get() users = followingResult.data followingIds = users.map(u => { return u.userId }) users.map(u => { idNameMap[u.userId] = u.name }) // 获取动态 const postResult = await db .collection("poster") .orderBy("date", "desc") .where({ // 通过高级筛选功能筛选出符合条件的 userId authorId: db.command.in(followingIds) }) .get() const postData = postResult.data // 向返回的数据添加 存储用户昵称的 author 属性、存储格式化后的时间的 formatDate 属性 postData.map(p => { p.author = idNameMap[p.authorId] p.formatDate = new Date(p.date).toLocaleDateString("zh-Hans", options) }) return postData } [代码] 最后在 [代码]pages/circle/circle.js[代码] 里补充云调用: [代码]getMainPageData: function(userId) { const that = this wx.cloud .callFunction({ name: "getMainPageData", data: { userId: userId, isEveryOne: that.data.groupArrayIndex === 0 ? false : true } }) .then(res => { that.setData({ pageMainData: res.result, pageMainLoaded: true }) }) .catch(err => { wx.showToast({ title: "获取动态失败", image: "/images/error.png" }) wx.hideLoading() }) } [代码] 即可展示 Feed 流数据给用户。 之后,[代码]getMainPageData[代码] 还会根据使用场景的不同,新增了查询所有用户动态、查询关注用户动态的功能,但是原理是一样的,看源码可以轻易理解,后续就不再说明。 4. 关注系统 上一节中我们一口气把云开发中的大部分主要功能:云数据库、云存储、云函数、云调用都用了一遍,接下来其他功能的实现也基本都依赖它们。 4.1 poster_user_follows 首先我们需要建一个新的 collection [代码]poster_user_follows[代码],其中的每一项数据的数据结构如下: [代码]{ "followerId": "xxx", "followingId": "xxx" } [代码] 很简单,[代码]followerId[代码] 表示关注人,[代码]followingId[代码] 表示被关注人。 4.2 user-data 页面 关注或者取消关注需要进入他人的个人主页操作,我们在 [代码]pages/circle/user-data/user-data.wxml[代码] 中放一个 [代码]user-info[代码] 的自定义组件,然后新建该组件编辑: [代码]<view class="user-info"> <view class="info-item" hover-class="info-item-hover">用户名: {{userName}}</view> <view class="info-item" hover-class="info-item-hover" bindtap="onPosterCountTap">动态数: {{posterCount}}</view> <view class="info-item" hover-class="info-item-hover" bindtap="onFollowingCountTap">关注数: {{followingCount}}</view> <view class="info-item" hover-class="info-item-hover" bindtap="onFollowerCountTap">粉丝数: {{followerCount}}</view> <view class="info-item" hover-class="info-item-hover" wx:if="{{originId && originId !== '' && originId !== userId}}"><button bindtap="onFollowTap">{{followText}}</button></view> </view> [代码] 这里注意条件渲染的 [代码]button[代码]:如果当前访问个人主页的用户 id (originId) 和 被访问的用户 id (userId)的值是相等的话,这个按钮就不会被渲染(自己不能关注/取消关注自己)。 我们重点看下 [代码]onFollowTap[代码] 的实现: [代码]onFollowTap: function() { const that = this // 判断当前关注状态 if (this.data.isFollow) { wx.showLoading({ title: "操作中", mask: true }) wx.cloud .callFunction({ name: "cancelFollowing", data: { followerId: this.properties.originId, followingId: this.properties.userId } }) .then(res => { wx.showToast({ title: "取消关注成功" }) that.setData({ isFollow: false, followText: "关注" }) }) .catch(error => { wx.showToast({ title: "取消关注失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } else if (this.data.isFollow !== undefined) { wx.showLoading({ title: "操作中", mask: true }) const data = { followerId: this.properties.originId, followingId: this.properties.userId } db.collection("poster_user_follows") .add({ data: { ...data } }) .then(res => { wx.showToast({ title: "关注成功" }) that.setData({ isFollow: true, followText: "取消关注" }) }) .catch(error => { wx.showToast({ title: "关注失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } } } [代码] 这里读者可能会有疑问:为什么关注的时候直接调用 [代码]db.collection().add()[代码] 即可,而取消关注却要调用云函数呢?这里涉及到云数据库的设计问题:删除多个数据的操作,或者说删除使用 [代码]where[代码] 筛选的数据,只能在服务端执行。如果确实想在客户端删除,则在查询用户关系时,将唯一标识数据的 [代码]_id[代码] 用 [代码]setData[代码] 存下来,之后再使用 [代码]db.collection().doc(_id).delete()[代码] 删除即可。这两种实现方式读者可自行选择。当然,还有一种实现是不实际删除数据,只是加个 [代码]isDelete[代码] 字段标记一下。 查询用户关系的实现很简单,云函数的实现方式如下: [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() // 云函数入口函数 exports.main = async(event, context) => { const followingResult = await db.collection("poster_user_follows") .where({ followingId: event.followingId, followerId: event.followerId }).get() return followingResult } [代码] 客户端只要检查返回的数据长度是否大于 0 即可。 另外附上 [代码]user-data[代码] 页面其他数据的获取云函数实现: [代码]// 云函数入口文件 const cloud = require("wx-server-sdk") cloud.init() const db = cloud.database() async function getPosterCount(userId) { return { value: (await db.collection("poster").where({ authorId: userId }).count()).total, key: "posterCount" } } async function getFollowingCount(userId) { return { value: (await db.collection("poster_user_follows").where({ followerId: userId }).count()).total, key: "followingCount" } } async function getFollowerCount(userId) { return { value: (await db.collection("poster_user_follows").where({ followingId: userId }).count()).total, key: "followerCount" } } async function getUserName(userId) { return { value: (await db.collection("poster_users").where({ userId: userId }).get()).data[0].name, key: "userName" } } // 云函数入口函数 exports.main = async (event, context) => { const userId = event.userId const tasks = [] tasks.push(getPosterCount(userId)) tasks.push(getFollowerCount(userId)) tasks.push(getFollowingCount(userId)) tasks.push(getUserName(userId)) const allData = await Promise.all(tasks) const finalData = {} allData.map(d => { finalData[d.key] = d.value }) return finalData } [代码] 很好理解,客户端获取返回后直接使用即可。 5. 搜索页面 这部分其实很好实现。关键的搜索函数实现如下: [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() const MAX_LIMIT = 100 async function getDbData(dbName, whereObj) { const totalCountsData = await db.collection(dbName).where(whereObj).count() const total = totalCountsData.total const batch = Math.ceil(total / 100) const tasks = [] for (let i = 0; i < batch; i++) { const promise = db .collection(dbName) .where(whereObj) .skip(i * MAX_LIMIT) .limit(MAX_LIMIT) .get() tasks.push(promise) } const rrr = await Promise.all(tasks) if (rrr.length !== 0) { return rrr.reduce((acc, cur) => { return { data: acc.data.concat(cur.data), errMsg: acc.errMsg } }) } else { return { data: [], errMsg: "empty" } } } // 云函数入口函数 exports.main = async (event, context) => { const text = event.text const data = await getDbData("poster_users", { name: { $regex: text } }) return data } [代码] 这里参考了官网所推荐的分页检索数据库数据的实现(因为搜索结果可能有很多),筛选条件则是正则模糊匹配关键字。 搜索页面的源码路径是 [代码]pages/circle/search-user/search-user[代码],实现了点击搜索结果项跳转到对应项的用户的 [代码]user-data[代码] 页面,建议直接阅读源码理解。 6. 其他扩展 6.1 poster_likes 与 点赞 由于转发、评论、点赞的原理基本相同,所以这里只介绍点赞功能如何编写,另外两个功能读者可以自行实现。 毫无疑问我们需要新建一个 collection [代码]poster_likes[代码],其中每一项的数据结构如下: [代码]{ "posterId": "xxx", "likeId": "xxx" } [代码] 这里的 [代码]posterId[代码] 就是 [代码]poster[代码] collection 里每条记录的 [代码]_id[代码] 值,[代码]likeId[代码] 就是 [代码]poster_users[代码] 里的 [代码]userId[代码] 了。 然后我们扩展一下 [代码]poster-item[代码] 的实现: [代码]<view class="post-item" hover-class="post-item-hover" bindlongpress="onItemLongTap" bindtap="onItemTap"> ... <view class="interact-area"> <view class="interact-item"> <button class="interact-btn" catchtap="onLikeTap" style="color:{{liked ? '#55aaff' : '#000'}}">赞 {{likeCount}}</button> </view> </view> </view> [代码] 即,新增一个 [代码]interact-area[代码],其中 [代码]onLikeTap[代码] 实现如下: [代码]onLikeTap: function() { if (!this.properties.originId) return const that = this if (this.data.liked) { wx.showLoading({ title: "操作中", mask: true }) wx.cloud .callFunction({ name: "cancelLiked", data: { posterId: this.properties.data._id, likeId: this.properties.originId } }) .then(res => { wx.showToast({ title: "取消成功" }) that.refreshLike() that.triggerEvent('likeEvent'); }) .catch(error => { wx.showToast({ title: "取消失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } else { wx.showLoading({ title: "操作中", mask: true }) db.collection("poster_likes").add({ data: { posterId: this.properties.data._id, likeId: this.properties.originId } }).then(res => { wx.showToast({ title: "已赞" }) that.refreshLike() that.triggerEvent('likeEvent'); }) .catch(error => { wx.showToast({ title: "赞失败", image: "/images/error.png" }) }) .finally(wx.hideLoading()) } } [代码] 细心的读者会发现这和关注功能原理几乎是一样的。 6.2 数据刷新 我们可以使用很多方式让主页面刷新数据: [代码]onShow: function() { wx.showLoading({ title: "加载中", mask: true }) const that = this function cb(userId) { that.refreshMainPageData(userId) that.refreshMePageData(userId) } this.getUserId(cb) } [代码] 第一种是利用 [代码]onShow[代码] 方法:它会在页面每次从后台转到前台展示时调用,这个时候我们就能刷新页面数据(包括 Feed 流和个人信息)。但是这个时候用户信息可能会丢失,所以我们需要在 [代码]getUserId[代码] 里判断,并将刷新数据的函数们整合起来,作为回调函数。 第二种是让用户手动刷新: [代码]onPageMainTap: function() { if (this.data.currentPage === "main") { this.refreshMainPageData() } this.setData({ currentPage: "main" }) } [代码] 如图所示,当目前页面是 Feed 流时,如果再次点击 首页 Tab,就会强制刷新数据。 第三种是关联数据变更触发刷新,比如动态类型选择、删除了一条动态以后触发数据的刷新。这种可以直接看源码学习。 6.3 首次加载等待 当用户第一次进入主页面时,我们如果想在 Feed 流和个人信息都加载好了再允许用户操作,应该如何实现? 如果是类似 Vue 或者 React 的框架,我们很容易就能想到属性监控,如 [代码]watch[代码]、[代码]useEffect[代码] 等等,但是小程序目前 [代码]Page[代码] 并没有提供属性监控功能,怎么办? 除了自己实现,还有一个方法就是利用 [代码]Component[代码] 的 [代码]observers[代码],它和上面提到的属性监控功能差不多。虽然官网文档对其说明比较少,但摸索了一番还是能用来监控的。 首先我们来新建一个 [代码]Component[代码] 叫 [代码]abstract-load[代码],具体实现如下: [代码]// pages/circle/component/abstract-load.js Component({ properties: { pageMainLoaded: { type: Boolean, value: false }, pageMeLoaded: { type: Boolean, value: false } }, observers: { "pageMainLoaded, pageMeLoaded": function (pageMainLoaded, pageMeLoaded) { if (pageMainLoaded && pageMeLoaded) { this.triggerEvent("allLoadEvent") } } } }) [代码] 然后在 [代码]pages/circle/circle.wxml[代码] 中添加一行: [代码]<abstract-load is="abstract-load" pageMainLoaded="{{pageMainLoaded}}" pageMeLoaded="{{pageMeLoaded}}" bind:allLoadEvent="onAllLoad" /> [代码] 最后实现 [代码]onAllLoad[代码] 函数即可。 另外,像这种没有实际展示数据的 [代码]Component[代码],建议在项目中都用 [代码]abstract[代码] 开头来命名。 6.4 scroll-view 在 iOS 的 bug 如果读者使用 iOS 系统调试这个小程序,可能会发现 Feed 流比较短的时候,滚动 [代码]scroll-view[代码] header 和 [代码]button[代码] 会有鬼畜的上下抖动现象,这是因为 iOS 自己实现的 WebView 对于滚动视图有回弹的效果,而该效果也会触发滚动事件。 对于这个 bug,官方人员也表示暂时无法修复,只能先忍一忍了。 6.5 关于消息 Tab 读者可能会疑惑我为什么没有讲解消息 Tab 以及消息提醒的实现。首先是因为源码没有这个实现,其次是我觉得目前云开发所提供的能力实现主动提醒比较麻烦(除了轮询想不到其他办法)。 希望未来云开发可以提供 数据库长连接监控 的功能,这样通过订阅者模式可以很轻松地获取到数据更新的状态,主动提醒也就更容易实现了。到那时我可能会再更新相关源码。 6.6 关于云函数耗时 读者可能会发现我有一个叫 [代码]benchmark[代码] 的云函数,这个函数只是做了个查询数据库的操作,目的在于计算查询耗时。 诡异的是,我前天在调试的时候,发现查询一次需要1秒钟,而写这篇文章时却不到100ms。建议在一些需要多次操作数据库的函数配置里,把超时时间设置长一点吧。目前云函数的性能不太稳定。 7. 结语 那么关于迷你版微博开发实战介绍就到此为止了,更多资料可以直接下载源码查看哦。 源码链接 https://github.com/TencentCloudBase/Good-practice-tutorial-recommended
2019-07-24 - 【文章】优秀文章推送
前言 开发小程序时间不久,看的文章可能大家都看过了,所以此文就送给在小程序开发路上刚刚起跑的一些小伙伴,大佬勿喷 文章来源各大社区或博客 来自掘金 [译] 前端项目中常见的 CSS 问题 [译]一行css代码搞定响应式布局 📝你本可以少写些 if-else 小程序开发经验:多页面数据同步 --腾讯IVWEB团队 手把手教会你小程序登录鉴权 用wxDraw.js制作酷炫的小程序canvas动画『wxDraw 小程序界的zrender』 小程序多端框架全面测评 --凹凸实验室 来自知乎 有用!关于微信小程序,那些开发文档没有告诉你的 开发“小程序”必备书单 来自简书 微信小程序资源整理 小程序的常用居中弹性布局样式整理 最后 看过的文章真的不多,还得加油啊😥!就先分享这些,如果还行记的点赞哦!后期有文章在加吧 欢迎在评论区将你的好文分享一下
2019-06-21 - 微信小程序码获取-从频繁失败到成功率100%
早期实现方案 1. 方案实现 通过微信的appSecret获取小程序accessToken并缓存 微信小程序上很多操作都需要使用accessToken,比如用户授权手机号,当然也包括获取小程序码 通过微信提供的api获取到对应的小程序码,由于http接口直接返回的是图片本身,所以考虑将图片上传七牛服务器并获取图片链接,最后使用图片的链接来展示或保存小程序码 2. 方案优点 由于上传了小程序码,对于一些跳转固定页面和参数的码可以将图片链接存到数据库,以供用户下次分享使用,无需重复获取 3. 存在的问题 稳定性很差,获取小程序码的失败率比较高,甚至会出现一个时间段内完全获取不到码的情况 接口效率不好,由于每次都会存在图片上传,而且上传本身又比较耗时,导致服务器压力巨大且频繁出现慢接口,可能会影响到项目中的其他服务 改造后方案 1. 方案实现 获取小程序码后不再上传七牛,直接通过图片流的方式返回给前端 2. 方案优点 取消了图片的上传操作,接口效率大幅提升,提高了小程序码的获取成功率,也减轻了服务的压力 3. 存在的问题 依旧存在小程序码获取失败的情况 4. 问题排查 经排查日志发现是accessToken失效导致,缓存的accessToken失效时间远比微信规定的失效时间短,那究竟又是什么情况会导致accessToken失效呢?经讨论和实验发现以下三点: 我们微信的appSecret授权给第三方网站使用(比如阿拉丁),他们也有获取小程序码的服务,运营可以通过阿拉丁获取小程序码,这就会导致阿拉丁使用我们的appSecret获取accessToken,以至于我们缓存中的accessToken失效。 后端缓存中的accessToken存入和获取的逻辑存在缺陷,每当从缓存读取accessToken时,若缓存不命中,则通过微信api获取新的accessToken然后再存入缓存,这个逻辑容易导致缓存穿透,即当多个请求都没有命中缓存时,只有一个线程能通过微信api拿到新的accessToken,其他线程都拿不到。 当一个accessToken存在时间比较长时,手动调用微信api获取小程序码,会看到微信的api也会存在概率获取不到码的情况,但是一个全新生成的accessToken则不会有这种情况,至少在10分钟之内非常稳定。 最终的方案 1. 方案实现 通知运营不要再使用阿拉丁的生成小程序码的功能,若有这方面需求可以找技术帮忙获取。 缓存中的accessToken有效时间缩短至5分钟,保证每次使用的accessToken都能稳定获取小程序码。 修改accessToken的获取机制,由定时器来获取accessToken并更新缓存,定时器每4分钟执行一次,以确保每个请求都能命中缓存,若定时器出现异常,则回退之前的逻辑(请求没有命中缓存,通过微信api重新获取accessToken)。 最终效果 这一个方案上线后,线上再也没有出现小程序码没有获取成功的情况,观察日志也没再出现获取失败的情况,目前已经两周保持100%成功率了。 [图片]
2019-06-04 - [填坑手册]小程序Canvas生成海报(一)--完整流程
[图片] 海报生成示例 最近智酷君在做[小程序]canvas生成海报的项目中遇到一些棘手的问题,在网上查阅了各种资料,也踩扁了各种坑,智酷君希望把这些“填坑”经验整理一下分享出来,避免后来的兄弟重复“掉坑”。 [图片] 原型图 这是一个大致的原型图,下面来看下如何制作这个海报,以及整体的思路。 [图片] 海报生成流程 [代码片段]Canvas生成海报实战demo demo的微信路径:https://developers.weixin.qq.com/s/Q74OU3m57c9x demo的ID:Q74OU3m57c9x 如果你装了IDE工具,可以直接访问上面的demo路径 通过代码片段将demo的ID输入进去也可添加: [图片] [图片] 下面分享下主要的代码内容和“填坑现场”: 一、添加字体 https://developers.weixin.qq.com/miniprogram/dev/api/canvas/font.html [代码]canvasContext.font = value //示例 ctx.font = `normal bold 20px sans-serif`//设置字体大小,默认10 ctx.setTextAlign('left'); ctx.setTextBaseline("top"); ctx.fillText("《智酷方程式》专注研究和分享前端技术", 50, 15, 250)//绘制文本 [代码] 符合 CSS font 语法的 DOMString 字符串,至少需要提供字体大小和字体族名。默认值为 10px sans-serif 文字过长在canvas下换行问题处理(最多两行,超过“…”代替) [代码]ctx.setTextAlign('left'); ctx.setFillStyle('#000');//文字颜色:默认黑色 ctx.font = `normal bold 18px sans-serif`//设置字体大小,默认10 let canvasTitleArray = canvasTitle.split(""); let firstTitle = ""; //第一行字 let secondTitle = ""; //第二行字 for (let i = 0; i < canvasTitleArray.length; i++) { let element = canvasTitleArray[i]; let firstWidth = ctx.measureText(firstTitle).width; //console.log(ctx.measureText(firstTitle).width); if (firstWidth > 260) { let secondWidth = ctx.measureText(secondTitle).width; //第二行字数超过,变为... if (secondWidth > 260) { secondTitle += "..."; break; } else { secondTitle += element; } } else { firstTitle += element; } } //第一行文字 ctx.fillText(firstTitle, 20, 278, 280)//绘制文本 //第二行问题 if (secondTitle) { ctx.fillText(secondTitle, 20, 300, 280)//绘制文本 } [代码] 通过 ctx.measureText 这个方法可以判断文字的宽度,然后进行切割。 (一行字允许宽度为280时,判断需要写小点,比如260) 二、获取临时地址并设置图片 [代码]let mainImg = "https://demo.com/url.jpg"; wx.getImageInfo({ src: mainImg,//服务器返回的图片地址 success: function (res) { //处理图片纵横比例过大或者过小的问题!!! let h = res.height; let w = res.width; let setHeight = 280, //默认源图截取的区域 setWidth = 220; //默认源图截取的区域 if (w / h > 1.5) { setHeight = h; setWidth = parseInt(280 / 220 * h); } else if (w / h < 1) { setWidth = w; setHeight = parseInt(220 / 280 * w); } else { setHeight = h; setWidth = w; }; console.log(setWidth, setHeight) ctx.drawImage(res.path, 0, 0, setWidth, setHeight, 20, 50, 280, 220); ctx.draw(true); }, fail: function (res) { //失败回调 } }); [代码] 在开发过程中如果封面图无法按照约定的比例(280x220)给到: 那么我们就需要处理默认封面图过大或者过小的问题,大致思路是:代码中通过比较纵横比(280/220=1.27)正比例放大或者缩小原图,然后从左上切割,竟可能保证过高的图是宽度100%,过宽的图是高度100%。 在canvas中draw图片,必须是一个(相对)本地路径,我们可以通过将图片保存在本地后生成的临时路径。 微信官方提供两个API: wx.downloadFile(OBJECT)和wx.getImageInfo(OBJECT)。都需先配置download域名才能生效。 三、裁切“圆形”头像画图 [代码]ctx.save(); //保存画图板 ctx.beginPath()//开始创建一个路径 ctx.arc(35, 25, 15, 0, 2 * Math.PI, false)//画一个圆形裁剪区域 ctx.clip()//裁剪 ctx.closePath(); ctx.drawImage(headImageLocal, 20, 10, 30, 30); ctx.draw(true); ctx.restore()//恢复之前保存的绘图上下文 [代码] 使用图形上下文的不带参数的clip()方法来实现Canvas的图像裁剪功能。该方法使用路径来对Canvas话不设置一个裁剪区域。因此,必须先创建好路径。创建完整后,调用clip()方法来设置裁剪区域。 需要注意的是裁剪是对画布进行的,裁切后的画布不能恢复到原来的大小,也就是说画布是越切越小的,要想保证最后仍然能在canvas最初定义的大小下绘图需要注意save()和restore()。画布是先裁切完了再进行绘图。并不一定非要是图片,路径也可以放进去~ 小程序 canvas 裁切BUG [代码]ctx.setFillStyle("#fff"); ctx.fillRect(0, 0, 320, 500); //第一个填充矩形 wx.downloadFile({ url: headUri, success(res) { ctx.beginPath() ctx.arc(50, 50, 25, 0, 2 * Math.PI) ctx.clip() ctx.drawImage(res.tempFilePath, 25, 25); //第二个填充图片 ctx.draw() ctx.restore() ctx.setFillStyle("#fff"); ctx.fillRect(0, 0, 320, 500); ctx.draw(true) ctx.restore() } }) [代码] clip裁切这个功能,如果有超过一张图片/背景叠加,则裁切效果失效。 错误参考:http://html51.com/info-38753-1/ 四、将canvas导出成虚拟地址 [代码]wx.canvasToTempFilePath({ fileType: 'jpg', canvasId: 'customCanvas', success: (res) => { console.log(res.tempFilePath) //为canvas的虚拟地址 } }) res: { errMsg: "canvasToTempFilePath:ok", tempFilePath: "http://tmp/wx02935bb29080a7b4.o6zAJswFAuZuKQ5NZfPr….cGnD1a02PlVC0b3284be3a41d08986c2477579a5fd8e.jpg" } [代码] 这里需要把canvas里面的内容,导出成一个临时地址才能保存在相册,比如: http://tmp/wx02935bb29080a7b4.o6zAJswFAuZuKQ5NZfPr5UfJVR4k.cGnD1a02PlVC0b3284be3a41d08986c2477579a5fd8e.jpg 五、询问并获取访问手机本地相册权限 [代码]wx.getSetting({ success(res) { console.log(res) if (!res.authSetting['scope.writePhotosAlbum']) { //判断权限 wx.authorize({ //获取权限 scope: 'scope.writePhotosAlbum', success() { console.log('授权成功') //转化路径 self.saveImg(); } }) } else { self.saveImg(); } } }) [代码] 判断是否有访问相册的权限,如果没有,则请求权限。 六、保存到用户手机本地相册 [代码]wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: function (data) { wx.showToast({ title: '保存到系统相册成功', icon: 'success', duration: 2000 }) }, fail: function (err) { console.log(err); if (err.errMsg === "saveImageToPhotosAlbum:fail auth deny") { console.log("当初用户拒绝,再次发起授权") wx.openSetting({ success(settingdata) { console.log(settingdata) if (settingdata.authSetting['scope.writePhotosAlbum']) { console.log('获取权限成功,给出再次点击图片保存到相册的提示。') } else { console.log('获取权限失败,给出不给权限就无法正常使用的提示') } } }) } else { wx.showToast({ title: '保存失败', icon: 'none' }); } }, complete(res) { console.log(res); } }) [代码] 保存到本地需要一定的时间,需要加一个loading的状态。 七、关于组件中引用canvas [代码]let ctx = wx.createCanvasContext('posterCanvas',this); //需要加this [代码] 在components中canvas无法选中的问题: 在components自定义组件下,当前组件实例的this,表示在这个自定义组件下查找拥有 canvas-id 的 <canvas> ,如果省略则不在任何自定义组件内查找。
2021-09-13 - 小程序富文本能力的深入研究与应用
前言 在开发小程序的过程中,很多时候会需要使用富文本内容,然而现有的方案都有着或多或少的缺陷,如何更好的显示富文本将是一个值得继续探索的问题。 [图片] 现有方案 WxParse [代码]WxParse[代码] 作为一个应用最为应用最广泛的富文本插件,在很多时候是大家的首选,但其也明显的存在许多问题。 格式不正确时标签会被原样显示 很多人可能都见到过这种情况,当标签里的内容出现格式上的错误(如冒号不匹配等),在[代码]WxParse[代码]中都会被认为是文本内容而原样输出,例如:[代码]<span style="font-family:"宋体"">Hello World!</span> [代码] 这是由于[代码]WxParse[代码]的解析脚本中,是通过正则匹配的方式进行解析,一旦格式不正确,就将导致无法匹配而被直接认为是文本[代码]//WxParse的匹配模式 var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/, endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/, attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; [代码] 然而,[代码]html[代码] 对于格式的要求并不严格,一些诸如冒号不匹配之类的问题是可以被浏览器接受的,因此需要在解析脚本的层面上提高容错性。 超过限定层数时无法显示 这也是一个让许多人十分苦恼的问题,[代码]WxParse[代码] 通过 [代码]template[代码] 迭代的方式进行显示,当节点的层数大于设定的 [代码]template[代码] 数时就会无法显示,自行增加过多的层数又会大大增加空间大小,因此对于 [代码]wxml[代码] 的渲染方式也需要改进。 对于表格、列表等复杂内容支持性差 [代码]WxParse[代码] 对于 [代码]table[代码]、[代码]ol[代码]、[代码]ul[代码] 等支持性较差,类似于表格单元格合并,有序列表,多层列表等都无法渲染 rich-text [代码]rich-text[代码] 组件作为官方的富文本组件,也是很多人选择的方案,但也存在着一些不足之处 一些常用标签不支持 [代码]rich-text[代码] 支持的标签较少,一些常用的标签(比如 [代码]section[代码])等都不支持,导致其很难直接用于显示富文本内容 ps:最新的 2.7.1 基础库已经增加支持了许多语义化标签,但还是要考虑低版本兼容问题 不能实现图片和链接的点击 [代码]rich-text[代码] 组件中会屏蔽所有结点事件,这导致无法实现图片点击预览,链接点击效果等操作,较影响体验 不支持音视频 音频和视频作为富文本的重要内容,在 [代码]rich-text[代码] 中却不被支持,这也严重影响了使用体验 共同问题 不支持解析 [代码]style[代码] 标签 现有的方案中都不支持对 [代码]style[代码] 标签中的内容进行解析和匹配,这将导致一些标签样式的不正确 [图片] 方案构建 因此要解决上述问题,就得构建一个新的方案来实现 渲染方式 对于该节点下没有图片、视频、链接等的,直接使用 [代码]rich-text[代码] 显示(可以减少标签数,提高渲染效果),否则则继续进行深入迭代,例如: [图片] 对于迭代的方式,有以下两种方案: 方案一 像 [代码]WxParse[代码] 那样通过 [代码]template[代码] 进行迭代,对于小于 20 层的内容,通过 [代码]template[代码] 迭代的方式进行显示,超过 20 层时,用 [代码]rich-text[代码] 组件兜底,避免无法显示,这也是一开始采用的方案[代码]<!--超过20层直接使用rich-text--> <template name='rich-text-floor20'> <block wx:for='{{nodes}}' wx:key> <rich-text nodes="{{item}}" /> </block> </template> [代码] 方案二 添加一个辅助组件 [代码]trees[代码],通过组件递归的方式显示,该方式实现了没有层数的限制,且避免了多个重复性的 [代码]template[代码] 占用空间,也是最终采取的方案[代码]<!--继续递归--> <trees wx:else id="node" class="{{item.name}}" style="{{item.attrs.style}}" nodes="{{item.children}}" controls="{{controls}}" /> [代码] 解析脚本 从 [代码]htmlparser2[代码] 包进行改写,其通过状态机的方式取代了正则匹配,有效的解决了容错性问题,且大大提升了解析效率 [代码]//不同状态各通过一个函数进行判断和状态跳转 for (; this._index < this._buffer.length; this._index++) this[this._state](this._buffer[this._index]); [代码] 兼容 [代码]rich-text[代码] 为了解析结果能同时在 [代码]rich-text[代码] 组件上显示,需要对一些 [代码]rich-text[代码]不支持的组件进行转换[代码]//以u标签为例 case 'u': name = 'span'; attrs.style = 'text-decoration:underline;' + attrs.style; break; [代码] 适配渲染需要 在渲染过程中,需要对节点下含有图片、视频、链接等不能由 [代码]rich-text[代码]直接显示的节点继续迭代,否则直接使用 [代码]rich-text[代码] 组件显示;因此需要在解析过程中进行标记,遇到 [代码]img[代码]、[代码]video[代码]、[代码]a[代码] 等标签时,对其所有上级节点设置一个 [代码]continue[代码] 属性用于区分[代码]case 'a': attrs.style = 'color:#366092;display:inline;word-break:break-all;overflow:auto;' + attrs.style; element.continue = true; //冒泡:对上级节点设置continue属性 this._bubbling(); break; [代码] 处理style标签 解析方式 方案一 正则匹配[代码]var classes = style.match(/[^\{\}]+?\{([^\{\}]*?({[\s\S]*?})*)*?\}/g); [代码] 缺陷: 当 [代码]style[代码] 字符串较长时,可能出现栈溢出的问题 对于一些复杂的情况,可能出现匹配失败的问题 方案二 状态机的方式,类似于 [代码]html[代码] 字符串的处理方式,对于 [代码]css[代码] 的规则进行了调整和适配,也是目前采取的方案 匹配方式 方案一 将 [代码]style[代码] 标签解析为一个形如 [代码]{key:content}[代码] 的结构体,对于每个标签,通过访问结构体的相应属性即可知晓是否匹配成功[代码]if (this._style[name]) attrs.style += (';' + this._style[name]); if (this._style['.' + attrs.class]) attrs.style += (';' + this._style['.' + attrs.class]); if (this._style['#' + attrs.id]) attrs.style += (';' + this._style['#' + attrs.id]); [代码] 优点:匹配效率高,适合前端对于时间和空间的要求 缺点:对于多层选择器等复杂情况无法处理 因此在前端组件包中采取的是这种方式进行匹配 方案二 将 [代码]style[代码] 标签解析为一个数组,每个元素是形如 [代码]{key,list,content,index}[代码] 的结构体,主要用于多层选择器的匹配,内置了一个数组 [代码]list[代码] 存储各个层级的选择器,[代码]index[代码] 用于记录当前的层数,匹配成功时,[代码]index++[代码],匹配成功的标签出栈时,[代码]index--[代码];通过这样的方式可以匹配多层选择器等更加复杂的情况,但匹配过程比起方案一要复杂的多。 [图片] 遇到的问题 [代码]rich-text[代码] 组件整体的显示问题 在显示过程中,需要把 [代码]rich-text[代码] 作为整体的一部分,在一些情况下会出现问题,例如: [代码]Hello <rich-text nodes="<div style='display:inline-block'>World!</div>"/> [代码] 在这种情况下,虽然对 [代码]rich-text[代码] 中的顶层 [代码]div[代码] 设置了 [代码]display:inline-block[代码],但没有对 [代码]rich-text[代码] 本身进行设置的情况下,无法实现行内元素的效果,类似的还有 [代码]float[代码]、[代码]width[代码](设置为百分比时)等情况 解决方案 方案一 用一个 [代码]view[代码] 包裹在 [代码]rich-text[代码] 外面,替代最外层的标签[代码]<view style="{{item.attrs.style}}"> <rich-text nodes="{{item.children}}" /> </view> [代码] 缺陷:当该标签为 [代码]table[代码]、[代码]ol[代码] 等功能性标签时,会导致错误 方案二 对 [代码]rich-text[代码] 组件使用最外层标签的样式[代码]<rich-text nodes="{{item}}" style="{{item.attrs.style}}" /> [代码] 缺陷:当该标签的 [代码]style[代码] 中含有 [代码]margin[代码]、[代码]padding[代码] 等内容时会被缩进两次 方案三 通过 [代码]wxs[代码] 脚本将顶层标签的 [代码]display[代码]、[代码]float[代码]、[代码]width[代码] 等样式提取出来放在 [代码]rich-text[代码] 组件的 [代码]style[代码] 中,最终解决了这个问题[代码]var res = ""; var reg = getRegExp("float\s*:\s*[^;]*", "i"); if (reg.test(style)) res += reg.exec(style)[0]; reg = getRegExp("display\s*:\s*([^;]*)", "i"); if (reg.test(style)) { var info = reg.exec(style); res += (';' + info[0]); display = info[1]; } else res += (';display:' + display); reg = getRegExp("[^;\s]*width\s*:\s*[^;]*", "ig"); var width = reg.exec(style); while (width) { res += (';' + width[0]); width = reg.exec(style); } return res; [代码] 图片显示的问题 在 [代码]html[代码] 中,若 [代码]img[代码] 标签没有设置宽高,则会按照原大小显示;设置了宽或高,则按比例进行缩放;同时设置了宽高,则按设置的宽高进行显示;在小程序中,若通过 [代码]image[代码] 组件模拟,需要通过 [代码]bindload[代码] 来获取图片宽高,再进行 [代码]setData[代码],当图片数量较大时,会大大降低性能;另外,许多图片的宽度会超出屏幕宽度,需要加以限制 解决方案 用 [代码]rich-text[代码] 中的 [代码]img[代码] 替代 [代码]image[代码] 组件,实现更加贴近 [代码]html[代码] 的方式 ;对 [代码]img[代码] 组件设置默认的效果 [代码]max-width:100%;[代码] 视频显示的问题 当一个页面出现过多的视频时,同时进行加载可能导致页面卡死 解决方案 在解析过程中进行计数,若视频数量超过3个,则用一个 [代码]wxss[代码] 绘制的图片替代 [代码]video[代码] 组件,当受到点击时,再切换到 [代码]video[代码] 组件并设置 [代码]autoplay[代码] 以模拟正常效果,实现了一个类似懒加载的功能 [代码]<!--视频--> <view wx:if="{{item.attrs.id>'media3'&&!controls[item.attrs.id].play}}" class="video" data-id="{{item.attrs.id}}" bindtap="_loadVideo"> <view class="triangle_border_right"></view> </view> <video wx:else src='{{controls[item.attrs.id]?item.attrs.source[controls[item.attrs.id].index]:item.attrs.src}}' id="{{item.attrs.id}}" autoplay="{{item.attrs.autoplay||controls[item.attrs.id].play}}" /> [代码] 文本复制的问题 小程序中只有 [代码]text[代码] 组件可以通过设置 [代码]selectable[代码] 属性来实现长按复制,在富文本组件中实现这一功能就存在困难 解决方案 在顶层标签上加上 [代码]user-select:text;-webkit-user-select[代码] [图片] 实现更加丰富的功能 在此基础上,还可以实现更多有用的功能 自动设置页面标题 在浏览器中,会将 [代码]title[代码] 标签中的内容设置到页面标题上,在小程序中也同样可以实现这样的功能[代码]if (res.title) { wx.setNavigationBarTitle({ title: res.title }) } [代码] 多资源加载 由于平台差异,一些媒体文件在不同平台可能兼容性有差异,在浏览器中,可以通过 [代码]source[代码] 标签设置多个源,当一个源加载失败时,用下一个源进行加载和播放,在本插件中同样可以实现这样的功能[代码]errorEvent(e) { //尝试加载其他源 if (!this.data.controls[e.currentTarget.dataset.id] && e.currentTarget.dataset.source.length > 1) { this.data.controls[e.currentTarget.dataset.id] = { play: false, index: 1 } } else if (this.data.controls[e.currentTarget.dataset.id] && e.currentTarget.dataset.source.length > (this.data.controls[e.currentTarget.dataset.id].index + 1)) { this.data.controls[e.currentTarget.dataset.id].index++; } this.setData({ controls: this.data.controls }) this.triggerEvent('error', { target: e.currentTarget, message: e.detail.errMsg }, { bubbles: true, composed: true }); }, [代码] 添加加载提示 可以在组件的插槽中放入加载提示信息或动画,在加载完成后会将 [代码]slot[代码] 的内容渐隐,将富文本内容渐显,提高用户体验,避免在加载过程中一片空白。 最终效果 经过一个多月的改进,目前实现了这个功能丰富,无层数限制,渲染效果好,轻量化(30.0KB),效率高,前后端通用的富文本插件,体验小程序的用户数已经突破1k啦,欢迎使用和体验 [图片] github 地址 npm 地址 总结 以上就是我在开发这样一个富文本插件的过程大致介绍,希望对大家有所帮助;本人在校学生,水平所限,不足之处欢迎提意见啦! [图片]
2020-12-27 - js异步编程
前言 我们都知道,JS是单线程执行的,天生异步。在开发的过程中会遇到很多异步的场景,只用回调来处理简单的异步逻辑,当然是可以,但是逻辑逐渐复杂起来,回调的处理方式显得力不从心。 接下来会介绍js中处理异步的方式,通过对比了解各自的原理以及优缺点,帮助我们更好的使用这些强大的异步处理方式。 回调 基本用法 回调函数作为参数传进方法中,在合适的时机被调用。 比如调用ajax,或是使用定时器: [代码] // ajax请求 $.ajax({ url: '/ajax/hdportal_h.jsp?cmd=xxx', error: function(err) { console.log(err) }, success: function(data) { console.log(data) } }) // 定时器的回调 setTimeout(function callback() { console.log('hi') }, 1000) [代码] 回调的问题 1. 回调地狱 过深的嵌套,形成回调地狱 使得代码难以阅读和调试 层层嵌套,代码间耦合严重,牵一发而动全身 2.信任缺失,错误处理无法保证 控制反转,回调函数的调用是在请求函数内部,无法保证回调函数一定会被正确调用,回调本身没有错误处理机制,需要额外设计。 可能存在以下问题: 调用回调过早 调用回调过晚 调用回调次数太多或者太少 未能把所需的参数成功传给你的回调函数 吞掉可能出现的错误或异常 Promise 基本用法 Promise对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败) 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。 [代码] new Promise((resovle, reject) => { setTimeout(() => { resovle('hello promise') }, 1000) }).then(res => { console.log(res) }).catch(err => { console.log(err) }) [代码] Promise与回调的区别 Promise 不是对回调的替代。 Promise 在回调代码和将要执行这个任务的异步代码之间提供了一种可靠的中间机制来管理回调 Promise 并没有完全摆脱回调。它们只是改变了传递回调的位置。我们并不是把回调传递给处理函数,而是从处理函数得到Promise,然后把回调传给这个Promise Promise 保证了行为的一致性,使其变得可信任,我们传递的回调会被正确的执行 Promise如何解决信任缺失问题? 调用时机上,不会调用过早,也不会调用过晚 根据PromiseA+规范,then中的回调会在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。 这个事件队列可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。 所以提供给then的回调也总会在JavaScript事件队列的当前运行完成后,再被调用,即异步调用。 [代码] var p = Promise.resolve('p'); console.log('A'); p.then(function () { p.then(function () { console.log('E'); }); console.log('C'); }) .then(function () { console.log('D'); }); console.log('B'); [代码] 运行这段代码,会依次打印出ABCED 这里要注意两个点: 会先执行同步代码,再执行then中的代码 then执行回调时,打印D的代码晚于打印E的代码 调用次数上,不会出现回调未调用,也不会出现调用次数太多或者太少 一个Promise注册了一个成功回调和拒绝回调,那么Promise在决议的时候总会调用其中一个。 即使是在决议后调用then注册的回调函数,也会被正确调用,所以不会出现回调未调用的情况。 Promise只能被决议一次。如果处于多种原因,Promise创建代码试图调用多次resolve(…)或reject(…),或者试图两者都调用,那么这个Promise将只会接受第一次决议,忽略任何后续调用,所以调用次数不会太多也不会太少。 错误处理上,不会吞掉可能出现的错误或异常 如果在Promise的创建过程中或在查看其决议结果的过程中的任何时间点上,出现了一个JavaScript异常错误,比如一个TypeError或ReferenceError,这个异常都会被捕捉,并且会使这个Promise被拒绝。 [代码] var p = new Promise(function (resolve, reject) { foo.bar(); // foo未定义 resolve(2); }); p.then(function (data) { console.log(data); // 永远也不会到达这里 }, function (err) { console.log('出错了', err); // err将会是一个TypeError异常对象来自foo.bar()这一行 }); [代码] Promise中的then then方法的设计是promise中最重要的部分之一,可以看promise/A+规范中对then方法的描述 then方法会返回一个新的promise,因此可以链式调用,下面的代码会打印出6 [代码] var p = Promise.resolve(0); p.then(function (data) { return 1; }).then(function (data) { return data + 2; }).then(function (data) { return data + 3; }).then(function (data) { console.log(data); }); [代码] 如果在then中主动返回一个promise,依旧会返回一个新的promise,只是这个promise的状态“跟随”主动返回的pormise [代码] var p1 = new Promise(function (resolve, reject) { resolve('p1'); }); var p2 = new Promise(function (resolve, reject) { resolve('p2'); }); var p3 = p2.then(function (data) { return p1; }); console.log(p3 === p1); // false p3.then(function (data) { console.log(data); // p1 }); [代码] 静态方法 Promise.resolve() Promise.resolve(value)方法返回一个以给定值解析后的 Promise 对象。 但如果这个值是个 thenable(即带有 then 方法),返回的 promise 会“跟随”这个 thenable的对象,采用它的最终状态;否则以该值为成功状态返回 promise 对象。 Promise.reject() Promise.reject(reason)方法返回一个用reason拒绝的Promise。 [代码] // 以下两个 promise 是等价的 var p1 = new Promise( (resolve,reject) => { resolve( "Oops" ); }); var p2 = Promise.resolve( "Oops" ); var p1 = new Promise( (resolve,reject) => { reject( "Oops" ); }); var p2 = Promise.reject( "Oops" ); [代码] Promise.all() Promise.all方法用于将多个 Promise 实例,包装成一个新的 Promise 实例 [代码] const p = Promise.all([p1, p2, p3]); p.then(function (posts) { // ... }).catch(function(reason){ // ... }); [代码] p的状态由p1、p2、p3决定,分成两种情况。 (1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。 (2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。 Promise.race() Promise.race方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。 [代码] const p = Promise.race([p1, p2, p3]); p.then(function (posts) { // ... }).catch(function(reason){ // ... }); [代码] 只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数 Generator 名词解释 迭代器 (Iterator) 迭代器是一种对象,它具有一些专门为迭代过程设计的专有接口,所有迭代器对象都有一个 next 方法,每次调用都返回一个结果对象。 结果对象有两个属性,一个是 value,表示下一个将要返回的值;另一个是 done,它是一个布尔类型的值,当没有更多可返回数据时返回 true。 迭代器还会保存一个内部指针,用来指向当前集合中值的位置,每调用一次 next() 方法,都会返回下一个可用的值。 可迭代对象 (Iterable) 可迭代对象具有 Symbol.iterator 属性,是一种与迭代器密切相关的对象。 Symbol.iterator 通过指定的函数可以返回一个作用于附属对象的迭代器。 在 ECMCScript 6 中,所有的集合对象(数组、Set、及 Map 集合)和字符串都是可迭代对象,这些对象中都有默认的迭代器。 生成器 (Generator) 生成器是一种返回迭代器的函数,通过 function 关键字后的 * 号来表示。 此外,由于生成器会默认为 Symbol.iterator 属性赋值,因此所有通过生成器创建的迭代器都是可迭代对象。 for-of 循环 for-of 循环每执行一次都会调用可迭代对象的迭代器接口的 next() 方法,并将迭代器返回的结果对象的 value 属性储存在一个变量中,循环将持续执行这一过程直到返回对象的属性值为 true。 生成器的一般使用形式 [代码] function *foo() { var x = yield 2 var y = x * (yield x + 1) console.log( x, y ) return x + y } var it = foo() it.next() // {value: 2, done: false} it.next(3) // {value: 4, done: false} it.next(3) // 3 9, {value: 12, done: true} [代码] 遍历器对象的next方法的运行逻辑如下: (1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。 (2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。 (3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。 (4)如果该函数没有return语句,则返回的对象的value属性值为undefined。 需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。 异步迭代生成器 [代码] function foo() { setTimeout(() => { it.next('success') // 恢复*main() // it.throw('error') // 向*main()抛出一个错误 }, 2000); } function *main() { try { var data = yield foo() console.log(data) } catch(e) { console.log(e) } } var it = main() it.next() // 这里启动! [代码] 本例中我们在 *main() 中发起 foo() 请求,之后暂停;又在 foo() 中相应数据恢复 *mian() 继续运行,并将 foo() 的运行结果通过 next() 传递出来。 我们在生成器内部有了看似完全同步的代码(除了 yield 关键字本身),但隐藏在背后的是,在 foo(…)内的运行可以完全异步。并且在异步代码中实现看似同步的错误处理(通过try…catch)在可读性和合理性方面也都是一个巨大的进步。 Generator + Promise 通过promise来管理异步流程 [代码] function foo() { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('fai'); }, 2000); }); } function *main() { try { var data = yield foo() console.log(data) } catch(e) { console.error(e) } } var it = main(); var p = it.next().value; // p 的值是 foo() // 等待 promise p 决议 p.then( function(data) { it.next(data); // 将 data 赋值给 yield }, function(err) { it.throw(err); } ) [代码] *mian() 中执行 foo() 发起请求,返回promise 根据promise 决议结果,根据结果选择继续运行迭代器或抛出错误 如何执行有多处yield的Generator 函数? [代码] function foo(name) { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('hello ' + name); }, 2000); }); } var gen = function* (){ var r1 = yield foo('jarvis'); var r2 = yield foo('hth'); console.log(r1); console.log(r2); }; var g = gen(); // 手动执行 g.next().value.then(function(data){ g.next(data).value.then(function(data){ g.next(data); }); }); [代码] 手动执行的方式,其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器 自动执行Generator 函数 [代码] function foo(name) { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('hello ' + name); }, 2000); }); } var gen = function* (){ var r1 = yield foo('jarvis'); var r2 = yield foo('hth'); console.log(r1); console.log(r2); }; function run(gen){ var g = gen(); function next(data){ var result = g.next(data); if (result.done) return result.value; result.value.then(function(data){ next(data); }); } next(); } run(gen); [代码] 只要保证yield后面总是返回promise,就能用run函数自动执行Generator 函数 Async/Await async 函数的一般使用形式 async 函数是什么? 其实就是 promise+自动执行的Generator 函数的语法糖。类似于我们上面的实现 [代码] function foo(p) { return fetch('http://my.data?p=' + p) } async function main(p) { try { var data = await foo(p) return data } catch(e) { console.error(e) } } main(1).then(data => console.log(data)) [代码] 与 Generator 函数不同是,* 变成了async、yeild变成了await,同时我们也不用再定义 run(…) 函数来实现 Promise 与 Generator 的结合。 async 函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句,并且最终返回一个 Promise 对象。 正常情况下,await 命令后面是一个 Promise 对象。如果不是,会被转成一个立即 resolve 的 Promise 对象。 await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数会被 catch 方法的回调函数接收到。 async 函数的使用注意点 前面已经说过,await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中。 多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。 await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。 [代码] //getFoo 与 getBar 是两个互相独立、互不依赖的异步操作 // 错误写法 let foo = await getFoo(); let bar = await getBar(); // 正确写法一 let [foo, bar] = await Promise.all([getFoo(), getBar()]); // 正确写法二 let fooPromise = getFoo(); let barPromise = getBar(); let foo = await fooPromise; let bar = await barPromise; [代码] async 函数比Promise好在哪? 类同步写法,使得在写复杂逻辑时,可以用一种顺序的方式来书写,大大降低了理解的难度。 错误处理上,可以用try catch来捕获,同时处理同步和异步错误。 总结 JavaScript异步编程的发展历程有以下四个阶段: 回调函数: 有两个问题,回调地狱和信任缺失,回调地狱的坏处主要是代码阅读性和可维护性差,同时不好对异步逻辑进行封装。信任缺失主要体现在调用的时机,调用的次数,对异常的处理上缺乏一致性。 Promise 基于PromiseA+规范的实现解决了控制反转带来的信任问题。 Generator 使用生成器函数Generator,我们得以用同步的方式来书写异步的代码,解决了顺序性的问题,这是一种重大的突破。但是使用比较繁琐,需要手动去调用next(…)去控制流程和传参。 Async/Await Async/Await结合了Promise和Generator,并实现了自动执行生成器函数逻辑。使得使用者通添加少量关键字就可以用同步的方式书写异步代码,大大提高了开发效率和代码可维护性。 可以看到,目前Async/Await方式可以说是处理异步的终极解决方案,在项目中应该优先使用这种方式。
2019-06-11 - Painter 一款轻量级的小程序海报生成组件
生成海报相信大家有的人都做过,但是canvas绘图的坑太多。大家可以试试这个组件。然后附上楼下大哥做的可视化拖拽生成painter代码的工具:链接地址https://developers.weixin.qq.com/community/develop/article/doc/000e222d9bcc305c5739c718d56813
2019-09-27 - 如何实现快速生成朋友圈海报分享图
由于我们无法将小程序直接分享到朋友圈,但分享到朋友圈的需求又很多,业界目前的做法是利用小程序的 Canvas 功能生成一张带有小程序码的图片,然后引导用户下载图片到本地后再分享到朋友圈。相信大家在绘制分享图中应该踩到 Canvas 的各种(坑)彩dan了吧~ 这里首先推荐一个开源的组件:painter(通过该组件目前我们已经成功在支付宝小程序上也应用上了分享图功能) 咱们不多说,直接上手就是干。 [图片] 首先我们新增一个自定义组件,在该组件的json中引入painter [代码]{ "component": true, "usingComponents": { "painter": "/painter/painter" } } [代码] 然后组件的WXML (代码片段在最后) [代码]// 将该组件定位在屏幕之外,用户查看不到。 <painter style="position: absolute; top: -9999rpx;" palette="{{imgDraw}}" bind:imgOK="onImgOK" /> [代码] 重点来了 JS (代码片段在最后) [代码]Component({ properties: { // 是否开始绘图 isCanDraw: { type: Boolean, value: false, observer(newVal) { newVal && this.handleStartDrawImg() } }, // 用户头像昵称信息 userInfo: { type: Object, value: { avatarUrl: '', nickName: '' } } }, data: { imgDraw: {}, // 绘制图片的大对象 sharePath: '' // 生成的分享图 }, methods: { handleStartDrawImg() { wx.showLoading({ title: '生成中' }) this.setData({ imgDraw: { width: '750rpx', height: '1334rpx', background: 'https://qiniu-image.qtshe.com/20190506share-bg.png', views: [ { type: 'image', url: 'https://qiniu-image.qtshe.com/1560248372315_467.jpg', css: { top: '32rpx', left: '30rpx', right: '32rpx', width: '688rpx', height: '420rpx', borderRadius: '16rpx' }, }, { type: 'image', url: this.data.userInfo.avatarUrl || 'https://qiniu-image.qtshe.com/default-avatar20170707.png', css: { top: '404rpx', left: '328rpx', width: '96rpx', height: '96rpx', borderWidth: '6rpx', borderColor: '#FFF', borderRadius: '96rpx' } }, { type: 'text', text: this.data.userInfo.nickName || '青团子', css: { top: '532rpx', fontSize: '28rpx', left: '375rpx', align: 'center', color: '#3c3c3c' } }, { type: 'text', text: `邀请您参与助力活动`, css: { top: '576rpx', left: '375rpx', align: 'center', fontSize: '28rpx', color: '#3c3c3c' } }, { type: 'text', text: `宇宙最萌蓝牙耳机测评员`, css: { top: '644rpx', left: '375rpx', maxLines: 1, align: 'center', fontWeight: 'bold', fontSize: '44rpx', color: '#3c3c3c' } }, { type: 'image', url: 'https://qiniu-image.qtshe.com/20190605index.jpg', css: { top: '834rpx', left: '470rpx', width: '200rpx', height: '200rpx' } } ] } }) }, onImgErr(e) { wx.hideLoading() wx.showToast({ title: '生成分享图失败,请刷新页面重试' }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') }, onImgOK(e) { wx.hideLoading() // 展示分享图 wx.showShareImageMenu({ path: e.detail.path, fail: err => { console.log(err) } }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') } } }) [代码] 那么我们该如何引用呢? 首先json里引用我们封装好的组件share-box [代码]{ "usingComponents": { "share-box": "/components/shareBox/index" } } [代码] 以下示例为获取用户头像昵称后再生成图。 [代码]<button class="intro" bindtap="getUserInfo">点我生成分享图</button> <share-box isCanDraw="{{isCanDraw}}" userInfo="{{userInfo}}" bind:initData="handleClose" /> [代码] 调用的地方: [代码]const app = getApp() Page({ data: { isCanDraw: false }, // 组件内部关掉或者绘制完成需重置状态 handleClose() { this.setData({ isCanDraw: !this.data.isCanDraw }) }, getUserInfo(e) { wx.getUserProfile({ desc: "获取您的头像昵称信息", success: res => { const { userInfo = {} } = res this.setData({ userInfo, isCanDraw: true // 开始绘制海报图 }) }, fail: err => { console.log(err) } }) } }) [代码] 最后绘制分享图的自定义组件就完成啦~效果图如下: [图片] tips: 文字居中实现可以看下代码片段 文字换行实现(maxLines)只需要设置宽度,maxLines如果设置为1,那么超出一行将会展示为省略号 代码片段:https://developers.weixin.qq.com/s/J38pKsmK7Qw5 附上painter可视化编辑代码工具:点我直达,因为涉及网络图片,代码片段设置不了downloadFile合法域名,建议真机开启调试模式,开发者工具 详情里开启不校验合法域名进行代码片段的运行查看。 最后看下面大家评论问的较多的问题:downLoadFile合法域名在小程序后台 开发>开发设置里配置,域名为你图片的域名前缀 比如我文章里的图https://qiniu-image.qtshe.com/20190605index.jpg。配置域名时填写https://qiniu-image.qtshe.com即可。如果你图片cdn地址为https://aaa.com/xxx.png, 那你就配置https://aaa.com即可。
2022-01-20 - 写给学弟学妹的学习小程序经验总结
写给学弟学妹的学习小程序经验总结 此篇文章面向前端入门以及小程序新手,入门爬坑可以观看 我个人跟着教程写过一个电影小程序,然后就直接开始写比赛的小程序了,对小程序有一定认识,但理解还是比较浅的,如有错误欢迎批评指正。 学习小程序的前置知识 前端使用的是微信自定义的一套规范[代码]wxml[代码]+[代码]wxss[代码]+[代码]json[代码]+[代码]js[代码],其本质还是[代码]html[代码]+[代码]css[代码]+[代码]javascript[代码].打好基础才能快速学好框架。 编译器推荐:VS Code,有非常多的插件,代码补全,格式修正,颜色高亮,版本控制 推荐网站:MDN有标准语法文档方法函数忘记了可以去这个网站查,freecodecamp编程闯关、完成挑战 第一阶段:html,css,推荐书籍:Head First HTML与CSS(第2版)HTML5权威指南 CSS揭秘 CSS世界可以边看书或者视频边写代码。快速熟悉网页框架结构。 第二阶段:JavaScript,http这一阶段基础一定要打牢,前端的绝大部分都是以javascript为基本的。推荐书籍:JavaScript DOM编程艺术 (第2版)JavaScript高级程序设计(第3版)JavaScript语言精粹ES6标准入门(第3版)深入理解ES6如果觉得难可以先看视频,再看书,中间跟着写一些小demo,学完后做一个完整的网页(学习所有知识都可以按照此路径,视频:快速了解 > 书籍:深入探究 > 实践:理解巩固)。 扩展知识:学一点点linux和git对合作开发有更高的效率。 先学会了一些框架(vue,react)可以更快地上手小程序。 小程序的学习 官方学习视频《学做小程序》 官方学习文档《小程序微信官方文档》 通过看文档和教学视频学习小程序 入门指导 1.申请账号 进入小程序注册页 可以在小程序后台,依次点击「设置」->「开发设置」获取到这个「AppID」自己保存:AppID相当于小程序平台的身份证 2.安装开发工具 前往 开发者工具下载页面 稳定版 Stable Build (1.02.1904090) Windows 64 、 Windows 32 、 macOS 根据不同操作系统下载相应版本 3.创建项目 [图片] 4.在VS Code中编写代码,在小程序开发者工具里调试 VS Code官网下载:code.visualstudio.com/ 点击「扩展」,安装小程序插件推荐「minapp」「wxml」「wechat-snippet」,其他插件自行选择 [图片] 文件构成 小程序一般由下面四类文件组成: json:配置文件 小程序全局配置:「[代码]app.json[代码]」 [代码] //配置页面路径 "pages": [ "pages/index/index", "pages/logs/logs" ], //配置窗体样式 "window": { "backgroundTextStyle": "light", //顶部导航样式 "navigationBarBackgroundColor": "#fff", "navigationBarTitleText": "WeChat", "navigationBarTextStyle": "black" }, [代码] 开发工具配置:「[代码]project.config.json[代码]」 开发者工具的统一配置,界面设置以及云函数相关 单页面配置:「[代码]page.json[代码]」 对本页面的窗口表现进行配置,会覆盖app.json的window中相同的配置项 wxml:模板文件,AS:HTML 1.跟html很像,类比[代码]<div>[代码]=[代码]<view>[代码],[代码]<p>[代码]=[代码]<text>[代码],[代码]<a>[代码]=[代码]<navigator>[代码]等 2.与html不同的是,可以在标签中加判断或循环语句[代码]wx:if、else/wx:for[代码]还可以使用三元运算符等 3.在标签里使用[代码]{{motto}}[代码]可以读取当前页面JS文件[代码]data:{motto:'hello World'}[代码]data对象中的数据 4.在标签中通过[代码]bindtap/catch="functionName"[代码]冒泡/捕获绑定事件 wxss:样式文件,AS:CSS 1.[代码]2rpx[代码]=[代码]1px[代码]小程序根据不同屏幕大小,底层来换算像素单位 2.flex布局:推荐阮一峰老师的博客Flex 布局教程:语法篇 Flex 布局教程:实战篇 js:脚本逻辑文件 1.页面生命周期** [代码]// pages/life.js 页面初始化时,小程序自动给我们创立了生命周期函数 Page({ /** * 页面的初始数据,可以使用this.setData({ msg: "Hello World" })传入data对象wxml可以从中获取数据 */ data: { }, /** * 生命周期函数--监听页面加载 */ onLoad: function (options) { }, /** * 生命周期函数--监听页面初次渲染完成 */ onReady: function () { }, /** * 生命周期函数--监听页面显示 */ onShow: function () { }, /** * 生命周期函数--监听页面隐藏 */ onHide: function () { }, /** * 生命周期函数--监听页面卸载 */ onUnload: function () { }, /** * 页面相关事件处理函数--监听用户下拉动作 */ onPullDownRefresh: function () { }, /** * 页面上拉触底事件的处理函数 */ onReachBottom: function () { }, /** * 用户点击右上角分享 */ onShareAppMessage: function () { } }) [代码] 很多人说[代码]this.setData()[代码]传不进数据,有人回答在函数里先申明个[代码]var that=this[代码],这种回答很敷衍,提问人还是不会懂,治标不治本。推荐不懂的同学去了解一下this指向原理:this 永远指向最后调用它的那个对象,多看几篇技术文章this、apply、call、bind、Javascript 深入浅出 this 2.发起http请求 [代码]//仅为示例 wx.request({ url: 'http:'//test.php, data: { x: '', y: '' }, method:'GET'\'POST', header: { 'content-type': 'application/json' }, success (res) { console.log(res.data) } }) [代码] 通过请求后台接口,发送获取数据 3.用户登陆 基本每个小程序都少不了登陆流程 彻底搞懂小程序登陆流程 [图片] 个人小结 之前喜欢计算机,但是摸不到门路,大约一年前加入工作室,因为比较喜欢写完代码就看到界面交互的效果,确立了学习方向:前端。上学期入门了html,CSS ,JavaScript期间写了几个网页,巩固了一下基础.这学期写完工作室官网,刚学完ES6就开始小程序大赛了。 我们工作室是独立非学校、非盈利的计算机学习组织,主要学习互联网开发,前端后端,设计,三个方向,每周30小时学习打卡,一次学习经验分享会,让我们互相学习监督,共同成长。感谢前辈创业,学长引路,让我从单喜欢计算机到有了专业方向。 前辈传给我们,我们再教学弟学妹,我们有共同理想,共同爱好,并为之付出努力。我记得王小波在维也纳看三个青年在街头演奏的一席话:青年的动人之处,就在于勇气,和他们的远大前程。 希望能给学弟学妹们,前端刚入门者一些指导。 喜欢前端、计算机志同道合的朋友可以加WX交流学习经验:Jason-JCWu 更新分割线 几年前写的,目前已进入tx
2021-09-22 - 小程序如何生成海报分享朋友圈
摘要: 小程序开发必备技能啊… 原文:小程序如何生成海报分享朋友圈 作者:小白 Fundebug经授权转载,版权归原作者所有。 项目需求写完有一段时间了,但是还是想回过来总结一下,一是对项目的回顾优化等,二是对坑的地方做个记录,避免以后遇到类似的问题。 需求 利用微信强大的社交能力通过小程序达到裂变的目的,拉取新用户。 生成的海报如下: [图片] 需求分析 1、利用小程序官方提供的api可以直接分享转发到微信群打开小程序 2、利用小程序生成海报保存图片到相册分享到朋友圈,用户长按识别二维码关注公众号或者打开小程序来达到裂变的目的 实现方案 一、分析如何实现 相信大家应该都会有类似的迷惑,就是如何按照产品设计的那样绘制成海报,其实当时我也是不知道如何下手,认真想了下得通过canvas绘制成图片,这样用户保存这个图片到相册,就可以分享到朋友圈了。但是要绘制的图片上面不仅有文字还有数字、图片、二维码等且都是活的,这个要怎么动态生成呢。认真想了下,需要一点一点的将文字和数字,背景图绘制到画布上去,这样通过api最终合成一个图片导出到手机相册中。 二、需要解决的问题 二维码的动态获取和绘制(包括如何生成小程序二维码、公众号二维码、打开网页二维码) 背景图如何绘制,获取图片信息 将绘制完成的图片保存到本地相册 处理用户是否取消授权保存到相册 三、实现步骤 这里我具体写下围绕上面所提出的问题,描述大概实现的过程 ①首先创建canvas画布,我把画布定位设成负的,是为了不让它显示在页面上,是因为我尝试把canvas通过判断条件动态的显示和隐藏,在绘制的时候会出现问题,所以采用了这种方法,这里还有一定要设置画布的大小。 [代码]<canvas canvas-id="myCanvas" style="width: 690px;height:1085px;position: fixed;top: -10000px;"></canvas> [代码] ②创建好画布之后,先绘制背景图,因为背景图我是放在本地,所以获取 <canvas> 组件 canvas-id 属性,通过createCanvasContext创建canvas的绘图上下文 CanvasContext 对象。使用drawImage绘制图像到画布,第一个参数是图片的本地地址,后面两个参数是图像相对画布左上角位置的x轴和y轴,最后两个参数是设置图像的宽高。 [代码]const ctx = wx.createCanvasContext('myCanvas') ctx.drawImage('/img/study/shareimg.png', 0, 0, 690, 1085) [代码] ③创建好背景图后,在背景图上绘制头像,文字和数字。通过getImageInfo获取头像的信息,这里需要注意下在获取的网络图片要先配置download域名才能生效,具体在小程序后台设置里配置。 获取头像地址,首先量取头像在画布中的大小,和x轴Y轴的坐标,这里的result[0]是我用promise封装返回的一个图片地址 [代码]let headImg = new Promise(function (resolve) { wx.getImageInfo({ src: `${app.globalData.baseUrl2}${that.data.currentChildren.headImg}`, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) let avatarurl_width = 60, //绘制的头像宽度 avatarurl_heigth = 60, //绘制的头像高度 avatarurl_x = 28, //绘制的头像在画布上的位置 avatarurl_y = 36; //绘制的头像在画布上的位置 ctx.save(); // 先保存状态 已便于画完圆再用 ctx.beginPath(); //开始绘制 //先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针 ctx.arc(avatarurl_width / 2 + avatarurl_x, avatarurl_heigth / 2 + avatarurl_y, avatarurl_width / 2, 0, Math.PI * 2, false); ctx.clip(); //画了圆 再剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 ctx.drawImage(result[0], avatarurl_x, avatarurl_y, avatarurl_width, avatarurl_heigth); // 推进去图片 [代码] 这里举个例子说下如何绘制文字,比如我要绘制如下这个“字”,需要动态获取前面字数的总宽度,这样才能设置“字”的x轴坐标,这里我本来是想通过measureText来测量字体的宽度,但是在iOS端第一次获取的宽度值不对,关于这个问题,我还在微信开发者社区提了bug,所以我想用另一个方法来实现,就是先获取正常情况下一个字的宽度值,然后乘以总字数就获得了总宽度,亲试是可以的。 [图片] [代码]let allReading = 97 / 6 / app.globalData.ratio * wordNumber.toString().length + 325; ctx.font = 'normal normal 30px sans-serif'; ctx.setFillStyle('#ffffff') ctx.fillText('字', allReading, 150); [代码] ④绘制公众号二维码,和获取头像是一样的,也是先通过接口返回图片网络地址,然后再通过getImageInfo获取公众号二维码图片信息 ⑤如何绘制小程序码,具体官网文档也给出生成无限小程序码接口,通过生成的小程序可以打开任意一个小程序页面,并且二维码永久有效,具体调用哪个小程序二维码接口有不同的应用场景,具体可以看下官方文档怎么说的,也就是说前端通过传递参数调取后端接口返回的小程序码,然后绘制在画布上(和上面写的绘制头像和公众号二维码一样的) [代码]ctx.drawImage('小程序码的本地地址', x轴, Y轴, 宽, 高) [代码] ⑥最终绘制完把canvas画布转成图片并返回图片地址 [代码] wx.canvasToTempFilePath({ canvasId: 'myCanvas', success: function (res) { canvasToTempFilePath = res.tempFilePath // 返回的图片地址保存到一个全局变量里 that.setData({ showShareImg: true }) wx.showToast({ title: '绘制成功', }) }, fail: function () { wx.showToast({ title: '绘制失败', }) }, complete: function () { wx.hideLoading() wx.hideToast() } }) [代码] ⑦保存到系统相册;先判断用户是否开启用户授权相册,处理不同情况下的结果。比如用户如果按照正常逻辑授权是没问题的,但是有的用户如果点击了取消授权该如何处理,如果不处理会出现一定的问题。所以当用户点击取消授权之后,来个弹框提示,当它再次点击的时候,主动跳到设置引导用户去开启授权,从而达到保存到相册分享朋友圈的目的。 [代码]// 获取用户是否开启用户授权相册 if (!openStatus) { wx.openSetting({ success: (result) => { if (result) { if (result.authSetting["scope.writePhotosAlbum"] === true) { openStatus = true; wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } } }, fail: () => { }, complete: () => { } }); } else { wx.getSetting({ success(res) { // 如果没有则获取授权 if (!res.authSetting['scope.writePhotosAlbum']) { wx.authorize({ scope: 'scope.writePhotosAlbum', success() { openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) }, fail() { // 如果用户拒绝过或没有授权,则再次打开授权窗口 openStatus = false console.log('请设置允许访问相册') wx.showToast({ title: '请设置允许访问相册', icon: 'none' }) } }) } else { // 有则直接保存 openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } }, fail(err) { console.log(err) } }) } [代码] 总结 至此所有的步骤都已实现,在绘制的时候会遇到一些异步请求后台返回的数据,所以我用promise和async和await进行了封装,确保导出的图片信息是完整的。在绘制的过程确实遇到一些坑的地方。比如初开始导出的图片比例大小不对,还有用measureText测量文字宽度不对,多次绘制(可能受网络原因)有时导出的图片上的文字颜色会有误差等。如果你也遇到一些比较坑的地方可以一起探讨下做个记录,下面附下完整的代码 [代码]import regeneratorRuntime from '../../utils/runtime.js' // 引入模块 const app = getApp(), api = require('../../service/http.js'); var ctx = null, // 创建canvas对象 canvasToTempFilePath = null, // 保存最终生成的导出的图片地址 openStatus = true; // 声明一个全局变量判断是否授权保存到相册 // 获取微信公众号二维码 getCode: function () { return new Promise(function (resolve, reject) { api.fetch('/wechat/open/getQRCodeNormal', 'GET').then(res => { console.log(res, '获取微信公众号二维码') if (res.code == 200) { console.log(res.content, 'codeUrl') resolve(res.content) } }).catch(err => { console.log(err) }) }) }, // 生成海报 async createCanvasImage() { let that = this; // 点击生成海报数据埋点 that.setData({ generateId: '点击生成海报' }) if (!ctx) { let codeUrl = await that.getCode() wx.showLoading({ title: '绘制中...' }) let code = new Promise(function (resolve) { wx.getImageInfo({ src: codeUrl, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) let headImg = new Promise(function (resolve) { wx.getImageInfo({ src: `${app.globalData.baseUrl2}${that.data.currentChildren.headImg}`, success: function (res) { resolve(res.path) }, fail: function (err) { console.log(err) wx.showToast({ title: '网络错误请重试', icon: 'loading' }) } }) }) Promise.all([headImg, code]).then(function (result) { const ctx = wx.createCanvasContext('myCanvas') console.log(ctx, app.globalData.ratio, 'ctx') let canvasWidthPx = 690 * app.globalData.ratio, canvasHeightPx = 1085 * app.globalData.ratio, avatarurl_width = 60, //绘制的头像宽度 avatarurl_heigth = 60, //绘制的头像高度 avatarurl_x = 28, //绘制的头像在画布上的位置 avatarurl_y = 36, //绘制的头像在画布上的位置 codeurl_width = 80, //绘制的二维码宽度 codeurl_heigth = 80, //绘制的二维码高度 codeurl_x = 588, //绘制的二维码在画布上的位置 codeurl_y = 984, //绘制的二维码在画布上的位置 wordNumber = that.data.wordNumber, // 获取总阅读字数 // nameWidth = ctx.measureText(that.data.wordNumber).width, // 获取总阅读字数的宽度 // allReading = ((nameWidth + 375) - 325) * 2 + 380; // allReading = nameWidth / app.globalData.ratio + 325; allReading = 97 / 6 / app.globalData.ratio * wordNumber.toString().length + 325; console.log(wordNumber, wordNumber.toString().length, allReading, '获取总阅读字数的宽度') ctx.drawImage('/img/study/shareimg.png', 0, 0, 690, 1085) ctx.save(); // 先保存状态 已便于画完圆再用 ctx.beginPath(); //开始绘制 //先画个圆 前两个参数确定了圆心 (x,y) 坐标 第三个参数是圆的半径 四参数是绘图方向 默认是false,即顺时针 ctx.arc(avatarurl_width / 2 + avatarurl_x, avatarurl_heigth / 2 + avatarurl_y, avatarurl_width / 2, 0, Math.PI * 2, false); ctx.clip(); //画了圆 再剪切 原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内 ctx.drawImage(result[0], avatarurl_x, avatarurl_y, avatarurl_width, avatarurl_heigth); // 推进去图片 ctx.restore(); //恢复之前保存的绘图上下文状态 可以继续绘制 ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.setFontSize(28); // 文字字号 ctx.fillText(that.data.currentChildren.name, 103, 78); // 绘制文字 ctx.font = 'normal bold 44px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(wordNumber, 325, 153); // 绘制文字 ctx.font = 'normal normal 30px sans-serif'; ctx.setFillStyle('#ffffff') ctx.fillText('字', allReading, 150); ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText('打败了全国', 26, 190); // 绘制文字 ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#faed15'); // 文字颜色 ctx.fillText(that.data.percent, 154, 190); // 绘制孩子百分比 ctx.font = 'normal normal 24px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText('的小朋友', 205, 190); // 绘制孩子百分比 ctx.font = 'normal bold 32px sans-serif'; ctx.setFillStyle('#333333'); // 文字颜色 ctx.fillText(that.data.singIn, 50, 290); // 签到天数 ctx.fillText(that.data.reading, 280, 290); // 阅读时长 ctx.fillText(that.data.reading, 508, 290); // 听书时长 // 书籍阅读结构 ctx.font = 'normal normal 28px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(that.data.bookInfo[0].count, 260, 510); ctx.fillText(that.data.bookInfo[1].count, 420, 532); ctx.fillText(that.data.bookInfo[2].count, 520, 594); ctx.fillText(that.data.bookInfo[3].count, 515, 710); ctx.fillText(that.data.bookInfo[4].count, 492, 828); ctx.fillText(that.data.bookInfo[5].count, 348, 858); ctx.fillText(that.data.bookInfo[6].count, 212, 828); ctx.fillText(that.data.bookInfo[7].count, 148, 726); ctx.fillText(that.data.bookInfo[8].count, 158, 600); ctx.font = 'normal normal 18px sans-serif'; ctx.setFillStyle('#ffffff'); // 文字颜色 ctx.fillText(that.data.bookInfo[0].name, 232, 530); ctx.fillText(that.data.bookInfo[1].name, 394, 552); ctx.fillText(that.data.bookInfo[2].name, 496, 614); ctx.fillText(that.data.bookInfo[3].name, 490, 730); ctx.fillText(that.data.bookInfo[4].name, 466, 850); ctx.fillText(that.data.bookInfo[5].name, 323, 878); ctx.fillText(that.data.bookInfo[6].name, 184, 850); ctx.fillText(that.data.bookInfo[7].name, 117, 746); ctx.fillText(that.data.bookInfo[8].name, 130, 621); ctx.drawImage(result[1], codeurl_x, codeurl_y, codeurl_width, codeurl_heigth); // 绘制头像 ctx.draw(false, function () { // canvas画布转成图片并返回图片地址 wx.canvasToTempFilePath({ canvasId: 'myCanvas', success: function (res) { canvasToTempFilePath = res.tempFilePath that.setData({ showShareImg: true }) console.log(res.tempFilePath, 'canvasToTempFilePath') wx.showToast({ title: '绘制成功', }) }, fail: function () { wx.showToast({ title: '绘制失败', }) }, complete: function () { wx.hideLoading() wx.hideToast() } }) }) }) } }, // 保存到系统相册 saveShareImg: function () { let that = this; // 数据埋点点击保存学情海报 that.setData({ saveId: '保存学情海报' }) // 获取用户是否开启用户授权相册 if (!openStatus) { wx.openSetting({ success: (result) => { if (result) { if (result.authSetting["scope.writePhotosAlbum"] === true) { openStatus = true; wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } } }, fail: () => { }, complete: () => { } }); } else { wx.getSetting({ success(res) { // 如果没有则获取授权 if (!res.authSetting['scope.writePhotosAlbum']) { wx.authorize({ scope: 'scope.writePhotosAlbum', success() { openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) }, fail() { // 如果用户拒绝过或没有授权,则再次打开授权窗口 openStatus = false console.log('请设置允许访问相册') wx.showToast({ title: '请设置允许访问相册', icon: 'none' }) } }) } else { // 有则直接保存 openStatus = true wx.saveImageToPhotosAlbum({ filePath: canvasToTempFilePath, success() { that.setData({ showShareImg: false }) wx.showToast({ title: '图片保存成功,快去分享到朋友圈吧~', icon: 'none', duration: 2000 }) }, fail() { wx.showToast({ title: '保存失败', icon: 'none' }) } }) } }, fail(err) { console.log(err) } }) } }, [代码]
2019-06-15 - 【高校开发者】双生日记开发经验分享
双生日记开发经验分享 Hello,我是双生日记的 Founder & Developer Airing。该项目的小程序端获得了 2018 C4——微信小程序应用开发赛的一等奖,而 iOS 端则获得了 2018 C4——移动应用创新赛的一等奖,目前累计注册用户已达 1 万+,并仍在不断开发维护中~ 本文将简要概括我们团队在产品的整个研发流程中的所做的工作,更侧重于介绍产品研发与团队管理的方法。 我将整个产品的研发分为以下四步: 立项 设计 开发 维护 [图片] 可以看到,以上四步形成了项目流程的闭环,使得产品能够良性发展。接下来我来具体谈谈这四步工作的内容。 1. 立项 项目立项是所有环节最开始的部分,我觉得也是最重要的部分,它的工作内容类似于“产品经理”的职责。虽然我是 Founder,但产品的探讨还是与大家共同完成的。具体而言,这个环节有两个内容: 产品脑暴 文档整理 1.1 产品脑暴 首先,我会先在团队中提出我的想法,并创建一个讨论区供大家讨论。我们是一个非常大的兴趣团队,虽然参与双生研发的只有寥寥三人,但是在产品脑暴的时候,团队的成员都提出了各自的见解与建议。例如,下图是我们在团队研发中讨论的内容。 [图片] 这里我们团队用的是产品是“语雀”,当然工具是随意的,用腾讯文档我觉得也非常方便,重要的是一定要形成电子版记录材料,如果只是在微信群里讨论或者线下简单聊聊,那讨论了、忘记了,那就相当低效,约等于没有讨论。 1.2 文档整理 第二步,整理脑暴的文档并撰写相关的研发文档,具体来说,包括但不限于: 需求文档 产品文档 模型文档 接口文档 [图片] PS. 这是我们团队的文档库,仅供参考:零熊 | 语雀 [图片] 2. 设计 设计工具我们用的 Sketch,但是不会把源文件直接发给研发同学,因为正版 Sketch 挺贵的,而且只支持 mac 系统。这里我们使用的工具是蓝湖,开始用的也是语雀的画板,但是发现实在是太难用了…另外,在蓝湖中的设计稿是可以分享的,并且邀请团队里的同学进行点评。 设计稿的内容具体包括: 规范 原型 UI 切图 规范重点是色彩规范、组件规范、和字体规范。原型更多的是交互说明,这里我们只是用批注的方式在 ui 上详细说明了一下交互,但如果直接用 flinto 去做也是可以的。flinto 的好处是更加直观,但是开发人员不一定能 get 到设计同学的全部内容。 [图片] 3. 开发 本部分分享的是产品研发的核心环节:项目开发。本环节我分享的内容会稍微多一些,但也略微零散,主要包含三个内容: 规划记录 开发工具 建议事项 3.1 规划记录 在开发之前,我习惯于自己先列一个 todolist 去罗列出项目中的各个需求点或技术点,从整体上会有一个直观的感受,也方便我去安排和规划自己的开发任务。这里我使用的工具是 Notion,我先按照重要的模块把产品分割成了 8 个部分,然后再在每个部分里写各自的 todolist,以免单文件 todolist 过长。 [图片] [图片] 当然,todolist 不单单记录待办事项而已,它更多的是承担一个开发日记的作用。我个人倾向于把开发中遇到的难点问题及解决方法,或者用到的资源顺手记录下来。我认为开发是一个学习和成长的过程,而不仅仅只是完成业务需求。 随手记录是方便日后整理为博客或者再遇到类似的问题可以快速定位,若不记录则很容易忘记。因此,做开发日记对学习的成效是非常大的。 [图片] 3.2 开发工具 针对微信小程序开发,我建议对开发很熟悉的同学可以尝试去使用 VS Code + 扩展 + 真机的模式进行开发,个人觉得这套流程既高效又不会出错。“高效”是 VS Code 自身的高效,而“出错”指的则是模拟器有时候效果与真机不同。 这里顺便安利一下我自己的 VS Code 配置: [图片] 我喜欢把资源管理器放在右边,有两个原因:一是左边是人的注意区,故应该放代码编辑器;二是我随时按 Cmd + B 可以隐藏资源管理器而同时不改变编辑器的位置,如果放左边,隐藏的时候编辑器会有一个位移,眼睛会很不舒服。 对于扩展,我这里用了几个比较有意思的: Color Highlight:颜色值高亮可视化为颜色本身,方便前端样式开发 TODO Highlight:高亮 TODO 与 FIXME miniapp:小程序标签与属性自动补全 Bracket Pair Colorizer:括号着色配对,这个特别方便。 Image preview:方便在代码里预览 uri 上的图片,我是用来看看自己资源路径有没有引错。 REST Client:HTTP 测试,方便开发、分享与 mock。 主题我用的是 Winter is Coming Theme + Material Icon Theme,我个人觉得黑色默认也非常好看。 3.3 注意事项 如果是协同开发我推荐搭配 Git History + Eslint 插件,当然如果自己开发,也免不了 Eslint。Git Commit 规范我们用的是这套: Commit 提交规范 [图片] 开发的时候也别忘记埋点,做一些打点统计,需要打点的地方根据项目需要检测的内容来定。如 PV、UV 这些小程序自带帮你统计了你可以不用打,但其他项目还是要统计的,或者直接规划好 Nginx 的日志,再对日志做分析也是 ok 的~ 如果前后端分离开发,前端同学可以自己接 mockjs 做一套符合接口文档规范的 mock 接口。 4. 维护 对于用户的反馈,我们智能筛选后自动提交到 github issue,再针对 issue 进行 label 和优先级分配。这是我们项目开源的主地址:oh-bear/2life。 [图片] 可以看到 issue 是比较杂乱的,所以还需要 github 的 project 去做一个任务画板。 [图片] by the way,安利一下小工具Devhub,可以很方便的检测自己负责项目的 issue。 [图片] 针对这些 issue,可以做一个阶段性的文档,回归到“立项”步骤,进行下一个小版本的开发。 [图片] 可以发现,我始终没有去选择使用甘特图软件,虽然甘特图更加直观,但是我不太喜欢把任务排的满满的、紧紧的,这样会不自觉地产生工作压力。最重要的是,我们毕竟不是工作嘛,只是一个兴趣开发,所以还是遵循自己的喜好来便好~ 好了,这次的分享就到这里。我是 Airing,我的个人博客是:https://me.ursb.me,欢迎大家来访交流~
2019-05-14