阅读视图

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

一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战

author: 大布布将军

前言:万恶之源

本故事有虚构成分。但来源于现实开发场景。 事情是这样的。

某个周五下午 4 点,产品经理(PM)迈着六亲不认的步伐向我们前端走来。他满面春风地说:“哎,那个列表页,用户反馈说找不到想要的数据,加个实时搜索功能吧?要那种一边打字一边过滤的,丝般顺滑的感觉,懂我意思吧?”

前端心想:这还不简单?input 绑定 onChange,拿到 value 往列表里一 filter,完事儿。半小时搞定,还能赶上 6 点的地铁。

于是,前端写下了那段让我后来后悔不已的代码。


第一阶段:由于过度自信导致的翻车

为了还原案发现场,前端写了一个简化版的 Demo。假设我们有一个包含 10,000 条数据的列表(别问为什么前端要处理一万条数据,问就是后端甩锅)。

也就是这几行“天真”的代码:


// 假装这里有一万个商品
const generateProducts = () => {
  return Array.from({ length: 10000 }, (_, i) => `超级无敌好用的商品 #${i}`);
};

const dummyProducts = generateProducts();

export default function SearchList() {
  const [query, setQuery] = useState('');

  // 🔴 罪魁祸首在这里:每次 render 都要遍历一万次
  const filteredProducts = dummyProducts.filter(p => 
    p.toLowerCase().includes(query.toLowerCase())
  );

  const handleChange = (e) => {
    setQuery(e.target.value); // 这一步更新 state,触发重渲染
  };

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} 
        onChange={handleChange} 
        placeholder="搜索..." 
        className="border p-2 w-full"
      />
      <ul className="mt-4">
        {filteredProducts.map((p, index) => (
          <li key={index}>{p}</li>
        ))}
      </ul>
    </div>
  );
}

结果如何?

在前端的 ThinkBook 上跑了一下,输入的时候感觉像是在 PPT 里打字。每一个字符敲下去,都要顿个几百毫秒才会显示在输入框里。

为什么? 这是 React 的基本原理:

  1. 用户输入 'a' -> 触发 setQuery
  2. React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的 render 函数。
  3. render 函数里有一个极其昂贵的 filter 操作(遍历 10k 次)。
  4. 即使 filter 完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。
  5. JS 线程被堵死,UI 渲染被阻塞,用户看到的就是:卡顿

第二阶段:万金油防抖 (Debounce) —— 治标不治本

作为老油条,第一反应当然是:“切,防抖一下不就行了?”

只要让用户打字的时候不触发计算,停下来再计算,不就完了?

import { debounce } from 'lodash';

// ... 省略部分代码

const handleChange = debounce((e) => {
    setQuery(e.target.value);
}, 300);

效果: 输入框确实不卡了,打字很流畅。但是,当你停止打字 300ms 后,页面会突然“冻结”一下,然后列表瞬间刷新。

痛点: 这种体验就像是便秘。虽然没有一直在用力,但最后那一下还是很痛苦。而且,UI 的响应滞后感很强,依然没有达到 PM 要求的“丝般顺滑”。

第三阶段:祭出神器 useDeferredValue

React 18 发布这么久了,是时候让它出来干点活了。

这时候作为前端的我们需要引入一个概念:并发模式 (Concurrent Features)

简单来说,就是把更新任务分为“大哥”和“小弟”。

  • 大哥(紧急更新) :用户的打字输入、点击反馈。这玩意儿必须马上响应,不然用户会以为死机了。
  • 小弟(非紧急更新) :列表的过滤渲染。晚个几百毫秒没人在意。

React 18 给了我们一个 Hook 叫 useDeferredValue,专门用来处理这种场景。

改造后的代码:


export default function OptimizedSearchList() {
  const [query, setQuery] = useState('');
  
  //  魔法在这里:创建一个“滞后”的副本
  // React 会在空闲的时候更新这个值
  const deferredQuery = useDeferredValue(query);

  const handleChange = (e) => {
    // 这里依然是紧急更新,保证 input 框打字流畅
    setQuery(e.target.value); 
  };

  // 只有当 deferredQuery 变了,才去跑这个昂贵的 filter
  // 注意:这里要配合 useMemo,不然也没用
  const filteredProducts = useMemo(() => {
    return dummyProducts.filter(p => 
      p.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} // 绑定实时的 query
        onChange={handleChange} 
        className="border p-2 w-full"
      />
      
      {/* 甚至可以加个 loading 状态,判断 query 和 deferredQuery 是否同步 */}
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        <ul className="mt-4">
          {filteredProducts.map((p, index) => (
            <li key={index}>{p}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

这一波操作到底发生了什么?

  1. 输入 'a' :React 此时收到了两个任务。

    • 任务 A(高优先级):更新 query,让 input 框显示 'a'。
    • 任务 B(低优先级):更新 deferredQuery,并重新计算列表。
  2. React 的调度

    • React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
    • React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
  3. 结果

    • JS 线程没有被长列表渲染锁死。
    • 输入框始终保持 60fps 的响应速度。
    • 列表会在资源空闲时“不知不觉”地更新完成。

深度解析:React 原理是咋搞的?

为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。

1. 以前的 React (Stack Reconciler)

想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。

2. 现在的 React (Fiber & Concurrency)

现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。

  • 切两刀,抬头看看:“老板,有急事吗?”
  • 老板:“没事,你继续。” -> 继续切。
  • 切两刀,抬头:“老板?”
  • 老板:“有!客人要喝水(用户输入了)!”
  • React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。

useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”

总结 & 避坑指南

这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”

但是,兄弟们,请注意以下几点(防杠声明):

  1. 不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用 useDeferredValue 纯属脱裤子放屁,反而更慢。
  2. 配合 useMemo:就像代码里写的,过滤逻辑必须包裹在 useMemo 里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue 就白用了。
  3. 性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上 react-windowreact-virtualized 吧,DOM 节点的数量才是真正的瓶颈。

好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。


下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用 useDeferredValue 包一下,说不定有奇效。

❌