با رشد پیچیدگی پروژههای React، نیاز به یک سیستم جستجوی پیشرفته، قدرتمند و منعطف بیش از پیش احساس میشود. جستجو در رابطهای کاربری، تنها به فیلتر کردن ساده دادهها محدود نیست؛ بلکه باید بتواند در مواجهه با دادههای حجیم، ساختارهای تودرتو و حتی اشتباهات تایپی کاربران نیز عملکرد مؤثری داشته باشد.
در این مقاله، یک راهکار جامع برای پیادهسازی سیستم جستجو در React مورد بررسی قرار میگیرد. تمرکز اصلی، طراحی یک هوک قابل استفاده مجدد به نام useSearch
است که قابلیتهایی مانند پشتیبانی از جستجوی فازی (Fuzzy Search)، بهینهسازی عملکرد با تکنیکهای مختلف و مدیریت دادههای بزرگ با استفاده از Pagination را فراهم میکند.
این مقاله برای توسعهدهندگان React در سطوح مختلف طراحی شده است؛ چه افراد تازهکار که به دنبال ارتقاء قابلیتهای پروژههای خود هستند و چه برنامهنویسان حرفهای که با محدودیتهای رایج در جستجو پیشرفته React مواجهاند، از جمله کندی در پردازش دادههای حجیم، ضعف در تشخیص تایپ اشتباه یا دشواری در مدیریت ساختارهای پیچیده داده.
هدف این مقاله، ارائه یک سیستم جستجوی پیشرفته React است که هم قابل اعتماد باشد و هم بهراحتی در پروژههای واقعی قابل استفاده و توسعهپذیر باقی بماند.
در پایان این مقاله، با مفاهیم و مهارتهای زیر آشنا خواهیم شد:
کار خود را با ساخت یک کامپوننت جستجوی ابتدایی شروع میکنیم:
function SimpleSearch() { const data = [ { name: "JavaScript" }, { name: "Python" }, { name: "Java" } ]; const [query, setQuery] = useState(""); const results = data.filter((item) => item.name.includes(query)); return ( <div> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <ul> {results.map((item, index) => ( <li key={index}>{item.name}</li> ))} </ul> </div> ); }
در نگاه اول، بهنظر میرسد که این پیادهسازی بهخوبی کار میکند: یک کوئری وارد کرده و نتایج مرتبط را دریافت میکنیم. اما در اپلیکیشنهای واقعی، جستجو باید فراتر از مقایسه ساده رشتهها باشد. در ادامه، به برخی از محدودیتهای اصلی این روش اشاره میکنیم:
{ user: { name: "JavaScript" } }
) این روش جواب نمیدهد."javascrpt"
باعث میشود "JavaScript"
پیدا نشود، که برای کاربر آزاردهنده است.واضح است که به چیزی قدرتمندتر نیاز داریم. در ادامه قصد داریم سیستمی بسازیم که هم انعطافپذیر باشد، هم بهینه و هم کاربرپسند.
برای حل این مشکلات، هوکی طراحی میکنیم که:
ابتدا هوک را تعریف میکنیم. این هوک دادهها، کوئری جستجو و لیستی از توابع فیلترکننده را دریافت میکند:
// hooks/useSearch.js function useSearch(data, query, ...filters) { const debouncedQuery = useDebounce(query, 300); return React.useMemo(() => { const dataArray = Array.isArray(data) ? data : [data]; try { // Apply each filter function in sequence return filters.reduce( (acc, feature) => feature(acc, debouncedQuery), dataArray ); } catch (error) { console.error("Error applying search features:", error); return dataArray; } }, [data, debouncedQuery, filters]); }
بدون استفاده از debounce، هر بار که کاربر کلیدی را فشار میدهد، یک جستجوی جدید آغاز میشود. تصور کنید میخواهیم واژهی "apple"
را تایپ کنیم؛ با هر حرف (a
، p
، p
، l
، e
) یک درخواست جستجو ارسال میشود که منجر به رندرهای مکرر و در نهایت افت عملکرد خواهد شد.
برای حل این مشکل، در هوک useSearch
از مکانیزم debounce استفاده کردهایم که صبر میکند تا کاربر تایپ کردن را متوقف کند، سپس جستجو را انجام میدهد. در ادامه، هوک useDebounce
را داریم:
import React from "react"; function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = React.useState(value); React.useEffect(() => { const timeoutId = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timeoutId); }, [value, delay]); return debouncedValue; }
این هوک تضمین میکند که جستجو فقط پس از ۳۰۰ میلیثانیه عدم فعالیت کاربر اجرا شود. به این ترتیب از رندرهای غیرضروری جلوگیری شده و واکنشپذیری برنامه افزایش مییابد.
فیلتر کردن دادههای حجیم میتواند پردازشی سنگین باشد. اگر منطق جستجوی ما در هر بار رندر شدن کامپوننت اجرا شود،حتی زمانی که کوئری تغییری نکرده باشد، میتواند باعث افت سرعت شود. در اینجا React.useMemo()
به کمک ما میآید.
با قرار دادن منطق جستجو در هوک useMemo
، اطمینان حاصل میکنیم که فقط زمانی محاسبه دوباره انجام شود که کوئری، فیلترها یا دادهها واقعاً تغییر کرده باشند:
return React.useMemo(() => { // Filtering logic }, [data, debouncedQuery, filters]);
اما این کار چقدر تفاوت ایجاد میکند؟ تصور کنید یک کامپوننت parent داریم با یک state بیربط (مثل یک شمارنده). هر بار که کامپوننت parent رندر میشود، در حالت بدون useMemo، جستجو مجدداً اجرا میشود، حتی اگر کوئری ثابت مانده باشد.
با استفاده از هوک useMemo
، منطق جستجو فقط زمانی اجرا میشود که کوئری، فیلترها یا دادهها تغییر کنند. این کار باعث میشود عملکرد برنامه روانتر باشد و از محاسبات غیرضروری جلوگیری شود.
در این هوک از متد .reduce()
استفاده میکنیم تا فیلترها را بهصورت پشتسرهم روی دادهها اعمال کنیم. این روش باعث میشود منطق جستجو تمیز، قابل فهم و قابل توسعه باقی بماند:
return filters.reduce( (acc, feature) => feature(acc, debouncedQuery), dataArray );
این رویکرد همچنین اضافه یا حذف کردن فیلترهای جدید را بسیار ساده میکند.
فیلترها در پیادهسازی جستجو پیشرفته React نقش کلیدی دارند. آنها مسئول پردازش دادهها بر اساس کوئری ورودی هستند. در این پروژه، دو نوع فیلتر طراحی شده است: یکی برای عملیات جستجو و دیگری برای مدیریت Pagination.
فیلتر جستجو، فیلدهای مشخصشده در هر آبجکت را بررسی میکند تا ببیند آیا با کوئری مطابقت دارند یا نه. این فیلتر از چندین روش تطبیق (matching) پشتیبانی میکند:
// utils/search.js export function search(options) { const { fields, matchType } = options; return (data, query) => { const trimmedQuery = String(query).trim().toLowerCase(); if (!trimmedQuery) return data; return data.filter((item) => { const fieldsArray = fields ? Array.isArray(fields) ? fields : [fields] : getAllKeys(item); return fieldsArray.some((field) => { const fieldValue = getFieldValue(item, field); if (fieldValue == null) return false; const stringValue = convertToString(fieldValue).toLowerCase(); switch (matchType) { case "exact": return stringValue === trimmedQuery; case "startsWith": return stringValue.startsWith(trimmedQuery); case "endsWith": return stringValue.endsWith(trimmedQuery); case "contains": return stringValue.includes(trimmedQuery); default: throw new Error(`Unsupported match type: ${matchType}`); } }); }); }; }
بیایید نگاهی به مراحل عملکرد این فیلتر بیندازیم:
۱. پاکسازی کوئری:
const trimmedQuery = String(query).trim().toLowerCase(); if (!trimmedQuery) { return data; }
در این مرحله، جستجو حساسیت به حروف بزرگ و کوچک ندارد و فاصلههای اضافی حذف میشوند.
۲. تعیین فیلدهایی که باید جستجو شوند:
const fieldsArray = fields ? Array.isArray(fields) ? fields : [fields] : getAllKeys(item);
اگر فیلدهای خاصی مشخص نشده باشند، تمام کلیدها از جمله کلیدهای تودرتو استخراج میشوند.
۳. فیلتر کردن دادهها:
return fieldsArray.some((field) => { const fieldValue = getFieldValue(item, field); if (fieldValue == null) { return false; } const stringValue = convertToString(fieldValue).toLowerCase(); // Matching logic based on matchType follows... });
برای حفظ سادگی و تمرکز در منطق فیلترسازی در جستجو پیشرفته React، از چند تابع کمکی بهره میبریم. این توابع وظایفی مانند استخراج کلیدهای آبجکتها، دسترسی به مقادیر تودرتو و تبدیل آنها به رشته را انجام میدهند. با این ساختار، فیلتر جستجو میتواند بدون پیچیده شدن منطق اصلی، انواع مختلف داده را پردازش کند.
استخراج همه کلیدها با getAllKeys
:
تابع getAllKeys
یک آبجکت را اسکن میکند تا تمام کلیدهای آن را جمعآوری کند، حتی کلیدهایی که درون آرایهها یا آبجکتهای تودرتو قرار دارند. اگر فیلدهای خاصی برای جستجو مشخص نشده باشد، این تابع تضمین میکند که تمام فیلدهای ممکن در نظر گرفته شوند.
// utils/getAllKeys.js export function getAllKeys(item, prefix = "") { if (!item || typeof item !== "object") { return []; } const fields = []; for (const key of Object.keys(item)) { const value = item[key]; const fieldPath = prefix ? `${prefix}.${key}` : key; if (Array.isArray(value)) { value.forEach((arrayItem, index) => { if ( arrayItem && typeof arrayItem === "object" && !(arrayItem instanceof Date) ) { fields.push(...getAllKeys(arrayItem, `${fieldPath}[${index}]`)); } else { fields.push(`${fieldPath}[${index}]`); } }); } else if (value instanceof Date) { fields.push(fieldPath); } else if (value && typeof value === "object") { fields.push(...getAllKeys(value, fieldPath)); } else { fields.push(fieldPath); } } return fields; }
استخراج مقدار فیلدها با getFieldValue
:
تابع getFieldValue
مقدار یک فیلد خاص را با استفاده از مسیر آن (مثل "user.name"
یا "items[0].title"
) از یک آبجکت استخراج میکند. این تابع مسیر را به کلیدهای جداگانه تقسیم کرده و بهصورت مرحلهبهمرحله آبجکت را پیمایش میکند تا مقدار مورد نظر را پیدا کند.
// utils/getFieldValue.js export function getFieldValue(item, field) { const keys = field.split(/[\.\[\]]/).filter(Boolean); let value = item; for (const key of keys) { if (value == null) { return null; } value = value[key]; } return value; }
تبدیل مقادیر به رشته با convertToString
:
برای مقایسههای جستجو، لازم است تمام دادهها به فرمت رشتهای تبدیل شوند. تابع convertToString
این کار را انجام میدهد. این تابع تاریخها را به فرمت ISO و مقادیر بولین را به رشتههای "true"
یا "false"
تبدیل میکند تا فیلتر جستجو با فرمتی یکنواخت و قابل اطمینان کار کند.
// utils/convertToString.js export function convertToString(value) { if (value instanceof Date) { return value.toISOString(); } if (typeof value === "boolean") { return value ? "true" : "false"; } return String(value); }
برای مجموعه دادههای بزرگ، نمایش همه نتایج بهطور همزمان عملی نیست. فیلتر صفحهبندی (Pagination) با بازگرداندن فقط یک زیرمجموعه از دادهها براساس صفحه فعلی و تعداد آیتمها در هر صفحه، هم عملکرد را بهبود میبخشد و هم دادهها را برای کاربر قابل مدیریتتر میسازد.
در این تابع، شاخص شروع با استفاده از شماره صفحه و اندازه صفحه محاسبه میشود. سپس با متد slice
در جاوااسکریپت، تنها آیتمهای مربوط به آن صفحه انتخاب میشوند. پارامتر query در اینجا نقشی ندارد و فقط برای حفظ یکپارچگی رابط هوک استفاده شده است.
// utils/paginate.js export function paginate(options) { const { page = 1, pageSize = 10 } = options; return (data, query) => { // Query is not used here; it’s only for compatibility with our hook. const startIndex = (page - 1) * pageSize; return data.slice(startIndex, startIndex + pageSize); }; }
در این کد، فیلتر Pagination آرایه دادهها را بهصورت بهینه برش میدهد تا تنها زیرمجموعهای از نتایج که در صفحه فعلی باید نمایش داده شوند، بازگردانده شوند.
حالا که فیلترهای جستجو و صفحهبندی را آماده کردیم، بررسی میکنیم تا ببینیم چطور میتوانیم آنها را در یک کامپوننت React استفاده کنیم.
در مرحله اول، هوک سفارشی useSearch
و توابع فیلتر را import میکنیم.
import useSearch from "./hooks/useSearch.js"; import search from "./utils/search.js"; import paginate from "./utils/paginate.js";
سپس، یک کامپوننت میسازیم که از این فیلترها استفاده کند. در این مثال، ما یک آرایه از آیتمها داریم و میخواهیم بر اساس نام جستجو کنیم و تعداد مشخصی از نتایج را در هر صفحه نمایش دهیم. همچنین، هر زمان که کاربر یک کوئری جستجوی جدید وارد کند، به صفحه اول باز میگردیم.
function SearchComponent() { // Example data array const data = [ { name: "JavaScript" }, { name: "Python" }, { name: "Java" }, { name: "Ruby" }, // Imagine more data here ]; const [query, setQuery] = React.useState(""); const [page, setPage] = React.useState(1); const pageSize = 3; // Items per page // Apply both search and pagination filters with our custom hook. const results = useSearch( data, query, search({ fields: ["name"], matchType: "contains", // Options: "exact", "startsWith", etc. }), paginate({ page, pageSize }) ); // Compute total pages based on filtered results (without pagination) const filteredData = search({ fields: ["name"], matchType: "contains" })( data, query ); const totalPages = Math.ceil(filteredData.length / pageSize); return ( <div style={{ padding: "20px", fontFamily: "Arial, sans-serif" }}> <h2>Search and Pagination</h2> <input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); // Reset to first page on new search }} placeholder="Search by name..." style={{ padding: "8px", width: "300px", marginBottom: "10px" }} /> <ul> {results.map((item, index) => ( <li key={index}>{item.name}</li> ))} </ul> <div style={{ marginTop: "10px" }}> <button onClick={() => setPage((prev) => Math.max(prev - 1, 1))} disabled={page === 1} style={{ padding: "6px 12px", marginRight: "10px" }} > Previous </button> <span>Page {page} of {totalPages}</span> <button onClick={() => setPage((prev) => Math.min(prev + 1, totalPages))} disabled={page >= totalPages} style={{ padding: "6px 12px", marginLeft: "10px" }} > Next </button> </div> </div> ); }
جستجو یکی از قدیمیترین قابلیتها در وب است، اما این به معنای آن نیست که کاربران همیشه آن را بهدرستی انجام میدهند. در واقع، اشتباهات تایپی بسیار رایج هستند. تصور کنید کاربری قصد دارد واژه “PlayStation” را جستجو کند، اما به اشتباه “PlauStation” تایپ میکند. آنها همچنان انتظار دارند نتایج مرتبط را ببینند، بنابراین سیستم جستجوی ما باید آنقدر هوشمند باشد که بتواند این اشتباهات جزئی را نادیده بگیرد.
برای رسیدن به این هدف، از تکنیکی بهنام جستجوی مبهم (Fuzzy Search) استفاده میکنیم که کلمات مشابه را حتی در صورت تفاوت املایی، با یکدیگر تطبیق میدهد. ما این جستجوی مبهم را با استفاده از الگوریتمی بهنام n-gram پیادهسازی میکنیم؛ این الگوریتم کلمات را به بخشهای کوچکتری به نام n-gram تقسیم کرده و آنها را با هم مقایسه میکند.
الگوریتم n-gram با تقسیم کوئری جستجو و مقادیر دیتاست به دنبالههای کوچک و همپوشان از کاراکترها (n-grams) کار میکند و سپس آنها را با هم مقایسه مینماید:
// utils/nGramFuzzySearch.js export const nGramFuzzySearch = (value, query) => { const n = 2; // Default to bigrams (two-character sequences) const valueGrams = generateNGrams(value.toLowerCase(), n); const queryGrams = generateNGrams(query.toLowerCase(), n); const intersection = valueGrams.filter((gram) => queryGrams.includes(gram)); return intersection.length / Math.max(valueGrams.length, queryGrams.length); }; const generateNGrams = (str, n) => { const grams = []; for (let i = 0; i <= str.length - n; i++) { grams.push(str.slice(i, i + n)); } return grams; };
بهعنوان مثال، فرض کنید کاربر کلمهی “PlauStation” را جستجو میکند و نام محصول “PlayStation” است.
در ابتدا، الگوریتم bigram (دنبالههای دوحرفی) برای هر دو کلمه تولید میکند:
PlayStation → ["pl", "la", "ay", "ys", "st", "ta", "at", "ti", "io", "on"] PlauStation → ["pl", "la", "au", "us", "st", "ta", "at", "ti", "io", "on"]
سپس، با محاسبه تعداد bigramهای مشترک، میزان شباهت را میسنجد. هرچه میزان همپوشانی بیشتر باشد، تطبیق قویتر خواهد بود. چون اغلب bigramها در این مثال مشابهاند، الگوریتم امتیاز شباهت بالایی برمیگرداند و بنابراین میتواند “PlauStation” را بهعنوان تطبیق مناسبی برای “PlayStation” تشخیص دهد، حتی با وجود اشتباه تایپی.
اکنون باید فیلتر جستجوی خود را بهروزرسانی کنیم تا از یک matchType
جدید برای جستجوی فازی پشتیبانی کند:
// Update in utils/search.js import { nGramFuzzySearch } from "./nGramFuzzySearch"; export function search(options) { const { fields, matchType } = options; return (data, query) => { const trimmedQuery = String(query).trim().toLowerCase(); if (trimmedQuery === "") { return data; } return data.filter((item) => { const fieldsArray = fields ? Array.isArray(fields) ? fields : [fields] : getAllKeys(item); return fieldsArray.some((field) => { const fieldValue = getFieldValue(item, field); if (fieldValue == null) { return false; } const stringValue = convertToString(fieldValue).toLowerCase(); switch (matchType) { case "exact": return stringValue === trimmedQuery; case "startsWith": return stringValue.startsWith(trimmedQuery); case "endsWith": return stringValue.endsWith(trimmedQuery); case "contains": return stringValue.includes(trimmedQuery); case "fuzzySearch": { const threshold = 0.5; // Minimum similarity score required const score = nGramFuzzySearch(stringValue, trimmedQuery); return score >= threshold; } default: throw new Error(`Unsupported match type: ${matchType}`); } }); }); }; }
اکنون میتوانیم تنها با ارسال fuzzySearch
بهعنوان matchType
، جستجوی مبهم را فعال کنیم:
const results = useSearch( data, query, search({ fields: ["name"], matchType: "fuzzySearch", }) );
اگر تمایلی به ساخت همه چیز از صفر نداریم، جای نگرانی نیست. یک نسخه بهینه و آماده از هوک useSearch
تحت عنوان use-search-react در npm منتشر شده است. این پکیج نهتنها قابلیت جستجو را فراهم میکند، بلکه پشتیبانی داخلی از مرتبسازی، صفحهبندی، گروهبندی، و انواع الگوریتمهای جستجوی مبهم را نیز ارائه میدهد تا ما بتوانیم تمرکز خود را بر توسعه برنامه خود بگذاریم.
بهسادگی پکیج را با npm نصب میکنیم:
npm install use-search-react
استفاده از این هوک در کامپوننت ما بسیار ساده است. برای مثال، در ادامه کامپوننتی داریم که از این هوک برای انجام جستجوی مبهم بر روی یک آرایه داده استفاده میکند:
import { useSearch, search } from "use-search-react"; import { useState } from "react"; function SearchComponent() { const [query, setQuery] = useState(""); const data = [ { name: "JavaScript" }, { name: "Python" }, { name: "Java" } ]; // The 'search' function here is configured to perform a fuzzy search. const results = useSearch( data, query, search({ fields: ["name"], matchType: "fuzzy", }) ); return ( <div> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <ul> {results.map((item, index) => ( <li key={index}>{item.name}</li> ))} </ul> </div> ); }
این مثال نشان میدهد که استفاده از این هوک در کامپوننتهای React چقدر آسان است. این پکیج برای کار با دیتاستهای بسیار بزرگ (تا دهها هزار رکورد) نیز طراحی شده و همچنان عملکرد سریع و پاسخگو دارد.
ساخت یک سیستم جستجوی پیشرفته در React فراتر از صرفاً فیلتر کردن دادههاست. این کار به معنای خلق تجربهای شهودی و پاسخگو برای کاربران است.
در این مقاله آموختیم چگونه یک هوک سفارشی useSearch
بسازیم که چالشهای رایجی مانند عملکرد، دادههای تودرتو و حتی اشتباهات تایپی کاربران را با جستجوی مبهم مدیریت کند. همچنین با استفاده از Pagination، توانستیم دادههای بزرگ را بهصورت بهینه نمایش دهیم.
دیدگاهها: