面试题手册

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

服务端阅读 05月27日 17:39

PromQL 常用函数怎么选?一文讲透用法与避坑

PromQL 是 Prometheus 的核心查询语言,掌握常用函数是写出高效监控告警规则的基础。本文按实际使用场景分类梳理 PromQL 函数,并标注每种函数的适用指标类型和常见陷阱。变化率与增量函数变化率类函数是 PromQL 中使用频率最高的函数,但也是最容易用错的一类。rate(v range-vector):计算 Counter 类型指标在时间窗口内的平均每秒增长率。适合绘制趋势图和配置告警规则,结果会被平滑处理。例如 rate(http_requests_total[5m]) 计算过去 5 分钟内 HTTP 请求的每秒平均增长率。irate(v range-vector):计算 Counter 的瞬时增长率,仅使用时间窗口内最后两个数据点。适合绘制细粒度波动图,但不适合告警(抖动太大)。例如 irate(http_requests_total[5m])。increase(v range-vector):计算 Counter 在时间窗口内的总增量,等价于 rate() * 窗口秒数。返回值是整数趋势但实际可能是小数(外推导致)。例如 increase(http_requests_total[1h]) 表示过去 1 小时新增了多少请求。delta(v range-vector):计算 Gauge 类型指标的差值,仅用于 Gauge。例如 delta(cpu_temp_celsius[1h]) 表示温度变化量。关键区别:rate 和 irate 只能用于 Counter,delta 用于 Gauge。increase 虽然概念上返回整数,但由于外推机制,结果可能非整数。如果需要精确整数,用 floor(increase(...))。时间窗口选择:窗口太短会导致数据稀疏时出现 NaN,太长会掩盖波动。一般建议窗口至少是采集间隔的 4 倍。聚合函数聚合函数用于将多个时间序列合并为更少的结果序列,是构建大盘面板的基础。sum():求和。最常用的聚合,例如 sum(rate(http_requests_total[5m])) 计算总 QPS。avg():求平均值。注意平均值容易受极端值影响,监控延迟场景更推荐用分位数。max() / min():最大值和最小值。适合找出峰值或谷值。count():计数。统计时间序列的数量,例如 count(up == 0) 统计宕机实例数。count_values():按值分组计数。例如 count_values("status", http_requests_total) 统计各状态码出现次数。topk(k, …):返回值最大的 k 个时间序列。适合找出负载最高的实例,例如 topk(5, rate(http_requests_total[5m]))。bottomk(k, …):返回值最小的 k 个时间序列。quantile(φ, …):计算分位数。例如 quantile(0.95, rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])) 估算 P95 延迟。分组聚合:使用 by 子句按标签分组,例如 sum(rate(http_requests_total[5m])) by (method) 按请求方法分组求和。使用 without 排除指定标签,sum(...) without (instance) 按 instance 维度聚合。时间窗口聚合函数(overtime 系列)这类函数对时间窗口内每个时间序列的数据点做聚合,与上面聚合函数的区别是:聚合函数跨序列合并,overtime 系列在单个序列的时间维度上聚合。avgovertime(x[5m]):5 分钟内平均值,适合平滑 Gauge 指标。maxovertime(x[5m]):5 分钟内最大值,常用于找出峰值。minovertime(x[5m]):5 分钟内最小值。sumovertime(x[5m]):5 分钟内求和。countovertime(x[5m]):5 分钟内数据点数量,可用于检测数据缺失。quantileovertime(0.9, x[5m]):5 分钟内分位数计算。stddevovertime(x[5m]):5 分钟内标准差,衡量波动程度。stdvarovertime(x[5m]):5 分钟内方差。典型用法:max_over_time(node_load15[1h]) 找出过去 1 小时最大负载。数学与取整函数abs(v):绝对值。ceil(v):向上取整,例如 ceil(cpu_usage) 将 72.3 变为 73。floor(v):向下取整。round(v, nearest):四舍五入到最近的整数或指定精度。round(3.14159, 0.01) 结果为 3.14。sqrt(v):平方根。exp(v):指数函数 e^v。ln(v) / log2(v) / log10(v):自然对数、以 2 为底和以 10 为底的对数。clamp(v, min, max):将值限制在指定范围内。clamp(cpu_percent, 0, 100) 确保百分比不超出 0-100。clampmax(v, max) / clampmin(v, min):单侧限制。预测与统计函数predict_linear(v range-vector, t):基于 2 小时(默认)的线性回归预测 t 秒后的值。常用于磁盘空间预警:predict_linear(node_filesystem_avail_bytes[1h], 3600*24) < 0 预测 24 小时后磁盘是否耗尽。deriv(v range-vector):计算 Gauge 指标的瞬时导数(变化率),适合 Gauge 趋势分析。holt_winters(v range-vector, sf, tf):基于 Holt-Winters 双指数平滑进行平滑和预测。sf 是平滑因子(0-1),tf 是趋势因子(0-1)。适合有周期性波动的指标。标签操作函数labelreplace(v, dstlabel, replacement, src_label, regex):基于正则从源标签提取值写入目标标签。例如 label_replace(up, "host", "$1", "instance", "(.*):.*") 从 instance 标签提取主机名写入 host 标签。labeljoin(v, dstlabel, separator, srclabel1, srclabel2, …):将多个源标签拼接后写入目标标签。例如 label_join(up, "endpoint", "-", "job", "instance") 将 job 和 instance 用短横线拼接。其他实用函数changes(v range-vector):计算时间窗口内值变化的次数。适合检测配置变更或重启次数:changes(process_start_time_seconds[1d]) 检测一天内重启次数。absent(v):如果传入的向量没有数据点则返回 1,有数据则返回空。常用于检测指标消失:absent(up{job="myapp"}) 在 myapp 完全无数据时触发告警。time():返回当前 Unix 时间戳。常用于相对时间计算。timestamp(v):返回向量中每个样本的时间戳。sort(v) / sort_desc(v):升序/降序排列。histogram_quantile(φ, v):从直方图桶中计算分位数,是延迟监控的核心函数。例如 histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) 计算 P99 延迟。注意 by (le) 不可省略,le 是桶边界标签。实战示例计算 QPS 并按服务分组:sum(rate(http_requests_total[5m])) by (service)计算内存使用率百分比:sum(container_memory_usage_bytes) by (container) / sum(container_spec_memory_limit_bytes) by (container) * 100计算 P95 请求延迟:histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, method))预测 24 小时后磁盘是否耗尽:predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[1h], 3600*24) < 0检测服务重启:changes(process_start_time_seconds[1d]) > 0检测指标缺失:absent(up{job="critical-service"})常见陷阱对 Gauge 用 rate:rate/irate/increase 只适用于 Counter(只增不减的累计值)。Gauge 用 delta 或 deriv。窗口过短:采集间隔 15 秒却用 [30s] 窗口,容易得到 NaN。窗口建议至少 4 倍采集间隔。increase 结果非整数:由于外推机制,increase 可能返回小数。需要精确整数时加 floor()。histogram_quantile 忘记 by(le):le 标签是分位数计算必需的,省略会导致结果错误。topk 不稳定:topk 结果随数据波动变化大,不适合直接用于告警。absent 的触发条件:absent 返回 1 表示无数据,告警规则应写 absent(...) == 1 而非 absent(...) > 0。
服务端阅读 05月27日 17:39

如何实现 Prometheus 的高可用和联邦架构?

Prometheus 本身是单机架构,不内置集群能力。生产环境中,单点 Prometheus 面临两个核心风险:实例宕机导致监控盲区,单机存储和采集能力无法支撑大规模集群。解决思路分两条线——高可用保证不丢数据不中断,联邦架构保证能横向扩展。高可用方案:从简单到可靠多副本冗余最直接的方式是部署两个或更多 Prometheus 实例,配置完全相同的目标列表。它们各自独立采集、独立存储、独立告警。查询时通过负载均衡(如 Nginx、HAProxy)将请求分散到不同实例。优点是部署简单,缺点是每个实例都存全量数据,存储成本翻倍,且两个实例的告警可能同时触发造成重复通知。需要配合 Alertmanager 的 cluster 模式做告警去重。Thanos 方案:高可用的标准答案Thanos 在 Prometheus 之上增加四个核心组件,解决长期存储和全局查询问题:Sidecar:与 Prometheus 部署在同一 Pod,每隔 2 小时将 TSDB 块上传到对象存储(S3/OSS),同时提供 StoreAPI 让 Query 组件直接读取 Prometheus 的近端数据Store Gateway:从对象存储中读取历史数据块,响应 Query 的查询请求Query:聚合多个数据源(Sidecar + Store Gateway),对外提供统一的 PromQL 查询接口,自动去重 HA 副本的数据Compact:对对象存储中的历史块做降采样和合并,减少存储占用和查询扫描量数据流:Prometheus 采集 → Sidecar 上传到对象存储 → Query 同时查询 Sidecar(热数据)和 Store Gateway(冷数据)→ 返回合并结果。Query 的 --deduplicate 参数会基于 replica label 自动去重,解决多副本数据重复问题。关键配置:每个 Prometheus 实例必须设置不同的 external_labels(如 replica: A / replica: B),这是 Query 去重的依据。联邦架构:横向扩展的分层设计联邦是 Prometheus 原生的数据汇总机制。每个 Prometheus 实例暴露 /federate 端点,上级 Prometheus 可以像抓取 Exporter 一样从该端点拉取指标。层级联邦典型三层架构:边缘层(Edge):每个集群部署独立的 Prometheus,采集本集群所有目标的详细指标,scrape_interval 设为 15s区域层(Regional):从多个边缘实例拉取聚合后的指标(通过 recording rules 预聚合),scrape_interval 放宽到 30-60s全局层(Global):汇总所有区域的关键指标,用于跨集群看板和全局告警关键点:上级不需要拉取全量指标,通过 match[] 参数只拉取需要的指标,大幅降低数据量。联邦配置示例scrape_configs: - job_name: 'federate' scrape_interval: 30s honor_labels: true metrics_path: '/federate' params: 'match[]': - '{job="prometheus"}' - '{__name__=~"job:.*"}' static_configs: - targets: - 'edge-prometheus-cluster1:9090' - 'edge-prometheus-cluster2:9090'honor_labels: true 确保源实例的标签不被覆盖,避免数据来源混淆。match[] 只拉取 job="prometheus" 和 recording rules 产生的聚合指标,而非全量 raw metrics。跨服务联邦另一种场景:不同的 Prometheus 分别监控不同业务域(基础设施、中间件、业务应用),通过联邦在全局实例上汇聚跨域指标,做关联分析和统一看板。与层级联邦不同,这里不是按地域分层,而是按功能域分片。Thanos vs Cortex vs VictoriaMetrics:如何选择Thanos适合中大规模(10-100 个集群),核心优势是与原生 Prometheus 无缝集成——现有 Prometheus 不需要改动,加 Sidecar 即可。依赖对象存储做长期持久化,Query 组件天然支持 HA 去重。缺点是组件多、运维复杂度高,Query 查询冷数据时延迟较大(需要从对象存储加载块)。Cortex适合多租户场景(SaaS 平台、大型组织内部多团队共用)。完全分布式架构,数据写入和查询都可以水平扩展,通过分布式 kv-store(Consul/etcd)做成员管理。支持多租户隔离,每个租户有独立的速率限制和存储策略。但部署和运维门槛最高,需要依赖多个基础设施组件。VictoriaMetrics适合性能优先、资源有限的场景。单二进制即可部署(也支持集群模式),兼容 Prometheus 的查询和采集协议,可以直接替换 Prometheus。写入和查询性能优于 Prometheus,内存占用更低。缺点是生态不如 Thanos 成熟,高级特性(如多租户)需要在集群版中才能使用。决策参考| 规模 | 推荐方案 | 理由 ||------|---------|------|| 1-5 个集群 | 多副本 + 负载均衡 | 运维最简,够用 || 5-50 个集群 | Thanos | 生态成熟,HA + 长期存储一体化 || 多租户需求 | Cortex | 原生多租户支持 || 性能优先/资源紧张 | VictoriaMetrics | 低资源高吞吐 |生产环境注意事项数据持久化:Prometheus 默认数据存在本地磁盘,实例销毁数据即丢失。必须配置远程写入(remote_write)到外部存储,或使用 Thanos Sidecar 定期上传到对象存储。监控的监控:Prometheus 自身的健康状态需要有独立的监控方案。常见做法是用一个轻量级 Prometheus 或 Grafana Agent 监控主 Prometheus 的 up 指标和采集延迟。告警去重:多副本部署下,两个实例会触发相同告警。必须配置 Alertmanager 集群模式,并设置 group_by 包含告警名称和关键标签,确保同一告警只通知一次。联邦的性能开销:每增加一层联邦,上级实例的内存增加约 5%。全局 Prometheus 的资源规划要预留余量,特别是当边缘集群数量超过 50 个时。逐步上线:先在测试环境验证联邦配置和 match[] 规则是否符合预期,确认数据量和延迟在可接受范围内,再推广到生产环境。
服务端阅读 05月27日 17:39

Prometheus Exporter 有哪些常用类型?选型与配置实战

Prometheus 本身只负责数据采集和存储,真正的指标来源要靠 Exporter。Exporter 是一个独立进程,它把各种系统、数据库、应用的内部指标转换成 Prometheus 能够抓取的格式暴露出来。理解每类 Exporter 的定位和关键配置,是搭建可观测体系的基础。系统层 ExporterNode Exporter 是部署量最大的 Exporter 之一,几乎所有 Prometheus 环境都会跑它。它采集 Linux/Unix 主机的 CPU 使用率、内存余量、磁盘 I/O、网络吞吐和文件系统挂载状态,暴露的指标如 nodecpusecondstotal 和 nodememoryMemAvailablebytes 是容量规划和告警的基础数据源。部署方式很简单,在每台主机上以 systemd 服务或者 DaemonSet 运行,默认监听 9100 端口。Windows Exporter 通过 WMI 接口采集 Windows 服务器指标,覆盖 CPU、内存、磁盘、网络以及 Windows 服务和 IIS 性能计数器。如果你的环境里混合了 Windows 节点,它是 Node Exporter 的对等替代。数据库 ExporterMySQL Exporter 连接到 MySQL 或 MariaDB 实例后,暴露连接数、查询吞吐、InnoDB 缓冲池命中率、慢查询计数和主从复制延迟等指标。关键指标 mysqlglobalstatusthreadsconnected 能帮你判断连接池是否够用,mysqlglobalstatusslowqueries 则是慢查询巡检的入口。配置时通过 DATASOURCENAME 环境变量传入 DSN,建议使用只读账号。PostgreSQL Exporter 采集活动连接数、事务速率、缓存命中率、锁等待和复制状态。它支持通过查询 pgstatstatements 扩展来暴露 SQL 级别的性能数据,需要在 postgresql.conf 中提前开启该扩展。Redis Exporter 暴露内存使用量、键空间命中率、连接客户端数、驱逐键数和命令统计。对于 Redis Cluster 模式,它可以逐节点采集,帮助你定位热 key 和内存不均衡问题。应用层 ExporterBlackbox Exporter 不采集指标,而是主动探测外部端点的可用性。它支持 HTTP/HTTPS 请求、TCP 连接、DNS 解析和 ICMP Ping。你用它来验证服务是否可达、SSL 证书是否过期、DNS 解析是否正常。配置时在 blackbox.yml 中定义模块,然后在 Prometheus 的 scrape_configs 里用 relabel 把探测目标注入。JMX Exporter 是 Java 应用的监控桥梁。它通过 JMX 获取 JVM 堆内存、GC 暂停、线程数以及应用层的 MBean 指标。Kafka、Tomcat、Cassandra 等中间件都能通过它暴露指标。有两种运行模式:Java Agent 模式随应用启动,独立进程模式单独采集。容器与云平台 ExportercAdvisor 采集容器的 CPU、内存、网络和文件系统使用量,在 Kubernetes 里已经内嵌到 Kubelet,不需要额外部署就能拿到容器级别的资源数据。Kube-State-Metrics 关注的是 Kubernetes 资源对象的状态而非资源用量。它暴露 Pod 的生命周期阶段、Deployment 的副本数偏差、Job 的完成状态和 PVC 的绑定情况。和 cAdvisor 互补,一个看资源消耗,一个看对象健康。选型决策选 Exporter 的核心原则是只部署你真正需要监控的组件。每个 Exporter 都会消耗目标系统的资源,MySQL Exporter 的查询会给数据库带来额外负载,JMX Exporter 的 MBean 采集会占用 JVM 堆外内存。建议先梳理业务关键路径,沿着路径部署对应的 Exporter,而不是一股脑全装。对于 Kubernetes 环境,Node Exporter + cAdvisor + Kube-State-Metrics 三件套是起步配置,再根据业务组件加 MySQL Exporter、Redis Exporter 等。对于传统 VM 环境,Node Exporter + 业务相关的数据库 Exporter 即可覆盖基础监控。配置示例以下是一个涵盖系统、数据库和探测的典型配置:scrape_configs: - job_name: 'node' scrape_interval: 15s static_configs: - targets: ['node1:9100', 'node2:9100'] labels: env: 'production' - job_name: 'mysql' scrape_interval: 30s static_configs: - targets: ['mysql-exporter:9104'] - job_name: 'blackbox-http' scrape_interval: 60s metrics_path: /probe params: module: [http_2xx] static_configs: - targets: - https://api.example.com/health relabel_configs: - source_labels: [__address__] target_label: __param_target - source_labels: [__param_target] target_label: instance - target_label: __address__ replacement: blackbox-exporter:9115注意 scrape_interval 的设置策略:系统级指标变化快,15 秒采集一次能及时捕捉异常;数据库指标相对稳定,30 秒足够;探测类任务频率可以降到 60 秒,避免对目标造成压力。常见问题Exporter 本身挂了怎么办?Prometheus 的 up 指标会变成 0,配合 alertmanager 规则 for 1m 即可触发告警。建议对每个 Exporter 都配置 up == 0 的告警。指标太多导致 Prometheus 存储暴涨?用 metricrelabelconfigs 过滤掉不需要的指标。例如 Node Exporter 默认暴露数百个指标,大部分文件系统指标如果不需要可以丢弃。MySQL Exporter 连接报错?检查 DSN 格式是否正确,确认账号有 PROCESS 和 REPLICATION CLIENT 权限,网络策略是否放通了 Exporter 到数据库的连接。
服务端阅读 05月27日 17:39

Prometheus 工作原理是什么?从架构到数据流转全解析

Prometheus 是 CNCF 毕业的开源监控告警系统,以拉取(Pull)模式采集指标、TSDB 存储时序数据、PromQL 查询语言为核心,成为云原生可观测性的事实标准。本文从架构组件到数据流转,系统讲解其工作原理。核心架构与组件Prometheus 的架构围绕数据采集、存储、查询和告警四个环节展开,各组件协同完成从指标暴露到告警通知的完整链路。Prometheus Server 是整个系统的核心,承担三个职责:通过 Scraper 定期从 Target 拉取指标数据,将数据写入本地 TSDB 存储,并对外提供 PromQL 查询接口。Server 本身是单节点部署,不依赖分布式存储,这也是它轻量高效的原因。Exporter 是指标转换桥梁。各类第三方系统(MySQL、Redis、Node、Nginx 等)本身不暴露 Prometheus 格式的指标,Exporter 负责将它们的内部指标转换为标准格式,供 Server 拉取。每个 Exporter 暴露一个 /metrics HTTP 端点。Pushgateway 解决短期任务的指标上报问题。批处理任务、Cron Job 等短生命周期进程可能在 Server 拉取前就已结束,Pushgateway 作为中间缓存接收这类任务主动推送的指标,再由 Server 从 Pushgateway 拉取。Alertmanager 接收 Server 推送的告警,负责去重、分组、路由和静默,最终通过邮件、Slack、钉钉、Webhook 等方式通知。它支持抑制规则避免告警风暴,支持多路由将不同级别告警分发到不同渠道。数据流转全链路理解 Prometheus 工作原理的关键是弄清数据从产生到消费的完整链路。第一步:指标暴露。 被监控服务自身或通过 Exporter 在 /metrics 端点暴露指标,每个指标由指标名称和一组键值对标签唯一标识,例如 http_requests_total{method="GET",path="/api"} 。第二步:服务发现与拉取。 Prometheus Server 通过静态配置或服务发现机制(Kubernetes SD、Consul SD、DNS SRV 等)找到 Target 地址,按 scrape_interval(默认 15 秒)周期性发送 HTTP 请求拉取指标数据。服务发现让 Prometheus 能自动感知 Kubernetes 中 Pod 的增删,无需手动更新配置。第三步:TSDB 存储与压缩。 拉取的样本数据以时间序列形式写入本地 TSDB。TSDB 采用列式存储,每个时间线独立存放,并通过压缩算法大幅降低存储占用。默认保留 15 天数据,可通过 --storage.tsdb.retention.time 调整。对于长期存储需求,可配置 Remote Write 将数据写入 Thanos、GreptimeDB 等远端存储。第四步:PromQL 查询与聚合。 用户通过 PromQL 查询时序数据,支持即时查询和范围查询。常用操作包括速率计算 rate(http_requests_total[5m])、聚合 sum by (method)(rate(http_requests_total[5m]))、预测 predict_linear(node_filesystem_free[1h], 3600) 等。第五步:规则评估与告警触发。 Server 根据 alert.rules 中定义的规则持续评估指标,当条件满足且持续 for 指定时长后,将告警推送到 Alertmanager。Pull 模式的设计考量Prometheus 选择 Pull 而非 Push 模式并非偶然。Pull 模式下 Server 掌握拉取节奏,避免被监控端突发流量冲垮;Server 可感知 Target 是否存活——拉取失败即意味着目标不可达;同时无需在被监控端配置 Server 地址,降低耦合。对于必须 Push 的场景(短任务、Federation 跨集群),通过 Pushgateway 和 Remote Write 提供补充。关键配置项解析Prometheus 的主配置文件 prometheus.yml 控制着采集行为:scrape_interval:全局拉取间隔,默认 15 秒,可按 Job 覆盖evaluation_interval:规则评估间隔,默认 15 秒scrape_timeout:单次拉取超时,默认 10 秒,不得超过 scrape_intervalmetric_relabel_configs:拉取后对指标标签进行重写、过滤,减少无用时间线合理的配置调优能显著降低 TSDB 的时间线基数,提升查询性能。告警配置实战一条完整的告警规则包含条件、持续时间和标签:groups: - name: node-alerts rules: - alert: HighMemoryUsage expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9 for: 5m labels: severity: critical annotations: summary: "节点 {{ $labels.instance }} 内存使用率超过 90%"for 字段确保瞬时波动不会触发告警,只有持续超阈值才正式触发。Alertmanager 收到后按路由规则分发。版本演进与最新特性Prometheus 3.0 于 2024 年底发布,是七年来最大版本更新。截至 2026 年 5 月,最新版本为 v3.12,LTS 版本为 v3.5.x。主要进展包括:原生直方图(Native Histogram)在 v3.8 达到稳定状态,提供更高效的桶式聚合Remote Write 2.0 协议提升远端写入性能和可靠性UI 全面现代化,内置更直观的查询界面OTLP 写入支持,可直接接收 OpenTelemetry 指标这些演进使 Prometheus 在保持轻量架构的同时,逐步补齐长期存储和多协议兼容的短板。适用场景与局限Prometheus 在云原生容器监控、微服务可观测性、基础设施指标采集等场景下表现优异,尤其与 Kubernetes 的深度集成使其成为集群监控的首选。需要注意的是,Prometheus 不适合需要 100% 精度的计费场景(采样间隔内可能丢失数据),原生 TSDB 的存储周期有限(长期存储需搭配远端方案),且不擅长日志和链路追踪的采集——这部分通常由 Loki 和 Tempo 补充,共同构成完整的可观测性体系。
服务端阅读 05月27日 17:38

如何优化MariaDB查询性能?

查询性能是数据库系统的生命线。一条低效的SQL可能拖垮整个应用,而一次精准的优化能让响应时间从秒级降到毫秒级。这篇文章从诊断、索引、写法、配置四个层面,给出经过生产验证的优化方法。用 EXPLAIN 定位性能瓶颈优化之前,先要找到问题。EXPLAIN 是最直接的诊断工具:EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'paid';输出中有四个字段值得重点关注:type — 访问类型,从差到优依次为 ALL → index → range → ref → eq_ref → const。出现 ALL 意味着全表扫描,必须优化key — 实际使用的索引。如果为 NULL,说明索引未被命中rows — 预估扫描行数。数字越大,查询越慢Extra — Using filesort 表示额外排序,Using temporary 表示使用了临时表,两者都应尽量避免一个简单的判断标准:type 不是 ALL 且 Extra 没有 Using filesort/Using temporary,查询基本合格。索引:最有效的加速手段建立合适的复合索引单列索引在多条件查询时往往不够用。复合索引遵循最左前缀原则,把区分度高的列放前面:-- 假设查询条件为 WHERE user_id = ? AND status = ?-- user_id 区分度远高于 status,放前面CREATE INDEX idx_user_status ON orders(user_id, status);用覆盖索引避免回表当查询的列全部包含在索引中时,引擎无需回表读取数据行,性能提升显著:-- 索引 idx_user_status(user_id, status) 无法覆盖此查询(需要 amount 列)SELECT user_id, status, amount FROM orders WHERE user_id = 100;-- 建立覆盖索引后,直接从索引读取所有数据CREATE INDEX idx_user_status_amount ON orders(user_id, status, amount);避免索引失效的常见写法以下写法会导致索引无法命中:对索引列使用函数:WHERE YEAR(created_at) = 2025 改为 WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01'隐式类型转换:WHERE varchar_col = 123 改为 WHERE varchar_col = '123'前缀模糊查询:WHERE name LIKE '%John' 改为 WHERE name LIKE 'John%'使用 OR 连接不同索引列:改用 UNION ALL 拆分查询写法的优化技巧只查需要的列SELECT * 是性能杀手。它强制读取所有列的数据,增加 I/O 和内存开销,还可能破坏覆盖索引:-- 不推荐SELECT * FROM users WHERE id = 1;-- 推荐:只查业务需要的列SELECT id, name, email FROM users WHERE id = 1;用 JOIN 替代子查询MariaDB 优化器对子查询的处理不如 JOIN 高效,特别是 IN 子查询:-- 不推荐SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);-- 推荐SELECT u.id, u.name, u.email FROM users uINNER JOIN orders o ON u.id = o.user_idWHERE o.amount > 1000;UNION ALL 替代 UNIONUNION 会对结果去重,需要额外的排序操作。如果确定结果集无重复,用 UNION ALL 省掉去重开销:-- 不需要去重时SELECT name FROM customers WHERE region = 'east'UNION ALLSELECT name FROM suppliers WHERE region = 'east';深分页的两种优化方案OFFSET 值很大时,数据库需要扫描并跳过前面的所有行:-- 传统写法:跳过 10 万行,极其缓慢SELECT * FROM orders ORDER BY id LIMIT 100000, 10;-- 方案一:游标分页(要求排序字段连续且有索引)SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;-- 方案二:延迟关联(先查主键再回表,减少扫描列数)SELECT o.* FROM orders oINNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 10) tmpON o.id = tmp.id;JOIN 优化被驱动表的连接列必须有索引小结果集驱动大表,减少循环次数当优化器选错连接顺序时,用 STRAIGHT_JOIN 强制指定:SELECT * FROM small_table sSTRAIGHT_JOIN large_table l ON s.id = l.small_id;配置层面的调优InnoDB 缓冲池这是影响 InnoDB 性能最重要的参数,建议设为物理内存的 50%-70%:innodb_buffer_pool_size = 4Ginnodb_buffer_pool_instances = 4排序和连接缓冲sort_buffer_size = 4M -- 每个连接的排序缓冲join_buffer_size = 4M -- 每个无索引连接的缓冲read_rnd_buffer_size = 4M -- MRR 读取缓冲临时表大小tmp_table_size = 256Mmax_heap_table_size = 256M超过此大小的临时表会写到磁盘,导致性能骤降。关于查询缓存注意:MariaDB 10.6 起默认禁用查询缓存,后续版本已移除该功能。如果使用 10.6+,不要配置 querycachesize,而是关注应用层缓存(如 Redis)。监控慢查询开启慢查询日志,定期分析并优化:SET GLOBAL slow_query_log = ON;SET GLOBAL long_query_time = 1; -- 超过 1 秒记录SET GLOBAL log_queries_not_using_indexes = ON; -- 记录未使用索引的查询结合 pt-query-digest 工具分析慢查询日志,找出最需要优化的 SQL:pt-query-digest /var/lib/mysql/slow.log优化决策路径面对一个慢查询,按以下顺序排查:先用 EXPLAIN 查看执行计划,确认是否走了索引如果走了索引仍然慢,考虑建立覆盖索引或调整索引列顺序如果索引没有问题,检查查询写法是否有优化空间(避免 SELECT *、子查询改 JOIN、深分页优化)如果单条 SQL 已最优,考虑配置调优(缓冲池、排序缓冲、临时表大小)配置也调不动了,考虑架构层面优化(读写分离、分库分表、引入缓存)每个阶段都有明确的检查点和动作,避免盲目调参。
服务端阅读 05月27日 17:37

Prometheus Pull 和 Push 模式怎么选?

Prometheus 的数据采集方式是架构设计中最关键的选择之一。理解 Pull 和 Push 模式的差异,直接影响监控系统的可靠性、可维护性和扩展能力。Pull 模式:Prometheus 的原生方式Pull 模式下,Prometheus Server 主动向目标服务发起 HTTP 请求,从 /metrics 端点拉取指标数据。这是 Prometheus 从设计之初就确立的核心模式。工作流程:Prometheus 根据 scrape_configs 中配置的抓取目标,按照设定的间隔(默认 15s)定期请求目标的 metrics 端点,将返回的数据存入时序数据库。核心优势在于控制权在采集端:Prometheus 完全掌握采集节奏和目标列表,即使某个目标宕机,Prometheus 也能感知到抓取失败并记录告警,不会收到过期或虚假数据。服务发现是 Pull 模式的重要支撑。在 Kubernetes 环境中,Prometheus 通过 API 自动发现新的 Pod 和 Service,无需手动更新配置:scrape_configs: - job_name: 'kubernetes-pods' kubernetes_sd_configs: - role: pod relabel_configs: - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] action: keep regex: true - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] action: replace target_label: __metrics_path__ regex: (.+)除了 Kubernetes,Prometheus 还支持 Consul、DNS SRV、EC2、Azure 等多种服务发现机制,适应不同的基础设施环境。Pull 模式最适合长期运行的服务——Web 服务、数据库、消息队列等。这些服务稳定暴露端口,Prometheus 可以持续采集。Push 模式:短期任务的补充方案Push 模式并非应用直接推送数据给 Prometheus,而是通过 Pushgateway 中转:应用将指标推送到 Pushgateway,Prometheus 再从 Pushgateway 拉取。为什么要这样设计?因为有些任务的生存时间短于 Prometheus 的采集间隔。一个只运行 5 秒的批处理任务,Prometheus 的 15s 抓取周期可能根本来不及采集,任务就已经结束了。Pushgateway 解决了这个问题——任务在退出前把指标推送到 Pushgateway,Prometheus 随后统一拉取。# 推送指标到 Pushgatewaycat <<EOF | curl --data-binary @- http://pushgateway:9091/metrics/job/batch_task/instance/task01# TYPE batch_duration_seconds gaugebatch_duration_seconds 3.14# TYPE batch_status counterbatch_status{result="success"} 1EOFPush 模式的适用场景很明确:批处理任务:定时跑批、数据清洗、报表生成等短生命周期作业临时性脚本:一次性执行的工具脚本,无法持续暴露端口防火墙隔离环境:目标在内网,Prometheus 无法直接访问但 Pushgateway 带来了额外的问题。它是一个单点——如果 Pushgateway 挂了,所有推送数据丢失;更重要的是,Pushgateway 上的数据不会自动过期,一个已经停止的任务,其指标仍然留在 Pushgateway 上,Prometheus 会持续采集到过时数据。必须在配置中设置 honor_labels: true 并通过 --metrics.resolution 等参数管理数据生命周期。两种模式的核心差异从架构层面看,根本区别在于谁掌握主动权:Pull:采集端控制节奏。目标服务只需暴露端口,不需要知道 Prometheus 的存在。目标挂了,采集端立刻发现。Push:上报端控制节奏。目标主动推送,采集端被动接收。目标挂了,采集端无法感知。从数据质量看:Pull 模式下,抓取失败就是失败,不会产生假数据Push 模式下,Pushgateway 上的旧数据可能被反复采集,造成指标失真从运维复杂度看:Pull 模式依赖服务发现和目标可达性,网络规划需要保证 Prometheus 到目标的连通Push 模式依赖 Pushgateway 的可用性和数据清理策略,运维 Pushgateway 本身就是额外负担如何选择:决策思路选择标准可以归纳为一条原则——能用 Pull 就用 Pull,只有 Pull 不行时才用 Push。具体判断:服务长期运行且网络可达 → PullKubernetes / Docker 环境 → Pull + 服务发现任务生命周期短于采集间隔 → Push(Pushgateway)目标在隔离网络,Prometheus 无法主动访问 → Push(或部署 Prometheus Agent 做远程写入)实际生产中,混合使用是常态。核心业务服务走 Pull 模式,批处理任务走 Pushgateway,两者共存于同一套 Prometheus 集群。关键是 Pushgateway 不要滥用——它只服务于短期任务,不要把长期服务的指标也推上去。采集间隔的设置也需要平衡:太短增加网络和存储压力,太长可能错过瞬时波动。一般建议 15s 到 1min,关键服务可以设为 10s,低频指标可以放宽到 2-5min。
前端阅读 05月27日 17:34

Rspack 的缓存机制是如何工作的?

Rspack 的缓存机制是提升构建性能的核心手段。Rspack 目前支持内存缓存(Memory Cache)和持久化缓存(Persistent Cache)两种类型,配合快照策略、构建依赖追踪、可移植缓存等机制,能够在开发调试和生产构建中显著缩短耗时。内存缓存内存缓存是 Rspack 最基础的缓存形式,将模块编译结果和依赖图保存在内存中,使得增量构建和 HMR 能够快速响应。在开发模式下,Rspack 默认启用内存缓存:module.exports = { cache: true}也可以显式指定类型:module.exports = { cache: { type: 'memory' }}内存缓存的特点是速度极快,但进程退出后缓存即丢失。对于日常开发中的热更新场景,内存缓存已经足够,这也是 Rspack 在 HMR 性能上表现优异的原因之一。在生产模式下,cache 默认为 false,即不启用任何缓存。持久化缓存持久化缓存将构建结果写入磁盘,使得下次启动时可以直接复用上一次的编译产物,而无需重新执行模块解析和代码转换。这对于大型项目的冷启动场景尤为关键。基本配置const path = require('path')module.exports = { cache: { type: 'persistent' }}启用后,Rspack 默认将缓存存储在 node_modules/.cache/rspack 目录下。你也可以自定义存储位置:module.exports = { cache: { type: 'persistent', storage: { type: 'filesystem', directory: path.resolve(__dirname, '.cache/rspack') } }}构建依赖(buildDependencies)buildDependencies 用于声明哪些文件的变更应当导致缓存失效。Rspack 会计算这些文件的哈希值,当哈希值发生变化时,持久化缓存自动失效。module.exports = { cache: { type: 'persistent', buildDependencies: [__filename, path.join(__dirname, 'tsconfig.json')] }}需要注意的是,与 webpack 不同,Rspack 默认不预设任何构建依赖项。如果你希望配置文件修改后缓存能正确失效,必须将配置文件路径加入 buildDependencies。缓存版本(version)version 字段用于隔离不同配置的缓存。不同 version 值的缓存互不干扰,Rspack 会为每个版本生成独立的缓存目录。module.exports = { cache: { type: 'persistent', version: '1.0.0' }}当项目配置发生重大变更(如升级 loader 版本、修改 babel 配置等)时,更新 version 可以避免旧缓存导致的构建错误。需要注意的是,不要在配置不同的构建之间共享相同的 version 和 storage.directory,否则可能命中错误的缓存。缓存清理(maxGenerations)Rspack 通过 maxGenerations 控制缓存的存活周期。默认值为 1,意味着如果某条缓存在下一次编译中没有被使用,就会被清理。增大该值可以让缓存存活更多轮次:module.exports = { cache: { type: 'persistent', maxGenerations: 5 }}此外,Rspack 在启动时会自动清理超过 7 天未被访问的缓存目录,无需手动维护。快照策略(snapshot)快照策略决定了 Rspack 如何判断文件是否在上次构建后被修改,直接影响缓存验证的效率。managedPathsmanagedPaths 用于指定由包管理器管理的目录(默认包含 node_modules)。对这些路径下的文件,Rspack 通过 package.json 中的 version 字段判断是否变更,而不是逐文件计算哈希,从而大幅加速缓存验证。module.exports = { cache: { type: 'persistent', snapshot: { managedPaths: [/node_modules/] } }}immutablePathsimmutablePaths 用于指定内容不会变更的路径。一旦路径被标记为不可变,Rspack 在热重启时会跳过对这些文件的检查,直接认为缓存有效:module.exports = { cache: { type: 'persistent', snapshot: { immutablePaths: [path.join(__dirname, 'dist')] } }}unmanagedPathsunmanagedPaths 用于排除不应被 managedPaths 规则覆盖的路径。如果你的 node_modules 中有通过 git submodule 等方式管理的包,可以将其加入此列表,让 Rspack 对这些文件采用更精确的哈希验证。可移植缓存(portable)可移植缓存是 Rspack 为 CI/CD 场景设计的能力。启用后,缓存序列化时会将绝对路径转换为相对路径,使得缓存可以在不同机器和操作系统之间共享。module.exports = { cache: { type: 'persistent', portable: true }}典型的应用场景是在 CI 环境中:先将缓存构建并上传为 artifact,后续的构建任务直接下载复用,无需每次从零开始编译。Windows、Linux、macOS 之间可以共享同一份缓存,不需要为每个平台维护独立的缓存副本。需要注意的是,可移植模式下,项目目录外的文件会被转换为相对路径,在新环境中如果这些文件不存在,可能触发额外的重新编译。只读缓存(readonly)只读模式适用于 CI 场景中使用预热缓存的构建任务。启用后,Rspack 只从磁盘读取缓存,不会写入新数据:module.exports = { cache: { type: 'persistent', readonly: Boolean(process.env.CI) }}这在多构建任务共享同一份缓存 artifact 时特别有用,可以避免并发写入导致的缓存损坏。从 webpack 迁移缓存配置如果你从 webpack 迁移到 Rspack,缓存配置需要做以下调整:缓存类型:将 cache.type: 'filesystem' 改为 cache.type: 'persistent'构建依赖:将 buildDependencies 从对象格式 { config: [__filename] } 改为数组格式 [__filename]缓存版本:将 webpack 的 cache.name 和 cache.version 合并为 Rspack 的 cache.version快照配置:将顶层的 snapshot 配置移入 cache.snapshot// webpack 配置module.exports = { cache: { type: 'filesystem', buildDependencies: { config: [__filename] }, name: 'my-app', version: '1.0' }}// Rspack 配置module.exports = { cache: { type: 'persistent', buildDependencies: [__filename], version: 'my-app-1.0' }}实际使用建议开发环境:默认启用内存缓存即可满足需求。如果项目较大导致冷启动慢,可以同时开启持久化缓存。生产构建:生产模式下缓存默认关闭。对于频繁构建的场景(如预发布环境),可以开启持久化缓存并将 buildDependencies 配置完整,确保配置变更时缓存正确失效。CI/CD 环境:结合 portable: true 和 readonly: true,将构建产物缓存作为 pipeline artifact 上传和复用,可以显著减少 CI 构建时间。根据社区反馈,命中缓存后构建速度可以提升 2-3 倍。缓存失效排查:如果遇到缓存未命中或构建结果异常,首先检查 buildDependencies 是否完整,其次确认 version 是否需要更新。Rspack 在 cache.profile: true 开启时会输出缓存统计信息,有助于定位问题。
前端阅读 05月27日 17:34

Qwik 的 SSR 和 CSR 是如何协同工作的?

传统 SSR 框架在服务端渲染 HTML 后,客户端还需要重新下载和执行 JavaScript 来"水合"页面,恢复事件绑定和状态。这个过程随着应用规模增长,开销越来越大。Qwik 提出了完全不同的方案——恢复性(Resumability),让 SSR 产出的 HTML 自带全部状态和事件信息,客户端无需水合即可直接交互。Qwik 的 SSR 渲染流程Qwik 在服务器端执行组件渲染时,不仅生成 HTML 结构,还会将组件状态、事件处理器引用、组件层级关系等全部序列化到 HTML 中。最终返回给浏览器的 HTML 包含了完整的应用快照。具体流程如下:执行组件渲染:Qwik 在 Node.js 环境中执行组件函数,生成虚拟 DOM 并渲染为 HTML 字符串收集状态和事件:渲染过程中,Qwik 收集所有 useSignal、useStore 等响应式状态的当前值,以及所有 onClick$ 等 $ 后缀事件处理器的引用序列化到 HTML:将状态数据以 JSON 格式注入到 HTML 末尾的 <script type="qwik/json"> 标签中,事件绑定信息以 HTML 属性形式嵌入对应 DOM 节点发送完整 HTML:服务器将包含状态和事件元数据的 HTML 响应发送给浏览器export const App = component$(() => { const count = useSignal(0); return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}> Increment </button> </div> );});服务端渲染后,count.value 的值 0 会被序列化到 HTML 中,onClick$ 处理器会被 Qwik Optimizer 编译为一个独立的 chunk 文件,HTML 中只保留该 chunk 的引用路径。浏览器首次加载时不需要下载这段逻辑代码。这样做的直接好处是:首屏 HTML 包含完整可交互内容,搜索引擎可以直接抓取;客户端零 JavaScript 启动成本,FCP 和 TTI 几乎同时达成。客户端恢复:零水合的交互激活浏览器收到 HTML 后,Qwik 的客户端工作方式与传统框架完全不同。传统框架需要下载整个应用的 JavaScript,重新执行组件渲染函数,逐个绑定事件监听器——这就是水合过程。Qwik 跳过了这整个步骤。qwikloader 全局事件代理Qwik 在 HTML 中注入了一个约 1KB 的 qwikloader 脚本,它做一件事:在 document 上监听所有 DOM 事件。当用户点击按钮时,事件冒泡到 document,qwikloader 根据事件目标元素上的 HTML 属性找到对应的事件处理器 chunk 路径,然后动态加载并执行该 chunk。<!-- 服务端渲染后的 HTML 片段 --><button on:click="./app_component_ClickHandler.js#default"> Increment</button><script type="qwik/json">{"state":{"count":"0"},"refs":{}}</script>这意味着:页面加载时不下载任何组件代码和事件处理器代码,只在用户真正交互时按需加载对应的代码块。状态反序列化当事件处理器 chunk 加载后,Qwik 从 <script type="qwik/json"> 中反序列化状态,将 count 恢复为响应式的 useSignal 对象。组件函数不需要重新执行,状态直接恢复到服务端渲染时的快照。细粒度代码分割Qwik Optimizer 编译器在构建阶段自动进行细粒度代码分割:组件级分割:每个 component$() 包裹的组件生成独立 chunk事件处理器级分割:每个 onClick$、onChange$ 等 $ 后缀回调生成独立 chunk状态更新逻辑分割:涉及状态变更的逻辑单独提取$ 后缀是 Qwik 的核心语法约定,它告诉编译器"这个函数的闭包需要被提取为独立模块"。这就是为什么 Qwik 要求事件处理器使用 onClick$ 而非 onClick——编译器需要显式知道哪些函数边界可以被分割。Qwik City 的 SSR 能力Qwik City 是 Qwik 的全栈框架,提供了路由、数据获取和服务端操作能力。路由数据加载routeLoader$ 在服务端执行数据获取,结果随 HTML 一起序列化发送:import { component$ } from '@builder.io/qwik';import { routeLoader$ } from '@builder.io/qwik-city';export const useProductData = routeLoader$(async ({ params, env }) => { const response = await fetch(`https://api.example.com/products/${params.id}`); return response.json();});export default component$(() => { const product = useProductData(); return ( <div> <h1>{product.value.name}</h1> <p>{product.value.description}</p> </div> );});routeLoader$ 的 $ 后缀同样是分割标记——数据获取逻辑在服务端执行,结果序列化后客户端直接使用,不需要重复请求。服务端操作action$ 处理表单提交等写操作,同样在服务端执行:import { action$ } from '@builder.io/qwik-city';export const useAddToCart = action$(async (data, { requestEvent }) => { const session = requestEvent.sharedMap.get('session'); // 服务端执行业务逻辑 return { success: true };});流式 SSRQwik City 支持流式 SSR,服务器可以在渲染完成前就开始向客户端发送 HTML 片段。对于数据量较大的页面,用户可以更快看到首屏内容,而不是等待整个页面渲染完毕才收到第一个字节。与传统 SSR 框架的关键区别| 维度 | Qwik | Next.js (React) | Nuxt (Vue) ||------|------|-----------------|------------|| 客户端激活方式 | 恢复性,无需水合 | 水合,重新执行组件 | 水合,重新执行组件 || 首屏 JS 体积 | 接近零(仅 qwikloader ~1KB) | 较大(框架运行时 + 组件代码) | 中等(框架运行时 + 组件代码) || 代码分割粒度 | 编译器自动按函数级分割 | 需手动配置 dynamic import | 需手动配置或依赖约定路由 || TTI 表现 | FCP 与 TTI 几乎一致 | TTI 明显滞后于 FCP | TTI 滞后于 FCP || 状态传递 | 自动序列化到 HTML | 需手动处理服务端状态注入 | 需手动处理或依赖框架约定 |核心差异在于水合成本。Next.js 的一个典型 SSR 页面,即便 HTML 已经包含完整内容,客户端仍需下载 React 运行时(约 40KB gzipped)和所有页面组件代码来执行水合。Qwik 完全跳过这一步,首次加载仅需 qwikloader 的 ~1KB。实际性能表现Qwik 官方基准测试中,一个中等复杂度页面的性能指标:FCP(首次内容绘制):约 0.3sTTI(可交互时间):约 0.9s首次加载 JS 体积:约 1KB(qwikloader)作为对比,相同页面在 Next.js 下的典型数据:FCP:约 0.5sTTI:约 2.5s(需要等待水合完成)首次加载 JS 体积:约 80-150KB(React 运行时 + 组件代码)大众点评 M 站在 2026 年初完成基于 Qwik.js 的重构,生产环境验证了恢复性方案在大型电商场景下的性能收益——首屏加载速度提升约 40%,TTI 从 3.2s 降至 1.1s。最佳实践服务端数据获取优先优先使用 routeLoader$ 在服务器获取数据,避免客户端额外请求。数据随 HTML 一起序列化,客户端直接使用。合理使用客户端任务仅在必须访问浏览器 API 时使用 useVisibleTask$,如需要 window、document 或 Web API 的场景。不要用它来获取可以 SSR 的数据。利用 useResource$ 处理异步数据useResource$ 适合需要客户端动态获取数据的场景,它返回的 Resource 对象可以追踪加载状态(pending / resolved / rejected),便于在 UI 中展示 loading 态:export default component$(() => { const searchData = useResource$(async ({ cleanup }) => { const controller = new AbortController(); cleanup(() => controller.abort()); const res = await fetch(`/api/search?q=keyword`, { signal: controller.signal }); return res.json(); }); return ( <Resource value={searchData} onPending={() => <p>加载中...</p>} onResolved={(data) => <div>{data.result}</div>} /> );});混合渲染策略静态内容(如博客文章、产品描述)使用 SSR 确保搜索引擎可抓取;动态交互(如购物车、搜索建议)依赖 Qwik 的按需加载机制,只在用户交互时加载对应逻辑。Qwik 的恢复性架构让 SSR 和 CSR 不再是二选一的取舍,而是一个连续光谱上的不同策略。服务端负责渲染和数据获取,客户端负责交互和按需加载,两者通过序列化机制无缝衔接,开发者不需要手动处理状态同步和水合逻辑。
前端阅读 05月27日 17:33

Qwik 项目里 TypeScript 怎么写?核心类型与实战用法

Qwik 从设计之初就深度整合了 TypeScript,项目脚手架默认生成 .ts/.tsx 文件,组件、状态、事件、路由等核心 API 均提供完整的类型推导。理解 Qwik 的类型系统,关键在于把握 QRL(可恢复引用)这一独特概念——它决定了 Qwik 中函数类型的书写方式与普通 React/Vue 项目有本质区别。组件 Props 类型Qwik 组件通过 component$ 泛型参数声明 Props 类型。$ 后缀是 Qwik 的核心约定,表示该函数是一个 QRL(Resumable Lazy-Load Reference),框架会在需要时才加载和执行它。import { component$, PropsOf } from '@builder.io/qwik';interface ButtonProps { label: string; onClick$: () => void; disabled?: boolean; variant?: 'primary' | 'secondary' | 'danger';}export const Button = component$<ButtonProps>((props) => { return ( <button onClick$={props.onClick$} disabled={props.disabled} class={`btn btn-${props.variant || 'primary'}`} > {props.label} </button> );});当需要扩展原生 HTML 元素的属性时,使用 PropsOf 工具类型提取内置属性,再通过交叉类型追加自定义字段:import { component$, PropsOf } from '@builder.io/qwik';export const CustomInput = component$<PropsOf<'input'> & { customProp?: string;}>((props) => { return <input {...props} />;});PropsOf 会自动包含 input 元素的所有标准属性(value、placeholder、onChange 等),避免手动维护冗长的类型列表。QRL 类型与 $ 后缀QRL 是 Qwik 类型系统中最重要的概念。所有带 $ 后缀的函数(onClick$、useTask$、server$ 等)都是 QRL 包裹的懒加载引用,类型上用 QRL 表示。import { type QRL } from '@builder.io/qwik';interface ListProps<T> { items: T[]; renderItem$: QRL<(item: T, index: number) => JSXNode>; keyExtractor$: QRL<(item: T) => string>;}实际开发中,通常不需要手动声明 QRL 类型——component$ 和事件处理器的泛型推导会自动处理。但理解这个机制有助于排查类型报错:如果在一个需要 QRL 的位置传入了普通函数,TypeScript 会提示类型不匹配。状态管理的类型标注useSignaluseSignal 用于基本类型的响应式状态,通过泛型参数声明类型:import { component$, useSignal } from '@builder.io/qwik';export const Counter = component$(() => { const count = useSignal<number>(0); const name = useSignal<string>(''); const isActive = useSignal<boolean>(false); return ( <div> <p>Count: {count.value}</p> <input value={name.value} onInput$={(e) => name.value = (e.target as HTMLInputElement).value} /> <button onClick$={() => isActive.value = !isActive.value}> Toggle </button> </div> );});访问和修改值统一通过 .value 属性,TypeScript 会根据泛型参数严格检查赋值类型。useStoreuseStore 用于对象类型的响应式状态,推荐用 interface 定义完整结构:import { component$, useStore } from '@builder.io/qwik';interface User { id: number; name: string; email: string; address: { street: string; city: string; country: string; };}export const UserProfile = component$(() => { const user = useStore<User>({ id: 1, name: 'John Doe', email: 'john@example.com', address: { street: '123 Main St', city: 'New York', country: 'USA' } }); return ( <div> <h2>{user.name}</h2> <p>{user.email}</p> <p>{user.address.city}, {user.address.country}</p> </div> );});useStore 支持深度响应,嵌套对象的属性变更同样会触发更新,类型推导也会深入到嵌套层级。useTask$ 和 useVisibleTask$useTask$ 在服务端和客户端都会执行,适合监听信号变化后的副作用;useVisibleTask$ 仅在浏览器端执行,适合 DOM 操作或浏览器 API 调用。import { component$, useSignal, useTask$, useVisibleTask$ } from '@builder.io/qwik';export const SearchComponent = component$(() => { const query = useSignal(''); const results = useSignal<string[]>([]); useTask$(({ track }) => { const keyword = track(() => query.value); // 服务端和客户端都会执行 results.value = keyword ? [`${keyword}-result-1`, `${keyword}-result-2`] : []; }); useVisibleTask$(() => { // 仅在浏览器执行,例如读取 localStorage const saved = localStorage.getItem('last-search'); if (saved) query.value = saved; }); return ( <div> <input value={query.value} onInput$={(e) => query.value = (e.target as HTMLInputElement).value} /> <ul>{results.value.map((r) => <li key={r}>{r}</li>)}</ul> </div> );});事件处理类型Qwik 事件处理器的类型签名是 (event: EventType, element: HTMLElement) => void,与 React 的 SyntheticEvent 不同,Qwik 直接使用浏览器原生事件类型:import { component$ } from '@builder.io/qwik';export const Form = component$(() => { const handleSubmit$ = (event: Event, element: HTMLFormElement) => { event.preventDefault(); const formData = new FormData(element); console.log(formData); }; const handleInput$ = (event: InputEvent, element: HTMLInputElement) => { console.log(element.value); }; return ( <form onSubmit$={handleSubmit$}> <input type="text" onInput$={handleInput$} /> <button type="submit">Submit</button> </form> );});自定义事件可以通过声明 detail 类型来约束:interface CustomEvent { detail: { id: string; value: number; };}export const CustomComponent = component$(() => { const handleCustomEvent$ = (event: CustomEvent) => { console.log(event.detail.id, event.detail.value); }; return <div onCustomEvent$={handleCustomEvent$}>Custom Component</div>;});路由与数据加载的类型routeLoader$routeLoader$ 用于在服务端加载数据,泛型参数声明返回数据的类型:import { component$, routeLoader$ } from '@builder.io/qwik-city';interface Product { id: number; name: string; price: number; description: string;}export const useProduct = routeLoader$<Product>(async ({ params }) => { const response = await fetch(`https://api.example.com/products/${params.id}`); return response.json();});export default component$(() => { const product = useProduct(); return <div>{product.value.name}</div>;});路由参数的类型通过 params 对象自动推导,params.id 在文件路由 [id] 布局下会被推断为 string。action$ 与 Zod 校验action$ 用于处理表单提交等写操作,配合 zod$ 实现运行时类型校验:import { action$, zod$, z } from '@builder.io/qwik-city';interface ActionResult { success: boolean; error?: string;}export const useContactForm = action$( async (data) => { // data 的类型由 zod schema 自动推导 return { success: true }; }, zod$({ name: z.string().min(2), email: z.string().email(), message: z.string().min(10) }));zod$ 同时提供了运行时校验和编译时类型推导,data 参数的类型会根据 zod schema 自动生成,无需重复声明 FormData 接口。Context 跨组件通信的类型Qwik 使用 createContextId 创建类型安全的上下文,注意不是旧版 API 的 createContext:import { component$, createContextId, useContextProvider, useContext } from '@builder.io/qwik';interface ThemeContextValue { theme: 'light' | 'dark'; toggleTheme$: () => void;}const ThemeContext = createContextId<ThemeContextValue>('theme');export const ThemeProvider = component$(() => { const theme = useSignal<'light' | 'dark'>('light'); const toggleTheme$ = () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; }; useContextProvider(ThemeContext, { theme: theme.value, toggleTheme$ }); return <Child />;});export const Child = component$(() => { const { theme, toggleTheme$ } = useContext(ThemeContext); return ( <div> <p>Current theme: {theme}</p> <button onClick$={toggleTheme$}>Toggle Theme</button> </div> );});createContextId 的泛型参数确保 Provider 写入的值和 Consumer 读取的值类型一致。字符串参数 'theme' 是上下文的唯一标识,需要保证全局不重复。泛型组件与类型复用Qwik 支持泛型组件,但受限于 component$ 的泛型推导,实际写法需要用 any 作为中间类型再在调用侧收窄:import { component$ } from '@builder.io/qwik';interface ListProps<T> { items: T[]; renderItem$: (item: T, index: number) => any; keyExtractor$: (item: T) => string;}export const List = component$<ListProps<any>>((props) => { return ( <ul> {props.items.map((item, index) => ( <li key={props.keyExtractor$(item)}> {props.renderItem$(item, index)} </li> ))} </ul> );});对于跨文件的类型复用,建议集中管理类型定义:// types.tsexport interface User { id: number; name: string; email: string;}export type UserRole = 'admin' | 'user' | 'guest';export interface ApiResponse<T> { data: T; status: number; message: string;}// component.tsximport { component$, useSignal } from '@builder.io/qwik';import type { User, UserRole, ApiResponse } from './types';export const UserComponent = component$(() => { const user = useSignal<User | null>(null); const role = useSignal<UserRole>('user'); return <div>{user.value?.name}</div>;});使用 type 关键字导入类型(import type)可以避免将类型代码打包到客户端产物中,这是 Qwik 优化加载性能的重要手段。server$ 的类型约束server$ 函数用于将逻辑限定在服务端执行,其泛型参数约束函数签名:import { server$ } from '@builder.io/qwik-city';const saveData = server$(async (data: { name: string; email: string }): Promise<{ success: boolean }> => { // 仅在服务端执行,可安全访问数据库等 return { success: true };});server$ 返回的函数类型与传入的函数类型一致,调用侧无需感知服务端/客户端边界,TypeScript 会自动推导正确的返回类型。类型断言与类型守卫在事件处理等场景中,类型断言不可避免,但应尽量缩小断言范围:const input = element.querySelector('input') as HTMLInputElement;console.log(input.value);更安全的做法是用类型守卫替代断言:function isString(value: unknown): value is string { return typeof value === 'string';}export const Component = component$(() => { const data = useSignal<unknown>(null); const processData$ = () => { if (isString(data.value)) { console.log(data.value.toUpperCase()); } }; return <button onClick$={processData$}>Process</button>;});类型组织的实践建议interface 和 type 各有适用场景:interface 适合定义对象结构,支持声明合并;type 适合联合类型、交叉类型和工具类型的组合。// 对象结构用 interfaceinterface ComplexProps { user: { id: number; profile: { name: string; avatar: string; }; };}// 联合类型和交叉类型用 typetype ButtonVariant = 'primary' | 'secondary' | 'danger';type ButtonProps = BaseProps & { variant: ButtonVariant };泛型工具函数可以大幅减少重复类型声明:export const useApi = <T>(url: string) => { return useResource$<T>(() => fetch(url).then(r => r.json()));};类型守卫在运行时校验与编译时类型之间建立桥梁,对于服务端返回的未校验数据尤其重要:function isValidUser(user: unknown): user is User { return typeof user === 'object' && user !== null && 'id' in user;}Qwik 的 TypeScript 集成不只是"能用",而是围绕 QRL 可恢复性架构重新设计了类型系统。掌握 QRL 类型、$ 后缀的语义、以及 import type 的按需加载,才能在 Qwik 项目中写出既类型安全又不拖累加载性能的代码。遇到类型报错时,先检查是否遗漏了 $ 后缀或将 QRL 位置传入了普通函数——这是从其他框架迁移到 Qwik 时最常见的类型陷阱。
前端阅读 05月27日 17:32

Qwik 组件系统的 $ 语法和可恢复性是如何工作的?

Qwik 组件系统的核心设计目标是可恢复性(Resumability)——框架在服务端渲染时将组件的状态和执行上下文序列化到 HTML 中,客户端无需重新执行组件代码即可恢复交互。这和传统 SSR 框架(如 Next.js)的 Hydration 方案有本质区别:Hydration 需要在客户端重新下载和执行组件代码来"重新激活"页面,而 Qwik 只在用户实际交互时才懒加载对应的代码。这个设计目标催生了 Qwik 组件系统中最显眼的特征:$ 语法。$ 语法:可恢复性边界$ 后缀不是语法糖,而是 Qwik 优化器(Optimizer)的编译指令。它标记了一个惰性边界——优化器会将 $ 标记的函数提取为独立的 chunk,按需加载:import { component$ } from '@builder.io/qwik';export const Counter = component$(() => { // component$ 本身就是惰性边界,组件代码会被分割为独立 chunk const count = useSignal(0); return ( <button onClick$={() => count.value++}> {/* onClick$ 也是一个惰性边界,事件处理函数独立分割 */} Count: {count.value} </button> );});编译后,上面这个组件至少产生三个 chunk:组件自身、事件处理函数、useSignal 的响应式逻辑。用户首次访问页面时,这些 chunk 都不会加载——只有点击按钮时,才会加载事件处理函数的 chunk。理解了 $ 的本质,再看组件系统的其他部分就顺理成章了。组件定义与编译产物Qwik 组件必须使用 component$ 包裹:import { component$ } from '@builder.io/qwik';export const Greeting = component$((props: { name: string }) => { return <div>Hello, {props.name}</div>;});编译器会在组件的 DOM 节点周围插入 <!--qv--> 注释标记,并通过 q:id(组件实例唯一标识)、q:key(列表渲染 key)、q:sref(响应式数据订阅引用)等属性在扁平的 HTML 中重建组件树结构。这意味着 Qwik 不需要虚拟 DOM——仅凭 HTML 标记就能识别组件层级和更新范围。状态管理:useSignal 与 useStoreuseSignal:简单值import { useSignal } from '@builder.io/qwik';export const Counter = component$(() => { const count = useSignal(0); return ( <button onClick$={() => count.value++}> Count: {count.value} </button> );});useSignal 返回一个 Signal 对象,通过 .value 读写。修改 .value 会精确触发依赖该 Signal 的 DOM 节点更新,而不是重新渲染整个组件。useStore:复杂对象import { useStore } from '@builder.io/qwik';export const Form = component$(() => { const form = useStore({ name: '', email: '' }); return ( <form> <input value={form.name} onInput$={(e) => form.name = (e.target as HTMLInputElement).value} /> <input value={form.email} onInput$={(e) => form.email = (e.target as HTMLInputElement).value} /> </form> );});useStore 对对象的属性进行深度响应式代理,修改任意属性只会更新引用该属性的 DOM 节点。事件处理所有事件处理函数必须使用 $ 后缀,否则编译器会报错:export const Button = component$(() => { return ( <button onClick$={() => console.log('clicked')}> Click me </button> );});onClick$ 而非 onClick,这是 Qwik 最容易让 React 开发者踩坑的地方。如果试图传递一个普通函数给事件属性,Qwik 优化器会直接报错,因为普通函数无法被序列化和懒加载。生命周期钩子Qwik 提供三个核心生命周期钩子,都使用 $ 后缀:useTask$:在组件挂载和响应式依赖变化时执行,类似于 React 的 useEffect + useMemo 的结合。可以追踪 Signal 变化并执行副作用:import { component$, useSignal, useTask$ } from '@builder.io/qwik';export const SearchComponent = component$(() => { const query = useSignal(''); const results = useSignal<string[]>([]); useTask$(({ track }) => { const keyword = track(() => query.value); // 当 query 变化时重新搜索 results.value = fetchResults(keyword); }); return ( <div> <input onInput$={(e) => query.value = (e.target as HTMLInputElement).value} /> <ul>{results.value.map(r => <li key={r}>{r}</li>)}</ul> </div> );});useVisibleTask$:只在组件进入视口时执行,用于客户端特定逻辑(如操作 DOM、绑定第三方库),不会在 SSR 阶段运行。useResource$:用于异步数据获取,返回一个 Resource 对象,可以通过 <Resource> 组件自动处理 loading/error/resolved 三种状态:import { component$, useResource$, Resource } from '@builder.io/qwik';export const UserProfile = component$((props: { id: string }) => { const userResource = useResource$(async () => { const res = await fetch(`/api/users/${props.id}`); return res.json(); }); return ( <Resource value={userResource} onPending={() => <p>Loading...</p>} onRejected={() => <p>Failed to load</p>} onResolved={(user) => <div>{user.name}</div>} /> );});组件通信Props 传递export const Parent = component$(() => { return <Child message="Hello from parent" count={42} />;});export const Child = component$((props: { message: string; count: number }) => { return <div>{props.message}: {props.count}</div>;});Props 通过编译时类型检查,并在序列化时自动处理。注意 Props 必须可序列化——函数、Symbol 等类型不能作为 Props 传递,这和 React 的"props 可以传任何值"有本质区别。Context 跨层级通信import { createContext, useContextProvider, useContext } from '@builder.io/qwik';const ThemeContext = createContext<string>('light');export const ThemeProvider = component$(() => { useContextProvider(ThemeContext, 'dark'); return <Child />;});export const Child = component$(() => { const theme = useContext(ThemeContext); return <div>Current theme: {theme}</div>;});Context 值同样需要可序列化。Slot 内容投影Qwik 使用 <Slot/> 实现内容投影,替代 React 的 children prop:import { component$, Slot } from '@builder.io/qwik';export const Card = component$(() => { return ( <div class="card"> <div class="card-header"><Slot name="header" /></div> <div class="card-body"><Slot /></div> <div class="card-footer"><Slot name="footer" /></div> </div> );});// 使用时:export const App = component$(() => { return ( <Card> <div q:slot="header">Title</div> <p>Body content</p> <div q:slot="footer">Footer</div> </Card> );});具名 Slot 通过 name 属性声明,使用方通过 q:slot 属性指定投影目标。未命名的 Slot 接收默认内容。样式方案Qwik 支持多种样式方式:CSS Modules(推荐):创建 .module.css 文件,通过 import styles from './xxx.module.css' 引用,类名自动 hash 化避免冲突。Tailwind CSS:开箱即用,Qwik 官方脚手架内置支持。useStylesScoped$:在组件内联作用域样式:import { component$, useStylesScoped$ } from '@builder.io/qwik';export const StyledButton = component$(() => { useStylesScoped$(` .btn { background: #0070f3; color: white; padding: 8px 16px; border-radius: 4px; } `); return <button class="btn">Click</button>;});全局 CSS:通过普通 CSS 文件导入。编译器自动优化Qwik 编译器接管了大量手动优化工作,开发者不需要 React.memo、useCallback、useMemo:每个组件自动分割为独立 chunk,按渲染需求懒加载事件处理函数自动分割,只在交互时加载响应式系统只更新变化的 DOM 节点,而非重新渲染组件树不可序列化的值在编译期就会被检测并报错,避免运行时问题这正是 Qwik 的核心价值主张:开发者写组件的体验接近 React,但运行时性能由编译器保证,而非依赖手动优化。
前端阅读 05月27日 17:32

Qwik 状态管理怎么用?从 useSignal 到 useResource

Qwik 的状态管理围绕一个核心理念:可恢复性(Resumability)。与传统框架在客户端重新执行组件代码来恢复状态不同,Qwik 在服务端渲染时就将状态序列化到 HTML 中,浏览器可以直接从序列化点恢复执行,无需水合(Hydration)。这个设计决策深刻影响了 Qwik 状态管理 API 的形态。useSignal:管理原始值useSignal 是最轻量的响应式状态,适合存储数字、字符串、布尔值等原始类型。它返回一个包含 .value 属性的对象,修改 .value 就能触发更新。import { component$, useSignal } from '@builder.io/qwik';export const Counter = component$(() => { const count = useSignal(0); return ( <div> <p>当前计数:{count.value}</p> <button onClick$={() => count.value++}>+1</button> <button onClick$={() => count.value--}>-1</button> </div> );});几点注意:useSignal 只能存储单个值,如果需要管理对象或数组,应该用 useStore访问值必须通过 .value,不能解构,否则会丢失响应性Qwik 对 useSignal 的更新是细粒度的,只有真正依赖这个值的 DOM 节点会重新渲染useStore:管理复杂对象当状态是嵌套对象或数组时,用 useStore 替代 useSignal。它会自动追踪对象属性的变化,同样做到细粒度更新。import { component$, useStore } from '@builder.io/qwik';export const TodoList = component$(() => { const state = useStore({ items: [ { id: 1, text: '学习 Qwik', done: false }, { id: 2, text: '构建应用', done: false } ], newTodoText: '' }); return ( <div> <input value={state.newTodoText} onInput$={(ev) => (state.newTodoText = (ev.target as HTMLInputElement).value)} /> <button onClick$={() => { if (!state.newTodoText.trim()) return; state.items.push({ id: Date.now(), text: state.newTodoText, done: false }); state.newTodoText = ''; }}> 添加 </button> <ul> {state.items.map((item) => ( <li key={item.id}> <input type="checkbox" checked={item.done} onInput$={() => (item.done = !item.done)} /> {item.text} </li> ))} </ul> </div> );});useStore 的一个重要特性:它不仅能追踪顶层属性,也能追踪深层嵌套属性的变化。这意味着修改 item.done 同样会触发对应 DOM 的更新。useComputed$:派生状态当某个值依赖其他状态计算得出时,用 useComputed$。它会在依赖变化时自动重新计算,未变化时返回缓存值。import { component$, useSignal, useComputed$ } from '@builder.io/qwik';export const PriceCalculator = component$(() => { const price = useSignal(100); const taxRate = useSignal(0.1); const total = useComputed$(() => { return price.value * (1 + taxRate.value); }); return ( <div> <p>单价:¥{price.value}</p> <p>税率:{taxRate.value * 100}%</p> <p>总价:¥{total.value.toFixed(2)}</p> </div> );});useComputed$ 本质上是一个只读的 Signal,你不能直接修改它的 .value,只能通过改变依赖项来间接触发更新。适合用在过滤列表、格式化输出、聚合计算等场景。useContext:跨组件共享状态当状态需要在组件树的多个层级间共享时,Qwik 提供了 Context 机制。先用 createContext 定义上下文,再用 useContextProvider 提供值,子组件通过 useContext 消费。import { component$, createContext, useContextProvider, useContext } from '@builder.io/qwik';// 定义 Context 的类型和默认值const ThemeContext = createContext<string>('light');export const App = component$(() => { // 在顶层组件提供值 useContextProvider(ThemeContext, 'dark'); return <ChildComponent />;});export const ChildComponent = component$(() => { // 在任意子组件消费 const theme = useContext(ThemeContext); return <p>当前主题:{theme}</p>;});与 React Context 的关键区别:Qwik 的 Context 值不需要是响应式的,但如果传入的是 useStore 或 useSignal 创建的响应式对象,消费组件同样会自动更新。useTask$:副作用与状态同步useTask$ 是 Qwik 中处理副作用的 hook,类似于 React 的 useEffect,但工作机制不同。它在服务端和客户端都会执行,可以通过 track 函数监听状态变化。import { component$, useSignal, useTask$ } from '@builder.io/qwik';export const SearchBox = component$(() => { const keyword = useSignal(''); const results = useSignal<string[]>([]); useTask$(({ track }) => { const query = track(() => keyword.value); // 当 keyword 变化时,重新计算搜索结果 if (query.length < 2) { results.value = []; return; } // 模拟搜索逻辑 results.value = ['结果1', '结果2'].filter((r) => r.includes(query)); }); return ( <div> <input value={keyword.value} onInput$={(ev) => (keyword.value = (ev.target as HTMLInputElement).value)} /> <ul> {results.value.map((r, i) => <li key={i}>{r}</li>)} </ul> </div> );});useTask$ 适合用在:根据某个状态变化去修改另一个状态、发起网络请求、操作 DOM 等场景。注意它不返回值,如果需要返回派生状态,应该用 useComputed$。useResource$:异步数据加载useResource$ 专门处理异步数据获取,内置了 pending、resolved、rejected 三种状态的管理,配合 Resource 组件可以方便地渲染不同状态。import { component$, useSignal, useResource$, Resource } from '@builder.io/qwik';export const UserProfile = component$(() => { const userId = useSignal(1); const userResource = useResource$(async ({ track }) => { const id = track(() => userId.value); const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`); if (!res.ok) throw new Error('加载失败'); return res.json(); }); return ( <div> <button onClick$={() => userId.value++}>下一个用户</button> <Resource value={userResource} onPending={() => <p>加载中...</p>} onRejected={() => <p>加载失败,请重试</p>} onResolved={(user) => ( <div> <p>姓名:{user.name}</p> <p>邮箱:{user.email}</p> </div> )} /> </div> );});useResource$ 的 track 函数让它能在依赖变化时自动重新获取数据,配合 <Resource> 组件可以清晰地处理三种 UI 状态,避免手动管理 loading/error 状态的样板代码。Qwik 与 React 状态管理的本质区别理解 Qwik 状态管理,关键在于理解它与 React 的根本差异:React 的状态绑定在组件实例上,组件卸载状态就消失,客户端需要通过水合重建组件树和状态。Qwik 的状态是独立的,它与创建它的组件解耦。状态被序列化到 HTML 的 <script type="qwik/json"> 中,浏览器无需执行任何组件代码就能恢复状态。这意味着:状态可以在组件间自由传递,不受组件树层级限制只有用户交互时才会下载对应的处理代码(懒执行)状态的可序列化是硬性要求,不能存储函数、DOM 引用等不可序列化的值选择合适的状态管理方式| 场景 | 推荐方式 ||------|----------|| 单个原始值(计数器、开关) | useSignal || 复杂对象或数组(表单、列表) | useStore || 依赖其他状态的派生值 | useComputed$ || 跨组件共享数据 | useContext + useContextProvider || 响应状态变化执行副作用 | useTask$ || 异步数据获取与状态管理 | useResource$ |在实际开发中,这些 API 往往组合使用。比如用 useStore 管理全局状态,通过 useContextProvider 注入组件树,子组件用 useContext 消费,再用 useTask$ 响应状态变化执行副作用,用 useResource$ 加载远程数据。Qwik 的编译器会自动处理细粒度更新和状态序列化,开发者只需关注业务逻辑本身。
前端阅读 05月27日 17:31

Qwik City 核心功能有哪些?路由、数据加载与全栈能力解析

Qwik City 是 Qwik 官方的全栈元框架,围绕路由、数据加载、表单处理、中间件和 SEO 五大核心能力,提供了一套完整的服务端渲染开发方案。与 Next.js 或 Remix 不同,Qwik City 从底层就利用了 Qwik 的可恢复性架构,首屏不发送 JavaScript、不做 hydration,这意味着同样的 SSR 页面,Qwik City 的 TTI(Time to Interactive)通常远低于传统框架。下面逐个拆解它的核心功能。路由系统Qwik City 采用基于文件系统的路由,src/routes/ 目录下的文件结构直接映射为 URL 路径。目录结构与路由映射src/├── routes/│ ├── index.tsx -> /│ ├── about/│ │ └── index.tsx -> /about│ ├── products/│ │ ├── index.tsx -> /products│ │ └── [id]/│ │ └── index.tsx -> /products/:id│ └── layout.tsx -> 全局布局方括号 [id] 表示动态路由参数,在 loader 或组件中通过 params.id 访问。这种约定式路由省去了手动配置路由表的步骤,新增页面只需创建文件。动态路由与数据加载结合动态路由最常见的场景是根据参数加载数据。routeLoader$ 在服务端执行,返回的数据自动序列化给客户端组件使用:// routes/products/[id]/index.tsximport { component$ } from '@builder.io/qwik';import { routeLoader$ } from '@builder.io/qwik-city';export const useProductData = routeLoader$(async ({ params }) => { const response = await fetch(`https://api.example.com/products/${params.id}`); return response.json();});export default component$(() => { const product = useProductData(); return ( <div> <h1>{product.value.name}</h1> <p>{product.value.description}</p> <p>Price: ${product.value.price}</p> </div> );});关键点:routeLoader$ 在 SSR 阶段执行,返回数据会自动随 HTML 一起发送到客户端,不会产生额外的 waterfall 请求。这与 Next.js 的 getServerSideProps 类似,但 Qwik City 的数据会通过 resumability 机制直接恢复,不需要重新执行组件逻辑。嵌套布局layout.tsx 用于定义共享布局,Slot 组件作为子路由的渲染出口:// routes/layout.tsximport { component$, Slot } from '@builder.io/qwik';export default component$(() => { return ( <div> <header>Header</header> <main><Slot /></main> <footer>Footer</footer> </div> );});布局文件支持嵌套——每一层目录都可以有自己的 layout.tsx,形成从外到内的布局包裹链。这与 Next.js 的 layout 嵌套机制类似,但 Qwik City 的布局组件同样是可恢复的,不会在客户端重新执行渲染逻辑。数据加载Qwik City 提供了三种数据获取方式,分别对应不同的执行时机和使用场景。选对加载方式直接影响首屏性能和交互体验。routeLoader$ — 服务端数据加载这是最常用的数据加载方式,在服务端执行,适合页面级数据的预获取:import { routeLoader$ } from '@builder.io/qwik-city';export const useUserData = routeLoader$(async ({ params, url, env, requestEvent }) => { // 路由参数 const userId = params.id; // 查询参数 const page = url.searchParams.get('page'); // 环境变量 const apiKey = env.get('API_KEY'); // Cookie const session = requestEvent.cookie.get('session'); const response = await fetch(`https://api.example.com/users/${userId}`); return response.json();});routeLoader$ 的回调接收一个 RequestEvent 对象,可以访问路由参数、URL、环境变量、Cookie 和请求头等完整的请求上下文。这意味着你不需要额外引入 express 的 req 对象或 Next.js 的 API 路由,所有请求信息都在一个对象上。clientLoader$ — 客户端数据加载当需要在客户端动态获取数据(比如用户交互后刷新)时使用:import { clientLoader$ } from '@builder.io/qwik-city';export const useClientData = clientLoader$(async ({ params, navigate }) => { const response = await fetch(`/api/data/${params.id}`); return response.json();});与 routeLoader$ 的区别:clientLoader$ 仅在浏览器端执行,不会阻塞 SSR。它适合非关键数据或需要实时刷新的场景,比如客户端导航后的数据更新。useResource$ — 组件级数据加载useResource$ 在组件内部使用,支持依赖追踪和响应式重新获取:import { component$, useResource$ } from '@builder.io/qwik';export const UserList = component$(() => { const users = useResource$(({ track, cleanup }) => { track(() => /* 追踪的依赖项 */); cleanup(() => { // 组件卸载时的清理逻辑 }); return fetch('https://api.example.com/users').then(r => r.json()); }); return ( <div> {users.value ? ( <ul> {users.value.map(user => <li key={user.id}>{user.name}</li>)} </ul> ) : ( <p>Loading...</p> )} </div> );});三种加载方式的选择依据:页面级首屏数据用 routeLoader$,客户端动态刷新用 clientLoader$,组件内响应式获取用 useResource$。如果你熟悉 React 生态,可以类比为:routeLoader$ ≈ getServerSideProps,useResource$ ≈ useSWR / useQuery。表单处理Qwik City 通过 action$ 实现表单提交的服务端处理,并内置了 Zod 验证集成。与传统框架需要手动编写 API 路由处理表单不同,action$ 把表单逻辑和组件放在同一个文件中。action$ — 服务端表单处理import { action$, zod$, z } from '@builder.io/qwik-city';import { component$, Form } from '@builder.io/qwik-city';export const useContactForm = action$(async (data, { requestEvent }) => { const { name, email, message } = data; await sendEmail({ name, email, message }); return { success: true };}, zod$({ name: z.string().min(2), email: z.string().email(), message: z.string().min(10)}));export default component$(() => { const action = useContactForm(); return ( <Form action={action}> <input name="name" placeholder="Name" /> <input name="email" type="email" placeholder="Email" /> <textarea name="message" placeholder="Message"></textarea> <button type="submit">Submit</button> {action.value?.success && <p>Message sent!</p>} </Form> );});action$ 的两个参数:第一个是处理函数,接收表单数据和服务端上下文;第二个是可选的 Zod schema,用于自动验证输入。验证失败时,Qwik City 会自动返回验证错误信息到 action.status,无需手动处理验证逻辑和错误返回。clientAction$ — 客户端表单处理import { clientAction$ } from '@builder.io/qwik-city';export const useClientAction = clientAction$(async (data) => { console.log('Client action:', data); return { success: true };});clientAction$ 适用于不需要服务端逻辑的轻量级交互,如本地状态更新或客户端计算。中间件中间件用于处理跨路由的通用逻辑,比如鉴权、日志、CORS 等。Qwik City 的中间件与 Express 的中间件概念相似,但运行在边缘函数(Edge Functions)环境中。请求拦截// routes/middleware.tsimport { middleware$ } from '@builder.io/qwik-city';export const onRequest = middleware$(async ({ requestEvent, next }) => { const url = requestEvent.url; const session = requestEvent.cookie.get('session'); if (!session && url.pathname !== '/login') { throw requestEvent.redirect(302, '/login'); } return next();});响应拦截export const onResponse = middleware$(async ({ requestEvent, next }) => { const response = await next(); response.headers.set('X-Custom-Header', 'value'); return response;});中间件按目录层级生效——放在 routes/ 根目录的中间件对所有路由生效,放在子目录的只对该子路由树生效。这个机制与布局嵌套类似,可以灵活控制中间件的作用范围。例如,routes/admin/middleware.ts 只保护 /admin/* 路由。SEO 优化Qwik City 支持通过 head 导出函数为每个页面设置元数据,包括 title、meta 标签和 Open Graph 信息:import { useDocumentHead$ } from '@builder.io/qwik-city';export const head = useDocumentHead$(({ resolveValue }) => { const product = resolveValue(useProductData); return { title: product.name, meta: [ { name: 'description', content: product.description }, { property: 'og:title', content: product.name }, { property: 'og:description', content: product.description }, { property: 'og:image', content: product.image } ] };});useDocumentHead$ 中可以通过 resolveValue 引用 routeLoader$ 的数据,实现动态 SEO。元数据在服务端生成,搜索引擎抓取时能看到完整内容,这对电商产品页、博客文章等需要社交分享和搜索排名的页面尤为重要。国际化Qwik City 的国际化通过社区库 qwik-speak 实现,支持多语言翻译和动态语言切换。服务端配置// src/entry.ssr.tsximport { renderToStream } from '@builder.io/qwik/server';import { Root } from './root';export default function (opts) { return renderToStream(<Root />, { ...opts, containerAttributes: { lang: opts.lang } });}组件内使用翻译import { component$ } from '@builder.io/qwik';import { useSpeak } from 'qwik-speak';export const MyComponent = component$(() => { const { t } = useSpeak(); return ( <div> <h1>{t('welcome.title')}</h1> <p>{t('welcome.description')}</p> </div> );});翻译键值对通过配置文件定义,qwik-speak 会根据请求的语言自动匹配对应的翻译内容。配合路由中间件,可以实现基于 URL 前缀(如 /zh/about、/en/about)的语言切换。实践要点三种数据加载方式的选择| 场景 | 推荐方式 | 执行环境 | 特点 ||------|---------|---------|------|| 页面首屏数据 | routeLoader$ | 服务端 | 数据随 HTML 下发,零 waterfall || 客户端动态刷新 | clientLoader$ | 浏览器 | 不阻塞首屏,适合交互后更新 || 组件内响应式获取 | useResource$ | 浏览器 | 支持依赖追踪,适合交互驱动的更新 |错误处理routeLoader$ 中应统一处理错误,避免未捕获异常导致 500:export const useData = routeLoader$(async ({ params, redirect }) => { try { const response = await fetch(`https://api.example.com/data/${params.id}`); if (!response.ok) { throw redirect(302, '/error'); } return response.json(); } catch (error) { throw redirect(302, '/error'); }});注意这里使用 redirect 而非 throw new Error(),因为 Qwik City 的 redirect 是框架级的跳转机制,会正确设置 HTTP 状态码和 Location 头。缓存策略利用 requestEvent.sharedMap 实现请求级缓存,同一请求中多个 loader 共享数据:export const useCachedData = routeLoader$(async ({ requestEvent }) => { const cacheKey = 'shared-data'; const cached = requestEvent.sharedMap.get(cacheKey); if (cached) { return cached; } const data = await fetchData(); requestEvent.sharedMap.set(cacheKey, data); return data;});sharedMap 的生命周期是单次请求,不同于浏览器缓存或 CDN 缓存,它解决的是同一请求中多个 loader 重复获取相同数据的问题。例如,布局 loader 和页面 loader 都需要用户信息时,sharedMap 可以避免两次 fetch。以上是 Qwik City 的核心功能覆盖。从路由到数据加载再到表单和中间件,Qwik City 的设计始终围绕一个目标:让 Qwik 的可恢复性架构能在全栈场景下完整落地,避免传统 SSR 框架中常见的 hydration 开销和数据瀑布。与 Next.js 和 Remix 相比,Qwik City 最大的差异化在于零 JavaScript 首屏策略——页面首次加载不发送任何 JS,仅在用户交互时按需加载对应的事件处理器。
前端阅读 05月27日 17:31

Qwik 框架的性能优化策略有哪些?从可恢复性到细粒度更新的完整解析

Qwik 之所以在首屏性能上远超传统前端框架,核心在于它的"可恢复性"架构——服务端渲染的 HTML 可以在客户端直接恢复状态和事件绑定,完全跳过了水合过程。下面从原理到实践,逐层拆解 Qwik 的性能优化策略。可恢复性:Qwik 性能的根基传统 SSR 框架(React、Vue、Next.js)在客户端需要重新下载组件代码并执行水合(hydration),将 DOM 节点与事件监听器重新关联。这个过程随着页面复杂度增长而变慢。Qwik 的做法完全不同:服务端渲染时,Qwik 将组件状态序列化为 JSON,注入到 HTML 的 <script> 标签中事件处理函数不会被打包进首屏 JS,而是在 HTML 中以属性形式记录引用路径(如 on:click="/src/components/app.js#handleClick")客户端只需加载约 1KB 的 Qwik Loader 脚本,即可监听所有交互事件并在触发时按需加载对应处理函数这意味着首屏加载几乎等同于纯 HTML 页面,没有框架运行时的启动开销。// 服务端渲染后的 HTML 片段示例// 事件绑定以引用路径形式存在,不包含实际 JS 代码<button on:click="./app.js#handleClick_0">Increment</button>// 状态序列化在 <script type="qwik/json"> 中零水合与按需加载Qwik Loader 机制Qwik 在 HTML 末尾注入一个极小的 Qwik Loader 脚本(约 1KB),它的唯一职责是监听 DOM 事件。当用户触发交互时,Loader 根据事件目标上的引用路径,动态 import 对应的代码块并执行。export const App = component$(() => { const count = useSignal(0); return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}> Increment </button> </div> );});上面这段代码编译后,component$ 内部的渲染逻辑和 onClick$ 回调会被分别打包成独立文件。首屏只输出 HTML 结构,JS 代码在用户点击按钮时才加载。与传统水合的对比| 阶段 | 传统 SSR 框架 | Qwik ||------|-------------|------|| 首屏 JS 体积 | 50KB-200KB+ | ~1KB || 水合过程 | 下载全部组件代码 → 解析 → 执行绑定 | 无水合,直接可交互 || 首次可交互时间 | 依赖 JS 下载+解析完成 | HTML 到达即可交互 || 交互延迟 | 无(代码已加载) | 首次交互需下载对应代码块(通常 <50ms) |细粒度代码分割Qwik 编译器在构建阶段自动进行组件级和函数级分割,不需要手动配置 dynamic import 或 React.lazy。组件级分割每个 component$() 包裹的组件都会被编译为独立文件:export const Dashboard = component$(() => { return ( <div> <Header /> <Sidebar /> <Content /> <Footer /> </div> );});编译产物:Dashboard.js、Header.js、Sidebar.js、Content.js、Footer.js 各自独立,按需加载。事件处理函数级分割$ 后缀的函数会被提取为独立模块:export const Form = component$(() => { const handleSubmit$ = () => { /* 提交逻辑 */ }; const handleReset$ = () => { /* 重置逻辑 */ }; const handleCancel$ = () => { /* 取消逻辑 */ }; return ( <form> <button onClick$={handleSubmit$}>Submit</button> <button onClick$={handleReset$}>Reset</button> <button onClick$={handleCancel$}>Cancel</button> </form> );});三个回调函数各自成为独立文件,只有在用户点击对应按钮时才发起请求。这种粒度是传统框架无法自动实现的。事件委托Qwik 在事件处理上采用全局委托策略:不在每个 DOM 节点上注册事件监听器,而是在 document 或 window 上统一监听。当事件冒泡到顶层时,Qwik Loader 从事件目标读取引用路径,动态加载对应的处理函数。这带来的好处:首屏无需注册任何事件监听器,减少 JS 执行量避免了传统框架中大量 addEventListener 调用的性能开销动态内容(如异步加载的组件)天然支持事件绑定,无需额外处理智能预取策略虽然 Qwik 的核心思路是"按需加载",但它并不会让用户在每次交互时都等待网络请求。Qwik 提供了预取机制:交互预取:当用户鼠标悬停(hover)或焦点移到可交互元素时,Qwik 提前下载对应代码块可见性预取:视口内的组件代码优先预取预取在主线程外执行:利用浏览器的 <link rel="modulepreload"> 或 import() 在 Worker 线程中完成,不阻塞主线程// 通过 prefetchStrategy 配置预取行为export default config({ prefetchStrategy: { implementation: { linkInsert: 'js-append', linkHref: (path) => path, workerFetch: true, }, },});预取策略让 Qwik 在"零首屏 JS"和"即时交互响应"之间取得平衡:首屏不加载多余代码,但用户即将交互时代码已经就绪。响应式细粒度更新Qwik 的响应式系统自动追踪状态依赖,只在状态变化时更新受影响的 DOM 节点。export const TodoList = component$(() => { const todos = useStore([ { id: 1, text: '学习 Qwik 基础', completed: false }, { id: 2, text: '实践代码分割', completed: false }, { id: 3, text: '部署到生产环境', completed: false } ]); return ( <ul> {todos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onClick$={() => { todo.completed = !todo.completed; }} /> <span>{todo.text}</span> </li> ))} </ul> );});点击某个 todo 的复选框时,只有该 <li> 内的复选框状态更新,其他项不会重新渲染。这与 React 的虚拟 DOM diff 或 Vue 的组件级更新不同,Qwik 能做到属性级的精确更新。开发实践中的性能优化选择合适的状态原语// 原始值用 useSignal——轻量,追踪精确const count = useSignal(0);const name = useSignal('');// 对象和数组用 useStore——深层响应式追踪const user = useStore({ name: '张三', settings: { theme: 'dark', language: 'zh-CN' }});useSignal 适合独立原始值,变更时只触发依赖该值的位置更新。useStore 适合嵌套对象,Qwik 会自动追踪到具体哪个属性发生了变化。用 useComputed$ 缓存派生计算export const ShoppingCart = component$(() => { const items = useStore([ { name: 'Qwik 实战手册', price: 79, qty: 1 }, { name: 'TypeScript 进阶', price: 59, qty: 2 } ]); const total = useComputed$(() => { return items.reduce((sum, item) => sum + item.price * item.qty, 0); }); return <div>合计:¥{total.value}</div>;});useComputed$ 只在依赖的状态变化时重新计算,避免每次渲染都执行计算逻辑。用 useResource$ 处理异步数据流export const UserProfile = component$(({ userId }: { userId: string }) => { const userData = useResource$(async ({ track }) => { track(() => userId); const res = await fetch(`/api/users/${userId}`); return res.json(); }); return ( <div> {userData.isLoading && <p>加载中...</p>} {userData.failed && <p>加载失败,请重试</p>} {userData.value && ( <div> <h3>{userData.value.name}</h3> <p>{userData.value.bio}</p> </div> )} </div> );});useResource$ 自带加载态和错误态处理,且会在 track 的依赖变化时自动重新请求。客户端专属逻辑用 useVisibleTask$export const MapWidget = component$(() => { const containerRef = useRef<HTMLDivElement>(); useVisibleTask$(() => { // 只在浏览器环境、组件可见时执行 const map = createMap(containerRef.current); return () => map.destroy(); // 清理函数 }); return <div ref={containerRef} style={{ height: '400px' }}></div>;});useVisibleTask$ 确保 DOM 依赖的逻辑只在客户端执行,不会在 SSR 阶段报错,且组件进入视口时才触发,避免不可见区域的无谓初始化。避免在渲染路径上创建新引用// 不推荐:每次渲染产生新的对象引用,可能导致不必要的子组件重渲染export const List = component$(() => { return <Child style={{ color: 'red' }} data={{ items: [] }} />;});// 推荐:将静态引用提到组件外部const staticStyle = { color: 'red' };const staticData = { items: [] };export const List = component$(() => { return <Child style={staticStyle} data={staticData} />;});状态序列化与恢复Qwik 的状态管理贯穿服务端和客户端。在 SSR 阶段,所有通过 useSignal、useStore、useContext 等创建的状态都会被序列化到 HTML 中。客户端加载时,Qwik 直接从 HTML 中反序列化恢复状态,无需重新请求接口或重新执行组件逻辑。这带来的实际收益:页面刷新后表单数据不丢失浏览器前进后退时状态完整恢复无需额外设计客户端缓存策略SSR 与 SSG 部署选择Qwik 支持多种渲染模式,不同模式对性能有直接影响:SSR(服务端渲染):适合动态内容为主的页面,每次请求实时渲染,配合 CDN 缓存可兼顾动态性和性能SSG(静态生成):适合内容相对固定的页面,构建时生成 HTML,部署到 CDN 后响应速度最快ISR(增量静态再生):SSG 的升级版,支持按时间或按需重新生成静态页面,兼顾性能和内容时效性实际项目中,通常将营销页和文档页用 SSG,用户仪表盘用 SSR,实现不同场景下的最优性能。性能监控指标部署后关注以下 Core Web Vitals 指标来验证优化效果:LCP(Largest Contentful Paint):最大内容绘制时间,衡量首屏主要内容加载速度。Qwik 的零 JS 策略通常能让 LCP 接近纯 HTML 页面水平FID / INP(首次输入延迟 / 交互到下次绘制):衡量交互响应速度。Qwik 的事件委托和预取策略使 INP 通常低于 50msCLS(Cumulative Layout Shift):累积布局偏移。Qwik 的 SSR 输出完整 DOM 结构,天然避免布局抖动使用 Chrome DevTools 的 Performance 面板或 Lighthouse 可以量化这些指标。Qwik 项目内置的 DevTools 还提供组件树可视化、代码分割视图和状态追踪功能,方便定位性能瓶颈。Qwik 的性能优势不是靠某个单一技巧实现的,而是可恢复性架构、编译时自动分割、事件委托、智能预取、细粒度响应式更新这几项机制协同工作的结果。理解这些原理后,结合上面的开发实践,就能在日常开发中充分发挥 Qwik 的性能潜力。
前端阅读 05月27日 17:31

Qwik 恢复性(Resumability)是什么?为什么不需要 Hydration?

Qwik 恢复性(Resumability)是什么?恢复性(Resumability)是 Qwik 框架的核心架构理念:应用在服务器端完成渲染后,客户端无需重新执行 JavaScript 即可直接恢复执行状态。这与传统框架的水合(Hydration)机制形成根本区别。传统 SSR 框架的流程是:服务器渲染 HTML → 客户端下载 JS → 重新执行全部 JS 恢复事件绑定 → 页面变为可交互。而 Qwik 的流程是:服务器渲染 HTML 并序列化状态 → 客户端直接从 HTML 恢复状态 → 页面已可交互。前者是"重新执行",后者是"继续执行"。Qwik 如何实现恢复性?延迟加载(Lazy Loading)Qwik 默认将所有 JavaScript 代码分割成细粒度的小块,只有用户实际交互时才加载对应的代码。传统框架通常需要下载整个应用的 JS 包后才能启动,而 Qwik 的首屏加载几乎不包含业务 JavaScript。<!-- Qwik 编译后的按钮:事件处理程序被替换为引用路径 --><button on:click="./click-handler.js#handleClick">Click me</button>用户点击按钮时,Qwik 才按需下载 click-handler.js 中的 handleClick 函数,而非整个应用。序列化状态到 HTMLQwik 将应用的组件状态、事件监听器定义、组件层次结构等信息序列化后嵌入 HTML,以属性和 <script> 标签的形式存在:<div q:state="{count: 0}"></div><script type="qwik/json"> {"count": 0}</script>浏览器加载页面时,直接从 HTML 中读取这些序列化数据恢复状态,不需要重新执行初始化代码来重建应用状态。无水合(No Hydration)传统框架(React、Vue、Angular)在 SSR 后必须在客户端重新执行 JavaScript 来附加事件监听器和重建组件树,这个过程称为水合(Hydration)。水合的问题在于:时间复杂度为 O(n):页面有多少组件,就需要重新执行多少组件代码TTI 延迟:页面看起来已经渲染完毕,但在 JS 执行完成前无法交互重复工作:服务器已经渲染过的逻辑,客户端再执行一遍Qwik 通过将事件监听器以引用路径的方式序列化到 HTML 中,完全跳过了水合步骤。客户端不需要重新执行组件代码来"发现"事件绑定——绑定信息已经在 HTML 里了。细粒度按需加载Qwik 可以加载单个函数或单个组件,而不是整个模块。点击一个按钮只会加载该按钮的事件处理程序,不会加载兄弟组件、父组件或其他无关代码。这种粒度是组件级甚至函数级的,远细于传统框架的路由级或页面级代码分割。可恢复的执行上下文Qwik 维护了一个可以在服务器和客户端之间传递的执行上下文。服务器渲染时捕获的闭包变量、组件作用域等信息被序列化保存,客户端可以直接恢复这些上下文,确保代码在不同运行环境中无缝衔接。恢复性 vs 水合:核心差异对比| 维度 | 水合(Hydration) | 恢复性(Resumability) ||---|---|---|| 启动方式 | 重新执行 JS 恢复状态 | 从 HTML 直接读取状态 || 时间复杂度 | O(n),与组件数量成正比 | O(1),框架代码即时可用 || 事件绑定 | 客户端重新执行代码附加 | 序列化在 HTML 属性中 || 首屏 JS 体积 | 需要下载框架+应用代码 | 近零 JS,按需加载 || TTI | 受 JS 下载和执行影响 | 接近即时可交互 |恢复性带来的优势更快的首屏加载:页面不依赖大量 JavaScript 即可完成渲染,首屏时间显著缩短即时可交互(TTI ≈ FCP):内容出现时即已可交互,没有水合等待期更低的带宽消耗:只加载用户实际交互所需的代码,其余代码不传输更好的 SEO:服务器端渲染输出完整 HTML,搜索引擎可直接索引可扩展性:应用功能增多不会线性增加首屏加载开销Qwik 编译器的角色恢复性的实现并不需要开发者手动管理代码分割和状态序列化。Qwik 的编译器在构建阶段自动完成这些工作:自动识别可延迟加载的代码边界,将事件处理程序和组件拆分为独立 chunk自动分析组件状态依赖关系,确定需要序列化的数据范围将事件监听器引用转换为可恢复的路径格式开发者编写代码时仍使用熟悉的组件模式,编译器在产出层确保一切符合恢复性架构的要求。
前端阅读 05月27日 17:30

Qwik 和 React 有什么区别?

Qwik 和 React 的核心架构差异是什么?Qwik 和 React 最大的区别在于架构理念:React 基于 虚拟 DOM + 水合(Hydration),Qwik 基于 可恢复性(Resumability)+ 按需加载。这个根本差异直接影响了加载策略、状态管理、性能表现等方方面面。加载策略:全量下载 vs 按需加载React 在页面渲染时,通常需要下载整个应用包(或多个 chunk)。即使使用了 Code Splitting 做懒加载,也需要开发者手动配置:// React 懒加载需要手动配置const LazyComponent = React.lazy(() => import('./HeavyComponent'));function App() { return ( <Suspense fallback={<Loading />}> <LazyComponent /> </Suspense> );}Qwik 的加载策略完全不同——所有 JavaScript 默认都是延迟加载的,只有用户与页面交互时才加载和执行相关代码:// Qwik 组件:事件处理器自动延迟加载import { component$, useSignal } from '@builder.io/qwik';export default component$(() => { const count = useSignal(0); return ( <button onClick$={() => count.value++}> 点击了 {count.value} 次 </button> );});注意 Qwik 中的 component$ 和 onClick$,$ 后缀表示这是一个延迟加载边界,编译器会自动将这段代码拆分为独立 chunk,仅在需要时加载。水合 vs 可恢复性这是 Qwik 和 React 最本质的区别。React 的水合过程:SSR 渲染出 HTML 后,客户端必须重新下载并执行 JavaScript,重建组件树、附加事件监听器,让页面变得可交互。这个过程称为 Hydration:SSR HTML → 下载 JS → 执行组件代码 → 附加事件 → 页面可交互 ↑ 这一步耗时且昂贵即使用 React 18 的 Selective Hydration 做了部分优化,仍然无法避免大量 JavaScript 的下载和执行。Qwik 的可恢复性:不需要水合。Qwik 在 SSR 时将组件状态序列化到 HTML 中,事件监听器通过 HTML 属性直接附加:<!-- Qwik 渲染出的 HTML --><button on:click="/build/bundle-abc.js#handler_xyz"> 点击了 0 次</button>浏览器拿到 HTML 后,当用户点击按钮时,才去加载对应的 JS 函数并执行。页面天然就是可交互的,不需要任何"唤醒"过程:SSR HTML → 页面立即可交互 ↑ 无需额外 JS 执行状态管理:细粒度更新 vs 重新渲染React 使用 useState、useReducer、Context API 管理状态,状态变化会触发组件重新渲染:// React:状态更新触发组件重渲染function Counter() { const [count, setCount] = useState(0); // count 变化 → 整个组件重新执行 return <button onClick={() => setCount(c => c + 1)}>{count}</button>;}复杂应用中,开发者需要借助 useMemo、useCallback、React.memo 手动优化渲染性能,或者引入 Redux、Zustand 等外部状态管理库。Qwik 使用 useSignal 和 useStore 管理状态,状态变化只更新绑定的 DOM 节点,不会触发组件重新渲染:// Qwik:状态更新只更新具体 DOM 节点export default component$(() => { const count = useSignal(0); // count 变化 → 只更新 {count.value} 对应的文本节点 return <button onClick$={() => count.value++}>{count.value}</button>;});此外,Qwik 的状态会被序列化到 HTML 中,刷新页面后状态依然存在,不需要额外的状态恢复逻辑。性能数据对比| 指标 | React + Next.js | Qwik + Qwik City ||------|----------------|-------------------|| 首屏 JS 体积 | 40-100KB+ | 约 1-2KB || TTI(可交互时间) | 需等待水合完成 | HTML 加载即交互 || 水合开销 | 重新执行全部组件 JS | 无水合过程 || 代码分割 | 手动配置(lazy/Suspense) | 编译器自动完成 |Qwik 在首屏加载上的优势尤为明显——初始 JS 包只有 1-2KB,而 React 应用即使做了代码分割,首屏仍需加载框架核心和组件代码。开发体验对比React 的优势:生态系统成熟,npm 上几乎任何需求都有现成库可用。社区支持强大,遇到问题容易找到解决方案。Next.js 提供了完善的 SSR/SSG 方案。Qwik 的学习成本:语法与 React 相似(JSX、Hooks 风格的 API),但有几个关键差异需要适应:component$ 替代普通函数组件$ 后缀标记延迟加载边界useSignal / useStore 替代 useStateuseTask$ 替代 useEffect编译器自动处理优化,不需要手动写 useMemo / useCallback各自适合什么场景?选择 Qwik 的场景:内容密集型网站(博客、新闻、电商列表页)对首屏加载速度和 SEO 排名有严格要求面向移动端用户或网络条件不稳定的场景大型应用希望减少 JS 体积对性能的影响选择 React 的场景:需要丰富的第三方库和工具支持团队已有 React 经验,迁移成本需要考虑项目复杂度高,需要成熟的架构方案(如 Next.js App Router)快速原型开发,优先开发效率而非极致性能迁移建议如果你正在考虑从 React 迁移到 Qwik,需要注意:Qwik 提供了 qwik-react 集成,可以在 Qwik 应用中逐步引入 React 组件,支持渐进式迁移并非所有 React 生态库都有 Qwik 对应方案,复杂项目建议先做技术评估对于已有 React 项目,迁移优先级应基于性能瓶颈:如果当前应用首屏加载不是痛点,迁移的收益有限Qwik 通过可恢复性架构在首屏性能上建立了明显优势,但 React 凭借成熟的生态和社区仍是更稳妥的选择。具体选型应基于项目对性能、生态和团队能力的综合考量。
前端阅读 05月27日 17:30

Qwik 编译器的工作原理是什么?从代码分割到可恢复序列化

Qwik 之所以能在首屏加载时做到近乎零 JavaScript,核心驱动力就是它的编译器。编译器将开发者编写的组件代码,在构建阶段就拆解成最小可延迟加载的单元,并把运行时状态序列化进 HTML,让浏览器无需重新执行应用即可恢复交互。下面从编译流程、代码分割、序列化机制、元数据生成、优化策略、类型安全与调试七个层面拆解 Qwik 编译器的工作原理。编译流程:从源码到可恢复产物Qwik 编译器(@builder.io/qwik/optimizer)的处理流程分为五个阶段:解析:读入 TypeScript/JSX 源码,构建 AST(抽象语法树)分析:遍历 AST,识别 component$、$ 后缀函数、useSignal 等 Qwik 特有构造,标记懒加载边界转换:将 $ 后缀的函数提取为独立模块,生成懒加载引用替代原位函数体代码生成:输出分割后的 JavaScript 文件与元数据清单优化:应用死代码消除、常量折叠、Tree Shaking 等优化入口调用示例:import { transform } from '@builder.io/qwik/optimizer';const result = transform({ code: sourceCode, filename: 'component.tsx', minify: true, sourceMap: true, entryStrategy: 'smart'});代码分割:$ 后缀是关键分割边界Qwik 的代码分割不是按路由或组件粒度,而是按交互粒度。编译器识别 $ 后缀标记(如 component$、onClick$、handleClick$),将每个标记的函数体提取为独立 chunk。// 原始代码export const App = component$(() => { const handleClick$ = () => { console.log('Clicked'); }; const handleSubmit$ = () => { console.log('Submitted'); }; return ( <div> <button onClick$={handleClick$}>Click</button> <button onClick$={handleSubmit$}>Submit</button> </div> );});编译后产物:dist/├── App.js # 主组件骨架(不含事件逻辑)├── handleClick.js # 点击处理函数,按需加载├── handleSubmit.js # 提交处理函数,按需加载└── q-manifest.json # 符号与 chunk 映射清单主组件只保留函数引用而非函数体,用户点击按钮时才加载对应 chunk。这就是 Qwik "延迟加载一切"策略的实现基础。分割策略可通过配置调整:// qwik.config.tsexport default defineConfig({ optimizer: { entryStrategy: { type: 'smart', // 'smart' | 'hook' | 'inline' manualChunks: { 'vendor': ['lodash'] } } }});smart:编译器自动判断最小分割粒度(推荐)hook:仅分割事件处理函数inline:不做分割,全部内联序列化机制:可恢复性的根基Qwik 编译器最独特的能力是将组件状态序列化进 HTML,使页面在服务端渲染后,客户端无需重新执行 JavaScript 即可恢复交互——这就是 Resumability(可恢复性)。状态序列化export const Counter = component$(() => { const count = useSignal(0); return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}>Increment</button> </div> );});编译器将信号状态直接写入 HTML:<div data-qwik="q-123"> <p>Count: <span data-qwik="q-456">0</span></p> <button data-qwik="q-789" onClick$="./handleClick.js#handleClick"> Increment </button> <script type="qwik/json"> { "q-456": { "value": 0 } } </script></div><script type="qwik/json"> 中存储了信号的当前值,按钮的 onClick$ 属性指向一个 chunk 路径而非内联函数。浏览器首次渲染时只解析 HTML,不执行任何 JavaScript;用户点击按钮后,才加载 handleClick.js 并恢复事件绑定。函数引用序列化编译器将函数引用序列化为路径映射:{ "q-789": { "func": "./handleClick.js#handleClick", "captures": [] }}captures 数组记录闭包捕获的变量引用。如果事件处理函数引用了外部变量,编译器会将这些变量的值一并序列化,确保恢复时闭包上下文完整。元数据生成:q-manifest.json编译器生成 q-manifest.json,它是运行时懒加载的路由表:{ "symbols": { "s_123": { "canonicalFilename": "./App.js", "hash": "abc123", "kind": "component", "name": "App" }, "s_456": { "canonicalFilename": "./handleClick.js", "hash": "def456", "kind": "eventHandler", "name": "handleClick" } }, "mapping": { "q-123": "s_123", "q-456": "s_456" }, "bundles": { "./App.js": { "size": 1024, "symbols": ["s_123"] }, "./handleClick.js": { "size": 512, "symbols": ["s_456"] } }}symbols:每个 $ 后缀函数对应的符号定义(类型、文件路径、哈希)mapping:DOM 节点 ID 到符号 ID 的映射,运行时据此查找应加载哪个 chunkbundles:每个 chunk 的体积与包含的符号列表Qwik 运行时在用户交互时,通过 DOM 节点的 data-qwik 属性查 mapping,再查 symbols 定位 chunk 文件,实现精准的按需加载。优化策略死代码消除编译器追踪信号的使用情况,移除未引用的信号和逻辑:// 原始代码export const Component = component$(() => { const used = useSignal(0); const unused = useSignal(0); // 模板中未引用 return <div>{used.value}</div>;});// 编译后,unused 被移除export const Component = component$(() => { const used = useSignal(0); return <div>{used.value}</div>;});Tree Shaking编译器基于 ES Module 的静态结构,移除未导出的函数和变量:// 原始代码export const used = () => {};const notUsed = () => {}; // 未导出,被移除// 编译后export const used = () => {};常量折叠与内联对于纯表达式,编译器在构建时求值并替换:// 原始代码const smallFunction$ = () => 1 + 1;export const Component = component$(() => { return <div>{smallFunction$()}</div>;});// 编译后export const Component = component$(() => { return <div>{2}</div>;});类型安全与调试支持TypeScript 集成编译器完全支持 TypeScript 类型检查,包括对 component$ Props 的类型推断:export const Component = component$((props: { name: string; count: number; onClick$: () => void;}) => { return ( <div> <h1>{props.name}</h1> <p>Count: {props.count}</p> <button onClick$={props.onClick$}>Click</button> </div> );});编译器会验证 onClick$ 的 $ 后缀是否正确使用,确保懒加载边界不被意外打破。Source Maps编译器生成 source maps 支持源码级调试:const result = transform({ code: sourceCode, filename: 'component.tsx', sourceMap: true});开发/生产模式const result = transform({ code: sourceCode, mode: 'development' // 生成详细错误信息与完整的符号名称});开发模式下保留完整的符号名称和详细错误栈,生产模式下压缩为短哈希以减小体积。编译器与 Resumability 的关系理解 Qwik 编译器的关键在于:它不是传统意义上的转译器,而是为 Resumability 服务的预处理工具。传统 SSR 框架(如 Next.js)在服务端渲染 HTML 后,客户端还需要重新下载并执行整个应用的 JavaScript 来"水合"(Hydration)DOM 事件。Qwik 编译器通过三个核心能力彻底避免了这个问题:将函数体提取为独立 chunk,HTML 中只保留路径引用——客户端不需要预先加载事件处理代码将状态序列化进 HTML——客户端不需要重新执行组件来恢复状态生成 manifest 映射——运行时能在用户交互瞬间精准定位并加载所需代码这就是 Qwik 实现"零 Hydration"的编译器层面原理:编译器在构建时完成了传统框架在运行时才做的事情。
服务端阅读 05月27日 16:58

Rspack Loader 系统的工作原理是什么?

Rspack 的 Loader 系统是其处理各类文件的核心机制。虽然 Rspack 基于 Rust 开发,但设计上充分考虑了与 Webpack Loader 生态的兼容性——大部分社区 Loader 可以直接使用,同时通过内置 Loader 提供了显著的性能优势。理解 Loader 系统的工作方式,是用好 Rspack 的关键前提。Loader 的角色与定位Loader 是一种模块转换器,负责将源文件转换为 Rspack 能够处理的模块格式。它的核心能力包括:语言编译:将 TypeScript、JSX 等编译为 JavaScript样式处理:将 Less、Sass 编译为 CSS资源管理:处理图片、字体等静态资源代码校验:集成 ESLint 等代码检查工具Rspack 中 Loader 的定位与 Webpack 一致:先通过 Loader 对模块进行预处理,转换为 Rspack 支持的模块类型,再根据 rules[].type 进行后置处理。这两步构成了完整的模块处理流水线。配置方式基本规则在 module.rules 中通过 test 匹配文件扩展名,用 use 指定 Loader:module.exports = { module: { rules: [ { test: /\.js$/, use: 'babel-loader' } ] }}多 Loader 链式调用多个 Loader 组成链式处理,例如处理 CSS 文件:module.exports = { module: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader', 'postcss-loader'] } ] }}带选项的 Loader通过 options 传递配置参数:module.exports = { module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } } ] }}执行顺序:从右到左与 Pitch 机制Loader 链的执行顺序是理解整个系统的关键。基本执行顺序Loader 按从右到左、从下到上的顺序执行。对于 use: ['style-loader', 'css-loader', 'sass-loader']:sass-loader 先执行,将 SCSS 编译为 CSScss-loader 解析 CSS 中的 @import 和 url()style-loader 将 CSS 注入 DOMPitch 阶段每个 Loader 除了正常的转换函数外,还可以定义一个 pitch 方法。Pitch 阶段从左到右执行,在所有正常阶段之前运行。当某个 Loader 的 pitch 方法返回了值,后续 Loader 的 pitch 和正常阶段都会被跳过,执行流程直接回溯到前一个 Loader。对于 use: ['a-loader', 'b-loader', 'c-loader'],完整执行流程为:a-loader pitch → b-loader pitch → c-loader pitch → c-loader normal → b-loader normal → a-loader normal如果 b-loader 的 pitch 返回了值,则跳过 c-loader 和 b-loader 的 normal 阶段,直接进入 a-loader 的 normal 阶段。这个机制在 style-loader 的实现中被使用——它在 pitch 阶段拦截请求,直接返回一段将 CSS 注入页面的代码。内置 Loader:Rspack 的性能利器Rspack 提供了多个 Rust 实现的内置 Loader,在保持与 JavaScript Loader 相同可组合性的同时,提供了远超 JS Loader 的性能。builtin:swc-loader基于 SWC 的超快 JavaScript/TypeScript 编译器,替代 babel-loader 和 ts-loader:module.exports = { module: { rules: [ { test: /\.(js|jsx|ts|tsx)$/, use: { loader: 'builtin:swc-loader', options: { jsc: { parser: { syntax: 'typescript', tsx: true }, transform: { react: { runtime: 'automatic' } } } } } } ] }}builtin:swc-loader 的核心优势在于 SWC 本身就是 Rust 编写的,因此这个 Loader 运行在 Rspack 的 Rust 进程内部,无需跨语言通信开销。builtin:lightningcss-loader基于 Lightning CSS 的内置 CSS 处理器,可替代 postcss-loader + autoprefixer 的组合:import { rspack } from '@rspack/core';module.exports = { module: { rules: [ { test: /\.css$/, use: [ { loader: 'builtin:lightningcss-loader', options: { targets: ['chrome 60', 'firefox 60'] } } ] } ] }}主要配置项:targets:指定目标浏览器,支持 browserslist 查询字符串或版本号对象errorRecovery:默认 true,遇到无效 CSS 语法时跳过并发出警告而非中断编译需要注意 Lightning CSS 严格遵循 CSS 规范,处理非标准 CSS(如 CSS Modules 的局部作用域语法)时可能出现兼容问题。builtin:css-loaderRspack 还提供了 builtin:css-loader 作为 css-loader 的内置替代:module.exports = { module: { rules: [ { test: /\.css$/, use: ['style-loader', 'builtin:css-loader'] } ] }}原生 CSS 支持除了内置 Loader,Rspack 还通过 experiments.css 提供原生 CSS 支持,CSS 作为一等公民被直接处理:module.exports = { experiments: { css: true }}启用后可通过 type: 'css'、type: 'css/auto'、type: 'css/global'、type: 'css/module' 等模块类型控制 CSS 处理方式。此时不需要 css-loader,也不能与 style-loader 搭配使用,需使用 CssExtractRspackPlugin 或 Rspack 内置的 CSS 提取方案。匹配规则与条件test / include / excludetest:正则匹配文件路径include:限定只处理指定目录exclude:排除指定目录{ test: /\.js$/, include: path.resolve(__dirname, 'src'), exclude: /node_modules/, use: 'babel-loader'}oneOf当同一类文件只需匹配一个规则时,使用 oneOf 避免重复处理:{ test: /\.css$/, oneOf: [ { resourceQuery: /module/, use: 'css-loader?modules' }, { use: 'css-loader' } ]}resource 与 issuerresource:匹配被导入的资源路径issuer:匹配发起导入的模块路径{ test: /\.css$/, issuer: /\.js$/, // 只处理从 JS 文件中导入的 CSS use: 'style-loader'}自定义 Loader同步 Loader最简单的 Loader 形式,接收源码字符串,返回转换结果:module.exports = function(content) { return content.toUpperCase();};异步 Loader需要执行异步操作时,调用 this.async() 获取回调函数:module.exports = function(content) { const callback = this.async(); someAsyncOperation(content).then(result => { callback(null, result); }).catch(err => { callback(err); });};Raw Loader默认情况下 Loader 接收 UTF-8 字符串。当需要处理二进制文件时,导出 raw: true 以接收 Buffer:module.exports = function(content) { // content 是 Buffer const size = content.length; return `export default ${size}`;};module.exports.raw = true;获取选项通过 this.getOptions() 获取配置参数:module.exports = function(content, map, meta) { const options = this.getOptions(); const result = content.replace(options.search, options.replace); return result;};Rspack Loader 架构:与 Webpack 的关键区别Rspack 的 Loader 系统在设计上与 Webpack 保持了 API 兼容,但底层架构有本质区别。旧架构:Rust 端的 Loader Runner在早期版本中,Rspack 将 JS Loader 转换为可以从 Rust 端调用的原生函数,Loader Runner 完全运行在 Rust 端。这种方式的局限在于对复杂 JS Loader 的兼容性不够理想。新架构:标识符驱动的调度机制当前架构中,Rspack 从 Rust 核心将 Loader 请求委托给 JS 端的调度器,使用修改版的 webpack loader-runner 执行。每个 JS Loader 通过标识符(identifier)唯一识别,包含无法序列化字段的选项会复用 Loader 标识来避免重复解析。Rust-JS 通信优化由于 Rspack 的核心构建逻辑在 Rust 端,而社区 Loader 运行在 JS 端,两者之间的通信是性能瓶颈。Rspack 的优化策略是将连续的 JS Loader 组合在一起执行,减少 Rust-JS 之间的通信轮次。当模块处理链中包含多个 JS Loader 时,Rspack 会把它们合并为一次 JS 调用,而非每个 Loader 都进行一次跨语言通信。增量构建优势与 esbuild 的 onLoad 回调不同,Rspack 在 rebuild 时只触发变动模块的 Loader 重复执行,未变化的模块直接使用缓存。在大项目中这是一个关键的 O(n) 复杂度优势。性能优化建议优先使用内置 Loader:builtin:swc-loader 替代 babel-loader,builtin:lightningcss-loader 替代 postcss-loader,性能提升通常在数倍到数十倍缩小 Loader 作用范围:使用 include 和 exclude 避免对 node_modules 等目录执行不必要的转换启用缓存:对 babel-loader 等仍需使用的 JS Loader 开启 cacheDirectory使用原生 CSS 方案:如果项目不依赖 css-loader 的特定功能,启用 experiments.css 可以获得更好的 CSS 处理性能避免不必要的 Loader:Rspack 原生支持 Asset Modules,不需要 file-loader 和 url-loader,通过 type: 'asset/resource' 和 type: 'asset' 替代兼容性说明Rspack 支持绝大多数社区 Loader,只有少数依赖 Webpack 内部实现细节的 Loader(如 cache-loader)尚未兼容。如果遇到不兼容的情况,可以通过 Rspack GitHub Issues 反馈。
前端阅读 05月27日 16:55

Rspack 如何处理 CSS?

Rspack 将 CSS 视为一等公民,内置了完整的 CSS 处理能力,无需像 Webpack 那样依赖 css-loader 和 style-loader。理解 Rspack 的 CSS 处理机制,是从 Webpack 迁移或新建项目时的关键知识。CSS 模块类型Rspack 通过 module.rules 中的 type 字段来区分 CSS 的处理方式,支持四种模块类型:| 类型 | 说明 ||------|------|| css/auto | 根据文件名自动判断:*.module.css 视为 CSS Modules,其余视为普通 CSS || css | 普通 CSS,不启用 CSS Modules || css/global | 以全局作用域模式解析 CSS Modules || css/module | 强制启用 CSS Modules |从 Rspack 0.6.0 起,*.css 文件默认类型从 css 变更为 css/auto,这意味着 style.css 和 style.module.css 可以在同一项目中自动区分处理,无需额外配置:module.exports = { module: { rules: [ { test: /\.css$/i, type: 'css/auto', // 默认值,可省略 }, ], },};CSS ModulesRspack 内置支持 CSS Modules,无需 css-loader。以 .module.css 结尾的文件会被自动识别:/* index.module.css */.container { display: flex;}.active { color: red;}在 JavaScript 中通过命名空间导入使用:import * as styles from './index.module.css';// 使用document.querySelector('.'app').className = styles.container;也支持命名导入:import { active } from './index.module.css';如果需要默认导入(import styles from './index.module.css'),需要关闭 namedExports:module.exports = { module: { rules: [ { test: /\.module\.css$/i, type: 'css/auto', use: [{ loader: 'css-loader', options: { modules: { namedExports: false } }, }], }, ], },};CSS 提取到独立文件生产环境通常需要将 CSS 提取到独立文件。Rspack 提供了内置的 CssExtractRspackPlugin,替代 Webpack 中的 mini-css-extract-plugin:import { rspack } from '@rspack/core';module.exports = { module: { rules: [ { test: /\.css$/i, type: 'css/auto', }, ], }, plugins: [ new rspack.CssExtractRspackPlugin({ filename: 'css/[name].[contenthash].css', chunkFilename: 'css/[id].[contenthash].css', }), ],};注意:CssExtractRspackPlugin 与 Rspack 内置 CSS 类型(css/auto、css、css/module)配合使用,不需要像 Webpack 那样在 loader 链中手动注入提取 loader。如果项目仍依赖 css-loader,可以使用传统方式:import { rspack } from '@rspack/core';module.exports = { module: { rules: [ { test: /\.css$/i, type: 'javascript/auto', // 覆盖内置 CSS 类型 use: [ rspack.CssExtractRspackPlugin.loader, 'css-loader', ], }, ], }, plugins: [ new rspack.CssExtractRspackPlugin({}), ],};CSS 预处理器Rspack 通过对应的 loader 支持主流 CSS 预处理器,处理结果交给 Rspack 内置 CSS 引擎进行后处理。Sass/SCSSmodule.exports = { module: { rules: [ { test: /\.s(?:a|c)ss$/, type: 'css/auto', // 自动识别 *.module.scss use: ['sass-loader'], }, ], },};Lessmodule.exports = { module: { rules: [ { test: /\.less$/, type: 'css/auto', // 自动识别 *.module.less use: ['less-loader'], }, ], },};Stylusmodule.exports = { module: { rules: [ { test: /\.styl$/, type: 'css/auto', use: ['stylus-loader'], }, ], },};关键区别:与 Webpack 不同,Rspack 不需要在 loader 链中加入 css-loader 和 style-loader,预处理器 loader 的输出直接由 Rspack 内置 CSS 引擎接管。PostCSS 与 Tailwind CSS 集成Rspack 通过 postcss-loader 集成 PostCSS 生态,这是接入 Tailwind CSS 等工具的标准方式。基础 PostCSS 配置module.exports = { module: { rules: [ { test: /\.css$/, type: 'css/auto', use: [ { loader: 'postcss-loader', options: { postcssOptions: { plugins: [ require('autoprefixer'), require('cssnano')({ preset: 'default' }), ], }, }, }, ], }, ], },};也可以使用独立的 postcss.config.js 配置文件:// postcss.config.jsmodule.exports = { plugins: [ require('autoprefixer')({ overrideBrowserslist: ['> 1%', 'last 2 versions'], }), ],};Tailwind CSS v4 集成Tailwind CSS v4 采用了新的 PostCSS 插件架构:npm install tailwindcss @tailwindcss/postcss postcss postcss-loader -D// postcss.config.mjsexport default { plugins: { '@tailwindcss/postcss': {}, },};// rspack.config.jsmodule.exports = { module: { rules: [ { test: /\.css$/, use: ['postcss-loader'], type: 'css', }, ], },};CSS 优化Rspack 内置了 CSS 优化能力,生产模式下默认启用:代码压缩:Rspack 使用内置压缩器处理 CSS,也可以通过 CssMinimizerPlugin 自定义压缩策略:const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin({ minimizerOptions: { preset: ['default', { discardComments: { removeAll: true } }], }, }), ], },};Tree Shaking:Rspack 在内置 CSS 处理中支持未使用 CSS 的移除,分析 JavaScript 中的类名引用,只保留实际使用的样式规则。代码分割:配合 splitChunks 可以将 CSS 按策略拆分:module.exports = { optimization: { splitChunks: { cacheGroups: { styles: { type: 'css/mini-extract', name: 'styles', chunks: 'all', enforce: true, }, }, }, },};从 Webpack 迁移 CSS 配置迁移时需要注意的核心差异:移除 css-loader 和 style-loader:Rspack 内置了 CSS 处理,这两个 loader 不再需要替换 mini-css-extract-plugin:使用内置的 rspack.CssExtractRspackPlugin设置模块类型:通过 type: 'css/auto' 替代 loader 链方式控制 CSS Modules 行为experiments.css:在 Rspack 2.0 中内置 CSS 支持默认启用,旧版本可通过 experiments: { css: true } 开启// Webpack 配置 → Rspack 配置// 之前:// { test: /\.css$/, use: ['style-loader', 'css-loader'] }// 之后:{ test: /\.css$/i, type: 'css/auto' }这种简化得益于 Rspack 用 Rust 实现的内置 CSS 解析管线,避免了 Webpack 中多 loader 串联的性能开销。
前端阅读 05月27日 16:54

Rspack 环境变量怎么配置和管理?

Rspack 的环境变量管理是前端工程化中的核心能力,用于区分开发、测试、生产等不同环境的配置。本文从 Rspack 原生插件、Rsbuild 集成、.env 文件、多环境配置、TypeScript 类型安全到 CI/CD 实践,系统讲解环境变量的完整管理方案。环境变量的作用环境变量在构建时注入到代码中,被 Rspack 直接替换为字面量值。这意味着代码中引用 process.env.NODE_ENV 的地方,打包后会被替换为 "production" 或 "development" 字符串,而非运行时读取。这一机制既能实现条件编译,也能配合 tree shaking 移除死代码。Rspack 原生插件配置DefinePluginRspack 内置 DefinePlugin,用法与 webpack 一致,用于将代码中的标识符替换为给定值:const { DefinePlugin } = require('@rspack/core');module.exports = { plugins: [ new DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production'), 'process.env.API_URL': JSON.stringify('https://api.example.com'), 'process.env.VERSION': JSON.stringify('1.0.0') }) ]};值的格式要求:所有值必须用 JSON.stringify() 包裹,因为插件做的是文本替换,不包裹会变成未定义的变量引用。EnvironmentPluginEnvironmentPlugin 是 DefinePlugin 针对 process.env 的语法糖,直接读取系统环境变量:const { EnvironmentPlugin } = require('@rspack/core');module.exports = { plugins: [ new EnvironmentPlugin({ NODE_ENV: 'development', // 默认值,process.env 中没有时使用 DEBUG: false, // 默认值 API_KEY: undefined // undefined 表示必须提供,否则构建报错 }) ]};与 DefinePlugin 的区别:EnvironmentPlugin 自动从 process.env 读取值并应用 JSON.stringify,无需手动处理。用 undefined 作默认值时,变量缺失会报错;用 null 作默认值则变量缺失时静默跳过。Rsbuild 中的环境变量Rsbuild 基于 Rspack 封装,提供了更简洁的环境变量管理方式。默认注入的变量Rsbuild 自动注入以下变量,无需手动配置:// import.meta.env 中可用import.meta.env.MODE // 构建模式:'production' | 'development' | 'none'import.meta.env.DEV // 是否为开发模式import.meta.env.PROD // 是否为生产模式import.meta.env.SSR // 是否为 SSR 模式import.meta.env.BASE_URL // 基础 URLimport.meta.env.ASSET_PREFIX // 资源前缀// process.env 中可用process.env.NODE_ENV // 自动设为 'development' 或 'production'process.env.BASE_URLprocess.env.ASSET_PREFIXsource.define 自定义变量通过 source.define 配置项注入自定义变量,这是 Rsbuild 推荐的方式:export default { source: { define: { 'process.env.CUSTOM_VAR': JSON.stringify('value'), 'import.meta.env.LANGUAGE': JSON.stringify('zh-CN'), }, },};关闭 NODE_ENV 自动注入如果需要自定义 process.env.NODE_ENV 的行为,通过 Rspack 的 optimization.nodeEnv 控制:export default { tools: { rspack: { optimization: { nodeEnv: false }, }, },};手动加载环境变量使用 Rsbuild 的 JavaScript API(非 CLI)时,需要手动调用 loadEnv:import { loadEnv, mergeRsbuildConfig } from '@rsbuild/core';const { parsed, publicVars } = loadEnv();const mergedConfig = mergeRsbuildConfig( { source: { define: publicVars, }, }, userConfig,);.env 文件管理文件加载规则Rsbuild CLI 自动使用 dotenv 加载项目根目录的 .env 文件,加载优先级从高到低:.env.[mode].local — 特定环境的本地覆盖(不提交到 Git).env.local — 本地覆盖(不提交到 Git).env.[mode] — 特定环境的共享配置.env — 所有环境的默认值以 PUBLIC_ 为前缀的变量会暴露到客户端代码中,其他变量仅在 Node 侧可用。可以通过 --no-env 选项禁用自动加载。文件命名示例# .env — 通用默认值PUBLIC_APP_TITLE=MyApp# .env.development — 开发环境PUBLIC_API_URL=http://localhost:3000DEBUG=true# .env.production — 生产环境PUBLIC_API_URL=https://api.example.comDEBUG=false# .env.local — 本地覆盖(加入 .gitignore)API_KEY=your-secret-key.gitignore 配置.env.local.env.*.local提交 .env.development 和 .env.production 方便团队共享,而 .local 后缀的文件仅用于本地敏感配置。命令行传递环境变量直接通过命令行设置环境变量:# Unix/Linux/macOSNODE_ENV=production API_URL=https://api.example.com npx rspack build# Windows(cmd)set NODE_ENV=production&& set API_URL=https://api.example.com&& npx rspack build# 跨平台方案npx cross-env NODE_ENV=production npx rspack buildRsbuild CLI 还支持 --env 参数传递:npx rsbuild build --env production环境变量与 Tree Shaking环境变量的文本替换特性可以标记死代码,帮助 Rspack 在构建时移除不需要的分支:// 源码if (import.meta.env.DEV) { console.log('debug info');}// 生产构建后,整个 if 分支被移除利用这一机制,可以通过自定义变量实现条件编译:export default { source: { define: { 'import.meta.env.LANGUAGE': JSON.stringify('zh-CN'), }, },};// 代码中if (import.meta.env.LANGUAGE === 'zh-CN') { // 仅中文版包含的代码}TypeScript 类型定义为环境变量添加类型声明,避免拼写错误和类型不安全:// env.d.tsdeclare namespace NodeJS { interface ProcessEnv { NODE_ENV: 'development' | 'production' | 'test'; API_URL: string; VERSION: string; DEBUG: string; }}// 或为 import.meta.env 添加类型(Rsbuild 项目)interface ImportMetaEnv { readonly MODE: string; readonly DEV: boolean; readonly PROD: boolean; readonly CUSTOM_VAR: string;}多环境配置方案方案一:配置文件拆分将通用配置和各环境配置拆分为独立文件,通过函数导出按环境合并:// rspack.config.jsmodule.exports = (env) => { const mode = env.mode || 'development'; const envConfig = require(`./rspack.${mode}.js`); return { ...commonConfig, ...envConfig };};// rspack.development.jsmodule.exports = { mode: 'development', devtool: 'eval-cheap-module-source-map', devServer: { hot: true, port: 3000 }};// rspack.production.jsmodule.exports = { mode: 'production', devtool: 'source-map', optimization: { minimize: true }};方案二:条件配置在同一配置文件中根据环境变量条件切换:module.exports = (env) => { const isProduction = env.mode === 'production'; return { mode: isProduction ? 'production' : 'development', plugins: [ new DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(env.mode), 'process.env.IS_PRODUCTION': JSON.stringify(isProduction) }), ...isProduction ? [ new TerserPlugin(), new CompressionPlugin() ] : [] ] };};环境变量的安全实践敏感信息处理// 错误:硬编码密钥const API_KEY = 'sk-xxxxx';// 正确:从环境变量读取const API_KEY = process.env.API_KEY;敏感信息存放在 .env.local 或 CI/CD 的 secrets 中,不提交到版本控制。必需变量验证const requiredVars = ['API_URL', 'API_KEY'];requiredVars.forEach(key => { if (!process.env[key]) { throw new Error(`Missing required environment variable: ${key}`); }});默认值设置const apiUrl = process.env.API_URL || 'http://localhost:3000';const timeout = parseInt(process.env.TIMEOUT || '5000', 10);const debug = process.env.DEBUG === 'true';CI/CD 中的环境变量GitHub Actionsjobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm install - run: npm run build env: NODE_ENV: production API_URL: ${{ secrets.API_URL }}DockerFROM node:20-alpineWORKDIR /appCOPY package*.json ./RUN npm installCOPY . .ARG NODE_ENV=productionARG API_URLENV NODE_ENV=$NODE_ENVENV API_URL=$API_URLRUN npm run buildRspack 性能分析从 Rspack 1.4 开始,通过 RSPACK_PROFILE 环境变量开启构建性能追踪:RSPACK_PROFILE=true npx rspack buildRspack 的环境变量管理覆盖了从开发调试到生产部署的完整链路:DefinePlugin 和 EnvironmentPlugin 提供底层注入能力,Rsbuild 的 source.define 和自动 .env 加载简化日常使用,import.meta.env 系列变量开箱即用,配合 TypeScript 类型声明和条件编译可以构建类型安全、产物精简的前端项目。
前端阅读 05月27日 16:54

Rspack Dev Server 如何配置和使用?

Rspack Dev Server 为本地开发提供热更新、代理、HTTPS 等能力,是日常开发的核心工具。Rspack 2.0 对 Dev Server 做了较大重构:底层从 Express 切换到 connect-next,@rspack/cli 不再自动依赖 @rspack/dev-server,需要手动安装。本文基于 Rspack 2.0,系统讲解 Dev Server 的配置和使用。安装与启动Rspack 2.0 起,@rspack/dev-server 是可选依赖,需手动安装:npm add @rspack/dev-server -D启动开发服务器有两种方式:# 方式一:rspack dev(推荐)npx rspack dev# 方式二:rspack serve(兼容写法)npx rspack serve指定配置文件或端口:npx rspack dev --config rspack.config.js --port 8080在配置文件中声明 Dev Server 选项:// rspack.config.jsmodule.exports = { mode: 'development', devServer: { static: { directory: path.join(__dirname, 'public'), }, compress: true, port: 9000, },};核心功能模块热更新(HMR)HMR 默认在 development 模式下启用,修改代码后页面局部刷新而不需要整页重载:module.exports = { devServer: { hot: true, // 启用 HMR(默认开启) liveReload: false, // 禁用整页自动刷新 },};注意:当 output.cssFilename 包含 [hash] 或 [contenthash] 时,CSS 的 HMR 不会生效。静态文件服务Dev Server 可以为静态资源提供文件服务:module.exports = { devServer: { static: { directory: path.join(__dirname, 'public'), publicPath: '/', serveIndex: true, watch: true, }, },};watch: true 会在静态文件变化时自动刷新页面,serveIndex: true 则允许浏览目录列表。代理配置开发环境常需解决跨域问题,Dev Server 内置了基于 http-proxy-middleware 的代理:module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, pathRewrite: { '^/api': '' }, }, }, },};也支持数组格式,适合更复杂的匹配场景:module.exports = { devServer: { proxy: [ { context: ['/api', '/graphql'], target: 'http://localhost:3000', changeOrigin: true, }, ], },};HTTPS 支持本地开发需要 HTTPS 时(如测试 Service Worker、Secure Cookie 等):const fs = require('fs');module.exports = { devServer: { server: { type: 'https', options: { key: fs.readFileSync('path/to/private.key'), cert: fs.readFileSync('path/to/certificate.pem'), }, }, },};高级配置错误覆盖与客户端日志Dev Server 可以在浏览器中实时显示编译错误,辅助快速定位问题:module.exports = { devServer: { client: { overlay: { errors: true, warnings: false, }, logging: 'warn', }, },};historyApiFallbackSPA 应用需要将所有路由回退到 index.html,避免刷新页面时 404:module.exports = { devServer: { historyApiFallback: { index: '/index.html', rewrites: [ { from: /^\/api/, to: '/404.html' }, ], }, },};文件监听当需要监听源码之外的文件变化(如 PHP 模板、公共资源)时:module.exports = { devServer: { watchFiles: { paths: ['src/**/*.php', 'public/**/*'], options: { usePolling: false, interval: 1000, }, }, },};devMiddleware 选项控制构建输出的写入和日志行为:module.exports = { devServer: { devMiddleware: { index: true, writeToDisk: false, stats: 'minimal', }, },};writeToDisk: false 表示构建产物仅存在内存中,加快速度;stats 控制终端日志的详细程度,可选 none、errors-only、minimal、normal、verbose。自定义中间件Rspack 2.0 的 connect-next 适配Rspack 2.0 将底层从 Express 替换为 connect-next,中间件写法需要适配:module.exports = { devServer: { setupMiddlewares: (middlewares, devServer) => { if (!devServer) { throw new Error('devServer is not defined'); } devServer.app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); return middlewares; }, },};如果项目仍依赖 Express 特有 API(如 req.query 的解析行为),可以显式提供 Express 实例:import express from 'express';export default { devServer: { app: async () => express(), },};WebSocket 配置自定义 WebSocket 连接地址和服务端类型:module.exports = { devServer: { client: { webSocketURL: 'auto://0.0.0.0:0/ws', }, webSocketServer: { type: 'ws', options: { host: 'localhost', port: 8080, }, }, },};也支持使用自定义 WebSocket 客户端实现,继承 BaseClient 类即可。压缩与性能启用 gzip 压缩减少传输体积:module.exports = { devServer: { compress: true, },};配合 devMiddleware 的 stats 选项保持终端输出简洁,能显著提升开发体验。从 Rspack 1.x 迁移Rspack 2.0 的 Dev Server 有几项破坏性变更需要注意:必须手动安装 @rspack/dev-server:@rspack/cli 不再自动包含此依赖底层切换为 connect-next:如果使用了 Express 专有 API,需显式提供 app: async () => express()Node.js 版本要求:Rspack 2.0 要求 Node.js 20.19+ 或 22.12+,不再支持 Node 18ESM-only 发布:@rspack/dev-server 已移除 CommonJS 构建,纯 ESM 包最佳实践开发/生产分离:Dev Server 仅用于开发环境,生产部署使用静态文件服务器代理环境变量化:代理目标地址通过环境变量管理,避免硬编码按需监听文件:watchFiles 只监听必要路径,避免不必要的重编译合理配置日志:生产前关闭 overlay,日常开发使用 errors-only 或 minimalHTTPS 按需开启:仅在需要安全上下文(Service Worker、Secure Cookie 等)时配置 HTTPS