去年帮一个商城项目做分享海报,需求简单得不能再简单:一张背景图 + 商品主图 + 标题 + 二维码,合在一张图上给用户保存。我以为是半天活儿,结果在小程序、H5、App 三个端各栽了一跟头。小程序真机上文字糊得看不清,H5 上传到后端是空白图,App 端保存相册按钮点了没反应。
问题不在业务逻辑,全在 canvas 各端实现的差异上。这篇文章把当时踩过的坑整理成一份清单,配上现在能直接用的代码,省得后来人再摸一遍。
先别碰旧版 canvas
uni-app 早期的 uni.createCanvasContext(canvasId) 已经明确不推荐用了。它有几个硬伤:
- 绘制 API 是异步队列,所有指令提交后要调
ctx.draw()才会真正执行。这意味着你不能在绘制中途读取中间状态。 - 小程序端会产生额外的临时 canvas,真机上文字和图片容易糊。
- 不支持
ctx.measureText(),文字换行只能靠猜。
现在的方案是 type="2d" 的 canvas,配合 W3C 标准的 2D 上下文。除了多端加载图片的方式不统一,其他 API 都跟浏览器里的 canvas 一致。
<canvas
type="2d"
id="poster"
canvas-id="poster"
class="poster-canvas"
/>
canvas-id 在新 API 下其实用不到了,但留着没坏处,某些低版本组件库还会读它。
拿到 canvas 节点:组件内和页面内不一样
页面里可以直接用 uni.createSelectorQuery(),但如果 canvas 写在自定义组件里,必须加 .in(instance),否则选不到节点——而且不报错,只是回调里 res[0] 是 null。
import { getCurrentInstance } from 'vue'
function getCanvasNode(selector = '#poster') {
return new Promise((resolve, reject) => {
const instance = getCurrentInstance()
// 页面内不需要 .in(),组件内必须要
// 统一写上也不会有副作用
const query = uni.createSelectorQuery()
// #ifdef MP-WEIXIN || APP-PLUS
if (instance) query.in(instance.proxy)
// #endif
query
.select(selector)
.fields({ node: true, size: true })
.exec(res => {
if (!res || !res[0] || !res[0].node) {
reject(new Error('canvas 节点没找到,检查选择器和 .in()'))
return
}
resolve({
canvas: res[0].node,
width: res[0].width,
height: res[0].height
})
})
})
}
回调里返回的 res[0] 有 node、width、height 三个字段。node 就是真正的 canvas 对象,不同端返回的东西类型不一样(小程序是原生节点,H5 是 HTMLCanvasElement),但用法一致。
尺寸初始化:顺序不能错
const dpr = uni.getSystemInfoSync().pixelRatio || 2
// 设置物理分辨率
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')
// 缩放坐标系,之后所有绘制都用逻辑像素
ctx.scale(dpr, dpr)
两个要点。第一,先设置 width/height,再 scale。设置尺寸会重置整个上下文状态,先 scale 会被清掉。第二,scale 之后,绘制坐标全部按逻辑像素来,也就是你在 CSS 里看到的尺寸,不用再乘 dpr 了。
这个 dpr 缩放是真机不糊的关键。不缩放的话,物理分辨率只有屏幕的一半,导出的图放大一看全是毛边。
加载图片:三端三个写法
这是踩得最深的一个坑。三端加载图片的方式完全不一样。
function loadImage(canvas, src, timeout = 10000) {
return new Promise((resolve, reject) => {
let img = null
// 小程序和 App 走 canvas.createImage
// #ifdef MP-WEIXIN || APP-PLUS
img = canvas.createImage()
// #endif
// H5 端直接 new Image()
// #ifdef H5
img = new Image()
img.crossOrigin = 'anonymous'
// #endif
if (!img) {
reject(new Error('当前端无法创建 Image 对象'))
return
}
const timer = setTimeout(() => {
reject(new Error(`图片加载超时: ${src}`))
}, timeout)
img.onload = () => {
clearTimeout(timer)
resolve(img)
}
img.onerror = (e) => {
clearTimeout(timer)
reject(new Error(`图片加载失败: ${src}`))
}
img.src = src
})
}
几个说明。
crossOrigin = 'anonymous' 是 H5 端必须加的。不加的话,只要图片来自不同域名,canvas 就被”污染”,之后 toDataURL() 会抛 SecurityError,甚至 canvasToTempFilePath 直接返回空白图。这个错误非常隐蔽,因为绘制阶段看不出任何异常。
小程序端的网络图片要在后台配置 downloadFile 白名单。不配置的话,真机上加载会失败,但开发工具里能加载——因为开发工具默认关了域名校验。这个坑让很多人误以为代码没问题,一上真机才发现。
App 端图片路径写错了也很麻烦。网络图直接用 URL,本地 static 目录下的图要写相对路径 /static/xxx.png。绝对路径在 App 端加载不了。
canvas.createImage() 返回的 Image 对象在微信小程序里没有 setAttribute,别手滑去调它。
批量加载
async function loadAll(canvas, sources) {
const list = await Promise.all(
sources.map(src => loadImage(canvas, src))
)
return list
}
不要用 for 循环里 await 串行加载,五张图能慢出半秒。用 Promise.all 并发跑,网络好的时候五张图几乎同时回来。
绘制圆角图片
canvas 没有原生的圆角图片绘制,需要先裁剪再绘制。
function drawRoundImage(ctx, img, x, y, w, h, r) {
ctx.save()
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
ctx.clip()
ctx.drawImage(img, x, y, w, h)
ctx.restore()
}
save 和 restore 一定要配对。裁剪会一直生效,不恢复的话后面所有绘制都被裁进这个圆角矩形里。
还有一个细节:arcTo 的四个参数是先画到拐角,再画到下一点。顺序必须是顺时针一圈,写成逆时针图形会歪掉。上面这个顺序对应的是”从左上角开始,顺时针绕一圈”。
文字换行:measureText 是关键
中文和英文混排的时候,按字符宽度硬换行会很难看。用 measureText 逐个测量是目前最稳的方案。
function wrapText(ctx, text, options) {
const {
x, y,
maxWidth,
lineHeight,
maxLines = 2
} = options
const chars = String(text).split('')
const lines = []
let current = ''
for (let i = 0; i < chars.length; i++) {
const next = current + chars[i]
const width = ctx.measureText(next).width
if (width > maxWidth && current) {
lines.push(current)
current = chars[i]
if (lines.length >= maxLines) break
} else {
current = next
}
}
if (lines.length < maxLines && current) {
lines.push(current)
}
// 超出行数时最后一行加省略号
if (lines.length === maxLines && chars.slice(-1).length > 0) {
const lastIndex = lines.length - 1
let last = lines[lastIndex]
while (ctx.measureText(last + '…').width > maxWidth && last.length) {
last = last.slice(0, -1)
}
lines[lastIndex] = last + '…'
}
lines.forEach((line, i) => {
ctx.fillText(line, x, y + i * lineHeight)
})
return lines.length
}
几个注意点。
ctx.measureText 只在 type="2d" 模式下可用。旧版的 canvas context 没有这个方法,这也是必须切新 API 的原因之一。
字体必须先设置好。测量依赖当前 ctx.font,如果先测量再设字体,宽度是按默认字体算的,换行位置会错。
ctx.font = 'bold 16px sans-serif'
ctx.fillStyle = '#222'
const lineCount = wrapText(ctx, title, {
x: 24,
y: 400,
maxWidth: 260,
lineHeight: 24,
maxLines: 2
})
小程序端字号只支持数字 + px,ctx.font = '16px' 或者带 weight 的写法都行。App 端如果用了自定义字体,得先通过 uni.loadFontFace 加载完毕再测量,否则宽度测量不准。
导出图片:三种格式三种坑
绘制完成后,导出成临时文件路径。
function exportCanvas(canvas, instance, options = {}) {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvas,
// 组件内必须传组件实例
...(instance ? { component: instance.proxy } : {}),
fileType: 'png',
quality: 1,
...options,
success: res => resolve(res.tempFilePath),
fail: err => reject(new Error(JSON.stringify(err)))
})
})
}
这里 canvas 参数是 canvas 节点对象,不是 canvasId。用新 API 就必须传节点,传 canvasId 会报找不到组件的错误。组件内调用时 component 参数也是必须的。
H5 端的特殊情况
H5 端调 canvasToTempFilePath,返回值是一个 blob URL,形如 blob:http://localhost:8080/xxx。这个 URL 在后端是没法直接下载的,上传到服务器之前要先转成 Blob 或者 base64。
async function h5ExportBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob) {
reject(new Error('导出失败,检查图片是否跨域污染了 canvas'))
return
}
resolve(blob)
}, 'image/png', 0.95)
})
}
如果 toBlob 回调里的 blob 是 null,八成是图片跨域污染。检查项包括:图片服务器是否返回了正确的 CORS 头,crossOrigin 有没有设置,两个是不是一致。
保存到相册:三端权限各不同
小程序端保存到相册需要用户授权,而且拒绝之后不能反复弹窗骚扰,必须走 openSetting 引导。
async function saveToAlbum(tempFilePath) {
// H5 端没有相册概念,走下载
// #ifdef H5
const a = document.createElement('a')
a.href = tempFilePath
a.download = `poster_${Date.now()}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
return
// #endif
// 小程序和 App
// #ifdef MP-WEIXIN || APP-PLUS
try {
await new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: resolve,
fail: reject
})
})
uni.showToast({ title: '已保存到相册', icon: 'success' })
} catch (err) {
// 用户主动拒绝授权
if (err && (err.errMsg || '').includes('auth deny') ||
(err && (err.errMsg || '').includes('authorize'))) {
uni.showModal({
title: '需要相册权限',
content: '保存海报需要访问相册,请在设置中开启',
confirmText: '去设置',
success: res => {
if (res.confirm) uni.openSetting()
}
})
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
// #endif
}
几点说明。
App 端需要在 manifest.json 里配置权限。app-plus 节点下的 distribute.android.permissions 里加上 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>,iOS 端要配置 NSPhotoLibraryAddUsageDescription 文案,不然提交审核会被打回来。
H5 端的下载提示。有些浏览器(尤其是微信内置浏览器)不支持 a.download 属性,点了没反应。更稳妥的做法是显示一张预览图,提示用户长按保存。检测方式可以在 UA 里查 MicroMessenger。
小程序端的错误对象。saveImageToPhotosAlbum 拒绝时的 err.errMsg 里包含 auth deny 或者 authorize,不同的基础库版本字符串略有差别,两个都判断一下比较稳。
完整案例:把上面这些拼成一首歌
写一个生成海报的完整函数,输入是数据,输出是临时文件路径。
import { getCurrentInstance } from 'vue'
const POSTER_W = 300
const POSTER_H = 500
export async function generatePoster(payload) {
const instance = getCurrentInstance()
const { canvas, width, height } = await getCanvasNode('#poster')
const dpr = uni.getSystemInfoSync().pixelRatio || 2
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
// 背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, POSTER_W, POSTER_H)
// 加载所有图片
const [cover, qrcode] = await Promise.all([
loadImage(canvas, payload.coverUrl),
loadImage(canvas, payload.qrcodeUrl)
])
// 主图
drawRoundImage(ctx, cover, 16, 16, POSTER_W - 32, 240, 12)
// 标题
ctx.font = 'bold 16px sans-serif'
ctx.fillStyle = '#222222'
ctx.textBaseline = 'top'
wrapText(ctx, payload.title, {
x: 16,
y: 274,
maxWidth: POSTER_W - 32,
lineHeight: 22,
maxLines: 2
})
// 价格
ctx.font = 'bold 20px sans-serif'
ctx.fillStyle = '#ff4d4f'
ctx.fillText(`¥${payload.price}`, 16, 330)
// 二维码
ctx.drawImage(qrcode, POSTER_W - 96, POSTER_H - 96, 80, 80)
// 底部提示
ctx.font = '12px sans-serif'
ctx.fillStyle = '#999999'
ctx.fillText('长按识别二维码查看', 16, POSTER_H - 40)
const tempFilePath = await exportCanvas(canvas, instance)
return tempFilePath
}
调用侧:
async function onShare() {
try {
uni.showLoading({ title: '生成中' })
const path = await generatePoster({
coverUrl: 'https://cdn.example.com/cover.png',
qrcodeUrl: 'https://cdn.example.com/qrcode.png',
title: '这是一段比较长的商品标题,测试一下换行和省略号的效果',
price: '199.00'
})
// H5 端 path 是 blob URL,小程序/App 是临时文件路径
await saveToAlbum(path)
} catch (e) {
uni.showToast({ title: e.message || '生成失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
几个零碎的坑
canvas 尺寸一旦设置就固定了。小程序端 canvas.width 只能设置一次,重复设置会被忽略。如果海报尺寸需要变化,得重新创建 canvas 节点(用 v-if 控制销毁重建)。这点和浏览器里的 canvas 不一样。
导出时机。所有图片必须等待 onload 之后再绘制,否则画上去的是一张空图。前文代码里 Promise.all 已经保证了这一点。如果绘制逻辑复杂,建议在每个 drawImage 之前都确认 img.complete 或者手动记个标记。
小程序端 ctx.filter 不支持。想加个模糊效果、灰度滤镜之类的,得自己在像素层面操作 getImageData 和 putImageData,性能很差。能不用就不用。
App 端 canvas 的层级。type="2d" 的 canvas 是原生组件,层级天然比普通 view 高。如果需要在 canvas 上盖一个 loading,得用 cover-view 之类的原生组件,普通 view 会被 canvas 挡在后面。
H5 端 canvas 元素定位。因为它是普通 DOM,会被父容器的 overflow: hidden 裁剪,也可能受 transform 影响产生模糊。type="2d" 的 canvas 最好脱离文档流,把宽高设成固定值,放在屏幕外或者用 visibility: hidden 藏起来。
反复生成时的内存。每次生成都会创建一个新的 canvas 上下文,如果用户连续点五次,内存会叠上去。做法是生成完成后手动把 canvas.width = 0 清一下(部分端支持),或者干脆用 v-if 控制,生成完就销毁。
最后
uni-app 的 canvas 2D 现在足够稳定,坑主要集中在多端行为差异上。把图片加载、导出、保存相册这三处的条件编译写清楚,剩下的绘制逻辑就是标准 canvas API,跟写网页时没区别。
如果项目里这些多端差异反复出现,建议把它们统一收进一个工具方法,各端差异封在工具里,业务代码只调一次生成函数。这样即使哪天再踩到新坑,也只改一个地方。

