当一个 Vue 应用同时运行在桌面浏览器、手机浏览器、PWA 和 Android WebView 中时,“首页跳转”会从一个简单路由问题,变成一套运行环境协议。
项目中同时存在两个入口:
/ 公开官网首页
/app 应用入口
最初的需求看起来并不复杂:
- 桌面浏览器访问
/,展示官网; - 手机浏览器第一次访问
/,也展示官网; - 手机浏览器再次访问时,可以直接进入应用;
- 移动 PWA 和 Android App 冷启动时,不应该先看到官网;
- Android App 无论屏幕多宽,都应该使用移动应用入口;
- 桌面 PWA 仍然可以保留桌面布局和账号首页偏好。
如果把这些判断全部写进 Vue Router Guard,功能通常能跑,但用户会看到一个明显问题:官网先闪一下,然后才跳进应用。
更麻烦的是,通用 Android WebView 可能错误报告 standalone,viewport meta 生效前 innerWidth 可能接近 980px,桌面屏幕短边又可能被误判成移动宽度。
本文复盘一套多运行环境入口设计,重点讨论:
- 为什么路由守卫处理首屏分流太晚;
- 怎样区分“运行时身份”和“响应式布局”;
- 哪些信号可以信,哪些信号只能用于体验优化;
- 如何让 Android 原生壳保留 WebView 状态,而不是每次回桌面都重建;
- 为什么入口判断失败时应该 fail-open,而不是把公开页面锁死。
一、先把三个问题拆开
很多入口 Bug 的根源,是把下面三件事混成了一个布尔值 isMobile:
1. 当前是什么运行环境?
2. 当前应该使用什么布局?
3. 当前应该进入哪个业务页面?
它们并不是同一件事。
运行环境
type Runtime = 'browser' | 'pwa-standalone' | 'android-app';
它决定是否拥有原生桥、是否是安装态、退出时回官网还是留在应用内。
渲染布局
mobile / desktop / compact
它主要由有效视口、指针类型和显式调试覆盖决定。
业务落点
今日
书签首页
账号配置的默认首页
公开首页
它还会受注册、登录、退出、视口宽度和账号偏好影响。
如果只写:
if (window.innerWidth < 768) {
router.replace('/workbenches');
}
Android 平板、桌面 PWA、新账号注册和普通移动浏览器首次访问都会被混在一起。
二、Router Guard 为什么太晚
典型 SPA 启动顺序如下:
服务器返回 index.html
↓
浏览器解析 HTML / CSS
↓
公开首页可能已经首次绘制
↓
下载并执行 JS 模块
↓
创建 Vue App
↓
Router Guard 判断
↓
replace('/app')
用户看到的就是:
官网 → 白一下 / 布局跳动 → 应用
解决办法不是让 Router 更快,而是把“是否需要离开根路径”的判断前移到 <head> 顶部。
在 Vite 中,可以通过 transformIndexHtml 注入经典内联脚本:
export default function earlyEntry(): Plugin {
return {
name: 'early-app-entry',
transformIndexHtml: {
order: 'pre',
handler() {
return [{
tag: 'script',
attrs: { 'data-early-entry': 'true' },
children: createEarlyEntryScript(),
injectTo: 'head-prepend',
}];
},
},
};
}
这里使用经典脚本而不是 ES Module,因为模块脚本会延后执行,无法稳定阻止第一次绘制。
如果决定跳转,先临时隐藏根节点,再使用:
window.location.replace('/app');
replace 不会把官网入口留在历史栈里,用户在应用首屏返回时不会又回到一闪而过的根页面。
三、运行时身份必须使用信号优先级
1. Android App 只认专属信号
可靠信号包括:
window.AndroidBridge 存在
专属自定义 UA 标记
通用的 ; wv) 只能说明当前是 Android WebView,不能证明它是自己的 App。
第三方应用内浏览器同样可能使用 WebView。如果把所有 wv 都当成自己的 APK,公开首页会在完全不相关的容器中自动跳转。
2. PWA 使用 display-mode,但要排除通用 WebView
matchMedia('(display-mode: standalone)').matches
是判断 PWA 安装态的重要信号,但一些第三方 Android WebView 可能错误报告 standalone。
因此顺序应该是:
专属 Android App
↓ 否
是否为通用 Android WebView
↓ 否
PWA standalone
↓ 否
普通浏览器
3. 本地历史只能改善体验,不能成为身份凭据
移动浏览器是否回访,可以参考:
- 是否已经展示过移动官网;
- 是否保存过记住邮箱;
- 是否存在近期登录兼容记录。
这些信号只决定“这次是否自动进入应用”,不能决定用户是否已经登录,更不能带入服务端权限。
浏览器本地记录可能过期、被清除或被伪造。真正的登录状态仍然必须由服务端会话确认。
四、viewport meta 生效前,innerWidth 可能骗你
早期脚本位于 <head> 顶部,甚至可能在 viewport meta 之前执行。
部分移动浏览器此时会返回接近 980px 的布局宽度。如果只看:
window.innerWidth
手机会被误判成桌面,等 meta 生效和 Vue 挂载后又切回移动布局,首屏产生二次跳动。
可以同时读取:
const innerWidth = Number(window.innerWidth);
const shortSide = Math.min(
Number(screen.width),
Number(screen.height),
);
但这里也有一个反向陷阱:桌面屏幕的短边通常是高度,例如 900px。若无条件使用 Math.min(innerWidth, shortSide),1600×900 的桌面会被误判成 900px 紧凑布局。
因此,屏幕短边只在它明确小于手机断点时用于修正:
let effectiveWidth = innerWidth;
if (shortSide < MOBILE_BREAKPOINT) {
effectiveWidth = Math.min(innerWidth, shortSide);
}
这类逻辑不能简单地从响应式工具函数复制,因为它处理的是“viewport 还没准备好时”的特殊阶段。
五、入口判断树应该是可解释的
根路径分流可以整理成一棵清楚的决策树:
专属 Android App?
是 → /app
否
当前路径是 / ?
否 → 不处理
是
是否为手机布局?
否 → 展示公开首页
是
PWA standalone?
是 → /app
否
是否回访 / 有兼容身份记录?
是 → /app
否 → 展示公开首页
入口脚本只处理根路径
/。用户直接打开某个详情页时,不应该被一个全局脚本粗暴改写到应用首页。
同时,所有 storage 操作都要放进 try/catch。
用户可能禁用了本地存储,隐私模式也可能让调用抛错。此时最安全的行为不是无限白屏,而是:
fail-open,继续展示公开首页
“无法判断是否回访”不等于“有权自动进入应用”。
六、渲染档位和路由落点要分开计算
早期脚本除了决定是否跳转,还可以提前给 <html> 写入渲染档位:
<html
class="mobile-rendering android-webview"
data-render-profile="mobile"
data-render-engine="android-webview"
>
这样 CSS 在 Vue 挂载前就能使用正确基线,减少首帧从桌面布局切到移动布局的闪动。
但渲染档位不能直接决定业务首页。
例如:
- 平板可能使用紧凑布局,却仍进入书签首页;
- Android App 即使运行在宽屏设备,也应该进入“今日”;
- 桌面 PWA 使用桌面布局,并按账号偏好进入;
- 新账号注册后不应该继承设备上一个账号的本地最近页面。
因此,可以把落点函数写成纯函数并分别测试:
getRuntimeApplicationHomePath(preferences, isMobileLayout, runtime)
getRuntimePostRegistrationPath(isMobileLayout, runtime)
getRuntimeApplicationEntryPath(preferences, viewportWidth, runtime)
getRuntimeGuestEntryPath(preferences, runtime)
典型规则:
移动布局 / Android App → 今日
平板 → 书签首页
桌面 → 账号偏好首页
桌面注册完成 → 固定书签首页
移动注册完成 → 今日
Android / 移动 PWA 退出 → 留在应用今日页
桌面浏览器退出 → 公开首页
“登录后去哪”“注册后去哪”“退出后去哪”是三个不同问题,不应共享一个万能函数。
七、Android 壳的首屏要做三段接力
Android WebView 冷启动通常经历:
系统 Splash
↓
Activity 创建
↓
WebView 加载
↓
网页首个路由完成绘制
如果系统 Splash、Activity 背景和 WebView 等待层不是同一套视觉,用户会看到:
系统启动图 → 纯色 / 黑屏 → 网页骨架 → 正文
更稳定的方案是三段接力:
- 系统
windowBackground或 Android 12 Splash; - Activity 中的原生 Launch Overlay;
- 网页首路由绘制完成后,通过受信消息发送
app.ready。
原生层收到
app.ready 后淡出覆盖层,同时保留两个兜底:
- 页面
onPageFinished后延迟关闭,兼容旧网页缓存; - 总超时强制关闭,避免异常时永久遮住 WebView。
重要的是:兜底负责可用性,app.ready 负责正确时机。
八、返回键不能只看 webView.canGoBack()
移动 Web 页面通常有三类状态:
overlay:弹框、抽屉、全屏选择器
page:详情页或二级页面
root:底部一级导航根页面
如果原生层只执行:
if (webView.canGoBack()) webView.goBack();
else finish();
会出现:
- 弹框还没关,整个页面先返回;
- 一级导航的历史占位被逐个回退;
- 回到根页后直接销毁 Activity;
- 再次点击桌面图标,WebView 状态和滚动位置全部丢失。
一种可行方式是让原生先询问网页当前状态:
window.history.state?.overlayId
? 'overlay'
: document.documentElement.dataset.primaryRoot === 'true'
? 'root'
: 'page'
然后:
overlay / page → WebView.goBack()
root → 两次返回确认后 moveTaskToBack(true)
这里选择 moveTaskToBack 而不是 finish,是为了让 singleTask 桌面入口恢复同一个 Activity 和 WebView 实例。
Android 13+ 的预测返回也应注册到同一 handleBackNavigation(),避免新旧返回路径行为不一致。
九、原生桥应该是“受信能力适配器”
WebView 壳通常还要处理:
- 文件选择和相机;
- 下载进度;
- 保存 Base64 图片;
- 安装下载后的 APK;
- 写入系统日历;
- 同步系统深浅色。
这些能力不应该通过任意 javascriptInterface 对所有页面开放。
更安全的方式是使用带允许来源的 WebMessage Listener:
只接受受信 origin
只接受主 frame
未知 message type 不执行任何操作
原生结果按 token 回传网页
例如日历写入:
Web 发 calendar.insert + token
↓
原生尝试打开系统日历
↓
回传 { token, ok, reason }
↓
Web 超时未收到结果 → 当作旧版不支持,回落到 .ics
这种“能力协商”不一定需要显式版本号。旧版壳不认识消息时不回复,网页超时后自然降级。
十、应该怎样测试多运行环境入口
这类逻辑非常适合纯函数和脚本快照测试。
运行时识别
专属 Bridge 存在 → android-app
仅有通用 wv → browser
通用 wv + standalone → 仍是 browser
无 WebView + standalone → pwa-standalone
根路径分流
桌面浏览器首次访问 → 不跳转
手机浏览器首次访问 → 不跳转
手机浏览器回访 → /app
移动 PWA → /app
Android App → /app,不受宽度影响
非根路径 → 不处理
localStorage 抛错 → 继续公开首页
视口修正
手机 innerWidth=980,screen shortSide=390 → 按 390 处理
桌面 1600×900 → 不把 900 当成手机宽度
粗指针紧凑设备 → 使用移动渲染档位
显式 desktop override → 保持桌面档位
Android 返回
overlay → 先关闭浮层
page + canGoBack → 正常回退
root 第一次返回 → 显示提示
root 两秒内第二次返回 → moveTaskToBack
从桌面恢复 → onNewIntent,不重建 WebView
测试重点不是某个 URL 字符串,而是优先级是否稳定、失败方向是否安全。
十一、可复用的四条经验
1. 首屏分流必须早于框架启动
只要目标是避免首次绘制错误页面,就不能把全部逻辑留在 Router Guard 中。
2. 运行环境、布局和业务首页必须解耦
一个 isMobile 无法表达 PWA、WebView、平板、回访浏览器和 Android App 的差异。
3. 体验信号不能升级成安全凭据
本地访问历史可以帮助决定是否跳过官网,但登录和权限永远由服务端确认。
4. 原生壳应保持薄,但必须接住系统语义
“薄壳”不等于只放一个 WebView。启动页、返回键、文件选择、下载、系统主题和受信消息都属于系统语义,应该由原生层负责;业务模型和页面仍由 Web 端统一维护。
结语
一套多端 Vue 应用真正难的不是“能不能在 WebView 中打开”,而是不同运行环境能否拥有符合预期的:
第一帧
入口落点
返回行为
状态连续性
系统能力边界
当这些问题被压成一个路由判断时,Bug 会以闪屏、错误跳转和状态丢失的形式不断出现。把它们拆成早期入口脚本、运行时识别、纯函数路由策略和薄原生壳后,系统才开始变得可解释、可测试。
本文实现来自一个公开的 Vue + Android WebView 项目,完整代码可参考: