阅读视图

发现新文章,点击刷新页面。

i18n Ally Next:重新定义 VS Code 国际化开发体验

生成关于 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 从编辑页返回后滑动按钮仍显示的问题

一、问题描述

在 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

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 线:一次结构升级的实现过程

在行情面板中加入 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渲染引擎

在之前《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 完美配合

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,是最基础也最必要的技能 (小红书长文也可以用哦)

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、云端协作,开发效率再升级!

本文由体验技术团队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中默认插槽、具名插槽、作用域插槽如何区分与使用?

一、插槽的基本概念

在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

在 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 详解

在前端面试和实际开发中,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

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 总是“失效”?

在开发移动端列表页(尤其是使用 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)) 轻松搞定全面屏适配。

Vite 项目优化分包填坑之依赖多版本冲突问题深度解析与解决方案

在前端开发中,依赖管理看似简单,实则暗藏玄机。最近在使用 Vite + pnpm 构建项目时,遇到了一个典型的多版本依赖冲突问题,值得深入探讨和分享。

问题现象

项目中引入了 ai-agent 这个第三方库后,发现构建上线后出现兼容性问题。经过排查,发现问题与 @vueuse/core 的版本冲突有关。

具体表现为:

  • 项目直接依赖:@vueuse/core@^10.4.1
  • ai-agent 依赖:@vueuse/core@^8.6.0
  • 当在 Vite 配置的 manualChunks 中对 @vueuse/core 进行单独分包时,应用运行异常
  • 注释掉相关分包配置后,问题消失

问题本质分析

npm/pnpm 的依赖解析机制

首先需要理解包管理器如何处理版本冲突:

npm 的嵌套依赖

node_modules/
├── @vueuse/core/           # v10.4.1 (项目依赖)
└── @frontend/
    └── ai-agent/
        └── node_modules/
            └── @vueuse/core/  # v8.6.0 (ai-agent 依赖)

pnpm 的符号链接

node_modules/
├── @vueuse/core → .pnpm/@vueuse+core@10.4.1/...
└── .pnpm/
    └── @frontend+ai-agent@1.0.12/
        └── node_modules/
            └── @vueuse/core → .pnpm/@vueuse+core@8.6.0/...

关键点:两个不同版本的 @vueuse/core 在文件系统中是完全独立的模块

Rollup 的模块处理机制

Rollup 默认情况下会根据完整的模块路径来区分不同的模块实例:

  • 路径 A: /node_modules/.pnpm/@vueuse+core@10.4.1/.../index.js
  • 路径 B: /node_modules/.pnpm/@frontend+ai-agent@1.0.12/.../@vueuse/core/index.js

这样,两个版本的代码会被分别打包,保持作用域隔离,互不干扰。

manualChunks 的陷阱

问题出在 manualChunks 配置:

// 危险的配置
manualChunks(id) {
  if (id.includes("@vueuse/core")) {
    return "vueuse"; // 强制所有 vueuse 模块进入同一 chunk
  }
}

这个配置的问题在于:

  1. 路径匹配过于宽泛:同时匹配到 v8 和 v10 的模块路径
  2. 破坏模块隔离:将两个不同版本的代码强制打包到同一个 chunk 中
  3. 运行时冲突:两个版本的实现可能互相覆盖或产生状态冲突

通过在 manualChunks 中添加日志可以验证:

console.log("vueuse===========", id);
// 输出两次,分别对应两个不同版本的完整路径

解决方案

方案一:移除 manualChunks 配置(推荐)

最简单有效的方案就是不要对存在版本冲突的依赖进行 manualChunks 分包

// 正确的做法:注释掉或删除相关配置
manualChunks(id) {
  if (id.includes("node_modules")) {
    // 不要对 @vueuse/core 进行特殊分包
    // if (id.includes("@vueuse/core")) {
    //   return "vueuse";
    // }
    
    // 其他安全的分包配置
    if (id.includes("axios")) {
      return "axios";
    }
    // ... 其他依赖
  }
}

优势

  • 简单可靠,无需额外配置
  • 保持 Rollup 默认的模块隔离机制
  • 避免人为干预导致的意外冲突

方案二:统一依赖版本

从根本上解决问题,确保整个项目使用同一版本:

使用 pnpm overrides

{
  "pnpm": {
    "overrides": {
      "@vueuse/core": "^10.4.1"
    }
  }
}

注意事项

  • 需要充分测试第三方库的兼容性
  • 可能导致 ai-agent 出现运行时错误
  • 适合对第三方库有控制权的场景

方案三:精确版本区分(不推荐)

理论上可以通过路径中的版本号进行精确区分:

manualChunks(id) {
  if (id.includes("node_modules")) {
    if (id.includes("@vueuse/core") && (id.includes("@8.") || id.includes("@8-"))) {
      return "vueuse-v8";
    }
    if (id.includes("@vueuse/core") && (id.includes("@10.") || id.includes("@10-"))) {
      return "vueuse-v10";
    }
    // ... 其他配置
  }
}

为什么不推荐

  • 路径匹配逻辑脆弱,容易失效
  • 增加维护成本
  • 仍然存在两个版本,只是分开了而已
  • 打包体积更大

经验总结

  1. 谨慎使用 manualChunks:只对版本稳定、无冲突风险的大型依赖进行分包
  2. 理解包管理器机制:npm/pnpm/yarn 在处理版本冲突时的行为差异
  3. 依赖版本一致性:尽量保持项目中核心依赖的版本统一
  4. 测试驱动配置:任何构建配置的修改都需要充分测试验证

这个问题虽然看似简单,但涉及了包管理、模块打包、依赖解析等多个层面的知识。通过深入理解这些机制,我们能够更好地避免类似的"坑",写出更健壮的构建配置。

JavaScript 深拷贝的完全解决方案

当我们说"深拷贝"时,我们到底在说什么?为什么简单的 JSON.parse(JSON.stringify(obj)) 不够用?如何优雅地处理循环引用、特殊对象、函数引用?本文将深入深拷贝的每一个角落,构建一个真正"完全"的深拷贝函数。

前言:一个看似简单的问题

我们先来看一个最简单的深拷贝:

const obj = { name: '张三', age: 25 };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy); // { name: '张三', age: 25 }

但这真的够用吗?如果我们有一个复杂对象:

const complexObj = {
  date: new Date(),
  regex: /test/gi,
  func: () => console.log('hello'),
  undef: undefined,
  inf: Infinity,
  nan: NaN,
  map: new Map([['key', 'value']]),
  set: new Set([1, 2, 3]),
  symbol: Symbol('test'),
  error: new Error('错误')
};

这时候应该如何处理呢?这就是为什么我们需要一个真正完善的深拷贝解决方案。

深拷贝的基础概念

深拷贝 vs 浅拷贝

  • 深拷贝:完全独立的副本,修改拷贝对象的属性值,不会影响原对象
  • 浅拷贝:只复制一层,修改拷贝对象的属性值,会影响原对象

浅拷贝示例

const original = {
  name: '张三',
  address: {
    city: '北京',
    street: '长安街'
  }
};

// 浅拷贝示例
const shallowCopy1 = Object.assign({}, original);
const shallowCopy2 = { ...original };

shallowCopy1.address.city = '上海';
console.log('original.address.city:', original.address.city); // 上海
console.log('shallowCopy1.address.city:', shallowCopy1.address.city); // 上海

深拷贝示例

const original = {
  name: '张三',
  address: {
    city: '北京',
    street: '长安街'
  }
};
function simpleDeepCopy(obj) {
  return JSON.parse(JSON.stringify(obj));
}

const deepCopy = simpleDeepCopy(original);
deepCopy.address.city = '广州';
console.log('original.address.city:', original.address.city); // 北京
console.log('deepCopy.address.city:', deepCopy.address.city); // 广州

为什么要深拷贝?

  • 状态管理要求不可变数据
  • 撤销/重做功能需要保存历史快照
  • 复杂表单的草稿保存
  • 数据处理的隔离环境
  • 跨线程/跨进程通信

深拷贝的挑战

  • 循环引用:对象相互引用导致无限递归
  • 特殊对象:需要保留原型链和构造函数
  • 函数:通常不拷贝,但需要处理
  • Symbol:作为属性名和值的处理
  • 不可枚举属性:需要遍历所有属性描述符
  • 原型链:是否需要继承
  • 性能:大量数据的拷贝效率

JSON 方法的全面评估

JSON 序列化的局限性

  1. undefined 会被忽略
  2. symbol 会被忽略
  3. function 会被忽略
  4. 特殊数值会变成 null
  5. Date / RegExp / Error 等对象会转成字符串
  6. Map / Set / WeakMap / WeakSet 等会变成空对象
  7. TypedArray / ArrayBuffer 会变成对象
  8. 循环引用会导致错误

JSON 方法的适用场景

  • 纯数据对象(只有普通对象、数组、字符串、数字、布尔值)
  • 与后端 API 通信的数据交换
  • 简单的本地存储(localStorage)
  • 不需要保持原对象类型的临时副本
  • 数据结构已知且可控的内部模块

完整深拷贝要考虑的问题

1. 基础功能

  • 支持所有原始类型
  • 支持普通对象和数组
  • 处理循环引用
  • 处理原型链

2. 内置对象

  • Date:保持 Date 对象
  • RegExp:保持正则表达式
  • Map:保持键值对结构
  • Set:保持集合结构
  • Error:保留错误信息
  • Promise:处理状态
  • Symbol:作为值和属性名

3. 二进制数据

  • ArrayBuffer
  • TypedArray 所有类型
  • DataView
  • SharedArrayBuffer

4. 其他特性

  • 支持不可枚举属性
  • 支持属性 getter/setter
  • 支持冻结/密封对象
  • 支持自定义类实例
  • 性能优化

递归实现与循环引用检测

基础递归实现

function basicDeepCopy(source) {
  // 原始类型直接返回
  if (source === null || typeof source !== 'object') {
    return source;
  }

  // 数组或对象
  const target = Array.isArray(source) ? [] : {};

  // 递归复制每个属性
  for (let key in source) {
    if (source.hasOwnProperty(key)) {
      target[key] = basicDeepCopy(source[key]);
    }
  }

  return target;
}

循环引用检测

function deepCopyWithCycleDetection(source, cache = new WeakMap()) {
  // 处理原始类型
  if (source === null || typeof source !== 'object') {
    return source;
  }

  // 检测循环引用
  if (cache.has(source)) {
    console.log('检测到循环引用,返回已缓存的对象');
    return cache.get(source);
  }

  // 创建目标对象
  const target = Array.isArray(source) ? [] : {};

  // 缓存当前对象
  cache.set(source, target);

  // 递归复制属性
  for (let key in source) {
    if (source.hasOwnProperty(key)) {
      target[key] = deepCopyWithCycleDetection(source[key], cache);
    }
  }

  return target;
}

性能优化版本

function optimizedDeepCopy(source, cache = new Map()) {
  // 快速路径:原始类型
  if (source === null || typeof source !== 'object') {
    return source;
  }

  // 快速路径:Date
  if (source instanceof Date) {
    return new Date(source);
  }

  // 快速路径:RegExp
  if (source instanceof RegExp) {
    return new RegExp(source.source, source.flags);
  }

  // 循环引用检测
  if (cache.has(source)) {
    return cache.get(source);
  }

  // 根据类型创建目标对象
  let target;

  if (Array.isArray(source)) {
    target = [];
  } else if (source instanceof Map) {
    target = new Map();
  } else if (source instanceof Set) {
    target = new Set();
  } else if (source instanceof WeakMap || source instanceof WeakSet) {
    // WeakMap/WeakSet 无法遍历,返回新实例
    return new source.constructor();
  } else {
    // 普通对象:使用原对象的构造函数
    target = Object.create(Object.getPrototypeOf(source));
  }

  // 缓存当前对象
  cache.set(source, target);

  // 处理数组
  if (Array.isArray(source)) {
    for (let i = 0; i < source.length; i++) {
      target[i] = optimizedDeepCopy(source[i], cache);
    }
    return target;
  }

  // 处理 Map
  if (source instanceof Map) {
    for (let [key, value] of source) {
      target.set(
        optimizedDeepCopy(key, cache),
        optimizedDeepCopy(value, cache)
      );
    }
    return target;
  }

  // 处理 Set
  if (source instanceof Set) {
    for (let value of source) {
      target.add(optimizedDeepCopy(value, cache));
    }
    return target;
  }

  // 处理普通对象
  const keys = [...Object.keys(source), ...Object.getOwnPropertySymbols(source)];

  for (let key of keys) {
    const descriptor = Object.getOwnPropertyDescriptor(source, key);

    if (descriptor) {
      // 复制属性描述符
      Object.defineProperty(target, key, {
        ...descriptor,
        value: optimizedDeepCopy(descriptor.value, cache)
      });
    }
  }

  return target;
}

内置对象的深拷贝

class BuiltInCopier {
  // Date 对象
  static copyDate(date) {
    return new Date(date.getTime());
  }

  // RegExp 对象
  static copyRegExp(regexp) {
    const flags =
      (regexp.global ? 'g' : '') +
      (regexp.ignoreCase ? 'i' : '') +
      (regexp.multiline ? 'm' : '') +
      (regexp.dotAll ? 's' : '') +
      (regexp.unicode ? 'u' : '') +
      (regexp.sticky ? 'y' : '');

    return new RegExp(regexp.source, flags);
  }

  // Error 对象
  static copyError(error) {
    const copy = new error.constructor(error.message);
    copy.stack = error.stack;
    copy.name = error.name;
    return copy;
  }

  // Map 对象
  static copyMap(map, copyFn) {
    const result = new Map();
    map.forEach((value, key) => {
      result.set(copyFn(key), copyFn(value));
    });
    return result;
  }

  // Set 对象
  static copySet(set, copyFn) {
    const result = new Set();
    set.forEach(value => {
      result.add(copyFn(value));
    });
    return result;
  }

  // WeakMap 对象
  static copyWeakMap(weakMap) {
    // WeakMap 不可遍历,返回空实例
    return new WeakMap();
  }

  // WeakSet 对象
  static copyWeakSet(weakSet) {
    // WeakSet 不可遍历,返回空实例
    return new WeakSet();
  }

  // ArrayBuffer 对象
  static copyArrayBuffer(arrayBuffer) {
    const copy = arrayBuffer.slice(0);
    return copy;
  }

  // TypedArray 对象
  static copyTypedArray(typedArray) {
    return new typedArray.constructor(typedArray);
  }

  // DataView 对象
  static copyDataView(dataView) {
    return new DataView(
      this.copyArrayBuffer(dataView.buffer),
      dataView.byteOffset,
      dataView.byteLength
    );
  }

  // Promise 对象
  static copyPromise(promise) {
    // Promise 无法复制,返回新的 pending Promise
    return new Promise(() => { });
  }
}

处理自定义类和原型链

自定义类的深拷贝

function copyCustomClass(instance, cache = new WeakMap()) {
  if (cache.has(instance)) {
    return cache.get(instance);
  }

  // 获取构造函数
  const Constructor = instance.constructor;

  // 创建新实例
  let copy;

  try {
    // 尝试使用构造函数创建新实例
    copy = Object.create(Constructor.prototype);
    Constructor.apply(copy, []);
  } catch (error) {
    // 如果构造函数需要参数,则使用 Object.create
    copy = Object.create(Constructor.prototype);
  }

  cache.set(instance, copy);

  // 复制所有属性
  const allKeys = Reflect.ownKeys(instance);

  for (const key of allKeys) {
    const descriptor = Object.getOwnPropertyDescriptor(instance, key);

    if (descriptor) {
      if (descriptor.value !== undefined) {
        descriptor.value = comprehensiveDeepCopy(descriptor.value, cache);
      }
      Object.defineProperty(copy, key, descriptor);
    }
  }

  return copy;
}

原型链的完整处理

function deepCopyWithPrototype(source, cache = new WeakMap()) {
  if (source === null || typeof source !== 'object') {
    return source;
  }

  if (cache.has(source)) {
    return cache.get(source);
  }

  let target;

  // 获取完整的原型链
  const getPrototypeChain = (obj) => {
    const chain = [];
    let proto = Object.getPrototypeOf(obj);
    while (proto && proto !== Object.prototype) {
      chain.unshift(proto);
      proto = Object.getPrototypeOf(proto);
    }
    return chain;
  };

  // 重建原型链
  const buildPrototypeChain = (obj, chain) => {
    if (chain.length === 0) {
      return obj;
    }

    let current = obj;
    for (let i = 0; i < chain.length; i++) {
      const proto = chain[i];
      const protoCopy = Object.create(Object.getPrototypeOf(proto));

      // 复制原型上的属性
      const keys = Reflect.ownKeys(proto);
      for (const key of keys) {
        const descriptor = Object.getOwnPropertyDescriptor(proto, key);
        if (descriptor) {
          if (descriptor.value !== undefined) {
            descriptor.value = deepCopyWithPrototype(descriptor.value, cache);
          }
          Object.defineProperty(protoCopy, key, descriptor);
        }
      }

      Object.setPrototypeOf(current, protoCopy);
      current = protoCopy;
    }

    return obj;
  };

  // 处理不同类型
  if (source instanceof Date) {
    target = new Date(source);
  } else if (source instanceof RegExp) {
    target = new RegExp(source);
  } else if (Array.isArray(source)) {
    target = [];
  } else if (source instanceof Map) {
    target = new Map();
  } else if (source instanceof Set) {
    target = new Set();
  } else {
    // 普通对象:先创建空对象,再设置原型链
    target = {};
  }

  cache.set(source, target);

  // 获取并重建原型链
  const protoChain = getPrototypeChain(source);
  buildPrototypeChain(target, protoChain);

  // 复制自身属性
  const allKeys = Reflect.ownKeys(source);
  for (const key of allKeys) {
    const descriptor = Object.getOwnPropertyDescriptor(source, key);
    if (descriptor) {
      if (descriptor.value !== undefined) {
        descriptor.value = deepCopyWithPrototype(descriptor.value, cache);
      }
      Object.defineProperty(target, key, descriptor);
    }
  }

  return target;
}

最终完整解决方案


// 深拷贝配置选项
class DeepCopyOptions {
  constructor({
    copySymbols = true,
    copyNonEnumerables = true,
    preservePrototype = true,
    copyFunctions = false,
    copyWeakCollections = false,
    maxDepth = Infinity,
    onError = (error, key, value) => console.warn(`拷贝 ${key} 时出错:`, error)
  } = {}) {
    this.copySymbols = copySymbols;
    this.copyNonEnumerables = copyNonEnumerables;
    this.preservePrototype = preservePrototype;
    this.copyFunctions = copyFunctions;
    this.copyWeakCollections = copyWeakCollections;
    this.maxDepth = maxDepth;
    this.onError = onError;
  }
}

// 最终版深拷贝
function cloneDeep(source, options = new DeepCopyOptions(), depth = 0, cache = new WeakMap()) {
  // 深度限制
  if (depth >= options.maxDepth) {
    return source;
  }

  // 处理原始类型
  if (source === null || typeof source !== 'object') {
    // 处理函数(可选)
    if (typeof source === 'function' && options.copyFunctions) {
      // 简单的函数复制,不保证完全等价
      return new Function('return ' + source.toString())();
    }
    return source;
  }

  // 循环引用检测
  if (cache.has(source)) {
    return cache.get(source);
  }

  let target;

  try {
    // 根据类型创建目标对象
    const constructor = source.constructor;

    // 内置对象处理
    switch (constructor) {
      case Date:
        target = new Date(source);
        break;

      case RegExp:
        target = new RegExp(source.source, source.flags);
        target.lastIndex = source.lastIndex;
        break;

      case Error:
        target = new source.constructor(source.message);
        target.stack = source.stack;
        target.name = source.name;
        break;

      case Map:
        target = new Map();
        cache.set(source, target);
        source.forEach((value, key) => {
          target.set(
            cloneDeep(key, options, depth + 1, cache),
            cloneDeep(value, options, depth + 1, cache)
          );
        });
        return target;

      case Set:
        target = new Set();
        cache.set(source, target);
        source.forEach(value => {
          target.add(cloneDeep(value, options, depth + 1, cache));
        });
        return target;

      case WeakMap:
        target = options.copyWeakCollections ? new WeakMap() : source;
        cache.set(source, target);
        return target;

      case WeakSet:
        target = options.copyWeakCollections ? new WeakSet() : source;
        cache.set(source, target);
        return target;

      case ArrayBuffer:
        target = source.slice(0);
        break;

      case DataView:
        target = new DataView(
          cloneDeep(source.buffer, options, depth + 1, cache),
          source.byteOffset,
          source.byteLength
        );
        break;

      default:
        // 检查 TypedArray
        if (ArrayBuffer.isView(source) && !(source instanceof DataView)) {
          target = new constructor(
            cloneDeep(source.buffer, options, depth + 1, cache),
            source.byteOffset,
            source.length
          );
          break;
        }

        // 普通对象或数组
        if (constructor === Object || constructor === Array) {
          target = Array.isArray(source) ? [] : {};
        } else if (options.preservePrototype) {
          // 保持原型链
          target = Object.create(constructor.prototype);
        } else {
          target = {};
        }
        break;
    }
  } catch (error) {
    options.onError(error, 'constructor', source);
    target = Array.isArray(source) ? [] : {};
  }

  // 缓存当前对象
  cache.set(source, target);

  // 处理数组
  if (Array.isArray(source)) {
    for (let i = 0; i < source.length; i++) {
      try {
        target[i] = cloneDeep(source[i], options, depth + 1, cache);
      } catch (error) {
        options.onError(error, i, source[i]);
        target[i] = undefined;
      }
    }
    return target;
  }

  // 获取所有属性键
  const allKeys = [];

  if (options.copySymbols) {
    allKeys.push(...Object.getOwnPropertySymbols(source));
  }

  if (options.copyNonEnumerables) {
    allKeys.push(...Object.getOwnPropertyNames(source));
  } else {
    allKeys.push(...Object.keys(source));
  }

  // 复制属性
  for (const key of allKeys) {
    try {
      const descriptor = Object.getOwnPropertyDescriptor(source, key);

      if (descriptor) {
        if (descriptor.value !== undefined) {
          descriptor.value = cloneDeep(descriptor.value, options, depth + 1, cache);
        }
        Object.defineProperty(target, key, descriptor);
      }
    } catch (error) {
      options.onError(error, key, source[key]);
    }
  }

  return target;
}

// 便利函数
const deepClone = {
  // 快速克隆(适用于大多数场景)
  quick: (obj) => cloneDeep(obj),

  // 完整克隆(保留所有特性)
  full: (obj) => cloneDeep(obj, new DeepCopyOptions({
    copySymbols: true,
    copyNonEnumerables: true,
    preservePrototype: true,
    copyFunctions: false,
    copyWeakCollections: false
  })),

  // 严格克隆(尽可能完整)
  strict: (obj) => cloneDeep(obj, new DeepCopyOptions({
    copySymbols: true,
    copyNonEnumerables: true,
    preservePrototype: true,
    copyFunctions: true,
    copyWeakCollections: true
  })),

  // 数据克隆(只保留可序列化的数据)
  data: (obj) => cloneDeep(obj, new DeepCopyOptions({
    copySymbols: false,
    copyNonEnumerables: false,
    preservePrototype: false,
    copyFunctions: false,
    copyWeakCollections: false
  }))
};

结语

最好的深拷贝是不需要深拷贝。通过良好的架构设计、使用不可变数据、避免深层嵌套等方式,可以减少对深拷贝的需求。当必须使用时,选择合适的实现方案,既满足需求又不过度设计。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

单点登录(SSO)在前端世界的落地形态

上一章我们聊了 OAuth2 与第三方登录的三个阶段:从 Implicit Flow 的混乱时代,到 PKCE 的安全崛起,再到 OAuth 2.1 + 一键登录的无感体验。但 OAuth/OIDC 主要解决的是“授权 + 身份认证”,在企业内部多系统间实现“一次登录、处处可用”的真正 SSO 时,前端还需要面对更复杂的落地挑战:跨域、跨顶级域、微前端、浏览器隐私策略变化等。

这一篇,我们从前端视角拆解 SSO 的主流落地形态,重点对比三种核心实现方式,并讨论 2024–2026 年浏览器变化(第三方 Cookie 逐步淘汰)带来的冲击与应对。

1. SSO 在前端的核心职责与挑战(2026 年视角)

前端在 SSO 中的真实角色:

  • 检测登录状态(silent check)
  • 无感跳转 / 刷新 token
  • 跨应用同步登录/登出状态
  • 处理跨域(子域 / 不同顶级域)
  • 兼容隐私沙盒(Chrome Partitioned Cookies、Storage Partitioning)

2025–2026 年最大变化:

  • 第三方 Cookie 基本被禁用(Chrome 100% rollout)
  • Storage Partitioning(不同顶级域的 localStorage 分区)
  • iframe + postMessage 方案受限(但仍可部分工作)

因此,纯 Cookie 共享 → 纯 Token 集中 → 混合 / BFF 模式 成为主流演进路径。

2. 三种主流前端 SSO 落地方式对比(2024–2026 现状)

实现方式 适用场景 跨域支持 依赖第三方 Cookie 浏览器兼容性(2026) 安全性 复杂度 代表方案 / 协议 当前流行度
基于 Cookie 的域共享 子域 SSO(*.company.com) 子域 / 同顶级域 是(顶级域 Cookie) 高(SameSite=None+Secure) 中–高 CAS、SAML、OIDC Cookie 模式 ★★★☆☆
基于 Token 的集中式认证 跨顶级域、多 SPA、微前端 任意域 最高(无 Cookie 依赖) 中–高 OIDC + PKCE + Refresh Token ★★★★★
iframe + postMessage 通信 遗留系统、临时桥接 跨域 部分(或无) 中(分区 + 限制) 中–低 早期 CAS、Zendesk cross-storage ★☆☆☆☆

方式一:基于 Cookie 的域共享(最传统、最简单)

适用:所有应用在同一顶级域下(如 app1.company.com、app2.company.com、sso.company.com)

核心机制:

  • SSO 服务器 Set-Cookie 时设置 domain=.company.com; Secure; HttpOnly; SameSite=Lax/None
  • 浏览器在所有子域自动携带该 Cookie
  • 前端几乎无感:只需检查 Cookie 或调用 /userinfo 接口

优点:浏览器原生、无需前端代码干预、登出可直接删 Cookie

缺点:

  • 仅限子域(跨顶级域失效)
  • 第三方 Cookie 限制下需 SameSite=None; Secure + 用户许可
  • 不适合微前端 / 多顶级域场景

2026 年现状:企业内网、传统 ToB 系统仍大量使用,但新项目已转向 Token 模式。

方式二:基于 Token 的集中式认证(目前最推荐、最主流)

适用:跨顶级域、多前端(React/Vue/Next.js + 微前端)、移动 + Web 混合

核心流程(OIDC + Authorization Code + PKCE + Refresh Token):

  1. 用户访问任意前端 → 未登录 → 重定向到 SSO 中心(/authorize
  2. SSO 中心登录成功 → 返回 code → 前端(或 BFF)用 PKCE 换 token(access_token + id_token + refresh_token)
  3. 前端存储 refresh_token(HttpOnly Cookie 或 secure storage),access_token 放内存 / localStorage(短效)
  4. 所有前端共享同一 SSO 中心 → 登录一次,后续 silent renew(iframe 或 refresh token)
  5. 登出:调用 /logout + 清本地 token + 通知其他 tab(BroadcastChannel / localStorage 事件)

前端关键实现点:

  • Silent authentication:hidden iframe 打开 authorize endpoint(check session)
  • Refresh:用 refresh_token 静默换新 access_token
  • 多应用同步:BroadcastChannel 或 Service Worker 监听登录/登出事件

代表方案:

  • Auth0 / Okta / Clerk / Supabase Auth / Keycloak(OIDC 模式)
  • NextAuth / Lucia + OIDC provider
  • 自建:oidc-client-ts / @auth0/auth0-spa-js

2026 年优势:

  • 无第三方 Cookie 依赖
  • 支持跨顶级域
  • 与微前端兼容(各子应用独立管理 token,但共享 SSO 会话)

痛点:

  • 前端需处理 token 刷新、silent renew、登出广播
  • refresh_token 安全存储(推荐 BFF 或 HttpOnly Cookie)

方式三:iframe + postMessage(逐渐被淘汰的过渡方案)

早期流行于跨域 SSO(不同顶级域),典型库:cross-storage、pym.js

机制:

  • 主应用嵌入 hidden iframe 指向 SSO 域
  • iframe 内登录 → localStorage 写 token
  • postMessage 通知父窗口 → 父窗口读取

2023–2025 年后问题:

  • Storage Partitioning(Chrome 等)让跨顶级域 localStorage 隔离
  • iframe sandbox 限制 + 第三方 Cookie 禁用
  • 性能差、SEO 问题、用户体验差

2026 年现状:仅遗留系统或极特殊场景使用,新项目已弃用。

3. 微前端 / 多 SPA 下的 SSO 特殊痛点与解决方案

微前端(qiankun、Module Federation、single-spa)常见场景:

  • 不同子应用可能不同框架、不同构建
  • 需要统一登录状态

解决方案(2025–2026 推荐):

  1. 统一 SSO 中心 + Token 模式:所有子应用用同一 OIDC Client ID,共享 refresh_token(通过主应用分发或 BFF)
  2. 主应用代理登录:基座应用负责 silent check 和 token 管理,子应用通过 props / 事件总线获取状态
  3. BroadcastChannel + localStorage 事件:登录/登出时广播,子应用监听同步
  4. BFF(Backend for Frontend):每个子应用有独立 BFF,BFF 持 refresh_token,前端只拿短效 access_token

4. 2026 年 SSO 前端 Checklist(实用建议)

  • 优先选 OIDC + PKCE + Refresh Token Rotation
  • 避免依赖第三方 Cookie(除非子域 + SameSite=None)
  • 使用成熟 SDK(oidc-client-ts、@auth0/auth0-spa-js、next-auth)
  • Silent renew 用 refresh_token 而非 iframe(更可靠)
  • 登出需调用 end_session_endpoint + 清本地 + 广播
  • 高安全场景用 BFF 模式(token 永不出现在浏览器 JS)
  • 测试隐私沙盒:Chrome Incognito + 第三方 Cookie 禁用

小结 & 过渡

前端 SSO 从 Cookie 域共享 → iframe 桥接 → Token 集中式(OIDC 主导)的演进,本质上是适应浏览器隐私保护 + 跨域需求的过程。

2026 年,基于 OIDC + Refresh Token 的集中式认证 是最主流、最可靠的落地形态,尤其适合现代 Web / 微前端 / 跨域场景。

OAuth2 与第三方登录的三个阶段(2010–至今)

上一章我们聊了 Token 时代的巅峰与隐痛:双 Token、刷新机制、黑名单战争,以及各种安全加固手段。但在第三方登录(Social Login、第三方授权)领域,OAuth2 的演进路径更独立,也更戏剧化。

OAuth2 从 2010 年左右开始大规模落地,到 2025–2026 年已进入 OAuth 2.1 时代。前端在其中的角色从“被动跳转 + 解析 URL fragment”到“主动管理 PKCE + 安全刷新”,发生了翻天覆地的变化。

这一篇,我们按时间和技术范式把 OAuth2 + 第三方登录分为三个主要阶段。

1. 第一阶段:早期混乱与 Implicit Flow 主导(2010–2016 左右)

OAuth 1.0(2007–2010)太复杂,OAuth 2.0(RFC 6749,2012 年正式发布)简化了授权框架,但早期实现五花八门。

典型第三方登录流程(Google、Facebook、Twitter 等 2010–2014 年):

  • Implicit Flow(response_type=token)最流行,尤其在 SPA 和早期移动 Web
  • 前端直接发起跳转:https://accounts.google.com/o/oauth2/auth?client_id=xxx&redirect_uri=yyy&response_type=token&scope=profile email
  • 用户同意后,授权服务器重定向回 redirect_uri#access_token=xxx&expires_in=3600
  • 前端解析 URL fragment(location.hash),拿到 access_token

为什么 Implicit Flow 这么火?

  • 当时浏览器跨域限制严格(CORS 不完善,XMLHttpRequest POST 到 token endpoint 跨域困难)
  • 前端无法安全存储 client_secret(public client)
  • 简单:不用后端参与 token 交换

前端典型代码(2012–2015 年 jQuery/AngularJS 时代):

// 登录按钮点击
window.location.href = `https://accounts.google.com/o/oauth2/auth?...&response_type=token`;

// 回调页(或单页 hashchange 监听)
function handleCallback() {
  const hash = window.location.hash.substring(1);
  const params = new URLSearchParams(hash);
  const token = params.get('access_token');
  if (token) {
    localStorage.setItem('google_token', token);
    // 用 token 调用 /userinfo 或 API
  }
}

痛点与安全隐患

  • Token 暴露在 URL(浏览器历史、referer、日志、肩窥攻击)
  • 无法安全用 refresh_token(规范不推荐)
  • XSS 风险极高(token 在 JS 可读)
  • 2015–2016 年 OAuth 安全最佳实践文档开始警告 Implicit Flow

这个阶段国内微信、QQ、新浪微博登录也大量用类似“跳转 + callback 带 code/token”模式。

2. 第二阶段:Authorization Code + PKCE 的崛起与 Implicit 的逐步废弃(2016–2022 左右)

2015–2016 年,浏览器 CORS 完善 + XMLHttpRequest/Fetch 支持跨域 POST,技术条件成熟。

关键转折:

  • 2015 年:RFC 7636 PKCE(Proof Key for Code Exchange)发布,专为 public client(SPA、移动端)设计
  • 2017–2019 年:OAuth Security BCP(Best Current Practice)草案强烈推荐 Authorization Code + PKCE,视 Implicit 为 deprecated
  • 2019 年:Okta、Auth0 等大厂公开宣布“Implicit Flow 已死”
  • 2020 年后:Chrome/Firefox 等浏览器加强 URL fragment 保护 + 第三方 Cookie 限制,Implicit 更难用

现代标准流程(Authorization Code + PKCE)

  1. 前端生成 code_verifier(随机高熵字符串) + code_challenge = BASE64URL(SHA256(verifier))
  2. 跳转授权:response_type=code&code_challenge=xxx&code_challenge_method=S256
  3. 用户同意 → 重定向回 redirect_uri?code=yyy
  4. 前端(或后端代理)用 code + verifier POST 到 token endpoint 换 token

前端示例(现代 React/Vue/Next.js + oidc-client-js 或 AppAuth 库):

// 使用 @auth0/auth0-spa-js 或类似库
const auth0 = createAuth0Client({
  domain: 'xxx.auth0.com',
  clientId: 'your_client_id',
  redirectUri: window.location.origin,
  useRefreshTokens: true,  // 支持安全 refresh
});

// 登录
await auth0.loginWithRedirect({
  authorizationParams: {
    scope: 'openid profile email',
    // PKCE 自动处理
  }
});

// 回调处理(自动)
const user = await auth0.getUser();

为什么 PKCE 更好?

  • Token 从不走 URL(防泄露)
  • Code 即使被截获,攻击者无 verifier 无法换 token
  • 支持 refresh_token(带 rotation 更安全)
  • 前端角色:管理 PKCE 参数、silent refresh(iframe 或 refresh token)

这个阶段 OIDC(OpenID Connect,2014 RFC)全面普及:返回 id_token(JWT 格式身份令牌)+ access_token,前端可直接解析用户信息而无需再调 userinfo endpoint。

国内:微信/支付宝/抖音等逐步支持 PKCE 或后端代理模式。

3. 第三阶段:OAuth 2.1 时代 + 一键登录 / 无感体验(2023–至今,2026 年现状)

OAuth 2.1(draft 持续迭代,至 2025 年 10 月最新 draft-14,预计很快 RFC)正式固化最佳实践:

  • 完全移除 Implicit Flow
  • Authorization Code 强制要求 PKCE(所有 client 类型,无例外)
  • 移除 ROPC(Resource Owner Password Credentials,密码直传 grant,已废弃)
  • 强制 exact redirect_uri 匹配、更严格参数校验
  • 推荐 refresh token rotation + sender-constrained tokens

前端变化:

  • 几乎所有主流 SDK(如 Google Identity Services、Apple Sign in JS、Auth0、Clerk、Supabase Auth)默认 PKCE + OIDC
  • 一键登录普及:Google One Tap、Apple Sign in with Apple、微信一键登录(运营商取号/静默授权)
  • Popup / Redirect 混合:早期 popup 窗口常见,现在 redirect + state 参数防 CSRF 更安全
  • 移动端 / Hybrid:AppAuth-iOS/Android + WebView 统一用 Code + PKCE
  • 国内特色:手机号一键登录(本机号码识别)+ 微信/支付宝生态闭环

典型现代前端接入(2025–2026):

  • 用库处理一切:oidc-client-ts、@okta/okta-auth-js、next-auth 等
  • 支持 silent authentication(hidden iframe renew)
  • Passkey/FIDO2 作为备用(下一章无密码主题)

OAuth 2.1 影响(2025–2026 已大量落地):

  • 旧 Implicit 项目必须迁移(许多 SaaS 2024–2025 年强制下线 Implicit 支持)
  • 前端复杂度略升(需处理 PKCE),但库屏蔽了细节
  • 安全性大幅提升:token 泄露窗口缩小、可主动 revoke

小结 & 过渡

OAuth2 + 第三方登录的三个阶段总结:

阶段 时间 主导 Flow 前端角色变化 安全水平 当前状态(2026)
第一阶段 2010–2016 Implicit Flow 跳转 + 解析 URL fragment 已废弃
第二阶段 2016–2022 Auth Code + PKCE 管理 PKCE + token 刷新 中–高 主流
第三阶段 2023–至今 OAuth 2.1 强制 PKCE 一键/无感 + OIDC 身份解析 标准 & 强制趋势

OAuth2 让前端从“被动接收 token”进化到“主动、安全地管理授权流程”。但第三方登录终究是“授权”而非“认证”——真正补全身份语义的是 OpenID Connect。

Flutter 顶部滚动行为限制实现:NoTopOverScrollPhysics

一、需求来源

最近需要实现当列表页面嵌套在 showModalBottomSheet 容器里,禁用下拉越界的需求(因为列表会和 showModalBottomSheet)一起向下滚动,极其诡异。就需要列表顶部禁用滚动,上拉加载更多仍在的行为。

二、使用示例

physics: const NoTopOverScrollPhysics(),

三、源码

//
//  NoTopOverScrollPhysics.dart
//  flutter_templet_project
//
//  Created by shang on 2026/1/16 12:17.
//  Copyright © 2026/1/16 shang. All rights reserved.
//

import 'package:flutter/material.dart';

class NoTopOverScrollPhysics extends ScrollPhysics {
  const NoTopOverScrollPhysics({super.parent});

  @override
  NoTopOverScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return NoTopOverScrollPhysics(parent: buildParent(ancestor));
  }

  /// ① 用户拖动阶段(最关键)
  @override
  double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
    // 在顶部 && 继续向下拖
    if (position.pixels <= position.minScrollExtent && offset > 0) {
      return offset; // 不消费,让手势传给上层
    }
    return super.applyPhysicsToUserOffset(position, offset);
  }

  /// ② 防止惯性或 ballistic 把位置拉到负值
  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    // 顶部越界
    if (value < position.minScrollExtent) {
      return value - position.minScrollExtent;
    }
    return super.applyBoundaryConditions(position, value);
  }

  /// ③ 明确声明:允许用户滚动(否则 BottomSheet 会抢)
  @override
  bool shouldAcceptUserOffset(ScrollMetrics position) => true;
}

四、ScrollPhysics知识总结

ScrollPhysics.png

一、ScrollPhysics 决定滚动视图“怎么滚”,包括:

-   能不能滚
-   怎么回弹
-   惯性有多大
-   边界怎么处理

它是 ScrollView 的“物理引擎”

二、ScrollPhysics 在哪生效?

所有使用 Scrollable 的组件:

  • ListView
  • GridView
  • PageView
  • CustomScrollView
  • SingleChildScrollView

二、设置入口:

  physics: const BouncingScrollPhysics(),

三、核心设计:Physics 链(极其重要)

BouncingScrollPhysics(
  parent: AlwaysScrollableScrollPhysics(),
)

设计思想

  • 责任链模式
  • 子 physics 不处理 → 交给 parent

内部调用顺序(简化)

User Drag
  ↓
applyPhysicsToUserOffset
  ↓
applyBoundaryConditions
  ↓
createBallisticSimulation

四、最常见内置 ScrollPhysics(必须掌握)

1️⃣ ClampingScrollPhysics(Android 默认)

  • 到边界直接“卡住”
  • 无回弹
ListView(physics: const ClampingScrollPhysics());

2️⃣ BouncingScrollPhysics(iOS 默认)

  • 可超出边界
  • 松手回弹
ListView(physics: const BouncingScrollPhysics());

3️⃣ NeverScrollableScrollPhysics

  • 完全禁止滚动
ListView(physics: const NeverScrollableScrollPhysics());

4️⃣ AlwaysScrollableScrollPhysics

  • 即使内容不足一屏也能滚
  • 下拉刷新必备

5️⃣ PageScrollPhysics

  • 专用于 PageView
  • 强制一页一页吸附

五、几个关键方法(理解这些 = 会自定义)

applyPhysicsToUserOffset

手指拖动时的位移修正

@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
  return offset * 0.5; // 阻尼
}

applyBoundaryConditions

是否允许越界

@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
  if (value < position.minScrollExtent) {
    return value - position.minScrollExtent;
  }
  return 0;
}

返回值:

  • 0 → 允许
  • ≠0 → 拒绝(消耗掉)

createBallisticSimulation

松手后的惯性 / 回弹

@override
Simulation? createBallisticSimulation(
  ScrollMetrics position,
  double velocity,
) {
  if (velocity.abs() < tolerance.velocity) return null;
  return ClampingScrollSimulation(...);
}

最后、总结

ScrollPhysics 是滚动知识体系的一部分,已经属于比较复杂的内容。各种滚动式行为自定义需要大量的实践,去细细体会不同设置的区别。现官方提供的几种基本已经能覆盖90%的开发场景。

github

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

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

描述

CustomDiffuse节点是Unity URP Shader Graph中一个功能强大的光照计算节点,专门用于实现用户自定义的固有色光照效果。该节点为着色器开发者提供了高度灵活的光照控制能力,允许用户基于物理的渲染原则或艺术化的视觉需求来定义材质的漫反射行为。在实时渲染管线中,漫反射光照是表面着色的基础组成部分,它决定了材质在直接光照下的基本外观特征。

CustomDiffuse节点的核心价值在于其可定制性。与标准的Lambert或Oren-Nayar漫反射模型不同,这个节点不强制使用特定的光照算法,而是将光照计算的各个要素作为输入端口开放给用户。这种设计理念使得开发者能够根据项目特定的视觉风格或性能要求,实现从简单的N·L点积计算到复杂的自定义BRDF模型。

在实际应用场景中,CustomDiffuse节点特别适合那些需要特殊材质表现的场合。比如在风格化渲染中,艺术家可能希望实现非真实感的漫反射过渡,或者在特定类型的表面(如丝绸、绒毛等)上实现物理准确的散射效果。通过组合不同的输入数据和自定义计算逻辑,开发者可以精确控制光线与材质表面的交互方式。

该节点的另一个重要特性是其与URP渲染管线的深度集成。它能够正确处理URP中的多光源设置、光照衰减和阴影信息,确保自定义的漫反射计算能够与引擎的其他渲染组件协同工作。这种集成保证了即使在复杂的场景光照条件下,自定义的漫反射效果也能保持视觉一致性和性能稳定性。

端口

输入端口详解

Diffuse输入端口接收Vector 3类型的数据,代表材质的基础固有色信息。这个端口通常连接到材质的Albedo纹理或基础颜色属性。在物理渲染上下文中,Diffuse输入应该表示材质表面对漫反射光的反射率系数,其数值范围通常在0到1之间。对于高质量的渲染结果,建议使用线性空间颜色值,并确保颜色值符合能量守恒原则。

Light Color输入端口提供灯光本身的颜色信息,这是实现准确色彩再现的关键要素。在URP中,不同类型的灯光(方向光、点光源、聚光灯)都会提供其颜色和强度信息。开发者可以利用这个端口实现各种创意效果,比如通过修改灯光颜色来模拟特殊的光照环境,或者根据表面特性对灯光颜色进行过滤处理。

Light Attenuation端口处理光照的衰减和阴影信息,这是实现真实光照效果的重要组成部分。该输入通常来自Shader Graph中的光照衰减节点,包含了距离衰减、角度衰减以及实时阴影数据。对于高级用法,开发者可以结合Shadowmask和光照探针数据来实现更复杂的光照交互效果。

Normal WS端口要求世界空间下的法线向量输入,这是计算光照方向性的基础。正确的法线数据对于任何基于物理的光照模型都至关重要。在实际使用中,法线信息可以来自顶点法线、法线贴图,或者是通过自定义计算生成的修改法线。确保法线向量为单位长度是获得准确光照结果的必要前提。

Light Direction WS端口提供从表面点到光源的方向向量,同样在世界空间下表示。这个向量通常通过标准化处理,并且指向光源的方向。在多点光源场景中,需要为每个光源分别计算其方向向量。对于方向光,这个方向是恒定的;而对于点光源和聚光灯,则需要基于片元位置实时计算。

输出端口特性

Out输出端口生成最终的自定义漫反射照明结果,以Vector 3形式表示RGB颜色值。这个输出可以直接用于后续的光照计算,或者与其他光照组件(如高光反射、环境光等)进行混合。输出的颜色值应该保持在合理的范围内,避免出现HDR效果,除非后续有适当的色调映射处理。

端口交互与数据流

理解这些端口之间的数据流关系对于有效使用CustomDiffuse节点至关重要。典型的数据处理流程开始于Diffuse和Light Color的乘法组合,这建立了基础的色彩响应。接着通过法线和光照方向的点积计算获得基础的漫反射强度,再结合光照衰减因子来模拟距离和阴影的影响。

在实际的着色器构建过程中,这些端口的连接顺序和数据处理方式可以根据需求灵活调整。例如,在某些卡通渲染风格中,可能会在计算N·L点积后添加一个步进函数来创建硬边缘的阴影过渡。而在追求物理准确性的场景中,则可能使用更复杂的函数来模拟表面粗糙度对漫反射的影响。

核心算法原理

基础光照模型

CustomDiffuse节点的默认行为基于经典的Lambertian漫反射模型,这是计算机图形学中最基础且广泛应用的光照模型之一。Lambert模型的核心理念是表面反射的光线强度与入射光线方向和表面法线夹角的余弦值成正比。数学表达式为:Diffuse = Albedo × LightColor × max(0, N·L),其中N·L表示法向量与光照方向向量的点积。

这个简单的模型虽然物理上不够精确,但在实时渲染中因其计算效率和直观性而被广泛使用。它假设表面是理想的漫反射体,在各个观察方向上呈现相同的亮度。在实际实现中,max(0, N·L)操作确保了当光线从表面后方照射时不会产生负值光照,这是符合物理直觉的约束。

高级漫反射模型

对于需要更高质量渲染效果的项目,CustomDiffuse节点可以扩展实现更先进的漫反射模型。Oren-Nayar模型是一个著名的改进,它考虑了表面粗糙度对漫反射的影响。与Lambert模型不同,Oren-Nayar不假设表面是完美漫反射体,而是通过粗糙度参数模拟微表面细节对光线的散射效应。

另一个值得关注的模型是Disney principled BRDF中的漫反射组件,它结合了多种散射效应以提供更加物理准确的结果。这种模型通常包含次表面散射的近似模拟,能够更好地表现诸如布料、皮肤等特殊材质的视觉特性。

能量守恒考虑

在实现自定义漫反射模型时,能量守恒是一个重要的物理原则。它要求表面反射的光线总能量不能超过入射光线的能量。在着色器设计中,这意味着漫反射、镜面反射和其他光能传输组件的总和应当合理约束。通过CustomDiffuse节点,开发者可以精确控制漫反射组件的能量分配,确保渲染结果的物理合理性。

实际应用示例

基础Lambert漫反射实现

创建一个基础的Lambert漫反射效果是理解CustomDiffuse节点用法的理想起点。首先需要在Shader Graph中创建相应的节点网络:

  • 将Albedo纹理或颜色属性连接到Diffuse输入端口
  • 使用URP中的Main Light节点获取主光源的颜色和方向信息
  • 通过Transform节点将物体空间法线转换到世界空间
  • 计算法线与光照方向的点积,并使用Saturate节点限制结果在0-1范围内
  • 将点积结果与光源颜色和Albedo颜色相乘,得到基础的漫反射输出

这种实现方式虽然简单,但已经能够为大多数实体材质提供可信的漫反射效果。它是许多游戏和交互应用中漫反射计算的基础。

风格化卡通渲染

在非真实感渲染中,CustomDiffuse节点可以创造出各种艺术化的光照效果。卡通渲染通常特征化地使用硬阴影边界和有限的颜色过渡。实现这种效果的关键在于对N·L点积结果进行离散化处理:

  • 使用Remap节点调整点积的范围和分布
  • 通过Posterize节点或自定义的步进函数创建离散的光照级别
  • 可以添加边缘光效果,通过在法线与视角方向接近垂直时添加额外的光照项
  • 结合阴影色阶,使用多个CustomDiffuse节点分别处理不同光照区域的颜色

这种技术广泛应用于动漫风格的游戏和媒体作品中,能够创造出鲜明、富有表现力的视觉风格。

布料和毛发特殊材质

某些材质类型需要特殊的漫反射处理来准确表现其视觉特性。布料材质通常表现出逆向的反射特性——当光照方向与观察方向相反时反而显得更亮。这种效果可以通过在CustomDiffuse节点中实现Wrap Lighting模型来实现:

  • 修改标准的N·L计算,添加一个偏移量:diffuse = saturate((N·L + w) / (1 + w))
  • 其中w参数控制包裹效果的强度,典型值在0到1之间
  • 对于绒毛材质,可以使用sheen项模拟边缘处的背光散射效果

这些高级用法展示了CustomDiffuse节点在实现特定材质特性时的灵活性和强大功能。

性能优化建议

计算复杂度管理

在使用CustomDiffuse节点实现复杂光照模型时,需要注意计算性能的平衡。实时渲染对着色器的计算效率有严格要求,特别是在移动平台或VR应用中。以下是一些优化建议:

  • 尽可能使用最简单的光照模型满足视觉需求
  • 避免在CustomDiffuse计算中使用复杂的数学函数如sin、pow等
  • 考虑使用近似计算代替精确但昂贵的运算
  • 对于静态物体,可以考虑将部分光照信息烘焙到光照贴图中

平台特定优化

不同硬件平台对着色器计算的能力和限制各不相同。在针对多平台开发时,需要特别关注:

  • 移动平台通常对分支语句和复杂纹理查询更加敏感
  • 在性能受限的情况下,可以考虑使用更低的计算精度(half代替float)
  • 某些平台可能对特定类型的数学运算有硬件加速,可以优先使用这些运算

光照模型简化策略

当项目面临性能压力时,可以考虑以下简化策略:

  • 使用预计算的查找纹理(LUT)替代实时复杂计算
  • 将部分每像素计算转移到每顶点计算
  • 在远距离或小尺寸物体上使用简化的光照模型
  • 利用URP的着色器变体功能,为不同质量设置提供不同复杂度的实现

常见问题与解决方案

光照不一致问题

在使用CustomDiffuse节点时,可能会遇到不同光源条件下光照效果不一致的问题。这通常是由于没有正确处理多光源环境或光照空间转换错误导致的:

  • 确保所有向量计算在相同的坐标空间中进行(通常推荐世界空间)
  • 检查法线向量的长度是否为单位长度,非单位法线会导致错误的光照计算
  • 验证光照方向向量是否正确指向光源,对于点光源需要基于片元位置计算方向

阴影衔接问题

自定义漫反射模型与URP阴影系统的集成可能会产生视觉瑕疵,特别是在阴影边界处:

  • 确保Light Attenuation输入正确包含了阴影信息
  • 在自定义模型中考虑阴影柔和度与漫反射过渡的协调性
  • 可以使用阴影颜色调制来改善阴影区域的艺术表现

HDR和颜色管理

在高动态范围渲染中,CustomDiffuse节点的输出可能需要特殊处理:

  • 注意颜色值范围,避免在未经色调映射的情况下输出HDR值
  • 在线性颜色空间下进行所有光照计算,确保物理准确性
  • 对于特别明亮的光源,可能需要单独处理以避免颜色过饱和

高级技巧与创意应用

动态材质效果

CustomDiffuse节点不仅可以处理静态光照计算,还可以实现各种动态效果:

  • 基于时间或顶点位置调制漫反射颜色,创建动态变化的表面外观
  • 结合噪声纹理模拟表面污染、磨损等随时间变化的效果
  • 使用世界空间坐标实现与场景位置相关的材质变化

非真实感渲染技术

除了传统的真实感渲染,CustomDiffuse节点在NPR领域也有广泛应用:

  • 实现水墨画风格的渐变控制,通过自定义的过渡函数
  • 创建素描效果,使用hatching纹理基于光照强度进行混合
  • 模拟油画笔触,结合噪声和方向性光照响应

特殊场景应用

在某些特定类型的场景中,CustomDiffuse节点可以提供针对性的解决方案:

  • 在水下环境中模拟光线的吸收和散射效应
  • 在雾霭场景中实现距离相关的颜色衰减
  • 为雪地或沙漠等高反射环境创建特殊的光照响应

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

❌