面向移动端 H5 场景,介绍如何从 Web 页面唤起已安装的原生 App,涵盖协议原理、各宿主环境限制、封装范式与十大坑点。看完可直接落地生产。
一、背景与协议原理
1. 什么是 URL Scheme
myapp://xxx?params=... 这样的 URL,是 App 在操作系统层面注册的 URL Scheme (iOS)/ Intent(Android),用于从外部(浏览器、其他 App)唤起自己。
原理:
- iOS:App 安装时向系统注册了自定义 scheme。任何环境(Safari、Chrome、系统 WebView、其他 App 的 WebView)访问该协议的 URL,系统会尝试把它交给已注册的 App 处理。
- Android:走 Intent 分发机制,同理。
关键结论 :scheme 唤起是操作系统级别的能力,不是"必须在 App 内才能用"。只要设备装了目标 App,H5 页面就可以唤起它。
2. 外部唤端 scheme 与 App 内路由协议的区别
很多同学容易混淆------App 内部也常有一套"跳转协议"(业务方封装的 buildSchema 之类的工具),它和用来唤端的 scheme 完全不是一回事:
| 维度 | 外部唤端 scheme(myapp://...) |
App 内跳转协议 |
|---|---|---|
| 使用环境 | App 外(H5、浏览器、第三方 WebView) | App 内 WebView |
| 作用 | 从外部唤起 App 并跳到指定页面 | 在 App 内拼装 Bridge 跳转协议,配合 invoke 使用 |
| 底层机制 | 操作系统 scheme 分发 | App 内路由协议字符串 |
不要混用:
- H5 页面唤端 → 用系统 scheme
- App 内跳转 → 用 App 内路由协议 + Bridge
二、常见 scheme 结构示例
大多数 App 的 scheme 都是类似的结构:
ini
myapp://virtual?params={JSON字符串,URL编码}
其中 params 是一个 URL 编码后的 JSON,用来描述要跳转的目标。
示例 1:跳商品详情页
js
const params = {
category: 'jump',
des: 'productDetail',
sku_id: '100012043978',
sourceType: 'h5Share',
sourceValue: 'share',
};
const scheme = `myapp://virtual?params=${encodeURIComponent(JSON.stringify(params))}`;
示例 2:跳首页
js
const params = { category: 'jump', des: 'home' };
示例 3:让 App 内 WebView 打开指定 H5
js
const params = {
category: 'jump',
des: 'm',
url: 'https://m.example.com/active/xxx.html',
};
具体的
des与参数键值,以你们 App 端维护的协议文档为准。
三、各环境下的可用性与限制
1. 系统浏览器(Safari / Chrome)
- ✅ 装了 App:可直接唤起
- ❌ 没装 App:无反应或提示"无法打开此页面"
- ⚠️ iOS Safari 14+ :
location.href = scheme必须由用户点击触发,页面加载后自动执行会被拦截 - ⚠️ Android Chrome:从 25+ 起对 scheme 也有限制,建议同时提供 Intent URL 兜底
2. 社交类 App 内 WebView(微信 / QQ / 抖音 / 小红书 等)🚫
大部分情况下唤不起来------这些平台屏蔽了外部 scheme(白名单机制),只允许自家或合作方 scheme。
通用做法:
- 检测到当前在社交类 App 环境,不要直接调 scheme ,改为引导页:
- "点击右上角 · · · → 在浏览器中打开"
- 或跳 Universal Link / 应用市场下载页
3. 自家 App 内 WebView ⚠️
- 已经在 App 里了,不需要唤端,应该判断 UA 后走 Bridge 或 App 内路由协议跳转
- 直接调 scheme 在 App 内 WebView 行为不确定(可能被 App 自己拦截处理,也可能没反应)
4. 鸿蒙 WebView / 厂商浏览器
- 装了目标 App 一般可以唤起
- 部分场景需要通过 Bridge 走系统层跳转,看具体宿主
5. Universal Link(iOS)/ App Link(Android)
更推荐的方案 :形如 https://xxx.example.com/... 的 https 链接,
- 装了 App → 系统直接打开 App
- 没装 App → 落到 H5 兜底页
优势:
- 天然带兜底,不用自己写超时逻辑
- 社交类 App 里也能用(它们只屏蔽自定义 scheme,无法屏蔽 https)
- 用户体验更平滑
劣势:
- 需要域名 apple-app-site-association / assetlinks.json 配置,接入成本高
- iOS 用户在某个域名下点过"在 Safari 中打开"后,UL 会失效直到 App 被下次触发
四、M 站唤端封装范式
1. 优先方案:先查内部能力
在成熟的前端工程里,先去内部库找现成能力,不要自己造:
bash
# 在内部 UI 库 / utils 库里搜
grep -r "openApp\|OpenApp\|scheme" packages/utils packages/ui-kit
内部 UI 库中通常已有类似 OpenApp 的组件,封装了:
- 环境判断(社交类 App / 自家 App / 普通浏览器)
- scheme 拼装 + Universal Link 兜底
- 超时未唤起 → 跳下载引导页
用法示意(以实际组件 API 为准):
tsx
import { OpenApp } from '@your-org/ui-kit';
<OpenApp
scheme="myapp://virtual?params=..."
downloadUrl="https://xxx.example.com/download"
onFail={() => console.log('唤起失败')}
>
<button>打开 App</button>
</OpenApp>
2. 兜底方案:自己封装
如果内部能力不满足或需要定制,参考下面的封装。核心思路 :尝试 scheme → 监听 visibilitychange 判定是否切后台 → 超时未切走 fallback。
ts
interface OpenAppOptions {
/** 要跳转的 scheme,如 myapp://virtual?params=... */
scheme: string;
/** 未唤起时的兜底 URL:Universal Link / 下载页 / H5 兜底 */
fallbackUrl: string;
/** 超时时长,默认 2500ms */
timeout?: number;
/** 唤起成功回调(页面被切到后台时触发) */
onSuccess?: () => void;
/** 唤起失败回调(超时未切后台) */
onFail?: () => void;
}
/**
* H5 唤起原生 App 通用封装
* 必须由用户手势触发(点击事件回调里调用),否则 iOS 会拦截
*/
export function openApp(opts: OpenAppOptions) {
const { scheme, fallbackUrl, timeout = 2500, onSuccess, onFail } = opts;
// 1. 环境预检
const ua = navigator.userAgent.toLowerCase();
const isWeChat = /micromessenger/.test(ua);
const isQQ = /qq\//.test(ua) && !/mqqbrowser/.test(ua);
const isSelfApp = /yourApp/.test(ua); // 按实际 UA 关键字调整
// 社交类 App 内 → 直接引导浏览器打开或走 UL
if (isWeChat || isQQ) {
// 建议:展示"右上角 → 浏览器打开"引导蒙层
// 或直接跳 Universal Link(社交 App 不拦 https)
location.href = fallbackUrl;
return;
}
// 已经在自家 App 内 → 不需要唤端,走 App 内跳转
if (isSelfApp) {
console.warn('已在 App 内,请使用 App 内路由协议而非唤端 scheme');
return;
}
// 2. 尝试唤起
const start = Date.now();
let timer: number | null = null;
const cleanup = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
};
const onVisibilityChange = () => {
if (document.hidden) {
cleanup();
onSuccess?.();
}
};
const onPageHide = () => {
cleanup();
onSuccess?.();
};
document.addEventListener('visibilitychange', onVisibilityChange, { passive: true });
window.addEventListener('pagehide', onPageHide, { passive: true });
timer = window.setTimeout(() => {
cleanup();
// 页面仍可见 = 大概率没装 App
if (!document.hidden && Date.now() - start < timeout + 500) {
onFail?.();
location.href = fallbackUrl;
}
}, timeout);
// 3. 触发唤起
// iOS 用 location.href,Android 也兼容;部分 Android 场景可考虑 iframe.src 方式
try {
location.href = scheme;
} catch (e) {
cleanup();
onFail?.();
location.href = fallbackUrl;
}
}
3. 使用示例
tsx
import { openApp } from '@/utils/open-app';
function BuyButton({ skuId }: { skuId: string }) {
const handleClick = () => {
const params = {
category: 'jump',
des: 'productDetail',
sku_id: skuId,
sourceType: 'h5Share',
sourceValue: location.href,
};
const scheme = `myapp://virtual?params=${encodeURIComponent(JSON.stringify(params))}`;
openApp({
scheme,
fallbackUrl: `https://m.example.com/product/${skuId}.html`,
timeout: 2500,
onSuccess: () => track('open_app_success', { skuId }),
onFail: () => track('open_app_fail', { skuId }),
});
};
return <button onClick={handleClick}>去 App 内查看</button>;
}
4. 社交类 App 内引导蒙层
社交类 App 里唤端基本无解,只能引导用户手动切浏览器:
tsx
{isWeChat && (
<div className="wechat-guide-mask">
<img src="/arrow-to-top-right.png" />
<p>点击右上角 · · ·</p>
<p>选择「在浏览器中打开」</p>
</div>
)}
五、常见坑点清单
| # | 坑点 | 规避方式 |
|---|---|---|
| 1 | iOS Safari 拦截自动 scheme 跳转 | 必须放在用户点击回调里执行 |
| 2 | 社交类 App 内唤不起 | 检测 UA,改为浏览器打开引导 或 Universal Link |
| 3 | 超时时长设置过短(<1.5s)误判为失败 | 建议 2~2.5s |
| 4 | 用 setTimeout 单一判定,未监听 visibilitychange |
双重判定,切后台 = 成功 |
| 5 | 页面 unmount 未清 timer 导致后续误跳 fallback | cleanup() 一定要在 unmount 时执行 |
| 6 | 已在自家 App 内还调唤端 scheme | 先判 UA,App 内走 Bridge |
| 7 | params 未 encodeURIComponent 导致解析失败 |
JSON.stringify 后必须 URL 编码 |
| 8 | Android 部分 WebView 对 scheme 分发不一致 | 兜底 Intent URL 或 Universal Link |
| 9 | iOS 用户点过"在 Safari 打开"后 Universal Link 失效 | 无法完全规避,UL + scheme 双兜底 |
| 10 | 页面自动 location.href = scheme 会导致后退回来时页面白屏 |
用点击触发;或用隐藏 iframe.src 触发 |
六、决策流程图
arduino
用户点击「打开 App」
│
▼
判断当前 UA 环境
│
┌────┼─────────────┬──────────────┐
▼ ▼ ▼ ▼
社交App 自家App内 普通浏览器 鸿蒙 WebView
│ │ │ │
▼ ▼ ▼ ▼
展示引导 App 内路由协议 尝试 scheme 尝试 scheme
蒙层 + Bridge 跳转 + 超时兜底 或 Bridge
│
┌────┴────┐
▼ ▼
切后台=成功 超时=未装
│
▼
跳 fallback
(UL / 下载页)
七、参考与协作
- App scheme 协议表:找 App 端同学要最新版
- 内部封装:优先用内部 UI 库里已封装好的唤端组件
- Universal Link 配置:需要客户端 + 服务端 + 域名 SSL 三方配合,找 App 团队接入
- 埋点:唤端成功 / 失败必须打点,方便后续分析转化漏斗
写在最后
H5 唤端看起来简单,实际是个"坑连着坑"的场景:宿主环境限制、系统版本差异、用户手势要求、超时判定精度......每一个点没处理好都会导致数据掉一大截。
一句话总结最佳实践 :优先 Universal Link → scheme 兜底 → 社交环境引导浏览器 → 失败跳下载页 ,配合双重判定(visibilitychange + setTimeout)和完整的埋点,才能拿到一个稳定的唤端转化率。