普通视图

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

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 负责结构,你负责生活感。

TinyEngine 2.10 版本发布:零代码 CRUD、云端协作,开发效率再升级!

2026年2月14日 11:31

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

前言

TinyEngine 是一款面向未来的低代码引擎底座,致力于为开发者提供高度可定制的技术基础设施——不仅支持可视化页面搭建等核心能力,更可通过 CLI 工程化方式实现深度二次开发,帮助团队快速构建专属的低代码平台。

无论是资源编排、服务端渲染、模型驱动应用,还是移动端、大屏端、复杂页面编排场景,TinyEngine 都能灵活适配,成为你构建低代码体系的坚实基石。

最近我们正式发布 TinyEngine v2.10 版本,带来多项功能升级与体验优化:模型驱动、登录鉴权、应用中心等全新特性,同时还有Schema面板与画布节点同步、出码源码即时预览、支持添加自定义 MCP 服务器等功能进行了增强,以让开发协作、页面搭建变得更简单、更高效。

版本特性总览

核心特性

  • 模型驱动:零代码创建 CRUD
  • 多租户与登录鉴权能力
  • 新增应用中心与模板中心

功能增强

  • 出码支持即时查看代码
  • 自定义 MCP 服务器,扩展 AI 助手能力
  • 画布与 Schema 面板支持同步滚动
  • 页面 Schema CSS 字段格式优化
  • 图表物料更新,组件属性配置平铺优化
  • 多项细节优化与 Bug 修复

体验升级

  • 新官网:UI 全面焕新
  • 新文档:域名迁移与样式升级
  • 新演练场:真实前后端,完整功能体验

新特性详解

1. 【核心特性】模型驱动:零代码极速创建CRUD页面(体验版本)

背景与问题

在传统的后台管理系统开发中,创建一个包含表单、表格和完整 CRUD(增删改查) 功能的页面往往需要开发者:

  • 重复配置相似的表单和表格组件
  • 手动绑定数据源、编写事件处理逻辑
  • 数据模型变更时,同步修改多个组件配置

这种重复性劳动不仅耗时,还容易出错。

核心功能

模型驱动特性通过声明式的数据模型配置,自动生成对应的 UI 组件和数据交互逻辑,实现真正的"零代码"生成数据管理页面。

核心模块

模块 功能
模型管理器插件 可视化创建数据模型、配置字段和 API,管理模型
内置模型组件 表单、表格、组合表单+表格,基于模型自动生成表单、表格,或组合生成完整 CRUD 页面
模型绑定配置器组件 为模型生成 UI、绑定 CRUD 逻辑

支持的模型字段类型:String(字符串)、Number(数字)、Boolean(布尔)、Date(日期)、Enum(枚举)、ModelRef(关联模型)

1.png

价值亮点

  • 开发效率大幅提升:通过配置模型即可生成完整的 CRUD 页面,无需手动配置每个组件
  • 后端自动生成:使用默认接口路径时,自动生成数据库表结构和 CRUD 接口
  • UI 与接口自动绑定:拖拽组件选择模型后,UI 自动生成,接口自动绑定,一站式完成前后端搭建
  • 支持嵌套模型:字段可关联其他模型,实现复杂数据结构(如用户关联部门)(后续实现)
  • 标准化输出:基于统一模型生成的 UI 组件保证了一致性
  • 灵活扩展:可自定义字段类型和组件映射

使用场景

  • 后台管理系统的数据管理页面
  • 需要频繁进行增删改查操作的业务场景
  • 需要快速原型的项目

快速上手

1. 创建数据模型

打开模型管理器插件,创建数据模型(如"用户信息"):

  • 配置模型基本信息:中文名称、英文名称、描述
  • 添加模型字段(如姓名、年龄、邮箱等)
  • 配置字段类型、默认值、是否必填等属性

2. 配置接口路径(可选)

创建模型时,可以选择:

  • 使用默认路径:系统自动使用后端模型接口作为基础路径,并在后端自动生成对应的 SQL 表结构和 CRUD 接口
  • 自定义路径:指定自己的接口基础路径,对接已有后端服务

3. 拖拽模型组件到页面

在物料面板中选择模型组件拖拽到画布:

  • 表格模型:生成数据列表
  • 表单模型:生成数据录入表单
  • 页面模型:生成包含搜索、表格、编辑弹窗的完整 CRUD 页面

4. 绑定模型,自动生成

选中组件后,在右侧属性面板:
1) 点击"绑定模型数据",选择刚才创建的模型
2) 系统自动生成 UI 界面
3) 系统自动绑定 CRUD 接口
4) 一站式完成前后端搭建

5. 预览页面

预览即可看到包含搜索、新增、编辑、删除、分页功能的完整数据管理页面。

2.gif

核心流程图

graph LR
    A[创建数据模型] --> B{选择接口路径}
    B -->|默认路径| C[后端自动生成<br/>SQL表结构+CRUD接口]
    B -->|自定义路径| D[对接已有后端]
    C --> E[拖拽模型组件到页面]
    D --> E
    E --> F[绑定模型]
    F --> G[系统自动生成UI]
    F --> H[系统自动绑定接口]
    G --> I[预览完整CRUD页面]
    H --> I

    style A fill:#e1f5fe
    style C fill:#fff3e0
    style G fill:#f3e5f5
    style H fill:#f3e5f5
    style I fill:#e8f5e9

用户只需关注

定义好数据模型,前后端自动生成:

  • ✅ 无需手动编写表单 HTML
  • ✅ 无需手动编写表格渲染逻辑
  • ✅ 无需手动编写 API 调用代码
  • ✅ 无需手动编写数据验证规则
  • ✅ 无需手动编写分页排序逻辑

让用户专注于业务逻辑和模型设计,而非重复的 UI 代码编写。

2. 【核心特性】多租户与登录鉴权能力

功能概述

TinyEngine v2.10 引入了完整的用户认证系统,支持用户登录、注册、密码找回,并结合多租户体系,让您的设计作品可以实现云端保存、多设备同步和团队协作。

登录注册

  • 用户登录:基于用户名/密码的身份认证,Token 自动管理
  • 用户注册:支持新用户注册,注册成功后提供账户恢复码用于找回密码
  • 密码找回:通过账户恢复码重置密码,无需邮件验证

3.png

组织管理

  • 多组织支持:用户可属于多个组织,灵活切换不同工作空间
  • 组织切换:动态切换组织上下文,组织间数据隔离
  • 创建组织:一键创建新组织,邀请团队成员加入

4.png

登录鉴权流程

系统采用 Token 认证机制,通过 HTTP 拦截器实现统一的请求处理和权限验证:

sequenceDiagram
    participant 用户
    participant 前端应用
    participant HTTP拦截器
    participant 后端API

    用户->>前端应用: 1. 输入用户名/密码登录
    前端应用->>后端API: 2. POST /platform-center/api/user/login
    后端API-->>前端应用: 3. 返回 Token
    前端应用->>前端应用: 4. 保存 Token 到 localStorage

    Note over 前端应用,后端API: 后续请求自动携带 Token

    前端应用->>HTTP拦截器: 5. 发起业务请求
    HTTP拦截器->>HTTP拦截器: 6. 检查 Token 并注入 Authorization 头
    HTTP拦截器->>后端API: 7. 携带 Token 的请求
    后端API-->>HTTP拦截器: 8. 返回数据 或 认证失败(401)

    alt 认证失败
        HTTP拦截器->>前端应用: 9. 清除 Token,显示登录弹窗
        前端应用->>用户: 10. 提示重新登录
    end

访问入口

1)登录界面:访问 TinyEngine 时系统会自动弹出登录窗口,未登录用户需完成登录或注册。

2)组织切换:登录后可通过以下方式切换组织:

  • 点击顶部工具栏的用户头像,选择「切换组织」
  • 在用户菜单中直接选择目标组织

3)退出/重新登录:已登录用户可以点击右上角头像在菜单点击"退出登录",进入登录页面重新登录

使用场景

1)个人使用:登录后即可享受云端保存、多设备同步等功能,设计作品永不丢失。

2)团队协作

  • 创建组织:为团队或项目创建独立组织空间
  • 数据隔离:不同组织的资源完全隔离,清晰区分个人与团队项目

💡 提示:新注册用户默认属于 public 公共组织,所有数据公共可见,您也可以创建自定义组织隔离数据。

开发者指南

1)环境配置

  • 开发环境:通过 pnpm dev:withAuth 命令启用登录认证,pnpm dev 默认不启用(mock server)
  • 生产环境:自动启用完整登录认证系统

也可以修改配置文件来启动或关闭登录鉴权:

export default {
  // enableLogin: true // 打开或关闭登录认证
}

2)多租户机制

  • 用户可属于多个组织,通过 URL 参数标识当前组织上下文
  • 组织间数据完全隔离,切换组织可查看不同资源
  • 当 URL 未携带应用 ID 或组织 ID 时,系统自动跳转到应用中心

3. 【核心特性】应用中心与模板中心

应用中心和模板中心是此次版本新增的两大核心功能模块。通过应用中心可以集中管理您创建的所有低代码应用,为不同的场景创建不同的应用;模板中心则让优秀页面设计得以沉淀为可复用资产,团队成员可以基于模板快速搭建新页面,大幅提升协作效率。

应用中心

登录后进入应用中心,集中管理您创建的所有低代码应用。

功能亮点

  • 统一管理:在一个界面查看、创建、打开所有应用
  • 快速切换:无需手动输入 URL,一键进入任意应用编辑器
  • 组织隔离:不同组织的应用数据隔离,清晰区分个人与团队项目

5.png

模板中心

模板中心让页面设计资产得以沉淀和复用,提升团队协作效率。

核心价值

  • 设计复用:保存优秀页面设计为模板,避免重复造轮子
  • 快速启动:基于模板创建新页面,继承已有布局和样式
  • 团队共享:组织内共享设计资产,统一 UI 风格和设计规范

6.png

7.png

访问入口

在编辑器中点击左上角菜单按钮,悬停即可看到应用中心模板中心入口,点击即可前往。

使用说明

自动跳转规则

  • 如果访问编辑器时未携带应用 ID 或组织 ID 参数,系统会自动跳转到应用中心
  • 您可以在应用中心创建新应用,或打开已有应用进入编辑器

组织权限说明

  • public 组织:默认公共组织,所有用户的应用对所有人可见
  • 自定义组织:用户新建的组织默认仅创建者可见,需手动邀请成员加入
  • 切换组织可以查看不同组织下的应用和资源

特性开关

如果不需要使用应用中心与模板中心,可以在注册表中进行关闭:

// registry.js
export default {
  [META_APP.AppCenter]: false, // 关闭应用中心
  [META_APP.TemplateCenter]: false // 关闭模板中心
  // ...
}

4. 【增强】出码即时预览 - 导出前预览所见即所得

出码功能新增源码预览能力,用户在导出代码前可以实时查看生成的源码内容,提升代码导出体验和准确性。

功能特性

  • 左右分栏布局:左侧树形文件列表,右侧 Monaco 代码编辑器预览
  • 智能初始化:打开对话框时自动显示当前编辑页面对应的文件代码
  • 实时预览:点击树形列表中的任意文件,即可在右侧预览其代码内容
  • 灵活选择:支持勾选需要导出的文件

使用方法

1) 在编辑器中点击「出码」按钮
2) 打开的弹窗中左侧树形列表显示所有可生成的文件,当前页面对应文件自动展示在右侧
3) 点击任意文件预览源码,勾选需要导出的文件
4) 点击「确定」选择保存目录完成导出

8.png

5. 【增强】自定义 MCP 服务器 - 扩展 AI 助手能力

之前版本中,TinyEngine已经提供内置MCP 服务,可以通过MCP工具让AI调用平台提供的各种能力。 本次特性是在TinyEngine 中支持添加自定义 MCP (Model Context Protocol) 服务器,可以通过配置轻松集成第三方 MCP 服务,扩展 AI 开发助手的工具能力。

功能特性

  • 灵活配置:通过注册表简单的配置即可添加自定义服务器
  • 协议支持:支持 SSE 和 StreamableHttp 两种传输协议
  • 服务管理:在 AI 插件的配置界面即可管理 MCP 服务器的开关状态
  • 工具控制:可查看并切换各个工具的启用状态

使用步骤

1) 准备您的 MCP 服务器(需符合 MCP 协议规范

2) 在项目的 registry.js 中添加配置

// 使用示例
// registry.js
export default {
  [META_APP.Robot]: {
    options: {
      mcpConfig: {
        mcpServers: {
          'my-custom-server': {
            type: 'SSE',              // 支持 'SSE' 或 'StreamableHttp'
            url: 'https://your-server.com/sse',
            name: '我的自定义服务器',
            description: '提供xxx功能的工具',
            icon: 'https://your-icon.png'  // 可选
          }
        }
      }
    }
  }
}

3) 刷新编辑器,在 AI 插件 MCP 管理面板中即可看到新添加的服务器

9.png

4) 启用服务器,选择需要的工具,即可在 AI 助手中开始使用!

场景示例

您可以集成企业内部 MCP 服务、社区 MCP 服务、第三方 MCP 工具等,扩展 AI 助手的业务能力。

例如,下面是一个添加图片搜索MCP服务后使用AI生成带图片页面的场景示例:

10.gif

6. 【增强】画布与 Schema 面板支持同步滚动

Schema 面板新增"跟随画布"功能,启用后当在画布中选中组件时,Schema 面板会自动滚动到选中组件的对应位置并高亮显示。

使用场景

  • 快速定位:当页面元素较多时,能快速找到对应组件的 Schema 配置
  • 双向对照:可视化视图与 JSON 代码视图对照,便于理解页面结构

使用方法

打开 Schema 面板,勾选面板标题栏的"跟随画布"复选框启用。在画布中点击切换元素,即可看到 Schema 面板跟随变化。

效果如下:

11.gif

7. 【优化】页面 Schema CSS 字段格式优化

页面 Schema 中的 CSS 样式字段由字符串格式优化为对象格式,提升样式配置的可读性和可维护性。系统会自动处理对象与字符串的相互转换,出码时自动转换为标准 CSS 字符串格式,同时完美兼容之前的字符串格式。

优化场景

  • AI场景更友好:AI生成代码及修改样式场景,能够更快速地进行增量生成及修改
  • 编辑更直观:对象格式支持属性智能提示和语法高亮,编辑体验更佳
  • 阅读更清晰:结构化的对象格式易于查看和修改样式属性
  • 维护更便捷:新增或修改样式规则时,无需手动拼接 CSS 字符串

格式对比

之前(字符串格式)

"css": ".page-base-style { padding: 24px; background: #FFFFFF; } .block-base-style { margin: 16px; } .component-base-style { margin: 8px; }"

现在(对象格式)

"css": {
  ".page-base-style": {
    "padding": "24px",
    "background": "#FFFFFF"
  },
  ".block-base-style": {
    "margin": "16px"
  },
  ".component-base-style": {
    "margin": "8px"
  }
}

兼容性说明

  • 两种格式完全兼容,可在同一项目中混用
  • 系统自动识别格式类型并进行转换
  • 出码时统一转换为标准 CSS 字符串格式
  • 页面样式设置等场景使用都与之前保持一致,不受该特性影响

8. 【增强】图表物料更新,组件属性优化

图表物料进行了如下优化:

  • 添加三种常用图表组件物料:仪表盘、拓扑图、进度图
  • 图表组件的配置面板优化,将原有的图标配置属性由整体 options 配置拆分为独立的属性配置项(颜色、数据、坐标轴等),使配置更加清晰直观。

12.png

9. 【新体验】新演练场 - 完整的前后端体验

演练场进行了全面升级,从原来的前端 Mock 数据改为完整的前后端部署,带来真实的体验环境。

升级亮点

  • 完整的前后端部署:不再是拦截接口 Mock 数据,而是真实的服务端环境
  • 支持用户登录:可以使用真实账户登录演练场
  • 数据隔离:用户数据基于租户进行共享或隔离,更符合实际使用场景
  • 功能完整体验:之前无法体验的功能现在都可以正常使用,如AI助手插件自然语言生成页面

新演练场地址playground.opentiny.design/tiny-engine…

13.png

通过下面两个入口都可以访问:

如您希望继续使用旧版演练场,依旧可以通过下面地址继续访问: 旧版演练场:opentiny.design/tiny-engine…

10. 【新体验】新官网 - UI 全面焕新

TinyEngine 官网首页 UI 全面焕新,带来更现代、更清爽的视觉体验。

  • 全新设计:首页内容刷新,并采用现代化的设计语言,视觉更加清爽美观
  • 响应式布局:完美适配各种屏幕尺寸,移动端访问更友好

访问新版官网:opentiny.design/tiny-engine

14.png

11.【新体验】新文档 - 全新文档体验

TinyEngine 文档与其他OpenTiny产品文档统一迁移至新docs子域名:

新域名docs.opentiny.design/tiny-engine…

文档变化:

  • 整体更统一,方便查找切换其他文档
  • 同时也进行了全面的样式优化,阅读体验更佳

15.png

12. 【其他】功能细节优化&bug修复

结语

回首这一年,TinyEngine 在开源社区的成长离不开每一位开发者和贡献者的支持。v2.10 版本作为春节前的最后一次发布,我们为大家带来了多项重磅特性:

特性 核心价值
模型驱动 零代码 CRUD,开发效率跃升
多租户与登录鉴权 云端协作、团队协作
应用中心与模板中心 应用管理、资产沉淀
出码预览 导出前预览,提升代码导出体验
自定义 MCP 扩展 AI 能力,集成企业服务
Schema 面板同步滚动 画布与代码视图联动
CSS 字段格式优化 对象格式,可读性更强
图表物料更新 配置平铺,更直观
新演练场 真实前后端,完整体验
新官网/文档 UI 焕新,体验升级

致谢

本次版本的开发和问题修复诚挚感谢各位贡献者的积极参与!同时邀请大家加入开源社区的建设,让 TinyEngine 在新的一年里成长得更加优秀和茁壮!

新春祝福

值此新春佳节即将到来之际,TinyEngine 团队衷心祝愿大家:

🧧 新年快乐,万事如意! 🧧

愿新的一年里:

  • 代码如诗行云流水
  • 项目如期顺利上线
  • Bug 远离,需求清晰
  • 团队协作高效顺畅
  • 事业蒸蒸日上,生活幸福美满!

🎊 春节快乐,阖家幸福! 🎊

让我们在春节后带着满满的热情和能量,继续在未来道路上探索前行!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyEngine源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyPro、TinyNG、TinyCLI、TinyEditor
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

Vue中默认插槽、具名插槽、作用域插槽如何区分与使用?

作者 kknone
2026年2月14日 11:10

一、插槽的基本概念

在Vue组件化开发中,插槽(Slot)是一种强大的内容分发机制,它允许父组件向子组件传递任意模板内容,让子组件的结构更加灵活和可复用。你可以把插槽想象成子组件中预留的“占位符”,父组件可以根据需要在这些占位符中填充不同的内容,就像给积木玩具替换不同的零件一样。

插槽的核心思想是组件的结构与内容分离:子组件负责定义整体结构和样式,父组件负责提供具体的内容。这种设计让组件能够适应更多不同的场景,同时保持代码的可维护性。

二、默认插槽:最简单的内容分发

2.1 什么是默认插槽

默认插槽是最基础的插槽类型,它没有具体的名称,父组件传递的所有未指定插槽名的内容都会被渲染到默认插槽的位置。

2.2 基础使用示例

子组件(FancyButton.vue)

<template>
  <button class="fancy-btn">
    <slot></slot> <!-- 插槽出口:父组件的内容将在这里渲染 -->
  </button>
</template>

<style scoped>
.fancy-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background-color: #42b983;
  color: white;
  cursor: pointer;
}
</style>

父组件使用

<template>
  <FancyButton>
    Click me! <!-- 插槽内容:将被渲染到子组件的slot位置 -->
  </FancyButton>
</template>

最终渲染出的HTML结构:

<button class="fancy-btn">Click me!</button>

2.3 为插槽设置默认内容

在父组件没有提供任何内容时,我们可以为插槽设置默认内容,确保组件在任何情况下都能正常显示。

子组件(SubmitButton.vue)

<template>
  <button type="submit" class="submit-btn">
    <slot>Submit</slot> <!-- 默认内容:当父组件没有传递内容时显示 -->
  </button>
</template>

父组件使用

<template>
  <!-- 不传递内容,显示默认的"Submit" -->
  <SubmitButton />
  
  <!-- 传递内容,覆盖默认值 -->
  <SubmitButton>Save Changes</SubmitButton>
</template>

三、具名插槽:精准控制内容位置

3.1 为什么需要具名插槽

当组件的结构比较复杂,包含多个需要自定义的区域时,默认插槽就不够用了。这时我们可以使用具名插槽,为每个插槽分配唯一的名称,让父组件能够精准地控制内容渲染到哪个位置。

3.2 基础使用示例

子组件(BaseLayout.vue)

<template>
  <div class="layout-container">
    <header class="layout-header">
      <slot name="header"></slot> <!-- 具名插槽:header -->
    </header>
    <main class="layout-main">
      <slot></slot> <!-- 默认插槽:未指定名称的内容将在这里渲染 -->
    </main>
    <footer class="layout-footer">
      <slot name="footer"></slot> <!-- 具名插槽:footer -->
    </footer>
  </div>
</template>

<style scoped>
.layout-container {
  max-width: 1200px;
  margin: 0 auto;
}
.layout-header {
  padding: 16px;
  border-bottom: 1px solid #eee;
}
.layout-main {
  padding: 24px;
}
.layout-footer {
  padding: 16px;
  border-top: 1px solid #eee;
  text-align: center;
}
</style>

父组件使用

<template>
  <BaseLayout>
    <!-- 使用#header简写指定内容渲染到header插槽 -->
    <template #header>
      <h1>我的博客</h1>
    </template>
    
    <!-- 未指定插槽名的内容将渲染到默认插槽 -->
    <article>
      <h2>Vue插槽详解</h2>
      <p>这是一篇关于Vue插槽的详细教程...</p>
    </article>
    
    <!-- 使用#footer简写指定内容渲染到footer插槽 -->
    <template #footer>
      <p>© 2025 我的博客 版权所有</p>
    </template>
  </BaseLayout>
</template>

3.3 动态插槽名

Vue还支持动态插槽名,你可以使用变

往期文章归档
免费好用的热门在线工具
量来动态指定要渲染的插槽:
<template>
  <BaseLayout>
    <template #[dynamicSlotName]>
      <p>动态插槽内容</p>
    </template>
  </BaseLayout>
</template>

<script setup>
import { ref } from 'vue'
const dynamicSlotName = ref('header') // 可以根据需要动态修改
</script>

四、作用域插槽:子组件向父组件传递数据

4.1 什么是作用域插槽

在之前的内容中,我们了解到插槽内容只能访问父组件的数据(遵循JavaScript的词法作用域规则)。但在某些场景下,我们希望插槽内容能够同时使用父组件和子组件的数据,这时就需要用到作用域插槽

作用域插槽允许子组件向插槽传递数据,父组件可以在插槽内容中访问这些数据。

4.2 基础使用示例

子组件(UserItem.vue)

<template>
  <div class="user-item">
    <!-- 向插槽传递user对象作为props -->
    <slot :user="user" :isAdmin="isAdmin"></slot>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const user = ref({
  name: '张三',
  age: 28,
  avatar: 'https://via.placeholder.com/60'
})
const isAdmin = ref(true)
</script>

父组件使用

<template>
  <!-- 使用v-slot指令接收插槽props -->
  <UserItem v-slot="slotProps">
    <img :src="slotProps.user.avatar" alt="用户头像" class="avatar">
    <div class="user-info">
      <h3>{{ slotProps.user.name }}</h3>
      <p>年龄:{{ slotProps.user.age }}</p>
      <span v-if="slotProps.isAdmin" class="admin-tag">管理员</span>
    </div>
  </UserItem>
</template>

4.3 解构插槽Props

为了让代码更简洁,我们可以使用ES6的解构语法直接提取插槽Props:

<template>
  <UserItem v-slot="{ user, isAdmin }">
    <img :src="user.avatar" alt="用户头像" class="avatar">
    <div class="user-info">
      <h3>{{ user.name }}</h3>
      <p>年龄:{{ user.age }}</p>
      <span v-if="isAdmin" class="admin-tag">管理员</span>
    </div>
  </UserItem>
</template>

4.4 具名作用域插槽

具名插槽也可以传递Props,父组件需要在对应的具名插槽上接收:

子组件

<template>
  <div class="card">
    <slot name="header" :title="cardTitle"></slot>
    <slot :content="cardContent"></slot>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const cardTitle = ref('卡片标题')
const cardContent = ref('这是卡片的内容...')
</script>

父组件

<template>
  <Card>
    <template #header="{ title }">
      <h2>{{ title }}</h2>
    </template>
    
    <template #default="{ content }">
      <p>{{ content }}</p>
    </template>
  </Card>
</template>

五、课后Quiz

题目

  1. 什么是默认插槽?请给出一个简单的使用示例。
  2. 具名插槽的主要作用是什么?如何在父组件中指定内容渲染到具名插槽?
  3. 作用域插槽解决了什么问题?请描述其工作原理。
  4. 如何为插槽设置默认内容?

答案解析

  1. 默认插槽是组件中没有指定名称的插槽,父组件传递的未指定插槽名的内容会被渲染到默认插槽的位置。示例:

    <!-- 子组件 -->
    <button><slot></slot></button>
    <!-- 父组件 -->
    <Button>点击我</Button>
    
  2. 具名插槽用于组件包含多个需要自定义的区域的场景,每个插槽有唯一的名称,父组件可以精准控制内容的渲染位置。父组件使用<template #插槽名>的语法传递内容到指定的具名插槽。

  3. 作用域插槽解决了插槽内容无法访问子组件数据的问题。工作原理:子组件在插槽出口上传递Props(类似组件Props),父组件使用v-slot指令接收这些Props,从而在插槽内容中访问子组件的数据。

  4. <slot>标签之间写入默认内容即可,当父组件没有传递内容时,默认内容会被渲染:

    <slot>默认内容</slot>
    

六、常见报错解决方案

1. 报错:v-slot指令只能用在<template>或组件标签上

原因v-slot指令只能用于<template>标签或组件标签,不能直接用于普通HTML元素。 解决办法:将v-slot指令移到<template>标签或组件标签上。例如:

<!-- 错误写法 -->
<div v-slot="slotProps">{{ slotProps.text }}</div>

<!-- 正确写法 -->
<template v-slot="slotProps">
  <div>{{ slotProps.text }}</div>
</template>

2. 报错:未定义的插槽Props

原因:父组件尝试访问子组件未传递的插槽Props。 解决办法

  • 确保子组件在插槽出口上传递了对应的Props;
  • 在父组件中使用可选链操作符(?.)避免报错:
    <MyComponent v-slot="{ text }">
      {{ text?.toUpperCase() }} <!-- 使用可选链操作符 -->
    </MyComponent>
    

3. 报错:具名插槽的内容未显示

原因

  • 父组件传递具名插槽内容时,插槽名拼写错误;
  • 子组件中没有定义对应的具名插槽。 解决办法
  • 检查插槽名是否拼写正确(注意大小写敏感);
  • 确保子组件中定义了对应的具名插槽:<slot name="header"></slot>

4. 报错:默认插槽和具名插槽同时使用时的作用域混淆

原因:当同时使用默认插槽和具名插槽时,直接为组件添加v-slot指令会导致编译错误,因为默认插槽的Props作用域会与具名插槽混淆。 解决办法:为默认插槽使用显式的<template>标签:

<!-- 错误写法 -->
<MyComponent v-slot="{ message }">
  <p>{{ message }}</p>
  <template #footer>
    <p>{{ message }}</p> <!-- message 属于默认插槽,此处不可用 -->
  </template>
</MyComponent>

<!-- 正确写法 -->
<MyComponent>
  <template #default="{ message }">
    <p>{{ message }}</p>
  </template>
  
  <template #footer>
    <p>页脚内容</p>
  </template>
</MyComponent>

参考链接

cn.vuejs.org/guide/compo…

React 样式——styled-components

作者 随意_
2026年2月14日 10:40

在 React 开发中,样式管理一直是绕不开的核心问题 —— 全局 CSS 命名冲突、动态样式繁琐、样式与组件解耦难等痛点,长期困扰着前端开发者。而 styled-components 作为 React 生态中最主流的 CSS-in-JS 方案,彻底颠覆了传统样式编写方式,将样式与组件深度绑定,让样式管理变得简洁、可维护且灵活。本文将从核心原理、基础语法、进阶技巧到实战场景,全面拆解 styled-components 的使用精髓,涵盖原生标签、自定义组件、第三方组件适配等全场景用法。

一、什么是 styled-components?

styled-components 是一款专为 React/React Native 设计的 CSS-in-JS 库,核心思想是 “将 CSS 样式写在 JavaScript 中,并与组件一一绑定”。它由 Max Stoiber 于 2016 年推出,目前 GitHub 星数超 40k,被 Airbnb、Netflix、Spotify 等大厂广泛采用。

核心优势

  1. 样式封装,杜绝污染:每个样式组件生成唯一的 className,彻底解决全局 CSS 命名冲突问题;
  2. 动态样式,灵活可控:直接通过组件 props 控制样式,无需拼接 className 或写内联样式;
  3. 自动前缀,兼容省心:自动为 CSS 属性添加浏览器前缀(如 -webkit--moz-),无需手动处理兼容;
  4. 语义化强,易维护:样式与组件代码同文件,逻辑闭环,可读性和可维护性大幅提升;
  5. 按需打包,体积优化:打包时自动移除未使用的样式,减少冗余代码;
  6. 通用适配,场景全覆盖:既支持 HTML 原生标签,也兼容自定义组件、第三方 UI 组件(如 KendoReact/Ant Design)。

二、基础语法:从原生 HTML 标签到样式组件

styled-components 的核心语法分为两种形式,分别适配不同场景,是掌握该库的基础。

1. 安装

在 React 项目中安装核心依赖(TypeScript 项目可额外安装类型声明):

# npm
npm install styled-components

# yarn
yarn add styled-components

# TypeScript 类型声明(新版已内置,可选)
npm install @types/styled-components --save-dev

2. 语法形式 1:styled.HTML标签(原生标签快捷写法)

这是最常用的基础语法,styled. 后紧跟 HTML 原生标签名(如 div/button/p/h1/input 等),本质是 styled() 函数的语法糖,用于快速创建带样式的原生 HTML 组件。

多标签示例:覆盖高频 HTML 元素

import React from 'react';
import styled from 'styled-components';

// 1. 布局容器:div
const Container = styled.div`
  width: 90%;
  max-width: 1200px;
  margin: 20px auto;
  padding: 24px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
`;

// 2. 标题:h1/h2
const TitleH1 = styled.h1`
  color: #1f2937;
  font-size: 32px;
  font-weight: 700;
  margin-bottom: 16px;
`;

// 3. 文本:p/span
const Paragraph = styled.p`
  color: #4b5563;
  font-size: 16px;
  line-height: 1.6;
  margin-bottom: 12px;
`;
const HighlightText = styled.span`
  color: #2563eb;
  font-weight: 500;
`;

// 4. 交互:button/a
const PrimaryButton = styled.button`
  padding: 10px 20px;
  background-color: #2563eb;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  &:hover { background-color: #1d4ed8; }
  &:disabled { background-color: #93c5fd; cursor: not-allowed; }
`;
const Link = styled.a`
  color: #2563eb;
  text-decoration: none;
  &:hover { text-decoration: underline; color: #1d4ed8; }
`;

// 5. 表单:input/label
const FormLabel = styled.label`
  display: block;
  font-size: 14px;
  color: #374151;
  margin-bottom: 6px;
`;
const Input = styled.input`
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  &:focus {
    outline: none;
    border-color: #2563eb;
    box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
  }
`;

// 6. 列表:ul/li
const List = styled.ul`
  margin: 16px 0;
  padding-left: 24px;
`;
const ListItem = styled.li`
  margin-bottom: 8px;
  &:last-child { margin-bottom: 0; }
`;

// 使用示例
function BasicTagDemo() {
  return (
    <Container>
      <TitleH1>原生标签样式化示例</TitleH1>
      <Paragraph>
        这是 <HighlightText>styled.p</HighlightText> 渲染的段落,支持 <HighlightText>styled.span</HighlightText> 行内样式。
      </Paragraph>
      <List>
        <ListItem>styled.div:布局容器核心标签</ListItem>
        <ListItem>styled.button:交互按钮,支持 hover/禁用状态</ListItem>
        <ListItem>styled.input:表单输入框,支持焦点样式</ListItem>
      </List>
      <FormLabel htmlFor="username">用户名</FormLabel>
      <Input id="username" placeholder="请输入用户名" />
      <PrimaryButton style={{ marginTop: '10px' }}>提交</PrimaryButton>
      <Link href="#" style={{ marginLeft: '10px' }}>忘记密码?</Link>
    </Container>
  );
}

3. 语法形式 2:styled(组件)(自定义 / 第三方组件适配)

当需要给自定义 React 组件第三方 UI 组件添加样式时,必须使用 styled() 通用函数(styled.xxx 仅支持原生标签)。

核心要求

被包裹的组件需接收并传递 className 属性到根元素(第三方组件库如 KendoReact/AntD 已内置支持)。

示例 1:给自定义组件加样式

import React from 'react';
import styled from 'styled-components';

// 自定义组件:必须传递 className 到根元素
const MyButton = ({ children, className }) => {
  // 关键:将 className 传给根元素 <button>,样式才能生效
  return <button className={className}>{children}</button>;
};

// 用 styled() 包裹自定义组件,添加样式
const StyledMyButton = styled(MyButton)`
  background-color: #28a745;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  &:hover { background-color: #218838; }
`;

function CustomComponentDemo() {
  return <StyledMyButton>自定义组件样式化</StyledMyButton>;
}

示例 2:给第三方组件(KendoReact)加样式

import React from 'react';
import styled from 'styled-components';
// 引入 KendoReact 按钮组件
import { Button } from '@progress/kendo-react-buttons';

// 用 styled() 覆盖第三方组件默认样式
const StyledKendoButton = styled(Button)`
  background-color: #dc3545 !important; /* 覆盖组件内置样式 */
  border-color: #dc3545 !important;
  color: white !important;
  padding: 8px 16px !important;
  
  &:hover {
    background-color: #c82333 !important;
  }
`;

function ThirdPartyComponentDemo() {
  return <StyledKendoButton>自定义样式的 KendoReact 按钮</StyledKendoButton>;
}

4. 两种语法的关系

styled.xxxstyled('xxx') 的语法糖(如 styled.div = styled('div')),仅简化原生标签的写法;而 styled(组件) 是通用方案,覆盖所有组件类型,二者底层均基于 styled-components 的样式封装逻辑。

三、进阶技巧:提升开发效率与可维护性

掌握基础语法后,这些进阶技巧能适配中大型项目的复杂场景。

1. 动态样式:通过 Props 控制样式

这是 styled-components 最核心的特性之一,无需拼接 className,直接通过 props 动态调整样式,适配状态切换、主题变化等场景。

jsx

import React from 'react';
import styled from 'styled-components';

// 带 props 的动态按钮
const DynamicButton = styled.button`
  padding: ${props => props.size === 'large' ? '12px 24px' : '8px 16px'};
  background-color: ${props => {
    switch (props.variant) {
      case 'primary': return '#2563eb';
      case 'danger': return '#dc3545';
      case 'success': return '#28a745';
      default: return '#6c757d';
    }
  }};
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  &:hover { opacity: 0.9; }
`;

function DynamicStyleDemo() {
  return (
    <div style={{ gap: '10px', display: 'flex', padding: '20px' }}>
      <DynamicButton variant="primary" size="large">主要大按钮</DynamicButton>
      <DynamicButton variant="danger">危险默认按钮</DynamicButton>
      <DynamicButton variant="success">成功按钮</DynamicButton>
    </div>
  );
}

2. 样式继承:复用已有样式

基于已定义的样式组件扩展新样式,避免重复代码,提升复用性。

import styled from 'styled-components';

// 基础按钮(通用样式)
const BaseButton = styled.button`
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  color: white;
  cursor: pointer;
  font-size: 14px;
`;

// 继承基础按钮,扩展危险按钮样式
const DangerButton = styled(BaseButton)`
  background-color: #dc3545;
  &:hover { background-color: #c82333; }
`;

// 继承并覆盖样式:轮廓按钮
const OutlineButton = styled(BaseButton)`
  background-color: transparent;
  border: 1px solid #2563eb;
  color: #2563eb;
  &:hover {
    background-color: #2563eb;
    color: white;
    transition: all 0.2s ease;
  }
`;

3. 全局样式:重置与全局配置

通过 createGlobalStyle 定义全局样式(如重置浏览器默认样式、设置全局字体),只需在根组件中渲染一次即可生效。

import React from 'react';
import styled, { createGlobalStyle } from 'styled-components';

// 全局样式组件
const GlobalStyle = createGlobalStyle`
  /* 重置浏览器默认样式 */
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  /* 全局字体和背景 */
  body {
    font-family: 'Microsoft YaHei', sans-serif;
    background-color: #f8f9fa;
    color: #333;
  }

  /* 全局链接样式 */
  a {
    text-decoration: none;
    color: #2563eb;
  }
`;

// 根组件中使用
function App() {
  return (
    <>
      <GlobalStyle /> {/* 全局样式生效 */}
      <div>应用内容...</div>
    </>
  );
}

4. 主题管理(ThemeProvider):全局样式统一

在中大型项目中,通过 ThemeProvider 统一管理主题(主色、副色、字体),支持主题切换(如浅色 / 暗黑模式)。

import React, { useState } from 'react';
import styled, { ThemeProvider } from 'styled-components';

// 定义主题对象
const lightTheme = {
  colors: { primary: '#2563eb', background: '#f8f9fa', text: '#333' },
  fontSize: { small: '12px', medium: '14px' }
};
const darkTheme = {
  colors: { primary: '#198754', background: '#212529', text: '#fff' },
  fontSize: { small: '12px', medium: '14px' }
};

// 使用主题样式
const ThemedCard = styled.div`
  padding: 20px;
  background-color: ${props => props.theme.colors.background};
  color: ${props => props.theme.colors.text};
  border-radius: 8px;
`;
const ThemedButton = styled.button`
  padding: 8px 16px;
  background-color: ${props => props.theme.colors.primary};
  color: white;
  border: none;
  border-radius: 4px;
`;

function ThemeDemo() {
  const [isDark, setIsDark] = useState(false);
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <div style={{ padding: '20px' }}>
        <button onClick={() => setIsDark(!isDark)}>
          切换{isDark ? '浅色' : '暗黑'}主题
        </button>
        <ThemedCard style={{ marginTop: '10px' }}>
          <ThemedButton>主题化按钮</ThemedButton>
        </ThemedCard>
      </div>
    </ThemeProvider>
  );
}

5. 嵌套样式:模拟 SCSS 语法

支持样式嵌套,贴合组件 DOM 结构,减少选择器冗余。

const Card = styled.div`
  width: 300px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;

  /* 嵌套子元素样式 */
  .card-title {
    font-size: 20px;
    margin-bottom: 10px;
  }
  .card-content {
    font-size: 14px;
    /* 深层嵌套 */
    .highlight { color: #2563eb; }
  }
`;

四、实战场景:什么时候用 styled-components?

  1. 中大型 React 项目:需要严格样式封装,避免多人协作时的样式冲突;
  2. 动态样式频繁的场景:如按钮状态切换、主题切换、响应式布局;
  3. 组件库开发:样式与组件逻辑内聚,便于组件发布和复用;
  4. 第三方组件库定制:覆盖 KendoReact/AntD 等组件的默认样式,精准且不污染全局;
  5. 响应式开发:通过媒体查询快速适配不同屏幕尺寸,样式与组件同文件更易维护。

五、注意事项与最佳实践

  1. 避免过度嵌套:嵌套层级建议不超过 2-3 层,否则可读性下降;
  2. 自定义组件必传 className:用 styled(组件) 时,确保组件将 className 传给根元素;
  3. 慎用!important:覆盖第三方组件样式时,优先提高选择器优先级,而非直接用 !important
  4. 样式组件定义在外部:避免在渲染函数内定义样式组件(导致每次渲染重新创建);
  5. 调试优化:安装 babel-plugin-styled-components 插件,让开发者工具显示有意义的 className;
  6. 抽离通用样式:将重复样式抽离为基础组件或主题变量,减少冗余。

六、总结

styled-components 并非简单的 “CSS 写在 JS 里”,而是 React 组件化思想在样式领域的延伸。其核心价值在于:

  1. 语法灵活styled.xxx 适配原生标签,styled(组件) 适配自定义 / 第三方组件,覆盖全场景;
  2. 样式闭环:样式与组件绑定,杜绝全局污染,提升可维护性;
  3. 动态能力:通过 props 和 ThemeProvider 轻松实现动态样式和主题管理;
  4. 生态兼容:无缝对接 KendoReact/AntD 等主流组件库,降低定制成本。

对于 React 开发者而言,掌握 styled-components 不仅能解决传统样式方案的痛点,更能构建出更健壮、易扩展的组件体系,是中大型 React 项目样式管理的首选方案。

深入浅出:CSS 中的“隐形结界”——BFC 详解

作者 陆枫Larry
2026年2月14日 10:16

在前端面试和实际开发中,BFC(Block Formatting Context,块级格式化上下文)可以说是一个“神级”概念。它听起来很抽象,但实际上它是解决 CSS 布局疑难杂症(如外边距折叠、高度塌陷、浮动重叠)的一把万能钥匙。

今天我们就用通俗易懂的方式,把 BFC 这个“黑盒子”彻底打开。


1. 什么是 BFC?

官方定义:块级格式化上下文。它是 Web 页面中一块独立的渲染区域,只有块级元素参与,它规定了内部的块级元素如何布局,并且与外部毫不相干。

通俗理解: BFC 就像是一个 “完全隔离的独立房间”。 在这个房间(容器)里:

  • 元素怎么折腾(比如浮动、乱跑的 margin)都不会影响到房间外面的布局。
  • 外面的人也不会影响到房间里面。
  • 在这个房间里,一切都要算清楚,不能含糊其辞地溢出到外面去。

2. 如何触发(开启)BFC?

并不是所有元素天然就是 BFC,你需要满足特定条件才能触发它。只要满足下列 任意一条,该元素就会创建一个 BFC:

  1. overflow 值不为 visible (常用 ✅)
    • 例如:hidden, auto, scroll。这是最常用的方式,因为它副作用最小。
  2. display 设置为特殊值
    • inline-block, table-cell, flex, grid, flow-root
    • 注:display: flow-root 是专门为了创建 BFC 而生的新属性,无副作用,未来趋势。
  3. position 设置为脱离文档流的值
    • absolute, fixed
  4. float 设置为不为 none 的值
    • left, right

3. BFC 的三大“超能力”(实战应用)

一旦开启了 BFC,这个元素就拥有了三项特异功能:

(1) 阻止外边距折叠 (Margin Collapse)

  • 痛点:父子元素之间,子元素的 margin-top 经常会“穿透”父元素,带着父元素一起往下掉;或者两个相邻兄弟元素的上下 margin 会合并。
  • BFC 解法给父元素开启 BFC(例如 overflow: hidden)。
    • 原理:BFC 是一堵墙。父元素变成了独立房间,子元素的 margin 再大也撞不开这堵墙,只能乖乖在墙内撑开父元素的内容,无法穿透出去

(2) 清除浮动(解决高度塌陷)

  • 痛点:子元素全部浮动 (float: left) 后,父元素因为检测不到高度,高度会塌陷为 0,背景色消失,布局乱套。
  • BFC 解法给父元素开启 BFC(例如 overflow: hidden)。
    • 原理:普通容器计算高度时会忽略浮动元素,但 BFC 容器规定:计算高度时,浮动元素也参与计算。所以它能自动包裹住浮动的子元素。

(3) 防止元素被浮动元素覆盖(自适应两栏布局)

  • 痛点:左边一个浮动元素,右边的普通 div 会无视它,直接钻到它底下去,导致内容重叠。
  • BFC 解法给右边的 div 开启 BFC(例如 overflow: hidden)。
    • 原理:BFC 的区域不会与浮动盒子重叠。利用这一点,可以轻松实现“左边固定宽度,右边自动填满剩余空间”的经典布局。

4. 为什么 overflow: hidden 最常用?

虽然 float: leftposition: absolute 也能触发 BFC,但它们会让元素脱离文档流,改变布局结构(比如宽度变窄、位置飞走)。

overflow: hidden 通常保持了块级元素的原本特性(独占一行、宽度撑满),只是顺带开启了 BFC 功能,副作用最小,所以成为了大家的首选。


5. 小结

下次当你遇到:

  • Margin 莫名其妙穿透/合并了
  • 父元素高度莫名其妙没了(塌陷)
  • 元素莫名其妙重叠了

请先想一想:“我是不是需要给父容器加一个 overflow: hidden 来开启 BFC?”

这通常是解决 CSS 疑难杂症最快、最有效的方法。

gsap 配置解读 --3

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

drawSVG 是什么

  <div class="card">
    <h1>案例 15:DrawSVG 绘制路径</h1>
    <p>DrawSVGPlugin 可以控制路径的绘制百分比。</p>
    <svg viewBox="0 0 200 200">
      <path id="path" class="stroke" d="M40 100 C40 40, 160 40, 160 100 C160 160, 40 160, 40 100" />
    </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/DrawSVGPlugin.min.js"></script>
  <script>
    const path = document.querySelector("#path");
    const playButton = document.querySelector("#play");

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

    // drawSVG: "0% 100%" 表示从头绘制到尾
    const tween = gsap.fromTo(
      path,
      { drawSVG: "0% 0%" },
      { drawSVG: "0% 100%", duration: 1.6, ease: "power2.out", paused: true }
    );

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

drawSVGGSAP(GreenSock Animation Platform)官方提供的一个强大插件 —— DrawSVGPlugin 的核心功能,专门用于以动画方式“绘制”或“擦除” SVG 路径(<path><line><polyline><polygon><rect><circle> 等),实现类似“手绘”、“描边动画”的效果。


📌 你的代码解释:

gsap.fromTo(
  path,
  { drawSVG: "0% 0%" },      // 起始状态:路径完全未绘制(0% 到 0%)
  { drawSVG: "0% 100%", duration: 1.6, ease: "power2.out", paused: true }
);

这段代码的作用是:

让 SVG 路径从“完全隐藏”状态,平滑地“画出来”,直到完整显示整个路径。

点击按钮后,调用 tween.restart() 重新播放这个绘制动画。


drawSVG 的工作原理

SVG 路径本身是一条“线”,但默认是立即完整显示的。
DrawSVGPlugin 通过动态控制 SVG 的 stroke-dasharraystroke-dashoffset 属性,来只显示路径的一部分,从而模拟“绘制”过程。

  • drawSVG: "0% 0%" → 显示 0% 到 0% → 完全隐藏
  • drawSVG: "0% 50%" → 显示前 50%
  • drawSVG: "0% 100%" → 显示全部 → 完整路径
  • drawSVG: "100% 100%" → 也完全隐藏(可以用来做“擦除”效果)

💡 它支持百分比("0% 100%")、绝对长度("0px 200px")或关键词("start", "end")。


🔧 常见用法示例

1. 从头到尾绘制(你的例子)
{ drawSVG: "0% 100%" }
2. 从尾到头绘制(反向)
{ drawSVG: "100% 0%" } // 注意顺序:起始 > 结束 = 反向
3. 中间一段高亮(常用于进度指示)
{ drawSVG: "40% 60%" }
4. 擦除效果(从完整到消失)
gsap.to(path, { drawSVG: "0% 0%", duration: 1 });
5. 循环绘制 + 擦除
gsap.to(path, {
  drawSVG: "0% 100%",
  duration: 1,
  yoyo: true,
  repeat: -1
});

✅ 支持的 SVG 元素

元素 是否支持
<path> ✅ 最常用
<line>
<polyline>
<polygon>
<rect> ✅(需有 stroke
<circle> / <ellipse>
<text> ❌(不支持,但可用 textPath 包裹路径)

⚠️ 要求元素必须有 stroke(描边),且 stroke-width > 0。填充(fill)不影响绘制动画。


🎨 样式建议(CSS)

.stroke {
  fill: none;           /* 通常设为无填充 */
  stroke: #3b82f6;      /* 描边颜色 */
  stroke-width: 4;      /* 描边宽度 */
  stroke-linecap: round;/* 线帽样式(可选)*/
}

⚠️ 注意事项

  1. 必须注册插件

    gsap.registerPlugin(DrawSVGPlugin);
    
  2. 路径必须是“单一线条”
    复杂组合路径(如多个子路径)可能表现异常,建议简化或拆分。

  3. 性能优秀
    DrawSVGPlugin 内部优化良好,即使在低端设备上也能流畅运行。

  4. 与 ScrollTrigger 结合极佳
    常用于“滚动触发动画”:

    gsap.to(path, {
      scrollTrigger: ".section",
      drawSVG: "0% 100%",
      duration: 1
    });
    

✅ 总结

术语 含义
drawSVG GSAP 的 DrawSVGPlugin 提供的属性,用于控制 SVG 路径的“绘制进度”
典型值 "0% 0%"(隐藏)、"0% 100%"(完整绘制)、"100% 0%"(反向绘制)

EaselPlugin是什么

<div class="card">
    <h1>案例 16:EaselPlugin + Canvas</h1>
    <p>用 GSAP 驱动 EaselJS 的对象属性。</p>
    <canvas id="stage" width="420" height="220"></canvas>
    <button id="play">播放动画</button>
  </div>
  <script src="https://code.createjs.com/1.0.0/easeljs.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/EaselPlugin.min.js"></script>
  <script>
    const canvas = document.querySelector("#stage");
    const playButton = document.querySelector("#play");

    // 创建 EaselJS 舞台与图形
    const stage = new createjs.Stage(canvas);
    const circle = new createjs.Shape();
    circle.graphics.beginFill("#38bdf8").drawCircle(0, 0, 26);
    circle.x = 60;
    circle.y = 110;
    stage.addChild(circle);
    stage.update();

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

    // GSAP 让 EaselJS 图形移动与缩放
    const tween = gsap.to(circle, {
      x: 360,
      scaleX: 1.4,
      scaleY: 1.4,
      duration: 1.4,
      ease: "power2.out",
      paused: true
    });

    // 每帧刷新舞台
    gsap.ticker.add(() => {
      stage.update();
    });

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

EaselJSCreateJS 套件中的一个核心库,专门用于在 HTML5 <canvas> 画布上进行高性能的 2D 图形绘制与交互开发。它提供了一套类似 Flash/ActionScript 的面向对象 API,让开发者能轻松创建、操作和动画化矢量图形、位图、文本等元素。


📌 在你的代码中,EaselJS 的作用是:

  1. 创建一个 Canvas 舞台(Stage)
  2. 绘制一个圆形(Shape)并添加到舞台上
  3. 通过 GSAP + EaselPlugin 控制该圆形的位置和缩放

EaselPlugin 是 GSAP 的一个官方插件,让 GSAP 能直接动画化 EaselJS 对象的属性(如 x, y, scaleX, rotation 等),并自动触发舞台重绘。


✅ EaselJS 核心概念

概念 说明
Stage 代表整个 <canvas> 画布,是所有显示对象的容器
DisplayObject 所有可视对象的基类(如 Shape, Bitmap, Text, Container
Shape 用于绘制矢量图形(圆、矩形、路径等)
Ticker EaselJS 自带的帧循环(但你的代码用的是 GSAP 的 ticker 来更新舞台)

🔍 你的代码逐行解析

// 1. 创建 EaselJS 舞台
const stage = new createjs.Stage(canvas);

// 2. 创建一个圆形 Shape
const circle = new createjs.Shape();
circle.graphics.beginFill("#38bdf8").drawCircle(0, 0, 26); // 画一个半径26的圆
circle.x = 60;
circle.y = 110;

// 3. 将圆形添加到舞台
stage.addChild(circle);

// 4. 首次渲染(否则看不到)
stage.update();
// 5. 注册 GSAP 插件
gsap.registerPlugin(EaselPlugin);

// 6. 用 GSAP 动画化 EaselJS 对象!
const tween = gsap.to(circle, {
  x: 360,       // EaselJS 对象的 x 属性
  scaleX: 1.4,  // 缩放
  scaleY: 1.4,
  duration: 1.4,
  ease: "power2.out",
  paused: true
});
// 7. 关键:每帧刷新 Canvas!
gsap.ticker.add(() => {
  stage.update(); // 告诉 EaselJS 重新绘制整个舞台
});

💡 如果没有 stage.update(),Canvas 不会更新,动画就“看不见”!


✅ 为什么需要 EaselPlugin

  • EaselJS 对象的属性(如 x, scaleX不是直接作用于 DOM 或 CSS,而是存储在 JavaScript 对象中。
  • GSAP 默认不知道如何“读取/写入”这些属性,也不知道何时需要调用 stage.update()
  • EaselPlugin 桥接了 GSAP 和 EaselJS
    • 自动识别 createjs.DisplayObject
    • 正确设置/获取 x, y, rotation, scaleX/Y, alpha 等属性
    • (可选)自动调用 stage.update()(但你的代码手动用 gsap.ticker 控制,更灵活)

🎯 EaselJS 的典型应用场景

场景 说明
游戏开发 2D 小游戏(平台跳跃、射击、解谜等)
数据可视化 动态图表、交互式信息图
广告 Banner HTML5 富媒体广告(替代 Flash)
教育/演示动画 复杂交互动画、流程演示
Canvas UI 组件 自定义控件、非 DOM 的界面

⚠️ 注意事项

  1. 性能 vs DOM
    Canvas 适合大量图形或高频更新(如游戏),但不支持 SEO、无障碍访问(a11y)。简单 UI 优先用 DOM + CSS。

  2. 坐标系
    EaselJS 使用标准 Canvas 坐标系:左上角 (0,0),向右为 X+,向下为 Y+。

  3. 事件处理
    EaselJS 支持鼠标/触摸事件(circle.on("click", handler)),但需启用:

    stage.enableMouseOver(10); // 启用 hover
    
  4. 替代方案
    现代项目也可考虑:

    • 纯 Canvas + requestAnimationFrame
    • PixiJS(更强大,支持 WebGL)
    • Three.js(3D)
    • SVG + GSAP(矢量、可访问性好)

✅ 总结

术语 含义
EaselJS 一个基于 HTML5 Canvas 的 2D 图形库,提供类似 Flash 的开发体验
EaselPlugin GSAP 插件,让 GSAP 能无缝动画化 EaselJS 对象的属性

你的代码展示了 “用 GSAP 驱动 Canvas 图形动画” 的经典模式:

  • EaselJS 负责 图形创建与渲染
  • GSAP 负责 复杂缓动、时间控制、序列编排
  • gsap.ticker 负责 每帧刷新画面

这种组合在需要精细控制 Canvas 动画时非常高效!

Flip 是什么

<div class="card">
    <h1>案例 17:Flip 位置变换</h1>
    <p>Flip 能把布局切换变成平滑动画。</p>
    <div class="grid" id="grid">
      <div class="item highlight">A</div>
      <div class="item">B</div>
      <div class="item">C</div>
      <div class="item">D</div>
      <div class="item">E</div>
      <div class="item">F</div>
    </div>
    <button id="toggle">切换布局</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/Flip.min.js"></script>
  <script>
    const grid = document.querySelector("#grid");
    const toggleButton = document.querySelector("#toggle");
    let expanded = false;

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

    toggleButton.addEventListener("click", () => {
      const items = gsap.utils.toArray(".item");

      // 记录布局状态
      const state = Flip.getState(items);

      // 切换布局
      expanded = !expanded;
      grid.style.gridTemplateColumns = expanded ? "repeat(2, 1fr)" : "repeat(3, 1fr)";
      items[0].classList.toggle("highlight", expanded);
      items[0].style.gridColumn = expanded ? "span 2" : "auto";

      // 用 Flip 生成补间动画
      Flip.from(state, {
        duration: 0.8,
        ease: "power2.inOut",
        stagger: 0.04
      });
    });
  </script>

FlipGSAP(GreenSock Animation Platform)官方提供的一个革命性插件 —— FlipPlugin,它的名字是 "First, Last, Invert, Play" 的缩写,是一种高效实现布局变换平滑动画的技术


🎯 核心思想:“记录变化前后的状态,自动生成中间过渡动画”

你不需要手动计算元素移动了多少像素、缩放了多少倍——
只需改变 DOM 结构或 CSS 布局(如 grid、flex、class、style),Flip 会自动检测差异并补间!


📌 你的代码解释:

// 1. 记录当前所有 .item 元素的状态(位置、尺寸等)
const state = Flip.getState(items);

// 2. 改变布局(这是“瞬间”的,没有动画)
expanded = !expanded;
grid.style.gridTemplateColumns = expanded ? "repeat(2, 1fr)" : "repeat(3, 1fr)";
items[0].classList.toggle("highlight", expanded);
items[0].style.gridColumn = expanded ? "span 2" : "auto";

// 3. 让 Flip 从“旧状态”动画到“新状态”
Flip.from(state, {
  duration: 0.8,
  ease: "power2.inOut",
  stagger: 0.04
});

效果:点击按钮后,网格从 3 列变为 2 列,第一个 item 跨两列并高亮,其他元素平滑地移动、缩放到新位置,而不是“跳变”。


🔍 Flip 的工作原理(F.L.I.P.)

步骤 含义 你的代码中
F - First 记录元素变化前的位置/尺寸 Flip.getState(items)
L - Last 应用新的布局(DOM/CSS 改变) 修改 gridTemplateColumnsgridColumn
I - Invert 通过 transform 将元素视觉上“倒回”到原始位置(用户看不见这一步) Flip 内部自动完成
P - Play 动画 transform 回到新位置,形成平滑过渡 Flip.from(state, {...})

💡 这种技术避免了强制重排(reflow),性能极高!


✅ Flip 的核心优势

优势 说明
零计算 无需手动算 x, y, width, height
自动处理复杂布局 支持 Grid、Flexbox、绝对定位、浮动等
高性能 只使用 transformopacity,60fps 流畅
智能匹配元素 自动根据 DOM 节点或 key 属性关联前后元素
支持嵌套、增删元素 可配合 onEnter, onLeave 处理新增/移除项

🔧 常见用法扩展

1. 指定唯一 key(推荐用于动态列表)
// 给每个 item 加 data-id
<div class="item" data-flip-id="A">A</div>

// Flip 会按 data-flip-id 匹配元素
Flip.from(state, { 
  absolute: true, // 使用绝对定位避免布局抖动
  simple: true    // 简化动画(仅位移+缩放)
});
2. 处理新增/删除元素
Flip.from(state, {
  onEnter: (elements) => gsap.from(elements, { opacity: 0, scale: 0 }), // 新增项淡入
  onLeave: (elements) => gsap.to(elements, { opacity: 0 })              // 移除项淡出
});
3. 与 React/Vue 集成

在虚拟 DOM 更新后调用 Flip.getState() → 触发渲染 → 调用 Flip.from(),实现声明式布局动画。


⚠️ 注意事项

  • 必须注册插件
    gsap.registerPlugin(Flip);
    
  • 元素需有明确尺寸和位置(避免 display: none 或未渲染状态)
  • 默认使用相对定位,若布局跳动可加 absolute: true
  • 不适用于纯颜色/文本内容变化(那是 CSS Transition 的领域)

✅ 总结

术语 含义
Flip (FlipPlugin) GSAP 插件,通过记录布局前后状态,自动生成平滑的元素位置/尺寸变换动画

你的代码是一个典型的 “响应式网格布局切换” 示例,广泛应用于:

  • 卡片列表 ↔ 网格视图切换
  • 侧边栏展开/收起
  • 动态表单布局调整
  • 数据可视化重排

💡 一句话记住 Flip
“改 CSS,记状态,自动生成动画” —— 布局动画从未如此简单!

什么是GSDevTools

 <div class="card">
      <h1>案例 18:GSDevTools 调试面板</h1>
      <p>GSDevTools 用来调试时间线与动画进度。</p>
      <div class="stage">
        <div class="block" id="block"></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/GSDevTools.min.js"></script>
    <script>
      const block = document.querySelector("#block");

      // 创建一个循环时间线
      const timeline = gsap.timeline({ repeat: -1, yoyo: true });
      timeline.to(block, { x: 460, duration: 1.4, ease: "power2.inOut" });
      timeline.to(block, { rotation: 180, duration: 0.8, ease: "power2.inOut" }, 0);

      // 创建调试面板
      GSDevTools.create({ animation: timeline });
    </script>

image.png

什么是 InertiaPlugin

<div class="card">
    <h1>案例 19:Inertia 惯性拖拽</h1>
    <p>松手后带惯性滑动。</p>
    <div class="stage">
      <div class="ball" id="ball"></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/Draggable.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/InertiaPlugin.min.js"></script>
  <script>
    const ball = document.querySelector("#ball");

    // 注册插件
    gsap.registerPlugin(Draggable, InertiaPlugin);

    // 开启惯性效果
    Draggable.create(ball, {
      type: "x,y",
      bounds: ".stage",
      inertia: true
    });
  </script>

就是添加这个属性 是否开启惯性 inertia: true

MotionPathPlugin是什么

<div class="card">
      <h1>案例 20:MotionPath 路径运动</h1>
      <p>让元素沿着 SVG 路径运动。</p>
      <svg viewBox="0 0 420 220">
        <path
          id="track"
          class="path"
          d="M20 180 C120 40, 300 40, 400 180"
        />
        <circle id="dot" class="dot" r="10" cx="20" cy="180"></circle>
      </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/MotionPathHelper.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/MotionPathPlugin.min.js"></script>
    <script>
      const dot = document.querySelector("#dot");
      const track = document.querySelector("#track");
      const playButton = document.querySelector("#play");

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

      const tween = gsap.to(dot, {
        duration: 1.8,
        ease: "power1.inOut",
        motionPath: {
          path: track,
          align: track,
          alignOrigin: [0.5, 0.5]
        },
        paused: true
      });

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

motionPathGSAP(GreenSock Animation Platform) 动画库中的一个强大功能,由 MotionPathPlugin 插件提供,用于让元素沿着指定的 路径(path) 进行动画运动。


📌 简单定义:

motionPath 允许你将一个 DOM 元素(如 <div><circle> 等)沿着 SVG 路径(<path><circle><rect> 等)或一组坐标点进行平滑移动,并可自动旋转以对齐路径方向。


✅ 核心特性:

  1. 路径支持多种格式

    • SVG 的 <path> 元素(最常用)
    • 其他 SVG 形状(如 <circle>, <rect>, <polygon>
    • 一组 {x, y} 坐标点组成的数组
    • 字符串形式的 SVG 路径数据(d 属性)
  2. 自动对齐(align)

    • 可通过 align: path 让元素在移动时朝向路径切线方向(比如让飞机头始终指向飞行方向)。
    • alignOrigin 控制对齐的“锚点”,例如 [0.5, 0.5] 表示元素中心对齐。
  3. 精确控制

    • 支持 startend 属性,控制沿路径的起止位置(0 到 1)。
    • 可结合 GSAP 的时间轴、缓动函数(ease)、重复等高级功能。

gsap.to(dot, {
  duration: 1.8,
  ease: "power1.inOut",
  motionPath: {
    path: track,           // 沿着 #track 这个 SVG 路径移动
    align: track,          // 元素方向对齐路径
    alignOrigin: [0.5, 0.5] // 以圆点中心为对齐基准
  },
  paused: true
});
  • #dot(一个小圆)会从路径起点 (20,180) 开始,
  • 沿着贝塞尔曲线 M20 180 C120 40, 300 40, 400 180 移动到终点 (400,180)
  • 移动过程中,由于设置了 align,它会自动旋转以匹配路径的走向(虽然圆形看不出旋转,但如果是箭头就很明显)。

💡 注:圆形 (<circle>) 本身没有方向感,所以 align 效果不明显。若换成 <use> 引用一个飞机图标,就能看到“朝向路径”的效果。


🌟 应用场景:

  • 游戏角色沿轨道移动
  • 数据可视化中的动态轨迹
  • 引导式 UI 动画(如教程提示沿路径走)
  • 创意交互动画(如文字沿曲线飞入)

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


总结:motionPath 是 GSAP 中实现“路径动画”的核心工具,让复杂轨迹运动变得简单、流畅且高度可控。

CSS 踩坑笔记:为什么列表底部的 margin-bottom 总是“失效”?

作者 陆枫Larry
2026年2月14日 10:09

在开发移动端列表页(尤其是使用 uni-app 或 Vue 开发小程序)时,我们经常遇到这样一个经典问题:

“明明给列表最后一个元素设置了 margin-bottom: 60rpx,为什么滚动到底部时,它依然紧贴着屏幕边缘?就像这行代码没写一样?”

这是一个困扰过无数前端新手的“灵异现象”。今天我们就来彻底梳理它的成因、背后的原理以及标准的解决方案。


1. 现象复现

假设我们有一个长列表,结构如下:

<view class="container">
  <view class="content">
    <!-- 很多内容 -->
    ...
    <!-- 最后一个按钮 -->
    <view class="submit-btn">提交</view>
  </view>
</view>
.submit-btn {
  margin-bottom: 60rpx; /* 期望按钮下方留出空隙 */
}

结果:页面滚动到底部,.submit-btn 紧贴视口底部,60rpx 的间距凭空消失了。


2. 核心原因

这个问题通常由两个核心 CSS 机制共同导致:

(1) 外边距折叠(Margin Collapse)与穿透

这是最常见的原因。根据 CSS 规范,块级元素的垂直外边距(margin)有时会发生合并(折叠)

如果父容器(.content)没有设置以下属性之一:

  • border(边框)
  • padding(内边距)
  • overflow: hidden/auto(创建 BFC)

那么,最后一个子元素的 margin-bottom 会“穿透”父容器,溢出到父容器外面,变成父容器的外边距。

后果

  • 子元素的 margin 不再撑开父容器的高度。
  • 如果父容器已经是页面最底层的元素,这个溢出的 margin 就相当于推了个寂寞(下面没有其他元素了),所以在视觉上,按钮依然贴底。

(2) 滚动容器的计算机制(Scroll Height)

在某些渲染引擎(特别是 Webkit 内核及部分小程序环境)中,计算 scrollHeight(可滚动高度)时,不会将最后一个子元素的 margin 计算在内

它认为:“内容只到元素的边界(Border Box)为止,外面的 Margin 是空的,不算作‘有效内容’。”

因此,即使 margin 还在那里,浏览器也不会为你提供额外的滚动距离来展示这个 margin。


3. 涉及知识点

  1. CSS 盒模型 (Box Model):理解 Content, Padding, Border, Margin 的区别。
  2. 外边距折叠 (Margin Collapse):CSS 中非常重要的布局规则,尤其是父子元素之间的折叠。
  3. 块格式化上下文 (BFC):如何通过 overflow 等属性创建隔离环境,防止 margin 穿透。
  4. 滚动视口 (Scrollport):浏览器如何计算滚动区域的大小。

4. 解决方法

方案 A:使用 padding-bottom(推荐 ✅)

这是最稳健、最符合逻辑的解法。既然 margin 容易折叠或被忽略,那我们就用 padding。Padding 属于容器内部空间,永远会被计算在高度内。

代码修改

/* 给父容器设置 padding-bottom */
.content {
  /* 加上原本想要的间距 */
  padding-bottom: 60rpx; 
  
  /* 如果有底部安全区需求(如 iPhone X+),还能完美叠加 */
  padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}

/* 子元素的 margin-bottom 可以去掉了 */
.submit-btn {
  margin-bottom: 0;
}

方案 B:给父容器加“墙”(BFC 或 Border)

如果你非要用 margin,可以给父容器加一道“墙”,把 margin 挡在里面,强迫它撑开高度。

.content {
  /* 方法1:加个透明边框 */
  border-bottom: 1px solid transparent; 
  
  /* 或者 方法2:触发 BFC */
  overflow: hidden; 
}

缺点overflow: hidden 可能会裁切掉其他故意溢出的元素(如阴影、弹窗),使用需谨慎。

方案 C:加个空元素垫底(不推荐 ❌)

以前常用的土办法,在列表最后加一个空的 <view style="height: 60rpx"></view>缺点:代码冗余,不仅增加了无语义的 DOM 节点,还不够优雅。


5. 小结

在处理滚动容器(无论是 scroll-view 还是页面级滚动)的底部留白时,请牢记一条黄金法则

“外边距(Margin)是用来推开别人的,内边距(Padding)才是用来撑大自己的。”

当你想让容器底部留出一段空白区域,永远优先选择给容器设置 padding-bottom。它不仅能完美避开 margin 折叠的坑,还能配合 calc(env(safe-area-inset-bottom)) 轻松搞定全面屏适配。

❌
❌