统一 H5 宿主壳能力协议

新增 HostBridge 通用宿主能力服务与测试

迁移登录支付分享订阅入口到通用 HostBridge API

保留微信小程序旧接口兼容包装

补充宿主壳协议文档与项目记忆
This commit is contained in:
2026-06-17 20:05:55 +08:00
parent 6984af782c
commit f92e791464
19 changed files with 795 additions and 416 deletions

View File

@@ -0,0 +1,230 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
canUseHostShareGrid,
getHostRuntime,
isWechatMiniProgramWebViewRuntime,
navigateHostNativePage,
openHostShareGrid,
openWechatMiniProgramShareGridPage,
postWechatMiniProgramMessage,
requestHostLogin,
requestHostPayment,
requestWechatMiniProgramPayment,
requestWechatMiniProgramPhoneLogin,
resolveHostRuntime,
setHostShareTarget,
} from './hostBridge';
afterEach(() => {
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
window.wx = undefined;
});
describe('hostBridge', () => {
test('识别微信小程序、原生 App 和普通浏览器宿主', () => {
expect(
getHostRuntime({
location: { search: '?clientRuntime=wechat_mini_program' },
}).kind,
).toBe('wechat_mini_program');
expect(
resolveHostRuntime({
navigator: {
userAgent:
'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram',
},
}).kind,
).toBe('wechat_mini_program');
expect(
resolveHostRuntime({
location: { search: '?clientRuntime=native_app' },
}).kind,
).toBe('native_app');
expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe(
'browser',
);
});
test('通过微信小程序原生页请求登录', async () => {
const navigateTo = vi.fn((options) => {
options.success?.();
});
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
navigateTo,
},
};
const requested = await requestHostLogin();
expect(requested).toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
await expect(requestWechatMiniProgramPhoneLogin()).resolves.toBe(true);
});
test('通过微信小程序原生页请求支付', async () => {
const navigateTo = vi.fn((options) => {
options.success?.();
});
vi.spyOn(Date, 'now').mockReturnValue(123456);
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
navigateTo,
},
};
const handled = await requestHostPayment({
payload: {
timeStamp: '1',
nonceStr: 'nonce',
package: 'prepay_id=1',
signType: 'RSA',
paySign: 'sign',
},
orderId: 'order-1',
});
expect(handled).toBe(true);
const url = navigateTo.mock.calls[0]?.[0].url;
expect(url).toContain('/pages/wechat-pay/index?');
const parsed = new URL(url, 'https://mini.test');
expect(parsed.searchParams.get('requestId')).toBe(
'wechat_pay_order-1_123456',
);
expect(parsed.searchParams.get('orderId')).toBe('order-1');
expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1');
await expect(
requestWechatMiniProgramPayment(
{
timeStamp: '1',
nonceStr: 'nonce',
package: 'prepay_id=1',
signType: 'RSA',
paySign: 'sign',
},
'order-1',
),
).resolves.toBeUndefined();
});
test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => {
await expect(requestHostLogin()).resolves.toBe(false);
await expect(
requestHostPayment({
payload: {
timeStamp: '1',
nonceStr: 'nonce',
package: 'prepay_id=1',
signType: 'RSA',
paySign: 'sign',
},
orderId: 'order-1',
}),
).resolves.toBe(false);
await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe(
false,
);
await expect(
requestHostPayment({
payload: null,
orderId: 'order-1',
}),
).resolves.toBe(false);
});
test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => {
const navigateTo = vi.fn((options) => {
options.success?.();
});
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
navigateTo,
},
};
expect(canUseHostShareGrid()).toBe(true);
const opened = await openHostShareGrid({
imageUrl: '/cover.png',
title: '暖灯猫街',
publicWorkCode: 'PZ-00000001',
});
expect(opened).toBe(true);
const url = navigateTo.mock.calls[0]?.[0].url;
const parsed = new URL(url, 'https://mini.test');
expect(parsed.pathname).toBe('/pages/share-grid/index');
expect(parsed.searchParams.get('imageUrl')).toBe(
`${window.location.origin}/cover.png`,
);
expect(parsed.searchParams.get('title')).toBe('暖灯猫街');
await expect(
openWechatMiniProgramShareGridPage({
imageUrl: '/cover.png',
title: '暖灯猫街',
publicWorkCode: 'PZ-00000001',
}),
).resolves.toBe(true);
});
test('向微信小程序宿主同步消息', () => {
const postMessage = vi.fn();
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
postMessage,
},
};
expect(
setHostShareTarget({
type: 'test',
}),
).toBe(true);
expect(postMessage).toHaveBeenCalledWith({
data: {
type: 'test',
},
});
expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true);
});
test('普通浏览器不开放微信小程序能力', () => {
expect(isWechatMiniProgramWebViewRuntime()).toBe(false);
expect(canUseHostShareGrid()).toBe(false);
expect(setHostShareTarget({ type: 'test' })).toBe(false);
});
});

View File

@@ -0,0 +1,340 @@
import type {
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
} from '../../../packages/shared/src/contracts/runtime';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const MINI_PROGRAM_AUTH_PAGE_URL =
'/pages/web-view/index?authAction=login&returnTo=previous';
const MINI_PROGRAM_PAY_PAGE_URL = '/pages/wechat-pay/index';
const MINI_PROGRAM_SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
export type HostRuntimeKind =
| 'browser'
| 'wechat_mini_program'
| 'native_app';
export type HostRuntimeSnapshot = {
kind: HostRuntimeKind;
clientType: string | null;
clientRuntime: string | null;
miniProgramEnv: string | null;
};
export type HostRuntimeContext = {
location?: Pick<Location, 'search'> | null;
navigator?: Partial<Pick<Navigator, 'userAgent'>> | null;
wx?: Window['wx'] | null;
};
export type HostNativePageNavigationOptions = {
errorMessage?: string;
beforeNavigate?: () => void;
};
export type HostPaymentRequest = {
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined;
orderId: string;
};
export type HostShareGridRequest = {
imageUrl: string;
title: string;
publicWorkCode: string;
};
function resolveLocation(context: HostRuntimeContext) {
return (
context.location ?? (typeof window !== 'undefined' ? window.location : null)
);
}
function resolveNavigator(context: HostRuntimeContext) {
return (
context.navigator ??
(typeof navigator !== 'undefined' ? navigator : null)
);
}
function resolveWxBridge(context: HostRuntimeContext) {
return context.wx ?? (typeof window !== 'undefined' ? window.wx : null);
}
function hasWechatMiniProgramBridge(wxBridge: Window['wx'] | null | undefined) {
return Boolean(
wxBridge?.miniProgram?.postMessage || wxBridge?.miniProgram?.navigateTo,
);
}
function isWechatMiniProgramUserAgent(userAgent: string) {
const normalizedUserAgent = userAgent.toLowerCase();
return (
normalizedUserAgent.includes('micromessenger') &&
normalizedUserAgent.includes('miniprogram')
);
}
export function resolveHostRuntime(
context: HostRuntimeContext = {},
): HostRuntimeSnapshot {
const location = resolveLocation(context);
const params = new URLSearchParams(location?.search ?? '');
const clientType = params.get('clientType');
const clientRuntime = params.get('clientRuntime');
const miniProgramEnv = params.get('miniProgramEnv');
const navigatorLike = resolveNavigator(context);
const wxBridge = resolveWxBridge(context);
if (
clientRuntime === 'wechat_mini_program' ||
clientType === 'mini_program' ||
isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') ||
hasWechatMiniProgramBridge(wxBridge)
) {
return {
kind: 'wechat_mini_program',
clientType,
clientRuntime,
miniProgramEnv,
};
}
if (clientRuntime === 'native_app' || clientType === 'native_app') {
return {
kind: 'native_app',
clientType,
clientRuntime,
miniProgramEnv,
};
}
return {
kind: 'browser',
clientType,
clientRuntime,
miniProgramEnv,
};
}
export function getHostRuntime(context: HostRuntimeContext = {}) {
return resolveHostRuntime(context);
}
export function isWechatMiniProgramWebViewRuntime(
context: HostRuntimeContext = {},
) {
return resolveHostRuntime(context).kind === 'wechat_mini_program';
}
export function isNativeAppRuntime(context: HostRuntimeContext = {}) {
return resolveHostRuntime(context).kind === 'native_app';
}
export function loadWechatMiniProgramBridge(
errorMessage = '请在微信小程序内完成操作',
) {
if (
typeof window === 'undefined' ||
!isWechatMiniProgramWebViewRuntime()
) {
return Promise.reject(new Error(errorMessage));
}
if (window.wx?.miniProgram?.navigateTo) {
return Promise.resolve(window.wx);
}
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${WECHAT_JS_SDK_URL}"]`,
);
const complete = () => {
if (window.wx?.miniProgram?.navigateTo) {
resolve(window.wx);
} else {
reject(new Error(errorMessage));
}
};
if (existingScript) {
if (window.wx?.miniProgram?.navigateTo) {
complete();
return;
}
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error(errorMessage)),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error(errorMessage));
document.head.appendChild(script);
});
}
export async function navigateWechatMiniProgramPage(
url: string,
errorMessage = '请在微信小程序内完成操作',
options: Pick<HostNativePageNavigationOptions, 'beforeNavigate'> = {},
) {
const wxBridge = await loadWechatMiniProgramBridge(errorMessage);
const navigateTo = wxBridge.miniProgram?.navigateTo;
if (typeof navigateTo !== 'function') {
throw new Error(errorMessage);
}
await new Promise<void>((resolve, reject) => {
options.beforeNavigate?.();
navigateTo({
url,
success() {
resolve();
},
fail(error) {
reject(new Error(error?.errMsg || errorMessage));
},
});
});
}
export async function navigateHostNativePage(
url: string,
options: HostNativePageNavigationOptions = {},
) {
const runtime = getHostRuntime();
if (runtime.kind !== 'wechat_mini_program') {
return false;
}
await navigateWechatMiniProgramPage(url, options.errorMessage, {
beforeNavigate: options.beforeNavigate,
});
return true;
}
export async function requestHostLogin() {
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
errorMessage: '请在微信小程序内完成登录',
});
}
export async function requestWechatMiniProgramPhoneLogin() {
return await requestHostLogin();
}
export async function requestHostPayment({
payload,
orderId,
}: HostPaymentRequest) {
if (getHostRuntime().kind !== 'wechat_mini_program') {
return false;
}
if (!payload) {
throw new Error('请在微信小程序内完成支付');
}
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
const searchParams = new URLSearchParams({
requestId,
orderId,
payParams: JSON.stringify(payload),
});
await navigateWechatMiniProgramPage(
`${MINI_PROGRAM_PAY_PAGE_URL}?${searchParams.toString()}`,
'请在微信小程序内完成支付',
);
return true;
}
export async function requestWechatMiniProgramPayment(
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined,
orderId: string,
) {
const handled = await requestHostPayment({ payload, orderId });
if (!handled) {
throw new Error('请在微信小程序内完成支付');
}
}
function buildAbsoluteUrl(value: string) {
if (typeof window === 'undefined') {
return value;
}
return new URL(value, window.location.origin).href;
}
export function canUseHostShareGrid(context: HostRuntimeContext = {}) {
return getHostRuntime(context).kind === 'wechat_mini_program';
}
export function canUseWechatMiniProgramShareGrid() {
return canUseHostShareGrid();
}
export async function openHostShareGrid(params: HostShareGridRequest) {
const imageUrl = params.imageUrl.trim();
if (!imageUrl || !canUseHostShareGrid()) {
return false;
}
const searchParams = new URLSearchParams({
imageUrl: buildAbsoluteUrl(imageUrl),
title: params.title.trim() || '我的作品',
publicWorkCode: params.publicWorkCode.trim(),
});
try {
return await navigateHostNativePage(
`${MINI_PROGRAM_SHARE_GRID_PAGE_URL}?${searchParams.toString()}`,
{
errorMessage: 'wechat_js_sdk_unavailable',
},
);
} catch {
return false;
}
}
export async function openWechatMiniProgramShareGridPage(
params: HostShareGridRequest,
) {
return await openHostShareGrid(params);
}
export function setHostShareTarget(message: unknown) {
if (
typeof window === 'undefined' ||
getHostRuntime().kind !== 'wechat_mini_program' ||
typeof window.wx?.miniProgram?.postMessage !== 'function'
) {
return false;
}
window.wx.miniProgram.postMessage({
data: message,
});
return true;
}
export function postWechatMiniProgramMessage(message: unknown) {
return setHostShareTarget(message);
}