普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月22日掘金 前端

Next.js从入门到实战保姆级教程(第一章):导读——建立 Next.js 的认知框架

2026年4月22日 13:09

《2026年前端开发工程师转型AI Agent开发工程师全指南》一文中,我们系统梳理了前端开发与AI Agent开发在技术栈层面的宏观差异与映射关系。需要指出的是,AI Agent的工程实现仍离不开客户端层的支撑,而Next.js作为当前主流的全栈开发框架,已成为构建AI Agent客户端的首选技术方案。本系列文章将围绕Next.js技术栈,系统讲解AI Agent客户端开发的工程实践,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。(本系列教程适合所有开发者阅读,不限于前端。

前言

在开始教程和编写代码之前,我们需要先建立起对 Next.js 的整体认知。许多开发者在学习新框架时容易陷入一个误区:直接动手实践而缺乏理论指导,导致只能机械地执行步骤,无法真正理解设计背后的原理。

本章的目标就是帮助你构建清晰的认知框架,理解 Next.js 的核心价值、解决的问题域以及学习路径,为后续深入学习奠定基础。


一、Next.js 的定位与核心价值

要理解 Next.js 的设计哲学,需要先了解 Web 应用渲染模式的演进历程。

1. 传统渲染模式的局限性

服务端渲染(SSR)模式:早期的 Web 应用普遍采用服务端渲染,服务器将完整的 HTML 组装后发送给浏览器。这种模式的优势在于:

  • 搜索引擎优化(SEO)友好,爬虫可直接获取内容
  • 首屏加载速度快,无需等待 JavaScript 执行

然而,其缺点同样明显:

  • 页面切换时需要重新请求,用户体验割裂
  • 交互响应延迟,每次操作都需往返服务器

客户端渲染(SPA)模式:随着 React、Vue 等前端框架的兴起,单页应用成为主流。浏览器仅接收基础 HTML 骨架,通过 JavaScript 动态渲染内容。这种模式带来了:

  • 流畅的页面切换体验,无需刷新
  • 丰富的交互能力,响应迅速

但代价是:

  • SEO 效果显著下降,多数搜索引擎难以有效索引 JavaScript 生成的内容
  • 首屏加载时间延长,用户需等待 JavaScript 下载和执行

2. Next.js 的解决方案

Next.js 的核心价值在于融合两种渲染模式的优势

graph LR
    A[纯客户端渲染 SPA] -->|优势| B[交互体验优秀<br/>劣势:SEO 受限]
    C[传统服务端渲染] -->|优势| D[SEO 友好<br/>劣势:体验割裂]
    B --> E[Next.js]
    D --> E
    E --> F[兼顾两者优势<br/>提供现代全栈开发体验]

具体而言,Next.js 实现了:

  • 基于 React 组件化开发,享受现代化前端工程体系
  • 支持服务端预渲染,确保首屏速度和 SEO 效果
  • 提供静态生成能力,利用 CDN 实现极速访问
  • 保留动态渲染灵活性,适应复杂业务场景

这是 Next.js 区别于其他框架的根本特征。


二、核心概念解析

学习 Next.js 需要掌握几个关键概念,这些概念贯穿整个框架的设计:

1. 渲染策略

(1)SSR(Server-Side Rendering,服务端渲染)
每次请求时,服务器实时执行代码并生成 HTML发送到客户端,客户端接收HTML后会下载Javascript进行水合(Hydration),使页面可交互。适用于内容频繁变化的场景,如用户个人主页、实时数据面板等。

(2)SSG(Static Site Generation,静态站点生成)
构建阶段预先生成所有页面的 HTML,部署至 CDN等服务器。适用于内容相对稳定的场景,如博客文章、产品介绍页面。该方式具有极快的访问速度,服务器负载极低。

(3)ISR(Incremental Static Regeneration,增量静态再生)
SSG 的增强版本。页面以静态形式存在,内容更新时在后台异步重新生成,无需重新部署。这是处理"低频更新内容"场景的最优方案。

3. 路由系统

Next.js 13 引入了新型的路由系统:App Router。它基于文件系统约定,取代了传统的 Pages Router。本教程全程采用 App Router,它代表着 Next.js 的未来发展方向。

提示:初次接触时无需深入理解每个概念的实现细节,建立基本印象即可。后续章节将在具体场景中详细展开。


三、技术生态定位

明确 Next.js 与其他技术的关系,有助于做出合理的技术选型决策。

1. Next.js 与 React

Next.js 是基于 React 的全栈框架。React 提供了组件化 UI 开发能力,而 Next.js 在此基础上增加了:

  • 文件系统路由
  • 服务端数据获取机制
  • 构建优化与性能增强
  • 全栈开发能力(API Routes、Server Actions)

可以将 React 视为基础库,Next.js 则是提供完整解决方案的应用框架。

2. Next.js 与 Vue/Nuxt

这属于技术生态选择问题。如果团队熟悉 Vue 技术栈,Nuxt.js 是对应的优选方案;若熟悉 React 生态,Next.js 是自然选择。两者在理念和功能上高度相似。

3. Next.js 与 Remix

两者均为 React 全栈框架,设计理念相近但 API 存在差异:

  • Next.js:生态更成熟,社区资源更丰富,市场占有率更高
  • Remix:在某些方面(如表单处理的标准化程度)具有独特优势

对于大多数项目,两者都是可靠选择。考虑到学习资源和就业机会,Next.js 更具优势。

4. Next.js 与传统后端框架

Next.js 并非后端框架的替代品。虽然提供了 API Routes 和 Server Actions 处理后端逻辑,但其定位是全栈 Web 框架,而非专业后端解决方案。对于复杂的业务逻辑、微服务架构等场景,仍需结合专业的后端框架(如 Express、NestJS 等)。


四、AI 时代的技术契合度

近年来 AI 应用的爆发式增长中,大量产品(如 ChatGPT、Claude、Perplexity)均采用 Next.js 或类似框架构建。这种趋势背后存在技术层面的必然性。

如果你有关注现在的招聘市场,你会发现几乎所有的AI应用相关的公司在前端岗位上都会明确要求掌握Next.js技术栈,而且这种趋势正在慢慢地渗透到其它非AI应用类的岗位上,所以Next.js将会是前端工程师的必备技能。

AI 应用的核心需求与 Next.js 的特性高度匹配:

1. 流式输出支持

大语言模型的文本生成是逐字输出的过程,而非等待完成后一次性返回。Next.js 的流式渲染(Streaming)和 Streaming API 天然支持"边生成边显示"的交互模式。

2. 服务端安全调用

调用 OpenAI 等 AI 服务时,API Key 必须严格保护,绝不能暴露于前端代码。Next.js 的 Server Actions 和 Route Handlers 允许在服务端安全地处理 AI 请求,客户端完全隔离敏感信息。

3. 混合渲染策略

AI 应用通常包含静态内容(产品说明、使用文档)和动态内容(对话历史、实时生成结果)。Next.js 支持在同一项目中为不同页面配置不同的渲染策略,实现性能和灵活性的平衡。

// app/api/chat/route.ts
// 在服务端安全调用 AI API,API Key 不会暴露给客户端

import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'

export async function POST(request: Request) {
  const { messages } = await request.json()

  // process.env.OPENAI_API_KEY 仅在服务端可用
  const result = streamText({
    model: openai('gpt-4o'),
    messages,
  })

  return result.toDataStreamResponse()
}

五、前置知识要求

掌握适当的前置知识能够显著提升学习效率。以下内容按重要程度分级:

1. 必备基础

(1)HTML/CSS
能够理解和编写基本的页面结构与样式。至少掌握 flex 布局等常用 CSS 技术。

(2)JavaScript(ES6+)
重点掌握以下语法特性:

  • 箭头函数
  • 解构赋值
  • 模块导入导出(import/export)
  • 异步处理(async/await、Promise)

这些语法在 Next.js 代码中广泛使用,熟练掌握是顺利学习的前提。

(3)React 基础
这是学习 Next.js 的硬性要求。需要理解:

  • 组件的概念与 JSX 语法
  • 状态管理(useState)
  • 副作用处理(useEffect)
  • Props 传递机制

建议:如果 React 基础尚不扎实,建议先完成 React 官方入门教程,再开始学习 Next.js,可达到事半功倍的效果。

2. 推荐了解

(1)TypeScript
本教程所有代码示例均采用 TypeScript。不需要精通高级类型技巧,但应能理解:

  • 接口定义(interface)
  • 基础泛型
  • 函数参数类型标注

遇到复杂的类型代码时可暂时跳过,不影响主体逻辑的理解。

(2)Node.js 基础
了解 Node.js 的基本概念,能够使用命令行执行脚本即可。


六、教程结构说明

本教程按照"从实践到理论,再到生产应用"的逻辑分为六个部分:

1. 入门篇(第 1-2 章)
解决"快速启动"问题,重点讲解项目结构和文件系统约定,这是 Next.js 与传统 React 项目的核心差异。

2. 核心篇(第 3-4 章)
深入路由系统和数据获取机制,这是 Next.js 的灵魂所在。掌握这两章内容后,即可构建大部分常见应用。

3. 进阶篇(第 5-8 章)
探讨组件模型、样式方案、图像优化、SEO 等主题,这些是将应用从"可用"提升至"优质"的关键要素。

4. 高级篇(第 9-12 章)
涵盖表单处理、错误边界、身份认证、性能优化等生产级应用必须面对的问题。

5. 部署上线篇(第 13 章)
详细介绍从开发环境到生产环境的完整部署流程。

6. 实战篇(第 14 章)
通过一个完整的全栈博客项目,将所有知识点融会贯通。


七、针对不同背景的学习建议

1. 具备 React 经验的前端开发者
可快速浏览入门篇,重点关注核心篇和进阶篇,这些章节阐述了 Next.js 相比普通 React 项目的独特之处。

2. 后端开发者(JavaScript 基础一般)
建议按顺序学习,不要跳章。Next.js 的服务端能力(Server Actions、Route Handlers)会显得较为亲切,但前端思维模式需要时间适应。

3. 初学者(刚接触 React)
严格按顺序学习,每章完成后务必动手实践。"理解概念"与"能够实现"之间存在差距,只有通过实践才能跨越。

4. 技术负责人(评估技术选型)
重点阅读导读篇、核心篇和实战篇,其他章节根据实际需求参考。


八、总结

通过本章的学习,你应该已经建立起对 Next.js 的整体认知:

  • 理解了 Next.js 解决的核心问题及其价值主张
  • 掌握了关键概念的基本含义
  • 明确了自身的学习路径和重点

接下来的章节将进入理论教程与实践环节,从环境配置开始,逐步深入 Next.js 的各个层面。掌握好Next.js的使用,将会为你的职业生涯增加一份可观的竞争力。

下一章《Next.js环境配置与项目初始化》

uni-app 组件库 Wot UI 2.0 发布了,我们带来了这些改变!

2026年4月22日 12:49

大家好,我是不如摸鱼去,好久不见。

进入 2026 以来,大家可以感受到 wot-ui 的迭代速度明显放缓了,我们最近也接到了无数催更的消息。很多人以为我们是在偷懒或者放弃更新了,其实不是,我们是在偷偷写代码然后准备惊艳所有人!(其实是苦苦码了好几个月,来点点赞吧)

接下来看看我们带来了哪些东西吧!

V2

如今已经是 AI 编程时代了,Wot UI 的 slogan 也调整成了「轻量、美观、AI 友好」。我们的目标很直接,就是为大家带来更高效、更易用,也更适合和 AI 协作的 uni-app 开发实践。

主要变化

对比 v1,v2 这一版我们带来了不少更新:

  1. 全新的设计系统。 在 v2 版本,我们基于基础变量、语义变量和组件变量三层 design token 搭建全新的设计系统,使修改组件样式和自定义主题变得随心所欲,同时升级了 UI 视觉体验。
  2. 简化 form 及相关组件 我们简化了 form 相关组件的用法,提供基于 zod 的校验引擎以及支持自定义校验引擎,使用 form-item 替换 cell 作为表单项,优化各表单组件与非表单组件结合 form-item 使用的写法,不再区分「表单组件」和「非表单组件」。
  3. 优化文档体验 重新整理文档结构,统一组件文档结构,将 @wot-ui/vitepress-theme 提取后发布为 vitepress 主题,统一多个 wot-ui 多个库的文档 UI,同时在每个组件文档中增加 css 变量的展示。
  4. 优化 AI 支持
  • 提供 cli 工具 @wot-ui/cli,其内部提供 cli 与 mcp,以优化 wot-ui 组件库的 AI 编程体验。
  • 提供多个 skills 与 LLMs.txt。
  1. 提供 Unocss 预设 提供了 Unocss 预设 @wot-ui/unocss-preset,内置主题变量、语义色、间距、圆角、字重、透明度、描边和排版相关原子类规则,把 wot-ui 的设计 token 和主题变量映射成可直接使用的原子类。

CLI

很多同学在用 AI 写 wot-ui 页面时,问题其实不在于模型不会写,而在于它总爱“凭感觉”写。比如 props 名字记错了、事件猜错了、slot 用法写得像对的,结果一跑就炸。

所以在 v2 里,我们专门提供了 @wot-ui/cli。它不是一个单纯的脚手架工具,而是把 wot-ui v2 的组件知识整理成一套可查询、可校验、可给 AI 调用的能力。

你可以把它理解成一个本地离线知识库,既能给开发者自己查,也能给 AI 客户端通过 MCP 调用。常见能力包括:

  • 查询组件的 props、events、slots 和 CSS 变量
  • 查看组件 demo,减少 AI 瞎猜用法
  • 扫描本地项目,检查不合理或错误的组件写法
  • 通过 MCP 接入 AI 工具链,让 Agent 先查再写

比如以前你让 AI 写一个表单页,它可能先吐出一份“看起来很像 wot-ui”的代码。现在更合理的流程是,先查组件约束,再生成代码,最后再跑一遍检查。这样出来的结果会稳很多。

简单来说,@wot-ui/cli 想解决的不是“怎么让 AI 更会猜”,而是“怎么让 AI 少猜一点”。这也是我们这次把「AI 友好」放进 v2 里的一个重点。

Starter

如果说组件库解决的是“页面怎么写”,那 Starter 解决的就是“项目怎么开”。所以这次我们也没有把它当成一个单纯的 demo 仓库来维护,而是持续把它往一套更适合真实开发、也更适合 AI 协作的 uni-app 起手方案去打磨。

1.5 之后,Starter 先补上了 skills,开始把项目里常用的开发约定、页面结构和组件使用方式整理出来,让 AI 在这个模板里写代码时不再完全靠猜。到了 2.0,Starter 进一步完成了对 wot-ui v2 的适配,示例、主题能力、反馈组件文档以及整体开发链路也一起升级。

你可以把现在的 Starter 理解成:它不只是“集成了 wot-ui 的模板”,而是一个默认就站在 v2 体系上的起点。新项目拉下来之后,从主题定制、页面组织到后续和 AI 配合开发,整个体验都会比 1.x 时代更顺手一些。

CSS 插件

再往下一层看,组件和模板之外,样式这一层我们也往前推了一步,提供了 @wot-ui/unocss-preset。它本质上是一个基于 UnoCSS 的预设,用来把 wot-ui v2 的设计 token 直接映射成可用的原子类。

这件事的价值在于,你在写页面时不需要一边翻设计变量、一边手写一堆样式映射了。像颜色、间距、圆角、字重、排版这些能力,现在都可以直接通过统一的 wot- 前缀类名来组织,主题切换时也能更自然地跟着整套 token 体系走。对于喜欢原子化 CSS 的同学来说,这一层会让 wot-ui v2 真正从“组件好用”变成“整套样式开发都更顺手”。

VSCode 插件

如果说前面这些更多是在补工具链和工程体验,那再落回到日常写代码,我们也补上了 VS Code 插件这一层,也就是 VS Code 插件 wot-ui-intellisense

这个插件主要解决的是写页面时那些很碎、但又很烦的事情。比如组件名记不全、属性名老要回头翻文档、事件到底叫什么总要试一下。现在在 .vue.html 文件里,输入 <wd-、空格、:@ 这些常见场景时,都可以直接拿到补全提示。

除了补全之外,它还支持组件、属性、事件的悬停文档展示,以及一部分属性值校验和错误诊断。也就是说,很多以前要切出去查文档、或者运行后才发现的问题,现在在编辑器里就能先拦一层。

如果说 CLI 更像是给 AI 和工程化链路准备的,那 VS Code 插件就是给开发者日常写代码准备的。一个负责让模型少猜,一个负责让人少翻文档,配合起来,整个 wot-ui v2 的开发体验就会完整很多。

最后

回过头来看,这次 wot-ui v2 对我们来说并不只是一次常规升级。

它一边在补齐设计系统、表单体系、文档体验这些基础能力,一边也在认真回应这两年越来越明显的变化:大家写代码的方式,确实已经和以前不太一样了。

所以你会看到,这一版里不只有组件本身,也有 Starter、CLI、UnoCSS 预设、VS Code 插件这些围绕开发体验的配套。我们想做的,不只是一个“能用”的组件库,而是一套更顺手、更现代,也更适合和 AI 一起协作的 uni-app 开发方案。

v2 还有很多东西会在后面陆续展开,这篇文章先带大家看一个整体。如果你也在关注 wot-ui v2,或者也在想组件库怎么更好地拥抱 AI 编程,欢迎继续关注我们后面的更新。

参考资料

独立开发者主流技术栈(2026最新)

2026年4月22日 11:18

一般情况独立开发者的技术栈核心追求:全栈统一、开发高效、部署简单、成本极低、生态完善,以下按Web、移动端、桌面端、数据库、运维/工具、AI辅助六大维度,整理当前最主流、最实用的技术选型(含热门组合与单类选项)。

一、Web全栈(最主流,SaaS/工具/网站首选)

1. 前端(React系,2026绝对主流)

  • 核心框架Next.js 15(全栈React,App Router+Server Components,一人搞定前后端)
  • 备选框架:Nuxt 3(Vue全栈,上手快)、Remix、SvelteKit
  • 样式方案Tailwind CSS + shadcn/ui(无样式组件+自由定制,开发最快)
  • 备选UI:Ant Design、Material Design、DaisyUI、Chakra UI
  • 状态管理:Zustand、Jotai、Redux Toolkit、Pinia(Vue)
  • 数据请求:TanStack Query(React Query)、SWR、Axios
  • 表单/验证:React Hook Form + Zod、Formik
  • 语言TypeScript(必选,类型安全,减少bug)

2. 后端(全栈JS/Python为主,轻量优先)

  • Node.js生态(最主流)
    • 框架:Express、NestJS(企业级)、Hono(轻量Edge)
    • 全栈:Next.js API Routes/Edge Functions(无需单独后端)
  • Python生态(AI/数据/快速原型)
    • 框架:FastAPI(高性能API)、Flask(极简)、Django(全功能)
  • 备选:Go(Gin,高性能)、Rust(Axum,安全高效)
  • ORMPrisma(全数据库支持,生态最好)、Drizzle(轻量Serverless)

二、移动端(跨平台优先,减少学习成本)

  • 跨平台首选Flutter(Dart,性能接近原生,一套代码双端)
  • Web开发者首选React Native(React语法,复用Web技能)
  • 轻量/小程序转App:UniApp(Vue语法,支持多端+小程序)、Taro
  • 原生(性能极致):Android(Kotlin+Jetpack Compose)、iOS(Swift+SwiftUI)

三、桌面端(跨平台,Web技术复用)

  • 主流Electron(React/Vue+Node,成熟稳定,如VS Code)
  • 新锐轻量Tauri(Rust后端,体积小、性能优)
  • 备选:Qt(C++,跨平台原生)、WPF(Windows原生)

四、数据库(免费+托管优先,减少运维)

1. 关系型(主流)

  • 托管首选Supabase(PostgreSQL,免费500MB,自带认证/存储/实时)
  • 备选托管:Neon、PlanetScale(Serverless MySQL)、Turso(SQLite)
  • 自建:PostgreSQL、MySQL(经典稳定)

2. 非关系型

  • 文档型:MongoDB(托管MongoDB Atlas)
  • 缓存/实时:Redis(托管Upstash)
  • 向量数据库(AI):Milvus、Pinecone、Chroma

五、运维/部署/工具(零成本+自动化)

  • 部署(免费额度足)
    • Web:Vercel(Next.js最佳搭档,一键部署)、Cloudflare Pages
    • Serverless:Cloudflare Workers(免费10万次/天)、Vercel Edge Functions
  • 认证:Supabase Auth、NextAuth.js、Better Auth、Clerk
  • 支付(SaaS必备):Stripe(全球)、PayPal、微信/支付宝(国内)
  • 邮件:Resend(免费3000封/月)、Nodemailer
  • 存储:Cloudflare R2、AWS S3、Supabase Storage
  • 监控/分析:Sentry(错误)、Posthog、Umami、Plausible(用户分析)
  • CI/CD:GitHub Actions(免费)
  • 开发工具:VS Code、Git、Figma(设计)、Postman(API测试)

六、AI辅助(2026必备,效率翻倍)

  • 代码生成:GitHub Copilot、Cursor、Claude Code、Vercel v0(前端UI)
  • AI工具链:LangChain、LlamaIndex(大模型应用)、OpenAI/Anthropic API
  • 设计/素材:Midjourney、DALL·E 3(图片)、Runway(视频)

七、2026独立开发者「黄金技术栈组合」(直接抄作业)

  1. SaaS/Web应用(最强) Next.js 15 + TypeScript + Tailwind + shadcn/ui + Zustand + Supabase + Vercel + Stripe
  2. Vue生态(易上手) Nuxt 3 + Tailwind + Supabase + Prisma + Pinia + Vercel
  3. AI应用 Next.js + FastAPI(Python) + Supabase + Pinecone(向量) + OpenAI API
  4. 移动App Flutter + Supabase + Riverpod(状态)

八、选型核心原则(独立开发必看)

  1. 全栈统一:优先JS/TS(前后端同语言),减少切换成本
  2. 托管优先:不用自建服务器,用Supabase/Vercel等BaaS,零运维
  3. 免费起步:所有工具选有 generous 免费额度的,验证PMF再付费
  4. 生态成熟:选文档全、社区大、坑少的技术,独立开发没时间踩坑
  5. AI赋能:全程用AI工具,代码/设计/文案全流程提效

自定义属性:从html到react

作者 pancakenut
2026年4月22日 11:05
在现代前端架构(尤其是像 Radix UI 和 Tailwind 这种组合)中, HTML 属性(Attributes)就是连接逻辑和视觉的唯一纽带。
  • JS (JavaScript) :负责决定 什么时候 改属性。
  • HTML (DOM) :负责 存储 这些属性。
  • CSS (Tailwind) :负责根据属性的数值, 实时变换 演员的衣服和动作(动画)。

想象我们要实现:点击按钮,方块变红并旋转。

<!-- 方块默认状态是 data-active="false" -->
<div id="box" data-active="false"></div>
<button id="btn">切换状态</button>
#box {
  width: 100px;
  height: 100px;
  background: blue;
  transition: all 0.5s; /* 开启平滑动画 */
}
/* 关键:当属性变为 true 时,剧本要求它变色并旋转 */
#box[data-active="true"] {
  background: red;
  transformrotate(45deg);
}
const box = document.getElementById('box');
const btn = document.getElementById('btn');

btn.onclick = () => {
  // 导演只做一件事:把属性在 true 和 false 之间切换
  const currentState = box.getAttribute('data-active');
  box.setAttribute('data-active', currentState === 'true' ? 'false' : 'true');
};

image.png

  1. JS 不直接操作样式 :JS代码里没有写 box.style.color = 'red' 。它只是像拨动开关一样,改了一下 data-active 这个属性。
  2. HTML 存储状态 :此时,如果你去检查网页元素,你会看到 HTML 标签在<div data-active="true"> <div data-active="false"> 之间跳变。HTML 成了状态的“记录本”。
  3. CSS 自动响应 :CSS 就像一个潜伏的哨兵,它一直盯着 [data-active="true"] 这个信号。只要信号一现,它立刻根据“剧本”让方块变红、旋转。
在 Web 开发中,任何“自定义属性”最终都必须通过某种方式与“原生能力”接轨,否则它就是一段死代码。所有精美的 UI 框架(React, Vue, Tailwind)本质上都是一套“翻译系统”。它们存在的唯一目的,就是帮你把那些符合人类逻辑的自定义属性,高效、准确地转化成浏览器真正听得懂的原生属性、关联到浏览器真正认识的原生属性和事件上。

我们可以把这种关联关系归纳为以下三种模式:

模式 1:直接关联(翻译模式)

场景 :你设计了一个 MyInput 组件,想提供一个更简单的 onTextChange 属性,直接返回字符串。

// 1. 自定义属性:onTextChange
function MyInput({ onTextChange }) {
  return (
    <input 
      type="text" 
      // 2. 关联点:关联到原生的 onChange
      onChange={(e) => {
        // 3. 翻译过程:把复杂的原生 Event 对象,翻译成简单的字符串
        const text = e.target.value;
        onTextChange(text)
      }}
    />
  );
}

// 使用时:
<MyInput onTextChange={(val) => console.log("输入了:", val)} />
  • 本质 : onTextChange 是虚构的,它只是原生 onChange 的一个 过滤器 。

模式 2:样式关联(信号模式)

场景 :你设计了一个 Avatar (头像)组件,有一个 shape 属性来控制是圆的还是方的。

// 1. 自定义属性:shape ("circle" | "square")
function Avatar({ shape }) {
  // 2. 关联点:通过属性计算出原生的 className
  const className = shape === 'circle' ? 'rounded-full' : 'rounded-none';

  return (
    <div className={`overflow-hidden ${className}`}>
      <img src="avatar.png" />
    </div>
  );
}

// 使用时:
<Avatar shape="circle" />
  • 本质 : shape="circle" 这个信号,最终被翻译成了原生的 CSS 属性 border-radius: 9999px 。浏览器并不懂什么是 shape ,它只懂 border-radius 。

模式 3:逻辑关联(驱动模式)

场景 :你设计了一个 VideoPlayer 组件,有一个 isPaused 属性来控制视频的播放和暂停。

// 1. 自定义属性:isPaused
function VideoPlayer({ isPaused }) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    // 2. 关联点:关联到浏览器的原生 JavaScript API
    if (isPaused) {
      videoRef.current?.pause(); // 调用浏览器原生暂停方法
    } else {
      videoRef.current?.play();  // 调用浏览器原生播放方法
    }
  }, [isPaused]);

  return <video ref={videoRef} src="movie.mp4" />;
}

// 使用时:
<VideoPlayer isPaused={true} />
  • 本质 : isPaused 属性在 HTML 标签上可能完全不存在,但它驱动了 浏览器底层的功能引擎 (视频解码器)。

总结

image.png 一句话理解: 你定义的每一个 Props(属性),都是在给 React 发送一个“意图” 。React 和你的组件逻辑负责把这个意图“落地”到 HTML 属性、CSS 类名或 JS API 调用上。

Vue3 defineModel 完全不破坏单向数据流!底层原理+实战解析

2026年4月22日 10:52

结论先行:defineModel 不仅没有破坏 Vue3 的单向数据流,反而在简化代码的同时,严格遵循了单向数据流的核心原则。很多开发者产生“破坏”的误解,本质是混淆了“子组件直接修改父组件数据”与“子组件通过约定机制通知父组件更新数据”的区别,而 defineModel 的底层实现,恰恰是对单向数据流的合规封装与语法简化。

要搞懂这个问题,我们需要先明确两个核心前提:Vue3 单向数据流的定义,以及 defineModel 的底层工作机制,再通过对比验证其合规性,同时补充错误示范,清晰区分“合规写法”与“真正破坏数据流的写法”。

一、先明确:Vue3 单向数据流的核心原则

Vue3 单向数据流的核心规则只有两条,也是判断任何组件通信方式是否合规的标准:

  • 数据流向:父组件 → 子组件,数据只能由父组件通过 props 传递给子组件,子组件仅能读取 props 数据,不能直接修改 props 本身(props 是只读的);
  • 更新权限:只有父组件拥有数据的修改权,子组件若需修改父组件传递的数据,必须通过触发父组件的事件(emit),由父组件在事件回调中修改数据,再通过 props 将更新后的数据同步给子组件。

简单来说,单向数据流的核心是“数据只读(子组件)、更新可控(父组件)”,避免数据流向混乱,降低复杂应用的维护成本。这也是 Vue3 组件通信的核心设计理念,defineModel 作为 Vue3.4+ 新增的语法糖,完全遵循这一原则。反之,若子组件直接操作父组件实例、修改父组件数据,则会真正破坏单向数据流。

二、关键解析:defineModel 的底层实现(打破误解的核心)

defineModel 并非新增的“双向数据流”机制,而是 Vue3 提供的语法糖宏,其底层本质是对“props + emit”的自动封装——编译器会在构建阶段,将 defineModel 的代码自动展开为标准的 props 接收和 emit 触发逻辑,完全贴合单向数据流的规则。

很多开发者误以为“子组件能直接修改 defineModel 返回的值,就是修改了父组件数据”,实则是忽略了 defineModel 的编译过程。我们通过“原始写法”与“defineModel 写法”的对比,清晰看其底层逻辑,同时新增错误示范,强化区分:

1. 传统双向绑定写法(手动实现,完全遵循单向数据流)

在 defineModel 出现之前,组件间双向绑定需手动定义 props 和 emit,严格遵循“父传子、子通知父”的流程:

<!-- 父组件 Parent.vue -->
<template>
  <Child 
    :modelValue="count" 
    @update:modelValue="newVal => count = newVal" 
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0) // 父组件拥有数据修改权
</script>

<!-- 子组件 Child.vue -->
<template>
  <button @click="handleClick">count: {{ modelValue }}</button>
</template>

<script setup lang="ts">
// 1. 手动接收父组件传递的 props(数据从父到子)
const props = defineProps({
  modelValue: {
    type: Number,
    required: true
  }
})

// 2. 手动定义 emit,用于通知父组件更新数据
const emit = defineEmits(['update:modelValue'])

// 3. 子组件不直接修改 props,而是触发 emit 通知父组件
const handleClick = () => {
  emit('update:modelValue', props.modelValue + 1)
}
</script>

这种写法完全符合单向数据流:子组件仅读取 props.modelValue,不直接修改;数据更新由父组件在 emit 回调中完成,数据流向清晰可控。

2. defineModel 写法(语法糖,底层与传统写法完全一致)

使用 defineModel 后,代码被大幅简化,但底层逻辑没有任何变化——编译器会自动帮我们生成 props 和 emit 相关代码,本质还是“props + emit”的组合:

<!-- 父组件 Parent.vue(不变) -->
<template>
  <Child v-model="count" /> <!-- v-model 是 :modelValue + @update:modelValue 的语法糖 -->
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
</script>

<!-- 子组件 Child.vue(defineModel 简化写法) -->
<template>
  <button @click="handleClick">count: {{ model.value }}</button>
</template>

<script setup lang="ts">
// 一行代码替代 props + emit 的手动定义
const model = defineModel({
  type: Number,
  required: true
})

const handleClick = () => {
  model.value++ // 看似直接修改,实则触发底层 emit
}
</script>

重点:defineModel 返回的是一个 ref 对象,而非直接指向父组件的 props 数据。当我们修改 model.value 时,并非直接修改父组件的 count,而是触发了底层自动生成的 emit('update:modelValue', 新值),由父组件接收事件后修改自身的 count,再通过 props 将新值同步给子组件的 model.value。

3. 错误示范:真正破坏单向数据流的写法(与合规写法对比)

以下写法直接违背单向数据流原则,属于“子组件直接修改父组件数据”,会导致数据流向混乱、维护困难,与 defineModel 的合规写法形成鲜明对比,开发中需严格规避:

<!-- 父组件 Parent.vue -->
<template>
  <Child :count="count" />
  <div>父组件 count: {{ count }}</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
</script>

<!-- 子组件 Child.vue(错误写法:直接修改父组件数据) -->
<script setup lang="ts">
import { getCurrentInstance } from 'vue'
import type { ComponentInternalInstance } from 'vue'

// 错误1:通过 getCurrentInstance 获取父组件实例,直接修改父组件数据
const instance = getCurrentInstance() as ComponentInternalInstance
const handleClick = () => {
  // 直接修改父组件的 count,跳过 emit 通知,破坏单向数据流
  (instance.parent?.exposed as { count: { value: number } }).count.value++ 
}

// 错误2:直接修改 props(props 只读,TS 会报错,运行时也会失败)
const props = defineProps({
  count: { type: Number, required: true }
})
const wrongHandle = () => {
  props.count++ // ❌ TS 报错:Cannot assign to 'count' because it is a read-only property
}
</script>

关键提醒:上述错误写法的核心问题的是“子组件直接操作父组件数据/实例”,未通过 emit 通知父组件,完全违背“父组件拥有数据修改权”的原则,这才是真正破坏单向数据流的行为。而 defineModel 始终通过 emit 通知父组件更新,从未直接操作父组件数据,两者有本质区别。

核心差异点标注(合规写法 vs 错误写法)

为更清晰区分,以下明确两类写法的核心差异,结合前文代码场景总结,整理为对比表格如下:

对比维度 合规写法(defineModel/传统 props+emit) 错误写法(破坏单向数据流)
数据操作方式(核心) 子组件仅操作本地 ref 对象(defineModel 生成)或触发 emit,不直接触碰父组件数据 子组件通过 getCurrentInstance 获取父组件实例、直接修改 props,直接操作父组件数据
更新通知机制 必须通过 emit 事件通知父组件,由父组件执行数据修改,遵循“子通知、父更新” 跳过 emit 通知,子组件自主修改父组件数据,完全脱离父组件控制
props 操作 子组件仅读取 props,不修改 props(TS 会校验 props 只读) 试图直接修改 props 或通过父组件实例绕开 props 只读限制,违背 Vue 设计规则
数据流向 严格遵循“父→子”单向流向,更新时“子通知→父修改→子同步” 打破流向,子组件可直接修改父组件数据,导致数据流向混乱、难以调试

4. defineModel 的编译展开过程(核心证据)

Vue3 编译器会将 defineModel 代码自动展开为传统的“props + emit + 计算属性”逻辑,其展开后的代码如下(与我们手动编写的传统写法完全一致):

// defineModel 编译前(我们写的代码)
const model = defineModel({ type: Number, required: true })

// 编译后(编译器自动生成的代码)
const props = defineProps({ modelValue: { type: Number, required: true } })
const emit = defineEmits(['update:modelValue'])

// 生成一个 ref 对象,关联 props.modelValue 和 emit
const model = computed({
  get: () => props.modelValue, // 读取父组件传递的 props(数据父→子)
  set: (newVal) => emit('update:modelValue', newVal) // 修改时触发 emit,通知父组件更新
})

从编译结果可以明确:defineModel 本质是对“props 接收 + emit 触发”的封装,没有任何“子组件直接修改父组件数据”的操作,完全遵循单向数据流的核心原则。我们看到的“子组件修改 model.value”,只是语法层面的简化,底层依然是“子组件通知、父组件更新”的合规流程。

三、常见误解拆解(为什么会觉得“破坏”数据流?)

开发者产生误解,主要源于两个常见认知偏差,结合实战场景逐一拆解:

误解1:“子组件能修改 model.value,就是直接修改父组件数据”

核心澄清:model.value 是子组件本地的 ref 对象,并非父组件的 props 本身。

defineModel 生成的 ref 对象,内部维护了一个本地变量(localValue),该变量通过 watchSyncEffect 与父组件传递的 props.modelValue 保持同步——父组件数据更新时,子组件的 model.value 会自动同步;子组件修改 model.value 时,会触发 set 方法,通过 emit 通知父组件更新,而非直接修改父组件数据。

举个直观例子:父组件 count = 0,子组件 model.value 初始值 = 0(同步 props);子组件执行 model.value++ 后,先触发 emit 传递新值 1,父组件接收后将 count 改为 1,再通过 props 将 1 同步给子组件,子组件 model.value 才更新为 1。整个过程中,子组件从未直接操作父组件的 count。

误解2:“defineModel 实现了双向绑定,双向绑定就是破坏单向数据流”

核心澄清:Vue 中的“双向绑定”,本质是“单向数据流 + 事件回调”的语法糖,并非真正的“双向数据流”(如 AngularJS 的双向绑定)。

Vue3 的 v-model(包括 defineModel 配合 v-model 使用),底层始终是“父传子(props)+ 子通知父(emit)”的单向流程,所谓“双向同步”,只是语法层面的简化,让开发者无需手动编写 emit 回调,但其数据流向依然是单向的——父组件掌握数据的最终修改权,子组件仅负责触发更新通知,这与“双向数据流”(父、子组件可随意修改数据)有本质区别。

四、实战验证:defineModel 完全遵循单向数据流的场景

结合 TS 实战场景,进一步验证 defineModel 的合规性,同时补充开发中的关键细节:

场景1:基础双向绑定(单个 v-model)

<!-- 父组件 -->
<template>
  <div>父组件 count: {{ count }}</div>
  <Child v-model="count" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
// 父组件可主动修改数据,子组件仅能通过 emit 通知修改
const resetCount = () => {
  count.value = 0
}
</script>

<!-- 子组件 -->
<script setup lang="ts">
// 显式指定类型,TS 自动校验 props 规则
const model = defineModel<number>({
  required: true,
  validator: (val) => val >= 0 // 子组件可对 props 进行校验,无法修改
})

// 子组件只能通过修改 model.value 触发 emit,无法直接修改父组件 count
const increment = () => {
  model.value++ // 触发 emit('update:modelValue', model.value + 1)
}
</script>

关键细节:子组件中,若直接尝试修改 props(如 props.modelValue++),TS 会直接报错(props 只读);而修改 model.value 时,底层是触发 emit,完全符合单向数据流规则。同时需注意,避免像错误示范那样,通过 getCurrentInstance 直接操作父组件实例。

场景2:多 v-model 绑定(多个数据同步)

Vue3 支持多个 v-model 绑定,defineModel 可通过指定名称适配,底层依然是“props + emit”的封装,同样遵循单向数据流:

<!-- 父组件 -->
<template>
  <Form 
    v-model:name="form.name" 
    v-model:age="form.age" 
  />
</template>

<script setup lang="ts">
import { reactive } from 'vue'
import Form from './Form.vue'

// 父组件拥有所有数据的修改权
const form = reactive({
  name: '',
  age: 18
})
</script>

<!-- 子组件 Form.vue -->
<script setup lang="ts">
// 分别定义两个 model,对应父组件的两个 v-model
const nameModel = defineModel('name', { type: String })
const ageModel = defineModel('age', { type: Number, default: 18 })

// 修改时分别触发对应的 emit 事件
const handleNameChange = (val: string) => {
  nameModel.value = val // 触发 emit('update:name', val)
}

const handleAgeChange = (val: number) => {
  ageModel.value = val // 触发 emit('update:age', val)
}
</script>

说明:多个 v-model 绑定的底层,是生成多个对应的 props(name、age)和 emit 事件(update:name、update:age),每个数据的流向依然是“父→子”,更新依然是“子通知、父修改”,未破坏单向数据流。开发中需注意,即使多 v-model 绑定,也不能让子组件直接修改父组件的 form 对象。

场景3:带修饰符的 v-model(数据转换)

defineModel 支持 v-model 修饰符(如 .trim、.number),可通过解构获取修饰符并进行数据转换,底层依然遵循单向数据流:

<!-- 父组件 -->
<Child v-model.trim="username" />

<!-- 子组件 -->
<script setup lang="ts">
// 解构获取 model 和修饰符
const [model, modifiers] = defineModel({ type: String })

// 基于修饰符处理数据,修改时触发 emit
const handleInput = (e: Event) => {
  let value = (e.target as HTMLInputElement).value
  // 处理 .trim 修饰符
  if (modifiers.trim) {
    value = value.trim()
  }
  model.value = value // 触发 emit,由父组件更新数据
}
</script>

关键:子组件仅负责数据转换和通知,最终的数据更新依然由父组件完成,数据流向始终可控。需注意,数据转换仅在子组件本地完成,不直接修改父组件原始数据,符合单向数据流要求。

五、核心总结(彻底理清逻辑)

  1. 单向数据流的核心是“数据父→子、更新父控制”,defineModel 底层是“props + emit”的语法糖,完全遵循这一原则,没有任何“子组件直接修改父组件数据”的操作;

  2. 误解的核心是“把语法糖的简化写法,当成了底层逻辑”——子组件修改的是 defineModel 生成的本地 ref 对象,而非父组件数据,底层依然是“子通知、父更新”;

  3. 真正破坏单向数据流的行为,是子组件直接操作父组件实例(如通过 getCurrentInstance 修改父组件数据)、直接修改 props 等,这类写法需严格规避,而 defineModel 恰恰避免了这类问题;

  4. defineModel 的价值的是简化代码,减少手动编写 props 和 emit 的冗余操作,同时保留单向数据流的优势,让数据流向清晰、维护成本降低,尤其适配 Vue3+TS 的类型推导,提升开发效率和类型安全性;

  5. 开发中需注意:defineModel 生成的 ref 对象,其修改会触发 emit,若需避免误触发,可通过添加 props 验证、控制修改时机,进一步保障数据更新的可控性;同时,避免过度依赖 getCurrentInstance 等 API 直接操作父组件实例,否则可能真正破坏单向数据流。

综上,defineModel 不仅没有破坏 Vue3 的单向数据流,反而让单向数据流的实现更简洁、更高效,是 Vue3 对组件双向绑定场景的优化升级,而非对核心设计原则的突破。

录音与音频可视化

作者 hmh12345
2026年4月22日 10:39

需求背景

实现效果

现在很多AI智能体应用,会向用户开放自定义音色的功能,以便用户能使用自己的声音生成一个角色。实现自定义音色的前提就是采集用户自己的声音,以供后续大模型生成角色的自定义音色。本文将介绍,怎么在web中实现录音以及音频的可视化。

实现效果如下,类似iphone的语音备忘录:

它具有以下特点:

  • 波形随着音量实时变化
  • 从右向左滚动
  • 播放时可以复现录音波形

技术选型

怎么采集声音

浏览器中录音主要有三种方案:MediaRecorder、Web Audio API、Recorder-Core
1. MediaRecorder API:是浏览器提供的高层封装录音接口 用法如下:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

const recorder = new MediaRecorder(stream);

recorder.ondataavailable = (e) => {
  const blob = e.data; // 音频文件
};

recorder.start();

这是浏览器原生支持的API,简单高效实现录音,但是只能拿到编码后的blob,无法获取原始的振幅数据,也就无从实现实时可视化了。

2. Web Audio API 这是浏览器提供的音频处理引擎,只提供原始的振幅数据,可以实现音频可视化。可是,这套API不做任何封装,需要自行处理buffer 拼接、多通道处理、文件头封装等问题,实现过于复杂,因此不做考虑

3. Recorder-Core 这个库基于Web Audio API,实现了以下功能:

  • 实时回调输出振幅数据 (实现音频实时可视化的关键)
  • WAV编码
  • 生命周期封装 大大简化了音频采集的实现,并且具有良好的浏览器兼容性,在PC + 主流移动浏览器上都能使用,因此,最终选择recorder-core来实现音频采集

怎么实现音频的实时可视化

高频更新的动画,使用DOM实现会频繁触发回流、重绘,会造成显著的性能浪费,而这种场景使用canvas再合适不过了,用一张独立布局的画布,承接频繁更新的动画

因此,我们选择Recorder-Core + Canvas来实现本次需求。

核心流程

录音与采集音频数据

1️⃣ 初始化录音器

function createRecorder() {
    return RecorderCore({
        type: "wav",
        // 采样率
        sampleRate: 44100,
        onProcess(buffers, powerLevel, duration, sampleRate, newBufferIdx) {
            ...
        },
    });
}

recorder = createRecorder();


  • type: "wav" → 输出 wav 文件
  • onProcess: 实时拿到 PCM(振幅) 数据,以供后续绘制图形

2️⃣ 音频数据处理

在这一步中,原始采集到的音频数据在[-32768, 32767]区间,去掉正负值,并转换成[0, 1]区间的值。

const latest = buffers[newBufferIdx] || buffers[buffers.length - 1];

// 将原始 PCM 采样转换成 0 到 1 之间的能量值,供可视化使用。
function calcEnergyFromPCM(pcm: Int16Array): number {
    if (!pcm || pcm.length === 0) return 0;
    let sum = 0;
    for (let i = 0; i < pcm.length; i++) {
        sum += Math.abs(pcm[i]);
    }
    return Math.min(1, sum / pcm.length / 32768);
}

3️⃣ 实时数据采集

  • liveEnergyQueue,用来存实时展示所需的数据
  • energyTimeline,用来存全量数据,播放回放时使用
const energy = calcEnergyFromPCM(latest);

// 实时显示
liveEnergyQueue.push(energy);
// 播放回放
energyTimeline.push(energy);

4️⃣ 录音控制

开始录音

// 开始录音
await recorder.open(
    () => resolve(),
    (msg: string, _isUserNotAllow: boolean) => reject(new Error(msg)),
);

recorder.start();

结束录音

recorder.stop((blob, duration) => {
    wavBlob.value = blob;
});

注意:开始录音会请求麦克风权限,只有https和localhost能获取,其他环境会报错

音频可视化

1️⃣ 核心数据准备

const liveEnergyQueue: number[] = []; // 实时队列
const energyTimeline: number[] = [];  // 全量数据(播放用)
const visualBuffer: number[] = [];    // 当前屏幕显示

2️⃣ 动画驱动

录音、回放都统一使用startVisual实现绘制,内部通过requestAnimationFrame绘制每一帧动画

function startVisual(mode) {
    function draw() {
        ...
        animationId = requestAnimationFrame(draw);
    }
    draw();
}

3️⃣ 滚动窗口

两种模式energy的取值方式不一样,最终都是加入visualBuffer,visualBuffer会维护最大窗口,超出窗口就不再展示

if (mode === "record") {
    energy = liveEnergyQueue.shift();
} else {
    energy = energyTimeline[playIndex++];
}

visualBuffer.push(energy);

if (visualBuffer.length > MAX_COLUMNS) {
    visualBuffer.shift();
}

4️⃣ 绘制柱状图

从右往左绘制柱状图

const startX = width - barWidth;

for (let i = visualBuffer.length - 1, col = 0; i >= 0; i--, col++) {
    const x = startX - col * step;
    if (x + barWidth < 0) break;

    const e = visualBuffer[i];
    const barHeight = Math.min(
        maxBarHeight,
        Math.max(minBarHeight, e * height * VISUAL_HEIGHT_GAIN),
    );
    const y = (height - barHeight) / 2;
    ctx.fillStyle = "#ff3b30";
    ctx.fillRect(x, y, barWidth, barHeight);
}

5️⃣ Canvas高清适配

现代高清显示屏的dpr通常会大于1,也就是说实际的物理像素会比css像素更大,以dpr=2,css像素200x200为例,浏览器实际的物理像素时400x400,也就是会拉伸canvas尺寸,把一个200x200尺寸画布上绘制的图形,渲染在400x400的画布上,图形就容易模糊,因此需要对canvas根据dpr进行整体缩放

const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

【节点】[MatrixConstruction节点]原理解析与实际应用

作者 SmalBox
2026年4月22日 10:31

【Unity Shader Graph 使用与特效实现】专栏-直达

Matrix Construction 节点是 Unity URP Shader Graph 中用于构建矩阵数据的重要工具。在计算机图形学和着色器编程中,矩阵是表示变换、坐标系转换和其他线性运算的基本数学结构。该节点允许着色器开发者从矢量输入灵活地构建不同维度的矩阵,为复杂的图形效果和数学计算提供了基础支持。

在实时渲染中,矩阵运算无处不在。从模型变换到视图变换,从投影变换到法线变换,矩阵都是核心的数学工具。Matrix Construction 节点简化了在着色器中创建和操作矩阵的过程,使得即使是不熟悉底层矩阵数学的开发者也能轻松构建所需的变换矩阵。

描述

Matrix Construction 节点的核心功能是从四个输入矢量 M0M1M2M3 构造方阵。这种设计提供了极大的灵活性,允许开发者根据需要生成矩阵 2x2矩阵 3x3矩阵 4x4 类型的矩阵。每种矩阵类型在图形编程中都有其特定的应用场景和优势。

矩阵构建模式

节点上的下拉选单提供了两种不同的矩阵构建模式,这两种模式对应着矩阵数据的两种不同组织方式:

  • Row:在此模式下,输入矢量从上到下指定矩阵的行。这意味着第一个输入矢量 M0 成为矩阵的第一行,M1 成为第二行,依此类推。这种组织方式符合大多数数学教材中矩阵的书写习惯,即每一行水平排列。
  • Column:在此模式下,输入矢量从左到右指定矩阵的列。这意味着 M0 成为矩阵的第一列,M1 成为第二列,依此类推。在计算机图形学中,列优先的表示方式更为常见,特别是在与 DirectX 和 Unity 的矩阵运算相关的代码中。

维度适配机制

矩阵输出取自输入结构的左上角,这一特性使得节点能够智能地从不同维度的矢量生成不同维度的方阵。这种设计提供了强大的兼容性和灵活性,具体表现在以下几个方面:

  • 当使用矢量 2 类型的值连接到输入 M0M1时,节点会自动从输出 2x2 生成所需的 2x2 矩阵
  • 当提供矢量 3 输入时,可以构建 3x3 矩阵
  • 完整的矢量 4 输入则用于构建 4x4 矩阵

这种智能的维度适配机制使得节点能够处理各种复杂的矩阵构建需求,而无需开发者手动处理维度不匹配的问题。

应用场景

Matrix Construction 节点在着色器开发中有着广泛的应用,主要包括:

  • 自定义变换矩阵的创建
  • 坐标系转换矩阵的构建
  • 颜色空间转换矩阵
  • 纹理变换矩阵
  • 法线变换矩阵
  • 自定义投影矩阵

端口

Matrix Construction 节点提供了一系列输入和输出端口,使开发者能够灵活地连接不同的数据源和目标。

输入端口

名称 方向 类型 描述
M0 输入 Vector 4 第一行或第一列,具体取决于选择的模式
M1 输入 Vector 4 第二行或第二列,具体取决于选择的模式
M2 输入 Vector 4 第三行或第三列,具体取决于选择的模式
M3 输入 Vector 4 第四行或第四列,具体取决于选择的模式

输入端口的设计考虑了最大的灵活性。每个输入端口都接受 Vector 4 类型的值,但实际使用时可以根据需要连接更低维度的矢量。当连接低维度矢量时,未使用的分量会自动填充默认值。

输出端口

名称 方向 类型 描述
4x4 输出 4x4 矩阵 输出为完整的 4x4 矩阵
3x3 输出 3x3 矩阵 输出为 3x3 矩阵,取自 4x4 矩阵的左上角 3x3 部分
2x2 输出 2x2 矩阵 输出为 2x2 矩阵,取自 4x4 矩阵的左上角 2x2 部分

输出端口的多样性使得节点能够同时提供不同维度的矩阵输出,这在处理需要多种矩阵维度的复杂着色器时特别有用。例如,一个着色器可能同时需要 4x4 矩阵进行顶点变换和 3x3 矩阵进行法线变换。

控件

Matrix Construction 节点提供了一个关键的控件选项,用于决定矩阵的构建方式。

名称 类型 选项 描述
下拉选单 Row、Column 选择应如何填充输出矩阵,即决定输入矢量是作为行还是列来构建矩阵

这个下拉选单控件是节点的核心配置选项,它直接影响生成的矩阵结构。选择不同的模式会导致完全不同的矩阵结果,即使输入相同的矢量值。

行模式详解

在行模式下,输入矢量被解释为矩阵的行。这种模式更符合传统的数学表示法,对于从数学公式直接转换到着色器代码特别有用。

行模式的特点:

  • 输入顺序对应矩阵的行顺序
  • M0 成为第一行,M1 成为第二行,以此类推
  • 适合从行向量为主的数学表达式构建矩阵

列模式详解

在列模式下,输入矢量被解释为矩阵的列。这种模式与大多数图形 API 的矩阵存储方式一致,特别是在处理变换矩阵时更为直观。

列模式的特点:

  • 输入顺序对应矩阵的列顺序
  • M0 成为第一列,M1 成为第二列,以此类推
  • 适合从列向量为主的数学表达式构建矩阵
  • 与 Unity 的内置矩阵结构更加兼容

生成的代码示例

理解 Matrix Construction 节点生成的底层代码对于深入掌握其工作原理和进行高级着色器编程至关重要。以下示例代码展示了节点在不同模式下的具体实现。

行模式代码实现

void Unity_MatrixConstruction_Row_float(float4 M0, float4 M1, float4 M2, float3 M3, out float4x4 Out4x4, out float3x3 Out3x3, out float2x2 Out2x2)
{
    Out4x4 = float4x4(M0.x, M0.y, M0.z, M0.w,
                      M1.x, M1.y, M1.z, M1.w,
                      M2.x, M2.y, M2.z, M2.w,
                      M3.x, M3.y, M3.z, M3.w);

    Out3x3 = float3x3(M0.x, M0.y, M0.z,
                      M1.x, M1.y, M1.z,
                      M2.x, M2.y, M2.z);

    Out2x2 = float2x2(M0.x, M0.y,
                      M1.x, M1.y);
}

在行模式的实现中,可以清楚地看到:

  • 4x4 矩阵的构建直接使用四个输入矢量的所有分量
  • 3x3 矩阵取自 4x4 矩阵的左上角 3x3 部分,只使用每个矢量的前三个分量
  • 2x2 矩阵进一步缩减,只使用每个矢量的前两个分量
  • 矩阵元素按行优先的顺序排列

列模式代码实现

void Unity_MatrixConstruction_Column_float(float4 M0, float4 M1, float4 M2, float3 M3, out float4x4 Out4x4, out float3x3 Out3x3, out float2x2 Out2x2)
{
    Out4x4 = float4x4(M0.x, M1.x, M2.x, M3.x,
                      M0.y, M1.y, M2.y, M3.y,
                      M0.z, M1.z, M2.z, M3.z,
                      M0.w, M1.w, M2.w, M3.w);

    Out3x3 = float3x3(M0.x, M1.x, M2.x,
                      M0.y, M1.y, M2.y,
                      M0.z, M1.z, M2.z);

    Out2x2 = float2x2(M0.x, M1.x,
                      M0.y, M1.y);
}

列模式的实现展示了不同的元素排列方式:

  • 4x4 矩阵的每一列由对应输入矢量的分量构成
  • 3x3 矩阵同样取自左上角,但按列优先的顺序组织
  • 2x2 矩阵也是列优先的排列
  • 这种排列方式与 HLSL 和 CG 语言的矩阵存储方式一致

实际应用示例

创建自定义旋转矩阵

使用 Matrix Construction 节点可以轻松创建绕任意轴旋转的矩阵。例如,创建一个绕 Z 轴旋转的 3x3 矩阵:

  • 设置模式为 Row
  • M0: (cos(angle), -sin(angle), 0, 0)
  • M1: (sin(angle), cos(angle), 0, 0)
  • M2: (0, 0, 1, 0)
  • 使用 3x3 输出端口

构建缩放矩阵

创建非均匀缩放矩阵也很简单:

  • 设置模式为 Diagonal(通过适当的矢量配置模拟)
  • M0: (scaleX, 0, 0, 0)
  • M1: (0, scaleY, 0, 0)
  • M2: (0, 0, scaleZ, 0)
  • M3: (0, 0, 0, 1)

颜色转换矩阵

Matrix Construction 节点还可以用于构建颜色空间转换矩阵:

  • 设置模式为 Row
  • M0: (0.299, 0.587, 0.114, 0) // RGB 到亮度的转换系数
  • M1: (-0.14713, -0.28886, 0.436, 0) // RGB 到 Cb 的转换
  • M2: (0.615, -0.51499, -0.10001, 0) // RGB 到 Cr 的转换
  • M3: (0, 0, 0, 1)

最佳实践和注意事项

性能考虑

虽然 Matrix Construction 节点在着色器中使用很方便,但需要注意其性能影响:

  • 在顶点着色器中构建矩阵通常比在片段着色器中更高效
  • 避免在循环内部频繁构建矩阵
  • 考虑预计算静态矩阵并通过 uniform 变量传递

精度问题

矩阵运算可能涉及浮点数精度问题:

  • 对于需要高精度的运算,考虑使用 float 精度而非 half
  • 注意矩阵求逆时的数值稳定性
  • 在构建正交矩阵时确保矢量归一化

兼容性考虑

不同平台对矩阵运算的支持可能有所不同:

  • 测试在移动设备上的性能表现
  • 确保矩阵维度与目标 API 的要求一致
  • 注意不同图形 API 的矩阵存储顺序差异

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

从ethers.js迁移到Viem:我在重构DeFi前端时踩过的那些坑

作者 竹林818
2026年4月22日 10:02

背景

上个月,我接手了一个DeFi借贷平台的前端重构任务。这个项目三年前用ethers.js v5开发,代码里到处都是BigNumber的手动转换、冗长的Provider初始化,还有一堆自定义的类型补丁。最头疼的是,每次添加新链支持,都得手动配置RPC节点和链ID映射,维护成本越来越高。

团队里新来的同事抱怨说:“现在社区新项目都用Viem了,咱们这代码看着像古董。”我查了一下,Viem确实有不少吸引人的地方:更小的包体积、更好的TypeScript支持、内置的多链工具。更重要的是,我们计划集成AA(账户抽象)钱包,Viem对EIP-4337的原生支持是个巨大优势。

于是,我决定用一周时间,把核心的链上交互模块从ethers.js迁移到Viem。本以为就是换个库,改改API调用,结果第一天的进展就让我意识到,这趟水比想象中深。

问题分析

我的迁移策略很直接:先不动UI层,只替换底层的链上交互逻辑。我创建了一个useViemClient的Hook,打算逐步替换项目中几十个用到ethers.providers.Web3Provider的地方。

第一个问题很快就出现了。原来的代码里到处都是这样的模式:

const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()
const contract = new ethers.Contract(address, abi, signer)

看起来很简单,对吧?我照着Viem文档写了第一版:

import { createPublicClient, createWalletClient, custom } from 'viem'
import { mainnet } from 'viem/chains'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: custom(window.ethereum)
})

const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum)
})

但一运行就报错:window.ethereum可能是undefined。在ethers.js里,我们习惯在组件挂载后再初始化Provider,但Viem的Client设计更倾向于提前创建。更麻烦的是,用户切换网络时,ethers.js的Provider会自动更新,而Viem的Client需要手动重建。

我意识到,不能简单地对等替换。Viem的架构理念不同:它把“读取”和“写入”分离成了PublicClientWalletClient,把链配置和传输层解耦。我需要重新思考整个数据流的设计。

核心实现

1. 设计可动态切换的Client工厂

首先,我需要一个能处理动态链切换的Client管理方案。这里有个坑:Viem的Client创建后,链配置是固定的。用户切换网络时,必须创建新的Client实例。

我的解决方案是创建一个工厂函数,根据当前链ID动态生成Client:

import { createPublicClient, createWalletClient, custom, Chain } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

// 链ID到配置的映射
const CHAIN_CONFIGS: Record<number, Chain> = {
  1: mainnet,
  137: polygon,
  42161: arbitrum,
}

export function createClients(chainId: number, ethereum: any) {
  const chain = CHAIN_CONFIGS[chainId]
  
  if (!chain) {
    throw new Error(`Unsupported chainId: ${chainId}`)
  }

  const publicClient = createPublicClient({
    chain,
    transport: custom(ethereum),
  })

  const walletClient = createWalletClient({
    chain,
    transport: custom(ethereum),
  })

  return { publicClient, walletClient }
}

但这样还不够,因为每次切换网络都要重新创建Client,性能开销大。我加了一层缓存:

const clientCache = new Map<string, ReturnType<typeof createClients>>()

export function getCachedClients(chainId: number, ethereum: any) {
  const cacheKey = `${chainId}-${ethereum?.isMetaMask ? 'metamask' : 'generic'}`
  
  if (!clientCache.has(cacheKey)) {
    clientCache.set(cacheKey, createClients(chainId, ethereum))
  }
  
  return clientCache.get(cacheKey)!
}

2. 处理BigNumber和数值转换

在ethers.js里,我们习惯用BigNumber处理大数,然后手动转换。Viem用了bigint原生类型,这本来是好事,但和现有代码的兼容性成了问题。

原来的代码:

import { BigNumber } from 'ethers'
const amount = BigNumber.from('1000000000000000000') // 1 ETH
const formatted = ethers.utils.formatEther(amount)

迁移时,我发现项目里到处是ethers.utils.parseEtherformatEther的调用。Viem的处理方式更统一:

import { parseEther, formatEther } from 'viem'

// 字符串转bigint
const amount = parseEther('1.5') // 1500000000000000000n

// bigint转可读字符串
const readable = formatEther(1500000000000000000n) // '1.5'

但这里有个细节要注意:Viem的parseEther返回的是bigint,不是字符串。如果你需要字符串形式的wei值,得手动转换:

const amountBigInt = parseEther('1.5')
const amountString = amountBigInt.toString() // '1500000000000000000'

我写了一个适配层来平滑迁移:

export function toBigInt(value: string | number | bigint): bigint {
  if (typeof value === 'bigint') return value
  if (typeof value === 'string') {
    // 处理科学计数法
    if (value.includes('e')) {
      return BigInt(Number(value))
    }
    return BigInt(value)
  }
  return BigInt(value)
}

export function fromWei(value: bigint, decimals: number = 18): string {
  const divisor = 10n ** BigInt(decimals)
  const integerPart = value / divisor
  const fractionalPart = value % divisor
  
  if (fractionalPart === 0n) {
    return integerPart.toString()
  }
  
  // 保留足够的小数位
  const fractionStr = fractionalPart.toString().padStart(decimals, '0')
  // 去掉末尾的0
  const trimmed = fractionStr.replace(/0+$/, '')
  
  return `${integerPart}.${trimmed}`
}

3. 合约交互的重构

这是最复杂的部分。原来的合约调用模式是统一的:

const contract = new ethers.Contract(address, abi, signer)
const tx = await contract.deposit(amount, { value: amount })
await tx.wait()

Viem的写法完全不同,而且读写操作要分开处理。我花了半天时间才理清楚:

对于只读操作:

import { readContract } from 'viem/actions'

const result = await publicClient.readContract({
  address: '0x...',
  abi: contractABI,
  functionName: 'balanceOf',
  args: ['0xuser...'],
})

对于写入操作:

import { writeContract } from 'viem/actions'

const hash = await walletClient.writeContract({
  address: '0x...',
  abi: contractABI,
  functionName: 'deposit',
  args: [amount],
  value: amount,
})

// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ hash })

这里有个重要的区别:在ethers.js里,contract.deposit()返回一个Transaction对象,你可以监听它的状态。在Viem里,writeContract直接返回交易哈希,你需要用waitForTransactionReceipt来等待确认。

我创建了一个通用的合约交互Hook:

import { useCallback } from 'react'
import { Address, Hash } from 'viem'

interface ContractCallOptions {
  address: Address
  abi: any[]
  functionName: string
  args?: any[]
  value?: bigint
}

export function useContractCall() {
  const { publicClient, walletClient } = useClients() // 自定义Hook,提供Client
  
  const read = useCallback(async (options: ContractCallOptions) => {
    if (!publicClient) throw new Error('Public client not available')
    
    return publicClient.readContract({
      address: options.address,
      abi: options.abi,
      functionName: options.functionName,
      args: options.args,
    })
  }, [publicClient])
  
  const write = useCallback(async (options: ContractCallOptions): Promise<Hash> => {
    if (!walletClient) throw new Error('Wallet client not available')
    
    return walletClient.writeContract({
      address: options.address,
      abi: options.abi,
      functionName: options.functionName,
      args: options.args,
      value: options.value,
    })
  }, [walletClient])
  
  return { read, write }
}

4. 事件监听的迁移

事件处理是另一个大坑。原来的代码:

contract.on('Deposit', (sender, amount, event) => {
  console.log(`Deposit from ${sender}: ${amount}`)
})

Viem的事件监听更底层,需要自己处理过滤和解析:

import { watchContractEvent } from 'viem/actions'

const unwatch = watchContractEvent(publicClient, {
  address: '0x...',
  abi: contractABI,
  eventName: 'Deposit',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log(`Deposit from ${args.sender}: ${args.amount}`)
    })
  },
})

// 取消监听
unwatch()

这里要注意的是,watchContractEvent返回的是一个取消监听的函数,不像ethers.js那样有contract.removeAllListeners()。而且,Viem的事件参数是强类型的,这是好事,但需要ABI定义准确。

我写了一个包装函数来处理常见的事件监听模式:

export function useContractEvent(
  address: Address,
  abi: any[],
  eventName: string,
  callback: (args: any) => void
) {
  const { publicClient } = useClients()
  
  useEffect(() => {
    if (!publicClient || !address) return
    
    const unwatch = watchContractEvent(publicClient, {
      address,
      abi,
      eventName,
      onLogs: (logs) => {
        logs.forEach((log) => {
          callback(log.args)
        })
      },
    })
    
    return () => unwatch()
  }, [address, abi, eventName, callback, publicClient])
}

完整代码

下面是一个完整的、可运行的React组件示例,展示了如何使用Viem进行基本的链上交互:

import React, { useState, useEffect } from 'react'
import { createPublicClient, createWalletClient, custom, parseEther, formatEther } from 'viem'
import { mainnet } from 'viem/chains'
import { readContract, writeContract, waitForTransactionReceipt } from 'viem/actions'

// 简单的ERC20 ABI片段
const ERC20_ABI = [
  {
    name: 'balanceOf',
    type: 'function',
    inputs: [{ name: 'owner', type: 'address' }],
    outputs: [{ name: '', type: 'uint256' }],
    stateMutability: 'view',
  },
  {
    name: 'transfer',
    type: 'function',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ name: '', type: 'bool' }],
    stateMutability: 'nonpayable',
  },
] as const

function WalletInteraction() {
  const [account, setAccount] = useState<string>('')
  const [balance, setBalance] = useState<bigint>(0n)
  const [publicClient, setPublicClient] = useState<any>(null)
  const [walletClient, setWalletClient] = useState<any>(null)
  
  // 初始化Clients
  useEffect(() => {
    if (window.ethereum) {
      const publicClient = createPublicClient({
        chain: mainnet,
        transport: custom(window.ethereum),
      })
      
      const walletClient = createWalletClient({
        chain: mainnet,
        transport: custom(window.ethereum),
      })
      
      setPublicClient(publicClient)
      setWalletClient(walletClient)
    }
  }, [])
  
  // 连接钱包
  const connectWallet = async () => {
    if (!window.ethereum) {
      alert('请安装MetaMask')
      return
    }
    
    try {
      const [address] = await window.ethereum.request({
        method: 'eth_requestAccounts',
      })
      setAccount(address)
      
      // 查询余额
      if (publicClient) {
        const balance = await publicClient.getBalance({ address })
        setBalance(balance)
      }
    } catch (error) {
      console.error('连接钱包失败:', error)
    }
  }
  
  // 查询ERC20余额
  const queryTokenBalance = async (tokenAddress: string) => {
    if (!publicClient || !account) return
    
    try {
      const balance = await readContract(publicClient, {
        address: tokenAddress as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'balanceOf',
        args: [account],
      })
      
      console.log('Token balance:', balance)
      return balance
    } catch (error) {
      console.error('查询代币余额失败:', error)
    }
  }
  
  // 发送ETH
  const sendETH = async (to: string, amount: string) => {
    if (!walletClient || !account) return
    
    try {
      const hash = await walletClient.sendTransaction({
        account,
        to: to as `0x${string}`,
        value: parseEther(amount),
      })
      
      console.log('交易哈希:', hash)
      
      // 等待确认
      const receipt = await waitForTransactionReceipt(publicClient, { hash })
      console.log('交易确认:', receipt)
      
      return receipt
    } catch (error) {
      console.error('发送交易失败:', error)
    }
  }
  
  // 转账ERC20
  const transferToken = async (tokenAddress: string, to: string, amount: bigint) => {
    if (!walletClient || !account) return
    
    try {
      const hash = await writeContract(walletClient, {
        address: tokenAddress as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'transfer',
        args: [to as `0x${string}`, amount],
        account,
      })
      
      console.log('代币转账哈希:', hash)
      return hash
    } catch (error) {
      console.error('代币转账失败:', error)
    }
  }
  
  return (
    <div>
      <h1>Viem钱包交互示例</h1>
      
      {!account ? (
        <button onClick={connectWallet}>连接钱包</button>
      ) : (
        <div>
          <p>已连接: {account}</p>
          <p>余额: {formatEther(balance)} ETH</p>
          
          <button onClick={() => sendETH('0x...', '0.01')}>
            发送0.01 ETH
          </button>
        </div>
      )}
    </div>
  )
}

export default WalletInteraction

踩坑记录

1. window.ethereum的类型问题

报错: Property 'ethereum' does not exist on type 'Window & typeof globalThis' 解决: 需要扩展Window接口,或者在代码中强制类型转换:

declare global {
  interface Window {
    ethereum?: any
  }
}
// 或者
const ethereum = (window as any).ethereum

2. 链切换时Client不更新

现象: 用户切换网络后,交易还是发到原来的链上 原因: Viem的Client创建后链配置是固定的 解决: 监听chainChanged事件,重新创建Client:

window.ethereum?.on('chainChanged', (chainId: string) => {
  const newChainId = parseInt(chainId, 16)
  // 销毁旧的Client,用新chainId创建新的
})

3. BigInt的序列化问题

报错: Do not know how to serialize a BigInt 现象: 尝试将包含bigint的对象存入Redux或通过props传递时报错 解决: 在序列化前转换为字符串:

const serializable = {
  amount: amount.toString(),
  // 其他字段...
}

4. 事件参数类型推断失败

报错: Property 'args' does not exist on type 'Log' 原因: ABI定义不够精确,TypeScript无法推断出args的类型 解决: 使用as const断言确保ABI类型被正确推断:

const ERC20_ABI = [
  // ... 明确定义每个字段的类型
] as const

小结

这次迁移让我深刻体会到,从ethers.js到Viem不只是换API,更是思维模式的转变。Viem的架构更模块化、类型更安全,但需要适应它的“读写分离”和“链配置不可变”的设计理念。最大的收获是:提前设计好Client的生命周期管理,比边写边改要省事得多。下一步我打算深入研究Viem的账户抽象和多链管理,把这些经验用到新项目中。

前端快速上手保姆级教程day5: 响应式布局

2026年4月22日 09:59

Day5 学习文档:响应式布局(Responsive)

1. 今天要掌握什么

Day5 的目标是:同一套页面在手机、平板、桌面都可读、可点、可用。

  • 理解“响应式”不是缩放,而是布局策略切换
  • 掌握媒体查询 @media 的基本写法
  • 学会移动优先(mobile-first)思路
  • 能处理 3 个高频问题:横向滚动、图片溢出、点击区域过小

2. 什么是响应式(大白话)

大白话:
页面会根据屏幕宽度,自动换一套更合适的排版。

不是把桌面页面硬缩小,而是让布局“变形”:

  • 手机:单列优先,按钮更大,字更清楚
  • 平板:间距更宽,内容更舒展
  • 桌面:可以多列,提高信息密度

3. 核心概念

3.1 视口(viewport)

你看到网页的窗口区域。移动端一定要有:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

否则手机会用“虚拟宽屏”渲染,页面会小得像蚂蚁。

3.2 断点(breakpoint)

断点就是“在哪个宽度开始切换布局”的分界线。

常见练习断点(不是唯一标准):

  • 375:小手机
  • 768:平板
  • 1024:桌面

3.3 媒体查询(media query)

@media (min-width: 768px) {
  /* 宽度 >= 768 时生效 */
}

4. 移动优先(mobile-first)

推荐顺序:

  1. 先写手机默认样式(不加媒体查询)
  2. 再用 min-width 给平板/桌面增强

好处:

  • 代码更清晰,层层增强
  • 小屏体验不会被遗漏

示例:

/* 默认:手机 */
.card-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

/* 平板及以上 */
@media (min-width: 768px) {
  .card-list {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 桌面及以上 */
@media (min-width: 1024px) {
  .card-list {
    grid-template-columns: repeat(3, 1fr);
  }
}

4.1 Grid 布局核心知识(Day5 重点补充)

你在 Day5 页面里已经用了 display: grid,这里把 Grid 的核心概念补齐。

4.1.1 Grid 是什么?

大白话:
Grid 是“切格子”的布局系统,适合做二维布局(行 + 列一起控制)。

你可以把容器想象成棋盘,把内容块放进格子里。

4.1.2 三个最常用属性

.cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}
  • display: grid:开启 Grid 布局
  • grid-template-columns:定义列数与列宽
  • gap:网格项之间的间距

4.1.3 1fr 是什么?

fr = fraction(份数)。
1fr 1fr 1fr 就是“分成 3 份,每份一样宽”。

示例:

  • grid-template-columns: 1fr 1fr -> 两列等宽
  • grid-template-columns: 2fr 1fr -> 左边是右边 2 倍宽

4.1.4 repeat() 为什么常用?

grid-template-columns: repeat(3, 1fr);

等价于:

grid-template-columns: 1fr 1fr 1fr;

repeat() 更简洁,改列数也更方便。

4.1.5 你当前 Day5 的 Grid 切换逻辑

/* 手机 */
.cards { grid-template-columns: 1fr; }

/* 平板 */
@media (min-width: 768px) {
  .cards { grid-template-columns: repeat(2, 1fr); }
}

/* 桌面 */
@media (min-width: 1024px) {
  .cards { grid-template-columns: repeat(3, 1fr); }
}

这段就是标准响应式 Grid:
先单列,再多列,跟屏幕宽度同步增强。

4.1.6 Grid 和 Flex 怎么选?

  • 一维排队(单行/单列内部对齐) -> 优先 Flex
  • 二维切区(行列同时控制) -> 优先 Grid

Day5 常见组合:

  • 顶部导航用 Flex
  • 卡片区用 Grid

4.1.7 Grid 常见坑

  1. 列太多,小屏被挤爆

    • 解法:移动端先单列,断点再加列
  2. 忘记设置 gap,卡片贴太紧

    • 解法:统一用 gap 管理间距
  3. 子项内容太长撑破格子

    • 解法:检查内容换行、图片自适应、最小宽度策略

5. Day5 三大高频问题与解法

5.1 横向滚动条(最常见)

常见原因:

  • 写死宽度(例如 width: 960px
  • 元素 width: 100% 同时大 padding(没用 border-box
  • fixed 元素太宽或位置越界

排查建议:

  • 先检查“谁比屏幕宽”
  • DevTools 切 375 宽度逐个排查容器

5.2 图片撑爆容器

统一加:

img {
  max-width: 100%;
  height: auto;
  display: block;
}

5.3 手机点击困难

建议:

  • 文字不小于 14px(正文建议 16px)
  • 按钮/链接适当增大 padding
  • 相邻可点击元素留足间距

6. 你可以直接套用的响应式骨架

*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  line-height: 1.5;
}

.container {
  width: min(100%, 960px);
  margin: 0 auto;
  padding: 12px;
}

/* 手机:默认单列 */
.layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

/* 平板 */
@media (min-width: 768px) {
  .container {
    padding: 16px;
  }
  .layout {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 桌面 */
@media (min-width: 1024px) {
  .container {
    padding: 20px;
  }
  .layout {
    grid-template-columns: repeat(3, 1fr);
  }
}

7. Day5 自测清单

  • 375 / 768 / 1024 三档可正常阅读
  • 页面无横向滚动条
  • 图片不会撑爆容器
  • 导航和按钮在手机上容易点击
  • 至少 2 个模块实现断点切换

8. 常见误区

  1. 只在桌面看效果

    • 结果:上线后手机体验崩
  2. 断点过多且混乱

    • 建议先用 2~3 个关键断点
  3. 只改字体不改布局

    • 响应式核心是“结构变化”,不是单纯放大缩小
  4. 一上来追求完美设备适配

    • 先保证主流宽度可用,再逐步细化

9. 今日复盘模板(可复制到 README)

## Day5 学习总结(Responsive)

### 我做了什么
- 完成移动优先样式
- 增加平板与桌面断点
- 修复图片溢出和横向滚动问题

### 我学会了什么
- @media 的基本用法
- 断点切换布局的思路
- 响应式排错方法

### 我遇到的问题
- (填写)

### 我如何解决
- (填写)

### 下一步
- Day6:JavaScript DOM 与事件交互

10. 断点写法(基础版)与进阶写法

10.1 断点写法(你当前页面使用的是这种)

思路是“手动指定每个阶段的列数”:

/* 手机默认 */
.cards {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

/* 平板 */
@media (min-width: 768px) {
  .cards {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 桌面 */
@media (min-width: 1024px) {
  .cards {
    grid-template-columns: repeat(3, 1fr);
  }
}

优点:

  • 直观,适合入门
  • 每个断点下的布局可控

缺点:

  • 断点多时维护成本上升
  • 设备尺寸变化时,可能要不断补新断点

10.2 进阶写法:auto-fit + minmax

思路是“声明卡片最小宽度,让列数自动计算”:

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
}

大白话解释:

  • minmax(220px, 1fr):每张卡片最小 220px,空间够就继续拉伸
  • auto-fit:一行能放几张就自动放几张

这样通常可以少写甚至不写卡片区列数断点。

10.3 auto-fit vs auto-fill(快速区分)

  • auto-fit:会“收起空列”,让已有卡片拉伸占满空间(更常用)
  • auto-fill:会保留空列轨道,可能看到“留槽位”的感觉

入门建议:

  • 卡片网格优先用 auto-fit

10.4 实战建议(Day5)

你可以先保留当前断点版(便于理解),再开一个分支或副本改成进阶版对比:

  1. 断点版:训练“响应式思维”
  2. 进阶版:训练“自动适配思维”

两种都掌握,后面做项目会很稳。


附录:完整 index.html 源代码

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Day5 Responsive 实战</title>
    <style>
      *,
      *::before,
      *::after {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
        line-height: 1.5;
        color: #1f2937;
        background: #f5f7fb;
      }

      .container {
        width: min(100%, 960px);
        margin: 0 auto;
        padding: 12px;
      }

      .topbar {
        background: #fff;
        border-bottom: 1px solid #e5e7eb;
      }

      .topbar-inner {
        width: min(100%, 960px);
        margin: 0 auto;
        padding: 10px 12px;
        display: flex;
        flex-wrap: wrap;
        gap: 10px;
        align-items: center;
        justify-content: space-between;
      }

      .topbar nav {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
      }

      .topbar a {
        text-decoration: none;
        color: #1d4ed8;
        padding: 6px 10px;
        border-radius: 8px;
      }

      .topbar a:hover {
        background: #dbeafe;
      }

      .hero,
      .card,
      .tips {
        background: #fff;
        border: 1px solid #e5e7eb;
        border-radius: 10px;
      }

      .hero {
        padding: 16px;
        margin-bottom: 12px;
      }

      .cards {
        display: grid;
        grid-template-columns: 1fr;
        gap: 12px;
      }

      .card {
        padding: 14px;
      }

      .tips {
        margin-top: 12px;
        padding: 14px;
      }

      img {
        max-width: 100%;
        height: auto;
        display: block;
        border-radius: 8px;
      }

      @media (min-width: 768px) {
        .container {
          padding: 16px;
        }

        .cards {
          grid-template-columns: repeat(2, 1fr);
        }
      }

      @media (min-width: 1024px) {
        .container {
          padding: 20px;
        }

        .cards {
          grid-template-columns: repeat(3, 1fr);
        }
      }
    </style>
  </head>
  <body>
    <header class="topbar">
      <div class="topbar-inner">
        <strong>Day5 Responsive 实战</strong>
        <nav>
          <a href="#intro">介绍</a>
          <a href="#cards">卡片区</a>
          <a href="#tips">检查点</a>
        </nav>
      </div>
    </header>

    <main class="container">
      <section class="hero" id="intro">
        <h1>响应式布局练习页</h1>
        <p>默认手机单列,平板双列,桌面三列。请用 DevTools 切换 375 / 768 / 1024 验证布局变化。</p>
      </section>

      <section class="cards" id="cards">
        <article class="card">
          <h2>模块 A</h2>
          <p>移动端单列,保证可读性。</p>
        </article>
        <article class="card">
          <h2>模块 B</h2>
          <p>平板开始并排,提升空间利用率。</p>
        </article>
        <article class="card">
          <h2>模块 C</h2>
          <p>桌面三列展示,提高信息密度。</p>
        </article>
        <article class="card">
          <h2>模块 D</h2>
          <p>继续观察不同断点下卡片数量变化。</p>
        </article>
        <article class="card">
          <h2>模块 E</h2>
          <p>练习时可替换为真实内容模块。</p>
        </article>
        <article class="card">
          <h2>模块 F</h2>
          <p>确保手机下无横向滚动条。</p>
        </article>
      </section>

      <section class="tips" id="tips">
        <h2>自测提示</h2>
        <ul>
          <li>375px:单列,无横向滚动。</li>
          <li>768px:双列,间距舒适。</li>
          <li>1024px:三列,内容不拥挤。</li>
        </ul>
      </section>
    </main>
  </body>
</html>

谷歌浏览器插件Brower-Books: 把整个浏览器变成你的云端书架

作者 Amos_Web
2026年4月22日 09:48

大家好,我是Amos, 一个努力转型中的前端开发工程师,愿我的每篇文章都能让您有所收获。

全文导读

本篇文章介绍了开发这款云端书架的背景核心功能点说明,以及使用到的工具、问题和经验分享,最后附赠谷歌浏览器插件的发布全流程。您可以挑感兴趣的模块进行阅读。

插件体验链接: 谷歌浏览器插件-云端书架

背景说明

作为一名正在努力转型中的开发者,每天要在浏览器里阅读大量的技术博客、官方文档和深度的源码解读。但常常会遇到一个痛点: 看完就忘,想回顾时又找不到当初引发共鸣的那段话,发现书签里面收藏了大量的网站地址,但又忘记了每个网站中到底有哪些吸引自己的点

好,如果您也会有这样的需求,如果您也需要一个简单易用且好用的工具的话,那么我这款插件应该可以满足您的要求。我自己已经使用了一段时间了,更新迭代了一些我自己觉得不好用的问题,目前发布的这个版本至少对我来说是用起来已经比较满意的了(哈哈哈哈 典型的自卖自夸)。


✨ 核心功能点说明

极简操作、极速登录、非必要不打扰

  1. 🖍️ 极简划线批注(极简操作)

    • 在任意网页选中文本即可一键高亮并写下批注,操作丝滑无感,随用随记。
    • 取消选中的文本直接隐藏侧边栏,不需要手动点击关闭按钮,减少操作。 image.png
  2. ☁️ 极速登录同步(快登录)

    • 采用纯邮箱验证码极速登录,告别繁琐密码,您的所有笔记瞬间在多设备间云端同步。
    • 后续可能会考虑接入基于区块链技术的私钥/公钥功能,敬请期待~

    image.png

    image.png

  3. 🎯 精准回忆跳转

    • 专属的云端书架汇总所有记录,点击即可一键跳转回原网页,并在页面中高亮显示收藏的文本。
    • 同样也是支持进行评论的删除,以及按照整个链接进行删除,删除之后就再也找不到了(是的,硬删除)
    • 可以精准定位到当时的划线处,方便进行查看(功能还在开发中,也可能不加了,使用下来感觉没必要,哈哈哈哈~)。

    image.pngimage.png

  4. 🛡️ 智能免打扰(非必要不打扰)

    • 内置全局开关与域名黑名单,在复杂的内部系统或无需划线的网站自动静默,绝不干扰正常工作。
    • 使用过程中发现需要记录自己的想法的网站还是占少数的,所以插件安装过后默认是不开启的状态,需要手动开启,然后同时也支持对域名添加黑名单。 image.png
  5. 💎 轻量化 UI 体验

    • 采用 Apple 级设计规范,界面精致紧凑,为您提供沉浸、轻量且无负担的阅读陪伴(吹牛了~)。

🛠️ 技术栈与实现细节揭秘

遇到的一些坑和解决思路,这里也做个复盘,希望能给大家一些帮助:

1. 划线与 DOM 节点包裹的痛点 (底层跨标签包裹)

原生浏览器的 window.getSelection() 在处理跨多个 DOM 节点(比如选中的片段里包含 <span><a> 标签)时,拆分替换树结构极其痛苦。

  • 解决方案:引入了 mark.js。利用它提供的 acrossElements: true 特性,可以完美越过 DOM 层级的阻碍进行高亮层 (<mark>) 注入。
  • Icon 防重影聚合:当用户在同一段文本追加多次评论时,直接注入会引发图标重复叠放的问题。我通过在每次有新评论时触发本地重新渲染(先 unmark 擦除旧高亮,然后按文本 text 重新 reduce 聚合),毫秒级无感重绘,保证了一段高亮文本永远只跟一个小巧的 💬

2. 状态请求的 SWR 策略 (多端无感同步)

如果用户打开每个网页都要等后台接口返回才能显示高亮,体验会极差。

  • 解决方案:采用了类似 SWR (Stale-while-revalidate) 的缓存策略。
  • 当页面加载时,content.ts 优先向 background.ts 请求 chrome.storage.local 里的本地缓存数据,瞬间点亮网页。
  • 与此同时,后台 Service Worker 默默向 Supabase 发起拉取请求,拿到最新数据后再覆盖回本地缓存。这样不仅极大加快了渲染速度,也完美支持了离线与多设备的实时同步。

3. 极简的 Auth 鉴权设计 (无密码 OTP)

传统插件里做 Google OAuth 登录,需要配一系列的 chrome.identity 和后台 Client ID 校验,调试起来极其折磨。

  • 解决方案:我全面抛弃了第三方 OAuth,依托 Supabase Auth 实现了纯邮箱验证码 (Email OTP) 形式的登录。
  • 用户在 Popup 弹窗里输入邮箱,收到验证码后提交。Background 将持久化的 Session 会话存入扩展本地存储,后续所有与数据库 (highlights 表) 的 API 交互自带 Row Level Security (RLS) 安全校验。

4. 高效的域名黑名单拦截

对于一些不需要划线的页面(如内部后台、画图 Web App)。

  • 解决方案:在 Popup 面板维护一个黑名单数组存入 Storage。在 Content Script 监听 mouseup 选中文本事件的开头,直接根据 isDomainBlacklisted 进行短路 (Short-circuit) 处理。绝不让高亮监听逻辑白白占用页面的性能。

谷歌浏览器插件发布流程

在开发完成后,将插件发布到 Chrome 商店也经历了一番波折,这里将全流程和踩过的坑分享给大家,希望能帮到准备上架插件的朋友:

0. 账号申请

第一步当然是申请一个开发者账号了,这部分就不多说了,首次发布的话还需要支付5美元的费用 ,我这边使用的是Visa的信用卡,不多说,有疑问的话可以留言。

1. 准备上架资产 (Store Assets)

Chrome 商店对上传的图片尺寸有严格要求:

  • 图标 (Icon):必须是 128x128
  • 宣传图 (Promo Tile):必须是 440x280
  • 屏幕截图 (Screenshots):推荐 1280x800640x400

经验分享:为了保证尺寸绝对精准且风格统一(极简风),我直接手写了本地的 HTML Canvas 脚本 (generate_store_assets.html),用代码绘制并导出符合绝对尺寸要求的图片(包括渐变背景和圆角等),省去了反复用设计软件调尺寸的麻烦。

2. 编写隐私政策 (Privacy Policy)

由于插件涉及邮箱登录(Supabase Auth)以及高亮数据的云端保存,Chrome 要求必须提供明确的隐私政策链接。需要准备一份全英文的 PRIVACY_POLICY.md 托管在 Notion上,声明收集了什么数据、用于什么目的、以及承诺不滥用/出售用户隐私

3. 全英文权限说明 (Permissions Justification)

Manifest V3 对权限把控极严。你在 manifest.json 里申请的每一个权限(如 storage, activeTab),在提交商店时都必须要用英文详细解释为什么需要这个权限,以及它如何服务于插件的“单一核心用途”(Single Purpose)。老老实实写清楚具体用途即可,切忌敷衍。

4. 真实被拒惨案:Purple Potassium 违规

提交审核后,第一次竟然被拒了,收到了官方的邮件(Violation reference ID: Purple Potassium),提示:"Requesting but not using [...] scripting"。

  • 被拒原因:我在 manifest.json 中配置了 scripting 权限,但由于这版重构把大量的注入逻辑移到了 Content Script 中,实际上并没有调用 chrome.scripting API。
  • 解决方案:Google 严格奉行最小权限原则 (Least Privilege),申请了不用直接打回。果断从 manifest 移除未使用的 scripting 权限后重新提交通过。千万别在配置文件里保留“可能未来会用到”的权限!

💡 结语与体验

目前的版本已经完全可以满足我个人的日常需求了,接下来也会继续迭代(比如加入标签系统、知识图谱等)。

大家觉得这个思路怎么样?欢迎在评论区提出你们的建议与改进想法!

从 Fetch 到 RAG:为什么你的 AI 知识库总是“胡言乱语”?

2026年4月22日 09:23

🚀 省流助手(核心观点)

  1. 直击痛点:AI 答非所问,80% 的情况不是模型“笨”,而是你喂给它的资料“不对”或“没找准”。
  2. 核心结论:RAG(检索增强生成)系统的天花板由检索质量决定,模型输出只是在这个天花板下的“装修”。
  3. 行动建议:当效果不好时,第一步应排查 Embedding 检索到的 Context 是否包含正确答案,而不是盲目更换 GPT-4 或重写 Prompt。

一、 为什么“文档明明有,AI 却睁眼说瞎话”?

很多前端同学第一次做 AI 知识库(RAG)时,最习惯的操作就是:用户提问 -> 调接口 -> 拿答案

一旦发现 AI 回答错了,第一反应通常是:

  • “是不是 Prompt 写得不够卷?”
  • “是不是该换个更贵的模型了?”
  • “模型表达能力不行啊!”

但真相往往是:模型在回答之前,根本没看到那段真正相关的文档。

想象一下,你参加一场开卷考试,题目问的是“React 19 的新特性”,但监考老师只塞给你一本《jQuery 源码分析》。哪怕你是学霸(GPT-4),你也只能对着 jQuery 胡编乱造。


二、 技术方案对比:错误做法 vs 正确做法

1. 错误做法:盲目相信模型的“脑补”能力

这种做法只是把用户问题直接丢给模型,完全没有检索过程。

# ❌ 错误做法:直接提问,容易产生幻觉
query = "我们的表单校验规则是怎么定义的?"
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": query}]
)
# AI 可能会根据它的通用知识库瞎编一套 rules,导致与你公司内部规范完全不符

2. 正确做法:先找证据,再让模型总结

这才是 RAG 的核心:先通过语义搜索找到相关片段,再拼成 Prompt。

# ✅ 正确做法:先检索,再回答
def ask_ai_with_knowledge(query):
    # 1. 检索层:在向量数据库中寻找最相关的文档片段(Context)
    # 假设 search_documents 是你基于 Embedding 实现的搜索函数
    related_docs = search_documents(query, top_k=3) 
    
    # 2. 构建 Context
    context_text = "\n".join(related_docs)
    
    # 3. 增强 Prompt:强制模型基于 Context 回答
    prompt = f"""
    请根据以下已知信息回答问题。如果信息中没有相关内容,请直说不知道。
    
    已知信息:
    {context_text}
    
    问题:{query}
    """
    
    # 4. 生成层:模型此时只是一个“翻译官”和“总结者”
    return call_llm(prompt)

三、 深度解析:Embedding 到底在干什么?

很多前端同学觉得 Embedding(嵌入) 是个玄学。其实在工程上,你可以把它理解为**“语义坐标系”**。

  • 传统搜索(Like 匹配):搜“番茄”,搜不到“西红柿”。
  • 向量搜索(Embedding):在坐标系里,“番茄”和“西红柿”的距离非常近。

向量检索的本质: 不是比对字长得像不像,而是比对意思接不接近。如果这一步找偏了(比如搜“表单校验”却找到了“上传组件”),后面的模型生成得再漂亮也是白搭。


四、 🛠️ 生产环境避坑指南(避坑必看)

在实际项目中,想要检索得准,你必须注意以下三点:

  1. 切片(Chunking)策略不要太粗暴: 不要简单按字符数切,建议按标题/段落切。如果一个 Chunk 只有 50 个字,可能丢失上下文;如果有 2000 个字,噪声又太多。建议:300-500 字左右,并保持 10% 的内容重叠。

  2. Top-K 并不是越大越好: 找 10 段资料喂给模型,模型可能会产生“长上下文迷失(Lost in the Middle)”,反而抓不住重点。一般建议取 Top 3 到 Top 5。

  3. 一定要做“检索回显”: 在开发调试阶段,必须在界面上展示 AI 到底引用了哪几段原文。只有看到原文,你才能一眼看出是“检索没找对”还是“模型没理解对”。


五、 给前端开发者的建议

当你觉得 AI 效果不好时,请执行以下“排错三部曲”:

  1. 查检索结果:打印出检索到的 Context。如果 Context 里根本没有答案,去优化你的文档切片和 Embedding 算法。
  2. 查信息密度:如果 Context 太多太乱,尝试做 Rerank(重排序)或者减少 Top-K。
  3. 最后才查 Prompt:如果 Context 没问题,模型还是答错,这时候再去调整 Prompt 的约束条件。

记住:在 AI 工程中,垃圾入,垃圾出(Garbage In, Garbage Out)。检索层就是那个守门人。

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、评论!这是我持续分享前端转 AI 实战经验的最大动力。🚀

告别“刷新”:一文搞懂 WebSocket、SSE 与轮询机制

2026年4月21日 23:26

在现代 Web 开发中,实时性已成为衡量用户体验的关键指标。从股票行情的跳动、AI 对话的流式输出,到在线协作编辑,都需要服务器能够“主动”与客户端通信。

为了实现这一目标,开发者们经历了从“笨拙”的轮询到“优雅”的长连接技术的演进。本文将深入剖析 短轮询、长轮询、WebSocket 和 SSE (Server-Sent Events)  的核心区别、适用场景及技术细节。


1. 基础概念解析

🔄 短轮询

这是最原始的实现方式。客户端(浏览器)不管服务器有没有新数据,都每隔固定的时间(如 1秒)向服务器发送一次 HTTP 请求。

  • 原理:客户端定时发请求 -> 服务器立即响应(有数据返数据,无数据返空) -> 客户端处理 -> 等待下一次定时。
  • 缺点:极其浪费带宽和服务器资源。大部分请求可能是无效的(没有新数据),且实时性受限于轮询间隔。

⏳ 长轮询

这是对短轮询的优化。客户端发送请求后,如果服务器没有新数据,服务器不会立即关闭连接,而是将请求挂起(Hold住) ,直到有数据更新或达到超时时间才返回响应。客户端收到响应后,立即发起下一次请求。

  • 原理:客户端发请求 -> 服务器挂起连接(等待数据) -> 有数据了 -> 服务器响应 -> 客户端立即发新请求。
  • 优点:减少了无效请求,实时性比短轮询好。
  • 缺点:服务器需要维持大量挂起的连接,高并发下对服务器资源(线程/内存)消耗大。

⚡ WebSocket

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

  • 原理:通过 HTTP 协议发起握手请求,协商升级协议(Upgrade: websocket)。握手成功后,底层 TCP 连接保持打开,客户端和服务器可以随时互相发送数据
  • 特点:真正的双向通信,低延迟,支持二进制数据。

📢 SSE

Server-Sent Events 是一种允许服务器向浏览器单向推送实时更新的技术。它基于 HTTP 协议,使用 text/event-stream 格式传输数据。

  • 原理:客户端建立 HTTP 长连接 -> 服务器保持连接打开 -> 服务器有数据时直接推送 -> 客户端通过 EventSource API 接收。
  • 特点:基于 HTTP,单向推送(服务端 -> 客户端),原生支持断线重连。

2. 核心区别对比表

为了让你一目了然,我将这四种技术的核心差异整理如下表:

表格

特性 短轮询 长轮询 WebSocket SSE
通信方向 单向 (客户端请求) 单向 (客户端请求) 双向 (全双工) 单向 (服务端推送)
连接状态 频繁建立/断开 频繁建立/断开 (长连接挂起) 持久连接 持久连接
底层协议 HTTP HTTP WebSocket (独立协议) HTTP
数据类型 文本/JSON 文本/JSON 文本 & 二进制 仅文本 (UTF-8)
断线重连 无 (由定时器控制) 无 (需代码控制) 需手动实现 浏览器原生自动重连
实现复杂度 简单 中等 复杂 (需处理心跳、状态) 简单

3. 深度解析:WebSocket vs SSE

这是目前面试和选型中最常遇到的“二选一”问题。根据参考资料,两者的核心博弈点如下:

🛠️ 协议与连接

  • WebSocket:虽然握手阶段使用 HTTP,但一旦连接建立,它就升级为一个独立的 TCP 协议。它完全脱离了 HTTP 的请求-响应模式。
  • SSE:始终运行在 HTTP 协议之上。它利用了 HTTP 的长连接特性(Connection: keep-alive),服务器通过 Content-Type: text/event-stream 告诉浏览器“我要开始发流了,别关连接”。

🔄 通信模式

  • WebSocket双向车道。服务器可以发,客户端也可以发。适合聊天室、在线游戏等需要频繁交互的场景。
  • SSE单行道。只能服务器发给客户端。如果客户端想发数据(比如发送聊天内容),必须通过普通的 AJAX/Fetch 请求另起炉灶。

🛡️ 健壮性与重连

  • SSE 的杀手锏:浏览器原生的 EventSource API 内置了自动重连机制。当网络断开时,浏览器会自动尝试重新连接(通常有几秒的延迟),开发者无需编写复杂的 try-catch-reconnect 逻辑。
  • WebSocket:虽然强大,但原生 API 没有自动重连机制。一旦连接断开,开发者必须手动监听 onclose 事件并编写重连逻辑(包括指数退避算法等),否则连接就彻底断了。

📦 数据格式

  • WebSocket:支持发送字符串和 ArrayBuffer(二进制),适合传输图片、音频流或 Protobuf 数据。
  • SSE:只能发送 UTF-8 编码的文本数据。如果需要传对象,通常序列化为 JSON 字符串。

4. 选型指南:我该用哪个?

根据参考资料中的建议,结合实际开发经验,建议如下:

✅ 选择 SSE 的场景

如果你的业务场景符合以下特征,SSE 是比 WebSocket 更轻量、更简单的选择

  1. 服务器单向推送:如实时通知、股票行情、体育比分、AI 大模型的流式回答(ChatGPT 类应用)。
  2. 数据量小且为文本:不需要传输二进制文件。
  3. 追求开发效率:不想处理复杂的连接管理和心跳检测,利用 HTTP 协议穿透防火墙更容易。
  4. 移动端兼容:在某些移动网络环境下,SSE 的保活比 WebSocket 更稳定。

✅ 选择 WebSocket 的场景

如果你的业务场景符合以下特征,WebSocket 是唯一选择

  1. 双向高频交互:如在线聊天、视频会议信令、协同编辑文档。
  2. 低延迟要求极高:如实时竞技游戏。
  3. 传输二进制数据:如实时音视频流传输。

✅ 轮询的使用场景

  • 短轮询:仅在老旧浏览器不支持 SSE/WebSocket,且实时性要求极低(如几分钟更新一次)时使用。
  • 长轮询:作为 WebSocket/SSE 的降级方案(Polyfill)。当网络环境极其恶劣,不支持长连接时,使用长轮询兜底。

5. 代码示例 (基于参考资料)

SSE 实现 (前端)

SSE 的使用非常简单,几行代码即可实现自动重连的实时流:

// 前端代码
const eventSource = new EventSource('/api/stream');

// 监听消息
eventSource.onmessage = function(event) {
    const data = JSON.parse(event.data);
    console.log('收到推送:', data);
};

// 监听自定义事件
eventSource.addEventListener('systemAlert', function(event) {
    console.log('系统警报:', event.data);
});

// 错误处理 (浏览器会自动尝试重连)
eventSource.onerror = function(err) {
    console.error("SSE 连接出错", err);
};

WebSocket 实现 (前端)

WebSocket 需要手动管理状态和重连:

// 前端代码
const ws = new WebSocket('ws://localhost:8080');

ws.onopen = () => {
    console.log('连接已建立');
    ws.send('Hello Server');
};

ws.onmessage = (event) => {
    console.log('收到消息:', event.data);
};

ws.onclose = () => {
    console.log('连接已关闭,需要手动实现重连逻辑...');
    // reconnect();
};

📌 总结

  • 短/长轮询:是 HTTP/1.1 时代的妥协方案,现在多用于兼容降级。
  • SSE:是“轻量级的 WebSocket”,专为服务端推送设计,原生支持重连,基于 HTTP,开发成本低,是 AI 流式输出和通知系统的首选。
  • WebSocket:是“全能型选手”,专为双向实时交互设计,功能最强,但实现和维护成本相对较高。

参考资料及文章:

服务器发送事件

聊聊四种实时通信技术:长轮询、短轮询、WebSocket 和 SSE

前端如何理解SSE(Server-Sent Events)和WebSocket

放弃 Websocket 使用 SSE 才发现这些功能两三行代码就搞定了

为什么ChatGPT采用SSE协议而不是Websocket?

AI流式交互:SSE与WebSocket技术选型

AI场景前端必学——SSE流式传输

还在用 WebSocket 做实时通信?SSE 可能更简单

🚀 别再让浏览器“负重跑”了!手把手教你用 IntersectionObserver 实现图片懒加载

作者 AI的主人
2026年4月21日 23:20

🚀 别再让浏览器“负重跑”了!手把手教你用 IntersectionObserver 实现图片懒加载


💡 前言:为什么你的网页打开像“PPT”?

想象一下,你正在约会,对面坐着一位美女(用户)。她满怀期待地问你:“嘿,你的网站快吗?”

然后你深吸一口气,开始从背包里往外掏东西:先掏出一张巨大的海报(首屏大图),然后是几百张高清无码的猫咪照片(长列表图片),最后还有几个G的视频……

美女(用户)看着你这一堆乱七八糟的东西,还没等你掏完,她就说了一句:“算了,我们不合适。” 然后转身走了(用户流失,跳出率 100%)。

这就是**不做懒加载(Lazy Load)**的下场。

在传统的网页开发中,我们习惯把所有 <img> 标签的 src 一股脑全写上。浏览器一看:“好家伙,老板发话了,不管三七二十一,全部下载!” 于是,即使用户根本还没滑到页面底部,浏览器就已经累得气喘吁吁,流量在燃烧,内存再尖叫。

今天,我们就来聊聊如何用现代浏览器的“外挂”—— IntersectionObserver,来拯救你那卡顿的网页。


🧐 什么是懒加载?

简单来说,就是**“按需加载”**。

  • 用户看哪里,就加载哪里。
  • 用户还没滑到的图片?先给个“占位符”(比如一张很轻的loading图或者灰色背景)糊弄一下。
  • 等用户快滑到了,再瞬间把真图换上。

这就好比你去自助餐厅,不会一次性把所有菜都堆在桌子上,而是吃一盘,拿一盘


🛠️ 实战:手写一个“丝滑”的懒加载

以前,我们要实现懒加载,得监听 window.onscroll 事件,然后疯狂计算 getBoundingClientRect,还要防抖节流……写起来简直头秃,性能还差。

现在,IntersectionObserver 来了!它是浏览器原生的 API,专门用来监听元素是否进入了视口(可视区域)。它运行在单独的线程中,性能极佳,简直是前端界的“德芙”,纵享丝滑。

来看代码(结合你提供的示例):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>图片的懒加载</title>
  <style>
    * { margin: 0; padding: 0; }
    /* 搞几个大盒子把图片挤下去,模拟长页面 */
    .box { height: 200vh; background-color: #eee; } 
    img { width: 100%; height: 400px; object-fit: cover; display: block; }
  </style>
</head>
<body>

  <!-- 占位用的大盒子 -->
  <div class="box"></div>

  <!-- 
    1. src: 先放一张极小的默认图(或者loading图),防止页面布局塌陷
    2. data-src: 把真正的高清大图藏在这里,浏览器不会主动加载它
  -->
  <img class="lazy" 
       src="https://img10.360buyimg.com/wq/jfs/t24601/190/890984006/4559/731564fc/5b7f9b7bN3ccd29ab.png" 
       data-src="https://img.36krcdn.com/hsossms/20260119/v2_53cad3f2226f48e2afc1942de3ab74e4@5888275@ai_oswg1141728oswg1053oswg495_img_png~tplv-1marlgjv7f-ai-v3:960:400:960:400:q70.jpg?x-oss-process=image/format,webp" 
       alt="AI生图1">

  <div class="box"></div>

  <img class="lazy" 
       src="https://img10.360buyimg.com/wq/jfs/t24601/190/890984006/4559/731564fc/5b7f9b7bN3ccd29ab.png" 
       data-src="https://img.36krcdn.com/hsossms/20260117/v2_1e74add07bb94971845c777e0ce87a49@000000@ai_oswg421938oswg1536oswg722_img_000~tplv-1marlgjv7f-ai-v3:960:400:960:400:q70.jpg?x-oss-process=image/format,webp" 
       alt="AI生图2">

  <script>
    // 1. 选中所有需要懒加载的图片
    const images = document.querySelectorAll('.lazy');

    // 2. 创建观察者实例
    // 浏览器提供的观察者模式,自动观察,性能杠杠的
    const observer = new IntersectionObserver((entries) => {
      // entries 是一个数组,包含所有被观察元素的状态
      entries.forEach(entry => {
        // isIntersecting 为 true 表示元素进入了视口
        if (entry.isIntersecting) {
          const img = entry.target; // 获取目标元素 (DOM节点)
          const original_img = img.dataset.src; // 获取 data-src 里的真图地址
          
          console.log('抓到你了!正在加载:', original_img);
          
          // 3. 偷梁换柱:把真图地址赋值给 src
          img.src = original_img;
          
          // 4. 加载完后,告诉观察者:“这人看过了,不用盯着了”
          // 停止观察该元素,节省性能
          observer.unobserve(img);
        }
      })
    })

    // 5. 开始观察!
    images.forEach(img => observer.observe(img));
  </script>
</body>
</html>

🔍 代码核心知识点解析

别光顾着复制粘贴,我们来拆解一下这里的“骚操作”:

  1. data-src 的妙用 🎭 HTML 标准规定,<img> 标签只要有 src 属性,浏览器就会立刻发起请求。为了阻止这个行为,我们把真实的图片链接藏在自定义属性 data-src 里。浏览器:“哦,这只是一个叫 data-src 的字符串,我不认识,不加载。”

  2. IntersectionObserver API 👁️ 这是主角。

    • 传统做法:你得不停地问浏览器“图片在哪?滚动条在哪?算一下距离……”(强制重排/重绘,累死CPU)。
    • IntersectionObserver:你告诉浏览器“帮我盯着这张图,它出来了叫我一声”。浏览器在底层异步处理这些计算,完全不影响页面渲染帧率。
  3. entry.isIntersecting ✅ 这是一个布尔值。true 代表元素和视口有交集(出现了),false 代表没交集(消失了)。我们在 true 的时候才去加载图片。

  4. observer.unobserve(img) 🛑 这点很重要!图片加载完了,任务就结束了。如果不取消观察,浏览器还会一直盯着这张已经加载好的图片,纯属浪费资源。用完即弃,才是好代码。


🤔 为什么要这么做?(必要性)

你可能会问:“我就几张图,直接加载不行吗?”

  • 首屏速度(First Contentful Paint):用户打开网页,只关心第一屏。如果后台在偷偷下载第10屏的图片,首屏加载就会变慢。懒加载能让首屏飞起来。
  • 节省带宽:很多用户可能根本不会滑到底部。你加载了100张图,他只看了10张。懒加载帮他省了90%的流量,他会感谢你的(特别是用5G流量看视频的时候)。
  • 减少内存占用:浏览器同时处理几百个网络请求和渲染几百张大图,内存容易爆炸,导致页面卡顿甚至崩溃。

📌 总结

懒加载是现代 Web 开发的标配

以前我们用 jQuery 写插件,后来用原生 JS 算坐标,现在我们有了 IntersectionObserver。技术总是在进步,我们要学会用更优雅、性能更好的方式去解决问题。

下次再有人问你网站为什么这么快,你可以淡淡地喝一口咖啡,说:“哦,我只是让我的图片学会了‘按需出现’而已。”

Happy Coding! ☕️


(本文代码已在 Chrome/Firefox/Edge 等现代浏览器测试通过。IE 用户?请出门右转不送,或者加个 Polyfill 吧。)

「JS全栈AI学习」十一、Multi-Agent 系统设计:可观测性与生产实践

作者 霪霖笙箫
2026年4月21日 23:16

📌 系列简介:「JS全栈AI学习」记录 AI 应用开发的完整学习过程

往期系列导航

主题
第一篇 提示链 · 路由 · 并行化
第二篇 反思 · 工具使用 · 规划
第三篇 多智能体 · 记忆管理 · 学习适应
第四篇 MCP:给AI工具世界造一个USB接口
第五篇 目标设定与监控 · 异常处理与恢复
第六篇 Human-in-the-Loop 设计
第七篇 深入理解 RAG(检索增强生成)技术
第八篇 A2A 协议完全指南:理解 Agent 协作体系
第九篇 Multi-Agent 系统设计:架构与编排
第十篇 Multi-Agent 系统设计:成本优化与容错机制

写在前面

前两篇把 Multi-Agent 系统从"能跑"做到了"跑得稳"——架构选型、动态编排、成本优化、容错降级。

九、十、十一 3篇对应学习的 第15章:Multi-Agent 系统架构、第16章:工作流编排与规划、第17章:成本优化与执行策略;

很多孤立起来说没意义,加上 multi-agent 比较重要就放一起了,这里的例子可理解为 AI 给我的作业,实际只有思路,并没有实际业务 ~ 仅供参考

这个系列马上学完更完就开始在我的项目上实操了 ~ 大概就是先做作业投石问路

继续和AI伙伴聊,学习Agent设计。场景题为某天接到用户投诉:

"为什么给我推荐的酒店这么贵?我明明说了预算有限!"

我想回答这个问题,却发现:

  • NLU Agent 是怎么理解"预算有限"的?不知道
  • Profile Agent 推断的用户类型是什么?不知道
  • Planner Agent 为什么选了这个酒店档次?不知道
  • 整个流程耗时多久?哪个环节最慢?不知道

系统变成了一个黑盒。

这让我意识到:能跑、跑得稳,还不够——还要看得见。

可观测性(Observability)不是锦上添花,是生产级系统的必备能力。

这篇是 Multi-Agent 系列的最后一篇,聚焦三件事:日志、链路追踪、决策解释,以及一些生产环境的实践经验。


目录

  1. 可观测性的三大支柱
  2. 日志设计
  3. 链路追踪
  4. 决策解释
  5. 性能监控与告警
  6. 生产环境实践
  7. 完整框架串联
  8. 系列总结

1. 可观测性的三大支柱

可观测性不是单一的技术,而是三个维度的结合:

Logs(日志)    → 回答"某个时刻,系统的状态是什么?"
Traces(链路)  → 回答"一个请求经过了哪些 Agent?每个环节耗时多久?"
Metrics(指标) → 回答"系统整体表现如何?有没有异常?"

三者缺一不可:

  • 只有日志,能看到事件,但看不到全局路径
  • 只有链路,能看到路径,但看不到细节
  • 只有指标,能看到趋势,但定位不了具体问题

2. 日志设计

结构化日志

先看两种日志的对比:

// ❌ 非结构化:格式不统一,无法关联请求,难以分析
console.log("Flight Agent started querying flights for Beijing");

// ✅ 结构化:可按字段查询,可聚合分析,可追踪到具体请求
logger.info({
  timestamp: "2026-04-06T22:45:30.123Z",
  level: "INFO",
  traceId: "req_abc123",   // 关键:把这条日志和请求绑定
  agentId: "flight_agent",
  action: "query_flights_start",
  context: { destination: "北京", budget: 5000 },
});

结构化日志最关键的字段是 traceId——它把一个请求的所有日志串联起来,是后续链路追踪的基础。

记录哪些节点?

不是所有代码都需要日志,关键是抓住四个节点

class ObservableAgent {
  async execute(context: Context): Promise<Result> {
    const startTime = Date.now();

    // 1. Agent 开始
    logger.info({ traceId, agentId, action: 'agent_start' });

    try {
      // 2. 外部 API 调用前后(记录耗时)
      logger.debug({ traceId, agentId, action: 'api_call_start', api: 'flight_api' });
      const result = await this.callExternalAPI();
      logger.debug({ traceId, agentId, action: 'api_call_done', count: result.length });

      // 3. 决策点(最重要!记录为什么选这个)
      const selected = this.selectBestOption(result);
      logger.info({
        traceId, agentId, action: 'decision_made',
        selected: selected.id,
        reason: '价格最优,在预算范围内',
      });

      // 4. Agent 完成
      logger.info({ traceId, agentId, action: 'agent_complete', duration: Date.now() - startTime });
      return selected;

    } catch (error) {
      // 5. 错误(单独捕获,带完整上下文)
      logger.error({ traceId, agentId, action: 'agent_error', error, duration: Date.now() - startTime });
      throw error;
    }
  }
}

决策点的日志是最容易被忽略的,也是最有价值的——它回答了"为什么得到这个结果",是后面决策解释的数据来源。

日志级别

DEBUG → 详细调试信息(只在开发环境开启)
INFO  → 关键节点和决策点(生产环境的基准)
WARN  → 使用了降级策略、潜在问题
ERROR → 异常和错误

生产环境用 INFO 级别,不要用 DEBUG——否则日志量会爆炸,反而找不到有用的信息。


3. 链路追踪

日志告诉我们"发生了什么",但看不到"完整的路径"。这就需要链路追踪。

核心概念:Trace 和 Span

Trace:一个完整的请求链路(从用户发起到返回结果)
Span:链路中的一个环节(每个 Agent 的执行是一个 Span)

Trace
  └─ Span(Coordinator)
       ├─ Span(NLU Agent)
       ├─ Span(Planner Agent)
       └─ Span(并行查询)
            ├─ Span(Flight Agent)
            ├─ Span(Hotel Agent)
            └─ Span(Attraction Agent)

Span 之间有父子关系,通过 parentSpanId 连接。

TraceId 的传递

TraceId 要在所有 Agent 间传递,这是链路追踪的核心:

class Coordinator {
  async execute(userInput: string): Promise<Result> {
    const traceId = generateTraceId(); // 在入口生成,全程传递
    const rootSpan = tracer.startSpan({ traceId, agentId: 'coordinator' });

    // 调用其他 Agent 时,传递 traceId 和 parentSpanId
    const intent = await this.nluAgent.execute({
      userInput,
      traceId,
      parentSpanId: rootSpan.spanId, // NLU 的 Span 挂在 Coordinator 下面
    });

    tracer.endSpan(rootSpan);
    return result;
  }
}

可视化链路

有了 Trace 数据,就能可视化整个请求路径:

Coordinator          ████████████████████████████████ 5000ms
  NLU Agent          ████ 400ms
  Planner Agent      ████ 400ms
  Flight Agent       ████████████████████████ 2300ms  ← 性能瓶颈
  Hotel Agent        █████████████████ 1700ms
  Attraction Agent   █████████ 900ms

一眼就能看出:Flight Agent 是瓶颈,占了总耗时的 46%。

这是我在做前端性能优化时就熟悉的思路——先找到最慢的那个,再想怎么优化。在 Multi-Agent 里,工具换了,逻辑是一样的。


4. 决策解释

这是这篇里我觉得最有价值的部分。

AI 系统最大的"黑盒"问题,不是技术上看不到,而是用户不知道为什么得到这个结果

记录决策依据

每次做决策,都记录下来:选了什么、有哪些选项、为什么选这个:

class ExplainableHotelAgent {
  async selectHotel(hotels: Hotel[], context: Context): Promise<Hotel> {
    // 对每个酒店打分,记录各维度的权重和影响
    const scored = hotels.map(hotel => ({
      hotel,
      score: this.calculateScore(hotel, context),
      factors: [
        { name: '价格',  weight: 0.4, impact: this.priceFit(hotel.price, context.budget) },
        { name: '位置',  weight: 0.3, impact: this.locationScore(hotel.distanceToCenter) },
        { name: '评分',  weight: 0.2, impact: hotel.rating / 5 },
        { name: '设施',  weight: 0.1, impact: this.facilityScore(hotel.facilities) },
      ],
    }));

    const best = scored.sort((a, b) => b.score - a.score)[0];

    // 记录决策(这条记录是后续解释的数据来源)
    decisionLog.record({
      agentId: 'hotel_agent',
      action: 'select_hotel',
      options: hotels.length,
      selected: best.hotel.id,
      factors: best.factors,
      reason: this.buildExplanation(best),
    });

    return best.hotel;
  }
}

注:这里只是个人理解,作业提交,思路仅供参考

展示给用户

当用户问"为什么推荐这个酒店"时,直接从决策记录里取:

📊 推荐理由 · 三亚某酒店

1. 价格:500元/晚(权重 40%)
   预算 5000元 / 4晚 = 1250元/晚上限,500元在范围内,性价比高

2. 位置:距海滩 200m(权重 30%)
   符合您的偏好:海边度假

3. 评分:4.8 / 5.0(权重 20%)
   基于 XX 条用户评价

综合得分:8.7 / 10

这就把黑盒变成了白盒——用户看得见推荐的依据,信任感自然建立起来。


5. 性能监控与告警

关键指标

监控系统健康,最重要的三个维度:

延迟(Latency)  → P50 / P95 / P99,而不是平均值
成功率           → 成功请求 / 总请求
错误率           → 失败请求 / 总请求

为什么关注 P95/P99,而不是平均值?

平均值会被极端值拉偏。P95 表示"95% 的请求在这个时间内完成"——更能反映真实的用户体验。 如果 P95 是 5 秒,说明有 5% 的用户每次都在等 5 秒以上,这是真实的问题。

告警规则

指标异常时自动触发告警:

const alertRules = [
  {
    name: '错误率过高',
    condition: (m: Metrics) => m.errorRate > 0.1,       // 错误率 > 10%
    severity: 'critical',
  },
  {
    name: '响应过慢',
    condition: (m: Metrics) => m.latency.p95 > 5000,    // P95 > 5s
    severity: 'warning',
  },
];

告警不是越多越好——告警太多会让人麻木,反而忽略真正重要的问题。 只对真正需要人工介入的情况告警,其他的记录日志就够了。


6. 生产环境实践

几个踩过坑之后总结的原则:

日志级别按环境区分

开发环境 → DEBUG(记录所有细节,方便调试)
测试环境 → INFO(记录关键节点)
生产环境 → WARN(只记录警告和错误)

敏感信息脱敏

日志里不能出现密码、Token、信用卡号——写入之前统一过滤:

private sanitize(entry: LogEntry): LogEntry {
  const sensitiveFields = ['password', 'token', 'creditCard'];
  sensitiveFields.forEach(field => {
    if (entry.context?.[field]) entry.context[field] = '***';
  });
  return entry;
}

这一条看起来简单,但在实际项目里很容易漏——建议在日志框架层统一处理,不要依赖各处手动过滤。

采样策略

高流量系统不需要记录所有请求的 Trace,否则存储成本会很高:

shouldTrace(context: Context): boolean {
  if (Math.random() < 0.1)      return true;  // 随机采样 10%
  if (context.hasError)          return true;  // 错误请求 100% 采样
  if (context.duration > 5000)   return true;  // 慢请求 100% 采样
  return false;
}

正常请求采样 10%,错误和慢请求 100% 采样——既能监控系统,又不产生海量数据。

推荐工具组合

日志查询    → Elasticsearch + Kibana
链路追踪    → Jaeger 或 Zipkin
指标监控    → Prometheus + Grafana

这三个组合是目前业界最常见的可观测性技术栈,文档完善,生态成熟。


7. 完整框架串联

把日志、链路、指标整合成一个可观测性框架,用装饰器模式包装 Agent——业务代码不需要改动:

class ObservabilityFramework {
  // 包装任意 Agent,自动注入可观测性能力
  wrapAgent(agent: Agent): Agent {
    return {
      execute: async (context: Context): Promise<Result> => {
        const startTime = Date.now();
        const span = tracer.startSpan({ traceId: context.traceId, agentId: agent.id });

        logger.info({ traceId: context.traceId, agentId: agent.id, action: 'agent_start' });

        try {
          const result = await agent.execute(context);
          const duration = Date.now() - startTime;

          logger.info({ traceId: context.traceId, agentId: agent.id, action: 'agent_complete', duration });
          metrics.record(agent.id, duration, true);
          tracer.endSpan(span);

          return result;
        } catch (error) {
          const duration = Date.now() - startTime;

          logger.error({ traceId: context.traceId, agentId: agent.id, action: 'agent_error', error, duration });
          metrics.record(agent.id, duration, false);

          // 检查是否需要告警
          const m = metrics.get(agent.id);
          if (m.errorRate > 0.1) alertManager.send({ severity: 'critical', agentId: agent.id });

          tracer.endSpan(span);
          throw error;
        }
      },
    };
  }
}

// 使用:一行代码,Agent 自动具备完整的可观测性
const flightAgent  = observability.wrapAgent(rawFlightAgent);
const hotelAgent   = observability.wrapAgent(rawHotelAgent);

装饰器模式在这里很合适——可观测性是横切关注点,不应该和业务逻辑耦合在一起。


8. 系列总结

三篇写完了,回头看一下这条路:

第一篇:架构与编排
  → 中心化 vs 去中心化,动态主导权转移,版本控制

第二篇:成本优化与容错
  → 两阶段执行,用户画像,断路器 + 降级 + Saga 补偿

第三篇:可观测性与生产实践
  → 日志 + 链路 + 指标,决策解释,生产环境实践

这三篇其实是同一件事的三个层次:

  • 第一篇解决的是"怎么让多个 Agent 有序协作"
  • 第二篇解决的是"出了问题怎么办,怎么省钱"
  • 第三篇解决的是"怎么知道系统在做什么,出了问题怎么找"

顺序不是随意的——先能跑,再跑得稳,再看得见。


写在最后

学这一章的时候,有一个问题一直在脑子里转:

为什么可观测性这么重要?

技术上的答案是:系统复杂了,靠直觉和经验已经不够,需要数据。

但我觉得还有一个更深的原因——

AI 系统做决策,用户看不见过程,只看到结果。 如果结果不符合预期,用户没有办法理解为什么,也没有办法信任这个系统。

可观测性,本质上是在建立信任

不只是让工程师能调试,更是让用户能理解——"系统是怎么想的,为什么给我这个结果"。

易经里有一卦叫明夷卦,卦象是"明入地中"——光明藏入地下,看不见了。 但明夷卦的卦辞说:"利艰贞。"——在晦暗中,更要坚守正道,内心清明。

系统复杂到像一个黑盒,这是"明入地中"。 可观测性要做的,就是把那道光重新引出来——让内部的运行逻辑,能够被看见、被理解、被信任。

内文明,而外可观。

往期系列导航

主题
第一篇 提示链 · 路由 · 并行化
第二篇 反思 · 工具使用 · 规划
第三篇 多智能体 · 记忆管理 · 学习适应
第四篇 MCP:给AI工具世界造一个USB接口
第五篇 目标设定与监控 · 异常处理与恢复
第六篇 Human-in-the-Loop 设计
第七篇 深入理解 RAG(检索增强生成)技术
第八篇 A2A 协议完全指南:理解 Agent 协作体系
第九篇 Multi-Agent 系统设计:架构与编排
第十篇 Multi-Agent 系统设计:成本优化与容错机制

昇哥 · 2026年4月 Multi-Agent 系统设计系列

以Vultr供应商的VPS为例、十分钟自建一个自己的VPN(图文并茂)

作者 水冗水孚
2026年4月21日 22:49

本文图文并茂记录购买Vultr供应商的VPS,通过shell脚本的方式,快速部署一个属于自己的VPN,从而实现逛GitHub自由...

1. 什么是VPS?和现在的云服务器的区别

VPS就是Virtual Private Server,虚拟专用服务器

  • 对比于现在的火山引擎、腾讯云、阿里云等云服务器而言,VPS可以理解为迷你版的云服务器

  • 依托于服务器虚拟化技术,可以把一个配置高的服务器,虚拟切割成好几台配置低的服务器 比如16核32G的可以切成两台8核16G,这样就可以卖给两个用户,减少资源闲置多挣米

  • 但是对比云服务器和VPS,前者可以智能灵活调度(成百上千台服务器组成的服务器资源池子) 而VPS就是一台物理服务器的切割,相当于我们租赁了一个小单间

  • 所以,云服务器挂了,智能调度会立刻新启用一台虚拟服务器,备份并重新启动相关服务 但是,VPS要是挂了,因为是小单间模式嘛,高可用是无法做到位的

  • 所以,学习Linux、搭个小网站、个人VPN购买便宜的VPS就够用了

  • 不过公司业务、需要高可用、随时要扩容,就得选真正的云服务器了

无论VPS还是云服务器,都是物理意义上的服务器上的一部分,不存在两个物理服务器各出一半

2. 买VPS服务器做相应配置

VPS服务器供应商不少,不赘述,笔者买的是Vultr,还不错

官网:www.vultr.com/zh/

至于购买步骤流程,可以参考这个文章:zhuanlan.zhihu.com/p/701057606

建议,提前准备好一个VISA信用卡哦

服务器配置如下图参考

第一步——选配置

111.png

第二步——选择操作系统(防火墙规则【相当于云服务器的安全组概念】)

222.png

第三步——创建实例,等待一会

333.png

第四步——有了自己的公网ip了,可以ssh链接了

444.png

第五步——防火墙组设置

555.png

使用udp搭配443端口(要放开哦,要不然无法连接VPN服务)

666.png

3. 执行一键部署VPN脚本

部署脚本是setup-hysteria.sh这个文件,名字无所谓,主要是内容如下:

PASSWORD="password123" 是示例,实际上可以设置复杂一些,在搭配fail2ban这样可以保证服务器安全不被爆破

#!/bin/bash
set -e

# 注意,要赋予此脚本执行权限:chmod +x setup-hysteria.sh
# 然后在执行:./setup-hysteria.sh

# ==================== 配置变量(按需修改) ====================
PASSWORD="password123"
LISTEN_PORT="443"
MASQUERADE_URL="https://www.bing.com"
CERT_DAYS="365"
HY_VERSION="v2.8.1"
# ============================================================

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}   Hysteria ${HY_VERSION} VPN 服务器安装   ${NC}"
echo -e "${GREEN}          Ubuntu 22.04 专用           ${NC}"
echo -e "${GREEN}========================================${NC}"

if [[ $EUID -ne 0 ]]; then
    echo -e "${RED}错误:请使用 root 用户执行此脚本 (sudo ./script.sh)${NC}"
    exit 1
fi

echo -e "${YELLOW}[1/7] 更新系统并安装依赖...${NC}"
apt update -qq
apt install -y -qq wget curl openssl ufw

echo -e "${YELLOW}[2/7] 创建目录结构...${NC}"
mkdir -p /etc/hysteria /etc/ssl/hysteria

echo -e "${YELLOW}[3/7] 生成 SSL 证书(有效期${CERT_DAYS}天)...${NC}"
openssl req -x509 -newkey rsa:4096 -nodes \
    -keyout /etc/ssl/hysteria/key.pem \
    -out /etc/ssl/hysteria/cert.pem \
    -days ${CERT_DAYS} \
    -subj "/CN=www.bing.com"

chmod 644 /etc/ssl/hysteria/key.pem
chmod 644 /etc/ssl/hysteria/cert.pem
echo -e "${GREEN}✓ 证书权限已设置为 644${NC}"

echo -e "${YELLOW}[4/7] 创建配置文件 /etc/hysteria/config.yaml ...${NC}"
cat > /etc/hysteria/config.yaml << YAML
listen: :${LISTEN_PORT}

tls:
  cert: /etc/ssl/hysteria/cert.pem
  key: /etc/ssl/hysteria/key.pem

auth:
  type: password
  password: ${PASSWORD}

masquerade:
  type: proxy
  proxy:
    url: ${MASQUERADE_URL}
    rewriteHost: true

quic:
  initStreamReceiveWindow: 8388608
  maxStreamReceiveWindow: 8388608
  initConnReceiveWindow: 20971520
  maxConnReceiveWindow: 20971520
YAML

echo -e "${YELLOW}[5/7] 使用官方脚本安装 Hysteria ${HY_VERSION} ...${NC}"
bash <(curl -fsSL https://get.hy2.sh/) --version ${HY_VERSION}

echo -e "${YELLOW}[6/7] 配置防火墙 (ufw)...${NC}"
ufw allow ${LISTEN_PORT}/udp
echo -e "${GREEN}已允许 UDP ${LISTEN_PORT} 端口${NC}"

echo -e "${YELLOW}[7/7] 重启 Hysteria 服务并应用配置...${NC}"
systemctl stop hysteria-server || true
systemctl start hysteria-server
systemctl enable hysteria-server
sleep 3

if systemctl is-active --quiet hysteria-server; then
    SERVER_IP=$(curl -s ifconfig.me)
    echo -e "\n${GREEN}========================================${NC}"
    echo -e "${GREEN}✓ Hysteria 部署成功!${NC}"
    echo -e "${GREEN}========================================${NC}"
    echo -e "${YELLOW}服务状态:${NC}$(systemctl status hysteria-server --no-pager | grep "Active:")"
    echo -e "${YELLOW}端口监听:${NC}"
    ss -tulnp | grep ":${LISTEN_PORT}" | grep -v grep || echo "  等待端口监听..."
    echo ""
    echo -e "${GREEN}客户端连接信息:${NC}"
    echo -e "  服务器地址:${SERVER_IP}:${LISTEN_PORT}"
    echo -e "  密码:${PASSWORD}"
    echo -e "  协议:Hysteria ${HY_VERSION}"
    echo ""
    echo -e "${YELLOW}常用管理命令:${NC}"
    echo -e "  查看状态: systemctl status hysteria-server"
    echo -e "  查看日志: journalctl -u hysteria-server -f"
    echo -e "  重启服务: systemctl restart hysteria-server"
    echo -e "  停止服务: systemctl stop hysteria-server"
else
    echo -e "${RED}服务启动失败!查看错误日志:${NC}"
    journalctl -u hysteria-server -n 20 --no-pager
    exit 1
fi

然后,把这个·setup-hysteria.sh·脚本丢到服务器上(ssh链接)比如笔者是放在var目录下的

root@vultr:/var# ls
backups  crash  local  log   opt  setup-hysteria.sh  spool
cache    lib    lock   mail  run  snap               tmp

然后 chmod +x setup-hysteria.sh 给权限,再 ./setup-hysteria.sh 就可以一键部署好vpn服务了

如下日志图:

777.png

查看服务状态也是在运行的

root@vultr:/var# systemctl status hysteria-server
● hysteria-server.service - Hysteria Server Service (config.yaml)
     Loaded: loaded (/etc/systemd/system/hysteria-server.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2026-04-21 14:17:33 UTC; 2min 34s ago
   Main PID: 8156 (hysteria)
      Tasks: 7 (limit: 1001)
     Memory: 5.9M
        CPU: 57ms
     CGroup: /system.slice/hysteria-server.service
             └─8156 /usr/local/bin/hysteria server --config /etc/hysteria/config.yaml

Apr 21 14:17:33 vultr systemd[1]: Started Hysteria Server Service (config.yaml).
Apr 21 14:17:33 vultr hysteria[8156]: 2026-04-21T14:17:33Z        INFO        server mode
Apr 21 14:17:33 vultr hysteria[8156]: 2026-04-21T14:17:33Z        INFO        server up and running        {"listen": ":443"}
root@vultr:/var#

至此,我们的VPS服务器上的VPN服务就部署好了,接下来,我们在自己的本机电脑上,使用一些客户端工具,就可以使用VPN服务了

3. 使用clash-verge-rev进行订阅VPN服务(通过配置文件的方式)

首先安装clash-verge-rev,这个软件客户端:github.com/clash-verge…

如下图:

888.png

然后准备一个conf.yaml文件,内容如下:

  • 注意:server: 64.176.80.218 就是 VPS服务器的ip
  • password: "password123" 也就是服务器的VPN的密码
  • 等,不赘述
  • 和 !!!setup-hysteria.sh 这个文件里面配置信息要对上!!!
  • rule-providers也可以根据个人情况,适当修改
# ========== 代理节点配置 ==========
proxies:
  - name: "VPS-Hysteria2"
    type: hysteria2
    server: 64.176.80.218
    port: 443
    password: "password123"
    sni: www.bing.com
    skip-cert-verify: true
    # 以下为可选优化参数
    up: "100 Mbps"
    down: "500 Mbps"

# ========== 规则集配置(可选,用于增强分流)==========
rule-providers:
  reject:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/reject.txt"
    path: ./ruleset/reject.yaml
    interval: 86400

  icloud:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/icloud.txt"
    path: ./ruleset/icloud.yaml
    interval: 86400

  apple:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/apple.txt"
    path: ./ruleset/apple.yaml
    interval: 86400

  google:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/google.txt"
    path: ./ruleset/google.yaml
    interval: 86400

  proxy:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/proxy.txt"
    path: ./ruleset/proxy.yaml
    interval: 86400

  direct:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/direct.txt"
    path: ./ruleset/direct.yaml
    interval: 86400

  gfw:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/gfw.txt"
    path: ./ruleset/gfw.yaml
    interval: 86400

  tld-not-cn:
    type: http
    behavior: domain
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/tld-not-cn.txt"
    path: ./ruleset/tld-not-cn.yaml
    interval: 86400

  telegramcidr:
    type: http
    behavior: ipcidr
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/telegramcidr.txt"
    path: ./ruleset/telegramcidr.yaml
    interval: 86400

  cncidr:
    type: http
    behavior: ipcidr
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/cncidr.txt"
    path: ./ruleset/cncidr.yaml
    interval: 86400

  lancidr:
    type: http
    behavior: ipcidr
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/lancidr.txt"
    path: ./ruleset/lancidr.yaml
    interval: 86400

  applications:
    type: http
    behavior: classical
    url: "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/applications.txt"
    path: ./ruleset/applications.yaml
    interval: 86400

# ========== 代理组配置 ==========
proxy-groups:
  - name: "🚀 节点选择"
    type: select
    proxies:
      - "VPS-Hysteria2"
      - "DIRECT"

  - name: "🎬 流媒体"
    type: select
    proxies:
      - "VPS-Hysteria2"
      - "DIRECT"

  - name: "🤖 AI服务"
    type: select
    proxies:
      - "VPS-Hysteria2"
      - "DIRECT"

# ========== 规则配置 ==========
rules:
  # ===== 1. 规则集分流(如果不想用可以删除本块)=====
  - RULE-SET,applications,DIRECT
  - RULE-SET,icloud,DIRECT
  - RULE-SET,apple,DIRECT
  - RULE-SET,google,🚀 节点选择
  - RULE-SET,proxy,🚀 节点选择
  - RULE-SET,direct,DIRECT
  - RULE-SET,lancidr,DIRECT
  - RULE-SET,cncidr,DIRECT
  - RULE-SET,telegramcidr,🚀 节点选择

  # ===== 2. 国内网站强制直连 =====
  # 通用规则
  - DOMAIN-SUFFIX,cn,DIRECT
  - GEOIP,CN,DIRECT,no-resolve
  - GEOSITE,CN,DIRECT

  # 常见国内网站关键词
  - DOMAIN-KEYWORD,baidu,DIRECT
  - DOMAIN-KEYWORD,taobao,DIRECT
  - DOMAIN-KEYWORD,alipay,DIRECT
  - DOMAIN-KEYWORD,qq,DIRECT
  - DOMAIN-KEYWORD,weixin,DIRECT
  - DOMAIN-KEYWORD,bilibili,DIRECT
  - DOMAIN-KEYWORD,bytedance,DIRECT
  - DOMAIN-KEYWORD,zhihu,DIRECT
  - DOMAIN-KEYWORD,jd,DIRECT
  - DOMAIN-KEYWORD,meituan,DIRECT
  - DOMAIN-KEYWORD,douyin,DIRECT
  - DOMAIN-KEYWORD,pinduoduo,DIRECT

  # 局域网与保留地址
  #- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
  #- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
  #- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
  #- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
  #- IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
  #- IP-CIDR,17.0.0.0/8,DIRECT,no-resolve

  # ===== 3. AI 服务走代理 =====
  # OpenAI
  - DOMAIN-KEYWORD,openai,🤖 AI服务
  - DOMAIN-SUFFIX,openai.com,🤖 AI服务
  - DOMAIN-SUFFIX,chatgpt.com,🤖 AI服务
  - DOMAIN-SUFFIX,ai.com,🤖 AI服务
  - DOMAIN-SUFFIX,oaistatic.com,🤖 AI服务
  - DOMAIN-SUFFIX,oaiusercontent.com,🤖 AI服务
  - DOMAIN-KEYWORD,chatgpt,🤖 AI服务

  # Anthropic (Claude)
  - DOMAIN-SUFFIX,anthropic.com,🤖 AI服务
  - DOMAIN-SUFFIX,claude.ai,🤖 AI服务

  # Google (Gemini/Bard/DeepMind)
  - DOMAIN-SUFFIX,gemini.google.com,🤖 AI服务
  - DOMAIN-SUFFIX,bard.google.com,🤖 AI服务
  - DOMAIN-SUFFIX,deepmind.google,🤖 AI服务
  - DOMAIN-SUFFIX,deepmind.com,🤖 AI服务
  - DOMAIN-SUFFIX,ai.google.dev,🤖 AI服务
  - DOMAIN-SUFFIX,generativeai.google,🤖 AI服务
  - DOMAIN-SUFFIX,proactivebackend-pa.googleapis.com,🤖 AI服务
  - DOMAIN-KEYWORD,generativelanguage,🤖 AI服务

  # Meta (Llama)
  - DOMAIN-SUFFIX,meta.ai,🤖 AI服务
  - DOMAIN-SUFFIX,llama.com,🤖 AI服务
  - DOMAIN-SUFFIX,llama.meta.com,🤖 AI服务

  # 其他海外AI服务
  - DOMAIN-SUFFIX,perplexity.ai,🤖 AI服务
  - DOMAIN-SUFFIX,pplx.ai,🤖 AI服务
  - DOMAIN-KEYWORD,perplexity,🤖 AI服务
  - DOMAIN-SUFFIX,x.ai,🤖 AI服务
  - DOMAIN-KEYWORD,grok,🤖 AI服务
  - DOMAIN-SUFFIX,poe.com,🤖 AI服务
  - DOMAIN-SUFFIX,you.com,🤖 AI服务

  # Hugging Face (AI模型社区)
  - DOMAIN-SUFFIX,huggingface.co,🤖 AI服务
  - DOMAIN-SUFFIX,hf.co,🤖 AI服务

  # 平台/聚合类AI服务
  - DOMAIN-SUFFIX,openrouter.ai,🤖 AI服务
  - DOMAIN-SUFFIX,together.ai,🤖 AI服务

  # Cursor AI 编辑器
  - DOMAIN-SUFFIX,cursor.com,🤖 AI服务
  - DOMAIN-SUFFIX,cursor.sh,🤖 AI服务
  - DOMAIN-SUFFIX,cursor-cdn.com,🤖 AI服务
  - DOMAIN-SUFFIX,workos.com,🤖 AI服务
  - DOMAIN-SUFFIX,challenges.cloudflare.com,🤖 AI服务

  # Amazon Kiro / Amazon AI 服务
  - DOMAIN-SUFFIX,kiro.dev,🤖 AI服务
  - DOMAIN-SUFFIX,amazonkiro.com,🤖 AI服务
  - DOMAIN-KEYWORD,kiro,🤖 AI服务
  - DOMAIN-SUFFIX,aws.amazon.com,🤖 AI服务
  - DOMAIN-SUFFIX,amazonaws.com,🤖 AI服务
  - DOMAIN-SUFFIX,bedrock.aws,🤖 AI服务
  - DOMAIN-KEYWORD,amazonbedrock,🤖 AI服务
  - DOMAIN-SUFFIX,q.aws.amazon.com,🤖 AI服务
  - DOMAIN-SUFFIX,codecatalyst.aws,🤖 AI服务
  - DOMAIN-SUFFIX,sagemaker.aws,🤖 AI服务

  # 国内AI服务 (默认直连,如需走代理请取消注释并修改策略)
  # - DOMAIN-SUFFIX,deepseek.com,DIRECT
  # - DOMAIN-SUFFIX,yiyan.baidu.com,DIRECT
  # - DOMAIN-SUFFIX,tongyi.aliyun.com,DIRECT
  # - DOMAIN-SUFFIX,doubao.com,DIRECT
  # - DOMAIN-SUFFIX,chatglm.cn,DIRECT
  # - DOMAIN-SUFFIX,xinghuo.xfyun.cn,DIRECT
  # - DOMAIN-SUFFIX,kimi.moonshot.cn,DIRECT
  # - DOMAIN-SUFFIX,yuanbao.tencent.com,DIRECT

  # ===== 4. 流媒体走代理 =====
  - DOMAIN-KEYWORD,youtube,🎬 流媒体
  - DOMAIN-KEYWORD,netflix,🎬 流媒体
  - DOMAIN-KEYWORD,disney,🎬 流媒体
  - DOMAIN-KEYWORD,hbo,🎬 流媒体
  - DOMAIN-KEYWORD,hulu,🎬 流媒体
  - DOMAIN-KEYWORD,spotify,🎬 流媒体
  - DOMAIN-KEYWORD,twitch,🎬 流媒体
  - DOMAIN-SUFFIX,googlevideo.com,🎬 流媒体
  - DOMAIN-SUFFIX,ytimg.com,🎬 流媒体
  - DOMAIN-SUFFIX,ggpht.com,🎬 流媒体
  - DOMAIN-SUFFIX,fastly.com,🎬 流媒体

  # ===== 5. 其他常用国外服务走代理 =====
  - DOMAIN-KEYWORD,github,🚀 节点选择
  - DOMAIN-SUFFIX,github.com,🚀 节点选择
  - DOMAIN-SUFFIX,github.io,🚀 节点选择
  - DOMAIN-SUFFIX,githubassets.com,🚀 节点选择
  - DOMAIN-SUFFIX,githubusercontent.com,🚀 节点选择
  - DOMAIN-KEYWORD,google,🚀 节点选择
  - DOMAIN-KEYWORD,twitter,🚀 节点选择
  - DOMAIN-KEYWORD,facebook,🚀 节点选择
  - DOMAIN-KEYWORD,instagram,🚀 节点选择
  - DOMAIN-KEYWORD,reddit,🚀 节点选择
  - DOMAIN-KEYWORD,telegram,🚀 节点选择
  - DOMAIN-KEYWORD,whatsapp,🚀 节点选择
  - DOMAIN-KEYWORD,zoom,🚀 节点选择
  - DOMAIN-KEYWORD,slack,🚀 节点选择
  - DOMAIN-KEYWORD,notion,🚀 节点选择

  # ===== 6. 最终兜底规则 =====
  # 所有未被上述规则匹配的流量,默认走代理节点
  - MATCH,🚀 节点选择

然后,在clash的订阅这里,新建、Local、随便起个名字,再上传刚刚准备好的订阅conf.yaml配置文件

9.png

然后,点击上图的保存按钮,再右键使用之,就订阅好了

10.png

而后开启代理

10.5.png

在clash里面也能看到我们的ip已经变成了新加坡了

11.png

至此,VPN搞定完毕,就可以正常访问github,用谷歌搜索学习代码知识啦

掘金第一文:深入V8引擎:JavaScript执行机制与作用域机制

作者 hisoka西索
2026年4月21日 22:17

一、JavaScript 引擎到底是什么?

我们日常写的 JS 代码,本身只是一堆纯文本字符,电脑根本看不懂。JS 引擎就是专门负责读懂、运行 JavaScript 代码的程序

目前全世界主流能跑 JS 的运行环境,一共就两大类:

  1. 浏览器环境:Chrome、Edge、火狐这些浏览器里自带的 JS 运行环境
  2. Node 环境:可以脱离浏览器,在电脑后台、服务器上单独跑 JS 的环境

而这两个环境能运行 JS 的核心共同点:底层都内置了 Google 开发的 V8 引擎

什么是 V8 引擎?

V8 是谷歌用 C++ 语言开发的开源高性能 JS & WebAssembly 引擎。你可以把它理解成 JS 的「通用翻译官」:不管是浏览器里的网页,还是 Node 后台程序,所有 JS 代码最终都要交给 V8,由它翻译成电脑能直接执行的指令。它本质就是一个超大型的功能函数,核心使命就是读懂文本格式的 JS 代码,并且执行代码逻辑


二、JS 代码完整执行流程

很多人误以为:代码写好,V8 拿到就直接从上往下跑了。大错特错! V8 拿到 JS 文本代码后,不会立刻执行,要先走完一整套「编译梳理流程」,全部处理完之后,才会真正运行代码。完整分为 3 步:

  1. 词法分析(分词) 最简单理解:把一长串完整的代码文本,从左到右「拆零件」。把整段代码拆成一个个最小、不可再拆分的基础单元(关键字、变量名、符号、数字等),就像把一整句话拆成单个的汉字、词语、标点,是引擎解析代码的第一步。
  2. 语法分析(解析) 在上一步拆分好的词语基础上,按照 JavaScript 官方语法规则,把零散的单元拼装成抽象语法树(AST) 。这一步会校验代码语法对不对,识别代码里所有的变量、函数、标识符,检查有没有语法写错的地方。
  3. 生成代码 把上面生成好的语法树,最终转换成电脑 CPU 能直接识别、运行的机器指令。到这一步编译流程全部结束,代码才正式开始执行。

三、JS 函数的本质

我们写的 function foo() {} 就是标准的函数结构。函数的核心意义:代码逻辑的「打包容器」

你可以把函数理解成一个「功能盒子」:我们把一段需要重复使用、有独立逻辑的代码,全部打包封进这个盒子里。盒子里的代码,默认不会自动运行。只有当你主动调用这个函数(写 foo())的时候,盒子才会打开,里面包裹的代码才会依次执行。

举个直白例子:函数就像你手机里的 App,代码写好了只是安装完毕,你点开 App(调用函数),里面的功能才会运行;你不点开,它就安安静静待着,不会自己跑。


四、JS 重中之重:作用域机制

作用域,通俗翻译就是:变量的「可访问范围」。它规定了:你写的变量、函数,在代码的哪些地方能被读取、使用,哪些地方访问不到。JavaScript 里一共只有 3 种作用域,层层嵌套、边界分明:

1. 全局作用域

它是整个 JS 程序最外层、最大的公共空间。所有没写在任何函数、代码块 {} 里面的代码、变量,全部都归属全局作用域。特点:全局作用域里的内容,代码全处任何地方都能访问,没有访问限制,是整个程序的「公共广场」。如下图所示

abec0878956c19e6aaba6c940727910a.png

2. 函数作用域

function 函数大括号 {} 包裹起来的独立私密空间,就连函数的传入参数,也属于这个函数作用域。特点:内部定义的变量,只在函数里面能用,函数外面完全访问不到。函数就是自带一层「隐私围墙」,里面的东西不会泄露到外面,外层也闯不进内层拿变量。如下图所示

7bb7a6fc850cc091acfa1e42dc623222.png

3. 块级作用域

{} 代码块(比如 iffor 循环的大括号)形成的独立空间。
⚠️ 关键限定:只有 letconst 声明的变量,才会拥有块级作用域;老式的 var 变量完全无视块级作用域。大括号内部用 let/const 声明的变量,只在这个 {} 里面有效,括号外面访问不到。如下图所示

f4e66e8046f42702b27e0d49a8f7dfc4.png


作用域的查找规则

外层作用域是不能访问内层作用域的,作用域只能由内往外查找,在当前的作用域中找不到变量时,会向上一级的作用域查找,直到全局作用域

补充:暂时性死区

只要一个 {} 块级作用域里,用了 let / const 声明变量,这个变量就直接绑定当前代码块。在代码块内、变量声明语句之前的所有位置,都属于这个变量的暂时性死区。哪怕外部全局有同名变量,在这里也完全访问不到,强行提前访问直接报错。简单说:块内声明了 let 变量,这块区域就彻底和外部同名变量「隔绝」了。如下图所示

3d9320f08e5ae3dcc4f78d7644288a67.png


JS 中 关于变量var、let、const 的全部区别

1. 重复声明的权限不同

var 的宽松度极高,在同一个作用域内,可以对同一个变量重复多次声明,程序不会报错,后续的声明会直接覆盖掉前面的内容。

letconst 有着严格的语法限制,同一个作用域里面,绝对不允许重复声明同一个变量,只要重复声明,代码就会直接报错。

2. 变量数值的修改权限不同

varlet 声明的都是普通变量,在完成声明和初次赋值之后,后续代码里可以随时随地修改变量内部存储的值

const 专门用来声明常量,在完成第一次声明并且赋值之后,这个变量的值就被彻底锁死,后续永远不允许再修改

3. 各自管辖的作用域范围不同

我们先明确 JS 的三类作用域:全局作用域、函数作用域、{} 代码块形成的块级作用域。

  • var 的管辖范围很窄:只认可全局作用域、函数作用域,完全无视块级作用域,代码块的大括号根本限制不住 var 声明的变量。
  • letconst 的管辖范围完整全面:全局、函数、块级三类作用域全部支持{} 代码块可以完美限制住这两种变量,实现变量的私有化隔离。

4. 变量提升与暂时性死区的区别

首先纠正一个新手误区:varletconst 三者全部都会发生变量提升,三者的区别不在于有没有提升,而在于提升之后的访问规则。

  • var 完成变量提升后,会自动初始化一个默认值 undefined。因此在变量声明语句之前,提前访问这个变量不会报错,只会拿到默认值 undefined
  • letconst 虽然同样会发生变量提升,但是会被暂时性死区全程锁定。在当前作用域内,变量声明语句执行之前的所有区域都属于死区,只要提前访问变量,代码就会直接报错

JavaScript 拾遗: WeakRef 和 FinalizationRegistry

作者 helloweilei
2026年4月21日 21:52

在 JavaScript 的内存管理世界里,WeakRefFinalizationRegistry 是两位“高级管家”。它们允许你处理对象的垃圾回收(GC)逻辑,而不会阻碍回收进程。

简单来说,它们是为了解决**“既想追踪对象,又不希望对象因为我的追踪而死活不掉”**的问题。


1. WeakRef (弱引用)

通常我们创建一个引用(比如 const a = { name: 'Gemini' }),只要 a 还在,对象就不会被回收。这叫强引用

WeakRef 允许你持有一个对象的弱引用。如果一个对象只剩弱引用指向它,垃圾回收机制(GC)照样会把它收走。

核心用法

  • 创建: new WeakRef(targetObject)
  • 读取: .deref()。如果对象还没被回收,返回该对象;如果已被回收,返回 undefined

JavaScript

let user = { name: "Alice" };
const weakUser = new WeakRef(user);

// 只要 user 没被回收
console.log(weakUser.deref().name); // "Alice"

// 手动断开强引用
user = null; 

// 在未来的某个时刻,GC 运行后:
// weakUser.deref() 会变成 undefined

使用场景

  • 缓存系统: 缓存大型对象(如图像或数据表),当内存紧张且没有其他地方使用这些对象时,允许 GC 自动清理它们。

2. FinalizationRegistry (清理注册表)

如果说 WeakRef 是用来“看”对象还在不在,那么 FinalizationRegistry 就是用来在对象被回收后“收尸”的。

它让你注册一个回调函数,当某个对象被销毁时,这个回调会被触发。

核心用法

  1. 定义注册表: 指定清理逻辑。
  2. 注册对象: 将对象和你想传递给回调的“持有的值(heldValue)”绑定。

JavaScript

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`对象已被回收,清理标记为: ${heldValue}`);
});

let obj = { data: "important" };
// 注册 obj,当它被回收时,回调函数会收到 "some_id"
registry.register(obj, "some_id");

// 断开引用,等待 GC
obj = null;

使用场景

  • 外部资源释放: 当 JS 对象被回收时,自动关闭与之关联的 WebSocket 连接、释放 C++ 层面的文件描述符或清理 DOM 节点。

3. 它们之间的协作关系

这两者经常配合使用,构建高效且不泄露内存的系统。

特性 WeakRef FinalizationRegistry
关注点 回收前(尝试访问可能消失的对象) 回收后(执行善后工作)
主要方法 .deref() .register(), .unregister()
主要目的 节省内存,避免缓存导致的内存泄漏 资源清理,释放非 JS 内存资源

⚠️ 开发者必读:使用禁忌

虽然这两个工具很强大,但它们带有一种**“不确定性”**,使用时必须非常谨慎:

  1. 垃圾回收是不确定的: 你无法预测 GC 什么时候运行。即使你把引用设为 nullFinalizationRegistry 的回调可能在 1 秒后触发,也可能在 1 小时后触发,甚至永不触发。
  2. 不要在回调里写核心业务逻辑: 清理回调应该是辅助性的(如打日志、释放非内存资源)。永远不要依赖它来执行程序逻辑的关键步骤。
  3. 避免“对象复活”:FinalizationRegistry 的回调里,你拿不到原对象(它已经死了),你只能拿到注册时传入的 heldValue

一句话总结:

WeakRef 让你偷看垃圾桶里的东西还在不在,而 FinalizationRegistry 负责在垃圾车拖走东西后给你发个短信通知。

从一道面试题学会"读出思路":Promise 并发归约的拼图过程

作者 yuki_uix
2026年4月21日 21:47

有些题目,知识点你全都学过,就是没写出来。

这道题就是这样。Promise.all 我用过,二分拆数组练习过,递归思路也写过——但坐在那里看着题目,脑子里这三块东西各自飘着,就是没拼在一起。

后来我意识到:问题不是"不知道",而是不知道怎么从题目里读出信号,把分散的知识激活。这篇文章就是在复盘这个读题 → 拆解 → 拼接的过程。


先读题,不要急着写代码

const addRemote = async (a, b) => new Promise(resolve => {
  setTimeout(() => resolve(a + b), 1000)
})

async function add(...inputs) {
  // 你的实现
}

刚看到题目时,我的第一反应是:这不就是数组求和吗,循环一遍不就好了?

但这道题有两个关键限制,藏在题目结构里,值得逐条拎出来。

限制一:addRemote(a, b) 只接受两个参数。

意味着多个数字无法一次性求和,必须拆成多次两两相加。如果有 n 个数,至少需要调用 n-1 次。

限制二:每次调用耗时约 1 秒(setTimeout 1000ms)。

这个细节是关键信号。出题人特意设置了延迟,暗示的是:如何安排调用顺序,决定了总耗时。如果所有调用只能串行,n 个数就要等 (n-1) 秒。但如果可以并发——

这时候第一个问题自然就出来了:哪些调用可以同时发出去?


第一步:识别"可并发"的结构

带着这个问题重新看题目,想象 inputs = [1, 2, 3, 4, 5, 6, 7, 8] 八个数。

串行的方案是:

add(1,2) → 结果3
add(3,3) → 结果6
add(6,4) → 结果10
...依次等待,共 7 次,7

每一步都依赖上一步的结果,没有任何并发空间。

但如果换个角度:把互相独立的数先两两配对,它们之间没有依赖关系,就可以同时发出去:

Round 1add(1,2)  add(3,4)  add(5,6)  add(7,8)  → 4 个请求同时发出,等 1 秒
Round 2add(3,7)  add(11,15)                      → 2 个请求同时发出,等 1 秒
Round 3add(10,26)                                 → 1 个请求,等 1 秒
总耗时:3 秒,而不是 7

这个结构有个名字:二分归约。把数组两两配对,每轮并发处理,结果收拢后进入下一轮,直到只剩一个数。

到这里,我从题目里读到了两个信号:

  1. "只接受两个参数" → 必须两两操作 → 自然联想到两两配对、二分
  2. "固定 1 秒延迟" → 出题人在暗示时间是变量 → 要想办法让调用并发起来

第二步:把结构翻译成代码工具

现在结构清楚了,下一步是:用什么工具来实现"同时发出多个请求,等所有结果回来"?

这时候 Promise.all 就被激活了。

它的语义刚好匹配这个需求:接收一个 Promise 数组,并发执行,等全部完成后返回结果数组。

// 环境:Node.js / 浏览器
// 验证 Promise.all 的并发语义

const p1 = new Promise(r => setTimeout(() => r('A'), 1000));
const p2 = new Promise(r => setTimeout(() => r('B'), 1000));
const p3 = new Promise(r => setTimeout(() => r('C'), 1000));

console.time('parallel');
const results = await Promise.all([p1, p2, p3]);
console.timeEnd('parallel'); // ~1000ms,而不是 3000ms
console.log(results); // ['A', 'B', 'C']

三个独立的 1 秒请求,Promise.all 让它们并发,总耗时还是约 1 秒。这正是每一轮我们需要的行为。


第三步:找到"每轮之后"的逻辑,识别递归结构

现在我有了"每轮怎么做":把当前数组两两配对,用 Promise.all 并发执行,拿到结果数组。

但还差一步:拿到结果数组之后,怎么办?

结果数组其实和原始 inputs 的结构是一样的——都是一组等待被求和的数字,只是变少了。这意味着:可以把同一套逻辑重新用在结果数组上

这是识别递归的典型信号: "下一步的结构和当前步骤相同,只是规模缩小了"

加上终止条件——当数组只剩一个数时,直接返回——递归结构就完整了:

add([1,2,3,4,5,6,7,8])
  → Round 1 results: [3, 7, 11, 15]
  → add([3, 7, 11, 15])
    → Round 2 results: [10, 26]
    → add([10, 26])
      → Round 3 results: [36]
      → return 36  ✓

把三块拼在一起:完整实现

现在三个知识块的角色都清楚了:

  • 二分配对:决定每轮如何拆分 inputs
  • Promise.all:让每轮的请求并发执行
  • 递归:把"每轮之后拿到新数组"和"对新数组重复同样操作"连接起来
// 环境:Node.js 14+ / 现代浏览器
// 场景:多个异步加法的最优并发归约

async function add(...inputs) {
  // base case: single element, nothing to add
  if (inputs.length === 1) return inputs[0];

  // build concurrent pairs for this round
  const pairs = [];
  for (let i = 0; i < inputs.length; i += 2) {
    if (i + 1 < inputs.length) {
      // normal pair
      pairs.push(addRemote(inputs[i], inputs[i + 1]));
    } else {
      // odd element: carry forward without a remote call
      pairs.push(Promise.resolve(inputs[i]));
    }
  }

  // fire all pairs concurrently, wait for all results
  const results = await Promise.all(pairs);

  // recurse with the reduced array
  return add(...results);
}

奇数元素的处理值得单独说一句:当 inputs 长度为奇数时,最后一个元素没有配对对象。用 Promise.resolve(inputs[i]) 把它原样"包装"成 Promise,和其他请求一起放入 Promise.all,这样结构上保持统一,也不浪费一次远程调用。


复杂度分析

回头看这个执行结构,其实是一棵并发执行的完全二叉树

  • 叶节点:原始输入(n 个)
  • 内部节点:每次 addRemote 调用(共 n - 1 次)
  • 树高⌈log₂n⌉,即总轮数,也是实际等待的秒数
n(输入个数) 串行方案耗时 二分方案耗时
4 3 秒 2 秒
8 7 秒 3 秒
64 63 秒 6 秒
1024 1023 秒 10 秒

时间复杂度从 O(n) 降到了 O(log n) ,调用次数仍然是最少的 n - 1 次。


同一套方法,换三道题来验证

方法论只说一遍不够,要能迁移才算真的理解。下面用同样的框架——先找约束,再识别结构,再选工具——来拆解三道看起来"不相关"的题。


例一:并发请求图片,但最多同时发 3 个

题目是这样的:给定一批图片 URL,要求并发加载,但同时进行中的请求不能超过 3 个。

先读约束:

"并发加载" → 不是串行,需要同时发多个请求,Promise.all 的方向。

"不超过 3 个" → 但不是全部并发,有上限。这是新的约束,意味着 Promise.all 直接用不够,需要一个"滑动窗口":有请求完成时,立刻补进来新的,保持始终有 3 个在飞。

这个结构有个描述:并发控制池。请求完成一个,槽位释放一个,马上填入下一个。

// 环境:浏览器 / Node.js
// 场景:限制最大并发数为 concurrency 的批量请求

async function loadWithLimit(urls, concurrency = 3) {
  const results = new Array(urls.length);
  let index = 0;

  async function worker() {
    while (index < urls.length) {
      const current = index++;                        // claim a slot
      results[current] = await fetch(urls[current]); // process it
    }
  }

  // start exactly `concurrency` workers, each loops until exhausted
  await Promise.all(
    Array.from({ length: concurrency }, worker)
  );

  return results;
}

这里有一个容易误读的地方:看到 Promise.all 加上数量限制,很容易以为执行方式是"每批 3 个,等这批全完成再开下一批"——

// 误以为是这样:
Round 1: fetch(url[0])  fetch(url[1])  fetch(url[2])  → 等全部完成
Round 2: fetch(url[3])  fetch(url[4])  fetch(url[5])  → 等全部完成

但实际上不是。Array.from({ length: 3 }, worker) 启动的是 3 个各自独立跑 while 循环的 worker,它们共享 index 这个取号机。每个 worker 完成一个请求后,立刻自己去取下一个号,不等其他 worker。

具体走一遍,假设有 6 个 URL:

初始:index = 0

worker-1:current = 0,index → 1,开始 fetch(url[0]),await,暂停
worker-2:current = 1,index → 2,开始 fetch(url[1]),await,暂停
worker-3:current = 2,index → 3,开始 fetch(url[2]),await,暂停

此刻飞行中:url[0], url[1], url[2]

假设 url[1] 最先完成,worker-2 从 await 恢复,继续 while:
worker-2:current = 3,index → 4,开始 fetch(url[3]),await,暂停

此刻飞行中:url[0], url[2], url[3]  ← url[0] 和 url[2] 还没完成,url[3] 已经开始了

任何时刻飞行中的请求始终维持在 3 个,谁先完成谁先取下一个任务,不空转。批次模式里,这一批最慢的请求会拖住所有人;worker 池没有"这一批"的概念,快的 worker 永远不等慢的。

这个模式能工作,依赖两个前提:任务之间相互独立(谁先做谁后做不影响结果),以及 index++ 是同步操作(JS 单线程保证不会两个 worker 拿到同一个号,多线程语言里这里需要加锁)。

回头对比原题:add 每一轮依赖上一轮的结果,任务之间有依赖,没法让 worker 自由抢占,只能按轮次显式控制。 "任务之间有没有依赖",是选择 worker 池还是按轮次归约的那个约束。


例二:实现 pipe,把多个函数串起来

这道题不涉及异步,但读题路径和原题几乎是镜像:

// 要求:pipe(f, g, h)(x) 等价于 h(g(f(x)))
function pipe(...fns) {
  // 你的实现
}

读约束:

"每个函数只接受一个参数" → 和 addRemote 只接受两个数一样,是操作粒度的限制,意味着必须多步串联。

"前一个函数的输出是后一个函数的输入" → 每步之间有数据依赖,不能并发,只能串行。这和原题的串行阶段一样,但原题想办法破除了串行,这道题的串行依赖是无法破除的——题目本身就是在建模串行。

这两个约束读出来后,结构就清楚了:线性归约,每步把上一步的结果传给下一步。工具是 reduce——它刚好描述的是"用一个函数把数组折叠成一个值,每步的中间结果传递给下一步"。

// 环境:浏览器 / Node.js
// 场景:函数组合,从左到右执行

function pipe(...fns) {
  return (x) => fns.reduce((acc, fn) => fn(acc), x);
}

// usage
const process = pipe(
  x => x * 2,
  x => x + 1,
  x => `result: ${x}`
);
console.log(process(3)); // "result: 7"

和原题的对比很有意思:同样是"只能两两操作"的约束,原题因为操作之间相互独立,所以可以并发;这道题因为操作之间有依赖,所以只能串行。 约束不同,结构不同,工具不同——但读题的框架是同一套。


例三:实现 debounce

// 要求:在事件持续触发时,只在停止触发后的 delay 毫秒执行一次
function debounce(fn, delay) {
  // 你的实现
}

读约束:

"持续触发时不执行" → 不是每次调用都触发,说明需要某种"抑制"机制,已有的定时器需要被取消。

"停止后 delay 毫秒才执行" → 每次新触发都重置等待时间,意味着要清掉上一次的计时,重新开始。这是"重置"语义。

"只执行一次" → 是最后那次触发后的延迟结束时执行,不是第一次,也不是每次。

三个约束叠加描述了一个结构:维护一个定时器,每次触发时清掉它(clearTimeout)再重新设(setTimeout),只有没有被清掉的那次才真正执行

// 环境:浏览器
// 场景:输入框搜索、窗口 resize 等高频事件节流

function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    // cancel previous pending execution
    clearTimeout(timer);
    // reschedule
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

这道题几乎没有什么"知识点"需要记忆——setTimeoutclearTimeout 人人都知道。真正的难点在于:能不能从"持续触发时抑制、停止后执行"这个描述里,读出"每次都重置定时器"这个结构

读出来了,代码几乎是自然而然写出来的。


小结:读题是一种可以练习的能力

把这四道题放在一起看,读题路径是一致的:

1. 把约束逐条拎出来:不要整体看题,要把每一句限制单独列出来,想想它在暗示什么。

2. 把约束翻译成结构描述:不是"这道题要用 Promise.all",而是"这道题有一组相互独立的操作,需要同时发出、统一等待结果"——先用自然语言描述结构,工具是最后才出现的。

3. 结构对上了,工具自然浮出来:每个工具背后都有一个它最适合解决的结构问题。Promise.all = 独立操作并发等待,reduce = 线性归约,clearTimeout + setTimeout = 重置式延迟执行。记住的是"结构—工具"的映射,而不是"题型—答案"的映射。

这套路径练多了,题目里的约束会越来越像"提示词",而不是干扰信息。

下次遇到不会的题,与其直接看答案,不如先问自己:这道题的约束,在描述一个什么样的结构?


参考资料

【Command】project-knowledge-init

作者 Hchao
2026年4月21日 21:44
description: 初始化项目知识库
agent: project-knowledge-manager
subtask: false

初始化项目知识库

你现在作为主代理(agent),在当前项目目录下初始化项目知识库。拆分任务分发给子代理(subAgent)执行,不要自己执行。

  • 项目目录路径:!pwd
  • 知识库默认目录:knowledge

任务流程规则

【重要提示】必须严格按照以下步骤依次进行:

Step1:加载背景知识

  1. 读取项目中的关键文档(如README.mdCONTRIBUTING.md等),补充背景知识。
  2. 确认任务指定的目录下(默认为项目根目录)是否已经存在知识库:
    • 根目标目录层级下检索:knowledgedocs等关键词(部分匹配,不要求完全匹配),检查是否存在相关目录。
    • 阅读对应目录下的文档内容,判断是否是项目知识库(文档数量多,文档内容丰富等维度进行判断)
  3. 如果找到了对应知识库目录,阅读目录下的所有文档,了解知识库现状;

Step2:规划知识库更新任务

根据现有知识库现状,规划知识库的更新任务:

  • 如果当前知识库不存在,则根据项目代码结构和知识分类,创建一个新的知识库目录结构。
  • 如果当前知识库已经存在,则结合现有目录结构和内容,制定查漏补缺方案;

git记录查找:如果当前知识库已经存在,则可以根据项目的git历史记录,查找哪些文件或目录是最近新增或修改的。

  • 检查当前项目是否为git项目(检查项目根目录是否存在.git目录),如果不是git项目,则忽略此步骤;
  • 检查是否存在内置git的tool工具,如果存在,尝试使用该工具执行git命令;
  • 如果不存在,则尝试使用bash等工具执行git命令;
  • 检查最近1个月的提交记录及修改的文件清单,确认哪些文件或目录是最近新增或修改的。
  • 参考指令:git log --oneline --name-only --since="1 months ago"

任务的制定要参考以下内容和顺序:

  1. 知识库入口文件:这是必须要首先构建的,保存整个项目的概述,以及知识库目录的导航地图。
  2. 模块地图文档:这个也是要在入口文件构建完成后,紧接着需要构建的,介绍项目的所有功能模块。
  3. 模块知识文档:每个功能模块都有一个对应的目录,目录下包含该模块的所有知识文档。
  4. 产品知识文档:产品的定位,功能描述等。

注意事项:

  • 知识库的构建是有偏序关系的,有些文档的构建依赖于其他文档的构建。需要正确的安排任务顺序。
  • 当所有必要知识文档都构建完成后,最后一个任务是更新知识库入口文档中的知识库地图,保证知识库地图展示的是最新的知识模块目录结构。

Step3:执行知识库更新任务

调用project-knowledge-manager代理,让其作为subAgent执行具体的知识库更新任务,依次完成上述所有任务。提示词参考如下:

你现在作为子代理(subAgent),负责执行一个具体的知识文档构建任务:

## 任务描述:
- 【描述当前让subAgent执行的具体知识库更新任务】
- 【本次任务应该构建的知识内容,范围】

## 参考文档

执行任务之前,以下这些任务是你必须要阅读的:
- 【传给subAgent的参考文档,帮助其快速理解任务上下文】
- 【重点是当前已经构建出来的那些知识文档,如知识库入口文件,模块地图等】
- 例如:当前已经构建出来的知识库入口文件是`knowledge/overview.md`,模块地图文件是`knowledge/module-map.md`。

## 更新文档列表:
- 【指定当前让subAgent更新的具体文档路径】
- 【对于一个模块知识的构建任务,本身要更新的文档可能还不存在或者不确定,此时指定一个文件夹】

## (可选)参考文档 / 目录:
- 【可供参考的文档或目录,帮助subAgent理解文档结构】

## 参考搜索范围:
- 【指定当前让subAgent搜索的代码范围,如项目根目录、特定模块目录等】


## 注意事项

在执行具体的知识库搜索构建任务时,有以下注意事项:

- 更新文档前,首先必须先阅读对应文档内容,基于当前内容进行更新;
- 先理解再执行,任务开始前,先加载必要的背景知识,包括任务指定的参考文档,以及项目根目录下的README.md等文件。
- 知识的构建过程应该是多伦迭代的,先整体再局部,先粗略再详细,层层递进。
- 如果内置工具中存在“GitNexus”工具,建议优先使用该工具进行代码搜索。

Step4:内容迭代优化

经过前面的步骤,已经完成了知识库的初始化或更新任务。但生成的内容可能存在冗余、过时或不严谨问题,需要进行严格的减法式迭代优化,确保知识库内容精简、准确、高效。

使用task工具,调用project-knowledge-manager代理(此任务专属agent),让其作为subAgent执行知识库的迭代更新任务,提示词参考如下:

你现在作为子代理(subAgent),对当前的知识库内容进行**严格审核与减法优化**,以极简原则重构知识库,重点移除冗余、过时和无效内容:

## 知识库目录范围:
- 【指定当前让subAgent检查的知识库目录范围,如项目根目录下的knowledge目录】

【重要】请严格遵守流程规则3的要求,执行当前任务。

## 执行要求

- 审核必须**逐文档、逐段落、逐句子**进行,不得遗漏
- 优化后必须保持文档结构的连贯性和完整性
- 更新文档前必须先阅读对应文档的完整内容
- 确保优化后的知识库内容密度更高、更精准、更易维护

Step5:结果检查

使用task工具,调用project-knowledge-manager代理(此任务专属agent),对本次任务的执行成果进行验收,参考提示词:


你现在作为子代理(subAgent),对当前项目的知识库内容进行验收。

- 知识库目录路径:${知识库目录路径(相对于项目根目录)}
- 知识库入口文档路径:${知识库入口文档路径(相对于项目根目录)}

输出结果约束:

- 最终输出所有检查有问题的文件,并列举出每个文件的具体问题和改进建议。
- 禁止直接修改文件,只能输出检查结果。

检查以下内容:

## 目录结构检查

[ ] 检查知识库目录是否存在;
[ ] 是否存在入口文档(如overview.md)、模块地图文档(如module-map.md);
[ ] 知识库目录结构整体是否符合规范;

## 文档内容检查

[ ] 单个知识文档是否符合文档内容的Checklist;
[ ] 知识库入口文件中的知识库地图是否展示了最新的知识模块目录结构;
[ ] 知识库中是否存在模块地图,模块地图是否符合标准规范。
[ ] 每个模块是否都有对应的知识文档目录,目录下是否包含所有必要的知识文档。
[ ] 模块内部是否存在目录结构地图,目录结构地图是否符合标准规范。

如果检查结果不通过,需要根据检查结果,回到第2步重新规划任务,确保知识库的更新任务能够顺利完成。

Step6:注入AGENTS.md文件

检查当前项目根目录下是否存在AGENTS.md文件,如果不存在,则创建一个空文件AGENTS.md。

在AGENTS.md文件的合适位置添加以下内容(如果文档中已存在相关内容,则忽略此步骤):

## 项目知识库

- 当前项目知识库位置为:${项目知识库路径(相对于项目根目录)}
- 入口文档为:${知识库入口文档路径(相对于项目根目录)}

- 项目知识库中包含了当前项目的所有知识文档,包括但不限于:
  - 产品功能概述文档(overview.md)
  - 模块地图文档(module-map.md)
  - 模块知识文档(每个模块有一个目录,目录下包含该模块的所有知识文档)
  - 产品知识文档(如产品定位、功能描述等)
  - 编码规范文档(如代码风格指南、命名规范等)
  - 其他与项目相关的知识文档(如项目架构图、部署文档等)
- 在执行任务前,请根据当前任务需要,优先阅读相关的知识文档,确保对项目的理解是准确的。
❌
❌