You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js客户端组件搜索输入时失焦问题求助

解决Next.js客户端组件搜索时输入框失焦问题

我有一个Next.js客户端组件,用于展示数据表格并提供搜索栏筛选内容。当前遇到的问题是:在搜索栏输入内容时,整个组件会触发重新渲染,导致输入框失去焦点,打断输入流程。虽然已经对搜索功能做了防抖处理,但较长的查询内容仍会受此问题影响。此外,由于需要通过onPartSelected将选中行的数据传递给父组件,该组件必须保持为客户端组件。

问题根源

出现这个问题的核心原因有两个:

  • PartsTable被定义为异步函数组件(async function PartsTable),但内部没有执行任何异步操作。在Next.js中,异步客户端组件会引发额外的重渲染开销,甚至导致组件树重新挂载,直接造成输入框失焦。
  • 传递给子组件的回调函数(handlePartRowClicked、handleQueryChanged)在每次父组件重渲染时都会创建新实例,触发子组件不必要的重渲染。

修复方案

1. 将PartsTable改为普通客户端组件

去掉async关键字,因为组件内部没有异步逻辑(useRouter的使用不需要异步):

'use client'

import { PartsTable } from '@/app/lib/definitions';
import Search from '@/app/ui/search';
import { useRouter } from 'next/navigation'
import { Button } from '@/app/ui/button';

// 移除async关键字
export default function PartsTable({
  parts,
  disableControls,
  onRowClick
}: {
  parts: any[],
  disableControls?: boolean,
  onRowClick?: any
}) {
  const router = useRouter();

  const handleAddNewClick = () => {
    router.push(`/dashboard/inventory/add`);
  }
  const handleRowClick = (part: any) => {
    onRowClick(part);
  }
  return (
    <div className="w-full">
      {disableControls || (
        <div className='flex'>
          <Button className='mr-5' onClick={() => handleAddNewClick()}>
            Add new +
          </Button>
          <Search placeholder="Search parts..." />
        </div>
      )}
      <div className="mt-6 flow-root">
        <div className="overflow-x-auto">
          <div className="inline-block min-w-full align-middle">
            <div className="overflow-hidden rounded-md bg-gray-50 p-2 md:pt-0">
              <table className="hidden min-w-full rounded-md text-gray-900 md:table">
                <thead className="rounded-md bg-gray-50 text-left text-sm font-normal">
                  <tr>
                    <th scope="col" className="px-4 py-5 font-bold sm:pl-6">
                      Stock Code
                    </th>
                    <th scope="col" className="px-3 py-5 font-bold">
                      Description
                    </th>
                    <th scope="col" className="px-3 py-5 font-bold">
                      Order Form Group
                    </th>
                    <th scope="col" className="px-3 py-5 font-bold">
                      Selling Price
                    </th>
                    <th scope="col" className="px-3 py-5 font-bold">
                      Manufacturer
                    </th>
                    <th scope="col" className="px-3 py-5 font-bold">
                      Stock Year
                    </th>
                  </tr>
                </thead>

                <tbody className="divide-y divide-gray-200 text-gray-900">
                  {parts.map((part) => (
                    <tr key={part.id} className="group cursor-pointer hover:text-blue-600 hover:bg-sky-100" onClick={() => handleRowClick(part)}>
                      <td className="whitespace-nowrap pl-4 pr-3 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6">
                        <div className="flex items-center gap-3">
                          <p>{part.stock_code}</p>
                        </div>
                      </td>
                      <td className="whitespace-nowrap px-4 py-5 text-sm">
                        {part.description}
                      </td>
                      <td className="whitespace-nowrap px-4 py-5 text-sm">
                        {part.order_form_group}
                      </td>
                      <td className="whitespace-nowrap px-4 py-5 text-sm">
                        {(part.selling_price_cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD" })}
                      </td>
                      <td className="whitespace-nowrap px-4 py-5 text-sm">
                        {part.manufacturer}
                      </td>
                      <td className="whitespace-nowrap px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md">
                        {part.stock_year}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

2. 使用useCallback缓存回调函数

在PartSearch组件中,用useCallback包裹回调函数,确保它们在组件重渲染时保持相同引用:

'use client'

import { useEffect, useState, useCallback } from "react";
import { PartsTable as PartsRow } from "@/app/lib/definitions";
import PartsTable from "@/app/ui/parts/table";
import NoQueryParamSearch from "../no-query-param-search";

export default function PartSearch({
    onPartSelected
}: {
    onPartSelected?: any
}) {
    const [parts, setParts] = useState([] as PartsRow[]);
    const [search, setSearch] = useState('');

    // 缓存行点击回调
    const handlePartRowClicked = useCallback((part: any) => {
        onPartSelected?.(part);
    }, [onPartSelected]);

    // 缓存搜索回调
    const handleQueryChanged = useCallback((query: string) => {
        setSearch(query);
    }, []);

    useEffect(() => {
        // 添加请求取消逻辑,避免竞态问题
        const controller = new AbortController();
        const signal = controller.signal;

        fetch(`/api/parts?query=${search}`, { signal })
            .then(async (response) => {
                if (!response.ok) throw new Error('请求失败');
                const parts = await response.json();
                setParts(parts);
            })
            .catch(err => {
                if (err.name !== 'AbortError') console.error('获取数据失败:', err);
            });

        return () => controller.abort();
    }, [search]);

    return (
        <div>
            <NoQueryParamSearch placeholder="Search ID, description, year, etc..." onChange={handleQueryChanged} />
            <div className="border border-solid border-gray-200 rounded-md my-2">
                <PartsTable parts={parts.slice(0, 5)} onRowClick={handlePartRowClicked} disableControls />
            </div>
        </div>
    );
}

3. 可选:优化搜索组件的防抖逻辑

确保防抖回调的引用稳定,进一步减少不必要的重渲染:

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useDebouncedCallback } from 'use-debounce';
import { useCallback } from 'react';

export default function NoQueryParamSearch({ placeholder, onChange, autoComplete = true }: { placeholder: string, onChange: any, autoComplete?: boolean }) {

    const handleSearch = useDebouncedCallback(
        useCallback((term) => {
            onChange(term);
        }, [onChange]),
        300
    );

    return (
        <div className="relative flex flex-shrink-0">
            <label htmlFor="search" className="sr-only">
                Search
            </label>
            <input
                className="peer block w-96 rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                placeholder={placeholder}
                onChange={(e) => {
                    handleSearch(e.target.value);
                }}
                autoComplete={autoComplete ? undefined : 'off'}
                autoCorrect={autoComplete ? undefined : 'off'}
            />
            <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
        </div>
    );
}

效果说明

修改完成后,组件的重渲染会被大幅减少,输入框不会再因重渲染而失焦。同时添加的请求取消逻辑可以避免旧请求覆盖新请求的竞态问题,提升整体用户体验。

内容的提问来源于stack exchange,提问作者Austin

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 16:05:55