در این درس میخوانید
این درس بیستم، جمعبندی عملی مجموعهٔ رایگان جاوااسکریپت درسآوند است. یک برنامهٔ مدیریت کار میسازیم که هم داده را نگه میدارد و هم به کاربر بازخورد روشن میدهد. برای اینکه اجرای اولیه ساده باشد، HTML، CSS و JavaScript در یک فایل کامل آمدهاند. پس از اجرای موفق میتوانید در تمرین توسعه، بخشها را مطابق درس ماژولها جدا کنید. برنامه برای کارهای غیرحساس و تمرین است و به حساب یا پرداخت وصل نیست.
چه چیزی میسازیم؟
- افزودن عنوان با فرم و Enter؛ فاصلهٔ ابتدا و انتها کنار گذاشته میشود.
- علامتزدن کار انجامشده و حذف عضو مشخص.
- فیلتر همه، انجامنشده و انجامشده؛ همراه آمار و حالت خالی.
- ذخیرهٔ نسخهدار در localStorage و بازیابی پس از تازهکردن صفحه.
- اعلام خطای ذخیره و جلوگیری از بازنویسی خودکار دادهٔ قبلیِ نامعتبر.
روش اجرا
یک پوشهٔ آزمایشی بسازید و تمام کد باکس بعدی را دقیقاً در index.html قرار دهید. وبسرور محلی درس دوازدهم را در همین پوشه اجرا و آدرس 127.0.0.1 را باز کنید. نمونه در مرورگر بهروز و محیط امنِ localhost از crypto.randomUUID استفاده میکند. اگر با file:// اجرا کنید، رفتار ذخیرهسازی میتواند وابسته به مرورگر باشد؛ برای نتیجهٔ قابل تکرار از وبسرور محلی استفاده کنید.
npx http-server . -a 127.0.0.1 -p 8080
# سپس در مرورگر: http://127.0.0.1:8080کد کامل پروژه — index.html
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>مدیریت کارهای من</title>
<style>
*{box-sizing:border-box}body{font-family:Tahoma,sans-serif;background:#f6f7f4;color:#173b32;margin:0;padding:24px}
main{max-width:720px;margin:auto;background:white;padding:24px;border-radius:16px}h1{font-size:1.7rem}
form,.tools,li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}input[type=text]{flex:1;min-width:180px}
input,select,button{font:inherit;padding:10px;border:1px solid #a9b6ae;border-radius:8px}button{cursor:pointer;background:#e3eee6}
ul{list-style:none;padding:0}li{padding:12px 0;border-bottom:1px solid #e0e6e1;justify-content:space-between}
li label{display:flex;gap:10px;align-items:center;overflow-wrap:anywhere;min-width:0;flex:1}li.done span{text-decoration:line-through}
:focus-visible{outline:3px solid #346cce;outline-offset:3px}#status{min-height:1.6em}small{line-height:1.8}
</style>
</head>
<body>
<main>
<h1>مدیریت کارهای من</h1>
<form id="task-form">
<label for="task-title">عنوان کار</label>
<input id="task-title" type="text" required maxlength="100" autocomplete="off">
<button type="submit">افزودن</button>
</form>
<div class="tools"><label for="filter">نمایش</label><select id="filter"><option value="all">همه</option><option value="open">انجامنشده</option><option value="done">انجامشده</option></select><p id="stats"></p></div>
<p id="status" role="status" aria-live="polite"></p>
<p id="empty">هنوز کاری در این نما وجود ندارد.</p>
<ul id="tasks"></ul>
<small>این نمونه برای کارهای غیرحساس است؛ ذخیرهٔ مرورگر، پشتیبان سرور نیست.</small>
</main>
<script>
const KEY = 'darsavand-demo.tasks.v1';
const form = document.querySelector('#task-form');
const input = document.querySelector('#task-title');
const filter = document.querySelector('#filter');
const list = document.querySelector('#tasks');
const status = document.querySelector('#status');
const stats = document.querySelector('#stats');
const empty = document.querySelector('#empty');
let tasks = [];
let canPersist = true;
function validTask(task) {
return task && typeof task === 'object' && typeof task.id === 'string' &&
task.id.length > 0 && task.id.length <= 100 && typeof task.title === 'string' &&
task.title.trim().length > 0 && task.title.length <= 100 && typeof task.done === 'boolean';
}
function restore() {
try {
const raw = localStorage.getItem(KEY);
if (raw === null) return;
const saved = JSON.parse(raw);
if (!saved || saved.version !== 1 || !Array.isArray(saved.tasks) ||
saved.tasks.length > 300 || !saved.tasks.every(validTask) ||
new Set(saved.tasks.map(task => task.id)).size !== saved.tasks.length) {
throw new Error('Invalid saved tasks');
}
tasks = saved.tasks.map(({id,title,done}) => ({id,title,done}));
} catch {
canPersist = false;
status.textContent = 'دادهٔ قبلی خوانده نشد؛ این نشست فقط در حافظه کار میکند و دادهٔ قبلی بازنویسی نمیشود.';
}
}
function render() {
const visible = tasks.filter(task => filter.value === 'done' ? task.done : filter.value === 'open' ? !task.done : true);
const fragment = document.createDocumentFragment();
for (const task of visible) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('done', task.done);
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.done;
checkbox.dataset.action = 'toggle';
checkbox.setAttribute('aria-label', 'تکمیل کار ' + task.title);
const text = document.createElement('span');
text.textContent = task.title;
label.append(checkbox, text);
const remove = document.createElement('button');
remove.type = 'button';
remove.dataset.action = 'remove';
remove.textContent = 'حذف';
remove.setAttribute('aria-label', 'حذف کار ' + task.title);
li.append(label, remove);
fragment.append(li);
}
list.replaceChildren(fragment);
empty.hidden = visible.length > 0;
stats.textContent = tasks.filter(task => task.done).length + ' انجامشده از ' + tasks.length;
}
function commit(next, message) {
tasks = next;
if (canPersist) {
try { localStorage.setItem(KEY, JSON.stringify({version:1, tasks})); }
catch { canPersist = false; }
}
render();
status.textContent = message + (canPersist ? '' : ' تغییرها فقط تا بازبودن این صفحه باقی میمانند.');
}
form.addEventListener('submit', event => {
event.preventDefault();
const title = input.value.trim();
if (!title || title.length > 100 || tasks.length >= 300) {
status.textContent = 'عنوان ۱ تا ۱۰۰ نویسه وارد کنید؛ سقف این نمونه ۳۰۰ کار است.';
input.focus();
return;
}
commit([...tasks, {id:crypto.randomUUID(), title, done:false}], 'کار اضافه شد.');
form.reset();
input.focus();
});
list.addEventListener('change', event => {
const target = event.target;
if (!(target instanceof Element) || !target.matches('input[data-action=toggle]')) return;
const id = target.closest('li')?.dataset.id;
if (!id) return;
commit(tasks.map(task => task.id === id ? {...task, done:target.checked} : task), 'وضعیت کار تغییر کرد.');
const replacement = Array.from(list.querySelectorAll('input')).find(element => element.closest('li')?.dataset.id === id);
(replacement || input).focus();
});
list.addEventListener('click', event => {
const button = event.target instanceof Element ? event.target.closest('button[data-action=remove]') : null;
if (!button || !list.contains(button)) return;
const id = button.closest('li')?.dataset.id;
if (!id) return;
commit(tasks.filter(task => task.id !== id), 'کار حذف شد.');
input.focus();
});
filter.addEventListener('change', render);
restore();
render();
</script>
</body>
</html>منطق برنامه را در چهار بخش بخوانید
tasks مدل داده است و DOM از روی آن ساخته میشود. restore دادهٔ قبلی را میخواند و نسخه، ساختار، سقف تعداد و یکتایی شناسهها را بررسی میکند. render فقط نمایش فیلترشده، آمار و حالت خالی را میسازد. commit تغییر مدل، تلاش برای ذخیره و پیام نتیجه را هماهنگ میکند. رویدادها بیرون render یکبار ثبت شدهاند؛ بنابراین رندر دوباره، listener تکراری نمیسازد. تغییر تکمیل با map و حذف با filter انجام میشود.
وقتی ذخیرهسازی شکست میخورد
اگر دادهٔ قبلی خراب یا ناسازگار باشد، برنامه وارد حالت حافظهای میشود و همان دادهٔ اصلی را خودکار بازنویسی نمیکند. اگر نوشتن localStorage شکست بخورد نیز پیام میگوید تغییرها با بستن صفحه باقی نخواهند ماند. این رفتار، موفقیت ظاهریِ ذخیره را از ذخیرهٔ واقعی جدا میکند. در محصول واقعی باید راه بازیابی یا خروجیگرفتن هم طراحی شود؛ در این نمونه، دادهٔ مهم و حساس نگه نمیداریم.
سناریوهای بازبینی
- یک عنوان با فاصلهٔ ابتدا و انتها اضافه کنید؛ فقط متن trimشده باید بماند.
- عنوان شامل <b> یا <img> باید متن دیده شود و عنصر HTML نسازد.
- دو کار اضافه کنید، یکی را تکمیل کنید و هر سه فیلتر را بررسی کنید.
- یک عضو را حذف کنید؛ عضو دیگر نباید عوض شود.
- صفحه را تازه کنید؛ دادهٔ ذخیرهشدهٔ معتبر باید برگردد.
- دادهٔ کلید نمونه را در محیط آزمایشی نامعتبر کنید؛ برنامه باید هشدار دهد و آن را بازنویسی نکند.
برای این نمونه، پنج آزمون DOM برای درج امن متن، تکمیل/فیلتر/حذف، بازیابی پس از بارگذاری دوباره، حفظ دادهٔ خراب و رد ورودی نامعتبر اجرا شده و موفق بودهاند. این آزمونها جای سنجش بصری روی همهٔ دستگاهها را نمیگیرند؛ مسیر فرم را با Tab و Enter نیز بررسی کنید. focus پس از عمل باید در جای قابل پیشبینی قرار بگیرد و عنوان بلند نباید صفحه را از عرض بیرون ببرد.
تمرین توسعه
نخست آمار را با Intl.NumberFormat فارسی کنید. سپس یک جستوجوی عنوان اضافه کنید و شرط آن را با فیلتر وضعیت ترکیب کنید. در مرحلهٔ بعد، تابعهای اعتبارسنجی و ذخیره را به ماژول جدا منتقل کنید و برای ورودیهای مرزی آزمون بنویسید. برای همگامسازی چند برگه یا چند دستگاه به طراحی بیشتری نیاز است؛ localStorage این نمونه، همگامسازی ابری ایجاد نمیکند. افزودن بکاند باید با هویت، مجوز، اعتبارسنجی و مدیریت همزمانی همراه باشد.
منابع انگلیسی برای مرور مفاهیم
- MDN — Document Object Model
- https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
- MDN — Using the Web Storage API
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
- MDN — EventTarget.addEventListener
- https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
کد پروژه و سناریوهای آزمون برای این مسیر آموزشی نوشته شدهاند. برای مرور منظم، به فهرست دورهٔ ۲۰ قسمتی برگردید و هر بخشی را که هنگام توسعه مبهم بوده دوباره تمرین کنید.
پیشنیاز و محیط اجرای مثالها
درس 20 از ۲۰؛ پیشنیاز: درسهای قبلی مجموعه. درس ۲۰ از ۲۰؛ پیشنیاز: درسهای ۱ تا ۱۹. کد کامل را در index.html ذخیره و از وبسرور محلی با مرورگر بهروز باز کنید؛ crypto.randomUUID به محیط امن مانند localhost نیاز دارد.
پیشرفت روی همین مرورگر ذخیره میشود.