渲染流程
本文按场景说明 Gemkit 如何渲染页面、每一步从哪里取内容,以及这样设计的原因。
参与者
| 名称 | 说明 |
|---|---|
| layout / 页面 | src/pages 中的元素,<gemkit-outlet> 从根路由开始逐层匹配并渲染它们 |
load 选项 |
同构的数据加载,服务端渲染和客户端导航时都会执行 |
*.server.ts |
只在服务端执行的 load、actions、paths,客户端通过数据文件获取结果 |
| 纯服务端元素 | @server() 标记的元素,只输出 HTML,客户端不加载代码,也不水合 |
| 延时视图元素 | @lazy() 标记的元素,先显示服务端的 HTML,用户交互时(或者 on 选项:页面加载完、进入视口时)才加载代码并水合 |
__data.json |
<页面路径>/__data.json,页面和所有 layout 的服务端数据 |
__elements.json |
<页面路径>/__elements.json,页面中纯服务端元素和延时视图元素的 HTML,只有预渲染时才有 |
POST /__gemkit_element |
服务端按需渲染元素,同一轮渲染中的元素合并成一个请求 |
部署方式
开发(rsbuild dev) |
gemkit start |
静态托管 | |
|---|---|---|---|
| 页面 HTML | 每次请求时渲染 | 优先使用预渲染的文件,没有的页面请求时渲染 | 只有预渲染的页面 |
__data.json |
请求时生成 | 优先使用预渲染的文件 | 只有预渲染的页面 |
__elements.json |
客户端不请求 | 预渲染的页面才有,其他页面返回 {} |
只有预渲染的页面 |
POST /__gemkit_element |
可用 | 可用 | 不可用 |
表单 actions |
可用 | 可用 | 不可用 |
- 带查询参数的请求:
gemkit start不使用预渲染的文件,因为load可能读取了url.searchParams, 结果和预渲染时不同。 - 哪些页面会预渲染:需要同时满足:没有设置
prerender = false、handle没有读取request或cookies、没有跳转或者出错。原因是因人而异的内容不能写成静态文件。load也不能读取请求或者 cookie, 这种情况需要导出prerender = false。 - 开发时和关闭预渲染(
prerender: false)时:不会生成__elements.json,客户端直接请求服务端渲染元素。 这在构建时就已确定(process.env.GEMKIT_PRERENDER),可以少一次必定为空的请求。
首屏(服务端渲染和预渲染)
预渲染就是在构建时对每个路径执行同样的渲染,再写成文件。
- 执行
handle:执行src/app.server.ts的handle,例如读取当前用户。- 跳转时直接响应跳转。
- 出错时,错误页面渲染在第一个有服务端模块的路由的位置。这个位置和客户端导航时请求数据的路由一致, 所以两边显示的结果相同。
- 渲染应用:
<gemkit-outlet>逐层匹配路由,执行 layout 和页面的load。 其中服务端数据直接调用*.server.ts,不经过数据文件。 - 等页面准备好再输出文档:页面的数据和标题确定后才开始输出。
- 响应的状态码(404、错误)和
<title>、<meta>在文档头部,必须先确定。 load中跳转时,不输出任何内容,直接响应跳转。
- 响应的状态码(404、错误)和
- 流式输出剩余内容:之后的异步内容(例如
@server()方法)继续流式输出,不阻塞前面的内容。 - 输出数据:最后输出 layout 和页面的数据(
<script type="application/json">)以及服务端更新过的 store。水合时直接使用这些数据,不再请求。 - 服务端元素:纯服务端元素和延时视图元素的 HTML 也直接输出在页面中,并带有
ssr-server标记。- 预渲染时还会把它们收集到
__elements.json。 - 客户端导航到这个页面时,元素是客户端新建的,没有服务端内容,需要从这里获取。
- 预渲染时还会把它们收集到
水合
- 准备路由:客户端入口设置路由后才定义
<gemkit-outlet>。- 服务端输出的
<gemkit-outlet>在定义时就会升级,必须先有路由。
- 服务端输出的
- 路由渲染:路由加载 layout 和页面的模块,
load直接取页面中输出的数据(takeSSRData),不发请求。- 服务端渲染时出错的路由直接渲染错误页面,和服务端结果一致。
- 首屏的数据也会写入历史缓存,见前进和后退。
- 元素水合:普通元素复用服务端输出的节点并绑定事件。
- 恢复了服务端状态的元素会跳过首轮副作用中的
@server()方法,因为这些方法已经在服务端执行过。
- 恢复了服务端状态的元素会跳过首轮副作用中的
- 纯服务端元素:保留服务端内容,不水合。
- 延时视图元素:保留服务端内容,先不加载代码,按
on选项(默认用户交互时)再加载代码并水合。- 用户第一次交互(
pointerdown、click、focusin、keydown、input)时才加载代码。 - 加载完成后元素升级、水合,再把触发加载的事件重新派发给原来的目标。
- 很少交互的部分(例如点赞按钮)不必在首屏下载代码。
- 用户第一次交互(
- 滚动位置:保持浏览器恢复的滚动位置,例如刷新后。
客户端导航到新的历史记录
点击 <gem-link>、调用 history.push 或 replace 时,都会生成新的历史记录,总是获取最新的内容。
预取:链接悬停或获得焦点时开始预取,导航时如果还没有预取,就在这时开始。预取的内容有:
- 目标路由的 layout 和页面模块。
__data.json:只在需要时请求。如果有服务端模块的路由都是可以复用的 layout,就不请求。__elements.json:开启预渲染时才请求。页面中有没有服务端元素要渲染后才知道,所以总是请求。
预取的结果在 30 秒内被导航使用一次。这样页面模块、数据和元素可以并行请求,不用等渲染到对应位置才开始。
逐层渲染路由:outlet 从根路由开始逐层匹配。layout 渲染后,它内部的 outlet 再匹配子路由。
- 离开某个 layout 时,即将被移除的 outlet 也会响应路由变化,但它匹配不到当前路径,所以不加载。 否则它会加载 404 页面并覆盖标题。
layout 数据:在子路由之间导航时,如果 layout 的参数没变,直接复用已渲染的 layout 的数据。 提交表单后不复用。
页面数据:layout 和页面共用同一个
__data.json请求,优先使用预取的结果。load的参数中data是服务端数据。没有load时,直接使用服务端数据。- 数据文件中有跳转时,替换当前导航。
- 数据文件中有出错的路由时,在该路由的位置渲染错误页面,其他路由的数据仍然可以使用。
标题和 meta:页面加载后更新标题,layout 和页面的 meta 合并后更新
<head>。页面元素:参数改变时(例如
/blog/a到/blog/b)重新创建页面元素,只有查询参数改变或者提交表单后 重新加载时复用。- 同一个页面元素换了参数,其中的元素状态(例如
@mounted()中获取的数据、输入框的内容)属于上一个页面, 复用会显示错误的内容。查询参数通常是同一个页面的筛选、分页,提交表单后也是同一个页面, 复用可以保留输入框等状态。 - 实现:参数改变时换用另一个包装模板,lit 不复用不同模板中的元素。两个模板的内容不同 (第二个多一个空插值),因为开发时 HMR 会把内容相同的模板当作同一个。服务端和水合时总是使用第一个, 和 SSR 的内容一致。
- 同一个页面元素换了参数,其中的元素状态(例如
获取服务端元素的 HTML:页面渲染后,新建的纯服务端元素和延时视图元素按以下顺序获取 HTML:
- 当前历史记录的缓存:只在前进后退时命中。
__elements.json:预渲染时才有。POST /__gemkit_element:同一轮渲染中的元素合并成一个请求。
如果延时视图元素的代码已经加载过,它就是一个普通元素,直接由客户端渲染,不获取 HTML。 延时视图元素填入服务端内容后,加载代码的时机和首屏一样,按
on选项。滚动:滚动到顶部。如果 URL 中有 hash,则保持位置,交给锚点处理。
前进和后退
每条历史记录离开时的状态缓存在内存中,key 是历史记录的 key,最多保留最近的 50 条。
- 缓存的内容:layout 和页面的数据(包括首屏的数据和客户端
load的结果),以及纯服务端元素和 延时视图元素的 HTML。 - 前进后退时:直接使用缓存,不预取,也不请求。
- 新导航时:push 和 replace 都会生成新的 key,所以永远不会命中缓存。
- 提交表单后:清空整个缓存。提交可能改变了登录状态等,之前的内容都不能再使用。
- 滚动位置:恢复离开时的位置。恢复前先等待页面中服务端元素的 HTML 填入,否则页面高度不够,
滚动位置会被截断。
- 等待前先等当前任务结束。复用的元素(例如只改了查询参数的页面元素)在微任务中更新,
MutationObserver的回调也在微任务中执行,这之后需要获取的元素才都开始获取。
- 等待前先等当前任务结束。复用的元素(例如只改了查询参数的页面元素)在微任务中更新,
原因:后退表示回到之前看到的状态,而不是获取最新的数据,浏览器的往返缓存也是这样。 所以不请求,内容和离开时一致,速度也最快。
首屏的服务端元素 HTML 在 DOM 中,没有写入缓存。所以后退到首屏页面时,这些元素需要重新获取一次。
查询参数和 hash 变化
- 查询参数变化:生成新的历史记录,重新加载页面数据。数据文件的 URL 包含查询参数。
- 只有 hash 变化:不重新渲染路由,也不加载数据。
提交表单(actions)
- 有 JS 时:
submitAction把表单POST到<页面路径>?/<name>。- 请求期间表单有
aria-busy属性。 - 提交后清空预取结果和历史缓存,layout 不再复用数据。
- 成功时重新渲染当前页面,滚动位置保持不变,因为是同一条历史记录。
- 跳转时客户端导航到目标地址。
- 出错时在页面的位置渲染错误页面。
- 请求期间表单有
- 没有 JS 时:原生表单提交。
- 成功或跳转时响应 303,跳转到目标地址或当前页面。
- 出错时直接响应错误页面。
跳转和错误
| 来源 | 服务端渲染 | 客户端导航 |
|---|---|---|
handle 跳转 |
响应跳转 | 数据文件或元素响应中带有跳转地址,客户端替换导航 |
load 跳转 |
响应跳转,不输出内容 | 数据文件中带有跳转地址,客户端替换导航 |
error() / notFound() |
错误页面渲染在出错路由的位置,响应使用它的状态码 | 同样位置渲染错误页面 |
| 没有匹配的路由 | 渲染错误页面,状态码 404 | 渲染错误页面 |
静态托管时,跳转写成 <meta http-equiv="refresh"> 页面和数据文件中的 redirect。
切换语言
语言链接是普通的 <a href>,切换语言时整页重新加载。
路径前缀(basePath)、语言包和所有内容都会改变,重新加载最简单,也和服务端渲染的结果一致。
内容来源一览
从上到下依次查找,命中就停止。
layout 和页面数据(loadData)
- 已渲染的 layout 的数据:只对 layout 有效,要求参数不变,且没有提交过表单。
- 当前历史记录的缓存:前进后退时命中。
- 页面中输出的数据:首屏水合时使用。
- 预取的
__data.json。 - 请求
__data.json。
纯服务端元素和延时视图元素的 HTML(loadServerElement)
- 当前历史记录的缓存:前进后退时命中。
__elements.json:开启预渲染时才请求,可以使用预取的结果。POST /__gemkit_element。
已知的取舍
- 服务端重复执行 layout 的
load:在同一个 layout 下导航时,__data.json仍然包含 layout 的数据, 服务端会重新执行 layout 的load。原因是数据文件要能作为静态文件预渲染,内容不能随客户端的状态变化。 - 动态渲染的页面执行两次
handle:动态渲染的页面导航时,__data.json和POST /__gemkit_element是两个请求,各执行一次handle。 - 开启预渲染时的空请求:开启预渲染时,未预渲染的页面也会请求
__elements.json,得到{}后再POST。原因是客户端不知道哪些页面被预渲染了。
