۹. رابط پویا؛ رندر امن، واگذاری رویداد و پاکسازی
در دورهٔ مقدماتی با انتخاب عنصر و تغییر متن آشنا شدید. مسئلهٔ این درس، صفحهای است که دادههایش مرتب عوض میشوند: کاربر هزینه را حذف میکند، فیلتر میزند و دوباره به فهرست برمیگردد. باید بدانیم چه بخشی از صفحه بازسازی شود، رویداد هر دکمه چگونه به مدل برسد و هنگام بستن رابط چه چیزی پاک شود. هدف، ساخت یک جزء کوچک با چرخهٔ عمر مشخص است؛ لازم نیست برای یادگیری این قرارداد از چارچوب استفاده کنیم.
مثال اول؛ داده را به HTML تبدیل نکنید
مثال زیر مستقل است و در Console مرورگر اجرا میشود. یک فهرست میسازد و رشتهای شبیه برچسب را بهعنوان متن نشان میدهد. متن عنوان میتواند از فرم یا API آمده باشد. به همین دلیل آن را به innerHTML نمیدهیم. ساخت عنصر، متن و رفتار سه کار جدا هستند.
const demoList = document.createElement('ul');
document.body.append(demoList);
function renderRows(container, rows) {
const fragment = document.createDocumentFragment();
for (const item of rows) {
const row = document.createElement('li');
const title = document.createElement('span');
title.textContent = item.title;
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'حذف';
button.dataset.action = 'remove';
button.dataset.id = item.id;
row.append(title, ' ', button);
fragment.append(row);
}
container.replaceChildren(fragment);
}
renderRows(demoList, [{ id: 'a', title: '<b>خرید کتاب</b>' }]);خروجی، متن «<b>خرید کتاب</b>» و یک دکمه است؛ عنوان پررنگ نمیشود. fragment اجازه میدهد عناصر را پیش از اتصال به صفحه بسازیم، اما آن را وعدهٔ افزایش قطعی سرعت ندانید. در این اندازه، روشن بودن کد مهمتر است. replaceChildren هم کل فرزندان را عوض میکند؛ بنابراین انتخاب متن، فوکوس و وضعیت داخلی عناصر قبلی ممکن است از بین برود.
مثال دوم؛ یک شنونده برای فهرست متغیر
به جای ثبت رویداد روی تکتک دکمههای تازه، روی والد ثابت شنونده میگذاریم. رویداد کلیک از فرزند به والد میرسد. target جایی است که کلیک آغاز شده و currentTarget همان عنصری است که شنونده روی آن ثبت شده است. closest کمک میکند کلیک روی آیکن یا متن داخل دکمه هم به همان عمل برسد.
function mountExpenses(container, initial) {
let rows = [...initial];
const lifecycle = new AbortController();
const draw = () => renderRows(container, rows);
container.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-action="remove"]');
if (!button || !container.contains(button)) return;
const id = button.dataset.id;
if (!rows.some(item => item.id === id)) return;
rows = rows.filter(item => item.id !== id);
draw();
container.tabIndex = -1;
container.focus();
}, { signal: lifecycle.signal });
draw();
return () => {
lifecycle.abort();
container.replaceChildren();
};
}
const dispose = mountExpenses(demoList, [
{ id: 'a', title: 'کتاب' }, { id: 'b', title: 'رفتوآمد' }
]);
// پس از پایان آزمایش: dispose(); demoList.remove();با حذف یک ردیف، دکمههای ردیفهای باقیمانده همچنان کار میکنند؛ دوباره شنونده ثبت نکردهایم. تابع برگشتی مسئول پایان عمر جزء است. در صفحهای که فقط یک بار بارگذاری میشود، این مسئله کمتر دیده میشود؛ در رابطی که پنل بارها باز و بسته میشود، ثبت دوبارهٔ شنونده میتواند یک کلیک را چند بار اجرا کند. AbortController در این مثال برای عمر شنونده است و به شبکه ارتباطی ندارد.
اتصال به پروژهٔ دفتر هزینه
در فایل projects/expenses/app.mjs، render ردیفها را میسازد و شنوندهٔ ثابت list عمل ویرایش یا حذف را از dataset میخواند. مدل حذف در model.mjs است. dataset یک قرارداد رابط است؛ جای مجوز یا اعتبارسنجی داده را نمیگیرد. کاربر میتواند مقدار آن را در DevTools تغییر دهد، پس پیش از انجام عمل باید وجود شناسه در مدل بررسی شود.
فوکوس را هم جزئی از رفتار بدانید. وقتی دکمهٔ فعال را حذف میکنیم، کاربر صفحهکلید نباید نقطهٔ ادامه را گم کند. برای فهرست طولانی، انتقال فوکوس به دکمهٔ ردیف بعدی از تمرکز روی کل فهرست بهتر است؛ مثال بالا شکل سادهٔ این سیاست را نشان میدهد. بازسازی همهٔ ردیفها برای دادهٔ کوچک قابل فهم است؛ برای حجم بالا ابتدا اندازهگیری کنید، سپس بهروزرسانی انتخابی را بررسی کنید.
تمرین مستقل و پاسخ تشریحی
به هر ردیف دکمهٔ «کپی عنوان» اضافه کنید و همچنان فقط یک شنوندهٔ کلیک داشته باشید. ابتدا خروجی را در یک عنصر status نشان دهید؛ وابستگی به مجوز clipboard لازم نیست. عنوان را از مدل و بر اساس شناسه بخوانید، نه از HTML ردیف. پاسخ مناسب پس از یافتن button، action را بررسی میکند، دادهٔ همان شناسه را مییابد و با textContent نمایش میدهد. تغییر فیلتر نباید شنوندهٔ تازه بسازد.
معیار تحویل: پنج بار رندر کنید و ثابت کنید هر کلیک فقط یک عمل دارد؛ عنوان شبیه HTML اجرا نمیشود؛ پس از dispose هیچ عملی اجرا نمیشود؛ مسیر کامل با Tab و Enter قابل استفاده است. اگر این چهار شرط را نمیتوانید توضیح دهید، صرف نمایش درست فهرست برای اتمام درس کافی نیست.
منابع انگلیسی برای مطالعهٔ تکمیلی
https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Event_bubbling
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent
تألیف و مثالهای مستقل: تحریریه درسآوند.