阅读视图

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

【LangChain.js学习】大模型分类

大模型按功能和使用场景可分为三大核心类型,以下结合 LangChain.js 调用通义千问的实操代码,分别讲解各类模型的定位、用法和适用场景:

模型分类 核心定位 典型能力 适用场景
大语言模型(LLM) 基础文本生成引擎 文本补全、续写、单一问答 简单文本生成、基础问答
对话模型(Chat Model) 多轮交互式对话引擎 多角色交互、上下文理解 智能客服、翻译、多轮对话
嵌入模型(Embedding Model) 文本向量化引擎 语义转化、相似度计算 知识库检索、文本聚类、推荐

对话模型(Chat Model)

核心特点

专为多轮对话设计,支持 system/human/ai 多角色设定,能理解上下文并按预设规则生成响应,是交互类场景的核心模型。

调用代码

import { ChatOpenAI } from "@langchain/openai"

const chatModel = new ChatOpenAI({
    model: "qwen-max",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
})

const response = await chatModel.invoke([
    {
        // system角色:预设模型行为和能力
        role: "system",
        content: "你是一个专业的翻译,你可以将中文翻译成英文",
    },
    {
        // user角色:输入具体用户指令
        role: "user",
        content: "请帮我翻译成英文:你好",
    },
])
console.log(response.content) // 输出示例:Hello

嵌入模型(Embedding Model)

核心特点

将文本转化为固定维度的数值向量(通义千问 text-embedding-v2 为768维),向量间的距离可表征文本语义相似度,是语义检索、知识库的基础。

调用代码

import { OpenAIEmbeddings } from "@langchain/openai"

const embeddingsModel = new OpenAIEmbeddings({
    model: "text-embedding-v2",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
})

// 单文本向量化:返回一维向量数组
const queryEmbedding = await embeddingsModel.embedQuery("你好")
console.log(queryEmbedding) // 输出示例:[0.012, -0.045, 0.078, ...]

// 扩展:多文本批量向量化(适用于知识库构建)
// const batchEmbeddings = await embeddingsModel.embedDocuments(["你好", "世界"])

大语言模型(LLM)

核心特点

最基础的大模型类型,以「文本补全」为核心能力,无多角色交互设计,输入单一文本指令,返回对应的生成结果。

与对话模型的区别

  • 大语言模型:输入输出均为纯文本,无角色区分,适合简单的文本生成/问答;
  • 对话模型:基于大语言模型封装,支持多角色、多轮上下文,交互性更强。

调用代码

import { OpenAI } from "@langchain/openai"

const llm = new OpenAI({
    model: "qwen-plus",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
})

// 输入单一文本指令,直接返回结果
const response = await llm.invoke("翻译“你好”成英文")
console.log(response) // 输出示例:Hello

gsap 配置解读 --2

yoyo 是什么意思

在 GSAP(GreenSock Animation Platform)中,yoyo: true 是一个控制重复动画播放方向的选项

const tween = gsap.to(slider, {
x: 420,
duration: 1.2, 
ease: "power1.inOut",
repeat: -1, // 无限重复
yoyo: true, // ← 关键:开启“来回”模式
paused: true
});

这段代码的作用是:

让 slider 元素在 x: 0 和 x: 420 之间无限来回移动(像钟摆一样),每次往返耗时 2.4 秒(1.2 秒去 + 1.2 秒回)。


✅ yoyo: true 的核心行为

yoyo 动画重复时的行为
false(默认) 每次重复都从头开始0 → 420,然后重置回 0 再 0 → 420(会有“跳回”感)
true 每次重复都反向播放0 → 420,然后 420 → 0,再 0 → 420……形成平滑来回
对比示例:
  • repeat: -1, yoyo: false
    0 → 420 → 瞬间跳回 0 → 0 → 420 → 瞬间跳回 0 …(不连贯)
  • repeat: -1, yoyo: true
    0 → 420 → 420 → 0 → 0 → 420 → 420 → 0 …(流畅往复)

💡 yoyo 这个名字来源于“悠悠球”——扔出去再自动收回来,形象地表达了“来回”运动。


🔧 配合 repeat 使用

  • repeat: 1 + yoyo: true → 播放一次正向 + 一次反向(共 2 次)
  • repeat: -1 + yoyo: true → 无限循环往复(最常见用法)
  • repeat: 0(默认)→ yoyo 无效(因为没有重复)

🎯 常见应用场景

  1. 背景元素左右/上下浮动

    js

    gsap.to(".cloud", { x: 100, repeat: -1, yoyo: true, duration: 3 });
    
  2. 指示器、光标闪烁或晃动

    js

    gsap.to(".cursor", { opacity: 0, repeat: -1, yoyo: true, duration: 0.5 });
    
  3. 机械式往复运动(如风扇叶片微调、机器人手臂)


⚠️ 注意事项

  • yoyo 只影响动画的播放方向,不会改变 ease(缓动)。

    • 正向:使用你指定的 ease(如 "power1.inOut"
    • 反向:GSAP 会自动反转缓动曲线,确保运动自然(无需手动设置)
  • 如果你用了 keyframesyoyo 会反转整个关键帧序列。


✅ 总结

配置 含义
repeat: -1 无限重复动画
yoyo: true 每次重复时反向播放,实现“来回”效果

你的代码创建了一个水平方向无限来回滑动的动画,常用于:

  • 轮播图自动滚动(配合暂停/继续)
  • 装饰性动态元素
  • 游戏中的巡逻敌人/NPC

配合 paused: true,你可以在需要时(如鼠标悬停、点击按钮)通过 tween.play() 启动这个循环动画。

onStart onUpdate onComplete 是什么意思

在 GSAP(GreenSock Animation Platform)中,onStartonUpdate 和 onComplete 是 动画生命周期回调函数(callback functions) ,用于在动画的特定时刻执行自定义 JavaScript 代码

 const tween = gsap.to(dot, {
        x: 460,
        duration: 1.4,
        ease: "power2.out",
        paused: true,
        onStart: () => {
          log.textContent = "动画开始...";
        },
        onUpdate: () => {
          const progress = Math.round(tween.progress() * 100);
          log.textContent = `进行中:${progress}%`;
        },
        onComplete: () => {
          log.textContent = "动画完成!";
        }
      });

这段代码为动画定义了三个关键时机的“监听器”:


✅ 各回调函数的作用

回调 触发时机 常见用途
onStart 动画刚开始播放的第一帧(在第一次渲染前) 显示提示、启动计时器、添加 class 等
onUpdate 每一帧动画更新时都会触发(每秒约 60 次) 实时更新进度条、显示百分比、同步其他元素状态
onComplete 动画完全结束(到达最后一帧) 隐藏元素、跳转页面、播放音效、触发下一个动画

🔍 详细说明

1. onStart
  • 只会执行 一次
  • 在动画真正开始移动/变化之前触发。
  • 适合做“初始化”操作。

✅ 示例:

js

onStart: () => {
  dot.classList.add('animating');
  console.log('Dot animation started!');
}

2. onUpdate
  • 高频触发(每帧一次),性能敏感,避免做 heavy 操作(如 DOM 查询、复杂计算)。
  • 常配合 tween.progress()(返回 0~1 的进度值)或 tween.time() 使用。

✅ 你的代码:

js

onUpdate: () => {
  const progress = Math.round(tween.progress() * 100); // 转为 0~100%
  log.textContent = `进行中:${progress}%`;
}

→ 实时显示动画完成百分比。

💡 提示:如果只是想读取当前属性值,也可以用 gsap.getProperty(dot, "x")


3. onComplete
  • 动画正常播放完毕时触发(不包括被暂停、反向或 killed 的情况)。
  • 如果设置了 repeat: -1onComplete 永远不会触发(因为动画永不结束)。

✅ 示例:

js

onComplete: () => {
  setTimeout(() => dot.style.display = 'none', 500);
  playNextAnimation();
}

⚠️ 其他相关回调(扩展)

回调 说明
onReverseComplete 反向播放完成时触发(比如调用了 tween.reverse() 并回到起点)
onRepeat 每次重复开始时触发(需配合 repeat 使用)
onInterrupt 动画被kill 或 stop 时触发(GSAP 3.10+)

✅ 总结

回调 何时运行 你的代码作用
onStart 动画刚开始 显示 “动画开始...”
onUpdate 每一帧更新 实时显示进度百分比
onComplete 动画彻底结束 显示 “动画完成!”

这些回调让你能精确控制动画与 UI、逻辑、数据的同步,是实现高级交互动效的关键工具。

💡 小技巧:即使 paused: true,一旦你调用 tween.play(),这些回调仍会按规则触发。

tween.play tween.pause tween.reverse tween.restart是什么意思

在 GSAP(GreenSock Animation Platform)中,tween.play()tween.pause()tween.reverse() 和 tween.restart() 是 控制动画播放状态的核心方法。它们让你可以像操作视频播放器一样,灵活地控制动画的播放、暂停、倒放和重播。

 const ship = document.querySelector("#ship");
      const progress = document.querySelector("#progress");
      const playButton = document.querySelector("#play");
      const pauseButton = document.querySelector("#pause");
      const reverseButton = document.querySelector("#reverse");
      const restartButton = document.querySelector("#restart");

      // 创建一个补间并绑定更新进度条
      const tween = gsap.to(ship, {
        x: 470,
        duration: 3,
        ease: "power1.inOut",
        paused: true,
        onUpdate: () => {
          progress.value = Math.round(tween.progress() * 100);
        }
      });

      playButton.addEventListener("click", () => tween.play());
      pauseButton.addEventListener("click", () => tween.pause());
      reverseButton.addEventListener("click", () => tween.reverse());
      restartButton.addEventListener("click", () => tween.restart());

      // 拖动进度条,手动 seek 到指定位置
      progress.addEventListener("input", (event) => {
        const value = Number(event.target.value) / 100;
        tween.progress(value).pause();
      });

假设你有如下动画:

js

const tween = gsap.to(box, {
  x: 300,
  duration: 2,
  paused: true // 初始暂停,等待手动控制
});

此时动画已创建但未播放。你可以通过以下方法控制它:


✅ 1. tween.play()

作用:从当前进度开始正向播放动画。

  • 如果是第一次播放 → 从头开始(进度 0 → 1)
  • 如果之前被暂停在 50% → 从 50% 继续播放到 100%

js

tween.play(); // 开始或继续播放

💡 相当于点击“播放”按钮 ▶️


✅ 2. tween.pause()

作用暂停动画,停留在当前帧。

  • 动画状态被冻结,不会继续更新。
  • 可随时用 play() 或 reverse() 恢复。

js

tween.pause(); // 暂停动画
console.log(tween.progress()); // 比如输出 0.6(60% 进度)

💡 相当于点击“暂停”按钮 ⏸️


✅ 3. tween.reverse()

作用反向播放动画(倒放)。

  • 如果当前在正向播放 → 立即掉头往回走
  • 如果已暂停 → 从当前位置倒放到起点
  • 再次调用 reverse() 会切回正向

js

tween.reverse(); // 开始倒放
// 再次调用:
tween.reverse(); // 又变回正向播放

💡 相当于“倒带” ◀️,常用于 hover 离开时还原状态


✅ 4. tween.restart()

作用重置并重新播放动画(从头开始)。

  • 无论当前在什么进度,都会跳回 0%  并开始正向播放。
  • 相当于 tween.progress(0).play()

js

tween.restart(); // 从头开始播放

💡 相当于“重新开始” 🔁


🔄 状态变化图示

假设动画总时长 2 秒:

方法 当前进度 调用后行为
初始 0%
.play() 0% → 正向播放 → 100%
播放到 60% 时 .pause() 停在 60%
.play() 60% → 继续正向 → 100%
.reverse() 60% → 倒放 → 0%
.restart() 无论在哪 → 跳回 0% → 正向播放

💡 实际应用场景

场景 1:Hover 效果

js

box.addEventListener('mouseenter', () => tween.play());
box.addEventListener('mouseleave', () => tween.reverse());
场景 2:按钮控制

js

playBtn.onclick = () => tween.play();
pauseBtn.onclick = () => tween.pause();
resetBtn.onclick = () => tween.restart();
场景 3:滚动触发动画

js

ScrollTrigger.create({
  trigger: ".section",
  onEnter: () => tween.play(),
  onLeaveBack: () => tween.reverse()
});

⚠️ 注意事项

  • 这些方法返回 tween 自身,支持链式调用:

    js

    tween.play().timeScale(2); // 2倍速播放
    
  • reverse() 不会改变 duration,只是反向运行。

  • 如果动画已完成(100%),调用 play() 不会重播(需用 restart())。


✅ 总结

方法 作用 类比
.play() 从当前位置正向播放 ▶️ 播放
.pause() 暂停在当前帧 ⏸️ 暂停
.reverse() 从当前位置反向播放 ◀️ 倒放
.restart() 重置到开头并播放 🔁 重播

这些方法赋予你对 GSAP 动画完全的程序化控制能力,是实现交互动效的基础。

gsap.defaults 是什么

在 GSAP(GreenSock Animation Platform)中,gsap.defaults()  是一个全局配置方法,用于为所有后续创建的 GSAP 动画(tween 或 timeline)设置默认参数,避免重复书写相同的配置项(如 durationease 等)。

   const boxes = gsap.utils.toArray(".box");
      const playButton = document.querySelector("#play");

      // 设置默认动画参数
      gsap.defaults({
        duration: 0.8,
        ease: "power2.out"
      });

      const timeline = gsap.timeline({ paused: true });

      timeline.to(boxes[0], { x: 220, background: "#22d3ee" });
      timeline.to(boxes[1], { x: 180, background: "#a3e635" }, "<0.1");
      timeline.to(boxes[2], { x: 140, background: "#f472b6" }, "<0.1");

      playButton.addEventListener("click", () => {
        timeline.restart();
      });

这行代码的意思是:

从此以后,所有通过 gsap.to()gsap.from()gsap.timeline().to() 等创建的动画,如果没有显式指定 duration 和 ease,就会自动使用 duration: 0.8 和 ease: "power2.out"


✅ 作用:减少重复代码,统一动效风格

在你的后续代码中:

js

timeline.to(boxes[0], { x: 220, background: "#22d3ee" });
timeline.to(boxes[1], { x: 180, background: "#a3e635" }, "<0.1");
timeline.to(boxes[2], { x: 140, background: "#f472b6" }, "<0.1");

虽然你没有写 duration 和 ease,但由于前面设置了 gsap.defaults(),这三个 .to() 动画会自动继承

  • duration: 0.8
  • ease: "power2.out"

等价于:

js

timeline.to(boxes[0], { x: 220, background: "#22d3ee", duration: 0.8, ease: "power2.out" });
// ...其他同理

🔧 gsap.defaults() 支持的常用属性

属性 说明
duration 默认动画时长(秒)
ease 默认缓动函数(如 "power2.out""elastic""back.inOut" 等)
delay 默认延迟时间
overwrite 默认覆盖行为(如 "auto"
opacity 默认透明度(较少用)

⚠️ 注意:局部配置会覆盖默认值
例如:gsap.to(el, { x: 100, duration: 2 }) 中的 duration: 2 会覆盖 defaults 中的 0.8


🎯 使用场景

  1. 项目级动效规范
    整个网站/应用使用统一的动画节奏和缓动曲线。
  2. 快速原型开发
    先用默认值快速搭建动画,后期再微调个别参数。
  3. 减少样板代码
    避免在几十个动画中反复写 duration: 0.8, ease: "power2.out"

⚠️ 注意事项

  • gsap.defaults() 只影响调用之后创建的动画,对已存在的 tween/timeline 无效。
  • 它设置的是全局默认值,会影响所有 GSAP 动画(包括 gsap.totimeline.to 等)。
  • 如果你在团队协作中使用,建议在项目入口文件(如 main.js)中统一设置。

gsap.quickTo 和 gsap.utils.clamp 是什么

GSAP 的两个非常高效且实用的工具:gsap.quickTo()  和 gsap.utils.clamp() 。它们常用于高性能指针/光标跟随、拖拽、实时交互等场景。下面分别详细解释:


  const stage = document.querySelector("#stage");
      const cursor = document.querySelector("#cursor");

      // quickTo 可以高频率更新属性,且保持平滑
      const moveX = gsap.quickTo(cursor, "x", { duration: 0.3, ease: "power3.out" });
      const moveY = gsap.quickTo(cursor, "y", { duration: 0.3, ease: "power3.out" });

      // utils.clamp 限制数值范围
      const clampX = gsap.utils.clamp(0, stage.clientWidth - 36);
      const clampY = gsap.utils.clamp(0, stage.clientHeight - 36);

      stage.addEventListener("mousemove", (event) => {
        const rect = stage.getBoundingClientRect();
        const x = clampX(event.clientX - rect.left - 18);
        const y = clampY(event.clientY - rect.top - 18);
        moveX(x);
        moveY(y);
      });

✅ 1. gsap.quickTo()

🔍 是什么?

gsap.quickTo() 是 GSAP 提供的一个高性能属性更新器,它会预先创建一个轻量级的 tween(动画) ,然后你可以通过调用返回的函数高频次地更新目标值,而无需反复创建新动画。

📌 你的代码:

js

const moveX = gsap.quickTo(cursor, "x", { duration: 0.3, ease: "power3.out" });
const moveY = gsap.quickTo(cursor, "y", { duration: 0.3, ease: "power3.out" });
  • 创建了两个“快速更新器”:moveX 和 moveY
  • 它们分别控制 cursor 元素的 x 和 y 属性
  • 每次调用 moveX(100),就会让 cursor 的 x 平滑地动画到 100(耗时 0.3 秒,带缓动)

🎯 在事件中使用:

js

stage.addEventListener("mousemove", (event) => {
  // ...计算 x, y
  moveX(x); // ← 高频调用(每秒可能几十次)
  moveY(y);
});

✅ 为什么用 quickTo 而不用 gsap.to

表格

方式 问题
gsap.to(cursor, { x: newX, duration: 0.3 }) 每次 mousemove 都新建一个 tween,内存和性能开销大,容易卡顿
gsap.quickTo(...) 只创建一次 tween,后续只是更新它的目标值,极其高效 ✅

💡 quickTo 内部会自动处理“中断上一帧动画、平滑过渡到新目标”的逻辑,非常适合鼠标跟随、拖拽预览等场景。


✅ 2. gsap.utils.clamp()

🔍 是什么?

clamp(钳制/限制)是一个数值范围限制工具函数,确保一个值不会超出指定的最小值和最大值

📌 你的代码:

js

const clampX = gsap.utils.clamp(0, stage.clientWidth - 36);
const clampY = gsap.utils.clamp(0, stage.clientHeight - 36);
  • clampX 是一个函数,它接收一个数字,返回被限制在 [0, stage.clientWidth - 36] 范围内的值
  • -36 是因为你的 cursor 元素宽高为 36px(假设),要防止它超出容器右/下边缘

🎯 在事件中使用:

js

const x = clampX(event.clientX - rect.left - 18); // -18 是 cursor 宽度的一半(居中对齐)

✅ 作用:防止光标移出舞台区域

比如:

  • 舞台宽度是 500px,cursor 宽 36px → 最大允许 x = 500 - 36 = 464
  • 如果用户把鼠标移到 500px 处,clampX(500 - 18) = clampX(482) → 返回 464
  • 这样 cursor 就不会“跑出”舞台右边

🔁 等价于手写:

js

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

但 GSAP 的版本更简洁、可复用。


🧩 整体逻辑总结

“受限区域内的平滑自定义光标”

  1. 监听 #stage 的鼠标移动
  2. 计算鼠标相对于舞台的坐标clientX - rect.left
  3. 减去 18px 使光标中心对齐鼠标(假设光标是 36×36)
  4. 用 clamp 限制坐标,不让光标超出舞台边界
  5. 用 quickTo 高性能、平滑地更新光标位置

✅ 优势

技术 好处
gsap.quickTo 高频更新不卡顿,动画流畅,内存友好
gsap.utils.clamp 一行代码实现边界限制,代码清晰
GSAP 的 x/y 自动使用 transform,性能优于 left/top

💡 扩展建议

  • 如果想让光标在离开舞台时隐藏,可加:

    js

    stage.addEventListener("mouseleave", () => gsap.set(cursor, { autoAlpha: 0 }));
    stage.addEventListener("mouseenter", () => gsap.set(cursor, { autoAlpha: 1 }));
    
  • quickTo 也支持其他属性,如 scalerotationbackgroundColor 等。


✅ 总结

方法 作用
gsap.quickTo(target, prop, vars) 创建高性能属性更新器,适合高频调用
gsap.utils.clamp(min, max) 生成一个限制数值范围的函数,防止越界

这两个工具组合起来,是实现专业级交互动效(如自定义光标、拖拽预览、游戏 UI)的黄金搭档!

Draggable 是什么

  <div class="card">
      <h1>案例 14:Draggable 拖拽</h1>
      <p>拖动方块,体验 Draggable 的基础能力。</p>
      <div class="stage">
        <div class="drag" id="drag">拖我</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/Draggable.min.js"></script>
    <script>
      const drag = document.querySelector("#drag");

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

      // 创建可拖拽对象并限制在父容器内
      Draggable.create(drag, {
        type: "x,y",
        bounds: ".stage",
        inertia: false
      });
    </script>

DraggableGSAP(GreenSock Animation Platform)官方提供的一个强大插件,用于快速创建高性能、可定制的拖拽交互(drag-and-drop)。它不仅支持鼠标拖动,还完美兼容触摸设备(手机/平板),并内置了惯性滚动、边界限制、对齐吸附、旋转拖拽等高级功能。


📌 你的代码解释:

Draggable.create(drag, {
  type: "x,y",      // 允许水平和垂直拖动
  bounds: ".stage", // 限制拖拽范围在 .stage 容器内
  inertia: false    // 禁用惯性(松手后不会继续滑动)
});

这段代码的作用是:

#drag 元素变成一个可在 .stage 区域内自由拖动的方块,且松手后立即停止(无惯性滑动)。


Draggable 的核心能力

功能 说明
跨平台支持 自动适配鼠标 + 触摸(无需额外代码)
高性能 使用 transform(非 left/top),60fps 流畅拖拽
边界限制 (bounds) 可限制在父容器、自定义矩形区域或另一个元素内
惯性动画 (inertia) 松手后根据速度继续滑动(类似 iOS 滚动)
多种拖拽类型 (type) x(水平)、y(垂直)、x,y(自由)、rotation(旋转)、scroll(模拟滚动)等
事件回调 onDragStart, onDrag, onDragEnd 等,便于扩展逻辑
与其他 GSAP 动画无缝集成 拖拽过程中可触发 timeline、tween 等

🔧 常见配置项详解

1. type
  • "x":仅水平拖动
  • "y":仅垂直拖动
  • "x,y":自由二维拖动(最常用)
  • "rotation":围绕中心点旋转(适合旋钮、转盘)
  • "scrollTop" / "scrollLeft":模拟滚动条
2. bounds
  • 字符串选择器:".container" → 限制在该元素内
  • DOM 元素:document.body
  • 对象:{ top: 0, left: 0, width: 500, height: 300 }
3. inertia
  • true:松手后根据拖拽速度继续滑动(需加载 InertiaPlugin
  • false:松手立即停止(默认)

🎯 实际应用场景

场景 配置示例
可拖拽卡片 type: "x,y", bounds: ".card-container"
滑块/进度条 type: "x", bounds: ".slider-track"
旋转调节器 type: "rotation", bounds: { minRotation: 0, maxRotation: 180 }
图片裁剪框 type: "x,y", bounds: ".image"
游戏人物移动 type: "x,y", onDrag: updateCharacterPosition

💡 事件回调示例

Draggable.create(drag, {
  type: "x,y",
  bounds: ".stage",
  onDragStart: () => console.log("开始拖拽"),
  onDrag: () => console.log("正在拖拽", drag._gsap.x, drag._gsap.y),
  onDragEnd: () => console.log("拖拽结束")
});

📌 拖拽过程中的位置可通过 element._gsap.x / element._gsap.y 实时获取(GSAP 自动维护)。


⚠️ 注意事项

  1. 必须注册插件
    gsap.registerPlugin(Draggable);
    
  2. 被拖拽元素需定位:建议设置 position: absolutefixed,否则可能布局错乱。
  3. 避免与原生滚动冲突:在移动端可能需要 touch-action: none
    .drag {
      touch-action: none; /* 禁用浏览器默认拖拽/缩放 */
    }
    

当然可以!在 GSAP 的 Draggable 插件中,type 选项决定了拖拽行为的类型。以下是多个常见的 type 配置及其实际应用场景和代码示例,帮助你快速掌握不同拖拽模式的用法。


✅ 1. type: "x" —— 仅水平拖动

适用于滑块、时间轴、横向卡片流等。

Draggable.create(".slider-handle", {
  type: "x",
  bounds: ".slider-track" // 限制在轨道内
});

📌 效果:只能左右拖,不能上下移动。


✅ 2. type: "y" —— 仅垂直拖动

适用于音量条、滚动预览、垂直进度条。

Draggable.create(".volume-knob", {
  type: "y",
  bounds: ".volume-bar"
});

📌 效果:只能上下拖,不能左右移动。


✅ 3. type: "x,y" —— 自由二维拖动(最常用)

适用于可移动窗口、拖拽图标、自定义光标、游戏人物。

Draggable.create(".draggable-box", {
  type: "x,y",
  bounds: ".container" // 限制在容器内
});

📌 效果:可任意方向拖动,但不会超出 .container 边界。


✅ 4. type: "rotation" —— 旋转拖拽

适用于旋钮、转盘、角度调节器、图片旋转工具。

Draggable.create(".knob", {
  type: "rotation",
  bounds: { minRotation: 0, maxRotation: 270 } // 限制旋转角度
});

📌 效果:鼠标/手指绕元素中心旋转,值为角度(°)。

💡 元素需设置 transform-origin: center(默认即是)。


✅ 5. type: "scrollTop" —— 模拟垂直滚动

适用于自定义滚动条、迷你地图导航。

// 拖动小方块控制大内容区滚动
Draggable.create(".scroll-thumb", {
  type: "scrollTop",
  scrollElement: document.querySelector(".content") // 要滚动的目标元素
});

📌 效果:拖动 .scroll-thumb 时,.content 区域会同步垂直滚动。


✅ 6. type: "scrollLeft" —— 模拟水平滚动

适用于横向长图浏览、时间线导航。

Draggable.create(".horizontal-thumb", {
  type: "scrollLeft",
  scrollElement: document.querySelector(".timeline")
});

📌 效果:拖动 thumb 控制 .timeline 水平滚动。


✅ 7. type: "top,left" —— 使用 top/left 定位(不推荐)

⚠️ 性能较差,仅用于特殊布局(如非 transform 兼容场景)。

Draggable.create(".legacy-element", {
  type: "top,left",
  bounds: ".parent"
});

📌 区别

  • 默认 x,y 使用 transform: translate()(高性能、不影响文档流)
  • top,left 直接修改 CSS top/left(触发重排,性能低)

建议优先使用 x,y


✅ 8. 组合类型(GSAP 3.12+ 支持)—— type: "x,rotation"

同时支持水平移动 + 旋转(高级交互)。

Draggable.create(".dial", {
  type: "x,rotation",
  bounds: { minX: 0, maxX: 300 }
});

📌 效果:左右拖动改变位置,同时可旋转(需配合手势或逻辑判断)。

🔔 注意:组合类型需明确指定每个维度的行为,实际使用较少。


🎯 补充:如何读取拖拽状态?

无论哪种 type,你都可以通过以下方式获取实时值:

const drag = Draggable.get(".element");

console.log(drag.x);        // 当前 x 位移(px)
console.log(drag.y);        // 当前 y 位移(px)
console.log(drag.rotation); // 当前旋转角度
console.log(drag.scrollY);  // 当前 scrollTop 值(如果 type 是 scrollTop)

✅ 总结:常用 type 对照表

type 用途 是否常用
"x" 水平滑块 ✅ 高频
"y" 垂直滑块 ✅ 高频
"x,y" 自由拖拽(窗口/图标) ✅ 最常用
"rotation" 旋钮、转盘 ✅ 中频
"scrollTop" 自定义垂直滚动条 ✅ 中频
"scrollLeft" 自定义水平滚动条 ✅ 中频
"top,left" 兼容旧布局(不推荐) ❌ 少用

通过合理选择 type,你可以用极少的代码实现丰富的交互效果。结合 boundsinertia、事件回调(onDrag 等),Draggable 几乎能满足所有拖拽需求!

✅ 总结

术语 含义
Draggable GSAP 官方拖拽插件,提供高性能、跨平台、可定制的拖拽交互能力

代码是一个典型的 “受限区域内的基础拖拽” 示例,非常适合入门。通过组合 boundstypeinertia 和事件回调,你可以轻松实现从简单 UI 控件到复杂游戏交互的各种需求。

Angular 中的增量水合:构建“秒开且可交互”的 SSR 应用

原文:Incremental Hydration In Angular Apps

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

增量水合封面图

Angular 的增量水合(Incremental Hydration)通过把“可见”与“可交互”的成本拆开:页面仍然用 SSR 很快渲染出完整 HTML(有利于 LCP/SEO),但把某些区域的客户端激活(事件绑定、变更检测等)推迟到「空闲 / 进入视口 / 交互 / 定时」等触发时机,从而减少主线程阻塞(TBT)并让首屏更“顺滑”。

目录

    1. 性能悖论:看起来好了,但还不能用
    1. 演进与术语:从“破坏式”到“非破坏式”再到“增量”
    1. 深入:@defer 的加载与水合双触发
    1. 实现与配置:开启增量水合与事件回放
    1. 架构:嵌套块、层级规则与 HTML 约束
    1. 排错与调试:水合不匹配(Hydration Mismatch)
  • 常见问题
  • 总结与行动清单

1. 性能悖论

现代 Web 应用经常陷入一个悖论:

  • **业务与指标(Core Web Vitals)**希望尽快看到内容:通过 SSR 改善 LCP(Largest Contentful Paint)。
  • 用户体验希望像 SPA 一样顺滑可交互:事件绑定、变更检测、路由与各种组件逻辑都要跑起来。

问题在于:“看起来 ready”与“用起来 ready”之间存在时间差

在传统水合(hydration)里,浏览器需要在主线程上启动框架、遍历 DOM、挂载监听器等。用户看到页面已经“完整”,但点击按钮没有反应、菜单卡住——这段时间就像性能的“恐怖谷”,通常发生在 LCP(内容已绘制)到 TTI(Time to Interactive)之间。

Angular 的增量水合把应用视为一组相对独立的“岛屿”:不是启动时把整棵组件树一次性激活,而是让某些部分在合适的时机再醒来。收益通常体现在更低的 TBT(Total Blocking Time)与更快的“体感可用”。

标准水合 vs 增量水合

2. 演进与术语

理解 Angular 的水合语法之前,先把“hydration”在不同阶段的含义理清:

2.1 破坏式水合(早期/遗留)

在一些旧方案里,“水合”更像是误用:

  1. 服务端返回 HTML。
  2. 浏览器先把它画出来。
  3. 客户端框架丢弃这份 DOM,再用 JS 从头重建。

这会导致闪烁(flicker)与大量计算开销。

2.2 非破坏式水合(Angular 16+)

Angular 16 引入非破坏式水合:

  • 启动后遍历已有的 SSR DOM;
  • 将 DOM 节点与组件树匹配;
  • 在复用 DOM 的前提下挂载事件监听。

这是巨大进步,但仍是“一刀切”:启动时仍要把整棵树都水合。

2.3 增量水合(Incremental Hydration)

增量水合建立在非破坏式水合之上,但进一步提供“粒度”。它基于 @defer 块作为边界:可以让某些组件子树先以静态 HTML(dehydrated)呈现,等触发条件满足时再执行客户端逻辑并挂载监听。

它与“懒加载(lazy loading)”的关键差异是:

  • 懒加载(常见于 CSR):代码晚点加载,DOM 往往也是晚点渲染(可能先显示骨架/占位)。
  • 增量水合(SSR):内容先由服务端渲染出来,用户立刻能看到;只是先不激活交互,等触发再水合。

因此它更像是在“保持视觉完整”的前提下,优化主线程执行成本。

3. 深入:@defer 的加载与水合双触发

在增量水合语境里,一个 @defer 块实际控制两件事:

  1. **Loading:**什么时候去拉取对应的 JS chunk。
  2. **Hydrating:**什么时候执行逻辑、把监听器挂到已存在的 HTML 上。

这意味着你可以做出更“精细”的性能画像:先把代码悄悄拉下来,但把激活推迟到真正需要的时候。

3.1 双触发示例

@defer (on idle; hydrate on interaction) {
  <app-heavy-chart />
}
  • **首屏(SSR):**服务端会渲染 <app-heavy-chart />,用户立刻看到内容。
  • on idle:浏览器空闲时在后台拉取图表的 JS。
  • hydrate on interaction:先不执行图表逻辑,让它保持“静态壳”;主线程保持轻。
  • **当用户交互(点击/触摸/键盘等):**触发水合,组件“醒来”。
  • 如果是 CSR 路由进入(没有 SSR):on idle 会影响该块什么时候真正渲染。

3.2 常见水合触发方式

下面这些是更偏“水合时机”的触发类型(不同版本/文档里表述略有差异,核心思想一致):

  1. hydrate on idle(默认型优化)

    • 行为:在浏览器空闲时水合(概念上类似 requestIdleCallback 的时机)。
    • 适合:大多数非关键区域。
  2. hydrate on viewport(首选的“屏外内容”策略)

    • 行为:进入视口才水合(基于 IntersectionObserver)。
    • 适合:长列表、评论区、页脚等。
  3. hydrate on interaction(重组件“按需启动”)

    • 行为:点击/触摸/键盘等交互触发。
    • 适合:地图、复杂日期选择器等“看得见但不一定会用”的部件。
  4. hydrate on hover(提前一点点)

    • 行为:鼠标悬停 / focus 触发。
    • 适合:下拉菜单等,鼠标靠近时提前准备。
  5. hydrate on timer (X)(按时间排队)

    • 行为:延迟 X 毫秒后水合。
    • 适合:你想明确安排启动顺序,比如侧边栏 500ms、广告 2000ms。
  6. hydrate on immediate(关键交互)

    • 行为:在非延迟内容渲染完之后尽快水合。
    • 适合:首屏必须马上可点的关键按钮。
@defer (hydrate on immediate) {
  <hero-cta-button />
} @placeholder {
  <div>Loading...</div>
}
  1. hydrate when <condition>(条件门控)

    • 行为:当某个信号或布尔条件变为真时水合。
    • 适合:例如「只有管理员才需要的仪表盘组件」。
    • 注意:条件通常只能在最外层尚未水合的 @defer 上可靠评估;父块还没水合时,子块条件也无法被计算。
  2. hydrate never(纯静态块)

    • 行为:服务端渲染后永不水合。
    • 适合:完全没有交互需求的内容(条款、静态介绍等)。

4. 实现与配置:开启增量水合与事件回放

开启增量水合通常只是一处配置,但真正的关键点在于:交互触发的那一下不能丢

4.1 基本配置

app.config.ts 中启用客户端水合,并开启增量能力:

import { ApplicationConfig, provideZoneChangeDetection } from "@angular/core";
import {
  provideClientHydration,
  withIncrementalHydration,
} from "@angular/platform-browser";

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideClientHydration(withIncrementalHydration()),
  ],
};

4.2 自动事件回放(Event Replay)

很多人第一反应是:

如果我用了 hydrate on interaction,那用户第一次点击是不是会被吞掉?

Angular 的思路是:在真正框架逻辑还没起来时,先用一段轻量脚本捕获事件,把它们缓冲起来,等对应块水合完成后再“回放”。在启用 withIncrementalHydration() 时,这类事件回放能力通常会一并启用(文档中也常提到 withEventReplay())。

事件回放大致流程:

  1. **捕获:**在文档根部注册全局事件分发器。
  2. **缓冲:**如果事件发生在尚未水合的 @defer 区域内,就先暂存。
  3. **触发水合:**事件本身触发 hydrate on interaction
  4. **回放:**代码加载 + 水合完成后,把事件交给新挂载的监听器执行。

Angular 水合与事件回放示意

5. 架构:嵌套块与约束

增量水合带来收益,也带来一些你必须遵守的“架构规则”。忽略它们会导致退化(de-opt),甚至回落到破坏式重渲染。

5.1 层级规则:自上而下

Angular 水合是有层级的:

  • 父级必须先水合(或同时水合),子级才能可靠水合。
  • 子组件依赖父组件的变更检测与输入绑定;如果父级仍是“脱水”状态,子级很难独立激活。

实践建议:把 @defer 块设计得更“自包含”,避免出现点了叶子节点却把整棵树都连锁唤醒的“瀑布效应”。

5.2 HTML 结构必须有效且一致

非破坏式水合依赖“复用 DOM”:服务端输出的 DOM 结构需要与浏览器最终 DOM、以及客户端期望结构严格一致。

常见坑:

  • <a> 嵌套 <a>
  • <p> 里塞了 <div> 这类块级元素
  • <table><tbody>
  • 由于无效 HTML 导致浏览器自动修复、从而改变了 DOM

这些都会导致水合复用失败,进而触发重建。

5.3 SEO 影响?通常不会

有人担心 @defer 会伤害 SEO。增量水合的前提是 SSR:主内容在服务端模板里已经输出成语义化 HTML,搜索引擎拿到的就是完整内容。

水合触发控制的是“什么时候执行 JS 让它可交互”,不是“内容什么时候出现”。

5.4 @placeholder 仍然需要

即使 SSR 会把真实内容渲染出来,@placeholder 仍然很重要——主要用于 CSR 路由导航 的场景。

当用户通过 routerLink 在客户端导航进入某页时,该页的 @defer 更像常规延迟块:

  • 会先显示 @placeholder
  • 然后根据触发条件加载并渲染真实内容
@defer (on viewport; hydrate on interaction) {
  <comments-section />
} @placeholder {
  <div class="comments-skeleton">Loading comments...</div>
}

建议:给占位提供接近真实内容的尺寸,减少 CSR 下的布局抖动(CLS)。

6. 排错与调试:Hydration Mismatch

最常见的问题是 Hydration Mismatch(水合不匹配)

服务端生成的 HTML 与客户端期望的 DOM 不一致。

本质原因是:客户端在水合时要求“可复用的 DOM”必须匹配预期;哪怕是一个文本节点差异,都会出问题。

6.1 常见原因

  1. **动态日期:**模板里直接 new Date(),服务端与客户端时间不同。
  2. **随机 ID:**用 Math.random() 之类生成随机值。
  3. **浏览器规范化:**无效 HTML 被浏览器修复后结构变了。

6.2 调试手段

  • **控制台日志:**Angular 通常会提示具体不匹配的节点。
  • **Angular DevTools:**可以查看组件树;在较新版本里也能看到组件的水合状态(Hydrated / Skipped / Dehydrated)。
  • **可视化标记:**临时用 CSS(如 .ng-hydrating)给“醒来”的组件加高亮,观察时序。

常见问题

增量水合解决了什么问题?

它减少了 SSR 应用里“内容已出现但还不能交互”的间隙,通过延迟/分批激活交互逻辑降低启动期主线程压力。

它和标准水合有什么区别?

标准水合倾向于启动时激活整棵组件树;增量水合根据触发条件只激活需要的部分。

它等同于懒加载吗?

不等同。懒加载往往会推迟渲染;增量水合强调 SSR 先渲染出内容,再推迟交互激活。

会影响 SEO 吗?

通常不会。SSR 已输出完整内容,触发控制的是 JS 执行时机。

@defer 的作用是什么?

它定义水合边界,并控制“什么时候加载代码 / 什么时候激活交互”。

总结与行动清单

增量水合的核心很简单:

  • 服务端把内容都渲染出来(用户立刻看到、SEO 友好)
  • 客户端只在需要时才水合(主线程更清爽、体感更快)

你可以从这份行动清单开始:

  1. **做一次页面盘点:**哪些在首屏?哪些在首屏下方?哪些必须立即可点?
  2. 为不同区域选择触发:
    • Hero + CTA:hydrate on immediate
    • 评论区/长列表:hydrate on viewport
    • 重型但不一定会用的组件:hydrate on interaction
    • 纯静态块:hydrate never
  3. **CSR 场景别忘 @placeholder:**占位尽量稳定尺寸,避免 CLS。
  4. **在真实设备上验证:**用 DevTools 观察水合状态与事件回放是否符合预期。

一句话结论:不要在启动时把所有东西一次性“唤醒”。让组件在用户需要时再启动,你会得到更快、更稳、更顺滑的 Angular SSR 体验。

TypeScript 类型体操:如何为 SDK 编写优雅的类型定义

背景

作为一款 SDK,提供完善的 TypeScript 类型定义(.d.ts)是对用户最基本的尊重。 AutoForm 的配置项非常复杂,且存在很多联动关系。如何用 TS 准确描述这些关系?

今天带大家做一套类型体操。

挑战一:互斥属性

如果配置了 mode: 'auto',则 interval 必填;如果 mode: 'manual',则 interval 不可填。

错误写法:

interface Config {
  mode: 'auto' | 'manual';
  interval?: number;
}

正确写法(Discriminated Unions):

type AutoConfig = {
  mode: 'auto';
  interval: number;
};

type ManualConfig = {
  mode: 'manual';
  interval?: never; // 关键:禁止出现
};

type Config = AutoConfig | ManualConfig;

挑战二:事件回调的类型推导

我们希望用户在监听事件时,能自动推导出 event 对象的类型。

sdk.on('success', (e) => {
  console.log(e.data); // e 应该是 SuccessEvent
});

sdk.on('error', (e) => {
  console.log(e.message); // e 应该是 ErrorEvent
});

实现:

type EventMap = {
  success: { any };
  error: { message: string; code: number };
};

class SDK {
  on<K extends keyof EventMap>(
    type: K, 
    handler: (event: EventMap[K]) => void
  ) {
    // ...
  }
}

挑战三:深度 Partial

用户配置时,通常只需要覆盖默认配置的一部分。我们需要一个递归的 Partial

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

总结

TypeScript 不仅仅是类型检查工具,更是最好的文档。写好类型定义,能让用户在使用 SDK 时获得极致的智能提示体验,减少查阅文档的时间。

👉 官网地址:51bpms.com

Vite 插件开发实战:如何自动注入 SDK 脚本

需求背景

AutoForm SDK 开发完成后,我们需要提供给客户集成。 对于使用 Vite 的客户,如果能提供一个插件,让他们在 vite.config.ts 里配置一下就能用,体验会好很多。

本文将手把手教你开发一个 Vite 插件,实现 SDK 脚本的自动注入。

插件结构

Vite 插件本质上是一个返回特定对象的函数。

// vite-plugin-autoform.ts
export default function autoFormPlugin(options) {
  return {
    name: 'vite-plugin-autoform',
    // 插件钩子
    transformIndexHtml(html) {
      // ...
    }
  };
}

核心逻辑:transformIndexHtml

我们需要在 index.html<body> 结束标签前插入 SDK 的 <script> 标签。

transformIndexHtml(html) {
  const scriptTag = `
    <script>
      window.AIFormConfig = ${JSON.stringify(options)};
    </script>
    <script src="https://cdn.autoform.com/sdk.js" async></script>
  `;
  
  return html.replace('</body>', `${scriptTag}</body>`);
}

进阶:开发环境与生产环境区分

在开发环境(npm run dev)下,我们可能希望注入本地的 SDK 脚本,方便调试。

configResolved(config) {
  isDev = config.command === 'serve';
},

transformIndexHtml(html) {
  const src = isDev 
    ? 'http://localhost:3000/sdk.js' 
    : 'https://cdn.autoform.com/sdk.js';
    
  // ...
}

发布到 NPM

  1. 创建 package.json
  2. 配置 maintypes
  3. npm publish

现在,用户只需要:

npm install vite-plugin-autoform

然后在 vite.config.ts 中:

import autoForm from 'vite-plugin-autoform';

export default {
  plugins: [autoForm({ token: 'xxx' })]
};

总结

Vite 插件开发非常简单,但能极大地提升用户体验。对于 SDK 开发者来说,提供配套的构建工具插件是必不可少的。

👉 官网地址:51bpms.com

别再用 ID 定位了!教你用"语义指纹"实现 99% 的元素定位成功率

痛点

做过自动化测试或爬虫的同学一定遇到过这种情况: 昨天写的脚本 document.querySelector('#login-btn') 还能跑,今天网站更新了,ID 变成了 #login-btn-123,脚本直接挂掉。

在开发 AutoForm 智能表单填充 SDK 时,我们面临同样的挑战:如何让 SDK 在页面结构变化后,依然能找到那个"用户名输入框"?

答案是:语义指纹(Semantic Fingerprinting)

什么是语义指纹?

人类找元素不是靠 ID,而是靠"特征"。 当你看到一个输入框,旁边写着"用户名",里面提示"请输入手机号",你就知道它是干嘛的。

我们将这些特征提取出来,生成一个唯一的哈希值,这就是"语义指纹"。

算法实现

1. 特征提取

我们提取以下维度的特征:

  • Label 文本:这是最强的语义特征。
  • Placeholder:提示文案。
  • Name 属性:通常包含字段含义(如 username, email)。
  • Input Type:输入类型(text, password, checkbox)。
  • 前驱/后继文本:DOM 树中相邻的文本节点。
function extractFeatures(element) {
  return {
    tag: element.tagName,
    type: element.type,
    name: element.name,
    label: findLabel(element), // 关联的 label 文本
    placeholder: element.placeholder,
    surroundingText: getSurroundingText(element)
  };
}

2. 指纹生成

将特征拼接成字符串,然后计算 Hash。

import { md5 } from 'hash-wasm';

const fingerprint = await md5(JSON.stringify(features));

3. 模糊匹配

当页面更新后,新元素的指纹可能与旧指纹不完全一致(比如 placeholder 变了)。这时我们计算相似度(Similarity Score)

我们使用 Levenshtein Distance(编辑距离) 算法来比较两个特征对象的相似度。如果相似度 > 80%,我们就认为找到了目标。

效果

引入语义指纹后,AutoForm 的定位抗干扰能力大幅提升:

  • ID 变化:完全免疫。
  • DOM 结构微调:完全免疫。
  • 文案微调:只要核心语义没变(如"用户名"变成"请输入用户名"),依然能识别。

总结

在 AI 时代,基于规则的硬编码(Hard-coding)已经过时了。基于特征的模糊匹配才是未来的方向。


思考:如果页面上有两个完全一样的输入框(如两个"手机号"),该如何区分?欢迎评论区交流!

👉 官网地址:51bpms.com

Mokup:构建工具友好的可视化 Mock 工具

devio-cover.jpg

Mokup:构建工具友好的可视化 Mock 工具

大家好呀,我是 icebreaker,一名前端开发者兼开源爱好者。

马上就过年了,在这个特别的时间点,我先祝大家:新年快乐!身体健康!工作顺利!来年发大财!


当然,回归正题,这里也向大家介绍一下我最近做的一个开源项目:Mokup,一个基于文件路由的 HTTP Mock 工具。

我做这个当时的目的,主要是给我团队里的同学用的,想让大家最低成本地在现有前端工程里接入服务端的能力,让大家循序渐进的成为全栈,这样才能在AI时代立足。

项目地址:GitHub , 官网与文档

Mokup 是什么

Mokup 是一个基于文件路由的 HTTP Mock 工具。你把 mock 文件放在 mock/ 目录里,它会自动生成可匹配的路由并提供响应。

它的目标很直接:让 mock 在你已有的前端工程里尽快跑起来,减少“为了联调再造一套服务”的成本。

有什么特性

  • 构建工具友好:Vite / Webpack 都能快速的接入,接入成本极低。
  • 可视化:内置 Playground,路由是否生效一眼可见。
  • 开发体验好:mock 文件和目录配置改完就刷新,不用频繁重启。
  • 能部署到多个环境:本地开发、Node 服务端、Worker、Service Worker 都可用。

为什么要做它

这个实际上也和我自己在我自己的群里,还有公司里的项目组搜集痛点有关,那就是,很多团队的痛点不是“不会写 mock”,而是:

  • 接入步骤多,换个构建工具就要重配一次。
  • 本地排查时看不到全局路由状态,只能翻文件猜。
  • 每改一个 mock 都要重启或手动验证,反馈慢。

Mokup 就是为了解决这三个问题:接入更轻、可视化更强、开发反馈更快。

构建工具友好

Vite 接入

import mokup from 'mokup/vite'

export default {
  plugins: [
    mokup({
      entries: { dir: 'mock', prefix: '/api' },
    }),
  ],
}

然后这时候 你就可以在 mock/ 目录里放 mock 文件了,Mokup 会自动扫描并生成对应的路由。

你也可以在你的 CLI 中快速访问 mokup 的 playground 进行可视化调试

cli.png

Webpack 接入

const { mokupWebpack } = require('mokup/webpack')

const withMokup = mokupWebpack({
  entries: { dir: 'mock', prefix: '/api' },
})

module.exports = withMokup({})

你可以在不改动业务代码结构的情况下,把 mock 能力挂到现有构建流程里。

可视化:Playground(重点)

Mokup 内置 Playground,用来查看当前被扫描到的路由、方法、路径和配置链。

Vite 开发时默认入口:

http://localhost:5173/__mokup

在线体验 Demo:mokup.icebreaker.top/__mokup/

playground.png

它解决的是一个非常实际的问题: 接口不生效时,你不用到处去找问题,只要打开页面就能看到“有没有被扫到、有没有被禁用、匹配到了什么配置”。

开发体验:哪些文件会热更新

在 Vite dev 下,Mokup 会监听 mock 目录变化并自动刷新路由表。常见会触发热更新的改动包括:

  • 新增/修改/删除 mock 路由文件,例如:
    • mock/users.get.ts
    • mock/messages.get.json
    • mock/orders/[id].patch.ts
  • 修改目录配置文件:mock/**/index.config.ts
  • 调整目录结构(移动、重命名、创建子目录)

改完后 Playground 会自动刷新路由列表,调试链路更短。

如果你不需要监听,可以在 entries 里配置 watch: false

快速示例:从写文件到看到结果

// mock/users.get.ts
import { defineHandler } from 'mokup'

export default defineHandler({
  handler: c => c.json([{ id: 1, name: 'Ada' }]),
})

启动 dev 后访问 /api/users (你设置了 prefix: '/api' ),即可拿到 mock 数据。

mock-dir.png

快速集成 @faker-js/faker

@faker-js/faker 是我们造假数据最常使用的库了,这里也可以很好的和它集成

Mokup 的 handler 本质上就是 TS/JS 函数,所以能直接接入 @faker-js/faker 这类 mock 数据库,不需要额外适配层。

下面这个示例会根据查询参数 size 返回一组用户列表:

// mock/users.get.ts
import { faker } from '@faker-js/faker'
import { defineHandler } from 'mokup'

export default defineHandler((c) => {
  const size = Number(c.req.query('size') ?? 10)
  const count = Number.isNaN(size) ? 10 : Math.min(Math.max(size, 1), 50)
  const list = Array.from({ length: count }, () => ({
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    city: faker.location.city(),
    createdAt: faker.date.recent({ days: 30 }).toISOString(),
  }))

  return c.json({
    list,
    total: 200,
    page: 1,
    pageSize: count,
  })
})

这对列表页、搜索页、详情页联调都很实用。 如果你希望测试结果可复现,可以在 handler 顶部加上 faker.seed(123)

可部署到多个环境

这套 mock 可以运行在多个环境:比如在 Node.js 里直接使用,甚至还能部署到 Cloudflare Worker。

Node.js 直接使用示例:

import { createFetchServer, serve } from 'mokup/server/node'

const app = await createFetchServer({ entries: { dir: 'mock' } })
serve({ fetch: app.fetch, port: 3000 })

部署到 Cloudflare Worker 示例:

import { createMokupWorker } from 'mokup/server/worker'
import mokupBundle from 'virtual:mokup-bundle'

export default createMokupWorker(mokupBundle)

提示:virtual:mokup-bundle 仅在 Vite 与 @cloudflare/vite-plugin 集成环境可用;Node.js Dev 模式可直接使用 createFetchServer,无需该虚拟模块。

核心架构

core-zh.jpg

适用场景与边界

适合:

  • 已有 Vite/webpack 工程,想低成本接入 mock 的团队
  • 需要可视化路由排查能力的项目
  • 重视开发反馈速度,希望 mock 修改后立即可见的场景

不太适合:

  • 主要依赖复杂动态代理链路的场景
  • 完全不希望引入构建期/插件能力的极轻量脚本方案

Mokup 不是为了替代所有 mock 方案,而是让 mock 更快接入、更好调试、更贴近日常开发流程。

AI 友好性

都已经在这个时代了,怎么能不用 AI 呢, Mokup 当时设计就是考虑到 AI 时代的开发者需求的,所以在设计上也做了一些 AI 友好的考虑,

毕竟时代变了,我们也像纺纱机的工人那样,换到了蒸汽纺纱机,每天奴役 AI 24小时帮我们打工,爽是真的爽,验证问题效率极高。

而且很多开发是懒得写文档的,现在文档什么完全不是问题,就像这篇文章,大部分也是由AI生成的。

结语

Mokup 目前还在快速迭代中,欢迎大家试用并提反馈!无论是功能需求、使用体验还是文档改进都非常欢迎。

如果你也有类似的痛点,或者对 mock 工具有什么想法,也欢迎在评论区交流!我们一起让前端 mock 更好用、更高效!

有志同道合的小伙伴,也可以访问我的 Github 主页联系我一起交流!

拒绝重写!Flutter Add-to-App 全攻略:让原生应用“渐进式”拥抱跨平台

为什么我们需要 Add-to-App?

unwatermarked_Gemini_Generated_Image_51ku3f51ku3f51ku.png 在移动开发领域,Flutter 的跨平台优势(Write once, run anywhere)毋庸置疑。但在现实世界中,我们往往面临着沉重的“历史包袱”。

痛点场景:

“我们公司有一个维护了 5 年的电商 App,原生代码几十万行。最近老板嫌 UI 迭代慢,想用 Flutter,但完全重写是不可能的——业务线太长,风险太大。我们要的是渐进式的改变。”

这就是 Add-to-App 存在的意义。它允许我们将 Flutter 视为一个“库”或“模块”,嵌入到现有的 Android 或 iOS 应用中。

它的核心价值在于:

  1. 成本控制:无需抛弃现有的原生资产(支付模块、复杂的底层算法等)。
  2. 渐进迁移:可以从一个非核心页面(如“关于我们”或“活动页”)开始,逐步扩大 Flutter 的版图。
  3. 复用能力:新开发的 Flutter 模块可以直接在 Android 和 iOS 甚至 Web 上复用,从一开始就享受跨平台红利。

Add-to-App 的基本概念与原理

什么是 Add-to-App?

简单来说,Add-to-App 就是把 Flutter 环境(Dart VM + Flutter Engine)打包成一个原生组件(View 或 ViewController/Activity),塞进现有的原生 App 里。

  • 对于 Android:Flutter 只是一个 View,或者一个 Activity/Fragment。
  • 对于 iOS:Flutter 只是一个 UIView,或者 FlutterViewController。

运行模式:多引擎 vs 多视图

在混合开发中,理解 Flutter 的“寄生”方式至关重要:

策略 描述 优点 缺点
单引擎复用 (Single Engine) 全局维护一个 Engine,在不同原生页面间跳转时,通过 attach/detach 挂载到当前界面。 内存占用最低;状态不仅共享且保持。 导航栈管理极其复杂(原生页面 A -> Flutter B -> 原生 C -> 返回 B 时需恢复现场)。
多引擎 (Multi-Engine) 每次打开 Flutter 页面都创建一个新 Engine。 逻辑隔离,互不干扰;导航栈管理简单。 内存爆炸(每个 Engine 默认消耗较大),启动延迟明显。
FlutterEngineGroup (推荐) 官方提供的轻量级多引擎方案(Flutter 2.0+)。 多个 Engine 共享 GPU 上下文、字体和代码段,新增一个 Engine 仅需 ~180KB 内存 Dart Isolate 彼此隔离,状态不共享(需通过数据层同步)。

误区提示:桌面端/Web 支持的“多视图(Multi-view)”模式(即一个 Engine 渲染多个窗口)目前尚未在移动端 Add-to-App 场景中稳定支持。在移动端,请优先考虑 FlutterEngineGroup

最佳实践场景

  • 高频迭代的业务模块:如电商的活动页、个人中心。
  • 复杂的 UI 交互:如需要高性能动画的图表页。
  • 统一逻辑:双端逻辑完全一致的表单提交或业务计算。

实战 I:在 Android 原生 App 中嵌入 Flutter

创建 Flutter Module

注意,我们不能 flutter create my_app,因为我们不需要一个完整的 App 壳子,我们需要的是一个模块

# 在原生项目同级目录下执行
flutter create -t module my_flutter_module

执行后,你会发现生成的目录结构中,androidios 文件夹是隐藏的(.android, .ios),因为它们是自动生成的包装器。

将 Flutter Module 导入 Android 项目

自 Flutter 3.x 起,官方推荐通过 Gradle 脚本自动管理依赖,避免手动编写 implementation 导致的版本冲突。

步骤 1:修改 settings.gradle

在 include ':app' 之后加入:

// 绑定 Flutter 模块构建脚本
setBinding(new Binding([gradle: this]))
evaluate(new File(
  settingsDir.parentFile, // 假设 flutter_module 与当前项目同级
  'my_flutter_module/.android/include_flutter.groovy'
))

步骤 2:修改 app/build.gradle

依赖会自动注入,通常无需手动添加 implementation project(':flutter')。但需确保 compileSdkVersion 与 Flutter 模块要求一致(通常需 API 33+)。

在 Android 上渲染 Flutter (Activity 与 Fragment)

方式 A:使用 FlutterActivity(全屏场景)

适合独立的业务流程,如“个人中心”或“设置页”。

// 使用缓存 Engine 启动(推荐)
startActivity(
    FlutterActivity
        .withCachedEngine("my_engine_id")
        .build(this)
);

方式 B:使用 FlutterFragment(局部嵌入)

适合将 Flutter 作为一个 View 块嵌入原生页面,例如在一个原生 Tab 页中展示 Flutter 列表。

// 在原生 Activity 或 Fragment 中
FragmentManager fragmentManager = getSupportFragmentManager();
fragmentManager
    .beginTransaction()
    .replace(R.id.fragment_container, 
             FlutterFragment.withCachedEngine("my_engine_id").build())
    .commit();

性能优化 Tip:使用缓存 Engine

withNewEngine() 会导致每次打开页面都有明显的“白屏”或加载延迟。推荐使用 FlutterEngineCache 进行预热:

// 1. 在 Application 启动时预热
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        // 实例化 Engine
        FlutterEngine flutterEngine = new FlutterEngine(this);
        // 开始执行 Dart 代码(预加载)
        flutterEngine.getDartExecutor().executeDartEntrypoint(
            DartExecutor.DartEntrypoint.createDefault()
        );
        // 存入缓存
        FlutterEngineCache
            .getInstance()
            .put("my_engine_id", flutterEngine);
    }
}

// 2. 启动时使用缓存的 Engine
startActivity(
    FlutterActivity
        .withCachedEngine("my_engine_id")
        .build(this)
);

实战 II:在 iOS 原生 App 集成 Flutter

创建 Flutter Module

(同上,使用同一个 my_flutter_module 即可)

CocoaPods 集成

这是 iOS 最标准的集成方式。

修改 Podfile

在 iOS 工程的 Podfile 中添加脚本钩子:

# Podfile
platform :ios, '14.0'

# 定义 Flutter 模块路径
flutter_application_path = '../my_flutter_module'

# 加载 Flutter 的 Pod 助手脚本
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'MyApp' do
  use_frameworks!
  
  # 安装 Flutter 依赖
  install_all_flutter_pods(flutter_application_path)
end

执行 pod install,你会发现 Flutter 相关的 Framework 已经被链接进来了。

在 iOS 中打开 Flutter View

使用 FlutterViewController

import Flutter

// 在某个按钮点击事件中
@objc func showFlutter() {
    // 获取 Flutter Engine(同样建议使用 Cache,这里演示简单模式)
    let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
    
    let flutterViewController = FlutterViewController(
        engine: flutterEngine, 
        nibName: nil, 
        bundle: nil
    )
    
    present(flutterViewController, animated: true, completion: nil)
}

在 iOS 中使用缓存 Engine

为了避免点击按钮时卡顿,强烈建议在 App 启动时预热 Engine。

步骤 1:在 AppDelegate 中初始化并缓存

import UIKit
import Flutter
import FlutterPluginRegistrant // 用于注册插件

@main
class AppDelegate: FlutterAppDelegate { // 继承 FlutterAppDelegate
  
  lazy var flutterEngine = FlutterEngine(name: "my_engine_id")

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // 1. 运行 Engine (预热)
    flutterEngine.run();
    // 2. 注册插件(关键!否则 Flutter 里的插件无法使用)
    GeneratedPluginRegistrant.register(with: flutterEngine);
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions);
  }
}

步骤 2:使用缓存 Engine 弹出页面

@objc func showFlutter() {
    let appDelegate = UIApplication.shared.delegate as! AppDelegate
    let flutterEngine = appDelegate.flutterEngine
    
    let flutterVC = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
    present(flutterVC, animated: true, completion: nil)
}

进阶:原生与 Flutter 的双向通信 (MethodChannel)

当混合开发时,不可避免地需要数据交互:Flutter 读取原生的 Token,或者原生调用 Flutter 的刷新方法。MethodChannel 是最常用的桥梁。

5.1 Flutter 端 (Dart)

import 'package:flutter/services.dart';

class NativeBridge {
  static const platform = MethodChannel('com.example.app/data');

  // 调用原生方法
  Future<String> getUserToken() async {
    try {
      final String token = await platform.invokeMethod('getToken');
      return token;
    } on PlatformException catch (e) {
      return "Failed: '${e.message}'.";
    }
  }
}

5.2 Android 端

// 需在 Engine 初始化后注册 Channel
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "com.example.app/data")
    .setMethodCallHandler(
        (call, result) -> {
            if (call.method.equals("getToken")) {
                // 执行原生逻辑获取 Token
                String token = MyAuthManager.getToken();
                result.success(token);
            } else {
                result.notImplemented();
            }
        }
    );

注意事项:

MethodChannel 并非能传递任意对象,它的底层依赖 BinaryMessenger 进行二进制流传输。

  • StandardMethodCodec(标准编解码器) : Flutter 默认使用此 Codec,它只支持高效序列化以下基础类型

    • null, bool, int, double, String
    • List, Map (仅限上述基础类型的集合)
    • 二进制数据 (Uint8List / byte[])

注意:如果你尝试直接传递一个自定义类 User,通道会报错。 解决方案:将对象转为 JSON String 或 Map 进行传递,或者自定义 Codec。


进阶:混合栈管理与多 Engine 挑战

在 Add-to-App 中,最头疼的问题往往是 导航栈(Navigation Stack)。

比如:原生 A -> Flutter B -> 原生 C -> Flutter D

挑战

  1. 内存爆炸:如果每次 > Flutter 都创建一个新 Engine,内存会迅速耗尽。
  2. 状态丢失:如果复用同一个 Engine,从 C 返回 B 时,Flutter 的状态怎么恢复?

解决方案策略

当原生应用需要在 Feed 流中嵌入多个 Flutter 卡片,或者同时存在多个 Flutter 页面栈时,单纯的“单引擎”或“多引擎”都不够完美。

终极方案:FlutterEngineGroup 这是官方为了解决“多实例内存占用”推出的 API。

原理: 它允许你创建多个 Engine 实例,这些实例共享内存重的资源(如 Skia Shader、字体、Dart VM 快照),但保持 Dart Isolate 隔离

代码示例 (Android)

// 创建 EngineGroup
FlutterEngineGroup engineGroup = new FlutterEngineGroup(context);

// 创建第一个轻量级 Engine
FlutterEngine engine1 = engineGroup.createAndRunDefaultEngine(context);

// 创建第二个轻量级 Engine(复用资源,内存开销极低)
FlutterEngine engine2 = engineGroup.createAndRunDefaultEngine(context);

状态管理挑战: 由于 EngineGroup 中的 Isolate 是隔离的,engine1 中的全局变量无法被 engine2 直接读取。

  • 解决:相比于通过原生层(Host)作为中转站,或者使用持久化存储(Database/SharedPrefs)来同步不同 Flutter 页面间的数据。使用平台通道并且搭配上pigeon,相信会给你复杂原生交互提供不少的便利。

7. 常见问题与“避坑”指南

场景 现象/原因 解决方案
冷启动 点击按钮后,等待 1-2 秒才出现 Flutter 画面,且有白屏。 必须预热 Engine!在 App 启动时初始化 Engine 并存入 Cache。
调试 运行原生 App 后,无法使用 Flutter 的热重载 (Hot Reload)。 在终端运行 flutter attach,连接到正在运行的设备。
图片加载 Flutter 无法加载原生 Assets 中的图片。 原生图片需在 Flutter pubspec.yaml 中声明,或通过 Platform Channel 传递图片数据(字节流)。
生命周期 Flutter 页面退后台后,代码被挂起。 原生层需正确转发生命周期事件(lifecycle_channel),确保 Flutter 知道自己处于前台还是后台。

8. 总结

Add-to-App 方案打破了“非黑即白”的技术选型困境,是目前大型 App 引入 Flutter 的主流路径。

核心路径回顾:

  1. flutter create -t module 创建模块。
  2. 利用 FlutterEngineCache 解决性能问题。
  3. 利用 MethodChannel 打通数据经脉。

混合开发没有银弹,只有不断的权衡。希望本文能帮助你在现有的原生堡垒中,成功开辟出第一块 Flutter 的疆土!

延伸阅读

希望这篇分享对你有帮助!如果想了解更深层的 Engine 源码分析,欢迎留言讨论。

Vue3 子传父全解析:从基础用法到实战避坑

在 Vue3 开发中,组件通信是绕不开的核心场景,而子传父作为最基础、最常用的通信方式之一,更是新手入门必掌握的知识点。不同于 Vue2 的 $emit 写法,Vue3 组合式 API(<script setup>)简化了子传父的实现逻辑,但也有不少细节和进阶技巧需要注意。

本文将抛开 TypeScript,用最通俗的语言 + 可直接复制的实战代码,从基础用法、进阶技巧、常见场景到避坑指南,全方位讲解 Vue3 子传父,新手看完就能上手,老手也能查漏补缺。

一、核心原理:子组件触发事件,父组件监听事件

Vue3 子传父的核心逻辑和 Vue2 一致:子组件通过触发自定义事件,将数据传递给父组件;父组件通过监听该自定义事件,接收子组件传递的数据

关键区别在于:Vue3 <script setup> 中,无需通过 this.$emit 触发事件,而是通过 defineEmits 声明事件后,直接调用 emit 函数即可,语法更简洁、更直观。

先记住核心流程,再看具体实现:

  1. 子组件:用 defineEmits 声明要触发的自定义事件(可选但推荐);
  2. 子组件:在需要传值的地方(如点击事件、接口回调),调用 emit('事件名', 要传递的数据)
  3. 父组件:在使用子组件的地方,通过 @事件名="处理函数" 监听事件;
  4. 父组件:在处理函数中,接收子组件传递的数据并使用。

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

我们用一个「子组件输入内容,父组件实时显示」的简单案例,讲解基础用法,代码可直接复制到项目中运行。

1. 子组件(Child.vue):声明事件 + 触发事件

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <!-- 输入框输入内容,触发input事件,传递输入值 -->
    <input 
      type="text" 
      v-model="childInput" 
      @input="handleInput"
      placeholder="请输入要传递给父组件的内容"
    />
    <!-- 按钮点击,传递固定数据 -->
    <button @click="handleClick" style="margin-top: 10px;">
      点击向父组件传值
    </button>
  </div>
</template>

<script setup>
// 1. 声明要触发的自定义事件(数组形式,元素是事件名)
// 可选,但推荐声明:增强代码可读性,IDE会有语法提示,避免拼写错误
const emit = defineEmits(['inputChange', 'btnClick'])

// 子组件内部数据
const childInput = ref('')

// 输入框变化时,触发事件并传递输入值
const handleInput = () => {
  // 2. 触发事件:第一个参数是事件名,第二个参数是要传递的数据(可选,可多个)
  emit('inputChange', childInput.value)
}

// 按钮点击时,触发事件并传递固定对象
const handleClick = () => {
  emit('btnClick', {
    name: '子组件',
    msg: '这是子组件通过点击按钮传递的数据'
  })
}
</script>

2. 父组件(Parent.vue):监听事件 + 接收数据

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>子组件输入的内容:{{ parentMsg }}</p>
    <p>子组件点击传递的数据:{{ parentData }}</p>
    
    <!-- 3. 监听子组件声明的自定义事件,绑定处理函数 -->
    <Child 
      @inputChange="handleInputChange"
      @btnClick="handleBtnClick"
    />
  </div>
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件接收数据的容器
const parentMsg = ref('')
const parentData = reactive({
  name: '',
  msg: ''
})

// 4. 处理子组件触发的inputChange事件,接收传递的数据
const handleInputChange = (val) => {
  // val 就是子组件emit传递过来的值(childInput.value)
  parentMsg.value = val
}

// 处理子组件触发的btnClick事件,接收传递的对象
const handleBtnClick = (data) => {
  // data 是子组件传递的对象,直接解构或赋值即可
  parentData.name = data.name
  parentData.msg = data.msg
}
</script>

3. 核心细节说明

  • defineEmits 是 Vue3 内置的宏,无需导入,可直接使用;
  • emit 函数的第一个参数必须和 defineEmits 中声明的事件名一致(大小写敏感),否则父组件无法监听到;
  • emit 可传递多个参数,比如 emit('event', val1, val2),父组件处理函数可对应接收 (val1, val2) => {}
  • 父组件监听事件时,可使用 @事件名(简写)或 v-on:事件名(完整写法),效果一致。

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

基础用法能满足简单场景,但在实际开发中,我们还会遇到「事件校验」「双向绑定」「事件命名规范」等需求,这部分进阶技巧能让你的代码更规范、更健壮。

1. 事件校验:限制子组件传递的数据类型

通过 defineEmits 的对象形式,可对事件传递的数据进行类型校验,避免子组件传递错误类型的数据,提升代码可靠性(类似 props 校验)。

<script setup>
// 对象形式声明事件,key是事件名,value是校验函数(参数是子组件传递的数据,返回boolean)
const emit = defineEmits({
  // 校验inputChange事件传递的数据必须是字符串
  inputChange: (val) => {
    return typeof val === 'string'
  },
  // 校验btnClick事件传递的数据必须是对象,且包含name和msg属性
  btnClick: (data) => {
    return typeof data === 'object' && 'name' in data && 'msg' in data
  }
})

// 若传递的数据不符合校验,控制台会报警告(不影响代码运行,仅提示)
const handleInput = () => {
  emit('inputChange', 123) // 传递数字,不符合校验,控制台报警告
}
</script>

2. 双向绑定:v-model 简化子传父(高频场景)

很多时候,子传父是为了「修改父组件的数据」,比如表单组件、开关组件,这时可使用 v-model 简化代码,实现父子组件双向绑定,无需手动声明事件和处理函数。

Vue3 中,v-model 本质是「语法糖」,等价于 :modelValue="xxx" @update:modelValue="xxx = $event"

优化案例:子组件开关,父组件显示状态

<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <h4>子组件开关</h4>
    <button @click="handleSwitch">
      {{ isOpen ? '关闭' : '打开' }}
    </button>
  </div>
</template>

<script setup>
// 1. 接收父组件通过v-model传递的modelValue
const props = defineProps(['modelValue'])
// 2. 声明update:modelValue事件(固定命名,不可修改)
const emit = defineEmits(['update:modelValue'])

// 子组件内部使用父组件传递的值
const isOpen = computed(() => props.modelValue)

// 开关切换,触发事件,修改父组件数据
const handleSwitch = () => {
  emit('update:modelValue', !isOpen.value)
}
</script>
<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <h3>父组件:{{ isSwitchOpen ? '开关已打开' : '开关已关闭' }}</h3>
    <!-- 直接使用v-model,无需手动监听事件 -->
    <Child v-model="isSwitchOpen" />
  </div>
</template>

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

const isSwitchOpen = ref(false)
</script>

扩展:多个 v-model 双向绑定

Vue3 支持给同一个子组件绑定多个 v-model,只需给 v-model 加后缀,对应子组件的propsemit 即可。

<!-- 父组件 -->
<Child 
  v-model:name="parentName" 
  v-model:age="parentAge" 
/>

<!-- 子组件 -->
<script setup>
// 接收多个v-model传递的props
const props = defineProps(['name', 'age'])
// 声明对应的update事件
const emit = defineEmits(['update:name', 'update:age'])

// 触发事件修改父组件数据
emit('update:name', '新名字')
emit('update:age', 25)
</script>

3. 事件命名规范:提升代码可读性

在实际开发中,遵循统一的事件命名规范,能让团队协作更高效,推荐以下规范:

  • 事件名采用「kebab-case 短横线命名」(和 HTML 事件命名一致),比如 input-change 而非 inputChange
  • 事件名要语义化,体现事件的用途,比如 form-submit(表单提交)、delete-click(删除点击);
  • 双向绑定的事件固定为 update:xxx,xxx 对应 props 名,比如 update:nameupdate:visible

四、实战场景:子传父的常见应用

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

场景1:子组件表单提交,父组件接收表单数据

<!-- 子组件(FormChild.vue) -->
<template>
  <div class="form-child">
    <input v-model="form.name" placeholder="请输入姓名" />
    <input v-model="form.age" type="number" placeholder="请输入年龄" />
    <button @click="handleSubmit">提交表单</button>
  </div>
</template>

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

const emit = defineEmits(['form-submit'])

const form = reactive({
  name: '',
  age: ''
})

const handleSubmit = () => {
  // 表单校验(简化)
  if (!form.name || !form.age) return alert('请填写完整信息')
  // 提交表单数据给父组件
  emit('form-submit', form)
  // 提交后重置表单
  form.name = ''
  form.age = ''
}
</script>

场景2:子组件关闭弹窗,父组件控制弹窗显示/隐藏

<!-- 子组件(ModalChild.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="handleClose">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['visible'])
const emit = defineEmits(['close-modal'])

const handleClose = () => {
  // 触发关闭事件,通知父组件隐藏弹窗
  emit('close-modal')
}
</script>

场景3:子组件列表删除,父组件更新列表

<!-- 子组件(ListChild.vue) -->
<template>
  <div class="list-child">
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <button @click="handleDelete(item.id)">删除</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['list'])
const emit = defineEmits(['delete-item'])

const handleDelete = (id) => {
  // 传递要删除的id给父组件,由父组件更新列表
  emit('delete-item', id)
}
</script>

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

很多新手在写子传父时,会遇到「父组件监听不到事件」「数据传递失败」等问题,以下是最常见的 4 个坑点,帮你快速避坑。

坑点1:事件名大小写不一致

子组件 emit('inputChange'),父组件 @inputchange="handle"(小写),会导致父组件监听不到事件。

解决方案:统一采用 kebab-case 命名,子组件 emit('input-change'),父组件 @input-change="handle"

坑点2:忘记声明事件(defineEmits)

子组件直接调用 emit('event'),未用 defineEmits 声明事件,虽然开发环境可能不报错,但生产环境可能出现异常,且 IDE 无提示。

解决方案:无论事件是否需要校验,都用 defineEmits 声明(数组形式即可)。

坑点3:传递复杂数据(对象/数组)时,父组件修改后影响子组件

子组件传递对象/数组给父组件,父组件直接修改该数据,会影响子组件(因为引用类型传递的是地址)。

解决方案:父组件接收数据后,用 JSON.parse(JSON.stringify(data)) 深拷贝,或用 reactive + toRaw 处理,避免直接修改原始数据。

坑点4:v-model 双向绑定时报错,提示「modelValue 未定义」

原因:子组件未接收 modelValue props,或未声明 update:modelValue 事件。

解决方案:确保子组件 defineProps(['modelValue'])defineEmits(['update:modelValue']) 都声明。

六、总结:子传父核心要点回顾

Vue3 子传父的核心就是「事件触发 + 事件监听」,记住以下 3 个核心要点,就能应对所有场景:

  1. 基础写法:defineEmits 声明事件 → emit 触发事件 → 父组件 @事件名 监听;
  2. 进阶优化:事件校验提升可靠性,v-model 简化双向绑定,遵循 kebab-case 命名规范;
  3. 避坑关键:事件名大小写一致、必声明事件、复杂数据深拷贝、v-model 对应 props 和 emit 命名正确。

子传父是 Vue3 组件通信中最基础的方式,掌握它之后,再学习父传子(props)、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

前端监控实践

从零开发前端监控 SDK:异常、性能、访问量一网打尽

本文将带你从零开发一个完整的前端监控 SDK,涵盖异常监控、性能监控和访问量统计三大核心功能。

目录

  1. 为什么需要前端监控
  2. SDK 架构设计
  3. 核心功能实现
  4. 使用示例
  5. 总结与展望

为什么需要前端监控

在现代 Web 应用中,前端监控已经成为保障用户体验的重要手段:

  • 异常监控:及时发现并修复线上 Bug,减少用户流失
  • 性能监控:优化页面加载速度,提升用户体验
  • 访问统计:了解用户行为,指导产品决策

市面上已有 Sentry、Fundebug 等成熟的监控服务,但开发自己的 SDK 能让我们:

  1. 完全掌控数据,保障隐私安全
  2. 根据业务需求定制功能
  3. 深入理解监控原理,提升技术能力

SDK 架构设计

整体架构

┌─────────────────────────────────────────────────────────────┐
│                        Monitor SDK                          │
├─────────────────────────────────────────────────────────────┤
│  Core Layer  │  Reporter (上报中心)  │  Config (配置管理)    │
├─────────────────────────────────────────────────────────────┤
│  Module Layer│  ErrorMonitor │ PerformanceMonitor │ VisitMonitor│
├─────────────────────────────────────────────────────────────┤
│  Utils Layer │  Device │ Storage │ UUID │ Sampling           │
└─────────────────────────────────────────────────────────────┘

设计原则

  1. 模块化:每个监控功能独立模块,可单独启用/禁用
  2. 插件化:Reporter 统一管理上报,支持批量和即时发送
  3. 低侵入:自动捕获异常,业务代码零改动
  4. 高兼容:支持多种引入方式(ESM/CJS/UMD)

核心功能实现

1. 异常监控模块

异常监控是 SDK 的核心功能,我们需要捕获多种类型的错误:

1.1 JavaScript 运行时错误
// src/modules/error/globalError.ts
export function initGlobalError(reporter: Reporter): () => void {
  const handler = (event: ErrorEvent) => {
    const errorData: ErrorData = {
      type: 'js',
      message: event.message,
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      stack: event.error?.stack
    };
    reporter.report('error', errorData);
  };

  window.addEventListener('error', handler);
  return () => window.removeEventListener('error', handler);
}

通过监听 window.onerror,我们可以捕获所有同步和异步的 JavaScript 错误。

1.2 Promise 未捕获异常
// src/modules/error/promiseError.ts
export function initPromiseError(reporter: Reporter): () => void {
  const handler = (event: PromiseRejectionEvent) => {
    const errorData: ErrorData = {
      type: 'promise',
      message: event.reason?.message || String(event.reason),
      stack: event.reason?.stack
    };
    reporter.report('error', errorData);
  };

  window.addEventListener('unhandledrejection', handler);
  return () => window.removeEventListener('unhandledrejection', handler);
}

现代前端大量使用 Promise,未捕获的 Promise 错误会导致应用崩溃。

1.3 资源加载错误
// src/modules/error/resourceError.ts
export function initResourceError(reporter: Reporter): () => void {
  const handler = (event: Event) => {
    const target = event.target as HTMLElement;
    const tagName = target.tagName?.toLowerCase();

    if (!['img', 'script', 'link'].includes(tagName)) return;

    const src = (target as any).src || (target as any).href || '';
    const errorData: ErrorData = {
      type: 'resource',
      message: `Failed to load ${tagName}: ${src}`,
      filename: src,
      extra: { tagName }
    };
    reporter.report('error', errorData);
  };

  window.addEventListener('error', handler, true); // 捕获阶段监听
  return () => window.removeEventListener('error', handler, true);
}

使用捕获阶段(true)可以监听到资源加载错误。

1.4 网络请求错误

通过劫持 XMLHttpRequest 和 fetch API,监控所有网络请求:

// src/modules/error/networkError.ts
const originalFetch = window.fetch;
window.fetch = function(input: RequestInfo | URL, init?: RequestInit) {
  const startTime = Date.now();
  const url = typeof input === 'string' ? input : input.toString();

  return originalFetch.apply(this, arguments as any)
    .then(response => {
      if (!response.ok) {
        reporter.report('error', {
          type: 'network',
          message: `Fetch ${response.status}: ${response.statusText}`,
          extra: { method: init?.method || 'GET', url, status: response.status }
        });
      }
      return response;
    })
    .catch(error => {
      reporter.report('error', {
        type: 'network',
        message: `Fetch failed: ${error.message}`,
        extra: { method: init?.method || 'GET', url }
      });
      throw error;
    });
};

2. 性能监控模块

2.1 Web Vitals 指标

Core Web Vitals 是 Google 提出的衡量用户体验的关键指标:

// src/modules/performance/webVitals.ts

// LCP - 最大内容绘制
export function observeLCP(reporter: Reporter): void {
  const observer = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    const value = (lastEntry as any).renderTime || lastEntry.startTime;

    reporter.report('performance', {
      type: 'web-vitals',
      name: 'LCP',
      value: Math.round(value),
      rating: value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
    });
  });

  observer.observe({ entryTypes: ['largest-contentful-paint'] as any });
}

// CLS - 累积布局偏移
export function observeCLS(reporter: Reporter): void {
  let clsValue = 0;

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const layoutEntry = entry as PerformanceEntry & { hadRecentInput: boolean; value: number };
      if (!layoutEntry.hadRecentInput) {
        clsValue += layoutEntry.value;
      }
    }
  });

  observer.observe({ entryTypes: ['layout-shift'] as any });

  // 页面隐藏时上报
  window.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      reporter.report('performance', {
        type: 'web-vitals',
        name: 'CLS',
        value: Math.round(clsValue * 1000) / 1000,
        rating: clsValue <= 0.1 ? 'good' : clsValue <= 0.25 ? 'needs-improvement' : 'poor'
      });
    }
  });
}
2.2 导航性能

利用 Navigation Timing API 获取页面加载各阶段耗时:

export function observeNavigation(reporter: Reporter): void {
  window.addEventListener('load', () => {
    setTimeout(() => {
      const navigation = performance.getEntriesByType('navigation')[0]
        as PerformanceNavigationTiming;

      const metrics = [
        { name: 'DNS', value: navigation.domainLookupEnd - navigation.domainLookupStart },
        { name: 'TCP', value: navigation.connectEnd - navigation.connectStart },
        { name: 'TTFB', value: navigation.responseStart - navigation.startTime },
        { name: 'DOM解析', value: navigation.domInteractive - navigation.responseEnd },
        { name: 'Load', value: navigation.loadEventEnd - navigation.startTime }
      ];

      metrics.forEach(({ name, value }) => {
        if (value > 0) {
          reporter.report('performance', {
            type: 'navigation',
            name,
            value: Math.round(value)
          });
        }
      });
    }, 0);
  });
}
2.3 API 耗时监控

劫持 XMLHttpRequest 和 fetch,统计所有 API 请求耗时:

export function observeAPI(reporter: Reporter): () => void {
  // 劫持 XMLHttpRequest
  const originalXHRSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.send = function() {
    const startTime = Date.now();

    this.addEventListener('loadend', function() {
      const duration = Date.now() - startTime;
      reporter.report('performance', {
        type: 'api',
        name: `API: ${this._url}`,
        value: duration
      });
    });

    return originalXHRSend.apply(this, arguments);
  };

  // 劫持 fetch...
}

3. 访问监控模块

3.1 PV 统计
// src/modules/visit/pv.ts
export function observePV(reporter: Reporter, enableSPA: boolean): () => void {
  // 初始页面 PV
  reportPV(reporter);

  if (!enableSPA) return;

  // 劫持 history API 监听路由变化
  const originalPushState = history.pushState;
  history.pushState = function(...args) {
    originalPushState.apply(this, args);
    reportPV(reporter);
  };

  window.addEventListener('popstate', () => reportPV(reporter));
  window.addEventListener('hashchange', () => reportPV(reporter));
}
3.2 Session 管理
// src/modules/visit/session.ts
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30分钟

export function initSession(reporter: Reporter): void {
  const startTime = Date.now();

  // 上报会话开始
  reporter.report('visit', { type: 'session-start' });

  // 页面可见性变化
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      const lastActive = parseInt(storage.get('session_time') || '0');
      if (Date.now() - lastActive > SESSION_TIMEOUT) {
        // 新会话
        reporter.report('visit', { type: 'session-start' });
      }
    } else {
      storage.set('session_time', Date.now().toString());
    }
  });

  // 页面卸载时上报会话结束
  window.addEventListener('beforeunload', () => {
    reporter.report('visit', {
      type: 'session-end',
      duration: Date.now() - startTime
    });
  });
}

4. 数据上报中心

4.1 上报策略
// src/core/reporter.ts
export class Reporter {
  private queue: QueueItem[] = [];
  private readonly FLUSH_INTERVAL = 5000; // 5秒刷新
  private readonly MAX_QUEUE_SIZE = 10;   // 10条批量发送

  report(type: ReportData['type'], data: ReportData['data']): void {
    // 采样检查
    const sampleRate = this.config.sampleRate?.[type] || 1;
    if (!shouldSample(sampleRate)) return;

    const url = this.config.reportUrl[type];
    if (!url) return;

    // 异常数据立即上报
    if (type === 'error') {
      this.sendImmediately(data, url);
    } else {
      // 性能和访问数据批量上报
      this.addToQueue(data, url);
    }
  }

  private addToQueue(data: ReportData, url: string): void {
    this.queue.push({ data, url });

    if (this.queue.length >= this.MAX_QUEUE_SIZE) {
      this.flush();
    } else {
      this.scheduleFlush();
    }
  }
}
4.2 页面关闭补发

使用 sendBeacon API 在页面关闭前发送剩余数据:

private bindEvents(): void {
  const sendRemaining = () => {
    if (this.queue.length === 0) return;

    this.queue.forEach(({ data, url }) => {
      navigator.sendBeacon?.(url, JSON.stringify(data));
    });
  };

  window.addEventListener('beforeunload', sendRemaining);
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      sendRemaining();
    }
  });
}

5. 设备信息解析

// src/utils/device.ts
export function getDeviceInfo(): DeviceInfo {
  const ua = navigator.userAgent;

  // 解析操作系统
  let os = 'unknown';
  let osVersion = 'unknown';

  if (ua.indexOf('Win') !== -1) {
    os = 'Windows';
    const match = ua.match(/Windows NT (\d+\.\d+)/);
    if (match) osVersion = match[1];
  } else if (ua.indexOf('Mac') !== -1) {
    os = 'macOS';
    // ...
  } else if (/iPad|iPhone|iPod/.test(ua)) {
    os = 'iOS';
    // ...
  } else if (ua.indexOf('Android') !== -1) {
    os = 'Android';
    // ...
  }

  // 解析浏览器
  let browser = 'unknown';
  let browserVersion = 'unknown';

  if (ua.indexOf('Chrome') !== -1 && ua.indexOf('Edg') === -1) {
    browser = 'Chrome';
    const match = ua.match(/Chrome\/(\d+\.\d+)/);
    if (match) browserVersion = match[1];
  }
  // ... Safari, Firefox, Edge

  return {
    ua,
    os,
    osVersion,
    browser,
    browserVersion,
    screen: `${window.screen.width}x${window.screen.height}`,
    language: navigator.language
  };
}

使用示例

基础使用

import Monitor from 'frontend-monitor-sdk';

Monitor.init({
  appId: 'my-app',
  appVersion: '1.0.0',
  env: 'production',
  reportUrl: {
    error: 'https://api.example.com/error',
    performance: 'https://api.example.com/perf',
    visit: 'https://api.example.com/visit'
  },
  sampleRate: {
    error: 1,         // 异常100%上报
    performance: 0.1, // 性能10%采样
    visit: 0.1        // 访问10%采样
  },
  enableSPA: true,
  beforeReport: (data) => {
    // 上报前钩子,可修改数据或返回 false 阻止上报
    if (data.type === 'error' && data.data.message?.includes('ignore')) {
      return false;
    }
    return data;
  }
});

Vue 集成

import { createApp } from 'vue';
import Monitor from 'frontend-monitor-sdk';

Monitor.init({ /* ... */ });

const app = createApp(App);
app.config.errorHandler = Monitor.vueErrorHandler;

React 集成

class ErrorBoundary extends React.Component {
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    Monitor.reportError(error, {
      componentStack: errorInfo.componentStack
    });
  }

  render() {
    return this.props.children;
  }
}

总结与展望

已实现功能

异常监控:JS 错误、Promise 错误、资源错误、网络错误、控制台错误、框架错误 ✅ 性能监控:Web Vitals、导航计时、资源性能、API 耗时、长任务 ✅ 访问监控:PV/UV、Session、设备信息、SPA 路由监听 ✅ 数据上报:分类上报、采样控制、批量上报、页面关闭补发

技术亮点

  1. 类型安全:完整的 TypeScript 类型定义
  2. 模块化设计:各功能独立,可灵活组合
  3. 低侵入性:自动捕获,业务代码零改动
  4. 高兼容性:支持 ESM/CJS/UMD 多种格式

未来优化方向

🔲 SourceMap 解析:实现错误堆栈的源码还原 🔲 用户行为录屏:记录用户操作路径,辅助问题定位 🔲 性能面板可视化:开发 Chrome 插件查看性能数据 🔲 离线缓存:支持网络断开时的数据本地存储


参考资源


本文完,如有问题欢迎留言讨论!

想学 Electron?这份「能跑的示例集」一篇搞懂

想学 Electron?这份「能跑的示例集」一篇搞懂

VS Code、钉钉、Slack、Figma 桌面版……这些你熟悉的软件,背后都有同一个名字:Electron。用前端技术(HTML/CSS/JS)就能做桌面应用,是很多团队的选择。但官方文档知识点多、自己从零搭又容易踩坑,有没有一份「按主题拆好、每个都能直接跑」的示例? 有。本文就介绍这样一份示例仓库,并帮你把 Electron 的核心知识点串一遍,看完一篇,知道学什么、去哪看代码


一、Electron 是什么?这个仓库能帮你什么?

一句话: Electron 让你用写网页的技术(HTML/CSS/JavaScript)来开发桌面软件,一套代码可跑在 Windows、macOS、Linux 上。

这个仓库做什么: 把 Electron 常见能力拆成 23 个独立小项目,每个项目一个文件夹,里面是可运行代码 + 说明文档。你不需要从零搭环境,克隆下来进目录、装依赖、运行命令就能看到效果。适合:

  • 想学 Electron 的人:按主题边看边跑,比光看文档好懂;
  • 做桌面端开发的人:遇到「窗口、菜单、通知、IPC」等问题,可以对着对应示例改;
  • 面试前突击的人:仓库里还带 3 套 Electron 面试卷(含答案),可用来自查。

二、怎么跑起来?(3 步)

环境准备: 本机装好 Node.js(建议 18 及以上),示例在 Windows 11 下验证过,macOS / Linux 下大多也可直接运行。

运行任意一个示例:

  1. 克隆仓库;
  2. 进入想玩的案例目录(例如 ipcdarkmode);
  3. 在该目录下执行:npm installnpm run startnpm run start:1(具体看该目录的 readme)。

每个案例的详细命令和说明都在对应目录的 readme.md 里,进去就能看到。


三、Electron 知识点串讲(对应仓库怎么用)

下面按「从入门到进阶」的顺序,把主要概念过一遍,并标出仓库里哪一类示例可以对照着看。不展开代码细节,只帮你建立地图。

1. 入门:第一个应用长什么样?

Electron 应用至少有两个「角色」:主进程(负责创建窗口、系统 API)和渲染进程(你看到的页面)。两者不能直接互相调函数,要通过 preload 脚本安全地暴露接口,或用 IPC(进程间通信)传消息。

仓库对应: tutorial-first-app —— 第一个应用、主进程 / 渲染进程 / preload / IPC 入门。


2. 进程与通信:页面和「后台」怎么配合?

  • IPC:渲染进程和主进程互相发消息(单向、双向、主进程主动推给页面等)。
  • MessagePort:更灵活的通道,适合「渲染进程直连」「流式回复」等场景。
  • Utility Process(效率进程):跑 CPU 重活或容易崩的逻辑,和主进程隔离,用 MessagePort 通信。
  • 多线程:在渲染进程里用 Web Worker,避免大量计算卡住界面。
  • 沙盒:渲染进程默认沙盒、安全配置,减少安全风险。

仓库对应: ipcmessage-portsefficiency-processmultithreadingsandbox


3. 窗口与 UI:桌面应用「长什么样」?

  • 暗色模式:用系统主题或自己切换,和 CSS 的 prefers-color-scheme 配合。
  • 任务栏:Windows 上的 JumpList、缩略图工具栏、进度条、图标闪烁等。
  • 窗口定制:无边框、自定义标题栏、拖拽区域。
  • 进度条:在任务栏 / Dock 上显示进度(如下载、处理任务)。

仓库对应: darkmodewindows-taskbarwindow-customizationprogressbar


4. 系统与原生能力:和操作系统打交道

  • 菜单:应用菜单、右键菜单、托盘菜单。
  • 快捷键:绑定在菜单上的、全局的、窗口内自己监听的。
  • 系统通知:像微信/邮件那样在系统通知栏弹出,而不是只在页面里弹个提示。
  • 设备访问:例如蓝牙(Web Bluetooth API)。
  • 深度链接:自定义协议,从浏览器或别的应用点链接唤起你的应用。

仓库对应: menuskeyboardshortcutnotificationsDemobluetoothdeeplinks


5. 文件与文档、Web 与导航

  • 文件拖拽:把文件从应用拖到桌面或资源管理器。
  • 最近文档:系统「最近打开」列表的集成。
  • Web 嵌入:在窗口里嵌网页(iframe、webview、WebContentsView)。
  • 导航历史:窗口内前进/后退(goBack / goForward)。
  • 在线/离线:检测网络状态,做离线提示或缓存策略。

仓库对应: draganddroprecentdocumentsweb-embedsnavigationHistoryonlineofflineevents


6. 其他常用能力

  • 离屏渲染:在不可见的画布上渲染(例如生成图、PDF)。
  • 拼写检查:系统级拼写检查集成。

仓库对应: offscreenrenderingspellchecker


7. 学习与面试

仓库内带 paper 目录:3 套 Electron 面试卷,各 100 分,含答案与解析,适合考前自查。


四、案例目录一览(按需进目录看 readme 和代码)

下面表格里的目录名,在仓库里对应一个文件夹,点进去有 readme可运行代码。不同子项目可能有不同启动命令(如 npm run start:1start:2),以该目录下的 readme 为准。

分类 序号 目录 说明
入门 1 tutorial-first-app 第一个应用(主进程、渲染进程、preload、IPC 入门)
窗口与 UI 2 darkmode 黑暗模式 / 主题切换
3 windows-taskbar Windows 任务栏(JumpList、缩略图、叠加图标、闪烁)
4 window-customization 自定义窗口(无边框、自定义标题栏、拖拽区域)
5 progressbar 任务栏/Dock 进度条
进程与通信 6 ipc 进程间通信(单向/双向、主→渲染)
7 message-ports 消息端口(MessageChannel、流式回复)
8 efficiency-process 效率进程(Utility Process、MessagePort)
9 multithreading 多线程(Web Workers)
10 sandbox 进程沙盒与安全配置
系统与原生 11 menus 菜单(应用菜单、上下文菜单、托盘)
12 keyboardshortcut 键盘快捷键(局部、全局、窗口内)
13 notificationsDemo 系统通知(主进程/渲染进程)
14 bluetooth 设备访问(如蓝牙)
15 deeplinks 深度链接(自定义协议、从链接唤起应用)
文件与文档 16 draganddrop 文件拖拽(拖出到桌面/资源管理器)
17 recentdocuments 最近文件(系统最近文档列表)
Web 与导航 18 web-embeds Web 嵌入(iframe、webview、WebContentsView)
19 navigationHistory 导航历史(前进/后退)
20 onlineofflineevents 在线/离线事件
其他 21 offscreenrendering 离屏渲染
22 spellchecker 拼写检查器
学习与面试 23 paper Electron 面试卷(3 套,含答案与解析)

五、去哪看代码?仓库在这里

以上所有示例的完整代码、运行命令和说明都在下面这个仓库里,每个案例独立可运行,按目录即可找到对应 readme 和代码。

仓库地址: [gitee.com/sharetoyouc…]

克隆后,进入任意目录执行 npm install,再按该目录 readme 的脚本运行即可。MIT 许可,欢迎 Star、Fork,或提 Issue / PR。


本文基于 Electron 官方文档与示例整理,旨在帮助初学者和开发者快速建立知识地图并找到可运行示例。

告别 JSON.parse(JSON.stringify()) — 原生深拷贝 structuredClone

深拷贝的老办法

在 JavaScript 中深拷贝一个对象,最常见的"hack"写法是:

const copy = JSON.parse(JSON.stringify(original));

这个方法简单粗暴,但有一堆坑:

const obj = {
  date: new Date(),
  regex: /test/gi,
  map: new Map([["key", "value"]]),
  set: new Set([1, 2, 3]),
  undef: undefined,
  fn: () => "hello",
  nan: NaN,
  infinity: Infinity,
};

const copy = JSON.parse(JSON.stringify(obj));
console.log(copy);
// {
//   date: "2026-02-11T06:00:00.000Z",  ← 变成了字符串
//   regex: {},                           ← 丢失了
//   map: {},                             ← 丢失了
//   set: {},                             ← 丢失了
//                                        ← undefined 直接消失
//                                        ← 函数直接消失
//   nan: null,                           ← 变成了 null
//   infinity: null                       ← 变成了 null
// }

还有一个致命问题 —— 循环引用直接报错:

const a = { name: "a" };
a.self = a;
JSON.parse(JSON.stringify(a)); // ❌ TypeError: Converting circular structure to JSON

structuredClone 登场

structuredClone() 是浏览器和 Node.js (v17+) 提供的原生深拷贝方法:

const original = {
  date: new Date(),
  regex: /test/gi,
  map: new Map([["key", "value"]]),
  set: new Set([1, 2, 3]),
  nested: { deep: { value: 42 } },
  arr: [1, [2, [3]]],
};

const copy = structuredClone(original);

copy.nested.deep.value = 0;
console.log(original.nested.deep.value); // 42 ✅ 互不影响

copy.date instanceof Date; // true ✅ 类型保留
copy.regex instanceof RegExp; // true ✅
copy.map instanceof Map; // true ✅
copy.set instanceof Set; // true ✅

循环引用也能正确处理:

const a = { name: "a" };
a.self = a;
const b = structuredClone(a); // ✅ 正常工作
b.self === b; // true(引用指向拷贝后的自身)

支持的类型

structuredClone 使用的是结构化克隆算法,支持绝大多数内置类型:

类型 JSON 方式 structuredClone
Date ❌ 变字符串
RegExp ❌ 变 {}
Map / Set ❌ 变 {}
ArrayBuffer
undefined ❌ 丢失
NaN / Infinity ❌ 变 null
循环引用 ❌ 报错

不支持什么

有几种东西是 structuredClone 无法克隆的:

// ❌ 函数
structuredClone({ fn: () => {} });
// DOMException: () => {} could not be cloned.

// ❌ DOM 节点
structuredClone(document.body);

// ❌ 原型链(拷贝后丢失)
class Dog {
  bark() { return "woof"; }
}
const dog = new Dog();
const cloned = structuredClone(dog);
cloned instanceof Dog; // false
cloned.bark; // undefined

所以如果你的对象包含函数或需要保留原型链,structuredClone 不适用。

一个实用技巧:transferable objects

structuredClone 支持第二个参数 transfer,可以"移交"而不是"复制"某些对象(如 ArrayBuffer),避免内存翻倍:

const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const copy = structuredClone(buffer, { transfer: [buffer] });

console.log(buffer.byteLength); // 0 ← 原始的被清空了
console.log(copy.byteLength); // 1048576 ← 数据转移到了 copy

这在处理大型二进制数据时非常有用。

兼容性

  • Chrome 98+, Firefox 94+, Safari 15.4+, Node.js 17+
  • 2026 年的今天,基本可以放心使用

总结

场景 推荐方案
简单对象,无特殊类型 JSON.parse(JSON.stringify()) 仍然可用
包含 Date/Map/Set/循环引用 structuredClone()
需要保留原型链/函数 手写递归或 lodash _.cloneDeep()

以后深拷贝,先想想 structuredClone 吧。


原文链接:chenguangliang.com/posts/js-st…

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

免责声明:本文仅供技术交流与学习,请勿利用文中技术手段对他人的服务器造成压力或进行恶意爬取。所有测试数据均来自公开接口。

🕵️‍♂️ 从一个好奇心开始

前几天逛一个数字产品合租平台(nf.video)时,我发现它首页只孤零零地挂着 6 个商品:Netflix、Disney+、Spotify 等常见的全家桶。

作为一个前端开发者,我的直觉告诉我:事情没这么简单

通常这类平台为了 SEO 或者后台管理的统一性,数据库里往往躺着更多商品,只是因为库存、策略原因被前端"隐藏"了。今天就带大家通过浏览器控制台(Console),用几招前端调试技巧,扒出那些藏在代码背后的秘密。


🔍 第一层:摆在明面上的数据

首先,我们看看普通用户能看到什么。打开控制台,简单查一下 DOM:

// 获取首页所有商品卡片
const cards = document.querySelectorAll('.platFormItem');
console.log(`首页可见商品数: ${cards.length}`);
// 输出: 6

确实只有 6 个。这建立了我们的"基准线"。如果后面我们找到了多于 6 个的数据,那就说明有"隐藏款"。


🎣 第二层:Vue Router 拦截术

点击商品卡片会跳转到购买页。通常我们会看 Network 面板找链接,但这个网站是 SPA(单页应用),点击是路由跳转。

为了不真的跳走(跳走就得退回来,麻烦),我们可以利用 Vue Router 的全局前置守卫来做一个"钩子"。我们想知道点击卡片后,路由到底想带我们去哪?

我们可以直接在控制台注入这段代码:

// 假设挂载在 app 上的 router 实例(视具体项目而定,通常在 vueApp.config 或 __vue_app__ 中)
// 这里演示思路
const router = document.querySelector('#app').__vue_app__.config.globalProperties.$router;

// 👮‍♂️ 注册一个拦截守卫
router.beforeEach((to, from, next) => {
    console.log(`🎯 捕获到目标路由: ${to.fullPath}`);
    console.log(`📦 参数 ID: ${to.params.id}`);
    
    // ✋ next(false) 阻止实际跳转,我们就停在当前页
    next(false); 
});

然后在页面上点击一个"苹果商店"的卡片:

Console 输出: 🎯 捕获到目标路由: /buy/31 📦 参数 ID: 31

Bingo!我们摸清了路由规则:/buy/:id。这意味着商品是以 ID 为索引的。


🕵️ 第三层:Performance API 里的蛛丝马迹

页面加载完了,Network 面板里的请求都被冲掉了或者很难找。这时,浏览器原生的 Performance API 就像一个黑匣子,记录了所有发生过的资源请求。

我想看看前端到底请求了哪些 API 接口:

// 筛选所有 XMLHttpRequest 或 Fetch 请求
const apiRequests = performance.getEntriesByType('resource')
  .filter(e => e.initiatorType === 'xmlhttprequest' || e.initiatorType === 'fetch')
  .map(e => e.name);

console.table(apiRequests);

在一堆日志里,我发现了这几个有趣的接口:

  • /api/applets/goods/get/homeManage (首页数据,估计就那 6 个)
  • /api/applets/goods/get/categoryGoods (分类商品?这个听起来有戏!)

我尝试手动调用了一下这个 categoryGoods 接口:

fetch('/api/applets/goods/get/categoryGoods')
  .then(res => res.json())
  .then(data => console.log(`拿到所有商品数: ${data.data.length}`));
// 输出: 27

27 个! 远超首页的 6 个。

通过分析返回的 JSON,我看到了大量首页没展示的商品:

  • ID 20: MagSafe 三合一无线充
  • ID 96: 银河次时代智能 NAS (这啥黑科技?)
  • ID 111: Typora 正版授权

到这里,如果是普通用户可能就满足了。但作为程序员,我注意到 ID 并不连续。最大的 ID 是 113,但中间缺了很多数字。

那些消失的 ID 去哪了?


🚀 第四层:ID 暴力枚举与深度挖掘

既然知道了 API 模式是 /api/applets/goods/get/:id,且 ID 是数字。那我能不能写个脚本,把 1 到 200 的 ID 全扫一遍?

这就像是在玩"扫雷"。

// 简单的并发探测脚本
async function scanHiddenGoods(maxId) {
    const hiddenGoods = [];
    
    console.log(`🚀 开始扫描 ID 1 - ${maxId}...`);
    
    const promises = [];
    for (let id = 1; id <= maxId; id++) {
        const p = fetch(`/8081/api/applets/goods/get/${id}`)
            .then(res => res.json())
            .then(res => {
                // 如果接口返回成功且有数据
                if (res.code === 10000 && res.data) {
                    return { id, name: res.data.goodsName, price: res.data.price };
                }
                return null;
            })
            .catch(() => null);
        promises.push(p);
    }

    const results = await Promise.all(promises);
    return results.filter(Boolean); // 过滤掉 null
}

// 让我们跑一下
scanHiddenGoods(200).then(goods => {
    console.table(goods);
    console.log(`🎉 共发现商品: ${goods.length} 个`);
});

几秒钟后,控制台打出了一张长长的表格。

结果令人震惊:

除了刚才分类列表里的 27 个,我又挖出了 8 个"幽灵商品"。这些商品连分类 API 都不返回,完全是"隐形"的,只有通过 ID 直达才能看到:

ID 名称 这居然也有?
18 GPT Plus 可能因为合规问题隐藏
26 Midjourney 只能直接访问购买
50 Runway 那个文生视频的 AI
105 Codex 编程神器

这些商品很可能是测试下架的、或者是仅限内部/老客户通过链接购买的。


📝 总结

通过这次探索,我们发现了网站里共有 34 个有效商品,而首页只展示了 17%

回顾一下我们的"作案工具":

  1. DOM 解析:看清表象。
  2. Vue Router 守卫:拦截路由,探知路径规则。
  3. Performance API:回溯历史请求,定位关键后端接口。
  4. Promise.all 并发探测:暴力枚举,发现离散数据。

前端开发不仅仅是画页面,善用浏览器提供的调试工具,我们可以对正在运行的应用有更深层的理解(或者单纯是为了满足好奇心 😉)。


如果你觉得这个分析过程有趣,欢迎点赞收藏!

零 JavaScript 的性能优化视频嵌入

原文:Performance-Optimized Video Embeds with Zero JavaScript

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

嵌入视频往往会显著拖慢页面:播放器会加载一堆额外资源,即使用户最终根本不点播放。

常见的优化是用 lite-youtube-embed 之类的轻量组件先占位、再按需加载。但如果视频就在首屏(above the fold),仍然可能因为占位与真实播放器尺寸/渲染时机问题带来 CLS(累计布局偏移)。

这篇文章给出一种“极简但很实用”的模式:只用原生 HTML 的 <details> / <summary> + 一点 CSS,实现交互时才加载 iframe,并且不写一行 JS。

解决方案:用 <details> / <summary> 作为交互边界

<summary> 的默认行为类似按钮:点击会展开对应 <details>,浏览器会给 <details> 加上 open 属性;再点一次就收起。

页面初始加载时,<details> 内除了 <summary> 以外的内容默认不显示——这使它天然适合“用户交互后才呈现”的内容(比如 iframe 视频)。

懒加载:要避免“首屏懒加载反伤”

现代浏览器支持 loading="lazy" 对图片与 iframe 做原生懒加载。

但需要注意:把所有东西都懒加载,可能反而让 LCP 变差。Chrome 团队的研究提到,过度懒加载可能让 LCP 下降约 20%,尤其是当你把内容懒加载到首屏视口里时。

这里的关键点在于:iframe 视频作为 <details> 的内容,在用户点击之前并不算“初始视口内容”,所以不会触发那种“首屏懒加载带来的反效果”。

结论:如果你本来就把视频放在一个可折叠区域里(accordion),那就非常适合把它延迟到“用户想看”的那一刻才加载。

样式:把 <summary> 做成视频缩略图

默认的 <details> 样式很朴素。我们可以把 <summary> 做成一个“视频缩略图占位”,上面叠一个自定义播放按钮。

<details class="video-embed">
  <summary class="video-summary" aria-label="播放视频:Big Buck Bunny">
    <img
      src="https://lab.n8d.studio/htwoo/htwoo-core/images/videos/big-bug-bunny.webp"
      class="video-thumbnail"
      alt=""
    />
    <svg class="video-playicon" viewBox="0 0 32 32" aria-hidden="true">
      <path d="m11.167 5.608 16.278 8.47a2.169 2.169 0 0 1 .011 3.838l-.012.006-16.278 8.47a2.167 2.167 0 0 1-3.167-1.922V7.529a2.167 2.167 0 0 1 3.047-1.981l-.014-.005.134.065z" />
    </svg>
  </summary>

  <div class="video-content">
    <!-- 原始 embed 代码尽量不改,直接放进来 -->
    <iframe
      src="https://www.youtube.com/embed/aqz-KE-bpKQ?autoplay=1"
      title="Big Buck Bunny"
      loading="lazy"
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
      allowfullscreen
    ></iframe>
  </div>
</details>

要点:

  • 缩略图与 iframe 维持同一宽高比(避免布局跳动)。
  • 播放按钮用自有 SVG,保证品牌一致性。
  • aria-label 给屏幕阅读器一个明确的动作提示(作者也强调需要做跨 VoiceOver/NVDA/JAWS 的实际测试)。

CSS 可以用 grid 把按钮叠在缩略图正中:

.video-summary {
  display: grid;
  place-items: center;
}

.video-thumbnail,
.video-playicon {
  grid-area1 / 1;
}

.video-playicon {
  width64px;
  height64px;
}

展开后隐藏缩略图,让 iframe 出现

<summary> 默认即使展开也会持续可见;但我们展开后希望看到的是 iframe,而不是缩略图。

思路很简单:当 <details> 具备 open 属性时,把 summary 隐藏。

.video-embed {
  position: relative;
}

.video-embed[open] .video-summary {
  visibility: hidden;
}

.video-content iframe {
  width100%;
  height100%;
}

用户点击缩略图时:

  • 浏览器把 open 加到 <details>
  • summary 被隐藏
  • iframe 进入视口并开始加载(而且只在用户真的想看时才加载)

小提示:对于 YouTube,可以在 iframe URL 上加 ?autoplay=1,让播放器尽快开始播放;但如果用户浏览器禁用了 autoplay,仍需要再次点击。

性能对比(与 lite-youtube-embed)

作者用同一张缩略图对比了本方案与 lite-youtube-embed

指标 <details> 模式 lite-youtube-embed 更优
Load Time 595ms 693ms <details>(约快 14%)
FCP 11ms 70ms <details>(约快 6.4×)
LCP 97ms 157ms <details>(约快 1.6×)
Transfer 34 KB 84 KB <details>(约少 2.5×)
CLS 0.0075 0.0000 都不错
TBT 0ms 0ms 持平
JavaScript 0 ~3KB <details>

(原文还提到资源请求数量也显著更少。)

收尾

  • <details> 自 2011 起就在浏览器中可用
  • iframe 原生 lazy loading 大约在 2019 落地

把两者结合起来,你就能获得“首屏更快、重内容延后、交互自然、键盘可用”的视频嵌入体验,而且完全不依赖 JavaScript。

它不是一个“产品”,而是一个“模式”:同样适用于 Vimeo、自托管视频、GIF、CodePen、地图等任何重量级嵌入内容。

gsap 配置解读 --1

toggleActions: "play none none reverse" 是什么意思

gsap.to(panel, {
y: 0,
opacity: 1,
duration: 0.8,
ease: "power2.out", 
scrollTrigger: {
trigger: panel, 
start: "top 80%", // 当 panel 的顶部到达 viewport 的 80% 位置时,进入触发区
end: "top 40%", // 当 panel 的顶部到达 viewport 的 40% 位置时,离开触发区 
toggleActions: "play none none reverse"
} 
});
位置 触发时机 说明
1. onEnter 元素从上往下滚动进入触发区间(比如进入 startend 区域) 此处是 "play" → 播放动画
2. onLeave 元素继续向下滚动,离开触发区间(滚出 end 之后) 此处是 "none" → 什么都不做
3. onEnterBack 元素从下往上滚动,重新进入触发区间(反向滚动进入) 此处是 "none" → 什么都不做
4. onLeaveBack 元素继续向上滚动,离开触发区间(反向滚出 start 之前) 此处是 "reverse" → 反向播放动画(即倒放)

toggleActions

动作值 效果
"play" 播放动画(从当前进度开始)
"pause" 暂停动画
"resume" 恢复播放(如果已暂停)
"reverse" 反向播放(倒放)
"restart" 从头开始播放
"reset" 重置到初始状态
"none" 无操作(保持当前状态)

 典型使用场景对比:

需求 推荐 toggleActions
进入播放,离开重置 "play none none reset"
进入播放,反向离开时倒放 "play none none reverse" ← 你的情况
只播放一次,之后不再动 "play pause pause pause"
来回都播放 "play play play play"(不推荐,会闪烁)

paused: true是什么意思

{
x: 280, 
scale: 0.5,
opacity: 0,
duration: 1,
ease: "power2.out",
paused: true 
});

在 GSAP(GreenSock Animation Platform)中,paused: true 是一个动画配置选项,它的作用是:

创建动画时立即暂停(不自动播放),等待后续手动控制播放。

  • gsap.from(...) 表示:从指定的起始状态(x=280, scale=0.5, opacity=0)动画到元素当前的 CSS 状态
  • 但由于设置了 paused: true,这个动画不会立刻执行,而是被“冻结”在初始状态(即元素保持原样,不会动)。
  • 你需要手动调用 tween.play() 才会开始播放动画。

✅ 为什么需要 paused: true

通常用于以下场景:

1. 延迟触发动画

比如点击按钮、滚动到某位置、或满足某个条件后再播放:

document.querySelector('#btn').addEventListener('click', () => { 
tween.play(); // 点击时才播放
});
2. 配合 ScrollTrigger 或其他交互逻辑

你可能先定义好动画,等 ScrollTrigger 初始化完成后再关联:

ScrollTrigger.create({
trigger: ".section",
start: "top center",
onEnter: () => tween.play()
});
3. 复用动画

同一个 tween 可以多次 play()reverse()restart(),而不会重复创建。

方法 作用
tween.play() 播放动画
tween.pause() 暂停动画
tween.reverse() 反向播放(从当前进度倒放回起点)
tween.restart() 从头开始播放
tween.seek(0.5) 跳转到动画 50% 进度
tween.progress(1) 瞬间跳到结束状态
配置 含义
paused: true 创建动画但不自动播放,需手动调用 .play() 等方法控制
默认(不写) 动画创建后立即自动播放

gsap.set() set 是什么意思

在 GSAP(GreenSock Animation Platform)中,gsap.set() 是一个立即设置元素属性的方法,不会产生动画过渡,而是瞬间应用指定的样式或属性值

方法 是否动画 用途
gsap.set(target, vars) ❌ 否 立即设置属性(相当于“初始化状态”)
gsap.to(target, vars) ✅ 是 从当前状态 动画到 指定状态
gsap.from(target, vars) ✅ 是 从指定状态 动画到 当前状态
gsap.fromTo(target, fromVars, toVars) ✅ 是 自定义起始和结束状态

clearProps: "all"是什么意思

gsap.set([boxA, boxB], { clearProps: "all" }); 这行代码的作用是:

立即清除 boxA 和 boxB 元素上由 GSAP 设置的所有内联样式属性(比如 transformopacitybackgroundColor 等),让它们恢复到 GSAP 干预之前的状态(即仅受 CSS 类或原始 HTML 样式控制)。


✅ clearProps 的作用详解

  • GSAP 在执行动画(如 gsap.to()gsap.from())或 gsap.set() 时,会直接写入元素的 style 属性(例如:<div style="transform: translateX(100px); opacity: 0.5;">)。
  • 这些内联样式优先级很高,会覆盖你写的 CSS 类。
  • 使用 clearProps 可以清理这些“残留”的内联样式,避免干扰后续布局或样式。
说明
"all" 清除 所有 GSAP 设置过的内联样式(最常用)✅
"transform" 仅清除 transform 相关属性(如 x, y, scale, rotation 等)
"opacity,backgroundColor" 清除指定的多个属性(用逗号分隔)
"x,y" 仅清除 xy(即 transform: translateX/Y

💡 注意:clearProps 只清除 GSAP 显式设置过 的属性,不会影响其他 JavaScript 或 HTML 中原本就有的 style

🎯 使用场景举例

场景 1:重置动画状态

js

// 先执行一个动画
gsap.to(boxA, { x: 100, backgroundColor: "red", duration: 1 });

// 后来想让它完全回到原始 CSS 样式
gsap.set(boxA, { clearProps: "all" });
// 效果相当于:boxA.style.cssText = ""; (但更安全,只清 GSAP 设置的)
场景 2:避免 transform 冲突

css

.my-box {
  transform: rotate(10deg); /* 原始 CSS transform */
}

js

gsap.to(".my-box", { x: 50 }); // GSAP 会合并 transform,变成 rotate + translate
gsap.set(".my-box", { clearProps: "transform" }); // 清除后,只剩 rotate(10deg)
场景 3:组件销毁前清理

在 React/Vue 组件卸载时,清除 GSAP 添加的样式,防止内存泄漏或样式残留。


⚠️ 注意事项

  1. clearProps: "all" 不会删除非 GSAP 设置的内联样式
    比如你手动写了 <div style="color: blue">,GSAP 不会动它。
  2. transform 是一个整体
    即使你只设置了 x: 100clearProps: "transform" 也会清除整个 transform 字符串。
  3. autoAlpha 会同时影响 opacity 和 visibility
    如果你用了 autoAlpha,需要同时清除这两个属性。
代码 作用
gsap.set(el, { clearProps: "all" }) 彻底清除 GSAP 对该元素设置的所有内联样式,恢复“干净”状态

keyframes是什么意思

const tween = gsap.to(shape, {
        keyframes: [
          { x: -160, rotation: -15, duration: 0.4 },
          { x: 0, scale: 1.2, duration: 0.4 },
          { x: 160, rotation: 20, duration: 0.4 },
          { x: 0, scale: 1, rotation: 0, duration: 0.4 }
        ],
        ease: "power1.inOut",
        paused: true
      });

在 GSAP(GreenSock Animation Platform)中,keyframes 是一种将多个动画步骤串联起来的方式,类似于 CSS 的 @keyframes,但功能更强大、更灵活。

这段代码的意思是:

对 shape 元素执行一个由 4 个关键帧组成的复合动画,每个关键帧持续 0.4 秒,总共 1.6 秒。动画被暂停(paused: true),需手动调用 .play() 才会运行。


✅ keyframes 的工作原理

  • 每个对象代表一个动画阶段(关键帧)
  • GSAP 会按顺序依次播放这些关键帧。
  • 每一帧的属性是从上一帧的结束状态过渡到当前帧的目标值。
  • 每帧可以有自己的 durationease(如果未指定,则继承外层的 ease)。
动画流程分解:
阶段 起始状态 → 目标状态 效果
第1帧 当前状态 → {x: -160, rotation: -15} 向左飞 + 左转
第2帧 上一帧结束 → {x: 0, scale: 1.2} 回到中心 + 放大
第3帧 上一帧结束 → {x: 160, rotation: 20} 向右飞 + 右转
第4帧 上一帧结束 → {x: 0, scale: 1, rotation: 0} 回到原位 + 还原大小和角度

🔧 keyframes 的高级用法

1. 每帧可单独设置缓动(ease)

js

keyframes: [
  { x: 100, duration: 0.3, ease: "back.out" },
  { x: 0, duration: 0.3, ease: "elastic.out" }
]
2. 支持回调函数

js

keyframes: [
  { x: 100, duration: 0.5 },
  { 
    x: 0, 
    duration: 0.5,
    onComplete: () => console.log("第二帧完成") 
  }
]
3. 与 ScrollTrigger、Timeline 结合

js

gsap.timeline({
  scrollTrigger: { trigger: ".section", start: "top center" }
}).to(shape, {
  keyframes: [ /* ... */ ]
});

⚠️ 注意事项

  • keyframes 是 GSAP 3.0+  引入的功能,在旧版本中不可用。
  • 外层的 ease(如你的 "power1.inOut")会作为默认缓动应用到每一帧(除非某帧自己指定了 ease)。
  • 如果某帧没有指定 duration,它会继承前一帧的 duration 或使用默认值(通常为 0.3 秒)。

✅ 为什么用 keyframes 而不用多个 gsap.to()

表格

方式 优点
keyframes 代码更紧凑,自动串联,易于管理单个动画序列
多个 gsap.to() 更灵活(可插入延迟、回调等),适合复杂编排(推荐用 gsap.timeline()

对于简单的线性多步动画,keyframes 非常简洁;对于复杂时间轴,建议用 gsap.timeline()


keyframes = 把多个动画步骤写在一个数组里,GSAP 自动按顺序播放它们。

你的代码创建了一个“左右晃动 + 缩放”的弹性动画,常用于:

  • 按钮点击反馈
  • 错误提示抖动
  • 卡片翻转/弹跳效果

配合 paused: true,你可以在需要时(如点击、滚动)通过 tween.play() 触发动画,非常高效!

stagger 是什么意思

在 GSAP(GreenSock Animation Platform)中,stagger 是一个非常强大的功能,用于对多个目标元素(如数组、NodeList)依次错开播放动画,从而创建出“波浪式”、“逐个入场”等流畅的序列动画效果。

 const tween = gsap.from(cells, {
        opacity: 0,
        scale: 0.4,
        y: 20,
        duration: 0.6,
        ease: "power2.out",
       stagger: { 
           each: 0.04, // 每个元素之间的延迟时间(秒)
           from: "center" // 动画从中间的元素开始,向两边扩散
       },
        paused: true
      });

这段代码的作用是:

对 cells(一组 DOM 元素)执行“从透明、缩小、下移”状态淡入放大的动画,但不是同时播放,而是:

  • 从中间的元素开始
  • 相邻元素之间间隔 0.04 秒依次播放
  • 整体形成一种“由中心向外扩散”的入场效果 ✨

✅ stagger 的核心概念

当你对多个元素(如 document.querySelectorAll('.cell'))使用 GSAP 动画时:

  • 不加 stagger → 所有元素同时动画。
  • 加上 stagger → 元素依次错开动画,产生节奏感。

🔧 stagger 的常见写法

1. 最简形式:只指定间隔时间

js

stagger: 0.1  // 等价于 { each: 0.1 }

→ 从第一个元素开始,每个间隔 0.1 秒。

2. 对象形式(你用的方式):更精细控制

js

stagger: {
  each: 0.04,     // 每个元素间隔 0.04 秒
  from: "center", // 起始位置:可选 "start"(默认)、"center""end" 或具体索引(如 3)
  grid: "auto",   // 如果是网格布局,可设为 [rows, cols] 来按行/列交错
  axis: "x"       // 在网格中限制交错方向("x""y""xy")
}

🎯 from 的取值说明

效果
"start"(默认) 从第一个元素开始,依次到最后一个
"center" 从中间元素开始,向左右(或上下)同时扩散
"end" 从最后一个元素开始,倒序播放
数字(如 2) 从索引为 2 的元素开始

✅  from: "center" 非常适合居中对齐的列表、图标阵列、卡片网格等场景,视觉上更平衡。


💡 实际效果示例

假设 cells 有 5 个元素:[A, B, C, D, E]

  • from: "center" → 播放顺序:C → B & D → A & E
  • 每个间隔 0.04s,所以整个动画在约 0.04 × 2 = 0.08s 内完成扩散(因为两边并行)

这比线性播放(A→B→C→D→E)更生动!


⚠️ 注意事项

  • stagger 只在目标是多个元素时生效。如果 cells 只有一个元素,stagger 会被忽略。
  • stagger 的延迟是叠加在 duration 之上的,不影响单个动画的时长。
  • 可与 paused: true 完美配合,实现“按需触发动画序列”。

配置 含义
stagger: { each: 0.04, from: "center" } 从中间元素开始,以 0.04 秒的间隔向两侧依次播放动画

这是 GSAP 实现高级交互动效(如列表加载、菜单展开、数据可视化入场)的核心技巧之一。你的代码就是一个典型的“优雅批量入场”动画!

从 ES2015 到 ES2025:你还跟得上吗

ES6 是 2015 年发布的。
距离现在,已经过去整整十年。

这十年里,JavaScript 每一年都在进化。
新语法、新 API、新并发模型、新数据结构……

可大多数人,对 JavaScript 的认知,仍停留在:

  • 箭头函数
  • 解构赋值
  • Promise
  • let / const

从 ES2016 到 ES2025,你真的跟上了吗?

这篇文章,我会按时间线带你系统梳理 JavaScript 十年的演进轨迹。


ES2016 → ES2020

这5年新出的特性我想大多数人都已经熟练使用了,这里就简单列下,不详细介绍api细节了

ES2016

这是一个小版本,主要有以下3个特性:

  • Array.prototype.includes()
  • 指数运算符 (**)
    2 ** 3; // 8
    
  • 幂赋值运算符**=
    let num = 2; 
    num **= 3; // num = num ** 3 
    console.log(num); // 8
    

ES2017

ES2017的重点是异步编程,对象操作

  • async/await
  • Object.values()/Object.entries()
  • Object.getOwnPropertyDescriptors(): 返回对象所有自身属性的描述符对象
  • 字符串填充String Padding
console.log('5'.padStart(3, '0')); // '005' 
console.log('hello'.padEnd(10, '*')); // 'hello*****'
  • SharedArrayBuffer 和 Atomics

    这两个用在web worker中。主线程和worker使用postMessage通信往往要将数据拷贝一份,SharedArrayBuffer 则允许 Worker 线程与主线程共享同一块内存,通过 postMessage 将 SharedArrayBuffer 转移给 Worker,不会复制数据:

    // main.js
     const sab = new SharedArrayBuffer(1024);
     const worker = new Worker('worker.js');
     worker.postMessage(sab); //不复制数据
    
    // worker.js
     self.onmessage = (e) => {
       const sab = e.data; // 同一个内存块
     };
    

计算机中写操作可能被编译成多条指令,如果尚未写完就有其他线程读数据,便会产生错误。在多线程操作SharedArrayBuffer时就可能会出现这种问题。Atomics提供了原子级操作,其他线程读取到的数据,要么是没写入的,要么是已写完的。另外Atomics还提供了线程的阻塞和唤醒。

ES2018

ES2018新增了多个特性,算是一次中等规模升级,主要有异步编程的增强、对象和数组操作的改进、正则表达式的扩展,以及模板字面量的优化。

  • 异步生成器/异步迭代器
async function* fetchPages() {
  let page = 1;
  while (page <= 3) {
    const response = await fetch(`https://api.example.com/page/${page}`);
    yield await response.json();
    page++;
  }
}

(async () => {
  for await (const data of fetchPages()) {
    console.log(data);
  }
})();
  • Promise.prototype.finally()
  • rest/spreat操作符...
  • 正则表达式增强(后行断言,命名捕获等)
  • 模板字符串的标签模板提供raw访问原始字符串
function tag(strings) { 
    return strings.raw[0]; // 访问原始字符串,包括非法转义,比如LaTeX语法
} 
console.log(tag`\u{00000042}`); // strings[0]是'B' strings.raw[0]为\u{00000042}

ES2019

该版本内容不多但很实用

  • Array.prototype.flat() / flatMap()
  • Object.fromEntries()
  • String.trimStart() / trimEnd()
  • Optional catch binding: catch 可省略错误参数
try {
  JSON.parse('invalid json');
} catch {
  console.log('Parsing failed'); // 无需未使用的 error 变量
}
  • Symbol.description
  • Function.prototype.toString()能返回函数精确源码,包括注释和空格,方便调试
  • 稳定的 Array.prototype.sort()

ES2020

这个版本也是一个里程碑,更新了大量内容,而且都很实用

  • BigInt
  • Dynamic Import import()
  • 空值合并运算符??
  • 可选链运算符?.
  • Promise.allSettled()
  • String.prototype.matchAll()
  • 标准全局对象globalThis
  • 模块命名空间导出(export * as ns from 'mod')
  • for-in 枚举顺序与定义顺序一致

从ES2021开始新增的特性,在我日常code review中看到的越来越少了,但很多特性还是很实用的。

ES2021

String.prototype.replaceAll()

在此之前全局替换需要用正则/g标志

逻辑赋值运算符 (&&=, ||=, ??=)

let x = 0;
x ||= 10; // x = 10(因为 0 是 falsy)

let y = 5;
y &&= 20; // y = 20(因为 5 是 truthy)

let z;
z ??= 'default'; // z = 'default'(因为 undefined 是 nullish)

数字分隔符(1_000_000)

允许在数字字面量中使用下划线(_)作为分隔符,提高大数字的可读性。

Promise.any()

返回一个 Promise,在任意一个输入 Promise resolved 时解决;如果所有 rejected,则返回 AggregateError。

  • 语法:Promise.any(iterable)

    • iterable:Promise 数组或其他可迭代对象。
  • 示例

    const promises = [
      Promise.reject('Error 1'),
      Promise.resolve('Success'),
      Promise.reject('Error 2')
    ];
    Promise.any(promises)
      .then(value => console.log(value)) // 'Success'(第一个 resolved)
      .catch(error => console.error(error)); // 如果全 reject:AggregateError
    

WeakRefs和FinalizersRegistry:

  • WeakRefs 用于引用对象而不阻止垃圾回收
  • FinalizersRegistry 用于缓存或观察对象,而不干扰内存管理;FinalizationRegistry 提供清理回调,但不保证时序。
let obj = { name: 'Alice' };
const weak = new WeakRef(obj);

const registry = new FinalizationRegistry(heldValue => {
  console.log(`Object with ${heldValue} cleaned up`);
  console.log(weak.deref());//undefined
});
registry.register(obj, 'Alice');
obj = null; // GC 时调用 callback

ES2022

Top-level await:

可以直接在模块最外层使用 await

Class的私有/静态成员和方法

增加了#标识私有,static标识静态(现在都用ts了,这两个特性很少用到)

 class Counter {
  #value = 0;

  #increment() {
    this.#value++;
  }

  get #count() {
    return this.#value;
  }

  add() {
    this.#increment();
  }

  getValue() {
    return this.#count;
  }
}

const c = new Counter();
c.add();
console.log(c.getValue()); // 1
// c.#increment(); // SyntaxError,不过控制台访问不会报错
class MathUtils {
  static PI = 3.14159;
  static #secret = 42;

  static getPi() {
    return this.PI;
  }

  static getSecret() {
    return this.#secret;
  }
}

console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.getSecret()); // 42

Error.cause:

在 Error 对象中添加 cause 属性,用于链式记录错误上下文,方便调试。

 try {
  throw new Error('Original issue');
} catch (err) {
  throw new Error('Failed operation', { cause: err });
}
// 结果错误:Failed operation (cause: Original issue)

at方法:

新增访问索引方法,可用于数组、字符串和 TypedArray

const arr = [1, 2, 3];
console.log(arr.at(1)); // 2
console.log(arr.at(-1)); // 3(最后一个元素,极力推荐这种写法)
console.log('hello'.at(-2)); // 'l'

Object.hasOwn():

代替Object.prototype.hasOwnProperty.call()

正则表达式的/d标志:

可用/d标志获取匹配范围

    const match = 'hello world'.matchAll(/(hello)/dg);
    for (const m of match) {
      console.log(m.indices[0]); // [0, 5] 对于 'hello'
    }

ES2023

Array 和 TypedArray的末尾查找

新增findLast() / findLastIndex()

通过拷贝修改数组(不改变原数组)

新增 toSorted() / toReversed() / toSpliced() / with()

  • 语法

    • array.toReversed():返回反转拷贝。
    • array.toSorted(compareFn):返回排序拷贝。
    • array.toSpliced(start, deleteCount, ...items):返回拼接拷贝。
    • array.with(index, value):返回替换指定索引值的拷贝。
  • 示例

    const arr = [1, 3, 2];
    console.log(arr.toSorted()); // [1, 2, 3](原 arr 不变)
    console.log(arr.toReversed()); // [2, 3, 1]
    
    console.log(arr.toSpliced(1, 1, 4)); // [1, 4, 2]
    console.log(arr.with(0, 5)); // [5, 3, 2]
    

Hashbang 语法

允许在 ECMAScript 文件开头使用 #!(shebang)注释,指示解释器执行脚本。

#!/usr/bin/env node
console.log('Hello from Node.js');

允许 Symbols 用作 WeakMap、WeakSet 的键

此前key仅支持对象


ES2024

Promise.withResolvers()

同时创建 Promise 及其 resolve 和 reject 函数,便于手动控制 Promise 的状态。

const { promise, resolve, reject } = Promise.withResolvers();

setTimeout(() => resolve('Success'), 1000);

promise.then(value => console.log(value)); // 'Success'

Object.groupBy() 和 Map.groupBy()

静态方法,用于根据回调函数返回的值对可迭代对象进行分组,返回一个对象(Object.groupBy)或 Map(Map.groupBy)。

  • 语法

    • Object.groupBy(iterable, callback)
    • Map.groupBy(iterable, callback)
  • 示例

    const items = [
      { name: 'apple', type: 'fruit' },
      { name: 'carrot', type: 'vegetable' },
      { name: 'banana', type: 'fruit' }
    ];
    
    const grouped = Object.groupBy(items, item => item.type);
    console.log(grouped); // { fruit: [{...}, {...}], vegetable: [{...}] }
    
    const groupedMap = Map.groupBy(items, item => item.type);
    console.log(groupedMap.get('fruit')); // [{...}, {...}]
    

正则表达式/v标志

新标志 /v 启用 Unicode 集模式,支持属性的组合、范围、否定、交集 / 并集运算

/v 标志出现前,JS 正则有 /u 标志支持基础 Unicode 属性转义(如 \p{Letter} 匹配字母),但只能匹配 “单一属性”,无法直接表达 “属性的组合 / 范围 / 否定”,而 /v 标志正是为了解决这个问题,提供扩展 Unicode 属性转义能力。

  • 语法:/pattern/v

  • 示例

    const regex = /[\p{Emoji}&&\p{Emoji_Presentation}]/v;
    console.log(regex.test('😀')); // true(Emoji)
    
    // 集操作示例
    const setDiff = /[a-z--[aeiou]]/v; // 辅音
    console.log(setDiff.test('b')); // true
    

Atomics.waitAsync()

共享内存的异步等待方法,返回一个 Promise,在共享值变化时解决。

  • 语法:Atomics.waitAsync(array, index, value, timeout)

  • 示例

    const sab = new SharedArrayBuffer(16);
    const int32 = new Int32Array(sab);
    
    const result = Atomics.waitAsync(int32, 0, 0);
    result.value.then(() => console.log('Woken up'));
    // 其他线程:Atomics.store(int32, 0, 1); Atomics.notify(int32, 0);
    

ArrayBuffer 和 SharedArrayBuffer 的resize和transfer

  • 语法

    • buffer.resize(newLength)
    • buffer.transfer(newLength):返回新缓冲区,旧的被分离。
    • 类似方法可用于 SharedArrayBuffer。
  • 示例

    const buffer = new ArrayBuffer(8, { maxByteLength: 16 });
    buffer.resize(16);
    console.log(buffer.byteLength); // 16
    
    const transferred = buffer.transfer();
    // 原 buffer 被分离,无法使用
    

String.prototype.isWellFormed() 和 toWellFormed()

这两个api用于"格式不良"字符串.

JavaScript 字符串基于 UTF-16 编码,其中单独的代理对字符(未配对的高 / 低代理项) 属于 “格式不良” 的字符串(也叫 “畸形 UTF-16 字符串”)。

高代理项范围:0xD800 - 0xDBFF

低代理项范围:0xDC00 - 0xDFFF只有高 + 低代理项配对才是合法的 UTF-16 字符,单独出现其中一个就是 “格式不良”。

   // 1. 格式良好的字符串(正常字符、合法代理对)
   const validStr1 = 'Hello 世界';
   console.log(validStr1.isWellFormed()); // true

   const validStr2 = '\uD83D\uDE00'; // 😀(合法的高+低代理对)
   console.log(validStr2.isWellFormed()); // true

   // 2. 格式不良的字符串(单独的高代理项/低代理项)
   const invalidStr1 = '\uD83D'; // 单独的高代理项(无对应低代理项)
   console.log(invalidStr1.isWellFormed()); // false

   const invalidStr2 = '测试\uDC00'; // 单独的低代理项(无对应高代理项)
   console.log(invalidStr2.isWellFormed()); // false

   // 3. 格式良好的字符串:返回原字符串副本
   const validStr = 'Hello 😀';
   console.log(validStr.toWellFormed()); // Hello 😀
   console.log(validStr.toWellFormed() === validStr); // true(内容相同,引用不同)

   // 4. 格式不良的字符串:替换未配对代理项为 �
   const invalidStr1 = '\uD83D'; // 单独高代理项
   console.log(invalidStr1.toWellFormed()); // �

ES2025

迭代器助手方法(Iterator Helpers)

引入 Iterator 全局对象及其原型方法,支持对任何可迭代对象(如 Array、Set、Map)进行函数式操作,如 map、filter 等。这些方法返回新的迭代器,支持惰性求值。

  • 语法:Iterator.from(iterable).method(callback)

    • 支持方法:map()、filter()、reduce()、take()、drop()、flatMap()、toArray()、forEach() 等。
  • 示例

    const arr = [1, 2, 3, 4];
    const evenSquares = Iterator.from(arr)
      .filter(x => x % 2 === 0)
      .map(x => x ** 2)
      .toArray();
    console.log(evenSquares); // [4, 16]
    

Set新增方法

为 Set.prototype 添加数学集合操作方法,支持集合的并集、交集、差集等。

  • 新增方法

    • set.union(other) 并集
    • set.intersection(other) 交集
    • set.difference(other) 差集
    • set.symmetricDifference(other) 对称差集(并集减交集)
    • set.isSubsetOf(other) 子集
    • set.isSupersetOf(other) 超集
    • set.isDisjointFrom(other) 不相交
  • 示例

    const setA = new Set([1, 2, 3]);
    const setB = new Set([2, 3, 4]);
    
    console.log(setA.union(setB)); // Set {1, 2, 3, 4}
    console.log(setA.intersection(setB)); // Set {2, 3}
    console.log(setA.isSubsetOf(setB)); // false
    

直接导入JSON 模块

引入导入属性(with 关键字),支持直接导入 JSON 文件作为模块。

  • 语法:import json from "./data.json" with { type: "json" };

  • 示例

    import data from "./config.json" with { type: "json" };
    console.log(data); // { key: "value" }
    

Promise.try()

一个新的静态方法,用于包装函数调用,确保返回 Promise,无论函数是否异步或抛出错误。

  • 语法:Promise.try(callback)

  • 示例

    Promise.try(() => {
      if (Math.random() > 0.5) throw new Error('Fail');
      return 'Success';
    })
      .then(result => console.log(result))
      .catch(error => console.error(error));
    

新增Float16Array

引入 Float16Array 类型数组,支持 16 位浮点数,以及 DataView 的 getFloat16/setFloat16 和 Math.f16round()。

RegExp.escape() 方法

一个静态方法,用于转义字符串,使其可安全用于正则表达式。

  • 语法:RegExp.escape(str)

  • 示例

    const userInput = 'a.b*c?';
    const regex = new RegExp(RegExp.escape(userInput), 'g');
    console.log('a.b*c?'.replace(regex, 'replaced')); // 'replaced'
    

正则表达式内联标志

  • 语法:/(?i:case-insensitive)/

  • 示例

    const regex = /(?i:hello)/;
    console.log(regex.test('HELLO')); // true(忽略大小写)
    

正则表达式重复命名捕获组

允许在正则表达式中重复使用相同的命名捕获组名称。

  • 语法:/(?<group>a)|(?<group>b)/

  • 示例

    const regex = /(?<year>\d{4})-(?<month>\d{2})|(?<year>\d{4})/(?<month>\d{2})/;
    const match = '2025-07'.match(regex);
    console.log(match.groups.year); // '2025'
    console.log(match.groups.month); // '07'
    

Vue 实战:从零实现“划词标注”与“高亮笔记”功能

在在线教育、文档阅读或博客系统中,划词标注(Highlight & Note) 是一个非常实用的功能。它允许用户像在纸质书上一样,用鼠标选中一段文字,进行高亮标记或添加读书笔记。

本文将拆解如何在 Vue 项目中实现这一功能,涵盖从底层的 Selection API 调用到 DOM 操作,再到数据状态管理的完整流程。


核心原理

实现划词标注的核心在于浏览器提供的 Selection APIRange API

  1. Selection: 代表用户当前选中的文本范围(可能跨越多个节点)。
  2. Range: 代表文档中一个连续的区域(Selection 通常包含一个 Range)。
  3. DOM 操作: 将选中的文本用一个特定样式的标签(如 <span>)包裹起来,从而实现高亮效果。

Step 1: 监听选区 (Capture Selection)

首先,我们需要在用户松开鼠标(mouseup)时捕获选区。

HTML 结构: 在内容容器上绑定 mouseup 事件。

<div class="content-container" @mouseup="handleTextSelection">
  <!-- 文章内容 -->
  <p>这是一段可以被选中的文本...</p>
</div>

JavaScript 实现

handleTextSelection() {
  // 延时保证选区状态已更新
  setTimeout(() => {
    const selection = window.getSelection();

    // 1. 基础校验:必须是 Range 类型且非空
    if (selection.toString().trim() === '' || selection.type !== 'Range' || selection.isCollapsed) {
      this.selectionMenuVisible = false; // 隐藏菜单
      return;
    }

    // 2. 获取核心 Range 对象
    const range = selection.getRangeAt(0);

    // 3. (可选) 进阶校验:禁止跨特定区域选择
    // 比如:不能同时选中 A 选项和 B 选项
    if (this.isCrossBlockSelection(range)) {
      selection.removeAllRanges();
      return;
    }

    // 4. 执行高亮包裹逻辑(见下文)
    this.createTempHighlight(range, selection);
  }, 0);
}

Step 2: 包裹文本 (Wrap Text)

获取到 Range 后,我们需要将选中的文本用一个临时标签(Temp Span)包裹起来。这个标签通常有两个作用:

  1. 视觉反馈:给用户一个“预选中”的状态(例如浅蓝色背景)。
  2. 定位锚点:用于计算后续“操作菜单”的显示位置。

核心代码

createTempHighlight(range, selection) {
  // 创建一个包裹标签
  const span = document.createElement('span');
  span.className = 'temp-selection-highlight'; // 自定义样式类

  try {
    // 核心操作:提取内容 -> 插入节点
    // range.extractContents() 会将选区内容从 DOM 树中移除并返回 DocumentFragment
    span.appendChild(range.extractContents());
    // 将包裹后的 span 插入回原处
    range.insertNode(span);

    // ⚠️重要:重置选区
    // 因为 DOM 结构改变了,原有的 selection 会失效或错位
    // 我们需要重新选中这个 span 的内容,让用户感觉“高亮还在”
    const newRange = document.createRange();
    newRange.selectNodeContents(span);
    selection.removeAllRanges();
    selection.addRange(newRange);

    // 保存当前 Range 引用,供后续操作使用
    this.currentRange = newRange;

    // 5. 显示操作菜单
    this.showActionMenu(span);

  } catch (e) {
    console.error('Wrapping failed:', e);
  }
}

Step 3: 菜单定位 (Positioning Menu)

操作菜单(“高亮”、“笔记”)通常悬浮在选区上方。我们可以利用 getBoundingClientRect()getClientRects() 来获取位置。

showActionMenu(spanElement) {
  // 获取元素的位置信息
  // getClientRects() 对于跨行文本更准确,取最后一行
  const rects = spanElement.getClientRects();
  const lastRect = rects.length > 0 ? rects[rects.length - 1] : spanElement.getBoundingClientRect();

  // 计算菜单坐标(相对于视口)
  this.selectionMenuPosition = {
    top: (lastRect.bottom + 5) + 'px', // 显示在下方 5px 处
    left: (lastRect.right + 5) + 'px'
  };

  this.selectionMenuVisible = true;
}

Step 4: 确认与状态管理 (Confirm & State)

用户点击菜单中的“高亮”或“笔记”按钮后,我们需要将临时的 span 转换为持久化的状态。

  1. 修改样式:将 temp-selection-highlight 类替换为 permanent-highlight(黄色)或 note-highlight(蓝色)。
  2. 生成 ID:给 span 添加一个唯一 ID(如 data-id="167...")。
  3. 保存数据:将笔记内容推入 Vue 的数据数组中。
applyHighlight(type) {
  const span = document.querySelector('.temp-selection-highlight');
  if (!span) return;

  // 1. 生成唯一 ID
  const id = Date.now().toString();

  // 2. 更新 DOM 类名和属性
  span.className = type === 'note' ? 'note-highlight' : 'highlight-text';
  span.setAttribute('data-id', id);

  // 3. 存入数据层
  const newNote = {
    id,
    text: span.innerText, // 选中的原文
    type, // 'highlight' or 'note'
    color: type === 'note' ? '#e6f7ff' : '#ffeb3b',
    createTime: new Date().toISOString()
  };

  this.notesList.push(newNote);

  // 4. 持久化(保存到后端或 LocalStorage)
  this.saveData();

  // 5.如果是笔记,打开侧边栏供用户输入
  if (type === 'note') {
    this.openNoteSidebar(id);
  }

  // 清除选中状态
  window.getSelection().removeAllRanges();
  this.selectionMenuVisible = false;
}

Step 5: 取消高亮 (Unwrap)

如果用户想删除高亮,我们需要执行“反向操作”:将 span 去掉,保留里面的文字。

removeHighlight(id) {
  const span = document.querySelector(`span[data-id="${id}"]`);
  if (span) {
    const parent = span.parentNode;
    // 将 span 的子节点(文本)移动到父节点中 span 的前面
    while (span.firstChild) {
      parent.insertBefore(span.firstChild, span);
    }
    // 移除空 span
    parent.removeChild(span);
    // 规范化节点,合并相邻的文本节点
    parent.normalize();
  }

  // 同步删除数据
  this.notesList = this.notesList.filter(n => n.id !== id);
}

进阶技巧:从数据还原 DOM

最大的难点在于:页面刷新后,如何重新渲染这些高亮?

如果你的内容是纯静态的,可以直接保存包含 span 标签的 HTML 字符串。但由于 Vue 的 v-html 或 React 的 dangerouslySetInnerHTML 会导致 DOM 重绘,简单的 HTML 替换可能会丢失事件绑定。

更稳健的做法是:

  1. 保存 选区路径(如:第 X 个段落,第 Y 个字符开始,长度 Z)。
  2. 页面加载时,遍历数据,利用 createRange() 重新定位并包裹 DOM。

由于这通常涉及到复杂的 DOM 遍历算法,生产环境中推荐结合成熟库(如 Rangy 或自行实现基于 XPath 的定位)来处理复杂场景。


总结

实现一个划词笔记功能,本质上是对 DOM Range 的灵活运用。通过 监听(Listen) -> 包裹(Wrap) -> 存储(Store) -> 还原(Restore) 这四个步骤,我们就能为用户提供流畅的沉浸式阅读体验。

深入解析 React 回到顶部(BackToTop)组件的实现与设计

深入解析 React 回到顶部(BackToTop)组件的实现与设计

在现代网页开发中,长页面的场景十分常见,为了提升用户体验,“回到顶部” 功能几乎成为标配。本文将基于一段 React 实现的 BackToTop 组件代码,从结构、核心逻辑、性能优化等维度,全面解析该组件的设计与实现细节。

一、组件整体结构概览

首先来看 BackToTop 组件的完整代码结构,该组件基于 React 函数式组件实现,核心依赖 React 的 Hooks、UI 组件库、图标库以及自定义的节流工具函数,整体结构清晰且模块化。

import { useEffect, useState } from "react";
import { Button } from '@/components/ui/button'
import { ArrowUp } from "lucide-react";
import { throttle } from "@/utils";

// 定义组件Props类型
interface BackToTopProps {
    // 滚动超过多少像素后显示按钮
    threshold?: number
}

// 函数式组件,设置threshold默认值为400
const BackToTop: React.FC<BackToTopProps> = ({
    threshold = 400
}) => {
    // 状态管理:控制按钮是否可见
    const [isVisible, setIsVisible] = useState<boolean>(false);
    
    // 回到顶部核心逻辑
    const scrollTop = () => {
        window.scrollTo({
            top: 0,
            behavior:'smooth'
        })
    }
    
    // 监听滚动事件,控制按钮显示/隐藏
    useEffect(() => {
        const toggleVisibility = () => {
            setIsVisible(window.scrollY > threshold);
        }
        // 节流处理滚动监听函数
        const thtottled_func = throttle(toggleVisibility,200);
        window.addEventListener('scroll', thtottled_func);
        // 清理副作用:移除滚动监听
        return () => window.removeEventListener('scroll', thtottled_func);
    },[threshold])
    
    // 条件渲染:未达到阈值时不渲染组件
    if(!isVisible) return null;
    
    // 组件UI渲染
    return (
        <Button variant="outline" size="icon" onClick={scrollTop} className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50">
            <ArrowUp className="h-4 w-4" />
        </Button>
    )
}

export default BackToTop

组件整体可分为 5 个核心部分:

  1. 依赖导入与类型定义;
  2. 状态管理(控制按钮可见性);
  3. 回到顶部核心逻辑;
  4. 滚动事件监听与性能优化;
  5. 条件渲染与 UI 展示。

二、核心功能逐行解析

1. 类型定义与 Props 设计

interface BackToTopProps {
    threshold?: number
}

const BackToTop: React.FC<BackToTopProps> = ({
    threshold = 400
}) => { ... }
  • 定义BackToTopProps接口,仅暴露threshold可选属性,用于配置 “滚动超过多少像素后显示按钮”,符合 “最小可用 API” 设计原则;
  • 通过解构赋值为threshold设置默认值 400,确保组件在未传入参数时仍能正常工作。

2. 状态管理:控制按钮可见性

const [isVisible, setIsVisible] = useState<boolean>(false);

使用useState Hook 创建布尔类型状态isVisible,初始值为false(页面加载时按钮默认隐藏),该状态用于控制组件的条件渲染。

3. 回到顶部逻辑:平滑滚动实现

const scrollTop = () => {
    window.scrollTo({
        top: 0,
        behavior:'smooth'
    })
}
  • 调用window.scrollTo方法实现滚动到页面顶部;
  • 通过配置behavior: 'smooth'实现平滑滚动,替代传统的瞬间跳转,提升用户体验;
  • 该函数作为按钮的点击事件回调,触发回到顶部操作。

4. 滚动监听与性能优化(核心)

useEffect(() => {
    const toggleVisibility = () => {
        setIsVisible(window.scrollY > threshold);
    }
    const thtottled_func = throttle(toggleVisibility,200);
    window.addEventListener('scroll', thtottled_func);
    return () => window.removeEventListener('scroll', thtottled_func);
},[threshold])

这是组件的核心逻辑,需重点解析:

(1)滚动监听函数toggleVisibility

toggleVisibility的作用是判断页面滚动距离(window.scrollY)是否超过阈值(threshold),并通过setIsVisible更新按钮可见状态。

(2)节流处理的必要性

scroll事件是高频触发事件(页面滚动时会连续触发),若直接将toggleVisibility绑定到scroll事件,会导致该函数被频繁调用,引发不必要的状态更新和重渲染,影响页面性能。

因此,组件通过throttle工具函数对toggleVisibility进行节流处理,设置 200ms 的节流间隔 —— 即滚动事件触发时,toggleVisibility最多每 200ms 执行一次,有效减少函数执行次数,优化性能。

(3)副作用的挂载与清理
  • useEffect在组件挂载时执行,为window添加scroll事件监听,绑定节流后的函数;
  • useEffect的返回值是一个清理函数,在组件卸载时执行,移除scroll事件监听 —— 避免内存泄漏,是 React 函数式组件处理事件监听的标准写法;
  • useEffect的依赖数组包含threshold,确保当阈值变化时,重新绑定监听函数。

5. 条件渲染与 UI 展示

if(!isVisible) return null;

return (
    <Button variant="outline" size="icon" onClick={scrollTop} className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50">
        <ArrowUp className="h-4 w-4" />
    </Button>
)
  • 条件渲染:当isVisiblefalse时,组件返回null,不渲染任何内容;仅当滚动距离超过阈值时,才渲染回到顶部按钮;

  • UI 设计细节:

    • 使用 UI 组件库的Button组件,设置variant="outline"(轮廓样式)、size="icon"(图标尺寸);
    • 通过className设置固定定位(fixed)、位置(bottom-6 right-6,右下角)、圆角(rounded-full)、阴影(shadow-lg/xl)、层级(z-50),确保按钮悬浮在页面最上层且样式美观;
    • 嵌入lucide-reactArrowUp图标作为按钮内容,直观传达 “回到顶部” 的功能;
    • 按钮绑定onClick事件,触发scrollTop函数。

三、节流工具函数(throttle)解析

组件依赖的throttle函数位于index.ts中,其实现如下:

type ThrottleFunction = (...args: any[]) => void;

export function throttle(fun: ThrottleFunction, delay: number): ThrottleFunction {
  let last: number | undefined;
  let deferTimer: NodeJS.Timeout | undefined;

  return function (...args: any[]) {
    const now = +new Date();

    if (last && now < last + delay) {
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function () {
        last = now;
        fun(args);
      }, delay);
    } else {
      last = now;
      fun(args);
    }
  };
}

节流函数的核心原理

节流(Throttle)的核心思想是:在指定时间间隔内,只允许函数执行一次,即使触发多次,也仅生效一次。该实现的关键逻辑:

  1. 定义last(上一次函数执行的时间戳)和deferTimer(延迟定时器)两个闭包变量,用于记录执行状态;

  2. 每次触发函数时,获取当前时间戳now

  3. 若距离上一次执行时间不足delay

    • 清除原有定时器,避免重复执行;
    • 重新设置定时器,延迟delay后执行函数,并更新last
  4. 若距离上一次执行时间超过delay:直接执行函数,并更新last

注意点

该实现中fun(args)的传参方式需注意 —— 原函数的参数通过数组形式传递,若原函数依赖参数解构,需确保传参逻辑匹配(本文中toggleVisibility无参数,因此无影响)。

四、组件的使用与扩展

1. 基础使用

import BackToTop from '@/components/BackToTop';

const App = () => {
  return (
    <div>
      {/* 其他页面内容 */}
      <BackToTop threshold={500} />
    </div>
  );
};

仅需引入组件,可通过threshold自定义显示阈值,开箱即用。

2. 扩展方向

  • 自定义样式:通过className覆盖默认样式,或新增className Props 支持自定义样式;
  • 自定义图标:将图标作为 Props 传入,支持替换为自定义图标;
  • 滚动目标:扩展 Props 支持滚动到指定元素(而非仅顶部);
  • 动画效果:添加按钮显示 / 隐藏的过渡动画(如 React Transition Group);
  • 移动端适配:针对移动端调整按钮尺寸和位置;
  • 无障碍访问(a11y) :添加aria-label等属性,提升无障碍体验。

五、总结

本文解析的 BackToTop 组件是一个典型的 “小而美” 的 React 组件,其设计具备以下优点:

  1. 类型安全:通过 TypeScript 定义 Props 接口,确保类型校验;
  2. 性能优化:使用节流处理高频滚动事件,避免性能损耗;
  3. 用户体验:平滑滚动、条件渲染、美观的 UI 设计;
  4. 可维护性:模块化结构、清晰的逻辑拆分、完善的副作用清理;
  5. 可扩展性:通过 Props 暴露核心配置,便于扩展。

该组件的实现思路不仅适用于 “回到顶部” 功能,也可迁移到其他需要监听滚动事件的场景(如导航栏吸顶、懒加载等),是 React 函数式组件开发的典型实践案例。

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

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

Baked GI 节点是 Unity URP Shader Graph 中一个重要的光照计算节点,它允许着色器访问预计算的光照信息,为场景中的静态物体提供高质量的间接光照效果。在实时渲染中,全局光照(Global Illumination)的计算通常非常耗费性能,因此 Unity 提供了烘焙光照的解决方案,将复杂的光照计算预先处理并存储在光照贴图或光照探针中,运行时直接采样这些预计算数据,既能保证视觉效果又能保持高性能。

该节点的核心功能是根据物体的位置和朝向,从预先烘焙的光照数据中获取相应的光照颜色值。这些数据可以来自两种主要来源:光照贴图用于静态几何体,以及光照探针用于动态物体或需要动态光照的静态物体。通过合理使用 Baked GI 节点,开发者可以创建出具有丰富间接光照和真实感光照交互的着色器,而无需承担实时全局光照计算的性能开销。

在 URP 管线中,Baked GI 节点的实现经过了优化,专门针对移动平台和性能敏感的场景。与内置渲染管线或 HDRP 相比,URP 中的 Baked GI 节点可能有一些特定的限制和行为差异,但这些差异主要是为了确保在目标平台上的最佳性能表现。理解这些差异对于创建跨管线兼容的着色器至关重要。

描述

Baked GI 节点为着色器提供了访问烘焙全局光照值的能力,这些值可以在顶点着色器或片元着色器阶段使用。节点需要几个关键的输入参数来确定如何采样光照数据,包括世界空间中的位置和法线向量,以及用于光照贴图采样的 UV 坐标。

烘焙全局光照基础

烘焙全局光照是 Unity 光照系统的重要组成部分,它通过预计算场景中光线如何在不同表面之间反射和传播,生成静态的光照信息。这个过程包括直接光照和间接光照的计算,但只针对标记为静态的物体进行。烘焙完成后,光照信息会被存储到光照贴图或光照探针中:

  • 光照贴图是应用于静态几何体的纹理,包含预先计算的光照信息
  • 光照探针是在场景空间中放置的采样点,存储了该位置的光照信息,可用于动态物体或需要动态光照的静态物体

Baked GI 节点的作用就是在着色器执行时,根据提供的输入参数,从这些预计算的光照数据中获取相应的颜色值。

位置和法线输入的重要性

位置和法线输入对于正确采样光照探针数据至关重要。光照探针数据是基于球谐函数编码的,这种编码方式能够高效地存储全方向的光照信息。当着色器需要获取某点的光照信息时,系统会根据该点的位置找到最近的光照探针组,然后使用法线方向来评估球谐函数,得到该方向上的光照颜色。

如果提供的位置或法线不正确,可能会导致光照采样错误,表现为不自然的光照过渡或错误的光照方向。因此,确保这些输入参数的准确性是使用 Baked GI 节点的关键。

光照贴图坐标的作用

Static UV 和 Dynamic UV 输入用于采样不同类型的光照贴图:

  • Static UV 通常对应网格的 UV1 通道,用于采样静态光照贴图
  • Dynamic UV 通常对应网格的 UV2 通道,用于采样动态全局光照的光照贴图

在 Unity 的光照设置中,开发者可以选择使用不同的光照模式,如 Baked、Mixed 或 Realtime。对于 Mixed 光照模式的静态物体,Unity 会生成两套光照贴图:一套用于完全烘焙的光照,另一套用于与实时光照结合的效果。Baked GI 节点通过不同的 UV 输入来访问这些不同的光照贴图。

节点行为的管线依赖性

一个重要的注意事项是,Baked GI 节点的具体行为并未在全局范围内统一定义。Shader Graph 本身并不定义这个节点的功能实现,而是由每个渲染管线决定为此节点生成什么样的 HLSL 代码。这意味着:

  • 在高清渲染管线中,Baked GI 节点可能有特定的优化和功能
  • 在通用渲染管线中,节点的实现可能更注重性能和跨平台兼容性
  • 在内置渲染管线中,节点的行为可能又有所不同

这种设计使得每个渲染管线可以根据自身的架构和需求,优化 Baked GI 节点的实现方式。对于着色器开发者来说,这意味着如果计划创建在多种渲染管线中使用的着色器,需要在每个目标管线中测试 Baked GI 节点的行为,确保它按预期工作。

无光照着色器中的限制

在 URP 和 HDRP 中,Baked GI 节点不能在无光照着色器中使用。无光照着色器通常用于不需要复杂光照计算的物体,如UI元素、粒子效果或特殊效果。这些着色器通常会绕过管线的标准光照流程,因此无法访问烘焙全局光照数据。

如果尝试在无光照着色器中使用 Baked GI 节点,可能会遇到编译错误或运行时错误。对于需要简单光照的无光照物体,考虑使用其他光照技术,如顶点光照或简单的漫反射计算。

端口

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

Position 输入端口

Position 输入端口接收世界空间中的位置坐标,用于确定光照采样的空间位置。这个位置信息主要用于:

  • 光照探针采样:确定使用哪些光照探针的数据
  • 光照贴图索引:在某些情况下,帮助确定使用哪张光照贴图

在大多数情况下,应该将物体的世界空间位置连接到这个端口。在顶点着色器阶段使用 Baked GI 节点时,可以使用 Position 节点获取顶点在世界空间中的位置;在片元着色器阶段使用时,可以使用屏幕位置或通过其他方式计算得到的世界位置。

当使用光照探针时,位置输入的准确性尤为重要。如果位置偏差过大,可能会导致物体采样到错误位置的光照探针数据,造成光照不匹配的现象。

Normal 输入端口

Normal 输入端口接收世界空间中的法线向量,用于确定表面朝向,从而影响光照采样的方向。法线输入的主要作用包括:

  • 光照探针评估:球谐光照基于法线方向评估光照颜色
  • 光照贴图采样:在某些高级用法中,法线可能影响光照贴图的采样方式

法线向量应当是世界空间中的单位向量。如果提供的法线没有归一化,可能会导致光照计算错误。通常情况下,可以使用 Transform 节点将物体空间法线转换到世界空间,并确保使用正确的变换矩阵(通常是转置逆矩阵)。

对于动态法线效果(如法线贴图),需要将修改后的法线向量连接到 Normal 端口,这样 Baked GI 节点就会基于修改后的表面朝向计算光照,创造出更加丰富的视觉效果。

Static UV 输入端口

Static UV 输入端口用于指定静态光照贴图的纹理坐标。这些坐标通常对应于网格的 UV1 通道,也就是在建模软件中为光照贴图准备的 UV 集。Static UV 的作用包括:

  • 采样完全烘焙的光照贴图
  • 访问静态物体的间接光照信息
  • 在 Mixed 光照模式下,采样烘焙的间接光照部分

当场景中使用 Baked 或 Mixed 光照模式时,Unity 会为静态物体生成光照贴图。这些光照贴图包含了预计算的直接和间接光照信息。Static UV 输入确保着色器能够正确访问这些光照数据。

如果网格没有正确设置光照贴图 UV,或者 Static UV 输入不正确,可能会导致光照贴图采样错误,表现为拉伸、扭曲或重复的光照图案。

Dynamic UV 输入端口

Dynamic UV 输入端口用于指定动态光照贴图的纹理坐标,通常对应于网格的 UV2 通道。Dynamic UV 的主要应用场景包括:

  • 在 Mixed 光照模式下,采样用于实时光照交互的光照贴图
  • 访问动态全局光照系统生成的光照信息
  • 处理需要与实时光源交互的静态物体的光照

在 Mixed 光照模式下,Unity 会为静态物体生成两套光照贴图:一套用于完全烘焙的光照(通过 Static UV 访问),另一套用于与实时光源结合的效果(通过 Dynamic UV 访问)。这种设计允许静态物体既受益于高质量的烘焙光照,又能与场景中的实时光源正确交互。

Out 输出端口

Out 输出端口提供从烘焙全局光照系统采样的颜色值。这个输出是三维向量,表示 RGB 颜色空间中的光照颜色。输出的光照值已经考虑了:

  • 直接光照和间接光照的贡献
  • 颜色反射和光能传递效果
  • 场景的环境光遮蔽

输出的颜色值通常需要与材质的反照率颜色相乘,以实现正确的光照着色。在基于物理的着色模型中,Baked GI 的输出代表入射光强度,应当与表面反照率相乘来计算出射光强度。

在某些高级用法中,Baked GI 的输出可以用于更复杂的光照计算,如与实时光照结合,或作为其他着色效果的输入。

控件

Baked GI 节点提供了一个重要的控件选项,用于调整光照贴图的处理方式。

Apply Lightmap Scaling 切换

Apply Lightmap Scaling 是一个布尔切换控件,决定是否对光照贴图坐标自动应用缩放和偏移。这个选项默认为启用状态,在大多数情况下应该保持启用。

当启用 Apply Lightmap Scaling 时,节点会自动应用 Unity 光照系统中定义的光照贴图缩放和偏移变换。这些变换确保光照贴图正确映射到网格表面,考虑到了光照贴图的分包、排列和压缩设置。

禁用 Apply Lightmap Scaling 的情况较为少见,通常只在以下特定场景中考虑:

  • 当手动处理光照贴图坐标时
  • 当使用自定义的光照贴图布局时
  • 在某些特殊效果着色器中,需要直接访问原始光照贴图坐标

在大多数标准用法中,建议保持此选项启用,以确保光照贴图正确映射。如果禁用此选项,需要手动确保光照贴图坐标的正确性,否则可能导致光照贴图采样错误。

生成代码示例

Baked GI 节点在生成着色器代码时,会根据所在的渲染管线产生相应的 HLSL 代码。以下示例展示了 URP 中 Baked GI 节点可能生成的代码结构。

基本函数定义

HLSL

void Unity_BakedGI_float(float3 Position, float3 Normal, float2 StaticUV, float2 DynamicUV, out float3 Out)
{
    Out = SHADERGRAPH_BAKED_GI(Position, Normal, StaticUV, DynamicUV, false);
}

这个函数定义展示了 Baked GI 节点的基本代码结构。函数接收位置、法线和光照贴图坐标作为输入,通过 SHADERGRAPH_BAKED_GI 宏计算烘焙全局光照值,并将结果输出到 Out 参数。

SHADERGRAPH_BAKED_GI 是一个由 Shader Graph 系统定义的宏,它的具体实现取决于目标渲染管线。在 URP 中,这个宏会展开为访问 URP 烘焙光照系统的代码。

实际应用示例

在实际的着色器中,Baked GI 节点通常与其他光照计算结合使用。以下是一个简单的表面着色器示例,展示如何将 Baked GI 与实时直接光照结合:

HLSL

void surf(Input IN, inout SurfaceOutputStandard o)
{
    // 采样反照率贴图
    fixed4 albedo = tex2D(_MainTex, IN.uv_MainTex) * _Color;

    // 获取烘焙全局光照
    float3 bakedGI;
    Unity_BakedGI_float(IN.worldPos, IN.worldNormal, IN.uv1, IN.uv2, bakedGI);

    // 计算实时直接光照(简化示例)
    float3 directLight = _LightColor0 * max(0, dot(IN.worldNormal, _WorldSpaceLightPos0.xyz));

    // 结合光照
    o.Albedo = albedo.rgb;
    o.Emission = bakedGI * albedo.rgb;
    // 直接光照已经在光照模型中处理
}

这个示例展示了烘焙间接光照与实时直接光照的基本结合方式。在实际的 URP 着色器中,光照计算可能更加复杂,涉及更多光照模型和渲染特性。

顶点与片元着色器中的使用

Baked GI 节点既可以在顶点着色器中使用,也可以在片元着色器中使用,取决于性能和质量的需求:

顶点着色器中使用:

HLSL

v2f vert (appdata v)
{
    v2f o;
    // ... 其他顶点变换

    // 在顶点着色器中计算烘焙GI
    Unity_BakedGI_float(mul(unity_ObjectToWorld, v.vertex).xyz,
                        normalize(mul(v.normal, (float3x3)unity_WorldToObject)),
                        v.uv1, v.uv2, o.bakedGI);

    return o;
}

片元着色器中使用:

HLSL

fixed4 frag (v2f i) : SV_Target
{
    // 在片元着色器中计算烘焙GI(更高质量)
    float3 bakedGI;
    Unity_BakedGI_float(i.worldPos, normalize(i.worldNormal), i.uv1, i.uv2, bakedGI);

    // ... 其他着色计算
}

在顶点着色器中使用 Baked GI 性能更好,但光照细节较少;在片元着色器中使用质量更高,但性能开销更大。根据目标平台和性能要求选择合适的阶段。

最佳实践和性能考虑

使用 Baked GI 节点时,遵循一些最佳实践可以确保最佳的性能和视觉效果。

光照贴图设置优化

确保场景的光照贴图设置正确优化:

  • 使用适当的光照贴图分辨率,平衡质量和内存使用
  • 合理设置光照贴图压缩,在移动平台上使用压缩格式
  • 对不需要高质量光照的物体使用较低的光照贴图分辨率

光照探针布局优化

光照探针的布局影响动态物体的光照质量:

  • 在光照变化明显的区域放置更多光照探针
  • 确保动态物体的移动路径上有足够的光照探针覆盖
  • 使用光照探针代理卷提高大范围区域的光照探针效率

着色器性能优化

在着色器中使用 Baked GI 节点时考虑性能:

  • 在移动平台上,考虑在顶点着色器中使用 Baked GI
  • 对于远处物体,使用简化的光照计算
  • 避免在透明物体的着色器中过度使用复杂的光照计算

跨管线兼容性

如果计划创建跨渲染管线使用的着色器:

  • 在目标管线中测试 Baked GI 节点的行为
  • 使用着色器变体或自定义函数处理管线特定的差异
  • 提供回退方案,当 Baked GI 节点不可用时使用替代光照计算

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

❌