渲染流程

本文按场景说明 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),可以少一次必定为空的请求。

首屏(服务端渲染和预渲染)

预渲染就是在构建时对每个路径执行同样的渲染,再写成文件。

  1. 执行 handle:执行 src/app.server.ts 的 handle,例如读取当前用户。
    • 跳转时直接响应跳转。
    • 出错时,错误页面渲染在第一个有服务端模块的路由的位置。这个位置和客户端导航时请求数据的路由一致, 所以两边显示的结果相同。
  2. 渲染应用:<gemkit-outlet> 逐层匹配路由,执行 layout 和页面的 load。 其中服务端数据直接调用 *.server.ts,不经过数据文件。
  3. 等页面准备好再输出文档:页面的数据和标题确定后才开始输出。
    • 响应的状态码(404、错误)和 <title>、<meta> 在文档头部,必须先确定。
    • load 中跳转时,不输出任何内容,直接响应跳转。
  4. 流式输出剩余内容:之后的异步内容(例如 @server() 方法)继续流式输出,不阻塞前面的内容。
  5. 输出数据:最后输出 layout 和页面的数据(<script type="application/json">)以及服务端更新过的 store。水合时直接使用这些数据,不再请求。
  6. 服务端元素:纯服务端元素和延时视图元素的 HTML 也直接输出在页面中,并带有 ssr-server 标记。
    • 预渲染时还会把它们收集到 __elements.json。
    • 客户端导航到这个页面时,元素是客户端新建的,没有服务端内容,需要从这里获取。

水合

  1. 准备路由:客户端入口设置路由后才定义 <gemkit-outlet>。
    • 服务端输出的 <gemkit-outlet> 在定义时就会升级,必须先有路由。
  2. 路由渲染:路由加载 layout 和页面的模块,load 直接取页面中输出的数据(takeSSRData),不发请求。
    • 服务端渲染时出错的路由直接渲染错误页面,和服务端结果一致。
    • 首屏的数据也会写入历史缓存,见前进和后退。
  3. 元素水合:普通元素复用服务端输出的节点并绑定事件。
    • 恢复了服务端状态的元素会跳过首轮副作用中的 @server() 方法,因为这些方法已经在服务端执行过。
  4. 纯服务端元素:保留服务端内容,不水合。
  5. 延时视图元素:保留服务端内容,先不加载代码,按 on 选项(默认用户交互时)再加载代码并水合。
    • 用户第一次交互(pointerdown、click、focusin、keydown、input)时才加载代码。
    • 加载完成后元素升级、水合,再把触发加载的事件重新派发给原来的目标。
    • 很少交互的部分(例如点赞按钮)不必在首屏下载代码。
  6. 滚动位置:保持浏览器恢复的滚动位置,例如刷新后。

客户端导航到新的历史记录

点击 <gem-link>、调用 history.push 或 replace 时,都会生成新的历史记录,总是获取最新的内容。

  1. 预取:链接悬停或获得焦点时开始预取,导航时如果还没有预取,就在这时开始。预取的内容有:

    • 目标路由的 layout 和页面模块。
    • __data.json:只在需要时请求。如果有服务端模块的路由都是可以复用的 layout,就不请求。
    • __elements.json:开启预渲染时才请求。页面中有没有服务端元素要渲染后才知道,所以总是请求。

    预取的结果在 30 秒内被导航使用一次。这样页面模块、数据和元素可以并行请求,不用等渲染到对应位置才开始。

  2. 逐层渲染路由:outlet 从根路由开始逐层匹配。layout 渲染后,它内部的 outlet 再匹配子路由。

    • 离开某个 layout 时,即将被移除的 outlet 也会响应路由变化,但它匹配不到当前路径,所以不加载。 否则它会加载 404 页面并覆盖标题。
  3. layout 数据:在子路由之间导航时,如果 layout 的参数没变,直接复用已渲染的 layout 的数据。 提交表单后不复用。

  4. 页面数据:layout 和页面共用同一个 __data.json 请求,优先使用预取的结果。

    • load 的参数中 data 是服务端数据。没有 load 时,直接使用服务端数据。
    • 数据文件中有跳转时,替换当前导航。
    • 数据文件中有出错的路由时,在该路由的位置渲染错误页面,其他路由的数据仍然可以使用。
  5. 标题和 meta:页面加载后更新标题,layout 和页面的 meta 合并后更新 <head>。

  6. 页面元素:参数改变时(例如 /blog/a 到 /blog/b)重新创建页面元素,只有查询参数改变或者提交表单后 重新加载时复用。

    • 同一个页面元素换了参数,其中的元素状态(例如 @mounted() 中获取的数据、输入框的内容)属于上一个页面, 复用会显示错误的内容。查询参数通常是同一个页面的筛选、分页,提交表单后也是同一个页面, 复用可以保留输入框等状态。
    • 实现:参数改变时换用另一个包装模板,lit 不复用不同模板中的元素。两个模板的内容不同 (第二个多一个空插值),因为开发时 HMR 会把内容相同的模板当作同一个。服务端和水合时总是使用第一个, 和 SSR 的内容一致。
  7. 获取服务端元素的 HTML:页面渲染后,新建的纯服务端元素和延时视图元素按以下顺序获取 HTML:

    1. 当前历史记录的缓存:只在前进后退时命中。
    2. __elements.json:预渲染时才有。
    3. POST /__gemkit_element:同一轮渲染中的元素合并成一个请求。

    如果延时视图元素的代码已经加载过,它就是一个普通元素,直接由客户端渲染,不获取 HTML。 延时视图元素填入服务端内容后,加载代码的时机和首屏一样,按 on 选项。

  8. 滚动:滚动到顶部。如果 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)

  1. 已渲染的 layout 的数据:只对 layout 有效,要求参数不变,且没有提交过表单。
  2. 当前历史记录的缓存:前进后退时命中。
  3. 页面中输出的数据:首屏水合时使用。
  4. 预取的 __data.json。
  5. 请求 __data.json。

纯服务端元素和延时视图元素的 HTML(loadServerElement)

  1. 当前历史记录的缓存:前进后退时命中。
  2. __elements.json:开启预渲染时才请求,可以使用预取的结果。
  3. POST /__gemkit_element。

已知的取舍

  • 服务端重复执行 layout 的 load:在同一个 layout 下导航时,__data.json 仍然包含 layout 的数据, 服务端会重新执行 layout 的 load。原因是数据文件要能作为静态文件预渲染,内容不能随客户端的状态变化。
  • 动态渲染的页面执行两次 handle:动态渲染的页面导航时,__data.json 和 POST /__gemkit_element 是两个请求,各执行一次 handle。
  • 开启预渲染时的空请求:开启预渲染时,未预渲染的页面也会请求 __elements.json,得到 {} 后再 POST。原因是客户端不知道哪些页面被预渲染了。

相关

  • 页面 中的 load 和 paths。
  • 服务端元素 中的 @server()、@lazy() 和 @client()。
  • 请求 中的 handle、cookie 和预渲染。