You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

通过window.postMessage触发翻页计数器动画失效,仅单个数字闪烁且无法终止

通过window.postMessage触发翻页计数器动画失效,仅单个数字闪烁且无法终止

我帮你分析了代码里的问题,主要是DOM元素引用过时、动画未正确重置,以及Observer监听未同步更新导致的。下面是具体的问题点和修复方案:

问题根源拆解

  1. 过时的DOM元素引用
    你在connectedCallback里提前获取了numbersContainer,但每次调用updateComponent()都会完全替换shadowRoot的内容,这个引用就变成了脱离DOM的无效元素。之后通过postMessage或滚动触发动画时,其实是在给一个已经不在页面上的元素加animation类,自然看不到正确效果。

  2. CSS动画无法重复触发
    CSS动画默认只执行一次,就算重复添加animation类,只要元素已经处于动画结束状态,浏览器不会重新播放。这就导致你多次触发时,只有第一次可能生效,后续看起来像"闪烁"或不执行。

  3. Intersection Observer未同步更新
    初始创建的Observer只监听了第一次渲染的元素,当组件重新渲染后,新的DOM节点没有被Observer监听,同时旧的Observer还在监听已移除的元素,可能导致异常触发和内存泄漏。


修复后的完整代码

下面是修复后的代码,我标注了关键修改点:

console.log("Version 1.77 - Explicit Viewport Trigger Integration");

class RolodexCounterElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.observer = null; // 保存Observer实例,方便后续清理
    this.handleMessage = null; // 保存消息监听函数,方便移除
  }

  static get observedAttributes() {
    return ["start-value", "end-value", "duration", "font-size", "font-family", "font-weight", "color"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute changed: ${name} from ${oldValue} to ${newValue}`);
    if (oldValue !== newValue) {
      this.updateComponent();
    }
  }

  connectedCallback() {
    console.log("Rolodex Counter connected to DOM");
    this.updateComponent();
    this.setupIntersectionObserver(); // 单独抽离Observer初始化
    this.setupMessageListener(); // 单独抽离消息监听
  }

  // 清理旧的Observer和事件监听,避免内存泄漏
  disconnectedCallback() {
    if (this.observer) {
      this.observer.disconnect();
    }
    if (this.handleMessage) {
      window.removeEventListener("message", this.handleMessage);
    }
    window.removeEventListener("resize", this.enforceAlignment);
  }

  setupIntersectionObserver() {
    // 先清理旧的Observer
    if (this.observer) {
      this.observer.disconnect();
    }

    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        // 每次触发时,获取最新的numbersContainer
        const numbersContainer = this.shadowRoot.querySelector(".frm-elmnt-numbers");
        if (!numbersContainer) return;

        if (entry.isIntersecting) {
          console.log("Element entered viewport: triggering animation");
          this.triggerAnimation(); // 复用触发逻辑
        } else {
          numbersContainer.classList.remove("animation");
        }
      });
    });

    // 监听当前组件(而不是内部元素,避免引用过时)
    this.observer.observe(this);
  }

  setupMessageListener() {
    // 先移除旧的监听,避免重复绑定
    if (this.handleMessage) {
      window.removeEventListener("message", this.handleMessage);
    }
    this.handleMessage = (event) => {
      if (event.data.trigger === "startCounting") {
        console.log("Manual trigger received");
        this.triggerAnimation(); // 复用触发逻辑
      }
    };
    window.addEventListener("message", this.handleMessage);
  }

  // 统一的动画触发方法,处理重置逻辑
  triggerAnimation() {
    const numbersContainer = this.shadowRoot.querySelector(".frm-elmnt-numbers");
    if (!numbersContainer) return;

    // 重置动画:先移除类,强制浏览器重绘后再添加
    numbersContainer.classList.remove("animation");
    // 用requestAnimationFrame确保DOM更新完成
    requestAnimationFrame(() => {
      numbersContainer.classList.add("animation");
    });
  }

  updateComponent() {
    const startValue = parseInt(this.getAttribute("start-value")) || 0;
    const endValue = parseInt(this.getAttribute("end-value")) || 99;
    const duration = parseInt(this.getAttribute("duration")) || 4000;
    const fontSize = this.getAttribute("font-size") || "7.5rem";
    const fontFamily = this.getAttribute("font-family") || "'Graphik', sans-serif";
    const fontWeight = this.getAttribute("font-weight") || "300";
    const color = this.getAttribute("color") || "#2e2e38";

    console.log("Attributes:", { startValue, endValue, duration, fontSize, fontFamily, fontWeight, color });

    const template = `
            <div class="frm-elmnt-counter">
                <div class="frm-elmnt-numbers-wrapper">
                    <div class="frm-elmnt-numbers"></div>
                </div>
                <div class="frm-elmnt-percent">%</div>
            </div>
            <style>
                :host {
                    display: flex;
                    justify-content: center;
                    align-items: center;
                    width: auto;
                    max-width: 100%;
                    height: auto;
                    max-height: 100%;
                    background: transparent;
                    overflow: hidden;
                    box-sizing: border-box;
                }
                .frm-elmnt-counter {
                    display: flex;
                    align-items: baseline;
                    font-size: ${fontSize};
                    font-weight: ${fontWeight};
                    font-family: ${fontFamily};
                    color: ${color};
                    line-height: 1;
                    overflow: hidden;
                }
                .frm-elmnt-numbers-wrapper {
                    height: ${fontSize};
                    overflow: hidden;
                    position: relative;
                }
                .frm-elmnt-numbers {
                    display: flex;
                    flex-direction: column;
                    transform: translateY(0);
                    will-change: transform;
                }
                .frm-elmnt-numbers.animation {
                    animation: frm-elmnt-roll-animation ${duration}ms ease-out forwards;
                }
                .frm-elmnt-percent {
                    margin-left: 0.1em;
                    font-weight: ${fontWeight};
                    display: inline-block;
                    line-height: 1;
                    position: relative;
                    color: ${color};
                    font-size: ${fontSize};
                    font-family: ${fontFamily};
                    margin-left: 0.05em;
                    padding-left: 0.05em;
                    letter-spacing: -0.005em;
                }
                @keyframes frm-elmnt-roll-animation {
                    0% {
                        transform: translateY(0);
                    }
                    85% {
                        transform: translateY(var(--final-position));
                    }
                    90% {
                        transform: translateY(var(--overshoot-position));
                    }
                    95% {
                        transform: translateY(var(--final-position));
                    }
                    100% {
                        transform: translateY(var(--final-position));
                    }
                }
            </style>
        `;

    this.shadowRoot.innerHTML = template;

    const numbersContainer = this.shadowRoot.querySelector(".frm-elmnt-numbers");
    const percentElement = this.shadowRoot.querySelector(".frm-elmnt-percent");

    // Generate Numbers
    const steps = endValue - startValue + 1;
    const numbers = Array.from({ length: steps }, (_, i) => startValue + i);
    numbersContainer.innerHTML = numbers.map((num) => `<span>${num}</span>`).join("");

    console.log("Generated numbers:", numbers);

    // Calculate Positions
    const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
    const lineHeight = parseFloat(getComputedStyle(numbersContainer).fontSize);
    const remConversion = lineHeight / rootFontSize;
    const finalPosition = `-${(steps - 1) * remConversion}rem`;
    const overshootPosition = `-${(steps - 1) * remConversion + 0.3}rem`;

    numbersContainer.style.setProperty("--final-position", finalPosition);
    numbersContainer.style.setProperty("--overshoot-position", overshootPosition);

    // Align Percent Sign
    this.enforceAlignment = () => {
      const numbersHeight = numbersContainer.offsetHeight;
      const percentHeight = percentElement.offsetHeight;
      const topAdjustment = Math.min((numbersHeight - percentHeight) / 2, 0);
      percentElement.style.top = `${topAdjustment}px`;
    };

    this.enforceAlignment();
    // 移除旧的resize监听,避免重复绑定
    window.removeEventListener("resize", this.enforceAlignment);
    window.addEventListener("resize", this.enforceAlignment);

    // 重新设置Observer(因为组件可能重新渲染,需要监听最新的元素)
    this.setupIntersectionObserver();
  }
}

customElements.define("rolodex-counter-element", RolodexCounterElement);

关键修改说明

  1. 统一动画触发逻辑:新增triggerAnimation()方法,负责重置并启动动画。先移除animation类,通过requestAnimationFrame确保DOM更新后再添加类,强制浏览器重新播放动画。

  2. 避免过时DOM引用:每次需要操作numbersContainer时,都通过this.shadowRoot.querySelector()获取最新的DOM元素,不再依赖提前保存的引用。

  3. Observer和事件监听的清理与复用:

    • 新增disconnectedCallback()清理Observer、消息监听和resize监听,避免内存泄漏。
    • setupIntersectionObserver()和setupMessageListener()方法负责创建/清理监听,确保每次组件重新渲染后,监听的是最新的元素,且不会重复绑定事件。
  4. Resize监听的优化:将enforceAlignment保存为实例方法,方便在disconnectedCallback中移除,避免内存泄漏。

现在你再测试window.postMessage({ trigger: "startCounting" }, "*");,应该能看到数字正常滚动动画,重复触发也能正确重置并重新执行了。

备注:内容来源于stack exchange,提问作者Moshe Klein

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.14 15:23:09