服务端5月28日 04:02
React 迁移 Qwik 完全指南:渐进式策略与实战踩坑React 和 Qwik 表面相似——都用 JSX、都有组件、都支持 TypeScript。但打开 DevTools 就会看到本质差异:同一个中等页面,React SSR 首次交互需要加载 40-100KB 的 JavaScript,Qwik 只需 1-2KB。差距来自一个架构选择:React 用 hydration 重建页面,Qwik 用 resumability 接着跑。
这篇文章把 React 迁移 Qwik 拆成五个阶段,每个阶段对照核心 API、解释设计差异、指出踩坑点。读完你能拿到一条可执行的迁移路线,而不是一堆代码片段。
## 为什么 React 的 Hydration 是性能瓶颈
传统 SSR 框架的工作流:服务端渲染 HTML → 浏览器下载 JS → 重新执行所有组件代码 → 重建组件树 → 绑定事件 → 页面可交互。这个过程叫 hydration,用户看到内容但点不动的那段"假活"时间,就是 hydration 在干活。
问题在于 hydration 是全量的——即使页面只有一个按钮需要交互,也要把整棵组件树跑一遍。React 18 的 Selective Hydration 和 Suspense 做了优化,但本质没变:仍然要先下载并执行大量 JS,再逐步让页面活起来。
Qwik 的 resumability 方案绕过了重建。服务端渲染时,Qwik 把三样东西序列化进 HTML:组件边界、事件监听位置、应用状态。浏览器拿到 HTML 后不执行任何组件代码。用户点击按钮的瞬间,Qwik 才去加载那个按钮的点击处理函数——通常只有几百字节。
大众点评 M 站 2026 年基于 Qwik 重构后,Core Web Vitals 各项指标显著改善,TTI 从秒级降到百毫秒级。这个案例说明了 resumability 在内容密集型场景的实际价值。
但 Qwik 不是万能药。如果你的应用是重交互 SPA(数据仪表盘、实时协作工具、复杂表单系统),React 的生态和工具链依然更成熟。迁移决策应该基于具体场景,而不是框架热度。
## 理解 Qwik $ 符号:懒加载的核心机制
写迁移代码之前,必须理解 Qwik 最特殊的语法:`$` 后缀。它不是语法糖,而是 Qwik Optimizer 的指令标记。
```tsx
// React - 普通函数
const MyComponent = ({ name }) => <div>Hello {name}</div>;
// Qwik - $ 标记懒加载边界
const MyComponent = component$(({ name }) => <div>Hello {name}</div>);
```
每次出现 `$`,Optimizer 在构建时就把后面的函数提取成独立的懒加载模块。`component$` 里的渲染逻辑不会在首屏加载,`onClick$` 的处理函数不会在按钮出现时加载——只有用户真正点击时才下载执行。
**`$` 常见用法速查**:
| React 写法 | Qwik 写法 | 懒加载粒度 |
|-----------|---------|-----------|
| `function Comp()` | `component$(() => ...)` | 整个组件渲染逻辑 |
| `onClick={fn}` | `onClick$={fn}` | 单个事件处理函数 |
| `useEffect(cb)` | `useTask$(cb)` | 副作用逻辑 |
| `useLayoutEffect(cb)` | `useVisibleTask$(cb)` | 客户端 DOM 操作 |
| `useMemo(fn)` | `useComputed$(fn)` | 计算缓存 |
| `useCallback(fn)` | 不需要 | 自动优化,无需记忆化 |
理解 `$` 后面的代码对照就不会困惑了。
## 迁移阶段一:项目搭建与路由配置
新建 Qwik 项目比在 React 项目里混入 Qwik 更省事。Qwik 的 Optimizer 需要从入口就介入,中途嫁接反而更复杂。
```bash
npm create qwik@latest
```
项目结构:
```
src/
routes/ # Qwik City 文件系统路由
index.tsx # 首页
about/
index.tsx # /about 页面
users/
[id]/
index.tsx # /users/:id 动态路由
layout.tsx # 全局布局
components/ # 组件目录
root.tsx # 应用入口
```
**路由对照**:React Router 的声明式路由 `<Route path="/users/:id" />` 对应 `routes/users/[id]/index.tsx`。不需要手写路由配置,文件路径即路由。
**布局对照**:`layout.tsx` 里的 `<Slot />` 等价于 React 的 `<Outlet />`,自动包裹子路由:
```tsx
// src/routes/layout.tsx
import { Slot } from '@builder.io/qwik';
export default component$(() => {
return (
<div class="app-shell">
<nav>导航栏</nav>
<main><Slot /></main>
</div>
);
});
```
**构建配置**:Qwik 基于 Vite,开箱支持 TypeScript、CSS Modules、Tailwind。ESLint 需要 `eslint-plugin-qwik`,它会检查 `$` 使用是否合规——比如 `component$` 内部不能引用闭包中的非响应式变量。
## 迁移阶段二:组件与样式迁移
从纯展示组件开始。改动最小,主要是两处替换。
**1. 用 `component$` 包裹组件**
```tsx
// React
export const Header = ({ title }: { title: string }) => {
return <header><h1>{title}</h1></header>;
};
// Qwik
export const Header = component$(({ title }: { title: string }) => {
return <header><h1>{title}</h1></header>;
});
```
**2. `className` 改为 `class`**
Qwik 遵循标准 HTML 属性名,用 `class` 不用 `className`:
```tsx
// React: <div className="container">
// Qwik: <div class="container">
```
CSS Modules 的导入方式完全一致,只是模板里用 `class` 替代 `className`:
```tsx
import styles from './Header.module.css';
// React: className={styles.header}
// Qwik: class={styles.header}
```
内联样式也有差异。React 用驼峰对象,Qwik 用短横线对象或字符串:
```tsx
// React
<div style={{ backgroundColor: 'red', fontSize: '16px' }}>
// Qwik - 方式一:短横线对象
<div style={{ 'background-color': 'red', 'font-size': '16px' }}>
// Qwik - 方式二:字符串(更推荐)
<div style="background-color: red; font-size: 16px">
```
建议先迁移所有纯展示组件,确认渲染正常再往下走。这一步风险极低,属于热身。
## 迁移阶段三:状态与响应式迁移
这是核心难点。React 是 immutable 更新(必须调 setter 触发重渲染),Qwik 是 mutable 更新(直接改属性,自动追踪)。思维不转换,代码就写不对。
### useState 对应 useSignal 和 useStore
```tsx
// React - 简单值
const [count, setCount] = useState(0);
setCount(prev => prev + 1);
// Qwik - useSignal
const count = useSignal(0);
count.value++; // 直接修改,自动触发更新
// React - 对象
const [user, setUser] = useState({ name: 'Tom', age: 25 });
setUser(prev => ({ ...prev, name: 'Jerry' }));
// Qwik - useStore
const user = useStore({ name: 'Tom', age: 25 });
user.name = 'Jerry'; // 直接改属性,自动追踪
```
`useStore` 默认深度追踪嵌套对象的变化。如果只需要浅层追踪,传 `{ deep: false }` 减少性能开销。
### useContext 对照
```tsx
// React
const ThemeContext = createContext('light');
// Qwik
import { createContext, useContext } from '@builder.io/qwik';
const ThemeContext = createContext('light');
const theme = useContext(ThemeContext);
```
API 几乎一致。关键差异:Qwik 的 Context 在服务端和客户端之间自动序列化,不需要 Provider 组件层层包裹。
### 闭包陷阱:component$ 内的变量作用域
这是 React 开发者踩坑最多的地方。`$` 函数会被 Optimizer 提取到独立文件,所以不能引用外层的普通变量:
```tsx
// 错误!name 会被提取到别的文件,运行时不可访问
component$(({ name }) => {
const handleClick$ = () => console.log(name); // ESLint 报错
});
// 正确:用 useSignal 持有响应式数据
component$(() => {
const name = useSignal('Tom');
const handleClick$ = () => console.log(name.value); // OK
});
```
好消息是 `eslint-plugin-qwik` 会在编译期捕获这类错误,不会遗漏到运行时。
## 迁移阶段四:副作用与异步数据获取
### useEffect 拆分为 useTask$ 和 useVisibleTask$
React 的 `useEffect` 混合了两种语义:响应数据变化和操作浏览器 DOM。Qwik 把它们拆开了。
**`useTask$`**:响应响应式数据变化时执行。用 `track()` 显式声明追踪目标,替代 React 的依赖数组:
```tsx
// React
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
// Qwik
useTask$(({ track }) => {
const currentCount = track(() => count.value);
document.title = `Count: ${currentCount}`;
});
```
`track()` 比 React 依赖数组更安全——不会遗漏依赖导致 stale closure,也不会写多余依赖导致过度执行。
**`useVisibleTask$`**:组件在浏览器可见时执行一次,等价于 `useLayoutEffect`,用于必须操作 DOM 或浏览器 API 的场景:
```tsx
useVisibleTask$(() => {
const observer = new IntersectionObserver(/* ... */);
return () => observer.disconnect(); // cleanup
});
```
### 异步数据获取:useEffect + fetch 改为 routeLoader$
React 中最常见的 useEffect + fetch 模式,在 Qwik 里用 `routeLoader$` 替代,天然支持 SSR:
```tsx
// React
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(data => { setUsers(data); setLoading(false); });
}, []);
// Qwik - 在 route 文件中定义 loader
export const useUserList = routeLoader$(async () => {
const res = await fetch('https://api.example.com/users');
return res.json();
});
// 在组件中使用
export default component$(() => {
const users = useUserList();
// users.value 就是数据,没有 loading 状态
return (
<ul>
{users.value.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
});
```
`routeLoader$` 在服务端预执行,数据直接序列化到 HTML。客户端不需要重复请求,也不需要 loading 状态管理。这比 React 的 useEffect + loading 方案简洁得多。
### 表单处理:action$ + Form 渐进增强
React 的表单处理靠 `onSubmit` + `preventDefault`,Qwik City 提供了 `action$` + `Form` 组合,天然支持渐进增强——即使 JavaScript 没加载,表单也能正常提交:
```tsx
import { action$, Form } from '@builder.io/qwik-city';
export const useContactAction = action$(async (data) => {
const name = data.get('name') as string;
await submitForm({ name });
return { success: true };
});
export default component$(() => {
const action = useContactAction();
return (
<Form action={action}>
<input name="name" required />
<button type="submit">提交</button>
{action.value?.success && <p>提交成功</p>}
</Form>
);
});
```
## 迁移阶段五:第三方库兼容与复杂组件
### 用 qwikify$ 过渡包装 React 组件
如果项目依赖的 React 组件库没有 Qwik 替代品,可以用 `qwikify$` 临时包装:
```tsx
/** @jsxImportSource react */
import { qwikify$ } from '@builder.io/qwik-react';
import ReactDatePicker from 'react-datepicker';
export const DatePicker = qwikify$(ReactDatePicker, {
eagerness: 'hover', // hover 时才加载 React 运行时
});
```
注意:使用 `qwikify$` 会加载 React 运行时(约 40KB+),Qwik 的包体积优势消失。这只适合过渡期,长期应该找 Qwik 原生替代品。
### 用 useVisibleTask$ 包装纯 JS 库
不需要 React 的第三方库(如图表库、工具库),用 `useVisibleTask$` 在客户端初始化:
```tsx
component$(() => {
const chartRef = useSignal<HTMLCanvasElement>();
useVisibleTask$(async () => {
const { Chart } = await import('chart.js');
const chart = new Chart(chartRef.value!, config);
return () => chart.destroy();
});
return <canvas ref={chartRef} />;
});
```
`await import()` 确保图表库只在客户端按需加载,不影响 SSR。
### 列表渲染的 key 位置
Qwik 的 `key` 加在组件上而非 HTML 元素上:
```tsx
// React
{items.map(item => <div key={item.id}>{item.name}</div>)}
// Qwik - 如果渲染的是组件
{items.map(item => (
<Item key={item.id} data={item} />
))}
```
## React 性能优化在 Qwik 中的对应
迁移完组件后,你会发现 React 里很多手动性能优化在 Qwik 里不再需要:
| React 优化 | Qwik 对应 | 还需要手动做吗 |
|-----------|---------|-------------|
| `React.memo` | 不需要 | 否,组件自动按需加载渲染 |
| `useCallback` | 不需要 | 否,`$` 函数天然懒加载 |
| `useMemo` | `useComputed$` | 是,计算密集场景仍需缓存 |
| `React.lazy` + `Suspense` | 不需要 | 否,所有 `component$` 自动代码分割 |
| 手动 `import()` 代码分割 | 不需要 | 否,Optimizer 自动处理 |
| `useEffect` cleanup | `useVisibleTask$` cleanup | 是,需手动 return 清理函数 |
Qwik 把 React 里最繁琐的性能优化变成了默认行为。但 `useComputed$` 仍然值得在计算密集场景使用——它和 React `useMemo` 的作用一样,避免重复计算。
## 迁移风险与踩坑总结
**坑一:component$ 内引用闭包变量**
```tsx
// 报错 - 外部常量在提取后的文件里不可访问
const API_URL = 'https://api.example.com';
component$(() => {
const handler$ = () => fetch(API_URL); // ESLint 报错
});
// 解决方案一:直接写字面量
component$(() => {
const handler$ = () => fetch('https://api.example.com');
});
// 解决方案二:通过 useContext 传递配置
```
**坑二:服务端代码混入浏览器 API**
`routeLoader$` 和 `action$` 在服务端执行,里面出现 `window`、`document`、`localStorage` 会直接报错。需要浏览器 API 的逻辑必须放到 `useVisibleTask$` 里。
**坑三:useStore 的响应性边界**
`useStore` 追踪的是对象属性的变化。如果你替换了整个对象引用,Qwik 不会检测到变更:
```tsx
// 错误 - 替换整个对象,变更丢失
const store = useStore({ items: [] });
store = { items: newData }; // 不生效
// 正确 - 修改属性
store.items = newData; // OK
```
**坑四:qwikify$ 组件的交互限制**
通过 `qwikify$` 包装的 React 组件默认不响应 Qwik 的状态变化。需要通过 `props` 显式传入数据,并设置 `eagerness` 控制何时加载 React 运行时。
## 推荐的迁移节奏
一次迁移整个项目风险太高。四周节奏参考:
- **第一周**:搭建 Qwik 项目骨架,迁移纯展示组件,跑通路由和布局
- **第二周**:迁移有状态组件,useState 改 useSignal/useStore,处理闭包问题
- **第三周**:迁移数据获取和表单,useEffect+fetch 改 routeLoader$,表单改 action$
- **第四周**:处理第三方库兼容,评估哪些需要 Qwik 替代品,清理 qwikify$ 过渡代码
迁移完成后跑一遍 Lighthouse,对比 React 版和 Qwik 版的 Core Web Vitals。LCP、FID、CLS 三项数据是检验迁移效果最直接的依据。如果数据没有明显改善,说明迁移过程中引入了新的性能问题(比如过度使用 qwikify$ 导致 React 运行时常驻),需要排查。标签
Qwik
Qwik 是一个为服务器端渲染(SSR)和 "恢复(resumability)" 优化的前端 JavaScript 框架。它被设计成在浏览器中尽可能快地加载,即使是最大型和最复杂的Web应用程序。Qwik 的主要卖点是其独特的 "按需加载" 机制,它能够确保只有当用户与页面交互时,相关代码才会被加载和执行。

前端5月27日 17:34
Qwik 的 SSR 和 CSR 是如何协同工作的?传统 SSR 框架在服务端渲染 HTML 后,客户端还需要重新下载和执行 JavaScript 来"水合"页面,恢复事件绑定和状态。这个过程随着应用规模增长,开销越来越大。Qwik 提出了完全不同的方案——恢复性(Resumability),让 SSR 产出的 HTML 自带全部状态和事件信息,客户端无需水合即可直接交互。
## Qwik 的 SSR 渲染流程
Qwik 在服务器端执行组件渲染时,不仅生成 HTML 结构,还会将组件状态、事件处理器引用、组件层级关系等全部序列化到 HTML 中。最终返回给浏览器的 HTML 包含了完整的应用快照。
具体流程如下:
1. **执行组件渲染**:Qwik 在 Node.js 环境中执行组件函数,生成虚拟 DOM 并渲染为 HTML 字符串
2. **收集状态和事件**:渲染过程中,Qwik 收集所有 `useSignal`、`useStore` 等响应式状态的当前值,以及所有 `onClick$` 等 `$` 后缀事件处理器的引用
3. **序列化到 HTML**:将状态数据以 JSON 格式注入到 HTML 末尾的 `<script type="qwik/json">` 标签中,事件绑定信息以 HTML 属性形式嵌入对应 DOM 节点
4. **发送完整 HTML**:服务器将包含状态和事件元数据的 HTML 响应发送给浏览器
```tsx
export const App = component$(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>
Increment
</button>
</div>
);
});
```
服务端渲染后,`count.value` 的值 `0` 会被序列化到 HTML 中,`onClick$` 处理器会被 Qwik Optimizer 编译为一个独立的 chunk 文件,HTML 中只保留该 chunk 的引用路径。浏览器首次加载时不需要下载这段逻辑代码。
这样做的直接好处是:首屏 HTML 包含完整可交互内容,搜索引擎可以直接抓取;客户端零 JavaScript 启动成本,FCP 和 TTI 几乎同时达成。
## 客户端恢复:零水合的交互激活
浏览器收到 HTML 后,Qwik 的客户端工作方式与传统框架完全不同。传统框架需要下载整个应用的 JavaScript,重新执行组件渲染函数,逐个绑定事件监听器——这就是水合过程。Qwik 跳过了这整个步骤。
### qwikloader 全局事件代理
Qwik 在 HTML 中注入了一个约 1KB 的 `qwikloader` 脚本,它做一件事:在 `document` 上监听所有 DOM 事件。当用户点击按钮时,事件冒泡到 `document`,qwikloader 根据事件目标元素上的 HTML 属性找到对应的事件处理器 chunk 路径,然后动态加载并执行该 chunk。
```html
<!-- 服务端渲染后的 HTML 片段 -->
<button on:click="./app_component_ClickHandler.js#default">
Increment
</button>
<script type="qwik/json">
{"state":{"count":"0"},"refs":{}}
</script>
```
这意味着:页面加载时不下载任何组件代码和事件处理器代码,只在用户真正交互时按需加载对应的代码块。
### 状态反序列化
当事件处理器 chunk 加载后,Qwik 从 `<script type="qwik/json">` 中反序列化状态,将 `count` 恢复为响应式的 `useSignal` 对象。组件函数不需要重新执行,状态直接恢复到服务端渲染时的快照。
### 细粒度代码分割
Qwik Optimizer 编译器在构建阶段自动进行细粒度代码分割:
- **组件级分割**:每个 `component$()` 包裹的组件生成独立 chunk
- **事件处理器级分割**:每个 `onClick$`、`onChange$` 等 `$` 后缀回调生成独立 chunk
- **状态更新逻辑分割**:涉及状态变更的逻辑单独提取
`$` 后缀是 Qwik 的核心语法约定,它告诉编译器"这个函数的闭包需要被提取为独立模块"。这就是为什么 Qwik 要求事件处理器使用 `onClick$` 而非 `onClick`——编译器需要显式知道哪些函数边界可以被分割。
## Qwik City 的 SSR 能力
Qwik City 是 Qwik 的全栈框架,提供了路由、数据获取和服务端操作能力。
### 路由数据加载
`routeLoader$` 在服务端执行数据获取,结果随 HTML 一起序列化发送:
```tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const useProductData = routeLoader$(async ({ params, env }) => {
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>
</div>
);
});
```
`routeLoader$` 的 `$` 后缀同样是分割标记——数据获取逻辑在服务端执行,结果序列化后客户端直接使用,不需要重复请求。
### 服务端操作
`action$` 处理表单提交等写操作,同样在服务端执行:
```tsx
import { action$ } from '@builder.io/qwik-city';
export const useAddToCart = action$(async (data, { requestEvent }) => {
const session = requestEvent.sharedMap.get('session');
// 服务端执行业务逻辑
return { success: true };
});
```
### 流式 SSR
Qwik City 支持流式 SSR,服务器可以在渲染完成前就开始向客户端发送 HTML 片段。对于数据量较大的页面,用户可以更快看到首屏内容,而不是等待整个页面渲染完毕才收到第一个字节。
## 与传统 SSR 框架的关键区别
| 维度 | Qwik | Next.js (React) | Nuxt (Vue) |
|------|------|-----------------|------------|
| 客户端激活方式 | 恢复性,无需水合 | 水合,重新执行组件 | 水合,重新执行组件 |
| 首屏 JS 体积 | 接近零(仅 qwikloader ~1KB) | 较大(框架运行时 + 组件代码) | 中等(框架运行时 + 组件代码) |
| 代码分割粒度 | 编译器自动按函数级分割 | 需手动配置 dynamic import | 需手动配置或依赖约定路由 |
| TTI 表现 | FCP 与 TTI 几乎一致 | TTI 明显滞后于 FCP | TTI 滞后于 FCP |
| 状态传递 | 自动序列化到 HTML | 需手动处理服务端状态注入 | 需手动处理或依赖框架约定 |
核心差异在于水合成本。Next.js 的一个典型 SSR 页面,即便 HTML 已经包含完整内容,客户端仍需下载 React 运行时(约 40KB gzipped)和所有页面组件代码来执行水合。Qwik 完全跳过这一步,首次加载仅需 qwikloader 的 ~1KB。
## 实际性能表现
Qwik 官方基准测试中,一个中等复杂度页面的性能指标:
- **FCP(首次内容绘制)**:约 0.3s
- **TTI(可交互时间)**:约 0.9s
- **首次加载 JS 体积**:约 1KB(qwikloader)
作为对比,相同页面在 Next.js 下的典型数据:
- **FCP**:约 0.5s
- **TTI**:约 2.5s(需要等待水合完成)
- **首次加载 JS 体积**:约 80-150KB(React 运行时 + 组件代码)
大众点评 M 站在 2026 年初完成基于 Qwik.js 的重构,生产环境验证了恢复性方案在大型电商场景下的性能收益——首屏加载速度提升约 40%,TTI 从 3.2s 降至 1.1s。
## 最佳实践
### 服务端数据获取优先
优先使用 `routeLoader$` 在服务器获取数据,避免客户端额外请求。数据随 HTML 一起序列化,客户端直接使用。
### 合理使用客户端任务
仅在必须访问浏览器 API 时使用 `useVisibleTask$`,如需要 `window`、`document` 或 Web API 的场景。不要用它来获取可以 SSR 的数据。
### 利用 useResource$ 处理异步数据
`useResource$` 适合需要客户端动态获取数据的场景,它返回的 Resource 对象可以追踪加载状态(pending / resolved / rejected),便于在 UI 中展示 loading 态:
```tsx
export default component$(() => {
const searchData = useResource$(async ({ cleanup }) => {
const controller = new AbortController();
cleanup(() => controller.abort());
const res = await fetch(`/api/search?q=keyword`, { signal: controller.signal });
return res.json();
});
return (
<Resource
value={searchData}
onPending={() => <p>加载中...</p>}
onResolved={(data) => <div>{data.result}</div>}
/>
);
});
```
### 混合渲染策略
静态内容(如博客文章、产品描述)使用 SSR 确保搜索引擎可抓取;动态交互(如购物车、搜索建议)依赖 Qwik 的按需加载机制,只在用户交互时加载对应逻辑。
Qwik 的恢复性架构让 SSR 和 CSR 不再是二选一的取舍,而是一个连续光谱上的不同策略。服务端负责渲染和数据获取,客户端负责交互和按需加载,两者通过序列化机制无缝衔接,开发者不需要手动处理状态同步和水合逻辑。前端5月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),框架会在需要时才加载和执行它。
```tsx
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 工具类型提取内置属性,再通过交叉类型追加自定义字段:
```tsx
import { 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<T> 表示。
```tsx
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 会提示类型不匹配。
## 状态管理的类型标注
### useSignal
useSignal 用于基本类型的响应式状态,通过泛型参数声明类型:
```tsx
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 会根据泛型参数严格检查赋值类型。
### useStore
useStore 用于对象类型的响应式状态,推荐用 interface 定义完整结构:
```tsx
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 调用。
```tsx
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 直接使用浏览器原生事件类型:
```tsx
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 类型来约束:
```tsx
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$ 用于在服务端加载数据,泛型参数声明返回数据的类型:
```tsx
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$ 实现运行时类型校验:
```tsx
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:
```tsx
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 作为中间类型再在调用侧收窄:
```tsx
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>
);
});
```
对于跨文件的类型复用,建议集中管理类型定义:
```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$ 函数用于将逻辑限定在服务端执行,其泛型参数约束函数签名:
```tsx
import { server$ } from '@builder.io/qwik-city';
const saveData = server$(async (data: { name: string; email: string }): Promise<{ success: boolean }> => {
// 仅在服务端执行,可安全访问数据库等
return { success: true };
});
```
server$ 返回的函数类型与传入的函数类型一致,调用侧无需感知服务端/客户端边界,TypeScript 会自动推导正确的返回类型。
## 类型断言与类型守卫
在事件处理等场景中,类型断言不可避免,但应尽量缩小断言范围:
```tsx
const input = element.querySelector('input') as HTMLInputElement;
console.log(input.value);
```
更安全的做法是用类型守卫替代断言:
```tsx
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 适合联合类型、交叉类型和工具类型的组合。
```tsx
// 对象结构用 interface
interface ComplexProps {
user: {
id: number;
profile: {
name: string;
avatar: string;
};
};
}
// 联合类型和交叉类型用 type
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonProps = BaseProps & { variant: ButtonVariant };
```
泛型工具函数可以大幅减少重复类型声明:
```tsx
export const useApi = <T>(url: string) => {
return useResource$<T>(() => fetch(url).then(r => r.json()));
};
```
类型守卫在运行时校验与编译时类型之间建立桥梁,对于服务端返回的未校验数据尤其重要:
```tsx
function isValidUser(user: unknown): user is User {
return typeof user === 'object' && user !== null && 'id' in user;
}
```
---
Qwik 的 TypeScript 集成不只是"能用",而是围绕 QRL 可恢复性架构重新设计了类型系统。掌握 QRL<T> 类型、$ 后缀的语义、以及 import type 的按需加载,才能在 Qwik 项目中写出既类型安全又不拖累加载性能的代码。遇到类型报错时,先检查是否遗漏了 $ 后缀或将 QRL 位置传入了普通函数——这是从其他框架迁移到 Qwik 时最常见的类型陷阱。前端5月27日 17:32
Qwik 组件系统的 $ 语法和可恢复性是如何工作的?Qwik 组件系统的核心设计目标是**可恢复性(Resumability)**——框架在服务端渲染时将组件的状态和执行上下文序列化到 HTML 中,客户端无需重新执行组件代码即可恢复交互。这和传统 SSR 框架(如 Next.js)的 Hydration 方案有本质区别:Hydration 需要在客户端重新下载和执行组件代码来"重新激活"页面,而 Qwik 只在用户实际交互时才懒加载对应的代码。
这个设计目标催生了 Qwik 组件系统中最显眼的特征:`$` 语法。
## `$` 语法:可恢复性边界
`$` 后缀不是语法糖,而是 Qwik 优化器(Optimizer)的编译指令。它标记了一个**惰性边界**——优化器会将 `$` 标记的函数提取为独立的 chunk,按需加载:
```tsx
import { 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$` 包裹:
```tsx
import { 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:简单值
```tsx
import { 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:复杂对象
```tsx
import { 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 节点。
## 事件处理
所有事件处理函数必须使用 `$` 后缀,否则编译器会报错:
```tsx
export const Button = component$(() => {
return (
<button onClick$={() => console.log('clicked')}>
Click me
</button>
);
});
```
`onClick$` 而非 `onClick`,这是 Qwik 最容易让 React 开发者踩坑的地方。如果试图传递一个普通函数给事件属性,Qwik 优化器会直接报错,因为普通函数无法被序列化和懒加载。
## 生命周期钩子
Qwik 提供三个核心生命周期钩子,都使用 `$` 后缀:
- **useTask$**:在组件挂载和响应式依赖变化时执行,类似于 React 的 useEffect + useMemo 的结合。可以追踪 Signal 变化并执行副作用:
```tsx
import { 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 三种状态:
```tsx
import { 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 传递
```tsx
export 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 跨层级通信
```tsx
import { 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:
```tsx
import { 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$**:在组件内联作用域样式:
```tsx
import { 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,但运行时性能由编译器保证,而非依赖手动优化。前端5月27日 17:32
Qwik 状态管理怎么用?从 useSignal 到 useResourceQwik 的状态管理围绕一个核心理念:可恢复性(Resumability)。与传统框架在客户端重新执行组件代码来恢复状态不同,Qwik 在服务端渲染时就将状态序列化到 HTML 中,浏览器可以直接从序列化点恢复执行,无需水合(Hydration)。这个设计决策深刻影响了 Qwik 状态管理 API 的形态。
## useSignal:管理原始值
`useSignal` 是最轻量的响应式状态,适合存储数字、字符串、布尔值等原始类型。它返回一个包含 `.value` 属性的对象,修改 `.value` 就能触发更新。
```tsx
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<div>
<p>当前计数:{count.value}</p>
<button onClick$={() => count.value++}>+1</button>
<button onClick$={() => count.value--}>-1</button>
</div>
);
});
```
几点注意:
- `useSignal` 只能存储单个值,如果需要管理对象或数组,应该用 `useStore`
- 访问值必须通过 `.value`,不能解构,否则会丢失响应性
- Qwik 对 `useSignal` 的更新是细粒度的,只有真正依赖这个值的 DOM 节点会重新渲染
## useStore:管理复杂对象
当状态是嵌套对象或数组时,用 `useStore` 替代 `useSignal`。它会自动追踪对象属性的变化,同样做到细粒度更新。
```tsx
import { component$, useStore } from '@builder.io/qwik';
export const TodoList = component$(() => {
const state = useStore({
items: [
{ id: 1, text: '学习 Qwik', done: false },
{ id: 2, text: '构建应用', done: false }
],
newTodoText: ''
});
return (
<div>
<input
value={state.newTodoText}
onInput$={(ev) => (state.newTodoText = (ev.target as HTMLInputElement).value)}
/>
<button onClick$={() => {
if (!state.newTodoText.trim()) return;
state.items.push({
id: Date.now(),
text: state.newTodoText,
done: false
});
state.newTodoText = '';
}}>
添加
</button>
<ul>
{state.items.map((item) => (
<li key={item.id}>
<input type="checkbox" checked={item.done} onInput$={() => (item.done = !item.done)} />
{item.text}
</li>
))}
</ul>
</div>
);
});
```
`useStore` 的一个重要特性:它不仅能追踪顶层属性,也能追踪深层嵌套属性的变化。这意味着修改 `item.done` 同样会触发对应 DOM 的更新。
## useComputed$:派生状态
当某个值依赖其他状态计算得出时,用 `useComputed$`。它会在依赖变化时自动重新计算,未变化时返回缓存值。
```tsx
import { component$, useSignal, useComputed$ } from '@builder.io/qwik';
export const PriceCalculator = component$(() => {
const price = useSignal(100);
const taxRate = useSignal(0.1);
const total = useComputed$(() => {
return price.value * (1 + taxRate.value);
});
return (
<div>
<p>单价:¥{price.value}</p>
<p>税率:{taxRate.value * 100}%</p>
<p>总价:¥{total.value.toFixed(2)}</p>
</div>
);
});
```
`useComputed$` 本质上是一个只读的 Signal,你不能直接修改它的 `.value`,只能通过改变依赖项来间接触发更新。适合用在过滤列表、格式化输出、聚合计算等场景。
## useContext:跨组件共享状态
当状态需要在组件树的多个层级间共享时,Qwik 提供了 Context 机制。先用 `createContext` 定义上下文,再用 `useContextProvider` 提供值,子组件通过 `useContext` 消费。
```tsx
import { component$, createContext, useContextProvider, useContext } from '@builder.io/qwik';
// 定义 Context 的类型和默认值
const ThemeContext = createContext<string>('light');
export const App = component$(() => {
// 在顶层组件提供值
useContextProvider(ThemeContext, 'dark');
return <ChildComponent />;
});
export const ChildComponent = component$(() => {
// 在任意子组件消费
const theme = useContext(ThemeContext);
return <p>当前主题:{theme}</p>;
});
```
与 React Context 的关键区别:Qwik 的 Context 值不需要是响应式的,但如果传入的是 `useStore` 或 `useSignal` 创建的响应式对象,消费组件同样会自动更新。
## useTask$:副作用与状态同步
`useTask$` 是 Qwik 中处理副作用的 hook,类似于 React 的 `useEffect`,但工作机制不同。它在服务端和客户端都会执行,可以通过 `track` 函数监听状态变化。
```tsx
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
export const SearchBox = component$(() => {
const keyword = useSignal('');
const results = useSignal<string[]>([]);
useTask$(({ track }) => {
const query = track(() => keyword.value);
// 当 keyword 变化时,重新计算搜索结果
if (query.length < 2) {
results.value = [];
return;
}
// 模拟搜索逻辑
results.value = ['结果1', '结果2'].filter((r) => r.includes(query));
});
return (
<div>
<input value={keyword.value} onInput$={(ev) => (keyword.value = (ev.target as HTMLInputElement).value)} />
<ul>
{results.value.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
);
});
```
`useTask$` 适合用在:根据某个状态变化去修改另一个状态、发起网络请求、操作 DOM 等场景。注意它不返回值,如果需要返回派生状态,应该用 `useComputed$`。
## useResource$:异步数据加载
`useResource$` 专门处理异步数据获取,内置了 pending、resolved、rejected 三种状态的管理,配合 Resource 组件可以方便地渲染不同状态。
```tsx
import { component$, useSignal, useResource$, Resource } from '@builder.io/qwik';
export const UserProfile = component$(() => {
const userId = useSignal(1);
const userResource = useResource$(async ({ track }) => {
const id = track(() => userId.value);
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
if (!res.ok) throw new Error('加载失败');
return res.json();
});
return (
<div>
<button onClick$={() => userId.value++}>下一个用户</button>
<Resource
value={userResource}
onPending={() => <p>加载中...</p>}
onRejected={() => <p>加载失败,请重试</p>}
onResolved={(user) => (
<div>
<p>姓名:{user.name}</p>
<p>邮箱:{user.email}</p>
</div>
)}
/>
</div>
);
});
```
`useResource$` 的 `track` 函数让它能在依赖变化时自动重新获取数据,配合 `<Resource>` 组件可以清晰地处理三种 UI 状态,避免手动管理 loading/error 状态的样板代码。
## Qwik 与 React 状态管理的本质区别
理解 Qwik 状态管理,关键在于理解它与 React 的根本差异:
**React 的状态绑定在组件实例上**,组件卸载状态就消失,客户端需要通过水合重建组件树和状态。
**Qwik 的状态是独立的**,它与创建它的组件解耦。状态被序列化到 HTML 的 `<script type="qwik/json">` 中,浏览器无需执行任何组件代码就能恢复状态。这意味着:
- 状态可以在组件间自由传递,不受组件树层级限制
- 只有用户交互时才会下载对应的处理代码(懒执行)
- 状态的可序列化是硬性要求,不能存储函数、DOM 引用等不可序列化的值
## 选择合适的状态管理方式
| 场景 | 推荐方式 |
|------|----------|
| 单个原始值(计数器、开关) | useSignal |
| 复杂对象或数组(表单、列表) | useStore |
| 依赖其他状态的派生值 | useComputed$ |
| 跨组件共享数据 | useContext + useContextProvider |
| 响应状态变化执行副作用 | useTask$ |
| 异步数据获取与状态管理 | useResource$ |
在实际开发中,这些 API 往往组合使用。比如用 `useStore` 管理全局状态,通过 `useContextProvider` 注入组件树,子组件用 `useContext` 消费,再用 `useTask$` 响应状态变化执行副作用,用 `useResource$` 加载远程数据。Qwik 的编译器会自动处理细粒度更新和状态序列化,开发者只需关注业务逻辑本身。
前端5月27日 17:31
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 路径。
### 目录结构与路由映射
```
src/
├── 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$ — 服务端数据加载
这是最常用的数据加载方式,在服务端执行,适合页面级数据的预获取:
```tsx
import { 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$ — 客户端数据加载
当需要在客户端动态获取数据(比如用户交互后刷新)时使用:
```tsx
import { 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$` 在组件内部使用,支持依赖追踪和响应式重新获取:
```tsx
import { 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$ — 服务端表单处理
```tsx
import { 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$ — 客户端表单处理
```tsx
import { 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();
});
```
### 响应拦截
```tsx
export 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 信息:
```tsx
import { 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
}
});
}
```
### 组件内使用翻译
```tsx
import { 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:
```tsx
export 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 共享数据:
```tsx
export 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,仅在用户交互时按需加载对应的事件处理器。前端5月27日 17:31
Qwik 框架的性能优化策略有哪些?从可恢复性到细粒度更新的完整解析Qwik 之所以在首屏性能上远超传统前端框架,核心在于它的"可恢复性"架构——服务端渲染的 HTML 可以在客户端直接恢复状态和事件绑定,完全跳过了水合过程。下面从原理到实践,逐层拆解 Qwik 的性能优化策略。
## 可恢复性:Qwik 性能的根基
传统 SSR 框架(React、Vue、Next.js)在客户端需要重新下载组件代码并执行水合(hydration),将 DOM 节点与事件监听器重新关联。这个过程随着页面复杂度增长而变慢。Qwik 的做法完全不同:
- 服务端渲染时,Qwik 将组件状态序列化为 JSON,注入到 HTML 的 `<script>` 标签中
- 事件处理函数不会被打包进首屏 JS,而是在 HTML 中以属性形式记录引用路径(如 `on:click="/src/components/app.js#handleClick"`)
- 客户端只需加载约 1KB 的 Qwik Loader 脚本,即可监听所有交互事件并在触发时按需加载对应处理函数
这意味着首屏加载几乎等同于纯 HTML 页面,没有框架运行时的启动开销。
```tsx
// 服务端渲染后的 HTML 片段示例
// 事件绑定以引用路径形式存在,不包含实际 JS 代码
<button on:click="./app.js#handleClick_0">Increment</button>
// 状态序列化在 <script type="qwik/json"> 中
```
## 零水合与按需加载
### Qwik Loader 机制
Qwik 在 HTML 末尾注入一个极小的 Qwik Loader 脚本(约 1KB),它的唯一职责是监听 DOM 事件。当用户触发交互时,Loader 根据事件目标上的引用路径,动态 import 对应的代码块并执行。
```tsx
export const App = component$(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>
Increment
</button>
</div>
);
});
```
上面这段代码编译后,`component$` 内部的渲染逻辑和 `onClick$` 回调会被分别打包成独立文件。首屏只输出 HTML 结构,JS 代码在用户点击按钮时才加载。
### 与传统水合的对比
| 阶段 | 传统 SSR 框架 | Qwik |
|------|-------------|------|
| 首屏 JS 体积 | 50KB-200KB+ | ~1KB |
| 水合过程 | 下载全部组件代码 → 解析 → 执行绑定 | 无水合,直接可交互 |
| 首次可交互时间 | 依赖 JS 下载+解析完成 | HTML 到达即可交互 |
| 交互延迟 | 无(代码已加载) | 首次交互需下载对应代码块(通常 <50ms) |
## 细粒度代码分割
Qwik 编译器在构建阶段自动进行组件级和函数级分割,不需要手动配置 `dynamic import` 或 `React.lazy`。
### 组件级分割
每个 `component$()` 包裹的组件都会被编译为独立文件:
```tsx
export const Dashboard = component$(() => {
return (
<div>
<Header />
<Sidebar />
<Content />
<Footer />
</div>
);
});
```
编译产物:`Dashboard.js`、`Header.js`、`Sidebar.js`、`Content.js`、`Footer.js` 各自独立,按需加载。
### 事件处理函数级分割
`$` 后缀的函数会被提取为独立模块:
```tsx
export const Form = component$(() => {
const handleSubmit$ = () => { /* 提交逻辑 */ };
const handleReset$ = () => { /* 重置逻辑 */ };
const handleCancel$ = () => { /* 取消逻辑 */ };
return (
<form>
<button onClick$={handleSubmit$}>Submit</button>
<button onClick$={handleReset$}>Reset</button>
<button onClick$={handleCancel$}>Cancel</button>
</form>
);
});
```
三个回调函数各自成为独立文件,只有在用户点击对应按钮时才发起请求。这种粒度是传统框架无法自动实现的。
## 事件委托
Qwik 在事件处理上采用全局委托策略:不在每个 DOM 节点上注册事件监听器,而是在 `document` 或 `window` 上统一监听。当事件冒泡到顶层时,Qwik Loader 从事件目标读取引用路径,动态加载对应的处理函数。
这带来的好处:
- 首屏无需注册任何事件监听器,减少 JS 执行量
- 避免了传统框架中大量 `addEventListener` 调用的性能开销
- 动态内容(如异步加载的组件)天然支持事件绑定,无需额外处理
## 智能预取策略
虽然 Qwik 的核心思路是"按需加载",但它并不会让用户在每次交互时都等待网络请求。Qwik 提供了预取机制:
- **交互预取**:当用户鼠标悬停(hover)或焦点移到可交互元素时,Qwik 提前下载对应代码块
- **可见性预取**:视口内的组件代码优先预取
- **预取在主线程外执行**:利用浏览器的 `<link rel="modulepreload">` 或 `import()` 在 Worker 线程中完成,不阻塞主线程
```tsx
// 通过 prefetchStrategy 配置预取行为
export default config({
prefetchStrategy: {
implementation: {
linkInsert: 'js-append',
linkHref: (path) => path,
workerFetch: true,
},
},
});
```
预取策略让 Qwik 在"零首屏 JS"和"即时交互响应"之间取得平衡:首屏不加载多余代码,但用户即将交互时代码已经就绪。
## 响应式细粒度更新
Qwik 的响应式系统自动追踪状态依赖,只在状态变化时更新受影响的 DOM 节点。
```tsx
export const TodoList = component$(() => {
const todos = useStore([
{ id: 1, text: '学习 Qwik 基础', completed: false },
{ id: 2, text: '实践代码分割', completed: false },
{ id: 3, text: '部署到生产环境', completed: false }
]);
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onClick$={() => { todo.completed = !todo.completed; }}
/>
<span>{todo.text}</span>
</li>
))}
</ul>
);
});
```
点击某个 todo 的复选框时,只有该 `<li>` 内的复选框状态更新,其他项不会重新渲染。这与 React 的虚拟 DOM diff 或 Vue 的组件级更新不同,Qwik 能做到属性级的精确更新。
## 开发实践中的性能优化
### 选择合适的状态原语
```tsx
// 原始值用 useSignal——轻量,追踪精确
const count = useSignal(0);
const name = useSignal('');
// 对象和数组用 useStore——深层响应式追踪
const user = useStore({
name: '张三',
settings: {
theme: 'dark',
language: 'zh-CN'
}
});
```
`useSignal` 适合独立原始值,变更时只触发依赖该值的位置更新。`useStore` 适合嵌套对象,Qwik 会自动追踪到具体哪个属性发生了变化。
### 用 useComputed$ 缓存派生计算
```tsx
export const ShoppingCart = component$(() => {
const items = useStore([
{ name: 'Qwik 实战手册', price: 79, qty: 1 },
{ name: 'TypeScript 进阶', price: 59, qty: 2 }
]);
const total = useComputed$(() => {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
});
return <div>合计:¥{total.value}</div>;
});
```
`useComputed$` 只在依赖的状态变化时重新计算,避免每次渲染都执行计算逻辑。
### 用 useResource$ 处理异步数据流
```tsx
export const UserProfile = component$(({ userId }: { userId: string }) => {
const userData = useResource$(async ({ track }) => {
track(() => userId);
const res = await fetch(`/api/users/${userId}`);
return res.json();
});
return (
<div>
{userData.isLoading && <p>加载中...</p>}
{userData.failed && <p>加载失败,请重试</p>}
{userData.value && (
<div>
<h3>{userData.value.name}</h3>
<p>{userData.value.bio}</p>
</div>
)}
</div>
);
});
```
`useResource$` 自带加载态和错误态处理,且会在 `track` 的依赖变化时自动重新请求。
### 客户端专属逻辑用 useVisibleTask$
```tsx
export const MapWidget = component$(() => {
const containerRef = useRef<HTMLDivElement>();
useVisibleTask$(() => {
// 只在浏览器环境、组件可见时执行
const map = createMap(containerRef.current);
return () => map.destroy(); // 清理函数
});
return <div ref={containerRef} style={{ height: '400px' }}></div>;
});
```
`useVisibleTask$` 确保 DOM 依赖的逻辑只在客户端执行,不会在 SSR 阶段报错,且组件进入视口时才触发,避免不可见区域的无谓初始化。
### 避免在渲染路径上创建新引用
```tsx
// 不推荐:每次渲染产生新的对象引用,可能导致不必要的子组件重渲染
export const List = component$(() => {
return <Child style={{ color: 'red' }} data={{ items: [] }} />;
});
// 推荐:将静态引用提到组件外部
const staticStyle = { color: 'red' };
const staticData = { items: [] };
export const List = component$(() => {
return <Child style={staticStyle} data={staticData} />;
});
```
## 状态序列化与恢复
Qwik 的状态管理贯穿服务端和客户端。在 SSR 阶段,所有通过 `useSignal`、`useStore`、`useContext` 等创建的状态都会被序列化到 HTML 中。客户端加载时,Qwik 直接从 HTML 中反序列化恢复状态,无需重新请求接口或重新执行组件逻辑。
这带来的实际收益:
- 页面刷新后表单数据不丢失
- 浏览器前进后退时状态完整恢复
- 无需额外设计客户端缓存策略
## SSR 与 SSG 部署选择
Qwik 支持多种渲染模式,不同模式对性能有直接影响:
- **SSR(服务端渲染)**:适合动态内容为主的页面,每次请求实时渲染,配合 CDN 缓存可兼顾动态性和性能
- **SSG(静态生成)**:适合内容相对固定的页面,构建时生成 HTML,部署到 CDN 后响应速度最快
- **ISR(增量静态再生)**:SSG 的升级版,支持按时间或按需重新生成静态页面,兼顾性能和内容时效性
实际项目中,通常将营销页和文档页用 SSG,用户仪表盘用 SSR,实现不同场景下的最优性能。
## 性能监控指标
部署后关注以下 Core Web Vitals 指标来验证优化效果:
- **LCP(Largest Contentful Paint)**:最大内容绘制时间,衡量首屏主要内容加载速度。Qwik 的零 JS 策略通常能让 LCP 接近纯 HTML 页面水平
- **FID / INP(首次输入延迟 / 交互到下次绘制)**:衡量交互响应速度。Qwik 的事件委托和预取策略使 INP 通常低于 50ms
- **CLS(Cumulative Layout Shift)**:累积布局偏移。Qwik 的 SSR 输出完整 DOM 结构,天然避免布局抖动
使用 Chrome DevTools 的 Performance 面板或 Lighthouse 可以量化这些指标。Qwik 项目内置的 DevTools 还提供组件树可视化、代码分割视图和状态追踪功能,方便定位性能瓶颈。
Qwik 的性能优势不是靠某个单一技巧实现的,而是可恢复性架构、编译时自动分割、事件委托、智能预取、细粒度响应式更新这几项机制协同工作的结果。理解这些原理后,结合上面的开发实践,就能在日常开发中充分发挥 Qwik 的性能潜力。前端5月27日 17:31
Qwik 恢复性(Resumability)是什么?为什么不需要 Hydration?## Qwik 恢复性(Resumability)是什么?
恢复性(Resumability)是 Qwik 框架的核心架构理念:应用在服务器端完成渲染后,客户端无需重新执行 JavaScript 即可直接恢复执行状态。这与传统框架的水合(Hydration)机制形成根本区别。
传统 SSR 框架的流程是:服务器渲染 HTML → 客户端下载 JS → 重新执行全部 JS 恢复事件绑定 → 页面变为可交互。而 Qwik 的流程是:服务器渲染 HTML 并序列化状态 → 客户端直接从 HTML 恢复状态 → 页面已可交互。前者是"重新执行",后者是"继续执行"。
## Qwik 如何实现恢复性?
### 延迟加载(Lazy Loading)
Qwik 默认将所有 JavaScript 代码分割成细粒度的小块,只有用户实际交互时才加载对应的代码。传统框架通常需要下载整个应用的 JS 包后才能启动,而 Qwik 的首屏加载几乎不包含业务 JavaScript。
```html
<!-- Qwik 编译后的按钮:事件处理程序被替换为引用路径 -->
<button on:click="./click-handler.js#handleClick">Click me</button>
```
用户点击按钮时,Qwik 才按需下载 `click-handler.js` 中的 `handleClick` 函数,而非整个应用。
### 序列化状态到 HTML
Qwik 将应用的组件状态、事件监听器定义、组件层次结构等信息序列化后嵌入 HTML,以属性和 `<script>` 标签的形式存在:
```html
<div q:state="{count: 0}"></div>
<script type="qwik/json">
{"count": 0}
</script>
```
浏览器加载页面时,直接从 HTML 中读取这些序列化数据恢复状态,不需要重新执行初始化代码来重建应用状态。
### 无水合(No Hydration)
传统框架(React、Vue、Angular)在 SSR 后必须在客户端重新执行 JavaScript 来附加事件监听器和重建组件树,这个过程称为水合(Hydration)。水合的问题在于:
- **时间复杂度为 O(n)**:页面有多少组件,就需要重新执行多少组件代码
- **TTI 延迟**:页面看起来已经渲染完毕,但在 JS 执行完成前无法交互
- **重复工作**:服务器已经渲染过的逻辑,客户端再执行一遍
Qwik 通过将事件监听器以引用路径的方式序列化到 HTML 中,完全跳过了水合步骤。客户端不需要重新执行组件代码来"发现"事件绑定——绑定信息已经在 HTML 里了。
### 细粒度按需加载
Qwik 可以加载单个函数或单个组件,而不是整个模块。点击一个按钮只会加载该按钮的事件处理程序,不会加载兄弟组件、父组件或其他无关代码。这种粒度是组件级甚至函数级的,远细于传统框架的路由级或页面级代码分割。
### 可恢复的执行上下文
Qwik 维护了一个可以在服务器和客户端之间传递的执行上下文。服务器渲染时捕获的闭包变量、组件作用域等信息被序列化保存,客户端可以直接恢复这些上下文,确保代码在不同运行环境中无缝衔接。
## 恢复性 vs 水合:核心差异对比
| 维度 | 水合(Hydration) | 恢复性(Resumability) |
|---|---|---|
| 启动方式 | 重新执行 JS 恢复状态 | 从 HTML 直接读取状态 |
| 时间复杂度 | O(n),与组件数量成正比 | O(1),框架代码即时可用 |
| 事件绑定 | 客户端重新执行代码附加 | 序列化在 HTML 属性中 |
| 首屏 JS 体积 | 需要下载框架+应用代码 | 近零 JS,按需加载 |
| TTI | 受 JS 下载和执行影响 | 接近即时可交互 |
## 恢复性带来的优势
- **更快的首屏加载**:页面不依赖大量 JavaScript 即可完成渲染,首屏时间显著缩短
- **即时可交互(TTI ≈ FCP)**:内容出现时即已可交互,没有水合等待期
- **更低的带宽消耗**:只加载用户实际交互所需的代码,其余代码不传输
- **更好的 SEO**:服务器端渲染输出完整 HTML,搜索引擎可直接索引
- **可扩展性**:应用功能增多不会线性增加首屏加载开销
## Qwik 编译器的角色
恢复性的实现并不需要开发者手动管理代码分割和状态序列化。Qwik 的编译器在构建阶段自动完成这些工作:
- 自动识别可延迟加载的代码边界,将事件处理程序和组件拆分为独立 chunk
- 自动分析组件状态依赖关系,确定需要序列化的数据范围
- 将事件监听器引用转换为可恢复的路径格式
开发者编写代码时仍使用熟悉的组件模式,编译器在产出层确保一切符合恢复性架构的要求。前端5月27日 17:30
Qwik 和 React 有什么区别?## Qwik 和 React 的核心架构差异是什么?
Qwik 和 React 最大的区别在于架构理念:React 基于 **虚拟 DOM + 水合(Hydration)**,Qwik 基于 **可恢复性(Resumability)+ 按需加载**。这个根本差异直接影响了加载策略、状态管理、性能表现等方方面面。
## 加载策略:全量下载 vs 按需加载
**React** 在页面渲染时,通常需要下载整个应用包(或多个 chunk)。即使使用了 Code Splitting 做懒加载,也需要开发者手动配置:
```jsx
// React 懒加载需要手动配置
const LazyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
);
}
```
**Qwik** 的加载策略完全不同——所有 JavaScript 默认都是延迟加载的,只有用户与页面交互时才加载和执行相关代码:
```tsx
// Qwik 组件:事件处理器自动延迟加载
import { component$, useSignal } from '@builder.io/qwik';
export default component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
点击了 {count.value} 次
</button>
);
});
```
注意 Qwik 中的 `component$` 和 `onClick$`,`$` 后缀表示这是一个延迟加载边界,编译器会自动将这段代码拆分为独立 chunk,仅在需要时加载。
## 水合 vs 可恢复性
这是 Qwik 和 React 最本质的区别。
**React 的水合过程**:SSR 渲染出 HTML 后,客户端必须重新下载并执行 JavaScript,重建组件树、附加事件监听器,让页面变得可交互。这个过程称为 Hydration:
```
SSR HTML → 下载 JS → 执行组件代码 → 附加事件 → 页面可交互
↑ 这一步耗时且昂贵
```
即使用 React 18 的 Selective Hydration 做了部分优化,仍然无法避免大量 JavaScript 的下载和执行。
**Qwik 的可恢复性**:不需要水合。Qwik 在 SSR 时将组件状态序列化到 HTML 中,事件监听器通过 HTML 属性直接附加:
```html
<!-- Qwik 渲染出的 HTML -->
<button on:click="/build/bundle-abc.js#handler_xyz">
点击了 0 次
</button>
```
浏览器拿到 HTML 后,当用户点击按钮时,才去加载对应的 JS 函数并执行。页面天然就是可交互的,不需要任何"唤醒"过程:
```
SSR HTML → 页面立即可交互
↑ 无需额外 JS 执行
```
## 状态管理:细粒度更新 vs 重新渲染
**React** 使用 `useState`、`useReducer`、Context API 管理状态,状态变化会触发组件重新渲染:
```jsx
// React:状态更新触发组件重渲染
function Counter() {
const [count, setCount] = useState(0);
// count 变化 → 整个组件重新执行
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
```
复杂应用中,开发者需要借助 `useMemo`、`useCallback`、`React.memo` 手动优化渲染性能,或者引入 Redux、Zustand 等外部状态管理库。
**Qwik** 使用 `useSignal` 和 `useStore` 管理状态,状态变化只更新绑定的 DOM 节点,不会触发组件重新渲染:
```tsx
// Qwik:状态更新只更新具体 DOM 节点
export default component$(() => {
const count = useSignal(0);
// count 变化 → 只更新 {count.value} 对应的文本节点
return <button onClick$={() => count.value++}>{count.value}</button>;
});
```
此外,Qwik 的状态会被序列化到 HTML 中,刷新页面后状态依然存在,不需要额外的状态恢复逻辑。
## 性能数据对比
| 指标 | React + Next.js | Qwik + Qwik City |
|------|----------------|-------------------|
| 首屏 JS 体积 | 40-100KB+ | 约 1-2KB |
| TTI(可交互时间) | 需等待水合完成 | HTML 加载即交互 |
| 水合开销 | 重新执行全部组件 JS | 无水合过程 |
| 代码分割 | 手动配置(lazy/Suspense) | 编译器自动完成 |
Qwik 在首屏加载上的优势尤为明显——初始 JS 包只有 1-2KB,而 React 应用即使做了代码分割,首屏仍需加载框架核心和组件代码。
## 开发体验对比
**React 的优势**:生态系统成熟,npm 上几乎任何需求都有现成库可用。社区支持强大,遇到问题容易找到解决方案。Next.js 提供了完善的 SSR/SSG 方案。
**Qwik 的学习成本**:语法与 React 相似(JSX、Hooks 风格的 API),但有几个关键差异需要适应:
- `component$` 替代普通函数组件
- `$` 后缀标记延迟加载边界
- `useSignal` / `useStore` 替代 `useState`
- `useTask$` 替代 `useEffect`
- 编译器自动处理优化,不需要手动写 `useMemo` / `useCallback`
## 各自适合什么场景?
**选择 Qwik 的场景**:
- 内容密集型网站(博客、新闻、电商列表页)
- 对首屏加载速度和 SEO 排名有严格要求
- 面向移动端用户或网络条件不稳定的场景
- 大型应用希望减少 JS 体积对性能的影响
**选择 React 的场景**:
- 需要丰富的第三方库和工具支持
- 团队已有 React 经验,迁移成本需要考虑
- 项目复杂度高,需要成熟的架构方案(如 Next.js App Router)
- 快速原型开发,优先开发效率而非极致性能
## 迁移建议
如果你正在考虑从 React 迁移到 Qwik,需要注意:
- Qwik 提供了 `qwik-react` 集成,可以在 Qwik 应用中逐步引入 React 组件,支持渐进式迁移
- 并非所有 React 生态库都有 Qwik 对应方案,复杂项目建议先做技术评估
- 对于已有 React 项目,迁移优先级应基于性能瓶颈:如果当前应用首屏加载不是痛点,迁移的收益有限
Qwik 通过可恢复性架构在首屏性能上建立了明显优势,但 React 凭借成熟的生态和社区仍是更稳妥的选择。具体选型应基于项目对性能、生态和团队能力的综合考量。前端5月27日 17:30
Qwik 编译器的工作原理是什么?从代码分割到可恢复序列化Qwik 之所以能在首屏加载时做到近乎零 JavaScript,核心驱动力就是它的编译器。编译器将开发者编写的组件代码,在构建阶段就拆解成最小可延迟加载的单元,并把运行时状态序列化进 HTML,让浏览器无需重新执行应用即可恢复交互。下面从编译流程、代码分割、序列化机制、元数据生成、优化策略、类型安全与调试七个层面拆解 Qwik 编译器的工作原理。
## 编译流程:从源码到可恢复产物
Qwik 编译器(`@builder.io/qwik/optimizer`)的处理流程分为五个阶段:
1. **解析**:读入 TypeScript/JSX 源码,构建 AST(抽象语法树)
2. **分析**:遍历 AST,识别 `component$`、`$` 后缀函数、`useSignal` 等 Qwik 特有构造,标记懒加载边界
3. **转换**:将 `$` 后缀的函数提取为独立模块,生成懒加载引用替代原位函数体
4. **代码生成**:输出分割后的 JavaScript 文件与元数据清单
5. **优化**:应用死代码消除、常量折叠、Tree Shaking 等优化
入口调用示例:
```typescript
import { transform } from '@builder.io/qwik/optimizer';
const result = transform({
code: sourceCode,
filename: 'component.tsx',
minify: true,
sourceMap: true,
entryStrategy: 'smart'
});
```
## 代码分割:`$` 后缀是关键分割边界
Qwik 的代码分割不是按路由或组件粒度,而是按交互粒度。编译器识别 `$` 后缀标记(如 `component$`、`onClick$`、`handleClick$`),将每个标记的函数体提取为独立 chunk。
```tsx
// 原始代码
export const App = component$(() => {
const handleClick$ = () => {
console.log('Clicked');
};
const handleSubmit$ = () => {
console.log('Submitted');
};
return (
<div>
<button onClick$={handleClick$}>Click</button>
<button onClick$={handleSubmit$}>Submit</button>
</div>
);
});
```
编译后产物:
```
dist/
├── App.js # 主组件骨架(不含事件逻辑)
├── handleClick.js # 点击处理函数,按需加载
├── handleSubmit.js # 提交处理函数,按需加载
└── q-manifest.json # 符号与 chunk 映射清单
```
主组件只保留函数引用而非函数体,用户点击按钮时才加载对应 chunk。这就是 Qwik "延迟加载一切"策略的实现基础。
分割策略可通过配置调整:
```typescript
// qwik.config.ts
export default defineConfig({
optimizer: {
entryStrategy: {
type: 'smart', // 'smart' | 'hook' | 'inline'
manualChunks: {
'vendor': ['lodash']
}
}
}
});
```
- `smart`:编译器自动判断最小分割粒度(推荐)
- `hook`:仅分割事件处理函数
- `inline`:不做分割,全部内联
## 序列化机制:可恢复性的根基
Qwik 编译器最独特的能力是将组件状态序列化进 HTML,使页面在服务端渲染后,客户端无需重新执行 JavaScript 即可恢复交互——这就是 Resumability(可恢复性)。
### 状态序列化
```tsx
export const Counter = component$(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>Increment</button>
</div>
);
});
```
编译器将信号状态直接写入 HTML:
```html
<div data-qwik="q-123">
<p>Count: <span data-qwik="q-456">0</span></p>
<button data-qwik="q-789" onClick$="./handleClick.js#handleClick">
Increment
</button>
<script type="qwik/json">
{ "q-456": { "value": 0 } }
</script>
</div>
```
`<script type="qwik/json">` 中存储了信号的当前值,按钮的 `onClick$` 属性指向一个 chunk 路径而非内联函数。浏览器首次渲染时只解析 HTML,不执行任何 JavaScript;用户点击按钮后,才加载 `handleClick.js` 并恢复事件绑定。
### 函数引用序列化
编译器将函数引用序列化为路径映射:
```json
{
"q-789": {
"func": "./handleClick.js#handleClick",
"captures": []
}
}
```
`captures` 数组记录闭包捕获的变量引用。如果事件处理函数引用了外部变量,编译器会将这些变量的值一并序列化,确保恢复时闭包上下文完整。
## 元数据生成:q-manifest.json
编译器生成 `q-manifest.json`,它是运行时懒加载的路由表:
```json
{
"symbols": {
"s_123": {
"canonicalFilename": "./App.js",
"hash": "abc123",
"kind": "component",
"name": "App"
},
"s_456": {
"canonicalFilename": "./handleClick.js",
"hash": "def456",
"kind": "eventHandler",
"name": "handleClick"
}
},
"mapping": {
"q-123": "s_123",
"q-456": "s_456"
},
"bundles": {
"./App.js": { "size": 1024, "symbols": ["s_123"] },
"./handleClick.js": { "size": 512, "symbols": ["s_456"] }
}
}
```
- `symbols`:每个 `$` 后缀函数对应的符号定义(类型、文件路径、哈希)
- `mapping`:DOM 节点 ID 到符号 ID 的映射,运行时据此查找应加载哪个 chunk
- `bundles`:每个 chunk 的体积与包含的符号列表
Qwik 运行时在用户交互时,通过 DOM 节点的 `data-qwik` 属性查 mapping,再查 symbols 定位 chunk 文件,实现精准的按需加载。
## 优化策略
### 死代码消除
编译器追踪信号的使用情况,移除未引用的信号和逻辑:
```tsx
// 原始代码
export const Component = component$(() => {
const used = useSignal(0);
const unused = useSignal(0); // 模板中未引用
return <div>{used.value}</div>;
});
// 编译后,unused 被移除
export const Component = component$(() => {
const used = useSignal(0);
return <div>{used.value}</div>;
});
```
### Tree Shaking
编译器基于 ES Module 的静态结构,移除未导出的函数和变量:
```tsx
// 原始代码
export const used = () => {};
const notUsed = () => {}; // 未导出,被移除
// 编译后
export const used = () => {};
```
### 常量折叠与内联
对于纯表达式,编译器在构建时求值并替换:
```tsx
// 原始代码
const smallFunction$ = () => 1 + 1;
export const Component = component$(() => {
return <div>{smallFunction$()}</div>;
});
// 编译后
export const Component = component$(() => {
return <div>{2}</div>;
});
```
## 类型安全与调试支持
### TypeScript 集成
编译器完全支持 TypeScript 类型检查,包括对 `component$` Props 的类型推断:
```tsx
export const Component = component$((props: {
name: string;
count: number;
onClick$: () => void;
}) => {
return (
<div>
<h1>{props.name}</h1>
<p>Count: {props.count}</p>
<button onClick$={props.onClick$}>Click</button>
</div>
);
});
```
编译器会验证 `onClick$` 的 `$` 后缀是否正确使用,确保懒加载边界不被意外打破。
### Source Maps
编译器生成 source maps 支持源码级调试:
```typescript
const result = transform({
code: sourceCode,
filename: 'component.tsx',
sourceMap: true
});
```
### 开发/生产模式
```typescript
const result = transform({
code: sourceCode,
mode: 'development' // 生成详细错误信息与完整的符号名称
});
```
开发模式下保留完整的符号名称和详细错误栈,生产模式下压缩为短哈希以减小体积。
## 编译器与 Resumability 的关系
理解 Qwik 编译器的关键在于:它不是传统意义上的转译器,而是为 Resumability 服务的预处理工具。传统 SSR 框架(如 Next.js)在服务端渲染 HTML 后,客户端还需要重新下载并执行整个应用的 JavaScript 来"水合"(Hydration)DOM 事件。Qwik 编译器通过三个核心能力彻底避免了这个问题:
1. **将函数体提取为独立 chunk**,HTML 中只保留路径引用——客户端不需要预先加载事件处理代码
2. **将状态序列化进 HTML**——客户端不需要重新执行组件来恢复状态
3. **生成 manifest 映射**——运行时能在用户交互瞬间精准定位并加载所需代码
这就是 Qwik 实现"零 Hydration"的编译器层面原理:编译器在构建时完成了传统框架在运行时才做的事情。服务端5月27日 14:32
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$:组件的懒加载入口
```tsx
import { 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$:服务端与客户端共享的生命周期
```tsx
export 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$:纯客户端的生命周期
```tsx
export 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 式的服务端函数
```tsx
import { 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、环境变量等:
```tsx
const 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 命名,而是一个个精确的懒加载边界——它们共同构成了一张按需加载的网络,让浏览器只在真正需要时才执行代码。