第一次在技术群里看到有人拿 Solid 和 React 做对比时,我第一反应是:又一个蹭热度的新框架。直到后来我真的用 Solid 把一个含有十几个组件的后台模块重构了一遍,才意识到它组件的响应式模型跟 React 完全不是一个路子。这篇文章就专门拆一拆 Solid 的组件系统,聊聊组件本质、信号驱动、通信方案、控制流、动态加载,最后用轮播图组件把所有知识点串起来,再聊聊怎么把业务组件沉淀成一个可发布的组件库。这里说的 Solid 是前端框架 SolidJS,不是 SolidWorks 那个 CAD 软件,别搞混了。
1. 先看清 Solid 在组件层面的底层思维:为什么组件函数只执行一次
1.1 无虚拟 DOM 的编译期响应式
很多从 React 转过来的开发者,第一次接触 Solid 都会问一个问题:组件函数不是每次状态变化都会重新执行吗?答案是否定的。在 React 里,函数组件本质是渲染函数,每次 setState 都会把整个组件函数重新调用一遍,生成新的虚拟 DOM,再做 diff。Solid 完全不是这个套路。
Solid 是一个编译型响应式 UI 库。你在组件里写的 JSX,并不是在运行时被解释成虚拟 DOM,而是在构建阶段就被 babel-preset-solid 或者 vite 插件编译成了一组细粒度的响应式表达式。比如下面这段代码:
const [count, setCount] = createSignal(0); return <span>{count()}</span>;编译过后,<span>这个真实 DOM 节点上会挂一个更新函数,这个函数只负责一件事:当count()的值发生变化时,更新这个 span 的文本内容。组件函数本身在创建时执行一次,之后就不再重新执行了。
用一个生活化的类比:传统框架是"整页打印出来再检查哪里需要改",React 是"拍一张照片然后对比差异",Solid 则是"每块显示牌直接接一根电线到对应数据源,数据一变,对应牌子自己亮"。省掉了虚拟 DOM 的对比成本,更新路径极短。
1.2 组件不是渲染边界,信号才是
正因为组件函数只执行一次,Solid 里的组件在概念上不是一个"渲染边界"。React 中,父组件状态更新会连带子组件重新渲染,所以需要 memo、useCallback、useMemo 来优化。Solid 里没有这个问题:父组件的某个信号变化了,只有 JSX 中显式读取那个信号的位置会更新,子组件函数根本不会重新执行。
这也意味着你在组件函数里写的普通局部变量、闭包、事件处理函数,天然具备"创建时捕获"的语义,不会被多次执行反复创建。副作用代码写在哪里,就只执行一次,不会因为"重新渲染"而重复触发。
我刚开始写 Solid 时,最不适应的是在事件回调里读信号值:
const [count, setCount] = createSignal(0); function handleClick() { // 这里读到的 count() 永远是当前最新的值 // 因为 signal 是稳定的引用,不需要依赖注入 console.log(count()); }在 React 里要拿到最新值,要么加依赖数组,要么用 ref 绕一圈。在 Solid 里,signal 本身就是一个不变的引用,读取时自动获取最新值,写起来非常省心。
1.3 和 React、Vue 3 组件模型的直观对比
为了让你更清楚 Solid 处在什么位置,我整理了一个简单的对比表:
| 维度 | React | Vue 3 | Solid |
|---|---|---|---|
| 组件执行时机 | 每次状态变化重新执行 | setup 执行一次,渲染函数重新执行 | 组件函数只执行一次 |
| 更新粒度 | 组件级,配合 diff | 组件级 + 响应式依赖 | 精确到 DOM 节点 |
| 依赖收集方式 | 无运行时依赖收集,靠依赖数组 | 代理对象属性访问 | signal 函数调用 |
| 状态更新函数 | setState 替换值 | ref/reactive 修改属性 | setSignal 替换值 |
| 是否需要 memo | 需要 | 一般不需要 | 不需要 |
注意 Vue 3 的<template>编译也做细粒度更新,但 Vue 组件逻辑和模板是分离的,Solid 则是把响应式原语直接嵌入 JSX 表达式,写法上更接近"带信号量的 React"。
2. Signal 与 Effect:组件内部状态的驱动逻辑
2.1 createSignal 的读取与写入语义
Solid 最核心的状态原语是 Signal:createSignal(0)返回一个数组,第一项是读取函数,第二项是写入函数。为什么不是useState那样的"值 + setter"?因为 Solid 需要在运行时追踪依赖——每次调用读取函数时,它会把"当前正在运行的作用域"记到自己的订阅列表里,这样后续写入时就能精确通知。
import { createSignal } from "solid-js"; const [count, setCount] = createSignal(0); setCount(1); // 直接覆盖 setCount(c => c + 1); // 基于上一次值更新setCount(c => c + 1)这种函数式更新非常实用。在多处同时处理增量逻辑时,不依赖闭包里可能过期的值,而是让 Signal 自己给出当前值,避免竞态。
2.2 createEffect 的自动依赖收集
createEffect是 Solid 的副作用入口,它在函数体执行时自动收集用到的所有 Signal。之后任何一个依赖发生变化,都会重新执行一次副作用函数。不需要依赖数组,也不需要担心"忘了把某个依赖写进去"。
import { createSignal, createEffect } from "solid-js"; const [count, setCount] = createSignal(0); createEffect(() => { console.log("count 变化了:", count()); });这段副作用只在count()变化时触发。如果在 effect 里同时读取了多个 Signal,任何一个变化都会触发。如果哪个 Signal 都没读,这个 effect 就只执行一次。
这里的实现逻辑是:执行 effect 时,Signal 的读取函数会记录"当前 effect 上下文",并把自己挂到该 Signal 的依赖列表里。写入时,Signal 会遍历依赖列表,触发对应的更新函数。整个机制非常像观察者模式,但依赖关系是运行时自动建立的,不用手工维护。
2.3 生命周期:onMount 与 onCleanup 的正确打开方式
Solid 的生命周期比 React 简单得多。onMount在组件挂载后执行一次;onCleanup在组件卸载时执行,用来清理定时器、解绑事件、取消请求。
import { onMount, onCleanup } from "solid-js"; function Clock() { let timer: ReturnType<typeof setInterval>; onMount(() => { timer = setInterval(() => { // 每秒更新时间 }, 1000); }); onCleanup(() => { clearInterval(timer); }); return <div>clock</div>; }有个细节值得注意:Solid 的createEffect如果返回一个函数,它也会被当作清理函数执行,效果等同于在 effect 内部手动注册 onCleanup。我实际写代码时,更习惯直接在 effect 里返回清理逻辑,让清理代码和副作用代码放在一起,可读性更好:
createEffect(() => { const timer = setInterval(/* ... */, 1000); return () => clearInterval(timer); });无论组件的哪个部分导致 effect 重新执行,上一次的清理函数都会先执行,再运行新的副作用。这个机制对管理动态更新非常友好。
3. 跨组件通信的落地姿势:props、回调、Context 与插槽化 children
3.1 props 是响应式代理,解构前请三思
Solid 里父级传给子组件的 props,不是一个普通对象,而是一个具响应式能力的代理对象。你在子组件里读取props.name,读到的是当前值;父组件更新后,子组件的对应绑定位置会自动更新。这是 Solid 组件通信的核心。
但这里有个典型的新手坑:如果你图省事,在子组件里直接解构 props:
function UserCard(props: { name: string }) { // 错误示范 const { name } = props; // name 在这里变成了普通字符串,父组件后续更新不会再同步 return <div>{name}</div>; }name被解构出来后就脱离了响应式代理,父组件再怎么更新,这个子组件的文本都不会变。正确的做法是保持props.name访问,或者使用splitProps有选择地拆分:
import { splitProps } from "solid-js"; function UserCard(props) { const [local, others] = splitProps(props, ["name", "age"]); return <div>{local.name} / {local.age}</div>; }splitProps返回的local里的字段依然是响应式的,同时把剩余字段放到others里方便透传。这个 API 在写通用组件时几乎是必需品。
3.2 子传父用回调,事件绑定要注意原生事件命名
Solid 的"子传父"没有单独的事件系统,直接通过回调 prop 实现。父组件传一个函数,子组件在合适的时机调用:
function UserCard(props: { name: string; onLogout: () => void }) { return ( <div> <span>{props.name}</span> <button onClick={props.onLogout}>退出登录</button> </div> ); } function App() { return ( <UserCard name="张三" onLogout={() => console.log("执行退出逻辑")} /> ); }Solid 默认的onClick、onInput这类事件绑定是通过事件委托挂在根节点上的,性能很好,但某些场景下委托行为会影响原生事件捕获,比如在 Shadow DOM 里,或者需要监听某个非冒泡事件。这时候可以用冒号语法绑定真正的原生事件:
<button on:click={handleNativeClick}>原生绑定</button>这个语法会把事件监听器直接绑定到当前元素上,绕过委托。我在封装一些和第三方 DOM 库对接的组件时,经常用on:处理一些特殊事件。
3.3 Context 处理跨层级数据,children 函数实现插槽效果
如果组件层级很深,逐层传 props 会非常痛苦。Solid 的 Context 体系和 React 类似:createContext创建上下文,Provider提供数据,useContext在任意后代组件中接收。
import { createContext, useContext } from "solid-js"; const ThemeContext = createContext("light"); function ThemeProvider(props) { return ( <ThemeContext.Provider value={props.theme}> {props.children} </ThemeContext.Provider> ); } function Button() { const theme = useContext(ThemeContext); return <button className={`btn-${theme}`}>按钮</button>; }Context 适合放主题、当前用户信息、路由状态这类"全局但不想全局 Store"的数据。需要注意:Context 的值变化时,所有读取了该 Context 的子组件绑定位置都会更新,所以别把频繁变化的大对象放进 Context。
Solid 的childrenprop 还可以是函数,实现类似"插槽 + 作用域":父组件传入一个函数,子组件调用时传入内部数据,由父组件决定如何渲染。
function ListItem(props) { return <div>{props.children(props.item)}</div>; } <ListItem item={{ title: "标题" }}> {(item) => <h3>{item.title}</h3>} </ListItem>这种模式在封装表格、轮播图、虚拟列表这类"子内容需要拿到内部状态"的组件时非常有用。
4. 控制流组件是 Solid 组件化的精髓:Show、For、Index 与动态加载
4.1 Show、Switch、Match:条件渲染该选谁
在 React 里,我们习惯了{condition && <Comp />}或三元表达式。Solid 虽然也支持在 JSX 里写{condition() && <Comp />},但在遇到比较复杂的分支时,我更推荐使用官方控制流组件Show和Switch/Match。
Show的语义很清晰:when为真时渲染内容,为假时渲染fallback。它保证了条件分支只在状态切换时创建或销毁,而且在when为假时甚至不会计算子表达式。
import { Show } from "solid-js"; <Show when={user()} fallback={<div>未登录</div>}> <div>欢迎,{user().name}</div> </Show>多分支场景用Switch/Match,比嵌套三元表达式可读性好太多:
import { Switch, Match } from "solid-js"; <Switch fallback={<div>未知状态</div>}> <Match when={status() === "loading"}>加载中</Match> <Match when={status() === "ready"}>已就绪</Match> </Switch>注意when的值是一个"读取函数调用",比如user(),这样 Solid 才能建立依赖追踪;如果直接传user这个函数本身,条件判断永远为真,控制流就不会正确响应变化。
4.2 For 与 Index:列表渲染中 key 的差异
列表渲染是 Solid 和 React 差异最大的地方。React 里list.map(item => <Item />)是常规操作,但 Solid 中千万不要对响应式数组直接.map()——数组每次变化都会生成新的数组引用,编译器无法追踪每一项的更新。正确做法是用For或Index。
For以数据项的引用作为 key,适合列表项本身带稳定 id、增删改频繁的场景:
<For each={todos()}> {(todo) => <TodoItem todo={todo} />} </For>For会尽量复用已有 DOM 元素,当数组排序变化时,它会移动元素而不是重建。不过要注意each接收的是信号返回值,即todos(),不是todos这个函数引用。
Index则不一样,它按数组下标作为 key,回调里拿到的是"当前下标上的值"和"下标",这两者都是响应式的:
<Index each={items()}> {(item, index) => <div>{index()}: {item()}</div>} </Index>乍一看很绕,为什么item()也是函数?因为在Index的模型里,数组项本身可能被替换、数组可能被重排,为了始终拿到"当前下标位置的最新值",必须用函数读取。
什么时候用哪个?我总结了一个经验:如果列表项是身份稳定的复杂对象,用For,这样每一项的子组件状态不会因为排序变化而丢失;如果列表项是原始值或者频繁整体重建,用Index,因为它不依赖对象引用,性能更稳。轮播图我下面会讲,它用的就是Index——因为图片数组变化时我们更关心"第几个位置",而不是"哪张图"。
4.3 Dynamic、lazy 与 Suspense:动态组件加载与按需分包
业务里经常遇到"根据类型渲染不同组件"的需求,比如表单根据控件类型渲染 Input、Select、DatePicker。手写一堆Switch/Match当然可以,但组件类型一多就繁琐。Dynamic组件就是专门干这个的,它接收component属性,动态渲染对应组件:
import { Dynamic } from "solid-js/web"; <Dynamic component={controlType()} value={value()} onChange={handleChange} />controlType()可以是组件函数、DOM 标签名、或者一个异步加载过来的组件引用。配合 props 透传,相当灵活。
官网还提供了lazy+Suspense做异步组件加载,实现代码分割:
import { lazy } from "solid-js"; import { Suspense } from "solid-js/web"; const ChartPanel = lazy(() => import("./ChartPanel")); <Suspense fallback={<div>图表加载中...</div>}> <ChartPanel data={data()} /> </Suspense>lazy(() => import(...))返回一个组件,只有它真正渲染时才加载对应 chunk。Suspense会在加载完成前显示 fallback。我封装组件库时,体积较大的图表、富文本编辑器、地图组件全部用这种方式异步引入,首屏体积能小不少。
5. 手写一个轮播图组件:把组件通信和控制流串起来
5.1 组件需求与状态设计
理论讲再多,不如写一个真实组件。轮播图是典型的前端通用组件,覆盖了信号、effect、事件、列表渲染、props 透传、清理副作用这些知识点。
需求就四个:自动播放、鼠标悬停暂停、左右切换、指示点跳转。状态只有两个:当前索引index、是否暂停paused。index用 Signal,paused也用 Signal。自动播放用createEffect配合setInterval,组件卸载时用onCleanup清理定时器。
5.2 关键实现与代码
import { createSignal, createEffect, onCleanup, For } from "solid-js"; function Carousel(props: { images: string[]; interval?: number }) { const [index, setIndex] = createSignal(0); const [paused, setPaused] = createSignal(false); let timer: ReturnType<typeof setInterval> | undefined; createEffect(() => { if (timer) clearInterval(timer); timer = setInterval(() => { if (!paused()) { setIndex(i => (i + 1) % props.images.length); } }, props.interval ?? 3000); }); onCleanup(() => { if (timer) clearInterval(timer); }); const goTo = (i: number) => { setIndex((i + props.images.length) % props.images.length); }; return ( <div class="carousel" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} > <div class="carousel-track"> <For each={props.images}> {(src, i) => ( <div class="slide" classList={{ active: i() === index() }} > <img src={src} alt="" /> </div> )} </For> </div> <button class="prev" onClick={() => goTo(index() - 1)}>上一张</button> <button class="next" onClick={() => goTo(index() + 1)}>下一张</button> <div class="dots"> <For each={props.images}> {(_, i) => ( <button class="dot" classList={{ active: i() === index() }} onClick={() => goTo(i())} /> )} </For> </div> </div> ); }这里有两个 Solid 特性值得强调。
第一,createEffect内部先清理再设置定时器,所以当props.interval变化时,老的定时器会先被清掉,然后按新的间隔重新开始。这比 React 里 useEffect 写依赖数组更直接。
第二,轮播图的图片列表我用的是For,但回调里的下标i()是响应式函数,每次索引变化时,只有需要切换 active 类名的位置会更新,其他 DOM 节点完全不动。如果换作Index,回调里的src会变成一个函数,写法稍微要变一下;这里用For更符合直觉——每张图对应一个固定 DOM,移动的是 active 状态。
5.3 我踩过的三个坑
第一个坑:定时器泄漏。最初我只写了createEffect设置定时器,忘了onCleanup,结果组件在反复切换路由时定时器越积越多,而且旧的定时器还在引用旧组件的状态,页面行为变得非常诡异。后来我养成了一个习惯:凡是setInterval、setTimeout、addEventListener成对出现的地方,旁边必须写清理逻辑,不留侥幸。
第二个坑:props.interval被解构后不再更新。我一开始图省事,在组件开头写了const interval = props.interval ?? 3000,结果父组件动态修改间隔完全不生效。这个就是 3.1 里说的 props 解构陷阱的实战版本。正确做法始终在createEffect内部读取props.interval。
第三个坑:连续点击"下一张"时,组件因为轮播动画还没结束,索引已经跳了两次,图片闪烁。后来我在goTo里加了节流处理,用时间戳记录上一次切换时间,小于 500ms 的点击直接忽略。这种细节在真实业务里特别重要,因为轮播图往往会放在页面首屏,用户频繁操作的概率很高。
6. 从业务组件到组件库:封装、透传与发布
6.1 通用组件的接口设计
业务做到一定程度,你一定会发现重复的 UI 模式:按钮、弹窗、表格、表单控件。这时候把它们抽成通用组件是顺理成章的事。但组件库不是把代码复制粘贴到一个目录里就完事,接口设计才是关键。
一个通用组件要回答三个问题:
- 它接收什么数据,以什么形状传入?
- 它允许外部控制哪些事件?
- 它对外暴露哪些定制能力(class、style、插槽)?
比如封装一个带前缀图标的输入框,接口可以这样设计:
function IconInput(props: { label?: string; value?: string; onValueChange?: (value: string) => void; }) { return ( <label> {props.label} <input value={props.value ?? ""} onInput={(e) => props.onValueChange?.(e.currentTarget.value)} /> </label> ); }这里面最关键的是:不要把组件写成"只能用于当前业务"的形状。比如数据源始终接收string,而不是写成某个特定接口的字段名,事件回调只回传最原始的数据,格式转换放在使用方做。
6.2 让 props 透传更优雅
组件库的另一个常见需求是透传:让使用者像操作原生input一样设置placeholder、disabled、>import { splitProps } from "solid-js"; function StyledInput(props) { const [local, others] = splitProps(props, ["class", "variant"]); return ( <input {...others} class={`input input-${local.variant ?? "default"} ${local.class ?? ""}`} /> ); }
splitProps把自定义属性分到local,把剩余 DOM 属性放到others,再展开到原生元素上。这样调用方传入的placeholder、type、onBlur都能自动生效,而组件自己的class、variant又能单独处理。我遇到的很多"某些属性传不进去"的问题,最后都是因为没做这种拆分,直接把整个 props 对象展开了。
6.3 发布到 npm 前的注意事项
如果你打算把组件沉淀成内部包或发布到 npm,有几个点要提前想清楚。
Solid 组件的 JSX 需要编译,发布产物不能直接丢源码。建议用 Vite 库模式打包,配合官方vite-plugin-solid,输出 ESM 和 CSS 文件。样式最好独立成一个 CSS 文件,不要打包进 JS,否则使用方无法按需覆盖样式。
package.json里记得标注sideEffects: false或者至少标明哪些文件有副作用,这样使用方在 tree-shaking 时才能把没用到的组件摇掉。types字段指向.d.ts,配合exports字段提供子路径导入,比如my-lib/button。
单元测试也不可少。Solid 官方生态有solid-testing-library,对组件做渲染、触发事件、断言 DOM 都是上手很快的。我个人的做法是:每个组件至少覆盖"默认渲染、受控状态变化、事件回调触发、props 更新后视图同步"这四个核心场景,基本就能保证重构时不至于崩。
组件库是一门越做越有味道的工程。Solid 的响应式模型让组件之间的数据流非常干净,但接口一旦设计得混乱,再好的底层也救不回来。我自己的体会是:刚开始别追求"一份组件到处用",先在两个项目里跑通,提炼公共形态,再回头重构接口,这时候沉淀出来的组件才真正稳定。