普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月27日技术

彻底淘汰老旧 SVG 插件:unplugin-icons 与 Tailwind CSS v4 自定义图标最佳实践

作者 梅下班
2026年3月27日 18:16

最近在打包项目的时候发现打包极其的慢,直接vite打包栈溢出,打包失败 进行了一下排查,优化,最终发现罪魁祸首是 vite-plugin-svg-icons

主要原因是:loader 的时候每次都会完整构建一遍,复杂度随着项目文件数目和 svg 文件数目指数上升

  • 依赖极其老旧,停止维护:仓库最后一次更新定格在 4 年前。

  • 严重的性能与内存问题:由于其处理机制的问题,在大型项目中会导致打包极其缓慢,甚至出现栈溢出(OOM)报错(详见 Issue #112#124)。

  • 安全风险:安全扫描工具频频报出底层依赖的漏洞(详见 Issue #123)。

image.png

为了彻底解决这些痛点,我决定将图标系统重构。采用知名开源大佬 Anthony Fu (antfu) 维护的unplugin-icons来处理组件化图标,并结合最新的 Tailwind CSS v4 及 Iconify 官方插件来实现 CSS 类的自定义图标方案。这不仅极大提升了打包速度,还让图标的使用变得前所未有的灵活。

image.png

下面是详细的迁移与配置流程。

第一步:安装插件

pnpm i -D unplugin-icons

第二步:安装图标数据

使用 Iconify 作为图标数据源(支持 100+ 个图标集)

VS Code 用户:安装 Iconify IntelliSense 扩展以获得内联预览、自动完成和悬停信息

pnpm i -D @iconify/json

完整安装 这将安装所有图标集(约 120MB)。只有你实际使用的图标才会在生产环境中被打包。

安装单个图标集

仅安装你需要的图标集:

pnpm i -D @iconify-json/mdi @iconify-json/carbon

自动安装(实验性)

让 unplugin-icons 在你导入图标集时自动安装它们:

Icons({
  autoInstall: true, // Auto-detects npm/yarn/pnpm
})

构建工具配置

// vite.config.ts
import Icons from 'unplugin-icons/vite'

export default defineConfig({
  plugins: [
    Icons({ /* options */ }),
  ],
})

依据使用的框架配置 compiler 选项

Icons({ compiler: 'vue3' })

通过在导入路径中添加 ?raw 来将图标作为原始 SVG 字符串导入。适用于直接在 HTML 模板中嵌入 SVG。

<script setup lang='ts'>
import RawMdiAlarmOff from '~icons/mdi/alarm-off?raw&width=4em&height=4em'
import RawMdiAlarmOff2 from '~icons/mdi/alarm-off?raw&width=1em&height=1em'
</script>

<template>
  <!-- raw example -->
  <pre>
    import RawMdiAlarmOff from '~icons/mdi/alarm-off?raw&width=4em&height=4em'
    {{ RawMdiAlarmOff }}
    import RawMdiAlarmOff2 from '~icons/mdi/alarm-off?raw&width=1em&height=1em'
    {{ RawMdiAlarmOff2 }}
  </pre>
  <!-- svg example -->
  <span v-html="RawMdiAlarmOff" />
  <span v-html="RawMdiAlarmOff2" />
</template>

每一个图标就是一个组件

自定义图标

unplugin-icons 默认支持通过 @iconify/json 使用海量的开源图标库,但由于我们是从旧插件迁移,项目里肯定有大量业务专属的本地 SVG 文件。

我们需要通过 FileSystemIconLoader 来加载这些自定义图标。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // 如果你使用的是 Vue
import Icons from 'unplugin-icons/vite'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    vue(),
    tailwindcss(), // Tailwind v4 的 Vite 插件
    
    // unplugin-icons 配置
    Icons({
      // 指定编译器,根据你的框架选择 'vue3', 'react', 'svelte' 等
      compiler: 'vue3', 
      autoInstall: true,
      customCollections: {
        // 这里的 'custom' 是你自定义图标集合的名称
        // 参数一是你本地 SVG 文件夹的相对路径
        // 参数二是可选的转换函数,通常用于将 svg 的 fill 或 stroke 替换为 currentColor 以支持 CSS 动态改色
        'custom': FileSystemIconLoader(
          './src/assets/svg', 
          svg => svg.replace(/^<svg /, '<svg fill="currentColor" ')
        ),
      },
    }),
  ],
})

使用方式为

import IconAccount from '~icons/my-icons/account'
import IconFoo from '~icons/my-other-icons/foo'
import IconBar from '~icons/my-yet-other-icons/bar'

使用解析器自动导入

使用自动导入时,注册你的自定义集合名称:

// vite.config.ts
IconResolver({
  customCollections: [
    'local',
    'my-other-icons',
    'my-yet-other-icons',
  ],
})

直接使用

<i-local-account/>

组件命名

图标按照以下命名规则自动导入:

{prefix}-{collection}-{icon}

prefix : 组件名称前缀(默认值: i )

collection : Iconify 集合 ID(例如, mdi 、 carbon 、 fa-solid )

icon : 图标名称(kebab-case)

自定义前缀

IconsResolver({
  prefix: 'icon', // Use 'icon' instead of 'i'
})
无前缀: false
  <icon-mdi-account />
 <mdi-account />

设置图标集别名

IconsResolver({
  alias: {
    park: 'icon-park',  // Use <icon-park-* /> instead of <icon-icon-park-* />
    fas: 'fa-solid',    // Use <icon-fas-* /> instead of <icon-fa-solid-* />
  }
})

配置 Tailwind CSS v4 及自定义图标(CSS 类方案)

安装

pnpm i -D @iconify/tailwind4

插件不包含图标。您需要添加要使用的图标集。

您也可以通过安装 @iconify-json/{prefix} 依赖项(其中"{prefix}"是图标集前缀)来仅安装您想要使用的图标集,例如 @iconify-json/mdi-light

Tailwind CSS v4 带来了革命性的变化,最大的区别就是去掉了 tailwind.config.js,所有的配置直接在 CSS/全局样式文件中通过 CSS At-rules(@规则)完成。

借助 @iconify/tailwind4 插件,我们不仅能用原子类写公共开源图标,还能直接把本地存放 SVG 的文件夹映射为 Tailwind 的原子类!

修改你的主 CSS 文件(例如 src/style.csssrc/main.css):

/* 引入 Tailwind v4 核心 */
@import "tailwindcss";

/* 1. 全局配置:直接引入完整的 Iconify 支持(如果需要用到海量开源图标) */
@plugin "@iconify/tailwind4";

/* 2. 自定义本地 SVG 配置 */
@plugin "@iconify/tailwind4" {
  /* from-folder(前缀名, 文件夹路径)
    这里我们将 src/assets/svg 文件夹映射为 `local` 集合
  */
  icon-sets: from-folder(local, "./src/assets/icons");
}

注意:Iconify 插件在底层会自动清理并优化 from-folder 加载的 SVG,如果图片是单色,它会自动转化为 mask,以完美支持 Tailwind 的 text-red-500 等颜色类名。

要使用图标,请为图标添加动态选择器,例如

<span class="icon-[mdi-light--home]"></span>

还可以自定义设置图标的前缀和大小(默认为1em)

@plugin "@iconify/tailwind4" {
  prefix: "iconify";
  scale: 1.2;
}

自定义图标

加载图标集有两种方法:

  • 加载以 IconifyJSON 格式预解析的图标集。
  • 加载本地文件夹中的所有svg文件

配置示例

@plugin "@iconify/tailwind4" {
  icon-sets: from-json(test, "./icon-sets/test.json"), from-folder(test2, "./icon-sets/svgs");
}

在 CSS 的插件配置中添加“icon-sets”选项,选项集以逗号分隔。

从 JSON 文件加载速度更快,因为无需进行清理操作

文件必须为 IconifyJSON 格式,可使用 Iconify Tools 生成。

如果您项目里的本地 SVG 图标非常多(比如几百上千个),每次项目启动时使用 from-folder 让 Vite 在运行时去逐个读取、清理和转化 SVG,依然会消耗一定的构建时间。

更优雅且极致的解决方案是:使用 Iconify 官方提供的 @iconify/tools,编写一个独立js脚本或是vite插件,将所有的本地 SVG 预先处理、压缩,并打包成一个 .json 文件。 之后无论是 Tailwind v4 还是 unplugin-icons,直接读取这个 JSON 文件即可,实现“零运行时开销”。

具体详细配置可查看文档@iconify/tools

弊端:脚本在处理文件时会把所有颜色都换成了 currentColor。如果你的图标全是单色的菜单 Icon,这很完美。但如果你的文件夹里混入了一个多色的插画 SVG(比如带有蓝色衣服、黄色帽子的彩色 Logo),经过脚本处理后,它会变成黑乎乎的一团(也就是失去了原本的彩色)

额外类名

每个图标有 2 个类名:

图标的类名,例如“mdi-light--home”。

渲染模式的类名:"iconify" 或 "iconify-color"(可配置)。

所有图标均遵循相同的规则,图片 URL 除外。

为避免代码重复,通用规则已被拆分为实用类。此外,这还允许您选择图标的渲染方式:

“iconify” 会将图标渲染为蒙版图像,因此图标会采用与文本相同的颜色。若要更改图标颜色,请更改文本颜色。此方法适用于未硬编码配色方案的图标。

“iconify-color” 将图标渲染为背景图像。此功能适用于具有硬编码调色板的图标。

为什么需要配置?

Tailwind CSS 的工作原理是查找代码中的类名,并为这些类名生成相应的 CSS 样式。

在使用动态类名(例如“icon-[mdi-light--home]”)时,Tailwind CSS 会查找所有此类类名,并将它们传递给插件以生成 CSS。这意味着插件知道使用了哪些图标,并仅加载所需的图标。

然而,当使用普通类名(例如“mdi-light--home”)时,Tailwind CSS 需要先通过插件为所有可能的类名生成 CSS,然后再在项目中查找类名,最后移除未使用的类名。这意味着插件必须为所有可能存在的图标生成 CSS。

为每个图标生成 CSS 并非快速的过程。鉴于可用的图标超过 275,000 个,这可能会耗费大量时间。此外,Tailwind CSS 会将所有内容保存在内存中,这可能会导致 Tailwind CSS 内存不足。为避免这种情况,您必须指定要使用的图标集列表。

配置完后直接通过类名生成图标

<i class="text-blue-500 text-xl icon-[local--user]" />

总结

通过移除四年前的 vite-plugin-svg-icons,并引入 unplugin-icons + @iconify/tailwind4

  1. 彻底告别了项目打包时的内存泄漏(OOM) ,打包速度肉眼可见地提升。
  2. 我们享受到了 antfuIconify 社区持续活跃维护带来的红利,告别了安全漏洞警告。
  3. 拥抱了下一代构建工具 Tailwind CSS v4 的极简 CSS 架构。

希望这篇文章能帮助正在使用老旧 Vben 等模板架构的开发者们成功渡劫!如果有问题,欢迎在评论区交流。

说说我为什么放弃使用 GetX,转而使用 flutter_bloc + GetIt

作者 明君87997
2026年3月27日 17:49

写在前面:这不是一篇中立的对比文章,这是一篇事后复盘。我在公司的多个生产项目里深度使用了 GetX,然后花了大量时间在填它挖的坑。如果你正在技术选型,希望这篇文章能帮你少走一些弯路。


一切的开始:GetX 真的很香

说实话,GetX 在我第一次接触 Flutter 时给我留下了极好的印象。

不需要 BuildContext,直接 Get.to() 跳页面;不用写 InheritedWidget,直接 GetxController 管状态;依赖注入?Get.put() 一行搞定。对于一个从其他生态转过来的开发者来说,GetX 简直像是 Flutter 世界里的"万能胶"——把所有烦人的东西都粘在一起,开箱即用,上手极快。

所以我在项目里大量使用了它。Controller 继承 GetxController,页面里 Get.find<XxxLogic>() 随处调用,路由用 Get.toNamed(),弹窗用 Get.dialog(),依赖用 Get.put() 注册……

然后,问题开始慢慢浮出水面。


问题一:Get.find 不是"依赖注入",它是"全局变量换了个马甲"

我项目里有大量这样的代码:

class SomeDetailPage extends StatelessWidget {
  final SomeLogic logic = Get.find<SomeLogic>();
  final SomeState state = Get.find<SomeLogic>().state;

  SomeDetailPage({Key? key}) : super(key: key);
  // ...
}

乍一看没问题,但你有没有想过——这个 Get.find<SomeLogic>() 是在 构造函数里 执行的?这意味着在这个 Widget 被实例化的那一刻,SomeLogic 必须已经在 GetX 的全局容器里注册好了。如果没有注册,直接崩溃。

更麻烦的是,你没办法通过构造函数传入一个 mock,这让单元测试和 Widget 测试变得极其痛苦。你没办法孤立地测试这个 Widget,因为它对全局容器有隐式依赖。

真正的依赖注入,是把依赖从外部传进来。而 Get.find 做的事情,本质上就是一个全局 Map 的查找,只是包了一层类型安全的外壳而已。


问题二:Controller 注册时机是一个隐形的定时炸弹

我在项目里实际遇到了这样的代码,最开始我以为是自己写的有问题,后来才意识到这是 GetX 设计本身带来的:

void someMethod() {
  if (Get.isRegistered<AnotherLogic>()) {
    try {
      Get.find<AnotherLogic>().doSomething();
    } catch (e) {
      Future.delayed(const Duration(milliseconds: 100), () {
        if (Get.isRegistered<AnotherLogic>()) {
          Get.find<AnotherLogic>().doSomething();
        }
      });
    }
  }
}

注意看——这里有 isRegistered 检查,有 try-catch,还有 Future.delayed 兜底。为什么会写成这样?

因为 GetX 的 Controller 注册时机和 Widget 生命周期是分离且难以预测的。当 A 的 onInit 被调用时,B 可能还没注册进去。两个 Controller 之间相互依赖时,你没有一个可靠的方式来保证顺序,只能靠这种"等一会儿再试"的 hack。

这种代码一旦出现,就说明你的架构里有一个无法被类型系统或编译器检测到的隐患——一个随时可能因为时序问题而爆炸的地雷。


问题三:路由系统和 Flutter 原生 Navigator 的双轨并行

GetX 有自己的一套路由管理,Get.back()Get.to()Get.off(),这套 API 背后维护着 GetX 自己的导航栈。

问题在于,Flutter 本身也有一套 Navigator 栈。当你混用了 showDialogshowBottomSheet 这类原生方法,或者使用了某些第三方 UI 库,两套栈就会出现不同步的情况。

最典型的场景:底部弹出一个 BottomSheet,用户点击关闭,调用 Get.back()——结果关掉的不是 BottomSheet,而是后面的页面。因为 GetX 的栈以为当前最顶层是那个页面,而 Flutter 的 Navigator 知道顶层是 BottomSheet。

这类 bug 极难稳定复现,在测试阶段往往发现不了,偏偏在生产环境的某些特定操作路径下必现。而且一旦出现,表现就是页面凭空消失,用户一脸懵逼,你看日志也找不到任何异常。 吐槽: 我想你应该能体会到这个问题第一次出现的时候, 查遍了日志和测试人员一起反复的测试都无发复现, 但是生产人员却一直在提这个Bug的感受吗?


问题四:permanent: true 的幽灵

GetX 提供了 permanent 参数,让 Controller 在整个 App 生命周期内不被销毁:

Get.put(SomeService(), permanent: true);

这本来是用来处理全局单例服务的。但在实际开发中,这个参数很容易被滥用,或者说——在依赖关系复杂起来之后,你不得不把很多 Controller 标记为 permanent,因为你不知道它会在什么时候被 GetX 自动销毁。

结果就是:一堆"应该随页面销毁"的 Controller 变成了全局常驻对象,它们持有的资源(Stream 订阅、数据库连接、定时器……)永远不会被释放。Crashlytics 上的内存增长曲线会告诉你,你的 App 在连续操作几十分钟后内存占用会不断攀升。

GetX 的自动销毁机制听起来很美好,但它的触发条件是"当没有任何 Widget 依赖这个 Controller 时",这个判断本身在复杂页面嵌套下就很不可靠。


问题五:维护风险

这一点我觉得是最需要认真对待的。

GetX 把路由、状态管理、依赖注入、网络请求、国际化、主题、工具类……几乎所有东西都打包在一个包里。这种"大一统"的设计本身就是一种风险——你对一个生态如此深度绑定。

更重要的是,GetX 从始至终基本上是一个人在维护。不是 Google,不是 Flutter 团队,不是一个活跃的开源社区——是一个人。Issues 堆积,PR 几个月无回应,这在 GitHub 上都是公开可查的事实。

当你的项目依赖于一个可能随时停止维护的库来管理它的路由、状态和依赖注入,你承担的技术债务比你想象的要重得多。


为什么是 flutter_bloc + GetIt?

迁移之后,我选择了这个组合,说说我的理由。

flutter_bloc 的核心优势是可预测性。每一次状态变化都是显式的 Event → State 流转,你可以在任何时间点知道当前的状态是什么,是怎么来的。Bloc 天然适合单元测试,因为它就是一个接收输入、产生输出的函数,不依赖任何全局状态。bloc_test 提供的 DSL 让测试写起来非常顺手。

GetIt 是一个纯粹的服务定位器(Service Locator),它只做一件事:依赖注入。它不碰路由,不碰状态,就是一个类型安全的全局容器。与 injectable 搭配使用时,可以通过注解自动生成注册代码,极大减少样板代码。最重要的是,GetIt 是一个人们可以放心依赖的、久经考验的库,有大量大型项目在生产中使用。

路由方面我用回了原生 Navigator 2.0 或者 go_router——Flutter 官方出品,跟着 Flutter 一起更新,稳定性有保证。


一些真心话

我不是说 GetX 没有价值, 现在公司多数的APP项目还是在使用它。它降低了 Flutter 的入门门槛,让很多初学者能快速搭起一个能跑的应用,这是实实在在的贡献。

但有一句话我觉得挺有道理:GetX 给了你一把能很快建起房子的电动工具,但这把工具的设计,让你在建的过程中很难检查地基有没有问题。

当项目还小的时候,GetX 的问题都能被"快速开发"的效率掩盖住。等项目大了,屏数多了,逻辑复杂了,那些被掩盖的问题就会以各种奇怪的方式冒出来——路由乱跳、状态不同步、内存上涨、测试无法写……

迁移是痛苦的,但值得。 推荐一个网站: 里面的文章深受启发, 需要翻墙偶😯 medium

SSE 同域长连接排队问题解析与前端最佳实践

作者 leafyyuki
2026年3月27日 17:35

在基于 Server-Sent Events(SSE) 的 Web 应用中,你是否遇到过这样的困惑:单条数据流一切正常,但同一页面内同时建立多条流时,延迟陡增,新请求长时间挂起?这很可能不是后端处理慢,而是浏览器的同域并发连接限制在“作祟”。本文将深入剖析这一现象背后的原理,并提供一套从前端到架构的通用解决方案。

一、问题现象:容易被误判的“慢”

当你的应用出现以下情况时,应当优先排查 SSE 连接管理问题,而非直接归咎于后端性能:

  • 并发流延迟:单个 SSE 连接响应迅速,但页面内对同一域名同时发起两条或以上 SSE 连接时,延迟显著增加,甚至请求被挂起。
  • “僵尸”连接阻塞:用户已取消上一个操作或切换了筛选条件,新的 SSE 请求需要等待很长时间才能建立,仿佛在排队。
  • 监控与体感不符:服务端监控显示接口耗时极短,但用户从点击到看到数据反馈的端到端体验却非常“慢”。

这些现象常被误判为网关超时或后端服务瓶颈,实则根源多在网络连接层

二、核心原理:为何“同域”会成为瓶颈?

1. 浏览器的连接池限制

在 HTTP/1.1 环境下,浏览器对同一 host(协议+域名+端口)通常会维护一个数量有限的并行 TCP 连接池(常见上限为6个)。短请求会快速释放连接,而 SSE 作为长连接,会持续占用其中一个名额。当多个 SSE 连接与常规的 XHR/Fetch 请求、静态资源加载共享同一域名时,极易触达上限,新请求只能排队等待。

2. 按“域”非按“路径”限制

关键一点:此限制是基于域名的。同一域名下的不同 API 路径(如 /api/streamA和 /api/streamB共享同一个连接池,无法通过增加路径来绕过限制。

3. 连接释放的滞后性

即使前端调用了 EventSource.close()或 AbortController.abort()主动关闭连接,底层的 TCP 连接可能进入 TIME_WAIT状态,或者经过 HTTP 代理、CDN 时,上游连接的回收存在延迟。这会导致短时间内连续建立新连接时,仍能感受到“排队”现象。

4. 代理与 HTTP/2 的影响

  • 反向代理:如果代理服务器对 text/event-stream类型的响应启用了缓冲,或设置了不合理的读写超时,会直接引发卡顿、断连等问题。
  • HTTP/2:其多路复用特性可以有效缓解 HTTP/1.1 的连接数限制压力。但 SSE 在 HTTP/2 上的实际表现,需在目标部署环境下具体验证。

三、问题根因排查清单

遇到 SSE 延迟问题时,可对照下表快速定位可能的原因:

根因类型 具体表现与说明
同域多路长连接 页面内多个独立模块各自创建 SSE 连接,快速耗尽同 host 连接池。
未及时释放连接 组件销毁、路由切换后,未调用关闭方法,连接存活直至超时,持续占用名额。
重复建连 发起新请求前未取消旧的流,导致针对同一数据源存在多条并行流。
重连风暴 连接断开后,无退避机制的高频重试逻辑短时间发起大量请求,占满连接池。
部署环境问题 网关、代理或负载均衡器对 SSE 长连接的支持策略(如缓冲、超时、连接限制)配置不当。

四、分层解决方案

层级 1:架构与产品设计(优先考虑)

  • 合并连接(首选) :评估是否能让页面内的多个消费方共用一条 SSE 连接。服务端推送不同类型的事件,前端再根据事件类型分发。从源头上减少连接数。
  • 拆分域名:如果必须使用多条独立连接,可将它们分配至不同的子域(如 stream-a.example.comstream-b.example.com)。每个子域拥有独立的浏览器连接池。需注意 CORS、Cookie 作用域和运维成本。
  • 降低非核心流实时性:对实时性要求不高的数据更新,改用短轮询或长轮询,将稀缺的长连接资源留给核心实时流。

层级 2:前端连接生命周期管理(必须遵守)

  • 卸载即释放:在 SPA 或组件化框架中,务必在组件销毁的生命周期钩子(如 onBeforeUnmountuseEffect的清理函数)中,调用 EventSource.close()或 AbortController.abort()
  • 新请求前取消旧请求:在同一数据源发起新的 SSE 请求前,必须确保先取消(abort)上一次的请求,防止旧的连接未被清理。
  • 整页退出处理:监听 pagehide或 beforeunload事件,主动关闭所有 SSE 连接,确保资源及时回收。

层级 3:实现模式参考

无论是使用原生的 EventSource还是基于 fetch的封装库(如 @microsoft/fetch-event-source),都应遵循以下模式:

// 伪代码示例:基于 AbortController 的管理模式
let currentAbortController = null;

async function startNewSSEStream(url) {
  // 1. 发起新请求前,先取消可能存在的旧请求
  if (currentAbortController) {
    currentAbortController.abort();
  }

  // 2. 创建新的控制器并发起请求
  currentAbortController = new AbortController();
  try {
    const response = await fetch(url, {
      signal: currentAbortController.signal
    });
    // ... 处理 SSE 流
  } catch (err) {
    if (err.name === ‘AbortError’) {
      // 请求被正常取消,无需处理
      return;
    }
    // 处理其他错误
  }
}

// 3. 在组件卸载时清理
function onComponentUnmount() {
  if (currentAbortController) {
    currentAbortController.abort();
  }
}

最佳实践:将上述连接管理逻辑(创建、取消、清理)封装成可复用的 Hook、Composable 或高阶函数,避免在业务代码中散落重复的样板代码。

层级 4:重连策略

  • 无需自动重连:对于一次性的流式请求(如导出、报告生成),失败后应由用户手动重试,无需在前端实现自动重连。
  • 需要自动重连:对于需持久化的订阅型连接,应实现指数退避重试,并增加随机抖动,避免所有客户端同时重连形成“重连风暴”。同时设定最大重试次数上限。

层级 5:部署与协议优化

  • 在 Nginx、HAProxy 等代理配置中,为 SSE 路径禁用响应缓冲,并设置适合长连接的、更长的超时时间。
  • 确保生产环境启用了 HTTPS 并正确支持 HTTP/2,以利用多路复用特性提升连接效率。

五、总结

当使用 SSE 遇到性能问题时,请按以下思路排查:

  1. 首先检查连接层:通过浏览器开发者工具的 Network 面板,查看是否存在对同一域名的多个长时间存活的 SSE 连接(Pending或 Stalled状态)。
  2. 坚守前端纪律务必做到“先取消,后新建”和“卸载即释放”。这是避免连接泄漏和排队的基础。
  3. 评估架构优化:优先考虑合并连接;若不可行,再评估拆分子域或调整非核心数据同步方式。
  4. 合理配置环境:与运维同事协作,检查网关和代理配置,确保其对 SSE 友好,并验证 HTTP/2 是否生效。

通过系统性地应用以上实践,可以有效解决 SSE 同域连接排队问题,提升流式应用的稳定性和用户体验。

最新版vue3+TypeScript开发入门到实战教程之路由详解三

作者 angerdream
2026年3月27日 16:27

上节内容是嵌套路由与路由传参。路由的传参有两方式,一是query,一是params。这节主要内容:

  • 路由的props
  • 路由的replace属性
  • 路由编程式导航
  • 路由的重定向

1、 什么是路由的props

前几节详细讲解组件的props,父组件给子组件传递参数,就是通过props。如下例:父组件将变量fish、price传递给子组件fish

<Fish :name="fish" :price="price"/>

子组件通过defineProps接收参数,可在模版中直接使用

<template>
  <h2>我是子组件</h2>
  <h3>{{ name }}</h3>
  <h3>{{ price }}</h3>
</template>
<script setup lang="ts">
defineProps(['name', 'price']);
</script>

1.1路由的props

路由传参有两种方式,一是通过query,一是通过params,props就是第三种方式。用户通过点击router-link标签时,会跳转到对应的路由,此时组件被创建。是路由创建的组件。以下是路由的props的含义: 路由在创建组件时,可把路由参数通过组件的props传递给组件。 路由传递参数给组件的props,有两种方式:

  • 默认传参params
  • 通过函数传递自定义数据

1.2props默认传参

props默认传参是params。以父组件Fish点击路由,跳转到Fishdetail组件为例。

  • 创建Fish组件传递params参数
  • 创建路由,路由配置props参数为true
  • 创建FishDetail组件,通过defineProps接收props参数 Fish组件代码
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',params:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件代码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
defineProps(['name', 'id', 'price']);
</script>

路由代码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail/:id/:name/:price?',
        component: FishDetial,
        props:true
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

运行查看效果 在这里插入图片描述 对比路由params传参与props默认传参的区别

  • props默认传参,在子组件中不需要通过useRoute()接收路由参数
  • props默认传参,通过defineProps接收数据,可直接在模版中使用
  • props默认传参,在路由中设置props为true即可

1.3路由props通过函数传递自定义数据

自定义数据可以根据需要来定义,以传递query数据为例。 路由配置:

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
  const routes = [
    {
      name: 'fish',
      path: '/fish',
      component: Fish,
      children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
    },
    { path: '/cat', component: Cat },
    { path: '/bird', component: Bird }, // 动态路由
  ]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

Fish代码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail代码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
defineProps(['name', 'id', 'price']);
</script>

注意核心代码分两处,一是路由的配置,一是route-link跳转

 children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
 <router-link
      :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">
         {{ item.name }}
 </router-link>

2、路由的replace属性

与push相对,当点击路由进入页面时。默认是push模式,push是一个一个页面堆叠在一起,点击浏览器返回键,可返回到上一页面。replace属性则不是,它只有一个页面,当点击路由时,它替换当前页面。如下:如需要给标签加上replace即可

    <router-link replace :to="{name:'fish'}">跳转到鱼</router-link>
<router-link replace to="/cat">跳转到猫</router-link>
    <router-link replace to="/bird">跳转到鸟</router-link>

如图,点击路由跳转时,无法后退 在这里插入图片描述

3、编程式路由导航

编程式路由导航是在开发中使用最常见的一种方式,而前边使用的router-link,实则就是a标签。

<a href="/cat" class="">跳转到鱼</a>
<router-link replace to="/cat">跳转到猫</router-link>

这两种写法等效。编程式导航是使用api跳转路由,如打开页面三秒跳转到cat页面,再如用户登录成功后跳转到个人页面。这些都需要编程式导航。

  • 创建Fish组件、FishDetail
  • 在Fish组件引入useRouter函数,创建路由器router,注意与route区别
  • router使用push或者replace跳转到对应路由
  • push或者replace函数的参数与router-link中的to参数使用方法是一样的 Fish组件
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <!-- <router-link :to="`/fish/detail/${item.id}/${item.name}`">{{item.name  }}</router-link> -->
         <button @click="goDetail(item)">查看{{ item.name }}</button>
        <!-- <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link> -->
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { useRouter } from 'vue-router';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
let router = useRouter();
function goDetail(fish: any) {
  router.push({
    name: 'fishdetail',
    query:fish
  });
}
</script>

FishDetail组件源码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
// let route = useRoute();
defineProps(['name', 'id', 'price']);
</script>

路由源码

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
  const routes = [
    {
      name: 'fish',
      path: '/fish',
      component: Fish,
      children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
    },
    { path: '/cat', component: Cat },
    { path: '/bird', component: Bird }, // 动态路由
  ]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

运行查看效果: 在这里插入图片描述 注意对比一下router.push的参数与router-link的to参数。两者参数用法是一致的,不管路由如何配置,是使用query还是params传参还是props传参。 如下例:

 <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">
         {{ item.name }}</router-link>
function goDetail(fish: any) {
  router.push({
    name: 'fishdetail',
    query:fish
  });
}

router-link的to用法与 router.push用法一致。

4、路由的重定向

路由的重定向,就是访问a路由,自动跳转到b路由。如打开主页,默认访问http://localhost:5173/,访问的路径是/,能否一打开就跳转到/fish。就用重定向来解决。

 {
    path: '/',
    redirect: '/fish'

  },

路由具体代码:

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
const routes = [
  {
    path: '/',
    redirect: '/fish'

  },
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail',
        component: FishDetial,
        props(route: any) {
          return route.query;
        }
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

效果是一打开页面,就重定向到fish页面 在这里插入图片描述

关于Scheduler 类,一个并发控制调度器

2026年3月27日 16:09

代码实现

class Scheduler {
    constructor(limit) {
        this.limit = limit      // 最大并发数
        this.queue = []         // 等待队列
        this.running = 0        // 当前运行中的任务数
    }

    add(task) {
        return new Promise(resolve => {
            // 将任务包装后加入队列
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        // 达到并发上限或无任务时返回
        if (this.running >= this.limit || !this.queue.length) return
        
        // 执行任务
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()  // 执行下一个任务
        })
    }
}

执行流程可视化

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            console.log(`添加任务到队列,当前队列长度: ${this.queue.length + 1}`)
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) {
            console.log(`运行状态: running=${this.running}, 队列长度=${this.queue.length}`)
            return
        }
        
        this.running++
        const task = this.queue.shift()
        console.log(`开始执行任务,当前并发: ${this.running}/${this.limit}`)
        
        task().finally(() => {
            console.log(`任务完成,当前并发: ${this.running-1}/${this.limit}`)
            this.running--
            this.run()
        })
    }
}

// 测试
const scheduler = new Scheduler(2)

const createTask = (name, delay) => () => 
    new Promise(resolve => {
        setTimeout(() => {
            console.log(`${name} 完成`)
            resolve(name)
        }, delay)
    })

scheduler.add(createTask('任务1', 1000))
scheduler.add(createTask('任务2', 500))
scheduler.add(createTask('任务3', 300))
scheduler.add(createTask('任务4', 400))

/* 输出示例:
添加任务到队列,当前队列长度: 1
开始执行任务,当前并发: 1/2
添加任务到队列,当前队列长度: 1
开始执行任务,当前并发: 2/2
添加任务到队列,当前队列长度: 1
运行状态: running=2, 队列长度=1
添加任务到队列,当前队列长度: 2
运行状态: running=2, 队列长度=2
任务2 完成
任务完成,当前并发: 1/2
开始执行任务,当前并发: 2/2
任务3 完成
任务完成,当前并发: 1/2
开始执行任务,当前并发: 2/2
任务4 完成
任务完成,当前并发: 1/2
任务1 完成
任务完成,当前并发: 0/2
*/

详细分析

1. 构造函数

constructor(limit) {
    this.limit = limit      // 并发限制数
    this.queue = []         // 任务队列(存储包装后的函数)
    this.running = 0        // 当前正在执行的任务数
}

2. add 方法

add(task) {
    return new Promise(resolve => {
        // 关键:将任务包装成可执行函数并加入队列
        this.queue.push(() => task().then(resolve))
        this.run()
    })
}

关键点

  • 返回 Promise,外部可以等待任务完成
  • 任务被包装:() => task().then(resolve)
  • 包装函数执行时会调用原任务,并在完成后 resolve 外部 Promise

3. run 方法

run() {
    if (this.running >= this.limit || !this.queue.length) return
    
    this.running++
    const task = this.queue.shift()
    task().finally(() => {
        this.running--
        this.run()  // 递归调用,执行下一个任务
    })
}

关键点

  • 检查是否达到并发上限
  • 从队列取出任务并执行
  • 任务完成后减少计数并继续执行

使用示例

示例1:限制请求并发

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 模拟 API 请求
function fetchUser(id) {
    return () => new Promise(resolve => {
        console.log(`开始请求用户 ${id}`)
        setTimeout(() => {
            console.log(`用户 ${id} 数据返回`)
            resolve({ id, name: `User${id}` })
        }, Math.random() * 2000)
    })
}

const scheduler = new Scheduler(3)
const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 添加所有请求
const promises = userIds.map(id => 
    scheduler.add(fetchUser(id))
)

// 等待所有请求完成
Promise.all(promises).then(results => {
    console.log('所有用户数据:', results)
})

示例2:文件上传控制

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 模拟文件上传
function uploadFile(fileName, size) {
    return () => new Promise(resolve => {
        const startTime = Date.now()
        console.log(`开始上传: ${fileName} (${size}MB)`)
        
        // 模拟上传耗时
        setTimeout(() => {
            const duration = Date.now() - startTime
            console.log(`完成上传: ${fileName},耗时 ${duration}ms`)
            resolve({ fileName, size, duration })
        }, size * 500)  // 每MB 500ms
    })
}

const scheduler = new Scheduler(2)

const files = [
    { name: 'video.mp4', size: 10 },
    { name: 'image.jpg', size: 2 },
    { name: 'document.pdf', size: 1 },
    { name: 'music.mp3', size: 5 },
    { name: 'archive.zip', size: 8 }
]

files.forEach(file => {
    scheduler.add(uploadFile(file.name, file.size))
        .then(result => console.log(`${result.fileName} 上传成功`))
})

示例3:爬虫并发控制

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 模拟爬虫
function crawlUrl(url) {
    return () => new Promise(resolve => {
        console.log(`[${new Date().toLocaleTimeString()}] 爬取: ${url}`)
        
        setTimeout(() => {
            console.log(`[${new Date().toLocaleTimeString()}] 完成: ${url}`)
            resolve({ url, data: `内容来自 ${url}` })
        }, Math.random() * 2000)
    })
}

const scheduler = new Scheduler(3)
const urls = [
    'https://example.com/page1',
    'https://example.com/page2',
    'https://example.com/page3',
    'https://example.com/page4',
    'https://example.com/page5',
    'https://example.com/page6',
    'https://example.com/page7',
    'https://example.com/page8'
]

const results = []
urls.forEach(url => {
    scheduler.add(crawlUrl(url))
        .then(result => results.push(result))
})

// 监听完成
setTimeout(() => {
    console.log(`\n共爬取 ${results.length} 个页面`)
}, 10000)

边界情况测试

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 测试各种边界情况
console.log('=== 边界测试 ===\n')

// 1. limit = 0
const scheduler1 = new Scheduler(0)
scheduler1.add(() => Promise.resolve('test'))
    .then(console.log)
console.log('limit=0: 任务永远不会执行')

// 2. limit = 1 (串行)
const scheduler2 = new Scheduler(1)
const startTime = Date.now()
scheduler2.add(() => new Promise(r => setTimeout(() => r('任务1'), 1000)))
scheduler2.add(() => new Promise(r => setTimeout(() => r('任务2'), 1000)))
scheduler2.add(() => new Promise(r => setTimeout(() => r('任务3'), 1000)))
    .then(() => {
        const duration = Date.now() - startTime
        console.log(`串行执行总耗时: ${duration}ms (约3000ms)`)
    })

// 3. 任务失败处理
const scheduler3 = new Scheduler(2)
scheduler3.add(() => Promise.reject('错误'))
    .catch(e => console.log('捕获到错误:', e))
scheduler3.add(() => Promise.resolve('成功'))
    .then(r => console.log('成功:', r))

// 4. 动态添加任务
const scheduler4 = new Scheduler(2)
setTimeout(() => {
    console.log('动态添加任务')
    scheduler4.add(() => Promise.resolve('动态任务'))
}, 1000)

改进版本

改进1:支持任务优先级

class PriorityScheduler extends Scheduler {
    add(task, priority = 0) {
        return new Promise(resolve => {
            const wrappedTask = () => task().then(resolve)
            // 按优先级插入队列
            let index = this.queue.findIndex(item => item.priority < priority)
            if (index === -1) index = this.queue.length
            this.queue.splice(index, 0, { task: wrappedTask, priority })
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const { task } = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

改进2:支持任务超时

class TimeoutScheduler extends Scheduler {
    add(task, timeout = null) {
        return new Promise((resolve, reject) => {
            const wrappedTask = () => {
                if (timeout) {
                    return Promise.race([
                        task(),
                        new Promise((_, reject) => 
                            setTimeout(() => reject(new Error('任务超时')), timeout)
                        )
                    ]).then(resolve, reject)
                }
                return task().then(resolve, reject)
            }
            this.queue.push(wrappedTask)
            this.run()
        })
    }
}

改进3:支持进度回调

class ProgressScheduler extends Scheduler {
    constructor(limit) {
        super(limit)
        this.total = 0
        this.completed = 0
    }

    add(task) {
        this.total++
        return new Promise(resolve => {
            this.queue.push(() => task().then(result => {
                this.completed++
                this.onProgress?.(this.completed, this.total)
                resolve(result)
            }))
            this.run()
        })
    }

    onProgress(callback) {
        this.onProgress = callback
        return this
    }
}

// 使用
const scheduler = new ProgressScheduler(3)
scheduler.onProgress((completed, total) => {
    console.log(`进度: ${completed}/${total} (${Math.round(completed/total*100)}%)`)
})

改进4:支持暂停/恢复

class PausableScheduler extends Scheduler {
    constructor(limit) {
        super(limit)
        this.paused = false
    }

    pause() {
        this.paused = true
    }

    resume() {
        this.paused = false
        this.run()
    }

    run() {
        if (this.paused) return
        super.run()
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }
}

与其他并发控制对比

// 1. Promise.all - 无并发限制
const all = Promise.all(tasks.map(t => t()))

// 2. Promise.allSettled - 无并发限制
const settled = Promise.allSettled(tasks.map(t => t()))

// 3. 你的 Scheduler - 有并发限制
const scheduler = new Scheduler(3)
tasks.forEach(t => scheduler.add(t))

// 4. p-limit 库
const pLimit = require('p-limit')
const limit = pLimit(3)
const promises = tasks.map(t => limit(() => t()))

性能分析

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 性能测试
async function performanceTest() {
    const tasks = Array(100).fill().map((_, i) => 
        () => new Promise(r => setTimeout(() => r(i), Math.random() * 100))
    )
    
    // 测试不同并发限制
    for (const limit of [1, 5, 10, 20]) {
        const scheduler = new Scheduler(limit)
        const start = Date.now()
        
        await Promise.all(tasks.map(task => scheduler.add(task)))
        
        const duration = Date.now() - start
        console.log(`并发限制 ${limit}: ${duration}ms`)
    }
}

performanceTest()

Skill 与 Agent:AI 开发中两个最容易混淆的概念

作者 小哈猪
2026年3月27日 16:02

Skill 与 Agent:AI 开发中两个最容易混淆的概念

入门 AI 开发时,很多人会被这两个词搞懵:Skill 和 Agent 到底有什么区别?都是 Python 文件,都能调用工具,为什么要分开?本文用最直白的方式,彻底讲清楚这两个概念。


一、先从一个比喻开始

想象你开了一家餐厅:

  • Agent = 餐厅的大厨:他会思考、决策,根据客人的需求决定做什么菜、用什么食材、什么顺序出菜。他是有主动意识的执行者。
  • Skill = 大厨手边的菜谱和工具:炒锅、菜谱、调味料……这些本身不会思考,但大厨需要它们才能完成任务。
用户需求
   ↓
[Agent 大脑] → 思考 → 决定用哪个 Skill
   ↓
[Skill 工具] → 执行 → 返回结果
   ↓
[Agent 大脑] → 整合结果 → 回答用户

二、Skill 是什么?

Skill(技能/插件) 是一个具有明确功能边界的可调用模块。它本身不会主动做任何事,只在被调用时执行特定任务。

Skill 的三个核心特征

1. 功能单一 每个 Skill 只做一件事:查天气、搜索网页、发消息、签到……职责越单一越好。

2. 被动执行 Skill 不会主动触发,必须被 Agent 或用户显式调用。

3. 有描述文件 在 OpenClaw 等平台中,Skill 通常有一个 SKILL.md 说明文件,告诉 AI 系统这个技能是干什么的、怎么用。

skill/
  SKILL.md          ← 给 AI 看的说明书(自然语言描述)
  scripts/
    get_weather.py  ← 实际执行逻辑

SKILL.md 长什么样?

---
name: weather
description: 查询天气预报,支持全球城市
---

## 使用方法
curl "wttr.in/{城市名}?format=3"

## 参数
- city: 城市名称,如:上海、北京

AI 读懂这个文件后,就知道什么时候该调用它、怎么调用。


三、Agent 是什么?

Agent(智能体) 是一个能够自主感知、思考、决策并执行任务的 AI 系统。它有一个「大脑」(LLM),有一套工具(Skill 或函数),有一个运行循环。

Agent 的四个核心要素

┌─────────────────────────────────────┐
│              Agent                  │
│                                     │
│  ┌─────────┐    ┌────────────────┐  │
│  │  感知   │    │    决策/规划   │  │
│  │ Perceive│───▶│     Plan       │  │
│  └─────────┘    └───────┬────────┘  │
│                         │           │
│  ┌─────────┐    ┌───────▼────────┐  │
│  │  记忆   │    │    执行工具    │  │
│  │ Memory  │    │    Act/Tools   │  │
│  └─────────┘    └────────────────┘  │
└─────────────────────────────────────┘

1. 感知(Perceive):接收用户输入、环境信息

2. 决策(Plan):LLM 大脑分析需求,决定下一步做什么

3. 执行(Act):调用工具/Skill,获取外部信息或执行操作

4. 记忆(Memory):记住对话历史,支持多轮交互

最简单的 Agent 代码结构

def run_agent(user_input):
    messages = [{"role": "user", "content": user_input}]
    
    while True:
        # 1. LLM 思考:要做什么?
        response = llm.chat(messages, tools=TOOLS)
        
        # 2. 不需要工具 → 直接回答
        if not response.tool_calls:
            return response.content
        
        # 3. 需要工具 → 调用工具
        for tool_call in response.tool_calls:
            result = execute_tool(tool_call)  # 这里调用 Skill
            messages.append(tool_result(result))
        
        # 4. 拿到工具结果 → 继续思考

这个循环:思考 → 行动 → 观察 → 再思考,就是 Agent 的本质,也叫 ReAct 模式


四、核心区别一览

对比维度 Skill Agent
本质 工具/插件 智能体/系统
有无大脑 ❌ 无 ✅ 有 LLM
主动性 被动,等待调用 主动,自主决策
复杂度 低,单一功能 高,多工具协调
入口文件 SKILL.md(自然语言) Python/代码(逻辑)
调用方 Agent 或平台 用户或系统
典型例子 查天气、搜索、签到 ChatGPT、AutoGPT
能否独立运行 ❌ 需要宿主 ✅ 独立运行

五、它们如何配合工作?

一个完整的 AI 应用,通常是这样的结构:

┌──────────────────────────────────────────┐
│                  Agent                   │
│  ┌────────┐                              │
│  │  LLM   │  ← 大脑,负责思考和决策      │
│  └───┬────┘                              │
│      │ 调用                              │
│  ┌───▼──────────────────────────────┐   │
│  │           Skill 工具箱            │   │
│  │  [天气] [搜索] [签到] [发消息]   │   │
│  └──────────────────────────────────┘   │
└──────────────────────────────────────────┘

实际工作流程举例:

用户说:「今天上海天气适合出门吗?顺便帮我掘金签到」

Step 1: Agent 接收输入
        ↓
Step 2: LLM 分析:需要查天气 + 掘金签到,两个任务
        ↓
Step 3: 调用 weather Skill → 返回「晴天,22°C」
        ↓
Step 4: 调用 juejin_checkin Skill → 返回「签到成功,+200矿石」
        ↓
Step 5: LLM 整合结果,生成最终回答
        ↓
Step 6: 「上海今天晴天22°C,非常适合出门!掘金签到也完成了,获得200矿石 🎉」

六、在 OpenClaw 中的对应关系

如果你用过 OpenClaw(或类似的 AI 助手平台),可以这样理解:

OpenClaw 平台
    │
    ├── Agent(就是 AI 助手本身,有 LLM 大脑)
    │       ↓ 读取并调用
    └── Skills(扩展能力的插件)
            ├── weather/SKILL.md      → 查天气
            ├── baidu-search/SKILL.md → 百度搜索  
            ├── mx-stocks/SKILL.md    → 股票筛选
            └── juejin/SKILL.md       → 掘金签到
  • SKILL.md 是写给 AI 看的「工具说明书」,用自然语言描述这个工具是干什么的
  • AI Agent 读懂说明书后,在合适的时机自动调用对应的脚本
  • 这就是为什么 Skill 的入口是 .md 文件,而不是 .py 文件

七、自己写 Agent 时怎么设计 Skill?

如果你在用 Python 自己写 Agent(比如基于 LangChain 或直接调用 OpenAI API),Skill 在代码里就是工具函数 + 描述

# Skill 的代码形式:函数 + JSON Schema 描述
TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询城市天气",  ← 这就是给 LLM 看的「SKILL.md」
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"}
                }
            }
        }
    }
]

# Skill 的实现
def get_weather(city: str) -> str:
    return f"{city}:晴天,22°C"

本质上,无论是 OpenClaw 的 SKILL.md,还是 OpenAI 的 JSON Schema,都在做同一件事:告诉 LLM 这个工具叫什么、能做什么、需要什么参数。形式不同,目的一样。


八、总结

用一句话记住:

Agent 是会思考的大脑,Skill 是它手里的工具。

记忆方法
Skill 螺丝刀——功能单一,用的时候才拿起来
Agent 工程师——知道什么时候用哪把螺丝刀

学 AI 开发的路径建议:

  1. 先搞懂一个 Skill 怎么写(单一功能,容易理解)
  2. 再搞懂 Agent 的 ReAct 循环(思考→行动→观察)
  3. 最后学多 Agent 协作(多个 Agent 分工合作)

你已经在这条路上了——加油!🚀


如果这篇文章对你有帮助,欢迎点赞收藏~有问题欢迎评论区交流。

sleep 函数在React项目中的运用

2026年3月27日 16:00

核心原理

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

作用:创建一个延迟指定毫秒数后才会 resolve 的 Promise,用于实现异步等待。

执行流程

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 执行流程可视化
console.log('1. 开始执行')
sleep(2000).then(() => {
    console.log('3. 2秒后执行')
})
console.log('2. 立即执行,不阻塞')

// 输出顺序:
// 1. 开始执行
// 2. 立即执行,不阻塞
// 3. 2秒后执行

详细分析

1. Promise 构造器

new Promise(resolve => setTimeout(resolve, delay))
  • resolve 是 Promise 提供的回调函数
  • setTimeout 在延迟后调用 resolve
  • 当 resolve 被调用时,Promise 状态变为 fulfilled

2. 返回值

返回一个 Promise 对象,可以链式调用 .then() 或使用 async/await

使用方式

方式1:Promise.then()

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

console.log('开始')
sleep(1000).then(() => {
    console.log('1秒后')
    return sleep(1000)
}).then(() => {
    console.log('2秒后')
})

方式2:async/await

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function demo() {
    console.log('开始')
    await sleep(1000)
    console.log('1秒后')
    await sleep(1000)
    console.log('2秒后')
}

demo()

方式3:配合循环使用

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function countdown(seconds) {
    for (let i = seconds; i > 0; i--) {
        console.log(`${i} 秒...`)
        await sleep(1000)
    }
    console.log('时间到!')
}

countdown(5)

实际应用场景

场景1:轮询

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function pollData() {
    let retries = 3
    
    while (retries > 0) {
        try {
            const data = await fetchData()
            if (data) return data
            console.log('数据未就绪,1秒后重试...')
            await sleep(1000)
            retries--
        } catch (error) {
            console.log('请求失败,重试中...')
            await sleep(2000)
            retries--
        }
    }
    throw new Error('获取数据失败')
}

场景2:限流/节流

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function rateLimitedRequests(urls) {
    const results = []
    
    for (const url of urls) {
        const result = await fetch(url)
        results.push(result)
        await sleep(1000)  // 每秒最多请求一次
    }
    
    return results
}

场景3:模拟加载

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function simulateLoading() {
    console.log('加载中...')
    await sleep(2000)
    console.log('✅ 加载完成')
    
    console.log('处理数据...')
    await sleep(1500)
    console.log('✅ 处理完成')
    
    console.log('保存结果...')
    await sleep(1000)
    console.log('✅ 保存完成')
}

simulateLoading()

场景4:超时控制

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

function timeout(promise, ms) {
    return Promise.race([
        promise,
        sleep(ms).then(() => {
            throw new Error('操作超时')
        })
    ])
}

// 使用
async function fetchWithTimeout() {
    try {
        const data = await timeout(fetch('https://api.example.com'), 3000)
        console.log('数据:', data)
    } catch (error) {
        console.log('超时错误:', error.message)
    }
}

边界情况

1. delay 为 0

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

console.log('1. 同步代码')
sleep(0).then(() => console.log('3. 微任务后执行'))
console.log('2. 同步代码')

// 输出:
// 1. 同步代码
// 2. 同步代码
// 3. 微任务后执行

2. 负数 delay

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 负数会被转换为0或接近0
sleep(-1000).then(() => console.log('立即执行(微任务)'))
console.log('同步代码')

3. 非常大的 delay

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 最大延迟约 24.8 天(2^31-1 毫秒)
sleep(2 ** 31 - 1).then(() => console.log('永远不会执行?'))
// setTimeout 最大延迟约 24.8 天

性能分析

1. 精度问题

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function measurePrecision() {
    const start = Date.now()
    await sleep(1000)
    const actualDelay = Date.now() - start
    console.log(`期望: 1000ms, 实际: ${actualDelay}ms`)
    console.log(`误差: ${actualDelay - 1000}ms`)
}

measurePrecision()
// 通常在 1000-1010ms 左右(4-10ms 误差)

2. 内存占用

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 大量并发 sleep
async function manySleeps() {
    const promises = []
    for (let i = 0; i < 10000; i++) {
        promises.push(sleep(1000))
    }
    await Promise.all(promises)
    console.log('所有 sleep 完成')
}
// 会创建 10000 个定时器,内存占用较大

增强版本

版本1:支持取消

function sleep(delay) {
    let timeoutId
    const promise = new Promise(resolve => {
        timeoutId = setTimeout(resolve, delay)
    })
    
    promise.cancel = () => {
        clearTimeout(timeoutId)
    }
    
    return promise
}

// 使用
const sleepPromise = sleep(5000)
sleepPromise.then(() => console.log('不会执行'))

// 3秒后取消
setTimeout(() => {
    sleepPromise.cancel()
    console.log('已取消')
}, 3000)

版本2:支持值传递

function sleep(delay, value) {
    return new Promise(resolve => setTimeout(() => resolve(value), delay))
}

// 使用
sleep(1000, '完成').then(result => {
    console.log(result)  // 1秒后输出 '完成'
})

// 配合 async/await
async function demo() {
    const result = await sleep(2000, '数据已加载')
    console.log(result)
}

版本3:带状态提示

function sleep(delay, options = {}) {
    const { signal, onStart, onEnd } = options
    
    return new Promise((resolve, reject) => {
        if (signal?.aborted) {
            return reject(new Error('已取消'))
        }
        
        onStart?.()
        
        const timeoutId = setTimeout(() => {
            onEnd?.()
            resolve()
        }, delay)
        
        signal?.addEventListener('abort', () => {
            clearTimeout(timeoutId)
            reject(new Error('已取消'))
        })
    })
}

// 使用 AbortController
const controller = new AbortController()
sleep(5000, { signal: controller.signal })
    .then(() => console.log('完成'))
    .catch(e => console.log('取消:', e.message))

setTimeout(() => controller.abort(), 2000)

版本4:精确延迟(使用 performance)

function sleepPrecise(delay) {
    const start = performance.now()
    return new Promise(resolve => {
        function tick(now) {
            if (now - start >= delay) {
                resolve()
            } else {
                requestAnimationFrame(tick)
            }
        }
        requestAnimationFrame(tick)
    })
}

// 更精确但更耗 CPU
async function test() {
    const start = performance.now()
    await sleepPrecise(1000)
    const actual = performance.now() - start
    console.log(`精确延迟: ${actual.toFixed(2)}ms`)
}

与其他实现对比

// 1. 你的实现
function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 2. 使用 setTimeout 直接返回
function sleep2(delay) {
    return new Promise(resolve => {
        setTimeout(resolve, delay)
    })
}

// 3. 使用 async/await 包装(多余)
async function sleep3(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 4. 带错误处理
function sleep4(delay) {
    if (typeof delay !== 'number' || delay < 0) {
        return Promise.reject(new Error('delay must be a positive number'))
    }
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 性能测试
async function comparePerformance() {
    const iterations = 1000
    
    console.time('sleep 原始')
    for (let i = 0; i < iterations; i++) {
        await sleep(10)
    }
    console.timeEnd('sleep 原始')
    
    console.time('sleep4 带检查')
    for (let i = 0; i < iterations; i++) {
        await sleep4(10)
    }
    console.timeEnd('sleep4 带检查')
}

常见使用模式

1. 顺序延迟

async function sequentialDelay() {
    console.log('步骤1')
    await sleep(1000)
    console.log('步骤2')
    await sleep(1000)
    console.log('步骤3')
}

2. 并发延迟

async function concurrentDelay() {
    const promises = [
        sleep(1000).then(() => '任务1'),
        sleep(2000).then(() => '任务2'),
        sleep(3000).then(() => '任务3')
    ]
    const results = await Promise.all(promises)
    console.log('所有任务完成:', results)
}

3. 指数退避

async function exponentialBackoff(fn, maxRetries = 5) {
    let delay = 1000
    
    for (let i = 0; i < maxRetries; i++) {
        try {
            return await fn()
        } catch (error) {
            if (i === maxRetries - 1) throw error
            
            console.log(`第 ${i + 1} 次重试,等待 ${delay}ms...`)
            await sleep(delay)
            delay *= 2  // 指数增长
        }
    }
}

总结

 sleep 函数:

✅ 优点

  1. 简洁优雅:一行代码实现核心功能
  2. 性能良好:使用原生 Promise 和 setTimeout
  3. 易于使用:支持 then 和 async/await
  4. 无副作用:纯函数,不修改外部状态

✅ 应用场景

  • 测试异步代码
  • 实现轮询
  • 控制请求频率
  • 模拟耗时操作
  • 动画和过渡效果
  • 超时控制

⚠️ 注意事项

  • 精度约为 4-10ms(受事件循环影响)
  • 大量并发可能占用较多定时器资源
  • 不会阻塞事件循环(非阻塞延迟)

CSS Flex 与 Grid:谁才是布局之王?

作者 小哈猪
2026年3月27日 15:53

CSS Flex 与 Grid:谁才是布局之王?

在现代前端开发中,Flexbox 和 Grid 是两大最强布局工具。很多开发者在选择时感到困惑:到底该用哪个?本文带你彻底搞清楚两者的区别,以及各自最适合的场景。


一、核心理念不同

Flexbox(弹性盒) 是一维布局模型,它一次只处理一个方向——要么横向(行),要么纵向(列)。你可以把它想象成一排弹性橡皮筋,元素可以伸缩、对齐,但本质上是线性的。

Grid(网格) 是二维布局模型,同时控制行和列。你可以把它想象成一张表格,但比表格强大得多——元素可以跨行、跨列,整体布局一目了然。

/* Flexbox:一维,沿主轴排列 */
.flex-container {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

/* Grid:二维,同时定义行列 */
.grid-container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: auto;
  gap: 16px;
}

二、核心区别对比

对比项 Flexbox Grid
维度 一维(行或列) 二维(行+列)
控制方向 由内容决定 由布局决定
对齐能力 强(主轴+交叉轴) 更强(行+列双向)
元素跨越 不支持跨行/列 支持 span 跨行列
间距控制 gap(现代)/ margin gap(精准)
浏览器支持 极好 极好(IE11部分支持)
上手难度

三、Flexbox 最擅长的场景

Flex 更适合组件内部的微观布局,比如:

1. 导航栏

.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 24px;
}

2. 按钮组 / 标签组

.tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

3. 垂直居中(经典场景)

.center {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}

4. 卡片内容排列

.card {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

四、Grid 最擅长的场景

Grid 更适合页面整体的宏观布局,比如:

1. 整体页面结构

.page {
  display: grid;
  grid-template-areas:
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 240px 1fr;
  grid-template-rows: 60px 1fr 40px;
  min-height: 100vh;
}

.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.footer { grid-area: footer; }

2. 图片瀑布流 / 画廊

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 16px;
}

3. 不规则元素跨行列

.featured {
  grid-column: span 2;
  grid-row: span 2;
}

五、谁的功能更强?

客观来说,Grid 功能更强大。

原因如下:

  1. Grid 是 Flex 的超集:几乎所有 Flex 能做的,Grid 都能做;但 Grid 能做的,Flex 做不到。
  2. 二维控制:Grid 同时掌控行和列,Flex 只能管一个方向。
  3. 更精准的定位:Grid 可以精确指定元素放在第几行第几列,甚至跨越多行多列。
  4. 布局意图更清晰:Grid 布局代码直接描述整体结构,可读性更好。

但这不意味着你应该抛弃 Flex。强大不等于适合


六、正确的使用姿势

Grid 管大局,Flex 管细节

在实际项目中,两者经常配合使用:

/* Grid 定义页面骨架 */
.layout {
  display: grid;
  grid-template-columns: 260px 1fr;
  gap: 24px;
}

/* Flex 处理组件内部排列 */
.card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

一个简单的判断标准:

  • 需要同时控制行和列?→ 用 Grid
  • 只需要单方向排列内容?→ 用 Flex
  • 整体页面布局?→ Grid
  • 组件内部对齐?→ Flex

总结

Flexbox Grid
适合 组件级、单维度、内容驱动 页面级、双维度、布局驱动
一句话 让内容自适应流动 先定好格子再填内容
功能强度 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

Flex 和 Grid 不是竞争关系,而是互补关系。掌握两者,才能在面对任何布局需求时游刃有余。

如果只能选一个入门,先学 Flex,因为它更直觉、上手快;等你对布局有了感觉,再深入 Grid,你会发现一个全新的世界。


如果这篇文章对你有帮助,欢迎点赞收藏~

质量与交付篇(3/6):崩溃分析与线上问题回溯机制

2026年3月27日 15:38

崩溃分析与线上问题回溯机制:从“看到报错”到“快速止血”

系列:质量与交付篇(3/6)
标签:Flutter CI/CD 自动化构建 签名 发布

线上最怕的不是崩溃本身,而是:崩了但不知道谁、何时、为何崩
这篇文章讲我在 Flutter 项目里落地的一套闭环:崩溃采集 → 版本定位 → 用户路径还原 → 快速修复验证


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

在多人协作、快节奏发版的 Flutter 项目里,常见问题有:

  • 崩溃平台有告警,但只看到一条 Null check operator used on a null value
  • 同一个异常在不同机型、不同系统版本表现不一致
  • 热修前后告警量变化大,但缺少统一维度比较
  • 客服反馈“点了就闪退”,研发却无法复现
  • 修复后没有验证机制,下一版又回归

结果就是:问题发现晚、定位慢、修复不稳


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

核心原因

  • 只接了崩溃采集,没接业务上下文(页面、用户动作、会话信息)
  • 缺少发布元数据(appVersion/buildNumber/gitSha/flavor
  • 错误分级混乱:把可恢复异常和致命崩溃混在一起
  • 没有“事件时间线”,只能看单点堆栈

排查过程(推荐顺序)

  1. 先看影响面:崩溃率、影响用户数、版本分布
  2. 再看归因:是否集中在某机型/系统/渠道
  3. 最后看链路:崩溃前 10~20 秒发生了什么(路由、请求、WS 事件、权限弹窗)

经验:80% 的定位时间花在“补上下文”而不是“看堆栈”。


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

方案对比

  • 仅 Crash SDK(最省事)
    优点:接入快;缺点:定位深度不足。
  • Crash + 日志平台分离(常见)
    优点:灵活;缺点:跨平台关联成本高。
  • 统一事件模型(推荐)
    优点:崩溃、业务日志、发布信息同一 trace 维度;缺点:初期要做规范。

最终选择(可落地)

采用“三层闭环”:

  1. 采集层:Flutter 全局异常 + Zone 异常 + 平台异常
  2. 上下文层:注入会话、路由、请求摘要、设备信息、版本信息
  3. 回溯层:按 traceId/sessionId/release 关联崩溃前事件,形成时间线

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

4.1 全局异常兜底

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details);
    CrashReporter.recordFlutterError(details);
  };

  PlatformDispatcher.instance.onError = (error, stack) {
    CrashReporter.recordError(error, stack, fatal: true);
    return true; // 已处理
  };

  runZonedGuarded(() {
    runApp(const MyApp());
  }, (error, stack) {
    CrashReporter.recordError(error, stack, fatal: true);
  });
}

4.2 路由与会话上下文注入

class CrashContext {
  static final Map<String, Object?> _ctx = {};

  static void setUser(String? userId) => _ctx['userId'] = userId;
  static void setRoute(String route) => _ctx['route'] = route;
  static void setRelease({
    required String version,
    required String build,
    required String gitSha,
    required String flavor,
  }) {
    _ctx['version'] = version;
    _ctx['build'] = build;
    _ctx['gitSha'] = gitSha;
    _ctx['flavor'] = flavor;
  }

  static Map<String, Object?> snapshot() => Map.of(_ctx);
}

4.3 崩溃前事件环形缓冲(关键)

class Breadcrumbs {
  static const _max = 100;
  static final List<Map<String, Object?>> _events = [];

  static void add(String type, Map<String, Object?> data) {
    _events.add({
      'ts': DateTime.now().toIso8601String(),
      'type': type,
      'data': data,
    });
    if (_events.length > _max) _events.removeAt(0);
  }

  static List<Map<String, Object?>> dump() => List.of(_events);
}

上报时把 CrashContext.snapshot() + Breadcrumbs.dump() 一起带上,定位效率会明显提升。


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

建议每次迭代都看这 4 个指标:

  • 崩溃率Crash-Free Userssessions crash rate
  • 定位时长:从告警到明确 root cause 的中位时间
  • 修复时长:从定位到发布修复包的中位时间
  • 回归率:同类问题在后续版本再次出现比例

示例目标

  • 定位中位时长:6h -> 1.5h
  • 热点崩溃修复周期:2 天 -> 半天
  • 同类回归率:18% -> 5%

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

通用经验

  • 崩溃治理是工程问题,不是 SDK 问题:关键在“上下文完整性”
  • 发布元数据必须标准化:版本、构建号、commit、渠道缺一不可
  • 先止血再根治:高频崩溃优先降损(降级/开关),再做结构性修复
  • 把回溯链路产品化:让值班同学不依赖“最懂代码的人”

避坑清单

  • 只采集异常,不采集用户路径
  • 把所有异常都标记 fatal,导致告警噪音
  • 不区分前台/后台崩溃场景
  • 缺少版本维度,无法判断是否由新发版引入
  • 修复后无回归监控窗口(至少观察 24~72 小时)

时序图(崩溃回溯闭环)

sequenceDiagram
    participant U as 用户
    participant App as Flutter App
    participant BC as Breadcrumb缓冲
    participant CR as Crash上报服务
    participant A as 告警系统
    participant Dev as 研发

    U->>App: 进入页面并触发操作
    App->>BC: 记录路由/点击/请求事件
    App->>App: 发生异常(Flutter/Platform/Zone)
    App->>CR: 上报异常堆栈 + 上下文 + Breadcrumb
    CR->>A: 触发告警(按版本/机型聚合)
    Dev->>CR: 查询崩溃详情与前序事件
    Dev->>App: 提交修复(降级/补丁/发版)
    App->>CR: 新版本运行数据回传
    CR->>Dev: 验证崩溃率下降与无回归

质量与交付篇(2/6):CI/CD 实战——自动构建、签名、分发

2026年3月27日 15:33

CI/CD 实战——自动构建、签名、分发

系列:质量与交付篇(2/6)
标签建议:Flutter CI/CD 自动化构建 签名 发布

很多 Flutter 团队都经历过同一种“发版焦虑”:
本地打包成功了,但换台机器就失败;安卓签名文件在群里传来传去;iOS 证书过期到最后一天才发现;测试包分发靠手动上传,版本号还会写错。

这篇文章聚焦一个目标:让“发版”从人工操作变成可重复、可追溯、可回滚的流水线。


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

在业务项目中,通常要同时支持:

  • Android 多渠道/多环境(dev、staging、prod)
  • iOS TestFlight + 正式商店
  • 测试同学频繁要“最新可安装包”
  • 紧急修复需要“半小时内可交付”

常见现象:

  • 构建依赖人:只有某个同学机器能打包
  • 签名高风险:keystore、p12、provisioning profile 管理混乱
  • 版本不一致:Git tag、应用版本号、发版记录对不上
  • 分发链路断裂:包打出来后还要手动上传、手动通知
  • 失败不可定位:构建日志散落,本地复现成本高

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

核心原理

CI/CD 的本质不是“上一个平台”,而是把发布过程拆成三段:

  1. CI(持续集成):代码合并即触发验证(lint/test/build)
  2. CD(持续交付):产物自动归档并分发到测试渠道
  3. Release(持续发布):满足条件后自动/半自动上架

为什么团队会卡住

  • 构建脚本写在个人本地,没进入仓库标准化
  • 环境变量、签名文件、密钥没有统一密管策略
  • “分支策略”和“发布策略”脱节(例如 main 能直接推生产)
  • 缺少“失败即阻断”的质量门禁(测试失败也能打包)

快速排查清单

  • 同一 commit 是否能在任意 Runner 复现产物?
  • 签名资产是否都在密钥系统里,且有权限审计?
  • 是否实现了“一键拿测试包链接”?
  • 是否有从 tag 到安装包的全链路追踪?

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

方案对比

  • 方案 A:纯手动脚本(本地执行)
    优点:上手快;缺点:不可控、不可审计、不可规模化

  • 方案 B:CI 只做测试,打包仍手动
    优点:比 A 稳一点;缺点:最容易在“最后一公里”出事故

  • 方案 C:全流程流水线(推荐)

    • PR 阶段:静态检查 + 单测 + Widget 测试
    • 合并阶段:自动构建 Android/iOS 候选包
    • Tag 阶段:自动签名、归档、分发(Firebase/App Center/TestFlight)
    • 生产阶段:审批后发布,失败可回滚

最终选择(中小团队可直接落地)

采用 “分层流水线 + 环境隔离 + 签名密管”

  • 分层流水线verify -> build -> sign -> distribute -> release
  • 环境隔离:dev/staging/prod 使用不同变量与密钥
  • 签名密管:签名文件不入库,统一走 CI Secret + 临时文件注入
  • 版本规范:Git tag 驱动版本(如 v2.3.1+231

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

以下示例为通用模板,平台可替换为 GitHub Actions / GitLab CI / Jenkins / Codemagic。

4.1 Flutter CI 基础流水线(校验 + 构建)

name: flutter-ci

on:
  pull_request:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  verify:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test --coverage

  build-android:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: verify
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
      - run: flutter pub get
      - run: flutter build apk --release --dart-define=ENV=prod
      - uses: actions/upload-artifact@v4
        with:
          name: android-release-apk
          path: build/app/outputs/flutter-apk/app-release.apk

4.2 Android 签名注入(避免 keystore 入库)

# CI 中通过 Secret 注入
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/release.keystore

cat > android/key.properties <<EOF
storePassword=$ANDROID_STORE_PASSWORD
keyPassword=$ANDROID_KEY_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
storeFile=release.keystore
EOF

4.3 iOS 签名与导出(Fastlane 思路)

lane :beta do
  build_app(
    workspace: "ios/Runner.xcworkspace",
    scheme: "Runner",
    export_method: "app-store"
  )
  upload_to_testflight
end

4.4 自动分发到测试群(示例)

# 构建完成后上传 Firebase App Distribution
firebase appdistribution:distribute build/app/outputs/flutter-apk/app-release.apk \
  --app "$FIREBASE_APP_ID_ANDROID" \
  --groups "qa,product" \
  --release-notes "build from ${GIT_TAG}"

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

上线后建议跟踪这 4 组指标:

  • 构建成功率:近 30 天主干构建成功率(目标 > 95%)
  • 平均交付时长:从 merge 到测试可安装包(目标 < 15 分钟)
  • 回归成本:每次发版人工步骤数(目标降到 3 步以内)
  • 发布事故率:签名错误/版本错误/错包分发次数(目标趋近 0)

日志侧重点:

  • 每个阶段有明确起止日志(verify/build/sign/distribute)
  • 产物命名统一(app-prod-v2.3.1+231.apk
  • 每次发布关联 commit、tag、构建号、发布人

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

通用经验

  • 先把“可重复”做对,再追求“全自动”
  • 签名资产永远不进仓库,只走受控 Secret 注入
  • Tag 驱动发布,不要靠手填版本号
  • 失败即阻断:测试/校验不过,禁止进入签名与分发环节
  • 发布结果可追溯:包名、日志、通知都能定位到 commit

避坑清单

  • --dart-define 环境变量和后端环境是否一一对应
  • Android/iOS 的 bundle id、包名、渠道名是否一致
  • 签名证书有效期是否有提前告警(至少提前 30 天)
  • PR 是否必须通过 CI 才能合并
  • 是否保留最近 N 个可回滚产物
  • 分发通知是否包含版本号、变更摘要、下载链接、回滚说明

结语

CI/CD 的价值不只是“省时间”,而是把发布从“个人经验”升级为“团队系统能力”。
当你的团队做到:任何人、任何时间、任意机器都能稳定产出同质量包,交付就真正可控了。

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

2026年3月27日 15:55

在很多团队里,多端协作的主要问题是“同一个需求被翻译了多少次”。最典型的场景是: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 辅助的模式。

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

作者 牛奶
2026年3月27日 15:53

为什么你在北京打开一个视频,加载只要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全自动生成连环画?我试了,效果惊艳!

作者 Younglina
2026年3月27日 15:13

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


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

我一直想把女儿和她喜欢的卡通人物生成连环画的想法,现在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 渐进式详解

作者 Devin_chen
2026年3月27日 15:08

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 用于控制属性的读写逻辑。

原型链大白话详解

作者 Devin_chen
2026年3月27日 15:05

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

原型链就是 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 数据,但是走的原来的接口,要怎么做

作者 NolanKy
2026年3月27日 15:01

在前端开发中 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的区别?看这篇就够了(含心跳机制详解)

作者 ETA8
2026年3月27日 14:31

最近在复习计算机网络和 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 微前端配置详解

作者 陈林梓
2026年3月27日 14:23

本文档以本项目(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):动画体系:隐式动画到自定义动画

2026年3月27日 14:09

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

系列: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、语义色、组件侧零魔法数)。

❌
❌