问题背景:一登录就白屏,用户直接“失联”
把 ChatGPT 能力嵌进自家产品后,最常收到的工单不是“回答不准”,而是“页面白屏”。
体验路径很直接:用户点击“使用 AI 功能”→ 跳到登录 → 授权成功 → 回调回来只剩一片白。
刷新、清缓存、换浏览器都没用,只能关掉标签页。
对业务的影响是实打实的:30% 的新增用户在首次登录环节流失,客服压力飙升,老板天天问“到底哪里崩了”。
白屏不是前端“没渲染”,而是请求链路上某个环节直接断掉,浏览器拿不到数据,React 根节点挂载失败,于是“什么都不敢画”。。
下面把我在两个 SaaS 项目里踩出来的完整诊断流程摊开,照着做,基本能在 15 分钟内定位是谁“掐断”了流量。
诊断流程:先画地图再拆炸弹
先给出一张“白屏排查地图”,看一眼就能知道走到哪一步该查什么。
graph TD A[登录回调] --> B{URL 带 code?} B -->|否| C[授权失败,重走 OAuth] B -->|是| D[请求 /api/token] D --> E{HTTP 200?} E -->|否| F[看 CORS/状态码] E -->|是| G[JWT 解析] G --> H{exp 有效?} H -->|否| I[触发刷新或重新登录] H -->|是| J[进业务页] J --> K{React 渲染?} K -->|否| L[Console 报错/边界]打开 DevTools → Network,过滤
api/token,看有没有红色。
红色且是CORS error:大概率是后端没把Access-Control-Allow-Origin写对;
红色 401/403:令牌无效或接口没配好路由。如果 Network 里直接没有这条请求,九成是前端跳转逻辑把 code吃掉了。
常见写法:window.location.replace()把参数冲掉,或 React Router 的<Navigate>把 search 清空。
解决:在跳转前先把window.location.search存到sessionStorage,再手动拼回。拿到 200 以后,立刻在 Application → Local Storage 里看
access_token。
把值粘到 jwt.io,看exp时间。
若当前时间戳 >exp,后端却还给 200,说明颁发逻辑没校验刷新令牌,属于后端 Bug,直接甩锅。令牌 OK 仍白屏 → 切到 Console。
React 项目 90% 是React.createElement: type is invalid,原因是动态 import 失败;
Vue 项目则可能是Failed to resolve module,都是代码分割把 chunk 打到需要登录态的路由,结果第一次渲染就 404。
修复:把import()提前到公共路由,或给 webpackpublicPath写死绝对路径。
解决方案:三板斧砍下去,白屏基本消失
1. Axios 拦截器:一次写好,终身省心
// src/utils/request.ts import axios, { AxiosError } from 'axios'; import { refreshToken } from '@/services/auth'; const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 8000, withCredentials: true, // 关键:带 cookie 走 refresh }); // 响应拦截 instance.interceptors.response.use( (res) => res, async (error: AxiosError) => { const originalRequest = error.config!; // 仅对 401 做自动刷新 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const { accessToken } = await refreshToken(); // 把新 token 写回 header originalRequest.headers.Authorization = `Bearer ${accessToken}`; return instance(originalRequest); } catch { // 刷新也失败 → 踢回登录 window.location.href = '/login'; return Promise.reject(error); } } return Promise.reject(error); } ); export default instance;要点注释
_retry防循环;withCredentials让 refresh 接口能带 httpOnly cookie,避免 XSS 偷令牌;- 刷新失败立刻硬跳转,防止用户卡在白屏。
2. CORS & CSP 配置:抄作业即可
Nginx 示例
add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; add_header Access-Control-Allow-Headers "Authorization,Content-Type";CSP 头(防内联脚本把页面打白)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://static.cdn.com; connect-src 'self' https://api.openai.com";3. 存储策略:sessionStorage vs localStorage
| 场景 | 推荐 | 原因 |
|---|---|---|
| 仅单次会话有效 | sessionStorage | 关标签即清,降低 XSS 持久化风险 |
| 刷新页仍需保持 | localStorage | 避免重复走 OAuth,但需加 1h 过期兜底 |
| 移动端 WKWebView | localStorage | iOS 重启进程后 sessionStorage 会丢 |
实战组合:access_token→ 内存变量 +sessionStorage(内存优先,刷新页再读);refresh_token→ httpOnly cookie,前端 0 触碰;user_profile→ localStorage,过期时间写死 1h,定时任务清掉。
生产环境考量:让白屏永远先发“预警”
令牌刷新机制
后端下发双令牌:短 5min 的access+ 长 7d 的refresh。
前端在第 4 分钟主动调/refresh,成功则无缝续命;失败则弹 Toast“登录已过期”,2s 后跳转。
这样用户几乎感知不到,也杜绝“用到一半突然白屏”。错误边界(React 示例)
// src/components/ErrorBoundary.tsx import React, { Component, ReactNode } from 'react'; interface Props { fallback: ReactNode; children: ReactNodeDimmer } export class ErrorBoundary extends Component<Props> { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(err: Error) { // 上报 Sentry/Falcon window.$log.error('Render_Fatal', err); } render() { return this.state.hasError ? this.props.fallback : this.props.children; } }根组件包一层,白屏变成**“哎呀,网络开小了一下”**的刷新按钮,留存率能抬 8%。
- 监控 & 报警
- 前端:在
componentDidCatch与window.onerror里用navigator.sendBeacon把url+stack打到日志服务; - 阈值:1 分钟内同一条路由 >5 次报错即触发飞书机器人 Webhook;
- 后端:对
/api/token的 5xx 做 P1 报警,防止“后端崩了”背锅给前端。
- 前端:在
避坑指南:把坑先填上,就不用再跳
反模式:把
access_token存 Redux 持久化插件,页面刷新后仓库清空,直接白屏。
正确:只把必须持久化的放 Storage,Redux 做运行时内存。反模式:登录页和主站不同域,回调带
#片段,结果location.hash被 Nginx 重写掉。
正确:用history模式,或把 hash 换成?查询串,Nginx 配try_files $uri /index.html。反模式:把
refreshToken写成同步轮询,导致页面卡顿。
正确:只在 401 拦截里惰性刷新,正常请求别碰 refresh 接口。监控盲区:只埋点 PV,不埋点首次有意义渲染(FMP)。
白屏用户连 DOM 都没渲染,PV 照样发,结果数据一片祥和。
解决:把 FMP 上报条件写成document.querySelector('#root') 存在且高度 > 100。
延伸思考
- 如果你的产品同时支持 Web & 小程序,双令牌体系在小程序 WebView里如何无缝复用?
- 当刷新接口也 429 限流时,前端该用指数退避还是静默队列?
- 白屏监控只能告诉你“发生了”,如何结合用户录屏与性能指标精确定位是哪一行代码导致渲染中断?
把上面流程跑通,我最大的感受是:白屏不是玄学,只是请求链路上某个环节“没回包”。
只要按地图逐步缩小范围,基本都能在用户发飙前修完。
如果你想亲手搭一套“能说话”的 AI 应用,顺便把实时语音链路也跑通,可以试试这个动手实验——
从0打造个人豆包实时通话AI
我跟着做了一遍,语音端到端延迟 600ms 左右,前端部分同样用 React + Axios,把今天这些拦截器直接搬进去就能用,小白也能顺利体验。祝调试愉快,永不白屏!