背景

个人站用 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
}

几个设计决策:


在详情页挂载

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

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。

也就是说:

这个问题在 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 输出格式。只要这个测试通过,签名逻辑就与微信官方保持一致。


联调清单

真机测试前需要确认:


小结

层次方案作用
OG 元标签generateMetadataopenGraph兜底,爬虫解析
JS-SDK 签名服务端签名 API + 进程内缓存精确控制分享内容
客户端组件WechatShareClient,UA 检测 + 静默降级微信内增强体验

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