前端阅读 05月27日 17:33
Qwik 项目里 TypeScript 怎么写?核心类型与实战用法
Qwik 从设计之初就深度整合了 TypeScript,项目脚手架默认生成 .ts/.tsx 文件,组件、状态、事件、路由等核心 API 均提供完整的类型推导。理解 Qwik 的类型系统,关键在于把握 QRL(可恢复引用)这一独特概念——它决定了 Qwik 中函数类型的书写方式与普通 React/Vue 项目有本质区别。组件 Props 类型Qwik 组件通过 component$ 泛型参数声明 Props 类型。$ 后缀是 Qwik 的核心约定,表示该函数是一个 QRL(Resumable Lazy-Load Reference),框架会在需要时才加载和执行它。import { 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 工具类型提取内置属性,再通过交叉类型追加自定义字段:import { component$, PropsOf } from '@builder.io/qwik';export const CustomInput = component$<PropsOf<'input'> & { customProp?: string;}>((props) => { return <input {...props} />;});PropsOf 会自动包含 input 元素的所有标准属性(value、placeholder、onChange 等),避免手动维护冗长的类型列表。QRL 类型与 $ 后缀QRL 是 Qwik 类型系统中最重要的概念。所有带 $ 后缀的函数(onClick$、useTask$、server$ 等)都是 QRL 包裹的懒加载引用,类型上用 QRL 表示。import { 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 会提示类型不匹配。状态管理的类型标注useSignaluseSignal 用于基本类型的响应式状态,通过泛型参数声明类型:import { 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 会根据泛型参数严格检查赋值类型。useStoreuseStore 用于对象类型的响应式状态,推荐用 interface 定义完整结构:import { 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 调用。import { 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 直接使用浏览器原生事件类型:import { 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 类型来约束:interface 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$ 用于在服务端加载数据,泛型参数声明返回数据的类型:import { 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$ 实现运行时类型校验:import { 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:import { 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 作为中间类型再在调用侧收窄:import { 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> );});对于跨文件的类型复用,建议集中管理类型定义:// types.tsexport interface User { id: number; name: string; email: string;}export type UserRole = 'admin' | 'user' | 'guest';export interface ApiResponse<T> { data: T; status: number; message: string;}// component.tsximport { 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$ 函数用于将逻辑限定在服务端执行,其泛型参数约束函数签名:import { server$ } from '@builder.io/qwik-city';const saveData = server$(async (data: { name: string; email: string }): Promise<{ success: boolean }> => { // 仅在服务端执行,可安全访问数据库等 return { success: true };});server$ 返回的函数类型与传入的函数类型一致,调用侧无需感知服务端/客户端边界,TypeScript 会自动推导正确的返回类型。类型断言与类型守卫在事件处理等场景中,类型断言不可避免,但应尽量缩小断言范围:const input = element.querySelector('input') as HTMLInputElement;console.log(input.value);更安全的做法是用类型守卫替代断言:function 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 适合联合类型、交叉类型和工具类型的组合。// 对象结构用 interfaceinterface ComplexProps { user: { id: number; profile: { name: string; avatar: string; }; };}// 联合类型和交叉类型用 typetype ButtonVariant = 'primary' | 'secondary' | 'danger';type ButtonProps = BaseProps & { variant: ButtonVariant };泛型工具函数可以大幅减少重复类型声明:export const useApi = <T>(url: string) => { return useResource$<T>(() => fetch(url).then(r => r.json()));};类型守卫在运行时校验与编译时类型之间建立桥梁,对于服务端返回的未校验数据尤其重要:function isValidUser(user: unknown): user is User { return typeof user === 'object' && user !== null && 'id' in user;}Qwik 的 TypeScript 集成不只是"能用",而是围绕 QRL 可恢复性架构重新设计了类型系统。掌握 QRL 类型、$ 后缀的语义、以及 import type 的按需加载,才能在 Qwik 项目中写出既类型安全又不拖累加载性能的代码。遇到类型报错时,先检查是否遗漏了 $ 后缀或将 QRL 位置传入了普通函数——这是从其他框架迁移到 Qwik 时最常见的类型陷阱。