编程 Inertia v3 上线踩的 8 个坑:409 版本冲突、303 重定向与被 CDN 吃掉的 Vary 头

2026-10-19 00:09:59

Inertia v3 的 HTTP 协议与上线踩坑:409、303、Vary 与 partial reload

  • 官网:https://inertiajs.com
  • 仓库:https://github.com/inertiajs/inertia(默认分支 3.x,MIT,TypeScript)
  • 协议文档:https://inertiajs.com/docs/v3/core-concepts/the-protocol
  • 工作原理:https://inertiajs.com/docs/v3/core-concepts/how-it-works
  • 服务端安装:https://inertiajs.com/docs/v3/installation/server-side-setup
  • Partial reloads:https://inertiajs.com/docs/v3/data-props/partial-reloads

Inertia 不替代 Laravel 或 Vue,它做的是把服务端渲染出的 page object 交给客户端组件树。路由、控制器、中间件、鉴权、校验仍在 Laravel 里;Vue 只接管视图层。它没有客户端路由,也没有独立的 API 层,因此上线时出问题的往往不是组件,而是 HTTP 层:响应头、状态码、缓存和版本号。

首次请求与后续 XHR

首次访问是一个普通全页 HTML:完整文档 + 根元素 + 一段 data-page 的 JSON script,里面包含 component、props、url、version。客户端读这段 JSON 启动框架,渲染首屏。

之后的站内跳转走 XHR,请求头带三个关键字段:

GET https://example.com/events/80
Accept: text/html, application/xhtml+xml
X-Requested-With: XMLHttpRequest
X-Inertia: true
X-Inertia-Version: 6b16b94d7c51cbe5b1fa42aac98241d5

服务端识别到 X-Inertia 后不再返回 HTML,而是返回 application/json 的 page object,并带上响应头:

HTTP/1.1 200 OK
Content-Type: application/json
Vary: X-Inertia
X-Inertia: true

{"component":"Event","props":{"errors":{},"event":{...}},"url":"/events/80","version":"6b16b94d7c51cbe5b1fa42aac98241d5","encryptHistory":true}

page object 的字段固定为:component、props(其中 errors 默认 {})、url、version、encryptHistory / clearHistory、preserveFragment。Vary: X-Inertia 是给中间缓存看的,同一 URL 会因这个头返回 HTML 或 JSON 两种表示,缓存层必须按它分桶。

用 curl 直接验证协议:

curl -i https://example.com/events/80 \
-H 'X-Inertia: true' \
-H 'X-Inertia-Version: 6b16b94d7c51cbe5b1fa42aac98241d5' \
-H 'X-Requested-With: XMLHttpRequest' \
-H 'Accept: text/html, application/xhtml+xml'

客户端如何分支处理响应

客户端只看链尾响应,规则按响应头而不是状态码单独判断:

响应客户端行为
带 X-Inertia: true直接渲染,应用 page object,替换组件
409 + X-Inertia-Locationwindow.location 整页跳转,重新加载资源;后台请求在版本变化时豁免,避免丢掉未保存状态
409 + X-Inertia-Redirect发起一次新的 Inertia GET
其他 HTML / 普通 JSON / 错误页视为异常,触发可取消的 HTTP exception 事件,默认弹错误框,不会静默跳转
普通 3xx不进入上述规则,浏览器透明跟随并重放请求头,X-Inertia 会到达重定向目标

状态码 ≥400 的合法 Inertia 响应也会先触发可取消的 HTTP exception 事件,取消后不渲染。这一条常被忽略:422 校验错误如果没被表单接住,同样会走异常分支。

版本号与 409

version 可以是数字、字符串或文件 hash,资源变化时必须变化;不跟踪就传空串。客户端把当前版本放在 X-Inertia-Version 回传,服务端通常在中间件里比较。

版本不一致时,GET 立即返回 409 Conflict,带 X-Inertia-Location(目标 URL)和当前 X-Inertia-Version,客户端整页跳转。非 GET 请求不会收到版本不匹配的 409,但随后的 GET 重定向仍可能触发。adapter 在 409 时会 reflash flash session 数据,让它在后续请求里存活。

curl -i https://example.com/events/80 \
-H 'X-Inertia: true' \
-H 'X-Inertia-Version: stale-version' \
-H 'X-Requested-With: XMLHttpRequest'
# 期望:409,响应头包含 X-Inertia-Location

重定向:303 与两种 409

PUT/PATCH/DELETE 之后返回的 302 Found 会被转换成 303 See Other,让浏览器对目标发 GET,避免重复非 GET 请求。这是表单提交后不重复写库的关键。

外部重定向跳出 Inertia 应用时,请求头无法存活,服务端返回 409 + X-Inertia-Location,客户端做整页跳转。

非 prefetch 且目标含 fragment 的重定向,返回 409 + X-Inertia-Redirect(完整 URL),客户端发起新的 Inertia GET。这和 preserveFragment 不是一回事:后者是把原始请求的 fragment 带过普通重定向。

校验错误与 error bag

Laravel adapter 把 errors 注册为 always prop,每个 page object 都有,无错误时为 {}。每个 key 可以是单条消息或数组。命名 bag 通过请求头隔离:

-H 'X-Inertia-Error-Bag: createUser'

同页多个表单各自用自己的 bag,互不污染。Precognition 校验请求用状态码区分:204 表示成功无错误,422 表示有错误,响应体带 errors。

partial reloads 与惰性求值

partial reload 只对同一页面组件的访问有效。客户端用 only: ['users'] / except: ['users'],或 router.reload({only:[...]}),`` 上也支持 only。

HTTP 层对应三个头,可以直接用 curl 复现:

curl -i 'https://example.com/users' \
-H 'X-Inertia: true' \
-H 'X-Inertia-Version: 6b16b94d7c51cbe5b1fa42aac98241d5' \
-H 'X-Inertia-Partial-Component: Users/Index' \
-H 'X-Inertia-Partial-Data: users' \
-H 'X-Inertia-Partial-Except: companies' \
-H 'X-Requested-With: XMLHttpRequest'

服务端必须配合惰性求值,闭包包裹的 prop 才会按需求值:

Inertia::render('Users/Index', [
'users' => fn () => User::all(),
'companies' => fn () => Company::all(),
]);

Inertia::optional(fn () => User::all()); // 普通访问从不求值,only 显式请求才求值
写法普通访问partial求值时机
User::all()Always可选总是求值
fn () => User::all()Always可选仅需要时
Inertia::optional(fn () => User::all())Never可选仅需要时
Inertia::always(fn () => User::all())AlwaysAlways总是

errors 就是用 Inertia::always() 共享的。partial reload 不跑校验也会返回一个空 error bag,覆盖客户端已有的校验错误,需要 preserveErrors: true 保留。once 可以链在 optional 上:Inertia::optional(...)->once(),只解析一次并由客户端记住。

merge props

merge props 让客户端把新数据合并进已有数据而不是替换:数组 append/prepend,对象浅合并或深合并。page object 里用 mergeProps / prependProps / deepMergeProps / matchPropsOn 标记。合并只发生在 partial reload,完整访问永远是替换。

SSR 链路

默认不做 SSR。开启后多一条链路:adapter 把 page object POST 到 Node SSR server,默认地址 http://127.0.0.1:13714/render,返回 {head:[...], body:"..."}。body 里带 data-server-rendered="true",客户端 hydrate 现有 DOM 而不是重新挂载。

SSR server 还有两个端点:GET /health 返回 {"status":"OK"};POST /shutdown 停止进程,Laravel 的 inertia:stop-ssr 就是调它。渲染失败返回 500,JSON body 里分类错误。

curl -s http://127.0.0.1:13714/health
# {"status":"OK"}

head/body 由 root template 的两个 slot 承接,Laravel adapter 暴露为 和。未启用 SSR 时,head 组件可以用 slot 给 fallback 内容。

Laravel 端安装要点

composer require inertiajs/inertia-laravel
php artisan inertia:middleware

@vite('resources/js/app.js')

把发布的 HandleInertiaRequests 追加到 bootstrap/app.php 的 web 组,在中间件里提供 version() 与 share()。控制器返回:

return Inertia::render('Event/Show', [
'event' => $event->only('id', 'title', 'start_date', 'description'),
]);

v3 之前用 @inertiaHead / @inertia 指令,仍支持但没有 SSR head fallback;新项目用 Blade 组件。根元素 id 默认 app,改动要同步客户端;Inertia::setRootView() 可以改默认根模板名。

上线踩坑

1. 部署后第一次点击整页刷新

现象:每次发布后用户点第一个链接都白一下整页重载,之后恢复正常。
原因:version() 返回空串或固定值,服务端认为版本一致;反过来,如果 version 变了但客户端拿的还是旧页面,GET 会收到 409 + X-Inertia-Location。
处理:version() 返回 Vite manifest 的 hash 或构建产物 filemtime 的组合,保证资源变则 version 变。用上面的 stale version curl 验证 409 分支是否按预期返回。

2. CDN 缓存把 JSON 发给了普通页面

现象:直接访问某 URL 浏览器显示一段 JSON;刷新几次又变成正常页面。
原因:Vary: X-Inertia 在反向代理或 CDN 层被吞掉,同一 URL 的 HTML 和 JSON 表示进了同一个缓存桶。
处理:curl -sI 带 X-Inertia: true 检查响应头是否保留 Vary;对带 X-Inertia 的请求 bypass 缓存,或至少确保缓存键包含该头。

3. 409 被网关或异常处理器改写

现象:版本不一致时前端弹出通用错误框,而不是整页跳转。
原因:409 被 Laravel exception handler 渲染成 HTML,或 nginx/WAF 把 409 重写成 502,X-Inertia-Location 丢失。
处理:确认 409 响应不被当成异常页处理,响应头完整透传;在网关层放行 409。

4. 外部跳转用了 redirect()->away()

现象:跳转到外部登录或支付页时前端报请求异常,页面不动。
原因:跨域重定向后请求头丢失,客户端拿到的是链尾 HTML,不满足任何 Inertia 分支。
处理:外部跳转返回 409 + X-Inertia-Location,让客户端做整页跳转。

5. partial reload 没省下查询

现象:router.reload({only:['users']}) 之后数据库日志里仍然是全量查询。
原因:props 写成普通数组值,普通访问和 partial 都会求值。
处理:改成闭包或 Inertia::optional();用 X-Inertia-Partial-Data 的 curl 对比返回的 props 键。

6. 局部刷新后校验错误消失

现象:表单提交报错后触发一次 partial reload,之前显示的错误文案没了。
原因:errors 是 always prop,partial reload 返回空 error bag 并覆盖客户端状态。
处理:请求加 preserveErrors: true。

7. 无限滚动刷新后列表回退

现象:滚动加载正常,手动刷新页面后列表回到第一页或出现重复项。
原因:merge props 只在 partial reload 生效,完整访问总是替换。
处理:把首屏数据与追加数据分开,确认 mergeProps 标记只作用于增量请求。

8. SSR 进程没起来

现象:开启 SSR 后首屏仍由客户端渲染,或整页 500。
原因:Node 进程未启动、端口不是 127.0.0.1:13714,或渲染异常被归到 500。
处理:用 /health 做探活,交给 supervisor 守护;停进程用 php artisan inertia:stop-ssr,它走的是 POST /shutdown。

复制全文 生成海报 Inertia.js Laravel Vue PHP SPA 前端路由

推荐文章

程序员茄子在线接单