JS操作DOM

因为在看FLIP教程的时候别人时用的就是Web Animation API,所以特地研究一下

Web Animation API(简称 WAAPI)

因为在看FLIP教程的时候别人时用的就是Web Animation API,所以特地研究一下

js
elm.animate([
    {
        transformOrigin: 'top left',
        transform: `
            translate(${deltaX}px, ${deltaY}px)
            scale(${deltaW}, ${deltaH})
        `
    }, 
    {
        transformOrigin: 'top left',
        transform: 'none'
    }], 
    {
        duration: 300,
        easing: 'ease-in-out',
        fill: 'both'
    }
);

https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Animations_API

https://developer.mozilla.org/zh-CN/docs/Web/API/Element/animate

Web Animations API(简称 WAAPI)。它是现代浏览器中一种 原生的动画编程接口,用来直接控制动画的播放、暂停、反转、时序等,比 CSS 动画(transition@keyframes)更灵活,也比 JavaScript 手动 requestAnimationFrame 更高效和易用。

一、基本概念

Web Animations API 允许你通过 JavaScript 控制元素的动画。 核心方法是:

js
element.animate(keyframes, options)

返回一个 Animation 对象,供你后续控制。


二、最简单的例子

js
const box = document.querySelector('.box')

const animation = box.animate(
  [
    { transform: 'translateX(0px)', opacity: 1 },
    { transform: 'translateX(200px)', opacity: 0.5 }
  ],
  {
    duration: 1000, // 动画持续 1 秒
    easing: 'ease', // 缓动函数
    iterations: 1   // 播放次数,Infinity 表示无限循环
  }
)

这段代码会让 .box 向右移动并透明度降低。 它实际上是浏览器底层优化过的动画系统,不需要手动 requestAnimationFrame

三、返回的 Animation 对象

element.animate() 会返回一个 Animation 实例,你可以控制:

js
animation.play()       // 播放
animation.pause()      // 暂停
animation.reverse()    // 反向播放
animation.cancel()     // 取消动画,恢复到初始状态
animation.finish()     // 跳到动画结束状态

还可以读取或设置时间:

js
animation.currentTime = 500   // 设置到第 500ms
animation.playbackRate = 2    // 两倍速播放

或者监听事件:

js
animation.onfinish = () => console.log('动画结束')

四、参数说明

keyframes(关键帧)

关键帧可以是:

✅ 数组形式
js
[
  { transform: 'translateX(0px)', opacity: 1 },
  { transform: 'translateX(100px)', opacity: 0.5 }
]
✅ 对象形式(带百分比)
js
{
  0: { transform: 'translateX(0)' },
  1: { transform: 'translateX(100px)' }
}

或者:

js
[
  { transform: 'translateX(0)', offset: 0 },
  { transform: 'translateX(100px)', offset: 1 }
]

其中 offset 表示关键帧位置(0~1)。


options(动画参数)

参数含义示例
duration持续时间(毫秒)1000
easing缓动函数'ease', 'linear', 'ease-in-out', 'cubic-bezier(0.4, 0, 0.2, 1)'
delay延迟开始200
iterations播放次数Infinity
direction播放方向'normal', 'reverse', 'alternate'
fill填充模式'none', 'forwards', 'backwards', 'both'

一、fill 是做什么的?

默认情况下,动画只在运行的那段时间(delay 之后,duration 之内)生效。 动画结束后,元素会自动恢复到原来的样子。

但是有时候我们希望:

  • 动画结束后停留在最后一帧
  • 或者动画开始前就
  • 应用第一帧样式

这就是 fill 的作用。

含义效果
'none'默认值,不保留任何动画状态动画开始前、结束后都恢复原样
'forwards'保留动画的最后一帧状态动画结束后停在最后一帧
'backwards'在延迟阶段(delay)时显示动画第一帧动画延迟期间立即显示开始帧
'both'同时应用 'forwards''backwards'延迟时显示第一帧,结束后停在最后一帧
时间段'none''forwards''backwards''both'
延迟前原始状态原始状态第一帧状态第一帧状态
动画中动画运行中动画运行中动画运行中动画运行中
动画后原始状态最后一帧状态原始状态最后一帧状态

五、与 CSS 动画的区别

特性CSS 动画Web Animations API
定义方式写在 CSS 中用 JS 控制
动态控制限制较多可随时暂停、反转、加速
性能已优化与 CSS 动画一样走浏览器合成层
可组合性依赖类名切换可通过多个 Animation 对象灵活控制
适用场景简单过渡复杂交互、时间轴动画

六、结合多个动画

可以创建多个动画并组合:

js
const box = document.querySelector('.box')

const move = box.animate(
  [{ transform: 'translateX(0)' }, { transform: 'translateX(200px)' }],
  { duration: 1000, fill: 'forwards' }
)

const fade = box.animate(
  [{ opacity: 1 }, { opacity: 0 }],
  { duration: 1000, fill: 'forwards' }
)

Promise.all([
  move.finished,
  fade.finished
]).then(() => console.log('所有动画结束'))

七、用 AnimationTimeline 控制(进阶)

如果你想统一控制多个动画(比如时间轴拖动),可以使用 DocumentTimelineAnimationTimeline

js
const timeline = new DocumentTimeline({ originTime: 0 })
const anim = element.animate(keyframes, { duration: 2000, timeline })

可以通过调整 timeline.currentTime 实现全局控制。

八、性能优势

WAAPI 是浏览器原生支持的动画系统:

  • 不依赖主线程 JS 计算(和 CSS 动画一样跑在 compositor 层)
  • requestAnimationFrame 相比更平滑、更节能
  • 可直接与 CSS 动画配合(未来可以从 CSS 获取并操作)

其他说明

这个api在操作一个名为这个的样式,作用于同一个元素两次animate调用都是在改变这一个!不是新建一个

image-20251022101407700

执行顺序逐帧分析

1、最小的过渡

js
function handleClick3() {
  let el = elment.value
  el.style.transition = 'transform 2s ease'   // ① 告诉浏览器:transform 的变化要过渡
  el.style.transform = `translateX(200px)`   // ② 改变 transform 值
}

2、终点后回来

js
function handleClick2() {
  let el = elment.value
  el.style.transition = 'transform 2s ease'
  el.style.transform = `translateX(200px)`

  setTimeout(() => {
    el.style.transform = ''
  }, 2000)
}

3、FLIP

js
function handleClick1(){
    let el = elment.value
    el.style.transition = ''
    el.style.transform = `translateX(200px)`
    requestAnimationFrame(() => {
        el.style.transition = 'transform 2s ease'
        el.style.transform = ''
    })
}

只要在同一个动画帧内设置的transitiontransform就会生效

JS 执行 → 样式计算 → 布局 → 绘制 → 合成 → 下一帧

requestAnimationFrame(简称 rAF)的回调函数,会在浏览器准备开始下一帧绘制(渲染)之前执行。 我们可以说:它是**“动画帧渲染前”**的一个 JavaScript 执行时机。

每一帧(Frame,大约 16.6ms 一次,60fps 时)浏览器会经历以下过程:

js
[JS 执行] → [样式计算] → [布局] → [绘制] → [合成显示]

如果你调用了 requestAnimationFrame(callback), 浏览器会在下一帧绘制之前执行这个回调:

js
这一帧执行完后 ↓
下一帧开始前 → 调用你的 callback → 然后计算样式 → 布局 → 绘制

MutationObserver

mutation n.[遗]突变;变化;转变

https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

构造函数

MutationObserver()

创建并返回一个新的 MutationObserver 它会在指定的 DOM 发生变化时被调用。

语法

var observer = new MutationObserver(callback);

参数

  • callback

    一个回调函数,每当被指定的节点或子树以及配置项有 DOM 变动时会被调用。回调函数拥有两个参数:一个是描述所有被触发改动的 MutationRecord 对象数组,另一个是调用该函数的 MutationObserver 对象。

js
function callback(mutationList, observer) {
  mutationList.forEach((mutation) => {
    switch (mutation.type) {
      case "childList":
        /* 从树上添加或移除一个或更多的子节点;参见 mutation.addedNodes 与
           mutation.removedNodes */
        break;
      case "attributes":
        /* mutation.target 中某节点的一个属性值被更改;该属性名称在 mutation.attributeName 中,
           该属性之前的值为 mutation.oldValue */
        break;
    }
  });
}

使用 mutation.type 获取发生的变动类别(无论是子节点的变动,还是节点属性的变动)。

方法

disconnect()

阻止 MutationObserver 实例继续接收的通知,直到再次调用其 observe() 方法,该观察者对象包含的回调函数都不会再被调用。

observe()

配置 MutationObserver 在 DOM 更改匹配给定选项时,通过其回调函数开始接收通知。

语法

mutationObserver.observe(target[, options])
  • target

    DOM 树中的一个要观察变化的 DOM Node (可能是一个 Element),或者是被观察的子节点树的根节点。

  • options

    此对象的配置项描述了 DOM 的哪些变化应该报告给 MutationObservercallback。当调用 observe() 时,childListattributescharacterData 中,必须有一个参数为 true。否则会抛出 TypeError 异常。

属性名作用默认值
subtree是否监听整个子树(包括 target 内所有后代节点)false
childList是否监听子节点的添加或删除false
attributes是否监听属性变化(如 classid 等)true(但如果设置了 attributeFilterattributeOldValue,默认变为 false
attributeFilter指定要监听的属性名数组,只监听这些属性的变化无(监听所有属性)
attributeOldValue是否记录属性变化前的旧值false
characterData是否监听文本内容的变化(比如节点中的文字被修改)true(但如果设置了 characterDataOldValue,默认变为 false
characterDataOldValue是否记录文本变化前的旧内容false

takeRecords()

从 MutationObserver 的通知队列中删除所有待处理的通知,并将它们返回到 MutationRecord 对象的新 Array 中。

js
var targetNode = document.querySelector("#someElement");
var observerOptions = {
  childList: true,
  attributes: true,
};

var observer = new MutationObserver(callback);
observer.observe(targetNode, observerOptions);

// ... 运行中监听 DOM 变化 ...

// 停止监听前

var mutations = observer.takeRecords();

if (mutations) {
  callback(mutations);
}

observer.disconnect();

代码中第 12-17 行抓取了所有未处理的变更记录,然后调用回调,并将变更记录列表传递给回调,以保证所有变更记录都被处理。这是在调用 disconnect() 之前完成的,以便停止观察 DOM。

停止监听前 你调用 disconnect() 想彻底关闭观察器。 但是——此时可能队列里还残留一些尚未被处理的 mutation 记录(浏览器还没来得及触发回调)。

takeRecords() 就是用来:立刻取出还没交给回调的 mutation 记录。

也就是让你手动把那些“还没来得及触发回调的变化”取出来处理一下。

否则如果你直接 disconnect(),这些记录就会被丢弃,你就永远拿不到它们了。

示例

js
// 选择需要观察变动的节点
const targetNode = document.getElementById("some-id");

// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };

// 当观察到变动时执行的回调函数
const callback = function (mutationsList, observer) {
  // Use traditional 'for loops' for IE 11
  for (let mutation of mutationsList) {
    if (mutation.type === "childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type === "attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
};

// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 之后,可停止观察
observer.disconnect();

备注

for...of 是用来遍历 可迭代对象 (iterable)

for...of 取出的,是 每一项的值

for...in 是用来遍历 对象的键名 (key)

它通常用于普通对象({}),但也可以用于数组,只不过遍历的是 索引

ResizeObserver

https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver

ResizeObserver 接口监视Element内容盒或边框盒或者 SVGElement 边界尺寸的变化。

ResizeObserver 避免了通过回调函数调整大小时,通常创建的无限回调循环和循环依赖项。它只能通过在后续的帧中处理 DOM 中更深层次的元素来做到这一点。如果它的实现遵循规范,则应在绘制前和布局后调用 resize 事件。

构造函数

ResizeObserver()

创建并返回一个新的 ResizeObserver 对象。

语法

js
new ResizeObserver(callback)

参数

callback

每当观测的元素调整大小时,调用该函数。该函数接收两个参数:

`entries`

	一个 `ResizeObserverEntry` 对象数组,可以用于获取每个元素改变后的新尺寸。

`observer`

	对 `ResizeObserver` 自身的引用,因此需要它的时候,你要从回调函数的内部访问。例如,这可用于在达到特定的情况时,自动取消对观察者的监听,但如果你不需要它,可以省略它。回调通常遵循以下模式:
js
function callback(entries, observer) {
  for (const entry of entries) {
    // Do something to each entry
    // and possibly something to the observer itself
  }
}

ResizeObserverEntry的属性

  • ResizeObserverEntry.borderBoxSize 只读

    一个对象,当运行回调时,该对象包含着正在观察元素的新边框盒的大小。

  • ResizeObserverEntry.contentBoxSize 只读

    一个对象,当运行回调时,该对象包含着正在观察元素的新内容盒的大小。

  • ResizeObserverEntry.devicePixelContentBoxSize 只读

    一个对象,当运行回调时,该对象包含着正在观察元素的新内容盒的大小(以设备像素为单位)。

  • ResizeObserverEntry.contentRect 只读

    一个对象,当运行回调时,该对象包含着正在观察元素新大小的 DOMRectReadOnly 对象。请注意,这比以上两个属性有着更好的支持,但是它是 Resize Observer API 早期实现遗留下来的,出于对浏览器的兼容性原因,仍然被保留在规范中,并且在未来的版本中可能被弃用。

  • ResizeObserverEntry.target 只读

    对正在观察 ElementSVGElement 的引用。

方法

ResizeObserver.disconnect()

ResizeObserver 接口的 disconnect() 方法取消所有的对 ElementSVGElement 目标的监听。

ResizeObserver.observe()

ResizeObserver 接口的 observe() 方法用于监听指定的 ElementSVGElement

语法:

js
observe(target)
observe(target, options)

参数

  • target

    对要监听的 ElementSVGElement 的引用。

  • options 可选

    一个可选的对象,允许你为监听的对象设置参数。目前,这只有一个参数:

    box设置 observer 将监听的盒模型。可能的值是:

    • content-box(默认)CSS 中定义的内容区域的大小。
    • border-boxCSS 中定义的边框区域的大小。
    • device-pixel-content-box在对元素或其祖先应用任何 CSS 转换之前,CSS 中定义的内容区域的大小,以设备像素为单位。

ResizeObserver.unobserve()

unobserve(target)

结束对指定 Element 的监听。

示例

在 resize-observer-text.html(见源码)示例中,我们使用 resize observer 去更改头和段落的 font-size,随着 slider 的值被改变,也引起了包含的 <div> 的宽度改变。这展示了你可以响应元素大小的变化,即使它们与视口无关。

我们也提供了一个 checkbox 来关闭和打开 observer。如果它是关闭的,文本将不会随着 <div> 的宽度改变而改变。

JavaScript 看起来像这样:

js
const h1Elem = document.querySelector("h1");
const pElem = document.querySelector("p");
const divElem = document.querySelector("body > div");
const slider = document.querySelector('input[type="range"]');
const checkbox = document.querySelector('input[type="checkbox"]');

divElem.style.width = "600px";

slider.addEventListener("input", () => {
  divElem.style.width = `${slider.value}px`;
});

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    if (entry.contentBoxSize) {
      // Firefox implements `contentBoxSize` as a single content rect, rather than an array
      const contentBoxSize = Array.isArray(entry.contentBoxSize)
        ? entry.contentBoxSize[0]
        : entry.contentBoxSize;

      h1Elem.style.fontSize = `${Math.max(
        1.5,
        contentBoxSize.inlineSize / 200,
      )}rem`;
      pElem.style.fontSize = `${Math.max(
        1,
        contentBoxSize.inlineSize / 600,
      )}rem`;
    } else {
      h1Elem.style.fontSize = `${Math.max(
        1.5,
        entry.contentRect.width / 200,
      )}rem`;
      pElem.style.fontSize = `${Math.max(1, entry.contentRect.width / 600)}rem`;
    }
  }

  console.log("Size changed");
});

resizeObserver.observe(divElem);

checkbox.addEventListener("change", () => {
  if (checkbox.checked) {
    resizeObserver.observe(divElem);
  } else {
    resizeObserver.unobserve(divElem);
  }
});

观察错误(Observation Errors)

为什么有时候你会在控制台里看到 “ResizeObserver loop completed with undelivered notifications.” 这样的错误信息,以及它背后的机制。

ResizeObserver 是用来监听元素尺寸变化的 API。当元素大小改变时,它会触发回调,让你有机会去做一些相应的操作(比如调整布局)。

但有时你的回调代码会反过来修改元素的尺寸(比如根据监听结果又去改变宽高)。这就可能导致一种情况:

  1. 浏览器检测到元素变大 → 调用回调。
  2. 回调又改变了元素的大小 → 再次触发回调。
  3. 如此反复……形成无限循环

浏览器是怎么处理的

为了防止这种死循环把浏览器卡死,规范要求浏览器这么做:

  • 每一轮循环中,只处理更深层的 DOM 元素(也就是避免重复计算同一层的元素)。

    规范规定浏览器:

    在一轮“观察并触发回调”的过程中,**只处理更深层(子元素)**的变化。

    意思是:

    • 如果父元素和子元素都变了,优先处理子元素的回调;
    • 父元素的回调(或同层级的其他)就暂时跳过;
    • 等下一帧再处理。

    这样就能避免“父 → 子 → 父 → 子”这种循环反复触发的死循环。

  • 其他的 resize 事件就延迟到下一帧再执行。

  • 同时在控制台输出一条错误信息:

    js
    ResizeObserver loop completed with undelivered notifications.

这只是个警告,意思是:“有些尺寸变化没有在本帧完成,已被推迟处理。”

它只是告诉你:有代码在不停地触发尺寸变化,导致浏览器不得不延迟处理,可能会出现布局闪烁或跳动

需要注意的是,这样做只是为了防止浏览器(用户代理)卡死(lockup),并不能真正阻止无限循环本身。 例如,下面这段代码会导致 divElem 的宽度无限增长,并且控制台中每一帧都会重复输出上面的错误消息:

js
const divElem = document.querySelector("body > div");

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    entry.target.style.width = `${entry.contentBoxSize[0].inlineSize + 10}px`;
  }
});

resizeObserver.observe(divElem);

window.addEventListener("error", (e) => {
  console.error(e.message);
});

只要 error 事件不是无限触发,ResizeObserver 最终就会稳定下来,并产生一个稳定且大概率正确的布局。 不过,用户可能会看到短暂的布局错乱闪烁,因为原本应在同一帧中完成的一系列变化,被分散到了多个帧中逐步执行

如果你想要避免这些错误,解决方法取决于你想实现的效果:

如果你的确有意制造一个无限循环(例如持续响应尺寸变化), 那么你只需要把 ResizeObserver 回调中的尺寸调整逻辑延迟到浏览器重绘之后再执行。 也就是说,可以把这部分代码放进一个 requestAnimationFrame 回调中。

js
const divElem = document.querySelector("body > div");

const resizeObserver = new ResizeObserver((entries) => {
  requestAnimationFrame(() => {
    for (const entry of entries) {
      entry.target.style.width = `${entry.contentBoxSize[0].inlineSize + 10}px`;
    }
  });
});

resizeObserver.observe(divElem);

window.addEventListener("error", (e) => {
  console.error(e.message);
});

如果你并非有意制造无限循环,就应当确保你的尺寸调整代码不会再次触发 ResizeObserver 的回调

实现这一点有多种方式,例如: 你可以设置一个“期望尺寸(expected size)”, 在检测到当前尺寸已经等于该期望值时, 就不要再执行重新调整操作。

js
const divElem = document.querySelector("body > div");
const expectedSizes = new WeakMap();

const resizeObserver = new ResizeObserver((entries) => {
  requestAnimationFrame(() => {
    for (const entry of entries) {
      const expectedSize = expectedSizes.get(entry.target);
      if (entry.contentBoxSize[0].inlineSize === expectedSize) {
        continue;
      }
      const newSize = entry.contentBoxSize[0].inlineSize + 10;
      entry.target.style.width = `${newSize}px`;
      expectedSizes.set(entry.target, newSize);
    }
  });
});

resizeObserver.observe(divElem);

window.addEventListener("error", (e) => {
  console.error(e.message);
});
情况意图解决方式
有意制造循环(持续自适应)想持续响应变化把更新逻辑放进 requestAnimationFrame,让变化延到下一帧执行
无意制造循环(逻辑错误)不想循环,只想响应一次添加条件判断(expected size),避免重复触发

IntersectionObserver

https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver

IntersectionObserver 接口是 Intersection Observer API 的一部分,它提供了一种异步观察目标元素与其祖先元素(或顶层文档视口)相交状态变化的方法。这个祖先元素或视口被称为 root(根)

当创建一个 IntersectionObserver 实例时,你可以设置它在 root 内不同可见比例(visibility ratio) 下进行监测。 一旦创建后,这个配置就不能再修改, 因此同一个观察器实例只能用于监听特定可见度变化的情况。

不过,你仍然可以使用同一个观察器对象同时观察多个目标元素

构造函数

IntersectionObserver()

创建一个新的 IntersectionObserver 对象,当其监听到目标元素的可见部分(的比例)超过了一个或多个阈值(threshold)时,会执行指定的回调函数。

js
new IntersectionObserver(callback)
new IntersectionObserver(callback, options)
  • callback

    当目标元素的可见百分比超过阈值时调用的函数。回调函数接收两个参数作为输入:

    entries

      一个对象数组`IntersectionObserverEntry`,每个对象代表一个已超过的阈值,该阈值的可见性可能高于或低于该阈值指定的百分比。您不应假设条目数量,因为在一次回调调用中可能会报告多个超过阈值的事件。这些条目使用队列进行调度,因此应按生成时间排序,但您最好使用`IntersectionObserverEntry.time`队列来正确排序。
      

    observer

      调用`IntersectionObserver`回调的。
  • options

    自定义观察者的可选对象。

选项名类型说明默认值
delaynumber(毫秒)指定观察者通知之间允许的最小延迟时间。用于限制通知触发频率,因为跟踪可见性是计算密集型操作。若启用 trackVisibility: true,最小值为 100;如果设置小于 100 或未指定,浏览器会自动设为 1000
rootElementDocument作为目标元素的祖先节点,其边界矩形会被视为可见区域(视口)。如果目标在该区域外,则被视为不可见。未指定时,默认使用**文档视口(viewport)**作为根。文档视口
rootMarginstring指定在计算交集时,为根元素的边界矩形添加的偏移量,可以扩大或缩小观察区域。支持 px%,语法与 CSS 的 margin 类似。"0px 0px 0px 0px"
scrollMarginstring指定在计算交点时,为滚动容器相对于目标的路径添加的偏移量,可用于处理嵌套滚动容器的可见性检测。语法与 rootMargin 相同。"0px 0px 0px 0px"
thresholdnumbernumber[]指定目标可见面积与其总面积的比例阈值。值为 0.0 表示只要有一个像素可见就触发;值为 1.0 表示目标完全可见才触发。0
trackVisibilityboolean是否启用可见性跟踪。若为 true,浏览器会检测目标是否被遮挡、透明、被滤镜隐藏等。此功能性能开销较大,应仅在必要时使用。启用时应同时设置 delayfalse

scrollMarginIntersectionObserver 新增的一个选项,用来修正在多层(嵌套)滚动容器中计算可见区域时的偏移

在实际网页中,你的目标元素(target)可能并不直接在视口(viewport)里, 而是嵌套在多个滚动容器overflow: auto/scroll 的 div)里:

<div class="outer-scroll">   <!-- 外层滚动容器 -->
<div class="inner-scroll"> <!-- 内层滚动容器 -->
 <div id="target"></div>  <!-- 要观测的目标 -->
</div>
</div>

正常情况下,IntersectionObserver 只会根据 root(比如 outer-scroll 或视口)的边界去判断目标是否可见。 但是在嵌套滚动容器的情况下,外层滚动可能裁剪(clip)掉目标的一部分, 导致“明明在滚动区中,但被判定为不可见”。

它让 IntersectionObserver 在计算目标是否被裁剪(clipped)时,对每个滚动容器都“扩大”或“缩小”判定区域。

当 root 自己在视口外时,target 是否会被认为“可见”?

仍然可能被认为“可见”!

IntersectionObserver 计算的“可见”关系是:

target 和 root 的可视区域(root 的边界矩形)之间是否有交集

而不是 root 和 视口 的关系。

换句话说:

  • 如果 target 在 root 内部(哪怕整个 root 在屏幕外), 它们之间的交集依然存在
  • 所以浏览器会认为 target 在 root 中是“可见”的。

实例属性

  • IntersectionObserver.root 只读

  • IntersectionObserver.rootMargin

计算交叉时添加到根边界盒的矩形偏移量,可以有效的缩小或扩大根的判定范围从而满足计算需要。此属性返回的值可能与调用构造函数时指定的值不同,因此可能需要更改该值,以匹配内部要求。所有的偏移量均可用像素px)或百分比%)来表达,默认值为“0px 0px 0px 0px”。

  • IntersectionObserver.thresholds 只读

    一个包含阈值的列表,按升序排列,列表中的每个阈值都是监听对象的交叉区域与边界区域的比率。当监听对象的任何阈值被越过时,都会生成一个通知(Notification)。如果构造器未传入值,则默认值为 0。

实例方法

disconnect()

使 IntersectionObserver 对象停止监听目标。

observe()

使 IntersectionObserver 开始监听一个目标元素。

takeRecords()

返回所有观察目标的 IntersectionObserverEntry 对象数组。

unobserve()

使 IntersectionObserver 停止监听特定目标元素。

js
const intersectionObserver = new IntersectionObserver((entries) => {
  // 如果 intersectionRatio 为 0,则目标在视野外,
  // 我们不需要做任何事情。
  if (entries[0].intersectionRatio <= 0) return;

  loadItems(10);
  console.log("Loaded new items");
});
// 开始监听
intersectionObserver.observe(document.querySelector(".scrollerFooter"));

IntersectionObserverEntry

属性名只读说明
boundingClientRect返回目标元素的边界矩形(DOMRectReadOnly),计算方式与 Element.getBoundingClientRect() 相同。
intersectionRatio返回 intersectionRectboundingClientRect 的面积比例,即可见部分占整体的比值(0~1)。
intersectionRect返回一个 DOMRectReadOnly 对象,表示目标元素当前可见的区域矩形
isIntersecting布尔值,表示目标元素是否与根元素(root)的可视区域有交集。为 true 表示进入可见状态;为 false 表示从可见状态变为不可见。
rootBounds返回根元素(root)的边界矩形(DOMRectReadOnly)。
target指示哪个元素(Element)的可见性发生了变化。
time返回记录交叉状态的时间戳(DOMHighResTimeStamp),相对于该观察器的时间原点。

PerformanceObserver

https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver

PerformanceObserver 用于监测性能度量事件,在浏览器的性能时间轴记录新的 performance entry 的时候将会被通知。

构造函数

  • PerformanceObserver()

    创建并返回一个新的 PerformanceObserver 对象。

方法

  • PerformanceObserver.observe()

    指定监测的 entry types 的集合。当 performance entry 被记录并且是指定的 entryTypes 之一的时候,性能观察者对象的回调函数会被调用。

  • PerformanceObserver.disconnect()

    性能监测回调停止接收 性能条目。

  • PerformanceObserver.takeRecords()

    返回当前存储在性能观察器的 performance entry 列表,并将其清空。

示例

js
function perf_observer(list, observer) {
  // Process the "measure" event
  // 处理 "measure" 事件
}
var observer2 = new PerformanceObserver(perf_observer);
observer2.observe({ entryTypes: ["measure"] });

自定义事件

https://developer.mozilla.org/zh-CN/docs/Web/API/CustomEvent

JavaScript 的 CustomEvent 接口允许你创建和触发自定义事件,这是实现组件间通信、解耦代码和模拟用户行为的强大工具。

1. 基本语法

js
new CustomEvent(type, options);
  • type: 事件类型(字符串),如'myEvent','dataLoaded'
  • options(可选): 一个对象,包含以下属性:
  • detail: 任意数据,随事件传递(可以是字符串、数字、对象、数组等)。
  • bubbles:true/false,是否允许事件冒泡。
  • cancelable:true/false,是否允许调用preventDefault()
  • composed:true/false,是否可以在 Shadow DOM 跨越边界冒泡(用于 Web Components)。

2.创建和触发自定义事件

js
// 1. 创建自定义事件
const myEvent = new CustomEvent('userLogin', {
  detail: {
    username: 'alice',
    timestamp: Date.now(),
    role: 'admin'
  },
  bubbles: true,
  cancelable: true
});

// 2. 触发事件(在某个 DOM 元素上)
document.getElementById('loginBtn').dispatchEvent(myEvent);
// 或者在 window 上
// window.dispatchEvent(myEvent);

3. 监听自定义事件

js
// 使用 addEventListener 监听
document.getElementById('loginBtn').addEventListener('userLogin', function(e) {
  console.log('用户登录了:', e.detail.username);
  console.log('角色:', e.detail.role);
});

// 或监听 window
window.addEventListener('userLogin', (e) => {
  console.log('全局监听到登录:', e.detail);
});
js
// 创建自定义事件
const catFound = new CustomEvent("animalfound", {
  detail: {
    name: "猫",
  },
});
const dogFound = new CustomEvent("animalfound", {
  detail: {
    name: "狗",
  },
});

// 添加合适的事件监听器
obj.addEventListener("animalfound", (e) => console.log(e.detail.name));

// 触发事件
obj.dispatchEvent(catFound);
obj.dispatchEvent(dogFound);

// 控制台中输出“猫”和“狗”

虚拟化列表

虚拟列表是为了提高页面性能而出现的。我们知道,一个页面上的 DOM 树越复杂,节点越多性能越低,每次重排(reflow)的成本越高。于是,虚拟列表出现了。虚拟列表的原理是只渲染可视部分以及部分预渲染的节点,待滚动之后替换可视部分节点。余下的空间则用 padding-top padding-bottom 撑开。

1618733932744

本篇文章不讨论如何实现一个虚拟列表,此类文章网上有很多。

实现虚拟列表的步骤

1、 计算视口高度和滚动位置

  • 计算当前视口的高度,以及滚动条的位置,以确定哪些数据项需要渲染。

2、 创建占位元素

  • 根据总数据量和单个数据项的高度,创建占位元素来模拟整个列表的高度,使滚动条显示正确的长度。

3、渲染可视区域元素

  • 根据当前滚动位置计算需要渲染的数据项,并在 DOM 中插入这些元素。

4、 处理滚动事件

  • 监听滚动事件,动态计算需要渲染的内容和更新视口内的元素。当用户滚动时,调整可视区域内的元素,并更新占位元素。

5、 优化性能

  • 节流/防抖:在处理滚动事件时,使用节流(throttling)或防抖(debouncing)技术来减少处理频率,避免性能问题。
  • 异步渲染:将渲染操作拆分成异步任务,避免阻塞主线程。

今天,我们就照着react-window的使用方式来实现三种虚拟列表。

  • 元素固定高度的虚拟列表
  • 元素不定高度的虚拟列表
  • 元素动态高度的虚拟列表

535a8a0506354d923c02d9b4e6936c81

看右边的元素个数,会发现起初只有6个,之后无论怎么滚动,他都保持着8个元素,由此我们可以得出他的静态原理图是这样的。

1d517845bed33ec969f3bcec8b27b855

当我们进行了滚动后。

11ccab9bd8a5c12d1a9f8cc799ccb7f2

从上面两图我们可以总结出,整个虚拟列表划分为三个区域,分别是上缓冲区(0/2个元素),可视区(n个元素),下缓冲区(2个元素)。当我们滚动到一个元素离开可视区范围内时,就去掉上缓冲区顶上的一个元素,然后再下缓冲区增加一个元素。这就是虚拟列表的核心原理了。

一、元素固定高度的虚拟列表
jsx
const Row = ({ index, style, forwardRef }) => {
  return (
    <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} ref={forwardRef}>
      {`Row ${index}`}
    </div>
  )
}
 
const App = () => {
  return (
    <FixedSizeList
      className="list"
      height={200}
      width={200}
      itemSize={50}
      itemCount={1000}
    >
      {Row}
    </FixedSizeList>
  );
}
实现

(1)首先先计算出由1000个元素撑起的盒子(称之为container)的高度,撑开盒子,让用户能进行滚动操作。

(2)计算出可视区的起始索引、上缓冲区的起始索引以及下缓冲区的结束索引(就像上图滚动后,上缓冲区的起始索引为2,可视区起始索引为4,下缓冲区结束索引为9)。

(3)采用绝对定位,计算上缓冲区到下缓冲区之间的每一个元素在contianer中的top值,只有知道top值才能让元素出现在可视区内。

98ed0601efa30cae0f1b09b6e9cc713f

(4)将上缓冲区到下缓冲区的元素塞到container中。

409b0149b280ca3dcab4f49155e15f2d

二、元素不定高度的虚拟列表
jsx
const rowSizes = new Array(1000).fill(true).map(() => 25 + Math.round(Math.random() * 55))
const getItemSize = (index) => rowSizes[index];
 
const Row = ({ index, style }) => {
  return (
    <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} >
      Row {index}
    </div>
  )
}

const App = () => {
  return (
    <VariableSizeList
      className="list"
      height={200}
      width={200}
      itemSize={getItemSize}
      itemCount={1000}
    >
      {Row}
    </VariableSizeList>
  );
}

从代码可以看出,Row每一个高度都是随机的,就不能像第一种虚拟列表那样简单得通过itemSize * index计算出top值了。

思路
难点一:

由于每个元素高度不一,我们起先无法直接计算出container的总高度。

难点二:

每个元素高度不一,每个元素的top值不能通过itemSize * index直接计算出top值。

难点三:

每个元素高度不一,不能直接通过scrollOffset / itemSize计算出已被滚动掉的元素的个数,很难获取到可视区的起始索引。

难点一的解决方案

可以通过遍历所有的Row计算出总高度,但我认为计算出精确总高度的必要性不大,同时也为了兼容第三种虚拟列表,我们不去计算精确的总高度。现在我们回到出发点,思考container的高度的作用是什么?其实就是为了足够大,让用户能进行滚动操作,那我们可以自己假设每一个元素的高度,在乘上个数,弄出一个假的但足够高的container让用户去触发滚动事件。当然这种方案会带来一些小bug(这个bug的影响不大,我认为是可以忽略的)。

难点二和难点三的解决方案

其实难点二和难点三本质都一样,元素高度不一,导致不知道被滚动掉了多少元素,只要知道被滚动掉的元素的个数,top值和索引都迎刃而解。

我们可以采用这种解决方案,那就是每次只需要计算上缓冲区到下缓冲区之间的元素,并记录他们,并且记录下最底下的那个元素的索引,当用户进行滚动时,如果我们是向上滚动,就可以直接从已经计算好的记录里取,如果向下滚动,我们根据上一次记录的最大的索引的那个元素不断累加新元素的高度,直到它大于已经滚动掉的高度,此时的索引值就是可视区的起始索引了,这个起始索引所对应的top就是累加的高度。

文字看起来生硬拗口,我们可以看下面这张图。

8e6881e3c1ad8e85e12c0df1d2938d95

每一个元素的top值都能通过上一个元素的top值 + 上一个元素的height计算出来。

举个例子,假设我们需要知道item14的top值

(1)我们先在记录里找有没有item13的数据,如果有,我们就拿item13.top + item13.heighht得到item14的top。

(2)如果记录中(由上图得知我们只记录了item1-item10的数据)没有,我们就拿到记录中最后一个元素的数据(item10)进行累加,先计算并记录item11的,再计算并记录item12的,再计算并记录item13的,最后就是item14的了。

以上代码主要写了个思路和功能,其实优化点是很多的,这里给出两个显而易见的优化点。

  • 缓存每一个已经计算完成的item的样式,这样回滚的时候不用重新计算样式。
  • getStartIndex可以通过二分法去优化。

c822e8598cc8c9a696c4fe1faadb5d45

元素动态高度的虚拟列表

最后这一种虚拟列表其实就是基于第二种来实现的,只不过增加监听元素高度变化事件,在某个元素发生变化的时候重新计算各种数据。

结尾

react-window只有前两种虚拟列表,最后一种虚拟列表是在别的虚拟列表库中有,借鉴了一下各路大佬的思路实现的,总得来说三种虚拟列表虽然表现和实现都不同,但只要掌握了核心原理,手撸出来虚拟列表还是手到擒来的。

最后,希望这篇文章能帮助到各位读者。同时也非常欢迎各位大佬对上面的各种实现提出建议,也希望各位大佬对于第二种虚拟列表提出更多的优化点。

元素坐标获取

元素自身的坐标 / 尺寸

这些是针对某个 DOM 元素本身的:

属性 / 方法作用返回值类型坐标参考系
element.offsetTop / offsetLeft元素相对于最近的有定位的祖先元素(offsetParent)的上/左偏移量number相对最近定位祖先
element.offsetWidth / offsetHeight元素的边框盒宽高(padding + border +content)number(px)自身大小
element.clientWidth / clientHeight内容区宽高(包含 padding+content)number(px)自身大小
element.scrollTop / scrollLeft元素自身滚动条滚动的偏移number(px)元素内部滚动位置(元素是滚动容器)
element.getBoundingClientRect()获取元素相对 视口 的位置和尺寸,返回 DOMRect 对象{x, y, top, left, right, bottom, width, height}相对视口(会随滚动改变)

鼠标事件的坐标

当监听鼠标事件(如 click, mousemove)时,事件对象里会有多种坐标:

属性作用参考系
event.clientX / clientY鼠标相对视口左上角的位置视口
event.pageX / pageY鼠标相对整个文档左上角的位置文档(包含滚动偏移)
event.screenX / screenY鼠标相对屏幕左上角的位置显示器屏幕
event.offsetX / offsetY鼠标相对事件目标元素左上角的位置事件元素

窗口 / 文档滚动与尺寸

获取整个页面或浏览器窗口的偏移和大小:

属性作用返回值
window.scrollX / scrollY页面当前的水平 / 垂直滚动距离number(px)
document.documentElement.scrollTop获取或设置页面滚动的距离(兼容老浏览器时用)number(px)
window.innerWidth / innerHeight浏览器视口的宽高number(px)
window.outerWidth / outerHeight包含整个浏览器窗口的宽高(含工具栏等)number(px)

高级坐标方法

  • element.scrollIntoView() 可以让元素滚动到视口中,可选参数控制滚动行为。
  • window.getComputedStyle(element) 可获取元素的计算后样式(可以用来获取 margintransform 位移等)。

直观例子

css
<div id="box" style="width:100px;height:100px;background:red;margin-top:50px;"></div>
<script>
  const box = document.getElementById('box');
  console.log('offsetTop:', box.offsetTop);
  console.log('getBoundingClientRect:', box.getBoundingClientRect());
  console.log('窗口滚动距离:', window.scrollX, window.scrollY);
  window.addEventListener('click', e => {
    console.log('clientX/Y:', e.clientX, e.clientY);
    console.log('pageX/Y:', e.pageX, e.pageY);
    console.log('offsetX/Y:', e.offsetX, e.offsetY);
  });
</script>

· 通过 style 设置绝对坐标

最常见的方式是直接给元素设置 style.left / style.top,前提是这个元素要有定位:

<div id="box" style="position:absolute;width:100px;height:100px;background:red;"></div>

<script>
const box = document.getElementById('box');
box.style.left = '200px';
box.style.top = '100px';
</script>
  • 这会把 box 放到其定位上下文(最近的有 position: relative/absolute/fixed/sticky 的祖先元素)的 (200,100) 坐标。
  • 常用于拖拽、动画、游戏元素定位。

2️⃣ 通过 transform 平移

用 CSS transform: translate() 移动元素,这种不会改变文档流位置(也不会影响 offsetTop/Left):

box.style.transform = 'translate(200px, 100px)';

优点:更适合动画,GPU 加速,性能好 缺点:元素原始位置不变,可能影响交互坐标计算


3️⃣ 通过 scroll 系列设置滚动位置

如果你要改变容器或页面的滚动位置,可以直接设置:

// 设置某个元素的滚动条
element.scrollTop = 100;
element.scrollLeft = 50;

// 设置整个页面滚动条
window.scrollTo({ top: 300, left: 0, behavior: 'smooth' }); // 平滑滚动

4️⃣ 通过属性设置 SVG / Canvas 坐标

如果是 SVG 元素,可以直接设置 xy 属性:

rect.setAttribute('x', 100);
rect.setAttribute('y', 200);

Canvas 更是直接用 ctx.moveTo(x, y)ctx.fillRect(x, y, w, h) 控制坐标。


5️⃣ 通过元素几何方法定位

配合 getBoundingClientRect(),可以动态计算目标位置,再用上面的方法设置:

const targetRect = target.getBoundingClientRect();
box.style.left = targetRect.left + window.scrollX + 'px';
box.style.top  = targetRect.bottom + window.scrollY + 'px';

这种写法常用于弹窗、下拉菜单的定位。

滚动事件

1. scroll 事件

scroll 事件会在 元素滚动时 触发。

  • 触发对象 任何可以滚动的元素(overflow: auto | scroll)或 window 对象。
  • 触发频率 非常高!几乎每滚动一像素就会触发一次。所以在性能敏感场景要 节流或防抖
  • 示例
js
const box = document.querySelector('.box');

box.addEventListener('scroll', (e) => {
  console.log('滚动位置:', box.scrollTop);
});

2. 相关属性

2.1 滚动位置

属性说明读/写
scrollTop元素内容顶部可视区域顶部的距离(垂直方向)✅可读可写
scrollLeft元素内容左边可视区域左边的距离(水平方向)✅可读可写

写入 scrollTopscrollLeft 可以让元素滚动到指定位置。


2.2 尺寸与范围

属性说明
scrollHeight元素内容的总高度(包括溢出不可见部分)
scrollWidth元素内容的总宽度
clientHeight元素可视区域的高度(不含滚动条)
clientWidth元素可视区域的宽度(不含滚动条)
offsetHeight元素的边框+padding+内容高度(含滚动条)
offsetWidth元素的边框+padding+内容宽度(含滚动条)

常用判断“滚动到底部”:

js
const isBottom = box.scrollTop + box.clientHeight >= box.scrollHeight;

3. 滚动相关方法

方法说明
scrollTo(x, y)滚动到指定坐标(绝对位置)
scrollBy(dx, dy)从当前位置滚动一定距离(相对位置)
scrollIntoView(options)滚动使元素进入可视区域

示例

js
// 滚动到顶部
box.scrollTo({ top: 0, behavior: 'smooth' });

// 向下滚动100px
box.scrollBy({ top: 100, behavior: 'smooth' });

// 滚动让目标元素可见
document.querySelector('#target').scrollIntoView({ behavior: 'smooth' });

4. 性能优化建议

  • 节流 / 防抖scroll 事件可能触发得非常频繁,建议使用 requestAnimationFramelodash.throttle 或手写节流函数优化。

  • 被动监听: 使用 { passive: true },提高滚动性能:

    js
    box.addEventListener('scroll', handler, { passive: true });

passive: true 的意思是:告诉浏览器,监听函数不会调用 event.preventDefault() 来阻止默认行为

浏览器在触发某些事件(尤其是 scrolltouchstarttouchmovewheel)时,需要先确认监听器里会不会调用 preventDefault()

  • 如果你可能会调用 preventDefault(),浏览器必须等你的监听器执行完,再决定是否阻止滚动,这会导致滚动有一点延迟。
  • 如果你显式声明 passive: true,浏览器就可以立刻滚动,而不用等你的监听器执行完,滚动体验更流畅

普通监听(可能阻塞滚动)

document.addEventListener('scroll', (e) => {
  // 浏览器必须等这个执行完再决定是否滚动
  console.log(window.scrollY);
});

可拖动元素(改变大小)

image-20250926155310611

css
.cursor-ew-resize {
    cursor: ew-resize;
}

高性能方案

示例代码

vue
<template>
  <div class="p-[40px]">
    <div class=" h-[200px] bg-pink-500 inline-block" :style="{ width: leftWidth + 'px' }"></div><div class="h-[200px] w-1 [cursor:ew-resize] bg-gray-700 inline-block" @mousedown="handleMouseDown"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const leftWidth = ref(100) // 初始宽度
const isDragging = ref(false)
const handleMouseDown = () => {
  console.log('down');
  isDragging.value = true
  document.body.style.userSelect = 'none'
}
const handleMouseUp = () => {
  console.log('up');
  isDragging.value = false
  document.body.style.userSelect = ''
}
function onMouseMove(e) {
  if (!isDragging.value) return
  // 限制最小宽度 100px,最大宽度 600px
  leftWidth.value = Math.min(Math.max(e.clientX, 100), 600)-40
}
onMounted(() => {
  document.addEventListener('mousemove', onMouseMove)
  document.addEventListener('mouseup', handleMouseUp)
})
</script>

相关原理:

document上绑定松开鼠标事件

js
document.addEventListener('mouseup', handleMouseUp)

这样在屏幕任何位置松开都可以

在控制元素上绑定mousedown,只有在这个元素上按下才会开始处理。鼠标移动时,实时处理

设置用户不可选:

js
//恢复浏览器默认行为(通常是**允许用户选择文字**,除非外部 CSS 有覆盖)
document.body.style.userSelect = ''
//显式禁止用户选择 `body` 里的任何文字或元素。
document.body.style.userSelect = 'none'

如果不设置,拖拽的过程就会选中其他元素:

利用 CSS resize 属性?

确实有一个内置 CSS 属性 resize,可以让用户拖动改变元素大小:

html
<template>
  <div class="p-[40px]">
    <div style="resize:both; overflow: auto; width: 300px; border: 1px solid #ccc;">2  可拖动改变宽度3</div>
  </div>
</template>

取值:both | horizontal | vertical

✅ 优点:

  • 不用写任何 JS,浏览器直接支持
  • 支持横向、纵向、双向调整

❌ 缺点:

  • 样式不可控(浏览器原生绘制右下角拖拽手柄)
  • 只能拖动元素右下角(不能像 Figma 一样有独立的竖条)
  • 没办法同步其他元素宽度(比如右侧自适应)

专用库(最优雅)

如果想避免“自己实现拖拽”,可以用专门的 Vue 组件库,比如:

vue3 使用vue3-draggable-resizable实现 拉伸,拖拽

npm install --save vue-draggable-resizable

全局注册组件main.js中写入:

import Vue from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'

// 可选择导入默认样式
import 'vue-draggable-resizable/dist/VueDraggableResizable.css'

Vue.component('vue-draggable-resizable', VueDraggableResizable)

局部注册:在使用的组件里引用

import VueDraggableResizable from 'vue-draggable-resizable'
import 'vue-draggable-resizable/dist/VueDraggableResizable.css'

结论

  • “监听鼠标移动”是必须的,因为浏览器没有内置 resize-bar
  • 你可以:
    1. 用原生 CSS resize(简单但丑)
    2. 用 Vue 事件绑定简化逻辑(比手动 addEventListener 干净)
    3. 用现成 Vue 组件库(最优雅,不用管拖拽逻辑)

FLIP

FLIPFirstLastInvertPlay四个单词首字母的缩写

First,指的是在任何事情发生之前(过渡之前),记录当前元素的位置和尺寸,即动画开始之前那一刻元素的位置和尺寸信息,可以使用 getBoundingClientRect()这个 API来处理(大部分情况下其实 offsetLeftoffsetTop也是可以的)

Last:执行一段代码,让元素发生相应的变化,并记录元素在动画最后状态的位置和尺寸,即动画结束之后那一刻元素的位置和尺寸信息

Invert:计算元素第一个位置(First)和最后一个位置(Last)之间的位置变化(如果需要,还可以计算两个状态之间的尺寸大小的变化),然后使用这些数字做一定的计算,让元素进行移动(通过 transform来改变元素的位置和尺寸),从而创建它位于第一个位置(初始位置)的一个错觉

即,一上来直接让元素处于动画的结束状态,然后使用 transform属性将元素反转回动画的开始状态(这个状态的信息在 First步骤就拿到了)

Play:将元素反转(假装在first位置),我们可以把 transform设置为 none,因为失去了 transform的约束,所以元素肯定会往本该在的位置(即动画结束时的那个状态)进行移动,也就是last的位置,如果给元素加上 transition的属性,那么这个过程自然也就是以一种动画的形式发生了

按照我的理解,就是对动画元素起止状态的一个量化,量化成一个公式,绝大部分的连续动画都可以通过套用这个公式来完成,提升动画的开发效率,更加详细的请自行参见 FLIP技术给Web布局带来的变化

由于 transform动画默认的 transform-origin为元素的中心,即50% 50%,但是计算出来的 lefttop却是相对于没有缩放的图片而言的,所以当 scale取值不唯一时,图片动画的 First状态就会发生偏差,需要将 transform设为 0 0以消除这种偏差

视图之间的过渡

在移动应用程序中从一个视图移动到另一个视图时,焦点元素通常会在视图之间从一个位置移动到另一个位置。比如下面的这些场景:

flip-layout-1

图片展开和收缩效果

flip-layout-2

项目删除和添加时填充空白区域的效果

flip-layout-3

但是在实现这些动画时,有一个烦人的问题:如何在同一个项目中的两个视图或状态之间有一个流畅地动效?。引入FLIP技术,我们就可以解决这样的烦恼,在两种状态之间可以共享一个元素。

性能优化

你可以已经尝试过了改变元素的heightwidthtopleft或者除也transformopacity之外的其他属性来实现一个动画效果。或许你已经在以往的经历(做的动效)中发现自己的实现的动效有点粗糙甚至卡顿,其实这是有一定原因的。任何触发布局变化的属性(比如height),浏览器都会递归检查布局中的其他元素是否也因此改变,这样的一个过程花销是很贵的。如果这个计算所费的时间比一个动画帧(大约16.7ms)更长,那么动画就会丢帧,从而导致动画迟滞,给用户的体验是,你的动画是卡顿的,这主要是因为动画丢的帧没有及时渲染。@Paul Lewis的博客《像素是昂贵的》一文中深入地阐述了如何渲染像素以及各种性能的开销。

@Paul Irish和@Paul Lewis在HTML5 Rocks写的另一篇文章中也解释了如何获得高性能的动画。简单地说,如何利用管道确保动画达到60fps。也就是说,只有改变属性才能触发合成(Compositing)而不是布局(Layout)或绘制(Paint):

flip-layout-4

有关于这方面相关的知识不做过多的阐述,如果感兴趣的话,可以阅读下面几篇文章:

也就是说,如果要让动效更流畅,就要尽量的让样式计算的数量尽量的少,而且尽可能的快。关键是在动画中尽量的只使用transformopacity。只不过,只使用transformopacity是令人沮丧的。但幸运地是,FLIP可以让我们如何只使用transform来模拟布局变化。

具体过程

First,指的是在任何事情发生之前(过渡之前),记录当前元素的位置和尺寸。可以使用getBoundingClientRect()这个API来处理,比如:

js
// 获取当前元素的边界
const first = el.getBoundingClientRect() 

getBoundingClientRect()将会返回一个DOMRect对象,如下图所示:

flip-layout-5

Last:执行一段代码,让元素发生相应的变化,并记录元素在最后状态的位置和尺寸,比如:

js
// 通过给元素添加一个类名,设置元素最后状态的位置和大小 (在.totes-at-the-end中添加相应的样式规则)
// 布局发生了变化
el.classList.add('totes-at-the-end')

// 记录元素最后状态的位置和尺寸大小
const last = el.getBoundingClientRect()

Invert:计算元素第一个位置(first)和最后一个位置(last)之间的位置变化(如果需要,还可以计算两个状态之间的尺寸大小的变化),然后使用这些数字做一定的计算,让元素进行移动(通过transform来改变元素的位置和尺寸),从而创建它位于第一个位置(初始位置)的一个错觉:

js
const deltaX = first.left - last.left
const deltaY = first.top - last.top
const deltaW = first.width / last.width
const deltaH = first.height / last.height

Play:将元素反转(假装在first位置),我们可以把transform设置为none,将其移动到last位置,让元素有动画效果:

js
elm.animate([
    {
        transformOrigin: 'top left',
        transform: `
            translate(${deltaX}px, ${deltaY}px)
            scale(${deltaW}, ${deltaH})
        `
    }, 
    {
        transformOrigin: 'top left',
        transform: 'none'
    }], 
    {
        duration: 300,
        easing: 'ease-in-out',
        fill: 'both'
    }
);

注意:在Play中使用了Web Animations API。https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Animations_API

https://developer.mozilla.org/zh-CN/docs/Web/API/Element/animate

animate(keyframes, options)

transform-origin

transform-origin CSS 属性让你更改一个元素变形的原点。

image-20251014094919787

把所有代码合在一起,如下:

js
// 获取当前元素的边界
const first = el.getBoundingClientRect() 

// 通过给元素添加一个类名,设置元素最后状态的位置和大小 (在.totes-at-the-end中添加相应的样式规则)
// 布局发生了变化
el.classList.add('totes-at-the-end')

// 记录元素最后状态的位置和尺寸大小
const last = el.getBoundingClientRect()

const deltaX = first.left - last.left
const deltaY = first.top - last.top
const deltaW = first.width / last.width
const deltaH = first.height / last.height

elm.animate([
    {
        transformOrigin: 'top left',
        transform: `
            translate(${deltaX}px, ${deltaY}px)
            scale(${deltaW}, ${deltaH})
        `
    }, 
    {
        transformOrigin: 'top left',
        transform: 'none'
    }], 
    {
        duration: 300,
        easing: 'ease-in-out',
        fill: 'both'
    }
);

flip-layout-6

加上最后一个过程Play,实现的动画效果如下图所示:

flip-layout-7

父子元素的过渡

上面我们看到的示例,元素的边界是基于window的。对于大多数用例,这是可以的,但考虑一下这样的场景:

  • 元素改变位置,需要一个过渡效果
  • 该元素包含一个子元素,该子元素本身需要过渡到父元素中的不同位置

由于之前计算的边界是相对于window的,我们的子元素的计算将会关闭。为了解决这个问题,我们需要确保这些边界是相对于父元素计算的:

js
// 获取父元素
const parentElm = document.querySelector('.parent');

// 获取子元素
const childElm = document.querySelector('.parent > .child');

// First: 父元素和子元素的初始位置和尺寸
const parentFirst = parentElm.getBoundingClientRect();
const childFirst = childElm.getBoundingClientRect();

doSomething();

// Last: 父元素和子元素最后位置和尺寸
const parentLast = parentElm.getBoundingClientRect();
const childLast = childElm.getBoundingClientRect();

// Invert: 父元素开始和结束位置
const parentDeltaX = parentFirst.left - parentLast.left;
const parentDeltaY = parentFirst.top - parentLast.top;

// Invert: 子元素相对于父元素位置 
const childDeltaX = (childFirst.left - parentFirst.left) - (childLast.left - parentLast.left);
const childDeltaY = (childFirst.top - parentFirst.top) - (childLast.top - parentLast.top);

// Play: 使用 WAAPI 设置动效
parentElm.animate([
    { 
        transform: `translate(${parentDeltaX}px, ${parentDeltaY}px)` 
    },
    { 
        transform: 'none' 
    }
], { 
        duration: 300, easing: 'ease-in-out' 
    }
);

childElm.animate([
    { 
        transform: `translate(${childDeltaX}px, ${childDeltaY}px)` 
    },
    { 
        transform: 'none' 
    }
], { 
        duration: 300, easing: 'ease-in-out' 
    }
);

这里有几点需要注意:

  • 父元素和子元素的计时选项(比如durationeasing等)不一定需要与些技术匹配。可以自由发挥你的创造力
  • 上面的示例中省略了更改父元素或子元素的尺寸(widthheight
  • 可以把父元素和子元素组合在一起,成为一个共享元素,然后再使用FLIP,可以更灵活

flipping.js

FLIP技术原理,上面应该阐述地差不多了。如果你不想花更多的时间去了解怎么使用或者更深层次的相关技术,而是仅仅希望在项目中使用该技术,那么可以考虑@Davidkpiano写的一个库:flipping.js。有关于这个库怎么使用,这里不做过多的阐述。如果感兴趣可以阅读Github仓库中提供的说明文档

总结

巧妙地点就是,巧妙地利用了第一次设置transform会瞬间移动,但是再次改变transform之后是动画移动的。巧妙掌控整个过程,保证最终transform清空,以便下次动画。巧妙使用瞬间改变元素的方案,而不是元素先做动画,在删除。

如果不使用FLIP,做列表删除动画,即使知道了向上移动的距离dy ,如果使用向上过渡+过渡结束之后的重置过渡删除元素,最终面临回弹问题(渲染与过渡同时发生)。即使处理好了,对于其他的场景也不适用!

文本选中相关API

1. 控制文本是否可选中

通过 CSS 的 user-select 属性:

css
/* 禁止选中文本 */
.no-select {
  user-select: none;
}

/* 可以正常选中(默认值) */
.select {
  user-select: text;
}

/* 只允许双击或拖动选择整个块 */
.block-select {
  user-select: all;
}

常见取值:

  • none → 禁止用户选中文本(比如按钮文字)。
  • text → 允许正常选择。
  • all → 点击元素时选中整个元素。
  • auto → 跟随浏览器默认。

2. 文本被选中时的样式

用伪类 ::selection 可以改变被选中文字的样式:

css
::selection {
  background: #ff9800; /* 选中文字背景色 */
  color: white;        /* 选中文字颜色 */
}

⚠️ 注意:

  • ::selection 只支持部分 CSS 属性(比如 colorbackgroundtext-shadow),不能加边框、字体大小之类的。

3. JS 选中文本

(1)选中某个元素里的全部文本

js
function selectText(el) {
  const range = document.createRange();
  range.selectNodeContents(el);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}

// 用法:传入 DOM 节点
selectText(document.getElementById("target"));

(2)选中某个输入框的内容

js
const input = document.querySelector("#myInput");
input.select(); // 选中输入框内容

(3)获取用户当前选中的文本

js
const text = window.getSelection().toString();
console.log("当前选中的文本:", text);

📌 总结:

  • CSS 层面:用 user-select 控制能不能选,用 ::selection 控制选中样式。
  • JS 层面:用 window.getSelection() 获取或设置选中范围,用 Range API 精确控制。

1. 主动触发复制(点击按钮复制内容)

现代浏览器推荐用 Clipboard API

html
<input id="myInput" value="Hello, 复制我!">
<button id="copyBtn">复制</button>

<script>
document.getElementById("copyBtn").addEventListener("click", async () => {
  const input = document.getElementById("myInput");
  try {
    // 方法一:直接复制 input 的 value
    await navigator.clipboard.writeText(input.value);
    alert("已复制到剪贴板:" + input.value);
  } catch (err) {
    console.error("复制失败:", err);
  }
});
</script>

如果要复制 普通文本(非输入框),同样直接传字符串即可:

js
await navigator.clipboard.writeText("任意内容也能复制");

2. 拦截用户复制(自定义复制内容)

监听 copy 事件,然后用 event.clipboardData.setData 修改内容:

js
document.addEventListener("copy", (e) => {
  e.preventDefault(); // 阻止默认复制
  e.clipboardData.setData("text/plain", "这是我指定的复制内容!");
});

这样无论用户选中什么,按 Ctrl+C 都会复制上面那句话。


3. 兼容老版本写法(不推荐,仅备用)

js
function oldCopy(text) {
  const textarea = document.createElement("textarea");
  textarea.value = text;
  document.body.appendChild(textarea);
  textarea.select();
  document.execCommand("copy");
  document.body.removeChild(textarea);
}

🛠 实现思路

  1. 记录元素信息 每次绘制文字(或图形)时,把它的坐标、宽高、内容保存下来。

    let elements = [
      { type: "text", text: "Hello", x: 50, y: 100, font: "20px Arial" },
      { type: "text", text: "World", x: 200, y: 150, font: "30px Arial" }
    ];
  2. 检测点击位置

    • 监听 canvas.addEventListener("click", handler)
    • 获取鼠标点击的画布坐标(注意要换算 canvas 的缩放/偏移)。
    canvas.addEventListener("click", (e) => {
      let rect = canvas.getBoundingClientRect();
      let x = e.clientX - rect.left;
      let y = e.clientY - rect.top;
      
      checkHit(x, y);
    });
  3. 命中检测 (Hit Test)

    • 对文字:用 ctx.measureText() 测量文字宽度,高度可以用字体大小估算。
    • 判断点击坐标是否落在这个矩形范围里。
    function checkHit(x, y) {
      for (let el of elements) {
        if (el.type === "text") {
          ctx.font = el.font;
          let width = ctx.measureText(el.text).width;
          let fontSize = parseInt(el.font, 10); // 简单获取字体大小
          
          if (
            x >= el.x &&
            x <= el.x + width &&
            y <= el.y && 
            y >= el.y - fontSize
          ) {
            console.log("你点击了文本:", el.text);
            return;
          }
        }
      }
    }

🔍 补充说明

  • 复杂形状(圆、路径) 可以用 ctx.isPointInPath()ctx.isPointInStroke() 来检测:

    ctx.beginPath();
    ctx.arc(100, 100, 50, 0, Math.PI * 2);
    ctx.closePath();
    
    if (ctx.isPointInPath(x, y)) {
      console.log("点中了圆");
    }
  • 文字的 hit test 只能自己用矩形来估算,因为 Canvas 没有内置 isPointInText


✅ 这样做就能在 Canvas 上检测你点击了哪一个“元素”或“文本”。

元素可见性

元素位置 + 滚动计算

手动计算元素在视口的位置:

js
// 检查当前周是否可见
const checkCurrentWeekVisibility = (container) => {
  const currentWeekElement = document.querySelector(`.timeline-item:nth-child(${currentWeekIndex.value + 1})`);
  if (currentWeekElement) {
    const elementRect = currentWeekElement.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();
      //计算坐标
    const isVisible = elementRect.left >= containerRect.left && elementRect.right <= containerRect.right;
    showBackToCurrentWeek.value = !isVisible;
  }
}

✅ 优点:简单、兼容性好 ⚠️ 缺点:同样无法检测“遮挡”,需要自己监听 scrollresize

IntersectionObserver

IntersectionObserver 是浏览器提供的 API,用来监听“某个元素进入/离开可视区域”的状态变化。

js
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素可见', entry.intersectionRatio);
    } else {
      console.log('元素不可见');
    }
  });
}, {
  root: null, // 默认为视口
  threshold: [0, 0.5, 1] // 可见比例阈值
});

observer.observe(document.querySelector('#target'));

✅ 优点:浏览器原生优化,适合滚动监听、懒加载。 ⚠️ 缺点:无法检测“被别的元素遮挡”。

elementFromPoint / elementsFromPoint 检测遮挡

判断元素是否被其他元素盖住:

css
function isFullyVisible(el) {
  const rect = el.getBoundingClientRect();
  const cx = rect.left + rect.width / 2;
  const cy = rect.top + rect.height / 2;
  return document.elementFromPoint(cx, cy) === el;
}

✅ 可以判断遮挡 ⚠️ 只能检测某个点,可能需要取多个点才能确保完全可见

可拖动元素(改变位置)

好问题!拖动“改变一个元素的位置”(不是大小,而是位置),方案就比改变宽度多很多了。我帮你系统梳理一下所有常见做法,从最基础到最优雅:


原生事件手写方案(最通用)

这是最基本的方案,也是很多库的底层原理:

  1. 监听 mousedown / touchstart,记录起始位置
  2. 监听 mousemove / touchmove,计算偏移量
  3. style.transform = translate(x, y)style.left/top 改变位置
  4. 监听 mouseup / touchend 停止拖动

示例(简化版):

vue
<template>
  <div
    class="absolute w-32 h-32 bg-blue-400 cursor-grab"
    :style="{ transform: `translate(${x}px, ${y}px)` }"
    @mousedown="startDrag"
  ></div>
</template>

<script setup>
import { ref } from 'vue'
const x = ref(0)
const y = ref(0)
let startX, startY, originX, originY

function startDrag(e) {
  startX = e.clientX
  startY = e.clientY
  originX = x.value
  originY = y.value
  document.addEventListener('mousemove', onMove)
  document.addEventListener('mouseup', stopDrag)
}

function onMove(e) {
  x.value = originX + (e.clientX - startX)
  y.value = originY + (e.clientY - startY)
}

function stopDrag() {
  document.removeEventListener('mousemove', onMove)
  document.removeEventListener('mouseup', stopDrag)
}
</script>

✅ 优点:灵活、跨浏览器 ❌ 缺点:要自己处理边界、性能优化、触控支持


原生 HTML5 拖拽 API

draggable="true"

可以让元素自带拖拽能力:

html
<div draggable="true" @dragstart="onDragStart" @dragend="onDragEnd">
  拖动我
</div>
  • dragstart → 开始拖拽
  • dragover → 拖拽经过容器时触发
  • drop → 放下元素时触发

✅ 优点:浏览器原生支持,不用监听鼠标移动 ❌ 缺点:

  • 主要设计给拖文件、拖文本、拖 DOM 节点到别处
  • 不适合做平滑的「实时跟随鼠标」的 UI(有内置的“幽灵图像”)
  • 移动端支持很差
vue
<template>
  <div class="container">
    <div
      class="draggable"
      draggable="true"
      :style="{ left: pos.x + 'px', top: pos.y + 'px' }"
      @dragstart="onDragStart"
      @dragend="onDragEnd"
    >
      拖我
    </div>
  </div>
</template>

<script setup>
import { reactive } from "vue";

const pos = reactive({ x: 50, y: 50 });
let offset = { x: 0, y: 0 };

const onDragStart = (e) => {
  // 计算鼠标点击点相对元素左上角的偏移
  const rect = e.target.getBoundingClientRect();
  offset.x = e.clientX - rect.left;
  offset.y = e.clientY - rect.top;
  // 让浏览器不显示默认的拖拽影子
  const img = new Image();
  img.src =
    "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==";
  e.dataTransfer.setDragImage(img, 0, 0);
};

const onDragEnd = (e) => {
  // 松开时更新元素位置
  pos.x = e.clientX - offset.x;
  pos.y = e.clientY - offset.y;
};
</script>

<style scoped>
.container {
  width: 100%;
  height: 100vh;
  background: #f8f8f8;
  position: relative;
}
.draggable {
  width: 80px;
  height: 80px;
  background: #42b883;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: grab;
  position: absolute;
  user-select: none;
}
</style>

关键点

不用监听 mousemove,只依赖 dragstart + dragend。 ✅ 用 setDragImage() 设置透明图片,让“幽灵图像”不干扰视觉效果。 ✅ 拖完之后重新计算 left/top,实现任意位置停靠。


⚠️ 缺点:

  • 拖动过程中看不到元素实时移动(因为 HTML5 拖拽不会更新元素位置),只有松手后才会“瞬移”到新位置。
  • 如果你想实现“实时跟随鼠标移动”,就得用 mousedown + mousemove + mouseup 方案手写,或者用现成的拖拽库。

利用 Pointer Events API

mousedown/mousemove 更现代,支持鼠标 + 触控 + 触控笔,一套 API 搞定:

js
element.addEventListener('pointerdown', e => {
  element.setPointerCapture(e.pointerId)
})
element.addEventListener('pointermove', e => {
  // 更新位置
})
element.addEventListener('pointerup', e => {
  element.releasePointerCapture(e.pointerId)
})

✅ 优点:现代、统一 ✅ 优化多指触控更容易 ❌ IE11 兼容性差(但移动端和现代浏览器都支持)

vue
<template>
  <div class="container">
    <div
      class="draggable"
      :style="{ left: pos.x + 'px', top: pos.y + 'px' }"
      ref="dragEl"
    >
      拖我
    </div>
  </div>
</template>

<script setup>
import { onMounted, ref, reactive } from "vue";

const dragEl = ref(null);
const pos = reactive({ x: 50, y: 50 }); // 元素当前坐标
let start = { x: 0, y: 0 }; // pointerdown 时鼠标位置
let offset = { x: 0, y: 0 }; // pointerdown 时元素位置

onMounted(() => {
  const el = dragEl.value;

  el.addEventListener("pointerdown", (e) => {
    start.x = e.clientX;
    start.y = e.clientY;
    offset.x = pos.x;
    offset.y = pos.y;

    // 捕获指针,保证即使光标移出元素仍能接收 pointermove
    el.setPointerCapture(e.pointerId);
  });

  el.addEventListener("pointermove", (e) => {
    if (el.hasPointerCapture(e.pointerId)) {
      // 计算偏移量
      const dx = e.clientX - start.x;
      const dy = e.clientY - start.y;
      pos.x = offset.x + dx;
      pos.y = offset.y + dy;
    }
  });

  el.addEventListener("pointerup", (e) => {
    el.releasePointerCapture(e.pointerId);
  });
});
</script>

<style scoped>
.container {
  width: 100%;
  height: 100vh;
  position: relative;
  background: #f5f5f5;
}

.draggable {
  width: 80px;
  height: 80px;
  background: #42b883;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  position: absolute;
  cursor: grab;
  user-select: none;
  touch-action: none; /* 防止触摸时触发页面滚动 */
}
</style>

关键点解析

  1. setPointerCapture(pointerId) 捕获后就算指针移出元素,也能继续接收 pointermove,保证拖拽流畅。
  2. 实时更新 pos → left/toptransform: translate() 也行,性能更好,适合做动画。
  3. touch-action: none 解决移动端拖动时页面跟着滚动的问题。
  4. 同时支持鼠标、触控、触控笔,不需要区分事件类型。

✅ 优势

  • mousedown / mousemove 兼容更多输入设备
  • 不会和多指操作冲突
  • 代码简洁,Vue/React 都能直接用

Pointer Events 是啥

Pointer Events 是一种统一输入设备的事件模型,浏览器会把鼠标、触控、手写笔都抽象成“pointer”。 常见的事件有:

  • pointerdown → 手指按下 / 鼠标按下
  • pointermove → 移动时触发
  • pointerup → 手指抬起 / 鼠标松开
  • pointercancel → 系统打断,比如手势缩放、弹出对话框

mousedown/touchstart 区别:你不需要自己写两套逻辑去区分 PC 和手机。

拖拽的核心思路

其实拖拽的本质就是:

  1. 记录 起始位置(pointerdown)
  2. 在移动时(pointermove)计算偏移量
  3. 更新元素的位置(通常改 left/toptransform
  4. 在松手时(pointerup)结束拖拽

整个流程就像这样:

pointerdown: 记住鼠标起点 + 元素原始位置
pointermove: 算出偏移量 = 当前坐标 - 起点
pointermove: 元素新位置 = 元素原始位置 + 偏移量
pointerup: 停止监听

关键 API 原理

(1) setPointerCapture(pointerId)

作用:把当前 pointer “锁”到这个元素上。 为什么要锁?

  • 不锁的话,手指/鼠标拖出元素范围,pointermove 事件就不再触发了,拖拽会丢失。
  • 锁了之后,即使光标移出元素,还是能持续收到 pointermove,直到 pointerup

(2) 计算偏移量

const dx = e.clientX - start.x
const dy = e.clientY - start.y
  • clientX/clientY 是鼠标相对视口的位置。
  • 用它减去按下时的起始位置,就能得到本次移动了多少像素。
  • 再加回元素原始位置,就得到新的坐标。

(3) 更新元素位置

pos.x = offset.x + dx
pos.y = offset.y + dy
  • offset 是元素按下时的旧位置。
  • dx/dy 是移动的距离。
  • 两者相加就是“当前应该在的位置”。

(4) touch-action: none

阻止浏览器的默认行为,比如:

  • 触摸拖动时页面滚动
  • 双指缩放 不加这个,移动端体验会非常差。

确实,pointerup 会自动结束捕获,但是否要手动 releasePointerCapture,要看你的需求。


1️⃣ 浏览器默认行为

当你在 pointerdown 里调用了

el.setPointerCapture(e.pointerId)

然后用户抬手(触发 pointerup)时,浏览器会自动:

  • 结束捕获
  • 触发 gotpointercapture / lostpointercapture 事件

也就是说,就算你不写:

el.releasePointerCapture(e.pointerId)

捕获也会被释放。


2️⃣ 为什么示例代码里手动释放?

手动 releasePointerCapture 只是显式地告诉浏览器:

通常有两种情况会手动释放:

  1. 提前取消拖拽 比如用户按 Esc 取消操作,不等 pointerup,直接中断。
  2. 多指触控场景 有时候需要在一个 pointer 抬起时释放,另一个 pointer 继续捕获(复杂交互)。

但我们这种简单的拖拽,完全可以不写,浏览器会在 pointerup 自动释放,效果一模一样。


借助第三方库(推荐)

如果项目里已经用 Vue/React,直接用成熟库更省心:

这些库都已经帮你做好:

  • 边界限制
  • 移动端支持
  • 拖动时平滑动画
  • 性能优化(requestAnimationFrame

对比总结

方案复杂度移动端适合场景
手写 mouse/touch⭐⭐⭐需要自己写触控逻辑任何简单拖拽
HTML5 Drag API⭐⭐很差拖放文件、交换 DOM 节点
Pointer Events API⭐⭐⭐⭐原生支持现代项目,跨输入设备
第三方库很好生产环境、复杂需求

伪元素动态变化

有的时候我们希望控制伪元素的一些属性,比如保持和父元素一致没或者是某种变化的值,但是伪元素 (::before / ::after) 在 DOM 里并不存在,我们无法直接控制它们的属性。接下来讲解几种解决方案

1、使用继承inherit

直接让 ::before 继承父元素的背景,而不是单独设置颜色:

.custom-button-class::before {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -8px;
  transform: translateX(-50%);
  width: 8px;
  height: 8px;
  background: inherit;   /* 继承父背景 */
  clip-path: polygon(50% 100%, 0 0, 100% 0); /* 裁剪成三角 */
}

2、变量传递

可以使用css变量将动态的属性通过父元素传递给伪元素

html
<!--vue写法-->
<div class="custom-button-class" :style="{ '--bg-color': getGradientColor(classRate)}">
js
//原生写法
document.querySelector('.custom-button-class')
        .style.setProperty('--bg-color', '#37E19A');

样式:

css
.custom-button-class::before {
  content: "";
  border-color: var(--bg-color);
}

3、动态插入样式规则

这样直接改 CSS 规则,但需要知道你要插入到哪个 stylesheet,而且可能会覆盖之前的定义。

js
const sheet = document.styleSheets[0]; 
sheet.insertRule(
  ".custom-button-class::before { border-color: #37E19A transparent transparent transparent; }",
  sheet.cssRules.length
);

4、用额外元素替代伪元素

既然 Vue/JS 都能直接控制 DOM 元素样式,那最干脆的就是不用 ::before

评论

评论加载中……