标签

Qwik

Qwik 是一个为服务器端渲染(SSR)和 "恢复(resumability)" 优化的前端 JavaScript 框架。它被设计成在浏览器中尽可能快地加载,即使是最大型和最复杂的Web应用程序。Qwik 的主要卖点是其独特的 "按需加载" 机制,它能够确保只有当用户与页面交互时,相关代码才会被加载和执行。

Qwik
服务端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 运行时常驻),需要排查。
前端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 命名,而是一个个精确的懒加载边界——它们共同构成了一张按需加载的网络,让浏览器只在真正需要时才执行代码。