Qwik 组件系统的 $ 语法和可恢复性是如何工作的?
Qwik 组件系统的核心设计目标是可恢复性(Resumability)——框架在服务端渲染时将组件的状态和执行上下文序列化到 HTML 中,客户端无需重新执行组件代码即可恢复交互。这和传统 SSR 框架(如 Next.js)的 Hydration 方案有本质区别:Hydration 需要在客户端重新下载和执行组件代码来"重新激活"页面,而 Qwik 只在用户实际交互时才懒加载对应的代码。
这个设计目标催生了 Qwik 组件系统中最显眼的特征:$ 语法。
$ 语法:可恢复性边界
$ 后缀不是语法糖,而是 Qwik 优化器(Optimizer)的编译指令。它标记了一个惰性边界——优化器会将 $ 标记的函数提取为独立的 chunk,按需加载:
tsximport { component$ } from '@builder.io/qwik'; export const Counter = component$(() => { // component$ 本身就是惰性边界,组件代码会被分割为独立 chunk const count = useSignal(0); return ( <button onClick$={() => count.value++}> {/* onClick$ 也是一个惰性边界,事件处理函数独立分割 */} Count: {count.value} </button> ); });
编译后,上面这个组件至少产生三个 chunk:组件自身、事件处理函数、useSignal 的响应式逻辑。用户首次访问页面时,这些 chunk 都不会加载——只有点击按钮时,才会加载事件处理函数的 chunk。
理解了 $ 的本质,再看组件系统的其他部分就顺理成章了。
组件定义与编译产物
Qwik 组件必须使用 component$ 包裹:
tsximport { component$ } from '@builder.io/qwik'; export const Greeting = component$((props: { name: string }) => { return <div>Hello, {props.name}</div>; });
编译器会在组件的 DOM 节点周围插入 <!--qv--> 注释标记,并通过 q:id(组件实例唯一标识)、q:key(列表渲染 key)、q:sref(响应式数据订阅引用)等属性在扁平的 HTML 中重建组件树结构。这意味着 Qwik 不需要虚拟 DOM——仅凭 HTML 标记就能识别组件层级和更新范围。
状态管理:useSignal 与 useStore
useSignal:简单值
tsximport { useSignal } from '@builder.io/qwik'; export const Counter = component$(() => { const count = useSignal(0); return ( <button onClick$={() => count.value++}> Count: {count.value} </button> ); });
useSignal 返回一个 Signal 对象,通过 .value 读写。修改 .value 会精确触发依赖该 Signal 的 DOM 节点更新,而不是重新渲染整个组件。
useStore:复杂对象
tsximport { useStore } from '@builder.io/qwik'; export const Form = component$(() => { const form = useStore({ name: '', email: '' }); return ( <form> <input value={form.name} onInput$={(e) => form.name = (e.target as HTMLInputElement).value} /> <input value={form.email} onInput$={(e) => form.email = (e.target as HTMLInputElement).value} /> </form> ); });
useStore 对对象的属性进行深度响应式代理,修改任意属性只会更新引用该属性的 DOM 节点。
事件处理
所有事件处理函数必须使用 $ 后缀,否则编译器会报错:
tsxexport const Button = component$(() => { return ( <button onClick$={() => console.log('clicked')}> Click me </button> ); });
onClick$ 而非 onClick,这是 Qwik 最容易让 React 开发者踩坑的地方。如果试图传递一个普通函数给事件属性,Qwik 优化器会直接报错,因为普通函数无法被序列化和懒加载。
生命周期钩子
Qwik 提供三个核心生命周期钩子,都使用 $ 后缀:
- useTask$:在组件挂载和响应式依赖变化时执行,类似于 React 的 useEffect + useMemo 的结合。可以追踪 Signal 变化并执行副作用:
tsximport { component$, useSignal, useTask$ } from '@builder.io/qwik'; export const SearchComponent = component$(() => { const query = useSignal(''); const results = useSignal<string[]>([]); useTask$(({ track }) => { const keyword = track(() => query.value); // 当 query 变化时重新搜索 results.value = fetchResults(keyword); }); return ( <div> <input onInput$={(e) => query.value = (e.target as HTMLInputElement).value} /> <ul>{results.value.map(r => <li key={r}>{r}</li>)}</ul> </div> ); });
-
useVisibleTask$:只在组件进入视口时执行,用于客户端特定逻辑(如操作 DOM、绑定第三方库),不会在 SSR 阶段运行。
-
useResource$:用于异步数据获取,返回一个 Resource 对象,可以通过
<Resource>组件自动处理 loading/error/resolved 三种状态:
tsximport { component$, useResource$, Resource } from '@builder.io/qwik'; export const UserProfile = component$((props: { id: string }) => { const userResource = useResource$(async () => { const res = await fetch(`/api/users/${props.id}`); return res.json(); }); return ( <Resource value={userResource} onPending={() => <p>Loading...</p>} onRejected={() => <p>Failed to load</p>} onResolved={(user) => <div>{user.name}</div>} /> ); });
组件通信
Props 传递
tsxexport const Parent = component$(() => { return <Child message="Hello from parent" count={42} />; }); export const Child = component$((props: { message: string; count: number }) => { return <div>{props.message}: {props.count}</div>; });
Props 通过编译时类型检查,并在序列化时自动处理。注意 Props 必须可序列化——函数、Symbol 等类型不能作为 Props 传递,这和 React 的"props 可以传任何值"有本质区别。
Context 跨层级通信
tsximport { createContext, useContextProvider, useContext } from '@builder.io/qwik'; const ThemeContext = createContext<string>('light'); export const ThemeProvider = component$(() => { useContextProvider(ThemeContext, 'dark'); return <Child />; }); export const Child = component$(() => { const theme = useContext(ThemeContext); return <div>Current theme: {theme}</div>; });
Context 值同样需要可序列化。
Slot 内容投影
Qwik 使用 <Slot/> 实现内容投影,替代 React 的 children prop:
tsximport { component$, Slot } from '@builder.io/qwik'; export const Card = component$(() => { return ( <div class="card"> <div class="card-header"><Slot name="header" /></div> <div class="card-body"><Slot /></div> <div class="card-footer"><Slot name="footer" /></div> </div> ); }); // 使用时: export const App = component$(() => { return ( <Card> <div q:slot="header">Title</div> <p>Body content</p> <div q:slot="footer">Footer</div> </Card> ); });
具名 Slot 通过 name 属性声明,使用方通过 q:slot 属性指定投影目标。未命名的 Slot 接收默认内容。
样式方案
Qwik 支持多种样式方式:
- CSS Modules(推荐):创建
.module.css文件,通过import styles from './xxx.module.css'引用,类名自动 hash 化避免冲突。 - Tailwind CSS:开箱即用,Qwik 官方脚手架内置支持。
- useStylesScoped$:在组件内联作用域样式:
tsximport { component$, useStylesScoped$ } from '@builder.io/qwik'; export const StyledButton = component$(() => { useStylesScoped$(` .btn { background: #0070f3; color: white; padding: 8px 16px; border-radius: 4px; } `); return <button class="btn">Click</button>; });
- 全局 CSS:通过普通 CSS 文件导入。
编译器自动优化
Qwik 编译器接管了大量手动优化工作,开发者不需要 React.memo、useCallback、useMemo:
- 每个组件自动分割为独立 chunk,按渲染需求懒加载
- 事件处理函数自动分割,只在交互时加载
- 响应式系统只更新变化的 DOM 节点,而非重新渲染组件树
- 不可序列化的值在编译期就会被检测并报错,避免运行时问题
这正是 Qwik 的核心价值主张:开发者写组件的体验接近 React,但运行时性能由编译器保证,而非依赖手动优化。