Qwik 项目里 TypeScript 怎么写?核心类型与实战用法
Qwik 从设计之初就深度整合了 TypeScript,项目脚手架默认生成 .ts/.tsx 文件,组件、状态、事件、路由等核心 API 均提供完整的类型推导。理解 Qwik 的类型系统,关键在于把握 QRL(可恢复引用)这一独特概念——它决定了 Qwik 中函数类型的书写方式与普通 React/Vue 项目有本质区别。
组件 Props 类型
Qwik 组件通过 component$ 泛型参数声明 Props 类型。$ 后缀是 Qwik 的核心约定,表示该函数是一个 QRL(Resumable Lazy-Load Reference),框架会在需要时才加载和执行它。
tsximport { component$, PropsOf } from '@builder.io/qwik'; interface ButtonProps { label: string; onClick$: () => void; disabled?: boolean; variant?: 'primary' | 'secondary' | 'danger'; } export const Button = component$<ButtonProps>((props) => { return ( <button onClick$={props.onClick$} disabled={props.disabled} class={`btn btn-${props.variant || 'primary'}`} > {props.label} </button> ); });
当需要扩展原生 HTML 元素的属性时,使用 PropsOf 工具类型提取内置属性,再通过交叉类型追加自定义字段:
tsximport { component$, PropsOf } from '@builder.io/qwik'; export const CustomInput = component$<PropsOf<'input'> & { customProp?: string; }>((props) => { return <input {...props} />; });
PropsOf<'input'> 会自动包含 input 元素的所有标准属性(value、placeholder、onChange 等),避免手动维护冗长的类型列表。
QRL 类型与 $ 后缀
QRL 是 Qwik 类型系统中最重要的概念。所有带 $ 后缀的函数(onClick$、useTask$、server$ 等)都是 QRL 包裹的懒加载引用,类型上用 QRL
tsximport { type QRL } from '@builder.io/qwik'; interface ListProps<T> { items: T[]; renderItem$: QRL<(item: T, index: number) => JSXNode>; keyExtractor$: QRL<(item: T) => string>; }
实际开发中,通常不需要手动声明 QRL 类型——component$ 和事件处理器的泛型推导会自动处理。但理解这个机制有助于排查类型报错:如果在一个需要 QRL 的位置传入了普通函数,TypeScript 会提示类型不匹配。
状态管理的类型标注
useSignal
useSignal 用于基本类型的响应式状态,通过泛型参数声明类型:
tsximport { component$, useSignal } from '@builder.io/qwik'; export const Counter = component$(() => { const count = useSignal<number>(0); const name = useSignal<string>(''); const isActive = useSignal<boolean>(false); return ( <div> <p>Count: {count.value}</p> <input value={name.value} onInput$={(e) => name.value = (e.target as HTMLInputElement).value} /> <button onClick$={() => isActive.value = !isActive.value}> Toggle </button> </div> ); });
访问和修改值统一通过 .value 属性,TypeScript 会根据泛型参数严格检查赋值类型。
useStore
useStore 用于对象类型的响应式状态,推荐用 interface 定义完整结构:
tsximport { component$, useStore } from '@builder.io/qwik'; interface User { id: number; name: string; email: string; address: { street: string; city: string; country: string; }; } export const UserProfile = component$(() => { const user = useStore<User>({ id: 1, name: 'John Doe', email: 'john@example.com', address: { street: '123 Main St', city: 'New York', country: 'USA' } }); return ( <div> <h2>{user.name}</h2> <p>{user.email}</p> <p>{user.address.city}, {user.address.country}</p> </div> ); });
useStore 支持深度响应,嵌套对象的属性变更同样会触发更新,类型推导也会深入到嵌套层级。
useTask$ 和 useVisibleTask$
useTask$ 在服务端和客户端都会执行,适合监听信号变化后的副作用;useVisibleTask$ 仅在浏览器端执行,适合 DOM 操作或浏览器 API 调用。
tsximport { component$, useSignal, useTask$, useVisibleTask$ } from '@builder.io/qwik'; export const SearchComponent = component$(() => { const query = useSignal(''); const results = useSignal<string[]>([]); useTask$(({ track }) => { const keyword = track(() => query.value); // 服务端和客户端都会执行 results.value = keyword ? [`${keyword}-result-1`, `${keyword}-result-2`] : []; }); useVisibleTask$(() => { // 仅在浏览器执行,例如读取 localStorage const saved = localStorage.getItem('last-search'); if (saved) query.value = saved; }); return ( <div> <input value={query.value} onInput$={(e) => query.value = (e.target as HTMLInputElement).value} /> <ul>{results.value.map((r) => <li key={r}>{r}</li>)}</ul> </div> ); });
事件处理类型
Qwik 事件处理器的类型签名是 (event: EventType, element: HTMLElement) => void,与 React 的 SyntheticEvent 不同,Qwik 直接使用浏览器原生事件类型:
tsximport { component$ } from '@builder.io/qwik'; export const Form = component$(() => { const handleSubmit$ = (event: Event, element: HTMLFormElement) => { event.preventDefault(); const formData = new FormData(element); console.log(formData); }; const handleInput$ = (event: InputEvent, element: HTMLInputElement) => { console.log(element.value); }; return ( <form onSubmit$={handleSubmit$}> <input type="text" onInput$={handleInput$} /> <button type="submit">Submit</button> </form> ); });
自定义事件可以通过声明 detail 类型来约束:
tsxinterface CustomEvent { detail: { id: string; value: number; }; } export const CustomComponent = component$(() => { const handleCustomEvent$ = (event: CustomEvent) => { console.log(event.detail.id, event.detail.value); }; return <div onCustomEvent$={handleCustomEvent$}>Custom Component</div>; });
路由与数据加载的类型
routeLoader$
routeLoader$ 用于在服务端加载数据,泛型参数声明返回数据的类型:
tsximport { component$, routeLoader$ } from '@builder.io/qwik-city'; interface Product { id: number; name: string; price: number; description: string; } export const useProduct = routeLoader$<Product>(async ({ params }) => { const response = await fetch(`https://api.example.com/products/${params.id}`); return response.json(); }); export default component$(() => { const product = useProduct(); return <div>{product.value.name}</div>; });
路由参数的类型通过 params 对象自动推导,params.id 在文件路由 [id] 布局下会被推断为 string。
action$ 与 Zod 校验
action$ 用于处理表单提交等写操作,配合 zod$ 实现运行时类型校验:
tsximport { action$, zod$, z } from '@builder.io/qwik-city'; interface ActionResult { success: boolean; error?: string; } export const useContactForm = action$( async (data) => { // data 的类型由 zod schema 自动推导 return { success: true }; }, zod$({ name: z.string().min(2), email: z.string().email(), message: z.string().min(10) }) );
zod$ 同时提供了运行时校验和编译时类型推导,data 参数的类型会根据 zod schema 自动生成,无需重复声明 FormData 接口。
Context 跨组件通信的类型
Qwik 使用 createContextId 创建类型安全的上下文,注意不是旧版 API 的 createContext:
tsximport { component$, createContextId, useContextProvider, useContext } from '@builder.io/qwik'; interface ThemeContextValue { theme: 'light' | 'dark'; toggleTheme$: () => void; } const ThemeContext = createContextId<ThemeContextValue>('theme'); export const ThemeProvider = component$(() => { const theme = useSignal<'light' | 'dark'>('light'); const toggleTheme$ = () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; }; useContextProvider(ThemeContext, { theme: theme.value, toggleTheme$ }); return <Child />; }); export const Child = component$(() => { const { theme, toggleTheme$ } = useContext(ThemeContext); return ( <div> <p>Current theme: {theme}</p> <button onClick$={toggleTheme$}>Toggle Theme</button> </div> ); });
createContextId 的泛型参数确保 Provider 写入的值和 Consumer 读取的值类型一致。字符串参数 'theme' 是上下文的唯一标识,需要保证全局不重复。
泛型组件与类型复用
Qwik 支持泛型组件,但受限于 component$ 的泛型推导,实际写法需要用 any 作为中间类型再在调用侧收窄:
tsximport { component$ } from '@builder.io/qwik'; interface ListProps<T> { items: T[]; renderItem$: (item: T, index: number) => any; keyExtractor$: (item: T) => string; } export const List = component$<ListProps<any>>((props) => { return ( <ul> {props.items.map((item, index) => ( <li key={props.keyExtractor$(item)}> {props.renderItem$(item, index)} </li> ))} </ul> ); });
对于跨文件的类型复用,建议集中管理类型定义:
tsx// types.ts export interface User { id: number; name: string; email: string; } export type UserRole = 'admin' | 'user' | 'guest'; export interface ApiResponse<T> { data: T; status: number; message: string; }
tsx// component.tsx import { component$, useSignal } from '@builder.io/qwik'; import type { User, UserRole, ApiResponse } from './types'; export const UserComponent = component$(() => { const user = useSignal<User | null>(null); const role = useSignal<UserRole>('user'); return <div>{user.value?.name}</div>; });
使用 type 关键字导入类型(import type)可以避免将类型代码打包到客户端产物中,这是 Qwik 优化加载性能的重要手段。
server$ 的类型约束
server$ 函数用于将逻辑限定在服务端执行,其泛型参数约束函数签名:
tsximport { server$ } from '@builder.io/qwik-city'; const saveData = server$(async (data: { name: string; email: string }): Promise<{ success: boolean }> => { // 仅在服务端执行,可安全访问数据库等 return { success: true }; });
server$ 返回的函数类型与传入的函数类型一致,调用侧无需感知服务端/客户端边界,TypeScript 会自动推导正确的返回类型。
类型断言与类型守卫
在事件处理等场景中,类型断言不可避免,但应尽量缩小断言范围:
tsxconst input = element.querySelector('input') as HTMLInputElement; console.log(input.value);
更安全的做法是用类型守卫替代断言:
tsxfunction isString(value: unknown): value is string { return typeof value === 'string'; } export const Component = component$(() => { const data = useSignal<unknown>(null); const processData$ = () => { if (isString(data.value)) { console.log(data.value.toUpperCase()); } }; return <button onClick$={processData$}>Process</button>; });
类型组织的实践建议
interface 和 type 各有适用场景:interface 适合定义对象结构,支持声明合并;type 适合联合类型、交叉类型和工具类型的组合。
tsx// 对象结构用 interface interface ComplexProps { user: { id: number; profile: { name: string; avatar: string; }; }; } // 联合类型和交叉类型用 type type ButtonVariant = 'primary' | 'secondary' | 'danger'; type ButtonProps = BaseProps & { variant: ButtonVariant };
泛型工具函数可以大幅减少重复类型声明:
tsxexport const useApi = <T>(url: string) => { return useResource$<T>(() => fetch(url).then(r => r.json())); };
类型守卫在运行时校验与编译时类型之间建立桥梁,对于服务端返回的未校验数据尤其重要:
tsxfunction isValidUser(user: unknown): user is User { return typeof user === 'object' && user !== null && 'id' in user; }
Qwik 的 TypeScript 集成不只是"能用",而是围绕 QRL 可恢复性架构重新设计了类型系统。掌握 QRL