Flutter Web刷新白屏?路由404与Service Worker缓存排查指南

📅 发布时间:2026/9/15 12:49:27
Flutter Web刷新白屏?路由404与Service Worker缓存排查指南
聊个比较有意思的 Flutter Web 问题。上周帮一个朋友排查他刚上线的 Flutter Web 后台系统用户反馈说“从列表页点进详情一切正常但只要手一抖按了 F5页面就白屏浏览器地址栏那串路径还是原来的后端日志里全是 404”。他当时用的是默认的 flutter build web 产出去部署的代码本身在本地flutter run -d chrome里跑得别提多顺。我第一反应是“历史路由没配服务端 fallback”但真到服务器上复现以后发现这里头还藏着另一个跟 Service Worker 缓存有关的坑比 404 更容易让人抓狂。这篇文章就把这两个问题和排查过程完整拆开写成一份可以直接照着处理的记录给同样在搞 Flutter Web 的同行少走几步弯路。1. 第一个问题为什么 Flutter Web 一刷新就 4041.1 现象描述与最小复现步骤朋友项目的现象非常典型首页能打开登录后点几个菜单跳到/dashboard/orders这种二级路径也没问题列表渲染、接口请求都正常。但只要用户在那个路径下按 F5 或者手动刷新浏览器就会给你一个白屏加 404。复现步骤其实很简单执行flutter build web得到build/web目录。把build/web里的文件直接用 Nginx 或者 Apache 部署到服务器。启动服务后访问http://你的域名/一切正常。从页面内部点击路由跳转到http://你的域名/some/route正常。刷新浏览器404。我做过最小化验证不用 Flutter随便写一个静态页面丢在/some/route服务器上根本没有这个文件所以 404 是一点都不奇怪。Flutter Web 本质上是个单页应用打包出来就一个index.html加一堆 JS、CSS、CanvasKit 和资源文件。你在页面内部点链接跳转其实是 JavaScript 在浏览器里把地址改成了/some/route然后 Flutter 自己根据路由渲染对应页面这个过程没有向服务器发第二次请求所以没问题。但手动刷新是另一回事浏览器直接向服务器要/some/route这个路径的内容服务器找了一圈发现没这个文件自然回 404。1.2 根因解释SPA 路由与服务端路由的错位要彻底理解这个坑需要先弄清楚 Flutter Web 默认的路由机制。Flutter Web 里的路由导航默认用的是所谓 History 路由也叫 Path 路由。它依赖浏览器 History API 里的pushState。简单来说当你从首页跳转到/dashboard/orders时Flutter 调用pushState让地址栏变成了新路径但页面并没有重新加载而是由 Flutter 在内存里完成了组件的切换。这里的关键是浏览器地址栏上看起来像是访问了一个新 URL但服务器上其实没有对应的目录或文件。服务器在收到/dashboard/orders这个请求时它不会知道这是前端路由只会老老实实地找dashboard/orders文件。找不到就返回 404。这跟传统多页面应用每个页面都有真实文件完全不同。所以问题核心不是 Flutter 代码出错而是我们让服务器“假装”所有未知路径都返回同一个index.html让 Flutter 的启动脚本先接管页面再由 Flutter 路由根据当前 URL 渲染正确页面。这个方案业内叫 SPA fallback。1.3 怎么用浏览器开发者工具快速确认遇到这种现象先别急着改代码用浏览器开发者工具确认一下请求路径很关键。打开 DevTools 的 Network 面板清空日志然后在出问题的页面按 F5 刷新。看第一条文档请求也就是类型是document的那一项。如果它的 URL 是http://你的域名/dashboard/orders状态码是 404那基本可以肯定是服务端没做 fallback。你切换到 Console 面板往往还能看到一行类似Failed to load resource: the server responded with a status of 404 (File not found)的提示。这时候可以顺手看一眼 Response 内容如果返回的是 Nginx 或者 Apache 的默认 404 页面那就更确信了。如果返回的居然是index.html的内容那说明 fallback 已经配了问题可能出在其他地方比如静态资源加载路径错了——那也是另一个常见坑后面会提到。2. 刷新 404 的几种标准解法2.1 Nginx 下 try_files 一劳永逸大多数团队用的是 Nginx。解决方案是在server配置块里加一段location规则server { listen 80; server_name your-domain.com; root /var/www/flutter_web; index index.html; location / { try_files $uri $uri/ /index.html; } }核心就是try_files $uri $uri/ /index.html;这一行。它的意思是先尝试按当前 URL 找真实文件找不到就尝试按目录找比如请求/dashboard/orders就去目录列表里找再找不到就统一返回/index.html。这样 Flutter 应用被加载后会读取地址栏里的 URL自己路由到对应页面。要注意try_files需要放在location /下面。如果你项目里还配了 API 接口比如/api/需要转发给后端那么必须单独写一条前缀匹配规则不能让/api/也被 fallback 到index.html。通常我会这样拆location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location / { try_files $uri $uri/ /index.html; }改完配置记得nginx -t检查语法再systemctl reload nginx或nginx -s reload平滑重载。2.2 Apache 和 Caddy 同样有对应配置Apache 相对少见一点但思路一样。在项目根目录放一个.htaccess或者直接在 VirtualHost 配置里写IfModule mod_rewrite.c RewriteEngine On RewriteBase / RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] /IfModule这里的逻辑是如果请求的文件存在就直接返回如果目录存在就直接返回否则重写到index.html。等价于 Nginx 的 try_files。如果你用的是 Caddy配置更短your-domain.com { root * /var/www/flutter_web try_files {path} /index.html file_server }Caddy 的try_files {path} /index.html同样完成 fallback。注意以上配置都是针对用 History 路由的场景。如果你因为某些原因改用 Hash 路由那么 URL 会变成http://你的域名/#/dashboard/orders刷新时请求的始终是根路径/理论上不会 404。但它有别的代价下面会细说。2.3 静态托管平台和本地文件预览的限制不少人最初是在本地验证的直接把build/web整个文件夹拖进浏览器或者双击index.html打开。此时访问根路径能显示页面可一旦你用代码跳转到/dashboard/orders再刷新就会立刻 404因为文件协议下根本没有对应的文件而且file://下也没有服务器能帮你 fallback。所以本地验证 Flutter Web 成品时我建议起一个本地静态服务器比如cd build/web python3 -m http.server 8080然后访问http://localhost:8080这样能模拟线上环境。换成 GitHub Pages 这类平台情况会好一些它默认支持对不存在的路径做部分处理但严格来说并不能在所有场景下保证 SPA fallback 完美可用。最稳妥的做法还是用平台支持的机制GitHub Pages 可以创建一个404.html脚本会把所有 404 跳转到这个页面然后在这个 404 页面里重写地址栏为根路径来引导 Flutter 应用加载。听起来有点绕不如直接改用 Hash 路由省心。2.4 History 路由真的完美吗既然刷新 404 这么麻烦为什么 Flutter Web 还默认用 History 路由因为 URL 好看、干净也没有#符号更利于分享和搜索引擎理解页面路径。但“好看”是有代价的它要求服务端必须配合配置 fallback一旦你部署在静态存储、对象存储或某些不支持自定义路由规则的平台就会出现刷新 404。Hash 路由的好处是对服务端零要求缺点是 URL 里有#不够美观且对 SEO 的友好度会差一些。对于内部管理系统、后台工具这类不需要 SEO 的场景Hash 路由完全够用。如果你只是临时部署到某个不听话的平台上可以直接切到 Hash 路由在main.dart里给MaterialApp配置usePathUrlStrategy或者直接设置hashUrlStrategy。在 Flutter 3.x 版本下一个常见的做法import package:flutter_web_plugins/url_strategy.dart; void main() { usePathUrlStrategy(); // 使用 History 路由默认 // 想用 Hash 路由就在项目里改成 // useHashUrlStrategy(); runApp(const MyApp()); }url_strategy插件提供了方便的路由策略切换方法。我个人的建议是只要你有服务器控制权优先用 History 路由别因为怕麻烦而选 Hash。但如果你是部署到 CDN 边缘静态节点且平台不提供目录路由重写能力那老老实实上 Hash 路由反而活得轻松。3. 第二个有意思的问题Service Worker 把旧版本“焊死”在用户浏览器里3.1 现象更新完代码用户还是旧版甚至白屏刷新 404 解决后朋友高兴了一天。第二天又碰上更邪门的事他在服务器上重新部署了新版本自己手机上清除数据后访问一切正常但用户群里好几个人反馈“页面还是老样子甚至白屏”而且用户怎么强刷都没用直到有人换个浏览器或者开了隐身窗口才看到新页面。听起来像 CDN 缓存可他压根没上 CDN就是一台 Nginx。真正的问题出在 Flutter Web 默认生成的 Service Worker 上。3.2 Flutter Web 的缓存机制flutter_service_worker.js 默认策略flutter build web生成的web目录里你可以看到这样几个关键文件index.htmlflutter_bootstrap.jsflutter.jsflutter_service_worker.jsmanifest.json一堆带 hash 的 JS、字体、CanvasKit 资源Flutter 为了让你体验“安装到桌面”和离线访问默认会注册一个 Service Worker也就是flutter_service_worker.js。它的缓存策略大体是应用第一次加载时把main.dart.js、资产清单、字体等静态资源全部缓存到浏览器的 Cache Storage 里。后续再打开页面Service Worker 会拦截请求优先从缓存里取资源所以加载速度很快甚至离线也能访问。问题也在这里。很多版本策略是“缓存优先”也就是说只要缓存里有main.dart.js哪怕你服务端已经换成新版本了浏览器仍然会用旧缓存。Flutter 构建时会给主要资源文件名打上 hash所以如果你每次都重新生成build/web新版本的main.dart.js文件名可能已经变了理论上 Service Worker 会从服务器拉取新的 index.html 再发现新文件。但实际情况很复杂特别是index.html和flutter_bootstrap.js文件名是固定的一旦这两者被缓存住Service Worker 可能根本不会去请求服务器看有没有新版本用户就永远停留在旧逻辑里。更闹心的是白屏旧 Service Worker 在缓存列表里记录着一批旧 hash 的资源文件。服务器部署新版本后旧文件可能已经被清理掉了。当 Service Worker 尝试按旧清单去缓存里找资源时找不到就会请求服务器结果返回 404最终页面 JS 加载失败白屏。3.3 实测从 DevTools 里看到缓存真身要验证是不是 Service Worker 搞的鬼打开用户报错的浏览器按 F12 进 Application 面板在左侧找到Service Workers看当前页面是否注册了flutter_service_worker.js状态是否是activated and is running。看左侧Cache Storage展开flutter开头的缓存空间里面通常有几个 key包括flutter_app、flutter_assets等。双击一条缓存查看是否有main.dart.js以及文件 hash 是否跟服务器上的最新版本一致。如果发现缓存里文件的名称和服务器上的不一致或者页面加载时 Network 面板显示资源来自Service Worker而不是Server那基本可以实锤。还有一个排查技巧在 Application 面板的 Service Workers 区域勾选Bypass for network然后强制刷新。如果页面立刻变成了新版本说明问题确实出在 Service Worker 的缓存优先策略上。3.4 解法一更新策略把缓存优先改成网络优先针对这个问题业内用得最多的方案是自定义flutter_service_worker.js把默认的缓存优先改为网络优先。简单说页面每次加载都先去服务器要资源拿不到再用缓存兜底这样部署新版本后用户刷新一下就能拿到最新资源。Flutter 生成的 service worker 文件结构并不复杂但它被固定写在web/目录下改完之后flutter build web会保留你的修改。所以你可以直接编辑web/flutter_service_worker.js在 fetch 事件监听里调整策略。不过不同 Flutter 版本生成的脚本差异很大直接改脚本容易被后续升级覆盖而且改动难度也不小。更推荐的做法是在项目的web/index.html里去掉自动注册 Service Worker 的逻辑。Flutter 默认在index.html里有一段类似下面的初始化代码script if (serviceWorker in navigator) { window.addEventListener(load, function () { navigator.serviceWorker.register(flutter_service_worker.js); }); } /script如果你不希望它默认开启可以注释这段脚本或者改成手动注册自己的自定义 service worker。比如你在web/sw.js里实现一个 network-first 的 fetch 逻辑self.addEventListener(install, function (event) { self.skipWaiting(); }); self.addEventListener(activate, function (event) { event.waitUntil(self.clients.claim()); }); self.addEventListener(fetch, function (event) { if (event.request.mode navigate || event.request.url.includes(/main.dart.js)) { event.respondWith( fetch(event.request) .then(function (response) { return response; }) .catch(function () { return caches.match(event.request); }) ); return; } event.respondWith( caches.match(event.request).then(function (cached) { return cached || fetch(event.request); }) ); });然后在index.html里注册script if (serviceWorker in navigator) { window.addEventListener(load, function () { navigator.serviceWorker.register(sw.js); }); } /script这样关键导航请求始终走网络网络失败才掉到缓存既能保证新版本尽快生效又能保留离线能力。3.5 解法二如果不在乎离线功能直接关掉 Service Worker对很多后台管理系统、企业内部工具来说离线访问并不是刚需。与其跟默认的 service worker 纠缠不如直接关掉它。在index.html中删除或者注释掉那段注册代码即可。这样每次刷新浏览器都会直接向服务器请求最新资源改动即所见排查问题也少一个变量。但要注意如果用户之前已经注册过旧版 service worker你只是部署了新代码并没有让浏览器“忘记”旧 service worker那旧 service worker 还会继续控制页面。这时候需要在页面里显式注销它。你可以在index.html里添加一段统一清理逻辑if (serviceWorker in navigator) { navigator.serviceWorker.getRegistrations().then(function (registrations) { registrations.forEach(function (registration) { registration.unregister(); }); }); }这样新用户打开页面不会注册 service worker老用户也会被卸载掉缓存控制权。优先保证功能正确性和更新及时性比离线访问更贴合大多数业务场景。3.6 解法三结合版本化资源和响应头做双保险最后一条防线是 HTTP 缓存头。就算你用了自定义 service worker如果 Nginx 对index.html设置了很长的Cache-Control浏览器主文档还是会走强缓存导致新页面根本不会请求服务器。Flutter 构建出来的带 hash 的资源文件可以放心设置长缓存因为文件名变了旧缓存自动失效。但index.html和flutter_bootstrap.js这种入口文件一定不能长缓存。推荐在 Nginx 里做这样的区别location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate; } location / { try_files $uri $uri/ /index.html; }也可以给整个静态目录设置一个合理的Cache-Control比如 JS/CSS 设为public, max-age31536000, immutable但入口文档必须禁用缓存。这样即使没有 service worker用户刷新时也会先检查服务器有没有新版本拿到新index.html后再去加载新的资源文件。另外还要注意一些云厂商的 CDN 会自动缓存index.html部署新版本后需要在 CDN 控制台刷新缓存否则你怎么改上游都白搭。4. 这两个问题叠加时怎么排查附避坑清单4.1 正确排查顺序先看 Network再看 Application很多同学遇到“Flutter Web 上不了新版本”这类问题习惯第一反应去清浏览器缓存。但正确顺序应该是先点开 DevTools从 Network 面板看主文档请求的状态码和来源。如果主文档请求状态是 304 或者 200 from disk cache说明入口文档被缓存了优先解决 HTTP 缓存头。如果主文档是 200 from ServiceWorker说明 service worker 拦截了请求优先解决 service worker。如果主文档是正常的 200但后续加载的 JS 里面返回 404那多半是部署的时候没把build/web完整传上去或者服务器上删除了旧 hash 文件导致 service worker 清单里的资源找不到了。打个比方整个加载链路就像快递派送index.html是收件人main.dart.js是包裹service worker 是小区物业代收点。用户更新完看不到新东西可能是收件人地址变了入口缓存也可能是物业把旧包裹当宝贝一直留着service worker还有可能是快递柜换了锁资源 hash 变了但物业不知道。要分环节一个个看而不是一上来就盲清缓存。4.2 我踩过的几个高频小坑这里整理一些实际项目中容易踩的坑每条我都是拿头发换回来的。反代没有保留原始 Host如果你用 Nginx 反代 Flutter Web 静态服务上游也要正确传递请求头否则某些情况下资源加载会跳到别的域名。至少加一行proxy_set_header Host $host;try_files 配了但没重启Nginx 配置改动后必须 reload。曾经有同事改完配置自信地说“我试过了没问题”结果是nginx -t通过但没 reload旧配置还跑着。改完配置记得执行nginx -s reload如果是 Docker 容器记得重新加载或重启容器。浏览器 Service Worker 的更新延迟即使你部署了新版本并且关闭了旧的 service worker浏览器也要等到旧页面关闭或无活动标签页后才会激活新的 service worker。这是浏览器机制不是你部署错了。测试时可以勾选 DevTools 里的Update on reload强制刷新测试。资源服务器删除了旧 hash 文件很多打包平台会做增量上传为了节省空间把旧文件清理掉。但老用户浏览器里的 service worker 缓存清单里还记录着那些旧文件名。一旦缓存里没有它就会去请求服务器结果 404。这就导致“部分用户白屏”。解决方案就是在服务端保留几个版本的旧资源或者等待 service worker 清理完成或者干脆禁用 service worker。4.3 上线前花十分钟做个自检折腾完这两个问题后我在自己团队里定了一个检查单供参考检查项检查方法通过标准入口文档不缓存查看响应头index.html的 Cache-Control不得有max-age长缓存服务器 fallback 生效任意深层路径直接刷新返回index.html内容或正常页面Service Worker 策略查看flutter_service_worker.js或注册脚本network-first 或已注销部署完整性检查服务器上main.dart.js哈希与本地构建一致一致缓存列表DevTools Application 面板查看 Cache Storage无旧版本 hash 残留这个自检不用花多长时间但它能规避掉 Flutter Web 线上 90% 的“迷之白屏”问题。4.4 顺手一提渲染模式对部署的影响如果你用的是 CanvasKit 渲染模式打包出来的资源里会包含一个体积不小的 CanvasKit WASM 文件。某些老版本 Flutter 在部署时可能因为 MIME 类型配置不对导致.wasm文件加载后报CompileError。Nginx 默认通常能正确处理.wasm但如果你在自定义 MIME 配置里动了手脚得确保有一行types { application/wasm wasm; }这个倒不是什么新坑但每次在 Flutter Web 部署问题里都容易被忽略。检查一下没坏处。5. 一些关于 Flutter Web 的心里话折腾完刷新 404 和 Service Worker 这两个问题后我自己最大的一个体会是Flutter Web 的难点从来不在 Dart 和 Flutter 本身而在“你怎么把它当作一个真实 Web 应用去运维”。框架帮你生成了代码但部署、缓存、路由、兼容性这些 Web 基本功一样不能少。所以我现在的习惯是只要是 Flutter Web 项目上线前必须把前面那张自检表过一遍。尤其要记住两点服务端必须对所有非文件路径做 fallbackService Worker 要么改成 network-first要么干脆关掉。这两个点看着不起眼但真遇到线上故障时每一个都能折腾你一整晚。如果你也正在做 Flutter Web希望这篇记录能帮你少熬一次夜。遇到类似问题先对照自己属于哪一种场景再去动手改配置别一上来就 “清缓存、换浏览器、重装系统”。问题一般比你想象的简单只是藏在了大家都容易忽略的地方。