面试题手册

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

服务端阅读 05月27日 16:09

Serverless 架构下的错误处理和重试机制如何设计?

Serverless 架构中常见的错误类型Serverless 应用运行在托管平台上,开发者对基础设施的控制力有限,因此错误处理策略与传统服务端架构有明显差异。理解错误类型的划分是设计处理机制的前提。函数内部错误是最常见的一类,包括代码抛出的未捕获异常、运行时类型错误、空指针引用等。这类错误往往可以通过完善代码逻辑和单元测试来预防。依赖服务错误发生在函数调用外部服务时,比如数据库连接超时、第三方 API 返回 5xx、消息队列服务暂时不可用。这类错误具有暂时性特征,适合通过重试机制来恢复。平台资源限制引发的错误容易被忽视,但后果严重。AWS Lambda 的执行超时上限为 15 分钟,单次调用内存上限 10GB;阿里云函数计算的单实例并发数有上限。当函数接近这些边界时,平台会强制终止执行。配置与权限错误属于部署阶段的问题,比如 IAM 角色缺少 S3 读取权限、环境变量引用了不存在的密钥。这类错误在函数首次调用时就会暴露,应在 CI/CD 流程中通过预检脚本拦截。区分这些错误类型的意义在于:不同类型需要不同的处理策略——内部错误靠代码质量,依赖错误靠重试与降级,资源错误靠限流与拆分,配置错误靠流程管控。重试机制的核心设计原则重试是处理暂时性错误最直接的手段,但盲目重试会让系统雪上加霜。设计合理的重试机制需要遵循三个原则。指数退避与抖动固定间隔重试会在高并发场景下导致"惊群效应"——所有失败的请求在同一时刻重试,再次压垮下游服务。指数退避让重试间隔按 2 的幂次增长(1s、2s、4s、8s…),给下游服务留出恢复窗口。但纯粹的指数退避仍不够。当大量实例同时失败时,它们的退避时间点仍然会高度重叠。加入随机抖动(Jitter)可以打散重试时间点。实际配置中,重试间隔的计算公式为:delay = min(base_delay * 2^attempt + random_jitter, max_delay)AWS Step Functions 原生支持指数退避配置,通过 IntervalSeconds、MaxAttempts、BackoffRate 三个参数控制。例如设置间隔 2 秒、退避率 2.0、最大尝试 3 次,实际重试序列为 2s → 4s → 8s。对于延迟敏感的业务,可以适当降低退避率(如 1.5),换取更快的恢复速度。最大重试次数与熔断重试不能无限进行。设置最大重试次数时需要权衡两个因素:业务对延迟的容忍度和下游服务的承载能力。一般建议异步任务重试 3-5 次,同步请求重试 1-2 次。当错误率持续攀升时,应该触发熔断而非继续重试。熔断器的工作模式是:正常状态下请求直接通过;当错误率超过阈值(如 50%),熔断器打开,后续请求直接走降级逻辑,不再调用下游服务;经过一段冷却期后,熔断器进入半开状态,放行少量请求探测恢复情况。幂等性保证重试的隐含前提是:同一操作执行多次与执行一次的效果相同。如果函数不具备幂等性,重试可能导致重复扣款、重复发送通知等严重问题。实现幂等性的常见方式:请求去重:使用请求 ID 或业务唯一键做去重表。在 DynamoDB 中可以用 ConditionExpression: attribute_not_exists(requestId) 保证写入唯一性。天然幂等操作:PUT 请求覆盖写入、数据库 UPSERT 操作天然具有幂等性,优先选择这类操作语义。补偿事务:对于无法天然幂等的操作,在检测到重复执行时执行逆向补偿。死信队列:重试的最后一道防线当所有重试都失败后,事件不能就此丢失。死信队列(DLQ)负责接收所有处理失败的消息,确保数据可追溯、可恢复。AWS Lambda 的 DLQ 机制AWS Lambda 对异步调用的默认行为是重试 2 次,间隔约 1 分钟。如果 2 次重试仍然失败,事件会被丢弃——除非配置了 DLQ。DLQ 可以是 SQS 队列或 SNS 主题。配置方式(以 SQS 为例):{ "DeadLetterConfig": { "TargetArn": "arn:aws:sqs:us-east-1:123456789012:order-processor-dlq" }}Lambda 执行角色需要 sqs:SendMessage 权限才能向 DLQ 写入消息。消息进入 DLQ 后,原始事件数据和失败原因都会保留,方便事后排查。阿里云函数计算的死信队列阿里云函数计算支持将异步调用失败的事件投递到 MNS 队列或 RocketMQ。配置路径为:函数配置 → 异步调用 → 死信队列。与 AWS 不同的是,阿里云允许自定义最大重试次数(0-8 次)和消息存活时间。DLQ 的运维实践设置消息保留期:建议 14 天,既留出排查时间,又避免队列无限膨胀。配置告警:当 DLQ 中出现新消息时,立即触发 CloudWatch 或 SLS 告警,通知运维人员介入。定期重放:对于因下游服务短暂不可用导致的失败,可以在服务恢复后从 DLQ 重新投递消息。根因分类:对 DLQ 中的消息按错误类型分组统计,识别系统性问题。分层错误处理架构生产环境中的 Serverless 应用不应该只靠单一的重试机制,而应构建分层的错误处理架构。第一层:函数内部防护在函数代码入口处做统一异常拦截,区分可恢复错误和不可恢复错误。可恢复错误返回特定状态码触发平台重试,不可恢复错误直接记录日志并返回。exports.handler = async (event) => { try { return await processEvent(event); } catch (err) { if (isTransientError(err)) { // 返回错误触发平台重试 throw err; } // 持久性错误,记录并返回成功(避免触发重试) console.error('Permanent error:', err); return { status: 'failed', reason: err.message }; }};第二层:平台级重试与 DLQ利用 Lambda/函数计算平台内置的异步重试机制,配合 DLQ 兜底。这一层不需要写额外代码,只需正确配置重试次数和 DLQ 目标。第三层:编排层重试(Step Functions / 工作流)对于涉及多个服务调用的复杂流程,使用 Step Functions 等编排服务管理重试。编排层的优势在于可以针对不同步骤设置差异化的重试策略,并实现分支回滚。{ "Retry": [{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 3, "MaxAttempts": 3, "BackoffRate": 2.0 }], "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "HandleFailure" }]}第四层:全局监控与告警使用 CloudWatch、SLS 或自定义看板监控关键指标:函数错误率、DLQ 深度、重试成功率、P99 延迟。设置多级告警:错误率超过 1% 触发警告,超过 5% 触发紧急通知,超过 20% 触发熔断。面试高频追问及回答思路"如何区分暂时性错误和永久性错误?"根据 HTTP 状态码和错误类型判断:4xx 错误(除 429 外)通常是永久性的,表示请求本身有问题;5xx 错误和 429 通常是暂时性的,表示服务端暂时不可用或限流。对于 SDK 抛出的异常,需要根据异常类型判断——连接超时是暂时性的,权限不足是永久性的。"Serverless 场景下熔断器怎么实现?"传统熔断器依赖进程内状态,Serverless 函数无状态,需要借助外部存储。常见方案:用 DynamoDB 或 Redis 记录错误计数和熔断状态,函数每次执行前先查询熔断状态。也可以使用 Lambda Extension 在函数实例级别维护短期状态,减少外部调用。"如何处理部分失败?"批量处理场景中,一批记录可能部分成功部分失败。AWS Lambda 事件源映射支持 BisectBatchOnFunctionError,将失败的批次对半拆分重试,逐步缩小失败范围。更精细的方案是在代码层面逐条处理,单独捕获每条记录的错误,将失败记录写入 DLQ。
服务端阅读 05月27日 16:09

Serverless 架构的安全性如何保障?

Serverless 架构将服务器运维交给了云厂商,但安全责任并没有随之转移。开发者需要在自己的可控范围内做好每一层防护。身份认证与授权Serverless 应用中,函数是基本执行单元,每个函数都可能成为攻击入口。身份认证和授权是第一道防线。函数访问控制: 使用 IAM 角色和策略限制每个函数的权限范围。避免多个函数共享同一个 IAM 角色,为不同功能的函数分配独立的角色和策略,做到权限精准匹配。API 网关认证: 在 API 网关层集成认证机制,如 AWS Cognito、OAuth2、JWT 验证等。不要让函数直接暴露在公网,所有外部请求都应经过网关的认证和校验。最小权限原则: 只为函数分配执行任务所需的最低权限。研究表明,超过 90% 的 Serverless 函数被赋予了过高的权限。权限过大意味着一旦函数被攻破,攻击者能访问的资源也更多。数据安全Serverless 应用通常会对接多种数据存储服务(数据库、对象存储、消息队列),数据安全需要在传输和存储两个环节同时保障。传输加密: 所有服务间通信必须使用 HTTPS/TLS 加密。这包括函数与数据库的连接、函数与第三方 API 的交互,以及 API 网关到客户端的通信。存储加密: 对数据库(如 DynamoDB、RDS)和对象存储(如 S3)启用静态加密。主流云厂商都提供了默认加密选项,开启成本极低但安全收益显著。密钥管理: 使用 AWS KMS、Azure Key Vault 等专业的密钥管理服务,不要将密钥硬编码在代码或环境变量中。通过密钥管理服务可以实现密钥的轮换、审计和访问控制。敏感数据处理: 避免在日志中记录敏感信息(如用户身份标识、令牌、个人数据)。Serverless 应用的日志通常集中存储在云日志服务中,访问范围可能超出预期。网络安全虽然 Serverless 函数由云厂商管理运行环境,但网络层的配置仍然由开发者控制。VPC 配置: 将需要访问内部资源的函数部署在私有子网中,通过 NAT 网关访问外部服务。这样可以避免函数直接暴露在公网,减少攻击面。安全组规则: 为函数所在的安全组配置严格的入站和出站规则。默认拒绝所有流量,只开放必要的端口和目标地址。VPC 端点: 使用 VPC 端点(如 AWS PrivateLink)访问云服务,流量不需要经过公网。这既提高了安全性,也降低了延迟。代码安全代码层面的安全问题是 Serverless 应用最常见的风险来源,传统的注入攻击在 Serverless 环境中依然存在。依赖扫描: 定期扫描第三方依赖的已知漏洞。Serverless 应用的依赖链通常较长,一个间接依赖的漏洞就可能危及整个应用。可以使用 Snyk、OWASP Dependency-Check 等工具进行自动化扫描。代码审计: 进行静态代码分析(SAST)和动态应用安全测试(DAST),重点关注输入验证、SQL 注入、命令注入和 XSS 等常见漏洞。Serverless 应用的输入源不止 HTTP 请求,还包括消息队列事件、存储变更事件、定时触发器等,所有入口都需要校验。环境变量与密钥管理: 使用 Secrets Manager 或 Parameter Store 管理敏感配置,而不是直接写在环境变量中。环境变量在运行时可以被函数代码完整读取,一旦代码存在漏洞就可能泄露。运行时安全运行时安全关注的是函数执行过程中的隔离、资源控制和异常处理。函数隔离: 云厂商在基础设施层面提供了函数间的隔离,但开发者也需要注意应用层面的隔离。不同敏感级别的函数不应共享状态或资源,避免低权限函数成为攻击高权限函数的跳板。资源限制: 为每个函数设置合理的内存和超时限制。过长的超时时间可能被攻击者利用执行耗时操作,过高的内存分配则会增加资损风险(Serverless 按资源消耗计费,异常流量可能导致高额账单)。异常处理: 妥善处理所有异常,避免将内部错误信息(如堆栈跟踪、数据库连接串)返回给调用方。异常信息泄露是攻击者收集系统信息的重要途径。安全责任共担Serverless 的安全责任是共担的:云厂商负责底层基础设施(计算、网络、操作系统)的安全,开发者负责应用代码、数据、配置和访问控制的安全。理解这个边界,是做好 Serverless 安全防护的前提。面试中回答这个问题,应从上述五个层面展开,并结合实际项目说明如何落地这些安全措施,而不是停留在罗列要点的层面。
服务端阅读 05月27日 16:09

什么是 Serverless 架构及其核心优势?

什么是 Serverless 架构?Serverless 架构(无服务器架构)是一种云计算执行模型,开发者无需预置或管理服务器,只需编写业务逻辑代码,由云平台负责基础设施的动态分配、弹性伸缩和运维管理。需要明确的是,"无服务器"并非真的没有服务器,而是服务器对开发者透明——从 IaaS、PaaS 到 Serverless,本质是运维责任的持续转移。Serverless 架构由两个核心组件构成:FaaS(函数即服务):将业务逻辑封装为独立的函数,由事件触发执行,运行在托管的短生命周期容器中。典型代表有 AWS Lambda、Azure Functions、阿里云函数计算 FC、腾讯云 SCF。函数无状态,每次调用相互独立,执行完毕后资源即被回收。BaaS(后端即服务):将数据库、对象存储、消息队列、身份认证等后端能力托管为服务,开发者通过 API 直接调用,无需自行搭建和维护。典型代表有 AWS DynamoDB、Firebase、阿里云表格存储等。Serverless 的核心优势1. 按需付费,成本显著降低传统云服务器采用包月/包年计费,无论是否承载流量都需要付费。Serverless 按实际执行时长和调用次数计费,空闲时费用为零。以阿里云 FC 为例,计费单位是"GB·秒"(内存规格 × 执行时长),对于流量波动大的应用,成本可降至传统集群的 10%-30%。2. 毫秒级弹性伸缩云平台根据请求量自动扩缩容,支持每秒数万次的突发流量,无需人工配置阈值或编写弹性策略。流量回落时自动缩容至零,既保证性能又避免资源浪费。3. 零运维,聚焦业务开发者不再需要关注操作系统补丁、运行时升级、容量规划、负载均衡配置等运维工作,将精力全部集中在业务逻辑本身,显著提升开发效率和迭代速度。4. 快速部署,加速交付代码上传或提交即可触发部署,从开发到上线可以在分钟级完成。结合 CI/CD 流水线,可以实现代码提交后自动测试、自动部署的全流程自动化。5. 高可用与容错内置云平台在多可用区部署函数实例,自动处理故障转移和负载均衡,单个节点故障不会影响服务可用性。开发者无需自行实现容错机制。Serverless 的局限性与挑战冷启动延迟函数在首次调用或长时间空闲后,需要初始化运行环境,这个过程称为冷启动。不同语言的冷启动时间差异较大:Node.js/Python 通常在 100-300ms,Java 由于 JVM 初始化可能达到 2-15 秒。解决方案包括:使用预置并发(Provisioned Concurrency)保持实例热状态利用 AWS Lambda SnapStart 通过内存快照将冷启动时间缩短 90%精简部署包体积,延迟加载非必要依赖选择冷启动更快的运行时(优先 Node.js/Python 而非 Java)实现智能预热策略,基于流量预测定期调用函数状态管理受限函数实例不保存本地状态,每次调用都是独立的。需要持久化的状态必须依赖外部存储(Redis、DynamoDB 等),增加了架构复杂度和调用延迟。供应商锁定深度使用某个云厂商的 Serverless 服务后,迁移成本较高。不同厂商的函数运行时、触发器配置、BaaS 服务接口差异较大。可以通过抽象层(如 Serverless Framework)降低耦合,但无法完全消除。执行时长限制主流 Serverless 平台对单次函数执行有严格的时间限制,如 AWS Lambda 最长 15 分钟。长时间运行的任务(如视频转码、大规模数据批处理)需要拆分为多个函数或选择其他方案。调试与可观测性复杂分布式函数调用链路追踪、本地模拟调试、性能分析都比传统架构更加复杂,需要借助云平台提供的可观测性工具或第三方 APM 方案。Serverless vs 微服务:如何选择?两者并非取代关系,而是可以结合使用。核心区别在于:| 维度 | 微服务 | Serverless ||------|--------|------------|| 运维责任 | 团队自行管理容器和基础设施 | 云平台全托管 || 执行模式 | 长时间运行的服务进程 | 事件驱动的短生命周期函数 || 扩展方式 | 手动配置 Auto Scaling | 自动弹性伸缩至零 || 计费模式 | 按实例数或资源占用付费 | 按执行时长和调用次数付费 || 状态管理 | 可维护有状态服务 | 无状态,依赖外部存储 || 适用场景 | 持续高流量的 API、有状态业务 | 事件驱动、流量波动大、低频调用 |实际项目中常采用混合架构:核心业务逻辑使用微服务保证稳定性和可控性,边缘功能(图片处理、通知推送、定时任务)使用 Serverless 降低成本和运维负担。适用场景与最佳实践适合 Serverless 的场景:事件驱动应用:用户上传图片后自动生成缩略图、订单创建后触发通知API 网关后端:为移动端/前端提供 RESTful API数据处理流水线:日志分析、ETL 转换、实时数据聚合定时任务:报表生成、数据备份、缓存刷新聊天机器人:接收消息后触发处理逻辑不适合 Serverless 的场景:长时间运行的任务(超过平台执行时长限制)需要持久连接的应用(WebSocket 长连接、流媒体)对冷启动延迟极度敏感的实时系统高频稳定流量且对成本极度敏感的场景(持续高流量下 Serverless 可能比预留实例更贵)最佳实践:单一职责:每个函数只做一件事,保持代码精简控制依赖:减少第三方库引入,缩小部署包体积连接复用:在函数初始化阶段创建数据库连接,利用运行时复用避免重复建连幂等设计:函数可能被重复调用,确保多次执行结果一致分层部署:将依赖层和业务代码分离,利用层缓存加速部署面试回答要点面试中被问到 Serverless 时,建议从以下几个层次回答:先给出定义:Serverless 是一种云计算执行模型,开发者无需管理服务器,按需付费讲清两大组件:FaaS 负责计算,BaaS 负责后端服务列举核心优势:按需付费、弹性伸缩、零运维、快速部署主动提及局限:冷启动、状态管理、供应商锁定、执行时长限制,展现思考深度与微服务对比:两者不是互斥关系,可以结合使用结合实际经验:说明在什么业务场景下选择了 Serverless,解决了什么问题,遇到了什么挑战避免只列举优势而忽略局限,面试官更看重你对技术选型的全面理解和权衡能力。
服务端阅读 05月27日 16:08

Serverless 和传统服务器架构有什么区别?

Serverless 和传统服务器架构是两种截然不同的技术范式,理解它们的差异是后端架构面试中的高频考点。下面从核心原理到实际选型,逐层拆解。本质区别:谁在管理服务器传统架构中,开发者需要自行购买或租用服务器(物理机、虚拟机、容器),对操作系统、运行时环境、网络配置等全权负责。Serverless 并不是"没有服务器",而是将服务器的管理权完全交给云厂商,开发者只需编写业务代码并部署,底层基础设施由平台自动调度。简单理解:传统架构是你自己买车自己开自己保养,Serverless 是打车——你只管出发和到达,车和司机由平台提供。从技术实现上看,Serverless 通常由 FaaS(函数即服务)和 BaaS(后端即服务)两部分组成。FaaS 负责运行业务代码,BaaS 提供数据库、存储、消息队列等托管服务,两者配合实现完整的应用架构。成本模型:固定支出 vs 按量计费传统架构采用预留资源模式,无论服务是否被访问,服务器租金照付。需要按峰值容量预估采购,低峰期资源闲置浪费。Serverless 采用按量计费,只为函数实际执行的调用次数和运行时长付费。代码不运行时不产生任何费用,特别适合流量波动大或低频触发的场景。不过需要注意:如果应用持续高并发运行,Serverless 的累计费用可能超过传统架构的固定成本。成本优势取决于流量模式。一个经验判断——当服务利用率低于 10% 时,Serverless 的成本优势明显;利用率持续超过 70% 时,传统架构通常更经济。运维与扩展:手动运维 vs 自动伸缩传统架构需要运维团队处理服务器监控、系统补丁、安全加固、负载均衡配置、容量规划等。水平扩展需要手动增加实例并调整负载均衡策略,扩展速度受限于采购和部署周期。Serverless 平台自动处理基础设施运维,函数实例根据请求量自动创建和销毁,理论上可无限扩展。开发者无需关心容量规划,平台在毫秒级完成弹性伸缩。但自动伸缩也有边界:各云厂商对函数的并发执行数、单次执行时长都有上限(如 AWS Lambda 默认单次最长 15 分钟),超长运行任务不适合 Serverless。状态管理:有状态 vs 无状态这是一个面试中容易被追问的关键点。传统架构支持本地状态持久化,可以依赖内存中的 Session、本地缓存、文件系统等维持应用状态,也支持会话保持(Sticky Session)。Serverless 函数是无状态的,每次调用可能运行在不同的计算实例上。上一次调用的内存数据、本地文件在下次调用时不可用。状态必须外部化存储——使用 Redis、数据库、对象存储等。这意味着:不能依赖本地文件系统保存持久数据不能使用进程内缓存作为可靠的状态存储需要通过外部服务实现跨请求的状态共享冷启动问题Serverless 函数在长时间未被调用后,计算实例会被回收。下次请求到来时需要重新分配资源并初始化运行环境,这个延迟称为冷启动。冷启动的影响程度与运行时有关:Python、Node.js 等轻量运行时通常在百毫秒级,Java 等重运行时可能达到数秒。传统架构的服务器常驻运行,不存在冷启动问题。应对冷启动的常见策略:使用预热机制定时触发函数选择轻量运行时使用预留实例(Provisioned Concurrency)消除冷启动设置函数最小实例数供应商锁定风险传统架构的技术栈相对通用,应用可以在不同云平台或自建机房之间迁移。Serverless 深度依赖云厂商的 FaaS 和 BaaS 服务,不同厂商的函数接口、事件触发机制、服务集成方式差异较大。从 AWS Lambda 迁移到 Azure Functions 或阿里云函数计算,往往需要大幅改造代码。这是架构选型时必须评估的风险。降低锁定风险的实践:使用 Serverless Framework 等抽象层、将业务逻辑与平台 SDK 解耦、对核心服务保留传统架构方案作为兜底。开发与部署体验传统架构需要配置运行环境、管理依赖、编写部署脚本、处理灰度发布。部署流程复杂,迭代周期长。Serverless 的部署粒度更细,一个函数就是一个部署单元。代码打包上传即可运行,CI/CD 流程更简单。但本地调试和端到端测试的难度更大,因为很多触发器和依赖服务需要在云端才能完整运行。架构粒度:单体/微服务 vs 函数级传统架构以应用或微服务为部署单位,一个服务包含多个接口和业务逻辑。Serverless 将应用拆分为更细粒度的函数,每个函数通常只完成一个动作(处理一个 HTTP 请求、响应一个事件、执行一次数据转换)。这种细粒度带来更好的隔离性和独立扩展能力,但也增加了函数编排和调用的复杂度。适用场景对比| 维度 | 传统架构 | Serverless ||------|----------|------------|| 流量模式 | 稳定持续的高并发 | 突发、间歇性、不可预测 || 延迟要求 | 严格低延迟 | 可容忍冷启动延迟 || 运行时长 | 长时间运行的任务 | 短时计算任务 || 状态需求 | 有状态服务 | 无状态、事件驱动 || 迁移需求 | 需要多云/混合部署 | 接受供应商锁定 || 团队能力 | 有专业运维团队 | 运维资源有限 |Serverless 的典型场景:API 网关后端、事件驱动处理、定时任务、数据 ETL 流水线、实时文件处理、IoT 消息处理。传统架构的典型场景:长时间运行的服务(如 WebSocket 长连接)、对延迟极度敏感的交易系统、需要 GPU 的机器学习训练、有强合规要求需自建机房的业务。面试回答建议回答时不要只列差异,要展示选型思维:先明确两种架构的核心差异——谁管服务器、怎么计费、怎么扩展再深入技术细节——冷启动、无状态约束、供应商锁定最后给出选型依据——根据业务流量模式、延迟要求、团队规模、成本预算综合判断实际项目中,很多团队采用混合架构:核心服务用传统架构保证稳定性和控制力,边缘服务和异步任务用 Serverless 提升开发效率和降低成本。这种折中方案往往是最务实的选择。
服务端阅读 05月27日 16:05

SolidJS Router 如何使用?有哪些高级特性?

SolidJS Router 是 SolidJS 官方的客户端路由库,基于细粒度响应式系统构建,支持嵌套路由、数据预加载、懒加载和 SSR。它与 React Router、Vue Router 有何不同?核心差异在于路由状态天然响应式,数据获取与路由切换真正并行。下面从基础用法到高级特性逐层展开。基本使用安装 @solidjs/router 后,在应用入口用 Router 包裹路由定义:import { render } from "solid-js/web";import { Router, Route } from "@solidjs/router";import Home from "./pages/Home";import About from "./pages/About";render( () => ( <Router> <Route path="/" component={Home} /> <Route path="/about" component={About} /> </Router> ), document.getElementById("app"));需要注意,新版本中不再需要 Routes 组件,Route 直接作为 Router 的子组件使用。根级布局通过 Router 的 root 属性指定根布局组件,适合放置导航栏、侧边栏和全局 Context Provider。根布局在路由切换时不会重新渲染,这是性能优化的重要一环:function Layout(props) { return ( <div> <nav> <A href="/">首页</A> <A href="/about">关于</A> </nav> <main>{props.children}</main> </div> );}render(() => <Router root={Layout}> <Route path="/" component={Home} /> <Route path="/about" component={About} /></Router>, document.getElementById("app"));root 布局不会随路由切换而重新挂载,这使得全局状态(如登录态、主题切换)可以稳定地维持在布局层。动态路由参数使用冒号语法定义动态参数,通过 useParams 钩子响应式地访问参数值:import { useParams } from "@solidjs/router";function UserProfile() { const params = useParams(); return <div>用户 ID: {params.id}</div>;}// 路由定义<Route path="/users/:id" component={UserProfile} />useParams 返回的 params 是响应式对象,参数变化时组件自动更新。这一点与 React Router 的 useParams 不同——SolidJS 的版本天然具备细粒度响应性,不需要借助额外的状态管理。可选参数与通配符在参数名后加问号声明可选参数,匹配有无该参数两种情况:<Route path="/stories/:id?" component={Stories} />通配符用 * 匹配任意后代路径,必须是路径的最后一段,可以命名:<Route path="/docs/*rest" component={Docs} />在组件内通过 params.rest 获取匹配的剩余路径,适合文档站、知识库等深层嵌套场景。路由导航SolidJS Router 提供三种导航方式,覆盖声明式和编程式两大场景:声明式导航:A 组件会自动处理点击事件,并根据当前路径添加 active 状态类名,这是与普通 a 标签的核心区别:import { A } from "@solidjs/router";<A href="/about" activeClass="current">关于</A>编程式导航:useNavigate 返回导航函数,适合在异步操作完成后跳转:import { useNavigate } from "@solidjs/router";function LoginButton() { const navigate = useNavigate(); const handleLogin = async () => { await login(); navigate("/dashboard", { replace: true }); }; return <button onClick={handleLogin}>登录</button>;}navigate 的第二个参数支持 replace(替换历史记录,阻止用户回退)和 state(传递路由状态,目标页面通过 useLocation 获取)。查询参数管理useSearchParams 提供对 URL 查询参数的读写能力,常用于分页、筛选等场景:import { useSearchParams } from "@solidjs/router";function ProductList() { const [searchParams, setSearchParams] = useSearchParams(); const page = () => parseInt(searchParams.page || "1"); return ( <div> <span>当前页: {page()}</span> <button onClick={() => setSearchParams({ page: page() + 1 })}> 下一页 </button> </div> );}setSearchParams 会合并更新查询参数,不会覆盖其他已有的参数。查询参数的变化也会触发响应式更新。嵌套路由嵌套路由让父组件包裹子路由,通过 props.children 渲染子路由内容。这是构建复杂页面布局的关键模式:<Router> <Route path="/users" component={UserLayout}> <Route path="/" component={UserList} /> <Route path="/:id" component={UserDetail} /> <Route path="/:id/edit" component={UserEdit} /> </Route></Router>function UserLayout(props) { return ( <div class="user-section"> <Sidebar /> {props.children} </div> );}只有叶节点(最内层的 Route)会被渲染为独立路由,父路由承担布局职责。嵌套层级没有上限,但建议控制在 3 层以内以保持可维护性。MatchFilter 路径参数验证MatchFilter 可以对动态路径参数施加验证规则,不满足条件的路径不会匹配该路由。支持三种验证方式:import type { MatchFilters } from "@solidjs/router";const filters: MatchFilters = { parent: ["mom", "dad"], // 枚举值——只匹配指定选项 id: /^\d+$/, // 正则表达式——只允许数字 slug: (v) => v.length > 3 && v.endsWith(".html"), // 自定义验证函数};<Route path="/users/:parent/:id/:slug" component={UserPage} matchFilters={filters}/>MatchFilter 的执行顺序是枚举 → 正则 → 自定义函数。未通过验证的路径会跳过该路由,继续匹配后续定义,这种机制避免了在组件内部做参数校验的额外开销。路由守卫与权限控制SolidJS Router 没有内置守卫 API,但通过组件组合可以灵活实现。核心思路是在父路由组件中检查认证状态,未认证则重定向:import { Outlet, useNavigate } from "@solidjs/router";import { createEffect } from "solid-js";function AuthGuard() { const navigate = useNavigate(); createEffect(() => { const token = sessionStorage.getItem("token"); if (!token) { navigate("/signin", { replace: true }); } }); return <Outlet />;}// 路由配置<Route path="/dashboard" component={AuthGuard}> <Route path="/" component={Dashboard} /> <Route path="/settings" component={Settings} /></Route>这里用 Outlet 代替 props.children 渲染子路由,效果相同但语义更明确。认证检查放在 createEffect 中,当 token 状态响应式变化时会自动重新判断。这种模式的优势在于:守卫逻辑与路由配置解耦,可以针对不同的路由层级应用不同的守卫策略(如管理员路由、付费用户路由等)。懒加载使用 Solid 的 lazy 函数实现路由级代码分割,只在路由被访问时加载对应组件,显著减少首屏加载体积:import { lazy, Suspense } from "solid-js";const Dashboard = lazy(() => import("./pages/Dashboard"));const Settings = lazy(() => import("./pages/Settings"));<Suspense fallback={<Loading />}> <Route path="/dashboard" component={Dashboard} /> <Route path="/settings" component={Settings} /></Suspense>Suspense 包裹路由区域,在组件加载期间展示 fallback UI。与 React 的 Suspense 不同,Solid 的版本不依赖 Concurrent Mode,实现更轻量。数据预加载(Preload)Preload 是 SolidJS Router 区别于其他路由库的核心特性。它在路由加载或链接被悬停时提前获取数据,与组件懒加载并行执行,从根本上避免了数据瀑布流问题:import { query } from "@solidjs/router";// query 创建带缓存的数据获取函数export const getUser = query(async (id) => { const res = await fetch(`/api/users/${id}`); return res.json();}, "getUser");// preload 函数在路由匹配时或链接悬停时被调用export function preloadUser({ params, location, intent }) { return getUser(params.id);}<Route path="/users/:id" component={UserDetail} preload={preloadUser} />preload 函数接收三个参数:params:当前路由参数,与 useParams 返回值一致location:位置对象,包含 pathname、search、hash、query、state、keyintent:触发原因,有四个值——initial(首次加载)、native(浏览器前进后退)、navigate(编程式导航)、preload(链接悬停预加载)可以根据 intent 区分处理策略,比如只在 initial 和 navigate 时执行重量级数据获取,在 preload(悬停)时仅预取轻量数据。query API 提供四层缓存机制:服务端请求去重(请求生命周期内)浏览器预加载缓存(5 秒)响应式重新获取(基于 key 变化自动触发)浏览器前进后退缓存(5 分钟)这种多层缓存设计意味着:用户悬停链接时数据已经开始加载,点击时数据已就绪,回退时直接命中缓存,整个体验接近瞬时切换。配置对象方式除了 JSX 声明式配置,还支持以对象数组定义路由,适合需要动态生成路由的大型项目:const routes = [ { path: "/users", component: lazy(() => import("./pages/users")), children: [ { path: "/:id", component: lazy(() => import("./pages/users/[id]")), preload: preloadUser, }, ], },];render(() => <Router>{routes}</Router>, document.getElementById("app"));配置对象方式与 JSX 方式功能完全等价,选择哪种取决于项目的代码组织偏好。HashRouter 与 MemoryRouterSolidJS Router 提供两种替代路由模式,应对不同的部署和测试需求:HashRouter:使用 URL hash 部分(#后面的内容)进行路由,不需要服务端配置重写规则,适合 GitHub Pages 等静态托管环境MemoryRouter:将路由历史保存在内存中,不操作浏览器 URL,适合单元测试和 Storybook 集成import { HashRouter, MemoryRouter } from "@solidjs/router";// 静态部署——无需服务端配置<HashRouter> <Route path="/" component={Home} /></HashRouter>// 单元测试——不依赖浏览器 URL<MemoryRouter> <Route path="/" component={Home} /></MemoryRouter>404 与错误处理使用 * 通配符定义兜底路由,处理所有未匹配的路径:<Route path="*404" component={NotFound} />通配符路由可以在任意嵌套层级使用,确保子路由未匹配时也能正确兜底。命名通配符(如 *404 中的 "404")可以在组件内通过 useParams 获取。useIsRouting 与路由过渡useIsRouting 钩子返回布尔信号,指示路由是否正在过渡中,常用于顶部加载条:import { useIsRouting } from "@solidjs/router";function App() { const isRouting = useIsRouting(); return ( <div> {isRouting() && <LoadingBar />} {/* 路由内容 */} </div> );}结合 Solid 的 Transition API,可以实现路由切换时的平滑过渡效果,新内容在加载完成后一次性替换,避免中间状态的闪烁。核心要点总结| 特性 | 用途 | 核心 API ||------|------|----------|| 基本路由 | 路径与组件映射 | Router, Route || 根级布局 | 全局布局不重渲染 | root 属性 || 动态参数 | 路径参数提取 | useParams || 可选参数与通配符 | 灵活路径匹配 | ? 和 * 语法 || 路由导航 | 声明式与编程式跳转 | A, useNavigate || 查询参数 | URL search 读写 | useSearchParams || 嵌套路由 | 布局与子路由组合 | props.children, Outlet || 参数验证 | 约束路径参数格式 | MatchFilter || 路由守卫 | 认证与权限控制 | useNavigate, Outlet || 数据预加载 | 并行获取避免瀑布流 | preload, query || 懒加载 | 代码分割按需加载 | lazy, Suspense || 路由过渡 | 加载状态反馈 | useIsRouting || 替代模式 | 静态部署与测试 | HashRouter, MemoryRouter |SolidJS Router 的设计哲学是将路由与 Solid 的细粒度响应式系统深度融合。preload + query 的组合让数据获取与路由切换真正并行,四层缓存机制覆盖了从悬停预取到浏览器回退的全部场景——这是区别于 React Router 和 Vue Router 的核心优势。
服务端阅读 05月27日 16:01

SolidJS 和 React 有什么区别?前端框架该怎么选?

SolidJS 和 React 都基于 JSX 和组件化思想,但底层运行机制完全不同。理解它们的核心差异,才能在项目选型时做出正确判断。渲染机制:虚拟 DOM vs 细粒度响应式React 的核心是虚拟 DOM。每次状态变化,组件函数重新执行,React 通过 Diff 算法比较新旧虚拟 DOM 树,计算出最小 DOM 操作量再更新真实 DOM。这意味着即使只有一个状态变量改变,组件函数体也会完整执行一遍。SolidJS 完全不使用虚拟 DOM。它在编译阶段分析模板,将状态与具体 DOM 节点建立绑定关系。状态变化时,只更新绑定的那几个 DOM 节点,组件函数只执行一次。// React:count 变化时,整个组件重新执行function Counter() { const [count, setCount] = useState(0); console.log('组件重新执行'); // 每次 count 变化都会打印 return <p>{count}</p>;}// SolidJS:count 变化时,只更新 {count()} 对应的文本节点function Counter() { const [count, setCount] = createSignal(0); console.log('组件只执行一次'); // 只打印一次 return <p>{count()}</p>;}这个差异直接决定了两个框架在频繁更新场景下的性能表现。状态管理对比React 的 useState 返回的是值本身,读取状态就是读取一个普通变量。SolidJS 的 createSignal 返回的是一个 getter 函数,必须在响应式上下文(JSX 模板、createEffect、createMemo)中调用才能建立依赖追踪。// React:状态是值const [count, setCount] = useState(0);console.log(count); // 直接读取值// SolidJS:状态是函数const [count, setCount] = createSignal(0);console.log(count()); // 必须调用函数读取值对于复杂嵌套对象,SolidJS 提供了 createStore,支持细粒度嵌套更新——修改对象深层属性时,只触发依赖该属性的视图更新,而不是整个对象关联的视图都刷新。// SolidJS 的 createStore 支持嵌套细粒度更新const [user, setUser] = createStore({ name: '张三', address: { city: '北京' } });setUser('address', 'city', '上海'); // 只更新 city,name 关联的视图不受影响React 中要实现类似效果,需要配合 useReducer 和不可变数据更新模式,心智负担更重。副作用和派生状态React 的 useEffect 需要手动声明依赖数组,遗漏或多余的依赖是常见 Bug 来源。SolidJS 的 createEffect 自动追踪依赖,在函数体内读取的任何信号都会被自动收集。// React:手动声明依赖useEffect(() => { document.title = `${name} - ${count}`;}, [name, count]); // 遗漏依赖 = Bug// SolidJS:自动追踪createEffect(() => { document.title = `${name()} - ${count()}`; // 自动追踪 name 和 count});派生状态同理。React 的 useMemo 也需要手动依赖,SolidJS 的 createMemo 自动追踪:// Reactconst fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);// SolidJSconst fullName = createMemo(() => `${firstName()} ${lastName()}`);自动追踪减少了出错概率,但也需要注意:在响应式上下文之外读取信号不会建立依赖,这是 SolidJS 新手常踩的坑。条件渲染和列表渲染React 用 JavaScript 原生语法处理条件和列表,SolidJS 提供了专用的控制流组件,性能更优。// React 条件渲染{isLoggedIn && <Dashboard />}// SolidJS 条件渲染(Show 组件,条件切换时不销毁 DOM)<Show when={isLoggedIn()}> <Dashboard /></Show>Show 组件在条件为 false 时不会渲染子组件,条件变为 true 时才创建。与 React 的 && 短路不同,Show 在条件反复切换时不会反复创建销毁,适合频繁切换的场景。列表渲染方面,SolidJS 的 For 组件对每个列表项建立独立响应式绑定,更新单项时不需要重新映射整个列表:// React:key 变化时重新渲染{items.map(item => <Item key={item.id} data={item} />)}// SolidJS:每项独立响应<For each={items()}> {(item) => <Item data={item} />}</For>服务端渲染和元框架React 有 Next.js,SolidJS 有 SolidStart。两者的定位相似:提供路由、SSR、API 路由等全栈能力。Next.js 的生态更成熟,App Router 支持 React Server Components(RSC),可以将组件标记为服务端组件,减少客户端 JavaScript 体积。SolidStart 目前没有 RSC 的等价方案,但依靠 SolidJS 本身极小的运行时体积(约 7KB,React 约 40KB),客户端 JS 体积已经很小。SSR 场景下,SolidJS 支持 Streaming SSR 和同构渲染,与 React 18 的 Suspense Streaming 思路类似,但实现更轻量。实际性能差距根据 JS Framework Benchmark 数据:| 指标 | React | SolidJS ||------|-------|---------|| 创建 1000 行表格 | 较慢 | 快 30-40% || 更新单行 | 较慢 | 快 3-5 倍 || 交换行 | 较慢 | 快 2-3 倍 || 内存占用 | 较高 | 低 40-50% || 核心包体积 | ~40KB | ~7KB |这些数字在小型应用中感知不明显,但在大型数据表格、实时仪表盘、高频交互界面中,差距会变得显著。什么时候选 React团队已有 React 经验,学习成本几乎为零项目依赖大量 React 生态库(antd、react-query、react-hook-form 等)需要 React Server Components 的能力企业级应用,优先考虑人才招聘和长期维护需要 Next.js 的成熟方案(ISR、边缘渲染等)什么时候选 SolidJS应用对性能要求极高,尤其是大量动态列表或高频更新场景包体积敏感(嵌入式 Web 应用、移动端 H5)团队愿意投入学习成本,追求更优雅的响应式模型新项目从零开始,不依赖 React 特有生态对内存占用有严格要求(低配设备、长运行时间应用)迁移注意事项从 React 迁移到 SolidJS,语法层面相似度高,但思维模式需要转换:组件只执行一次——不要在组件体内写副作用逻辑,必须放在 createEffect 或事件处理中信号是函数不是值——忘记调用 count() 而写成 count 是最常见的错误解构会丢失响应性——const { name } = props 会丢失响应式追踪,必须用 props.name 或拆分信号没有 Hook 规则限制——SolidJS 的响应式原语可以在任何地方调用,不限于组件顶层ref 不是 useEffect 的替代品——onMount 和 onCleanup 更适合处理 DOM 操作从 SolidJS 迁回 React 的情况较少见,但如果项目后续需要接入 React 独有生态(如 RSC),需要重写响应式逻辑。
服务端阅读 05月27日 16:01

SolidJS 有哪些性能优化技巧?如何避免常见的性能陷阱?

SolidJS 的细粒度响应式系统本身已经具备出色的性能基础,但如果不理解其运行机制,仍然容易踩坑。以下是实际开发中最值得关注的优化技巧和常见陷阱。## 一、用 createMemo 缓存昂贵计算,但别滥用createMemo 会缓存计算结果,仅当依赖变化时才重新求值。对于涉及遍历、排序、过滤等开销较大的派生状态,使用 createMemo 能有效减少不必要的重复计算。javascriptconst [items, setItems] = createSignal([]);// 昂贵计算用 memo 缓存const total = createMemo(() => items().reduce((sum, item) => sum + item.price, 0));但对于简单表达式,普通函数就足够了,createMemo 本身也有开销:javascript// 简单派生不需要 memoconst doubled = () => count() * 2;// 只有计算成本高时才值得 memoconst filtered = createMemo(() => items().filter(expensivePredicate));陷阱:不要在 createMemo 中执行副作用。Memo 是纯函数,副作用应该放在 createEffect 中。## 二、用 batch 合并多次更新,减少渲染次数SolidJS 默认在同步代码中会逐次触发响应式更新。如果一个操作中需要修改多个信号,用 batch 包裹可以将它们合并为一次更新:javascriptimport { batch } from 'solid-js';function handleFormSubmit(data) { batch(() => { setName(data.name); setAge(data.age); setEmail(data.email); });}陷阱:在事件处理函数中,SolidJS 的批量机制已经自动生效,不需要手动 batch。但在 setTimeout、Promise 回调等异步场景中,batch 是必要的。## 三、For 与 Index 的选择:动态列表 vs 静态列表SolidJS 提供了两种列表渲染方式,选择错误会带来性能问题:- For:以对象引用作为 key,当列表项被插入或删除时,只会创建/销毁对应项,其余项保持不变。适合动态增删的列表。- Index:以数组索引作为 key,当列表顺序变化时,只会移动已有 DOM 节点而不会销毁重建。适合只读或顺序固定的列表。javascript// 动态列表 - 用 For<For each={todos()}> {(todo) => <TodoItem text={todo.text} done={todo.done} />}</For>// 静态列表 - 用 Index 更高效<Index each={columns()}> {(col, i) => <Column name={col().name} index={i} />}</Index>陷阱:用 Index 渲染频繁增删的列表,会导致 DOM 节点频繁创建和销毁,性能反而更差。## 四、用 lazy 和 Suspense 实现代码分割大型应用中,将非首屏组件用 lazy 延迟加载,能显著减小初始包体积:javascriptimport { lazy, Suspense } from 'solid-js';const Dashboard = lazy(() => import('./Dashboard'));const Settings = lazy(() => import('./Settings'));function App() { return ( <Suspense fallback={<p>加载中...</p>}> <Dashboard /> <Settings /> </Suspense> );}技巧:将 Suspense 边界放在尽可能靠近懒加载组件的位置,这样其他不受影响的内容可以正常显示。## 五、避免不必要的响应式包装SolidJS 的响应式系统会追踪信号读取。如果一个值不需要被追踪,就不要创建信号:javascript// 错误:用 createSignal 包装不需要独立追踪的派生值const [doubled, setDoubled] = createSignal(count() * 2);// 正确:简单派生用函数即可const doubled = () => count() * 2;// 需要缓存时才用 createMemoconst expensive = createMemo(() => computeHeavy(items()));陷阱:SolidJS 组件函数体只执行一次。如果在组件函数体中读取信号但不处于响应式上下文(如 JSX、createEffect、createMemo)内,该读取只发生在初始化阶段,后续信号变化不会触发更新。## 六、用 untrack 隔离不需要追踪的依赖在 createEffect 中读取信号会自动建立依赖关系。但有时你只需要读取当前值,不想因此触发 effect 重新执行:javascriptimport { untrack } from 'solid-js';createEffect(() => { const currentUserId = userId(); // 依赖:userId 变化时重新执行 const theme = untrack(() => theme()); // 不追踪:theme 变化不会触发此 effect fetchUserData(currentUserId, theme);});典型场景:日志记录、分析上报等需要读取但不需响应变化的场景。## 七、用 Store 管理复杂嵌套状态当状态是深层嵌套的对象时,用 createStore 替代多个 createSignal。Store 提供细粒度的嵌套响应式追踪,只有真正变化的属性才会触发更新:javascriptimport { createStore } from 'solid-js/store';const [user, setUser] = createStore({ profile: { name: 'Alice', age: 28 }, settings: { theme: 'dark', lang: 'zh' }});// 只更新 profile.name,settings 不会触发任何更新setUser('profile', 'name', 'Bob');陷阱:直接给 Store 属性赋整个新对象会丢失细粒度更新。使用 reconcile 可以智能地对比新旧数据,只更新变化的部分:javascriptimport { reconcile } from 'solid-js/store';setUser(reconcile(newUserData));## 八、不要解构 props这是 SolidJS 中最常见的反模式之一。解构会破坏响应式追踪,因为解构发生在组件函数体中(只执行一次),此时读取的是初始值:javascript// 错误:解构丢失响应式function UserCard({ name, avatar }) { return <div>{name}</div>; // name 永远是初始值}// 正确:通过 props 函数调用保持响应式function UserCard(props) { return <div>{props.name}</div>;}如果需要在派生逻辑中使用 props,用 mergeProps 或 splitProps:javascriptimport { mergeProps, splitProps } from 'solid-js';function MyComponent(props) { const [local, rest] = splitProps(props, ['className', 'style']); return <div class={local.className} {...rest} />;}## 九、用控制流组件替代条件表达式SolidJS 提供了 Show、Switch/Match 等控制流组件,它们比 JavaScript 条件表达式(三元运算符、&&)更高效,因为控制流组件能精确管理 DOM 节点的创建和销毁:javascriptimport { Show, Switch, Match } from 'solid-js';// 用 Show 替代 && 运算符<Show when={isLoggedIn()} fallback={<LoginForm />}> <Dashboard /></Show>// 用 Switch/Match 替代多重三元表达式<Switch fallback={<p>未知状态</p>}> <Match when={status() === 'loading'}><Spinner /></Match> <Match when={status() === 'error'}><ErrorMessage /></Match> <Match when={status() === 'success'}><Content /></Match></Switch>技巧:Show 的 when 还支持键函数(key function),当条件从 true 变为 false 再变回 true 时,可以用 key 控制是否复用 DOM:javascript<Show when={selectedUser()} keyed> {(user) => <Profile name={user.name} />}</Show>## 十、用 onCleanup 管理副作用生命周期SolidJS 组件没有 unmount 生命周期钩子,但 onCleanup 可以在任何响应式作用域中注册清理逻辑:javascriptimport { onCleanup } from 'solid-js';function Timer() { const [count, setCount] = createSignal(0); const timer = setInterval(() => setCount(c => c + 1), 1000); onCleanup(() => clearInterval(timer)); return <p>{count()}</p>;}典型场景:清除定时器、取消订阅、关闭 WebSocket 连接、释放 WebGPU 资源等。## 十一、避免在 createEffect 中写入信号在 effect 中写入信号是最容易导致无限循环的场景:javascript// 危险:可能导致无限循环createEffect(() => { setCount(count() + 1); // count 变化 -> effect 重新执行 -> count 再变 -> ...});// 正确做法:用 createMemo 派生新值const nextCount = createMemo(() => count() + 1);// 如果确实需要根据依赖执行副作用,确保写入不同的信号createEffect(() => { const id = selectedId(); setFormData(loadForm(id)); // selectedId 和 formData 是不同信号,不会循环});## 总结SolidJS 性能优化的核心思路是:理解细粒度响应式的工作机制,让系统只更新真正需要更新的部分。关键原则包括:合理使用 memo 和 batch、选择正确的列表渲染方式、避免破坏响应式追踪(不解构 props)、用控制流组件替代条件表达式、用 Store 管理复杂状态、以及不在 effect 中写入信号。掌握这些技巧后,SolidJS 的性能优势才能被充分发挥。
服务端阅读 05月27日 16:01

SolidJS 中的控制流组件有哪些?如何使用 Show、For、Switch 等?

SolidJS 的控制流组件是框架响应式系统的核心部分,与 React 中直接使用 JavaScript 表达式不同,SolidJS 通过专用组件让响应式追踪更加精确,从而实现细粒度的 DOM 更新。下面逐一介绍每个控制流组件的用法和设计原理。Show — 条件渲染Show 是最常用的控制流组件,用于根据条件决定是否渲染内容。import { Show, createSignal } from "solid-js";function App() { const [isLoggedIn, setIsLoggedIn] = createSignal(false); return ( <Show when={isLoggedIn()} fallback={<LoginButton />}> <UserDashboard /> </Show> );}关键细节:when 的值会被响应式追踪,变化时只更新受影响的 DOM 节点fallback 在条件为 falsy 时显示,省略则不渲染任何内容当条件从 false 变为 true 时,子元素不会被重新创建,而是重新插入 DOM(记忆化机制)when 为 truthy 时,子元素函数可以接收 when 的值:<Show when={currentUser()}> {(user) => <p>欢迎,{user().name}</p>}</Show>为什么不用 && 和三元表达式? 在 SolidJS 中,{condition() && <Component />} 虽然语法上可行,但每次条件变化时子元素会被销毁再重建。而 Show 会保留子元素的 DOM 引用,切回时直接复用,性能更优。Switch / Match — 多条件分支Switch 配合 Match 处理多个互斥条件,逻辑类似 JavaScript 的 switch/case。import { Switch, Match, createSignal } from "solid-js";function StatusPanel() { const [status, setStatus] = createSignal("loading"); return ( <Switch fallback={<p>未知状态</p>}> <Match when={status() === "loading"}> <Spinner /> </Match> <Match when={status() === "success"}> <SuccessView /> </Match> <Match when={status() === "error"}> <ErrorNotice /> </Match> </Switch> );}注意事项:Match 按书写顺序评估,第一个 when 为 truthy 的分支胜出fallback 是所有分支都不匹配时的默认内容嵌套 Show 可以替代 Switch,但当条件超过两个时 Switch 更清晰For — 键控列表渲染For 是 SolidJS 中推荐的列表渲染方式,通过引用相等性追踪每个列表项。import { For, createSignal } from "solid-js";function TodoList() { const [todos, setTodos] = createSignal([ { id: 1, text: "学习 SolidJS" }, { id: 2, text: "构建项目" }, ]); return ( <ul> <For each={todos()}> {(todo) => <li>{todo.text}</li>} </For> </ul> );}For 与 Array.map 的区别:| 特性 | <For> | array.map() ||------|---------|---------------|| 更新策略 | 只更新变化的项 | 重新映射整个数组 || 键追踪 | 按引用相等自动追踪 | 无追踪 || DOM 复用 | 复用已有 DOM 节点 | 条件变化时可能重建 || 性能 | 大列表场景优势明显 | 小列表无感,大列表卡顿 |当数组发生变化时,For 只会对新增、删除、移动的项操作 DOM,不会触及其他项的更新。这也是为什么 SolidJS 文档强烈推荐用 For 替代 map。Index — 索引键控列表Index 与 For 类似,但以索引位置作为键而非引用。import { Index } from "solid-js";function ScoreBoard() { const [scores, setScores] = createSignal([95, 87, 72]); return ( <Index each={scores()}> {(score, i) => <div>第 {i + 1} 名:{score()} 分</div>} </Index> );}For 还是 Index?用 For:列表项是对象,顺序可能变化(如拖拽排序),需要按身份追踪用 Index:列表项是原始值(string/number),位置稳定,值会更新Index 的子元素函数中,每项是 getter 函数(item()),因为值可能在该位置上变化。而 For 的子元素函数直接接收项对象本身,因为身份不变。Dynamic — 动态组件选择Dynamic 根据运行时条件选择渲染哪个组件,适合标签页、多态渲染等场景。import { Dynamic, createSignal } from "solid-js";import Home from "./Home";import About from "./About";import Contact from "./Contact";const tabs = { home: Home, about: About, contact: Contact };function App() { const [activeTab, setActiveTab] = createSignal("home"); return ( <> <nav> {Object.keys(tabs).map((key) => ( <button onClick={() => setActiveTab(key)}>{key}</button> ))} </nav> <Dynamic component={tabs[activeTab()]} /> </> );}component 属性接收组件函数或组件字符串(如 "div"),还可以通过其他属性传递 props。Portal — 跨 DOM 层级渲染Portal 将子元素渲染到 DOM 中的其他位置,常用于模态框、通知、下拉菜单等需要脱离当前组件 DOM 层级的场景。import { Portal } from "solid-js/web";function Modal() { return ( <Portal mount={document.getElementById("modal-root")}> <div class="modal-overlay"> <div class="modal-content">模态框内容</div> </div> </Portal> );}Portal 解决的核心问题是 CSS 层叠上下文——模态框不会被父元素的 overflow: hidden 或 z-index 裁切。Suspense — 异步加载协调Suspense 配合资源(createResource)使用,在异步数据加载完成前显示 fallback。import { Suspense, createResource } from "solid-js";function UserProfile() { const [user] = createResource(fetchUser); return ( <Suspense fallback={<Skeleton />}> <h2>{user().name}</h2> <p>{user().bio}</p> </Suspense> );}嵌套 Suspense:<Suspense fallback={<PageSkeleton />}> <Suspense fallback={<AvatarSkeleton />}> <UserAvatar /> </Suspense> <Suspense fallback={<FeedSkeleton />}> <PostFeed /> </Suspense></Suspense>外层 Suspense 等待所有子资源加载完成,内层各自独立 fallback,互不阻塞。这种模式让页面不同区域可以独立加载,而不是整个页面白屏等待。ErrorBoundary — 错误边界ErrorBoundary 捕获子组件树中的运行时错误,防止整个应用崩溃。import { ErrorBoundary } from "solid-js";function App() { return ( <ErrorBoundary fallback={(err, reset) => ( <div> <p>出错了:{err.message}</p> <button onClick={reset}>重试</button> </div> )}> <UnstableComponent /> </ErrorBoundary> );}fallback 接收错误对象和 reset 函数,调用 reset 可以重新渲染子组件树。组件选择速查| 场景 | 推荐组件 ||------|---------|| 简单条件显示/隐藏 | Show || 多个互斥条件 | Switch + Match || 渲染对象数组 | For || 渲染原始值数组 | Index || 运行时切换组件 | Dynamic || 模态框/弹窗/通知 | Portal || 异步数据加载 | Suspense || 运行时错误兜底 | ErrorBoundary |核心原则: SolidJS 的控制流组件不是语法糖,它们与响应式系统深度集成,是精确追踪依赖和最小化 DOM 更新的关键。在实际开发中,优先使用这些组件而非 JavaScript 表达式,才能充分发挥 SolidJS 的细粒度响应式性能优势。
服务端阅读 05月27日 16:00

SolidJS 为什么不用虚拟 DOM?细粒度响应式如何实现更高性能?

虚拟 DOM 曾是前端框架的核心创新,但 SolidJS 选择了一条不同的路——完全抛弃虚拟 DOM,转而依靠编译时优化和细粒度响应式系统来实现高性能。这种设计在 JS Framework Benchmark 中持续领先,更新速度可达 React 的 5-10 倍。虚拟 DOM 的性能瓶颈在哪?虚拟 DOM 的工作流程是:状态变化 → 生成新虚拟树 → Diff 对比新旧树 → 最小化 DOM 更新。这个流程存在三个固有开销:全量渲染:React 中 setState 触发后,整个组件函数重新执行,所有 JSX 表达式重新求值,即使只有一处状态改变Diff 计算:需要遍历虚拟树进行逐层对比,组件树越大,Diff 成本越高内存占用:同时持有新旧两棵虚拟树,对大型应用内存压力显著// React:状态更新触发组件重渲染function TodoList({ items }) { const [filter, setFilter] = useState("all"); // filter 变化时,整个 TodoList 重新执行 // 即使 items 没变,所有子组件也会重渲染 return ( <div> <FilterBar filter={filter} onChange={setFilter} /> {items.filter(matchFilter(filter)).map(item => <TodoItem key={item.id} />)} </div> );}SolidJS 的核心架构:编译时 + 细粒度响应式SolidJS 从两个层面同时优化:编译时将 JSX 转换为直接 DOM 操作,运行时通过 Signal 精准追踪依赖。编译时:JSX 到 DOM 指令SolidJS 的编译器不会生成 createElement 调用,而是将 JSX 直接编译为真实的 DOM 创建和更新指令:// 编译前:你写的 JSXfunction Counter() { const [count, setCount] = createSignal(0); return <div class="counter"><span>{count()}</span></div>;}// 编译后:实际运行的代码function Counter() { const [count, setCount] = createSignal(0); const _el$ = document.createElement("div"); _el$.className = "counter"; const _el$2 = document.createElement("span"); // 关键:这里建立的是 signal 与 DOM 节点的直接绑定 insert(_el$2, count); _el$.appendChild(_el$2); return _el$;}编译产物中没有任何虚拟节点对象,也没有 Diff 算法。insert 函数在首次渲染时执行 DOM 插入,后续 count 变化时直接更新 _el$2 的文本内容。运行时:Signal 的依赖追踪机制createSignal 创建的 Signal 内部维护一个订阅者集合。当 Signal 值被读取时,当前执行的响应式上下文(Effect、Memo 或表达式)自动注册为订阅者;当值被写入时,仅通知这些订阅者执行更新:const [count, setCount] = createSignal(0);// 读取 count() 时,这个 Effect 自动注册为 count 的订阅者createEffect(() => { document.getElementById("display").textContent = count();});// 只有上面的 Effect 会被触发,其他不依赖 count 的代码完全不受影响setCount(1);这种机制的核心优势是更新粒度到单个 DOM 节点级别——一次 setCount 调用只会修改一个 textContent,不涉及组件重渲染、虚拟树对比或任何中间层。与 React 的关键差异对比| 维度 | React | SolidJS ||------|-------|---------|| 状态更新 | 触发整个组件重渲染 | 仅更新绑定的 DOM 节点 || 更新粒度 | 组件级 | 节点级 || 编译策略 | JSX → React.createElement | JSX → 原生 DOM 操作 || 运行时开销 | 虚拟树 Diff + 协调 | 无 Diff,直接 DOM 操作 || 内存占用 | 持有虚拟树 | 仅 Signal + 订阅者集合 || 组件模型 | 函数重执行 | 函数只执行一次 |"函数只执行一次"是 SolidJS 与 React 最本质的区别。React 的组件是渲染函数,每次状态更新都重新调用;SolidJS 的组件是设置函数,只在挂载时执行一次,后续更新完全由 Signal 驱动。实际性能表现在 JS Framework Benchmark 的标准化测试中,SolidJS 的表现:创建行:比 React 快约 3-4 倍更新行:比 React 快约 5-10 倍内存占用:约为 React 的 30%-50%包体积:SolidJS 运行时约 7KB(gzip),React + ReactDOM 约 40KB+需要注意的是,这些数据来自极端场景的基准测试。在实际业务中,DOM 操作通常不是主要瓶颈,网络请求和数据处理往往占据更多时间。SolidJS 的优势在高频更新场景(实时数据、动画、大型表格)中最为明显。什么时候选择 SolidJS?适合的场景:实时数据仪表盘、金融行情等高频更新界面大型列表或表格的渲染与交互对首屏加载和运行时性能有严苛要求的应用需要极小包体积的嵌入式或移动端场景需要权衡的方面:生态系统远小于 React,第三方组件库较少团队学习成本:响应式思维与 React 的单向数据流思维差异较大社区和招聘资源相对有限SolidJS 证明了虚拟 DOM 并非前端框架的必选项。通过编译时优化消除运行时开销,通过细粒度响应式实现精准更新,它在架构层面提供了另一种解决前端性能问题的思路。
服务端阅读 05月27日 16:00

什么是Solidity编程语言?请解释Solidity的基本语法、特性和最佳实践

Solidity是以太坊智能合约的核心开发语言,掌握它的语法规则和安全写法,是进入Web3开发的第一道门槛。这篇文章从合约结构、数据类型、函数机制、继承体系、错误处理、安全模式到Gas优化,逐层拆解Solidity的关键知识点。Solidity是什么Solidity是一种面向合约的静态类型高级语言,运行在以太坊虚拟机(EVM)上。它的语法借鉴了C++的声明风格、Python的简洁表达和JavaScript的对象模型,但核心设计目标是让开发者能用合约(contract)这个概念来封装状态和逻辑。一个关键认知:Solidity不是通用编程语言。它没有网络请求、文件读写或随机数生成,因为EVM是一个确定性的沙盒环境。每一行代码都要消耗Gas,每一次状态修改都会被全节点验证,这决定了写Solidity的思维方式与写传统后端完全不同。合约的基本结构一个Solidity合约由状态变量、函数、事件、修饰符和构造函数组成,结构上类似面向对象语言中的类:// SPDX-License-Identifier: MITpragma solidity ^0.8.19;contract TokenVault { uint256 public totalDeposits; // 状态变量 mapping(address => uint256) private balances; // 映射 event Deposited(address indexed user, uint256 amount); // 事件 constructor() { totalDeposits = 0; } function deposit() external payable { require(msg.value > 0, "Must send ETH"); balances[msg.sender] += msg.value; totalDeposits += msg.value; emit Deposited(msg.sender, msg.value); } function getBalance(address user) external view returns (uint256) { return balances[user]; }}几个要点:pragma solidity ^0.8.19 声明编译器版本,^表示兼容0.8.x的补丁更新状态变量默认是storage存储,永久写在链上事件(event)是链上日志,DApp前端通过监听事件来响应合约状态变化数据类型详解值类型Solidity的值类型包括布尔(bool)、整数(uint/int)、地址(address)、定长字节数组(bytes1到bytes32)和枚举(enum)。整数类型需要特别关注位数选择。uint256是最常用的,但如果你只存储0-100的数值,用uint8可以节省Gas。0.8.0版本后Solidity自带溢出检查,不再需要SafeMath库。地址类型分为address和address payable,后者多了transfer()和send()方法,能接收ETH。但实际开发中更推荐用call()代替transfer(),因为transfer()的2300 Gas限制在某些场景下会不够用。引用类型引用类型包括数组、映射(mapping)、结构体(struct)和字符串。映射是Solidity中最常用的数据结构,但有一个容易踩的坑:映射不可遍历。如果你需要列出所有键,必须额外维护一个数组来记录。另外,映射的默认值是全零值——mapping(address => uint256)中未设置的键返回0,你无法区分"没设置"和"设置为0"。结构体用于组合多个字段,但要注意结构体中映射的初始化限制:包含映射的结构体只能作为storage变量,不能作为memory局部变量。struct UserInfo { string name; uint256 balance; bool isActive;}mapping(address => UserInfo) public users;函数与可见性修饰符可见性四个可见性关键字决定了谁能调用函数:public:任何地方都能调用,编译器会自动生成同名getterexternal:只能从合约外部调用,不能在合约内部用this.fn()以外的方式调用internal:本合约和继承合约可调用,是状态变量和函数的默认可见性private:仅本合约可调用,注意private不影响链上可见性——所有数据在链上都是公开的一个常见误区:把函数设为private以为数据就安全了。链上所有存储都是公开可读的,private只是限制了Solidity层面的调用权限。状态修饰符view:只读状态,不修改。调用view函数不消耗Gas(从外部调用时)pure:不读也不写状态,完全依赖输入参数计算payable:允许函数接收ETH,通过msg.value获取金额修饰符(Modifier)修饰符是Solidity独有的权限控制机制,本质上是一个包裹函数的拦截器:modifier onlyOwner() { require(msg.sender == owner, "Caller is not the owner"); _; // 占位符,代表被修饰函数的代码插入位置}function withdraw() external onlyOwner { // 只有owner能执行}_的位置很关键。如果_放在require之前,函数逻辑会先执行再做权限检查——这就是重入攻击的温床之一。继承与接口继承Solidity使用is关键字实现继承,支持多重继承,但需要处理菱形继承问题:contract ERC20Base { mapping(address => uint256) public balances; function _transfer(address from, address to, uint256 amount) internal virtual { balances[from] -= amount; balances[to] += amount; }}contract MyToken is ERC20Base { function transfer(address to, uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient balance"); _transfer(msg.sender, to, amount); }}virtual关键字允许子合约重写,override标记重写实现。多重继承时,按is声明顺序从右到左线性化(C3线性化),最后继承的优先级最高。接口接口定义了合约的外部调用规范,只包含函数签名,不含实现:interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256);}通过接口可以与已部署的合约交互,这是DeFi组合性的基础——一个合约调用另一个合约,只需要知道它的接口和地址。抽象合约当合约中有未实现的函数时,它自动成为抽象合约,不能直接部署。抽象合约介于接口和完整合约之间:可以有部分实现,也有未实现的方法。适合作为基础模板使用。事件与日志事件是合约与外部世界的通信桥梁。当emit触发事件时,数据被写入交易日志,前端通过Web3库监听:event Transfer(address indexed from, address indexed to, uint256 value);function transfer(address to, uint256 amount) external { // ...转账逻辑 emit Transfer(msg.sender, to, amount);}indexed关键字最多标记三个参数,这些参数会被索引,支持按条件过滤查询。但indexed参数如果是动态类型(string、bytes、数组),只会存储其keccak256哈希。未indexed的参数存储在日志的data部分,可以完整读取但不支持过滤。错误处理的三种方式require——输入校验首选require检查外部条件是否满足,失败时退还剩余Gas:function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient balance"); balances[msg.sender] -= amount;}revert——复杂逻辑分支当校验逻辑不是简单的布尔判断时,用revert更清晰:function process(uint256 value) external { if (value > 100) { revert("Value too large"); } if (value == 0) { revert("Value cannot be zero"); }}自定义错误——Gas更省0.8.4版本引入的自定义错误是当前推荐的写法,比字符串错误消息省Gas:error InsufficientBalance(uint256 requested, uint256 available);function withdraw(uint256 amount) external { uint256 balance = balances[msg.sender]; if (amount > balance) { revert InsufficientBalance(amount, balance); } balances[msg.sender] -= amount;}assert——不变量检查assert用于检查代码内部不变量,如果触发说明代码有bug。0.8.0后assert失败不会消耗所有Gas,但仍应仅用于测试和不变量断言。关键全局变量Solidity提供了一系列全局变量访问交易和区块信息:msg.sender:当前调用的地址,是最常用的权限判断依据msg.value:随调用发送的ETH数量(单位wei)msg.data:完整的调用数据(函数选择器+参数编码)block.timestamp:当前区块的时间戳(秒),注意矿工有一定操控空间,不适合做精确计时block.number:当前区块号tx.origin:交易的原始发起者,永远不要用tx.origin做权限判断,它会导致钓鱼攻击一个经典攻击场景:如果合约用tx.origin == owner做权限检查,攻击者可以构造一个合约,诱导owner调用该合约,该合约再调用目标合约——此时tx.origin仍然是owner,权限检查通过。库(Library)库是Solidity中代码复用的机制,与合约类似但不能有状态变量、不能继承也不能被继承:library SafeCast { function toUint64(uint256 value) internal pure returns (uint64) { require(value <= type(uint64).max, "SafeCast: value overflow"); return uint64(value); }}contract UsingLib { using SafeCast for uint256; function process(uint256 value) external pure returns (uint64) { return value.toUint64(); // 通过using...for语法调用 }}库的调用方式有两种:内部调用(代码直接嵌入合约,不产生DELEGATECALL)和外部调用(通过DELEGATECALL执行)。OpenZeppelin的SafeMath、Strings等都是典型的库实现。安全最佳实践重入攻击防护重入攻击是Solidity最著名的安全漏洞。攻击者利用外部调用回调合约自身,在状态更新完成前重复执行:// 有漏洞的写法function withdraw() external { uint256 amount = balances[msg.sender]; (bool success, ) = msg.sender.call{value: amount}(""); // 外部调用 require(success); balances[msg.sender] = 0; // 状态更新在外部调用之后——危险!}修复方案是检查-效果-交互(Checks-Effects-Interactions)模式:先检查条件,再更新状态,最后做外部调用。同时建议使用OpenZeppelin的ReentrancyGuard:import "@openzeppelin/contracts/security/ReentrancyGuard.sol";contract SecureVault is ReentrancyGuard { mapping(address => uint256) public balances; function withdraw() external nonReentrant { uint256 amount = balances[msg.sender]; require(amount > 0, "No balance"); balances[msg.sender] = 0; // 先更新状态 (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); }}访问控制使用OpenZeppelin的Ownable或更细粒度的AccessControl,避免手动实现权限逻辑。AccessControl支持基于角色的权限管理(RBAC),适合复杂项目。整数溢出0.8.0版本后Solidity内置溢出检查,算术运算溢出会自动revert。如果确实需要无检查算术(如Gas优化的循环计数器),用unchecked {}块显式声明:for (uint256 i = 0; i < array.length; ) { // 处理逻辑 unchecked { ++i; } // 省去溢出检查的Gas}Gas优化技巧Gas优化不是微优化,是设计层面的考量。存储优化EVM的storage按256位槽位组织。将多个小于256位的变量打包到一个槽位可以节省Gas:// 差:占3个槽位struct Bad { uint64 a; // 槽位1 uint256 b; // 槽位2 uint64 c; // 槽位3}// 好:占2个槽位(a和c打包在槽位1)struct Good { uint64 a; uint64 c; // 与a共用槽位1 uint256 b; // 槽位2}使用calldata替代memory外部函数的数组和字符串参数用calldata比memory省Gas,因为calldata直接读取调用数据,不需要复制到内存:function process(address[] calldata users) external { // calldata只读,不能修改,但省Gas}短路求值require的条件按Gas消耗从低到高排列,利用短路求值跳过昂贵的检查:require(amount > 0 && balances[msg.sender] >= amount, "Invalid");缓存storage变量在循环中多次读取storage变量时,先缓存到memory:// 差:每次循环都读storagefor (uint256 i = 0; i < users.length; i++) { ... }// 好:缓存到memoryuint256 len = users.length;for (uint256 i = 0; i < len; ) { unchecked { ++i; }}代理模式与可升级合约Solidity合约部署后不可修改,代理模式通过将逻辑和数据分离来实现可升级性。核心思路是:用户调用代理合约,代理通过delegatecall将调用转发到逻辑合约,数据存储在代理合约中。// 简化的代理合约contract Proxy { address public implementation; constructor(address _impl) { implementation = _impl; } fallback() external payable { address impl = implementation; assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } }}目前主流方案是UUPS(逻辑合约中包含升级函数)和透明代理(代理合约中包含升级逻辑,由管理员控制)。UUPS的Gas更低,但升级逻辑在逻辑合约中,需要更谨慎地编写。测试与部署使用Foundry测试Foundry是目前最流行的Solidity开发工具链,测试脚本本身也是Solidity,不需要切换语言:// TokenVault.t.solimport "forge-std/Test.sol";contract TokenVaultTest is Test { TokenVault vault; function setUp() public { vault = new TokenVault(); } function testDeposit() public { vault.deposit{value: 1 ether}(); assertEq(vault.getBalance(address(this)), 1 ether); } function testRevertOnZeroDeposit() public { vm.expectRevert("Must send ETH"); vault.deposit(); }}vm.expectRevert、vm.prank等作弊码(cheatcode)是Foundry测试的核心能力,能模拟各种链上场景。部署流程标准部署流程:编写合约 -> 编写测试 -> 本地测试(Anvil) -> 测试网部署 -> 审计 -> 主网部署。永远不要跳过审计环节,即使是个人项目也建议用Slither等静态分析工具做基础检查。开发要点总结始终使用0.8.0以上版本,自带溢出检查和更多安全特性遵循检查-效果-交互模式,状态更新必须在外部调用之前不要用tx.origin做权限判断,只用msg.sender用自定义错误替代字符串错误消息,省Gas且结构化合理使用calldata和storage打包,从设计层面优化Gas使用OpenZeppelin库,不要自己实现已有标准部署前必须审计,静态分析+人工审查写完整的测试覆盖,边界条件和异常路径比正常路径更重要代理模式实现可升级性时选UUPS,更省Gas但需注意升级逻辑的安全性所有链上数据都是公开的,private不等于不可读
服务端阅读 05月27日 15:59

SharedWorker 如何实现跨标签页通信?

SharedWorker 是 Web Worker 的一种特殊形式,允许多个浏览器上下文(标签页、窗口、iframe)共享同一个 Worker 实例。与 Dedicated Worker 的一对一模型不同,SharedWorker 通过端口(MessagePort)机制实现一对多通信,是浏览器原生提供的跨标签页通信方案之一。SharedWorker 的通信机制SharedWorker 的核心在于端口通信模型。每个页面连接到同一个 SharedWorker 时,Worker 内部通过 onconnect 事件获得一个独立的 MessagePort,页面和 Worker 之间通过这个端口双向收发消息。需要特别注意的是,主线程必须显式调用 port.start() 才能激活端口的消息接收功能,这是初学者最容易遗漏的步骤。// 主线程const worker = new SharedWorker('shared-worker.js');worker.port.start(); // 必须调用,否则 onmessage 不会触发worker.port.postMessage({ type: 'greeting', text: 'Hello' });worker.port.onmessage = (event) => { console.log('来自 Worker 的消息:', event.data);};Worker 端通过 self.onconnect 监听新连接,从事件中取出端口并管理:// shared-worker.jsconst ports = [];self.onconnect = (event) => { const port = event.ports[0]; ports.push(port); port.start(); port.onmessage = (e) => { // 广播给所有其他连接 ports.forEach((p) => { if (p !== port) { p.postMessage(e.data); } }); };};实现跨标签页通信的完整方案跨标签页通信的关键在于 Worker 端维护所有连接的端口列表,当某个端口收到消息时,将消息转发给其他所有端口。同时需要处理新连接加入时的状态初始化问题。连接管理与消息广播// shared-worker.jsconst connections = new Map();let connectionId = 0;self.onconnect = (event) => { const port = event.ports[0]; const id = ++connectionId; connections.set(id, port); port.start(); // 通知新连接其 ID port.postMessage({ type: 'connected', id }); // 通知其他连接有新成员加入 broadcast({ type: 'peer-joined', id }, id); port.onmessage = (e) => { const { type, data, target } = e.data; if (type === 'broadcast') { broadcast({ type: 'message', from: id, data }, id); } else if (type === 'send-to' && target) { // 定向发送给指定连接 const targetPort = connections.get(target); if (targetPort) { targetPort.postMessage({ type: 'private', from: id, data }); } } };};function broadcast(message, excludeId) { connections.forEach((port, connId) => { if (connId !== excludeId) { port.postMessage(message); } });}主线程封装主线程可以将 SharedWorker 的通信封装为更易用的接口:// cross-tab-channel.jsclass CrossTabChannel { constructor(workerUrl) { this.worker = new SharedWorker(workerUrl); this.port = this.worker.port; this.listeners = new Map(); this.id = null; this.port.start(); this.port.onmessage = (event) => { const { type, id } = event.data; if (type === 'connected') { this.id = id; return; } const handlers = this.listeners.get(type) || []; handlers.forEach((handler) => handler(event.data)); }; } on(type, handler) { if (!this.listeners.has(type)) { this.listeners.set(type, []); } this.listeners.get(type).push(handler); } send(data) { this.port.postMessage({ type: 'broadcast', data }); } sendTo(targetId, data) { this.port.postMessage({ type: 'send-to', target: targetId, data }); }}// 使用const channel = new CrossTabChannel('shared-worker.js');channel.on('message', (data) => { console.log(`来自标签页 ${data.from}:`, data.data);});channel.send('你好,其他标签页!');典型应用场景跨标签页状态同步最常见的场景是多个标签页共享同一份状态。例如用户在某个标签页切换了主题,其他标签页立即响应:// shared-worker.jslet state = { theme: 'light', user: null };self.onconnect = (event) => { const port = event.ports[0]; port.start(); // 新连接立即获取当前状态 port.postMessage({ type: 'state-init', state }); port.onmessage = (e) => { if (e.data.type === 'state-update') { state = { ...state, ...e.data.payload }; broadcast({ type: 'state-changed', state }, port); } };};WebSocket 连接共享在一个标签页建立 WebSocket 连接,其他标签页通过 SharedWorker 复用同一条连接,减少服务器压力和网络开销:// shared-worker.jsconst ports = [];let ws = null;self.onconnect = (event) => { const port = event.ports[0]; ports.push(port); port.start(); // 懒初始化 WebSocket if (!ws) { ws = new WebSocket('wss://example.com/realtime'); ws.onmessage = (msg) => { const data = JSON.parse(msg.data); ports.forEach((p) => p.postMessage({ type: 'ws-message', data })); }; ws.onclose = () => { ws = null; // 允许重连 }; } port.onmessage = (e) => { if (e.data.type === 'ws-send' && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(e.data.payload)); } };};连接断开的检测SharedWorker 没有内置的连接断开通知机制。port.onclose 事件在当前规范中并不可靠,标准做法是通过心跳检测来判断连接是否存活:// shared-worker.jsconst connections = new Map();const HEARTBEAT_INTERVAL = 5000;const HEARTBEAT_TIMEOUT = 10000;self.onconnect = (event) => { const port = event.ports[0]; const id = Date.now() + Math.random(); let lastActive = Date.now(); connections.set(id, { port, lastActive }); port.start(); port.postMessage({ type: 'connected', id }); port.onmessage = (e) => { lastActive = Date.now(); // 处理其他消息... };};// 定期检查连接活性setInterval(() => { const now = Date.now(); connections.forEach((conn, id) => { if (now - conn.lastActive > HEARTBEAT_TIMEOUT) { connections.delete(id); } });}, HEARTBEAT_INTERVAL);主线程配合发送心跳:// 主线程setInterval(() => { worker.port.postMessage({ type: 'heartbeat' });}, 5000);浏览器兼容性SharedWorker 的兼容性需要重点关注:Chrome、Firefox、Edge:完整支持Safari:从 Safari 16(2022 年)开始支持,更早版本不支持移动端浏览器:支持有限,iOS Safari 16+ 支持,Android Chrome 支持在生产环境中,如果需要兼容旧版 Safari 或移动端,应提供降级方案,比如回退到 BroadcastChannel API 或 localStorage + storage 事件。与其他跨标签页通信方案的对比| 方案 | 通信方向 | 数据类型 | 兼容性 | 适用场景 ||------|---------|---------|--------|---------|| SharedWorker | 双向 | 任意结构化克隆数据 | Chrome/Firefox/Edge/Safari 16+ | 需要共享逻辑和状态的场景 || BroadcastChannel | 单向广播 | 任意结构化克隆数据 | Chrome/Firefox/Edge/Safari 15.4+ | 简单的一对多通知 || localStorage + storage 事件 | 单向广播 | 仅字符串 | 所有浏览器 | 简单状态同步的降级方案 || postMessage(同源 iframe) | 双向 | 任意结构化克隆数据 | 所有浏览器 | iframe 间通信 |BroadcastChannel 的 API 更简洁,适合纯广播场景;SharedWorker 更适合需要在 Worker 端维护状态或执行逻辑的场景(如 WebSocket 共享连接)。两者不是互斥的,可以根据需求选择。常见陷阱忘记调用 port.start()这是最常见的错误。Dedicated Worker 不需要这一步,但 SharedWorker 的端口必须手动激活:// 错误:消息无法接收const worker = new SharedWorker('worker.js');worker.port.onmessage = handler; // 永远不会触发// 正确:先启动端口const worker = new SharedWorker('worker.js');worker.port.start();worker.port.onmessage = handler;SharedWorker 内部无法访问 DOM 和 localStorageSharedWorker 运行在独立的 Worker 线程中,无法访问 window、document、localStorage 等 DOM API。如果需要持久化数据,只能通过 IndexedDB 或将数据回传给主线程由主线程写入 localStorage。调试方法SharedWorker 无法在普通开发者工具的 Sources 面板中直接看到。Chrome 中需要访问 chrome://inspect/#workers,在 Shared Workers 区域找到对应的 Worker 点击 inspect 打开独立的调试窗口。同源限制SharedWorker 严格受同源策略约束。只有协议、域名、端口完全相同的页面才能共享同一个 Worker 实例。不同子域之间也无法共享,除非通过 document.domain 设置(但该特性已被废弃)。
服务端阅读 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。关键是根据动画复杂度、交互需求和兼容性要求做出权衡。