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-Location | window.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()) | Always | Always | 总是 |
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。