SERIES · web开发

在 Next.js App Router 中接入微信 JS-SDK 分享

2026-03-13 · 12 min read · by GUMP

在 Next.js App Router 中接入微信 JS-SDK 分享

从服务端签名、缓存设计到前端静默降级,记录把微信分享卡片接入个人站的完整踩坑过程。

背景

个人站用 Next.js App Router 构建,在微信内分享链接时,默认只显示裸 URL——既不展示标题,也没有封面图,严重影响传播效果。

解决这个问题有两个层次:

  1. OG 元标签(兜底):在 <head> 写好 og:title / og:description / og:image,微信爬虫抓取后在部分场景下能渲染卡片。
  2. JS-SDK 自定义分享(完整体验):通过公众号 JS-SDK 主动调用 updateAppMessageShareDataupdateTimelineShareData,精确控制"发给好友"和"朋友圈"的标题、描述、图片。

本文记录第二种方案的完整实现,包含签名算法、服务端缓存、前端组件设计,以及若干踩坑点。


整体架构

bash
浏览器(微信内)
└─ 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 模块。


环境变量

bash
# .env.local
NEXT_PUBLIC_SITE_URL=https://your-domain.com
WECHAT_APP_ID=wx_xxxxxxxxxxxxxxxxx
WECHAT_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
WECHAT_DEBUG=false # 可选,true 时在微信内显示调试面板

⚠️ WECHAT_APP_SECRET 绝对不能加 NEXT_PUBLIC_ 前缀,否则会暴露到客户端 bundle。


服务端实现

1. 配置校验(lib/wechat/config.ts

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_tokenjsapi_ticket 的有效期均为 7200 秒(2 小时),而且每个公众号每天调用上限为 2000 次。如果每次分享请求都去微信服务器换 ticket,很快就会触达上限。

解决方式是进程内存缓存,在过期前 5 分钟提前刷新:

ts
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_ticketnoncestrtimestampurl,且字段名全小写(注意 noncestr 不是 nonceStr)。

ts
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

ts
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

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:纯副作用的客户端组件

tsx
"use client";
export default function WechatShareClient({ share }: Props) {
useEffect(() => {
// 非微信环境静默退出
if (!isWechat()) return;
const pageUrl = window.location.href.split("#")[0];
void (async () => {
try {
await loadWxScript(); // 动态加载 JSSDK
const 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 标签作为兜底保证最低体验。

在详情页挂载

服务端组件拿到页面数据后,直接将分享参数传给客户端组件,不重复请求数据源

tsx
// 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 外部依赖

签名算法用微信官方文档里的示例值做校准测试:

ts
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 元标签generateMetadataopenGraph兜底,爬虫解析
JS-SDK 签名服务端签名 API + 进程内缓存精确控制分享内容
客户端组件WechatShareClient,UA 检测 + 静默降级微信内增强体验

三层叠加,非微信环境零开销,微信内获得完整的自定义分享卡片体验,而且整个实现没有引入任何新的 npm 依赖。