Android WebView 黑屏(Vite 构建目标过新,旧 WebView JS 解析失败)
症状
APK 内 WebView 加载 Vite 构建的 React SPA,整屏黑;同一站点在桌面浏览器打开完全正常。用户描述"黑屏/白屏""进去看不到具体页面"。
触发条件
- 页面是深色优先应用:
index.html在 React 挂载前用内联脚本强制documentElement.className='theme-dark'并把背景设成#0b1211(近黑); - React 没挂载 →
<div id="root">为空 → 页面只剩黑底 → 看起来就是纯黑屏; - 设备系统 WebView 较旧,解析不了 Vite 默认产物语法。
根因
链条是双层的:
- 黑屏其实是「空
#root+ 深色背景」。深色应用在挂载前把<html>背景刷黑,一旦 JS 没跑,这个黑底就是全部画面。浅色应用同样情况只会"白屏",视觉上更容易被识别成加载失败。 - JS 为什么没跑:Vite 7 默认构建目标是
baseline-widely-available(≈ Chrome 107+)。旧版 Android System WebView(老机型,国内很多手机厂长期不升级 WebView)解析不了?.、??、logical assignment 等新语法,module 脚本直接抛 SyntaxError,应用停在空挂载点。网页端用桌面浏览器(新内核)所以正常,问题只在 WebView。
实测定位(错误捕获层给出的真正根因)
页内错误捕获层实测抓到的是资源加载失败(error 事件 message 为空 → undefined @ inline 0;升级后显示具体 URL):
资源加载失败: https://appassets.androidplatform.net/assets/index-xxx.css @ inline:0真正的坑在 shouldInterceptRequest + 虚拟域 + CORS:
<script type="module">按规范永远以 CORS 模式请求(与是否写crossorigin属性无关);- 被拦截返回的
WebResourceResponse若没有Access-Control-Allow-Origin头,部分 WebView 会把拦截资源当跨域处理:module 脚本被拒(常静默,不触发 error 事件)、样式表因跨域无 ACAO 不被应用(会触发 error 事件); - 结果:JS 静默失败 →
#root空 → 黑屏;CSS 可见地报加载失败。
完整修复清单(按最终有效顺序)
vite.config.js降构建目标:build: { target: 'es2017' },esbuild 转掉?./??,扩到老 WebView。- 产物去掉
crossorigin:Vite 会给 module script / stylesheet 加crossorigin,用transformIndexHtml插件html.replace(/ crossorigin/g, '')剥掉(页面全同源,本不需要)。 - 拦截响应加 CORS 头:
AssetWebViewClient.serveAsset给WebResourceResponse加Access-Control-Allow-Origin: *。实测单独这条没救活——部分 WebView 不认拦截响应里带的 CORS 头,module script / stylesheet 依旧按跨域拒载。 最终有效的兜底:单文件内联、且保留 module 语义。post-build 脚本(
frontend/scripts/inline-module.cjs)把 JS 内联成<script type="module">、CSS 内联成<style>,产物index.html里不再有<script type="module" src>和<link rel="stylesheet">。既然 index.html 本身能被 WebView 加载(内联脚本能跑),整应用内联后必然能跑;除字体外无任何子资源请求,彻底绕开 shouldInterceptRequest 的子资源坑。- 关键教训:不能转成经典
<script>,必须保留type="module"。 React 应用以经典脚本执行会稳定触发 React #299(Rendered more hooks)(已在无头浏览器确认),模块执行则无此问题。v5 页面用经典脚本能跑是因为它不是 React。 - 内联前先确认 bundle 无顶层
export、无import.meta、无动态import(、无裸</script>(经典/module 兼容前提)。
- 关键教训:不能转成经典
- 页内错误捕获层兜底,让下次失败显示具体报错/资源 URL 而不是黑屏。
排查手段(不用 devtools 也能定位)
- 给
index.html加页内错误捕获层:监听window的error(capture 模式,能捕到 module 脚本加载/解析错误)和unhandledrejection,若#root无子节点则把报错文本渲染进#root。下次失败直接在屏幕上显示具体报错,截图即可,省去连 adb/devtools。 - 或装 debug APK 连
chrome://inspect(WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG))看 console。 - 检查构建产物是否残留新语法:
grep -F "?." dist/assets/index-*.js(必须-F字面量——正则?\.会把 URL 里的?dst/?id查询串误报成可选链)。 - 判断
#root是否为空:document.getElementById('root').children.length === 0。
解决步骤(按序执行)
- 改
frontend/vite.config.js:build.target: 'es2017'+stripCrossorigin插件(transformIndexHtml里html.replace(/ crossorigin/g, ''))。 - 改
AssetWebViewClient.serveAsset:所有WebResourceResponse加Access-Control-Allow-Origin: *头(关键)。 - 重建
npm run build;用grep -F "?." dist/assets/index-*.js复核无可选链残留,确认产物index.html的<script type="module">/<link>无crossorigin。 - 重打 APK 装 debug 包验证;仍有问题看页内错误捕获层输出的具体资源 URL 再对症。
下次怎么识别
- 判断特征:浏览器正常 + WebView 挂 → 高度怀疑构建目标/WebView 版本兼容,不是业务 bug。
- 深色应用"纯黑"而不是"白屏" → 空
#root+ 深背景,JS 没执行。 - 老设备 / 用户系统 WebView 不更新 → 前端构建目标别默认追最新,
es2017是稳妥下限。
关联
- WebView 桥把 native 物理像素当 CSS px(Android 壳顶部留空位)——同一个 WebView 壳工程里的另一个坑。
- 本项目:
frontend/vite.config.js(build.target + stripCrossorigin)、frontend/index.html(错误捕获层)、android/.../AssetWebViewClient.java(serveAsset 加 ACAO 头)。
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Programming