۱۵. کاتالوگ کاربردی؛ جستوجوی فارسی، صفحهبندی و Debounce
کاتالوگ فقط چند کارت محصول نیست. کاربر باید بفهمد فیلتر چه اثری گذاشته، چند نتیجه وجود دارد و چرا صفحهٔ فعلی خالی شده است. در این درس، جستوجو و صفحهبندی را روی دادهٔ محلی طراحی میکنیم. رابط همراه پروژهٔ فروشگاه اکنون جستوجوی فارسی، دسته، مرتبسازی، صفحهبندی چهارتایی و تأخیر ۲۵۰ میلیثانیهای جستوجو دارد. ابتدا نسخهٔ کوچک الگوریتم را مستقل میسازیم، سپس آن را با پیادهسازی فایلهای همراه مقایسه میکنیم.
قرارداد جستوجو را روشن کنید
حرف «ي» و «ی» در دادهٔ فارسی میتواند نتیجهٔ متفاوت بدهد. برای جستوجو میتوان شکلهای مشخصی را یکسان کرد، ولی متن اصلی نام محصول را تغییر نمیدهیم. این قرارداد باید محدود و مستند باشد؛ حذف بیحساب نویسهها ممکن است دو واژهٔ متفاوت را یکی کند. نمونهٔ زیر مستقل است و در Node یا Console مرورگر اجرا میشود. قیمتها تومان و دادهها ساختگیاند.
مثال اول؛ فیلتر، مرتبسازی، سپس صفحهبندی
const productsDemo = [
{ id: 'a', name: 'کیبورد', price: 700000 },
{ id: 'b', name: 'كيبورد کوچک', price: 500000 },
{ id: 'c', name: 'ماوس', price: 300000 },
{ id: 'd', name: 'کیبورد بیسیم', price: 900000 }
];
function normalizeSearch(value) {
return String(value).trim().toLocaleLowerCase('fa')
.replaceAll('ي', 'ی').replaceAll('ك', 'ک');
}
function catalogPage(products, { query = '', page = 1, size = 2 } = {}) {
if (!Number.isInteger(size) || size < 1 || size > 100) throw Error('size');
if (!Number.isInteger(page) || page < 1) throw Error('page');
const needle = normalizeSearch(query);
const filtered = products.filter(p => normalizeSearch(p.name).includes(needle))
.sort((a, b) => a.price - b.price || a.id.localeCompare(b.id));
const pages = Math.max(1, Math.ceil(filtered.length / size));
const current = Math.min(page, pages);
return {
items: filtered.slice((current - 1) * size, current * size),
total: filtered.length, page: current, pages
};
}
console.log(catalogPage(productsDemo, { query: 'کیبورد', page: 2 }));
// items: [محصول d]، total:3، page:2، pages:2ترتیب عملیات مهم است. اگر اول slice کنیم، جستوجو فقط در همان تکه انجام میشود و محصولات مرتبط صفحات دیگر گم میشوند. اگر مرتبسازی بعد از صفحهبندی باشد، هر صفحه جدا مرتب میشود. شکستن تساوی قیمت با شناسه هم ترتیب قابل تکرار میسازد. filter آرایهٔ تازه میسازد، بنابراین sort در این مثال آرایهٔ products را تغییر نمیدهد.
وقتی فیلتر عوض شد، معمولاً page را یک میکنیم. تابع علاوه بر آن، صفحهٔ خارج از بازه را به آخرین صفحهٔ موجود محدود میکند. برای نتیجهٔ خالی، این قرارداد page=1 و pages=1 دارد و total=0 تعیینکنندهٔ پیام خالی است. قرارداد دیگری هم ممکن است، ولی رابط و مدل باید یک معنا داشته باشند.
مثال دوم؛ جستوجوی آرامتر با امکان لغو
برای دادهٔ کوچک، جستوجوی فوری کاملاً مناسب است. debounce زمانی مفید است که اجرای هر جستوجو هزینه دارد یا درخواست شبکه میسازد. این الگو نتیجهٔ قدیمی شبکه را کنترل نمیکند؛ برای آن همچنان revision درس قبل لازم است. نمونهٔ زیر در مرورگر یا Node قابل اجراست و بعد از سه فراخوانی سریع، فقط «کتاب» را چاپ میکند.
function debounce(fn, delay = 250) {
let timer;
function delayed(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}
delayed.cancel = () => clearTimeout(timer);
return delayed;
}
const searchLater = debounce(query => console.log('جستوجو:', query), 100);
searchLater('ک');
searchLater('کت');
searchLater('کتاب');
// خروجی پس از سپری شدن تأخیر: جستوجو: کتابدر اتصال به input، مقدار رشتهای را همان لحظه بخوانید و به تابع بدهید؛ وابسته ماندن به یک عنصر یا شیء رویداد برای آینده، کد را دشوارتر میکند. هنگام بستن جزء، cancel را صدا بزنید تا callback دیرهنگام روی رابط بستهشده اجرا نشود. submit فرم باید جستوجوی نهایی را فوراً انجام دهد و تایمر معلق را لغو کند؛ کاربر با Enter نباید بیدلیل منتظر بماند.
اتصال به پروژه و مرز مقیاس
searchProducts در model.mjs آماده، query، category و sort را میگیرد و روی کاتالوگ محدود محلی کار میکند. تابع paginate در همان فایل، rows و گزینههای page و pageSize را میگیرد و items، page، pageCount و total برمیگرداند؛ دقت کنید نام pageCount در نسخهٔ همراه با pages در مثال ساده فرق دارد. تابع searchKey نیز علاوه بر نگاشت حروف، اعراب را حذف و نیمفاصله را برای جستوجو به فاصله تبدیل میکند. debounce کامل در projects/shared/timing.mjs متدهای cancel و flush دارد؛ flush کار معلق را فوراً اجرا میکند. کنترلهای قبلی و بعدی در app.mjs از همین مدل استفاده میکنند. شمارش نتیجه را در یک متن قابل خواندن نمایش دهید و صفحهٔ فعال را واضح کنید. صرف تغییر رنگ دکمه برای فهمیدن وضعیت کافی نیست.
این صفحهبندی، همهٔ داده را قبلاً دانلود کرده است. برای دهها هزار محصول، دریافت صفحه از سرور، کش، فیلتر سمت سرور و سیاست تازگی داده موضوع جداست. debounce هم جای محدودیت درخواست سرور یا کنترل سوءاستفاده را نمیگیرد.
تمرین مستقل و پاسخ تشریحی
جستوجو و صفحه را در URLSearchParams نگه دارید تا لینک قابل تکرار بسازید. ورودی page از URL رشته است؛ آن را به عدد تبدیل و سپس integer و مثبت بودن را بررسی کنید. پاسخ مناسب برای مقدار خراب به صفحهٔ یک برمیگردد، query را با URLSearchParams کدگذاری میکند و پس از تغییر جستوجو page را یک قرار میدهد. رشتهها را دستی به URL نچسبانید.
معیار تحویل: حرف عربی و فارسی نمونه نتیجهٔ یکسان بدهد؛ هیچ ردیفی بین صفحات تکرار نشود؛ فیلتر، ترتیب و تعداد نتیجه سازگار باشند؛ Enter جستوجو را فوری اجرا کند و هنگام ترک جزء تایمر باقی نماند.
منابع انگلیسی برای مطالعهٔ تکمیلی
https://developer.mozilla.org/en-US/docs/Glossary/Debounce
https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice
تألیف و مثالهای مستقل: تحریریه درسآوند.