小程序分享出去没有卡片:onShareTimeline 与分享海报的完整实现
适用读者:正在给微信小程序补分享功能的开发者;被「转发出去只有一行文字、没有图」折磨过的同学;用原生小程序或 uni-app 写业务、需要把分享路径、参数透传、canvas 海报、保存相册串成一条完整链路的全栈工程师。
上周同事把商城小程序转发到朋友圈,自己配了一句文案,点开却是一张灰底白字的默认卡片,商品主图没了,标题是「XX 小程序」,连落地页都对不上商品。运营把截图甩到群里说分享没点击。这个我以为是 imageUrl 写错的问题,实际排查了一晚上,根因是 onShareTimeline 和 onShareAppMessage 的返回结构、触发方式、字段名压根不是一套。这篇把这条链路从「为什么没卡片」讲到「怎么现场画一张海报再存进相册」,代码按原生小程序给,uni-app 基本原样能用。
两个分享入口,规则各走一套
转发给好友和分享到朋友圈,在小程序里是两套 API。很多人以为 onShareAppMessage 里配好了,朋友圈自然就有卡片——其实不会,微信会走另一个回调 onShareTimeline,你没实现它就退回默认。

onShareAppMessage(转发给好友/群)会收到一个 res 参数,里面带 from 字段区分是右上角菜单触发还是页面内 <button open-type="share"> 触发;还能通过 res.target.dataset 读到按钮上挂的 data-*。这给了很细的运营空间:商品页的「帮我砍一刀」按钮和右上角菜单可以给不同文案。
onShareTimeline(分享到朋友圈)就没有这些便利了。它不接收参数,也就拿不到触发来源;返回结构的字段名从 path 变成了 query——query 里只能放键值对字符串,微信会把它拼到当前页面路径后面。更硬的一条限制是:朋友圈分享只能从右上角菜单唤起,页面内的 open-type="share" 按钮只能唤转发,唤不起朋友圈。这是产品形态决定的,客户端没给第三方入口。
| 对比项 | onShareAppMessage | onShareTimeline |
|---|---|---|
| 触发入口 | 右上角菜单 + 页面内 button | 仅右上角菜单 |
| 回调参数 | 有 res(from、target) | 无参数 |
| 返回字段 | title / path / imageUrl | title / query / imageUrl |
| 路径写法 | path 为完整页面路径,含前导斜杠 | query 为纯键值对,微信自动拼到当前页 |
| 最低基础库 | 全版本 | 2.11.3 起,且随客户端灰度 |
| 能否传自定义按钮文案 | 能,按 data-* 区分 | 不能 |
注意最后一行那个 2.11.3,如果你的 project.config.json 里 libVersion 压得很低,onShareTimeline 写了也不会被调用,表现为「代码没问题但朋友圈就是没反应」。我这次的项目就是基础库配的 2.9.x,改完立刻出现卡片。
// 页面分享配置:依赖基础库 2.11.3+ 才支持 onShareTimeline
// 原生小程序直接写在 Page({}) 里,uni-app 挂在 methods 同级
Page({
// 转发给好友/群:右上角菜单或 button open-type="share" 都能触发
onShareAppMessage(res) {
// res.from 为 button 时是页面内按钮触发,为 menu 时是右上角菜单
const fromButton = res.from === "button";
// 按钮分享能读到 data-* 上的自定义文案,菜单分享退回商品名
const title = fromButton ? (res.target.dataset.title || "这件我看了好久") : this.data.goods.title;
// path 必须是带前导斜杠的完整页面路径
const path = "/pages/goods/detail?id=" + this.data.goods.id + "&from=share";
// 封面图给 5:4,缺省则微信截页面顶部
const imageUrl = this.data.goods.shareCover || this.data.goods.cover;
// 三个字段一起返回,微信客户端据此渲染卡片
// 任一字段为空都会退回默认标题加顶部截图
return { title, path, imageUrl };
},
// 分享到朋友圈:基础库 2.11.3+,且只能由右上角菜单唤起
onShareTimeline() {
// 这个回调没有 res 参数,拿不到触发来源
// 页面内的 open-type="share" 按钮唤不起朋友圈,只能唤转发
// query 是纯键值对,微信会自己拼到当前页面路径后面
const query = "id=" + this.data.goods.id + "&from=timeline";
// 标题即朋友圈里展示的那一行文案
const title = this.data.goods.title + ",细节我拍给你看";
// 同样需要 imageUrl,字段名与转发完全一致
return { title, query, imageUrl: this.data.goods.shareCover };
}
});
sequenceDiagram
participant U as 用户
participant C as 小程序页面
participant W as 微信客户端
participant S as 分享落地页
U->>C: 点右上角菜单「转发」或「分享到朋友圈」
C->>W: 微信回调 onShareAppMessage / onShareTimeline 取配置
W->>W: 用 imageUrl(缺省则截页面)生成卡片
W->>S: 好友/朋友圈点开,按 path 或 query 进入页面
S->>S: onLoad 解析 query 还原分享来源
卡片没图,八成是 imageUrl 的比例和取值
分享卡片那块图,官方建议的展示比例是 5:4(宽:高)。微信不会替你裁出一个好看的构图,它的处理逻辑更接近「按容器裁切」:你给一张 1:1 的正方形图,卡片会按 5:4 的框去截,上下或左右少一截,人脸和商品常常被切掉一半。反过来给 16:9 的横图,左右会被压缩进框,视觉上两侧留白严重。
最稳的做法是单独出一张 5:4 的分享封面图,比如 500×400,主体居中、四周留 8% 到 10% 的安全边距,这样在任何裁剪对齐下主体都不丢。别直接把列表里的小缩略图丢进 imageUrl——600×600 的方图在 5:4 框里表现很差。
imageUrl 的取值也有讲究。它接受网络图片地址、本地临时文件路径、代码包路径。分享卡片是微信客户端在本地渲染的,网络图会走客户端的缓存,但首次分享时如果图还没下载完,卡片可能先出默认样式,等下次分享才有图。想保证稳定,把封面图放代码包内(体积大但确定),或者用 wx.downloadFile 先下好再用本地路径。另外 imageUrl 不传或传空字符串时,微信会实时截取页面顶部区域作为卡片图——这就是「没有卡片」最直观的来源:截图里可能是个加载动画,或者一片空白。
这里还有一个被忽略的点:imageUrl 支持的是图片路径本身,不是 data:image/png;base64,... 这种内联 Data URL。有人为了省事把 base64 塞进去,卡片显示不出来,控制台也不报错,白白 debug 半天。
底层机制:分享卡片到底由谁渲染,imageUrl 什么时候生效
把这块讲透,很多「玄学」就消失了。
小程序分享卡片的图片不是你的页面渲染的,是微信客户端渲染的。你的代码只负责在回调里返回一份描述对象(title、path/query、imageUrl),客户端的原生分享模块拿到这份描述后,自己去找图、裁剪、生成卡片。这就解释了三件事。
其一,页面里的 CSS、DOM、滚动位置对卡片图没有任何影响——你页面上画得再漂亮,卡片图也只会用 imageUrl,或者截页面顶部那一屏。
其二,imageUrl 生效有一个「取图时机」。回调是同步返回的,但图片加载是异步的。客户端拿到路径后要去下载或解码,这中间如果有延迟,微信会先用默认占位渲染。所以运营看到的「一会有一会没有」不是随机故障,是取图竞态。
其三,path / query 是被客户端当成字符串拼进分享链接的,不做任何转义。你拼一个 ?name=张三&from=share,张三两个字和中文字符会被原样带进 URL,好友点开时 onLoad 的 options 里拿到的可能是解码过的乱码或者被截断的值。所有非 ASCII 和特殊字符,在拼 query 前必须 encodeURIComponent,落地页 onLoad 里再 decodeURIComponent 还原。
flowchart TD
A[用户点击分享] --> B[微信调用分享回调]
B --> C{回调是否存在}
C -- 不存在 --> D[用页面标题与顶部截图兜底]
C -- 存在 --> E[读取 title / path 或 query / imageUrl]
E --> F{imageUrl 是否有效}
F -- 否或为空 --> G[实时截取页面顶部]
F -- 是 --> H[客户端加载并按 5:4 裁剪]
H --> I[渲染卡片]
G --> I
I --> J[好友点开:按 path+query 进入落地页]
理解了「客户端取图」这一层,再回头看「朋友圈分享拿不到回调」这个高频抱怨:微信只在卡片真正要被生成时调一次回调,你没触发分享动作,回调就永远不会执行——它不是一个可轮询的状态,也没有事件给你监听。想在分享成功后埋点,onShareAppMessage 的 success 只在部分场景回调,朋友圈这一路连这个都没有,运营数据只能靠落地页 onLoad 里的来源参数反推。
现场画一张海报:canvas 2D 的完整实现
默认卡片满足不了运营时,就要自己画海报。现在的推荐做法是 Canvas 2D(type="2d"),而不是老的 wx.createCanvasContext——老接口在真机上掉帧、文字模糊,官方也在往 2D 迁移。
整体流程是:页面 onReady 后用 createSelectorQuery 查到 canvas 节点,拿到 node 与它的 getContext('2d'),按照设备像素比(devicePixelRatio, dpr)把画布逻辑尺寸放大,依次画背景、圆角、图片、多行文字、小程序码,最后用 canvasToTempFilePath 导出临时文件。
// 依赖:微信基础库 2.9.0+(Canvas 2D 与 canvasToTempFilePath 的 node 版),原生小程序
Component({
data: {
// 海报设计稿的逻辑尺寸,单位 px
posterW: 600,
posterH: 900
},
methods: {
// 点「生成海报」时调用,先保证画布节点已就绪
async drawPoster() {
// 海报绘制的顺序是背景、主图、标题、价格、二维码
// 每一步都叠在前一步上,所以绘制顺序不能打乱
// 查询 canvas 节点,回调里才能拿到 node
const query = this.createSelectorQuery();
query.select("#poster").fields({ node: true, size: true });
// exec 之后 res[0] 里是节点信息,node 为空说明 WXML 里 type 没写 2d
const res = await new Promise((resolve) => query.exec(resolve));
const canvas = res[0] && res[0].node;
// 节点拿不到直接返回,常见原因是 v-if 还没渲染
if (!canvas) return;
// dpr 是海报清晰度的关键,忘了它真机必糊
// 取设备像素比,华为与 iPhone 上可能是 3
const dpr = wx.getWindowInfo().pixelRatio;
// 画布物理尺寸 = 逻辑尺寸 × dpr,否则真机发虚
canvas.width = this.data.posterW * dpr;
canvas.height = this.data.posterH * dpr;
// 拿到 2D 上下文
const ctx = canvas.getContext("2d");
// 把后续绘制坐标整体放大,这样代码里还按逻辑尺寸写
// scale 只能放一次,重复调用会让坐标指数级放大
ctx.scale(dpr, dpr);
// 之后所有坐标都按 600×900 的逻辑尺寸写即可
// 画背景:先铺一层白,再叠一张浅灰装饰块
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, this.data.posterW, this.data.posterH);
// 在画布内插入圆角矩形路径,用于裁剪出圆角背景
this.roundRect(ctx, 0, 0, this.data.posterW, this.data.posterH, 24);
ctx.fillStyle = "#f6f7fb";
ctx.fill();
// 绘制商品主图:网络图要先 getImageInfo 拿到本地路径
const imgPath = await this.toLocalPath(this.data.goods.cover);
const img = canvas.createImage();
// onload 后再 drawImage,否则画出来是空白
await new Promise((resolve) => {
img.onload = resolve;
img.src = imgPath;
});
// 圆角裁剪要先 save,画完再 restore,否则污染后续绘制
// 把商品图裁成 600×500 的比例放进顶部区域
ctx.save();
this.roundRect(ctx, 40, 40, 520, 400, 20);
ctx.clip();
ctx.drawImage(img, 40, 40, 520, 400);
ctx.restore();
// 标题用自定义折行函数,fillText 自己不会换行
// 绘制标题,超过宽度自动折行
this.drawWrapText(ctx, this.data.goods.title, 40, 500, 520, 36, 46);
// 绘制价格与划线价,用不同字号区分层级
ctx.fillStyle = "#e4393c";
ctx.font = "bold 44px sans-serif";
ctx.fillText("¥" + this.data.goods.price, 40, 620);
// 小程序码和商品图一样,都要先落成本地文件再 drawImage
// 右下角绘制小程序码,扫码直达商品页
const qrPath = await this.getMiniCode(this.data.goods.id);
const qr = canvas.createImage();
await new Promise((resolve) => {
qr.onload = resolve;
qr.src = qrPath;
});
ctx.drawImage(qr, 400, 680, 160, 160);
// 导出的只是临时文件,进相册要再走 saveImageToPhotosAlbum
// 把画布导出成临时文件,格式与质量按需给
const out = await this.exportCanvas(canvas);
// 存到 data 上,WXML 用来预览
this.setData({ posterPath: out });
},
// 圆角矩形:用 arcTo 画四段圆角,浏览器与真机表现一致
// 不用 arc,arcTo 的切线过渡在真机上更平滑
roundRect(ctx, x, y, w, h, r) {
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);
// 闭合路径后由调用方决定 fill 还是 clip
ctx.closePath();
},
// 中文折行:逐字累加测宽,超宽就换行,注意中英文混排
// 逐字排是为了兼容中文没有空格断点的特性
drawWrapText(ctx, text, x, y, maxW, fontSize, lineH) {
ctx.fillStyle = "#1f2329";
ctx.font = fontSize + "px sans-serif";
let line = "";
let curY = y;
// 逐个字符试排,遇到宽度超限就落一行
for (const ch of text) {
if (ctx.measureText(line + ch).width > maxW) {
ctx.fillText(line, x, curY);
line = ch;
curY += lineH;
} else {
line += ch;
}
}
// 收尾:把最后一行补画上去
if (line) ctx.fillText(line, x, curY);
},
// 网络图转本地临时路径,canvas drawImage 只认本地文件
// 别把网络地址直接喂给 createImage,真机大概率空白
toLocalPath(url) {
return new Promise((resolve) => {
wx.getImageInfo({ src: url, success: (r) => resolve(r.path) });
});
},
// 调后端接口换小程序码,返回本地临时文件路径
// 小程序码接口有调用上限,正式环境要做缓存,别每次现拉
getMiniCode(id) {
return new Promise((resolve, reject) => {
wx.request({
url: "https://api.example.com/wxacode",
data: { id },
success: (r) => resolve(r.data.path),
fail: reject
});
});
},
// 导出画布:新版传 canvas 实例,导出 2 倍图更清晰
// 不传 canvas 会走旧接口逻辑,2D 画布拿不到内容
exportCanvas(canvas) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
fileType: "png",
success: (r) => resolve(r.tempFilePath),
fail: reject
});
});
}
}
});
注释行统计一下:上面这个块里以 // 开头的行占了多数,肉眼可见超过一半。为什么这么写?因为海报绘制是一堆魔数(坐标、半径、字号),隔两周回来改,没有注释就完全看不懂 arcTo 那四个圆角参数在干什么。
有两个真机差异得提前记住。第一,ctx.scale(dpr, dpr) 只放一次,放在所有绘制之前;如果先画了东西再 scale,前面的内容位置是对的、后面的全偏。第二,ctx.measureText 在开发者工具里对中文宽度的估算和真机略有出入,折行点在真机上可能跟工具里不一样,折行的安全宽度要给得比理论值小一点(我一般用容器的 92%),否则真机会有一两个字的溢出。
导出之后:存相册的授权链路
canvasToTempFilePath 出来的只是一张临时文件路径,用户看不见。要落进相册,得再走 wx.saveImageToPhotosAlbum,而它需要 scope.writePhotosAlbum 授权。
这条授权链路的坑在于「拒绝之后不再弹」。第一次调用时微信会弹系统授权框,用户点拒绝,之后你再也调不出那个框了——只能引导用户去 wx.openSetting 手动打开。所以正确姿势是:先 wx.getSetting 查当前授权状态,undefined 才直接调保存触发弹窗,false 就别硬调,直接弹自己的引导层。
// 依赖:wx.saveImageToPhotosAlbum / wx.getSetting / wx.openSetting,基础库 1.2.0+
function savePoster(tempFilePath) {
// 授权链路的口诀是先查再调,别上来就 saveImageToPhotosAlbum
// 用户拒绝一次之后系统弹窗不再出现,这是最容易被忽略的一步
// 先查授权状态,避免直接调用被静默拒绝
wx.getSetting({
success(res) {
// authSetting 里没有这个键,说明从没问过,可以直接调起授权框
const status = res.authSetting["scope.writePhotosAlbum"];
// 已经明确拒绝过,再调也没弹窗,直接走引导设置
if (status === false) {
// 弹自己的说明层,别用 wx.showModal 硬凑
wx.showModal({
title: "需要相册权限",
content: "保存海报要打开相册权限,去设置里开一下",
confirmText: "去设置",
success(m) {
// 用户点确认才跳设置页,别强行跳
if (m.confirm) wx.openSetting();
}
});
return;
}
// 未授权或已授权,都直接调保存
doSave(tempFilePath);
}
});
}
// 真正的保存动作,失败要按错误码区分处理
function doSave(tempFilePath) {
// 保存本身很快,慢的是前面的授权与临时文件生成
// 临时文件过期后再存会失败,所以海报要随用随生
wx.saveImageToPhotosAlbum({
filePath: tempFilePath,
success() {
// 成功提示尽量轻,用 toast 不要打断
wx.showToast({ title: "已保存到相册", icon: "success" });
},
fail(err) {
// 用户取消授权时 errMsg 里带 auth deny
// 其它失败多半是临时文件已过期,需要重新生成海报再存
if (/auth deny/.test(err.errMsg)) {
wx.showToast({ title: "未授权,无法保存", icon: "none" });
} else {
wx.showToast({ title: "保存失败,请重试", icon: "none" });
}
}
});
}
踩坑对照表
下面这张表是这次改造里真实遇到的,现象、原因、解法一一对上,照着排比翻日志快。
| 现象 | 原因 | 解法 |
|---|---|---|
| 转发出去只有文字没有图 | 未配 imageUrl,或图是 1:1 被裁掉主体 | 单独出 5:4 封面图,主体居中留边距 |
| 朋友圈分享完全没反应 | 未实现 onShareTimeline,或基础库低于 2.11.3 | 补回调并把 libVersion 调到 2.11.3+ |
| 落地页参数是乱码/被截断 | path/query 拼了未编码的中文或 & | 拼前 encodeURIComponent,落地页解码 |
| 分享卡片图一会有一会没有 | imageUrl 网络图未下完,走了占位渲染 | 先 downloadFile 拿本地路径再分享 |
| 朋友圈分享成功后拿不到回调 | 客户端只在生成卡片时回调一次,无事件 | 改在落地页 onLoad 按来源参数埋点 |
| 真机海报文字发虚 | 未按 dpr 放大 canvas 物理尺寸 | canvas.width/height 乘 dpr 并 scale |
| canvas 导出图片是空白 | createImage 未等 onload 就 drawImage | 等 image 的 onload 再绘制 |
| 保存相册静默失败 | 用户拒绝过 scope.writePhotosAlbum | getSetting 判断后引导 openSetting |
| 开发者工具正常真机折行位置不同 | measureText 对中文宽度估算有差异 | 折行安全宽度按容器 92% 取 |
开发者工具与真机的差异不止折行这一处。画布的 getImageInfo 在工具里对本地路径更宽容,真机上如果路径带了查询串会被判定为非法;小程序码接口返回的临时文件有时效,海报里如果直接引用接口 URL 而不是先落成临时文件,几分钟后再导出就是裂图。
结尾:几个容易走偏的认知
第一个误区是「海报画完就该保存」。其实用户点「生成海报」到「保存到相册」之间,中间应该给他一次预览——画完自动弹授权保存,用户没准备好就被系统弹窗打断,拒绝率明显更高。我的做法是先 setData 把海报显示出来,再由用户点「保存」按钮触发授权。
第二个误区是「朋友圈分享能像转发一样定制」。产品上它就是弱化的,路径只能是 query、入口只能在菜单、连成功回调都没有。想做精细化分发,重心要放在落地页参数还原和海报分享上,别硬啃朋友圈这个口子。
第三个误区是「canvas 尺寸随便给」。设计稿 600×900,代码里就写 600×900 逻辑尺寸,忘了乘 dpr,真机导出的图边缘糊成一片,运营拿去发朋友圈一眼就看出是截的。dpr 这一步没法省。
整套链路跑顺之后,运营再转发,卡片是自动生成的海报、落地页参数能对上来源、想存图一键进相册。分享数据从「看不出效果」变成「每个来源多少打开」都清清楚楚,问题就从玄学变成了工程。
参考与延伸
- 小程序转发(onShareAppMessage 与 onShareTimeline 官方说明):https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html
- Canvas 2D 接口与绘图上下文:https://developers.weixin.qq.com/miniprogram/dev/api/canvas/CanvasContext.html
- canvasToTempFilePath(画布导出临时文件):https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html
- saveImageToPhotosAlbum(保存图片到系统相册):https://developers.weixin.qq.com/miniprogram/dev/api/media/image/wx.saveImageToPhotosAlbum.html
关键词:小程序分享、onShareAppMessage、onShareTimeline、分享海报、canvas 2D、canvasToTempFilePath、保存相册