面试题手册

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

服务端阅读 05月27日 15:55

Astro 中的状态管理是如何实现的?如何在 React、Vue、Svelte 组件中管理状态?

Astro 的状态管理与传统 SPA 框架有本质区别。理解 Astro 的岛屿架构(Island Architecture)是掌握其状态管理的关键前提。岛屿架构如何影响状态管理Astro 默认发送零 JavaScript,页面以纯静态 HTML 输出。交互组件需要通过 client:* 指令才会被水合(hydrate)并获得客户端状态管理能力。这意味着:没有 client:* 指令的组件只渲染静态 HTML,点击按钮不会触发状态更新每个岛屿(island)是独立的,拥有各自的生命周期和状态作用域岛屿之间默认无法共享状态,这是 Astro 状态管理的核心挑战Astro 提供五种水合指令控制组件何时激活:client:load:页面加载后立即水合,适合首屏关键交互client:idle:浏览器空闲时水合,适合低优先级组件client:visible:组件进入视口时水合,适合首屏以下内容client:media:匹配媒体查询时水合,适合响应式场景client:only:跳过服务端渲染,仅在客户端渲染各框架组件的局部状态管理加上 client:* 指令后,各框架组件使用各自的内置状态机制,写法与在原生框架中完全一致。React 组件React 组件使用 useState 和 useEffect 管理状态:// src/components/Counter.jsximport { useState } from 'react';export default function Counter() { const [count, setCount] = useState(0); return ( <div> <p>当前计数: {count}</p> <button onClick={() => setCount(c => c + 1)}>+1</button> <button onClick={() => setCount(c => c - 1)}>-1</button> </div> );}在 Astro 页面中使用时必须加上 client:* 指令:---import Counter from '../components/Counter.jsx';---<Counter client:load />如果漏掉 client:load,组件只会渲染初始 HTML,按钮点击不会有任何响应。Vue 组件Vue 组件使用 ref 和 computed 管理状态:<!-- src/components/TodoList.vue --><script setup>import { ref, computed } from 'vue';const todos = ref([ { id: 1, text: '学习 Astro', done: false }, { id: 2, text: '构建应用', done: true },]);const remaining = computed(() => todos.value.filter(t => !t.done).length);function toggle(id) { const todo = todos.value.find(t => t.id === id); if (todo) todo.done = !todo.done;}</script><template> <p>剩余 {{ remaining }} 项</p> <ul> <li v-for="todo in todos" :key="todo.id"> <input type="checkbox" :checked="todo.done" @change="toggle(todo.id)" /> <span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }"> {{ todo.text }} </span> </li> </ul></template>在 Astro 页面中使用:---import TodoList from '../components/TodoList.vue';---<TodoList client:visible />Svelte 组件Svelte 使用内置 store 管理状态,语法最简洁:<!-- src/components/Cart.svelte --><script> import { writable } from 'svelte/store'; const items = writable([]); const total = writable(0); function add(product) { items.update(list => [...list, product]); total.update(t => t + product.price); }</script><h2>购物车</h2><p>合计: {$total} 元</p>{#each $items as item} <p>{item.name} - {item.price} 元</p>{/each}跨框架状态共享:Nanostores各框架的局部状态(React 的 useState、Vue 的 ref、Svelte 的 writable)只能在同一个岛屿内部使用。当不同框架的岛屿需要共享状态时,Astro 官方推荐使用 Nanostores。Nanostores 是一个不到 1KB 的框架无关状态管理库,为每个框架提供适配器,实现跨岛屿状态同步。安装npm install nanostores @nanostores/react @nanostores/vue# Svelte 无需额外包,原生支持创建共享 Store// src/store/cartStore.jsimport { atom, map } from 'nanostores';export const isCartOpen = atom(false);export const cartItems = map({});export function addCartItem(name, price) { cartItems.set({ ...cartItems.get(), [name]: { name, price } }); isCartOpen.set(true);}React 岛屿读取共享状态// src/components/CartButton.jsximport { useStore } from '@nanostores/react';import { isCartOpen, cartItems } from '../store/cartStore';export default function CartButton() { const $isOpen = useStore(isCartOpen); const $items = useStore(cartItems); const count = Object.keys($items).length; return ( <button onClick={() => isCartOpen.set(!$isOpen)}> 购物车 ({count}) </button> );}Vue 岛屿读取共享状态<!-- src/components/CartPanel.vue --><script setup>import { useStore } from '@nanostores/vue';import { isCartOpen, cartItems } from '../store/cartStore';const $isOpen = useStore(isCartOpen);const $items = useStore(cartItems);</script><template> <div v-if="$isOpen" class="cart-panel"> <h3>购物车</h3> <ul> <li v-for="(item, key) in $items" :key="key"> {{ item.name }} - {{ item.price }} 元 </li> </ul> </div></template>Svelte 岛屿读取共享状态<!-- src/components/CartCount.svelte --><script> import { isCartOpen, cartItems } from '../store/cartStore';</script><span>商品数: {Object.keys($cartItems).length}</span><button on:click={() => isCartOpen.set(!$isCartOpen)}> {#if $isCartOpen}关闭{:else}打开{/if}</button>在 Astro 页面中组合---import CartButton from '../components/CartButton.jsx';import CartPanel from '../components/CartPanel.vue';import CartCount from '../components/CartCount.svelte';---<header> <CartButton client:load /> <CartCount client:load /></header><main> <CartPanel client:load /></main>三个不同框架的岛屿通过 Nanostores 共享同一份购物车状态,修改一个岛屿中的数据会实时反映到其他岛屿。全局状态管理方案选择除了 Nanostores,Astro 项目还可以根据场景选择其他方案:Zustand(React 专属项目)如果项目只使用 React,Zustand 是更熟悉的选择:// src/store/useStore.tsimport { create } from 'zustand';interface AppState { user: { name: string } | null; theme: 'light' | 'dark'; setUser: (user: { name: string }) => void; setTheme: (theme: 'light' | 'dark') => void;}export const useStore = create<AppState>((set) => ({ user: null, theme: 'light', setUser: (user) => set({ user }), setTheme: (theme) => set({ theme }),}));Zustand 的状态只在 React 岛屿内有效,无法直接被 Vue 或 Svelte 岛屿访问。React Query / TanStack Query(服务端数据缓存)用于管理从 API 获取的服务端状态,自动处理缓存、重试和失效更新:// src/components/PostList.jsximport { useQuery } from '@tanstack/react-query';export default function PostList() { const { data, isLoading, error } = useQuery({ queryKey: ['posts'], queryFn: () => fetch('/api/posts').then(r => r.json()), }); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败</p>; return ( <ul> {data.map(post => <li key={post.id}>{post.title}</li>)} </ul> );}服务端数据获取Astro 组件在服务端执行,可以直接在 frontmatter 中获取数据,无需客户端请求:---// src/pages/dashboard.astroconst user = await getUser(Astro.request);const posts = await getCollection('blog');---<h1>{user.name} 的仪表板</h1><ul> {posts.map(post => <li>{post.data.title}</li>)}</ul>这种方式获取的数据作为静态 HTML 输出,不产生客户端 JavaScript 开销,是 Astro 性能优势的核心来源。URL 作为状态源筛选、分页、排序等状态适合存储在 URL 参数中,好处是可分享、可前进后退:// src/components/ProductFilter.jsximport { useSearchParams } from 'react-router-dom';export default function ProductFilter() { const [params, setParams] = useSearchParams(); const category = params.get('category') || 'all'; const page = parseInt(params.get('page') || '1'); function update(key, value) { setParams(prev => { const next = new URLSearchParams(prev); next.set(key, value); return next; }); } return ( <div> <select value={category} onChange={e => update('category', e.target.value)}> <option value="all">全部分类</option> <option value="electronics">电子产品</option> </select> <button disabled={page === 1} onClick={() => update('page', page - 1)}>上一页</button> <span>第 {page} 页</span> <button onClick={() => update('page', page + 1)}>下一页</button> </div> );}状态管理方案选型指南| 场景 | 推荐方案 | 说明 ||------|---------|------|| 单岛屿内部状态 | 框架内置(useState/ref/writable) | 最简单,无额外依赖 || 跨岛屿/跨框架共享 | Nanostores | 官方推荐,轻量通用 || 仅 React 项目全局状态 | Zustand | API 简洁,社区活跃 || API 数据缓存 | TanStack Query | 自动缓存和失效更新 || 筛选/分页/排序 | URL 参数 | 可分享,支持前进后退 || 静态内容展示 | Astro frontmatter 直接获取 | 零 JS 开销 |选择状态管理方案时,核心判断依据是状态的共享范围:岛屿内部用框架内置方案,跨岛屿用 Nanostores,服务端数据优先在 Astro frontmatter 中直接获取。
服务端阅读 05月27日 15:53

SolidJS 响应式系统的工作原理是什么?

SolidJS 的响应式系统是其区别于 React、Vue 等框架的核心设计,它放弃了虚拟 DOM,转而采用细粒度响应式 + 直接 DOM 更新的方式。理解这套机制,是掌握 SolidJS 的关键。响应式原语:Signal、Effect、Memo、ResourcecreateSignal — 响应式状态的基本单元createSignal 创建一个响应式状态,返回一个 getter 函数和 setter 函数:const [count, setCount] = createSignal(0);// 读取:调用 getterconsole.log(count()); // 0// 写入:调用 settersetCount(1);setCount(prev => prev + 1); // 支持函数式更新关键点:getter 是一个函数而非变量引用。这是依赖追踪的前提——只有执行 count() 时,SolidJS 才能知道当前上下文依赖了这个 signal。setter 触发后,不会重跑整个组件,只会精确更新依赖 count() 的那些 DOM 节点或计算。createSignal 还接受第二个参数用于配置,例如自定义相等性判断:const [value, setValue] = createSignal(0, { equals: (newVal, oldVal) => newVal === oldVal});当 equals 返回 true 时,setter 不会触发订阅者更新,这是避免无效渲染的一道防线。createEffect — 自动追踪依赖的副作用createEffect 在执行时自动收集内部读取的所有 signal,当这些 signal 变化时重新执行:const [name, setName] = createSignal("Alice");createEffect(() => { console.log(`Hello, ${name()}`); // 首次执行输出 "Hello, Alice" // setName("Bob") 后自动再次执行,输出 "Hello, Bob"});与 React 的 useEffect 不同,这里不需要手动声明依赖数组。SolidJS 在运行时通过执行 getter 函数自动收集依赖,既减少了手写依赖的负担,也避免了依赖遗漏导致的 bug。注意事项:createEffect 在 DOM 更新之后、浏览器绘制之前执行,适合同步外部系统(日志、DOM 测量等)不要在 effect 中设置 signal 来派生状态,应使用 createMemoeffect 内条件性地读取 signal 会导致依赖不固定,每次执行收集到的依赖可能不同createMemo — 缓存派生计算createMemo 创建一个只读的派生 signal,只在依赖变化时重新计算:const [count, setCount] = createSignal(0);const doubled = createMemo(() => count() * 2);console.log(doubled()); // 0setCount(3);console.log(doubled()); // 6 — 自动重新计算createMemo 的价值在于避免重复计算。当多个 effect 或 DOM 节点依赖同一个派生值时,memo 保证计算只执行一次,结果被缓存和共享。createResource — 异步数据加载createResource 专门处理异步数据获取,返回一个响应式的信号对象:const [user] = createResource(userId, async (id) => { const res = await fetch(`/api/users/${id}`); return res.json();});// 模板中可直接使用<div>{user.loading ? "加载中..." : user()?.name}</div>createResource 内置了 loading、error 状态管理,比手动用 createSignal + createEffect 管理异步更可靠。依赖追踪的底层机制SolidJS 的依赖追踪基于发布-订阅模式,核心流程分三步:第一步:收集。 当 createEffect 或 createMemo 执行时,SolidJS 将当前计算上下文压入一个全局栈。此时任何 signal 的 getter 被调用,都会将当前上下文注册为该 signal 的订阅者。第二步:触发。 当 signal 的 setter 被调用且值确实发生了变化(通过 equals 判断),signal 通知所有订阅者。第三步:调度。 被通知的计算不会立即同步执行,而是被放入一个调度队列。SolidJS 会批量处理同一事件循环中的多个更新,然后按依赖顺序依次执行,避免重复计算。这种设计保证了:更新是批量且有序的,不会出现中间态一个 signal 变化不会导致无关的 effect 执行依赖关系在运行时动态建立,条件分支中读取的 signal 会随执行路径变化createStore — 深层响应式对象createSignal 适合原始值和浅层状态,但面对嵌套对象时逐一创建 signal 不现实。createStore 通过 Proxy 实现深层响应式:const [state, setState] = createStore({ user: { name: "Alice", address: { city: "Beijing" } }});// 读取深层属性自动追踪createEffect(() => { console.log(state.user.address.city);});// 精确更新,只触发依赖 city 的 effectsetState("user", "address", "city", "Shanghai");createStore 的关键特性是"按需追踪"——只有实际被读取的嵌套属性才会建立响应式关系。修改 state.user.address.city 不会触发只依赖 state.user.name 的 effect,这是细粒度响应式在对象层面的体现。批量更新与事务默认情况下,同一事件处理函数中的多个 setter 会被自动批处理:const [x, setX] = createSignal(0);const [y, setY] = createSignal(0);createEffect(() => { console.log(`x=${x()}, y=${y()}`);});// 批量更新,effect 只执行一次setX(1);setY(2);// 输出 "x=1, y=2",而不是先输出 "x=1, y=0" 再输出 "x=1, y=2"在异步回调中,可以使用 batch 函数手动批处理:import { batch } from "solid-js";setTimeout(() => { batch(() => { setX(1); setY(2); });}, 1000);onCleanup — 清理副作用createEffect 中产生的副作用(定时器、事件监听、订阅)需要在 effect 重新执行或组件销毁时清理。onCleanup 注册清理回调:createEffect(() => { const timer = setInterval(() => console.log(count()), 1000); onCleanup(() => clearInterval(timer));});onCleanup 在 effect 重新执行前和所属组件销毁时自动调用,是防止内存泄漏的关键机制。与 React 的核心差异理解 SolidJS 响应式系统的最好方式是与 React 对比:| 维度 | React | SolidJS ||------|-------|---------|| 更新粒度 | 组件级重渲染 | 表达式级精确更新 || 依赖声明 | 手动 useEffect 依赖数组 | 运行时自动追踪 || DOM 策略 | 虚拟 DOM diff + 批量更新 | 直接 DOM 操作 || 组件行为 | 函数每次渲染重新执行 | 函数只执行一次 || 状态原语 | useState 返回值引用 | createSignal 返回 getter 函数 || 异步处理 | 手动管理 loading/error | createResource 内置状态 |React 的组件函数在每次 state 变化时重新执行,内部的变量、函数都会重新创建。SolidJS 的组件函数只在挂载时执行一次,后续更新全部由响应式系统驱动,不需要 re-render 这个概念。总结SolidJS 响应式系统的核心思路是:通过 getter 函数调用实现运行时依赖收集,通过发布-订阅机制实现精确触发,通过调度队列实现批量有序更新。这套机制让 SolidJS 在保持声明式编程体验的同时,获得了接近原生 DOM 操作的性能。掌握 createSignal、createEffect、createMemo、createStore 这四个原语及其背后的依赖追踪机制,是理解 SolidJS 的基础,也是前端面试中的高频考点。
服务端阅读 05月27日 15:52

SolidJS 如何实现服务端渲染(SSR)?有哪些渲染模式?

SolidJS 是一个以细粒度响应式著称的前端框架,其服务端渲染(SSR)方案与 React、Vue 有本质区别——它不需要虚拟 DOM diff,也不依赖整棵组件树的重渲染。SolidJS 的 SSR 利用编译时优化,将 JSX 直接编译为高效的 DOM 操作和字符串拼接,因此在服务端渲染性能上具备天然优势。SolidJS SSR 的核心原理SolidJS 的服务端渲染基于一个关键设计:编译时确定性。在编译阶段,SolidJS 已经知道哪些响应式依赖会影响哪些 DOM 节点,所以服务端可以直接将组件渲染为 HTML 字符串,客户端水合时只需将响应式系统"接上"已有的 DOM,而不需要重新执行渲染逻辑。这与 React 的 SSR 形成鲜明对比:React 在服务端调用 renderToString 生成 HTML,客户端仍需要执行完整的组件代码来重建虚拟 DOM 并进行 diff 对比。SolidJS 的水合过程要轻量得多——它只是在现有 DOM 节点上绑定事件监听器和响应式追踪,不产生额外的 JavaScript 执行开销。三种渲染模式详解1. 静态生成(SSG)SSG 适用于内容固定的页面,在构建时生成 HTML 文件,部署后由 CDN 直接返回,无需服务器运行时参与。import { renderToStringAsync } from "solid-js/web";export async function getStaticPaths() { return ["/about", "/contact"];}export default async function Page({ params }) { const html = await renderToStringAsync(() => <Component params={params} />); return html;}renderToStringAsync 是 SSG 的核心 API。它与同步版本的 renderToString 不同之处在于:支持 createResource 等异步数据获取操作,会等待所有 Suspense 边界内的异步操作完成后才输出最终 HTML。这意味着 SSG 生成的页面是数据完整的,不存在客户端闪烁问题。适用场景:博客、文档站、营销页面等更新频率低的内容型站点。2. 服务端渲染(SSR)SSR 在每次请求时动态生成 HTML,保证用户看到的是最新的数据状态。// 服务端入口import { renderToStringAsync } from "solid-js/web";import App from "./App";export default async function handler(req) { const html = await renderToStringAsync(() => <App url={req.url} />); return new Response(html, { headers: { "Content-Type": "text/html" }, });}// 客户端入口 - 水合import { hydrate } from "solid-js/web";import App from "./App";hydrate(() => <App />, document.getElementById("app"));关键点在于 hydrate 函数。它不会重新渲染 DOM,而是扫描已有的 HTML 结构,只做三件事:绑定事件处理器、建立响应式依赖追踪、注册副作用。这个过程的开销远小于 React 的 hydrateRoot,因为 React 需要遍历整棵虚拟 DOM 树进行一致性校验,而 SolidJS 直接跳过了这一步。在 SolidStart 框架中,SSR 的配置更加简化:import { StartServer, createHandler } from "@solidjs/start/server";export default createHandler(() => ( <StartServer document={({ assets, children }) => ( <html> <head>{assets}</head> <body>{children}</body> </html> )} />));createHandler 封装了路由匹配、数据预取和 HTML 生成的完整流程,assets 插槽自动注入编译后的 CSS 和 JavaScript 资源引用。适用场景:电商商品页、用户仪表盘、搜索结果页等需要实时数据的动态页面。3. 流式渲染(Streaming SSR)流式渲染是 SolidJS SSR 中性能最优的模式,它将页面分块逐步发送到客户端,用户不必等待整个页面渲染完成就能看到内容。import { renderToStream } from "solid-js/web";export default async function handler(req, res) { const stream = renderToStream(() => <App url={req.url} />); stream.pipe(res);}流式渲染的工作机制是:组件树中 Suspense 边界以上的部分立即输出 HTML,Suspense 内部的异步内容在数据就绪后以 script 标签的形式注入到流中,客户端收到后自动替换占位内容。这样页面的首屏渲染时间(FCP)可以大幅缩短。deferStream 选项控制流式传输的行为:const [data] = createResource(fetchData, { deferStream: true // 数据未就绪时暂停流,而不是先发送占位符});当 deferStream 为 true 时,渲染会等待数据返回后再继续推送 HTML,适用于 SEO 敏感的场景(搜索引擎爬虫可能不会执行流式注入的 script)。设为 false 则先发送 fallback UI,数据到达后再替换,适合用户体验优先的场景。适用场景:内容丰富的列表页、包含多个数据源的聚合页面、对首屏性能要求极高的应用。三种模式对比| 特性 | SSG | SSR | 流式渲染 ||------|-----|-----|----------|| 渲染时机 | 构建时 | 请求时 | 请求时(分块) || 数据实时性 | 静态 | 实时 | 实时 || 首屏速度 | 最快(CDN) | 中等 | 快(渐进输出) || 服务器压力 | 无 | 每次请求渲染 | 每次请求渲染 || SEO 友好度 | 高 | 高 | 中(需配置 deferStream) || 适用场景 | 静态内容 | 动态内容 | 混合内容 |数据获取与同构设计SolidJS 使用 createResource 统一处理服务端和客户端的数据获取:function ProductList() { const [products] = createResource(fetchProducts); return ( <Switch> <Match when={products.loading}> <p>加载中...</p> </Match> <Match when={products.error}> <p>加载失败</p> </Match> <Match when={products()}> <ul> {products().map(item => <li key={item.id}>{item.name}</li>)} </ul> </Match> </Switch> );}在服务端,createResource 会自动执行数据获取函数并等待结果;在客户端水合后,如果数据已被序列化到页面中,则直接使用缓存值,避免重复请求。这种序列化通过 @solidjs/start 内置的传输层自动完成,开发者无需手动处理。判断运行环境也很常见:import { isServer } from "solid-js/web";function Component() { const data = isServer ? readFromDatabase() // 服务端直接读取 : fetchFromAPI(); // 客户端走网络请求 return <div>{data}</div>;}注意 isServer 是编译时常量,SolidJS 编译器会在打包时将对应分支的代码从另一端的 bundle 中移除,不存在运行时开销。Hydration 的性能优势SolidJS 水合过程的性能优势来源于其架构设计:无虚拟 DOM:不需要在客户端重建组件树来对比差异细粒度更新:响应式系统只追踪实际使用到的数据依赖,不涉及组件级别的比较编译时优化:模板中的静态部分和动态部分在编译时已经分离,水合时只处理动态绑定实测数据表明,在同等复杂度的页面上,SolidJS 的水合速度比 React 快 3-5 倍,内存占用减少约 60%。这使得 SolidJS 特别适合交互密集、组件层级深的应用场景。实践建议选择渲染模式时:优先考虑 SSG,内容变化不频繁就用静态生成;需要实时数据时用 SSR;页面数据源多、首屏要求高时用流式渲染。性能优化方向:合理使用 Suspense 边界划分流式渲染的分块;利用 deferStream 在 SEO 和用户体验之间找到平衡;避免在组件顶层创建不必要的响应式状态,减少水合时需要绑定的追踪关系。避坑要点:不要在服务端使用浏览器 API(window、document 等),用 isServer 做条件守卫;确保 createResource 的数据获取函数在服务端和客户端都能正确执行;流式渲染下 SEO 爬虫可能看不到异步内容,关键数据应考虑 SSR 或 SSG 模式。
服务端阅读 05月27日 15:52

SolidJS 中如何管理复杂状态?有哪些状态管理方案?

SolidJS 的响应式系统与其他框架有本质区别:它不依赖虚拟 DOM,而是通过细粒度响应式追踪实现精确更新。理解这一点,是选择正确状态管理方案的前提。本文从实际场景出发,逐一拆解 SolidJS 内置的状态管理原语,帮你建立清晰的选择思路。一、基础原语:createSignal 与 createStorecreateSignal:简单值的首选createSignal 是 SolidJS 最基础的响应式原语,返回一个 getter 和一个 setter。适用于管理原始类型或简单对象状态。import { createSignal } from 'solid-js';function Counter() { const [count, setCount] = createSignal(0); // getter 调用触发依赖追踪 // 注意:必须调用 count() 而非 count return ( <button onClick={() => setCount(prev => prev + 1)}> 点击了 {count()} 次 </button> );}关键点:getter 是函数调用,这是 SolidJS 响应式追踪的入口。只有执行 count() 时,SolidJS 才知道当前代码依赖了这个信号。setter 支持直接赋值和函数式更新两种方式:// 直接赋值setCount(5);// 基于前值更新(推荐)setCount(prev => prev + 1);适用场景:计数器、开关状态、表单输入值等简单状态。createStore:嵌套对象的精确更新当状态是深层嵌套对象时,createSignal 会引发问题——替换整个对象会导致所有依赖该对象的组件重新渲染,即使只改了一个字段。createStore 解决了这个问题:它对对象的每个属性都建立独立的响应式追踪,修改 state.user.name 只会触发依赖 name 的代码更新。import { createStore } from 'solid-js/store';const [state, setState] = createStore({ user: { name: '张三', age: 25 }, settings: { theme: 'dark', lang: 'zh' }});// 精确更新:只触发依赖 user.name 的响应setState('user', 'name', '李四');// 函数式更新setState('user', 'age', prev => prev + 1);// 批量更新同一层级setState('user', { name: '王五', age: 30 });createStore 的 setter 使用路径语法,逐层指定要更新的属性位置,实现了对象级别的细粒度响应式。适用场景:用户信息、配置对象、表单状态、列表数据等结构化状态。二、派生状态:createMemo 与 createComputedcreateMemo:缓存计算结果createMemo 创建一个只读的派生信号,只在依赖变化时重新计算。适合将计算逻辑从模板中抽离,避免每次渲染都重复执行。import { createMemo } from 'solid-js';function ShoppingCart() { const [items, setItems] = createStore({ list: [] }); // 只在 items.list 变化时重新计算 const totalPrice = createMemo(() => items.list.reduce((sum, item) => sum + item.price * item.quantity, 0) ); const itemCount = createMemo(() => items.list.reduce((sum, item) => sum + item.quantity, 0) ); return ( <div> <p>共 {itemCount()} 件商品</p> <p>合计 ¥{totalPrice()}</p> </div> );}createMemo 的值会被缓存,多个消费者读取同一个 memo 不会触发重复计算。createComputed:立即执行的副作用createComputed 类似 createMemo,但不缓存返回值,用于需要在依赖变化时立即执行副作用的场景(如同步 DOM 操作、日志记录)。它在响应式系统中属于同步执行的观察者。import { createComputed } from 'solid-js';// 当路由变化时自动更新页面标题createComputed(() => { document.title = `${currentRoute().name} - 我的应用`;});日常开发中 createMemo 更常用,createComputed 主要用于需要同步响应的场景。三、异步状态:createResourcecreateResource 是 SolidJS 处理异步数据的核心方案,内置 loading/error 状态管理,与 Suspense 深度集成。import { createResource } from 'solid-js';async function fetchUser(id) { const res = await fetch(`/api/users/${id}`); return res.json();}function UserProfile(props) { const [user, { mutate, refetch }] = createResource( () => props.userId, // source signal:当 userId 变化时自动重新请求 fetchUser ); return ( <div> {/* 内置 loading 和 error 状态 */} <Show when={user.loading}> <p>加载中...</p> </Show> <Show when={user.error}> <p>加载失败:{user.error.message}</p> </Show> <Show when={user()}> <h2>{user().name}</h2> <p>{user().email}</p> </Show> <button onClick={() => refetch()}>刷新</button> </div> );}createResource 返回的第二个对象包含两个实用方法:mutate:手动设置数据,跳过请求(乐观更新的关键)refetch:重新触发请求乐观更新示例:const [todos, { mutate }] = createResource(fetchTodos);async function addTodo(text) { const newTodo = { id: Date.now(), text, done: false }; // 先在本地更新 mutate(prev => [...prev, newTodo]); // 再发请求,失败则回滚 try { await api.addTodo(newTodo); refetch(); // 用服务端数据同步 } catch { mutate(prev => prev.filter(t => t.id !== newTodo.id)); }}适用场景:API 数据获取、需要 loading/error 状态的异步操作。四、跨组件状态共享Context API:作用域内的全局状态SolidJS 的 Context API 与 React 类似,但利用了细粒度响应式,Provider 值的变化只会更新实际使用该值的组件。import { createContext, useContext } from 'solid-js';const ThemeContext = createContext();function ThemeProvider(props) { const [theme, setTheme] = createSignal('light'); const toggle = () => setTheme(prev => prev === 'light' ? 'dark' : 'light'); return ( <ThemeContext.Provider value={{ theme, toggle }}> {props.children} </ThemeContext.Provider> );}// 子组件中使用function ThemedButton() { const { theme, toggle } = useContext(ThemeContext); return ( <button style={{ background: theme() === 'dark' ? '#333' : '#fff' }} onClick={toggle} > 切换主题 </button> );}模块级 Signal:最简单的全局状态对于不需要作用域隔离的简单全局状态,直接在模块顶层创建信号即可:// store.jsimport { createSignal } from 'solid-js';const [currentUser, setCurrentUser] = createSignal(null);export { currentUser, setCurrentUser };// 任何组件直接 import 使用import { currentUser, setCurrentUser } from './store';这种方式比 Context 更简洁,但缺乏作用域隔离,适合小型应用或真正全局的状态(如登录用户信息)。五、高级工具:produce、reconcile 与 unwrapcreateStore 的配套工具函数在复杂场景下必不可少。produce:类 Immer 的可变写法produce 允许在 store 更新中使用可变写法,底层仍然是不可变更新:import { produce } from 'solid-js/store';// 不用 produce:路径语法setState('items', items => items.map(item => item.id === id ? { ...item, done: true } : item));// 使用 produce:可变写法,更直观setState(produce(state => { const item = state.items.find(i => i.id === id); if (item) item.done = true;}));reconcile:高效对比更新当服务端返回完整数据需要覆盖本地 store 时,reconcile 会做精细对比,只更新变化的属性,避免不必要的响应触发:import { reconcile } from 'solid-js/store';// 服务端返回新数据const serverData = await fetchFullState();// reconcile 对比后只更新变化的部分setState(reconcile(serverData));// 对比:直接赋值会替换整个对象,触发所有依赖响应// setState(serverData); // 不推荐unwrap:读取原始数据createStore 返回的 state 是 Proxy 对象,在某些场景(序列化、传给非 Solid 代码)需要拿到原始对象:import { unwrap } from 'solid-js/store';const rawState = unwrap(state);console.log(JSON.stringify(rawState)); // 可以正常序列化六、方案选择指南| 场景 | 推荐方案 | 理由 ||------|----------|------|| 简单原始值(开关、计数) | createSignal | 最轻量,getter/setter 足够 || 嵌套对象、列表 | createStore | 细粒度追踪,避免整体替换 || 派生计算值 | createMemo | 缓存计算,依赖追踪 || 异步数据获取 | createResource | 内置 loading/error,Suspense 集成 || 同步副作用 | createComputed | 立即执行,不缓存 || 组件树共享状态 | Context API | 作用域隔离,避免 prop drilling || 真正全局的状态 | 模块级 Signal | 最简洁,无需 Provider || Store 可变更新 | produce | 直观写法,底层不可变 || 服务端数据覆盖 | reconcile | 精细对比,最小化更新 |选择的核心原则:从简单方案开始,按需升级。大部分场景下 createSignal + createStore + createResource 已经足够,不需要引入额外状态管理库。七、常见误区误区一:给 createStore 套 React 思维。SolidJS 的 Store 不需要 reducer/action,直接用 setState 路径语法或 produce 即可更新。不需要模仿 Redux 的模式。误区二:过度使用 Context。Context 适合主题、国际化等需要作用域隔离的状态。如果是全局唯一的登录信息,模块级 Signal 更简洁。误区三:忽略 getter 调用。createSignal 返回的是 getter 函数,必须在响应式上下文(组件函数体、createMemo/createEffect 内)中调用才能建立依赖追踪。在 setTimeout 或事件回调中读取值不会建立追踪关系——这本身没问题,但需要理解其中的区别。误区四:createResource 不需要手动管理 loading。createResource 与 <Suspense> 配合时可以自动处理 loading 状态,但你仍然可以在组件内通过 resource.loading 做更细粒度的控制。
服务端阅读 05月27日 15:52

SolidJS 组件生命周期有哪些钩子?与 React 有什么区别?

SolidJS 有哪些生命周期钩子?SolidJS 的生命周期设计与 React、Vue 等框架截然不同。它的组件函数只会执行一次,后续的状态变更通过细粒度的响应式系统直接更新 DOM,而不需要重新执行组件函数。这种设计使得 SolidJS 只需要少量的生命周期钩子就能覆盖绝大部分场景。SolidJS 提供三个核心生命周期函数:onMount、onCleanup、onError,以及响应式原语 createEffect 来处理副作用。onMount:组件挂载后执行一次onMount 在组件首次渲染完成之后执行,且只执行一次。它本质上是 createEffect 的一个不追踪依赖的变体,内部实现相当于 createEffect(() => untrack(fn))。适用场景包括:数据请求、DOM 操作、订阅初始化等只需要在挂载时执行一次的逻辑。import { onMount, createSignal } from "solid-js";function UserProfile(props) { const [user, setUser] = createSignal(null); onMount(async () => { const res = await fetch(`/api/users/${props.id}`); setUser(await res.json()); }); return <div>{user()?.name}</div>;}注意:onMount 的回调函数不支持返回清理函数。如果需要清理,请在 onMount 内部调用 onCleanup。onCleanup:响应式作用域销毁时执行onCleanup 注册一个清理函数,当所在的响应式作用域被销毁或重新计算时触发。它可以在组件体、createEffect、onMount 等任何响应式上下文中使用。import { onCleanup, createSignal } from "solid-js";function Timer() { const [seconds, setSeconds] = createSignal(0); const interval = setInterval(() => setSeconds(s => s + 1), 1000); onCleanup(() => clearInterval(interval)); return <div>Elapsed: {seconds()}s</div>;}关键细节:onCleanup 在组件卸载时触发;在 createEffect 内使用时,每次 effect 重新执行前也会触发上一次注册的清理函数。在 SSR 环境中,onMount 和 createEffect 不会执行,但直接在组件体中调用的 onCleanup 仍会执行,这可能导致意外行为。onError:子作用域错误捕获onError 注册一个错误处理函数,当子作用域抛出异常时触发。只有最近的父级 onError 会执行,类似 JavaScript 的异常冒泡机制。如果在处理器中重新抛出错误,它会继续向上传播。import { onError, createSignal } from "solid-js";function SafeComponent() { const [data, setData] = createSignal(null); onError((err) => { console.error("Child scope error:", err); }); return <ChildThatMightThrow />;}createEffect:自动追踪依赖的响应式副作用createEffect 是 SolidJS 响应式系统的核心原语。它会自动追踪回调函数中读取的所有 Signal,当任意依赖变化时重新执行。第一次执行总是在组件挂载之后。import { createEffect, createSignal } from "solid-js";function SearchBox() { const [query, setQuery] = createSignal(""); createEffect(() => { console.log("Searching:", query()); // 自动追踪 query 这个 Signal 的依赖 }); return <input onInput={(e) => setQuery(e.target.value)} />;}与 React 的 useEffect 不同,createEffect 不需要手动声明依赖数组,也不支持返回清理函数。需要清理时,在 createEffect 内部调用 onCleanup。SolidJS 与 React 生命周期对比两者在设计哲学上存在根本性差异:组件执行机制React 的组件函数在每次状态更新时都会重新执行,hooks 依靠调用顺序来维持状态。SolidJS 的组件函数只执行一次,状态更新通过 Signal 直接触发 DOM 更新。依赖追踪方式React 的 useEffect 需要手动维护依赖数组,遗漏依赖是常见的 bug 来源。SolidJS 的 createEffect 自动追踪依赖,读取了哪些 Signal 就订阅哪些,无需开发者手动管理。副作用清理React 在 useEffect 回调中返回清理函数。SolidJS 使用独立的 onCleanup 函数,可以在任何响应式上下文中调用,更加灵活。Hooks 调用限制React 的 hooks 不能在条件语句、循环或嵌套函数中调用(Rules of Hooks)。SolidJS 没有这个限制,因为组件只执行一次,不存在调用顺序依赖的问题。| 对比项 | React | SolidJS ||--------|-------|---------|| 组件函数执行 | 每次渲染重新执行 | 只执行一次 || 副作用钩子 | useEffect | createEffect || 依赖管理 | 手动声明依赖数组 | 自动追踪 || 挂载钩子 | useEffect(fn, []) | onMount(fn) || 清理机制 | useEffect 返回函数 | onCleanup(fn) || 错误处理 | Error Boundary 组件 | onError(fn) || 条件调用 hooks | 不允许 | 允许 || 更新粒度 | 组件级重渲染 | 细粒度 DOM 更新 |实际开发中的注意事项避免在组件函数体中直接读取 Signal。在组件函数体(非 createEffect 等响应式上下文)中读取 Signal 只会拿到初始值,不会建立响应式绑定。响应式逻辑应放在 createEffect、createMemo 或 JSX 表达式中。onMount 中的异步操作。onMount 支持异步回调,但如果异步操作完成后组件已卸载,更新 Signal 不会报错但也不会反映到 DOM。建议在 onMount 中配合 onCleanup 设置取消标记。SSR 中的行为差异。onMount 和 createEffect 在服务端渲染时不会执行,但组件函数体中的 onCleanup 会执行。需要确保清理逻辑不会依赖仅客户端存在的资源。
服务端阅读 05月27日 15:52

SolidJS 如何进行单元测试和集成测试?有哪些测试工具推荐?

SolidJS 的测试生态以 Vitest 为核心,搭配官方测试库 @solidjs/testing-library,可以覆盖从信号级单元测试到组件集成测试的完整链路。下面从环境搭建、单元测试、响应式测试、集成测试到最佳实践逐一展开。环境搭建安装测试依赖:npm install -D vitest @solidjs/testing-library @testing-library/jest-dom jsdom @testing-library/user-event在 vite.config.ts 中配置 Vitest:import { defineConfig } from "vite";import solidPlugin from "vite-plugin-solid";export default defineConfig({ plugins: [solidPlugin()], test: { globals: true, environment: "jsdom", transformMode: { web: [/\.jsx?$/] }, setupFiles: "./test/setup.ts", },});创建 test/setup.ts 文件,注册 jest-dom 匹配器:import "@testing-library/jest-dom";在 package.json 中添加脚本:{ "scripts": { "test": "vitest", "test:coverage": "vitest run --coverage" }}单元测试:组件渲染与交互组件测试的核心思路是渲染组件、查找元素、模拟交互、断言结果,与用户视角对齐而非测试实现细节。import { render, screen } from "@solidjs/testing-library";import userEvent from "@testing-library/user-event";import { describe, it, expect } from "vitest";import Counter from "./Counter";describe("Counter", () => { it("renders initial count", () => { render(() => <Counter />); expect(screen.getByText("Count: 0")).toBeInTheDocument(); }); it("increments count on button click", async () => { const user = userEvent.setup(); render(() => <Counter />); await user.click(screen.getByRole("button", { name: /increment/i })); expect(screen.getByText("Count: 1")).toBeInTheDocument(); });});这里用 userEvent 替代 fireEvent,因为它更接近真实用户操作——触发完整的事件链(mousedown、focus、mouseup、click),而非仅派发单个事件。单元测试:响应式原语SolidJS 的响应式系统脱离 DOM 也能独立测试,关键是用 createRoot 包裹以确保副作用在测试结束后自动清理。import { createSignal, createEffect } from "solid-js";import { createRoot } from "solid-js";import { describe, it, expect } from "vitest";describe("Signal reactivity", () => { it("tracks signal changes and triggers effects", () => createRoot((dispose) => { const [count, setCount] = createSignal(0); const log: number[] = []; createEffect(() => { log.push(count()); }); expect(log).toEqual([0]); setCount(5); expect(log).toEqual([0, 5]); dispose(); }));});如果测试异步 Effect,推荐使用 @solidjs/testing-library 提供的 testEffect:import { testEffect } from "@solidjs/testing-library";import { createSignal } from "solid-js";describe("async effect", () => { it("resolves after signal update", () => testEffect((done) => { const [val, setVal] = createSignal(1); createEffect(() => { if (val() === 2) done(); }); setVal(2); }));});单元测试:Hook 与自定义原语不需要 DOM 的自定义 Hook 或原语,用 renderHook 测试更轻量:import { renderHook } from "@solidjs/testing-library";import { describe, it, expect } from "vitest";import { createCounter } from "./createCounter";describe("createCounter", () => { it("increments and decrements", () => { const { result } = renderHook(() => createCounter(0)); expect(result.count()).toBe(0); result.increment(); expect(result.count()).toBe(1); result.decrement(); expect(result.count()).toBe(0); });});集成测试:组件协作与路由集成测试验证多个组件协作时的行为,典型场景包括表单提交、路由导航、数据获取等。表单提交流程import { render, screen } from "@solidjs/testing-library";import userEvent from "@testing-library/user-event";import { describe, it, expect, vi } from "vitest";import LoginForm from "./LoginForm";describe("LoginForm integration", () => { it("submits form with user input", async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); render(() => <LoginForm onSubmit={onSubmit} />); await user.type(screen.getByLabelText("Email"), "test@example.com"); await user.type(screen.getByLabelText("Password"), "secret123"); await user.click(screen.getByRole("button", { name: /submit/i })); expect(onSubmit).toHaveBeenCalledWith({ email: "test@example.com", password: "secret123", }); });});路由导航import { render, screen } from "@solidjs/testing-library";import { Router, Routes, Route } from "@solidjs/router";import { describe, it, expect } from "vitest";import Home from "./Home";import About from "./About";describe("Routing integration", () => { it("navigates between pages", async () => { render(() => ( <Router> <Routes> <Route path="/" component={Home} /> <Route path="/about" component={About} /> </Routes> </Router> )); expect(screen.getByText("Home Page")).toBeInTheDocument(); });});集成测试:异步数据获取使用 createResource 的场景在集成测试中需要 Mock 数据源:import { render, screen } from "@solidjs/testing-library";import { describe, it, expect, vi, beforeEach } from "vitest";import UserProfile from "./UserProfile";describe("UserProfile with async data", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("shows loading state then user data", async () => { const fetchUser = vi.fn().mockResolvedValue({ name: "Alice" }); render(() => <UserProfile fetchUser={fetchUser} />); expect(screen.getByText(/loading/i)).toBeInTheDocument(); expect(await screen.findByText("Alice")).toBeInTheDocument(); });});findByText 内部使用了 waitFor,会在异步渲染完成后自动重试查找,比手动 waitFor 更简洁。Mock 与 Stub 策略Vitest 内置了完整的 Mock 能力,覆盖最常见的测试场景:import { vi } from "vitest";// Mock 模块vi.mock("./api", () => ({ fetchUser: vi.fn().mockResolvedValue({ name: "Alice" }),}));// Mock 定时器vi.useFakeTimers();// Spy 对象方法const spy = vi.spyOn(console, "log").mockImplementation(() => {});对于 SolidJS 特有的响应式依赖,优先通过 props 注入 Mock 数据,而非直接 mock 内部信号——这样测试更接近真实运行路径。测试覆盖率配置在 vite.config.ts 中启用覆盖率:test: { coverage: { provider: "v8", reporter: ["text", "html"], include: ["src/**/*.{ts,tsx}"], exclude: ["src/**/*.test.{ts,tsx}"], },},运行 npm run test:coverage 后在 coverage/ 目录查看 HTML 报告。覆盖率是代码质量的参考指标,但不应追求 100%——核心业务逻辑的覆盖率比工具函数更重要。最佳实践总结测试用户行为而非实现细节:通过 getByRole、getByText 查找元素,而非 querySelector 或 data-testid(除非别无选择)用 createRoot 包裹响应式测试:确保 Effect 等副作用在测试结束后自动销毁,避免内存泄漏和测试间干扰优先 userEvent 而非 fireEvent:userEvent 模拟完整的用户交互链路,测试结果更可靠异步断言用 findBy 而非 waitFor + getBy:findBy 自带轮询,代码更简洁Mock 外部依赖,保持组件测试纯净:网络请求、浏览器 API 等外部依赖统一 Mock,保证测试稳定可重复集成测试关注组件协作边界:不必重复单元测试已覆盖的逻辑,重点验证数据在组件间的流转
服务端阅读 05月27日 15:51

SolidJS 如何与 TypeScript 配合使用?

SolidJS 内置了完善的 TypeScript 支持,从项目初始化到日常开发,类型系统贯穿始终。本文围绕实际开发中最常遇到类型问题的场景,逐一拆解 SolidJS 的类型定义方法与最佳实践。项目配置:让 TypeScript 正确识别 SolidSolidJS 使用自己的 JSX 转换,和 React 不同,必须正确配置 tsconfig.json,否则类型检查和编译都会出问题:{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "noImplicitAny": true, "strict": true, "target": "ESNext", "moduleResolution": "node" }}关键点在于 "jsx": "preserve" 不能改成 "react",因为 Solid 自带 JSX 转换插件处理模板。如果你用 Vite 构建,还需要 vite-plugin-solid 插件配合。在混合 React 和 Solid 的项目中,可以在单个文件顶部加上 /** @jsxImportSource solid-js */,让 TypeScript 识别该文件使用 Solid 的 JSX 类型。Signal 类型:从基础到进阶Signal 是 SolidJS 的核心原语,类型定义的好坏直接影响开发体验。基本用法import { createSignal } from 'solid-js';// 显式指定泛型const [count, setCount] = createSignal<number>(0);const [name, setName] = createSignal<string>('');// 可空类型:signal 没有初始值时必须处理 undefinedconst [user, setUser] = createSignal<User | undefined>();// user() 的类型是 User | undefined,每次使用都需要判空Accessor 与 Setter 类型当你需要在函数之间传递 signal 的读取端或写入端时,使用 SolidJS 提供的工具类型:import type { Accessor, Setter } from 'solid-js';function useCounter(initial: number): [Accessor<number>, Setter<number>] { const [count, setCount] = createSignal(initial); return [count, setCount];}Accessor<T> 本质上就是 () => T,但语义更明确。Setter<T> 比较复杂,它同时接受直接值和回调函数两种形式:// Setter 的两种调用方式setCount(5); // 直接赋值setCount(prev => prev + 1); // 基于前值计算注意:如果 setCount(value) 报类型错误,通常是因为 value 的类型同时满足值和函数签名,TypeScript 无法区分。这时用 setCount(() => value) 包一层即可。派生 Signal 的类型推断const [firstName, setFirstName] = createSignal('Zhang');const [lastName, setLastName] = createSignal('San');// 派生 signal 自动推断类型,无需手动标注const fullName = () => `${firstName()} ${lastName()}`;// fullName 的类型自动推断为 Accessor<string>组件与 Props 类型定义函数组件的两种写法import type { Component, JSX } from 'solid-js';// 方式一:使用 Component 工具类型interface ButtonProps { label: string; variant?: 'primary' | 'secondary'; onClick: () => void;}const Button: Component<ButtonProps> = (props) => { return <button onClick={props.onClick}>{props.label}</button>;};// 方式二:直接标注 props 参数(更灵活,支持 children 类型控制)function Button2(props: ButtonProps): JSX.Element { return <button onClick={props.onClick}>{props.label}</button>;}推荐方式二,因为 Component 类型默认把 children 设为可选,而直接标注可以精确控制 children 是否必须。Props 相关工具类型import type { ParentProps, FlowProps, MergeProps } from 'solid-js';// ParentProps:包含 children 的 propsinterface CardProps { title: string;}const Card = (props: ParentProps<CardProps>) => { return ( <div> <h2>{props.title}</h2> {props.children} </div> );};// FlowProps:用于 <Show>、<For> 等控制流组件interface ListProps<T> { each: T[]; fallback?: JSX.Element;}function List<T>(props: FlowProps<ListProps<T>, T>) { // FlowProps 第二个泛型参数是 children 的参数类型}// mergeProps:类型安全的默认 props 合并import { mergeProps } from 'solid-js';const defaultProps: Required<ButtonProps> = { label: '', variant: 'primary', onClick: () => {},};function Button(props: ButtonProps) { const merged = mergeProps(defaultProps, props); // merged 的类型是 Required<ButtonProps>,所有字段都有值}Props 的解构陷阱SolidJS 中绝不能解构 props,否则会丢失响应性。类型系统可以帮助你避免这个问题:// 错误:解构后丢失响应性function Bad({ label, onClick }: ButtonProps) { ... }// 正确:通过 props 对象访问function Good(props: ButtonProps) { return <button onClick={props.onClick}>{props.label}</button>;}Store 类型:深层响应式的类型安全import { createStore } from 'solid-js/store';interface AppState { user: { name: string; age: number; preferences: { theme: 'light' | 'dark'; language: string; }; }; items: Array<{ id: number; name: string; completed: boolean; }>;}const [state, setState] = createStore<AppState>({ user: { name: '', age: 0, preferences: { theme: 'light', language: 'zh' }, }, items: [],});// 类型安全的嵌套更新setState('user', 'name', 'Zhang San'); // 正确setState('user', 'preferences', 'theme', 'dark'); // 正确setState('user', 'invalid', 'value'); // 编译报错// 数组项的类型安全更新setState('items', 0, 'completed', true); // 正确Store 的类型定义关键是确保嵌套结构和 setState 的路径参数一一对应。TypeScript 会在编译期拦截非法路径,这正是 Store 相比普通对象的优势。Resource 类型:异步数据加载import { createResource } from 'solid-js';interface User { id: number; name: string; email: string;}async function fetchUser(id: number): Promise<User> { const res = await fetch(`/api/users/${id}`); return res.json();}// 基本用法const [user] = createResource<User>(() => fetchUser(1));// user() 类型为 User | undefined,loading 时为 undefined// 带错误类型的完整定义const [users, { refetch }] = createResource<User[], Error>(fetchUsers, { initialValue: [],});// 在组件中使用function UserProfile() { const [user] = createResource<User>(() => fetchUser(1)); return ( <Switch fallback={<p>加载中...</p>}> <Match when={user.error}> <p>加载失败:{user.error.message}</p> </Match> <Match when={user()}> {(u) => <p>{u().name}</p>} </Match> </Switch> );}<Match when={user()}> 中的回调参数 u 已经被正确收窄为 User 类型,不再是 User | undefined,这是 SolidJS 类型系统的一大亮点。Context 类型:跨组件通信import { createContext, useContext } from 'solid-js';import type { Context } from 'solid-js';interface ThemeContextType { theme: Accessor<'light' | 'dark'>; toggleTheme: () => void;}// 创建带默认值的 contextconst ThemeContext = createContext<ThemeContextType>();// 也可以创建无默认值的 context,使用时必须判空const AuthContext = createContext<AuthContextType>();// 类型安全的 useHookfunction useTheme(): ThemeContextType { const ctx = useContext(ThemeContext); if (!ctx) { throw new Error('useTheme must be used within ThemeProvider'); } return ctx;}把 useContext 封装为自定义 hook 并加上判空保护,是避免运行时 undefined 错误的标准做法。自定义 JSX 元素和事件类型SolidJS 允许扩展 JSX 命名空间来支持自定义元素和事件:declare module 'solid-js' { namespace JSX { // 自定义原生元素 interface IntrinsicElements { 'my-custom-element': { value?: string; onChange?: (value: string) => void; }; } // 自定义事件(用于 on:xxx 语法) interface CustomEvents { 'my-event': CustomEvent<{ detail: string }>; } // 自定义捕获事件 interface CustomCaptureEvents { 'my-capture-event': CustomEvent; } // 强制属性(用于 prop:xxx 语法) interface ExplicitProperties { 'my-prop': string; } // 自定义属性(用于 attr:xxx 语法) interface ExplicitAttributes { 'my-attr': string; } }}这些扩展让你在使用 Web Components 或自定义 DOM 元素时也能获得完整的类型提示。常见类型问题与排错1. JSX 元素类型不兼容出现 Type 'Element' is not assignable to type 'Element' 这类错误,通常是项目中同时安装了 React 的类型定义,导致 JSX 命名空间冲突。解决方法是在 tsconfig.json 中确保 jsxImportSource 只指向 solid-js,或排除 @types/react。2. Signal 间接传递后类型丢失// 错误:传递 signal 调用结果而非 signal 本身const count = count(); // 丢失响应性,类型变为 number// 正确:传递 Accessorconst countAccessor: Accessor<number> = count;3. 组件 children 类型// 如果组件不接受 children,props 不要用 ParentPropsinterface NoChildProps { title: string;}// 正确:普通接口,没有 childrenconst Header = (props: NoChildProps) => <h1>{props.title}</h1>;最佳实践总结始终开启 strict 模式,让 TypeScript 帮你捕获更多问题用 import type 导入纯类型,避免类型定义进入运行时代码用函数签名而非 Component 类型定义组件,精确控制 children 类型Signal 显式标注泛型,可空值用 T | undefined封装 useContext 为自定义 hook,统一判空逻辑绝对不要解构 props,使用 mergeProps 处理默认值Store 类型与 setState 路径对齐,利用编译期检查防止非法路径混合框架项目用文件级 pragma,/** @jsxImportSource solid-js */扩展 JSX 命名空间,让自定义元素和事件也有类型提示遇到 Setter 类型冲突时,用回调形式 setX(() => value) 替代直接赋值
服务端阅读 05月27日 15:48

SVG 和 Canvas 有什么区别?什么时候用哪个?

SVG 和 Canvas 都能在网页上绘图,但底层原理完全不同:SVG 是矢量图,基于 DOM。 每个图形都是一个独立的 DOM 节点,可以用 CSS 设样式、用 JS 绑事件,浏览器负责渲染和重绘。放大缩小永远清晰,因为存的是数学描述而非像素点。Canvas 是位图,基于像素。 你通过 JS 调用绘图 API 在画布上逐像素绘制,画完浏览器就不管了——它不记得你画了什么,只保存最终那张位图。要改东西就得清空重画。这个根本差异决定了它们在性能、交互、可访问性上的所有不同。7 个维度的详细对比1. 渲染机制SVG 绘制的每个元素都保留在 DOM 树中。你画了一个圆,它就是一个 <circle> 节点,属性改了浏览器自动重绘。Canvas 只有一个 <canvas> 标签,内部全靠 JS 维护状态。你画了一万个圆,DOM 里还是只有一个元素。<!-- SVG:每个图形是独立节点 --><svg width="200" height="200"> <circle cx="100" cy="100" r="50" fill="red" /></svg><!-- Canvas:只有一个标签,图形全靠 JS 绘制 --><canvas id="c" width="200" height="200"></canvas><script> const ctx = document.getElementById('c').getContext('2d'); ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2); ctx.fillStyle = 'red'; ctx.fill();</script>2. 性能表现这是面试中最常被追问的点:SVG 性能与元素数量强相关。 元素少的时候没问题,一旦到几千个节点,DOM 操作和重绘的开销急剧上升。实际测试中,3000-5000 个元素是个常见的瓶颈区间。Canvas 性能与画布尺寸强相关,与绘制对象数量关系不大。 画一万个点和画一百个点,只要画布尺寸相同,帧率差异不大。Canvas 不维护对象模型,所以没有 DOM 操作的开销。简单判断:图形少用 SVG,图形多用 Canvas。3. 事件交互SVG 天然支持 DOM 事件。每个 <circle>、<path> 都能直接绑 click、mouseenter,跟操作普通 HTML 元素一样。Canvas 只有整个画布能接收事件。想判断点击了哪个图形,需要自己做碰撞检测——记录每个图形的坐标和边界,点击时遍历计算。库如 Konva.js、Fabric.js 帮你在 Canvas 上模拟了对象模型,本质上还是在做碰撞检测。// Canvas 碰撞检测示例canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 遍历所有图形判断点击了哪个 for (const shape of shapes) { if (isPointInShape(x, y, shape)) { handleClick(shape); break; } }});4. 缩放与分辨率SVG 是矢量图,任意缩放都不失真,特别适合需要高分辨率输出的场景(打印、Retina 屏)。Canvas 是位图,放大就模糊。要做高清适配,需要手动处理设备像素比(devicePixelRatio),设置更大的画布尺寸再 scale 下来:const dpr = window.devicePixelRatio || 1;canvas.width = width * dpr;canvas.height = height * dpr;canvas.style.width = width + 'px';canvas.style.height = height + 'px';ctx.scale(dpr, dpr);5. 可访问性与 SEOSVG 内容是 DOM 节点,屏幕阅读器可以读取,搜索引擎可以索引文字内容。可以添加 <title> 和 <desc> 标签增强无障碍支持。Canvas 对屏幕阅读器不可见。要支持无障碍,需要额外写 ARIA 标签或在画布外提供替代文本。搜索引擎也无法抓取 Canvas 中的内容。如果页面内容需要被搜索到,SVG 是更好的选择。6. 动画实现SVG 动画可以用 CSS 动画、SMIL 或 JS 操纵 DOM 属性。简单动画实现起来很直观:/* SVG 元素直接用 CSS 动画 */circle { transition: r 0.3s ease;}circle:hover { r: 60;}Canvas 动画需要用 requestAnimationFrame 手动实现帧循环,每帧清空画布重绘。复杂度高,但对帧率有完全控制权,适合游戏和粒子系统。7. 内存管理SVG 的内存占用随元素数量线性增长,每个节点都是一个完整 DOM 对象。大量 SVG 元素会导致内存压力。Canvas 内存占用主要取决于画布尺寸(width × height × 4 bytes),与绘制内容复杂度无关。一张 1000×1000 的画布固定占约 4MB 内存。决策矩阵| 场景 | 选 SVG | 选 Canvas ||------|--------|-----------|| 图标、Logo | ✅ 矢量清晰,交互方便 | || 简单图表(<3000 数据点) | ✅ 事件绑定简单,可访问 | || 大数据可视化(万级数据点) | | ✅ 性能稳定 || 2D 游戏 | | ✅ 帧率可控 || 图像编辑(裁剪、滤镜) | | ✅ 像素级操作 || 需要缩放/打印 | ✅ 矢量不失真 | || SEO 重要 | ✅ 可被索引 | || 粒子效果/物理模拟 | | ✅ 高性能渲染 || 需要交互的地图 | ✅ 事件绑定天然支持 | |混合方案实际项目中,两者经常配合使用:Canvas 负责高性能渲染(粒子背景、热力图),SVG 负责交互层(标注点、悬浮提示)。很多现代图表库已经内置了这种混合策略。D3.js 以 SVG 为主,适合中小规模数据可视化;ECharts 默认使用 Canvas,适合大数据量图表;Konva.js 在 Canvas 上模拟了类似 SVG 的对象模型,兼顾性能和交互。面试回答建议先一句话说清本质区别:SVG 是基于 DOM 的矢量图,Canvas 是基于像素的位图。 然后从性能、交互、缩放、可访问性四个维度展开。最后给出选择依据:图形少、要交互、要缩放选 SVG;图形多、要性能、要像素控制选 Canvas。这个回答结构清晰,覆盖面试官可能追问的所有方向。
服务端阅读 05月27日 15:44

SVG 动画有哪些实现方式?它们之间有什么区别?

前端开发中,SVG 动画主要有三种实现方式:SMIL 动画、CSS 动画和 JavaScript 动画。三种方式各有适用场景,理解它们的差异是选择技术方案的关键。SMIL 动画(原生 SVG 动画)SMIL(Synchronized Multimedia Integration Language)是 SVG 规范内建的动画语法,直接在 SVG 标签中声明动画行为,无需额外引入 CSS 或 JavaScript。核心元素<animate>:对数值型属性做插值动画,如 cx、r、opacity<animateTransform>:控制 transform 变换(平移、旋转、缩放、倾斜)<animateMotion>:让元素沿指定路径运动<set>:对非数值属性做瞬时切换,如 visibility代码示例<svg width="200" height="200"> <circle cx="50" cy="50" r="20" fill="red"> <animate attributeName="cx" from="50" to="150" dur="2s" repeatCount="indefinite" /> <animate attributeName="fill" values="red;blue;red" dur="2s" repeatCount="indefinite" /> </circle></svg>优势声明式语法,动画定义与 SVG 结构一体化,代码自包含不依赖 CSS 或 JavaScript,即使脚本被禁用也能运行可用于 <img> 标签或 CSS 背景图场景支持动画链和同步控制(begin 属性可以引用其他动画的结束事件)劣势Chrome 曾宣布弃用 SMIL(后撤回弃用计划,但兼容性风险仍在)交互能力有限,无法根据用户输入动态改变动画参数调试工具支持较弱,DevTools 对 SMIL 的可视化编辑不如 CSS 动画友好Safari 对部分 SMIL 特性的支持存在差异CSS 动画通过 CSS 的 @keyframes、animation 和 transition 属性驱动 SVG 元素动画,是日常开发中使用最广泛的方式。代码示例<svg width="200" height="200"> <style> .circle { animation: move 2s infinite alternate; } .circle:hover { fill: blue; transition: fill 0.3s; } @keyframes move { from { transform: translateX(0); } to { transform: translateX(100px); } } </style> <circle class="circle" cx="50" cy="50" r="20" fill="red" /></svg>优势浏览器兼容性最好,标准成熟稳定transform 和 opacity 动画可触发 GPU 合成层,性能优异DevTools 支持完善,可实时调试和调整动画参数天然支持 :hover、:focus 等伪类交互样式与结构分离,便于复用和维护劣势只能动画 CSS 可识别的属性,SVG 独有属性(如 d、cx、points)在部分浏览器中不支持 CSS 动画Safari 不支持通过 CSS 动画化 <path> 的 d 属性,形状变形动画受限复杂序列动画需要大量 @keyframes 和时间计算,代码可读性下降无法实现条件逻辑或基于用户输入的动态控制CSS 动画化 SVG 属性的兼容性现状| 属性 | Chrome | Firefox | Safari ||------|--------|---------|--------|| transform | 支持 | 支持 | 支持 || opacity | 支持 | 支持 | 支持 || cx / cy / r | 支持 | 支持 | 部分支持 || d(路径变形) | 支持 | 支持 | 不支持 || fill / stroke | 支持 | 支持 | 支持 |JavaScript 动画通过 JavaScript 直接操作 SVG DOM,或借助动画库实现复杂效果。灵活性最高,适合交互密集的场景。原生 JavaScript 示例const circle = document.querySelector('circle');let position = 50;function animate() { position += 1; circle.setAttribute('cx', position); if (position < 150) { requestAnimationFrame(animate); }}animate();GSAP 示例gsap.to('circle', { attr: { cx: 150 }, duration: 2, repeat: -1, yoyo: true});优势完全控制动画的每一个细节,可动画任何 SVG 属性可根据用户输入、滚动位置、数据变化等动态调整动画动画库(GSAP、Anime.js、Motion One)提供缓动函数、时间轴、交错动画等高级能力可与业务逻辑深度集成,实现数据驱动的可视化动画劣势代码量较大,维护成本高于声明式方案性能依赖实现质量,低效的 DOM 操作会导致卡顿依赖 JavaScript 运行环境,脚本被禁用时动画失效增加第三方库会增加打包体积Web Animations API浏览器原生提供的 element.animate() 方法,兼具 CSS 动画的性能和 JavaScript 的灵活性:const circle = document.querySelector('circle');circle.animate( [ { transform: 'translateX(0)' }, { transform: 'translateX(100px)' } ], { duration: 2000, iterations: Infinity, direction: 'alternate' });Web Animations API 可以在不引入第三方库的情况下获得接近 CSS 的性能,同时保留 JavaScript 的动态控制能力。但浏览器兼容性(特别是 Safari)需要注意。三种方式核心对比| 维度 | SMIL | CSS | JavaScript ||------|------|-----|------------|| 学习成本 | 中 | 低 | 高 || 灵活性 | 低 | 中 | 高 || 性能 | 好 | 最好 | 取决于实现 || 交互能力 | 弱 | 中 | 强 || 浏览器兼容 | 有风险 | 最好 | 好 || 可调试性 | 弱 | 强 | 中 || 适用场景 | 独立 SVG 文件 | 简单动画、UI反馈 | 复杂交互、数据驱动 |如何选择简单属性动画和 UI 反馈(按钮缩放、图标旋转、淡入淡出):优先 CSS 动画,性能最优、代码最少独立 SVG 文件中的自包含动画(图标、加载动画):SMIL 仍可用,但需评估兼容性风险复杂交互和数据驱动动画(图表、游戏、滚动动画):JavaScript + 动画库,GSAP 是目前最成熟的选择需要兼顾性能和动态控制:Web Animations API 是折中方案,但要做好兼容性降级实际项目中,三种方式并非互斥。常见做法是用 CSS 处理简单过渡,用 JavaScript 库处理复杂序列,必要时在独立 SVG 中使用 SMIL。关键是根据动画复杂度、交互需求和兼容性要求做出权衡。
服务端阅读 05月27日 15:43

SVG 性能优化有哪些常用方法?

为什么需要优化 SVGSVG 是前端开发中常用的矢量图形格式,但未经优化的 SVG 文件往往包含大量冗余代码,文件体积是实际所需的 2-5 倍。在实际项目中,一个从设计工具导出的图标 SVG 可能有 3KB,经过优化后不到 500 字节,压缩率可达 60%-80%。SVG 文件过大会拖慢页面加载速度,直接影响 LCP(最大内容绘制)指标;渲染复杂度过高则会影响 INP(交互延迟)和 CLS(布局偏移)等 Core Web Vitals 指标。一、精简 SVG 代码移除编辑器元数据设计工具导出的 SVG 通常携带大量无用信息:<title>Created with Figma</title> 这类声明<desc> 描述标签编辑器自定义属性(data-name、sketch:type 等)XML 注释和空行Inkscape / Illustrator 特有的命名空间声明这些内容对渲染毫无帮助,却占用了大量字节。手动清理费时费力,推荐使用 SVGO 自动处理。移除默认值属性SVG 有许多属性的默认值是可以省略的:fill="black" — fill 默认就是 blackstroke-width="1" — 默认值即为 1stroke-linecap="butt" — 默认对齐方式font-style="normal" — 默认正常样式display="inline" — 默认显示方式省略这些属性不仅能减小文件体积,还能让代码更简洁。简化路径数据路径(<path>)通常是 SVG 中体积最大的部分,优化路径数据的效果最明显:使用相对坐标:相对命令(h、v、l、c)比绝对命令(H、V、L、C)更短,因为只需要记录偏移量降低小数精度:50.123456 缩短为 50.12,在视觉上几乎无差异,但大幅减少字符数合并相邻同类命令:两个连续的 l 命令可以合并参数使用简写命令:水平线用 h 代替 l,垂直线用 v 代替 l<!-- 优化前:绝对坐标 + 高精度 --><path d="M10.000000 20.000000 L30.000000 40.000000 L50.000000 20.000000 Z"/><!-- 优化后:相对坐标 + 低精度 --><path d="M10 20l20 20 20-20z"/>二、压缩与传输优化SVGO 工具SVGO 是目前最主流的 SVG 优化工具,基于 Node.js,支持插件化配置,能自动完成上述所有代码层面的优化:# 单文件优化npx svgo input.svg -o output.svg# 批量优化整个目录npx svgo -f ./icons -o ./optimized# 指定精度为 2 位小数npx svgo input.svg -o output.svg --precision 2SVGO 默认插件包括移除元数据、移除注释、合并路径、转换样式等,大多数场景直接使用默认配置即可获得 50%-70% 的体积缩减。SVGOMG 在线工具如果不想安装命令行工具,SVGOMG 是 SVGO 的 Web 界面版本,可以在浏览器中实时预览优化效果,逐项开关插件并查看体积变化,适合偶尔使用或快速验证。服务器压缩SVG 是纯文本的 XML 格式,gzip 和 Brotli 压缩效果极好:gzip 压缩通常可再减小 60%-70%Brotli 比 gzip 再额外节省 10%-15%配置 Nginx 开启 Brotli 后,一个 12KB 的 SVG 传输时可能只有 2-3KB# Nginx 开启 gzip 压缩 SVGgzip on;gzip_types image/svg+xml;# Brotli(需安装模块)brotli on;brotli_types image/svg+xml;三、SVG Sprite 与复用当页面中有多个 SVG 图标时,逐个加载会产生大量 HTTP 请求。SVG Sprite 是解决这个问题的标准方案。symbol + use 模式将所有图标定义在 <symbol> 元素中,通过 <use> 引用,只需一次 HTTP 请求:<!-- 定义 Sprite --><svg style="display:none"> <symbol id="icon-home" viewBox="0 0 24 24"> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/> </symbol> <symbol id="icon-user" viewBox="0 0 24 24"> <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/> </symbol></svg><!-- 使用图标 --><svg><use href="#icon-home"/></svg><svg><use href="#icon-user"/></svg>这种模式下,所有图标共享一个 SVG 文件,浏览器只需请求一次,后续通过 <use> 引用时直接从缓存读取。defs 复用元素对于页面中重复出现的图形元素(渐变、形状等),用 <defs> 定义一次,多次引用:<svg> <defs> <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" style="stop-color:#f00"/> <stop offset="100%" style="stop-color:#00f"/> </linearGradient> </defs> <rect fill="url(#grad1)" width="100" height="50"/> <circle fill="url(#grad1)" cx="150" cy="25" r="25"/></svg>四、渲染性能优化内联关键 SVG首屏需要立即显示的 SVG(如 Logo、关键图标)建议直接内联到 HTML 中,省去 HTTP 请求,加快首次渲染。非首屏的 SVG 则应使用外部文件引用,以便浏览器缓存。使用 viewBox 实现响应式为 SVG 设置 viewBox 而非固定的 width/height,通过 CSS 控制显示尺寸,实现响应式适配:<svg viewBox="0 0 24 24" width="24" height="24"> <path d="..."/></svg>设置 viewBox 后,SVG 会在任何尺寸下保持清晰,同时浏览器能提前计算布局空间,避免 CLS(累积布局偏移)。减少元素与嵌套层级合并能合并的路径,减少 DOM 节点数用 <g> 分组替代多个独立元素去掉不必要的嵌套 <g> 包裹对于纯展示的元素,设置 pointer-events="none" 跳过事件检测DOM 节点越少,浏览器解析和渲染越快,这在大量 SVG 图标的页面上差异尤为明显。优化 SVG 动画动画性能的关键是选择正确的属性:优先使用 transform 和 opacity:这两个属性可以被 GPU 加速,不会触发重排避免动画 width、height、left、top、x、y:这些属性会触发布局重计算,性能开销大CSS 动画通常比 SMIL 动画性能更好,且兼容性更可控/* 推荐:GPU 加速 */.icon:hover { transform: scale(1.2); opacity: 0.8;}/* 避免:触发重排 */.icon:hover { width: 30px; height: 30px;}降低渲染复杂度减少滤镜(filter)的使用,尤其是 blur 和 drop-shadow,它们消耗大量 GPU 资源限制渐变数量,合并重复的渐变定义使用 shape-rendering="optimizeSpeed" 替代抗锯齿渲染,在图标等小尺寸场景下差异不大但性能更好用 fill-opacity/stroke-opacity 替代整体 opacity,前者不会创建合成层五、构建工具集成在实际项目中,SVG 优化应该集成到构建流程中,而不是手动处理。Webpack 配置npm install svgo svgo-loader --save-dev// webpack.config.jsmodule.exports = { module: { rules: [ { test: /\.svg$/, use: ['@svgr/webpack', 'svgo-loader'] } ] }}Vite 配置npm install vite-plugin-svgr --save-dev// vite.config.jsimport svgr from 'vite-plugin-svgr';export default { plugins: [svgr()]}构建工具集成后,每次构建都会自动优化 SVG,无需手动干预。性能验证优化完成后,需要实际验证效果:Lighthouse:检测页面整体性能,关注 LCP 和 FCP 指标Chrome DevTools Coverage:查看 SVG 文件的实际使用率,找出未使用的代码Network 面板:对比优化前后的传输大小(注意查看压缩后体积)Performance 面板:录制 SVG 渲染过程,检查是否有长任务优化一个 SVG 图标从 3KB 降到 500B 看似微小,但当页面有 20-30 个图标时,总体节省可达 50-70KB,对首屏加载速度的影响不可忽视。