Qwik 中的 $ 符号到底在做什么?
写过 React 的人第一次看到 Qwik 代码,大概率会愣住——为什么到处都是 $?component$、onClick$、useTask$、server$……这个符号不是装饰,而是 Qwik 整个架构的支点。它决定了你的代码在哪里被切割、何时被加载、怎样被恢复。
$ 的本质:懒加载边界标记
$ 后缀是一个编译器指令,告诉 Qwik Optimizer:"这个函数是一个代码分割的边界,请把它提取成独立的 chunk。"
tsx// 你写的代码 export const Counter = component$(() => { const count = useSignal(0); const increment$ = () => { count.value++; }; return <button onClick$={increment$}>{count.value}</button>; });
Optimizer 在编译时会把 component$ 的回调、increment$ 函数、onClick$ 的引用分别提取成独立文件。最终产出的 HTML 里,这些函数不再是 JavaScript 代码,而是序列化后的 QRL(Qwik Resource Locator)引用:
html<button on:click="./counterchunk.js#increment" data-qwik-state="..."> 0 </button>
用户点击按钮时,Qwik Loader 才根据 QRL 去加载对应的 chunk 并执行。这就是为什么 Qwik 首屏只需要约 1KB 的 JavaScript——其余代码全部在 $ 标记的边界处被切走,按需加载。
Resumability:不需要水合的恢复机制
理解 $ 就必须理解 Qwik 的核心设计理念——可恢复性(resumability)。
传统 SSR 框架(Next.js、Nuxt)的工作流程是:服务端渲染 HTML → 客户端下载 JavaScript → 执行水合(hydration) → 页面可交互。水合要重建三样东西:事件监听器、组件树、应用状态。这意味着客户端必须重新执行一遍组件逻辑,开销随应用复杂度线性增长。
Qwik 的做法完全不同:服务端渲染时,把事件监听器的引用、组件状态、闭包捕获的变量全部序列化到 HTML 中。客户端拿到 HTML 后,不需要重新执行任何组件代码,直接从序列化数据中恢复状态。$ 标记的函数就是序列化的单位——每个 $ 函数的引用被编码成 QRL,闭包中引用的外部变量被序列化到 data-qwik-state 属性中。
结果是:Qwik 应用的启动时间是 O(1) 的,与代码总量无关。一个 1MB JavaScript 的应用和一个 10KB 的应用,首屏加载速度几乎没有差异。
QRL:$ 背后的序列化协议
QRL(Qwik Resource Locator)是 $ 函数的运行时表示。一个 QRL 包含三个关键信息:
- Chunk 路径:函数所在的 JS 文件路径,如
./chunks/counter-abc.js - 符号名:从 chunk 中导出的函数名,如
increment - 捕获的词法作用域:闭包中引用的外部变量引用
当 Optimizer 检测到 $(...) 调用时,它会进行如下转换:
tsx// 编译前 useOnDocument("mousemove", $((event) => console.log(event))); // 编译后 useOnDocument("mousemove", qrl("./chunk-abc.js", "onMousemove"));
运行时,qwikloader(约 1KB 的引导脚本)监听所有 DOM 事件。当用户触发 click,qwikloader 解析 QRL、动态加载 chunk、恢复闭包上下文、执行函数。整个过程对开发者透明——你只管写 onClick$,Optimizer 和 qwikloader 负责剩下的事。
闭包序列化是 QRL 最精妙的部分。传统框架无法序列化闭包,因为 JavaScript 闭包绑定的是运行时作用域。Qwik 的 Optimizer 在编译时分析闭包引用了哪些变量,将这些变量的引用编码进 QRL 的 capture 字段,运行时再通过 inflateQrl 恢复。这允许你写出自然的闭包代码,同时享受按需加载。
$ 在具体 API 中的应用
component$:组件的懒加载入口
tsximport { component$, useSignal } from '@builder.io/qwik'; export const SearchBox = component$(() => { const query = useSignal(''); return <input onInput$={(e) => query.value = e.target.value} />; });
component$ 标记的回调会被提取为独立 chunk。Qwik 只在组件需要渲染时才加载它,而不是在页面加载时就把所有组件代码打包进主 bundle。对比 React:React 组件无论是否可见,其代码都会包含在初始 bundle 中。
事件处理器中的 $
Qwik JSX 中的事件属性全部带 $ 后缀:onClick$、onInput$、onKeyUp$等。这和 React 的onClick` 有本质区别:
tsx// React:onClick 回调在 hydration 时注册 <button onClick={() => setCount(c => c + 1)}>+</button> // Qwik:onClick$ 回调被序列化,用户点击时才加载和执行 <button onClick$={() => count.value++}>+</button>
React 的事件处理器在 hydration 阶段就必须可用,因此包含它的 JS 必须在页面可交互前下载。Qwik 的事件处理器只在用户第一次点击时加载,加载后会被缓存,后续点击零延迟。
useTask$:服务端与客户端共享的生命周期
tsxexport const Profile = component$(() => { const userId = useSignal(''); const data = useSignal(null); useTask$(({ track }) => { track(() => userId.value); // 同构执行:SSR 时在服务端运行,CSR 时在客户端运行 // 不会重复执行:SSR 执行过的任务,客户端不会重新运行 fetch(`/api/user/${userId.value}`) .then(res => res.json()) .then(json => data.value = json); }); return <div>{data.value?.name}</div>; });
useTask$ 的回调是同构的(isomorphic),在 SSR 和 CSR 环境都会执行。但 Qwik 的 resumability 机制保证:如果某个 useTask$ 在服务端已经执行过,客户端不会重复执行——它直接从序列化状态中恢复结果。这避免了传统 SSR 框架中"服务端跑一遍,客户端再跑一遍"的浪费。
useVisibleTask$:纯客户端的生命周期
tsxexport const Chart = component$(() => { const canvasRef = useSignal<Element>(); useVisibleTask$(() => { // 只在浏览器中执行,可以安全访问 DOM API const ctx = canvasRef.value?.getContext('2d'); drawChart(ctx); }); return <canvas ref={canvasRef} />; });
useVisibleTask$ 类似 React 的 useEffect,只在组件可见时于客户端执行。适合操作 DOM、订阅浏览器事件、初始化第三方库等纯浏览器逻辑。和 useTask$ 的关键区别是:useVisibleTask$ 在 SSR 期间完全不执行。
server$:RPC 式的服务端函数
tsximport { server$ } from '@builder.io/qwik-city'; // 定义服务端函数 const saveToDB = server$(async (data: FormData) => { // 这段代码永远不会出现在客户端 bundle 中 await db.insert(data); return { success: true }; }); export const Form = component$(() => { const handleSubmit$ = () => { saveToDB({ name: 'test' }); // 客户端调用,实际在服务端执行 }; return <button onClick$={handleSubmit$}>Submit</button>; });
server$ 是一种 RPC 机制:你在客户端代码中直接调用,函数却在服务端执行。客户端 bundle 不包含 server$ 内部的任何代码。通过 this 可以访问 RequestEvent,读取 cookie、环境变量等:
tsxconst getUser = server$(async function () { const token = this.cookie.get('auth-token')?.value; if (!token) return null; return verifyToken(token); });
与 Next.js 的 Server Actions 相比,server$ 更轻量——不需要额外的路由文件或 API 约定,直接在组件旁定义即可。
与 React / Next.js 的架构对比
| 维度 | React / Next.js | Qwik |
|---|---|---|
| 首屏 JS | 组件代码全部在 bundle 中 | 按需加载,约 1KB 引导脚本 |
| 水合方式 | 全量水合:重建监听器、组件树、状态 | 零水合:从序列化状态恢复 |
| 事件处理器 | hydration 前必须下载 | 点击时才加载对应 chunk |
| 代码分割粒度 | 路由级别(React.lazy / dynamic import) | 函数级别(每个 $ 函数独立 chunk) |
| 服务端函数 | Server Actions(需约定路由) | server$(RPC,直接定义) |
| 闭包处理 | 运行时绑定,无法序列化 | 编译时分析,序列化到 HTML |
| 启动时间 | O(n),与组件数正相关 | O(1),与代码总量无关 |
实际性能差距:一个中等复杂度的页面,Next.js 的 Time to Interactive 约 350ms,Qwik 约 90ms。这 260ms 的差距主要来自水合开销——Next.js 需要下载并执行 180KB+ 的 JavaScript 来水合页面,Qwik 只需要 1KB 的 qwikloader 加上按需加载的 chunk。
但 Qwik 并非万能。对于高度交互的单页应用(实时编辑器、复杂图表),Qwik 的按需加载反而可能引入交互延迟——首次操作需要额外加载 chunk。React 的预加载策略在这种场景下更合适。
常见陷阱
内联函数与 $ 的关系:在 JSX 中可以直接写 onClick$={() => ...},内联箭头函数本身不需要加 $。$ 加在事件属性名上,而不是回调函数上。但如果把事件处理器提取为变量,变量名需要加 $:
tsx// 直接内联:$ 在属性名上 <button onClick$={() => count.value++}>+</button> // 提取变量:变量名也加 $ const increment$ = () => count.value++; <button onClick$={increment$}>+</button>
不要在 $ 函数外部访问 DOM:component$ 回调在 SSR 时执行,此时没有 DOM。DOM 操作必须放在 useVisibleTask$ 中。
闭包捕获有限制:$ 函数可以捕获外部变量,但这些变量必须是可序列化的。函数、DOM 节点、类实例等不能被 $ 函数闭包捕获。
从 $ 看框架设计哲学
$ 符号揭示了一个根本性的取舍:Qwik 选择把"何时加载代码"的控制权交给编译器,开发者只需用 $ 声明边界。这和 React 的哲学相反——React 假设所有代码都会在客户端执行,开发者需要手动用 React.lazy 和 dynamic import 来分割代码。
$ 不是语法糖,不是命名约定,而是一种对代码执行模型的重新定义。它让"惰性"成为默认行为,"立即加载"成为需要特别处理的例外。这种反转恰好解决了现代 Web 应用最痛的问题:首屏加载过慢。当你看到 component$、onClick$、server$ 时,读到的不是 API 命名,而是一个个精确的懒加载边界——它们共同构成了一张按需加载的网络,让浏览器只在真正需要时才执行代码。