5月27日 17:34

Qwik 的 SSR 和 CSR 是如何协同工作的?

传统 SSR 框架在服务端渲染 HTML 后,客户端还需要重新下载和执行 JavaScript 来"水合"页面,恢复事件绑定和状态。这个过程随着应用规模增长,开销越来越大。Qwik 提出了完全不同的方案——恢复性(Resumability),让 SSR 产出的 HTML 自带全部状态和事件信息,客户端无需水合即可直接交互。

Qwik 的 SSR 渲染流程

Qwik 在服务器端执行组件渲染时,不仅生成 HTML 结构,还会将组件状态、事件处理器引用、组件层级关系等全部序列化到 HTML 中。最终返回给浏览器的 HTML 包含了完整的应用快照。

具体流程如下:

  1. 执行组件渲染:Qwik 在 Node.js 环境中执行组件函数,生成虚拟 DOM 并渲染为 HTML 字符串
  2. 收集状态和事件:渲染过程中,Qwik 收集所有 useSignaluseStore 等响应式状态的当前值,以及所有 onClick$$ 后缀事件处理器的引用
  3. 序列化到 HTML:将状态数据以 JSON 格式注入到 HTML 末尾的 <script type="qwik/json"> 标签中,事件绑定信息以 HTML 属性形式嵌入对应 DOM 节点
  4. 发送完整 HTML:服务器将包含状态和事件元数据的 HTML 响应发送给浏览器
tsx
export const App = component$(() => { const count = useSignal(0); return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}> Increment </button> </div> ); });

服务端渲染后,count.value 的值 0 会被序列化到 HTML 中,onClick$ 处理器会被 Qwik Optimizer 编译为一个独立的 chunk 文件,HTML 中只保留该 chunk 的引用路径。浏览器首次加载时不需要下载这段逻辑代码。

这样做的直接好处是:首屏 HTML 包含完整可交互内容,搜索引擎可以直接抓取;客户端零 JavaScript 启动成本,FCP 和 TTI 几乎同时达成。

客户端恢复:零水合的交互激活

浏览器收到 HTML 后,Qwik 的客户端工作方式与传统框架完全不同。传统框架需要下载整个应用的 JavaScript,重新执行组件渲染函数,逐个绑定事件监听器——这就是水合过程。Qwik 跳过了这整个步骤。

qwikloader 全局事件代理

Qwik 在 HTML 中注入了一个约 1KB 的 qwikloader 脚本,它做一件事:在 document 上监听所有 DOM 事件。当用户点击按钮时,事件冒泡到 document,qwikloader 根据事件目标元素上的 HTML 属性找到对应的事件处理器 chunk 路径,然后动态加载并执行该 chunk。

html
<!-- 服务端渲染后的 HTML 片段 --> <button on:click="./app_component_ClickHandler.js#default"> Increment </button> <script type="qwik/json"> {"state":{"count":"0"},"refs":{}} </script>

这意味着:页面加载时不下载任何组件代码和事件处理器代码,只在用户真正交互时按需加载对应的代码块。

状态反序列化

当事件处理器 chunk 加载后,Qwik 从 <script type="qwik/json"> 中反序列化状态,将 count 恢复为响应式的 useSignal 对象。组件函数不需要重新执行,状态直接恢复到服务端渲染时的快照。

细粒度代码分割

Qwik Optimizer 编译器在构建阶段自动进行细粒度代码分割:

  • 组件级分割:每个 component$() 包裹的组件生成独立 chunk
  • 事件处理器级分割:每个 onClick$onChange$$ 后缀回调生成独立 chunk
  • 状态更新逻辑分割:涉及状态变更的逻辑单独提取

$ 后缀是 Qwik 的核心语法约定,它告诉编译器"这个函数的闭包需要被提取为独立模块"。这就是为什么 Qwik 要求事件处理器使用 onClick$ 而非 onClick——编译器需要显式知道哪些函数边界可以被分割。

Qwik City 的 SSR 能力

Qwik City 是 Qwik 的全栈框架,提供了路由、数据获取和服务端操作能力。

路由数据加载

routeLoader$ 在服务端执行数据获取,结果随 HTML 一起序列化发送:

tsx
import { component$ } from '@builder.io/qwik'; import { routeLoader$ } from '@builder.io/qwik-city'; export const useProductData = routeLoader$(async ({ params, env }) => { const response = await fetch(`https://api.example.com/products/${params.id}`); return response.json(); }); export default component$(() => { const product = useProductData(); return ( <div> <h1>{product.value.name}</h1> <p>{product.value.description}</p> </div> ); });

routeLoader$$ 后缀同样是分割标记——数据获取逻辑在服务端执行,结果序列化后客户端直接使用,不需要重复请求。

服务端操作

action$ 处理表单提交等写操作,同样在服务端执行:

tsx
import { action$ } from '@builder.io/qwik-city'; export const useAddToCart = action$(async (data, { requestEvent }) => { const session = requestEvent.sharedMap.get('session'); // 服务端执行业务逻辑 return { success: true }; });

流式 SSR

Qwik City 支持流式 SSR,服务器可以在渲染完成前就开始向客户端发送 HTML 片段。对于数据量较大的页面,用户可以更快看到首屏内容,而不是等待整个页面渲染完毕才收到第一个字节。

与传统 SSR 框架的关键区别

维度QwikNext.js (React)Nuxt (Vue)
客户端激活方式恢复性,无需水合水合,重新执行组件水合,重新执行组件
首屏 JS 体积接近零(仅 qwikloader ~1KB)较大(框架运行时 + 组件代码)中等(框架运行时 + 组件代码)
代码分割粒度编译器自动按函数级分割需手动配置 dynamic import需手动配置或依赖约定路由
TTI 表现FCP 与 TTI 几乎一致TTI 明显滞后于 FCPTTI 滞后于 FCP
状态传递自动序列化到 HTML需手动处理服务端状态注入需手动处理或依赖框架约定

核心差异在于水合成本。Next.js 的一个典型 SSR 页面,即便 HTML 已经包含完整内容,客户端仍需下载 React 运行时(约 40KB gzipped)和所有页面组件代码来执行水合。Qwik 完全跳过这一步,首次加载仅需 qwikloader 的 ~1KB。

实际性能表现

Qwik 官方基准测试中,一个中等复杂度页面的性能指标:

  • FCP(首次内容绘制):约 0.3s
  • TTI(可交互时间):约 0.9s
  • 首次加载 JS 体积:约 1KB(qwikloader)

作为对比,相同页面在 Next.js 下的典型数据:

  • FCP:约 0.5s
  • TTI:约 2.5s(需要等待水合完成)
  • 首次加载 JS 体积:约 80-150KB(React 运行时 + 组件代码)

大众点评 M 站在 2026 年初完成基于 Qwik.js 的重构,生产环境验证了恢复性方案在大型电商场景下的性能收益——首屏加载速度提升约 40%,TTI 从 3.2s 降至 1.1s。

最佳实践

服务端数据获取优先

优先使用 routeLoader$ 在服务器获取数据,避免客户端额外请求。数据随 HTML 一起序列化,客户端直接使用。

合理使用客户端任务

仅在必须访问浏览器 API 时使用 useVisibleTask$,如需要 windowdocument 或 Web API 的场景。不要用它来获取可以 SSR 的数据。

利用 useResource$ 处理异步数据

useResource$ 适合需要客户端动态获取数据的场景,它返回的 Resource 对象可以追踪加载状态(pending / resolved / rejected),便于在 UI 中展示 loading 态:

tsx
export default component$(() => { const searchData = useResource$(async ({ cleanup }) => { const controller = new AbortController(); cleanup(() => controller.abort()); const res = await fetch(`/api/search?q=keyword`, { signal: controller.signal }); return res.json(); }); return ( <Resource value={searchData} onPending={() => <p>加载中...</p>} onResolved={(data) => <div>{data.result}</div>} /> ); });

混合渲染策略

静态内容(如博客文章、产品描述)使用 SSR 确保搜索引擎可抓取;动态交互(如购物车、搜索建议)依赖 Qwik 的按需加载机制,只在用户交互时加载对应逻辑。

Qwik 的恢复性架构让 SSR 和 CSR 不再是二选一的取舍,而是一个连续光谱上的不同策略。服务端负责渲染和数据获取,客户端负责交互和按需加载,两者通过序列化机制无缝衔接,开发者不需要手动处理状态同步和水合逻辑。

标签:Qwik