阅读视图

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

面了3个人后我发现:AI用得最溜的,未必是我最想要的工程师

这两周,我面试了 3 个人,技术岗。

现在聊技术面试,很多人默认一个前提:

大家都想要那种 AI 用得很溜的人。

但我实际聊下来,发现真正需要的,未必只是这种人。

真正稀缺的,是有判断力的人。

原因很简单。

怎么用 AI,其实不难。

真正难的,是你能不能想到:这东西原来还能这么玩。

所以我面试一个人,看的已经不只是会不会写代码了。

我会更关注两件事。

第一,他有没有真正搞懂自己的项目到底是怎么跑起来的。

第二,他借助 AI,已经能做到什么程度。

因为 AI 工具换得太快了。

今天你熟这个,明天可能就冒出来一个更强的。

产品化的 AI 工具,上手门槛其实非常低。很多时候根本不需要培训一两天,可能装上就能开始干活。真正的门槛,反而变成了愿不愿意用,或者怎么把它装起来。

我面试的一个人,就是很典型的例子。

5 年经验,使用 AI 写代码非常快,效率也确实高,已经能干过去两个自己干的活。

乍一看,这种人很亮眼。

但我往下问了一层,问题就出来了。

工具是公司给的。

他对 AI 为什么能做到这件事,几乎不知道。

再继续问项目本身是怎么运转起来的,也没有想象中那么清楚。

更关键的是,在 AI 带来的提效之后,他原本的技术成长,反而停住了。

而且这一停,就是 1 年。

最后呈现出来的状态就是:能力和年限不匹配。

他能提效的核心原因,不是自己突然变得特别厉害,而是公司提供的工具真的很好用。

换句话说,他没有形成真正属于自己的能力。

但现在这个环境,资历浅的开发工程师,如果还想单靠传统技术路径往上升,其实已经越来越难了。

不是说技术不重要了。

而是现实情况是,很多人已经没有那么多机会,去亲手解决那些真正有深度的问题。

就好比每次课后作业,你都可以参考班里那个学习最好的同学,老师也只要求你把结果交上去就行。

在这种环境里,你当然能完成任务。

但如果哪天碰到一道连那个好学生都不会的题,你真的还能指望自己上场就更强吗?

相比之下,我面试的另外一个人,反倒是我更愿意高看一眼的类型。

8 年经验,因为行业限制,他没有很深入地接触 AI。

如果只看这个点,他不算亮眼。

但他的技术底子很扎实,解决问题的思路也很清楚。很多东西你一问,就知道底子在,很多能力是实打实沉淀过的。

更重要的是,他不是排斥 AI。只是之前没有足够多的机会,系统地去用。

我问了他一个问题:如果你有机会提效,但是要付出成本。你每个月愿意最多出多少预算?

每个人心里有自己的一杆秤。他的回答我认同。

所以我不会因为他现在 AI 这块不够亮眼,就刻意把评价压低。

相反,我会按照他真实的技术能力去评价他。

原因也很简单。

AI 这件事,学起来没有很多人想得那么玄。

尤其对于一个技术基本功强的人来说,他理解 AI、接住 AI、用好 AI 的成本,只会比别人更低。

这类人最缺的,很多时候不是能力,而是一个真正拥抱 AI 的决心。

很多高级工程师的问题,也不在能力,而在心理上。

这类人太容易相信自己过去那套路径了,也太容易依赖原有经验了。

结果就是,AI 他们不是不会用,而是总用得别别扭扭,总比别人慢半拍。

说白了,就是不太愿意把自己重新当成新人。

但偏偏,AI 时代最强的一批高级工程师,恰恰是那些愿意重新归零的人。

你越强,越要学会承认一件事:

有些问题,AI 解决得就是比你快,比你广,比你不知疲倦。

这不丢人。

跟计算器较劲的人,最后往往不是数学家,而是算盘手。

那么问题来了。

绝大多数人并没有很强的技术功底,以后又越来越少有机会,去独立解决复杂问题。

那怎么办?

我觉得,未来评判一个工程师,至少会越来越看重两个标准。

第一,一个人借助 AI,自己能代替过去多少人古法手搓的活。

第二,一个人借助 AI,能够帮助多少人提升效率。

一个是给自己提效。

一个是给别人提效。

显然,第一个方向更适合大部分人。

它有点像在美国大平原种田。

当机械化工具来了之后,就不再需要那么多传统农民了。一个农场主,可能就能干过去 100 个农民的工作量。

放到工程师身上,其实也是一样的。

借助 AI,一个人未来能吃下的工作量,一定会越来越大。

但这个方向也更卷,更残酷。

因为这个行业里的“农民”太多了,而未来未必还需要这么多人。

甚至很多后发的人,反而可能更有优势。

因为他们天生包袱更少,也总能更快接住新工具,精力也更充沛。

给自己提效还有一个思路,就是把手往上游和下游伸。

去抢那些其他岗位的饭碗,比如产品经理、项目主管、测试、运维等这些角色。

说得再直接一点:

如果你能做他们做的事情,还能把整条链路的事情一起做完,那企业为什么还要按原来的方式配那么多人?

这其实就是一种降维打击。

写到这里,其实我真正想说的,只有一句话:

AI 时代当然要学 AI。

但别把“会用 AI”,误以为是全部。

因为工具会越来越强,教程会越来越多,门槛会越来越低。

真正拉开差距的,依然还是那些更底层的东西。

比如你的判断力。

比如你的问题拆解能力。

比如你的架构思维。

比如你的学习能力。

以及最重要的一点:

你有没有勇气,重新把自己当成一个新人。

真正厉害的人,不是手里拿着多少个 AI 工具的人。

而是即使工具天天在变,也总能把问题解决掉的人。

这种人,放在什么时候,都不会太差。

2026 年前端面试问什么

背景

由于所在的外企撤出中国,我再次开始了面试之旅。这次我没有选择大厂和小公司,而是主要聚焦在外企和中厂。经过一段时间的面试,我发现 2026 年的前端面试已经发生了显著的变化,特别是 AI 相关的内容占比大幅提升。

面试内容分布

根据我的面试经历,2026 年前端面试的内容分布大致如下(本人接近十年工作经验,仅供参考):

  • coding 20% (LeetCode 算法题和手写代码各一半吧)
  • 八股文:20%(主要是 React Fiber 等核心原理)
  • 项目经历:30%
  • 系统设计:10%(如设计一个支付页面)
  • AI相关问题:20%(这是 2026 年的新重点)

可以看到,相比几年前,AI 相关的内容已经成为面试的重要组成部分。

常见 AI 面试问题

在我面试的过程中,几乎每家公司都会问到以下问题:

  1. 你的日常 AI 工作流是什么?
  2. 如何保证 AI 生成代码的质量?
  3. 你使用哪些 AI 工具?各自的优势是什么?

下面我会详细讲解我的答案。

我的 AI 工作流

我将 AI 工作流分为五个阶段,每个阶段都有明确的目的和技术方案:

1. 需求前:Context 优化

目的:让 AI 充分理解项目上下文,提供高质量的代码生成

技术方案

  • 生成并维护项目 Rule 文件,定义代码规范和架构约束
  • 配置 MCP 服务,提供项目特定的上下文
  • 配置 Skills,为特定任务提供专业知识
  • 生成 Onboarding 文档和 README,帮助 AI 快速理解项目
  • 设定清晰的输入输出规范
  • 定期更新项目总结文档,保持 AI 对项目状态的同步

2. 需求分析:定义问题和约束

目的:明确需求,分析技术方案,设定实现步骤

技术方案

  • 使用 AI 进行需求拆解和分析
  • 让 AI 识别潜在的技术风险和约束
  • 生成详细的实现步骤和 TODO 列表
  • 评估不同技术方案的优劣

3. 需求实现:AI 生成代码

目的:高效生成高质量代码

技术方案

  • UI to Code:从设计稿直接生成组件代码
  • 组件生成:生成可复用的 React 组件
  • 逻辑实现:生成业务逻辑和状态管理代码
  • 测试生成:自动生成单元测试和集成测试
  • 任务拆分:将大任务拆分为小任务,逐步实现
  • 设计优先:让 AI 先设计架构,再实现细节

4. 需求验证:自动验证

目的:确保代码质量和功能正确性

技术方案

  • 静态检查:ESLint、TypeScript、Prettier
  • 自动测试:Jest、React Testing Library、Playwright
  • CI Pipeline:GitHub Actions、GitLab CI
  • 代码审查:AI 辅助的 Code Review

5. 上线与优化:持续优化

目的:持续改进代码质量和 AI 使用效率

技术方案

  • Code Review 反馈:收集团队对 AI 生成代码的反馈
  • 线上监控:监控 AI 生成代码的运行表现
  • Prompt 优化:根据反馈优化 AI 提示词
  • 知识库更新:将最佳实践沉淀到知识库

AI 代码质量保障体系

面试官通常会追问:如何保证 AI 生成的代码质量?我的答案是建立四个阶段的质量保障体系:

开发前:规范与架构约束

  • 制定详细的代码规范(Rule 文件)
  • 定义架构约束和设计模式
  • 配置 AI 的上下文和知识库
  • 设定代码生成的边界条件

开发中:静态质量检查

  • 高质量的 Prompt:清晰、具体、包含上下文
  • 充分的上下文:提供相关代码、文档、历史记录
  • 可复用的 Skill:沉淀常见任务的最佳实践
  • 实时反馈:及时纠正 AI 的错误方向

高质量 Prompt 的要素

一个好的 Prompt 应该包含:

  1. 明确的目标:要实现什么功能
  2. 具体的约束:技术栈、代码规范、性能要求
  3. 充分的上下文:相关代码、接口定义、业务逻辑
  4. 期望的输出:代码、文档、测试等
  5. 质量要求:类型安全、错误处理、可访问性

提交时:自动化测试与 Code Review

  • 运行完整的测试套件
  • 执行静态代码分析
  • AI 辅助的 Code Review
  • 人工 Review 关键代码

运行时:监控与反馈

  • 错误监控和告警
  • 性能指标追踪
  • 用户行为分析
  • 持续优化迭代

我使用的 AI 工具栈

面试官通常会问你使用哪些 AI 工具。我的回答是:

  1. Cursor:主力 IDE,集成了 AI 编程助手

    • 用于:日常编码、代码补全、重构、生成测试
    • 优势:深度集成开发环境,理解项目上下文
    • 使用频率:每天 80% 的编码时间
  2. Claude / GPT-4:用于复杂问题的分析和方案设计

    • 用于:架构设计、技术方案评估、复杂问题分析
    • 优势:强大的推理能力,能够处理复杂的上下文
    • 使用场景:需求分析、技术选型、疑难问题解决
  3. ChatGPT:学习和快速查询

    • 用于:新技术学习、API 查询、快速问答
    • 优势:响应快速,适合碎片化学习

每个工具都有其适用场景,关键是要知道什么时候用什么工具。

如何看待 AI 与程序员的关系

这是面试官经常会问的一个开放性问题。我的回答是:

AI 是放大器,不是替代品

  • AI 让优秀的工程师更加高效,但不能让不合格的工程师变得合格
  • AI 擅长执行明确的任务,但不擅长理解模糊的需求
  • AI 可以生成代码,但不能做出架构决策
  • AI 可以提供建议,但不能承担责任

我的使用原则

  1. AI 负责执行,人负责决策

    • 架构设计、技术选型由人来做
    • 具体实现、测试生成由 AI 来做
  2. AI 负责初稿,人负责精修

    • AI 生成代码的初稿
    • 人进行 Review 和优化
  3. AI 负责重复,人负责创新

    • 重复性的 CRUD、样板代码由 AI 生成
    • 创新性的解决方案由人来设计
  4. 持续学习,保持竞争力

    • AI 在进化,我们也要进化
    • 学习如何更好地使用 AI
    • 学习 AI 无法替代的能力(架构、业务理解、团队协作)

其他面试内容

Coding(20%)

约 70% 的公司会有 coding 环节。速度很关键,如果你在这一环节耗时太多(超过 20 分钟),你的面试大概率就失败了。

LeetCode 算法题(10%):

  • 数组和字符串操作(高频)
  • DFS 和 BFS(中频)
  • 动态规划基础题(低频)
  • 难度:Medium 为主,偶尔有 Easy 或 Hard

手写代码(10%):

  • 手写 Promise、Promise.all、Promise.race
  • 手写数组拍平
  • 手写promise并发

准备建议

  • LeetCode 刷 100-150 题即可,重点是高频题
  • 手写代码要能讲清楚原理,不是背答案
  • 可以使用 AI 帮你理解算法原理,但要自己手写实现

八股文(20%)

主要集中在 React 核心原理,这部分是外企和中厂都很看重的:

React Fiber 架构(高频必考):

  • Fiber 是什么?为什么需要 Fiber?
  • Fiber 的工作原理:双缓冲、时间切片
  • Fiber 的两个阶段:render 阶段和 commit 阶段
  • 优先级调度机制
  • 可中断渲染的实现原理

Hooks 原理(高频):

  • useState 的实现机制:链表结构、闭包
  • useEffect 的执行时机和清理机制
  • useCallback 和 useMemo 的区别和使用场景
  • 自定义 Hook 的设计原则
  • Hooks 的规则和原因(为什么不能在条件语句中使用)

并发特性(中频):

  • Concurrent Mode 的原理和优势
  • Suspense 的使用场景和实现原理
  • Transitions 和 useTransition 的应用
  • 自动批处理(Automatic Batching)

状态管理(中频):

  • Redux vs Zustand 的对比
  • 什么时候需要全局状态管理
  • Context API 的性能问题和优化
  • 服务端状态管理(React Query / SWR)

性能优化(高频):

  • React.memo、useMemo、useCallback 的使用场景和区别
  • 虚拟列表的实现原理
  • 代码分割和懒加载
  • 如何分析和优化 React 应用的性能

准备建议

  • 不要死记硬背,要理解原理
  • 准备好代码示例,能够现场讲解
  • 可以让 AI 帮你梳理知识点,但要自己消化理解
  • 关注 React 19 的新特性(Server Components、Actions 等)

项目经历(30%)

这是面试的重头戏,也是最能展示你能力的部分。建议准备 3-5 个项目,覆盖不同维度:

  1. 技术深度项目:展示你对某个技术的深入理解

  2. 项目管理项目:展示你的规划和推动能力

  3. 失败的项目:展示你如何应对挫折

  4. 团队协作项目:展示你的沟通和协作能力

回答框架(CARL 模型)

每个项目准备好 CARL 模型的回答:

  • Context(背景):项目背景、面临的挑战、为什么重要
  • Action(行动):你具体做了什么、如何做的、为什么这样做
  • Result(结果):最终的成果、量化的数据、业务影响
  • Learning(收获):学到了什么、如何应用到后续工作

示例:技术深度项目

Context:
公司的管理后台有一个包含 10 万条数据的表格,用户反馈滚动卡顿,
体验很差。传统的分页方案不满足产品需求,需要支持无限滚动。

Action:
1. 性能分析:使用 React DevTools Profiler 定位性能瓶颈
2. 技术调研:对比 react-window、react-virtualized 等方案
3. 方案设计:选择 react-window + 自定义 hooks 实现虚拟滚动
4. 实现细节:
   - 动态行高计算
   - 滚动位置保持
   - 数据预加载
   - 搜索和筛选优化
5. 测试验证:性能测试、兼容性测试

Result:
- 首屏渲染时间从 3 秒降低到 0.3 秒(提升 90%)
- 滚动帧率从 20fps 提升到 60fps
- 内存占用从 500MB 降低到 50MB(降低 90%)
- 用户满意度从 60% 提升到 95%

Learning:
- 深入理解了浏览器渲染机制
- 学会了使用 Performance API 进行性能分析
- 认识到性能优化要基于数据,而不是猜测
- 虚拟化是处理大数据渲染的有效方案

系统设计(10%)

这是外企比较看重的部分。

常见题目

  • 设计一个支付页面
  • 设计一个图片上传和裁剪系统
  • 设计一个实时协作编辑器
  • 设计一个电商购物车系统
  • 设计一个新闻推荐系统

答题框架(RADIO 原则)

  1. Requirements(需求分析)

    • 功能需求:核心功能有哪些?
    • 非功能需求:性能、可用性、国际化等
  2. Architecture(架构设计)

    • 前端架构:组件结构、状态管理
    • 后端架构:API 设计、数据库设计
    • 关键组件及其交互
  3. Data Model(数据模型)

    • 数据结构设计
    • 数据流设计
    • 状态管理方案
  4. Integration(集成方案)

    • API 接口设计
    • 第三方服务集成
    • 前后端通信协议
  5. Optimization(优化方案)

    • 性能优化:缓存、懒加载、CDN
    • 可扩展性:负载均衡、分库分表
    • 可靠性:容错、降级、监控
  6. Deep Dive(深入讨论)

    • 根据面试官的兴趣深入某个技术点

示例:设计一个支付页面

1. Requirements
   - 功能:支持多种支付方式(信用卡、支付宝、微信)
   - 安全:PCI DSS 合规,敏感信息加密
   - 性能:3 秒内完成支付流程
   - 可用性:99.9% 可用性

2. Architecture
   - 前端:React + TypeScript + Tailwind CSS
   - 状态管理:Zustand
   - 表单验证:React Hook Form + Zod
   - 支付 SDK:集成第三方支付网关

3. Data Model
   - 订单信息:订单号、金额、商品信息
   - 支付信息:支付方式、支付状态、交易流水号
   - 用户信息:用户 ID、收货地址

4. Integration
   - POST /api/orders/create - 创建订单
   - POST /api/payments/process - 处理支付
   - GET /api/payments/status - 查询支付状态
   - Webhook 接收支付结果通知

5. Optimization
   - 性能:预加载支付 SDK、使用 CDN
   - 安全:HTTPS、CSP、输入验证、防重放攻击
   - 可靠性:支付失败重试、超时处理、降级方案

6. Deep Dive
   - 如何防止重复支付?使用幂等性设计
   - 如何处理支付超时?轮询 + Webhook 双保险
   - 如何保证支付安全?Token 化、加密传输、风控系统

答题技巧

  1. 先问清楚需求:不要上来就开始设计,先问面试官关于规模、重点等问题

    • 预期的用户规模是多少?
    • 重点关注哪个方面?(性能、安全、可扩展性)
    • 是前端系统设计还是全栈系统设计?
  2. 画图辅助说明:在白板或纸上画出架构图、数据流图

    • 组件结构图
    • 数据流图
    • 系统架构图
  3. 从高层到细节:先讲整体架构,再深入某个模块

    • 不要一开始就陷入实现细节
    • 根据面试官的反馈调整深度
  4. 讨论权衡取舍:展示你的思考深度

    • 方案 A 的优势和劣势
    • 为什么选择方案 B
    • 在什么情况下会选择方案 C

准备资源

  • 《System Design Interview》by Alex Xu
  • YouTube: Grokking the System Design Interview
  • 前端系统设计博客和文章

总结

2026 年的前端面试已经发生了显著变化,AI 工作流成为了重要的考察点。但本质上,面试官想要的仍然是:

  1. 扎实的基础:JavaScript、React、工程化
  2. 解决问题的能力:分析问题、设计方案、实现落地
  3. 持续学习的能力:拥抱新技术、适应变化
  4. 工程素养:代码质量、团队协作、项目管理
  5. AI 时代的新能力:高效使用 AI、保证质量、持续优化

我的核心观点

AI 只是新增的一个维度,它让优秀的工程师更加高效,但不能替代工程师的核心能力。在 AI 时代,我们需要:

  • 更强的架构能力:AI 能生成代码,但不能设计架构
  • 更深的业务理解:AI 能实现需求,但不能理解业务
  • 更好的判断力:AI 能提供方案,但不能做出决策
  • 更高的工程素养:AI 能写代码,但不能保证质量

最后的建议

面试是一个展示自己的机会,也是一个学习的机会。每次面试后,我都会:

  • 记录面试中的问题和自己的回答
  • 分析哪些地方回答得好,哪些地方需要改进
  • 补充不会的知识点
  • 优化下次面试的策略

希望这篇文章能帮助到正在准备面试的你。如果你有任何问题或想要交流面试经验,欢迎在评论区留言!

祝大家都能找到满意的工作!

前端工程化 + AI 赋能,从需求到运维一条龙怎么搭 ❓❓❓

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

企业级前端工程化的本质,是把"人肉重复、靠经验兜底"的开发方式,收敛成可复用、可度量、可演进的一套体系。从零搭建前端时,先想清楚要解决什么、要什么结果,再选工具和流程,会少走很多弯路。

工程化主要针对三类问题:

image.png

把这三块从"人治"变成"机制",工程化才算真正落地。

落到团队层面,能带来几件事。流程上,标准化、能自动化的尽量自动化,关键环节可以借 AI 提效,结果上,开发成本下来、迭代速度上去,代码质量和可维护性提高,bug 和线上风险更容易被提前拦住。这些都不是单点工具能完成的,需要从需求到上线的整条链路一起设计。

接下来我们按常见阶段展开,依次是需求与规范、开发与联调、测试与优化、构建与部署、运维与监控。每个阶段会写目标、推荐流程、常用工具、典型场景,以及适合用 AI 或自动化做得更好的地方。

前端工程化总览

整条链路可以概括为五个阶段,从需求规范到运维监控依次串联。整体追求三个结果,稳(高可用、可回滚)、快(敏捷交付、自动化流水线)、省(低成本工具链、资源复用),下面用一张流程图把阶段关系画清楚。

20260225100028

各阶段侧重不同。需求规范阶段重在建立统一标准、预防潜在风险、提升协作效率,常见动作包括需求与接口规范、文档沉淀与知识库、以及用 AI 做文档自动化。开发联调阶段和测试优化阶段共同指向高效协作、减少阻塞、保障代码质量,前者覆盖基础框架与脚手架、组件与物料库管理、工程化工具链、前后端接口联调与 Mock,后者覆盖单元与 E2E 自动化测试、性能与体积优化、合规与安全扫描、埋点与数据上报。构建部署阶段和运维监控阶段则共同强调高效交付、稳定发布、灵活回滚,构建部署侧重构建与打包优化、CI/CD 部署方案、灰度发布与一键回滚,运维监控侧重性能与可用性监控、异常与错误追踪、用户行为与转化分析、大屏可视化与告警,目标是实时感知风险、快速定位原因、持续优化体验。

下图是同一套阶段与目标的示意,便于对照查阅。

image.png

需求规范阶段

需求规范阶段是整条链路的起点。先把这一步打牢,后面的开发联调、测试优化才不会一路踩坑。这里要做的事,本质上是把团队里各自为战的习惯和经验,沉淀成一套大家都认可的统一标准,既预防潜在风险,又减少日常协作里的摩擦。为了方便梳理,可以把这一阶段拆成三块,对应代码与接口、文档与知识库,以及用 AI 做文档自动化。

下图是这一阶段的手绘示意,可以当作后文三小节的导航来对照着看。

image.png

需求与接口规范

落到开发这侧,最直观的感受就是,大家写出来的代码和提交流程要像是一个团队,而不是各写各的。第一步是把代码规范和协作流程统一,用一套约定来消除协作摩擦。代码这一块,可以用 ESLintPrettier 配合 Husky 去强制约束代码风格,缩进、命名这些细节交给工具,提交前自动跑一遍,不通过就推不上去,讨论就能更多回到设计和实现本身。

协作流程方面,建议一开始就说清楚 Git 分支策略(例如简化版 Git Flow)和 Commit 信息格式,例如用 Commitizen 这样的工具来规范提交说明。久而久之,提交历史会变成一本可以查账的项目日记,谁在什么时间、因为什么调整了哪些代码,一目了然。

这里有两类问题,最好在一开始就通过规范挡住。一类是随手写 fix bugupdate 这类没有信息量的提交信息,事后谁也看不出当时改动的动机。另一类是没经过 Code Review 就把改动直接合进主分支,质量风险一路带到线上。有些团队会要求,所有人都基于 master 拉分支开发,在 testuatrelease 这些共享环境分支以及 master 上都禁止直接 push,只能通过合并请求进入,这样一旦出问题,也能顺着合并记录快速定位到具体改动。

文档沉淀与知识库

文档沉淀这块,目标是打破信息孤岛,让新人靠看文档也能尽量还原当时的需求背景和取舍过程。需求如果只散落在聊天记录里,过一阵子连原作者自己都很难说清楚当时为什么要这么定。比较实用的做法,是用语雀、飞书文档把业务需求拆成技术方案,把功能边界和验收标准写清楚,再准备一套固定的需求文档模板,背景、原型、接口定义这些模块都预留好位置,后面类似需求直接套用,既省事又不容易漏。

接口和设计的配合,同样可以通过工具来固化。可以用 Apifox 维护接口文档,后端接口还没完全就绪时,前端先基于 Mock 数据开发,不必干等。与此同时,联动 Figma、即时设计这类工具里的设计稿标注,让 API 与设计稿保持同步,很多本来要靠口头解释的细节,直接在文档和设计稿里就能对齐。

AI 赋能文档自动化

如果完全手写,一份中等复杂度的技术文档,往往要花上两到四个小时,写着写着还容易走神。现在可以把这种重复性工作交给 AI。例如用 Writely(飞书 AI),输入 PRD 里的关键词(例如"用户管理系统"),让它先生成一份大致合理的技术文档目录和示例代码片段,你再根据实际业务补充细节、删掉不适用的部分。

实际体验下来,传统纯手写从零到一可能要两到四个小时,而让 AI 先搭好骨架、再人工完善,大多数情况下半小时左右就能收工。这样的方式尤其适合需求说明、接口说明、技术方案骨架这类重复度很高的文档,一方面整体结构更统一,另一方面也把时间留给那些必须由人来判断的业务决策和权衡。

开发联调阶段

开发联调阶段是前端工程化真正动手写代码、跑起来的那一段,目标很清晰,就是高效协作、减少阻塞、保障代码质量,让前后端和设计之间尽量无缝衔接。下面按基础框架、物料复用、工程化流水线、前后端协作四块来说,最后补几条联调时容易踩的坑。

image.png

基础框架搭建

框架选型决定了团队未来几年的技术底座,选好了能少踩很多坑。轻量一点、迭代快的项目,可以用 Vue 3ReactVite,开发体验好、上手也快,Vite 后续会集成 Rust 实现的 Rolldown,生产构建会更快。业务比较复杂、偏中后台或需要 SSR 的,可以看 Next.jsRsbuild 等,Next.js 开发环境已支持 Turbopack,大仓冷启和 HMR 更猛。超大体量或需要兼容现有 Webpack 生态的,可以看 Rust 系的 Rspack。运行时除了 Node.js,也可按需选 Bun 做脚本和工具链。要是还有小程序、H5 等多端需求,可以看 TaroUni-App 这类跨端方案,一套代码多端跑。

选完框架,最好再准备一套模板仓库,新项目直接基于模板拉,而不是每次从零配。例如预置好 ESLintPrettier 的脚手架,或者用 Next.jsRsbuild 等自带的脚手架快速生成项目,再按需加权限、数据流等。如果团队里会有多个应用、共享组件库或公共包需要一起维护,可以提前考虑是否采用 Monorepo 架构(例如用 pnpm workspace、TurborepoNx 等),把多包放在一个仓库里统一依赖和构建,能减少后期拆仓、版本对齐的折腾。这一步也可以交给 AI 省时间,例如在 Cursor 里输入"创建 NextJs + TypeScript 项目",让它生成基础配置。

物料库管理

组件、工具函数、页面模板如果能复用,重复开发会少很多。有条件的团队会做企业自研组件库,常见两条路。一条是在 Ant DesignElement Plus 这类开源组件库上做二次封装,贴合自家业务和设计规范,再用 Bit 这类工具管理组件版本和依赖,甚至支持私有化部署。

另一条是,如果团队已经在用 Tailwind CSS,并且用过 shadcn/ui 这类"拷贝即用"的组件方案,可以在现有基础上做二开,例如统一品牌色、间距和圆角等设计 token,把常用变体收拢成团队约定,再补一份内部文档(哪些组件可直接用、哪些改过、使用示例和注意事项),这样既保留 Tailwind 的灵活度,又有一致的设计和可维护的物料沉淀。Tailwind CSS v4 已发布,构建更快、配置更简单,新项目可以直接上 v4。工具函数这块,用 lodashdayjs 等成熟库即可,不必自己造。

AI 在这块也能帮上忙。例如即时 AI 可以把 Figma 设计稿转成 VueReact 组件代码,减少从设计到代码的重复劳动。CodeGeeX 可以根据组件的 Props 描述自动生成单元测试用例。当然,小团队或小公司不一定要自建组件库和物料体系,先把业务跑稳、再按需沉淀组件和模板,会更现实。

工程化系统

工程化系统说白了就是通过工具链把创建项目、检查、构建、部署串成一条流水线,减少人工操作。创建项目阶段,现在普遍用 Vite 创建 VueReact 项目(create-vite 或各框架官方模板),或用 Next.js 自带的脚手架起手,预置好规范与配置即可。到了持续集成和部署,可以用 GitHub ActionsGitLab CI 在提交后自动跑代码检查、构建和部署,或者用 Jenkins 做更复杂的多环境流水线。如果希望需求、开发、部署都在一个平台里完成,可以选阿里云效这类一站式 DevOps 平台,功能全、上手相对简单,也支持私有化部署,不少团队的实际项目就是用云效搭的流水线。

前后端协作

前后端联调最容易出问题的地方,往往是接口约定不一致、文档滞后、环境对不齐。接口文档建议用 ApifoxApidog 这类工具维护,支持 OpenAPI 规范、自动 Mock 和接口测试。很多平台还能根据接口文档自动生成前端的请求代码和 TypeScript 类型,文档一改、类型跟着变,减少手写接口定义。后端接口还没好时,前端可以先用 Mock.jsFaker.js 生成贴近真实的测试数据,或者用 MSW(Mock Service Worker)在浏览器层做请求拦截,配合 TypeScript 做类型安全的 Mock,适合单测和本地联调。全栈都是 TypeScript 的项目,还可以考虑 tRPC 或更轻量的 Hono RPC,前后端共享类型定义,服务端改接口、客户端立刻有类型提示,无需单独维护一份接口文档和类型。Hono RPChc 客户端加 Zod 校验即可实现类型安全,适合前后端同仓或协作紧密的团队。

当接口多了、前端需要聚合多个接口或按需拉字段时,可以加一层 BFF(Backend For Frontend),用 Node.js 中间层(例如 NestJSMidway.jsExpress)聚合多接口,或者用 GraphQL(如 Apollo Server)让前端按需定制响应字段。BFF 可以由后端团队维护,也可以由前端团队自建,实现真正的接口层解耦。

接口文档若能通过统一协议进到开发环境里,前后端对接会轻松很多。可以把后端的 OpenAPI 规范用 MCP(Model Context Protocol)暴露出来,例如用 OpenAPI MCP Server 把接口定义转成 MCP 的 tools、resources,在 Cursor、VS Code 等 IDE 里配置好 MCP 后,就能在写代码时直接读到最新接口文档、让 AI 按文档生成请求代码或类型,避免文档和实现脱节。

阿里云、腾讯云等也有 OpenAPI MCP Server,适合把云产品 API 接到 IDE。自建后端可以用 @reapi/mcp-openapi、FastMCP 的 from_openapi() 等从 OpenAPI 规范生成 MCP 服务,前后端共用同一份文档,联调时接口变更能更快同步到前端。

AI 也能参与进来,例如 ApifoxAI 可以根据接口文档自动生成 Mock 规则和测试用例,CodeGeeX 可以根据现有 RESTful 接口生成一层 GraphQL 包装代码,减少手写胶水代码。

联调时还有几点值得注意。一是接口变更要及时同步,用 Apifox 这类工具把最新接口定义推给前端,或通过 OpenAPI 自动生成类型,避免文档和实现各说各的。二是开发、测试、生产环境要隔离,用 .env.development.env.production 等把配置拆开,别在本地写死生产地址。三是依赖版本要锁死,用 pnpm 等包管理器严格锁定依赖,能少很多"在我机器上是好的"这类问题。

测试优化阶段

测试优化阶段的目标很明确,就是提前暴露风险、保障线上稳定、提升用户体验,用分层测试把核心场景兜住,减少漏测和线上事故。从人工点点点到自动化、再配合 AI 生成用例,测试效率会明显上去。

20260226091000

自动化测试

建议按单元测试、E2E、视觉回归三层建体系,而不是一上来就全押 E2E。单元测试负责验证组件逻辑和工具函数,用 JestVitestReact Testing Library 即可,VitestVite 同源、冷启和 HMR 更快,适合在每次提交时跑。组件层若要在真实浏览器里跑,可用 Vitest 的 Browser Mode 配 Playwright 驱动。例如下面这段,用 render 渲染按钮组件、screen.getByRole 找到按钮并模拟点击,再断言传入的 onClick 被调用了一次,用来保证点击回调不会丢。

test("Button 点击触发事件", async () => {
  const handleClick = vi.fn<[], void>();
  render(<MyButton onClick={handleClick} />);
  await userEvent.click(screen.getByRole("button"));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

E2E 测试覆盖真实用户路径,在浏览器里跑完整流程。Playwright 支持 Chromium、WebKit、Firefox 多端,自带录制回放,适合做跨浏览器回归。Cypress 的可视化调试和时间旅行对复杂交互(例如购物车、多步表单)很友好,按团队习惯二选一或搭配用即可。

视觉回归测试解决的是"功能没坏、但界面悄悄变了"的问题。改了一处样式或依赖升级导致组件渲染异常,单测和 E2E 不一定能发现,视觉回归通过对比页面或组件的截图,先拍一份基准快照,后续每次跑用例时再拍一张,和基准做像素或区域对比,有差异就报出来,由人确认是预期改动还是误伤。可以用 BackstopJS 在本地或 CI 里跑,配置好要截的 URL 或组件,生成基准后纳入版本管理,以后每次 PR 自动跑一遍对比。组件库或设计系统也可以用 ChromaticPercy 这类托管服务,和 Storybook 结合,每个 Story 自动做视觉回归。适合对 UI 稳定性要求高的首页、关键流程页和公共组件,基准图多了之后要注意维护,避免无关改动带来大量噪点。

AI 也能参与测试用例的生成和验证。一类是依据行为数据生成脚本,例如 Testin AI 分析用户行为日志,把高频操作转成 E2E 用例,先覆盖核心路径再补边缘场景。另一类是让 AI 直接连上真实浏览器做调试和验证,例如 Chrome 官方的 Chrome DevTools MCP,在 Cursor、Claude 等里配置好 MCP 后,AI 可以调 DevTools 能力做性能追踪、网络与 Console 排查、DOM 与样式检查、表单与用户行为模拟,并在浏览器里实时验证改动的效果,相当于"边写代码边在真机里跑一遍"。和 Playwright MCP 搭配时,Playwright 负责 UI 自动化与用例执行,DevTools MCP 补足性能与运行时观测,适合做智能回归和 Core Web Vitals 等自动化检查。

性能优化

测试通过之后,还要保证页面秒开、交互不卡。可以给自己定一个简单目标,例如首屏可交互 FCP 控制在 1.5 秒内、首次输入延迟 FID 在 100ms 以内。性能检测方面,用 Lighthouse CI 把跑分集成进 CI 流水线,分数低于阈值(例如 90)就拦掉合并,避免性能劣化代码进主干。真实用户数据用 Google Analytics 4 或阿里云 ARMS 采集 Web Vitals,看线上实际表现而不是只看本机。

优化手段按资源、代码、分发来拆。资源上,构建阶段自动压缩图片,例如用 vite-plugin-imagemin 在打包时处理;代码上,用 React.lazySuspense 做路由级懒加载,首屏只拉当前路由需要的 chunk。分发上,静态资源扔到阿里云 OSS 再挂 CDN,利用全球节点做加速。AI 也能参与,例如阿里云 ARMS 的智能诊断会根据性能数据推荐优化项(如未压缩图片列表),部分构建工具已支持基于预测的 Tree Shaking 策略,进一步剔除无效代码。

合规与安全

合规与安全要从代码和数据两头抓,避免法律风险和用户隐私问题。代码侧,用 SonarQube 做静态扫描,揪出 XSS、SQL 注入等常见漏洞。依赖侧,用阿里云安全中心等扫描已知漏洞(例如 Log4j、老旧版本的 lodash),有风险就升级或替换。隐私合规方面,用腾讯云合规助手这类工具检查隐私政策是否满足 GDPR、个保法等要求。日志里对手机号、身份证号等做脱敏,例如通过 log4js 等插件的过滤规则自动打码,避免敏感数据落盘。

AI 可以辅助安全扫描,例如用大模型扫描代码里的敏感信息(如硬编码的 API 密钥)。部分 AI 代码助手能自动把不安全写法替换成更安全的实现(如将 eval 改为 Function),适合在 Code Review 前跑一遍。

数据埋点

埋点做得好,产品迭代才有数据支撑,否则容易变成"盲人摸象"。埋点大致分无埋点和自定义埋点。若注重隐私或希望数据自托管,可以用 Umami 这类开源方案,无 Cookie、符合 GDPR,脚本轻量(约 2KB),支持页面浏览与自定义事件,可 Docker 自建或使用官方云,适合中小站点和不想依赖第三方统计的场景。

无埋点还可用 GrowingIO 等方案自动采集页面点击、曝光等事件,接入简单、覆盖面大。自定义埋点用神策数据等 SDK 在关键行为(如按钮点击、表单提交)上手动上报,灵活、可针对业务做分析。数据进来之后,用 Metabase 这类开源 BI 做 SQL 自助分析,或用阿里 DataV 做大屏展示核心指标(如 DAU、转化率)。AI 也能参与,例如 GrowingIO 的智能推荐会根据用户路径建议高价值埋点事件,神策的聚类分析能自动识别用户分群(如高流失风险用户),方便做精细化运营。

测试与优化阶段还有几点容易踩坑。一是别盲目追求 100% 测试覆盖率,优先把核心链路(登录、支付等)兜住,再按需补边缘场景。二是性能优化别撒胡椒面,内部管理后台等低频页面不必死磕,把资源留给用户高频访问的页面。三是埋点必须拿到用户授权,禁止收集设备 ID、IMEI 等敏感信息,否则会踩数据隐私的雷。

构建部署阶段

构建与部署阶段是前端工程化的交付出口,目标是高效交付、稳定发布、灵活回滚,让代码从开发环境到生产环境顺畅流转。下面按构建优化、部署方案、灰度与回滚三块说。

20260226094131

构建优化

构建工具在技术选型阶段通常已经定好了(例如 ViteWebpack 5RspackNext.js),这里侧重在既定工具上的优化策略。Vite 新版本已接入 Rust 实现的 Rolldown 做生产打包,构建耗时明显下降。选 Next.js 的可以用 Turbopack 做开发和生产构建,冷启和增量构建更快。Rspack 等 Rust 系方案在大仓下同样有优势。

优化时先把 Tree Shaking 开满,在库和业务里合理配置 sideEffects: false,让打包器删掉未引用代码。代码拆分用动态 importReact.lazy 把非首屏做成按需加载,再用 manualChunks 把大依赖(如 monaco-editor、图表库)单独拆包,避免首屏 chunk 过大。产物体积可用 rollup-plugin-visualizervite-plugin-perfsee 做分析,一眼看出谁在占空间。线上传输用 vite-plugin-compression 做 Gzip 或 Brotli,Nginx 侧开 gzip_static 即可。

部分构建工具已支持基于 AI 的智能缓存和构建日志分析,自动推荐合并重复 Chunk、优化依赖顺序等,可在 CI 里跑一遍看报告。

部署方案

部署从手动发包走向一键发布、多环境隔离,才能做到分钟级回滚。静态资源托管最常见,用阿里云 OSS 挂 CDN 按量付费、支持缓存刷新,或选托管平台:Vercel 和 Git 深度集成、推分支即发布,适合 Next.js。Cloudflare Pages 边缘节点多、免费带宽大,已支持 Docker 和 @opennextjs/cloudflare 跑 Next,还有 Workers AI 做边缘推理。Netlify 在组合式架构和 CMS 集成上比较顺手。需要极快 git 部署、少建站过程的可以看 Deno Deploy,代码直传边缘、无需拉机子做长构建,适合接口或中间层。

需要跑 Node 或做 SSR 的,用 Docker 多阶段构建把镜像压到几十 MB,再配合 Kubernetes(如阿里云 ACK)做集群。不想管机器的用 Serverless,阿里云函数计算、Vercel Edge Functions 等按需执行、边缘就近跑。

AI 也能参与,例如 GitLab Code Suggestions 可根据项目生成 DockerfileCI 脚本,观测云等能根据资源负载推荐扩缩容策略。

灰度与回滚

发布要可控,灰度把风险压到最小,回滚要能快速切回去。灰度本质是流量逐步切到新版本,常见做法有 Nginx 按 IP 或 Cookie 分流,先给 5%~10% 用户上新版,观察一段时间再放量。阿里云 EDAS 支持全链路灰度,应用和数据库都能隔离。云原生 API 网关也支持蓝绿、金丝雀发布,按比例或规则切流量。除了流量灰度,还可以用特性开关(Feature Flags),在代码里用开关控制功能是否露出,用 ConfigCat、LaunchDarkly 等或自研,发版和上线解耦,随时可关。

灰度期间要有可观测,接 Prometheus、Grafana 或现有监控,盯错误率、响应时间,一旦超阈值(例如错误率 >0.5%)自动回滚或告警。回滚要提前准备好,在 GitLab CI 或 GitHub Actions 里做基于版本 Tag 的回滚脚本,出问题一键切回上一版。静态资源用 OSS 版本控制保留历史,回滚时改 CDN 回源即可。

AI 也能参与,例如阿里云 AHAS 可根据历史流量推荐灰度比例,Sentry 等可在错误率突增时自动触发回滚或通知,减少人工判断时间。

运维监控阶段

运维与监控是前端工程化的最后一道防线,目标是实时感知风险、快速定位原因、持续优化体验,让线上系统稳定、用户行为可观测。下面按性能监控、异常监控、用户行为分析、可视化与告警四块说,最后补一版低成本与大型企业的工具链参考,以及几条容易踩的坑。

20260226095212

性能监控

性能监控要保障 Web Vitals 等核心体验指标达标,并持续发现瓶颈。核心指标用 Google Analytics 4 或阿里云 ARMS 等采集真实用户数据(RUM),关注 LCP(最大内容绘制)、INP(交互到下一帧,已逐步替代 FID)、CLS(累计布局偏移)等,可配合 web-vitals 库在端上采集后上报。除了平台自动采集,关键链路可以加自定义性能埋点,例如在页面加载完成后取 performance.timing 算出加载耗时并上报,方便按页面或版本对比。下面示例在 load 事件后计算从导航开始到加载结束的耗时,并通过自有 SDK 上报,用于做首屏性能趋势分析。

const timing: PerformanceTiming = performance.timing;
const loadTime: number = timing.loadEventEnd - timing.navigationStart;
SDK.report({ type: "page_load", duration: loadTime });

资源侧可以看 CDN 日志分析请求成功率、缓存命中率(如阿里云 CDN)。接口耗时用 SkyWalking、Zipkin 或 OpenTelemetry 做链路追踪,约定 P99 等目标(例如 500ms 以内)。Sentry 等已支持与 OpenTelemetry 对接,前端错误和接口链路可以串成一条 trace,排查时从页面一路跟到后端。AI 也能参与,例如阿里云 ARMS 智能诊断会关联 JS 错误与接口超时,给出根因建议。New Relic 等可根据历史数据预测流量峰值,辅助提前扩容。

异常监控

异常监控要争取分钟级发现线上问题,把 MTTR(平均修复时间)压下去。错误追踪用 Sentry 捕获前端 JS 错误、自动聚合相似问题,并支持 SourceMap 解析还原源码位置。国内团队也可以用支持微信、钉钉实时告警的国产方案,和现有协作习惯对齐。日志分析用阿里云 SLS 做 Nginx 访问日志的实时分析,快速发现 5xx 突增等异常。自建可选 Loki 配 Grafana,资源占用比传统 ELK 小,用 LogQL 查"近 1 小时 404 TOP10"这类问题很顺手。

AI 可以辅助降噪和归因,例如 Sentry 的智能聚类能把大量错误归成少量根因(如未捕获的 TypeError)。基于 Elasticsearch Machine Learning 或类似能力可以做日志模式异常检测,例如发现突然出现大量非常规 UA 或异常请求路径,提前发现爬虫或攻击。

用户行为分析

用户行为数据用来驱动产品优化和转化率提升。无埋点用 GrowingIO 等自动采集页面点击、跳转、停留时长,并生成热力图。自定义分析用神策等做事件与漏斗(如注册流程各步转化)。关键业务节点需要自定义埋点时,在按钮或流程节点上打点上报事件和业务参数,例如下单按钮点击时上报商品 ID 和价格,便于后续做转化和营收分析。下面示例在购买按钮点击时上报事件名和业务字段,接入方替换成实际 SDK 即可。

document.getElementById("buy-button")?.addEventListener("click", () => {
  SDK.track("purchase_click", { product_id: "123", price: 299 });
});

AI 能参与分析结论的产出,例如神策的智能路径分析用户流失点并给出优化建议,GrowingIO 可根据行为聚类生成推荐或运营策略参数。

可视化与告警

监控数据要通过大屏和告警变成可执行的决策。可视化用 Grafana 做自定义监控面板,或用阿里云 DataV 搭实时运维大屏。告警用 Prometheus 配 Alertmanager 配置阈值(如 CPU 使用率 >80% 持续 5 分钟),告警事件通过钉钉、飞书机器人推到协作群,并支持 @ 负责人。AI 可以用于智能阈值和降噪,例如根据历史数据动态计算合理阈值(如凌晨自动放宽延迟阈值),或把重复告警合并成一条,减少告警风暴。

工具链参考

中小团队、预算有限时,可以组合:监控用 Prometheus 自建 + Grafana,告警接微信或钉钉。日志用 Loki 替代 ELK,资源消耗更低。再搭配阿里云 ARMS 免费版做基础性能分析、或开源组件的异常检测能力,整体月成本可控。对高可用要求高、数据量大的团队,可以用阿里云 ARMS 做全链路、SLS 做 PB 级日志,配合 DataV 大屏和自研或第三方 AI 分析平台。

运维监控还有几点要注意。一是避免过度监控,只采核心业务相关指标,否则存储和告警成本都会上去。二是告警要设静默期,同一类告警在 30 分钟内不重复推送,减少告警疲劳。三是日志必须脱敏,避免原始敏感数据泄露。

React-深度拆解 React路由:从实战进阶到底层原理

前言

在单页面应用(SPA)开发中,前端路由是核心基石。它让我们可以根据 URL 的变化,在不刷新页面的情况下切换组件。本文将带你从 React Router 的基本使用出发,深入其底层的浏览器实现机制。

一、 React 路由配置实战

1. 基础环境搭建

首先,我们需要安装 React 路由的核心库:

npm install react-router-dom

2. 注入路由容器

需要在应用最顶层(如 main.jsx/App.jsx)包裹路由核心容器,决定使用 Hash 模式 还是 History 模式,二者的核心区别是 URL 是否带 #,以及服务端配置要求。

两种模式核心区别:

特性 History 模式 Hash 模式
URL 形式 https://xxx.com/home(无 #) https://xxx.com/#/home(带 #)
服务端配置 需要 Nginx配置兜底 无需服务端配置
兼容性 依赖 HTML5 History API 兼容所有浏览器
场景 =现代浏览器 老项目兼容
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, HashRouter } from 'react-router-dom';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));

// 方式1:History 模式(推荐,URL 无 #,美观)
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

// 方式2:Hash 模式(兼容老浏览器/无需后端配置,URL 带 #)
// root.render(
//   <HashRouter>
//     <App />
//   </HashRouter>
// );

3. 定义路由映射

通过 <Routes><Route> 定义「URL 路径 → 组件」的映射关系,用 <Link> 替代原生 <a> 标签实现无刷新导航。

import { Routes, Route, Link } from 'react-router-dom';
// 导入页面组件(需提前创建)
import Home from './pages/Home';
import About from './pages/About';
import User from './pages/User';
import NotFound from './pages/NotFound';

function App() {
  return (
    <div className="App">
      <nav style={{ margin: '10px 0' }}>
        <Link to="/" style={{ marginRight: '10px' }}>首页</Link>
        <Link to="/about" style={{ marginRight: '10px' }}>关于我们</Link>
        <Link to="/user/123">用户123</Link>
      </nav>

      {/* 路由匹配规则:Routes 是 v6 新增的容器(替代旧版 Switch) */}
      <Routes>
        {/* 基础路由:路径完全匹配时渲染对应组件 */}
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        {/* 动态路由:匹配 /user/任意值,通过 params 传参 */}
        <Route path="/user/:id" element={<User />} />
        {/* 404 路由:匹配所有未定义的路径(必须放最后) */}
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

export default App;

二、 深度解析路由两种模式底层原理

React Router 的本质是基于浏览器原生能力的封装,核心作用是根据 URL 路径匹配对应的组件并渲染,实现单页面应用(SPA)的无刷新页面切换。。

2. Hash 模式实现原理

Hash 模式依赖 URL 中 # 后的哈希值,核心逻辑如下:

  • URL 变化监听:基于 window.hashchange 事件监听哈希值变化;

  • 设置 URL 值:通过 location.hash = '新路径' 修改哈希值;

  • 获取当前 URL 值:通过 location.hreflocation.hash 获取;

  • URL 页面跳转:通过 location.assign('#/新路径') 实现跳转;

  • 核心优势# 后的内容不会发送到服务器,所有请求都指向 域名/index.html,服务端无需额外配置。

3. History 模式实现原理

History 模式依赖 HTML5 新增的 History API,核心逻辑如下:

  • URL 变化监听:基于 window.popstate 事件监听浏览器前进 / 后退导致的 URL 变化;

  • 新增 / 替换历史记录

    • history.pushState(stateObj, title, url):新增一条历史记录(无刷新跳转);
    • history.replaceState(stateObj, title, url):替换当前历史记录(不新增记录);
  • 获取当前 URL 值:通过 window.location.pathname 获取;

  • 页面前进 / 后退:通过 history.go(number)(前进 / 后退指定步数)、history.back()(后退一步)实现;

  • 获取自定义状态:通过 history.state 获取 pushState 传入的自定义状态对象;

  • 核心注意:URL 无 #,但刷新页面时浏览器会向服务器发送对应路径的请求,需服务端配置兜底,否则会 404。


四、 注意:History 模式的服务器配置

当你使用 History 模式 部署到 Nginx 时,最常见的问题是:点击页面内的 Link 没问题,但一按刷新就 404

原因:History 模式下,浏览器会向服务器请求 /home 这个物理路径。服务器上并没有这个文件,因此报错。

解决方案:在 Nginx 配置中增加 try_files 指令,让所有找不到的请求都重定向到 index.html,交给 React Router 去处理。

location / {
     root   /usr/share/nginx/html;   # 前端打包文件的存放目录
     index  index.html index.htm;    # 默认访问文件
     try_files $uri $uri/ /index.html;  # 核心配置:如果找不到资源,统统指向 index.html
}

📝 总结

  • React 路由需先安装 react-router-dom,顶层包裹 BrowserRouter(History 模式)或 HashRouter(Hash 模式);

  • 核心使用 <Routes>/<Route> 定义路由规则,<Link> 实现导航;

  • Hash 模式无需服务端配置,History 模式需在 Nginx 中配置 try_files 兜底,避免刷新 404;

  • React Router 本质是监听 URL 变化(Hash 监听 hashchange,History 监听 popstate),匹配并渲染对应组件。


React-手把手带你实现 Keep-Alive 效果

前言

在 Vue 中,我们可以通过 <keep-alive> 轻松缓存组件实例。但在 React 中,组件卸载(Unmount)意味着状态和 DOM 的彻底销毁。如何在 React 中实现“切换页面不丢失滚动位置、不重置表单”?本文将为你拆解三种主流方案。

一、 Keep-Alive 的本质是什么?

在实现 React 版缓存组件前,先明确 Vue keep-alive 的核心逻辑,才能精准复刻:

  • 核心本质:缓存组件实例,保留组件内部状态(如输入框内容、滚动位置);
  • 行为特征:组件切换时不销毁 / 重建,仅通过「隐藏 / 显示」控制渲染状态;
  • React 目标:实现和 Vue 一致的效果 —— 组件切走不丢状态,切回来能恢复。

二、 React 实现 keep-alive 的 3 种方案

方案 1:CSS 隐藏 + 不卸载组件(最简单)

核心思路:

通过 display: none 隐藏不活跃的组件,保留组件的 DOM 节点和内部状态,仅切换 display 属性控制显示 / 隐藏,不触发组件的卸载 / 重新挂载生命周期。

适用场景

  • 少量组件切换(2-3 个,如 tab 标签页);
  • 简单业务场景(如表单页、列表页切换)。

优缺点

✅ 优点:实现简单,无额外依赖,状态保留完整;

❌ 缺点:所有组件都会挂载在 DOM 树中,组件数量多(如 5 个以上)会增加 DOM 节点数量,可能影响页面渲染性能。

import { useState } from 'react';

// 模拟 Tab 切换场景
const KeepAliveByCSS = () => {
  // 控制当前激活的标签
  const [activeKey, setActiveKey] = useState('tab1');

  return (
    <div>
      <div className="tab-header">
        <button onClick={() => setActiveKey('tab1')}>标签1</button>
        <button onClick={() => setActiveKey('tab2')}>标签2</button>
      </div>
      <div className="tab-content">
        {/* 始终挂载,仅通过 CSS 隐藏 */}
        <div style={{ display: activeKey === 'tab1' ? 'block' : 'none' }}>
          <Tab1 />
        </div>
        <div style={{ display: activeKey === 'tab2' ? 'block' : 'none' }}>
          <Tab2 />
        </div>
      </div>
    </div>
  );
};

// 带状态的子组件
const Tab1 = () => {
  // 切换标签后,输入框内容不会丢失
  const [inputVal, setInputVal] = useState('');
  return <input value={inputVal} onChange={(e) => setInputVal(e.target.value)} placeholder="标签1输入框" />;
};

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

方案 2:使用 react-activation 第三方库(推荐)

这是目前社区内最成熟的方案,它通过将组件渲染到“外部容器”再动态挂载回来的方式,模拟了 Vue 的行为。

核心功能

  • 提供 <KeepAlive> 容器组件,包裹需要缓存的组件即可生效;

  • 内置 useActivate/useDeactivate 钩子函数,分别在组件「激活」和「失活」时触发;

  • 支持缓存控制(如指定缓存 Key、条件缓存);

  • 能保留 DOM 状态(如滚动条位置、输入框焦点)。

使用示例

import { KeepAlive, useActivate, useDeactivate } from 'react-activation';
import { useState } from 'react';

const KeepAliveByLib = () => {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button onClick={() => setShow(!show)}>
        {show ? '隐藏组件' : '显示组件'}
      </button>
      {/* 用 KeepAlive 包裹需要缓存的组件 */}
      {show && (
        <KeepAlive id="cached-component">
          <CachedComponent />
        </KeepAlive>
      )}
    </div>
  );
};

// 被缓存的组件
const CachedComponent = () => {
  const [inputVal, setInputVal] = useState('');
  const [scrollTop, setScrollTop] = useState(0);

  // 组件激活时触发(显示时)
  useActivate(() => {
    console.log('组件被激活');
  });

  // 组件失活时触发(隐藏时)
  useDeactivate(() => {
    console.log('组件被失活');
  });

  // 模拟滚动条状态保留
  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div>
      <input 
        value={inputVal} 
        onChange={(e) => setInputVal(e.target.value)} 
        placeholder="缓存的输入框" 
      />
      <div 
        style={{ height: '200px', overflow: 'auto', marginTop: '10px' }}
        onScroll={handleScroll}
      >
        {Array.from({ length: 50 }).map((_, index) => (
          <p key={index}>滚动测试行 {index + 1}</p>
        ))}
      </div>
      <p>当前滚动位置:{scrollTop}</p>
    </div>
  );
};

方案 3:全局状态管理 + 状态回显(Redux)

如果不想引入第三方库,也可以通过全局状态缓存实现核心效果,本质是组件卸载前存状态,重新挂载时取状态。

核心思路

在组件 useEffect 的清理函数中,将关键数据(输入框、计数、滚动位置等)保存到全局 Store;重新挂载时再读取初始化。

  • 优点:符合 React 数据流规范,内存占用可控。

  • 缺点

    1. 无法恢复 DOM 状态:如页面的滚动位置、输入框的焦点、已播放的视频进度,需单独编写逻辑处理。
    2. 开发成本高:每个需要缓存的组件都要手动编写保存/恢复逻辑。
  • 补救措施:若要恢复滚动位置,需手动在卸载前记录 scrollTop,并在渲染后通过 window.scrollTo 还原。


三、缓存 React Router 路由组件

实际开发中,最常见的场景是切换路由不丢失页面状态(如列表页滚动位置、表单输入内容),可结合 React Router + react-activation 实现。

核心代码如下:

import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { KeepAlive } from 'react-activation';
import Profile from './pages/Profile';
import Home from './pages/Home';
import Settings from './pages/Settings';

// 路由容器组件(获取当前路由路径)
const RouterContainer = () => {
  const location = useLocation();
  const currentPath = location.pathname;

  return (
    <Routes>
      {/* 普通路由(不缓存) */}
      <Route path="/" element={<Home />} />
      <Route path="/settings" element={<Settings />} />
      
      {/* 缓存路由组件:when 控制是否缓存,id 为缓存标识 */}
      <Route
        path="/profile"
        element={
          <KeepAlive id="profile" when={currentPath === "/profile"}>
            <Profile />
          </KeepAlive>
        }
      />
    </Routes>
  );
};

// 根组件
const App = () => {
  return (
    <BrowserRouter>
      <RouterContainer />
    </BrowserRouter>
  );
};

四、 方案选择建议

方案 优点 缺点 适用场景
CSS 隐藏 实现简单、无依赖 DOM 节点冗余、性能一般 少量组件(2-3 个)、简单 tab 切换
react-activation 功能完整、支持 DOM 状态缓存 新增第三方依赖 中大型项目、需完整 keep-alive 效果
全局状态缓存 无额外依赖、贴合状态管理 仅恢复数据、需手动处理 DOM 已用全局状态库、仅需数据缓存

五、总结

  1. React 无原生 keep-alive,但可通过CSS 隐藏、react-activation 库、全局状态缓存3 种方案模拟核心效果;
  2. 简单场景用 CSS 隐藏,中大型项目优先选 react-activation(兼顾易用性和完整性);
  3. 路由组件缓存可结合 React Router + react-activation 实现,核心是通过 KeepAlive 包裹路由元素并指定缓存标识。

Vue - @ 事件指南:原生 / 内置 / 自定义事件全解析

前言

在 Vue 开发中,@v-on 指令的简写,是绑定事件监听的核心语法。很多新手容易混淆不同类型的 @ 事件用法,本文整理了 Vue 中所有常用的 @ 事件类型,包括原生 DOM 事件、内置组件事件、自定义事件,以及提升开发效率的事件修饰符,看完就能直接上手用!

一、 Vue @ 事件的核心分类

Vue 中的 @ 事件本质是对 DOM 事件 / 组件事件的封装,核心分为三大类:

  • 原生 DOM 事件:浏览器自带的基础交互事件
  • Vue 内置组件事件:Vue 官方组件专属的状态监听事件
  • 自定义事件:组件间通信的核心自定义事件

二、原生 DOM 事件

这类事件是浏览器原生支持的 DOM 事件,Vue 可直接通过 @ 绑定,覆盖绝大部分交互场景,按类型整理如下:

1. 鼠标事件

事件语法 说明 常用场景
@click 点击事件(最常用) 按钮点击、卡片跳转
@dblclick 双击事件 列表项编辑、文件重命名
@mouseenter 鼠标进入(不冒泡) 悬浮提示、菜单展开
@mouseleave 鼠标离开(不冒泡) 悬浮提示隐藏、菜单收起
@mousemove 鼠标移动 拖拽跟随、坐标监听
@mousedown 鼠标按下 拖拽开始、按住触发
@mouseup 鼠标松开 拖拽结束、松开停止
@contextmenu 右键菜单事件 自定义右键菜单

2. 键盘事件

事件语法 说明 注意点
@keydown 键盘按下时触发 可监听组合键(如 @keydown.ctrl.s
@keyup 键盘松开时触发 常用 @keyup.enter 监听回车
@keypress 键盘按压时触发 已逐步废弃,推荐用 keydown 替代

3. 表单事件

事件语法 说明 触发时机对比
@input 输入框内容变化 实时触发(每输入一个字符都触发)
@change 表单值变化 失去焦点 / 选择完成后触发(如下拉框选值)
@submit 表单提交事件 点击提交按钮 / 按回车触发
@focus 元素获取焦点 输入框激活、下拉框展开
@blur 元素失去焦点 输入框失活、表单校验

4. 移动端触摸事件

事件语法 说明 适用场景
@touchstart 触摸开始 移动端点击、滑动开始
@touchend 触摸结束 移动端点击完成、滑动结束

5. 页面 / 窗口事件

事件语法 说明 优化建议
@scroll 滚动事件 监听页面滚动加载、导航栏吸顶
@resize 窗口大小变化 响应式布局适配、画布重绘

6.使用示例

<template>
  <div>
    <!-- 点击事件 -->
    <button @click="handleClick">普通点击</button>
    <!-- 键盘事件(监听回车) -->
    <input @keyup.enter="handleSearch" placeholder="按回车搜索" />
    <!-- 表单输入事件 -->
    <input @input="handleInput" @blur="handleBlur" placeholder="实时输入监听" />
  </div>
</template>

<script setup>
const handleClick = () => console.log('按钮被点击');
const handleSearch = () => console.log('执行搜索');
const handleInput = (e) => console.log('实时输入:', e.target.value);
const handleBlur = () => console.log('输入框失活,可做校验');
</script>


三、 Vue 内置组件事件:监听生命周期

Vue 的内置组件(如动画、路由)拥有自己独特的“生命周期事件”,让我们能精准控制交互细节。

内置组件 常用事件 触发时机
<transition> @before-enter / @enter 进入动画开始前与执行中
@after-enter 动画完全结束,常用于清理工作
@leave / @after-leave 离开动画的相关节点
<router-link> @click 点击跳转(Vue Router 内部处理)
@navigate (Vue Router 4+) 导航正式开始时触发

四、 自定义事件:父子通信核心

自定义事件是 Vue 父子组件通信的重要方式,子组件通过 emit 触发事件,父组件通过 @ 监听事件并接收参数。

  1. 子组件触发:使用 emit 抛出事件和数据。

  2. 父组件监听:通过 @ 绑定回调。

<!-- 子组件 Child.vue -->
<template>
  <button @click="sendData">向父组件传值</button>
</template>

<script setup>
  // 定义可触发的自定义事件
  const emit = defineEmits(['custom-event']);

  const sendData = () => {
    // 触发事件并传递参数
    emit('custom-event', { name: 'Vue', version: '3.x' });
  };
</script>

<!-- 父组件 Parent.vue -->
<template>
  <!-- 监听子组件自定义事件 -->
  <Child @custom-event="handleCustomEvent" />
</template>

<script setup>
  import Child from './Child.vue';

  const handleCustomEvent = (data) => {
    console.log('接收子组件数据:', data); // 输出:{ name: 'Vue', version: '3.x' }
  };
</script>


五、 扩展:事件修饰符

Vue 提供事件修饰符简化事件处理逻辑,无需手动调用 e.preventDefault()/e.stopPropagation(),常用修饰符如下:

1. 流程控制

  • .stop阻止冒泡。相当于 e.stopPropagation()
  • .prevent阻止默认行为。常用于 <a> 标签和 <form> 提交。
  • .capture:使用捕获模式触发事件。

2. 触发频率与性能

  • .once只触发一次。之后再点击将失效。

  • .passive提升性能(移动端必用)

3. 按键与鼠标修饰符

  • .enter / .esc / .space:特定按键触发。
  • .left / .right / .middle:限制特定的鼠标按键。

RAG-如何对文档分块

上文我们讲了RAG是如何进行数据加载的,那么文档加载完数据就能直接喂给大模型进行问答吗,答案是否定的。因为把所有的文档都一并喂给大模型,那么大模型接受的上下文是非常巨大的,这会超出大模型所支持的最大token,而且每次会话,都要把上下文喂给大模型才能回答我们问的问题,这使得大模型的响应速度会变得很慢,如果是调用在线的大模型API的话,一次问答会消耗很多的token,钱包顶不住啊。所以要将文档数据加载后,进行数据分块、向量嵌入、存入向量数据库,通过向量检索将有用的数据喂给大模型,最后生成结果返回。这一篇我们着重说明数据分块是怎么做的。

在展示文本分块前说明下什么是token

  • 在英文里,一个单词可能是一个token,也可能被拆成多个。例如:playing 可能拆成 play + ing
  • 在中文里,通常一个汉字常常接近1个token, 但也不绝对
  • 标点、空格、换行也可能占token
文档分块方法
字符分块

用单一分隔符进行文档分块,代码如下:

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import CharacterTextSplitter

# ---------- 1. 加载 PDF ----------
loader = PyMuPDFLoader("../document/企业财务报表分析-图表.pdf")
documents = loader.load()

# ---------- 2. 文档分块 ----------
# chunk_size: 每块最大字符数;chunk_overlap: 块与块之间的重叠字符数,避免语义被截断
text_splitter = CharacterTextSplitter(chunk_size=200, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)

# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
print(f"  CharacterTextSplitter      -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  Character:  最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:CharacterTextSplitter切分的文本

1773908015462.png (C:\Users\yd-19\AppData\Roaming\Typora\typora-user-images\1773908015462.png)

文中的CharacterTextSplitter是按照字符长度切分文本,其配置是:

  • chunk_size=500: 每块最多约为200字符
  • chunk_overlap=50: 相邻块重叠50字符,减少语言被截断
  • 不考虑语义,只看长度

这里有个问题就是虽然我们配置的文本块约为200个字符,但看返回的结果最大的文本块是1266个字符,远超200字符。这是为什么呢。因为CharacterTextSplitter的工作方式是:

  • 先用sparator把文本切开(默认是“\n\n”
  • 然后把切出来的小段尝试合并,合并到接近chunk_size为止
  • 但如果某一段本身就超过了chunk_size,它就不会再进一步切割

因为样例PDF里“\n\n”很少,CharacterTextSplitter按照“\n\n”切完块后,每段本身就很长,也不会对超长段再做二次切分。所以分块出来的结果最大文本块超过了200字符,并且切割出来的字符很不均匀。接下来我们介绍另一种分块方法。

递归分块

多级,按优先级递归分隔符,代码如下:

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"

loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=50,
)

chunks = recursive_splitter.split_documents(documents)


# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
print(f"  RecursiveCharacterTextSplitter      -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  Character:  最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:RecursiveCharacterTextSplitter切出的文本

1773911123241.png 通过结果我们可以看出,RecursiveCharacterTextSplitter切出来的文本更多,更加均匀,更接近我们设置的字符数。RecursiveCharacterTextSplitter切割分隔符是通过递归:\n\n\n空格字符,对于超长块的处理,会自动降级到更细的分隔符继续切。

1773921209044.png

我们继续观察结果得知,切出来的内容语义并不完整,一段完整的话被切成两个分块,所以也要根据文中的内容进行策略分块。

分块思想
分层分块

按照文档的章节结构、句子边界进行分块,优先保留完整的句子,在元数据中加入页码、章节、分块数量。代码如下:

import re
from copy import deepcopy

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"
MAX_CHUNK_SIZE = 500
CHUNK_OVERLAP = 50

CHAPTER_RE = re.compile(r"(?=(?:^|\n)[一二三四五六七八九十]+、)")
SECTION_RE = re.compile(r"(?=(?:^|\n)([一二三四五六七八九十]+))")

fallback_splitter = RecursiveCharacterTextSplitter(
    chunk_size=MAX_CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
    separators=["。", ";", "\n", ",", " ", ""],
    keep_separator=True,
)


def extract_heading(text: str, pattern: re.Pattern) -> str:
    """从块开头提取标题行。"""
    first_line = text.strip().split("\n")[0].strip()
    if pattern.search("\n" + first_line):
        return first_line
    return ""


def split_by_regex(text: str, pattern: re.Pattern) -> list[str]:
    """按正则切分,保留分隔符在各段开头。"""
    parts = pattern.split(text)
    result = []
    for p in parts:
        stripped = p.strip()
        if stripped:
            result.append(stripped)
    return result if result else [text]


def hierarchical_chunk(docs: list[Document]) -> list[Document]:
    full_text = "\n\n".join(doc.page_content for doc in docs)
    base_meta = docs[0].metadata if docs else {}

    chapters = split_by_regex(full_text, CHAPTER_RE)
    chunks: list[Document] = []

    for chapter_text in chapters:
        chapter_heading = extract_heading(chapter_text, CHAPTER_RE)

        sections = split_by_regex(chapter_text, SECTION_RE)

        for section_text in sections:
            section_heading = extract_heading(section_text, SECTION_RE)

            meta = deepcopy(base_meta)
            meta["chapter"] = chapter_heading
            meta["section"] = section_heading

            if len(section_text) <= MAX_CHUNK_SIZE:
                chunks.append(Document(page_content=section_text.strip(), metadata=meta))
            else:
                sub_chunks = fallback_splitter.split_text(section_text)
                for idx, sub in enumerate(sub_chunks):
                    sub_meta = deepcopy(meta)
                    sub_meta["sub_chunk"] = f"{idx + 1}/{len(sub_chunks)}"
                    chunks.append(Document(page_content=sub.strip(), metadata=sub_meta))

    # 过小的块(如纯章节标题)合并到下一块,避免碎片
    MIN_CHUNK_SIZE = 50
    merged: list[Document] = []
    carry = ""
    for chunk in chunks:
        if len(chunk.page_content) < MIN_CHUNK_SIZE:
            carry += chunk.page_content + "\n"
        else:
            if carry:
                chunk.page_content = carry + chunk.page_content
                carry = ""
            merged.append(chunk)
    if carry and merged:
        merged[-1].page_content += "\n" + carry.strip()

    return merged


# ---------- 执行 ----------
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = hierarchical_chunk(documents)

# ---------- 打印结果 ----------
print(f"=== 分层分块结果(共 {len(chunks)} 块)===\n")
char_lens = [len(c.page_content) for c in chunks]
print(f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens) // len(char_lens)}\n")

for i, chunk in enumerate(chunks, 1):
    ch = chunk.metadata.get("chapter", "")
    sec = chunk.metadata.get("section", "")
    sub = chunk.metadata.get("sub_chunk", "")
    label = f"[{ch}]" if ch else ""
    if sec:
        label += f" [{sec}]"
    if sub:
        label += f" (子块 {sub})"

    content = chunk.page_content
    preview = content[:200] + "..." if len(content) > 200 else content
    print(f"--- 第 {i}/{len(chunks)}{label} (长度: {len(content)}) ---")
    print(preview)
    print("-" * 80)
print()

返回的部分结果:

1773921947941.png 这种分块的方法能保留语义的完整性,切出来的块自带章节的标签,定位精准

滑动窗口分块

滑动窗口分块不看标点、不看换行、不看章节,纯按字符位置滑动。

  • 优点:块大小完全均匀,覆盖无死角(每个字符至少出现在 1~2 个块里)
  • 缺点:会从句子/词中间切断,语义完整性最差

代码如下:

from copy import deepcopy

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document

PDF_PATH = "../document/企业财务报表分析-图表.pdf"
WINDOW_SIZE = 300
STEP_SIZE = 200


def sliding_window_chunk(docs: list[Document], window: int, step: int) -> list[Document]:
    """
    滑动窗口分块:固定窗口大小,按步长向前滑动。
    window - step = 重叠字符数(本例 300 - 200 = 100 字符重叠)
    """
    chunks: list[Document] = []
    for doc in docs:
        text = doc.page_content
        if not text.strip():
            continue

        start = 0
        chunk_idx = 0
        while start < len(text):
            end = start + window
            segment = text[start:end].strip()
            if segment:
                meta = deepcopy(doc.metadata)
                meta["chunk_index"] = chunk_idx
                meta["char_start"] = start
                meta["char_end"] = min(end, len(text))
                chunks.append(Document(page_content=segment, metadata=meta))
                chunk_idx += 1
            start += step

    return chunks


# ---------- 执行 ----------
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = sliding_window_chunk(documents, window=WINDOW_SIZE, step=STEP_SIZE)

# ---------- 打印结果 ----------
print(f"=== 滑动窗口分块结果(window={WINDOW_SIZE}, step={STEP_SIZE}, overlap={WINDOW_SIZE - STEP_SIZE})===\n")
print(f"  共 {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}\n")

for i, chunk in enumerate(chunks, 1):
    content = chunk.page_content
    preview = content[:200] + "..." if len(content) > 200 else content
    start = chunk.metadata["char_start"]
    end = chunk.metadata["char_end"]
    print(f"--- 第 {i}/{len(chunks)} 块 [字符 {start}~{end}] (长度: {len(content)}) ---")
    print(preview)
    print("-" * 80)
print()

返回部分结果:

1773922983844.png

句子边界优先分块

按照标点符号将整段文本拆成一句一句的,再把句子一句一句的往块里放,快满了就输出一块。输出一块后,不是从零开始。而是从前一块末尾回带几句(总字符数 ≤ chunk_overlap=50)作为新块的开头。回带也是以整句为单位,不会把句子劈开。

  • 优点:每个块里的句子都是完整的,embedding 质量好,检索到的上下文读起来通顺。
  • 缺点:不感知文档结构(章节/标题),可能把不同章节的内容拼到同一个块里。
import re
from copy import deepcopy

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"
CHUNK_SIZE = 300
CHUNK_OVERLAP = 50


def split_sentences_zh(text: str) -> list[str]:
    """按中文句号/问号/感叹号/分号切句,尽量保留句子语义完整。"""
    text = text.strip()
    if not text:
        return []
    parts = re.split(r"(?<=[。!?;!?;])\s*", text)
    return [p.strip() for p in parts if p.strip()]


def sentence_aware_chunk_documents(
    docs: list[Document],
    chunk_size: int,
    chunk_overlap: int,
) -> list[Document]:
    """先按句切,再按句合并;超长句再兜底按字符切分。"""
    fallback_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        keep_separator=True,
    )

    chunks: list[Document] = []
    overlap_chars = max(0, chunk_overlap)

    for doc in docs:
        sentences = split_sentences_zh(doc.page_content)
        if not sentences:
            continue

        current_sentences: list[str] = []
        current_len = 0

        for sentence in sentences:
            sent_len = len(sentence)

            # 单句本身超长,先把当前块落盘,再对超长句做兜底切分
            if sent_len > chunk_size:
                if current_sentences:
                    content = "".join(current_sentences).strip()
                    if content:
                        chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))
                    current_sentences = []
                    current_len = 0

                for sub in fallback_splitter.split_text(sentence):
                    sub = sub.strip()
                    if sub:
                        chunks.append(Document(page_content=sub, metadata=deepcopy(doc.metadata)))
                continue

            # 如果加上当前句会超长,则先输出当前块,再按 overlap 回带末尾句子
            if current_sentences and (current_len + sent_len > chunk_size):
                content = "".join(current_sentences).strip()
                if content:
                    chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))

                # 按字符数控制 overlap(以句子为单位回带,避免把句子切开)
                overlap_buf: list[str] = []
                overlap_len = 0
                for prev in reversed(current_sentences):
                    if overlap_len >= overlap_chars:
                        break
                    overlap_buf.insert(0, prev)
                    overlap_len += len(prev)

                current_sentences = overlap_buf
                current_len = sum(len(s) for s in current_sentences)

            current_sentences.append(sentence)
            current_len += sent_len

        if current_sentences:
            content = "".join(current_sentences).strip()
            if content:
                chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))

    return chunks


loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = sentence_aware_chunk_documents(
    docs=documents,
    chunk_size=CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
)

# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果(句子边界优先)===\n")
print(f"  Sentence-aware splitter -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(
    f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}"
)
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:

1773923355591.png

通过返回结果看,分块内的句子是完整的。这个方法与分层分块结合效果更好

父子文本分块

将文本切成子块和父块,其检索流程是,用子块向量搜索,命中子块后回溯拿到它对应的父块,把父块拼成上下文喂给LLM。

  • 子块:切的更小,用来做向量检索(更容易精准命中)。
  • 父块:比子块更大,用来给LLM作为更完整的上下文(避免只拿到碎片)。

代码如下:

import uuid
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"

loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()

parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=50)

parent_chunks = parent_splitter.split_documents(documents)

all_children = []
for parent in parent_chunks:
    parent_id = str(uuid.uuid4())[:8]
    parent.metadata["parent_id"] = parent_id

    children = child_splitter.split_documents([parent])
    for child in children:
        child.metadata["parent_id"] = parent_id
    all_children.extend(children)

# ---------- 打印父块 ----------
print(f"=== 父块(共 {len(parent_chunks)} 块,chunk_size=800)===\n")
for i, p in enumerate(parent_chunks, 1):
    pid = p.metadata["parent_id"]
    preview = p.page_content[:150] + "..." if len(p.page_content) > 150 else p.page_content
    print(f"[父块 {i}] id={pid}  长度={len(p.page_content)}")
    print(f"  {preview}")
    print()

# ---------- 打印子块(只展示前 3 个父块对应的子块)----------
print("=" * 80)
print(f"=== 子块(共 {len(all_children)} 块,chunk_size=200)===\n")

shown_parents = set()
for child in all_children:
    pid = child.metadata["parent_id"]
    if pid not in shown_parents:
        shown_parents.add(pid)
        if len(shown_parents) > 3:
            break
        print(f"  ┌─ 父块 id={pid}")

    siblings = [c for c in all_children if c.metadata["parent_id"] == pid]
    for j, sib in enumerate(siblings, 1):
        preview = sib.page_content[:100] + "..." if len(sib.page_content) > 100 else sib.page_content
        print(f"  │  子块 {j}/{len(siblings)}  长度={len(sib.page_content)}")
        print(f"  │  {preview}")
    print(f"  └─ 共 {len(siblings)} 个子块")
    print()

返回的部分结果:

1773924400688.png

1773924412258.png

检索时拿小块的 parent_id 回溯到父块,把父块的完整内容交给 LLM。

实现文本分块后的问答

说完分块思想,接下来让我们通过分块后的文本做个简单的RAG系统。实现流程如下:

RAG最小实现流程.png 在做RAG之前,有必要说明下嵌入模型和向量库。

嵌入模型

嵌入模型是把文本变成一组数字(向量)的模型,让计算机能“理解”文本的语义。

如人看到"营业收入增长"和"营收提升"会知道意思差不多,但计算机只认数字。嵌入模型的作用就是:

"营业收入增长"  →  [0.12, -0.33, 0.87, ..., 0.07]   (一个 1024 维的向量)
"营收提升"      →  [0.11, -0.31, 0.85, ..., 0.08]   (和上面很接近)
"今天天气不错"  →  [0.78,  0.42, -0.15, ..., 0.63]  (和上面离得远)
  • 语义相近->向量距离近
  • 语义无关->向量距离远

嵌入模型VS大语言模型(LLM)

嵌入模型 大语言模型(LLM)
输入 一段文本 一段文本(提示/对话)
输出 一个向量(一组数字) 文本(回答/续写)
用途 计算文本相似度、检索 理解问题、生成回答
RAG 中的角色 负责找到相关文档片段 负责根据片段回答问题

我用的线上嵌入模型是BAAI/bge-large-zh-v1.5,支持最大512个的token输入长度。

1773909383550.png

向量数据库

专门用来存储向量,按相似度搜索向量的数据库。文本切成块之后就会被嵌入模型转成向量,存入向量数据库。

传统数据库 向量数据库
存什么 行、列、文本、数字 向量(一组浮点数)
怎么查 WHERE name = '张三'(精确匹配) "找最像这个向量的 Top-K"(相似度匹配)
核心算法 B-tree 索引 ANN(近似最近邻)索引
实现代码
"""
基于 PDF 的 RAG 问答脚本:
加载 PDF → 分块 → 将分块内容作为上下文 → 使用 LLM 回答用户问题。
"""

import os
from dotenv import load_dotenv

load_dotenv()

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore

# ---------- 1. 加载 PDF ----------
loader = PyMuPDFLoader("../document/企业财务报表分析-图表.pdf")
documents = loader.load()

# ---------- 2. 文档分块 ----------
# chunk_size: 每块最大字符数;chunk_overlap: 块与块之间的重叠字符数,避免语义被截断
text_splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)

# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

# ---------- 4. 配置 LLM(代理地址与 API Key 从 .env 读取) ----------
llm = ChatOpenAI(
    model=os.getenv("PROXY_AI_MODEL", "gemini-2.5-flash"),
    base_url=os.getenv("PROXY_AI_BASE_URL"),
    api_key=os.getenv("PROXY_AI_API_KEY"),
    temperature=0.3,
    max_tokens=1024,
)

embeddings = OpenAIEmbeddings(
    model="BAAI/bge-large-zh-v1.5",
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    base_url="https://api.siliconflow.cn/v1",
    chunk_size=32,
)

vector_store = InMemoryVectorStore.from_documents(chunks, embeddings)

# ---------- 5. 构建提示与调用链 ----------
# 系统消息中注入 PDF 上下文,用户消息为问题
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是一个助手。请仅根据下面「PDF 内容」回答用户问题,不要编造。回答简洁。\n\nPDF 内容:\n{context}",
        ),
        ("human", "{question}"),
    ]
)
chain = prompt | llm

# ---------- 6. 交互式问答 ----------
print("基于 PDF 的问答(输入空行回车退出)\n")
while True:
    question = input("你的问题: ").strip()
    if not question:
        break
    # 把问题做成向量检索
    retrieved = vector_store.similarity_search(question, k=8)
    context = "\n\n".join(doc.page_content for doc in retrieved)
    answer = chain.invoke({"context": context, "question": question})
    print(f"回答: {answer.content}\n")

返回部分结果: 1773927282417.png

回答的结果对比文档出处:

1773927410613.png

1773927360666.png

1773927327431.png

总结
  • 字符分块:按一个分隔符切一次,超长也不管。
  • 递归分块:多级分隔符递归切,尽量控制块大小。
  • 句子边界:以句子为最小单位,不在句中截断。
  • 层级分块:先按章节结构切,再对超长段做二次切。
  • 滑动窗口:按固定字符数滑窗,重叠一段,块大小均匀。
  • 父子分块:小块检索、大块回答,检索细、回答有上下文。
结尾

文本分块的目的,是让每块内容更聚焦、语义更完整,从而提升RAG系统的检索准确度。好了,文档分块的内容就分享到这儿。在座的彦祖、亦菲们有什么好的文档分块方法,也欢迎到评论区讨论哦!

Vue-Vue2与Vue3核心差异与进化

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件

Vue2:数组/对象操作避坑大全

前言

在 Vue 2 开发中,你是否遇到过“明明数据变了,视图却没动”的诡异情况?这通常不是代码逻辑问题,而是由于 Vue 2 基于 Object.defineProperty 的响应式原理存在天然的局限性。本文将带你攻克这些响应式盲区。

一、 响应式的“硬伤”:为什么会失效?

Vue 2 在初始化阶段,会遍历 data 中的属性并使用 Object.defineProperty 将其转为 getter/setter

它的核心问题在于:

  1. 无法检测对象属性的添加或删除(因为它只在初始化时进行监听)。
  2. 无法检测数组索引的直接修改和长度变化

二、 对象操作:打破“属性新增”的僵局

1. 新增/删除属性

如果你直接通过 this.obj.newKey = value 赋值,Vue 是无法感知的。

  • 新增属性:使用 this.$set (或全局 Vue.set)。

    • 语法:this.$set(target, key, value)
    • 示例:this.$set(this.user, 'age', 18)
  • 删除属性:使用 this.$delete (或全局 Vue.delete)。

2. 批量修改属性

如果你需要一次性增加多个属性,不要写一堆 $setVue2 可以监听对象引用变化,最高效的方法是替换整个对象引用

// 这种方式 Vue 能够通过监听对象的引用变化来触发更新
this.user = Object.assign({}, this.user, {
  age: 18,
  gender: 'male'
});

// 批量更新user对象属性
this.user = {
  ...this.user,
  age: 20,
  gender: '男',
  address: '北京'
}

三、 数组操作:被“重写”的 7 个方法

在 Vue 2 中,直接执行 this.items[0] = 'new' 是不会触发更新的。解决方案同样是使用 this.$set,以及使用vue重写的相关数组方法。

1. 自动触发更新的方法

只要调用以下方法,Vue 就会自动检测到变化并更新视图:

  • push() / pop():队尾操作
  • unshift() / shift():队头操作
  • splice()最万能,可实现增、删、改。
  • sort():排序。
  • reverse():翻转。

2. 数组的特殊场景

  • 根据索引修改值

    • ❌ 错误:this.items[index] = newValue
    • ✅ 正确:this.$set(this.items, index, newValue)this.items.splice(index, 1, newValue)
  • 修改数组长度

    • ❌ 错误:this.items.length = 0 (清空数组失效)
    • ✅ 正确:this.items.splice(0)this.items = []

四、 进阶补充:Vue 3 是如何解决的?

  • Vue 3 使用了 ES6 Proxy:Proxy 代理的是整个对象而不是属性。

  • 优势:Proxy 可以原生监听到属性的动态添加、删除,以及数组索引的变化,因此在 Vue 3 中,你不再需要使用 $set 了!


五、 总结

  1. vue2对象新增属性:首选 this.$set,批量新增选 Object.assign

  2. vue2数组修改:养成使用 splicepush 等 7 个变异方法的习惯。

  3. 调试技巧:如果视图没更新,先用 console.log 确认数据是否变了,再检查是否触碰了上述响应式盲区。

Vue3:ref 与 reactive 超全对比

前言

在 Vue 3 的 Composition API 中,refreactive 是定义响应式数据的两大基石。很多初学者常纠结于“什么时候该用哪个”。本文将从底层原理到实战场景,带你彻底理清两者的区别。

一、 核心概念对比

1. ref:全能型选手

  • 定义:主要用于定义基本类型(String, Number, Boolean 等),也可以定义引用类型。

  • 本质:通过对原始值进行包装,生成一个具有 .value 属性的对象。对于引用类型,ref 内部会自动调用 reactive 来处理。

  • 访问控制

    • 在 JS 中必须通过 .value 访问;
    • <template> 模板中,Vue 会自动解包,直接写变量名即可,无需加 .value。

2. reactive:对象专家

  • 定义:专门用于定义引用类型(Object, Array, Map, Set)。

  • 本质:基于 ES6 Proxy 实现,直接代理整个对象。

  • 访问控制:像操作普通原生对象一样直接访问属性,无需 .value

    注意: 传入基本类型会触发 Vue 警告且丢失响应式。


二、 深度差异对比

特性 ref reactive
支持类型 基本类型 + 引用类型 仅限 引用类型
JS 访问方式 .value 直接访问属性
模板访问 自动解包,无需 .value 直接访问
底层实现 包装基本类型,内部调用 reactive 处理引用类型 基于 Proxy 深度代理整个对象
替换整个对象 支持 (ref.value = 新对象/新数组) 不支持(直接赋值会丢失代理,失去响应式)
解构支持 直接解构丢失响应式(需 toRefs 直接解构丢失响应式(需 toRefs

三、 使用场景:我该怎么选?

推荐使用 ref 的场景:

  1. 基本类型数据:计数器、开关状态、输入框的值。

  2. 需要重置的数据:例如从后端获取列表后,直接 list.value = res.data

  3. 简单组件逻辑:代码更清晰,.value 提醒这是一个响应式变量。

推荐使用 reactive 的场景:

  1. 复杂业务模型:包含多个相互关联属性的大对象(如用户信息、表单整组数据)。

  2. 追求原生感:不希望在逻辑代码中到处看到 .value

  3. 聚合数据:将一类变量聚合在一个对象中管理,减少变量声明。


四、 高频易错点

1. reactive 直接赋值整个对象会丢失响应式

let state = reactive({ count: 0 });
// ❌ 错误操作:这会导致 state 失去响应式,因为它变成了一个普通的普通对象
state = { count: 1 }; 

// ✅ 正确方案 A (ref):
const state = ref({ count: 0 });
state.value = { count: 1 };

// ✅ 正确方案 B (Object.assign):
Object.assign(state, { count: 1 });

2. 解构 reactive 数据丢失响应式

当你需要从一个响应式对象中提取属性并保持响应式时,必须使用 toRefs,否则会丢失响应式

const props = reactive({ title: 'Vue3', author: 'Gemini' });
// 直接解构:const { title } = props; -> title 只是一个普通的字符串
const { title } = toRefs(props); // -> title 变成了一个 ref,保持响应式

3. Watch 监听的差异

  • 监听 ref:默认只监听 .value 的变化,如果 ref 包裹的是对象,深度监听需要开启 { deep: true }

  • 监听 reactive:默认强制开启深度监听,且无法关闭。


📝 总结

  • ref 是万金油,虽然多了个 .value,但胜在灵活且不易出错。
  • reactive 适合组织复杂的对象数据,但要注意赋值和解构的陷阱。

JavaScript 对象操作进阶:从属性描述符到对象创建模式

背景与收益

在实际开发中,我们经常遇到这样的场景:需要批量创建结构相似的对象,或者需要精确控制对象属性的行为(可写、可枚举、可配置等)。如果只用最基础的对象字面量和 Object.defineProperty,代码会变得冗长且难以维护。

本文将带你深入理解:

  • 如何高效地批量定义对象属性及其描述符
  • JavaScript 提供的对象限制方法及其实战应用场景
  • 创建多个同类对象的最佳实践:工厂模式 vs 构造函数

适合已掌握 JavaScript 基础语法、希望提升对象操作能力的开发者。


一、批量定义对象属性

1.1 问题场景

在上一章节中,我们学习了 Object.defineProperty 来定义单个属性的描述符。但实际开发中,一个对象往往有多个属性需要配置。如果每个属性都调用一次 defineProperty,代码会非常冗余:

let obj = { JS: 1 };

Object.defineProperty(obj, 'name', {
  value: 'XiaoWu',
  writable: true,
  enumerable: true,
  configurable: true
});

Object.defineProperty(obj, 'age', {
  value: 18,
  writable: false,
  enumerable: true,
  configurable: true
});

能否通过遍历来优化?当然可以。

1.2 手动实现批量定义

我们可以将多个属性的描述符封装成对象,然后遍历处理:

let obj = {
  JS: 1
};

let props = {
  name: {
    value: 'XiaoWu',
    writable: true,
    enumerable: true,
    configurable: true
  },
  age: {
    value: 18,
    writable: false,
    enumerable: true,
    configurable: true
  }
};

function defineProperties(obj, properties) {
  for (let prop in properties) {
    // hasOwnProperty 用于判断是否为对象自有属性(非继承属性)
    if (properties.hasOwnProperty(prop)) {
      Object.defineProperty(obj, prop, properties[prop]);
    }
  }
  return obj;
}

defineProperties(obj, props);

console.log(obj.name);  // XiaoWu
console.log(obj.age);   // 18

1.3 原生方法:Object.defineProperties

JavaScript 原生提供了 Object.defineProperties 方法,功能与我们手动实现的一致,但处理了更多边界情况:

Object.defineProperties(obj, props);

实战案例:私有属性的访问控制

在实际开发中,我们常用 _ 前缀标识私有属性,并通过 getter/setter 控制访问:

var obj = {
  _age: 20  // 私有属性,存储真实数据
};

Object.defineProperties(obj, {
  name: {
    configurable: true,
    enumerable: true,
    value: "小吴",
    writable: true
  },
  age: {
    configurable: false,
    enumerable: false,  // 不可枚举,for-in 遍历时不会出现
    get: function() {
      return this._age;
    },
    set: function(value) {
      this._age = value;
    }
  }
});

console.log(obj.age);  // 20
console.log(obj);      // { _age: 20, name: '小吴' }  注意:age 不可枚举
obj.age = 18;
console.log(obj.age);  // 18

设计思想

  • _age 是真实数据存储,外部不应直接访问
  • age 是对外暴露的接口,通过 getter/setter 控制访问逻辑
  • 这种"马甲模式"可以在 setter 中加入校验、日志等逻辑,保证数据安全

1.4 对象字面量中的 getter/setter

除了使用 defineProperties,我们也可以直接在对象字面量中定义 getter/setter:

var obj = {
  _age: 20,
  set age(value) {
    this._age = value;
  },
  get age() {
    return this._age;
  }
};

两种写法的差异

写法 控制台输出 精细控制
对象字面量 { _age: 20, age: [Getter/Setter] } 无法配置 configurable/enumerable
defineProperties { _age: 20 } 可精确控制所有描述符

图 1:getter/setter 在终端的表达形式

选择建议

  • 简单场景:直接在对象字面量中定义,代码更简洁
  • 需要精细控制(如设置不可枚举):使用 defineProperties

二、对象方法补充

2.1 获取属性描述符

之前我们提到,[[]] 标记的内部属性无法直接访问,需要通过特定 API 获取:

// 获取单个属性的描述符
Object.getOwnPropertyDescriptor(obj, prop);

// 获取所有自有属性的描述符
Object.getOwnPropertyDescriptors(obj);

示例

var obj = {
  names: "小吴",
  age: 18
};

console.log(Object.getOwnPropertyDescriptor(obj, 'names'));
// { value: '小吴', writable: true, enumerable: true, configurable: true }

console.log(Object.getOwnPropertyDescriptors(obj));
// {
//   names: { value: '小吴', writable: true, enumerable: true, configurable: true },
//   age: { value: 18, writable: true, enumerable: true, configurable: true }
// }

图 2:obj 对象的属性描述符详情

2.2 对象限制方法

JavaScript 提供了三个方法来限制对象的可变性,它们的限制程度逐级递增:

2.2.1 Object.preventExtensions - 禁止扩展

禁止给对象添加新属性,但可以修改和删除现有属性:

var obj = {
  names: "小吴",
  age: 18
};

Object.preventExtensions(obj);
obj.newProperty = 'new';  // 添加失败(严格模式下报错)
console.log(obj.newProperty);  // undefined

2.2.2 Object.seal - 密封对象

preventExtensions 基础上,将所有现有属性的 configurable 设为 false,禁止删除和重新配置属性:

Object.seal(obj);
delete obj.age;  // 删除失败
console.log(obj.age);  // 18
obj.names = "JS高级";  // 可以修改值
console.log(obj.names);  // JS高级

2.2.3 Object.freeze - 冻结对象

seal 基础上,将所有现有属性的 writable 设为 false,完全冻结对象:

Object.freeze(obj);
obj.names = "why";  // 修改失败
console.log(obj.names);  // JS高级

实战应用:Vue 性能优化

在 Vue 中,响应式系统会劫持对象的 getter/setter。如果有大量静态数据(如几十万条配置数据)不需要响应式,可以用 Object.freeze 冻结,避免 Vue 进行响应式处理,显著提升性能:

// 大量静态数据
const staticData = Object.freeze([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  // ... 几十万条
]);

export default {
  data() {
    return {
      list: staticData  // 不会被 Vue 响应式处理
    };
  }
};

三种方法对比

方法 禁止新增 禁止删除 禁止修改值 禁止重新配置
preventExtensions
seal
freeze

三、创建多个对象的方案

3.1 问题场景

假设我们需要创建多个 Person 对象,每个对象都有 name、age、sex、address 等属性,以及 eating、running 等方法。如果用对象字面量:

var p1 = {
  name: "小吴",
  age: 20,
  sex: "男",
  address: "福建",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

var p2 = {
  name: "why",
  age: 35,
  sex: "男",
  address: "广州",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

问题:代码重复率极高,难以维护。

解决方案

  1. 工厂模式
  2. 构造函数
  3. ES6 Class(后续章节)
  4. 原型 + Object.create(后续章节)

本文重点讲解前两种。

3.2 方案一:工厂模式

3.2.1 基本实现

工厂模式的核心思想:抽离共性,参数化差异,流水线生产

function createPerson(name, age, sex, occupation, address) {
  var p = new Object();
  p.name = name;
  p.age = age;
  p.sex = sex;
  p.occupation = occupation;
  p.address = address;
  p.eating = function() {
    console.log(this.name + "在吃满汉全席");
  };
  return p;
}

var p1 = createPerson("小吴", 20, "男", "大三学生", "福建");
var p2 = createPerson("why", 35, "男", "全栈工程师兼教师", "广州");

console.log(p1, p2);

图 3:new 调用所产生的结构共性

3.2.2 工厂模式的缺点

  1. 类型信息丢失:所有对象的类型都是 Object,无法区分是 Person 还是其他类型
  2. 无法利用原型链:每个对象都有自己的方法副本,无法共享,浪费内存
  3. 调试困难:堆栈跟踪中难以定位对象的创建源
console.log(p1);  // Object { name: '小吴', age: 20, ... }
// 无法看出这是一个 Person 对象

适用场景

  • 简单的对象创建,不需要类型区分
  • 临时性的数据结构封装

3.3 方案二:构造函数

3.3.1 什么是构造函数

构造函数本质上是普通函数,但通过 new 关键字调用时,会执行特殊的对象创建流程:

function foo() {
  console.log("foo~");
}

// 普通调用
foo();

// 构造函数调用
new foo();  // 或 new foo

3.3.2 new 操作符的执行流程

当使用 new 调用函数时,会自动执行以下步骤:

  1. 在内存中创建一个新的空对象
  2. 将这个对象的 [[Prototype]] 指向构造函数的 prototype 属性
  3. 将构造函数内部的 this 指向这个新对象
  4. 执行构造函数的代码(给 this 添加属性)
  5. 如果构造函数返回一个对象,则返回该对象;否则返回步骤 1 创建的对象
function foo() {
  // 内部隐式执行:
  // var obj = {};
  // this = obj;
  console.log("foo~");
  // 隐式返回 this
}

var f1 = new foo();  // foo~
console.log(f1);     // foo {}

类型验证

function XiaoWu(name) {
  this.name = name;
  console.log("我是小吴");
}

var f1 = new XiaoWu("小吴");  // 我是小吴
console.log(f1);  // XiaoWu { name: '小吴' }
console.log(f1.__proto__.constructor.name);  // XiaoWu

3.3.3 构造函数实现

function Person(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new Person("小吴同学", 20, "男", "福建");
console.log(f1);
// Person {
//   name: '小吴同学',
//   age: 20,
//   sex: '男',
//   address: '福建',
//   eating: [Function (anonymous)],
//   running: [Function (anonymous)]
// }

var f2 = new Person("小满zs", 23, "男", "北京");
var f3 = new Person("洛洛", 20, "萌妹子", "福建");

图 4:构造函数 Person 调用结果

3.3.4 如何识别构造函数

构造函数与普通函数在语法上没有区别,社区约定了以下规范:

  1. 命名规范:首字母大写,使用大驼峰命名(PascalCase)
  2. 编辑器提示:当函数内使用 this 赋值时,编辑器会提示"此构造函数可能会转换为类声明"
function XiaoWu(name) {
  this.name = name;  // 使用 this 赋值,编辑器识别为构造函数
}

图 5:如何区分是否为构造函数(编辑器中的构造函数)

注意:只有通过 new 调用时,函数才真正成为构造函数。

3.3.5 构造函数的缺点

每次创建对象时,方法都会被重新创建,导致内存浪费:

function foo() {
  function bar() {
    console.log("你猜一不一样");
  }
  return bar;
}

var f1 = foo();
var f2 = foo();
console.log(f1 === f2);  // false  每次调用都创建新的函数对象

应用到构造函数

function XiaoWu(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  // 每次 new 都会创建新的函数对象
  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new XiaoWu("小吴同学", 20, "男", "福建");
var f2 = new XiaoWu("小吴同学", 20, "男", "福建");

console.log(f1.eating === f2.eating);  // false
console.log(f1.running === f2.running);  // false

问题分析

  • 虽然 f1f2eating 方法功能完全相同,但它们是两个不同的函数对象
  • 当创建大量实例时,会造成内存浪费

解决方案:使用原型(Prototype),将方法定义在原型上,所有实例共享。这将在下一章节详细讲解。


四、工厂模式 vs 构造函数

对比维度 工厂模式 构造函数
调用方式 普通函数调用 使用 new 关键字
类型识别 所有对象都是 Object 可以识别具体类型(如 Person
原型链 无法利用 可以利用原型共享方法
内存占用 每个对象独立方法 每个对象独立方法(未优化时)
代码复杂度 简单直观 需要理解 newthis
适用场景 简单对象创建 需要类型区分和原型链的场景

选择建议

  • 简单场景、不需要类型区分:工厂模式
  • 需要类型识别、后续会用到原型链:构造函数
  • 现代开发:优先使用 ES6 Class(本质是构造函数的语法糖)

五、实战建议

5.1 属性描述符使用场景

  1. 配置对象保护:将配置对象冻结,防止被意外修改
  2. 私有属性模拟:通过不可枚举 + getter/setter 实现访问控制
  3. 数据校验:在 setter 中加入校验逻辑

5.2 对象创建模式选择

  1. 单个对象:对象字面量
  2. 少量同类对象:工厂模式或构造函数
  3. 大量同类对象:构造函数 + 原型(下一章)
  4. 现代项目:ES6 Class

5.3 性能优化要点

  1. 避免在构造函数中定义方法:应该定义在原型上(下一章详解)
  2. 大量静态数据使用 Object.freeze:特别是在 Vue 等响应式框架中
  3. 合理使用属性描述符:不要过度使用,会增加代码复杂度

六、总结与下一步

6.1 核心要点

  1. Object.defineProperties 可以批量定义属性描述符,比多次调用 defineProperty 更高效
  2. preventExtensionssealfreeze 三个方法提供了不同级别的对象保护
  3. 工厂模式简单直观,但无法识别对象类型
  4. 构造函数通过 new 调用,可以创建具有特定类型的对象
  5. 构造函数的缺点是方法无法共享,需要通过原型解决

6.2 遗留问题

在本文中,我们多次提到"原型"(Prototype),并且发现构造函数存在方法无法共享的问题。在控制台查看对象时,总能看到神秘的 [[Prototype]] 属性:

图 6:对象中的原型世界

6.3 下一章预告

在下一章节中,我们将深入学习:

  • 什么是原型(Prototype)和原型链
  • 如何通过原型实现方法共享,解决构造函数的内存浪费问题
  • 原型链的查找机制和继承原理
  • 大量内存图帮助理解原型的指向关系

原型是 JavaScript 中最重要的概念之一,理解原型是掌握 JavaScript 面向对象编程的关键。


MiniMax 发布 M2.7,Agent 开始走向自我进化

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

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

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

3月18日,MiniMax 发布新一代 Agent 旗舰大模型 M2.7。如果只看表面,这像是一次常规的模型升级。但从公开信息来看,M2.7 真正值得关注的,不只是分数更高、能力更强,而是它首次对外展示了一条更具代表性的技术路线,也就是"模型自我进化"。

根据 3 月 18 日公开报道,M2.7 通过构建 Agent Harness 体系,让模型深度参与自身训练与优化流程。在部分研发场景中,这套机制已经可以承担 30% 到 50% 的工作量,并在内部评测集上带来约 30% 的效果提升。这个表述背后释放出的信号很明确,AI 正在从"回答问题的模型"迈向"能够参与迭代自身能力的系统"。

这次发布最重要的,不只是性能提升

过去很长一段时间,行业讨论大模型,重点往往集中在参数规模、训练成本、推理速度以及 benchmark 排名上。但 Agent 时代的竞争逻辑已经开始变化。真正决定模型价值的,越来越不是单点能力,而是它能不能进入真实工作流,承担连续任务,并在执行过程中形成可积累、可复用、可优化的闭环。

M2.7 这次最大的不同,就在于它不再只是研发流程中的被优化对象,而开始成为研发流程中的参与者。

所谓 Agent Harness,可以把它理解为围绕 Agent 构建的一整套执行、反馈、优化机制。模型不只是完成任务,还会进入任务分解、流程回放、错误暴露、策略修正和样本反馈等环节。这样做的意义不在于概念新,而在于它把模型能力提升从"一次性训练结果"推进到了"持续演化过程"。

换句话说,过去我们更熟悉的是"训练一个更强的模型",而 M2.7 想展示的是"让模型参与把自己变得更强"。

工程能力,已经开始逼近一线水位

从公开成绩来看,M2.7 在工程场景上的表现是这次发布的另一大看点。

根据当日披露数据,M2.7SWE-bench Pro 上取得了 56.22% 的成绩。这个指标之所以重要,是因为它衡量的不是简单补全代码,而是真实软件工程环境中的问题理解、代码修改、上下文追踪和任务闭环能力。能在这个测试里打出有竞争力的成绩,说明模型已经不只是"会写代码",而是更接近"能参与工程"。

与此同时,M2.7VIBE-ProTerminal Bench 2 等更接近真实研发流程的测试中也有突出表现。公开说法中提到,它已经能够支持端到端项目交付与复杂系统理解。这一点比单个 benchmark 分数更值得重视,因为真实企业环境看重的从来不是一道题做对,而是模型能否在复杂上下文里持续完成任务。

从研发团队视角看,这意味着 Agent 的角色正在发生变化。它不再只是辅助写一段函数、解释一条报错,而是开始承担更完整的工作单元,比如理解项目结构、分析系统依赖、处理跨文件修改,甚至在终端和工程环境中完成连续操作。

如果这个趋势持续下去,开发团队对 AI 的期待也会随之改变。未来最有价值的模型,不一定是最会答题的那个,而是最能稳定交付结果的那个。

办公场景,开始成为另一条主战线

除了工程能力,M2.7 在办公场景上的提升也非常值得注意。

公开信息显示,它在 GDPval-AA 上取得了 1495 的 ELO 得分,并被描述为开源最高。同时,模型在 Office 文档处理、多轮编辑、复杂内容整理等任务上的表现也有明显增强。

这背后其实说明了一件事,MiniMax 对 M2.7 的定位,并不是单纯的代码模型,而是更偏向通用生产力 Agent。它既要能进入开发流程,也要能进入知识工作和协作流程。因为在真实企业场景里,研发、产品、运营、文档、汇报、分析并不是割裂存在的,大家需要的是一个能够跨场景接手任务的系统,而不是一个只能在单点场景里亮眼的模型。

从这个角度看,办公能力的提升并不是"附加项",而是 Agent 真正走向大规模落地的必要条件。

为什么“自我进化”这四个字值得单独拎出来看

这次发布里,最值得继续观察的,仍然是"模型自我进化"这条路线。

过去行业谈 Agent,经常会关注几个关键词,比如工具调用、长任务拆解、环境感知、记忆能力、多智能体协作。这些能力当然都很重要,但如果只停留在"会不会调用工具"这一层,Agent 的上限其实并不高。

更深的问题在于,当模型已经能完成任务之后,它能不能利用任务执行过程反过来优化自己。

如果答案是可以,那么大模型的发展路径就会发生结构性变化。未来领先的,不只是训练出一个更强基础模型的公司,而是能建立一套完整演化系统的公司。模型做任务,任务产反馈,反馈进入优化,优化再反哺下一轮任务执行。这样的闭环一旦跑顺,AI 的进步速度就不再完全依赖人工标注和传统训练流程,而会更多来自系统自身在真实世界里的持续学习能力。

这也是 M2.7 这次发布最有想象空间的地方。它传递的已经不是简单的"又一个更强模型来了",而是 Agent 正在从工具形态向系统形态迁移。

这次发布意味着什么

M2.7 目前已经在 MiniMax Agent 与开放平台上线。对开发者来说,这意味着相关能力不再只是实验室概念,而是已经开始进入可调用、可接入、可验证的产品阶段。对行业来说,这次发布的意义可能也不止于一次模型升级。

它更像一个明确信号,AI 竞争正在从"谁的模型更会说"进入"谁的系统更会做"。而在"会做"之后,下一个更关键的问题就是,谁能最先构建出真正有效的自我演化闭环。

如果说过去的大模型更像工具,那么 M2.7 想证明的是,Agent 正在变成系统。再往前一步,它甚至可能变成一种具备持续自我改进能力的数字生产力基础设施。

这或许才是 3 月 18 日这场发布最值得被记住的地方。

Vue-Vue Router核心原理+实战用法全解析

前言

无论是单页面应用(SPA)还是复杂的后台管理系统,路由(Router)都是其灵魂。它通过 URL 映射组件,实现了无刷新的页面切换。本文将从底层原生 API 出发,带你彻底弄懂 Vue Router 的运行机制。

一、 路由的本质:Hash vs History

前端路由的核心是:改变 URL,页面不刷新,但渲染不同的组件。 Vue Router 本质上是基于浏览器原生的 window.location.hashhistory API 实现的,通过监听 URL 变化,动态匹配路由规则并渲染对应组件,无需后端参与页面切换。

1. Hash 模式 (window.location.hash)

  • URL 特征:路径中携带# 符号,例如 http://xxx.com/#/homehttp://xxx.com/#/about
  • 底层依赖window.location.hash
  • 核心特性:URL 中 # 后的内容属于锚点定位,不会发送到服务器端,所有前端路由请求最终都会指向 域名/index.html,服务器只需返回首页文件即可。
  • 优势:无需额外配置服务器,刷新页面、直接访问子路由都不会出现 404 错误,兼容性极强。

2. History 模式 (window.history)

  • URL 特征:路径中无 # 符号,形态更简洁,例如 http://xxx.com/homehttp://xxx.com/about
  • 底层依赖:浏览器原生 history API
  • 核心坑点:当用户刷新页面、直接访问子路由时,浏览器会向服务器发送对应路径的 GET 请求(如请求 /home),如果服务器未配置路由指向,会直接返回 404 错误。
  • 解决方案:必须在 Nginx 等服务器中配置规则,将所有路由请求都指向项目入口 index.html,由前端路由接管匹配逻辑。
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
  # 关键:找不到资源时返回 index.html
  try_files $uri $uri/ /index.html; 
}

二、 底层原理实现

1. Hash 模式实现链路

  • 监听变化:基于 windowhashchange 事件,监听 URL 中 hash 值的变化。
  • 设置值:修改 location.hash手动修改路由路径。
  • 跳转:使用 location.assign()实现路由跳转。
  • 获取当前路径:通过 location.hreflocation.hash 解析。

2. History 模式实现链路

  • 监听变化:基于浏览器原生 popstate 事件,仅监听浏览器前进/后退操作触发的路由变化。

    ⚠️ 避坑点:调用 history.pushStatereplaceState 改变 URL 时,并不会触发 popstate。Vue Router 内部通过劫持这些方法手动触发了更新。

  • 操作记录

    • pushState(stateObj, title, url):添加历史记录。
    • replaceState(stateObj, title, url):替换当前记录。
  • 获取路径:基于 window.location.pathname获取纯路径部分。

  • 状态存储:通过 history.state 获取传给 pushState 的自定义对象。


三、 Vue 路由跳转实战

方法一:声明式导航 <router-link>

这是日常开发中最常用的方式,本质是对 <a> 标签的封装,默认无刷新跳转,语法简洁且支持路由参数传递。核心参数如下:

  • to(必传) :目标路由路径,支持字符串格式和对象格式

    • 字符串格式:<router-link to="/home">首页</router-link>
    • 对象格式:可搭配 name、query、params 实现精细化跳转
  • name:通过路由名称跳转(推荐,避免路径硬编码),示例::to="{ name: 'About' }"

  • query:传递查询参数,参数会拼接在 URL 中(刷新不丢失),示例::to="{ name: 'About', query: { name: 'test' } }",最终 URL:/about?name=test

  • params:传递动态路由参数,参数不会拼接在 URL(刷新会丢失),必须配合 name 使用,示例::to="{ name: 'About', params: { id: 123 } }"

    注意:若路由规则中未定义动态参数(如 :id),仅通过 name + params 传参,刷新页面后 params 会丢失;

    解决办法:在路由规则中添加 :id(必传)或 :id?(可选),例如 path: '/about/:id?'

方法二:编程式导航 useRouter

通过 useRouter 获取路由实例,用代码控制路由跳转,适合非点击触发的场景(如接口请求成功后跳转、条件判断跳转、定时器跳转等)

<script setup>
import { useRouter } from 'vue-router'
// 获取路由实例
const router = useRouter()

// 编程式跳转
const goToPage = () => {
  // 1. push 跳转(新增历史记录,可返回)
  router.push('/home')
  // 对象格式跳转
  router.push({ name: 'About', query: { name: 'test' } })

  // 2. replace 跳转(替换历史记录,不可返回)
  router.replace('/about')

  // 3. 路由前进/后退
  router.go(-1) // 后退一页
  router.back() // 后退一页(等价 go(-1))
  router.forward() // 前进一页(等价 go(1))
}
</script>

四、 Vue 路由监听三大方法

Vue 监听路由变化,本质是监听 route 对象(包含 path/params/query 等属性)的变化,触发自定义回调函数,常用于路由切换时更新数据、重置状态等场景.

1. 使用 watch + useRoute

通过 useRoute 获取当前路由对象,搭配 watch 监听器实现路由变化监听,支持立即执行、深度监听,适用性最广。

const route = useRoute();

watch(
  () => route.query,
  (newQuery) => {
    console.log('搜索参数变了:', newQuery);
  },
  { immediate: true, deep: true } // immediate 确保初始化时执行
);

2. 路由守卫 onBeforeRouteUpdate

Vue Router 提供的导航守卫,仅在组件复用时触发(例如 /detail/123/detail/456),路由跳转到其他组件时不会触发,适合列表页跳转详情页等场景。

  • 优点:不需要 watch 那么大的开销,专门针对参数更新。
  • 局限:离开该组件或首次进入时不触发。
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'

// 组件复用时触发
onBeforeRouteUpdate((to, from) => {
  console.log('即将跳转至:', to.path)
  console.log('从:', from.path, '跳转而来')
  // 可在此处更新组件数据
})
</script>

3. 原生监听(底层方案)

直接监听浏览器原生路由事件,脱离 Vue Router API 实现监听,适合特殊定制场景,需注意事件解绑避免内存泄漏。

window.addEventListener('popstate', callback)


性能优化:CDN 缓存加速与调度原理

前言

在前端性能优化中,静态资源加载速度往往是首屏渲染的瓶颈。CDN(Content Delivery Network) 通过将资源分发至全球各地的边缘节点,实现了“物理距离”上的访问加速。本文将带你深入 CDN 的内部,看它是如何通过 DNS 调度实现就近访问的。

一、 核心概念:什么是 CDN?

CDN 是一种分布式网络构建。它通过在全国各地(乃至全球)部署海量边缘节点服务器,缓解因用户地域差异、带宽不同、服务器距离过远导致的访问延迟问题,让用户就近获取所需资源,大幅提升网站响应速度、访问成功率,同时减轻源服务器压力。

1. 解决的痛点

  • 物理距离过远:跨国、跨省访问带来的高延迟。
  • 运营商带宽瓶颈:跨运营商(如电信访问联通)的互联互通问题。
  • 源站压力过大:热点资源引发的服务器并发冲击。

二、 深度拆解:CDN 的通信与调度流程

当用户在浏览器输入一个使用了 CDN 的域名时,背后的解析流程比普通 DNS 复杂得多,CDN具体通信调度流程如下:

  1. 域名解析请求:用户在浏览器输入域名,浏览器向本地DNS服务器请求解析,获取对应IP地址。

  2. CNAME 指向:DNS服务器不会直接返回源站IP,而是返回一个CNAME(别名记录) ,该记录指向CDN专用的全局负载均衡(GSLB)系统。。

  3. 智能调度计算:浏览器重新向CDN全局负载均衡系统发起请求。GSLB 会根据以下维度进行综合计算:

    • 地理位置:用户 IP 距离哪个节点最近?
    • 运营商环境:用户是移动还是电信?选择匹配的线路。
    • 节点健康度:目标服务器当前的负载和带宽是否充足?
    • 资源命中情况:请求的资源在哪个节点有缓存?
  4. 返回边缘节点 IP:GSLB 选择一个最优的区域负载均衡设备(SLB) ,并将这个边缘节点的IP地址返回给用户浏览器。

  5. 资源获取与回源

    • 命中(Hit) :用户向该 IP 请求,边缘节点直接返回资源。
    • 回源(Miss) :如果该节点无缓存,则逐级向上寻找,直至回到源站服务器拉取内容并缓存到本地。

核心逻辑:用户永远不直接访问源站,而是访问CDN边缘节点,源站只负责提供原始资源,极大降低源站压力。


三、 评价指标:如何衡量 CDN 的服务质量?

CDN 的核心价值在于“命中”,我们通常用以下两个指标来评估:

指标 定义 理想状态
命中率 (Hit Rate) 用户访问的资源恰好在CDN节点缓存系统中的比例 越高越好。代表 CDN 拦截了大部分请求,减轻了源站压力。
回源率 (Origin Pull Rate) 用户访问的资源CDN节点无缓存/缓存过期,必须向上级节点或源站请求资源的次数,占总访问次数的比例。 越低越好。高回源率可能导致源站带宽瞬间爆满。

四、 进阶实战:CDN 预热与刷新

在实际项目部署中,我们经常会听到两个核心操作:

1. CDN 预热 (Pre-warming)

  • 场景:大版本上线或活动开启前(如双 11)。
  • 操作:主动将源站资源推送到全国各地的 CDN 节点。
  • 效果:用户在第一波访问时就能直接“命中”,避免瞬间大量请求涌向源站导致崩溃。

2. CDN 刷新 (Refresh)

  • 场景:修复了紧急 Bug,更新了相同文件名的静态资源。
  • 操作:强制清除节点上的缓存。用户下次访问时将触发回源。
  • 优化:推荐在打包时使用 Content Hash(如 main.v123.js),通过文件名变更自然失效,而非手动刷新。

五、 最佳实践:前端如何使用 CDN?

1. 第三方库托管

对于成熟的库(Vue, React, Echarts, Axios),直接使用公共 CDN(如 cdnjs, unpkg, 静态资源库)。

  • 优点:减少自建服务器带宽压力;利用浏览器缓存(如果用户在别的网站也加载过同一个 CDN 链接,则无需下载)。
    <!-- 示例:CDN引入Vue、Axios、ECharts -->
    <script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    

2. 静态资源部署

将打包生成的 dist 目录(JS、CSS、图片)直接部署至云厂商的 对象存储(如阿里云 OSS, 腾讯云 COS) 并绑定 CDN 加速。

  • 策略:HTML 放在自己的服务器(防止缓存无法更新),而静态资源全走 CDN。

六、核心总结

  • CDN本质:分布式节点+就近访问+缓存加速,解决远程访问延迟、源服务器压力大的问题
  • 调度核心:DNS解析→CNAME指向→负载均衡选最优节点→节点缓存响应
  • 质量关键:命中率越高、回源率越低,CDN加速效果越好
  • 前端用法:第三方库直引、项目dist资源上传部署,是必备性能优化手段

为什么资深前端都在悄悄学 WebAssembly?

2026 年,WebAssembly(WASM)早已不是“前端黑科技”。
它正以静默的方式,重构前端工程师的能力边界

你可能以为 WASM 只是用来加速图像处理或跑个游戏引擎。
但真相是:顶尖团队用 WASM 解决的,从来不是“快一点”的问题,而是“能不能做”的问题

以下是资深前端不敢公开说、却在疯狂投入的 4 个真实原因。


原因1:JavaScript 的“能力天花板”,正在被 WASM 击穿

JavaScript 无法做这些事:

  • 直接操作二进制数据流(如解析 .zip、.pdf、.dwg)
  • 实现确定性浮点运算(金融/科学计算要求 IEEE 754 严格一致)
  • 运行成熟的 C/C++/Rust 生态库(如 OpenCV、FFmpeg、TensorFlow Lite)

而 WASM 可以。

案例:某在线 CAD 平台,将 Autodesk 的 C++ 渲染引擎编译为 WASM,直接在浏览器中打开 500MB 的工程图纸——过去这只能靠桌面软件。

这意味着什么?
前端不再只是“调 API + 写 UI”,而是能构建真正的生产力工具:视频剪辑器、3D 建模器、代码编译器、甚至操作系统模拟器。


原因2:WASM 是对抗“框架内卷”的终极武器

React、Vue、Svelte……框架月月新,API 天天变。
但 WASM 模块一旦编译,十年后仍可运行

更关键的是:WASM 与框架无关
你用 Rust 写的核心算法模块,今天嵌入 React,明天迁到 Svelte,后天跑在 Deno Edge Runtime——零改造成本

资深前端的焦虑,不是学不动新框架,而是怕自己变成“API 搬运工”。
而 WASM 让你沉淀可复用、跨平台、高壁垒的核心逻辑


原因3:隐私合规时代,WASM 是“数据不出浏览器”的唯一解

GDPR、CCPA、中国《个人信息保护法》……全球监管趋严。
用户数据一旦传到服务器,就是法律风险。

而 WASM 允许你在浏览器沙箱内完成敏感计算

  • 人脸模糊(不上传原始照片)
  • 医疗影像分析(DICOM 文件本地处理)
  • 财务报表加密(密钥永不离开设备)

某欧洲银行用 WASM 实现本地 KYC 验证,用户上传身份证 → 浏览器内 OCR + 活体检测 → 仅上传验证结果
合规成本下降 70%,用户信任度飙升。


原因4:WASM 正在成为“全栈统一语言”的桥梁

过去:前端写 JS,后端写 Go/Rust,算法写 Python——三套代码,三套部署,三套调试。

现在:用 Rust 写一次核心逻辑,编译成 WASM(前端用) + Native(后端用) + CLI(运维用)

案例:一个加密货币钱包项目

  • 浏览器端:WASM 运行签名算法
  • 移动端:Rust Native 库
  • 后台服务:同一份 Rust 代码编译为 gRPC 服务
    三端逻辑 100% 一致,漏洞率下降 90%

这不仅是效率提升,更是工程可靠性的质变


但别被 hype 蒙蔽:WASM 不是万能药

资深前端之所以“悄悄学”,是因为他们清楚 WASM 的边界:

  • 不能操作 DOM(必须通过 JS 调用)
  • 启动有冷启动开销(不适合高频小函数)
  • 调试体验仍弱于 JS(但 DevTools 已支持 WASM Source Map)

所以,WASM 的正确姿势是:JS 负责交互,WASM 负责计算——两者协同,而非替代。


如何开始?三条务实路径(2026 年最新)

  1. 从“痛点场景”切入

    • 图片/视频处理 → 试试 ffmpeg.wasm
    • 加密/哈希 → 用 wasm-crypto
    • 数学计算 → 编译 Eigen(C++ 线性代数库)
  2. 选择友好语言

    • Rust + wasm-pack(生态最成熟)
    • AssemblyScript(TypeScript 子集,学习曲线平缓)
    • C/C++ + Emscripten(适合移植现有库)
  3. 集成现代工具链

    // Vite / Webpack 5 原生支持 .wasm
    import init, { run_algorithm } from './pkg/my_wasm.js';
    await init();
    const result = run_algorithm(input);
    

结语:WASM 不是前端的终点,而是“能力主权”的起点

当别人还在争论“React vs Vue”,
聪明人已经用 WASM 把浏览器变成了通用计算终端

2026 年,前端工程师的价值,不再由“会几个框架”定义,
而由“能否用 WASM 解决别人解决不了的问题”决定。

未来的全栈开发者,左手 JS,右手 WASM。

学 WASM,不是为了取代 JavaScript,
而是为了让 JavaScript,只做它该做的事。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

【JavaScript面试题-this 绑定】请说明 `this` 在不同场景下的指向(默认、隐式、显式、new、箭头函数)。

今天我们来聊一聊 JavaScript 中一个既基础又让人头疼的概念——this

一、this 是什么?

简单来说,this 是函数执行时内部自动生成的一个对象,它指向调用该函数的上下文。你可以把它理解为函数内部的“环境变量”,代表了当前函数运行时所处的对象。

一个形象的比喻

想象一下,你有一个“自我介绍”的功能,不同的人调用它时,“我”这个字指向不同的人:

  • 小明说“叫小明”,这里的“我”就是小明。
  • 小红说“叫小红”,这里的“我”就是小红。

在 JavaScript 中,this 就像这句话里的“我”,而那个自我介绍的函数就像一句模板:“我叫 xxx”。这个模板里的 this.name 会根据是谁在调用而自动替换成对应的人名。

用代码表示:

javascript

function introduce() {
  console.log(`我叫 ${this.name}`);
}

const ming = { name: '小明', introduce };
const hong = { name: '小红', introduce };

ming.introduce(); // 我叫 小明(this 指向 ming)
hong.introduce(); // 我叫 小红(this 指向 hong)

这里的 introduce 函数内部的 this 就像“我”一样,随着调用者(ming 或 hong)不同,指向也不同。这就是 this 的动态性——它是在函数执行时,根据调用它的对象确定的。

二、this 能做什么?

理解了 this 是动态上下文,那么它能为我们做什么呢?

  • 让同一个函数服务于不同的对象,实现代码复用;
  • 在构造函数中初始化实例属性
  • 在事件处理中方便地访问触发元素
  • 显式地指定上下文,借用其他对象的方法
  • 在回调函数中优雅地保留外层 this

下面我们就通过一个个实战场景,来体会 this 的妙用。


三、实战场景一网打尽

场景1:对象方法中的 this —— 隐式绑定

假设我们有一个用户对象,需要输出用户的名称:

javascript

const user1 = {
  name: '小明',
  greet() {
    console.log(`大家好,我是 ${this.name}`);
  }
};

user1.greet(); // 大家好,我是 小明

当 greet 作为 user1 的方法被调用时,this 指向 user1,所以能正确访问 name

能做什么:我们可以定义多个类似的对象,使用同一个方法结构,轻松访问各自的数据。

陷阱:如果把方法赋值给一个变量再调用,this 就会丢失:

javascript

const fn = user1.greet;
fn(); // 大家好,我是 undefined (非严格模式下 this 指向 window,没有 name 属性)

解决方法:使用 bind 强制绑定 this,或者用箭头函数(后面会讲)。


场景2:构造函数中的 this —— new 绑定

在面向对象编程中,我们经常用构造函数来创建对象:

javascript

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.intro = function() {
    console.log(`我叫 ${this.name},今年 ${this.age} 岁。`);
  };
}

const p1 = new Person('小红', 20);
p1.intro(); // 我叫 小红,今年 20 岁。

当使用 new 调用 Person 时,this 指向新创建的空对象,然后我们往这个对象上添加属性,最后返回这个对象。

能做什么:轻松批量创建结构相似的对象,并且每个对象的方法都能正确访问自己的属性。

注意:如果忘记写 newthis 会指向全局对象,导致全局变量污染。所以构造函数通常首字母大写,提醒自己用 new 调用。


场景3:DOM 事件处理中的 this

在浏览器中处理事件时,this 通常指向触发事件的 DOM 元素:

html

<button id="myBtn">点我</button>
<script>
  const btn = document.getElementById('myBtn');
  btn.addEventListener('click', function() {
    console.log(this); // <button id="myBtn">点我</button>
    this.textContent = '已点击';
  });
</script>

能做什么:在事件回调中直接通过 this 操作当前元素,非常方便。

注意:如果回调使用箭头函数,this 就会指向外层作用域(比如 window),无法直接操作元素。所以事件回调一般用普通函数。


场景4:显式指定 this —— call / apply / bind

有时候我们需要手动指定函数的 this,比如“借用”其他对象的方法。

javascript

const user2 = { name: '小刚' };
const user3 = { name: '小丽' };

function introduce(hobby) {
  console.log(`我是 ${this.name},喜欢 ${hobby}`);
}

introduce.call(user2, '篮球'); // 我是 小刚,喜欢 篮球
introduce.apply(user3, ['跳舞']); // 我是 小丽,喜欢 跳舞

const introduceXiaoGang = introduce.bind(user2, '足球');
introduceXiaoGang(); // 我是 小刚,喜欢 足球
  • call 和 apply 立即调用函数,区别是传参方式不同。
  • bind 返回一个新函数,永久绑定 this,可用于后续调用。

能做什么:实现函数复用,动态改变上下文;也可以用于“函数借用”,比如数组方法借用给类数组对象。


场景5:回调函数中保持 this —— 箭头函数的妙用

在异步回调或定时器中,我们经常需要访问外层的 this,但普通函数的 this 会指向全局(或 undefined 严格模式),导致无法访问期望的对象。

传统解决方式是用 var self = this 缓存,或者用 bind

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++; // 这里的 this 指向 window,无法更新 count
    console.log(this.count);
  }, 1000);
}
new Counter(); // 输出 NaN 或 undefined

用 bind 修正:

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++;
    console.log(this.count);
  }.bind(this), 1000);
}
new Counter(); // 1 2 3 ...

而箭头函数让这一切变得简单:箭头函数没有自己的 this,它会捕获定义时外层作用域的 this

javascript

function Counter() {
  this.count = 0;
  setInterval(() => {
    this.count++; // 这里的 this 继承自 Counter 实例
    console.log(this.count);
  }, 1000);
}
new Counter(); // 1 2 3 ...

能做什么:在回调、事件监听、Promise 等场景中,优雅地保留外层 this,避免繁琐的 self = this 或 bind

注意:箭头函数的 this 一旦确定,就无法通过 call/apply/bind 改变,所以不能用于动态上下文。


场景6:嵌套函数中的 this 问题

在对象方法内部定义普通函数,这个普通函数的 this 会指向全局(或 undefined),这常常让人困惑:

javascript

const obj = {
  name: 'obj',
  foo() {
    function bar() {
      console.log(this.name);
    }
    bar(); // 非严格模式输出 undefined 或 window.name
  }
};
obj.foo();

如何让 bar 也能访问 obj 的 name?有几种方法:

  • 用箭头函数(推荐):

    javascript

    foo() {
      const bar = () => {
        console.log(this.name);
      };
      bar(); // obj
    }
    
  • 在外层保存 this

    javascript

    foo() {
      const self = this;
      function bar() {
        console.log(self.name);
      }
      bar();
    }
    
  • 用 bind

    javascript

    foo() {
      function bar() {
        console.log(this.name);
      }
      bar.bind(this)();
    }
    

能做什么:保证嵌套函数也能访问外层对象的属性,避免作用域丢失。


四、this 绑定规则优先级(一句话总结)

当多种规则同时适用时,this 的绑定优先级是:

new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(对象方法) > 默认绑定(独立调用)

箭头函数不参与这个优先级,它完全由外层作用域决定。


五、总结与思考

回到最初的问题:this 能做什么?

  • 它让函数灵活地适应不同的调用对象,实现代码复用;
  • 它在构造函数中帮助我们初始化实例;
  • 它在事件处理中方便操作当前元素;
  • 它通过显式绑定让我们能动态指定上下文;
  • 它配合箭头函数,优雅地解决了回调中的 this 保持问题。

掌握 this 的关键,不是死记硬背规则,而是在写代码时问自己:这个函数是怎么被调用的?  调用方式决定了 this 的指向。

希望这篇文章能帮你从“this 是什么”的困惑,走向“this 能做什么”的熟练应用。如果你有更多关于 this 的实战经验或疑惑,欢迎在评论区留言讨论!


最后留个思考题:下面代码的输出是什么?为什么?

javascript

const length = 10;
function fn() {
  console.log(this.length);
}
const obj = {
  length: 5,
  method(fn) {
    fn();
    arguments[0]();
  }
};
obj.method(fn, 1);

(答案:先输出 10(或 undefined),然后输出 2。因为第一次调用 fn() 是默认绑定,第二次 arguments[0]() 是隐式绑定,this 指向 arguments 对象,其 length 是传入的参数个数,即 2。)

欢迎留言你的答案和理解!我们下期再见。

#前端、#前端面试、#干货

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

别再手动调 Prompt 了!这款开源神器让 AI 输出质量提升 300%,支持 Claude、GPT、Gemini,还免费开源!

你是不是也这样?

  • 写 10 版提示词,AI 还是答非所问
  • 想让小模型做角色扮演,结果它“人格分裂”
  • 提取知识图谱,输出格式天天变
  • 本地部署 Ollama,但 Web 工具连不上……

而今天,我要介绍的这个 GitHub 23k Star 的开源神器,能一键优化你的提示词,让 GPT-4、Claude、Gemini 甚至 7B 小模型都稳定输出高质量结果

更惊人的是——
纯前端,数据不上传
支持 Chrome 插件,边写边优化
内置文生图(T2I)和图生图(I2I)
可私有化部署,还能接入 Claude Desktop
完全免费 + 开源 + 中文友好

它就是 —— Prompt Optimizer(提示词优化器)


一、为什么你需要一个“提示词优化器”?

大模型很强大,但提示词的质量决定输出上限
普通用户写:“写一首诗” → AI 随便糊弄。
高手写:“以‘春夜细雨’为主题,七言绝句,押平水韵,意象含柳、灯、纸伞,情感含蓄哀而不伤” → 出精品。

但没人天生会写好 Prompt。
Prompt Optimizer 能自动帮你把模糊需求转化为精准指令,并支持多轮迭代优化。

它不是“替代你思考”,而是“放大你的意图”。


二、三大核心场景,效果炸裂

场景 1:激发小模型潜力(降本增效)

在成本敏感或隐私要求高的场景(如本地 Ollama),结构化提示词能让 Qwen2.5-7B 稳定扮演“--”,对话一致性提升 300%。

无需微调,仅靠提示词工程,小模型也能“演得像”。

场景 2:保障生产环境稳定性

当你要程序化解析文本(如提取知识图谱),高质量提示词能让 输出格式 100% 符合 JSON Schema,避免后端解析崩溃。

降低对模型智能度的要求,经济的小模型也能扛生产。

场景 3:辅助创意探索

从“写首诗”到“指定意象+情感+格律”,工具帮你把模糊灵感细化为可执行指令,与 AI 共创独一无二的作品。


三、五大隐藏能力,远超想象

能力 说明
双模式优化 同时优化系统提示词(System Prompt)和用户提示词(User Prompt)
多模型集成 支持 OpenAI、Gemini、DeepSeek、智谱、SiliconFlow、Ollama(兼容 OpenAI 接口)
图像生成 内置 T2I(文生图)和 I2I(图生图),支持 Gemini、Seedream
高级测试模式 上下文变量管理、多轮会话测试、Function Calling 调试
纯客户端架构 所有数据直连 AI 服务商,不经过任何中间服务器

甚至支持 MCP 协议,可直接在 Claude Desktop 中调用优化服务!


四、四种使用方式,总有一款适合你

在线体验(最快)

直接访问:prompt.always200.com

  • 无需安装
  • 数据仅存浏览器本地
  • 支持所有核心功能

Chrome 插件(最方便)

  • 安装地址:Chrome 商店
  • 点击图标即可打开优化器
  • 边写提示词边优化

桌面应用(最稳定)

  • 下载地址:GitHub Releases
  • 无跨域限制(可直连本地 Ollama)
  • 自动更新
  • 独立运行,性能更强

Docker 私有化部署(最安全)

docker run -d -p 8081:80 \
  -e VITE_OPENAI_API_KEY=your_key \
  -e ACCESS_PASSWORD=your_password \
  --name prompt-optimizer \
  linshen/prompt-optimizer
  • 支持访问密码保护
  • 国内镜像加速:registry.cn-guangzhou.aliyuncs.com/prompt-optimizer/prompt-optimizer

五、为什么它能火遍全球?

  • 真正解决痛点:不是玩具,而是生产级工具
  • 极致用户体验:Web + 桌面 + 插件 + Docker 全覆盖
  • 开源精神:AGPL-3.0 协议,可商用但需开源衍生作品
  • 社区驱动:23k+ Star,2.8k+ Fork,23 位贡献者

项目文档极其完善:Vercel 部署指南MCP 使用说明


六、如何开始?30 秒上手

  1. 打开 prompt.always200.com
  2. 在左侧输入你的原始提示词(如“写一首关于春天的诗”)
  3. 点击“优化提示词”
  4. 查看优化前后对比,一键复制!

首次使用?点击右上角⚙️配置你的 API 密钥(OpenAI/Gemini/DeepSeek 等)


结语:好工具,值得被更多人看见

在这个 AI 爆发的时代,
不会写 Prompt 的人,正在被会用工具的人甩开

而 Prompt Optimizer 的出现,
让普通人也能写出接近专家级的提示词。

它不炫技,不炒作,
只是默默帮你——
把“随便问问”,变成“精准得到”。

抓紧转发给你身边有需要的人吧

在线体验:prompt.always200.com
GitHub:github.com/linshenkx/p…


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

深度拆解 fetch-event-source库实现原理

前言

在 AI 大模型火热的今天,流式输出(Streaming)已成为标配。虽然浏览器原生提供了 EventSource (SSE),但在复杂的业务实战中,它却显得力不从心。本文将带你深度剖析 fetch-event-source 的底层实现,看看它是如何突破原生限制,优雅实现流式交互的。

一、 为什么原生 EventSource 走到了尽头?

原生 EventSource 在 AI 聊天场景中有两个“死穴”:

  1. 方法受限:只能发送 GET 请求。AI 聊天往往需要携带庞大的上下文(Context),URL 长度限制是无法逾越的障碍。
  2. 鉴权困境:无法自定义 Header。在需要通过 Authorization 传递 Token 的现代 Web 应用中,这非常致命。

fetch-event-source 的出现,本质上是给 fetch 套上了一层 SSE 的协议外壳,完美继承了 fetch 的灵活性。


二、 核心原理:基于 ReadableStream 的流式解析

fetch-event-source 的核心魔法在于利用了 fetch 返回值中的 Response.body。它是一个 ReadableStream(可读流),允许我们在数据还没全部到达时,就开始处理已经“流”进来的字节块。

1. 协议头强制对齐

要模拟 SSE,请求头必须严格遵守规范:

  • Accept: text/event-stream:告知后端我们需要流式响应。
  • Cache-Control: no-cache:禁用缓存,确保实时性。
  • Connection: keep-alive:保持长连接。

2. 状态机解析逻辑

由于 SSE 格式具有高度可预测性(以 \n 分隔行,以 \n\n 分隔消息块),我们可以通过一个简单的状态机进行逐行扫描:

  • data: 开头 -> 暂存数据片段。
  • event: 开头 -> 记录事件类型。
  • retry: 开头 -> 更新客户端的重连等待时间。
  • 空行 (\n\n) -> 表示一条消息解析完成,触发 onmessage 回调。

三、 手写一个简易版

理解原理最好的方式就是复刻它。以下是基于 fetchTextDecoder 的核心实现逻辑:

async function fetchEventSource(url, options) {
  const { signal, onopen, onmessage, onerror, retryDelay = 1000 } = options;
  let retryCount = 0;

  // 1. 循环处理(失败重试)
  while (!signal.aborted) {
    try {
      const response = await fetch(url, {
        method: 'POST', // 突破 GET 限制,支持 POST 发送上下文
        headers: {
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Content-Type': 'application/json',
          ...options.headers,
        },
        body: JSON.stringify(options.body),
        signal,
      });

      // 2. 响应合法性校验
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
        throw new Error('Invalid Content-Type, expected text/event-stream');
      }

      onopen?.({ response });

      // 3. 读取流式响应体 (核心)
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = ''; 

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // 解码二进制数据并追加到缓冲区
        buffer += decoder.decode(value, { stream: true });
        
        // 4. 按 SSE 规范拆分消息块 (\n\n)
        let parts = buffer.split('\n\n');
        buffer = parts.pop(); // 最后一个可能是残缺的,留到下一轮处理

        for (const part of parts) {
          // 这里解析 data: event: 等字段
          const parsed = parseSSEPart(part); 
          onmessage?.(parsed);
        }
      }

      await reader.releaseLock();
      if (signal.aborted) break;

      throw new Error('Connection closed by server');
    } catch (error) {
      // 5. 错误处理与指数退避重连
      const retry = onerror?.(error) ?? true;
      if (!retry || signal.aborted) break;

      const delay = retryDelay * Math.pow(2, retryCount);
      await new Promise(resolve => setTimeout(resolve, delay));
      retryCount++;
    }
  }
}

四、 总结

fetch-event-source 并不是魔法,它只是站在了 fetchReadableStream 的肩膀上,通过手动实现 SSE 协议解析,解决了原生 API 的痛点。在 AI 对话应用中,它是实现实时、鉴权、高扩展性流式输出的最佳实践。

SSE 流式传输:中断超时处理

前言

在开发 AI 聊天应用时,fetch-event-source 几乎是前端标配。但你是否思考过:为什么原生的 EventSource 不行?它是如何解析二进制流的?当网络波动导致连接“假死”时,如何实现无感重连和数据去重?本文将带你拆解这些核心细节。

一、 为什么原生 EventSource 在 AI 场景“退环境”了?

原生 EventSource 虽好,但在复杂的 AI 业务场景中有两个“致命伤”:

  1. 仅支持 GET 请求:AI 对话通常需要发送长篇累牍的上下文(Context),URL 长度限制会导致请求失败。
  2. 无法自定义 Header:无法在请求头中携带 Authorization 令牌,给鉴权带来了麻烦。

fetch-event-source 的原理:它是基于原生 fetchReadableStream(可读流) 实现的。它通过手动解析 HTTP 响应体中的二进制数据,模拟了 SSE 的行为,同时继承了 fetch 支持各种 Method 和 Header 的灵活性。


二、 核心实战:如何处理 SSE 异常中断与超时?

在长连接中,最怕“连接还在,但数据没了”的假死状态。我们需要对库进行二次封装,引入超时检测指数退避重连

1. 超时检测机制

设置一个心跳定时器。如果在规定时间内(如 15s)没有收到任何 onmessage 信号,说明连接可能已失效。

  • 动作:主动调用 abort() 中断当前请求,并触发重连。
  • 重置:每当有新数据到达或连接开启时,重置该定时器。

2. 指数退避自动重连

为了减轻服务器压力,重连间隔不应是固定的。

  • 策略:从 2s 开始,每次失败翻倍(2s → 4s → 8s...),上限 30s。
  • 终止:设置最大重连次数(如 10 次),失败后提示用户“服务器繁忙,请手动重试”。

3. 断点续传与去重

重连后,后端可能会重新推送历史数据。

  • 前端方案:维护一个 lastMsgId。请求时带上这个标识,让后端从断点处开始推送;或者前端根据 id 对收到的消息进行 Map 去重。

三、 中断超时处理实现:基于fetchEventSource 简易实现

import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage, ElMessageBox } from 'element-plus'

// 全局状态管理(避免多请求冲突)
let controller = new AbortController()
let timeoutTimer = null // 超时定时器
let reconnectCount = 0 // 重连次数
let reconnectInterval = 2000 // 初始重连间隔(2s)
const MAX_RECONNECT_COUNT = 10 // 最大重连次数
const MAX_RECONNECT_INTERVAL = 30000 // 最大重连间隔(30s)
let lastMessageId = '' // 记录最后一条消息ID(断点续传用)

/**
 * 重置超时定时器(收到消息/建立连接时调用)
 * @param {number} timeout 超时时间(默认30s)
 */
const resetTimeoutTimer = (timeout = 30000) => {
  // 清除原有定时器
  if (timeoutTimer) clearTimeout(timeoutTimer)
  // 新建超时定时器:超时未收到消息则主动中断
  timeoutTimer = setTimeout(() => {
    ElMessage.warning('连接超时,正在尝试重连...')
    controller.abort() // 主动中断请求
    reconnectStream() // 触发重连
  }, timeout)
}

/**
 * 重连流式请求(指数退避策略)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调
 */
const reconnectStream = async (url, headers, data, handleMessage) => {
  // 超过最大重连次数,停止自动重连
  if (reconnectCount >= MAX_RECONNECT_COUNT) {
    ElMessageBox.alert('服务器繁忙,请稍后手动重试', '重连失败', {
      confirmButtonText: '确定'
    })
    // 重置重连状态
    reconnectCount = 0
    reconnectInterval = 2000
    return
  }

  // 指数退避:间隔翻倍,不超过30s
  const currentInterval = Math.min(reconnectInterval, MAX_RECONNECT_INTERVAL)
  ElMessage.info(`第${reconnectCount + 1}次重连,间隔${currentInterval / 1000}s...`)

  // 延迟重连
  await new Promise((resolve) => setTimeout(resolve, currentInterval))

  // 更新重连状态
  reconnectCount++
  reconnectInterval *= 2

  // 重新发起请求(携带最后一条消息ID,实现断点续传)
  requestStream(
    url,
    headers,
    {
      ...data,
      lastMessageId: lastMessageId // 传给后端,让后端从断点续传
    },
    handleMessage
  )
}

/**
 * 流式请求核心方法(带超时、重连、断点续传)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调(接收流式数据)
 */
export const requestStream = (url, headers, data, handleMessage) => {
  // 中断原有请求
  if (controller) controller.abort()
  controller = new AbortController()

  // 初始化超时定时器(30s超时检测)
  resetTimeoutTimer()

  fetchEventSource(url, {
    method: 'POST',
    signal: controller.signal,
    headers: {
      ...headers,
      Accept: 'text/event-stream', // SSE必需头
      'Cache-Control': 'no-cache'
    },
    body: JSON.stringify(data),
    openWhenHidden: true, // 页面隐藏时继续请求
    async onopen(response) {
      console.log('建立连接的回调')
      // 连接建立:重置超时定时器+重连状态
      resetTimeoutTimer()
      reconnectCount = 0
      reconnectInterval = 2000

      // 校验响应合法性
      if (!response.ok) {
        throw new Error(`连接失败,状态码:${response.status}`)
      }
    },
    onmessage(msg) {
      // 收到消息:重置超时定时器
      resetTimeoutTimer()

      // 记录最后一条消息ID(断点续传核心)
      if (msg.id) lastMessageId = msg.id
      // 处理消息(去重逻辑:避免重连后数据重复)
      handleMessage(msg)
    },
    onclose() {
      console.log('连接正常关闭')
      // 清除定时器+中断请求
      if (timeoutTimer) clearTimeout(timeoutTimer)
      controller.abort()
      // 重置状态
      reconnectCount = 0
      reconnectInterval = 2000
      lastMessageId = ''
    },
    onerror(err) {
      // 清除超时定时器
      if (timeoutTimer) clearTimeout(timeoutTimer)

      // 手动中断不触发重连(比如用户点击停止)
      if (controller.signal.aborted) {
        console.log('用户手动中断请求')
        return
      }

      // 异常重连
      ElMessage.error(`连接异常:${err.message || '网络错误'}`)
      reconnectStream(url, headers, data, handleMessage)

      // 必须抛出错误才会停止当前请求循环
      throw err
    }
  })
}

/**
 * 停止流式请求(手动中断)
 */
export const stopRequest = () => {
  // 清除超时定时器
  if (timeoutTimer) {
    clearTimeout(timeoutTimer)
    timeoutTimer = null
  }
  // 中断请求
  if (controller) {
    controller.abort()
    controller = new AbortController()
  }
  // 重置重连状态
  reconnectCount = 0
  reconnectInterval = 2000
  lastMessageId = ''
  ElMessage.info('已停止数据请求')
}


四、 注意:关于 Nginx 与浏览器限制

  1. Nginx 缓存屏蔽:一定要记得设置 proxy_buffering off;,否则 Nginx 会等缓冲区满了才一次性吐给前端,导致流式效果失效。
  2. 浏览器连接数限制:如果是 HTTP/1.1,浏览器对同一个域名的长连接通常限制在 6 个。如果打开多个 AI 对话页,可能会导致后续连接卡死。建议升级 HTTP/2,它可以多路复用,避开此限制。
  3. 手动停止 vs 自动重连:当用户点击“停止生成”时,必须标记一个 manualStop 状态位,否则 onerror 可能会误以为是网络异常而不断尝试重连。

五、💡 扩展:异步并发池 (Async Pool)

它不直接用于单个 SSE 连接,但在批量 AI 任务处理(例如一次性给 100 张图片生成描述)时非常有用。它可以限制同时进行的 HTTP 请求数量,防止瞬间撑爆浏览器带宽或后端并发限制。

1. 归属识别:唯一 ID + 专属缓存

  • 每个请求分配requestId(如stream-request-0);
  • streamDataCacherequestId为 key,每个请求的片段只往自己的缓存里加;
  • 即使多个请求的onmessage同时触发,也不会串数据(比如stream-request-0的片段绝不会跑到stream-request-1的缓存里)。

2. 有序拼接:数组按顺序存储片段

  • 每个请求的缓存里用fragments数组存储片段;
  • onmessage每次触发时,cache.fragments.push(msg.data)保证片段按返回顺序存储;
  • 收到结束标识[DONE]时,用join('')拼接数组,得到完整结果。

3. 并发控制:不等待 Promise 完成,只控制启动数

  • runningRequestCount记录正在运行的请求数;
  • runTasks里用while (runningRequestCount >= limit)等待,直到有请求结束、并发数下降;
  • 每个请求结束后(onclose/onerror),runningRequestCount--,并自动执行下一个任务;
  • 这种方式既限制了并发数,又不阻塞流式请求的 “持续返回片段”。
/**
 * 异步任务池(适配流式请求的并发控制)
 * @param {Array<Object>} requestList 批量请求列表(含url/headers/data)
 * @param {number} limit 最大并发数
 * @param {Function} onComplete 单个请求完成回调(参数:requestId, fullResult)
 */
export const batchStreamRequest = async (requestList, limit = 3, onComplete) => {
  // 为每个请求分配唯一ID
  const requestListWithId = requestList.map((item, index) => ({
    ...item,
    requestId: `stream-request-${index}`
  }))

  // 任务执行队列:递归执行,控制并发数
  const runTasks = async (taskIndex = 0) => {
    // 所有任务处理完毕
    if (taskIndex >= requestListWithId.length) return

    const currentTask = requestListWithId[taskIndex]
    const { requestId, url, headers, data } = currentTask

    // 等待:直到并发数低于限制
    while (runningRequestCount >= limit) {
      await new Promise((resolve) => setTimeout(resolve, 100)) // 每100ms检查一次
    }

    // 启动当前流式请求
    runningRequestCount++
    console.log(`启动请求${requestId},当前并发数:${runningRequestCount}`)

    // 执行单个流式请求(不等待完成,只标记启动)
    singleStreamRequest(requestId, url, headers, data, onComplete)
      .catch((err) => console.error(`请求${requestId}失败:`, err))
      .finally(() => {
        // 当前请求结束后,自动执行下一个任务
        runTasks(taskIndex + 1)
      })

    // 立即执行下一个任务(检查并发数)
    runTasks(taskIndex + 1)
  }

  // 启动任务队列
  await runTasks(0)
}

/**
 * 停止单个/所有流式请求
 * @param {string} [requestId] 可选:指定停止的请求ID,不传则停止所有
 */
export const stopStreamRequest = (requestId) => {
  if (requestId) {
    // 停止指定请求
    const controller = requestControllers[requestId]
    if (controller) {
      controller.abort()
      delete requestControllers[requestId]
      // 标记缓存为完成
      if (streamDataCache[requestId]) {
        streamDataCache[requestId].isCompleted = true
      }
      runningRequestCount--
    }
  } else {
    // 停止所有请求
    Object.keys(requestControllers).forEach((id) => {
      requestControllers[id].abort()
      delete requestControllers[id]
      if (streamDataCache[id]) {
        streamDataCache[id].isCompleted = true
      }
    })
    runningRequestCount = 0
    ElMessage.info('已停止所有流式请求')
  }
}

// ---------------------- 调用示例 ----------------------
// 批量请求列表
const batchRequests = [
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSO单点登录' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍Token无感刷新' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSE流式请求' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍asyncPool并发控制' } }
]

// 执行批量请求(限制最大并发数2)
batchStreamRequest(batchRequests, 2, (requestId, fullResult) => {
  // 单个请求完成后的回调:拿到拼接好的完整结果
  console.log(`请求${requestId}完成,完整结果:`, fullResult)
  // 这里可以做后续处理:渲染、入库等
})

❌