增量渲染与配置
主线位置:首屏主线的两头。
render_to的第一步是查增量缓存,命中就把缓存的 HTML 整页送出、不建VirtualDom;最后一步是把这次渲染的结果存进缓存。中间的每一段 HTML 从哪来,由ServeConfig里的index.html模板决定。
flowchart TB
req["GET 页面"] --> chk{"内存缓存有 path?query"}
chk -->|有| out1["整页返回 带 Age"]
chk -->|没有| fsc{"文件缓存 去掉 query"}
fsc -->|有且没过期| put1["放进内存"]
put1 --> out1
fsc -->|没有| render["新建 VirtualDom 渲染"]
render --> err{"根错误边界有错"}
err -->|有| errout["状态码加消息 不缓存"]
err -->|没有| send["发给浏览器"]
send --> again["再渲染一份完整 HTML"]
again --> store["写内存和文件"]
实验应用是 server function 那一页的 fs2-app(dioxus = "=0.7.10",router + fullstack,dx build --platform web --fullstack --release,dx 0.7.9)。启动时读环境变量:FS2_MODE 含 isr 时加 incremental(IncrementalRendererConfig::new().static_dir(FS2_STATIC)),设了 FS2_TTL 再加 .invalidate_after(Duration::from_secs(FS2_TTL))。两个页面:
#[get("/api/whoami", headers: HeaderMap)]
pub async fn whoami() -> Result<String, ServerFnError> {
let user = headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("user=")))
.unwrap_or("anonymous")
.to_string();
info!("whoami() -> {user} in {}", where_am_i());
Ok(user)
}
#[component]
fn Me() -> Element {
let user = use_server_future(whoami)?;
rsx! {
p { id: "hello", "hello, {user():?}" }
}
}
#[component]
fn Gone() -> Element {
use_hook(|| {
dioxus::fullstack::FullstackContext::commit_http_status(
dioxus::fullstack::StatusCode::NOT_FOUND,
None,
)
});
rsx! { p { "this page is gone" } }
}
/me 按请求的 cookie 显示用户名(SSR 时 whoami 直接在页面请求里执行,拿的是页面请求的 cookie),/gone 把状态码设成 404 但正常渲染。M2 Max、macOS 27、rustc 1.99.0。
ServeConfig
ServeConfig 四个字段:
| 字段 | 设置方法 | 用在哪 |
|---|---|---|
index: IndexHtml | new() 自动读;with_index_html 自己给 | render_head、render_after_main 等按段写 |
incremental: Option<IncrementalRendererConfig> | incremental | 建渲染池时 build() 成 IncrementalRenderer |
context_providers | context、context_provider | 每个请求新建 VirtualDom 后 insert_any_root_context |
streaming_mode | streaming_mode、enable_out_of_order_streaming | 见流式渲染 |
dioxus::launch 用默认的 ServeConfig::new();要改,用 LaunchBuilder::new().with_cfg(server_only! { ServeConfig::new()... }),server_only! 让客户端那次编译看不到这段代码。LaunchBuilder::with_context 给的 context 在服务端也被追加进 context_providers。
ServeConfig::new 去 public_path() 找 index.html:环境变量 DIOXUS_PUBLIC_PATH,否则是服务端可执行文件旁边的 public/(dx 就是这么摆的)。找到了交给 IndexHtml::new,解析失败就 expect panic;没有 index.html 时打一条警告,用 IndexHtml::ssr_only 的最小模板(没有加载 wasm 的 <script>,页面不会水合)。
IndexHtml::new 只做字符串切分,依次找 id="main"(以及它后面第一个 >)、</head>、</body>、<title> / </title>,切成六段:<title> 前、标题、<title> 后到 </head> 前、</head> 到 id="main">、id="main"> 之后到 </body> 前、</body> 及之后。前三个找不到都是错误,<title> 可以没有。服务端发出的每一页都是这六段中间夹着渲染结果,一次页面请求里逐行对过。
增量缓存挂在哪
SsrRendererPool::render_to 一开头,在借渲染器、spawn_pinned 之前,同步地查缓存:
let route = parts
.uri
.path_and_query()
// ...
.to_string();
// ...
// before we even spawn anything, we can check synchronously if we have the route cached
if let Some(freshness) = self.check_cached_route(&route, &mut into) {
return Ok((
HttpError {
status: StatusCode::OK,
message: None,
},
HeaderMap::new(),
freshness,
// ...
));
}
键是 path 加 query 的字符串,和请求头、cookie 都无关。命中时状态码写死 200、响应头是空的 HeaderMap,正文是缓存里的整页 HTML,作为一块送出。
没命中就正常渲染。存缓存发生在 render_to 的最后,流式循环结束之后,而且只在没有出错时(根错误边界有错误时函数早就返回了):
// If incremental rendering is enabled, add the new render to the cache without the streaming bits
if let Some(incremental) = &self.incremental_cache {
let mut cached_render = String::new();
if let Err(err) = Self::render_head(&cfg, &mut cached_render, &virtual_dom) {
throw_error!(err);
}
renderer.reset_hydration();
if let Err(err) = renderer.render_to(&mut cached_render, &virtual_dom) {
throw_error!(IncrementalRendererError::RenderError(err));
}
if let Err(err) = Self::render_after_main(&cfg, &mut cached_render, &virtual_dom) {
throw_error!(err);
}
cached_render.push_str(&post_streaming);
if let Ok(mut incremental) = incremental.write() {
let _ = incremental.cache(route, cached_render);
}
}
这是对同一个 VirtualDom 的第二次完整渲染:所有 suspense 都已完成,所以缓存里的版本没有 fallback、没有替换块,水合数据是整页的。开了流式时,第一个请求收到的是流式版本,之后的请求收到的是这份。
IncrementalRendererConfig
IncrementalRendererConfig 的选项和默认值:
| 方法 | 默认 | 作用 |
|---|---|---|
static_dir | ./static(相对进程的工作目录) | 文件缓存的根目录 |
memory_cache_limit | 10000 | 内存 LRU 的条数。设成 0 不是「只用文件缓存」:lru 变成 None,try_get_or_insert 直接返回 None、不去读文件,整个缓存只写不读(见增量渲染缓存) |
invalidate_after | None | 过期时间;同时决定文件名带不带时间戳 |
clear_cache | false | build() 时清空两层缓存 |
map_path | 按 / 切 path | 路由到文件路径的映射 |
pre_render | false | 只存进字段,0.7.10 里没有任何代码读它 |
IncrementalRenderer 是两层:
- 内存:
lru::LruCache<String, (DateTime<Utc>, Vec<u8>)>,键是完整的path?query。 - 文件:默认的
map_path先去掉?之后的部分,再按/一段段拼到static_dir下。没有invalidate_after时文件是<path>/index.html;有的话是<path>/index/<十六进制秒数>.html,读的时候扫这个目录,过期的文件当场删掉。
cache 两层都写;get 先查内存,内存没有时(try_get_or_insert 的插入闭包里)去读文件,读到就放进内存。
实验:一页带用户名的页面
isr.sh:FS2_TTL=3,先后用不同的 cookie 请求,每次只打印状态行、age、cache-control 和 #hello 那一段:
### 19:59:30 GET /me (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### 19:59:30 GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### 19:59:30 GET /me
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### 19:59:30 GET /me?x=1 (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### 19:59:30 GET /gone
HTTP/1.1 404 Not Found
age: 0
</script><p data-node-hydration="0">this page is gone</p><script>window.initial_dioxus_hydration_data="g4EY9oYYZRgvGGcYbxhuGGX2";</script></div>
### 19:59:30 GET /gone
HTTP/1.1 200 OK
age: 0
cache-control: max-age=3
</script><p data-node-hydration="0">this page is gone</p><script>window.initial_dioxus_hydration_data="g4EY9oYYZRgvGGcYbxhuGGX2";</script></div>
这一段时间里服务端日志只有一次 whoami:
2026-10-28T11:59:30.294465Z INFO exp_dioxus_fs2_app::api: whoami() -> alice in GET /me
逐条看:
- 第一次是真渲染:
age: 0,没有cache-control。新渲染的响应用的是RenderFreshness::now(None),即使配了invalidate_after也不带max-age。 - bob 和不带 cookie 的请求命中内存缓存,拿到的是 alice 的页面。缓存键里没有 cookie,
whoami没有再跑。页面里的水合数据也是 alice 的,浏览器水合时use_server_future直接用它,不会重新请求。 /me?x=1的内存键和/me不同,内存没命中;接着查文件,文件路径去掉了 query,正好是刚才 alice 那次写下的me/index/6ae1e3a2.html。于是 bob 拿到了 alice 的页面,而且这一次不是同一个键。依赖 query 的页面(分页、搜索)会互相串。/gone第一次 404,第二次命中缓存变成 200:缓存只存 HTML,命中时状态码写死 200。组件设的响应头(add_response_header)同样不会出现在命中的响应里。出错的页面不进缓存,只设了状态码、正常渲染完的页面照样进。
实验结束时文件缓存目录里是:
isr-static/gone/index/6ae1e3a2.html
isr-static/me/index/6ae1e3a2.html
增量缓存对所有路由生效,ServeConfig 里没有按路由关掉它的选项;IncrementalRenderer::invalidate 是公开方法,但渲染器藏在 FullstackState 的私有字段里,应用拿不到。能放心缓存的只有「同一个 path 对谁都一样、和 query 无关」的页面。页面里只要有一处读 cookie、读请求头、按 query 变,就不该打开 incremental,或者把那一部分改成水合之后在客户端请求。
过期时间不起作用
接着上面,等 4 秒(超过 invalidate_after 的 3 秒)再请求:
### 19:59:34 GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 18446744073709551612
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### 19:59:34 GET /me (cookie: user=carol)
HTTP/1.1 200 OK
age: 18446744073709551612
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
没有过期,Age 是 2⁶⁴ − 4。原因在 InMemoryCache::try_get_or_insert:
let now = Utc::now();
let elapsed = timestamp.signed_duration_since(now);
let age = elapsed.num_seconds();
// The cache entry is out of date, so we need to remove it.
if let Some(invalidate_after) = self.invalidate_after {
// If we can't convert to a std duration, the duration is negative and hasn't elapsed yet.
if let Ok(std_elapsed) = elapsed.to_std() {
if std_elapsed > invalidate_after {
tracing::trace!("memory cache out of date");
memory_cache.pop(route);
return Ok(None);
}
}
}
timestamp.signed_duration_since(now) 是「存入时刻减现在」,对过去存入的条目永远是负数。注释说负数表示「hasn't elapsed yet」,可这里只要条目是过去存的,它就是负的:to_std() 失败,过期检查从不成立。age 也是负数,后面 age as u64 变成接近 2⁶⁴ 的数,RenderFreshness::write 把它写进 Age 头。实验里存入后 4 秒,num_seconds() 是 −4,Age 就是 18446744073709551612。存入后不到 1 秒的命中,num_seconds() 截断成 0,所以前面几次是 age: 0。
只要条目还在内存 LRU 里,它就一直被返回。文件那一层的过期判断用的是 SystemTime::elapsed(),方向是对的,但内存命中时根本走不到文件那一层;只有内存里没有这个键时(重启之后、LRU 挤掉之后、或者换了一个 query),才会去看文件有没有过期。
重启之后
isr2.sh:同一个 static_dir,请求、重启服务端、再请求。先不设 invalidate_after,再设成 60 秒:
== no invalidate_after, run 1
### GET /me (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
~/exp-dioxus/fs2-app/isr-static2/me/index.html
== no invalidate_after, after restart
### GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("bob"))
### GET /me (cookie: user=carol)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("bob"))
== invalidate_after 60s, run 1
### GET /me (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
~/exp-dioxus/fs2-app/isr-static2/me/index/6ae1e3d6.html
== invalidate_after 60s, after restart
### GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 18446744073709551613
cache-control: max-age=60
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
- 不设
invalidate_after时,文件每次都写,但重启后从来不读:重启后 bob 那次是真渲染(日志里有whoami() -> bob),覆盖了 alice 的文件。原因在ValidCachedPath::freshness:Some(RenderFreshness::new(age, max_age?, ..)),max_age是None时?让它返回None,FileSystemCache::get就当没找到。这种配置下文件缓存是只写的。 - 设了
invalidate_after时,重启后从文件读到 alice 的页面(这次 bob 没有触发渲染),放进内存,于是又回到上一节:之后这个条目在内存里不再过期,Age是溢出的数。
try_get_or_insert 的前身在 dioxus-ssr 的 incremental.rs 里,当时的写法是 if let Ok(elapsed) = timestamp.elapsed(),timestamp 是 web_time::SystemTime,elapsed() 算的是「现在减存入时刻」,方向正确。PR #1948「feat(fullstack): support wasm target」要让服务端能编译成 wasm(描述里说「In order to run a server with Cloudflare Pages Functions (Workers), the server must be built with WASM」),其中一个提交是「swap out web-time for chrono」:时间戳换成 chrono::DateTime<Utc>,判断改成 let elapsed = timestamp.signed_duration_since(now);。chrono 的 a.signed_duration_since(b) 是 a - b,于是方向反了。这个 PR 首次随 0.5.0 发布;那时的写法是 elapsed.to_std().unwrap(),0.7.10 里换成了 if let Ok(..),负数不再 panic,但也不再过期。
RenderFreshness 与响应头
RenderFreshness 有 age、max_age、timestamp 三个字段,render_handler 拿到后调 write 写两个头:Age: <age>,max_age 有值时再写 Cache-Control: max-age=<max_age>。没开 incremental 时也会写 age: 0(FS2_MODE 为空时 curl -i /gone 的响应头里就有这一行)。
| 情况 | Age | Cache-Control |
|---|---|---|
没开 incremental,或开了但这次是新渲染 | 0 | 无 |
内存命中,没有 invalidate_after | 负数秒转 u64(1 秒内是 0) | 无 |
内存命中,有 invalidate_after | 同上 | max-age=<秒> |
Cache-Control: max-age 会让浏览器和中间的 HTTP 缓存再缓存一层。配合上面的问题:一个带用户名的页面,命中 ISR 后还带着 max-age,前面若有 CDN,它也会把这页当成公共内容。
FS2_MODE=isr FS2_TTL=3,刚启动的服务端(static_dir 是空的)。先 curl -H 'cookie: user=alice' /me,等 5 秒,再 curl -H 'cookie: user=bob' /me。第二次的 #hello 里是谁的名字?Age 头是多少?(M2 Max、macOS 27、rustc 1.99.0,release 构建。)
动脑筋答案
### GET /me (cookie: user=alice)
HTTP/1.1 200 OK
age: 0
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
### (5 秒后) GET /me (cookie: user=bob)
HTTP/1.1 200 OK
age: 18446744073709551611
cache-control: max-age=3
<p id="hello" data-node-hydration="0">hello, Some(Ok("alice"))
还是 alice。invalidate_after 是 3 秒,可内存缓存的过期判断用的是「存入时刻减现在」,5 秒后是 −5 秒,to_std() 失败,判断不成立,条目原样返回。Age 是 −5 转成 u64:2⁶⁴ − 5 = 18446744073709551611。max-age=3 照样写,按 HTTP 的规则,Age 远大于 max-age,这个响应一到浏览器就已经过期了,而服务端还会一直发它,直到进程重启或者被 LRU 挤掉。
小结
ServeConfig::new()读public/index.html(或DIOXUS_PUBLIC_PATH),按id="main"、</head>、</body>、<title>切成六段;另有根 context、流式开关和增量缓存。改它要用LaunchBuilder::with_cfg(server_only! { .. })。- 增量缓存在
render_to的开头同步查、在结尾(所有 suspense 完成、没有出错时)用一次完整的非流式渲染写入;键是path?query,不看 cookie、请求头。命中时状态码写死 200、响应头为空。 - 两层:内存 LRU 按完整键;文件按去掉 query 的 path,有
invalidate_after时文件名带时间戳。依赖 query 的页面会在内存未命中时从文件串到别的 query 的结果。 - 0.7.10 的内存缓存从不过期,
Age头是负数转u64;没有invalidate_after时文件缓存只写不读;pre_render选项没有任何作用。 - 增量缓存对所有路由生效、应用拿不到失效接口:只适合对所有人都相同、与 query 无关的站点。带用户信息的页面一旦被缓存,下一个人看到的是上一个人的页面。