前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

小程序绘制海报总结 canvas 两种画法与踩坑

首页2021-01-02 12:01:24Front-End
小程序海报canvas

运营要一个「分享赚钱」的功能,用户点一下弹出一张带自己头像、昵称和专属二维码的海报,长按保存发朋友圈。听着就是画张图的事,真动手才发现全是细节,画布往哪儿放才不挡页面、网络图片为什么画不上去、头像圆角怎么切、文字超出一行怎么折。

这篇把当时踩过的坑整理成两套完整实现。一套是用旧版 wx.createCanvasContext 封装成组件,哪儿要海报就往哪儿塞;另一套是用新的 type="2d" 画布配一个 Draw 工具类,把 view、image、text 的绘制抽象出来。两套代码都能直接抄,后面会说清楚各自适合什么场景。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 小程序里生成海报为什么绕不开 canvas
  • 用旧版 createCanvasContext 把海报封装成一个可复用组件的完整代码
  • 隐藏画布的定位技巧,为什么不能用 display: none
  • 网络图片、base64 二维码怎么画进画布,域名白名单要配什么
  • 圆形头像的裁剪写法,以及 save 和 restore 为什么必须配对
  • 新版 canvas 2d 的用法,配一个通用 Draw 类处理 view、image、text
  • 多行文本折行、字号、高清屏模糊这些细节怎么处理
  • 保存到相册的授权流程和几个容易忽略的失败分支

# 一、小程序里画海报,为什么只能用 canvas

先把前提说清楚。海报的最终形态是一张图片,用户要长按保存到相册,所以它必须是真正的图片文件,不能是一堆 view 拼出来的页面。小程序没有 html2canvas 那种把 DOM 截图的能力,服务端渲染图片又要多一次网络往返和一套图片服务,于是客户端 canvas 就成了最直接的路。

流程固定是三步。往画布上画背景、图片、文字,调 wx.canvasToTempFilePath 把画布导出成一个临时文件路径,再用 wx.saveImageToPhotosAlbum 存进相册。中间那步是关键,画布本身用户是看不到也保存不了的,能保存的只有导出后的那个临时文件。

理解了这条链路,后面那些奇怪的写法就都有解释了。

# 二、方案一,用旧版 canvas 接口封装成组件

这套是最早写的,基于 wx.createCanvasContext。好处是可以整个封装进一个自定义组件,页面里引入之后调一个方法就出海报。

# 2.1 组件配置

先声明这是个组件,并引入弹层和图标:

{
  "component": true,
  "usingComponents": {
    "van-popup": "@vant/weapp/popup/index",
    "van-icon": "@vant/weapp/icon/index"
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

"component": true 这一行决定了它是自定义组件而不是页面,弹层用的是 Vant Weapp 的 van-popup,你项目里用别的组件库或者自己写一个都行,不影响绘制逻辑。

# 2.2 结构,画布 + 弹层 + 两个按钮

结构分两块,一块是那个用来画图的 canvas,一块是海报出来之后展示的弹层:

<canvas canvas-id="poster-share" class="poster" />

<view bind:tap="handleShare">
	<slot wx:if="{{useSlot}}" />
</view>
<van-popup custom-class="page-van-popup" show="{{ showShare }}" bind:close="shareClose">
	<view class="df fxdc h100v w100v" wx:if="{{showShare}}">
		<view class="df jcc aic fxa posr mt120" bind:tap="shareClose">
			<image class="poster-image" src="{{ tempFilePath }}" mode="aspectFit" />
		</view>
		<view class="fxn h238 bgfff df aic jcc bdrs16t">
			<view class="fxa w0 df aic jcc">
				<view class="df fxdc posr">
					<button class="posa t0 l0 w100p h100p op0" open-type="share" />
					<image class="h108 w108" src="./assets/ic_fenxiang.svg" />
					<text class="mt16 c333 fz28">分享好友</text>
				</view>
			</view>
			<view class="fxa w0 df aic jcc">
				<view class="df fxdc" bind:tap="savePicture">
					<image class="h108 w108" src="./assets/ic_baocun.svg" />
					<text class="mt16 c333 fz28">保存图片</text>
				</view>
			</view>
		</view>
	</view>
</van-popup>
@前端进阶之旅: 代码已经复制到剪贴板

这里有三个点值得单独拎出来。

弹层里展示的是 tempFilePath,也就是画布导出后的临时文件,不是画布本身。用户看到的、长按的、保存的都是这张图。

「分享好友」那个按钮用了一个绝对定位、透明度为 0 的 button 盖在图标上,open-type="share" 只能挂在 button 上,但设计稿要的是一个图标加一行字。透明按钮盖上去是小程序里最常用的解法,比强行改 button 的默认样式省事。

slot 配合 useSlot 属性,是为了让调用方自己决定触发海报的入口长什么样,有的页面是个悬浮按钮,有的是列表里的一行文字。

# 2.3 样式,重点是把画布藏起来

@import "../../../../libs/wxss/index.wxss";
.container {
	width: 100%;
}
.h377 {
	height: 377rpx;
}
.h417 {
	height: 417rpx;
}
.pb70 {
	padding-bottom: 70rpx;
}

.bgview {
	height: 651rpx;
	opacity: .63;
}

.tip,
.share {
	right: 0;
	border-radius: 100rpx 0 0 100rpx;
	width: 88rpx;
	height: 36rpx;
	background: #ffd300;
	color: #007142;
}

.tip {
	top: 75rpx;
}

.share {
	top: 140rpx;
}

.title-wrap {
	width: 100%;
	height: 85rpx;
	background: #fff0e5;
}

.shopping-num {
	border-radius: 15rpx;
	height: 28rpx;
	background: linear-gradient(90deg, rgba(255, 64, 0, 1) 0%, rgba(255, 163, 41, 1) 100%);
}

.page-van-popup {
	--popup-background-color: transparent;
}
.popup__content {
	width: 590rpx;
	height: 800rpx;
}

.poster {
	position: fixed;
	left: 0;
	top: -1000000px;
	z-index: 1000;
	width: 570px;
	height: 920px;
	pointer-events: none;
}

.nodata {
	padding-top: 150rpx;
	height: 700rpx;
}

.count-down .van-count-down {
	width: 200rpx;
	line-height: unset;
	font-size: 20rpx;
	color: unset;
}

.poster-image {
	width: 690rpx;
	height: 920rpx;
}

.cffffff88 {
	color: #ffffff88;
}

.w252 {
	width: 252rpx;
}
@前端进阶之旅: 代码已经复制到剪贴板

样式里最关键的是 .poster 这一段,单独看一遍:

画布用 position: fixed 加 top: -1000000px 挪到了屏幕外面,还加了 pointer-events: none。为什么要这么写?因为旧版 canvas 是原生组件,display: none 或者 visibility: hidden 之后它就不参与渲染了,你画上去的东西导不出来,canvasToTempFilePath 会返回一张空白图甚至直接失败。宽高也不能设成 0,画布的尺寸就是最终图片的尺寸。所以只能让它「存在但看不见」,挪出可视区是最稳的办法。

顺带说一句单位。.poster 的宽高用的是 px 而不是 rpx,因为绘制时传给 drawImage 的坐标是按画布的逻辑像素算的,混用 rpx 会导致你在设计稿上量的坐标和实际画出来的对不上。展示海报的 .poster-image 才用 rpx,那是给用户看的,需要跟着屏幕宽度走。

另外那行 #fff88 是原文的笔误,十六进制颜色不存在五位写法,按类名 cffffff88 推断应该是 #ffffff88,这里改过来了。写错的后果不是报错,是这条颜色规则整条失效,排查时很容易漏掉。

# 2.4 绘制逻辑,一行一行画上去

下面是组件的核心,代码有点长,先看完再拆:

// layer/component/sharePoster/index.js
import { rGetShareErCode } from '../../../../netapi/redPackets/index';
import { KEY_APP_ID } from '../../../../constants/config';
import { stringLimit } from '../../../../utils/string'

Component({
  /**
   * 组件的属性列表
   */
  properties: {
    useSlot: {
      type: Boolean,
      value: false
    },
    showShare: {
      type: Boolean,
      value: false
    },
    pagePath: {
      type: String,
      value: '/pages/packagesSection/redPacketsDistribute/index',
    },
  },

  /**
   * 组件的初始数据
   */
  data: {
    tempFilePath: '',
  },
  query: {},

  observers: {
  },

  /**
   * 组件的方法列表
   */
  methods: {
    shareClose() {
      this.setData({ showShare: false });
    },
    // 其余方法见下文
  }
})
@前端进阶之旅: 代码已经复制到剪贴板

properties 和 data 这两处我调整了一下。原文里 showShare 同时出现在 properties 和 data 里,小程序自定义组件不允许这样,同名的话行为取决于实现细节,开发者工具也会告警。既然它需要由外部传入控制弹层显隐,就留在 properties 里,data 只保留组件自己维护的 tempFilePath。

fe
  • 一、小程序里画海报,为什么只能用 canvas
  • 二、方案一,用旧版 canvas 接口封装成组件
    • 2.1 组件配置
    • 2.2 结构,画布 + 弹层 + 两个按钮
    • 2.3 样式,重点是把画布藏起来
    • 2.4 绘制逻辑,一行一行画上去
    • 2.5 调用与效果
  • 三、方案二,canvas 2d 加一个通用 Draw 类
    • 3.1 页面里怎么用
  • 四、两套方案怎么选
  • 五、这几年变了什么
  • 总结
  • 参考

← 初探vscode插件开发 从环境搭建到发布上架Vue3笔记总结 组合式API与新特性完整梳理 →