EduFlow
أساسيات JavaScript
⁦0%⁩

الوحدة 5 · كتابة برامج حقيقية

بناء Pocket

22 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

تجمع Pocket في تطبيق متصفّح مكتمل له كائن state واحد، ومنطق نقي، ودالة render واحدة، وأحداث مفوَّضة، وتصفية، وبيانات محفوظة، وتفحصه وفق تعريف واضح لـ “مكتمل”.

  1. 1الشكل: state واحد، وطريقة واحدة لتغييره
  2. 2دالة render تشتقّ كل شيء
  3. 3معالجات الأحداث تترجم الأحداث إلى تحديثات
  4. 4تعريف “مكتمل”

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

لديك كل القطع: دوال مساعدة للمال، والتحقّق من المدخلات، والعرض، والأحداث، والنموذج، والتخزين، والوحدات، والأصناف. يجمعها هذا الدرس في Pocket المكتمل، والأهم أنه يُريك الشكل الذي يُمسك تطبيقًا صغيرًا متماسكًا فيبقى سهل التغيير.

الشكل: state واحد، وطريقة واحدة لتغييره

لكل تطبيق تفاعلي بيانات تتغيّر. في Pocket هي قائمة المصاريف وفلتر الفئة الذي اختاره المستخدم. ضعها كلها في كائن واحد، هو الـ state (الحالة):

src/main.js
const state = {
  expenses: loadExpenses(),
  filter: "all",
};

ثم اجعل دالة واحدة هي الطريقة الوحيدة لتغييره. إنها تطبّق التغيير، وتحفظ، وتعيد العرض:

src/main.js
function update(changes) {
  Object.assign(state, changes);
  saveExpenses(state.expenses);
  render();
}

Object.assign(state, changes) تنسخ خصائص changes إلى state، لذلك لا تغيّر update({ filter: "food" }) إلا الفلتر. وصار كل معالج حدث ينتهي بالطريقة نفسها: احسب القيم الجديدة، ثم استدعِ update. ولن تحتاج أبدًا إلى تذكّر الحفظ أو إعادة الرسم، لأن نسيانهما صار مستحيلًا.

حدث من المستخدم يذهب إلى معالج حدث، يحسب القيم الجديدة بدوال نقية ويستدعي update. تغيّر update الـ state، وتحفظه في التخزين، وتستدعي render، التي ترسم الصفحة التي يراها المستخدم، جاهزةً للحدث التالي. حدث المستخدم معالج الحدث يستخدم دوال نقية update() render() حفظ الصفحة
الحلقة التي يمرّ بها كل حدث

دالة render تشتقّ كل شيء

تقرأ render الـ state وترسم الواجهة كلها. وكل ما يمكن حسابه من الـ state يُحسب هنا، لا يُخزَّن: القائمة المرئية تعتمد على الفلتر، والمجموع يعتمد على القائمة المرئية.

src/ui.js
function render() {
  const visible =
    state.filter === "all"
      ? state.expenses
      : state.expenses.filter((e) => e.category === state.filter);

  list.replaceChildren();
  if (visible.length === 0) {
    const empty = document.createElement("li");
    empty.className = "empty";
    empty.textContent = "No expenses yet. Add your first one above.";
    list.append(empty);
  }
  for (const expense of visible) {
    list.append(renderItem(expense));
  }
  totalEl.textContent = `Total: ${formatMoney(totalCents(visible))}`;
}

function renderItem(expense) {
  const li = document.createElement("li");
  li.dataset.id = expense.id;
  const text = document.createElement("span");
  text.textContent = `${expense.label}: ${formatMoney(expense.amount)} (${expense.category})`;
  const button = document.createElement("button");
  button.type = "button";
  button.className = "delete";
  button.textContent = "Delete";
  button.setAttribute("aria-label", `Delete ${expense.label}`);
  li.append(text, button);
  return li;
}

لأن render تبدأ دائمًا من الـ state، لا يمكن للفلتر والقائمة والمجموع أن تتناقض أبدًا. وتبديل الفلتر إلى “food” لا يُخفي بعض عناصر <li> ويعيد حساب رقم؛ بل يرسم ببساطة صورة مختلفة للـ state نفسه.

تفصيلتان تستحقّان النسخ. الحالة الفارغة تحوّل القائمة الخالية إلى إرشاد. والـ aria-label على زر الحذف تخبر مستخدمي قارئات الشاشة أيّ مصروف يحذفه الزر، فعشرة أزرار كلها باسم “Delete” لا فائدة منها لمن لا يرى الصف.

معالجات الأحداث تترجم الأحداث إلى تحديثات

حين يكون الـ state وupdate وrender في مكانها، تصبح كل ميزة بضعة أسطر:

src/main.js
form.addEventListener("submit", (event) => {
  event.preventDefault();
  const result = readExpenseForm(form);          // { values } or { errors }
  if (result.errors) {
    showErrors(form, result.errors);
    return;
  }
  update({ expenses: addExpense(state.expenses, { id: nextId(), ...result.values }) });
  form.reset();
  form.elements.label.focus();
});

list.addEventListener("click", (event) => {
  const button = event.target.closest(".delete");
  if (!button) return;
  const id = button.closest("li").dataset.id;
  update({ expenses: state.expenses.filter((e) => e.id !== id) });
});

filterSelect.addEventListener("change", () => {
  update({ filter: filterSelect.value });
});

render();

readExpenseForm وshowErrors هما كود النموذج من القسم 4، وقد نُقل إلى دالتين صغيرتين لهما اسمان: الأولى تقرأ وتشذّب وتحوّل وتتحقّق، وتُرجع إمّا قيمًا نظيفة أو كائن أخطاء؛ والثانية تكتب الرسائل وتضبط aria-invalid. ومنح كل منهما اسمًا يحوّل معالج الإرسال إلى ملخّص تستطيع قراءته في خمس ثوانٍ.

لاحظ ما لا تحتويه معالجات الأحداث: لا بناء للـ DOM، ولا حفظ، ولا مجاميع. إنها تقرأ الحدث، وتستدعي دوال نقية مثل addExpense من القسم 3، وتسلّم النتيجة إلى update. القواعد تعيش في expenses.js، حيث تستطيع اختبارها دون متصفّح؛ أما معالجات الأحداث فهي الغراء الذي يربط الأجزاء.

تعريف “مكتمل”

“إضافة مصروف تعمل” هي نقطة بداية الاختبار، لا نهايته. قبل أن تقول إن Pocket اكتمل، مرّ على هذه القائمة يدويًا:

  • الفراغ: دون أي بيانات، تشرح الصفحة ما يجب فعله.
  • المدخلات السيئة: التسمية الفارغة، والمبلغ abc أو 0 أو -5، كل منها يعرض رسالة واضحة ولا يضيف شيئًا.
  • إعادة التحميل: كل ما أضفته ما زال موجودًا، والفلتر يبدأ على “All”.
  • لوحة المفاتيح وحدها: تستطيع الانتقال بـ Tab إلى كل حقل وزر، والإرسال بـ Enter، والحذف بـ Enter أو Space.
  • البيانات الغريبة: تسمية مثل <b>hi</b> تظهر نصًا، والتسمية الطويلة جدًا لا تكسر التخطيط.
  • الـ Console: لا أخطاء ولا تحذيرات أثناء قيامك بكل ما سبق.

كل بند هنا يأتي من درس في هذه الدورة، وكل منها يلتقط فئة حقيقية من الأخطاء كان المستخدمون سيكتشفونها نيابةً عنك.

التمرين أدناه يسلّمك Pocket شبه مكتمل، وما زال عليك ربط التصفية والحذف. وبعد ذلك، درس قصير أخير حول وجهتك التالية.

أهم النقاط

  • احتفظ بكل البيانات المتغيّرة في مكان واحد، وغيّرها عبر دالة واحدة تحفظ وتعيد العرض، فلا تختلف الصفحة والبيانات والتخزين أبدًا.
  • اشتقّ كل ما يمكنك اشتقاقه، مثل القائمة المرئية والمجاميع، من الـ state وقت العرض بدلًا من تخزينه منفصلًا.
  • الدوال النقية تحمل القواعد؛ ومعالجات الأحداث تبقى قصيرة ولا تفعل إلا ترجمة الأحداث إلى تغييرات في الـ state.
  • يكتمل التطبيق حين يتعامل مع الحالة الفارغة، والمدخلات السيئة، وإعادة التحميل، والاستخدام بلوحة المفاتيح وحدها، لا مع المسار السعيد فقط.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

يعرض Pocket المصاريف مصفّاةً على “food” مع مجموع في الأسفل. من أين يجب أن تأتي القائمة المصفّاة؟

السؤال 2 من 3

كل معالج حدث في Pocket ينتهي بالخطوات الثلاث نفسها. أيّ ترتيب صحيح؟

السؤال 3 من 3

أيّ مما يلي جزء من تعريف معقول لـ “مكتمل” في Pocket، يتجاوز “إضافة مصروف تعمل”؟

تدريب

بيئة تجريب الكود

أكمل Pocket

Pocket على وشك الاكتمال: الـ state وupdate وrender والتخزين ونموذج الإضافة كلها تعمل. اربط القطع الثلاث الأخيرة:

  1. visibleExpenses() تُرجع فقط المصاريف التي تطابق category فيها قيمة state.filter، أو كلها حين يكون الفلتر "all". وrender تستخدمها أصلًا للقائمة وللمجموع.
  2. حين تتغيّر قائمة الاختيار #filter، حدّث الفلتر.
  3. أضف مستمع نقرات مفوَّضًا واحدًا على #expense-list يحذف المصروف الذي نُقر زر الحذف الخاص به.

لا تغيّر البيانات إلا عبر update(...)، كي يُحفظ كل تغيير ويُعاد عرضه.

<h1>Pocket</h1>
<form id="expense-form" novalidate>
  <label for="label">What</label>
  <input id="label" name="label">
  <label for="amount">Amount ($)</label>
  <input id="amount" name="amount" type="number" step="0.01">
  <label for="category">Category</label>
  <select id="category" name="category">
    <option value="food">Food</option>
    <option value="travel">Travel</option>
    <option value="fun">Fun</option>
  </select>
  <p id="form-error" role="alert"></p>
  <button type="submit">Add expense</button>
</form>

<label for="filter">Show</label>
<select id="filter">
  <option value="all">All</option>
  <option value="food">Food</option>
  <option value="travel">Travel</option>
  <option value="fun">Fun</option>
</select>

<ul id="expense-list"></ul>
<p id="total"></p>
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

المعاينة

الـ console

(لا مخرجات)

الفحوص

  • يعرض المصاريف الثلاثة المحفوظة ومجموعها
  • اختيار Food يعرض مصاريف الطعام فقط ومجموعها
  • الحذف يُزيل المصروف من الصفحة ومن التخزين
  • الفلتر الذي لا يطابق شيئًا يعرض رسالة الفراغ و$0.00
  • إضافة مصروف والفلتر على All تعرضه وتحفظه بمعرّف جديد