症状

APK 内 WebView 加载 Vite 构建的 React SPA,整屏黑;同一站点在桌面浏览器打开完全正常。用户描述"黑屏/白屏""进去看不到具体页面"。

触发条件

  • 页面是深色优先应用:index.html 在 React 挂载前用内联脚本强制 documentElement.className='theme-dark' 并把背景设成 #0b1211(近黑);
  • React 没挂载 → <div id="root"> 为空 → 页面只剩黑底 → 看起来就是纯黑屏;
  • 设备系统 WebView 较旧,解析不了 Vite 默认产物语法。

根因

链条是双层的:

  1. 黑屏其实是「空 #root + 深色背景」。深色应用在挂载前把 <html> 背景刷黑,一旦 JS 没跑,这个黑底就是全部画面。浅色应用同样情况只会"白屏",视觉上更容易被识别成加载失败。
  2. 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 可见地报加载失败。

完整修复清单(按最终有效顺序)

  1. vite.config.js 降构建目标:build: { target: 'es2017' },esbuild 转掉 ?./??,扩到老 WebView。
  2. 产物去掉 crossorigin:Vite 会给 module script / stylesheet 加 crossorigin,用 transformIndexHtml 插件 html.replace(/ crossorigin/g, '') 剥掉(页面全同源,本不需要)。
  3. 拦截响应加 CORS 头:AssetWebViewClient.serveAsset 给 WebResourceResponse 加 Access-Control-Allow-Origin: *。实测单独这条没救活——部分 WebView 不认拦截响应里带的 CORS 头,module script / stylesheet 依旧按跨域拒载。
  4. 最终有效的兜底:单文件内联、且保留 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 兼容前提)。
  5. 页内错误捕获层兜底,让下次失败显示具体报错/资源 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。

解决步骤(按序执行)

  1. 改 frontend/vite.config.js:build.target: 'es2017' + stripCrossorigin 插件(transformIndexHtml 里 html.replace(/ crossorigin/g, ''))。
  2. 改 AssetWebViewClient.serveAsset:所有 WebResourceResponse 加 Access-Control-Allow-Origin: * 头(关键)。
  3. 重建 npm run build;用 grep -F "?." dist/assets/index-*.js 复核无可选链残留,确认产物 index.html 的 <script type="module">/<link> 无 crossorigin。
  4. 重打 APK 装 debug 包验证;仍有问题看页内错误捕获层输出的具体资源 URL 再对症。

下次怎么识别

  • 判断特征:浏览器正常 + WebView 挂 → 高度怀疑构建目标/WebView 版本兼容,不是业务 bug。
  • 深色应用"纯黑"而不是"白屏" → 空 #root + 深背景,JS 没执行。
  • 老设备 / 用户系统 WebView 不更新 → 前端构建目标别默认追最新,es2017 是稳妥下限。

关联


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Programming