阅读视图

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

微信小程序开发01:XR-FRAME的快速上手

一、前言

最近要基于微信小程序实现一个具备AR功能的APP,在进行技术选型时,发现小程序本身自带了XR-FRAME这个框架,

image.png

从描述上来看:

image.png

没有比它更“合适”的,用来进行AR功能开发的框架了 本来想使用 Vibe Coding 无痛完成开发,但是却在实际使用中,发现大模型写不太来 wxml<xr-...>相关的代码

于是在此开了一个系列文章,用来记录我遇到的坑 😓

二、从 1 到 1.x

个人的建议,一开始不从0到1,而是从1到1.x即基于现有的demo二次开发一个

否则,如果想在学习完下方所有<xr->相关的基础元素,再开始代码编写,着实头疼

image.png

官方文档里提供了扫码即可查看的示例

image.png

不过呢,没放源码的链接,这边我通过github找到了大概就是官方文档示例的源码仓库,地址如下:

dtysky/xr-frame-demo: Demos for xr-frame system in wx-mini-program.

image.png

运行后的效果如下:

image.png

三、动手试试

需求背景:我期望实现一个基础的AR功能,即扫描一张自定义的图片,然后能够出现一个自定义的元素

编码工具:Trae-CN

3.1 如何用【Trae】帮忙编写【微信开发者工具】里的工程代码

习惯了vibe coding后,【微信开发者工具】并不像VSCODE一样,也不能说通过IDEA插件的方式安装AI IDE工具 多少有点寸步难行,真的不想 古法编程 😭


其实很简单,在【微信开发者工具】里新建一个工程之后

image.png

在Trae里打开此工程即可

image.png

image.png

然后Trae里写代码,【微信开发者工具】里负责 编译 即可

3.2 基于现有页面完成自定义改造

我们将刚刚git clone下来的项目的源码直接替换掉新建的示例工程的代码,

image.png 然后运行它,选择下方标签

image.png 然后再选择此功能页

image.png

可以看到,此功能页的实际效果,就符合了我们在本章节初的需求背景

1e49e907220497e2a7e6b6be7a4de161.jpg

扫描具体的某一张图片(鹿的图片),然后出现自定义元素(蝴蝶)

我们快速在源码中定位到相关页面的

image.png

但是却发现怎么JS里面几乎是空的?

image.png

查阅文档后我们明白behaviors有点类似 Vue 中的mixins

image.png 那显然,我们暂时不用关心sceneReadyBehavior中到底有什么

接着看别的文件,我们发现了在JSON中的这个

image.png

一开始我没仔细看,我还纳闷为什么这个组件还要引用自己 😓

image.png

然后才发现这里命名都很一致,不过一个是 pages/... 一个是 components/... 😓

不过也可以取巧,试着🎲赌一下被识别的图片名称和官网示例的链接是一致的

image.png

你还真别说,还真的一致,😀

image.png 这种另辟蹊径的方式,也能帮我这位老眼昏花的人,找到核心JS代码的位置 miniprogram\components\template\xr-template-markerLock\index.js image.png

3.3 资源替换

3.3.1 识别图替换

由于微信小程序对打包上传的代码有严格的大小限制,不超过2MB,

image.png

🙅‍因此图方便使用静态图片放在工程里,走不通

这里我用某云的对象存储解决这个问题,提供一个公有读,私有写的链接即可

不过说个题外话,我发现生成的链接粘贴到浏览器里会触发立刻下载,

image.png

而不是和微信官网示例的鹿的图片一样,可以网页预览

image.png

好奇的同时去学习了一下,发现是 Header 的问题, 我们设置 Content-Dispositioninline 即可实现网页预览了

image.png

3.3.2 展示元素替换

我期望将原来的模型换成视频,这时候就可以利用Tare基于工程上下文去帮我们实现,同样运行demo工程 找到应用视频的页面,定位到源码位置

image.png

image.png

image.png

我们不需要去了解 xr.XRGLTF 切换到 xr.XRMesh 需要注意什么,Trae 会去了解的

3.3.3 成果

微信视频2026-03-16_015939_400 00_00_00-00_00_07.gif

四、总结

在本篇文章,我们实现了最基础的AR功能,在下一篇文章,我们会将模型、视频、图片相结合,实现拥有更多功能的AR页面。

DocsJS npmjs 自动化发布复盘(Trusted Publisher)

DocsJS npmjs 自动化发布复盘(Trusted Publisher)

本文是 @coding01/docsjs@coding01/docsjs-editor@coding01/docsjs-markdown 的发布复盘与最终标准方案。目标是:后续发布只走一条稳定路径,不再重复踩坑。

产品矩阵与链接、

image.png

1) @coding01/docsjs(核心引擎)

Word/DOCX 高保真导入与渲染核心,提供 Web Component + React/Vue 适配能力。

  1. 官网:docsjs.coding01.cn/
  2. GitHub:github.com/fanly/docsj…
  3. npmjs:www.npmjs.com/package/@co…

2) @coding01/docsjs-editor(编辑器桥接层)

面向多编辑器(如 CKEditor/Tiptap 等)的集成桥接层,负责快照注入、读回与适配切换。

  1. GitHub:github.com/fanly/docsj…
  2. npmjs:www.npmjs.com/package/@co…

3) @coding01/docsjs-markdown(Markdown 转换层)

将 docsjs HTML 快照或 DOCX 转换为 Markdown(Standard/GFM/frontmatter)。

  1. 产品页:fanly.github.io/docsjs-mark…
  2. GitHub:github.com/fanly/docsj…
  3. npmjs:www.npmjs.com/package/@co…

1. 最终发布架构

只保留一条 npmjs 发布链路:

  1. Git tag 触发:v*.*.*
  2. GitHub Actions workflow:.github/workflows/publish.yml
  3. npm Trusted Publisher(OIDC)签发并发布
  4. npm publish --provenance --access public

明确禁止:

  1. ci.yml 里再做第二条发布路径
  2. 混用 NPM_TOKEN 和 Trusted Publisher
  3. 同时维护多个“看起来都能发布”的 workflow

2. 这次踩到的关键问题

问题 A:E404 Not Found - PUT https://registry.npmjs.org/@coding01%2fdocsjs

现象:

  • 构建、测试、verify 全通过
  • publish 阶段报 E404

根因:

  • 包级 Trusted Publisher 绑定和实际 OIDC 身份不匹配,或存在脏配置。

验证方法:

  1. 在 workflow 中打印 OIDC claims(sanitized):
    • sub
    • repository
    • workflow_ref
    • job_workflow_ref
    • ref
  2. 用 claims 对照 npm 包页面的 Trusted Publisher 配置逐字段比对。

问题 B:ENEEDAUTH This command requires you to be logged in

现象:

  • CI 中报需要 npm adduser

根因:

  • ci.yml 里残留了 token 发布 job(NODE_AUTH_TOKEN 指向仓库 secret NPM_TOKEN),不是 Trusted Publisher 路径。

修复:

  1. 删除 ci.yml 里的发布 job
  2. 发布只由 publish.yml 负责

问题 C:CI workflow 无效

现象:

  • Invalid workflow file
  • (Line: 9): Unexpected value 'tag'

根因:

  • YAML 触发字段写错:tag 应为 tags(在 push 下)。

问题 D:Linting could not start

现象:

  • vp check 报 lint 无法启动

处理策略:

  1. 拆分检查职责,避免重复启动 lint:
    • lint: vp lint .
    • fmt:check: vp check --no-lint
    • typecheck: vp exec tsc --noEmit
  2. verify 改为串联上述步骤,降低工具链并发冲突概率。

3. 当前标准配置(必须保持)

3.1 publish.yml

要求:

  1. permissions 包含 id-token: write
  2. on.push.tagsv*.*.*
  3. npm ci + npm run verify
  4. 仅执行 npm publish --provenance --access public

3.2 ci.yml

要求:

  1. 只做质量检查(lint/fmt/typecheck/test/build)
  2. 不做 npm 发布

3.3 package.json

建议:

  1. 保留 publishConfig.access=public
  2. prepublishOnlyverify
  3. prepare 在 CI 中应可安全跳过(避免发布时副作用)

4. 发布前检查清单(实战)

每次发版前按顺序执行:

  1. 本地:
    • npm run verify
  2. 包信息:
    • nameversionfilesexports 正确
  3. Git:
    • package.json 版本与 tag 一致
    • git tag vX.Y.Z
  4. npm 包页面:
    • Trusted Publisher 指向正确 repo + workflow filename
  5. Actions:
    • 只有 publish.yml 执行发布

5. 故障快速定位流程

如果发布失败,按这个顺序排:

  1. 先看失败 step:
    • Verify 失败:先修代码/脚本
    • Publish 失败:优先查 npm 权限或 Trusted Publisher 绑定
  2. 看错误码:
    • E404:通常是 TP 绑定不匹配/权限隐藏
    • ENEEDAUTH:说明走了 token 登录路径,不是 TP 路径
  3. 看 OIDC claims:
    • 逐字段比对 repository/workflow_ref/ref/sub

6. 结论

正确做法不是“多加一条兜底发布”,而是保证发布链路唯一、可观测、可复现:

  1. CI 只做质量门
  2. publish workflow 只做 Trusted Publisher 发布
  3. 所有失败都能映射到单一责任面(代码、workflow、npm 绑定)

按本文执行,可以稳定避免本轮出现过的 E404ENEEDAUTH、workflow 语法错误和 lint 启动异常。

附录:三个产品入口

  1. docsjs: GitHub | npmjs | 官网
  2. docsjs-editor: GitHub | npmjs
  3. docsjs-markdown: GitHub | npmjs | 产品页

《实时渲染》第3章-图形处理单元-3.7几何着色器

实时渲染

3. 图形处理单元

3.7 几何着色器

几何着色器可以将图元转换为其他图元,这是曲面细分阶段无法做到的。例如,可以通过让每个三角形创建线边将三角形网格转换为线框视图。或者,线条可以被面向观察者的四边形替换,因此制作具有较厚边缘的线框渲染[1492]。随着DirectX 10的发布,几何着色器在2006年末随着DirectX 10的发布被添加到硬件加速图形管线中。它位于管道中的曲面细分着色器之后,它的使用是可选的。虽然它是Shader Model 4.0的必需部分,但在早期的着色器模型中并未使用。OpenGL 3.2和OpenGL ES 3.2也支持这种类型的着色器。

几何着色器的输入是单个对象及其关联的顶点。对象通常由带状三角形、线段或简单的点组成。几何着色器可以定义和处理扩展图元。特别是,可以传入三角形外的三个附加顶点,可以使用折线上的两个相邻顶点。见图3.12。使用DirectX 11和Shader Model 5.0,您可以传入更精细的面片,最多有32个控制点。也就是说,曲面细分阶段对于面片生成更有效[175]。

图3.12. 几何着色器程序的几何着色器输入是某种单一类型:点、线段、三角形。最右边的两个图元包括与线和三角形对象相邻的顶点。更复杂的面片类型是可能的。

几何着色器处理该图元并输出零个或多个顶点,这些顶点被视为点、折线或三角形条带。请注意,几何着色器根本无法生成任何输出。通过这种方式,可以通过编辑顶点、添加新图元和删除其他图元来有选择地修改网格。

几何着色器旨在修改传入数据或制作有限数量的副本。例如,一种用途是生成六个转换后的数据副本,以同时渲染立方体贴图的六个面;详见第10.4.3节。 它还可以用于高效地创建级联阴影贴图以生成高质量的阴影。利用几何着色器的其他算法包括从点数据创建可变大小的粒子、沿着轮廓挤出翅片以进行毛发渲染,以及找出物体边缘的阴影算法。更多示例请参见图3.13。本书的其余部分将讨论这些和其他用途。

图3.13. 几何着色器(GS)的一些用途。在左侧,使用GS即时执行元球等值面细分。在中间,使用GS完成线段的分形细分并输出,并且由GS生成广告牌以显示闪电。在右侧,布料模拟是通过使用带有流输出的顶点和几何着色器来执行的。(来自 NVIDIA SDK 10 [1300]示例的图像,由NVIDIA Corporation提供。)

DirectX 11添加了几何着色器使用实例化的能力,其中几何着色器可以在任何给定的图元上运行一定次数[530, 1971]。在OpenGL 4.0中,这是用调用计数指定的。几何着色器还可以输出最多四个流。一个流可以向下发送到渲染管线以进行进一步处理。所有这些流都可以选择发送到流输出渲染目标。

几何着色器保证以与输入相同的顺序输出图元的结果。这会影响性能,因为如果多个着色器内核并行运行,则必须保存和排序结果。这个和其他因素不利于几何着色器用于在单个调用中复制或创建大量几何体[175, 530]。

在发出绘制调用后,管线中只有三个地方可以在GPU上创建工作:光栅化、细分阶段和几何着色器。其中,考虑到所需的资源和内存,几何着色器的行为是最不可预测的,因为它是完全可编程的。在实践中,几何着色器通常用处不大,因为它不能很好地映射到GPU的优势。在某些移动设备上,它是用软件实现的,因此在移动端不鼓励使用它[69]。

3.7.1 流输出

GPU管线的标准用途是通过顶点着色器发送数据,然后光栅化生成的三角形并在像素着色器中处理它们。过去数据总是通过管线传递,无法访问中间结果。流输出的概念是在Shader Model 4.0中引入的。在顶点着色器(以及可选的曲面细分和几何着色器)处理顶点之后,除了被发送到光栅化阶段之外,它们还可以以流的形式输出,即有序数组。事实上,可以完全关闭光栅化,然后将管线纯粹用作非图形流处理器。以这种方式处理的数据可以通过管线发回,从而允许迭代处理。这种类型的操作对于模拟流动的水或其他粒子效应非常有用,如第13.8节所述。它还可以用于为模型蒙皮,然后让这些顶点可重复使用(第4.4节)。

流输出仅以浮点数的形式返回数据,因此它可能具有显着的内存成本。流输出适用于图元,而不是直接适用于顶点。如果网格沿着管线发送,每个三角形都会生成自己的一组三个输出顶点。原始网格中的任何顶点共享都将丢失。出于这个原因,一个更典型的用途是将顶点作为点集图元通过管线发送。在OpenGL中,流输出阶段称为变换反馈,因为它的大部分使用重点是变换顶点并返回它们以供进一步处理。图元保证按照它们输入的顺序发送到流输出目标,这意味着顶点顺序将被保持[530]。

尤雨溪宣布 Vite+ 正式开源,前端工具链要大一统了

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群

3 月 13 日深夜,尤雨溪在 X 上发了一条推文,平静地宣布了一件大事。

We are happy to announce that Vite+ is now fully open source under MIT license. Free for everyone!

20260315094604

Vite+ 以 MIT 协议全量开源,所有人免费使用。官网已经上线,地址是 viteplus.dev

如果说 Vite 8 的发布是"换了个引擎",那 Vite+ 的开源就是直接掀了桌子。它不是 Vite 的升级版,而是一个全新的物种,一个二进制文件,吃掉你整条前端工具链。

Vite+ 到底是什么

官网给出的定位很直白,"The Unified Toolchain for the Web"。

一句话来说,Vite+ 是一个统一的 Web 开发工具链,把 ViteVitestOxlintOxfmtRolldowntsdownVite Task 七个项目合并成了一个 CLI,命令叫 vp

它的野心不小。管构建,管运行时,管包依赖,管代码检查,管格式化,管测试,管打包发布,甚至管 monorepo 的任务编排。以前你需要 npmpnpmViteESLintPrettierJestnvm 各自配置、各自维护,现在一个 vp 全包了。

值得注意的是,Vite+ 是两段式设计:vp 是全局安装的命令行工具,vite-plus 是每个项目里安装的本地包。这两者协同工作,vp 负责统一入口,vite-plus 负责具体的构建逻辑。

image.png

完整命令地图

vp 的命令覆盖了开发全流程,分成几个维度来看:

启动和初始化

命令 做什么
vp create 创建新项目(支持 app、包、monorepo 模板)
vp migrate 把现有项目迁移到 Vite+
vp env 管理 Node.js 版本
vp install 用正确的包管理器安装依赖
vp config 配置 commit hooks 和 agent 集成

日常开发

命令 做什么 替代谁
vp dev 开发服务器,即时 HMR vite dev
vp check 类型检查 + Lint + 格式化 tscESLintPrettier
vp lint 单独运行 Lint ESLint
vp fmt 单独运行格式化 Prettier
vp test 运行测试 JestVitest
vp staged 对暂存文件跑检查 lint-staged

构建和发布

命令 做什么 替代谁
vp build 生产构建 vite build
vp preview 本地预览生产构建 vite preview
vp pack 库打包 + DTS 生成 tsuptsdown
vp run monorepo 任务执行(带缓存) turboreponx

依赖管理

命令 做什么
vp add / vp remove / vp update 包管理操作
vp dedupe / vp outdated / vp why 依赖分析
vp dlx 不安装直接运行包(类似 npx
vpx 全局执行二进制

还有一个彩蛋命令,vp implode,它会把 vp 本身和所有相关数据从机器上清除干净,如果用了之后觉得不适合自己,一条命令可以走得一干二净。

官网 viteplus.dev 首页的终端示例里,用 vp create acme-web --template react-ts 创建 React + TypeScript 项目,从脚手架生成到依赖安装完成,显示耗时 1.1 秒。

性能数字很夸张

Vite+ 的底层全部用 Rust 重写,官方给出的性能对比数据:

  • 生产构建比 webpack 快 40 倍(基于 Vite 8 + Rolldown
  • OxlintESLint 快 50 到 100 倍
  • OxfmtPrettier 快 30 倍
  • 开发时 HMR 始终保持即时响应

这些数字不是 Vite+ 团队自己编的。OxlintOxfmtOxc 项目里已经跑了很久的 benchmark,社区早有验证。Vite+ 做的事是把这些分散的高性能工具统一到了一个入口。

vp check 不只是 Lint

vp check 是这个工具链里设计最有意思的命令之一,值得单独说说。

它把三件事合进一个命令:Oxfmt 负责格式化,Oxlint 负责代码检查,tsgolint 负责 TypeScript 类型检查。三个工具并行跑,比分别执行快得多。

当你在 vite.config.ts 里开启 typeCheck 选项后,vp check 还会接入 TypeScript Go 工具链做类型感知的静态分析,这是微软正在推进的下一代 TypeScript 编译器,速度比原来的 tsc 快了一个数量级。

import { defineConfig } from 'vite-plus'

export default defineConfig({
  lint: {
    options: {
      typeAware: true,
      typeCheck: true,
    },
  },
})

开启之后,一条 vp check 就能搞定格式、Lint、类型三重检查。加上 --fix 参数还能自动修复可修复的问题:

vp check        # 检查
vp check --fix  # 检查并自动修复

一个配置文件管所有

以前的前端项目,配置文件能铺满项目根目录,vite.config.ts.eslintrc.prettierrcvitest.config.tstsconfig.jsonlint-staged.config.js……

Vite+ 的做法是把所有配置收拢到一个 vite.config.ts

import { defineConfig } from 'vite-plus'

export default defineConfig({
  // 开发服务器
  server: { port: 3000 },

  // Oxlint 规则
  lint: {
    options: {
      typeAware: true,
      typeCheck: true,
    },
  },

  // Oxfmt 格式化
  fmt: { /* ... */ },

  // Vitest 测试
  test: { /* ... */ },

  // 任务编排
  tasks: { /* ... */ },

  // commit 前的暂存检查(替代 lint-staged)
  staged: {
    '*.{js,ts,tsx,vue,svelte}': 'vp check --fix',
  },

  // 库打包(替代 tsdown.config.ts)
  pack: {
    entry: ['src/index.ts'],
    dts: true,
    format: ['esm', 'cjs'],
  },
})

一个文件,一套类型提示,一个 IDE 插件搞定所有配置的智能补全。对于强迫症开发者来说,这可能比性能提升更让人兴奋。

vp env 能精细管理 Node 版本

nvm 的用户应该对这种场景很熟悉,不同项目需要不同版本的 Node,切换还容易忘。

vp env 的设计是让 nodenpm 等命令都通过 Vite+ 的 shim 来走,自动识别当前项目锁定的 Node 版本,无需手动切换。

常用命令:

vp env pin lts          # 把项目锁定到最新 LTS 版本(写入 .node-version)
vp env use 20           # 当前 shell 会话临时切换到 Node 20
vp env default lts      # 设置全局默认版本
vp env current          # 查看当前解析到的环境
vp env doctor           # 运行环境诊断,排查问题
vp env list             # 列出本地已安装的版本
vp env list-remote --lts  # 查看可安装的 LTS 版本列表

如果你不想让 Vite+ 接管 Node 版本管理,可以用 vp env off 切到"系统优先"模式,Vite+ 只在系统 Node 找不到时才接管。

现有项目怎么迁移

这是官网里最有价值的部分之一,也是原文没有覆盖到的内容。

对于已有的 Vite 项目,迁移命令是:

vp migrate

这条命令会自动完成:把各个工具的分散配置合并进 vite.config.ts,更新项目依赖,重写 vitevitest 的导入路径,更新 package.json 里的 scripts。

官方建议的迁移前准备:先升级到 Vite 8+ 和 Vitest 4.1+,了解现有的 Lint、格式化、测试配置。迁移后跑一遍验证:

vp install
vp check
vp test
vp build

有意思的一个细节,官网的迁移文档里提供了一段专门写给 AI 编码助手的 migration prompt,可以直接粘贴给 Cursor 或 Claude 来代劳整个迁移过程。这说明 Vite+ 团队在设计工具时已经把 AI 辅助开发纳入考虑了。

不止是 Vue 生态的事

Vite+ 支持的框架列表相当长,包括 ReactVueSvelteSolidAstroNuxtNext.jsRemix,官网列了超过 20 个框架。

这意味着它不是"Vue 生态的专属工具"。任何前端框架的开发者都可以用,而且迁移成本几乎为零,因为底层就是 Vite,现有的 Vite 插件理论上都能直接用。

部署方面,Vite+ 可以与 Nitro 配合,直接部署到 VercelNetlifyCloudflareRender 等平台,从 SPA 到全栈 meta 框架都有完整支持。

怎么装

macOS 或 Linux 下:

curl -fsSL https://vite.plus | bash

Windows(PowerShell)下:

irm https://vite.plus/ps1 | iex

装完就是一个独立二进制文件,不依赖 Node.js 全局安装,不需要 npm install -g。安装后打开新的终端窗口,运行 vp help 就能看到所有命令。在 CI 环境里可以用官方提供的 setup-vp Action。

运行 vp upgrade 可以更新 vp 本身到最新版本。

谁在做这件事

Vite+ 背后是 VoidZero,尤雨溪在 2024 年创立的公司,专注于 Web 工具链。核心团队成员里有几个名字值得关注:

  • 尤雨溪,Vue.jsVite 的创造者
  • LONG Yinan,Oxc 项目的核心作者,Rust 工具链领域的资深开发者
  • Christoph Nakazawa,前 Meta 工程师,Jest 的创造者

没错,Jest 的创造者现在在给 Vite+ 写测试框架。这个阵容不需要多解释。

GitHub 仓库显示,Vite+ 的代码库有 608 个 commit,62.9% 是 Rust,33.4% 是 TypeScript。目前最新版本是 v0.1.11,处于 Alpha 阶段。

Vite 本身每周 npm 下载量已达 6900 万次,GitHub 星标 78.7K,是前端构建工具的事实标准。Vitest 每周下载量也超过 3500 万。这套工具链的用户基数不需要从零积累。

商业模式

很多人关心的问题,这么大的项目,免费能持续多久?

VoidZero 的做法是,Vite+ 完全开源,MIT 协议,永久免费。公司的营收来源是另一个独立的商业产品 Void,具体形态还没公开,但大概率是面向企业的增强版或云服务。

这和 VercelNext.js 免费,平台收费)的路线类似,开源工具做增长飞轮,商业产品做营收。这条路已经被验证过了。

现阶段要注意的几点

虽然 Vite+ 的愿景很性感,但当前有几个现实问题值得正视。

第一,它现在是 Alpha 版本。v0.1.11,连 Beta 都没到,API 可能随时调整,生产环境请三思。官方文档里也明确说了,vp migrate 运行完之后大多数项目还需要手动调整,不是一键无缝。

第二,"大一统"是双刃剑。统一工具链的好处是减少配置和兼容性问题,但坏处是一旦某个模块出问题,整条链都可能受影响。以前 ESLint 出错不影响构建,以后就不好说了。

第三,生态兼容性需要时间。虽然理论上兼容 Vite 插件,但实际使用中肯定会有各种边界情况,社区插件的适配需要一个过程。

第四,包管理这块水很深。npmpnpmyarn 打了很多年,每家都有自己的 resolve 策略和 lockfile 格式,Vite+ 要在这个领域站稳脚跟,挑战不小。

这件事的意义

前端工具链的碎片化问题困扰社区很久了。一个新项目光配置工具链就要半天,node_modules 动辄几百 MB,各种工具之间的版本冲突是家常便饭。

Vite+ 的出现代表了一种趋势,用 Rust 重写性能敏感的部分,用统一的入口消除工具之间的缝隙。

类似的尝试不止 Vite+ 一家,BunDenoBiome 都在做类似的事。但 Vite+ 有一个独特优势,它站在 Vite 的肩膀上,从 ViteVite+ 的迁移路径是最短的,用户基数也是最大的。

从现在的角度来看,Alpha 阶段先关注、多试用、遇到问题提 issue 才是正确姿势。但这件事本身值得认真看待,前端工具链可能真的要变了。

📖 2026年 大厂前端面试手写题库已开源(2.3k star)

前端手写题集锦 use js 记录大厂笔试,面试常考手写题, 致力打造最全的前端JavaScript手写题题库和答案的最优解

Github:github.com/Sunny-117/j…

谢谢您的star,您的star是我更新的动力🥳

里面有答案,为了让你们有一个参考,不过非常希望你们能提供自己的思路,指出答案中存在的问题,复杂度优化等等, 期待你们的contribute, 想来一起维护这个项目,可以联系我,成为contributor

主要是让大家讨论出最优解,然后merge,一起贡献这个项目,有些答案有点问题,所以我给出的答案仅作参考,也欢迎发现的小伙伴提PR

贡献此项目

提PR就行

思考很久,用issue形式收集各种手写题,并让小伙伴们讨论题解

JavaScript HOT 100 题

中大厂面试,最常考的100个题,每一题都非常具有代表性,想要准备面试突击的同学,优先看这些题,祝在座的每一位都能拿到满意的offer

实现 Promise (hot)

Promise 周边场景题(hot)

JavaScript 常考手写题

设计模式相关

树-场景题(hot)

实现 JS 原生方法

JS 库函数实现

js utils

手写 nodejs 模块

正则相关

排序算法

实现自定义HOOK

组件设计题(Vue/React/JS均可)

HTML CSS 手写题

React 快速入门:Vue 开发者指南

React 快速入门:Vue 开发者指南

通过对比 Vue 和 React,快速掌握 React 核心概念


一、项目结构对比

1.1 依赖管理

React (package.json):

{
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  }
}

Vue (package.json):

{
  "dependencies": {
    "vue": "^3.4.0"
  }
}

关键差异:

  • React 分两个包:react(核心库)+ react-dom(DOM 渲染器)
  • Vue 只需要一个包
  • React 设计更通用,支持多平台(Web、Native 等)

1.2 入口文件

React (main.jsx):

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Vue (main.js):

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

对比:

  • React 需要显式调用 render()
  • Vue 更简洁,一行完成创建和挂载
  • React 的 StrictMode 提供开发时检查

二、JSX:React 的模板语法

2.1 什么是 JSX?

JSX = JavaScript + XML,允许在 JS 中直接写 HTML 标签。

示例:

function App() {
  const name = "vue";
  return (
    <h1 className="title">Hello {name}!</h1>
  )
}

2.2 JSX vs Vue 模板

特性 React JSX Vue 模板
类名 className class
插值 {name} {{ name }}
事件 onClick={handler} @click="handler"
条件 {condition && <div />} v-if="condition"
列表 {items.map(i => <li />)} v-for="i in items"

2.3 JSX 的本质

JSX 代码:

const element = <h2>标题</h2>

编译后等价于:

const element2 = React.createElement('h2', null, '标题')

为什么使用 JSX?

  1. 更直观,接近 HTML
  2. 完整的 JavaScript 能力
  3. 更好的编辑器支持

三、组件基础

3.1 React 组件

// 函数就是组件
function App() {
  return <h1>Hello React!</h1>
}

export default App

关键点:

  • 组件是函数
  • 返回 JSX
  • 组件名必须大写(区分 HTML 标签)

3.2 组件组合

function Header() {
  return <header><h1>首页</h1></header>
}

function Articles() {
  return <div>文章列表</div>
}

function App() {
  return (
    <>
      <Header />
      <Articles />
    </>
  )
}

Fragment (<>):不会创建额外 DOM 节点,类似 Vue 的 <template>

3.3 Props 传递

// 父组件
function App() {
  return <UserProfile name="张三" />
}

// 子组件
function UserProfile({ name }) {
  return <h1>欢迎,{name}!</h1>
}

对比 Vue:

<template>
  <UserProfile :name="'张三'" />
</template>

<script setup>
const props = defineProps({ name: String })
</script>

四、状态管理:useState

4.1 基本用法

import { useState } from 'react';

function App() {
  const [name, setName] = useState("vue");
  
  return <h1>Hello {name}!</h1>
}

解析:

  • useState 返回数组:[状态值,更新函数]
  • "vue" 是初始值
  • 调用 setName() 会触发重新渲染

4.2 多个状态

function App() {
  const [name, setName] = useState("vue");
  const [todos, setTodos] = useState([
    { id: 1, title: "学习 react" },
    { id: 2, title: "学习 node" },
  ]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  
  return (
    <>
      <h1>Hello {name}!</h1>
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
    </>
  )
}

4.3 不可变更新

// ❌ 错误:直接修改
todos.push(newTodo);
setTodos(todos);

// ✅ 正确:创建新数组
setTodos([...todos, newTodo]);

// ✅ 更新对象
setUser({ ...user, age: 26 });

为什么?

  • React 使用浅比较检测变化
  • 不可变数据更可预测
  • 支持并发特性

对比 Vue:

<script setup>
const todos = ref([])
// Vue 支持直接修改
todos.value.push(newTodo)
</script>

Vue 使用 Proxy 自动追踪变化,React 要求不可变更新。


五、事件处理

5.1 基本用法

function App() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1);
  };
  
  return <button onClick={handleClick}>+1</button>
}

关键点:

  • 事件名驼峰命名:onClick(不是 onclick
  • 传递函数引用:onClick={handleClick}
  • 不是调用:onClick={handleClick()}

5.2 事件传参

function App() {
  const handleDelete = (id) => {
    console.log('删除:', id);
  };
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} onClick={() => handleDelete(todo.id)}>
          {todo.title}
        </li>
      ))}
    </ul>
  )
}

使用箭头函数传参,简单直观。

5.3 对比 Vue

特性 React Vue
语法 onClick={handler} @click="handler"
阻止默认行为 e.preventDefault() .prevent 修饰器
事件对象 自动传递 $event

六、条件渲染

6.1 三元运算符

{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}

6.2 逻辑与运算符

{isLoggedIn && <div>已登录</div>}

6.3 对比 Vue

React:

{count > 0 ? <p>{count}</p> : <p>无数据</p>}

Vue:

<p v-if="count > 0">{{ count }}</p>
<p v-else>无数据</p>

设计哲学:

  • React:使用 JavaScript 原生语法
  • Vue:使用模板指令

七、列表渲染

7.1 使用 map

function App() {
  const todos = [
    { id: 1, title: "学习 react" },
    { id: 2, title: "学习 node" },
  ];
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.title}
        </li>
      ))}
    </ul>
  )
}

7.2 key 的重要性

// ✅ 正确:使用唯一 ID
<li key={todo.id}>

// ❌ 错误:使用索引
<li key={index}>

为什么需要 key?

  • 帮助 React 识别元素
  • 优化虚拟 DOM diff
  • 避免不必要的重新渲染

7.3 对比 Vue

React:

{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}

Vue:

<li v-for="todo in todos" :key="todo.id">
  {{ todo.title }}
</li>

八、完整示例

import { useState } from 'react';
import './App.css';

function App() {
  // 状态管理
  const [name, setName] = useState("vue");
  const [todos, setTodos] = useState([
    { id: 1, title: "学习 react", done: false },
    { id: 2, title: "学习 node", done: false },
    { id: 3, title: "学习 js", done: false },
  ]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  // 事件处理
  const toggleLogin = () => {
    setIsLoggedIn(!isLoggedIn);
  }

  // JSX 元素
  const element = <h2>JSX 是 React 的语法扩展</h2>

  return (
    <> 
      {element}
      <h1>Hello <span className="title">{name}!</span></h1>
      
      {/* 条件渲染 + 列表渲染 */}
      {todos.length > 0 ? (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              {todo.title}
            </li>
          ))}
        </ul>
      ) : (<div>暂无待办事项</div>)}
      
      {/* 条件渲染 */}
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      
      {/* 事件绑定 */}
      <button onClick={toggleLogin}>
        {isLoggedIn ? "退出登录" : "登录"}
      </button>
    </>
  )
}

export default App

代码要点:

  1. 使用 useState 管理三个状态
  2. 三元运算符实现条件渲染
  3. map 方法实现列表渲染
  4. 箭头函数处理事件
  5. Fragment (<>) 包裹多个元素

九、核心差异总结

9.1 设计哲学

方面 React Vue
定位 库 (Library) 框架 (Framework)
模板 JSX (JavaScript) 模板语法 (HTML-like)
状态更新 不可变 可变
学习曲线 较陡峭 较平缓
灵活性 中等

9.2 代码对比

React:

import { useState } from 'react';

function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

Vue:

<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="count++">+1</button>
  </div>
</template>

9.3 关键记忆点

  1. JSX 用 {} 插值,不是 {{ }}
  2. 类名用 className,不是 class
  3. 事件用 onClick,不是 @click
  4. 状态不可变更新,不能直接修改
  5. 列表需要 key,使用唯一 ID
  6. 条件用三元运算符,不是 v-if

十、常见陷阱

陷阱 1:直接修改状态

// ❌ 错误
count = count + 1;

// ✅ 正确
setCount(count + 1);

陷阱 2:忘记 key

// ❌ 错误
{items.map(item => <div>{item.name}</div>)}

// ✅ 正确
{items.map(item => <div key={item.id}>{item.name}</div>)}

陷阱 3:混淆 class

// ❌ 错误
<div class="container">

// ✅ 正确
<div className="container">

陷阱 4:事件立即执行

// ❌ 错误
<button onClick={handleClick()}>

// ✅ 正确
<button onClick={handleClick}>

十一、学习建议

11.1 学习路线

第 1 周:基础

  • JSX 语法
  • 组件定义
  • useState

第 2 周:进阶

  • 事件处理
  • 条件/列表渲染
  • useEffect

第 3 周:生态

  • React Router
  • 状态管理
  • UI 组件库

11.2 思维转换

从 Vue 到 React,需要转变:

  1. 从模板到 JSX:接受"一切皆 JavaScript"
  2. 从可变到不可变:习惯创建新对象
  3. 从指令到函数:用原生语法替代指令

11.3 选择建议

选 React 如果:

  • JavaScript 基础好
  • 需要灵活性
  • 想开发跨平台应用

选 Vue 如果:

  • 快速上手
  • 喜欢完整方案
  • 主要开发 Web 应用

总结

React 核心要点:

  1. ✅ JSX 是 JavaScript 扩展,不是 HTML
  2. ✅ 组件是函数,返回 JSX
  3. ✅ useState 管理状态,不可变更新
  4. ✅ 事件用 onClick,传递函数引用
  5. ✅ 条件用三元运算符,列表用 map
  6. ✅ key 帮助优化渲染,必须提供

最后的话:

React 和 Vue 都是优秀框架,没有绝对好坏。理解差异,选择适合的,持续学习才是关键。

资源推荐:

祝你学习顺利! 🚀

基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染

基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染

AgentExecutor.invoke() 那个 Promise resolve 的时候,你用户已经对着空白页发了 40 秒呆。

这不是性能问题。这是产品层面的硬伤——LLM Agent 做推理天生就慢,一个中等复杂度的任务跑个 3 到 5 轮 tool.call() 很正常,每轮都要等模型吐完 token、解析结构化输出、跑一下外部调用、再把结果塞回 messages 数组喂回去,整条链路跑下来十几秒起步,你要是把这些全藏在一个 loading spinner 后面,用户的耐心大概撑不过第二轮。所以真正要解决的问题不是"怎么让 Agent 跑起来",是怎么把它边跑边想的过程实时地、结构化地渲染出来(当然这是理想情况)。

Tool 选择、参数组装、中间结果、重试决策。全得摊开给用户看。说白了嘛,就是给 LLM 的"内心戏"搭一个可视化的舞台,让用户知道它不是卡死了而是真的在干活。跑通一个 demo 不难,难的是这套东西在生产环境里不崩——两个字概括就是"耐操"。

用户输入
  ↓
LLM 决策(选 Tool + 生成参数)
  ↓                    ↓
Tool A 执行         Tool B 执行(并行)
  ↓                    ↓
结果合并 → LLM 再决策
               ↓
          Tool C 执行
               ↓
          最终输出

这个流程画出来像个 DAG。但运行时它是动态生长的——你在第一步根本不知道后面会长出几个分支,也不知道哪个 Tool 会超时、哪个会返回意料之外的格式让 LLM 的 JSON.parse 直接炸掉。这篇文章围绕这个矛盾展开:怎么设计一套前端架构让 Tool 可插拔注册、思维链状态可追踪、DAG 可实时渲染,同时不把代码写成一坨谁都不想维护的东西。

Tool 注册机制:别让你的 Agent 变成一个巨型 switch-case

先上问题。LangChain.js 里注册 Tool 的标准姿势大概长这样:

import { DynamicStructuredTool } from '@langchain/core/tools'
import { z } from 'zod'

const searchTool = new DynamicStructuredTool({
  name: 'web_search',
  description: '搜索互联网获取实时信息',
  schema: z.object({
    query: z.string().describe('搜索关键词'),
    maxResults: z.number().optional().default(5),
  }),
  func: async ({ query, maxResults }) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=${maxResults}`)
    const data = await res.json()
    return JSON.stringify(data.results.slice(0, maxResults))
  },
})

一个 Tool 写成这样没问题。三个也凑合。十五个呢?

真实项目里 Agent 要调的 Tool 很容易膨胀到两位数——搜索、计算、db.query()、文件读写、外部 REST API 调用、沙箱代码执行——每一个都有自己的 schema 定义、错误处理逻辑、重试策略、权限校验规则,你要是把它们全塞在一个文件里就会得到一个 800 行的 tools.ts,三个月后没人敢碰这玩意。

需要 registry 模式。

// tool-registry.ts
// 核心思路:Tool 自己知道自己是谁,registry 只负责收集和分发

type ToolMeta = {
  category: 'search' | 'compute' | 'io' | 'external'
  requiresAuth: boolean
  timeout: number  // 毫秒,超时直接 abort
  retryable: boolean
}

class ToolRegistry {
  private tools = new Map<string, DynamicStructuredTool>()
  private meta = new Map<string, ToolMeta>()

  register(tool: DynamicStructuredTool, meta: ToolMeta) {
    if (this.tools.has(tool.name)) {
      // 同名 Tool 重复注册,直接炸——这种 bug 越早发现越好
      throw new Error(`Tool "${tool.name}" already registered`)
    }
    this.tools.set(tool.name, tool)
    this.meta.set(tool.name, meta)
  }

  getTools(filter?: { category?: ToolMeta['category'] }): DynamicStructuredTool[] {
    let entries = [...this.tools.entries()]
    if (filter?.category) {
      entries = entries.filter(([name]) => 
        this.meta.get(name)?.category === filter.category
      )
    }
    return entries.map(([, tool]) => tool)
  }

  getMeta(name: string): ToolMeta | undefined {
    return this.meta.get(name)
  }
}

export const registry = new ToolRegistry()

然后每个 Tool 自己单独一个文件,文件末尾做自注册,import 的副作用就是把自己挂到 registry 上:

// tools/web-search.ts
import { registry } from '../tool-registry'

const tool = new DynamicStructuredTool({
  name: 'web_search',
  description: '搜索互联网获取实时信息',
  schema: z.object({ query: z.string() }),
  func: async ({ query }) => {
    // ...实际逻辑
  },
})

registry.register(tool, {
  category: 'search',
  requiresAuth: false,
  timeout: 10000,
  retryable: true,
})

这个模式有个隐含的坑。

const toolModules = import.meta.glob('./tools/*.ts', { eager: true })
// eager: true → 同步加载,确保注册发生在 Agent 创建之前
// 不需要用返回值,import 的副作用已经完成注册

静态注册搞定了。

但跑起来还有一层:Tool 执行过程中的生命周期钩子。你需要知道一个 Tool 什么时候开始执行、什么时候结束、返回了什么、报错了没有——这些信息不只是后面思维链可视化的数据源,它就是思维链本身的骨架,没有这些事件流你后面画个锤子的 DAG。

嗯,继续。

LangChain.js 原生提供了 callbacks 机制来做这事。但它的回调设计——怎么说呢——有点"Java 味儿",handleToolStarthandleToolEndhandleToolError 一堆方法签名糊你脸上,参数类型还经常对不上文档(虽然这个设计我觉得有点奇怪,明明 TypeScript 项目为什么类型定义这么随意)。我的做法是在 registry 层包一层代理把 Tool 的 func 拦截掉:

// 在 ToolRegistry.register 方法内部
register(tool: DynamicStructuredTool, meta: ToolMeta) {
  const originalFunc = tool.func.bind(tool)

  const wrappedFunc = async (input: any, runManager?: any) => {
    const startTime = Date.now()
    const executionId = crypto.randomUUID()

    this.emit('tool:start', { 
      executionId, 
      toolName: tool.name, 
      input, 
      timestamp: startTime 
    })

    try {
      const result = await Promise.race([
        originalFunc(input, runManager),
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error(`Tool ${tool.name} timeout`)), meta.timeout)
        ),
      ])

      this.emit('tool:end', { 
        executionId, 
        toolName: tool.name, 
        result, 
        duration: Date.now() - startTime 
      })
      return result
    } catch (err) {
      this.emit('tool:error', { 
        executionId, 
        toolName: tool.name, 
        error: err, 
        duration: Date.now() - startTime,
        retryable: meta.retryable,
      })
      throw err
    }
  }

  ;(tool as any).func = wrappedFunc
  this.tools.set(tool.name, tool)
  this.meta.set(tool.name, meta)
}

这段代码有个细节值得停一下。Promise.race 里塞 setTimeout 做超时兜底这个套路很常见,但用在 LangChain Tool 里有一个陷阱——timeout reject 之后原始的 fetch 或者数据库查询其实还在跑着呢。你的 Agent 已经收到报错往下走了,后台还挂着一个请求在那耗资源。前端并发高这个说法本身就有点奇怪对吧?一个用户一次也就跑一个 Agent。但你仔细想——如果 Agent 支持并行 Tool 调用,同时起 3、4 个 fetch,再叠上用户可能开了好几个对话 tab 每个 tab 都在跑,这个泄漏就不是理论问题了,AbortController 是正解但 DynamicStructuredTool 不方便把 AbortSignal 传进 func 里,得自己在闭包里存一个,写出来不好看,先欠着。

嗯,继续。

真正让 registry 模式值回票价的是动态 Tool 集,不同用户角色、不同对话场景,Agent 能调的 Tool 不一样。管理员能用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。哦不,准确说是用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。处理代码问题时加载 code_executor,闲聊天的时候不需要。

function getToolsForContext(user: User, conversationType: string) {
  const tools = registry.getTools()

  return tools.filter(tool => {
    const meta = registry.getMeta(tool.name)!
    if (meta.requiresAuth && !user.permissions.includes(tool.name)) {
      return false
    }
    if (conversationType === 'casual' && meta.category === 'compute') {
      return false
    }
    return true
  })
}

const agent = await createOpenAIFunctionsAgent({
  llm,
  tools: getToolsForContext(currentUser, 'technical'),
  prompt,
})

这段 filter 看着朴素,本质上是把 Tool 的注册和使用解耦了。

不过话说回来。这套 registry 最大的受益者不是运行时(虽然官方文档不是这么说的)。是后面的 DAG 渲染,因为 tool:starttool:end 这些事件流出来了,思维链的数据源就有了。

思维链状态管理:把 LLM 的内心戏变成一棵可追踪的树

AgentExecutor 跑起来之后内部在干嘛?

就是一个循环:

while (true) {
  1. 把当前 messages 数组发给 LLM
  2. LLM 返回:要调 Tool(哪个 Tool 什么参数)或者直接吐最终答案
  3. 最终答案 → break
  4. Tool 调用 → 执行 → 结果塞回 messages → 回到 1
}

循环每转一圈就是思维链上一个节点。问题在于 LangChain 的 callbacks 能告诉你这些事件发生了,但它不给你一个结构化的状态对象来表达整条链的拓扑关系——你拿到的是一堆散装事件,得自己攒成一棵树。

一开始设计太复杂了后来砍了又砍,砍到不能再砍:(数据结构。踩了几次坑之后收敛出来的版本)

type ThinkingNodeType = 'llm_call' | 'tool_call' | 'tool_result' | 'final_answer' | 'error'

type ThinkingNodeStatus = 'pending' | 'running' | 'completed' | 'failed'

interface ThinkingNode {
  id: string
  type: ThinkingNodeType
  status: ThinkingNodeStatus
  parentId: string | null
  label: string
  data: Record<string, any>
  startedAt: number
  completedAt: number | null
  children: string[]
  streamTokens?: string[]
}

interface ThinkingChain {
  sessionId: string
  rootId: string
  nodes: Map<string, ThinkingNode>
  currentNodeId: string | null
}

ThinkingNodeparentIdchildren 形成树结构。等下——不是说好了 DAG 吗?对,理论上如果两个 Tool 的结果同时喂给下一轮 LLM 决策那确实是 DAG 不是树。但在 LangChain.js 目前的 AgentExecutor 实现里(注意我说的是 AgentExecutor 不是 langgraph)并行 Tool 调用的结果最终还是拼成一条消息喂回去的,所以中间状态用树来建模够用了,真要严格 DAG 后面单独讲。

管理器,维护这棵树同时对接 LangChain 的 callback 体系:

class ThinkingChainManager {
  private chain: ThinkingChain
  private listeners = new Set<(chain: ThinkingChain) => void>()

  constructor(sessionId: string) {
    const rootId = crypto.randomUUID()
    this.chain = {
      sessionId,
      rootId,
      nodes: new Map(),
      currentNodeId: null,
    }
  }

  addNode(
    type: ThinkingNodeType,
    label: string,
    parentId: string | null,
    data: Record<string, any> = {}
  ): string {
    const id = crypto.randomUUID()
    const node: ThinkingNode = {
      id, type, status: 'pending', parentId, label, data,
      startedAt: Date.now(), completedAt: null, children: [],
    }

    this.chain.nodes.set(id, node)

    if (parentId && this.chain.nodes.has(parentId)) {
      this.chain.nodes.get(parentId)!.children.push(id)
    }

    this.notify()
    return id
  }

  updateStatus(nodeId: string, status: ThinkingNodeStatus) {
    const node = this.chain.nodes.get(nodeId)
    if (!node) return
    node.status = status
    if (status === 'completed' || status === 'failed') {
      node.completedAt = Date.now()
    }
    if (status === 'running') {
      this.chain.currentNodeId = nodeId
    }
    this.notify()
  }

  appendStreamToken(nodeId: string, token: string) {
    const node = this.chain.nodes.get(nodeId)
    if (!node) return
    if (!node.streamTokens) node.streamTokens = []
    node.streamTokens.push(token)
    // 这里刻意不调 notify()
  }

  subscribe(listener: (chain: ThinkingChain) => void) {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener)
  }

  private notify() {
    this.listeners.forEach(fn => fn(this.chain))
  }

  getSnapshot(): ThinkingChain {
    return this.chain
  }
}

为什么 appendStreamToken 不触发 notify()

因为 GPT-4 和 Claude 吐 token 的速度大概每秒 30 到 80 个,短 token 飞起来的时候能到 100 以上——如果每个 token 都触发一次 React re-render 你的 UI 线程会直接卡成幻灯片放映。正确做法是在消费端 throttle,用 requestAnimationFrame 一帧刷一次就够了:

useEffect(() => {
  const unsub = chainManager.subscribe(chain => {
    setDisplayChain(structuredClone(chain))
  })

  let rafId: number
  const tickStream = () => {
    setDisplayChain(structuredClone(chainManager.getSnapshot()))
    rafId = requestAnimationFrame(tickStream)
  }
  rafId = requestAnimationFrame(tickStream)

  return () => {
    unsub()
    cancelAnimationFrame(rafId)
  }
}, [chainManager])

structuredClone 在这里是有点奢侈的。节点多的时候每帧 clone 一次整棵树开销不小(虽然说实话 20 个节点的对象 clone 一次也就微秒级别),更好的做法是上 immer 维护 immutable 结构,但过早优化不如先跑通再说。

写到这里突然觉得之前说的不太对。

接着要把 ThinkingChainManager 和 LangChain 的 callback 对接。继承 BaseCallbackHandler 重写一堆 handle* 方法:

import { BaseCallbackHandler } from '@langchain/core/callbacks/base'

class ThinkingChainCallbackHandler extends BaseCallbackHandler {
  name = 'ThinkingChainHandler'
  private manager: ThinkingChainManager
  private runNodeMap = new Map<string, string>()
  private currentLlmNodeId: string | null = null

  constructor(manager: ThinkingChainManager) {
    super()
    this.manager = manager
  }

  async handleLLMStart(llm: any, prompts: string[], runId: string) {
    const parentId = this.getParentNodeId()
    const nodeId = this.manager.addNode(
      'llm_call',
      '正在思考...',
      parentId,
      { model: llm?.modelName || 'unknown' }
    )
    this.runNodeMap.set(runId, nodeId)
    this.currentLlmNodeId = nodeId
    this.manager.updateStatus(nodeId, 'running')
  }

  async handleLLMNewToken(token: string) {
    if (this.currentLlmNodeId) {
      this.manager.appendStreamToken(this.currentLlmNodeId, token)
    }
  }

  async handleLLMEnd(output: any, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (nodeId) {
      this.manager.updateStatus(nodeId, 'completed')
    }
    this.currentLlmNodeId = null
  }

  async handleToolStart(tool: any, input: string, runId: string) {
    const parentId = this.currentLlmNodeId || this.getParentNodeId()
    const nodeId = this.manager.addNode(
      'tool_call',
      `调用 ${tool.name || 'Tool'}`,
      parentId,
      { toolName: tool.name, input: JSON.parse(input || '{}') }
    )
    this.runNodeMap.set(runId, nodeId)
    this.manager.updateStatus(nodeId, 'running')
  }

  async handleToolEnd(output: string, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (!nodeId) return

    const resultNodeId = this.manager.addNode(
      'tool_result',
      '结果返回',
      nodeId,
      { output: output.slice(0, 500) }
    )
    this.manager.updateStatus(resultNodeId, 'completed')
    this.manager.updateStatus(nodeId, 'completed')
  }

  async handleToolError(err: any, runId: string) {
    const nodeId = this.runNodeMap.get(runId)
    if (nodeId) {
      this.manager.updateStatus(nodeId, 'failed')
      this.manager.addNode('error', `错误: ${err.message}`, nodeId, { error: err })
    }
  }

  private getParentNodeId(): string | null {
    return this.manager.getSnapshot().currentNodeId
  }
}

这段 handler 有一个 LangChain 做得不好的地方——handleToolStart 的第二个参数 inputstring 不是结构化对象,你得自己 JSON.parse,而且它有时候给你的不是合法 JSON。不是 bug。是"特性"。(我已经在 GitHub issue 里看到过不下十个人吐槽这个事了,官方一直没改。)

串起来。启动代码:

const chainManager = new ThinkingChainManager(sessionId)
const callbackHandler = new ThinkingChainCallbackHandler(chainManager)

const executor = AgentExecutor.fromAgentAndTools({
  agent,
  tools: getToolsForContext(currentUser, conversationType),
  callbacks: [callbackHandler],
  // streaming 这个配置名字叫 streaming
  // 但实际控制的是 callback 的粒度——不开的话 handleLLMNewToken 不触发
})

registry.on('tool:start', (event) => {
  // 补充 meta 信息:预期耗时、是否可重试之类的
})

到这一步思维链的数据流就通了,每一步推理每一次 Tool 调用都会在 ThinkingChainManager 里生成对应节点。

拉回来讲渲染。

DAG 渲染:把动态生长的图画到屏幕上

这是整个方案里最容易做出来、也最容易做烂的部分。

先明确一下要渲染什么:

[用户提问][LLM 思考 #1] ──→ [调用 web_search("天气")] ──→ [结果: 晴 25°C]
    ↓                                                    ↓
[LLM 思考 #2] ←──────────────────────────────────────────┘
    ↓
    ├──→ [调用 calculator("25 * 9/5 + 32")] ──→ [结果: 77°F]
    │
    └──→ [调用 translator("晴", "en")] ──→ [结果: "Sunny"]
              ↓                                    ↓
[LLM 思考 #3] ←──────────────────────────────────┘
    ↓
[最终回答: "今天天气晴朗,25°C (77°F)"]

节点类型不统一,有 llm_calltool_calltool_resultfinal_answer。连边方向单一但有并行分支。整个图是边跑边长的——这很要命。

用什么库?

核心挑战不在渲染。在布局算法。

每次新增节点整个图的布局可能要重算,如果用 dagre 做自动布局(react-flow 文档推荐的方式),每次 addNode 就重新算一遍所有节点的 x/y 坐标——已有节点位置会跳。用户正盯着某个节点看呢突然它蹦到另一个位置去了。体验极差。

我的方案是增量布局。新节点根据父节点位置做相对定位,已有节点纹丝不动:

import { useCallback, useRef } from 'react'

const LAYOUT = {
  nodeWidth: 240,
  nodeHeight: 80,
  horizontalGap: 60,
  verticalGap: 100,
} as const

function useIncrementalLayout() {
  const positionCache = useRef(new Map<string, { x: number; y: number }>())
  const depthCounters = useRef(new Map<number, number>())

  const getNodePosition = useCallback((
    nodeId: string,
    parentId: string | null,
    depth: number
  ): { x: number; y: number } => {
    if (positionCache.current.has(nodeId)) {
      return positionCache.current.get(nodeId)!
    }

    const currentCount = depthCounters.current.get(depth) || 0
    depthCounters.current.set(depth, currentCount + 1)

    let x: number, y: number

    if (!parentId) {
      x = 400
      y = 50
    } else {
      const parentPos = positionCache.current.get(parentId)
      if (parentPos) {
        x = parentPos.x + (currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap))
        y = parentPos.y + LAYOUT.verticalGap
        
        const siblings = currentCount
        if (siblings > 0) {
          x = parentPos.x + ((siblings - 0.5) * (LAYOUT.nodeWidth + LAYOUT.horizontalGap) / 2)
        }
      } else {
        x = currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap)
        y = depth * LAYOUT.verticalGap
      }
    }

    const pos = { x, y }
    positionCache.current.set(nodeId, pos)
    return pos
  }, [])

  return { getNodePosition }
}

坦白讲这段布局代码写得有点糙。并行分支水平展开的算法不太对,三个以上并行 Tool 的时候节点会挤成一坨——但 80% 的场景够用。再说吧。完美的 DAG 布局是一个学术级问题,Sugiyama 算法那一套你真去实现要写好几百行,在这个业务场景下追求完美属于浪费生命。你的用户关心的是"Agent 在干嘛""到第几步了""哪步挂了",不是这图的 margin 对不对称。

自定义节点组件,根据 ThinkingNodeType 渲染不同样式:

function ThinkingNodeComponent({ data }: { data: ThinkingNode }) {
  const statusColor = {
    pending: '#94a3b8',
    running: '#3b82f6',
    completed: '#22c55e',
    failed: '#ef4444',
  }[data.status]

  return (
    <div 
      className={`thinking-node thinking-node--${data.type}`}
      style={{ borderLeftColor: statusColor, borderLeftWidth: 4 }}
    >
      <div className="thinking-node__header">
        <span className="thinking-node__icon">{getIcon(data.type)}</span>
        <span>{data.label}</span>
        {data.status === 'running' && <PulseIndicator />}
      </div>
      
      {data.streamTokens && data.status === 'running' && (
        <div className="thinking-node__stream">
          {data.streamTokens.join('')}
          <BlinkingCursor />
        </div>
      )}
      
      {data.type === 'tool_call' && data.data.input && (
        <Collapsible title="参数">
          <pre>{JSON.stringify(data.data.input, null, 2)}</pre>
        </Collapsible>
      )}
      
      {data.type === 'tool_result' && (
        <Collapsible title="结果">
          <pre>{data.data.output}</pre>
        </Collapsible>
      )}
    </div>
  )
}

ThinkingChain 转成 @xyflow/react 要的 nodesedges 数组——BFS 遍历顺便算深度:

function chainToFlowElements(
  chain: ThinkingChain,
  getPosition: (id: string, parentId: string | null, depth: number) => { x: number; y: number }
) {
  const nodes: Node[] = []
  const edges: Edge[] = []

  const queue: Array<{ nodeId: string; depth: number }> = []
  const visited = new Set<string>()

  for (const [id, node] of chain.nodes) {
    if (!node.parentId) {
      queue.push({ nodeId: id, depth: 0 })
    }
  }

  while (queue.length > 0) {
    const { nodeId, depth } = queue.shift()!
    if (visited.has(nodeId)) continue
    visited.add(nodeId)

    const thinkingNode = chain.nodes.get(nodeId)!
    const position = getPosition(nodeId, thinkingNode.parentId, depth)

    nodes.push({
      id: nodeId,
      type: 'thinkingNode',
      position,
      data: thinkingNode,
    })

    if (thinkingNode.parentId) {
      edges.push({
        id: `${thinkingNode.parentId}-${nodeId}`,
        source: thinkingNode.parentId,
        target: nodeId,
        animated: thinkingNode.status === 'running',
        style: { stroke: thinkingNode.status === 'failed' ? '#ef4444' : '#64748b' },
      })
    }

    for (const childId of thinkingNode.children) {
      queue.push({ nodeId: childId, depth: depth + 1 })
    }
  }

  return { nodes, edges }
}

最终 React 组件:

function AgentDAGViewer({ chainManager }: { chainManager: ThinkingChainManager }) {
  const [chain, setChain] = useState<ThinkingChain | null>(null)
  const { getNodePosition } = useIncrementalLayout()

  useEffect(() => {
    return chainManager.subscribe(newChain => {
      setChain(structuredClone(newChain))
    })
  }, [chainManager])

  const { nodes, edges } = useMemo(() => {
    if (!chain) return { nodes: [], edges: [] }
    return chainToFlowElements(chain, getNodePosition)
  }, [chain, getNodePosition])

  const reactFlowInstance = useReactFlow()
  useEffect(() => {
    if (chain?.currentNodeId) {
      const pos = getNodePosition(chain.currentNodeId, null, 0)
      reactFlowInstance.setCenter(pos.x, pos.y, { duration: 300, zoom: 1 })
    }
  }, [chain?.currentNodeId])

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      nodeTypes={{ thinkingNode: ThinkingNodeComponent }}
      fitView={false}
      panOnDrag
      zoomOnScroll
      minZoom={0.3}
      maxZoom={1.5}
    >
      <Background />
      <Controls />
    </ReactFlow>
  )
}

踩坑提醒:useReactFlow() 必须在 <ReactFlowProvider> 内部调用否则直接报错,而且这个 Provider 不能和 <ReactFlow> 在同一个组件里——得包在外面一层,文档里写了但不显眼,十个人里九个半会踩这个。

设计权衡和边界

langgraph 还是 AgentExecutor?绕不开的选择。

LangChain 团队自己都在推 langgraph 作为 Agent 编排的下一代方案,AgentExecutor 某种意义上已经进维护模式了。langgraph 原生就是图结构——StateGraph 加节点加边——天然比 AgentExecutor 那个 while 循环模型更贴合 DAG 可视化的需求:

import { StateGraph } from '@langchain/langgraph'

const workflow = new StateGraph({ channels: agentState })
  .addNode('agent', callModel)
  .addNode('tools', callTools)
  .addEdge('__start__', 'agent')
  .addConditionalEdges('agent', shouldContinue, {
    continue: 'tools',
    end: '__end__',
  })
  .addEdge('tools', 'agent')

const app = workflow.compile()

langgraph 也不是万能药,它的学习曲线比 AgentExecutor 陡不少——StateGraphchannelsconditional edgescheckpointer 一堆新概念砸过来,而且 JS 版本目前功能比 Python 版少了一截。如果你的场景就是一个简单的 ReAct 循环,AgentExecutor 配上前面那套 callback 机制已经够使了,别为了架构上的"正确性"引入不必要的复杂度。能跑。够了。

性能方面最大的瓶颈根本不在前端渲染。

状态持久化这块,ThinkingChainManager 的数据目前纯内存,location.reload() 一下就全没了。如果需要回放历史对话的推理过程——企业场景里这个需求挺常见,审计合规什么的——得把整个 ThinkingChain 序列化存后端,每个事件带 timestamp,回放时按时间戳重新 replay。这块展开讲又是一整篇文章的体量了。

跑了大半年生产环境,这套方案最大的教训就一句话:别想一步到位。ThinkingNodetype 枚举我改了四版,ToolMeta 的结构加了三次字段,DAG 布局算法换过两种方案。先用 AgentExecutor 加最基础的 callback 加一个简单的列表式渲染跑通,确认产品方向没问题了再逐步往上堆 DAG 可视化、增量布局、流式 token 这些花活。想等一步到位只会等出个寂寞来。

Elpis: 基于vue3+webpack5+nodejs搭建一个完整项目

前言

  • 本文主要是基于抖音哲玄前端进行学习与总结,如有需要可以抖音搜索 哲玄前端 进行了解学习。
  • 个人目前是纯前端,对于服务端所知不够,如果你也有学习完整项目的想法,可以和跟着我的文章一起学习了解整个项目的过程。后续会持续更新。文章中若有不对的地方请多多指教,我会及时更正。

项目背景

日常开发中我们往往面临

  1. 重复性很高的工作
  2. CRUD等基础工作
  3. 多套系统交付间产生大量工作
  4. 更多偏向纯前端开发,设计不到基建、服务相关知识

从这些点出发,我们将开发这个项目。

项目介绍

  • 技术栈:NodeJS 、Koa 、 vue3 、 webpack5
  • 一个企业级应用框架,用于快速构建企业级应用
  • 项目一开始经历了技术选型,然后确定整体架构。项目初始化后,先从BFF层开始开发。

项目架构

项目整体分成3层:

前端(Web/App/小程序) 
       ↓
专属 BFF(如 Web-BFFMobile-BFF)  
       ↓ 
通用后端服务(微服务/数据库/第三方API

image.png

其中BFF层的结构方式为

image.png

项目elpis-cor解析(BFF层)

elpis-cor是基于koa来实现的解析器,专门解析规定app目录下的中间件部分。 结构目录简单组成:

elpis
  |--app           //解析器指定解析的中间件
      |--middleware
      |    |--error-handler.js    //错误边界中间件
      |--router
      |    |--view.js             //加载路由
      middleware.js     //注入中间件地方
  |--config        //环境配置
  |--elpis-core    //解析器
      |--loader        //解析器相关文件都放这
          |--middleware.js    //middleware中间件解析器
          |--router.js        //路由中间件解析器
            ...
      |--index.js      //解析器入口
  |--logs          //日志
  |--index.js      //入口文件
  |--package.json  //配置文件

index.js入口文件是整个项目的入口,可以传入一些配置,比如项目名、首页地址等,然后先去加载解析器,同时会去加载注入中间件的app下的middleware.js,利用loader去处理整体的app文件下中间件。

让 AI 用自然语言操控三维地球 -- Cesium MCP 开源实践

让 AI 用自然语言操控三维地球 -- Cesium MCP 开源实践

一句"飞到埃菲尔铁塔,加个红色标记",Claude/Copilot/Cursor 就能帮你在 CesiumJS 里完成操作。这是怎么做到的?

演示效果

先看效果,了解 cesium-mcp 能做什么:

demo-full.gif

演示中通过 AI 对话完成了相机飞行、添加标记、样式修改等操作。

背景:当 GIS 遇上 AI Agent

CesiumJS 是 WebGL 三维地球可视化的事实标准。但凡涉及地理信息系统(GIS)的 Web 项目——智慧城市、数字孪生、无人机航线规划——几乎绑定 CesiumJS。

问题是:Cesium API 体量庞大,光 Viewer 就有几十个配置项,Entity 系统更是嵌套层层。新人写个"在地图上加个点"都要翻半天文档。

2024 年底 Anthropic 推出了 MCP(Model Context Protocol),让 AI 智能体能以标准化方式调用外部工具。我们顺着这条路做了一件事:

把 CesiumJS 的能力通过 MCP 协议暴露出来,让任何 AI 智能体都能用自然语言操控三维地球。

这就是 cesium-mcp

它能做什么

整体架构

graph LR
    subgraph AI["AI 智能体"]
        A1["Claude Desktop"]
        A2["VS Code Copilot"]
        A3["Cursor"]
    end

    subgraph MCP_Server["cesium-mcp-runtime<br/>(Node.js MCP Server)"]
        R1["MCP stdio 接口"]
        R2["WebSocket Server"]
    end

    subgraph Browser["浏览器"]
        B1["cesium-mcp-bridge<br/>(SDK)"]
        C1["CesiumJS Viewer<br/>三维地球"]
    end

    A1 -->|"MCP 协议<br/>(stdio)"| R1
    A2 -->|"MCP 协议<br/>(stdio)"| R1
    A3 -->|"MCP 协议<br/>(stdio)"| R1
    R1 <--> R2
    R2 <-->|"WebSocket<br/>JSON-RPC"| B1
    B1 -->|"命令执行"| C1

    style AI fill:#e8f4f8,stroke:#2196F3,stroke-width:2px
    style MCP_Server fill:#fff3e0,stroke:#FF9800,stroke-width:2px
    style Browser fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

简单说就是三层:

  1. cesium-mcp-bridge(浏览器 SDK):嵌入你的 CesiumJS 应用,通过 WebSocket 接收命令并执行
  2. cesium-mcp-runtime(MCP Server):连接 AI 智能体和浏览器,暴露 19 个标准化工具
  3. cesium-mcp-dev(开发辅助 MCP Server):在 IDE 里让 AI 助手更懂 Cesium API

19 个工具,覆盖 GIS 核心场景

类别 工具 说明
相机 flyTo setView getView zoomToExtent 飞行定位、视角切换
图层 addGeoJsonLayer addHeatmap addMarker addLabel 数据叠加、热力图
图层管理 removeLayer setLayerVisibility listLayers updateLayerStyle 增删改查
三维数据 load3dTiles loadTerrain loadImageryService 3D Tiles、地形、影像服务
底图 setBasemap 天地图、ArcGIS、OSM 一键切换
交互 highlight screenshot 要素高亮、截图
动画 playTrajectory 沿路径播放轨迹动画

你对 AI 说"加载这个 GeoJSON,用渐变色渲染人口密度",它会自动调用 addGeoJsonLayer 并传入样式参数。

三分钟跑起来

第一步:浏览器嵌入 bridge

npm install cesium-mcp-bridge
import { CesiumMcpBridge } from 'cesium-mcp-bridge';

// viewer 是你已有的 Cesium.Viewer 实例
const bridge = new CesiumMcpBridge(viewer, { port: 9100 });
bridge.connect();

第二步:启动 MCP 运行时

npx cesium-mcp-runtime

第三步:接入 AI 智能体

以 Claude Desktop 为例,在配置文件中添加:

{
  "mcpServers": {
    "cesium": {
      "command": "npx",
      "args": ["-y", "cesium-mcp-runtime"]
    }
  }
}

VS Code Copilot 用户在 .vscode/mcp.json 中配置:

{
  "servers": {
    "cesium": {
      "command": "npx",
      "args": ["cesium-mcp-runtime"]
    }
  }
}

然后直接用自然语言下指令:

  • "飞到北京天安门,高度 1000 米"
  • "加载这个 3D Tiles 模型"
  • "画一条从上海到纽约的折线"
  • "截张图发我"

开发时也有 AI 加持

除了运行时操控,我们还做了 cesium-mcp-dev——专为 IDE AI 助手设计的 MCP 服务器:

graph LR
    subgraph IDE["IDE 环境"]
        D1["GitHub Copilot"]
        D2["Cursor AI"]
        D3["Claude Code"]
    end

    subgraph DevServer["cesium-mcp-dev<br/>(MCP Server)"]
        T1["cesium_api_lookup<br/>API 文档查询"]
        T2["cesium_code_gen<br/>代码生成"]
        T3["cesium_entity_builder<br/>Entity 构建器"]
    end

    subgraph Output["输出"]
        O1["API 签名 & 示例"]
        O2["TypeScript 代码片段"]
        O3["Entity 配置 JSON"]
    end

    D1 -->|"MCP stdio"| DevServer
    D2 -->|"MCP stdio"| DevServer
    D3 -->|"MCP stdio"| DevServer
    T1 --> O1
    T2 --> O2
    T3 --> O3

    style IDE fill:#f3e5f5,stroke:#9C27B0,stroke-width:2px
    style DevServer fill:#fff3e0,stroke:#FF9800,stroke-width:2px
    style Output fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

提供 3 个工具:

工具 功能
cesium_api_lookup 按类名/方法查 Cesium API 文档,覆盖 Viewer、Entity、Camera 等 12 个核心类
cesium_code_gen 自然语言生 Cesium 代码,内置 11 个常见场景模板
cesium_entity_builder 交互式构建 Entity 配置,支持 8 种类型(point/polygon/model 等)

配置方式和 runtime 完全一致:

{
  "servers": {
    "cesium-dev": {
      "command": "npx",
      "args": ["cesium-mcp-dev"]
    }
  }
}

这意味着你在 VS Code 里写 Cesium 代码时,Copilot 可以直接查 API、生成代码片段、构建 Entity 配置——再也不用频繁切到文档网站。

一次操控的完整流程

以"飞到北京天安门,加个红色标记"为例,看看数据是怎么流转的:

sequenceDiagram
    participant User as 用户
    participant AI as AI 智能体
    participant RT as cesium-mcp-runtime
    participant BR as cesium-mcp-bridge
    participant CS as CesiumJS

    User->>AI: "飞到北京天安门,加个红色标记"
    AI->>AI: 理解意图,拆解为两步

    rect rgb(232, 244, 248)
    Note over AI,CS: 第一步:飞行定位
    AI->>RT: MCP tool_call: flyTo({lon:116.39, lat:39.91, h:1000})
    RT->>BR: WebSocket JSON-RPC
    BR->>CS: viewer.camera.flyTo(...)
    CS-->>BR: 飞行完成
    BR-->>RT: result: success
    RT-->>AI: tool_result: "已飞行到目标位置"
    end

    rect rgb(232, 245, 233)
    Note over AI,CS: 第二步:添加标记
    AI->>RT: MCP tool_call: addMarker({lon:116.39, lat:39.91, color:"red"})
    RT->>BR: WebSocket JSON-RPC
    BR->>CS: viewer.entities.add(...)
    CS-->>BR: entity created
    BR-->>RT: result: {id: "marker-1"}
    RT-->>AI: tool_result: "已添加红色标记"
    end

    AI-->>User: "已飞到天安门并添加了红色标记"

AI 自动将自然语言拆解为多个工具调用,每个工具走完 MCP -> WebSocket -> CesiumJS 的完整链路,结果逐级回传。用户只需要说一句话。

技术实现要点

Bridge:命令注册与执行

cesium-mcp-bridge 的核心是一个命令注册表。每个 MCP 工具对应一个命令处理器,通过 CesiumBridge.execute() 分发:

const bridge = new CesiumBridge(viewer);
// 收到 WebSocket 消息后
const result = await bridge.execute({
  action: 'flyTo',
  params: { longitude: 116.4, latitude: 39.9, height: 1000 }
});

Bridge 不关心命令从哪来——WebSocket、HTTP、甚至手动调用都行。这种解耦使得 Bridge SDK 可以独立于 MCP 使用。

Runtime:双向通信

Runtime 同时充当 MCP stdio 服务器和 WebSocket 服务器。AI 智能体通过 MCP 协议发送工具调用,Runtime 把它翻译成 JSON-RPC 命令通过 WebSocket 推给浏览器,等待执行结果后回传给 AI。

支持多会话(session),同一个 Runtime 可以连接多个浏览器页面。

版本策略

主版本号.次版本号跟踪 CesiumJS(1.139.x 对应 Cesium ~1.139.0),补丁版本独立迭代 MCP 功能。这样用户一看版本号就知道兼容哪个 Cesium。

已上架平台

平台 状态
npm Registry cesium-mcp-bridge / cesium-mcp-runtime / cesium-mcp-dev v1.139.2
MCP Official Registry io.github.gaopengbin/cesium-mcp-runtime / cesium-mcp-dev
Smithery runtime(19 tools)/ dev(3 tools)
awesome-mcp-servers PR 已提交

适用场景

  • 快速原型:用自然语言几分钟搭出 GIS 可视化 demo
  • 非开发人员:分析师、项目经理可以直接对 AI 说需求,AI 在 Cesium 上渲染结果
  • 教学演示:课堂上让学生用自然语言探索地理数据
  • 自动化流水线:CI/CD 中自动截图、自动验证地图渲染
  • 智慧城市/数字孪生:AI Agent 作为交互层,终端用户通过对话操控三维场景

参与贡献

项目完全开源(MIT),欢迎参与:

git clone https://github.com/gaopengbin/cesium-mcp.git
cd cesium-mcp
npm install
npm run build
npm test

GitHub: github.com/gaopengbin/… 官方文档: gaopengbin.github.io/cesium-mcp


如果你也在做 GIS + AI 的事情,欢迎交流。有问题直接在 GitHub Issues 提,我们会及时回复。

前端国际化(i18n)实战指南:构建多语言应用的完整方案

在当今全球化的互联网时代,构建支持多语言的前端应用已成为必备技能。本文将深入探讨前端国际化(i18n)的完整实现方案,从基础概念到高级实践,帮助你构建真正国际化的Web应用。

国际化核心概念

国际化(Internationalization,简称i18n)是指设计和开发能够适应不同语言和地区的应用程序。与之相关的还有本地化(Localization,简称l10n),即将应用适配到特定语言和地区的过程。

前端国际化主要涉及以下几个方面:

  • 文本翻译
  • 日期时间格式化
  • 数字和货币格式化
  • 文本方向(RTL/LTR)
  • 图片和资源本地化

技术方案选择

1. react-i18next(React生态首选)

// 安装依赖
npm install i18next react-i18next i18next-browser-languagedetector

// 配置i18n
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: 'zh',
    resources: {
      en: {
        translation: {
          welcome: 'Welcome to our application',
          greeting: 'Hello, {{name}}!',
          items: '{{count}} items'
        }
      },
      zh: {
        translation: {
          welcome: '欢迎使用我们的应用',
          greeting: '你好,{{name}}!',
          items: '{{count}}个项目'
        }
      }
    }
  });

// 在组件中使用
import { useTranslation } from 'react-i18next';

function WelcomeComponent() {
  const { t } = useTranslation();
  return <div>{t('welcome')}</div>;
}

2. Vue I18n(Vue生态首选)

// 安装依赖
npm install vue-i18n

// 配置Vue I18n
import { createI18n } from 'vue-i18n';

const i18n = createI18n({
  locale: 'zh',
  fallbackLocale: 'en',
  messages: {
    en: {
      message: {
        hello: 'hello world'
      }
    },
    zh: {
      message: {
        hello: '你好世界'
      }
    }
  }
});

// 在组件中使用
const { t } = useI18n();
console.log(t('message.hello'));

高级功能实现

1. 动态语言切换

// React实现
function LanguageSwitcher() {
  const { i18n } = useTranslation();
  
  const changeLanguage = (lng) => {
    i18n.changeLanguage(lng);
    // 保存用户偏好到localStorage
    localStorage.setItem('preferredLanguage', lng);
  };
  
  return (
    <div>
      <button onClick={() => changeLanguage('en')}>English</button>
      <button onClick={() => changeLanguage('zh')}>中文</button>
    </div>
  );
}

2. 复数和变量处理

// 翻译文件配置
{
  "items": "{{count}} items",
  "items_with_plural": "{{count}} item",
  "items_with_plural_plural": "{{count}} items"
}

// 使用插值和复数
const { t } = useTranslation();
t('items', { count: 5 }); // "5 items"
t('items', { count: 1 }); // "1 item"

3. 日期和数字格式化

import { format } from 'date-fns';
import { zhCN, enUS } from 'date-fns/locale';

// 日期格式化
根据当前语言选择locale
const formatDate = (date, locale) => {
  return format(date, 'PPPP', { 
    locale: locale === 'zh' ? zhCN : enUS 
  });
};

// 数字格式化
const formatNumber = (num, locale) => {
  return new Intl.NumberFormat(locale).format(num);
};

// 货币格式化
const formatCurrency = (amount, locale) => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: locale === 'zh' ? 'CNY' : 'USD'
  }).format(amount);
};

性能优化策略

1. 按需加载翻译文件

// 动态导入翻译资源
const loadLanguageAsync = async (lng) => {
  const resources = await import(`./locales/${lng}.json`);
  i18n.addResourceBundle(lng, 'translation', resources.default);
  await i18n.changeLanguage(lng);
};

// 使用时调用
loadLanguageAsync('en');

2. 翻译文件分离

// 按功能模块分离翻译文件
// common.json - 通用翻译
// user.json - 用户相关
// product.json - 产品相关

// 按需加载
i18n.addResourceBundle('en', 'common', commonTranslations);
i18n.addResourceBundle('en', 'user', userTranslations);

最佳实践建议

  1. 翻译键命名规范:使用层级结构,如user.profile.title
  2. 避免硬编码文本:所有用户可见文本都应通过翻译函数处理
  3. 考虑文本长度差异:不同语言的文本长度可能差异很大
  4. 支持RTL语言:为阿拉伯语等从右到左的语言提供支持
  5. 图片本地化:包含文字的图片也需要提供多语言版本
  6. SEO优化:为不同语言版本设置正确的hreflang标签

常见问题解决

1. 翻译缺失处理

// 配置fallback策略
i18n.init({
  fallbackLng: 'en',
  missingKeyHandler: (lng, ns, key) => {
    console.warn(`Missing translation: ${key} for language: ${lng}`);
  }
});

2. 上下文相关翻译

// 使用上下文区分相同词汇的不同含义
{
  "save": "保存",
  "save_context": "保存文件"
}

// 使用时指定上下文
t('save', { context: 'file' });

前端国际化是一个持续演进的过程,需要根据项目需求选择合适的技术方案。通过本文介绍的方法,你可以构建出支持多语言、用户体验优秀的国际化应用。记住,好的国际化不仅仅是翻译,更是对不同文化用户的尊重和理解。

Agent Skill 和 Rules 有什么区别?

用过 Cursor 或者 Claude 的人对 Rules 应该不陌生——在设置里写几条规则,AI 就会按你的要求来。

那它和 Agent Skill 有什么不同?我见过不少人把这两个混用,或者干脆不知道该用哪个。

Rules 是什么

Rules 就是约束条件,告诉 Agent "你必须怎样,不能怎样"。

比如:

- 回复必须用中文
- 代码注释不能用英文
- 不要使用 var,统一用 const/let
- 每次修改代码后,提醒我写单元测试

Rules 的特点是全局生效,只要在对话范围内,每一次交互都受它约束。它不是在教 Agent 怎么完成某件事,而是在限定 Agent 的行为边界。

Agent Skill 是什么

Skill 是执行方法,告诉 Agent "这件事应该怎么做"。

同样是 Code Review 场景:

## Code Review 执行指南
1. 优先检查安全漏洞,标记 [HIGH]
2. 检查边界条件和空值处理
3. 评估性能问题
4. 输出格式:问题 + 等级 + 修改建议

Skill 是按需触发的,用户发起相关任务时才加载进上下文,完成后卸载。

核心区别

Rules 管边界,Skill 管方法。

Rules Agent Skill
本质 约束 / 行为准则 执行方法 / 操作流程
生效范围 全局,始终有效 按需,任务触发时加载
解决什么 Agent 该怎么"做人" Agent 该怎么"做事"
类比 公司规章制度 岗位工作手册

同一场景,两者分工不同

还是代码助手的例子:

Rules 负责的部分:

- 所有建议必须附带代码示例
- 不要直接帮用户改代码,先说明问题让用户自己改
- 回复保持简洁,不要长篇大论

这些是行为约束,不管 Agent 在做什么任务,都要遵守。

Skill 负责的部分:

Code Review 时:先查安全 → 再查逻辑 → 再查性能 → 按模板输出报告

这是执行流程,只在做 Code Review 这件具体任务时才生效。

两者叠加的效果:Agent 做 Code Review,按 Skill 定义的流程走,同时遵守 Rules 里"必须附带代码示例"和"保持简洁"的约束。


什么时候用 Rules,什么时候用 Skill

用 Rules:

  • 希望 Agent 在所有场景下保持某种风格或约束
  • 涉及输出格式、语言、态度等通用行为
  • 内容简短,几条原则就能说清楚

用 Skill:

  • 某类任务有明确的执行步骤和质量标准
  • 不同任务之间的执行逻辑差异很大
  • 执行内容较重,不适合一直挂在上下文里

一个判断方法:如果这件事对所有任务都适用,写进 Rules;如果只在特定任务里才需要,封装成 Skill。

常见的混用错误

把执行流程塞进 Rules:

# 错误示范
Rules:
- 做 Code Review 时,先检查安全漏洞,再检查逻辑问题,
  再检查性能,最后输出报告,报告格式为……(一大段)

这段内容只在 Code Review 时有用,却全局常驻在上下文里,纯属浪费 token,应该封装成 Skill。

把约束写进 Skill:

# 错误示范
code-review Skill instruction:
- 回复必须用中文
- 保持简洁
- 不要直接帮用户改代码

这些是通用行为约束,不只 Code Review 需要,应该写进 Rules 全局生效。

总结

  • Rules:全局约束,管的是 Agent 的行为边界,始终生效
  • Skill:执行方法,管的是具体任务怎么做,按需加载
  • 两者不冲突,配合使用——Rules 定底线,Skill 定打法

设计 Agent 系统时,把该全局的放 Rules,把该按需的封 Skill,结构会清晰很多。

数字孪生大屏必看:Cesium 3D 模型选中交互,3 种高亮效果拿来就用!

接前文,3D模型加载到页面以后肯定要执行各种各样的操作,模型在大屏上的主要作用是执行相应的建筑物交互。

问题

这里需要注意3D模型的加载仍然存在一些问题。

首先是如果你使用的GLTF文件,在某些特殊情况下可能导致模型的网格加载异常,会出现无法选中的情况。

这一点我目前没发现有什么太好的解决方案,所以这里采用GLB文件规避掉了这个问题。

image.png

如果你手里只有GLTF文件,可以考虑使用 Blender 进行一下转换。

另外模型本身离地高度都是 0 的话可能存在无法选中的问题,所以这里建议背景设置离地高度为 -0.5,普通模型正常为 0

还有就是模型选中以后的显示问题。

解决方案

模型无法选中和选择错误的问题通过两个方案进行规避,一个是height离地高度,一个是pickable拾取

首先设置背景的离地高度为 -0.5,普通可以选中的模型离地高度为 0

image.png

另外设置一下 pickPriority拾取优先级pickable是否可拾取

最后就是设置模型的选中效果,这里我简单写了三种效果给大家选择,可以自行决定。

实际代码

模型添加的时候增加拾取优先级参数:

const buildingEntity = viewer.entities.add({
    id: options.id, // 唯一ID,点击交互时识别核心
    name: options.properties.name || options.id, // 建筑名称(可选)
    position: position,
    orientation: orientation, // 控制模型朝向
    pickPriority: options.pickPriority, // (核心)添加拾取优先级
    pickable: options.pickable,  // (核心)允许拾取
    model: {
        uri: options.modelUrl, // glTF/glb 模型路径
        scale: options.scale || 1.0, // 保证模型真实比例(建模时单位为米)
        minimumPixelSize: 0, // 取消最小像素限制,模型随地图缩放正常变化
        maximumScale: 20000, // 最大缩放限制
        runAnimations: false, // 静态建筑关闭动画(节省性能)
        clampToGround: true, // 贴地(自动适配地形高度,可选)
    },
    properties: options.properties || {}, // 绑定自定义属性(如状态接口)
});

这里的参数其实在模型选择那里可以再增加一层判断。

模型选中方法主要有三种:

// 1. 轮廓线方案
/**
 * 选中指定模型
 * @param {Cesium.Entity} entity 要选中的模型实体
 * @param {Function} onSelect 选中回调(如展示状态弹窗)
 * @param {Function} onUnselect 取消选中回调(用于先取消之前的选中)
 */
const selectModel = (entity, onSelect, onUnselect) => {
    // 取消之前的选中状态(包括回调执行)
    if (selectedEntity) {
        unselectModel(onUnselect);
    }

    // 校验是否为模型实体
    if (!entity || !entity.model) {
        console.warn('❌ 选中的不是模型实体');
        return;
    }

    // 标记为当前选中实体
    selectedEntity = entity;

    // 轮廓线高亮(更醒目,性能略高,需 Cesium 1.90+)
    entity.model.outlineColor = Cesium.Color.RED;
    entity.model.outlineWidth = 2;
    entity.model.outline = true;

    // 执行选中回调(绑定业务逻辑)
    if (typeof onSelect === 'function') {
        onSelect(entity);
    }
};

如果没有特殊要求,轮廓线方案其实非常简单实用。

// 2. 颜色高亮,修改模型材质
originalModelMaterial = entity.model.color || Cesium.Color.WHITE.clone();
entity.model.color = Cesium.Color.fromCssColorString('#fb0528').withAlpha(0.8);
// 强制刷新
viewer.scene.requestRender();

这里需要注意,修改模型材质一定要执行强制刷新

// 3. 模型遮罩效果
viewer.entities.add({
    position: entity.position,
    orientation: entity.orientation,
    model: {
        uri: entity.model.uri, // 复用同一个模型文件
        scale: 0.35, // 稍微大一点
        color: Cesium.Color.fromCssColorString('#409EFF').withAlpha(0.5), // 半透明蓝
        silhouetteColor: Cesium.Color.BLUE, // 可选:配合轮廓
        silhouetteSize: 2.0
    }
});

这种效果也非常不错,复用模型文件稍大一号,让他完美的遮住原始模型,给出一个透明色作为材质,显得很有科技感。

总结

模型设计的时候推荐大家优先使用 GLB 格式替代 GLTF 规避网格加载异常问题,

另外通过pickPriority(拾取优先级)和pickable(是否可拾取)参数,从逻辑层面控制模型的交互规则,彻底解决 点错模型、点不到模型 的问题。

后续增加相关图标的单击和操作,实现小型设备的交互。

Cornerstone3D源码-DICOMLoaderIImage 详解

Cornerstone3D 中的 DICOMLoaderIImage 详解

在 Cornerstone3D 里,从 DICOM 文件到屏幕上的图像,中间会经过解析、解码和封装几步。最终交给渲染和测量使用的,是一个实现 IImage 接口的对象;而当这个对象来自 DICOM 加载器时,其具体类型就是 DICOMLoaderIImage。本文说明 DICOMLoaderIImage 的含义、全部属性(含继承自 IImage 的),以及它和 IImageFrame 的关系,方便在阅读源码或做二次开发时心里有数。


一、图像从哪来、到哪去

Cornerstone3D 的图像大致会经历这样一条管线:

  1. 获取 DICOM 数据:通过 WADO-URI、WADO-RS 或本地文件拿到 DICOM 字节(P10 文件)。
  2. 解析与解码:用 dicom-parser 把字节解析成 DataSet,再按传输语法(Transfer Syntax)解码像素。
  3. 封装成“图像对象”:把解码后的像素和元数据(窗宽窗位、间距等)封装成一个实现 IImage 的对象,放进 core 的缓存、供 Viewport 使用。
  4. 渲染:core 和 vtk.js 根据 IImage 提供的像素和元数据在 2D/3D 里绘制。

这里的 IImage(图像接口)是 @cornerstonejs/core 定义的通用图像接口,不关心图像来自 DICOM 还是别的来源;DICOMLoaderIImage 则是 @cornerstonejs/dicom-image-loader 在加载 DICOM 时产出的子类型,在满足 IImage 契约之外,额外带上 DICOM 与解码相关的字段。

补充两点:DataSet 是 dicom-parser 解析 DICOM 字节后得到的结构化数据,可以按 tag 读各种元素;传输语法 UID 则决定像素是如何压缩/编码的(如 JPEG、RLE、未压缩等),解码器靠它还原像素。后文第六节会专门对比 IImage 与 IImageFrame 的语义与职责,并给出二者之间的赋值源码。


二、DICOMLoaderIImage 是什么

DICOMLoaderIImage 表示「由 DICOM 加载器创建、并可供 Cornerstone3D 使用的一张单帧图像」。定义在 packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts

export interface DICOMLoaderIImage extends Types.IImage {
  decodeTimeInMS: number;
  floatPixelData?: ByteArray | Float32Array;
  loadTimeInMS?: number;
  totalTimeInMS?: number;
  data?: DataSet;
  imageFrame?: Types.IImageFrame;
  transferSyntaxUID?: string;
}

继承 Types.IImage,所以拥有 core 需要的全部“图像”能力(尺寸、像素、间距、窗宽窗位、getPixelDatagetCanvas 等);同时新增上述 7 个与 DICOM 加载/解码相关的字段。可以简单记成:DICOMLoaderIImage = IImage + DICOM 加载与解码的元数据

下面先看它自己的属性,再看从 IImage 继承来的属性(不分组,一表列全)。


三、DICOMLoaderIImage 自身属性

属性 类型 必选 含义
decodeTimeInMS number 解码该帧像素所花时间(毫秒),用于性能统计与调试。
floatPixelData ByteArray | Float32Array 浮点像素数据;部分算法或模态(如 PET SUV)需要,若解码时已生成会放这里。
loadTimeInMS number 从开始加载到拿到可解码数据所花时间(毫秒)。
totalTimeInMS number 加载 + 解码的总耗时(毫秒)。
data DataSet 原始 DICOM 解析结果;保留后可再按 tag 读元素,用于测量、标注、导出等。
imageFrame Types.IImageFrame 该图像对应的帧级数据(尺寸、位深、调色板、解码后的 pixelData 等);createImage 会把解码得到的 frame 挂在这里。
transferSyntaxUID string DICOM 传输语法 UID,标识该帧像素的压缩/编码方式。

其中 ByteArrayDataSet 来自 dicom-parserTypes.IImageFrame 即 core 的 IImageFrame(后文第六节会说明它和 IImage 的区别)。与 IImage 重叠的字段(如 decodeTimeInMS)在 DICOMLoaderIImage 上以本节定义为准。


四、从 IImage 继承的属性(完整一览)

DICOMLoaderIImage 继承自 Types.IImage,因此下面这些 IImage 上的属性在 DICOMLoaderIImage 上同样存在、含义一致。此处按属性名列出,并标注必选/可选(类型带 ? 或为可选字段)。

属性 类型 必选 含义
imageId string 图像唯一标识(如带 wadouri/wadors 的 URL 或带 frame 的 id),用于缓存与查找。
referencedImageId string? 若本图由其他图像派生(如重建、融合),指向被引用图像的 imageId。
sharedCacheKey string? 多帧/多实例共享缓存时的键。
isPreScaled boolean? 加载时是否已做预缩放(如 Rescale Slope/Intercept 或 SUV)。
preScale object? 预缩放配置:是否启用、是否已缩放、以及 modality、rescaleSlope、rescaleIntercept、PT suvbw 等。
minPixelValue number 图像最小像素值。
maxPixelValue number 图像最大像素值。
slope number 灰度映射斜率(常为 Modality LUT / Rescale Slope)。
intercept number 灰度映射截距(常为 Rescale Intercept)。
windowCenter number[] | number 窗位(VOI),可多值。
windowWidth number[] | number 窗宽(VOI),可多值。
voiLUTFunction VOILUTFunctionType 窗宽窗位应用的函数类型:LINEAR、SIGMOID、LINEAR_EXACT。
invert boolean 是否反转显示(如 MONOCHROME1)。
modalityLUT CPUFallbackLUT? CPU 渲染用 Modality LUT。
voiLUT CPUFallbackLUT? CPU 渲染用 VOI LUT。
getPixelData () => PixelDataTypedArray 返回当前帧像素数组,core 与工具通过它取像素。
getCanvas () => HTMLCanvasElement 返回用于 CPU 渲染的 2D 画布(颜色图常用)。
dataType PixelDataTypedArrayString 像素数组类型名,如 "Int16Array"、"Uint8Array"。
sizeInBytes number 像素数据占用的字节数。
bufferView { buffer: ArrayBuffer; offset: number }? 像素在更大 ArrayBuffer 中的视图,用于零拷贝或共享缓冲。
rows number 行数(高度方向像素数)。
columns number 列数(宽度方向像素数)。
height number 显示高度(通常等于 rows)。
width number 显示宽度(通常等于 columns)。
columnPixelSpacing number 列方向像素间距(mm)。
rowPixelSpacing number 行方向像素间距(mm)。
sliceThickness number? 层厚(mm)。
numberOfComponents number 每像素分量数(1=灰度,3=RGB,4=RGBA)。
color boolean 是否为颜色图。
rgba boolean 是否为 RGBA(含 alpha)。
photometricInterpretation string? DICOM 光度解释,如 "MONOCHROME2"、"RGB"、"PALETTE COLOR"。
calibration IImageCalibration? 校准信息:像素间距、比例、类型、提示文案、超声区域等。
scaling object? 与缩放相关的元数据,如 PT 的 SUV 相关因子。
FrameOfReferenceUID string? DICOM Frame of Reference UID,用于空间配准与多序列对齐。
render function? CPU 回退时的自定义绘制函数。
stats object? 上次渲染、LUT 生成、像素存取等耗时统计。
cachedLut object? 缓存的 LUT(windowWidth/Center、invert、lutArray、modalityLUT、voiLUT)。
colormap CPUFallbackColormap? CPU 伪彩色映射。
imageQualityStatus ImageQualityStatus? 帧的质量等级,数值越大越接近无损;用于渐进式加载或替换低质帧。
imageFrame IImageFrame? 帧级数据;在 IImage 上可选,DICOMLoaderIImage 中会挂 createImage 产出的 frame。
voxelManager IVoxelManager<number> | IVoxelManager<RGB>? 按坐标访问/采样体素的管理器。
loadTimeInMS number? 加载耗时(毫秒)。
decodeTimeInMS number? 解码耗时(毫秒);在 IImage 上可选,在 DICOMLoaderIImage 上为必选并覆盖。

相关子类型简要说明:IImageFrame 描述单帧的尺寸、位深、调色板、pixelData、transferSyntax 等;VOILUTFunctionType 为窗宽窗位函数枚举(LINEAR、SIGMOID、LINEAR_EXACT);ImageQualityStatus 表示损失程度/分辨率等级,数值越大越无损;IImageCalibration 为校准信息;PixelDataTypedArray 为像素数组联合类型,PixelDataTypedArrayString 为其类型名字符串。


五、为什么 IImage 和 DICOMLoaderIImage 都定义了 imageFrame?

两边都写了 imageFrame?: IImageFrame,类型相同、都是可选,并不是在“覆盖”或“重写”属性,而是同一属性在不同层级上的语义区分

IImage 里,imageFrame 是可选的,因为 IImage 要兼容各种来源(DICOM、内存、Canvas、其他 loader),很多来源没有“帧”的概念,所以基类只表示“有的图像会带帧数据”。

DICOMLoaderIImage 里再次声明,是为了在类型层面明确:由 DICOM 加载器产出的图像会携带帧级数据;createImage 在构造时会把解码得到的 frame 挂上去,运行时通常都有值,但类型上仍保持可选,以便和 IImage 一致。

在 core 里,缓存/清理时可能会访问 image.imageFrame(例如 delete image.imageFrame.pixelData 以释放像素引用),而 StackViewport 等用 image?.imageFrame 做可选链访问,以兼容没有 imageFrame 的图像。

总结:两处定义的是同一个属性,基类表示“可选、部分图像有”,子类再次列出表示“DICOM 加载器会填这个字段”,更多是语义与文档上的强调。


六、IImageFrame 与 IImage 的语义与功能区别

两者有不少重复字段(如 rows、columns、min/max、preScale、decodeTimeInMS、photometricInterpretation),是因为所处阶段和职责不同,重复是有意为之。

IImageFrame 处于解码阶段:由 getImageFrame() 从元数据填一部分,再由 decodeImageFrame() 填像素等,在 loader 包内流动。它贴近 DICOM/解码结果,包含 bitsAllocated、bitsStored、pixelRepresentation、调色板、pixelData、transferSyntax、decodeLevel 等,使用 DICOM 用语(smallestPixelValue / largestPixelValue)。

IImage 处于运行时阶段:由 createImage() 用已解码的 IImageFrame 拼出来,作为缓存的条目和 Viewport/工具消费的“图像”。它贴近显示与测量,提供 getPixelData()、getCanvas()、窗宽窗位、几何与校准等,使用 minPixelValue / maxPixelValue,并可选地持有 imageFrame 以便访问原始帧。

之所以在 IImage 上再保留一份 rows、min/max 等,是因为 core 的 cache、Viewport、工具只依赖 IImage,不依赖“一定有 imageFrame”;很多图像来源没有 frame,所以 IImage 需要自包含,调用方用 image.rowsimage.minPixelValue 即可。在 DICOM 路径下,createImage 从同一个 IImageFrame 拷贝这些值到 IImage,并把该 frame 挂到 image.imageFrame,所以同一份信息会同时出现在 frame 和 image 上。

简言之:Frame = 解码管线里的“一帧原始数据”;Image = 对外暴露的“一张可显示/可测量的图像”。重复不是为了冗余,而是让 IImage 作为对外契约能独立使用,IImageFrame 则保留解码与格式细节;frame 驱动解码与构造,image 驱动缓存与渲染。

IImageFrame 与 IImage 之间的赋值:源码说明

1. IImageFrame 的创建与填充

帧对象先由元数据构造出“壳”,再交给解码器填像素。在 createImage 里(packages/dicomImageLoader/src/imageLoader/createImage.ts):

const imageFrame = getImageFrame(imageId);   // 从 metaData 得到 imagePixelModule,构造初始 IImageFrame
imageFrame.decodeLevel = options.decodeLevel;
// ...
const decodePromise = decodeImageFrame(
  imageFrame,
  transferSyntax,
  pixelData,
  canvas,
  options,
  decodeConfig
);

getImageFramepackages/dicomImageLoader/src/imageLoader/getImageFrame.ts)根据 imageId 从 metaData 取出 imagePixelModule,返回一个只含像素描述(rows、columns、bitsAllocated、调色板等)、pixelData 仍为 undefined 的 IImageFrame。随后 decodeImageFrame 在同一对象上写入 pixelDatasmallestPixelValuelargestPixelValuepreScaledecodeTimeInMS 等,并 resolve 该 imageFrame。

2. 从 IImageFrame 赋到 IImage(createImage)

解码完成后,在 decodePromise.then 里用同一个 imageFrame 构造 DICOMLoaderIImage,既把帧上的字段拷贝到 image,又把帧引用挂到 image.imageFrame(见 createImage.ts):

decodePromise.then(function (imageFrame: Types.IImageFrame) {
  // ...
  const image: DICOMLoaderIImage = {
    imageId,
    dataType: imageFrame.pixelData.constructor.name as Types.PixelDataTypedArrayString,
    columns: imageFrame.columns,
    height: imageFrame.rows,
    rows: imageFrame.rows,
    width: imageFrame.columns,
    preScale: imageFrame.preScale,
    minPixelValue: imageFrame.smallestPixelValue,
    maxPixelValue: imageFrame.largestPixelValue,
    sizeInBytes: imageFrame.pixelData.byteLength,
    decodeTimeInMS: imageFrame.decodeTimeInMS,
    imageFrame,                                    // 整帧引用挂到 image 上
    getPixelData: () => imageFrame.pixelData,
    // ... 其余来自 imagePlaneModule、voiLutModule、modalityLutModule 等
  };
  // ...
});

未提供窗宽窗位时,会用 image.imageFrame 上的最小/最大像素值算默认窗宽窗位:

if (image.windowCenter === undefined || image.windowWidth === undefined) {
  const windowLevel = utilities.windowLevel.toWindowLevel(
    image.imageFrame.smallestPixelValue,
    image.imageFrame.largestPixelValue
  );
  image.windowWidth = windowLevel.windowWidth;
  image.windowCenter = windowLevel.windowCenter;
}

3. core 侧对 image.imageFrame 的使用

图像进入 core 后,若需要补建 voxelManager(例如原本没有 voxelManager 的 loader),会在 ensureVoxelManager 里用 image.getPixelData() 创建 voxelManager,并删除 frame 上的 pixelData 引用以释放内存(packages/core/src/loaders/imageLoader.ts):

function ensureVoxelManager(image: IImage): void {
  if (!image.voxelManager) {
    const voxelManager = VoxelManager.createImageVoxelManager({
      scalarData: image.getPixelData(),
      width: image.width,
      height: image.height,
      numberOfComponents: image.numberOfComponents,
    });
    image.voxelManager = voxelManager;
    image.getPixelData = () => voxelManager.getScalarData() as PixelDataTypedArray;
    delete image.imageFrame.pixelData;   // 释放对原始 pixelData 的引用
  }
}

当前 core 实现中并未对 image.imageFrame 做存在性判断,实际调用链里由 DICOM loader(createImage)保证会挂上 imageFrame;若自定义 loader 不提供 imageFrame,在走 ensureVoxelManager 前需自行做存在性判断或避免调用会访问 imageFrame 的逻辑。


七、DICOMLoaderIImage 的用途

  • 作为 loadImage / createImage 的返回类型wadouri/loadImagewadors/loadImagecreateImage 在完成 DICOM 解析与像素解码后会构造并返回 DICOMLoaderIImage(或个别路径先返回 IImageFrame 再封装),调用方将该对象交给 core。
  • 注入 core 缓存与 Viewport:core 的缓存和 StackViewport 接收实现 IImage 的对象,DICOMLoaderIImage 可直接被缓存并用于 2D 显示、测量、窗宽窗位、多帧滚动等。
  • 保留 DICOM 上下文:通过 data(DataSet)、transferSyntaxUID、imageFrame 等,后续仍可访问原始 DICOM 元素与帧级信息,用于测量、标注、导出或高级渲染(如浮点像素、SUV 显示)。
  • 性能与质量决策:decodeTimeInMS、loadTimeInMS、totalTimeInMS 用于监控加载性能;imageQualityStatus 用于渐进式加载或用高质帧替换低质帧。

整体上,DICOMLoaderIImage 就是在 IImage 基础上、专门表示“由 DICOM 加载器产生”的图像类型,并带有 DICOM 与解码相关的扩展字段,贯穿从 DICOM 到 Cornerstone3D 渲染的整条管线。若你做自定义 loader(例如从非 DICOM 源加载图像)并希望产出的对象能被 core 缓存与 Viewport 使用,需要实现 IImage 接口(至少提供 imageId、getPixelData、getCanvas、尺寸与窗宽窗位等必选字段);若也要保留“帧”级原始数据供清理或高级用法,可像 DICOMLoaderIImage 一样可选挂载 imageFrame

相关文件

  • packages/dicomImageLoader/src/types/DICOMLoaderIImage.ts
  • packages/core/src/types/IImage.ts
  • packages/core/src/types/IImageFrame.ts
  • packages/dicomImageLoader/src/imageLoader/createImage.ts
  • packages/dicomImageLoader/src/imageLoader/getImageFrame.ts
  • packages/core/src/loaders/imageLoader.ts

CSS 几何美学:从基础图形到创意绘制的艺术之旅

CSS 几何美学:从基础图形到创意绘制的艺术之旅

在 Web 开发的浩瀚星空中,CSS(层叠样式表)往往被视作排版与配色的工具。然而,在资深前端工程师的眼中,CSS 更是一支神奇的画笔。无需依赖任何外部图片资源,仅凭几行代码,我们就能在浏览器画布上勾勒出千变万化的几何图形。

本文将深入解析代码中的图形奥秘,并在此基础上拓展更多高阶画法,带您领略“纯 CSS 绘图”的无限可能。


第一章:代码解码——基础图形的构建逻辑

您提供的代码片段虽然简短,却蕴含了 CSS 绘图的三大核心原理:边框 Trick(Border Trick)圆角裁剪(Border-Radius)变换旋转(Transform)。让我们逐一拆解。

1. 三角形的魔法:边框的障眼法

代码中的 .triangle 类展示了经典的“边框绘图法”。

.triangle {
    width: 5px;
    height: 5px;
    border: 15px solid transparent;
    border-top-color: #f00;
}

原理解析: 当一个元素的宽和高极小(甚至为 0),而边框(border)很宽时,浏览器的渲染引擎会将四个边框渲染为四个梯形,并在中心交汇。

  • border 设为 transparent(透明),意味着我们只保留了边框的“形状”,隐藏了颜色。
  • 单独设置 border-top-color 为红色,就只留下了上方的梯形。
  • 由于底边极窄,这个梯形最终变成了一个完美的等腰三角形注:代码中注释掉的部分展示了四色边框的效果,那是四个不同颜色的三角形拼接成的正方形,是理解此原理的绝佳实验。

2. 扇形与圆弧:圆角的极致运用

代码提供了两种扇形画法,分别代表了两种不同的思路。

思路 A:宽高比控制 (.sector)

.sector {
    width: 100px;
    height: 100px;
    border-radius: 100px 0 0; /* 左上角半径极大,其余为0 */
    background-color: #00f;
}

通过设置 border-radius 的四个值(左上、右上、右下、左下),我们可以独立控制每个角的曲率。当左上角的半径值大于元素本身宽高时,它就会形成一个 90 度的扇形(四分之一圆)。

思路 B:边框与圆角结合 (.sector2)

.sector2 {
    border: 100px solid transparent;
    width: 0;
    border-radius: 100px;
    border-top-color: #f00;
}

这是在三角形原理基础上的进化。给一个宽为 0、边框透明的元素加上 border-radius,会让原本尖锐的边框交汇处变得圆润,从而切割出弧形边缘,形成扇形。

3. 箭头与椭圆:变换与比例

  • 箭头 (.arrow):利用 border 只保留右边和下边,再通过 transform: rotate(45deg) 旋转 45 度,两条边瞬间合二为一,形成一个指向右下方的箭头。这是对话框气泡尾部的经典实现方式。
  • 椭圆 (.oval):最简单的图形。只要 width 不等于 height,再配合 border-radius: 50%,正方形就会拉伸成完美的椭圆。

第二章:进阶扩展——解锁更多 CSS 图形秘籍

基于上述原理,我们可以进一步探索更复杂的图形绘制,无需 SVG 或 Canvas,仅用 CSS 即可实现。

1. 平行四边形 (Parallelogram)

想要让矩形“倾斜”起来?不要直接旋转整个元素(否则内容也会歪斜),请使用 skew 变换。

.parallelogram {
    width: 150px;
    height: 60px;
    background: #8e44ad;
    transform: skew(-20deg); /* 水平倾斜 -20 度 */
}
/* 如果内部有文字,需要反向倾斜回来 */
.parallelogram span {
    display: block;
    transform: skew(20deg); 
}

应用场景:科技感的数据看板、动态按钮背景。

2. 六角星 (Hexagram) / 大卫之星

这是两个等边三角形的叠加。利用伪元素 ::before::after 可以轻松实现,无需额外 HTML 标签。

.star {
    position: relative;
    width: 0;
    height: 0;
    border-left: 50px solid transparent;
    border-right: 50px solid transparent;
    border-bottom: 80px solid #f1c40f; /* 正三角 */
}
.star::before, .star::after {
    content: "";
    position: absolute;
    width: 0;
    height: 0;
    border-left: 50px solid transparent;
    border-right: 50px solid transparent;
}
.star::before {
    border-bottom: 80px solid #f1c40f;
    top: 30px; /* 调整位置以重叠 */
    transform: rotate(60deg);
}
.star::after {
    border-bottom: 80px solid #f1c40f;
    top: 30px;
    transform: rotate(-60deg);
}

原理:利用绝对定位将三个三角形(一个本体,两个伪元素)以 60 度差值旋转叠加。

3. 心形 (Heart)

心形是浪漫网页设计的标配,它由两个圆形和一个旋转的正方形组合而成。

.heart {
    position: relative;
    width: 50px;
    height: 50px;
    background-color: #e74c3c;
    transform: rotate(-45deg); /* 整体旋转 45 度 */
}
.heart::before, .heart::after {
    content: "";
    position: absolute;
    width: 50px;
    height: 50px;
    background-color: #e74c3c;
    border-radius: 50%; /* 变成圆形 */
}
.heart::before {
    top: -25px; /* 向上移半个身位 */
    left: 0;
}
.heart::after {
    left: 25px; /* 向右移半个身位 */
    top: 0;
}

视觉效果:两个圆分别位于正方形的上方和右方,旋转后完美融合成心形。

4. 对话气泡 (Speech Bubble)

结合“三角形箭头”和“圆角矩形”,我们可以快速制作聊天气泡。

.bubble {
    position: relative;
    width: 120px;
    height: 80px;
    background: #3498db;
    border-radius: 10px;
    padding: 10px;
    color: white;
}
/* 利用伪元素制作尾巴 */
.bubble::after {
    content: "";
    position: absolute;
    bottom: -10px; /* 定位到底部下方 */
    left: 20px;
    width: 0;
    height: 0;
    border-left: 10px solid transparent;
    border-right: 10px solid transparent;
    border-top: 10px solid #3498db; /* 颜色与气泡一致 */
}

5. 加载动画:旋转的圆环 (Loader)

静态图形是基础,动态图形才是灵魂。利用 border 的部分透明化加上 animation,可以制作流畅的 Loading 效果。

.loader {
    width: 40px;
    height: 40px;
    border: 4px solid #f3f3f3; /* 灰色底色 */
    border-top: 4px solid #3498db; /* 蓝色高亮 */
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

原理:一个只有顶部有颜色的圆环,通过无限旋转,视觉上就像在“吃豆人”一样转动,给用户明确的等待反馈。


第三章:为什么选择纯 CSS 绘图?

在 SVG 和 Canvas 如此强大的今天,我们为什么还要钻研 CSS 画图?

  1. 性能极致:CSS 图形由浏览器原生渲染引擎直接绘制,无需下载额外的图片资源,减少了 HTTP 请求,提升了页面加载速度。
  2. 无限缩放:基于矢量的边框和圆角特性,CSS 图形在任何分辨率屏幕(Retina屏、4K屏)下都清晰锐利,绝无锯齿。
  3. 灵活可控:通过 CSS 变量(Custom Properties)和伪类(:hover, :active),图形可以轻易地响应交互、改变颜色或形状,这是静态图片难以比拟的。
  4. 代码即设计:设计师的意图直接转化为代码,减少了切图环节,让前端开发更加流畅。

结语

从那个小小的红色三角形开始,我们看到了 CSS 蕴含的巨大能量。它不仅仅是样式的描述语言,更是一套严谨的几何构建系统。

掌握这些技巧,意味着您不再受限于素材库。无论是需要一个简单的下拉箭头,还是一个复杂的动态徽章,您都可以信手拈来,用代码编织出视觉的奇迹。下一次,当您面对空白的设计稿时,请记得:您的键盘,就是最强大的画笔。

为什么敲几个字母就能访问网站?DNS原理大揭秘

你有没有想过一个问题:为什么输入"baidu.com"就能打开百度?

今天,我用查字典的故事,来讲讲DNS到底在做什么。


原文地址

墨渊书肆/为什么敲几个字母就能访问网站?DNS原理大揭秘


DNS是干嘛的?

想象一下,你想知道某个同学的电话号码。

有两种方式:

  1. 直接记住:你把全班同学的电话号码都背下来
  2. 查通讯录:你不知道,但你可以查学校的通讯录

显然,第二种更现实。

互联网也是一样的:

  • IP地址:就像电话号码,比如112.80.248.76
  • 域名:就像人名,比如baidu.com

你不可能记住所有网站的IP地址,但你可以记住域名。

DNS(Domain Name System,域名系统)就是互联网的"通讯录"——它负责把域名翻译成IP地址。


域名是怎么组成的?

先来看看www.baidu.com这个域名:

www.baidu.com
  │     │    │
  │     │    └──── .com 是顶级域名
  │     └───────── baidu 是二级域名
  └─────────────── www 是三级域名

域名层级

  • 根域名.(那个隐藏的点)
  • 顶级域名.com.cn.org.edu
  • 二级域名baidutaobaogoogle
  • 三级域名wwwmailblog

就像地址一样:

中国 → 北京市 → 海淀区 → 中关村大街1号
.     →   .cn  →  .beijing →  .zhongguancun

DNS是怎么工作的?

你输入baidu.com,浏览器是怎么找到服务器的?

让我用查字典的故事来解释:

想象一下查字典

你想查"百度"这个词的意思:

  1. 先翻记忆(浏览器缓存):上次好像在哪儿见过?
  2. 翻家里的小本子(操作系统hosts):上次记在笔记本上了
  3. 问老师(本地DNS服务器):我们学校有本字典能查
  4. 老师问校长(根DNS服务器):这个词太长了,得分头查
  5. 校长查索引(顶级域名服务器):.com开头的词都归.com部门管
  6. 最后查到(权威DNS服务器):找到了!这个词的意思是"IP 112.80.248.76"

这就是DNS查询的全过程!

更直观的流程图

你输入 baidu.com
        │
        ▼
┌───────────────────┐
│  1. 浏览器缓存    │ 有?直接用
└─────────┬─────────┘
          │ 没有
          ▼
┌───────────────────┐
│  2. 操作系统缓存   │ 有?直接用
└─────────┬─────────┘
          │ 没有
          ▼
┌───────────────────┐
│  3. 本地DNS服务器  │
│   (114.114.114.114)│
└─────────┬─────────┘
          │
          ▼
    一级一级往下问
          │
    ┌─────┴─────┐
    ▼           ▼
 根DNS      .com DNS
    │           │
    │           ▼
    │     baidu.com DNS
    │           │
    └─────┬─────┘
          │
          ▼
┌───────────────────┐
│  返回IP: 112.80.248.76 │
└───────────────────┘
          │
          ▼
   浏览器访问这个IP

每个步骤具体做什么?

步骤 谁在做 做了什么
1 浏览器 检查最近访问过的域名缓存
2 操作系统 检查hosts文件
3 本地DNS 运营商的DNS服务器,帮你递归查询
4 根DNS服务器 负责.和顶级域名(.com.cn
5 权威DNS 域名所有者自己的DNS(百度公司)

整个过程可能只需要几十毫秒,你根本感觉不到!


DNS缓存:不用每次都问

上面的流程看起来很复杂,但因为DNS缓存无处不在,实际上很少走完完整流程:

  • 浏览器缓存:几分钟到几小时
  • 操作系统缓存:Windows、macOS会缓存更久
  • 路由器缓存:你家路由器也可能缓存
  • 运营商缓存:运营商的DNS服务器会缓存

所以第一次访问baidu.com可能慢一点,之后就快了。


DNS记录类型:不止A记录

DNS不只是存IP地址,还有很多类型:

类型 作用 例子
A记录 域名 → IPv4 baidu.com112.80.248.76
AAAA记录 域名 → IPv6 baidu.com240e:ff:e020:9e::100
CNAME 域名 → 另一个域名 www.baidu.comwww.a.shifen.com
MX记录 域名 → 邮件服务器 @baidu.commx.baidu.com
TXT记录 存放文本信息 用来验证域名所有权
NS记录 指定域名由哪个DNS服务器解析 baidu.comdns.baidu.com

CNAME的好处

www.baidu.com其实指向了www.a.shifen.com

这就是CNAME——别名。

百度可以随时把www.baidu.com指向新的IP,只需要改一下CNAME,用户不需要记住新IP,体验不变。


DNS有什么问题?

DNS很棒,但不是完美的。

1. DNS污染

有些运营商或防火墙会篡改DNS结果。你输入google.com,返回了错误的IP——你访问不了Google,或者访问了假网站。

这叫DNS污染(DNS Spoofing)。

2. DNS劫持

黑客黑掉你的路由器或DNS服务器,故意返回错误的IP——你以为是访问银行,实际上是钓鱼网站。

这叫DNS劫持(DNS Hijacking)。

3. 隐私问题

DNS查询是明文的,你的运营商知道你访问了哪些网站。


怎么解决?——DNS over HTTPS

既然DNS有问题,那就加密!

DoH(DNS over HTTPS)

把DNS查询伪装成HTTPS请求,就像普通的网页请求一样。

别人看到了HTTPS请求,但不知道你在查DNS。

DoT(DNS over TLS)

用TLS加密DNS查询,更加安全。

公共DNS

除了运营商的DNS,还有一些公共DNS

  • Google DNS8.8.8.88.8.4.4
  • Cloudflare DNS1.1.1.11.0.0.1

这些公共DNS通常更快、更安全、不劫持。


总结:DNS做了什么?

步骤 做了什么
1. 输入域名 baidu.com
2. 查缓存 浏览器、操作系统、路由器有没有记录?
3. 递归查询 根服务器 → .com服务器 → baidu.com DNS
4. 返回IP 112.80.248.76
5. 访问服务器 浏览器向这个IP发起HTTP请求

写在最后

现在你应该懂了:

  • DNS = 互联网的"通讯录",把域名翻译成IP
  • A记录 = 域名→IP的对应关系
  • CNAME = 别名,让换IP更容易
  • DNS缓存 = 各地都有小本本,不用每次都查
  • DoH/DoT = 加密查询,更安全

下次你输入baidu.com的时候,记得——这背后有一群DNS服务器在帮你"查号"呢。

HTTP裸奔,HTTPS穿盔甲——它们有什么区别?

你有没有想过一个问题:为什么有的网站显示"不安全",有的显示"安全"?同样是HTTP,加了个"S"到底有什么区别?

今天,我用写信的故事,来讲讲HTTPS到底在加密什么。


原文地址

墨渊书肆/HTTP裸奔,HTTPS穿盔甲——它们有什么区别?


HTTP是怎么"裸奔"的?

想象一下,你给朋友寄信。

如果用HTTP,相当于你把信写在明信片上,直接塞进邮筒。

邮递员、门卫、邻居——谁都能看到信里的内容。

你写的「我爱你」「我的密码是123456」——所有人都一览无余。

这就是HTTP的现状:数据是明文传输的,谁都能偷看。

HTTP会被谁偷看?

  • 隔壁老王连上了同一个WiFi
  • 运营商能查到你在访问什么网站
  • 某些"中间人"专门拦截网络流量

所以在HTTP上输密码、填银行卡——跟裸奔没什么区别。


HTTPS是怎么"穿盔甲"的?

现在换一种方式。

你把信放进一个带锁的盒子里,只有你和朋友有钥匙。

邮递员拿到了盒子?没关系,他打不开。

这就是HTTPS:数据是加密传输的,别人看到了也看不懂。

HTTPS怎么做到加密?

这就涉及到两种加密方式:对称加密非对称加密


对称加密:一把钥匙(代表:AES)

对称加密就像一把钥匙,能开锁也能锁门。

  • 加密用这把钥匙
  • 解密也用这把钥匙

AES(Advanced Encryption Standard,高级加密标准)就是最典型的对称加密算法。

  • 优点:速度快,加密1G数据可能只需要几毫秒
  • 缺点:密钥传输问题——怎么把这把钥匙安全地交给对方?

现实中的问题

你想给朋友寄一把钥匙过去,但邮递员能看到啊!他拿到钥匙之后,不就能打开你的盒子了吗?

这就是对称加密的困境:钥匙送不到对方手里。

AES到底有多强?

AES是现在最常用的对称加密算法,被美国政府采用,取代了之前的DES。

它有多安全?

  • AES-128:密钥长度128位,暴力破解需要上万亿年
  • AES-256:更安全,连量子计算机都很难破解

所以AES本身是非常安全的,关键是怎么安全地把密钥送到对方手里。


非对称加密:两把钥匙(代表:RSA)

后来密码学家发明了非对称加密,像是一把神奇的锁。

它配两把钥匙

  • 公钥(Public Key):锁,可以复制多份分发出去
  • 私钥(Private Key):钥匙,只有自己手里有

RSA就是最著名的非对称加密算法,由三位数学家Rivest、Shamir、Adleman的名字命名。

RSA是怎么工作的?

  1. 你生成一对密钥:公钥私钥
  2. 公钥(锁)公开,谁都可以拿到
  3. 别人用你的公钥加密信息
  4. 只有你手里的私钥能解密

怎么用?

  1. 你把公钥(锁)寄给朋友
  2. 朋友把信放进盒子里,用锁锁好
  3. 只有你手里的私钥(钥匙)能打开

这样一来,公钥寄出去没关系,反正只能用来"锁",不能用来"开"。

RSA的缺点

  • 非常:比对称加密慢几百甚至上千倍
  • 只能加密少量数据:一般用来加密"密钥",而不是大量内容

这就是为什么不能全程用RSA加密。


HTTPS是怎么结合两者的?

HTTPS很聪明,它把两者结合起来用:

  1. 用RSA等非对称加密,把一把"对话密钥"(会话密钥)安全地传给对方
  2. 之后双方都用"对话密钥"做AES对称加密,速度就快了

这就跟做生意一样:

  • 见面谈判(慢,但安全)→ 确立合作方式,交换暗语
  • 之后用暗语交流(快)→ 正式合作

HTTPS也是这个道理:先"握手"一次确定加密方式(用RSA),之后就快(用AES)了。

具体流程

第一次访问(握手):
1. 浏览器:你好,我想访问 example.com
2. 服务器:你好,这是我的证书(包含RSA公钥)
3. 浏览器:(验证证书)OK,我生成一个随机数作为会话密钥
4. 浏览器:用服务器的RSA公钥加密会话密钥,发给服务器
5. 服务器:用RSA私钥解密,得到会话密钥

后续通信:
6. 浏览器 & 服务器:用会话密钥 + AES算法,加密/解密所有数据

证书:怎么证明"你是你"?

等等!上面的方案有个漏洞。

如果有个坏人假冒服务器呢?

比如他拦截了你的公钥请求,然后把自己的公钥寄给你——这叫"中间人攻击"。

你以为是跟朋友通信,实际上是跟坏人通信!

怎么办?

你需要证明身份

HTTPS引入了证书机制,就像身份证一样。

证书是怎么工作的?

  1. 网站去CA机构(Certificate Authority,证书颁发机构)申请一个证书
  2. CA机构核实网站身份,用自己的私钥给证书"签名"
  3. 证书里包含:网站域名、网站的RSA公钥、CA的签名
  4. 浏览器访问网站时,先验证证书

浏览器内置了一些可信的CA机构(比如DigiCert、Let's Encrypt、GlobalSign),就像公安局一样。

如果证书是假的,或者域名对不上——浏览器会显示"不安全"。

证书链:验证过程

你可能会想:CA机构 themselves 谁来证明?

这就是证书链

  • 根证书:浏览器内置,最可信
  • 中间证书:CA机构颁发
  • 服务器证书:你申请的

浏览器会一级一级验证上去。


TLS握手:到底发生了什么?

你访问一个HTTPS网站时,背后会发生这些事情:

1. 浏览器:你好,我想访问 example.com(用的是HTTPS)
2. 服务器:你好,这是我的证书(包含RSA公钥)
3. 浏览器:(验证证书)OK,这是合法的网站
4. 浏览器:我生成一个随机数(会话密钥),用你的公钥加密后发给你
5. 服务器:用我的私钥解密,得到会话密钥
6. 浏览器 & 服务器:好的,用这个会话密钥 + AES算法,开始加密通信

这就是著名的TLS握手(HTTPS = HTTP + TLS)。

握手完成之后,后续的数据传输就都是加密的、很快的。


SSL/TLS/HTTPS到底是什么关系?

  • SSL(Secure Sockets Layer):最早的安全协议,1994年由Netscape发明
  • TLS(Transport Layer Security):SSL的升级版,1999年发布,SSL的继任者
  • HTTPS:HTTP + TLS,简单说就是"穿上TLS盔甲的HTTP"

现在基本都用TLS,但很多人还是习惯叫HTTPS为SSL。


HTTPS有什么缺点?

虽然HTTPS很好,但不是完美的。

1. 慢一点

TLS握手需要时间,首次访问会慢一些。

但现在有TLS 1.3,0-RTT握手,几乎没影响。

2. 要花钱

HTTPS需要证书,以前很贵。

现在有Let's Encrypt等免费CA,证书基本不要钱。

3. 不是100%安全

HTTPS只加密传输过程,但如果:

  • 服务器被黑客攻破
  • 用户中了木马
  • 用了弱密码

——一样完蛋。

HTTPS不是万能的,但它让"偷看"变得几乎不可能。


总结:HTTP vs HTTPS

\ HTTP HTTPS
传输方式 明文 加密
加密算法 RSA + AES
速度 稍慢
安全性 裸奔 穿盔甲
证书 不需要 需要CA颁发
端口 80 443
用途 不涉及隐私的页面 登录、支付、敏感操作

写在最后

现在你应该懂了:

  • HTTP = 明信片,谁都能看
  • HTTPS = 带锁的盒子,只有你能开
  • RSA = 一把锁配两把钥匙,用来安全地送"钥匙"
  • AES = 用"钥匙"快速加密大量数据
  • 证书 = 身份证,证明"服务器是服务器"

下次看到浏览器显示"不安全",就别在上边输密码了。

看到"安全",也不是100%安全——但至少,不会"裸奔"了。

【鸿蒙开发】HMRouter一款和好用的管理路由三方工具

HMRouter

HMRouter作为应用内页面跳转场景解决方案,为开发者提供了功能完备、高效易用的路由框架。

HMRouter底层对系统Navigation进行封装,集成了NavigationNavDestinationNavPathStack的系统能力,提供了可复用的路由拦截、页面生命周期、自定义转场动画,并且在跳转传参、额外的生命周期、服务型路由方面对系统能力进行了扩展,同时开发者可以高效的将历史代码中的Navigation组件接入到HMRouter框架中。

目的是让开发者在开发过程中减少模板代码,降低拦截器、自定义转场动画、组件感知页面生命周期等高频开发场景的实现复杂度,帮助开发者更好的实现路由与业务模块间的解耦。

特性

  • 基于注解声明路由信息(普通页面、Dialog页面、单例页面)
  • 注解参数支持使用字符串常量定义
  • 页面路径支持正则匹配
  • 支持在Har、Hsp、Hap中使用
  • 支持Navigation路由栈嵌套
  • 支持服务型路由
  • 跳转时支持标准URL解析
  • 支持路由拦截器(包含全局拦截、单页面拦截、跳转时一次性拦截)
  • 支持生命周期回调(包含全局生命周期、单页面生命周期、NavBar生命周期)
  • 内置转场动画(普通页面、Dialog),支持交互式转场动画,同时支持配置某个页面的转场动画、跳转时的一次性动画
  • 提供更多高阶转场动画,包括一镜到底等(需依赖@hadss/hmrouter-transitions
  • 支持配置自定义页面模版,可以更灵活的生成页面文件
  • 支持混淆白名单自动配置
  • 支持与系统Navigation/NavDestination组件混用

gitee链接:HMRouter: 实现HMRouter的基本功能

使用

1. 安装依赖

使用 ohpm 安装
# 安装路由框架核心库
ohpm install @hadss/hmrouter

# 如需高级转场动画,安装转场动画库
ohpm install @hadss/hmrouter-transitions

2. 配置编译插件

依赖配置

修改工程根目录下的hvigor/hvigor-config.json 文件,加入路由编译插件

{
  "dependencies": {
    "@hadss/hmrouter-plugin": "latest"  // 使用npm仓版本号
  },
}
插件配置

修改工程根目录下的hvigorfile.ts,使用路由编译插件

import { appTasks } from '@ohos/hvigor-ohos-plugin';
import { appPlugin } from "@hadss/hmrouter-plugin";

export default {
  system: appTasks,
  plugins: [appPlugin({ ignoreModuleNames: [ /** 不需要扫描的模块 **/ ] })]
};

3. 初始化路由框架

在EntryAbility中初始化路由框架

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 日志开启需在init之前调用,否则会丢失初始化日志
    HMRouterMgr.openLog("INFO")
    HMRouterMgr.init({
      context: this.context
    })
  }
}

这里必须进行初始化

4. 定义路由入口

HMRouter 依赖系统 Navigation 能力,所以必须在页面中定义一个 HMNavigation 容器,并设置相关参数,具体代码如下:

@Entry
@Component
export struct Index {
  modifier: MyNavModifier = new MyNavModifier();

  build() {
    Column(){
      // 使用HMNavigation容器
      HMNavigation({
        navigationId: 'MainNavigation', homePageUrl: 'HomePage',
        options: {
          standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,// 标准页面的全局转场动画
          dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR, //  对话框全局转场动画
          modifier: this.modifier // Navigation组件的属性修改器
        }
      })
    }
    .height('100%')
    .width('100%')
  }
}

class MyNavModifier extends AttributeUpdater<NavigationAttribute> {
  // AttributeUpdater首次设置给组件时提供的样式
  initializeModifier(instance: NavigationAttribute): void {
    instance.hideNavBar(true); // 隐藏导航栏
  }
}

这里默认不变即可,想实现其他功能可参考官网的属性配置

5. 页面定义与路由跳转

使用 @HMRouter 标签定义页面并跳转

定义HomePage

import { HMRouter, HMRouterMgr } from "@hadss/hmrouter"

const PAGE_URL: string = 'HomePage'

@HMRouter({ pageUrl: PAGE_URL })
@Component
export struct HomePage {
  build() {
    Column() {
      Text('HomePage')
      Button('Push')
        .onClick(() => {
          HMRouterMgr.push({ pageUrl: 'PageB',param:`123456` })
        })
    }
  }
}

这里官网是通过HMRouterMgr.push({ pageUrl: 'PageB?msg=abcdef' })传递参数的,

自测试获取到的值为undefined,强烈避坑

Page页面,并且接收参数

import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'
import { JSON } from '@kit.ArkTS'

@HMRouter({ pageUrl: 'PageB' })
@Component
export struct PageB {
  @State param: Object | null = HMRouterMgr.getCurrentParam()

  build() {
    Column() {
      Text('PageB')
      Text(`${JSON.stringify(this.param)}`)
      Button('popWithResult')
      .onClick(() => {
        HMRouterMgr.pop({ param: { 'resultFrom': 'PageB' } })
      })
    }
  }
}

现在我们已经能实现初步的页面跳转了

PixPin_2026-03-15_11-34-28.gif

6.拦截器的使用

拦截器流程简图

首页(HomePage) → 点击跳转 PageB → 拦截器检查 isLogin
                                      │
                    ┌─────────────────┼─────────────────┐
                    ▼                 │                 ▼
              未登录 → 去登录页(LoginPage)           已登录 → 进入 PageB
                    │
                    ▼
              点击登录 → 写 isLogin=true → 再跳 PageB → 进入 PageB

代码实现

EntryAbility中设置全局登录状态

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 初始化全局登录状态,供拦截器与登录页使用
    AppStorage.setOrCreate('isLogin', false);
  }
}

完善Index,添加基本功能

import { HMDefaultGlobalAnimator, HMNavigation } from '@hadss/hmrouter';
import { AttributeUpdater } from '@kit.ArkUI';
import { HomePage } from '../view/home/HomePage';
import { MyPage } from '../view/my/MyPage';

interface TabItem {
  text: string
  normal: ResourceStr
  active: ResourceStr
}


@Entry
@Component
export struct Index {
  modifier: MyNavModifier = new MyNavModifier();
  @State currentIndex: number = 0
  @Provide isLogin: boolean = false
  list: TabItem[] = [
    { text: '首页', normal: $r('app.media.startIcon'), active: $r('app.media.startIcon') },
    { text: '商城', normal: $r('app.media.startIcon'), active: $r('app.media.startIcon') },
    { text: '购物 ', normal: $r('app.media.startIcon'), active: $r('app.media.startIcon') },
    { text: '活动', normal: $r('app.media.startIcon'), active: $r('app.media.startIcon') },
    { text: '我的', normal: $r('app.media.startIcon'), active: $r('app.media.startIcon') },
  ]

  build() {
    Column() {
      HMNavigation({
        navigationId: 'MainNavigation',
        homePageUrl: 'Index',
        options: {
          standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,
          dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR,
          modifier: this.modifier
        }
      }) {
        Tabs({ barPosition: BarPosition.End }) {
          ForEach(this.list, (item: TabItem, index: number) => {
            TabContent() {
              if (this.currentIndex === 0) {
                HomePage()
              } else if (this.currentIndex === 1) {
              } else if (this.currentIndex === 2) {
              } else if (this.currentIndex === 3) {
              } else if (this.currentIndex === 4) {
                MyPage()
              }
            }
            .tabBar(this.TabItemBuilder(item, index))
          })
        }
        .onChange((index: number) => {
          this.currentIndex = index
        })
        .barMode(BarMode.Fixed)
        .scrollable(false)
        .animationMode(AnimationMode.NO_ANIMATION)
      }
    }
    .height('100%')
    .width('100%')
  }

  @Builder
  TabItemBuilder(item: TabItem, index: number) {
    Column() {
      Image(this.currentIndex === index ? item.active : item.normal)
        .fillColor(this.currentIndex === index ? '#ffef9608' : Color.Gray)
        .width(20)
        .aspectRatio(1)
      Text(item.text)
        .fontColor(this.currentIndex === index ? '#ffef9608' : Color.Gray)
        .fontSize(14)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceEvenly)
    .height(50)
  }
}

class MyNavModifier extends AttributeUpdater<NavigationAttribute> {
  initializeModifier(instance: NavigationAttribute): void {
    instance.mode(NavigationMode.Stack);
    //instance.hideNavBar(true) //这句会导致底部导航不展示
    instance.navBarWidth('100%')
    instance.hideTitleBar(true)
    instance.hideToolBar(true)
  }
}

优化LoginPage

import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'

const PAGE_URL: string = 'LoginPage'
const KEY_IS_LOGIN = 'isLogin'

@HMRouter({ pageUrl: PAGE_URL })
@Component
export struct LoginPage {
  @Consume isLogin: boolean

  build() {
    Column() {
      Text('登录')
        .fontSize(24)
        .fontWeight(FontWeight.Medium)
        .margin({ bottom: 32 })
      Text('登录后即可访问 PageB')
        .fontSize(14)
        .fontColor('#666')
        .margin({ bottom: 48 })
      Button('登录并跳转 PageB')
        .width('80%')
        .fontSize(16)
        .onClick(() => {
          // 同步到 AppStorage,拦截器据此放行
          AppStorage.setOrCreate(KEY_IS_LOGIN, true)
          HMRouterMgr.replace({ pageUrl: 'PageB', param: `123456` })
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

创建拦截器

import { HMInterceptor, HMInterceptorAction, HMInterceptorInfo, HMRouterMgr, IHMInterceptor } from "@hadss/hmrouter";

/** 登录状态在 AppStorage 中的 key,与登录页、Index 保持一致 */
const KEY_IS_LOGIN = 'isLogin';

@HMInterceptor({ interceptorName: 'PageInterceptor' })
export class PageInterceptor implements IHMInterceptor {

  handle(info: HMInterceptorInfo): HMInterceptorAction {
    // 从 AppStorage 读取登录状态(与 LoginPage 写入处一致,避免拦截器实例变量与页面不同步)
    const isLogin = AppStorage.get<boolean>(KEY_IS_LOGIN) === true;
    if (isLogin) {
      return HMInterceptorAction.DO_NEXT;
    }
    HMRouterMgr.push({
      pageUrl: 'LoginPage',
      param: { targetUrl: info.targetName },
      skipAllInterceptor: true
    });
    return HMInterceptorAction.DO_REJECT;
  }
}

在PageB添加拦截器注解

import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'
import { JSON } from '@kit.ArkTS'

@HMRouter({
  pageUrl: 'PageB',
  interceptors: ['PageInterceptor'],
})
@Component
export struct PageB {
  @State param: Object | null = HMRouterMgr.getCurrentParam()

  build() {
    Column() {
      Text('PageB')
      Text(`${JSON.stringify(this.param)}`)
      Button('返回首页')
        .onClick(() => {
          HMRouterMgr.pop()
        })
    }
  }
}

成果展示

PixPin_2026-03-15_13-34-23.gif

7.生命周期的使用

使用@HMLifecycle标签定义生命周期处理器,并实现IHMLifecycle接口

import { HMLifecycle, HMLifecycleContext, IHMLifecycle } from "@hadss/hmrouter";

@HMLifecycle({ lifecycleName: 'PageLifecycle' })
export class PageLifecycle implements IHMLifecycle {
  private time: number = 0;

  onShown(ctx: HMLifecycleContext): void {
    this.time = new Date().getTime();
  }

  onHidden(ctx: HMLifecycleContext): void {
    const duration = new Date().getTime() - this.time;
    // 检测进入PageB到离开PageB持续的时间
    console.info(`Page ${ctx.navContext?.pathInfo.name} stay ${duration}`);
  }
}

PageB中添加生命周期注解

import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'
import { JSON } from '@kit.ArkTS'

@HMRouter({
  pageUrl: 'PageB',
  interceptors: ['PageInterceptor'],
  lifecycle: 'PageLifecycle',
})
@Component
export struct PageB {
  @State param: Object | null = HMRouterMgr.getCurrentParam()
  build() {
    Column() {
      Text('PageB')
      Text(`${JSON.stringify(this.param)}`)
      Button('返回首页')
        .onClick(() => {
          HMRouterMgr.pop()
        })
    }
  }
}

成果展示

PixPin_2026-03-15_13-57-28.gif

8.自定义动画

通过@HMAnimator标签定义转场动画,并实现IHMAnimator接口

import { HMAnimator, HMAnimatorHandle, IHMAnimator, OpacityOption, ScaleOption, TranslateOption } from "@hadss/hmrouter"

@HMAnimator({ animatorName: 'liveCommentsAnimator' })
export class liveCommentsAnimator implements IHMAnimator {
  effect(enterHandle: HMAnimatorHandle, exitHandle: HMAnimatorHandle): void {
    // 入场动画
    enterHandle.start((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '100%'
    })
    enterHandle.finish((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '0'
    })
    enterHandle.duration = 500

    // 出场动画
    exitHandle.start((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '0'
    })
    exitHandle.finish((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '100%'
    })
    exitHandle.duration = 500
  }
}

在登录页面添加自定义动画的注解

@HMRouter({ pageUrl: PAGE_URL, animator: 'liveCommentsAnimator' })

成果展示

PixPin_2026-03-15_14-15-34.gif

龙虾(openclaw)本地快速安装及使用教程

今天不废话,直接干货输出,安装龙虾就几个步骤

一、前提

1.1、大模型

首先你要有自己的大模型token,不然你安装了一个空龙虾没啥用

  • KIMI Coding Plan(https://www.kimi.com/code
  • MiniMax Coding Plan(https://platform.minimaxi.com/subscribe/coding-plan
  • GLM Coding Plan (https://bigmodel.cn/glm-coding

1.2、Node.js

没有安装的,可以前往 https://nodejs.org/zh-cn 进行下载安装

  • 版本建议:✅ 版本 >= v22

💡 Windows用户注意:官方推荐在WSL2中运行OpenClaw,能避免很多奇怪问题。WSL2安装指南:docs.microsoft.com/zh-cn/windo…

安装也简单 默认情况下,使用 wsl --install 命令安装的新 Linux 安装将设置为 WSL 2。

image.png

1.3、Git安装

没有的可以自己去官方下载https://git-scm.com/install/windows

image.png

二、开始安装Openclaw

2.1 OpenClaw 安装

  • 需要看官方文档的请:https://openclaw.ai 

2.2、命令

  • 正常跑这个:npm i -g openclaw
  • 超时跑这个:npm config set registry https://registry.npmmirror.com

2.3、验证是否安装

  • 指令:openclaw --version能看到版本号即代表安装成功。 image.png

2.4、运行向导:openclaw onboard

  • 开始配置初始化向导:openclaw onboard --install-daemon

2.4.1、选中yes

image.png

  • 如果出错,直接ctrl+c退出下,重新执行

2.4.2、选择quickstart:

image.png

2.4.3、选择模型提供商(自己选)我的token是MaxminMax,然后一路执行:

image.pngimage.png

image.png

image.png

image.png

2.4.4、先把龙虾工具跑通再去关联聊天工具,所以选择Skip for now

image.png

2.4.5、搜索商先跳过

image.png

2.4.6、选择yes:

image.png

2.4.7、按下空格,选择Skip for now,然后enter

image.png

2.4.8、Google Places API Key ? 用不到,直接NO执行完事:

image.pngimage.pngimage.png

2.4.9、配置 Hooks

官方说明里,Hooks 用来“在某些命令触发时自动执行动作”(例如 /new 时做会话记忆整理)

  • 优先 session-memory,若列表里有Skip for now image.png

恭喜成功了

image.pngimage.png

2.4.11、假如失败(希望你的不会):

image.png 如果上述启动,提示Gateway网关失败, 可能原因

  • 端口18789被占用
  • 权限不足
  • 配置文件错误 记得,确认一下网关是否有安装启动 openclaw gateway install openclaw gateway start 解决方法
# Linux查看端口占用
lsof -i :18789
# Windows查看端口占用
netstat -ano | findstr :18789
# 或者换端口启动
openclaw gateway start --port 18790

3、openclaw常用命令

image.png

4、OpenClaw测试检查

1、检查openclaw服务是否一切启动正常 image.png

5、访问控制界面

# 启动 Web 控制台 openclaw dashboard 或在浏览器中访问:http://127.0.0.1:18789/

image.png

结束

  • 安装完成需要去PC管理界面跟龙虾对话,设置他的名称、角色、注意事项等,想到什么就叫他做什么,并记录下来,下次就能直接用。
  • 其实安装龙虾不难,难的是怎么使用,怎么配置让它动起手来干。今天第一步安装完成了,接下来就是训练龙虾,让龙虾在做事的过程进行记忆存储或者叫驯化进程了。

另外说下,我用龙虾帮忙开发了一个小程序,大家可以去看下,小程序名称:“心问有答”

gh_a14786fed1c9_258.jpg

Flutter Sliver 高级滚动打造 iOS 通讯录体验(十三)

前言

上一篇文章中,我们用 LayoutBuilder 实现了自适应布局——大屏幕并排显示、小屏幕单页显示。但侧边栏和详情面板里都是占位文字,还没有真正的联系人列表。

今天这篇文章基于官方教程的「Scrolling and Slivers」章节,我们将学习 Flutter 中最强大的滚动机制——Sliver。通过它,你可以实现 iOS 通讯录那样的效果:滑动时导航栏自动折叠、搜索框隐藏在顶部、联系人按字母分组排列。


一、什么是 Sliver?

1.1 Sliver vs 普通 Widget

在 Flutter 中,普通 Widget(如 ColumnRowContainer)可以在任何地方使用。而 Sliver 是一种专门为滚动设计的特殊 Widget,只能放在滚动视图(如 CustomScrollView)内部。

你可以把它们想象成两种不同的"积木":普通 Widget 是通用积木,哪里都能摆;Sliver 是专用的"滚轮积木",只能放在滑轨上。

1.2 核心规则

// ✅ 正确:Sliver 放在 CustomScrollView 的 slivers 列表中
CustomScrollView(
  slivers: [
    CupertinoSliverNavigationBar(...),  // ← Sliver 组件
    SliverList(...),                     // ← Sliver 组件
  ],
)

// ❌ 错误:普通 Widget 不能直接放在 slivers 中
CustomScrollView(
  slivers: [
    Text('Hello'),  // ← 报错!Text 不是 Sliver
  ],
)

// ✅ 解决:用 SliverToBoxAdapter 包裹普通 Widget
CustomScrollView(
  slivers: [
    SliverToBoxAdapter(child: Text('Hello')),  // ← 正确!
  ],
)

1.3 常用 Sliver 组件

Sliver 组件 作用
CupertinoSliverNavigationBar iOS 风格的可折叠导航栏
SliverList 滚动列表(类似 ListView 的 Sliver 版本)
SliverGrid 滚动网格
SliverFillRemaining 填满剩余空间(用于放普通 Widget)
SliverToBoxAdapter 将普通 Widget 转为 Sliver

二、构建联系人分组列表

2.1 创建可复用的 _ContactGroupsView

为了让分组列表在小屏幕(作为主页面)和大屏幕(作为侧边栏)中都能复用,我们创建一个私有组件 _ContactGroupsView

更新 lib/screens/contact_groups.dart

import 'package:flutter/cupertino.dart';
import 'package:rolodex/data/contact.dart';
import 'package:rolodex/data/contact_group.dart';
import 'package:rolodex/main.dart';

// ContactGroupsPage:公开的页面组件
// 小屏幕模式下直接显示此页面
class ContactGroupsPage extends StatelessWidget {
  const ContactGroupsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return _ContactGroupsView(
      selectedListId: 0,
      onListSelected: (list) {
        // 下一课(导航)会实现页面跳转
        debugPrint(list.toString());
      },
    );
  }
}

// _ContactGroupsView:私有的可复用视图
// 包含 CustomScrollView + Sliver 实现可折叠导航栏 + 分组列表
class _ContactGroupsView extends StatelessWidget {
  const _ContactGroupsView({
    required this.onListSelected,
    this.selectedListId,
  });

  final int? selectedListId;
  // 回调函数:用户点击某个分组时调用
  final Function(ContactGroup) onListSelected;

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      backgroundColor: CupertinoColors.extraLightBackgroundGray,
      // ===== CustomScrollView =====
      // Sliver 组件的容器,所有 Sliver 都放在 slivers 列表中
      child: CustomScrollView(
        slivers: [
          // ===== 可折叠导航栏 =====
          // 向下滚动时,大标题会折叠成小标题,节省屏幕空间
          const CupertinoSliverNavigationBar(
            largeTitle: Text('Lists'),
          ),

          // ===== 分组列表 =====
          // SliverFillRemaining 填满导航栏下方的所有剩余空间
          // 内部放普通 Widget(非 Sliver)
          SliverFillRemaining(
            // ValueListenableBuilder 监听分组数据变化
            // 当数据更新时自动重绘列表
            child: ValueListenableBuilder<List<ContactGroup>>(
              valueListenable: contactGroupsModel.listsNotifier,
              builder: (context, contactLists, child) {
                // 定义图标:全部联系人用群组图标,其他用双人图标
                const groupIcon = Icon(
                  CupertinoIcons.group, weight: 900, size: 32,
                );
                const pairIcon = Icon(
                  CupertinoIcons.person_2, weight: 900, size: 24,
                );

                // CupertinoListSection.insetGrouped:iOS 风格的圆角分组列表
                return CupertinoListSection.insetGrouped(
                  header: const Text('iPhone'),
                  children: [
                    for (final ContactGroup contactList in contactLists)
                      CupertinoListTile(
                        // 根据分组类型显示不同图标
                        leading: contactList.id == 0 ? groupIcon : pairIcon,
                        title: Text(contactList.label),
                        // 右侧显示联系人数量和箭头
                        trailing: _buildTrailing(
                          contactList.contacts, context,
                        ),
                        // 点击时调用回调
                        onTap: () => onListSelected(contactList),
                      ),
                  ],
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  // 构建列表项右侧的"数量 + 箭头"
  Widget _buildTrailing(List<Contact> contacts, BuildContext context) {
    final TextStyle style = CupertinoTheme.of(context)
        .textTheme.textStyle
        .copyWith(color: CupertinoColors.systemGrey);

    return Row(
      mainAxisSize: MainAxisSize.min, // 让 Row 只占内容需要的宽度
      children: [
        Text(contacts.length.toString(), style: style),
        const Icon(
          CupertinoIcons.forward,
          color: CupertinoColors.systemGrey3,
          size: 18,
        ),
      ],
    );
  }
}

三、构建联系人列表(带搜索和字母索引)

更新 lib/screens/contacts.dart

import 'package:flutter/cupertino.dart';
import 'package:rolodex/data/contact.dart';
import 'package:rolodex/data/contact_group.dart';
import 'package:rolodex/main.dart';

// ContactListsPage:公开的页面组件
class ContactListsPage extends StatelessWidget {
  const ContactListsPage({super.key, required this.listId});

  final int listId;

  @override
  Widget build(BuildContext context) {
    return _ContactListView(listId: listId);
  }
}

// _ContactListView:私有的可复用联系人列表视图
// 包含:可折叠导航栏 + 搜索框 + 按字母分组的联系人列表
class _ContactListView extends StatelessWidget {
  const _ContactListView({
    required this.listId,
    this.automaticallyImplyLeading = true,
  });

  final int listId;
  // 是否自动显示返回按钮(大屏幕侧边栏模式不需要)
  final bool automaticallyImplyLeading;

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      child: ValueListenableBuilder<List<ContactGroup>>(
        valueListenable: contactGroupsModel.listsNotifier,
        builder: (context, contactGroups, child) {
          // 根据 listId 获取对应的分组数据
          final ContactGroup contactList =
              contactGroupsModel.findContactList(listId);
          // 获取按字母分组的联系人 Map
          // 如 {'A': [Alex, Anna], 'B': [Ben], ...}
          final AlphabetizedContactMap contacts =
              contactList.alphabetizedContacts;

          return CustomScrollView(
            slivers: [
              // ===== 可折叠导航栏 + 搜索框 =====
              // .search 构造函数提供集成搜索功能
              // 向下滚动时,大标题折叠,搜索框隐入导航栏
              CupertinoSliverNavigationBar.search(
                largeTitle: Text(contactList.title),
                automaticallyImplyLeading: automaticallyImplyLeading,
                // iOS 风格的搜索输入框
                searchField: const CupertinoSearchTextField(
                  // 右侧麦克风图标
                  suffixIcon: Icon(CupertinoIcons.mic_fill),
                  suffixMode: OverlayVisibilityMode.always,
                ),
              ),

              // ===== 按字母分组的联系人列表 =====
              // SliverList.list 接收普通 Widget 列表
              // 将它们变成可滚动的 Sliver 内容
              SliverList.list(
                children: [
                  const SizedBox(height: 20),
                  // 遍历每个字母分组,生成一个 ContactListSection
                  ...contacts.keys.map(
                    (String initial) => ContactListSection(
                      lastInitial: initial,
                      contacts: contacts[initial]!,
                    ),
                  ),
                ],
              ),
            ],
          );
        },
      ),
    );
  }
}

// ContactListSection:单个字母分组
// 如 "A" 分组下显示 Alex Anderson、Anna Haro 等
class ContactListSection extends StatelessWidget {
  const ContactListSection({
    super.key,
    required this.lastInitial,   // 字母(如 'A')
    required this.contacts,      // 该字母下的联系人列表
  });

  final String lastInitial;
  final List<Contact> contacts;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 0),
      child: Column(
        children: [
          const SizedBox(height: 15),
          // 字母标题(如 "A"、"B")
          Align(
            alignment: AlignmentDirectional.bottomStart,
            child: Text(
              lastInitial,
              style: const TextStyle(
                color: CupertinoColors.systemGrey,
                fontSize: 15,
                fontWeight: FontWeight.w700,
              ),
            ),
          ),
          // 该字母下的联系人列表
          CupertinoListSection(
            backgroundColor: CupertinoColors.systemBackground,
            dividerMargin: 0,
            additionalDividerMargin: 0,
            topMargin: 4,
            children: [
              for (final Contact contact in contacts)
                CupertinoListTile(
                  padding: EdgeInsets.zero,
                  title: Text('${contact.firstName} ${contact.lastName}'),
                ),
            ],
          ),
        ],
      ),
    );
  }
}

四、本节知识点小结

Sliver: Flutter 中专为滚动设计的特殊 Widget。只能放在 CustomScrollView 等滚动视图中。普通 Widget 需要用 SliverToBoxAdapterSliverFillRemaining 包裹后才能在 Sliver 上下文中使用。

CustomScrollView: Sliver 的容器,通过 slivers 列表组合多个 Sliver,实现复杂的滚动效果。

CupertinoSliverNavigationBar: iOS 风格的可折叠导航栏。滚动时大标题自动折叠成小标题。.search 构造函数还能集成搜索框。

SliverList: Sliver 版本的列表。SliverList.list 接收普通 Widget 列表,自动将它们变成可滚动内容。

字母索引: 利用 ContactGroup.alphabetizedContacts 按姓氏首字母分组,每组一个 ContactListSection,实现 iOS 通讯录风格的字母索引。


五、下一步学习

联系人列表已经能滚动、能搜索、按字母分组了!下一课是本系列的最后一课——Stack Based Navigation(基于栈的导航),让小屏幕上点击分组能跳转到联系人列表页面。

我们下篇文章见!

参考资料:Flutter 官方教程 - Scrolling and Slivers

❌