流畅用户体验的艺术:防抖和节流,打造更高性能的 UI

Github 代码库

在这个快节奏的世界里,我们所做的大部分工作都是在网络上进行的,而且速度很快。创造无缝、流畅的用户体验变得越来越重要。消费者喜欢运行速度快、没有延迟或延迟的用户界面。实现近乎完美的体验是可能的,尽管有些棘手。你听说过事件循环吗?

在 JavaScript 中,事件循环是一个基本概念,它管理代码的执行顺序、收集进程、将指令放入排队的子任务中并高效运行异步操作。以下是事件循环工作原理的快速分解:

  • 调用堆栈:所有函数在被调用时都会被添加到此堆栈中,并且控制流从函数返回时会弹出堆栈
  • 堆:所有变量和对象都从这个堆中分配内存
  • 队列:消息/指令的列表 - 一个接一个地执行
  • 这个事件循环不断检查调用堆栈。JavaScript 代码的执行持续到调用堆栈为空。

    事件处理是构建 JavaScript 应用程序的一个非常关键的部分。在这样的应用程序中,我们可能需要将多个事件与一个 UI 组件关联起来。

    An image of a Boy thinking

    想象...

    您的 UI 中有一个按钮,它可帮助在表格中填充最新的体育新闻。现在这需要您:

  • 单击按钮(将“单击”事件处理程序与按钮关联。
  • 从 API 获取结果
  • 解析输出(Json)并显示
  • 这 3 个过程以同步方式串联在一起。现在反复按下按钮将意味着多次 API 调用 - 导致 UI 被阻塞好几秒 - 用户体验似乎很差。

    这是防抖和节流等方法的一个很好的用例。对于触发一系列复杂事件的此类事件,我们可以使用这些策略来限制调用 API 的次数,或者从一般意义上讲,限制我们处理事件的速率。

    防抖与节流有什么区别?

    **去抖动**:推迟执行某个功能,直到自上次事件发生以来经过指定的冷却期。

    例如:

    如果我们将 `handleOnPressKey()` 去抖动**2 秒**,则只有当用户停止按键**2 秒**时它才会执行。

    设想:

  • 首次按键:启动一个 2000ms 的计时器来调用 handleOnPressKey()。
  • 1000 毫秒内随后按下按键:计时器重置;从这次最新的按键开始,我们再等待 2000 毫秒。
  • 2000 毫秒内没有按下任何按键:计时器完成,并调用 handleOnPressKey()。
  • **代码片段:**

    let debounceTimer; // Timer reference
    
    const handleOnPressKey = () => {
        console.log("Key pressed and debounce period elapsed!");
    };
    
    const debouncedKeyPress = () => {
        // Clear any existing timer
        clearTimeout(debounceTimer);
    
        // Start a new debounce timer
        debounceTimer = setTimeout(() => {
            handleOnPressKey(); // Execute the function after cooldown
        }, 2000); // Cooldown period of 2000ms
    };
    
    // Attach debouncedKeyPress to keypress events
    document.getElementById("input").addEventListener("keypress", debouncedKeyPress);

    **限制**:确保在指定的时间段内最多调用一次函数,无论事件发生的频率如何。

    例如:

    如果我们以**2 秒间隔**限制 `handleOnScroll()`,则该函数将每 2 秒最多执行一次,即使滚动事件在此期间触发多次。

    设想:

  • 初始滚动事件:调用 handleOnScroll(),并开始 2000 毫秒的冷却时间。
  • 2000 毫秒内的后续滚动事件:由于冷却期处于活动状态,这些事件将被忽略。
  • 2000ms 后的滚动事件:再次调用 handleOnScroll()。
  • **代码示例:**

    let throttleTimer; // Timer reference
    
    const handleOnScroll = () => {
        console.log("Scroll event processed!");
    };
    
    const throttledScroll = () => {
        if (!throttleTimer) {
            handleOnScroll(); // Execute the function immediately
            throttleTimer = setTimeout(() => {
                throttleTimer = null; // Reset timer after cooldown
            }, 2000); // Cooldown period of 2000ms
        }
    };
    
    // Attach throttledScroll to scroll events
    document.addEventListener("scroll", throttledScroll);

    现在让我们构建一些东西

    该项目是一款现代待办事项列表应用,旨在探索事件处理中的防抖和节流概念。它具有实时任务添加、由 Fuse.js 提供支持的搜索功能以及动态建议下拉菜单。
    UI showing a To-Do app prototype

    在讨论更关键的“script.js”之前,我们先快速看一下 HTML 代码

    我们使用 TailwindCSS 进行快速样式设置。您可以在此处查看其文档 Tailwind 文档 - 它对于快速制作原型非常有帮助

  • 页眉:页眉包含页面的标题。
  • 输入字段:用于添加注释的输入字段,使用 Tailwind CSS 样式。
  • 建议下拉菜单:隐藏的下拉菜单,将在用户输入时显示建议。
  • 静态任务列表:显示已添加的任务的列表。
  • 脚本:包括用于模糊搜索的 Fuse.js 库和用于自定义 JavaScript 逻辑的 script.js 文件。
  • 
    
    
        
        
        Event Loop Practice
        
        
        
        
    
    
        

    Make Notes

    send

    Static Task List

    为什么使用 Fuse.js?

    Fuse.js 是一个轻量级、可自定义的模糊搜索库。它可以处理拼写错误和部分匹配,为大型数据集提供高性能,并具有直观的 API。这将有助于通过灵活、用户友好的搜索体验增强您的搜索功能。此外,它还为您提供了 CDN 链接,因此它可以立即工作,无需导入或本地存储。

    现在让我们开始编写真正的代码 - JS

    1. 任务数组和变量

    const tasks = new Array (
        "Complete Blog on Throttling + Debouncing",
        "Make a list of 2025 Resolutions",
    );
    let fuse = undefined;
    let debounceTimer;
    let throttleTimer;

    本节初始化任务数组并声明 Fuse.js、防抖定时器和节流阀定时器的变量。为了这个项目,我们已经对一些任务进行了硬编码

    现在让我们构建“onSubmit”函数。一旦用户单击“提交箭头”,就会触发此函数。它会阻止默认的表单提交、检索输入值、清除输入字段、将新任务添加到任务数组并更新任务列表。

    const onSubmit = (event) => {
        //Prevent default
        event.preventDefault();
    
        const text = document.getElementById("input").value.trim();
        document.getElementById("input").value = "";
        tasks.push(text);
        updateList();
    }

    现在我们需要确保一旦任务提交,它就会在任务列表中更新

    const updateList = () => {
        const lists = document.getElementById("taskList");
        lists.innerHTML = "";
    
        //Loop through all elements in tasks
        tasks.forEach(task => {
            const taskElement = document.createElement("li");
            taskElement.classList.add("flex", "items-center", "space-x-2");
    
            //Add Bullet Point Element
            const bullet = document.createElement("span");
            bullet.classList.add("h-2", "w-2", "bg-blue-500", "rounded-full");
    
            //Add Span Tag
            const taskText = document.createElement("span");
            taskText.textContent = task;
    
            taskElement.appendChild(bullet);
            taskElement.appendChild(taskText);
            lists.appendChild(taskElement);
        })
    }

    `updateList()` 函数通过循环遍历任务数组并为每个任务创建列表项来呈现任务列表。每个列表项都包含一个要点和任务文本。

    现在我们需要确保列表在页面首次加载后得到更新。我们还希望在页面加载时初始化 Fuse.js - 并将“tasks”数组与其关联。请记住,我们希望在下拉列表中从此“tasks”数组呈现建议。

    const init = () => {
        console.log("Initializing...");
        //Update and render the list
        updateList();
    
        //Initialize Fuse with the updated array
        try{
            fuse = new Fuse(tasks, {
                includeScore: true,
                threshold: 0.3 //For sensitivity
            })
        } catch(e) {
            console.log("Error initializing Fuse:"+ fuse);
        }
    }
    document.addEventListener("DOMContentLoaded", init);

    现在我们需要确保每次输入时,我们都会搜索列表以在下拉列表中显示建议。这包括 3 个部分:

  • 编写搜索逻辑:searchTasks()
  • 在每个输入上填充下拉菜单:updateDropdown()
  • 将 updateDropdown() 与每次输入的调用关联起来(至少现在是这样 :-) -> 直到我们实现防抖/节流逻辑)
  • //Utility function to search within already entered values
    const searchTasks = (query) => {
        const result = fuse.search(query);
        const filteredTasks = result.map(result => result.item)
        updateDropdown(filteredTasks);
    }
    const updateDropdown = (tasks) => {
        const dropdown = document.getElementById("dropdown");
        dropdown.innerHTML = "";
    
        if(tasks.length === 0) {
            dropdown.style.display = "none";
            return;
        }
    
        tasks.forEach(task => {
            const listItem = document.createElement("li");
            listItem.textContent = task;
            listItem.addEventListener("click", () => {
                document.getElementById("input").value = task;
                dropdown.style.display = "none";
            })
            dropdown.appendChild(listItem);
        });
    
        dropdown.style.display = "block";
    }
    document.getElementById("submitButton").addEventListener("input", () => {
    searchTasks(event.target.value)
    });

    到目前为止:每次输入内容时,下拉列表都会更新 - 在更庞大的用户界面中,我们不希望有这种体验

    在庞大的用户界面中,每次按键时更新下拉列表可能会导致性能问题,造成延迟和糟糕的用户体验。频繁更新可能会使事件循环不堪重负,从而导致处理其他任务的延迟。

    我们现在将看到如何使用 Debouncing 或 throttling 来帮助管理更新频率,确保更流畅的性能和更灵敏的界面。

    下面说明了我们如何在笔记制作项目中实现这两种技术。

    防抖动:

    防抖功能可确保仅在自上次调用以来经过指定的时间后才调用函数。这对于搜索输入字段等场景非常有用,在这些场景中,我们希望等待用户完成输入后再进行 API 调用。

    **代码片段**:

    document.getElementById("input").addEventListener("input", (event) => {
        // Implement Debouncing - wait for 1 second of no input
        clearTimeout(debounceTimer); //debounceTimer is already declared in the beginning
        debounceTimer = setTimeout(() => {
            const query = event.target.value;
            searchTasks(query); // Call search function with the input value
        }, 1000);
    });

    **解释**:

  • 输入事件监听器附加到输入字段。
  • clearTimeout 函数清除任何现有的去抖动计时器。
  • setTimeout 函数设置一个新的防抖定时器,时长为 1 秒。如果在此期间未检测到任何输入,则使用输入值调用 searchTasks 函数。
  • 节流(在相同用例中)- 使用以下两种方法之一

    let lastCall = 0;  // To track the last time searchTasks was called
    document.getElementById("input").addEventListener("input", (event) => {
        const now = Date.now();
        const delay = 1000; // Throttle delay (1 second)
    
        // If enough time has passed since the last call, run the search
        if (now - lastCall >= delay) {
            const query = event.target.value.trim();
            searchTasks(query); // Call search function with the input value
            lastCall = now; // Update last call time
        }
    });

    **解释**:

  • let lastCall = 0;:初始化一个变量来跟踪上次调用searchTasks的时间。
  • document.getElementById("input").addEventListener("input", (event) => { ... });:将输入事件监听器附加到输入字段。
  • const now = Date.now();:以毫秒为单位获取当前时间。
  • const delay = 1000;:将油门延迟设置为 1 秒。
  • if (now - lastCall >= delay) { ... }:检查自上次调用以来是否已经过去了足够的时间。 const query = event.target.value.trim();:检索修剪后的输入值。 searchTasks(query);:使用输入值调用 searchTasks 函数。 lastCall = now;:将 lastCall 时间更新为当前时间。
  • 但请注意:节流并不适合这种情况,因为它将函数执行频率限制为固定间隔,这可能无法为实时搜索建议提供最佳用户体验。用户希望在输入时立即得到反馈,而节流可能会带来明显的延迟。

    节流的更好用例

    节流更适合于您想要控制事件处理速率以避免性能问题的场景。以下是一些示例:

  • 窗口大小调整:当用户调整浏览器窗口大小时,您可能需要更新布局或执行计算。节流可确保这些更新以受控的速率进行,从而防止过多的函数调用。
  • 滚动:处理滚动事件时,例如加载更多内容或根据滚动位置更新 UI,节流有助于管理更新频率,确保流畅的性能。
  • API 速率限制:进行 API 调用时,节流可以通过控制请求的频率来帮助您保持在速率限制之内。
  • 通过在这些场景中使用限制,您可以提高性能并确保更流畅的用户体验。

    完整代码请见此处

    祝您编码愉快!

    请留下反馈!

    希望您觉得本博客对您有帮助!您的反馈对我来说非常宝贵,因此请在下面的评论中留下您的想法和建议。

    欢迎随时在 LinkedIn 上与我联系,获取更多见解和更新。让我们保持联系,继续共同学习和成长!