聊个比较有意思的 Flutter Web 问题。上周帮一个朋友排查他刚上线的 Flutter Web 后台系统,用户反馈说“从列表页点进详情,一切正常;但只要手一抖按了 F5,页面就白屏,浏览器地址栏那串路径还是原来的,后端日志里全是 404”。他当时用的是默认的 flutter build web 产出去部署的,代码本身在本地flutter run -d chrome里跑得别提多顺。我第一反应是“历史路由没配服务端 fallback”,但真到服务器上复现以后,发现这里头还藏着另一个跟 Service Worker 缓存有关的坑,比 404 更容易让人抓狂。这篇文章就把这两个问题和排查过程完整拆开,写成一份可以直接照着处理的记录,给同样在搞 Flutter Web 的同行少走几步弯路。
1. 第一个问题:为什么 Flutter Web 一刷新就 404
1.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-age=31536000, 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 项目,上线前必须把前面那张自检表过一遍。尤其要记住两点:服务端必须对所有非文件路径做 fallback;Service Worker 要么改成 network-first,要么干脆关掉。这两个点看着不起眼,但真遇到线上故障时,每一个都能折腾你一整晚。
如果你也正在做 Flutter Web,希望这篇记录能帮你少熬一次夜。遇到类似问题,先对照自己属于哪一种场景,再去动手改配置,别一上来就 “清缓存、换浏览器、重装系统”。问题一般比你想象的简单,只是藏在了大家都容易忽略的地方。