普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月14日掘金 前端

React 闭包陷阱深度解析:从词法作用域到快照渲染

作者 NEXT06
2026年2月14日 21:59

在 React 函数式组件的开发过程中,开发者常会遭遇一种“幽灵般”的状态异常:页面 UI 已经正确响应并更新了最新的状态值,但在 setInterval 定时器、useEffect 异步回调或原生事件监听器中,打印出的变量却始终停滞在初始值。

这种现象通常被误认为是 React 的 Bug,但其本质是 JavaScript 语言核心机制——词法作用域(Lexical Scoping)与 React 函数式组件渲染特性发生冲突的产物。在社区中,这被称为“闭包陷阱”(Stale Closure)或“过期的闭包”。

本文将摒弃表象,从内存模型与执行上下文的角度,剖析这一问题的成因及标准解决方案。

核心原理:陷阱是如何形成的

要理解闭包陷阱,必须首先理解两个核心的前置概念:JavaScript 的词法作用域与 React 的快照渲染。

1. JavaScript 的词法作用域 (Lexical Scoping)

JavaScript 中的函数在定义时,其作用域链就已经确定了。闭包是指函数可以访问其定义时所在作用域中的变量。关键在于:闭包捕获的是函数创建那一刻的变量引用。如果该变量在后续没有发生引用地址的变更(如 const 声明的原始类型),闭包内访问的永远是创建时的那个值。

2. React 的快照渲染 (Rendering Snapshots)

React 函数组件的每一次渲染(Render),本质上都是一次独立的函数调用。

  • Render 1:React 调用 Component 函数,创建了一组全新的局部变量(包括 props 和 state)。
  • Render 2:React 再次调用 Component 函数,创建了另一组全新的局部变量。

虽然两次渲染中的变量名相同(例如都叫 count),但在内存中它们是完全不同、互不干扰的独立副本。每次渲染都像是一张“快照”,固定了当时的数据状态。

3. 致命结合:持久化闭包与过期快照

当我们将 useEffect 的依赖数组设置为空 [] 时,意味着该 Effect 只在组件挂载(Mount)时执行一次。

  1. Mount (Render 1) :count 初始化为 0。useEffect 执行,创建一个定时器回调函数。该回调函数通过闭包捕获了 Render 1 作用域中的 count (0)。
  2. Update (Render 2) :状态更新,count 变为 1。React 再次调用组件函数,产生了一个新的 count 变量 (1)。
  3. Conflict:由于依赖数组为空,useEffect 没有重新运行。内存中运行的依然是 Render 1 时创建的那个回调函数。该函数依然持有 Render 1 作用域的引用,因此它看到的永远是 count: 0。

代码实战与剖析

以下是一个经典的闭包陷阱反面教材。请注意代码注释中的内存快照分析。

JavaScript

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // 闭包陷阱发生地
    const timer = setInterval(() => {
      // 这里的箭头函数在 Render 1 时被定义
      // 根据词法作用域,它捕获了 Render 1 上下文中的 count 常量
      // Render 1 的 count 值为 0
      console.log('Current Count:', count); 
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 依赖数组为空,导致 effect 不会随组件更新而重建

  return (
    <div>
      <p>UI Count: {count}</p>
      {/* 点击按钮触发重渲染 (Render 2, 3...) */}
      <button onClick={() => setCount(count + 1)}>Add</button>
    </div>
  );
}

内存行为分析:

  • Render 1: count (内存地址 A) = 0。setInterval 创建闭包,引用地址 A。
  • User Click: 触发更新。
  • Render 2: count (内存地址 B) = 1。组件函数重新执行,创建了新变量。
  • Result: 此时 UI 渲染使用的是地址 B 的数据,但后台运行的定时器依然死死抓住地址 A 不放。

解决方案:逃离陷阱的三个层级

针对不同场景,我们有三种标准的架构方案来解决此问题。

方案一:规范依赖 (The Standard Way)

遵循 React Hooks 的设计规范,诚实地将所有外部依赖填入依赖数组。

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current Count:', count);
  }, 1000);

  return () => clearInterval(timer);
}, [count]); //  将 count 加入依赖
  • 原理:每当 count 变化,React 会先执行清除函数(clearInterval),然后重新运行 Effect。这将创建一个新的定时器回调,新回调捕获的是当前最新渲染作用域中的 count。
  • 代价:定时器会被频繁销毁和重建。如果计时精度要求极高,这种重置可能会导致时间偏差。

方案二:函数式更新 (The Functional Way)

如果逻辑仅仅是基于旧状态更新新状态,而不需要在副作用中读取状态值,可以使用 setState 的函数式更新。

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    //  这里的 c 是 React 内部传入的最新 state,不依赖闭包中的 count
    setCount(prevCount => prevCount + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依赖依然为空,但逻辑正确
  • 原理:React 允许将回调函数传递给 setter。执行时,React 内部会将最新的 State 注入该回调。这种方式绕过了当前闭包作用域的限制,直接操作 React 的状态队列。

方案三:Ref 引用 (The Ref Way)

如果必须在 useEffect 中读取最新状态,且不希望重启定时器,useRef 是最佳逃生舱。

JavaScript

const [count, setCount] = useState(0);
const countRef = useRef(count);

// 同步 Ref:每次渲染都更新 ref.current
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    //  访问 ref.current。
    // ref 对象在组件生命周期内引用地址不变,但其 current 属性是可变的。
    // 闭包捕获的是 ref 对象的引用,因此总能读到最新的 current 值。
    console.log('Current Count:', countRef.current);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依赖为空,且定时器不会重启
  • 原理:useRef 创建了一个可变的容器。闭包虽然被锁死在首次渲染,但它锁死的是这个“容器”的引用。容器内部的内容(current)是随渲染实时更新的,从而实现了“穿透”闭包读取最新数据。

总结

React 闭包陷阱的本质,是持久化的闭包引用了过期的快照变量

这并非框架设计的缺陷,而是函数式编程模型与 JavaScript 语言特性的必然交汇点。作为架构师,在处理此类问题时应遵循以下建议:

  1. 诚实对待依赖数组:绝大多数闭包问题源于试图欺骗 React,省略依赖项。ESLint 的 react-hooks/exhaustive-deps 规则应当被严格遵守。
  2. 理解引用的本质:清楚区分什么是不可变的快照(State/Props),什么是可变的容器(Ref)。在跨渲染周期的副作用中共享数据,Ref 是唯一的桥梁。

useMemo 与 useCallback 的原理与最佳实践

作者 NEXT06
2026年2月14日 21:36

在 React 的组件化架构中,性能优化往往不是一项大刀阔斧的重构工程,而是体现在对每一次渲染周期的精准控制上。作为一名拥有多年实战经验的前端架构师,我见证了无数应用因为忽视了 React 的渲染机制,导致随着业务迭代,页面交互变得愈发迟缓。

本文将深入探讨 React Hooks 中的两个关键性能优化工具:useMemo 和 useCallback。我们将透过现象看本质,理解它们如何解决“全量渲染”的痛点,并剖析实际开发中容易忽视的闭包陷阱。

引言:React 的渲染痛点与“摩天大楼”困境

想象一下,你正在建造一座摩天大楼(你的 React 应用)。每当大楼里的某一个房间(组件)需要重新装修(更新状态)时,整个大楼的施工队都要停下来,把整栋楼从地基到顶层重新刷一遍油漆。这听起来极度荒谬且低效,但在 React 默认的渲染行为中,这往往就是现实。

React 的核心机制是“响应式”的:当父组件的状态发生变化触发更新时,React 会默认递归地重新渲染该组件下的所有子组件。这种“全量渲染”策略保证了 UI 与数据的高度一致性,但在复杂应用中,它带来了不可忽视的性能开销:

  1. 昂贵的计算重复执行:与视图无关的复杂逻辑被反复计算。
  2. DOM Diff 工作量激增:虽然 Virtual DOM 很快,但构建和对比庞大的组件树依然消耗主线程资源。

性能优化的核心理念在于**“惰性”“稳定”**:只在必要时进行计算,只在依赖变化时触发重绘。


第一部分:useMemo —— 计算结果的缓存(值维度的优化)

核心定义

useMemo 可以被视为 React 中的 computed 计算属性。它的本质是“记忆化”(Memoization):在组件渲染期间,缓存昂贵计算的返回值。只有当依赖项发生变化时,才会重新执行计算函数的逻辑。

场景与反例解析

让我们看一个典型的性能瓶颈场景。假设我们有一个包含大量数据的列表,需要根据关键词过滤,同时组件内还有一个与列表无关的计数器 count。

未优化的代码(性能痛点)

JavaScript

import { useState } from 'react';

// 模拟昂贵的计算函数
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);
  const [keyword, setKeyword] = useState('');
  const [num, setNum] = useState(10);
  const list = ['apple', 'banana', 'orange', 'pear']; // 假设这是个大数组

  // 痛点 1:每次 App 渲染(如点击 count+1),filter 都会重新执行
  // 即使 keyword 根本没变
  const filterList = list.filter(item => {
    console.log('列表过滤执行');
    return item.includes(keyword);
  });
  
  // 痛点 2:每次 App 渲染,slowSum 都会重新运行
  // 导致点击 count 按钮时页面出现明显卡顿
  const result = slowSum(num);

  return (
    <div>
      <p>计算结果: {result}</p>
      {/* 输入框更新 keyword */}
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      
      {/* 仅仅是更新计数器,却触发了上面的重计算 */}
      <button onClick={() => setCount(count + 1)}>Count + 1 ({count})</button>
      
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

在上述代码中,仅仅是为了更新 UI 上的 count 数字,主线程却被迫去执行千万次的循环和数组过滤,这是极大的资源浪费。

优化后的代码

利用 useMemo,我们可以将计算逻辑包裹起来,使其具备“惰性”。

JavaScript

import { useState, useMemo } from 'react';

// ... slowSum 函数保持不变

export default function App() {
  // ... 状态定义保持不变

  // 优化 1:依赖为 [keyword],只有关键词变化时才重算列表
  const filterList = useMemo(() => {
    console.log('列表过滤执行');
    return list.filter(item => item.includes(keyword));
  }, [keyword]);
  
  // 优化 2:依赖为 [num],点击 count 不会触发此处的昂贵计算
  const result = useMemo(() => {
    return slowSum(num);
  }, [num]);

  return (
    // ... JSX 保持不变
  );
}

底层解析

useMemo 利用了 React Fiber 节点的内部存储(memoizedState)。在渲染过程中,React 会取出上次存储的 [value, deps],并将当前的 deps 与上次的进行浅比较(Shallow Compare)。

  • 如果依赖项完全一致,直接返回存储的 value,跳过函数执行。
  • 如果依赖项发生变化,执行函数,更新缓存。

第二部分:useCallback —— 函数引用的稳定(引用维度的优化)

核心定义

useCallback 用于缓存“函数实例本身”。它的作用不是为了减少函数创建的开销(JS 创建函数的开销极小),而是为了保持函数引用地址的稳定性,从而避免下游子组件因为 props 变化而进行无效重渲染。

痛点:引用一致性问题

在 JavaScript 中,函数是引用类型,且 函数 === 对象。
在 React 函数组件中,每次重新渲染(Re-render)都会重新执行组件函数体。这意味着,定义在组件内部的函数(如事件回调)每次都会被重新创建,生成一个新的内存地址。

比喻:咖啡店点单

为了理解这个概念,我们可以通过“咖啡店点单”来比喻:

  • 未优化的情况:你每次去咖啡店点单,都派一个替身去。虽然替身说的台词一模一样(“一杯拿铁,加燕麦奶”),但对于店员(子组件)来说,每次来的都是一个陌生人。店员必须重新确认身份、重新建立订单记录。这就是子组件因为函数引用变化而被迫重绘。
  • 使用 useCallback:你本人亲自去点单。店员一看:“还是你啊,老样子?”于是直接复用之前的订单记录,省去了沟通成本。这就是引用稳定带来的性能收益。

实战演示:父子组件的协作

失效的优化(反面教材)

JavaScript

import { useState, memo } from 'react';

// 子组件使用了 memo,理论上 Props 不变就不应该重绘
const Child = memo(({ handleClick }) => {
  console.log('子组件发生渲染'); // 目标:不希望看到这行日志
  return <button onClick={handleClick}>点击子组件</button>;
});

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

  // 问题所在:
  // 每次 App 渲染(点击 count+1),handleClick 都会被重新定义
  // 生成一个新的函数引用地址 (fn1 !== fn2)
  const handleClick = () => {
    console.log('子组件被点击');
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>父组件 Count + 1</button>
      
      {/* 
        虽然 Child 加了 memo,但 props.handleClick 每次都变了
        导致 Child 认为 props 已更新,强制重绘
      */}
      <Child handleClick={handleClick} />
    </div>
  );
}

正确的优化

我们需要使用 useCallback 锁定函数的引用,并配合 React.memo 使用。

JavaScript

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

const Child = memo(({ handleClick }) => {
  console.log('子组件发生渲染'); 
  return <button onClick={handleClick}>点击子组件</button>;
});

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

  // 优化:依赖项为空数组 [],表示该函数引用永远不会改变
  // 无论 App 渲染多少次,handleClick 始终指向同一个内存地址
  const handleClick = useCallback(() => {
    console.log('子组件被点击');
  }, []); 

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>父组件 Count + 1</button>
      
      {/* 
        现在:
        1. handleClick 引用没变
        2. Child 组件检测到 props 未变
        3. 跳过渲染 -> 性能提升
      */}
      <Child handleClick={handleClick} />
    </div>
  );
}

关键结论

useCallback 必须配合 React.memo 使用
如果在没有 React.memo 包裹的子组件上使用 useCallback,不仅无法带来性能提升,反而因为增加了额外的 Hooks 调用和依赖数组对比,导致性能变为负优化。


第三部分:避坑指南 —— 闭包陷阱与依赖项管理

在使用 Hooks 进行优化时,开发者常遇到“数据不更新”的诡异现象,这通常被称为“陈旧闭包”(Stale Closures)。

闭包陷阱的概念

Hooks 中的函数会捕获其定义时的作用域状态。如果依赖项数组没有正确声明,Memoized 的函数就会像一个“时间胶囊”,永远封存了旧的变量值,无法感知外部状态的更新。

典型场景与解决方案

场景:定时器或事件监听

假设我们希望在 useEffect 或 useCallback 中打印最新的 count。

JavaScript

// 错误示范
useEffect(() => {
  const timer = setInterval(() => {
    // 陷阱:这里的 count 永远是初始值 0
    // 因为依赖数组为空,闭包只在第一次渲染时创建,捕获了当时的 count
    console.log('Current count:', count); 
  }, 1000);
  return () => clearInterval(timer);
}, []); // ❌ 依赖项缺失

解决方案

  1. 诚实地填写依赖项(不推荐用于定时器):
    将 [count] 加入依赖。但这会导致定时器在每次 count 变化时被清除并重新设定,违背了初衷。

  2. 函数式更新(推荐):
    如果只是为了设置状态,使用 setState 的回调形式。

    JavaScript

    //  不需要依赖 count 也能实现累加
    setCount(prevCount => prevCount + 1);
    
  3. 使用 useRef 逃生舱(推荐用于读取值):
    useRef 返回的 ref 对象在组件整个生命周期内保持引用不变,且 current 属性是可变的。

    codeJavaScript

    const countRef = useRef(count);
    
    // 每次渲染更新 ref.current
    useEffect(() => {
      countRef.current = count;
    });
    
    useEffect(() => {
      const timer = setInterval(() => {
        //  总是读取到最新的值,且不需要重建定时器
        console.log('Current count:', countRef.current);
      }, 1000);
      return () => clearInterval(timer);
    }, []); // 依赖保持为空
    

总结:三兄弟的协作与克制

在 React 性能优化的工具箱中,我们必须清晰区分这“三兄弟”的职责:

  1. useMemo缓存值。用于节省 CPU 密集型计算的开销。
  2. useCallback缓存函数。用于维持引用稳定性,防止下游组件无效渲染。
  3. React.memo缓存组件。用于拦截 Props 对比,作为重绘的最后一道防线。

架构师的建议:保持克制

性能优化并非免费午餐。useMemo 和 useCallback 本身也有内存占用和依赖对比的计算开销。

请遵循以下原则:

  • 不要预先优化:不要默认给所有函数套上 useCallback。
  • 不要优化轻量逻辑:对于简单的 a + b 或原生 DOM 事件(如 
    ),原生 JS 的执行速度远快于 Hooks 的开销。
  • 先定位,后治理:使用 React DevTools Profiler 找出真正耗时的组件,再针对性地使用上述工具进行“外科手术式”的优化。

掌握了这些原理与最佳实践,你便不再是盲目地编写 Hooks,而是能够像架构师一样,精准控制应用的每一次渲染脉搏。

html翻页时钟 效果

作者 大时光
2026年2月14日 17:47
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Flip Clock</title>
  <style>
    body {
      background: #111;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      margin: 0;
      font-family: 'Courier New', monospace;
      color: white;
    }

    .clock {
      display: flex;
      gap: 20px;
    }

    .card-container {
      width: 80px;
      height: 120px;
      position: relative;
      perspective: 500px;
      background: #2c292c;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.5);
    }

    /* 中间分割线 */
    .card-container::before {
      content: "";
      position: absolute;
      left: 0;
      top: 50%;
      width: 100%;
      height: 4px;
      background: #120f12;
      z-index: 10;
    }

    .card-item {
      position: absolute;
      width: 100%;
      height: 50%;
      left: 0;
      overflow: hidden;
      background: #2c292c;
      color: white;
      text-align: center;
      font-size: 64px;
      font-weight: bold;
      backface-visibility: hidden;
      transition: transform 0.4s ease-in-out;
    }

    /* 下层数字:初始对折(背面朝上) */
    .card1 { /* 下层上半 */
      top: 0;
      line-height: 120px; /* 整体高度对齐 */
    }
    .card2 { /* 下层下半 */
      top: 50%;
      line-height: 0;
      transform-origin: center top;
      transform: rotateX(180deg);
      z-index: 2;
    }

    /* 上层数字:当前显示 */
    .card3 { /* 上层上半 */
      top: 0;
      line-height: 120px;
      transform-origin: center bottom;
      z-index: 3;
    }
    .card4 { /* 上层下半 */
      top: 50%;
      line-height: 0;
      z-index: 1;
    }

    /* 翻页动画触发 */
    .flip .card2 {
      transform: rotateX(0deg);
    }
    .flip .card3 {
      transform: rotateX(-180deg);
    }

    /* 冒号分隔符 */
    .colon {
      font-size: 64px;
      display: flex;
      align-items: center;
      color: #aaa;
    }
  </style>
</head>
<body>
  <div class="clock">
    <div class="card-container flip" id="hour" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>

    <div class="colon">:</div>

    <div class="card-container flip" id="minute" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>

    <div class="colon">:</div>

    <div class="card-container flip" id="second" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>
  </div>

  <script>
    function setHTML(dom, nextValue) {
      const curValue = dom.dataset.number;
      if (nextValue === curValue) return;

      // 更新 DOM 结构:下层为新值,上层为旧值
      dom.innerHTML = `
        <div class="card1 card-item">${nextValue}</div>
        <div class="card2 card-item">${nextValue}</div>
        <div class="card3 card-item">${curValue}</div>
        <div class="card4 card-item">${curValue}</div>
      `;

      // 触发重绘以重启动画
      dom.classList.remove('flip');
      void dom.offsetWidth; // 强制重排
      dom.classList.add('flip');

      dom.dataset.number = nextValue;
    }

    function updateClock() {
      const now = new Date();
      const h = String(now.getHours()).padStart(2, '0');
      const m = String(now.getMinutes()).padStart(2, '0');
      const s = String(now.getSeconds()).padStart(2, '0');

      setHTML(document.getElementById('hour'), h);
      setHTML(document.getElementById('minute'), m);
      setHTML(document.getElementById('second'), s);
    }

    // 初始化
    updateClock();
// setTimeout(updateClock,1000)

    setInterval(updateClock, 1000);
  </script>
</body>
</html>

在这里插入图片描述 这个翻页时钟(Flip Clock)通过 CSS 3D 变换 + 动画类切换 + DOM 内容动态更新 的方式,模拟了类似机械翻页牌的效果。下面从结构、样式和逻辑三方面详细分析其实现原理:


🔧 一、HTML 结构设计

每个时间单位(小时、分钟、秒)由一个 .card-container 容器表示,内部包含 4 个 .card-item 元素

<div class="card-container" id="second">
  <div class="card1">00</div> <!-- 下层上半 -->
  <div class="card2">00</div> <!-- 下层下半(初始翻转180°)-->
  <div class="card3">00</div> <!-- 上层上半(当前显示)-->
  <div class="card4">00</div> <!-- 上层下半 -->
</div>

四个卡片的作用:

  • .card3.card4:组成当前显示的数字(上半+下半),正常显示。
  • .card1.card2:组成即将翻出的新数字,但初始时 .card2rotateX(180deg) 翻转到背面(不可见)。
  • 中间有一条 ::before 伪元素作为“折痕”,增强翻页视觉效果。

🎨 二、CSS 样式与 3D 翻转原理

关键 CSS 技术点:

1. 3D 空间设置

.card-container {
  perspective: 500px; /* 创建 3D 视角 */
}
  • perspective 让子元素的 3D 变换有景深感。

2. 上下两半的定位与旋转轴

.card2 {
  transform-origin: center top;
  transform: rotateX(180deg); /* 初始翻到背面 */
}
.card3 {
  transform-origin: center bottom;
}
  • .card2顶部边缘旋转 180°,藏在下方背面。
  • .card3底部边缘旋转,用于向上翻折。

3. 翻页动画(通过 .flip 类触发)

.flip .card2 {
  transform: rotateX(0deg); /* 展开新数字下半部分 */
}
.flip .card3 {
  transform: rotateX(-180deg); /* 当前数字上半部分向上翻折隐藏 */
}
  • 动画持续 0.4s,使用 ease-in-out 缓动。
  • .card1.card4 始终保持静态,作为背景支撑。

视觉效果

  • 上半部分(.card3)向上翻走(像书页翻开)
  • 下半部分(.card2)从背面转正,露出新数字
  • 中间的“折痕”让翻页更真实

⚙️ 三、JavaScript 动态更新逻辑

核心函数:setHTML(dom, nextValue)

步骤分解:

  1. 对比新旧值:如果相同,不更新(避免无谓动画)。
  2. 重写整个容器的 HTML
    • 下层(新值).card1.card2 显示 nextValue
    • 上层(旧值).card3.card4 显示 curValue
  3. 触发动画
    dom.classList.remove('flip');
    void dom.offsetWidth; // 强制浏览器重排(关键!)
    dom.classList.add('flip');
    
    • 先移除 .flip,再强制重排(flush styles),再加回 .flip,确保动画重新触发。
  4. 更新 data-number 保存当前值。

时间更新:

  • 每秒调用 updateClock(),获取当前时分秒(两位数格式)。
  • 分别调用 setHTML 更新三个容器。

🌟 四、为什么能实现“翻页”错觉?

元素 初始状态 翻页后状态 视觉作用
.card3 显示旧数字上半 向上翻转 180° 隐藏 模拟“翻走”的上半页
.card2 旧数字下半(翻转180°藏起) 转正显示新数字下半 模拟“翻出”的下半页
.card1 / .card4 静态背景 不变 提供视觉连续性

💡 关键技巧

  • 利用 两个完整数字(新+旧)叠加,通过控制上下半部分的旋转,制造“翻页”而非“淡入淡出”。
  • 强制重排(offsetWidth 是确保 CSS 动画每次都能重新触发的经典 hack。

✅ 总结

这个 Flip Clock 的精妙之处在于:

  1. 结构设计:4 个卡片分工明确,上下层分离。
  2. CSS 3D:利用 rotateX + transform-origin 实现真实翻页。
  3. JS 控制:动态替换内容 + 巧妙触发动画。
  4. 性能优化:仅在值变化时更新,避免无效渲染。

这是一种典型的 “用 2D DOM 模拟 3D 物理效果” 的前端动画范例,既高效又视觉惊艳。

js 封装 动画效果

作者 大时光
2026年2月14日 17:42
/**
 * 通用动画函数
 * @param {Object} options 配置对象
 * @param {number} [options.duration] 动画持续时间 (毫秒),如果提供则优先使用
 * @param {number} [options.speed] 动画速度 (单位/毫秒),当未提供 duration 时生效
 * @param {number} options.from 起始值,默认为 0
 * @param {number} options.to 结束值
 * @param {Function} [options.callback] 每一帧的回调函数,接收 (currentValue, progress) 作为参数
 * @param {Function} [options.onComplete] 动画结束时的回调函数
 * @param {Function} [legacyCallback] 兼容旧调用的第二个参数作为回调
 * @returns {Function} 取消动画的函数
 */
let animateMoveFn = ({ duration, speed, from, to, callback, onComplete }) => {
 
    // --- 参数类型校验开始 ---
    
    // 校验 from
    if (from === undefined || from === null) {
        console.error(`animateMoveFn: "from" 必须是数字且必填。当前值: ${from}。动画将不执行。`);
        return () => { }; // 返回空的取消函数
    }
    if (typeof from !== 'number' || isNaN(from)) {
        console.warn(`animateMoveFn: "from" 必须是数字。当前值: ${from}。已重置为 0。`);
        return () => { }; // 返回空的取消函数
    }

    // 校验 to
    if (to === undefined || to === null) {
        console.error(`animateMoveFn: "to" 必须是数字且必填。当前值: ${to}。动画将不执行。`);
        return () => { }; // 返回空的取消函数
    }
    if (typeof to !== 'number' || isNaN(to)) {
        console.warn(`animateMoveFn: "to" 必须是数字。当前值: ${to}。已重置为 0。`);
        return () => { }; // 返回空的取消函数
    }

    // 校验 duration
    if (duration !== undefined && duration !== null) {
        if (typeof duration !== 'number' || isNaN(duration) || duration < 0) {
            console.warn(`animateMoveFn: "duration" 必须是非负数字。当前值: ${duration}。将忽略此参数。`);
            duration = undefined;
        }
    }

    // 校验 speed
    if (speed !== undefined && speed !== null) {
        if (typeof speed !== 'number' || isNaN(speed) || speed <= 0) {
            console.warn(`animateMoveFn: "speed" 必须是正数字。当前值: ${speed}。将忽略此参数。`);
            speed = undefined;
        }
    }

    // 校验 callback
    if (callback !== undefined && typeof callback !== 'function') {
        console.warn(`animateMoveFn: "callback" 必须是函数。当前类型: ${typeof callback}。`);
        callback = null;
    }

    // 校验 onComplete
    if (onComplete !== undefined && typeof onComplete !== 'function') {
        console.warn(`animateMoveFn: "onComplete" 必须是函数。当前类型: ${typeof onComplete}。`);
        onComplete = null;
    }

    // --- 参数类型校验结束 ---

    // 记录动画开始的时间戳
    let startTime = Date.now();

    // 存储当前的 requestAnimationFrame ID,用于取消动画
    let reqId = null;

    // 动画是否已取消的标志
    let isCancelled = false;

    // 核心动画循环函数
    let moveFn = () => {
        
        // 如果动画已取消,直接退出
        if (isCancelled) return;

        // 计算从开始到现在经过的时间
        let elapsed = Date.now() - startTime;

        // 当前动画进度 (0 到 1 之间)
        let progress = 0;

        if (duration && duration > 0) {
            // 模式 1: 基于持续时间 (Duration-based)
            progress = elapsed / duration;
        } else if (speed && speed > 0) {
            // 模式 2: 基于速度 (Speed-based)
            // 计算总距离
            let totalDistance = Math.abs(to - from);
            if (totalDistance === 0) {
                progress = 1;
            } else {
                // 已移动距离 = 速度 * 时间
                let coveredDistance = speed * elapsed;
                progress = coveredDistance / totalDistance;
            }
        } else {
            // 既无 duration 也无 speed,或者值无效,默认直接完成
            progress = 1;
        }

        // 确保进度不超过 1
        if (progress > 1) progress = 1;

        // 计算当前值:起始值 + (总变化量 * 进度)
        // 使用线性插值 (Linear Interpolation)
        let currentValue = from + (to - from) * progress;

        // 执行回调,将当前值和进度传递出去
        if (callback) {
            callback(currentValue, progress);
        }

        // 检查动画是否结束
        if (progress < 1) {
            // 动画未结束,请求下一帧
            reqId = requestAnimationFrame(moveFn);
        } else {
            // 动画结束
            onComplete(currentValue, progress);
        }
    };

    // 启动动画
    reqId = requestAnimationFrame(moveFn);

    // 返回一个取消函数,外部调用它可以立即停止动画
    return () => {
        isCancelled = true;
        if (reqId) {
            cancelAnimationFrame(reqId);
        }
    };
};

// 兼容旧的命名(如果项目中有其他地方用到)
window.animateMoeveFn = animateMoveFn;
window.animateMoveFn = animateMoveFn;

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Animation Test</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
            max-width: 800px;
            margin: 0 auto;
        }
        .box {
            width: 50px;
            height: 50px;
            background-color: #e74c3c;
            position: relative;
            margin-bottom: 30px;
            border-radius: 4px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-weight: bold;
            font-size: 12px;
        }
        .controls {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
            gap: 10px;
            margin-bottom: 20px;
        }
        button {
            padding: 10px 15px;
            cursor: pointer;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 4px;
            font-size: 14px;
            transition: background 0.2s;
        }
        button:hover {
            background-color: #2980b9;
        }
        button.cancel {
            background-color: #e67e22;
        }
        button.cancel:hover {
            background-color: #d35400;
        }
        #output {
            padding: 15px;
            border: 1px solid #ddd;
            background: #f8f9fa;
            max-height: 300px;
            overflow-y: auto;
            font-family: 'Consolas', monospace;
            font-size: 13px;
            border-radius: 4px;
        }
        .log-entry {
            margin-bottom: 4px;
            border-bottom: 1px solid #eee;
            padding-bottom: 2px;
        }
        .log-time {
            color: #888;
            margin-right: 8px;
        }
        .log-success { color: #27ae60; font-weight: bold; }
        .log-warn { color: #e67e22; }
        .log-error { color: #c0392b; }
    </style>
</head>
<body>

    <h1>Animation Test for ani.js</h1>

    <div class="box" id="testBox">0</div>

    <div class="controls">
        <button id="btnDuration">1. 时长模式 (Duration)</button>
        <button id="btnSpeed">2. 速度模式 (Speed)</button>
        <button id="btnReverse">3. 反向动画 (Reverse)</button>
        <button id="btnOnComplete">4. 完整回调 (onComplete)</button>
        <button id="btnPriority">5. 优先级 (Duration > Speed)</button>
        <button id="btnError">6. 错误参数测试 (Check Console)</button>
        <button id="btnCancel" class="cancel">7. 中途取消 (Cancel)</button>
        <button id="btnClearLog" style="background:#95a5a6">清除日志</button>
    </div>

    <div id="output">日志准备就绪...</div>

    <script src="./js/ani.js"></script>
    <script>
        const box = document.getElementById('testBox');
        const output = document.getElementById('output');
        let currentCancelFn = null;

        function log(msg, type = 'normal') {
            const div = document.createElement('div');
            div.className = 'log-entry';
            
            const timeSpan = document.createElement('span');
            timeSpan.className = 'log-time';
            timeSpan.textContent = `[${new Date().toLocaleTimeString()}]`;
            
            const msgSpan = document.createElement('span');
            msgSpan.textContent = msg;
            
            if (type === 'success') msgSpan.className = 'log-success';
            if (type === 'warn') msgSpan.className = 'log-warn';
            if (type === 'error') msgSpan.className = 'log-error';

            div.appendChild(timeSpan);
            div.appendChild(msgSpan);
            output.prepend(div);
        }

        function reset(startVal = 0) {
            if (currentCancelFn) {
                currentCancelFn();
                currentCancelFn = null;
                log('上一个动画已终止', 'warn');
            }
            box.style.left = startVal + 'px';
            box.textContent = Math.round(startVal);
        }

        // 1. 基础时长模式
        document.getElementById('btnDuration').onclick = () => {
            reset(0);
            log('测试1: 基于 Duration (0 -> 500px, 1000ms)');
            
            currentCancelFn = animateMoveFn({
                duration: 1000,
                from: 0,
                to: 500,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: (val) => {
                    log(`动画结束: 到达 ${val}px`, 'success');
                }
            });
        };

        // 2. 速度模式
        document.getElementById('btnSpeed').onclick = () => {
            reset(0);
            log('测试2: 基于 Speed (0 -> 500px, speed: 0.5px/ms)');
            log('预期耗时: 500 / 0.5 = 1000ms');

            currentCancelFn = animateMoveFn({
                speed: 0.5, // 0.5px per ms = 500px per second
                from: 0,
                to: 500,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: (val) => {
                    log(`动画结束: 到达 ${val}px`, 'success');
                }
            });
        };

        // 3. 反向动画
        document.getElementById('btnReverse').onclick = () => {
            reset(500);
            log('测试3: 反向动画 (500 -> 0px, speed: 1px/ms)');
            
            currentCancelFn = animateMoveFn({
                speed: 1, // 1000px/s, fast!
                from: 500,
                to: 0,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: () => log('反向动画结束', 'success')
            });
        };

        // 4. onComplete 测试
        document.getElementById('btnOnComplete').onclick = () => {
            reset(0);
            log('测试4: 测试 onComplete 回调');
            
            currentCancelFn = animateMoveFn({
                duration: 500,
                from: 0,
                to: 200,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: (val, progress) => {
                    log(`onComplete 触发! Val: ${val}, Progress: ${progress}`, 'success');
                    box.style.backgroundColor = '#2ecc71'; // 变绿
                    setTimeout(() => box.style.backgroundColor = '#e74c3c', 500); // 变回红
                }
            });
        };

        // 5. 优先级测试
        document.getElementById('btnPriority').onclick = () => {
            reset(0);
            log('测试5: 优先级测试 (传入 duration=2000 和 speed=10)');
            log('预期: 应该使用 duration (2秒),忽略极快的 speed');

            currentCancelFn = animateMoveFn({
                duration: 2000,
                speed: 10, // 如果生效只要 50ms,如果不生效要 2000ms
                from: 0,
                to: 500,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: () => log('动画结束 (检查耗时是否接近 2秒)', 'success')
            });
        };

        // 6. 错误参数测试
        document.getElementById('btnError').onclick = () => {
            reset(0);
            log('测试6: 错误参数 (请查看浏览器控制台 Console)', 'warn');
            
            // Case A: 缺少 to
            log('Case A: 缺少 "to" 参数 -> 应该报错不执行');
            animateMoveFn({ duration: 1000, from: 0 });

            // Case B: 错误的 duration
            // setTimeout(() => {
            //     log('Case B: duration 为字符串 -> 应该警告并忽略');
            //     animateMoveFn({ 
            //         duration: "invalid", 
            //         speed: 1, // 备用方案
            //         from: 0, 
            //         to: 100,
            //         callback: (v) => box.style.left = v + 'px'
            //     });
            // }, 500);
        };

        // 7. 取消测试
        document.getElementById('btnCancel').onclick = () => {
            reset(0);
            log('测试7: 启动并在 500ms 后取消');

            currentCancelFn = animateMoveFn({
                duration: 2000,
                from: 0,
                to: 800,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: () => log('ERROR: 动画不应该完成!', 'error')
            });

            setTimeout(() => {
                if (currentCancelFn) {
                    currentCancelFn();
                    currentCancelFn = null;
                    log('已调用 cancel()', 'warn');
                }
            }, 500);
        };

        document.getElementById('btnClearLog').onclick = () => {
            output.innerHTML = '';
            log('日志已清空');
        };

    </script>
</body>
</html>


在这里插入图片描述

ani.js 动画库实现原理解析教程

本教程将带你深入了解 ani.js 的实现原理。这是一个轻量级的通用动画函数,旨在通过精确的时间控制来实现平滑的数值过渡效果。它不仅支持传统的时长模式 (Duration),还创新地引入了速度模式 (Speed),非常适合用于 UI 交互、游戏开发或任何需要动态数值变化的场景。

1. 核心设计理念

ani.js 的核心思想是基于时间 (Time-based) 而非基于帧数 (Frame-based)

  • 基于帧数:每一帧增加固定的数值。如果设备卡顿,掉帧会导致动画变慢,总时长不可控。
  • 基于时间:根据当前时间与开始时间的差值 (elapsed) 来计算当前应处的位置。无论帧率如何波动,动画总是在预定的时间到达终点,保证了动画的流畅性和同步性。

2. 函数签名与参数设计

函数采用单一对象参数 options 的设计模式,这使得参数扩展变得非常灵活,同时保持了调用的清晰度。

let animateMoveFn = ({ 
    duration,   // 动画持续时间 (毫秒)
    speed,      // 动画速度 (单位/毫秒)
    from = 0,   // 起始值 (默认为 0)
    to,         // 结束值 (必填)
    callback,   // 每帧回调:(currentValue, progress) => {}
    onComplete  // 结束回调:(finalValue, progress) => {}
}) => { ... }

亮点分析:

  • 双模式驱动
    1. 时长优先:如果你提供了 duration,动画将严格在指定时间内完成。
    2. 速度优先:如果你未提供 duration 但提供了 speed,函数会自动根据 Math.abs(to - from) 计算所需时间。
  • 健壮性校验:函数内部对所有参数进行了严格的类型检查(如 typeof, isNaN),确保无效参数不会导致运行时错误,并提供友好的控制台警告。

3. 核心实现深度解析

3.1 动画循环 (The Loop)

动画引擎的心脏是 requestAnimationFrame。它比 setInterval 更高效,因为它会跟随浏览器的刷新率(通常是 60Hz),并在后台标签页暂停执行以节省电量。

let startTime = Date.now();
let moveFn = () => {
    // 1. 计算流逝的时间
    let elapsed = Date.now() - startTime;
    
    // 2. 计算进度 (0.0 ~ 1.0)
    // ... (核心算法见下文)

    // 3. 更新数值并绘制
    // ...

    // 4. 决定下一帧
    if (progress < 1) {
        reqId = requestAnimationFrame(moveFn);
    } else {
        // 动画结束
    }
};

3.2 进度计算策略 (The Math)

这是 ani.js 最精彩的部分。它根据输入模式动态决定进度计算方式:

模式 A:时长模式 (Duration Mode) 最常见的模式。进度等于“已过去的时间”除以“总时长”。

progress = elapsed / duration;

模式 B:速度模式 (Speed Mode) 当距离不确定,但希望保持恒定速度时使用(例如:无论滑块拖动多远,回弹速度一致)。

let totalDistance = Math.abs(to - from);
let coveredDistance = speed * elapsed; // 速度 * 时间 = 路程
progress = coveredDistance / totalDistance;

3.3 线性插值 (Linear Interpolation / Lerp)

一旦算出 progress (0 到 1 之间的浮点数),我们就可以计算当前的数值:

// 公式:当前值 = 起始值 + (总变化量 * 进度)
let currentValue = from + (to - from) * progress;

这个公式非常强大:

  • progress = 0 时,结果为 from
  • progress = 1 时,结果为 to
  • progress = 0.5 时,结果正好在中间。
  • 支持反向:即使 to < from,公式依然成立(因为 to - from 会是负数)。

3.4 生命周期管理与取消机制

为了让动画可控,函数返回了一个闭包函数 (Closure),用于取消动画。

return () => {
    isCancelled = true; // 标志位:阻止后续帧执行
    if (reqId) cancelAnimationFrame(reqId); // 清除浏览器队列中的请求
};

这种设计允许外部代码随时打断动画(例如用户再次触发了新的动画),防止多个动画冲突。

4. 最佳实践与使用示例

场景一:基础位移 (1秒内移动到 500px)

const cancel = animateMoveFn({
    duration: 1000,
    from: 0,
    to: 500,
    callback: (val) => element.style.left = val + 'px'
});

场景二:恒定速度回弹 (无论多远,速度都是 2px/ms)

const cancel = animateMoveFn({
    speed: 2, // 2000px/s,非常快
    from: currentPosition, // 动态获取当前位置
    to: 0,
    callback: (val) => element.style.left = val + 'px'
});

场景三:防止动画冲突 (Anti-conflict)

在启动新动画前,务必取消旧动画。

let currentAnim = null;

function startNewAnim() {
    if (currentAnim) currentAnim(); // 停止旧的
    
    currentAnim = animateMoveFn({
        to: 100,
        // ...
        onComplete: () => currentAnim = null // 结束后清理引用
    });
}

5. 总结

ani.js 是一个教科书式的现代 JavaScript 动画实现。它展示了如何通过:

  1. 参数解构与默认值 来提升 API 易用性。
  2. 防御性编程 来处理无效输入。
  3. 时间轴插值算法 来保证动画平滑度。
  4. 闭包与高阶函数 来管理状态和副作用。

掌握了这个函数的实现,你就掌握了前端动画引擎的基石。

gsap 配置解读 --7

作者 大时光
2026年2月14日 16:28

什么是registerEffect

 <div class="card">
      <h1>案例 41:registerEffect 自定义效果</h1>
      <p>封装常用动画为可复用效果。</p>
      <div class="row">
        <div class="box" id="boxA"></div>
        <div class="box" id="boxB"></div>
        <div class="box" id="boxC"></div>
      </div>
      <button id="play">播放效果</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script>
      const playButton = document.querySelector("#play");

      // 注册自定义效果
      gsap.registerEffect({
        name: "popIn",
        effect: (targets, config) => {
          return gsap.fromTo(
            targets,
            { scale: 0.6, opacity: 0 },
            {
              scale: 1,
              opacity: 1,
              duration: config.duration,
              stagger: config.stagger,
              ease: config.ease
            }
          );
        },
        defaults: { duration: 0.6, stagger: 0.08, ease: "back.out(1.6)" },
        extendTimeline: true
      });

      playButton.addEventListener("click", () => {
        gsap.effects.popIn(".box");
      });
    </script>

gsap.registerEffect()GSAP(GreenSock Animation Platform) 提供的一个强大功能,用于将常用的动画逻辑封装成可复用、可配置的“自定义效果”(custom effect),就像创建自己的动画“函数库”一样。


📌 简单定义:

registerEffect 允许你定义一个命名动画模板(如 "popIn"),之后通过 gsap.effects.effectName(targets, config) 一行代码即可在任意地方调用它,实现代码复用、语义化和团队协作标准化。


✅ 核心作用:

1. 封装复杂动画逻辑

把重复的 fromTotimeline 等逻辑打包成一个“黑盒”。

2. 支持参数配置

通过 config 对象传入自定义参数(如 durationstagger)。

3. 提供默认值

通过 defaults 设置常用参数的默认值,调用时可省略。

4. 无缝集成 GSAP 生态
  • 可用于 Timeline
  • 支持 stagger
  • 返回动画实例(可 play/pause/reverse

🔧 在你的代码中:

gsap.registerEffect({
  name: "popIn", // 效果名称
  effect: (targets, config) => {
    return gsap.fromTo(
      targets,
      { scale: 0.6, opacity: 0 },
      {
        scale: 1,
        opacity: 1,
        duration: config.duration,
        stagger: config.stagger,
        ease: config.ease
      }
    );
  },
  defaults: { 
    duration: 0.6, 
    stagger: 0.08, 
    ease: "back.out(1.6)" 
  },
  extendTimeline: true // 允许在 Timeline 中直接使用 .popIn()
});

然后调用:

gsap.effects.popIn(".box"); // 所有 .box 元素执行 popIn 动画

效果:

  • 三个盒子依次从小且透明放大到正常尺寸;
  • 带有弹性回弹(back.out 缓动);
  • 每个盒子延迟 0.08s 启动(stagger)。

🌟 优势 vs 普通函数封装:

普通函数 registerEffect
需手动管理返回值 ✅ 自动注册到 gsap.effects 命名空间
无法在 Timeline 中直接使用 ✅ 开启 extendTimeline: true 后可用 tl.popIn(...)
参数处理需自己写 ✅ 自动合并 configdefaults
团队协作需文档说明 ✅ 效果名称即文档(gsap.effects.popIn 语义清晰)

⚙️ 参数详解:

字段 说明
name 效果名称(字符串),注册后可通过 gsap.effects[name] 调用
effect(targets, config) 动画工厂函数: - targets: DOM 元素或选择器 - config: 用户传入的配置对象
defaults 默认配置(会被 config 覆盖)
extendTimeline 若为 true,可在 Timeline 实例上直接调用该效果: timeline.popIn(".box")

🛠️ 更多使用方式:

1. 传入自定义参数
gsap.effects.popIn(".item", {
  duration: 1,
  stagger: 0.2,
  ease: "elastic.out(1, 0.5)"
});
2. 在 Timeline 中使用(需 extendTimeline: true
const tl = gsap.timeline();
tl.popIn(".box", { duration: 0.5 });
3. 返回 Timeline 实现复杂效果
effect: (targets) => {
  const tl = gsap.timeline();
  tl.from(targets, { x: -100, opacity: 0 })
    .to(targets, { rotation: 360 }, "<");
  return tl;
}

🎨 典型应用场景:

  • UI 组件库:统一按钮点击、卡片入场、提示弹出等动效
  • 设计系统:确保全站动画风格一致
  • 游戏开发:角色受伤、道具拾取等特效复用
  • 快速原型:设计师给效果命名,开发者一键实现

⚠️ 注意事项:

  • 效果名称全局唯一,避免冲突;
  • targets 可以是单个元素、数组或 CSS 选择器字符串;
  • 免费功能registerEffect 是 GSAP 核心 API,无需额外插件或会员;
  • 如果效果内部使用了 ScrollTrigger 等插件,需确保已注册。

📚 官方文档:

👉 greensock.com/docs/v3/GSA…


✅ 总结:

gsap.registerEffect() 是 GSAP 的“动画组件化”方案——它将零散的动画代码提炼为可命名、可配置、可复用的效果模块,大幅提升开发效率与代码可维护性,是构建大型交互动效项目的最佳实践。

什么是ScrollTrigger

<header>
      <h1>案例 42:ScrollTrigger Pin 固定</h1>
      <p>滚动时固定元素并配合进度动画。</p>
    </header>
    <div class="spacer"></div>
    <section>
      <div class="panel">
        <div class="pin" id="pin">我被固定了</div>
      </div>
    </section>
    <section>
      <div class="panel">继续往下滚动</div>
    </section>
    <div class="spacer"></div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
    <script>
      // 注册 ScrollTrigger 插件
      gsap.registerPlugin(ScrollTrigger);

      // pin + scrub 绑定滚动进度
      gsap.to("#pin", {
        scale: 0.8,
        rotation: 10,
        scrollTrigger: {
          trigger: "#pin",
          start: "top center",
          end: "+=400",
          scrub: true,
          pin: true
        }
      });
    </script>

ScrollTriggerGSAP(GreenSock Animation Platform) 中最强大、最受欢迎的插件之一,它能将滚动行为(用户上下滑动页面)与GSAP 动画无缝结合,实现如“视差滚动”、“进度条动画”、“元素固定(Pin)”、“滚动触发动画”等高级交互动效。


📌 简单定义:

ScrollTrigger 让你把任何 GSAP 动画“绑定”到页面滚动位置上——当用户滚动到某个区域时,动画自动播放、暂停、反向或跟随滚动进度实时更新。

它本质上是一个滚动驱动的动画控制器


✅ 核心能力:

1. 触发动画(Toggle)
  • 当元素进入/离开视口时,播放/暂停动画。
scrollTrigger: {
  trigger: ".section",
  start: "top center", // 当 .section 顶部到达视口中心时触发
  toggleActions: "play none none reverse"
}
2. 滚动进度驱动(Scrub)
  • 动画进度完全跟随滚动位置,形成“拖拽式”效果。
scrollTrigger: {
  scrub: true // 滚动多少,动画就播放到多少
}
3. 固定元素(Pin)你的案例重点
  • 在滚动过程中将元素“钉”在视口某处,直到滚动结束。
scrollTrigger: {
  pin: true,       // 固定 trigger 元素
  // 或 pin: "#otherElement" 固定其他元素
  end: "+=400"     // 固定持续 400px 的滚动距离
}
4. 标记与指示器(Markers)
  • 开发时显示调试线(start/end 位置),方便调整。
scrollTrigger: {
  markers: true // 显示绿色(start)和红色(end)标记线
}

gsap.to("#pin", {
  scale: 0.8,
  rotation: 10,
  scrollTrigger: {
    trigger: "#pin",      // 监听 #pin 元素的滚动位置
    start: "top center",  // 当 #pin 顶部到达视口中心时开始
    end: "+=400",         // 滚动再往下 400px 后结束
    scrub: true,          // 动画进度随滚动平滑更新
    pin: true             // 在 start → end 区间内,#pin 被固定住
  }
});
用户体验流程:
  1. 向下滚动,当 #pin 到达屏幕中央时 →
  2. #pin 被固定在当前位置(不再随页面滚动而移动);
  3. 继续滚动的 400px 过程中,#pin 逐渐缩小并旋转scale: 0.8, rotation: 10);
  4. 滚动超过 400px 后,固定解除,#pin 随页面继续滚动。

💡 这就是“固定 + 进度动画”的经典组合,常用于产品展示、故事叙述等场景。


🌟 典型应用场景:

效果 描述
视差滚动 背景图慢速移动,前景快移
进度条/数字计数器 滚动时数字从 0 增长到目标值
元素入场/离场 卡片滑入、标题淡入
固定导航栏 滚动到某区域时导航栏吸顶
横向滚动画廊 垂直滚动驱动水平位移
3D 视差 滚动时多层元素产生景深感

⚙️ 关键配置项说明:

配置 作用
trigger 触发动画的参考元素(默认为动画目标)
start 动画开始的滚动位置(如 "top center"
end 动画结束的滚动位置(如 "bottom bottom""+=500"
scrub true = 平滑跟随滚动;number = 延迟秒数
pin true = 固定 trigger"#id" = 固定指定元素
toggleActions 控制进入/离开时的播放行为(play pause resume reset

📏 位置语法"edge1 edge2"

  • edge1: trigger 元素的边缘(top/bottom/center
  • edge2: 视口的边缘(top/bottom/center
    例如:"top bottom" = trigger 顶部碰到视口底部时触发

⚠️ 注意事项:

  • 必须注册插件:gsap.registerPlugin(ScrollTrigger)
  • pin自动包裹元素并设置 position: stickyfixed,无需手动写 CSS;
  • 如果页面高度不足,可能看不到完整效果(需确保有足够滚动空间);
  • 免费功能ScrollTrigger 是 GSAP 标准插件(无需 Club 会员);
  • 移动端性能优秀,支持触摸滚动。

📚 官方资源:


✅ 总结:

ScrollTrigger 是 GSAP 赋予网页“电影级滚动叙事能力”的核心插件——它将枯燥的滚动转化为精准、流畅、富有表现力的动画触发器,是现代高端网站交互动效的事实标准。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GSAP 案例 43 - ScrollTrigger toggleClass</title>
    <style>
      body {
        margin: 0;
        font-family: "Segoe UI", sans-serif;
        background: #0f172a;
        color: #e2e8f0;
      }
      header {
        padding: 80px 24px 40px;
        text-align: center;
      }
      section {
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .panel {
        width: 70%;
        max-width: 680px;
        padding: 32px;
        border-radius: 24px;
        background: #111827;
        box-shadow: 0 25px 60px rgba(15, 23, 42, 0.5);
        transition: transform 0.3s ease, background 0.3s ease;
      }
      .panel.active {
        transform: scale(1.03);
        background: #1f2937;
      }
      .spacer {
        height: 40vh;
      }
    </style>
  </head>
  <body>
    <header>
      <h1>案例 43:toggleClass 触发样式</h1>
      <p>滚动到卡片时添加高亮样式。</p>
    </header>
    <div class="spacer"></div>
    <section>
      <div class="panel" id="panel">滚动到这里会高亮</div>
    </section>
    <div class="spacer"></div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
    <script>
      // 注册 ScrollTrigger 插件
      gsap.registerPlugin(ScrollTrigger);

      ScrollTrigger.create({
        trigger: "#panel",
        start: "top 70%",
        end: "top 40%",
        toggleClass: "active"
      });
    </script>
  </body>
</html>

这段代码展示了 GSAP 的 ScrollTrigger 插件中一个非常实用的功能:toggleClass。它的作用是——

当用户滚动到指定区域时,自动给目标元素添加或移除一个 CSS 类名,从而触发样式变化(如高亮、缩放、变色等),无需手动编写 JavaScript 逻辑。


🔍 逐行解析核心部分:

ScrollTrigger.create({
  trigger: "#panel",     // 监听 #panel 元素的滚动位置
  start: "top 70%",      // 当 #panel 的顶部进入视口 70% 位置时 → 添加类
  end: "top 40%",        // 当 #panel 的顶部到达视口 40% 位置时 → 移除类
  toggleClass: "active"  // 要切换的 CSS 类名
});
📏 滚动位置语法说明:
  • "top 70%" 表示:trigger 元素的顶部视口的 70% 高度线 对齐。
  • 视口从上到下:0%(顶部)→ 100%(底部)
  • 所以 70% 在视口偏下方,40% 在视口偏上方。

效果逻辑

  • 向下滚动,当 #panel 进入视口下部(70%) 时 → 添加 .active
  • 继续滚动,当 #panel 上升到视口上部(40%) 时 → 移除 .active
  • 向上滚动时行为相反(进入 end 区域加类,离开 start 区域去类)

🎨 CSS 配合实现高亮:

.panel {
  /* 默认样式 */
  background: #111827;
  transform: scale(1);
}

.panel.active {
  /* 滚动到区域时激活 */
  background: #1f2937;     /* 背景变亮 */
  transform: scale(1.03);  /* 轻微放大 */
}

通过 transition 实现了平滑过渡,视觉反馈更自然。


toggleClass 的优势:

传统方式 使用 toggleClass
需监听 scroll 事件 + 计算位置 + 手动 classList.add/remove ✅ 一行配置自动完成
容易性能差(频繁触发 scroll) ✅ ScrollTrigger 内部优化(requestAnimationFrame + 节流)
逻辑分散,难维护 ✅ 声明式写法,意图清晰

⚙️ 其他用法示例:

1. 切换多个类
toggleClass: "highlight zoom-in"
2. 作用于其他元素
ScrollTrigger.create({
  trigger: "#section",
  toggleClass: { targets: ".nav-item", className: "current" }
});
3. 配合 onToggle 回调
ScrollTrigger.create({
  trigger: "#panel",
  toggleClass: "active",
  onToggle: self => console.log("是否激活:", self.isActive)
});

⚠️ 注意事项:

  • toggleClassScrollTrigger内置功能,无需额外插件;
  • 类名切换是双向的:进入区间加类,离开区间去类;
  • 如果 startend 顺序颠倒(如 start: "top 40%", end: "top 70%"),则行为反转(常用于“离开时激活”);
  • 移动端兼容性良好,支持触摸滚动。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrollTrigger.toggleClass 是实现“滚动高亮”、“区域激活”等交互的最简洁方案——它将复杂的滚动监听与 DOM 操作封装成声明式配置,让你专注于 CSS 样式设计,大幅提升开发效率与代码可读性。

getProperty + getVelocity 是什么

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GSAP 案例 44 - velocity / getProperty</title>
    <style>
      body {
        margin: 0;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "Segoe UI", sans-serif;
        background: #0f172a;
        color: #e2e8f0;
      }
      .card {
        width: 620px;
        padding: 28px;
        border-radius: 20px;
        background: #111827;
        box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
      }
      .track {
        height: 100px;
        border-radius: 14px;
        background: #0b1220;
        position: relative;
        margin: 18px 0;
      }
      .dot {
        width: 32px;
        height: 32px;
        border-radius: 50%;
        background: #a3e635;
        position: absolute;
        top: 34px;
        left: 20px;
      }
      .info {
        font-size: 14px;
        color: #94a3b8;
      }
      button {
        width: 100%;
        margin-top: 12px;
        padding: 12px 16px;
        border: none;
        border-radius: 12px;
        font-size: 14px;
        cursor: pointer;
        background: #a3e635;
        color: #0f172a;
        font-weight: 600;
      }
    </style>
  </head>
  <body>
    <div class="card">
      <h1>案例 44:getProperty + getVelocity</h1>
      <p>读取属性与速度,了解当前运动状态。</p>
      <div class="track">
        <div class="dot" id="dot"></div>
      </div>
      <div class="info" id="info">x: 0 | velocity: 0</div>
      <button id="play">播放</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script>
      const dot = document.querySelector("#dot");
      const info = document.querySelector("#info");
      const playButton = document.querySelector("#play");

      const tween = gsap.to(dot, {
        x: 480,
        duration: 2,
        ease: "power1.inOut",
        paused: true,
        onUpdate: () => {
          const x = Math.round(gsap.getProperty(dot, "x"));
          const velocity = Math.round(tween.getVelocity());
          info.textContent = `x: ${x} | velocity: ${velocity}`;
        }
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>
  </body>
</html>

GSAP(GreenSock Animation Platform) 中,gsap.getProperty()tween.getVelocity() 是两个用于实时读取动画状态的实用工具方法,常用于调试、交互反馈或基于物理状态的逻辑判断。


✅ 一、gsap.getProperty(target, property, unit?)

🔍 作用:

获取目标元素当前被 GSAP 控制的某个 CSS 属性或 transform 值。

即使该属性是通过 transform(如 x, y, rotation)设置的,也能正确返回数值。

📌 语法:
gsap.getProperty(element, "propertyName", "unit?");
  • element:DOM 元素
  • "propertyName":属性名,如 "x", "opacity", "backgroundColor"
  • "unit?"(可选):指定返回单位,如 "px", "deg";默认返回纯数字
const x = Math.round(gsap.getProperty(dot, "x"));
  • 实时读取小球当前的 水平位移 x 值(以像素为单位的数字)
  • 即使你用 gsap.to(dot, { x: 480 }) 设置的是“相对位移”,getProperty 也能返回绝对计算值

⚠️ 注意:它读取的是 GSAP 内部记录的值,不是 getComputedStyle() 的结果,因此更准确、更高效(尤其对 transform 属性)。


✅ 二、tween.getVelocity()

🔍 作用:

获取当前动画目标属性的瞬时速度(单位/秒)。

对于多属性动画(如同时动 xy),默认返回第一个属性的速度;也可指定属性:

tween.getVelocity("x") // 获取 x 方向速度
📌 特点:
  • 速度单位:每秒变化量(如 px/s, deg/s
  • 方向有正负:+ 表示正向(如向右),- 表示反向(如向左)
  • onUpdate 回调中调用最准确
const velocity = Math.round(tween.getVelocity());
  • 返回小球在 x 方向上的当前速度(px/s)
  • 动画开始和结束时速度接近 0(因为使用了 power1.inOut 缓动)
  • 中间时刻速度最大(约 ±240 px/s)

🔬 动画过程中的典型值(duration: 2s, x: 0 → 480):

时间 x velocity (px/s) 说明
0s 0 0 起始,静止
0.5s ~120 ~240 加速到峰值
1.0s 240 0 中点,瞬时静止(inOut 对称)
1.5s ~360 ~-240 反向加速(减速阶段)
2.0s 480 0 结束,静止

📌 注意:power1.inOut 是先加速后减速,在中点速度为 0(这是缓动函数决定的)。


🌟 典型应用场景:

场景 用途
物理模拟 根据速度决定反弹强度、摩擦力
交互反馈 鼠标松开时根据拖拽速度继续滑动(惯性滚动)
游戏开发 判断角色是否在移动、碰撞检测
动画调试 实时监控属性与速度变化
动态效果 速度越大,粒子越多 / 模糊越强

⚠️ 注意事项:

  • getProperty 仅能读取 GSAP 已经控制过的属性
  • getVelocity() 必须在 动画进行中 调用才有意义(暂停/结束后返回 0);
  • 对于 Timeline,需在具体 tween 上调用 getVelocity()
  • 这两个方法都是 GSAP 核心 API,无需额外插件。

📚 官方文档:


✅ 总结:

gsap.getProperty()tween.getVelocity() 是 GSAP 提供的“动画状态探测器”——它们让你能精确掌握元素当前的位置和运动速度,为构建基于物理、交互或调试需求的高级动画提供了关键数据支持。

什么是utils.random / wrap / interpolate

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>GSAP 案例 45 - utils random/wrap/interpolate</title>
  <style>
    body {
      margin: 0;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      font-family: "Segoe UI", sans-serif;
      background: #0b1020;
      color: #e2e8f0;
    }

    .card {
      width: 640px;
      padding: 28px;
      border-radius: 20px;
      background: #111827;
      box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
    }

    .stage {
      height: 160px;
      border-radius: 16px;
      background: #0f172a;
      position: relative;
      overflow: hidden;
      margin: 18px 0;
    }

    .dot {
      width: 30px;
      height: 30px;
      border-radius: 50%;
      background: #38bdf8;
      position: absolute;
      top: 65px;
      left: 20px;
    }

    button {
      width: 100%;
      padding: 12px 16px;
      border: none;
      border-radius: 12px;
      font-size: 14px;
      cursor: pointer;
      background: #38bdf8;
      color: #0f172a;
      font-weight: 600;
    }

    .info {
      margin-top: 8px;
      font-size: 13px;
      color: #94a3b8;
    }
  </style>
</head>

<body>
  <div class="card">
    <h1>案例 45:utils.random / wrap / interpolate</h1>
    <p>快速生成随机值、循环值与插值。</p>
    <div class="stage">
      <div class="dot" id="dot"></div>
    </div>
    <button id="play">随机移动</button>
    <div class="info" id="info"></div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
  <script>
    const dot = document.querySelector("#dot");
    const info = document.querySelector("#info");
    const playButton = document.querySelector("#play");

    // random 生成随机数,wrap 限制循环范围,interpolate 计算插值
    const randomX = gsap.utils.random(40, 540, 1);
    const wrapHue = gsap.utils.wrap(0, 360);
    const mix = gsap.utils.interpolate(0, 1);

    let step = 0;

    playButton.addEventListener("click", () => {
      const x = randomX;
      const hue = wrapHue(step * 80);
      const scale = mix(0.7, 1.4);

      gsap.to(dot, {
        x,
        scale,
        backgroundColor: `hsl(${hue}, 90%, 60%)`,
        duration: 0.6,
        ease: "power2.out"
      });

      info.textContent = `x: ${x}px | hue: ${hue} | scale: ${scale.toFixed(2)}`;
      step += 1;
    });
  </script>
</body>

</html>

GSAP(GreenSock Animation Platform) 中,gsap.utils 是一个内置的实用工具函数集合,提供了许多高效、简洁的辅助方法,用于处理动画中常见的数学和逻辑操作。

你提到的三个方法:

  • gsap.utils.random
  • gsap.utils.wrap
  • gsap.utils.interpolate

是其中最常用、最强大的三个工具,分别用于生成随机值循环限制范围计算插值。它们让复杂逻辑变得简单,且性能优异。


✅ 1. gsap.utils.random(min, max, [step])

🔍 作用:

生成一个指定范围内的随机数(可选步长)。

📌 语法:
const rand = gsap.utils.random(min, max, step);
  • min:最小值
  • max:最大值
  • step(可选):步长(如 1 表示整数,0.1 表示保留一位小数)

⚠️ 注意:它返回的是一个函数!调用该函数才会生成新随机数。

但如果你直接传数字(如你的代码),GSAP 会自动缓存一次结果(等价于 random(40, 540, 1)())。

const randomX = gsap.utils.random(40, 540, 1); // 实际返回一个数字(因为未作为函数调用)
  • 每次点击按钮,x 都是一个 40~540 之间的整数
  • 用于让小球随机水平移动

✅ 更推荐写法(每次点击都新随机):

playButton.addEventListener("click", () => {
  const x = gsap.utils.random(40, 540, 1)(); // 加 () 才是函数调用
});

✅ 2. gsap.utils.wrap(min, max)

🔍 作用:

将任意数值“包裹”到 [min, max) 范围内,实现无缝循环(类似取模 %,但支持浮点数和负数)。

📌 语法:
const wrap = gsap.utils.wrap(min, max);
const result = wrap(value); // 返回循环后的值
🌰 例子:
const wrap360 = gsap.utils.wrap(0, 360);
wrap360(400)   // → 40   (400 - 360)
wrap360(-20)   // → 340  (-20 + 360)
wrap360(720)   // → 0    (720 % 360)
const hue = wrapHue(step * 80); // step=0→0, step=1→80, step=2→160, step=3→240, step=4→320, step=5→40→40...
  • 实现 HSL 色相(0~360)的循环切换,避免颜色溢出
  • 视觉上形成:蓝 → 紫 → 红 → 橙 → 黄 → 绿 → 蓝 … 的循环

✅ 3. gsap.utils.interpolate(start, end)

🔍 作用:

创建一个插值函数,根据进度值(0~1)计算 startend 之间的中间值。

📌 语法:
const interpolator = gsap.utils.interpolate(start, end);
const value = interpolator(progress); // progress ∈ [0, 1]
🌰 例子:
const mix = gsap.utils.interpolate(10, 50);
mix(0)   // → 10
mix(0.5) // → 30
mix(1)   // → 50

它不仅支持数字,还支持颜色、数组、甚至对象

const scale = mix(0.7, 1.4); // ❌ 这里有误!

正确用法应该是:

// 先创建插值函数
const scaleInterp = gsap.utils.interpolate(0.7, 1.4);

// 再用 0~1 之间的值去插值(比如用 Math.random())
const scale = scaleInterp(Math.random());

这会导致 scale = 0.7(因为 mix(0.7) ≈ 0.7,第二个参数被忽略)。

修复建议:

// 方案 1:直接随机 scale
const scale = gsap.utils.random(0.7, 1.4, 0.01)();

// 方案 2:用插值 + 随机进度
const getScale = gsap.utils.interpolate(0.7, 1.4);
const scale = getScale(Math.random());

🌟 总结对比:

工具 用途 返回值 典型场景
random(min, max, step) 生成随机数 函数(或直接数值) 随机位置、延迟、颜色
wrap(min, max) 循环限制数值 函数 色相循环、角度归一化、无限滚动
interpolate(a, b) 计算 a→b 的中间值 函数 动态缩放、颜色混合、进度映射

🎯 高级技巧(Bonus):

支持颜色插值:
const colorMix = gsap.utils.interpolate("red", "blue");
colorMix(0.5); // → "rgb(128, 0, 128)"(紫色)
数组插值:
const pointMix = gsap.utils.interpolate([0, 0], [100, 200]);
pointMix(0.5); // → [50, 100]

📚 官方文档:

👉 greensock.com/docs/v3/GSA…


✅ 最终总结:

gsap.utils.randomwrapinterpolate 是 GSAP 提供的“动画数学工具箱”——它们以极简 API 解决了随机性、循环性和连续性三大常见需求,让你无需手写复杂公式,即可构建丰富、动态、可控的交互动效。

什么是timeScale / yoyoEase

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GSAP 案例 46 - timeScale / yoyoEase</title>
    <style>
      body {
        margin: 0;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "Segoe UI", sans-serif;
        background: #0f172a;
        color: #e2e8f0;
      }
      .card {
        width: 620px;
        padding: 28px;
        border-radius: 20px;
        background: #111827;
        box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
      }
      .track {
        height: 90px;
        border-radius: 14px;
        background: #0b1220;
        position: relative;
        margin: 18px 0;
      }
      .ball {
        width: 46px;
        height: 46px;
        border-radius: 50%;
        background: #f472b6;
        position: absolute;
        top: 22px;
        left: 20px;
      }
      .controls {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 10px;
      }
      button {
        padding: 10px 12px;
        border: none;
        border-radius: 12px;
        font-size: 13px;
        cursor: pointer;
        background: #1f2937;
        color: #e5e7eb;
      }
      button.primary {
        background: #f472b6;
        color: #0f172a;
        font-weight: 600;
      }
    </style>
  </head>
  <body>
    <div class="card">
      <h1>案例 46:timeScale 与 yoyoEase</h1>
      <p>调整播放速度,并在往返时使用不同缓动。</p>
      <div class="track">
        <div class="ball" id="ball"></div>
      </div>
      <div class="controls">
        <button id="slow">慢速</button>
        <button class="primary" id="play">播放</button>
        <button id="fast">快速</button>
      </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script>
      const ball = document.querySelector("#ball");
      const playButton = document.querySelector("#play");
      const slowButton = document.querySelector("#slow");
      const fastButton = document.querySelector("#fast");

      // yoyoEase 可以在回放时使用不同缓动
      const tween = gsap.to(ball, {
        x: 520,
        duration: 1.6,
        ease: "power2.out",
        yoyo: true,
        repeat: -1,
        yoyoEase: "power2.in",
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.paused(!tween.paused());
      });

      slowButton.addEventListener("click", () => {
        tween.timeScale(0.6);
      });

      fastButton.addEventListener("click", () => {
        tween.timeScale(1.6);
      });
    </script>
  </body>
</html>

GSAP(GreenSock Animation Platform) 中,timeScaleyoyoEase 是两个用于精细控制动画播放行为的强大特性:


✅ 一、timeScale控制动画播放速度

🔍 作用:

调整动画的时间流速,实现快放、慢放、甚至倒放,而不改变 duration

📌 基本用法:
tween.timeScale(1);   // 正常速度(默认)
tween.timeScale(0.5); // 半速(慢动作)
tween.timeScale(2);   // 2倍速(快进)
tween.timeScale(-1);  // 反向播放(倒放)
  • timeScale 是一个乘数因子
    • 1 = 100% 速度
    • 0.6 = 60% 速度(变慢)
    • 1.6 = 160% 速度(变快)
slowButton.addEventListener("click", () => {
  tween.timeScale(0.6); // 慢速
});

fastButton.addEventListener("click", () => {
  tween.timeScale(1.6); // 快速
});
  • 点击按钮即可实时改变动画速度,无需重新创建动画;
  • 即使动画正在播放,也能无缝变速

⚠️ 注意:timeScale 不影响 duration 的设定值,只影响实际播放耗时


✅ 二、yoyoEase为往返动画(yoyo)指定不同的缓动函数

🔍 背景知识:什么是 yoyo
  • 当设置 repeat: -1(无限重复) + yoyo: true 时,
  • 动画会正向播放 → 反向播放 → 正向播放 → …,形成“来回”效果。
📌 默认问题:
  • 如果只设 ease: "power2.out"
  • 那么正向和反向都使用同一个缓动,导致:
    • 正向:先快后慢(out)
    • 反向:先慢后快(因为是倒放 out)

但很多时候,我们希望去程和回程有不同的运动感觉

✅ 解决方案:yoyoEase

yoyoEase 允许你为“回程”(反向播放)单独指定一个缓动函数。

💡 在你的代码中:
const tween = gsap.to(ball, {
  x: 520,
  duration: 1.6,
  ease: "power2.out",      // 去程:先快后慢(弹出感)
  yoyo: true,
  repeat: -1,
  yoyoEase: "power2.in",   // 回程:先慢后快(吸入感)
  paused: true
});
🎯 视觉效果对比:
阶段 缓动 运动特点
去程(→) power2.out 快速冲出去,然后缓缓停下
回程(←) power2.in 缓缓启动,然后快速收回

这比单纯用 yoyo: true + 单一缓动更自然、更有“弹性”!


🔬 技术细节补充:

1. yoyoEase 的工作原理:
  • GSAP 不会倒放 ease,而是正向播放 yoyoEase 来模拟回程。
  • 所以 ease: "out" + yoyoEase: "in" = 去程快停 + 回程快启,非常合理。
2. yoyoEase 支持所有缓动类型:
yoyoEase: "elastic.out"
yoyoEase: "bounce.inOut"
yoyoEase: CustomEase.create(...)
3. timeScale 是可叠加的:
tween.timeScale(2).timeScale(0.5); // 最终 = 1(2 * 0.5)

🌟 典型应用场景:

场景 用途
UI 微交互动效 按钮点击“弹跳”:去程快,回程缓
游戏对象移动 敌人巡逻:匀速去,加速回
视频/音频播放器 拖拽预览时慢放,正常播放时快放
科学可视化 模拟不同速度下的物理过程

⚠️ 注意事项:

  • yoyoEase 仅在 yoyo: true 时生效
  • timeScale 影响整个时间线或 tween,包括子动画;
  • 两者都是 GSAP 核心功能,无需额外插件;
  • timeScale(0) 会暂停动画(等价于 paused(true))。

📚 官方文档:


✅ 总结:

timeScale 让你像“调速旋钮”一样控制动画节奏,而 yoyoEase 则赋予往返动画“去程与回程不同性格”的能力——两者结合,可构建出既灵活又富有表现力的交互动效,是 GSAP 高级动画控制的标志性特性。

做了个 EPUB 阅读器,被「阅读进度同步」折磨了一周,总结 4 个血泪教训

作者 PTC
2026年2月13日 14:22

你做过"打开一本书,自动回到上次阅读位置"这个功能吗?

听起来很简单对吧——存个页码,下次打开翻过去就行。我一开始也是这么想的,直到在 Web EPUB 阅读器上被反复打脸。

这篇文章不讲理论框架,直接讲:我在实现 Web/Mobile 阅读进度同步时踩过的每一个坑,以及为什么"存页码"这条路从一开始就是死的。

一句话结论

进度 = 内容位置(Anchor),页面 = 当前设备的渲染结果。

只要你不存页码,Web 双页 / Mobile 单页 / 字体可调 / 阅读器大小可调 / 多端同步,全部迎刃而解。

为什么不能存页码?

同一本 EPUB,79 章,30 万字:

环境 页数
PC 双页 (319px/页) 1165 页
iPad 横屏 (500px/页) 约 750 页
iPhone 竖屏 (350px/页) 约 1400 页
调大字号 (20px) 约 1800 页

用户在 PC 上读到第 142 页,存下来。手机打开,翻到第 142 页——内容完全对不上,可能差了好几章。

页码是渲染结果,不是内容属性。 它取决于字体、字号、行高、容器宽高、双页/单页模式。换任何一个参数,页码就变了。

Anchor 锚点设计

数据结构

interface ReadingAnchor {
  chapterIndex: number;   // 第 11 章
  blockIndex: number;     // 章内第 17 个段落
  charOffset: number;     // 段内第 0 个字符
  textSnippet: string;    // "尤里身体前倾,像是在敦促她"
}

每个字段都是内容属性——和设备无关、和字体无关、和屏幕宽度无关。

textSnippet 是保险:万一书的内容更新导致 blockIndex 偏移,还能用文字片段做模糊匹配(Kindle 也是这么做的)。

存储格式

anchor:11:17:0|snippet:尤里身体前倾,像是在敦促她|char:25000

char:25000 是全局字符偏移,供旧客户端降级。一个字符串,三层 fallback,向前兼容。

多端同步流程

手机端退出 → 保存 anchor → 后端存储
                                    ↓
PC 端打开 → 请求 anchor → 当前设置下重新分页 → 定位到锚点所在页

后端只做一件事:存最新的 anchor。"翻到哪一页"这个问题完全由前端根据当前设备环境实时计算。

前端分页:CSS 多列布局测量

EPUB 分页的核心是 CSS column-width。将章节 HTML 注入一个隐藏容器,浏览器自动按列排布,scrollWidth / columnWidth 就是页数。

// 隐藏测量容器
measureEl.innerHTML = `
  <div class="epub-measure-container" style="
    width: ${pageWidth}px;
    height: ${pageHeight}px;
    column-width: ${pageWidth}px;
    column-gap: 0px;
    column-fill: auto;
    font-size: ${fontSize}px;
    line-height: ${lineHeight};
  ">${chapter.html}</div>
`;

const scrollW = contentEl.scrollWidth;
const pageCount = Math.ceil(scrollW / pageWidth);

同时,遍历每个块级元素,记录它在第几列(第几页),构建 blockMap

// 用 getBoundingClientRect 计算元素所在列
const containerRect = containerEl.getBoundingClientRect();
for (const el of leafElements) {
  const elRect = el.getBoundingClientRect();
  const relativeLeft = elRect.left - containerRect.left;
  const pageInChapter = Math.floor(relativeLeft / columnWidth);
  // 记录:blockIndex → pageInChapter
}

有了 blockMap,锚点 → 页码的转换就是一次查表:range.startPage + block.pageInChapter

四个真实的坑

坑 1:测量 CSS ≠ 渲染 CSS → 定位偏移

这是最隐蔽的 Bug。测量容器和实际渲染的 CSS 差了几条规则:

/* 渲染容器有,测量容器漏了 */
h1, h2, h3 { margin-top: 0.5em; margin-bottom: 0.3em; }
blockquote { text-indent: 0 !important; }
a { color: inherit; text-decoration: underline; }

一个标题的 margin 差了 0.5em(≈ 8px),在 319px 宽的手机屏幕上,就足以让后续段落的列分配偏移一整页。79 章累积下来,锚点可以偏差几十页。

结论:测量 CSS 和渲染 CSS 必须完全一致,差一个属性就可能出错。

坑 2:offsetLeft 在多列布局中不可靠

最初用 el.offsetLeft / columnWidth 判断元素在哪一列。但 offsetLeft 的语义是"相对于 offsetParent",在 CSS 多列布局中,不同浏览器的实现有差异。

换成 getBoundingClientRect() 后解决。它返回元素的实际视觉位置,跨浏览器一致:

// ❌ 不可靠
const page = Math.floor(el.offsetLeft / columnWidth);

// ✅ 可靠
const rect = el.getBoundingClientRect();
const page = Math.floor((rect.left - containerRect.left) / columnWidth);

坑 3:字体设置变更 → 用旧数据算出错误页码

用户调整字号 → settingsFingerprint 变化 → 触发重新分页。但 React 中多个 Hook 的状态更新有时差:

Effect 看到:新的 settingsFingerprint ✓
             旧的 blockMaps ✗ (分页 Hook 还没完成重新测量)

用旧的 blockMaps + 新设置去算 anchorToPage,结果必然是错的。

解决方案:两阶段导航。

// 第一阶段:检测到设置变更,标记等待,不导航
if (isSettingsChange) {
  pendingSettingsNavRef.current = true;
  return; // 等分页重新测量
}

// 第二阶段:分页完成后,用新 blockMaps 安全导航
if (pendingSettingsNavRef.current) {
  pendingSettingsNavRef.current = false;
  const newPage = anchorToPage(anchor, newRanges, newBlockMaps);
  navigateTo(newPage);
}

坑 4:渐进加载 + 翻页库事件竞态

79 章的书不会一次加载完。第一次分页只有 17 章精确测量 + 62 章估算。当更多章节加载后,avgCharsPerPage 从 135 变成 129,所有估算章节的 startPage 集体偏移,锚点对应的全局页码从 132 变成 142。

但阅读器还停在 132 页,因为初始化后没有"自动修正"逻辑。

更麻烦的是,尝试用 setSettingsKey 重新挂载 flipbook 来修正时,翻页库在 mount 时会发射一个 onFlip({page: 0}) 的伪事件。这个事件把 currentPageRef 污染成 0,导致后续自动修正全部失效。

解决方案:两个机制配合。

门控机制:flipbook 初始化阶段忽略 onFlip 事件。

const flipReadyRef = useRef(false);

// mount 后 300ms 才标记就绪
setTimeout(() => { flipReadyRef.current = true; }, 300);

// handleFlip 中门控
if (!flipReadyRef.current) return; // 忽略伪事件

直接导航:渐进加载修正时用 turnToPage 而不是重新挂载,从根本上避免竞态。

if (!userHasFlippedRef.current && startPage !== currentPageRef.current) {
  flipBookRef.current?.pageFlip()?.turnToPage(startPage);
}

最终架构

┌───────────────────────────────────┐
│ 后端:只存 anchor 字符串          │  POST /api/library/progress
├───────────────────────────────────┤
│ 前端解析层:anchor ↔ 页码转换     │  anchorToPage / pageToAnchor
├───────────────────────────────────┤
│ 前端测量层:CSS columns 精确测量   │  buildBlockMap → blockMaps
├───────────────────────────────────┤
│ 前端渲染层:flipbook 翻页 UI      │  react-pageflip
└───────────────────────────────────┘

核心原则:

  • 后端不分页,只存内容位置
  • 页码纯前端算,根据当前设备环境实时计算
  • 锚点与设备无关,同一个锚点在任何设备上都能定位
  • 转换方向:永远是 anchor → page(打开时),page → anchor(保存时)

写在最后

实现这个功能的过程让我深刻理解了一件事:看似简单的需求,难点往往不在算法设计,而在工程细节的一致性

CSS 差一条规则、React Effect 的执行时序差一帧、第三方库的一个初始化事件——这些"微小"的不一致累积起来,就是"打开书发现位置完全不对"的用户体验灾难。

如果你也在做类似的阅读器产品,记住这个原则:

永远不要存页码。存内容位置,让前端去算页码。

这一个决策,能帮你避开 80% 的坑。

LeetCode 25. K个一组翻转链表:两种解法详解+避坑指南

作者 Wect
2026年2月14日 15:04

LeetCode 难度为 Hard 的经典链表题——25. K个一组翻转链表,这道题是链表翻转的进阶题,考察对链表指针操作的熟练度,也是面试中的高频考点,很多人会在“组内翻转”“组间连接”“边界处理”上踩坑。

今天不仅会讲解题目核心,还会对比两份不同思路的代码,分析它们的优缺点、避坑点,帮大家彻底吃透这道题,下次遇到直接秒解!

一、题目解读(清晰易懂版)

题目核心需求很明确,一句话概括:给一个链表,每k个节点当成一组,组内翻转;如果最后剩下的节点不足k个,就保持原样

关键约束(必看,避坑前提):

  • k是正整数,且k ≤ 链表长度(不用考虑k大于链表长度的情况);

  • 不能只改节点的值,必须实际交换节点(排除“偷巧”解法);

  • 组间顺序不变,只有组内节点翻转(比如链表1->2->3->4,k=2,结果是2->1->4->3,不是4->3->2->1)。

示例辅助理解:

  • 输入:head = [1,2,3,4,5], k = 2 → 输出:[2,1,4,3,5]

  • 输入:head = [1,2,3,4,5], k = 3 → 输出:[3,2,1,4,5]

  • 输入:head = [1,2], k = 2 → 输出:[2,1]

二、链表节点定义(题目给出,直接复用)

先贴出题目给出的ListNode定义,两份解法都基于这个结构,不用额外修改:

class ListNode {
  val: number
  next: ListNode | null
  constructor(val?: number, next?: ListNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
  }
}

三、两种解法详解对比

下面分别讲解两份代码(reverseKGroup_1 和 reverseKGroup_2),从思路、执行流程、优缺点三个维度拆解,帮大家看清两种思路的差异。

解法一:reverseKGroup_1(全局翻转+局部调整+回滚,新手易上手但需避坑)

1. 核心思路

这种思路的核心是「边遍历边全局翻转,每凑够k个节点,就调整一次组间连接;最后如果不足k个节点,再把这部分翻转回去」。

可以类比成:把链表当成一串珠子,从头开始逐个翻转(珠子顺序颠倒),每翻k个,就把这k个珠子“固定”到正确的位置(连接好前后组);如果最后剩的珠子不够k个,就把这几个珠子再翻回来,恢复原样。

2. 关键变量说明

  • dummy:虚拟头节点,避免处理头节点翻转的特殊情况(所有链表题的通用技巧);

  • preGroup:每组翻转的“前置节点”,负责连接上一组的尾和当前组的头;

  • prev:翻转节点时的“前驱节点”,记录当前节点的前一个节点(用于翻转指针);

  • curr:当前正在遍历、翻转的节点;

  • count:组内节点计数器,用于判断是否凑够k个节点。

3. 代码执行流程(以 head=[1,2,3,4], k=2 为例)

  1. 初始状态:dummy(0)->1->2->3->4,preGroup=dummy,prev=dummy,curr=1,count=0;

  2. 遍历curr=1:count≠2,翻转1(1.next=prev=dummy),prev=1,curr=2,count=1;

  3. 遍历curr=2:count≠2,翻转2(2.next=prev=1),prev=2,curr=3,count=2;

  4. 凑够k=2个节点:调整组间连接——preGroup.next=prev=2(dummy->2),原组头lastNode=1,1.next=curr=3(2->1->3);更新preGroup=1,prev=1,count=0;

  5. 继续遍历curr=3:重复步骤2-3,翻转3、4,凑够k=2个节点,调整连接(1->4,3.next=null);

  6. 循环结束,count=0,无不足k个的节点,返回dummy.next=2,最终结果2->1->4->3(正确)。

4. 优点&缺点

优点:思路直观,新手容易理解(只需要掌握“单个节点翻转”的基础操作,再加上计数和回滚);代码结构清晰,逐步骤执行,容易调试。

缺点:存在冗余逻辑(比如单独处理“最后一组刚好k个节点”的else if分支);过度使用空值断言(!),有潜在空指针风险;最后回滚步骤增加了少量时间开销(虽然时间复杂度还是O(n))。

5. 核心避坑点

  • 避免链表环:翻转后必须及时调整组尾的next指针(lastNode.next=curr),否则会出现“dummy<->1”的环,触发运行错误;

  • 回滚逻辑不能漏:如果最后剩余节点不足k个,必须把这部分翻转的节点再翻回来,否则会破坏原有顺序;

  • 空值判断:preGroup.next不可能为null,可移除多余的空值判断,避免错误返回null。

解法二:reverseKGroup_2(先找组边界+组内单独翻转,最优解法)

这是更推荐的解法,也是面试中更常考的思路——「先找每组的边界(头和尾),确认够k个节点后,再单独翻转这组节点;组间连接直接通过边界节点处理,无需回滚」。

类比:还是一串珠子,先找到前k个珠子(确定组头和组尾),把这k个珠子单独翻转,再连接好前后珠子;再找下k个珠子,重复操作;如果找不到k个,就直接结束,不用再调整。

1. 关键变量说明(新增/差异变量)

  • groupTail:当前组的尾节点,通过移动k次找到,同时判断剩余节点是否够k个;

  • groupHead:当前组的头节点(翻转后会变成组尾);

  • nextGroupHead:下一组的头节点,提前记录,避免翻转后找不到下一组。

2. 代码执行流程(以 head=[1,2,3,4], k=2 为例)

  1. 初始状态:dummy(0)->1->2->3->4,preGroup=dummy;

  2. 找第一组边界:groupTail从preGroup开始移动2次,找到groupTail=2(确认够k个节点);记录groupHead=1,nextGroupHead=3;

  3. 单独翻转当前组(1->2):prev初始化为nextGroupHead=3,curr=groupHead=1;循环翻转,直到curr=nextGroupHead,翻转后变成2->1;

  4. 连接组间:preGroup.next=groupTail=2(dummy->2),preGroup更新为groupHead=1(下一组的前置节点);

  5. 找第二组边界:groupTail从preGroup=1移动2次,找到groupTail=4;记录groupHead=3,nextGroupHead=null;

  6. 单独翻转当前组(3->4),连接组间;

  7. 下一次找组边界:移动不足2次,count<k,直接返回dummy.next=2,结果2->1->4->3(正确)。

3. 优点&缺点

优点:逻辑更高效,无需回滚(提前判断节点数量,不足k个直接返回);无冗余分支,代码更简洁;指针操作更严谨,避免链表环和空指针风险;时间复杂度O(n),空间复杂度O(1),是最优解法。

缺点:对指针操作的熟练度要求更高,需要提前规划好“找边界-翻转-连接”的流程,新手可能需要多调试几次才能理解。

4. 核心避坑点

  • 找组边界时,必须同时判断节点数量:移动k次后,如果groupTail.next不存在,说明不足k个节点,直接返回;

  • 翻转组内节点时,prev初始化为nextGroupHead:这样翻转后,组尾(原groupHead)的next会自动指向nextGroupHead,无需额外调整;

  • preGroup更新为原groupHead:翻转后,原groupHead变成组尾,作为下一组的前置节点,保证组间连接正确。

四、两份代码对比总结

对比维度 reverseKGroup_1 reverseKGroup_2
核心思路 全局翻转+组间调整+不足k个回滚 先找组边界+组内单独翻转+无回滚
时间复杂度 O(n)(回滚最多增加O(k),可忽略) O(n)(最优,每个节点只遍历一次)
空间复杂度 O(1) O(1)
可读性 高,新手易理解 中等,需熟练掌握指针操作
适用场景 新手刷题、快速调试 面试、生产环境(最优解)
潜在坑点 链表环、回滚遗漏、空值断言 组边界判断、prev初始化

五、刷题建议&拓展思考

1. 刷题建议

  • 新手:先吃透 reverseKGroup_1,掌握“翻转+计数+回滚”的思路,熟练后再过渡到 reverseKGroup_2;

  • 进阶:重点练习 reverseKGroup_2,尝试自己手写“找边界-翻转-连接”的流程,避免依赖模板;

  • 调试技巧:遇到指针混乱时,画链表结构图(比如用草稿纸写出每个节点的next指向),逐步骤跟踪指针变化,比单纯看代码更高效。

2. 拓展思考(面试高频追问)

  • 如果k可以大于链表长度,该如何修改代码?(提示:在找组边界时,判断count是否等于链表长度,不足则不翻转);

  • 如何用递归实现K个一组翻转链表?(提示:递归终止条件是剩余节点不足k个,递归逻辑是翻转当前组,再递归翻转下一组);

  • 如果要求“每k个节点一组翻转,不足k个节点时全部翻转”,该如何修改?(提示:移除回滚逻辑,或不判断节点数量,直接翻转)。

六、最终优化版代码(推荐面试使用)

基于 reverseKGroup_2 优化,移除空值断言,增加防御性判断,代码更健壮、简洁,适配面试场景:

function reverseKGroup(head: ListNode | null, k: number): ListNode | null {
  if (k === 1 || !head || !head.next) return head;

  const dummy = new ListNode(0, head);
  let preGroup = dummy; // 每组翻转的前置节点
  let count = 0;

  while (true) {
    // 第一步:找组尾,判断剩余节点是否够k个
    let groupTail = preGroup;
    count = 0;
    while (count < k && groupTail.next) {
      groupTail = groupTail.next;
      count++;
    }
    if (count < k) return dummy.next; // 不足k个,直接返回

    // 第二步:记录关键节点
    const groupHead = preGroup.next;
    const nextGroupHead = groupTail.next;

    // 第三步:组内翻转
    let prev: ListNode | null = nextGroupHead;
    let curr = groupHead;
    while (curr !== nextGroupHead) {
      const next = curr?.next;
      if (curr) curr.next = prev;
      prev = curr;
      curr = next;
    }

    // 第四步:组间连接
    preGroup.next = groupTail;
    preGroup = groupHead!;
  }
}

七、总结

LeetCode 25题的核心是「组内翻转+组间连接」,两种解法的本质都是通过指针操作实现,但思路的高效性有差异。

无论哪种解法,都要记住三个核心要点:① 用虚拟头节点简化头节点处理;② 明确每组的边界(头、尾、下一组头);③ 翻转时避免链表环和空指针。

刷题不是背代码,而是理解思路、掌握技巧。建议大家多调试、多画图,熟练掌握指针操作,下次遇到类似的链表翻转题(比如两两翻转、指定区间翻转),就能举一反三、轻松应对!

Claude Code 作者再次分享 Anthropic 内部团队使用技巧

作者 Immerse
2026年2月14日 14:52

大家好,我是 Immerse,一名独立开发者、内容创作者、AGI 实践者。

关注公众号:沉浸式AI,获取最新文章(更多内容只在公众号更新)

个人网站:yaolifeng.com 也同步更新。

转载请在文章开头注明出处和版权信息。

我会在这里分享关于编程独立开发AI干货开源个人思考等内容。

如果本文对您有所帮助,欢迎动动小手指一键三连(点赞评论转发),给我一些支持和鼓励,谢谢!


Boris 又发了一份 Anthropic 内部的 Claude Code 使用心得。

看完觉得挺实用,记录几条:

1. 多开 worktree 同时跑 3-5 个 git worktree,每个开一个独立会话。团队里公认这个最提效。Boris 自己习惯用 git checkout,但大部分人更爱 worktree。

2. 复杂任务先规划 遇到复杂活儿就开 plan mode。可以让一个 Claude 写计划,另一个当幕僚审查。跑偏了就切回去重新规划。验证环节也会专门进计划模式。

3. 错误后更新 CLAUDE.md 每次纠错完都加一句:"更新你的 CLAUDE.md,别再犯同样的错。"反复迭代到错误率明显降下来。

4. 自建 Skills 库 把常用操作做成 Skills 提交到 git,各项目复用。一天做两次以上的事就该做成 Skills。

5. 让 Claude 自己修 bug 接入 Slack MCP,把 bug 讨论帖扔给 Claude,说一句"修它"就行。或者直接"去修失败的 CI",不用管细节。

6. 提高提示词质量 试试"严格审查这些改动,测试不过不准建 PR",让 Claude 当审查员。或者"证明给我看这能跑通",让它对比 main 和功能分支的差异。

7. 追求更优方案 碰到平庸的修复就说:"基于现在掌握的信息,废掉这个方案,实现更优雅的。"任务前写详细规格,减少歧义。描述越具体,输出越好。

8. 终端配置 团队在用 Ghostty 终端,支持同步渲染、24 位色彩和完整 Unicode。用 /statusline 自定义状态栏显示上下文用量和 git 分支。给标签页做颜色编码和命名,一个任务一个标签页。

9. 语音输入 说话比打字快三倍,提示词也会详细很多。macOS 连按两次 fn 就能开启。

10. 用子代理 想让 Claude 多花点算力就加"use subagents"。把任务分给子代理,主代理的上下文窗口保持干净。

详情:x.com/bcherny/status/2017742741636321619 x

gsap 配置解读 --5

作者 大时光
2026年2月14日 14:52

什么是ScrollTo

 <header>
    <h1>案例 29:ScrollTo 平滑滚动</h1>
    <button id="to-second">滚动到第二屏</button>
  </header>
  <section>
    <div class="panel">第一屏内容</div>
  </section>
  <section id="second">
    <div class="panel">第二屏内容</div>
  </section>
  <section>
    <div class="panel">第三屏内容</div>
  </section>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollToPlugin.min.js"></script>
  <script>
    const button = document.querySelector("#to-second");

    // 注册 ScrollToPlugin
    gsap.registerPlugin(ScrollToPlugin);

    button.addEventListener("click", () => {
      gsap.to(window, {
        duration: 1,
        scrollTo: "#second",
        ease: "power2.out"
      });
    });
  </script>

ScrollToPluginGSAP(GreenSock Animation Platform) 提供的一个轻量级但非常实用的插件,用于实现 平滑、可控的页面滚动动画——无论是滚动到页面某个元素、指定坐标,还是精确控制滚动行为。


📌 简单定义:

ScrollToPlugin 让你用 GSAP 的动画语法(如 durationease)驱动浏览器窗口或任意可滚动容器,平滑滚动到目标位置。

它解决了原生 window.scrollTo() 只能“瞬间跳转”或简单 behavior: 'smooth' 缺乏控制的问题。


✅ 核心能力:

1. 滚动到多种目标
// 滚动到元素(通过选择器或 DOM 节点)
scrollTo: "#second"
scrollTo: document.querySelector(".footer")

// 滚动到具体坐标
scrollTo: { y: 500 }          // 垂直滚动到 500px
scrollTo: { x: 200, y: 300 }  // 水平 + 垂直

// 滚动到页面顶部/底部
scrollTo: { y: "top" }
scrollTo: { y: "bottom" }

// 滚动到元素并预留偏移(如避开固定导航栏)
scrollTo: { y: "#section", offsetY: 80 }
2. 完全控制动画体验
  • duration: 滚动持续时间(秒)
  • ease: 缓动函数(如 "power2.out""expo.inOut"
  • 可暂停、反向、加入时间轴(Timeline)
3. 支持任意可滚动容器

不仅限于 window,也可用于 <div style="overflow: auto"> 等局部滚动区域:

gsap.to(scrollableDiv, {
  duration: 1,
  scrollTo: { y: 1000 }
});

🔧 在你的代码中:

gsap.to(window, {
  duration: 1,
  scrollTo: "#second",      // 平滑滚动到 id="second" 的 <section>
  ease: "power2.out"        // 先快后慢的缓动效果
});

点击按钮后:

  • 页面不会“瞬间跳转”到第二屏;
  • 而是用 1 秒时间,以 优雅的缓动曲线 滚动到 #second 元素的顶部;
  • 用户体验更自然、专业。

🌟 典型应用场景:

场景 示例
导航跳转 点击菜单项平滑滚动到对应章节
“回到顶部”按钮 带缓动的返回顶部动画
表单错误定位 提交失败时滚动到第一个错误字段
交互式故事页 按钮触发滚动到下一情节
局部滚动容器 在聊天窗口中自动滚动到底部

⚙️ 高级选项(常用):

scrollTo: {
  y: "#target",
  offsetX: 0,       // 水平偏移
  offsetY: 60,      // 垂直偏移(常用于避开固定头部)
  autoKill: true    // 用户手动滚动时自动中断动画(默认 true)
}

🆚 对比原生方案:

方式 控制力 缓动 中断处理 兼容性
window.scrollTo({ behavior: 'smooth' }) 仅线性 现代浏览器
ScrollToPlugin 任意 GSAP 缓动 智能中断 全浏览器(含 IE11)

⚠️ 注意事项:

  • 必须注册插件:gsap.registerPlugin(ScrollToPlugin)
  • 目标元素必须已存在于 DOM 中
  • 如果结合 ScrollSmoother(平滑滚动容器),需使用其 API 而非直接操作 window

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrollToPlugin 是 GSAP 中实现“专业级页面导航动画”的标准工具——它用极简的代码,赋予滚动行为电影般的流畅感和精准控制,是提升网站交互质感的必备插件。

什么是SplitText

<div class="card">
      <h1 id="headline">SplitText 可以拆分文字做逐字动画</h1>
      <button id="play">逐字出现</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>
    <script>
      const headline = document.querySelector("#headline");
      const playButton = document.querySelector("#play");

      // 注册 SplitText 插件
      gsap.registerPlugin(SplitText);

      let split;

      playButton.addEventListener("click", () => {
        if (split) {
          split.revert();
        }

        // 将文字拆分为字符
        split = new SplitText(headline, { type: "chars" });

        gsap.from(split.chars, {
          opacity: 0,
          y: 20,
          duration: 0.6,
          ease: "power2.out",
          stagger: 0.04
        });
      });
    </script>

SplitTextGSAP(GreenSock Animation Platform) 提供的一个强大工具(虽然叫“插件”,但实际是一个独立的实用类),用于将 HTML 文本智能地拆分为可单独动画的 <span> 元素,从而实现精细的逐字、逐词或逐行动画效果。


📌 简单定义:

SplitText 能把一段普通文字(如 <h1>Hello</h1>)自动转换成包裹在 <span> 中的字符、单词或行,让你可以用 GSAP 对每个部分做独立动画。

例如:

<!-- 原始 -->
<h1 id="headline">你好</h1>

<!-- SplitText({ type: "chars" }) 处理后 -->
<h1 id="headline">
  <span class="char"></span>
  <span class="char"></span>
</h1>

✅ 核心功能:三种拆分模式

模式 说明 生成结构
type: "chars" 拆分为单个字符(包括中文、英文、标点) 每个字一个 <span class="char">
type: "words" 拆分为单词(以空格/标点分隔) 每个词一个 <span class="word">
type: "lines" 拆分为视觉上的行(根据实际换行) 每行外层包 <div class="line">

💡 也可组合使用:type: "words, chars" → 先分词,再把每个词拆成字。


split = new SplitText(headline, { type: "chars" });

gsap.from(split.chars, {
  opacity: 0,
  y: 20,
  duration: 0.6,
  ease: "power2.out",
  stagger: 0.04 // 每个字符延迟 0.04 秒启动
});
  • 点击按钮时,标题文字被拆成单个字符;
  • 每个字符从下方 20px、透明的状态,依次向上淡入
  • 形成“逐字打字机”或“文字飞入”的经典动效。

⚠️ 注意:每次点击前调用 split.revert() 是为了还原原始 HTML 结构,避免重复嵌套 <span> 导致样式错乱。


🌟 为什么需要 SplitText?

如果不使用它,手动写 <span> 包裹每个字:

  • 繁琐:尤其对动态内容或 CMS 内容不现实;
  • 破坏语义:影响 SEO 和可访问性(屏幕阅读器);
  • 难以维护

SplitText

  • 非破坏性:原始文本保持不变,仅运行时包装;
  • 智能处理:正确保留 HTML 标签、空格、换行、内联样式;
  • 支持复杂排版:包括多行、响应式断行(lines 模式会监听 resize)。

🛠️ 高级特性:

  • 保留原始样式:即使文字有 CSS 动画、颜色、字体,拆分后依然生效。
  • 与 ScrollTrigger 结合:实现“滚动到此处时逐字出现”。
  • 支持 SVG 文本(需额外配置)。
  • 可自定义包裹标签:默认 <span>,也可设为 <div> 等。

⚠️ 注意事项:

  • 不是免费插件:在 GSAP 3 中,SplitText 属于 Club 会员专属功能(可试用,但商业项目需授权)。
  • 不要重复拆分:务必在重新拆分前 revert(),否则会嵌套多层 <span>
  • 对 SEO 友好:因为原始 HTML 不变,搜索引擎仍能读取完整文本。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

SplitText 是 GSAP 中实现“高级文字动画”的基石工具——它将枯燥的文本转化为可编程的动画单元,让逐字淡入、弹跳、飞入等效果变得简单、可靠且专业,广泛应用于官网、片头、交互叙事等场景。

什么是TextPlugin

 <div class="card">
      <h1>案例 31:TextPlugin 数字滚动</h1>
      <p>让文本从 0 变化到目标值。</p>
      <div class="counter" id="counter">0</div>
      <button id="play">开始计数</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/TextPlugin.min.js"></script>
    <script>
      const counter = document.querySelector("#counter");
      const playButton = document.querySelector("#play");

      // 注册 TextPlugin
      gsap.registerPlugin(TextPlugin);

      const tween = gsap.to(counter, {
        duration: 1.6,
        text: "1280",
        ease: "power2.out",
        paused: true
      });

      playButton.addEventListener("click", () => {
        counter.textContent = "0";
        tween.restart();
      });
    </script>

TextPluginGSAP(GreenSock Animation Platform) 提供的一个轻巧但非常实用的插件,专门用于对 DOM 元素的文本内容进行动画化更新。它最经典的应用就是实现 “数字滚动计数器” 效果(如从 0 平滑变化到 1280),但也支持普通文本的渐变替换。


📌 简单定义:

TextPlugin 能让元素的 textContent 从一个值“动画过渡”到另一个值——对于数字,它会逐帧递增/递减;对于文字,它可模拟打字、随机字符替换等效果。


✅ 核心功能:

1. 数字滚动(最常用)
gsap.to(element, {
  duration: 2,
  text: "1000" // 自动从当前数字(如 "0")滚动到 1000
});
  • 自动识别数字并进行数值插值
  • 支持整数、小数、带千分位格式(需配合 delimiter);
  • 可设置前缀/后缀(如 $%)。
2. 文本替换动画
gsap.to(element, {
  text: "Hello World",
  duration: 1.5
});
  • 默认行为:直接替换(无中间动画);
  • 但配合 delimiter 或自定义逻辑,可实现打字机、乱码过渡等(不过复杂文本动画更推荐 ScrambleTextPlugin)。

gsap.to(counter, {
  duration: 1.6,
  text: "1280",        // 目标文本
  ease: "power2.out",
  paused: true
});
  • 初始文本是 "0"
  • 点击按钮后,TextPlugin 会:
    • 解析 "0""1280" 都是有效数字
    • 1.6 秒内,将文本内容从 0 → 1 → 2 → ... → 1280 逐帧更新
    • 视觉上形成“数字飞速增长”的计数器效果。

💡 注意:每次播放前重置 counter.textContent = "0" 是为了确保动画从起点开始。


⚙️ 常用配置选项(通过 text 对象):

gsap.to(element, {
  text: {
    value: "¥1,280",     // 目标值
    delimiter: ",",      // 千分位分隔符
    prefix: "¥",         // 前缀(也可直接写在 value 里)
    suffix: " 元",       // 后缀
    padSpace: true       // 保持文本长度一致(防跳动)
  },
  duration: 2
});

🌟 典型应用场景:

场景 示例
数据看板 用户数、销售额、点赞数的动态增长
加载进度 “加载中... 78%”
倒计时/计时器 活动剩余时间、秒表
游戏得分 分数变化动画
简单文本切换 状态提示(“成功” → “完成”)

🆚 对比其他方案:

方法 数字滚动 文本动画 精确控制 性能
手动 setInterval 一般
CSS + JS 拼接 ⚠️ 复杂 ⚠️ 有限 一般
TextPlugin ✅✅✅ (GSAP 时间轴)

⚠️ 注意事项:

  • 只作用于 textContent,不会影响 HTML 标签(即不能插入 <strong> 等);
  • 如果起始或目标文本不是纯数字,则直接替换(无滚动);
  • 要实现更炫的文字扰动(如乱码过渡),应使用 ScrambleTextPlugin
  • 免费可用TextPlugin 是 GSAP 的标准免费插件(无需会员)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

TextPlugin 是 GSAP 中实现“数字计数器动画”的首选工具——它用一行代码就能将静态数字变成动态增长的视觉焦点,简单、高效、且完全集成于 GSAP 动画生态系统。

什么是EasePack

 <div class="card">
    <h1>案例 32:EasePack 特殊缓动</h1>
    <p>RoughEase / SlowMo / ExpoScaleEase 都在 EasePack 中。</p>
    <div class="row">
      <div>
        <div class="lane">
          <div class="ball" id="ballA"></div>
        </div>
        <div class="label">RoughEase</div>
      </div>
      <div>
        <div class="lane">
          <div class="ball" id="ballB"></div>
        </div>
        <div class="label">SlowMo</div>
      </div>
      <div>
        <div class="lane">
          <div class="ball" id="ballC"></div>
        </div>
        <div class="label">ExpoScaleEase</div>
      </div>
    </div>
    <button id="play">播放缓动</button>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>

  <!-- RoughEase, ExpoScaleEase and SlowMo are all included in the EasePack file -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/EasePack.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
  <!-- CustomBounce requires CustomEase -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomBounce.min.js"></script>
  <!-- CustomWiggle requires CustomEase -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomWiggle.min.js"></script>
  <script>
    const ballA = document.querySelector("#ballA");
    const ballB = document.querySelector("#ballB");
    const ballC = document.querySelector("#ballC");
    const playButton = document.querySelector("#play");

    // 预设三个缓动
    const rough = RoughEase.ease.config({
      strength: 1.5,
      points: 20,
      template: Power1.easeInOut,
      randomize: true
    });
    const slowMo = SlowMo.ease.config(0.7, 0.7, false);
    const expoScale = ExpoScaleEase.config(1, 3);

    const timeline = gsap.timeline({ paused: true });
    timeline.to(ballA, { y: 100, duration: 1.2, ease: rough }, 0);
    timeline.to(ballB, { y: 100, duration: 1.2, ease: slowMo }, 0);
    timeline.to(ballC, { y: 100, duration: 1.2, ease: expoScale }, 0);

    playButton.addEventListener("click", () => {
      gsap.set([ballA, ballB, ballC], { y: 0 });
      timeline.restart();
    });
  </script>

RoughEaseSlowMoExpoScaleEaseGSAP(GreenSock Animation Platform) 中三个非常有特色的高级缓动函数(easing functions),它们都包含在 EasePack 插件中。它们超越了传统的“入/出”缓动(如 easeInOut),提供了更具创意和物理感的动画节奏。

下面分别解释它们的作用和适用场景:


1. 🌀 RoughEase —— “抖动式”缓动

✅ 作用:

模拟不规则、随机抖动的运动效果,常用于表现:

  • 手绘感、草图风格
  • 震动、故障、不稳定状态
  • 卡通式的“弹跳后晃动”

🔧 核心参数(通过 .config() 设置):

const rough = RoughEase.ease.config({
  strength: 1.5,     // 抖动强度(0~2,默认 1)
  points: 20,        // 抖动点数量(越多越密集)
  template: Power1.easeInOut, // 基础缓动曲线(决定整体趋势)
  randomize: true    // 是否每次播放随机(true=更自然)
});

🎯 在你的代码中:

  • 小球 A 下落时会上下轻微抖动,不是平滑移动,而是像“被手抖着拉下来”。

💡 适合:游戏中的受击反馈、加载失败提示、趣味 UI。


2. 🐢 SlowMo —— “慢动作中心”缓动

✅ 作用:

让动画在中间阶段变慢,两端加速,形成“慢镜头”效果。
特别适合强调某个关键状态(如悬停、高亮、停顿)。

🔧 核心参数:

const slowMo = SlowMo.ease.config(
  linearRatio,   // 中间匀速部分占比(0~1)
  power,         // 两端加速强度(0=线性,1=强缓出)
  yoyoMode       // 是否用于往返动画(true=对称)
);

例如:SlowMo.ease.config(0.7, 0.7, false)
→ 动画 70% 的时间以近似匀速缓慢进行,开头和结尾快速过渡。

  • 小球 B 下落时,大部分时间缓慢移动,只在开始和结束瞬间加速,仿佛“优雅降落”。

💡 适合:产品展示、LOGO 入场、需要突出中间状态的动画。


3. 📏 ExpoScaleEase —— “指数缩放”缓动

✅ 作用:

实现基于比例(scale)或指数增长/衰减的非线性缓动。
常用于:

  • 缩放动画(从 1x 到 10x)
  • 音量/亮度/透明度等对数感知属性
  • 模拟真实世界的指数变化(如声音衰减、光强)

🔧 核心参数:

const expoScale = ExpoScaleEase.config(startValue, endValue);
  • 它会将动画值从 startValueendValue指数曲线映射。
  • 通常配合 scaleopacity 或自定义属性使用。

🎯 虽然用于 y,但效果仍体现非线性:

  • 小球 C 的下落速度先快后慢(或反之,取决于范围),但变化是非线性的,比 power2 更“陡峭”。

💡 更典型用法:

gsap.to(circle, {
  scale: 5,
  ease: ExpoScaleEase.config(1, 5) // 从 1 倍到 5 倍的指数缩放
});

💡 适合:放大镜效果、爆炸扩散、雷达扫描、声波可视化。


🆚 对比总结:

缓动类型 视觉特点 典型用途
RoughEase 随机抖动、不规则 故障风、手绘感、震动反馈
SlowMo 中间慢、两头快 强调关键帧、优雅停顿
ExpoScaleEase 指数级加速/减速 缩放、对数感知属性、物理模拟

⚠️ 注意事项:

  • 这些缓动都来自 EasePack,需单独引入(如你代码中已做);
  • 它们可以像普通 ease 一样用在任何 GSAP 动画中;
  • 结合 Timeline 可创建复杂节奏组合。

📚 官方文档:


✅ 总结:

RoughEaseSlowMoExpoScaleEase 是 GSAP 赋予动画“性格”的秘密武器——它们让运动不再机械,而是充满随机性、戏剧性或物理真实感,是打造高级交互动效的关键工具。

什么是 CustomEase

<div class="card">
      <h1>案例 33:CustomEase 自定义缓动</h1>
      <p>用贝塞尔曲线定义缓动曲线。</p>
      <div class="track">
        <div class="block" id="block"></div>
      </div>
      <button id="play">播放自定义缓动</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
    <script>
      const block = document.querySelector("#block");
      const playButton = document.querySelector("#play");

      // 注册 CustomEase
      gsap.registerPlugin(CustomEase);

      // 创建一个自定义缓动曲线
      CustomEase.create("myEase", "0.25,0.1,0.25,1");

      const tween = gsap.to(block, {
        x: 470,
        duration: 1.4,
        ease: "myEase",
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

CustomEaseGSAP(GreenSock Animation Platform) 提供的一个强大插件,允许你通过自定义贝塞尔曲线(cubic-bezier)来创建完全个性化的缓动函数(easing function),从而精确控制动画的速度变化节奏。


📌 简单定义:

CustomEase 让你像在 CSS 或设计工具中那样,用 4 个控制点定义一条缓动曲线,并将其注册为可复用的 GSAP 缓动名称,用于任何动画。

它打破了内置缓动(如 power2.inOutelastic)的限制,实现电影级、品牌专属或物理拟真的运动节奏


✅ 核心原理:贝塞尔曲线

缓动曲线本质是一条 三次贝塞尔曲线(Cubic Bezier),由 4 个点定义:

  • 起点固定为 (0, 0)
  • 终点固定为 (1, 1)
  • 中间两个控制点 (x1, y1)(x2, y2) 决定曲线形状

CustomEase 中,你只需提供这 4 个数值(按顺序):

" x1, y1, x2, y2 "

例如你的代码:

CustomEase.create("myEase", "0.25,0.1,0.25,1");

表示:

  • 控制点 1: (0.25, 0.1)
  • 控制点 2: (0.25, 1)

这条曲线的特点是:启动非常快(y1 很低),然后突然减速并平稳结束,形成一种“急冲后刹车”的效果。


🔧 使用步骤:

  1. 引入插件

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
    
  2. 注册自定义缓动

    CustomEase.create("myEase", "0.25,0.1,0.25,1");
    
    • 第一个参数:缓动名称(字符串,如 "myEase"
    • 第二个参数:贝塞尔控制点(4 个 0~1 之间的数字,用逗号分隔)
  3. 在动画中使用

    gsap.to(block, {
      x: 470,
      duration: 1.4,
      ease: "myEase" // 直接使用注册的名称
    });
    

🌟 优势 vs 其他方式:

方式 灵活性 可视化 复用性 性能
CSS cubic-bezier() ✅(开发者工具) ❌(需重复写)
手动计算进度 ⚠️
CustomEase ✅✅✅ ✅(配合 GSAP 工具) ✅✅✅(全局注册) ✅✅(预计算优化)

💡 CustomEase预计算并缓存曲线数据,运行时性能极高,适合复杂动画。


🛠️ 如何获取贝塞尔值?

  1. 使用 GSAP 官方工具(推荐!)
    👉 GSAP Ease Visualizer

    • 拖动控制点实时预览动画
    • 自动生成 CustomEase 代码
  2. 从 CSS 复制
    如果你在 CSS 中写了:

    transition: all 1s cubic-bezier(0.25, 0.1, 0.25, 1);
    

    那么值就是 "0.25,0.1,0.25,1"

  3. 从 Figma / After Effects 导出
    许多设计工具支持导出贝塞尔缓动参数。


🎨 典型应用场景:

效果 贝塞尔示例 用途
弹性回弹 "0.68,-0.55,0.27,1.55" 按钮点击反馈
缓入缓出加强版 "0.33,0,0.67,1" 平滑过渡
快速启动+慢速结束 "0.25,0.1,0.25,1"(你的例子) 强调终点状态
延迟启动 "0.5,0,0.75,0" 悬停后才开始动画

⚠️ 注意事项:

  • 所有数值必须在 0 到 1 之间(超出会导致不可预测行为);
  • 注册一次后,可在整个项目中复用(如 "brandBounce""softEase");
  • 免费可用CustomEase 是 GSAP 的标准插件(无需 Club 会员);
  • 若需更复杂曲线(如多段),可结合 CustomWiggleCustomBounce(它们依赖 CustomEase)。

📚 官方资源:


✅ 总结:

CustomEase 是 GSAP 中实现“精准运动设计”的终极工具——它把缓动从“选择预设”升级为“自由创作”,让开发者和设计师能用同一套语言定义品牌专属的动画节奏,是打造高端用户体验的核心技术之一。

React 性能优化双子星:深入、全面解析 useMemo 与 useCallback

作者 AAA阿giao
2026年2月14日 14:51

引言

在现代 React 应用开发中,随着组件逻辑日益复杂、状态管理愈发庞大,性能问题逐渐成为开发者绕不开的话题。幸运的是,React 提供了两个强大而精巧的 Hooks —— useMemouseCallback,它们如同“缓存魔法”,帮助我们在不牺牲可读性的前提下,显著提升应用性能。

本文将结合完整代码示例,逐行解析、对比说明、深入原理,带你彻底掌握 useMemouseCallback 的使用场景、工作机制、常见误区以及最佳实践。文章内容力求全面、准确、生动有趣,并严格保留原始代码一字不变,确保你既能理解理论,又能直接复用实战。


一、为什么需要 useMemo 和 useCallback?

1.1 React 函数组件的“重运行”特性

在 React 中,每当组件的状态(state)或 props 发生变化时,整个函数组件会重新执行一遍。这意味着:

  • 所有变量都会重新声明;
  • 所有函数都会重新定义;
  • 所有计算逻辑都会重新跑一次。

这本身是 React 响应式更新机制的核心,但也会带来不必要的开销

💡 关键洞察
“组件函数重新运行” ≠ “DOM 重新渲染”。
React 会通过 Virtual DOM diff 算法决定是否真正更新 DOM。
昂贵的计算子组件的无谓重渲染,仍可能拖慢应用。


二、useMemo:为“昂贵计算”穿上缓存外衣

2.1 什么是“昂贵计算”?

看这段代码:

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

这个 slowSum 函数执行了 n * 10000 次循环!如果 n=100,就是一百万次加法。在每次组件重渲染时都调用它,用户界面可能会卡顿。

2.2 不用 useMemo 的后果

假设我们这样写:

const result = slowSum(num); // ❌ 每次渲染都重新计算!

那么,即使你只是点击了 count + 1 按钮(与 num 无关),slowSum 依然会被执行!因为整个 App 函数重新运行了。

2.3 useMemo 如何拯救性能?

React 提供 useMemo记忆(memoize)计算结果

const result = useMemo(() => {
  return slowSlow(num)
}, [num])

工作原理

  • 第一次渲染:执行函数,缓存结果。

  • 后续渲染:检查依赖项 [num] 是否变化。

    • 如果 num 没变 → 直接返回缓存值,不执行函数体
    • 如果 num 变了 → 重新执行函数,更新缓存。

2.4 完整上下文中的 useMemo 使用

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

  // ✅ 仅当 keyword 改变时才重新过滤
  const filterList = useMemo(() => {
    return list.filter(item => item.includes(keyword))
  }, [keyword])

  // ✅ 仅当 num 改变时才重新计算 slowSum
  const result = useMemo(() => {
    return slowSum(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)} />
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>count + 1</button>
      {
        filterList.map(item => (
          <li key={item}>{item}</li> 
        ))
      }
    </div>
  )
}

🔍 重点观察

  • 点击 “count + 1” 时:

    • slowSum 不会执行(因为 num 没变);
    • filterList 不会重新计算(因为 keyword 没变);
    • 控制台不会打印 “计算中...” 或隐含的 “filter执行”。
  • 这就是 useMemo 带来的精准缓存

2.5 关于 includes 和 filter 的小贴士

  • "apple".includes("") 确实返回 true(空字符串是任何字符串的子串);
  • list.filter(...) 返回的是一个新数组,即使结果为空(如 []),它也是一个新的引用

⚠️ 正因如此,如果不使用 useMemo,每次渲染都会生成一个新数组引用,可能导致依赖该数组的子组件误判为 props 变化而重渲染!


三、useCallback:为“回调函数”打造稳定身份

3.1 问题起源:函数是“新”的!

在 JavaScript 中,每次函数定义都会创建一个新对象

// 每次 App 重运行,handleClick 都是一个全新函数!
const handleClick = () => { console.log('click') }

即使函数体完全一样,handleClick !== previousHandleClick

3.2 子组件为何“无辜重渲染”?

看这段代码:

const Child = memo(({count, handleClick}) => {
  console.log('child重新渲染')
  return (
    <div onClick={handleClick}>
      <h1>子组件 count: {count}</h1>
    </div>
  )
})
  • memo 的作用:浅比较 props,若没变则跳过渲染。
  • 但每次父组件重渲染,handleClick 都是新函数 → props 引用变了 → memo 失效 → 子组件重渲染!

即使你只改了 numChild 也会重渲染,尽管它只关心 count

3.3 useCallback 的解决方案

useCallback 本质上是 useMemo 的语法糖,专用于缓存函数

const handleClick = useCallback(() => {
  console.log('click')
}, [count])

效果

  • 只要 count 不变,handleClick 的引用就保持不变;
  • Child 的 props 引用未变 → memo 生效 → 跳过重渲染

3.4 完整 useCallback 示例

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

const Child = memo(({count, handleClick}) => {
  console.log('child重新渲染')
  return (
    <div onClick={handleClick}>
      <h1>子组件 count: {count}</h1>
    </div>
  )
})

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

  // ✅ 缓存函数,依赖 count
  const handleClick = useCallback(() => {
    console.log('click')
  }, [count])
  
  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>count + 1</button>
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>num + 1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  )
}

🔍 行为验证

  • 点击 “num + 1”:Child 不会打印 “child重新渲染”;
  • 点击 “count + 1”:Child 重渲染(因为 counthandleClick 都变了);
  • 如果 handleClick 不依赖 count(依赖项为 []),则只有 count 变化时 Child 才重渲染。

四、useMemo vs useCallback:一张表说清区别

特性 useMemo useCallback
用途 缓存任意值(数字、数组、对象等) 缓存函数
本质 useMemo(fn, deps) useMemo(() => fn, deps) 的简写
典型场景 昂贵计算、过滤/映射大数组、创建复杂对象 传递给 memo 子组件的事件处理器
返回值 函数执行的结果 函数本身
错误用法 用于无副作用的纯计算 用于依赖外部变量但未声明依赖

💡 记住
useCallback(fn, deps)useMemo(() => fn, deps)


五、常见误区与最佳实践

❌ 误区1:到处使用 useMemo/useCallback

  • 不要为了“可能的优化”而滥用

  • 缓存本身也有开销(存储、比较依赖项)。

  • 只在以下情况使用

    • 计算确实昂贵(如大数据处理);
    • 导致子组件无谓重渲染(配合 memo);
    • 作为 props 传递给已优化的子组件。

❌ 误区2:依赖项遗漏

const handleClick = useCallback(() => {
  console.log(count) // 依赖 count
}, []) // ❌ 错误!应该写 [count]

这会导致函数捕获旧的 count(闭包陷阱)。

✅ 正确做法:所有外部变量都必须出现在依赖数组中

✅ 最佳实践

  1. 先写逻辑,再优化:不要过早优化。
  2. 配合 React DevTools Profiler:定位真实性能瓶颈。
  3. useMemo 用于值,useCallback 用于函数
  4. 依赖项要完整且精确:使用 ESLint 插件 eslint-plugin-react-hooks 自动检查。

六、总结:性能优化的哲学

useMemouseCallback 并非银弹,而是 React 赋予我们的精细控制权。它们让我们能够:

  • 隔离变化:让无关状态的更新不影响其他部分;
  • 减少冗余:避免重复计算和渲染;
  • 提升用户体验:使应用更流畅、响应更快。

正:

“count 和 keyword 不相关”
“某一个数据改变,只想让相关的子组件重新渲染”

这正是 React 性能优化的核心思想:局部更新,全局协调


附:完整代码地址

源码地址:react/memo/memo/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国

🎉 掌握 useMemouseCallback,你已经迈入 React 性能优化的高手之列!
下次遇到“为什么子组件总在乱渲染?”或“计算太慢怎么办?”,你就知道答案了。

Happy coding! 🚀

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2025详细解读

作者 shadowingszy
2026年2月14日 14:37

往期文章:

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2021 & state-of-css 2021详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2022 & state-of-js 2022详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2023详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2023详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2024和state-of-js 2024详细解读

一、写在前面

  • 本次分享的数据来源是state-of-js,是由Devgraphics开源社区团队发起的前端生态圈中规模最大的数据调查。
  • 想要贡献state-of-js调查结果中文翻译的同学可以联系我,或者直接向Devographics/locale-zh-Hans这个仓库提PR,然后艾特我来帮你review。
  • 如果这篇文章有其他意见或更好的建议,欢迎各位同学们多多指教。

二、受访者统计

今年的state-of-js调查共回收了13002份问卷结果。和去年相问卷结果又少了一些。

其实自从2022年起,填写问卷的人就越来越少,原因无外乎这么几个:

  • 前端的整体热度都在走低,像是google trends上前端相关的搜索词的热度都在下降;
  • 问卷内容过长导致内容填写起来比较麻烦;
  • 受访者虽然一直关注这项调查,但填了第一年的问卷之后第二年的问卷就不填了等等。

而在今年我结合我在Datawhale做的一些数据调查来看,有一个更重要的原因,就是AI的崛起——大部分开发者们的注意力已经转向了AI领域(包括我自己也是),基本不会在前端领域投入过多关注了

之前我也和调查发起人@SachaG聊过state-of-js调查的未来,作为一项坚持了9年的前端数据调查,也算是见证了前端领域的崛起与衰落。而如今,前端领域的热度早已不再是当年的样子,这项调查也不知道还能做多少年,大家且看且珍惜吧。

三、JS特性

语法特性

从今年的语法特性使用情况来看,社区对提升代码健壮性和简洁性的新特性抱有极大的热情:

  • 空值合并 运算符 ?? 的使用率高达 87% ,已经成为事实上的标准,这说明开发者在处理 nullundefined 时,迫切需要一种比 || 更严谨、更明确的工具来避免将 0false 等有效值意外覆盖,在日常开发中,我们应当优先使用 ?? 来处理默认值赋值,以增强代码的稳定性和可预测性。
  • 动态导入( Dynamic Import 66% 的使用率紧随其后,反映出代码分割和按需加载已是现代 Web 应用性能优化的核心实践,在构建大型应用、特别是需要考虑首屏加载速度的场景时,动态导入几乎是必修课。
  • 类私有字段( Private Fields 逻辑赋值 运算符 Logical Assignment 的使用率分别为 43%35% ,表明封装和代码简写同样是开发者追求的目标,尤其是私有字段,为在团队协作中保护内部状态、减少意外修改提供了语言层面的保障。

Array、Set、Object的特性

今年对 ArraySetObject 数据结构的新特性调查,揭示了不可变性(Immutability)数据处理便利性 已成为前端开发的核心趋势:

  • 返回新数组的 toSorted() 使用率已达 47% ,其孪生兄弟 toReversed() 也达到 37% ,说明社区正主动避免原地修改数组带来的副作用。
  • Set 新方法整体普及度不高,但在使用者中 union()intersection()difference() 等集合运算需求最集中,开始用于表达更复杂的数据关系与权限逻辑。
  • 首次进入调查的 Object.groupBy() 拿到 39% 使用率,说明了“按字段分组”这类高频需求可以摆脱 Lodash 等库,直接靠原生 JS 优雅解决。

Promise的特性

在异步编程领域,对多个 Promise 的精细化控制能力已成为现代前端的标配:

  • Promise.allSettled()52% 的使用率登顶,适合在“批量请求但不希望单点失败拖垮整体流程”的场景下使用,例如并行拉取多个非关键数据源、日志或埋点结果,它能保证我们总能拿到每个 Promise 的最终状态。
  • Promise.any() 使用率也达到 47% ,是“抢最快一个结果”的利器,典型场景是对多个镜像服务发起并行请求、谁先返回就用谁,从而显著优化响应延迟。
  • 这两个 API 的走红说明前端异步模型已经从“能并发”走向“可编排”,开发者不再满足于简单的 Promise.all,而是开始为不同业务场景选择更合适的并发策略。

浏览器API

浏览器 API 的使用情况反映了 Web 应用能力正从传统的页面展示,向功能更丰富、更接近原生应用的形态演进:

  • WebSocket 仍以 64% 的使用率牢牢占据基础设施地位,支撑了社交、协作、监控看板等场景中的实时通信。
  • PWA 使用率达到 48% ,说明离线能力、安装体验和通知能力已经被越来越多团队纳入评估维度。
  • 更值得关注的是 WebAssembly (WASM) ,使用率已达 21% 且排名上升 2 位,高性能语言(如 C++、Rust)编译到浏览器侧解决音视频处理、加解密、游戏等计算密集型问题,正在从先锋实践迈向工程常规武器。

JS语言的痛点

关于 JS 语言自身的痛点,今年的结果再次印证了社区共识:

  • 缺乏静态类型(Static Typing)28% 的提及率高居第一,这直接解释了为何 TypeScript 能在短时间内成为事实标准——大型项目在可维护性、重构安全和错误提前暴露上的诉求远非动态类型所能满足。
  • 日期处理(Dates)10% 排名第二,说明即便有 Temporal 提案在推进,现实中开发者仍大量依赖 date-fnsDay.js 等第三方库来填补标准库短板。
  • 同时,ESM CJS 的兼容问题标准库整体匮乏 等历史包袱也依然是工程实践中的绊脚石,这些痛点共同构成了“JS 好用但不够省心”的真实写照。

浏览器的痛点

当我们把视线从语言本身转向其运行环境——浏览器时,痛点显得更具工程现实感:

  • 跨浏览器支持(Browser support)31% 的提及率稳居首位,说明即便现代浏览器在标准实现上趋于一致,边缘行为差异、新特性落地节奏和兼容性策略仍是困扰前端团队的主要问题。
  • 浏览器测试(Browser testing)13% 位列第二,本质上是跨浏览器差异在测试和回归成本上的放大反馈
  • 而被单独点名的 Safari7% 成为第三大痛点,很多团队已经默认把它视作“新时代的 IE”,其标准跟进节奏和独特限制,为跨端一致性和平滑体验带来了额外负担。

四、JS技术

综述

这两张图分别从“历史趋势”和“当前满意度”两个维度,为我们描绘了 JS 技术生态的全景图:

  • 左侧四象限清晰展示出以 Vite 为代表的新一代工具,正沿着“低使用、高满意度”向“高使用、高满意度”高速跃迁,而曾经的王者 webpack 虽然仍有庞大使用量,但满意度明显滑落且轨迹线转为紫色,显示出疲态
  • 从右侧满意度分级我们可以发现,Vite (98%)Vitest (97%)Playwright (94%)Astro (94%) 等新星占据 S 级,而 webpack (26%)Angular (48%)Next.js (55%) 等传统选手则跌入 B/C 级,这意味着“存量巨大但口碑一般”的技术栈随时可能迎来用户流失;同时,Vite 生态中 Vite + Vitest 的双双登顶也说明高度协同的一体化工具链的优势,对于开发者而言,技术选型时不能只看当前占有率,更要关注满意度和趋势曲线,尤其要多留意那些位于右下象限、线条仍在上扬的新工具。

前端框架

前端框架的长期“三巨头”格局正在被悄然改写:

  • React 依旧以 80%+ 的使用率牢牢占据生态核心,但满意度已滑落到 B 级(72%),复杂的心智模型和渐进式演化成本让不少团队收到困扰。
  • Vue.js 在 2022 年前后正式超越 Angular 成为第二大框架,并以 84% 的满意度稳居 A 级,证明其在开发体验与性能之间找到了不错的平衡点。
  • Svelte 则凭借“无虚拟 DOM”的编译时理念持续走高,使用率已升至 26% ,成为追求极致性能和简洁语法团队的心头好。
  • 更有意思的是 HTMX,在近两年实现爆发式增长、使用率来到 13% ,它用“回归 HTML、用属性驱动交互”的思路,对当下 JS-heavy 的前端栈提出了有力反思。

元框架(前后端一体化框架)

元框架领域呈现出“一家独大 + 新星涌现”的混合格局:

  • Next.js 继续凭借与 React 的深度绑定,以近 60% 的使用率统治榜单,是大多数 React 团队构建生产级应用的默认选项,App Router 等激进改动和整体复杂度的提升正在透支开发者耐心。
  • Nuxt 在 Vue 生态中稳扎稳打,使用率升至 28%
  • AstroSvelteKit 则是近年最值得关注的两颗新星,前者在内容密集型站点中大放异彩,后者与 Svelte 深度绑定,为全栈应用提供了端到端的极致体验。

后端框架

在 Node.js 后端框架领域,我们不难看出,还是有些新面孔:

  • 老牌选手 Express 仍以 80%+ 的使用率稳居第一,作为“薄核心 + 丰富中间件”的事实标准难以被完全替代,但 81% 的满意度也表明开发者正在寻找更现代的方案
  • tRPC 是过去两年最耀眼的新星,通过直接在 TypeScript 中实现端到端类型安全调用,大幅简化了前后端联调与接口演进的成本。

测试框架

JavaScript 测试生态正在经历一场“现代化重构”:

  • 在单元与集成测试层面,Jest75% 的使用率独占鳌头。
  • 端到端测试领域则被 Cypress (55%)Playwright (49%) 两强主导,其中 Playwright 以 94% 的满意度跻身 S 级,体现了其在稳定性、调试体验和多浏览器支持上的优势。
  • 紧随其后的是 Vitest,作为 Vite 生态的一员,在短短两年内使用率冲到 50% ,满意度更是高达 97% ,验证了“测试工具与构建工具深度一体化”带来的体验红利。

构建工具

前端构建工具领域也在发生变革:

  • webpack 依旧以 85% 的使用率占据绝对存量,但满意度已经跌至 26% ,复杂配置和缓慢构建让它越来越像一座难以完全迁移的“基础设施债务”。
  • Vite 则是新时代的领跑者,使用率在短短数年间拉升到 83% 、几乎追平 webpack,满意度更是高达 98% ,依托基于 Go 的 esbuild 实现极快冷启动和热更新,重新定义了“本地开发体验”的下限
  • 在更底层 esbuild 的直接使用率已达 52%SWC 也拿到 83% 的满意度,说明社区正将编译热点下沉到 Rust/Go 等原生实现,再在其之上搭建更友好的工具。

五、其它工具

JS库使用情况

在通用 JS 库层面,数据清晰地表明开发者最在乎两件事:

  • 类型安全数据处理效率。以 TypeScript 为优先设计的校验库 Zod48% 的使用率登顶,成为“运行时数据校验 + 类型推导”领域的绝对主角,反映出大家在 API 返回、表单输入等链路上,对类型与数据一致性的强烈诉求。
  • 传统工具库 Lodash (39%) 依然宝刀不老,仍在大量项目中承担通用数据处理职责。
  • 而在日期处理上,date-fns (39%)Moment (25%)Day.js (24%) 等多家共存,本质上是对 JS 原生日期能力长期缺位的弥补
  • 即便是已经被视作“老古董”的 jQuery (16%) ,也仍凭借海量遗留项目保持着不可忽视的存在感。

AI使用情况

AI 工具已经深度嵌入前端开发者的日常工作流,成为新的基础设施:

  • ChatGPT60% 的使用率位居首位,承担了问答、代码草稿生成、调试思路辅助等“外脑”角色。
  • 深度集成 IDE 的 GitHub Copilot 使用率也达 51% ,更偏向于在写代码时提供上下文感知补全与重构建议,两者形成“离线思考 + 在线自动补全”的互补关系
  • 与此同时,Claude (44%)Google Gemini (28%) 等通用大模型产品也在快速补位,说明开发者愿意多源头对比体验
  • 值得注意的是 AI-native 编辑器 Cursor 已有 26% 的使用率,一部分人开始直接迁移到“以 AI 为核心交互对象”的编辑环境中,这预示着未来开发工具形态本身也会被 AI 重塑。
  • 另外,国产大模型 Deepseek 也榜上有名,占据了 8% 的使用率。

其它编程语言使用情况

这张图展示了 JS 开发者的多语言画像:

  • Python41% 的占比成为最常见的第二语言,依托后端开发、自动化脚本、数据分析与 AI 等丰富场景,为前端同学打开了更多技术边界。
  • PHP (27%) 的存在感说明不少人仍在使用 Web 传统栈构建项目或是在维护古老的历史项目。
  • 在工具链和 DevOps 侧,Bash (22%) 几乎是所有工程师的“必修课”。
  • Java (21%)Go (20%)C# (19%) 等企业级后端语言,以及以安全与性能著称的 Rust (16%) ,则构成了很多前端开发者向全栈或更底层系统方向延展的技能支点。

六、使用情况及痛点问题

TS与JS的使用情况

这张分布图有力地说明,TypeScript 已经从“可选增强”进化为 JavaScript 生态的默认选项

  • 48% 的受访者表示项目代码 100% 使用 TS 编写,体现出“一旦采用就倾向于全量迁移”的强烈偏好;在所有项目(包括纯 JS、纯 TS 与混合工程)中计算得到的平均采用率高达 77% ,意味着当今前端代码大部分都运行在类型系统保护之下;仍坚持纯 JS 的开发者仅占 6% ,多半集中在遗留项目或极轻量脚本场景;对于在做技术选型的新项目来说,这几乎已经构成了一个共识结论:默认使用 TS,而不是再纠结要不要上 TS

AI代码生成情况

这张图刻画了 AI 在代码生成中的“真实渗透率”,结论很清晰:

  • AI 目前更像是开发者的“副驾驶”,而非自动写代码的主力工程师。只有 10% 的受访者认为项目代码完全没有 AI 贡献,说明九成以上的团队或多或少已经在用 AI 提效;最集中的区间是 1%–20% 代码由 AI 生成(占 38% ),典型用法是让 AI 帮忙写模板代码、样板逻辑、特定算法实现或提供重构建议,而不是让它从零实现完整模块;总体算下来,平均约有 29% 的代码可以归功于 AI,这是一个不容忽视但远未到“全自动开发”的比例,也意味着复杂业务建模、架构设计和质量把控这些高阶工作,短期内仍牢牢掌握在人类开发者手中。

JS的痛点问题

在所有 JS 开发痛点中,真正让团队头疼的并不是某个语法细节,而是宏观层面的工程复杂度:

  • 代码架构(Code Architecture)38% 的提及率高居榜首,说明随着前端项目体量和生命周期不断拉长,如何拆分模块、划分边界、治理依赖、避免“屎山”成为最大挑战。
  • 紧随其后的是 状态管理(State Management,34%) ,无论是 React 的 hooks 与各种状态库,还是 Vue 的 Pinia,跨组件、跨页面的复杂状态流转依然极易失控。
  • 依赖管理(Managing Dependencies,32%) 也是老大难问题,node_modules 黑洞、版本冲突、安全漏洞以及 ESM/CJS 兼容性都会侵蚀工程稳定性。
  • 相对而言,曾经广受诟病的 异步 代码(Async Code) 如今只剩 11% 的人视其为痛点,Promiseasync/await 已经在很大程度上平滑了这块心智负担,这也从侧面证明语言与工具的演进确实可以逐步“消灭”一部分历史问题。

七、总结

首先,毫无疑问,TypeScript 已然胜出。它赢下的不只是「能编译成js的工具」的争论,而是语言本身。Deno 和 Bun 早已原生支持它。如今,你甚至能在稳定版 Node.js 中直接编写 TypeScript了。

而 Vite 的时代也已到来。今年,Vite 的下载量正式超越 webpack。与之相伴,Vitest 的使用量也大幅飙升。现在正是切换到新一代 Vite 工具链的好时机,而 2026 年注定会是全面落地之年—— 随着 Rolldown 稳定版发布,将驱动出更快的新一代 Vite,同时还有一体化的「Vite+」值得期待。

我们的开发工具从未如此优秀。但大家如今真正关心的却是另一个问题:AI 又将带来什么?

AI 即将彻底改变我们查阅文档、编写代码、做架构决策等一系列工作方式。各家公司都在全力押注全新的开发模式。对我们绝大多数人而言,AI 编程助手正在改变我们与代码交互的方式。

这是一件好事吗?

截至 2025 年底,已有近 30% 的代码由 AI 生成。Cursor 的人气暴涨,尽管它们暂时还无法撼动 VS Code 第一 IDE 的地位。而基于智能代理的工具,比如 Claude、Gemini 和 Copilot,也在迅速普及。

对开发者来说,无论使用什么工具,懂得分辨「什么是好代码」 将会比以往任何时候都更重要。紧跟新语言特性、知道该基于哪些库去开发,而非凭感觉从零手写一切,也会变得愈发关键。

现在,一天之内快速搭建新项目、轻松迁移老项目都已成为现实。这对框架和库的作者来说是个挑战。我们必须保证工具能持续服务好开发者,不能指望用户会一直因惯性而使用。

而这一点,恰恰值得所有开发者的期待。

就让我们拭目以待 2026 年的变化吧。我期待着更快的工具、更好的开发体验,以及技术真正成为能力放大器,强化我们自身的判断与选择。

父传子全解析:从基础到实战,新手也能零踩坑

2026年2月14日 14:30

在 Vue3 组件化开发中,父传子是最基础、最常用的组件通信方式,也是新手入门组件通信的第一步。无论是传递简单的字符串、数字,还是复杂的对象、数组,甚至是方法,父传子都有清晰、规范的实现方式。

不同于 Vue2 选项式 API 中 props 的写法,Vue3 组合式 API(

一、核心原理:单向数据流 + Props 传值

Vue3 父传子的核心逻辑只有两个关键词:Props单向数据流

  • Props:父组件通过在子组件标签上绑定属性(类似 HTML 标签属性),将数据传递给子组件;子组件通过定义 props,接收父组件传递过来的数据,相当于子组件的「输入参数」。
  • 单向数据流:数据只能从父组件流向子组件,子组件不能直接修改父组件传递过来的 props 数据(否则会报错)。如果子组件需要修改 props 数据,必须通过子传父的方式,通知父组件修改原始数据。

记住一句话:Props 是只读的,修改需找父组件。这是 Vue 组件通信的核心规范,也是避免数据混乱的关键。

父传子的核心流程(3步走):

  1. 父组件:在使用子组件的标签上,通过 :属性名="要传递的数据" 绑定数据;
  2. 子组件:通过 defineProps 定义要接收的 props(声明属性名和类型,可选但推荐);
  3. 子组件:在模板或脚本中,直接使用 props 中的数据(无需额外导入,直接通过 props.属性名 或 直接写属性名使用)。

二、基础用法:最简洁的父传子实现(必学)

我们用一个「父组件传递基本数据,子组件展示」的简单案例,讲解最基础的父传子写法,代码可直接复制到项目中运行,零门槛上手。

1. 父组件(Parent.vue):绑定数据并传递

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>父组件的基本数据:{{ parentName }}、{{ parentAge }}</p>
    <p>父组件的数组:{{ parentList.join('、') }}</p>
    <p>父组件的对象:{{ parentObj.name }} - {{ parentObj.gender }}</p>

    <!-- 1. 核心:在子组件标签上,通过 :属性名 绑定要传递的数据 -->
    <Child 
      :name="parentName"  // 传递字符串
      :age="parentAge"    // 传递数字
      :list="parentList"  // 传递数组
      :user-info="parentObj"  // 传递对象推荐用短横线命名)
    />
  </div>
</template>

<script setup>
// 引入子组件(Vue3 <script setup> 中,引入后可直接在模板中使用)
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件要传递的数据(涵盖基本类型、数组、对象)
const parentName = ref('张三') // 字符串
const parentAge = ref(25)     // 数字
const parentList = ref(['苹果', '香蕉', '橙子']) // 数组
const parentObj = reactive({  // 对象
  name: '李四',
  gender: '男',
  age: 30
})
</script>

2. 子组件(Child.vue):定义Props并使用

<template>
  <div class="child">
    <h4>我是子组件(接收父组件传递的数据)</h4>
    <p>接收的字符串:{{ name }}</p>
    <p>接收的数字:{{ age }} 岁</p>
    <p>接收的数组:{{ list.join('、') }}</p>
    <p>接收的对象:{{ userInfo.name }}({{ userInfo.gender }})</p>
  </div>
</template>

<script setup>
// 2. 核心:通过 defineProps 定义要接收的 props
// 写法1:数组形式(简单场景,只声明属性名,不限制类型)
// const props = defineProps(['name', 'age', 'list', 'userInfo'])

// 写法2:对象形式(推荐,可限制类型、设置默认值、必填校验)
const props = defineProps({
  // 字符串类型
  name: {
    type: String,
    default: '默认用户名' // 默认值(父组件未传递时使用)
  },
  // 数字类型
  age: {
    type: Number,
    default: 18
  },
  // 数组类型(注意:数组/对象的默认值必须用函数返回,避免复用污染)
  list: {
    type: Array,
    default: () => [] // 数组默认值:返回空数组
  },
  // 对象类型(同理,默认值用函数返回)
  userInfo: {
    type: Object,
    default: () => ({}) // 对象默认值:返回空对象
  }
})

// 3. 在脚本中使用 props 数据(通过 props.属性名)
console.log('脚本中使用props:', props.name, props.age)
</script>

3. 基础细节说明(新手必看)

  • defineProps 是 Vue3 内置宏,无需导入,可直接在
  • 父组件传递数据时,属性名推荐用 kebab-case(短横线命名),比如 :user-info,子组件接收时用 camelCase(小驼峰命名),比如 userInfo,Vue 会自动做转换;
  • 数组/对象类型的 props,默认值必须用 函数返回(比如 default: () => []),否则多个子组件会复用同一个默认值,导致数据污染;
  • 子组件模板中可直接使用 props 的属性名(比如{{ name }}),脚本中必须通过 props.属性名 使用(比如 props.name)。

三、进阶用法:优化父传子的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「必填校验」「类型多可选」「props 数据转换」等需求,这部分进阶技巧能让你的代码更规范、更健壮,避免后续维护踩坑。

1. Props 校验:必填项 + 多类型 + 自定义校验

通过 defineProps 的对象形式,我们可以对 props 进行全方位校验,避免父组件传递错误类型、遗漏必填数据,提升代码可靠性。

<script setup>
const props = defineProps({
  // 1. 必填项校验(required: true)
  username: {
    type: String,
    required: true, // 父组件必须传递该属性,否则控制台报警告
    default: '' // 注意:required: true 时,default 无效,可省略
  },

  // 2. 多类型校验(type 为数组)
  id: {
    type: [Number, String], // 允许父组件传递数字或字符串类型
    default: 0
  },

  // 3. 自定义校验(validator 函数)
  score: {
    type: Number,
    default: 0,
    // 自定义校验规则:分数必须在 0-100 之间
    validator: (value) => {
      return value >= 0 && value <= 100
    }
  }
})
</script>

说明:校验失败时,Vue 会在控制台打印警告(不影响代码运行),但能帮助我们快速定位问题,尤其适合团队协作场景。

2. Props 数据转换:computed 处理 props 数据

子组件不能直接修改 props 数据,但可以通过 computed 对 props 数据进行转换、格式化,满足子组件的展示需求,不影响原始 props 数据。

<template>
  <div class="child">
    <p>父组件传递的分数:{{ score }}</p>
    <p>转换后的等级:{{ scoreLevel }}</p>
    <p>父组件传递的姓名(大写):{{ upperName }}</p>
  </div>
</template>

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

const props = defineProps({
  score: {
    type: Number,
    default: 0
  },
  name: {
    type: String,
    default: ''
  }
})

// 对 props 分数进行转换:0-60 不及格,60-80 及格,80-100 优秀
const scoreLevel = computed(() => {
  const { score } = props
  if (score >= 80) return '优秀'
  if (score >= 60) return '及格'
  return '不及格'
})

// 对 props 姓名进行格式化:转为大写
const upperName = computed(() => {
  return props.name.toUpperCase()
})
</script>

3. 传递方法:父组件给子组件传递回调函数

父传子不仅能传递数据,还能传递方法(回调函数)。核心用途:子组件通过调用父组件传递的方法,通知父组件修改数据(解决子组件不能直接修改 props 的问题)。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <p>父组件计数器:{{ count }}</p>
    <!-- 传递方法::方法名="父组件方法" -->
    <Child 
      :count="count"
      :addCount="handleAddCount"  // 传递父组件的方法
    />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { ref } from 'vue'

const count = ref(0)

// 父组件的方法(将被传递给子组件)
const handleAddCount = () => {
  count.value++
}
</script>
<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <p>子组件接收的计数器:{{ count }}</p>
    <!-- 调用父组件传递的方法 -->
    <button @click="addCount">点击让父组件计数器+1</button>
  </div>
</template>

<script setup>
const props = defineProps({
  count: {
    type: Number,
    default: 0
  },
  // 声明接收父组件传递的方法(type 为 Function)
  addCount: {
    type: Function,
    required: true
  }
})

// 也可以在脚本中调用父组件的方法
const callParentMethod = () => {
  props.addCount()
}
</script>

注意:传递方法时,父组件只需写 :addCount="handleAddCount"(不带括号),子组件调用时再带括号 addCount();如果父组件写 :addCount="handleAddCount()",会导致方法立即执行,而非传递方法本身。

4. 批量传递 props:v-bind 绑定对象

如果父组件需要给子组件传递多个 props,逐个绑定会比较繁琐,这时可以用 v-bind 批量绑定一个对象,子组件只需对应接收即可。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 批量传递:v-bind="对象",等价于逐个绑定对象的属性 -->
    <Child v-bind="userObj" />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { reactive } from 'vue'

// 要批量传递的对象
const userObj = reactive({
  name: '张三',
  age: 25,
  gender: '男',
  address: '北京'
})
</script>
<!-- 子组件(Child.vue) -->
<script setup>
// 逐个接收父组件批量传递的 props,和普通 props 接收一致
const props = defineProps({
  name: String,
  age: Number,
  gender: String,
  address: String
})
</script>

四、实战场景:父传子的高频应用(贴合实际开发)

结合实际开发中的高频场景,补充 3 个常用案例,覆盖大部分父传子需求,直接套用即可。

场景1:父组件控制子组件弹窗显示/隐藏

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <button @click="visible = true">打开子组件弹窗</button>
    <!-- 传递弹窗显示状态 + 关闭弹窗的方法 -->
    <ChildModal 
      :visible="visible"
      :closeModal="handleCloseModal"
    />
  </div>
</template>

<script setup>
import ChildModal from './ChildModal.vue'
import { ref } from 'vue'

const visible = ref(false)

// 关闭弹窗的方法
const handleCloseModal = () => {
  visible.value = false
}
</script>
<!-- 子组件(ChildModal.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="closeModal">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  closeModal: {
    type: Function,
    required: true
  }
})
</script>

场景2:父组件给子组件传递接口数据

实际开发中,父组件通常会请求接口,将接口返回的数据传递给子组件展示,这是最常见的场景之一。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 加载中状态 -->
    <div v-if="loading">加载中...</div>
    <!-- 接口数据请求成功后,传递给子组件 -->
    <ChildList :list="goodsList" v-else />
  </div>
</template>

<script setup>
import ChildList from './ChildList.vue'
import { ref, onMounted } from 'vue'

const goodsList = ref([])
const loading = ref(false)

// 父组件请求接口
onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch('https://api.example.com/goods')
    const data = await res.json()
    goodsList.value = data.list // 接口返回的列表数据
  } catch (err) {
    console.error('接口请求失败:', err)
  } finally {
    loading.value = false
  }
})
</script>

场景3:子组件复用,父组件传递不同配置

子组件复用是组件化开发的核心优势,通过父传子传递不同的配置,让同一个子组件实现不同的展示效果。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 同一个子组件,传递不同配置,展示不同效果 -->
    <Button 
      :text="按钮1"
      :type="primary"
      :disabled="false"
    />
    <Button 
      :text="按钮2"
      :type="default"
      :disabled="true"
    />
  </div>
</template>

<script setup>
import Button from './Button.vue'
</script>
<!-- 子组件(Button.vue) -->
<template>
  <button 
    class="custom-btn"
    :class="type === 'primary' ? 'btn-primary' : 'btn-default'"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
    required: true
  },
  type: {
    type: String,
    default: 'default',
    validator: (val) => {
      return ['primary', 'default', 'danger'].includes(val)
    }
  },
  disabled: {
    type: Boolean,
    default: false
  }
})
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写父传子时,会遇到「props 接收不到数据」「修改 props 报错」「方法传递后无法调用」等问题,以下是最常见的 5 个坑点,帮你快速避坑。

坑点1:父组件传递数据时,忘记加冒号(:)

错误写法:<Child name="parentName"></Child>(没有冒号,传递的是字符串 "parentName",而非父组件的 parentName 变量);

正确写法:<Child :name="parentName"></Child>(加冒号,传递的是父组件的变量)。

坑点2:子组件直接修改 props 数据

错误写法:props.name = '李四'(直接修改 props,会报错);

正确写法:通过父传子的方法,通知父组件修改原始数据(参考「传递方法」章节),或通过 computed 转换数据(不修改原始 props)。

坑点3:数组/对象 props 的默认值未用函数返回

错误写法:list: { type: Array, default: [] }(直接写数组,会导致多个子组件复用同一个数组,数据污染);

正确写法:list: { type: Array, default: () => [] }(用函数返回数组,每个子组件都会得到一个新的空数组)。

坑点4:传递方法时,父组件带了括号

错误写法:<Child :addCount="handleAddCount()"></Child>(方法立即执行,传递的是方法的返回值,而非方法本身);

正确写法:<Child :addCount="handleAddCount"></Child>(不带括号,传递方法本身)。

坑点5:props 命名大小写不一致

错误写法:父组件 :userInfo="parentObj",子组件接收 userinfo(小写 i);

正确写法:父组件用 kebab-case(:user-info),子组件用 camelCase(userInfo),或保持大小写一致(不推荐)。

六、总结:父传子核心要点回顾

Vue3 父传子的核心就是「Props 传值 + 单向数据流」,记住以下 4 个核心要点,就能应对所有父传子场景:

  1. 基础流程:父组件 :属性名="数据" 绑定 → 子组件 defineProps 接收 → 子组件使用数据;
  2. 核心规范:Props 是只读的,子组件不能直接修改,修改需通过父传子的方法通知父组件;
  3. 进阶技巧:props 校验提升可靠性,computed 转换数据,v-bind 批量传值,传递方法实现双向交互;
  4. 避坑关键:加冒号传递变量、不直接修改 props、数组/对象默认值用函数返回、传递方法不带括号。

父传子是 Vue3 组件通信中最基础、最常用的方式,掌握它之后,再学习子传父、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

i18n Ally Next:重新定义 VS Code 国际化开发体验

作者 Lyda
2026年2月14日 12:38

生成关于 i18n Ally Next 的图片.png

做国际化(i18n)是前端开发中最「看起来简单、做起来要命」的事情之一。翻译文件散落各处,键名拼写错误要到运行时才发现,新增语言要手动逐个补全,源语言改了但翻译没跟上……如果你也深有同感,这篇文章就是写给你的。

为什么要做 i18n Ally Next?

i18n Ally 是 VS Code 生态中最受欢迎的国际化插件之一,由 @antfu 创建。它提供了内联注解、翻译管理、文案提取等一系列强大功能,极大地提升了 i18n 开发体验。

但随着时间推移,原项目的维护逐渐放缓。社区积累了大量 Issue 和 PR 未被处理,一些现代框架(如 Next-intl、Svelte 5)的支持也迟迟没有跟上。更关键的是——原项目没有文档站点,所有使用说明散落在 README 和 Wiki 中,新用户上手成本很高。

i18n Ally Next 正是在这样的背景下诞生的——我们 fork 了原项目,在保留所有经典功能的基础上,持续修复 Bug、新增特性、改善性能,并且从零搭建了完整的文档体系

为什么文档这么重要?

原版 i18n Ally 功能强大,但有一个致命问题:你不知道它能做什么

很多开发者安装后只用到了内联注解这一个功能,对审阅系统、自定义框架、正则匹配、路径匹配等高级功能一无所知。这不是用户的问题,是文档缺失的问题。

i18n Ally Next 的文档站点从以下几个维度重新组织了内容:

🧱 结构化的指南体系

  • 快速开始 — 从安装到看到第一个内联注解,5 分钟搞定
  • 支持的框架 — 完整列出 25+ 支持的框架及其自动检测机制
  • 语言文件格式 — JSON、YAML、JSON5、PO、Properties、FTL……每种格式的用法和注意事项
  • 命名空间 — 大型项目必备的翻译文件组织方式
  • 文案提取 — 从硬编码字符串到 i18n 键的完整工作流
  • 审阅系统 — 团队协作翻译的质量保障
  • 机器翻译 — 8 种翻译引擎的配置与对比

🏗️ 框架最佳实践

不同框架的 i18n 方案差异很大。我们为每个主流框架编写了专属的最佳实践指南:

📋 完整的配置参考

每一个配置项都有:类型、默认值、使用场景说明和代码示例。不再需要猜测某个配置是干什么的。

新增功能详解

🤖 Editor LLM:零配置 AI 翻译

这是 i18n Ally Next 最具创新性的功能之一。它直接调用你编辑器内置的大语言模型进行翻译——无需 API Key,无需额外配置

{ "i18n-ally-next.translate.engines": ["editor-llm"] }

它会自动检测你的编辑器环境:

  • VS Code — 调用 GitHub Copilot
  • Cursor — 调用 Cursor 内置模型
  • Windsurf — 调用 Windsurf 内置模型

更强大的是,Editor LLM 支持批量翻译。当你选择翻译多个键时,它会将同一语言对的翻译请求合并为一次 API 调用,按 JSON 格式批量处理,大幅提升翻译速度并降低 token 消耗。

你也可以指定模型:

{ "i18n-ally-next.translate.editor-llm.model": "gpt-4o" }

🦙 Ollama:完全离线的本地翻译

对于有数据安全要求的团队,Ollama 引擎让你可以使用本地部署的大模型进行翻译,数据完全不出本机

{
  "i18n-ally-next.translate.engines": ["ollama"],
  "i18n-ally-next.translate.ollama.apiRoot": "http://localhost:11434",
  "i18n-ally-next.translate.ollama.model": "qwen2.5:latest"
}

通过 OpenAI 兼容 API 调用,支持任何 Ollama 上可用的模型。翻译 prompt 经过专门优化,能正确保留 {0}{name}{{variable}} 等占位符。

🔌 8 种翻译引擎全覆盖

引擎 特点 适用场景
Google 免费、语言覆盖广 日常开发
Google CN 国内可直接访问 国内开发者
DeepL 翻译质量最佳 面向用户的正式翻译
OpenAI 灵活、可自定义 API 地址 需要高质量 + 自定义
Ollama 完全离线、数据安全 企业内网环境
Editor LLM 零配置、批量翻译 快速迭代
百度翻译 国内 API、中文优化 中文项目
LibreTranslate 开源自托管 完全自主可控

引擎可以配置多个作为 fallback:

{ "i18n-ally-next.translate.engines": ["editor-llm", "deepl", "google"] }

🕵️ 陈旧翻译检测

这是一个容易被忽视但极其重要的功能。当源语言的文案发生变更时,其他语言的翻译可能已经过时了——但你不会收到任何提醒。

i18n Ally Next 的陈旧翻译检测(Stale Translation Check)解决了这个问题:

  1. 插件会记录每个键的源语言快照
  2. 当你运行检测命令时,它会对比快照与当前值
  3. 发现变更后,你可以选择:
    • 全部重新翻译 — 一键将所有过期翻译发送到翻译引擎
    • 逐个确认 — 逐条查看变更内容,决定是否重新翻译
    • 仅更新快照 — 确认当前翻译仍然有效,更新基准

这意味着你的翻译永远不会「悄悄过期」。

🔍 全项目扫描与批量提取

单文件的硬编码检测很有用,但真正的 i18n 迁移需要全项目级别的能力。

「扫描并提取全部」命令可以:

  1. 扫描项目中所有支持的文件(可通过 glob 配置范围)
  2. 检测每个文件中的硬编码字符串
  3. 显示扫描结果摘要(N 个文件,M 个硬编码字符串)
  4. 确认后自动批量提取,为每个字符串生成键名并写入 locale 文件
{
  "i18n-ally-next.extract.scanningInclude": ["src/**/*.{ts,tsx,vue}"],
  "i18n-ally-next.extract.scanningIgnore": ["src/generated/**"],
  "i18n-ally-next.extract.keygenStrategy": "slug",
  "i18n-ally-next.extract.keygenStyle": "camelCase"
}

📝 审阅系统(v2的功能)

翻译不是一个人的事。i18n Ally Next 内置了审阅系统(Review System),支持:

  • 逐条审阅翻译结果,标记为「通过」或「需修改」
  • 留下评论,与团队成员异步协作
  • 审阅数据以 JSON 存储在项目中,可纳入版本控制
  • 翻译结果可先保存为候选翻译translate.saveAsCandidates),审阅通过后再正式写入

这意味着翻译质量不再是黑盒——每一条翻译都有迹可循。

自定义框架:支持任意 i18n 方案

这是 i18n Ally Next 最灵活的功能之一。无论你使用什么 i18n 库,甚至是团队自研的方案,都可以通过一个 YAML 配置文件让插件完美支持。

为什么需要自定义框架?

内置框架覆盖了 25+ 主流方案,但现实中总有例外:

  • 公司内部封装的 i18n 工具函数
  • 使用了非标准的翻译函数名(如 __(), lang(), msg()
  • 新兴框架尚未被内置支持
  • 需要同时匹配多种调用模式

如何配置?

在项目根目录创建 .vscode/i18n-ally-next-custom-framework.yml

# 指定生效的文件类型
languageIds:
  - typescript
  - typescriptreact
  - vue

# 正则匹配翻译函数调用,{key} 是占位符
usageMatchRegex:
  - "\\Wt\\(\\s*['\"`]({key})['\"`]"
  - "\\W\\$t\\(\\s*['\"`]({key})['\"`]"
  - "\\Wi18n\\.t\\(\\s*['\"`]({key})['\"`]"

# 提取重构模板,$1 代表键名
refactorTemplates:
  - "t('$1')"
  - "{t('$1')}"

# 命名空间支持
namespace: true
namespaceDelimiter: ":"

# 作用域范围检测(如 React useTranslation hook)
scopeRangeRegex: "useTranslation\\(['\"](.+?)['\"]\\)"

# 是否禁用所有内置框架
monopoly: false

作用域范围是什么?

scopeRangeRegex 是一个非常实用的功能。以 React 为例:

const { t } = useTranslation("settings")

t("title")        // → 自动解析为 "settings.title"
t("theme.dark")   // → 自动解析为 "settings.theme.dark"

插件会根据正则匹配的结果自动划分作用域范围——从匹配位置到下一个匹配位置(或文件末尾)。在作用域内的所有 t() 调用都会自动加上命名空间前缀。

热重载

修改 YAML 配置文件后无需重启 VS Code,插件会自动检测文件变更并重新加载。这让调试正则表达式变得非常方便——改完立刻看效果。

快速上手

安装

在 VS Code 扩展面板搜索 i18n Ally Next,或从以下渠道安装:

最小配置

对于大多数项目,你只需要两步:

1. 指定语言文件路径

// .vscode/settings.json
{
  "i18n-ally-next.localesPaths": ["src/locales"],
  "i18n-ally-next.sourceLanguage": "zh-CN"
}

2. 打开项目,开始使用

插件会自动检测你的 i18n 框架(Vue I18n、react-i18next 等),无需额外配置。

打开任意包含翻译键的文件,你会看到:

  • 🏷️ 翻译键旁边出现内联注解,直接显示翻译值
  • 🌐 悬停键名可查看所有语言的翻译
  • ✏️ 点击即可编辑翻译
  • 📊 侧边栏显示翻译进度和缺失项

从 i18n Ally 迁移

如果你正在使用原版 i18n Ally,迁移非常简单:

  1. 卸载 i18n Ally
  2. 安装 i18n Ally Next
  3. settings.json 中的 i18n-ally. 前缀替换为 i18n-ally-next.

所有配置项保持兼容,无需其他改动。

写在最后

国际化不应该是痛苦的。i18n Ally Next 的目标是让 i18n 成为开发流程中自然而然的一部分——写代码时看到翻译,提交前检查缺失,源文案变更时自动提醒,协作时有据可查。

我们不只是在做一个插件,更是在构建一套完整的 i18n 开发工具链:从文档到配置,从检测到提取,从翻译到审阅,每一个环节都有对应的解决方案。

如果你觉得这个项目有用,欢迎:

  • ⭐ 在 GitHub 上给我们一个 Star
  • 🐛 提交 Issue 反馈问题
  • 💬 分享给你的团队和朋友
  • 📖 阅读完整文档开始使用

本文同步发布于 i18n Ally Next 官方文档

uni-swipe-action 从编辑页返回后滑动按钮仍显示的问题

作者 陆枫Larry
2026年2月14日 11:59

一、问题描述

在 uni-app 二手车小程序首页,车辆列表使用了 uni-swipe-action 组件实现左滑展示「删除」和「编辑」按钮。用户操作流程如下:

  1. 左滑某条车辆,露出「删除」「编辑」按钮
  2. 点击「编辑」,跳转到编辑页面
  3. 保存成功,返回首页

Bug 表现:返回首页后,之前滑开的那条车辆仍然处于展开状态,「删除」「编辑」按钮依然可见,而不是像首次进入页面那样只显示列表本身。

期望行为:从编辑页返回首页时,应重置为初始状态,只看到车辆列表,看不到操作按钮。


二、原因分析

2.1 页面生命周期与状态保持

在 uni-app 中,使用 uni.navigateTo 跳转到编辑页时,首页并不会被销毁,而是被压入页面栈并进入「隐藏」状态。当用户从编辑页返回时,首页会触发 onShow,从隐藏恢复为显示。

问题在于:Vue 组件及其内部状态都被保留,包括 uni-swipe-action-item 的展开/收起状态。因此之前滑开的项会保持展开状态。

2.2 原有关闭逻辑的不足

原先的实现思路是:通过 ref 拿到每个 uni-swipe-action-item,调用其 close() 方法关闭:

const closeAllSwipeActions = () => {
  if (swipeItems.value && swipeItems.value.length) {
    swipeItems.value.forEach(item => {
      if (item && typeof item.close === 'function') {
        item.close()
      }
    })
  }
}

但在 @dcloudio/uni-ui 的 swipe-action 实现中:

  • 在微信小程序、H5 等平台,滑动状态由 wxs/renderjs 控制,内部使用 is_show 状态
  • close() 主要供部分非 H5/微信平台使用
  • 官方推荐的关闭方式是调用父组件 uni-swipe-actioncloseAll() 方法,由父组件统一遍历子组件并设置 vm.is_show = 'none'

因此,直接对子项调用 close() 在上述平台上可能无法正确关闭。

2.3 ref 使用方式的影响

原先把 ref="swipeItems" 写在 v-foruni-swipe-action-item 上,Vue 3 中会得到子组件数组。不同平台下子组件的 API 不完全一致,直接遍历子项调用 close() 的可靠性和兼容性较差。


三、解决方案

3.1 使用父组件的 closeAll API

ref 放在父组件 uni-swipe-action 上,并调用其 closeAll() 方法:

<uni-swipe-action ref="swipeActionRef">
  <uni-swipe-action-item
    v-for="car in carList"
    :key="car._id"
    :right-options="getSwipeOptions(car)"
    @click="onSwipeAction($event, car)"
  >
    <!-- 内容 -->
  </uni-swipe-action-item>
</uni-swipe-action>
const swipeActionRef = ref(null)

const closeAllSwipeActions = () => {
  const swipeAction = swipeActionRef.value
  if (swipeAction && typeof swipeAction.closeAll === 'function') {
    swipeAction.closeAll()
  }
}

3.2 在 onShow 中配合 nextTick 调用

从编辑页返回时,onShow 会触发,但此时 DOM 可能尚未完全恢复。使用 nextTick 确保在下次 DOM 更新后再关闭:

onShow(() => {
  nextTick(() => {
    closeAllSwipeActions()
  })
  getCarList()
})

3.3 跳转前主动关闭(可选)

在点击「编辑」跳转前,也调用一次 closeAllSwipeActions(),避免带着展开状态离开页面,有利于状态更一致。


四、知识点小结

要点 说明
页面栈 navigateTo 跳转时原页面不销毁,返回时只是 onShow,组件状态保留
uni-swipe-action 关闭 应通过父组件 uni-swipe-actioncloseAll() 关闭,而非遍历子项调用 close()
ref 使用 父组件统一管理子项状态时,优先对父组件加 ref,调用其对外 API
nextTick 页面恢复显示时,用 nextTick 等 DOM 更新后再操作组件,避免时序问题

五、参考资料

  • uni-swipe-action 插件文档
  • uni-ui 源码:node_modules/@dcloudio/uni-ui/lib/uni-swipe-action/uni-swipe-action.vue
  • uni-ui 源码:node_modules/@dcloudio/uni-ui/lib/uni-swipe-action-item/mpwxs.js

gsap 配置解读 --4

作者 大时光
2026年2月14日 11:51

morphSVG 是什么

 <div class="card">
      <h1>案例 22:MorphSVG 形状变形</h1>
      <p>把一个 SVG 形状平滑变形为另一个。</p>
      <svg viewBox="0 0 200 200">
        <path
          id="shape"
          class="shape"
          d="M40 100 C40 40, 160 40, 160 100 C160 160, 40 160, 40 100 Z"
        />
        <path
          id="target"
          d="M100 20 L180 180 L20 180 Z"
          fill="none"
          stroke="none"
        />
      </svg>
      <button id="play">开始变形</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/MorphSVGPlugin.min.js"></script>
    <script>
      const shape = document.querySelector("#shape");
      const target = document.querySelector("#target");
      const playButton = document.querySelector("#play");

      // 注册 MorphSVGPlugin
      gsap.registerPlugin(MorphSVGPlugin);

      const tween = gsap.to(shape, {
        duration: 1.4,
        ease: "power2.inOut",
        morphSVG: target,
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

morphSVGGSAP(GreenSock Animation Platform) 动画库中的一个高级插件(MorphSVGPlugin),专门用于实现 SVG 路径(<path>)之间的平滑形状变形动画


📌 简单定义:

morphSVG 可以让一个 SVG 形状(如圆形、心形、自定义路径)流畅地“变形”为另一个完全不同的 SVG 形状,即使它们的点数、结构完全不同。


✅ 核心能力:

  1. 自动路径匹配
    即使两个 <path>d 属性(路径数据)包含不同数量的锚点或命令(比如一个有 4 个点,另一个有 20 个点),MorphSVGPlugin智能地插入/删除点,使变形过程平滑自然。

  2. 无需手动对齐
    传统变形需要手动确保路径点数一致,而 morphSVG 自动处理这些复杂细节。

  3. 支持多种目标格式

    • 另一个 <path> 元素(如你的代码中的 #target
    • SVG 路径字符串(如 "M10,10 L50,50 ..."
    • 基本 SVG 形状(如 "circle", "rect" —— 插件会自动转换为等效路径)
  4. 高性能 & 精确
    基于数学算法优化,确保变形流畅且视觉准确。


gsap.to(shape, {
  duration: 1.4,
  ease: "power2.inOut",
  morphSVG: target, // 把 #shape 变形成 #target 的形状
  paused: true
});
  • 起始形状#shape 是一个椭圆/胶囊形(用两个三次贝塞尔曲线构成的闭合路径)。
  • 目标形状#target 是一个三角形M100 20 L180 180 L20 180 Z)。
  • 点击按钮后,椭圆会平滑地变成三角形,中间经过自然的过渡形态。

💡 注意:#target 设置了 fill="none" stroke="none",因为它只是作为“目标形状数据”存在,不需要显示出来。


⚠️ 重要前提:

  • morphSVG 只能作用于 <path> 元素
    如果你有 <circle><rect> 等,GSAP 会自动将其转换为等效的 <path>(需插件支持)。
  • 需要加载 MorphSVGPlugin(如你代码中已引入)。
  • GSAP 3 中该插件属于会员功能(免费版不能用于商业项目,但可试用)。

🌟 应用场景:

  • Logo 动画变形(如从图标变文字)
  • 加载动画(形状循环变化)
  • 数据可视化(图表类型切换)
  • 创意交互动画(按钮 hover 变形、图标切换等)

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

morphSVG 是 GSAP 中实现“SVG 形状魔法变形”的利器——只需一行代码,就能让任意两个 SVG 形状之间产生电影级的流畅过渡效果,极大简化了复杂矢量动画的开发。

什么是 Observer

 <div class="card">
      <h1>案例 23:Observer 监听输入</h1>
      <p>滚轮或触摸滑动时移动条形。</p>
      <div class="stage">
        <div class="bar" id="bar"></div>
      </div>
      <div class="hint">在页面上滚动鼠标或触摸滑动</div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Observer.min.js"></script>
    <script>
      const bar = document.querySelector("#bar");
      const limit = 380;
      let position = 0;

      // 注册 Observer 插件
      gsap.registerPlugin(Observer);

      Observer.create({
        target: window,
        type: "wheel,touch,pointer",
        onChange: (event) => {
          position += event.deltaX + event.deltaY;
          position = gsap.utils.clamp(-limit, limit, position);
          gsap.to(bar, { x: position, duration: 0.3, ease: "power2.out" });
        }
      });
    </script>

ObserverGSAP(GreenSock Animation Platform) 提供的一个实用插件(Observer plugin),用于统一监听和处理多种用户输入事件,如:

  • 鼠标滚轮(wheel
  • 触摸滑动(touch
  • 指针设备移动(pointer,包括鼠标、触控笔、触摸屏等)

它的核心目标是:跨设备、跨浏览器地标准化输入行为,让你用一套简洁的 API 响应各种交互,而无需手动处理不同事件的兼容性问题。


📌 简单定义:

Observer 是一个“输入事件聚合器”,它把滚轮、触摸、指针等操作统一转化为带有 deltaX / deltaY 的标准化数据,方便你驱动动画或逻辑。


✅ 核心特性:

  1. 多输入类型支持
    通过 type: "wheel,touch,pointer" 一行代码同时监听多种交互方式,适配桌面(滚轮)和移动端(滑动)。

  2. 标准化增量值(delta)

    • event.deltaX:水平方向的滚动/滑动量(向右为正)
    • event.deltaY:垂直方向的滚动/滑动量(向下为正)
      不同设备/浏览器的原始事件(如 wheel.deltaYtouchmove 位移)会被自动归一化,数值更一致。
  3. 防抖与性能优化
    内置节流(throttle)机制,避免高频事件导致性能问题。

  4. 灵活的目标绑定
    可监听 windowdocument 或任意 DOM 元素。

  5. 额外控制选项

    • preventDefault: 是否阻止默认滚动行为
    • tolerance: 触发阈值(避免误触)
    • dragMinimum: 最小拖拽距离

Observer.create({
  target: window,                     // 监听整个窗口
  type: "wheel,touch,pointer",        // 同时响应滚轮、触摸、指针
  onChange: (event) => {
    position += event.deltaX + event.deltaY; // 累加水平+垂直移动量
    position = gsap.utils.clamp(-limit, limit, position); // 限制范围 [-380, 380]
    gsap.to(bar, { x: position, ... });       // 驱动条形移动
  }
});
  • 当你在页面上滚动鼠标滚轮在手机上左右/上下滑动时,
  • Observer 会捕获这些动作,并计算出 deltaX(水平)和 deltaY(垂直),
  • 代码将两者相加(实现“任意方向滑动都影响条形位置”),
  • 然后用 GSAP 动画平滑更新 .barx 位置。

💡 注意:这里把 deltaX + deltaY 合并使用,意味着上下滚轮也会移动条形(通常只用 deltaX 做横向拖拽)。这是一种简化设计,实际项目中可能只取某一方向。


🌟 典型应用场景:

场景 说明
自定义滚动容器 禁用原生滚动,用 Observer 驱动内容平移
视差/交互动画 滚动时触发元素位移、缩放、透明度变化
移动端拖拽交互 实现可拖拽的时间轴、图片对比滑块等
游戏控制 用滚轮/触摸控制角色移动或视角

⚠️ 注意事项:

  • Observer 不会阻止浏览器默认行为(如页面滚动),除非设置 preventDefault: true
  • 如果只想监听特定方向,建议分开处理 deltaXdeltaY,避免干扰。
  • 在触摸设备上,若需精确拖拽,通常配合 Draggable 插件更合适;Observer 更适合“感应式”输入(如滚动触发)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

Observer 是 GSAP 中用于“感知用户输入”的瑞士军刀——它抹平了滚轮、触摸、指针设备的差异,提供统一、流畅、高性能的交互数据,是构建现代交互动画不可或缺的工具。

什么是physics2D

<div class="card">
      <h1>案例 24:Physics2D 抛物线</h1>
      <p>模拟速度与重力的抛物线。</p>
      <div class="stage">
        <div class="ball" id="ball"></div>
      </div>
      <button id="play">发射小球</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Physics2DPlugin.min.js"></script>
    <script>
      const ball = document.querySelector("#ball");
      const playButton = document.querySelector("#play");

      // 注册 Physics2DPlugin
      gsap.registerPlugin(Physics2DPlugin);

      const tween = gsap.to(ball, {
        duration: 1.6,
        physics2D: {
          velocity: 420,
          angle: 60,
          gravity: 600
        },
        paused: true,
        onComplete: () => {
          gsap.set(ball, { clearProps: "all" });
        }
      });

      playButton.addEventListener("click", () => {
        gsap.set(ball, { x: 0, y: 0 });
        tween.restart();
      });
    </script>

Physics2DGSAP(GreenSock Animation Platform) 提供的一个专用插件(Physics2DPlugin),用于模拟二维空间中的基础物理运动,比如抛物线轨迹、弹道、重力下落等效果。


📌 简单定义:

Physics2D 插件让你只需指定初始速度(velocity)、发射角度(angle)和重力(gravity),就能自动计算并驱动元素沿真实的物理抛物线运动。

它把复杂的物理公式(如匀加速运动、矢量分解)封装成简单的配置项,无需手动编写运动方程。


✅ 核心参数说明:

physics2D: {
  velocity: 420,   // 初始速度(单位:像素/秒)
  angle: 60,       // 发射角度(单位:度,0°=向右,90°=向上)
  gravity: 600     // 重力加速度(单位:像素/秒²,方向向下)
}

GSAP 会自动将速度分解为水平(x)和垂直(y)分量:

  • vx = velocity * cos(angle)
  • vy = velocity * sin(angle)

然后根据物理公式逐帧更新位置:

  • x(t) = vx * t
  • y(t) = vy * t + 0.5 * gravity * t²

从而生成自然的抛物线轨迹


🔧 案例中发生了什么?

  1. 点击“发射小球”按钮;
  2. 小球被重置到起点(x: 0, y: 0);
  3. GSAP 启动 Physics2D 动画:
    • 420 像素/秒 的初速度,
    • 60° 角(斜向上)发射,
    • 受到 600 像素/秒² 的重力影响(向下加速);
  4. 小球沿一条先上升后下降的抛物线飞行;
  5. 动画结束后,clearProps: "all" 清除内联样式,便于重复播放。

🌟 典型应用场景:

场景 说明
游戏开发 子弹、炮弹、跳跃角色的轨迹
交互动效 点击时元素“弹出”并落回(如点赞动画)
数据可视化 模拟粒子运动、物理演示
趣味 UI 抛物线菜单、飞入购物车的商品

⚠️ 注意事项:

  • Physics2D 只控制 xy 属性(即 transform: translate(x, y)),不影响旋转、缩放等。
  • 不处理碰撞检测(如撞墙反弹),如需复杂物理,应使用专业引擎(如 Matter.js、Box2D)。
  • 所有单位基于CSS 像素,数值需根据视觉效果调整(例如 gravity: 600 比真实地球重力大很多,只为视觉流畅)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

Physics2D 是 GSAP 中实现“真实感抛物线运动”的快捷方式——你只需提供初速度、角度和重力,它就能自动生成符合牛顿力学的平滑轨迹,让网页动画更具物理直觉和趣味性。

PhysicsProps是什么

 <div class="card">
      <h1>案例 25:PhysicsProps 物理属性</h1>
      <p>同时给 x / y 设置速度与加速度。</p>
      <div class="stage">
        <div class="square" id="square"></div>
      </div>
      <button id="play">启动物理运动</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/PhysicsPropsPlugin.min.js"></script>
    <script>
      const square = document.querySelector("#square");
      const playButton = document.querySelector("#play");

      // 注册 PhysicsPropsPlugin
      gsap.registerPlugin(PhysicsPropsPlugin);

      const tween = gsap.to(square, {
        duration: 1.6,
        physicsProps: {
          x: { velocity: 380, acceleration: -100 },
          y: { velocity: -420, acceleration: 600 }
        },
        paused: true,
        onComplete: () => {
          gsap.set(square, { clearProps: "all" });
        }
      });

      playButton.addEventListener("click", () => {
        gsap.set(square, { x: 0, y: 0 });
        tween.restart();
      });
    </script>

PhysicsPropsGSAP(GreenSock Animation Platform) 提供的一个高级插件(PhysicsPropsPlugin),用于为任意 CSS 属性(如 xyrotationscale 等)模拟基于“初速度”和“加速度”的物理运动

Physics2D(仅限二维抛物线)不同,PhysicsProps灵活、通用——你可以分别控制每个属性的物理行为。


📌 简单定义:

PhysicsProps 允许你为元素的任意可动画属性设置初始速度(velocity)和恒定加速度(acceleration),GSAP 会根据物理公式自动计算其随时间变化的值。

它本质上实现了:
位移 = 初速度 × 时间 + ½ × 加速度 × 时间²
(即经典运动学公式 s=v0t+12at2s = v_0 t + \frac{1}{2} a t^2


✅ 核心特点:

  • 按属性独立控制:可以只给 x 加速度,y 只有速度,rotation 单独减速等。
  • 支持任意数值型属性:不仅限于位置,还可用于 opacityscalebackgroundColor(需配合其他插件)等。
  • 精确物理模拟:基于真实时间(秒)和像素/单位/秒² 的加速度模型。

🔧 在你的代码中:

physicsProps: {
  x: { velocity: 380, acceleration: -100 },
  y: { velocity: -420, acceleration: 600 }
}

这意味着:

方向 初速度(velocity) 加速度(acceleration) 物理含义
x(水平) +380 px/s(向右) -100 px/s²(向左减速) 小方块先快速右移,但逐渐慢下来,甚至可能反向
y(垂直) -420 px/s(向上) +600 px/s²(向下加速) 小方块先快速上冲,然后被“重力”拉回并加速下落

💡 注意:y 轴在网页中是向下为正,所以 velocity: -420 表示向上运动,而 acceleration: 600 表示向下加速(类似重力)。

这实际上也形成了一个抛物线轨迹,但比 Physics2D 更自由——你可以让 x 减速、y 加速,甚至让 rotation 也参与物理运动。


🆚 PhysicsProps vs Physics2D

特性 Physics2D PhysicsProps
用途 专为 2D 抛物线设计 通用物理属性模拟
输入方式 velocity, angle, gravity 每个属性单独设 velocityacceleration
灵活性 低(固定模式) 高(任意组合)
适用属性 xy 任何数值型属性(x, y, rotation, scaleX...)

✅ 简单抛物线 → 用 Physics2D
✅ 复杂多属性物理效果 → 用 PhysicsProps


🌟 应用场景举例:

  • 弹跳方块y 有负初速度 + 正重力加速度,配合 onComplete 实现多次弹跳
  • 旋转减速rotation: { velocity: 720, acceleration: -300 }(转两圈后慢慢停下)
  • 淡入淡出惯性opacity: { velocity: 1, acceleration: -0.5 }
  • 组合动画:位置抛物线 + 自身旋转减速

⚠️ 注意事项:

  • duration 仍然有效,动画会在指定时间内结束(即使物理上还没“停”)。
  • 如果希望无限物理模拟(如持续受力),应使用 gsap.ticker 手动更新,而非 to() 动画。
  • Physics2D 一样,不处理碰撞或边界反弹,需自行添加逻辑。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

PhysicsProps 是 GSAP 中实现“精细化物理动画”的利器——它让你像写物理题一样,为每个属性设定初速度和加速度,从而创造出更真实、更动态的交互动效。

什么是PixiPlugin

   <div class="card">
      <h1>案例 26:PixiPlugin + PixiJS</h1>
      <p>用 GSAP 控制 PixiJS 图形。</p>
      <div id="canvas-wrapper"></div>
      <button id="play">播放动画</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/pixi.js@7.4.0/dist/pixi.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/PixiPlugin.min.js"></script>
    <script>
      const wrapper = document.querySelector("#canvas-wrapper");
      const playButton = document.querySelector("#play");

      // 创建 Pixi 应用
      const app = new PIXI.Application({
        width: 520,
        height: 220,
        backgroundColor: 0x0f172a
      });
      wrapper.appendChild(app.view);

      // 创建一个圆形图形
      const circle = new PIXI.Graphics();
      circle.beginFill(0xf97316).drawCircle(0, 0, 30).endFill();
      circle.x = 80;
      circle.y = 110;
      app.stage.addChild(circle);

      // 注册 PixiPlugin
      gsap.registerPlugin(PixiPlugin);

      const tween = gsap.to(circle, {
        duration: 1.6,
        pixi: {
          x: 440,
          y: 110,
          rotation: Math.PI * 2,
          scale: 1.3
        },
        ease: "power2.inOut",
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

PixiPluginGSAP(GreenSock Animation Platform) 提供的一个专用插件,用于无缝、高效地对 PixiJS 创建的图形对象(如 SpriteGraphicsContainer 等)进行动画控制


📌 简单定义:

PixiPlugin 让你像操作普通 DOM 元素一样,用 GSAP 的简洁语法直接动画 PixiJS 对象的属性(如位置、旋转、缩放、颜色等),而无需手动更新渲染或处理底层细节。


✅ 为什么需要它?

PixiJS 是一个基于 WebGL 的高性能 2D 渲染引擎,常用于游戏、交互式图形和复杂动画。但它的对象属性(如 x, y, rotation, scale不是普通的 CSS 属性,而是 JavaScript 对象的数值字段。

如果没有 PixiPlugin,你虽然也能用 GSAP 动画这些值,但:

  • 无法自动触发 Pixi 的渲染更新;
  • 不能直接使用某些 Pixi 特有属性(如 tint 颜色、anchor);
  • 缩放(scale)是 { x, y } 对象,不能直接写 scale: 1.5

PixiPlugin 解决了这些问题!


🔧 核心功能:

gsap.to(circle, {
  duration: 1.6,
  pixi: {
    x: 440,
    y: 110,
    rotation: Math.PI * 2, // 旋转 360°
    scale: 1.3             // 自动作用于 circle.scale.x 和 .y
  },
  ease: "power2.inOut",
  paused: true
});
PixiPlugin 做了什么?
属性 普通方式问题 PixiPlugin 如何处理
x, y 可以直接设,但需确保在渲染循环中生效 自动集成,高效更新
rotation 单位是弧度,需手动计算 直接接受弧度(也可配置角度)
scale {x: 1, y: 1} 对象,不能直接赋值数字 scale: 1.3 → 自动设为 scale.x = scale.y = 1.3
tint 颜色是十六进制数字(如 0xff0000 支持字符串 "red""#ff0000" 自动转数字
anchor, pivot 同样是 {x, y} 对象 支持简写(如 anchor: 0.5

此外,PixiPlugin 还会:

  • 自动与 Pixi 的渲染循环协同,确保动画流畅;
  • 优化性能,避免不必要的计算;
  • 支持 GSAP 所有高级功能:时间轴、缓动、重复、回调等。

🌟 典型可动画的 Pixi 属性(通过 pixi: {}):

pixi: {
  x: 100,
  y: 200,
  rotation: Math.PI / 2,
  scaleX: 1.5,
  scaleY: 0.8,
  scale: 1.2,        // 同时设 scaleX 和 scaleY
  anchor: 0.5,       // 或 { x: 0.5, y: 0.5 }
  pivot: { x: 10, y: 10 },
  tint: "#ff5500",   // 自动转为 0xff5500
  alpha: 0.7         // 注意:alpha 不需要 pixi 前缀,但也可用
}

💡 注意:像 alphavisible 等通用属性,即使不写在 pixi 对象里,GSAP 也能直接动画。但强烈建议把 Pixi 专属属性放在 pixi: {},以获得最佳兼容性和功能支持。


🆚 对比:不用插件 vs 用 PixiPlugin

不用插件(繁琐且易错)

gsap.to(circle.scale, { x: 1.3, y: 1.3, duration: 1.6 });
gsap.to(circle, { x: 440, y: 110, rotation: Math.PI * 2, duration: 1.6 });
// tint 颜色还得自己转换...

PixiPlugin(简洁直观)

gsap.to(circle, {
  pixi: { x: 440, y: 110, rotation: Math.PI * 2, scale: 1.3, tint: "orange" }
});

⚠️ 使用前提:

  1. 已引入 PixiJS(如 pixi.min.js
  2. 已引入 GSAPPixiPlugin
  3. 调用 gsap.registerPlugin(PixiPlugin)

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

PixiPlugin 是 GSAP 与 PixiJS 之间的“翻译官”和“加速器”——它让你用最简洁的代码,高效、准确地驱动高性能 WebGL 图形的复杂动画,是开发 PixiJS 交互动画的必备工具。

什么是ScrambleText

<div class="card">
      <h1>案例 27:ScrambleText 文字扰动</h1>
      <p>让文字从乱码过渡到目标内容。</p>
      <div class="text" id="text">GSAP 很好用</div>
      <button id="play">开始扰动</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrambleTextPlugin.min.js"></script>
    <script>
      const text = document.querySelector("#text");
      const playButton = document.querySelector("#play");

      // 注册 ScrambleTextPlugin
      gsap.registerPlugin(ScrambleTextPlugin);

      const tween = gsap.to(text, {
        duration: 1.2,
        scrambleText: {
          text: "ScrambleText 很炫酷",
          chars: "上下左右123456",
          revealDelay: 0.2
        },
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

ScrambleTextGSAP(GreenSock Animation Platform) 提供的一个趣味性插件(ScrambleTextPlugin),用于实现 “文字扰动”动画效果
即让一段文本从随机乱码字符开始,经过短暂的“闪烁/跳动”过程,最终平滑揭示出目标文字内容


📌 简单定义:

ScrambleText 会将元素的文本临时替换为指定的乱码字符(如符号、数字、自定义文字),然后逐字“解码”为目标文本,营造出黑客风、密码破解、数据加载等酷炫效果。


✅ 核心特性:

1. 乱码字符可自定义

通过 chars 参数指定用于扰动的字符集:

chars: "上下左右123456"  // 使用这些汉字和数字作为乱码
// 或内置预设:
// chars: "upperCase"    → A-Z
// chars: "lowerCase"    → a-z
// chars: "upperAndLowerCase" → A-Za-z
// chars: "all"          → 所有 ASCII 可见字符
2. 控制揭示节奏
  • revealDelay: 每个字符开始“解码”前的延迟(秒),制造逐字显现效果。
  • 整体动画时长由 duration 控制。
3. 保留原始样式

动画只改变文本内容,不会影响元素的 CSS 样式(字体、颜色、大小等)。

4. 支持中文、emoji 等 Unicode 字符

只要 chars 和目标文本使用合法 Unicode 字符,即可正常工作(如你的例子中使用中文)。


scrambleText: {
  text: "ScrambleText 很炫酷", // 最终显示的文字
  chars: "上下左右123456",     // 乱码阶段使用的字符
  revealDelay: 0.2            // 每个字延迟 0.2 秒开始揭示
}

动画过程:

  1. 初始文本 "GSAP 很好用" 被隐藏;
  2. 显示与目标文本相同长度的乱码(如 "上1下2左3右4");
  3. 从左到右(或按内部逻辑),每个字符在 0.2s 间隔后,从乱码“跳变”为目标字符;
  4. 最终完整显示 "ScrambleText 很炫酷"

💡 注意:乱码长度 = 目标文本长度。如果目标文本更长,会补乱码;更短则截断。


🌟 典型应用场景:

场景 示例
科技感 UI 登录界面密码验证、数据加载提示
标题动画 页面标题/LOGO 的入场效果
游戏反馈 得分变化、关卡名称切换
营销 H5 “揭晓答案”、“惊喜文案”展示

⚠️ 注意事项:

  • 只作用于纯文本内容,不能用于富文本(如包含 <span> 的 HTML)。
  • 如果目标文本包含 HTML 标签,会被当作普通字符处理(不推荐)。
  • 动画结束后,元素的 textContent 会被永久替换为目标文本。
  • GSAP 3 中该插件属于会员功能(免费版可用于学习/非商业项目)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrambleText 是 GSAP 中实现“文字解码”特效的魔法工具——只需一行配置,就能让静态文字变身科幻电影中的动态信息流,极大提升界面的趣味性和专业感。

什么是ScrollSmoother

<div class="wrapper" id="smooth-wrapper">
      <div class="content" id="smooth-content">
        <header>
          <h1>案例 28:ScrollSmoother 平滑滚动</h1>
          <p>滚动更柔和,体验更顺滑。</p>
        </header>
        <div class="section">
          <div class="card">第一屏内容</div>
        </div>
        <div class="section">
          <div class="card">第二屏内容</div>
        </div>
        <div class="section">
          <div class="card">第三屏内容</div>
        </div>
      </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollSmoother.min.js"></script>
    <script>
      // 注册插件
      gsap.registerPlugin(ScrollTrigger, ScrollSmoother);

      // 创建平滑滚动
      ScrollSmoother.create({
        wrapper: "#smooth-wrapper",
        content: "#smooth-content",
        smooth: 1.2
      });
    </script>

ScrollSmootherGSAP(GreenSock Animation Platform) 提供的一个高级插件(属于 ScrollSmoother 模块),用于实现 “平滑滚动”(Smooth Scrolling) 效果——即当你用鼠标滚轮或触摸板滚动页面时,内容不是“一格一格”跳动,而是像被“缓冲”一样流畅、柔顺地滑动,极大提升用户体验。


📌 简单定义:

ScrollSmoother 通过拦截原生滚动事件,用 GSAP 驱动页面内容以缓动动画的方式移动,从而实现电影级的丝滑滚动体验。

它常用于高端网站、作品集、品牌官网等追求精致交互的场景。


✅ 核心原理:

  1. 结构要求

    • 一个外层容器(wrapper):设置 overflow: hidden,作为“视窗”。
    • 一个内层内容(content):高度由实际内容决定,被 GSAP 控制垂直位移。
  2. 工作方式

    • 用户滚动时,不直接滚动页面,而是记录滚动意图(delta)。
    • GSAP 用 requestAnimationFrame 和缓动函数(如 power3.out逐步更新 contenty 位置
    • 视觉上形成“惯性滚动”或“阻尼滚动”效果。
  3. 与 ScrollTrigger 无缝集成

    • 所有基于滚动的动画(如元素淡入、视差)仍能正常工作。
    • ScrollTrigger 会自动识别 ScrollSmoother,使用其内部的虚拟滚动位置。

ScrollSmoother.create({
  wrapper: "#smooth-wrapper",   // 外层容器(固定高度,隐藏溢出)
  content: "#smooth-content",   // 内层可滚动内容
  smooth: 1.2                   // 平滑系数(值越大越“慢”,默认 1)
});
  • 页面结构被包裹在 .wrapper 中;
  • 实际内容放在 .content 里;
  • 滚动时,.content 会被 GSAP 以 ease-out 方式平滑移动;
  • smooth: 1.2 表示轻微增强平滑感(数值通常在 0.5 ~ 2 之间)。

💡 注意:你不需要写任何 CSS 动画或监听 wheel 事件——ScrollSmoother 全自动处理!


🌟 主要优势:

特性 说明
极致流畅 消除原生滚动的卡顿感,尤其在 macOS 触控板上效果显著
保留原生行为 地址栏、锚点链接、键盘导航(↑↓ PgUp/PgDn)依然有效
兼容 ScrollTrigger 所有 GSAP 滚动触发动画无需修改即可运行
支持方向控制 可限制仅垂直/水平平滑,或完全禁用某方向
移动端优化 在 iOS/Android 上自动降级为原生滚动(避免性能问题)

⚙️ 常用配置选项:

ScrollSmoother.create({
  wrapper: "#wrapper",
  content: "#content",
  smooth: 1,               // 平滑系数
  effects: true,           // 启用高级效果(如视差需此开启)
  smoothTouch: 0.1,        // 移动端平滑度(0 = 关闭)
  normalizeScroll: true,   // 防止浏览器地址栏收起导致的跳动
  ignoreMobileResize: true // 避免 iOS 键盘弹出时布局错乱
});

⚠️ 注意事项:

  • 必须正确设置 HTML/CSS 结构
    #smooth-wrapper {
      overflow: hidden;
      position: fixed;
      top: 0; left: 0;
      width: 100%;
      height: 100%;
    }
    
  • 不适合所有网站:内容极少或需要快速滚动的页面(如文档、博客列表)可能反而降低效率。
  • 性能敏感:确保内容不包含大量未优化的动画或重绘元素。
  • SEO 友好:因为只是视觉平滑,不影响 HTML 结构,对 SEO 无影响。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrollSmoother 是 GSAP 打造高端网页体验的“秘密武器”——它用一行代码将生硬的页面滚动升级为丝滑流畅的交互动画,同时完美兼容 ScrollTrigger 和原生浏览器行为,是现代创意网站的标配工具。

在行情面板中加入 K 线:一次结构升级的实现过程

作者 瞌睡不醒
2026年2月14日 11:34

在行情面板中加入 K 线:一次结构升级的实现过程

系列文章 · Demo#2

在上一篇《用 Ticker API 写一个行情面板:一次完整的实现过程》中,我用 REST Ticker + 定时刷新,完成了一个可以长期运行的行情展示面板。

那个版本解决的是"看一眼实时行情"的问题:无论是美股、港股,还是外汇、指数,只要通过统一的行情 API 拉取数据,就可以稳定展示当前价格、涨跌幅与波动区间。

但当这个面板真正跑起来之后,我很快意识到——它只能告诉我"现在",却无法回答"它是怎么走到这里的"。

对于一个真正可用的行情系统来说,无论是做股票分析、量化研究,还是单纯查看历史走势,K 线都是不可或缺的一部分。

这篇文章,就是在原有实时行情面板的基础上,加入 K 线数据,完成一次结构升级。


一、让列表给图表让位

Demo#1 上线后,我盯着那个行情列表看了很久。

它可以稳定展示多市场实时行情数据,包括美股、港股、外汇等不同品种。但每次我点开某个品种,都会下意识地想知道:

它这几天是怎么涨上来的?

于是我决定在原有结构上做一次升级,而不是新开一个页面。

我没有做路由跳转,而是选择让列表"让位"------点击某一行后,左侧收缩成产品列表,右侧展开 K 线详情。

行情列表+K线 .png


二、我只是想把图画出来

一开始目标很简单:

把蜡烛图显示出来。

我直接去接 /kline 接口,然后丢进图表里。

真实接入行情 API 后,第一个报错是:

map is not a function

我才回去认真看了一遍官方文档

接口实际上是:

  • /kline ------ 历史 K 线数据\
  • /kline/latest ------ 当前周期实时更新

真正数组在 data.klines 里。

那一刻我意识到:

永远不要假设接口结构。


三、图表开始"反抗"我

排查后我发现:

  • API 返回倒序数据\
  • 数值是字符串\
  • 图表只接受最小结构

K 线转换逻辑大概是这样的:

const rawKlines = result.data.klines
const klineData = rawKlines.map(item => ({
    time: Math.floor(item.time / 1000),  // 毫秒转秒
    open: parseFloat(item.open),
    high: parseFloat(item.high),
    low: parseFloat(item.low),
    close: parseFloat(item.close)
}))

klineData.sort((a, b) => a.time - b.time)  // 升序排序

这一段看起来简单,但它让我第一次真正理解:

图表库并不是黑盒,它要求的是"结构正确的时间序列"。


四、当价格动而图不动时

/kline 只返回已经完成的周期。

当前正在形成的这一根,在历史数据里是不存在的。

于是逻辑变成:

// 加载历史数据
historyData = await fetchKLine(symbol, interval, 75)

// 获取最新K线
latestKline = await fetchLatestKLine(symbol, interval)

if latestKline exists:
    existingIndex = historyData.findIndex(item => item.time === latestKline.time)
    if existingIndex >= 0:
        historyData[existingIndex] = latestKline  // 更新现有K线
    else:
        historyData.push(latestKline)  // 添加新K线

从那一刻起,我开始理解:

行情系统的核心不是"画图",而是处理时间。实时行情是点,K 线是区间。


五、预加载策略的演进

最初的逻辑很简单:

chart.onScroll(() => {
    if scrollToLeft:
        fetchKLine(symbol, interval, 50)
})

能用,但滚动会卡顿。

后来我改成了"buffer 策略":

|------------------ 75 ------------------|
|------ 50 visible ------|-- 25 buffer --|

当可视区域消耗掉一半 buffer 时 → 触发加载

对应逻辑:

const KLINE_CONFIG = {
    initialLoad: 75,
    batchLoad: 25,
    triggerRatio: 0.5
}

// 首次加载
fetchKLine(symbol, interval, KLINE_CONFIG.initialLoad)

// 监听可见范围变化
onVisibleRangeChange():
    leftBufferCount = visibleRange.from
    minBufferCount = batchLoad * triggerRatio
    
    if leftBufferCount < minBufferCount:
        preloadHistoricalData(symbol, interval, KLINE_CONFIG.batchLoad)

这一刻我意识到:功能完成,不等于体验完成。


六、Resize 给我的提醒

有一次我关闭浏览器的开发者工具。

图表变宽了,但加载范围没变。

于是我开始用 ResizeObserver:

const resizeObserver = new ResizeObserver(entries => {
    if chartInstance && chartEl:
        // 保存当前可见范围
        currentRange = chartInstance.timeScale().getVisibleLogicalRange()
        
        // 更新图表尺寸
        chartInstance.applyOptions({
            width: chartEl.clientWidth,
            height: chartEl.clientHeight
        })
        
        // 恢复可见范围
        if currentRange:
            chartInstance.timeScale().setVisibleLogicalRange(currentRange)
            
            // 检查是否需要预加载
            loadMoreHistoricalData()
})

逻辑范围和视觉范围,是两回事。图变宽,单位时间内展示的 K 线数量会变化。

这一步让我第一次认真思考"视图驱动的数据加载"。


七、不是所有错误都值得被看到

我最终做了一个决定:

// 首次加载历史数据 - 失败显示错误
async function fetchKLine(symbol, interval, limit, startTime, endTime, silent = false):
    try:
        // 加载数据
        ...
    catch error:
        if not silent:
            klineErrorByKey[key] = error.message  // 显示错误
            updateKLineUI()
        else:
            console.warn('预加载失败(不影响显示)')  // 静默失败

// 预加载历史数据 - 使用静默模式
preloadHistoricalData(symbol, interval, count):
    await fetchKLine(symbol, interval, count, startTime, endTime, true)  // silent = true

// 获取最新K线 - 静默失败
fetchLatestKLine(symbol, interval):
    try:
        // 获取最新K线
        ...
    catch error:
        console.warn('获取最新K线失败(不影响显示)')
        return null
  • 首次加载是核心能力\
  • 预加载是增强能力\
  • latest 是增强能力

增强层失败,不应该影响主图。


八、它已经不像一个 Demo

当我开始处理这些边界时,我突然意识到:

我已经不再只是实现一个功能。

在 Demo #1 里,我解决的是实时行情快照。

在 Demo #2 里,我开始处理历史行情与时间结构。

但它还不够。

因为 K 线依然是静止的。

ezgif-71293c90c1d84a80.gif

这就是现在的 Demo #2。

下一步,它应该动起来了。


源码与示例

完整Demo代码已开源:

👉 github.com/TickDB/tick…

实战A2UI:从JSON到像素——深入Lit渲染引擎

2026年2月14日 11:25

在之前《A2UI初探:为什么前端不再需要手写UI代码了?》和《A2UI二番战:AI生成UI的“三权分立”与生态融合》这两篇文章我们介绍了A2UI的基本原理和特征,现在,是时候卷起袖子进行实践了。

image.png 本文将带你进行一次深度实战之旅,依托高考 信息查询的业务场景(本合集中之前AI Agent开发案例),演练通过A2UI实现动态渲染的具体做法,并深入探讨其背后的安全架构与前沿实践

我们将以流行的Lit库作为实现载体,因为它轻量、高性能且基于Web Components,与A2UI的组件化理念高度契合。

这个A2UI系列文章,是组内前端大叔研究梳理,完整介绍了A2UI在AI应用中出现的逻辑和对应场景,它的设计理念、有了A2UI以后对前端工程师的影响,以及它与Vibe Coding的区别、与AG-UI的区别、与开发框架的关系等,最后分享一个真实的案例验证过程。

全景应用示例:基于高考信息查询Agent实战



1、业务背景说明

在我们正式开始介绍之前,先回顾下本公众号“AI Agent开发”的文章合集,针对高考信息查询这个业务需求开发Agent的过程做了详细介绍,本文在此基础上,来进行我们的实战练习。

原有“高考信息查询Agent”支持考生或者家长通过自然语言输入,查询历年高考信息,含考生人数、录取人数、复读人数、普通高校数量、本科录取人数、专科录取人数等。

我们现在引入CopilotKit框架升级该Agent,并搭配相应前端组件,根据用户输入的不同意图,由“高考信息查询Agent”返回相应结果,实现动态渲染。

2、实现流程描述

技术栈协同工作流:

image.png 1. 用户:在Web应用中对AI说:“历年高考信息分析”。 2. 前端 (使用CopilotKit React SDK):捕获用户输入,通过 AG-UI协议 发送 user.action 事件到后端。 3. 后端AI Agent (使用CopilotKit Backend SDK):

    • 接收到请求。
    • 通过NL2SQL将自然语言转成SQL,通过SQL查询数据。
    • 通过 MCP协议 调用“高考信息数据库查询工具”,获取历年高考信息。
    • 将数据发送给专业的“数据分析Agent”(A2A协议用于Agent间协作)。
    • 收到分析结果(如“2025年高考考生报名人数,录取人数”)。
    • 决定生成一个包含年份、报名人数、查询详情的卡片列表界面。
    • 按照 A2UI规范 编排这个界面的JSON描述。
    • 通过 AG-UI协议,以 ui.delta 事件流的形式,将A2UI JSON 增量式地发回前端。

4. 前端 (CopilotKit内置A2UI渲染器):实时接收AG-UI事件流,解析其中的A2UI JSON,调用本地注册的企业级安全组件(如List、Card),将界面渲染出来。5. 用户:看到一个交互式卡片列表,可以点击卡片查看详情。

  • 让我们从输入“历年高考信息”开始

image.png

  • Agent返回历年高考信息卡片列表

image.png

  • 接口协议遵循“application/json+a2ui”规范,下面是接口返回的UI结构定义(surfaceUpdate)JSON
[    {        "id":"root-column",        "component":{            "Column":{                "children":{                    "explicitList":[                        "title-heading",                        "item-list"                    ]
                }
            }
        }
    },
    {
        "id":"title-heading",
        "component":{
            "Text":{
                "usageHint":"h1",
                "text":{
                    "path":"title"
                }
            }
        }
    },
    {
        "id":"item-list",
        "component":{
            "List":{
                "direction":"vertical",
                "children":{
                    "template":{
                        "componentId":"item-card-template",
                        "dataBinding":"/items"
                    }
                }
            }
        }
    },
    {
        "id":"item-card-template",
        "component":{
            "Card":{
                "child":"card-layout"
            }
        }
    },
    {
        "id":"card-layout",
        "component":{
            "Row":{
                "alignment":"center",
                "children":{
                    "explicitList":[
                        "template-year",
                        "template-count",
                        "template-query-button"
                    ]
                }
            }
        }
    },
    {
        "id":"template-year",
        "weight":1,
        "component":{
            "Text":{
                "usageHint":"h3",
                "text":{
                    "path":"year"
                }
            }
        }
    },
    {
        "id":"template-count",
        "weight":2,
        "component":{
            "Text":{
                "text":{
                    "path":"countText"
                }
            }
        }
    },
    {
        "id":"template-query-button",
        "weight":1,
        "component":{
            "Button":{
                "child":"btn-text",
                "primary":true,
                "action":{
                    "name":"query_year_detail",
                    "context":[
                        {
                            "key":"year",
                            "value":{
                                "path":"year"
                            }
                        }
                    ]
                }
            }
        }
    },
    {
        "id":"btn-text",
        "component":{
            "Text":{
                "text":{
                    "literalString":"查询详情"
                }
            }
        }
    }
]
  • 如果我们输入的是“2024年高考详细信息”Agent会返回如下渲染界面

image.png

  • 对应的A2UI协议JSON
[    {        "id":"detail-card",        "component":{            "Card":{                "child":"detail-column"            }        }    },    {        "id":"detail-column",        "component":{            "Column":{                "children":{                    "explicitList":[                        "detail-title",                        "div1",                        "candidates-info",                        "retake-info",                        "div2",                        "admission-total",                        "undergrad-info",                        "specialty-info",                        "div3",                        "uni-count"                    ]
                }
            }
        }
    },
    {
        "id":"detail-title",
        "component":{
            "Text":{
                "usageHint":"h2",
                "text":{
                    "path":"yearTitle"
                }
            }
        }
    },
    {
        "id":"candidates-info",
        "component":{
            "Text":{
                "text":{
                    "path":"candidatesCount"
                }
            }
        }
    },
    {
        "id":"retake-info",
        "component":{
            "Text":{
                "text":{
                    "path":"retakeCount"
                }
            }
        }
    },
    {
        "id":"admission-total",
        "component":{
            "Text":{
                "usageHint":"h4",
                "text":{
                    "path":"totalAdmission"
                }
            }
        }
    },
    {
        "id":"undergrad-info",
        "component":{
            "Text":{
                "text":{
                    "path":"undergradCount"
                }
            }
        }
    },
    {
        "id":"specialty-info",
        "component":{
            "Text":{
                "text":{
                    "path":"specialtyCount"
                }
            }
        }
    },
    {
        "id":"uni-count",
        "component":{
            "Text":{
                "usageHint":"caption",
                "text":{
                    "path":"universityCount"
                }
            }
        }
    },
    {
        "id":"div1",
        "component":{
            "Divider":{}
        }
    },
    {
        "id":"div2",
        "component":{
            "Divider":{}
        }
    },
    {
        "id":"div3",
        "component":{
            "Divider":{}
        }
    }
]

如果我们输入“历年高考人数变化情况”,LLM识别到用户更想了解高考人数的趋势变化,这时候可能会以折线图或者其他展示形式的组件去渲染,前提是我们的“可信组件库”已经实现了这些组件。

3、关键模式解析

1. literalString vs path:literalString代表静态文本,而path指向一个动态数据模型(如title),这是数据绑定的核心。2. 组件复合:title-heading、item-list、等布局组件通过explicitList管理子组件ID,形成清晰的层级。3. 根布局组件,组件类型:Column(垂直列布局)

{
    "id": "root-column",
    "component": {
        "Column": {
            "children": {
                "explicitList": [
                    "title-heading",
                    "item-list"
                ]
            }
        }
    }
}

4. 列表组件 - 核心部分

亮点设计:

  • 采用模板化渲染(template + dataBinding)
  • 垂直方向排列,适合移动端展示
{
    "id":"item-list",
    "component":{
        "List":{
            "direction":"vertical",
            "children":{
                "template":{
                    "componentId":"item-card-template",
                    "dataBinding":"/items"
                }
            }
        }
    }
}

5. 卡片模板架构

权重系统:通过weight属性控制各部分宽度比例

Card(卡片容器)
└── Row(水平布局)
    ├── 年份文本 (weight1)
    ├── 人数文本 (weight2)  
    └── 查询按钮 (weight1)

6. 按钮交互功能实现

交互流程:

  • 用户点击"查询详情"按钮
  • 触发query_year_detail动作
  • 携带年份参数(如"2024年")
{
  "action":{
    "name":"query_year_detail",
    "context":[
      {
        "key":"year",
        "value":{"path":"year"}
      }
    ]
}
}

7. 完整渲染流程

  • 初始化阶段:beginRendering创建画布

  • 结构定义:surfaceUpdate定义组件树

  • 数据注入:dataModelUpdate填充内容

  • 交互就绪:状态变为input-required

Lit渲染引擎实现:四步构建安全UI

第1步:定义与注册“可信组件白名单”(Catalog)

这是安全的第一道防线。我们不是渲染任意JSON,而是只渲染Catalog中注册过的组件类型。

// catalog.js - 我们的安全组件库
import { LitElement, html, css } from'lit';
import { repeat } from'lit/directives/repeat.js';

// 1. 基础安全按钮组件
exportclassSafeButtonextendsLitElement {
static properties = {
    label: { typeString },
    variant: { typeString },
    icon: { typeString }
  };

render() {
    return html`
      <buttonclass="btn btn-${this.variant}" @click=${this._handleClick}>
        ${this.icon ? html`<i class="icon-${this.icon}"></i>` : ''}
        ${this.label}
      </button>
    `;
  }

// 点击事件不直接执行,而是派发一个自定义事件
_handleClick() {
    this.dispatchEvent(newCustomEvent('a2ui-action', {
      bubblestrue,
      composedtrue,
      detail: { 
        type'BUTTON_CLICK',
        idthis.id// 组件ID来自A2UI描述
      }
    }));
  }

static styles = css`...`// 封装样式,防止污染
}
customElements.define('safe-button'SafeButton);

// 2. 绑定到数据模型的输入框组件
exportclassSafeTextFieldextendsLitElement {
static properties = {
    label: { typeString },
    value: { typeString },
    fieldPath: { typeString } // 对应A2UI中的 `path`
  };

constructor() {
    super();
    // 从全局状态管理器订阅数据变化
    window.A2UIState.subscribe(this.fieldPath(newVal) => {
      if (this.value !== newVal) {
        this.value = newVal;
      }
    });
  }

_onInput(e) {
    const newValue = e.target.value;
    // 派发事件,通知状态管理器更新数据
    this.dispatchEvent(newCustomEvent('a2ui-model-update', {
      detail: {
        paththis.fieldPath,
        value: newValue
      }
    }));
  }

render() {
    return html`
      <label>${this.label}
        <input
          type="text"
          .value=${this.value || ''}
          @input=${this._onInput}
        >
      </label>
    `;
  }
}
customElements.define('safe-text-field'SafeTextField);

// 3. Catalog 映射表
exportconstA2UI_CATALOG = {
'Button'(descriptor, id) => html`<safe-button.id=${id}.label=${descriptor.label}.variant=${descriptor.variant}></safe-button>`,
'TextField'(descriptor, id) => {
    // 安全属性提取:只允许已知属性
    const safeProps = {};
    const allowedProps = ['label''placeholder''textFieldType''width'];
    allowedProps.forEach(prop => {
      if (descriptor[prop] !== undefined) safeProps[prop] = descriptor[prop];
    });
    // 特别处理数据绑定路径
    if (descriptor.value && descriptor.value.path) {
      safeProps.fieldPath = descriptor.value.path;
    }
    return html`<safe-text-field.id=${id}...=${safeProps}></safe-text-field>`;
  },
'DataTable'(descriptor, id) => { /* 实现复杂表格 */ },
'Header'(descriptor, id) => { /* 实现标题 */ },
// ... 注册更多组件
};

第2步:实现A2UI解析与渲染引擎

这个引擎负责将A2UI JSON转化为Lit模板,并管理组件实例。

// a2ui-renderer.js
import { A2UI_CATALOG } from'./catalog.js';

exportclassA2UIRenderer {
constructor(rootElement) {
    this.root = rootElement;
    this.componentMap = newMap(); // id -> Lit TemplateResult
    this.dataModel = {}; // 维护当前数据状态
    this.subscriptions = newMap(); // 数据路径 -> 回调函数集合
  }

// 处理 surfaceUpdate 消息
applySurfaceUpdate(componentsDescriptorArray) {
    const updates = [];
    
    for (const desc of componentsDescriptorArray) {
      const { id, component } = desc;
      const componentType = Object.keys(component)[0];
      const componentConfig = component[componentType];
      
      if (!A2UI_CATALOG[componentType]) {
        console.warn(`[A2UI Security] 组件类型 "${componentType}" 不在白名单中,已跳过。`);
        continue; // 关键安全策略:忽略未注册组件
      }
      
      // 调用Catalog中的工厂函数,创建该组件的Lit模板
      const template = A2UI_CATALOG[componentType](componentConfig, id);
      this.componentMap.set(id, template);
      updates.push(id);
    }
    
    // 智能重渲染:找出受影响的根节点进行更新
    this._recomputeAndRender(updates);
  }

// 处理 dataModelUpdate 消息
applyDataModelUpdate(path, value) {
    // 使用JSON Patch风格路径,如 '/filters/searchKeyword'
    this._setDataByPath(this.dataModel, path, value);
    
    // 通知所有订阅了此路径的组件更新
    const callbacks = this.subscriptions.get(path) || [];
    callbacks.forEach(cb =>cb(value));
  }

// 辅助函数:根据路径设置对象值
_setDataByPath(obj, path, value) {
    const parts = path.split('/').filter(p => p);
    let current = obj;
    for (let i0; i < parts.length - 1; i++) {
      if (!current[parts[i]]) current[parts[i]] = {};
      current = current[parts[i]];
    }
    current[parts[parts.length - 1]] = value;
  }

// 订阅数据变化(供组件调用)
subscribe(path, callback) {
    if (!this.subscriptions.has(path)) {
      this.subscriptions.set(path, []);
    }
    this.subscriptions.get(path).push(callback);
    // 立即回调当前值
    const currentVal = this._getDataByPath(path);
    callback(currentVal);
  }

// 核心渲染函数(使用Lit的render函数)
_recomputeAndRender(updatedIds) {
    // 1. 找到需要更新的最顶层根ID(避免重复渲染子树)
    const rootsToUpdate = this._findRootComponentsToUpdate(updatedIds);
    
    // 2. 为每个需要更新的根节点重新渲染
    rootsToUpdate.forEach(rootId => {
      const rootTemplate = this.componentMap.get(rootId);
      if (rootTemplate) {
        const container = this.root.querySelector(`[data-a2ui-id="${rootId}"]`) || this._createContainer(rootId);
        // Lit的核心渲染API
        render(rootTemplate, container);
      }
    });
  }

// 根据A2UI的引用关系,计算依赖树,优化渲染范围
_findRootComponentsToUpdate(ids) {
    // 实现依赖分析算法,此处简化返回所有ID
    return ids;
  }
}

第3步:构建状态管理与事件总线

这是连接“静态UI”和“动态交互”的关键。

// state-event-bus.js
exportclassA2UIStateManager {
constructor() {
    this.state = {};
    this.eventHandlers = newMap(); // actionType -> handler function
  }

// 注册安全的动作处理器(由前端开发者控制)
registerActionHandler(actionType, handler) {
    // 关键:只有这里注册的action才能被A2UI事件触发
    this.eventHandlers.set(actionType, handler);
  }

// 处理来自UI组件的事件(如按钮点击、输入框变化)
asynchandleUIEvent(eventDetail) {
    const { action, payload } = eventDetail;
    
    if (!this.eventHandlers.has(action)) {
      console.error(`[A2UI Security] 未注册的动作类型: ${action}`);
      return;
    }

    const handler = this.eventHandlers.get(action);
    
    try {
      // 执行安全注册的处理器
      const result = awaithandler(payload);
      
      // 处理器可以返回新的A2UI指令或数据更新
      if (result && result.type === 'surfaceUpdate') {
        // 触发渲染器更新UI
        window.A2UIRenderer.applySurfaceUpdate(result.components);
      }
    } catch (error) {
      console.error(`[A2UI] 动作执行失败: ${action}`, error);
    }
  }
}

// 全局单例
window.A2UIState = newA2UIStateManager();

// 注册一些示例动作处理器
window.A2UIState.registerActionHandler('navigate'(payload) => {
console.log('导航到:', payload.route);
// 可以集成到你的路由系统,如React Router
});

window.A2UIState.registerActionHandler('updateFilter'(payload) => {
// 1. 更新本地状态
window.A2UIRenderer.applyDataModelUpdate(`/filters/${payload.field}`, payload.value);
// 2. 可选:触发后端API调用,获取新的筛选结果
fetchUsersWithFilters(window.A2UIRenderer.dataModel.filters);
});

第4步:集成与启动

最后,将所有部分连接起来。

<!DOCTYPE html>
<html>
<head>
<scripttype="module"src="./catalog.js"></script>
<scripttype="module"src="./a2ui-renderer.js"></script>
<scripttype="module"src="./state-event-bus.js"></script>
</head>
<body>
<divid="a2ui-root"></div>

<scripttype="module">
    import { A2UI_CATALOG } from'./catalog.js';
    import { A2UIRenderer } from'./a2ui-renderer.js';
    
    // 初始化
    const rootEl = document.getElementById('a2ui-root');
    window.A2UIRenderer = newA2UIRenderer(rootEl);
    
    // 模拟接收来自AI Agent(通过AG-UI)的A2UI消息
    asyncfunctionsimulateAIStream() {
      // 第一批:渲染骨架
      const skeletonUpdate = [...]; // 包含Container, Header, loading状态的JSON
      window.A2UIRenderer.applySurfaceUpdate(skeletonUpdate);
      
      // 模拟网络延迟
      awaitnewPromise(resolve =>setTimeout(resolve, 300));
      
      // 第二批:更新数据(例如用户数)
      window.A2UIRenderer.applyDataModelUpdate('/userCount', 42);
      
      // 第三批:渲染复杂的表格和筛选栏
      const mainContentUpdate = [...]; // 包含DataTable, FilterBar等的JSON
      window.A2UIRenderer.applySurfaceUpdate(mainContentUpdate);
      
      // 第四批:填充表格数据
      const userData = [...];
      window.A2UIRenderer.applyDataModelUpdate('/users/list', userData);
    }
    
    simulateAIStream();
</script>
</body>
</html>

安全措施再加固

除了可信组件白名单机制,还需要多层防护:

  • JSON Schema验证:在解析A2UI JSON之前,先用严格的JSON Schema验证其结构,过滤畸形数据。
  • 属性值净化:对于所有字符串类型的属性值(如label, placeholder),进行HTML实体编码,防止HTML/脚本注入。
  • 资源加载限制:对于image组件的src,限制只允许加载来自可信CDN或数据URI的图片。
  • 事件速率限制:防止恶意脚本通过快速触发onClick事件进行DDoS攻击。
  • CSP(内容安全策略)集成:即使组件被破坏,严格的CSP也能阻止任何脚本执行。

前端开发者的实践路线图

1. 入门(1-2周):

2. 进阶(1-2月):

    • 为你所在团队的设计系统,构建10-20个核心A2UI Catalog组件。
    • 集成到现有React/Vue应用中的一个非核心功能模块进行试点。

3. 精通(3-6月):

    • 实现高性能、支持SSR的A2UI渲染引擎。
    • 设计并实现一套完整的A2UI+AG-UI开发、调试、部署工作流。
    • 主导将某个核心业务流的UI生成迁移到A2UI模式。

4. 专家(长期):

    • 研究如何将A2UI与你的后端LLM编排框架深度集成。
    • 探索在AR/VR、车载系统等新兴平台上的A2UI渲染方案。
    • 为开源A2UI生态贡献核心特性或工具。

结语:从执行者到规则制定者

通过这次深度实战,我们清晰地看到,A2UI并非一个遥远的“黑盒”技术。它是一套清晰、可实现的规范与模式。前端开发者的角色,正从UI细节的“手工执行者”,转变为定义AI如何安全、高效构建UI的“规则制定者”和“系统架构师”。

我们不再仅仅关心“这个按钮的圆角是4px还是6px”,而是思考:

  • “如何设计一个SmartForm组件,能让AI根据数据模型自动生成最合适的表单布局?”
  • “如何建立一套事件处理机制,既能满足复杂交互,又绝无安全漏洞?”
  • “如何让同一份A2UI描述,在手机、桌面、大屏上都呈现最佳体验?”

这既是挑战,更是前所未有的机遇。 我们正在亲手编写人机交互新纪元的底层代码。当你理解了A2UI从JSON到像素的每一个字节的旅程,你便掌握了开启这扇未来之门的钥匙。

现在,代码世界静待你的重新定义。

本系列说明:完整介绍A2UI在AI应用中出现的逻辑和对应场景,它的设计理念、有了A2UI以后对前端工程师的影响,以及它与Vibe Coding的区别、与AG-UI的区别、与开发框架的关系等,最后分享一个真实的案例验证过程。

本文作者:一只大叔

本文原载:公众号“木昆子记录AI”

React 页面加载埋点的正确姿势:useEffect 与 document.readyState 完美配合

作者 an31742
2026年2月14日 11:21

React 页面加载埋点的正确姿势:useEffect 与 document.readyState 完美配合

前端埋点必看:告别“假PV”,确保用户真正看到页面再上报

在前端数据埋点体系中,页面 PV 上报是最基础也最关键的一环。我们追求的理想效果是:页面完全加载、用户真正看到完整内容时,只上报一次

但在 React 项目里,一个很容易踩的坑是:组件挂载时机 ≠ 页面完全加载时机。直接在 useEffect 里上报,大概率会出现“页面还没渲染完就埋点”的问题。

今天就从问题根源、原理到最佳实践,一次性讲透 React 页面加载埋点的标准方案。


一、埋点痛点:为什么 useEffect 直接上报不靠谱?

先看一段很多人写过的“错误代码”:

useEffect(() => {
  // ❌ 错误示范:组件挂载就上报
  dataReport('PageView', 'Load', 'HomePage');
  sessionStorage.setItem('REPORTED', 'true');
}, []);

这段代码逻辑上能跑,但埋点时机不准

  • React 执行 useEffect 时,DOM 可能刚构建完
  • 图片、样式、字体等静态资源还在加载
  • 用户看到的是不完整页面,此时上报不符合“真实可见”埋点原则

所以,我们不能只依赖 React 生命周期,必须结合浏览器原生加载状态一起判断。


二、前置知识:document.readyState 三个状态

浏览器提供了 document.readyState 用来精准描述页面加载状态,这是埋点的核心依据:

// 1. loading:HTML 还在解析,页面处于加载中
// 2. interactive:DOM 构建完成,但资源(图片/CSS/字体)可能未加载完
// 3. complete:页面 + 所有资源加载完成 ✅

我们要的就是 complete 状态——这才是用户真正看到完整页面的时刻。


三、正确方案:useEffect + readyState + load 事件

最佳实践思路:

  1. sessionStorage 做幂等,防止重复上报
  2. 先判断当前是否已经加载完成
  3. 已完成:直接上报
  4. 未完成:监听 load 事件,加载完再上报
  5. 组件卸载时清理监听,避免内存泄漏

标准工具化代码(可直接复制使用)

useEffect(() => {
  const hasReported = sessionStorage.getItem('PAGE_LOAD_REPORTED');

  // 已上报过,直接跳过
  if (hasReported) return;

  // 上报逻辑
  const reportPageView = () => {
    dataReport('PageView', 'Load', 'HomePage');
    sessionStorage.setItem('PAGE_LOAD_REPORTED', 'true');
  };

  // 情况1:页面已加载完成,立即上报
  if (document.readyState === 'complete') {
    reportPageView();
  } 
  // 情况2:页面还在加载,监听 load 事件
  else {
    window.addEventListener('load', reportPageView);
    // 清理监听
    return () => window.removeEventListener('load', reportPageView);
  }
}, []);

四、两种加载场景详解

场景1:页面加载极快

0ms:   页面开始加载
50ms:  HTML 解析完成
80ms:  所有资源加载完成(readyState = 'complete')
100ms: React 渲染,useEffect 执行
       ↓
       检测到页面已完成加载
       ↓
       直接上报 ✓

场景2:页面加载较慢(图片多/网络差)

0ms:   页面开始加载
50ms:  HTML 解析完成
80ms:  React 渲染,useEffect 执行(readyState = 'interactive')
       ↓
       未加载完成,注册 load 监听
500ms: 所有资源加载完成(readyState = 'complete')
       ↓
       load 事件触发,执行上报 ✓

这套逻辑能同时兼容快慢页面,保证时机绝对准确。


五、业务实战示例(带业务状态)

在真实项目中,埋点通常需要带上业务参数(如来源、当前页面信息、用户状态),这里给一个生产可用版本:

const HomePage = () => {
  const { isInZone, runningGame } = useGlobalState();

  useEffect(() => {
    const hasReported = sessionStorage.getItem('PAGE_LOAD_REPORTED');
    if (hasReported) return;

    const reportPageView = () => {
      dataReport('LZ_aiAgent', 'LZ_aiagentWindowShow', 'pv', {
        detail: JSON.stringify({
          source: isInZone ? 'app' : 'game',
          gameName: runningGame?.gameName || '',
          timestamp: Date.now()
        })
      });
      sessionStorage.setItem('PAGE_LOAD_REPORTED', 'true');
    };

    if (document.readyState === 'complete') {
      reportPageView();
    } else {
      window.addEventListener('load', reportPageView);
      return () => window.removeEventListener('load', reportPageView);
    }
  }, [isInZone, runningGame]);

  return <div>页面内容</div>;
};

六、高频 QA 避坑指南

Q1:为什么用 sessionStorage,不用 localStorage?

  • sessionStorage:关闭标签页自动清空,刷新页面可重新上报,符合 PV 统计逻辑
  • localStorage:永久存储,会导致二次进入页面不上报,不符合业务需求

Q2:会不会出现重复上报?

不会

  • if-else 互斥逻辑,只会走一条分支
  • sessionStorage 幂等标记,上报后直接拦截

Q3:为什么不直接用 window.onload?

window.onload 会被其他代码覆盖,事件覆盖会导致埋点丢失。 使用 addEventListener 是更安全、更工程化的方案。

Q4:Next.js 客户端组件要注意什么?

Next.js 服务端渲染时不存在 window 对象,必须确保:

  • 组件顶部加 'use client'
  • 所有浏览器相关逻辑(window/document)都写在 useEffect 内部

七、总结

React 页面加载埋点的正确四件套

  1. document.readyState 判断真实加载状态
  2. 结合 window.load 事件兼容慢加载场景
  3. sessionStorage 做幂等,防止重复上报
  4. 组件卸载时清理事件监听,避免内存泄漏

按照这套写法,无论普通 React 项目还是 Next.js 项目,都能做到:时机准、不重复、兼容强、无隐患

你在项目里遇到过哪些埋点坑?欢迎在评论区交流~


标签

React 前端埋点 数据上报 useEffect document.readyState 前端性能 Next.js


AI 时代掌握 Markdown,是最基础也最必要的技能 (小红书长文也可以用哦)

作者 threerocks
2026年2月14日 12:23

AI 时代一来,最常用、最通用、最省心的技能,是 Markdown

就那套:

# 是标题

- 是列表

三个反引号是代码块……

以前我把它当程序员小玩具,写 README 用的。

现在我写提示词写知识库写会议纪要写项目计划、甚至写小红书草稿都在用它

很多大模型默认吐出来的也是 Markdown

你不学也会天天用;你学了,就能把它用得更顺、更像人机协作的公共语言

这篇文章就把 AI 时代的 Markdown 技能讲清楚:

  • 为啥说 Markdown 是 AI 时代基础必修
  • 新手怎么用 30 分钟上手
  • 我踩过的坑
  • 直接给你几套能复制走就用的模板

AI 时代大家都在写『文档』

以前我们写作给人看的。

现在很多文字要同时两类读者看:

  • 人:扫一眼懂不懂、读起来顺不顺
  • AI:能不能切分、能不能检索、能不能执行、会不会误解

Markdown 的优势特别像夹在中间的翻译官

  • 对人:比 Word 清爽,写起来也不费手
  • 对模型:结构清晰、层级明确、token 还省(真的省)

一个很现实的事:会 Markdown,不代表你是技术人;但不会 Markdown,你在 AI 时代做事会莫名卡住

比如:

  • 你发提示词给模型,一大段纯文本没结构,模型抓不到重点
  • 你做个人知识库,内容堆在一起,RAG 切分一塌糊涂
  • 你写项目计划,别人看不懂,Agent 更执行不了

Markdown 其实就是把我要说的话变成可计算的文字

为什么它是新手最容易掌握的技能

你想学一个新技能,最怕两件事:

你掌握 10% 的语法,就能覆盖 90% 场景;你今天学,今天就能用在:提示词、笔记、文档、发文、写代码说明。

而且它是纯文本。

这意味着,不依赖软件、不依赖平台、不怕格式丢失、还能被 Git 管起来。

你换工具、换平台、换设备,Markdown 都能带走。

这在 AI 时代太重要了,因为你的内容会被喂给各种模型、各种工具链。

30 分钟上手法

我自己教朋友(完全小白那种)就用这四个:

1)标题:# 真的就够了

这是大标题

这是小标题

这是更小的标题

最常见的坑:# 后面要空一格。

写成 #标题 有的平台不认,真的会气到。

2)列表:用 -1.,全篇统一就行

无序列表

  • 这是一条

  • 这也是一条

  • 这也是一条

有序列表

  1. 这是一条

  2. 这也是一条

  3. 这也是一条

列表上下不空行,有的平台会在一起。你就当空行是免费的,随便用。

3)链接

这是链接文字

你以后写提示词、写文档、写小红书参考来源,这个非常好用。

4)代码块


npm i

新手最常见的痛苦:代码复制出来一坨。加上代码块,世界瞬间清净。

踩过的坑

坑 1:换行不是你以为的换行

Markdown 里回车一下不一定等于换行

很多渲染器会把同一段落里的换行当成空格。

解决办法就两个:

  • 真分段:中间空一行(部分渲染引擎支持)
  • 想强制换行:上一行行尾加 3 个空格再接回车一下即可,或者用 <br>

坑 2:你从 Markdown 复制到某些平台,会出现一堆 ###

比如你发到某些富文本编辑器(包括部分社媒的长文功能),它不完整支持 Markdown,就会把符号原样贴上去。

我的小技巧:

  • 在 VS Code 里(或者其他 Markdown 预览器)先打开预览(Mac:Cmd + Shift + V
  • 在预览里复制,再粘贴到平台

这个技巧我自己用来发公众号也挺稳。

坑 3:文件名有空格/中文,后面做知识库会很难受

我以前喜欢起名:“今天学 Markdown 好开心.md”

后来要做链接引用、做同步、做脚本处理,全变成麻烦。

解决办法:

  • kebab-caseai-markdown-guide.md
  • 或者加日期:2026-02-14-notes.md

中文当然也能用,只是后续工具链会更容易出小毛病(尤其跨平台)。

它不只是排版,它是结构化提示词的容器

我以前写提示词是:一大段话丢给模型,靠缘分。

后来我发现:你用 Markdown 把提示词分区,模型执行力会明显变好。

你可以直接复制这个模板:

你的角色

你是一个严谨但很会讲人话的编辑。

背景

我要写一篇小红书长文,主题是:掌握 Markdown 是 AI 时代的基础技能。

读者是完全新手。

输出要求

  • 口语化

  • 有真实使用场景

  • 不要“首先/其次/总结一下”那种模板腔

  • 给出可复制的 Markdown 示例

我已经有的素材

  • 我经常用 VS Code 写 Markdown

  • 我会把笔记喂给 AI 做总结

你需要交付

成稿 + 配图提示词

这玩意儿本质上就是,你用标题告诉模型这块是什么,减少它乱猜。

做个人知识库、做 RAG、做第二大脑

我以前记笔记是什么都往一个长文档里堆,然后想找东西就靠搜索,搜不到就崩溃。

后来我学会一个很简单的思路:

把一条知识写成一个小条目,用标题+短段落+列表。

你甚至可以给每条笔记加一个 YAML 头(有些工具会识别):


title: Markdown 换行规则

tags: [markdown, writing, ai]

created: 2026-02-14


结论

段落之间要空一行。需要强制换行就用行尾两个空格或 <br>

我踩过的坑

  • 直接回车在某些渲染器里不会换行

示例

第一行

第二行

这种结构对你也友好,对 AI 也友好,AI 检索的时候能更容易切到对的块,总结也更不容易跑偏

进阶一点点:表格、任务清单、引用

这几个我个人用得特别多,但我只在平台支持的时候用(比如 GitHub、Notion、一些博客系统)。

任务清单

  • 学会标题

  • 学会列表

  • 学会代码块

引用

这是一段引用。

我用它来放原文/结论/别人的观点。

表格

| 场景 | 用 Markdown 的原因 |

| --- | --- |

| 写提示词 | 结构清晰,模型更听话 |

| 写知识库 | 易切分,易检索 |

| 写文档 | 跨平台,不怕格式丢 |

写作这事,在 AI 时代更像你的生活感 + 结构能力的组合。

Markdown 负责结构,你负责生活感。

❌
❌