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

项目结构:

shell
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不需要否,$ 函数天然懒加载
useMemouseComputed$是,计算密集场景仍需缓存
React.lazy + Suspense不需要否,所有 component$ 自动代码分割
手动 import() 代码分割不需要否,Optimizer 自动处理
useEffect cleanupuseVisibleTask$ 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$ 在服务端执行,里面出现 windowdocumentlocalStorage 会直接报错。需要浏览器 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