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 路径。
目录结构与路由映射
shellsrc/ ├── 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$ — 服务端数据加载
这是最常用的数据加载方式,在服务端执行,适合页面级数据的预获取:
tsximport { 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$ — 客户端数据加载
当需要在客户端动态获取数据(比如用户交互后刷新)时使用:
tsximport { 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$ 在组件内部使用,支持依赖追踪和响应式重新获取:
tsximport { 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$ ≈ getServerSideProps,useResource$ ≈ useSWR / useQuery。
表单处理
Qwik City 通过 action$ 实现表单提交的服务端处理,并内置了 Zod 验证集成。与传统框架需要手动编写 API 路由处理表单不同,action$ 把表单逻辑和组件放在同一个文件中。
action$ — 服务端表单处理
tsximport { 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$ — 客户端表单处理
tsximport { 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(); });
响应拦截
tsxexport 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 信息:
tsximport { 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 } }); }
组件内使用翻译
tsximport { 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:
tsxexport 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 共享数据:
tsxexport 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,仅在用户交互时按需加载对应的事件处理器。