普通视图

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

事件监听器销毁完全指南:如何避免内存泄漏?

作者 wuhen_n
2026年3月17日 09:24

前言

我们在实际开发中可能遇到过这样的情况:打开一个网页,一开始很流畅,但后面越用越卡;尤其是切换页面后,感觉浏览器变慢了;长时间不刷新,页面最终崩溃了。

这很可能就是 内存泄漏 在作祟。

想象一下:我们有个垃圾桶,每天都在往里面扔垃圾,但从来不倒。一开始没什么问题,但一个月后,垃圾堆满了屋子,我们连站的地方都没有了。

事件监听器导致的内存泄漏,就是这样——垃圾不倒,导致越积越多。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,深入探讨事件监听器导致内存泄漏的成因、检测方法、预防措施,以及 TypeScript 如何帮助我们构建类型安全的清理策略。

为什么事件监听器会成为内存杀手?

从一个简单的例子开始

App.vue

<template>
  <div>
    <button @click="show = !show">切换组件</button>
    <ChildComponent v-if="show" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

ChildComponent.vue

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

onMounted(() => {
  // 每次组件挂载时,都添加一个滚动监听
  window.addEventListener('scroll', () => {
    console.log('滚动位置:', window.scrollY)
  })
})
</script>

这看起来没什么问题,但实际上发生了什么呢? 每次切换组件,都会增加一个新的监听器! 当成百上千次切换后,就有上千个监听器在工作...

为什么没有自动清理?

很多人都以为只要组件销毁了,它里面的东西会自动清理。但事实是:

  • Vue 可以自动清理:组件的数据、事件、计算属性等
  • Vue 不能自动清理:window/document 上的事件、定时器、WebSocket 等

内存泄漏的危害有多大?

指标 正常状态 泄漏状态 影响
内存占用 50MB 500MB+ 页面卡顿,甚至崩溃
事件响应 即时 延迟1-2秒 用户体验差
CPU使用率 10% 60%+ 电脑发烫,风扇狂转
电池消耗 正常 快3倍 移动端灾难

三种事件注册方式及其清理

三种注册方式对比

注册方式 优点 缺点 清理方法
内联事件 简单直接 无法移除多个,污染HTML 赋值为null
属性赋值 可移除 只能绑定一个 赋值为null
addEventListener 可绑定多个,灵活 需要对应 remove removeEventListener

内联事件的清理

// 移除内联事件
const button = document.querySelector('button')
button.onclick = null

// 或者移除整个元素
button.remove()

// 更彻底:清空父元素内容
parent.innerHTML = ''  // 会移除所有子元素的事件

注:实际 Vue 开发中,不推荐直接使用内联事件,推荐使用 Vue 的事件绑定 @click 等。

属性赋值的清理

// 注册
window.onresize = handleResize
document.onkeydown = handleKeyDown
button.onclick = handleClick

// 清理
window.onresize = null
document.onkeydown = null
button.onclick = null

注:属性赋值只能有一个监听器 window.onresize = fn1 window.onresize = fn2
此时 fn2 会覆盖 fn1

addEventListener 的正确清理

function handleResize() {
  console.log('resize')
}
window.addEventListener('resize', handleResize)
window.removeEventListener('resize', handleResize)

为什么 removeEventListener 有时候不工作?

场景一:匿名函数无法移除

window.addEventListener('click', () => {})
window.removeEventListener('click', () => {})  // ❌ 错误:匿名函数无法移除

因为匿名函数每次创建时都是新的,会重复创建,因此无法移除。

场景二:capture 参数不同,无法移除

window.addEventListener('click', handleClick, true)
window.removeEventListener('click', handleClick, false)  //   ❌ 错误::capture 不同,无法移除

场景三:options 对象不同,无法移除

const options1 = { passive: true }
const options2 = { passive: true }
element.addEventListener('click', handleClick, options1)
element.removeEventListener('click', handleClick, options2)  //  ❌ 错误:不同对象,无法移除

一句话总结:removeEventListener 的参数必须和 addEventListener 完全一致才能移除。

Vue 组件中的事件清理

最基本的清理模式

<script setup>
import { onMounted, onUnmounted, ref } from 'vue'

const scrollTop = ref(0)

// 1. 使用具名函数
function handleScroll() {
  scrollTop.value = window.scrollY
}

onMounted(() => {
  // 2. 注册事件
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  // 3. 组件卸载时移除事件
  window.removeEventListener('scroll', handleScroll)
})
</script>

<template>
  <div>滚动位置: {{ scrollTop }}</div>
</template>

封装可复用的组合式函数

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, handler) {
  // 确保 target 存在
  if (!target?.addEventListener) return
  
  // 注册
  onMounted(() => {
    target.addEventListener(event, handler)
  })
  
  // 自动清理
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

使用示例:

useEventListener(window, 'resize', () => {
  console.log('窗口大小变化', window.innerWidth)
})

useEventListener(document, 'visibilitychange', () => {
  console.log('页面可见性变化')
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') {
    console.log('按下 ESC 键')
  }
})

支持多个事件的组合式函数

// composables/useWindowEvents.js
import { onMounted, onUnmounted } from 'vue'

export function useWindowEvents(handlers) {
  const entries = Object.entries(handlers)
  
  onMounted(() => {
    entries.forEach(([event, handler]) => {
      window.addEventListener(event, handler)
    })
  })
  
  onUnmounted(() => {
    entries.forEach(([event, handler]) => {
      window.removeEventListener(event, handler)
    })
  })
}

使用示例:

useWindowEvents({
  resize: () => console.log('resize'),
  scroll: () => console.log('scroll'),
  click: (e) => console.log('click at', e.clientX, e.clientY)
})

返回清理函数的 Hook 模式

// composables/useResizeObserver.js
import { ref, onUnmounted } from 'vue'

export function useResizeObserver(target) {
  const width = ref(0)
  const height = ref(0)
  
  // 创建观察者
  const observer = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (entry) {
      width.value = entry.contentRect.width
      height.value = entry.contentRect.height
    }
  })
  
  // 开始观察
  const el = unref(target)
  if (el) {
    observer.observe(el)
  }
  
  // 返回清理函数
  const cleanup = () => {
    observer.disconnect()
  }
  
  // 组件卸载时自动清理
  onUnmounted(cleanup)
  
  return {
    width,
    height,
    cleanup  // 也可以手动调用
  }
}

使用示例:

const container = ref()
const { width, height } = useResizeObserver(container)

内存泄漏的检测与诊断

Chrome DevTools 内存面板使用

// 步骤1:录制内存分配时间线
// Performance 面板 → Memory 勾选 → 开始录制
// 执行可能导致泄漏的操作 → 停止录制
// 查看内存曲线:正常应该波动后回落,泄漏会持续增长

// 步骤2:拍摄堆快照
// Memory 面板 → Take heap snapshot

// 步骤3:对比快照
// 操作前后各拍一次 → 选择 Comparison 视图
// 重点查看:
// - Detached 元素(已从 DOM 移除但未被回收)
// - 增加的 EventListener 数量
// - 新增的闭包引用

// 步骤4:使用 Allocation instrumentation on timeline
// 实时记录内存分配,定位泄漏的具体代码

Performance Monitor 实时监控

// 在 DevTools 中打开 Performance Monitor(Ctrl+Shift+P 搜索)
// 关注指标:
// - JS Heap size:堆内存大小,正常应该稳定在某个范围
// - DOM Nodes:DOM 节点数量,动态内容应有增有减
// - Event Listeners:事件监听器数量,不应无限增长
// - Documents:文档数量,通常为1

// 正常情况:操作前后指标应该基本持平
// 泄漏情况:指标持续增长,不会下降

手动检测代码

// 在开发环境添加监控工具
if (import.meta.env.DEV) {
  // 每5秒输出一次内存状态
  setInterval(() => {
    console.table({
      '时间': new Date().toLocaleTimeString(),
      'JS Heap': formatBytes((performance as any).memory?.usedJSHeapSize),
      'DOM Nodes': document.querySelectorAll('*').length,
      'Event Listeners': countEventListeners(),
      'Detached Nodes': countDetachedNodes()
    })
  }, 5000)
}

function countEventListeners(): number {
  // 遍历所有 DOM 元素,统计监听器(仅限 Chrome)
  let count = 0
  const allElements = document.querySelectorAll('*')
  
  allElements.forEach(el => {
    const listeners = (el as any).getEventListeners?.()
    if (listeners) {
      count += Object.values(listeners).flat().length
    }
  })
  
  return count
}

function countDetachedNodes(): number {
  // 统计已从 DOM 移除但未被回收的元素
  const heapSnapshot = (window as any).heapSnapshot
  if (!heapSnapshot) return 0
  
  let count = 0
  // 遍历堆快照统计 detached 元素
  // 具体实现依赖 DevTools 协议
  return count
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}

常见陷阱与解决方案

陷阱一:在循环中注册事件

// ❌ 错误:每秒增加一个监听器
setInterval(() => {
  window.addEventListener('resize', () => {
    console.log('resize')
  })
}, 1000)

// ✅ 正确:只注册一次
window.addEventListener('resize', () => {
  console.log('resize')
})

setInterval(() => {
  // 做其他事
}, 1000)

陷阱二:watch 中注册事件

// ❌ 错误:每次 ID 变化都增加监听器
watch(() => route.params.id, () => {
  window.addEventListener('scroll', handleScroll)
})

// ✅ 正确:只注册一次
onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

function handleScroll() {
  // 根据当前 ID 做不同处理
  if (route.params.id) {
    console.log('当前ID:', route.params.id)
  }
}

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

陷阱三:箭头函数的 this 问题

class Component {
  data = 'test'
  
  // ❌ 错误:每次调用都创建新函数
  render() {
    button.addEventListener('click', () => {
      console.log(this.data)  // 无法移除
    })
  }
  
  // ✅ 正确:使用类属性方法
  handleClick = () => {
    console.log(this.data)
  }
  
  render() {
    button.addEventListener('click', this.handleClick)
    // 可以移除
    button.removeEventListener('click', this.handleClick)
  }
}

陷阱四:第三方库不销毁

import Swiper from 'swiper'
import * as echarts from 'echarts'

let swiper = null
let chart = null

onMounted(() => {
  // ❌ 只创建不销毁
  swiper = new Swiper('.swiper', {})
  chart = echarts.init(document.getElementById('chart'))
})

onUnmounted(() => {
  // ✅ 必须调用销毁方法
  if (swiper) {
    swiper.destroy(true, true)
    swiper = null
  }
  
  if (chart) {
    chart.dispose()
    chart = null
  }
})

最佳实践清单

开发时 Checklist

  • 每个 addEventListener 都有对应的 removeEventListener
  • 清理函数是否在 onUnmounted 中调用?
  • 匿名函数是否改成了具名函数或变量引用?
  • 节流/防抖的定时器是否清理了?
  • IntersectionObserver/ResizeObserver 是否调用了 disconnect
  • 第三方库实例是否调用了 destroydispose 方法?
  • 动态添加的元素,事件是否在移除元素时清理?

代码审查 Checklist

  • 是否有在循环或高频操作中注册事件?
  • 事件回调中是否持有大量数据的引用?(可能导致内存泄漏)
  • 多个组件共享的全局事件,是否考虑了竞态条件?
  • 组件销毁时,是否清理了所有自定义事件?
  • 使用 once 选项的事件是否确实只需要执行一次?

性能监控 Checklist

  • 是否定期检查 DevTools 的 Event Listeners 数量?
  • 是否有内存泄漏的自动化测试?
  • 生产环境是否有内存监控告警?
  • 是否建立了性能基准,跟踪内存趋势?
  • 是否在关键操作前后进行了内存快照对比?

注册清理对应表

注册 清理
addEventListener removeEventListener
setInterval clearInterval
setTimeout clearTimeout
new Observer observer.disconnect()
new WebSocket websocket.close()
new Swiper swiper.destroy()
echarts.init chart.dispose()

结语

好的代码不仅要能运行,还要能优雅地停止。学会正确地清理事件监听器,是每个前端开发者从入门到进阶的必修课。

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

为什么 :is(::before, ::after) 不能工作?

2026年3月17日 08:57

在 CSS 中,:is() 是一个非常实用的函数型伪类,它可以帮助我们简化和合并选择器。很多 Web 开发者都会用它来减少重复代码,让选择器更简洁、更易读。

例如:

button.large,
button.small {
    /* CSS */
}

可以使用 :is() 改写为:

button:is(.large, .small) {
    /* CSS */
}

这样既保持了相同的效果,又让代码更加紧凑。因此,很多人会把 :is() 理解为一种 “合并选择器”的工具

不过,当你对 :is() 越来越熟悉时,可能会产生一个看起来很合理的想法:既然 :is() 可以合并选择器,那是否也可以用来同时选择元素的伪元素?例如:

button:is(::before, ::after) {
    /* CSS */
}

从直觉上看,这似乎是在说:选择 button ::before ::after。但实际上,这段代码不会生效。浏览器会直接忽略它,因为在 CSS 规范中,伪元素是不允许写在 :is() 里的

这就引出了一个非常有意思的问题:为什么 :is(::before, ::after) 不能工作? 要理解这个问题,我们需要先弄清楚一件事: :is() 在 CSS 选择器中到底是如何工作的。 很多开发者对它的理解,其实和浏览器真正的解析方式并不完全一样。

在接下来的内容中,我们会一步一步拆解:

  • :is() 的真实作用是什么

  • 为什么伪元素不能出现在 :is()

  • 为什么 :is(:hover, :focus) 是合法的,而 :is(::before, ::after) 却不是

  • 以及如何正确地编写涉及伪元素的选择器

理解这些规则之后,你不仅能避免一个常见的 CSS 坑,还能更准确地理解 CSS 选择器的工作方式

:is() 的真实作用是什么?

很多人第一次接触 :is() 时,都会把它理解为一种用来合并选择器的语法糖。例如:

button.large,
button.small {
    /* CSS */
}

可以写成:

button:is(.large, .small) {
    /* CSS */
}

从结果上看,两种写法确实是等价的,因此很容易让人产生一个印象: :is() 的作用就是把多个选择器合并成一个。但实际上,这只是表面现象。

在 CSS 选择器中,:is() 的真实作用并不是“展开选择器”,而是为当前元素增加匹配条件。换句话说,:is() 本身是一个伪类(pseudo-class) 。它的行为和 .class#id:hover 这些选择器类似,都是在为元素增加匹配规则。

例如:

button:is(.large, .small) {
    /* CSS */
}

正确的理解方式是:选择所有 既是 button 元素,同时又匹配 :is() 内部条件的元素。也就是:

  • 元素必须是 button

  • 同时具有 .large .small

因此,它并不是先被浏览器转换成:

button.large,
button.small {
    /* CSS */
}

再去匹配元素,而是浏览器直接按照选择器规则进行匹配判断

理解这一点非常重要,因为它会影响我们如何阅读选择器。例如:

:is(.card, .panel) {
    /* CSS */
}

表示:选择任何 匹配 .card .panel 的元素。 而如果写成:

button:is(.card, .panel) {
    /* CSS */
}

含义就变成:选择 既是 button 元素,同时又匹配 .card .panel 的元素。

因此可以总结为一句话: :is() 用来为当前元素增加一组“可选的匹配条件”。 它并不会改变选择器的目标,也不会去“选择”括号里的内容,而只是判断当前元素是否满足其中任意一个条件

正是因为这个机制,才会导致一个容易让人困惑的现象:伪元素(例如 ::before ::after )不能写在 :is() 里面。

为什么伪元素不能出现在 :is() 中?

理解这个问题的关键,仍然是 :is() 的作用。正如前面提到的,:is() 是一个伪类,它只是为当前元素增加匹配条件。也就是说,它是在判断:当前元素是否满足括号中的某个选择器

例如:

button:is(.large, .small) {
    /* CSS */
}

意思是:选择既是 button 元素,同时又匹配 .large.small 的元素。换句话说,:is() 只是用来判断 button 元素是否符合这些条件

现在再来看这个选择器:

button:is(::before, ::after) {
    /* CSS */
}

如果按照同样的逻辑去阅读,它的含义就变成:选择既是 button 元素,同时又匹配 ::before ::after 的元素

问题就在这里,元素不可能是伪元素:

  • button 是一个 真实的 DOM 元素

  • ::before::after伪元素

伪元素并不是独立的节点,而是附属于某个元素生成的内容。因此,一个元素不可能同时既是元素又是伪元素。所以这个选择器的条件永远不可能成立,浏览器也就不会匹配到任何内容。

这也是为什么 :is() 中只允许写普通选择器或伪类,而不能写伪元素。例如下面的写法是完全合法的:

button:is(:hover, :focus, :active){
    /* CSS */
}

它的意思是:选择所有 处于 :hover :focus :active 状态的 button 元素。因为 :hover:focus:active 都是伪类,只是描述元素状态,因此可以作为匹配条件使用。

简单来说,可以记住这样一条规则: :is() 用来给元素增加匹配条件,而不是用来选择伪元素。 同样的限制也适用于另外两个伪类 :not():where()

温馨提示:有关于 :is():not():where() 选择更详细的介绍,请移步阅读《CSS 选择器::where() vs. :is()》!

如何正确地编写涉及伪元素的选择器?

既然 :is() 不能包含伪元素,那么当我们需要同时为多个伪元素编写样式时,应该怎么写?最常见、也是最推荐的方法,就是使用逗号分隔的选择器列表

例如:

button::before,
button::after {
    content: "";
    position: absolute;
}

这种写法虽然稍微长一点,但它清晰、直观,而且完全符合 CSS 规范。

如果多个选择器的主体部分相同,而只有伪元素不同,也通常只能这样写:

.card::before,
.card::after {
    content: "";
    position: absolute;
}

这里实际上是在选择.card::before.card::after。而不是试图把伪元素放进 :is() 中。

不过,我们仍然可以在元素部分使用 :is() ,然后在最后添加伪元素。例如:

:is(button, a)::before {
    content: "";
}

这个选择器的意思是:选择 buttona 元素的 ::before 伪元素。同样的思路也可以用于类选择器:

:is(.card, .panel)::after {
    content: "";
}

表示 .card::after.panel::after

因此,可以记住一个简单的经验法则: :is() 可以用来匹配元素,但伪元素必须写在选择器的最后。 例如:

/* ✅ 正确写法 */
:is(button, a)::before {
    /* CSS */
}

/* ❌ 错误写法 */
button:is(::before, ::after) {
    /* CSS */
}

总结

:is() 是一个非常强大的 CSS 工具,它可以帮助我们减少重复选择器,让代码更简洁。但需要记住两点关键规则:

  • :is() 用于匹配元素条件,而不是选择伪元素

  • 伪元素必须始终写在选择器的最后

理解这一点之后,你不仅可以避免 :is(::before, ::after) 这样的常见错误,还能更深入地理解 CSS 选择器的匹配机制

Cursor 独有的 12 个技巧:这些是 Claude Code 没有的

作者 清汤饺子
2026年3月17日 07:49

前言

Hi~大家好呀,我是清汤饺子。

最近身边问 Cursor 和 Claude Code 区别的人越来越多了。每次被问到,我都会先问一句:你更享受在终端里敲命令,还是更喜欢在编辑器里直接改代码?

这个问题没有标准答案。但如果你选了 Cursor,这篇文章就是为你准备的——那些它独有的、编辑器才能做到的能力。

说起来,我之前写过两篇 Cursor 相关的文章:

这篇只讲一件事:在上面两篇都没覆盖的地方,Cursor 还有哪些独有的能力。那些因为它是编辑器、而不是命令行工具,才能做到的事。


第一部分:编辑器独有的执行能力

1. Plan Mode——计划是可以编辑的

这个功能上一篇文章讲过了,这里不重复啦。只补充一个很多朋友可能没注意到的细节:

Cursor 的 Plan Mode,生成的计划是一个可以直接编辑的文档,而不只是一段可以阅读的文字。

我第一次用的时候还不知道这个功能,后来发现了这个"隐藏技能",才发现它有多好用~

💡 心得:直接在计划上删删改改,比跟 AI 来来回回对话舒服多了。你试一次就知道啦!


2. Debug Mode——有系统地找根因,而不是猜

遇到难搞的 bug 时,你们有没有试过把报错贴给 Agent,让它猜着改?

反正我试过,而且踩过大坑……

有一次我被一个奇怪的 bug 折磨了整整一天,把报错贴给 Agent,它猜着改,一遍不行,再来一遍,还不行……前前后后改了十几个地方,最后发现根因根本不是我最初想的那个问题。

后来我发现了 Cursor 的 Debug Mode,按 Shift+Tab 切换。我跟你说,这个功能简直打开了新世界的大门!

这个功能是怎么工作的呢?

  • 第一步,生成假设:Agent 会先分析你的代码,列出几种可能的根因。它不是直接改,而是先猜一猜可能是什么问题。

  • 第二步,插入日志:它在关键的地方帮你加上 log 语句,这些日志会发送到 Cursor 扩展内置的一个本地 debug server。

  • 第三步,让你复现:Agent 会告诉你具体怎么操作才能触发这个 bug,等你来帮它复现问题。

  • 第四步,分析日志:拿到日志后,Agent 会读取运行时的数据,基于真实的证据来确定到底哪里出了问题。

  • 第五步,精准修复:找到根因后,Agent 只会改最少的代码,往往就几行,不像以前那样改一大堆。

  • 第六步,清理日志:修复完成后,Agent 会自动把之前加的 log 语句全部删掉,不让你的代码变得脏脏的~

💡 心得:Debug Mode 让 Agent 先问"是什么",再回答"怎么修"。这个顺序的改变,把我十几轮猜测压缩成两三次操作。你们也试试~

3. 内置浏览器——Agent 能直接操作你的页面

前端问题用文字描述给 Agent,往往说不清楚对吧?

Cursor 内置了浏览器工具,Agent 可以直接打开你的页面来操作和观察,不需要你手动截图、复制日志~是不是很方便!

Agent 能帮你做这些事

  • 点点点:打开网页、点击按钮、填表单、触发各种用户操作,Agent 都能帮你做
  • 截图:实时看到页面长什么样,布局对不对、样式有没有问题,一目了然
  • 看 Console:JS 报错和调试输出,Agent 直接帮你读,不用你复制粘贴
  • 看 Network:API 调了什么、请求体是什么、返回了什么状态码,一清二楚
  • 可视化改样式:直接在浏览器里拖拖拽拽调整布局、颜色、间距、圆角,确认效果后一键让 Agent 把改动写回代码

怎么用:直接在 Agent 对话里跟它说就行,比如:

@browser 帮我打开 localhost:3000,看看控制台有没有报错,
顺便截个图给我看看登录页现在长啥样

💡 一个小细节:浏览器日志不是直接塞进上下文的,而是先写到文件里,Agent 需要的时候再 grep 读取需要的那几行。这样就算页面输出一大堆噪音,主对话也不会被撑爆~是不是很贴心!

💡 这是 Claude Code 做不到的哦。Claude Code 没有内置浏览器,你只能手动截图或者复制粘贴日志贴给它。Cursor 的浏览器和 Agent 深度集成,前端调试效率差距真的特别明显。你们有同感吗?


4. Parallel Agents + Best-of-N——让多个模型同时跑同一个任务

这是我觉得 Cursor 里最有意思的功能之一,但知道的人不多~

简单说:Cursor 用 Git Worktree 来隔离并行 Agent 的工作区,每个 Agent 跑在独立的分支里,互不干扰,改完可以单独 Apply 到你的主分支。

有两种用法

用法一:普通并行——同时跑多个不同任务,互不阻塞。一个 Agent 在重构支付模块,另一个在写测试,两个任务并行推进,都完成后分别 Apply。是不是很爽!

用法二:Best-of-N——把同一个任务同时交给多个模型,然后挑最好的结果。在 Agent 输入框下方的下拉里选多个模型,提交后你会看到多张卡片,每张对应一个模型的实现方案,点哪里就预览哪里的改动,最后选你最满意的那个 Apply。简直选择困难症的福音!

什么时候用 Best-of-N 呢

  • 遇到很难的问题,不确定哪个模型会给出更好的方案
  • 想看看不同模型在你代码库上表现怎么样
  • 做架构决策的时候,想对比两种不同的实现思路

💡 心得:我现在遇到比较难的任务,习惯性地开 Best-of-N 跑两个模型。不同模型确实会走不同的路,有时候一个完全卡住的问题,换个模型反而一下子就解决了。


第二部分:模型与工作流控制

5. 按任务阶段切换模型,平衡质效

用模型选择器下拉,或按 Cmd+/ 循环切换~

我是这么用的

  • 探索和读代码的时候:用快模型,比如 Gemini Flash 或 Claude Haiku,速度快,还省钱
  • 写代码的时候:换更强的模型,比如 Claude Opus 或 GPT-5,效果更好

💡 一个很关键的点:切换模型的时候,上下文完整保留,不需要重开对话。你可以在同一个对话里,探索阶段用快模型,遇到真正卡住的问题再切到最强模型,完全不影响连续性。这个你们一定要试试!真的好用!

💡 省钱小技巧:在 Cursor Settings → Models 里设置你的日常默认模型。日常开发大部分任务用中等模型就够,把强模型留给真正需要它的场景~很划算!


6. Skills——把最佳实践打包成可调用的命令

上一篇文章里讲过 Rules,Rules 是给 Agent 的持久化背景知识。Skills 是另一个层次的东西:它是把一套完整的操作流程打包成一个可复用、可分享的命令~

简单说:Rules 告诉 Agent "怎么做事",Skills 告诉 Agent "做哪件事"。是不是很好理解!

Skills 放在哪里呢

位置 谁能用到
.cursor/skills/ 或 .agents/skills/ 当前项目
~/.cursor/skills/ 你的所有项目

每个 Skill 是一个文件夹,里面放一个 SKILL.md,还可以附带 scripts/、references/、assets/ 目录~

一个典型的 Skill 长这样

---
name: rework-commits
description: 把当前 branch 的改动重新拆分成语义化的小 commit,每个 commit 只做一件事。提交 PR 前使用。
---

# Rework Commits

## 步骤

1.  git log 查看当前 branch 上的所有改动
2. 按功能边界把改动分组
3.  git add -p 分批暂存,逐个提交
4. 每条 commit message 格式:<type>: <描述>,并说明改动原因

两种调用方式

  • 在 Agent 对话里输入 /rework-commits,直接触发
  • Agent 根据上下文自动判断是否相关,主动调用(在 description 里写清楚"什么时候用",Agent 就能自己判断)

如果不想让 Agent 自动触发,只想手动调用,在 frontmatter 里加一行:

---
name: rework-commits
description: ...
disable-model-invocation: true
---

💡 心得:我把部署前检查、生成 changelog、整理 commit、跑安全扫描这几个流程都做成了 Skills,团队成员 clone 项目就能直接用,不用每次口头交代步骤。Skills 存进 git,团队共享——这是 Rules 做不到的事。


7. Hooks——在 Agent 操作前后插入你自己的逻辑

第一篇文章里提过 Hooks 的基本概念啦~这里不重复基础,重点讲三个 Cursor 独有的能力~

能力一:Tab 专属 Hooks

Cursor 有两套 Hook 事件,分别针对 Agent 和 Tab(内联补全):

Hook 什么时候触发
beforeTabFileRead Tab 补全要读文件之前
afterTabFileEdit Tab 补全写完文件之后

这两个 Hook 只有 Cursor 有哦!它让你对 Tab 自动补全的行为做单独控制,比如在 Tab 读取文件前过滤掉敏感字段,或者在 Tab 写完代码后自动跑格式化。Claude Code 没有内联补全,自然也没有这两个 Hook~是不是很酷!

能力二:团队云端分发

企业版可以在 Cursor Dashboard 里直接配置 Hooks,自动同步到所有团队成员,不需要每个人手动配置或通过 git 分发:

Dashboard → Team Content → Hooks
→ 新建 Hook 配置
→ 自动同步到所有成员(每 30 分钟一次)

配合操作系统定向投放,可以给 Mac 和 Windows 用户分别下发不同的 Hook 脚本。

能力三:开箱即用的合作伙伴集成

Cursor 官方和一批安全工具做了 Hooks 集成,拿来即用:

工具 能帮我们做什么
Semgrep Agent 写完代码自动扫描漏洞,有问题直接让它重新生成
Snyk 实时检测 Agent 操作里的 prompt injection 和危险工具调用
Endor Labs Agent 安装包之前扫描恶意依赖,防供应链攻击
1Password Shell 命令执行前验证 .env 是否正确挂载,密钥不落磁盘

💡 我是这么用的:在 afterFileEdit 里挂一个格式化脚本,Agent 每次改完文件自动跑 prettier,再也不用手动格式化了。团队里用企业版的朋友可以直接从 Dashboard 统一推下去,一次配置全员生效。


第三部分:安全边界

8. 用 .cursorignore 保护敏感文件

默认情况下,Agent 可以读取你项目里的所有文件。如果项目里有密钥、证书、敏感配置,需要主动排除~

这个真的很重要很重要!之前有朋友把 .env 文件忘了加 ignore,结果 Agent 读取了还贴到对话里,尴尬死了……你们千万别学他!

在项目根目录创建 .cursorignore:

# 环境变量和密钥
.env
.env.*
*.key
*.pem
**/secrets.json
**/credentials.json

# 不需要 AI 读取的大型生成文件
dist/
build/*.min.js

💡 全局 ignore:在 Cursor Settings 里可以配置全局 ignore 规则,对你所有项目生效,不用每个项目单独配~


9. 用 sandbox.json 精确管控网络和文件权限

Cursor Agent 在沙盒环境里执行终端命令,你可以通过 .cursor/sandbox.json 精确控制它能访问哪些域名和文件路径~

一个典型的配置

{
  "networkPolicy": {
    "default": "deny",
    "allow": [
      "registry.npmjs.org",
      "pypi.org",
      "*.githubusercontent.com"
    ]
  }
}

default: deny 表示除了明确允许的域名,其他全部拒绝。这是最安全的做法~

还能控制文件系统访问范围

{
  "networkPolicy": {
    "default": "deny",
    "allow": ["registry.npmjs.org"]
  },
  "additionalReadonlyPaths": [
    "/opt/shared/design-tokens"
  ],
  "enableSharedBuildCache": true
}

💡 沙盒内置的保护机制,默认就已经生效,不需要额外配置啦:

  • 私有 IP(RFC 1918)和云服务元数据端点(169.254.169.254)默认全部拒绝,防止 SSRF
  • .cursorignore、.git/config、.vscode/ 等配置文件默认写保护,Agent 改不了
  • Agent 的文件操作默认限制在 workspace 内,不会跑到外面去创建或修改文件
  • 用户级(~/.cursor/sandbox.json)和项目级(.cursor/sandbox.json)可以同时存在,项目级优先,但团队管理员和 Cursor 硬编码的安全规则永远在最上层,任何配置都无法覆盖

💡 有了这些保护,用 Cursor 是不是安心多了!


第四部分:代码质量与团队协作

10. Bugbot——把质量检查从"手动触发"变成"自动运转"

如果说 Plan Mode 是你主动触发的防线,Bugbot 是全自动的后台守卫。你推 PR,它自动跑,在代码行内直接留评论~是不是很酷!

你们敢信?我上次忘了开 Bugbot,结果有个安全漏洞直接被忽略了……后来想想都后怕!

后来开了 Bugbot 之后,每次 PR 它都会自动检查,简直是安全感拉满!

它能帮我们发现这些问题

  • 逻辑错误
  • 空指针风险
  • Race condition
  • 安全隐患
  • 缺失的错误处理

两种触发方式

  1. 每次推 PR 更新,自动运行
  2. 在 PR 下面评论 cursor review 或 bugbot run,手动触发

发现问题后有两种处理方式

  • 点 "Fix in Cursor" 链接,跳回编辑器,让本地 Agent 修复
  • 如果开启了 Autofix,Bugbot 直接调起 Cloud Agent 自动提交修复,全程不需要你介入

还能配置项目特有的检查规则:在 .cursor/BUGBOT.md 里写:

## 安全规范
所有 API 入参必须经过 zod 校验,发现缺失的校验直接标记为 blocking bug。

## 测试要求
修改 backend/ 下的文件必须有对应的测试变更,否则标记为 blocking bug。

团队管理员还可以在 Dashboard 里配置全团队通用的 Bugbot Rules,自动应用到所有仓库,不需要每个项目单独写~是不是很方便!

💡 怎么设置:打开 cursor.com/dashboard → Integrations → 连接 GitHub → Bugbot tab 开启对应的仓库


11. Cloud Agent——把耗时任务放到后台跑

本地 Agent 跑任务时,你需要保持电脑开着,网络连着。如果是个耗时很长的任务,或者你想同时干别的事,用 Cloud Agent~是不是很爽!

我最近天天用 Cloud Agent 写测试覆盖率你们知道吗?以前得开着电脑等半天,现在直接交给 Cloud Agent,跑完自动开 PR,我回来看结果就行,效率拉满!真的!

怎么用:在 Agent 输入框下方的下拉菜单里选 "Cloud",然后正常发送任务~

Cloud Agent 会帮你做这些

  1. 自动 clone 你的仓库到独立的云端沙盒
  2. 在隔离环境里跑任务(能构建、跑测试、操作浏览器)
  3. 完成后开一个 PR,等你来 review
  4. 通过 Slack、邮件或网页界面通知你

还能从这些地方触发 Cloud Agent

从哪里触发 怎么用
cursor.com/agents 网页界面,手机也能用
Slack @cursor [描述],支持指定仓库和模型
Linear 把 issue 指派给 Cursor,或评论 @cursor
GitHub 在 PR 或 Issue 下评论 @cursor [描述]
API 用 API 触发,接入内部工具

💡 Slack 有个很好用的小细节:Cloud Agent 会读取整个 thread 的上下文。团队在 Slack 里讨论了一个问题,直接在那个 thread 里 @cursor 触发,它能理解整个讨论背景,不用你重新解释一遍~很贴心!

💡 关于费用:Cloud Agent 按 API 用量计费,使用前需要在 Dashboard → Settings 里开启 usage-based pricing~很灵活!


12. Automations——定时或事件驱动,让 Agent 全自动运转

这是前两篇都没讲到的功能,也是我觉得 Cursor 最被低估的能力之一~

Cloud Agent 是你主动触发的。Automations 是让 Agent 自己跑起来——不需要你在场,不需要你手动触发。

一句话解释:给 Cloud Agent 配一个触发器,满足条件就自动跑,跑完自动发 PR 或发 Slack 通知~

支持这些触发方式

触发方式 举个例子
定时触发 每天凌晨 2 点、每小时、每周一
GitHub 事件 PR 打开、PR 合并、push 到分支、CI 完成
Slack 事件 某个频道有新消息、消息包含特定关键词
Linear 事件 issue 创建、状态变更、cycle 结束
PagerDuty 事件 告警触发、告警解除
Webhook 自定义 HTTP 端点,接入任何内部系统

举几个真实场景

场景一:每天自动清理 feature flags

触发器:每天凌晨 2 点
任务:扫描代码库,找出已经全量的 feature flags,
 删除相关条件分支,提交 PR

场景二:PR 打开时自动做安全扫描

触发器:GitHub - Pull request opened
任务:分析 PR diff,检查是否有安全漏洞、
 硬编码的密钥、不安全的依赖,
 在 PR 下留 review 评论

场景三:Slack 报 bug 自动触发修复

触发器:Slack - #bugs 频道有新消息
任务:读取消息内容,定位相关代码,
 提交修复 PR,回复到原 thread

Automations 还有记忆功能:同一个 Automation 的多次运行之间可以共享持久化笔记,Agent 能记住上次发现了什么、做了什么,下次运行时接着来。比如安全扫描的 Automation 可以记录"上周已经报告过这个问题",避免重复留评论~是不是很智能!

💡 心得:我现在有三个 Automation 一直在后台跑:每周一自动扫描依赖更新、每次 PR 合并后自动更新 changelog、每天检查 TODO 注释是否有对应的 issue。这些事情以前要么靠人记着做,要么靠 CI 脚本维护——现在交给 Automation,我完全不用操心了~

写在最后

16 + 7 + 12 = 35 个技巧,说多不多,说少不少。

但最重要的是:看完一定要去用哦~

不然就白看啦~

回头看这三篇文章,其实有一条主线贯穿始终:你给 Agent 的上下文质量,决定了它输出的质量。 Rules 是上下文,Plan 是上下文,运行时日志是上下文,浏览器截图是上下文。所有的技巧,本质上都是在帮你把正确的信息送到模型面前。

希望这篇文章对你们有帮助呀~ 有问题欢迎来问我!

欢迎关注我的公众号「清汤饺子」,获取更多技术干货~

qrcode_for_gh_ca5e8769bdf3_1280.jpg

参考文档

零成本本地大模型!用 Next.js + Ollama + Qwen3 打造流式聊天应用

作者 倾颜
2026年3月17日 03:04

大家好!今天我来给大家分享一个非常实用的技术实现:如何在本地零成本搭建一个可以流式输出的 AI 聊天应用。不需要昂贵的 API 密钥,也不需要复杂的配置,跟着我一步步来,你也能拥有属于自己的本地 AI 助手!

一、先看效果

最终我们能实现这样一个功能:

  • 在本地运行大模型(Qwen3:4B)
  • Next.js 作为后端服务,实现流式转发
  • 前端实时展示 AI 的响应,打字机效果拉满

stream.png

二、准备工作

1. 安装并启动 Ollama

Ollama 是一个非常优秀的本地大模型运行工具,支持各种主流开源模型。

下载安装: 访问 Ollama 官网 下载对应系统的安装包,Windows/macOS/Linux 都支持。

验证安装: 安装完成后,打开终端运行:

ollama --version

如果看到版本号,说明安装成功啦!

2. 下载 Qwen3:4B 模型

Qwen 是阿里开源的系列模型,Qwen3:4B 体积小、速度快,非常适合在普通电脑上运行。

在终端中运行:

ollama pull qwen3:4b

等待下载完成后,我们可以测试一下:

ollama run qwen3:4b

如果能正常和 AI 对话,说明模型已经准备好了!按 Ctrl+C 退出。

三、Next.js 项目搭建

如果你还没有 Next.js 项目,可以快速创建一个:

npx create-next-app@latest my-ai-app
cd my-ai-app

四、核心实现:API 路由开发

这是最关键的一步,我们需要创建一个 Next.js API 路由来对接 Ollama,并实现流式输出。

app/api/ollama/route.ts 中:

import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  try {
    const { prompt, model = 'qwen3:4b' } = await request.json()

    if (!prompt) {
      return new Response('Prompt is required', { status: 400 })
    }

    const ollamaResponse = await fetch('http://localhost:11434/api/generate', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model,
        prompt,
        stream: true,
      }),
    })

    if (!ollamaResponse.ok) {
      return new Response('Failed to connect to Ollama', { status: 500 })
    }

    const encoder = new TextEncoder()
    const decoder = new TextDecoder()
    const stream = new ReadableStream({
      async start(controller) {
        const reader = ollamaResponse.body?.getReader()
        if (!reader) {
          controller.close()
          return
        }

        try {
          while (true) {
            const { done, value } = await reader.read()
            if (done) break
            
            const chunk = decoder.decode(value, { stream: true })
            const lines = chunk.split('\n').filter(line => line.trim())
            
            for (const line of lines) {
              try {
                const data = JSON.parse(line)
                if (data.response) {
                  controller.enqueue(encoder.encode(data.response))
                }
                if (data.done) {
                  break
                }
              } catch {
                continue
              }
            }
          }
        } finally {
          reader.releaseLock()
          controller.close()
        }
      },
    })

    return new Response(stream, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8',
        'Transfer-Encoding': 'chunked',
      },
    })
  } catch (error) {
    console.error('Ollama API error:', error)
    return new Response('Internal server error', { status: 500 })
  }
}

技术要点解析:

  1. 流式转发的核心:使用 ReadableStream 创建自定义流式响应
  2. 数据解析:Ollama 返回的是每行一个 JSON 对象,我们逐行解析并提取 response 字段
  3. Transfer-Encoding: chunked:这个响应头告诉浏览器这是一个分块传输的流式响应

五、前端实现:自定义 Hook + 流式展示

为了代码的复用性和可维护性,我们把流式处理逻辑封装成一个自定义 Hook。

1. 创建 useOllamaStream.ts

'use client'

import { useState, useCallback, useRef } from 'react'

interface UseOllamaStreamOptions {
  onChunk?: (chunk: string) => void
  onError?: (error: Error) => void
  onComplete?: () => void
}

export function useOllamaStream(options: UseOllamaStreamOptions = {}) {
  const [response, setResponse] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const abortControllerRef = useRef<AbortController | null>(null)

  const cancel = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort()
      abortControllerRef.current = null
    }
  }, [])

  const sendMessage = useCallback(async (prompt: string) => {
    if (!prompt.trim()) return

    setIsLoading(true)
    setResponse('')
    setError(null)
    
    const controller = new AbortController()
    abortControllerRef.current = controller

    try {
      const res = await fetch('/api/ollama', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ prompt }),
        signal: controller.signal,
      })

      if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status}`)
      }

      const reader = res.body?.getReader()
      const decoder = new TextDecoder()

      if (reader) {
        while (true) {
          const { done, value } = await reader.read()
          if (done) break
          
          const chunk = decoder.decode(value)
          setResponse(prev => prev + chunk)
          options.onChunk?.(chunk)
        }
      }
      
      options.onComplete?.()
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        setError('Request cancelled')
      } else {
        const errorMessage = err instanceof Error ? err.message : 'An error occurred'
        setError(errorMessage)
        options.onError?.(err instanceof Error ? err : new Error(errorMessage))
      }
    } finally {
      setIsLoading(false)
      abortControllerRef.current = null
    }
  }, [options])

  return {
    response,
    isLoading,
    error,
    sendMessage,
    cancel,
  }
}

2. 创建主页面 page.tsx

'use client'

import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import { useOllamaStream } from './useOllamaStream'

const ResponseDisplay = React.memo(({ response, isLoading }: { response: string; isLoading: boolean }) => {
  const responseRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (responseRef.current) {
      responseRef.current.scrollTop = responseRef.current.scrollHeight
    }
  }, [response])

  if (!response && !isLoading) return null

  return (
    <div style={{ 
      border: '1px solid #ccc', 
      padding: '15px', 
      borderRadius: '5px',
      maxHeight: '400px',
      overflowY: 'auto',
    }} ref={responseRef}>
      <h3 style={{ marginTop: 0 }}>Response:</h3>
      <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
        {response}
        {isLoading && <span style={{ opacity: 0.5 }}></span>}
      </div>
    </div>
  )
})

ResponseDisplay.displayName = 'ResponseDisplay'

export default function Page() {
  const [prompt, setPrompt] = useState('')

  const { response, isLoading, error, sendMessage, cancel } = useOllamaStream()

  const handleSubmit = useCallback((e: React.FormEvent) => {
    e.preventDefault()
    if (isLoading) {
      cancel()
    } else {
      sendMessage(prompt)
    }
  }, [prompt, isLoading, sendMessage, cancel])

  const containerStyle = useMemo(() => ({
    maxWidth: '800px',
    margin: '0 auto',
    padding: '20px',
  }), [])

  const textareaStyle = useMemo(() => ({
    width: '100%',
    minHeight: '100px',
    padding: '10px',
    marginBottom: '10px',
    fontSize: '16px',
    resize: 'vertical' as const,
  }), [])

  const buttonStyle = useMemo(() => ({
    padding: '10px 20px',
    fontSize: '16px',
    cursor: isLoading ? 'not-allowed' : 'pointer',
    backgroundColor: isLoading ? '#ff4444' : '#0070f3',
    color: 'white',
    border: 'none',
    borderRadius: '5px',
    marginRight: '10px',
  }), [isLoading])

  return (
    <div style={containerStyle}>
      <h1>Hello InstantMind</h1>
      
      {error && (
        <div style={{ 
          backgroundColor: '#ffebee', 
          color: '#c62828', 
          padding: '10px', 
          borderRadius: '5px', 
          marginBottom: '15px' 
        }}>
          Error: {error}
        </div>
      )}
      
      <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
        <textarea
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="Enter your prompt here..."
          style={textareaStyle}
          disabled={isLoading}
        />
        <div>
          <button
            type="submit"
            style={buttonStyle}
          >
            {isLoading ? 'Cancel' : 'Send'}
          </button>
        </div>
      </form>
      
      <ResponseDisplay response={response} isLoading={isLoading} />
    </div>
  )
}

六、性能优化亮点

  1. useCallback 缓存函数:避免不必要的函数重新创建
  2. useMemo 缓存样式:样式对象每次渲染都是新的,用 useMemo 可以避免子组件不必要的重渲染
  3. React.memo 缓存子组件ResponseDisplay 组件只有在 responseisLoading 变化时才重新渲染
  4. AbortController 取消请求:支持中途取消生成,体验更好
  5. 自动滚动到底部:内容过长时自动跟随

七、运行项目

启动 Next.js 开发服务器:

npm run dev

然后访问 http://localhost:3000/instamind,输入问题试试看!

八、常见问题

Q: Ollama 连接失败怎么办? A: 确保 Ollama 服务正在运行,检查 http://localhost:11434 是否可以访问。

Q: 响应速度很慢? A: 可以尝试更小的模型,比如 qwen3:1.8b,或者升级电脑硬件。

Q: 可以更换其他模型吗? A: 当然!在 API 路由中修改 model 参数即可,Ollama 支持的模型都可以用。

总结

今天我们实现了:

  1. ✅ 本地 Ollama 服务搭建
  2. ✅ Qwen3 模型下载和运行
  3. ✅ Next.js API 路由流式转发
  4. ✅ 前端流式响应展示
  5. ✅ 请求取消、自动滚动等优化

这一套方案完全免费,所有数据都在本地,隐私性极佳!快去试试吧!

如果你觉得这篇文章对你有帮助,别忘了点赞、收藏、关注三连!有问题也可以在评论区交流~


📦 完整代码

本博客对应的代码已发布 v0.0.1 版本:

👉 GitHub Release - v0.0.1

参考资料:

每日一题-重新排列后的最大子矩阵🟡

2026年3月17日 00:00

给你一个二进制矩阵 matrix ,它的大小为 m x n ,你可以将 matrix 中的  按任意顺序重新排列。

请你返回最优方案下将 matrix 重新排列后,全是 1 的子矩阵面积。

 

示例 1:

输入:matrix = [[0,0,1],[1,1,1],[1,0,1]]
输出:4
解释:你可以按照上图方式重新排列矩阵的每一列。
最大的全 1 子矩阵是上图中加粗的部分,面积为 4 。

示例 2:

输入:matrix = [[1,0,1,0,1]]
输出:3
解释:你可以按照上图方式重新排列矩阵的每一列。
最大的全 1 子矩阵是上图中加粗的部分,面积为 3 。

示例 3:

输入:matrix = [[1,1,0],[1,0,1]]
输出:2
解释:由于你只能整列整列重新排布,所以没有比面积为 2 更大的全 1 子矩形。

示例 4:

输入:matrix = [[0,0],[0,0]]
输出:0
解释:由于矩阵中没有 1 ,没有任何全 1 的子矩阵,所以面积为 0 。

 

提示:

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m * n <= 105
  • matrix[i][j] 要么是 0 ,要么是 1

枚举子矩形的底边 + O(mn) 优化(Python/Java/C++/Go)

作者 endlesscheng
2026年3月10日 22:22

做法类似 85. 最大矩形,枚举子矩形的底边(最后一行),定义 $\textit{heights}[j]$ 表示从 $\textit{matrix}[i][j]$ 往上有多少个连续的 $1$(柱子的高度),问题变成:

  • 你可以重排 $\textit{heights}$。重排后,对于 $\textit{heights}$ 的连续子数组,子数组长度(矩形底边长)$\times$ 子数组最小值(矩形的高),即为全 $1$ 子矩形的面积。

对于示例 1,以第三行为底边算出来的 $\textit{heights} = [2,0,3]$,下图重排后是 $[2,3,0]$。其中子数组 $[2,3]$,长为 $2$,最小值为 $2$,所以对应的子矩形面积为 $2\times 2 = 4$。

lc1727.png{:width=430px}

如何找到面积最大的子矩形?还是枚举。

枚举子数组的长度 $k = 1,2,\ldots,n$。由于我们可以重排 $\textit{heights}$,那么贪心地,把 $\textit{heights}$ 最大的 $k$ 个数排在一起,就可以让子数组的最小值(矩形的高)尽量大,从而得到最大的矩形面积。

对于 $\textit{heights}$ 的计算,如果 $\textit{matrix}[i][j]=0$,那么 $\textit{heights}[j] = 0$。否则,把高度增加 $1$。形象地说,就是在柱子下面垫一块石头,把柱子抬高。

优化前

###py

class Solution:
    def largestSubmatrix(self, matrix: List[List[int]]) -> int:
        n = len(matrix[0])
        heights = [0] * n
        ans = 0

        for row in matrix:  # 枚举子矩形的底边
            for j, x in enumerate(row):
                if x == 0:
                    heights[j] = 0
                else:
                    heights[j] += 1

            hs = sorted(heights)  # 复制一份 heights 再排序
            for i, h in enumerate(hs):  # 把 hs[i:] 作为子数组
                # 子数组长为 n-i,最小值为 h,对应的子矩形面积为 (n-i)*h
                ans = max(ans, (n - i) * h)  

        return ans

###java

class Solution {
    public int largestSubmatrix(int[][] matrix) {
        int n = matrix[0].length;
        int[] heights = new int[n];
        int ans = 0;

        for (int[] row : matrix) { // 枚举子矩形的底边
            for (int j = 0; j < n; j++) {
                if (row[j] == 0) {
                    heights[j] = 0;
                } else {
                    heights[j]++;
                }
            }

            int[] hs = heights.clone();
            Arrays.sort(hs);
            for (int i = 0; i < n; i++) { // 把 [i,n-1] 作为子数组
                // 子数组长为 n-i,最小值为 hs[i],对应的子矩形面积为 (n-i)*hs[i]
                ans = Math.max(ans, (n - i) * hs[i]); 
            }
        }

        return ans;
    }
}

###cpp

class Solution {
public:
    int largestSubmatrix(vector<vector<int>>& matrix) {
        int n = matrix[0].size();
        vector<int> heights(n);
        int ans = 0;

        for (auto& row : matrix) { // 枚举子矩形的底边
            for (int j = 0; j < n; j++) {
                int x = row[j];
                if (x == 0) {
                    heights[j] = 0;
                } else {
                    heights[j]++;
                }
            }

            auto hs = heights;
            ranges::sort(hs);
            for (int i = 0; i < n; i++) { // 把 [i,n-1] 作为子数组
                // 子数组长为 n-i,最小值为 hs[i],对应的子矩形面积为 (n-i)*hs[i]
                ans = max(ans, (n - i) * hs[i]); 
            }
        }
        return ans;
    }
};

###go

func largestSubmatrix(matrix [][]int) (ans int) {
n := len(matrix[0])
heights := make([]int, n)

for _, row := range matrix { // 枚举子矩形的底边
for j, x := range row {
if x == 0 {
heights[j] = 0
} else {
heights[j]++
}
}

hs := slices.Clone(heights)
slices.Sort(hs)
for i, h := range hs { // 把 hs[i:] 作为子数组
ans = max(ans, (n-i)*h) // 子数组长为 n-i,最小值为 h,对应的子矩形面积为 (n-i)*h
}
}

return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn\log n)$,其中 $m$ 和 $n$ 分别是 $\textit{matrix}$ 的行数和列数。瓶颈在排序上,有 $m$ 行,每行都要跑一个 $\mathcal{O}(n\log n)$ 的排序。
  • 空间复杂度:$\mathcal{O}(n)$。

优化

考察从 $i-1$ 行到 $i$ 行,$\textit{heights}$ 会如何变化:

  • 如果 $\textit{matrix}[i][j] = 0$,那么 $\textit{heights}[j] = 0$。在排序后,$0$ 会排在大于 $0$ 的高度前面。
  • 如果 $\textit{matrix}[i][j] = 1$,那么 $\textit{heights}[j]$ 增加一。对于那些增加一的高度,相对大小是不变的,无需再次排序。比如把 $1,2,3$ 都增加一,得到 $2,3,4$,这三个数的相对大小不变。

举个例子。假设 $i-1$ 行的 $\textit{heights}$ 排序后是 $[0,{\color{red}0},{\color{red}0},1,{\color{red}2},{\color{red}3}]$,把红色数字加一,其余数字变成 $0$,得到 $[0,{\color{red}1},{\color{red}1},0,{\color{red}3},{\color{red}4}]$。把 $0$ 排在红色数字前面,得到 $[0,0,{\color{red}1},{\color{red}1},{\color{red}3},{\color{red}4}]$。注意红色数字的相对大小是不变的,无需再次排序。

一般地,如果已知 $i-1$ 行的 $\textit{heights}$ 排序后的结果,那么对于 $i$ 行,我们只需把高度变成 $0$ 的数据排在前面,其余(增加一的)高度的相对大小不变,无需再次排序。这样就可以把排序的时间从 $\mathcal{O}(n\log n)$ 优化成 $\mathcal{O}(n)$。

但是,如果直接对 $\textit{heights}$ 排序,我们就不知道每个高度对应矩阵的哪一列了。如何解决?创建一个 $0$ 到 $n-1$ 的下标数组(列号数组)$\textit{idx}$,对下标数组排序。

###py

class Solution:
    def largestSubmatrix(self, matrix: List[List[int]]) -> int:
        n = len(matrix[0])
        heights = [0] * n
        idx = list(range(n))  # 按照高度排序后的列号
        ans = 0

        for row in matrix:
            zeros = []
            non_zeros = []
            for j in idx:
                if row[j] == 0:
                    heights[j] = 0
                    zeros.append(j)
                else:
                    heights[j] += 1
                    non_zeros.append(j)
            idx = zeros + non_zeros  # 把高度为 0 的列号排在其他高度前面

            # heights[idx[i]] 是递增的
            for i in range(len(zeros), n):  # 高度 0 无需计算
                ans = max(ans, (n - i) * heights[idx[i]])

        return ans

###java

class Solution {
    public int largestSubmatrix(int[][] matrix) {
        int n = matrix[0].length;
        int[] heights = new int[n];
        int[] idx = new int[n]; // 按照高度排序后的列号
        for (int i = 0; i < n; i++) {
            idx[i] = i;
        }
        int[] nonZeros = new int[n]; // 避免在循环内反复申请内存
        int ans = 0;

        for (int[] row : matrix) {
            int p = 0;
            int q = 0;
            for (int j : idx) {
                if (row[j] == 0) {
                    heights[j] = 0;
                    idx[p++] = j; // 高度 0 排在前面
                } else {
                    heights[j]++;
                    nonZeros[q++] = j;
                }
            }

            // heights[idx[i]] 是递增的
            for (int i = p; i < n; i++) { // 高度 0 无需计算
                idx[i] = nonZeros[i - p]; // 把 nonZeros 复制到 idx 的 [p,n-1] 中
                ans = Math.max(ans, (n - i) * heights[idx[i]]);
            }
        }

        return ans;
    }
}

###cpp

class Solution {
public:
    int largestSubmatrix(vector<vector<int>>& matrix) {
        int n = matrix[0].size();
        vector<int> heights(n);
        vector<int> idx(n); // 按照高度排序后的列号
        ranges::iota(idx, 0); // idx[i] = i
        vector<int> non_zeros(n); // 避免在循环内反复申请内存
        int ans = 0;

        for (auto& row : matrix) {
            int p = 0, q = 0;
            for (int j : idx) {
                if (row[j] == 0) {
                    heights[j] = 0;
                    idx[p++] = j; // 高度 0 排在前面
                } else {
                    heights[j]++;
                    non_zeros[q++] = j;
                }
            }

            // heights[idx[i]] 是递增的
            for (int i = p; i < n; i++) { // 高度 0 无需计算
                idx[i] = non_zeros[i - p]; // 把 non_zeros 复制到 idx 的 [p,n-1] 中
                ans = max(ans, (n - i) * heights[idx[i]]);
            }
        }

        return ans;
    }
};

###go

func largestSubmatrix(matrix [][]int) (ans int) {
n := len(matrix[0])
heights := make([]int, n)
idx := make([]int, n) // 按照高度排序后的列号
for i := range idx {
idx[i] = i
}
_nonZeros := make([]int, n) // 避免在循环内反复申请内存

for _, row := range matrix {
zeros := idx[:0]
nonZeros := _nonZeros[:0]
for _, j := range idx {
if row[j] == 0 {
heights[j] = 0
zeros = append(zeros, j)
} else {
heights[j]++
nonZeros = append(nonZeros, j)
}
}
idx = append(zeros, nonZeros...) // 把高度为 0 的列号排在其他高度前面

// heights[idx[i]] 是递增的
for i := len(zeros); i < n; i++ { // 高度 0 无需计算
ans = max(ans, (n-i)*heights[idx[i]])
}
}

return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{matrix}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(n)$。

专题训练

见下面贪心题单的「§1.6 先枚举,再贪心」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

【贪心】活学活用,做题不慌

作者 Arsenal-591
2021年1月17日 12:22

提示:如果你没有做出来这道题,建议先去回顾一下 85.最大矩形 的思想。本质上,这类题目都是通过枚举其中一个维度,将问题划归为一维问题来进行求解。

设矩阵为 $H$ 行 $W$ 列。

首先,我们维护一个等大的数组 $\textit{up}$,其中 $\textit{up}[i][j]$ 表示 $\textit{matrix}[i][j]$ 上面有多少个 $1$(包括它自己)。

随后,我们枚举最大矩形的底部位置 $i$。由于列之间可以任意排序,所以可以按照 $\textit{up}[i][0], \textit{up}[i][1], ... , \textit{up}[i][W-1]$ 的大小进行递增排序。

在递增排序过后,设(排序后的)第 $i$ 行第 $1$ 列上面有 $a_1$ 个 $1$。由于已经递增排序,所以第 $i$ 行第 $1$ 列右面的所有位置的上面都至少有 $a_1$ 个 $1$。于是,底边为第 $i$ 行,高度为 $a_1$ 的矩阵的最大宽度为 $W$,对应面积为 $a_1W$。

同理,设第 $i$ 行第 $2$ 列上面有 $a_2$ 个 $1$,则第 $i$ 行第 $1$ 列右面的所有位置的上面都至少有 $a_2$ 个 $1$,因此对应面积 $a_2(W-1)$。以此类推,我们能够得到底边为第 $i$ 行的矩形的最大面积。

随后,再枚举所有的 $i$,就可以得到整体的最大面积。

class Solution {
public:
    int largestSubmatrix(vector<vector<int>>& matrix) {
        int h = matrix.size(), w = matrix[0].size();
        vector<vector<int>> up(h, vector<int>(w, 0));
        
        for (int i = 0; i < h; i++) {
            for (int j = 0; j < w; j++) {
                if (matrix[i][j] == 1) {
                    up[i][j] = (i == 0 ? 0 : up[i-1][j]) + 1;
                }
            }
        }

        int ret = 0;
        for (int i = 0; i < h; i++) {
            vector<int> buf;
            for (int j = 0; j < w; j++) {
                buf.push_back(up[i][j]);
            }
            sort(buf.begin(), buf.end());
            for (int j = 0; j < w; j++) {
                ret = max(ret, buf[j] * (w - j));
            }
        }
        return ret;
    }
};

时间复杂度
共枚举 $H$ 行,每行需要 $O(W\log W)$ 的排序以及 $O(W)$ 的额外扫描,故总体复杂度为 $O(HW\log W)$。

Java 预处理数组,遍历每行排序

预处理数组,计算以这个点为结尾,上面有多少个连续的1,就是这一列以这个点为结尾的最大高度
这样就将二维问题转成一维

遍历每一行,对每一行进行排序,记录矩形的最长的高度,每次更新结果

class Solution {
    public int largestSubmatrix(int[][] matrix) {
        int n=matrix.length;
        int m=matrix[0].length;
        int res=0;
        for(int i=1;i<n;i++){
            for(int j=0;j<m;j++){
                if(matrix[i][j]==1){
                    //记录向上连续1的个数
                    matrix[i][j]+=matrix[i-1][j];
                }
            }
        }
        for(int i=0;i<n;i++){
            Arrays.sort(matrix[i]);
            for(int j=m-1;j>=0;j--){
                //更新矩形的最大高度
                int height=matrix[i][j];
                //更新最大面积
                res=Math.max(res,height*(m-j));
            }
        }
        return res;
    }
}

基于 AST 与 Proxy沙箱 的局部代码热验证

作者 July_lly
2026年3月17日 02:06

前言

在真实开发中系统中,我们常常会做/需要做一些代码运行或者检测工作。但是全量的代码运行消耗的时间是漫长的。那么我们有没有办法能够只处理我们修改的部分呢?答案是肯定的。

下面将验证介绍一种结合 AST (抽象语法树)沙箱技术 的方案,局部代码热验证。

具体重服务mock代码会放在文章末尾

整体 -> 局部

我们切换一个方向:过去我们总是使用整体运行完拿到export的内容。在一些情况下,不论是 build 构建还是 dev 开发,我们通常都是全量编译打包一次。当然我们可以让他执行两次(比如只测某个函数),不过消耗的时间计算成本将会成倍上升,且容易受到文件中其他无关代码的干扰。

我们不再关注“整个文件”,而是关注 “当前选中的函数及其最小依赖集”。 通过 AST 技术,我们将代码像做手术一样“切”出来,只在内存中构建一个微型的运行环境。

code

先看AST分析转化部分

import { Node, Project, SyntaxKind } from 'ts-morph';

let lastCodeHash = '';

function extractMinimalUnitForFunction(sourceText: string, functionName: string): { code: string; changed: boolean } {
    const project = new Project({ useInMemoryFileSystem: true });
    const sourceFile = project.createSourceFile('heavy-service.ts', sourceText);

    const topLevelDeclMap = new Map<string, Node>();

    for (const stmt of sourceFile.getStatements()) {
        if (Node.isFunctionDeclaration(stmt) && stmt.getName()) {
            topLevelDeclMap.set(stmt.getName()!, stmt);
        }
        if (Node.isVariableStatement(stmt)) {
            for (const decl of stmt.getDeclarationList().getDeclarations()) {
                topLevelDeclMap.set(decl.getName(), stmt);
            }
        }
    }

    if (!topLevelDeclMap.has(functionName)) {
        throw new Error(`未找到 ${functionName}`);
    }

    const neededSymbols = new Set<string>([functionName]);
    const queue = [functionName];

    while (queue.length > 0) {
        const symbol = queue.shift()!;
        const declNode = topLevelDeclMap.get(symbol);
        if (!declNode) continue;

        const ids = declNode.getDescendantsOfKind(SyntaxKind.Identifier);
        for (const id of ids) {
            const text = id.getText();
            if (text === symbol) continue;
            if (topLevelDeclMap.has(text) && !neededSymbols.has(text)) {
                neededSymbols.add(text);
                queue.push(text);
            }
        }
    }

    const allReferencedIds = new Set<string>();
    for (const sym of neededSymbols) {
        const node = topLevelDeclMap.get(sym);
        if (!node) continue;
        for (const id of node.getDescendantsOfKind(SyntaxKind.Identifier)) {
            allReferencedIds.add(id.getText());
        }
    }

    const importLines: string[] = [];
    for (const stmt of sourceFile.getStatements()) {
        if (!Node.isImportDeclaration(stmt)) continue;
        const usedNames = stmt.getNamedImports()
            .map((ni) => ni.getName())
            .filter((n) => allReferencedIds.has(n));
        if (usedNames.length > 0) {
            const moduleName = stmt.getModuleSpecifierValue();
            importLines.push(`import { ${usedNames.join(', ')} } from '${moduleName}';`);
        }
    }

    const minimalStatements: Node[] = [];
    for (const stmt of sourceFile.getStatements()) {
        if (Node.isFunctionDeclaration(stmt) && stmt.getName() && neededSymbols.has(stmt.getName()!)) {
            minimalStatements.push(stmt);
            continue;
        }
        if (Node.isVariableStatement(stmt)) {
            const names = stmt.getDeclarationList().getDeclarations().map((d) => d.getName());
            if (names.some((n) => neededSymbols.has(n))) {
                minimalStatements.push(stmt);
            }
        }
    }

    const declLines = minimalStatements.map((s) => s.getText());
    const minimalCode = [...importLines, '', ...declLines].join('\n');

    console.log('--- AST 提取的最小单元 ---\n', minimalCode, '\n--- 结束 ---\n');

    const currentHash = hashCode(minimalCode);
    const changed = currentHash !== lastCodeHash;
    lastCodeHash = currentHash;

    return { code: minimalCode, changed };
}

大致描述一下: 首先第一次执行扫描一遍文件,把所有的顶层函数名、变量名作为 Key,对应的 AST 节点作为 Value 存起来。这相当于给整个文件画了一张索引表。通过队列来做递归依赖查找,直到把所有嵌套调用的依赖全部找齐。

找齐了依赖还没完,它还要处理 import,进行treeShaking,最后计算生成的 minimalCode 的哈希值,如果我们改了文件中不相关的部分(比如改了另一个函数),这个最小单元的 Hash 就不会变。只有修改的代码真正影响到了目标函数时,changed 才会是 true

这里面其实牵扯出一个概念:节点回溯

节点回溯

在编译器和代码分析领域,节点回溯(Node Traversal / Upward Walking) 就像是给 AST装上了“导航回程”系统。

如果说传统的 AST 遍历是“从树根向下寻找叶子”,那么节点回溯就是 “从叶子向上寻找祖先”

例如: 我们修改了一个数字 10

  1. 定位: 你的编辑器告诉你,位置在第 500 行,对应 AST 里的 NumericLiteral
  2. 回溯第一步: 它的 parent 是一个 BinaryExpression (例如 x + 10)。
  3. 回溯第二步: 再往上,是一个 VariableDeclarator (例如 const total = x + 10)。
  4. 回溯第三步: 再往上,是一个 BlockStatement(函数体的大括号)。
  5. 回溯终点: 最终碰到 FunctionDeclaration

此时回溯停止。成功锁定:这次修改的影响范围就在函数FunctionDeclaration内。

相关import引用处理

这时候其实我们会发现代码中存在import { round2 } from './tax-utils'这种导入工具的方法,treeShaking也会认为他是真实存在的。而在真实开发中,这个导入可能是非常多的。可能相关的引用缠绕的太深不会比重新构建引用试图,编译一次耗时差多少。

我们可以考虑一下我们这个引用是否是全部真实需要的呢?如果需要我们可以保留编译进我们的文件内,不需要我们是否可以不要这些依赖。

proxy沙箱代理

当我们拿到了相关代码时,不做任何操作进行运行或者是打包其实本身自带的依赖的bundle还是会有很深引用层级,这时候我们可以使用proxy对我们要代理的对象路径进行更改,指定他们或者直接取消引用都是可以,但是为了代码的健壮性与稳定性,我们通常通过proxy进行代理访问。

 // 定义你的调控配置
    const config = {
        // 强制 Mock 的路径模式
        mockPatterns: ['./tax-utils'],
        // 即使被引用也不提取源码,直接用 Proxy 占位
    };
    const proxyInjections: string[] = [];
    const finalImportLines: string[] = [];

    // 预设一个万能 Proxy 定义
    const MAGIC_PROXY_DEF = `const __MAGIC_PROXY__ = new Proxy(() => __MAGIC_PROXY__, {
        get: (target, prop) => {
            // 关键:拦截系统转换请求
            if (prop === Symbol.toPrimitive) return (hint) => (hint === 'number' ? 0 : '转成string了');
            if (prop === 'toString' || prop === 'valueOf') return () => '走到toString了 ';
            if (typeof prop === 'symbol') return '无路可走了只能undefined';

            return __MAGIC_PROXY__;
        },
        apply: () => __MAGIC_PROXY__
    });`;

    // 按每句代码读取
    for (const stmt of sourceFile.getStatements()) {
        if (!Node.isImportDeclaration(stmt)) continue;

        const modulePath = stmt.getModuleSpecifierValue();
        const isMock = config.mockPatterns.some(p => modulePath.includes(p));

        if (isMock) {
            // 如果在 Mock 名单里,将 import 里的变量名全部指向 Proxy
            const namedImports = stmt.getNamedImports().map(ni => ni.getName());
            namedImports.forEach(name => {
                proxyInjections.push(`const ${name} = __MAGIC_PROXY__;`);
            });
        } else {
            // 否则,正常保留(或者递归提取源码)
            finalImportLines.push(stmt.getText());
        }
    }

    const declLines = minimalStatements.map((s) => s.getText());
    const minimalCode = [
        MAGIC_PROXY_DEF,       // 1. 注入 Proxy 引擎
        ...proxyInjections,    // 2. 注入被拦截的变量声明 (const round2 = ...)
        '',
        ...finalImportLines,   // 3. 注入真实的 Import (非 Mock 的路径)
        '',
        ...declLines           // 4. 注入目标函数及其内部依赖
    ].join('\n');

我采取了 “逻辑截断与指令重定向” 的策略。通过配置化的 依赖调控(Dependency Control) ,系统会对深层或重型的外部依赖进行“漂白”or “替换”:

  • 拦截深层引用:当 AST 扫描到预设的拦截路径(如 ./tax-utils)时,系统会切断递归,不再打包其源码。
  • 注入递归代理(Recursive Proxy) :在生成的代码头部注入一个的万能代理对象 __MAGIC_PROXY__

原理: 无论目标函数如何调用这些被拦截的依赖(如 service.user.get().name),Proxy 都会通过拦截 getapply 陷阱,返回自身以确保链路不崩溃,从而实现逻辑执行的“硬件加速”。

image.png

最终,系统产出一段包含 [代理定义 + 拦截声明 + 真实 Import + 目标函数] 的纯粹代码段。这段代码被注入内存沙箱(如 vm 模块)进行“影子执行”。 这种姿势不仅甩掉了沉重的依赖包袱,更避开了昂贵的重排(Layout)与全量编译过程。

结尾

我们对“局部热验证”方案的探索,本质上是对现代前端工程两大核心思想的深度集成:

  • AST 节点回溯(Node Traversal):语义化的精准 这不仅是 SlideJS 等解析引擎实现精准定位的基础,更是所有现代编译器(Babel, SWC, esbuild)的灵魂。它让我们脱离了低效的正则匹配,进入了“语义化操控”的时代。在本项目中,回溯机制确保了我们能以毫秒级速度,从海量源码中锁定受影响的“逻辑最小单元”。
  • Proxy 沙箱代理:从“物理依赖”到“协议仿真” Proxy 劫持微前端(隔离沙箱)Vue 3(响应式系统) 以及 Vite(依赖预构建拦截) 等基建工具的共同基石。在我们的方案中,它不仅用于隔离,更用于“欺骗”——通过伪造深层依赖的虚幻环境,让局部逻辑在脱离母体后依然能保持强健执行。

这里面之时还是比较干的,可以仔细运行读取一下练习。

// 重执行函数
import { normalizeIncome, round2 } from './tax-utils';
import { test } from './test-utils';
const serviceName = 'heavy-tax-service';

// 模拟重负载初始化(busy wait)
function sleepMs(ms: number): void {
  const start = Date.now();
  while (Date.now() - start < ms) {
    // busy wait:模拟数据库连接、缓存预热等耗时操作
  }
}

const taxRate = 0.13;
const extraFee = 12;

/**
 * 目标函数:我们真正想热验证的逻辑。
 * 依赖:taxRate、extraFee(本文件声明) + normalizeIncome、round2(来自 ./tax-utils)
 */
export function calculateTax(income: number): number {
  const normalized = normalizeIncome(income);
  const baseTax = normalized * taxRate + extraFee;
  return round2(baseTax);
}

/**
 * 对比函数:用于演示 AST diff 增量执行
 * 当修改这个函数时,AST 分析会只执行这个函数及其依赖,跳过 sleepMs 等无关代码
 */
export function calculateDiscount(price: number): any {
  const discountRate = 0.2;
  const finalPrice = price * (1 - discountRate);
  return {
    value: round2(finalPrice),
    test_value: test, // 来自 test-utils 的依赖,演示 AST 依赖提取
  };
}

console.log('[heavy-service] bootstrapping huge runtime...');

// 关键耗时点:全量执行时会在这里阻塞约 2 秒
sleepMs(2000);
calculateTax(1000);



const runtimeConfig = {
  region: process.env.REGION || 'cn',
  featureFlag: true,
};

console.log('[heavy-service] side effects done', runtimeConfig, serviceName);

thanks

告别登录中断:前端双 Token无感刷新

2026年3月17日 00:23

前言

在前后端分离的项目中,为了安全,Token 通常会设置有效期。但如果 Token 过期时强制用户重新登录,会极大地破坏用户体验。如何做到在用户毫无察觉的情况下,自动完成 Token 的续期?本文将深度拆解 “双 Token 无感刷新” 的实现机制。

一、 为什么需要“无感刷新”?

举个简单例子,你正在某 App 编辑内容,中途切出几分钟,再切回来时,直接弹出登录页,提示“登录已过期,请重新登录”,这种场景很容易让用户流失。

传统的单 Token 方案存在一个两难境地:

  • 有效期过短:用户操作频繁,动不动就跳回登录页,用户体验极差。
  • 有效期过长:Token 一旦被截获,风险极高。

解决方案:双 Token 机制

  1. access_token:访问令牌。有效期短(如 1 小时),每次接口请求都携带,降低泄露风险。
  2. refresh_token:刷新令牌。有效期长(如 7 天),仅用于 access_token 过期时换取新令牌。

只要用户在 7 天内活跃过,系统就能通过 refresh_token 自动“续命”,实现长效无感登录。


二、 核心流程设计

  1. 正常请求:前端携带 access_token 访问。

  2. 触发过期:后端返回 401 Unauthorized

  3. 判断逻辑

    • 如果是普通接口报 401:说明 access_token 失效,尝试刷新。
    • 如果是刷新接口报 401:说明 refresh_token 也失效了,强制重新登录。
  4. 无感替换:前端自动调用刷新接口,获取新 Token 覆盖本地存储,并重新发起之前失败的请求。


三、 细节攻坚:如何处理并发请求?

痛点:如果页面同时发出了 5 个请求,而此时 Token 刚好过期,会导致这 5 个请求同时触发“刷新 Token”的操作,造成资源浪费甚至后端异常。

解决策略

  • 状态锁 (refreshing) :记录当前是否正在刷新中。
  • 任务队列 (queue) :在刷新期间到达的请求,先暂存起来,不直接报错。
  • 批量回放:等待 Token 刷新成功后,依次执行队列里的请求,实现“无感”衔接。

四、 代码实现 (Axios 拦截器)

以下是基于 Axios 的完整工程化实现:

import axios, { AxiosRequestConfig } from 'axios';

interface PendingTask {
    config: AxiosRequestConfig;
    resolve: Function;
}

let refreshing = false; // 状态锁:标志是否正在刷新 Token
let queue: PendingTask[] = []; // 请求队列:暂存 Token 刷新期间的请求

const axiosInstance = axios.create({
    baseURL: '/api'
});

// 1. 请求拦截器:自动注入 Token
axiosInstance.interceptors.request.use((config) => {
    const accessToken = localStorage.getItem('access_token');
    if (accessToken && config.headers) {
        config.headers.authorization = `Bearer ${accessToken}`;
    }
    return config;
});

// 2. 响应拦截器:处理 Token 过期
axiosInstance.interceptors.response.use(
    (response) => response,
    async (error) => {
        const { data, config } = error.response;

        // 情况 A:正在刷新 Token 中,将后续请求存入队列
        if (refreshing) {
            return new Promise((resolve) => {
                queue.push({ config, resolve });
            });
        }

        // 情况 B:access_token 过期 (状态码 401 且非刷新接口本身)
        if (data.statusCode === 401 && !config.url.includes('/refresh')) {
            refreshing = true;
            
            try {
                const res = await refreshToken();
                refreshing = false;

                if (res.status === 200) {
                    // 核心逻辑:Token 刷新成功,回放队列中的所有请求
                    queue.forEach(({ config, resolve }) => {
                        resolve(axiosInstance(config));
                    });
                    queue = []; // 清空队列
                    
                    // 执行当前触发刷新的那个请求
                    return axiosInstance(config);
                }
            } catch (err) {
                refreshing = false;
                queue = [];
                // 情况 C:refresh_token 也过期了,彻底清除登录态
                localStorage.clear();
                window.location.href = '/login';
                return Promise.reject(err);
            }
        }

        return Promise.reject(error);
    }
);

/**
 * 刷新 Token 的异步方法
 */
async function refreshToken() {
    const res = await axios.get('/api/refresh', {
        params: {
            token: localStorage.getItem('refresh_token')
        }
    });
    // 更新本地存储
    localStorage.setItem('access_token', res.data.accessToken);
    localStorage.setItem('refresh_token', res.data.refreshToken);
    return res;
}

五、 注意事项

  1. 并发请求的 Promise 挂起:在 refreshingtrue 时,返回一个不带 resolvenew Promise 是关键,它能让 Axios 请求处于 pending 状态。
  2. 错误捕获refreshToken 接口本身报错(如 500 或 401)必须妥善处理,直接引导至登录页。
  3. 安全性:普通项目中可以使用 localStorage,但在更高要求的项目中,建议配合 HttpOnly Cookie 存储 refresh_token 以防 XSS 攻击。
  4. 接口重定向陷阱:确保刷新 Token 的接口不会再次进入 401 拦截死循环。

SSO单点登录:从同域到跨域实战

2026年3月17日 00:13

前言

在企业级应用集群中,如果用户每打开一个内部系统都要重新输入一次密码,体验将是灾难性的。SSO(Single Sign-On) 的出现解决了这一痛点:它允许用户“一处登录,处处通行”。本文将深度拆解 SSO 的两种核心实现逻辑。

SSO 的核心概念

SSO(单点登录) 是指在多个应用系统中,用户只需要登录一次,就可以访问所有相互信任的应用系统。

典型场景: 登录了“支付宝”网页版后,直接打开“淘宝”、“天猫”或“阿里云”,你会发现自己已经处于登录状态。


二、 方案一:同域名下的 SSO(父域 Cookie 共享)

这是最简单的实现方式,利用了浏览器 Cookie 可以跨子域共享 的特性。

1. 实现原理

如果所有系统的域名都属于同一个顶级域名(如 a.company.comb.company.com),我们可以将 Cookie 的 Domain 设置为父级域名 .company.com

2. 执行流程

  1. 重定向: 用户访问业务系统 A,A 发现未登录,跳转至 sso.company.com
  2. 认证: 用户在 SSO 页面完成登录。
  3. 种下全局 Cookie: SSO 验证成功,在响应头设置 Set-Cookie: sessionid=xxx; Domain=.company.com; Path=/,这样所有子域名系统都会自动带上这个 Cookie
  4. 自动带入: 当用户跳转回系统 A 或访问系统 B 时,浏览器会自动带上这个 .company.com 域下的 Cookie。
  5. 校验: 业务系统后端获取 Cookie 并请求 SSO 服务验证有效性,完成登录。

注意: 该方案仅适用于公司内部子系统,局限性在于必须处于同一父域下。


三、 方案二:跨域名下的 SSO(Token+code模式)

当系统域名完全不同(如 taobao.comalipay.com)时,Cookie 无法跨域共享。此时需要一个独立的 统一认证中心(CAS/SSO)

核心流程:Token + Code 交换模式

1. 首次登录(以系统 A 为例)

  1. 路由拦截: 业务系统 A 的路由守卫发现本地无 token
  2. 跳转认证: A 引导用户跳转至 SSO 登录页,并携带回跳地址:https://sso.com/login?client_id=A&redirect_uri=https://a.com/callback
  3. SSO 认证: 用户在 SSO 完成登录,SSO 在自己的域名(sso.com)下种下 全局登录态 Cookie
  4. 下发 Code: SSO 生成一个临时授权码 code,通过 URL 重定向带回给系统 A:https://a.com/callback?code=xxxxxx
  5. 换取 Token: 系统 A 前端获取 code,再次向 SSO 服务发起请求。SSO 校验 Cookie + code 有效后,返回正式的 token
  6. 本地存储: 系统 A 获取 token 后存储在 localStorage 或本地 Cookie 中,登录成功。

2. 二次登录(访问系统 B)

  1. 无感跳转: 用户打开系统 B,B 发现未登录,跳转至 SSO 系统。
  2. Cookie 自动识别: 此时浏览器会自动带上 sso.com 域下的全局 Cookie。
  3. 直接授权: SSO 发现用户已登录,直接生成一个新的 临时 code 并重定向回系统 B。
  4. B 换取 Token: 系统 B 使用新 code 换取属于 B 的 token,实现单点登录。

四、 注意事项

1. 为什么不直接返回 Token,而是用 Code 换取?

安全性。 如果直接在 URL 中返回 Token,Token 会暴露在浏览器历史记录中,容易被窃取。使用 临时 code(通常有效期仅 1-5 分钟且只能使用一次)配合后端校验,安全性更高。

2. 重定向时的“瞬间空白”如何处理?

跨域 SSO 在进行域名跳转时,由于需要经过 SSO 系统的中转判断,不可避免会有短暂的白屏或闪烁。

  • UI 优化: 在重定向过程中展示一个统一的 Loading 动画。
  • 静默校验: 如果技术条件允许,可以通过 iframe 尝试静默检查 SSO 登录态,减少全屏跳转。

3. 安全增强

  • State 参数: 在跳转时增加一个随机字符串 state,并在回调时比对,防止 CSRF(跨站请求伪造) 攻击。
  • HTTPS: SSO 全流程必须在 HTTPS 协议下进行,防止敏感信息被中间人劫持。

五、 总结

跨域 SSO 的核心思想是:将“身份验证”权力收拢到统一认证中心,利用 SSO 域下的 Cookie 维持全局登录态,通过“授权码交换”实现跨域权限传递。

昨天 — 2026年3月16日技术

Claude Code Skill 从入门到自定义完整教程(Windows 版)

作者 烛阴
2026年3月16日 22:35

Claude Code Skill(技能)是 Claude CLI 的插件系统,允许你通过简单的斜杠命令(/skill-name)调用预先定义好的复杂工作流。你可以把 Skill 理解为"给 Claude 装外挂"——一个 Skill 文件,即可让 Claude 具备生成小红书图片、转换 Markdown、发布公众号等专业能力。

01-cover-claude-skill-tutorial.png


一、什么是 Claude Skill

核心概念

Skill 本质上是一个包含 SKILL.md 文件的目录,它告诉 Claude:

  • 这个 Skill 能做什么(功能描述)
  • 何时自动触发(触发关键词)
  • 如何操作(分步工作流)
  • 可执行哪些脚本(可选的 scripts/ 目录)

当你在对话中输入 /skill-name 或说出触发关键词时,Claude 会自动加载对应的 Skill 并按照其定义的流程工作。

Skill 与普通对话的区别

对比项 普通对话 使用 Skill
指令复杂度 需要详细描述每一步 一个命令自动执行完整流程
可重复性 每次结果不一致 标准化流程,结果稳定
专业能力 通用能力 领域专精(图像生成、发布等)
配置 无法记住偏好 支持 EXTEND.md 保存个人配置

两种 Skill 类型

纯提示词型:仅包含 SKILL.md,通过详细的自然语言指令引导 Claude 完成任务,无需安装任何依赖。

脚本增强型:包含 SKILL.md + scripts/ 目录,通过 Bun/Node.js 脚本调用 API、处理文件等,能力更强大。


二、安装与目录结构

Skill 存放位置

Skills 存放在固定目录,Claude CLI 启动时会自动扫描:

位置 路径 说明
项目级 .claude/skills/<skill-name>/SKILL.md 仅对当前项目生效
用户级 Users/用户名/.claude/skills/<skill-name>/SKILL.md

在 Windows 下,项目级 Skill 路径示例:

C:\Users\你的用户名\projects\myproject\
└── .claude\
    └── skills\
        ├── baoyu-image-gen\
        │   └── SKILL.md
        ├── baoyu-markdown-to-html\
        │   ├── SKILL.md
        │   └── scripts\
        │       └── main.ts
        └── my-custom-skill\
            └── SKILL.md

安装社区 Skill

社区提供了大量开箱即用的 Skill,以 baoyu 系列为例:

方式一:

# 进入你的项目目录
cd C:\Users\你的用户名\projects\myproject

# 创建 skills 目录
New-Item -ItemType Directory -Force -Path ".claude\skills"

# 下载 baoyu-image-gen Skill(示例)
# 将 SKILL.md 等文件复制到对应目录即可

方式二:

直接告诉 Claude Code:

请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills

方式三:

  1. 注册插件市场

在 Claude Code 中运行:

/plugin marketplace add JimLiu/baoyu-skills

2. 直接安装

2. 安装指定插件
/plugin install content-skills@baoyu-skills

安装 Skill 后无需重启,Claude CLI 会在下次对话时自动识别。


三、使用 Skill

方式一:斜杠命令

在 Claude CLI 对话中,直接输入斜杠加 Skill 名称:

/baoyu-image-gen 一只可爱的猫咪坐在窗边

/baoyu-markdown-to-html article.md --theme grace

/baoyu-xhs-images mcp-tutorial.md

方式二:自然语言触发

每个 Skill 都定义了触发关键词,说出对应词语时 Claude 会自动加载 Skill:

帮我生成一张封面图片

把这篇 Markdown 转成 HTML

为这篇文章生成小红书图片系列

方式三:带参数调用

许多 Skill 支持参数选项:

/baoyu-image-gen --provider openai --ar 16:9 一片金色的麦田

/baoyu-markdown-to-html article.md --theme modern --color red

/baoyu-xhs-images mcp-tutorial.md --style notion --layout dense

查看可用 Skill

在 Claude CLI 中查看当前项目已安装的所有 Skill:

/skills

四、Skill 偏好配置(EXTEND.md)

什么是 EXTEND.md

EXTEND.md 是 Skill 的个人配置文件,用于保存你的使用偏好,避免每次都重复选择。

存放路径(优先级从高到低):

项目级:.baoyu-skills/<skill-name>/EXTEND.md
用户级:C:\Users\你的用户名\.baoyu-skills\<skill-name>\EXTEND.md

配置示例

baoyu-image-gen 的 EXTEND.md:

# baoyu-image-gen Preferences

default_provider: openai
default_quality: 2k
default_model:
  openai: gpt-image-1

首次运行自动引导

大多数 Skill 在首次使用时会自动弹出配置引导,回答几个问题后自动生成 EXTEND.md:

> /baoyu-image-gen 一只猫咪

[首次使用检测]
请选择默认图片生成服务商:
① OpenAI (gpt-image-1)
② Google (gemini-3-pro-image)
③ DashScope (通义万象)

你的选择:

五、开发自定义 Skill

最简 Skill 结构

只需一个 SKILL.md 文件即可创建 Skill:

.claude/skills/my-translator/
└── SKILL.md

SKILL.md 编写规范

SKILL.md 使用 YAML frontmatter + Markdown 正文格式:

---
name: my-translator
description: Translates text between Chinese and English. Use when user asks to "translate", "翻译", or provides text to convert between languages.
---

# 智能翻译助手

将用户提供的文字在中英文之间互译,保持原文风格和语气。

## 工作流程

### Step 1:识别语言

检测输入文字的语言:
- 主要为中文 → 翻译成英文
- 主要为英文 → 翻译成中文
- 混合 → 询问用户目标语言

### Step 2:翻译

翻译时注意:
- 保持原文的语气(正式/口语)
- 专业术语保持准确
- 地道表达,避免直译

### Step 3:输出

输出格式:
- 原文(引用块显示)
- 译文(直接显示)
- 如有歧义,附加说明

## 使用示例

/my-translator Hello, how are you? /my-translator 今天天气真好 翻译:这段英文合同条款

带脚本的 Skill

如需调用外部 API 或处理文件,可以添加 scripts/main.ts

.claude/skills/my-weather/
├── SKILL.md
└── scripts/
    └── main.ts

SKILL.md 中通过 Bash 调用脚本:

## 执行查询

```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts --city "北京"

**scripts/main.ts** 示例:

```typescript
import { parseArgs } from "util";

const { values } = parseArgs({
  args: process.argv.slice(2),
  options: { city: { type: "string" } },
});

// 调用天气 API
const res = await fetch(`https://api.weather.com/?city=${values.city}`);
const data = await res.json();

console.log(JSON.stringify({
  city: values.city,
  temp: data.temperature,
  condition: data.condition,
}));

EXTEND.md 配置支持

在 SKILL.md 中说明支持哪些配置项,让用户可以通过 EXTEND.md 自定义行为:

## 用户配置(EXTEND.md)

支持以下配置项:

# yaml
# 默认目标语言(zh / en)
default_target: zh

# 是否显示原文
show_original: true

# 翻译风格(formal / casual / literary)
style: casual

配置文件路径:$HOME/.skills/my-translator/EXTEND.md


六、总结

Claude Skill 将复杂的 AI 工作流封装成一个斜杠命令,大幅降低了重复性操作的门槛。通过本教程,你已经掌握了:

  • Skill 的核心概念与两种类型
  • 安装和目录结构规范
  • 三种使用方式:斜杠命令、自然语言、带参数调用
  • EXTEND.md 个人偏好配置
  • 从零开发自定义 Skill 的完整流程

推荐资源:

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

作者 SmalBox
2026年3月16日 19:17

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

在Unity URP Shader Graph中,Sample Cubemap节点是一个功能强大的工具,专门用于对立方体贴图资源进行采样操作。立方体贴图是一种特殊类型的纹理,由六个二维纹理面组成,形成一个完整的立方体环境映射。这种纹理格式在实时渲染中广泛应用于天空盒、环境反射、折射效果以及基于图像的光照计算。

Sample Cubemap节点的核心功能是根据给定的三维方向向量,从立方体贴图中获取对应的颜色信息。与普通的2D纹理采样不同,立方体贴图采样不需要UV坐标,而是使用方向向量来确定采样位置。这使得它特别适合表示全方位环境数据,如周围环境的全景视图。

在URP渲染管线中,Sample Cubemap节点优化了现代图形API的使用,包括对移动平台和高端PC平台的良好支持。它能够无缝集成到Shader Graph的可视化编程环境中,让开发者无需编写复杂的HLSL代码即可实现高级的立方体贴图效果。

该节点支持多种高级功能,包括多级渐远纹理采样、自定义采样器状态配置,以及与Shader Graph其他节点的灵活连接。这些特性使得Sample Cubemap节点成为创建高质量环境反射、天空盒渲染和复杂光照效果的关键工具。

描述

Sample Cubemap节点的主要作用是从立方体贴图资源中提取颜色信息,并将其转换为可在着色器中使用的Vector 4格式数据。立方体贴图本质上是一个由六个2D纹理组成的立方体结构,每个面对应立方体的正负X、Y、Z轴方向。这种结构使其能够完整地表示一个三维空间中的环境信息。

采样原理

立方体贴图的采样过程基于方向向量而非传统的UV坐标。当提供一个三维方向向量时,采样器会确定该向量与立方体哪个面的交点,并在对应的2D纹理上进行采样。这一过程完全在GPU层面实现,具有极高的效率。

在世界空间中进行采样时,方向向量通常需要归一化处理,以确保采样结果的准确性。方向向量的长度不会影响采样位置,只有方向本身决定了采样点。这意味着无论向量的模长是多少,只要方向相同,就会采样到立方体贴图上的同一位置。

LOD功能

Sample Cubemap节点支持细节级别参数,通常称为LOD。LOD技术允许在不同的观察距离或条件下使用不同精度的纹理版本。当LOD值为0时,采样器使用最高质量的原始纹理;随着LOD值的增加,采样器会使用预先生成的、分辨率更低的mipmap级别。

LOD功能在实现多种视觉效果时非常有用:

  • 通过使用较高的LOD值,可以实现立方体贴图的模糊效果,常用于创建柔和的反射或远处的环境细节
  • 在性能优化方面,可以根据物体与相机的距离动态调整LOD值,减少远处物体的纹理采样开销
  • 在特定的艺术效果中,如梦境场景或水下效果,可以通过LOD控制环境反射的清晰度

自定义采样器

Sampler输入允许开发者定义自定义的采样器状态,包括纹理过滤模式和寻址模式等参数。默认情况下,节点使用立方体贴图资源自带的采样器设置,但通过此端口可以覆盖这些设置。

自定义采样器的应用场景包括:

  • 更改纹理过滤模式,如在像素艺术风格中使用点过滤,或在高质量渲染中使用各向异性过滤
  • 修改寻址模式,控制当采样方向超出正常范围时的行为
  • 实现特殊的纹理采样效果,如边缘钳制或镜像重复

版本兼容性

如果在包含自定义函数节点或子图形的图形中使用Sample Cubemap节点时遇到纹理采样错误,这可能是由于早期Shader Graph版本的已知问题。Unity建议通过升级到10.3或更高版本来解决这些问题。新版本提供了更稳定的纹理采样机制和更好的节点兼容性。

升级到较新版本不仅可以解决采样错误,还能获得性能改进和新功能支持,如增强的立方体贴图压缩格式支持和改进的mipmap生成算法。

端口

Sample Cubemap节点包含多个输入和输出端口,每个端口都有特定的功能和数据类型。理解这些端口的作用对于正确使用该节点至关重要。

输入端口

  • Cube端口

    Cube端口是Sample Cubemap节点的主要输入,用于接收立方体贴图资源。此端口的数据类型是Cubemap,只能连接立方体贴图类型的资源。在Shader Graph中,可以通过Expose功能将立方体贴图作为材质的可配置属性,或在子图中定义特定的立方体贴图资源。

    使用Cube端口时需要注意:

    • 确保连接的资源确实是立方体贴图类型,普通的2D纹理无法正常工作
    • 立方体贴图的分辨率会影响采样质量和性能,需要根据目标平台和用途选择适当的分辨率
    • 立方体贴图的压缩格式会影响内存使用和视觉效果,在移动平台上尤其需要注意
  • Dir端口

    Dir端口接收Vector 3类型的方向向量,用于确定立方体贴图上的采样位置。此端口的世界空间绑定为法线空间,意味着输入的方向向量通常在世界空间坐标系中表示。

    Dir端口的使用要点:

    • 方向向量通常需要归一化处理,但非归一化的向量也能工作,采样器会自动处理
    • 向量的方向决定了采样点,与向量长度无关
    • 在实际应用中,方向向量可以来自表面法线、反射向量、视图方向或其他计算得到的方向数据
  • Sampler端口

    Sampler端口允许指定自定义的采样器状态,覆盖立方体贴图的默认采样设置。此端口是可选的,如果未连接,节点将使用立方体贴图资源自带的采样器状态。

    自定义采样器的常见配置:

    • 过滤模式:控制纹理缩放时的插值方式,如点过滤、双线性过滤、三线性过滤等
    • 寻址模式:定义当采样坐标超出[0,1]范围时的行为,如重复、钳制、镜像等
    • 各向异性级别:控制各向异性过滤的质量,适用于大角度观察纹理表面的情况
  • LOD端口

    LOD端口接收Float类型的值,用于指定采样的细节级别。当LOD值为0时,使用最高分辨率的mipmap级别;随着LOD值增加,使用更低分辨率的mipmap级别。

    LOD端口的应用场景:

    • 值为0:使用最清晰的纹理,适合近处物体的高质量反射
    • 值为1-3:使用中等模糊级别的纹理,适合中等距离的反射效果
    • 值大于3:使用高度模糊的纹理,适合远处环境或特殊视觉效果

输出端口

  • Out端口

    Out端口输出采样得到的Vector 4颜色值。这四个分量通常对应RGBA颜色通道,但具体含义取决于立方体贴图的内容和用途。

    输出颜色的应用方式:

    • 直接作为表面颜色,用于天空盒渲染
    • 与环境光、漫反射和镜面反射结合,实现基于图像的光照
    • 作为反射/折射效果的颜色来源
    • 与其他纹理或颜色数据混合,创建复杂的材质效果

生成的代码示例

Sample Cubemap节点在编译时生成的HLSL代码基于Unity的纹理采样函数。理解这些底层代码有助于更深入地掌握节点的功能,并在需要时进行自定义扩展。

基本代码结构

以下示例代码表示此节点的一种可能结果:

float4 _SampleCubemap_Out = SAMPLE_TEXTURECUBE_LOD(Cubemap, Sampler, Dir, LOD);

这行代码展示了Sample Cubemap节点的核心功能:使用SAMPLE_TEXTURECUBE_LOD宏对立方体贴图进行采样。该宏是Unity对HLSL原生texCUBElod函数的封装,提供了跨平台兼容性和优化。

代码分解

  • SAMPLE_TEXTURECUBE_LOD:这是Unity提供的宏,用于在着色器中采样立方体贴图的特定mipmap级别。它会根据目标平台和图形API转换为适当的采样指令。
  • Cubemap:参数对应Cube输入端连接的立方体贴图资源。在编译后的代码中,这通常是一个纹理对象,通过Unity的材质属性系统或全局着色器属性进行绑定。
  • Sampler:参数指定使用的采样器状态。如果通过Sampler输入端提供了自定义采样器,则使用该采样器;否则使用立方体贴图默认的采样器状态。
  • Dir:参数是从Dir输入端接收的方向向量。这个向量决定了在立方体贴图上的采样位置,采样器会找到与该方向向量对应的立方体面和纹理坐标。
  • LOD:参数控制采样的mipmap级别。当LOD为0时,使用最高分辨率的mipmap级别;随着LOD值增加,使用更低分辨率的mipmap级别,产生更模糊的结果。

变体与平台差异

根据目标平台和设置,Sample Cubemap节点可能会生成不同的代码变体:

  • 在支持现代图形API的平台(如DX11、Vulkan、Metal)上,通常会使用更高效的采样指令
  • 在移动平台上,可能会使用简化版的采样函数以减少着色器复杂度
  • 当LOD输入端未连接或使用固定值时,编译器可能会优化为不使用LOD的采样函数,如SAMPLE_TEXTURECUBE
  • 在特定的图形特性(如HDR渲染)启用时,采样函数可能会自动处理高动态范围数据

自定义扩展

了解生成的代码结构后,开发者可以在自定义函数节点中扩展Sample Cubemap节点的功能:

// 示例:自定义立方体贴图采样函数
void SampleCubemapCustom_float(TextureCube Cubemap, SamplerState Sampler, float3 Dir, float LOD, out float4 Out)
{
    // 基本采样
    Out = SAMPLE_TEXTURECUBE_LOD(Cubemap, Sampler, Dir, LOD);

    // 自定义后处理:调整颜色
    Out.rgb = pow(Out.rgb, 1.0/2.2); // 简单的gamma校正

    // 自定义后处理:基于方向调整强度
    float intensity = saturate(dot(normalize(Dir), float3(0, 1, 0)));
    Out.rgb *= lerp(0.8, 1.2, intensity);
}

这种自定义扩展允许实现更复杂的立方体贴图效果,如方向相关的颜色调整、动态曝光补偿或特殊滤镜效果。

实际应用示例

Sample Cubemap节点在URP着色器开发中有多种实际应用。以下是一些常见的使用场景和实现方法。

环境反射

环境反射是Sample Cubemap节点最典型的应用之一。通过结合相机的反射向量和立方体贴图,可以实现高质量的实时反射效果。

实现环境反射的基本步骤:

  • 使用Reflection Vector节点计算表面点的反射方向
  • 将此方向向量连接到Sample Cubemap节点的Dir输入
  • 将环境立方体贴图连接到Cube输入
  • 将输出的颜色值与表面材质混合

高级环境反射技巧:

  • 使用Fresnel效应控制反射强度,使掠射角度的反射更强烈
  • 根据表面粗糙度调整LOD值,粗糙表面使用更高LOD产生模糊反射
  • 结合屏幕空间反射,实现更准确的局部反射效果

天空盒渲染

Sample Cubemap节点可用于创建动态天空盒效果,特别是在需要程序化生成或动态修改天空的场景中。

天空盒实现方法:

  • 使用片元的世界位置或视图方向作为采样方向
  • 连接天空盒立方体贴图到Cube输入
  • 可选:使用时间变量动态修改采样方向或LOD,实现云层移动或日夜变化效果

增强天空盒的技巧:

  • 使用多个立方体贴图和混合操作实现复杂的多层天空
  • 通过LOD控制远处天空的细节程度,优化性能
  • 结合体积云或大气散射着色,增强视觉真实感

基于图像的照明

在PBR渲染流程中,Sample Cubemap节点常用于基于图像的照明,提供高质量的环境光照信息。

IBL实现流程:

  • 使用表面法线采样辐照度图,提供漫反射环境光
  • 使用反射向量采样预过滤的环境贴图,提供镜面反射
  • 结合BRDF查找表,完成完整的PBR光照计算

优化技巧:

  • 使用不同分辨率的立方体贴图平衡质量和性能
  • 根据表面材质属性动态选择mipmap级别
  • 在低端设备上简化IBL计算,如使用球谐函数近似

折射效果

Sample Cubemap节点也可以用于实现折射效果,如玻璃、水或其他透明材质的视觉效果。

折射实现方法:

  • 使用折射向量而非反射向量作为采样方向
  • 折射向量可以通过Refract节点计算,考虑表面法线和折射率
  • 将采样结果与表面颜色混合,创建透明或半透明效果

高级折射技巧:

  • 使用多个采样层模拟复杂折射,如毛玻璃或扭曲玻璃效果
  • 结合深度信息调整折射强度,实现基于距离的效果变化
  • 使用自定义的立方体贴图,包含专门为折射优化的环境信息

性能优化建议

在使用Sample Cubemap节点时,合理的性能优化可以确保应用在各种设备上都能流畅运行。

纹理优化

  • 选择合适的立方体贴图分辨率,平衡视觉质量和内存占用
  • 使用适当的纹理压缩格式,如ASTC用于移动设备,BC系列用于PC
  • 确保立方体贴图具有完整的mipmap链,以支持LOD功能
  • 考虑使用立方体贴图阵列,批量处理多个环境贴图

采样优化

  • 仅在必要时使用高LOD值,避免不必要的模糊采样
  • 在片段着色器中谨慎使用立方体贴图采样,考虑在顶点着色器中预计算简单情况
  • 使用纹理流送系统,动态加载和卸载立方体贴图资源
  • 对于静态环境,考虑将立方体贴图烘焙到光照贴图中

平台特定优化

  • 在移动平台上,优先使用较低分辨率的立方体贴图
  • 在支持tier分级的情况下,为不同设备等级配置不同质量的立方体贴图
  • 使用Unity的Quality Settings系统,根据设备性能动态调整立方体贴图质量
  • 考虑在低端设备上完全禁用某些立方体贴图效果,使用更简单的替代方案

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

Unity —— Animator 状态机事件通知(Animator 1-2)

2026年3月16日 18:23

一. 状态机监听

  1. AnimStateHook - 状态机钩子
  • 继承自 Unity 的 StateMachineBehaviour,附加到 Animator Controller 的状态上
  • 拦截 Animator 的三个生命周期回调:OnAnimEnter、OnAnimUpdate、OnAnimExit
  • 从 Animator 所在 GameObject 获取 AnimEventDispatcher 组件并转发事件
public class AnimStateHook : StateMachineBehaviour
{
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, AnimatorControllerPlayable controller)
    {
        AnimEventDispatcher call = animator.gameObject.GetComponent<AnimEventDispatcher>();
        if (call != null)
        {
            call.OnEnterChannel(animator, stateInfo, layerIndex);
        }
    }

    public override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, AnimatorControllerPlayable controller)
    {
        AnimEventDispatcher call = animator.gameObject.GetComponent<AnimEventDispatcher>();
        if (call != null)
        {
            call.OnUpdateChannel(animator, stateInfo, layerIndex);
        }
    }

    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, AnimatorControllerPlayable controller)
    {
        AnimEventDispatcher call = animator.gameObject.GetComponent<AnimEventDispatcher>();
        if (call != null)
        {
            call.OnExitChannel(animator, stateInfo, layerIndex);
        }
    }
}
  1. AnimEventDispatcher - 事件分发器
  • 作为 MonoBehaviour 组件挂载在有 Animator 的 GameObject 上, 提供外部监听方法
  • 接收 AnimStateHook 的回调并广播对应事件
public class AnimEventDispatcher : MonoBehaviour
{
    public Action<int, float> OnEnterEvent;
    public Action<int, float> OnUpdateEvent;
    public Action<int, float> OnExitEvent;


    public void OnEnterChannel(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        OnEnterEvent?.Invoke(stateInfo.shortNameHash, stateInfo.normalizedTime);
    }

    public void OnUpdateChannel(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        OnUpdateEvent?.Invoke(stateInfo.shortNameHash, stateInfo.normalizedTime);
    }

    public void OnExitChannel(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        OnExitEvent?.Invoke(stateInfo.shortNameHash, stateInfo.normalizedTime);
    }
}

  1. 总结: 如上提供了一种监听Animator状态变化的监听方法, 入状态进入(Enter), 状态Update(Update), 状态退出(Exit)注意事项
  • normalizedTime为百分比时间
  • 两个相邻状态A -> B, Unity无法保证A的退出先执行, B的进入后执行, 可能先执行B的进入,再执行A的退出也未可知

Three.js一起学-如何通过官方例子高效学习 Three.js?手把手带你“抄”出一个3D动画

2026年3月16日 18:16

前言

在之前的文章中有说过,我认为学习three.js最好的方法就是通过官网的例子去学习,在实践中练习。 因为Three.js中api很多,没有系统的学习文档,因此我觉得通过例子去学习,使用到某个概念或者api在深入学习它,这是一个不错的学习方式。 那么具体如何学习呢?

45ce1782386deb25cf01ea76ee0705b6.gif

好了言归正传,下面我就教大家如何打出一套如来神掌,啊不对,是通过官方例子学习Three.js。

0CFA5E8C.jpg

准备

阅读本文需要一点点的 WebGL 的知识点,至少这个文档的基础知识部分看完即可。

此外各位同学需要下载一下 Three.js 源码。源码里有我们需要用到的模型和官网例子的源码。

搭建一个 web 工程,本文演示创建基于 vue3 的工程。

bash

npm init vue@latest

按照自己的喜好选择要安装哪些插件即可。生成的项目结构如下,我们为此项目添加 three.js,修改 package.json 如下。

json

{
  "dependencies": {
    ...
    "three": "^0.143.0"
  }
}

如需使用 typescript,添加如下依赖:

json

{
  "devDependencies": {
    ...  
    "@types/three": "^0.143.0"
  }
}

然后执行 npm install 安装依赖即可。

至此我们前期创建项目的准备工作就做完了。

体验例子

接下来我们在 three.js 官网上找到 感兴趣的例子,打开它的源码,先大致阅读一遍。通过这个例子中我们将学到如下几个知识点:相机、场景、网格、灯光、材质、形状、动画等。下面我们跟着例子写一遍代码,带大家学会如何通过例子去学习。

j1j33-c8781.gif

写(抄)代码

源码在上面的链接已经给出来,我就不在这里凑字数了。我在这里一步一步地写一下上述例子的代码,并演示如何通过此例来学习 Three.js。那么就让我们愉快地开始吧。

首先,我们在 Vue 项目中创建一个组件(比如 ThreeExample.vue),并在 mounted 生命周期中编写 Three.js 代码。当然,你也可以用原生 HTML+JS,但这里我们以 Vue3 为例。

1. 初始化场景、相机和渲染器

打开官方例子的源码,我们会看到一开始就创建了 scenecamerarenderer 这三个基本对象。这是每一个 Three.js 应用的起点。

js

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建透视相机(参数:视野角度、宽高比、近裁面、远裁面)
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 2000);
camera.position.set(2, 2, 5); // 设置相机位置
camera.lookAt(0, 0, 0);        // 让相机看向原点

// 3. 创建 WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement); // 将 canvas 添加到页面

学习点:这里我们用到了 PerspectiveCamera,如果不清楚它的参数含义,就可以去 官网文档 查看。文档会详细解释每个参数的作用,比如 fov(视野角度)决定了你能看到多大的范围,aspect(宽高比)通常设为画布的宽度/高度,否则图像会被拉伸。

2. 添加光源

例子中使用了多种光源:环境光、点光源和聚光灯。光源是让物体可见并产生阴影和立体感的关键。

js

// 环境光:提供基础照明,均匀照亮所有面
const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);
scene.add(ambientLight);

// 点光源:从某个点向所有方向发射光线
const pointLight = new THREE.PointLight(0xffffff, 1);
pointLight.position.set(1, 2, 3);
scene.add(pointLight);

// 还可以添加其他光源,比如聚光灯、平行光等,根据需要选择

学习点:看到 AmbientLight 和 PointLight,我们可以去文档了解每种光源的特点和适用场景。例如环境光没有方向,通常用来提亮阴影部分;点光源类似灯泡,会产生阴影(需配合阴影设置)。

3. 加载模型

例子中加载了一个带有骨骼动画和变形动画的模型(models/gltf/Soldier.glb)。我们需要使用 GLTFLoader 来加载 glTF 格式的模型。

首先,引入加载器(需要额外导入):

js

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

然后,在代码中创建加载器并加载模型:

js

const loader = new GLTFLoader();
loader.load('models/gltf/Soldier.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);

  // 获取模型的动画剪辑
  const animations = gltf.animations;
  if (animations && animations.length) {
    // 创建动画混合器并播放动画(后面会讲到)
    mixer = new THREE.AnimationMixer(model);
    const action = mixer.clipAction(animations[0]);
    action.play();
  }
}, undefined, (error) => {
  console.error('模型加载失败:', error);
});

注意:模型路径需要根据你存放的位置调整。官方源码中的模型路径是相对 examples/ 目录的,你可以把 models 文件夹复制到你的 public 目录下。

学习点:遇到 GLTFLoader,我们可以去文档或源码中查看它的用法。GLTF 是 Three.js 推荐的 3D 模型格式,支持动画、材质、骨骼等。通过这个例子,我们学会了如何加载外部模型并添加到场景。

4. 动画循环

例子中使用 requestAnimationFrame 实现动画循环,并在每一帧更新动画混合器。

js

let mixer = null; // 在加载模型时赋值

function animate() {
  requestAnimationFrame(animate);

  const delta = clock.getDelta(); // 获取时间差,用于平滑动画
  if (mixer) {
    mixer.update(delta); // 更新动画混合器
  }

  // 渲染场景
  renderer.render(scene, camera);
}

// 创建 Clock 对象用于计算时间差
const clock = new THREE.Clock();

// 启动动画循环
animate();

学习点AnimationMixer 和 Clock 是 Three.js 中处理动画的重要 API。通过查看文档,我们可以了解 mixer.update(delta) 如何基于时间差驱动模型动画。

5. 处理窗口大小变化

为了让画布自适应窗口,我们需要监听窗口的 resize 事件,更新相机宽高比和渲染器尺寸。

js

window.addEventListener('resize', onWindowResize, false);

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix(); // 必须调用,使相机参数生效
  renderer.setSize(window.innerWidth, window.innerHeight);
}

学习点updateProjectionMatrix() 方法的作用是更新相机的投影矩阵,当相机参数改变时(如宽高比),需要调用此方法让 Three.js 重新计算投影。

6. 遇到不懂的就去官网查找 API

在“抄”代码的过程中,你一定会遇到很多陌生的 API,比如 PointLightGLTFLoaderAnimationMixerClockupdateProjectionMatrix 等等。这时候,最好的学习方式就是打开 Three.js 官方文档,搜索这些 API,仔细阅读其用法和参数含义。

例如,你看到 camera.lookAt(0, 0, 0),可以查阅 Object3D 的 lookAt 方法,理解它是如何让物体朝向某个点的。

文档通常包含详细说明和示例代码,非常有助于深入理解。通过这样的方式,你不仅学会了这个例子,还能举一反三,应用到其他场景。

完整的代码整合

将上述代码片段整合到一个 Vue 组件中,大概如下(省略了样式和模板部分):

vue

<template>
  <div ref="container" style="width:100%; height:100vh;"></div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

const container = ref(null);

onMounted(() => {
  // 1. 创建场景
  const scene = new THREE.Scene();

  // 2. 创建相机
  const camera = new THREE.PerspectiveCamera(45, container.value.clientWidth / container.value.clientHeight, 1, 2000);
  camera.position.set(2, 2, 5);
  camera.lookAt(0, 0, 0);

  // 3. 创建渲染器
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(container.value.clientWidth, container.value.clientHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  container.value.appendChild(renderer.domElement);

  // 4. 添加光源
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);
  scene.add(ambientLight);
  const pointLight = new THREE.PointLight(0xffffff, 1);
  pointLight.position.set(1, 2, 3);
  scene.add(pointLight);

  // 5. 加载模型
  const loader = new GLTFLoader();
  let mixer = null;
  loader.load('/models/gltf/Soldier.glb', (gltf) => {
    const model = gltf.scene;
    scene.add(model);
    if (gltf.animations.length) {
      mixer = new THREE.AnimationMixer(model);
      const action = mixer.clipAction(gltf.animations[0]);
      action.play();
    }
  });

  // 6. 动画循环
  const clock = new THREE.Clock();
  function animate() {
    requestAnimationFrame(animate);

    const delta = clock.getDelta();
    if (mixer) {
      mixer.update(delta);
    }

    renderer.render(scene, camera);
  }
  animate();

  // 7. 窗口大小自适应
  const onWindowResize = () => {
    camera.aspect = container.value.clientWidth / container.value.clientHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(container.value.clientWidth, container.value.clientHeight);
  };
  window.addEventListener('resize', onWindowResize);
});
</script>

如何进一步深入学习

通过“抄”这个例子,我们已经接触到了 Three.js 的核心概念。但这仅仅是开始。下面是一些进阶学习建议:

  1. 修改参数,观察效果:尝试调整相机位置、光源颜色和强度、模型缩放比例等,实时查看变化,加深理解。
  2. 尝试添加交互:比如使用 OrbitControls 让用户可以用鼠标旋转视角。引入对应的控制器并启用,能极大地提升体验。
  3. 研究动画:例子中只播放了第一个动画剪辑。你可以尝试播放其他动画,或者混合多个动画。
  4. 阅读更多例子:Three.js 官网有上百个例子,涵盖了粒子系统、后期处理、物理效果等。按照同样的方法,一个个“抄”过去,你的 Three.js 水平会飞速提升。
  5. 参与社区:遇到问题时,可以到 Stack Overflow、GitHub Issues 或中文社区(如掘金)提问,也可以阅读他人的源码和文章。

最后

以上就是本文的全部内容了。我们通过一个官方的骨骼动画例子,逐步学习了 Three.js 的基础:场景、相机、渲染器、光源、模型加载、动画和自适应窗口。更重要的是,我们实践了“通过例子学习”的方法——遇到不懂的 API 就去查文档,然后亲手写一遍。

这种学习方法不仅适用于 Three.js,也适用于其他任何技术栈。希望各位同学能举一反三,不再畏惧陌生的框架和库,勇敢地打开源码,开始你的“抄”级学习之旅!

如果感觉阅读本文后有所收获,欢迎点赞、收藏和评论,也欢迎分享你通过例子学习 Three.js 的心得。我们下期再见!

后续会持续更新我的WebGL和Three.js学习过程和经验分享,如果感兴趣可以关注我的专栏Three.js一起来学

相关文章会同步发布到我的公众号:【编程智匠】

我花了三天用AI写了个上一代前端构建工具

作者 达拉
2026年3月16日 18:15

前端工程化实践:从复制粘贴到一键生成,xcli 解决了什么问题,又是如何设计的。

以前:2小时的痛苦

# 第1步:创建目录
mkdir my-project && cd my-project

# 第2步:初始化 package.json
npm init -y

# 第3步:安装 TypeScript
npm install -D typescript
npx tsc --init
# 然后手动改 tsconfig.json ...

# 第4步:安装 ESLint
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# 然后创建 .eslintrc.json,配置规则 ...

# 第5步:安装 Prettier
npm install -D prettier eslint-config-prettier
# 然后创建 .prettierrc ...

# 第6步:安装 Vite
npm install -D vite @vitejs/plugin-react
# 然后创建 vite.config.ts ...

# 第7步:配置 browserslist
# 创建 .browserslistrc,写兼容配置 ...

# 第8步:配置 PostCSS
npm install -D postcss autoprefixer
# 创建 postcss.config.js ...

# ... 此处省略 20 步

# 第 N 步:终于跑起来了
npm run dev

耗时:约 2 小时

现在:3分钟的优雅

npx @jserxiao/xcli init my-project -t react -d
cd my-project
pnpm dev

耗时:约 3 分钟


xcli 是什么?

xcli 是一个可插拔的 TypeScript 项目脚手架 CLI 工具。

它的核心理念:配置标准化、可复用、开箱即用

# 全局安装
npm install -g @jserxiao/xcli

# 或者直接用 npx
npx @jserxiao/xcli init my-project

核心功能详解

1. 三种项目模板

根据实际业务场景,我设计了三种模板:

📦 Library 模板

适合开发 npm 包、工具函数库。

my-lib/
├── src/
│   └── index.ts          # 入口文件
├── dist/                 # 编译输出
├── package.json
├── tsconfig.json
└── README.md

特性

  • TypeScript 5 严格模式
  • 同时输出 ESM + CJS 格式
  • 自动生成类型声明

⚛️ React 模板

基于 pnpm monorepo 的企业级前端项目。

my-app/
├── src/                    # 主应用源码
│   ├── main.tsx
│   ├── App.tsx
│   ├── pages/              # 页面
│   ├── components/         # 组件
│   ├── router/             # 路由
│   ├── api/                # HTTP 请求
│   │   └── request.ts      # Axios/Fetch 封装
│   └── store/              # 状态管理
│       ├── index.ts
│       ├── counterSlice.ts
│       └── middleware/
├── packages/               # pnpm workspace
│   ├── shared/             # 共享工具库
│   └── ui/                 # UI 组件库
├── vite.config.ts          # Vite 配置
├── eslint.config.js        # ESLint Flat Config
├── tsconfig.json
├── postcss.config.js
├── .browserslistrc         # 浏览器兼容
└── pnpm-workspace.yaml

状态管理可选

  • Redux Toolkit(推荐)
  • MobX

HTTP 请求可选

  • Axios(带完整封装)
  • Fetch(原生 API 封装)

💚 Vue 模板

同样是 pnpm monorepo 结构,默认集成 Pinia。

my-vue-app/
├── src/
│   ├── main.ts
│   ├── App.vue
│   ├── pages/
│   ├── components/
│   ├── router/
│   ├── api/
│   └── store/              # Pinia 状态管理
├── packages/
│   ├── shared/
│   └── ui/
└── ...配置文件

2. 丰富的插件系统

插件系统
├── 代码规范
│   ├── ESLint 9 (Flat Config) ⭐ 最新格式
│   ├── Prettier
│   └── Stylelint (支持 CSS/Less/SCSS)
│
├── 构建工具
│   ├── Vite 5 ⭐ 默认推荐
│   ├── Webpack 5
│   └── Rollup
│
├── 测试工具
│   ├── Vitest ⭐ Vite 原生
│   └── Jest
│
└── Git 工具
    ├── Husky (Git Hooks)
    └── Commitlint (提交规范)

每个插件都是独立、可插拔的。你可以选择需要的,跳过不需要的。


3. 浏览器兼容性:一处配置,处处生效

这是我最想重点介绍的功能。

以前的问题

CSS 前缀和 JS Polyfill 分开配置,经常对不上:

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {
      overrideBrowserslist: ['last 2 versions']  // 这里
    }
  }
}

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: { browsers: ['> 1%'] }  // 和这里不一致!
    }]
  ]
}

结果:CSS 兼容 Chrome 70,JS 兼容 Chrome 60,乱套了。

xcli 的解决方案

统一使用 .browserslistrc

# .browserslistrc
[production]
> 0.5%
last 2 versions
not dead
not IE 11
Chrome >= 86    # 明确指定 Chrome 86+

[development]
last 1 chrome version
last 1 firefox version
last 1 safari version

然后所有工具自动读取:

工具 作用 配置方式
Autoprefixer 添加 CSS 前缀 自动读取 .browserslistrc
Babel preset-env JS Polyfill 自动读取 .browserslistrc
Vite Legacy 旧浏览器兼容 自动读取 .browserslistrc

一处配置,处处生效。再也不用操心兼容性问题。


4. ESLint 9 Flat Config

很多脚手架还在用 ESLint 8 的 .eslintrc.json 格式,xcli 直接上了 ESLint 9+ Flat Config

旧格式(ESLint 8)

// .eslintrc.json
{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "env": {
    "node": true
  }
}

问题:

  • 多个配置文件(.eslintrc + .eslintignore
  • 配置格式不统一
  • 插件加载顺序容易出问题

新格式(ESLint 9 Flat Config)

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    },
  },
  prettierConfig,
);

优势:

  • ✅ 单一配置文件
  • ✅ JavaScript 原生数组操作,可扩展性更强
  • ✅ 显式导入,没有隐式依赖
  • ✅ 性能更好

实战演示

场景 1:快速启动一个 React 项目

# 交互式创建
npx @jserxiao/xcli init my-react-app

# 然后根据提示选择:
# ? 项目类型: React
# ? 样式预处理器: Less
# ? 状态管理: Redux Toolkit
# ? HTTP 请求库: Axios
# ? 打包工具: Vite
# ? 创建 VSCode 配置: Yes

或者直接命令行一把梭:

npx @jserxiao/xcli init my-react-app \
  -t react \
  -s less \
  -m redux \
  -h axios \
  -b vite \
  -d

生成的项目结构:

my-react-app/
├── src/
│   ├── main.tsx              # React 18 入口
│   ├── App.tsx               # 根组件
│   ├── pages/
│   │   ├── Home.tsx          # 首页(带 Redux 示例)
│   │   └── About.tsx         # 关于页
│   ├── components/
│   │   └── Layout.tsx        # 布局组件
│   ├── router/
│   │   └── index.tsx         # React Router 6
│   ├── api/
│   │   └── request.ts        # Axios 封装(含拦截器)
│   ├── store/
│   │   ├── index.ts          # Store 配置
│   │   ├── counterSlice.ts   # Counter 示例
│   │   ├── apiSlice.ts       # RTK Query
│   │   └── middleware/
│   │       └── logger.ts     # 日志中间件
│   └── assets/
├── packages/
│   ├── shared/               # 共享工具函数
│   │   └── src/
│   │       └── index.ts
│   └── ui/                   # UI 组件库
│       └── src/
│           └── index.ts
├── public/
├── vite.config.ts            # Vite 5 配置
├── eslint.config.js          # ESLint 9 Flat Config
├── tsconfig.json             # TypeScript 5
├── postcss.config.js         # PostCSS + Autoprefixer
├── .browserslistrc           # 浏览器兼容
├── .prettierrc
├── pnpm-workspace.yaml
└── package.json

直接运行:

cd my-react-app
pnpm install
pnpm dev

打开浏览器,一个完整的 React 项目已经跑起来了:

  • ✅ React 18 + TypeScript 5
  • ✅ React Router 6
  • ✅ Redux Toolkit(含 RTK Query)
  • ✅ Axios 封装
  • ✅ Vite 5 + HMR
  • ✅ ESLint 9 + Prettier
  • ✅ pnpm monorepo

总耗时:3 分钟


场景 2:企业级 Vue 项目

npx @jserxiao/xcli init my-vue-app \
  -t vue \
  -s scss \
  -b webpack \
  -d

注意这里用了 Webpack 而不是 Vite。

为什么?因为有些企业项目需要:

  • 更细粒度的构建控制
  • 复杂的 loader 配置
  • 特定的优化策略

xcli 的 Webpack 配置包含:

// webpack.config.cjs 节选
module.exports = (env, argv) => {
  return {
    // ...
    module: {
      rules: [
        // Babel:自动读取 .browserslistrc
        {
          test: /\.[jt]sx?$/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', {
                  useBuiltIns: 'usage',  // 按需 Polyfill
                  corejs: 3,
                }],
                '@babel/preset-typescript',
              ],
            },
          },
        },
        // CSS + PostCSS
        {
          test: /\.css$/,
          use: [
            MiniCssExtractPlugin.loader,
            'css-loader',
            'postcss-loader',  // Autoprefixer
          ],
        },
      ],
    },
    // 代码分割
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
          },
        },
      },
    },
  };
};

场景 3:开发一个 npm 工具库

npx @jserxiao/xcli init my-utils -t library -d

生成的 Library 项目:

my-utils/
├── src/
│   └── index.ts          # 入口文件
├── dist/                 # ESM + CJS 输出
├── package.json
├── tsconfig.json
├── rollup.config.ts      # Rollup 配置
└── README.md

package.json 自动配置:

{
  "name": "my-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  }
}

直接发布到 npm:

pnpm build
npm publish

技术细节:xcli 是如何设计的?

插件架构

每个插件都是一个独立的对象:

export const vitePlugin: Plugin = {
  name: 'vite',
  displayName: 'Vite',
  description: '下一代前端构建工具',
  category: 'bundler',
  defaultEnabled: true,
  devDependencies: {
    vite: '^5.0.0',
    '@vitejs/plugin-react': '^4.0.0',
    '@vitejs/plugin-legacy': '^5.0.0',
  },
  scripts: {
    dev: 'vite',
    build: 'vite build',
    preview: 'vite preview',
  },
  files: [
    {
      path: 'vite.config.ts',
      content: (context) => getViteConfig(context),
    },
  ],
};

好处

  • 插件之间完全解耦
  • 可以独立更新某个插件
  • 社区可以自定义插件

模板系统

模板负责生成项目结构:

export const reactTemplate = {
  type: 'react',
  displayName: 'React',
  description: 'React 前端项目 (pnpm monorepo)',

  createStructure: async (projectPath, context) => {
    // 1. 创建目录结构
    // 2. 生成配置文件
    // 3. 生成源代码
    // 4. 根据选项调整(Redux/MobX、Axios/Fetch、Vite/Webpack)
  },

  getDependencies: (styleType, stateManager, httpClient, bundler) => {
    // 根据选择返回对应的依赖
    return {
      dependencies: { ... },
      devDependencies: { ... },
    };
  },
};

版本管理

所有依赖版本统一在 versions.ts 中管理:

export const BUNDLER_VERSIONS = {
  vite: '^5.0.12',
  webpack: '^5.98.0',
  // ...
};

export const FRAMEWORK_VERSIONS = {
  react: '^18.2.0',
  vue: '^3.4.15',
  // ...
};

好处

  • 版本升级只需改一处
  • 确保所有项目使用相同版本
  • 避免版本冲突

对比:xcli vs 其他脚手架

特性 xcli create-react-app Vite 官方模板
TypeScript ✅ 原生支持 ⚠️ 需要 eject ✅ 支持
Monorepo ✅ pnpm workspace ❌ 不支持 ❌ 不支持
状态管理 ✅ 可选 Redux/MobX/Pinia ❌ 无 ❌ 无
HTTP 封装 ✅ 可选 Axios/Fetch ❌ 无 ❌ 无
ESLint 9 ✅ Flat Config ❌ 旧格式 ⚠️ 需手动配
浏览器兼容 ✅ 统一配置 ⚠️ 需手动配 ⚠️ 需手动配
构建工具 ✅ Vite/Webpack ❌ 仅 Webpack ✅ Vite
插件系统 ✅ 可插拔 ❌ 无 ❌ 无

使用建议

个人项目

# 快速启动,默认配置够用
xcli init my-app -t react -d

团队项目

# 明确指定每个选项,确保一致性
xcli init team-project \
  -t react \
  -s scss \
  -m redux \
  -h axios \
  -b vite \
  -d

建议团队制定一份 xcli 使用规范,确保所有项目配置统一。

开源库

xcli init my-lib -t library -d

写在最后

xcli 解决的是一个"小"问题——省去配置的时间。

但它带来的价值是"大"的:

  • 时间节省:从 2 小时到 3 分钟
  • 配置标准化:团队所有项目配置统一
  • 技术债减少:不再需要维护多份配置
  • 新人友好:降低项目启动门槛

如果你也受够了重复配置,不妨试试:

npx @jserxiao/xcli init my-project

附录:常用命令速查

# 创建项目
xcli init my-project
xcli init my-project -t react -d
xcli init my-project -t vue -d
xcli init my-lib -t library -d

# 插件管理
xcli plugin list
xcli plugin add vitest
xcli plugin remove jest

# 升级 CLI
xcli upgrade --check
xcli upgrade

# 查看版本
xcli version

这个工具是我用AI花了三四天写的;没错,文章也是AI写的;有问题或建议,欢迎在评论区交流 💬

【31-Ai-Agent】ai-agent的核心实现细节-bysking

作者 bysking
2026年3月16日 18:13

一、文章目的

帮助学习了解agent的核心原理

二、原理拆解

2.1 解决用户输入&输出的交互

Node.js 中,你可以使用内置的 readline 模块来实现不断读取用户命令行输入并执行不同逻辑的功能。以下是一个完整的实现示例。(当然还可以使用 commander 这个流行的库来实现,咱们就先简单实现)

const readline = require('readline');

// 创建 readline 接口
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
  prompt: '> ' // 命令提示符
});

// 显示欢迎信息
console.log('欢迎使用命令行交互工具!');
console.log('可用命令:');
console.log('  hello - 显示问候信息');
console.log('  time - 显示当前时间');
console.log('  info - 显示系统信息');
console.log('  exit - 退出程序');
console.log('');

// 开始提示符
rl.prompt();

// 监听用户输入
rl.on('line', (input) => {
  // 去除首尾空白字符
  const command = input.trim().toLowerCase();
  
  // 根据不同命令执行不同逻辑
  switch (command) {
    case 'hello':
      console.log('你好!欢迎使用命令行工具。');
      break;
    case 'time':
      console.log(`当前时间:${new Date().toString()}`);
      break;
    case 'info':
      console.log('系统信息:');
      console.log(`  节点版本: ${process.version}`);
      console.log(`  平台: ${process.platform}`);
      console.log(`  架构: ${process.arch}`);
      break;
    case 'exit':
      console.log('再见!');
      rl.close();
      return; // 退出当前回调
    default:
      console.log(`未知命令: ${command}`);
      console.log('可用命令: hello, time, info, exit');
  }
  
  // 重新显示提示符,等待下一次输入
  console.log('');
  rl.prompt();
});

// 监听关闭事件
rl.on('close', () => {
  console.log('\n程序已退出。');
  process.exit(0);
});

2.2 实现和AI大模型的一问一答交互

这里没什么特别的,就是普通的请求调用逻辑,我们基于deepseek实现一个简单演示代码,如下:


const DEEPSEEK_API_KEY = 'xxxxxx'; // 这里需要替换成你自己的api,一般不要定义在项目里面,有泄漏风险

/**
 * 调用 DeepSeek API 获取模型回复
 * @param messages 对话消息列表,包含 system、user 和 assistant 角色的消息
 * @returns 模型生成的回复文本
 * @throws 如果 API 请求失败或返回格式不正确,将抛出错误
 */
async function callLLMs(messages) {
  const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages,
      temperature: 0.35,
    }),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`DeepSeek API 错误: ${res.status} ${text}`);
  }

  const data = await res.json();
  const content = data.choices?.[0]?.message?.content;
  if (typeof content !== 'string') {
    throw new Error('DeepSeek 返回内容为空');
  }
  return content;
}


module.exports = { callLLMs };

我们可以编写测试代码来执行一下

const { callLLMs } = require('./agent');

// 测试代码
async function run(input) {
  const messages = [
    { role: 'system', content: '你是一个专业的助手' },
    { role: 'user', content: input },
  ];
  const response = await callLLMs(messages);
  return response;
}

// 测试代码
run('你好').then(console.log);

2.3 增加工具调用

做完上面的两步,那么将他们组合一下,你就能得到一个命令行的AI对话工具,当然,agent不止如此,我们接着往下走,思考一下,我们期望大模型在合适的时间调用我们提供的tools, 当然大模型并不知道我们有哪些工具,所以我们需要告诉大模型,我们有哪些工具

我们可以通过提示词构造来告诉大模型当前有哪些可用的工具列表:

const { callLLMs } = require('./agent');


// 通过系统提示词来告诉大模型角色定位,可用工具列表,工具参数,返回结构等。
const systemPrompt = `
你是天气查询的工具型助手,回答要简洁。

可用工具列表如下(action 的 tool 属性需与下列名称一致):
- getTime: 返回当前 time 字符串,参数为空。
- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}。
回复格式(严格使用 XML,小写标签): 对问题的简短思考 工具输入 等待 后再继续思考。 如果已可直接回答,则输出: 最终回答(中文,必要时引用数据来源)

规则:
每次仅调用一个工具;工具输入要尽量具体,根据用户输入,识别意图,如果需要调用工具,必须提供必要的参数,不允许使用大模型的默认工具。
如果拿到 observation 后有了答案,应输出 而不是重复调用。
未知工具时要说明,但仍用 XML 格式。
避免幻觉,不确定时请说明。请用中文回答。
`;

const question = `现在几点`;
const history = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: question },
];

async function run() {
  const response = await callLLMs(history);
  console.log(response);
}

run();


我们看一下返回的是啥

<think>
用户问现在几点,我需要获取当前时间。我可以使用 getTime 工具来获取当前时间字符串。
</think>
<action>
<tool>getTime</tool>
<input></input>
</action>

我们需要做啥:通过正则解析返回,发现需要进行工具调用,则解析出工具名,参数,然后本地调用得到结果后,再回给大模型作为上下文,继续后面的逻辑

  • 实现解析函数
const parseAssistantResponse = (content) => {
  const parsed = {
    actions: [], // 存储多个 action 的数组
    final: null,
  };

  // 1. 提取所有 <action> 标签
  const actionRegex = /<action>([\s\S]*?)<\/action>/gi;
  let actionMatch;
  while ((actionMatch = actionRegex.exec(content)) !== null) {
    const actionContent = actionMatch[1];

    // 2. 解析当前 action 中的 tool 和 input
    const toolMatch = actionContent.match(/<tool>([\s\S]*?)<\/tool>/i);
    const inputMatch = actionContent.match(/<input>([\s\S]*?)<\/input>/i);

    if (toolMatch) {
      const actionItem = {
        tool: toolMatch[1].trim(),
        input: inputMatch ? inputMatch[1].trim() : '',
      };
      parsed.actions.push(actionItem);
    }
  }

  // 3. 提取 <final> 标签
  const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i);
  if (finalMatch) {
    parsed.final = finalMatch[1].trim();
  }

  return parsed;
};

我们构造一个大模型的返回结果,进行一个工具解析测试:

  const response = `
    <think>用户问现在几点,我需要获取当前时间。我可以使用 getTime 工具来获取当前时间字符串。</think>
    <action>
      <tool>getTime</tool>
      <input></input>
    </action>

     <action>
      <tool>getTime2</tool>
      <input></input>
    </action>

    <final>当前时间是 10:00。</final>
  `;

  const parseRes = parseAssistantResponse(response);
  console.log(parseRes);

查看打印的测试结果

{
  actions: [ { tool: 'getTime', input: '' }, { tool: 'getTime2', input: '' } ],
  final: '当前时间是 10:00。'
}

那下一步,思路就清晰很多了,我们针对大模型的返回,判断是否已经得到了最终结果(判断条件是:final有值),有结果,直接返回, 如果没有最终结果,说明还是只是中间过程(final没有值),中间过程需要继续解析工具得到结果,然后继续进行大模型处理

  • 接下来,我们就在本地执行大模型解析后需要调用的工具函数,得到返回结果,然后拼接到大模型的输入上下文里面,继续进行用户的提问处理流程。

2.4 增加agent的工具处理循环

上一个步骤我们能注意到,只是处理了单次对话,单次工具调用,实际场景下,我们几乎会遇到多个工具的调用,我们需要一个循环来不断处理这些工具的调用结果。

const { callLLMs } = require('./agent');

const systemPrompt = `
你是天气查询的工具型助手,回答要简洁。

可用工具列表如下(action 的 tool 属性需与下列名称一致):
- getTime: 返回当前 time 字符串,参数为空。
- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}。
回复格式(严格使用 XML,小写标签): 时间获取,必须使用本地工具,对问题的简短思考 工具输入 等待 后再继续思考。 
如果已可直接回答,则输出: 最终回答,必须使用xml格式,使用final标签包裹(中文,必要时引用数据来源)

规则:
每次仅调用一个工具;工具输入要尽量具体,根据用户输入,识别意图,如果需要调用工具,必须提供必要的参数,不允许使用大模型的默认工具。
如果拿到 observation 后有了答案,应输出 而不是重复调用。
未知工具时要说明,但仍用 XML 格式。
避免幻觉,不确定时请说明。请用中文回答。
`;

const parseAssistantResponse = (content) => {
  const parsed = {
    actions: [], // 存储多个 action 的数组
    final: null,
  };

  // 1. 提取所有 <action> 标签
  const actionRegex = /<action>([\s\S]*?)<\/action>/gi;
  let actionMatch;
  while ((actionMatch = actionRegex.exec(content)) !== null) {
    const actionContent = actionMatch[1];

    // 2. 解析当前 action 中的 tool 和 input
    const toolMatch = actionContent.match(/<tool>([\s\S]*?)<\/tool>/i);
    const inputMatch = actionContent.match(/<input>([\s\S]*?)<\/input>/i);

    if (toolMatch) {
      const actionItem = {
        tool: toolMatch[1].trim(),
        input: inputMatch ? inputMatch[1].trim() : '',
      };
      parsed.actions.push(actionItem);
    }
  }

  // 3. 提取 <final> 标签
  const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/gi);
  if (finalMatch) {
    parsed.final = finalMatch[0].trim();
  }

  return parsed;
};

const TOOLKIT = {
  getTime: () => '2026-03-16 15:15',
  getWeather: (input) => {
    const { city, time } = JSON.parse(input);
    return `模拟天气信息:${city} ${time} 晴转多云,温度 25°C,湿度 60%`;
  },
};

/**
 * Agent 主循环,负责与 LLM 交互、解析回复、调用工具并更新对话历史
 * @param question
 * @returns 最终回答字符串,或错误提示
 */
async function AgentLoop(question) {
  const maxStep = 10;
  const history = [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: question },
  ];

  for (let step = 0; step < maxStep; step++) {
    const assistantText = await callLLMs(history);
    console.log(`\n[LLM 第 ${step + 1} 轮输出]\n${assistantText}\n`);
    
    history.push({ role: 'assistant', content: assistantText });

    const parsed = parseAssistantResponse(assistantText);

    console.log(parsed, '本轮解析转化结果');

    // 如果已经获得了处理结果,则直接返回结果
    if (parsed.final) {
      return parsed.final;
    }

    // 如果有 action,则调用工具并添加到对话历史中
    if (parsed.actions?.length) {
      const actionName = parsed.actions[0]?.tool;
      const actionParams = parsed.actions[0]?.input;
      const toolFn = TOOLKIT[actionName];
      let observation;

      observation = toolFn ? await toolFn(actionParams) : `未知工具: ${actionName}`;

      history.push({
        role: 'user',
        content: `<observation>${observation}</observation>`,
      });
      continue;
    }

    break; // 未产生 action 或 final
  }

  return '未能生成最终回答,请重试或调整问题。';
}

async function run() {
  const question = `现在几点`;
  const response = await AgentLoop(question);
  console.log(response);
}

// 启动测试
run();

三、下一步Todo

现在,组合上面的代码,监听用户输入输出,调用agent解析循环,就能实现一个迷你版本的获取时间,和天气的agent,后续我们需要进行工程化版本的生产级别的agent应用,还需要更多的封装,和支持网络能力,文件读写能力,上下文token监控和上下文压缩等等 我们还可以使用开源的框架进行项目搭建,比如:ai-sdk.dev/docs/agents… 基础原理,其实这篇文章看完也应该能理解大部分了,剩下的就交给大家自行探索了。

参考:github1s.com/minorcell/m…

里程碑5 - 完成框架 npm 包抽象封装并发布

作者 oo12138
2026年3月16日 18:12

一、目标

将之前elpis框架开发的代码抽象为sdk,将代码进行整合,区分elpis框架和具体业务;最后发布到 npm 上,实现部署

二、代码整合

1. 整合loader加载

在之前完成的代码里面,loader解析的仅是elpis框架内的相关文件;现在对这些loader进行整合,保留之前对框架的解析的同时,添加对业务逻辑的处理,样例代码如下:


    // 获取 elpis框架的 app目录
    app.appBaseDir = path.resolve(__dirname, `..${sep}app`);

    // 读取elpis框架下 app/XXXXXX/**/**.js 下所有的文件
    const XXXXXXPath = path.resolve(app.appBaseDir, `.${sep}XXXXXX`);
    const fileList = glob.sync(
        path.resolve(XXXXXXPath, `.${sep}**${sep}**.js`)
    );
    fileList.forEach(file => handleFile(file));

    let businessControllerPath;
    let businessFileList;
    if (app.businessPath !== app.appBaseDir) {
        // 读取 业务根目录下 app/XXXXXX/**/**.js 下所有的文件
        businessXXXXXXPath = path.resolve(app.businessPath, `.${sep}XXXXXX`);
        businessFileList = glob.sync(path.resolve(businessXXXXXXPath, `.${sep}**${sep}**.js`));
        businessFileList.forEach(file => handleFile(file));
    }

2. DSL整合

将由DSL模板衍生出来的 model 和 projec 挪到业务项目的 /model/文件下;让业务项目自己完成具体配置,elpis框架中只保留模板解析相关代码。

3. 自定义页面扩展

之前在elpis框架中,我们将自定义页面的router定义为 todo,留给业务进行自定义开发,elpis框架中保留共同组件。

  • 自定义页面、侧边栏 router修改

    1. 将elpis框架中 todo 相关的目录和文件删除,
    2. 在业务项目中app/pages/dashboard/XXX 目录下完成XXX页面开发。
    3. 将自定义页面路由和自定义侧边栏路由配置到app/pages/dashboard/router.js 文件中
    4. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    5. 在dashboard路由配置中引入业务项目导出的模块,完成路由整合。
  • 自定义动态组件扩展

    1. 在业务项目 app/pages/dashboard/complex-view/schema-view/components 目录下写组件
    2. 配置到 app/pages/dashboard/complex-view/schema-view/components/component-config.js
    3. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    4. 在elpis框架的 component-config.js 文件中,引入配置,整合后导出;配置项重复时,允许业务项目配置覆盖elpis框架配置
  • 引用业务中的自定义FormItem

    1. 在业务项目 app/pages/widgets/schema-form/complex-view 目录下写控件
    2. 配置到 app/pages/widgets/schema-form/form-item-config.js
    3. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    4. 在elpis框架的 form-item-config.js 文件中,引入配置,整合后导出;配置项重复时,允许业务项目配置覆盖elpis框架配置
  • 引用业务中的自定义SearchItem

    1. 在业务项目 app/pages/widgets/schema-search-bar/complex-view 目录下写控件
    2. 配置到 app/pages/widgets/schema-search-bar/search-item-config.js
    3. 在elpis框架的webpack配置中,确保这个配置文件是存在的,并定义路径别名
    4. 在elpis框架的 search-item-config.js 文件中,引入配置,整合后导出;配置项重复时,允许业务项目配置覆盖elpis框架配置

三、实现 npm 部署

  1. 提交前确认package.json中的 name 和 version,多次提交记得修改版本号
  2. 部署前使用命令 npm config get registry,确认指向 https://registry.npmjs.org/,如果指向别的地方,输入命令 npm config set registry 清空源
  3. 执行npm login进行登录,登陆后可以使用 npm whoami 查询
  4. 第一次提交可能会需要使用命令 npm publish --access public,告知npm我们的包为共有包,后续使用 npm publish 命令就行

四、整合过程中遇到的小问题

1. 修改config-loader的时候,不管我怎么切换环境验证,返回的始终是demo项目中default.cinfog配置的值

刚开始我demo项目的package.json中设置的启动命令是"dev": "set _ENV = 'local' && nodemon ./server.js"

后来发现按照windows cmd里set的语法:= 两面是不能有空格的、且变量值不需要引号;我配置的启动命令set _ENV = 'local'实际上相当于变量名: "_ENV ",变量值: " 'local'"

我将启动命令按照规则修改为"dev": "set _ENV=local && nodemon ./server.js",发现process.env._ENV可以成功获取的值,但是返回的还是default.cinfog中的值

打印出来之后发现,package.json中启动命令set _ENV=local,在windows解析set的时候,被解析成 _ENV="local ",后面有一个空格。。。

在判断环境的地方增加trim()之后可以正常取得环境值。

2. 在prod构建和启动的时候,浏览器中提示Uncaught Error: [HMR] Hot Module Replacement is disabled.GET http://127.0.0.1:9002/__webpack_hmr net::ERR_CONNECTION_REFUSED

这个很让人感到意外,因为我们webpack的配置是只有dev环境启动的时候才会热更新。

刚开始以为是在执行完build:dev后,没有把public删除导致的,后续删了之后重新执行build:prod后,启动prod环境发现还是提示这个错误。

根据浏览器控制台中错误提示信息,发现是在vandor包里面打入了hmr相关的内容;而浏览器发现我们的bundle文件中有webpack-hot-middleware,就会自动连接,但是我们的server没有启HMR,就会发生这个错误

经过查找发现是在webpack.dev.js的代码中向 entry 配置加入 hmr 的时候,修改了 baseConfig.entry,导致 HMR client 被打进 vendor,并污染了生产环境的构建

修改方案:配置中加入hmr的时候,不修改baseConfig.entry,而是将值给到新的devEntry,完成之后在webpackConfig中配置 entry: devEntry,代码参考:

  const devEntry = {};
  // 开发环境的 entry 配置需要加入 hmr
  Object.keys(baseConfig.entry).forEach(v => {
      // 第三方包不作为hmr入口
      if (v !== "vendor") {
          devEntry[v] = [
              baseConfig.entry[v],
              `${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}`
          ];
      } else {
          devEntry[v] = baseConfig.entry[v];
      }
  });

3. 在elpis框架的alias中配置'vue': require.resolve("vue"),而不是在新的业务代码里面npm i vue

这让人很疑惑,为什么不是在业务代码里面npm i vue下载Vue资源,而是引用elpis框架中的Vue?难道不应该是elpis框架包中不包含vue,然后在新的业务代码里面npm i vue吗?

实际上这两种方法应该都是可以的,只是我们之前使用npm link进行整合和拆分,为了避免出现多个vue实例,才在elpis框架的alias中配置'vue': require.resolve("vue"),是为了强制所有地方只使用同一个vue。

不然的话,elpis自己依赖一个vue,业务项目又进行了npm i vue,最终会导致node_modules中的层级可能会是这样的:

node_modules
 ├ vue (业务项目安装)
 └ elpis
     └ node_modules
         └ vue (elpis 自己安装)

此时项目中会存在两个vue实例,这样会导致provide/inject失效vue.component注册组件找不到Vue.use(plugin)失效;造成这些问题的原理是:

项目里面存在两个vue构造函数实例时,业务代码的Vue1 !== 内部组件的Vue2

vue.component全局注册组件时挂在vue构造函数上,如果组件是在vue1全局注册Vue1.component(...),而组件是在vue2进行创建Vue2.extend(…),vue在渲染时检查 component instanceof Vue1结果是false,vue会认为组件不存在

provide/inject的实现依赖Vue prototype chain. Vue1.prototype !== Vue2.prototype,所以inject找不到provide

插件安装Vue.use(plugin)实际上是plugin.install(Vue),如果plugin install在Vue1,但是组件运行在Vue2,插件逻辑根本不会生效

后续修改完成后可以将elpis 改为只引用、不打包,由业务项目安装Vue;实现最终运行时全局只有一个Vue,下面是具体实现方式(验证中):

  1. package.json中下载的vue要放在 peerDependencies 里,而不是dependencies里;要注意的是要写清楚版本范围,如果 elpis用的是 vue 2,项目安装的是 vue 3,可能会直接炸掉。
    {
        "peerDependencies": {
            "vue": ^3.3.4
        }
    }
  1. 在elpis的webpack配置中添加 externals,含义是当代码里面写import Vue from "vue"时,不打包,而是从运行环境获取,具体配置为:

    // 简化版
    {
        externals: {
            vue: "vue"
        }
    }
    
    // 兼容不同环境版
    {
        externals: {
            vue: {
                commonjs: "vue",
                commonjs2: "vue",
                amd: "vue",
                root: "vue",
            }
        }
    }
  1. 在业务项目中进行 npm install vue,这样配置的话目录结构会变成:

    elpis-demo
        ├ node_modules
        │   ├ vue
        │   └ @togurodi/elpis
    

「前端何去何从」(React教程)React 状态管理:从局部 State 到可扩展架构

作者 从文处安
2026年3月16日 18:05

React 状态管理:从局部 State 到可扩展应用

当 React 应用开始变复杂,真正决定代码质量的,往往不是组件写法,而是状态设计

状态管理是 React 真正开始变难的地方

在前两篇里,我们已经知道:

  • 组件会根据 props 和 state 渲染 UI
  • 用户操作会触发事件
  • 调用 setState 会让 React 重新渲染

但学到这里,很多人会开始遇到一个新的问题:代码明明能跑,为什么一复杂就开始乱?

真实项目里更常见的问题是:

  • 一个界面到底该定义哪些 state?
  • 多个组件需要同步时,状态放哪一层?
  • 为什么切换页面后,有的输入框内容还在,有的又被重置了?
  • 状态逻辑越来越复杂时,什么时候该用 useReducer
  • 跨很多层传数据时,什么时候该用 Context?

这些问题表面上看是在问 API,实际上问的是另一件事:

你的状态是不是设计清楚了。

React 官方文档把这一章叫做“状态管理”,我觉得非常准确。因为从这一章开始,重点已经不是“怎么写一个交互”,而是“怎么让交互在复杂度上升后仍然清晰、可维护”。

本文会覆盖什么

本文对应 React 官方文档“状态管理”章节及其子章节,按学习顺序讲解:

  • 用 State 响应输入
  • 选择 State 结构
  • 在组件间共享状态
  • 对 state 进行保留和重置
  • 迁移状态逻辑至 Reducer 中
  • 使用 Context 深层传递参数
  • 使用 Reducer 和 Context 拓展你的应用

学完之后你应该能做到什么

如果你认真跟着本文走完,应该能掌握这些能力:

  • 能把一个交互拆成几个明确的界面状态
  • 能判断哪些数据应该放进 state,哪些不该放
  • 能在兄弟组件之间正确共享状态
  • 能控制组件 state 什么时候保留、什么时候重置
  • 能把复杂的更新逻辑迁移到 reducer
  • 能用 context 避免层层传 props

这篇文章的目标不是让你记住几个 API 名字,而是让你建立一套更稳定的状态思维。


Part 1: 用 State 响应输入

不要一上来就写事件处理函数

React 官方文档在这一节强调的重点是:

先把界面看成一组状态,再去写组件。

很多初学者写交互时,习惯直接想:

  • 点击后把按钮禁用
  • 请求回来后显示成功提示
  • 出错时显示错误文案

这种写法的问题是,你是在“命令式地改界面”。界面稍微复杂一点,就会越来越乱。

React 更适合用另一种方式思考:

组件会处于哪些状态?每个状态应该显示什么 UI?哪些事件会让它切换到下一个状态?

这听起来有点抽象,但一旦你习惯了这种思路,很多交互代码会自然变简单。

第一步:列出组件的所有状态

以一个问答表单为例,你可以先列出这些状态:

  • 输入中
  • 提交中
  • 提交成功
  • 提交失败

这一步非常重要。很多 bug 的源头,不是代码写错,而是你一开始就漏掉了某个状态。

第二步:为状态选择合适的数据结构

来看一个典型实现:

import { useState } from 'react';

export default function QuizForm() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    setError(null);

    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setError(err);
      setStatus('typing');
    }
  }

  if (status === 'success') {
    return <h1>答对了!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={answer}
        onChange={e => setAnswer(e.target.value)}
        disabled={status === 'submitting'}
      />
      <br />
      <button disabled={answer.length === 0 || status === 'submitting'}>
        提交
      </button>
      {error !== null && <p className="error">{error.message}</p>}
    </form>
  );
}

这里有三个 state:

  • answer:用户输入的答案
  • error:提交失败后的错误信息
  • status:当前界面所处状态

这三个值已经足够描述整个交互,而且没有多余信息。

为什么 status 比多个布尔值更好

很多人一开始会这样写:

const [isTyping, setIsTyping] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);

这样的问题是,很容易出现互相矛盾的状态,比如:

  • isSubmitting === true
  • isSuccess === true

这通常不是你想要的结果。

更好的方式是用一个 status 表示互斥状态:

const [status, setStatus] = useState('typing');

这样状态更清晰,也更不容易进入“不可能状态”。

第三步:找出触发状态变化的事件

状态变化通常来自两类来源:

  • 用户事件:输入、点击、提交
  • 异步结果:请求成功、请求失败

以上面那个表单为例:

  • onChange 会更新 answer
  • onSubmit 会把 status 改成 submitting
  • 请求成功后,把 status 改成 success
  • 请求失败后,更新 error,并把 status 改回 typing

这样一来,交互的流转路径就很清楚了。你不是在零散地改 UI,而是在管理状态切换。

这一节最应该记住什么

写交互时,先做这 3 件事:

  1. 列出所有可见状态
  2. 选出最少的 state 来表示它们
  3. 明确每个事件会把状态切换到哪里

如果你把这套顺序养成习惯,后面设计复杂界面会轻松很多。


Part 2: 选择 State 结构

State 结构设计得好,后面的代码会轻松很多;设计得差,后面每加一个功能都像在补漏洞。

React 官方文档在这一节给了几条非常重要的原则。这里我按教程方式,一条一条讲清楚。

如果你只记一句话,那就是:

state 应该尽可能少,但又足够表达当前界面。

原则 1:如果总是一起变化,就考虑合并

比如鼠标位置:

const [x, setX] = useState(0);
const [y, setY] = useState(0);

这能工作,但如果你每次更新时都要同时改 xy,更自然的写法是:

const [position, setPosition] = useState({ x: 0, y: 0 });

这样更适合表达“它们本来就是同一件事”。

不过也别为了“整洁”把所有状态都塞进一个对象。没有关系的 state,拆开反而更清楚。React 没有要求你一定用对象或一定拆开,关键看它们是不是同一个概念。

原则 2:避免矛盾的 state

看这个例子:

const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

这两个 state 如果维护不好,就可能互相矛盾。

更好的写法通常是:

const [status, setStatus] = useState('typing');

用一个字段表达互斥状态,通常比多个布尔值更稳定。这是状态建模里非常常用的技巧。

原则 3:能计算出来的值,不要放进 state

这是最常见的错误之一。

// 不推荐
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

这里的 fullName 完全可以由前两个值计算出来,不需要再单独存一份。

// 推荐
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

const fullName = firstName + ' ' + lastName;

为什么不建议把 fullName 再存一份?

因为一旦存了两份数据,你就要保证它们一直同步。同步一旦漏掉,UI 就会出错。

这个问题在表单里尤其常见。我的建议是:能算出来的值,优先算,不要存。

原则 4:不要重复存同一份数据

假设你有一个商品列表,并且支持选中某一项:

const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(items[0]);

这看起来很方便,但会产生同步问题。比如 items 更新了,selectedItem 可能还是旧对象。

更推荐的写法是只存 selectedId

const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);

const selectedItem = items.find(item => item.id === selectedId);

这是一个很实用的经验,也是很多项目里常见的重构方向:

尽量存 ID,而不是存对象副本。

这样做的好处是,真正的数据源只有一份。后面列表更新、排序、过滤时,也不容易出现“选中的还是旧对象”这种问题。

原则 5:避免深度嵌套 state

如果 state 嵌套太深,更新会变得非常麻烦:

setPlan({
  ...plan,
  childPlaces: {
    ...plan.childPlaces,
    42: {
      ...plan.childPlaces[42],
      title: 'New title',
    },
  },
});

遇到这种情况时,优先考虑:

  • 扁平化数据结构
  • 用 ID 建立关系
  • 把局部状态拆到更小的组件里

一个简单的判断方法

写完一个 state 结构后,你可以问自己:

  • 这个值真的需要“记住”吗?
  • 它能不能从别的值算出来?
  • 它会不会和别的 state 打架?
  • 同一份数据是不是存了两次?

只要这里面有一两项答“是”,就说明 state 结构值得重审。

我在项目里看过很多“状态管理很乱”的组件,问题通常不在 React,而在这里一开始就没设计好。


Part 3: 在组件间共享状态

问题:兄弟组件各自有 state,但业务要求同步

这是 React 里非常经典的一类问题。

先看一个常见场景:手风琴组件。

如果每个 Panel 都自己维护是否展开:

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);

  return (
    <section>
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>显示</button>
      )}
    </section>
  );
}

这没问题,但它只能保证“每个面板自己能展开”。

如果需求变成:

同一时间只允许展开一个面板

那就不能让它们各自管理自己的 isActive 了。

解法:状态提升

React 的标准解法非常直接:把共享状态提升到最近的共同父组件。

import { useState } from 'react';

function Panel({ title, children, isActive, onShow }) {
  return (
    <section>
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>显示</button>
      )}
    </section>
  );
}

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <>
      <Panel
        title="关于"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        React 是一个用于构建用户界面的库。
      </Panel>
      <Panel
        title="词源"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        “React” 这个名字强调界面对状态变化的响应。
      </Panel>
    </>
  );
}

这里发生了三件事:

  • Panel 自己不再保存 isActive
  • 父组件保存共享状态 activeIndex
  • 父组件通过 props 把值和事件处理函数传给子组件

这就是 React 文档里反复强调的单向数据流。状态放在父组件,值往下传,事件往上传。

什么时候该提升状态

当你发现两个或多个组件:

  • 需要显示同一份数据
  • 需要对同一件事达成一致
  • 一个组件更新后,另一个组件也要跟着变

那就应该考虑把状态提到它们最近的共同父级。

这个“最近”很重要。放得太低,没法共享;放得太高,顶层组件会被很多无关状态塞满。

所以“状态提升”不是把所有状态都往上丢,而是找到一个刚好能覆盖使用范围的父组件。

受控组件和非受控组件

在上面的例子里,Panel 的显示与否完全由父组件控制,这种组件叫受控组件

如果一个组件把状态完全保存在自己内部,父组件无法控制它,它就更接近非受控组件

这两个概念你不用死记定义,记住下面这个判断就够了:

  • 状态由父组件传入并控制:更偏受控
  • 状态封装在组件内部:更偏非受控

实际开发里,只要状态需要多个组件协作,通常就要往受控方向走。


Part 4: 对 State 进行保留和重置

这一节是 React 状态管理里最容易让人困惑的一节,但也非常重要。

React 什么时候会保留 state

React 会根据组件在渲染树中的位置来判断要不要保留 state。

你可以先记住一个最实用的结论:

同一种组件,出现在同一个位置,React 通常会保留它的 state。

看这个例子:

function App() {
  const [isFancy, setIsFancy] = useState(false);

  return (
    <div>
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => setIsFancy(e.target.checked)}
        />
        使用 fancy 样式
      </label>
    </div>
  );
}

虽然 JSX 写了两个分支,但对 React 来说,这个位置渲染的始终都是 Counter。所以它会保留 Counter 的 state。

这也是为什么很多人会觉得 React 的 state “有点反直觉”。你看到的是两段 JSX,React 看到的是同一个位置上的同一种组件。

理解这点之后,很多“为什么这里没清空”“为什么这里还记着上一次输入”的问题,都会一下子变得很好解释。

React 什么时候会重置 state

如果某个位置上渲染的不再是同一种组件,React 就会重置 state。

例如:

{isPaused ? <p>See you later!</p> : <Counter />}

这里在同一个位置上,一会儿是 p,一会儿是 Counter。类型变了,所以 state 不会保留。

React 官方文档在这里讲得很清楚:state 不是“挂在 JSX 标签上”的,而是 React 根据渲染树位置保存的。位置和类型都变了,之前那份 state 自然就没了。

key 不只给列表用

很多人只知道列表渲染时要写 key,但 key 还有一个非常重要的用途:

告诉 React,这是两个不同身份的组件。

比如聊天窗口:

<Chat key={to.id} contact={to} />

如果你不给 key,切换聊天对象时,输入框里的内容可能会被保留下来。

如果你加上 key={to.id},React 会把不同联系人对应的 Chat 当成不同组件实例处理,输入框 state 就会重置。

所以,key 的本质不是“列表专用语法”,而是“身份标识”。

这点在表单、聊天窗口、切换用户视图这类场景里特别有用。

一个容易踩坑的错误:在组件内部定义组件

看下面的代码:

export default function App() {
  function MyTextField() {
    const [text, setText] = useState('');
    return <input value={text} onChange={e => setText(e.target.value)} />;
  }

  return <MyTextField />;
}

这种写法会让组件在每次渲染时都重新定义,React 可能把它当成一个新的组件,导致 state 被重置。

正确做法是把组件定义放在顶层。

这个问题前两篇其实也提到过。组件定义放在顶层,不只是为了代码风格,更是为了让 React 能稳定识别组件身份。

这一节最重要的结论

你可以把这节压缩成这 3 句:

  • state 跟组件在树中的位置有关
  • 类型相同、位置相同,state 通常会保留
  • 想强制重置,就改变组件身份,比如使用不同的 key

Part 5: 迁移状态逻辑至 Reducer 中

什么时候应该考虑 useReducer

一个组件只有一两个简单 state 时,useState 很好用。

但如果你开始遇到这些情况,就该考虑 reducer:

  • 同一个 state 会被很多事件处理函数修改
  • 更新逻辑越来越长
  • 组件里有很多 setXxx(...)
  • 你越来越难看清“某个操作到底改了什么”

这时候的问题通常不是“React 不够用”,而是“更新逻辑已经散了”。

我的经验是,只要你开始频繁在不同函数里改同一份 state,就该警觉了。因为这往往意味着后面会越来越难维护。

先看 useState 写法

以任务列表为例:

const [tasks, setTasks] = useState(initialTasks);

function handleAddTask(text) {
  setTasks([
    ...tasks,
    { id: nextId++, text, done: false },
  ]);
}

function handleChangeTask(task) {
  setTasks(tasks.map(t => (
    t.id === task.id ? task : t
  )));
}

function handleDeleteTask(taskId) {
  setTasks(tasks.filter(t => t.id !== taskId));
}

这没有错,但随着操作变多,逻辑会越来越分散。

把“怎么更新”收拢到 reducer

使用 reducer 后,事件处理函数只负责派发 action,也就是描述“发生了什么”:

function handleAddTask(text) {
  dispatch({
    type: 'added',
    id: nextId++,
    text,
  });
}

function handleChangeTask(task) {
  dispatch({
    type: 'changed',
    task,
  });
}

function handleDeleteTask(taskId) {
  dispatch({
    type: 'deleted',
    id: taskId,
  });
}

真正的更新逻辑集中写在 reducer:

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added':
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    case 'changed':
      return tasks.map(task =>
        task.id === action.task.id ? action.task : task
      );
    case 'deleted':
      return tasks.filter(task => task.id !== action.id);
    default:
      throw Error('Unknown action: ' + action.type);
  }
}

组件中这样使用:

import { useReducer } from 'react';

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

React 官方文档把这个迁移过程拆成了三个步骤:

  1. 把设置 state 的逻辑改写成 dispatch action
  2. 编写 reducer 函数
  3. 在组件中用 useReducer

这个拆分很实用,因为它告诉你 reducer 不是“推倒重来”,而是一步一步迁移过去。

Reducer 的好处是什么

用了 reducer 之后:

  • 事件处理函数变短了
  • 更新逻辑集中到一个地方
  • 每个 action 都在描述“发生了什么”
  • reducer 是纯函数,更容易测试

我觉得 reducer 最大的价值,不是“更高级”,而是把状态更新逻辑从组件里剥出来。组件负责交互,reducer 负责状态变化,这样职责会清楚很多。

写 reducer 时的两个要求

1. reducer 必须是纯函数

不要在 reducer 里:

  • 发请求
  • 改外部变量
  • 直接修改原对象或原数组

它只应该根据 state + action 返回新的 state。

2. action 要表达业务动作

好的 action 例子:

dispatch({ type: 'deleted', id: 3 });
dispatch({ type: 'changed', task });

它们表达的是“用户做了什么”,而不是“我要调用哪个 setter”。

useStateuseReducer 怎么选

可以这样简单判断:

  • 简单组件:优先 useState
  • 更新逻辑复杂、操作很多:考虑 useReducer

不要为了“高级”而用 reducer。它的价值在于整理复杂逻辑,不在于替代所有 useState


Part 6: 使用 Context 深层传递参数

Context 解决什么问题

假设你有这样一棵组件树:

<Page>
  <Layout>
    <Sidebar />
    <Content>
      <Profile />
    </Content>
  </Layout>
</Page>

如果 Profile 需要当前登录用户,而这个用户数据在 Page 里,你可能会这样传:

<Layout user={user} />
<Content user={user} />
<Profile user={user} />

这就叫 prop drilling,也就是逐层透传 props。

如果中间组件根本不关心这个数据,只是被迫传下去,代码会越来越烦。

Context 就是为了解决这个问题。

你可以把它理解成一种“跨中间层传值”的机制。

React 官方文档这里有一句话我很认同:如果数据可以“不经过 props 直达需要它的组件”,很多中间层组件就能干净很多。

Context 的基本三步

第一步:创建 context
import { createContext } from 'react';

export const LevelContext = createContext(1);
第二步:提供 context
import { LevelContext } from './LevelContext.js';

function Section({ level, children }) {
  return (
    <LevelContext value={level}>
      {children}
    </LevelContext>
  );
}
第三步:读取 context
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

function Heading({ children }) {
  const level = useContext(LevelContext);

  switch (level) {
    case 1:
      return <h1>{children}</h1>;
    case 2:
      return <h2>{children}</h2>;
    case 3:
      return <h3>{children}</h3>;
    default:
      return <p>{children}</p>;
  }
}

这样,Heading 不需要一级一级接收 level,它会直接从最近的 provider 读取。

这个例子看起来简单,但它很好地说明了 Context 最适合做什么:提供一个“周围环境”。像标题层级、主题、语言、当前用户,都属于这类信息。

Context 的特点

你需要记住两点:

  • useContext(SomeContext) 会读取最近的 provider 提供的值
  • 如果上层没有 provider,就使用 createContext 时传入的默认值

什么情况下该用 Context

比较适合 Context 的数据:

  • 主题
  • 当前用户
  • 语言环境
  • 路由信息
  • 某个功能域内多层组件都要读取的状态

什么情况下不要急着用 Context

如果只是传一两层,props 往往更直接。

因为 props 的依赖关系是显式的,读代码时很容易看懂。而 Context 一旦用多了,数据来源会变得不够直观。

所以一个很实用的建议是:

先问自己,这个值是真的“很多层都要用”吗?如果不是,就先用 props。

React 官方文档还提醒了另一种替代思路:有时候你不需要传某个具体 prop,而是可以把 JSX 作为 children 往下传。这样也能减少中间层的负担。


Part 7: 使用 Reducer 和 Context 拓展你的应用

到了这里,你已经学了两件事:

  • useReducer 可以整理复杂的状态更新逻辑
  • Context 可以避免层层传递 props

把它们结合起来,就是 React 原生组织复杂状态的一种常见方式。

如果说 reducer 解决的是“逻辑分散”,那 Context 解决的就是“传递太深”。

一个典型问题

任务列表状态在顶层:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

接下来你会发现:

  • TaskList 需要读 tasks
  • AddTask 需要用 dispatch
  • Task 也需要用 dispatch

如果继续用 props 层层往下传,组件树很快会变得臃肿。

官方文档的常见做法

statedispatch 放进两个不同的 context。

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

然后统一提供:

import { useReducer } from 'react';

function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        <h1>任务列表</h1>
        <AddTask />
        <TaskList />
      </TasksDispatchContext>
    </TasksContext>
  );
}

深层组件直接读取:

import { useContext } from 'react';

function TaskList() {
  const tasks = useContext(TasksContext);

  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>{task.text}</li>
      ))}
    </ul>
  );
}
function AddTask() {
  const dispatch = useContext(TasksDispatchContext);

  function handleAdd(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text,
    });
  }

  // ...
}

为什么分成两个 context

这是一个很常见的组织方式,因为它把:

  • 读取状态
  • 触发更新

分开了。

这样做的一个好处是,读代码时你会更容易分清:哪些组件只是消费数据,哪些组件还会发起更新。

React 官方文档在后面还进一步把这部分封装成 useTasksuseTasksDispatch。这个思路很好,因为它能把“从哪里取数据”也一起隐藏起来,让业务组件更干净。

后面如果你想封装自定义 Hook,也会更清楚:

function useTasks() {
  return useContext(TasksContext);
}

function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

什么时候适合这样做

Reducer + Context 比较适合这些场景:

  • 某个功能模块已经有一定复杂度
  • 多层组件都要读写同一份状态
  • 你还不想引入额外状态管理库

它不是所有项目都必须上,但它是 React 原生能力里非常值得掌握的一种模式。

如果项目规模还小,直接 props 或局部 state 完全没问题。只有当一个功能域已经明显变复杂时,Reducer + Context 才会真正体现价值。


学习路径与实践建议

到这里,你可以把本章内容串成一条完整的学习链路:

  1. 先学会把 UI 拆成几个状态
  2. 再学会只保存必要的 state
  3. 接着学会把共享状态提升到父组件
  4. 再理解 React 为什么会保留或重置 state
  5. 状态逻辑复杂后,用 reducer 收拢更新
  6. 组件层级太深后,用 context 传递状态或 dispatch

最常见的几个错误

如果你刚开始练习状态管理,最容易犯这些错:

  • 把能计算出来的值也放进 state
  • 用多个布尔值描述互斥状态
  • 兄弟组件各自维护本该共享的数据
  • 不理解 key,导致 state 该重置时没重置
  • 过早使用 Context,让数据流变复杂

你可以把这几个问题当成自己的排错清单。

如果你发现某个组件越来越难懂,通常可以反过来检查:

  • state 是不是存多了
  • 谁拥有这份 state 是不是不清楚
  • 更新逻辑是不是散在太多地方
  • 组件身份是不是不稳定

很多所谓“React 状态管理问题”,最后都能落回这几个基本点。


总结

这一章最核心的不是 API,而是状态思维。

  • 用 State 响应输入:先定义状态,再写 UI
  • 选择 State 结构:只保存必要且不冲突的数据
  • 共享状态:把共享数据放到最近的共同父级
  • 保留和重置:理解位置、类型和 key
  • Reducer:把复杂的更新逻辑集中起来
  • Context:减少层层传递 props 的成本
  • Reducer + Context:在 React 原生能力内组织更复杂的应用

如果你能把这一章真正吃透,后面再学表单、路由、全局状态管理库,都会顺很多。

如果说前两篇解决的是“React 是怎么渲染 UI 和响应交互的”,那这一篇解决的就是另一件更实际的事:

当你的应用不再只是几个按钮和输入框时,状态应该怎么组织,代码才不会失控。


相关资源


本文基于 React 官方文档 “状态管理” 章节。

❌
❌