症状
Profiler 让爆炸半径可见:录制一次按键,火焰图显示这次提交渲染了整页而不只是输入框。“按键到绘制”的路径就是诊断:keystroke → setState → 重渲染 App 加 400 个组件 → paint,其中除输入子树外全是浪费。所有权问题定位根因:value 状态住在哪?住得高且后代昂贵,意味着每个字符都要协调全世界。内联计算检查再分离“渲染浪费”与“计算浪费”:render 体里对上万行的 filter 无论树大小都是每键几十毫秒。迟滞随列表规模与页面复杂度增长,是成本结构的外显。
诊断思路
先 Profiler 录单键看提交范围与耗时;再定位每次按键变化的状态及其在树中的位置(爆炸半径由位置决定);最后对可疑计算插桩计时,区分渲染树成本与行内计算成本。每次按键同步写服务器或 localStorage 而非防抖,是常被忽略的第三来源。
根因
受控输入每次按键重渲染其所有者。所有者位于树的高处、下方子树庞大或昂贵时,每个字符都为无关组件付一次完整协调,输入延迟随状态变化波及的一切而增长。
修复与验证
把输入状态下推到最小所有者:FilterBar 持有 query、memo 化、ResultsTable 不随按键重渲染;昂贵计算 useMemo 化并配合 useDeferredValue 在提交值上过滤;离开组件的一切(服务器、localStorage)防抖 500ms;重编辑器(代码、富文本)用非受控输入加 ref 读取、blur 或显式保存时提交状态,把打字彻底移出渲染路径。验证时万行页面上输入即时:Profiler 每键提交保持在几毫秒且局限于输入子树。盲目把状态从依赖 UI 拆走会造成视图失同步——共享的保持共享,只下推按键局部状态。
代码示例
keystroke -> setState -> re-render App + 400 components -> paint
^ everything here is waste
function SearchPage({ rows }) {
return (
<>
<FilterBar rows={rows} /> {/* input state lives here */}
<ResultsTable rows={rows} /> {/* not re-rendered per keystroke */}
</>
);
}
const FilterBar = memo(function FilterBar({ rows }) {
const [query, setQuery] = useState('');
const deferred = useDeferredValue(query);
const results = useMemo(
() => filterRows(rows, deferred),
[rows, deferred]
);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
});
useEffect(() => {
const t = setTimeout(() => saveDraft(value), 500);
return () => clearTimeout(t);
}, [value]);