面试题手册

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

服务端阅读 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 设置(但该特性已被废弃)。