小程序分享出去没有卡片:onShareTimeline 与分享海报的完整实现

2026-10-08 08:36:50 1 次浏览
微信小程序小程序开发onShareTimelinecanvas海报前端开发

适用读者:正在给微信小程序补分享功能的开发者;被「转发出去只有一行文字、没有图」折磨过的同学;用原生小程序或 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、保存相册

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。