5月27日 17:31

Qwik City 核心功能有哪些?路由、数据加载与全栈能力解析

Qwik City 是 Qwik 官方的全栈元框架,围绕路由、数据加载、表单处理、中间件和 SEO 五大核心能力,提供了一套完整的服务端渲染开发方案。与 Next.js 或 Remix 不同,Qwik City 从底层就利用了 Qwik 的可恢复性架构,首屏不发送 JavaScript、不做 hydration,这意味着同样的 SSR 页面,Qwik City 的 TTI(Time to Interactive)通常远低于传统框架。下面逐个拆解它的核心功能。

路由系统

Qwik City 采用基于文件系统的路由,src/routes/ 目录下的文件结构直接映射为 URL 路径。

目录结构与路由映射

shell
src/ ├── routes/ │ ├── index.tsx -> / │ ├── about/ │ │ └── index.tsx -> /about │ ├── products/ │ │ ├── index.tsx -> /products │ │ └── [id]/ │ │ └── index.tsx -> /products/:id │ └── layout.tsx -> 全局布局

方括号 [id] 表示动态路由参数,在 loader 或组件中通过 params.id 访问。这种约定式路由省去了手动配置路由表的步骤,新增页面只需创建文件。

动态路由与数据加载结合

动态路由最常见的场景是根据参数加载数据。routeLoader$ 在服务端执行,返回的数据自动序列化给客户端组件使用:

tsx
// routes/products/[id]/index.tsx import { component$ } from '@builder.io/qwik'; import { routeLoader$ } from '@builder.io/qwik-city'; export const useProductData = routeLoader$(async ({ params }) => { 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> <p>Price: ${product.value.price}</p> </div> ); });

关键点:routeLoader$ 在 SSR 阶段执行,返回数据会自动随 HTML 一起发送到客户端,不会产生额外的 waterfall 请求。这与 Next.js 的 getServerSideProps 类似,但 Qwik City 的数据会通过 resumability 机制直接恢复,不需要重新执行组件逻辑。

嵌套布局

layout.tsx 用于定义共享布局,Slot 组件作为子路由的渲染出口:

tsx
// routes/layout.tsx import { component$, Slot } from '@builder.io/qwik'; export default component$(() => { return ( <div> <header>Header</header> <main><Slot /></main> <footer>Footer</footer> </div> ); });

布局文件支持嵌套——每一层目录都可以有自己的 layout.tsx,形成从外到内的布局包裹链。这与 Next.js 的 layout 嵌套机制类似,但 Qwik City 的布局组件同样是可恢复的,不会在客户端重新执行渲染逻辑。

数据加载

Qwik City 提供了三种数据获取方式,分别对应不同的执行时机和使用场景。选对加载方式直接影响首屏性能和交互体验。

routeLoader$ — 服务端数据加载

这是最常用的数据加载方式,在服务端执行,适合页面级数据的预获取:

tsx
import { routeLoader$ } from '@builder.io/qwik-city'; export const useUserData = routeLoader$(async ({ params, url, env, requestEvent }) => { // 路由参数 const userId = params.id; // 查询参数 const page = url.searchParams.get('page'); // 环境变量 const apiKey = env.get('API_KEY'); // Cookie const session = requestEvent.cookie.get('session'); const response = await fetch(`https://api.example.com/users/${userId}`); return response.json(); });

routeLoader$ 的回调接收一个 RequestEvent 对象,可以访问路由参数、URL、环境变量、Cookie 和请求头等完整的请求上下文。这意味着你不需要额外引入 express 的 req 对象或 Next.js 的 API 路由,所有请求信息都在一个对象上。

clientLoader$ — 客户端数据加载

当需要在客户端动态获取数据(比如用户交互后刷新)时使用:

tsx
import { clientLoader$ } from '@builder.io/qwik-city'; export const useClientData = clientLoader$(async ({ params, navigate }) => { const response = await fetch(`/api/data/${params.id}`); return response.json(); });

routeLoader$ 的区别:clientLoader$ 仅在浏览器端执行,不会阻塞 SSR。它适合非关键数据或需要实时刷新的场景,比如客户端导航后的数据更新。

useResource$ — 组件级数据加载

useResource$ 在组件内部使用,支持依赖追踪和响应式重新获取:

tsx
import { component$, useResource$ } from '@builder.io/qwik'; export const UserList = component$(() => { const users = useResource$(({ track, cleanup }) => { track(() => /* 追踪的依赖项 */); cleanup(() => { // 组件卸载时的清理逻辑 }); return fetch('https://api.example.com/users').then(r => r.json()); }); return ( <div> {users.value ? ( <ul> {users.value.map(user => <li key={user.id}>{user.name}</li>)} </ul> ) : ( <p>Loading...</p> )} </div> ); });

三种加载方式的选择依据:页面级首屏数据用 routeLoader$,客户端动态刷新用 clientLoader$,组件内响应式获取用 useResource$。如果你熟悉 React 生态,可以类比为:routeLoader$getServerSidePropsuseResource$useSWR / useQuery

表单处理

Qwik City 通过 action$ 实现表单提交的服务端处理,并内置了 Zod 验证集成。与传统框架需要手动编写 API 路由处理表单不同,action$ 把表单逻辑和组件放在同一个文件中。

action$ — 服务端表单处理

tsx
import { action$, zod$, z } from '@builder.io/qwik-city'; import { component$, Form } from '@builder.io/qwik-city'; export const useContactForm = action$(async (data, { requestEvent }) => { const { name, email, message } = data; await sendEmail({ name, email, message }); return { success: true }; }, zod$({ name: z.string().min(2), email: z.string().email(), message: z.string().min(10) })); export default component$(() => { const action = useContactForm(); return ( <Form action={action}> <input name="name" placeholder="Name" /> <input name="email" type="email" placeholder="Email" /> <textarea name="message" placeholder="Message"></textarea> <button type="submit">Submit</button> {action.value?.success && <p>Message sent!</p>} </Form> ); });

action$ 的两个参数:第一个是处理函数,接收表单数据和服务端上下文;第二个是可选的 Zod schema,用于自动验证输入。验证失败时,Qwik City 会自动返回验证错误信息到 action.status,无需手动处理验证逻辑和错误返回。

clientAction$ — 客户端表单处理

tsx
import { clientAction$ } from '@builder.io/qwik-city'; export const useClientAction = clientAction$(async (data) => { console.log('Client action:', data); return { success: true }; });

clientAction$ 适用于不需要服务端逻辑的轻量级交互,如本地状态更新或客户端计算。

中间件

中间件用于处理跨路由的通用逻辑,比如鉴权、日志、CORS 等。Qwik City 的中间件与 Express 的中间件概念相似,但运行在边缘函数(Edge Functions)环境中。

请求拦截

tsx
// routes/middleware.ts import { middleware$ } from '@builder.io/qwik-city'; export const onRequest = middleware$(async ({ requestEvent, next }) => { const url = requestEvent.url; const session = requestEvent.cookie.get('session'); if (!session && url.pathname !== '/login') { throw requestEvent.redirect(302, '/login'); } return next(); });

响应拦截

tsx
export const onResponse = middleware$(async ({ requestEvent, next }) => { const response = await next(); response.headers.set('X-Custom-Header', 'value'); return response; });

中间件按目录层级生效——放在 routes/ 根目录的中间件对所有路由生效,放在子目录的只对该子路由树生效。这个机制与布局嵌套类似,可以灵活控制中间件的作用范围。例如,routes/admin/middleware.ts 只保护 /admin/* 路由。

SEO 优化

Qwik City 支持通过 head 导出函数为每个页面设置元数据,包括 title、meta 标签和 Open Graph 信息:

tsx
import { useDocumentHead$ } from '@builder.io/qwik-city'; export const head = useDocumentHead$(({ resolveValue }) => { const product = resolveValue(useProductData); return { title: product.name, meta: [ { name: 'description', content: product.description }, { property: 'og:title', content: product.name }, { property: 'og:description', content: product.description }, { property: 'og:image', content: product.image } ] }; });

useDocumentHead$ 中可以通过 resolveValue 引用 routeLoader$ 的数据,实现动态 SEO。元数据在服务端生成,搜索引擎抓取时能看到完整内容,这对电商产品页、博客文章等需要社交分享和搜索排名的页面尤为重要。

国际化

Qwik City 的国际化通过社区库 qwik-speak 实现,支持多语言翻译和动态语言切换。

服务端配置

tsx
// src/entry.ssr.tsx import { renderToStream } from '@builder.io/qwik/server'; import { Root } from './root'; export default function (opts) { return renderToStream(<Root />, { ...opts, containerAttributes: { lang: opts.lang } }); }

组件内使用翻译

tsx
import { component$ } from '@builder.io/qwik'; import { useSpeak } from 'qwik-speak'; export const MyComponent = component$(() => { const { t } = useSpeak(); return ( <div> <h1>{t('welcome.title')}</h1> <p>{t('welcome.description')}</p> </div> ); });

翻译键值对通过配置文件定义,qwik-speak 会根据请求的语言自动匹配对应的翻译内容。配合路由中间件,可以实现基于 URL 前缀(如 /zh/about/en/about)的语言切换。

实践要点

三种数据加载方式的选择

场景推荐方式执行环境特点
页面首屏数据routeLoader$服务端数据随 HTML 下发,零 waterfall
客户端动态刷新clientLoader$浏览器不阻塞首屏,适合交互后更新
组件内响应式获取useResource$浏览器支持依赖追踪,适合交互驱动的更新

错误处理

routeLoader$ 中应统一处理错误,避免未捕获异常导致 500:

tsx
export const useData = routeLoader$(async ({ params, redirect }) => { try { const response = await fetch(`https://api.example.com/data/${params.id}`); if (!response.ok) { throw redirect(302, '/error'); } return response.json(); } catch (error) { throw redirect(302, '/error'); } });

注意这里使用 redirect 而非 throw new Error(),因为 Qwik City 的 redirect 是框架级的跳转机制,会正确设置 HTTP 状态码和 Location 头。

缓存策略

利用 requestEvent.sharedMap 实现请求级缓存,同一请求中多个 loader 共享数据:

tsx
export const useCachedData = routeLoader$(async ({ requestEvent }) => { const cacheKey = 'shared-data'; const cached = requestEvent.sharedMap.get(cacheKey); if (cached) { return cached; } const data = await fetchData(); requestEvent.sharedMap.set(cacheKey, data); return data; });

sharedMap 的生命周期是单次请求,不同于浏览器缓存或 CDN 缓存,它解决的是同一请求中多个 loader 重复获取相同数据的问题。例如,布局 loader 和页面 loader 都需要用户信息时,sharedMap 可以避免两次 fetch。


以上是 Qwik City 的核心功能覆盖。从路由到数据加载再到表单和中间件,Qwik City 的设计始终围绕一个目标:让 Qwik 的可恢复性架构能在全栈场景下完整落地,避免传统 SSR 框架中常见的 hydration 开销和数据瀑布。与 Next.js 和 Remix 相比,Qwik City 最大的差异化在于零 JavaScript 首屏策略——页面首次加载不发送任何 JS,仅在用户交互时按需加载对应的事件处理器。

标签:Qwik