阅读视图

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

从定时器管理出发,彻底搞懂防抖与节流的实现逻辑

在前端开发中,高频事件(如输入、滚动、窗口缩放)若不加控制,极易引发性能问题。为应对这一挑战,防抖(debounce)节流(throttle) 成为必备工具。


一、防抖:每次触发都重置定时器

假设我们要实现一个功能:用户在输入框打字时,只有当他停止输入超过 1 秒,才发送请求。

第一步:我需要延迟执行

显然,要用 setTimeout

setTimeout(() => {
    ajax(value);
}, 1000);

第二步:但如果用户继续输入,之前的请求就不该发

→ 所以必须取消之前的定时器,再建一个新的。

这就要求我们保存定时器 ID

let timerId;
// 每次触发时:
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
    ajax(value);
}, 1000);

第三步:处理 this 和参数

因为 ajax 可能依赖上下文或多个参数,不能直接写死。我们需要在触发时捕获当前的 thisarguments

function debounce(fn, delay) {
    let timerId;
    return function(...args) {
        const context = this;
        if (timerId) clearTimeout(timerId);
        timerId = setTimeout(() => {
            fn.apply(context, args);
        }, delay);
    };
}

到此,防抖完成。它的全部逻辑就源于一句话: “每次触发,先删旧定时器,再建新定时器。”
所谓“停手后执行”,只是这种操作的自然结果。


二、节流:控制执行频率,必要时预约补发

现在需求变了:不管用户多快输入,每 1 秒最多只发一次请求,且最后一次输入不能丢

第一步:我能立即执行吗?

用时间戳判断是否已过 delay

const now = Date.now();
if (now - last >= delay) {
    fn(); 
    last = now; // 记录执行时间
}

这能保证最小间隔,但有个致命缺陷:如果用户快速输入后立刻停止,最后一次可能永远不会执行。

第二步:如何不丢尾?

→ 在冷却期内,预约一次未来的执行。这就要用到 setTimeout

于是逻辑分裂为两条路径:

  • 路径 A(可立即执行) :时间到了,马上执行,更新 last
  • 路径 B(还在冷却) :清除之前的预约,重新预约一次执行

第三步:管理预约定时器

我们需要一个变量 deferTimer 来保存预约任务的 ID:

let last = 0;
let deferTimer = null;

当处于冷却期时:

clearTimeout(deferTimer); // 清除旧预约
deferTimer = setTimeout(() => {
    last = Date.now(); // 关键:这次执行也要记录时间!
    fn.apply(this, args);
}, delay - (now - last)); // 精确计算剩余等待时间

第四步:整合逻辑

function throttle(fn, delay) {
    let last = 0;
    let deferTimer = null;

    return function(...args) {
        const context = this;
        const now = Date.now();

        if (now - last >= delay) {
            // 路径 A:立即执行
           
            last = now;
            fn.apply(context, args);
        } else {
            // 路径 B:预约执行
            if (deferTimer) clearTimeout(deferTimer);
            deferTimer = setTimeout(() => {
                last = Date.now(); // 必须更新!
               
                fn.apply(context, args);
            }, delay - (now - last));
        }
    };
}

节流的核心,是两种执行方式的协同

  • 立即执行靠时间戳判断
  • 补发执行靠 setTimeout 预约
    而两者共享同一个 last 状态,确保整体节奏不乱。

三、对比总结:防抖 vs 节流的机制差异

维度 防抖(Debounce) 节流(Throttle)
核心操作 每次触发都 clearTimeout + setTimeout 冷却期内 clearTimeout + setTimeout,否则立即执行
状态变量 仅需 timerId last(时间) + deferTimer(预约ID)
执行特点 只执行最后一次 固定间隔执行,且不丢尾
适用场景 搜索建议、表单校验 滚动加载、按钮限频、实时位置上报

React 表单的控制欲:什么时候我们真得控制它了,什么时候该放养了?

在写 React 代码时,你有没有过这样的困惑:

我只是想让用户输入一段文字,然后提交。
为什么非要搞个 useState 来管理它?
ref 直接读取 DOM 值不行吗?

如果你也这样想过,那你不是一个人。

今天我们就来聊一聊这个看似简单、实则深藏玄机的问题:React 中的受控组件和非受控组件,到底该选谁?


一、问题的起点:我该如何获取表单值?

最原始的需求是:用户输入内容 → 点击提交 → 获取输入内容

这听起来很简单,但实现方式却有两条路:

  • 方案A:给 <input> 加一个 value 属性,并通过 onChange 更新状态。
  • 方案B:不设置 value,只用 ref 在提交时读取 DOM 的 .value

我们先看一段代码:

const [value, setValue] = useState('');
const inputRef = useRef(null);

const doLogin = (e) => {
  e.preventDefault();
  console.log(inputRef.current.value); // 从 DOM 读值
};

return (
  <form onSubmit={doLogin}>
    <input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
    <input type="text" ref={inputRef} />
    <button type="submit">登录</button>
  </form>
);

这段代码里有两个输入框:

  • 第一个是受控的(value={value}
  • 第二个是非受控的(ref={inputRef}

当你点击“登录”,你会看到两个值都被打印出来 —— 但它们的来源完全不同。


二、思考:为什么需要“控制”?

我们先问自己一个问题:

如果我不用 state 控制输入框,而是直接读 DOM,是不是更省事?

看起来是的。代码少,逻辑清晰,还能避免状态同步的问题。

但这里有一个关键的认知偏差:我们以为“读取值”只需要一次操作,但实际上,用户的交互是一个持续的过程。

🌪️ 想象这样一个场景:

你在做一个登录表单,要求密码至少 6 位,且包含数字。

如果使用非受控组件,你只能在提交时判断是否符合规则。
但如果用户输入了 abc123,系统提示“太短了”,然后他继续打 456,变成 abc123456,这时候你才发现合格。

问题是:你怎么知道他在中间改了多少次?怎么实时反馈?

而受控组件可以做到这一点:

<input
  type="password"
  value={form.password}
  onChange={(e) => {
    const pwd = e.target.value;
    setForm({ ...form, password: pwd });
    if (pwd.length >= 6 && /\d/.test(pwd)) {
      setValid(true);
    } else {
      setValid(false);
    }
  }}
/>

这就是“控制”的价值:把数据流从“被动响应”变成“主动驱动”。


三、再深入一步:什么是“受控”?

很多人误以为“受控”就是“加了个 value”,其实不然。

真正的“受控”是一种设计理念

所有的 UI 状态都由 React 的 state 驱动,而不是由 DOM 自行决定。

这意味着:

  • 输入框的值来自 state
  • 用户输入触发事件,更新 state
  • 页面重新渲染,显示新的值

这是一个闭环,形成了单向数据流

这种模式的好处在于:

  • 数据可预测(不会出现“页面显示 A,实际是 B”的问题)
  • 可以在任意时刻进行校验、重置、保存
  • 更容易测试和调试

ref 虽然能拿到值,但它绕过了 React 的状态系统,属于“黑箱操作”。


四、那什么时候该“放手”?

既然受控这么好,为什么还要有非受控组件?

因为有些场景,我们并不需要“控制”。

比如评论框:

const textareaRef = useRef(null);

const handleSubmit = () => {
  const comment = textareaRef.current.value;
  if (!comment) {
    alert('请输入评论');
    return;
  }
  console.log(comment);
};

在这个例子中:

  • 用户输入完就提交
  • 不需要实时校验
  • 不需要联动其他字段
  • 也不需要预览或自动补全

这时候,用 ref 是一种轻量级的选择

而且,在某些性能敏感的场景下,频繁触发 setState 会影响性能。例如文件上传、富文本编辑器等,这些组件内部有自己的状态管理机制,强行用 React 控制反而会增加复杂度。


五、结论:不是选择题,而是权衡题

回到最初的问题:我应该用受控还是非受控?

答案不是“哪个更好”,而是:

根据业务需求做权衡。

场景 推荐方式 理由
登录/注册 受控组件 需要校验、联动、错误提示
评论/留言 非受控组件 一次性提交,无需实时处理
文件上传 非受控组件 DOM 内部状态复杂,不适合 React 管理
实时搜索 受控组件 需要即时反馈结果
富文本编辑器 非受控组件 使用第三方库,内部状态独立

六、最后的思考:React 的本质是什么?

React 的核心思想是:UI 是状态的函数

也就是说,页面长什么样,完全取决于当前的状态。

当你使用受控组件时,你是在践行这一理念:每一个变化,都是状态驱动的结果

而当你使用非受控组件时,你实际上是在说:“这个部分我暂时不想管,让它自己玩。”

这不是坏事,但你要清楚地知道:你在放弃一部分控制权。

所以,不要为了“简洁”而滥用非受控组件,也不要为了“规范”而过度使用受控组件。

我们应该要在“控制”与“放手”之间找到平衡点。


写在最后

技术没有绝对的对错,只有合适的时机。

下次面对一个表单时,可以先想想

“我需要在用户输入的过程中做什么?”

如果答案是“什么也不做”,那就放手吧。(useRef)
如果答案是“我要校验、联动、展示”,那就牢牢抓住它。(useState)

这才是 React 表单设计的真正智慧。

❌