普通视图

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

用心写好一个登录页:代码、体验与细节的平衡

作者 有意义
2026年1月7日 17:09

写在前面

今天,我们将使用 React + Vite + Tailwind CSS + Lucide React,快速搭建一个简洁、响应式且注重细节的登录页面,并顺手拆解几个提升用户体验的小技巧。

为什么登录页面非常重要?

别小看这个看似简单的页面——它往往是用户对产品的第一印象
登录页远不止是一个表单,更是整个产品体验的入口:设计得当,用户顺畅进入;处理草率,可能直接导致流失。

image.png


用tindwindcss完成一个登录页面。

借助 Tailwind CSS 的原子化类名体系,我们能够高效构建出美观、响应式且高度可定制的登录界面。

无需传统 CSS,仅通过组合语义清晰的工具类,即可实现精致的布局、柔和的阴影、流畅的过渡动画以及跨设备的自适应表现。

配合 React 的状态管理与 Lucide React 的简洁图标,整个登录页不仅视觉清爽,交互也细腻自然——从密码可见性切换到聚焦态反馈,每一处细节都服务于用户体验。

这不仅是“完成一个表单”,更是用代码传递信任与温度的过程。

这里用到的一些技术栈

这个小项目基于现代前端工程化理念构建,选用了以下轻量的技术组合:

React:作为核心 UI 库,利用其声明式语法和组件化思想,将登录表单拆解为可维护、可复用的逻辑单元。通过 useState 等 Hooks 管理状态,实现数据驱动的交互体验。

Tailwind CSS:采用 Utility-First(原子化)开发模式,摒弃传统 CSS 的命名负担与样式冗余。所有样式直接通过语义清晰的类名在 JSX 中组合而成,极大提升开发效率与设计一致性,同时天然支持响应式布局和主题扩展。

Lucide React:一个轻量、开源且风格统一的图标库,提供简洁优雅的 SVG 图标组件。项目中使用了 <Mail /><Lock /><Eye /> 和 <EyeOff /> 等图标,增强界面视觉引导,且无需额外配置即可与 Tailwind 样式无缝融合。

这套技术栈兼顾开发体验与运行性能,既适合快速原型验证,也具备良好的可维护性与扩展能力,是构建现代化登录界面的理想选择。

这里用到的tindwind 类名的解释:

  1. min-h-screen — 设置元素最小高度为视口高度
  2. bg-slate-50 — 设置背景色为浅 slate 灰(非常淡的灰色)
  3. flex items-center justify-center — 使用 Flex 布局,垂直和水平居中子元素
  4. p-4 — 内边距为 1rem(16px)
  5. max-w-md — 最大宽度为中等尺寸(默认 28rem / 448px)
  6. bg-white — 背景色为纯白色
  7. rounded-3xl — 圆角非常大(默认 1.5rem / 24px)
  8. shadow-xl — 添加超大阴影,增强浮层感
  9. border-slate-100 — 边框颜色为极浅 slate 灰
  10. space-y-6 — 子元素之间垂直间距为 1.5rem(24px)

实现登录页面的一些关键逻辑:

const [formData,setFormData] = useState({
    email:'',
    password:'',
    rememberMe:false
  })

这里通过 useState 定义了 formData 状态,用于统一管理用户输入的数据,包括email、password以及rememberMe

    const [showPassword,setShowPassword] = useState(false);
    const [isLoading,setLoading] = useState(false);

使用另一个状态 showPassword 来控制密码字段的可见性。当该值为 false 时,密码以密文形式显示;切换为 true 时,则以明文展示,提升用户体验,尤其在移动端输入复杂密码时非常实用

此外,还定义了 isLoading 状态,用于表示登录请求是否正在进行中。虽然当前代码中尚未接入实际的 API 调用,但这一状态为未来防止重复提交、显示加载指示器等交互提供了基础支持。

const handleChange =  (e) => {
    const {name,value,type,checked} = e.target;//input
    setFormData((prev) => ({
      ...prev,
      [name]:type === "checkbox" ? checked : value
    }));
  }

表单的输入变化由 handleChange 函数统一处理。

它通过解构事件对象的 name、value、type 和 checked 属性,智能判断当前元素类型:若是复选框(如“记住我”),则取 checked 值;否则取 value。随后,利用函数式更新方式安全地合并新值到 formData 中,确保状态更新的准确性和可维护性。

const handleSubmit = async(e) => {
      e.preventDefault();
    }

表单提交由 handleSubmit 函数接管,其首要任务是调用 e.preventDefault() 阻止浏览器默认的页面跳转或刷新行为

我们在输入框中键入内容时,handleChange 会实时捕获并更新对应状态;点击“登录”按钮时,handleSubmit 被触发,准备发起认证请求;而点击密码框右侧的眼睛图标,则会切换 showPassword 状态,动态改变密码输入框的 type 属性,实现密码的显示与隐藏。

整个流程结构清晰、状态集中、扩展性强,为构建健壮的登录界面打下了良好基础。

image.png


为什么这个登录页“可维护”?

这份代码之所以易于迭代和调试,并非偶然。所有表单数据被统一收纳在 formData 对象中结构清晰,便于追踪状态变化

输入处理逻辑被抽象为通用的 handleChange 函数,无论面对文本输入、密码框还是复选框,都能自动判断类型并更新对应字段,彻底避免了重复代码

UI 层面完全由 Tailwind 的语义化类名描述外观,而 React 状态则专注表达交互行为,两者职责分明、互不耦合。

正因如此,未来的扩展变得异常轻松:若需新增“验证码”字段,只需在状态对象中添加一个属性并绑定到新输入框;若想加入“微信登录”或“Apple 登录”等第三方选项,也只需在现有的 space-y-6 容器中插入一行即可。

这种结构天然支持灵活演进,而非牵一发而动全身。

响应式:使用场景的切换,始终优雅

界面的优雅不仅在于视觉美感,更在于它如何从容应对不同屏幕尺寸。

借助 Tailwind CSS 的响应式断点系统,我们仅用一行 p-8 md:p-10 就实现了内边距的智能适配

在手机上保持紧凑,在中等及以上屏幕则适度舒展。整个登录卡片采用居中布局,搭配柔和的 rounded-3xl 圆角与克制的 shadow-xl 阴影,在 小屏设备上不显拥挤,在 电脑大屏显示器上也依然得体。

而容器宽度 max-w-md 的设定并非随意为之——它落在人眼阅读最舒适的“黄金区间”:太宽会让视线左右扫视疲劳,太窄又显得局促不安。

这个经过验证的尺寸,是功能与美学平衡的结果。

总结

通过这个登录页的实现,我们不仅完成了一个功能完整的 UI 组件,更实践了现代前端开发的核心理念:以用户为中心,用工程化思维打造有温度的体验

借助 React 的状态管理,我们让数据流清晰可控;

利用 Tailwind CSS 的原子化样式,快速构建出响应式、一致且美观的界面;

通过 Lucide React 引入轻量图标,提升视觉引导;而像密码可见性切换、聚焦反馈、加载状态预留等细节,则体现了对用户体验的细致考量。

这不仅仅是一个登录表单——它是产品信任感的起点,是技术与设计的交汇点,也是我们作为开发者传递用心的方式。

代码可以简洁,但体验不能将就。

附录:参考文章以及源码

参考文章

关于如何在 React 项目中安装和配置 Tailwind CSS,可以参考这篇文章: Tailwind CSS 入门指南:从传统 CSS 到原子化开发的高效跃迁

我的源码:

// esm React 代表默认引入
// useState hooks 引入 部分引入
// esm cjs 优秀的地方 懒加载
import {
  useState
} from 'react';
import {
  Eye,
  EyeOff,
  Lock,
  Mail
} from 'lucide-react';
export default function App () {
  
  const [formData,setFormData] = useState({
    email:'',
    password:'',
    rememberMe:false
  })
  // 密码显示隐藏
    const [showPassword,setShowPassword] = useState(false);
    // 登录api等状态
    const [isLoading,setLoading] = useState(false);
  // 抽象的事件处理函数
  // input type="text|password|checkbox"
  // name email|password|rememberMe
  // value 数据状态
  // checked 选中状态
  const handleChange =  (e) => {
    // e.target 
    const {name,value,type,checked} = e.target;//input
    setFormData((prev) => ({
      // 传一个函数比较合适
      ...prev,
      [name]:type === "checkbox" ? checked : value
    }));
  }
   const handleSubmit = async(e) => {
      e.preventDefault();
    }
  return ( 
    <div 
      className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
        <div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border-slate-100 p-8 md:p-10">
          <div className="text-center mb-10">
            <div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-600 text-white mb-4 shadow-lg shadow-indigo-200">
              <Lock size={24}/>
                
            </div>
              <h1 className="text-2xl font-bold text-slate-900">欢迎回来</h1>
              <p className="text-slate-500 mt-2">请登录你的账号</p>

          </div>
          <form onSubmit={handleSubmit} className="space-y-6">
              {/* 邮箱输入框 */}
              <div className='space-y-2'>
                <label className='text-sm font-medium text-slate-700 ml-1'>Email:</label>
                <div className='relative group'>
                  <div className="absolute inset-y-0 left-0 pl-4 
                  flex items-center pointer-events-none 
                  text-slate-400 group-focus-within:text-indigo-600 transition-colors
                  ">
                      <Mail size={18}/>

                  </div>
                  <input 
                   type="email"
                   name="email" 
                   required 
                   value={formData.email} 
                   onChange={handleChange} 
                   placeholder='name@company.com'
                   className='block w-full pl-11 pr-4 py-3 bg-slate-50 
                   border border-slate-200 rounded-xl text-slate-900
                   placeholder:text-slate-400 focus:outline-none
                   focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600
                   transition-all'/>
                </div>
              </div>
              {/* 密码输入框 */}
           <div className="space-y-2">
            <div className="flex justify-between items-center ml-1">
              <label className="text-sm font-medium text-slate-700">密码</label>
              <a href="#" 
              className="text-sm font-medium text-indigo-600 hover:text-indigo-500 
              transition-colors">忘记密码?</a>
            </div>
            <div className="relative group">
              <div className="absolute inset-y-0 left-0 pl-4 
              flex items-center pointer-events-none
              text-slate-400 group-focus-within:text-indigo-600 transition-colors
              "
              >
                <Lock size={18} />
              </div>
              <input 
                type={showPassword ? "text" : "password"} 
                name="password"
                required
                value={formData.password}
                onChange={handleChange}
                placeholder='*******'
                className="block w-full pl-11 pr-4 py-3 bg-slate-50
                border border-slate-200 rounded-xl text-slate-900
                placeholder:text-slate-400 focus:outline-none 
                focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600
                transition-all
                "
              />
              <button
                type="button"
                onClick={() => setShowPassword(!showPassword)}
                className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
              >
                {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
              </button>
            </div>
          </div>
          </form>
        </div>
    </div>
  )
}

聊聊我对 React Hook 不一样的理解

作者 fe小陈
2026年1月7日 17:05

什么是 React Hook

React Hook 是 React 16.8 版本推出的特性,核心作用是让函数组件也能使用状态(State)、生命周期等原本只有类组件才能拥有的 React 特性。它通过一系列预定义的钩子函数(如 useState、useEffect),让开发者无需编写类组件,就能更简洁、灵活地管理组件逻辑,同时也便于逻辑的复用与拆分。

网上有大量的总结文章教会你如何使用 react hook,包括一些诸如取代 mixin 、hoc、类组件继承,所以这不是我想讲的重点。

两面性

Hook的出现不仅是React语法层面的优化,更重塑了函数组件的能力边界与代码组织方式,但也随之引入了新的认知与实践门槛。从核心能力来看,其价值主要体现在三个维度:

1. 逻辑复用的革命性突破:相比类组件时代mixins的命名冲突、HOC的嵌套地狱,Hook通过自定义Hook实现了“逻辑抽取-复用”的极简路径。开发者可将分散在不同生命周期的关联逻辑(如数据请求+加载状态+异常处理)抽离为独立Hook,在多个组件中直接复用,且不存在属性透传或嵌套冗余的问题。

2. 状态与副作用的集中管控:类组件中需分散在componentDidMount、componentDidUpdate、componentWillUnmount的副作用逻辑,在Hook中可通过useEffect统一管理,配合返回函数完成资源清理,实现“关联逻辑聚合”,大幅提升代码可读性。同时,useState、useReducer让函数组件无需依赖this即可实现灵活的状态管理,摆脱了类组件中this指向的诸多陷阱。

3. 更友好的工程化适配:Hook天然契合函数式编程理念,与TypeScript的类型推导无缝兼容,能显著降低强类型项目的开发成本。此外,React 18后续推出的useTransition、useDeferredValue等新Hook,进一步拓展了并发渲染场景下的能力,让函数组件能更好地适配现代前端复杂的性能需求。

但能力的拓展也伴随着新的痛点,这些问题往往源于对Hook设计理念的理解偏差,而非特性本身:

1. 依赖管理的“隐形门槛” :useEffect、useCallback等Hook的依赖数组是最易踩坑的环节。依赖缺失会导致副作用不触发更新,依赖冗余(如未缓存的函数、每次渲染新建的对象)则会引发频繁重渲染,更隐蔽的是“依赖闭环”导致的无限循环(如useEffect中更新state却依赖该state),排查成本极高。

2. 闭包陷阱的高频踩坑:函数组件每次渲染都会创建新的作用域,异步操作(定时器、Promise回调)极易捕获旧作用域的“过期状态”。例如依赖数组为空的useEffect中,定时器始终获取初始state值,这类问题因表象与逻辑预期背离,新手往往难以定位。

3. 副作用清理的隐蔽风险:useEffect的清理函数(返回函数)是避免内存泄漏的关键,但实际开发中常被遗漏(如window事件监听、WebSocket连接未解绑)。尤其在复杂组件中,多个副作用叠加时,清理逻辑的顺序与完整性更难把控,容易引发隐性bug。

4. 复杂场景下的性能优化难题:Hook简化了代码编写,但也容易催生“胖Hook”——一个useEffect包含多个无关副作用逻辑,导致组件耦合度升高。同时,新手常忽视useMemo、useCallback的合理使用,在大数据渲染、深层组件传递函数时,易出现不必要的重渲染,且性能瓶颈难以定位。

限制与规则

React Hook 并非可以随意使用,其设计遵循严格的规则与限制,这些规则是 React 能够稳定管理 Hook 状态关联的核心保障,违反规则可能导致组件渲染异常、状态错乱等难以排查的问题。核心规则与限制主要包括以下几点:

1. 只能在函数组件或自定义 Hook 的顶层调用:这是最核心的规则。Hook 不能嵌套在循环、条件语句(if/else)、switch 语句或嵌套函数内部调用。原因是 React 依靠 Hook 的调用顺序来建立状态与组件的关联,若调用顺序不固定(如条件判断导致某些 Hook 有时执行有时不执行),会破坏 React 对状态的追踪,导致状态错乱。例如:不能在 if (isShow) { useState(0) } 中调用 Hook。

2. 只能在 React 函数中调用 Hook:Hook 仅能用于 React 函数组件(包括箭头函数组件)和自定义 Hook 中,不能在普通的 JavaScript 函数中调用。这是因为 Hook 依赖 React 的内部机制来管理状态和副作用,普通 JS 函数不具备这样的运行环境,调用后无法正常工作。

3. 自定义 Hook 必须以 “use” 开头命名:这是 React 约定的命名规范,并非语法强制要求,但遵循该规范能让 React 识别自定义 Hook,同时让开发者快速区分普通函数与 Hook,避免误用。例如:useRequest(数据请求 Hook)、useWindowSize(监听窗口大小 Hook),若命名为 requestHook 则无法被 React 正确识别为 Hook,也不便于团队协作维护。

4. 状态更新的不可变性限制:使用 useState 或 useReducer 管理引用类型状态(对象、数组)时,必须遵循不可变性原则,不能直接修改原始状态对象(如 state.obj.name = 'new'),而应创建新的对象/数组来更新状态。因为 React 通过浅比较引用是否变化来判断是否需要重新渲染,直接修改原始状态不会改变引用,导致组件无法触发重渲染。

5. 副作用清理的必要性限制:使用 useEffect 管理副作用(如事件监听、定时器、网络连接)时,若副作用会产生内存泄漏风险(如组件卸载后仍执行回调),必须在 useEffect 的返回函数中编写清理逻辑(如移除事件监听、清除定时器、关闭连接)。这是保障组件性能和稳定性的重要限制,忽略清理可能导致内存泄漏、多次触发副作用等问题。

不一样的想法

某些规则是可以打破的

juejin.cn/post/758429…

  1. 只能在函数顶部使用 hook
  2. 条件 hook
  3. 类组件内使用 hook

类组件完全放弃了吗?代价是什么?

在新的项目中,几乎已经看不到类组件被使用(除了手搓 ErrorBoundary)。

但在享受 hook 带来函数式组件魔法的过程中,也引入了许多的问题

  1. 为了防止子组件重渲染,需要对回调函数、数据做 memo(useCallback、useMemo)
  2. 少传个 dep,导致闭包问题、子组件不更新问题
  3. 然后又引入了 React Compiler 、useEventEffect

这就有点为了填一个坑,挖了另一个坑的感觉

类组件是有可取之处的,比如

  1. 回调方法通过 this.state 是可以取到最新的状态的,因此不需要那么多 useCallback useMemo,减少了性能优化的心智负担;

  2. ref 可以直接使用组件的属性,无需像函数组件那样借助 useRef 再手动关联,操作更简洁;

  3. 生命周期逻辑时序更直观:类组件通过 componentDidMount、componentDidUpdate、componentWillUnmount 等明确的钩子划分生命周期阶段,复杂副作用(如多轮数据请求、时序依赖的资源操作)的执行时机更易把控,无需像 useEffect 那样通过依赖数组间接控制;

  4. 状态更新支持自动合并:类组件中 setState 会自动合并对象类型状态的部分属性(如 this.setState({ name: 'new' }) 不会覆盖其他未修改的状态字段),而函数组件 useState 需手动通过扩展运算符(...)实现合并,降低了状态更新的代码复杂度。

但 Hook 在逻辑注入、复用方面相比类组件有绝对的优势。

所以有没有人想过在类组件里面使用 Hook,将两者的优势结合一下? juejin.cn/post/758429…

Hook 作为状态管理的一种方式,却依赖于组件生命周期

想必 React 开发者最头疼的就是状态管理方案了,但是一旦引入了状态管理方案如 redux、zustand,你会直接失去 Hook 的能力。 juejin.cn/post/759172…

原本可以使用 ahooks 的 useRequest 发起请求,迁移到 zustand 直接就是一坨。

没有对比,就真没有伤害。

如果你用过 vue 生态中的 pinia pinia.vuejs.org/zh/cookbook… ,就会知道 pinia 是可以直接复用 vue 的 composition api 以及 VueUse 相关的能力的。

针对这个课题,我也进行了尝试。 juejin.cn/post/759120…

总结

综上,React Hook 绝非完美的“银弹”,而是一把兼具强大能力与使用门槛的“双刃剑”。它以革命性的逻辑复用方式、集中化的状态与副作用管控,以及友好的工程化适配性,重塑了React函数组件的开发模式,成为现代React项目的主流选择。但与此同时,依赖管理难题、闭包陷阱、副作用清理风险等痛点,也让开发者面临更高的认知与实践成本。

关于Hook的规则,并非绝对不可突破,在特定场景下通过合理封装实现动态Hook调用、类组件间接使用Hook等探索,为特殊需求(如旧项目迁移)提供了更多可能,但需警惕代码复杂度提升的风险。而类组件与Hook的取舍之争,本质是开发效率、可维护性与性能之间的权衡——类组件在状态获取、生命周期直观性等方面的优势仍不可忽视,完全放弃可能陷入“为填坑而挖新坑”的循环。

此外,Hook依赖组件生命周期的特性,使其在状态管理场景中存在天然局限,相比Vue Pinia对组合式API的无缝复用能力,仍有优化空间。这也提示我们,不应盲目迷信Hook的“魔法”,而应回归开发本质:既要充分发挥其逻辑复用的核心优势,也要理性看待其不足,结合项目场景(新旧项目、复杂度、团队习惯)灵活选择技术方案,甚至探索类组件与Hook的优势融合路径。最终,技术的价值在于解决问题,对Hook的理解不应局限于“规范用法”,而应基于对其底层逻辑的深刻认知,实现灵活、高效且稳定的开发实践。

React 自定义 Hooks 生存指南:7 个让你少加班的"偷懒"神器

2026年1月7日 17:00

摘要:都 2026 年了,还在写重复代码?还在 useEffect 里疯狂 copy-paste?醒醒,自定义 Hooks 才是现代 React 开发者的"摸鱼"神器。本文手把手教你封装 7 个超实用的自定义 Hooks,从此告别 996,拥抱 WLB。代码即拿即用,CV 工程师狂喜。


引言:一个关于"偷懒"的故事

场景一: 产品经理:"这个搜索框要做防抖。" 你:"好的。"(打开 Google,搜索 "react debounce") 产品经理:"那个页面也要。" 你:"好的。"(再次 copy-paste) 产品经理:"还有这 10 个页面..." 你:(开始怀疑人生)

场景二: 你:"这个表单状态管理写得真优雅。" (三个月后) 你:"这 TM 是谁写的?!" Git blame:"是你自己。" 你:(沉默)

场景三: Code Review 时—— 同事:"这段逻辑我在另外 5 个文件里见过。" 你:"那个...我准备重构的..." 同事:"你三个月前也是这么说的。" 你:(想找个地缝钻进去)

如果你也有类似经历,恭喜你,这篇文章就是为你准备的。

今天,我要分享 7 个超实用的自定义 Hooks,让你:

  • 代码复用率提升 300%
  • 每天少写 200 行重复代码
  • 准时下班不是梦

第一章:自定义 Hooks 的"道"与"术"

1.1 什么是自定义 Hook?

简单说,自定义 Hook 就是一个以 use 开头的函数,里面可以调用其他 Hooks。

// 这就是一个最简单的自定义 Hook
function useMyHook() {
  const [state, setState] = useState(null)

  useEffect(() => {
    // 做一些事情
  }, [])

  return state
}

为什么要用自定义 Hook?

  1. 复用逻辑:同样的逻辑写一次,到处用
  2. 关注点分离:组件只管渲染,逻辑交给 Hook
  3. 更好测试:Hook 可以单独测试
  4. 代码更清晰:组件代码从 500 行变成 50 行

1.2 自定义 Hook 的命名规范

// ✅ 正确:以 use 开头
useLocalStorage()
useDebounce()
useFetch()

// ❌ 错误:不以 use 开头(React 不会识别为 Hook)
getLocalStorage()
debounceValue()
fetchData()

记住:use 开头不是装逼,是 React 识别 Hook 的方式。不这么写,React 的 Hooks 规则检查会失效。


第二章:7 个让你少加班的自定义 Hooks

Hook #1:useLocalStorage —— 本地存储の优雅姿势

痛点: 每次用 localStorage 都要 JSON.parse、JSON.stringify,还要处理 SSR 报错。

解决方案:

import { useState, useEffect, useCallback } from "react"

/**
 * 将状态同步到 localStorage 的 Hook
 * @param {string} key - localStorage 的键名
 * @param {any} initialValue - 初始值
 * @returns {[any, Function, Function]} [存储的值, 设置函数, 删除函数]
 */
function useLocalStorage(key, initialValue) {
  // 获取初始值(惰性初始化)
  const [storedValue, setStoredValue] = useState(() => {
    // SSR 环境下 window 不存在
    if (typeof window === "undefined") {
      return initialValue
    }

    try {
      const item = window.localStorage.getItem(key)
      // 如果存在则解析,否则返回初始值
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
      return initialValue
    }
  })

  // 设置值的函数
  const setValue = useCallback(
    (value) => {
      try {
        // 支持函数式更新
        const valueToStore =
          value instanceof Function ? value(storedValue) : value
        setStoredValue(valueToStore)

        if (typeof window !== "undefined") {
          window.localStorage.setItem(key, JSON.stringify(valueToStore))
        }
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    },
    [key, storedValue]
  )

  // 删除值的函数
  const removeValue = useCallback(() => {
    try {
      setStoredValue(initialValue)
      if (typeof window !== "undefined") {
        window.localStorage.removeItem(key)
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error)
    }
  }, [key, initialValue])

  return [storedValue, setValue, removeValue]
}

export default useLocalStorage

使用示例:

function App() {
  // 就像 useState 一样简单!
  const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light")
  const [user, setUser] = useLocalStorage("user", null)

  return (
    <div className={`app ${theme}`}>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        切换主题:{theme}
      </button>

      <button onClick={() => setUser({ name: "张三", age: 25 })}>登录</button>

      <button onClick={removeTheme}>重置主题</button>

      {user && <p>欢迎,{user.name}!</p>}
    </div>
  )
}

为什么这个 Hook 香?

  • 自动处理 JSON 序列化/反序列化
  • 支持 SSR(不会报 window is not defined)
  • 支持函数式更新(和 useState 一样)
  • 提供删除功能

Hook #2:useDebounce —— 防抖の终极方案

痛点: 搜索框输入时,每敲一个字就发请求,服务器直接被你打爆。

解决方案:

import { useState, useEffect } from "react"

/**
 * 防抖 Hook:延迟更新值,避免频繁触发
 * @param {any} value - 需要防抖的值
 * @param {number} delay - 延迟时间(毫秒)
 * @returns {any} 防抖后的值
 */
function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    // 设置定时器
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    // 清理函数:值变化时清除上一个定时器
    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

export default useDebounce

使用示例:

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState("")
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  // 防抖处理:用户停止输入 500ms 后才触发
  const debouncedSearchTerm = useDebounce(searchTerm, 500)

  useEffect(() => {
    if (debouncedSearchTerm) {
      setLoading(true)
      // 模拟 API 请求
      fetch(`/api/search?q=${debouncedSearchTerm}`)
        .then((res) => res.json())
        .then((data) => {
          setResults(data)
          setLoading(false)
        })
    } else {
      setResults([])
    }
  }, [debouncedSearchTerm]) // 只在防抖值变化时触发

  return (
    <div>
      <input
        type='text'
        placeholder='搜索...'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {loading && <p>搜索中...</p>}

      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

进阶版:带回调的防抖

import { useCallback, useRef, useEffect } from "react"

/**
 * 防抖函数 Hook:返回一个防抖处理后的函数
 * @param {Function} callback - 需要防抖的回调函数
 * @param {number} delay - 延迟时间(毫秒)
 * @returns {Function} 防抖后的函数
 */
function useDebouncedCallback(callback, delay = 500) {
  const timeoutRef = useRef(null)
  const callbackRef = useRef(callback)

  // 保持 callback 最新
  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  // 清理定时器
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
    }
  }, [])

  const debouncedCallback = useCallback(
    (...args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }

      timeoutRef.current = setTimeout(() => {
        callbackRef.current(...args)
      }, delay)
    },
    [delay]
  )

  return debouncedCallback
}

// 使用示例
function SearchWithCallback() {
  const [results, setResults] = useState([])

  const handleSearch = useDebouncedCallback((term) => {
    console.log("搜索:", term)
    // 发起请求...
  }, 500)

  return (
    <input
      type='text'
      onChange={(e) => handleSearch(e.target.value)}
      placeholder='输入搜索...'
    />
  )
}

Hook #3:useFetch —— 数据请求の瑞士军刀

痛点: 每个组件都要写 loading、error、data 三件套,烦死了。

解决方案:

import { useState, useEffect, useCallback, useRef } from "react"

/**
 * 数据请求 Hook
 * @param {string} url - 请求地址
 * @param {object} options - fetch 选项
 * @returns {object} { data, loading, error, refetch }
 */
function useFetch(url, options = {}) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  // 用 ref 存储 options,避免无限循环
  const optionsRef = useRef(options)
  optionsRef.current = options

  const fetchData = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const response = await fetch(url, optionsRef.current)

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

      const result = await response.json()
      setData(result)
    } catch (err) {
      setError(err.message || "请求失败")
    } finally {
      setLoading(false)
    }
  }, [url])

  useEffect(() => {
    fetchData()
  }, [fetchData])

  // 手动重新请求
  const refetch = useCallback(() => {
    fetchData()
  }, [fetchData])

  return { data, loading, error, refetch }
}

export default useFetch

使用示例:

function UserProfile({ userId }) {
  const {
    data: user,
    loading,
    error,
    refetch,
  } = useFetch(`https://jsonplaceholder.typicode.com/users/${userId}`)

  if (loading) return <div className='skeleton'>加载中...</div>
  if (error) return <div className='error'>错误:{error}</div>
  if (!user) return null

  return (
    <div className='user-profile'>
      <h2>{user.name}</h2>
      <p>📧 {user.email}</p>
      <p>📱 {user.phone}</p>
      <p>🏢 {user.company?.name}</p>

      <button onClick={refetch}>刷新数据</button>
    </div>
  )
}

进阶版:支持缓存和自动重试

import { useState, useEffect, useCallback, useRef } from "react"

// 简单的内存缓存
const cache = new Map()

/**
 * 增强版数据请求 Hook
 * @param {string} url - 请求地址
 * @param {object} config - 配置项
 */
function useFetchAdvanced(url, config = {}) {
  const {
    enabled = true, // 是否启用请求
    cacheTime = 5 * 60 * 1000, // 缓存时间(默认 5 分钟)
    retry = 3, // 重试次数
    retryDelay = 1000, // 重试延迟
    onSuccess, // 成功回调
    onError, // 失败回调
  } = config

  const [state, setState] = useState({
    data: null,
    loading: enabled,
    error: null,
  })

  const retryCountRef = useRef(0)

  const fetchData = useCallback(async () => {
    // 检查缓存
    const cached = cache.get(url)
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      setState({ data: cached.data, loading: false, error: null })
      return
    }

    setState((prev) => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      const data = await response.json()

      // 存入缓存
      cache.set(url, { data, timestamp: Date.now() })

      setState({ data, loading: false, error: null })
      retryCountRef.current = 0
      onSuccess?.(data)
    } catch (err) {
      // 重试逻辑
      if (retryCountRef.current < retry) {
        retryCountRef.current++
        console.log(
          `请求失败,${retryDelay}ms 后重试 (${retryCountRef.current}/${retry})`
        )
        setTimeout(fetchData, retryDelay)
        return
      }

      setState({ data: null, loading: false, error: err.message })
      onError?.(err)
    }
  }, [url, cacheTime, retry, retryDelay, onSuccess, onError])

  useEffect(() => {
    if (enabled) {
      fetchData()
    }
  }, [enabled, fetchData])

  return { ...state, refetch: fetchData }
}

Hook #4:useToggle —— 布尔值の优雅切换

痛点: setIsOpen(!isOpen) 写了 100 遍,手都酸了。

解决方案:

import { useState, useCallback } from "react"

/**
 * 布尔值切换 Hook
 * @param {boolean} initialValue - 初始值
 * @returns {[boolean, Function, Function, Function]} [值, 切换, 设为true, 设为false]
 */
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => setValue((v) => !v), [])
  const setTrue = useCallback(() => setValue(true), [])
  const setFalse = useCallback(() => setValue(false), [])

  return [value, toggle, setTrue, setFalse]
}

export default useToggle

使用示例:

function Modal() {
  const [isOpen, toggle, open, close] = useToggle(false)
  const [isDarkMode, toggleDarkMode] = useToggle(false)

  return (
    <div className={isDarkMode ? "dark" : "light"}>
      <button onClick={toggleDarkMode}>
        {isDarkMode ? "🌙" : "☀️"} 切换主题
      </button>

      <button onClick={open}>打开弹窗</button>

      {isOpen && (
        <div className='modal-overlay' onClick={close}>
          <div className='modal' onClick={(e) => e.stopPropagation()}>
            <h2>我是弹窗</h2>
            <p>点击遮罩层或按钮关闭</p>
            <button onClick={close}>关闭</button>
          </div>
        </div>
      )}
    </div>
  )
}

Hook #5:useClickOutside —— 点击外部关闭の神器

痛点: 下拉菜单、弹窗点击外部关闭,每次都要写一堆事件监听。

解决方案:

import { useEffect, useRef } from "react"

/**
 * 点击元素外部时触发回调
 * @param {Function} callback - 点击外部时的回调函数
 * @returns {React.RefObject} 需要绑定到目标元素的 ref
 */
function useClickOutside(callback) {
  const ref = useRef(null)

  useEffect(() => {
    const handleClick = (event) => {
      // 如果点击的不是 ref 元素内部,则触发回调
      if (ref.current && !ref.current.contains(event.target)) {
        callback(event)
      }
    }

    // 使用 mousedown 而不是 click,响应更快
    document.addEventListener("mousedown", handleClick)
    document.addEventListener("touchstart", handleClick)

    return () => {
      document.removeEventListener("mousedown", handleClick)
      document.removeEventListener("touchstart", handleClick)
    }
  }, [callback])

  return ref
}

export default useClickOutside

使用示例:

function Dropdown() {
  const [isOpen, setIsOpen] = useState(false)

  // 点击下拉菜单外部时关闭
  const dropdownRef = useClickOutside(() => {
    setIsOpen(false)
  })

  return (
    <div className='dropdown-container' ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        选择选项 {isOpen ? "▲" : "▼"}
      </button>

      {isOpen && (
        <ul className='dropdown-menu'>
          <li onClick={() => setIsOpen(false)}>选项 1</li>
          <li onClick={() => setIsOpen(false)}>选项 2</li>
          <li onClick={() => setIsOpen(false)}>选项 3</li>
        </ul>
      )}
    </div>
  )
}

进阶:支持多个 ref

import { useEffect, useRef, useCallback } from "react"

/**
 * 支持多个元素的点击外部检测
 * @param {Function} callback - 点击外部时的回调
 * @returns {Function} 返回一个函数,调用它获取 ref
 */
function useClickOutsideMultiple(callback) {
  const refs = useRef([])

  const addRef = useCallback((element) => {
    if (element && !refs.current.includes(element)) {
      refs.current.push(element)
    }
  }, [])

  useEffect(() => {
    const handleClick = (event) => {
      const isOutside = refs.current.every(
        (ref) => ref && !ref.contains(event.target)
      )

      if (isOutside) {
        callback(event)
      }
    }

    document.addEventListener("mousedown", handleClick)
    return () => document.removeEventListener("mousedown", handleClick)
  }, [callback])

  return addRef
}

// 使用示例:弹窗 + 触发按钮都不算"外部"
function PopoverWithTrigger() {
  const [isOpen, setIsOpen] = useState(false)
  const addRef = useClickOutsideMultiple(() => setIsOpen(false))

  return (
    <>
      <button ref={addRef} onClick={() => setIsOpen(!isOpen)}>
        触发按钮
      </button>

      {isOpen && (
        <div ref={addRef} className='popover'>
          点击这里不会关闭
        </div>
      )}
    </>
  )
}

Hook #6:usePrevious —— 获取上一次的值

痛点: 想对比新旧值做一些操作,但 React 不给你上一次的值。

解决方案:

import { useRef, useEffect } from "react"

/**
 * 获取上一次渲染时的值
 * @param {any} value - 当前值
 * @returns {any} 上一次的值
 */
function usePrevious(value) {
  const ref = useRef()

  useEffect(() => {
    ref.current = value
  }, [value])

  // 返回上一次的值(在 useEffect 更新之前)
  return ref.current
}

export default usePrevious

使用示例:

function Counter() {
  const [count, setCount] = useState(0)
  const prevCount = usePrevious(count)

  return (
    <div>
      <p>当前值:{count}</p>
      <p>上一次:{prevCount ?? "无"}</p>
      <p>
        变化趋势:
        {prevCount !== undefined &&
          (count > prevCount
            ? "📈 上升"
            : count < prevCount
            ? "📉 下降"
            : "➡️ 不变")}
      </p>

      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
    </div>
  )
}

实际应用:检测 props 变化

function UserProfile({ userId }) {
  const prevUserId = usePrevious(userId)
  const [user, setUser] = useState(null)

  useEffect(() => {
    // 只有当 userId 真正变化时才重新请求
    if (userId !== prevUserId) {
      console.log(`用户 ID 从 ${prevUserId} 变为 ${userId}`)
      fetchUser(userId).then(setUser)
    }
  }, [userId, prevUserId])

  return <div>{user?.name}</div>
}

Hook #7:useMediaQuery —— 响应式の优雅方案

痛点: CSS 媒体查询很方便,但 JS 里想根据屏幕尺寸做逻辑判断就麻烦了。

解决方案:

import { useState, useEffect } from "react"

/**
 * 媒体查询 Hook
 * @param {string} query - CSS 媒体查询字符串
 * @returns {boolean} 是否匹配
 */
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    // SSR 环境下返回 false
    if (typeof window === "undefined") return false
    return window.matchMedia(query).matches
  })

  useEffect(() => {
    if (typeof window === "undefined") return

    const mediaQuery = window.matchMedia(query)

    // 初始化
    setMatches(mediaQuery.matches)

    // 监听变化
    const handler = (event) => setMatches(event.matches)

    // 现代浏览器用 addEventListener
    if (mediaQuery.addEventListener) {
      mediaQuery.addEventListener("change", handler)
      return () => mediaQuery.removeEventListener("change", handler)
    } else {
      // 兼容旧浏览器
      mediaQuery.addListener(handler)
      return () => mediaQuery.removeListener(handler)
    }
  }, [query])

  return matches
}

export default useMediaQuery

使用示例:

function ResponsiveComponent() {
  const isMobile = useMediaQuery("(max-width: 768px)")
  const isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)")
  const isDesktop = useMediaQuery("(min-width: 1025px)")
  const prefersDark = useMediaQuery("(prefers-color-scheme: dark)")

  return (
    <div className={prefersDark ? "dark-theme" : "light-theme"}>
      {isMobile && <MobileNav />}
      {isTablet && <TabletNav />}
      {isDesktop && <DesktopNav />}

      <main>
        <p>
          当前设备:{isMobile ? "📱 手机" : isTablet ? "📱 平板" : "💻 桌面"}
        </p>
        <p>主题偏好:{prefersDark ? "🌙 深色" : "☀️ 浅色"}</p>
      </main>
    </div>
  )
}

封装常用断点:

// hooks/useBreakpoint.js
import useMediaQuery from "./useMediaQuery"

export function useBreakpoint() {
  const breakpoints = {
    xs: useMediaQuery("(max-width: 575px)"),
    sm: useMediaQuery("(min-width: 576px) and (max-width: 767px)"),
    md: useMediaQuery("(min-width: 768px) and (max-width: 991px)"),
    lg: useMediaQuery("(min-width: 992px) and (max-width: 1199px)"),
    xl: useMediaQuery("(min-width: 1200px)"),
  }

  // 返回当前断点名称
  const current =
    Object.entries(breakpoints).find(([, matches]) => matches)?.[0] || "xs"

  return {
    ...breakpoints,
    current,
    isMobile: breakpoints.xs || breakpoints.sm,
    isTablet: breakpoints.md,
    isDesktop: breakpoints.lg || breakpoints.xl,
  }
}

// 使用
function App() {
  const { isMobile, isDesktop, current } = useBreakpoint()

  return (
    <div>
      <p>当前断点:{current}</p>
      {isMobile ? <MobileLayout /> : <DesktopLayout />}
    </div>
  )
}

第三章:Hooks 组合の艺术

3.1 组合多个 Hooks 解决复杂问题

场景: 一个带搜索、分页、缓存的列表组件

import { useState, useEffect, useMemo } from "react"

// 组合使用多个自定义 Hooks
function useSearchableList(fetchFn, options = {}) {
  const { pageSize = 10, debounceMs = 300 } = options

  // 搜索关键词
  const [searchTerm, setSearchTerm] = useState("")
  const debouncedSearch = useDebounce(searchTerm, debounceMs)

  // 分页
  const [page, setPage] = useState(1)

  // 数据请求
  const { data, loading, error, refetch } = useFetch(
    `${fetchFn}?search=${debouncedSearch}&page=${page}&pageSize=${pageSize}`
  )

  // 搜索时重置页码
  const prevSearch = usePrevious(debouncedSearch)
  useEffect(() => {
    if (prevSearch !== undefined && prevSearch !== debouncedSearch) {
      setPage(1)
    }
  }, [debouncedSearch, prevSearch])

  // 计算总页数
  const totalPages = useMemo(() => {
    return data?.total ? Math.ceil(data.total / pageSize) : 0
  }, [data?.total, pageSize])

  return {
    // 数据
    items: data?.items || [],
    total: data?.total || 0,
    loading,
    error,

    // 搜索
    searchTerm,
    setSearchTerm,

    // 分页
    page,
    setPage,
    totalPages,
    hasNextPage: page < totalPages,
    hasPrevPage: page > 1,

    // 操作
    refetch,
    nextPage: () => setPage((p) => Math.min(p + 1, totalPages)),
    prevPage: () => setPage((p) => Math.max(p - 1, 1)),
  }
}

// 使用示例
function UserList() {
  const {
    items,
    loading,
    error,
    searchTerm,
    setSearchTerm,
    page,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
  } = useSearchableList("/api/users", { pageSize: 20 })

  return (
    <div className='user-list'>
      <input
        type='text'
        placeholder='搜索用户...'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {loading && <div className='loading'>加载中...</div>}
      {error && <div className='error'>{error}</div>}

      <ul>
        {items.map((user) => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>

      <div className='pagination'>
        <button onClick={prevPage} disabled={!hasPrevPage}>
          上一页
        </button>
        <span>
          {page} / {totalPages}
        </span>
        <button onClick={nextPage} disabled={!hasNextPage}>
          下一页
        </button>
      </div>
    </div>
  )
}

3.2 创建 Hook 工厂

场景: 多个表单都需要类似的验证逻辑

/**
 * 表单验证 Hook 工厂
 * @param {object} validationRules - 验证规则
 * @returns {Function} 返回一个自定义 Hook
 */
function createFormValidation(validationRules) {
  return function useFormValidation(initialValues = {}) {
    const [values, setValues] = useState(initialValues)
    const [errors, setErrors] = useState({})
    const [touched, setTouched] = useState({})

    // 验证单个字段
    const validateField = (name, value) => {
      const rules = validationRules[name]
      if (!rules) return ""

      for (const rule of rules) {
        if (rule.required && !value) {
          return rule.message || "此字段必填"
        }
        if (rule.minLength && value.length < rule.minLength) {
          return rule.message || `最少 ${rule.minLength} 个字符`
        }
        if (rule.maxLength && value.length > rule.maxLength) {
          return rule.message || `最多 ${rule.maxLength} 个字符`
        }
        if (rule.pattern && !rule.pattern.test(value)) {
          return rule.message || "格式不正确"
        }
        if (rule.validate && !rule.validate(value, values)) {
          return rule.message || "验证失败"
        }
      }
      return ""
    }

    // 验证所有字段
    const validateAll = () => {
      const newErrors = {}
      let isValid = true

      Object.keys(validationRules).forEach((name) => {
        const error = validateField(name, values[name] || "")
        if (error) {
          newErrors[name] = error
          isValid = false
        }
      })

      setErrors(newErrors)
      return isValid
    }

    // 处理输入变化
    const handleChange = (name) => (e) => {
      const value = e.target ? e.target.value : e
      setValues((prev) => ({ ...prev, [name]: value }))

      // 实时验证已触碰的字段
      if (touched[name]) {
        const error = validateField(name, value)
        setErrors((prev) => ({ ...prev, [name]: error }))
      }
    }

    // 处理失焦
    const handleBlur = (name) => () => {
      setTouched((prev) => ({ ...prev, [name]: true }))
      const error = validateField(name, values[name] || "")
      setErrors((prev) => ({ ...prev, [name]: error }))
    }

    // 重置表单
    const reset = () => {
      setValues(initialValues)
      setErrors({})
      setTouched({})
    }

    return {
      values,
      errors,
      touched,
      handleChange,
      handleBlur,
      validateAll,
      reset,
      isValid: Object.keys(errors).length === 0,
      getFieldProps: (name) => ({
        value: values[name] || "",
        onChange: handleChange(name),
        onBlur: handleBlur(name),
      }),
    }
  }
}

// 创建登录表单验证 Hook
const useLoginForm = createFormValidation({
  email: [
    { required: true, message: "请输入邮箱" },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
  ],
  password: [
    { required: true, message: "请输入密码" },
    { minLength: 6, message: "密码至少 6 位" },
  ],
})

// 创建注册表单验证 Hook
const useRegisterForm = createFormValidation({
  username: [
    { required: true, message: "请输入用户名" },
    { minLength: 3, message: "用户名至少 3 个字符" },
    { maxLength: 20, message: "用户名最多 20 个字符" },
  ],
  email: [
    { required: true, message: "请输入邮箱" },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
  ],
  password: [
    { required: true, message: "请输入密码" },
    { minLength: 8, message: "密码至少 8 位" },
    {
      pattern: /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      message: "需包含大小写字母和数字",
    },
  ],
  confirmPassword: [
    { required: true, message: "请确认密码" },
    {
      validate: (value, values) => value === values.password,
      message: "两次密码不一致",
    },
  ],
})

// 使用示例
function LoginForm() {
  const { values, errors, touched, getFieldProps, validateAll } = useLoginForm()

  const handleSubmit = (e) => {
    e.preventDefault()
    if (validateAll()) {
      console.log("提交:", values)
      // 发起登录请求...
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div className='form-group'>
        <input type='email' placeholder='邮箱' {...getFieldProps("email")} />
        {touched.email && errors.email && (
          <span className='error'>{errors.email}</span>
        )}
      </div>

      <div className='form-group'>
        <input
          type='password'
          placeholder='密码'
          {...getFieldProps("password")}
        />
        {touched.password && errors.password && (
          <span className='error'>{errors.password}</span>
        )}
      </div>

      <button type='submit'>登录</button>
    </form>
  )
}

第四章:避坑指南

4.1 常见错误 #1:在条件语句中调用 Hook

// ❌ 错误:条件调用 Hook
function BadComponent({ shouldFetch }) {
  if (shouldFetch) {
    const data = useFetch("/api/data") // 💥 报错!
  }
  return <div>...</div>
}

// ✅ 正确:Hook 始终调用,用参数控制行为
function GoodComponent({ shouldFetch }) {
  const { data } = useFetch("/api/data", { enabled: shouldFetch })
  return <div>...</div>
}

4.2 常见错误 #2:忘记依赖项

// ❌ 错误:缺少依赖项,callback 永远是旧的
function BadHook(callback) {
  useEffect(() => {
    window.addEventListener("resize", callback)
    return () => window.removeEventListener("resize", callback)
  }, []) // callback 变了也不会更新!
}

// ✅ 正确:使用 ref 保持最新引用
function GoodHook(callback) {
  const callbackRef = useRef(callback)

  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  useEffect(() => {
    const handler = (...args) => callbackRef.current(...args)
    window.addEventListener("resize", handler)
    return () => window.removeEventListener("resize", handler)
  }, [])
}

4.3 常见错误 #3:闭包陷阱

// ❌ 错误:count 永远是 0
function BadCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count) // 永远打印 0
      setCount(count + 1) // 永远设置为 1
    }, 1000)
    return () => clearInterval(timer)
  }, []) // 空依赖,count 被闭包捕获

  return <div>{count}</div>
}

// ✅ 正确:使用函数式更新
function GoodCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => c + 1) // 函数式更新,不依赖外部 count
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  return <div>{count}</div>
}

4.4 常见错误 #4:无限循环

// ❌ 错误:每次渲染都创建新对象,导致无限循环
function BadComponent() {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch("/api/data")
      .then((res) => res.json())
      .then(setData)
  }, [{ page: 1 }]) // 每次都是新对象!无限循环!

  return <div>{data}</div>
}

// ✅ 正确:使用原始值或 useMemo
function GoodComponent() {
  const [data, setData] = useState(null)
  const page = 1

  useEffect(() => {
    fetch(`/api/data?page=${page}`)
      .then((res) => res.json())
      .then(setData)
  }, [page]) // 原始值,不会无限循环

  return <div>{data}</div>
}

写在最后:Hook 的哲学

自定义 Hooks 不只是代码复用的工具,更是一种思维方式:

1. 关注点分离

  • 组件负责"长什么样"(UI)
  • Hook 负责"怎么工作"(逻辑)

2. 组合优于继承

  • 小而专注的 Hook 可以自由组合
  • 比 HOC 和 Render Props 更灵活

3. 声明式思维

  • 描述"要什么",而不是"怎么做"
  • useDebounce(value, 500) 比手写 setTimeout 清晰 100 倍

最后,送你一句话:

"好的代码不是写出来的,是删出来的。"

当你发现自己在 copy-paste 时,就是该写自定义 Hook 的时候了。


💬 互动时间:你在项目中封装过哪些好用的自定义 Hooks?评论区分享一下,让大家一起"偷懒"!

觉得这篇文章有用?点赞 + 在看 + 转发,让更多 React 开发者早点下班~


本文作者是一个靠自定义 Hooks 实现准时下班的前端开发。关注我,一起用更少的代码,写更好的应用。

AI 流式对话该怎么做?SSE、fetch、axios 一次讲清楚

作者 Sailing
2026年1月7日 16:56

在做 AI 对话产品 时,很多人都会遇到一个问题:

为什么有的实现能像 ChatGPT 一样逐字输出,而有的只能“等半天一次性返回”?

问题的核心,往往不在模型,而在 前后端的流式通信方式

本文从实战出发,系统讲清楚 SSE、fetch、axios 在 AI 流式对话中的本质区别与选型建议

先给结论(重要)

AI 流式对话的正确打开方式:

  • ✅ 首选:fetch + ReadableStream
  • ✅ 可选:SSE(EventSource)
  • ❌ 不推荐:axios

如果你现在用的是 axios,还在纠结“为什么没有逐 token 输出”,可以直接往下看结论部分。

AI 流式对话的本质需求

在传统接口中,请求和响应通常是这样的:

请求 → 等待 → 返回完整结果

但 AI 对话不是。

AI 流式对话的真实需求是:

  • 模型 逐 token 生成
  • 前端 边接收、边渲染
  • 连接可持续数十秒
  • 用户能感知“正在思考 / 正在输出”

这决定了:必须支持真正的 HTTP 流式响应

SSE、fetch、axios 的本质区别

在对比之前,先明确一个容易混淆的点:

1、SSE 是「协议能力」

SSE(Server-Sent Events) 是一种 基于 HTTP 的流式推送协议

  • Content-Type: text/event-stream
  • 服务端可以不断向客户端推送数据
  • 浏览器原生支持 EventSource

它解决的是:“服务端如何持续推送数据”

2、fetch / axios 是「请求工具」

工具 本质
fetch 浏览器原生 HTTP API
axios 对 XHR / fetch 的封装库

它们解决的是:“前端如何发请求、拿响应”

常用流式方案

SSE:最简单的流式方案

const es = new EventSource('/api/chat/stream')

es.onmessage = (e) => {
  console.log(e.data)
}

优点

  • ✅ 原生支持流式
  • ✅ 自动重连
  • ✅ 心跳、事件类型清晰
  • ✅ 非常适合 AI 单向输出

缺点(关键)

  • ❌ 只支持 GET
  • ❌ 不能自定义 Header(鉴权不友好)
  • ❌ 只能 服务端 → 客户端

适合场景:AI 回答输出推理过程 / 日志流实时通知类数据

fetch + ReadableStream(推荐)

这是目前 AI 产品中最主流、最灵活的方案

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

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

while (true) {
  const { value, done } = await reader.read()
  if (done) break
  const chunk = decoder.decode(value)
  console.log(chunk)
}

为什么它是首选?

  • ✅ 支持 POST(可传 prompt、上下文)
  • ✅ 可自定义 Header(token、traceId)
  • ✅ 真正的 chunk / token 级流式
  • ✅ 与 OpenAI / Claude 接口完全一致
  • ✅ Web / Node / Edge Runtime 通用

一句话总结fetch + stream 是目前 AI 流式对话的标准

axios:为什么不适合 AI 流式?

这是很多人踩坑最多的地方。

常见误解

axios.post('/api/chat', data, {
  onDownloadProgress(e) {
    console.log(e)
  }
})

看起来像“流式”,但实际上 axios 的真实问题

  • 浏览器端基于 XHR
  • 响应会被 缓冲
  • onDownloadProgress 不是 token 级回调
  • 延迟明显、体验差

结论:axios 在浏览器端 不支持真正的流式响应

它更适合普通 REST API、表单提交、数据请求,但 不适合 AI 流式输出

总结

方案 真流式 POST Header 推荐度
SSE (EventSource) ⭐⭐⭐
fetch + stream ⭐⭐⭐⭐⭐
axios
  • SSE 是流式协议
  • fetch 是流式容器
  • axios 是传统请求工具

如果你正在做 AI 产品,通信层选错,后面再怎么优化模型和前端体验,都会事倍功半。

React 性能优化的“卧龙凤雏”:useMemo 与 useCallback 到底该怎么用

2026年1月7日 16:55

在 React 的世界里,组件的渲染就像一场“牵一发而动全身”的多米诺骨牌。父组件打个喷嚏(State 变了),底下的子组件全得跟着感冒(重新渲染)。

虽然 React 够快,但如果你的组件里住着一只“吞金兽”(昂贵的计算逻辑),或者你的子组件是个“强迫症”(非要 Props 完全没变才肯不渲染),那你就得请出 React 性能优化的两尊大神了:useMemouseCallback

很多人分不清它俩,其实很简单:

  • useMemo 缓存的是结果(脑子转完产出的东西)。
  • useCallback 缓存的是函数本身(干活的工具)。

今天咱们就拿一段真实的代码,扒一扒这俩货到底怎么帮我们省资源。


useMemo:给你的组件装个“缓存大脑”

想象一下,你有一个超级复杂的数学题要算(比如从 0 加到 1000 万)。

优化前:笨笨的复读机

看这段代码,我们有一个 slowSum 函数,它模拟了一个耗时的计算过程:

JavaScript

// 昂贵的计算:模拟 CPU 密集型任务
function slowSum(n) {
  console.log('🔥 疯狂计算中...');
  let sum = 0;
  // 假装这里跑了很久
  for (let i = 0; i < n * 10000000; i++) {
    sum += i;
  }
  return sum;
}

export default function App() {
  const [count, setCount] = useState(0); // 这个 state 和计算毫无关系
  const [num, setNum] = useState(1);     // 这个 state 才是计算需要的

  // 😱 灾难现场:
  // 只要组件重新渲染(比如你点击了 count+1),这行代码就会重新跑一遍!
  const result = slowSum(num); 

  return (
    <>
      <p>计算结果:{result}</p>
      {/* 点击这里,slowSum 居然也会执行?! */}
      <button onClick={() => setCount(count + 1)}>Count + 1 (无辜路人)</button> 
      <button onClick={() => setNum(num + 1)}>Num + 1 (正主)</button>
    </>
  )
}

痛点:当你点击 Count + 1 时,明明 num 没变,结果也没变,但 React 重新执行组件函数,slowSum 又傻乎乎地跑了一遍。页面卡顿随之而来。

优化后:学会“偷懒”

这时候 useMemo 就登场了。它像一个记性很好的会计,只有当依赖项(账本)变了,它才重新算。

JavaScript

// ✅ 智能缓存
const result = useMemo(() => {
  return slowSum(num);
}, [num]); // 👈 只有当 num 变了,才重新跑里面的函数

现在你再疯狂点击 Count + 1,控制台不会再打印“计算中...”,页面丝般顺滑。

场景二:代替 Vue 的 Computed

除了昂贵计算,useMemo 也是处理派生状态的神器,类似于 Vue 里的 computed

比如这里有一个过滤列表的需求:

JavaScript

const [keyword, setKeyword] = useState('');
const list = ['apple', 'banana', 'orange', 'pear'];

// 如果不用 useMemo:
// 每次组件渲染(比如 count 变了),filter 都会重新遍历数组。
// 虽然这里数组小看不出性能损耗,但如果是大数据列表,这就是性能杀手。

const filterList = useMemo(() => {
  // 只有关键词变了,我才重新过滤
  return list.filter(item => item.includes(keyword));
}, [keyword]);

(注:includes('') 默认为 true,所以初始状态会显示所有水果,完美符合搜索逻辑。)


useCallback + memo:父子组件的“定情信物”

接下来聊聊 useCallback。很多人觉得:“我不就传个函数给子组件吗,为啥要包一层?”

这得从 JavaScript 的特性说起。

优化前:最熟悉的陌生人

父组件给子组件传 Props,子组件用 React.memo 包裹,本来是想做性能优化(Props 不变就不重新渲染)。但是...

JavaScript

// 子组件:使用了 memo,理论上 Props 不变我就不渲染
const Child = memo(({ handleClick }) => {
  console.log('👶 Child 重新渲染了 (我不想这样)');
  return <div onClick={handleClick}>子组件</div>
});

export default function App() {
  const [count, setCount] = useState(0);

  // 😱 问题出在这里:
  // 每次 App 重新渲染,handleClick 都会被重新定义!
  // 在 JS 里,function A() {} !== function A() {}
  // 引用地址变了 -> memo 认为 Props 变了 -> 子组件被迫渲染
  const handleClick = () => {
    console.log('click');
  }

  return (
    <div>
      {/* 我改了 count,跟 Child 半毛钱关系没有,但 Child 还是渲染了 */}
      <button onClick={() => setCount(count + 1)}>Count + 1</button>
      <Child handleClick={handleClick} />
    </div>
  )
}

痛点React.memo 就像一个严格的保安,它对比 Props 是否变化用的是“浅比较”(引用对比)。因为父组件每次渲染都生成一个新的函数地址,保安觉得:“这函数换人了!” 于是放行,导致子组件无意义渲染。

优化后:给函数发个“身份证”

useCallback 的作用就是把这个函数“固化”下来。

JavaScript

// ✅ 保持函数引用稳定
const handleClick = useCallback(() => {
  console.log('click');
}, []); // 依赖为空,说明这个函数永远是同一个引用地址

现在,当你点击 Count + 1 时,父组件重渲染了,但 handleClick 还是原来那个 handleClickChild 组件发现 Props 没变,就安心地躺平不渲染了。

注意:如果你需要在回调里用到 count,记得把它加进依赖数组:

JavaScript

const handleClick = useCallback(() => {
  // 如果依赖数组里没写 count,这里永远打印 0 (闭包陷阱)
  console.log('click', count); 
}, [count]); 
// 👆 一旦 count 变了,函数引用还是会变,Child 还是会渲染。
// 这是为了保证逻辑正确性必须付出的代价。

总结

别为了优化而优化。useMemouseCallback 也是有成本的(它们本身也需要消耗内存来做依赖对比)。

请遵循这套“心法”:

  1. useMemo

    • 昂贵计算:当你看到 for 循环次数巨大,或者复杂的递归时。
    • 引用稳定:当你计算出的对象/数组,要作为 useEffect 的依赖项,或者传给被 memo 包裹的子组件时。
  2. useCallback

    • 配合 React.memo:当你的函数需要传给一个“很重”的子组件,且该子组件被 memo 包裹时。
    • 作为 Hooks 依赖:当这个函数要被用作 useEffect 的依赖项时。

聊聊那个让 React 新手抓狂的“闭包陷阱”:Count 为什么永远是 0?

2026年1月7日 16:48

写 React Hooks 的时候,你有没有遇到过这种“灵异事件”:

明明天在这个组件里 setCount 已经加到飞起了,界面上的数字也在跳动,但是 setInterval 或者是 useEffect 里的 console.log 打印出来的,却永远是初始值 0

这时候你会怀疑人生:“是我眼花了,还是 React 坏了?”

其实 React 没坏,你只是掉进了**“闭包陷阱” (Stale Closure)**。今天咱们就借一段简单的代码,扒一扒这个坑的底裤,顺便看看怎么优雅地爬出来。

案发现场:诡异的“时间冻结”

让我们先看看这段经典的“受害者”代码。这是很多同学(包括刚开始写 Hooks 的我)都会写出的逻辑:

JavaScript

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  // ❌ 这是一个典型的闭包陷阱现场
  useEffect(() => {
    const timer = setInterval(() => {
      // 这里的 count 永远是 0,仿佛时间被冻结了
      console.log('Current count:', count); 
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 👈 罪魁祸首在这里:空依赖数组

  return (
    <>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>count + 1</button>
    </>
  );
}

现象描述

当你运行这段代码,点击按钮让 count 增加时:

  1. 界面(UI) :显示 1, 2, 3... (正常更新,说明 State 确实变了)。
  2. 控制台(Console)Current count: 0 ... Current count: 0 ... (像复读机一样)。

image.png

为什么会这样?

要理解这个问题,首先要修正一个心智模型:每一次渲染(Render),都是一次独立的“快照”。

  1. 第一次渲染 (Mount)

    • React 创建了组件,此时 count = 0
    • useEffect 执行。因为它依赖是 [],所以它只在第一次渲染时执行
    • setInterval 被创建。关键点来了: 这个定时器的回调函数是在 count0 的那个闭包作用域里定义的。它捕获了那一刻的 count(也就是 0)。
  2. 第二次渲染 (点击按钮后)

    • React 再次执行组件函数,count 变成了 1
    • 但是! useEffect 的依赖数组是空的,React 认为“没必要重新运行这个 Effect”。
    • 于是,那个旧的定时器(Mount 时创建的)依然在坚强地活着。它手里紧紧攥着的,依然是第一次渲染时的旧变量 0

简单来说:你的组件 UI 已经活在 2026 年了,但那个定时器还活在 2023 年,它根本不知道外面的世界变了。这就是 JS 词法作用域与 React Hooks 机制碰撞出的“火花”。


怎么爬出陷阱?

既然知道了是因为“引用了旧变量”,那想要实现如下图片效果,思路就很清晰了:要么让 Effect 重新执行,要么用某种方式穿透闭包。

image.png

方法一:诚实地告诉 React 你的依赖(官方推荐)

这就是修复后的代码逻辑,也是最符合 React 数据流直觉的写法:

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    // ✅ 此时能读到最新的 count
    console.log('Current count:', count);
  }, 1000);

  // 每次 effect 重新执行之前 都会执行上一次的清理函数
  return () => clearInterval(timer);
}, [count]); // 👈 把 count 加入依赖数组

原理分析: 一旦把 [count] 加入依赖数组,逻辑就变了:

  1. count 变了 -> useEffect 发现依赖变了。
  2. React 先执行 cleanup 函数(clearInterval),杀掉旧的定时器。
  3. React 执行新的 useEffect,创建一个的定时器。
  4. 这个定时器是在当前渲染闭包里创建的,所以它捕获的是最新count

潜在问题: 虽然 Bug 修好了,但带来了性能抖动。如果 count 变化很快(比如动画),定时器会被频繁地 创建 -> 销毁 -> 创建。如果定时器间隔很短,这可能会导致计时不准。


方法二:函数式更新

如果你只是想让 count 加 1,而不关心在 setInterval 里打印日志,可以用函数式更新:

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    // ✅ prev 永远是 React 内部拿到的最新状态,不需要依赖 count
    setCount(prev => prev + 1); 
  }, 1000);
  return () => clearInterval(timer);
}, []); 

这能解决 UI 更新问题,但解决不了“在定时器里获取最新值打印”的问题。


方法三:终极大法 useRef

如果你既不想让定时器频繁重启(保持依赖为 []),又想在回调里拿到最新的值,useRef 是最佳选择。

为什么? 因为 useRef 返回的 ref 对象在组件的整个生命周期内保持引用不变,但它的 .current 属性是可变的。这就像一个挂在墙上的白板,无论房间(闭包)怎么换,白板还是那一块,上面的字随时能改。

JavaScript

// 1. 创建一个 ref
const countRef = useRef(count);

// 2. 每次渲染都把最新的 count 写入 ref
// 这一步确保 ref.current 永远是最新的
countRef.current = count; 

useEffect(() => {
  const timer = setInterval(() => {
    // 3. ✅ 永远读取 ref 里的最新值
    // 这里的闭包引用的是 countRef 对象本身,这个对象是永远不变的
    console.log('Current count:', countRef.current);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 👈 依赖依然是空,定时器稳如泰山,不会重启!

这也是知名 Hooks 库 ahooksuseInterval 的核心实现原理。


总结

React 闭包陷阱本质上是 JavaScript 闭包机制React 声明式编程 之间的一种“沟通误会”。

  • 陷阱成因useEffectuseCallback 等 Hooks 的依赖数组写少了,导致内部函数引用了旧的渲染闭包中的变量。
  • 基础解法:补全依赖数组(但要注意副作用的频繁执行)。
  • 进阶解法:使用 useRef 作为“逃生舱”,在不重启 Effect 的情况下,透过闭包读取最新状态。

历时1年,TinyEditor v4.0 正式发布!

2026年1月7日 16:32

本文由体验技术团队Kagol原创。

TinyEditor 是一个基于 Quill 2.0 的富文本编辑器,在 Quill 基础上扩展了丰富的模块和格式,框架无关、功能强大、开箱即用。

去年1月2日,我们发布了 v3.25 版本,功能基本已经完备,之后 v3.x 版本进入了维护期,同时开启了漫长的 v4.0 版本的开发,v4.0 的核心目标是体验优化和稳定性提升,并支持多人协同编辑。

在长达1年的开发和打磨后,我们荣幸地宣布 TinyEditor v4.0 正式发布!这个版本汇聚了团队的心血,带来了激动人心多人协同编辑新功能、以及大量体验优化和稳定性改进。

重点特性:

  • 支持多人协同编辑:一起在编辑器写(玩)文档(贪吃蛇游戏摸鱼)🐶
  • 基于 quill-table-up 的新表格方案:表格操作体验++⚡️
  • 基于 emoji-mart 的 Emoji 表情:表情党最爱😍
  • 支持斜杆菜单和丰富的快捷键:键盘流的福音😄
  • 图片/视频/文件上传体验优化🌄

详细的 Release Notes 请参考:github.com/opentiny/ti…

欢迎安装 v4.0 版本体验:

npm i @opentiny/fluent-editor@4.0.0

1 亮点特性

1.1 多人协作编辑

v4.0 最重磅的功能之一是引入了完整的协作编辑能力。我们集成了 quill-cursor 模块,支持多人实时协作编辑,并提供了独立的 npm 包供开发者集成。无论是需要离线支持还是云端协作,TinyEditor 都能胜任。

你可以在我们的演示项目中进行体验:opentiny.github.io/tiny-editor…

效果如下:

1.JPG

关于协同编辑更详细的介绍,参考:如何使用 TinyEditor 快速部署一个多人协同富文本编辑器?

1.2 表格能力升级

集成了 table-up 模块,大幅提升了表格编辑和操作能力,支持更复杂的表格场景。

体验地址:opentiny.github.io/tiny-editor…

效果如下:

2.gif

详细介绍可以参考之前的文章: TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!

1.3 更丰富的 Emoji 表情😘

  • 集成 emoji-mart,提供丰富的表情选择
  • 修复了插入表情后的光标位置问题
  • 完善了表情插入的交互体验

体验地址:opentiny.github.io/tiny-editor…

效果如下:

3.gif

详细介绍可以参考之前的文章:TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!

1.4 快捷键和快速菜单

新增了强大的快捷键系统和快速菜单功能,让高级用户能够更高效地操作编辑器。

体验地址:opentiny.github.io/tiny-editor…

效果如下:

4.png

1.5 颜色选择器升级

自定义颜色选择器现在能保存当前选择,并支持添加更多颜色。

效果如下:

5.png

1.6 文本模板与国际化

  • 支持 i18n 文本模板替换
  • 完善了国际化翻译(header、picker 等组件)
  • 更好的多语言支持体验

1.7 图片和文件增强

  • 图片工具栏:选中图片时显示专门的操作工具栏
  • 自定义上传:增加 allowInvalidUrl 选项,支持 Electron 等特定场景
  • 改进的上传逻辑:优化了失败状态的处理

2 技术改进

2.1 构建和工程化

  • 修复了 SSR 构建问题
  • 优化了 Vite 配置,解决了 PostCSS 和 Tailwind 的兼容性问题
  • 改进了 SCSS 文件引入方式
  • 输出文件名称优化

2.2 依赖管理

  • 外部化 emoji-mart 和 floating-ui 依赖,减少包体积
  • 移除了 better-table 和 lodash-es,优化依赖树

2.3 代码质量

  • 完整的测试覆盖率提升
  • 重构优化:移除冗余代码
  • API 标准化:scrollIntoView → scrollSelectionIntoView
  • 示例代码 async/await 改造,代码现代化

2.4 类型安全

  • 修复了因 TypeScript 类型导致的编译错误
  • 改进了类型定义

2.5 API 导出增强

v4.0 导出了工具栏配置常量,方便开发者定制:

  • DEFAULT_TOOLBAR:默认工具栏配置
  • FULL_TOOLBAR:完整工具栏配置

2.6 增加自动发包工作流

  • 增加 auto-publish / auto-deploy 等自动化工作流,支持打 tag 之后自动发版本、生成 Release Notes
  • PR 门禁在单元测试基础上增加 npm 包和网站构建,确保合入 PR 之前,npm 包构建和网站构建是正常的,通过自动化方式保障版本质量。

3 问题修复

v4.0 修复了大量已知问题,包括:

  • 工具栏选择器不跟随光标变化的问题
  • 行高作用域问题
  • 列表样式显示不正确
  • 背景色 SVG 图标问题
  • VitePress 默认样式影响的问题
  • 自定义上传失败时表格数据结构破坏的问题
  • 多项文档和国际化翻译问题

4 社区贡献

感谢所有为 v4.0 做出贡献的开发者!你们的辛勤付出让 TinyEditor 变得更好!

  • @chenxi-20
  • @GaoNeng-wWw
  • @jany55555
  • @qwangry
  • @shenyaofeng
  • @vaebe
  • @wuyiping0628
  • @Yinlin124
  • @zzxming

注:排名不分先后,按名字首字母排序。

如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系。

往期推荐文章

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

深入理解react——1. jsx与虚拟dom

作者 time_rg
2026年1月7日 16:10

通过课程和博客学习react,能够应付平时的开发工作。但到了面试等环节,对于fiber,setState的同步异步问题,说是知道,但往往朝深处一问,结合实际做一些输出题,脑袋里往往没有清晰的脉络,所以我决定自己实现一份miniReact,提升自己对react的理解。

本文大部分内容都是从历史好文build your own react中参考借鉴,这确实是我看到的最好的学习react的文章,在这里表示感谢。

地址:pomb.us/build-your-…

准备工作

首先新启一个项目

npm init
npm i vite

简单配置vite.config.js

新建入口文件index.html,引入index.js

现在我们的准备工作就完成了。

一,jsx

jsx是一个语法糖,在编译后其实是使用了createElement函数。所以我们第一步就是实现createElement用于创建虚拟dom。我们miniReact只关心部分使用到的属性,不做完全详尽的处理。

(面试点,为什么老版本的react需要在顶部引入react,而新版本不需要)

现在,我们先从hello world开始

const rootDOM = document.getElementById("root");
const element = createElement("div", null, "hello world");
render(element, rootDOM);

接下来我们需要依次实现createElement用于创建虚拟dom,以及render用于将虚拟dom渲染到界面上。

简化版的虚拟dom需要三个参数,分别是type,props,以及children。

1.1 createElement

const createElement = (type, props, ...children) => {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) => (typeof child === "object" ? child : createTextElement(child))),
    },
  };
};

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}

1.2 render

function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}

恭喜我们完成了第一步,成功的将一个虚拟dom渲染到了界面上,虽然简单,但是开始比什么都重要!

1.3 测试

接下来让我们做一些简单的测试

const elementList = Array.from({ length: 100 }, (_, i) => {
  const key = `Item-${i}`;
  return createElement("li", { key }, key);
});
const element = createElement("ul", null, ...elementList);
render(element, rootDOM);

将渲染的dom给得多一些,就可以看到很明显的卡顿,在此期间界面没法操作,这就是react fiber架构要解决的主要问题。

React 性能优化之道:useMemo、useCallback 与闭包陷阱的深度剖析

作者 不会js
2026年1月7日 16:01

React 性能优化之道:useMemo、useCallback 与闭包陷阱的深度剖析

大家好,今天,我们来聊聊 React 中那些让人又爱又恨的性能优化工具——useMemo 和 useCallback,以及隐藏在背后的闭包陷阱。作为一名 React 开发者,你是否曾经遇到过这样的场景:组件明明只改了一个状态,却导致整个页面重新渲染,性能像漏气的轮胎一样瘪了下去?或者,你在 useEffect 里设置了一个定时器,结果它永远捕捉不到最新的状态值,像个固执的守门员,只认旧球不认新球?这篇文章将基于 React 的核心机制,带你一步步拆解这些问题。

第一部分:React 性能优化的痛点与必要性

想象一下,你在建造一座摩天大楼(你的 React 应用)。每当大楼里的一个房间(组件)需要装修时,整个大楼都要停工重刷一遍油漆?这听起来多荒谬!但在 React 的默认行为中,这就是现实:组件函数每次渲染都会重新执行,导致不必要的计算和子组件重绘。为什么会这样?因为 React 的渲染是“响应式”的——状态变化触发重新渲染,以确保 UI 与数据同步。但这种“全量渲染”在复杂应用中会带来性能开销,比如昂贵的计算重复执行,或子组件无谓刷新。

性能优化的核心在于“惰性”:只在必要时计算,只在 props 变化时重绘。React Hooks 提供了 useMemo 和 useCallback 来实现这一点,它们就像大楼的“智能电梯”,只在特定楼层停靠,避免无谓的上下奔波。同时,我们还要警惕闭包陷阱——它像大楼里的“幽灵通道”,悄无声息地捕捉旧值,导致逻辑出错。

第二部分:useMemo —— 缓存计算结果的“懒汉守护者”

useMemo 的诞生背景与核心概念

在 Vue 中,我们有 computed 计算属性,它像个聪明的管家,只在依赖变化时重新计算。React 没有内置 computed,但 useMemo 就是它的“DIY 版”。useMemo 的本质是“记忆化”(Memoization):缓存昂贵计算的结果,避免重复劳动。

为什么需要它?考虑一个场景:你有一个列表,需要根据搜索关键词过滤。每次状态变化(哪怕无关),过滤函数都会重跑。如果列表有上万项,那就太浪费了!

useMemo 的 API 很简单:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
  • 第一个参数:一个返回计算结果的函数。
  • 第二个参数:依赖数组。只有数组中的值变化时,函数才会重执行。

底层逻辑:useMemo 利用 Hooks 的内部存储(fiber.hooks),在渲染间存储上次的计算结果和依赖。如果依赖浅比较(===)不变,就直接返回缓存值。这避免了组件重渲染时的重复计算。

扩展知识点:useMemo 不是“防抖”或“节流”,它针对纯计算。昂贵计算的例子包括:大数据排序、复杂数学运算(如斐波那契数列递归)、或处理 API 数据(如聚合统计)。

实战示例:从痛点到优化

来看一个示例。我们有一个列表 ['apple', 'banana', 'orange', 'pear'],需要根据 keyword 过滤。同时,有一个 count 状态和一个昂贵的 slowSum 计算。

原始代码(痛点版):

const filterList = list.filter(item => item.includes(keyword)); // 每次渲染都重跑
const result = slowSum(num); // 模拟昂贵计算,每次都 console.log('计算中...')

问题:count 变化时,filterList 和 slowSum 都会重执行,尽管它们不依赖 count。这导致性能浪费,尤其 slowSum 循环上百万次!

优化版(使用 useMemo):

import { useState, useMemo } from "react";

function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 1000000; i++) {
    sum += i;
  }
  return sum;
}

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const [num, setNum] = useState(0);
  const list = ['apple', 'banana', 'orange', 'pear'];

  const filterList = useMemo(() => {
    console.log('filter 执行'); // 只在 keyword 变时执行
    return list.filter(item => item.includes(keyword));
  }, [keyword]); // 依赖 keyword

  const result = useMemo(() => slowSum(num), [num]); // 只在 num 变时重算

  return (
    <div>
      <p>结果: {result}</p>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <input type="text" value={keyword} onChange={e => setKeyword(e.target.value)} />
      {count}
      <button onClick={() => setCount(count + 1)}>count+1</button>
      {filterList.map(item => <li key={item}>{item}</li>)}
    </div>
  );
}

现在,点击 count+1 时,filterList 和 result 不会重跑!控制台只在 keyword 或 num 变时打印日志。

易错提醒:

  1. 依赖数组漏写:如果忘了 [keyword],useMemo 只跑一次,keyword 变也不会更新——像个“失忆的管家”。
  2. 过度依赖:数组中放对象/数组时,浅比较失效(因为新对象 !== 旧对象)。解决:用 useMemo 缓存对象,或用 lodash 的 deepEqual(但不推荐,增加开销)。
  3. 返回值类型:useMemo 可以缓存任何值,包括 JSX!如 const memoizedJSX = useMemo(() => <HeavyComponent />, [deps]); 用于优化虚拟 DOM 生成。
  4. 性能陷阱:useMemo 本身有开销(比较依赖 + 存储)。只用于真正昂贵的计算。测试工具:用 React DevTools 的 Profiler 测量渲染时间。

扩展:useMemo vs useEffect。useEffect 是“副作用钩子”,适合异步操作;useMemo 是同步计算钩子。useMemo 返回值直接用在渲染中,而 useEffect 不返回。

第三部分:useCallback —— 缓存函数的“稳定器”

useCallback 的核心与 React 渲染机制

React 的数据流是单向的:父组件持数据,子组件渲染。子组件用 React.memo 包裹时,会浅比较 props。如果 props 不变,子组件跳过渲染。但问题来了:函数 props(如 onClick)每次渲染都是新函数(因为组件函数重执行),导致 === 失败,子组件总重绘!

useCallback 解决这个:缓存函数引用。只有依赖变时,才返回新函数。

API:

const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);

底层逻辑:类似 useMemo,但专为函数。Hooks 存储上次的函数和依赖,依赖不变时返回相同引用。

扩展:为什么函数引用重要?因为 JavaScript 函数是对象,每次定义都是新实例。React.memo 的浅比较依赖 ===,新函数总触发重绘。

实战示例:父子组件优化

原始痛点:父组件有 count 和 num,子组件依赖 count 和 handleClick。但 handleClick 每次新生成,导致 Child 总重绘。

优化版:

import { useState, memo, useCallback } from "react";

const Child = memo(({ count, handleClick }) => {
  console.log('child重新渲染'); // 只在 count 或依赖变时打印
  return (
    <div onClick={handleClick}>
      子组件 {count}
    </div>
  );
});

export default function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const handleClick = useCallback(() => {
    console.log('click');
  }, [count]); // 如果依赖 count,count 变时返回新函数

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>count+1</button>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  );
}

点击 num+1 时,Child 不重绘!因为 handleClick 引用稳定。

易错提醒:

  1. 空依赖 []:函数永不更新,但如果函数内用闭包捕获变量,会导致“陈旧值”问题(详见闭包陷阱)。
  2. 过度使用:useCallback 缓存函数,但如果子组件不 memo,就没必要。记住:优化是针对瓶颈的。
  3. 与 useMemo 的区别:useCallback 是 useMemo 的特化版,等价于 useMemo(() => fn, deps)。但 useCallback 更语义化。
  4. 事件处理:onClick 等常依赖状态。如果不放依赖,函数捕获旧状态;放了,函数引用变,子组件重绘。权衡:如果子组件不昂贵,优先正确性。

扩展:高级用法——useCallback 在列表渲染中缓存 item 的 onClick,避免每个 item 新函数。结合 useImperativeHandle,可优化 ref 转发。

第四部分:React 闭包陷阱 —— 隐藏的“幽灵捕手”

闭包的形成与 React 中的陷阱

闭包是 JavaScript 的核心:函数记住其词法作用域。即使外部函数结束,闭包仍持有变量引用。

在 React,Hooks 如 useEffect、useCallback、useMemo 会形成闭包。因为它们在组件函数(外部作用域)中定义,捕获当前渲染的状态。

陷阱场景:useEffect([]) 只跑一次,捕获初始状态。后续状态变,effect 内看不到——像个“时间胶囊”,永远封存旧值。

为什么?因为依赖数组决定“ freshness”:空数组意味着“永不更新闭包”。

实战示例:定时器中的闭包陷阱

痛点版:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('current count', count); // 永远打印 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖,只捕获初始 count=0

问题:定时器闭包捕获初始 count,状态变也不更新。

优化版:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('current count', count);
  }, 1000);
  return () => clearInterval(timer); // 每次 count 变,清旧定时器,新建
}, [count]); // 依赖 count,闭包更新

现在,count 变时,effect 重跑,闭包捕获新值。但注意:这会创建多个定时器?不!返回函数先清旧的。

易错提醒:

  1. 忘记依赖: ESLint 的 react-hooks/exhaustive-deps 会警告,但别盲目加——理解后再加。
  2. 无限循环:如果 effect 内 setState,且依赖该 state,会循环。解决:用函数式更新 setCount(c => c + 1),不依赖当前值。
  3. useRef 逃脱陷阱:用 ref.current 存储可变值,不受闭包影响。如 const countRef = useRef(count); 在 effect 内更新 ref。
  4. 事件处理函数:onClick 内用状态,如果是 useCallback([]),捕获旧值。解决:依赖状态,或用 ref。

扩展底层逻辑:React Hooks 用链表存储(fiber.hooks)。每个 Hook 有 memoizedState 和 updateQueue。依赖比较用 Object.is(类似 ===)。闭包陷阱本质是词法作用域 + 渲染隔离:每个渲染是独立的“快照”。

高级避免:用 useReducer 集中状态逻辑,或自定义 Hooks 封装闭包。

第五部分:彻底分清“三兄弟”

用最直白的话总结它们的区别:

名字 本质是什么 缓存的是什么 主要解决什么问题 使用位置
useMemo Hook 任意值的计算结果(数字、字符串、对象、数组、甚至 JSX) 避免重复执行昂贵的计算 组件函数内部
useCallback Hook 函数本身(函数引用) 避免每次渲染都创建一个新函数 组件函数内部
React.memo 高阶组件(HOC) 整个组件的渲染结果 避免 props 没变时子组件无谓重渲染 组件定义外面(包裹组件)

虽然它们为什么“长得像”,但其实干的活完全不一样。

1. useMemo:缓存“值”的计算结果

核心目的:我有一个很贵的计算,只想在它真正依赖的东西变化时才重新算一遍。

const expensiveValue = useMemo(() => {
  console.log('我在做很贵的计算...');
  return heavyComputation(a, b);  // 比如大数据过滤、排序、数学运算
}, [a, b]);  // 只有 a 或 b 变了,才重新算
  • 缓存的是 heavyComputation 的返回值(一个值)。
  • 每次渲染时,如果 [a, b] 没变,就直接返回上次的缓存值,不执行函数。
  • 典型场景:过滤列表、计算衍生数据、处理复杂对象。

记住:useMemo 是“懒汉”,它懒得重复算值。

2. useCallback:缓存“函数”本身

核心目的:我定义了一个函数,每次渲染都会重新创建一个新函数,但我不想这样,因为新函数会导致子组件误以为 props 变了而重渲染。

const handleClick = useCallback(() => {
  console.log('点击了', count);
  // 做点事
}, [count]);  // 只有 count 变了,才返回一个新函数
  • 缓存的是 函数引用(也就是 handleClick 这个变量本身)。

  • 如果依赖 [count] 没变,它永远返回同一个函数实例(=== 相同)。

  • 为什么需要这个?因为 JavaScript 里这样写:

    jsx

    const handleClick = () => { ... }
    

    每次组件渲染都会创建一个全新的函数对象,即使代码一模一样。

典型场景:把函数作为 props 传给子组件,尤其是子组件被 React.memo 包裹时。

记住:useCallback 是“稳定器”,它稳定函数的引用,防止子组件误以为 props 变了。

小知识:useCallback 其实是 useMemo 的特例!它等价于:

jsx

const handleClick = useMemo(() => () => { ... }, [count]);

React 单独给它起了个名字,就是因为这个场景太常见了。

3. React.memo:缓存“整个组件”的渲染

核心目的:这个子组件渲染很贵,但它的 props 经常没变,父组件重渲染时我不想让它也跟着重渲染。

const Child = React.memo(function Child({ data, onClick }) {
  console.log('Child 渲染了');  // 只有 props 真的变了才会打印
  return <div>复杂的 UI</div>;
});
  • 缓存的是 组件上一次的渲染结果(虚拟 DOM 树)。
  • React 会自动浅比较新旧 props,如果完全一样(===),就直接复用上次渲染的结果,完全跳过这个组件的函数执行
  • 它不关心你里面用了什么 Hook,只看 props。

记住:React.memo 是“门卫”,它守着子组件的大门,只有 props 真正变了才放行渲染。

为什么感觉他们“太像了”?

因为它们都用了“记忆化”(memoization)这个思想: “如果输入没变,就别重新干活,直接用上次的结果。”

  • useMemo:输入是依赖数组,输出是值 → 记忆值
  • useCallback:输入是依赖数组,输出是函数 → 记忆函数
  • React.memo:输入是 props,输出是渲染结果 → 记忆组件渲染

经典组合拳

// 1. 子组件用 memo 包裹,防止无谓渲染
const Child = React.memo(function Child({ data, onClick }) {
  return <ExpensiveUI data={data} onClick={onClick} />;
});

// 2. 父组件里
function Parent() {
  const [count, setCount] = useState(0);
  const [filter, setFilter] = useState('');

  // 用 useMemo 缓存计算结果(稳定 data 对象引用)
  const filteredData = useMemo(() => {
    return bigList.filter(item => item.includes(filter));
  }, [filter]);

  // 用 useCallback 缓存函数(稳定 onClick 引用)
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);  // 用函数式更新避免依赖 count

  return (
    <div>
      <Child data={filteredData} onClick={handleClick} />
    </div>
  );
}

这样:

  • filteredData 引用稳定 → Child 的 data props 稳定
  • handleClick 引用稳定 → Child 的 onClick props 稳定
  • Child 被 memo 包裹 → props 没变就不渲染 完美优化!

总结口诀(背下来就行)

  • useMemo:缓存,防重复计算
  • useCallback:缓存函数,防引用变化
  • React.memo:缓存组件,防无谓渲染

三兄弟各司其职,配合起来天下无敌!

结语:从优化到 mastery

通过 useMemo 和 useCallback,我们让 React 像精密仪器一样高效;避开闭包陷阱,则让逻辑如丝般顺滑。记住,React 的美在于响应式,但优化是艺术——平衡正确性和性能。

Three.js 高性能天气效果实现:下雨与下雪的 GPU 粒子系统

作者 niconicoC
2026年1月7日 15:55

本文介绍如何使用 Three.js 的 InstancedMesh + 自定义 Shader 实现高性能的下雨和下雪效果,支持数万级粒子、无限循环、广告牌效果等特性。

效果预览

20260107_154756.gif

实现的天气效果具有以下特性:

  • 高性能 - 基于 GPU Instancing,支持 30000+ 雪花 / 50000+ 雨滴
  • 🔄 无限循环 - 粒子围绕相机循环,无视觉边界
  • 📷 广告牌效果 - 粒子始终面向相机
  • 🎨 可配置 - 支持数量、速度、大小、颜色等参数动态调节

核心技术选型

为什么选择 InstancedMesh?

传统粒子系统(Points)的问题:

  • 粒子只能是正方形点
  • 无法实现复杂形状(如拉长的雨滴)
  • 难以实现自定义着色

InstancedMesh 优势

  • 单次 Draw Call 渲染数万实例
  • 每个实例可以有独立的位置、缩放、旋转
  • 支持完全自定义的 Shader
// 创建 InstancedMesh
const geometry = new THREE.PlaneGeometry(0.2, 0.2);
const material = new THREE.ShaderMaterial({ /* 自定义 Shader */ });
const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);

下雪效果实现 ❄️

1. 实例属性设计

每个雪花需要独立的属性:

// 为每个雪花实例分配随机属性
const aOffset = new Float32Array(MAX_COUNT * 3);  // 初始位置偏移
const aSpeed = new Float32Array(MAX_COUNT);       // 下落速度
const aSwayFreq = new Float32Array(MAX_COUNT);    // 摇摆频率
const aSwayAmp = new Float32Array(MAX_COUNT);     // 摇摆幅度
const aScale = new Float32Array(MAX_COUNT);       // 大小缩放

for (let i = 0; i < MAX_COUNT; i++) {
  // 随机分布在 100x50x100 的空间内
  aOffset[i * 3] = (Math.random() - 0.5) * 100;     // X
  aOffset[i * 3 + 1] = (Math.random() - 0.5) * 50;  // Y
  aOffset[i * 3 + 2] = (Math.random() - 0.5) * 100; // Z
  
  aSpeed[i] = 2.0 + Math.random() * 3.0;
  aSwayFreq[i] = 1.0 + Math.random() * 2.0;
  aSwayAmp[i] = 0.5 + Math.random() * 1.0;
  aScale[i] = 0.5 + Math.random() * 1.0;
}

// 绑定为实例属性
geometry.setAttribute('aOffset', new THREE.InstancedBufferAttribute(aOffset, 3));
geometry.setAttribute('aSpeed', new THREE.InstancedBufferAttribute(aSpeed, 1));
// ...

2. 顶点着色器:无限循环

核心逻辑在顶点着色器中实现:

uniform float uTime;
uniform float uHeight;        // 垂直范围
uniform float uRange;         // 水平范围
uniform vec3 uCameraPosition; // 相机位置
uniform float uSizeScale;
uniform float uSpeedScale;

attribute float aSpeed;
attribute float aSwayFreq;
attribute float aSwayAmp;
attribute vec3 aOffset;
attribute float aScale;

void main() {
  vec3 pos = aOffset;
  
  // 1. 动态下落(Y轴)
  float timeOffsetY = uTime * aSpeed * uSpeedScale;
  
  // 2. 无限循环:使用 mod 让粒子围绕相机循环
  pos.x = mod(aOffset.x - uCameraPosition.x, uRange) - uRange * 0.5 + uCameraPosition.x;
  pos.z = mod(aOffset.z - uCameraPosition.z, uRange) - uRange * 0.5 + uCameraPosition.z;
  pos.y = mod(aOffset.y - timeOffsetY - uCameraPosition.y, uHeight) - uHeight * 0.5 + uCameraPosition.y;

  // 3. 水平摇摆(模拟风吹雪花飘动)
  pos.x += sin(uTime * aSwayFreq + aOffset.y) * aSwayAmp;
  pos.z += cos(uTime * aSwayFreq + aOffset.x) * aSwayAmp;

  // 4. 广告牌效果:让平面始终面向相机
  vec4 mvPosition = viewMatrix * modelMatrix * vec4(pos, 1.0);
  mvPosition.xyz += position * aScale * uSizeScale;  // position 是平面的局部坐标

  gl_Position = projectionMatrix * mvPosition;
}

关键技巧解读

  1. mod 取模运算 - 让粒子在固定范围内循环,超出边界自动回到另一侧
  2. 相机位置跟随 - 循环范围始终以相机为中心,玩家移动时雪花跟随
  3. viewMatrix 应用 - 直接在视图空间中偏移顶点,实现广告牌效果

3. 片元着色器:圆形雪花 + 边缘渐隐

uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
varying float vAlpha;

void main() {
  // 计算到中心的距离,生成圆形
  float dist = distance(vUv, vec2(0.5));
  float alpha = smoothstep(0.5, 0.3, dist);

  if (alpha < 0.01) discard;

  // 应用边缘渐隐和全局透明度
  gl_FragColor = vec4(uColor, alpha * vAlpha * uOpacity);
}

边缘渐隐在顶点着色器中计算:

// 距离相机越远,alpha 越低
float fadeLimit = uRange * 0.45;
vAlpha = smoothstep(fadeLimit, fadeLimit * 0.8, abs(pos.x - uCameraPosition.x));
vAlpha *= smoothstep(fadeLimit, fadeLimit * 0.8, abs(pos.z - uCameraPosition.z));

下雨效果实现 🌧️

下雨效果在雪的基础上增加了折射效果,让雨滴更真实。

1. 雨滴形状

雨滴是细长的矩形,通过缩放实现:

dummy.scale.set(0.02, THREE.MathUtils.randFloat(0.4, 0.8), 0.02);

2. 背景折射(FBO 技术)

要实现雨滴的透明折射效果,需要:

  1. 先渲染背景到 RenderTarget(FBO)
  2. 雨滴采样 FBO 并偏移 UV 模拟折射
// 创建低分辨率 FBO(性能优化)
this.bgFBO = new THREE.WebGLRenderTarget(
  canvas.width * 0.1,  // 10% 分辨率
  canvas.height * 0.1
);

// 渲染循环中
preRender() {
  // 1. 隐藏雨滴
  this.instancedMesh.visible = false;
  
  // 2. 渲染场景到 FBO
  this.renderer.setRenderTarget(this.bgFBO);
  this.renderer.render(this.scene, this.camera);
  this.renderer.setRenderTarget(null);
  
  // 3. 恢复雨滴,并传入 FBO 纹理
  this.instancedMesh.visible = true;
  this.rainMaterial.uniforms.uBgRt.value = this.bgFBO.texture;
}

3. 雨滴 Shader 中的折射计算

// 顶点着色器:计算屏幕空间坐标
vec2 screenspace(mat4 proj, mat4 mv, vec3 pos) {
  vec4 temp = proj * mv * vec4(pos, 1.0);
  temp.xyz /= temp.w;
  temp.xy = 0.5 + temp.xy * 0.5;  // [-1,1] -> [0,1]
  return temp.xy;
}

varying vec2 vScreenspace;  // 传递给片元着色器
vScreenspace = screenspace(projectionMatrix, viewMatrix, finalPos);
// 片元着色器:采样背景并偏移
uniform sampler2D uBgRt;
uniform float uRefraction;

void main() {
  // 计算雨滴法线(模拟圆柱形水滴)
  vec3 normal = vec3((vUv.x - 0.5) * 2.0, 0.0, 0.5);
  normal = normalize(normal);
  
  // 根据法线偏移 UV,模拟折射
  vec2 bgUv = vScreenspace + normal.xy * uRefraction;
  vec4 bgColor = texture2D(uBgRt, bgUv);
  
  // 添加高光和蓝色调
  float brightness = 0.8 * pow(max(0.0, normal.z), 4.0);
  vec3 col = bgColor.rgb + vec3(brightness);
  col += vec3(0.0, 0.05, 0.1) * alpha;  // 蓝色调
  
  gl_FragColor = vec4(col, alpha);
}

性能优化要点

1. InstancedMesh 复用

预分配最大数量的实例,通过 mesh.count 控制实际渲染数量:

const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);
mesh.count = currentCount;  // 动态调整,无需重建

2. 低分辨率 FBO

雨效折射只需要模糊的背景信息,使用 10% 分辨率的 FBO 大幅降低性能开销。

3. 禁用视锥剔除

粒子系统的包围盒难以准确计算,直接禁用避免闪烁:

mesh.frustumCulled = false;

4. GPU 端计算

所有位置更新、循环判断都在 Shader 中完成,CPU 只传递时间和相机位置。


封装为可复用模块

最终将天气效果封装为独立的管理器类:

class SnowManager {
  constructor(scene, camera) {
    this.scene = scene;
    this.camera = camera;
    this._init();
  }
  
  setEnabled(enabled, config) { /* ... */ }
  updateConfig(config) { /* ... */ }
  update(time) {
    this.material.uniforms.uTime.value = time * 0.001;
    this.material.uniforms.uCameraPosition.value.copy(this.camera.position);
  }
  dispose() { /* 清理资源 */ }
}

使用方式:

const snowManager = new SnowManager(scene, camera);

// 开启下雪
snowManager.setEnabled(true, { count: 10000, speed: 1.0 });

// 在动画循环中更新
function animate(time) {
  snowManager.update(time);
  renderer.render(scene, camera);
}

总结

技术点 实现方式
高性能渲染 InstancedMesh + GPU Instancing
无限循环 mod 取模 + 相机位置跟随
广告牌效果 viewMatrix 空间中偏移顶点
边缘渐隐 smoothstep + 距离衰减
雨滴折射 FBO 背景采样 + UV 偏移

通过这套方案,可以在 单个 Draw Call 内渲染数万级粒子,同时保持 60fps 的流畅体验。


源码地址

GitHub: Meteor3D

查看效果:meteor3d.cn

欢迎 Star ⭐ 和 Fork 🍴!


如果这篇文章对你有帮助,请点个赞 👍

echarts 饼图显示设置

作者 hang_bro
2026年1月7日 15:55

上图

image.png

这是最终显示效果

代码

<template>
  <div class="w-full h-full py-[10px] px-[20px] flex flex-col gap-[10px]">
    <!-- 上 -->
    <div class="flex flex-col flex-1 bg-white rounded-[6px] px-[18px] py-[20px] shadow-[0px_0px_8px_rgba(0,0,0,0.16)]">
      <div class="flex items-center">
        <img src="@/assets/images/layout/yunhe/dashboard_title.png" class="w-[4px] h-[16px] mr-[10px]" alt="" />
        <h2 class="text-[18px] font-bold">单位汇总</h2>
      </div>
      <div class="flex-1 flex">
        <!-- 左 -->
        <div class="flex-1 flex flex-col items-center justify-center" style="border-right: 2px dashed #dfdfdf">
          <div
            class="flex items-end text-[20px] h-[51px] text-[#1e1e1e] w-[510px] bg-[url('@/assets/images/layout/yunhe/dashboard_top_bg.png')] bg-no-repeat bg-bottom bg-size-[510px_24px]"
          >
            <img
              src="@/assets/images/layout/yunhe/dashboard_top_left.png"
              class="w-[77px] h-[51px] ml-[67px] mr-[27px]"
              alt=""
            />
            <div class="mb-[5px]">
              <span>频繁停电用户数</span>
              <span class="font-bold ml-[31px] mr-[27px] text-[#08a279] text-[24px]">6000</span>
              <span></span>
            </div>
          </div>
          <div ref="leftPieChartRef" class="flex-1 w-full flex items-center justify-center"></div>
        </div>
        <!-- 右 -->
        <div class="flex-1 flex flex-col items-center justify-center">
          <div
            class="flex items-end text-[20px] h-[51px] text-[#1e1e1e] w-[510px] bg-[url('@/assets/images/layout/yunhe/dashboard_top_bg.png')] bg-no-repeat bg-bottom bg-size-[510px_24px]"
          >
            <img
              src="@/assets/images/layout/yunhe/dashboard_top_right.png"
              class="w-[77px] h-[51px] ml-[67px] mr-[27px]"
              alt=""
            />
            <div class="mb-[5px]">
              <span>昨日发生停电户数</span>
              <span class="font-bold ml-[31px] mr-[27px] text-[#08a279] text-[24px]">6000</span>
              <span></span>
            </div>
          </div>
          <div ref="rightPieChartRef" class="flex-1 w-full flex items-center justify-center">1</div>
        </div>
      </div>
    </div>
   
    </div>
    <!--  -->
  </div>
</template>
<script lang="ts" setup>
import { useEcharts, EchartsOption } from '@/hooks/useEcharts'
import { useEchartsOption } from '@/hooks/useEchartsOption'
import dashboardPieBg from '@/assets/images/layout/yunhe/dashboard_pie_bg.png'
import * as echarts from 'echarts'
const tableData = ref<any[]>([])
const chartData = ref<any[]>([])
const loading = ref(false)
const barChartRef = ref()
const leftPieChartRef = ref()
const rightPieChartRef = ref()


const getLeftPieOption = (): EchartsOption => {
  return {
    color: ['#08a279', '#ff6600', '#50b9ef'],
    tooltip: {
      trigger: 'item',
    },

    graphic: [
      {
        type: 'image',
        left: 'center', //调整图片位置
        top: 'center', //调整图片位置
        z: -10, //确保图片在饼图下方
        //设置图片样式
        style: {
          image: dashboardPieBg,
          width: 280 * 1.3, // 图片宽度
          height: 226 * 1.3, // 图片高度
        },
      },
    ],

    series: [
      {
        type: 'pie',
        radius: ['55%', '60%'],
        center: ['50%', '50%'],
        avoidLabelOverlap: false,
        itemStyle: {
          borderRadius: 50,
          borderColor: '#fff',
          borderWidth: 1,
        },
        label: {
          show: true,
          fontSize: 16,
          color: '#1e1e1e',
          verticalAlign: 'middle',
          // 启用富文本(关键配置)
          rich: {
            // 定义名称样式
            text: {
              fontSize: 14,
              color: '#1e1e1e',
              padding: [5, 5, 5, 0], // 上右下左内边距
              align: 'left', // 富文本内部左对齐
            },
            textRight: {
              fontSize: 14,
              color: '#1e1e1e',
              padding: [5, 5, 5, 0], // 上右下左内边距
              align: 'right', // 富文本内部右对齐
            },
            // 2次
            count2: {
              fontSize: 17,
              fontWeight: 'bold',
              color: '#08a279',
              align: 'left', // 统一左对齐
            },
            // 3-8次
            count3to8: {
              fontSize: 17,
              fontWeight: 'bold',
              color: '#ff6600',
              align: 'left', // 补充左对齐,避免默认居中
            },
            // 8次以上
            count8More: {
              fontSize: 17,
              fontWeight: 'bold',
              color: '#50b9ef',
              align: 'left', // 补充左对齐
            },
          },
          // 重构 formatter:移除无效空标签,简化逻辑
          formatter: (params) => {
            // 统一获取样式名,简化逻辑
            let styleName = ''
            if (params.name == '2次') styleName = 'count2'
            else if (params.name == '3-8次') styleName = 'count3to8'
            else if (params.name == '8次以上') styleName = 'count8More'

            const content = `{${styleName}|${params.value}} {text|户} {${styleName}|${params.percent}} {text|%}`

            // 最终返回:名称换行 + 数值内容,无多余空标签
            return `{${params.name == '2次' ? 'textRight' : 'text'}|${params.name}}\n${content}`
          },
        },
        labelLine: {
          show: true,
          length: 20,
          length2: 130,
          // smooth: true,
          lineStyle: {
            color: '#dfdfdf',
            width: 1.5,
          },
        },
        labelLayout: function (params: any) {
          const isLeft = params.labelRect.x < leftPieChartInstance.value.getWidth() / 2
          const points = params.labelLinePoints
          // Update the end point.
          points[2][0] = isLeft ? params.labelRect.x : params.labelRect.x + params.labelRect.width
          return {
            labelLinePoints: points,
          }
        },
        data: [
          { value: 3000, name: '2次' },
          { value: 2000, name: '3-8次' },
          { value: 1000, name: '8次以上' },
        ],
      },
    ],
  }
}

const { instance: leftPieChartInstance } = useEcharts(leftPieChartRef, getLeftPieOption())

const getList = () => {
  loading.value = true
  tableData.value = Array.from({ length: 40 }, (_, index) => ({
    gdsName: `供电所${index + 1}`,
    twoTimes: `${index * 100}`,
    threeToEightTimes: `${index * 200}`,
    eightTimesMore: `${index * 300}`,
    threeTimesMoreRatio: `${index * 400}`,
  }))

  loading.value = false
}
const getChartData = () => {
  chartData.value = [
    { value: 16, name: '一级预警' },
    { value: 27, name: '二级预警' },
    { value: 38, name: '三级预警' },
    { value: 22, name: '四级预警' },
  ]
  barChartInstance.value?.setOption(getBarOption())
}
const getLeftPieChartData = () => {}

onMounted(() => {
  getList()

  getLeftPieChartData()
})
</script>
<style lang="scss" scoped></style>

记录一下

🍔 fabric如何实现辅助选区捏

2026年1月7日 15:35

功能概述

基本流程:以不同的颜色分区作为依据,解析图片数据,帮助辅助选区

最终效果:

1.1 核心功能

  • 图像追踪:将位图选区图片转换为 SVG 矢量路径

  • 路径解析:按颜色分组解析 SVG 路径数据

  • 交互选择:支持鼠标点击、悬停等交互操作

1.2 技术栈

  • Fabric.js:Canvas 图形库,用于路径渲染和交互

  • imageTracer.js:图像追踪库,用于位图转矢量


核心实现流程

主函数:test()

test() 函数是选区识别与交互功能的核心入口,完整流程如下:

初始化画布状态

selectionLoading.value = true // 显示加载提示
fabricCanvas.skipTargetFind = false // 启用对象检测
fabricCanvas.isDrawingMode = false // 禁用绘制模式
fabricCanvas.renderAll() // 重新渲染

关键点

  • skipTargetFind: false 允许鼠标事件被路径对象捕获
  • isDrawingMode: false 确保不会触发自由绘制

加载选区图片

此处需要一张以不同颜色区分选区的图片,后续将基于这些颜色进行区域识别:

const img = new Image()
img.crossOrigin = 'anonymous' // 跨域支持
img.src = props.selectionUrl

await new Promise((resolve) => {
  img.onload = resolve
})

提取选区图片像素数据

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)

创建临时 Canvas 获取图片的像素数据

将图片canvas的数据导入进 imageTracer;将其转化为svg,

const svgString = imageTracer.imageDataToSVG(ctx?.getImageData(0, 0, canvas.width, canvas.height), {
  ltres: 1, // 直线阈值
  qtres: 1, // 二次曲线阈值
  pathomit: 8, // 路径简化阈值
})

参数说明

  • ltres:控制直线检测的精度,值越小越精确
  • qtres:控制曲线检测的精度
  • pathomit:路径简化参数,值越大简化程度越高

SVG 格式的字符串,包含多个 <path> 元素。这些path元素记录了我们需要的区域信息,比方说在这张图的那张位置,边界是如何巴拉巴拉。

<path> 元素提供了精确的区域信息,将其导入fabric的path中,利用fabric的交互监听,可以帮助进行处理

解析 SVG 并按颜色分组

const parser = new DOMParser()
const svgDoc = parser.parseFromString(svgString, 'image/svg+xml')
const pathGroupByColor = new Map<string, string>()

svgDoc.querySelectorAll('path').forEach((path) => {
  const color = path.getAttribute('fill') || '#000000'
  // 过滤黑色背景
  if (color === 'rgb(0,0,0)' || color === '#000000') {
    return
  }
  const pathData = path.getAttribute('d') || ''
  const oriVal = pathGroupByColor.get(color) || ''
  pathGroupByColor.set(color, oriVal + ' ' + pathData)
})

处理逻辑

  1. 使用 DOMParser 解析 SVG 字符串为 DOM 对象
  2. 遍历所有 <path> 元素
  3. fill 颜色属性分组,相同颜色的路径数据合并
  4. 过滤掉黑色背景(#000000rgb(0,0,0)

为什么按颜色分组

  • 图像追踪可能产生多个不同颜色的路径区域
  • 相同颜色的路径通常属于同一个选区区域
  • 合并后可以减少路径对象数量,提高性能

创建可交互的 Fabric Path 对象

pathGroupByColor.forEach((pathData) => {
 const fabricPath = new fabric.Path(pathData, {
  fill: 'transparent',
  opacity: 0.01,
  stroke: 'transparent',
  strokeWidth: 1,
    perPixelTargetFind: true, // 像素级精确检测
  })

  // 绑定交互事件...
  selectionPaths.value.push(fabricPath)
})

关键配置

  • perPixelTargetFind: true:启用像素级命中检测,提高点击精度

必须使用opacity: 0.01,因为此处是使用perPixelTargetFind: true 进行像素检测,如果说完全透明+无背景,fabric无法监测这个path的交互事件

绑定鼠标交互事件

const initPtah = {
  fill: 'white',
  opacity: 0.01,
  stroke: 'transparent',
  strokeWidth: 1,
};
const selectedPath = {
  fill: pencilBrushColor.value,
  globalCompositeOperation: 'xor',
  stroke: pencilBrushColor.value,
  opacity: 1,
};
// 点击
fabricPath.on('mousedown', (e: any) => {
  const opacity = fabricPath.get('opacity')
  const pathOption = opacity === 1 ? initPtah : selectedPath

  fabricPath.set({
    ...pathOption,
  })
  fabricCanvas.renderAll()
})

// hover:
fabricPath.on('mouseover', (e: any) => {
  const opacity = fabricPath.get('opacity')
  if (opacity === 1) return // 已选中的不处理

  fabricPath.set({
    ...selectedPath,
    opacity: 0.75, // 半透明高亮
  })
  fabricCanvas.renderAll()
})

// leave
fabricPath.on('mouseout', (e: any) => {
  const opacity = fabricPath.get('opacity')
  if (opacity === 1) return // 已选中的保持选中状态

  fabricPath.set({
    ...initPtah, // 恢复初始透明状态
  })
  fabricCanvas.renderAll()
})

组合路径并适配画布

const scaleX = canvasWidth.value / img.width!
const scaleY = canvasHeight.value / img.height!
const scale = Math.min(scaleX, scaleY) // 保持宽高比
const group = new fabric.Group(selectionPaths.value, {
  evented: true, // 启用事件
  hasControls: false, // 隐藏控制点
  subTargetCheck: true, // 子对象检测
})

group.set({
  scaleX: scale,
  scaleY: scale,
  left: (canvasWidth.value - img.width! * scale) / 2, // 水平居中
  top: (canvasHeight.value - img.height! * scale) / 2, // 垂直居中
  lockMovementX: true, // 锁定水平移动
  lockMovementY: true, // 锁定垂直移动
})

缩放计算

  • 计算水平和垂直方向的缩放比例
  • 取较小值确保图片完整显示且保持宽高比

Group 配置

  • evented: true:允许接收鼠标事件
  • subTargetCheck: true:检测子对象(路径),确保点击路径时能触发事件
  • hasControls: false:隐藏变换控制点,防止用户误操作
  • lockMovementX/Y: true:锁定位置,防止拖动

📚 最新版 SCSS(Sass)完整教程(2026 年版)

2026年1月7日 15:08

📚 最新版 SCSS(Sass)完整教程(2026 年版)

适用版本:Dart Sass(当前主流实现,v1.70+)
语法风格:SCSS(.scss,推荐使用)
目标读者:前端开发者、CSS 工程师、全栈工程师


1. 什么是 Sass / SCSS?

Sass(Syntactically Awesome Style Sheets)是一种 CSS 预处理器,它扩展了 CSS 的能力,支持:

  • 变量
  • 嵌套规则
  • 混合(Mixin)
  • 继承
  • 函数
  • 条件与循环
  • 模块化

两种语法:

语法 文件扩展名 特点
SCSS .scss CSS 超集,兼容所有 CSS 代码,使用 {};
Sass(缩进语法) .sass 使用缩进代替大括号,已不推荐

强烈推荐使用 SCSS,因其易读、易迁移、社区支持广泛。


2. 安装与编译

安装 Dart Sass(官方推荐)

# 全局安装(需 Node.js)
npm install -g sass

# 或作为项目依赖
npm install sass --save-dev

编译命令

# 单文件编译
sass main.scss main.css

# 监听模式(自动编译)
sass --watch styles/main.scss:dist/main.css

# 输出压缩格式
sass --style=compressed main.scss main.min.css

# 启用 source map(调试用)
sass --source-map main.scss main.css

构建工具集成

  • Vite:原生支持 .scss
  • Webpack:使用 sass-loader
  • React/Vue/Angular:脚手架默认支持

3. 基础语法

SCSS 是 CSS 的超集,所有合法 CSS 都是合法 SCSS:

/* 合法的 SCSS */
body {
  margin: 0;
  font-family: Arial;
}

4. 变量(Variables)

$ 声明变量,作用域为局部或全局。

// 全局变量
$primary-color: #3498db;
$font-size-base: 16px;

// 局部变量(在选择器内)
.component {
  $local-var: red;
  color: $local-var;
}

默认值(!default

$theme-color: blue !default; // 仅当未定义时赋值

变量作用域

  • 在选择器/函数/mixin 内定义的变量为局部变量
  • 外部无法访问,但可通过 !global 强制提升为全局(慎用)
$global: white;

.content {
  $global: black !global; // 修改全局变量
}

5. 嵌套(Nesting)

让结构更清晰,但避免过度嵌套(建议 ≤ 3 层)。

.nav {
  ul {
    margin: 0;
    li {
      display: inline-block;
      a {
        color: $primary-color;
        &:hover { text-decoration: underline; }
      }
    }
  }

  // 父选择器引用 &
  &--fixed {
    position: fixed;
  }
}

伪类/伪元素嵌套

.button {
  &:hover { opacity: 0.8; }
  &::before { content: "→"; }
}

属性嵌套(较少用)

.box {
  border: {
    top: 1px solid red;
    bottom: 2px dashed blue;
  }
}

6. 混合(Mixins)

可复用的代码块,支持参数、默认值、内容块。

基本用法

@mixin clearfix {
  &::after {
    content: "";
    display: table;
    clear: both;
  }
}

.container {
  @include clearfix;
}

带参数的 Mixin

@mixin button($bg, $color: white) {
  background: $bg;
  color: $color;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
}

.primary-btn {
  @include button(#3498db);
}

可变参数(...

@mixin box-shadow($shadows...) {
  box-shadow: $shadows;
}

.card {
  @include box-shadow(0 2px 4px rgba(0,0,0,0.1), inset 0 1px 0 white);
}

传递内容块(@content

@mixin media($breakpoint) {
  @if $breakpoint == mobile {
    @media (max-width: 768px) { @content; }
  }
}

.sidebar {
  width: 300px;
  @include media(mobile) {
    width: 100%;
  }
}

7. 继承(@extend)

共享样式,避免重复代码。

.message {
  border: 1px solid #ccc;
  padding: 10px;
}

.success {
  @extend .message;
  border-color: green;
}

占位符选择器(%)

不会输出 CSS,仅用于继承:

%btn-base {
  padding: 8px 16px;
  border: none;
  cursor: pointer;
}

.btn-primary {
  @extend %btn-base;
  background: blue;
}

⚠️ 警告:过度使用 @extend 可能导致选择器爆炸(selector bloat)。


8. 插值(Interpolation)

#{} 动态插入变量值。

动态类名

$theme: dark;
.panel-#{ $theme } { background: black; }

动态属性

$prop: margin;
$side: top;
.#{ $prop }-#{ $side } {
  #{ $prop }-#{ $side }: 20px;
}

在字符串中

.icon-#{ $name } {
  background: url("/icons/#{ $name }.svg");
}

❌ 不能用于:数学表达式、@import/@use 路径、媒体查询条件等静态上下文。


9. 控制指令(Control Directives)

@if / @else if / @else

@mixin theme($light: true) {
  @if $light {
    background: white;
    color: black;
  } @else {
    background: black;
    color: white;
  }
}

@for

@for $i from 1 through 5 {
  .item-#{$i} { width: 20px * $i; }
}

@each

$colors: (red, green, blue);
@each $color in $colors {
  .btn-#{$color} { background: $color; }
}

// 遍历 map
$sizes: (sm: 12px, md: 16px, lg: 20px);
@each $name, $size in $sizes {
  .text-#{$name} { font-size: $size; }
}

@while

$i: 1;
@while $i <= 3 {
  .item-#{$i} { z-index: $i; }
  $i: $i + 1;
}

10. 函数(Functions)

返回值,不输出 CSS。

自定义函数

@function to-rem($px, $base: 16px) {
  @return ($px / $base) * 1rem;
}

.text {
  font-size: to-rem(24px); // → 1.5rem
}

内置函数示例

$color: #3498db;
.lighter: lighten($color, 20%);     // 变亮
.darker: darken($color, 10%);       // 变暗
.transparent: transparentize($color, 0.3); // 增加透明度

11. 模块系统(@use 与 @forward)

取代旧的 @import(已废弃)

@use:导入模块

// _variables.scss
$primary: #3498db;

// main.scss
@use 'variables';

.alert {
  color: variables.$primary;
}

命名空间别名

@use 'variables' as var;
color: var.$primary;

无命名空间(谨慎使用)

@use 'variables' as *;
color: $primary; // 直接使用

@forward:重新导出

// _all.scss
@forward 'buttons';
@forward 'forms';

// main.scss
@use 'all';

12. 内置模块与函数

Sass 提供多个内置模块(需显式 @use):

@use 'sass:math';
@use 'sass:string';
@use 'sass:color';
@use 'sass:list';
@use 'sass:map';
@use 'sass:selector';

示例:数学运算

@use 'sass:math';

$half: math.div(10, 2); // 5

⚠️ 注意:/ 不再表示除法!必须用 math.div()


13. 最佳实践

建议 说明
✅ 使用 @use 而非 @import 避免全局污染,提升性能
✅ 变量命名语义化 $button-primary-bg 优于 $blue
✅ 限制嵌套层级 ≤ 3 层,避免低效选择器
✅ 用 %placeholder 替代无意义类 减少 CSS 体积
✅ 拆分文件(7-1 模式) base/, components/, layout/
✅ 使用 linter(如 stylelint) 保证代码规范

14. 常见陷阱与性能优化

❌ 陷阱

  • 过度嵌套 → 生成冗长选择器
  • 滥用 @extend → 选择器爆炸
  • 在循环中生成大量 CSS → 文件膨胀
  • 使用 !global → 难以维护

✅ 优化

  • 使用 @if 控制 mixin 输出
  • 合并相似样式到 mixin
  • 压缩输出:--style=compressed
  • 利用 source map 调试

15. 工具与生态

工具 用途
Sass Playground 在线实时编译
stylelint-scss Lint 规则
PostCSS + Sass 后处理增强
VS Code 插件 Live Sass Compiler、SCSS IntelliSense

📌 总结

SCSS 极大地提升了 CSS 的可维护性与开发效率。掌握其核心特性(变量、嵌套、mixin、模块化)后,你将能构建可扩展、可复用、高性能的样式系统。

🔗 官方文档:sass-lang.com/documentati…
📘 中文参考:www.sass.hk


Accessibility 辅助功能总结

作者 坚强小橙
2026年1月7日 15:04

总结一下接手项目以来Accessibility的一些收获,这个产品要求支持辅助功能,虽然已经是按照最低要求,但做起来即要考虑全键盘可以操作,也要考虑所有显示的内容和需要交互的内容可以被讲述人读出来,还要考虑高对比下的样式兼容,也是有不少的维护开发成本。

css可以通过特定选择器来指定高对比度模式下的样式

@media screen and (forced-colors: active) {
 color: ButtonText;
 backgroundColor: ButtonFace;
}
// 判断系统主题
@media (prefers-color-scheme: dark) {
  .brand-text {
    color: #ffffff;
  }
}

可用的内置变量:

1. Button
Keyword Meaning
ButtonBorder Border color of native buttons/controls (e.g., the outline of a button).
2. Text & content
Keyword Meaning
Canvas Background color of the main content area (e.g., a window's background).
CanvasText Text color for content on the Canvas background.
LinkText Color of unvisited hyperlinks (matches system's default link color).
VisitedText Color of visited hyperlinks.

3. Highlight & selection

Keyword Meaning
Highlight Background color of selected text/elements (e.g., highlighted text).
HighlightText Text color of selected content (contrasts with Highlight).

4. UI chrome (window/frame elements)

Keyword Meaning
ActiveBorder Border color of active windows/dialogs.
ActiveCaption Background color of the title bar of an active window.
ActiveCaptionText Text color in the title bar of an active window.
InactiveBorder Border color of inactive windows/dialogs.
InactiveCaption Background color of the title bar of an inactive window.

5. Status & feedback

Keyword Meaning
GrayText Color of disabled text (e.g., grayed-out buttons/options).
InfoBackground Background color of tooltip/info panels.
InfoText Text color in tooltip/info panels.

html inert 属性

学到了一个新的且很重要的html属性 inert,这个词表示 “使惰性”,它可以让元素中的用户交互事件都失效,适合展示顶层窗口时,把交互trap到当前窗口中。 `

export function disableApp() {
  document.getElementById('app')?.setAttribute('inert', '')
}
export function enableApp() {
  document.getElementById('app')?.removeAttribute('inert')
}

焦点 管理

如果不是做 accessibility, 我都不知道聚焦是一个这个难以处理的东西。不过也是因为这个项目之前给第三方组件库提了需求,要求弹窗弹出来自动聚焦第一个可聚焦的元素,但是可能没区分好触发方式:

  1. 键盘导航触发 (应该自动聚焦)
  2. 鼠标点击触发(不应该自动聚焦)
  3. 编程式触发 xxx.focus() (不应该自动聚焦,但组件库聚焦了)

为了不牵扯第三方去修改,我们监听了focus事件,发现有自动聚焦但不想聚焦的时候,就去设置blur,其中监听事件有两种:

parentElement.addEventListener('focus', handlefocus, { capture: true, once: true })
等价于
parentElement.addEventListener('focusin', handlefocus, { once: true }) //childElement也会触发
}

几种关于聚焦元素的伪类选择器

*:focus
*:focus-within
*:focus-visible  如果没有意外情况,这个选择器本身就只有在键盘导航时才显示样式,已经和鼠标做了区分

判断DOM元素是否可聚焦或者已经聚焦

// 检查焦点相关状态
function diagnoseFocus(element) {
  return {
    hasFocusVisible: element.matches(':focus-visible'),
    hasFocus: element.matches(':focus'),
    isActive: element.matches(':active'),
    tabIndex: element.tabIndex,
    computedOutline: getComputedStyle(element).outline,
    focusVisibleSupported: CSS.supports('selector(:focus-visible)')
  };
}

// 实时监控
document.addEventListener('focusin', (e) => {
  console.log('焦点诊断:', diagnoseFocus(e.target));
});

其实非预期的行为大多出现在编程式触发,让组件库在调用 focus()方法的时候加一个 flag,使用组件库的控制起来会方便很多。

Electron入门指南:从零开始构建跨平台桌面应用

2026年1月7日 14:48

前言

随着前端技术的飞速发展,Web 技术不再局限于浏览器。Electron 的出现,让开发者可以用熟悉的 HTML、CSS 和 JavaScript 构建功能强大的桌面应用程序。从 Visual Studio Code 到 Slack,再到网易云音乐,众多知名软件都基于 Electron 构建。

本文将带你全面了解 Electron 的核心概念,深入理解其架构与多进程通信机制,掌握常用 API,并通过一个简单的工程案例带你快速入门。


一、Electron 是什么?能做什么?优缺点分析

1.1 什么是 Electron?

Electron 是一个由 OpenJS Foundation 与社区共同维护的开源框架,它将 Chromium 渲染引擎Node.js 运行时 深度整合,允许开发者使用 Web 技术(HTML、CSS、JavaScript)构建跨平台的桌面应用程序。

  • 诞生背景:最初作为 GitHub Atom 编辑器的底层框架(名为 Atom Shell),于 2013 年启动,2015 年正式更名为 Electron 并独立发布。
  • 核心技术栈:JavaScript + HTML + CSS
  • 支持平台:Windows、macOS、Linux

1.2 Electron 能做什么?

Electron 不只是一个“网页打包器”,它能实现接近原生应用的功能和体验,典型应用场景包括:

  • 代码编辑器与 IDE:如 Visual Studio Code、Atom
  • 通讯工具:如 Slack、Discord、Zoom 客户端
  • 媒体应用:如网易云音乐、百度网盘
  • 远程控制与工具类软件:如 Figma 桌面端、Postman
  • 企业级管理后台桌面化
  • 游戏客户端或轻量级游戏

得益于 Node.js 的加持,Electron 应用可以访问文件系统、调用系统 API、创建系统托盘、发送通知等,实现传统 Web 应用无法完成的操作。

1.3 Electron 的优势

优势 说明
🌐 跨平台支持 一套代码可编译为 Windows、macOS、Linux 三端应用,极大降低开发与维护成本
🧑‍💻 上手简单 前端开发者无需学习 C++、Swift 等原生语言,即可开发桌面应用
⚙️ 底层系统权限 可通过 Node.js 访问文件系统、注册表、命令行工具等,实现复杂系统交互
🧩 生态丰富 背靠庞大的 npm 和前端生态,模块复用方便;社区活跃,文档齐全
🛠️ 易于调试 支持 Chrome DevTools,调试体验接近 Web 开发

1.4 Electron 的缺点

缺点 说明 建议
📦 应用体积较大 每个应用都内嵌 Chromium 和 Node.js,最小包通常在 50MB 以上 使用打包优化工具(如 electron-builder)压缩资源
⚠️ 性能开销 多进程架构和渲染开销可能导致低配设备卡顿 优化渲染逻辑,避免白屏,合理使用硬件加速
🔐 安全风险 若未正确配置 nodeIntegration 或加载远程内容,可能引发 RCE(远程代码执行)漏洞 禁用危险配置,启用上下文隔离,使用 contextIsolation: true
💸 内存占用高 多个窗口或复杂页面可能导致内存占用上升 合理管理窗口生命周期,及时销毁无用窗口

📌 总结:Electron 特别适合工具类、中后台管理类、跨平台协作型应用,不适合对性能和体积要求极高的场景(如大型游戏或系统级工具)。


二、Electron 架构与多进程通信原理

2.1 整体架构

Electron 采用 主进程(Main Process) + 渲染进程(Renderer Process) 的多进程架构,类似于现代浏览器的设计。

  • 主进程:运行 main.js,负责创建窗口、管理应用生命周期、处理系统事件、调用原生 API。
  • 渲染进程:每个窗口对应一个独立的渲染进程,运行在 Chromium 中,负责渲染 UI 界面,可使用 DOM、CSS、Canvas 等 Web API。

🔍 注意:主进程只有一个,但可以有多个渲染进程(每个窗口一个)。

2.2 多进程模型图示

+---------------------+
|     主进程 (Main)    |
|  - app, BrowserWindow |
|  - Tray, Menu, etc.  |
+----------+----------+
           |
           | IPC 通信
           v
+---------------------+     +---------------------+
| 渲染进程 1 (Renderer) |     | 渲染进程 2 (Renderer) |
|  - HTML/CSS/JS       |     |  - 独立上下文         |
|  - 可访问 window      |     |  - 无法直接访问 Node |
+---------------------+     +---------------------+

💡 关键点

2.3 多进程通信原理(IPC)

Electron 提供了两种主要的 IPC 模块:

  • ipcMainipcRenderer:发送异步消息
  • ipcMain.invoke / ipcMain.handleipcRenderer.invoke:支持异步请求-响应模式(推荐用于函数调用)

示例:主进程与渲染进程通信

主进程(main.js)

const { ipcMain } = require('electron');

ipcMain.on('message-from-renderer', (event, data) => {
  console.log('收到渲染进程消息:', data);
  event.sender.send('message-to-renderer', { response: '主进程已处理' });
});

渲染进程(renderer.js)

const { ipcRenderer } = require('electron');

ipcRenderer.send('message-from-renderer', { text: '你好主进程' });

ipcRenderer.on('message-to-renderer', (event, response) => {
  console.log('主进程回复:', response);
});

最佳实践


三、Electron 常用 API 讲解

以下是 Electron 开发中最常用的核心模块及其用途:

模块 作用 使用场景
app 控制应用生命周期 监听 readywindow-all-closedactivate 事件
BrowserWindow 创建和管理浏览器窗口 创建主窗口、子窗口、设置大小、位置、是否置顶等
ipcMain / ipcRenderer 主进程与渲染进程通信 数据传递、触发系统操作
dialog 显示系统对话框 打开文件、保存文件、提示框等
Tray 创建系统托盘图标 后台运行、最小化到托盘
Menu / MenuItem 自定义菜单 右键菜单、顶部菜单栏
nativeImage 处理原生图像 设置托盘图标、窗口图标
shell 打开外部资源 打开 URL、打开文件夹

示例代码片段

1. 创建系统托盘

const { Tray, nativeImage } = require('electron');
let tray = null;

app.whenReady().then(() => {
  const icon = nativeImage.createFromPath('icon.png');
  tray = new Tray(icon);
  tray.setToolTip('我的 Electron 应用');
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: '打开', click: () => createWindow() },
    { label: '退出', click: () => app.quit() }
  ]));
});

2. 打开文件选择框

const { dialog } = require('electron');

dialog.showOpenDialog({
  properties: ['openFile', 'multiSelections'],
  filters: [{ name: 'Images', extensions: ['jpg', 'png', 'gif'] }]
}).then(result => {
  console.log(result.filePaths);
});

四、简单工程搭建案例

下面我们从零开始搭建一个基础的 Electron 项目。

4.1 环境准备

确保已安装:

  • Node.js(建议 v16+)
  • npm 或 yarn
  • 代码编辑器(推荐 VS Code)

4.2 创建项目结构

mkdir electron-hello-world
cd electron-hello-world
npm init -y
npm install --save-dev electron

4.3 创建项目文件

(1)main.js —— 主进程入口

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  win.loadFile('index.html');
  win.webContents.openDevTools(); // 开发时打开 DevTools
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

// 示例 IPC 处理
ipcMain.on('ping', (event) => {
  event.sender.send('pong', { message: 'Pong 来自主进程!' });
});

(2)preload.js —— 预加载脚本(安全桥梁)

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  sendPing: () => ipcRenderer.send('ping'),
  onPong: (callback) => ipcRenderer.on('pong', (event, data) => callback(data))
});

🔒 说明:使用 contextBridge 安全地将 API 暴露给渲染进程,避免直接引入 Node 模块。

(3)index.html —— 渲染界面

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Hello Electron</title>
  <style>
    body { font-family: "Segoe UI", sans-serif; text-align: center; margin-top: 100px; }
    button { padding: 12px 24px; font-size: 16px; margin: 10px; }
  </style>
</head>
<body>
  <h1>🎉 欢迎来到 Electron 世界!</h1>
  <p>这是一个基础的桌面应用示例</p>
  <button id="pingBtn">发送 Ping</button>
  <p id="result"></p>

  <script type="module">
    const { electronAPI } = window;

    document.getElementById('pingBtn').addEventListener('click', () => {
      electronAPI.sendPing();
    });

    electronAPI.onPong((data) => {
      document.getElementById('result').textContent = data.message;
    });
  </script>
</body>
</html>

(4)package.json 配置启动脚本

{
  "name": "electron-hello-world",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron .",
    "dev": "electron ."
  },
  "author": "Your Name",
  "license": "MIT",
  "description": "A simple Electron demo app"
}

4.4 启动应用

npm start

如果一切正常,你将看到一个窗口弹出,点击按钮可与主进程通信。


五、后续学习建议

  1. 阅读官方文档www.electronjs.org/docs 是最权威的学习资源。
  2. 使用脚手架工具
    • Electron Forge:集成打包、自动更新、发布等功能
    • Electron Builder:更强大的打包与分发方案
  3. 关注安全性
    • 始终启用 contextIsolation: true
    • 避免在渲染进程中直接引入 require
    • 使用 sandbox: true 提升安全性
  4. 性能优化
    • 减少白屏时间(使用 ready-to-show 事件)
    • 合理管理多窗口生命周期
    • 使用懒加载和代码分割

六、结语

Electron 让前端开发者真正实现了“一次编写,到处运行”的桌面开发梦想。虽然它有体积大、资源占用高等局限,但在工具类、跨平台协作型应用中表现出色。

通过本文的学习,你已经掌握了:

  • Electron 是什么及其适用场景
  • 多进程架构与 IPC 通信机制
  • 常用 API 的使用方式
  • 一个完整的入门项目搭建流程

下一步,你可以尝试添加以下功能来深化理解

  • 实现“打开文件”功能
  • 添加系统托盘和右键菜单
  • 实现窗口最小化到托盘
  • 使用 electron-builder 打包为 .exe.dmg

🌟 保持好奇心,持续探索。浏览器之外的世界,同样精彩。


🙌 感谢阅读!欢迎在评论区分享你的第一个 Electron 应用想法或遇到的问题。

前端项目测试覆盖率检测

2026年1月7日 14:39

背景介绍

什么是测试覆盖率?

测试覆盖率(Test Coverage)是衡量代码被测试用例覆盖程度的指标。它帮助开发者了解:

  • 哪些代码已被测试:确保关键功能有测试保护
  • 哪些代码未被测试:识别测试盲点,发现潜在风险
  • 代码质量趋势:通过覆盖率变化评估代码质量改进情况

测试框架

本文使用 Jest 作为测试框架,配合 ts-jest 支持 TypeScript 测试。Jest 提供了内置的覆盖率收集功能,无需额外配置即可生成详细的覆盖率报告。


前提条件

安装所有必要的依赖,包括:

  • jest - 测试框架
  • ts-jest - TypeScript 支持
  • @jest/globals - Jest 全局类型
  • jest-environment-jsdom - DOM 环境支持
  • @types/jest - TypeScript 类型定义

验证依赖安装

检查 node_modules 目录是否存在,或运行:

pnpm list jest ts-jest

项目配置

Jest 配置文件

  • 文件位置jest.config.ts(项目根目录)

配置参考:

import type { Config } from 'jest';

const config: Config = {
  // 使用 ts-jest 预设
  preset: 'ts-jest',

  // 测试环境
  testEnvironment: 'jsdom',

  // 根目录
  rootDir: '.',

  // 测试文件匹配模式
  testMatch: [
    '**/__tests__/**/*.test.ts',
    '**/__tests__/**/*.test.tsx',
    '**/?(*.)+(spec|test).ts',
    '**/?(*.)+(spec|test).tsx'
  ],

  // 模块文件扩展名
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],

  // 模块路径映射(根据项目结构调整)
  moduleNameMapper: {
    '^xxxx$': '<rootDir>/packages/xxxx/src',
    '^@xxxx/(.*)$': '<rootDir>/packages/xxxx/src/$1',
  },

  // 转换配置
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        tsconfig: {
          // 测试环境需要的额外配置
          module: 'commonjs',
          esModuleInterop: true,
          allowSyntheticDefaultImports: true,
          target: 'ES5',
          strict: true,
          skipLibCheck: true,
          moduleResolution: 'node',
          sourceMap: true,
        },
      },
    ],
  },

  // 覆盖率配置
  collectCoverageFrom: [
    'packages/**/src/**/*.{ts,tsx}',
    '!packages/**/src/**/*.d.ts',
    '!packages/**/src/**/__tests__/**',
    '!packages/**/src/**/*.test.{ts,tsx}',
    '!packages/**/src/**/index.ts', // 通常不测试入口文件
  ],

  // 覆盖率报告目录
  coverageDirectory: '<rootDir>/coverage',

  // 覆盖率阈值(可选,根据需要调整)
  // coverageThreshold: {
  //   global: {
  //     branches: 80,
  //     functions: 80,
  //     lines: 80,
  //     statements: 80,
  //   },
  // },

  // 忽略的目录
  testPathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/coverage/',
  ],

  // 清除 mock
  clearMocks: true,

  // 恢复 mock
  restoreMocks: true,

  // 显示覆盖率
  collectCoverage: false,

  // 详细输出
  verbose: true,
};

export default config;


TypeScript 配置

  • 文件位置tsconfig.json(项目根目录)
  • 要求:启用 strict: true 模式

package.json 脚本

  • 文件位置package.json(项目根目录)
  • 要求:包含 test:coverage 脚本
  • 当前配置
    {
      "scripts": {
        "test:coverage": "jest --coverage"
      }
    }
    

快速开始

1. 运行覆盖率检测

在项目根目录执行以下命令:

# 生成覆盖率报告
pnpm test:coverage

# 或者只测试 xxxx 包
pnpm test:xxxx --coverage

2. 查看覆盖率报告

运行完成后,覆盖率报告会生成在 coverage/ 目录下:

  • HTML 报告:打开 coverage/lcov-report/index.html 在浏览器中查看详细的覆盖率报告
  • 终端输出:命令执行完成后,会在终端显示覆盖率摘要

3. 覆盖率指标说明

Jest 会报告以下四个覆盖率指标:

  • Statements(语句覆盖率):已执行的语句百分比
  • Branches(分支覆盖率):已执行的分支(if/else、switch 等)百分比
  • Functions(函数覆盖率):已调用的函数百分比
  • Lines(行覆盖率):已执行的行百分比

当前配置

项目已配置了 Jest 覆盖率收集,配置位于 jest.config.ts

// 覆盖率配置
collectCoverageFrom: [
  'packages/**/src/**/*.{ts,tsx}',
  '!packages/**/src/**/*.d.ts',
  '!packages/**/src/**/__tests__/**',
  '!packages/**/src/**/*.test.{ts,tsx}',
  '!packages/**/src/**/index.ts', // 通常不测试入口文件
],

// 覆盖率报告目录
coverageDirectory: '<rootDir>/coverage',

包含的文件

  • packages/**/src/**/*.{ts,tsx} - 所有 TypeScript 源文件

排除的文件

  • *.d.ts - TypeScript 声明文件
  • __tests__/** - 测试文件目录
  • *.test.{ts,tsx} - 测试文件
  • index.ts - 入口文件(通常只做导出)

设置覆盖率阈值

可以在 jest.config.ts 中启用覆盖率阈值,确保代码质量:

coverageThreshold: {
  global: {
    branches: 80,    // 分支覆盖率至少 80%
    functions: 80,   // 函数覆盖率至少 80%
    lines: 80,       // 行覆盖率至少 80%
    statements: 80,  // 语句覆盖率至少 80%
  },
},

启用后,如果覆盖率低于阈值,测试将失败。

查看详细报告

HTML 报告

  1. 运行 pnpm test:coverage
  2. 打开 coverage/lcov-report/index.html
  3. 在浏览器中浏览:
    • 文件列表视图:查看每个文件的覆盖率
    • 文件详情视图:查看哪些行被覆盖,哪些未覆盖(红色=未覆盖,绿色=已覆盖)

终端输出示例

-------------------|---------|----------|---------|---------|
File               | % Stmts | % Branch | % Funcs | % Lines |
-------------------|---------|----------|---------|---------|
All files          |    XX   |    XX    |    XX   |    XX   |
 utils/            |    XX   |    XX    |    XX   |    XX   |
  file.ts          |    XX   |    XX    |    XX   |    XX   |
  guid.ts          |    XX   |    XX    |    XX   |    XX   |
-------------------|---------|----------|---------|---------|

其他有用的命令

# 只运行测试(不生成覆盖率)
pnpm test

# 监视模式运行测试
pnpm test:watch

# 只测试 uploader 包
pnpm test:uploader

# 生成覆盖率并只显示摘要(不生成 HTML)
jest --coverage --coverageReporters=text

覆盖率报告格式

Jest 默认生成以下格式的报告:

  • lcov - 用于 CI/CD 集成(coverage/lcov.info
  • text - 终端输出
  • text-summary - 终端摘要
  • html - HTML 报告(coverage/lcov-report/index.html

可以在 jest.config.ts 中自定义:

coverageReporters: ['text', 'lcov', 'html', 'json-summary'],

CI/CD 集成

覆盖率报告可以集成到 CI/CD 流程中:

  1. GitHub Actions:使用 actions/upload-artifact 上传 coverage/ 目录
  2. Codecov:上传 coverage/lcov.info 到 Codecov
  3. Coveralls:上传 coverage/lcov.info 到 Coveralls

注意事项

  1. coverage 目录已添加到 .gitignore,不会提交到版本控制
  2. 覆盖率报告会显示所有源文件,包括未测试的文件
  3. 某些文件(如类型定义、入口文件)可能不需要 100% 覆盖率
  4. 覆盖率只是质量指标之一,不能完全代表代码质量

提高覆盖率

  1. 识别未覆盖的文件和函数
  2. 为关键业务逻辑编写测试
  3. 测试边界情况和错误处理
  4. 使用覆盖率报告找出遗漏的测试场景

Tauri iOS 开发中 "pnpm: command not found" 错误解决方案

作者 codexu
2026年1月7日 14:21

问题描述

在使用 Tauri 2 进行 iOS 开发时,运行 pnpm tauri ios dev 命令时遇到以下错误:

/Users/xu/Library/Developer/Xcode/DerivedData/note-gen-coowqggwgabxygdvvpindbhbxduh/Build/Intermediates.noindex/ArchiveIntermediates/note-gen_iOS/IntermediateBuildFilesPath/note-gen.build/release-iphoneos/note-gen_iOS.build/Script-BCAE7E672E9B737A5106BE1A.sh: line 2: pnpm: command not found
Command PhaseScriptExecution failed with a nonzero exit code

问题原因分析

1. Xcode 构建环境的特殊性

Xcode 的构建脚本运行在一个受限的环境中,其 PATH 环境变量与用户终端环境不同。即使你在终端中可以正常使用 pnpm,Xcode 的构建脚本也无法找到它。

2. NVM 安装路径问题

大多数开发者使用 NVM (Node Version Manager) 来管理 Node.js 版本,NVM 将 Node.js 和相关工具(如 pnpm)安装在用户目录下:

/Users/用户名/.nvm/versions/node/v24.11.0/bin/pnpm

而 Xcode 的默认 PATH 通常只包含系统级目录:

  • /usr/bin
  • /bin
  • /usr/sbin
  • /sbin
  • /usr/local/bin

3. 构建脚本执行机制

Tauri 在生成 Xcode 项目时,会在构建阶段(Build Phases)添加一个 "Run Script" 步骤,该脚本会执行类似以下命令:

pnpm tauri ios xcode-script -v --platform iOS --sdk-root ${SDKROOT:?} ...

由于 Xcode 的 PATH 中不包含 NVM 的路径,脚本执行时就会报 "command not found" 错误。

解决方案

官方推荐方案:创建符号链接

根据 Tauri GitHub 社区的讨论(Discussion #6382),最可靠的解决方案是将 pnpmnode 创建符号链接到 /usr/local/bin/ 目录:

# 创建 pnpm 符号链接
sudo ln -s $(which pnpm) /usr/local/bin/pnpm

# 创建 node 符号链接  
sudo ln -s $(which node) /usr/local/bin/node

为什么这个方案有效?

  1. 系统级可访问性/usr/local/bin/ 在 Xcode 的默认 PATH 中
  2. 保持版本一致性:符号链接指向你当前使用的 NVM 版本
  3. 无需修改配置:不需要修改 tauri.conf.json 或 Xcode 项目设置
  4. 永久解决方案:一次配置,所有项目都能使用

验证安装

执行以下命令验证符号链接是否创建成功:

ls -la /usr/local/bin/ | grep -E "pnpm|node"

预期输出:

lrwxr-xr-x  1 root  wheel  46 日期  node -> /Users/用户名/.nvm/versions/node/v24.11.0/bin/node
lrwxr-xr-x  1 root  wheel  46 日期  pnpm -> /Users/用户名/.nvm/versions/node/v24.11.0/bin/pnpm

其他尝试过的方案(不推荐)

1. 修改 tauri.conf.json

尝试在 beforeDevCommand 中使用完整路径:

{
  "build": {
    "beforeDevCommand": "/Users/用户名/.nvm/versions/node/v24.11.0/bin/pnpm dev"
  }
}

问题:Xcode 构建脚本仍然会执行 pnpm tauri ios xcode-script,这个命令无法通过配置文件修改。

2. 设置环境变量

尝试在构建脚本中设置 PATH:

export PATH="$HOME/.nvm/versions/node/v24.11.0/bin:$PATH" && pnpm dev

问题:Xcode 的构建脚本执行环境限制,环境变量设置可能不生效。

3. 使用 npx

尝试使用 npx pnpm

{
  "build": {
    "beforeDevCommand": "npx pnpm dev"
  }
}

问题:Xcode 环境中连 npx 也找不到。

最佳实践

1. 一次性配置

建议在设置新的开发环境时,就执行符号链接创建命令,避免后续开发中遇到问题。

2. 版本管理

如果你切换 Node.js 版本,需要重新创建符号链接:

# 切换 Node.js 版本后
sudo rm /usr/local/bin/node /usr/local/bin/pnpm
sudo ln -s $(which node) /usr/local/bin/node
sudo ln -s $(which pnpm) /usr/local/bin/pnpm

3. 团队协作

在团队开发中,确保所有成员都执行了相同的符号链接配置,避免环境差异导致的问题。

总结

"pnpm: command not found" 错误是 Tauri iOS 开发中的常见问题,根本原因是 Xcode 构建环境与用户终端环境的 PATH 差异。通过创建系统级符号链接,可以让 Xcode 正确找到 NVM 安装的 Node.js 工具,这是目前最可靠和推荐的解决方案。

这个方案不仅解决了当前问题,也为后续的 Tauri iOS 开发提供了稳定的环境基础。

为什么用 useReducer 而不用 useState?

2026年1月7日 14:03

一、简单场景:用 useState

1. 只有一个简单的计数器

适合: 状态简单,就一两个值

 const [count, setCount] = useState(0)

 setCount(count + 1)  // 简单!

二、 复杂场景:必须用 useReducer

场景1:状态逻辑复杂

用 useState(乱七八糟)

const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
const [discount, setDiscount] = useState(0)
const [tax, setTax] = useState(0)

 // 添加商品 - 要改4个地方!
 const addItem = (item) => {
    setItems([...items, item])
    setTotal(total + item.price)
    setTax((total + item.price) * 0.1)
    setDiscount(calculateDiscount(total + item.price))
    // 容易漏改,容易出 bug!
 }

// 删除商品 - 又要改4个地方!
const removeItem = (id) => {
    const item = items.find(i => i.id === id)
    setItems(items.filter(i => i.id !== id))
    setTotal(total - item.price)
    setTax((total - item.price) * 0.1)
    setDiscount(calculateDiscount(total - item.price))
    // 太容易出错了!
}

用 useReducer(清晰)

 const [state, dispatch] = useReducer(reducer, {
   items: [],
   total: 0,
   discount: 0,
   tax: 0
})

 // 添加商品 - 只需要一句话!
const addItem = (item) => {
   dispatch({ type: 'ADD_ITEM', payload: item })
   // 所有逻辑都在 reducer 里,不会漏
}

// 删除商品 - 也是一句话!
const removeItem = (id) => {
   dispatch({ type: 'REMOVE_ITEM', payload: id })
   // 简单清晰
}

// reducer 里统一处理所有逻辑
function reducer(state, action) {
    switch(action.type) {
      case 'ADD_ITEM':
        const newTotal = state.total + action.payload.price
        return {
          items: [...state.items, action.payload],
          total: newTotal,
          tax: newTotal * 0.1,
          discount: calculateDiscount(newTotal)
        }
      case 'REMOVE_ITEM':
        // 所有相关逻辑都在这里,集中管理
    }
}

场景2:多个操作修改同一个状态

用 useState(代码重复)

// 修改名字
const updateName = (name) => {
    setUser({ ...user, name })
}

// 修改年龄
const updateAge = (age) => {
    setUser({ ...user, age })
}

// 修改城市
const updateCity = (city) => {
    setUser({ ...user, city })
}

// 重置
const reset = () => {
    setUser({ name: '', age: 0, city: '' })
}

// 每个函数都在写 setUser({...user, ...}),好烦!

用 useReducer(统一管理)

 const [user, dispatch] = useReducer(userReducer, { name: '', age: 0, city: '' })

// 一个函数搞定所有操作
const update = (field, value) => {
    dispatch({ type: 'UPDATE', field, value })
}

// 或者更清晰的方式
dispatch({ type: 'UPDATE_NAME', payload: '张三' })
dispatch({ type: 'UPDATE_AGE', payload: 25 })
dispatch({ type: 'RESET' })

// reducer 统一处理
function userReducer(state, action) {
    switch(action.type) {
      case 'UPDATE_NAME':
        return { ...state, name: action.payload }
      case 'UPDATE_AGE':
        return { ...state, age: action.payload }
      case 'UPDATE_CITY':
        return { ...state, city: action.payload }
      case 'RESET':
        return { name: '', age: 0, city: '' }
    }
}

场景3:下一个状态依赖当前状态

用 useState(可能出错)

const [count, setCount] = useState(0)

// 快速点3次按钮
onClick={() => {
    setCount(count + 1)  // count = 0,变成 1
    setCount(count + 1)  // count 还是 0,又变成 1
    setCount(count + 1)  // count 还是 0,还是变成 1
    // 最后 count = 1,而不是 3!
  }}

// 需要用函数式更新
setCount(prev => prev + 1)  // 这样才对

用 useReducer(不会出错)

const [state, dispatch] = useReducer(reducer, { count: 0 })

onClick={() => {
    dispatch({ type: 'INCREMENT' })
    dispatch({ type: 'INCREMENT' })
    dispatch({ type: 'INCREMENT' })
    // 不会出错,肯定是 +3
}}

function reducer(state, action) {
    switch(action.type) {
      case 'INCREMENT':
        return { count: state.count + 1 }  // 总是用最新的 state
    }
}

三、 什么时候用 useReducer or useState?

1. 用 useState ✅

 // 1. 简单值
const [name, setName] = useState('')
const [age, setAge] = useState(0)
const [isOpen, setIsOpen] = useState(false)

// 2. 独立的状态
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

// 3. 不相关的多个值
const [count1, setCount1] = useState(0)
const [count2, setCount2] = useState(0)

2. 用 useReducer ✅

// 1. 购物车(多个相关值)
const [cart, dispatch] = useReducer(cartReducer, {
    items: [],
    total: 0,
    discount: 0
})

// 2. 表单(多个字段)
const [form, dispatch] = useReducer(formReducer, {
    name: '',
    email: '',
    phone: '',
    address: ''
})

// 3. 复杂的状态机
const [state, dispatch] = useReducer(reducer, {
    status: 'idle',  // idle -> loading -> success -> error
    data: null,
    error: null
})

四、形象比喻

1. useState = 自己管钱

// 你有3个钱包
const [工资, set工资] = useState(5000)
const [奖金, set奖金] = useState(1000)
const [存款, set存款] = useState(10000)

// 发工资了
set工资(6000)
set存款(存款 + 1000)  // 还要记得更新存款

2.useReducer = 请财务帮你管钱

 const [账户, dispatch] = useReducer(财务管理, {
    工资: 5000,
    奖金: 1000,
    存款: 10000
})

// 发工资了,告诉财务一声
dispatch({ type: '发工资', 金额: 1000 })

// 财务会自动帮你:
// 1. 工资加1000
// 2. 存款也加1000
// 3. 更新账户余额
// 不会漏掉任何步骤!

五、总结

为什么用 useReducer 而不用 useState?

898ef6f1-0b5c-4471-9f9e-9d8de9ee9cf9.png

每日一题-分裂二叉树的最大乘积🟡

2026年1月7日 00:00

给你一棵二叉树,它的根为 root 。请你删除 1 条边,使二叉树分裂成两棵子树,且它们子树和的乘积尽可能大。

由于答案可能会很大,请你将结果对 10^9 + 7 取模后再返回。

 

示例 1:

输入:root = [1,2,3,4,5,6]
输出:110
解释:删除红色的边,得到 2 棵子树,和分别为 11 和 10 。它们的乘积是 110 (11*10)

示例 2:

输入:root = [1,null,2,3,4,null,null,5,6]
输出:90
解释:移除红色的边,得到 2 棵子树,和分别是 15 和 6 。它们的乘积为 90 (15*6)

示例 3:

输入:root = [2,3,9,10,7,8,6,5,4,11,1]
输出:1025

示例 4:

输入:root = [1,1]
输出:1

 

提示:

  • 每棵树最多有 50000 个节点,且至少有 2 个节点。
  • 每个节点的值在 [1, 10000] 之间。
❌
❌