前端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

前端阅读 05月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),框架会在需要时才加载和执行它。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 工具类型提取内置属性,再通过交叉类型追加自定义字段:import { component$, PropsOf } from '@builder.io/qwik';export const CustomInput = component$<PropsOf<'input'> & { customProp?: string;}>((props) => { return <input {...props} />;});PropsOf 会自动包含 input 元素的所有标准属性(value、placeholder、onChange 等),避免手动维护冗长的类型列表。QRL 类型与 $ 后缀QRL 是 Qwik 类型系统中最重要的概念。所有带 $ 后缀的函数(onClick$、useTask$、server$ 等)都是 QRL 包裹的懒加载引用,类型上用 QRL 表示。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 会提示类型不匹配。状态管理的类型标注useSignaluseSignal 用于基本类型的响应式状态,通过泛型参数声明类型: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 会根据泛型参数严格检查赋值类型。useStoreuseStore 用于对象类型的响应式状态,推荐用 interface 定义完整结构: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 调用。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 直接使用浏览器原生事件类型: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 类型来约束: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$ 用于在服务端加载数据,泛型参数声明返回数据的类型: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$ 实现运行时类型校验: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: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 作为中间类型再在调用侧收窄: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> );});对于跨文件的类型复用,建议集中管理类型定义:// types.tsexport interface User { id: number; name: string; email: string;}export type UserRole = 'admin' | 'user' | 'guest';export interface ApiResponse<T> { data: T; status: number; message: string;}// component.tsximport { 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$ 函数用于将逻辑限定在服务端执行,其泛型参数约束函数签名:import { server$ } from '@builder.io/qwik-city';const saveData = server$(async (data: { name: string; email: string }): Promise<{ success: boolean }> => { // 仅在服务端执行,可安全访问数据库等 return { success: true };});server$ 返回的函数类型与传入的函数类型一致,调用侧无需感知服务端/客户端边界,TypeScript 会自动推导正确的返回类型。类型断言与类型守卫在事件处理等场景中,类型断言不可避免,但应尽量缩小断言范围:const input = element.querySelector('input') as HTMLInputElement;console.log(input.value);更安全的做法是用类型守卫替代断言: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 适合联合类型、交叉类型和工具类型的组合。// 对象结构用 interfaceinterface ComplexProps { user: { id: number; profile: { name: string; avatar: string; }; };}// 联合类型和交叉类型用 typetype ButtonVariant = 'primary' | 'secondary' | 'danger';type ButtonProps = BaseProps & { variant: ButtonVariant };泛型工具函数可以大幅减少重复类型声明:export const useApi = <T>(url: string) => { return useResource$<T>(() => fetch(url).then(r => r.json()));};类型守卫在运行时校验与编译时类型之间建立桥梁,对于服务端返回的未校验数据尤其重要:function isValidUser(user: unknown): user is User { return typeof user === 'object' && user !== null && 'id' in user;}Qwik 的 TypeScript 集成不只是"能用",而是围绕 QRL 可恢复性架构重新设计了类型系统。掌握 QRL 类型、$ 后缀的语义、以及 import type 的按需加载,才能在 Qwik 项目中写出既类型安全又不拖累加载性能的代码。遇到类型报错时,先检查是否遗漏了 $ 后缀或将 QRL 位置传入了普通函数——这是从其他框架迁移到 Qwik 时最常见的类型陷阱。
前端阅读 05月27日 17:32

Qwik 组件系统的 $ 语法和可恢复性是如何工作的?

Qwik 组件系统的核心设计目标是可恢复性(Resumability)——框架在服务端渲染时将组件的状态和执行上下文序列化到 HTML 中,客户端无需重新执行组件代码即可恢复交互。这和传统 SSR 框架(如 Next.js)的 Hydration 方案有本质区别:Hydration 需要在客户端重新下载和执行组件代码来"重新激活"页面,而 Qwik 只在用户实际交互时才懒加载对应的代码。这个设计目标催生了 Qwik 组件系统中最显眼的特征:$ 语法。$ 语法:可恢复性边界$ 后缀不是语法糖,而是 Qwik 优化器(Optimizer)的编译指令。它标记了一个惰性边界——优化器会将 $ 标记的函数提取为独立的 chunk,按需加载: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$ 包裹: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 与 useStoreuseSignal:简单值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:复杂对象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 节点。事件处理所有事件处理函数必须使用 $ 后缀,否则编译器会报错:export const Button = component$(() => { return ( <button onClick$={() => console.log('clicked')}> Click me </button> );});onClick$ 而非 onClick,这是 Qwik 最容易让 React 开发者踩坑的地方。如果试图传递一个普通函数给事件属性,Qwik 优化器会直接报错,因为普通函数无法被序列化和懒加载。生命周期钩子Qwik 提供三个核心生命周期钩子,都使用 $ 后缀:useTask$:在组件挂载和响应式依赖变化时执行,类似于 React 的 useEffect + useMemo 的结合。可以追踪 Signal 变化并执行副作用: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 三种状态: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 传递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 跨层级通信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: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$:在组件内联作用域样式: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,但运行时性能由编译器保证,而非依赖手动优化。
前端阅读 05月27日 17:32

Qwik 状态管理怎么用?从 useSignal 到 useResource

Qwik 的状态管理围绕一个核心理念:可恢复性(Resumability)。与传统框架在客户端重新执行组件代码来恢复状态不同,Qwik 在服务端渲染时就将状态序列化到 HTML 中,浏览器可以直接从序列化点恢复执行,无需水合(Hydration)。这个设计决策深刻影响了 Qwik 状态管理 API 的形态。useSignal:管理原始值useSignal 是最轻量的响应式状态,适合存储数字、字符串、布尔值等原始类型。它返回一个包含 .value 属性的对象,修改 .value 就能触发更新。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。它会自动追踪对象属性的变化,同样做到细粒度更新。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$。它会在依赖变化时自动重新计算,未变化时返回缓存值。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 消费。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 函数监听状态变化。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 组件可以方便地渲染不同状态。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 的编译器会自动处理细粒度更新和状态序列化,开发者只需关注业务逻辑本身。
前端阅读 05月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$ 在服务端执行,返回的数据自动序列化给客户端组件使用:// routes/products/[id]/index.tsximport { 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 组件作为子路由的渲染出口:// routes/layout.tsximport { 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$ — 服务端数据加载这是最常用的数据加载方式,在服务端执行,适合页面级数据的预获取: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$ — 客户端数据加载当需要在客户端动态获取数据(比如用户交互后刷新)时使用: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$ 在组件内部使用,支持依赖追踪和响应式重新获取: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$ — 服务端表单处理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$ — 客户端表单处理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)环境中。请求拦截// routes/middleware.tsimport { 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();});响应拦截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 信息: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 实现,支持多语言翻译和动态语言切换。服务端配置// src/entry.ssr.tsximport { renderToStream } from '@builder.io/qwik/server';import { Root } from './root';export default function (opts) { return renderToStream(<Root />, { ...opts, containerAttributes: { lang: opts.lang } });}组件内使用翻译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: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 共享数据: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,仅在用户交互时按需加载对应的事件处理器。
前端阅读 05月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 页面,没有框架运行时的启动开销。// 服务端渲染后的 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 对应的代码块并执行。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$() 包裹的组件都会被编译为独立文件:export const Dashboard = component$(() => { return ( <div> <Header /> <Sidebar /> <Content /> <Footer /> </div> );});编译产物:Dashboard.js、Header.js、Sidebar.js、Content.js、Footer.js 各自独立,按需加载。事件处理函数级分割$ 后缀的函数会被提取为独立模块: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 线程中完成,不阻塞主线程// 通过 prefetchStrategy 配置预取行为export default config({ prefetchStrategy: { implementation: { linkInsert: 'js-append', linkHref: (path) => path, workerFetch: true, }, },});预取策略让 Qwik 在"零首屏 JS"和"即时交互响应"之间取得平衡:首屏不加载多余代码,但用户即将交互时代码已经就绪。响应式细粒度更新Qwik 的响应式系统自动追踪状态依赖,只在状态变化时更新受影响的 DOM 节点。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 能做到属性级的精确更新。开发实践中的性能优化选择合适的状态原语// 原始值用 useSignal——轻量,追踪精确const count = useSignal(0);const name = useSignal('');// 对象和数组用 useStore——深层响应式追踪const user = useStore({ name: '张三', settings: { theme: 'dark', language: 'zh-CN' }});useSignal 适合独立原始值,变更时只触发依赖该值的位置更新。useStore 适合嵌套对象,Qwik 会自动追踪到具体哪个属性发生了变化。用 useComputed$ 缓存派生计算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$ 处理异步数据流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$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 阶段报错,且组件进入视口时才触发,避免不可见区域的无谓初始化。避免在渲染路径上创建新引用// 不推荐:每次渲染产生新的对象引用,可能导致不必要的子组件重渲染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 通常低于 50msCLS(Cumulative Layout Shift):累积布局偏移。Qwik 的 SSR 输出完整 DOM 结构,天然避免布局抖动使用 Chrome DevTools 的 Performance 面板或 Lighthouse 可以量化这些指标。Qwik 项目内置的 DevTools 还提供组件树可视化、代码分割视图和状态追踪功能,方便定位性能瓶颈。Qwik 的性能优势不是靠某个单一技巧实现的,而是可恢复性架构、编译时自动分割、事件委托、智能预取、细粒度响应式更新这几项机制协同工作的结果。理解这些原理后,结合上面的开发实践,就能在日常开发中充分发挥 Qwik 的性能潜力。
前端阅读 05月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。<!-- Qwik 编译后的按钮:事件处理程序被替换为引用路径 --><button on:click="./click-handler.js#handleClick">Click me</button>用户点击按钮时,Qwik 才按需下载 click-handler.js 中的 handleClick 函数,而非整个应用。序列化状态到 HTMLQwik 将应用的组件状态、事件监听器定义、组件层次结构等信息序列化后嵌入 HTML,以属性和 <script> 标签的形式存在:<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自动分析组件状态依赖关系,确定需要序列化的数据范围将事件监听器引用转换为可恢复的路径格式开发者编写代码时仍使用熟悉的组件模式,编译器在产出层确保一切符合恢复性架构的要求。
前端阅读 05月27日 17:30

Qwik 和 React 有什么区别?

Qwik 和 React 的核心架构差异是什么?Qwik 和 React 最大的区别在于架构理念:React 基于 虚拟 DOM + 水合(Hydration),Qwik 基于 可恢复性(Resumability)+ 按需加载。这个根本差异直接影响了加载策略、状态管理、性能表现等方方面面。加载策略:全量下载 vs 按需加载React 在页面渲染时,通常需要下载整个应用包(或多个 chunk)。即使使用了 Code Splitting 做懒加载,也需要开发者手动配置:// React 懒加载需要手动配置const LazyComponent = React.lazy(() => import('./HeavyComponent'));function App() { return ( <Suspense fallback={<Loading />}> <LazyComponent /> </Suspense> );}Qwik 的加载策略完全不同——所有 JavaScript 默认都是延迟加载的,只有用户与页面交互时才加载和执行相关代码:// 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 属性直接附加:<!-- Qwik 渲染出的 HTML --><button on:click="/build/bundle-abc.js#handler_xyz"> 点击了 0 次</button>浏览器拿到 HTML 后,当用户点击按钮时,才去加载对应的 JS 函数并执行。页面天然就是可交互的,不需要任何"唤醒"过程:SSR HTML → 页面立即可交互 ↑ 无需额外 JS 执行状态管理:细粒度更新 vs 重新渲染React 使用 useState、useReducer、Context API 管理状态,状态变化会触发组件重新渲染:// 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 节点,不会触发组件重新渲染:// 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 替代 useStateuseTask$ 替代 useEffect编译器自动处理优化,不需要手动写 useMemo / useCallback各自适合什么场景?选择 Qwik 的场景:内容密集型网站(博客、新闻、电商列表页)对首屏加载速度和 SEO 排名有严格要求面向移动端用户或网络条件不稳定的场景大型应用希望减少 JS 体积对性能的影响选择 React 的场景:需要丰富的第三方库和工具支持团队已有 React 经验,迁移成本需要考虑项目复杂度高,需要成熟的架构方案(如 Next.js App Router)快速原型开发,优先开发效率而非极致性能迁移建议如果你正在考虑从 React 迁移到 Qwik,需要注意:Qwik 提供了 qwik-react 集成,可以在 Qwik 应用中逐步引入 React 组件,支持渐进式迁移并非所有 React 生态库都有 Qwik 对应方案,复杂项目建议先做技术评估对于已有 React 项目,迁移优先级应基于性能瓶颈:如果当前应用首屏加载不是痛点,迁移的收益有限Qwik 通过可恢复性架构在首屏性能上建立了明显优势,但 React 凭借成熟的生态和社区仍是更稳妥的选择。具体选型应基于项目对性能、生态和团队能力的综合考量。
前端阅读 05月27日 17:30

Qwik 编译器的工作原理是什么?从代码分割到可恢复序列化

Qwik 之所以能在首屏加载时做到近乎零 JavaScript,核心驱动力就是它的编译器。编译器将开发者编写的组件代码,在构建阶段就拆解成最小可延迟加载的单元,并把运行时状态序列化进 HTML,让浏览器无需重新执行应用即可恢复交互。下面从编译流程、代码分割、序列化机制、元数据生成、优化策略、类型安全与调试七个层面拆解 Qwik 编译器的工作原理。编译流程:从源码到可恢复产物Qwik 编译器(@builder.io/qwik/optimizer)的处理流程分为五个阶段:解析:读入 TypeScript/JSX 源码,构建 AST(抽象语法树)分析:遍历 AST,识别 component$、$ 后缀函数、useSignal 等 Qwik 特有构造,标记懒加载边界转换:将 $ 后缀的函数提取为独立模块,生成懒加载引用替代原位函数体代码生成:输出分割后的 JavaScript 文件与元数据清单优化:应用死代码消除、常量折叠、Tree Shaking 等优化入口调用示例: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。// 原始代码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 "延迟加载一切"策略的实现基础。分割策略可通过配置调整:// qwik.config.tsexport default defineConfig({ optimizer: { entryStrategy: { type: 'smart', // 'smart' | 'hook' | 'inline' manualChunks: { 'vendor': ['lodash'] } } }});smart:编译器自动判断最小分割粒度(推荐)hook:仅分割事件处理函数inline:不做分割,全部内联序列化机制:可恢复性的根基Qwik 编译器最独特的能力是将组件状态序列化进 HTML,使页面在服务端渲染后,客户端无需重新执行 JavaScript 即可恢复交互——这就是 Resumability(可恢复性)。状态序列化export const Counter = component$(() => { const count = useSignal(0); return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}>Increment</button> </div> );});编译器将信号状态直接写入 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 并恢复事件绑定。函数引用序列化编译器将函数引用序列化为路径映射:{ "q-789": { "func": "./handleClick.js#handleClick", "captures": [] }}captures 数组记录闭包捕获的变量引用。如果事件处理函数引用了外部变量,编译器会将这些变量的值一并序列化,确保恢复时闭包上下文完整。元数据生成:q-manifest.json编译器生成 q-manifest.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 的映射,运行时据此查找应加载哪个 chunkbundles:每个 chunk 的体积与包含的符号列表Qwik 运行时在用户交互时,通过 DOM 节点的 data-qwik 属性查 mapping,再查 symbols 定位 chunk 文件,实现精准的按需加载。优化策略死代码消除编译器追踪信号的使用情况,移除未引用的信号和逻辑:// 原始代码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 的静态结构,移除未导出的函数和变量:// 原始代码export const used = () => {};const notUsed = () => {}; // 未导出,被移除// 编译后export const used = () => {};常量折叠与内联对于纯表达式,编译器在构建时求值并替换:// 原始代码const smallFunction$ = () => 1 + 1;export const Component = component$(() => { return <div>{smallFunction$()}</div>;});// 编译后export const Component = component$(() => { return <div>{2}</div>;});类型安全与调试支持TypeScript 集成编译器完全支持 TypeScript 类型检查,包括对 component$ Props 的类型推断: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 支持源码级调试:const result = transform({ code: sourceCode, filename: 'component.tsx', sourceMap: true});开发/生产模式const result = transform({ code: sourceCode, mode: 'development' // 生成详细错误信息与完整的符号名称});开发模式下保留完整的符号名称和详细错误栈,生产模式下压缩为短哈希以减小体积。编译器与 Resumability 的关系理解 Qwik 编译器的关键在于:它不是传统意义上的转译器,而是为 Resumability 服务的预处理工具。传统 SSR 框架(如 Next.js)在服务端渲染 HTML 后,客户端还需要重新下载并执行整个应用的 JavaScript 来"水合"(Hydration)DOM 事件。Qwik 编译器通过三个核心能力彻底避免了这个问题:将函数体提取为独立 chunk,HTML 中只保留路径引用——客户端不需要预先加载事件处理代码将状态序列化进 HTML——客户端不需要重新执行组件来恢复状态生成 manifest 映射——运行时能在用户交互瞬间精准定位并加载所需代码这就是 Qwik 实现"零 Hydration"的编译器层面原理:编译器在构建时完成了传统框架在运行时才做的事情。
前端阅读 05月27日 16:55

Rspack 如何处理 CSS?

Rspack 将 CSS 视为一等公民,内置了完整的 CSS 处理能力,无需像 Webpack 那样依赖 css-loader 和 style-loader。理解 Rspack 的 CSS 处理机制,是从 Webpack 迁移或新建项目时的关键知识。CSS 模块类型Rspack 通过 module.rules 中的 type 字段来区分 CSS 的处理方式,支持四种模块类型:| 类型 | 说明 ||------|------|| css/auto | 根据文件名自动判断:*.module.css 视为 CSS Modules,其余视为普通 CSS || css | 普通 CSS,不启用 CSS Modules || css/global | 以全局作用域模式解析 CSS Modules || css/module | 强制启用 CSS Modules |从 Rspack 0.6.0 起,*.css 文件默认类型从 css 变更为 css/auto,这意味着 style.css 和 style.module.css 可以在同一项目中自动区分处理,无需额外配置:module.exports = { module: { rules: [ { test: /\.css$/i, type: 'css/auto', // 默认值,可省略 }, ], },};CSS ModulesRspack 内置支持 CSS Modules,无需 css-loader。以 .module.css 结尾的文件会被自动识别:/* index.module.css */.container { display: flex;}.active { color: red;}在 JavaScript 中通过命名空间导入使用:import * as styles from './index.module.css';// 使用document.querySelector('.'app').className = styles.container;也支持命名导入:import { active } from './index.module.css';如果需要默认导入(import styles from './index.module.css'),需要关闭 namedExports:module.exports = { module: { rules: [ { test: /\.module\.css$/i, type: 'css/auto', use: [{ loader: 'css-loader', options: { modules: { namedExports: false } }, }], }, ], },};CSS 提取到独立文件生产环境通常需要将 CSS 提取到独立文件。Rspack 提供了内置的 CssExtractRspackPlugin,替代 Webpack 中的 mini-css-extract-plugin:import { rspack } from '@rspack/core';module.exports = { module: { rules: [ { test: /\.css$/i, type: 'css/auto', }, ], }, plugins: [ new rspack.CssExtractRspackPlugin({ filename: 'css/[name].[contenthash].css', chunkFilename: 'css/[id].[contenthash].css', }), ],};注意:CssExtractRspackPlugin 与 Rspack 内置 CSS 类型(css/auto、css、css/module)配合使用,不需要像 Webpack 那样在 loader 链中手动注入提取 loader。如果项目仍依赖 css-loader,可以使用传统方式:import { rspack } from '@rspack/core';module.exports = { module: { rules: [ { test: /\.css$/i, type: 'javascript/auto', // 覆盖内置 CSS 类型 use: [ rspack.CssExtractRspackPlugin.loader, 'css-loader', ], }, ], }, plugins: [ new rspack.CssExtractRspackPlugin({}), ],};CSS 预处理器Rspack 通过对应的 loader 支持主流 CSS 预处理器,处理结果交给 Rspack 内置 CSS 引擎进行后处理。Sass/SCSSmodule.exports = { module: { rules: [ { test: /\.s(?:a|c)ss$/, type: 'css/auto', // 自动识别 *.module.scss use: ['sass-loader'], }, ], },};Lessmodule.exports = { module: { rules: [ { test: /\.less$/, type: 'css/auto', // 自动识别 *.module.less use: ['less-loader'], }, ], },};Stylusmodule.exports = { module: { rules: [ { test: /\.styl$/, type: 'css/auto', use: ['stylus-loader'], }, ], },};关键区别:与 Webpack 不同,Rspack 不需要在 loader 链中加入 css-loader 和 style-loader,预处理器 loader 的输出直接由 Rspack 内置 CSS 引擎接管。PostCSS 与 Tailwind CSS 集成Rspack 通过 postcss-loader 集成 PostCSS 生态,这是接入 Tailwind CSS 等工具的标准方式。基础 PostCSS 配置module.exports = { module: { rules: [ { test: /\.css$/, type: 'css/auto', use: [ { loader: 'postcss-loader', options: { postcssOptions: { plugins: [ require('autoprefixer'), require('cssnano')({ preset: 'default' }), ], }, }, }, ], }, ], },};也可以使用独立的 postcss.config.js 配置文件:// postcss.config.jsmodule.exports = { plugins: [ require('autoprefixer')({ overrideBrowserslist: ['> 1%', 'last 2 versions'], }), ],};Tailwind CSS v4 集成Tailwind CSS v4 采用了新的 PostCSS 插件架构:npm install tailwindcss @tailwindcss/postcss postcss postcss-loader -D// postcss.config.mjsexport default { plugins: { '@tailwindcss/postcss': {}, },};// rspack.config.jsmodule.exports = { module: { rules: [ { test: /\.css$/, use: ['postcss-loader'], type: 'css', }, ], },};CSS 优化Rspack 内置了 CSS 优化能力,生产模式下默认启用:代码压缩:Rspack 使用内置压缩器处理 CSS,也可以通过 CssMinimizerPlugin 自定义压缩策略:const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin({ minimizerOptions: { preset: ['default', { discardComments: { removeAll: true } }], }, }), ], },};Tree Shaking:Rspack 在内置 CSS 处理中支持未使用 CSS 的移除,分析 JavaScript 中的类名引用,只保留实际使用的样式规则。代码分割:配合 splitChunks 可以将 CSS 按策略拆分:module.exports = { optimization: { splitChunks: { cacheGroups: { styles: { type: 'css/mini-extract', name: 'styles', chunks: 'all', enforce: true, }, }, }, },};从 Webpack 迁移 CSS 配置迁移时需要注意的核心差异:移除 css-loader 和 style-loader:Rspack 内置了 CSS 处理,这两个 loader 不再需要替换 mini-css-extract-plugin:使用内置的 rspack.CssExtractRspackPlugin设置模块类型:通过 type: 'css/auto' 替代 loader 链方式控制 CSS Modules 行为experiments.css:在 Rspack 2.0 中内置 CSS 支持默认启用,旧版本可通过 experiments: { css: true } 开启// Webpack 配置 → Rspack 配置// 之前:// { test: /\.css$/, use: ['style-loader', 'css-loader'] }// 之后:{ test: /\.css$/i, type: 'css/auto' }这种简化得益于 Rspack 用 Rust 实现的内置 CSS 解析管线,避免了 Webpack 中多 loader 串联的性能开销。
前端阅读 05月27日 16:54

Rspack 环境变量怎么配置和管理?

Rspack 的环境变量管理是前端工程化中的核心能力,用于区分开发、测试、生产等不同环境的配置。本文从 Rspack 原生插件、Rsbuild 集成、.env 文件、多环境配置、TypeScript 类型安全到 CI/CD 实践,系统讲解环境变量的完整管理方案。环境变量的作用环境变量在构建时注入到代码中,被 Rspack 直接替换为字面量值。这意味着代码中引用 process.env.NODE_ENV 的地方,打包后会被替换为 "production" 或 "development" 字符串,而非运行时读取。这一机制既能实现条件编译,也能配合 tree shaking 移除死代码。Rspack 原生插件配置DefinePluginRspack 内置 DefinePlugin,用法与 webpack 一致,用于将代码中的标识符替换为给定值:const { DefinePlugin } = require('@rspack/core');module.exports = { plugins: [ new DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production'), 'process.env.API_URL': JSON.stringify('https://api.example.com'), 'process.env.VERSION': JSON.stringify('1.0.0') }) ]};值的格式要求:所有值必须用 JSON.stringify() 包裹,因为插件做的是文本替换,不包裹会变成未定义的变量引用。EnvironmentPluginEnvironmentPlugin 是 DefinePlugin 针对 process.env 的语法糖,直接读取系统环境变量:const { EnvironmentPlugin } = require('@rspack/core');module.exports = { plugins: [ new EnvironmentPlugin({ NODE_ENV: 'development', // 默认值,process.env 中没有时使用 DEBUG: false, // 默认值 API_KEY: undefined // undefined 表示必须提供,否则构建报错 }) ]};与 DefinePlugin 的区别:EnvironmentPlugin 自动从 process.env 读取值并应用 JSON.stringify,无需手动处理。用 undefined 作默认值时,变量缺失会报错;用 null 作默认值则变量缺失时静默跳过。Rsbuild 中的环境变量Rsbuild 基于 Rspack 封装,提供了更简洁的环境变量管理方式。默认注入的变量Rsbuild 自动注入以下变量,无需手动配置:// import.meta.env 中可用import.meta.env.MODE // 构建模式:'production' | 'development' | 'none'import.meta.env.DEV // 是否为开发模式import.meta.env.PROD // 是否为生产模式import.meta.env.SSR // 是否为 SSR 模式import.meta.env.BASE_URL // 基础 URLimport.meta.env.ASSET_PREFIX // 资源前缀// process.env 中可用process.env.NODE_ENV // 自动设为 'development' 或 'production'process.env.BASE_URLprocess.env.ASSET_PREFIXsource.define 自定义变量通过 source.define 配置项注入自定义变量,这是 Rsbuild 推荐的方式:export default { source: { define: { 'process.env.CUSTOM_VAR': JSON.stringify('value'), 'import.meta.env.LANGUAGE': JSON.stringify('zh-CN'), }, },};关闭 NODE_ENV 自动注入如果需要自定义 process.env.NODE_ENV 的行为,通过 Rspack 的 optimization.nodeEnv 控制:export default { tools: { rspack: { optimization: { nodeEnv: false }, }, },};手动加载环境变量使用 Rsbuild 的 JavaScript API(非 CLI)时,需要手动调用 loadEnv:import { loadEnv, mergeRsbuildConfig } from '@rsbuild/core';const { parsed, publicVars } = loadEnv();const mergedConfig = mergeRsbuildConfig( { source: { define: publicVars, }, }, userConfig,);.env 文件管理文件加载规则Rsbuild CLI 自动使用 dotenv 加载项目根目录的 .env 文件,加载优先级从高到低:.env.[mode].local — 特定环境的本地覆盖(不提交到 Git).env.local — 本地覆盖(不提交到 Git).env.[mode] — 特定环境的共享配置.env — 所有环境的默认值以 PUBLIC_ 为前缀的变量会暴露到客户端代码中,其他变量仅在 Node 侧可用。可以通过 --no-env 选项禁用自动加载。文件命名示例# .env — 通用默认值PUBLIC_APP_TITLE=MyApp# .env.development — 开发环境PUBLIC_API_URL=http://localhost:3000DEBUG=true# .env.production — 生产环境PUBLIC_API_URL=https://api.example.comDEBUG=false# .env.local — 本地覆盖(加入 .gitignore)API_KEY=your-secret-key.gitignore 配置.env.local.env.*.local提交 .env.development 和 .env.production 方便团队共享,而 .local 后缀的文件仅用于本地敏感配置。命令行传递环境变量直接通过命令行设置环境变量:# Unix/Linux/macOSNODE_ENV=production API_URL=https://api.example.com npx rspack build# Windows(cmd)set NODE_ENV=production&& set API_URL=https://api.example.com&& npx rspack build# 跨平台方案npx cross-env NODE_ENV=production npx rspack buildRsbuild CLI 还支持 --env 参数传递:npx rsbuild build --env production环境变量与 Tree Shaking环境变量的文本替换特性可以标记死代码,帮助 Rspack 在构建时移除不需要的分支:// 源码if (import.meta.env.DEV) { console.log('debug info');}// 生产构建后,整个 if 分支被移除利用这一机制,可以通过自定义变量实现条件编译:export default { source: { define: { 'import.meta.env.LANGUAGE': JSON.stringify('zh-CN'), }, },};// 代码中if (import.meta.env.LANGUAGE === 'zh-CN') { // 仅中文版包含的代码}TypeScript 类型定义为环境变量添加类型声明,避免拼写错误和类型不安全:// env.d.tsdeclare namespace NodeJS { interface ProcessEnv { NODE_ENV: 'development' | 'production' | 'test'; API_URL: string; VERSION: string; DEBUG: string; }}// 或为 import.meta.env 添加类型(Rsbuild 项目)interface ImportMetaEnv { readonly MODE: string; readonly DEV: boolean; readonly PROD: boolean; readonly CUSTOM_VAR: string;}多环境配置方案方案一:配置文件拆分将通用配置和各环境配置拆分为独立文件,通过函数导出按环境合并:// rspack.config.jsmodule.exports = (env) => { const mode = env.mode || 'development'; const envConfig = require(`./rspack.${mode}.js`); return { ...commonConfig, ...envConfig };};// rspack.development.jsmodule.exports = { mode: 'development', devtool: 'eval-cheap-module-source-map', devServer: { hot: true, port: 3000 }};// rspack.production.jsmodule.exports = { mode: 'production', devtool: 'source-map', optimization: { minimize: true }};方案二:条件配置在同一配置文件中根据环境变量条件切换:module.exports = (env) => { const isProduction = env.mode === 'production'; return { mode: isProduction ? 'production' : 'development', plugins: [ new DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(env.mode), 'process.env.IS_PRODUCTION': JSON.stringify(isProduction) }), ...isProduction ? [ new TerserPlugin(), new CompressionPlugin() ] : [] ] };};环境变量的安全实践敏感信息处理// 错误:硬编码密钥const API_KEY = 'sk-xxxxx';// 正确:从环境变量读取const API_KEY = process.env.API_KEY;敏感信息存放在 .env.local 或 CI/CD 的 secrets 中,不提交到版本控制。必需变量验证const requiredVars = ['API_URL', 'API_KEY'];requiredVars.forEach(key => { if (!process.env[key]) { throw new Error(`Missing required environment variable: ${key}`); }});默认值设置const apiUrl = process.env.API_URL || 'http://localhost:3000';const timeout = parseInt(process.env.TIMEOUT || '5000', 10);const debug = process.env.DEBUG === 'true';CI/CD 中的环境变量GitHub Actionsjobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm install - run: npm run build env: NODE_ENV: production API_URL: ${{ secrets.API_URL }}DockerFROM node:20-alpineWORKDIR /appCOPY package*.json ./RUN npm installCOPY . .ARG NODE_ENV=productionARG API_URLENV NODE_ENV=$NODE_ENVENV API_URL=$API_URLRUN npm run buildRspack 性能分析从 Rspack 1.4 开始,通过 RSPACK_PROFILE 环境变量开启构建性能追踪:RSPACK_PROFILE=true npx rspack buildRspack 的环境变量管理覆盖了从开发调试到生产部署的完整链路:DefinePlugin 和 EnvironmentPlugin 提供底层注入能力,Rsbuild 的 source.define 和自动 .env 加载简化日常使用,import.meta.env 系列变量开箱即用,配合 TypeScript 类型声明和条件编译可以构建类型安全、产物精简的前端项目。
前端阅读 05月27日 16:54

Rspack Dev Server 如何配置和使用?

Rspack Dev Server 为本地开发提供热更新、代理、HTTPS 等能力,是日常开发的核心工具。Rspack 2.0 对 Dev Server 做了较大重构:底层从 Express 切换到 connect-next,@rspack/cli 不再自动依赖 @rspack/dev-server,需要手动安装。本文基于 Rspack 2.0,系统讲解 Dev Server 的配置和使用。安装与启动Rspack 2.0 起,@rspack/dev-server 是可选依赖,需手动安装:npm add @rspack/dev-server -D启动开发服务器有两种方式:# 方式一:rspack dev(推荐)npx rspack dev# 方式二:rspack serve(兼容写法)npx rspack serve指定配置文件或端口:npx rspack dev --config rspack.config.js --port 8080在配置文件中声明 Dev Server 选项:// rspack.config.jsmodule.exports = { mode: 'development', devServer: { static: { directory: path.join(__dirname, 'public'), }, compress: true, port: 9000, },};核心功能模块热更新(HMR)HMR 默认在 development 模式下启用,修改代码后页面局部刷新而不需要整页重载:module.exports = { devServer: { hot: true, // 启用 HMR(默认开启) liveReload: false, // 禁用整页自动刷新 },};注意:当 output.cssFilename 包含 [hash] 或 [contenthash] 时,CSS 的 HMR 不会生效。静态文件服务Dev Server 可以为静态资源提供文件服务:module.exports = { devServer: { static: { directory: path.join(__dirname, 'public'), publicPath: '/', serveIndex: true, watch: true, }, },};watch: true 会在静态文件变化时自动刷新页面,serveIndex: true 则允许浏览目录列表。代理配置开发环境常需解决跨域问题,Dev Server 内置了基于 http-proxy-middleware 的代理:module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, pathRewrite: { '^/api': '' }, }, }, },};也支持数组格式,适合更复杂的匹配场景:module.exports = { devServer: { proxy: [ { context: ['/api', '/graphql'], target: 'http://localhost:3000', changeOrigin: true, }, ], },};HTTPS 支持本地开发需要 HTTPS 时(如测试 Service Worker、Secure Cookie 等):const fs = require('fs');module.exports = { devServer: { server: { type: 'https', options: { key: fs.readFileSync('path/to/private.key'), cert: fs.readFileSync('path/to/certificate.pem'), }, }, },};高级配置错误覆盖与客户端日志Dev Server 可以在浏览器中实时显示编译错误,辅助快速定位问题:module.exports = { devServer: { client: { overlay: { errors: true, warnings: false, }, logging: 'warn', }, },};historyApiFallbackSPA 应用需要将所有路由回退到 index.html,避免刷新页面时 404:module.exports = { devServer: { historyApiFallback: { index: '/index.html', rewrites: [ { from: /^\/api/, to: '/404.html' }, ], }, },};文件监听当需要监听源码之外的文件变化(如 PHP 模板、公共资源)时:module.exports = { devServer: { watchFiles: { paths: ['src/**/*.php', 'public/**/*'], options: { usePolling: false, interval: 1000, }, }, },};devMiddleware 选项控制构建输出的写入和日志行为:module.exports = { devServer: { devMiddleware: { index: true, writeToDisk: false, stats: 'minimal', }, },};writeToDisk: false 表示构建产物仅存在内存中,加快速度;stats 控制终端日志的详细程度,可选 none、errors-only、minimal、normal、verbose。自定义中间件Rspack 2.0 的 connect-next 适配Rspack 2.0 将底层从 Express 替换为 connect-next,中间件写法需要适配:module.exports = { devServer: { setupMiddlewares: (middlewares, devServer) => { if (!devServer) { throw new Error('devServer is not defined'); } devServer.app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); return middlewares; }, },};如果项目仍依赖 Express 特有 API(如 req.query 的解析行为),可以显式提供 Express 实例:import express from 'express';export default { devServer: { app: async () => express(), },};WebSocket 配置自定义 WebSocket 连接地址和服务端类型:module.exports = { devServer: { client: { webSocketURL: 'auto://0.0.0.0:0/ws', }, webSocketServer: { type: 'ws', options: { host: 'localhost', port: 8080, }, }, },};也支持使用自定义 WebSocket 客户端实现,继承 BaseClient 类即可。压缩与性能启用 gzip 压缩减少传输体积:module.exports = { devServer: { compress: true, },};配合 devMiddleware 的 stats 选项保持终端输出简洁,能显著提升开发体验。从 Rspack 1.x 迁移Rspack 2.0 的 Dev Server 有几项破坏性变更需要注意:必须手动安装 @rspack/dev-server:@rspack/cli 不再自动包含此依赖底层切换为 connect-next:如果使用了 Express 专有 API,需显式提供 app: async () => express()Node.js 版本要求:Rspack 2.0 要求 Node.js 20.19+ 或 22.12+,不再支持 Node 18ESM-only 发布:@rspack/dev-server 已移除 CommonJS 构建,纯 ESM 包最佳实践开发/生产分离:Dev Server 仅用于开发环境,生产部署使用静态文件服务器代理环境变量化:代理目标地址通过环境变量管理,避免硬编码按需监听文件:watchFiles 只监听必要路径,避免不必要的重编译合理配置日志:生产前关闭 overlay,日常开发使用 errors-only 或 minimalHTTPS 按需开启:仅在需要安全上下文(Service Worker、Secure Cookie 等)时配置 HTTPS
前端阅读 05月27日 16:53

Rspack 如何支持 TypeScript?内置 SWC 编译与类型检查配置

Rspack 通过内置的 SWC 编译器为 TypeScript 提供开箱即用的支持,无需安装 ts-loader 或 babel-loader 等额外依赖,直接导入 .ts 和 .tsx 文件即可完成编译。下面从配置方法、SWC Loader 选项、tsconfig.json 集成、类型检查策略以及常见问题几个方面展开说明。基本配置最小可运行配置一个最简单的 Rspack + TypeScript 项目只需要以下配置:// rspack.config.jsmodule.exports = { entry: './src/index.ts', module: { rules: [ { test: /\.ts$/, use: 'builtin:swc-loader', type: 'javascript/auto', }, ], }, resolve: { extensions: ['.ts', '.js'], },};builtin:swc-loader 是 Rspack 内置的 SWC 加载器,不需要额外安装。SWC 用 Rust 编写,编译速度比 Babel 快 20-70 倍,比 tsc 快 10-30 倍,同时内存占用更低。支持 JSX 的完整配置当项目使用 React + TypeScript 时,需要启用 TSX 解析和 React 自动运行时:// rspack.config.jsmodule.exports = { entry: './src/index.tsx', module: { rules: [ { test: /\.(ts|tsx)$/, use: { loader: 'builtin:swc-loader', options: { jsc: { parser: { syntax: 'typescript', tsx: true, decorators: true, dynamicImport: true, }, transform: { react: { runtime: 'automatic', }, }, }, }, }, type: 'javascript/auto', }, ], }, resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'], },};这里 type: 'javascript/auto' 不可省略,它告诉 Rspack 将 TypeScript 文件作为普通 JavaScript 模块处理。decorators: true 启用装饰器语法支持,dynamicImport: true 启用动态 import() 语法。SWC Loader 进阶配置编译目标与产物体积SWC 默认可能将代码降级到 ES5,导致产物体积偏大。建议显式指定 target 以控制降级范围:{ loader: 'builtin:swc-loader', options: { jsc: { parser: { syntax: 'typescript', tsx: true, }, transform: { react: { runtime: 'automatic', importSource: '@emotion/react', // 配合 CSS-in-JS 库 }, }, target: 'es2022', // 避免不必要的降级,减小产物体积 externalHelpers: true, // 将 helper 函数抽取为外部依赖 }, env: { targets: '> 0.25%, not dead', coreJs: 3, }, sourceMaps: true, },}target: 'es2022' 让 SWC 只在必要时降级语法,比默认的 ES5 产物小很多。externalHelpers: true 将 __spreadArray、__awaiter 等运行时辅助函数抽取到共享模块中,避免每个文件内联一份。env 选项配合 coreJs 可按需注入 polyfill。全局变量替换在构建时替换环境变量可以消除开发代码:jsc: { optimizer: { globals: { vars: { 'process.env.NODE_ENV': '"production"', }, }, },}配合 Dead Code Elimination,if (process.env.NODE_ENV === 'development') 中的代码块会被完全移除。tsconfig.json 集成Rspack 不直接读取 tsconfig.json 来决定编译行为(这是 SWC 的工作),但 TypeScript 语言服务和类型检查器依赖它。一个典型的 Rspack 项目 tsconfig.json 如下:{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src"], "exclude": ["node_modules"]}几个关键配置说明:isolatedModules: true:必须开启。Rspack/SWC 逐文件编译,不做跨模块类型分析,这个选项让 tsc 的行为与 Rspack 对齐,避免导出类型但未实际使用的场景下出现运行时错误。noEmit: true:让 tsc 只做类型检查,不输出文件,因为编译工作由 Rspack 完成。moduleResolution: "bundler":适用于 Rspack 这类打包工具的模块解析策略,支持 package.json 的 exports 字段。paths:路径别名映射,但注意这仅影响 tsc 的类型解析,Rspack 的模块解析需要单独配置 resolve.alias。路径别名在 Rspack 中的配置tsconfig.json 中的 paths 不会自动生效于 Rspack 的模块解析,需要在 rspack.config.js 中添加对应的 alias:const path = require('path');module.exports = { resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, extensions: ['.ts', '.tsx', '.js', '.jsx'], },};两边配置需要保持一致,否则编辑器中不报错但构建时找不到模块。类型检查SWC 只做语法转译,不执行类型检查。类型检查需要额外方案:方案一:ForkTsCheckerWebpackPlugin在构建过程中并行执行类型检查,不影响编译速度:const rspack = require('@rspack/core');module.exports = { plugins: [ new rspack.ForkTsCheckerWebpackPlugin({ typescript: { configFile: './tsconfig.json', memoryLimit: 4096, // MB,大型项目可能需要调高 }, }), ],};开发模式下该插件不会阻塞构建,类型错误会以 overlay 或终端警告的形式展示;生产构建时会阻塞,类型错误将导致构建失败。方案二:独立运行 tsc在 CI/CD 中用 tsc --noEmit 单独执行类型检查,与构建过程完全解耦:{ "scripts": { "build": "rspack build", "typecheck": "tsc --noEmit", "ci": "npm run typecheck && npm run build" }}这种方式更灵活,类型检查不影响构建性能,适合大型项目。类型检查的最佳实践开发环境:使用编辑器内置的 TypeScript 语言服务做实时检查即可,不需要在 dev server 中运行 ForkTsCheckerWebpackPlugin,避免拖慢 HMR 速度。生产构建:启用完整类型检查,阻止类型错误的代码部署。CI/CD:将 tsc --noEmit 作为独立步骤,与构建并行执行,缩短流水线耗时。rspack.config.ts — 用 TypeScript 写配置从 Rspack v1.5.0 开始,CLI 内置了对 TypeScript 配置文件的支持。使用 rspack.config.ts 可以获得完整的类型提示:import type { Configuration } from '@rspack/core';const config: Configuration = { entry: './src/index.tsx', module: { rules: [ { test: /\.(ts|tsx)$/, use: { loader: 'builtin:swc-loader', options: { jsc: { parser: { syntax: 'typescript', tsx: true, }, transform: { react: { runtime: 'automatic' }, }, }, }, }, type: 'javascript/auto', }, ], }, resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'], },};export default config;Rspack CLI 默认使用 --configLoader=auto,会优先尝试原生 TypeScript 支持,失败则回退到 Jiti 转译。常见问题类型声明文件找不到安装对应的 @types 包即可,例如 npm install -D @types/lodash。如果使用自定义类型声明,在 tsconfig.json 的 include 中确保覆盖了声明文件所在目录,或者通过 typeRoots 指定。路径别名在编辑器中正常但构建时找不到模块这是因为 tsconfig.json 的 paths 只影响 TypeScript 语言服务,Rspack 的模块解析依赖 resolve.alias。需要在 rspack.config.js 中添加与 tsconfig.json 一致的 alias 配置。SWC 编译后产物体积比 Webpack 大SWC 默认可能降级到 ES5。在 jsc.target 中指定 'es2022' 或更高版本,同时启用 externalHelpers: true,可以显著减小产物体积。装饰器语法报错在 SWC parser 配置中设置 decorators: true,同时在 tsconfig.json 中配置 "experimentalDecorators": true。如果使用 TC39 Stage 3 装饰器,SWC 需要设置 decorators: true 且 decoratorVersion: "2022-03"。Rspack 的 TypeScript 支持核心在于内置 SWC 编译器带来的零配置转译能力,配合 ForkTsCheckerWebpackPlugin 或独立 tsc 执行类型检查,既能获得极快的构建速度,又能保持完整的类型安全保障。对于从 Webpack 迁移的项目,重点关注 isolatedModules 配置、SWC target 设置和路径别名映射这三处差异即可。
前端阅读 185月27日 01:17

JavaScript 异步解决方案的发展历程及优缺点

JS 异步方案演进:Callback(回调):异步操作完成时调用。缺点:回调地狱(嵌套超过 3 层就难读)、错误处理分散、流程控制难(并行/串行需要自己写计数器)Promise(ES6):链式调用可读性提升,.catch() 统一错误处理,Promise.all/race 等内置并行控制。缺点:长链仍不好读,一旦进入 .then() 链中间没法跳出(无法取消)Generator + co(ES6):yield 暂停执行,配合自动执行器实现"看起来同步"的代码。缺点:需要额外库(co),需要理解 Generator 概念,心智负担高async/await(ES8):Promise + Generator 的语法精华。写法像同步,错误处理用 try-catch,分支和循环直接写。缺点:滥用串行 await 破坏并发性能(两个无关请求应放 Promise.all)追问什么时候不推荐用 async/await?简单场景(单次 .then().catch() 比 async 包装更简洁)需要并发时(多个 await 写在一起是串行的)顶层模块代码中(ES6 模块顶层已有 await,但 CJS 不支持)数组方法中(.map(async fn) 返回 Promise 数组,需要再 Promise.all)Promise 可以取消吗?原生 Promise 不支持取消。但有 AbortController 变通方案:fetch 传入 signal,abort 时 fetch 抛 AbortError,.then() 不会执行。真正意义的 Promise 取消需要第三方库(如 bluebird)或用 RxJS 的 Subscription。
前端阅读 485月27日 01:17

== 和 === 的区别是什么?什么情况下用 == 相等?

=== 是严格相等:类型不同直接 false,类型相同才比较值。== 是宽松相等:类型不同时做类型转换(强制类型转换),然后再比较。大多数场景用 ===。但 == 也有实际用途:if (x == null) —— 等价于 x === null || x === undefined,很简洁你明确知道两端类型相同时(和 === 没区别)处理字符串和数字比较时('5' == 5 是 true),比如从 input 里读出来的值// == 的经典坑'' == 0; // true[] == 0; // true[] == ''; // true[] == ![]; // true (?!)null == undefined; // trueNaN == NaN; // false (即使 === 也是 false)追问Object.is 和 === 有什么区别?两个不同:Object.is(NaN, NaN) 是 true(=== 是 false),Object.is(0, -0) 是 false(=== 是 true)。其他行为和 === 完全一致。if (x == null) 比 if (x === null || x === undefined) 有什么风险吗?几乎没有。== null 只在值为 null 或 undefined 时为 true,对 0、''、false 都是 false。这是 == 唯一一个业界认可的"干净"用法。
前端阅读 255月27日 01:17

JavaScript 的暂时性死区是什么?

暂时性死区(TDZ)是 let 和 const 的特性:从块级作用域开始到变量被声明为止,这个区间内访问变量会抛 ReferenceError。console.log(x); // undefined — var 提升但不报错var x = 1;console.log(y); // ReferenceError — let 有 TDZlet y = 2;let/const 确实有变量提升(引擎知道这个变量存在于作用域中),但在声明语句之前,这个绑定处于"未初始化"状态——这就是 TDZ。var 提升后直接被初始化为 undefined,所以能用。TDZ 的意义:帮你发现"在声明前就使用变量"的错误——这种 bug 用 var 时会被悄悄忽略。追问typeof 在 TDZ 中也会报错吗?会。typeof x 在 x 的 TDZ 中直接 ReferenceError。这是 typeof 唯一不安全的场景——通常 typeof 未声明变量 返回 "undefined" 不会报错,但 TDZ 是"已声明但未初始化",typeof 也会报错。TDZ 对函数参数的默认值有影响吗?有。参数默认值中引用的后面的参数,会遇到 TDZ:function fn(a = b, b = 1) {} // a 的默认值引用 b 时,b 还在 TDZ 中fn(); // ReferenceError
前端阅读 495月27日 01:17

Promise 和 async/await 和 Callback 有什么区别?

三个阶段的异步方案,层层递进:Callback:把后续操作作为回调函数传给异步操作。问题是回调地狱——多层嵌套横向增长,错误处理每个回调都得单独处理。Promise:把回调包装成对象,链式 .then() 解决横向嵌套,.catch() 统一处理错误。但长链仍不够直观,且 .then() 里不能直接用 try-catch。async/await:Promise 的语法糖。async 函数返回 Promise,await 暂停执行等结果。写法就是同步代码的样子,错误用 try-catch。本质还是 Promise——await 的值就是 .then() 回调的参数。// 三个方案的同一操作// CallbackgetData((err, data) => { if (err) return; process(data); });// PromisegetData().then(process).catch(handleError);// async/awaittry { const data = await getData(); process(data); } catch { handleError(); }追问async/await 怎么处理并发请求?Promise.all([fetch1, fetch2]) 配合 await。不要写成 await fetch1(); await fetch2()——这样是串行的,第二个请求等第一个完成才发。async 函数返回的 Promise 和普通 Promise 有区别吗?没有本质区别。async 函数内部抛错等于 reject,return 值等于 resolve。唯一注意的是:async 函数返回的 Promise 是原生 Promise,即使你 return 的是一个 thenable 对象,也会自动包裹成 Promise。
前端阅读 325月27日 01:16

module.exports 和 exports 的区别是什么?export 和 export default 的区别是什么?

两对概念,一个在 CommonJS,一个在 ESModule。module.exports vs exports(CommonJS):module.exports 是真正的导出对象。exports 只是 module.exports 的引用(const exports = module.exports)给 exports 赋新值会断开引用,导出失败;module.exports 赋新值可以安全做法:只添加属性用 exports.foo = bar,需要替换整个导出用 module.exports = foo// 正确module.exports = { a: 1 };exports.b = 2;// 错误 — exports 被重新赋值,断开引用exports = { a: 1 }; // module.exports 还是 {}export vs export default(ESModule):export 是命名导出,可以有多个。导入时用 { name } 且名字必须匹配export default 是默认导出,每个模块只有一个。导入时可以取任意名字一个模块可以同时有命名导出和默认导出// 导入区别import { foo } from './a'; // 命名导出import foo from './a'; // 默认导出import foo, { bar } from './a'; // 两者都有追问为什么 export default 导入可以随意命名?因为默认导出本质上导出的是 { default: value } 这个特殊 key。import x from 就是取 default key 的值。因此也叫 default import。项目中应该优先用命名导出还是默认导出?命名导出更好——IDE 自动补全、refactor 改名时更安全、Tree-Shaking 友好。默认导出适合"这个模块只有一个主要导出"(如一个组件、一个工具函数)。但争议是社区级的,没有绝对的优劣。
前端阅读 125月27日 01:16

一个 DOM 必须要操作几百次,该如何优化?

核心思路:批量操作,减少重排次数。DocumentFragment:创建一个脱离文档流的容器,在内存中构建完所有 DOM 再一次性插入。Fragment 插入后自己会消失,只留下子节点。const fragment = document.createDocumentFragment();for (let i = 0; i < 500; i++) { const li = document.createElement('li'); li.textContent = i; fragment.appendChild(li);}ul.appendChild(fragment); // 一次 DOM 操作display: none:先把容器隐藏,批量改完再显示。隐藏期间的 DOM 操作不触发重排(元素不参与布局计算)。cloneNode:克隆节点,在克隆上做修改,改完后替换原节点。虚拟列表:不是优化 DOM 操作次数,而是减少 DOM 节点总数——几百次操作通常意味着在渲染大量数据。只渲染视口内可见的几十个元素,滚动时复用。追问DocumentFragment 和直接 appendChild 性能差多少?大量操作时差几个数量级。直接 appendChild 每次操作都触发一次重排(元素加入布局树)。Fragment 里的操作不触发重排,只最后 append 时触发一次。为什么不用 innerHTML 一次性拼接字符串?innerHTML 确实比逐个创建 DOM 快,但有 XSS 风险(用户输入会执行脚本)。纯服务端数据可以用 innerHTML,有用户数据用 createElement + textContent。
前端阅读 965月27日 01:16

Koa.js 如何实现文件上传的断点续传?

断点续传的本质是"客户端记住传到哪了,服务端知道从哪继续接"。核心流程:上传前计算文件 hash(MD5/SHA1),作为文件唯一标识发请求到服务端查"这个文件的哪些分片你有了"(返回已上传分片索引)客户端只上传缺失的分片服务端暂存每个分片全部分片上完后,服务端合并分片为完整文件Koa 侧关键点:用 @koa/multer 或直接读 stream 接收分片分片命名规则:{hash}-{index},便于按 hash 查找和按 index 排序合并分片前校验每个分片的大小是否正确合并完后校验完整文件的 hash 是否和客户端一致追问分片大小怎么定?一般 1-5MB。太小请求次数多(HTTP 开销),太大断点续传意义不大了。网速好的用户可以用更大的分片。并发上传多个分片好还是串行好?并发上传更快,浏览器对同一域名的 HTTP/1.1 最大并发是 6 个(HTTP/2 不受限)。注意并发数不能太大——文件 I/O 是性能瓶颈,服务端同时写入大量分片会 IO 打满。合并完大文件后内存会炸吗?不会,用 fs.createWriteStream(流式写入)和 fs.createReadStream(流式读取)顺序追加。Koa 生态有 fs-extra 库做这些操作,底层是流式的不会一次性加载整个文件到内存。
前端阅读 305月27日 01:16

var、let、const 之间的区别是什么?

三个维度的区别:作用域:var 是函数作用域,let/const 是块级作用域。{ } 内部用 let 声明的变量,括号外访问不到。变量提升:var 有提升且初始化为 undefined(声明前访问得到 undefined)。let/const 也有提升但存在暂时性死区(TDZ)——声明前访问直接 ReferenceError。重复声明:var 可重复声明(后覆盖前),let/const 在同一作用域不能重复声明。const 额外特性:声明时必须初始化,且不能重新赋值。但对象和数组的属性可以修改(const 锁的是绑定,不是值)。// var:函数作用域,讨厌的经典 bugfor (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 3 3 3}// let:块级作用域,每次迭代创建新绑定for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 0 1 2}追问为什么 let 能解决 for 循环的回调/闭包问题?var 是整个 for 循环共享一个变量,循环结束后 i 是最终值。let 每次循环迭代都会创建一个新的绑定,每个 setTimeout 捕获的 i 是不同的绑定。即使循环结束后,这些绑定的值仍然保留着当时的 i。const 声明的对象属性为什么可以修改?const 锁定的是变量名到值的绑定关系——"这个变量名不能指向别的值"。对象属性是变量指向的内存地址内部的变更,不改变绑定关系。