阅读视图

发现新文章,点击刷新页面。

一套面向 Web、H5、小程序与 Flutter 的多端一致性技术方案

在很多团队里,多端协作的主要问题是“同一个需求被翻译了多少次”。最典型的场景是:Web 一套实现,Flutter 一套实现,H5 和小程序又各有一套适配逻辑。产品提一个需求,设计讲一遍,前端理解一遍,Flutter 再理解一遍,最后各端虽然都“做出来了”,但视觉、交互、状态处理、权限逻辑、埋点口径往往并不一致。沟通成本高、返工频繁、质量不稳定。

如果团队还希望进一步引入 AI 辅助开发,那么问题会更明显。因为 AI 并不能天然理解团队的设计语言、组件规范、页面模式和业务边界。如果没有一套结构化、可检索、可校验的规范体系,大模型生成的结果往往只能做“演示代码”,无法真正进入工程体系。

因此,真正有效的多端技术方案,不应该只是“做一套 Design Token”,也不应该只是“尝试一套代码通吃所有端”。更合理的思路是: 先统一设计语言,再统一组件协议和页面模式,继续沉淀业务规范,最后把这些规范结构化,供大模型参与生成、校验和辅助开发。 规范不是靠发文件落地的,是靠‘让别人用起来更省事’落地的。

一、问题到底出在哪里

很多团队一提多端一致性,第一反应是颜色、字号、间距不一致。但这其实只是表层问题。真正让协作成本变高的,通常是下面几类问题。

第一类,是需求翻译成本。同一个“企业洞察页”需求,Web 理解为信息卡 + 图表 + 推荐列表,Flutter 可能理解为信息页 + Tab + 卡片流,结果做出来像两个产品。

第二类,是组件行为不一致。同样一个按钮,Web 支持 loading 态并禁止重复点击,Flutter 可能只有 disabled;同样一个空态页,Web 有引导操作,Flutter 只有一段提示文案。

第三类,是状态模型不一致。正常态大家都能做,但 loading、empty、error、no-permission、offline、partial-error 这些状态,经常每个端各自发挥。

第四类,是数据与规则不一致。接口字段解释不同,权限判断方式不同,埋点参数命名不同,最后统计口径都不一样。

第五类,是AI 无法真正接入工程体系。设计规范写在 PPT 里,组件约定写在 Confluence 里,业务规则散落在需求文档里,大模型拿不到稳定的 source of truth,自然无法参与受控生成。

所以,多端一致性不是一个“视觉层优化”问题,而是一个从需求到实现的翻译层重构问题

二、方案总览:统一语义,而不是强行统一实现

这套方案的核心原则很简单:

统一语义,不强行统一实现。

Web、H5、小程序、Flutter 的渲染机制、组件生态和交互能力都不一样,硬要一套代码跑所有端,通常只会让所有端都不舒服。真正应该统一的,是下面这些层:

  1. Design Token:统一设计语言
  2. 组件 Contract:统一组件语义和行为
  3. 状态矩阵与页面 Pattern:统一交互模式
  4. 业务 Spec / Contract / Schema:统一需求表达
  5. 工程化与校验链路:统一交付方式
  6. Agent 接入层:统一 AI 辅助方式

可以把整个方案理解成这样一条链路:

设计源头 → Token → 组件协议 → 页面模式 → 业务规范 → 结构化规则 → 多端实现 → AI 生成与校验

这不是单点优化,而是一套可以逐步演进的前端基础设施。

三、Design Token 是基础,但不能停在这里

Design Token 解决的是“设计值如何被标准化和跨端传递”的问题。它是多端一致性的起点,但绝不是终点。

在实现上,我推荐把 Token 分成三层:

1. Primitive Token:原始值层

这是最底层的设计原材料,比如:

{
  "color": {
    "blue": { "500": "#2F6BFF" },
    "gray": { "900": "#111827" }
  },
  "space": {
    "8": "8px",
    "16": "16px"
  },
  "radius": {
    "4": "4px"
  }
}

这一层回答的是“具体数值是多少”,适合设计系统维护者和构建脚本使用,不应该直接暴露给业务页面。

2. Semantic Token:语义层

语义层把原始值转成产品语言,例如:

{
  "color-text-primary": "{color.gray.900}",
  "color-bg-surface-card": "{color.white}",
  "space-page-section-gap": "{space.16}"
}

这一层回答的是“这个值在界面里扮演什么角色”。它是跨端最值得统一的一层,也是页面开发默认应该使用的一层。

3. Component Token:组件层

组件层是组件内部的状态和部位规则,例如:

{
  "button-primary-bg-default": "{color-bg-brand-primary}",
  "button-primary-bg-hover": "{color.blue.600}",
  "input-border-focus": "{color-border-focus}"
}

这一层回答的是“某个组件在某种状态下应该怎么表现”,适合组件库内部实现,不建议业务页面直接使用。

为什么要这么分层

因为业务页面真正关心的是“主文本”“卡片背景”“页面间距”,而不是 blue-500button-primary-bg-hover 这种底层细节。

所以一个健康的约束应该是:

  • 定义基础值,用 Primitive
  • 写页面布局,用 Semantic
  • 做组件实现,用 Component

对于多端来说,最应该统一的是 Semantic Token 命名和语义,而不是强求每个平台完全共享底层实现。Web 可以映射到 CSS Variables,Flutter 可以映射到 ThemeData,小程序可以做裁剪版映射。统一的是产品语言,不是渲染机制。

四、比 Token 更重要的是组件 Contract

如果说 Token 解决的是“长得像不像”,那么 Component Contract 解决的是“行为是不是同一套”。

这里的 contract,是组件层面的“协议”或“契约”。它定义的是一个组件的输入、输出、状态、行为和边界。

以 Button 为例,一个合格的 Button contract 至少应该包含这些内容:

  • 支持哪些 variant:primary / secondary / text
  • 支持哪些尺寸:sm / md / lg
  • 支持哪些状态:default / loading / disabled
  • loading 态是否禁止再次点击
  • icon 支持哪些位置
  • 文案最大长度建议
  • 埋点何时触发
  • 无障碍要求是什么

它可以写成这样的结构:

{
  "component": "Button",
  "variants": ["primary", "secondary", "text"],
  "sizes": ["sm", "md", "lg"],
  "states": {
    "loading": { "disableClick": true },
    "disabled": { "emitClick": false }
  },
  "iconPlacement": ["left", "right"],
  "a11y": {
    "requireLabel": true
  }
}

一旦这份 contract 稳定下来,Web 和 Flutter 都可以按同一套语义实现,而不是各写各的。

这一步非常关键。因为很多团队做了 Token,却没有做 Contract,结果所有端看起来像一家人,但行为逻辑还是各自为战。

五、状态矩阵与页面 Pattern 才是多端协作的真正降本点

大部分返工,并不是因为某个颜色错了,而是因为状态和页面结构没有统一

1. 状态矩阵

在 AI 产品和复杂 B 端产品里,页面状态往往远不止一个“加载中”。一个成熟的状态模型,至少要覆盖:

  • loading
  • refreshing
  • empty
  • partial-error
  • full-error
  • no-permission
  • offline
  • generating
  • interrupted

状态矩阵不是为了写文档,而是为了让所有端都知道:同一个页面在不同状态下应该展示什么、隐藏什么、保留什么交互、是否允许重试。

2. 页面 Pattern

很多需求其实不是全新页面,而是“列表页”“详情页”“洞察页”“趋势页”“报告编辑页”“智能体配置页”的某种变体。

所以与其每次从零设计,不如沉淀页面 pattern library。每个 pattern 里定义:

  • 页面区块组成
  • 信息优先级
  • 推荐组件组合
  • 常见交互
  • 状态切换方式
  • 多端适配建议

例如“企业洞察页”可以规定:

  • 顶部:企业基础信息卡
  • 中部:趋势图 + 风险摘要
  • 底部:相关推荐
  • 高风险摘要必须带引用来源
  • 导出按钮仅分析师可见
  • loading 时骨架屏优先展示顶部和中部关键区域

一旦 pattern 稳定,同一个需求在 Web 和 Flutter 上的“翻译成本”就会下降很多。

六、把需求变成结构化规范,而不是散落的文档

到这里,多端协作已经不只是 UI 层问题了,必须往更高层抽象。

这里最容易混淆的三个概念是:spec、contract、schema

1. Spec:整体规格说明

Spec 关注的是“这个需求整体要做成什么样”。它通常包括:

  • 页面目标
  • 页面结构
  • 交互流程
  • 状态处理
  • 权限规则
  • 埋点要求
  • 验收标准

Spec 是完整说明书,适合给产品、设计、前后端和测试一起看。

2. Contract:边界约定

Contract 关注的是“边界两边如何对接”。它包括:

  • API contract
  • 组件 contract
  • 事件埋点 contract
  • 页面区块 contract

它强调的是输入、输出、状态、行为和边界。

3. Schema:数据结构定义

Schema 关注的是“数据长什么样”,比如:

  • 字段名
  • 字段类型
  • 必填项
  • 枚举值
  • 嵌套关系

例如:

{
  "companyName": "string",
  "riskLevel": "low | medium | high",
  "canExport": "boolean",
  "tags": ["string"]
}

在工程里,可以粗略理解为:

Schema 是结构,Contract 是约定,Spec 是全局规则。

三者一起使用,才能真正把需求从“会讨论”变成“可执行”。

七、工程体系的重点不是复用代码,而是复用定义

到了工程层,很多团队会陷入一个误区:一说多端,就想“一套代码跑全部端”。

实际上,对 Web 和 Flutter 这样的异构平台来说,更现实的目标不是复用所有代码,而是复用定义、约束和生成链路

一个比较健康的工程目录,可以是这样:

design-system/
  tokens/
    primitive.json
    semantic.json
    component.json
  components/
    button.contract.json
    card.contract.json
    input.contract.json
  patterns/
    insight-page.spec.md
    report-editor.spec.md
  business-rules/
    permission.rules.json
    tracking.rules.json
  platform-mappings/
    web/
      tokens.css
    flutter/
      theme_mapping.dart
    mini-program/
      token_mapping.json

在这套结构里,Web 和 Flutter 不一定共享组件代码,但它们共享:

  • 设计语言
  • 组件协议
  • 页面模式
  • 业务规则
  • 类型定义
  • 校验标准

这样真正减少的,不是“写代码的次数”,而是“需求被重复翻译的次数”。

八、最后才是把规范结构化,供大模型参与生成和校验

很多团队在引入 AI 时,最容易犯的错误是:一上来就希望大模型“自动生成页面”。但如果前面的规范体系还没建立好,这种生成只能停留在 demo 层面。

更现实的路径应该是:

第一步,让 Agent 先读规范,而不是先写代码

Agent 不应该靠一大段 prompt 去猜团队规范,而应该按需读取:

  • Semantic Token
  • Component Contract
  • Page Pattern
  • Business Rules
  • Schema

第二步,让 Agent 先做受控生成

最适合 AI 先接入的场景包括:

  • 页面骨架生成
  • 表单 / 列表 / 详情区 schema 驱动生成
  • TS types / Dart models 自动生成
  • 埋点 / 权限 / 状态处理检查
  • 组件使用规范检查

第三步,再做确定性校验

大模型适合生成,但最终质量不能靠模型“自觉”。要把关键规则做成 validator:

  • 是否用了非法 token
  • 是否绕过组件库直接写样式
  • 是否缺少 loading / empty / error
  • 是否遗漏权限态
  • 是否漏了埋点
  • 是否违反页面 pattern

所以 AI 在这套体系里的位置,不是“替代开发”,而是:

基于结构化规范做受控生成,再基于确定性规则做质量校验。

九、落地路径:不要一上来就做大一统

这套方案看起来大,但完全可以分阶段推进。

第一阶段:先统一最基础的三件事

  • 基础 Design Token
  • 高复用组件 Contract
  • 接口模型与类型自动生成

这一阶段的目标不是“多先进”,而是先把最容易反复沟通的部分统一起来。

第二阶段:沉淀页面 Pattern 和状态矩阵

挑选高频页面类型,比如列表页、详情页、洞察页、配置页,把交互模式和状态处理收敛起来。

第三阶段:开始结构化业务规范

把 Spec、Contract、Schema 分层管理,逐步形成 source of truth。

第四阶段:引入 Agent 和 Validator

先让 AI 参与骨架生成和规则检查,再逐步扩大到辅助开发。

这条路径的好处是:每一步都能独立产生价值,而不是必须一次性完成一个庞大的“平台化工程”。

这套方案最终解决了什么

如果这套体系跑起来,真正被降低的不是代码行数,而是以下几类成本:

  • 同一个需求在多端之间的翻译成本
  • 视觉、行为、状态不一致带来的返工成本
  • 接口、权限、埋点理解不一致的沟通成本
  • 新人接手复杂项目时的理解成本
  • AI 生成无法进入正式工程体系的失控成本

换句话说,它把“多端协作”从一种靠人力补位的模式,变成一种靠规范驱动、工程约束和 AI 辅助的模式。

为什么全国人民都能秒开同一个视频?

为什么你在北京打开一个视频,加载只要1秒?你的朋友在新疆,打开同一个视频,也是1秒?服务器难道全国各地都放了?

今天,用**"快递"**的故事,来讲讲CDN的原理。


原文地址

墨渊书肆/为什么全国人民都能秒开同一个视频?


没有CDN时,网络请求是怎么跑的?

你在北京,想从上海寄一箱苹果。

没有CDN的情况下:

你(北京)→ 上海工厂 → 快递翻山越岭 → 你收到苹果

耗时:3-5天。

网络请求同理:

你在北京 → 请求到上海服务器 → 上海服务器返回资源 → 你收到响应

耗时:100-300ms(物理距离决定)。

如果服务器在上海,你在新疆,延迟可能高达500ms。

更严重的是——100万人同时看这个视频,上海服务器直接崩溃


CDN是怎么解决这个问题的?

CDN的核心思想:把内容复制到离用户最近的地方

快递公司在全国建了很多仓库:

你在北京 → 北京仓库有货 → 当天到达

网络请求同理:

你在北京 → 北京CDN节点有缓存 → 10ms到达

这就是CDN(Content Delivery Network,内容分发网络)的核心:就近访问


CDN的工作原理

1. DNS智能解析

当你输入网址,DNS会解析到离你最近的CDN节点:

# 传统DNS(固定IP)
你(北京)→ dns.example.com  123.125.115.110(上海源站)

# CDN智能DNS
你(北京)→ dns.example.com  GSLB判断位置  返回1.2.3.4(北京节点)
你(新疆)→ dns.example.com  GSLB判断位置  返回5.6.7.8(新疆节点)

GSLB(Global Server Load Balance,全局负载均衡)根据地理位置返回最近节点IP。

2. 边缘节点就近响应

CDN节点称为PoP(Point of Presence,边缘节点),分布在全国各地:

┌─────────────────────────────────────────────────────────────┐
                      CDN全国节点分布                          
                                                              
   东北区PoP        华北区PoP         华东区PoP              
   (沈阳)           (北京)             (上海)                  
                                                              
   西南区PoP        华中区PoP         华南区PoP              
   (成都)           (武汉)             (广州)                  
                                                              
                    ┌─────────────────┐                       
                        源站服务器                            
                       (上海自建)                          
                    └─────────────────┘                       
└─────────────────────────────────────────────────────────────┘

3. 缓存命中与回源

CDN节点会缓存源站内容:

缓存未命中:
用户  CDN节点  源站服务器  返回内容并缓存

缓存命中:
用户  CDN节点  (直接返回缓存,不回源)

深入了解CDN 🔬

CDN缓存策略

CDN通过HTTP响应头控制缓存行为:

# 缓存有效期(秒)
Cache-Control: public, max-age=86400

# 不缓存(直接回源)
Cache-Control: no-cache, no-store
响应头 作用
Expires 过期时间点(绝对时间)
Cache-Control: max-age 缓存有效期(相对时间)
Cache-Control: private 仅浏览器可缓存,CDN不可缓存
Cache-Control: no-store 禁止缓存

缓存失效机制

源站内容更新后,CDN可能仍返回旧缓存。解决方案:

方案 原理 适用场景
缓存预热 发布前主动推送新内容到CDN节点 大促、热更新
缓存刷新 手动删除指定URL的缓存 紧急更新
版本化URL URL携带hash如app.js?v=2.1.0 静态资源(推荐)
短TTL 牺牲性能换取新鲜度 频繁更新

CDN判断缓存状态

CDN返回时会携带自定义头,标识缓存命中状态:

X-Cache: HIT    # 命中缓存
X-Cache: MISS   # 未命中,回源

X-Cache-Status: HIT
X-Cache-Status: REVALIDATED  # 缓存过期但内容未变,验证通过

Anycast与DNS劫持

大型CDN使用Anycast技术:多个节点共享同一IP,路由器自动把请求路由到最近节点。

# Anycast示意
北京节点、上海节点、广州节点  都使用IP 1.2.3.4
用户请求  路由器自动选择物理距离最近的节点

国内CDN的特殊性

由于国内ICP备案制度,CDN需要域名已备案才能接入。正规CDN服务商会对域名备案状态进行校验。


为什么CDN能"秒开"?

1. 就近访问——物理距离近

访问路径 单程延迟 往返延迟(RTT)
北京 → 上海(无CDN) 100-150ms 200-300ms
北京 → 北京CDN节点 1-5ms 2-10ms

快了20-100倍。

2. 骨干网络直连

CDN服务商自建骨干网络,像高铁专线:

# 公网路由(多跳)
北京  西安中转  成都中转  上海服务器
(每跳增加10-50ms延迟)

# CDN骨干网络
北京PoP ←→ 上海PoP ←→ 源站服务器
(少跳数、低延迟)

3. 边缘计算能力

现代CDN不只是缓存,还能做边缘计算:

CDN边缘节点能力:
├── 静态资源缓存
├── 动态请求加速(路由优化)
├── TLS/SSL终止(加解密在边缘完成)
├── 图片压缩/格式转换(WebP/AVIF)
├── A/B测试分流
├── 防DDoS攻击
└── Edge Functions(边缘函数)

CDN服务架构

┌─────────────────────────────────────────────────────────────┐
                         用户请求                              
└─────────────────────────┬───────────────────────────────────┘
                          
                          
┌─────────────────────────────────────────────────────────────┐
                      DNS智能解析                              
               (GeoDNS:根据地理位置返回节点)                 
└─────────────────────────┬───────────────────────────────────┘
                          
                          
┌─────────────────────────────────────────────────────────────┐
                    GSLB全局负载均衡器                        
                     (健康检查 + 就近调度)                   
└───────┬─────────┬─────────┬─────────┬─────────┬───────────┘
                                            
                                            
   ┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐
   │北京PoP ││上海PoP ││广州PoP ││成都PoP ││新疆PoP 
   └────┬───┘└────┬───┘└────┬───┘└────┬───┘└────┬───┘
                                            
        └─────────┴────┬────┴─────────┴─────────┘
                        缓存未命中时回源
                       
              ┌─────────────────┐
                  源站服务器     
                 (上海自建)   
              └─────────────────┘

CDN服务商选择

服务商 特点 适用场景
Cloudflare 免费额度大,全球Anycast 个人博客、中小型网站
Akamai 全球最大,节点最多 大型企业、国际化
阿里云CDN 国内节点密集 国内业务
腾讯云CDN 与微信生态集成 游戏、直播
AWS CloudFront 与AWS生态深度集成 AWS用户
自建CDN 完全可控 超大型企业

选型考量

因素 说明
节点分布 用户群体地理位置
价格模型 按流量、按带宽、按请求数
缓存命中率 命中率越高,源站压力越小
HTTPS支持 免费证书、自动续期
附加功能 防DDoS、边缘计算、HTTP/3支持

CDN的常见问题

1. 缓存生效延迟

更新网站内容后,CDN仍在返回旧缓存:

你更新了CSS → CDN节点仍有旧版本 → 用户看到错位页面

解决方案:使用版本化URL(style.css?v=2.0.0)、发布后手动刷新缓存。

2. 缓存穿透

恶意请求大量不存在URL,直接打到源站:

攻击者 → 随机URL → CDN无缓存 → 回源 → 源站崩溃

解决方案:CDN安全配置、源站防护、使用BloomFilter判断存在性。

3. HTTPS证书管理

CDN节点需配置SSL证书,更新时需同步到所有节点:

证书过期 → TLS握手失败 → 用户无法访问

解决方案:使用CDN自带免费证书、配置自动续期、监控证书状态。


总结:CDN核心知识点

概念 说明
PoP/边缘节点 离用户最近的CDN服务器
回源 缓存未命中时,向源站请求内容
GSLB 全局负载均衡,根据位置返回最近节点
GeoDNS 根据用户地理位置返回不同解析结果
缓存命中率 缓存命中请求占总请求的比例,越高越好
Anycast 多节点共享IP,路由自动选最近节点

写在最后

现在应该明白了:

  • CDN = 在全国各地建仓库,就近发货
  • PoP/边缘节点 = 离你最近的仓库
  • 回源 = 仓库没货,去工厂拿
  • GSLB = 智能调度,看你在哪就分配哪个仓库
  • 秒开 = 物理距离近 + 骨干网络 + 缓存复用

下次视频加载飞快,记得——背后是数千个CDN节点在为你"跑腿"。

技术不复杂,但让"全国人民秒开同一个视频"成为可能。

用AI全自动生成连环画?我试了,效果惊艳!

全程只需自然语言对话,从分镜脚本到成品图,一条龙搞定。


前言:为什么我折腾这个?

我一直想把女儿和她喜欢的卡通人物生成连环画的想法,现在AI已经这么发达了,能不能有简单、免费的方法用AI自动生成一套完整的连环画?

不是那种随便生成几张图,而是有完整故事线、人物设定统一、分镜连贯的真正意义上的漫画

最近我用 Claude Code + baoyu-skills + 阿里百炼 实现了这个想法,今天把完整流程分享出来。

1.jpg

02-page1.png

03-page2.png

04-page3.png

05-page4.png

06-page5.png

07-page6.png

08-page7.png

09-page8.png


第一步:安装 AI 绘画技能包

打开 Claude Code,我只说了一句话:

请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills

Claude Code 自动完成了:

  • 克隆仓库
  • 安装依赖
  • 配置技能

里面包含 baoyu-comic(连环画生成)、baoyu-imagine(AI生图)等20+技能。


第二步:给一个参考图,让AI记住主角长相

我上传了一张小朋友的照片作为参考:

这是我家小朋友葵宝,请记住她的样子

Claude Code 生成了详细的角色设定描述

3-5岁中国小女孩,双丸子头配粉色绒球装饰,穿着红色中式棉服,白毛领,圆脸蛋,大眼睛,呆萌可爱

这样后面每一页的人物都能保持一致。


第三步:说个故事主题,AI自动生成分镜脚本

我只给了一个标题:

葵宝和Hello Kitty的超市奇遇

Claude Code 自动生成了8页完整分镜脚本

页码 场景 核心情节
封面 超市门口 葵宝和Hello Kitty的超市奇遇
第1页 出发去超市 蝴蝶结发光,冒险开始
第2页 进入迷你国 货架变巨大,商品会动
第3页 饼干桥 踩着巧克力豆过河
第4页 酸奶河 用净水手电筒找种子
第5页 果冻迷宫 留猫爪印记找出口
第6页 云端货架 光束托下奶油帽子
第7页 盛大派对 感谢派对,吃蛋糕
第8页 回到妈妈身边 蝴蝶结多了枚勋章

每一页都有2x2四格布局详细的画面描述文字气泡


第四步:自动生成生图提示词

针对每个分镜,Claude Code 自动生成了结构化提示词

四格漫画《饼干桥》kawaii可爱风格,带中文文字气泡

角色设定:
- 葵宝:3-5岁中国小女孩,双丸子头配粉色绒球,红色中式棉服
- Hello Kitty:白色小猫,蝴蝶结发光束

画面布局(2x2网格):
左上格:葵宝拍胸脯,Hello Kitty点头...
右上格:巨大的饼干桥,桥下巧克力河...
...

提示词包含:

  • 画风要求(kawaii日漫可爱风格)
  • 角色一致性描述
  • 每格的画面内容
  • 文字气泡内容
  • 比例要求(3:4竖版)

第五步:调用AI生图API

这里Claude code会自动使用阿里百炼的免费额度(通义万象 qwen-image-2.0-pro),Claude Code 批量生成了9张图:

npx bun .agents/skills/baoyu-imagine/scripts/main.ts \
  --promptfile prompts/01-cover.md \
  --image 01-cover.png \
  --provider dashscope \
  --size 1152x1536

每张图大约30秒生成,全程自动化。


整个流程耗时多久?

步骤 耗时
安装技能 2分钟
上传参考图 1分钟
生成脚本 3分钟(AI自动)
生成提示词 5分钟(AI自动)
批量生图 约15分钟(9张图)

总计:约25分钟,从零到一套完整连环画。


技术要点总结

  1. 角色一致性:通过详细的文字描述 + 参考图保持人物统一
  2. 分镜脚本:让AI先生成完整故事结构,再细化每一页
  3. 提示词模板:使用固定格式确保风格一致
  4. 批量生成:用脚本自动化调用API

局限性

  • AI生成的中文文字有时会乱码,建议后期用PS加字
  • 复杂场景的人物一致性仍需微调
  • 免费API有额度限制

写在最后

以前做一套连环画,从构思、分镜、线稿、上色,少说也要几周。

现在用AI辅助,20来分钟搞定

AI不会取代创作者,但会让创作变得更简单、更高效。

技术的意义,就是让更多人能够表达自己的故事。


如果这篇对你有帮助,点个赞吧! 👍

有问题欢迎评论区交流~


相关链接


ES6 Class 渐进式详解

ES6 Class 渐进式详解

ES6 引入的 class 是 JavaScript 面向对象编程的语法糖,它让原型继承的写法更清晰、更接近传统面向对象语言(如 Java、C++)的风格。本文将从基础到进阶,配合例子帮你彻底理解。

1. 为什么需要 ES6 Class?

在 ES6 之前,我们是用构造函数 + 原型链实现面向对象的,写法比较繁琐:

javascript

运行

// ES5 构造函数写法
function Person(name) {
  this.name = name; // 实例属性
}

// 原型方法(共享给所有实例)
Person.prototype.sayHi = function() {
  console.log(`你好,我是${this.name}`);
};

// 实例化
const alice = new Person('Alice');
alice.sayHi(); // 输出:你好,我是Alice

ES6 的 class 把这些逻辑封装得更简洁,本质上还是基于原型链,但可读性大幅提升。

2. 基本语法:定义一个类

class 关键字定义类,类名通常首字母大写:

javascript

运行

// 定义 Person 类
class Person {
  // 类的主体
}

// 实例化(和构造函数一样,用 new)
const alice = new Person();

这是最简单的类,但还没有属性和方法,接下来我们逐步添加。

3. 构造函数:constructor

constructor 是类的默认方法,当你 new 一个实例时,会自动调用它,用来初始化实例属性

javascript

运行

class Person {
  // 构造函数,接收参数
  constructor(name, age) {
    // this 指向当前实例
    this.name = name; // 实例属性:姓名
    this.age = age;   // 实例属性:年龄
  }
}

// 实例化时传参
const alice = new Person('Alice', 25);
console.log(alice.name); // 输出:Alice
console.log(alice.age);  // 输出:25
  • 如果不写 constructor,类会自动生成一个空的 constructor()
  • this 关键字在类中指向当前实例对象

4. 实例方法:类的行为

在类中定义方法,不需要 function 关键字,直接写方法名即可。这些方法会被添加到类的原型上,所有实例共享:

javascript

运行

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  // 实例方法:打招呼
  sayHi() {
    console.log(`你好,我是${this.name},今年${this.age}岁`);
  }

  // 实例方法:过生日
  birthday() {
    this.age++; // 修改实例属性
    console.log(`${this.name}过生日了,现在${this.age}岁`);
  }
}

const alice = new Person('Alice', 25);
alice.sayHi();    // 输出:你好,我是Alice,今年25岁
alice.birthday(); // 输出:Alice过生日了,现在26岁

5. 静态成员:类本身的属性 / 方法

static 关键字定义静态属性静态方法,它们属于类本身,而不是实例,需要通过类名调用:

javascript

运行

class Person {
  // 静态属性(ES2022 支持,旧版本需用 Person.type = '人类' 定义)
  static type = '人类';

  constructor(name) {
    this.name = name;
  }

  // 静态方法:判断是否是 Person 的实例
  static isPerson(obj) {
    return obj instanceof Person;
  }
}

// 调用静态成员(通过类名)
console.log(Person.type);        // 输出:人类
const alice = new Person('Alice');
console.log(Person.isPerson(alice)); // 输出:true

// 错误:实例无法调用静态成员
// alice.type;   // undefined
// alice.isPerson(); // 报错
  • 静态方法中的 this 指向类本身,而不是实例。

6. 继承:extendssuper

extends 关键字让一个类继承另一个类,子类会拥有父类的所有属性和方法,还可以扩展自己的内容:

javascript

运行

// 父类(基类)
class Person {
  constructor(name) {
    this.name = name;
  }

  sayHi() {
    console.log(`你好,我是${this.name}`);
  }
}

// 子类(派生类):Student 继承 Person
class Student extends Person {
  constructor(name, studentId) {
    // 必须先调用 super(),它会执行父类的 constructor
    super(name); 
    this.studentId = studentId; // 子类自己的属性
  }

  // 子类自己的方法
  study() {
    console.log(`${this.name}(学号:${this.studentId})正在学习`);
  }

  // 重写父类方法(覆盖)
  sayHi() {
    // 可以通过 super 调用父类的方法
    super.sayHi(); 
    console.log(`我是一名学生,学号是${this.studentId}`);
  }
}

// 实例化子类
const bob = new Student('Bob', '2024001');
bob.sayHi();   // 输出:你好,我是Bob → 我是一名学生,学号是2024001
bob.study();   // 输出:Bob(学号:2024001)正在学习

关键点:

  1. 子类的 constructor 必须先调用 super(),否则会报错(因为子类的 this 是基于父类构建的)。
  2. 子类可以重写父类的方法(如 sayHi),也可以通过 super.方法名() 调用父类的原方法。

7. Getter 和 Setter:控制属性访问

getset 关键字定义属性访问器,可以在读取或设置属性时添加逻辑(如验证、计算):

javascript

运行

class Person {
  constructor(name) {
    this._name = name; // 用下划线表示“私有属性”(约定俗成,不是真私有)
  }

  // getter:读取 name 属性时触发
  get name() {
    return this._name.toUpperCase(); // 自动转大写
  }

  // setter:设置 name 属性时触发
  set name(value) {
    if (value.length < 2) {
      console.log('名字太短了!');
      return;
    }
    this._name = value;
  }
}

const alice = new Person('Alice');
console.log(alice.name); // 输出:ALICE(触发 getter)

alice.name = 'Bob';      // 触发 setter
console.log(alice.name); // 输出:BOB

alice.name = 'A';        // 输出:名字太短了!(setter 验证失败)
  • 注意:getset 是方法,但调用时不需要加括号,像普通属性一样访问即可。
  • 这里的 _name 是 “伪私有”,外部依然可以直接访问 alice._name(ES2022 支持真私有属性 #name,但兼容性需注意)。

8. 类的本质:语法糖

虽然 class 看起来像新东西,但本质上还是函数 + 原型链,我们可以验证一下:

javascript

运行

class Person {}

// 1. 类的类型是 function
console.log(typeof Person); // 输出:function

// 2. 类有 prototype 属性(和构造函数一样)
console.log(Person.prototype); // 输出:{ constructor: Person }

// 3. 实例的 __proto__ 指向类的 prototype
const alice = new Person();
console.log(alice.__proto__ === Person.prototype); // 输出:true

所以 class 只是让原型继承的写法更优雅,底层逻辑和 ES5 是一致的。

总结

  • class 是 ES6 的语法糖,简化了面向对象编程。
  • constructor 用于初始化实例属性,new 时自动调用。
  • 实例方法定义在原型上,静态方法用 static 定义,属于类本身。
  • 继承用 extends,子类 constructor 必须先调用 super()
  • get/set 用于控制属性的读写逻辑。

原型链大白话详解

原型链大白话详解(含闭环流程图)

原型链就是 JS 实现面向对象继承的核心机制。我会用最通俗的方式拆解,全程无晦涩术语,配合可直接运行的代码,最后给你讲透闭环的形成逻辑 + 完整流程图。


一、先搞懂 2 个核心概念(90% 的人学不懂都是因为搞混了这俩)

先给两个概念做个大白话翻译,再配合例子验证,保证你一次记住:

概念 大白话定义 谁才有? 核心作用
prototype(原型对象) 函数自带的公共仓库,一个对象,存着所有实例能共享的属性 / 方法 只有函数(尤其是构造函数)才有 给 new 出来的所有实例提供共享资源,实现属性 / 方法复用
__proto__(原型指针) 每个对象自带的找爹指针,指向「创造这个对象的构造函数」的prototype 所有对象(实例、函数、原型对象本身都算对象)都有 当对象找不到属性 / 方法时,顺着这个指针往上找,形成原型链的核心

举个最直观的例子(和你之前学的 class 完全对应)

    // 1. 定义构造函数(等价于class Person)
    function Person(name) {
      this.name = name; // 实例自身的属性
    }
    
    // 2. 给「公共仓库prototype」加共享方法
    Person.prototype.sayHi = function() {
      console.log(`我是${this.name}`);
    };
    
    // 3. new一个实例alice
    const alice = new Person('Alice');
    
    // 👇 核心验证(复制到浏览器控制台就能看到结果)
    // 实例alice的「找爹指针__proto__」,指向构造函数Person的「公共仓库prototype」
    console.log(alice.__proto__ === Person.prototype); // true
    
    // alice自己没有sayHi方法,但能正常执行:顺着__proto__找到了仓库里的方法
    alice.sayHi(); // 输出:我是Alice

二、原型链到底是什么?

大白话类比(家谱逻辑)

你可以把原型链理解成 「找东西的家谱路径」

  1. 你要找一个东西,先在自己家里找(当前对象自身);
  2. 自己家没有,就顺着「找爹指针」去你爸的仓库(爹的 prototype)找;
  3. 你爸的仓库也没有,就去你爷爷的仓库找;
  4. 一直往上找,直到找到老祖宗的仓库,老祖宗的仓库也没有,就返回undefined
  5. 这条从你到祖宗的查找路径,就是原型链

代码验证基础原型链

接上面的例子,我们顺着指针往上找,看完整的基础链条:

    // 1. 第一层:alice自己 → 找不到就去alice.__proto__
    // 2. 第二层:alice.__proto__ = Person.prototype → 找不到继续往上
    // 3. 第三层:Person.prototype也是个对象,它的__proto__指向谁?
    console.log(Person.prototype.__proto__ === Object.prototype); // true
    // 4. 第四层:Object.prototype是老祖宗,它的__proto__指向谁?
    console.log(Object.prototype.__proto__ === null); // true

所以基础的原型链完整路径是:

alice(实例)Person.prototypeObject.prototypenull

划重点:null是原型链的终点,就是为了避免无限循环查找。


三、原型链的闭环是怎么形成的?

很多人搞不懂的「闭环」,核心是 JS 里 FunctionObject的特殊关系 ,也是 JS「一切皆对象」的底层逻辑。

先记住 2 个黄金规则(闭环的核心)

  1. 所有函数,都是Function构造函数的实例(包括PersonObject、甚至Function自己)
  2. 所有对象,都是Object构造函数的实例(包括函数的prototypeFunction.prototype

一步步拆解闭环的形成

我们还是用代码验证,每一步都有结果,你可以直接复制运行:

1. 所有构造函数,都是Function的实例
    // Person是函数,所以是Function的实例
    console.log(Person.__proto__ === Function.prototype); // true
    
    // 连Object构造函数本身,也是函数,所以也是Function的实例
    console.log(Object.__proto__ === Function.prototype); // true
    
    // 甚至Function自己,也是函数,所以自己是自己的实例
    console.log(Function.__proto__ === Function.prototype); // true
2. 所有原型对象,都是Object的实例
    // Function.prototype也是个普通对象,所以是Object的实例
    console.log(Function.prototype.__proto__ === Object.prototype); // true
    
    // 之前的Person.prototype,也是Object的实例
    console.log(Person.prototype.__proto__ === Object.prototype); // true
3. 闭环最终形成,且有终点不会死循环

把上面的关系连起来,你就看到闭环了:

  • Object(构造函数)是Function的实例 → Object.__proto__指向Function.prototype
  • Function.prototypeObject的实例 → Function.prototype.__proto__指向Object.prototype
  • 最终Object.prototype.__proto__指向null,给闭环加了终点,不会无限查找

简单说:Function创造了ObjectObject又反过来成为Function的顶层原型,两者互相指向形成闭环,最终汇合到Object.prototype,再到null结束。


四、完整原型链闭环流程图

下面用流程图把所有关系画出来,一眼看懂整个链条和闭环

image.png

流程图说明

  1. 所有函数(包括 ObjectFoo)的 __proto__ 都指向 Function.prototype:因为函数在 JS 中本质上也是对象,由 Function 构造而来。
  2. Function.__proto__ === Function.prototype:这是原型链上最神奇的一环(闭环),由引擎底层规定:Function 自己也是个函数,属于自身的实例。
  3. Function.prototype.__proto__ === Object.prototype:函数的原型本身是一个普通对象,因此它继承自 Object.prototype
  4. Object.prototype.__proto__ === null:万物归土,它是原型链的最顶端和终点,再往上就是 null

五、一句话总结

原型链就是对象顺着__proto__指针向上查找属性 / 方法的链式路径,而闭环的本质,是 JS 为了实现「一切皆对象」,让FunctionObject互相成为对方的实例,最终所有链条都汇总到Object.prototype,再以null为终点,既形成了完整的继承体系,又避免了无限循环。

前端开发的过程中,需要mock 数据,但是走的原来的接口,要怎么做

在前端开发中 mock 数据但走原来的接口,有几种常见的方案:

1. 使用 Mock Service Worker (MSW)  ⭐ 推荐

拦截网络请求,返回 mock 数据,不需要修改业务代码。

npm install msw --save-dev
// src/mocks/handlers.js
import { http, HttpResponse } from 'msw'

export const handlers = [
  // 拦截具体接口
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: 1,
      name: 'John Doe',
      email: 'john@example.com'
    })
  }),
  
  http.post('/api/login', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({
      token: 'mock-token-123',
      user: body
    })
  })
]
// src/mocks/browser.js (浏览器环境)
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/main.js
if (process.env.NODE_ENV === 'development') {
  const { worker } = await import('./mocks/browser')
  worker.start()
}

2. 代理服务器 + Mock 数据

通过 Vite/Webpack 的 proxy 配置,转发到本地 mock 服务器。

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3001', // 本地 mock 服务器
        changeOrigin: true
      }
    }
  }
}

3. 拦截器方案(axios/fetch)

在请求拦截器中判断是否需要返回 mock 数据。

// mockData.js
export const mockDatabase = {
  '/api/user': { id: 1, name: 'Mock User' },
  '/api/products': [{ id: 1, name: 'Product 1' }]
}

// axios-instance.js
import axios from 'axios'
import { mockDatabase } from './mockData'

const instance = axios.create()

// 添加响应拦截器
instance.interceptors.response.use(
  response => response,
  error => {
    // 如果开启 mock 且有对应的 mock 数据
    if (process.env.VITE_USE_MOCK === 'true') {
      const mockData = mockDatabase[error.config.url]
      if (mockData) {
        return Promise.resolve({ data: mockData })
      }
    }
    return Promise.reject(error)
  }
)

4. 条件编译 + 环境变量

根据环境变量决定是否使用 mock。

// api/user.js
import mockData from './mock/user.json'

export async function getUser() {
  if (import.meta.env.VITE_USE_MOCK) {
    return Promise.resolve(mockData)
  }
  return fetch('/api/user').then(res => res.json())
}

推荐方案对比

方案 优点 缺点 适用场景
MSW 不侵入代码、真实模拟网络请求 需要额外配置 团队开发、E2E 测试
代理 + Mock 服务器 灵活、可共享 需要额外服务 多人协作
拦截器 简单快速 侵入代码 快速开发调试
条件编译 完全控制 代码冗余 简单场景

我推荐使用 MSW,它最接近真实网络请求,且不需要修改业务代码,便于后续切换到真实接口。

使用 Whistle 是一个非常好的选择!它是专业的抓包调试工具,可以直接拦截和替换接口响应,无需修改代码。

5. Whistle Mock 接口方案

1. 安装 Whistle

npm install -g whistle

# 启动 whistle (默认端口 8899)
w2 start

# 或指定端口
w2 start -p 8899

2. 配置代理

启动后访问 http://localhost:8899 打开管理界面。

浏览器代理设置:

  • 手动设置代理:127.0.0.1:8899
  • 或使用 SwitchyOmega 等插件快速切换

3. Mock 数据配置方式

方式 1:直接返回 JSON 数据

在 Whistle Rules 中配置:

# 拦截接口返回 mock 数据
example.com/api/user resBody://{user-mock.json}

然后在 Values 中创建 user-mock.json

{
  "code": 0,
  "data": {
    "id": 1,
    "name": "Mock User",
    "email": "mock@example.com"
  }
}

方式 2:使用本地文件

# 返回本地文件内容
example.com/api/products file:///path/to/mock/products.json

方式 3:使用 Values(推荐)

# 使用 Values 中定义的内容
example.com/api/user resBody://{user-data}
example.com/api/list resBody://{product-list}

在 Values 标签页创建对应的 key-value。

方式 4:转发到本地 Mock 服务器

# 转发到本地 Node.js mock 服务
example.com/api http://127.0.0.1:3001

4. 实用配置示例

# ========== Mock API 配置 ==========

# 用户信息接口
example.com/api/user/info resBody://{user-info}

# 列表接口(支持正则匹配)
example.com/api/goods/list* resBody://{goods-list}

# POST 请求也可以 mock
example.com/api/login resBody://{login-success}

# 延迟响应(模拟慢网络)
example.com/api/slow resBody://{slow-data} resDelay://2000

# 返回错误状态
example.com/api/error statusCode://500 resBody://{error-msg}

# 只 mock 开发环境(通过域名区分)
dev.example.com/api/user resBody://{user-mock}
# 生产环境不拦截
prod.example.com/api/user # 不写规则就走真实请求

5. 高级技巧

按环境区分

# 开发环境 mock
dev.example.com/api resBody://{mock-data}

# 测试环境走真实接口
test.example.com/api # 不配置规则

动态 Mock(使用本地服务器)

创建 mock-server.js

const express = require('express')
const app = express()

app.get('/api/user', (req, res) => {
  res.json({
    id: Math.floor(Math.random() * 1000),
    name: 'Dynamic Mock User',
    timestamp: Date.now()
  })
})

app.listen(3001, () => {
  console.log('Mock server running on http://localhost:3001')
})

Whistle 配置:

example.com/api http://127.0.0.1:3001

组合规则

# 同时设置多个规则
example.com/api/user resBody://{user-data} resCors://* resDelay://1000

6. Whistle vs MSW 对比

特性 Whistle MSW
无需修改代码 ✅ 完全独立 ✅ 需初始化代码
跨项目使用 ✅ 全局生效 ❌ 每个项目配置
调试真实请求 ✅ 可抓包分析 ❌ 无法抓包
团队共享配置 ✅ 导出规则文件 ✅ 提交代码仓库
动态修改 ✅ 实时生效 ❌ 需刷新页面
学习成本 中等

7. 快速开始步骤

# 1. 安装并启动
npm install -g whistle
w2 start

# 2. 配置代理
# Chrome: 设置 -> 系统 -> 代理 -> 127.0.0.1:8899

# 3. 访问管理界面
# http://localhost:8899

# 4. 在 Rules 标签添加规则
your-domain.com/api/user resBody://{"code":0,"data":{"name":"Test"}}

# 5. 刷新页面查看效果

8. 推荐工作流

1. 项目启动前先启动 Whistle: w2 start
2. 配置需要 mock 的接口规则
3. 开发时实时修改 mock 数据(无需重启)
4. 联调时关闭对应规则即可走真实接口
5. 团队共享:导出规则文件提交到代码仓库

面试官问SSE和WebSocket的区别?看这篇就够了(含心跳机制详解)

最近在复习计算机网络和 LLM 相关技术时,我突然意识到一个很有意思的现象:现在的 AI 聊天大多用的是 SSE,但提到真正的实时互动,还得看 WebSocket

为了搞懂这玩意儿,我手写了一个简易版的聊天室。今天就把我的学习笔记、代码实现,还有那些让人头秃的协议对比,一次性全掏出来!


🤔 为什么 HTTP 不适合聊天?

咱们先聊聊背景。作为前端,我们最熟悉的是 HTTP 协议。

HTTP 就像是一个“高冷”的客服

  • 你问一句(Request),它答一句(Response)。
  • 答完就挂电话(短连接),下次想问得重新拨号。

如果你想做一个聊天室,用 HTTP 怎么办?只能靠轮询

// 每隔 1 秒问一次服务器:“有新消息吗?有新消息吗?”
setInterval(() => {
  fetch('/api/messages').then(...)
}, 1000);

这太蠢了,对吧?性能差,延迟高,服务器都要被问烦了。

SSE 呢?SSE 适合 LLM 那种“流式输出”(我一次提问,它一直吐字),它是单向的。但聊天是双向的,我要发,你也要发。

所以,我们需要一个能建立“长连接”、双方都能主动说话的协议——WebSocket


💡 WebSocket:一次握手,终身相伴

WebSocket 是 HTML5 提供的一种在单个 TCP 连接上进行全双工通讯的协议。

  • 全双工:就像打电话,双方都可以同时说话,不需要等对方说完。
  • 长连接:一旦建立,除非主动断开,否则一直连着。

📝 核心代码逻辑拆解

这里我用 Koa + koa-websocket 来实现。为了让大家看得更清楚,我把代码拆成三个关键步骤来讲。

步骤一:搭建舞台(服务端初始化)

首先,我们需要让 Koa 具备处理 WebSocket 的能力,并准备一个“花名册”来记录所有连进来的用户。

javascript

编辑

const Koa = require('koa');
const websocket = require('koa-websocket');

// 1. 初始化 Koa 并赋予 WebSocket 能力
const app = websocket(new Koa());

// 2. 准备一个 Set 集合,用来存储所有连接的客户端
// 为什么用 Set?因为我们要保证连接对象的唯一性
const clients = new Set();

步骤二:派发请柬(处理 HTTP 请求)

WebSocket 连接通常是从一个网页开始的。所以,我们需要一个普通的 HTTP 中间件,返回给浏览器一个包含聊天界面的 HTML 页面。

javascript

编辑

// 3. 处理普通 HTTP 请求:返回我们的聊天页面
app.use(async (ctx) => {
    ctx.body = `
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <title>WebSocket Chat</title>
    </head>
    <body>
       <div id="messages" style="height:300px;overflow-y:scroll; border:1px solid #ccc;"></div>
        <input type="text" id="messageInput" placeholder="输入消息..."/>
        <button onclick="sendMessage()">发送</button>
        <script>
            // 核心在这里:前端建立 WebSocket 连接
            // 注意协议是 ws:// 而不是 http://
            const ws = new WebSocket('ws://localhost:3000/ws');
            
            // 监听服务器发来的消息
            ws.onmessage = function(event){
                const messageDiv = document.getElementById('messages');
                messageDiv.innerHTML += '<div>' + event.data + '</div>';
            }
            
            function sendMessage(){
                const input = document.getElementById('messageInput');
                ws.send(input.value); // 发送消息
                input.value = '';
            }
        </script>
    </body>
    </html>
    `
})

步骤三:建立专线(处理 WebSocket 连接)

这是最关键的一步。当浏览器执行了 new WebSocket() 后,服务器会通过 app.ws.use 捕获到这个连接请求。

这里我们主要做三件事:登记用户监听消息广播消息

javascript

编辑

// 4. 处理 WebSocket 连接
app.ws.use(async (ctx) => {
    // A. 登记:将当前连接加入集合
    clients.add(ctx.websocket);
    console.log('当前在线人数:', clients.size);

    // B. 监听:当收到某人的消息时
    ctx.websocket.on('message', message => {
        // C. 广播:把这条消息发给“花名册”里的每一个人
        for(const client of clients){
            client.send(message.toString());
        }
    });

    // D. 离场:监听断开连接,把人从花名册里删掉
    ctx.websocket.on('close', () => {
        clients.delete(ctx.websocket);
        console.log('有人离开了...');
    });
})

app.listen(3000, () => {
    console.log('🚀 服务器启动,请访问 http://localhost:3000');
});

运行效果:
打开浏览器访问 localhost:3000,你可以打开好几个标签页,在一个标签页发消息,所有标签页都会实时收到消息!这就是广播

image.png


📊 一张表看懂 HTTP、SSE 与 WebSocket

为了面试(408 计算机网络)和工作,这三个协议的区别必须门儿清:

特性 HTTP SSE WebSocket
连接方式 短连接 (请求-响应) 长连接 (单向推送) 长连接 (双向通讯)
通讯方向 客户端发起 服务端 -> 客户端 客户端 <-> 服务端
适用场景 网页加载、API 请求 AI 流式输出、股票行情 聊天室、即时游戏、协作编辑
数据格式 文本/JSON/二进制 仅限文本 (text/event-stream) 二进制帧/文本帧
  • SSE:适合“我不动,你推给我”的场景(比如 LLM 打字机效果)。
  • WebSocket:适合“你一句我一句”的场景。

❤️ 心跳机制:长连接的“异地恋”哲学

既然 WebSocket 是长连接,那就面临一个现实问题:网络是不稳定的

路由器重启、手机进电梯、防火墙拦截……都可能导致连接“静默断开”。这时候,客户端以为连着,服务器以为断了,这就尴尬了。

怎么解决?—— 心跳机制

这就好比异地恋的情侣

你们不能一直打电话(开销太大),但必须定期确认对方还在。

  • 客户端:“宝,你在吗?”(Ping)
  • 服务端:“在呢,活着呢。”(Pong)

如果客户端发了 Ping,过了 30 秒还没收到 Pong,那就判定为“分手”(连接断开),然后触发重连机制

代码逻辑示意:

// 客户端
setInterval(() => {
    if(ws.readyState === WebSocket.OPEN){
        ws.send(JSON.stringify({type: 'ping'}));
    }
}, 30000); // 每30秒问候一次

// 服务端
ws.on('message', (msg) => {
    const data = JSON.parse(msg);
    if(data.type === 'ping'){
        ws.send(JSON.stringify({type: 'pong'})); // 秒回
    }
});

📌 总结

今天我们从 HTTP 的局限性出发,手搓了一个基于 Koa 的 WebSocket 聊天室,顺便复习了 SSE 和心跳机制。

划重点:

  1. HTTP 是“一问一答”,WebSocket 是“双向奔赴”。
  2. SSE 适合流式输出(AI),WebSocket 适合即时通讯(Chat)。
  3. 心跳机制是长连接保活的关键,防止“假死”连接。

希望这篇文章能帮你搞定 WebSocket!如果觉得有用,记得点个赞 👍,我们下期见!

Qiankun 微前端配置详解

本文档以本项目(odp-center-vue)和子系统(odp-opcard-vue)的实际配置为基础,详细说明 qiankun 的接入流程及在其他系统中的复用方案。


目录

  1. 架构概述
  2. 核心概念
  3. 子应用接入步骤(以 Vite + Vue3 为例)
  4. 配置详解(逐文件)
  5. 父子应用通信机制
  6. CSS 样式隔离方案
  7. 在新系统中复用的标准模板
  8. 常见问题排查

1. 架构概述

本项目的微前端层级关系如下:

主应用(顶层父应用)
    └── odp-center-vue(本项目,作为"中间层"子应用)
            └── odp-opcard-vue(下级子应用,由 odp-center-vue 承载)

重要说明:

  • odp-center-vue 对上层主应用而言是子应用(Slave),对 odp-opcard-vue 而言是父应用(Master)。
  • 每一层都使用相同的技术栈:vite-plugin-qiankun + Vue3。
  • 所有子应用均支持独立运行(无需主应用也可正常启动)。

2. 核心概念

概念 说明
vite-plugin-qiankun Vite 生态下的 qiankun 适配插件,替代直接安装 qiankun
qiankunWindow 插件提供的沙箱化 window 对象,避免全局变量污染
__POWERED_BY_QIANKUN__ 标识当前是否运行在 qiankun 环境中的全局变量
renderWithQiankun 注册子应用生命周期钩子的核心函数
data-qiankun HTML 根容器的标识属性,CSS 隔离的锚点
useDevMode 开发模式下允许跨域加载,生产环境必须关闭

3. 子应用接入步骤

Step 1:安装依赖

npm install vite-plugin-qiankun --save-dev
npm install postcss-prefix-selector autoprefixer --save-dev

Step 2:修改 index.html

在根容器上添加 data-qiankun 属性,值为子应用名称:

<!-- 修改前 -->
<div id="app"></div>

<!-- 修改后 -->
<div id="app" data-qiankun="你的子应用名称"></div>

Step 3:配置 vite.config.ts

import { defineConfig } from 'vite'
import qiankun from 'vite-plugin-qiankun'

export default defineConfig(({ command }) => {
  const APP_NAME = 'your-app-name'  // 子应用唯一名称

  return {
    base: command === 'serve' ? '/' : `/${APP_NAME}`,

    plugins: [
      // ... 其他插件
      qiankun(APP_NAME, {
        useDevMode: command === 'serve'  // 开发环境开启,生产关闭
      })
    ],

    css: {
      postcss: {
        plugins: [
          // CSS 隔离(仅在 qiankun 环境下生效)
          // 详见第 6 节
        ]
      }
    }
  }
})

Step 4:修改 src/router/index.ts

import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper'

// 关键:qiankun 环境下 base 必须与 vite 的 build base 一致
const base = qiankunWindow.__POWERED_BY_QIANKUN__ ? '/your-app-name' : '/'

export const router = createRouter({
  history: createWebHistory(base),
  routes
})

Step 5:改造 src/main.ts

这是最核心的一步,将应用挂载逻辑抽取为可复用函数:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
import App from './App.vue'
import { router } from './router'

let app: ReturnType<typeof createApp> | null = null

function createMyApp() {
  const instance = createApp(App)
  instance.use(createPinia())
  instance.use(router)
  return instance
}

function setupFn(appInstance: ReturnType<typeof createApp>, container: string | HTMLElement) {
  appInstance.mount(container)
}

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  // 独立运行模式
  app = createMyApp()
  setupFn(app, '#app')
} else {
  // qiankun 子应用模式
  renderWithQiankun({
    bootstrap() {
      return Promise.resolve()
    },
    async mount(props) {
      app = createMyApp()

      // 接收父应用传递的数据
      const { parentStore, parentRouter, parentEvents } = props
      app.config.globalProperties.parentStore = parentStore

      // 存储父应用 props 到 store(可选)
      // const commonStore = useCommonStore()
      // commonStore.setParentProps({ parentStore, parentRouter, parentEvents })

      // 挂载到父应用提供的容器中
      setupFn(app, props.container?.querySelector('#app') as HTMLElement)
    },
    update() {},
    unmount() {
      app?.unmount()
      app = null
    }
  })
}

4. 配置详解(逐文件)

4.1 vite.config.ts 完整 qiankun 相关配置

基于 odp-opcard-vue 的实际配置,以下是所有 qiankun 相关配置项的说明:

// 子应用唯一名称(须与父应用注册时的 name 一致)
const APP_NAME = 'odp-opcard-vue'

export default defineConfig(({ command }) => ({
  // 1. base 路径:开发环���为 /,生产环境为 /子应用名
  base: command === 'serve' ? '/' : `/${APP_NAME}`,

  plugins: [
    // 2. qiankun 插件(放在其他插件之后)
    qiankun(APP_NAME, {
      useDevMode: command === 'serve'
    }),

    // 3. 修复 scoped CSS 与 qiankun 前缀冲突的自定义插件(见第 6 节)
    {
      name: 'fix-css-selector-qiankun-global',
      // ...
    }
  ],

  css: {
    postcss: {
      plugins: [
        // 4. CSS 前缀隔离(仅在 qiankun 环境下启用)
        ...(qiankunWindow.__POWERED_BY_QIANKUN__ ? [
          prefixer({
            prefix: `div[data-qiankun="${APP_NAME}"]`,
            transform(prefix, selector, prefixedSelector) {
              // 跳过全局选择器
              if (['#app', 'body', 'html'].includes(selector)) return selector
              return prefixedSelector
            }
          })
        ] : [])
      ]
    }
  }
}))

4.2 index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>子应用标题</title>
</head>
<body>
  <!-- data-qiankun 属性是 CSS 隔离的锚点,值必须与 APP_NAME 一致 -->
  <div id="app" data-qiankun="your-app-name"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

4.3 src/store/modules/common.ts(父应用 props 存储)

import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useCommonStore = defineStore('common', () => {
  const parentProps = ref<Record<string, any>>({})

  const setParentProps = (data: Record<string, any>) => {
    parentProps.value = { ...parentProps.value, ...data }
  }

  return { parentProps, setParentProps }
})

5. 父子应用通信机制

5.1 父应用向子应用传递数据(通过 Props)

父应用在注册子应用时传入数据:

// 父应用侧
registerMicroApps([
  {
    name: 'your-app-name',
    entry: '//localhost:9001',
    container: '#subapp-container',
    activeRule: '/your-app-name',
    props: {
      parentStore: store,       // 父应用的 Pinia store
      parentRouter: router,     // 父应用的路由实例
      parentEvents: eventBus,   // 父子通信事件总线
    }
  }
])

子应用在 mount 钩子中接收:

async mount(props) {
  const { parentStore, parentRouter, parentEvents } = props

  // 方式一:挂载到全局属性(任何组件可通过 getCurrentInstance() 访问)
  app.config.globalProperties.parentStore = parentStore

  // 方式二:存入 Pinia store(推荐,响应式)
  const commonStore = useCommonStore()
  commonStore.setParentProps({ parentStore, parentRouter, parentEvents })
}

5.2 子应用调用父应用方法

// 在子应用的任意组件或 store 中
import { useCommonStore } from '@/store/modules/common'

const commonStore = useCommonStore()

// 调用父应用的退出登录方法
commonStore.parentProps.parentStore.user.dispatchLogOut()

// 使用父应用路由跳转
commonStore.parentProps.parentRouter.push('/other-system')

5.3 弹窗容器挂载适配

在 qiankun 沙箱中,弹窗默认挂载到 document.body 会导致样式隔离失效。需适配挂载点:

// App.vue 或全局配置
const getPopupContainer = (el: HTMLElement) => {
  if (qiankunWindow.__POWERED_BY_QIANKUN__) {
    // 挂载到子应用根容器内,保持样式隔离
    return document.querySelector('#app[data-qiankun="your-app-name"]') || document.body
  }
  return document.body
}

// Ant Design Vue 配置示例
// <a-config-provider :get-popup-container="getPopupContainer">

6. CSS 样式隔离方案

6.1 方案原理

使用 postcss-prefix-selector 为所有 CSS 选择器自动添加 div[data-qiankun="APP_NAME"] 前缀,使样式只作用于子应用根容器内部。

/* 处理前 */
.my-button { color: red; }

/* 处理后 */
div[data-qiankun="your-app-name"] .my-button { color: red; }

6.2 完整配置(包含选择器过滤规则)

import prefixer from 'postcss-prefix-selector'
import autoprefixer from 'autoprefixer'
import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper'

const APP_NAME = 'your-app-name'

// 仅在 qiankun 环境下启用
const postcssPlugins = qiankunWindow.__POWERED_BY_QIANKUN__ ? [
  prefixer({
    prefix: `div[data-qiankun="${APP_NAME}"]`,
    transform(prefix, selector, prefixedSelector, filePath) {
      // 1. 跳过全局根选择器(避免破坏布局)
      if ([
        '#app', 'body', 'html', ':root',
        '.menu', '.ant-scrolling-effect'
      ].some(s => selector.startsWith(s))) {
        return selector
      }

      // 2. 非 Vue 组件文件中的 Ant Design 原生样式不加前缀
      // (避免与全局 antd 样式冲突)
      if (!filePath.includes('src/') && selector.includes('.ant-')) {
        return selector
      }

      // 3. Vue 组件和业务代码中的样式添加前缀
      return prefixedSelector
    }
  }),
  autoprefixer({})
] : []

6.3 修复 Scoped CSS 与 qiankun 前缀冲突

Vue 的 scoped 样式会生成如 .my-class[data-v-xxxxxx] 的选择器,与 qiankun 前缀叠加后可能出现格式错误。需要自定义 Vite 插件修复:

// vite.config.ts plugins 中添加
{
  name: 'fix-css-selector-qiankun-global',
  // 处理开发环境中的 transform
  transform(code, id) {
    if (!id.includes('.vue')) return code
    // 修复形如:div[data-qiankun="xxx"].foo[data-v-yyy]
    // 变为:div[data-qiankun="xxx"] .foo[data-v-yyy]
    return code.replace(
      /div\[data-qiankun="[^"]+"\](\.[^{,\s]+\[data-v-)/g,
      (match, p1) => match.replace(p1, ` ${p1}`)
    )
  },
  // 处理构建产物中的 CSS 文件
  generateBundle(options, bundle) {
    for (const [fileName, chunk] of Object.entries(bundle)) {
      if (fileName.endsWith('.css') && chunk.type === 'asset') {
        chunk.source = (chunk.source as string).replace(
          /div\[data-qiankun="[^"]+"\](\.[^{,\s]+\[data-v-)/g,
          (match, p1) => match.replace(p1, ` ${p1}`)
        )
      }
    }
  }
}

7. 在新系统中复用的标准模板

7.1 复用清单(新接入子应用时逐项检查)

# 文件 修改内容 关键值
1 package.json 添加依赖 vite-plugin-qiankun, postcss-prefix-selector
2 index.html 根容器加属性 data-qiankun="APP_NAME"
3 vite.config.ts 注册插件,配置 base 和 CSS APP_NAME, useDevMode
4 src/router/index.ts 动态设置 base qiankunWindow.__POWERED_BY_QIANKUN__
5 src/main.ts 注册生命周期钩子 renderWithQiankun, 四个生命周期
6 src/store/modules/common.ts 存储父应用 props setParentProps
7 src/App.vue 弹窗容器适配 getPopupContainer

7.2 main.ts 复用模板(直接复制,替换 APP_NAME)

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
import App from './App.vue'
import { router } from './router'
import { useCommonStore } from './store/modules/common'

// ======== 修改这里 ========
const APP_NAME = 'your-app-name'
// ==========================

let app: ReturnType<typeof createApp> | null = null

function createMyApp() {
  const instance = createApp(App)
  const pinia = createPinia()
  instance.use(pinia)
  instance.use(router)
  return instance
}

function setupFn(
  appInstance: ReturnType<typeof createApp>,
  container: string | HTMLElement
) {
  appInstance.mount(container)
}

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  app = createMyApp()
  setupFn(app, '#app')
} else {
  renderWithQiankun({
    bootstrap() {
      return Promise.resolve()
    },
    async mount(props) {
      app = createMyApp()
      const { parentStore, parentRouter, parentEvents } = props

      app.config.globalProperties.parentStore = parentStore

      const commonStore = useCommonStore()
      commonStore.setParentProps({ parentStore, parentRouter, parentEvents })

      setupFn(app, props.container?.querySelector('#app') as HTMLElement)
    },
    update() {},
    unmount() {
      app?.unmount()
      app = null
    }
  })
}

7.3 router/index.ts 复用模板

import { createRouter, createWebHistory } from 'vue-router'
import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper'

// ======== 修改这里 ========
const APP_NAME = 'your-app-name'
// ==========================

const routes = [
  // 你的路由配置...
]

const base = qiankunWindow.__POWERED_BY_QIANKUN__ ? `/${APP_NAME}` : '/'

export const router = createRouter({
  history: createWebHistory(base),
  routes
})

7.4 vite.config.ts 关键片段复用模板

import qiankun from 'vite-plugin-qiankun'
import prefixer from 'postcss-prefix-selector'
import autoprefixer from 'autoprefixer'
import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper'

// ======== 修改这里 ========
const APP_NAME = 'your-app-name'
// ==========================

export default defineConfig(({ command }) => ({
  base: command === 'serve' ? '/' : `/${APP_NAME}`,

  plugins: [
    vue(),
    qiankun(APP_NAME, { useDevMode: command === 'serve' }),
    // CSS 选择器修复插件(直接从本项目复制)
  ],

  css: {
    postcss: {
      plugins: [
        ...(qiankunWindow.__POWERED_BY_QIANKUN__ ? [
          prefixer({
            prefix: `div[data-qiankun="${APP_NAME}"]`,
            transform(prefix, selector, prefixedSelector, filePath) {
              if (['#app', 'body', 'html', ':root'].some(s => selector.startsWith(s))) {
                return selector
              }
              return prefixedSelector
            }
          }),
          autoprefixer({})
        ] : [])
      ]
    }
  }
}))

8. 常见问题排查

Q1:子应用独立运行正常,但在主应用中加载空白

检查项:

  1. vite.config.ts 中的 base 是否配置正确(生产环境需要 /${APP_NAME}
  2. index.htmldata-qiankun 属性是否与注册的 name 一致
  3. 主应用注册时的 entry 路径和 container 选择器是否正确

Q2:样式污染(子应用样式影响主应用)

检查项:

  1. postcss-prefix-selector 是否正确配置
  2. transform 函数中是否有遗漏的全局选择器未被过滤
  3. 弹窗类组件的 getContainer/getPopupContainer 是否指向子应用容器

Q3:路由跳转后白屏或 404

检查项:

  1. routerbase 是否在 qiankun 环境下设为 /${APP_NAME}
  2. 主应用的 activeRule 是否与子应用路由 base 一致
  3. Nginx/服务器是否将 /${APP_NAME}/* 的请求都指向子应用的 index.html

Q4:父应用 store 在子应用中访问为空

检查项:

  1. mount 钩子中是否正确解构了 props
  2. createPinia() 是否在 mount 内部(每次 mount 都要新建,不能复用)
  3. setParentProps 是否在 app.use(pinia) 之后调用

Q5:开发环境跨域报错

检查项:

  1. useDevMode: true 是否在 command === 'serve' 时开启
  2. Vite devServer 是否配置了 CORS:
    server: {
      cors: true,
      headers: { 'Access-Control-Allow-Origin': '*' }
    }
    

附录:本项目实际使用的子应用名称

系统 APP_NAME 开发端口 生产 base
odp-center-vue odp-center-vue 9001 /odp-center-vue
odp-opcard-vue odp-opcard-vue (查看其 vite.config) /odp-opcard-vue

注意:每个子应用的 APP_NAME 必须全局唯一,且在主应用注册时的 namevite.config 的插件参数、index.htmldata-qiankun、路由 base 四处保持完全一致。

UI 与交互篇 (3/6):动画体系:隐式动画到自定义动画

动画体系:隐式动画到自定义动画

系列:UI 与交互篇 · 第 3/6 篇

Flutter 动画 ImplicitlyAnimatedWidget AnimationController 性能


1. 问题背景:业务场景 + 现象

  • 场景:房间状态角标、排行数字跳动、底部 Tab 切换、弱提示条滑入滑出、列表项展开收起、游戏 HUD 分数滚动等,都需要「顺眼」的过渡,而不是硬切。
  • 现象
    • setState 里改数值,外包一层 AnimatedContainer一多就乱,时长曲线各写各的。
    • 需要串行动画(先缩再放)时,把 Future.delayedsetState 堆在一起,取消导航或 dispose 后仍回调,偶发报错。
    • 列表里每个 cell 都挂 AnimationController,滑动时 CPU 飙高、掉帧
    • 设计要「品牌曲线」,发现 Curves.ease 全家不够用,不敢碰 CustomPainter / TweenSequence

目标:用一套从隐式到显式、再到完全自控的升级路径,让动画可组合、可复用、可收尾


2. 原因分析:核心原理 + 排查过程

2.1 Flutter 动画在框架里大致怎么走

  • 隐式动画 Widget(如 AnimatedOpacityTweenAnimationBuilder):内部替你管 AnimationControllerAnimationduration + curve + 目标值变化即触发重建插值。
  • 显式动画:你自己 TickerProvider + AnimationController,把 Animation<double> 交给子组件或 AnimatedBuilder适合多段、手势驱动、可暂停恢复
  • 自定义绘制动画CustomPainterrepaint 监听 Listenable(常常是 controller),在 paint 里按进度算路径/矩阵——适合无法用布局表达的形变

2.2 常见卡顿与错乱从哪里来

类型 典型原因
掉帧 每帧 build 里做重计算;列表内过多独立 AnimationController;大图未缓存仍参与过渡
内存/泄漏 Controller 未在 dispose 释放;路由 pop 后 addStatusListener 仍触发
视觉「假」 时长与 curve 与交互节奏不一致;多属性不同步(透明度结束了位移还在跑)

2.3 排查时可问自己的三个问题

  1. 这是单一属性随数据变,还是编排一段表演?前者 → 隐式 / TweenAnimationBuilder;后者 → Controller + Interval
  2. 动画是否绑定在列表 item 生命周期?若是,能否抽成「可见时才驱动」或使用隐式减少 ticker 数量?
  3. 退出页面时,谁负责 stop() / dispose()?是否在 Ticker 已停用的 context 里再 setState

3. 解决方案:方案对比 + 最终选择

3.1 分层选型(建议团队统一口径)

需求 优先方案 说明
单一数值/样式随状态变 Animated*TweenAnimationBuilder 代码少,自带 controller 生命周期
多段、循环、手势跟手 AnimationController + Tween / Curve 可控 forward/reverse/repeat
多条动画不同时间段 一条 Controller + 多个 Interval(或 TweenSequence 避免多个 controller 抢同一套状态
形变/粒子/路径 CustomPainter + repaint: animation 少触发布局,GPU 友好

3.2 最终选择(落地原则)

  • 默认隐式样:能用 TweenAnimationBuilder 就不要手写 controller。
  • 编排显式样:一个页面一个「导演」controller(或 AnimationController + staggered),子组件只接收 Animation<double> 或具体 Tween
  • 列表里慎挂 ticker:优先数据驱动的隐式短动画,长表演用 Hero / 独立层 / 可见性策略。
  • 退出必清理dispose controller;异步结束回调先判断 mounted(或统一用可取消的 token)。

4. 关键代码:最小必要代码片段

4.1 隐式:透明度 + 位移(无手写 Controller)

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: visible ? 1 : 0),
  duration: const Duration(milliseconds: 220),
  curve: Curves.easeOutCubic,
  builder: (context, t, child) {
    return Opacity(
      opacity: t,
      child: Transform.translate(
        offset: Offset(0, (1 - t) * 8),
        child: child,
      ),
    );
  },
  child: bannerChild,
);

要点:child 传给 builder 外面,避免子树随 tween 每帧重建

4.2 显式:单 Controller 串行两段(缩放 → 淡出)

class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 600),
  );

  late final Animation<double> scale = Tween(begin: 1.0, end: 1.08).animate(
    CurvedAnimation(parent: _c, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
  );

  late final Animation<double> fade = Tween(begin: 1.0, end: 0.0).animate(
    CurvedAnimation(parent: _c, curve: const Interval(0.45, 1.0, curve: Curves.easeIn)),
  );

  @override
  void dispose() {
    _c.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _c,
      builder: (context, child) {
        return Opacity(
          opacity: fade.value,
          child: Transform.scale(scale: scale.value, child: child),
        );
      },
      child: widget.child,
    );
  }
}

4.3 自定义:Painter 跟一条 Animation

class RibbonPainter extends CustomPainter {
  RibbonPainter(this.progress) : super(repaint: progress);
  final Animation<double> progress;

  @override
  void paint(Canvas canvas, Size size) {
    final t = progress.value;
    // 用 t 插值路径控制点、渐变起止等
  }

  @override
  bool shouldRepaint(covariant RibbonPainter oldDelegate) =>
      oldDelegate.progress != progress;
}

5. 效果验证:数据/截图/日志

  • DevTools Performance:打开 Performance overlay 或记录一段 Timeline,对比优化前后 UI thread jankbuild 次数。
  • 直觉验收:用 0.75× / 1.25× 系统动画速度走一遍关键路径,慢放仍能感到节奏一致
  • 压测:在列表里快速滚动同时触发动画,观察是否出现 Concurrent modification / Ticker disposed 类异常(若有,检查异步与 dispose 顺序)。

可记录前后:平均帧耗时、单次交互内 build 调用次数、列表 scroll 时 CPU%(定性即可,适合写进复盘)。


6. 可复用结论:通用经验 + 避坑清单

通用经验

  1. 先问是不是编排:是 → 一条时间轴管起来;否 → 隐式收尾最快。
  2. TweenAnimationBuilderchild 复用是免费性能点,和 AnimatedBuilder 同理。
  3. 把「进度」往下传,不要把「Controller」泄漏到无关子组件。
  4. 曲线即产品语言:团队定 2~3 套 Duration + Curve 组合,比每人手写更像同一款 App。

避坑清单

  • 列表项里人手一个长生命周期 AnimationController,滑动仍 repeat
  • AnimationControllerinitStateforward(),但忘记在 dispose 里释放。
    [ ] 用 async/await 串联动画,页面退出后仍改 state
  • buildcontroller.forward()(应放在事件回调或 didUpdateWidget 等明确时机)。
  • heavy 的 decode/布局计算放在 paint 每帧做(应缓存或下沉到静态资源)。

下一篇预告:深色模式、主题系统与设计令牌ThemeExtension、语义色、组件侧零魔法数)。

Generator 函数:那个能“暂停”的函数,到底有什么用?

你有没有想过,如果函数可以“暂停”,等你想好了再继续,会是什么样?今天我们就来认识JavaScript里的“时间管理大师”——Generator函数。它能让你在执行到一半的时候停下来,等你喊“继续”再往下走。这听起来有点科幻,但它却是async/await的祖师爷。

前言

普通函数就像一支穿云箭,发射出去就直奔终点,中间绝不回头。但有时候我们需要更灵活的控制:比如我要分几步做一件事,每一步之间可能隔着十万八千里,或者我想让调用方决定什么时候继续。

Generator函数就是来解决这个问题的。它让你可以“暂停”函数执行,等会儿再“恢复”。这就像打游戏时按了暂停键,你去泡个面,回来继续打。

一、Generator长啥样?

Generator函数在function后面加个星号*,里面用yield关键字来“暂停”。

function* myGenerator() {
  console.log('第一步');
  yield '暂停一下';
  console.log('第二步');
  yield '再停一下';
  console.log('第三步');
  return '结束了';
}

调用这个函数并不会立即执行,而是返回一个迭代器对象。你通过调用next()来一步步执行。

const gen = myGenerator();

console.log(gen.next()); // 输出:第一步,{ value: '暂停一下', done: false }
console.log(gen.next()); // 输出:第二步,{ value: '再停一下', done: false }
console.log(gen.next()); // 输出:第三步,{ value: '结束了', done: true }
console.log(gen.next()); // { value: undefined, done: true }

每次next()都会执行到下一个yield,然后暂停。yield后面的值会作为value返回。等所有代码执行完,done就变成true

二、yield是“暂停键”,next是“播放键”

这个机制有点像你写文章写到一半,突然想喝杯咖啡。你把光标停在某个位置(yield),喝完咖啡回来,再敲一下键盘(next),继续往下写。

更神奇的是,next()还可以传参,这个参数会成为上一个yield的返回值。这就像你暂停时给函数塞了张纸条,告诉它接下来该怎么走。

function* talkGenerator() {
  const name = yield '你叫什么名字?';
  const age = yield `${name},你多大了?`;
  return `${name}今年${age}岁`;
}

const talk = talkGenerator();

console.log(talk.next());        // { value: '你叫什么名字?', done: false }
console.log(talk.next('张三'));   // { value: '张三,你多大了?', done: false }
console.log(talk.next(18));      // { value: '张三今年18岁', done: true }

看到没?第一次next()只是启动,第二次next('张三')把“张三”传给了name,第三次传年龄。这就是Generator的“对话”能力。

三、协程:Generator的底层哲学

Generator函数的这种“暂停/恢复”能力,其实是**协程(Coroutine)**思想的体现。协程是一种比线程更轻量级的并发单元,它可以在多个任务之间主动让出控制权。

在JavaScript里,Generator就是协程的一种实现。你可以用它来模拟多任务协作,比如交替执行两个任务:

function* task1() {
  yield '任务1: 第1步';
  yield '任务1: 第2步';
  return '任务1完成';
}

function* task2() {
  yield '任务2: 第1步';
  yield '任务2: 第2步';
  return '任务2完成';
}

const t1 = task1();
const t2 = task2();

console.log(t1.next().value); // 任务1: 第1步
console.log(t2.next().value); // 任务2: 第1步
console.log(t1.next().value); // 任务1: 第2步
console.log(t2.next().value); // 任务2: 第2步

这样两个任务就像在“交替执行”,但实际还是单线程,只是每次让出控制权。这就是“协作式多任务”。

四、Generator的“主战场”:异步流程控制

在async/await出现之前,Generator是处理异步的利器。比如你要按顺序发起三个网络请求,用Promise可以这么写:

function fetchUser() { return fetch('/user').then(r => r.json()); }
function fetchOrders(userId) { return fetch(`/orders?userId=${userId}`).then(r => r.json()); }
function fetchProducts(orderId) { return fetch(`/products?orderId=${orderId}`).then(r => r.json()); }

// 用Generator + 自动执行器
function* fetchFlow() {
  const user = yield fetchUser();
  const orders = yield fetchOrders(user.id);
  const products = yield fetchProducts(orders[0].id);
  return products;
}

// 需要一个自动执行器,让yield后面的Promise自动执行
function run(generator) {
  const gen = generator();
  function step(result) {
    if (result.done) return result.value;
    return result.value.then(
      res => step(gen.next(res)),
      err => step(gen.throw(err))
    );
  }
  return step(gen.next());
}

run(fetchFlow).then(products => console.log(products));

这个run函数就是传说中的自动执行器,它不断调用next,把Promise的结果传回去。这其实就是async/await的前身——用Generator模拟同步写法。

后来ES7直接把这种模式内置成了async/await,所以现在我们很少直接写Generator了,但它的思想深深影响了现代JS。

五、Generator的实用场景:不仅仅是异步

虽然有了async/await,Generator并没有被淘汰,它还在一些地方发光发热:

1. 无限数据结构

用Generator可以生成无限序列,比如斐波那契数列:

function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
// 可以无限取下去

2. 状态机

Generator可以很方便地实现状态机,每个yield代表一个状态:

function* stateMachine() {
  let state = 'idle';
  while (true) {
    const action = yield state;
    switch (state) {
      case 'idle':
        if (action === 'start') state = 'running';
        break;
      case 'running':
        if (action === 'pause') state = 'paused';
        else if (action === 'stop') state = 'idle';
        break;
      case 'paused':
        if (action === 'resume') state = 'running';
        else if (action === 'stop') state = 'idle';
        break;
    }
  }
}

const sm = stateMachine();
console.log(sm.next().value); // idle
console.log(sm.next('start').value); // running
console.log(sm.next('pause').value); // paused
console.log(sm.next('resume').value); // running

3. 简化迭代器

如果一个对象需要实现[Symbol.iterator],用Generator可以省掉很多模板代码:

const myIterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
  }
};

for (const x of myIterable) {
  console.log(x); // 1,2,3
}

六、Generator vs async/await

既然async/await已经这么方便,为什么还要学Generator?

  • async/await:专注于异步,语法简洁,是处理异步任务的终极形态。
  • Generator:更底层,更灵活,可以暂停任何操作(不仅仅是Promise),还可以用于创建迭代器、状态机等。

async/await本质上就是Generator + 自动执行器的语法糖。所以理解Generator,就能更深刻理解async/await的运作原理。

七、总结:Generator是JS里的“时间胶囊”

Generator函数让我们能够:

  • 暂停函数执行,等以后再继续
  • 通过next传值,实现双向通信
  • yield实现惰性求值和无限序列
  • 模拟协程,实现协作式多任务
  • 为async/await打下基础

虽然现在很少直接写Generator做异步了,但它的思想无处不在。当你用for...of遍历数组时,背后有迭代器;当你用async/await时,底层有Generator的影子。

下次面试官问你“Generator有什么用”,你可以告诉他:这是JavaScript的“时间管理大师”,既能暂停时间,又能穿越时空,还能让异步代码看起来像同步。

明天我们将进入迭代器与可迭代对象,看看for...of、扩展运算符这些语法糖背后,到底藏着什么秘密。如果你觉得这篇文章够有趣,点个赞让更多人看到。我们明天见!

UI 与交互篇(1/6):组件化思路:从页面复制到可复用组件

组件化思路:从页面复制到可复用组件

系列:UI 与交互篇·第 1/6 篇

做业务 UI 时,最常见的捷径是:需求来了 → 打开旧页面 → 复制一整段 build → 改文案改颜色改接口。短期能交货,两周后同款卡片出现在三个页面,状态各写一份,动一个间距要改三遍,这才是「技术债」的真实利息。

这篇把「复制页面」升级成「可复用组件」的套路讲清楚:什么时候该抽、抽到哪一层、props 怎么定,让后来的需求改在组件上自动传导到所有引用点。


1. 问题背景:业务场景 + 现象

  • 场景:列表卡片、空态占位、带角标的头像区、底部操作条、筛选项一行等,在产品迭代里反复出现变体。
  • 现象
    • 多处 Column/Row 结构几乎相同,只有边距、图标、回调不同。
    • 同一视觉在 A 页用 GestureDetector,B 页用 InkWell,点击态不一致。
    • 「 small 改一下」要 grep 好几个文件,还容易漏改。
    • 新人不敢动大块页面文件,只能继续复制。

目标不是「每个 Widget 都要抽象成库」,而是在重复第三次之前,把稳定结构收拢成组件


2. 原因分析:核心原理 + 排查过程

2.1 Flutter 里「组件」到底是什么

在工程语境下,可复用单元通常是:无业务路由知识、少副作用、通过构造函数明确输入输出的 Widget(+ 可选的 Controller/Style)。页面负责拼装与导航;组件负责一块 UI 的展示与局部交互。

2.2「只复制页面」会坏在哪里

根因 表现
没有稳定边界 样式、文案、监听散落在页面 State
隐式约定 「和首页一样」靠口头,不靠 API
不可组合 无法把同一卡片嵌进列表/弹窗/横滑

2.3 自检:该不该抽成组件

满足任意两条就值得抽:

  1. 同一结构在 2+ 页面出现或即将出现。
  2. 有一处 设计规范(圆角、字号、间距)要统一收口。
  3. 需要单独 单测 / Goldens 覆盖一小块 UI。
  4. 同一文件 build 超过 ~150 行且可读性明显下降。

3. 解决方案:方案对比 + 最终选择

3.1 三种常见抽象层级

A. 原子级(Atom)
PrimaryButtonAppAvatar:只解决视觉与交互基线。适合设计系统。

B. 模块级(Molecule / 业务区块)
RoomSeatTileUserStatsHeader:带业务语义,但仍不直连全局单例,通过回调/参数注入。
业务项目里 80% 的「从复制到复用」落在这层。

C. 模板级(Template / Page Section)
Scaffold + AppBar + 统一 padding:少而精,避免过早抽象整页。

3.2 最终选择(推荐口径)

  1. 先抽「结构 + 可变点」:把不变的骨架留在组件内;把文案、资源、回调、bool isEnabled 等放到构造参数。
  2. 样式用 Theme / 设计令牌收口(本篇不深展开,同系列第 4 篇会继续)。抽组件时至少避免魔法数字散落在三个文件。
  3. 禁止组件里 Navigator.push 写死路由名(除非你就是壳工程里的路由封装);改成 onTapVoidCallback? onMore 让页面接线。
  4. 状态策略:展示型用 StatelessWidget + 外部数据;需要动画/展开收起再升 StatefulWidget;和列表滚动强相关再考虑把 Controller 外置。

4. 关键代码:最小必要片段

4.1 反面:页面内联一大段「将来会复制」的 UI

// page_profile.dart(示意:能跑但难复用)
Widget build(BuildContext context) {
  return Column(
    children: [
      Row(
        children: [
          CircleAvatar(radius: 28, backgroundImage: NetworkImage(avatarUrl)),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisConversion: CrossAxisAlignment.start,
              children: [
                Text(nickname, style: Theme.of(context).textTheme.titleMedium),
                Text(bio, style: Theme.of(context).textTheme.bodySmall),
              ],
            ),
          ),
          IconButton(onPressed: onEdit, icon: const Icon(Icons.edit_outlined)),
        ],
      ),
      // ... 下面重复结构又出现在「房间成员列表头」
    ],
  );
}

问题:Row 骨架重复;edit 行为绑死在页面。

4.2 正面:抽出「模块级」组件,显式 API

/// 模块级:ProfileHeaderBar
/// - 不负责拉取头像 URL,只展示
/// - 编辑/更多通过回调交给页面(接线层)
class ProfileHeaderBar extends StatelessWidget {
  const ProfileHeaderBar({
    super.key,
    required this.avatarUrl,
    required this.title,
    this.subtitle,
    this.onEdit,
    this.trailing,
  });

  final String avatarUrl;
  final String title;
  final String? subtitle;
  final VoidCallback? onEdit;
  final Widget? trailing;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        CircleAvatar(
          radius: 28,
          backgroundImage: NetworkImage(avatarUrl),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(title, style: theme.textTheme.titleMedium),
              if (subtitle != null)
                Text(subtitle!, style: theme.textTheme.bodySmall),
            ],
          ),
        ),
        if (trailing != null) trailing!,
        if (onEdit != null)
          IconButton(
            onPressed: onEdit,
            icon: const Icon(Icons.edit_outlined),
          ),
      ],
    );
  }
}

页面里只剩「拼数据 + 导航」:

ProfileHeaderBar(
  avatarUrl: user.avatarUrl,
  title: user.nickname,
  subtitle: user.bio,
  onEdit: () => context.push(EditProfileRoute(user.id)),
)

4.3 需要可选变体时:用命名构造或 small API,不要复制类

class ProfileHeaderBar extends StatelessWidget {
  const ProfileHeaderBar.compact({
    super.key,
    required this.avatarUrl,
    required this.title,
    this.subtitle,
    this.onEdit,
  })  : dense = true,
        trailing = null;

  // ...

  final bool dense;
  // dense 为 true 时缩小 avatar、字号 —— 仍是一个组件
}

原则:变体是同一组件的参数组合,而不是再复制一个 ProfileHeaderBar2


5. 效果验证:数据 / 截图 / 日志

工程向的验证可以这样落:

维度 做法 预期
修改成本 改一处圆角/间距,全局引用点同步 grep 组件名,引用 ≥2
CR 可读性 PR 中页面 diff 主要是数据与回调 build 行数下降
一致性 设计走查:同组件无不一致点击态 统一 InkWell/Material
回归 可选:对组件做 Golden test 改视觉时有基线对比

6. 可复用结论:通用经验 + 避坑清单

经验:

  1. 第三次重复再抽往往太晚;第二次出现就要评估接口。
  2. 组件 API 要像小函数签名:参数少而准,复杂对象用 ValueKey + 深比较要小心。
  3. 页面是编排(orchestration),组件是表达(presentation)——导航、埋点、权限可在页面层组合。

避坑:

  • Provider/Riverpodref.watch 写进「本应通用的」小组件里,导致任何页面一引用就绑全局。
  • context 滥用:MediaQueryTheme 在子树覆写时行为诡异;需要可考虑把 EdgeInsets 等由上层传入。
  • 一个 500 行的 God Widget:先纵向拆文件,再横向抽模块。
  • 仅为了「看起来优雅」抽原子,却导致 20 个参数;宁可保留一层「业务区块」适度综合。

下期预告:UI 与交互篇(2/6)——复杂列表性能优化(卡顿定位与修复)。

驾驭Skill市场:从3000+技能包中筛出真正能打的20个

驾驭Skill市场:从3000+技能包中筛出真正能打的20个

GitHub Copilot Extensions 上架数量突破 800,Cursor Rules 社区贡献超过 2000 条,Claude Code 的 /slash 命令生态刚过 500 大关——三个月前我带队做一个存量系统的架构重构,团队 8 个人,每人装了十几个 Skill,结果发现真正高频使用的不超过 5 个,剩下的不是互相冲突就是拖慢响应速度。花了两周时间做 Skill 梳理和标准化,构建效率反而比"装满插件"时提升了 40%。

这件事让我意识到一个被忽略的问题:Skill 市场正在经历和十年前 npm 生态一样的野蛮生长期,数量爆炸但质量参差不齐。大多数开发者还停留在"看到推荐就装"的阶段,缺少一套筛选和搭配的方法论。

这篇文章是我踩完坑之后整理的实战手册。

当前三大 Skill 生态的真实现状

我在重构项目中实际使用过三个平台的 Skill 生态,直接说结论和体感。

GitHub Copilot Extensions:大厂背书,但封闭

Copilot 的 Extension 走的是 GitHub Marketplace 的路子,审核严格,质量下限高。我们团队用过的 @docker@azure 这类官方 Extension,调用稳定,响应速度在 2-3 秒。

但问题也明显:你没法自定义它的行为逻辑。比如 @docker 生成的 Dockerfile 默认用 node:18-alpine 作为基础镜像,我们的项目因为依赖了一些 native addon,必须用 node:18-bullseye。在 Copilot 的体系里,你只能在每次对话时手动指定,没有持久化配置的入口。

实测数据:Copilot Extensions 市场上架 800+ 个,其中官方/大厂维护的约 120 个,社区贡献的 680+ 个里,超过 3 个月没更新的占 62%

Cursor Rules:社区驱动,灵活但混乱

Cursor 的 .cursorrules 是目前最灵活的 Skill 机制。它其实就是一个放在项目根目录的文本文件,AI 在每次对话时会自动读取。社区贡献了超过 2000 条 Rules,覆盖从 React 到 Rust 的几乎所有技术栈。

灵活的代价是质量波动大。我在 GitHub 上的 cursor-rules-collection 仓库里随机抽了 50 条 Rules 做测试:

质量等级 占比 典型问题
可直接使用 24%
需要小改 38% 版本过时、规则冲突
基本不可用 38% 过于笼统、互相矛盾

最典型的问题是"大而全"。有个 Star 数很高的 fullstack-nextjs-rules,一个文件写了 400 多行规则,从数据库选型到 CSS 命名规范全塞进去了。结果 AI 在处理简单需求时也要加载全部上下文,响应时间从 1.5 秒飙到 4.8 秒,而且规则之间经常打架——前面说"优先使用 Server Components",后面又说"表单交互统一用客户端组件处理",AI 生成的代码在两种模式之间反复横跳。

Claude Code CLAUDE.md:约定式,上限最高

Claude Code 的方案是在项目根目录放一个 CLAUDE.md 文件,加上 ~/.claude/ 目录下的全局配置。没有市场的概念,完全靠自己写或者从社区搬运。

这个方案看着最原始,用下来上限最高。因为 CLAUDE.md 就是普通的 Markdown,你可以用自然语言描述任何规则,不受 YAML schema 的限制。我们重构项目时最终选了这个方案,把团队规范写成了一份 1200 字的 CLAUDE.md

# 项目上下文
- 这是一个 B2B SaaS 平台的前端,Vue 3.4 + TypeScript 5.4
- 构建工具 Vite 5.x,包管理 pnpm 9.x
- 后端 API 规范遵循 OpenAPI 3.1,接口定义在 /api-specs 目录

# 代码规范(优先级从高到低)
1. 类型安全:禁止 any,联合类型必须穷举处理
2. 组合优于继承:用 composables 代替 mixins
3. 副作用隔离:API 调用只允许出现在 /services 目录

# 重构专用规则
- 改动已有文件时,先跑该文件关联的单元测试
- 单次重构范围不超过 3 个文件
- 如果涉及公共组件改动,必须列出所有调用方

这份文件让 AI 的行为变得可预测。团队成员反馈最多的一句话是:"终于不用每次都教 AI 我们的规矩了。"

五个高频踩坑场景:你装的 Skill 可能正在拖后腿

在重构的两个月里,我们踩过的 Skill 相关的坑,归纳起来就这五类。

坑一:规则冲突导致 AI 输出"精神分裂"

场景是这样的:前端同事小王装了两个 Cursor Rules,一个是 typescript-strict 要求所有函数必须显式声明返回类型,另一个是 react-hooks-best-practice 里有一条"自定义 Hook 让 TypeScript 自动推断返回类型以保持灵活性"。

结果 AI 生成自定义 Hook 时,一会儿加返回类型一会儿不加,同一个文件里风格不统一。小王花了半天排查,一开始还以为是 AI 模型的问题,最后才定位到是两条规则互相打架。

解决方案:我们建了一个规则优先级机制。在 CLAUDE.md 顶部写明:

# 规则冲突处理
当以下规则发生冲突时,按此顺序执行:
1. /CLAUDE.md 中的显式规则(本文件)
2. 当前文件所在目录的 .claude 局部规则
3. 全局 ~/.claude/CLAUDE.md 规则
同一层级内,后声明的规则覆盖先声明的规则。

坑二:过时的 Skill 引入废弃 API

这个坑最隐蔽。我们用了一个社区 Star 数 1.2k 的 vue3-composition-api-rules,看起来很专业,实际上里面有几条规则还在引导 AI 使用 @vue/composition-api 这个 Vue 2 时代的兼容包。生成的代码能跑,但引入了完全不必要的依赖,而且和 Vue 3.4 的某些响应式优化冲突,导致 watchEffect 的触发次数异常。

排查过程:先是发现某个页面的接口被重复调用了 3 次,用 Vue DevTools 追踪 reactivity 链路,发现 watchEffect 在不该触发的时候触发了。最后 git blame 发现是 AI 生成的代码里多了一层 toRefs 的不必要转换,而这个写法正是那个过时 Skill 里推荐的模式。

解决方案:对每个引入的 Skill,做一次版本审计。我写了一个简单的检查脚本:

#!/bin/bash
# check-skill-freshness.sh
# 检查 Skill 文件中引用的包名是否在 package.json 中存在
SKILL_FILE=$1
grep -oP '`[a-z@][a-z0-9@/-]*`' "$SKILL_FILE" | tr -d '`' | while read pkg; do
  if ! grep -q "\"$pkg\"" package.json 2>/dev/null; then
    echo "[WARN] Skill 引用了项目中不存在的包: $pkg"
  fi
done

坑三:Skill 堆叠导致上下文溢出

AI 模型的上下文窗口是有限的。当你同时加载了 5 个 Skill,每个 Skill 有 300-500 token 的规则描述,光 Skill 就占掉了 1500-2500 token。对于 Claude 这种 200k 上下文的模型来说似乎不多,但问题在于——Skill 规则在每一轮对话中都会重复注入。我们做过一个测试:一个包含 8 轮对话的重构任务,加载 5 个 Skill 和不加载 Skill 的对比:

指标 无 Skill 5 个 Skill
平均响应时间 1.8s 3.2s
最终上下文占用 12k tokens 31k tokens
AI 忘记前文概率 中(第6轮开始遗忘)

说人话就是:Skill 不是免费的,它在持续消耗你的上下文预算。装得越多,AI 能记住你前面说的话就越少。

解决方案:精简到只保留 3 个以内的核心 Skill,其余需求用单次 prompt 注入。我们团队最后收敛到这个配置——一个项目级 CLAUDE.md(约 800 token)、一个全局代码风格规则(约 300 token)、一个重构专用流程规则(约 400 token),总计不超过 1500 token。

坑四:Skill 写了"做什么",没写"不做什么"

这条反直觉,但踩过坑的人都知道:告诉 AI 不要做什么,比告诉它要做什么更重要

我们有个规则写的是"使用 Zustand 状态管理"一下。AI 确实用了 Zustand,但它同时在某些组件里用了 useState 管理本该放在 store 里的状态。从字面上看,它没违反规则——规则说的是"使用 Zustand",没说"不能用 useState 管应该共享的状态"。

改成下面这个写法之后问题消失了:

# 状态管理
- 跨组件共享的状态:必须使用 Zustand store,文件放在 /stores 目录
- 组件内部 UI 状态(如 modal 开关、表单输入):使用 useState
- 禁止:在组件内用 useState 管理需要跨组件共享的业务数据
- 禁止:在一个组件中直接修改另一个 store 的状态,必须通过 action

经验提炼:写 Skill 规则的时候,用"情景-指令-禁止"三段式。先描述适用场景,再给出要怎么做,最后明确列出不允许做什么。规则的精确度直接决定 AI 输出的可控性。

坑五:盲目搬运大V推荐,水土不服

这个不需要太多技术分析,直接说现象:团队里有同事看到某个技术博主推荐了一套 Cursor Rules,说"用了之后效率翻倍",直接整个 copy 到项目里。那套 Rules 是给 Next.js 14 App Router + Prisma + tRPC 技术栈写的。

这就像拿着北京地铁图在上海坐地铁一样——工具是好工具,但前提是适配你的场景。

解决方案:所有外部引入的 Skill,必须经过"本地化"改造,核心步骤就三个:删掉和当前技术栈无关的规则、把示例代码换成自己项目的真实代码片段、加上项目特有的约束条件。

精选 Skill 推荐:按场景分类的实战清单

下面这份清单是从我们实际使用过的 Skill 中筛出来的,筛选标准就两条:用了之后确实减少了重复劳动,以及不引入额外的认知负担

场景一:代码审查与质量把关

推荐 Skill:code-review-checklist

这是我在整个重构中使用频率最高的一个。它不是一个现成的社区 Skill,而是我们根据团队的 Code Review 标准自己写的。核心思路是让 AI 在生成代码后自动执行一遍 Review:

# Code Review 自检规则
每次生成或修改代码后,自动检查以下项目:
1. 类型安全:是否有隐式 any?联合类型是否穷举?
2. 错误处理:API 调用是否有 try-catch?错误是否上报?
3. 性能:是否有不必要的 re-render?computed 是否被合理使用?
4. 安全:用户输入是否做了 XSS 过滤?SQL 拼接是否用了参数化?
5. 可测试性:函数是否可被单独测试?是否依赖了全局状态?

检查完毕后,在代码末尾以注释形式输出检查结果。
如果有不通过项,不要直接给出代码,先说明问题并提供修改建议。

我们统计了使用这个 Skill 前后的 Code Review 通过率:第一次提交就通过 Review 的比例从 34% 提升到 71%。最大的收益不是 AI 写的代码变好了,而是它帮团队成员养成了"先自查再提交"的习惯。

场景二:重构与迁移

推荐 Skill:safe-refactor-guard

重构最怕的不是改错,是改漏。这个 Skill 的核心是强制 AI 在重构前做影响分析:

# 重构安全规则
当收到重构类指令时,执行以下流程:
1. 先列出当前文件被哪些文件 import
2. 列出改动会影响的公共接口(导出的函数、类型、组件)
3. 如果影响超过 5 个文件,暂停并让用户确认范围
4. 执行改动后,给出需要同步修改的文件清单
5. 如果项目有测试,改动后自动运行相关测试文件

实际效果:在两个月的重构周期里,因为"改了 A 忘了 B"导致的线上 bug 从每周平均 3.2 个降到了 0.4 个。这个 Skill 本身很简单,但它把"人容易忘记的步骤"固化成了 AI 的默认流程。

场景三:API 接口对接

推荐 Skill:api-contract-first

前后端协作中最浪费时间的环节就是接口联调。这个 Skill 让 AI 基于 OpenAPI 规范生成类型安全的请求代码:

# API 对接规则
- 接口定义文件位于 /api-specs/*.yaml
- 生成 API 调用代码时,必须从 spec 文件读取参数类型
- 请求函数统一放在 /services 目录,按业务模块分文件
- 返回类型必须和 spec 中的 response schema 完全一致
- 错误处理:HTTP 4xx 走业务错误处理,5xx 走全局兜底
- 禁止手写接口 URL,必须从统一常量文件导入

搭配这个 Skill 之后,AI 生成的接口调用代码基本可以一次跑通。之前我们每个接口的联调时间平均要 25 分钟(包括改类型、调路径、处理边界情况),现在降到了 8 分钟左右。

场景四:测试生成

推荐 Skill:test-writer-pragmatic

注意名字里的 pragmatic(务实),这是和市面上大部分测试生成 Skill 的核心区别。大部分测试 Skill 会让 AI 追求覆盖率数字,生成一堆测试 undefinednull、空字符串的边界 case。这些测试写了不出错,但也没什么价值。

我们的做法是:

# 测试生成规则
优先级排序(从高到低):
1. 业务核心路径:用户最常触发的操作流程
2. 金额/权限相关:涉及钱和权限的逻辑必须测
3. 已知 bug 回归:修复过的 bug 必须补回归测试
4. 边界条件:仅测试真实场景中可能出现的边界值

不要测的:
- getter/setter、纯展示组件的渲染测试
- 第三方库的功能(那是别人该测的)
- 只为提升覆盖率而存在的测试

这个 Skill 配合 Vitest 使用效果最好。AI 生成的测试用例命中实际 bug 的概率比之前提升了大约 3 倍——不是因为它写了更多测试,而是因为它写的测试更贴近真实的出 bug 场景。

场景五:文档与注释

推荐 Skill:doc-as-code

很多团队的文档和代码是脱节的。这个 Skill 的思路是让 AI 在生成代码的同时维护文档:

# 文档同步规则
- 新增公共函数时,同步更新 /docs/api.md
- 修改组件 Props 时,同步更新组件目录下的 README.md
- 文档格式:一句话说明用途 + 参数表格 + 一个最小使用示例
- 禁止写"待补充""TODO"类占位符,要么写完要么不写

这条规则看着简单,实际用下来我们项目的文档完整度从不到 30% 提升到了 85%。关键在于最后那条"禁止占位符"——它逼着 AI(也逼着人)在写代码的当下就把文档补齐,而不是留个 TODO 然后再也不管。

如何构建你自己的 Skill 体系

上面推荐的都是"别人的经验",但 Skill 的价值在于适配你自己的项目。分享一下我们建立团队 Skill 体系的完整过程。

第一步:收集高频 prompt

让团队成员记录一周内和 AI 对话时重复说过的话。我们收集到的 top 5 是:

  1. "我们用 Vue 3 + TS,不要给我 React 代码"(出现 47 次)
  2. "样式用 CSS Modules"(出现 31 次)
  3. "错误处理要用我们封装的 useRequest"(出现 28 次)
  4. "这个接口的类型定义在 /types/api 目录"(出现 22 次)
  5. "测试用 Vitest 不用 Jest"(出现 19 次)

这些重复出现的 prompt 就是你的 Skill 素材。

第三步:版本化管理

Skill 文件必须进 Git。我们在 .git/hooks/pre-commit 里加了一个检查:

#!/bin/bash
# 检查 CLAUDE.md 的改动是否经过团队 Review
CHANGED=$(git diff --cached --name-only | grep "CLAUDE.md")
if [ -n "$CHANGED" ]; then
  echo "检测到 Skill 规则文件改动: $CHANGED"
  echo "请确保已经过团队 Review 后再提交"
  echo "如果已 Review,使用 git commit --no-verify 跳过此检查"
  exit 1
fi

为什么这么严格?因为 Skill 规则的改动影响的是整个团队每个人的 AI 行为。一个人随手改了一条规则,可能让其他 7 个人的 AI 都开始输出不符合规范的代码。这比改公共组件的影响面还大。

第四步:定期淘汰

每两周做一次 Skill 回顾,标准很简单——过去两周有没有人因为这条规则而受益?如果没有,删掉。我们第一次回顾就删掉了 40% 的规则。

好的规则一定是违反 AI 默认行为的。如果一条规则删掉之后 AI 的输出没有变化,那它从一开始就不该存在。

01. Node.js 运行时

01. Node.js 运行时

先别急着背框架。后端第一步,是搞懂 Node.js 为什么能持续处理请求,以及什么代码会把服务拖垮。

Node.js 的核心不是“会写异步”,而是理解这三个东西怎么配合:

  • V8:执行 JavaScript
  • libuv:负责事件循环、线程池、I/O 调度
  • Node 标准库:提供 httpfsstreamnet 等能力

核心认知

  • Node.js 不是“把浏览器里的 JavaScript 搬到后端”。
  • JavaScript 执行通常是单线程的,但 I/O 能并发推进,这也是 Node.js 适合做网络服务的原因。
  • 服务端进程会长期运行,所以稳定性、资源释放和错误处理比页面渲染更重要。

一条请求在 Node.js 里经历了什么

  1. 客户端建立 TCP 连接,发来 HTTP 请求。
  2. Node.js 的网络层收到请求,把它包装成 req / res 对象。
  3. 事件循环调度对应的回调或中间件。
  4. 你的代码可能去查数据库、读文件、访问 Redis。
  5. I/O 完成后,回调被重新放回事件循环继续执行。
  6. 最终写回响应,连接保持或关闭。

要点只有一句:Node.js 可以同时管理很多 I/O,但不能容忍你长时间霸占主线程。

必懂 4 件事

1. Event Loop
  • Node.js 不是一次只处理一个请求,而是依靠事件循环调度大量异步任务。
  • 只要你写了长时间的同步阻塞代码,整个进程都会被卡住。
  • 所以要警惕同步文件操作、超大 JSON 解析、死循环、重 CPU 计算。

最少要知道这些阶段的名字:

  • timers:执行 setTimeout / setInterval
  • pending callbacks
  • poll:等待和处理大部分 I/O 回调
  • check:执行 setImmediate
  • close callbacks

还要额外记住两个“优先队列”:

  • process.nextTick
  • Promise microtask

process.nextTick 和 Promise microtask 都会在阶段切换前优先清空,所以滥用也会饿死 I/O。

console.log('A');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

console.log('B');

典型输出通常是:

A
B
nextTick
promise
timeout / immediate

你不需要死记每次谁先谁后,但必须知道:nextTick 和 Promise 回调优先级高于下一轮普通 I/O。

2. 什么叫阻塞主线程

下面这类代码在浏览器里也许只是卡一下页面,在服务端会直接拖慢所有用户请求:

import express from 'express';

const app = express();

app.get('/block', (_req, res) => {
  let total = 0;

  for (let i = 0; i < 1_000_000_000; i++) {
    total += i;
  }

  res.json({ total });
});

这段代码的坏处不是“写法丑”,而是它在循环期间完全占住了主线程。其他请求即使只是查一个轻量接口,也得排队。

遇到重 CPU 任务时,常见做法有三种:

  • 改算法,减少同步计算量
  • 拆成离线任务或消息队列
  • worker_threads 或独立服务处理计算任务
3. Stream 和 Buffer
  • Buffer 是二进制数据的容器。
  • Stream 是分块处理数据的方式,不必一次性把所有内容读入内存。
  • 文件上传、下载、反向代理、SSE、大模型流式输出都离不开它。

为什么服务端必须重视 Stream:

  • 大文件不能一次性读进内存
  • 上游和下游速度不一致时,需要背压控制
  • 文件、网络、压缩、代理都天然是流式场景

下面是一个标准的下载接口写法:

import express from 'express';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

const app = express();

app.get('/download', async (_req, res, next) => {
  try {
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', 'attachment; filename="report.csv"');

    const fileStream = createReadStream('./files/report.csv');
    await pipeline(fileStream, res);
  } catch (error) {
    next(error);
  }
});

错误写法通常是这样:

const content = await fs.promises.readFile('./files/report.csv');
res.send(content);

文件小时没问题,文件一大、并发一高,内存就会顶上去。

4. 进程与内存
  • 页面卡了可以刷新,服务卡了会影响所有请求。
  • 需要关注内存泄漏、未关闭连接、无限增长的缓存、未处理异常。
  • 最基本的观察项包括:rss、heap、错误日志、请求耗时。

一个最常见的泄漏例子:

import express from 'express';

const app = express();
const leaked: unknown[] = [];

app.get('/leak', (req, res) => {
  leaked.push({
    query: req.query,
    now: Date.now(),
  });

  res.json({ size: leaked.length });
});

只要这个数组不清,进程就会一直涨。真实项目里更隐蔽的版本包括:

  • 全局 Map 缓存从不淘汰
  • 长连接对象没有正确关闭
  • 定时器创建后不清理
  • 每个请求都把大对象挂在全局变量上

可以用最小代码观察内存:

setInterval(() => {
  const memory = process.memoryUsage();

  console.log({
    rssMB: Math.round(memory.rss / 1024 / 1024),
    heapUsedMB: Math.round(memory.heapUsed / 1024 / 1024),
    externalMB: Math.round(memory.external / 1024 / 1024),
  });
}, 5000);

最小实践

  • 写一个接口,用流返回大文件,而不是一次性读进内存。
  • 故意写一个同步阻塞接口,观察并发请求响应时间变差。
  • 打印 process.memoryUsage(),理解进程内存的变化。

常见误区

  • 误以为“异步 = 多线程”。不是,JavaScript 代码执行仍主要在主线程上。
  • 误以为 Promise.all 越多越快。一次把几千个任务并发打出去,可能先把数据库压垮。
  • 误以为 Node.js 不适合所有重任务。准确说法是:它不适合把重 CPU 任务长期放在主线程。

学会的标准

  • 你能解释为什么 fs.readFileSync 在服务端要慎用。
  • 你知道文件上传为什么不该默认整文件进内存。
  • 你知道 Node.js 的问题不只有“代码慢”,也可能是阻塞、资源泄漏和并发放大。

前端性能优化解决方案

前言

任何一个项目,随着开发迭代的增加,都离不开前端性能优化这一块,

宇宙第一定律:“熵增定律”。任何东西都会从有序走向无序,我们能做的只是减缓这个无序化过程。

性能优化恰恰就是减缓利器。

什么是前端性能优化?

前端性能优化‌是指通过一系列技术手段和策略,提升网页或 Web 应用的‌加载速度‌、‌渲染效率‌和‌交互响应能力‌,从而改善用户体验、降低跳出率、提升转化率与 SEO 表现的过程。

为什么要做前端性能优化?

  • 用户体验直接影响业务指标‌:

    • BBC 发现:页面加载时间每增加 1 秒,用户流失率上升 10%。
    • Pinterest:加载时间减少 40%,搜索和注册数提升 15%。
    • Google 数据:移动网页加载超过 3 秒,53% 的用户会离开 ‌48。
  • 搜索引擎排名因素‌:核心 Web 指标(如 LCP、CLS、FID)已成为 Google 排名依据之一 ‌4。

  • 成本节约‌:减少资源传输量可降低带宽与服务器压力 ‌2。

思考分析

知道了前端优化是什么?以及为什么要做前端优化?就可以思考下应该怎么做?

先看看整个工作过程

  • 首先我们项目打包完成后是放在服务器
  • 然后客户端发送请求,服务器把项目通过网络传输给浏览器
  • 最后浏览器加载项目

这个过程是不是和网购很像,仓库囤货,用户下单,仓库发货,快递运输,到达用户手里。

这里可以类比思想来分析,殊途同归。

  • 仓库对应服务器
  • 快递对应网络传输
  • 浏览器对应用户
  • 项目对应货物

仓库都做了些啥?

  1. 让仓库离用户更近,就近发货
  2. 仓库更大,类目更齐全,吞吐量更大

快递做了啥?

  1. 飞机送快递
  2. 扩大快递员规模

用户能做啥?

  1. 地址更具体清晰
  2. 提前选择合适的收件方案,收件时间

货物能做啥?

  1. 分类处理
  2. 压缩处理
  3. 预处理,提前加工

解决方案

通过上面思考分析,可以把前端性能优化分为3部分:

第一部分,服务器端优化

第二部分,传输优化

第三部分,浏览器优化

第四部分,项目优化

服务器端优化

通过仓库离用户更近,可以联想到分布式网络架构,也就是CDN

1. 部署CDN(内容分发网络)

CDN是服务器端性能优化的首选方案‌。它通过将静态资源(如JS、CSS、图片)缓存到全球分布的边缘节点,使用户能从离自己最近的节点获取资源,显著缩短加载时间。

  • 效果‌:可使首屏渲染时间(FCP)缩短40%-60%,页面加载延迟降低50%以上。

  • 实践建议‌:

    • 将静态资源托管至CDN,配置合理的缓存策略(如Cache-Control: max-age=31536000)。
    • 使用独立域名(如static.example.com)存放静态资源,避免携带主域Cookie,减少请求体积。

2. 启用服务器压缩(Gzip/Brotli)

在服务器端对传输资源进行压缩,能大幅减小文件体积,提升传输效率。

  • Gzip‌:广泛支持,可压缩HTML、CSS、JS等文本资源,压缩率约70%。
  • Brotli‌(.br):压缩率比Gzip高15%-25%,尤其适合文本资源,推荐在支持的服务器上启用。

建议操作:在Nginx或Apache中配置压缩模块,并结合构建工具预压缩资源以降低CPU开销。

3. 服务端渲染(SSR)

SSR能显著提升首屏加载速度和SEO表现‌。服务器提前将页面渲染为HTML字符串返回,用户无需等待JavaScript下载和执行即可看到内容。

  • 适用场景‌:内容型网站(如新闻、电商)、SEO敏感页面。
  • 注意事项‌:SSR会增加服务器计算负载,需合理设计缓存策略(如页面级缓存)以平衡性能。

4. 优化服务器配置与协议

  • 启用HTTP/2或HTTP/3‌:支持多路复用、头部压缩,减少连接开销,提升资源并行加载效率。
  • 使用负载均衡‌:在高并发场景下,通过负载均衡分散请求压力,提升系统可用性。
  • 配置长连接(Keep-Alive) ‌:减少TCP握手次数,提升连续资源请求的效率。

5. 动态资源加速与边缘计算

现代CDN支持在边缘节点处理动态内容,如:

  • 对API请求进行缓存或限流。
  • 在边缘节点完成用户鉴权等轻量逻辑,减少回源次数,降低后端负载。

传输优化

传输其实就是http请求,从传输层面优化前端性能,核心是‌减少数据传输耗时与网络延迟‌,让资源更快抵达用户设备。

先来分析下http的不足,然后思考如何改善优化?给出解决方案,最后总结

HTTP/1.1 作为曾经广泛使用的 Web 协议版本,虽然在当时显著提升了 Web 通信效率,但其设计也带来了一些性能瓶颈和限制,尤其是在高并发、高延迟或资源密集型的现代 Web 应用场景中。

1. 队头阻塞(Head-of-Line Blocking)

这是 HTTP/1.1 最核心的性能问题之一。

  • 成因‌:HTTP/1.1 使用持久连接(Keep-Alive),允许在一个 TCP 连接上发送多个请求,但‌响应必须按照请求的顺序返回‌。如果第一个请求处理缓慢,后续所有请求的响应都会被阻塞。
  • 影响‌:在高延迟网络中,一个慢响应会显著拖慢整个页面的加载速度,导致用户体验下降。例如,当浏览器请求一个包含多张图片的页面时,如果第一张图片加载缓慢,其他图片的加载也会被延迟。
  • 解决方案‌:HTTP/2 引入了‌多路复用‌(Multiplexing),允许在单个连接上并行发送多个请求和响应,从而消除了队头阻塞问题。

2. 头部冗余与未压缩

HTTP/1.1 每个请求和响应都携带完整的头部信息,且未进行压缩,导致不必要的带宽消耗。

  • 问题‌:例如,每次请求都必须重复发送 HostUser-AgentAccept 等字段,即使这些信息在多个请求中是相同的。
  • 影响‌:在频繁请求的场景下,这些冗余头部会显著增加传输数据量,尤其在移动端或低带宽环境下影响明显。
  • 解决方案‌:HTTP/2 引入了‌头部压缩机制‌(HPACK 算法),大幅减少了头部传输的体积。

3. 单连接限制

尽管 HTTP/1.1 支持持久连接,但‌每个连接只能处理一个请求‌,在高并发场景下仍需建立多个连接,增加了资源开销。

  • 问题‌:浏览器通常会为单个域名开启多个 TCP 连接(如 6 个),但这仍会导致:

    • 增加 TCP 连接建立和关闭的开销(三次握手、四次挥手)。
    • 服务器需要维护多个连接状态,增加内存和 CPU 负载。
    • TLS 握手带来的额外延迟(HTTPS 场景下)。
  • 解决方案‌:HTTP/2 通过‌多路复用‌,在一个连接上并发处理多个请求,避免了多个连接的开销。

4. 缺乏服务器推送

HTTP/1.1 不支持服务器主动推送资源,客户端必须先请求资源,服务器才能响应。

  • 问题‌:这导致了不必要的往返延迟。例如,服务器知道页面需要 CSS 和 JS 文件,但必须等待客户端请求后才发送,浪费了时间。
  • 解决方案‌:HTTP/2 引入了‌服务器推送‌(Server Push),允许服务器在客户端请求之前主动推送资源,提升加载速度。

5. 文本协议格式

HTTP/1.1 使用纯文本格式传输数据,解析效率低,且容易受到安全攻击。

  • 问题‌:文本格式需要解析器进行逐字符处理,效率不如二进制格式。
  • 解决方案‌:HTTP/2 采用‌二进制分帧‌(Binary Framing),将数据拆分为更小的帧,提高解析效率和安全性。

总结

升级传输协议(HTTP/2 或 HTTP/3)

先进协议能显著提升并发传输效率,减少连接开销‌。

  • HTTP/2‌:

    • 支持‌多路复用‌,多个请求可在同一连接并行传输,解决HTTP/1.1的“队头阻塞”。
    • 支持‌头部压缩‌(HPACK算法),减少请求头体积。
    • 可启用‌服务器推送‌(Server Push),提前推送关键资源(如CSS、JS)。
  • HTTP/3‌(基于QUIC):

    • 基于UDP协议,实现‌真正的多路复用‌,丢包不影响其他流。
    • 连接建立更快,支持‌0-RTT快速重连‌,特别适合移动端和弱网环境。

建议:优先启用HTTP/2,条件允许时逐步迁移至HTTP/3,尤其适用于高交互、多资源加载的Web应用。

浏览器优化

从浏览器出发优化前端性能,核心是‌利用浏览器自身机制提升资源加载效率与渲染流畅度‌,减少用户可见的等待时间与交互卡顿。

1. 合理利用浏览器缓存

缓存是减少重复请求、加速二次访问的核心手段‌。通过设置合适的HTTP缓存头,让静态资源直接从本地读取。

  • 强缓存‌:设置 Cache-Control: max-age=31536000,配合文件哈希(如app.a1b2c3.js)实现长期缓存。
  • 协商缓存‌:对HTML等动态内容使用 ETag 或 Last-Modified,由服务器判断是否更新。
  • Service Worker 缓存‌:可实现离线访问和精细缓存控制,适用于PWA应用。

实践建议:将JS、CSS、图片等静态资源纳入缓存策略,避免每次访问都回源请求。

2. 利用现代API提升效率

Vue、React其实就用到了这些新的API,React的Fiber架构其实就是用到requestIdleCallback,将渲染任务拆分成一个个小的时间切片,在浏览器空闲时,在加载。

使用高性能浏览器API替代传统实现方式‌。

  • ‌**IntersectionObserver**‌:替代 scroll 事件监听,实现高效的懒加载与可视区域检测,滚动性能提升70%以上。
  • ‌**ResizeObserver**‌:异步监听元素尺寸变化,避免手动计算带来的性能损耗。
  • ‌**requestIdleCallback**‌:在浏览器空闲时执行非关键任务,避免影响关键渲染。

项目优化

从项目出发优化前端性能,核心是‌将性能优化融入开发流程与工程体系‌,实现从编码、构建到部署的全链路提效。这不仅是技术手段的组合,更是开发规范与协作模式的升级。

1. 代码层面:提升可维护性与执行效率

高质量代码是性能优化的基石‌,从源头减少性能隐患。

  • 组件级优化(React/Vue) ‌:

    • 使用 React.memouseMemouseCallback 避免不必要渲染。
    • Vue 中利用 v-memo 和响应式优化减少依赖追踪开销。
  • 避免重排与重绘‌:

    • 动画优先使用 transform 和 opacity,触发GPU合成。
    • 批量修改样式,避免“读-写-读”布局抖动。
  • 事件优化‌:

    • 对 scrollresize 等高频事件使用防抖(debounce)或节流(throttle)。
    • 使用事件委托减少监听器数量,提升内存效率。

2. 构建优化:减小体积、加速打包

构建阶段是性能增益的关键环节‌,直接影响资源加载速度。

  • 代码分割(Code Splitting) ‌:

    • 路由懒加载:React.lazy + Suspense 实现按需加载,首屏JS体积减少50%以上。
    • 组件懒加载:非首屏复杂组件(如图表、编辑器)动态引入。
  • Tree Shaking‌:

    • 确保使用 ES Module 语法,配置 sideEffects: false 剔除未使用代码。
  • 依赖分包与共享‌:

    • 使用 SplitChunksPlugin 将 React、Vue、Lodash 等公共依赖提取为 vendor 包,利用浏览器缓存复用。
    • 微前端场景下,通过 Module Federation 实现跨应用依赖共享。
  • 构建工具调优‌:

    • 升级至 Vite,利用 ESBuild 加速编译,热更新进入毫秒级。
    • Webpack 启用持久化缓存,提升重复构建速度。

3. 资源与加载优化:提升首屏体验

控制资源加载节奏,优先保障核心内容展示‌。

  • 图片与静态资源优化‌:

    • 使用 WebP/AVIF 格式,体积比 JPG/PNG 减少30%-50%。
    • 图标优先使用 SVG 或字体图标,避免小图HTTP请求。
  • 预加载与预读取‌:

    • <link rel="preload"> 提前加载首屏关键字体、CSS、JS。
    • <link rel="prefetch"> 空闲时预读下一页资源,提升跳转速度。
  • 懒加载(Lazy Loading) ‌:

    • 图片/视频使用 loading="lazy" 原生支持。
    • 长列表采用虚拟滚动(如 react-window),内存占用减少90%以上。

4. 工程化与持续优化机制

性能优化不是一次性任务,而是持续的工程实践‌。

  • 性能预算(Performance Budget) ‌:

    • 在 CI/CD 中设定 LCP ≤ 1.2s、首屏JS ≤ 200KB 等硬性指标,超标则阻断发布。
  • 自动化监控与分析‌:

    • 集成 Lighthouse 审计,定期生成性能报告。
    • 使用 Web Vitals 监控真实用户性能数据(如 FCP、LCP、CLS)。
  • 骨架屏与加载反馈‌:

    • 首屏复杂页面使用骨架屏,降低用户感知延迟,提升体验流畅度。

写在最后

我是凉城a,一个前端,热爱技术也热爱生活。

与你相逢,我很开心。

如果你想了解更多,请点这里,期待你的小⭐⭐

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌

语法全景对照

PHP、Go、JavaScript (ES6+) 核心语法全景对照指南

第一部分:变量声明与基础数据类型

三种语言在变量作用域、类型检查机制上有着本质区别:

  • PHP:弱类型(但 PHP 7/8 引入了强类型声明),变量以 $ 开头,作用域通常在函数内。
  • Go:静态强类型,编译型语言,严格区分类型,支持类型推导 :=,包级作用域与块级作用域。
  • JavaScript:弱类型,动态脚本语言,推荐使用 letconst 实现块级作用域。

1. PHP 实现

<?php
declare(strict_types=1); // 开启严格类型模式

// 1. 变量与常量声明
$username = "Alice"; // 字符串
$age = 25;           // 整型
$balance = 100.50;   // 浮点型
$isActive = true;    // 布尔型
$data = null;        // 空值

// 常量
define('MAX_LOGIN_ATTEMPTS', 5);
const APP_VERSION = "1.0.0";

// 2. 字符串操作
$greeting = "Hello, $username!"; // 双引号支持变量解析
$concat = 'Age: ' . $age;        // 单引号不支持解析,使用 . 拼接

// 3. 类型声明 (PHP 7.4+)
function printUserInfo(string $name, int $age, ?float $bal): void {
    echo "User: {$name}, Age: {$age}, Balance: " . ($bal ?? 0.0) . "\n";
}

printUserInfo($username, $age, $balance);

2. Go 实现

package main

import "fmt"

// 1. 包级变量与常量声明
const MaxLoginAttempts int = 5
const AppVersion = "1.0.0" // 无类型常量,根据上下文推导

var globalConfig string = "default"

func variablesDemo() {
    // 2. 局部变量声明
    var username string = "Alice"
    var age int = 25
    var balance float64 = 100.50
    var isActive bool = true
    
    // 短变量声明 (仅限函数内部)
    data := "Some Data" // 自动推导为 string
    
    // Go 中没有 null,只有各类型的零值 (nil 适用于指针、切片、映射、接口等)
    var ptr *int = nil 

    // 3. 字符串操作
    // Go 字符串不可变,支持双引号和反引号(多行)
    greeting := fmt.Sprintf("Hello, %s!", username)
    concat := "Age: " + fmt.Sprint(age)

    printUserInfo(username, age, balance)
    fmt.Println(greeting, concat, isActive, data, ptr)
}

// 4. 函数参数严格定型
func printUserInfo(name string, age int, bal float64) {
    fmt.Printf("User: %s, Age: %d, Balance: %.2f\n", name, age, bal)
}

3. JavaScript 实现

// 1. 变量与常量声明 (ES6+)
const MAX_LOGIN_ATTEMPTS = 5; // 常量,不可重新赋值
const APP_VERSION = "1.0.0";

let username = "Alice"; // 块级作用域变量
let age = 25;           // Number 类型 (JS 中不区分整型和浮点型)
let balance = 100.50;   // Number 类型
let isActive = true;    // Boolean
let data = null;        // Null 类型
let notDefined;         // Undefined 类型

// 2. 字符串操作
// 模板字符串 (Template Literals),支持多行和表达式插值
let greeting = `Hello, ${username}!`; 
let concat = 'Age: ' + age;

// 3. 弱类型函数 (可以使用 TypeScript 增加强类型)
function printUserInfo(name, age, bal) {
    // 使用 nullish coalescing operator (??) 赋默认值
    console.log(`User: ${name}, Age: ${age}, Balance: ${bal ?? 0.0}`);
}

printUserInfo(username, age, balance);

第二部分:复合数据结构(数组、字典、切片)

  • PHP:天下无敌的 Array(实际上是有序哈希表),既当列表又当字典。
  • Go:严格区分 Array(定长)、Slice(动态切片)和 Map(哈希表)。
  • JavaScript:区分 Array(动态列表)和 Object/Map(键值对)。

1. PHP 的万能数组

<?php
// 1. 索引数组 (List)
$fruits = ["Apple", "Banana", "Orange"];
$fruits[] = "Mango"; // 追加元素
array_push($fruits, "Grape");

// 2. 关联数组 (Map/Dictionary)
$user = [
    "id" => 101,
    "username" => "bob_smith",
    "email" => "bob@example.com",
    "roles" => ["admin", "editor"] // 嵌套数组
];

// 添加/修改键值对
$user["status"] = "active";

// 3. 数组遍历
foreach ($user as $key => $value) {
    if (is_array($value)) {
        echo "$key: " . implode(", ", $value) . "\n";
    } else {
        echo "$key: $value\n";
    }
}

// 4. 常用数组操作
$keys = array_keys($user);
$hasEmail = array_key_exists("email", $user);
$filtered = array_filter($fruits, fn($f) => strlen($f) > 5); // 闭包过滤

2. Go 的 Slice 与 Map

package main

import (
    "fmt"
    "strings"
)

func dataStructuresDemo() {
    // 1. 数组 (Array) - 长度固定,较少直接使用
    var arr [3]string = [3]string{"Apple", "Banana", "Orange"}

    // 2. 切片 (Slice) - 动态数组,最常用
    fruits := []string{"Apple", "Banana", "Orange"}
    fruits = append(fruits, "Mango", "Grape") // 追加元素

    // 3. 映射 (Map) - 键值对,必须使用 make 初始化或字面量初始化
    user := map[string]interface{}{ // 使用 interface{} 支持不同类型的值
        "id":       101,
        "username": "bob_smith",
        "email":    "bob@example.com",
        "roles":    []string{"admin", "editor"},
    }

    // 添加/修改键值对
    user["status"] = "active"

    // 4. 遍历
    for key, value := range user {
        // 类型断言 (Type Assertion)
        if roles, ok := value.([]string); ok {
            fmt.Printf("%s: %s\n", key, strings.Join(roles, ", "))
        } else {
            fmt.Printf("%s: %v\n", key, value)
        }
    }

    // 5. 检查键是否存在
    email, exists := user["email"]
    if exists {
        fmt.Println("Email found:", email)
    }
}

3. JavaScript 的 Array 与 Object

// 1. 数组 (Array)
const fruits = ["Apple", "Banana", "Orange"];
fruits.push("Mango", "Grape"); // 追加元素

// 2. 对象 (Object) - 作为字典使用
const user = {
    id: 101,
    username: "bob_smith",
    email: "bob@example.com",
    roles: ["admin", "editor"]
};

// 添加/修改键值对
user.status = "active";
user["last_login"] = "2023-10-01";

// 3. 遍历对象
for (const [key, value] of Object.entries(user)) {
    if (Array.isArray(value)) {
        console.log(`${key}: ${value.join(", ")}`);
    } else {
        console.log(`${key}: ${value}`);
    }
}

// 4. 常用高级操作 (ES6 数组方法)
const keys = Object.keys(user);
const hasEmail = "email" in user;
// 链式调用
const longFruits = fruits
    .filter(f => f.length > 5)
    .map(f => f.toUpperCase());

第三部分:函数、闭包与高阶特性

1. PHP 函数特性

<?php
// 1. 默认参数与可变参数
function buildQuery(string $table, array $conditions = [], string ...$fields): string {
    $select = empty($fields) ? "*" : implode(", ", $fields);
    $sql = "SELECT {$select} FROM {$table}";
    if (!empty($conditions)) {
        $sql .= " WHERE " . http_build_query($conditions, '', ' AND ');
    }
    return $sql;
}

// 2. 匿名函数与闭包 (使用 use 关键字引入外部变量)
$multiplier = 3;
$calculate = function (int $number) use ($multiplier): int {
    return $number * $multiplier;
};

// 3. 箭头函数 (PHP 7.4+,单行,自动捕获外部变量)
$calculateArrow = fn(int $number) => $number * $multiplier;

// 4. 命名参数 (PHP 8.0+)
$query = buildQuery(
    fields: "id", "name",
    table: "users",
    conditions: ["status" => 1]
);

2. Go 函数特性

package main

import (
    "fmt"
    "strings"
)

// 1. 多返回值与可变参数
func buildQuery(table string, conditions map[string]interface{}, fields ...string) (string, error) {
    if table == "" {
        return "", fmt.Errorf("table name cannot be empty")
    }
    
    selectFields := "*"
    if len(fields) > 0 {
        selectFields = strings.Join(fields, ", ")
    }
    
    sql := fmt.Sprintf("SELECT %s FROM %s", selectFields, table)
    // 省略复杂的 conditions 拼接逻辑...
    
    return sql, nil
}

func functionDemo() {
    // 2. 匿名函数与闭包 (自动捕获外部变量,无需类似 PHP 的 use)
    multiplier := 3
    calculate := func(number int) int {
        return number * multiplier
    }
    
    fmt.Println(calculate(10)) // 30

    // 3. 延迟执行 defer (Go 独有,常用于资源清理)
    defer fmt.Println("This runs at the end of functionDemo")
    
    // 4. 处理多返回值
    query, err := buildQuery("users", nil, "id", "name")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println(query)
}

3. JavaScript 函数特性

// 1. 默认参数与剩余参数 (Rest parameters)
function buildQuery(table, conditions = {}, ...fields) {
    const selectFields = fields.length === 0 ? "*" : fields.join(", ");
    let sql = `SELECT ${selectFields} FROM ${table}`;
    
    const condKeys = Object.keys(conditions);
    if (condKeys.length > 0) {
        const where = condKeys.map(k => `${k}=${conditions[k]}`).join(" AND ");
        sql += ` WHERE ${where}`;
    }
    return sql;
}

// 2. 匿名函数赋值
const calculate = function(number) {
    return number * multiplier; // 依赖外部作用域变量
};

// 3. 箭头函数 (Arrow Functions, 不绑定自己的 this)
let multiplier = 3;
const calculateArrow = (number) => number * multiplier;

// 4. 解构赋值传参
function processUser({ id, username, roles = [] }) {
    console.log(`Processing ${username} (ID: ${id}) with roles: ${roles}`);
}

const userObj = { id: 1, username: "admin", email: "a@a.com" };
processUser(userObj); // 只提取需要的字段

第四部分:面向对象 (OOP) 与结构体

  • PHP:经典的基于类的单继承 OOP(Class, Interface, Abstract, Trait)。
  • Go:没有 Class 和继承。通过 Struct(结构体)封装数据,通过给结构体绑定方法实现行为,通过 Interface(鸭子类型)实现多态,通过结构体嵌套实现组合。
  • JavaScript:基于原型链(Prototype)。ES6 引入了 class 语法糖。

1. PHP 的经典 OOP

<?php

// 接口定义契约
interface LoggerInterface {
    public function log(string $message): void;
}

// Trait 代码复用机制
trait TimestampTrait {
    protected function getTimestamp(): string {
        return date('Y-m-d H:i:s');
    }
}

// 抽象类
abstract class BaseService {
    protected LoggerInterface $logger;
    
    public function __construct(LoggerInterface $logger) {
        $this->logger = $logger;
    }
    
    abstract public function execute(): bool;
}

// 具体实现类
class PaymentService extends BaseService {
    use TimestampTrait;

    // PHP 8.0 构造器属性提升
    public function __construct(
        LoggerInterface $logger,
        private float $amount
    ) {
        parent::__construct($logger);
    }

    public function execute(): bool {
        $time = $this->getTimestamp();
        $this->logger->log("[{$time}] Processing payment of {$this->amount}");
        return true;
    }
}

// 匿名类实现接口
$consoleLogger = new class implements LoggerInterface {
    public function log(string $message): void {
        echo "CONSOLE: $message\n";
    }
};

$service = new PaymentService($consoleLogger, 99.99);
$service->execute();

2. Go 的结构体与接口 (组合与鸭子类型)

package main

import (
    "fmt"
    "time"
)

// 1. 接口定义 (Go 的接口是隐式实现的)
type Logger interface {
    Log(message string)
}

// 2. 结构体 (代替类)
type ConsoleLogger struct {
    Prefix string
}

// 3. 为结构体绑定方法 (实现 Logger 接口)
// 只要实现了 Log 方法,它就是 Logger
func (c *ConsoleLogger) Log(message string) {
    fmt.Printf("%s: %s\n", c.Prefix, message)
}

// 4. 基础服务结构体
type BaseService struct {
    logger Logger // 依赖注入
}

// 5. 具体服务结构体 (通过嵌套实现类似继承的"组合")
type PaymentService struct {
    BaseService // 匿名嵌套,继承了 BaseService 的字段
    Amount      float64
}

// 为 PaymentService 定义方法
func (p *PaymentService) Execute() bool {
    timestamp := time.Now().Format("2006-01-02 15:04:05")
    msg := fmt.Sprintf("[%s] Processing payment of %.2f", timestamp, p.Amount)
    // 调用嵌套结构体中的 logger
    p.logger.Log(msg)
    return true
}

func oopDemo() {
    logger := &ConsoleLogger{Prefix: "SYS_LOG"}
    
    service := &PaymentService{
        BaseService: BaseService{logger: logger},
        Amount:      99.99,
    }
    
    service.Execute()
}

3. JavaScript 的 Class 语法糖

// JS 没有内置的 Interface,通常靠文档或 TypeScript 约束

// 1. 定义类
class BaseService {
    // 私有字段 (ES2022+)
    #logger;

    constructor(logger) {
        this.#logger = logger;
    }

    // Getter
    get logger() {
        return this.#logger;
    }

    // 抛出错误模拟抽象方法
    execute() {
        throw new Error("Method 'execute()' must be implemented.");
    }
}

// 2. 继承
class PaymentService extends BaseService {
    #amount;

    constructor(logger, amount) {
        super(logger); // 必须调用 super
        this.#amount = amount;
    }

    // 私有方法
    #getTimestamp() {
        return new Date().toISOString();
    }

    // 方法重写
    execute() {
        const time = this.#getTimestamp();
        this.logger.log(`[${time}] Processing payment of ${this.#amount}`);
        return true;
    }
}

// 3. 对象字面量实现依赖 (鸭子类型)
const consoleLogger = {
    log: function(message) {
        console.log(`CONSOLE: ${message}`);
    }
};

const service = new PaymentService(consoleLogger, 99.99);
service.execute();

第五部分:错误与异常处理

1. PHP (Try-Catch)

<?php
class CustomDatabaseException extends Exception {}

function connectDB(string $host) {
    if (empty($host)) {
        // 抛出异常
        throw new CustomDatabaseException("Host cannot be empty");
    }
    // 模拟连接成功
    return true;
}

try {
    connectDB("");
} catch (CustomDatabaseException $e) {
    error_log("DB Error: " . $e->getMessage());
} catch (Exception $e) {
    // 捕获其他所有异常
    error_log("General Error: " . $e->getMessage());
} finally {
    // 无论是否报错都会执行,常用于释放资源
    echo "Cleanup resources.\n";
}

2. Go (Error 值返回与 Panic)

Go 不推荐使用类似 try-catch 的控制流,而是将错误作为普通的返回值处理。

package main

import (
    "errors"
    "fmt"
)

// 定义自定义错误变量
var ErrEmptyHost = errors.New("host cannot be empty")

func connectDB(host string) (bool, error) {
    if host == "" {
        // 返回错误值
        return false, ErrEmptyHost
    }
    return true, nil
}

func errorDemo() {
    success, err := connectDB("")
    
    // 显式检查错误 (Go 的标志性写法)
    if err != nil {
        // 错误判定 (Go 1.13+)
        if errors.Is(err, ErrEmptyHost) {
            fmt.Println("DB Error: Provided host is empty.")
        } else {
            fmt.Println("Unknown Error:", err)
        }
        return
    }
    
    fmt.Println("Connected:", success)
    
    // Go 中的 Panic/Recover 仅用于极其严重的不可恢复错误
    // 类似于 try-catch,但不应作为常规业务逻辑
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()
    // panic("Critical failure!") 
}

3. JavaScript (Try-Catch)

class CustomDatabaseError extends Error {
    constructor(message) {
        super(message);
        this.name = "CustomDatabaseError";
    }
}

function connectDB(host) {
    if (!host) {
        throw new CustomDatabaseError("Host cannot be empty");
    }
    return true;
}

try {
    connectDB("");
} catch (error) {
    if (error instanceof CustomDatabaseError) {
        console.error("DB Error:", error.message);
    } else {
        console.error("General Error:", error);
    }
} finally {
    console.log("Cleanup resources.");
}

第六部分:并发与异步处理 (核心差异)

这是三种语言差异最大的地方:

  • PHP:传统模型是同步阻塞的(多进程模型,如 PHP-FPM),每次请求一个进程。
  • Go:天生为并发设计。使用轻量级的 goroutinechannel 进行通信。
  • JavaScript:单线程事件循环。使用回调、Promiseasync/await 处理非阻塞 I/O。

1. PHP (同步阻塞)

<?php
// PHP 原生核心不支持非阻塞异步(不借助 Swoole/ReactPHP 等扩展)
function fetchData(string $url): string {
    sleep(2); // 模拟耗时网络请求,这里会阻塞整个进程
    return "Data from $url";
}

echo "Start\n";
$data1 = fetchData("API_1"); // 阻塞 2 秒
$data2 = fetchData("API_2"); // 阻塞 2 秒
echo "End: $data1, $data2\n"; // 总耗时 4 秒

2. Go (Goroutines 与 Channels)

Go 语言可以通过 go 关键字瞬间启动成千上万个并发任务。

package main

import (
    "fmt"
    "sync"
    "time"
)

// 模拟耗时请求
func fetchData(url string, ch chan<- string, wg *sync.WaitGroup) {
    defer wg.Done() // 函数结束时通知 WaitGroup 完成
    
    time.Sleep(2 * time.Second) // 模拟耗时
    // 将结果发送到通道 Channel
    ch <- fmt.Sprintf("Data from %s", url) 
}

func concurrencyDemo() {
    fmt.Println("Start")
    
    // 创建一个通道用于接收结果
    results := make(chan string, 2)
    // WaitGroup 用于等待所有 goroutine 完成
    var wg sync.WaitGroup
    
    urls := []string{"API_1", "API_2"}
    
    for _, url := range urls {
        wg.Add(1)
        // 开启 Goroutine 并发执行
        go fetchData(url, results, &wg) 
    }
    
    // 开启一个后台 Goroutine 等待所有任务完成并关闭通道
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // 从通道中读取数据(阻塞直到有数据或通道关闭)
    for data := range results {
        fmt.Println("Received:", data)
    }
    
    fmt.Println("End") // 总耗时约 2 秒
}

3. JavaScript (Async / Await 与 Promise)

JS 使用异步非阻塞 I/O,主线程不等待,而是把回调挂起。

// 模拟返回 Promise 的耗时请求
function fetchData(url) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(`Data from ${url}`);
        }, 2000);
    });
}

// 使用 async/await 以同步的代码风格写异步逻辑
async function main() {
    console.log("Start");
    
    try {
        // 并发执行多个 Promise
        const [data1, data2] = await Promise.all([
            fetchData("API_1"),
            fetchData("API_2")
        ]);
        
        console.log(`Received: ${data1}`);
        console.log(`Received: ${data2}`);
    } catch (error) {
        console.error("Async Error:", error);
    }
    
    console.log("End"); // 总耗时约 2 秒
}

main();

总结

  • PHP:围绕 Web 请求生命周期构建,数组操作极其灵活,OOP 体系严谨成熟,适合快速开发传统 Web 后端。
  • Go:静态编译,极简语法,舍弃了传统类继承,以强悍的并发能力(Goroutine/Channel)和极高的运行效率见长,适合微服务和云原生架构。
  • JavaScript:一处编写到处运行,对象和函数高度灵活,以事件循环和异步非阻塞为核心,全栈(Node.js + 前端)开发的霸主。

Node.js/Express 实现 AI 流式输出 (SSE) 踩的坑:为什么客户端会“瞬间断开连接”?

Node.js/Express 实现 AI 流式输出 (SSE) 的深坑:为什么客户端会“瞬间断开连接”?

1. 背景与现象

最近在做一个基于 Node.js 和 Express 的 AI 爆款文案生成器(接入了类似 OpenAI/硅基流动的 API)。为了实现打字机效果,我使用了 Server-Sent Events (SSE) 技术将 AI 的返回结果流式推送到前端。

然而,在测试时遇到了一个极其诡异的 Bug:

  • 后端日志 :每次收到请求后,刚准备推送数据,瞬间就打印出 客户端提前断开连接,终止 AI 请求 。
  • 前端现象 :浏览器 Network 里的请求瞬间变成 Canceled (取消),或者终端用 curl 测试时直接卡死(Hanging),收不到任何数据。
  • 迷惑性 :一开始以为是前端组件刷新导致请求中断,或者网络代理拦截,排查了一圈发现都不是。

2. 罪魁祸首排查过程

问题出在后端代码里判断“客户端是否断开”的逻辑上。在处理长连接时,我们通常需要在客户端断开时停止向外推流,以节省服务器资源。最初的代码是这样的:

// 错误写法 1:依赖 Express 的 req.
closed 属性
for await (const chunk of stream) {
  if (req.closed) { 
    console.log('客户端提前断开');
    break; 
  }
  res.write(`data: ${content}\n\n`);
  res.flush?.(); // 强制刷新
}

或者这样的:

// 错误写法 2:依赖 HTTP 请求的 close 事件
req.on('close', () => {
  console.log('客户端断开');
  isDisconnected = true;
});

为什么会翻车?

  1. req.closed 的假阳性 :在 Express 5 及某些 Node.js 版本中, req.closed 属性在长连接流式响应下非常不可靠。它有时会在请求体(Body)被解析完成或者发送了第一批响应头后, 错误地将状态标记为 true ,导致后端误杀正常的请求。
  2. req.on('close') 提前触发 :同理,HTTP 层的 close 事件有时代表的是“请求接收完毕”,而不是“连接彻底断开”。
  3. res.flush() 杀手 :在没有正确引入压缩中间件的情况下,盲目调用 res.flush() 会破坏底层的 chunked 数据流状态,甚至直接导致底层 Socket 异常关闭。

3. 终极解决指南(正确姿势)

为了完美实现 SSE 并准确监听客户端断开,需要做以下三个关键的调整:

关键点一:放弃 req.closed,监听底层 TCP Socket

不要监听 HTTP 请求层的 close ,而是直接监听最底层的网络 Socket。只有 Socket 关了,才是真的断开了。

let isClientDisconnected = false;

// 正确姿势:监听底层的 socket 断开
req.socket.on('close', () => {
  isClientDisconnected = true;
  console.log('底层 socket 真实断开');
});

关键点二:一次性规范地设置 SSE Header

使用 res.writeHead 一次性下发所有头部,并务必加上 X-Accel-Buffering: no ,这能防止 Nginx 等反向代理层因为缓冲而导致数据卡顿。

// 正确姿势:使用 writeHead 并禁用代理缓冲
res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  'Cache-Control': 'no-cache, no-transform',
  'Connection': 'keep-alive',
  'X-Accel-Buffering': 'no' 
});

关键点三:移除所有手动的 res.flush()

Node.js 只要设置了正确的流式头部,在调用 res.write() 时底层会自动处理数据分块传输(Chunked Encoding),不需要、也不应该再手动调用 res.flush() 。

for await (const chunk of stream) {
  if (isClientDisconnected) break;
  
  const content = chunk.choices[0]?.delta?.content || '';
  if (content) {
    res.write(`data: ${JSON.stringify({ text: content })}\n\n`);
    // 删掉这行:(res as any).flush();
  }
}

4. 总结

在 Node.js 中做大模型流式输出时:

  1. 监听断开请认准 req.socket.on('close') 。
  2. Header 里带上 X-Accel-Buffering: no 。
  3. 相信 Node.js 的流管理,不要乱用 flush() 。

Qt Quick Controls 控件库、样式与布局(八)

适合人群: 已掌握 Qt Quick 基础视觉元素,想使用完整 UI 控件库的开发者

前言

上一篇我们用 RectangleTextMouseArea 手工搭建了 UI 组件。但在实际开发中,按钮、输入框、复选框、滑块这些常见控件不需要从零造——Qt Quick Controls 模块提供了一套完整的、开箱即用的 UI 控件库。

本文系统介绍 Qt Quick Controls 的核心控件、控件解剖结构、内置样式,以及如何用 Layouts 模块管理控件的排列与尺寸。


一、什么是 Qt Quick Controls

QtQuick.Controls 是建立在 Qt Quick 之上的控件模块,提供了:

  • 按钮类:ButtonCheckBoxRadioButtonSwitch
  • 输入类:TextFieldTextAreaSliderSpinBoxComboBox
  • 容器类:GroupBoxFrameScrollViewTabBar
  • 弹窗类:DialogPopupMenuDrawer
  • 导航类:StackViewSwipeViewPageIndicator
  • 显示类:LabelProgressBarBusyIndicator

导入方式:

import QtQuick.Controls

二、控件的解剖结构

理解 Qt Quick Controls 的关键是理解每个控件由哪些部分组成。以 Button 为例:

Button
├── background   ← 背景(Rectangle、图片等)
├── contentItem  ← 内容区域(通常是 Text 或 Icon)
├── indicator    ← 指示器(CheckBox 的勾选框等)
└── overlay      ← 覆盖层(按下时的涟漪效果等)

这个结构意味着你可以单独替换任意部分来自定义外观,而不需要重写整个控件:

Button {
    text: "自定义按钮"

    // 只替换背景,保留其他默认行为
    background: Rectangle {
        radius: 8
        color: parent.pressed ? "#2C72C7" : "#4A90E2"
        border.width: 0
    }

    // 只替换文字样式
    contentItem: Text {
        text: parent.text
        font.pixelSize: 15
        font.bold: true
        color: "white"
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
    }
}

三、内置样式

Qt Quick Controls 提供了几套内置样式,无需任何代码即可改变所有控件的外观。

可用样式

样式名 特点
Basic 极简风格,白色背景,适合自定义的起点
Fusion 跨平台桌面风格,类 Qt Widgets 外观
Material Google Material Design 风格
Universal Windows Universal 风格
iOS Apple iOS 风格(需在 iOS 平台)
macOS macOS 原生风格
Windows Windows 原生风格

设置全局样式

方式一:在 main.cpp 中设置(推荐)

#include <QQuickStyle>

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQuickStyle::setStyle("Material");    // 设置为 Material 风格

    QQmlApplicationEngine engine;
    // ...
}

方式二:通过环境变量

QT_QUICK_CONTROLS_STYLE=Material ./MyApp

方式三:在 qtquickcontrols2.conf 配置文件中设置

在项目根目录创建 qtquickcontrols2.conf,并在 CMakeLists.txt 中注册为资源:

[Controls]
Style=Material

[Material]
Theme=Light
Accent=Blue
Primary=BlueGrey

Material 样式示例

import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material

ApplicationWindow {
    width: 360; height: 500
    visible: true

    // Material 样式全局配置
    Material.theme: Material.Light
    Material.accent: Material.Blue
    Material.primary: Material.BlueGrey

    Column {
        anchors.centerIn: parent
        spacing: 16
        width: 280

        Button {
            width: parent.width
            text: "普通按钮"
        }

        Button {
            width: parent.width
            text: "高亮按钮"
            highlighted: true    // Material 风格下显示强调色
        }

        Button {
            width: parent.width
            text: "扁平按钮"
            flat: true
        }
    }
}

四、按钮类控件

4.1 Button

Button {
    text: "提交"
    enabled: true          // 是否可点击
    highlighted: false     // 强调样式(Material 风格有明显效果)
    flat: false            // 扁平样式(无边框背景)
    checkable: false       // 是否可切换选中状态
    icon.source: "images/send.png"    // 图标

    onClicked: console.log("提交")
    onPressAndHold: console.log("长按")
}

4.2 CheckBox — 复选框

Column {
    spacing: 8

    CheckBox {
        id: agreeCheck
        text: "我已阅读并同意用户协议"
        checked: false
        onCheckedChanged: console.log("同意状态:" + checked)
    }

    CheckBox {
        text: "订阅新闻邮件"
        checked: true
    }

    Button {
        text: "提交"
        enabled: agreeCheck.checked    // 绑定:勾选协议后才可提交
    }
}

4.3 RadioButton — 单选框

同一 ButtonGroup 中的单选框互斥:

import QtQuick
import QtQuick.Controls

Column {
    spacing: 8

    Label {
        text: "选择性别:"
        font.bold: true
    }

    ButtonGroup {
        id: genderGroup
    }

    RadioButton {
        text: "男"
        ButtonGroup.group: genderGroup
        checked: true
    }

    RadioButton {
        text: "女"
        ButtonGroup.group: genderGroup
    }

    RadioButton {
        text: "不愿透露"
        ButtonGroup.group: genderGroup
    }

    Label {
        text: "已选:" + genderGroup.checkedButton?.text
        color: "#888"
        font.pixelSize: 13
    }
}

4.4 Switch — 开关

Column {
    spacing: 12

    Switch {
        id: wifiSwitch
        text: "Wi-Fi"
        checked: true
        onCheckedChanged: console.log("Wi-Fi:" + (checked ? "开" : "关"))
    }

    Switch {
        text: "蓝牙"
        checked: false
    }

    Switch {
        text: "深色模式"
        checked: false
    }
}

五、输入类控件

5.1 TextField — 单行输入

Column {
    spacing: 12
    width: 280

    TextField {
        id: emailField
        width: parent.width
        placeholderText: "邮箱地址"
        inputMethodHints: Qt.ImhEmailCharactersOnly    // 键盘类型提示
        validator: RegularExpressionValidator {
            regularExpression: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/
        }
    }

    TextField {
        width: parent.width
        placeholderText: "密码"
        echoMode: TextInput.Password    // 密码掩码
    }

    TextField {
        width: parent.width
        placeholderText: "手机号"
        inputMethodHints: Qt.ImhDigitsOnly    // 只允许数字键盘
        maximumLength: 11
    }
}

5.2 TextArea — 多行输入

ScrollView {
    width: 300
    height: 150

    TextArea {
        placeholderText: "请输入详细描述..."
        wrapMode: TextArea.Wrap
        font.pixelSize: 14
    }
}

5.3 Slider — 滑块

Column {
    spacing: 16
    width: 300

    // 水平滑块
    Row {
        spacing: 12
        Label {
            anchors.verticalCenter: parent.verticalCenter
            text: "音量"
            width: 40
        }
        Slider {
            id: volumeSlider
            width: 200
            from: 0; to: 100; value: 70
            stepSize: 1
        }
        Label {
            anchors.verticalCenter: parent.verticalCenter
            text: Math.round(volumeSlider.value)
            width: 30
        }
    }

    // 垂直滑块
    Slider {
        orientation: Qt.Vertical
        height: 120
        from: 0; to: 100; value: 50
    }
}

5.4 SpinBox — 数字输入框

Row {
    spacing: 12
    Label {
        anchors.verticalCenter: parent.verticalCenter
        text: "数量:"
    }
    SpinBox {
        from: 1
        to: 99
        value: 1
        stepSize: 1
        editable: true    // 允许直接键盘输入
    }
}

5.5 ComboBox — 下拉选择框

Column {
    spacing: 12
    width: 240

    ComboBox {
        width: parent.width
        model: ["北京", "上海", "广州", "深圳", "杭州"]
        onCurrentIndexChanged: console.log("选中:" + currentText)
    }

    // 可编辑的 ComboBox
    ComboBox {
        width: parent.width
        editable: true
        model: ListModel {
            ListElement { text: "苹果" }
            ListElement { text: "香蕉" }
            ListElement { text: "橙子" }
        }
        onAccepted: {
            if (find(editText) === -1)
                model.append({ text: editText })    // 添加新选项
        }
    }
}

六、Layouts 布局模块

QtQuick.Layouts 提供了比 anchors 更强大的布局管理,特别适合需要自适应尺寸的 UI。

导入:

import QtQuick.Layouts

6.1 RowLayout — 水平布局

RowLayout {
    width: 400
    spacing: 8

    Button { text: "取消" }

    Item { Layout.fillWidth: true }    // 弹性空间,把后面的按钮推到右边

    Button { text: "确认"; highlighted: true }
}

6.2 ColumnLayout — 垂直布局

ColumnLayout {
    width: 300
    spacing: 12

    Label { text: "用户名" }

    TextField {
        Layout.fillWidth: true    // 填满父布局宽度
        placeholderText: "请输入用户名"
    }

    Label { text: "密码" }

    TextField {
        Layout.fillWidth: true
        echoMode: TextInput.Password
        placeholderText: "请输入密码"
    }

    Button {
        Layout.fillWidth: true
        Layout.topMargin: 8
        text: "登录"
        highlighted: true
    }
}

6.3 GridLayout — 网格布局

GridLayout {
    columns: 2
    columnSpacing: 12
    rowSpacing: 12
    width: 320

    Label { text: "姓名:" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入姓名" }

    Label { text: "手机:" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入手机号" }

    Label { text: "城市:" }
    ComboBox {
        Layout.fillWidth: true
        model: ["北京", "上海", "广州"]
    }

    // 跨列的按钮
    Button {
        Layout.columnSpan: 2
        Layout.fillWidth: true
        text: "提交"
        highlighted: true
    }
}

6.4 Layout 附加属性

在子元素上使用 Layout.* 属性控制其在布局中的行为:

RowLayout {
    width: 400

    Button {
        text: "固定宽度"
        Layout.preferredWidth: 100    // 期望宽度
        Layout.minimumWidth: 80       // 最小宽度
        Layout.maximumWidth: 120      // 最大宽度
    }

    TextField {
        Layout.fillWidth: true        // 填满剩余空间
        Layout.preferredHeight: 40
    }

    Button {
        text: "搜索"
        Layout.alignment: Qt.AlignVCenter    // 垂直居中对齐
    }
}

七、综合示例:用户注册表单

整合本文所有知识点,构建一个完整的注册表单:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    width: 400
    height: 580
    visible: true
    title: "用户注册"

    // 整体滚动支持
    ScrollView {
        anchors.fill: parent
        contentWidth: availableWidth

        ColumnLayout {
            width: parent.width
            spacing: 0

            // 顶部标题区
            Rectangle {
                Layout.fillWidth: true
                height: 100
                color: "#4A90E2"

                Column {
                    anchors.centerIn: parent
                    spacing: 4

                    Text {
                        anchors.horizontalCenter: parent.horizontalCenter
                        text: "创建账号"
                        font.pixelSize: 22
                        font.bold: true
                        color: "white"
                    }

                    Text {
                        anchors.horizontalCenter: parent.horizontalCenter
                        text: "加入我们,开始你的旅程"
                        font.pixelSize: 13
                        color: "#d0e8ff"
                    }
                }
            }

            // 表单区域
            ColumnLayout {
                Layout.fillWidth: true
                Layout.margins: 24
                spacing: 16

                // 姓名行
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 12

                    ColumnLayout {
                        Layout.fillWidth: true
                        spacing: 4
                        Label { text: "姓"; font.pixelSize: 13; color: "#555" }
                        TextField {
                            Layout.fillWidth: true
                            placeholderText: "姓氏"
                        }
                    }

                    ColumnLayout {
                        Layout.fillWidth: true
                        spacing: 4
                        Label { text: "名"; font.pixelSize: 13; color: "#555" }
                        TextField {
                            Layout.fillWidth: true
                            placeholderText: "名字"
                        }
                    }
                }

                // 邮箱
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "邮箱地址"; font.pixelSize: 13; color: "#555" }
                    TextField {
                        id: emailField
                        Layout.fillWidth: true
                        placeholderText: "example@email.com"
                        inputMethodHints: Qt.ImhEmailCharactersOnly
                    }
                }

                // 密码
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "密码"; font.pixelSize: 13; color: "#555" }
                    TextField {
                        id: passwordField
                        Layout.fillWidth: true
                        placeholderText: "至少 8 位字符"
                        echoMode: TextInput.Password
                    }
                    Label {
                        visible: passwordField.text.length > 0 && passwordField.text.length < 8
                        text: "密码长度不足 8 位"
                        font.pixelSize: 12
                        color: "#E24A4A"
                    }
                }

                // 城市选择
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "所在城市"; font.pixelSize: 13; color: "#555" }
                    ComboBox {
                        Layout.fillWidth: true
                        model: ["请选择城市", "北京", "上海", "广州", "深圳", "杭州", "成都"]
                    }
                }

                // 性别选择
                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 4
                    Label { text: "性别"; font.pixelSize: 13; color: "#555" }
                    RowLayout {
                        ButtonGroup { id: genderGroup }
                        RadioButton {
                            text: "男"
                            ButtonGroup.group: genderGroup
                            checked: true
                        }
                        RadioButton {
                            text: "女"
                            ButtonGroup.group: genderGroup
                        }
                    }
                }

                // 接收通知
                CheckBox {
                    id: notifyCheck
                    text: "接收产品更新通知"
                    checked: true
                }

                // 同意协议
                CheckBox {
                    id: agreeCheck
                    text: "我已阅读并同意《用户协议》和《隐私政策》"
                    font.pixelSize: 13
                }

                // 注册按钮
                Button {
                    Layout.fillWidth: true
                    Layout.topMargin: 8
                    text: "立即注册"
                    highlighted: true
                    enabled: agreeCheck.checked &&
                             emailField.text.length > 0 &&
                             passwordField.text.length >= 8
                    onClicked: console.log("注册成功!邮箱:" + emailField.text)
                }

                // 登录跳转
                RowLayout {
                    Layout.alignment: Qt.AlignHCenter
                    Label {
                        text: "已有账号?"
                        font.pixelSize: 13
                        color: "#888"
                    }
                    Button {
                        text: "立即登录"
                        flat: true
                        font.pixelSize: 13
                        onClicked: console.log("跳转到登录页")
                    }
                }
            }
        }
    }
}

八、常见问题

Q:Layout.fillWidthanchors.fill 有什么区别?

  • anchors.fill:用于 anchors 定位系统,将元素尺寸绑定到父元素
  • Layout.fillWidth:用于 Layouts 布局系统,让元素占满布局中的剩余宽度

两套系统不能混用——在 RowLayout / ColumnLayout 的直接子元素上,用 Layout.* 属性,不要用 anchors(会产生冲突警告)。

Q:控件的默认尺寸从哪里来?

Qt Quick Controls 的每个控件都有 implicitWidthimplicitHeight,由控件内容自动计算。不设置 width/height 时控件使用 implicit 尺寸;放入 Layout 后可以用 Layout.fillWidth 覆盖。

Q:如何统一修改应用内所有按钮的字体大小?

使用 ApplicationWindow 上的 font 属性设置全局字体,所有控件会继承:

ApplicationWindow {
    font.pixelSize: 15
    font.family: "PingFang SC"
    // ...
}

总结

控件 / 概念 用途
Button 可点击按钮,支持图标、高亮、扁平样式
CheckBox 复选框,独立勾选状态
RadioButton 单选框,配合 ButtonGroup 实现互斥
Switch 开关控件,适合设置页面
TextField 单行输入,支持验证器和键盘类型提示
TextArea 多行输入,配合 ScrollView 使用
Slider 滑块,水平或垂直方向
SpinBox 数字步进框
ComboBox 下拉选择,支持可编辑模式
RowLayout 水平自适应布局
ColumnLayout 垂直自适应布局
GridLayout 网格布局,支持跨行跨列
Layout.* 附加属性 控制子元素在布局中的尺寸和对齐
内置样式 BasicMaterialFusion 等,全局切换控件外观

一次 CR 引发的思考:我的 rules.ts 构想,究竟属于哪种开发哲学?

在一次普通的 Code Review 里,我提出我从一篇讲解 Spec-Driven Development 的文章中受到启发,想把组件里复杂的业务逻辑抽离成一个 rules.ts 文件作为核心维护管理的源头文件。

没想到这个想法引来了团队里两位同学的不同回应——后端同学说"这更像是 Business-Driven Development",另一位前端同学说"其实这是面向 AI 编程的重构"。

我当时有点懵:我只是想让代码好维护一点,怎么突然冒出来三个不同的概念?它们说的是同一件事吗?还是各说各的?

这篇文章是我事后整理的一些思考,不是标准答案,更多是一次概念厘清的过程。


起因:一段让人"胃疼"的组件逻辑

场景大概是这样的:一个表单组件,里面有十几个字段,每个字段的显示、禁用、必填状态都依赖彼此的值,还受到用户角色、当前流程状态、后端返回的配置项等多个因素影响。

随着需求迭代,这些判断逻辑开始散落在 JSX 的各个 disabledhiddenrequired 属性里,或者藏在 useEffect 的某个角落。改一个需求要跳好几个地方,测试也很难覆盖。

我的想法是:把这些判断统一收进一个文件。

// env: React + TypeScript
// scene: extract form business rules into a single file

// OrderForm.rules.ts

export type OrderState = {
  items: CartItem[];
  address: Address | null;
  userRole: 'guest' | 'member' | 'vip';
  flowStatus: 'draft' | 'pending' | 'submitted';
  isPending: boolean;
};

// Can the order be submitted?
export const canSubmitOrder = (state: OrderState): boolean =>
  state.items.length > 0 &&
  state.address !== null &&
  !state.isPending &&
  state.flowStatus === 'draft';

// Why can't it be submitted? (for tooltip / disabled hint)
export const getSubmitDisabledReason = (state: OrderState): string | null => {
  if (state.items.length === 0) return 'Cart is empty';
  if (!state.address) return 'Please fill in the delivery address';
  if (state.flowStatus !== 'draft') return 'Order has been submitted';
  return null;
};

// Should the VIP discount field be shown?
export const shouldShowVipDiscount = (state: OrderState): boolean =>
  state.userRole === 'vip' && state.items.length > 0;

这些函数有几个共同点:纯函数、无副作用、不依赖任何 UI 层的东西。它们可以被独立测试,也可以被 AI copilot 单独修改,不会误伤组件渲染逻辑。

然后 CR 的时候,争论来了。


三个概念,说的是同一件事吗?

Spec-Driven Development:先定规范,再写实现

后端同学最初提的是 Spec-Driven Development(规范驱动开发) ,但随即他自己也觉得这个词不太准。

SDD 的核心是:有一份"单一真相来源"(Single Source of Truth),开发围绕它展开。最典型的例子是 API 开发里先写 OpenAPI YAML,再生成 server stub 和 client SDK;或者先定 TypeScript 类型,再写实现。

我的 rules.ts 某种程度上也在做这件事——先定义"业务规则是什么",再让组件去引用它。这个文件就是那份 source of truth。

但 SDD 更多强调的是流程,而不是文件结构本身。它关心的是"你是不是先写了规范才动手写实现",而不是"你把规范放在哪里"。

Business-Driven Development:让代码说人话

后端同学后来改口说"其实更像是 BDD",这里他说的不是测试领域里的 Behavior-Driven Development(那个 BDD 特指用 Gherkin 语法写测试用例的实践),而是一种更广义的业务驱动思想:代码要贴近业务语言,要让不写代码的人也能读懂意图

这背后其实是 DDD(领域驱动设计)里"通用语言(Ubiquitous Language)"的落地——领域专家、产品、开发用同一套词汇描述同一件事。

放在我的场景里,这层意思是:rules.ts 里的函数名要用业务词汇。

// Business-oriented naming (recommended)
export const canSubmitOrder = ...
export const shouldShowVipDiscount = ...
export const isAddressRequired = ...

// Technical-oriented naming (harder to maintain)
export const checkFlag = ...
export const validateConditionA = ...
export const controlVisibility = ...

前者用业务动词命名,看名字就能理解意图;后者命名模糊,要深入读代码才能知道它在判断什么业务规则。

这个维度说的是语义,和放不放在一个文件里其实是两回事,但两者结合起来会更有价值。

AI-First Refactoring:为工具协作重新组织代码

另一位前端同学说的"面向 AI 编程的重构",是最近才开始被广泛讨论的工程实践,目前还没有一个统一的正式名字。

它的核心假设是:AI copilot 在处理小的、单一职责的、自描述的文件时效果最好。一个 500 行的组件文件里混杂着 UI 结构、样式逻辑、副作用和业务判断,AI 改起来容易"误伤";而一个 60 行的 rules.ts,里面只有纯函数和类型定义,AI 一眼就能理解意图,改起来精准且可预测。

从这个角度看,rules.ts 是在为 AI 协作创造一个"安全操作区":

OrderForm/
  index.tsx           ← UI structure, calls rules, doesn't contain logic
  OrderForm.rules.ts  ← pure business rules, safe area for AI to operate
  OrderForm.test.ts   ← only tests rules, no need to mount the component
  OrderForm.types.ts  ← shared type definitions

这个维度说的是工具链适配,和前两个是完全不同的维度。


那我的构想到底叫什么?

把三位同学的视角放在一起,我意识到他们说的其实是同一件事的三个面:

维度 概念 对 rules.ts 的意义
流程 Spec-Driven rules.ts 是规范,先写它再写组件
语义 Business-Driven rules.ts 里要用业务词汇命名
工具 AI-First 小文件单职责,让 AI 每次只改一处

但如果要给这个模式找一个最贴切的名字,我查阅资料后觉得它最接近的是业务规则外置(Business Rules Externalization) ,这是企业架构领域的成熟实践;在面向对象设计里,有时也叫 Policy Object 模式

思路很简单:把"判断逻辑"从"执行逻辑"里分离出来,单独成文件,单独测试,单独演化。

这种模式以前在前端的存在感不强,因为 Redux、MobX 这类状态管理库的 action/reducer 结构在一定程度上替代了它。但在 AI copilot 普及之后,它的价值被重新放大了——不只是为了人类维护,也是为了让 AI 能精准地修改业务规则,而不是在一个巨型组件文件里大海捞针。


落地时的几个小取舍

这个模式并不是银弹,在考虑引入的时候有几个地方值得权衡,我目前的理解是这样的(不一定对):

适合放进 rules.ts 的:

  • 返回 boolean 的状态判断(canXxxshouldXxxisXxx
  • 返回提示文案的逻辑(getXxxMessagegetXxxReason
  • 基于当前状态的派生值计算(getXxxConfig

不太适合放进去的:

  • 需要调用 API 的异步逻辑(那更适合放在 service 或 hook 里)
  • 直接操作 DOM 或依赖 React context 的逻辑(破坏了纯函数的特性)
  • 过于简单的单行判断(items.length > 0,直接内联更清晰)

还有一个细节:当 rules.ts 里的函数数量增多,可以考虑按业务子域继续拆分,比如 OrderForm.submit.rules.tsOrderForm.display.rules.ts,而不是一个文件越堆越大。


延伸想了一些问题

在整理这些思路的过程中,我产生了几个新的疑问,暂时还没有答案:

  1. 规则文件里的测试应该怎么组织? 纯函数很好测,但当 OrderState 的字段越来越多,构造 mock 数据会变得繁琐,有没有更好的测试策略?
  2. 如果规则本身来自后端配置(比如 feature flag 或动态表单配置),这个模式还成立吗? 这时候"规则"本身是运行时数据,而不是编译时代码,边界就模糊了。
  3. 这和 Zod 之类的 schema 验证库是什么关系? 两者都在做"约束表达",但侧重点不同——Zod 偏数据合法性校验,rules.ts 偏业务状态判断。能不能配合使用?
  4. AI 工具真的会更倾向于修改小文件吗? 这个假设我还没有系统性地验证过,只是直觉上觉得合理。

小结

回头看这次 CR 的对话,三位同学说的都有道理,只是各自在不同的维度切入。Spec-Driven、Business-Driven、AI-First,这三个标签并不是互斥的选择,它们描述的是同一个设计决策在不同语境下的意义。

把业务规则从组件里抽出来,这件事本身并不新鲜;但在 AI 工具成为日常开发协作者的今天,这种结构的价值被重新放大了。它既是给人类读者的清晰表达,也是给 AI 工具的精准操作界面。

这让我开始思考:我们在做代码组织决策的时候,"对 AI 是否友好",会不会慢慢变成和"可读性"、"可测试性"同等重要的考量维度?


参考资料

Qt Quick 视觉元素、交互与自定义组件(七)

适合人群: 已理解 QML 基础语法,想掌握 Qt Quick 核心视觉元素的开发者

前言

上一篇我们系统学习了 QML 的语法机制——对象、属性、绑定、信号。本篇进入 Qt Quick 模块本身:它提供了哪些视觉元素,如何导入外部资源,如何处理用户输入,以及如何用 JavaScript 扩展交互逻辑。

Qt Quick 是建立在 QML 语言之上的标准组件库,是你构建实际界面的直接工具。

一、搭建项目基础

本文所有示例基于以下 CMakeLists.txt 配置:

cmake_minimum_required(VERSION 3.16)
project(QtQuickDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Quick)
qt_standard_project_setup(REQUIRES 6.5)

qt_add_executable(QtQuickDemo main.cpp)

qt_add_qml_module(QtQuickDemo
    URI QtQuickDemo
    VERSION 1.0
    QML_FILES Main.qml
    RESOURCES
        images/background.jpg
        fonts/MyFont.ttf
)

target_link_libraries(QtQuickDemo PRIVATE Qt6::Quick)

main.cpp 使用标准模板不作修改,所有开发工作集中在 QML 文件中。


二、核心视觉元素

2.1 Item — 所有视觉元素的基类

Item 是 Qt Quick 中所有可视元素的基类,它本身不可见,但提供了所有视觉元素共有的属性:

import QtQuick

Item {
    width: 400
    height: 300

    // Item 的核心属性
    x: 0; y: 0            // 位置
    z: 0                  // 层叠顺序,数值大的在上层
    opacity: 1.0          // 透明度 0.0 ~ 1.0
    visible: true         // 是否显示
    clip: false           // 是否裁剪超出边界的子元素
    rotation: 0           // 旋转角度(度)
    scale: 1.0            // 缩放比例
}

2.2 Rectangle — 矩形

最常用的容器元素:

import QtQuick

Rectangle {
    width: 200
    height: 120
    color: "#4A90E2"
    radius: 12              // 圆角

    // 边框
    border.width: 2
    border.color: "#2C5F9E"

    // 渐变色
    gradient: Gradient {
        GradientStop { position: 0.0; color: "#6AB0F5" }
        GradientStop { position: 1.0; color: "#2C72C7" }
    }
}

2.3 Text — 文本

import QtQuick

Text {
    text: "Qt Quick 文本示例"

    // 字体设置
    font.family: "Arial"
    font.pixelSize: 18
    font.bold: true
    font.italic: false
    font.letterSpacing: 1.5    // 字间距

    // 颜色与对齐
    color: "#333333"
    horizontalAlignment: Text.AlignHCenter
    verticalAlignment: Text.AlignVCenter

    // 多行处理
    width: 300
    wrapMode: Text.WordWrap        // 自动换行
    elide: Text.ElideRight         // 超出显示省略号

    // 富文本
    textFormat: Text.RichText
    text: "普通文字 <b>加粗</b> <i>斜体</i> <font color='red'>红色</font>"
}

2.4 Image — 图片

import QtQuick

Image {
    width: 200
    height: 200
    source: "images/photo.jpg"     // 相对路径

    // 缩放模式
    fillMode: Image.PreserveAspectFit    // 保持比例,完整显示
    // fillMode: Image.PreserveAspectCrop  // 保持比例,裁剪填满
    // fillMode: Image.Stretch             // 拉伸填满(可能变形)

    // 加载状态
    onStatusChanged: {
        if (status === Image.Ready)
            console.log("图片加载完成")
        else if (status === Image.Error)
            console.log("图片加载失败")
    }
}

加载网络图片:

Image {
    source: "https://example.com/image.jpg"
    // 网络图片加载是异步的,status 会经历 Loading → Ready
}

三、导入外部资源

3.1 使用自定义字体

第一步:CMakeLists.txtRESOURCES 中注册字体文件。

第二步: 在 QML 中加载字体:

import QtQuick

Item {
    // 加载自定义字体
    FontLoader {
        id: customFont
        source: "fonts/MyFont.ttf"
    }

    Text {
        text: "自定义字体效果"
        font.family: customFont.name    // 使用加载的字体
        font.pixelSize: 24
    }
}

3.2 使用图片资源

注册到 CMakeLists.txt 后,在 QML 中直接用相对路径引用:

// 项目内资源(推荐)
Image { source: "images/logo.png" }

// 也可以用 qrc:/ 前缀显式引用
Image { source: "qrc:/QtQuickDemo/images/logo.png" }

四、定位:anchors 锚点系统

anchors 是 Qt Quick 中最灵活的定位机制,通过将一个元素的边与另一个元素的边对齐来定位。

4.1 基本锚点

Rectangle {
    id: parent_rect
    width: 400; height: 300

    // 贴左边
    Rectangle {
        width: 100; height: 100
        color: "red"
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.margins: 10         // 所有方向留 10px 间距
    }

    // 贴右下角
    Rectangle {
        width: 100; height: 100
        color: "blue"
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        anchors.rightMargin: 10
        anchors.bottomMargin: 10
    }

    // 水平居中,垂直方向在顶部
    Rectangle {
        width: 100; height: 40
        color: "green"
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: parent.top
        anchors.topMargin: 10
    }
}

4.2 填满父容器

Rectangle {
    anchors.fill: parent               // 完全填满父容器
    anchors.margins: 16                // 四周留 16px 边距
}

4.3 相对于兄弟元素定位

Rectangle {
    id: firstBox
    width: 100; height: 100
    color: "orange"
    anchors.left: parent.left
    anchors.top: parent.top
    anchors.margins: 20
}

Rectangle {
    width: 100; height: 100
    color: "purple"
    anchors.left: firstBox.right      // 紧跟在 firstBox 右边
    anchors.leftMargin: 10
    anchors.top: firstBox.top         // 与 firstBox 顶部对齐
}

五、处理用户输入

5.1 MouseArea — 鼠标与触控

import QtQuick

Rectangle {
    width: 200
    height: 100
    color: clickArea.pressed ? "#2C72C7" : "#4A90E2"    // 按下时变深色
    radius: 8

    MouseArea {
        id: clickArea
        anchors.fill: parent

        // 常用信号
        onClicked: console.log("点击,位置:" + mouse.x + "," + mouse.y)
        onDoubleClicked: console.log("双击")
        onPressAndHold: console.log("长按")
        onEntered: console.log("鼠标进入")
        onExited: console.log("鼠标离开")

        // 接受右键
        acceptedButtons: Qt.LeftButton | Qt.RightButton
        onClicked: {
            if (mouse.button === Qt.RightButton)
                console.log("右键点击")
        }
    }

    Text {
        anchors.centerIn: parent
        text: clickArea.pressed ? "按住中..." : "点击我"
        color: "white"
        font.pixelSize: 16
    }
}

5.2 鼠标悬停效果

Rectangle {
    id: card
    width: 180; height: 100
    radius: 10
    color: "#f5f5f5"
    border.width: 1
    border.color: hoverArea.containsMouse ? "#4A90E2" : "#e0e0e0"
    scale: hoverArea.containsMouse ? 1.03 : 1.0     // 悬停时轻微放大

    // scale 属性变化自动有过渡效果(需配合 Behavior,见后续课程)

    MouseArea {
        id: hoverArea
        anchors.fill: parent
        hoverEnabled: true    // 必须启用才能检测 containsMouse
    }

    Text {
        anchors.centerIn: parent
        text: "悬停查看效果"
        color: hoverArea.containsMouse ? "#4A90E2" : "#666"
        font.pixelSize: 14
    }
}

六、使用 JavaScript 扩展逻辑

QML 原生支持 JavaScript,可以直接在属性绑定和信号处理器中写逻辑,也可以定义函数。

6.1 内联 JavaScript

import QtQuick

Rectangle {
    width: 300; height: 200

    property int score: 75

    Text {
        anchors.centerIn: parent
        font.pixelSize: 18

        // 三元表达式
        text: score >= 90 ? "优秀"
            : score >= 75 ? "良好"
            : score >= 60 ? "及格"
            :                "不及格"

        color: score >= 75 ? "#1D9E75" : "#E24A4A"
    }
}

6.2 定义函数

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360; height: 300
    visible: true

    property real celsius: 0

    // 在 Item 内定义函数
    function celsiusToFahrenheit(c) {
        return (c * 9 / 5 + 32).toFixed(1)
    }

    function getTemperatureLabel(c) {
        if (c < 0)   return "冰点以下"
        if (c < 15)  return "寒冷"
        if (c < 25)  return "舒适"
        if (c < 35)  return "温热"
        return "炎热"
    }

    Column {
        anchors.centerIn: parent
        spacing: 16
        width: 280

        Slider {
            width: parent.width
            from: -20; to: 50; value: 0
            onValueChanged: celsius = value
        }

        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            text: celsius.toFixed(1) + "°C  =  " + celsiusToFahrenheit(celsius) + "°F"
            font.pixelSize: 22
            font.bold: true
        }

        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            text: getTemperatureLabel(celsius)
            font.pixelSize: 16
            color: "#888"
        }
    }
}

6.3 将 JS 逻辑抽离到独立文件

当 JavaScript 逻辑较复杂时,可以放入独立的 .js 文件:

新建 utils.js

// utils.js
.pragma library    // 声明为共享库,多个 QML 文件导入时只加载一次

function formatNumber(num) {
    return num.toLocaleString()
}

function clamp(value, min, max) {
    return Math.max(min, Math.min(max, value))
}

在 QML 中导入使用:

import "utils.js" as Utils

Text {
    text: Utils.formatNumber(1234567)    // 输出:1,234,567
}

七、创建自定义组件

7.1 提取独立组件文件

把一个"卡片"封装成可复用的组件 InfoCard.qml

// InfoCard.qml
import QtQuick

Rectangle {
    id: root

    // 对外暴露的属性接口
    property string title: "标题"
    property string subtitle: "副标题"
    property color accentColor: "#4A90E2"

    // 对外暴露的信号
    signal tapped()

    width: 240
    height: 90
    radius: 12
    color: "#ffffff"
    border.width: 1
    border.color: "#e8e8e8"

    // 左侧色条
    Rectangle {
        width: 4
        height: parent.height
        radius: 2
        color: root.accentColor
    }

    Column {
        anchors {
            left: parent.left
            leftMargin: 20
            verticalCenter: parent.verticalCenter
        }
        spacing: 4

        Text {
            text: root.title
            font.pixelSize: 16
            font.bold: true
            color: "#222"
        }

        Text {
            text: root.subtitle
            font.pixelSize: 13
            color: "#888"
        }
    }

    MouseArea {
        anchors.fill: parent
        onClicked: root.tapped()
    }
}

在主文件中使用:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360; height: 400
    visible: true

    Column {
        anchors.centerIn: parent
        spacing: 12

        InfoCard {
            title: "今日步数"
            subtitle: "8,432 步"
            accentColor: "#1D9E75"
            onTapped: console.log("点击了步数卡片")
        }

        InfoCard {
            title: "活跃时间"
            subtitle: "47 分钟"
            accentColor: "#4A90E2"
            onTapped: console.log("点击了时间卡片")
        }

        InfoCard {
            title: "消耗热量"
            subtitle: "312 千卡"
            accentColor: "#E2934A"
            onTapped: console.log("点击了热量卡片")
        }
    }
}

7.2 组件的属性别名(alias)

alias 让外部可以直接访问组件内部某个子元素的属性:

// SearchBar.qml
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root
    height: 44
    radius: 22
    color: "#f5f5f5"
    border.width: 1
    border.color: "#e0e0e0"

    // alias 将内部 textField  text 属性暴露出去
    property alias searchText: textField.text
    property alias placeholderText: textField.placeholderText

    signal searchSubmitted(string query)

    TextField {
        id: textField
        anchors {
            left: parent.left
            right: submitBtn.left
            verticalCenter: parent.verticalCenter
            leftMargin: 16; rightMargin: 8
        }
        background: Item {}     // 去掉默认背景
        placeholderText: "搜索..."
        onAccepted: root.searchSubmitted(text)
    }

    Button {
        id: submitBtn
        anchors {
            right: parent.right
            verticalCenter: parent.verticalCenter
            rightMargin: 8
        }
        text: "搜索"
        flat: true
        onClicked: root.searchSubmitted(textField.text)
    }
}

使用时直接访问 searchText

SearchBar {
    id: bar
    width: 300
    onSearchSubmitted: function(query) {
        console.log("搜索:" + query)
    }
}

Text {
    text: "当前输入:" + bar.searchText    // 通过 alias 访问内部属性
}

八、综合示例:个人资料卡片

整合本文所有知识点,构建一个完整的个人资料展示组件:

// ProfileCard.qml
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root

    property string avatarSource: ""
    property string name: "用户名"
    property string bio: "个人简介"
    property int followerCount: 0
    property int followingCount: 0

    signal followClicked()

    width: 320
    height: 200
    radius: 16
    color: "#ffffff"
    border.width: 0.5
    border.color: "#e8e8e8"

    // 顶部背景条
    Rectangle {
        width: parent.width
        height: 70
        color: "#4A90E2"
        radius: 16

        // 修复底部圆角
        Rectangle {
            width: parent.width
            height: 16
            anchors.bottom: parent.bottom
            color: parent.color
        }
    }

    // 头像
    Rectangle {
        id: avatarFrame
        width: 64; height: 64
        radius: 32
        color: "#e0e0e0"
        border.width: 3
        border.color: "white"
        anchors {
            left: parent.left
            leftMargin: 20
            top: parent.top
            topMargin: 38
        }

        Image {
            anchors.fill: parent
            anchors.margins: 2
            source: root.avatarSource
            fillMode: Image.PreserveAspectCrop
            layer.enabled: true
            layer.effect: null
        }

        // 无头像时显示首字母
        Text {
            anchors.centerIn: parent
            text: root.name.length > 0 ? root.name[0].toUpperCase() : "?"
            font.pixelSize: 24
            font.bold: true
            color: "#888"
            visible: root.avatarSource === ""
        }
    }

    // 关注按钮
    Button {
        text: "关注"
        anchors {
            right: parent.right
            rightMargin: 16
            top: parent.top
            topMargin: 80
        }
        onClicked: root.followClicked()
    }

    // 名字和简介
    Column {
        anchors {
            left: parent.left
            leftMargin: 20
            top: avatarFrame.bottom
            topMargin: 8
        }
        spacing: 2

        Text {
            text: root.name
            font.pixelSize: 16
            font.bold: true
            color: "#222"
        }

        Text {
            text: root.bio
            font.pixelSize: 13
            color: "#888"
            width: 200
            elide: Text.ElideRight
        }
    }

    // 粉丝数据
    Row {
        anchors {
            right: parent.right
            rightMargin: 20
            bottom: parent.bottom
            bottomMargin: 14
        }
        spacing: 16

        Column {
            horizontalItemAlignment: Qt.AlignHCenter
            Text {
                anchors.horizontalCenter: parent.horizontalCenter
                text: root.followerCount
                font.pixelSize: 15
                font.bold: true
                color: "#222"
            }
            Text {
                text: "粉丝"
                font.pixelSize: 11
                color: "#aaa"
            }
        }

        Column {
            horizontalItemAlignment: Qt.AlignHCenter
            Text {
                anchors.horizontalCenter: parent.horizontalCenter
                text: root.followingCount
                font.pixelSize: 15
                font.bold: true
                color: "#222"
            }
            Text {
                text: "关注"
                font.pixelSize: 11
                color: "#aaa"
            }
        }
    }
}

在主文件中使用:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 400; height: 300
    visible: true

    ProfileCard {
        anchors.centerIn: parent
        name: "林小明"
        bio: "Qt 开发爱好者 · 嵌入式工程师"
        followerCount: 1248
        followingCount: 362
        onFollowClicked: console.log("点击关注")
    }
}

九、下一步

掌握了 Qt Quick 的核心视觉元素之后,建议继续:

  1. Introduction to Qt Quick Controls — 学习更完整的 UI 控件库(按钮、输入框、菜单、对话框等)
  2. Positioners and Layouts — 深入布局系统,让 UI 自适应不同屏幕尺寸
  3. QML Fluid Elements and Animation — 为界面加入流畅动画

总结

元素 / 概念 用途
Item 所有视觉元素的基类,提供位置、透明度、旋转等基础属性
Rectangle 矩形容器,支持圆角、边框、渐变
Text 文本显示,支持富文本、换行、省略
Image 图片显示,支持多种缩放模式和异步加载
FontLoader 加载自定义字体文件
anchors 锚点定位系统,通过边对齐实现灵活布局
MouseArea 处理鼠标和触控事件
JavaScript 函数 在 QML 中定义逻辑函数,复杂逻辑可抽离到 .js 文件
自定义组件 独立 .qml 文件封装,property 暴露接口,alias 透传内部属性
❌