T_J_J_ 2026-01-09 11:49 采纳率: 0%
浏览 2

关于渲染管线的问题,渲染动画不符合预想, js主线程阻塞, requestAnimationFrame, transition


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>High Performance Popup (Best Practice)</title>
    <style>
        /* 
         * 最佳实践:使用 CSS 3D Transform 和 Opacity 进行动画
         * 避免触发布局回流 (Reflow) 和 重绘 (Repaint)
         */
        .popup-overlay {
            position: fixed;
            top: 50%;
            left: 50%;
            /* 初始状态:稍微缩小,制造弹出的弹性感 */
            transform: translate(-50%, -50%) scale(0.9);
            padding: 24px;
            background-color: white;
            border-radius: 8px;
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);

            /* 初始透明度为 0,且不占位 (display: none via JS logics) */
            opacity: 0;

            /* 
             * 告知浏览器这些属性将要变化,提示 GPU 提前分配图层 
             * 这是一个性能优化的 Hint
             */
            will-change: transform, opacity;

            /* 定义过渡效果 */
            transition: opacity 0.3s ease-out, transform 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
            z-index: 1000;
        }

        /* 激活状态:完全不透明,缩放恢复正常 */
        .popup-overlay.active {
            opacity: 1;
            transform: translate(-50%, -50%) scale(1);
        }
    </style>
</head>

<body>
    <div id="app"></div>
</body>
<script>
    class Popup {
        constructor() {
            this.el = null;
            this.resolveHandler = null;
            this.isOpened = false;
            this._init();
        }

        _init() {
            // 创建 DOM 结构
            this.el = document.createElement("div");
            this.el.className = "popup-overlay";
            this.el.setAttribute('role', 'dialog');
            this.el.setAttribute('aria-modal', 'true');

            this.contentSlot = document.createElement("div");
            this.contentSlot.style.marginBottom = "15px";
            this.el.appendChild(this.contentSlot);

            const closeBtn = document.createElement("button");
            closeBtn.innerText = "Close Popup";
            closeBtn.addEventListener("click", () => this.close());
            this.el.appendChild(closeBtn);

            document.body.appendChild(this.el);
        }

        show(content) {
            if (this.isOpened) return Promise.reject("Popup already opened");

            this.contentSlot.innerText = content || "Hello World";
            this.isOpened = true;

            // --- 核心渲染逻辑 (The Rendering Core) ---

            // 1. [Sync Task] 立即设置 display: block
            // 此时元素被加入渲染树,但 Opacity 为 0,用户不可见。
            // 这仅仅是修改了 DOM 属性,尚未绘制像素。
            this.el.style.display = 'block';

            // 验证辅助:监听真正的动画启动
            this.el.addEventListener('transitionstart', () => {
                console.log(`%c[Event] Animation Started at ${performance.now().toFixed(1)}ms`, "color: green; font-weight: bold");
            }, { once: true });

            // 2. [Frame N] 进入渲染管线前的调度
            // requestAnimationFrame 保证回调在“下一帧的渲染计算”之前执行。
            requestAnimationFrame((t1) => {
                console.log(`[Frame 1] rAF Executed. Browser knows element is block. Time: ${performance.now().toFixed(1)}ms`);

                // 此时,浏览器已经知道了 display: block 的存在。
                // 但为了确保 "State A" (Opacity 0) 被彻底提交并作为过渡的起点,
                // 我们需要等待下一帧。

                // 3. [Frame N+1] 真正触发动画
                this.el.classList.add("active");
                console.log("rAF1 预定执行时间", t1, " 当前时间", performance.now().toFixed(1));

                requestAnimationFrame((t2) => {
                    console.log(`[Frame 2] rAF Executed. Applying .active class. Time: ${performance.now().toFixed(1)}ms`);

                    // 状态变更:State A (Opacity 0) -> State B (Opacity 1)
                    // 浏览器捕捉到差异 (Diff),开始插值计算 (Interpolation)。
                    console.log("rAF2 预定执行时间", t2, " 当前时间", performance.now().toFixed(1));
                });
            });

            return new Promise(resolve => {
                this.resolveHandler = resolve;
            });
        }

        close() {
            if (!this.isOpened) return;
            this.isOpened = false;

            // 1. 移除 Active 类 -> 触发 Opacity 1 => 0 的过渡
            this.el.classList.remove("active");

            // 2. 等待过渡结束后,再隐藏 DOM (display: none)
            // { once: true } 自动移除监听器,这是现代浏览器最佳实践
            this.el.addEventListener('transitionend', () => {
                console.log(`[Event] Transition Ended. Setting display: none.`);
                this.el.style.display = 'none';
            }, { once: true });

            if (this.resolveHandler) {
                this.resolveHandler("Closed by user");
                this.resolveHandler = null;
            }
        }
        sleep(ms) {
            const start = performance.now();
            while (performance.now() - start < ms) { }
        }
    }

    class TriggerButton {
        constructor(popup, container) {
            this.popup = popup;
            const btn = document.createElement("button");
            btn.innerText = "Open Popup (with Nested rAF)";
            btn.style.padding = "10px 20px";
            btn.style.fontSize = "16px";
            btn.style.cursor = "pointer";

            btn.addEventListener("click", async () => {
                btn.disabled = true;
                console.clear();
                console.log("--- Start Sequence ---");
                try {
                    const res = await this.popup.show(`Request at: ${new Date().toLocaleTimeString()}`);
                    console.log("Result:", res);
                } catch (e) {
                    console.warn(e);
                } finally {
                    btn.disabled = false;
                }
            });

            (container || document.getElementById('app')).appendChild(btn);
        }
    }

    // Init
    new TriggerButton(new Popup());
</script>

</html>

这是我的第一个问题:
我再主线程修改属性为block,然后requestAnimationFrame,添加类名active,这个回调应该会在同一帧下,渲染管线开始的时候执行。而transition要发生的条件是两个状态有差异,而这两个状态是在渲染管线的Style阶段记录,按理来说,这俩样式都在Style阶段前执行,最后应该会归为同一个状态,那么为什么我浏览器渲染依然正常,每一次都正常?


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>High Performance Popup (Best Practice)</title>
    <style>
        /* 
         * 最佳实践:使用 CSS 3D Transform 和 Opacity 进行动画
         * 避免触发布局回流 (Reflow) 和 重绘 (Repaint)
         */
        .popup-overlay {
            position: fixed;
            top: 50%;
            left: 50%;
            /* 初始状态:稍微缩小,制造弹出的弹性感 */
            transform: translate(-50%, -50%) scale(0.9);
            padding: 24px;
            background-color: white;
            border-radius: 8px;
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);

            /* 初始透明度为 0,且不占位 (display: none via JS logics) */
            opacity: 0;

            /* 
             * 告知浏览器这些属性将要变化,提示 GPU 提前分配图层 
             * 这是一个性能优化的 Hint
             */
            will-change: transform, opacity, background-color;

            /* 定义过渡效果 */
            transition: opacity 0.3s ease-out, transform 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28), background-color 0.3s ease;
            z-index: 1000;
        }

        /* 激活状态:完全不透明,缩放恢复正常 */
        .popup-overlay.active {
            opacity: 1;
            transform: translate(-50%, -50%) scale(1);
        }
    </style>
</head>

<body>
    <div id="app"></div>
</body>
<script>
    class Popup {
        constructor() {
            this.el = null;
            this.resolveHandler = null;
            this.isOpened = false;
            this._init();
        }

        _init() {
            // 创建 DOM 结构
            this.el = document.createElement("div");
            this.el.className = "popup-overlay";
            this.el.setAttribute('role', 'dialog');
            this.el.setAttribute('aria-modal', 'true');

            this.contentSlot = document.createElement("div");
            this.contentSlot.style.marginBottom = "15px";
            this.el.appendChild(this.contentSlot);

            const closeBtn = document.createElement("button");
            closeBtn.innerText = "Close Popup";
            closeBtn.addEventListener("click", () => this.close());
            this.el.appendChild(closeBtn);

            document.body.appendChild(this.el);
        }

        show(content) {
            if (this.isOpened) return Promise.reject("Popup already opened");

            this.contentSlot.innerText = content || "Hello World";
            this.isOpened = true;

            // --- 核心渲染逻辑 (The Rendering Core) ---

            // 1. [Sync Task] 立即设置 display: block
            // 此时元素被加入渲染树,但 Opacity 为 0,用户不可见。
            // 这仅仅是修改了 DOM 属性,尚未绘制像素。

            // 验证辅助:监听真正的动画启动
            this.el.addEventListener('transitionstart', () => {
                console.log(`%c[Event] Animation Started at ${performance.now().toFixed(1)}ms`, "color: green; font-weight: bold");
            }, { once: true });

            this.el.style.display = 'block';
            // 阻塞
            // this.sleep(16);
            // 2. [Frame N] 进入渲染管线前的调度
            // requestAnimationFrame 保证回调在“下一帧的渲染计算”之前执行。
            requestAnimationFrame((t1) => {
                console.log(`[Frame 1] rAF Executed. Browser knows element is block. Time: ${performance.now().toFixed(1)}ms`);

                // 此时,浏览器已经知道了 display: block 的存在。
                // 但为了确保 "State A" (Opacity 0) 被彻底提交并作为过渡的起点,
                // 我们需要等待下一帧。

                // 3. [Frame N+1] 真正触发动画
                console.log("rAF1 预定执行时间", t1, " 当前时间", performance.now().toFixed(1));
                this.el.style.backgroundColor = 'blue';
                requestAnimationFrame((t2) => {
                    console.log(`[Frame 2] rAF Executed. Applying .active class. Time: ${performance.now().toFixed(1)}ms`);

                    this.el.classList.add("active");
                    this.el.style.backgroundColor = 'red';
                    // 状态变更:State A (Opacity 0) -> State B (Opacity 1)
                    // 浏览器捕捉到差异 (Diff),开始插值计算 (Interpolation)。
                    console.log("rAF2 预定执行时间", t2, " 当前时间", performance.now().toFixed(1));
                });
            });

            return new Promise(resolve => {
                this.resolveHandler = resolve;
            });
        }

        close() {
            if (!this.isOpened) return;
            this.isOpened = false;

            // 1. 移除 Active 类 -> 触发 Opacity 1 => 0 的过渡
            this.el.classList.remove("active");

            // 2. 等待过渡结束后,再隐藏 DOM (display: none)
            // { once: true } 自动移除监听器,这是现代浏览器最佳实践
            this.el.addEventListener('transitionend', () => {
                console.log(`[Event] Transition Ended. Setting display: none.`);
                this.el.style.display = 'none';
            }, { once: true });

            if (this.resolveHandler) {
                this.resolveHandler("Closed by user");
                this.resolveHandler = null;
            }
        }
        sleep(ms) {
            const start = performance.now();
            while (performance.now() - start < ms) { }
        }
    }

    class TriggerButton {
        constructor(popup, container) {
            this.popup = popup;
            const btn = document.createElement("button");
            btn.innerText = "Open Popup (with Nested rAF)";
            btn.style.padding = "10px 20px";
            btn.style.fontSize = "16px";
            btn.style.cursor = "pointer";

            btn.addEventListener("click", async () => {
                btn.disabled = true;
                console.clear();
                console.log("--- Start Sequence ---");
                try {
                    const res = await this.popup.show(`Request at: ${new Date().toLocaleTimeString()}`);
                    console.log("Result:", res);
                } catch (e) {
                    console.warn(e);
                } finally {
                    btn.disabled = false;
                }
            });

            (container || document.getElementById('app')).appendChild(btn);
        }
    }

    // Init
    new TriggerButton(new Popup());
</script>

</html>

然后这是我的第二个问题,我在第一层requestAnimationFrame中写this.el.style.backgroundColor = 'blue';第二层中写this.el.style.backgroundColor = 'red';,按理来说这是不同的两帧,为什么不会出现过度

asyncPopup_BestPractice.html:199 已清除控制台
asyncPopup_BestPractice.html:200 --- Start Sequence ---
asyncPopup_BestPractice.html:138 [Frame 1] rAF Executed. Browser knows element is block. Time: 919.7ms
asyncPopup_BestPractice.html:145 rAF1 预定执行时间 906.6  当前时间 919.7
asyncPopup_BestPractice.html:129 [Event] Animation Started at 923.4ms
asyncPopup_BestPractice.html:148 [Frame 2] rAF Executed. Applying .active class. Time: 923.5ms
asyncPopup_BestPractice.html:154 rAF2 预定执行时间 923.1  当前时间 923.7
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>High Performance Popup (Best Practice)</title>
    <style>
        /* 
         * 最佳实践:使用 CSS 3D Transform 和 Opacity 进行动画
         * 避免触发布局回流 (Reflow) 和 重绘 (Repaint)
         */
        .popup-overlay {
            position: fixed;
            top: 50%;
            left: 50%;
            /* 初始状态:稍微缩小,制造弹出的弹性感 */
            transform: translate(-50%, -50%) scale(0.9);
            padding: 24px;
            background-color: white;
            border-radius: 8px;
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);

            /* 初始透明度为 0,且不占位 (display: none via JS logics) */
            opacity: 0;

            /* 
             * 告知浏览器这些属性将要变化,提示 GPU 提前分配图层 
             * 这是一个性能优化的 Hint
             */
            will-change: transform, opacity, background-color;

            /* 定义过渡效果 */
            transition: opacity 0.3s ease-out, transform 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28), background-color 0.3s ease;
            z-index: 1000;
        }

        /* 激活状态:完全不透明,缩放恢复正常 */
        .popup-overlay.active {
            opacity: 1;
            transform: translate(-50%, -50%) scale(1);
        }
    </style>
</head>

<body>
    <div id="app"></div>
</body>
<script>
    class Popup {
        constructor() {
            this.el = null;
            this.resolveHandler = null;
            this.isOpened = false;
            this._init();
        }

        _init() {
            // 创建 DOM 结构
            this.el = document.createElement("div");
            this.el.className = "popup-overlay";
            this.el.setAttribute('role', 'dialog');
            this.el.setAttribute('aria-modal', 'true');

            this.contentSlot = document.createElement("div");
            this.contentSlot.style.marginBottom = "15px";
            this.el.appendChild(this.contentSlot);

            const closeBtn = document.createElement("button");
            closeBtn.innerText = "Close Popup";
            closeBtn.addEventListener("click", () => this.close());
            this.el.appendChild(closeBtn);

            document.body.appendChild(this.el);
        }

        show(content) {
            if (this.isOpened) return Promise.reject("Popup already opened");

            this.contentSlot.innerText = content || "Hello World";
            this.isOpened = true;

            // --- 核心渲染逻辑 (The Rendering Core) ---

            // 1. [Sync Task] 立即设置 display: block
            // 此时元素被加入渲染树,但 Opacity 为 0,用户不可见。
            // 这仅仅是修改了 DOM 属性,尚未绘制像素。

            // 验证辅助:监听真正的动画启动
            this.el.addEventListener('transitionstart', () => {
                console.log(`%c[Event] Animation Started at ${performance.now().toFixed(1)}ms`, "color: green; font-weight: bold");
            }, { once: true });

            console.log(`[Sync Task] Set display: block. Time: ${performance.now().toFixed(1)}ms`);
            // 阻塞
            this.sleep(16);
            // 2. [Frame N] 进入渲染管线前的调度
            // requestAnimationFrame 保证回调在“下一帧的渲染计算”之前执行。
            requestAnimationFrame((t1) => {
                console.log(`[Frame 1] rAF Executed. Browser knows element is block. Time: ${performance.now().toFixed(1)}ms`);

                // 此时,浏览器已经知道了 display: block 的存在。
                // 但为了确保 "State A" (Opacity 0) 被彻底提交并作为过渡的起点,
                // 我们需要等待下一帧。

                // 3. [Frame N+1] 真正触发动画
                console.log("rAF1 预定执行时间", t1, " 当前时间", performance.now().toFixed(1), "下一帧时间", (t1 + 1000 / 60).toFixed(1));
                this.el.style.display = 'block';

                requestAnimationFrame((t2) => {
                    console.log(`[Frame 2] rAF Executed. Applying .active class. Time: ${performance.now().toFixed(1)}ms`);

                    this.el.classList.add("active");
                    // 状态变更:State A (Opacity 0) -> State B (Opacity 1)
                    // 浏览器捕捉到差异 (Diff),开始插值计算 (Interpolation)。
                    console.log("rAF2 预定执行时间", t2, " 当前时间", performance.now().toFixed(1));
                });
            });

            return new Promise(resolve => {
                this.resolveHandler = resolve;
            });
        }

        close() {
            if (!this.isOpened) return;
            this.isOpened = false;

            // 1. 移除 Active 类 -> 触发 Opacity 1 => 0 的过渡
            this.el.classList.remove("active");

            // 2. 等待过渡结束后,再隐藏 DOM (display: none)
            // { once: true } 自动移除监听器,这是现代浏览器最佳实践
            this.el.addEventListener('transitionend', () => {
                console.log(`[Event] Transition Ended. Setting display: none.`);
                this.el.style.display = 'none';
            }, { once: true });

            if (this.resolveHandler) {
                this.resolveHandler("Closed by user");
                this.resolveHandler = null;
            }
        }
        sleep(ms) {
            const start = performance.now();
            while (performance.now() - start < ms) { }
        }
    }

    class TriggerButton {
        constructor(popup, container) {
            this.popup = popup;
            const btn = document.createElement("button");
            btn.innerText = "Open Popup (with Nested rAF)";
            btn.style.padding = "10px 20px";
            btn.style.fontSize = "16px";
            btn.style.cursor = "pointer";

            btn.addEventListener("click", async () => {
                btn.disabled = true;
                console.clear();
                console.log("--- Start Sequence ---");
                try {
                    const res = await this.popup.show(`Request at: ${new Date().toLocaleTimeString()}`);
                    console.log("Result:", res);
                } catch (e) {
                    console.warn(e);
                } finally {
                    btn.disabled = false;
                }
            });

            (container || document.getElementById('app')).appendChild(btn);
        }
    }

    // Init
    new TriggerButton(new Popup());
</script>

</html>
asyncPopup_BestPractice.html:199 已清除控制台
asyncPopup_BestPractice.html:200 --- Start Sequence ---
asyncPopup_BestPractice.html:132 [Sync Task] Set display: block. Time: 37041.6ms
asyncPopup_BestPractice.html:138 [Frame 1] rAF Executed. Browser knows element is block. Time: 37057.9ms
asyncPopup_BestPractice.html:145 rAF1 预定执行时间 37043.496  当前时间 37057.9 下一帧时间 37060.2
asyncPopup_BestPractice.html:149 [Frame 2] rAF Executed. Applying .active class. Time: 37058.7ms
asyncPopup_BestPractice.html:154 rAF2 预定执行时间 37043.496  当前时间 37058.9
asyncPopup_BestPractice.html:129 [Event] Animation Started at 37077.3ms

同样的,和第二个问题一样,为什么这里生效了,甚至由于阻塞,他们预定执行时间一样,执行的时间也确实差不多,这是追赶机制,这两个requestAnimationFrame难道没有合并吗

  • 写回答

2条回答 默认 最新

  • 檀越@新空间 2026-01-09 12:05
    关注

    中午好🌞🌞🌞
    本答案参考通义千问

    你提到的渲染管线问题、动画不符合预期、以及 JS 主线程阻塞的问题,可以从多个角度进行分析和优化。下面我将详细说明问题原因,并提供解决方案


    一、问题分析

    1. 动画不符合预期

    你的 .popup-overlay 使用了 transitiontransform 来实现弹窗动画。但根据代码逻辑,你在 requestAnimationFrame 中添加了 active 类,这可能导致动画触发时机不一致或不流畅。

    问题点:

    • transitionduration 是 0.3s,但你通过 requestAnimationFrame 控制动画的触发时间,可能会导致浏览器在计算时出现延迟。
    • 如果没有正确地让浏览器“感知”到元素状态变化,动画可能不会按预期执行。

    2. JS 主线程阻塞

    你使用了 requestAnimationFrame,这是正确的做法,因为它是用于与浏览器渲染周期同步的 API。但如果在 rAF 回调中执行大量同步操作(如 DOM 操作、计算等),仍然可能导致主线程阻塞。


    二、解决方案

    1. 确保动画的正确触发

    你需要确保浏览器能够识别元素的状态变化(如 display: blockopacity 的变化),并允许其进入渲染管线。

    ✅ 修改后的 show() 方法:

    show(content) {
        if (this.isOpened) return Promise.reject("Popup already opened");
    
        this.contentSlot.innerText = content || "Hello World";
        this.isOpened = true;
    
        // 1. 设置 display: block
        this.el.style.display = 'block';
    
        // 2. 使用 requestAnimationFrame 等待下一帧
        requestAnimationFrame(() => {
            console.log("[Frame 1] rAF Executed. Applying .active class.");
            this.el.classList.add("active");
        });
    
        // 3. 再次使用 requestAnimationFrame 确保动画开始
        requestAnimationFrame(() => {
            console.log("[Frame 2] rAF Executed. Animation started.");
        });
    
        return new Promise(resolve => {
            this.resolveHandler = resolve;
        });
    }
    

    重点说明:

    • 不要在同一个 rAF 回调中直接添加类,而是分两次 rAF 调用,以确保浏览器能正确识别状态变化。
    • 这样可以避免由于浏览器未完成当前帧绘制而导致的动画跳变或不执行。

    2. 避免 JS 主线程阻塞

    虽然你已经使用了 requestAnimationFrame,但仍需注意在回调中不要执行耗时操作。

    ✅ 优化建议:

    • 避免在 rAF 回调中做复杂计算或 DOM 操作
    • 将非关键任务异步处理(例如:使用 setTimeoutqueueMicrotask)。

    3. 添加性能监控和调试信息

    你可以使用 performance.now() 来记录关键时间点,帮助定位动画是否被正确触发。

    ✅ 示例调试输出:

    console.log(`[Event] Animation Started at ${performance.now().toFixed(1)}ms`);
    console.log(`[Frame 1] rAF Executed. Time: ${performance.now().toFixed(1)}ms`);
    console.log(`[Frame 2] rAF Executed. Time: ${performance.now().toFixed(1)}ms`);
    

    重点说明:

    • 确保 transitionstart 事件监听器正确绑定,并且使用 { once: true } 防止重复绑定。

    4. 使用 CSS 3D Transform 优化性能

    你已经在使用 transform: translate(-50%, -50%) scale(...),这是一个很好的做法,因为它可以利用 GPU 加速。

    重点说明:

    • 不要触发布局回流(Reflow),例如不要频繁修改 widthheighttopleft 等属性。
    • 优先使用 transformopacity 来实现动画。

    三、总结

    🧠 优化步骤概览:

    1. 确保动画触发顺序正确,使用两次 requestAnimationFrame 来保证状态变更被浏览器捕捉。
    2. 避免在 rAF 回调中执行耗时操作,防止主线程阻塞。
    3. 使用 transitiontransform 实现动画,避免布局回流。
    4. 添加性能监控和调试信息,便于排查动画问题。
    5. 确保 transitionstart 事件监听器正确绑定,并只触发一次。

    四、最终推荐代码片段

    show(content) {
        if (this.isOpened) return Promise.reject("Popup already opened");
    
        this.contentSlot.innerText = content || "Hello World";
        this.isOpened = true;
    
        this.el.style.display = 'block';
    
        // 第一次 rAF:确保 display: block 已生效
        requestAnimationFrame(() => {
            console.log("[Frame 1] rAF Executed. Applying .active class.");
            this.el.classList.add("active");
        });
    
        // 第二次 rAF:确认动画已启动
        requestAnimationFrame(() => {
            console.log("[Frame 2] rAF Executed. Animation started.");
        });
    
        return new Promise(resolve => {
            this.resolveHandler = resolve;
        });
    }
    

    如果你还有其他问题,比如动画卡顿、CPU 使用率高,也可以继续问我!

    评论

报告相同问题?

问题事件

  • 修改了问题 1月9日
  • 创建了问题 1月9日