Javascript
快来分享你的内容吧~
- 06-05 16:54·后端开发上周做 AI 人像换装需求直接卡崩,原本依赖 OpenAI 封装 SDK 快速开发,本地调试连续收到鉴权 401 报错,对着文档翻了一上午没找到问题根源。干脆删掉所有 SDK 依赖,用 Node 原生手动拼接请求对接阿里云万相 wan2.7-image 模型,反倒借着排错把多模态图文生成、Prompt 多图入参的知识点彻底捋顺了。 先聊聊这次的落地需求,也是我手上这三张素材的由来。查看全文加油鸭:太棒了!从401报错到手写fetch打通多模态,不仅跑通需求,更把底层逻辑和避坑经验梳理得清清楚楚,这份扎实的复盘力真让人佩服!634分享
- 04-01 21:48Web 应用部署后发送消息失败排查记录 一、问题描述 部署到生产环境后,聊天功能发送消息失败,显示"发送失败"错误提示。但本地开发环境一切正常。 环境信息: 前端:Vue 3 + Vite,Nginx 部署 后端:Spring Boot 2.7.2 部署:Docker Compose 访问地址: 二、排查过程 2.1 初步检查 确认前后端是否有报错 — 没有错误日志 检查 Nginx 配置 — W查看全文加油鸭:太棒了!从 WebSocket 到安全上下文的层层排查,逻辑清晰、细节扎实,这份记录本身就是高质量的技术沉淀!431分享
- 02-25 14:29·前端开发
- 01-11 03:16今天我们正式宣布:Incremark 完成 Solid 框架支持,实现 Vue、React、Svelte、Solid 四大主流框架全覆盖。查看全文加油鸭:框架无关的设计太赞了!一次实现,四端通用,真正解决了重复造轮子的问题,增量解析的性能优势在流式场景下尤其亮眼,为开源精神点赞!351分享
- 01-08 12:55Incremark: 增量式 Markdown 解析器,专为 AI 流式输出设计,极致性能体验。 增量解析,流式友好精确边界检测,精确边界检测,框架无关,高度可定制,SSR 友好,支持国际化与无障碍查看全文加油鸭:Incremark 的流式架构太惊艳了!把 O(n²) 变 O(n),不只是性能突破,更是对 AI 时代内容渲染的深刻洞察。为这份前瞻性点赞!542分享
- 2025-12-21·前端开发
第2章 项目技术栈选择
## 2.1 项目架构与工程组织 技术栈选择本身不是重点,真正的重点在于"用什么技术取决于需求需要什么",而不是这项技术本身有多少优点。 求职阶段,我会优先选择应聘方向所需的主流技术栈来实现需求,因为面试考察的是能否胜任团队现有的技术语境,而不是是否掌握了最前沿的方案。 进入企业之后,技术选型更多取决于业务所处的阶段和团队能承受的试错成本——业务越依赖稳定运行,团队的"技术冒险额度"就越有限,往往优先选最稳妥、出问题概率最低的方案;但如果业务处于快速迭代期、或需要靠技术差异化建立竞争力,团队愿意承担的风险预算也会相应更高。所以不是简单地"求职选主流、在职选稳定",而是要看清当前所处的阶段,把有限的风险预算留给真正需要创新突破的地方。 新技术往往比旧技术带来更多特性、更高效率,但要不要用,首先取决于团队是否真正掌握它,其次取决于当前需求是否真的必须依赖这个新特性才能实现,最后取决于万一出问题,团队有没有能力兜底。新技术的生态往往还不完善,遇到问题未必有现成文档和社区经验可查,能否接受这种不确定性、以及需求是否紧迫到必须冒险,是决定要不要采用的关键。 所有技术都是为需求服务的,因为需求需要用到这项技术,所以才用它,而不是为了用某个技术反过来给自己制造一个需求。不过需求也不只限于"当下已经暴露出来的问题":对于可预见的业务增长,提前做一些有依据、可验证的架构准备,也属于合理的需求范畴,只要它建立在具体的增长趋势和数据支撑之上,而不是单纯"想用某个新技术"倒推出来的理由,依然算是需求驱动而非技术驱动。 如果有人问我"为什么选择这项技术栈",我不会只说"因为需求需要"这一句就结束,而是会说这个需求需要什么样的能力(比如高并发下的类型安全、快速迭代效率、和团队现有技术栈的兼容成本),而这项技术恰好在这个具体维度上有优势,所以才选它。不是因为它整体先进,而是因为这个具体特性正好对上了这个具体的需求痛点。聊"为什么选这项技术",本质上聊的是需求、团队现状和技术特性三者之间如何精确匹配,而不是回避讨论技术本身的优点。 ### 2.1.1 系统总体架构 LearnWise 是一个融合英语课程学习、单词复习、AI 对话、语音交互、学习总结和在线支付的 Web 应用。此类系统既包含用户、课程、订单等结构稳定的传统业务,也包含大模型流式输出、智能体工具调用和异步报告生成等 AI 业务。若全部功能集中在单一服务中,虽然初期开发简单,但常规接口与耗时较长的模型请求会共享运行资源,模块边界也容易变得模糊。 项目因此采用前后端分离架构,并将服务端进一步划分为业务服务和 AI 服务。Vue 前端负责页面呈现和用户交互;NestJS 业务服务负责用户、课程、单词本、学习记录和支付;NestJS AI 服务负责模型调用、对话状态、联网搜索与学习报告生成;PostgreSQL 保存业务数据和 AI 检查点;Redis 与 BullMQ 承担延迟任务;MinIO 保存头像等对象文件。 这一设计尚未达到完整微服务架构。两个后端应用仍位于同一代码仓库并共享基础模块,因此更准确的说法是“模块化单体基础上的应用级拆分”。它减少了微服务注册发现、链路追踪和分布式事务等额外成本,同时为 AI 服务独立部署和扩容保留了空间。 ### 2.1.2 TypeScript 全栈与前后端分离 项目的前端、业务服务、AI 服务和共享类型均使用 TypeScript。与 JavaScript 相比,TypeScript 能在编译阶段检查参数、返回值和对象结构,尤其适合接口较多、数据模型复杂的全栈项目。课程、用户、单词和聊天消息等结构可以在 workspace 包中共享,减少前后端各自声明类型造成的不一致。 TypeScript 的代价是增加类型设计和编译配置成本,第三方库类型不完整时也需要额外处理。但本项目同时使用 Vue、NestJS、Prisma 和 LangChain,这些技术均具有较好的 TypeScript 支持,因此统一语言带来的维护收益明显高于额外成本。 前后端分离使前端可以独立构建和部署,并通过 `/api` 和 `/ai` 两类入口访问不同服务。开发环境由 Vite 代理隐藏端口差异,生产环境则可由反向代理统一暴露服务。该方式也使 REST、SSE 和 Socket.IO 能按照各自场景独立演进。 ### 2.1.3 pnpm Workspace 与 NestJS Monorepo 项目外层使用 pnpm Workspace 管理 `apps`、`server` 和 `packages`。相较 npm,pnpm 通过内容寻址存储和链接机制减少重复依赖占用,并对未声明依赖的访问更加严格;相较 Yarn Workspace,pnpm 配置直接、安装性能较好,适合中小型 TypeScript monorepo。 `packages/common` 用于共享业务类型,`packages/config` 用于共享端口等配置。后端内部又使用 NestJS Monorepo,将业务应用、AI 应用和共享库组织在同一工程中。两层 monorepo 的优点是代码复用和统一开发体验,缺点是构建边界容易复杂化,因此应保持共享包职责单一,避免将具体业务逻辑放入公共模块。 ## 2.2 前端核心框架选型 ### 2.2.1 Vue 3、React 与 Angular 对比 Vue 3 是本项目的前端核心框架。它采用响应式数据系统、单文件组件和 Composition API,适合将页面拆分为组件、状态和可复用逻辑。项目已将登录、聊天、课程练习、消息气泡等功能组织为组件,并将语音、Socket、登录和滚动控制封装为组合式函数。 React 生态规模更大,灵活性更强,但路由、状态和组件方案通常需要团队自行组合;Angular 提供完整且严格的企业级框架能力,但学习成本和工程体量相对较高。Vue 3 在渐进式使用、模板可读性和开发复杂度之间更均衡,符合本项目团队规模和交互型应用的需求。 项目选择 Vue 3 还因为 Element Plus、Pinia、Vue Router 和 Vite 等配套方案成熟。需要注意的是,Composition API 若缺乏统一规则,也可能出现单个组件逻辑过长的问题,因此项目通过 composables 和业务组件继续拆分复杂页面。 ### 2.2.2 Vite 构建工具选型 Vite 使用浏览器原生 ES Module 提供快速开发启动,并通过 Rollup 完成生产构建。与传统 Webpack 全量打包后再启动的方式相比,Vite 在开发阶段只按需转换被请求的模块,热更新速度更快,配置也更精简。 项目通过 Vite 集成 Vue、Tailwind CSS、Vue DevTools 和 SVG Loader,同时配置 `/api` 与 `/ai` 代理。Vite 还提供 `@` 到 `src` 的路径别名,使组件和工具模块的引用更清晰。对于当前规模的 Vue 单页应用,Vite 比维护复杂 Webpack 配置更合适。 ### 2.2.3 Vue Router、Pinia 与状态持久化 Vue Router 管理首页、聊天、课程、设置和单词本等页面。显式路由配置能够清晰表达页面关系,并支持后续增加鉴权守卫和懒加载。相比基于目录自动生成的文件路由,它需要手动维护,但对当前页面数量而言更加直观。 Pinia 管理用户信息和登录状态。与 Vuex 相比,Pinia API 更简洁,对 TypeScript 推导更友好,也不需要 mutation 层。项目通过 `pinia-plugin-persistedstate` 保存必要状态,使页面刷新后仍能恢复用户会话。持久化数据应限制在必要字段,敏感信息不应直接长期存放在浏览器中。 ## 2.3 UI、样式与可视化扩展 ### 2.3.1 Element Plus 与 Tailwind CSS 混合方案 项目没有完全依赖单一 UI 方案,而是使用 Element Plus 提供表单、消息提示和通用图标,同时使用 Tailwind CSS、原生 CSS 和 scoped CSS 完成业务界面。Element Plus 能降低表单校验、反馈提示等常规功能的开发成本;Tailwind CSS 适合快速组合布局;原生 CSS 则便于实现高度定制的聊天、课程和首页视觉效果。 Ant Design Vue 更偏企业后台风格,Naive UI 的 TypeScript 体验和主题能力较好,但项目已采用 Element Plus,且其中文生态成熟。Tailwind 与 Sass、CSS Modules 相比不强调预处理语法,而是通过原子类快速构建样式。混合方案兼顾效率与定制能力,不过也会产生样式来源分散的问题,因此应统一颜色、间距和断点变量,避免同类样式重复实现。 ### 2.3.2 自定义 SVG 组件化方案 项目为登录、聊天、导航、弹窗和设置等模块设计了多组 SVG 图标,并通过 `vite-svg-loader` 将 `.svg` 文件直接导入为 Vue 组件。相比 PNG,SVG 在任意缩放比例下仍保持清晰,可以通过 CSS 控制尺寸和部分颜色;相比 Icon Font,SVG 不存在字体加载闪烁和字符映射问题,也更适合多色图标。 构建阶段使用 SVGO 压缩冗余属性,同时显式保留 `viewBox`,确保图标可以响应式缩放。图标按业务分类并通过各目录的 `index.ts` 集中导出,降低页面对具体文件路径的依赖。项目同时保留 Element Plus Icons,用于无需定制的通用操作图标,形成“组件库图标负责通用语义,自定义 SVG 负责产品视觉”的组合方式。 ### 2.3.3 Three.js 与 glTF 三维模型展示 登录界面使用 Three.js 渲染本地 glTF 模型,并通过 GLTFLoader 加载模型、二进制数据和纹理,通过 OrbitControls 提供观察交互。Three.js 对原生 WebGL 的渲染流程进行了封装,可直接使用场景、相机、材质和灯光;与 Babylon.js 相比,它更轻量、生态广泛,也更适合在现有 Vue 页面中嵌入单个展示场景。 glTF 是面向实时渲染的三维资产格式,能够同时描述网格、材质、纹理和场景关系,比直接解析 OBJ 等格式更适合 Web。项目还对模型包围盒、中心位置、相机距离、环境光和阴影进行了调整。三维渲染会增加首屏资源体积和 GPU 消耗,因此需要按需加载,并在组件卸载时释放几何体、材质、纹理和渲染器资源。 ### 2.3.4 CSS 动画、自定义指令与组合式函数 项目使用 CSS 动画完成文字散落、页面揭示和过渡效果,并通过自定义指令封装自动聚焦和元素进入视口后的呈现行为。与将所有动画交给 JavaScript 相比,CSS 动画更容易由浏览器优化,也能减少主线程计算。 登录、语音、Socket、事件监听、头像和滚动锁定等能力被封装为组合式函数。这种设计让组件专注于模板和业务流程,同时提高逻辑复用性。对于监听器和动画帧,应在组件卸载时统一清理,避免页面切换后仍有后台任务运行。 ## 2.4 网络请求与实时通信选型 ### 2.4.1 Axios 与 REST API 用户、课程、学习记录、单词本和支付等常规业务通过 REST API 交互,前端使用 Axios 封装请求。Axios 内置请求与响应转换、拦截器、超时和取消支持,比原生 Fetch 更适合建立统一客户端。项目分别创建业务 API 和 AI API,并通过拦截器添加认证信息、处理错误和刷新 Token。 REST 资源模型清晰,便于调试、缓存和接口文档化,适用于一次请求对应一次完整响应的业务。然而它不适合持续推送 AI 文本或服务端主动通知,因此项目没有试图用单一通信方式覆盖所有场景。 ### 2.4.2 SSE 流式响应 AI 回答使用 Server-Sent Events 流式返回。SSE 基于 HTTP 长连接,服务端可以持续向浏览器推送文本事件,协议简单,并具备断线处理基础。项目采用 `@microsoft/fetch-event-source`,使 SSE 请求能够使用 POST、自定义 Header 和 JSON 请求体,弥补原生 EventSource 只能方便地发起 GET 请求的限制。 与 WebSocket 相比,SSE 只提供服务端到客户端的单向推送,但 AI 对话中用户输入本身可通过初始 HTTP 请求发送,后续主要是模型持续输出,因此单向模型恰好满足需求。它也比短轮询减少重复请求和额外延迟。 ### 2.4.3 Socket.IO 双向通信 项目使用 Socket.IO 在支付结果发生变化时主动通知前端。Socket.IO 在 WebSocket 之上提供事件语义、自动重连、心跳和兼容性回退,开发成本低于直接维护原生 WebSocket 协议。支付回调由服务端异步接收,前端无法预知完成时刻,因此实时连接比固定轮询更及时。 Socket.IO 的代价是客户端和服务端都要引入额外协议层,并不与原生 WebSocket 客户端完全兼容。当前项目只在确有双向或主动通知需求时使用它,避免所有接口都维持长连接。 ### 2.4.4 REST、SSE 与 Socket.IO 的协同 三种通信方案在项目中按职责组合:REST 处理确定性业务请求,SSE 处理 AI 单向流式输出,Socket.IO 处理支付状态等实时事件。这种选型比强行统一为 WebSocket 更容易开发和维护,也使接口语义更加明确。后续若实时协作功能显著增多,可再扩大 Socket.IO 的职责;若只有少量服务端通知,则应继续控制长连接范围。 ## 2.5 浏览器能力与内容呈现 ### 2.5.1 Web Speech API 语音交互 项目通过 SpeechRecognition 实现语音转文字,通过 SpeechSynthesis 和 SpeechSynthesisUtterance 朗读单词、例句或回答。相比调用云端语音服务,浏览器原生方案无需上传音频、接入成本低,也不会产生额外 API 费用,适合教学演示和基础发音辅助。 其局限是不同浏览器和操作系统的支持程度、可用音色和识别质量不一致。项目需要在调用前检测能力,并在不支持时回退到文本输入或隐藏语音按钮。若未来需要统一的发音质量、音素评分或口语测评,则应接入专业云端语音服务。 ### 2.5.2 Marked 与流式 Markdown 渲染 AI 回答天然包含标题、列表和代码等结构,项目使用 Marked 将 Markdown 转换为 HTML,并分别渲染推理内容和最终回答。Marked 体积较小、解析速度快,适合聊天场景;markdown-it 插件体系更灵活,Remark 则更适合基于语法树进行复杂转换。当前需求以快速展示为主,因此 Marked 足够直接。 流式内容可能在任意位置截断 Markdown 语法,前端需要容忍不完整片段并在后续数据到达后重新解析。由于最终 HTML 会进入页面,生产环境还应结合 DOMPurify 等工具进行清洗,避免模型输出或外部搜索内容形成跨站脚本风险。 ## 2.6 后端框架与服务设计 ### 2.6.1 NestJS 框架选型 后端使用 NestJS 11。NestJS 在 Express 之上提供模块、控制器、服务、守卫、拦截器和依赖注入,使用户、课程、支付、AI 等模块可以遵循统一结构。相比直接使用 Express 或 Koa,它的约束更多,但能减少大型项目中路由、依赖和错误处理方式不一致的问题。 Fastify 在吞吐性能方面通常更有优势,NestJS 也可以更换为 Fastify Adapter。不过本项目的主要瓶颈更可能来自数据库、模型 API 和外部服务,而非 HTTP 框架本身,因此优先选择生态成熟、团队易理解的 Express Adapter 更合理。 ### 2.6.2 业务服务与 AI 服务拆分 业务服务负责账户、课程、学习记录、单词本、订单和支付;AI 服务负责 DeepSeek 调用、会话记忆、联网搜索和学习总结。拆分后,AI 请求的长连接和较长执行时间不会直接混入常规业务模块,后续也可以针对模型并发单独配置资源。 与完整微服务相比,当前方案共享仓库、配置和公共库,没有引入消息总线作为所有模块的通信基础。这降低了部署和排错复杂度,适合项目当前阶段。需要避免两个服务直接复制业务逻辑,公共基础能力应通过 shared library 复用,业务数据仍应由明确的服务边界负责。 ### 2.6.3 模块化、依赖注入与统一异常响应 NestJS Module 组织 Prisma、JWT、邮件、MinIO、支付和队列等能力,依赖注入让业务服务无需自行创建底层客户端。全局拦截器负责统一成功响应,全局异常过滤器负责将错误转换为稳定结构,RxJS 则参与 Guard 和 Interceptor 的异步处理。 统一响应便于前端封装,但应保留正确的 HTTP 状态码,不能只在响应体中表达成功或失败。DTO 目前仍有继续完善的空间,后续可结合 class-validator 对外部输入进行运行时校验。 ## 2.7 数据库与数据访问层 ### 2.7.1 PostgreSQL 数据库选型 项目使用 PostgreSQL 保存用户、课程、单词、学习记录、订单和每日总结。此类数据之间关系明确,并涉及唯一约束、事务和按用户聚合查询,因此关系型数据库比 MongoDB 等文档数据库更匹配。PostgreSQL 在复杂查询、索引、JSON 扩展和事务能力方面较强,也能被 LangGraph 用作 AI 检查点存储。 MySQL 同样可以完成主要业务,但 PostgreSQL 对复杂数据类型和扩展功能支持更丰富。选择 PostgreSQL 使业务数据与 AI 对话持久化可以使用同类基础设施,不过二者仍应通过独立数据库或 Schema 隔离,防止生命周期和权限相互影响。 ### 2.7.2 Prisma ORM、迁移与种子数据 Prisma 通过 Schema 声明模型并生成类型安全客户端。与 TypeORM 的装饰器实体方式相比,Prisma 的模型定义更集中,查询结果类型推导清晰;与 Sequelize 相比,其 TypeScript 开发体验更现代。项目还使用 PostgreSQL Adapter 建立连接,通过 Prisma Migrate 管理结构变化,通过 Seed 初始化词库、课程和图片数据。 Prisma 的限制是复杂 SQL 和特定数据库能力有时仍需使用原生查询,生成客户端也会增加构建步骤。对本项目以 CRUD、关系查询和事务为主的数据访问场景而言,其开发效率和类型安全更有价值。 ### 2.7.3 关系建模、约束与索引设计 数据库围绕用户、单词、用户单词记录、复习日志、课程记录、支付记录和学习总结建立关系。用户与单词的学习状态需要按 `(userId, wordId)` 唯一,因此使用复合唯一约束;到期复习按照用户和下次复习时间查询,因此设置 `(userId, nextReviewAt)` 复合索引。 这些约束不仅提升查询性能,也把关键业务规则下沉到数据库,避免并发请求产生重复记录。关联记录使用级联删除时需要谨慎,尤其是支付和邮件日志等审计数据,后续可根据合规要求改为软删除或限制删除。 ## 2.8 AI 模型与智能体技术 ### 2.8.1 DeepSeek 模型选型 项目通过 `@langchain/deepseek` 接入 DeepSeek,分别配置普通对话和深度思考模式,并启用流式输出。普通模式适合日常问答、解释和练习反馈,深度思考模式适合复杂分析。模型温度、最大输出长度和 thinking 参数根据场景分别设置。 模型选型通常需要比较推理能力、中文与英文表现、延迟、上下文长度、价格和 API 稳定性。DeepSeek 在中文语境、推理能力和使用成本之间具有较好的平衡,也提供与 LangChain 兼容的接口。其风险是外部模型服务存在延迟、限流和不可用情况,因此服务端应配置超时、错误转换和必要的重试策略,并避免把模型供应商细节扩散到业务层。 ### 2.8.2 LangChain Agent 与工具调用 项目使用 LangChain 的 `createAgent` 构建智能体,并将联网搜索、学习数据读取等能力封装为 Tool。相比直接调用模型 API,LangChain 提供统一的消息、流式输出、工具调用和 Agent 抽象,适合需要模型自主选择工具的场景。 如果应用只是单轮问答,直接调用 API 会更轻、更容易调试。当前系统不仅要聊天,还要生成基于用户数据的学习复盘和进行联网搜索,因此 Agent 抽象具有实际价值。仍应控制工具数量和参数范围,并在服务端校验工具输入,避免模型拥有不必要的数据访问能力。 ### 2.8.3 LangGraph 对话记忆与持久化 项目使用 LangGraph PostgreSQL Checkpoint 保存 Agent 状态,并按对话线程恢复上下文。相较只把历史消息保存在前端,这种方式在刷新页面或更换设备后仍能继续会话,也避免客户端篡改完整上下文。 持久化记忆会持续增长,应设置历史裁剪、摘要或归档策略。对话数据还可能包含用户隐私,需要按照用户维度隔离查询,并明确保留和删除规则。 ### 2.8.4 Prompt、联网搜索与自定义 AI Skill 项目维护不同英语学习角色和每日复盘提示词,并通过博查搜索 API 为 Agent 提供联网信息。搜索结果被整理为标题、链接、摘要、站点和时间,再作为模型上下文。这属于搜索增强生成,与基于私有文档向量检索的传统 RAG 不同:它强调实时公开网页,而不是构建本地知识库。 每日学习复盘被封装为自定义 AI Skill,结合用户学习记录、Prompt、模型和邮件服务生成个性化总结。将能力封装为 Skill 有利于隔离提示词、输入类型和执行流程。联网内容并不天然可靠,后续应保留来源链接、限制不可信指令进入系统提示词,并对关键学习结论进行结构化校验。 ## 2.9 英语学习核心算法 ### 2.9.1 SM-2 间隔重复算法 项目自行实现 SM-2 间隔重复算法,根据回答质量调整难度系数、连续正确次数和下次复习间隔。相比每天固定复习相同单词,SM-2 会让熟悉内容的间隔逐步增长,让不熟悉内容更快重新出现,从而在有限学习时间内提高复习效率。 选择自行实现而不是引入大型学习算法库,是因为 SM-2 公式相对明确,且项目需要将状态直接映射到自己的单词记录模型中。缺点是经典 SM-2 对不同用户、词汇难度和学习场景的适应能力有限,后续可基于实际正确率校准评分映射,或评估 FSRS 等现代调度算法。 ### 2.9.2 掌握度计算与复习调度 每个用户对每个单词都保存独立的 `easeFactor`、`interval`、`repetitions`、`nextReviewAt` 和 `lastReviewAt`,同时记录正确次数、错误次数和连续答对次数。这符合记忆状态属于“用户与单词关系”而非单词本身的业务事实。 服务端按照用户和到期时间查询待复习单词,数据库复合索引保证调度查询效率。掌握状态由学习记录推导,而不是允许用户随意切换,使单词本、错词本和复习计划使用同一数据来源。 ## 2.10 异步任务与基础设施 ### 2.10.1 Redis 与 BullMQ 任务队列 项目使用 Redis 作为 BullMQ 的状态存储,通过 `@nestjs/bullmq` 注册队列、Worker 和 Processor。每日学习总结可能涉及数据库聚合、模型生成和邮件发送,不适合阻塞普通 HTTP 请求,因此采用异步任务处理。 RabbitMQ 提供成熟的消息路由和确认机制,Kafka 更适合高吞吐事件流;BullMQ 基于 Redis、与 Node.js 和 NestJS 集成直接,适合当前规模的延迟任务和后台作业。其前提是正确处理重试、幂等性和失败任务,防止同一用户重复生成或发送报告。 ### 2.10.2 MinIO 对象存储 用户头像等文件通过 MinIO 保存。与直接写入应用服务器磁盘相比,对象存储更容易独立扩容和统一访问;与公有云对象存储相比,MinIO 可以本地部署,并提供接近 S3 的接口,适合开发和可控部署环境。 服务端使用 MinIO SDK 管理 Bucket、上传对象并生成访问地址。生产环境还应限制 MIME 类型和文件大小,采用不可预测的对象名,并根据隐私需求使用签名 URL,而不是默认公开所有文件。 ### 2.10.3 邮件与每日学习报告 项目使用 Nodemailer 通过 SMTP 发送 HTML 邮件。AI 先生成 Markdown 学习总结,Marked 再将其转换为 HTML,BullMQ 负责按计划执行生成与发送。相比第三方邮件 API,SMTP 接入通用、迁移成本低;邮件 API 在送达率、统计和模板管理方面通常更强。 数据库保存学习报告和邮件日志,便于追踪任务状态并实施幂等控制。邮件正文仍需进行 HTML 清洗,同时应对模型失败、邮件失败分别记录,避免将部分成功误判为任务全部完成。 ## 2.11 鉴权、支付与安全设计 ### 2.11.1 JWT 双令牌鉴权 项目使用 JWT Access Token 和 Refresh Token。短期 Access Token 用于访问接口,过期后由 Refresh Token 获取新令牌,前端 Axios 拦截器负责刷新和重试。与服务器 Session 相比,JWT 减少共享会话存储需求,适合前后端分离和多服务验证。 JWT 签发后难以即时撤销,因此 Refresh Token 应支持服务端失效控制、轮换和异常复用检测。前端还要避免多个并发请求同时触发刷新,可通过刷新队列合并请求。 ### 2.11.2 支付宝支付与实时通知 支付模块使用支付宝 SDK 创建网页支付请求,并通过异步回调更新订单。Nano ID 用于生成业务订单标识,支付成功后 Socket.IO 主动通知前端。该流程比仅依赖支付页面跳转结果可靠,因为最终状态以支付宝服务端回调为准。 回调处理必须验证签名、金额、商户应用和订单状态,并保证同一通知重复到达时结果一致。订单更新和权益发放应处于同一事务或使用可靠的幂等状态机。 ### 2.11.3 当前安全方案及改进方向 当前前端使用 MD5 对密码摘要后再发送。MD5 已不适合作为密码安全存储算法:计算速度过快且无法抵抗现代暴力破解。如果数据库直接保存该摘要,即使网络传输使用 HTTPS,泄露后的破解风险仍然较高。 改进方案应由服务端使用 Argon2id 或 bcrypt 加随机盐保存密码,前端通过 HTTPS 传输原始密码或协议要求的临时凭据。除此之外,还应为 Markdown HTML 增加清洗、为上传增加校验、为登录和模型接口增加限流,并确保 `.env` 与密钥不会进入版本控制和日志。 ## 2.12 工程质量与测试体系 ### 2.12.1 代码规范、格式化与类型检查 后端使用 ESLint、typescript-eslint 和 Prettier,前端使用 Oxfmt,并通过 vue-tsc 检查 Vue 单文件组件类型。Oxfmt 追求更快的格式化速度,Prettier 的生态和稳定性更成熟;两者分别作用于不同子工程不会产生直接冲突,但长期最好统一格式规则和提交检查流程。 项目还使用路径别名简化模块引用,使用 Vue DevTools 调试前端,并通过 npm-run-all2 并行执行类型检查和构建。根脚本使用 concurrently 同时启动三个应用,提升本地联调效率。 ### 2.12.2 Jest、Supertest 与测试现状 后端已配置 Jest、ts-jest、NestJS Testing 和 Supertest,具备单元测试与端到端测试基础。Jest 适合测试 Service 和算法,Supertest 适合验证控制器、鉴权和完整 HTTP 流程。当前仓库中的实际测试仍以少量脚手架测试为主,覆盖度不足。 后续应优先覆盖风险较高的 SM-2 边界、Token 刷新、支付回调幂等、BullMQ 重试、AI 流式事件解析和 Prisma 事务。前端可补充 Vitest 与 Vue Test Utils,并使用端到端测试覆盖登录、学习、聊天和支付状态变化。 ## 2.13 技术选型总结 ### 2.13.1 技术栈协作关系与选型优势 项目的核心特点不是简单堆叠 Vue、NestJS 和 PostgreSQL,而是针对不同业务性质选择不同技术:Vue 3 与 Pinia 负责交互界面,Vite 负责开发和构建;REST 处理常规业务,SSE 传输模型流,Socket.IO 推送异步支付状态;NestJS 提供模块化服务端结构,Prisma 和 PostgreSQL保证业务数据一致性;LangChain、LangGraph 与 DeepSeek构成智能体能力;Redis、BullMQ、MinIO 和 Nodemailer 支撑异步任务、文件和邮件。 该方案在开发效率、类型安全、交互体验和 AI 扩展能力之间取得了较好平衡。业务服务与 AI 服务的拆分也使系统可以逐步演进,而不必在项目早期承担完整微服务架构的成本。 ### 2.13.2 局限性与后续演进方向 当前技术体系仍有若干改进点:MD5 密码方案需要替换;Markdown 渲染需要增加 HTML 清洗;DTO 运行时校验和接口限流尚需完善;测试覆盖不足;Three.js 资源和流式连接需要持续关注清理;AI 调用需要更完整的超时、重试、用量统计和供应商降级方案;队列任务需要严格的幂等与失败补偿。 此外,根依赖中的 Day.js 暂未在主要源码中发现明确使用,部分示例 Store 和测试也属于脚手架遗留。技术文档应区分“实际使用”“已集成但使用较少”和“仅安装未使用”,避免把依赖清单直接等同于技术栈。后续演进应以真实业务瓶颈为依据,而不是为了技术数量继续拆分服务或引入新的基础设施。
Notion 的公式栏里,藏着一台虚拟机——逆向 + 用 600 行 JS 复刻它的编译器与栈式 VM
> 本文基于对 Notion 公开前端产物的静态分析,所有指令名、变量名均为还原命名、行为等价,仅供学习与研究。文中区分了「逆向实抓」与「合理推断」,请放心食用。 在 Notion 里建一张表,加一个公式列,敲下: ```text prop("时薪") * prop("工时") ``` 回车,数字立刻出现。平平无奇——直到你打开 DevTools,在压缩后的前端代码里翻到一个 31KB 的模块,发现里面赫然躺着:一个**词法分析器**、一个**递归下降解析器**、一个把语法树编译成**字节码**的编译器,以及一台逐条执行字节码的**栈式虚拟机**。 一个笔记软件,为了算一列公式,在你的浏览器里塞了一台虚拟机。 为什么?这篇文章就顺着这个问题,把这台 VM 逆向出来,再用不到 600 行 JavaScript 把它复刻一遍——重点是它最精彩的两个设计:**用生成器实现「算到一半能挂起、取完数据再从原地继续」的求值**,以及**把 lambda 当作「字节码数据」在运行时重新喂回 VM**。  *** ## TL;DR * 逆向对象是 Notion 前端两个 rspack 模块:`448187`(VM + 编译器)与 `947152 / 942007`(函数目录,命名空间 `formula2`)。 * 它是一台**纯 JavaScript 解释器**,全程没有 `WebAssembly`。`formula2` 暴露了 **31 个算子 + 65 个函数**(`map`/`filter`/`sort` 等列表高阶函数、`let`/`lets` 绑定、正则字符串函数都在)。 * 栈上的每个值是带类型标签的「盒子」`{type, value}`。 * 编译器把操作数**逆序压栈**;VM 主循环「`ip` 先自增、后分派」;`if` 被编译成跳转字节码。 * 王牌是**生成器**:求值到 `prop("X")` 这种需要远端数据的地方就 `yield` 挂起,调度器取回数据后 `.next(data)` 让它从同一条指令继续。 * lambda 不是闭包,而是**编译好的子字节码当成常量压栈**;库函数执行时 `yield*` 把它重新喂回 VM——所以 VM 必须是**可重入**的。 * 我把整套东西做成了一个**可单步、全状态可视化**的教学网页(单文件 HTML,零依赖),源码在 [GitHub](https://github.com/fluffyox/notion-vm)。 *** ## 一、为什么不直接 `eval`? 最朴素的实现是:把用户公式拼成 JS,丢给 `eval` 或 `new Function`。Notion 没这么做,原因有三个,每一个都直接逼出了「编译器 + 虚拟机」这套架构。 **第一,同一个公式要在一整列上反复跑。** 一个公式列有几千行,每行都要算一遍;筛选、排序、滚动都会触发重算。把公式**编译一次**得到字节码,然后这列的每一行复用同一份字节码——这就是 compile-once-run-many。每次都重新解析语法树是巨大的浪费。 **第二,求值过程必须能「暂停」。** 公式里可以写 `prop("关联表").map(...)`,沿着 relation/rollup 去引用别的行、别的表。这些数据常常**不在本地**,要异步去取。如果用同步的 `eval`,碰到缺数据就只能阻塞或报错。Notion 要的是「同步的写法、异步的执行」:算到需要远端数据的那一刻,**把整个求值过程冻结起来**,去把数据取回来,再从冻结点继续。 **第三,公式语言有 lambda。** `map`、`filter`、`sort` 的参数是一段「对每个元素都要重新跑一遍」的表达式。它需要被表示成一个**可反复调用的独立执行单元**。 这三条约束——高频重算、异步可挂起、列表 lambda——单靠递归解释一棵语法树是很难优雅满足的。于是就有了一台字节码虚拟机。这跟 SQLite 把 SQL 编译成字节码喂给它的虚拟机 VDBE 是同一个思路(顺带一提,Notion 原生端的本地存储正是 SQLite)。 *** ## 二、流水线总览 一行公式从字符串到结果,要走五道工序: ```mermaid graph LR SRC["公式源码"] --> TOK["词法<br/>Token 流"] TOK --> AST["语法分析<br/>AST 语法树"] AST --> BC["编译器<br/>栈式字节码"] BC --> VM["栈式虚拟机<br/>生成器解释循环"] VM --> RES["结果盒子<br/>type + value"] VM -. 挂起取数 .-> NET["记录缓存/网络"] NET -. next 恢复 .-> VM ``` 词法和语法分析是教科书内容,本文不展开(我的复刻里是一个递归下降 + 优先级爬升的解析器)。真正有意思的是后三段:编译器、虚拟机、以及它们之间那条「挂起取数」的虚线。我们一段段拆。 *** ## 三、值是带类型标签的「盒子」 第一个设计决定:栈上跑的不是裸 JS 值,而是统一的「盒子」——`{type, value}`。逆向出的类型有: `number`、`text`(`value` 是富文本数组)、`checkbox`、`date`、`person`、`block`(行指针)、`array`、`undefined`,以及一个特别的 `compiledCode`(一段子字节码,后面讲 lambda 时会用到)。 为什么不用裸值?因为 `1 + "x"` 在公式里要做文本拼接、`date < date` 要走时区感知比较、`undefined` 在数值上下文里要当 0——**运算的语义由类型决定**。把类型随值一起带在盒子里,分派起来才干净。 配套的是一个看似普通、实则关键的栈类: ```js class Stack { constructor() { this.u = []; } push(v) { this.u.push(v); } popValueOrCode() { return this.u.pop(); } // 允许弹出 compiledCode popValue() { // 禁止弹出 compiledCode const v = this.u.pop(); if (v && v.type === "compiledCode") throw new Error("unexpected compiled code"); return v; } } ``` 注意它有**两种弹出**。普通运算用 `popValue`:如果你试图把一段「代码」当成「值」去做加法,它直接抛错。而库函数取它的惰性参数(lambda)时用 `popValueOrCode`,允许拿到那段代码。这个区分,是整个 lambda 机制的支柱——记住它,第六节会回来。 *** ## 四、第一个反直觉点:操作数逆序压栈 栈式 VM 的常识是:算 `a - b`,先把 `a`、`b` 压栈,再执行减法。但逆向出来的编译器,**操作数是反着压的**。 ```js function compileBin(node) { const { op } = node; // ……除法、取模、and/or 走库函数,此处省略…… const t = (op === "+" || op === "-") ? "add" : op === "*" ? "multiply" : op === "^" ? "exponentiation" : (op === "==" || op === "!=") ? "equality" : "relational"; // 逆序压栈:先发射 rhs,再发射 lhs return I(node.rhs).concat(I(node.lhs)).concat([{ type: t, op, node }]); } ``` `I(rhs)` 在前、`I(lhs)` 在后。以 `1 - 2` 为例,编译产物是: ```text 0 loadConstant number 2 ← 先压右操作数 1 loadConstant number 1 ← 再压左操作数(它在栈顶) 2 add (op: "-") ``` 为什么要这样?因为栈是**后进先出**。我们希望执行减法时,**先弹出的是左操作数**。逆序压栈之后,左操作数 `1` 正好在栈顶,于是: ```js case "add": { const a = frame.stack.popValue(); // 弹出 = 1(左操作数) const b = frame.stack.popValue(); // 再弹 = 2(右操作数) frame.stack.push(addOp(node, a, b)); // 算 a - b = -1,顺序正确 break; } ``` 这个规则在二元运算、函数参数、数组字面量里是统一应用的(参数也逆序发射,执行时 `pop` 重建书写顺序)。它不影响结果,但你不知道这条约定的话,照着写出来的减法、除法会全部算反——这是逆向时一个很容易栽的坑。 *** ## 五、VM 主循环:先自增,后分派 虚拟机的心脏是一个 `while` 循环。逆向出的版本有个细节:**取出当前指令后,先把指令指针 `ip` 自增,再去分派执行**。 ```js function* F(instrs, ctx) { const frame = { instrs, ip: 0, stack: new Stack(), ctx }; // …把 frame 压入运行时 frames 栈,用于可视化… while (frame.ip < instrs.length) { const T = instrs[frame.ip]; frame.ip++; // ★ 先自增,后分派 switch (T.type) { case "loadConstant": frame.stack.push(T.value); break; case "loadName": frame.stack.push(lookupBinding(ctx, T.name)); break; case "loadToken": frame.stack.push(yield* resolveToken(T, ctx)); break; // 可挂起 case "add": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(addOp(T.node, a, b)); break; } case "multiply": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(mulOp(a, b)); break; } case "relational": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(yield* relOp(T.node, a, b)); break; } // 可挂起 case "array": { const vs = []; for (let i = 0; i < T.count; i++) vs.push(frame.stack.popValue()); frame.stack.push({ type: "array", values: vs }); break; } case "relativeJump": frame.ip += T.offset; break; case "jumpIfTruthy": { const c = frame.stack.popValue(); if (truthy(c)) frame.ip += T.offset; break; } case "callLibraryFunction": { const args = []; for (let i = 0; i < T.argCount; i++) args.push(frame.stack.popValueOrCode()); frame.stack.push(yield* T.fn.eval(args, ctx)); break; } // 可挂起 + 可重入 case "runLets": frame.stack.push(yield* runLets(T, ctx)); break; } } return frame.stack.popValue(); } ``` (上面为聚焦主线略去了少量错误守卫,完整版见仓库。) ```mermaid graph TD A["ip = 0"] --> B{"ip < 指令数?"} B -- 否 --> Z["弹出栈顶作为返回值"] B -- 是 --> C["取指 T = instrs[ip]"] C --> D["ip++(先自增,后分派)"] D --> E{"按 T.type 分派"} E --> F1["loadConstant:压入常量"] E --> F2["add / multiply:弹2个算1个压回"] E --> F3["loadToken:yield 取数(可挂起)"] E --> F4["callLibraryFunction:yield* 调用函数"] E --> F5["jumpIfTruthy:改写 ip"] F1 --> B F2 --> B F3 --> B F4 --> B F5 --> B ``` 「先自增后分派」的意义,在跳转指令上才显出来:`jumpIfTruthy` 的偏移量 `offset` 是相对**已经自增过的** `ip` 计算的。复刻时如果偏移基准算错一格,整段跳转会错位。这就引出下一节。 注意 `switch` 里有好几个 `yield*`——它们是这台 VM 能「挂起」和「重入」的入口,是后两节的主角。 *** ## 六、`if` 被编译成跳转——顺便揭穿一个误解 公式里的 `if(条件, 真值, 假值)`,**不是一个普通函数**。如果它是函数,那么调用前两个分支都得先求值(参数总是先于调用被算出来),`if` 就失去短路能力了。逆向出的做法是:编译器把 `if` 直接**展开成跳转字节码**。 ```js function compileIf(node) { const cond = emit(node.args[0]); const thenBC = emit(node.args[1]); const elseBC = emit(node.args[2]); return [ ...cond, { type: "jumpIfTruthy", offset: elseBC.length + 1 }, // 条件为真 → 跳过 else 段 ...elseBC, { type: "relativeJump", offset: thenBC.length }, // else 执行完 → 跳过 then 段 ...thenBC, ]; } ``` 布局是「条件 → 跳转 → else 段 → 无条件跳转 → then 段」。条件为真时跳过整个 else 段、落到 then 段;为假时顺序落入 else 段、执行完再无条件跳过 then 段。两个分支永远只走一个——这才是真正的短路。`ifs`(多路条件)则被递归地拆成嵌套的 `if`。 **反过来,`and` / `or` 是急性求值的普通函数。** 它们的参数在调用前就已经被全部算到栈上了,所以 `and`/`or` **不短路**。在公式引擎里,唯一的短路控制流来自 `if`/`ifs` 的跳转。这个区别,不看字节码是不会注意到的。 *** ## 七、王牌:用生成器做一台「可挂起」的虚拟机 现在来到全篇最漂亮的设计。 回想第一节的动机二:求值碰到远端数据要能暂停。Notion 的实现是——**VM 主体 `F` 是一个生成器函数**(`function*`)。当执行到 `loadToken`(也就是读 `prop("X")`)而本地没有这条记录时,它不阻塞、不报错,而是 `yield` 出一个「我需要这个记录」的请求: ```js function* resolveToken(T, ctx) { if (T.token.kind === "property") { const data = yield { t: "fetch", pointer: ctx.rowPointer, property: T.token.name }; if (data == null) throw new Error("MissingThisRow"); // 取不到 → 结构化错误 return data; // 取到 → 作为值盒子返回,压栈 } } ``` `yield` 之后,**这个生成器就地冻结**——它的指令指针 `ip`、操作数栈、整条调用栈,全被 JavaScript 运行时原样保存在生成器对象里。外层的调度器(驱动循环)接管: ```js async function drive(bytecode, ctx) { const gen = F(bytecode, ctx); let injected; for (;;) { const { value: ev, done } = injected === undefined ? gen.next() : gen.next(injected); injected = undefined; if (done) return ev; // 求值完成,ev 是结果盒子 if (ev.t === "fetch") { const box = await getRecord(ev.pointer, ev.property); // 本地命中就立即返回,缺数据就走网络 injected = box; // 把数据通过 .next(box) 喂回挂起点 } } } ``` 关键在 `gen.next(injected)`:传给 `next` 的值,会成为生成器内部那个 `yield` 表达式的返回值。也就是说,数据取回来后,VM 从**那条 `loadToken` 的同一位置**继续往下跑,仿佛中间什么都没发生。 ```mermaid sequenceDiagram participant VM as 虚拟机·生成器 participant SCH as 调度器 participant DATA as 记录缓存/网络 VM->>VM: 执行到 loadToken(读 prop) VM-->>SCH: yield 需要的记录指针 Note over VM: 在此冻结:ip、操作数栈、<br/>整条调用栈被原样保留 SCH->>DATA: 本地有这条记录吗? alt 命中本地缓存 DATA-->>SCH: 立即返回值盒子 else 缺数据 DATA-->>SCH: 发起网络请求,返回值盒子 end SCH-->>VM: gen.next 把盒子喂回 Note over VM: 从同一条指令解冻、继续执行 ``` 一句话总结这个机制:**生成器捕获的那个挂起态,本身就是一个可以冻结、可以解冻的调用栈。** 当数据本来就在本地时,整个过程一次 `yield` 都不发生,纯同步,零开销;只有真要去远端取数时才挂起。这就是「同步的写法、异步的执行」。 > 我在复刻的教学工具里,把这个「冻结」做成了一个会盖在虚拟机面板上的覆盖层:求值撞到一个标记为「冷」的属性时,整台 VM 的 `ip`、栈、调用栈定格不动,取数返回后再「解冻」从原地继续。看一眼那个动画,比读十遍文字都直观。 *** ## 八、lambda 的真相:不是闭包,是「字节码即数据」 `map([1,2,3], current * current)` 里的 `current * current`,是一段要对每个元素都重跑的代码。Notion 怎么表示它? 不是闭包。逆向出的答案更硬核:**编译时把这段表达式单独编译成一串子字节码,包成一个 `compiledCode` 盒子,当成常量压栈。** ```js function compileCall(node) { const fn = LIB[node.name]; const args = node.args.slice(); let out = []; for (let k = args.length - 1; k >= 0; k--) { // 参数同样逆序压栈 const an = args[k]; if (fn.lazy && fn.lazy.has(k)) { // 该形参是「惰性/代码」参数? // 不直接发射这段表达式,而是把它编译成子字节码,作为常量压栈 out.push({ type: "loadConstant", value: { type: "compiledCode", instructions: I(an) } }); } else { out = out.concat(I(an)); // 普通参数照常发射 } } out.push({ type: "callLibraryFunction", name: node.name, argCount: args.length, fn }); return out; } ``` 每个库函数自带一张「哪些参数是惰性的」表(比如 `map` 的第 2 个参数)。惰性参数不会被立即求值,而是变成一颗 `compiledCode`「代码弹珠」躺在栈上。 到了运行时,`map` 的实现用 `popValueOrCode`(还记得第三节那两种弹出吗?)把这颗代码弹珠取出来,然后**对每个元素,`yield*` 把这段子字节码重新喂回 VM 主体 `F` 跑一遍**,跑之前往上下文里注入两个绑定——当前元素 `current` 和下标 `index`: ```js function* runLambda(codeBox, el, idx, ctx) { const childCtx = { ...ctx, values: [{ kind: "Binding", id: "current", value: el }, { kind: "Binding", id: "index", value: num(idx) }, ...ctx.values], }; return yield* F(codeBox.instructions, childCtx); // ★ VM 重新调用自己 } ``` `yield* F(...)` 这一句,就是 **VM 在执行库函数的过程中,又递归地驱动了一个新的 VM 帧**。这正是为什么主循环里那么多 `yield*`、为什么 VM 必须是**可重入**的生成器:lambda 的执行 = 子字节码 + 再次进入 VM。 ```mermaid graph TD M["main 帧:执行 sum(map(...))"] --> C1["遇到 callLibraryFunction map"] C1 --> L["map.eval 对每个元素调用 runLambda"] L --> R["yield* F(λ 子字节码, ctx'):注入 current / index"] R --> N["新建一个 VM 帧(VM 重入自己)"] N --> RET["lambda 求完值,帧弹出,结果回到 map"] RET --> C1 ``` 光说不够,看一段我的复刻在跑 `sum(map([1, 2], current * 10))` 时,逐指令打印的真实执行轨迹(精简了列): ```text 0 ip=0 loadConstant compiledCode(λ) stack:[] frames:[main] 1 ip=1 loadConstant number 2 stack:[λ] frames:[main] 2 ip=2 loadConstant number 1 stack:[λ 2] frames:[main] 3 ip=3 array count=2 stack:[λ 2 1] frames:[main] 4 ip=4 callLibraryFunction map stack:[λ [1 2]] frames:[main] 5 ip=0 loadConstant number 10 stack:[] frames:[main › λ current=1 [#0]] 6 ip=1 loadName current stack:[10] frames:[main › λ current=1 [#0]] 7 ip=2 multiply stack:[10 1] frames:[main › λ current=1 [#0]] 8 ip=0 loadConstant number 10 stack:[] frames:[main › λ current=2 [#1]] 9 ip=1 loadName current stack:[10] frames:[main › λ current=2 [#1]] 10 ip=2 multiply stack:[10 2] frames:[main › λ current=2 [#1]] 11 ip=5 callLibraryFunction sum stack:[[10 20]] frames:[main] RESULT 30 ``` 看第 5 行那一刻:`frames` 从 `[main]` 长出了 `[main > lambda current=1 [#0]]`——VM 重入了自己,调用栈多了一层 lambda 帧,`current` 被绑成了第一个元素。第 7 行栈是 `[10 1]`,正应了第四节的逆序压栈:先弹 `1`(即 `current`)、后弹 `10`,算 `current * 10`。两个元素各跑完一遍 lambda 帧后,回到 `main`,`sum` 把 `[10, 20]` 折叠成 `30`。 `map`/`filter`/`find`/`some`/`every`/`sort` 全都共用这一套底座。`let`/`lets` 也是类似套路:编译成一条 `runLets` 指令,逐个求值绑定、压进 `ctx.values` 头部,`loadName` 再反查。 *** ## 九、算术里的小心思:整数走快路、小数才付精度税 被坑过 `0.1 + 0.2 !== 0.3` 的人都知道浮点的麻烦。表格软件对数值精度是较真的。逆向出的加法语义是这样权衡的: ```js function addOp(node, a, b) { const op = node.op === "-" ? "-" : "+"; const aNum = a.type === "number" || a.type === "undefined"; const bNum = b.type === "number" || b.type === "undefined"; if (op === "+" && aNum && bNum) { // 两边都是数(undefined 当 0) const x = a.type === "undefined" ? 0 : a.value; const y = b.type === "undefined" ? 0 : b.value; return num(isInt(x) && isInt(y) ? x + y // 整数 → 原生加法(快路径) : decAdd(x, y)); // 含小数 → 任意精度加法(慢路径) } // 减法同构;任一边不是纯数字 → 转富文本后拼接成 text return txt(boxToText(a) + boxToText(b)); } ``` 精髓在那个三元表达式:**两个操作数都是整数,就走原生 `+`**(比任意精度库快一个数量级,而整数运算的 JS 原生结果是精确的);**只要含小数,才切到任意精度路径**,保证 `0.1 + 0.2` 得到 `0.3` 而不是 `0.30000000000000004`。常见的整数运算不为不存在的精度问题买单,小数才付这份「精度税」。`-`、`*`、`^` 都是同样的整数快路径 + 慢路径分流。 其余几个语义也值得记一笔:除法零除返回 `undefined`(不是 `Infinity`);`round` 的精度参数必须是整数且绝对值 ≤ 12(每个函数都自带参数类型校验和结构化错误);`min`/`max` 返回**原始盒子**以保留类型。 *** ## 十、海量派生为什么不退化成 N\*M 次往返? > 诚实声明:这一节描述的是 **VM 之上的调度层**,属于合理推断 + 业界标准做法,**不是从那两个模块逐字逆出**的。前面九节都有实抓代码支撑,这节没有,请区别对待。 第七节解决了「一个单元格挂起取数」。但一张大表有成百上千个派生单元格,每个都可能挂起、都要请求关系记录。如果每个请求各自单独往返,那就是 N 行 x M 条关系 = N\*M 次网络调用,必然卡死。 标准解法是在 VM 之上放一个**调度层**,把同一轮里所有挂起的请求**收集、去重、合并成一次批量取数**(就是 DataLoader 那套)。多个生成器各自挂在自己的 `yield` 上,调度器把它们的数据需求并起来、去重、一次性取回,再唤醒全部挂起的生成器。 ```text 4 个派生格,各自挂起,需要的关系记录有重叠 R1:[a b c] R2:[b c d] R3:[c d e] R4:[a e] │ │ │ │ └──── 收集 + 去重 → {a b c d e} ───────┘ │ 一次批量取数(1 次往返) │ └──── 唤醒全部挂起的生成器,各自恢复 ────┘ 朴素:3+3+3+2 = 11 次往返 合并后:1 次往返 ``` 注意:**底层用的还是第七节那套完全相同的挂起/恢复协议**,区别只在 VM 之上的调度层是否合并请求。再叠加依赖图标脏 + 拓扑重算(只重算受影响的单元格)、视口懒求值(只算屏幕上可见的行)、服务端聚合下推等手段,「断网只挂掉一个格子、而不是整张表」才成为可能。 *** ## 十一、我把它做成了一个能单步的「虚拟机示波器」 读到这你大概已经有画面了。但「字节码逆序压栈」「生成器挂起恢复」「VM 重入自己跑 lambda」这些,光看文字总隔一层。所以我把整套引擎复刻了一遍(不到 600 行、零运行时依赖),又给它套了一个可视化外壳——一台「虚拟机示波器」: * **三段流水线一屏可见**:左边语法树、中间字节码(lambda 的子字节码可以展开)、右边虚拟机执行。 * **逐指令单步**,或按速度自动播放。当前 `ip` 高亮,对应的语法树节点同步点亮。 * **操作数栈画成物理盘片**,按类型着色,`push`/`pop` 带动画;调用栈帧实时显示 `main > lambda current=... [#i]` 的重入层级。 * **两种模式**:教学模式每条指令都暂停;真实模式只在取数处挂起——直接把「真实 VM 只在哪儿停」摆给你看。 * **挂起/恢复的「冻结」动画**:把某个属性标成「冷·需取数」,求值撞上它时整台 VM 定格、弹出覆盖层,取数返回后再解冻继续。 * 还有个进阶面板演示第十节的请求合并:N 行如何不退化成 N\*M 次往返。 为了靠谱,引擎在 Node 里跑了 37 个公式/错误/挂起用例全过,整页又用 jsdom 做了端到端冒烟(单步执行、lambda 重入、冻结覆盖层、结果正确)全过。 我把它整理成了一个开箱即跑的小工程: ```text notion-vm/ ├── build.sh # 组装 src/* → dist/notion-vm.html ├── src/{engine,ui}.js, style.css, body.html ├── test/{test.js, step_smoke.js, dom_smoke.mjs} └── dist/notion-vm.html # 自包含单文件,浏览器直接打开 ``` ```bash npm run build # 从源码重建单文件 HTML npm test # 37 个公式/错误/挂起用例(纯 node,无需装依赖) ``` > 在线 Demo 与完整源码:[github.com/fluffyox/notion-vm](https://github.com/fluffyox/notion-vm) *** ## 收尾:它到底是什么? 逆完这一圈,很容易冒出一个念头:Notion 是不是一个跑在浏览器里的迷你操作系统?哲学层面确实有几分像——一切皆 block(一切皆文件)、字节码 VM(用户态运行时)、权限分级、事务队列(调度器)、GC…… 但要较真的话,它更像一个**带内嵌运行时的、local-first 的数据库**:没有硬件、没有内存保护、权威数据在服务端,公式语言也不是图灵完备的——它是一门受限的领域 DSL,不是通用编程语言。这台 VM 的存在,不是为了「能算任何东西」,而是为了让「一列公式在几千行上反复、可暂停、可沿关系链取数地求值」这件事,变得高效而可控。 一个笔记软件的公式栏,背后是一套相当完整的编译器 + 虚拟机工程。下次你在 Notion 里敲下一个公式,不妨想想:那一刻,你的浏览器里有一台小小的虚拟机,正把你的字符串编译成字节码、逐条执行,碰到要去远端取的数据就优雅地冻结自己,等数据回来再从原地醒来。 *** *如果这篇对你有用,欢迎点赞 / 收藏 / 关注。逆向与复刻的全部代码都在 [GitHub 仓库](https://github.com/fluffyox/notion-vm) 里,欢迎对着字节码自己玩。*
阿里云多模态图片生成!抛弃SDK手写Fetch请求,我终于搞懂了大模型调用底层
> 上周做 AI 人像换装需求直接卡崩,原本依赖 OpenAI 封装 SDK 快速开发,本地调试连续收到鉴权 401 报错,对着文档翻了一上午没找到问题根源。干脆删掉所有 SDK 依赖,用 Node 原生`fetch`手动拼接请求对接阿里云万相 wan2.7-image 模型,反倒借着排错把多模态图文生成、Prompt 多图入参的知识点彻底捋顺了。 > 帅哥美女们帮我的掘金点点赞呗😘👍 完整文章地址👉:https://juejin.cn/post/7647707869932765203 先聊聊这次的落地需求,也是我手上这三张素材的由来。    需求很直白:保留第一张女生的五官样貌,给她换上第二张的黑色连衣裙,并且严格按照第三张骨架标记的坐姿生成成片。放在 AIGC 里,这就是典型从纯文本生成过渡到**多模态图文混输**的场景,也是我笔记里`text generation → image generation`的实际落地案例。 说实话之前我对多模态一直一知半解,总觉得图生图就是丢一张参考图再加一句提示词就行,直到要一次性传入三张不同用途的参考图,才琢磨明白大模型接收多素材的运行逻辑。我拿生活化的例子捋了一遍:大模型好比影楼全能造型师,第一张人像图是固定出镜的模特(锁定长相、面部特征),第二张裙子图是选定的定制服装,第三张关键点图是摄影师规定好的摆拍姿势,最后的文字 Prompt 就是我跟造型师口述的成片要求,三份参考素材 + 一句指令,共同构成完整输入。 ### 能直接跑通的最小 Demo 代码 折腾半天整理出可本地运行的代码,注释里顺带标了我踩坑的关键点,直接替换.env 里的密钥就能测试: ````javascript import dotenv from 'dotenv'; dotenv.config(); async function generateImage() { // 坑1:密钥千万不要随便塞到Content-Type请求头,我在这栽了半小时 const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const response = await fetch( // 坑2:阿里云通义万相专属接口地址,别错填成OpenAI官方域名 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', { method: 'POST', // AIGC接口基本全用POST,后面细说原因 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}`, // 鉴权密钥固定放在这个字段 }, body: JSON.stringify({ "model": "wan2.7-image", "input": { "messages": [{ "role": "user", "content": [ // 三张参考图+文字指令,嵌套在同一个content数组是规范写法 { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/thtclx/input1.png" }, { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/iclsnx/input2.png" }, { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/gborgw/input3.png" }, { "text": "图1中的女生穿着图2中的黑色裙子按图3的姿势坐下" } ] }] } }) } ) const data = await response.json(); // 获取任务ID,方便后续异步轮询生成结果 const requestId = data.request_id || '未知'; console.log(`request_id: ${requestId}`); // 从返回体里提取生成图片链接 let imageUrl = null; if (data.output && data.output.choices && data.output.choices.length > 0) { const choice = data.output.choices[0]; if (choice.message && choice.message.content) { const content = choice.message.content; if (Array.isArray(content)) { const imageContent = content.find(item => item.image); if (imageContent && imageContent.image) { const imageData = imageContent.image; // 接口返回要么在线URL,要么base64编码图片,两种格式都做兼容 if (imageData.startsWith('http')) { imageUrl = imageData; } else if (imageData.startsWith('data:image')) { imageUrl = imageData; } } } } } console.log(`图片URL: ${imageUrl || '未找到'}`); return { requestId, imageUrl }; } generateImage(); 本地新建`.env`文件填入密钥,格式如下: ```env OPENAI_API_KEY=sk-xxx ```` 终端运行脚本后,控制台成功打印出生成图片的在线链接那一刻,悬着的心才算落地。  ### 深挖一层:不管什么 SDK,本质全是封装 HTTP 请求 跑通 demo 后我突然好奇,平时我们用的 OpenAI 官方 SDK 到底干了什么?翻了一圈 SDK 源码后恍然大悟:市面上所有大模型封装 SDK,底层没有黑魔法,全是对`fetch/axios`这类网络请求的二次封装。 说白了对接大模型接口永远绕不开三件事,正好对应我手写 fetch 的三个配置项: 1. **请求 URL**:不同厂商大模型有专属接口域名,阿里云万相、OpenAI、文心一言全不通用,填错直接接口报错; 2. **请求头 headers**:`Content-Type`固定`application/json`,鉴权密钥统一挂载在`Authorization: Bearer xxx`字段,用来校验调用权限; 3. **请求体 body**:业务参数全塞在这里,我们的参考图、Prompt 文本、模型名称都属于 body 内容。 顺带解惑了笔记里的疑问:为什么 AIGC 接口几乎清一色用 POST 而不是 GET?GET 的参数会拼接在 URL 链接里,一方面多张图片的资源链接过长极易超出 URL 长度限制,另一方面**密钥暴露**在链接上很容易被抓包窃取,POST 把数据藏在请求体里,安全和长度问题一次性解决。 ### 踩过的两个致命大坑,错误写法贴出来避坑 `这两个错误我实打实浪费近一小时排查,新手调用多模态接口大概率也会踩中` 1. **鉴权密钥存放位置错误**最开始随手把 API\_KEY 写在了`Content-Type`里,接口反复返回 401 无权限。 ```javascript // 错误写法!千万别这么写 headers: { 'Content-Type': `application/json;${OPENAI_API_KEY}` } // 正确写法:Authorization单独承载鉴权信息 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` } ``` 2. **多图 content 嵌套层级写错**初期我把三张 image 对象和 text 平级放在 message 外层,大模型完全识别不到参考图片,只会根据文字随机生成人物。规范要求:**所有图片资源 + 文本提示词,必须全部嵌套在同一个 user 角色的 content 数组中**。 除此之外还有个小乌龙:我曾用 OpenAI 的接口地址去调用万相的`wan2.7-image`模型,接口直接返回「模型不存在」,不同服务商的接口域名和模型命名体系完全割裂,不能混用。 ### 收尾:这次折腾沉淀下来的三点收获 折腾完整套流程,抛开代码本身,有三个实打实的感悟: 1. 看不懂第三方 SDK 的时候,抛弃封装、裸写原生网络请求是吃透底层最快的办法,拆解完请求结构,再回头看 SDK 源码一目了然; 2. 多模态图生图的多参考图逻辑:每张参考图各司其职(控人脸 / 控服饰 / 控姿态),Prompt 做最终约束,入参格式严格遵循 content 数组嵌套规范; 3. 所有大模型调用本质是远程 HTTP 通讯,鉴权、入参、域名是对接接口的三要素,掌握这三点,换任何厂商的 AIGC 接口都能快速上手。 另外客观说下这个方案的短板:单靠多张参考图 + Prompt 做换装,复杂褶皱服饰、高难度人体姿态很容易生成崩坏图,大批量商用换装场景不能只依赖 Prompt 参考图,需要针对性微调大模型权重。 如果你平时也在折腾 AI 图生图、多模态调用,踩过鉴权、传参相关的奇葩 bug,搞懂之后不妨在评论区聊聊,我也想瞅瞅大家遇到过哪些离谱报错。
GitVision · GitHub 仓库历史全景总结工具
github官方在提交页面没有设置分页跳转的功能,有时想看看某个仓库第一次提交的内容要费一些精力查找,遂诞生了这个项目🤓,希望鱼友们点个🌟支持一下! 一个纯原生 Node.js + 原生前端实现的 Web 工具,粘贴任意 GitHub 仓库地址即可: - 一键直达该仓库的 **第一次提交**(解决 GitHub 原生无法快速跳到最早 commit 的痛点) - 自动抓取完整提交历史概览、tag 列表、里程碑节点 - 按时间线 / 类别(feat / fix / refactor / perf / docs / test / chore)摘要整个项目的发展脉络 - 所有跳转链接严格遵循 GitHub 官方 URL 规则,可直接在浏览器打开 GitHub:https://github.com/hateStudyy/GitVision 直接访问:https://gitvision-wine.vercel.app/
Web 应用部署后发送消息失败排查记录
## 一、问题描述 部署到生产环境后,聊天功能发送消息失败,显示"发送失败"错误提示。但本地开发环境一切正常。 **环境信息**: - 前端:Vue 3 + Vite,Nginx 部署 - 后端:Spring Boot 2.7.2 - 部署:Docker Compose - 访问地址:`http://139.199.158.118:9001` ## 二、排查过程 ### 2.1 初步检查 1. **确认前后端是否有报错** — 没有错误日志 2. **检查 Nginx 配置** — WebSocket 代理配置问题 3. **检查网络请求** — 惊讶发现:**没有 API 请求发出** ### 2.2 WebSocket 连接问题 首先发现 WebSocket 连接失败: ``` WebSocket connection to 'ws://139.199.158.118:9001/api/ws/chat' failed ``` 排查 Nginx 配置,发现: - 后端 `context-path: /api` - WebSocket 端点完整路径是 `/api/ws/chat` - Nginx 需要正确代理 WebSocket 连接 修复 Nginx 配置后,WebSocket 连接成功,但消息仍然发送失败。 ### 2.3 深入排查 由于没有 API 请求发出,从浏览器 Console 逐步排查: 1. **确认用户登录状态** — 正常 2. **确认 conversationId** — 正常 3. **手动测试 API 调用** — 成功! 手动 fetch 调用成功说明: - 网络没问题 - 后端 API 没问题 - Cookie 认证也没问题 问题一定在前端代码中。 ### 2.4 定位根因 通过 Console 直接调用 `chatStore.sendMessage()` 方法: ```javascript chatStore.sendMessage('2038981077471002625', { messageType: 1, content: '测试', senderId: '2038274525120315394' }) ``` **终于看到错误**: ``` TypeError: crypto.randomUUID is not a function ``` ## 三、根因分析 ### 3.1 技术原因 `crypto.randomUUID()` 只在**安全上下文**(Secure Context)中可用: | 上下文 | 是否安全 | | --------------- | -------- | | `https://` | ✓ | | `localhost` | ✓ | | `127.0.0.1` | ✓ | | `http://域名` | ✗ | | `http://IP地址` | ✗ | ### 3.2 为什么本地正常? 本地开发环境使用 `http://localhost:5173`,属于安全上下文,`crypto.randomUUID()` 可用。 生产环境使用 `http://139.199.158.118:9001`(HTTP + IP地址),不属于安全上下文,API 不可用。 ### 3.3 浏览器检测结果 ```javascript // 生产环境 Console 输出 User-Agent: Mozilla/5.0 ... Chrome/144.0.0.0 crypto.randomUUID: undefined // ← 不可用 location.protocol: http: location.hostname: 139.199.158.118 ``` ## 四、解决方案 ### 4.1 代码修复 添加兼容性处理: ```javascript // 修改前 const clientMsgId = crypto.randomUUID() // 修改后 const clientMsgId = typeof crypto?.randomUUID === 'function' ? crypto.randomUUID() : `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` ``` ### 4.2 长期方案 配置 HTTPS 证书,使生产环境也使用安全上下文。 ## 五、经验总结 1. **"本地正常生产异常"** — 考虑环境差异,特别是安全上下文、协议差异 2. **没有网络请求** — 代码在调用前就出错,用 Console 直接调试比看日志更高效 3. **安全上下文限制** — `crypto.randomUUID()`、`Service Worker` 等 API 需要安全上下文 4. **调试技巧** — 通过 `document.querySelector('#app').__vue_app__` 访问 Vue 应用实例进行调试 5. **Nginx WebSocket 代理** — 需要设置 `Upgrade` 和 `Connection` 头,以及更长的超时时间
全栈开发者的谎言:什么都会 = 什么都不精?
上周面了一个自称5年全栈的兄弟🤔。 简历漂亮得像报菜名:精通 Vue/React,熟悉 Node.js/Go,玩过 K8s,能画原型图,甚至还写过两个 Flutter App。 我只问了一个问题:如果不使用任何框架,Node.js 的 HTTP 模块是如何处理高并发下的内存积压的? 他愣了三秒,支支吾吾说:厄...一般我们都用 NestJS,框架处理好了吧?😖 那一刻,我看到了无数前端人的缩影:我们拼命想成为无所不能的全栈大神,最后却活成了什么都懂一点、什么都搞不定的API 缝合怪。 全栈不等于样样稀松,真正的价值在于深耕核心难题。与其在重复造轮子中消耗精力,不如用RollCode 低代码平台 提效。它支持私有化部署 和自定义组件 ,搞定 静态页面发布(SSG+SEO),让开发回归技术本质。 ## 全栈的本质 你要知道,全栈工程师(Full Stack Engineer)这个词,最开始是谁捧红的? 是硅谷的创业公司。 为什么?因为没钱。 他们招不起一个前端专家 + 一个后端专家 + 一个运维专家。他们需要一个性价比极高的耗材,一个人把这三个坑都填了。 于是,招聘 JD 画风突变: 25K,招全栈。要求精通 React、Node.js、MySQL、Docker、AWS... 你看似拿了比纯前端高 20% 的工资,干的却是 3 个人的活。你的大脑需要在 CSS 的 z-index 和 MySQL 的 Transaction Isolation Level 之间疯狂切换。 结果是什么? 你的认知被彻底击穿。 你以为你的认知,什么场景都能用。 但在真正的技术攻坚战里,什么都不是。🥱 **机-会** 技术大厂,前端-后端-测试,全国均有[机-会](https://jsj.top/f/o38ijj),感兴趣可以试试。待遇和稳定性都还不错~ ## 所谓的全栈,大多是全沾 我见过太多这种虚假全栈的代码了,简直是灾难现场。 他们写后端,思维还是前端那一套: 数据库设计:没有范式概念,一张表 50 个字段,全是 JSON 字符串。 错误处理:try-catch 包住整个 API,报错全返 200 OK,msg 里写 bug。 并发安全:在 for 循环里 await 查库,完全不懂什么是连接池耗尽。 让我们看一段典型的前端思维写后端的死代码: // 典型的假全栈代码 // 以为用了 async/await 就是后端大神了 router.post('/buy', async (req, res) => { // 1. 先查库存(没有锁,并发一来直接超卖) const stock = await db.query(`SELECT count FROM products WHERE id=${req.body.id}`); if (stock > 0) { // 2. 扣库存(中间如果服务挂了,数据不一致) await db.query(`UPDATE products SET count = count - 1 WHERE id=${req.body.id}`); // 3. 创建订单 await db.query(`INSERT INTO orders ...`); return res.json({ success: true }); } }); AI写代码 这种代码,稍微有点后端经验的人看了都会心肌梗塞。但在全栈眼里:跑通了啊,没报错啊! 什么都会 = 什么都不精。 你以为你拓宽了广度,其实你牺牲了深度。在裁员潮来临时,公司是会留一个能解决复杂内存泄漏的 Node 专家,还是留一个既能写页面又能写增删改查,但稍微上点量就崩服务的万金油? 在我们国内,答案是极其残酷的。 T 型人才的骗局 很多人反驳:我要做 T 型人才,一专多能! 理想很丰满,现实是绝大多数人做成了 一型人才 —— 横向铺得无限开,纵向深度为零。 学了 Docker,只会 docker run,不懂 Cgroup 原理。 学了 React,只会 useEffect,不懂 Fiber 调度。 学了 Rust,只会写 Hello World,借用检查器都过不去。 学了 SQLite, 只会增删改查,不懂什么叫锁,什么叫性能优化 这种简历驱动型学习产生的知识,极其脆弱。 一旦遇到深水区的 Bug,你的全栈光环瞬间破碎,只能去 AI Chat 复制粘贴,然后祈祷奇迹发生。 真正的全栈 是你能从前端的一个点击事件(Click),一路追踪到内核的系统调用(Syscall),这中间的每一层你都可控。 如果你做不到,那你充其量只是一个全栈水货。😥 请你先成为单栈战神 人的精力是有限的。在 35 岁危机到来之前,请功利一点,聚焦一点。 如果你是前端: 别急着去学 Go,别急着去搞 K8s。 先把浏览器渲染原理吃透,把 V8 垃圾回收搞懂,把图形学(WebGL/Canvas)啃下来。 当你在一个领域钻得足够深,深到能解决 99% 人解决不了的问题时,你才有资格去谈横向扩展。 这时候的扩展,不是为了凑简历,而是为了解决问题。 学 Node.js,是因为前端构建工具跑得太慢,你需要深入 OS 层优化 I/O。 学 Rust,是因为 JS 在计算密集型任务上拉胯,你需要 WASM 来救场。 这才是全栈的正确打开方式:降维打击。 别再用全栈来标榜自己了。 在这个分工日益精细化的时代,专家永远比杂家值钱。 专注你的赛道,把它做到极致。 那才是你不可被替代的根本。 大家怎么看🤔 ——转载自:ErpanOmer
Incremark Solid 版本上线:Vue/React/Svelte/Solid 四大框架,统一体验
Incremark 现已支持 Solid,至此完成了对 Vue、React、Svelte、Solid 四大主流前端框架的全面覆盖。 ## 为什么要做框架无关 市面上大多数 Markdown 渲染库都是针对特定框架开发的。这带来几个问题: 1. **重复造轮子**:每个框架社区都在独立实现相似的功能 2. **能力不一致**:不同框架的实现质量参差不齐 3. **团队切换成本**:换框架意味着重新学习新的 API Incremark 采用不同的思路:**核心逻辑与 UI 框架完全解耦**。 `@incremark/core` 负责所有解析、转换、增量更新的工作,输出的是框架无关的数据结构。各框架包(`@incremark/vue`、`@incremark/react`、`@incremark/svelte`、`@incremark/solid`)只需要把这些数据渲染成对应框架的组件即可。 这意味着: - 核心能力一次实现,四个框架同时受益 - Bug 修复和性能优化自动同步到所有框架 - API 设计保持高度一致,切换框架几乎零学习成本 ## 包结构 ``` ┌───────────────────────────────┐ │ @incremark/core │ │ │ │ 增量解析 · 双引擎 · 插件系统 │ └───────────────┬───────────────┘ │ ▼ ┌───────────────────────────────┐ │ @incremark/vue │ │ @incremark/react │ │ @incremark/svelte │ │ @incremark/solid ← NEW │ └───────────────┬───────────────┘ │ ▼ ┌───────────────────────────────┐ │ @incremark/theme │ │ │ │ 样式 · 主题 · 代码高亮 │ └───────────────────────────────┘ ``` ## 增量解析 传统 Markdown 渲染器在流式场景下存在性能问题:每次新内容到达都要重新解析整个文档,复杂度是 O(n²)。 Incremark 只处理新增内容,已解析的块不再重复处理,复杂度降至 O(n)。 ## 四个框架的用法对比 四个框架的组件 API 完全一致,只是语法风格不同: **Vue** ```vue <script setup> import { IncremarkContent } from '@incremark/vue' // ... </script> <template> <IncremarkContent :content="content" :is-finished="isFinished" /> </template> ``` **React** ```tsx import { IncremarkContent } from '@incremark/react' // ... <IncremarkContent content={content} isFinished={isFinished} /> ``` **Svelte** ```svelte <script> import { IncremarkContent } from '@incremark/svelte' // ... </script> <IncremarkContent content={content} isFinished={isFinished} /> ``` **Solid** ```tsx import { IncremarkContent } from '@incremark/solid' // ... <IncremarkContent content={content()} isFinished={isFinished()} /> ``` 可以看到,除了各框架本身的响应式语法差异(Vue 的 `ref`、React 的 `useState`、Svelte 的 `$state`、Solid 的 `createSignal`),组件的使用方式完全统一。 ## 在线演示 - [Solid Demo](https://solid.incremark.com/) - [Vue Demo](https://vue.incremark.com/) - [React Demo](https://react.incremark.com/) - [Svelte Demo](https://svelte.incremark.com/) ## 链接 - npm: [@incremark/core](https://www.npmjs.com/package/@incremark/core) - 文档: [incremark.com](https://www.incremark.com) - GitHub: [github.com/anthropics/incremark](https://github.com/anthropics/incremark) MIT 许可证。
Incremark 0.3.0 发布:双引擎架构 + 完整插件生态,AI 流式渲染的终极方案
# 从 O(n²) 到 O(n):为 AI 时代打造的流式 Markdown 渲染器 如果你开发过 AI 聊天应用,你可能注意到一个令人沮丧的问题:**对话越长,渲染越卡**。 原因很简单——每次 AI 输出新的 token,传统 markdown 解析器都会*从头开始*重新解析整个文档。这是一个根本性的架构问题,而且随着 AI 输出越来越长,问题只会越来越严重。 我们开发了 **Incremark** 来解决这个问题。 ## 2025 年 AI 的残酷现实 如果你一直关注 AI 的发展,你会发现数据变得越来越夸张: - **2022**:GPT-3.5 的回复?几百个字,问题不大 - **2023**:GPT-4 把输出提升到 2,000-4,000 字 - **2024-2025**:推理模型(o1、DeepSeek R1)输出 **10,000+ 字的"思考过程"** 我们正在从 4K token 的对话走向 32K,甚至 128K。没人谈论的一个事实是:**渲染 500 字和渲染 50,000 字的 Markdown 是完全不同的工程问题。** 大多数 markdown 库?它们是为博客文章设计的,不是为会"大声思考"的 AI 设计的。 ## 为什么你的 Markdown 解析器在骗你 当你通过传统解析器流式传输 AI 输出时,底层发生了什么: ``` Chunk 1: 解析 100 字符 ✓ Chunk 2: 解析 200 字符 (100 旧 + 100 新) Chunk 3: 解析 300 字符 (200 旧 + 100 新) ... Chunk 100: 解析 10,000 字符 😰 ``` 总工作量:`100 + 200 + 300 + ... + 10,000 = 5,050,000` 字符操作。 这是 **O(n²)**。成本不是线性增长——而是*爆炸式增长*。 对于 20KB 的 AI 回复,这意味着: - **ant-design-x**:1,657 ms 解析时间 - **markstream-vue**:5,755 ms(将近 **6 秒**的解析!) 而这些都是流行的、维护良好的库。问题不在于代码写得不好——而在于架构选择错误。 ## 核心洞察 关键在这里: **一旦一个 markdown 块"完成",它就永远不会改变。** 想想看。当 AI 输出: ```markdown # 标题 这是一个段落。 ``` 在第二个空行之后,这个段落就*完成*了。锁定了。无论后面来什么——代码块、列表、更多段落——这个段落永远不会再被动了。 那我们为什么要重复解析它 500 次? ## Incremark 的工作原理 我们围绕这个洞察构建了 **Incremark**。核心算法: 1. **检测稳定边界** — 空行、新标题、代码块结束符 2. **缓存已完成的块** — 永不再动 3. **只重新解析待处理的块** — 当前正在接收输入的那个 ``` Chunk 1: 解析 100 字符 → 缓存稳定块 Chunk 2: 只解析 ~100 新字符 Chunk 3: 只解析 ~100 新字符 ... Chunk 100: 只解析 ~100 新字符 ``` 总工作量:`100 × 100 = 10,000` 字符操作。 这是 **500 倍的减少**。每个字符最多只被解析一次。这就是 O(n)。 ## 完整基准测试数据 ### 测试环境 - **测试文件**:38 个文件,共 6,484 行,128.55 KB - **测试方式**:模拟流式输入,逐字符 append - **测试数据**:真实 AI 对话、文档、代码分析报告(非合成数据) - **对比方案**:Streamdown、markstream-vue、ant-design-x ### 完整测试结果 | 文件名 | 行数 | 大小(KB) | Incremark | Streamdown | markstream | ant-design-x | vs Streamdown | vs markstream | vs ant-design-x | |--------|------|----------|-----------|------------|------------|--------------|---------------|---------------|-----------------| | test-footnotes-simple.md | 15 | 0.09 | 0.3 ms | 0.0 ms | 1.4 ms | 0.2 ms | 0.1x | 4.7x | 0.6x | | simple-paragraphs.md | 16 | 0.41 | 0.9 ms | 0.9 ms | 5.9 ms | 1.0 ms | 1.1x | 6.7x | 1.2x | | test-footnotes-multiline.md | 21 | 0.18 | 0.6 ms | 0.0 ms | 2.2 ms | 0.4 ms | 0.1x | 3.5x | 0.6x | | test-footnotes-edge-cases.md | 27 | 0.25 | 0.8 ms | 0.0 ms | 4.2 ms | 1.2 ms | 0.0x | 5.3x | 1.5x | | test-footnotes-complex.md | 28 | 0.24 | 2.1 ms | 0.0 ms | 4.8 ms | 1.0 ms | 0.0x | 2.3x | 0.5x | | introduction.md | 34 | 1.57 | 5.6 ms | 12.6 ms | 75.6 ms | 12.8 ms | 2.2x | 13.4x | 2.3x | | devtools.md | 51 | 0.92 | 1.2 ms | 0.9 ms | 6.1 ms | 1.1 ms | 0.8x | 5.0x | 0.9x | | footnotes.md | 52 | 0.94 | 1.7 ms | 0.2 ms | 10.6 ms | 1.9 ms | 0.1x | 6.3x | 1.2x | | html-elements.md | 55 | 1.02 | 1.6 ms | 2.2 ms | 12.6 ms | 2.8 ms | 1.4x | 7.8x | 1.7x | | themes.md | 58 | 0.96 | 1.9 ms | 1.3 ms | 8.6 ms | 1.8 ms | 0.7x | 4.4x | 0.9x | | test-footnotes-comprehensive.md | 63 | 0.66 | 5.6 ms | 0.1 ms | 25.8 ms | 7.7 ms | 0.0x | 4.6x | 1.4x | | auto-scroll.md | 72 | 1.68 | 3.9 ms | 3.5 ms | 39.9 ms | 4.9 ms | 0.9x | 10.1x | 1.2x | | custom-codeblocks.md | 72 | 1.44 | 3.4 ms | 2.0 ms | 14.9 ms | 2.5 ms | 0.6x | 4.4x | 0.7x | | custom-components.md | 73 | 1.40 | 4.0 ms | 2.0 ms | 32.7 ms | 2.9 ms | 0.5x | 8.1x | 0.7x | | custom-containers.md | 88 | 1.67 | 4.2 ms | 2.4 ms | 18.1 ms | 3.1 ms | 0.6x | 4.3x | 0.7x | | typewriter.md | 88 | 1.89 | 5.6 ms | 4.1 ms | 35.0 ms | 4.9 ms | 0.7x | 6.2x | 0.9x | | concepts.md | 91 | 4.29 | 12.0 ms | 50.5 ms | 381.9 ms | 53.6 ms | 4.2x | 31.9x | 4.5x | | INLINE_CODE_UPDATE.md | 94 | 1.66 | 4.7 ms | 17.2 ms | 60.9 ms | 15.6 ms | 3.7x | 12.9x | 3.3x | | comparison.md | 109 | 5.39 | 20.5 ms | 74.0 ms | 552.2 ms | 85.2 ms | 3.6x | 26.9x | 4.1x | | basic-usage.md | 130 | 3.04 | 8.5 ms | 12.3 ms | 74.1 ms | 14.1 ms | 1.4x | 8.7x | 1.7x | | CODE_BACKGROUND_SEPARATION.md | 131 | 2.83 | 8.7 ms | 28.8 ms | 153.6 ms | 31.3 ms | 3.3x | 17.6x | 3.6x | | P2_SUMMARY.md | 138 | 2.61 | 8.3 ms | 38.4 ms | 157.2 ms | 41.9 ms | 4.6x | 18.9x | 5.0x | | quick-start.md | 146 | 3.04 | 7.3 ms | 7.3 ms | 64.2 ms | 9.6 ms | 1.0x | 8.8x | 1.3x | | complex-html-examples.md | 147 | 3.99 | 9.0 ms | 58.8 ms | 279.3 ms | 57.2 ms | 6.6x | 31.1x | 6.4x | | CODE_COLOR_SEPARATION.md | 162 | 3.51 | 10.0 ms | 32.8 ms | 191.1 ms | 36.9 ms | 3.3x | 19.1x | 3.7x | | P0_OPTIMIZATION_REPORT.md | 168 | 3.53 | 10.1 ms | 56.2 ms | 228.0 ms | 58.1 ms | 5.6x | 22.6x | 5.8x | | COLOR_SYSTEM_REFACTOR.md | 169 | 3.78 | 18.5 ms | 64.0 ms | 355.5 ms | 69.1 ms | 3.5x | 19.2x | 3.7x | | FOOTNOTE_TEST_GUIDE.md | 219 | 2.87 | 12.3 ms | 0.2 ms | 167.6 ms | 45.0 ms | 0.0x | 13.7x | 3.7x | | P2_COLORS_PACKAGE_REPORT.md | 226 | 4.10 | 11.4 ms | 77.9 ms | 311.6 ms | 80.5 ms | 6.8x | 27.2x | 7.0x | | FOOTNOTE_FIX_SUMMARY.md | 236 | 3.93 | 22.7 ms | 0.5 ms | 535.0 ms | 120.8 ms | 0.0x | 23.6x | 5.3x | | BASE_COLORS_SYSTEM.md | 259 | 4.47 | 35.8 ms | 43.0 ms | 191.8 ms | 43.4 ms | 1.2x | 5.4x | 1.2x | | OPTIMIZATION_COMPARISON.md | 270 | 5.42 | 17.8 ms | 52.3 ms | 366.1 ms | 61.9 ms | 2.9x | 20.6x | 3.5x | | P1_OPTIMIZATION_REPORT.md | 327 | 5.63 | 20.7 ms | 106.8 ms | 433.8 ms | 114.8 ms | 5.2x | 21.0x | 5.5x | | OPTIMIZATION_PLAN.md | 371 | 6.89 | 33.1 ms | 67.6 ms | 372.1 ms | 76.7 ms | 2.0x | 11.2x | 2.3x | | OPTIMIZATION_SUMMARY.md | 391 | 6.24 | 19.1 ms | 208.4 ms | 980.6 ms | 217.8 ms | 10.9x | 51.3x | 11.4x | | P1.5_COLOR_SYSTEM_REPORT.md | 482 | 9.12 | 22.0 ms | 145.5 ms | 789.8 ms | 168.2 ms | 6.6x | 35.9x | 7.7x | | BLOCK_TRANSFORMER_ANALYSIS.md | 489 | 9.24 | 75.7 ms | 574.3 ms | 1984.1 ms | 619.9 ms | 7.6x | 26.2x | 8.2x | | test-md-01.md | 916 | 17.67 | 87.7 ms | 1441.1 ms | 5754.7 ms | 1656.9 ms | 16.4x | 65.6x | 18.9x | | **【合计】** | **6484** | **128.55** | **519.4 ms** | **3190.3 ms** | **14683.9 ms** | **3728.6 ms** | **6.1x** | **28.3x** | **7.2x** | ### 诚实面对:我们慢的地方 你会注意到数据中有些奇怪的地方。对于 `footnotes.md` 和 `FOOTNOTE_FIX_SUMMARY.md`,Streamdown 看起来快得多: | 文件 | Incremark | Streamdown | 原因 | |------|-----------|------------|------| | footnotes.md | 1.7 ms | 0.2 ms | Streamdown 不支持脚注 | | FOOTNOTE_FIX_SUMMARY.md | 22.7 ms | 0.5 ms | 同上——它直接跳过了 | **这不是性能问题——这是功能差异。** 当 Streamdown 遇到 `[^1]` 脚注语法时,它直接忽略。Incremark 完整实现了脚注——而且我们必须解决一个流式场景特有的棘手问题: 在流式场景中,**引用通常比定义先到达**: ``` Chunk 1: "详见脚注[^1]..." // 引用先到达 Chunk 2: "更多内容..." Chunk 3: "[^1]: 这是脚注定义" // 定义后到达 ``` 传统解析器假设你有完整的文档。我们构建了"乐观引用"机制,在流式传输过程中优雅地处理不完整的链接/图片,然后在定义到达时解析它们。 我们选择完整实现脚注、数学公式块(`$...$`)和自定义容器(`:::tip`),因为这些是真实 AI 内容所需要的。 ### 我们真正的优势 排除脚注文件,看看标准 markdown 的性能: | 文件 | 行数 | Incremark | Streamdown | 优势 | |------|------|-----------|------------|------| | concepts.md | 91 | 12.0 ms | 50.5 ms | **4.2x** | | comparison.md | 109 | 20.5 ms | 74.0 ms | **3.6x** | | complex-html-examples.md | 147 | 9.0 ms | 58.8 ms | **6.6x** | | OPTIMIZATION_SUMMARY.md | 391 | 19.1 ms | 208.4 ms | **10.9x** | | test-md-01.md | 916 | 87.7 ms | 1441.1 ms | **16.4x** | **规律很明显:文档越大,我们的优势越大。** 对于最大的文件(17.67 KB),Incremark 的优势最为明显: - vs Streamdown:快 **16.4 倍** - vs ant-design-x:快 **18.9 倍** - vs markstream-vue:快 **65.6 倍** ### 为什么差距这么大? 这就是 O(n) vs O(n²) 的实际表现。 传统解析器每次收到新 chunk 都重新解析整个文档: ``` Chunk 1: 解析 100 字符 Chunk 2: 解析 200 字符 (100 旧 + 100 新) Chunk 3: 解析 300 字符 (200 旧 + 100 新) ... Chunk 100: 解析 10,000 字符 ``` 总工作量:`100 + 200 + ... + 10,000 = 5,050,000` 字符操作。 Incremark 只处理新内容: ``` Chunk 1: 解析 100 字符 → 缓存稳定块 Chunk 2: 只解析 ~100 新字符 Chunk 3: 只解析 ~100 新字符 ... Chunk 100: 只解析 ~100 新字符 ``` 总工作量:`100 × 100 = 10,000` 字符操作。 这是 **500 倍的差距**。而且随着文档增长,差距只会更大。 ### 什么时候用 Incremark ✅ **适合使用 Incremark 的场景:** - AI 聊天流式输出(Claude、ChatGPT 等) - 长篇 AI 内容(推理模型、代码生成) - 实时 markdown 编辑器 - 需要脚注、数学公式或自定义容器的内容 - 100K+ token 的对话 ⚠️ **考虑使用其他方案的场景:** - 一次性静态 markdown 渲染(直接用 marked 就行) - 非常小的文件(<500 字符)——开销不值得 ## 双引擎,一个目标 **Marked 还是 Micromark?** 两者各有取舍。 Marked 极快但缺少高级功能。Micromark 规范完美但更重。 我们的答案:**两个都支持。** | 引擎 | 速度 | 最佳场景 | |------|------|----------| | **Marked**(默认) | ⚡⚡⚡⚡⚡ | 实时流式、AI 对话 | | **Micromark** | ⚡⚡⚡ | 复杂文档、严格 CommonMark | 我们用自定义 tokenizer 扩展了 Marked,支持脚注、数学公式和容器。如果遇到 Marked 无法处理的边界情况,只需一个配置就能切换到 Micromark。 两个引擎产生完全相同的 **mdast** 输出。你的渲染代码不关心底层用的是哪个引擎。 ## 没人谈论的打字机问题 你知道 ChatGPT 那种丝滑的"打字"效果吗?大多数实现是这样做的: ```ts displayText = fullText.slice(0, currentIndex) ``` 这会不断破坏 markdown。你会看到渲染到一半的 `**粗体**` 标签、闪烁的代码块、看起来像喝醉了的语法。 我们把动画移到了 **AST 层**。我们的 `BlockTransformer` 理解结构——它在节点*内部*做动画,永远不会跨节点。结果:丝滑流畅的打字效果,同时尊重 markdown 语义。 ## 跨框架支持 我们深知前端生态的多样性。Incremark 提供开箱即用的框架适配: | 框架 | 包名 | 版本要求 | |------|------|----------| | Vue | `@incremark/vue` | Vue 3.5+ | | React | `@incremark/react` | React 18+ | | Svelte | `@incremark/svelte` | Svelte 5+ | **一个核心,三个框架,零行为差异。** 所有框架共享: - 完全一致的 API 设计 - 相同的组件结构和 DOM 输出 - 统一的主题系统(`@incremark/theme`) - 相同的性能特性 ```bash # 选择你的框架 npm install @incremark/vue npm install @incremark/react npm install @incremark/svelte ``` ### Vue 示例 ```vue <script setup> import { ref } from 'vue' import { IncremarkContent } from '@incremark/vue' const content = ref('') const isFinished = ref(false) async function handleStream(stream) { for await (const chunk of stream) { content.value += chunk } isFinished.value = true } </script> <template> <IncremarkContent :content="content" :is-finished="isFinished" :incremark-options="{ gfm: true, math: true }" /> </template> ``` ### React 示例 ```tsx import { useState } from 'react' import { IncremarkContent } from '@incremark/react' function Chat() { const [content, setContent] = useState('') const [isFinished, setIsFinished] = useState(false) async function handleStream(stream: AsyncIterable<string>) { for await (const chunk of stream) { setContent(prev => prev + chunk) } setIsFinished(true) } return ( <IncremarkContent content={content} isFinished={isFinished} incremarkOptions={{ gfm: true, math: true }} /> ) } ``` ### Svelte 示例 ```svelte <script> import { IncremarkContent } from '@incremark/svelte' let content = $state('') let isFinished = $state(false) async function handleStream(stream) { for await (const chunk of stream) { content += chunk } isFinished = true } </script> <IncremarkContent {content} {isFinished} incremarkOptions={{ gfm: true, math: true }} /> ``` ## 下一步 这是 0.3.0 版本。我们才刚刚开始。 AI 世界正在走向更长的输出、更复杂的推理轨迹、更丰富的格式。传统解析器跟不上——它们的 O(n²) 架构注定如此。 我们开发 Incremark 是因为我们自己需要它。希望你也觉得它有用。 --- 📚 **文档**:[incremark.com](https://www.incremark.com/) 💻 **GitHub**:[kingshuaishuai/incremark](https://github.com/kingshuaishuai/incremark) 🎮 **在线演示**:[Vue](https://incremark-vue.vercel.app/) | [React](https://incremark-react.vercel.app/) | [Svelte](https://incremark-svelte.vercel.app/) 如果这篇文章帮你节省了调试时间,去 GitHub 点个 ⭐️ 吧。有问题?开个 issue 或者在下面留言。
搞懂前端代理:Axios baseURL 与 Vite Proxy 的协作机制
#前端工程化 #网络请求与跨域 #环境配置与部署 #Vite #Axios ### 在搭建前端项目时,我们常会发现 myAxios.ts(请求封装)和 vite.config.ts(构建配置)中似乎都在配置后端地址。为什么myAxios和vite.config都要配置请求地址?能不能只写一个? ### 这两者虽然看似重复,实则各司其职,**缺一不可**。 ### **1.职责分离** * **myAxios.ts (baseURL):是"暗号"** 它给所有请求加上统一前缀(如 /api),告诉代码:“凡是带这个前缀的,都是发给后端的”。 生效范围:开发环境 + 生产环境 * **vite.config.ts (Proxy):是"翻译官"** 它拦截带有“暗号”的请求,将其转发到真实的后端地址(如 localhost:8080),解决浏览器的同源策略 (跨域)限制 生效范围:仅限开发环境 * * * **2.流量流向对比** * **开发环境 (Dev):** Axios 发送 '/api/user' ➔ Vite 服务器拦截 ➔ 代理转发到 'http://localhost:8080/user' * **生产环境 (Prod):** Axios 发送 '/api/user' ➔ Nginx/后端服务器直接接收 (此时没有 Vite 了) * * * **3.为什么不能只写一个:** * 如果只在 Axios 写死 'http://localhost:8080' , 生产环境会报错(地址变了),且开发环境会有跨域问题。 * 如果只在 Vite 配代理:Axios 不加前缀,Vite 就不知道哪些请求需要被代理转发。 以此确保: * 开发环境可以正常工作(通过代理解决跨域) * 生产环境可以正确部署(直接请求后端) * 代码的可维护性(清晰的职责分离) * * * **4.代码实例:** ```typescript // 开发和生产都用这个相对路径 const myAxios = axios.create({ baseURL: '/api' // 统一前缀,不写死 IP }); ``` ```typescript // 只在开发环境使用 server: { proxy: { '/api': {// 捕获前缀 target: 'http://localhost:8080',// 真实后端地址 changeOrigin: true } } } ```
为了解决 AI 流式输出的重复解析问题,我发布了 incremark:普通情况下 AI 流式渲染也能提速 2-10 倍以上
昨天,我发布了周末开发的 [incremark](https://incremark-docs.vercel.app/)。实际性能远超预期——**在 AI 流式场景中通常实现了 2-10 倍的速度提升,对于更长的文档提升更大**。虽然最初打算作为自己产品的内部工具,但我意识到开源可能是一个更好的方向。 ## 解决的痛点问题 每次 AI 流式输出新的文本块时,传统的 markdown 解析器都会**从头开始重新解析整个文档**——在已经渲染的内容上浪费 CPU 资源。Incremark 通过只解析新增内容来解决这个问题。 ## 基准测试结果:眼见为实 **较短的 Markdown 文档:**  **较长的 Markdown 文档:**   > **说明:**由于分块策略的影响,每次基准测试的性能提升倍数可能有所不同。演示页面使用随机块长度:`const chunks = content.match(/[\s\S]{1,20}/g) || []`。这种分块方式会影响稳定块的生成,更好地模拟真实场景(一个块可能包含前一个或后一个块的内容)。无论如何分块,性能提升都是有保证的。演示网站没有使用任何人为的分块策略来夸大结果。 **在线演示:** - Vue 演示:<https://incremark-vue.vercel.app/> - React 演示:<https://incremark-react.vercel.app/> - 文档:<https://incremark-docs.vercel.app/> 对于超长的 markdown 文档,性能提升更加惊人。**20KB 的 markdown 基准测试实现了令人难以置信的 46 倍速度提升**。内容越长,提速越显著——理论上没有上限。 ## 核心优势 ⚡ **通常 2-10 倍提速** - 针对 AI 流式场景 🚀 **更大的提速** - 对于更长的文档(测试最高达 46 倍) 🎯 **零冗余解析** - 每个字符最多只解析一次 ✨ **完美适配 AI 流式** - 专为增量更新优化 💪 **也适用于普通 markdown** - 不仅限于 AI 场景 🔧 **框架支持** - 包含 React 和 Vue 组件 ## 为什么这么快? ### 传统解析器的问题 任何构建过 AI 聊天应用的人都知道,AI 流式输出会将内容分成小块传输到前端。每次接收到新块后,整个 markdown 字符串都必须喂给 markdown 解析器(无论是 remark、marked.js 还是 markdown-it)。这些解析器每次都会重新解析整个 markdown 文档,即使是那些已经渲染且稳定的部分。这造成了巨大的性能浪费。 像 vue-stream-markdown 这样的工具在渲染层做了努力,将稳定的 token 渲染为稳定的组件,只更新不稳定的组件,从而在 UI 层实现流畅的流式输出。 然而,这仍然无法解决根本的性能问题:**markdown 文本的重复解析**。这才是真正吞噬 CPU 性能的怪兽。输出文档越长,性能浪费越严重。 ### Incremark 的核心性能优化 除了在 UI 渲染层实现组件复用和流畅更新外,incremark 的关键创新在于 **markdown 解析**:**只解析不稳定的 markdown 块,永不重新解析稳定的块**。这将解析复杂度从 **O(n²) 降低到 O(n)**。理论上,输出越长,性能提升越大。 #### 1. 增量解析:从 O(n²) 到 O(n) 传统解析器每次都重新解析整个文档,导致解析工作量呈二次方增长。Incremark 的 `IncremarkParser` 类采用增量解析策略(参见 `IncremarkParser.ts`): ```typescript // 设计思路: // 1. 维护一个文本缓冲区来接收流式输入 // 2. 识别"稳定边界"并将已完成的块标记为 'completed' // 3. 对于正在接收的块,只重新解析该块的内容 // 4. 复杂的嵌套节点作为一个整体处理,直到确认完成 ``` #### 2. 智能边界检测 `append` 函数中的 `findStableBoundary()` 方法是关键优化点: ```typescript append(chunk: string): IncrementalUpdate { this.buffer += chunk this.updateLines() const { line: stableBoundary, contextAtLine } = this.findStableBoundary() if (stableBoundary >= this.pendingStartLine && stableBoundary >= 0) { // 只解析新完成的块,永不重新解析已完成的内容 const stableText = this.lines.slice(this.pendingStartLine, stableBoundary + 1).join('\n') const ast = this.parse(stableText) // ... } } ``` #### 3. 状态管理避免冗余计算 解析器维护几个关键状态来消除重复工作: - `buffer`:累积的未解析内容 - `completedBlocks`:已完成且永不重新解析的块数组 - `lineOffsets`:行偏移量前缀和,支持 O(1) 行位置计算 - `context`:跟踪代码块、列表等的嵌套状态 #### 4. 增量行更新优化 `updateLines()` 方法只处理新内容,避免全量 split 操作: ```typescript private updateLines(): void { // 找到最后一个不完整的行(可能被新块续上) const lastLineStart = this.lineOffsets[prevLineCount - 1] const textFromLastLine = this.buffer.slice(lastLineStart) // 只重新 split 最后一行及其后续内容 const newLines = textFromLastLine.split('\n') // 只更新变化的部分 } ``` ## 性能对比 这种设计在实际测试中表现卓越: | 文档大小 | 传统解析器(字符数) | Incremark(字符数) | 减少比例 | |---------|-------------------|-------------------|---------| | 1KB | 1,010,000 | 20,000 | 98% | | 5KB | 25,050,000 | 100,000 | 99.6% | | 20KB | 400,200,000 | 400,000 | 99.9% | ## 关键不变量 Incremark 的性能优势源于一个关键不变量:**一旦块被标记为 completed,就永远不会被重新解析**。这确保了每个字符最多只被解析一次,实现了 O(n) 的时间复杂度。 ## 适用场景 完美适用于: - 🤖 带流式响应的 AI 聊天应用 - ✍️ 实时 markdown 编辑器 - 📝 实时协作文档 - 📊 带 markdown 内容的流式数据看板 - 🎓 交互式学习平台 **无论你是在构建 AI 界面还是只是想要更快的 markdown 渲染,incremark 都能提供你需要的性能。** ## 欢迎体验与支持 非常欢迎尝试与体验,在线演示是感受速度提升最直观的方式: Vue 演示:https://incremark-vue.vercel.app/ React 演示:https://incremark-react.vercel.app/ 文档:https://incremark-docs.vercel.app/ 如果你觉得 incremark 有用并想要参与改进,也欢迎提交 issue 与独特想法 由于之前 v 站大哥们的反馈跟 star,打算将这个工具开源的事当个事儿来做,这里也欢迎 Y 站的朋友一起参与 
