资讯详情

SolidJS组件系统深度解析:Signal驱动与组件库实战

📅 2026/9/18 8:00:45 | 华诺云谱 👁 阅读
SolidJS组件系统深度解析:Signal驱动与组件库实战
第一次在技术群里看到有人拿 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 处在什么位置我整理了一个简单的对比表维度ReactVue 3Solid组件执行时机每次状态变化重新执行setup 执行一次渲染函数重新执行组件函数只执行一次更新粒度组件级配合 diff组件级 响应式依赖精确到 DOM 节点依赖收集方式无运行时依赖收集靠依赖数组代理对象属性访问signal 函数调用状态更新函数setState 替换值ref/reactive 修改属性setSignal 替换值是否需要 memo需要一般不需要不需要注意 Vue 3 的template编译也做细粒度更新但 Vue 组件逻辑和模板是分离的Solid 则是把响应式原语直接嵌入 JSX 表达式写法上更接近带信号量的 React。2. Signal 与 Effect组件内部状态的驱动逻辑2.1 createSignal 的读取与写入语义Solid 最核心的状态原语是 SignalcreateSignal(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: ReturnTypetypeof setInterval; onMount(() { timer setInterval(() { // 每秒更新时间 }, 1000); }); onCleanup(() { clearInterval(timer); }); return divclock/div; }有个细节值得注意Solid 的createEffect如果返回一个函数它也会被当作清理函数执行效果等同于在 effect 内部手动注册 onCleanup。我实际写代码时更习惯直接在 effect 里返回清理逻辑让清理代码和副作用代码放在一起可读性更好createEffect(() { const timer setInterval(/* ... */, 1000); return () clearInterval(timer); });无论组件的哪个部分导致 effect 重新执行上一次的清理函数都会先执行再运行新的副作用。这个机制对管理动态更新非常友好。3. 跨组件通信的落地姿势props、回调、Context 与插槽化 children3.1 props 是响应式代理解构前请三思Solid 里父级传给子组件的 props不是一个普通对象而是一个具响应式能力的代理对象。你在子组件里读取props.name读到的是当前值父组件更新后子组件的对应绑定位置会自动更新。这是 Solid 组件通信的核心。但这里有个典型的新手坑如果你图省事在子组件里直接解构 propsfunction 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} /} /ForFor会尽量复用已有 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 透传相当灵活。官网还提供了lazySuspense做异步组件加载实现代码分割import { lazy } from solid-js; import { Suspense } from solid-js/web; const ChartPanel lazy(() import(./ChartPanel)); Suspense fallback{div图表加载中.../div} ChartPanel data{data()} / /Suspenselazy(() import(...))返回一个组件只有它真正渲染时才加载对应 chunk。Suspense会在加载完成前显示 fallback。我封装组件库时体积较大的图表、富文本编辑器、地图组件全部用这种方式异步引入首屏体积能小不少。5. 手写一个轮播图组件把组件通信和控制流串起来5.1 组件需求与状态设计理论讲再多不如写一个真实组件。轮播图是典型的前端通用组件覆盖了信号、effect、事件、列表渲染、props 透传、清理副作用这些知识点。需求就四个自动播放、鼠标悬停暂停、左右切换、指示点跳转。状态只有两个当前索引index、是否暂停paused。index用 Signalpaused也用 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: ReturnTypetypeof 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 classcarousel onMouseEnter{() setPaused(true)} onMouseLeave{() setPaused(false)} div classcarousel-track For each{props.images} {(src, i) ( div classslide classList{{ active: i() index() }} img src{src} alt / /div )} /For /div button classprev onClick{() goTo(index() - 1)}上一张/button button classnext onClick{() goTo(index() 1)}下一张/button div classdots For each{props.images} {(_, i) ( button classdot 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 的响应式模型让组件之间的数据流非常干净但接口一旦设计得混乱再好的底层也救不回来。我自己的体会是刚开始别追求一份组件到处用先在两个项目里跑通提炼公共形态再回头重构接口这时候沉淀出来的组件才真正稳定。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。