
从服务端签名、缓存设计到前端静默降级,记录把微信分享卡片接入个人站的完整踩坑过程。
背景
个人站用 Next.js App Router 构建,在微信内分享链接时,默认只显示裸 URL——既不展示标题,也没有封面图,严重影响传播效果。
解决这个问题有两个层次:
- OG 元标签(兜底):在
<head>写好og:title / og:description / og:image,微信爬虫抓取后在部分场景下能渲染卡片。 - JS-SDK 自定义分享(完整体验):通过公众号 JS-SDK 主动调用
updateAppMessageShareData和updateTimelineShareData,精确控制"发给好友"和"朋友圈"的标题、描述、图片。
本文记录第二种方案的完整实现,包含签名算法、服务端缓存、前端组件设计,以及若干踩坑点。
整体架构
浏览器(微信内)└─ WechatShareClient (Client Component)├─ 检测 UA → /MicroMessenger/i├─ 动态加载 jweixin-1.6.0.js├─ GET /api/wechat/js-sdk?url=<当前页URL>│ └─ 服务端:getJsapiTicket() → buildSignature() → 返回 JSON└─ wx.config() → wx.ready() → updateAppMessageShareData / updateTimelineShareData服务端缓存(进程内存)├─ access_token(7200s TTL,提前 5min 刷新)└─ jsapi_ticket(7200s TTL,提前 5min 刷新)
不引入 Redis 或任何新依赖,缓存依靠 Node.js 进程内存;哈希使用 Node 内置 crypto 模块。
环境变量
# .env.localNEXT_PUBLIC_SITE_URL=https://your-domain.comWECHAT_APP_ID=wx_xxxxxxxxxxxxxxxxxWECHAT_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxWECHAT_DEBUG=false # 可选,true 时在微信内显示调试面板
⚠️
WECHAT_APP_SECRET绝对不能加NEXT_PUBLIC_前缀,否则会暴露到客户端 bundle。
服务端实现
1. 配置校验(lib/wechat/config.ts)
export interface WechatConfig {appId: string;appSecret: string;debug: boolean;}export function getWechatConfig(): WechatConfig {const appId = process.env.WECHAT_APP_ID;const appSecret = process.env.WECHAT_APP_SECRET;if (!appId || !appSecret) {throw new Error("WECHAT_APP_ID and WECHAT_APP_SECRET must be set");}return { appId, appSecret, debug: process.env.WECHAT_DEBUG === "true" };}
把校验集中在一处——路由和业务模块都通过这个函数取配置,缺少环境变量时在启动阶段就能快速定位。
2. Token / Ticket 缓存(lib/wechat/client.ts)
微信 access_token 和 jsapi_ticket 的有效期均为 7200 秒(2 小时),而且每个公众号每天调用上限为 2000 次。如果每次分享请求都去微信服务器换 ticket,很快就会触达上限。
解决方式是进程内存缓存,在过期前 5 分钟提前刷新:
interface TokenCache {value: string;expiresAt: number;}let accessTokenCache: TokenCache | null = null;let jsapiTicketCache: TokenCache | null = null;const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 提前 5 分钟刷新function isCacheValid(cache: TokenCache | null): cache is TokenCache {return cache !== null && Date.now() < cache.expiresAt - REFRESH_BUFFER_MS;}export async function getJsapiTicket(): Promise<string> {if (isCacheValid(jsapiTicketCache)) {return jsapiTicketCache.value;}const accessToken = await getAccessToken();const ticket = await fetchJsapiTicket(accessToken);jsapiTicketCache = {value: ticket,expiresAt: Date.now() + 7200 * 1000,};return ticket;}
权衡点:进程内存缓存在多实例部署(如 Vercel Serverless)下,每个实例都会独立缓存,最坏情况下 N 个实例同时冷启动时会有 N 次并发的 token 请求。对个人站的流量规模来说完全可接受;如果是高并发场景,换成 Redis 即可,接口不变。
3. 签名算法(lib/wechat/signature.ts)
微信的签名规则核心有两个坑:
坑一:字段名排序
签名字符串必须按字母序排列:jsapi_ticket → noncestr → timestamp → url,且字段名全小写(注意 noncestr 不是 nonceStr)。
export function computeSignature(params: WechatSignatureParams): string {const str = [`jsapi_ticket=${params.jsapiTicket}`,`noncestr=${params.nonceStr}`, // ← 字段名小写`timestamp=${params.timestamp}`,`url=${params.url}`,].join("&");return createHash("sha1").update(str).digest("hex");}
坑二:URL 必须去掉 hash
export function normalizeUrlForSign(rawUrl: string): string {const hashIndex = rawUrl.indexOf("#");return hashIndex === -1 ? rawUrl : rawUrl.slice(0, hashIndex);}
微信官方文档明确说明:签名用的 URL 是"当前网页的URL,不包含#及其后面部分"。这个细节如果漏掉,签名会一直校验失败,而且报错信息完全不指向这里,非常难定位。
4. API 路由(app/api/wechat/js-sdk/route.ts)
export const runtime = "nodejs"; // 必须,crypto 模块只在 Node runtime 可用export async function GET(request: NextRequest): Promise<NextResponse> {const pageUrl = request.nextUrl.searchParams.get("url");if (!pageUrl || !isValidHttpUrl(pageUrl)) {return NextResponse.json({ error: "missing_or_invalid_url" },{ status: 400 });}try {const { appId } = getWechatConfig();const ticket = await getJsapiTicket();const { nonceStr, timestamp, signature } = buildSignature(ticket, pageUrl);return NextResponse.json({ appId, timestamp, nonceStr, signature });} catch (err) {// 日志记录错误信息,但响应体不暴露内部细节console.error("[wechat/js-sdk] sign failed:", err instanceof Error ? err.message : err);return NextResponse.json({ error: "wechat_sign_failed" }, { status: 500 });}}
注意 runtime = "nodejs" 这一行——Next.js 的 Edge Runtime 不包含 crypto 模块,必须显式指定 Node.js runtime,否则会在部署后遇到神秘的运行时错误。
前端实现
WechatShareClient:纯副作用的客户端组件
"use client";export default function WechatShareClient({ share }: Props) {useEffect(() => {// 非微信环境静默退出if (!isWechat()) return;const pageUrl = window.location.href.split("#")[0];void (async () => {try {await loadWxScript(); // 动态加载 JSSDKconst sig = await fetchSignature(pageUrl); // 拿签名window.wx?.config({ ... });window.wx?.ready(() => {window.wx?.updateAppMessageShareData({ ...share });window.wx?.updateTimelineShareData({ ...share });});window.wx?.error((res) => {console.warn("[WechatShare] wx.error:", res.errMsg);});} catch (err) {// 静默降级——OG 标签作为兜底console.warn("[WechatShare] init failed:", err instanceof Error ? err.message : err);}})();}, [share.title, share.desc, share.link, share.imgUrl]);return null; // 纯副作用,不渲染任何 DOM}
几个设计决策:
return null:组件只做副作用,不产生任何 DOM 节点,挂载成本极低。- UA 检测优先:非微信环境直接
return,不发请求、不加载外部脚本,对普通用户零开销。 - 动态加载脚本:
jweixin-1.6.0.js只在微信内、且运行时才加载,不污染主 bundle。 try/catch全包裹:任何环节失败都静默降级,普通用户不受影响,OG 标签作为兜底保证最低体验。
在详情页挂载
服务端组件拿到页面数据后,直接将分享参数传给客户端组件,不重复请求数据源:
// app/blog/[slug]/page.tsx (Server Component)import WechatShareClient from "@/components/wechat/WechatShareClient";import { buildShareData } from "@/lib/wechat/share";export default async function BlogDetailPage({ params }: Props) {const post = await getPost(params.slug);const share = buildShareData({title: post.title,desc: post.summary,link: `${siteUrl}/blog/${params.slug}`,imgUrl: post.cover ? `${siteUrl}${post.cover}` : undefined,});return (<><WechatShareClient share={share} />{/* 页面正文 */}</>);}
buildShareData 提供站点级默认值,页面传 overrides 覆盖,任何字段都不强制必填。
iOS 的特殊问题
iOS 微信的签名 URL 必须是"首次进入页面的 URL",而不是路由跳转后的 URL。
也就是说:
- 用户从外部直接打开
/blog/abc,签名 URL 就是/blog/abc✅ - 用户先打开首页,然后通过 Next.js 的客户端路由跳转到
/blog/abc,但签名 URL 仍然是首页的地址 ❌
这个问题在 Android 微信上不存在(每次路由跳转都能重新签名)。
临时缓解方案:关键的详情页避免完全依赖客户端路由进入,保证用户能通过直接访问 URL 触达;或者在组件首次挂载时记录 window.location.href,而不是在用户点击分享时才获取。
单元测试
测试策略是对纯函数直接断言,对 API 路由 mock 外部依赖。
签名算法用微信官方文档里的示例值做校准测试:
test("computeSignature 与微信官方示例一致", () => {const result = computeSignature({jsapiTicket: "sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg==",nonceStr: "Wm3WZYTPz0wzccnW",timestamp: 1414587457,url: "http://mp.weixin.qq.com?params=value",});assert.equal(result, "ebe852d573b74e8cb86a0faf9f2f733922bf8a96");});
这个测试有三重价值:验证算法正确、验证字段排序、验证 SHA1 输出格式。只要这个测试通过,签名逻辑就与微信官方保持一致。
联调清单
真机测试前需要确认:
- 公众号后台「JS接口安全域名」已配置线上域名
- 站点 HTTPS 且公网可访问
- 分享图片必须是 HTTPS 且公网可访问的绝对路径
- 测试时在 URL 后追加
?v=N参数绕过微信强缓存 - 微信开发者工具可开
debug: true查看wx.config返回结果
小结
| 层次 | 方案 | 作用 |
|---|---|---|
| OG 元标签 | generateMetadata 写 openGraph | 兜底,爬虫解析 |
| JS-SDK 签名 | 服务端签名 API + 进程内缓存 | 精确控制分享内容 |
| 客户端组件 | WechatShareClient,UA 检测 + 静默降级 | 微信内增强体验 |
三层叠加,非微信环境零开销,微信内获得完整的自定义分享卡片体验,而且整个实现没有引入任何新的 npm 依赖。