اذهب إلى المحتوى

البحث في الموقع

المحتوى عن 'جافاسكربت متقدمة'.

  • ابحث بالكلمات المفتاحية

    أضف وسومًا وافصل بينها بفواصل ","
  • ابحث باسم الكاتب

نوع المحتوى


التصنيفات

  • الإدارة والقيادة
  • التخطيط وسير العمل
  • التمويل
  • فريق العمل
  • دراسة حالات
  • التعامل مع العملاء
  • التعهيد الخارجي
  • السلوك التنظيمي في المؤسسات
  • عالم الأعمال
  • التجارة والتجارة الإلكترونية
  • نصائح وإرشادات
  • مقالات ريادة أعمال عامة

التصنيفات

  • مقالات برمجة عامة
  • مقالات برمجة متقدمة
  • PHP
    • Laravel
    • ووردبريس
  • جافاسكربت
    • لغة TypeScript
    • Node.js
    • React
    • Vue.js
    • Angular
    • jQuery
    • Cordova
  • HTML
  • CSS
    • Sass
    • إطار عمل Bootstrap
  • SQL
  • لغة C#‎
    • ‎.NET
    • منصة Xamarin
  • لغة C++‎
  • لغة C
  • بايثون
    • Flask
    • Django
  • لغة روبي
    • إطار العمل Ruby on Rails
  • لغة Go
  • لغة جافا
  • لغة Kotlin
  • لغة Rust
  • برمجة أندرويد
  • لغة R
  • الذكاء الاصطناعي
  • صناعة الألعاب
  • سير العمل
    • Git
  • الأنظمة والأنظمة المدمجة

التصنيفات

  • تصميم تجربة المستخدم UX
  • تصميم واجهة المستخدم UI
  • الرسوميات
    • إنكسكيب
    • أدوبي إليستريتور
  • التصميم الجرافيكي
    • أدوبي فوتوشوب
    • أدوبي إن ديزاين
    • جيمب GIMP
    • كريتا Krita
  • التصميم ثلاثي الأبعاد
    • 3Ds Max
    • Blender
  • نصائح وإرشادات
  • مقالات تصميم عامة

التصنيفات

  • مقالات DevOps عامة
  • خوادم
    • الويب HTTP
    • البريد الإلكتروني
    • قواعد البيانات
    • DNS
    • Samba
  • الحوسبة السحابية
    • Docker
  • إدارة الإعدادات والنشر
    • Chef
    • Puppet
    • Ansible
  • لينكس
    • ريدهات (Red Hat)
  • خواديم ويندوز
  • FreeBSD
  • حماية
    • الجدران النارية
    • VPN
    • SSH
  • شبكات
    • سيسكو (Cisco)

التصنيفات

  • التسويق بالأداء
    • أدوات تحليل الزوار
  • تهيئة محركات البحث SEO
  • الشبكات الاجتماعية
  • التسويق بالبريد الالكتروني
  • التسويق الضمني
  • استسراع النمو
  • المبيعات
  • تجارب ونصائح
  • مبادئ علم التسويق

التصنيفات

  • مقالات عمل حر عامة
  • إدارة مالية
  • الإنتاجية
  • تجارب
  • مشاريع جانبية
  • التعامل مع العملاء
  • الحفاظ على الصحة
  • التسويق الذاتي
  • العمل الحر المهني
    • العمل بالترجمة
    • العمل كمساعد افتراضي
    • العمل بكتابة المحتوى

التصنيفات

  • الإنتاجية وسير العمل
    • مايكروسوفت أوفيس
    • ليبر أوفيس
    • جوجل درايف
    • شيربوينت
    • Evernote
    • Trello
  • تطبيقات الويب
    • ووردبريس
    • ماجنتو
    • بريستاشوب
    • أوبن كارت
    • دروبال
  • الترجمة بمساعدة الحاسوب
    • omegaT
    • memoQ
    • Trados
    • Memsource
  • برامج تخطيط موارد المؤسسات ERP
    • تطبيقات أودو odoo
  • أنظمة تشغيل الحواسيب والهواتف
    • ويندوز
    • لينكس
  • مقالات عامة

التصنيفات

  • آخر التحديثات

أسئلة وأجوبة

  • الأقسام
    • أسئلة البرمجة
    • أسئلة ريادة الأعمال
    • أسئلة العمل الحر
    • أسئلة التسويق والمبيعات
    • أسئلة التصميم
    • أسئلة DevOps
    • أسئلة البرامج والتطبيقات

التصنيفات

  • كتب ريادة الأعمال
  • كتب العمل الحر
  • كتب تسويق ومبيعات
  • كتب برمجة
  • كتب تصميم
  • كتب DevOps

ابحث في

ابحث عن


تاريخ الإنشاء

  • بداية

    نهاية


آخر تحديث

  • بداية

    نهاية


رشح النتائج حسب

تاريخ الانضمام

  • بداية

    نهاية


المجموعة


النبذة الشخصية

  1. يمكن استخدام لغة الأنماط الانسيابية CSS في إنشاء رسوم متحركة بسيطة، دون الحاجة إلى شيفرة JavaScript إطلاقًا، لكن يمكننا بالطبع استخدام JavaScript للتحكم برسوم CSS المتحركة وجعلها أفضل بكتابة القليل من الشيفرة. الحركة الانتقالية في CSS إنّ فكرة الحركة الانتقالية transition في CSS بسيطة جدًا، حيث نصف خاصيةً محددةً وآليةً لإظهار التغيرات فيها على شكل رسوم متحركة، فعندما تتغير قيمة الخاصية سيُظهر المتصفح هذا التغيير في حركة، وبالتالي كل ما علينا فعله هو تغيير الخاصية، وسينفذ المتصفح دفقًا من التغيرات المتتالية فيها. تُحرّك شيفرة CSS التغييرات في الخاصية background-color لمدة ثلاث ثوان: .animated { transition-property: background-color; transition-duration: 3s; } فلو كان لأي عنصر الصنف CSS الذي سميناه animated، فستظهر أي تغيرات على الخاصية background-color في هذا العنصر في حركة لمدة 3 ثوان. انقر على الزر الذي ستُظهره الشيفرة التالية لتحريك الخلفية: <button id="color">Click me</button> <style> #color { transition-property: background-color; transition-duration: 3s; } </style> <script> color.onclick = function() { this.style.backgroundColor = 'red'; }; </script> See the Pen JS-P3-03-Fetch-Download-progress-ex8 by Hsoub (@Hsoub) on CodePen. توصف الحركة الانتقالية في CSS بأربعة خصائص، هي: transition-property. transition-duration. transition-timing-function. transition-delay. سنشرح هذه الخصائص بعد قليل، لكن دعونا نلاحظ أنّ الخاصية transition ستسمح بالتصريح عن الخصائص الأربعة السابقة معًا وفق الترتيب التالي:property duration timing-function delay، بالإضافة إلى قدرتها على إظهار الحركة الانتقالية على عدة خصائص معًا. يُظهر النقر على الزر في الشيفرة التالية الحركة الانتقالية للخاصيتين color وfont-size: <button id="growing">Click me</button> <style> #growing { transition: font-size 3s, color 2s; } </style> <script> growing.onclick = function() { this.style.fontSize = '36px'; this.style.color = 'red'; }; </script> See the Pen JS-P3-05-CSS-animations-ex2 by Hsoub (@Hsoub) on CodePen. سنشرح الآن خصائص الحركة الانتقالية. الخاصية transition-property نكتب ضمن هذه الخاصية قائمةً بخصائص CSS التي نريد إظهار تغييراتها على شكل حركة انتقالية، مثل left وmargin-left وheight وcolor وغيرها، أو يمكن أن نختار all التي تعني تحريك كل الخصائص . توجد خصائص لا يمكن تحريكها، لكن معظم الخصائص شائعة الاستعمال وقابلة للتحريك. الخاصية transition-duration يمكن أن نحدد في هذه الخاصية المدة التي ستجري فيها الحركة الانتقالية، وينبغي أن يكون التوقيت بتنسيق CSS ومقدرًا بالثانية s أو بالميلي ثانية ms. الخاصية transition-delay نحدد في هذه الخاصية فترة الانتظار قبل عرض الحركة الانتقالية، فلو كانت قيمتها ثانيةً واحدةً وقيمة الخاصية transition-duration ثانيتن، فستُعرض الحركة الانتقالية بعد ثانية من تغيّر الخاصيّة، وستستمر لمدة ثانيتين، كما يمكن استخدام قيم سالبة أيضًا، وعندها ستبدأ الحركة مباشرةً، إلا أنّ نقطة البداية ستنسحب قليلًا، فلو كانت قيمة الخاصية transition-delay هي "-1" ثانية وقيمة الخاصية transition-duration هي "2" ثانية، فستبدأ الحركة من المنتصف، ولمدة ثانية واحدة. لاحظ تغير الأعداد من 0 إلى 9 في الرسم المتحرك التالي باستخدام الخاصية translate في CSS: شيفرة الملف script.js: stripe.onclick = function() { stripe.classList.add('animate'); }; شيفرة الملف style.css: #digit { width: .5em; overflow: hidden; font: 32px monospace; cursor: pointer; } #stripe { display: inline-block } #stripe.animate { transform: translate(-90%); transition-property: transform; transition-duration: 9s; transition-timing-function: linear; } شيفرة الملف index.html: <!doctype html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> </head> <body> Click below to animate: <div id="digit"><div id="stripe">0123456789</div></div> <script src="script.js"></script> </body> </html> وستكون النتيجة: تجري الحركة الانتقالية للخاصية transform بالشكل التالي: #stripe.animate { transform: translate(-90%); transition-property: transform; transition-duration: 9s; } تضيف شيفرة JavaScript السابقة الصنف animate إلى العنصر، وتبدأ الحركة عند تنفيذ الأمر: stripe.classList.add('animate'); يمكن أن نبدأ التحريك انطلاقًا من نقطة ما من الحركة الانتقالية، أي من رقم محدد -وقد يكون متعلقًا بالثانية الحالية مثلًا- وباستخدام قيمة سالبة للخاصية transition-delay. لو نقرت على الرقم في المثال التالي، فستبدأ الحركة من الثانية الحالية: شيفرة الملف script.js: stripe.onclick = function() { let sec = new Date().getSeconds() % 10; stripe.style.transitionDelay = '-' + sec + 's'; stripe.classList.add('animate'); }; شيفرة الملف style.css: #digit { width: .5em; overflow: hidden; font: 32px monospace; cursor: pointer; } #stripe { display: inline-block } #stripe.animate { transform: translate(-90%); transition-property: transform; transition-duration: 9s; transition-timing-function: linear; } شيفرة الملف index.html: <!doctype html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> </head> <body> Click below to animate: <div id="digit"><div id="stripe">0123456789</div></div> <script src="script.js"></script> </body> </html> وستكون النتيجة: يمكن تنفيذ ذلك باستخدام JavaScript لكن بإضافة سطر آخر من الشيفرة: stripe.onclick = function() { let sec = new Date().getSeconds() % 10; // مثلًا تعني القيمة -3 هنا أن الحركة ستبدأ من الثانية الثالثة stripe.style.transitionDelay = '-' + sec + 's'; stripe.classList.add('animate'); }; الخاصية transition-timing-function تصف دالة التوقيت هذه كيفية توزيع الحركة الانتقالية أثناء فترة العرض، كأن تبدأ ببطء ثم تسرع، أو العكس. ستبدو هذه الخاصية في البداية معقدة، لكنها ستغدو بسيطةً إذا فهمناها، كما ستقبل نوعين من القيم هما منحني بيزيه Bezier curve، أو الدالة steps، لنبدأ مع المنحني كونه الأكثر استخدامًا. منحني بيزيه يمكن إعداد منحنى بيزيه بأربعة نقاط حاكمة تحقق الشروط التالية: إحداثيات النقطة الأولى (0,0). إحداثيات النقطة الأخيرة (1,1). ينبغي أن يكون الإحداثي x لنقطتي المنتصف بين 0 و1، بينما يمكن أن نختار أي قيمة للإحداثي y. إن صيغة منحني بيزيه هي: (cubic-bezier(x2, y2, x3, y3، إذًا علينا فقط أن نحدد النقطتين الحاكمتين الثانية والثالثة، لأنّ الأولى والأخيرة ثابتتان. تحدد دالة التوقيت سرعة تحريك عملية تشكيل المنحني: يمثل المحور x محور الزمن الذي يبدأ بالنقطة "0"، وينتهي بالنقطة "1" لقيمة transition-duration. يمثل المحور y مقدار اكتمال العملية، ويبدأ بالنقطة "0" وينتهي بالنقطة "1" لقيمة الخاصية التي نريد إظهار الحركة الانتقالية لها. المثال الأبسط هو التحريك المنتظم، أي الخطي، وذلك باستخدام المنحني (cubic-bezier(0, 0, 1, 1، الذي سيبدو بالشكل التالي: عندما يمر الوقت (x) ستكتمل عملية التحريك (y) بثبات وتناغم من القيمة 0 إلى 1. وفي مثالنا التالي ستتحرك صورة القطار من اليسار إلى اليمين بسرعة ثابتة، إذا نقرت عليه: شيفرة الملف style.css: .train { position: relative; cursor: pointer; width: 177px; height: 160px; left: 0; transition: left 5s cubic-bezier(0, 0, 1, 1); } شيفرة الملف index.html: <!doctype html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> </head> <body> <img class="train" src="https://js.cx/clipart/train.gif" onclick="this.style.left='450px'"> </body> </html> وستكون النتيجة: إليك شيفرة استخدام الخاصية transition بناءً على منحني بيزيه: .train { left: 0; transition: left 5s cubic-bezier(0, 0, 1, 1); /* JavaScript sets left to 450px */ } لكن كيف سنرى القطار وهو يُبطئ؟ يمكن استعمال منحني بيزيه آخر من الشكل (cubic-bezier(0.0, 0.5, 0.5 ,1.0، وفي المقال التالي سنرى كيفية بدء العملية بسرعة، حيث يتحرك القطار سريعًا ثم يتباطأ. شيفرة الملف style.css: .train { position: relative; cursor: pointer; width: 177px; height: 160px; left: 0px; transition: left 5s cubic-bezier(0.0, 0.5, 0.5, 1.0); } شيفرة الملف index.html: <!doctype html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> </head> <body> <img class="train" src="https://js.cx/clipart/train.gif" onclick="this.style.left='450px'"> </body> </html> وستكون النتيجة: شيفرة CSS: .train { left: 0; transition: left 5s cubic-bezier(0, .5, .5, 1); /* JavaScript sets left to 450px */ } يمكنك استخدام العديد من المنحنيات الجاهزة، مثل linear أو ease أو ease-in أو ease-out أو ease-in-out، وإليك جدولًا بالمنحنيات الموافقة لها: وفي ملاحظة هامة، تجدر الإشارة إلى أن * تستخدَم ease قيمةً افتراضيةً إذا لم نحدد دالة توقيت timing-function. وكما هو واضح يمكن استعمال ease-out في حالة القطار الذي يُبطئ حركته: .train { left: 0; transition: left 5s ease-out; /* transition: left 5s cubic-bezier(0, .5, .5, 1); */ } لكنه يبدو مختلفًا نوعًا ما. قد تتجاوز الحركة الانتقالية المدى عند استخدام منحني بيزيه. ويمكن أن تحمل النقاط الحاكمة في منحني بيزيه أي قيمة للإحداثي y حتى القيم السالبة أو الضخمة، وبهذا قد يمتد المنحني على مساحة ضيقة جدًا أو واسعة جدًا، وقد تتجاوز الرسوم المتحركة مداها الطبيعي. لاحظ الشيفرة التالية: .train { left: 100px; transition: left 5s cubic-bezier(.5, -1, .5, 2); /* JavaScript sets left to 400px */ } ينبغي إظهار حركة انتقالية للخاصية left بين القيمتين 100px و400px، لكنك بالنقر على صورة القطار سترى ما يلي: أولًا سيتراجع القطار إلى الخلف، وستغدو قيمة left أقل من 100px. يتقدم بعدها القطار إلى الأمام لتتجاوز left القيمة 400px بقليل. ثم يعود مجددًا إلى القيمة 400px للخاصية left. شيفرة الملف style.css: .train { position: relative; cursor: pointer; width: 177px; height: 160px; left: 100px; transition: left 5s cubic-bezier(.5, -1, .5, 2); } شيفرة الملف index.html: <!doctype html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="style.css"> </head> <body> <img class="train" src="https://js.cx/clipart/train.gif" onclick="this.style.left='400px'"> </body> </html> وستكون النتيجة على النحو الآتي: ما جرى واضح جدًا إذا نظرنا إلى رسم منحني بيزيه لهذه الحركة، فقد نقلنا قيمة الإحداثي y للنقطة الثانية تحت الصفر، وجعلنا قيمته بالنسبة للنقطة الثالثة تتجاوز 1، لذا سيخرج شكل المنحني عن الشكل النظامي للمنحني من الدرجة الرابعة، فقيمة y خارج المجال المعياري بين 0 و1. وكما نعرف، تقيس y مدى اكتمال الحركة الانتقالية، حيث تتطابق القيمة y = 0 مع بداية الحركة، والقيمة y = 1 مع نهايتها. إذًا ستنقل القيمة y<0 نقطة البدء إلى ما قبل البداية، والقيمة y>1 إلى ما بعدها. يُعَد هذا التغيير بسيطًا طبعًا، فلو ضبطنا y على القيمة 99 أو -99، فسيقفز القطار بسرعة أكبر بكثير خارج المجال، لكن كيف سننشئ منحني بيزيه من أجل مهمة معينة؟ هنالك أدوات كثيرة، كما يمكن تنفيذه ضمن مواقع عديدة على الإنترنت. دالة التوقيت steps تسمح دالة التوقيت ([steps(number of steps[, start/end بتقسيم الحركة الانتقالية إلى خطوات، لنرى ذلك من خلال مثال قائمة من الأرقام التي لا تبدي أي حركة: شيفرة الملف style.css: #digit { border: 1px solid red; width: 1.2em; } #stripe { display: inline-block; font: 32px monospace; } شيفرة الملف index.html: <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> </head> <body> <div id="digit"><div id="stripe">0123456789</div></div> </body> </html> وستكون النتيجة: سنُظهر الأرقام منفصلة، وذلك بجعلها غير مرئية خارج النافذة الحمراء، بعدها سنحرك القائمة نحو اليسار في كل خطوة، وبالتالي سنحتاج إلى 9 خطوات لتنفيذ الأمر: #stripe.animate { transform: translate(-90%); transition: transform 9s steps(9, start); } شيفرة الملف style.css: #digit { width: .5em; overflow: hidden; font: 32px monospace; cursor: pointer; } #stripe { display: inline-block } #stripe.animate { transform: translate(-90%); transition-property: transform; transition-duration: 9s; transition-timing-function: steps(9, start); } شيفرة الملف index.html: <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> </head> <body> Click below to animate: <div id="digit"><div id="stripe">0123456789</div></div> <script> digit.onclick = function() { stripe.classList.add('animate'); } </script> </body> </html> وستكون النتيجة: يُمثّل الوسيط الأول للدالة (steps(9, start عدد الخطوات، أي ستقسم الحركة الانتقالية إلى 9 أجزاء -10% لكل خطوة-، كما سيُقسم الفاصل الزمني تلقائيًا إلى 9 أجزاء أيضًا، فلو كانت قيمة الخاصية transition تعادل 9 ثوان، فسيظهر كل رقم لمدة ثانية؛ أما الوسيط الثاني فيأخذ إحدى القيمتين start أو end، حيث تعني القيمة start أننا نريد تنفيذ الخطوة الأولى مباشرةً عند بداية الحركة، ويمكنك ملاحظة ذلك خلال الحركة، فعندما ننقر على الرقم سيتغير إلى "1" -أي الخطوة الأولى- مباشرةً، ثم يكمل ثانيةً وهكذا. تتقدم العملية كالتالي: -10% - 0s: يحدث التغيير الأول في بداية الثانية الأولى مباشرةً. 1s – -20%. … 8s – -80%. تُظهر الثانية الأخيرة القيمة الأخيرة. بينما تعني القيمة end وجوب تطبيق التغيير في نهاية كل ثانية، ويكتمل في نهاية الثانية المعدودة. وتتقدم العملية كالتالي: 0s – 0: لا يتغير شيء في الثانية الأولى. 1s – -10%: يحدث التغير الأول عند نهاية الثانية الأولى مباشرةً. 2s – -20%. … 9s – -90%. إليك مثالًا نموذجيًا عن استخدام الدالة (steps(9, start. شيفرة الملف style.css: #digit { width: .5em; overflow: hidden; font: 32px monospace; cursor: pointer; } #stripe { display: inline-block } #stripe.animate { transform: translate(-90%); transition-property: transform; transition-duration: 9s; transition-timing-function: steps(9, end); } شيفرة الملف index.html: <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> </head> <body> Click below to animate: <div id="digit"><div id="stripe">0123456789</div></div> <script> digit.onclick = function() { stripe.classList.add('animate'); } </script> </body> </html> وستكون النتيجة: إليك بعض القيم المختصرة: step-start: تماثل (steps(1, start. step-end: تماثل (steps(1, end. لا تستخدم هذه القيم إلا نادرًا لأنها لا تُصنَّف حركات فعلًا، بل تغيرات تحدث بالخطوة. الحدث transitionend يقع الحدث transitionend عندما تنتهي الحركة المبنية على خصائص CSS، ويستخدم لتنفيذ عمل ما بعد انتهاء الحركة، حيث يمكننا مثلًا ضم حركات مختلفة، فالسفينة في المثال التالي ستبدأ رحلتها وتستمر بالعودة عند النقر، وفي كل مرة تبتعد نحو اليمين أكثر: تبدأ الحركة نتيجة تنفيذ الدالة go، التي يُعاد تنفيذها في كل مرة تنتهي فيها الحركة، وتغير اتجاه الحركة: boat.onclick = function() { //... let times = 1; function go() { if (times % 2) { // sail to the right boat.classList.remove('back'); boat.style.marginLeft = 100 * times + 200 + 'px'; } else { // sail to the left boat.classList.add('back'); boat.style.marginLeft = 100 * times - 200 + 'px'; } } go(); boat.addEventListener('transitionend', function() { times++; go(); }); }; لكائن الحدث transitionend عدة خصائص مميزة، هي: event.propertyName: يعيد اسم الخاصية التي أنهت حركتها الانتقالية، وتظهر فائدتها عند تحريك عدة خصائص معًا. event.elapsedTime: الوقت الذي استغرقته الحركة بالثواني، دون احتساب transition-delay. القاعدة keyframes يمكن ضم عدة حركات بسيطة معًا باستخدام قاعدةkeyframes@ من CSS، والتي تحدد اسم الحركة الانتقالية، وقواعد مكانها وزمانها وكيفيتها، بعدها سنتمكن باستخدام الخاصية animation من ربط الحركة بالعنصر وتخصيص معاملات إضافية قد نحتاجها، إليك مثالًا مع الشرح: <div class="progress"></div> <style> @keyframes go-left-right { /* "go-left-right" أعط القاعدة اسمًا*/ from { left: 0px; } /* left: 0px حرك ابتداءً من قيمة */ to { left: calc(100% - 50px); } /* 100%-50px: حرك نحو اليسار */ } .progress { animation: go-left-right 3s infinite alternate; /* طبق الحركة التي عرفناها في الأعلى على العنصر المدة 3 ثوان عدد المرات: لانهائي بدّل اتجاه الحركة في كل مرة */ position: relative; border: 2px solid green; width: 50px; height: 20px; background: lime; } </style> See the Pen JS-P3-05-CSS-animations-ex3 by Hsoub (@Hsoub) on CodePen. ستجد العديد من المقالات التي تشرح القاعدة @keyframes من CSS مع ميزاتها المفصلة، ولن تحتاجها غالبًا إلا عندما يكون كل شيء في موقعك متحركًا باستمرار. الأداء يمكن تحريك أغلب خاصيات CSS بما أنها قيم رقمية مثل width و color و font-size، فيعمل المتصفح عندما تحرك هذه الخاصيات على تغيير هذه القيم تدريجيًا في كل إطار من الإطارات مما يعطي إيحاء بتأثير حركي سلس، ولكن انتظر، لا تبدو كل الحركات سلسلة كما تريدها لأن تغيير بعض خاصيات CSS عملية مجهدة. لنغوص أكثر في التفاصيل، عندما يكتشف المتصفح حدوث تغير في التنسيق، فإنه يمر بثلاثة خطوات حتى يخرج الصفحة بالتنسيق الجديد وهي: التخطيط Layout: إعادة حساب إحداثيات ومواضع كل عنصر من عناصر الصفحة، ثم الشكل Paint: إعادة حساب كيف يجب أن يظهر كل عنصر بعد تموضعه في مكانه وكل شيء حرفيًا بما فيها الخلفيات والألوان، ثم التركيب Composite: تصيير النتائج النهائية وإخراجها وعرضها على بكسلات الشاشة وتطبيق تحويلات CSS إن وجدت. تكرر هذه الخطوات خلال إجراء تحريك عبر CSS لكل إطار من الإطارات، رغم أن خاصيات CSS التي لا تؤثر على الإحداثيات أو التموضع مثل تغيير خاصية اللون color فقد تتخطى الخطوة الأولى وينتقل المتصفح إلى الخطوة الثانية مباشرةً ثم الثالثة وقد تتخطى بعض الخاصيات حتى الخطوة الثانية وتنتقل إلى الخطوة الثالثة مباشرةً، ويمكنك أن تطلع على قائمة خاصيات CSS والمراحل التي تستهدفها من موقع CSS Triggers. معلومٌ أن تلك الخطوات تأخذٌ وقتًا في الحساب خصوصًا إن حوت الصفحة على عناصر كثيرة وكانت تخطيطها معقدًا، فقد يُلاحظ تأخر في الحركة على أغلب الأجهزة مسببًا حركة مضطربة مرتعشة، لذا تتصف الحركات المطبقة على الخاصيات التي تتخطى حساب التخطيط (ويفضل أيضًا الشكل أي الخطوة الثانية) بالسرعة والسلاسة. تعد الخاصية transform خيارًا جيدًا لسببين هما: تستهدف التحويلات في CSS صندوق العنصر ككل (تدوير، أو قلب أو تمديد أو إزاحة …إلخ.) لا تؤثر التحويلات على العناصر المجاورة للعنصر المستهدف. وفقًا ذلك، تطبق التحويلات على عناصر يكون فيها التخطيط والشكل محسوبين مسبقًا وتنتقل مباشرةً إلى الخطوة الثالثة من الخطوات السابقة، أي أن المتصفح يحسب التخطيط (الحجم والمواضع) ثم يضيف لها تسنيق الشكل من ألوان وواجهات وغيرهما في المرحلة الثانية ثم يطبق التحويل الممثل بالخاصية transform على صندوق العنصر إن وجدت. أي تغييرات (تحريكات) تطبق على الخاصية transform لا تستدعي المرور ضمن الخطوة الأولى والثانية مطلقًا، علاوة على أن المتصفح يستغل قدرات الحاسوب الرسومية (بطاقة العرض التي تكون مدمجة في المعالج أو منفصلة) لمعالجة التحويلات مما يزيد من كفاءة العملية. مما سبق نجد أن الخاصية transform قوية للغاية ويمكنك عبرها تدوير عنصرٍ أو قلبه أو تطويله أو تقصيره أو حتى تحريكه وغيرها (انظر صفحة الخاصية على موسوعة حسوب)، فيمن استعمال transform: translateX(…)‎ بدلًا من استعمال الخاصية left أو margin-left أو استعمال transform: scale لزيادة حجم عنصر وهكذا. لاحظ أن الخاصية opacity لا تمر على الخطوة الأولى من الخطوات السابقة (أيضًا تتخطى الخطوة الثانية في محرك Mozilla Gecko)، ويمكن استعمالها لتطبيق تأثيرات مثل الإظهار أو الإخفاء أو التلاشي، ويمكن استعمال transform معها لإنشاء حركات سلسلة جميلة. انظر مثلًا المثال التالي الذي يتحرك فيه العنصر بمجرد الضغط عليه إلى اليمين مقدار 300 بكسل ثم يختفي: <img src="https://js.cx/clipart/boat.png" id="boat"> <style> #boat { cursor: pointer; transition: transform 2s ease-in-out, opacity 2s ease-in-out; } .move { transform: translateX(300px); opacity: 0; } </style> <script> boat.onclick = () => boat.classList.add('move'); </script> See the Pen JS-P3-05-CSS-animations-ex4 by Hsoub (@Hsoub) on CodePen. وإليك أيضًا مثال آخر أكثر تعقيدًا يُطبَّق فيه إطارات الحركة ‎@keyframes: <h2 onclick="this.classList.toggle('animated')">click me to start / stop</h2> <style> .animated { animation: hello-goodbye 1.8s infinite; width: fit-content; } @keyframes hello-goodbye { 0% { transform: translateY(-60px) rotateX(0.7turn); opacity: 0; } 50% { transform: none; opacity: 1; } 100% { transform: translateX(230px) rotateZ(90deg) scale(0.5); opacity: 0; } } </style> See the Pen JS-P3-05-CSS-animations-ex5 by Hsoub (@Hsoub) on CodePen. خلاصة تسمح الحركات الانتقالية التي تدعمها CSS بإجراء تغيرات ناعمة على خاصية أو أكثر، وهي تقنية جيدة للكثير من مهام الرسوم المتحركة، كما يمكننا استخدام رسوم JavaScript المتحركة وهذا ما سنراه في جزئية لاحقة من هذه السلسلة. النقاط الأساسية التي تجعل الرسومات المتحركة باستخدام CSS محدودةً موازنةً برسوم JavaScript هي: الإيجابيات. تنفَّذ الأشياء البسيطة ببساطة. سريعة وخفيفة على المعالج. السلبيات. رسوم JavaScript أكثر مرونةً، إذ يمكنها تنفيذ أي منطق رسومي، مثل "انفجار" عنصر مثلًا. لا تعتمد رسوميات JavaScript على تغيرات الخاصية، بل يمكن إنشاء عناصر جديدة في JavaScript مثل جزء من الرسوميات. في أمثلتنا الأولى حركنا font-size وleft وwidth وheight، لكن إذا رغبنا في أداء أفضل في مشاريعنا الحقيقية، فيجب أن نستخدم ()transform: scale و()transform: translate. يمكن تنفيذ الرسوميات المتحركة باستخدام CSS كما شرحناها في هذا المقال، وسيسمح لنا الحدث transitionend باستخدام JavaScript بعد انتهاء الحركة الانتقالية، وبالتالي ستتكامل جيدًا مع الشيفرة. سنتعرف في المقال القادم على رسوم JavaScript المتحركة التي تساعدنا في تنفيذ حالات أكثر تعقيدًا. مهام لإنجازها تحريك طائرة في CSS أظهر رسمًا متحركًا يشابه صورة الطائرة في الأسفل: يزداد حجم الصورة من 40x24px إلى 400x240px. تستغرق العملية 3 ثوان. اطبع كلمة "!Done" عند الانتهاء. لا يجب أن تتوقف العملية بالنقر على الصورة أثناء التنفيذ. افتح المثال في بيئة تجريبية الحل إليك الشيفرة: /* original class */ #flyjet { transition: all 3s; } /* JS adds .growing */ #flyjet.growing { width: 400px; height: 240px; } انتبه إلى أن الحدث transitionend يقع مرتين، مرة لكل خاصية، وبذلك ستظهر الرسالة مرتين إن لم نضع أي شرط تحقق. تحرك الطائرة 2 أعد التمرين السابق لكن اجعل الحجم يتجاوز الحجم الأقصى للصورة 400x240px، ثم يعود إلى هذا الحجم. الحل نريد اختيار أنسب منحني بيزيه لتلك الحركة، إذ يجب أن تحقيق y>1 في موضع ما للطائرة لتحقيق تأثير القفزة مثل cubic-bezier(0.25, 1.5, 0.75, 1.5)‎، انظر المخطط: افتح الحل في بيئة تجريبية. تحريك دائرة أنشئ الدالة showCircle(cx, cy, radius) التي تظهر دائرةً تنمو. cx,cy: يمثلان إحداثيات مركز الدائرة بالنسبة إلى إحداثيات النافذة. radius: نصف قطر الدائرة. انقر الزر لترى كيف سيظهر الحل: تجد المثال في هذه البيئة التجريبية وتجد حله في هذه البيئة التجريبية. تحريك دائرة مع دالة استدعاء انطلاقًا من المهمة السابقة، نحتاج إلى دائرة تُظهر رسالةً داخلها. ينبغي أن تظهر الرسالة مباشرةً بعد اكتمال الحركة، أي ظهور الدائرة بحجمها الكامل، وإلا سيبدو المنظر سيئًا. ترسم الدالة (showCircle(cx, cy, radius الدائرة، لكن لا يمكنها أن تدلّك على اكتمال الرسم، لذا أضف دالة استدعاء callback مثل معامل إلى الدالة السابقة (showCircle(cx, cy, radius, callback، حيث تُستدعى عندما ينتهي الرسم المتحرك. ينبغي أن تتلقى دالة الاستدعاء callback العنصر <div> للدائرة مثل وسيط. إليك مثالًا: showCircle(150, 150, 100, div => { div.classList.add('message-ball'); div.append("Hello, world!"); }); النموذج: تجد حل المثال في هذه البيئة التجريبية. ترجمة -وبتصرف- للفصل css-animations من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: منحنى بيزيه وأهميته في الرسوميات وصناعة الحركات في جافاسكربت تأثيرات الانتقال والحركة في CSS منحنى بيزيه وأهميته في الرسوميات وصناعة الحركات في جافاسكربت table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } p iframe { border: 1px solid #e7e5e3 !important; }
  2. بعد أن تعرفنا على أساسيات التعابير النمطية ثم تعرفنا على المجموعات ثم المحددات الكمية ثم تعلمنا كيفية التقاط عدة مجموعات عبر التعابير النمطية، سنقدم فيما يلي مجموعةً من الأفكار المتقدمة في بناء وتنفيذ تعابير نمطية أكثر كفاءةً في البحث عن التطابقات المطلوبة، مثل المراجع References، والمحرف البديل OR، والبحث قُدُمًا lookahead، والبحث إلى الخلف lookbehind، والبحث في موقع محدد باستخدام الراية y. المراجع إلى المجموعات بالأعداد والأسماء يمكن استخدام محتويات أقواس المجموعات الملتقطة (...) في النتيجة أو في النص البديل، وحتى في النمط نفسه. المراجع باستخدام الأعداد يمكن الإشارة إلى مجموعة ضمن نمط باستخدام N\، حيث N هو رقم المجموعة، ولتوضيح أهمية هذه الإشارة المرجعية لنتأمل المهمة التالية: نريد إيجاد ما داخل إشارتي التنصيص المفردتين '...' أو المزدوجتين "..." في نص ما، فكيف سننجز ذلك؟ يمكن وضع نوعي إشارة التنصيص في أقواس مربعة ['"](?*.)['"]، لكنه في هذه الحالة سيجد محتويات مختلطةً، مثل "...' و'..."، مما سيقودنا إلى تطابقات خاطئة عندما يظهر نوع من الإشارات ضمن آخر، مثل "!She's the one". let str = `He said: "She's the one!".`; let regexp = /['"](.*?)['"]/g; // النتيجة ليست كما نتوقع alert( str.match(regexp) ); // "She' وجد النمط -كما توقعنا- إشارة تنصيص البداية "، ثم استهلك النص بعدها حتى وجد إشارة تنصيص أخرى ' أنهت التطابق، وللتأكد من مطابقة إشارة التنصيص الختامية لإشارة تنصيص البداية، يمكن وضعها ضمن قوسي مجموعة، والإشارة إليها بعدد مرجعي 1\(?*.)(["']) إليك الشيفرة الصحيحة: let str = `He said: "She's the one!".`; let regexp = /(['"])(.*?)\1/g; alert( str.match(regexp) ); // "She's the one!" سيجد محرك التعبير النمطي إشارة تنصيص البداية (["'])، ويتذكر محتواها الذي يمثل المجموعة الملتقطة الأولى، حيث يعني العدد 1\ في النمط إيجاد نفس التطابق الموجود في المجموعة الأولى، وهي في حالتنا إشارة تنصيص تطابق تمامًا إشارة البداية. وسيعني العدد 2\ محتويات المجموعة الثانية، والعدد 3\ محتويات الثالثة، وهكذا. لاحظ، لن نتمكن من الإشارة إلى مجموعة إذا استخدمنا نمط الاستثناء :?، ولن يتذكر المحرك محتوى المجموعات المستثناة (...:?). انتبه، لا تخلط بين الإشارة 1\ في نمط ما، والإشارة 1$ في النص البديل، حيث نستخدم إشارة الدولار 1$ في الإشارة المرجعية ضن النص البديل، والشرطة المعكوسة 1\ ضمن الأنماط. المراجع باستخدام الأسماء إذا احتوى التعبير النمطي على عدة أقواس، فمن الأنسب استخدام الأسماء للدلالة عليها، نستخدم <k<name\ في الدلالة على القوس بالاسم، فإذا سُميت مجموعة ضمن التعبير النمطي بالاسم <quote>?، فسيكون الاسم المرجعي لها هو <k<quote\، وإليك مثالًا: let str = `He said: "She's the one!".`; let regexp = /(?<quote>['"])(.*?)\k<quote>/g; alert( str.match(regexp) ); // "She's the one!" البديل باستخدام OR في التعابير النمطية يعني مصطلح "البديل" Alternation في التعابير النمطية استخدام العملية المنطقية "OR"، ويرمز لها ضمن التعابير النمطية بالخط العمودي |، فلو أردنا مثلًا إيجاد لغات برمجة مثل HTML أو PHP أو Java أو JavaScript، فسيكون التعبير النمطي المناسب هو ?(html|php|java(script، وإليك مثالًا تطبيقيًا: let regexp = /html|php|css|java(script)?/gi; let str = "First HTML appeared, then CSS, then JavaScript"; alert( str.match(regexp) ); // 'HTML', 'CSS', 'JavaScript' لكننا رأينا سابقًا أنّ الأقواس المربعة تنفذ أمرًا مشابهًا، فهي تسمح باختيار أحد المحارف التي توضع ضمنها، فإذا استخدمنا النمط gr[ae]y مثلًا فسنحصل على التطابقين gray أو grey، إذ تسمح الأقواس المربعة باستخدام المحارف أو أصناف المحارف ضمنها، بينما يسمح البديل باستخدام أي عبارات، حيث يعني التعبير A|B|C أيًا من العبارات A أو B أو C، وإليك بعض الأمثلة: يماثل النمط gr(a|e)y النمط gr[ae]y. يعني النمط gra|ey أيًا من gra أو ey. ولتطبيق البديل على جزء محدد من نمط، يمكن وضع هذا الجزء داخل قوسين: يطابق النمط I love HTML|CSS كلًا من I love HTML أو CSS. يطابق النمط (I love (HTML|CSS كلًا من I love HTML أو I love CSS مثال: تعبير نمطي لإيجاد الوقت صادفنا في المقالات السابقة مهمة بناء تعبير نمطي يبحث عن الوقت وفق التنسيق hh:mm، مثل 12:00، لكن التعبير الذي استُخدم d\d:\d\d\ سطحي جدًا، إذ يقبل هذا النمط قيمًا خاطئة للتوقيت مثل 25:99، فكيف سننجز نمطًا أفضل؟ بالنسبة للساعات: إذا كان الرقم الأول 0 أو1 فيمكن أن يكون الثاني أي رقم. إذا كان الرقم الأول 2، فيجب أن يكون الثاني [‎0-3]. لا يسمح بأي رقم آخر غير ذلك. يمكن كتابة تعبير نمطي يضم الحالتين باستخدام البديل بالشكل التالي: [01]\d|2[0-3] أما بالنسبة للدقائق: ينبغي أن تكون الدقائق بين 00 و59، ويكتب هذا في التعبير النمطي بالشكل ‎[0-5]\d، أي يمكن أن تكون الآحاد أي رقم، والعشرات من 1 إلى 5. وعند ضم الساعات والدقائق معًا سنحصل على التعبير: [01]\d|2[0-3]:[0-5]\d انتهينا تقريبًا لكن مع وجود مشكلة صغيرة، إذ وضعنا محرف البديل | بالشكل السابق فصل التعبير إلى قسمين منفصلين: [01]\d | 2[0-3]:[0-5]\d إليك ما سيحدث، سيبحث النمط عن أحد النمطين، لكن هذا خاطئ، لأنّ البديل سيستخدَم فقط في قسم الساعات من التعبير النمطي، وسنصلح ذلك بوضع قسم الساعات ضمن قوسين ([01]\d|2[0-3]):[0-5]\d إليك الحل النهائي: let regexp = /([01]\d|2[0-3]):[0-5]\d/g; alert("00:00 10:10 23:59 25:99 1:2".match(regexp)); // 00:00,10:10,23:59 التحقق مما يلي أو يسبق التعبير النمطي نحتاج في بعض الأحيان إلى إيجاد تطابقات بشرط أن يأتي بعدها أو قبلها تطابقًا أو نمطًا محددًا دون أن تدخل تلك التطابقات ضمن قيم النتيجة النهائية، ولهذه الغاية سنجد صيغتا تحقق تُدعيان "انظر أمام النمط" lookahead،" وانظر خلف النمط "lookbehind،" وقبل أن نبدأ موضوعنا، سنأخذ مثالًا نحاول فيه إيجاد السعر ضمن النص ‎1 turkey costs 30€، وهو عدد تتبعه الإشارة €. انظر أمام التعبير النمطي النمط (X(?=Y يعني "ابحث عن X، لكن أعد نتيجة التطابق فقط إذا كان بعدها Y"، ويمكن أن تجد أي نمط مكان X أو Y، فمن أجل عدد صحيح تتبعه الإشارة €، سيكون النمط المناسب هو (€=?)+d\: let str = "1 turkey costs 30€"; alert( str.match(/\d+(?=€)/) ); // 30, € يهمل الرقم 1 لأنه غير متبوع بالإشارة لاحظ أنّ مطابقة ما أمام النمط هو اختبار وعملية تحقيق فقط ببساطة، ولا يشكل محتوى ما بين القوسين جزءًا من النتيجة، فعندما نبحث عن النمط (X(?=Y، فسيجد المحرك X ومن ثم يتحقق من وجود Y بعدها مباشرةً إن وجده أعاد X فقط وإلا لم يعد شيئًا، ثم يتابع البحث. يمكن كتابة اختبارات أكثر تعقيدًا، مثل النمط (x(?=y)(?=z الذي يعني: جد X. تحقق من وجود Y مباشرةً بعد X، وتجاوز التطابق إن لم يتحقق ذلك. تحقق أيضًا من وجود Z مباشرةً بعد X، وتجاوز التطابق إن لم يتحقق ذلك. إذا نجح الاختباران السابقان فأعد قيمة X، وإلا فتابع البحث. أي أننا نبحث عن X التي تتبعها القيمتان Y وZ في نفس الوقت. وسيبحث النمط (d+(?=\s)(?=.*30\ عن عدد +d\ يتبعه فراغ (s\=?)، ثم العدد 30 في مكان ما من النص: let str = "1 turkey costs 30€"; alert( str.match(/\d+(?=\s)(?=.*30)/) ); // 1 التطابق الوحيد المناسب في المثال السابق هو 1. لنفترض بأننا سنحتاج إلى الكمية وليس السعر من النص نفسه، وبالتالي سنحتاج إلى عدد +d\ غير متبوع بالإشارة €، يمكن إنجاز ذلك باستعمال النمط الاختباري (x(?!y، أي "انظر أمام النمط، يجب أن لا يتحقق الشرط" بمعنى ابحث عن X التي لا يتبعها Y مباشرةً. let str = "2 turkeys cost 60€"; alert( str.match(/\d+\b(?!€)/g) ); // 2 (the price is not matched) انظر خلف التعبير النمطي يشابه هذا النمط نمط التحقق مما يلي التعبير، لكنه يضع شرطًا على ما قبل التعبير المتطابق ويمكن أن نستخدمه بالصيغ التالية: "التحقق من مطابقة ما يسبق التعبير" Positive lookbehind عبر الصيغة Y)X=>?): أوجد X إذا سبقتها Y. "التحقق من عدم مطابقة ما يسبق التعبير" Negative lookbehind عبر الصيغة Y)X!>?): أوجد X إذا لم تسبقها Y. لنغير على سبيل المثال السعر إلى دولار، حيث تكون إشارة الدولار $ عادةً قبل السعر، فللبحث عن 30$ سنستخدم النمط +d\($\=>?): let str = "1 turkey costs $30"; // \$ سنتجاوز الإشارة alert( str.match(/(?<=\$)\d+/) ); // 30 وإذا أردنا الكمية، فسنبحث عن عدد لا تسبقه الإشارة $، وبالتالي نستخدم البحث السلبي خلفًا +d\($\!>?): let str = "2 turkeys cost $60"; alert( str.match(/(?<!\$)\b\d+/g) ); // 2 إدخال الشرط ضمن نتيجة التطابق لا يظهر محتوى ما بين القوسين جزءًا من النتيجة عادةً، فلن تكون الإشارة € مثلًا ضمن نتيجة التطابق مع النمط (€=?)+d\. وهذا أمر طبيعي، لأننا نبحث عن العدد الذي قبلها، وهي مجرد اختبار، لكن قد نحتاج في بعض الحالات إلى ما حول التطابق أو إلى جزء منه، ويمكن إنجاز ذلك بوضع الجزء المطلوب ضمن قوسين إضافيين، سنعيد في المثال التالي السعر مع إشارة العملة (kr|€): let str = "1 turkey costs 30€"; let regexp = /\d+(?=(€|kr))/; // €|kr أقواس إضافية حول alert( str.match(regexp) ); // 30, € إليك مثالًا مع البحث خلفًا: let str = "1 turkey costs $30"; let regexp = /(?<=(\$|£))\d+/; alert( str.match(regexp) ); // 30, $ الراية y والبحث في موقع محدد تسمح الراية y بالبحث في موقع محدد ضمن النص الأصلي، ولفهم عمل هذه الراية وأساليب استخدامها في التعابير النمطية جيدًا سنطرح مشكلةً عمليةً، حيث تعتبر عملية التحليل التجريدي لنص lexical analysis من المهام الأساسية لمحركات التعابير النمطية، ففي نصوص الشيفرة في لغات البرمجة تلعب محركات التعابير الدور الأساسي في إيجاد العناصر البنيوية، مثل وسوم HTML والسمات التي في داخلها، وستساعد في JavaScript على إيجاد المتغيرات والدوال وغيرها، ولن ندخل في موضوع إنشاء المحللات التجريدية فهي خارج نطاق موضوعنا، لكن توجد مهمة مشتركة وهي إيجاد شيء ما في موقع محدد، لنفترض مثلًا أنّ لدينا الشيفرة التالية "let varName = "value في صيغة نص، ونحتاج إلى تحديد اسم المتغير الذي سيبدأ في الموقع الرابع، سنبحث عن اسم المتغير من خلال التعبير +w\، وطبعًا نحتاج إلى تعابير أكثر تعقيدًا للبحث الدقيق عن أسماء المتغيرات في JavaScript لكن لن نهتم بذلك حاليًا. سيجد التابع (/+str.match(/\w الكلمة الأولى فقط في السطر وهي (let)، وطبعًا ليست هي الكلمة المطلوبة. ولو استخدمنا الراية g فسيبحث التابع عن جميع الكلمات في النص، لكننا لا نحتاج سوى الكلمة الموجودة في الموقع الرابع. كيف سنبحث عن نمط محدد ابتداءً من موقع محدد؟ لنحاول استخدام التابع (regexp.exec(str، حيث سيبحث هذا التابع عن التطابق الأول بطريقة مشابهة للتابع (str.match(regexp عندما يُستخدم دون الرايتين g أو y، لكنه سيبحث في النص ابتداءً من الموقع المخزن في الخاصية regexp.lastIndex عندما نفعّل الراية g، فإذا وجد تطابقًا فسيخزن الموقع الذي يلي التطابق في الخاصية regexp.lastIndex، أي ستكون الخاصية regexp.lastIndex نقطة بداية للبحث، وسيغير كل استدعاء للتابع (regexp.exec(str قيمتها إلى القيمة الجديدة -بعد إيجاد تطابق-، وبالتالي سيعيد الاستدعاء المتكرر للتابع (regexp.exec(str التطابقات واحدًا تلو الآخر، وإليك مثالًا: let str = 'let varName'; // لنبحث عن كل الكلمات في هذه السلسلة let regexp = /\w+/g; alert(regexp.lastIndex); // 0 ( lastIndex=0) let word1 = regexp.exec(str); alert(word1[0]); // let (الكلمة الأولى) alert(regexp.lastIndex); // 3 (الموقع بعد التطابق) let word2 = regexp.exec(str); alert(word2[0]); // varName (الكلمة الثانية) alert(regexp.lastIndex); // 11 (الموقع بعد التطابق) let word3 = regexp.exec(str); alert(word3); // null (لا تطابقات) alert(regexp.lastIndex); // 0 (يصفر قيمة الخاصية) ويمكن الحصول على كل التطابقات من خلال حلقة: let str = 'let varName'; let regexp = /\w+/g; let result; while (result = regexp.exec(str)) { alert( `Found ${result[0]} at position ${result.index}` ); // Found let at position 0, then // Found varName at position 4 } يمثل استخدام التابع regexp.exec أسلوبًا بديلًا عن str.matchAll مع بعض السيطرة على مجرى العملية. بالعودة إلى مهمتنا، سنتمكن من تحديد الموقع بإسناد القيمة 4 إلى الخاصية lastIndex لنبدأ البحث من الموقع المطلوب بالشكل التالي: let str = 'let varName = "value"'; let regexp = /\w+/g; // "g" دون استخدام lastIndex يتجاهل المحرك الخاصية regexp.lastIndex = 4; let word = regexp.exec(str); alert(word); // varName لقد حُلِّت المشكلة! والنتيجة صحيحة، لكن ينبغي أن نتروّى قليلًا.. يبدأ التابع regexp.exec البحث انطلاقًا من الموقع lastIndex، ثم يستأنف البحث، فإذا لم يكن التطابق موجودًا في هذا الموقع بل في موقع يأتي بعده فسيجده أيضًا: let str = 'let varName = "value"'; let regexp = /\w+/g; // ابدأ البحث في الموقع 3 regexp.lastIndex = 3; let word = regexp.exec(str); // سيجد التطابق في الموقع 4 alert(word[0]); // varName alert(word.index); // 4 إنّ هذا الأمر خاطئ في العديد من المهام ومنها التحليل التجريدي، إذ يجب إيجاد التطابق في الموقع المحدد تمامًا وليس بعده، وهذا سبب وجود الراية y. حيث تجبر الراية y التابع regexp.exec على البحث في الموقع lastIndex بالتحديد وليس انطلاقًا منه. إليك مثالًا: let str = 'let varName = "value"'; let regexp = /\w+/y; regexp.lastIndex = 3; alert( regexp.exec(str) ); // null (يوجد فراغ في هذا الموقع وليس كلمة) regexp.lastIndex = 4; alert( regexp.exec(str) ); // varName (الكلمة في الموقع 4) لاحظ أننا لن نحصل على التطابق مع التعبير w+/y\/ في الموقع 3 (على عكس g) لكن في الموقع 4، وليس هذا فحسب، بل توجد فائدة أخرى لاستخدام الراية y، تخيل أن لدينا نصًا طويلًا ولا توجد فيه أية تطابقات للنمط الذي نبحث عنه، سيبحث المحرك عند تفعيل الراية g حتى نهاية النص ولن يجد نتيجةً، وهذا ما سيستهلك الوقت مقارنةً باستخدام الراية y التي تتحقق فقط في الموقع المحدد. سنواجه في المهام التي تشبه التحليل التجريدي عمليات بحث متعددةً في مواقع محددة، للتحقق مما هو موجود، وسيكون استخدام الراية y المفتاح لإنجاز مثل هذه العمليات بدقة وفعالية. خلاصة يفيدنا التحقق مما يلي التطابق ويسبقه في إيجاد تطابق بناءً على ما قبله أو بعده. يمكن استخدام تعابير نمطية بسيطة لإنجاز ذلك يدويًا، كأن نبحث عن كل التطابقات المطلوبة ونرشحها من خلال الحلقات وفق السياق المطلوب. تذكر أن التابع str.match (مع الراية g) والتابع str.match (دائمًا) سيعيدان مصفوفةً لها الخاصية index، وبالتالي سنعلم تمامًا موقع التطابق في النص، ثم نتحقق مما قبله أو بعده. إذا نفِّذ البحث خلفًا أو قدمًا آليًا فسيكون أفضل من الطريقة اليدوية، مثلما فعلنا في هذا المقال: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } النمط نوع البحث ماذا يُطابق؟ (X(?=Y بحث إيجابي قُدمًا X إذا جاءت بعدها Y (X(?!Y بحث سلبي قدمًا X إذا لم تكن بعدها Y y)x=>?) بحث إيجابي خلفًا X إذا جاءت بعد Y y)x!>?) بحث سلبي خلفًا X إذا لم تأت بعد Y تجبر الراية y التابع regexp.exec على البحث في الموقع lastIndex بالتحديد وليس انطلاقًا منه. مهام لإنجازها إيجاد لغات البرمجة اكتب تعبيرًا نمطيًا يبحث عن أسماء لغات البرمجة الموجودة في النص Java JavaScript PHP C++ C let regexp = /your regexp/g; alert("Java JavaScript PHP C++ C".match(regexp)); // Java JavaScript PHP C++ C الحل: تقتضي الفكرة الأولى بترتيب اللغات بحيث يفصل بينها المحرف|، لكن هذا الحل لن ينفع: let regexp = /Java|JavaScript|PHP|C|C\+\+/g; let str = "Java, JavaScript, PHP, C, C++"; alert( str.match(regexp) ); // Java,Java,PHP,C,C سيبحث محرك التعابير النمطية عن البدائل واحدًا تلو الآخر، فلو تحقق بداية من "Java" ووجدها، فلن يجد بالطبع "JavaScript" وكذلك الأمر بالنسبة للغتين "C" و "++C". هنالك حلين للمشكلة: غير ترتيب اللغات لتكون اللغة ذات الاسم الأطول أولًا: JavaScript|Java|C\+\+|C|PHP ادمج اللغات التي لها نفس البداية : Java(Script)?|C(\+\+)?|PHP إليك الشيفرة: let regexp = /Java(Script)?|C(\+\+)?|PHP/g; let str = "Java, JavaScript, PHP, C, C++"; alert( str.match(regexp) ); // Java,JavaScript,PHP,C,C++ إيجاد زوجي وسوم [tag]…[/tag] لنفترض وجود زوج الوسوم [tag]...[/tag]، حيث يمثّل tag أحد العناصر b أو url أو quote، إليك مثالًا: [b]text[/b] [url]http://google.com[/url] قد تكون هذه الوسوم متداخلةً، لكن لا يمكن أن يتداخل العنصر مع نفسه: Normal: [url] [b]http://google.com[/b] [/url] [quote] [b]text[/b] [/quote] Can't happen: [b][b]text[/b][/b] ليس بالضرورة أن تكون وسوم البداية والنهاية لعنصر على نفس السطر: [quote] [b]text[/b] [/quote] اكتب تعبيرًا نمطيًا للبحث عن تلك الوسوم ومحتوياتها، إليك مثالًا: let regexp = /your regexp/flags; let str = "..[url]http://google.com[/url].."; alert( str.match(regexp) ); // [url]http://google.com[/url] إذا كانت الوسوم متداخلةً فنريد فقط العنصر الخارجي: let regexp = /your regexp/flags; let str = "..[url][b]http://google.com[/b][/url].."; alert( str.match(regexp) ); // [url][b]http://google.com[/b][/url] الحل: يُعطى التعبير النمطي للبحث عن وسم البداية بالشكل: \[(b|url|quote)] ولإيجاد المحتوى كاملًا حتى بلوغ وسم النهاية سنستخدم النمط ?* مع الراية s لالتقاط أي محرف بما في ذلك محرف بداية السطر ثم سنضيف مرجعًا للخلف إلى وسم النهاية. \[(b|url|quote)\].*?\[/\1] إليك الشيفرة: let regexp = /\[(b|url|quote)].*?\[\/\1]/gs; let str = ` [b]hello![/b] [quote] [url]http://google.com[/url] [/quote] `; alert( str.match(regexp) ); // [b]hello![/b],[quote][url]http://google.com[/ur إيجاد ما بين إشارتي التنصيص "…." أنشئ تعبيرًا نمطيًا لإيجاد سلسلة نصية بين إشارتي تنصيص"….". يجب أن يدعم النص فكرة تجاوز المحارف تمامًا كما في جافا سكربت. أي يمكن أن نضع علامة التنصيص بالشكل "\ وأن نضع علامة السطر الجديد بالشكل n\ والشرطة المائلة بالشكل \\. let str = "Just like \"here\"."; وانتبه إلى أن علامة التنصيص التي يجري تجاوزها "\ لن تعتبر نهاية للسلسلة النصية. وبالتالي لا بدّ من البحث من إشارة تنصيص إلى أخرى متجاهلين علامات التنصيص التي يجري تجاوزها. أمثلة عن نصوص وتطابقاتها: .. "test me" .. .. "Say \"Hello\"!" ... (داخلها علامة تنصيص جرى تجاوزها) .. "\\" .. (داخلها شرطة مائلة مزدوجة) .. "\\ \"" .. (داخلها شرطة مائلة مزدوجة وعلامة تنصيص جرى تجاوزها) لا بد في جافاسكربت من مضاعفة الشرطات المائلة لتمريرها إلى النصوص كالتالي: let str = ' .. "test me" .. "Say \\"Hello\\"!" .. "\\\\ \\"" .. '; // the in-memory string alert(str); // .. "test me" .. "Say \"Hello\"!" .. "\\ \"" .. الحل: /"(\\.|[^"\\])*"/g نتبع الآتي خطوةً بخطوة: نبحث أولًا عن إشارة تنصيص البداية ". إن كان هناك شرطة عكسية \\ لا بد من مضاعفتها في التعبير لأنها محرف خاص. بعدها يمكن أن يأتي أي محرف. باستثناء الحالتين السابقتين يمكن أن نأخذ أية محارف ما عدا إشارة التنصيص (التي تعني نهاية النص) والشرطة العكسية (تستخدم الشرطة العكسية فقط مع رموز أخرى بعدها لمنع ظهورها مفردة): [^"\\] وهكذا حتى نصل إلى إشارة تنصيص نهاية النص. إليك الشيفرة: let regexp = /"(\\.|[^"\\])*"/g; let str = ' .. "test me" .. "Say \\"Hello\\"!" .. "\\\\ \\"" .. '; alert( str.match(regexp) ); // "test me","Say \"Hello\"!","\\ \"" إيجاد وسم بأكمله اكتب تعبيرًا نمطيًا لإيجاد الوسم <style> كاملًا. قد لا يضم الوسم أية صفات attributes أو عددًا منها: <style type="..." id="..."> لا يجب أن يطابق التعبير الوسم <styler> مثلًا: let regexp = /your regexp/g; alert( '<style> <styler> <style test="...">'.match(regexp) ); // <style>, <style test="..."> الحل: من المؤكد أن بداية النمط هي <style. لكن لا يمكن أن تكون بقية النمط ببساطة <?*.style> لأن وسمًا مثل <styler> سيكون نتيجة محتملة. نحتاج إلى فراغ بعد <style ثم محارف اختيارية بعده أو النهاية، وبالتالي سيكون النمط المطلوب: <style(>|\s.*?>) إليك الشيفرة: let regexp = /<style(>|\s.*?>)/g; alert( '<style> <styler> <style test="...">'.match(regexp) ); // <style>, <style test إيجاد أعداد صحيحة موجبة لدينا سلسلة نصية من الأعداد الصحيحة . أنشئ تعبيرًا نمطيًا يبحث عن الأعداد الموجبة فقط (يُسمح بالتقاط الصفر) مثال عن الاستخدام: let regexp = /your regexp/g; let str = "0 12 -5 123 -18"; alert( str.match(regexp) ); // 0, 12, 123 الحل: التعبير النمطي المناسب لإيجاد عدد صحيح هو : \d+ يمكن التخلص من الأعداد السالبة باستخدام النظر خلفًا إلى الإشارة السالبة : (?<!-)\d+ لو استخدمنا هذا النمط فقد نلاحظ ظهور نتيجة إضافية: let regexp = /(?<!-)\d+/g; let str = "0 12 -5 123 -18"; console.log( str.match(regexp) ); // 0, 12, 123, 8 كما ترى فقد وجد 8 بدلًا من -18. لحل المشكلة، لا بدّ من التحقق أن التعبير النمطي لن يبدأ المطابقة من منتصف رقم آخر غير مطابق. يمكن إنجاز الأمر بتخصيص عملية بحث أخرى للخلف عن الإشارة السالبة : (?<!-)(?<!\d)\d+ يتحقق النمط (?<!\d) أن المطابقة لن تبدأ بعد رقم آخر، بل فقط ما نحتاجه، ويمكن ضمهما أيضًا في عملية بحث للخلف واحدة: . let regexp = /(?<![-\d])\d+/g; let str = "0 12 -5 123 -18"; alert( str.match(regexp) ); // 0, 12, 123 إضافة شيفرة بعد الوسم Head لدينا نص ضمن مستند HTML. اكتب تعبيرًا نمطيًا يدخل الشيفرة <h1>Hello</h1> بعد الوسم <body> مباشرة ولا تنس أنّ هذا الوسم قد يحوي صفات داخله. إليك مثالًا: let regexp = /your regular expression/; let str = ` <html> <body style="height: 200px"> ... </body> </html> `; str = str.replace(regexp, `<h1>Hello</h1>`); يجب أن تكون قيمة str بعد ذلك : <html> <body style="height: 200px"><h1>Hello</h1> ... </body> </html> الحل: علينا أولًا إيجاد الوسم <body>وذلك باستخدام التعبير النمطي: <body.*?> لا حاجة لتعديل الوسم <body> بل فقط إضافة النص بعده. إليك ما يمكن فعله: let str = '...<body style="...">...'; str = str.replace(/<body.*?>/, '$&<h1>Hello</h1>'); alert(str); // ...<body style="..."><h1>Hello</h1>... يعني النمط في النص المُستبدل التطابق الذاتي أي الجزء من النص الأصل المرتبط بالتعبير النمطي <?*. body> حيث سيُستبدل بنفسه إضافة إلى "<h1>Hello</h1>". يقتضي الحل البديل استخدام البحث خلفًا: let str = '...<body style="...">...'; str = str.replace(/(?<=<body.*?>)/, `<h1>Hello</h1>`); alert(str); // ...<body style="..."><h1>Hello</h1>... لاحظ وجود جزء البحث خلفًا فقط في هذا التعبير النمطي حيث يعمل كالتالي: تحقق عند كل موقع في النص إن تبعه النمط <?*. body>. إن كان الأمر كذلك فقد حصلنا على التطابق. لن يُعاد الوسم <?*. body> وستكون النتيجة نصًا فارغًا يتطابق الموقع الذي يتبعه النمط <?*. body>. عندها سيُستبدل النص الفارغ بالنمط <?*. body> يليه النص <h1>Hello</h1>. يمكن الاستفادة من الرايات s و i في النمط كالتالي: /<body.*?>/si تدفع الراية s المحرف "." إلى مطابقة سطر جديد وتدفع الراية i النص <body> لمطابقة النص <BODY> (تحسس لحالة الأحرف). ترجمة -وبتصرف- للفصول Backreferences in Patten وAlternation OR وlookahead and lookbehind وsticky flag y, searching at position من سلسلة The Modern JavaScript Tutorial اقرأ أيضًا التعابير النمطية Regular Expressions في جافاسكريبت مقدمة في التعابير النمطية Regular Expressions التعابير النمطية (regexp/PCRE) في PHP
  3. سنغطي في هذا الفصل التوابع المتنوعة التي تعمل مع التعابير النمطية بشيء من التفصيل بعد أن غطينا موضوع التعابير النمطية تغطية شاملة بدءًا من مقال أساسيات التعابير النمطية وحتى مقال كتابة تعابير نمطية متقدمة (إن لم تتطلع عليها، فننصحك بالرجوع إليها أولًا). التابع (str.match(regexp يبحث هذا التابع عن تطابقات للتعبير regexp في النص str، وله ثلاثة أنماط: النمط الأول، الراية g غير مفعّلة: يعيد التابع التطابق الأول ضمن مصفوفة، تحوي مجموعات ملتقطةً capturing groups وخصائص، هي موقع التطابق index، والنص الذي نبحث فيه input، وهو النص str. let str = "I love JavaScript"; let result = str.match(/Java(Script)/); alert( result[0] ); // JavaScript (تطابق كامل) alert( result[1] ); // Script (المجموعة الملتقطة الأولى) alert( result.length ); // 2 // Additional information: alert( result.index ); // 7 (موقع التطابق) alert( result.input ); // I love JavaScript (النص الأصلي) النمط الثاني، الراية g مفعلة: سيعيد التابع مصفوفةً تضم كل التطابقات الموجودة في صيغة قيم نصية، دون مجموعات ملتقطة، أو غيرها من التفاصيل. let str = "I love JavaScript"; let result = str.match(/Java(Script)/g); alert( result[0] ); // JavaScript alert( result.length ); // 1 النمط الثالث، إذا لم يوجد تطابق فسيعيد التابع القيمة null، سواء استخدمنا الراية g أم لم نستخدمها، انتبه جيدًا إلى أنه لا يعيد مصفوفةً فارغةً عندما لا يجد تطابقات، بل يعيد القيمة null: let str = "I love JavaScript"; let result = str.match(/HTML/); alert(result); // null alert(result.length); // Error إذا أردنا الحصول على النتيجة في مصفوفة، فيمكننا كتابة الشيفرة على الشكل: let result = str.match(regexp) || []; التابع (str.matchAll(regexp يمثل التابع نسخةً محدثةً ومطورةً عن التابع str.match، ويستخدَم لإيجاد جميع التطابقات وفق المجموعات المحددة، ويختلف عن التابع str.match في ثلاثة أمور، هي: لا يعيد مصفوفةً بل كائنًا قابلًا للتكرار iterable object، ويمكن إنشاء مصفوفة نظامية منه باستخدام Array.from. عند استخدام الراية g يعيد كل تطابق في مصفوفة تحتوي مجموعات. عندما لا يجد تطابقات فلا يعيد null، بل كائنًا فارغًا قابلًا للتكرار. أمثلة عن استخدامه: let str = '<h1>Hello, world!</h1>'; let regexp = /<(.*?)>/g; let matchAll = str.matchAll(regexp); alert(matchAll); // ليس مصفوفة بل كائن matchAll = Array.from(matchAll); // الآن مصفوفة let firstMatch = matchAll[0]; alert( firstMatch[0] ); // <h1> alert( firstMatch[1] ); // h1 alert( firstMatch.index ); // 0 alert( firstMatch.input ); // <h1>Hello, world!</h1> إذا استخدمنا الحلقة for..of للحصول على تطابقات matchAll، فلن نحتاج إلى تحويل الكائن إلى مصفوفة من خلال Array.from. التابع (str.split(regexp|substr, limit يقسم النص وفقًا لتعبير نمطي (أو نص فرعي)، ويمكن استخدامه دون نص بالشكل التالي: alert('12-34-56'.split('-')) // array of ['12', '34', '56'] كما يمكن التقسيم وفقًا لتعبير نمطي بنفس الأسلوب: alert('12, 34, 56'.split(/,\s*/)) // array of ['12', '34', '56'] التابع (str.search(regexp يعيد التابع موقع التطابق الأول، أو يعيد 1- إذا لم يجد تطابقًا: let str = "A drop of ink may make a million think"; alert( str.search( /ink/i ) ); // 10 (first match position) المحدودية الأكبر للتابع هي إيجاده لأول تطابق فقط. فإذا أردنا مواقع بقية التطابقات فلا بدّ من استخدام طرق أخرى، مثل البحث عن جميع التطابقات باستخدام matchAll. التابع (str.replace(str|regexp, str|func وهو التابع الأساسي للبحث والاستبدال، والأكثر فائدةً، ويمكن استخدامه للبحث عن أجزاء من النص دون الحاجة لتعابير نمطية. // بدل الشرطة القصيرة بنقطتين متعامدين alert('12-34-56'.replace("-", ":")) // 12:34-56 مع ذلك قد يصعب استخدامه أحيانًا. عندما يكون المعامل الأول replace نصًا فسيستبدل التطابق الأول فقط حيث ستلاحظ في المثال الأول استبدال الشرطة القصيرة الأولى فقط بالنقطتين المتعامدتين، ولإيجاد بقية التطابقات واستبدالها، لا بدّ من استخدام التعبير النمطي g/-/ بدلًا من النص "-"، مع التفعيل الإجباري للراية g. // بدل كل شرطة قصيرة بنقطتين متعامدين alert( '12-34-56'.replace( /-/g, ":" ) ) // 12:34:56 يمكن استخدام محارف خاصة في الوسيط الثاني كونه النص البديل. table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الرمز ما يفعله ضمن النص replacment &$ يمثل نص التطابق `$ يمثل النص الواقع قبل نص التطابق. '$ يمثل النص الواقع بعد نص التطابق. n$ يمثل التطابق ذا الرقم n من مجموعة التطابق (الموجود ضمن قوسي تجميع"()" ) وسنتعرف لاحقًا عليها في فصل: المجموعات الملتقطة. ‎$<name>‎ يمثل التطابق ذا الاسم name من مجموعة التطابق (الموجود ضمن قوسي تجميع"()" )، وسنتعرف لاحقًا ععليها في فصل: المجموعات الملتقطة. $$ يمثل المحرف $ إليك مثالًا: let str = "John Smith"; // swap first and last name alert(str.replace(/(john) (smith)/i, '$2, $1')) // Smith, John يمكن أن نمرر دالةً ضمن الوسيط الثاني، إذا كانت الحالة تتطلب ذلك. وستُستدعى هذه الدالة عند كل تطابق، وستصبح القيمة التي تُعيدها بمثابة النص البديل، أما شكل الدالة فهو: func(match, p1, p2, ..., pn, offset, input, groups) حيث: match: التطابق. p1, p2, ..., pn: محتويات المجموعات الملتقطة، إن وجدت. offset: موقع التطابق. input: النص الأصلي. groups: كائن يضم المجموعات المُسمّاة. إذا لم توجد أقواس ضمن التعبير النمطي فسيكون لدينا ثلاثة وسطاء فقط (func(str, offset, input. وسنعرض بعض الأمثلة: تحويل التطابقات إلى أحرف كبيرة: let str = "html and css"; let result = str.replace(/html|css/gi, str => str.toUpperCase()); alert(result); // HTML and CSS استبدال كل تطابق بموقعه في النص: alert("Ho-Ho-ho".replace(/ho/gi, (match, offset) => offset)); // 0-3-6 في المثال التالي، ستجد قوسين مفتوحين في التعبير النمطي وبالتالي ستقبل الدالة خمسة وسطاء، الأول للتطابق بأكمله، ثم محتوى القوسين، وبعدهما (لم يستخدما في مثالنا) موقع التطابق والنص الأصلي: let str = "John Smith"; let result = str.replace(/(\w+) (\w+)/, (match, name, surname) => `${surname}, ${name}`); alert(result); // Smith, John يفضل استخدام التفكيك destruction عند وجود مجموعات عدة: let str = "John Smith"; let result = str.replace(/(\w+) (\w+)/, (...match) => `${match[2]}, ${match[1]}`); alert(result); // Smith, John عندما نستخدم المجموعات المسماة، فسيكون الكائن groups مع المجموعات دائمًا في الموقع الأخير، وبالتالي سنحصل عليها بالشكل التالي: let str = "John Smith"; let result = str.replace(/(?<name>\w+) (?<surname>\w+)/, (...match) => { let groups = match.pop(); return `${groups.surname}, ${groups.name}`; }); alert(result); // Smith, John سيتيح لنا استخدام دالة عند استبدال النصوص قدرةً كبيرةً، لأنها ستزودنا بكل المعلومات عن التطابق، ولها القدرة على الوصول إلى المتغيرات الخارجية، وتنفيذ أي شيء نريده. التابع (str.replaceAll(str|regexp, str|func وله وظيفة التابع str.replace نفسها، مع وجود اختلافين رئيسيين: سيستبدل كل التطابقات الموجودة إذا كان وسيطه الأول نصًا، بينما يستبدل replace التطابق الأول فقط. يعمل تمامًا مثل التابع replace إذا كان وسيطه الأول تعبيرًا نمطيًا والراية g مفعلةً، ويعطي خطأً إذا لم تكن كذلك. ويستخدم بشكل رئيسي عندما نريد استبدال جميع التطابقات، وإليك مثالًا: // استبدل كل الشرطات بنقطتين عموديتين alert('12-34-56'.replaceAll("-", ":")) // 12:34:56 التابع (regexp.exec(str يعيد هذا التابع تطابقًا مع نمط إذا وجده ضمن النص، وعلى خلاف التوابع السابقة سيُستدعى من قبل كائن تعبير نمطي regexp وليس من قبل نص str، ويسلك سلوكًا مختلفًا عند تفعيل الراية g أو عدم تفعيلها، فإذا لم تكن هذه الراية مفعلةً فسيعيد التطابق الأول فقط، تمامًا مثل التابع (str.match(regexp، ولن يقدم هذا السلوك أي جديد، ولكن مع وجود الراية g: سيعيد التطابق الأول، ويخزن الموقع الذي يلي التطابق مباشرةً ضمن الخاصية regexp.lastIndex. عندما يُستدعى مجددًا سيبدأ البحث انطلاقًا من الموقع المُخزّّن ضمن الخاصية regexp.lastIndex معيدًا التطابق التالي، وسيخزن الموقع الذي يليه مباشرةً ضمن الخاصية regexp.lastIndex محدّثًا قيمتها. وهكذا يستمر العمل. إذا لم يجد تطابقات فسيعيد القيمة null، ويسند القيمة 0 إلى الخاصية regexp.lastIndex. سيعيد الاستدعاء المتكرر لهذا التابع كل التطابقات كما شرحنا في الخطوات السابقة، بينما قبل وجود هذا التابع، كان لا بدّ من استخدام الحلقات للحصول على التطابقات جميعها: let str = 'More about JavaScript at https://javascript.info'; let regexp = /javascript/ig; let result; while (result = regexp.exec(str)) { alert( `Found ${result[0]} at position ${result.index}` ); // Found JavaScript at position 11, then // Found javascript at position 33 } سيعمل الأسلوب المتبع في المثال السابق أيضًا، على الرغم من أنّ استخدام التابع str.matchAll سيناسب المتصفحات الحديثة أكثر. يمكن استخدام التابع (regexp.exec(str للبحث انطلاقًا من موقع محدد بضبط قيمة الخاصية regexp.lastIndex يدويًا. وإليك مثالًا: let str = 'Hello, world!'; let regexp = /\w+/g; //lastIndex يتجاهل المحرك قيمة الخاصية "g" دون الراية regexp.lastIndex = 5; // البحث انطلاقًا من الموقع 5 alert( regexp.exec(str) ); // world يفرض وجود الراية y البحث في الموقع المحدد ضمن الخاصية regexp.lastIndex تمامًا، وليس بعده. لنستبدل الراية y بالراية g في المثال السابق، وسنلاحظ عدم وجود تطابقات: let str = 'Hello, world!'; let regexp = /\w+/y; regexp.lastIndex = 5; // search exactly at position 5 alert( regexp.exec(str) ); // null يناسب هذا الأمر الحالات التي نحتاج فيها إلى قراءة شيء ما من نص باستخدام التعابير النمطية انطلاقًا من موقع محدد تمامًا. التابع (regexp.test(str يتأكد هذا التابع من وجود تطابق، ويعيد إحدى القيمتين true/false، وإليك مثالًا: let str = "I love JavaScript"; // ينفذ الاختباران التاليان العمل نفسه alert( /love/i.test(str) ); // true alert( str.search(/love/i) != -1 ); // true مثال مع جواب سلبي: let str = "Bla-bla-bla"; alert( /love/i.test(str) ); // false alert( str.search(/love/i) != -1 ); // false في الحالة التي نفعل فيها الراية g، سيبحث التابع عن الخاصية regexp.lastIndex ويحدّث قيمتها، تمامًا مثل التابع regexp.exec، لذلك يمكن استخدامه للبحث في موقع محدد: let regexp = /love/gi; let str = "I love JavaScript"; // يبدأ البحث من الموقع 10 regexp.lastIndex = 10; alert( regexp.test(str) ); // false (لا تطابق) لاحظ أنه قد يخفق الاختبار المستمر لتعبير نمطي عام على نصوص مختلفة، لأن التابع regexp.exec يستدعي قيمًا متقدمةً للخاصية regexp.lastIndex، وبالتالي قد يبدأ البحث في نص آخر ابتداءً من موقع مختلف عن الصفر. لاحظ في هذا المثال كيف سنختبر النص ذاته مرتين متتاليتين، وسيخفق الاختبار الثاني: let regexp = /javascript/g; // (regexp just created: regexp.lastIndex=0) alert( regexp.test("javascript") ); // true (regexp.lastIndex=10 now) alert( regexp.test("javascript") ); // false للالتفاف على هذه المشكلة، يمكننا ضبط قيمة الخاصية regexp.lastIndexعلى الصفر قبل البدء بكل بحث، أو استخدام توابع النصوص، مثل .../str.match/search، بدلًا من توابع التعابير النمطية، فهي لا تستخدم الخاصية lastIndex. ترجمة -وبتصرف- للفصل Methods of RegExp and string من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: فهم التعقب التراجعي الكارثي في التعابير النمطية RegEx مطابقة عدة مجموعات نمطية في التعابير النمطية RegEx المحددات الكمية وأنماط استخدامها في التعابير النمطية المجموعات والمجالات في التعابير النمطية
  4. قد تبدو بعض التعابير النمطية regular expressions بسيطةً لكن قد يستغرق تنفيذها وقتًا طويلًا، وقد يسبب توقف محرك JavaScript عن الاستجابة، وسيواجه المطورون عاجلًا أم آجلًا هذا السلوك، ومن أعراضه توقف استجابة محرك تعبير نمطي يعمل جيدًا في بعض الأحيان، عندما يبحث ضمن نص معين مستهلكًا موارد المعالج 100%، حيث سيقترح المتصفح في حالة مثل هذه إيقاف تنفيذ السكربت، وإعادة تحميل الصفحة، وليس جيدًا بالطبع أن يُوقف سكربت JavaScript يعمل في الواجهة الخلفية استجابة عملية من عمليات الخادم، فلا بد إذًا من إلقاء نظرة على ذلك. مشكلة انتظار انتهاء التعبير النمطي لنفترض وجود نص نريد أن نتحقق من كونه يتألف من كلمات +w\ يفصل بينها فراغات اختيارية ?s\، حيث ستكون إحدى الطرق الواضحة إنشاء تعبير نمطي يبحث عن كلمة يليها فراغ اختياري ?w+\s\، وأخيرًا نضيف المحدد الكمي * لتكرار العملية، ويقود هذا التعبير إلى استخدام التعبير $*(?w+\s\)^ الذي يبحث عن كلمة على الأقل بالمواصفات السابقة، بحيث يبدأ البحث من بداية النص ^ وينتهي بنهايته $. let regexp = /^(\w+\s?)*$/; alert( regexp.test("A good string") ); // true ناجح alert( regexp.test("Bad characters: $@#") ); // false فاشل يبدو أنّ التعبير سيعمل والنتيجة صحيحة، لكنه في نصوص معينة سيستغرق وقتًا طويلًا حتى تتوقف استجابة محرك JavaScript، وتُستهلك موارد المعالج 100%. قد لا تلاحظ شيئًا إن نفّذت المثال التالي، لأن محرك JavaScript سيتوقف عن الاستجابة، وسيتوقف المتصفح عن التجاوب مع الأحداث، وستتوقف واجهة المستخدم عن العمل (تتيح معظم المتصفحات ميزة التمرير فقط)، وسيقترح المتصفح بعد فترة إعادة تحميل الصفحة، فكن على حذر. let regexp = /^(\w+\s?)*$/; let str = "An input string that takes a long time or even makes this regexp hang!"; // سيأخذ بعض الوقت alert( regexp.test(str) ); وعلينا القول -حتى نكون منصفين- بأن بعض محركات التعابير النمطية تتعامل مع هذا النوع من البحث بفعالية، فالمحرك "V8" وابتداءً من النسخة 8.8 قادر على ذلك، فلن تتوقف استجابة المتصفح 88 Chrome في حالات مثل هذه، بينما ستتوقف استجابة متصفح Firefox. السؤال الذي طرح نفسه، ما المشكلة؟ لماذا تتوقف استجابة التعبير النمطي؟ لتوضيح ذلك دعونا نبسّط المثال السابق بإزالة الفراغات ?S\، وبالتالي سيصبح التعبير النمطي على الشكل $*(?w+\s\)^، ولتوضيح الأمر أكثر دعونا نستبدل الصنف d\ بالصنف w\، وستتوقف مع ذلك استجابة التعبير الجديد أيضًا، فمثلًا: let regexp = /^(\d+)*$/; let str = "012345678901234567890123456789z"; // انتبه، سيأخذ بعض الوقت alert( regexp.test(str) ); ما المشكلة في هذا التعبير النمطي؟ قد يلاحظ القارئ أنّ التعبير *(+d\) غريب بعض الشيء، فوجود المحدد الكمي * يبدو مبالغًا فيه، فإن أردنا عددًا يمكن استخدام d\، ومع ذلك يبدو التعبير الجديد المبسط عمليًا أكثر، لكن سبب بطئه أيضًا لم يتغير، لهذا علينا دراسته بالتفصيل للوقوف على المشكلة، فما الذي يحدث أثناء البحث عن النمط $*(+d\)^ ضمن النص 123456789z، واختُصر قليلًا للوضوح، ولماذا يستغرق الأمر وقتًا؟ إليك ما يفعله المحرك: أولًا، يحاول المحرك بدايةً البحث عن محتوى الأقواس، وهي الأعداد +d\، وطالما أنّ + محدد كمي جشع greedy افتراضيًا فسيضم كل الأرقام في النص. \d+....... (123456789)z عند ضم الأرقام جميعها يعدُّ المحرك أن البحث عن +d\ قد أنجز، وأن النتيجة هي 123456789، ثم ينتقل بعد ذلك إلى تطبيق المحدد الكمي *، لكن الأرقام في النص قد استهلكت جميعها، فلن يقدم مرتكز البداية ^ أي شيء، ثم يبحث المحرك عن آخر محارف النمط $، ولن يجده لأنّ المحرف الباقي من النص هو z: X \d+........$ (123456789)z ثانيًا، وطالما أنّ التطابق غير موجود فسينقص المُكمِّم + عدد المحارف واحدًا ويعيد البحث، لذلك ستكون نتيجة +d\ كل الأرقام عدا الأخير 12345678: \d+....... (12345678)9z ثالثًا، يحاول المحرك الآن البحث في الموقع التالي بعد 12345678، وعندها يمكن تطبيق المكمِّم *، وسيعطي النمط *(+d\) تطابقًا جديدًا وهو 9. \d+.......\d+ (12345678)(9)z ثم يحاول المحرك من جديد إيجاد آخر محرف من النمط $ فلن يجده، بل سيجد المحرف الباقي من النص، وهو z: X \d+.......\d+ (12345678)(9)z رابعًا، لن يحصل المحرك على التطابق المطلوب؛ وسيستمر في العودة والتعقب مخفضًا عدد التكرارات وهذا ما يسمى بعملية التعقب التراجعي Backtracking أو التراجع والمطابقة ببساطة، وتجري عملية التعقب التراجعي عادةً بالشكل التالي: يقلل آخر محدد كمي جشع عدد التكرارات حتى يصل إلى الحد الأدنى، ثم يأتي دور المحدد الكمي الجشع الذي يسبقه في إنقاص عدد التكرارات وهكذا، إلى أن يتقصى المحرك كل الحالات الممكنة، وإليك بعض الأمثلة عن هذه الحالات: العدد الأول مؤلف من 7 أرقام، ثم عدد برقمين: X \d+......\d+ (1234567)(89)z العدد الأول من 7 أرقام، ثم عددين كل منهما مكون من رقم واحد: X \d+......\d+\d+ (1234567)(8)(9)z العدد الأول من 6 أرقام، والثاني من ثلاثة: X \d+.......\d+ (123456)(789)z العدد الأول من 6 أرقام، يليه عددان آخران: X \d+.....\d+ \d+ (123456)(78)(9)z ويوجد عدد كبير من الاحتمالات التي نفصل فيها سلسلةً من الأرقام 123456789 إلى أعداد، ولنكون أكثر دقة توجد ‎2<sup>n</sup>-1 طريقة، حيث n هو طول سلسلة الأرقام، ففي حالة 9 أرقام -كما في حالتنا- لدينا 511 احتمال، أما في حالة 20 رقمًا فلدينا 1048575 احتمال، وبالتالي سيسبب مرور المحرك بهذه الحالات التأخير. العودة إلى الكلمات والنصوص يحدث الأمر ذاته كما في مثالنا الأول، عندما بحثنا عن كلمات باستخدام النمط $*(?w+\s\)^ ضمن النص التالي: An input that hangs!‎ والسبب طبعًا أن الكلمة +w\ قد تُمثَّل بعدد كبير من الحالات: (input) (inpu)(t) (inp)(u)(t) (in)(p)(ut) ... قد يكون عدم وجود التطابق واضحًا، لأن النص ينتهي بإشارة تعجب، لكن ما يتوقعه التعبير النمطي هو محرف كلمة w\ أو فراغ s\ في النهاية، وهذا ما لا يعرفه المحرك، إذ سيبحث عن كل الحالات التي يحتمل أن تطابق فيها النمط *(?w+\s\) كل محارف النص، بما في ذلك الحالات التي تضم الفراغ *(w+\s\) أو التي لا تضمها *(+w\)، لأن النمط ?s\ اختياري، وسيستغرق وقتًا طويلًا نظرًا لوجود عدد كبير من الحالات التي سيستكشفها المحرك، فما العمل؟ هل علينا تفعيل البحث الكسول lazy mode؟ لن يساعدنا ذلك لسوء الحظ، ستتوقف الاستجابة أيضًا إذا استبدلنا النمط ?+w\ بالنمط +w\، وسيتغير ترتيب الحالات التي سيبحث فيها المحرك فقط، وليس عددها. تتجنب بعض محركات التعبير النمطي المرور على كل الحالات من خلال بعض الاختبارات، أو استخدام وسائل أتمتة محدودة، أو قد تجعل العملية أكثر سرعةً، ومع ذلك لا تتبع معظم المتصفحات هذه الأساليب، كما أنها لا تساعد دومًا. ما هو الحل؟ توجد مقاربتان لحل المشكلة، الأولى تخفيض عدد الحالات الممكنة، فمثلًا لنجعل المساحة الفارغة إجباريةً، بجعل النمط بالشكل التالي $*w+\s)*\w\)^، أي سنبحث عن أي عدد من الكلمات التي يفصل بينها فراغ، عدا الكلمة الأخيرة فستكون اختيارية w\*، سينتهي البحث سواء وجدت أم لا، انظر إلى التعبير التالي المكافئ للسابق (يحصل على التطابقات نفسها) ويعمل جيدًا: let regexp = /^(\w+\s)*\w*$/; let str = "An input string that takes a long time or even makes this regex hang!"; alert( regexp.test(str) ); // false لماذا اختفت المشكلة؟ لأن الفراغ بين الكلمات أصبح إجباريًا، فلو حذفنا الفراغ في التعبير السابق فسيقود إلى عدد أكبر من حالات +w\ ضمن الكلمة ذاتها، إذ يمكن الحصول على الكلمة input من تكرارين +w\ بالشكل التالي: \w+ \w+ (inp)(ut) لكن النمط الجديد مختلف، فالكلمة متبوعة بفراغ حتمًا *(w+\s\)، وبالتالي لن نحصل على الكلمة من خلال تكرارين للنمط w+\s\، وبهذا لن يهدر المزيد من الوقت في البحث عن كل الحالات الممكنة للحصول على كلمة. منع التعقب التراجعي في التعابير النمطية لن تساعدنا إعادة كتابة النمط دائمًا، إذ كانت العملية سهلةً وواضحةً في المثال السابق، لكنها عادةً ليست كذلك، كما ستقود إعادة كتابة النمط إلى أنماط أكثر تعقيدًا، وهذا أمر سيء، فالتعابير النمطية معقدة بطبيعتها، لحسن الحظ توجد مقاربة بديلة تقتضي منع التعقب التراجعي backtracking للمحدد الكمي، فأصل المشكلة هو تجربة المحرًك للكثير من الحالات الخاطئة -من وجهة نظرنا طبعًا-، فمن الواضح أنّ تعقب + في النمط $*(+d\) سيسبب مشكلةً، ولن يتغير شيء إن بدّلنا النمط +d+\d\ بالنمط +d\: \d+........ (123456789)! \d+...\d+.... (1234)(56789)! وقد نرغب في مثالنا الأصلي $*(?w+\s\)^ بمنع تعقب +w\، لأنها من المفترض أن تبحث عن كلمة كاملة بأكبر طول ممكن، ولا حاجة لتخفيض عدد التكرارات، أو فصلها إلى كلمتين +w+\w\ وهكذا. تدعم محركات التعابير النمطية الحديثة المحددات الكمية الاستحواذية possessive quantifiers عن طريق إضافة الإشارة + بعد المحدد الكمي، أي نضع ++d\ بدلًا من +d\، وذلك لمنعه من الوقوع في فخ التعقب التراجعي، فالمحددات الكمية الاستحواذية أبسط من النظامية، حيث تطابق ما تستطيع من المحارف دون الوقوع في التعقب التراجعي، وسيكون البحث آنذاك أبسط. كما يوجد ما يُسمى "المجموعات الذرية الملتقطة" atomic capturing groups، وهو وسيلة لمنع التعقب التراجعي ضمن الأقواس، والخبر السيئ هو أنها غير مدعومة في JavaScript، لكن يمكن تقليدها باستخدام شرط التحقق مما يلي المطابقة lookahead transform. البحث عن الخلاص لقد وصلنا إلى موضوع متقدم فعلًا، إذ نريد منع المحددات الكمية -مثل +- من التعقب التراجعي، لأن تعقب بعض الأمور غير منطقي على الإطلاق. إنّ النمط الذي يأخذ أكبر عدد ممكن من تكرارات w\ دون تعقب تراجعي هو 1\((+w\)=?)، وبالطبع يمكن اختيار أي نمط بدل w\، وقد يبدو النمط غريبًا، لكنه في الواقع تحويل بسيط، لنصفه: سيبحث نمط البحث قُدُمًا =? عن أطول كلمة +w\ ابتداءً من الموقع الحالي. لن يتذكر المحرك محتوى ما بين القوسين المسبوق بالمحارف =?، لذلك وضعنا +w\ ضمن أقواس، ثم سيتذكر المحرك محتوى القوسين التاليين. ثم نشير إلى الأقواس الخارجية بالرقم 1. سيتقدم البحث إلى الأمام وعند وجود كلمة +w\ فسيحددها بالرقم 1\، وبكذا سنكون قد صممنا محددًا كميًا استحواذيًا من المحدد الكمي +، حيث يلتقط الكلمة +w\ كاملةً فقط، وليس جزءًا منها، فيمكن مثلًا الحصول على الكلمة Java من الكلمة JavaScript، وترك الكلمة Script لتتطابق مع بقية النمط، وإليك موازنةً بين نمطين: alert( "JavaScript".match(/\w+Script/)); // JavaScript alert( "JavaScript".match(/(?=(\w+))\1Script/)); // null في الحالة الأولى: سنحصل على الكلمة كاملةً، لكن المحدد الكمي سيتعقب بقية النمط متراجعًا محرفًا محرفًا، محاولًا إيجاد بقية النمط، ثم سينجح أخيرًا، عندما يتطابق النمط +w\ الكلمة Java. في الحالة الثانية: سيجري البحث قُدمًا وسيجد الكلمة JavaScript كاملةً، وسيحددها بالرقم 1، وبالتالي لا طريقة بعد ذلك لإيجاد الكلمة Script. يمكن استخدام تعابير نمطية أكثر تعقيدًا من w\ ضمن 1\((+w\)=?) عندما نريد منع التعقب التراجعي للمحدد الكمي +. لنكتب مثالنا الأول باستخدام التحقق مما يلي التطابق لمنع التعقب التراجعي: let regexp = /^((?=(\w+))\2\s?)*$/; alert( regexp.test("A good string") ); // true let str = "An input string that takes a long time or even makes this regex hang!"; alert( regexp.test(str) ); // false, يعمل وبسرعة وضعنا 2\ بدلًا من الرقم 1\ لوجود أقواس خارجية إضافية، كما يمكننا تسمية الأقواس أيضًا (+<word>\w>?). // ?<word>وتُسمى الأقواس كالتالي, \k<word>يشار إلى الأقواس كالتالي let regexp = /^((?=(?<word>\w+))\k<word>\s?)*$/; let str = "An input string that takes a long time or even makes this regex hang!"; alert( regexp.test(str) ); // false alert( regexp.test("A correct string") ); // true خلاصة تُسمى المشكلة التي وصفناها في هذا المقال بالتعقب التراجعي الكارثي، وغطينا طريقتين لحلها: تخفيض عدد الحالات الممكنة التي تتطابق مع نمط إلى الحد الأدنى. منع التعقب التراجعي. ترجمة -وبتصرف- للفصل Catastrophic backtracking من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا أساسيات البحث باستخدام التعابير النمطية في جافاسكربت المجموعات والمجالات في التعابير النمطية التعابير النمطية (regexp/PCRE) في PHP
  5. المجموعة الملتقطة capturing group هي الجزء الذي يضم محارف بين قوسين (...) في أي تعبيير نمطي RegEx، ولها تأثيران اثنان: تسمح بالحصول على جزء من التطابق مثل عنصر مستقل ضمن مصفوفة النتائج. يُطبق المحدد الكمي quantifier على مجموعة الالتقاط كلها إذا وضع بعد القوسين مباشرةً. أمثلة عن مطابقة عدة مجموعات لنتعرف كيفية عمل الأقواس من خلال الأمثلة. مثال gogogo يفيد النمط +go دون أقواس في إيجاد المحرف g يليه المحرف o مكررًا مرةً أو أكثر، مثل goooo أو goooooo، وإذا وضعنا محارف النمط السابق بين قوسين +(go)، فسيعني ذلك go أو gogo أو gogogo وهكذا. alert( 'Gogogo now!'.match(/(go)+/ig) ); // "Gogogo" مثال نطاق موقع ويب نحتاج إلى تعبير نمطي للبحث عن نطاق موقع ويب، مثل النطاقات التالية: mail.com users.mail.com smith.users.mail.com يتألف النطاق من كلمات متتالية تفصل بينها نقاط، ويقابل ذلك التعبير النمطي +w+\.)+\w\): let regexp = /(\w+\.)+\w+/g; alert( "site.com my.site.com".match(regexp) ); // site.com,my.site.com سيعمل النمط السابق، لكنه سيواجه مشكلةً عندما تحتوي الكلمات على شرطة قصيرة -، مثل my-site.com، فلا يعتبر هذا المحرف من محارف الصنف w\، وسنحل هذه المشكلة باستبدال التعبير w\ بالمجموعة [-w\]، في كل كلمة عدا الأخيرة، وسيصبح النمط بالشكل +w-]+\.)+\w\]). مثال البريد الإلكتروني يمكن توسيع المثال السابق لإنشاء تعبير نمطي لبريد إلكتروني اعتمادًا على النمط السابق، وما دام للبريد الإلكتروني الشكل name@domain، فيمكن أن تكون أي كلمة هي الاسم، حيث يُسمح ضمنها بالشرطة القصيرة أو النقاط، وبلغة التعبير النمطي ستكون [.-w\]، وسيكون التعبير النمطي للبريد الإلكتروني /+[-w.-]+@([\w-]+\.)+[\w\]/ let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g; alert("my@mail.com @ his@site.com.uk".match(regexp)); // my@mail.com, his@site.com.uk ليس هذا التعبير مثاليًا، لكنه سيعمل في معظم الأحيان، وسيساعدك في التخلص من الأخطاء الكتابية، وتأكد أن الطريقة الحقيقية الوحيدة للتحقق من بريد هو استلامه للرسالة التي أرسلتها! المحتوى الموجود بين قوسين عند البحث عن تطابق تُرقّم الأقواس من اليسار إلى اليمين، وسيتذكر المحرك المحتوى الذي يتطابق مع كل قوس، ويسمح بالحصول على هذه التطابقات ضمن النتيجة، ويبحث التابع (str.match(regexp عن التطابق الأول، ويعيد النتيجة في مصفوفة عندما لا تُستخدم الراية g ضمن التعبير regexp، حيث ستجد ضمن المصفوفة: التطابق بشكله الكامل في الموقع 0. محتوى القوس الأول في الموقع 1. محتوى القوس الثاني في الموقع 2. وهكذا… فلو أردنا مثلًا البحث عن وسوم HTML من النمط <?*.>، ثم معالجة النتائج، فمن المناسب أن نحصل على محتوى كل وسم ضمن متغير خاص به، وعندما نغلّف المحتوى الداخلي للوسم ضمن قوسين، بالشكل التالي <(?*.)>، فسنحصل على الوسم كاملًا، وليكن <h1>، وعلى محتوى هذا الوسم (أي النص h1) ضمن النتيجة: let str = '<h1>Hello, world!</h1>'; let tag = str.match(/<(.*?)>/); alert( tag[0] ); // <h1> alert( tag[1] ); // h1 المجموعات المتداخلة يمكن أن تتداخل الأقواس، وعندها ستُرقَّم أيضًا من اليسار إلى اليمين، فعندما نبحث عن وسم ضمن الوسم <span class="my"> مثلًا، فلربما نريد الحصول على: محتوى الوسم كاملًا "span class="my. اسم الوسم: span. سمات الوسم: "class="my. لنضف الأقواس إلى النمط <(([a-z]+)\s*([^>]*))>، لاحظ كيف تُرقَّم الأقواس من اليسار إلى اليمين: let str = '<span class="my">'; let regexp = /<(([a-z]+)\s*([^>]*))>/; let result = str.match(regexp); alert(result[0]); // <span class="my"> alert(result[1]); // span class="my" alert(result[2]); // span alert(result[3]); // class="my" سنجد دائمًا التطابق الكامل في الموقع صفر من المصفوفة، ثم المجموعات مرقمةً من اليسار إلى اليمين بواسطة القوس المفتوح، حيث تُعاد المجموعة الأولى في الموقع الأول [result[1، تليها الثانية الناتجة عن القوس المفتوح الثاني (+[a-z]) ضمن [result[2، ثم نتيجة التطابق مع النمط (‎[^>]*‎) ضمن [result[3، وستكون نتيجة كل مجموعة بصيغة نص. المجموعات الاختيارية حتى لو كانت المجموعة اختياريةً وغير موجودة ضمن التطابق، كأن يكون لها المحدد الكمي ?(...)، فسيبقى مكانها محجوزًا ضمن المصفوفة، وقيمته هي undefined، فلو تأملنا مثلًا التعبير ?(a(z)?(c، فسنجد أنه يبحث عن "a" متبوعًا -اختياريًا- بالحرف "z"، ومتبوعًا -اختياريًا أيضًا- بالحرف "c"، لأن المحدد الكمي ? يعني محرفًا أو لا شيء، فلو طبّقنا التعبير السابق على نص مكون من الحرف a فقط، فستكون النتيجة: let match = 'a'.match(/a(z)?(c)?/); alert( match.length ); // 3 alert( match[0] ); // a (whole match) alert( match[1] ); // undefined alert( match[2] ); // undefined سيكون طول المصفوفة 3 علمًا أن كل المجموعات فارغة! لكن لو كان النص هو ac: let match = 'ac'.match(/a(z)?(c)?/) alert( match.length ); // 3 alert( match[0] ); // ac (whole match) alert( match[1] ); // undefined, because there's nothing for (z)? alert( match[2] ); // c سيبقى طول المصفوفة 3، لكنك لن تجد تطابقًا يقابل المجموعة (z)?، وستكون النتيجة ["ac", undefined, "c"]. البحث عن كل التطابقات ضمن المجموعات: التابع matchAll لن يعيد التابع match محتوى المجموعات إذا استخدم للبحث بوجود الراية g، والتي تعني إيجاد كل التطابقات، وسنحاول في المثال التالي إيجاد كل الوسوم في النص: let str = '<h1> <h2>'; let tags = str.match(/<(.*?)>/g); alert( tags ); // <h1>,<h2> لاحظ أن النتيجة هي مصفوفة تحتوي على التطابقات كاملةً لكن دون تفاصيل، أي دون محتوى كل تطابق، لكننا نحتاج عمليًا إلى ذلك المحتوى، وسيساعدنا البحث باستخدام التابع (str.matchAll(regexp على استخلاص ذلك المحتوى، فقد أضيف هذا التابع إلى JavaScript بعد فترة طويلة من إضافة match مثل نسخة جديدة ومحسنة منه. يشابه matchAll التابع match، مع وجود ثلاثة اختلافات، وهي: لا يعيد مصفوفةً، بل كائنًا قابلًا للتكرار iterable object. يعيد كل تطابق مثل مصفوفة تحتوي مجموعات عند استخدام الراية g. عندما لا يجد تطابقات فلن يعيد null، بل كائنًا فارغًا قابلًا للتكرار. إليك مثالًا: let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi); // النتائج ليست مصفوفة بل كائن قابل للتعداد alert(results); // [object RegExp String Iterator] alert(results[0]); // undefined (*) results = Array.from(results); // تحويل النتيجة إلى مصفوفة عادية alert(results[0]); // <h1>,h1 (1st tag) alert(results[1]); // <h2>,h2 (2nd tag) إنّ الاختلاف الأول مهم جدًا كما يوضّحه السطر "(*)"، فلا يمكن الحصول على التطابق في الموقع [results[0، لأن الكائن لا يمثل مصفوفةً زائفةً pseudoarray، ويمكننا تحويلها إلى مصفوفة حقيقية باستخدام Array.from، وستجد العديد من التفاصيل عن المصفوفات الزائفة والكائنات القابلة للتكرار في المقال Iterables. لا حاجة لتحويل المصفوفة باستخدام Array.from إذا كنا سنشكل حلقةً من النتائج: let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi); for(let result of results) { alert(result); // first alert: <h1>,h1 // second: <h2>,h2 } أو عند استخدام التفكيك destructuring: let [tag1, tag2] = '<h1> <h2>'.matchAll(/<(.*?)>/gi); يشابه تنسيق كل تطابق يعيده التابع matchAll التنسيق الذي يعيده match دون الراية g، وهذا التنسيق هو مصفوفة مع خصائص إضافية index التي تطابق الفهرس في النص، وinput الذي يعني النص الأصلي: let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi); let [tag1, tag2] = results; alert( tag1[0] ); // <h1> alert( tag1[1] ); // h1 alert( tag1.index ); // 0 alert( tag1.input ); // <h1> <h2> انتبه، لماذا ستكون نتيجة التابع كائنًا قابلًا للتكرار وليس مصفوفةً؟ السبب بسيط وهو التحسين، فلن يُنفِّذ الاستدعاء عملية البحث، بل سيعيد كائنًا قابلًا للتكرار لا يحتوي على النتيجة مبدئيًا، ويُنفَّذ البحث في كل مرة نكرره (ضمن حلقة مثلًا)، وبالتالي سيجد العدد المطلوب من النتائج تمامًا، فإذا كان من المحتمل مثلًا وجود 100 تطابق، لكننا وجدنا في حلقة for..of خمسةً فقط، وقررنا أن هذا كاف وأوقفنا الحلقة، فلن يستهلك المحرك وقتًا إضافيًا في إيجاد التطابقات 95 الباقية. المجموعات المسماة يصعب تذكر المجموعات بأرقامها، على الرغم من بساطته في الأنماط البسيطة، لكن عند البحث عن أنماط أكثر تعقيدًا فلن يكون ترقيم الأقواس أمرًا مناسبًا، وسنجد أن خيار تسمية الأقواس هو الأفضل، ونسمي الأقواس بوضع الاسم بالشكل التالي <name>? مباشرةً بعد القوس، ولنبحث مثلًا عن تاريخ وفق التنسيق "يوم-شهر-سنة": let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/; let str = "2019-04-30"; let groups = str.match(dateRegexp).groups; alert(groups.year); // 2019 alert(groups.month); // 04 alert(groups.day); // 30 حيث سنجد المجموعات عبر الخاصية groups. لكل تطابق، ويمكن إيجاد جميع التواريخ باستخدام الراية g، كما ينبغي استخدام التابع matchAll للحصول على التطابق كاملًا بالإضافة إلى المجموعات: let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g; let str = "2019-10-30 2020-01-01"; let results = str.matchAll(dateRegexp); for(let result of results) { let {year, month, day} = result.groups; alert(`${day}.${month}.${year}`); // first alert: 30.10.2019 // second: 01.01.2020 } مطابقة مجموعات ثم تنفيذ عملية استبدال يسمح التابع (str.replace(regexp, replacement الذي يستبدل محتوى الأقواس ضمن النص replacement، بكل التطابقات regexp التي يجدها في النص str، وينفذ ذلك باستخدام الرمز n$، حيث n هو رقم المجموعة، وإليك مثالًا: let str = "John Bull"; let regexp = /(\w+) (\w+)/; alert( str.replace(regexp, '$2, $1') ); // Bull, John ستتغير العملية باستخدام <name>$ في الأقواس المسماة، ولتغيير تنسيق التاريخ مثلًا من "يوم -شهر-سنة" إلى "سنة.شهر.يوم": let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g; let str = "2019-10-30, 2020-01-01"; alert( str.replace(regexp, '$<day>.$<month>.$<year>') ); // 30.10.2019, 01.01.2020 استثناء التقاط المجموعات باستخدام :? نحتاج أحيانًا إلى الأقواس لتطبيق المحددات الكمية بطريقة صحيحة، لكننا لا نحتاج إلى محتواها ضمن النتائج، لذلك يمكن استثناء المجموعات باستخدام :? في البداية، فإذا أردنا مثلًا إيجاد النمط +(go)، لكننا لا نحتاج إلى محتوى الأقواس (go) في عنصر مصفوفة مستقل، فيمكننا صياغة النمط بالشكل `+(go?:)، وإليك مثالًا: let str = "Gogogo John!"; // ?: exludes 'go' from capturing let regexp = /(?:go)+ (\w+)/i; let result = str.match(regexp); alert( result[0] ); // Gogogo John (تطابق كامل) alert( result[1] ); // John alert( result.length ); // 2 (لا مزيد من العناصر ضمن المصفوفة) الخلاصة تُجمِّع الأقواس أجزاءً من التعبير النمطي ليُطبَّق المحدد الكمي عليها مثل زمرة واحدة. تُرقَّم أقواس المجموعات من اليسار إلى اليمين، كما يمُكن أن تُسمّى اختياريًا باستخدام النمط (...<name>?). يمكن الحصول على المحتوى الموجود داخل الأقواس -الذي يحقق التطابق- بصورة مستقلة ضمن النتيجة، حيث: يعيد التابع str.match المجموعة الملتقطة عند استخدام الراية فقط. يعيد التابع str.matchAll المجموعات الملتقطة دومًا. إذا لم تُسمَّ الأقواس فسنحصل على محتوياتها ضمن المصفوفة وفقًا لتسلسل ترقيمها، كما يمكن الحصول على محتويات الأقواس المسماة من خلال الخاصية groups.. يمكن استخدام محتويات الأقواس في النص البديل للتابع، إما عبر أرقامها من خلال n$، أو أسمائها من خلال <name>$. يمكن استثناء مجموعة من الترقيم باستخدام النمط :? قبلها، وذلك عندما نريد تطبيق مُحصٍ quantifier على كامل المجموعة، لكننا لا نريد أن تظهر محتويات المجموعة -الموجودة بين قوسين- في عنصر مستقل ضمن مصفوفة النتيجة، كما لا يمكن الإشارة إلى هذه الأقواس عند استخدام تابع الاستبدال. مهام لإنجازها تحقق من عنوان MAC يتكون عنوان MAC لواجهة اتصال مع الشبكات من 6 أرقام ست عشرية ذات خانتين تفصل بينها نقطتان ":"، مثل العنوان التالي '01:32:54:67:89:AB'، اكتب تعبيرًا نظاميًا يتحقق من أن النص هو عنوان MAC. let regexp = /your regexp/; alert( regexp.test('01:32:54:67:89:AB') ); // true alert( regexp.test('0132546789AB') ); // false (no colons) alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, must be 6) alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ at the end) الحل: يُعطى التعبير النمطي الذي يبحث عن عدد ست عشري من خانتين بالشكل: {2}[‎0-9a-f] مفترضين استخدام الراية i. سنحتاج الآن إلى هذا النمط وخمسة أنماط أخرى مشابهة له، وبالتالي سيكون التعبير النمطي على الشكل: [0-9a-f]{2}(:[0-9a-f]{2}){5} ولكي نجبر التعبير على التقاط كامل النص الموافق، لابد من وضع محرفي ارتكاز البداية والنهاية $...^ إليك شيفرة الحل بشكلها الكامل: let regexp = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i; alert( regexp.test('01:32:54:67:89:AB') ); // true alert( regexp.test('0132546789AB') ); // false (no colons) alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, need 6) alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ in the end) أوجد الألوان التي تنسّق بالشكل abc# أو abcdef اكتب تعبيرًا نمطيًا يبحث عن الألوان المكتوبة وفق أحد التنسيقين abc# أو abcdef#، أي المحرف # يليه ثلاث أو ست أرقام ست عشرية. let regexp = /your regexp/g; let str = "color: #3f3; background-color: #AA00ef; and: #abcd"; alert( str.match(regexp) ); // #3f3 #AA00ef لاحظ أنه لا ينبغي الحصول على تطابقات تحوي أربع أو خمس أرقام ست عشرية، بل 3 أو 6 فقط. الحل: سيكون التعبير النمطي المناسب للبحث عن شيفرة لون ثلاثية الأرقام كالتالي: /#[a-f0-9]{3}/i يمكننا أيضًا إضافة ثلاث أرقام ست عشرية أخرى بالضبط فلن نحتاج أكثر أو أقل، لكون الشيفرة اللونية مزيج من ثلاث أو ستة أرقام. لنستخدم إذًا المكمم {1,2} بعد وضع الصيغة السابقة للتعبير النمطي بين قوسين: /#([a-f0-9]{3}){1,2}/i لاحظ الشيفرة: let regexp = /#([a-f0-9]{3}){1,2}/gi; let str = "color: #3f3; background-color: #AA00ef; and: #abcd"; alert( str.match(regexp) ); // #3f3 #AA00ef #abc تواجهنا مشكلة صغيرة هنا، فسيجد التعبير النمطي المحارف abc# ضمن abcd#. يمكن وضع النمط b\ في النهاية لحل المشكلة: let regexp = /#([a-f0-9]{3}){1,2}\b/gi; let str = "color: #3f3; background-color: #AA00ef; and: #abcd"; alert( str.match(regexp) ); // #3f3 #AA00ef أوجد كل الأرقام اكتب تعبيرًا نمطيًا يبحث عن كل الأعداد العشرية، بما فيها الصحيحة والعشرية ذات الفاصلة العائمة أو السالبة. let regexp = /your regexp/g; let str = "-1.5 0 2 -123.4."; alert( str.match(regexp) ); // -1.5, 0, 2, -123.4 الحل: يُعطى نمط العدد العشري الموجب بوجود القسم العشري منه على الشكل: \d+(\.\d+)? لنضع الإشارة السالبة - لتكون اختيارية في بداية النمط: let regexp = /-?\d+(\.\d+)?/g; let str = "-1.5 0 2 -123.4."; alert( str.match(regexp) ); // -1.5, 0, 2, -123.4 فسر العبارات الرياضية تتكون العملية الحسابية من عددين بينهما إشارة عمليات، مثل: 1 + 2 1.2 * 3.4 -3 / -6 -2 - 2 قد تكون العملية "+"أو "-" أو "*" أو "/"، وقد توجد مساحات فارغة قبل أو بعد أو بين الأجزاء. أنشئ تابعًا (parse(expr يقبل العبارة معاملًا، ويعيد مصفوفةً من ثلاثة عناصر، وهي: العدد الأول. العامل الرياضي (إشارة العملية). العدد الثاني. let [a, op, b] = parse("1.2 * 3.4"); alert(a); // 1.2 alert(op); // * alert(b); // 3.4 الحل: يُعطى التعبير النمطي لإيجاد عدد كما رأينا في المهمة السابقة كالتالي: -?\d+(\.\d+)? تُعطى العملية الرياضية وفق النمط التالي [/*+-] ولابد من وضع المحرف - في بداية الأقواس المربعة لأنها ستعنى مجالًا من المحارف إن وضعت في المنتصف ونحن نريد فقط المحرف - بحد ذاته. لايد أيضًا من تجاوز المحرف /في التعبير النمطي كالتالي /.../ وهذا ما سنفعله لاحقًا. نحتاج إلى عدد ثم عملية ثم عدد آخر وقد تكون هناك مساحات فارغة اختيارية بينهم وبالتالي سيكون التعبير النمطي كاملًا: -?\d+(\.\d+)?\s*[-+*/]\s*-?\d+(\.\d+)? يتكون التعبير النمطي من ثلاثة أقسام يفصل بينها ‎s*‎\: /-?\d+(\.\d+)?/ // العدد الأول /[-+*/],/ // رمز العملية /-?\d+(\.\d+)?/ // العدد الثاني يمثل القسم الأول العدد الأول والقسم الثاني العملية الحسابية والثالث العدد الثاني، ولكي يظهر كل قسم كنتيجة مستقلة ضمن مصفوفة النتيجة، سنضع كل قسم ضمن قوسين: /(-?\d+(\.\d+)?)\s*([-+*/])\s*(-?\d+(\.\d+)?)/ إليك الشيفرة: let regexp = /(-?\d+(\.\d+)?)\s*([-+*\/])\s*(-?\d+(\.\d+)?)/; alert( "1.2 + 12".match(regexp) ); تتضمن النتائج: التطابق كاملًا : "result[0] == "1.2 + 12 المجموعة الأولى (‎-?\d+(\.\d+)?‎) وتمثل العدد الأول مع أجزائه العشرية: "1.2" المجموعة الثانية ‎(\.\d+)?‎ وتمثل الجزء العشري الأول: "‎.2" المجموعة الثالثة ‎([-+*\/])‎ وتمثل العملية الحسابية: "+" المجموعة الرابعة ‎(-?\d+(\.\d+)?)‎ وتمثل العدد الثاني: "12" المجموعة الخامسة ‎(\.\d+)?‎ والتي تمثل الجزء العشري من العدد الثاني وهو في الواقع غير موجود undefined نحتاج في الواقع إلى العددين والعملية الحسابية دون الأجزاء العشرية لذلك سنجعل التعبير أكثر وضوحًا ليلائم ما نريده. سنزيل العنصر الأول من المصفوفة والذي يمثل التطابق الكامل بإجراء انزياح لمصفوفة النتيجة array.shift. يمكن التخلص من الأجزاء العشرية (‎.\d+‎) في المجموعة الثانية والرابعة (أي النقط 3 و 4) من المصفوفة بوضع المحرف ? في بداية كل مجموعة. إليك الحل النهائي: function parse(expr) { let regexp = /(-?\d+(?:\.\d+)?)\s*([-+*\/])\s*(-?\d+(?:\.\d+)?)/; let result = expr.match(regexp); if (!result) return []; result.shift(); return result; } alert( parse("-1.23 * 3.45") ); // -1.23, *, 3.45 ترجمة -وبتصرف- للفصل Capturing Groups من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: المحددات الكمية وأنماط استخدامها في التعابير النمطية مقدمة في التعابير النمطية Regular Expressions التعبيرات النمطية Regular expressions في Cpp التعابير النمطية في البرمجة
  6. لنفترض أنّ لدينا النص التالي 67-45-123-(903)7+، وأننا نريد إيجاد كل الأعداد الموجودة ضمنه، سنحتاج هنا إلى أعداد كاملة، مثل 7 و903 و123 و45 و67، لا إلى كل رقم بمفرده، حيث يتألف العدد من رقم واحد أو تتابعٍ لأرقام d\، ولتحديد عدد الأرقام التي نحتاجها سنستخدم محددًا كميًا أو مكممًا quantifier. محدد الكمية {n} إنّ أبسط المحددات الكمية هو رقم ضمن أقواس معقوصة {n}، حيث يضاف المكمم إلى المحرف أو صنف المحرف أو الأقواس المربعة وغيرها؛ ليحدد العدد الذي نحتاجه منها، وللمكممات بعض الأشكال المتقدمة التي سنستعرض أمثلةً عنها: العدد الدقيق: مثل {5}، والذي يحدد العدد الدقيق من المحارف التي نحتاجها، فمثلًا يحدد {5}d خمسة أرقام تمامًا، كما لو كتبنا d\d\d\d\d\، ويعرض المثال التالي كيف نبحث عن خمسة أرقام متتالية: alert( "I'm 12345 years old".match(/\d{5}/) ); // "12345" ويمكن إضافة محرف حد الكلمة b\ لنستثني الأرقام الأطول. المجال: مثل {3,5}، ويحدد الأعداد التي تضم من 3 إلى 5 أرقام، ولإيجاد ذلك توضع حدود المجال ضمن أقواس معقوصة: alert( "I'm not 12, but 1234 years old".match(/\d{3,5}/) ); // "1234"` يمكننا تجاهل الحد الأعلى ليصبح التعبير على الشكل {,d{3\، وسيبحث عن أعداد مؤلفة من ثلاثة أرقام أو أكثر: alert( "I'm not 12, but 345678 years old".match(/\d{3,}/) );// "345678"` لنعد الآن إلى النص 67-45-123-(903)7+، حيث يتألف العدد من رقم واحد أو من تتابعٍ لأرقام في صف، فالتعبير النمطي المناسب هو {,d{1\: let str = "+7(903)-123-45-67"; let numbers = str.match(/\d{1,}/g); alert(numbers); // 7,903,123,45,67 اختصارات المحددات الكمية لمعظم المكممات اختصارات، وهي: +: يعني "واحد أو أكثر" مرة، تمامًا مثل النمط {,1}، إذ يبحث النمط +d\ عن الأعداد: let str = "+7(903)-123-45-67"; alert( str.match(/\d+/g) ); // 7,903,123,45,67` ?: يعني "صفر أو واحد" مرة، تمامًا مثل النمط {0,1}، أي يجعل الرمز اختياريًا، إذ يبحث النمط ou?r مثلًا عن المحرف o متبوعًا بمحرف u واحد أو غير متبوع به، ومن ثم المحرف r، لنبحث عن الكلمتين color أو colour: let str = "Should I write color or colour?"; alert( str.match(/colou?r/g) ); // color, colour *: يعني "صفر أو أكثر"، تمامًا مثل النمط {,0}، أي يبحث عن عدم وجود محرف أو تكراره مرةً واحدةً أو أكثر، إذ يبحث النمط *d0\ مثلًا عن رقم متبوع بأي تكرار للمحرف 0 (عدم وجوده أو تكراره مرةً أو عدة مرات): alert( "100 10 1".match(/\d0*/g) ); // 100, 10, 1` alert( "100 10 1".match(/\d0+/g) ); // 100, 10 // لا تطابق مع 1 لأن النمط 0+ سيتطلب أكثر من صفر أمثلة أكثر تستخدم المكممات كثيرًا، وتُعدّ البنية الرئيسية للتعابير النمطية المعقدة، وسنطلع على بعض الأمثلة. التعبير النمطي للكسور العشرية (عدد بفاصلة عشرية) ويعطى بالشكل +d+\.\d\، إليك مثالًا: alert( "0 1 12.345 7890".match(/\d+\.\d+/g) ); // 12.345 التعبير النمطي لفتح وسم HTML لا يحوي سمات مثل أو الطريقة الأبسط: a-z]+>/i]>/ alert( "<body> ... </body>".match(/<[a-z]+>/gi) ); // <body> يبحث التعبير عن المحرف '>' يتبعه حرف لاتيني أو أكثر، ثم المحرف '<'. الطريق المحسنة: a-z][a-z0-9]*>/i]>/: إذ يمكن أن يحتوي وسم HTML -مثلما تنص المعايير- على رقم في أي موقع من الاسم، عدا بدايته مثل <h1>. alert( "<h1>Hi!</h1>".match(/<[a-z][a-z0-9]*>/gi) ); // <h1> التعبير النمطي لفتح أو إغلاق وسم HTML لا يحوي سمات استخدم التعبير التالي: a-z][a-z0-9]\*>/i]?/\>/، حيث وضعنا شرطةً مائلةً اختياريةً ?/ قرب بداية النمط، وكان لابد من تجاوزها بشرطة معكوسة، وإلا فستعتقد JavaScript أنها نهاية النمط. alert( "<h1>Hi!</h1>".match(/<\/?[a-z][a-z0-9]*>/gi) ); // <h1>, </h1> لاحظ أنه لكتابة تعابير نمطية أكثر دقةً لا بدّ من تعقيد التعبير أكثر، فيمكن أن ترى بأن القاعدة المشتركة بين تلك الأمثلة هي أنّ الدقة تأتي من تعقيد التعبير وطوله، ففي وسوم HTML كان يمكننا استخدام التعبير <+w\>، لكن التعبير الذي استخدمناه هو الأصلح؛ بما يتوافق مع تقييدات التسمية وقواعدها في HTML. هل يمكن استخدام <+w\> أم يجب استخدام النمط <‎[a-z][a-z0-9]*‎>؟ يُقبَل استخدام كلا النمطين في الواقع العملي، ويعتمد اختيارنا على قدرتنا على التعامل مع التطابقات الناتجة عن البحث، وصعوبة استخلاصها بطرق أخرى. مكممات البحث الجشعة والكسولة تبدو المكممات Quantifiers بسيطة الاستخدام للوهلة الأولى، لكنها قد تغدو مربكةً عند استخدامها، لذلك لا بدّ من فهم آلية البحث جيدًا إذا خططنا للبحث عن أشياء أكثر تعقيدًا من /+d\/، لنلق نظرةً على المهمة التالية مثلًا: لدينا نص ما ونريد استبدال الإشارتين «...» بإشارتي التنصيص "..."، لأنها مفضلة في تنسيقات الطباعة والكتابة في بلدان عدة، أي يجب أن يصبح النص "مرحبًا أحمد" مثلًا بالشكل «مرحبًا أحمد»، وسنجد أيضًا إشارتي التنصيص „!Witam, świat” في اللغة البولندية، أو الإشارتين 「你好,世界」 في اللغة الصينية، لكننا سنختار الإشارتين «...» فقط في مهمتنا. ستكون الخطوة الأولى تحديد النصوص الموضوعة ضمن إشارتي التنصيص، ثم استبدالهما، وقد يبدو التعبير النمطي التالي g/"+."/، والذي يعني (إشارة تنصيص ثم شيء ما مكرر ثم إشارة تنصيص أخرى) مناسبًا، لكنه ليس كذلك. انظر إلى المثال التالي: let regexp = /".+"/g; let str = 'a "witch" and her "broom" is one'; alert( str.match(regexp) ); // "witch" and her "broom" لم يعمل النمط كما هو مطلوب! فبدلًا من إيجاد الكلمتين "witch" و"broom"، وجد العبارة التالية:"witch" and her "broom". البحث الجشع Greedy search يتبع محرك التعابير النمطية الخوارزمية التالية في إيجاد التطابقات: من أجل كل موقع في النص: يحاول إيجاد التطابق عند هذا الموقع. إذا لم يجد تطابقًا فسينتقل إلى الموقع التالي. لكن هذه الكلمات غير قادرة على وصف سبب فشل التعبير بدقة، لذلك سنفصل آلية البحث عن النمط "+.": الخطوة الأولى، يحاول المحرك إيجاد المحرف الأول من النمط "عند الموقع 0 من النص a "witch" and her "broom" is one، لكنه بدلًا من ذلك سيجد المحرف a، لذلك سيتابع إلى الموقع التالي ويحاول إيجاد أول محارف النمط وسيخفق ثانيةً، وأخيرًا سيجد تطابقًا في الموقع الثالث (الموقع 2). الخطوة الثانية، يجد المحرك المحرف الأول ويتابع لإيجاد بقية محارف النمط، ويحاول أن يكتشف فيما لو تطابق بقية النص مع المحارف الباقية من النمط "+.، وسيكون المحرف التالي في النمط هو النقطة .، التي تشير إلى أي محرف عدا محرف السطر الجديد، لذلك ستجد التطابق مع الحرف 'w'. الخطوة الثالثة، سيتابع المحرك المطابقة مع النقطة نظرًا لوجود المكمم + بعدها، وسيطابق كل المحارف الأخرى ويضيفها إلى النتيجة حتى يصل إلى نهاية النص، لأن كل المحارف عدا محرف السطر الجديد ستمثل تطابقًا. الخطوة الرابعة، سينتهي المحرك من تكرار المحرف . ويحاول إيجاد المحرف التالي من النمط -وهي إشارة التنصيص "-، وهنا تظهر المشكلة فقد انتهى النص ولم يعد هناك المزيد من المحارف للبحث ضمنها، عندها سيفهم المحرك أنه ضم الكثير من المحارف .+، وسيبدأ بالتراجع، حيث يختصر نتيجة التطابق التي ولّدها المكمم بمقدار محرف إلى الخلف؛ مفترضًا أن المحارف ستنتهي قبل انتهاء النص بمحرف واحد، ثم يحاول إيجاد بقية محارف النمط انطلاقًا من هذا الموقع، وسينهي المحرك البحث إذا وجد المحرف، لكنه سيجد المحرف 'e'، وبالتالي لن تنتهي العملية. الخطوة الخامسة، سيقلل المحرك عدد المحارف الناتجة عن تكرار النقطة بمحرف واحد أيضًا، إلى أن يجد التطابق مع آخر محارف النمط '"' حتى يجده. الخطوة السادسة، سيكتمل البحث عند إيجاد التطابق. الخطوة السابعة، وهكذا ستكون النتيجة هي "witch" and her "broom". الخطوة الثامنة، مع وجود الراية g سيتابع البحث عن النمط ابتداءً من الموقع الذي انتهى فيه التطابق الأول، ولن يحصل على نتيجة طبعًا، فلن يجد إشارة تنصيص أخرى، طبعًا لم نتوقع ذلك، لكن الأمور تسير هكذا! يكرَّر المكمم في النمط الموسَّع -افتراضيًا- أكبر عدد ممكن من المرات، حيث يضيف المحرك القدر الأكبر من المحارف التي تتطابق مع +.، ثم يختصر النتيجة محرفًا تلو الآخر. النمط الكسول Lazy mode النمط الكسول أو المحدود هو نمط معاكس للنمط الموَّسع أو الجشع، ويعني أقل تكرار للمحارف، ويمكن أن نختار هذا النمط بوضع إشارة الاستفهام ? بعد المكمم حيث يصبح بالشكل ?* أو ?+ أو حتى ?? لــ?، ولنوضح الأمر علينا معرفة أن إشارة الاستفهام ? هي مكمم بحد ذاتها (صفر أو واحد)، وعندما تُضاف بعد مكمم آخر فسيصبح لها معنىً آخر، وهو تحويل نمط البحث إلى النمط المحدود الكسول، وسيعمل النمط g/"?+."/ في مهمتنا السابقة بالشكل الصحيح: let regexp = /".+?"/g; let str = 'a "witch" and her "broom" is one'; alert( str.match(regexp) ); // "witch", "broom" ولفهم التغيّر بوضوح سنتعقب عملية البحث خطوةً خطوةَ: الخطوة الأولى، تبقى الخطوة الأولى نفسها، وهي إيجاد المحرف '"' في الموقع الثالث من النص. الخطوة الثانية، وسيجد المحرك في الخطوة الثانية ما يطابق المحرف '.'. الخطوة الثالثة، سيظهر الاختلاف الآن، حيث لا يحاول المحرك في النمط المحدود إيجاد تطابق مع ما تمثله النقطة مرةً أخرى، بل يتوقف ويحاول إيجاد تطابقات مع بقية محارف النمط (أي '"') مباشرةً، وسيتوقف البحث عن النمط إذا وجد تطابقًا، لكنه سيجد الحرف i بدلًا منه. الخطوة الرابعة، سيزيد بعد ذلك محرك التعبير النمطي عدد التكرارات لمحرف النقطة مرةً واحدةً، ثم يحاول مطابقة بقية النمط، لكنه سيفشل ثانيةً، وسيزيد عدد مرات التكرار مرةً أخرى وأخرى… الخطوة الخامسة، سيتوقف البحث عندما يجد التطابق مع المحرف '"'. الخطوة السادسة، يبدأ البحث الجديد، نظرًا لوجود الراية g، من الموقع الذي انتهى عنده البحث السابق، وسيجد النتيجة الثانية. يعمل النمط الكسول بشكل مشابه للمكممات ?* و??، حيث يزيد محرك التعبير النمطي عدد التكرارات إذا لم يجد تطابقًا مع بقية محارف النمط في الموقع المُعطى. يُفعّل النمط الكسول للمكممات إذا تبعها المحرف ?، وإلا فستبقى في النمط الموّسع الجشع، إليك مثالًا: alert( "123 456".match(/\d+ \d+?/) ); // 123 4 يحاول النمط +d\ إيجاد أكبر عدد ممكن من الأعداد (نمط موسّع)، لذلك سيجد 123 ويتوقف، لأن المحرف التالي سيكون الفراغ ' '. سيجد المحرك المحرف التالي في النمط وهو الفراغ ' '. يحاول إيجاد الأعداد التالية وفقًا للنمط ?+d\ حيث يكون المكمم محدودًا، وسيجد العدد 4 ثم يتحقق من وجود بقية محارف النمط بعده، لكن محارف النمط قد انتهت. لا يكرر النمط الكسول أي شيء دون حاجة إلى ذلك، لذا سينتهي البحث بالنتيجة 4 123. لاحظ، تحسن محركات التعابير النمطية الحديثة خوارزميات البحث الداخلية لتعمل بسرعة أكبر، لذلك قد تعمل بطريقة مختلفة قليلًا عما وصفناه سابقًا، ولن نحتاج إلى معرفة تفاصيل الخوارزمية المحسَّنة عند كتابة التعابير النمطية وفهمها، لأنها تعمل داخليًا، وبما أن التعابير المعقدة صعبة التحسين، فسيجري البحث وفق الخطوات التي وصفناها سابقًا تمامًا. مقاربة بديلة نجد عادةً عند استخدام التعابير النمطية عدة طرق لتنفيذ الشيء نفسه، إذ يمكن في حالتنا السابقة إيجاد إشارات التنصيص دون الحاجة إلى البحث المحدود، بل من خلال النمط "+[^"]" let regexp = /"[^"]+"/g; let str = 'a "witch" and her "broom" is one'; alert( str.match(regexp) ); // "witch", "broom" سينجح هذا النمط لأنه يبحث عن إشارة تنصيص يتبعها تكرار لمحرف أو أكثر لا يمثل إشارة تنصيص، ثم يبحث عن إشارة تنصيص أخرى لإغلاق النص، وتجدر الملاحظة أن هذا المنطق في البحث لا يشكل بديلًا عن البحث المحدود، فهنالك حالات نحتاجه فيها. مثال عن إخفاق المكمم الكسول ونجاح الأسلوب البديل لنحاول مثلًا إيجاد رابط من الشكل <a href="..." class="doc"‎> بحيث يصلح لأي عنوان href، فما هو التعبير النمطي الذي سنستخدمه؟ قد تكون الفكرة الأولى هي: a href=".*" class="doc">/g>/، وسنتحقق منها: let str = '...<a href="link" class="doc">...'; let regexp = /<a href=".*" class="doc">/g; // !عملت alert( str.match(regexp) ); // <a href="link" class="doc"> ستعمل هذه الفكرة، لكن دعونا نتحقق منها عند وجود عدة روابط في النص: let str = '...<a href="link1" class="doc">... <a href="link2" class="doc">...'; let regexp = /<a href=".*" class="doc">/g; // حصلنا على رابطين في نتيجة واحدة alert( str.match(regexp) ); // <a href="link1" class="doc">... <a href="link2" class="doc"> النتيجة خاطئة، لأنّ المكمم *. ضمّ الكثير من المحارف، وسيكون التطابق مشابهًا للوصف التالي: <a href="....................................." class="doc"> <a href="link1" class="doc">... <a href="link2" class="doc"> لنُعدِّل النمط بجعل المكمم كسولًا ( ?*.): let str = '...<a href="link1" class="doc">... <a href="link2" class="doc">...'; let regexp = /<a href=".*?" class="doc">/g; // Works! alert( str.match(regexp) ); // <a href="link1" class="doc">, <a href="link2" class="doc"> سينجح الأمر وسيجد الرابطين: <a href="....." class="doc"> <a href="....." class="doc"> <a href="link1" class="doc">... <a href="link2" class="doc"> لنختبر ذلك مع نص آخر: let str = '...<a href="link1" class="wrong">... <p style="" class="doc">...'; let regexp = /<a href=".*?" class="doc">/g; // أخفق أيضًا alert( str.match(regexp) ); // <a href="link1" class="wrong">... <p style="" class="doc"> لقد أخفق البحث مجددًا، فقد طابق البحث الكثير من محارف النص بما فيها <p>، فما السبب؟ إليك ما حدث: يجد محرك التعبير النمطي المحارف <"=a href> بدايةً. ثم يبحث عن المكمم المحدود ?*.، الذي يأخذ محرفًا ويتحقق من وجود تطابق مع بقية أحرف النمط "class="doc "، فلا يجد شيئًا. ثم يضيف محرفًا إلى نتيجة النمط ?*. ويستمر بالإضافة إلى أن يصل إلى "class="doc "، لكن المشكلة أن ما وجده سيكون خارج حدود الرابط <...a> وضمن وسم مختلف <P>. إليك وصفًا لما حدث: <a href="..................................." class="doc"> <a href="link1" class="wrong">... <p style="" class="doc"> لاحظ إخفاق كل من البحث الجشع والكسول في المهمة، لكن سيُنفذ النمط "*[^"]"=href المطلوب في حالتنا، لأنه سيبحث عن المحارف ضمن السمة href إلى أن يجد أقرب إشارة تنصيص، وهذا ما نريده. إليك مثالًا: let str1 = '...<a href="link1" class="wrong">... <p style="" class="doc">...'; let str2 = '...<a href="link1" class="doc">... <a href="link2" class="doc">...'; let regexp = /<a href="[^"]*" class="doc">/g; // Works! alert( str1.match(regexp) ); // null, لا تطابق وهذا صحيح alert( str2.match(regexp) ); // <a href="link1" class="doc">, <a href="link2" class="doc"> الخلاصة للمكممات Quantifiers نمطان للعمل: الواسع الجشع Greedy: وهو الخيار الافتراضي الذي يحاول فيه محرك التعبير النمطي تكرار المحرف المُحصى أكبر عدد ممكن من المرات، فالنمط +d\ مثلًا سيستهلك كل الأرقام الممكنة، وعندما لا يعود قادرًا على إضافة المزيد من المحارف سيتابع مطابقة بقية أحرف النمط، فإذا لم يجد تطابقًا فسيقلل عدد مرات التكرار مرةً واحدةً -يتراجع- ويحاول مجددًا. المحدود الكسول Lazy: ويُمكَّن بإضافة إشارة الاستفهام بعد المكمم، حيث يحاول المحرك إيجاد تطابق مع بقية محارف النمط قبل تكرار البحث عن المحرف المُحصى. ويمكن استخدام بحث موّسع ومعدّل بدقة وفق النمط "[^"]+" بديلًا عن الأسلوبين. مهام لإنجازها إيجاد ثلاث نقاط متتابعة أو أكثر اكتب نمطًا للبحث عن ثلاث نقاط متتابعة أو أكثر let regexp = /your regexp/g; alert( "Hello!... How goes?.....".match(regexp) ); // ..., ..... الحل: let regexp = /\.{3,}/g; alert( "Hello!... How goes?.....".match(regexp) ); // ..., ..... تذكر أن النقطة . محرف خاص ولابد من تجاوزه وحشر النمط .\. تحديد الألوان في HTML أنشئ تعبيرًا نمطيًا للبحث عن ألوان HTML المكتوبة على الشكل ABCDEF#، ابحث أولًا عن المحرف # تليه 6 محارف لأرقام ستة عشرية، إليك مثالًا: let regexp = /...your regexp.../ let str = "color:#121212; background-color:#AA00ef bad-colors:f#fddee #fd2 #12345678"; alert( str.match(regexp) ) // #121212,#AA00ef الحل: علينا أن نبحث عن المحرف # متبوعًا بستة محارف ست عشرية. يمكن وصف المحرف الست عشري وفق النمط [0‎-9a-fA-F]، أو وفق النمط [‎0-9a-f] عند استخدام الراية i. يمكننا عندئذ البحث عن ستة محارف ست عشرية باستخدام المكمم {6}. بالنتيجة سيكون التعبير النمطي المناسب من الشكل: /#[a-f0-9]{6}/gi إليك شيفرة الحل: let regexp = /#[a-f0-9]{6}/gi; let str = "color:#121212; background-color:#AA00ef bad-colors:f#fddee #fd2" alert( str.match(regexp) ); // #121212,#AA00ef المشكلة في هذا الحل أن التعبير يطابق سلسلة طويلة أكثر حتى لو كانت أكثر من 6 محارف مثل: alert( "#12345678".match( /#[a-f0-9]{6}/gi ) ) // #123456 يمكن حل هذه المشكلة بإضافة ‎\b لنهاية التعبير: // لون alert( "#123456".match( /#[a-f0-9]{6}\b/gi ) ); // #123456 // ليس لون alert( "#12345678".match( /#[a-f0-9]{6}\b/gi ) ); // null إيجاد تطابق مع النمط /?+d+? d/ ما نتيجة التطابق في المثال الآتي: alert( "123 456".match(/\d+? \d+?/g) ); // ? الحل: النتيجة هي 4 123. يحاول النمط الكسول ‎d+?‎\ أقل عدد ممكن من الأرقام لكنه سيصل إلى محرف المسافة الفارغة وبالتالي سيأخذ فقط 123، ثم يأخذ النمط الثاني ‎d+?‎\ رقمًا واحدًا فقط ويكتفي. إيجاد تعليقات HTML أوجد كل التعليقات في النص التالي: let regexp = /your regexp/g; let str = `... <!-- My -- comment test --> .. <!----> .. `; alert( str.match(regexp) ); // '<!-- My -- comment \n test -->', '<!---->' الحل: علينا إيجاد نمط بداية التعليق --!> ثم نأخذ المحارف اللاحقة له حتى نصل إلى نمط نهاية التعليق <-- إذًا سيكون النمط من الشكل <--?*.--!> ، حيث يجبر المكمم الكسول المحرف . على التوقف قبل النمط <--. نحتاج أيضًا إلى الراية s مع النقطة لكي تستمر في ضم المحارف حتى لو انتقلنا إلى سطر جديد وإلا لن نتمكن من التقاط التعليقات الممتدة على أكثر من سطر. let regexp = /<!--.*?-->/gs; let str = `... <!-- My -- comment test --> .. <!----> ..`; alert( str.match(regexp) ); // '<!-- My -- comment \n test -->', '<!---->' إيجاد وسوم HTML أنشئ تعبيرًا نمطيًا لإيجاد كل وسوم HTML (للبداية والنهاية) مع سماتها. إليك مثالًا: let regexp = /your regexp/g; let str = '<> <a href="/"> <input type="radio" checked> <b>'; alert( str.match(regexp) ); // '<a href="/">', '<input type="radio" checked>', '<b>' نفترض هنا عدم وجود المحرفين < و> ضمن سمات الوسم، مما يسهل الأمر قليلًا. الحل سيكون التعبير النمطي الصحيح من الشكل: <[^<>]+> إليك شيفرة الحل: let regexp = /<[^<>]+>/g; let str = '<> <a href="/"> <input type="radio" checked> <b>'; alert( str.match(regexp) ); // '<a href="/">', '<input type="radio" checked>', '<b>' ترجمة -وبتصرف- للفصلين َQuntifiers +,*,? And {n} وَGreedy and Lazy Quantifiers من سلسلة The Moden JavaScript Tutorial. اقرأ أيضًا المقال السابق: المجموعات والمجالات في التعابير النمطية أساسيات البحث باستخدام التعابير النمطية في جافاسكربت المجموعات والمجالات في التعابير النمطية
  7. وجود عدة محارف أو أصناف محارف ضمن قوسين مربعين […] يعني البحث عن أي محرف بينها. سيكمل هذا المقال الغوص في التعابير النمطية بعد أن تطرقنا إلى مقدمة شاملة عنها في المقال السابق بعنوان أساسيات التعابير النمطية في جافاسكربت. المجموعات يعني النمط [eao] البحث عن أيٍّ من المحارف الثلاثة 'a' أو 'e' أو 'o'، ويُدعى هذا النمط بالمجموعة، ويمكن استخدام المجموعات ضمن التعابير النمطية ومع محارف نظامية: //"op" ثم [t أو m] جد alert( "Mop top".match(/[tm]op/gi) ); // "Mop", "top" لاحظ أنه وعلى الرغم من وجود عدة محارف ضمن المجموعة، فإنها سترتبط بمحرف واحد تمامًا عند إعادة النتيجة، فمثلًا لن يعيد البحث التالي أي نتيجة: //"la" ثم [o أو i] ثم "V" جد alert( "Voila".match(/V[oi]la/) ); // null, no matches سيبحث النمط عن: V ثم أحد الحرفين [oi]. ثم la. لذا ستكون النتيجة إما Vola أو Vila، والنصان غير موجودين. المجالات يمكن أن تضم الأقواس المربعة مجالات من المحارف، مثل المجال [a-z]، الذي يحدد المحارف بين a وz، أو المجال [0-5] الذي يحدد الأرقام بين 0 و5، وسنبحث في المثال التالي عن "x" متبوعًا برقمين أو حرفين من A إلى F: alert( "Exception 0xAF".match(/x[0-9A-F][0-9A-F]/g) ); // xAF ستجد مجالين ضمن الأقواس المربعة [A-F0-9]، وسنبحث عن عدد من 0 إلى 9 أو حرف من A إلى F، ويمكن البحث عن أحرف بالحالة الصغيرة بإضافة المجال a-f، بحيث يصبح النمط [A-F0-9a-f] أو باستخدام الراية i، كما يمكن استخدام أصناف المحارف داخل الأقواس المربعة، فلو أردنا مثلًا البحث عن محرف كلمة أو شرطة قصيرة، فستكون المجموعة المناسبة هي [-w\]، ويمكن الجمع أيضًا بين أكثر من صنف محارف، مثل المجموعة [s\d\]، التي تعني البحث عن محرف فراغ أو رقم انتبه إلى أن أصناف المحارف هي اختصارات لمجموعات محددة من المحارف، فمثلًا: d\: هي نفسها المجموعة [9-0]. w\: هي نفسها المجموعة [a-zA-Z0-9]. s\: هي نفسها المجموعة [t\n\v\f\r\ ]، بالإضافة إلى بعض محارف فراغ Unicode الخاصة. مثال: محرف الكلمة w\ للغات متعددة يمكن لصنف المحارف w\، كونه اختصارًا للنمط [a-zA-Z0-9]، أن يجد المحارف العربية أو الصينية وغيرها، لذلك يمكننا كتابة أنماط من التعابير لإيجاد محارف في أي لغة باستخدام خصائص الترميز Unicode مثل التعبير النمطي التالي: [{p{Alpha}\p{M}\p{Nd}\p{Pc}\p{Join_C\] ولنفسر الآن هذا النمط، حيث ننشئ فيه مجموعةً خاصةً بنا تتضمن محارف لها خصائص Unicode التالية: Alpha: للأحرف. M: للعلامات فوق الأحرف. Nd: للأعداد بالنظام العشري. Pc: للشرطة السفلية (_) والمحارف المشابهة. Join_C: للمحارف الخاصة 200c و200d التي تستخدم في لغات غير لاتينية، مثل العربية. إليك مثالًا: let regexp = /[\p{Alpha}\p{M}\p{Nd}\p{Pc}\p{Join_C}]/gu; let str = `Hi 你好 12`; // جد كل الأحرف والأرقام: alert( str.match(regexp) ); // H,i,你,好,1,2 يمكن بالطبع تعديل هذه النمط بإضافة خصائص جديدة أو إزالتها، وسنفصّل في هذا الخصائص لاحقًا. انتبه إلى أن خصائص يونيكود Unicode أي p{...}‎ غير مدعومة في متصفح إنترنت إكسبلورر IE، فلا يدعم متصفح IE خصائص Unicode، فإذا احتجنا إليها فيمكننا استخدام المكتبة XRegExp، ومن الممكن أيضًا استخدام مجالات لأحرف اللغة المطلوبة، مثل المجال [ا-ي] في اللغة العربية. مجالات الاستثناء ستجد أيضًا مجالات لاستثناء محارف معينة تبدو بالشكل […^]، فهي معلّمة بالمحرف ^ في البداية، وتطابق أي محرف عدا المحارف المعطاة، وإليك بعض الأمثلة: [aeyo^]: أي محرف عدا المحارف 'a' أو 'e' أو 'y' أو 'o'. [0-9^]: أي محرف عدا الأرقام، لها وظيفة الصنف D\. [s\^]: أي محرف لا يمثل محرف فراغ. يبحث المثال التالي عن أي محارف عدا الأحرف أو الأرقام أو الفراغات: alert( "alice15@gmail.com".match(/[^\d\sA-Z]/gi) ); // @ and . التجاوز عند استخدام […] رأينا سابقًا أننا نتجاوز (نهرِّب) الوظيفة الخاصة لمحرف باستخدام الشرطة المعكوسة .\، وإذا أردنا البحث عن الشرطة المعكوسة بذاتها نكررها مرتين \\، ويمكن استخدام معظم المحارف الخاصة ضمن الأقواس المربعة دون الحاجة إلى تجاوزها: لاتحتاج الرموز . + ( ) إلى تجاوز. لا يحدث تجاوز للشرطة - في بداية ونهاية النص، أي عندما لا تحدد مجالًا. يحدث تجاوز للعلامة ^ في بداية النص فقط، عندما تعني الاستثناء. يحدث تجاوز دائم لقوس الإغلاق المربع [، إن أردنا البحث عنه بحد ذاته. وبعبارة أخرى يُسمح باستخدام المحارف الخاصة جميعها دون الحاجة للتجاوز؛ إلا عندما تعني شيئًا محددًا ضمن الأقواس المربعة، فالنقطة ضمن الأقواس المربعة تعني نقطة، فمثلًا سيبحث النمط [.,] مثلًا عن نقطة أو فاصلة. يبحث النمط [-().^+] في المثال التالي عن أي محرف من المحارف +().^-: // لا حاجة للتجاوز let regexp = /[-().^+]/g; alert( "1 + 2 - 3".match(regexp) ); // تطابق +, - لكن إذا قررت تجاوز هذه المحارف للاحتياط، فلا مشكلة في ذلك: // تجاوز كل شيء let regexp = /[\-\(\)\.\^\+]/g; alert( "1 + 2 - 3".match(regexp) ); // سينجح أيضًا +, - المجالات واستعمال الراية "u" إذا وجد محرف بصيغة زوج بديل surrogate pair، وهو تفسير بعض الخصائص للمحرف ذي البايتات الأربع، فلا بدّ من استخدام الراية u للتعامل مع هذه المحارف. لنبحث مثلًا عن النمط [‎??‎] ضمن النص "?": alert( '?'.match(/[??]/) ); // [?] سيظهر محرف غريب مثل // جرى البحث بطريقة غير صحيحة وأعاد نصف المحرف فقط ستكون النتيجة خاطئةً، لأن التعبير النمطي لا يعلم بوجود زوج بديل افتراضيًا، إذ يعتقد محرك التعبير النمطي أن النمط [‎??‎] مكون من أربعة محارف، وليس محرفين: الأول هو النصف اليساري من ?. الثاني هو النصف اليميني من ?. ,الثالث هو النصف اليساري من ?. الرابع هو النصف اليميني من ?. ويمكننا الاطلاع على ترميزها بالشكل التالي: for(let i=0; i<'??'.length; i++) { alert('??'.charCodeAt(i)); // 55349, 56499, 55349, 56500 }; إذًا سيجد المثال السابق النصف الأيسر فقط من ? ويعرضه، لكن سنحل المشكلة عند إضافة الراية u: alert( '?'.match(/[??]/u) ); // ? ونستخدم الحل نفسه عندما نبحث عن مجال يحوي محارف بأزواج بديلة، مثل [‎?-?‎]، وسينتج خطأً إذا لم نستخدم الراية u. '?'.match(/[?-?]/); // خطأ: تعبير نظامي غير صحيح والسبب أنّ الزوج البديل سيعامل مثل محرفين دون الراية u، وهكذا سيُفسَّر النمط [‎?-?‎] على الشكل التالي: [<56500><55349>-<56499><55349>] حيث يُبدَّل كل زوج بترميزه، وهكذا سيكون المجال 56499-55349 خاطئًا، لأن الرمز النصي 56499 لبداية المجال أكبر من الرمز النصي 55349 لنهايته، وهذا هو سبب الخطأ، وسيعمل النمط بشكل صحيح بوجود الراية u: // ? إلى ? البحث عن محارف من alert( '?'.match(/[?-?]/u) ); // ? ترجمة -وبتصرف- للفصل Sets and Ranges من سلسلة The Moden JavaScript Tutorial. اقرأ أيضًا التعابير النمطية (regexp/PCRE) في PHP مقدمة في التعابير النمطية Regular Expressions كيفية التعامل مع النصوص في البرمجة
  8. التعابير النمطية Regular expressions هي أنماط تزودنا بأسلوب فعّال للبحث عن النصوص التي تطابق نمطًا محددًا واستبدالها في JavaScript، يتيح الكائن RegExp استخدام هذه التعابير، وتُدمج مع التوابع التي تتعامل مع النصوص. مقدمة إلى التعابير النمطية تتألف التعابير النمطية من نمط pattern، ورايات flags اختيارية، ولإنشاء كائن تعبير نمطي يمكن استخدام صيغتين، صيغة طويلة وصيغة قصيرة، أما الصيغة الطويلة فتكتب بالشكل: regexp = new RegExp("pattern", "flags"); وأما الصيغة القصيرة فتكتب باستخدام المحرف "/" بالشكل: regexp = /pattern/; // لا رايات regexp = /pattern/gmi; // g و m و i باستخدام الرايات تخبر الخطوط المائلة JavaScript بأننا ننشئ تعبيرًا نمطيًا، فهي تلعب دورًا مماثلًا لعلامة الاقتباس في النصوص، وفي كلتا الصيغتين سيصبح الكائن regexp نسخةً عن الصنف المدمج RegExp. لا يُسمح بإدخال متغيرات تمثل الأنماط ضمن صيغة الخطوط المائلة /.../ باستخدام القوالب {...}$ الديناميكية مثلًا كما نفعل في النصوص، فهي ثابتة بشكل كامل، وهذا هو الاختلاف الرئيسي بين الصيغتين، إذ نستخدم الخطوط المائلة عندما نريد استخدام تعابير نمطية محددة ومعروفة بالنسبة لنا عند كتابة الشيفرة، وهذه هي الحالة الأكثر شيوعًا، بينما نستخدم الصيغة new RegExp عادةً عندما نحتاج إلى إنشاء تعابير نمطية أثناء التنفيذ انطلاقًا من نص أُنشئ ديناميكيًا. let tag = prompt("What tag do you want to find?", "h2"); let regexp = new RegExp(`<${tag}>`); // "h2" إن كان الجواب /<h2>/ نفس نتيجة الرايات للتعابير النمطية رايات تؤثر على نتيجة البحث، وتستخدم JavaScript ستةً منها، وهي: i: سيكون البحث غير حساس لحالة الأحرف مع هذه الراية، أي لن يكون هناك فرق بين "a" و"A". g: سيعيد البحث جميع النتائج المتطابقة مع هذه الراية، وإلا فسيعيد النتيجة الأولى. m: سيعمل البحث في نمط الأسطر المتعددة، وسنغطي ذلك لاحقًا. s: ستمكّن نمط "dotall" الذي يسمح بأن نعدَّ النقطة . هي محرف نهاية السطر n\، وسنغطيه لاحقًا. u: سيمكّن الدعم الكامل لترميز Unicode، وبالتالي المعالجة الصحيحة لمحتويات الأقواس المعقوصة {..}، وسنغطيه لاحقًا. y: سيمكّن النمط اللاصق "Sticky"، حيث يبحث في المكان المحدد تمامًا من النص، وسنغطيه لاحقًا. البحث باستخدام التابع str.match تتكامل التعابير النمطية مع توابع النصوص، إذ يبحث التابع (str.match(regexp عن كل ما يطابق التعبير regexp في النص str، ويعمل وفق ثلاثة أنماط: النمط الأول، التعبير النمطي مع الراية g، ويعيد مصفوفةً تضم النتائج المطابقة: let str = "We will, we will rock you"; alert( str.match(/we/gi) ); // سيعيد مصفوفة من الحالتين المتطابقتين للبحث وانتبه إلى أنّ البحث سيعيد النتيجتين "we" و"We" لأن راية حالة الأحرف i مفعّلة. النمط الثاني، إذا لم نفعّل الراية g، فسيعيد البحث النتيجة الأولى فقط ضمن مصفوفة، مع وضع التطابق في الدليل 0، بالإضافة إلى تفاصيل أخرى في الخصائص: let str = "We will, we will rock you"; let result = str.match(/we/i); // g دون الراية alert( result[0] ); // We (التطابق الأول) alert( result.length ); // 1 // Details: alert( result.index ); // 0 (موقع التطابق) alert( result.input ); // We will, we will rock you (النص الأصلي) قد تحتوي المصفوفة على عناصر أخرى بالإضافة إلى العنصر ذي الدليل 0؛ إذا كان جزء من التعبير النمطي ضمن قوسين مغلقين وسنغطي ذلك في المقالات القادمة. النمط الثالث، إذا لم توجد أي تطابقات، فسيعيد التابع القيمة null، بوجود الراية g أو عدم وجودها، لذا انتبه أننا لن نحصل في هذه الحالة على مصفوفة فارغة، بل على القيمة null، وسيؤدي نسيان هذه الحقيقة إلى مشاكل عدة: let matches = "JavaScript".match(/HTML/); // = null if (!matches.length) { //length خطأ لا يمكن قراءة الخاصية alert("Error in the line above"); } إذا أردنا أن نحصل دائمًا على مصفوفة، فيمكننا أن ننفذ الأمر بالشكل التالي: let matches = "JavaScript".match(/HTML/) || []; if (!matches.length) { alert("No matches"); // ستعمل الآن } الاستبدال باستخدام التابع str.replace يستبدل التابع (str.replace(regexp, replacement التطابقات الموافقة للكائن regexp في النص str بقيمة replacement، وستستبدل كل التطابقات عند استخدام الراية g، وإلا فسيستبدل أول تطابق فقط، وإليك مثالًا: // g دون الراية alert( "We will, we will".replace(/we/i, "I") ); // I will, we will // مع الراية alert( "We will, we will".replace(/we/ig, "I") ); // I will, I will يمكن استخدام محارف خاصة ضمن الوسيط الثاني replacement للبحث عن أجزاء من معيار التطابق: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الرمز ما يفعله ضمن النص replacment &$ يمثل نص التطابق `$ يمثل النص الواقع قبل نص التطابق. '$ يمثل النص الواقع بعد نص التطابق. n$ يمثل التطابق ذا الرقم n من مجموعة التطابق (الموجود ضمن قوسي تجميع"()" ) . ‎$<name>‎ يمثل التطابق ذا الاسم name من مجموعة التطابق (الموجود ضمن قوسي تجميع"()" ). $$ يمثل المحرف $ مثال باستخدام &$: alert( "I love HTML".replace(/HTML/, "$& and JavaScript") ); // I love HTML and JavaScript الاختبار باستخدام التابع regexp.test يبحث التابع (regexp.test(str عن تطابق واحد على الأقل -إن وُجد- ويعيد القيمة true، وإن لم يجد تطابقًا فسيعيد القيمة false. let str = "I love JavaScript"; let regexp = /LOVE/i; alert( regexp.test(str) ); // true سندرس لاحقًا في هذا القسم تعابير نمطيةً أكثر، وسنتعرف على توابع أخرى لاحقًا. أصناف المحارف لنتأمل مهمةً تتطلب تحويل رقم هاتف، مثل "67-45-123-(903)7+"، إلى رقم صرف، أي 79031234567، لتنفيذ ذلك يمكننا تتبع وحذف أي محرف لا يمثل رقمًا، وستساعدنا أصناف المحارف في ذلك، حيث تمثل إشارات خاصةً تطابق أي رمز ضمن مجموعة معينة، وسنتعرف بدايةً على الصنف "digit"، الذي يُعبَّر عنه بالشكل d\، ويرتبط بأي رقم مفرد، فمثلًا لنجد الرقم الأول في رقم الهاتف: let str = "+7(903)-123-45-67"; let regexp = /\d/; alert( str.match(regexp) ); // 7 سيبحث التابع عن كل الأرقام التي سيجدها في النص بإضافة الراية g، وسيعيدها ضمن مصفوفة: let str = "+7(903)-123-45-67"; let regexp = /\d/g; alert( str.match(regexp) ); // 7,9,0,3,1,2,3,4,5,6,7 // لنشكل عددًا من أرقام المصفوفة: alert( str.match(regexp).join('') ); // 79031234567 سنستعرض الآن بعض الأصناف الأكثر استعمالًا، وهي: d\: من كلمة digit رقم، ويعيد أي رقم بين 0 و9. ‎\s: من كلمة space فراغ، وتضم محارف الفراغ، مثل: مسافة الجدولة t\، والسطر الجديد n\، وغيرها من الرموز النادرة الاستخدام مثل r\ وv\ وf\. w\: من كلمة word كلمة، وتعيد محارف قد تشكل كلمةً، مثل الأحرف اللاتينية أو الأرقام أو الشرطة السفلية _، ولا تنتمي الأحرف غير اللاتينية، مثل العربية، إلى هذا الصنف، إذ تشير الرموز d\s\w\ مثلًا إلى رقم متبوع بمحرف فراغ، ويليه محرف كلمة مثل 1 a. يمكن أن يضم الكائن محارف تعابير نمطية بالإضافة إلى الأصناف، فمثلًا سيبحث التعبير CSS\d عن الكلمة CSS متبوعةً برقم: let str = "Is there CSS4?"; let regexp = /CSS\d/ alert( str.match(regexp) ); // CSS4 كما يمكن استخدام أصناف محارف عدة: alert( "I love HTML5!".match(/\s\w\w\w\w\d/) ); // ' HTML5' الأصناف المعكوسة لكل صنف من أصناف المحارف صنف معكوس inverse class، يرمز له بالحرف نفسه لكن بحالة حرف كبير uppercase، ويعني ذلك الحصول على أي محرف عدا المحرف الذي يعيده الصنف عادةً، أي: D\: لا يعيد رقمًا، وإنما يعيد أي محرف عدا المحارف التي يعيدها d\، مثل الأحرف اللاتينية. S\: لا يعيد محرف فراغ، وإنما يعيد أي محرف عدا المحارف التي يعيدها s\، مثل الأحرف اللاتينية. W\: لا يعيد محرف كلمة، وإنما يعيد أي محرف عدا المحارف التي يعيدها w\، مثل الأحرف غير اللاتينية أو الفراغات. لاحظ كيف سنستخدَم هذه الأصناف في تحويل رقم الهاتف في المثال السابق إلى رقم صرف وبطريقة أقصر، وذلك بإزالة المحارف التي لا تمثل أرقامًا D\: let str = "+7(903)-123-45-67"; alert( str.replace(/\D/g, "") ); // 79031234567 يمثل المحرف نقطة أي محرف يمثل المحرف . صنفًا مميزًا من المحارف، ويطابق أي محرف عدا محرف السطر الجديد، إليك مثالًا: alert( "Z".match(/./) ); // Z كما يمكن إدراجه وسط التعبير regexp: let regexp = /CS.4/; alert( "CSS4".match(regexp) ); // CSS4 alert( "CS-4".match(regexp) ); // CS-4 alert( "CS 4".match(regexp) ); // CS 4 (الفراغ هو محرف أيضًا) لاحظ أن النقطة تطابق أي محرف، لكنها لن تبحث عن محرف غير موجود، فلا بدّ من وجود محرف حتى يعيده هذا الصنف: alert( "CS4".match(/CS.4/) ); // null, لن يعيد الصنف شيئًا لعدم وجود محرف لاحظ أن محرف النقطة يمثل أي محرف بالفعل بوجود الراية "s"، فلا تعيد النقطة عادةً محرف السطر الجديد، إذ يعيد التعبير A.B الحرفين A وB وأي محارف بينهما عدا محرف السطر الجديد n\: alert( "A\nB".match(/A.B/) ); // null (لا تطابقات) لكننا في عدة حالات نريد للنقطة أن تعني أي محارف فعلًا، بما في ذلك السطر الجديد، لذلك سنستخدم الراية s وسنحصل على المطلوب: alert( "A\nB".match(/A.B/s) ); // A\nB (match!) انتبه إلى أن المتصفح IE لا يدعم الراية s، لكن يمكن استخدام التعبير النمطي [‎\s\S] الذي يعيد أي محرف في أي مكان، وسنغطي ذلك لاحقًا. alert( "A\nB".match(/A[\s\S]B/) ); // A\nB (match!) ويعني التعبير [‎\s\S] البحث عن (محرف فراغ أو محرف لا يمثل فراغًا)، وهذا يمثل عمليًا أي محارف، ويمكن استخدام أي أزواج من الأصناف وعكسها، مثل [‎\d\D]، كما يمكن استخدام التعبير [^] الذي يعني البحث عن أي محرف عدا "لا شيء"، ويمكن استخدام الحيلة ذاتها إن أردنا أن يؤدي المحرف "نقطة" . كلا الوظيفتين، أي مطابقة أي محارف عدا السطر الجديد . أو مع السطر الجديد [‎\s\S]. انتبه أيضًا إلى الفراغات، إذ لا نكترث عادةً لوجود فراغات زائدة، وسنعتبر النص 1-5 مماثلًا للنص 1 - 5، لكن قد يخفق التعبير النمطي regexp إن لم يأخذ هذه الفراغات في الحسبان. لنحاول مثلًا أن نجد أرقامًا مفصولةً بشرطة صغيرة (-): alert( "1 - 5".match(/\d-\d/) ); // null, لا تطابق سنصلح ذلك بإضافة فراغات إلى التعبير النمطي d - \d\: alert( "1 - 5".match(/\d - \d/) ); // 1 - 5, سنجد الآن تطابقًا // \s أو يمكن استخدام الصنف: alert( "1 - 5".match(/\d\s-\s\d/) ); // 1 - 5, سيعمل أيًا فالفراغ هو محرف، ولا يمكننا إضافة أو إزالة الفراغات من تعبير نمطي، ثم نتوقع أن يعمل كما هو مطلوب، فكل المحارف التي ندخلها في التعبير النمطي ستُؤخذ في الحسبان عند البحث، بما فيها الفراغات. تعامل التعابير النمطية مع ترميز يونيكود Unicode تستخدم JavaScript ترميز يونيكود Unicode لتمثيل النصوص، حيث تُرمّز معظم المحارف باستخدام 2 بايت، وهذا ما يسمح بترميز 65536 محرف كحد أقصى، ولا يعتبر العدد السابق كافيًا لترميز كل المحارف الممكنة، لذلك ستجد أن بعض المحارف الخاصة رمِّزت باستخدام 4 بايت، مثل المحرف ? -التمثيل الرياضي للمحرف X- أو ? -الابتسامة- وبعض المحارف الهيروغليفية وغيرها، وإليك قيم Unicode لبعض المحارف الخاصة: المحرف ترميز Unicode عدد البايتات المستخدمة a 0x0061 2 ≈ 0x2248 2 ? 0x1d4b3 4 ? 0x1d4b4 4 ? 0x1f604 4 تشغل محارف -مثل a- بايتين اثنين، ومحارف -مثل ? و?- أربعة بايتات. كان ترميز Unicode في الأيام الأولى لظهور JavaScript أكثر بساطةً، حيث لم توجد محارف من أربعة بايتات، لذلك ستجد أن بعض ميزات اللغة ستتعامل معها بشكل غير صحيح، فتعتقد الخاصية length مثلًا أنها عبارة عن محرفين: alert('?'.length); // 2 alert('?'.length); // 2 والسبب في ذلك أنّ هذه الخاصية ستعامل المحرف ذا البايتات الأربعة على أنه محرفان لكل منهما بايتان فقط، وهذا أمر خاطئ، حيث ينبغي اعتبارها محرفًا واحدًا، وتُدعى "الزوج البديل" surrogate pair، وسنرى ذلك لاحقًا. تعامل التعابير النمطية regular expression المحارف الطويلة ذات البايتات الأربع مثل زوج من البايتات الثنائية، وقد يقود ذلك إلى أخطاء كما هو الحال في النصوص، لكن -وعلى خلاف النصوص- للتعابير النمطية الراية u التي تعالج هذه المشكلة، إذ يعالج التعبير المحارف ذات البايتات الأربع بشكل صحيح عند وجود هذه الراية، وسيصبح البحث متاحًا ضمن خصائص الترميز Unicode كما سنرى تاليًا. خصائص ترميز Unicode باستعمال {…}p\ لمحارف Unicode العديد من الخصائص التي تصف الفئة التي ينتمي إليها المحرف، كما تقدم معلومات متنوعةً عنها، فلو كان للمحرف الخاصية Letter فسيعني ذلك أنه ينتمي إلى أبجدية ما، كما يعني امتلاكه الخاصية Number أنه رقم، وقد يكون رقمًا عربيًا أو صينيًا وهكذا، ويمكن البحث عن محرف يمتلك خاصيةً معينةً باستخدام الصنف {...}p\، ولاستخدام هذا الصنف لا بد أن نفعّل الراية u في التعبير النمطي. يبحث التعبير {p{Letter\مثلًا عن حرف في أي لغة، كما يمكن كتابته بالشكل {p{L\، وهو اسم مستعار للخاصية Letter، ولمعظم الخصائص أسماء مستعارة قصيرة. سيجد البحث في المثال التالي ثلاثة أنواع من الأحرف: انكليزي وجورجي وكوري. let str = "A ბ ㄱ"; alert( str.match(/\p{L}/gu) ); // A,ბ,ㄱ alert( str.match(/\p{L}/g) ); // null (no matches, \p doesn't work without the flag "u") إليك الفئات الأساسية للمحارف، وفئاتها الفرعية: الحرف Letter: واسمها المستعار L. Ll: حرف صغير. Lm: حرف معدِّل modifier. Lt: عنوان. Lu: حرف كبير. Lo: غير ذلك. العدد Number: واسمها المستعار N. Nd: رقم بالصيغة العشرية. Nl: رقم حرف. No: غير ذلك. علامات الترقيم Punctuation: واسمها المستعار P. Pc: واصلة. Pd: خط فاصل dash. Pi: علامة اقتباس فاتحة. Pf: علامة اقتباس غالقة. Ps: مفتوح. Pe: مغلق. Po: غير ذلك. العلامة أو الحركة Mark: واسمها المستعار M، مثل العلامات أو الحركات فوق الأحرف وغيرها. Mc: علامة ضم مع فراغ spacing combining، وعلامة الضم هي محرف يعدّل محرفًا آخر بإضافة علامة أو حركة فوقه أو تحته وهكذا. Me: علامة ضم محيطة enclosing. Mn: علامة ضم دون فراغ non-spacing. الرمز Symbol: واسمه المستعار S. Sc: رموز عملة. Sk: رمز مُعدِّل. Sm: رمز رياضي. So: رمز آخر. الفواصل Separator: واسمها المستعار Z. Zl: خط. Zp: مقطع. Zs: فراغ. فئات أخرى: واسمها المستعار C. Cc: تحكم. Cf: تنسيق. Cn: لم يحدد بعد. Co: استخدام خاص. Cs: بديل. فلو أردنا مثلًا أحرفًا كبيرة فسنستخدم {p{Ll\، بينما نستخدم {p{p\ لعلامات الترقيم وهكذا. ستجد أيضًا بعض الفئات المشتقة، مثل: Alphabetic: واسمها المستعار Alpha، وتضم الأحرف L، والأرقام على شكل أحرف Nl، مثل Ⅻ وهو 12 بالرومانية. Hex_Digit: وتضم الأرقام الست عشرية: 0-9 وa-f. يدعم الترميز Unicode خصائص عديدةً، وسيزيد ذكرها من حجم المقالة بشكل كبير، لكن يمكنك الاطلاع على المراجع التالية: قائمة بكل الخصائص وفقًا للمحرف. قائمة بكل المحارف وفقًا للخاصية. قائمة بالاسماء المستعارة القصيرة للخصائص. قاعدة كاملة بمحارف Unicode بتنسيق نصي مع كامل خصائصها. مثال: الأعداد الست عشرية لننظر مثلًا إلى الأعداد الست عشرية المكتوبة بالشكل xFF، حيث F هو رقم ست عشري (0-1 أو A-F)، ويمكن الإشارة إلى الرقم الست عشري بالشكل {p{Hex_Digit\: let regexp = /x\p{Hex_Digit}\p{Hex_Digit}/u; alert("number: xAF".match(regexp)); // xAF مثال: الكتابة التصويرية الصينية في ترميز Unicode خاصية تحدد نظام الكتابة، ويمكن أن تأخذ قيمًا مثل: Cyrillic وGreek وArabic وHan (الصينية) وغيرها من القيم المشابهة، وللبحث عن محرف في نظام كتابة محدد علينا استخدام الخاصية <Script=<value، فللعربية مثلًا نستخدم {p{sc=Arabic\، وللصينية نستخدم {p{sc=Han\ وهكذا. let regexpHan = /\p{sc=Han}/gu; // يعيد التصويرية الصينية let regexpArab = /\p{sc=Arabic}/gu; // يعيد العربية let str = `Hello Привет 你好 جميل 123_456`; alert( str.match(regexpHan) ); // 你,好 alert( str.match(regexpArab) ); // ج,م,ي,ل مثال: رموز العملة للمحارف التي ترمز إلى العملات -مثل $ و€ و¥- خاصية في ترميز Unicode، هي {p{Currency_Symbol\، واسمها المختصر {p{Sc\، لنستخدم هذه الخاصية في الحصول على سعر منتج ضمن تنسيق يحوي رمز عملة يليه رقم: let regexp = /\p{Sc}\d/gu; let str = `Prices: $2, €1, ¥9`; alert( str.match(regexp) ); // $2,€1,¥9 وسنتعرف لاحقًا في مقالات قادمة على آلية البحث عن أعداد تحتوي عدة أرقام. محارف الارتكاز: محرفا بداية النص ^ ونهايته $ يحمل المحرفان ^ و$ معانٍ خاصةً في التعابير النمطية regular expression، وتسمى بالمرتكزات أو المرابط Anchors، حيث تبحث العلامة ^ عن تطابق في بداية النص، بينما تبحث $ عن تطابق في نهايته، لنر مثلًا إن بدأ نص ما بالكلمة "Mary": let str1 = "Mary had a little lamb"; alert( /^Mary/.test(str1) ); // true يعني النمط أن نبحث عن الكلمة "Mary" في بداية السطر حصرًا، وبشكل مشابه يمكن أن نستخدم $ للتحقق من وجود الكلمة "snow" مثلًا في نهاية نص: let str1 = "it's fleece was white as snow"; alert( /snow$/.test(str1) ); // true يمكن استخدام توابع التعامل مع النصوص startsWith/endsWith في هذه الحالات بالتحديد، لكن ينبغي استخدام التعابير النمطية في الحالات الأكثر تعقيدًا. اختبار التطابق الكامل يستخدم المرتكزان السابقان معًا $...^ لاختبار تطابق نص بشكل كامل مع النمط، مثل التحقق من مطابقة ما يدخله المستخدم للتنسيق المطلوب، لنتحقق مثلًا أن نصًا له التنسيق الزمني التالي 12:34، وهو عدد من رقمين، تليه نقطتان ":"، ثم عدد من رقمين، لذا سنستخدم التعبير d\d:\d\d\ في عالم التعابير النمطية: let goodInput = "12:34"; let badInput = "12:345"; let regexp = /^\d\d:\d\d$/; alert( regexp.test(goodInput) ); // true alert( regexp.test(badInput) ); // false يجب أن يبدأ التطابق مباشرة ًعند بداية النص ^، وينتهي بنهايته $، ويجب أن يطابق تنسيق النص تنسيق التعبير النمطي، فإذا وجدَت أي اختلافات أو محارف زائدة فستكون النتيجة "false"، وتسلك المرتكزات سلوكًا مختلفًا بوجود الراية m، وسنطلع على ذلك لاحقًا. انتبه إلى أن ليس للمرتكزات عرض، فالمرتكزات محارف اختبار، وليس لها عرض، فهي لا تطابق محارف محددةً، بل تجبر محرك التعبير النمطي للتحقق من الشرط (بداية ونهاية نص). نمط الأسطر المتعددة: تأثير الراية "m" على المرتكزان ^ و $ يُفعّل نمط الأسطر المتعددة باستخدام الراية m، التي تؤثر على سلوك المرتكزين $ و^ فقط، فلا تطابق المرتكزات في نمط الأسطر المتعددة بداية ونهاية النص فقط، بل بداية ونهاية السطر. البحث عند بداية سطر ^ يحتوي النص في المثال التالي على عدة أسطر، وسيأخذ النمط d/gm\^/ رقمًا من بداية كل سطر: let str = `1st place: Winnie 2nd place: Piglet 3rd place: Eeyore`; alert( str.match(/^\d/gm) ); // 1, 2, 3 سنحصل دون الراية m على الرقم الموجود في أول سطر فقط يطابق النمط: let str = `1st place: Winnie 2nd place: Piglet 3rd place: Eeyore`; alert( str.match(/^\d/g) ); // 1 والسبب أنّ العلامة ^ ستطابق افتراضيًا بداية النص فقط، بينما ستطابق في نمط الأسطر المتعددة بداية أي سطر. انتبه، فتعني بداية السطر رسميًا المحرف الذي يأتي بعد محرف السطر الجديد، وهكذا ستطابق العلامة ^ في نمط الأسطر المتعددة كل المحارف التي يسبقها محرف السطر الجديد n\. البحث عند نهاية سطر $ يسلك المرتكز $ السلوك السابق نفسه، إذ يجد التعبير النمطي $d\ مثلًا آخر رقم في كل سطر: let str = `Winnie: 1 Piglet: 2 Eeyore: 3`; alert( str.match(/\d$/gm) ); // 1,2,3 سيطابق المرتكز $ نهاية النص ككل دون استخدام الراية m، وبالتالي سنحصل فقط على آخر رقم. انتبه، فتعني نهاية السطر رسميًا المحرف الذي يأتي قبل محرف السطر الجديد، وهكذا ستطابق العلامة $ في نمط الأسطر المتعددة كل المحارف التي تسبق محرف السطر الجديد n\ مباشرةً. البحث عن محرف السطر الجديد n\ بدل المرتكز $ يمكن البحث عن سطر جديد باستخدام المحرف n\ بدلًا من البحث عن المرتكزين ^ و$، لكن ما الفرق؟ سنبحث في المثال التالي عن d\n\بدلًا من $d\: let str = `Winnie: 1 Piglet: 2 Eeyore: 3`; alert( str.match(/\d\n/gm) ); // 1\n,2\n ستجد الشيفرة تطابقين بدلًا من ثلاث، وذلك لعدم وجود سطر جديد بعد الرقم 3، على الرغم من وجود مرتكز نهاية النص الذي يطابق $، أما الاختلاف الآخر فهو أنّ n\محرف، وسيظهر ضمن النتيجة، وليس مثل المرتكزين ^ و$ اللذين يحددان شرط البحث في بداية ونهاية النص، وبالتالي سنستخدم n\ إذا أردنا ظهور محرف السطر الجديد في النتيجة، وإلا استخدمنا ^ و$. حدود الكلمة: b\ حدود الكلمة اختبار، مثل ^ و$، حيث يشير وجود العلامة b\ بأنّ الموقع الذي وصل إليه محرك بحث التعابير النمطية هو حد لكلمة، وتوجد ثلاثة مواقع مختلفة يمكن اعتبارها حدودًا لكلمة، وهي: في بداية نص، إذا كان أول محرف نصي فيه هو محرف كلمة w\. بين محرفين في نص، إذا كان أحدهما محرف كلمة w\ والآخر ليس كذلك. في نهاية نص، إذا كان آخر محرف فيه هو محرف الكلمة w\. يمكن أن تجد التعبير bJava\b\ مثلًا في النص !Hello, Java عندما تكون الكلمة Java منفصلةً عن غيرها، لكنك لن تحصل على نفس النتيجة في النص !Hello, JavaScript alert( "Hello, Java!".match(/\bJava\b/) ); // Java alert( "Hello, JavaScript!".match(/\bJava\b/) ); // null في الشكل التالي ستمثل المواقع المشار إليها العلامة b\ في النص !Hello, Java: لذلك فهي تطابق النمط bHello\b\ لأن: بداية النص تتطابق مع العلامة b\. ثم تتطابق مع الكلمة Hello. ثم تتطابق مع العلامة b\ مرةً أخرى، كما لو أنها بين المحرف o والفاصلة. إذًا سنعثر على النمط bHello\b\، لكن ليس النمط bHell\b\، لعدم وجود علامة حد الكلمة b\ بعد المحرف l، كما لن نحصل على النمط Java!\b لأن علامة التعجب ليست محرف كلمة w\، وبالتالي لا وجود لعلامة حد الكلمة b\ بعده. alert( "Hello, Java!".match(/\bHello\b/) ); // Hello alert( "Hello, Java!".match(/\bJava\b/) ); // Java alert( "Hello, Java!".match(/\bHell\b/) ); // null (no match) alert( "Hello, Java!".match(/\bJava!\b/) ); // null (no match) يمكن استخدام b\ مع الأرقام أيضًا، إذ يبحث النمط b\d\d\b\ مثلًا عن عدد من رقمين -عدد بخانتين- منفصل عن غيره، أي يبحث عن عدد بخانتين غير محاط بمحارف الكلمة w\، مثل محارف الفراغات أو علامات الترقيم أو مرتكزات بداية ونهاية نص. alert( "1 23 456 78".match(/\b\d\d\b/g) ); // 23,78 alert( "12,34,56".match(/\b\d\d\b/g) ); // 12,34,56 انتبه، لا تعمل العلامة b\ مع الأبجديات غير اللاتينية، إذ تتحقق العلامة b\ من وجود محرف كلمة w\ في طرف نص وعدم وجوده في الطرف الآخر، وبما أنّ محرف الكلمة w\ سيدل على الأحرف اللاتينية a-z أو الأرقام أو الشرطة السفلية فقط، فلن يعمل مع محارف اللغات الأخرى، مثل العربية أو الصينية. المحارف الخاصة وطريقة تجاوزها يُستخدم المحرف \ كما رأينا للإشارة إلى أصناف المحارف -مثل d\- فهو إذًا محرف خاص، وستجد محارف خاصةً أخرى تحمل معنىً خاصًا في التعابير النمطية، وتستخدم لتنفيذ عمليات بحث أكثر قدرةً، وهذه المحارف هي: [ ]\ ^ $ . | ? * + ( )، ولا حاجة طبعًا لتذكر هذه القائمة، فسنتعامل مع كل محرف منها بشكل منفصل وستحفظها تلقائيًا. التجاوز لنفترض أننا نريد البحث عن محرف النقطة بذاته، وليس أي محرف، في هذه الحالة -وبقية الحالات التي نريد فيها البحث عن محرف خاص بحد ذاته كما لو أنه محرف عادي- سنضع المحرف \ قبله، أي .\ في حالة النقطة، وتُدعى هذه العملية بالتجاوز أو تهريب محرف escaping a character، إليك مثالًا: alert( "Chapter 5.1".match(/\d\.\d/) ); // 5.1 (تطابق!) alert( "Chapter 511".match(/\d\.\d/) ); // null تعتبر الأقواس محارف خاصةً أيضًا، فلا بد من البحث عنها وفق النمط )\، ويبحث المثال التالي عن النص "()g": alert( "function g()".match(/g\(\)/) ); // "g()" إذا أردنا البحث عن المحرف الخاص \ فلا بدّ من مضاعفته \\: alert( "1\\2".match(/\\/) ); // '\' الشرطة المائلة "/" لا تعتبر الشرطة المائلة '/' من المحارف الخاصة، لكنها تستخدم في JavaScript لفتح وإغلاق التعابير النمطية /...pattern.../ فلا بد من تجاوزها أيضًا، إليك الطريقة التي نبحث فيها عن الشرطة المائلة: alert( "/".match(/\//) ); // '/' لا داعي لتجاوز الشرطة المائلة في الحالة التي لا نستخدم فيها النمط /.../، بل ننشئ كائن تعبير نمطي باستخدام new RegExp: alert( "/".match(new RegExp("/")) ); // / يجد الإنشاء باستخدام new RegExp عندما ننشئ تعبيرًا نمطيًا باستخدام new RegExp، فلا حاجة لتجاوز المحرف / لكن يتحتم علينا تجاوز غيره. تأمل الشيفرة التالية: let regexp = new RegExp("\d\.\d"); alert( "Chapter 5.1".match(regexp) ); // null لقد نجح البحث المشابه في أمثلة سابقة عندما استخدمنا /\d\.\d/، لكن لن يعمل البحث باستخدام ("new RegExp("\d\.\d فما السبب؟ السبب أن الشرطة المائلة ستُستهلك من قبل النص، وكما نتذكر فللنصوص العادية محارفها الخاصة -مثل n\- وقد استخدمت الشرطة المعكوسة backslash للتهريب. إليك مثالًا: alert("\d\.\d"); // d.d تستهلك إشارتا التنصيص الشرطة المعكوسة وتفسرها بطريقتها، فمثلًا: تتحول n\ إلى محرف السطر الجديد. تتحول u1234\ إلى محرف Unicode بهذا الرمز. وفي حال لم يوجد معنىً لوجود الشرطة المعكوسة -مثل d\ أو z\- فستُزال هذه الشرطة ببساطة. يحصل new RegExp على نص بلا شرطات معكوسة، لهذا لن يفلح البحث، ولحل المشكلة لا بدّ من مضاعفة الشرطة المعكوسة، لأن إشارتي التنصيص تحولان \\ إلى \. let regStr = "\\d\\.\\d"; alert(regStr); // \d\.\d (عملية صحيحة) let regexp = new RegExp(regStr); alert( "Chapter 5.1".match(regexp) ); // 5.1 خلاصة يتألف التعبير النمطي من نمط pattern، ورايات flags اختيارية هي: g وi وm وu وs وy. لا تختلف عملية البحث دون استخدام الرايات والرموز الخاصة عن أي عملية بحث اعتيادية ضمن نص. يبحث التابع (str.match(regexp عن كل حالات التطابق التي يحددها التعبير regexp بوجود الراية g، وإلا فسيبحث عن أول تطابق فقط. يستبدل التابع (str.replace(regexp, replacement التطابقات التي يحددها التعبير regexp بالنص replacement، حيث يستبدل التطابقات كلها عند وجود الراية g، وإلا فسيستبدل أولها فقط. يعيد التابع (regexp.test(str القيمة true إن وجد تطابقًا على الأقل مع التعبير المستخدم، وإلا فسيعيد false. هنالك أصناف محارف متعددة، هي: d\: للأرقام. D\: لغير الأرقام. s\: لمحارف الفراغات والجدولة والسطر الجديد. S\: لكل المحارف عدا محارف الفراغات والجدولة والسطر الجديد. w\: كل الأرقام والأحرف اللاتينية والشرطة السفلية (_). W\: كل المحارف عدا ما ذكرناه في السطر السابق. .: أي محرف عدا محرف السطر الجديد، وأي محرف تمامًا عند استخدام الراية "s". تؤمن مجموعة المحارف Unicode التي تستخدمها JavaScript العديد من الميزات المتعلقة بالمحارف، مثل تحديد اللغة التي ينتمي إليها محرف معين، وهل يمثل المحرف علامة ترقيم وغيرها، ويتطلب ذلك استخدام الراية u. تدعم الراية u استخدام رموز Unicode في التعابير النمطية، ويعني ذلك أمرين اثنين: ستتعامل الميزات والخصائص مع المحارف المكونة من أربع بايتات بشكل صحيح ومثل محرف واحد، وليس مثل محرفين يتكون كل منهما من بايتين. يمكن استخدام خصائص Unicode في عمليات البحث من خلال {...}\p. يمكن البحث عن كلمات في لغة محددة باستخدام خصائص Unicode، بالإضافة إلى البحث عن محارف خاصة مثل إشارات التنصيص، العملات،…. للبحث عن المحارف الخاصة [ \ ^ $ . | ? * + ( ) بذاتها، لا بدّ من وضع الشرطة المائلة \ قبلها لتجاوز الوظيفة الخاصة للمحرف. ينبغي تجاوز الشرطة المعكوسة \ أيضًا في النمط /.../، لكن ليس ضمن new RegExp. عند تمرير نص إلى new RegExp فلا بدّ من مضاعفة الشرطة المعكوسة، لأن إشارة التنصيص \\ تستهلك إحداهما. ترجمة -وبتصرف- للفصول: Patterns and flags character classes Unicode: flag "u" and class \p{…} Anchors: string start ^ and end $ Multiline mode of anchors ^ $, flag "m" Word boundary: \b Escaping special characters من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: مكونات الويب: التعامل مع شجرة DOM الخفية التعابير النمطية (regexp/PCRE) في PHP مقدمة في التعابير النمطية Regular Expressions كيفية التعامل مع النصوص في البرمجة
  9. تعرفنا في المقال السابق مكونات الويب: عناصر HTML المخصصة وقوالبها على عناصر HTML المخصصة وكيفية إنشائها وسنكمل التعرف على موضوع مهم في هذا الصدد وهو شجرة DOM الخفية Shadow DOM -والتي تترجم أيضًا إلى ظل شجرة DOM- تستخدَم للتغليف encapsulation، والتي تسمح لكل مكوِّن ويب مخصص أن يمتلك شجرةً خفيةً لا يمكن للمستند الرئيسي الوصول إليها صدفةً، وقد تمتلك قواعد تنسيق خاصة وغير ذلك من الخواص. شجرة DOM الخفية هل لديك فكرة عن مدى تعقيد إنشاء وتنسيق أدوات تحكم المتصفح؟ لنفترض وجود العنصر <"input type="range> مثلًا: input[type="range" i] { appearance: auto; cursor: default; color: rgb(157, 150, 142); padding: initial; border: initial; margin: 2px; } يستخدم المتصفح ضمنيًا DOM/CSS لرسم أدوات التحكم، ولا تظهر بنية شجرة DOM لنا عادةً، لكن يمكننا رؤيتها باستخدام أدوات التطوير بتفعيل الخيار Show user agent shadow DOM من إعدادات أدوات المطور Dev Tools تحديدًا قسم Elements في المتصفح Chrome مثلًا. ستبدو الشجرة DOM للعنصر السابق كالتالي: ما نراه تحت العنوان shadow-root# يسمَّى شجرة DOM المخفية، ولا يمكن الحصول على عناصر شجرة DOM المخفية المضمنة باستخدام استدعاءات JavaScript النظامية أو المحددات selectors، فهذه العناصر ليست أبناءً نظاميةً، بل هي تقنية تغليف متقدمة. سنرى في المثال السابق السمة المفيدة pseudo، وهي سمة غير قياسية وموجودة لأسباب تاريخية، حيث يمكننا استخدامها لتنسيق العناصر الفرعية باستخدام CSS كالتالي: <style> /* make the slider track red */ input::-webkit-slider-runnable-track { background: red; } </style> <input type="range"> See the Pen JS-p3-06-Shadow-DOM -ex1 by Hsoub (@Hsoub) on CodePen. بدأت المتصفحات باختبار الشيفرة بالاستفادة من بنى DOM الداخلية لتجهيز أدوات التحكم، وبعد فترة من الزمن طوّرت شجرة DOM الخفية وأصبحت معياريةً لتسمح لنا وللمطورين بالقيام بأشياء مماثلة. سنستخدم لاحقًا المعيار الحديث لشجرة DOM الخفية الذي تغطيه مواصفات DOM وغيرها من المواصفات ذات الصلة. الشجرة الخفية Shadow tree يمكن لكائن DOM أن يمتلك نوعين من الأشجار الفرعية: شجرة ظاهرة Light tree: وتمثل شجرةً فرعيةً نظاميةً مكوّنةً من عناصر HTML أبناء، وجميع الأشجار التي رأيناها في المقال السابق من هذا النوع. شجرة خفية Shadow tree: شجرة DOM فرعية لا تظهر على صفحة HTML، بل تكون مخفيةً من الظهور والرؤية. إذا امتلك العنصر الشجرتين السابقتين فسيصيّر المتصفح الشجرة الخفية، لكن يمكن إعداد توليفة بين الشجرتين، وسنرى تفاصيل أكثر في مقالات لاحقة. يمكن استخدام الشجرة الخفية في العناصر المخصصة لإخفاء المكوّنات الداخلية وتطبيق تنسيق خاص بالمكوِّن. يخفي العنصر <show-hello> على سبيل المثال شجرة DOM الداخلية ضمن شجرة خفية: <script> customElements.define('show-hello', class extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({mode: 'open'}); shadow.innerHTML = `<p> Hello, ${this.getAttribute('name')} </p>`; } }); </script> <show-hello name="John"></show-hello> See the Pen JS-p3-06-Shadow-DOM -ex2 by Hsoub (@Hsoub) on CodePen. ستظهر النتيجة في أدوات تطوير متصفح بالشكل التالي، حيث تكون جميع المحتويات تحت "shadow-root#": يُنشئ الاستدعاء ({...:elem.attachShadow({mode شجرةً مخفيةً أولًا، لكن توجد عقبتان: يمكن إنشاء جذر مخفي واحد لكل عنصر. يجب أن يكون العنصر عنصرًا مخصصًا، أو أحد العناصر التالية: "article". "blockquote". "body". "div". "footer". "h1…h6". "header". "main". "nav". "p". "section". "span". ولا يمكن لغير هذه العناصر استضافة شجرة خفية. يضبط الخيار mode مستوى التغليف، ويمكن أن يحمل إحدى القيمتين: "open": يمكن أن نحصل على الجذر الخفي من خلال elem.shadowRoot، ويمكن لأي شيفرة الوصول إلى الشجرة الخفية لــ elem. "closed": ويعيد الأمر elem.shadowRoot القيمة null دائمًا. يمكن الوصول إلى شجرة DOM الخفية عن طريق المرجع الذي يعيده attachShadow فقط، ومن الممكن أن يكون مخفيًا ضمن صنف، وتكون الأشجار الخفية الأصلية المتعلقة بالمتصفح -مثل <input type="range"‎>- من النوع المغلق، ولا يمكن الوصول إليها، ويشابه الجذر الخفي shadow root الذي يعيده attachShadow العناصر، ويمكن إظهاره باستخدام innerHTML أو توابع DOM مثل append. يُدعى العنصر الذي يمتلك جذرًا خفيًا بمضيف الشجرة الخفية shadow tree host، ويتاح للاستخدام ضمن الخاصية host للجذر الخفي: // لن يعيد شيئًا elem.shadowRoot وإلا {mode: "open"} بافتراض أن alert(elem.shadowRoot.host === elem); // محقق التغليف Encapsulation تفصَل شجرة DOM الخفية عن المستند الرئيسي: لا تكون عناصر شجرة DOM الخفية مرئيةً للتابع querySelector من شجرة DOM الظاهرة، وقد تمتلك عناصر شجرة DOM الخفية معرفات id تتعارض مع تلك الموجودة في شجرة DOM الظاهرة، فهي فريدة ضمن الشجرة الخفية فقط. لشجرة DOM الخفية أوراق تنسيق خاصة بها، إذ لا تُطبق قواعد التنسيق الخارجية عليها. إليك مثالًا: <style> /*(1) #elem لن يطبق التنسيق على الشجرة الخفية للعنصر*/ p { color: red; } </style> <div id="elem"></div> <script> elem.attachShadow({mode: 'open'}); // (للشجرة الخفية تنسيقها الخاص (2 elem.shadowRoot.innerHTML = ` <style> p { font-weight: bold; } </style> <p>Hello, John!</p> `; // مرئية فقط للاستعلامات التي تجري داخل الشجرة <p> (3) alert(document.querySelectorAll('p').length); // 0 alert(elem.shadowRoot.querySelectorAll('p').length); // 1 </script> تشير (1) و(2) و(3) أعلاه إلى الآتي: (1) لا يؤثر تنسيق الصفحة على تنسيق الشجرة الخفية. (2) يؤدي التنسيق داخل الشجرة عمله بطريقة صحيحة. (3) للحصول على العناصر من الشجرة الخفية، لا بدّ من الاستعلام عنها من داخل الشجرة. تقليم شجرة DOM الخفية وعملية التركيب تحتاج العديد من المكوّنات إلى محتوىً لتصييره، مثل النوافذ ومعارض الصور والقوائم وغيرها، ويتوقع العنصر المخصص <custom-tabs> محتوى نافذة ليمرَّر إليه، على غرار العناصر الأصلية المضمنة في المتصفح مثل <select> -عدا عناصر <option>-، ويحتاج العنصر <custom-menu> كذلك إلى محتوى على شكل عناصر قائمة. قد تبدو الشيفرة التي تستعمل العنصر <custom-menu> كالتالي: <custom-menu> <title>Candy menu</title> <item>Lollipop</item> <item>Fruit Toast</item> <item>Cup Cake</item> </custom-menu> سيتمكن بعدها مكوّننا من تصيير المحتوى بصورة صحيحة، على شكل قائمة جميلة لها عنوان وعناصر ومعالجات لأحداث القائمة وغيرها، ولكن كيف سننجز ذلك؟ يمكننا أن نحاول تحليل محتوى العناصر وأن ننسخ أو نعيد ترتيب عقد DOM ديناميكيًا، لكن إذا كنا سننقل عناصر إلى شجرة DOM الخفية shadow DOM، فلن يُطبَّق تنسيق CSS الموجود ضمن المستند عليها، وبالتالي سنفقد التنسيق المرئي، وسيحتاج ذلك إلى كتابة بعض الشيفرة، ولحسن الحظ ليس علينا فعل ذلك، إذ تدعم شجرة DOM الخفية عناصر <slot> التي تُملأ تلقائيًا بالمحتوى الموجود في شجرة DOM الظاهرة وشبهنا هذه العملية بعملية "التقليم" التي تجرى على الأشجار من قص وإنشاء فتحة في الشجرة ووضع فرع في الفتحة من شجرة أو نوع آخر لذلك أطلقنا عليها هذا الاسم وسنستعمل اسم "فتحة" المقابل للكلمة الأجنبية slot (اسم العنصر) للسهولة. الفتحات المسماة Named slots لنلق نظرةً على طريقة عمل التقليمات في هذا المثال البسيط، حيث توفر شجرة DOM الخفية للعنصر المخصص <user-card> فتحتين تُملآن من شجرة DOM الظاهرة: <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <div>Name: <slot name="username"></slot> </div> <div>Birthday: <slot name="birthday"></slot> </div> `; } }); </script> <user-card> <span slot="username">John Smith</span> <span slot="birthday">01.01.2001</span> </user-card> See the Pen JS-p3-06-Shadow-DOM -ex3 by Hsoub (@Hsoub) on CodePen. يعرّف العنصر <"slot name="X> في شجرة DOM الخفية نقطة إدراج، وهو المكان الذي تُصيَّر فيه العناصر التي تعرف فيها الخاصية "slot="X، ثم ينفِّذ المتصفح عملية تركيب composition، إذ يأخذ عناصر من شجرة DOM الظاهرة، ويصيّرها ضمن الفتحات الملائمة ضمن شجرة DOM الخفية، وهكذا سنحصل في نهاية الأمر على مكوِّن يمكن ملؤه بالبيانات، وهذا ما نريده. إليك بنية الشجرة DOM بعد تنفيذ السكربت مهملين عملية التركيب: <user-card> #shadow-root <div>Name: <slot name="username"></slot> </div> <div>Birthday: <slot name="birthday"></slot> </div> <span slot="username">John Smith</span> <span slot="birthday">01.01.2001</span> </user-card> لقد أنشأنا شجرة DOM خفيةً، وسنجدها تحت ‎#shadow-root. سيمتلك العنصر الآن شجرتي DOM الظاهرة والخفية. سيبحث المتصفح -لأغراض التصيير- عن السمة "..."=slot لكل <"..."=slot name> التي لها نفس الاسم في شجرة DOM الظاهرة، وستُصيَّر هذه العناصر ضمن الفتحات. وعندها ستسمى النتيجة شجرة DOM المسطّحة flattened DOM. <user-card> #shadow-root <div>Name: <slot name="username"> <!-- slotted element is inserted into the slot --> <span slot="username">John Smith</span> </slot> </div> <div>Birthday: <slot name="birthday"> <span slot="birthday">01.01.2001</span> </slot> </div> </user-card> لقد أوُجِدت شجرة DOM المسطحة لأغراض التصيير ومعالجة الأحداث، قد تبدو الطريقة التي تُعرض بها الأمور افتراضيةً نوعًا ما، لكن لن تُنقَل العقد في المستند فعليًا، فمن السهل التحقق من أنّ العقد لا تزال في مكانها بتنفيذ الأمر querySelectorAll. alert( document.querySelectorAll('user-card span').length ); // 2 تُشتقّ شجرة DOM المسطحة من شجرة DOM الخفية بإدراج الفتحات، حيث يصيّرها المتصفح ويستخدمها في وراثة التنسيق وانتقال الأحداث -وهو ما سنوضّحه لاحقًا-، لكن نظرتها لن تغير JavaScript إلى المستند قبل الوصول إلى الشجرة المسطحة. ملاحظة: يمكن للأبناء من المستوى الأعلى فقط امتلاك السمة "…"=slot، إذ يمتلك الأبناء المباشرون للمضيف الخفي -وهو العنصر <user-card> في مثالنا- السمة "..."=slot فقط، بينما ستُهمل هذه السمة للعناصر الداخلية. وسيتجاهل المتصفح في المثال التالي العنصر <span> الثاني، فهو ليس ابنًا مباشرًا للعنصر <user-card>: <user-card> <span slot="username">John Smith</span> <div> <!-- invalid slot, must be direct child of user-card --> <span slot="birthday">01.01.2001</span> </div> </user-card> إذا وجدت عناصر لها فتحات بنفس الاسم في شجرة DOM الظاهرة، فستوضع في الفتحة واحدةً تلو الأخرى، إليك المثال التالي: <user-card> <span slot="username">John</span> <span slot="username">Smith</span> </user-card> إليك مثالًا عن شجرة DOM مسطحة تمتلك عنصرين ضمن الفتحة <"slot name="username>: <user-card> #shadow-root <div>Name: <slot name="username"> <span slot="username">John</span> <span slot="username">Smith</span> </slot> </div> <div>Birthday: <slot name="birthday"></slot> </div> </user-card> المحتوى الابتدائي للعنصر slot إذا وُضع محتوًى ما ضمن الفتحة <slot>، فسيصبح هو المحتوى الافتراضي حيث سيعرضه المتصفح إذا لم يوجد محتوىً مناسب ضمن شجرة DOM الظاهرة لملء الفتحة. عندها ستكون نتيجة تصيير هذا الجزء مثلًا من شجرة DOM الخفية هي Anonymous إذا لم توجد فتحة باسم "slot="username في شجرة DOM الظاهرة. <div>Name: <slot name="username">Anonymous</slot> </div> الفتحة الافتراضية هي أول فتحة بلا اسم ستكون أول فتحة <slot> في شجرة DOM الخفية غير مالكة لاسم هي الفتحة الافتراضية، وستحصل على كل العقد في شجرة DOM الظاهرة التي لم تُدرج في أي فتحة أخرى. لنضف في المثال التالي فتحةً افتراضيةً للعنصر المخصص <user-card> الذي سيخفي كل معلومات المستخدم غير المدرجة في فتحات: <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <div>Name: <slot name="username"></slot> </div> <div>Birthday: <slot name="birthday"></slot> </div> <fieldset> <legend>Other information</legend> <slot></slot> </fieldset> `; } }); </script> <user-card> <div>I like to swim.</div> <span slot="username">John Smith</span> <span slot="birthday">01.01.2001</span> <div>...And play volleyball too!</div> </user-card> See the Pen JS-p3-06-Shadow-DOM -ex4 by Hsoub (@Hsoub) on CodePen. سيوضع محتوى شجرة DOM الظاهرة غير المُدرج في المثال السابق ضمن العنصر <fieldset> الذي يحمل تسمية Other information، حيث ستُدرج العناصر ضمن الفتحة واحدًا تلو الآخر، ولذا ستُدرج قطعتا المعلومات معًا ضمن الفتحة الافتراضية. وستبدو شجرة DOM المسطحة كالتالي: <user-card> #shadow-root <div>Name: <slot name="username"> <span slot="username">John Smith</span> </slot> </div> <div>Birthday: <slot name="birthday"> <span slot="birthday">01.01.2001</span> </slot> </div> <fieldset> <legend>Other information</legend> <slot> <div>I like to swim.</div> <div>...And play volleyball too!</div> </slot> </fieldset> </user-card> مثال القائمة لنعد الآن إلى العنصر المخصص الذي أشرنا إليه سابقًا في هذا المقال، حيث يمكن استخدام الفتحات لتوزيع العناصر، إليك ترميز HTML للعنصر المخصص <custom-menu>: <custom-menu> <span slot="title">Candy menu</span> <li slot="item">Lollipop</li> <li slot="item">Fruit Toast</li> <li slot="item">Cup Cake</li> </custom-menu> تمثل الشيفرة التالية قالب شجرة DOM الخفية مزوّدةً بالفتحات الملائمة: <template id="tmpl"> <style> /* menu styles */ </style> <div class="menu"> <slot name="title"></slot> <ul><slot name="item"></slot></ul> </div> </template> يُدرج <"span slot="title> ضمن <"slot name="title>. هناك العديد من عناصر القائمة <li slot="item"> في القالب، لكن توجد فتحة مناسبة <slot name="item"> واحدة ضمنه، لذلك ستُدرج كل عناصر القائمة -مثل <li slot="item">- ضمن الفتحة الواحد تلو الآخر، وهذا ما يُشكِّل القائمة. ستصبح شجرة DOM المسطّحة كالتالي: <custom-menu> #shadow-root <style> /* menu styles */ </style> <div class="menu"> <slot name="title"> <span slot="title">Candy menu</span> </slot> <ul> <slot name="item"> <li slot="item">Lollipop</li> <li slot="item">Fruit Toast</li> <li slot="item">Cup Cake</li> </slot> </ul> </div> </custom-menu> قد نُلاحظ تواجد العنصر <li> ضمن القائمة <ul> في شجرة DOM صالحة، لكن الشجرة في الشيفرة السابقة مسطحة تصف آلية تصيير المكوِّن، وهذا الأمر طبيعي، ويبقى علينا إضافة معالج للحدث click لفتح وإغلاق القائمة <custom-menu> لتصبح جاهزةً: customElements.define('custom-menu', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); //هو قالب الشجرة الخفية في الأعلى this.shadowRoot.append( tmpl.content.cloneNode(true) ); // لا يمكن اختيار عقد من الشجرة الظاهرة، لذلك تُعالج النقرات ضمن الفتحة this.shadowRoot.querySelector('slot[name="title"]').onclick = () => { // فتح وإغلاق القائمة this.shadowRoot.querySelector('.menu').classList.toggle('closed'); }; } }); وستكون النتيجة: See the Pen JS-p3-06-Shadow-DOM -ex5 by Hsoub (@Hsoub) on CodePen. يمكن بالطبع تعزيز وظائف القائمة من خلال الأحداث والتوابع وغيرها. تحديث الفتحات ماذا لو أرادت الشيفرة الخارجية إضافة أو إزالة العناصر ديناميكيًا؟ يراقب المتصفح الفتحات، ثم تُحدَّث العناصر المصيّرة إذا تغيرت أو أزيلت العناصر المدرجة، وطالما ستُصيَّر العناصر الظاهرة فقط في الفتحات -ولن تُنسَخ- فستظهر أي تغييرات عليها مباشرةً، فلا حاجة لفعل أي شيء لتحديث نتيجة التصيير، لكن يمكن استخدام الحدث slotchange إذا أرادت شيفرة المكوّن مراقبة التغيرات في الفتحات. يُدرَج في المثال التالي عنصر قائمة ديناميكيًا بعد ثانية، ويتغير العنوان بعد ثانيتين: <custom-menu id="menu"> <span slot="title">Candy menu</span> </custom-menu> <script> customElements.define('custom-menu', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = `<div class="menu"> <slot name="title"></slot> <ul><slot name="item"></slot></ul> </div>`; //لا يمتلك الجذر الخفي معالجات أحداث لذا سنستخدم الابن الأول this.shadowRoot.firstElementChild.addEventListener('slotchange', e => alert("slotchange: " + e.target.name) ); } }); setTimeout(() => { menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Lollipop</li>') }, 1000); setTimeout(() => { menu.querySelector('[slot="title"]').innerHTML = "New menu"; }, 2000); </script> See the Pen JS-p3-06-Shadow-DOM -ex6 by Hsoub (@Hsoub) on CodePen. يُحدَّث تصيير القائمة كل مرة دون تدخلنا، وسنلاحظ في الشيفرة السابقة وجود حدثي slotchange: عند التهيئة: حيث يقع الحدث slotchange: title مباشرةً عندما يُدرج "slot="title من شجرة DOM الظاهرة ضمن الفتحة الموافقة له. بعد ثانية: يقع الحدث slotchange: item عندما يُضاف عنصر قائمة جديد <"li slot="item>. لاحظ أنه لا وجود للحدث slotchange بعد ثانيتين عندما يُعدَّل محتوى "slot="title، والسبب هو عدم وجود أي تغييرات في الفتحة، حيث عدّلنا محتوى عنصر مُدرج في الفتحة، وهذا أمر مختلف. يمكن تتبع التغيرات الداخلية ضمن شجرة DOM الظاهرة باستخدام شيفرة JavaScript من خلال الآلية المعمّمة المعروفة بـ MutationObserver. الواجهة البرمجية للفتحات لنلق نظرةً على توابع JavaScript المتعلقة بالفتحات، إذ تنظر JavaScript كما رأينا سابقًا إلى شجرة DOM الحقيقية دون تسطيح flattening، لكن إذا كانت قيمة الخيار mode في الشجرة الخفية هي "open"، فسنتمكن من معرفة أي العناصر أدرِجت في فتحات، كما يمكننا معرفة الفتحة من العنصر الذي أدرج ضمنه: node.assignedSlot: تعيد الفتحة <slot> التي أسندت إليها عقدة من الشجرة node. ({slot.assignedNodes({flatten: true/false: يعيد عقد DOM التي أسندت إلى فتحة، وستكون قيمة الخيار flatten هي "false" افتراضيًا، لكن إذا ضبطت على القيمة "true" صراحةً، فسيُنظر إلى DOM المسطحة بعمق، كما سيعيد التابع الفتحات المتداخلة nested في حال وجود مكوّنات متداخلة، ويعيد "محتوى التراجع" إذا لم يجد أي عقد أسندت إلى فتحات. ({slot.assignedElements({flatten: true/false: يعيد عناصر DOM التي أسندت إلى فتحة، وهو يشابه التابع السابق إلا أنه يعيد عقد العنصر. تُستخدَم هذه التوابع عندما نريد إظهار محتوى الفتحات وتتبعها باستخدام JavaScript. وإذا أراد المكوّن <custom-menu> مثلًا معرفة ما الذي يعرضه، فيمكنه تعقّب الحدث slotchange والحصول على العناصر من خلال slot.assignedElements: <custom-menu id="menu"> <span slot="title">Candy menu</span> <li slot="item">Lollipop</li> <li slot="item">Fruit Toast</li> </custom-menu> <script> customElements.define('custom-menu', class extends HTMLElement { items = [] connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = `<div class="menu"> <slot name="title"></slot> <ul><slot name="item"></slot></ul> </div>`; // يقع عندما يتغير محتوى الفتحة this.shadowRoot.firstElementChild.addEventListener('slotchange', e => { let slot = e.target; if (slot.name == 'item') { this.items = slot.assignedElements().map(elem => elem.textContent); alert("Items: " + this.items); } }); } }); // items update after 1 second setTimeout(() => { menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')}, 1000); </script> See the Pen Untitled by Hsoub (@Hsoub) on CodePen. تنسيق شجرة DOM الخفية يمكن أن تحتوي شجرة DOM الخفية على الوسمين <style> و<link rel="stylesheet" href="…"‎>، وتُخزّن أوراق التنسيق في الوسم الأخير في ذاكرة HTTP المؤقتة، لذلك لن يُعاد تحميلها للمكونات المختلفة التي تستخدم نفس القالب، ويمكن أن نعد ما لي قاعدةً عامةً: يعمل التنسيق المحلي داخل الشجرة الخفية، بينما تعمل تنسيقات المستند ككل خارجها، مع وجود بعض الاستثناءات. المحدد host: يسمح هذا المحدد باختيار المضيف الخفي، أي العنصر الذي يحتوي الشجرة الخفية، لنفترض مثلًا أننا سننشئ عنصرًا مخصصًا <custom-dialog> يجب أن يتوسط الصفحة، لذلك لا بدّ من تنسيق المكوّن <custom-dialog> ذاته، وهذا ما يفعله host: تمامًا: <template id="tmpl"> <style> /* the style will be applied from inside to the custom-dialog element */ :host { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); display: inline-block; border: 1px solid red; padding: 10px; } </style> <slot></slot> </template> <script> customElements.define('custom-dialog', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}).append(tmpl.content.cloneNode(true)); } }); </script> <custom-dialog> Hello! </custom-dialog> See the Pen JS-p3-06-Shadow-DOM-styling -ex1 by Hsoub (@Hsoub) on CodePen. توريث التنسيقات Cascading يبقى المضيف الخفي -<custom-dialog> ذاته- ضمن شجرة DOM الظاهرة، لذلك سيتأثر بقواعد CSS التي يخضع لها المستند كاملاً، فإذا وجدت خاصية جرى تنسيقها محليًا ضمن host: وفي المستند بنفس الوقت، فسيُطبق حينها تنسيق المستند. فإن كان المستند مثلًا: <style> custom-dialog { padding: 0; } </style> فسيُعرض العنصر <custom-dialog> دون حاشية padding. من الملائم أن نعّرف تنسيقًا افتراضيًا للمكوّن ضمن القاعدة host:، ثم نعيد التنسيق -إذا أردنا- باستخدام تنسيق المستند، ويبقى الاستثناء في الخاصية التي نعنونها important!،عندها يُطبق التنسيق المحلي. المحدد (host(selector: هو نفسه host: لكنه يُطبّق فقط إذا تطابق اسم المضيف الخفي مع الوسيط selector، فعلى سبيل المثال: نريد توسيط المكوّن <custom-dialog> في الصفحة فقط إذا امتلك السمة centered: <template id="tmpl"> <style> :host([centered]) { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); border-color: blue; } :host { display: inline-block; border: 1px solid red; padding: 10px; } </style> <slot></slot> </template> <script> customElements.define('custom-dialog', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}).append(tmpl.content.cloneNode(true)); } }); </script> <custom-dialog centered> Centered! </custom-dialog> <custom-dialog> Not centered. </custom-dialog> See the Pen JS-p3-06-Shadow-DOM-styling -ex2 by Hsoub (@Hsoub) on CodePen. والآن سيُطبّق التوسيط فقط على المكوّن الأول <custom-dialog centered>. باختصار، يمكن استخدام عائلة المحددات host: لتنسيق العنصر الرئيسي للمكوِّن بناءً على السياق، ويمكن تغيير هذه التنسيقات -ما لم تستخدم السمة important!- من قبل تنسيق المستند. تنسيق محتوى الفتحات لننظر إلى حالة وجود فتحات slots. تأتي المكوّنات المقلمة slotted components من شجرة DOM الظاهرة، لذا ستستخدم تنسيقات المستند، ولن يؤثر التنسيق المحلي على المحتوى المركب. سنرى في المثال التالي أنّ العنصر المركب <span> سيظهر بخط سميك وفق تنسيق المستند، لكنه لن يتأثر بقيمة الخاصية background العائدة للتنسيق المحلي للعنصر: <style> span { font-weight: bold } </style> <user-card> <div slot="username"><span>John Smith</span></div> </user-card> <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <style> span { background: red; } </style> Name: <slot name="username"></slot> `; } }); </script> See the Pen JS-p3-06-Shadow-DOM-styling -ex3 by Hsoub (@Hsoub) on CodePen. سيكون الخط سميكًا لكنه ليس باللون الأحمر. وإذا أردنا تنسيق العناصر المركبة في مكوننا فأمامنا خياران هما: الأول هو تنسيق الفتحة <slot> نفسه اعتمادًا على وراثة تنسيق CSS: <user-card> <div slot="username"><span>John Smith</span></div> </user-card> <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <style> slot[name="username"] { font-weight: bold; } </style> Name: <slot name="username"></slot> `; } }); </script> See the Pen JS-p3-06-Shadow-DOM-styling -ex4 by Hsoub (@Hsoub) on CodePen. سيكون الخط سميكًا في <p>John Smith</p>، وذلك لأن وراثة CSS ستنقل التنسيق من الفتحة <slot> إلى المحتويات، لكن ليست كل خصائص CSS قابلة للوراثة. الخيار الثاني هو استخدام محدد الصنف الوهمي (slotted(selector::، والتي تطابق العناصر بناءً على شرطين: أن يكون العنصر مركبًا وآتيًا من الشجرة الظاهرة، ولا يهم اسم الفتحة هنا، ويطبق على العنصر المركب ذاته وليس على أبنائه. أن يطابق العنصر قيم الوسيط selector. يختار المحدد (slotted(div:: في مثالنا التالي العنصر <"div slot="username> تمامًا وليس أبناءه: <user-card> <div slot="username"> <div>John Smith</div> </div> </user-card> <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <style> ::slotted(div) { border: 1px solid red; } </style> Name: <slot name="username"></slot> `; } }); </script> See the Pen JS-p3-06-Shadow-DOM-styling -ex5 by Hsoub (@Hsoub) on CodePen. لاحظ أنه يمكن للمحدد slotted:: التقدم داخل عناصر الفتحة، لذا تكون المحددات التالية غير صحيحة: ::slotted(div span) { /*مع هذا المحدد div لايتطابق العنصر */ } ::slotted(div) p { /* DOM لا يمكنه التقدم داخل */ } يمكن استخدام slotted:: أيضًا في querySelector. خطافات CSS مع خاصيات مخصصة كيف ننسق العناصر الداخلية للمكوِّن من المستند الرئيسي؟ تطبق المحددات -مثل host:- القواعد على عناصر مخصصة، مثل <custom-dialog> أو <user-card>، لكن كيف ننسق عناصر شجرة DOM الخفية ضمنها؟ لا يوجد محدد قادر على التأثير المباشر على تنسيق شجرة DOM الخفية من المستند الرئيسي، لكن -وكما عرضنا توابع للتفاعل مع مكوننا- يمكن عرض متغيرات CSS -خصائص CSS مخصصة- إلى التنسيق. تتواجد الخصائص المخصصة على كل المستويات وفي كلتا الشجرتين الخفية والظاهرة، حيث يمكن مثلًا استخدام المتغير ‎--user-card-field-color في شجرة DOM الخفية لتنسيق الحقول، ويمكن أن يضبط المستند الخارجي قيمتها: <style> .field { color: var(--user-card-field-color, black); /* معرفًا استخدم اللون الأسود --user-card-field-color */ } </style> <div class="field">Name: <slot name="username"></slot></div> <div class="field">Birthday: <slot name="birthday"></slot></div> عندها يمكن التصريح عن هذه الخاصية في المستند الخارجي للمكوّن <user-card>: user-card { --user-card-field-color: green; } تخترق خاصيات CSS المخصصة شجرة DOM الخفية، وهي مرئية في كل مكان، لذلك يمكن للقاعدة الداخلية field. استخدامها، وإليك مثالًا كاملًا: <style> user-card { --user-card-field-color: green; } </style> <template id="tmpl"> <style> .field { color: var(--user-card-field-color, black); } </style> <div class="field">Name: <slot name="username"></slot></div> <div class="field">Birthday: <slot name="birthday"></slot></div> </template> <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.append(document.getElementById('tmpl').content.cloneNode(true)); } }); </script> <user-card> <span slot="username">John Smith</span> <span slot="birthday">01.01.2001</span> </user-card> See the Pen JS-p3-06-Shadow-DOM-styling -ex6 by Hsoub (@Hsoub) on CodePen. شجرة DOM الخفية والأحداث إنّ الفكرة الأساسية من الشجرة الخفية هي تغليف تفاصيل التنفيذ الداخلي للمكوّن. لنفترض وقوع حدث نقرٍ داخل شجرة DOM الخفية للمكوّن <user-card>، لكن لا تملك السكربتات في المستند الرئيسي أي فكرة عن ما يجري داخل الشجرة، وخاصةً إذا أتى المكون من طرف ثالث، لذا يعيد المتصفح استهداف الحدث للمحافظة على تغليف التفاصيل. تستهدف الأحداث التي تحدث في شجرة DOM الخفية العنصر المضيف عندما تُلتقط خارج إطار المكوِّن. وإليك مثالًا بسيطًا: <user-card></user-card> <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = `<p> <button>Click me</button> </p>`; this.shadowRoot.firstElementChild.onclick = e => alert("Inner target: " + e.target.tagName); } }); document.onclick = e => alert("Outer target: " + e.target.tagName); </script> See the Pen JS-p3-06-Shadow-DOM-and-events -ex1 by Hsoub (@Hsoub) on CodePen. إذا نقرت على الزر فستظهر الرسائل التالية: الهدف الداخلي BUTTON: معالج حدث داخلي يحصل على الهدف الصحيح، وهو عنصر داخل الشجرة الخفية. الهدف الخارجي USER-CARD: معالج حدث المستند، والذي يحصل على المضيف الخفي مثل هدف. إنّ إعادة استهداف retargeting الأحداث أمر مفيد جدًا، إذ يُفترض أن لا يعرف المستند الخارجي ما يوجد داخل المكوّن، فالحدث قد وقع من وجهة نظره على المكوّن <user-card>. لا تحدث إعادة الاستهداف إذا وقع الحدث في عنصر مُركّب، وذلك لأنه موجود فيزيائيًا في شجرة DOM الظاهرة، فلو نقر مستخدم مثلًا على <span slot="username"‎> في المثال التالي، فسيستهدف الحدث العنصر span هذا بالتحديد، وفي كلا معالجي الحدث الخاصين بالشجرتين الظاهرة والخفية: <user-card id="userCard"> <span slot="username">John Smith</span> </user-card> <script> customElements.define('user-card', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = `<div> <b>Name:</b> <slot name="username"></slot> </div>`; this.shadowRoot.firstElementChild.onclick = e => alert("Inner target: " + e.target.tagName); } }); userCard.onclick = e => alert(`Outer target: ${e.target.tagName}`); </script> See the Pen JS-p3-06-Shadow-DOM-and-events -ex2 by Hsoub (@Hsoub) on CodePen. فإذا نُقر الاسم "John Smith"، فسيكون الهدف بالنسبة لمعالجي الحدث الداخلي والخارجي هو <"span slot="username>، وهنا لن يُعاد الاستهداف لأنه عنصر من DOM الظاهرة. من ناحية أخرى، إذا نُقر عنصر يعود إلى شجرة DOM الخفية مثل العنصر <b>Name</b>، فسُيعاد ضبط هدف الحدث event.target أثناء انسياب الحدث لخارج شجرة DOM الخفية ليصبح <user-card>. التابعان Bubbling و()event.composedPath تُستخدم DOM المسطحة لدفع الأحداث إلى الخارج bubbling، لذا إذا وُجد عنصر مركب slotted في فتحة ووقع حدث في مكان ما بداخله، فسيندفع الحدث إلى خارج العنصر باتجاه الفتحة <slot> وإلى الأعلى، ويمكن الحصول على المسار الكامل للهدف الأصلي للحدث مع كل عناصر شجرته الخفية باستخدام التابع ()event.composedPath، ويؤخذ هذا المسار -كما يدل اسم التابع- بعد عملية التركيب، وإليك شجرة DOM المسطحة للمثال السابق: <user-card id="userCard"> #shadow-root <div> <b>Name:</b> <slot name="username"> <span slot="username">John Smith</span> </slot> </div> </user-card> عند النقر على <span slot="username">، سيعيد استدعاء التابع ()event.composedPath المصفوفة التالية: [span, slot, div, shadow-root, user-card, body, html, document, window]، وهي شجرة العناصر كاملةً ابتداءًا من العنصر المُستهدف في شجرة DOM المسطحة بعد التركيب. التابع event.composed تنساب معظم الأحداث إلى خارج حدود شجرة DOM الخفية، لكن هذا لا ينطبق على بعضها، وذلك بحسب قيمة كائن الحدث composed، فإذا كانت "true" فستعبر الأحداث حدود الشجرة، وإلا فستلتقَط داخل حدود الشجرة فقط. إذا ألقينا نظرةً على توصيفات أحداث واجهة المستخدم UI Events specification، فسنجد أن قيمة composed هي "true" للأحداث: blur وfocus وfocusin وfocusout. click وdblclick. mousedown وmouseup وmousemove وmouseout وmouseover. wheel. beforeinput وinput وkeydown وkeyup. جميع أحداث اللمس والتأشير. أما الأحداث التي يكون فيها composed: false فهي: mouseenter وmouseleave: التي لا تنساب للخارج إطلاقًا. load وunload وabort وerror. select. slotchange. يمكن التقاط هذه الأحداث من قبل العناصر داخل شجرة DOM نفسها، حيث يقيم العنصر المستهدف. الأحداث المخصصة لا بدّ من ضبط الخاصيتين bubbles وcomposed على القيمة "true" إذا أردنا إيفاد أحداث مخصصة تنساب إلى مستوى أعلى وإلى خارج المكوّن، وسننشئ في المثال التالي div#inner في شجرة DOM الخفية ضمن div#outer ونطلق حدثين فيها، وسيصل الحدث الذي يمتلك الخاصية composed: true فقط إلى الخارج، وصولًا إلى المستند: <div id="outer"></div> <script> outer.attachShadow({mode: 'open'}); let inner = document.createElement('div'); outer.shadowRoot.append(inner); /* div(id=outer) #shadow-dom div(id=inner) */ document.addEventListener('test', event => alert(event.detail)); inner.dispatchEvent(new CustomEvent('test', { bubbles: true, composed: true, detail: "composed" })); inner.dispatchEvent(new CustomEvent('test', { bubbles: true, composed: false, detail: "not composed" })); </script> خلاصة شجرة DOM الخفية هي طريقة لإنشاء شجرة محلية خاصة بالمكوِّن. ({shadowRoot = elem.attachShadow({mode: open|closed: ينشئ شجرة DOM خفيةً للعنصر elem، ويمكن الوصول إليها باستخدام الخاصية elem.shadowRoot إذا كانت الخاصية "mode="open. يمكن تعميم الكائن shadowRoot باستخدام innerHTML أو توابع DOM أخرى. عناصر شجرة DOM الخفية: لها فضاء خاص لقيم المعرّفات id. غير مرئية لمحددات JavaScript الموجودة في المستند الرئيسي، مثل querySelector. تستخدم التنسيق الموجود ضمن الشجرة الخفية فقط، وليس تنسيق المستند الرئيسي. تُصيّر شجرة DOM الخفية -إن وجدت- ضمن ما يُسمى شجرة DOM الظاهرة light DOM، مثل عناصر أبناء نظاميين. إذا امتلك عنصر شجرة DOM خفيةً، فلن تُعرض شجرة DOM الظاهرة عادةً، لذلك تسمح الفتحات slots -الناتجة عن عملية تقليم الشجرة- بعرض عناصر من شجرة DOM الظاهرة في أماكن محددة من شجرة DOM الخفية، ويوجد نوعان من الفتحات: الفتحات المسماة <slot name="X">...</slot>: الحصول على أبناء شجرة DOM الظاهرة فتحتها slot="X"‎. الفتحة الافتراضية: وهي أول فتحة <slot> لا تحمل اسمًا، وتهمَل الفتحات غير المسماة التي تأتي لاحقًا، كما يحصل على أبناء شجرة DOM الظاهرة الذين لم يُدرجوا في فتحات. إذا وجدَت عدة عناصر في الفتحة نفسها، فستُدرج واحدًا بعد الآخر. يُستخدَم محتوى الفتحة <slot> محتوىً للتراجع، لذا يظهر هذا المحتوى عند عدم وجود أبناء لشجرة DOM الظاهرة في الفتحة. تُدعى عملية تصيير العناصر المدرجة في فتحات ضمن فتحاتها بالتركيب composition، وتسمى النتيجة شجرة DOM المسطّحة. لا تنقل عملية التركيب العقد في الواقع، فمن وجهة نظر JavaScript ستبقى شجرة DOM نفسها، ويمكن الوصول إلى الفتحات من خلال توابع JavaScript التالية: ()slot.assignedNodes/Elements: يعيد العقد أو العناصر داخل فتحة. node.assignedSlot: وهي الخاصية المعاكسة، وتعيد الفتحة من العقدة التي تشغلها. إذا أردنا معرفة ما نُظهره، فيمكننا تعقب محتوى الفتحة باستخدام: الحدث slotchange: ويقع في المرة الأولى التي تُملأ بها الفتحة، أو أثناء عمليات الإضافة أو الإزالة أو الاستبدال لعنصر مُدرج في الفتحة وليس لأبنائه، وستكون الفتحة event.target. MutationObserver للغوص عميقًا في محتوى الفتحة ومعرفة التغيرات التي حدثت ضمنها. بعد أن تعرفنا على كيفية إظهار عناصر من شجرة DOM الظاهرة، سنتعرف على كيفية تنسيقها بالشكل الصحيح، وتبقى القاعدة الأساسية هي أن عناصر شجرة DOM الخفية تُنسّق من الداخل، والظاهرة من الخارج -مع بعض الاستثناءات-، وهذا ما سنتعرف عليه في المقال القادم. يمكن أن تحوي شجرة DOM الخفية تنسيقات مثل <style> أو <"link rel="stylesheet>، وتؤثر التنسيقات المحلية في: الشجرة الخفية. المضيف الخفي عندما يستعمل عائلة المحددات host:. العناصر المركبة القادمة من شجرة DOM الظاهرة، حيث يسمح المحدد (slotted(selector:: باختيار العناصر المركبة ذاتها وليس أبنائها. بينما تؤثر تنسيقات المستند الرئيسي في: المضيف الخفي، إذا كان حيًّا في المستند الخارجي. العناصر المركبة ومحتوياتها، إذا كانت أيضًا في المستند الخارجي. سيُطبق تنسيق المستند الخارجي عندما يحدث تضارب في تنسيق العنصر، إلا عند استخدام الخاصية مع عنوان important!، حيث يُطبق التنسيق المحلي. تخترق خصائص CSS المخصصة شجرة DOM الخفية، وتستخدم مثل خطافات hooks لتنسيق المكوّن: يستخدم المكوِّن خصائص CSS المخصصة لتنسيق العناصر المفتاحية، مثل (<var(--component-name-title, <default value. ينشر مؤلف المكون هذه الخصائص للمطورين، وهي بأهمية توابع المكوّنات العامة. عندما لا يرغب المطور في قضاء وقت طويل على التنسيق، فسيسند الخاصية ‎--component-name-title إلى المضيف الخفي أو الخاصية التي أشرنا إليها في الأعلى. الربح طبعًا! تتجاوز الأحداث حدود شجرة DOM الخفية إذا كانت قيمة الراية composedهي true، والتي تكون كذلك لمعظم الأحداث المضمنة Built-in، كما هو محدد في التوصيفات: أحداث واجهة المستخدم. أحداث اللمس. أحداث التأشير. من الأحداث المضمنة التي يكون فيها composed: false: mouseenter وmouseleave: لا تنساب للخارج إطلاقًا. load وunload وabort وerror. select. slotchange. يمكن التقاط هذه الأحداث من قِبل العناصر داخل شجرة DOM نفسها. إذا أردنا إيفاد أحداث مخصصة CustomEvent فلا بد من ضبط الخاصية composed على القيمة "true" بشكل صريح. المراجع مراجع DOM. مراجع التوافقية Compatibility ذكرت الشجرة الخفية في الكثير من المواصفات مثل DOM Parsing. ترجمة -وبتصرف- للفصول Shadow DOM and events و Shadow DOM slots, Composition وShadow DOM وShadow DOM Style من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا إنشاء الحركات عبر جافاسكربت التنقل في شجرة DOM عبر جافاسكربت تعديل الخاصيات والأصناف والأنماط في شجرة DOM إجراء تعديلات على DOM طريقة الوصول للعناصر في DOM
  10. يصف هذا القسم مجموعةً من المعايير الحديثة لمكونات الويب، وهذه المعايير بصدد التطوير حاليًا. تُدعم بعض الميزات وتتكامل ضمن معايير HTML/DOM، بينما يبقى البعض الآخر في مرحلة الإعداد. ويمكنك تجربة الأمثلة في أي متصفح، لكن يبقى المتصفح كروم Chrome الأكثر حداثةً فيما يتعلق بدعم هذه الميزات، والسبب طبعًا هو أنّ أشخاصًا من Google هم من وضعوا العديد من هذه المواصفات. ما هو الشيء المشترك بين محطة الفضاء ومكونات الويب إن فكرة المكونات ليست جديدة، فقد استُعملت في العديد من إطارات العمل وفي منصات مختلفة، وذلك قبل الانتقال إلى تفاصيل إنجاز تلك الميزات. لنلقي نظرةً على الصورة التالية، والتي تمثل إنجازًا عظيمًا للإنسانية: إنها محطة الفضاء الدولية، وتمثل الصورة التالية المكوِّنات التي تتألف منها -تقريبيًا طبعًا-: نلاحظ الآتي: تتألف المحطة من مكوِّنات عديدة. يمتلك كل مكوِّن بدوره أجزاءً أصغر في داخله. المكوّنات معقدّة جدًا، وهي بالطبع أعقد بكثير من معظم مواقع الويب. طوّرت مكونات المحطة على صعيد عالمي، ومن قِبل فرق من دول مختلفة تتحدث بلغات مختلفة. تحلق المحطة في مدارها حول الأرض وتبقي من في داخلها على قيد الحياة، فكيف بني هذا الجهاز المعقد؟ وما هي المبادئ التي يمكن أن نستعيرها لنطوّر مكوّنات ويب لها نفس مستوى الوثوقية وقابلة للنمو، أو قريبة من ذلك على الأقل؟ معمارية المكون تنص القاعدة الشهيرة في تطوير البرمجيات المعقدة على أن لا تطوّر برمجيات معقّدة، فإذا وصل بك الأمر إلى أمر معقد، فافصله إلى أجزاء أبسط واربطها ببعضها بأكثر الطرق وضوحًا، فالمعماري الأمهر هو من يستطيع تحويل المعقد إلى بسيط. يمكن تقسيم واجهة المستخدم إلى مكوّنات مرئية لكلٍّ منها موقعه في الصفحة، وتُنفِّذ مهمةً محددةً بعناية وتكون مفصولةً عن بعضها البعض، لنلق نظرةً على موقع ويب، وليكن تويتر مثلًا، يُقسم الموقع إلى مكوّنات: مكوّن للتنقل. معلومات المستخدم. اقتراحات المتابعة. نموذج إرسال. رسائل. غيرها من المكوِّنات. قد يكون للمكوّنات مكوّنات فرعيةً أيضًا، فقد تكون الرسائل مكوّنات فرعيةً من مكوّن أعلى مستوىً مثل قائمة الرسائل، كما قد تكون صورة المستخدم التي تقبل النقر مكوّنًا.. وهكذا. كيف نحدد ما هو المكوِّن إذًا؟ يأتي ذلك من حسن البديهة والخبرة والمنطق، حيث تشكًل المكوّنات عادةً كيانات مرئيةً نميزها وفقًا لوظيفتها وكيفية تتفاعلها مع الصفحة، ففي حالة الصفحة السابقة، سنجد أنها تحوي وحدات يلعب كل منها دوره الخاص، ومن المنطق إذًا أن نجعلها مكوّنات. للمكوِّن: صنف JavaScript خاص به. بنية DOM تُدار منفردةً من قبل الصنف الخاص بها، ولا يمكن الوصول إليها من قبل شيفرة خارجية (مبدأ التغليف). تنسيق CSS يُطبَّق على المكوّن. واجهة برمجية API، أحداث وتوابع يستخدمها الصنف وغيره من الكائنات التي تتفاعل مع المكوّنات الأخرى. تذكر أنّ فكرة الكائن ككل لا تفرض وجود أي شيء خاص. تتواجد الكثير من المنصات وإطارات العمل التي تقدم وسائل لإنشاء المكوّنات، وتستخدم أصناف CSS بالإضافة إلى ترتيبات أخرى لتعزيز المكوّنات وتحديد مجال تطبيق CSS وتغليف DOM. تزوّدنا مكوِّنات الويب بإمكانيات تصفّح مدمجةً معها ولا حاجة لمحاولة تقليدها بعد الآن. عناصر مخصصة Custom elements: لتعريف عناصر HTML مخصصة. شجرة DOM مخفية Shadow DOM: لإنشاء شجرة داخلية للمكوّن مخفية عن بقية المكوّنات. مجال لتطبيق أوراق التنسيق CSS Scoping: لتعريف قواعد تنسيق يمكن تطبيقها على الشجرة الداخلية لمكوّن. إعادة توجيه الحدث Event retargeting وغيره من الأمور الثانوية: لتحسين قدرة المكوّنات على التلاؤم مع عملية التطوير. كل الميزات المدعومة جيدًا لمكوّنات الويب جيدة في الأمور التي تقدر على تنفيذها. عناصر HTML المخصصة يمكننا إنشاء عناصر HTML مخصصة يصفها الصنف الذي ننشئه لهذا الغرض مزوّدًا بتوابعه وخصائصه وأحداثه الخاصة، وسنتمكن من استخدام العنصر ضمن عناصر HTML الأصلية بعد أن نعرّفه، وهذا أمر جيد، فعلى الرغم من غنى HTML بالعناصر، إلا أنها لا تحوي عناصرًا مثل علامة جداول سهلة التعامل <easy-tabs> أو سير دائري منزلق <sliding-carousel> أو الرفع بطريقة جذابة <beautiful-upload> أو أي عناصر أخرى قد نحتاجها. يمكن تعريف العناصر المخصصة ضمن أصناف خاصة، واستخدامها كما لو أنها جزء من HTML، ويوجد نوعان من هذه العناصر المخصصة، وهما: عناصر مخصصة ذاتية التصرف Autonomous: وهي عناصر جديدة توّسع عمل الصنف المجرّد HTMLElement. عناصر أصلية مخصصة customized: توسّع العناصر الأصلية، لإنجاز أزرار مخصصة مبنية على أساس الصنف HTMLButtonElement مثلًا. سنغطي أولًا العناصر ذاتية التصرف، ثم سننتقل إلى العناصر المخصصة، ولإنشاء عنصر مخصص لا بدّ من إخبار المتصفح عن جملة من التفاصيل حوله، مثل كيف سيُظهره؟ وماذا سيفعل عند إضافته أو إزالته من الصفحة؟ يجري ذلك بإنشاء صنف له توابعه الخاصة، وسيكون الأمر بسيطًا، فهي عدة توابع فقط وجميعها اختيارية. إليك تصورًا مع قائمة كاملة من التوابع: class MyElement extends HTMLElement { constructor() { super(); // إنشاء العنصر } connectedCallback() { //يستدعيه المتصفح عند إضافة عنصر ويمكن استدعاؤه عدة مرات إن أضيف أو حذف العنصر } disconnectedCallback() { //يستدعيه المتصفح عند حذف عنصر ويمكن استدعاؤه عدة مرات إن أضيف أو حذف العنصر } static get observedAttributes() { return [/* مصفوفة من أسماء السمات التي تراقب التغيرات */]; } attributeChangedCallback(name, oldValue, newValue) { // يُستدعى عندما تتغير صفة من السمات المذكوره أعلاه } adoptedCallback() { // يُستدعى عندما يُنقل العنصر إلى صفحة أخرى } // إن كان هناك عناصر أو سمات أخرى } نحتاج بعد ذلك إلى تسجيل العنصر: // سيُخدّم من قبل الصنف الجديد <my-element> أبلغ المتصفح أن customElements.define("my-element", MyElement); ستتولد الآن نسخة عن الصنف MyElement لكل عنصر HTML له الوسم <my-element>، وستستدعى التوابع المذكورة. كما يمكن تنفيذ ذلك باستخدام شيفرة JavaScript كالتالي: document.createElement('my-element') مثال عنصر لتنسيق الوقت يُستخدم العنصر الأصلي <time> لإظهار الوقت والتاريخ، لكنه لا يقدم أي تنسيق للبيانات التي يعطيها، لننشئ إذًا العنصر <time-formatted> الذي يعرض الوقت بتنسيق جميل يأخذ اللغة بالحسبان: <script> class TimeFormatted extends HTMLElement { // (1) connectedCallback() { let date = new Date(this.getAttribute('datetime') || Date.now()); this.innerHTML = new Intl.DateTimeFormat("default", { year: this.getAttribute('year') || undefined, month: this.getAttribute('month') || undefined, day: this.getAttribute('day') || undefined, hour: this.getAttribute('hour') || undefined, minute: this.getAttribute('minute') || undefined, second: this.getAttribute('second') || undefined, timeZoneName: this.getAttribute('time-zone-name') || undefined, }).format(date); } } customElements.define("time-formatted", TimeFormatted); // (2) </script> <!-- (3) --> <time-formatted datetime="2019-12-01" year="numeric" month="long" day="numeric" hour="numeric" minute="numeric" second="numeric" time-zone-name="short" ></time-formatted> See the Pen JS-p3-06-Custom-elements -ex1 by Hsoub (@Hsoub) on CodePen. تشير (1) إلى الصنف تابع واحد فقط هو ()connectedCallback، حيث يستدعيه المتصفح عندما يُضاف العنصر إلى الصفحة، أو عندما يكتشف مفسّر HTML وجوده، ويستخدم العنصر تابع تنسيق البيانات المدمج Intl.DateTimeFormat الذي يلقى دعمًا جيدًا من معظم المتصفحات لعرض الوقت بتنسيق جميل. تعني (2) أنه لا بدّ من تسجيل العنصر الجديد باستخدام الأمر (customElements.define(tag, class). تعني (3) أنه يمكن الآن استخدام العنصر في أي مكان. تحديث العنصر المخصص إذا صادف المتصفح العنصر <time-formatted> قبل عبارة التعريف customElements.define فلن يعدّ ذلك خطأً، بل سيعدّه مجرد عنصر غير معروف مثل أي وسم غير معياري، ويمكن تنسيق هذه العناصر غير المعروفة باستخدام المحدد (not(:defined:، وهو أحد محددات CSS. ستُحدّث هذه العناصر عندما يُستدعى التابع customElement.define، وستتولد نسخة من الصنف TimeFormatted لكلٍّ منها، ثم يُستدعى التابع connectedCallback وتتحول إلى عناصر معرّفة defined:. توجد توابع عدة للحصول على معلومات عن عناصر مخصصة: (customElements.get(name: يعيد الصنف الخاص بالعنصر الذي يحمل الاسم المحدد name. (customElements.whenDefined(name: يعيد وعدًا promise يحَلَّ -دون قيمة- عندما يُعرَّف العنصر المخصص الذي له الاسم name المحدد. التصيير داخل connectedCallback لا في constructor رأينا في المثال السابق كيف جرى تصيير محتوى العنصر ضمن التابع connectedCallback، لكن لماذا لم يُصيّر ضمن الدالة البانية constructor؟ إنّ السبب بسيط، لأنه من المبكر جدًا تصيير المكوّن عند استدعاء constructor، فعندما يُنشأ العنصر في هذه المرحلة، فلن يُعالجه المتصفح أو يسند إليه السمات الخاصة به، فاستدعاء getAttribute سيعيد القيمة "لاشيء" null، وبالتالي لن نتمكن من تصيير شيء، ولو فكرنا بالأمر قليلًا فسنجد أنّ هذا الأداء أفضل لتأخير العمل حتى يكون كل شيء جاهزًا. يقع الحدث connectedCallback عندما يُضاف العنصر إلى الصفحة، ولن يُضاف إلى عنصر آخر مثل ابن فقط، بل سيغدو عمليًا جزءًا من الصفحة، وهكذا سنتمكن من بناء شجرة DOM منفصلة، وإنشاء عناصر وتحضيرها للعمل لاحقًا، وستصيّر هذه العناصر فعليًا عندما تُضاف إلى الصفحة. مراقبة السمات ليس لتغير السمات attribute في الطريقة الحالية لتنفيذ العنصر <time-formatted> أي تأثير بعد تصييره، وهذا غريب بالنسبة إلى عنصر HTML، فعندما نغيّر في صفة مثل a.href، فسنتوقع عادةً أن نرى التغير مباشرةً. نصلح هذا الأمر إذًا. يمكن مراقبة السمات بتزويد دالة الإحضار الساكنة ()observedAttributes بقائمة تضم أسماءهم، وهكذا ستُستدعى الدالة attributeChangedCallback عندما نجري أي تعديلات على تلك السمات. لن يقع هذا الحدث بالطبع عندما تتغير سمات غير مدرجة في القائمة، لأسباب تتعلق بالأداء. إليك شيفرة العنصر <time-formatted> الذي يُحدَّث تلقائيًا عندما تتغير سماته: <script> class TimeFormatted extends HTMLElement { render() { // (1) let date = new Date(this.getAttribute('datetime') || Date.now()); this.innerHTML = new Intl.DateTimeFormat("default", { year: this.getAttribute('year') || undefined, month: this.getAttribute('month') || undefined, day: this.getAttribute('day') || undefined, hour: this.getAttribute('hour') || undefined, minute: this.getAttribute('minute') || undefined, second: this.getAttribute('second') || undefined, timeZoneName: this.getAttribute('time-zone-name') || undefined, }).format(date); } connectedCallback() { // (2) if (!this.rendered) { this.render(); this.rendered = true; } } static get observedAttributes() { // (3) return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name']; } attributeChangedCallback(name, oldValue, newValue) { // (4) this.render(); } } customElements.define("time-formatted", TimeFormatted); </script> <time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted> <script> setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5) </script> See the Pen JS-p3-06-Custom-elements -ex2 by Hsoub (@Hsoub) on CodePen. يُقصَد بـ (1) أنه قد نُقل منطق التصيير إلى التابع المساعد <time-formatted>. تعني (2) أن هذا التابع يُستدعى مباشرةً في اللحظة التي يضاف فيها العنصر إلى الصفحة. تعني (3) أنه يقع الحدث attributeChangedCallback عند حدوث أي تغير في السمات التي مررت على شكل قائمة إلى ()observedAttributes تعني (4) أنه يُعاد تصيير العنصر بعد ذلك. تعني (5) أنه في النهاية يمكننا إنشاء مؤقت مباشر بكل سهولة. تسلسل التصيير عندما يبني مفسِّر HTML الشجرة DOM، تعالَج العناصر الواحد تلو الآخر والأب قبل الابن، فلو كان لدينا العنصر: <outer><inner></inner></outer> فسيتولد العنصر <outer> أولًا ويضاف إلى DOM، ثم العنصر <inner>، وسيقود ذلك إلى تبعات بالنسبة للعناصر المخصصة، فإذا أراد العنصر المخصص الوصول إلى innerHTML ضمن connectedCallback مثلًا، فلن يحصل على شيء. <script> customElements.define('user-info', class extends HTMLElement { connectedCallback() { alert(this.innerHTML); // empty (*) } }); </script> <user-info>محمد</user-info> إذا نفّذت الشيفرة السابقة فستظهر الرسالة alert فارغة، والسبب في ذلك هو عدم وجود عناصر أبناء في هذه المرحلة، وستكون الشجرة DOM غير مكتملة، يربط مفسِّر HTML العنصر المخصص <user-info> وسيستأنف العمل بعدها لربط أبنائه لكنه لم يفعلها بعد. إذا أردنا تمرير معلومات إلى عنصر مخصص، فيمكننا استخدام السمات فهي متاحة مباشرةً، وإذا كنا بحاجة فعلًا إلى الأبناء، فيمكن تأجيل الوصول إليهم باستخدام الحدث setTimeoutدون تأخير زمني Zero-delay. <script> customElements.define('user-info', class extends HTMLElement { connectedCallback() { setTimeout(() => alert(this.innerHTML)); // محمد (*) } }); </script> <user-info>John</user-info> وهكذا ستُظهر الرسالة alert في السطر (*) الكلمة "محمد" طالما أننا ننفذ الأمر دون تزامن، وذلك بعد اكتمال تفسير شيفرة HTML، ويمكن معالجة الأبناء إذا اقتضى الأمر ثم إنهاء مرحلة التهيئة، لكن هذا الحل ليس مثاليًا. فإذا استخدمت العناصر المخصصة الحدث لتهيئة نفسها، فإنها ستصطف بحيث يقع الحدث setTimeout للعنصر الخارجي ثم الداخلي، وبالتالي ستنهي العناصر الخارجية تهيئة نفسها قبل الداخلية، لنشرح ذلك من خلال المثال التالي: <script> customElements.define('user-info', class extends HTMLElement { connectedCallback() { alert(`${this.id} connected.`); setTimeout(() => alert(`${this.id} initialized.`)); } }); </script> <user-info id="outer"> <user-info id="inner"></user-info> </user-info> تسلسل الخرج: يُربط العنصر الخارجي بالشجرة. يُربط العنصر الداخلي. يُهيأ العنصر الخارجي. يُهيأ العنصر الداخلي. يمكننا أن نرى بوضوح أن العنصر الخارجي سينهي تهيئة نفسه (الخطوة 3) قبل الداخلي (الخطوة 4)، ولا يوجد استدعاء مدمج يمكن أن يقع بعد أن يصبح العنصر ضمن الشجرة، كما يمكن تنفيذ استدعاء مثل هذا عند الحاجة بأنفسنا، حيث يمكن للعناصر الداخلية إرسال أحداث مثل initialized تستمع إليها العناصر الخارجية وتتفاعل معها. العناصر الأصلية المخصصة ليس للعناصر المخصصة -مثل <time-formatted>- أي دلالات تتعلق بها، فهي غير معروفة لمحركات البحث مثلًا، ولا تستطيع الأجهزة التي تدعم مبدأ الوصول السهل accessibility التعامل معها. قد تكون هذه الأشياء مهمةً، فقد يرغب محرّك البحث بمعرفة أن هذا العنصر سيظهر الوقت، فإذا أنشأنا نوعًا خاصًا من الأزرار، فلماذا لا يمكننا إعادة استخدام وظيفة الزر الأصلي <button>؟ يمكننا توسيع وتخصيص العناصر الأصلية بالوراثة من أصنافها، فالأزرار مثلًا هي نسخ عن الصنف HTMLButtonElement، ولإنشاء عنصر يرث منه ويوسّعه عليك باتباع الآتي: وسِّع الصنف HTMLButtonElement عن طريق الصنف الذي سننشئه: class HelloButton extends HTMLButtonElement { /* custom element methods */ } أعط قيمةً للوسيط الثالث للدالة customElements.define والذي يمثّل الوسم tag المستهدف: customElements.define('hello-button', HelloButton, {extends: 'button'}); يمكن أن تشترك وسوم مختلفة بصنف DOM، لذلك سنحتاج إلى استخدام التعليمة extends. لكي نستخدم أخيرًا العنصر المخصص، استخدم الوسم الأصلي النظامي <button>، لكن أضف إليه الصفة "is="hello-button. <button is="hello-button">...</button> إليك المثال كاملًا: <script> // عند ضغطه "hello" الزر الذي يظهر class HelloButton extends HTMLButtonElement { constructor() { super(); this.addEventListener('click', () => alert("Hello!")); } } customElements.define('hello-button', HelloButton, {extends: 'button'}); </script> <button is="hello-button">Click me</button> <button is="hello-button" disabled>Disabled</button> See the Pen JS-p3-06-Custom-elements -ex3 by Hsoub (@Hsoub) on CodePen. يوسِّع الزر الجديد الزر الأصلي، وبالتالي سيحتفظ بتنسيق وميزات الزر الأصلي مثل الصفة disabled. قوالب HTML يقدم العنصر الأصلي <template> قوالبًا لتخزين شيفرة HTML ويتجاهل محتوياتها، ويتحقق من صحة التركيب اللغوي للشيفرة ضمنها حصرًا، لكن يمكن الوصول إليها واستخدامها في إنشاء عناصر أخرى. يمكن نظريًا إنشاء أي عناصر مخفية ضمن ملف HTML لتخزين شيفرة HTML، فما الغاية من <template>؟ أولًا: قد يحتوي على أي شيفرة HTM صالحة، حتى تلك التي تتطلب عادةً وسم إغلاق Closing tag، فيمكن أن نضع ضمنها أسطر جدول <tr>: <template> <tr> <td>Contents</td> </tr> </template> عندما نحاول عادةً وضع <tr> ضمن <div> مثلًا، فسيكتشف المتصفح الخطأ في بنية DOM ويصلحها بإضافة الوسم <table> وجعل السطر ضمنه تلقائيًا. من الناحية الأخرى سيُبقي <template> ما وضعناه في المكان الذي وضعناه فيه دون تعديل. يمكننا وضع تنسيقات وسكربتات ضمن <template> أيضًا: <template> <style> p { font-weight: bold; } </style> <script> alert("Hello"); </script> </template> يَعُد المتصفح محتويات <template> "خارج المستند"، أي لن تُطبق قواعد التنسيق الموجودة ضمنه ولن يعمل <video autoplay>، كما سيُصبح المحتوى حيًا -أي يُطبَّق التنسيق وتنفَّذ السكربتات- عندما ندرجه ضمن المستند. إدراج القالب يُتاح محتوى قالب ضمن الخاصية content العائدة له على شكل عقدة من النوع DocumentFragment، وهي نوع خاص من عقد DOM، ويمكن التعامل معها مثلما نتعامل مع أي عقدة DOM عدا خاصية واحدة مميزة، وهي إدراج الأبناء بدلًا من العقدة عندما نحاول إدراجها، فعلى سبيل المثال: <template id="tmpl"> <script> alert("Hello"); </script> <div class="message">Hello, world!</div> </template> <script> let elem = document.createElement('div'); // انسخ محتوى القالب لاستخدامه مرات عدة elem.append(tmpl.content.cloneNode(true)); document.body.append(elem); // سيعمل الآن السكربت الموجود ضمن القالب </script> دعونا نعيد كتابة مثال شجرة DOM الخفية في الفصل السابق باستخدام القالب <template>: <template id="tmpl"> <style> p { font-weight: bold; } </style> <p id="message"></p> </template> <div id="elem">Click me</div> <script> elem.onclick = function() { elem.attachShadow({mode: 'open'}); elem.shadowRoot.append(tmpl.content.cloneNode(true)); // (*) elem.shadowRoot.getElementById('message').innerHTML = "Hello from the shadows!"; }; </script> See the Pen JS-p3-06-Template-element -ex1 by Hsoub (@Hsoub) on CodePen. عندما ننسخ وندرج tmpl.content في السطر (*) مثل عقدة من النوع DocumentFragment، فسيُدرج أبناؤها (<style> و<p>) بدلًا منها، وسيشكلان شجرة DOM الخفية: <div id="elem"> #shadow-root <style> p { font-weight: bold; } </style> <p id="message"></p> </div> خلاصة للعناصر المخصصة نوعان: الأول عناصر مخصصة ذاتية التصرف Autonomous، لها وسم خاص جديد وتوسع الصنف HTMLElement، وإليك تخطيط التعريف لهذه العناصر: class MyElement extends HTMLElement { constructor() { super(); /* ... */ } connectedCallback() { /* ... */ } disconnectedCallback() { /* ... */ } static get observedAttributes() { return [/* ... */]; } attributeChangedCallback(name, oldValue, newValue) { /* ... */ } adoptedCallback() { /* ... */ } } customElements.define('my-element', MyElement); /* <my-element> */ أما النوع الثاني، فهو عناصر أصلية معدَّلة customized: توّسع عناصرًا أصليةً موجودة، وتتطلب وسيطًا ثالثًا للدالة define.، وإلى الصفة "..."=is ضمن وسمها. class MyButton extends HTMLButtonElement { /*...*/ } customElements.define('my-button', MyElement, {extends: 'button'}); /* <button is="my-button"> */ تدعم معظم المتصفحات العناصر المخصصة جيدًا، كما يوجد موائم polyfill أي تعويض نقص الدعم للمتصفحات غير المدعومة. قد يكون محتوى <template> أي عناصر HTML صالحة قواعديًا. يعُد المتصفح محتويات <template> خارج المستند، إذ لن يكون لها أي تأثير. يمكن الوصول إلى محتوى القالب template.content من JavaScript، ونسخها لإعادة استخدامها في مكوَّن جديد. الوسم فريد لأن: محتويات القالب <template> ستتعرض إلى تدقيق قواعدي من قِبل المتصفح، وهذا مخالف لاستخدام قالب نصي ضمن سكربت. يُسمح باستخدام وسوم HTML عالية المستوى حتى تلك التي لا يجب استخدامها دون عنصر يغلّفها، مثل <tr> التي ينبغي تغليفها بالوسم <table>. تصبح المحتويات حيّةً -أي تُطبَّق وتُنفَّذ- عندما تُدرج ضمن المستند. لا يدعم العنصر <template> المُكررات iterator، ولا آليات الربط بقواعد البيانات أو تبديل المتغيرات، لكن يمكن إدراجها في أعلى القالب. مهام لإنجازها عنصر توقيت مباشر أنشئ العنصر <live-timer> الذي يعرض الوقت الحالي: ينبغي أن يستخدم عنصر التوقيت العنصر <time-formatted> الذي أنشأناه سابقًا، بصورة ضمنية وليس بنسخ وظيفته. يُحدَّث المؤقت كل ثانية. مع كل تحديث للمؤقت سيتولد حدث خاص يُدعى tick يحمل التاريخ الحالي ضمن الخاصية event.detail، راجع فصل "إنشاء أحداث مخصصة في المتصفح عبر جافاسكربت". طريقة الاستخدام: <live-timer id="elem"></live-timer> <script> elem.addEventListener('tick', event => console.log(event.detail)); </script> إليك المثال النموذجي: يمكنك فتح المثال في بيئة تجريبية لاحظ أن: صفرنا المؤقت setInterval عند إزالة العنصر من الصفحة وهذا أمر مهم إذ بدونه سيستمر الحدث بالعمل كل ثانية في الوقت الذي لا يُستعمَل فيه، ولا يمكن للمتصفح أيضًا أن يمسح الذاكرة من تلقاء نفسه. يمكن الوصول إلى القيمة الحالية عبر الخاصية elem.date، إذ كل توابع الصنف وخاصياته هي توابع للعنصر وخاصياته. ويمكنك فتح الحل في بيئة تجريبية. المراجع HTML Living Standard Compatiblity ترجمة -وبتصرف- للفصول Custom elements وFrom Orbital Height وTemplate element من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: إنشاء الحركات عبر جافاسكربت المقال التالي: مكونات الويب: التعامل مع شجرة DOM الخفية iframe { border: 1px solid #e7e5e3 !important; width: 100%; } iframe div#path { margin: auto; }
  11. يمكن لرسوم JavaScript التعامل مع حالات لا يمكن أن تتعامل معها CSS، مثل التحرك على مسار معقد مختلف عن منحنيات بيزيه Bezier curves باستخدام دالة توقيت، أو رسوميات متحركة على لوحة رسم. استخدام الدالة setInterval يمكن إنجاز الرسوم المتحركة في صورة سلسلة من الإطارات، والتي تكون عادةً تغيرات صغيرةً في خصائص HTML/CSS، فعلى سبيل المثال: يؤدي تغيير قيمة الخاصية style.left من 0px إلى 100px إلى تحريك العنصر، وإذا زدنا هذه القيمة ضمن الدالة setInterval، فسيؤدي تغير مقداره 2px مع تأخير ضئيل، لتكرار العملية بمقدار 50 مرةً في الثانية، مما يجعل الحركة سلسةً وناعمةً، ويُتَّبع هذا الأسلوب في السينما، فعرض 24 إطارًا في الثانية يجعل الصورة سلسةً. إليك الشيفرة المجردة pseudo-code للفكرة: let timer = setInterval(function() { if (animation complete) clearInterval(timer); else increase style.left by 2px }, 20); // تغير بمقدار 2 بكسل بتأخير 20 ميلي ثانية يعطي 50 إطار في الثانية وهذا مثال أكثر تعقيدًا: let start = Date.now(); // تذكر وقت البدء let timer = setInterval(function() { // كم مضى من الوقت منذ البداية let timePassed = Date.now() - start; if (timePassed >= 2000) { clearInterval(timer); // أنهي الحركة بعد ثانيتين return; } // ارسم إطار الحركة في اللحظة الحالية draw(timePassed); }, 20); // عندما يتغير الوقت بين 0 و2000 ميلي ثانية // تتغير قيمة الخاصية من 0 بكسل إلى 400 بكسل function draw(timePassed) { train.style.left = timePassed / 5 + 'px'; } إليك المثال النموذجي التالي: شيفرة الملف index.html: <!DOCTYPE HTML> <html> <head> <style> #train { position: relative; cursor: pointer; } </style> </head> <body> <img id="train" src="https://js.cx/clipart/train.gif"> <script> train.onclick = function() { let start = Date.now(); let timer = setInterval(function() { let timePassed = Date.now() - start; train.style.left = timePassed / 5 + 'px'; if (timePassed > 2000) clearInterval(timer); }, 20); } </script> </body> </html> وستكون النتيجة: See the Pen JS-p3-05-JavaScript-animations -ex1 by Hsoub (@Hsoub) on CodePen. استخدام الدالة requestAnimationFrame لنفترض تنفيذ عدة حركات انتقالية معًا. إذا شغّلنا هذه الرسوم منفصلةً فسيعيد المتصفح -وعلى الرغم من أنّ لكل حركة دالة (setInterval(..., 20 خاصةً- رسم الصور بمعدل أكبر بكثير من 20 ميلي ثانية، ويحدث هذا لوجود أوقات بداية مختلفة لكل حركة، وبالتالي سيختلف تكرار التغيّر الذي ضبطناه عند 20 ميلي ثانية بالنسبة لكل حركة، وهكذا سنكوِّن حالات تشغيل مستقلةً لكل حركة انتقالية تتكرر كل 20 ميلي ثانية، وبكلمات أخرى انظر الشيفرة التالية: setInterval(function() { animate1(); animate2(); animate3(); }, 20) والتي ستكون أخفّ من ناحية التنفيذ على المتصفح من الشيفرة: setInterval(animate1, 20); // حركة انتقالية مستقلة setInterval(animate2, 20); // في أماكن مختلفة من السكربت setInterval(animate3, 20); ينبغي تجميع عمليات الرسم المستقلة لتسهيل الأمر على المتصفح، ولتخفيف الحِمل على وحدة المعالجة إلى جانب إظهار حركة أكثر نعومةً. تذكر دائمًا أنه يجب ألا نشغل عملية الرسم كل 20 ميلي ثانية، لأنها قد تزيد حمولة وحدة المعالجة، أو لوجود أسباب لتقليل عملية إعادة الرسم، مثل الحالة التي تكون ضمن نافذة مخفية للمتصفح. ولتمييز ذلك في JavaScript سنستخدم ميزةً تُدعى توقيت الحركة Animation timing، والتي تزوّدنا بالدالة requestAnimationFrame التي تتعامل مع هذه الأمور وأكثر، وإليك صيغة استخدامها: let requestId = requestAnimationFrame(callback) تجدول الدالة requestAnimationFrame دالة الاستدعاء callback للعمل في أقرب وقت يريد فيه المتصفح تنفيذ الحركة، فإذا نفَّذنا أي تغييرات على العناصر ضمن الدالة callback، فستُجمّع مع غيرها من دوال الاستدعاء التي تجدولها الدالة requestAnimationFrame ومع رسوميات CSS، وهكذا سيعيد المتصفح الحسابات الهندسية، ثم يعيد الرسم مرةً واحدةً بدلًا من مرات متعددة. يمكن استخدام القيمة requestId التي تعيدها الدالة requestAnimationFrame في إلغاء الاستدعاء: // ألغ تنفيذ الاستدعاءات المجدولة cancelAnimationFrame(requestId); لدالة الاستدعاء callback وسيط واحد، وهو الوقت الذي انقضى منذ بداية تحميل الصفحة مقدرًا بالميكروثانية، والذي يمكن الحصول عليه باستدعاء التابع performance.now. يُنفَّذ الاستدعاء callback مبكرًا إلا في حالة التحميل الزائد للمعالج، أو عندما تقارب بطارية الحاسوب المحمول على النفاد أو لأسباب مشابهة، وتظهر الشيفرة التالية الوقت المستغرق خلال مرات التنفيذ العشرة الأولى للدالة requestAnimationFrame، وهو عادةً بين 10-20 ميلي ثانية: <script> let prev = performance.now(); let times = 0; requestAnimationFrame(function measure(time) { document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " "); prev = time; if (times++ < 10) requestAnimationFrame(measure); }) </script> See the Pen JS-p3-05-JavaScript-animations -ex2 by Hsoub (@Hsoub) on CodePen. الرسومات المتحركة المهيكلة Structured animation سننشئ الآن دالةً أكثر عموميةً مبنيةً على الدالة requestAnimationFrame: function animate({timing, draw, duration}) { let start = performance.now(); requestAnimationFrame(function animate(time) { // يتحرك الزمنين 0 و1 let timeFraction = (time - start) / duration; if (timeFraction > 1) timeFraction = 1; // حساب الحالة الراهنة للرسوم المتحركة let progress = timing(timeFraction) draw(progress); // تنفيذ الرسم if (timeFraction < 1) { requestAnimationFrame(animate); } }); } تقبل الدالة animate ثلاثة معاملات تصف الحركة، وهي: duration: الزمن الكلي لتنفيذ الحركة مقدرًا بالميلي ثانية. (timing(timeFraction: دالة توقيت تشابه الخاصية transition-timing-function في CSS، وتعطي نسبة الوقت الذي انقضى (0 عند البداية و1 عند النهاية)، وتعيد ما يدل على اكتمال الحركة، مثل الإحداثي y عند رسم منحني بيزيه، ولنتذكر أن الدالة الخطية تعني أن الحركة ستتقدم بانتظام وبالسرعة ذاتها: function linear(timeFraction) { return timeFraction; } وسيبدو الرسم البياني للدالة الخطية كالتالي: وهي مشابهة تمامًا للخاصية transition-timing-function، وسنرى لاحقًا بعض أشكال الاستخدام الأخرى. (draw(progress: وهي الدالة التي تأخذ معاملًا هو مقدار اكتمال الحركة وترسمه، وتشير القيمة progress=0 إلى حالة بداية الحركة، بينما تشير القيمة progress=1 إلى حالة النهاية، فهي الدالة التي ترسم الحركة فعليًا، إذا يمكنها نقل عنصر مثلًا: function draw(progress) { train.style.left = progress + 'px'; } أو تنفيذ أي شيء آخر، وبالتالي يمكننا تحريك أي شيء بالطريقة التي نريد، لنحرّك العنصر width من 0 حتى 100% باستخدام هذه الدالة: شيفرة الملف animate.js: unction animate({duration, draw, timing}) { let start = performance.now(); requestAnimationFrame(function animate(time) { let timeFraction = (time - start) / duration; if (timeFraction > 1) timeFraction = 1; let progress = timing(timeFraction) draw(progress); if (timeFraction < 1) { requestAnimationFrame(animate); } }); } شيفرة الملف index.html: <!DOCTYPE HTML> <html> <head> <meta charset="utf-8"> <style> progress { width: 5%; } </style> <script src="animate.js"></script> </head> <body> <progress id="elem"></progress> <script> elem.onclick = function() { animate({ duration: 1000, timing: function(timeFraction) { return timeFraction; }, draw: function(progress) { elem.style.width = progress * 100 + '%'; } }); }; </script> </body> </html> ستكون النتيجة كالتالي: وإليك الشيفرة المستخدمة: animate({ duration: 1000, timing(timeFraction) { return timeFraction; }, draw(progress) { elem.style.width = progress * 100 + '%'; } }); يمكننا بهذا الأسلوب تنفيذ أي دوال توقيت ورسم على خلاف CSS، ولا ترتبط دوال التوقيت بمنحني بيزيه فقط، كما يمكن للدالة draw أن تتخطى الخصائص إلى إنشاء عناصر جديدة لرسوميات نحتاجها، مثل الألعاب النارية. دوال التوقيت اطلعنا في الفقرات السابقة على أبسط الدوال وهي الدالة الخطية، لنرى الآن بعض الدوال الأخرى، حيث سنجرب بعض الحركات الانتقالية باستخدام دوال توقيت مختلفة. دالة القوة من الدرجة n يمكن استعمال progress بدلالة القوة من الدرجة n لتسريع الحركة، مثل الدالة التربيعية (أي من الدرجة 2): function quad(timeFraction) { return Math.pow(timeFraction, 2) } إليك الرسم البياني: لترى النتيجة انقر على الشكل التالي: كما يمكنك استعمال الدالة التكعيبية (من الدرجة 3)، وسترى أن سرعة الحركة ستزداد بزيادة درجة القوة، إليك نموذجًا تكون فيه progress من الدرجة 5: الدالة المثلثية القطعية arc صيغة الدالة: function circ(timeFraction) { return 1 - Math.sin(Math.acos(timeFraction)); } الرسم البياني: المثال النموذج: دالة إطلاق السهم back عند اطلاق السهم bow shooting فسنسحب وتر القوس ثم نحرره، وخلافًا للدالتين السابقتين، ستعتمد الدالة على معامل إضافي x هو ثابت المرونة elasticity coefficient، والذي يُعرِّف المسافة التي نسحب بها وتر القوس: function back(x, timeFraction) { return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x) } الخط البياني للدالة عندما x = 1.5: المثال النموذجي عند نفس القيمة للمعامل x: دالة الارتداد bounce عندما نرمي كرةً فستسقط للأسفل وترتد عدة مرات ثم تتوقف. تسلك الدالة bounce هذا السلوك تمامًا لكن بترتيب معكوس، حيث يبدأ الارتداد مباشرةً، وتستخدم هذه الدالة بعض الثوابت الخاصة: function bounce(timeFraction) { for (let a = 0, b = 1, result; 1; a += b, b /= 2) { if (timeFraction >= (7 - 4 * a) / 11) { return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2) } } } إليك نموذجًا يستخدم دالة الإرتداد: دالة الحركة المرنة elastic إليك دالةً أخرى "مرنةً" تقبل معاملًا إضافيًا x يضبط المجال الابتدائي للحركة: function elastic(x, timeFraction) { return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction) } الخط البياني للدالة عندما x=1.5: إليك نموذجًا عن استخدام الدالة: الدوال بترتيب معكوس تعرفنا حتى اللحظة على دوال التوقيت التي تُعرف بتحويلات الدخول السهل easeIn، لكننا قد نحتاج أحيانًا إلى عرض الحركة بترتيب معكوس. تُنفَّذ هذه الحركات بالتحويل الذي يُعرف باسم الخروج السهل easeOut. التحويل easeOut تُوضع الدالة timing في هذا التحويل ضمن المُغلِّف timingEaseOut: timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction) بعبارة أخرى لدينا دالة التحويل makeEaseOut التي تقبل دالة توقيت نظاميةً وتعيد المُغلّف الذي يحيط بها: // accepts a timing function, returns the transformed variant function makeEaseOut(timing) { return function(timeFraction) { return 1 - timing(1 - timeFraction); } } يمكن على سبيل المثال اختيار الدالة bounce التي شرحناها سابقًا وتطبيقها: let bounceEaseOut = makeEaseOut(bounce); وهكذا لن تكون الحركة الارتدادية في بداية الحركة بل في نهايتها، وستبدو الحركة أفضل: شيفرة الملف style.css: #brick { width: 40px; height: 20px; background: #EE6B47; position: relative; cursor: pointer; } #path { outline: 1px solid #E8C48E; width: 540px; height: 20px; } شيفرة الملف index.html: <!DOCTYPE HTML> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> <script src="https://js.cx/libs/animate.js"></script> </head> <body> <div id="path"> <div id="brick"></div> </div> <script> function makeEaseOut(timing) { return function(timeFraction) { return 1 - timing(1 - timeFraction); } } function bounce(timeFraction) { for (let a = 0, b = 1, result; 1; a += b, b /= 2) { if (timeFraction >= (7 - 4 * a) / 11) { return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2) } } } let bounceEaseOut = makeEaseOut(bounce); brick.onclick = function() { animate({ duration: 3000, timing: bounceEaseOut, draw: function(progress) { brick.style.left = progress * 500 + 'px'; } }); }; </script> </body> </html> وستكون النتيجة: سنرى هنا كيف سيغيّر التحويل سلوك الدالة، إذ ستعرَض التأثيرات الموجودة في بداية الحركة -مثل الارتداد- في النهاية، ففي الشكل التالي ستجد الارتداد الاعتيادي باللون الأحمر، والارتداد وفق تحويل الخروج السهل باللون الأزرق. الارتداد الاعتيادي: يرتد الكائن عند القاع، ثم يقفز بحدّة إلى القمة في النهاية. باستخدام easeOut الخروج السهل: يقفز أولًا إلى القمة ثم يرتد هناك. التحويل easeInOut يمكن أن نُظهر التأثير المطلوب في بداية ونهاية الحركة معًا، ويُدعى هذا التحويل بالدخول والخروج السهل easeInOut. سنحسب حالة الحركة باعتماد تابع توقيت ما كالتالي: if (timeFraction <= 0.5) { // نصف الحركة الأول return timing(2 * timeFraction) / 2; } else { // النصف الثاني للحركة return (2 - timing(2 * (1 - timeFraction))) / 2; } شيفرة المُغلِّف: function makeEaseInOut(timing) { return function(timeFraction) { if (timeFraction < .5) return timing(2 * timeFraction) / 2; else return (2 - timing(2 * (1 - timeFraction))) / 2; } } bounceEaseInOut = makeEaseInOut(bounce); إليك مثالًا نموذجيًا: شيفرة الملف style.css: #brick { width: 40px; height: 20px; background: #EE6B47; position: relative; cursor: pointer; } #path { outline: 1px solid #E8C48E; width: 540px; height: 20px; } شيفرة الملف index.html: <!DOCTYPE HTML> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> <script src="https://js.cx/libs/animate.js"></script> </head> <body> <div id="path"> <div id="brick"></div> </div> <script> function makeEaseInOut(timing) { return function(timeFraction) { if (timeFraction < .5) return timing(2 * timeFraction) / 2; else return (2 - timing(2 * (1 - timeFraction))) / 2; } } function bounce(timeFraction) { for (let a = 0, b = 1, result; 1; a += b, b /= 2) { if (timeFraction >= (7 - 4 * a) / 11) { return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2) } } } let bounceEaseInOut = makeEaseInOut(bounce); brick.onclick = function() { animate({ duration: 3000, timing: bounceEaseInOut, draw: function(progress) { brick.style.left = progress * 500 + 'px'; } }); }; </script> </body> </html> وستكون النتيجة: يدمج التحويل كائنين رسوميين معًا، وهما التحويل easeIn (الاعتيادي) في النصف الأول للحركة، والتحويل easeOut (المعكوس) للنصف الثاني. سنرى التأثير بوضوح عند الموازنة بين التحويلات الثلاث easeIn وeaseOut وeaseInOut عند تطبيقها على دالة التوقيت circ: easeIn: باللون الأحمر. easeOut: باللون الأخضر. easeInOut: باللون الأزرق. كما نرى طبقنا على النصف الأول من الحركة easeIn، وعلى النصف الآخر easeOut، لذا ستبدأ الحركة وتنتهي بنفس التأثير. دالة draw أكثر تميزًا يمكننا القيام بأكثر من مجرد تحريك العنصر، وكل ما علينا فعله هو كتابة شيفرة مناسبة للدالة draw، إليك طريقةً لإظهار ارتداد أثناء كتابة نص مثلًا: شيفرة الملف style.css: textarea { display: block; border: 1px solid #BBB; color: #444; font-size: 110%; } button { margin-top: 10px; } شيفرة الملف index.html: <!DOCTYPE HTML> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> <script src="https://js.cx/libs/animate.js"></script> </head> <body> <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand: Long time the manxome foe he sought— So rested he by the Tumtum tree, And stood awhile in thought. </textarea> <button onclick="animateText(textExample)">Run the animated typing!</button> <script> function animateText(textArea) { let text = textArea.value; let to = text.length, from = 0; animate({ duration: 5000, timing: bounce, draw: function(progress) { let result = (to - from) * progress + from; textArea.value = text.substr(0, Math.ceil(result)) } }); } function bounce(timeFraction) { for (let a = 0, b = 1, result; 1; a += b, b /= 2) { if (timeFraction >= (7 - 4 * a) / 11) { return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2) } } } </script> </body> </html> وستكون النتيجة كالتالي: خلاصة يمكن أن تساعدك JavaScript في تنفيذ الرسوميات التي لا تستطيع CSS التعامل معها، أو تلك التي تتطلب تحكمًا دقيقًا. تّنفّذ حركات JavaScript باستخدام التابع المدمج requestAnimationFrame الذي يسمح بإعداد دالة استدعاء تُشغَّل عندما يحضّر المتصفح نفسه لعملية إعادة الرسم Repaint. لن تُنفَّذ عملية إعادة الرسم إطلاقًا عندما تكون الصفحة في الخلفية، وبالتالي لن تعمل دالة الاستدعاء وسيُعلّق تنفيذ الحركة، ولن يكون هناك استهلاك للموارد. إليك الدالة المساعدة animate التي تحضّر معظم الرسوميات المتحركة التي تحتاجها: function animate({timing, draw, duration}) { let start = performance.now(); requestAnimationFrame(function animate(time) { // timeFraction goes from 0 to 1 let timeFraction = (time - start) / duration; if (timeFraction > 1) timeFraction = 1; // calculate the current animation state let progress = timing(timeFraction); draw(progress); // draw it if (timeFraction < 1) { requestAnimationFrame(animate); } }); } الخيارات هي: duration: الزمن الكلي للحركة مقدرًا بالميلي ثانية. timing: الدالة التي تحسب مقدار تقدم الحركة، حيث تقبل الدالة قيمًا زمنيةً هي نسبة بين 0 و1، وتعيد مقدار تقدم العملية. draw: الدالة التي ترسم الحركة. وبالطبع يمكن تحسين هذه الدوال وإضافة العديد من الأمور الأخرى، لكن لا يتكرر استخدام رسوميات JavaScript كثيرًا، فهي تستخدَم لإظهار شيء مهم لا لأمور تقليدية، لذا يمكنك إضافة الميزة التي تريدها عند الحاجة. يمكن أن تستخدم JavaScript أي دوال توقيت، وقد غطينا الكثير منها في هذا الفصل وطبقنا عليها تحويلات عدةً، إذًا لسنا مقيدين بمنحنيات بيزيه كما هي الحال في CSS. يمكننا استخدام draw لتحريك أي شيء وليس خصائص CSS فقط. مهام لإنجازها 1. تحريك كرة مرتدة أنشئ كرةً ترتد كما في المثال التالي: افتح المثال في بيئة تجريبية. الحل لجعل الكرة ترتد، سنسنتعمل الخاصية top والخاصية position:absolute مع الكرة والخاصية position:relative مع الملعب، ويكن إحداثيات أرضية الملعب هي field.clientHeight. تشير الخاصية top إلى بداية أعلى الملعب لذا يجب أن تتغير من 0 إلى field.clientHeight - ball.clientHeight وهو أدنى موضع يمكن أن تنخفض إليه حافة الكرة العلوية. يمكن تطبيق تأثير الارتداد باستعمال دالة التوقيت bounce في الوضع easeOut. إليك الشيفرة النهاية الناتجة: let to = field.clientHeight - ball.clientHeight; animate({ duration: 2000, timing: makeEaseOut(bounce), draw(progress) { ball.style.top = to * progress + 'px' } }); افتح الحل في بيئة تجريبية. 2. حرك الكرة المرتدة إلى اليمين اجعل الكرة ترتد إلى اليمين كالتالي: اكتب الشيفرة بحيث تكون مسافة الانتقال إلى اليمين هي 100px. الحل احتجنا في التمرين السابق إلى تحريك خاصية واحدة فقط، بينما سنحتاج في هذا التمرين إلى تحريك خاصية إضافية هي elem.style.left. نريد تغيير الاحداثيات الأفقية للكرة بزيادتها تدريجيًا نحو اليمين أثناء السقوط، لذا سنضيف حركة إضافة anumate يمكن أن نستعمل معها دالة التوقيت linear ولكن تبدو makeEaseOut(quad)‎ أفضل بكثير. إليك الشيفرة النهاية الناتجة: let height = field.clientHeight - ball.clientHeight; let width = 100; // animate top (bouncing) animate({ duration: 2000, timing: makeEaseOut(bounce), draw: function(progress) { ball.style.top = height * progress + 'px' } }); // animate left (moving to the right) animate({ duration: 2000, timing: makeEaseOut(quad), draw: function(progress) { ball.style.left = width * progress + "px" } }); افتح الحل في بيئة تجريبية. ترجمة -وبتصرف- للفصل JavaScript Animation من سلسلة The Modern JavaScript Tutorial اقرأ أيضًا المقال السابق: إنشاء رسوم متحركة باستخدام CSS النسخة العربية الكاملة لكتاب: التحريك عبر CSS iframe { border: 1px solid #e7e5e3 !important; width: 100%; } iframe div#path { margin: auto; }
  12. تستخدم منحنيات بيزيه Bezier curves في رسوميات الحاسوب لإنشاء الأشكال والرسوم المتحركة المعتمدة على أوراق التنسيق المتتالية CSS وغيرها. هذه المنحنيات بسيطة وتستحق الدراسة، وبعدها ستكون مرتاحًا عند التعامل مع الرسوميات الشعاعية vector graphics والرسوم المتحركة المتقدمة. نقاط التحكم يحدد منحني بيزيه بواسطة مجموعة من النقاط الحاكمة، وقد يكون عددها 2 أو 3 أو 4 أو أكثر، لاحظ مثلًا منحنيًا من نقطتين: والمنحني التالي محدد بثلاث نقاط: وهذا بأربع نقاط: لو تمعنت في هذه المنحنيات، فيمكنك أن تلاحظ التالي: لا تنتمي النقاط بالضرورة إلى المنحني وهذا أمر طبيعي، وستفهم ذلك عندما سنشرح طريقة بنائه لاحقًا. درجة المنحني curve order تساوي عدد النقاط ناقصًا واحدًا، فالمنحني المؤلف من نقطتين هو خط مستقيم من الدرجة الأولى، والمنحني المؤلف من ثلاث نقاط هو منحن تربيعي، أي قطع مكافئ، وهكذا. ينحصر المنحني ضمن المضلع المحدّب convex hull الذي تشكله النقاط الحاكمة: سنتمكن من أَمثَلة اختبارات التداخل في رسوميات الحاسوب اعتمادًا على الخاصية الأخيرة، فلن تتداخل المنحنيات إن لم تتداخل المضلعات المحدبة، وبالتالي ستعطي دراسة تداخل المضلعات المحدبة جوابًا سريعًا لتداخل المنحنيات، بالإضافة إلى أنّ دراسة تداخل المضلعات أسهل لأنّ أشكالها مثل المثلث والمربع، تُعَد مفهومة موازنةً بالمنحنيات. تكمن الفائدة الرئيسية لاستخدام منحني بيزيه في تغيير شكل المنحني بمجرد تحريك النقاط الحاكمة. لاحظ المنحني الذي تولده الشيفرة التالية، واستخدم الفأرة لتحريك النقاط الحاكمة وراقب تغير المنحني: ستتمكن بعد التمرن قليلًا من رسم المنحني المطلوب بمجرد تحريك النقاط الحاكمة، ويمكنك عمليًا الحصول على أي شكل تريده بوصل عدة منحنيات، إليك بعض الأمثلة: خوارزمية دي كاستلجو De Casteljau يمكننا استخدام صيغة رياضية لرسم منحنيات بيزيه، وهذا ما سنشرحه لاحقًا، لأنّ خوارزمية دي كاستلجو De Casteljau’s algorithm التي نسبت لمخترعها، تتطابق مع التعريف الرياضي، وتساعدنا على تمييز طريقة إنشاء المنحنيات بصريًا. سنرى أولًا مثالًا لمنحن من ثلاث نقاط حاكمة، لاحظ أنه يمكنك تحريك النقاط 1 و 2 و 3 بالفأرة، ثم انقر زر التشغيل الأخضر. خوازمية دي كاستلجو لبناء منحني بيزيه من ثلاث نقاط ارسم النقاط الثلاث، والتي تمثلها النقاط 1 و2 و3 في المثال السابق. صل بين النقاط السابقة باتجاه واحد 3 → 2 → 1، وهي الخطوط البنية في المثال السابق. يأخذ المعامل t قيمه بين "0" و"1"، وقد استخدمنا في مثالنا النموذجي السابق خطوةً مقدارها "0.05"، أي تتحرك حلقة التنفيذ وفق الآتي: 0 ثم 0.05 ثم 0.1 ثم 0.15 وهكذا. لكل قيمة من قيم t: نأخذ على كل خط بني بين نقطتين متتاليتين نقطةً تبعد عن بدايته مسافةً تتناسب مع t، وطالما أن هناك خطان، فسنحصل على نقطتين، وستكون كلتا النقطتين في بداية الخط عندما يكون t=0 مثلًا، كما ستبعد النقطة الأولى عن بداية الخط مسافةً تعادل 25% من طوله عندما تكون t=0.25، وهكذا. نصل بين النقطتين المتشكلتين، والخط الواصل في المثال التالي باللون الأزرق. table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } p iframe.code-result__iframe { border: 1px solid #e7e5e3 !important; } t=0.25 t=0.5 خذ على الخط الأزرق السابق نقطةً تبعد عن طرفه مقدارًا يتناسب مع t، ستكون النقطة في نهاية الربع الأخير من الخط من أجل t=0.25، وستكون في منتصف الخط من أجل t=0.5، وهي النقطة الحمراء في الشكل السابق. عندما تتحول t بين 0 و1 ستضيف كل قيمة لهذا المعامل نقطةً من نقاط المنحني، وتمثل هذه المجموعة من النقاط منحني بيزيه. نتبع الأسلوب ذاته بالنسبة لمنحنى مكون من أربع نقاط حاكمة. الخوارزمية المتبعة لأربع نقاط هي: صل بين النقاط الحاكمة 1 إلى 2، و2 إلى 3، و3 إلى 4، وستحصل على ثلاثة خطوط بنية. لكل قيمة للمعامل t بين 0 و1: نأخذ نقاطًا على الخطوط الثلاثة تتناسب مع t كما فعلنا سابقًا، وبوصل هذه النقاط الثلاثة المتشكلة سنحصل على خطين جديدين، باللون الأخضر مثلًا. نأخذ على الخطين الأخضرين نقاطًا متناسبةً مع t، وعندها سنصل بين النقطتين المتشكلتين فنحصل على خط واحد، وليكن باللون الأزرق. نأخذ على الخط الأزرق نقطةً تبعد عن أحد طرفي الخط مسافةً متناسبةً مع t، فنحصل على نقطة حمراء واحدة تمثل أحد نقاط منحني بيزيه. تمثل النقاط الحمراء كلها المنحني الكامل. إن الخوارزمية السابقية تعاودية، أي قادرة على إعادة نفسها، لذلك يمكن تعميمها إلى N نقطة حاكمة: نصل بين النقاط فنحصل على N-1 خطًا. لكل قيمة للمعامل t بين 0 و1 نأخذ على كل خط نقطةً تبعد عن طرف الخط مسافةً متناسبةً مع t، ونصل بين هذه النقاط فنحصل على N-2 خطًا. نكرر الخطوة 2 حتى نحصل على نقطة واحدة تمثل إحدى نقاط منحني بيزيه. تشكل جميع النقاط الناتجة في الخطوة 3 منحني بيزيه. شغّل الرسوميات التالية ولاحظ شكل المنحني: منحن يبدو مثل الخط البياني للدالة y=1/t. نقاط حاكمة بشكل Zig-zag تبدو جيدةً أيضًا: يمكن رسم حلقة: يمكن رسم منحني بيزيه غير أملس أيضًا: تابع الأمثلة الحية السابقة لترى كيف يُبنى المنحني، إذا وجدت شيئًا مبهمًا في وصف الخوارزمية. يمكن استخدام العدد الذي نريده من النقاط الحاكمة لرسم منحني بيزيه من أي درجة، ما دامت الخوارزمية تعاوديةً، لكن يفضل عمليًا استخدام عدد قليل من النقاط -عادةً 2 أو 3 نقاط-، ثم نربط بين عدة منحنيات بسيطة إذا أردنا شكلًا أكثر تعقيدًا. الصيغ الرياضية يمكن توصيف منحني بيزيه باستخدام صيغة رياضية، وكما رأينا فلا حاجة لمعرفة هذه الصيغة. يرسم المستخدمون المنحني بمجرد تحريك النقاط بالفأرة، لكن إذا أردت استخدام الرياضيات فلك هذا. لنفترض أنّ إحداثيي نقطة حاكمة هو Pi. ستكون النقطة الأولى (P1 = (x1, y1، والثانية (P2 = (x2, y2، وهكذا حتى آخر نقطة (Pi = (xi, yi، ويمكن أن نصف منحني بيزيه بمعادلة متغيرها t يأخذ قيمه بين 0 و1. صيغة منحن يمر بنقطتين هي: صيغة منحن يمر من 3 نقاط هي: صيغة منحن يمر من 4 نقاط هي: إنّ المعادلات السابقة هي معادلات شعاعية، أي يمكننا وضع الإحداثيين x وy بدلًا من P، فمن أجل المنحني المار من ثلاث نقاط مثلًا ستصبح الصيغة بدلالة x وy هي: ينبغي تعويض القيم x1, y1, x2, y2, x3, y3 في المعادلات وكتابة الصيغة بدلالة المتغير t، فلو فرضنا أنّ إحداثيات النقاط الحاكمة الثلاثة هي: (0,0) و(0.5, 1) و(1, 0)،فستصبح قيمة إحداثيي النقطة المقابلة من منحني بيزيه: سنحصل على إحداثيي نقطة جديدة (x,y) من نقاط منحني بيزيه كلما تغيرت قيمة t بين 0 و1. خلاصة تُحدَّد منحنيات بيزيه بثلاث نقاط حاكمة، ويمكن رسمها بأسلوبين كما رأينا: باستخدام عملية رسومية: أي خوارزمية دي كاستلجو. باستخدام صيغة رياضية. من الصفات الجيدة لمنحنيات بيزيه: إمكانية رسم منحنيات ملساء عبر تحريك النقاط الحاكمة باستخدام الفأرة. إمكانية رسم أشكال أكثر تعقيدًا بوصل عدة منحنيات بيزيه معًا. استخدامات منحنيات بيزيه: في رسوميات الحاسوب والنمذجة ومحررات الرسوميات الشعاعية، كما توصف بها أنواع خطوط الكتابة. في تطوير تطبيقات الويب، من خلال الرسم ضمن لوحات وفي الملفات بتنسيق SVG، وقد كتبت الأمثلة النموذجية السابقة بتنسيق SVG، وهي عمليًا مستند SVG مفرد أُعطي نقاطًا مختلفةً مثل معاملات، ويمكن فتح هذه النماذج في نوافذ منفصلة والاطلاع على الشيفرة المصدرية: demo.svg. في الرسوميات المتحركة لوصف مسارها وسرعتها. ترجمة -وبتصرف- للفصل Bezier curve من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: كائنا localStorage وsessionStorage لتخزين بيانات الويب في جافاسكربت أساسيات منحنى بيزيه Bezier Curve في سكريبوس تأثيرات الانتقال والحركة في CSS
  13. IndexedDB هي قاعدة بيانات مدمجة مع المتصفح، ولها ميزات أقوى بكثير من الكائن localStorage، أهمها: تخزن أي نوع من القيم تقريبًا من خلال المفاتيح ذات الأنواع المختلفة. تدعم الإجرائيات المترابطة transactions لوثوقية أعلى. تدعم الاستعلامات عن المفاتيح ضمن مجالات، كما تدعم الوصول إلى المفتاح بالفهرس index. يمكن أن تخزّن بيانات أحجام أكبر بكثير مما يخزنه الكائن localStorage. إنّ القدرة التي تؤمّنها قاعدة البيانات هذه تفوق المطلوب في تطبيقات (خادم-عميل) التقليدية، فهي مصممة للتطبيقات التي تعمل دون اتصال offline، وذلك لتشترك مع تقنية عمال الخدمات ServiceWorkers وغيرها من التقنيات. تشرح توصيفات قاعدة البيانات IndexedDB الواجهة الأصلية للتعامل مع القاعدة، وتتميز بأنها مقادة بالأحداث، كما يمكن أيضًا استخدام آلية async/await بمساعدة مُغلّف wrapper يعتمد على الوعود promise مثل المُغلّف idb، وعلى الرغم من أن هذه آلية مريحة، إلا أنها ليست مثاليةً، إذ لن يتمكن المغلف من استبدال الأحداث في كل الحالات، لذلك سنبدأ أولًا بالتعرف على الأحداث، ثم نتفهم قاعدة البيانات IndexedDb، ثم سنعود لاستخدام المُغلِّف. الاتصال مع قاعدة البيانات نحتاج أولًا إلى تأسيس اتصال مع قاعدة البيانات IndexedDB باستعمال التابع open قبل البدء بالعمل معها، ولهذا التابع الصيغة التالية: let openRequest = indexedDB.open(name, version); name: قيمة نصية تشير إلى اسم قاعدة البيانات. version: قيمة صحيحة موجبة لنسخة قاعدة البيانات، وهي 1 افتراضيًا. قد توجد قواعد بيانات عديدة بأسماء مختلفة، لكنها تعود جميعها إلى نفس الأصل (نطاق/بروتوكول/منفذ)، ولا يمكن لمواقع الويب المختلفة الوصول إلى قواعد بيانات المواقع الأخرى. يعيد الاستدعاء الكائن openRequest، وينبغي علينا الاستماع إلى الأحداث المتعلقة به، وهي: success: يقع عندما تكون قاعدة البيانات جاهزة، أو لما يتواجد كائن قاعدة بيانات ضمن openRequest.result، والذي علينا استخدامه في الاستدعاءات اللاحقة. error: يقع عند الإخفاق في إنشاء الاتصال مع قاعدة البيانات upgradeneeded: قاعدة البيانات جاهزة، لكن نسختها قديمة. تتميز IndexedDB بوجود آلية مدمجة فيها لتحديد نسخة تخطيطها schema versioning، والتي لا نراها في قواعد البيانات الموجودة في جهة الخادم، فهي قاعدة بيانات تعمل من جهة العميل وتخزّن بياناتها ضمن المتصفح، كما لا يستطيع المطورون الوصول إليها في أي وقت، لذا عندما يزور المستخدم موقع الويب بعد إطلاق نسخة جديدة من التطبيق، فلا بدّ من تحديث قاعدة البيانات، فإذا كانت نسخة قاعدة البيانات أقل من تلك التي يحملها الأمر open، فسيقع الحدث upgradeneeded الذي يوازن بين النسختين ويُحدِّث هياكل البيانات بما يناسب، كما يقع هذا الحدث أيضًا عندما تكون قاعدة البيانات غير موجودة (أي تكون نسختها -تقنيًا- "0")، وهذا ما يجعلنا قادرين على إجراء عملية التهيئة. لنفترض أننا قد أصدرنا النسخة الأولى من تطبيقنا، حيث يمكننا عندها تأسيس الاتصال مع قاعدة بيانات نسختها "1"، وتهيئتها بالاستفادة من معالج الحدث upgradeneeded بالشكل التالي: let openRequest = indexedDB.open("store", 1); openRequest.onupgradeneeded = function() { // يقع عندما لا يمتلك العميل قاعدة بيانات // ...إنجاز التهيئة... }; openRequest.onerror = function() { console.error("Error", openRequest.error); }; openRequest.onsuccess = function() { let db = openRequest.result; // متابعة العمل مع قاعدة البيانات }; ثم أصدرنا لاحقًا النسخة الثانية، عندها سنتمكن من تأسيس الاتصال مع النسخة "2" والتحديث بالشكل التالي: let openRequest = indexedDB.open("store", 2); openRequest.onupgradeneeded = function(event) { // قاعدة البيانات أقل من النسخة 2 أو غير موجودة let db = openRequest.result; switch(event.oldVersion) { // النسخة الموجودة من القاعدة case 0: // لا قاعدة بيانات // تهيئة case 1: // يمتلك المستخدم النسخة 1 من القاعدة // تحديث } }; لاحظ أنه عندما تكون نسختنا الحالية هي "2"، فسيتضمن معالج الحدث onupgradeneeded شيفرةً تعالج حالة النسخة "0"، وهذا الأمر ملائم للمستخدمين الذين يزورون الصفحة للمرة الأولى ولا يمتلكون قاعدة بيانات، كما يتضمن شيفرةً تعالج وجود النسخة "1" لتحديثها. سيقع الحدث openRequest.onsuccess عندما ينتهي معالج الحدث onupgradeneeded بنجاح، وينجح تأسيس الاتصال مع قاعدة البيانات. لحذف قاعدة البيانات: let deleteRequest = indexedDB.deleteDatabase(name) //العملية deleteRequest.onsuccess/onerror يتتبع الحدثان لا يمكن تأسيس اتصال مع قاعدة بيانات بنسخة أقدم إذا كانت النسخة الحالية لقاعدة بيانات المستخدم أعلى من النسخة التي نمررها للاستدعاء، أي نسخة القاعدة "3" ونحاول تأسيس اتصال مع النسخة "2" مثلًا، فسيتولد خطأ وسيقع الحدث openRequest.onerror. وعلى الرغم من ندرة حدوث هذا الأمر، إلا أنه قد يحصل عندما يحاول الزائر تحميل شيفرة JavaScript قديمة، مثل أن تكون من الذاكرة المؤقتة لخادم وكيل مثلًا، حيث ستكون الشيفرة قديمةً والقاعدة حديثة. ولا بدّ من التحقق من نسخة قاعدة البيانات db.version، واقتراح إعادة تحميل الصفحة إذا أردنا الحماية من الأخطاء، كما نستخدم ترويسة HTTP ملائمةً للتعامل مع الذاكرة المؤقتة لتفادي تحميل شيفرة قديمة، وبالتالي لن نواجه مشاكل. مشكلة التحديث المتوازي Parallel update ما دمنا نتكلم عن تحديد نسخة التطبيق، فسنتطرق إلى مشكلة صغيرة مرتبطة بذلك، لنتأمل الحالة التالية: فتح مستخدم موقعنا في نافذة متصفح، وكانت نسخة قاعدة البيانات هي "1". ثم حدّثنا الصفحة وأصبحت الشيفرة أحدث. ثم فتح المستخدم نفسه موقعنا في نافذة أخرى. أي ستكون هناك نافذة متصلة بقاعدة بيانات نسختها "1"، بينما تحاول النافذة الأخرى تحديثها إلى النسخة "2" عبر معالج الحدث upgradeneeded. تتلخص المشكلة بأن قاعدة البيانات مشتركة بين نافذتين، لأنهما تعودان لنفس الموقع ولهما الأصل ذاته، ولا يمكن أن تكونا من النسختين "1" و"2" في نفس الوقت، ولتنفيذ عملية الانتقال إلى النسخة "2"، ينبغي إغلاق كل قنوات الاتصال مع النسخة "1" بما فيها قناة اتصال النافذة الأولى، ولتنظيم ذلك سيقع الحدث versionchange ضمن كائن قاعدة البيانات "المنتهية الصلاحية"، لذا يفترض الاستماع لهذا الحدث، وإغلاق اتصال قاعدة البيانات القديمة. يمكن اقتراح إعادة تحميل الصفحة للحصول على الشيفرة الأحدث، فإذا لم نستمع إلى الحدث versionchange ولم نغلق قناة الاتصال، فلن يُنفَّذ الاتصال الثاني، وسيعطي الكائن openRequest الحدث blocked بدلًا من success ولن تعمل النافذة الثانية. إليك الشيفرة التي تتعامل بشكل صحيح مع التحديث المتوازي، إذ تثبّت معالج الحدث onversionchange الذي يقع عندما يصبح الاتصال الحالي مع قاعدة البيانات منتهي الصلاحية، أي عندما تُحدَّث نسخة قاعدة البيانات في مكان آخر، ويغلق الاتصال. let openRequest = indexedDB.open("store", 2); openRequest.onupgradeneeded = ...; openRequest.onerror = ...; openRequest.onsuccess = function() { let db = openRequest.result; db.onversionchange = function() { db.close(); alert("Database is outdated, please reload the page.") }; // ...قاعدة البيانات جاهزة! استخدمها... }; openRequest.onblocked = function() { }; إن ما نفعله هنا بعبارة أخرى هو: يُعلمنا المستمِع إلى الحدث db.onversionchange عن محاولة التحديث المتوازي، عندما تصبح النسخة الحالية لقاعدة البيانات منتهية الصلاحية. يُعلمنا المستمِع إلى الحدث openRequest.onblocked عن الحالة المعاكسة، وهي وجود اتصال لم يُغلَق بعد مع نسخة منتهية الصلاحية في نافذة ما، وبالتالي لن يعمل الاتصال الجديد. يمكن أن نتعامل مع الموضوع بطريقة ألطف عند استخدام الحدث db.onversionchange الذي يبلغ المستخدم بوجوب حفظ بياناته قبل قطع الاتصال، يمكن أيضًا اعتماد مقاربة أخرى لا تتعلق بإغلاق الاتصال مع قاعدة البيانات في معالج الحدث db.onversionchange، بل باستخدام معالج الحدث onblocked -في نافذة متصفح أخرى- لتنبيه المستخدم بأن النسخة الجديدة لن تُحمَّل قبل إغلاق النافذة الأخرى. لا يحدث التعارض في التحديث إلا نادرًا، ومع ذلك لا بدّ من توقعه والتعامل معه، على الأقل باستخدام معالج الحدث onblocked لمنع انهيار السكربت. مخزن الكائن نحتاج إلى مخزن الكائنات Object Store لتخزين أي شيء في قاعدة البيانات IndexedDB، وهو مفهوم جوهري فيها، ويقابل مفهوم "الجداول tables" أو "المجموعات collections" في قواعد البيانات الأخرى، كما تُخزّن فيه البيانات. وقد تتكون القاعدة من عدة مخازن، يكون الأول فيها للمستخدمين، والثاني للبضائع مثلًا وغيرها. على الرغم من اسم "مخزن الكائن" إلا أنه يمكن تخزين القيم الأولية فيه بالإضافة إلى الكائنات، إذ يمكن تخزين أي قيمة بما فيها الكائنات المعقدة، وتستخدم قاعدة البيانات خوارزمية التفكيك المعيارية standard serialization algorithm لنسخ وتخزين الكائن، ويشابه ذلك استخدام الأمر JSON.stringify لكن مع إمكانيات أكثر، وقدرة على تخزين أنواع أكثر من البيانات. ومن الكائنات التي لا يمكن تخزينها في هذه المخازن، نجد الكائن ذو المراجع الحلقية التي تشكل حلقةً يدل آخرها على أولها، والتي لا يمكن أن تُفكك، وسيفشل الأمر JSON.stringify معها. يجب أن يكون لكل قيمة ستُخزّن في قاعدة البيانات مفتاح فريد key، كما يجب أن تكون هذه المفاتيح من أحد الأنواع التالية: رقم أو تاريخ أو نص أو قيمة ثنائية أو مصفوفة، وسنتمكن من البحث عن القيم أو إزالتها أو تحديثها بواسطة هذه المفاتيح الفريدة. سنرى قريبًا كيف سنحصل على مفتاح عند إضافة قيمة جديدة إلى المخزن، كما يحدث عند استخدام الكائن localStorage، لكن ستسمح لنا قاعدة البيانات IndexedDB عند تخزين كائن؛ بضبط وإعداد خصائصه مثل مفتاح، وهذه مقاربة أفضل بكثير، كما يمكننا توليد المفاتيح تلقائيًا، لكننا سنحتاج إلى إنشاء مخزن للكائن أولًا. إليك الصيغة المستخدمة في إنشاء مخزن لكائن: db.createObjectStore(name[, keyOptions]); name: وتمثل اسم المخزن. keyOptions: وتمثل كائنًا اختياريًا له خاصيتان، هما: keyPath: المسار إلى خاصية الكائن التي سنستخدمها مفتاحًا، مثل id. autoIncrement: إذا كانت قيمته true فسيتولد تلقائيًا مفتاح للكائن الجديد، مثل رقم يتزايد باستمرار. إذا لم نستخدم keyOptions، فلا بدّ حينها من التصريح عن المفتاح لاحقًا عند تخزين الكائن. يستخدم مخزن الكائن التالي الخاصية id مفتاحًا، وبشكل صريح: db.createObjectStore('books', {keyPath: 'id'}); لا يمكن إنشاء أو تعديل مخزن كائن عند تحديث نسخة قاعدة البيانات إلا ضمن معالج الحدث upgradneeded، ويشكل هذا الأمر محدوديةً تقنيةً، إذ يمكن إضافة أو إزالة أو تحديث البيانات خارج المعالج، بينما لا يمكننا إنشاء أو حذف أو تغيير مخزن الكائن إلا خلال تحديث نسخة قاعدة البيانات. وتوجد مقاربتان لتنفيذ عملية تحديث نسخة قاعدة البيانات: يمكن كتابة دوال خاصة لتحديث كل نسخة ممكنة إلى النسخة الجديدة، من "1" إلى "2"، ومن "2" إلى "3" وهكذا، ثم يمكن موازنة النسخ ضمن جسم دالة معالج الحدث upgradeneeded من القديمة 2 إلى الحديثة 4 مثلًا، وتنفيذ الدالة المناسبة لتنفيذ التحديث خطوةً بخطوة، أي من 2 إلى 3، ثم من 3 إلى 4. فحص قاعدة البيانات والحصول على قائمة بمخازن الكائنات الموجودة باستخدام التابع db.objectStoreNames، ويمثل الكائن المُعاد قائمةً من النوع DOMStringList، والذي يزودنا بالتابع (contains(name الذي يتحقق من وجود مخزن باسم محدد، ثم سنتمكن من إجراء التحديث اعتمادًا على ما هو موجود وما هو غير موجود، وهذه المقاربة أبسط لقواعد البيانات الصغيرة، وإليك مثالًا عن استخدامها: let openRequest = indexedDB.open("db", 2); // تحديث وإضافة قواعد البيانات دون تحقق openRequest.onupgradeneeded = function() { let db = openRequest.result; if (!db.objectStoreNames.contains('books')) { // "books" إن لم يكن هناك مخزن باسم db.createObjectStore('books', {keyPath: 'id'}); // أنشئه } }; ولحذف مخزن كائن: db.deleteObjectStore('books') الإجرائيات المترابطة Transactions يُعَد مصطلح "إجرائيات مترابطة" مصطلحًا عامًا، ويُستخدم في قواعد بيانات عديدة، كما يشير إلى مجموعة من العمليات التي ينبغي أن تُنفَّذ مترابطةً، بحيث تنجح معًا أو تخفق معًا، فعندما يشتري شخص شيئًا ما مثلًا، فعلينا: سحب المبلغ من حساب المشتري. إضافة المشتريات إلى سلة مشترياته. سيبدو الأمر سيئًا بالطبع لو أُنجزت الخطوة الأولى بنجاح وفشلت الخطوة الثانية لسبب أو لآخر، فإما أن تنجح الخطوتان -أي يكتمل الشراء- أو تخفق الخطوتان، فعندها لن يخسر المشتري ماله ويستطيع إعادة المحاولة مجددًا. ينبغي أن تُنفَّذ جميع العمليات على البيانات ضمن إجرائيات مترابطة في القاعدة IndexedDB، وللبدء بإجرائية مترابطة يجب تنفيذ الأمر: db.transaction(store[, type]); store: اسم المخزن الذي ستُنفَّّذ عليه إجرائية مترابطة، المخزن "books" مثلًا، ويمكن أن يكون مصفوفةً من أسماء المخازن إذا كنا نريد الوصول إلى عدة مخازن. type: نوع الإجرائية المترابطة، وقد يكون: readonly: يُنفِّذ عمليات قراءة فقط، وهذا هو الخيار الافتراضي. readwrite: يُنفِّذ عمليات قراءة وكتابة فقط، ولا يستطيع إنشاء أو حذف أو تبديل الكائن. كما يمكن تنفيذ إجرائيات مترابطة من النوع versionchange، ويستطيع هذا النوع تنفيذ أي شيء، لكن لا يمكن إنشاؤه يدويًا، إذ تنشئ قاعدة البيانات الإجرائيات المترابطة من النوع versionchange عند تأسيس الاتصال مع قاعدة البيانات لتنفيذ معالج الحدث updateneeded، لذا فهو المكان الوحيد الذي نستطيع فيه تحديث هيكلية قاعدة البيانات أو إنشاء وحذف كائن المخزن. يمكن إضافة العناصر إلى المخزن بعد إنشاء الإجرائية المترابطة بالشكل التالي: let transaction = db.transaction("books", "readwrite"); // (1) // الحصول على كائن المخزن للتعامل معه let books = transaction.objectStore("books"); // (2) let book = { id: 'js', price: 10, created: new Date() }; let request = books.add(book); // (3) request.onsuccess = function() { // (4) console.log("Book added to the store", request.result); }; request.onerror = function() { console.log("Error", request.error); }; تُنفَّذ العملية مبدئيًا وفق خطوات أربع هي: إنشاء إجرائية مترابطة تشير إلى كل المخازن الذي سنصل إليها. الحصول على كائن المخزن باستخدام الأمر (transaction.objectStore(name. تنفيذ العمليات على كائن المخزن (books.add(book. التعامل مع حالة نجاح أو فشل الإجرائية المترابطة على كائن المخزن (books.add(book. تدعم كائنات المخازن تابعين لتخزين القيم، هما: ([put(value,[key: حيث يضيف القيمة value إلى المخزن، ويزوَّد التابع بالمعامل key فقط في الحالة التي لا يمتلك فيها المخزن أحد الخيارين keyPath أو autoIncrement، فإن وجدت قيمة لها نفس المفتاح فستُستبدَل. ([add(value,[key: يشابه التابع السابق، لكن إن وجدت قيمة لها نفس المفتاح فسيخفق الطلب، وسيولِّد خطأً باسم "ConstraintError". يمكن إرسال الطلب (books.add(book مثلًا بما يتناسب مع تأسيس اتصال مع قاعدة بيانات، ومن ثم ننتظر وقوع أحد الحدثين success/error. سيكون مفتاح الكائن الجديد هو نتيجة الطلب request.result للتابع add. فإن وقع خطأ ما فسنجده في الكائن request.error. اكتمال الإجراءات المترابطة بدأنا الإجرائية المترابطة في المثال السابق بتنفيذ الطلب add، لكن -وكما أشرنا سابقًا- قد تتألف الإجرائية المترابطة من عدة طلبات ينبغي أن تنجح معًا أو تخفق معًا، فكيف سنميِّز إذًا انتهاء الإجرائية ولا توجد طلبات أخرى قيد التنفيذ؟ الجواب باختصار هو أننا لا نستطيع، ولا بدّ من وجود طريقة يدوية لإنهاء الإجرائيات المترابطة في النسخة التالية 3.0 من التوصيفات، لكن حاليًا في النسخة 2.0 لا يوجد شيء مشابه لهذا. وستكتمل الإجرائية تلقائيًا عندما تنتهي جميع الطلبات المتعلقة بإجرائية مترابطة، وسيصبح صف المهام المتناهية الصغر microtasks queue فارغًا. توصف الإجرائية عادةً بأنها مكتملة عندما تكتمل كل طلباتها، وينتهي تنفيذ الشيفرة المتعلقة بها، لذا فلا حاجة في الشيفرة السابقة مثلًا إلى استدعاء خاص لإنهاء الإجرائية المترابطة. ويترافق مبدأ الاكتمال التلقائي للإجرائيات المترابطة بتأثير جانبي مهم، فلا يمكن تنفيذ عملية غير متزامنة مثل fetch أو setTimeout أثناء تنفيذ الإجرائية، كما لن تبقي قاعدة البيانات IndexedDB الإجرائية المترابطة في حالة انتظار حتى تُنجز هذه العملية. سيُخفق الطلب request2 في السطر(*) من الشيفرة التالية لأن الإجرائية قد اكتملت بالفعل، ولن نتمكن من تنفيذ طلبات أخرى: let request1 = books.add(book); request1.onsuccess = function() { fetch('/').then(response => { let request2 = books.add(anotherBook); // (*) request2.onerror = function() { console.log(request2.error.name); // TransactionInactiveError }; }); }; لأنّ fetch عملية غير متزامنة وتمثل مهمةً مستقلةً macrotask، وستُغلَق الإجرائية المترابطة قبل أن يبدأ المتصفح بتنفيذ مهمات مستقلة، كما سيرى محررو توصيفات قاعدة البيانات IndexedDB أنّ مدة إنجاز الإجرائيات المترابطة لا بدّ أن تكون قصيرة، وذلك لأسباب تتعلق بالأداء في الغالب. تقفِل الإجرائيات المترابطة من النوع readwrite المخزن عند الكتابة فيه، لذا إذا حاول جزء آخر من التطبيق تنفيذ عملية مشابهة على نفس المخزن، فعليه الانتظار. ستعلَّق الإجرائية المترابطة الجديدة حتى تنتهي الأولى، وهذا ما يسبب تأخيرًا غريبًا إذا استغرقت إجرائية مترابطة ما وقتًا طويلًا، فما العمل إذًا؟ بإمكاننا في المثال السابق تنفيذ إجرائية مترابطة جديدة db.transaction تمامًا قبل الطلب الجديد في السطر "(*)"، لكن يفضَّل -إن أردنا إبقاء العمليات معًا في إجرائية مترابطة واحدة- أن نفصل الإجرائية المترابطة في قاعدة البيانات IndexedDB عن الأمور الأخرى غير المتزامنة. نفِّذ العملية fetch أولًا، ثم حضر البيانات إن تطلب الأمر ذلك، ثم أنشئ إجرائيةً مترابطةً، ونفّذ كل الطلبات وسينجح الأمر. استمع إلى الحدث transaction.oncomplete لمعرفة اللحظة التي تكتمل فيها الإجرائية المترابطة بنجاح. let transaction = db.transaction("books", "readwrite"); // ...perform operations... transaction.oncomplete = function() { console.log("Transaction is complete"); }; تضمن الخاصية complete فقط اكتمال وحفظ الإجرائية بالكامل، وقد تنجح طلبات بمفردها، لكن قد تفشل بالمقابل عملية الكتابة النهائية، بسبب خطأ في منظومة الدخل/خرج مثلًا. استدعي التابع التالي لإيقاف الإجرائية المترابطة يدويًا: transaction.abort(); سيلغي هذا الاستدعاء كل التغييرات التي نفذتها الطلبات، ويتسبب بوقوع الحدث transaction.onabort. معالجة الأخطاء قد تُخفق طلبات الكتابة، ولا بدّ من توقع هذا الأمر -لا نتيجةً للأخطاء المحتملة التي قد نرتكبها فقط- بل لأسباب تتعلق بالإجرائيات المترابطة بحد ذاتها، فقد يحدث تجاوز حجم المخزن المحدد على سبيل المثال، لذا لا بدّ أن نكون مستعدين للتعامل مع حالات كهذه. يوقف إخفاق الطلب الإجرائية المترابطة تلقائيًا ويلغي كل التغييرات التي حدثت، لكننا قد نحتاج إلى التعامل مع حالة إخفاق الطلب، لتجريب طلب آخر مثلًا، دون إلغاء التغييرات التي حدثت، ومن ثم متابعة الإجرائية المترابطة، وهذا أمر ممكن، إذ يمكن لمعالج الحدث request.onerror أن يمنع إلغاء الإجرائية المترابطة عن طريق استدعاء التابع ()event.preventDefault. سنرى في المثال التالي كيف يُضاف كتاب جديد بمفتاح id موجود مسبقًا، وعندها سيولِّد التابع store.add الخطأ "ConstraintError"، الذي نتعامل معه دون إلغاء الإجرائية المترابطة: let transaction = db.transaction("books", "readwrite"); let book = { id: 'js', price: 10 }; let request = transaction.objectStore("books").add(book); request.onerror = function(event) { //عند إضافة قيمة بمفتاح موجود مسبقًا ConstraintError يقع الخطأ if (request.error.name == "ConstraintError") { console.log("Book with such id already exists"); // التعامل مع الخطأ event.preventDefault(); // لا تلغ الإجرائية المترابطة // استخدم مفتاح جديد للكتاب؟ } else { // خطأ غير متوقع لايمكن التعامل معه // ستلغى الإجرائية المترابطة } }; transaction.onabort = function() { console.log("Error", transaction.error); }; تفويض الأحداث هل نحتاج إلى الحدثين onsuccess/onerror عند كل طلب؟ والجواب هو لا، ليس في كل مرة، إذ يمكننا أن نستعمل تفويضًا للحدث event delegation بدلًا من ذلك. تجري عملية انسياب Bubbling الحدث في قاعدة بيانات بالشكل التالي: request → transaction → database والتي تقتضي التقاط العنصر الداخلي ضمن شجرة DOM للحدث، ثم تستمع إليه الأحداث الخارجية بالتتالي. قد تنساب الأحداث في الشجرة DOM للخارج bubbling أو للداخل capturing، لكن يُستخدم عادةً الانسياب نحو الخارج، ويمكن حينها التقاط الأخطاء عن طريق معالج الحدث db.onerror لإظهارها أو لأي أسباب أخرى. db.onerror = function(event) { let request = event.target; // الطلب الذي ولّد الخطأ console.log("Error", request.error); }; لكن ماذا لو تمكنا من التعامل مع الخطأ كاملًا؟ عندها لن نحتاج لإظهار أي شيء، يمكننا إيقاف الانسياب الخارجي للأحداث، وبالتالي إيقاف الحدث db.onerror، باستخدام الأمر ()event.stopPropagation ضمن دالة معالجة الحدث request.onerror. request.onerror = function(event) { if (request.error.name == "ConstraintError") { console.log("Book with such id already exists"); // معالجة الخطأ event.preventDefault(); // لا توقف الإجرائية المترابطة event.stopPropagation(); // لا تجعل الأحداث تنساب للخارج } else { // لا تفعل شيئًا // إيقاف الإجرائية المترابطة // transaction.onabort يمكن التعامل مع الخطأ ضمن } }; عمليات البحث يوجد نوعان أساسيان للبحث في مخزن الكائن: بقيمة المفتاح أو مجال المفتاح، ففي مثالنا عن المخزن "books"، سنتمكن من البحث عن قيمة أو مجال من القيم للمفتاح book.id. باستخدام حقل آخر من حقول الكائن، مثل البحث في مثالنا السابق اعتمادًا على الحقل book.price، ويتطلب هذا البحث هيكليةً إضافيةً للبيانات تُدعى الفهرس index. البحث بالمفتاح لنتعرف أولًا على النوع الأول، وهو البحث بالمفتاح، وتدعم طرق البحث حالة القيمة الدقيقة للمفتاح أو ما يسمى "مجالًا من القيم"، ويمثلها الكائن IDBKeyRange، وهي كائنات تحدد مجالًا مقبولًا من قيم المفاتيح، وتتولد الكائنات IDBKeyRange نتيجةً لاستخدام الاستدعاءات التالية: ([IDBKeyRange.lowerBound(lower, [open: وتعني أن تكون القيم أكبر أو تساوي الحد الأدنى lower، أو أكبر تمامًا إذا كانت قيمة open هي "true". ([IDBKeyRange.upperBound(upper, [open: تعني أن تكون القيم أصغر أو تساوي الحد الأعلى upper، أو أصغر تمامًا إذا كانت قيمة open هي "true". ([IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen: تعني أن تكون القيمة محصورةً بين الحد الأدنى lower والأعلى upper، ولن يتضمن المجال قيمتي الحدين الأعلى والأدنى إذا ضبطنا open على القيمة "true". (IDBKeyRange.only(key: وتمثل مجالًا يتكون من مفتاح واحد، وهو نادر الاستخدام. سنرى التطبيق العملي لهذه الاستدعاءات السابقة قريبًا. ولتنفيذ بحث حقيقي ستجد التوابع التالية التي تقبل الوسيط query، وقد يكون هذا الوسيط قيمةً دقيقةً للمفتاح أو مجالًا: (store.get(query: يبحث عن أول قيمة من خلال مفتاح أو مجال. ([store.getAll([query], [count: يبحث عن جميع القيم، ويكون عدد القيم محدودًا إذا أعطينا قيمةً للوسيط count. (store.getKey(query: يبحث عن أول مفتاح يحقق الاستعلام، وعادةً يكون مجالًا. ([store.getAllKeys([query], [count: يبحث عن كل المفاتيح التي تحقق الاستعلام، ويكون عدد النتائج محدودًا إذا أعطينا count قيمةً. [(store.count([query: يعيد العدد الكلي للمفاتيح التي تحقق الاستعلام، وعادةً يكون مجالًا. قد يكون لدينا على سبيل المثال الكثير من الكتب في مخزننا، وما دام الحقل id هو المفتاح، فستتمكن تلك التوابع من البحث باستعماله، مثل: // الحصول على كتاب واحد books.get('js') // 'css' <= id <= 'html' الحصول على كتاب يكون books.getAll(IDBKeyRange.bound('css', 'html')) // id < 'html' الحصول على كتاب بحيث books.getAll(IDBKeyRange.upperBound('html', true)) // الحصول على كل الكتب books.getAll() // id > 'js' الحصول على كل الكتب التي تحقق books.getAllKeys(IDBKeyRange.lowerBound('js', true)) البحث بالحقول نحتاج إلى إنشاء هيكلية إضافية للبيانات تُدعى الفهرس index، لتنفيذ استعلام باستخدام حقل آخر من حقول الكائن، ويمثِّل الفهرس إضافةً add-on إلى المخزن لتتبع حقل محدد من كائن، ويُخزّن الفهرس -ومن أجل كل قيمة للحقل المحدد- قائمةً من مفاتيح الكائنات التي تمتلك تلك القيمة، وسنوضح ذلك لاحقًا بالتفصيل. إليك صيغة إنشاء الفهرس: objectStore.createIndex(name, keyPath, [options]); name: اسم الفهرس. keyPath: المسار إلى حقل الكائن الذي سيتتبعه الفهرس، وسنبحث اعتمادًا على ذلك الحقل. option: كائن اختياري له الخصائص التالية: unique: إذا كانت قيمته "true"، فيجب أن يوجد كائن واحد في المخزن له القيمة المعطاة في المسار المحدد، وسيجبر الفهرس تنفيذ هذا الأمر بتوليد خطأ إذا حاولنا إضافة نسخة مكررة. multiEntry: ويستخدَم فقط إذا كانت القيمة في المسار المحدد keyPath مصفوفةً، وسيعامل الفهرس في هذه الحالة المصفوفة بأكملها مثل مفتاح افتراضيًا، لكن إذا كانت قيمة multiEntry هي "true"، فسيحتفظ الفهرس بقائمة من كائنات المخزن لكل قيمة في تلك المصفوفة، وهكذا ستصبح عناصر المصفوفة مفاتيح فهرسة. نخزّن في المثال التالي الكتب باستعمال المفتاح id، ولنقل أننا نريد البحث باستعمال الحقل price، سنحتاج أولًا إلى إنشاء فهرس، ويجب أن ننجز ذلك ضمن معالج الحدث upgradeneeded تمامًا مثل مخزن الكائن: openRequest.onupgradeneeded = function() { //لابد من إنشاء الفهرس هنا، ضمن إجرائية تغيير النسخة let books = db.createObjectStore('books', {keyPath: 'id'}); let index = books.createIndex('price_idx', 'price'); }; سيتعقب الفهرس الحقل price. لن يكون السعر حقلًا بقيم فريدة، إذ يمكن وجود عدة كتب لها السعر نفسه، وبالتالي لن نضبط الخيار unique. السعر قيمة مفردة وليس مصفوفةً، لذا لن نطبّق الخيار multiEntry. لنتخيل الآن وجود 4 كتب في مخزننا inventory، إليك الصورة التي تظهر طبيعة الفهرس index: كما قلنا سابقًا، سيحفَظ الفهرس العائد لكل قيمة من قيم price -الوسيط الثاني- قائمةً بالمفاتيح التي ترتبط بهذا السعر، ويُحدَّث الفهرس تلقائيًا، فلا حاجة للاهتمام بهذا الأمر. سنطبِّق ببساطة التوابع السابقة نفسها على الفهرس عندما نريد أن نبحث عن سعر محدد: let transaction = db.transaction("books"); // قراءة فقط let books = transaction.objectStore("books"); let priceIndex = books.index("price_idx"); let request = priceIndex.getAll(10); request.onsuccess = function() { if (request.result !== undefined) { console.log("Books", request.result); // مصفوفة من الكتبالتي سعرها=10 } else { console.log("No such books"); } }; يمكن أن نستخدم IDBKeyRange أيضًا لإنشاء مجال محدد، والبحث عن الكتب الرخيصة أو باهظة الثمن: // جد الكتب التي سعرها يساوي أو أقل من 5 let request = priceIndex.getAll(IDBKeyRange.upperBound(5)); تُصنَّف الفهارس داخليًا وفق الحقل الذي تتعقبه، وهو السعر price في حالتنا، لذا سترتَّب النتائج حسب السعر عندما ننفذ البحث وفق حقل السعر. الحذف من مخزن يبحث التابع delete عن القيم التي يُطلب حذفها عبر استعلام، وله صيغة شبيهة بالتابع getAll. (delete(query: يحذف القيم المطابقة للاستعلام، إليك مثالًا: // id='js' احذف الكتاب الذي يحقق books.delete('js'); إذا أردنا حذف الكتب بناءً على سعرها أو بناءً على أي حقل آخر، فعلينا أولًا إيجاد المفتاح ضمن الفهرس، ثم استدعاء التابع delete: // ايجاد المفتاح في حالة السعر=5 let request = priceIndex.getKey(5); request.onsuccess = function() { let id = request.result; let deleteRequest = books.delete(id); }; ولحذف كل شيء: books.clear(); // افرغ المخزن المؤشرات Cursors تعيد التوابع -مثل getAll/getAllKeys- مصفوفةً من المفاتيح والقيم، وقد يكون كائن التخزين ضخمًا وأكبر من أن تتسع له الذاكرة المتاحة، وبالتالي سيخفق التابع getAll في الحصول على كل سجلات المصفوفة، فما العمل في حالة مثل هذه؟ ستساعدنا المؤشرات على الالتفاف على هذه المشكلة. المؤشرات هي كائنات خاصة تتجاوز كائن التخزين عند تنفيذ استعلام، وتعيد زوجًا واحدًا (مفتاح/قيمة) في كل مرة، وبالتالي ستساعدنا في توفير الذاكرة. ما دام مخزن الكائن سيُصنَّف وفقًا للمفتاح، فسينتقل المؤشر عبر المخزن وفق ترتيب المفتاح تصاعديًا (افتراضيًا). إليك صيغة استخدام المؤشر: // لكن مع مؤشر getAll مثل let request = store.openCursor(query, [direction]); // store.openKeyCursor للحصول على المفاتيح لا القيم query: مفتاح أو مجال لمفتاح، ويشابه في عمله getAll. direction: وهو وسيط اختياري، ويفيد في ترتيب تنقل المؤشر بين السجلات: next: وهي القيمة الافتراضية، حيث يتحرك المؤشر من المفتاح ذي القيمة الأدنى إلى الأعلى. prev: يتحرك المؤشر من القيمة العليا للمفتاح إلى الدنيا. nextunique وprevunique: تشابهان الخيارين السابقين، لكنهما تتجاوزان المفتاح المكرر، وتعملان فقط مع المؤشرات المبنية على فهارس، فعند وجود عدة قيم لن تُعاد إلا قيمة أول سعر يحقق معيار البحث. إن الاختلاف الرئيسي في عمل المؤشرات هو أنها تسبب وقوع الحدث request.onsuccess عدة مرات، مرةً عند كل نتيجة. إليك مثالًا عن استخدام المؤشر: let transaction = db.transaction("books"); let books = transaction.objectStore("books"); let request = books.openCursor(); // يُستدعى من أجل كل كتاب وجده المؤشر request.onsuccess = function() { let cursor = request.result; if (cursor) { let key = cursor.key; // (id) مفتاح الكتاب let value = cursor.value; // كائن الكتاب console.log(key, value); cursor.continue(); } else { console.log("No more books"); } }; للمؤشر التوابع التالية: (advance(count: يدفع المؤشر إلى الأمام بمقدار الوسيط count ويتجاوز القيم. ([continue([key]): يدفع المؤشر إلى القيمة التالية في المجال المطابق للبحث، أو إلى ما بعد مفتاح معين مباشرةً إذا حددنا القيمة key. يُستدعى معالج الحدث onsuccess، سواءً تعددت القيم التي تطابق معيار البحث أو لا، ثم سنتمكن من الحصول على المؤشر الذي يدل على السجل التالي ضمن النتيجة result التي حصلنا عليها، أو أن لا يؤشر إلى شيء unidefined. تدريب أنشئ المؤشر في المثال السابق لمخزن الكائن. يمكن أيضًا أن ننشئ المؤشر اعتمادًا على الفهارس، إذ تسمح الفهارس كما نتذكر بالبحث وفق أي حقل من حقول الكائن، وتتشابه المؤشرات المبنية على الفهارس مع تلك المبنية على أساس مخازن الكائنات، فهي توفر الذاكرة بإعادة قيمة واحدة في كل مرة. سيكون cursor.key مفتاح الفهرسة بالنسبة للمؤشرات المبنية على الفهارس، وينبغي أن نستخدم الخاصية cursor.primaryKey إذا أردنا الحصول على مفتاح الكائن. let request = priceIdx.openCursor(IDBKeyRange.upperBound(5)); // يُستدعى لكل سجل request.onsuccess = function() { let cursor = request.result; if (cursor) { let primaryKey = cursor.primaryKey; // مفتاح مخزن الكائن التالي (id field) let value = cursor.value; // مفتاح مخزن الكائن التالي (book object) let key = cursor.key; //مفتاح الفهرسة التالي (price) console.log(key, value); cursor.continue(); } else { console.log("No more books"); } }; مغلف الوعود Promise wrapper إنّ إضافة الحدثين onsuccess/onerror إلى كل طلب أمر مرهق، ويمكن أحيانًا تحسين الوضع باستخدام التفويض delegation، مثل إعداد معالجات أحداث للإجرائية المترابطة بأكملها، لكن الصيغة async/await أكثر ملائمةً. لنستخدم مُغلَّف الحدث البسيط idb في هذا المقال، حيث ننشئ كائن idb عامًا مزوّدًا بتوابع مبنية على الوعود promisified لقاعدة البيانات IndexedDB، وسنكتب الشيفرة التالية بدلًا من استخدام onsuccess/onerror: let db = await idb.openDB('store', 1, db => { if (db.oldVersion == 0) { // نفذ عملية التهيئة db.createObjectStore('books', {keyPath: 'id'}); } }); let transaction = db.transaction('books', 'readwrite'); let books = transaction.objectStore('books'); try { await books.add(...); await books.add(...); await transaction.complete; console.log('jsbook saved'); } catch(err) { console.log('error', err.message); } وهكذا سنرى الشيفرة المحببة للجميع، "شيفرة غير متزامنة " وكتلة "try…catch". معالجة الأخطاء إذا لم نلتقط الأخطاء، فستقع إلى أن تعترضها أول كتلة try..catch. ستتحول الأخطاء التي لا نعترضها إلى حدث "عملية رفض وعد غير معالجة" ضمن الكائن window، لكن يمكن التعامل مع هذا الخطأ بالشكل التالي: window.addEventListener('unhandledrejection', event => { let request = event.target; // IndexedDB كائن طلب أصلي للقاعدة let error = event.reason; //request.error خطأ غير مُعتَرض للكائن ...report about the error... }); إجرائية مترابطة غير فعالة تكتمل الإجرائية المترابطة -كما رأينا سابقًا- عندما ينتهي المتصفح من تنفيذ شيفرتها ومهامها المتناهية الصغر، فلو وضعنا مهمةً مستقلةً مثل fetch وسط إجرائية مترابطة، فلن تنتظر عندها الإجرائية انتهاء هذه المهمة المستقلة، بل ستكتمل الإجرائية ببساطة وسيخفق الطلب التالي. يبقى الأمر نفسه بالنسبة إلى مُغلَّف الوعود والصيغة async/await، إليك مثالًا عن العملية fetch داخل إجرائية مترابطة: let transaction = db.transaction("inventory", "readwrite"); let inventory = transaction.objectStore("inventory"); await inventory.add({ id: 'js', price: 10, created: new Date() }); await fetch(...); // (*) await inventory.add({ id: 'js', price: 10, created: new Date() }); // Error سيخفق الأمر inventory.add بعد العملية fetch في السطر "(*)" وسيقع الخطأ “inactive transaction” أي "إجرائية مترابطة غير فعالة"، لأنّ الإجرائية المترابطة قد اكتملت بالفعل وأغلقت، وسنلتف على هذه المشكلة كما فعلنا سابقًا، فإما أن ننشئ إجرائيةً مرتبطةً جديدةً، أو أن نفصل الأمور عن بعضها. حضّر البيانات ثم أحضر كل ما يلزم عبر fetch أولًا. احفظ البيانات داخل قاعدة البيانات. الحصول على كائنات أصيلة يُنفِّذ المُغلَّف طلب IndexedDB أصلي داخليًا، ويضيف معالجي الحدثين onerror/onsuccess إليه، ثم يعيد وعدًا يُرفَض أو يُنفَّذ مع نتيجة الطلب. يعمل هذا الأمر جيّدًا في معظم الأوقات، وستجد مثالًا في مستودع GitHub الخاص بمُغلًّف الحدث idb. وفي حالات قليلة نادرة، وعندما نحتاج إلى الكائن request الأصلي، يمكن الوصول إليه باستخدام الخاصية promise.request العائدة للوعد. let promise = books.add(book); // الحصول على الوعد دون انتظار النتيجة let request = promise.request; // كائن الطلب الأصلي let transaction = request.transaction; // كائن الإجرائية المترابطة الأصلي // ...do some native IndexedDB voodoo... let result = await promise; // إن كنا بحاجة لذلك الخلاصة يمكن تشبيه قاعدة البيانات بالكائن "localStorage" لكنها مدعَّمة، وهي قاعدة بيانات (مفتاح-قيمة) لها إمكانيات كبيرة كافية للتطبيقات التي تعمل دون اتصال، كما أنها سهلة الاستخدام. تُمثِّل التوصيفات الخاصة بقاعدة البيانات أفضل دليل لاستخدامها، ونسختها الحالية هي 2.0، كما ستجد بعض التوابع من النسخة 3.0 التي لن تختلف كثيرًا، وهي مدعومة جزئيًا. يمكن تلخيص طريقة استخدامها الأساسية بالشكل التالي: أولًا، الحصول على مُغلِّف وعود مثل idb. ثانيًا، فتح قاعدة البيانات. idb.openDb(name, version, onupgradeneeded) إنشاء مخزن للكائن وفهارس ضمن معالج الحدث onupgradeneeded، أو تنفيذ تحديث للنسخة عند الحاجة. ثالثًا، لتنفيذ الطلبات عليك باتباع الآتي: أنشئ إجرائيةً مترابطةً ('db.transaction('books، مع إمكانية القراءة والكتابة عند الحاجة. احصل على مخزن كائن بالشكل التالي ('transaction.objectStore('books. رابعًا، يمكنك البحث بالمفتاح أو استدعاء التوابع المتعلقة بكائن المخزن مباشرةً. أنشئ فهرسًا للبحث باستخدام حقل آخر من حقول الكائن. خامسًا، إذا لم تتسع الذاكرة للبيانات، فاستخدم مؤشرًا cursor. إليك هذا المثال النموذجي: <!doctype html> <script src="https://cdn.jsdelivr.net/npm/idb@3.0.2/build/idb.min.js"></script> <button onclick="addBook()">Add a book</button> <button onclick="clearBooks()">Clear books</button> <p>Books list:</p> <ul id="listElem"></ul> <script> let db; init(); async function init() { db = await idb.openDb('booksDb', 1, db => { db.createObjectStore('books', {keyPath: 'name'}); }); list(); } async function list() { let tx = db.transaction('books'); let bookStore = tx.objectStore('books'); let books = await bookStore.getAll(); if (books.length) { listElem.innerHTML = books.map(book => `<li> name: ${book.name}, price: ${book.price} </li>`).join(''); } else { listElem.innerHTML = '<li>No books yet. Please add books.</li>' } } async function clearBooks() { let tx = db.transaction('books', 'readwrite'); await tx.objectStore('books').clear(); await list(); } async function addBook() { let name = prompt("Book name?"); let price = +prompt("Book price?"); let tx = db.transaction('books', 'readwrite'); try { await tx.objectStore('books').add({name, price}); await list(); } catch(err) { if (err.name == 'ConstraintError') { alert("Such book exists already"); await addBook(); } else { throw err; } } } window.addEventListener('unhandledrejection', event => { alert("Error: " + event.reason.message); }); </script> وستكون النتيجة كالتالي: ترجمة -وبتصرف- للفصل indexeddb من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: ملفات تعريف الارتباط وضبطها في JavaScript تعرّف على IndexedDB مفهوم Service Worker وتأثيره في أداء وبنية مواقع وتطبيقات الويب
  14. يسمح هذان الكائنان بتخزين الأزواج "مفتاح/قيمة" في المتصفح، لكن الميزة الهامة لهما هي بقاء البيانات المخزنة في الكائن sessionStorage بعد تحديث الصفحة وبقاء المعلومات المخزنة في localStorage بعد إعادة تشغيل المتصفح، لكن السؤال الذي يلفت النظر هو: ما دام لدينا ملفات تعريف الارتباط cookies، فلماذا سنستخدم كائنات إضافيةً؟ والجواب: لا يُرسَل كائنا تخزين بيانات الويب هذان إلى الخادم مع كل طلب، وذلك خلافًا لملفات تعريف الارتباط، وبالتالي سنتمكن من تخزين بيانات أكثر، حيث تتيح أغلب المتصفحات حوالي 2 ميغابايت من البيانات -وأكثر-، ولها إعدادات لتهيئة حجم التخزين. لا يمكن للخادم التحكم بهذين الكائنين عبر ترويسات HTTP، وسيُنجز كل شيء باستخدام JavaScript، خلافًا لملفات تعريف الارتباط. ترتبط ذاكرة التخزين بالأصل الذي ولّدها (نطاق/بروتوكول/منفذ)، مما يعني أن البروتوكولات أو النطاقات الفرعية المختلفة ستدل على كائنات تخزين مختلفة، ولا يمكن أن تصل إلى بيانات بعضها البعض. لكائني التخزين التوابع والخصائص نفسها، وهي: (setItem(key, value: يخزّن الأزواج "مفتاح/قيمة". (getItem(key: يحصل على القيمة عن طريق المفتاح. (removeItem(key: يزيل المفتاح مع قيمته. ()clear: يحذف كل شيء. (key(index: يعيد مفتاحًا ذا موقع محدد. length: يعطي عدد العناصر المخزّنة. تشبه هذه التوابع ما يقوم به الترابط Map، لكنه يسمح أيضًا بالوصول إلى المفاتيح من خلال مواقعها (key(index. مثال نموذجي عن الكائن localStorage ميزات هذا الكائن الرئيسية هي: مشترك بين كل النوافذ التي تشترك بالأصل ذاته. ليس للبيانات فترة صلاحية، إذ تبقى بعد إعادة تشغيل المتصفح أو نظام التشغيل. فلو شغّلنا الشيفرة التالية مثلًا: localStorage.setItem('test', 1); إذا أغلقنا المتصفح ثم أعدنا تشغيله، أو فتحنا الصفحة نفسها في أكثر من نافذة، فسنحصل على القيمة التي خزناها بالشكل التالي: alert( localStorage.getItem('test') ); // 1 علينا فقط أن نكون ضمن صفحات تنتمي إلى الأصل ذاته (نطاق/منفذ/بروتوكول)، على الرغم من إمكانية اختلاف المسار، لأن الكائن localStorage مشترك بين كل النوافذ التي تنتمي إلى الأصل ذاته. الوصول بأسلوب الكائنات يمكن استخدام أسلوب الكائن البسيط للحصول على المفاتيح أو تغيير قيمها بالشكل التالي: // ضبط المفتاح localStorage.test = 2; // الحصول على قيمة المفتاح alert( localStorage.test ); // 2 // إزالة المفتاح delete localStorage.test; يُسمح بهذه الطريقة -التي ستعمل غالبًا- لأسباب تاريخية، لكن لا يُفضّل استعمالها للأسباب التالية: لو ولّد المستخدم المفتاح، فقد يكون أي شيء مثل length أو toString، أي قد يتشابه مع توابع محجوزة تستخدَم مع localStorage، في هذه الحالة ستعمل التوابع getItem/setItem، لكن سيخفق الوصول بأسلوب الكائنات . let key = 'length'; localStorage[key] = 5; // Error, can't assign length الحدث storage الذي يقع عند تعديل البيانات، وليس عند تطبيق أسلوب الكائنات، وسنرى ذلك لاحقًا في هذا المقال. التنقل بين المفاتيح ضمن حلقات تؤمّن التوابع السابقة وظائف الحصول على قيم المفاتيح وضبطها وحذفها، لكن كيف سنخزّن جميع القيم أو المفاتيح؟ لسوء الحظ لا تقبل كائنات تخزين البيانات التكرار، لكن إحدى الطرق المتبعة هي التنقل في حلقة كما لو أننا نتعامل مع مصفوفة: for(let i=0; i<localStorage.length; i++) { let key = localStorage.key(i); alert(`${key}: ${localStorage.getItem(key)}`); } يمكن استخدام الحلقة for key in localStorage كما نفعل مع الكائنات النظامية، حيث تتكرر تعليمات الحلقة وفقًا للمفاتيح المخزّنة، لكنها ستعطي حقولًا مدمجةً غير مطلوبة: // محاولة فاشلة for(let key in localStorage) { alert(key); // وغيرها من الحقول المدمجة getItem, setItem ستظهر } فإمّا أن نرشح الحقول التي ستُعرض بالتحقق من الخاصية hasOwnProperty: for(let key in localStorage) { if (!localStorage.hasOwnProperty(key)) { continue; // "setItem", "getItem" تتجاهل مفاتيح مثل } alert(`${key}: ${localStorage.getItem(key)}`); } أو نحصل على المفاتيح الخاصة بالكائن باستخدام الأمر Object.keys، ثم نطبق الحلقة عليها: let keys = Object.keys(localStorage); for(let key of keys) { alert(`${key}: ${localStorage.getItem(key)}`); } ستنجح الطريقة الأخيرة لأنّ التابع Object.keys سيعيد المفاتيح التي ترتبط بالكائن فقط، ويتجاهل النموذج الأولي prototype. قيم نصية فقط يجب الانتباه إلى أنّ المفاتيح وقيمها كائنات نصية، وستحوّل أي أنواع أخرى - مثل الأرقام- إلى قيم نصية تلقائيًا: sessionStorage.user = {name: "John"}; alert(sessionStorage.user); // [object Object] يمكن استخدام JSON لتخزين الكائنات أيضًا: sessionStorage.user = JSON.stringify({name: "John"}); // لاحقًا let user = JSON.parse( sessionStorage.user ); alert( user.name ); // John كما يمكن تحويل كائن التخزين بالكامل إلى نص، لأغراض التنقيح مثلًا: // لتبدو النتيجة أفضل JSON.stringify يمكن إضافة خيارات تنسيق إلى alert( JSON.stringify(localStorage, null, 2) ); الكائن sessionStorage يُستخدم في حالات أقل من الكائن localStorage، وله نفس التوابع والخصائص، لكنها أكثر محدوديةً. يتواجد الكائن فقط ضمن النافذة الحالية المفتوحة ضمن المتصفح. سيكون لنافذة أخرى مفتوحة ضمن المتصفح كائن آخر خاص بها. تتشارك النوافذ الضمنية الموجودة في نافذة نفس الكائن، بفرض أنها مشتركة بالأصل. تبقى البيانات المخزنة بعد تحديث الصفحة، لكنها تُحذف عند إغلاق النافذة وإعادة فتحها. شغّل هذه الشيفرة لترى آلية عمل الكائن: sessionStorage.setItem('test', 1); ثم حدّث الصفحة. عندها ستلاحظ أن البيانات مازالت موجودة: alert( sessionStorage.getItem('test') ); // after refresh: 1 لكن لو فتحت الصفحة نفسها في نافذة أخرى، وحاولت تنفيذ الشيفرة السابقة مجددًا، فستعيد القيمة "null" أي أنها لم تجد شيئًا، لأنّ الكائن sessionStorage لا يتعلق فقط بالأصل المشترك بل بالنافذة المفتوحة في المتصفح، لذا يندر استخدامه. أحداث التخزين عندما تُحدّث البيانات ضمن كائني التخزين فستقع أحداث التخزين التالية: key: المفتاح الذي تغيّر، وسيعيد null إذا استدعي التابع ()clear. oldValue: القيمة القديمة، وستكون null إذا أضيف المفتاح حديثًا. newValue: القيمة الجديدة، وستكون null إذا حُذف المفتاح. url: عنوان الصفحة التي حدث فيها التغيير. storageArea أو أحد الكائنين localStorage أو sessionStorage حيث حدث التغيير. أمّا الأمر الهام فهو أنّ هذه الأحداث ستقع في كل الكائنات window التي يمكن فيها الوصول إلى كائن تخزين البيانات، عدا تلك التي سببت وقوع الحدث. تخيل وجود نافذتين في المتصفح تعرضان الصفحة نفسها، عندئذ ستتشارك النافذتان الكائن localStorage نفسه، وقد يكون عرض الصفحة في نافذتين مختلفتين مناسبًا لاختبار الشيفرة التي سنعرضها تاليًا، إذا استمعت كلتا النافذتين إلى الحدث window.onstorage، فستتفاعلان مع التحديثات التي تجري في كلٍّ منهما. // يقع عند تحديث كائن التخزين من قبل صفحة أخرى window.onstorage = event => { // same as window.addEventListener('storage', event => { if (event.key != 'now') return; alert(event.key + ':' + event.newValue + " at " + event.url); }; localStorage.setItem('now', Date.now()); لاحظ أنّ الحدث سيتضمن أيضًا event.url، وهو عنوان الصفحة التي حصل فيها التغيير، كما يتضمن الحدث كائن التخزين (سيبقى الحدث نفسه للكائنين sessionStorage وlocalStorage)، لذا سيشير الحدث event.storageArea إلى الكائن الذي جرى تعديله منهما، وبما أننا نريد أن نعيد ضبط قيمة ما استجابةً للتغيير، فسيسمح ذلك للنوافذ ذات الأصل المشترك بتبادل الرسائل. تدعم المتصفحات الحديثة الواجهة البرمجية لقناة البث Broadcast channel API، وهي واجهة خاصة بتبادل الرسائل بين النوافذ التي لها أصل مشترك. لهذه الواجهة ميزات متكاملة أكثر لكنها أقل دعمًا، ومع ذلك فستجد الكثير من المكتبات التي توائم هذه الواجهة مع المتصفحات بالاستفادة من الكائن localStorage مما يجعلها متاحةً في أي مكان. خلاصة يسمح الكائنان localStorage وsessionStorage بتخزين الأزواج (مفتاح/قيمة) في المتصفح. المفتاح key والقيمة value من النوع النصي. الحد الأقصى للتخزين بحدود 5 ميغابايت وذلك تبعًا للمتصفح. ليس لها فترة صلاحية. ترتبط البيانات بأصل الصفحة (نطاق/ منفذ/بروتوكول). table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } localStorage sessionStorage مشترك بين النوافذ التي لها نفس الأصل تُرى ضمن نافذة واحدة في المتصفح بما فيها النوافذ الضمنية التي لها نفس الأصل تبقى بعد إعادة تشغيل المتصفح تبقى بعد تحديث الصفحة لكنها تحذف عند إغلاق النافذة الواجهة البرمجية: (setItem(key, value: يخزّن أزواج (مفتاح/قيمة). (getItem(key: يحصل على القيمة عن طريق المفتاح. (removeItem(key: يزيل المفتاح مع قيمته. ()clear: يحذف كل شيء. (key(index: يعيد مفتاحًا ذا موقع محدد. length: يعطي عدد العناصر المخزّنة. Object.keys: للحصول على جميع المفاتيح. يمكن الوصول إلى المفاتيح عبر خصائص الكائن، لكن لن يقع الحدث storage في هذه الحالة. أحداث التخزين: تقع نتيجةً للاستدعاءات التالية setItem أو removeItem أو clear. تحتوي على كل البيانات المتعلقة بالعملية key/oldValue/newValue، وبالصفحة url، وبكائن التخزين storageArea. تقع في جميع النوافذ التي يمكنها الوصول إلى كائن التخزين، عدا تلك التي ولّدته، ضمن نافذة واحدة بالنسبة للكائن sessionStorage، ولكل النوافذ بالنسبة للكائن localStorage. مهمات لإنجازها الحفظ التلقائي لحقل من حقول نموذج أنشئ حقلًا نصيًا textarea يحفظ تلقائيًا القيمة التي يحتويها بعد كل تغيير فيها، وبالتالي عندما يُغلق المستخدم الصفحة عن طريق الخطأ، ثم يفتحها مجددًا، فسيجد النص الذي لم يكمله بعد في مكانه. افتح المثال في بيئة تجريبية. وإن أردت الحل، فهو في هذه البيئة التجريبية. ترجمة -وبتصرف- للفصل localStorage, sessionStorage من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: العمل مع قاعدة البيانات IndexedDB في جافاسكربت ملفات تعريف الارتباط وضبطها في JavaScript المدخل الشامل لتعلم علوم الحاسوب
  15. سنشرح في هذا المقال آليات الاتصال الدائم المستمر مع الخادم وهو الاتصال الذي يُنشَأ فيه اتصال مستمر بين الخادم والعميل (غالبًا متصفح الويب) يجري فيه تبادل الطلبيات والردود آنيًا، وتنحصر آلية تنفيذ الاتصال الدائم تلك في ثلاثة أنواع: آلية الاستطلاع المفتوح Long polling وآلية مقابس الويب عبر البروتوكول WebSocket وآلية الأحداث المرسلة EventSource، سنناقش كل واحدة بالتفصيل على حدة. آلية الاستطلاع آلية الجس والاستطلاع polling هي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات خاصة مثل WebSocket، أو أي أحداث من طرف الخادم، كما أنها سهلة الإنجاز وكافية في الكثير من الحالات. الاستطلاع الدوري Regular polling الطريقة الأبسط للحصول على معلومات من الخادم هي الاستطلاع الدوري، بإرسال طلبات إلى الخادم لسؤاله عن أي معلومات جديدة على فترات زمنية منتظمة (مثلًا: كل 10 ثوان)، وعند الاستجابة ينتبه الخادم إلى اتصال العميل به، ثم يرسل حزمةً من الرسائل التي تلقاها حتى اللحظة، سيعمل الأمر كما هو متوقع، لكن مع بعض الجوانب السلبية، منها: تمرَّر الرسائل بتأخير زمني قد يصل إلى 10 ثوان (الزمن الفاصل بين الطلبات). سيُغرَق الخادم بالطلبات كل 10 ثوان حتى لو غادر المستخدم الموقع أو استغرق في النوم، وهذا حمل كبير عليه من منظور الأداء. لهذا قد تكون هذه المقاربة جيدةً عندما تكون الخدمة بسيطةً، لكنها وبشكل عام تحتاج إلى التحسين. الاستطلاع المفتوح Long polling وهي طريقة أفضل للاستطلاع أو الجس من حالة الخادم، وتتميز بسهولة تنفيذها، وقدرتها على تسليم الرسائل دون تأخير. حيث: يُرسَل الطلب إلى الخادم. لا يغلق الخادم الاتصال حتى تكون لديه رسالة لتسليمها. عندما تظهر الرسالة، يستجيب الخادم للطلب بهذه الرسالة. يرسل المتصفح طلبًا جديدًا مباشرة. إن أساس هذه الطريقة هو إرسال المتصفح للطلب وإبقاء الاتصال مع الخادم قيد الانتظار، ولا يهيّئ المتصفح الاتصال من جديد حتى يتسلم الاستجابة، فإن فُقد الاتصال نتيجة خطأ ما، فسيرسل المتصفح طلبًا آخر مباشرةً. تمثل الشيفرة التالية الدالة subscribe التي ترسل الطلبات المفتوحة من جهة العميل: async function subscribe() { let response = await fetch("/subscribe"); if (response.status == 502) { // 502 خطأ تجاوز الوقت المسموح للاتصال // يحدث عند بقاء الاتصال مفتوحًا لفترة طويلة جدًا // ويغلقه الخادم لذا يجب إعادة الاتصال await subscribe(); } else if (response.status != 200) { // إظهار الخطأ showMessage(response.statusText); // إعادة الاتصال خلال ثانية await new Promise(resolve => setTimeout(resolve, 1000)); await subscribe(); } else { // الحصول على الرسالة وإظهارها let message = await response.text(); showMessage(message); // استدعاء التابع من جديد await subscribe(); } } subscribe(); يرسل التابع subscribe طلب إحضار، وينتظر الاستجابة ليعالجها عندما تصل، ثم يستدعي نفسه مجددًا. مثال اختباري لنظام محادثة بسيط إليك تطبيق محادثة اختباريًا يمكنك تنزيله وتشغيله محليًا إذا كنت على دراية باستخدام Node.JS وتثبيت الوحدات البرمجية اللازمة: ملف شيفرة العميل "browser.js": // POSTإرسال رسالة، طلب function PublishForm(form, url) { function sendMessage(message) { fetch(url, { method: 'POST', body: message }); } form.onsubmit = function() { let message = form.message.value; if (message) { form.message.value = ''; sendMessage(message); } return false; }; } // تلقي الرسائل باستخدام الاستطلاع المفتوح function SubscribePane(elem, url) { function showMessage(message) { let messageElem = document.createElement('div'); messageElem.append(message); elem.append(messageElem); } async function subscribe() { let response = await fetch(url); if (response.status == 502) { // تجاوز الوقت المسموح // يحدث عندما يبقى الاتصال مفتوحًا لفترة طويلة // لا بد من إعادة الاتصال await subscribe(); } else if (response.status != 200) { // Show Error showMessage(response.statusText); // إعادة الاتصال خلال ثانية await new Promise(resolve => setTimeout(resolve, 1000)); await subscribe(); } else { // الحصول على الرسالة let message = await response.text(); showMessage(message); await subscribe(); } } subscribe(); } ملف شيفرة الواجهة الخلفية "server.js": let http = require('http'); let url = require('url'); let querystring = require('querystring'); let static = require('node-static'); let fileServer = new static.Server('.'); let subscribers = Object.create(null); function onSubscribe(req, res) { let id = Math.random(); res.setHeader('Content-Type', 'text/plain;charset=utf-8'); res.setHeader("Cache-Control", "no-cache, must-revalidate"); subscribers[id] = res; req.on('close', function() { delete subscribers[id]; }); } function publish(message) { for (let id in subscribers) { let res = subscribers[id]; res.end(message); } subscribers = Object.create(null); } function accept(req, res) { let urlParsed = url.parse(req.url, true); // مستخدم جديد يريد النتائج if (urlParsed.pathname == '/subscribe') { onSubscribe(req, res); return; } // إرسال رسالة if (urlParsed.pathname == '/publish' && req.method == 'POST') { // POSTقبول req.setEncoding('utf8'); let message = ''; req.on('data', function(chunk) { message += chunk; }).on('end', function() { publish(message); // النشر للجميع res.end("ok"); }); return; } // البقية ثابتة fileServer.serve(req, res); } function close() { for (let id in subscribers) { let res = subscribers[id]; res.end(); } } // ----------------------------------- if (!module.parent) { http.createServer(accept).listen(8080); console.log('Server running on port 8080'); } else { exports.accept = accept; if (process.send) { process.on('message', (msg) => { if (msg === 'shutdown') { close(); } }); } process.on('SIGINT', close); } الملف الأساسي "index.html": <!DOCTYPE html> <script src="browser.js"></script> All visitors of this page will see messages of each other. <form name="publish"> <input type="text" name="message" /> <input type="submit" value="Send" /> </form> <div id="subscribe"> </div> <script> new PublishForm(document.forms.publish, 'publish'); // random url parameter to avoid any caching issues new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random()); </script> وستظهر النتيجة بالشكل التالي: مجال الاستخدام تعمل طريقة الاستطلاع المفتوح بصورة ممتازة عندما تكون الرسائل نادرةً، لكن عندما تتكرر الرسائل بكثرة، فسيكون منحني (طلب-استجابة) المرسوم في الأعلى على هيئة أسنان المنشار، ولأن لكل رسالة طلبًا مستقلًا له ترويسات وآلية استيثاق خاصة، فمن الأفضل استخدام طرق أخرى مثل: Websocket أوServer Sent Events، التي سنشرحها في الفقرات التالية. استخدام البروتوكول WebSocket يزوّدنا هذا البروتوكول الموصوف في المعيار RFC 6455 بطريقة لتبادل البيانات بين الخادم والمتصفح عبر اتصال ثابت، حيث تمرَّر البيانات إلى كلا الطرفين على شكل حزم دون قطع الاتصال أو استخدام طلبات HTTP إضافية، وتظهر الفائدة الكبيرة لاستخدامه عند وجود خدمات تتطلب التبادل المستمر للبيانات، مثل ألعاب الشبكة وأنظمة التجارة العاملة في الزمن الحقيقي وغيرها. مثال بسيط سنحتاج إلى كائن مقبس اتصال جديد new WebSocket لفتح اتصال websocket باستخدام بروتوكول نقل خاص هو ws في العنوان: let socket = new WebSocket("ws://javascript.info"); كما يمكن استخدام البروتوكول المشفّر //:wss، فهو مشابه للبروتوكول HTTPS لكن مع websockets. ينبغي الاستماع إلى الأحداث التي تطرأ على مقبس الاتصال Socket بعد تأسيسه مباشرةً، وهي أربعة أحداث: open: عند تأسيس الاتصال. message: عند استقبال البيانات. error: عند وقوع خطأ يتعلق بالبروتوكول websocket. close: عند إغلاق الاتصال. ولإرسال أي بيانات يكفي استخدام الصيغة (socket.send(data، وإليك مثالًا: let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello"); socket.onopen = function(e) { alert("[open] Connection established"); alert("Sending to server"); socket.send("My name is John"); }; socket.onmessage = function(event) { alert(`[message] Data received from server: ${event.data}`); }; socket.onclose = function(event) { if (event.wasClean) { alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); } else { // كأن تلغى العملية أو فشل في الشبكة // 1006 قيمته في هذه الحالة عادة event.code alert('[close] Connection died'); } }; socket.onerror = function(error) { alert(`[error] ${error.message}`); }; لقد كتبنا شيفرة خادم تجريبي باستخدام Node.JS في الملف server.js لتشغيل الشيفرة في المثال السابق، وسيرد الخادم بالرسالة "Hello from server, John"، ثم ينتظر 5 ثوان ويقطع الاتصال. سترى إذًا تسلسل الأحداث open → message → close، والأمر بهذه البساطة فعليًا، لأنك تستخدم الآن البروتوكول WebSocket، لنتحدث الآن في أمور أكثر عمقًا. فتح اتصال مقبس ويب WebSocket يُنشئ الأمر (new WebSocket(url كائنًا جديدًا ويبدأ عملية الاتصال مباشرةً، سيسأل المتصفح الخادم خلال تأسيس الاتصال -عبر الترويسات- إذا كان يدعم البروتوكول Websocket، فإن كان الجواب نعم فسيستمر العمل به، وهو يختلف عن HTTP كليًا. وإليك مثالًا عن ترويسات المتصفح للطلب الذي يرسله الأمر (new WebSocket("wss://javascript.info/chat. GET /chat Host: javascript.info Origin: https://javascript.info Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13 Origin: أصل الصفحة العميلة (https://javascript.info مثلًا)، فالكائنات WebSocket هي كائنات من أصول مختلطة بطبيعتها، حيث لا توجد أي قيود لاستخدامها، وطالما أنّ الخوادم القديمة لا تدعم هذا البروتوكول، فلن نواجه مشكلة توافق. تظهر أهمية الترويسة Origin في أنها تسمح للخادم بالاختيار إن كان سيتواصل مع الصفحة العميلة وفق هذا البروتوكول أم لا. Connection: Upgrade: يشير إلى رغبة العميل في تغيير البروتوكول. Upgrade: websocket: تدل أنّ البروتوكول المطلوب هو websocket. Sec-WebSocket-Key: مفتاح أمان عشوائي يولده المتصفح. Sec-WebSocket-Version: نسخة بروتوكول websocket، وهي حاليًا النسخة 13. ينبغي على الخادم أن يعيد الرمز 101 عندما يوافق على التحوّل إلى البروتوكول WebSocket: 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g= ستكون الترويسة Sec-WebSocket-Accept هي نفسها Sec-WebSocket-Key مسجلةً باستخدام خوارزمية خاصة، وسيستخدمها المتصفح للتأكد من توافق الاستجابة مع الطلب. بعد ذلك ستُنقل البيانات باستخدام بروتوكول WebSocket، وسنطلع على بنيتها (إطاراتها) قريبًا. الموسعات والبروتوكولات الفرعية هنالك أيضًا الترويستان Sec-WebSocket-Extensions التي تصف الموسّعات، وSec-WebSocket-Protocol التي تصف البروتوكولات الفرعية. فمثلًا: Sec-WebSocket-Extensions: deflate-frame: يعني أن المتصفح سيدعم ضغط البيانات، فالموسّع هو شيء يتعلق بنقل البيانات ووظيفة توسّع عمل البروتوكول، يرسل المتصفح الترويسة Sec-WebSocket-Extensions تلقائيًا مزودةً بقائمة الموسّعات المدعومة كلها. Sec-WebSocket-Protocol: soap, wamp: يعني أننا سننقل البيانات باستخدام أحد البروتوكولين الفرعيين SOAP أو WAMP (وهو اختصار للعبارة " WebSocket Application Messaging Protocol" أي "بروتوكول نقل الرسائل لتطبيق websocket"). تُسجل البروتوكولات الفرعية في IANA catalogue، أي أن هذه الترويسة الاختيارية تحدد صيغ البيانات التي سنستخدمها، وتُضبَط باستخدام المعامل الثاني للدالة البانية new WebSocket، فلو أردنا استخدام SOAP أو WAMP مثلًا، فسنضعهما في مصفوفة بالشكل التالي: let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]); سيستجيب الخادم بقائمة من البروتوكولات والموسّعات التي يوافق على استخدامها، وإليك مثالًا: GET /chat Host: javascript.info Upgrade: websocket Connection: Upgrade Origin: https://javascript.info Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13 Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap, wamp وستكون الاستجابة: 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g= Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap أي أن الخادم سيدعم الموسّع "deflate-frame" والبروتوكول الفرعي SOAP فقط. نقل البيانات يتكون الاتصال من إطارات "frames"، وهي أجزاء من البيانات يمكن إرسالها من كلا الطرفين وقد تكون من الأنواع التالية: "text": يحتوي بيانات نصيةً ترسلها الأطراف إلى بعضها. "binary data": يحتوي بيانات ثنائيةً تُرسلها الأطراف إلى بعضها. "ping/pong": تستخدَم للتحقق من الاتصال، ويرسلها الخادم إلى المتصفح الذي يرد عليها تلقائيًا. ستجد أيضًا "connection close" وبعض الإطارات الأخرى. ونعمل في بيئة المتصفح مع الإطارات النصية والثنائية. يمكن للتابع ()send. إرسال بيانات نصية أو ثنائية.، ويسمح استدعاء التابع (socket.send(body بأن تكون قيمة جسم الطلبbody بالتنسيق النصي أو الثنائي، بما في ذلك الكائن Blob وArrayBuffer وغيرهما، ولا حاجة لإجراء ضبط خاص بل تُرسَل البيانات كما هي. تأتي البيانات النصية على شكل "string" عندما نستقبل البيانات، ومع ذلك يمكننا الاختيار بين BlobوArrayBuffer للبيانات الثنائية.** وكذا ضبط ذلك باستخدام الخاصية socket.binaryType، التي تأخذ القيمة "blob" افتراضيًا، لذلك تأتي البيانات الثنائية ضمن الكائن Blob، الذي يمثل كائن بيانات ثنائية عالي المستوى ويتكامل مباشرةً مع المعرّفين <a>و<img> وغيرهما، ويمكن الانتقال إلى استخدام الكائن ArrayBuffer عند معالجة البيانات الثنائية والعمل مع البايتات بشكل منفصل. socket.binaryType = "arraybuffer"; socket.onmessage = (event) => { //أما أن يكون نصًا أو بيانات ثنائيةً event.data }; محدودية معدل النقل Rate limiting لنتخيل أن تطبيقنا قادر على توليد كمية كبيرة من البيانات، لكن المستخدم يعاني من اتصال بطيء بالانترنت. يمكن استدعاء التابع (socket.send(data مرات عدةً، لكن البيانات ستُخزّن مؤقتًا في الذاكرة وستُرسَل بالسرعة التي تسمح بها الشبكة فقط، عندها يمكن استخدام الخاصية socket.bufferedAmount التي تُخزّن حجم البيانات الموجودة في المخزن المؤقت في هذه اللحظة وتنتظر الظروف المناسبة لنقلها، ويمكن فحص الحجم المتبقي من البيانات للتأكد من أن المقبس Socket لا يزال متاحًا لمتابعة نقل البيانات. // افحص المقبس كل 100 ثانية وحاول إرسال البيانات // عندما يكون حجم البيانات المخزّنة التي لم تصل صفرًا setInterval(() => { if (socket.bufferedAmount == 0) { socket.send(moreData()); } }, 100); إغلاق الاتصال عندما يحاول أحد الأطراف إغلاق الاتصال (للمخدم والمتصفح الحق نفسه في ذلك)، يرسَل عادةً إطار إغلاق الاتصال "connection close" الذي يحتوي قيمةً عدديةً وسببًا نصيًا للإغلاق. إليك الصيغة اللازمة: socket.close([code], [reason]); code: رمز خاص لإغلاق اتصال WebSocket، وهو اختياري. reason: نص يصف سبب الإغلاق، وهو اختياري. يستقبل معالج الحدث close في الطرف الآخر الرمز والسبب: // الطرف الذي سيغلق الاتصال: socket.close(1000, "Work complete"); // الطرف الآخر socket.onclose = event => { // event.code === 1000 // event.reason === "Work complete" // event.wasClean === true (clean close) }; إليك القيم الأكثر شيوعًا للرموز: 1000: وهي القيمة الافتراضية، وتستخدم للإغلاق العادي (تستخدم إن لم يكن هناك رمز code). 1006: لا يمكن ضبط هذا الرمز يدويًا ويشير إلى فقد الاتصال. 1001: عند مغادرة أحد الأطراف، مثل: إغلاق الخادم أو مغادرة المتصفح للصفحة. 1009: الرسائل أضخم من أن تُعالج. 1011: خطأ في الخادم. يمكن الاطلاع على القائمة الكاملة ضمن المعيار RFC6455, §7.4.1. إنّ رموز WebSocket مشابهة نوعًا ما لرموز HTTP وتختلف في بعض النواحي، فأي رمز أقل من 1000 سيكون محجوزًا، وسيتولد خطأ عند استخدامه. // في حال فشل الاتصال socket.onclose = event => { // event.code === 1006 // event.reason === "" // event.wasClean === false (no closing frame) }; حالة الاتصال تتيح الخاصية socket.readyState إمكانية معرفة الوضع الحالي للاتصال وفقًا للقيم التي تأخذها: 0 متصل "CONNECTING": أي أن الاتصال يجري، ولم يُؤسَّس بعد. 1 مفتوح "OPEN": التواصل قائم. 2 يُغلق "CLOSING": يجري إغلاق الاتصال. 3 مُغلق "CLOSED": الاتصال مغلق. مثال تطبيق محادثة آنية لنراجع مثال تطبيق المحادثة باستخدام الواجهة البرمجية WebSocket API والوحدة البرمجية Node.js WebSocket module، سنركز على طرف العميل، وسيكون الخادم بسيطًا أيضًا. نحتاج إلى نموذج <form> لإرسال الرسائل ومعرِّف <div> لاستقبالها. <!-- message form --> <form name="publish"> <input type="text" name="message"> <input type="submit" value="Send"> </form> <!-- div with messages --> <div id="messages"></div> كما سنستخدم في جافاسكربت: فتح الاتصال. إرسال رسالة عن طريق النموذج (socket.send(message. وضع الرسالة المستقبلة ضمن div#messages. وإليك الشيفرة اللازمة: let socket = new WebSocket("wss://javascript.info/article/websocket/chat/ws"); // إرسال رسالة إلى النموذج document.forms.publish.onsubmit = function() { let outgoingMessage = this.message.value; socket.send(outgoingMessage); return false; }; // div#messagesإظهار الرسالة بعد وصولها في socket.onmessage = function(event) { let message = event.data; let messageElem = document.createElement('div'); messageElem.textContent = message; document.getElementById('messages').prepend(messageElem); } لن نتعمق بشيفرة الواجهة الخلفية، فهي خارج نطاق مقالتنا، لكننا سنستخدم هنا Node.js، ومع ذلك فالأمر ليس إلزاميًا، إذ أن للعديد من المنصات طرقها للتعامل مع WebSocket. تمثل الخطوات التالية خوارزمية عمل الواجهة الخلفية: إنشاء مجموعة من مقابس الاتصال ()clients = new Set. إضافة كل مقبس مقبول إلى المجموعة clients.add(socket)، وتهيئة مستمع message للحدث للحصول على الرسائل المتعلقة بالمقبس. عندما استقبال رسالة كرر عملية إرسالها إلى كل عميل. احذف مقابس العملاء عند قطع الاتصال (clients.delete(socket. const ws = new require('ws'); const wss = new ws.Server({noServer: true}); const clients = new Set(); http.createServer((req, res) => { // websocket سنعالج فقط اتصالات // websocket في مشروعنا الحقيقي ستوجد هنا شيفرة تعالج الطلبات بطرق أخرى غير wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect); }); function onSocketConnect(ws) { clients.add(ws); ws.on('message', function(message) { message = message.slice(0, 50); // 50سيكون الحجم الأكبر للرسالة هو for(let client of clients) { client.send(message); } }); ws.on('close', function() { clients.delete(ws); }); } إليك النتيجة: يمكن تنزيل التطبيق وتجربته محليًا على جهازك، ولا تنس تثبيت Node.js، ثم تثبيت WebSocket باستخدام الأمر npm install ws قبل تشغيل التطبيق. الأحداث المرسلة من قبل الخادم تشرح التوصيفات Server-Sent Events صنفًا مدمجًا هو EventSource، حيث يحافظ على الاتصال مع الخادم ويسمح باستقبال أحداث منه، وهو اتصال ثابت مثل WebSocket تمامًا لكن مع بعض الاختلافات بينهما: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } WebSocket EventSource ثنائي الاتجاه: إذ يمكن أن يتبادل الخادم والعميل الرسائل وحيد الاتجاه: فالخادم هو من يرسل الرسائل إلى العميل نصوص وبيانات ثنائية نصوص فقط بروتوكول websocket بروتوكول HTTP النظامي. يمثل EventSource طريقةً أقل قدرةً للاتصال مع الخادم بالموازنة مع WebSocket، إذًا لماذا نستخدمها؟ يعود السبب الرئيسي لاستخدامه في كونه أبسط، إذ لا نحتاج إلى القدرة الكبيرة لبروتوكول WebSocket في بعض التطبيقات، فتلقي بيانات من الخادم مثل رسائل محادثة أو لوائح أسعار أو أي شيء مشابه هو مجال EventSource، كما أنه يدعم إعادة الاتصال، وهو أمر لا بد من إنجازه يدويًا في WebSocket، ويستخدم كذلك بروتوكول HTTP وليس أي بروتوكول جديد. الحصول على الرسائل لا بد من إنشاء كائن جديد من الصنف EventSource للبدء باستقبال الرسائل، وذلك بتنفيذ الأمر (new EventSource(url، حيث سيتصل المتصفح بالعنوان url ويبقي الاتصال مفتوحًا ومنتظرًا الأحداث، وهنا ينبغي أن يستجيب الخادم برمز الحالة 200 وبالترويسة Content-Type: text/event-stream، وسيبقي الخادم بعدها قناة الاتصال مفتوحةً، وسيرسل الرسائل ضمنها بتنسيق خاص بالشكل التالي: data: Message 1 data: Message 2 data: Message 3 data: of two lines تأتي الرسالة بعد الكلمة :data بينما يعَد الفراغ الموجود بعد النقطتين اختياريًا. يفصَل بين الرسائل برمز السطر الجديد مضاعفًا n\n\ يمكننا عند الحاجة إلى إرسال رمز سطر جديد n\ أن نرسل مباشرةً الكلمة :data (كما في الرسالة الثالثة في المثال السابق)، لكن عمليًاP تًرسل الرسائل المركّبة مرمزةً بتنسيق JSON، وترمّز محارف الانتقال إلى سطر جديد بالشكل n\ ضمنها، فلا حاجة عندها للرسائل :data متعددة السطر. فمثلًا: data: {"user":"John","message":"First line\n Second line"} وهكذا يمكن أن نعد أن العبارة data: ستليها رسالة واحدة تمامًا. يولَّد الحدث message لكل من تلك الرسائل بالشكل التالي: let eventSource = new EventSource("/events/subscribe"); eventSource.onmessage = function(event) { console.log("New message", event.data); // سيطبع ثلاث رسائل في المثال السابق }; // or eventSource.addEventListener('message', ...) الطلبات ذات الأصل المختلط يدعم الصنف EventSource الطلبات ذات الأصل المختلط cross-origin requests كما يفعل fetch، إذ يمكننا استخدام أي عنوان URL: let source = new EventSource("https://another-site.com/events"); سيستقبل الخادم البعيد الترويسة Origin وينبغي عليه الإجابة بالترويسة Access-Control-Allow-Origin للمتابعة، ولتمرير الثبوتيات credentials لا بد من ضبط خيار إضافي هو withCredentials بالشكل التالي: let source = new EventSource("https://another-site.com/events", { withCredentials: true }); راجع فصل استخدام Fetch مع الطلبات ذات الأصول المختلطة للاطلاع على تفاصيل أكثر حول ترويسات الأصول المختلطة. إعادة الاتصال بعد إنشاء الكائن EventSource فسيتصل بالخادم مباشرةً ويعيد الاتصال في حال انقطاعه، وبالتالي لا حاجة لإجراء تدابير خاصة بهذا الأمر، وبطبيعة الحال يوجد تأخير زمني صغير لثوان معدودة افتراضيًا بين كل محاولتين لإعادة الاتصال، وهنا يمكن للخادم أن يضبط التأخير اللازم باستخدام الخاصية :retry في الاستجابة (مقدرًا بالميلي ثانية). retry: 15000 data: Hello, I set the reconnection delay to 15 seconds يمكن أن تأتي :retry مصحوبةً ببيانات أخرى أو برسالة وحيدة، وهكذا سيكون على الخادم انتظار الفترة المحددة قبل إعادة الاتصال، وقد ينتظر فترة أطول، فإذا علم المتصفح -عن طريق نظام التشغيل- أن الشبكة غير متصلة حاليًا مثلًا، فسينتظر حتى يعود الاتصال ثم يحاول ثانيةً. إذا أراد الخادم من المتصفح التوقف عن إعادة الاتصال، فعليه أن يرد برمز الحالة 204 (رمز HTTP). إذا أراد المتصفح إغلاق الاتصال، فعليه استدعاء التابع eventSource.close. let eventSource = new EventSource(...); eventSource.close(); إذا احتوت الاستجابة على ترويسة Content-Type خاطئةً فلن يعاد الاتصال، وكذلك إذا اختلف رمز حالة HTTP عن القيم 301 أو 307 أو 200 أو 204، فعندها سيُولد الحدث "error" ولن يعيد المتصفح الاتصال. معرف الرسالة الفريد Message id عندما ينقطع الاتصال جراء خلل في الشبكة، tلن يتأكد كلا الطرفين من كون الرسائل استلمَت أم لا، وحتى يُستأنَف الاتصال بشكل صحيح، فينبغي أن تحمل كل رسالة مُعرِّفًا مميزًا id مثل التالي: data: Message 1 id: 1 data: Message 2 id: 2 data: Message 3 data: of two lines id: 3 عندما يتلقى المتصفح الرسالة مع معرّفها :id فإنه: يضبط قيمة الخاصية eventSource.lastEventId على قيمة المعرِّف. يعيد الترويسة Last-Event-ID أثناء إعادة الاتصال مع قيمة المعرّف id، ليتمكن الخادم من إرسال الرسائل التالية إن وجدت. حالة الاتصال: الخاصية ReadyState يمتلك الكائن EventSource الخاصية readyState التي تحمل إحدى القيم الثلاث التالية: EventSource.CONNECTING = 0; // يتصل أو يعيد الاتصال EventSource.OPEN = 1; // متصل EventSource.CLOSED = 2; // الاتصال مغلق تكون قيمة هذه الخاصية عند إنشاء كائن جديد أو انقطاع الاتصال هي EventSource.CONNECTING دومًا (وهي تعادل 0)، ويمكن الاستعلام عن هذه الخاصية لمعرفة حالة الكائن EventSource. أنواع الأحداث يوّلِّد الكائن EventSource افتراضيًا ثلاثة أحداث: message: عند استلام رسالة، وتكون متاحةً باستخدام event.data. open: عندما يكون الاتصال مفتوحًا. error: عند عدم إمكانية تأسيس الاتصال، كأن يعيد الخادم رمز الحالة 500. فمثلًا: event: join data: Bob data: Hello event: leave data: Bob لمعالجة الأحداث السابقة لابد من استخدام مستمع الحدث addEventListener وليس onmessage: eventSource.addEventListener('join', event => { alert(`Joined ${event.data}`); }); eventSource.addEventListener('message', event => { alert(`Said: ${event.data}`); }); eventSource.addEventListener('leave', event => { alert(`Left ${event.data}`); }); مثال نموذجي كامل يرسل الخادم في هذا المثال الرسائل 1 و2 و3 ثم bye ويقطع الاتصال، بعدها يعيد المتصفح الاتصال تلقائيًا. شيفرة الخادم "server.js": let http = require('http'); let url = require('url'); let querystring = require('querystring'); let static = require('node-static'); let fileServer = new static.Server('.'); function onDigits(req, res) { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' }); let i = 0; let timer = setInterval(write, 1000); write(); function write() { i++; if (i == 4) { res.write('event: bye\ndata: bye-bye\n\n'); clearInterval(timer); res.end(); return; } res.write('data: ' + i + '\n\n'); } } function accept(req, res) { if (req.url == '/digits') { onDigits(req, res); return; } fileServer.serve(req, res); } if (!module.parent) { http.createServer(accept).listen(8080); } else { exports.accept = accept; } شيفرة الملف "index.html": <!DOCTYPE html> <script> let eventSource; function start() { // "Start" عند ضغط الزر if (!window.EventSource) { // متصفح إنترنت إكسبلورر أو أي متصفح قديم alert("The browser doesn't support EventSource."); return; } eventSource = new EventSource('digits'); eventSource.onopen = function(e) { log("Event: open"); }; eventSource.onerror = function(e) { log("Event: error"); if (this.readyState == EventSource.CONNECTING) { log(`Reconnecting (readyState=${this.readyState})...`); } else { log("Error has occurred."); } }; eventSource.addEventListener('bye', function(e) { log("Event: bye, data: " + e.data); }); eventSource.onmessage = function(e) { log("Event: message, data: " + e.data); }; } function stop() { // when "Stop" button pressed eventSource.close(); log("eventSource.close()"); } function log(msg) { logElem.innerHTML += msg + "<br>"; document.documentElement.scrollTop = 99999999; } </script> <button onclick="start()">Start</button> Press the "Start" to begin. <div id="logElem" style="margin: 6px 0"></div> <button onclick="stop()">Stop</button> "Stop" to finish. وستظهر النتيجة بالشكل التالي: خلاصة تعرفنا في هذا الفصل على آليات إنشاء إتصال مستمر مع الخادم، فبدأ أولًا بعرض آلية الاستطلاع والجس وهي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات أو أي أحداث خاصة من طرف الخادم، وهي طريقة سهلة الإنجاز وكافية في الكثير من الحالات. انتقلنا بعدها إلى شرح بروتوكول WebSocket والذي يُقدم طريقةً عصريةً لتأسيس اتصال ثابت بين المتصفح والخادم، له ميزات منها: لا توجد تقييدات للأصول المختلطة. مدعوم جيدًا من قبل المتصفحات. يمكنه إرسال نصوص وبيانات ثنائية واستقبالها. واجهته البرمجية بسيطة. التوابع المستعملة في هذا البروتوكول: socket.send(data)‎ socket.close([ code ], [reason])‎ أما الأحداث: open message error close لا تقدّم WebSocket بذاتها تقنيات لإعادة الاتصال أو الاستيثاق وغيرها من الآليات عالية المستوى، لذلك ستجد مكتبات للعميل والخادم تهتم بهذه النقاط، ويمكنك أيضًا تنفيذ شيفرتها يدويًا بنفسك. ولكي تتكامل WebSocket مع مشروع جاهز، جرت العادة أن يشغل المستخدم خادم WebSocket بالتوازي مع خادم HTTP الرئيسي ليتشاركا بقاعدة بيانات واحدة، حيث تُرسل طلبات WebSocket إلى النطاق الفرعي wss://ws.site.com الذي يقود إلى خادم WebSocket، بينما يقود العنوان https://site.com إلى خادم HTTP الرئيسي، كما توجد طرق أخرى للتكامل. عرضنا أخيرًا الآلية الثالثة للاتصال وهي عبر الكائن EventSource الذي يؤسس اتصالًا ثابتًا مع الخادم ويسمح له بإرسال الرسائل عبر قناة الاتصال، ويقدم: إعادة الاتصال التلقائي مع قيمة زمنية retry لإعادة الاتصال بعدها. معرّفات فريدة للرسائل "ids" لاستئناف الأحداث من آخر معرّف أرسِل مع الترويسة Last-Event-ID أثناء إعادة الاتصال. حالة الاتصال من خلال الخاصية readyState. تجعل هذه المزايا الكائن EventSource بديًلا قيمًا للكائن WebSocket، الذي يعمل في مستوىً أدنى ويفتقر إلى الميزات السابقة، والتي يمكن إنجازها يدويًا بالطبع. يعَد الكائن EventSource في الكثير من التطبيقات الحقيقية قادرًا كفاية على إنجاز المطلوب، وتدعمه جميع المتصفحات الحديثة، بينما لا يدعمه IE، وإليك صيغة استخدامه: let source = new EventSource(url, [credentials]); للوسيط الثاني خيار وحيد ممكن هو: { withCredentials: true }، ويسمح بإرسال ثبوتيات طلبات الأصول المختلطة. إن عوامل الأمان المتعلقة باستخدام طلبات الأصول المختلطة للكائن EventSourceمشابهة للاتصال fetch وغيره من طرق الاتصال عبر الشبكات. خاصيات الكائن EventSource: readyState: حالة الاتصال القائم EventSource.CONNECTING (=0)‎ EventSource.OPEN (=1)‎ EventSource.CLOSED (=2)‎ lastEventId: آخر معرّف استقبله المتصفح، وسيرسله أثناء إعادة الاتصال مع الترويسة Last-Event-ID. وتوابع EventSource فتتمثل بالتابع: ()close: يغلق الاتصال. أما أحداث EventSource فهي: message: عند استلام رسالة، وتكون متاحةً باستخدام event.data. open: عندما يكون الاتصال مفتوحًا. error: في حال وقع خطأ، بما في ذلك فقدان الاتصال أو الأخطاء القاتلة، يمكننا التحقق من الخاصية readyState للتأكد من وجود محاولات لإعادة الاتصال. يمكن للخادم اختيار اسم مخصص لحدث ما من خلال :event، وينبغي معالجة هذه الأحداث من خلال مستمع حدث addEventListener وليس باستخدام <on<event. رأينا من أجل تنسيق استجابة الخادم أن الخادم يرسل الرسائل مفصولةً عن بعضها باستخدامn\n\، ويمكن أن تحتوي الرسالة الحقول التالية: :data ويمثل جسم الرسالة، وتُفسَّر سلسلة من الحقول data كرسالة واحدة يفصل بين أجزائها الكتلة n\. :id تُجدِّد قيمة lastEventId وتُرسَل ضمن الترويسة Last-Event-ID عند إعادة الاتصال. :retry تحدد فترة التأخير الزمني بين كل محاولتين لإعادة الاتصال بالميلي ثانية، ولا يمكن ضبطها باستخدام JavaScript. :event اسم الحدث وينبغي أن تسبق الحقل data. يمكن أن تحتوي الرسالة على أحد الحقول السابقة أو أكثر وبأي ترتيب، لكن الحقل :id يأتي في النهاية عادةً. ترجمة -وبتصرف- للفصول Long polling وWebSocket وServer sent events من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: استئناف رفع الملفات في جافاسكريبت تطويع البيانات في جافاسكربت
  16. هو كائن مدمَج في المتصفح، يسمح بإرسال طلبات HTTP في جافاسكريبت JavaScript، ويمكنه العمل مع أي نوع من البيانات، وليس فقط بيانات "XML" على الرغم من وجود الكلمة "XML" في تسميته. يساعدنا هذا الكائن في عمليات رفع وتنزيل الملفات وتتبع تقدمها وغير ذلك الكثير، ولا بدّ من الإشارة إلى وجود أسلوب أكثر حداثةً منه وهو استخدام fetch التي ألغت استخدامه بشكل أو بآخر. يُستخدم XMLHttpRequest في تقنيات تطوير الويب العصرية لثلاثة أسباب، هي الآتية: أسباب تاريخية: عند الحاجة إلى دعم سكربت موجود يستخدم XMLHttpRequest. الحاجة إلى دعم المتصفحات القديمة دون استخدام شيفرة "polyfill". إنجاز شيء لا يمكن تنفيذه باستخدام fetch مثل تتبع تقدم رفع ملف. إن كنت تحتاج ما ذكرناه تابع قراءة المقال وإلا فتوجه إلى فصل استخدام Fetch. الأساسيات يعمل الكائن XMLHttpRequest وفق النمطين المتزامن وغير المتزامن، سنتحدث بدايةً عن الوضع غير المتزامن، لأنه يستخدم في أغلب الحالات. يُنجز طلب HTTP وفق 4 خطوات: الخطوة الأولى، إنشاء الكائن XMLHttpRequest: let xhr = new XMLHttpRequest(); ليس للدالة البانية أي وسطاء. الخطوة الثانية، تهيئة الكائن، وتكون بعد إنشائه عادةً: xhr.open(method, URL, [async, user, password]) يؤمن التابع open المعاملات الرئيسية للطلب، وهي: method: تحدد نوع طلب HTTP، عادةً يكون GET أو POST. URL: عنوان الموقع الذي نرسل الطلب إليه، ويمكن استخدام الكائن URL. async: تكون العملية متزامنةً إذا أسندت لها القيمة false صراحةً، وسنغطي ذلك لاحقًا. user وpassword: اسم المستخدم وكلمة المرور للاستيثاق، عند الحاجة. لا يفتح التابع open الاتصال على الرغم من اسمه، بل يهيئ الطلب فقط، ولا يبدأ الاتصال عبر الشبكة إلا باستدعاء send. الخطوة الثالثة، إرسال الطلب: xhr.send([body]) يفتح هذا التابع الاتصال ويرسل الطلب إلى الخادم، حيث يحتوي المعامل body على جسم الطلب.ولا تمتلك الطلبات مثل GET جسماً، وتستخدم طلبات أخرى مثل POST الجسم لإرسال البيانات إلى الخادم، وسنرى أمثلةً عن ذلك لاحقًا. الخطوة الرابعة، الاستماع إلى أحداث الكائن xhr لتلقي الاستجابة، وإليك أكثر الأحداث استخدامًا: load: عندما يكتمل الطلب ونحصل على الاستجابة كاملةً، حتى لو أعاد الخادم رمز الحالة 500 أو 400 مثلًا. error: عندما يتعذر تنفيذ الطلب، كأن تكون الشبكة معطلةً أو العنوان خاطئًا. progress: ويقع دوريًا أثناء تنزيل الاستجابة، ويوضح الكمية التي نُزِّلت. xhr.onload = function() { alert(`Loaded: ${xhr.status} ${xhr.response}`); }; xhr.onerror = function() { // عندما لا تحدث الاستجابة إطلاقًا alert(`Network Error`); }; xhr.onprogress = function(event) { // يقع دوريًا // event.loaded - الحجم الذي نزل بالبايت // event.lengthComputable = true إن أرسل الخادم ترويسة طول المحتوى // event.total - العدد الكلي للبايتات alert(`Received ${event.loaded} of ${event.total}`); }; إليك مثالًا كاملًا، حيث تحمّل الشيفرة الموالية العنوان الآتي article/xmlhttprequest/example/load/ من الخادم، وتطبع تقدم العملية: // 1. إنشاء الكائن let xhr = new XMLHttpRequest(); // 2. URL /article/.../load تهيئته مع العنوان xhr.open('GET', '/article/xmlhttprequest/example/load'); // 3. إرسال الطلب عبر الشبكة xhr.send(); // 4. سيستدعى هذا الجزء عند تلقي الاستجابة xhr.onload = function() { if (xhr.status != 200) { // analyze HTTP status of the response alert(`Error ${xhr.status}: ${xhr.statusText}`); // e.g. 404: Not Found } else { // show the result alert(`Done, got ${xhr.response.length} bytes`); // response is the server response } }; xhr.onprogress = function(event) { if (event.lengthComputable) { alert(`Received ${event.loaded} of ${event.total} bytes`); } else { alert(`Received ${event.loaded} bytes`); // no Content-Length } }; xhr.onerror = function() { alert("Request failed"); }; نستقبل نتيجة الطلب باستخدام خصائص الكائن xhr التالية حالما يستجيب الخادم: status: رمز حالة HTTP، عدد، مثل 200 و404 و403 وهكذا، كما يمكن أن يكون 0 عند حدوث خطأ لا علاقة له بالنقل وفق بروتوكول HTTP. statusText: رسالة حالة HTTP، نص، مثل OK لرمز الحالة 200 وNot Found لرمز الحالة 404، وForbidden لرمز الحالة 403. response: قد تستخدم السكربتات القديمة responseText، وتمثل جسم الاستجابة التي يرسلها الخادم. يمكن أن نحدد أيضًا زمن انتهاء timeout مستخدمين الخاصية الموافقة: xhr.timeout = 10000; // زمن الانتهاء بالميلي ثانية إن لم ينجح الطلب خلال الفترة الزمنية المحددة فسيُلغى وسيقع الحدث timeout. إضافة معاملات بحث إلى العنوان URL let url = new URL('https://google.com/search'); url.searchParams.set('q', 'test me!'); // 'q' ترميز المعامل xhr.open('GET', url); // https://google.com/search?q=test+me%21 نوع الاستجابة من الممكن استخدام الخاصية ResponseType لضبط تنسيق الاستجابة: "": القيمة الافتراضية، الحصول على الاستجابة كنص. "text": الحصول على الاستجابة كنص. "arraybuffer": الحصول على الاستجابة على شكل كائن ArrayBuffer، للحصول على بيانات ثنائية. "blob": الحصول على الاستجابة على شكل كائن Blob، للحصول على بيانات ثنائية. "document": الحصول على مستند XML، باستخدام اللغة XPath أو غيرها. "json": الحصول على الاستجابة بصيغة JSON، تفسر الاستجابة تلقائيًا. لنستقبل الاستجابة بصيغة JSON مثلًا: let xhr = new XMLHttpRequest(); xhr.open('GET', '/article/xmlhttprequest/example/json'); xhr.responseType = 'json'; xhr.send(); // {"message": "Hello, world!"} الاستجابة هي xhr.onload = function() { let responseObj = xhr.response; alert(responseObj.message); // Hello, world! }; حالات الجاهزية Ready States يمر الكائن بعدة حالات عند تقدم تنفيذ الطلب، ويمكن الوصول إلى الحالة من خلال xhr.readyState، وإليك الحالات جميعها كما وردت في التوصيفات: UNSENT = 0; // الحالة الأساسية OPENED = 1; //open استدعاء التابع HEADERS_RECEIVED = 2; // استقبال ترويسة الاستجابة LOADING = 3; // تلقي جسم الاستجابة DONE = 4; // اكتمال الطلب ينتقل الكائن XMLHttpRequest بين الحالات السابقة بالترتيب 0 ثم 1 ثم 2 ثم 3 ثم 4، وتتكرر الحالة 3 في كل مرة تُستقبل فيها حزمة بيانات عبر الشبكة، ويمكننا تتبع هذه الحالات باستخدام الحدث readystatechange. xhr.onreadystatechange = function() { if (xhr.readyState == 3) { // تحميل } if (xhr.readyState == 4) { // انتهاء الطلب } }; قد تجد مستمعات للأحداث readystatechange حتى في الشيفرات القديمة، وذلك لأنّ أحداثًا مثل load وغيرها، لم تكن موجودةً في فترة ما، لكن معالجات الأحداث load/error/progress قد ألغت استخدامها. طلب الإلغاء Aborting request يمكن إلغاء الطلب في أي لحظة، وذلك باستدعاء التابع ()xhr.abort : xhr.abort(); // إلغاء الطلب يحرّض استدعاء التابع الحدث abort وتأخذ الخاصية xhr.status القيمة 0. الطلبات المتزامنة عند إسناد القيمة false إلى المعامل الثالث async للتابع open، فسيُنفَّذ الطلب بتزامن Synchronously. وبعبارة أخرى، ستتوقف JavaScript مؤقتًا عن التنفيذ عند إرسال الطلب ()send ثم تتابع عند تلقي الاستجابة، وهذا يشابه تعليمتي alert وprompt. إليك المثال السابق وقد أعيدت كتابته مع وجود المعامل الثالث للتابع open: let xhr = new XMLHttpRequest(); xhr.open('GET', '/article/xmlhttprequest/hello.txt', false); try { xhr.send(); if (xhr.status != 200) { alert(`Error ${xhr.status}: ${xhr.statusText}`); } else { alert(xhr.response); } } catch(err) { // onerror بدلًا من alert("Request failed"); } قد يبدو الأمر جيدًا لكنها طريقة يندر استخدامها، لأنها تعيق تنفيذ شيفرة JavaScript حتى يكتمل التحميل، وقد يصبح من المستحيل تمرير الصفحة لعرض بقية المحتويات أثناء تنفيذها في بعض المتصفحات، وقد يقترح المتصفح أيضًا إغلاق الصفحة العالقة إذا تأخر تنفيذ الطلب المتزامن كثيرًا. ولا تتاح العديد من الإمكانيات المتقدمة للكائن XMLHttpRequest، مثل الطلب من نطاق آخر أو تحديد زمن الانتهاء من الطلب، عند إرسال الطلبات بتزامن، ولا مؤشرات أيضًا على تقدم العملية، لذلك لا تُنفّذ الطلبات المتزامنة إلا نادرًا. ترويسات HTTP يتيح الكائن XMLHttpRequest إرسال ترويسات مخصصة وقراءة ترويسات الاستجابة، وهنالك ثلاثة توابع للتعامل مع الترويسات: (setRequestHeader(name, value: يعطي اسمًا name وقيمةً value لترويسة الطلب، وإليك مثالًا عن ذلك: xhr.setRequestHeader('Content-Type', 'application/json') فيما يلي مثال لتوضيح ذلك: xhr.setRequestHeader('X-Auth', '123'); xhr.setRequestHeader('X-Auth', '456'); // ستكون الترويسة: // X-Auth: 123, 456 (getResponseHeader(name: تعيد ترويسة الاستجابة ذات الاسم المحدد name، عدا Set-Cookie وSet-Cookie2، إليك مثالًا: xhr.getResponseHeader('Content-Type') ()getAllResponseHeaders: تعيد كل ترويسات الاستجابة، عدا Set-Cookie وSet-Cookie2، وتُعاد القيم ضمن سطر مفرد، حيث سيكون الفاصل هو "\r\n" (لا يتعلق بنظام تشغيل محدد) بين كل ترويستين، وبالتالي يمكن فصلها إلى ترويسات مفردة، وإليك مثالًا: Cache-Control: max-age=31536000 Content-Length: 4260 Content-Type: image/png Date: Sat, 08 Sep 2012 16:53:16 GMT تفصل بين اسم الترويسة وقيمتها النقطتان المتعامدتان، يليها فراغ " :"، وهذا أمر ثابت في التوصيفات، فلو أردنا مثلًا الحصول على كائن له اسم وقيمةP فلا بد من تنفيذ الأمر بالشكل التالي، مفترضين أنه عند وجود ترويستين بنفس الاسم، وعندها ستحل الأخيرة منهما مكان الأولى: let headers = xhr .getAllResponseHeaders() .split('\r\n') .reduce((result, current) => { let [name, value] = current.split(': '); result[name] = value; return result; }, {}); // headers['Content-Type'] = 'image/png' الطلب POST والكائن FormData يمكن استخدام الكائن FormData لإرسال طلب HTTP-POST: إليك الشيفرة اللازمة: let formData = new FormData([form]); //<form> إنشاء كائن يُملأ اختيارًا من formData.append(name, value); // ربط الحقل تنشئ الشيفرة السابقة الكائن FormData وتملؤه بقيم من نموذج form اختياريًا، وتضيف append حقولًا أخرى إن اقتضى الأمر، ومن ثم: نستخدم الأمر (...,'xhr.open('POST: لنؤسس الطلب POST. نستخدم الأمر (xhr.send(formData: لإرسال النموذج إلى الخادم. إليك المثال التالي: <form name="person"> <input name="name" value="John"> <input name="surname" value="Smith"> </form> <script> // form ملؤها من let formData = new FormData(document.forms.person); // إضافة حقل جديد formData.append("middle", "Lee"); // إرساله let xhr = new XMLHttpRequest(); xhr.open("POST", "/article/xmlhttprequest/post/user"); xhr.send(formData); xhr.onload = () => alert(xhr.response); </script> يُرسَل النموذج بترميز multipart/form-data، فإذا أردنا استخدام JSON فلا بدّ من تنفيذ الأمر JSON.stringify ثم إرساله في هيئة نص، إلى جانب ضبط الترويسة Content-Type على القيمة application/json، وتفكِّك العديد من إطارات العمل مع الخادم محتوى JSON تلقائيًا بهذه الطريقة. let xhr = new XMLHttpRequest(); let json = JSON.stringify({ name: "John", surname: "Smith" }); xhr.open("POST", '/submit') xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); xhr.send(json); ويمكن للتابع أن يُرسل أي جسم للطلب بما في ذلك كائنات Blob وBufferSource. تقدم عمليات رفع البيانات يقع الحدث progress في مرحلة التنزيل فقط، فلو نشرنا شيئًا ما باستخدام الطلب POST، فسيرفع الكائن XMLHttpRequest البيانات -جسم الطلب- أولًا ومن ثم ينزل الاستجابة. وسيكون تتبع تقدم عملية رفع البيانات خاصةً إن كانت ضخمة؛ أمرًا هامًا، لكن لن نستفيد من الحدث xhr.onprogress في حالتنا، يوجد كائن آخر لا يمتلك توابعًا، وهو مخصص حصرًا لتتبع أحداث رفع البيانات وهو xhr.upload، الذي يولِّد أحداثًا كما يفعلxhr، لكنها تقع فقط عند رفع البيانات: loadstart: يقع عندما يبدأ رفع البيانات. progress: يقع دوريًا مع تقدم الرفع. abort: يقع عند إلغاء الرفع. error: يقع عند وقوع خطأ لا يتعلق بالبروتوكول HTTP. load: يقع عند نجاح عملية الرفع. timeout: يقع عند انتهاء الوقت المخصص لرفع البيانات، إذا ضُبطت الخاصية timeout. loadend: يقع عند انتهاء الرفع بنجاح أو بإخفاق. إليك أمثلةً عن معالجات هذه الأحداث: xhr.upload.onprogress = function(event) { alert(`Uploaded ${event.loaded} of ${event.total} bytes`); }; xhr.upload.onload = function() { alert(`Upload finished successfully.`); }; xhr.upload.onerror = function() { alert(`Error during the upload: ${xhr.status}`); }; إليك أيضًا مثالًا واقعيًا عن رفع ملف مع مؤشرات على تقدم العملية: <input type="file" onchange="upload(this.files[0])"> <script> function upload(file) { let xhr = new XMLHttpRequest(); // تعقب تقدم عملية الرفع xhr.upload.onprogress = function(event) { console.log(`Uploaded ${event.loaded} of ${event.total}`); }; // تعقب الانتهاء، بنجاح أو إخفاق xhr.onloadend = function() { if (xhr.status == 200) { console.log("success"); } else { console.log("error " + this.status); } }; xhr.open("POST", "/article/xmlhttprequest/post/upload"); xhr.send(file); } </script> الطلبات ذات الأصول المختلطة يمكن للكائن XMLHttpRequest تنفيذ طلبات الأصل المختلط مستخدمًا سياسة CORS، تمامًا كما يفعل fetch، ولن يُرسل ملفات تعريف الارتباط cookies أو معلومات استيثاق إلى مواقع ذات أصل مختلف افتراضيًا. ولتمكين ذلك لا بدّ من ضبط الخاصية xhr.withCredentials على القيمة true. let xhr = new XMLHttpRequest(); xhr.withCredentials = true; xhr.open('POST', 'http://anywhere.com/request'); ... اطلع على فصل "استخدام Fetch مع الطلبات ذات الأصل المختلط" لمعلومات أكثر. خلاصة تمثل الشيفرة التالية، الشيفرة النموذجية لطلب GET باستخدام XMLHttpRequest let xhr = new XMLHttpRequest(); xhr.open('GET', '/my/url'); xhr.send(); xhr.onload = function() { if (xhr.status != 200) { // HTTP error? // معالجة الخطأ alert( 'Error: ' + xhr.status); return; } // xhr.response الحصول على الاستجابة من }; xhr.onprogress = function(event) { // إعطاء تقرير عن التقدم alert(`Loaded ${event.loaded} of ${event.total}`); }; xhr.onerror = function() { // handle non-HTTP error (e.g. network down) }; هنالك أحداث أكثر في التوصيفات الحديثة، والتي وُضِعت في قائمة مرتبة وفق دورة حياة كل حدث: loadstart: عندما يبدأ الطلب. progress: عند وصول حزمة بيانات، وتكون الاستجابة الكاملة في هذه اللحظة ضمن الاستجابة response. ()abort: عند إلغاء الطلب باستدعاء التابع. error: عند وقوع خطأ في الاتصال، مثل اسم نطاق خاطئ ولم يحدث نتيجة خطأ HTTP مثل 404. load: عند انتهاء الطلب بنجاح. timeout: عند إلغاء الطلب نتيجة تجاوز الوقت المخصص، ويحدث عندما تُضبَط هذه الخاصية. loadend: ويقع بعد الأحداث load أو error أو timeout أو abort، وهذه الأحداث متنافية فيما بينها، أي لا يمكن وقوع سوى حدث واحد منها. إن أكثر الأحداث استخدامًا هما حدث إكمال التحميل load وحدث إخفاق التحميل error، كما يمكن استعمال معالج الحدث loadend والتحقق من خصائص الكائن xhr للتأكد من طبيعة الحدث الذي وقع. لقد تعرفنا في هذا المقال أيضًا على الحدث readystatechange، والذي ظهر منذ زمن بعيد قبل استقرار التوصيفات، ولا حاجة حاليًا لاستخدامه، حيث يُستبدل بالأحداث الأكثر عصريةً. لكن مع ذلك قد نصادفه في بعض السكربتات القديمة. إذا أردنا تعقب تقدم رفع البيانات، فلا بدّ من الاستماع إلى نفس أحداث التنزيل لكن باستخدام الكائن xhr.upload. ترجمة -وبتصرف- للفصل XMLHttpRequest من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: كائنات URL في جافاسكريبت ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت فحص الأصناف عبر instanceof في جافاسكربت هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت الحياة السرية للكائنات في جافاسكريبت
  17. يقدم الصنف URL المدمج واجهةً ملائمةً لإنشاء عناوين الموارد وروابط URL وتفسيرها، لا تحتاج الطلبات عبر الشبكة إلى هذا الكائن بالتحديد، فالقيم النصية التي يمكن أن تعبّر عن العناوين كافية، وبالتالي لن نحتاج إليه تقنيًا، لكننا سنجد أن استخدامه مفيد في مناسبات عدة. إنشاء رابط URL إليك الصيغة البرمجية التي تُنشئ كائن URL جديدًا: new URL(url, [base]) url: عنوان URL الكامل، أو جزء منه عند إسناد قيمة إلى base. base: أساس اختياري للعنوان، فإذا أسندت قيمة لهذا الوسيط وكانت قيمة الوسيط الآخر url هي مسار فقط، فسيُولّد الكائن URL منسوبًا إلى القاعدة base. إليك مثالًا: let url = new URL('https://javascript.info/profile/admin'); لاحظ أن كائني URL التاليين متطابقين تمامًا: let url1 = new URL('https://javascript.info/profile/admin'); let url2 = new URL('/profile/admin', 'https://javascript.info'); alert(url1); // https://javascript.info/profile/admin alert(url2); // https://javascript.info/profile/admin يمكن بالطبع إنشاء كائن URL جديد مبني على مسار نسبي أساسه كائن URL موجود مسبقًا: let url = new URL('https://javascript.info/profile/admin'); let newUrl = new URL('tester', url); alert(newUrl); // https://javascript.info/profile/tester نستطيع الوصول إلى مكوّنات الكائن URL مباشرةً، وبالتالي سيقدم طريقةً أنيقةً لتفسير العناوين: let url = new URL('https://javascript.info/url'); alert(url.protocol); // https: alert(url.host); // javascript.info alert(url.pathname); // /url إليك لائحةً بمكوّنات URL: href: ويعيد العنوان كاملًا، تمامًا كما يفعل التابع ()url.toString. protocol: جزء من العنوان ينتهي بالنقطتين ":". search: سلسلة نصية من المعاملات يبدأ بإشارة الاستفهام "؟". hash: ويبدأ بالعلامة "#". كما يمكنك أن تجد الخاصيتين user وpassword عند استخدام استيثاق HTTP، مثل http://login:password@site.com، لكنه نادر الاستخدام. معامل البحث "?" لنفترض أننا سننشئ عنوان url له معاملات بحث محددة، مثل https://google.com/search?query=JavaScript، كما يمكننا وضع المعاملات عند إنشاء كائن URL: new URL('https://google.com/search?query=JavaScript') ويجب ترميز المعاملات إذا احتوت على فراغات أو أحرف ليست لاتينيةً وما شابه (ستجد المزيد عن ذلك في الفقرات التالية)، لذلك توجد خاصية تتولى ذلك هي url.searchParams، وهي كائن من النوع URLSearchParams، وتؤمن مجموعةً من التوابع التي تتعامل مع معاملات البحث: (append(name, value: يضيف المعامل المحدد بالاسم name. (delete(name: يحذف المعامل المحدد بالاسم name. (get(name: يحضر المعامل المحدد بالاسم name. (getAll(name: يحضر كل المعاملات التي لها نفس الاسم name، وهو أمر ممكن، مثل ?user=John&user=Pete. (has(name: التحقق من وجود معامل بالاسم name. (set(name, value: لضبط معامل أو تغييره. ()sort: فرز المعاملات بالاسم، وتُستخدّم نادرًا، وهي قابلة للمرور عليها Iterable بصورة مشابهة للترابط Map. let url = new URL('https://google.com/search'); url.searchParams.set('q', 'test me!'); //! يضيف معاملًا ضمنه فراغ وإشارة alert(url); // https://google.com/search?q=test+me%21 url.searchParams.set('tbs', 'qdr:y'); // ":" يضيف معاملًا يحوي العلامة // تُرمز المعاملات تلقائيًا alert(url); // https://google.com/search?q=test+me%21&tbs=qdr%3Ay //(المرور على المعاملات (فك ترميز for(let [name, value] of url.searchParams) { alert(`${name}=${value}`); // q=test me!, then tbs=qdr:y } الترميز Encoding يحدد المعيار RFC3986 المحارف المسموحة في العناوين، والمحارف التي لا يُسمح باستخدامها. وينبغي ترميز المحارف التي لا يسمح بها، مثل الأحرف غير اللاتينية والفراغات، باستبدالها بمقابلاتها في ترميز UTF-8 مسبوقًا بالمحرف "%"، كأن نكتب 20%. ويمكن ترميز الفراغ بالمحرف "+" لأسباب تاريخية -وهذه حالة استثنائية-، لكن الجيد بالأمر هو أنّ الكائن URL ينجز كل ذلك تلقائيًا، وكل ما علينا فعله هو تزويده بالمعاملات دون ترميز، وسيحول العنوان إلى نص: // using some cyrillic characters for this example let url = new URL('https://ru.wikipedia.org/wiki/Тест'); url.searchParams.set('key', 'ъ'); alert(url); //https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D1%81%D1%82?key=%D1%8A لاحظ أن القيمتين Тест في مسار العنوان وъ في المعامل قد رُمِّزا، سيغدو العنوان أطول لأن كل محرف سيُمثّل ببايتين في UTF-8، وبالتالي ستكون هناك كتلتان من الشكل ..% لكل محرف. ترميز القيم النصية استخدم المبرمجون -في السابق وقبل ظهور الكائن URL- القيم النصية لتمثيل العناوين، لكن استخدام الكائن URL حاليًا أكثر ملاءمةً، ومع ذلك لا يزال استخدام القيم النصية شائعًا، فهو يجعل العنوان أقصر في الكثير من الأحيان، ولا بدّ عند استخدام القيم النصية من ترميز أو فك ترميز المحارف الخاصة يدويًا، باستخدام دوال مدمجة هي: encodeURI: يُرمِّز العنوان بالكامل. decodeURI: يفك ترميز النص المرمَّز. encodeURIComponent: يرمّز مكوّنًا من مكونات العنوان، مثل معاملات البحث أو المسار. decodeURIComponent: يفك ترميز الجزء المُرمَز. لكن السؤال الطبيعي سيكون: "ما هو الفرق بين encodeURIComponent وencodeURI؟ ومتى سنستخدم كلًا منهما؟" من السهل استيعاب الفكرة عند النظر إلى الصورة السابقة التي تفصل العنوان التالي إلى مكوناته: https://site.com:8080/path/page?p1=v1&p2=v2#hash إذ يُسمح باستخدام المحارف التالية : و? و= و& و# في عنوان URL، ومن جهة أخرى إذا نظرنا إلى أي مكوّن من مكوّنات العنوان بمفرده، مثل: المعاملات، فلا بدّ من ترميز هذه المحارف حتى لا تخِلّ بتنسيق العنوان. وهنا سنرى استخدام الدالتين السابقتين: encodeURI: تُرمز المحارف المرفوضة كليًا في العناوين فقط. encodeURIComponent: ترمز نفس المحارف التي ترمزها الدالة السابقة بالإضافة إلى المحارف التالية: # و$ و& و+ و, و/ و: و; و= و? و@. لذلك يمكن استخدام الدالة encodeURI لترميز العنوان كاملًا: // using cyrillic characters in url path let url = encodeURI('http://site.com/привет'); alert(url); // http://site.com/%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82 بينما ستستخدم الدالة encodeURIComponent في ترميز معاملات العنوان: let music = encodeURIComponent('Rock&Roll'); let url = `https://google.com/search?q=${music}`; alert(url); // https://google.com/search?q=Rock%26Roll لاحظ الفرق عند استخدام encodeURI: let music = encodeURI('Rock&Roll'); let url = `https://google.com/search?q=${music}`; alert(url); // https://google.com/search?q=Rock&Roll حيث لا تُرمّز الدالة encodeURI المحرف & لأنه محرف مقبول في عنوان URL الكامل، لكن لا بدّ من ترميزه عندما يكون ضمن معامل البحث، وإلا ستكون نتيجة q=Rock&Roll هي q=Rock، بالإضافة إلى معامل غامض يمثله Roll، وهي ليست كما قصدنا. لذا لا بدّ من استخدام encodeURIComponent فقط عند تشفير كل معامل من معاملات البحث، لكي نضعه بشكله الصحيح في نص العنوان، وتبقى طريقة ترميز الاسم والعنوان معًا هي الأكثر أمانًا؛ إلا عندما نثق تمامًا بعدم احتواء أي منهما على محارف ممنوعة الاستخدام. // valid url with IPv6 address let url = 'http://[2607:f8b0:4005:802::1007]/'; alert(encodeURI(url)); // http://%5B2607:f8b0:4005:802::1007%5D/ alert(new URL(url)); // http://[2607:f8b0:4005:802::1007]/ ترجمة -وبتصرف- للفصل URL Objects من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: الواجهة البرمجية Fetch API ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
  18. تتيح الدالة fetch تتبع عملية التنزيل download. لاحظ أنه لا توجد حاليًا طريقة تسمح للدالة fetch بتتبع عملية الرفع upload، نستخدم لهذه الغاية الكائن XMLHttpRequest الذي سنغطيه لاحقًا. تُستخدم الخاصية response.body لتتبع تقدم التنزيل، وتمثل هذه الخاصية كائن ReadableStream، وهو كائن خاص يزودنا عند وصوله بجسم الطلب كتلةً بكتلة chunk-by-chunk، ستجد وصفًا لمجاري التدفق القابلة للقراءة Readable streams في توصيف الواجهة Streams API، وتمنح الخاصية response.body تحكمًا كاملًا بعملية القراءة على خلاف التابعين ()response.text و()response.json وغيرهما. كما تمنح إمكانية تقدير الوقت المستغرق في أية لحظة. إليك مثالُا عن شيفرة تقرأ الاستجابة من response.body: // والطرق الأخرى response.json() بدلا من const reader = response.body.getReader(); // حلقة لا نهائية حتي يكتمل التنزيل while(true) { // عند آخر جزء true القيمة done ستحمل //لبايتات كل جزء Unit8Array هو value const {done, value} = await reader.read(); if (done) { break; } console.log(`Received ${value.length} bytes`) } ستكون نتيجة الاستدعاء ()await reader.read كائنًا له الخاصيتان التاليتان: done : تأخذ القيمة true عندم اكتمال عملية القراءة، وإلا فستكون قيمتها false. value : مصفوفة من النوع Uint8Array. //واحصل على قارئ للبيانات Fetch الخطوة1: إبدأ تنفيذ الدالة let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100'); const reader = response.body.getReader(); // الخطوة2: احصل على الحجم الكلي const contentLength = +response.headers.get('Content-Length'); // الخطوة3 : إقرأ البيانات let receivedLength = 0; // حجم البايتات المستقبلة حتى اللحظة let chunks = []; // مصفوفة الأجزاء المستلمة التي تمثل جسم الاستجابة while(true) { const {done, value} = await reader.read(); if (done) { break; } chunks.push(value); receivedLength += value.length; console.log(`Received ${receivedLength} of ${contentLength}`) } // الخطوة 4: ضم الأجزاء في مصفوفة واحدة let chunksAll = new Uint8Array(receivedLength); // (4.1) let position = 0; for(let chunk of chunks) { chunksAll.set(chunk, position); // (4.2) position += chunk.length; } // الخطوة5: الترميز في سلسلة نصية let result = new TextDecoder("utf-8").decode(chunksAll); // النهاية let commits = JSON.parse(result); alert(commits[0].author.login); المخرجات: "Received 258566 of 0" "Received 444982 of 0" لنشرح الشيفرة السابقة: لقد نفّذنا الدالة ftech، لكننا استخلصنا مجرى التدفق ()reader response.body.getReader بدلًا من استدعاء التابع ()response.json، ولايمكن استخدام الطريقتين معًا لقراءة الاستجابة، استخدم إحداهما للحصول على النتيجة. يمكننا قبل الشروع في قراءة الاستجابة تحديد الحجم الكلي لها عن طريق الترويسة Content-Length، وقد لا تكون الترويسة موجودةً في الطلبات ذات الأصل المختلط Cross-origin، لكن لن يُعدَّها الخادم عمليًا، وستبقى في مكانها. نستدعي التابع ()await reader.read حتى ينهي عمله، ونُجمِّع أجزاء الاستجابة في المصفوفة chunks، وهذا الأمر ضروري لأن الاستجابة ستختفي ولن نتمكن من إعادة قراءتها باستخدام ()response.json ولا بأي طريقة أخرى، وستحصل على خطأ إذا حاولت ذلك. سنحصل في النهاية على chunks وهي مصفوفة من الأجزاء لها النوع Uint8Array، وعلينا تجميعها ضمن نتيجة واحدة، ولسوء الحظ لا يوجد تابع لضمها، لهذا علينا كتابة الشيفرة التي ستنجز العملية: إنشاء المصفوفة (chunksAll = new Uint8Array(receivedLength، وهي مصفوفة من النوع Uint8Array لها حجم جميع الأجزاء. استخدام التابع (set(chunk, position. لنسخ كل جزء بدوره إليها. سنحصل على النتيجة ضمن المصفوفة chunksAll، وهي مصفوفة من البايتات وليست نصًا، ولتحويلها إلى نص لا بد من تفسير هذه البيانات عن طريق الكائن TextDecoder، ثم استخدام JSON.parse إن استدعت الحاجة. لكن ماذا لو احتجنا إلى محتوىً ثنائي بدل النص؟ سيكون الأمر أبسط، علينا فقط استبدال الخطوتين 4 و5 بسطر وحيد يُنشئ كائن بيانات ثنائية Blob يضم كل الأجزاء. let blob = new Blob(chunks); وهكذا سنحصل على النتيجة بالصيغة التي نريد مع إمكانية تتبع تقدم العملية، مرةً أخرى يجب الانتباه إلى أن العملية غير صالحة لتتبع تقدم عملية الرفع، أي لا يمكن استخدام Fetch، بل فقط للتنزيل. ولا بد من التحقق من حجم البيانات الواصلة receivedLength في كل لحظة ضمن الحلقة وإنهائها بمجرد وصولها إلى حد معين، إذا لم يكن حجم البيانات التي سنستقبلها معروفًا، وبالتالي لن تستهلك المصفوفة chunks الذاكرة. الكائن AbortController: مقاطعة العمليات غير المتزامنة تعيد fetch كما نعرف وعدًا promise، ولكننا نعلم أنّ JavaScript لا تقبل إلغاء الوعود عمومًا، فكيف سنلغي عملية fetch أثناء تنفيذها؟ هنالك كائن خاص مدمج لهذا الغرض هو AbortController، يمكن استخدامه لإلغاء fetch وغيرها من المهام غير المتزامنة، ويطبّق مباشرةً. لننشئ متحكمًا بالشكل التالي: let controller = new AbortController(); والمتحكم هو كائن شديد البساطة، له: تابع وحيد هو ()abort. وخاصية واحدة هي signal تسمح بإعداد مستمع حدث event listener له. أما عند استدعاء التابع ()abort فسيحدث الآتي: تحرّض الخاصية controller.signal وقوع الحدث abort. تأخذ الخاصية controller.signal.aborted القيمة true. تكون للعملية في العادة مرحلتان: المرحلة التي تُنفِّذ عمليةً قابلة للإلغاء، وتهيئ مستمع حدث للخاصية controller.signal. المرحلة التي تلغي: وتُنفَّذ باستدعاء التابع ()controller.abort عندما يتطلب الأمر. إليك مثالًا كاملًا دون Fetch: let controller = new AbortController(); let signal = controller.signal; // القيام بعملية قابلة للإلغاء // "signal" الحصول على الكائن // controller.abort() ضبط إطلاق المستمع عند استدعاء signal.addEventListener('abort', () => alert("abort!")); // القيام بالإلغاء controller.abort(); // abort! // true القيمة signal.aborted إطلاق الحدث ويصبح لــ alert(signal.aborted); // true لاحظ أنّ الكائن AbortController هو مجرد أداة لتمرير الحدث abort عند استدعاء التابع ()abort، ومن الواضح أنه بالإمكان تنفيذ مستمع حدث كهذا باستخدام شيفرتنا الخاصة دون الحاجة إلى AbortController، لكن أهميته ستظهر عندما نعلم أن fetch تعرف تمامًا كيفية التعامل معه، فهو متكامل معها. مقاطعة العملية Fetch لإلغاء العملية fetch علينا تمرير قيمة الخاصية signal العائدة للكائن AbortController مثل خيار لها: let controller = new AbortController(); fetch(url, { signal: controller.signal }); تعلم fetch تمامًا كيفية التعامل مع AbortController، وستستمع إلى الحدث abort الذي تحرّض الخاصية signal وقوعه. لإلغاء العملية سنستدعي التابع: controller.abort(); وهكذا تلغى العملية، حيث تحصل fetch على الحدث abort من الخاصية signal وتلغي الطلب، عند إلغاء fetch سيُرفض الوعد الذي تعيده وسيُرمى الخطأ AbortError، وينبغي التعامل معه من خلال حلقة try..catch مثلًا. إليك مثالًا كاملًا مع استخدام fetch، حيث تلغى العملية بعد ثانية واحدة: // الإلغاء خلال ثانية واحدة let controller = new AbortController(); setTimeout(() => controller.abort(), 1000); try { let response = await fetch('/article/fetch-abort/demo/hang', { signal: controller.signal }); } catch(err) { if (err.name == 'AbortError') { // handle abort() alert("Aborted!"); } else { throw err; } } كائن قابل للتوسع يسمح الكائنAbortController بإلغاء عدة عمليات معًا، إليك الشيفرة التمثيلية التالية التي تحضر عدة موارد على التوازي، ثم تستخدم كائن متحكم وحيدًا لإلغائها جميعًا: let urls = [...]; //قائمة بالموارد التي ينبغي إحضارها let controller = new AbortController(); // fetch مصفوفة من الوعود التي ستعيدها عمليات let fetchJobs = urls.map(url => fetch(url, { signal: controller.signal })); let results = await Promise.all(fetchJobs); // بمجرد استدعاء حدث الإلغاء ستلغى جميع عمليات الإحضار وسنتمكن أيضًا من إلغاء أي عمليات أخرى غير متزامنة مع عمليات fetch باستخدام كائن AbortController وحيد، بمجرد الاستماع إلى الحدث abort: let urls = [...]; let controller = new AbortController(); let ourJob = new Promise((resolve, reject) => { // المهمة المطلوب إلغاءها ... controller.signal.addEventListener('abort', reject); }); let fetchJobs = urls.map(url => fetch(url, { // عمليات الإحضار signal: controller.signal })); // انتظار إنجاز جميع العمليات let results = await Promise.all([...fetchJobs, ourJob]); // بمجرد استدعاء حدث الإلغاء ستلغى جميع عمليات الإحضار // بالإضافة إلى بقية المهام خلاصة بهذا نكون قد تعرفنا على كيفية تتبع عملية التنزيل باستخدام Fetch، وذلك بالاعتماد على عدة خاصيات، كما تعرفنا على كيفية مقاطعة العملية Fetch، وذلك بالاعتماد على الكائنات الآتية: AbortController: هو كائن بسيط يولّد الحدث abort على الخاصية signal عند استدعاء التابع ()abort، الذي يعطي الخاصية signal.aborted القيمة "true" أيضًا. تتكامل fetch مع هذا الكائن، حيث تُمرر الخاصية signal كخيار لتستمع إليه، وبالتالي يصبح إلغاؤها ممكنًا. يمكن استخدام AbortController في شيفرتنا، حيث يستمع التابع ()abort إلى الحدث abort بعملية بسيطة تطبق في أي مكان، كما يمكن استخدامها دون استخدام fetch. ترجمة -وبتصرف- للفصلين Fetch: Download Progress وFetch: Abort من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
  19. يمكن للغة جافاسكربت JavaScript إرسال طلبات شبكة إلى الخادم وتحميل معلومات جديدة عندما يتطلب الأمر ذلك، إذ يمكننا على سبيل المثال استخدام طلبات الشبكة في الحالات التالية: إرسال طلب. بتحميل معلومات مستخدم. الحصول على آخر التحديثات من الخادم. ويجري كل ذلك دون إعادة تحميل الصفحة. تنضوي طلبات الشبكة التي تنفذها لغة JavaScript تحت المظلة AJAX، وهي اختصار للعبارة Asynchronous JavaScript And XML، ورغم ذلك لا نحتاج إلى استخدام XML، فقد وضعت العبارة السابقة منذ فترة طويلة لذلك وجدت هذه الكلمة ضمنها، وقد تكون سمعت بهذه العبارة الآن أيضًا. هنالك طرق عديدة لإرسال طلبات عبر الشبكة والحصول على معلومات من الخادم، وسنبدأ بالطريقة الأحدث ()fetch، علمًا أنه لا تدعم المتصفحات القديمة هذه الدالة (ويمكن الاستعاضة عنها بشيفرة بديلة)، لكنها مدعومة جيدًا في المتصفحات الحديثة، وإليك صيغتها: let promise = fetch(url, [options]) حيث: url: عنوان المورد الذي ستصل إليه الدالة. options: المعاملات الاختيارية من توابع وترويسات وغيرها. تتحول الدالة إلى طلب GET بسيط لتنزيل محتوى العنوان url إن لم تكن هناك معاملات اختيارية options، ويبدأ المتصفح الطلب مباشرةً ويعيد وعدًا promise ستستخدمه الشيفرة التي تستدعي الطلب للحصول على النتيجة، وتكون الاستجابة عادةً عمليةً بمرحلتين: الأولى: يُحلَّل الوعد الذي تعيده fetch عبر كائن من الصنف Respo-nse حالما يستجيب الخادم بالترويسات المناسبة، ويمكن التحقق من نجاح الطلب أو عدم نجاحه، والتحقق أيضًا من الترويسات، لكن لن يصل جسم الطلب في هذه المرحلة، ويُرفَض الوعد إن لم تكن fetch قادرةً على إنجاز طلب HTTP لمشاكل في الشبكة مثلًا، أو لعدم وجود موقع على العنوان المُعطى، ولن تسبب حالات HTTP غير العادية مثل 404 أو 500 أخطاءً. يمكن معرفة حالة طلب من خصائص الاستجابة: status: رمز الحالة status code لطلب HTTP مثل الرمز 200. ok: قيمة منطقية "true" عندما يكون رمز الحالة بين 200 و299. إليك المثال التالي: let response = await fetch(url); if (response.ok) { // إن كان رمز الحالة بين 200-299 // الحصول على جسم الطلب let json = await response.json(); } else { alert("HTTP-Error: " + response.status); } الثانية: استخدام استدعاء إضافي للحصول على جسم الطلب، ويؤمن الكائن Response عدة توابع مبنية على الوعد للوصول إلى جسم الطلب وبتنسيقات مختلفة: ()response.text: لقراءة الاستجابة وإعادة نص. ()response.json: يفسِّر النص وفق تنسيق JSON. ()response.formData: يعيد الاستجابة على شكل كائن FormData سنشرحه في الفقرة التالية. ()response.blob: يعيد الاستجابة على شكل كائن البيانات الثنائية Blob. ()response.arrayBuffer: يعيد الاستجابة على شكل كائن ArrayBuffer وهو تمثيل منخفض المستوى للبيانات الثنائية. الكائن response.body وهو كائن من الصنف ReadableStream يسمح بقراءة جسم الطلب كتلةً كتلةً، وسنعرض مثالًا عن ذلك لاحقًا. لنحاول على سبيل المثال الحصول على كائن JSON من آخر نسخة معتمدة لموقع الدورة التعليمية هذه على GitHub: let url = 'https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits'; let response = await fetch(url); let commits = await response.json(); // Json قراءة الاستجابة على شكل شيفرة alert(commits[0].author.login); See the Pen JS-P3-Fetch-ex02 by Hsoub (@Hsoub) on CodePen. كما يمكن فعل ذلك من خلال الوعود الصرفة دون استخدام await: fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits') .then(response => response.json()) .then(commits => alert(commits[0].author.login)); See the Pen JS-P3-Fetch-ex03 by Hsoub (@Hsoub) on CodePen. استخدم ()await response.text للحصول على نص الطلب بدلًا من ()json.: let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits'); let text = await response.text(); // قراءة جسم الاستجابة على شكل نص alert(text.slice(0, 80) + '...'); See the Pen JS-P3-Fetch-ex04 by Hsoub (@Hsoub) on CodePen. لنستعرض مثالًا عن قراءة بيانات بالصيغة الثنائية، ونحضر صورةً ما ونظهرها: let response = await fetch('https://javascript.info/article/fetch/logo-fetch.svg'); let blob = await response.blob(); // Blob تنزيل على شكل // <img> إنشاء عنصر let img = document.createElement('img'); img.style = 'position:fixed;top:10px;left:10px;width:100px'; document.body.append(img); // إظهاره img.src = URL.createObjectURL(blob); setTimeout(() => { // إخفاءه بعد ثلاث ثوان img.remove(); URL.revokeObjectURL(img.src); }, 3000); See the Pen JS-P3-Fetch-ex05 by Hsoub (@Hsoub) on CodePen. let text = await response.text(); // انتهاء معالجة جسم الطلب let parsed = await response.json(); // سيخفق، فقد جرت المعالجة وانتهت ترويسات الاستجابة يمكن الحصول على ترويسات الاستجابة على شكل كائن ترويسات شبيه بالترابط Map من خلال الأمر response.headers، ولا يُعَد الكائن ترابطًا تمامًا، لكنه يمتلك توابع مماثلةً للحصول على ترويسات من خلال اسمها أو بالمرور عليها: let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits'); // الحصول على ترويسة واحدة alert(response.headers.get('Content-Type')); // application/json; charset=utf-8 // المرور على الترويسات كلها for (let [key, value] of response.headers) { alert(`${key} = ${value}`); } See the Pen JS-P3-Fetch-ex06 by Hsoub (@Hsoub) on CodePen. ترويسات الطلب يمكن استخدام خيار الترويسات headers لإعداد ترويسة الطلب في الدالة featch، إذ تمتلك كائنًا يضم الترويسات المُرسَلة كالتالي: let response = fetch(protectedUrl, { headers: { Authentication: 'secret' } }); لكن هناك قائمة من ترويسات HTTP المنوعة التي لا يمكن ضبطها: Accept-Charset وAccept-Encoding Access-Control-Request-Headers Access-Control-Request-Method Connection Content-Length Cookie وCookie2 Date DNT Expect Host Keep-Alive Origin Referer TE Trailer Transfer-Encoding Upgrade Via Proxy-*‎ Sec-*‎ تضمن هذه الترويسات ملاءمة طلبات HTTP وأمانها، لذلك يتحكم فيها المتصفح حصرًا. طلبات الكتابة POST لإرسال طلب POST أو طلب من أي نوع لا بدّ من استخدام خيارات fetch: method: نوع طلب HTTP مثل HTTP-POST. body: ويمثل جسم الطلب وقد يكون: نصًا: بتنسيق JSON مثلًا. كائن FormData لإرسال بيانات على شكل form/multipart. كائن Blob أو BufferSourceلإرسال بيانات ثنائية. URLSearchParams لإرسال البيانات بتشفير x-www-form-urlencoded، وهو نادر الاستخدام. يُستخدم تنسيق JSON غالبًا، حيث تُرسل الشيفرة التالية الكائن user وفق تنسيق JSON مثلًا: let user = { name: 'John', surname: 'Smith' }; let response = await fetch('https://javascript.info/article/fetch/post/user', { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8' }, body: JSON.stringify(user) }); let result = await response.json(); alert(result.message); لاحظ ضبط الترويسة Content-Type افتراضيًا على القيمتين text/plain;charset=UTF-8 إذا كان جسم الطلب على شكل نص، لكن طالما أننا سنرسل البيانات بصيغة JSON، فسنستخدم الخيار headers لإرسال الترويسة application/json بدلًا عن text/plain كونها تمثل المحتوى الصحيح للبيانات. إرسال صورة يمكن إرسال بيانات ثنائية عبر الدالة fetch باستخدام الكائنات Blob أو BufferSource، سنجد في المثال التالي معرّف لوحة رسم <canvas> التي يمكننا الرسم ضمنها بتحريك الفأرة، ومن ثم إرسال الصورة الناتجة إلى الخادم عند النقر على الزر "submit": <body style="margin:0"> <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas> <input type="button" value="Submit" onclick="submit()"> <script> canvasElem.onmousemove = function(e) { let ctx = canvasElem.getContext('2d'); ctx.lineTo(e.clientX, e.clientY); ctx.stroke(); }; async function submit() { let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); let response = await fetch('/article/fetch/post/image', { method: 'POST', body: blob }); // يستجيب الخادم بتأكيد وصول البيانات وبحجم الصورة let result = await response.json(); alert(result.message); } </script> </body> ستظهر النتيجة كالتالي: See the Pen JS-P3-Fetch-ex07 by Hsoub (@Hsoub) on CodePen. لاحظ أننا لم نضبط هنا قيمة الترويسة Content-Type يدويًا، لأنّ الكائن Blob له نوع مضمَّن (هو image/png في حالتنا، كما ولّده التابع toBlob)، وسيمثّل هذا النوع قيمة الترويسة Content-Type في كائنات Blob. يمكن كتابة الدالة ()submit دون استخدام الصيغة async/await كالتالي: function submit() { canvasElem.toBlob(function(blob) { fetch('https://javascript.info/article/fetch/post/image', { method: 'POST', body: blob }) .then(response => response.json()) .then(result => alert(JSON.stringify(result, null, 2))) }, 'image/png'); } استخدام الكائن FormData لإرسال النماذج يمكننا الاستفادة من الكائن FormData لإرسال نماذج HTML مع ملفات أو بدونها، بالإضافة إلى حقول إضافية. وكما قد تخمِّن؛ يمثل هذا الكائن بيانات نماذج HTML، وإليك صيغة الدالة البانية له: let formData = new FormData([form]); سيتحكم الكائن FormData تلقائيًا بحقول العنصر form إذا استُخدم في مستند HTML، وما يميز الكائن FormData هو أنّ توابع إرسال الطلبات واستقبالها عبر الشبكة مثل Fetch ستقبله مثل جسم للطلب، إذ يُشفَّر ويُرسَل بترويسة قيمتها Content-Type: multipart/form-data، وتبدو العملية بالنسبة إلى الخادم مثل إرسال عادي لنموذج. إرسال نموذج بسيط لنرسل أولًا نموذجًا بسيطًا، وسيظهر في مثالنا هذا في نموذج من سطر واحد: <form id="formElem"> <input type="text" name="name" value="John"> <input type="text" name="surname" value="Smith"> <input type="submit"> </form> <script> formElem.onsubmit = async (e) => { e.preventDefault(); let response = await fetch('https://javascript.info/article/formdata/post/user', { method: 'POST', body: new FormData(formElem) }); let result = await response.json(); alert(result.message); }; </script> وستكون النتيجة كالتالي: See the Pen JS-P3-FormData-ex01 by Hsoub (@Hsoub) on CodePen. لا توجد شيفرة خاصة بالخادم في هذا المثال، لأنها خارج نطاق هذه الدورة التعليمية، حيث سيقبل الخادم الطلب HTTP-POST ويستجيب بالرسالة "User saved" أي "خُزّن المستخدم". توابع الكائن نستخدم عددًا من التوابع لتعديل الحقول في الكائن FormData: (formData.append(name, value: يُضيف حقلًا بالاسم name قيمته هي value. (formData.append(name, blob, fileName: يضيف حقلًا كما لو أنه العنصر <"input type="file>، حيث يحدد الوسيط الثالث للتابع fileName اسم الملف -وليس اسم الحقل- كما لو أنه اسم لملف في منظومة ملفات الجهاز. (formData.delete(name: يزيل حقلًا محددًا بالاسم name. (formData.get(name: يعطي قيمة الحقل المحدد بالاسمname. (formData.has(name: إذا وجد حقل بالاسم name فسيعيد القيمة true وإلا false. يمكن أن يحوي النموذج العديد من الحقول التي لها نفس الاسم، لذلك سينتج عن الاستدعاءات المختلفة لضم append الحقول حقولًا لها نفس الاسم. وسنجد التابع set الذي له صيغة append نفسها، لكنه يزيل جميع الحقول التي لها اسم محدد name، ثم يضيف الحقل الجديد، وبالتالي سنضمن وجود حقل وحيد بالاسم name، تشبه باقي التفاصيل التابع append. (formData.set(name, value (formData.set(name, blob, fileName. يمكن أيضًا إجراء تعداد على عناصر الكائن FormData باستخدام الحلقة for..of: let formData = new FormData(); formData.append('key1', 'value1'); formData.append('key2', 'value2'); // قائمة من الأزواج مفتاح/قيمة for(let [name, value] of formData) { alert(`${name} = ${value}`); // key1 = value1, then key2 = value2 } See the Pen JS-P3-FormData-ex02 by Hsoub (@Hsoub) on CodePen. إرسال نموذج مع ملف يُرسَل النموذج دائمًا بحيث تكون ترويسة المحتوى مثل التالي Content-Type: multipart/form-data، وتسمح هذه الطريقة في الترميز بإرسال الملفات، أي ستُرسَل الملفات التي يحددها العنصر <"input type="file> أيضًا بشكل مشابه للإرسال الاعتيادي للنماذج، إليك مثالًا عن ذلك: <form id="formElem"> <input type="text" name="firstName" value="John"> Picture: <input type="file" name="picture" accept="image/*"> <input type="submit"> </form> <script> formElem.onsubmit = async (e) => { e.preventDefault(); let response = await fetch('https://javascript.info/article/formdata/post/user-avatar', { method: 'POST', body: new FormData(formElem) }); let result = await response.json(); alert(result.message); }; </script> ستظهر النتيجة كالتالي: See the Pen JS-P3-FormData-ex03 by Hsoub (@Hsoub) on CodePen. إرسال ملف يحتوي على كائن بيانات ثنائية يمكن أن نرسل بيانات ثنائيةً مولّدةً تلقائيًا، مثل الصور على شكل كائن بيانات Blob، وبالتالي يمكن تمريره مباشرةً مثل المعامل body للدالة Fetch كما رأينا في الفقرة السابقة، ومن الأنسب عمليًا إرسال صورة لتكون جزءًا من نموذج له حقول وبيانات وصفية Metadata وليس بشكل منفصل، إذ يُلائم الخوادم عادةً استقبال نماذج مشفرة مكونة من أجزاء متعددة أكثر من بيانات ثنائية خام. يُرسل المثال التالي صورةً مرسومةً ضمن العنصر <canvas>، بالإضافة إلى بعض الحقول على شكل نموذج باستخدام الكائن FormData: <body style="margin:0"> <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas> <input type="button" value="Submit" onclick="submit()"> <script> canvasElem.onmousemove = function(e) { let ctx = canvasElem.getContext('2d'); ctx.lineTo(e.clientX, e.clientY); ctx.stroke(); }; async function submit() { let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); let formData = new FormData(); formData.append("firstName", "John"); formData.append("image", imageBlob, "image.png"); let response = await fetch('https://javascript.info/article/formdata/post/image-form', { method: 'POST', body: formData }); let result = await response.json(); alert(result.message); } </script> </body> وتظهر النتيجة كالتالي: See the Pen JS-P3-FormData-ex04 by Hsoub (@Hsoub) on CodePen. لاحظ كيف يُضاف الكائن Blob الذي يمثل الصورة: formData.append("image", imageBlob, "image.png"); وهذا الأسلوب مشابه لاستخدام العنصر ضمن نموذج، حيث يرسل الزائر الملف الذي يحمل الاسم "image.png" (الوسيط الثالث) والذي يحمل البيانات التي يحددها imageBlob (الوسيط الثاني) انطلاقًا من منظومة الملفات، ويقرأ الخادم بيانات النموذج وكذلك الملف كما لو أنها عملية إرسال نموذج اعتيادية. خلاصة يتكون طلب إحضار بيانات تقليدي من استدعاءين باستخدام الصيغة await: let response = await fetch(url, options); // يُنفَّذ مع ترويسة الاستجابة headers let result = await response.json(); // JSON قراءة جسم الطلب بتنسيق أو دون الصيغة await: fetch(url, options) .then(response => response.json()) .then(result => /* process result */) وتتمثل خصائص الاستجابة في الآتي: response.status: رمز حالة HTTP للاستجابة. response.ok: يأخذ القيمة "true" إذا كانت قيمة رمز الحالة بين 200-299. response.headers: تعيد كائنًا شبيهًا بالترابط Map يضم ترويسات HTTP. توابع الحصول على جسم الاستجابة: ()response.text: لقراءة الاستجابة وإعادة نص. ()response.json: يفسر النص وفق تنسيق JSON. ()response.formData: يعيد الاستجابة على شكل كائن FormData. ()response.blob: تعيد الاستجابة على شكل كائن بيانات ثنائية Blob. ()response.arrayBuffer: يعيد الاستجابة على شكل كائن ArrayBuffer وهو تمثيل منخفض المستوى للبيانات الثنائية. خيارات Fetch التي تعرفنا عليها حتى الآن: method: نوع طلب HTTP. headers: كائن يضم ترويسات الطلب، ويجب الانتباه إلى الترويسات التي يُمنع استخدامها. body: البيانات التي ستُرسل (جسم الطلب) على شكل string أو FormData أو BufferSource أو Blob أو UrlSearchParams. وسنتعرف على خيارات أخرى في الفصل التالي. تُستخدم الكائنات FormData للتحكم بنماذج وإرسالها باستخدام fetch أو أي دوال لإرسال الطلبات عبر الشبكة، ويمكن إنشاؤها بالأمر (new FormData(form انطلاقًا من نموذج HTML موجود، أو إنشاؤها دون نموذج ثم نضيف إليه الحقول باستخدام التوابع: (formData.append(name, value (formData.append(name, blob, fileName (formData.set(name, value (formData.set(name, blob, fileName لاحظ هاتين الميزتين: يزيل التابع set الحقول التي لها نفس الاسم، بينما لا يفعل ذلك التابع append، وهذا هو الاختلاف الوحيد بينهما. لا بدّ من استخدام صيغة تضم ثلاثة وسطاء لإرسال الملف، آخرها اسم الملف والذي يؤخذ عادةً من منظومة ملفات المستخدم من خلال العنصر <"input type="file>. من التوابع الأخرى: (formData.delete(name (formData.get(name (formData.has(name تمارين إحضار بيانات مستخدمين من GitHub أنشئ دالةً غير متزامنة "async" باسم (getUsers(names للحصول على مصفوفة من سجلات الدخول Logins على GitHub، وتحضر المستخدمين أيضًا، ثم تعيد مصفوفةً بأسماء المستخدمين على GitHub. سيكون العنوان الذي يحوي معلومات مستخدم معين له اسم مستخدم محدد USERNAME هو: api.github.com/users/USERNAME. ستجد مثالًا تجريبيًا وضعناه في نمط الحماية sandbox يوضح ذلك. تفاصيل مهمة: ينبغي أن يكون هناك طلب إحضار واحد لكل مستخدم. لا ينبغي أن ينتظر أي طلب انتهاء طلب آخر، لكي تصل البيانات بالسرعة الممكنة. في حال إخفاق أي طلب، أو عدم وجود مستخدم بالاسم المُعطى، فينبغي أن تعيد الدالة القيمة "null" في المصفوفة الناتجة. افتح التمرين في بيئة تجريبية الحل إليك حل التمرين: نفِّذ التعليمة التالية لإحضار مستخدم: fetch('https://api.github.com/users/USERNAME') استدع التابع ()json. لقراءة الكائن JS، إن كان رمز الحالة المرافق لاستجابة الخادم هو 200. في الحالة التي تخفق فيها تعليمة الإحضار fetch أو لم يكن رمز الحالة 200، أعد القيمة null في المصفوفة الناتجة. إليك الشيفرة: async function getUsers(names) { let jobs = []; for(let name of names) { let job = fetch(`https://api.github.com/users/${name}`).then( successResponse => { if (successResponse.status != 200) { return null; } else { return successResponse.json(); } }, failResponse => { return null; } ); jobs.push(job); } let results = await Promise.all(jobs); return results; } ملاحظة: يرتبط استدعاء التابع then. بمباشرة بالدالة fetch، وبالتالي لا تنتظر عمليات إحضار أخرى لتنتهي عندما تصلك الاستجابة على أحدها بل إبدأ بقراءة الاستجابة مستخدمًا ()json.. إن استخدمت الشيفرة التالية : await Promise.all(names.map(name => fetch(...))) ثم استدعيت ()json. لقراءة النتائج، فقد يكون عليك الانتظار لتنتهي جميع عمليات الإحضار. ستضمن قراءة نتيجة كل عملية إحضار بمفردها إن استخدمت مباشرة ()json. مع fetch. فما عرضناه كان مثالًا عن فائدة واجهة الوعود البرمجية منخفضة المستوى low-level Promise API حتى لو استخدمنا async/await. إليك الحل في بيئة تجريبية مع الاختبارات ترجمة -وبتصرف- للفصلين popups and window methods و FormData من سلسلة The Modern JavaScript Tutorial
  20. يمثل ArrayBuffer جزءًا من معيار "ECMA"، وهو جزء من جافاسكريبت JavaScript، لكن توجد كائنات عالية المستوى ضمن المتصفح وُصِفت في الواجهة البرمجية الخاصة بالملفات File API وبالتحديد الكائن Blob، والذي يتألف من نص افتراضي هو type (من النوع متعدد الوسائط MIME عادةً)، بالإضافة إلى الوسيط blobParts وهو سلسلة من كائنات Blob أخرى ونصوص ومصدر للبيانات الثنائية BufferSource. تأخذ الدالة البانية الصيغة التالية: new Blob(blobParts, options); حيث: blobParts: هو مصفوفة قيمها كائنات Blob وBufferSource وString. options: ويتضمن كائنات اختياريةً هي: type: يمثل نوع الكائن Blob، وهو عادةً من النوع متعدد الوسائط MIME مثل: "image/png". endings: ويحدد إن كنا سنحوّل محرف نهاية السطر للكائن Blob بما يناسب نظام التشغيل الحالي (n\ أو \r\n\)، وسيأخذ افتراضيًا القيمة "transparent" أي لا تفعل شيئًا، وقد يأخذ القيمة "native" أي أَجرِ التحويل. إليك المثال التالي: // (blob) إنشاء كائن بيانات ثنائية من نص let blob = new Blob(["<html>…</html>"], {type: 'text/html'}); // لاحظ أن الوسيط الأول هو مصفوفة // إنشاء كائن بيانات ثنائية من نص ومصفوفة let hello = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" بالصيغة الثنائية let blob = new Blob([hello, ' ', 'world'], {type: 'text/plain'}); يمكن استخراج الشرائح المكونة للكائن Blob كالتالي: blob.slice([byteStart], [byteEnd], [contentType]); حيث: byteStart: بايت البداية وافتراضيًا هو البايت 0. byteEnd: البايت الأخير (ضمنًا، وافتراضيًا حتى آخر المخزن). contentType: نوع كائن blob الجديد، وسيكون افتراضيًا نفس نوع مصدر البيانات. حيث تشابه هذه الوسائط مقابلاتها في التابع array.slice، ويُسمح باستخدام القيم السالبة. الكائن Blob كعنوان لمورد URL يمكن استخدام الكائن Blob مثل عناوين للرابط التشعبي <a> ولمعرّف الصورة <img> لإظهار محتوياتهما بفضل الوسيط type، كما يمكن رفع أو تنزيل الكائنات Blob، وسيتحول النوع type بالطبع إلى Content-Type في طلبات الشبكة network requests. لنبدأ بالمثال البسيط التالي، فبالنقر على الرابط سينزل كائن Blob مولَّد آليًا يحتوي على النص "Hello world" ضمن ملف: <!-- download attribute forces the browser to download instead of navigating --> <a download="hello.txt" href='#' id="link">Download</a> <script> let blob = new Blob(["Hello, world!"], {type: 'text/plain'}); link.href = URL.createObjectURL(blob); </script> See the Pen JS-P3-01-Blob-ex1 by Hsoub (@Hsoub) on CodePen. بالإمكان أيضًا إنشاء رابط آليًا في JavaScript، ومن ثم محاكاة عملية النقر بزر الفأرة ()link.click وسيبدأ بعدها التنزيل آليًا، إليك الشيفرة التالية التي تسمح للمستخدم بتنزيل كائن Blob المولَّد آليًا دون أي شيفرة HTML: let link = document.createElement('a'); link.download = 'hello.txt'; let blob = new Blob(['Hello, world!'], {type: 'text/plain'}); link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); See the Pen JS-P3-01-Blob-ex2 by Hsoub (@Hsoub) on CodePen. يأخذ التابع URL.createObjectURL الكائن Blob مثل وسيط وينشئ عنوانًا له على الشكل <blob:<origin>/<uuid. ستبدو قيمة الخاصية link.href كالتالي: blob:https://javascript.info/1e67e00e-860d-40a5-89ae-6ab0cbee6273 يُخزِّن المتصفح كل عنوان يولّده التابع URL.createObjectURL على شكل ارتباط map داخلي من الشكل "URL>Blob"، لذا ستكون هذه العناوين قصيرةً، لكنها تسمح بالوصول إلى الكائن Blob، وسيكون العنوان المولَّد -وبالتالي الرابط المتعلق به- صالحًا ضمن المستند الحالي طالما كان مفتوحًا، كما سيسمح بتحديد مرجع للكائن في كل من <img> و<a> وغيرهما من الكائنات التي تحتاج إلى عنوان URL، ومع ذلك هنالك أثر جانبي، فعلى الرغم من ارتباط Blob بالعنوان فهذا الكائن مقيم في الذاكرة، ولا يمكن للمتصفح تحرير الذاكرة المتعلقة به، وسيُزال الارتباط كليًا عند إنهاء المستند، وبالتالي ستتحرر الذاكرة المرتبطة بالكائنات Blob، لكن إن استمر التطبيق لفترة طويلة فلن تحدث هذه العملية خلال فترة وجيزة. إذا أنشأنا عنوانًا فسيبقى الكائن Blob مقيمًا في الذاكرة حتى لو لم تَعُد هناك حاجة له. يزيل التابع (URL.revokeObjectURL(url المرجع من علاقة الارتباط الداخلي بين الكائن Blob والعنوان متيحًا المجال لإزالته (إن لم يرتبط بمراجع أخرى) وتحرير الذاكرة، وقد حرصنا في المثال الأخير على استخدام Blob مرةً واحدةً للتنزيل الفوري، لذلك سنستدعي مباشرةً الآتي: URL.revokeObjectURL(link.href) لم نستدع التابع (URL.revokeObjectURL(link.href في مثالنا السابق الذي تضمن رابط HTML قابلًا للنقر، مما سيجعل الكائن Blob غير صالح، ولن يعمل العنوان بعد إلغاء الارتباط المرجعي. التحويل بين Blob ونص مشفر بطريقة base64 يمكن أن نحوّل الكائن Blob إلى سلسلة نصية بتشفير base64 لتكون يمثابة وسيلة بديلة لاستخدام التابع URL.createObjectURL، يمثل التشفير السابق البيانات الثنائية مثل نص يتكون من محارف ASCII من 0 حتى 64 قابلة للقراءة، ويتمتع بحيز أمان كبير جدًا، والأهم من ذلك إمكانية استخدام هذا التشفير مع عناوين موارد البيانات data urls، والتي لها الشكل: <data:[<mediatype>][;base64],<data يمكن استخدام هذه العناوين في أي مكان مثل العناوين النظامية. إليك طريقة تمثيل بيانات تعطي وجهًا تعبيريًا Smiley: <img src="data:image/png;base64,R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7"> حيث سيفكك المتصفح شيفرة البيانات ويعطي كنتيجة الوجه التعبيري التالي: نستخدم الكائن FileReader المدمج لتحويل Blob إلى base64، حيث يستطيع قراءة البيانات من Blob بصيغ مختلفة، وسنتعمق في هذا الموضوع أكثر في الفصل القادم. إليك نموذجًا عن تنزيل Blob من خلال التشفير base64 هذه المرة: let link = document.createElement('a'); link.download = 'hello.txt'; let blob = new Blob(['Hello, world!'], {type: 'text/plain'}); let reader = new FileReader(); reader.readAsDataURL(blob); // استدعاء التحويل reader.onload = function() { link.href = reader.result; // عنوان بيانات link.click(); }; See the Pen JS-P3-01-Blob-ex3 by Hsoub (@Hsoub) on CodePen. يمكن استخدام إحدى الطريقتين السابقتين لتحويل كائن Blob إلى عنوان، لكن تكون عادةً الطريقة (URL.createObjectURL(blob أبسط وأسرع. table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } Blob to data url (URL.createObjectURL(blob لا حاجة لإزالة أي شيء لابد من إزالتها إذا كنا نهتم بمقدار الذاكرة المحجوزة خسارة في الأداء والذاكرة لكائنات Blob الضخمة عند الترميز وصول مباشر إلى الكائن Blob، لا حاجة للترميز وفك الترميز تحويل الصورة إلى كائن Blob يمكن تحويل صورة أو جزء من صورة أو لقطة شاشة إلى كائن بيانات ثنائية Blob، وهذا مفيد عند تحميل هذه الصور إلى مكان ما، وتُنفّذ العمليات على الصور باستخدام العنصر <canvas>: ارسم صورةً أو جزءًا منها ضمن لوحة الرسم Canvas باستخدام التابع canvas.drawImage. استدع التابع (toBlob(callback, format, quality الذي يُنشئ وينفّذ استدعاءً محددًا عندما يكتمل. ستجد في المثال التالي صورةً نُسخت للتو، لكن يمكن اقتطاع جزء منها أو نقلها إلى لوحة رسم قبل إنشاء Blob: // خذ أية صورة let img = document.querySelector('img'); // شكّل لوحة رسم بنفس الحجم let canvas = document.createElement('canvas'); canvas.width = img.clientWidth; canvas.height = img.clientHeight; let context = canvas.getContext('2d'); // انسخ الصورة إلى اللوحة context.drawImage(img, 0, 0); // يمكن أن نعدل اللوحة كما نشاء // عملية التحويل إلى كائن ثنائي غير متزامنة canvas.toBlob(function(blob) { //الكائن جاهز سننزله let link = document.createElement('a'); link.download = 'example.png'; link.href = URL.createObjectURL(blob); link.click(); // امسح المرجع الداخلي للكائن حتى يتمكن Canvas المتصفح من إزالته URL.revokeObjectURL(link.href); }, 'image/png'); See the Pen JS-P3-01-The-clickjacking-attack-ex7 by Hsoub (@Hsoub) on CodePen. يمكن استخدام الصيغة async/await بدلًا من دوال الاستدعاء: let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); يمكن استخدام مكتبات خاصة لالتقاط صورة للشاشة، وما عليك إلا الانتقال ضمن الصفحة ورسمها ضمن لوحة <canvas>، ثم يمكن نقلها بعد ذلك إلى Blob بنفس الأسلوب السابق. التحويل من الكائن Blob إلى الكائن arrayBuffer تسمح الدالة البانية للكائن Blob بإنشاء هذا الكائن من أي شيء تقريبًا، بما في ذلك أية كائنات BufferSource، لكن لو أردنا إنجاز عملية معالجة منخفضة المستوى، فيمكننا الحصول على كائن ArrayBuffer ذو مستوى أدنى مستخدمين FileReader: // get arrayBuffer from blob let fileReader = new FileReader(); fileReader.readAsArrayBuffer(blob); fileReader.onload = function(event) { let arrayBuffer = fileReader.result; }; خلاصة تمثل الكائنات ArrayBuffer وUint8Array وغيرها من الكائنات التي تنضوي تحت المصطلح BufferSource بيانات ثنائيةً، بينما يمثل Blob بيانات ثنائيةً لها نوع، مما يجعل الكائن Blob مناسبًا لعمليات الرفع والتنزيل التي تستخدم بكثرة من المتصفح. يمكن للتوابع التي تُنفِّذ طلبات الويب web-requests مثل: XMLHttpRequest وfetch وغيرها؛ أن تعمل مع Blob كما تعمل مع غيره من أنواع البيانات الثنائية. يمكن التحويل بسهولة بين Blob وكائنات البيانات الثنائية منخفضة المستوى: يمكن التحويل بين Blob ومصفوفة النوع باستخدام الدالة البانية (...)new Blob يمكن الحصول على الكائن ArrayBuffer من الكائن Blob باستخدام التعليمة FileReader، ثم إنشاء كائن استعراض بناءً على هذا الأخير، وذلك لمعالجة البيانات الثنائية في مستويات عمل منخفضة. ترجمة -وبتصرف- للفصل Blob من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
  21. ملفات تعربف الارتباط هي بيانات نصية تُخزَّن مباشرةً في ذاكرة المتصفح، وهي في الواقع جزء من بروتوكول HTTP تُعرِّفها المواصفات RFC 6265، ويضبط الخادم عادةً هذه الملفات مستخدمًا ترويسة الاستجابة Set-Cookie، ثم يضيفها المتصفح إلى كل طلب -تقريبًا- يُرسل إلى النطاق ذاته مستخدمًا الترويسة Cookie. يُعَد الاستيثاق authentication أكثر الحالات شيوعًا لاستعمال ملفات تعريف الارتبابط، وذلك بسبب ما يلي: يستخدم الخادم الترويسة Set-Cookie في الاستجابة أثناء تسجيل الدخول لإعداد ملف تعريف ارتباط له مُعرِّف جلسة عمل session فريد. عند إرسال الطلب في المرة القادمة إلى نفس النطاق، سيُرسل المتصفح ملف تعريف الارتباط عبر الشبكة باستخدام الترويسة Cookie. وبالتالي سيعلم الخادم الجهة التي أرسلت الطلب. يمكن الوصول إلى ملفات تعريف الارتباط من المتصفح باستخدام الخاصية document.cookie، وستواجهك الكثير من النقاط المربكة عند استخدام ملفات تعريف الارتباط والخيارات المتعلقة بها، وسنغطي هذه النقاط بالتفصيل في هذا المقال. القراءة من الخاصية document.cookie لتعرف إذا ما كان متصفحك يُخزِّن أي ملفات تعريف ارتباط عائدة لموقع ما، استخدم الأمر: alert( document.cookie ); // cookie1=value1; cookie2=value2;... تتكون قيمة الخاصية document.cookie من أزواج name=value تفصل بينها فاصلة منقوطة ";"، حيث يمثل كل منها ملف تعريف ارتباط منفصل. لإيجاد ملف تعريف ارتباط معين يمكن فصل قيمة الخاصية document.cookie بالاستفادة من الفاصلة المنقوطة ";"، ثم إيجاد الاسم المطلوب، وننفذ ذلك باستخدام تعابير برمجية نظامية أو مصفوفة دوال، وسنترك ذلك تمرينًا للقارئ، كما ستجد في نهاية المقال دوالًا مساعدةً للتعامل مع ملفات تعريف الارتباط. الكتابة إلى الخاصية document.cookie يمكن أن نكتب قيمًا ضمن الخاصية document.cookie، وهي ليست خاصية بيانات، وإنما هي أشبه بدالة وصول accessor (ضابط setter/ جالب getter)، لذا لا بدّ من التعامل معها بخصوصية. يضبط الاستدعاء التالي مثلًا ملف تعريف ارتباط بالاسم user على القيمة John: document.cookie = "user=John"; alert(document.cookie); ستشاهد عند تنفيذ الشيفرة السابقة ملفات ارتباط عدةً غالبًا، لأن الأمر = document.cookie يغيِّر قيمة ملف الارتباط الذي يحمل الاسم user فقط. يمكن أن يتألف الاسم أو القيمة تقنيًا من أي محارف، لكن للإبقاء على تنسيق قابل للقراءة، ينبغي ترميز المحارف الخاصة باستخدام الدالة المدمجة encodeURIComponent: // ينبغي ترميز المحارف الخاصة let name = "my name"; let value = "John Smith" // encodes the cookie as my%20name=John%20Smith document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value); alert(document.cookie); // ...; my%20name=John%20Smith لملفات الارتباط خيارات عدة، وينبغي ضبط بعضها لأهميته، حيث توضع هذه الخيارات بعد الأزواج key=value، وتفصل بينها فواصل منقوطة ";": document.cookie = "user=John; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT" الخيار path path=/mypath: ينبغي أن يكون مسار العنوان مُطلقًا absolute، بحيث يمكن الوصول إلى ملفات الارتباط ضمن جميع الصفحات التي تقع تحت هذا المسار، أما القيمة الافتراضية فهي "المسار الحالي"، فلو كان المسار path=/admin، فستكون الملفات مرئيةً في الصفحة الحالية admin وفي أي صفحة تحتها admin/something/، لكن ليس في صفحات مثل home/ أو adminpage/، ويُضبط هذا الخيار عادةً على القيمة /=path لتتمكن كل صفحات الموقع من الوصول إلى ملفات الارتباط. الخيار domain domain=site.com: يحدد هذا الخيار المكان الذي يمكن الوصول منه إلى ملفات الارتباط، لكن لن نتمكن عمليًا من تحديد أي نطاقات نريد، إذ لا يمكن الوصول إلى ملفات الارتباط افتراضيًا سوى من النطاق الذي أعدّها، فلو أعد الموقع hsoub.com ملفات الارتباط، فلن يتمكن الموقع site.com من الوصول إليها، لكن الأمر المربك فعلًا، هو أننا لن نصل إلى ملفات الارتباط ضمن النطاقات الفرعية مثل academy.hsoub.com!. // hsoub.com في الموقع document.cookie = "user=John" // academy.hsoub.com في الموقع alert(document.cookie); // no user لا توجد طريقة تُمكِّن نطاقًا من المستوى الثاني من الوصول إلى ملفات الارتباط التي يُعدّها نطاق آخر، إذ تمنعنا أمور أمنية من تخزين بيانات حساسة لا يجب أن تظهر سوى في الموقع داخل ملفات الارتباط، لكن يمكن -إن أردنا- السماح للنطاق الفرعي academy.hsoub.com بالوصول إلى ملفات الارتباط، وذلك بضبط الخيار domain عند إعداد ملف ارتباط في النطاق hsoub.com، على قيمة النطاق الجذر domain=hsoub.com: // at hsoub.com // الوصول لملف الارتباط من النطاقات الفرعية // make the cookie accessible on any subdomain *.hsoub.com: document.cookie = "user=John; domain=hsoub.com" // لاحقًا // academy.hsoub.com في alert(document.cookie); // has cookie user=John يعمل الأمر domain=.hsoub.com -لاحظ النقطة قبل اسم النطاق- بالطريقة ذاتها لأسباب تاريخية، حيث سيسمح بالوصول إلى ملفات الارتباط من النطاقات الفرعية، ولا بدّ من استخدام هذا الأسلوب القديم إذا أردنا دعم المتصفحات القديمة. إذًا سيسمح لنا الخيار domain بجعل النطاقات الفرعية قادرةً على الوصول إلى ملفات الارتباط. الخياران expire و max-age إذا لم يتضمن ملف الارتباط أيًا من هذين الخيارين، فسيختفي الملف افتراضيًا مع إغلاق المتصفح، ويُدعى هذا النوع من ملفات الارتباط "ملفات ارتباط جلسة العمل"، ولنحافظ على ملف الارتباط بعد إغلاق المتصفح فلابدّ من ضبط أحد الخيارين expires أو max-age، وإليك مثالًا عن ذلك: expires=Tue, 19 Jan 2038 03:14:07 GMT يحدد تاريخ انتهاء الصلاحية الوقت الذي سيحذف فيه المتصفح ملف الارتباط، وينبغي أن يكون التاريخ بالتنسيق السابق تمامًا ووفق توقيت GMT، ويمكن أن نستخدم الدالة date.toUTCString للحصول على التاريخ بهذا التنسيق، حيث يمكن على سبيل المثال ضبط فترة انتهاء الصلاحية بعد يوم كامل: //بعد يوم من الآن let date = new Date(Date.now() + 86400e3); date = date.toUTCString(); document.cookie = "user=John; expires=" + date; سيُحذف الملف إذا ضبطنا قيمة الخيار expires على قيمة في الماضي. max-age=3600: يمثل الخيار max-age بديلًا عن expires، ويعبّر عن زمن انتهاء صلاحية ملف الارتباط بالثواني ابتداءً من اللحظة الحالية، وسيُحذف ملف الارتباط إذا ضُبط على القيمة صفر أو أي قيمة سالبة. // سيحذف الملف بعد ساعة document.cookie = "user=John; max-age=3600"; // سيحذف الملف مباشرة document.cookie = "user=John; max-age=0"; الخيار secure secure: يجب نقل ملفات الارتباط من خلال بروتوكول HTTPS فقط. لو أعددنا ملف ارتباط ضمن الموقع http://hsoub.com، فسيظهر افتراضيًا أيضًا ضمن الموقع https://hsoub.com، والعكس بالعكس، فملفات الارتباط متعلقة بالنطاقات ولا تميّز بين البروتوكولات، لكن يمكن تفادي ذلك باستخدام الخيار secure، إذ لن تظهر ملفات الارتباط التي يُعدها النطاق https://hsoub.com ضمن النطاق http://hsoub.com، لأن استخدام هذا الخيار مهم في منع نقل البيانات الحساسة عبر بروتوكول HTTP غير المشفر. // https:// بافتراض أننا حاليًا في // ضبط ملف الارتباط ليكون آمنًا document.cookie = "user=John; secure"; الخيار samesite وهو خيار من خيارات الأمان، وقد صُمم للحماية ضد الهجوم المعروف باسم "تزوير الطلبات ما بين الصفحات cross-site request forgery" أو اختصارًا XSFR، ولفهم الآلية التي يعمل بها هذا الخيار، ومتى يكون مفيدًا لنلق نظرةً على الهجوم XSFR. الهجوم XSFR لنفترض أنك سجلت دخولك إلى الموقع bank.com، وبالتالي ستحصل على ملف ارتباط استيثاق من هذا الموقع، وسيرسل متصفحك هذا الملف إلى الموقع مع كل طلب حتى يميّزك وينفذ لك عمليات ماليةً حساسةً مثلًا، ولنتخيل الآن أنك تتصفح الشابكة في نافذة أخرى ووصل بك الأمر إلى الموقع المشبوه evil.com، الذي يمتلك شيفرة JavaScript. ترسل إلى الموقع bank.com النموذج <form action="https://bank.com/pay"> الذي يحتوي حقولًا تهيئ لعملية تحويل مالي إلى حساب المخترق، وسيرسل المتصفح ملف الارتباط في كل مرة تزور فيها الموقع bank.com، حتى لو أُرسل النموذج من الموقع evil.com، وهكذا سيميُّزك البنك وينفذ عملية الدفع؛ هذا هو الهجوم XSFR. طبعًا تحمي البنوك الحقيقية نفسها من هذا الهجوم، فجميع النماذج التي يولّدها الموقع bank.com تحتوي على حقل مميز يدعى مفتاح الحماية المشفّر protection token من هجوم XSRF، والتي لا يمكن للصفحات المشبوهة توليدها أو استخلاصها من صفحة أخرى، وبالتالي ستستطيع هذه الصفحات إرسال النماذج، لكن لا يمكن أن تحصل على بيانات من المواقع المستهدفة، وسيتحقق الموقع bank.com من وجود مفتاح الحماية المشفر في كل نموذج يتلقاه. تستغرق طريقة الحماية هذه وقتًا لإنجازها، إذ يجب التحقق من وجود المفتاح في كل نموذج، كما ينبغي التحقق من كل طلب. استخدام الخيار samsite يؤمّن هذا الخيار طريقةً للحماية من الهجوم السابق، ولا يتطلب -نظريًا- استخدام مفتاح الحماية المشفر، ولهذا الخيار قيمتان، هما: samesite=strict: وهو مشابه لاستخدام الخيار بلا قيمة، حيث لن يُرسل ملف الارتباط -عند استخدام هذه القيمة- إذا لم يكن المستخدم ضمن النطاق نفسه. وبعبارة أخرى، لن تُرسل ملفات الارتباط سواءً تبع المستخدم رابطًا ضمن بريده الإلكتروني أم أرسل نموذجًا من موقع مشبوه أو نفّذ عمليةً خارج النطاق. عندما يكون لملف ارتباط الاستيثاق الخيار samesite، فلن تكون هناك فرصة لتحقيق هجوم XSRF، لأن النموذج الذي يرسله موقع مشبوه evil.com سيأتي دون ملفات ارتباط، وبالتالي لن يميز الموقع bank.com المستخدم ولن يتابع عملية الدفع، كما لن ترسل ملفات الارتباط فعليًا في هذه الحالة. يمكن الاعتماد على أسلوب الحماية هذا، لكن مع ذلك يمكن أن نقلق، فعندما يتتبع المستخدم رابطًا مشروعًا إلى الموقع bank.com -مثل أن يكون ضمن الملاحظات التي يوّلدها الموقع-؛ سيتفاجأ المستخدم أن الموقع لن يميّزه، ويمكن أن نتخطى هذا الأمر باستخدام نوعين من ملفات الارتباط، حيث يُستخدم أحدهما لتمييز المستخدم لأغراض عامة مثل أن تلقي التحية عليه "مرحبًا أحمد"، أما النوع الآخر فيُستخدم لعمليات تغيير البيانات وسيُزود بالخيار samesite=strict، بهذا سيرى المستخدم الذي يصل إلى الموقع bank.com من موقع آخر رسالة ترحيب، لكن العمليات المالية لن تُنفَّذ إلا من الموقع نفسه، بعد إرسال ملف الارتباط الثاني. samesite=lax: وهو خيار أقل تشددًا، يحمي من هجوم XSRF ولا يهدد تجربة المستخدم، حيث يمنع المتصفح من إرسال ملفات الارتباط التي تأتي من خارج الموقع، لكنه يضيف بعض الاستثناءات. تُرسل ملفات الارتباط ذات الخيار samesite=lax إذا تحقق الشرطان التاليان: نوع طلب HTTP آمن (GET مثلًا وليس POST)، وستجد قائمةً كاملةً بطلبات HTTP الآمنة في التوصيفات RFC7231 specification، لكن مبدئيًا، ستستخدم هذه الطلبات في قراءة البيانات وليس في كتابتها، فلا ينبغي لها تنفيذ عمليات لتغيير البيانات، ويُتبع الرابط دائمًا بطلب آمن من النوع GET. أن تُنفِّذ العملية انتقالًا إلى عنوان عالي المستوى (تُغيِّر العنوان في شريط عناوين المتصفح)، وهو أمر محقق عادةً، لكن إذا جرى الانتقال إلى عنوان من خلال نافذة ضمنية <iframe> فلن يكون انتقالًا عالي المستوى، ولا تنفذ طلبات JavaScript عبر الشبكة أي تنقلات كونها لن تتناسب مع هذه العملية. لذلك نرى أن كل ما يفعله الخيار samesite=lax هو السماح بامتلاك عمليات التنقل الأساسية ملفات ارتباط، مثل عمليات الانتقال من ملاحظة يولدها موقع إلى الموقع ذاته، فهي تحقق الشرطين السابقين، لكن ستفقد العمليات الأكثر تعقيدًا -مثل طلبات الشبكة بين موقعين مختلفين- ملفات الارتباط، لذا استخدم هذا الخيار إن كان الأمر مناسبًا لك، إذ لن يؤثر ذلك غالبًا على تجربة المستخدم وسيؤمن لك الحماية. وعلى الرغم من أن استخدام الخيار samesite أمر جيد، لكنك ستواجه العائق المهم الذي يتمحور حول تجاهل المتصفحات القديمة -2017 وما قبلها- لهذا الخيار، بحيث ستخترَق المتصفحات القديمة إذا اعتمدت على هذه الطريقة في الحماية فقط. لكن يمكننا استخدام samesite مع معايير حماية أخرى، مثل مفاتيح الحماية المشفرة من هجمات xsrf، وبذلك سنضيف طبقة حماية جديدة، وبالتالي سنتمكن غالبًا من تفادي هجمات xsrf حين يتوقف دعم المتصفحات القديمة مستقبلًا. الخيار httpOnly ليس لهذا الخيار علاقة بلغة JavaScript، لكن لا بدّ من ذكره حرصًا على اكتمال الصورة. يُستخدم خادم الويب الترويسة Set-Cookie لإعداد ملف الارتباط، كما يمكن للخادم استخدام الخيار httpOnly الذي يمنع أي شيفرة JavaScript من الوصول إلى ملف الارتباط، إذ لا يمكن عندها رؤية ملف الارتباط أو التعامل معه باستعمال الأمر document.cookie، ويستعمل هذا الخيار عاملًا احترازيًا لحماية المستخدم من هجمات معينة، يدفع فيها المخترق شيفرة JavaScript إلى صفحة ويب وينتظر الضحية ليزورها، وبالطبع لا ينبغي أن يتمكن المخترقون من دفع شيفراتهم إلى موقعنا، لكن قد نترك وراءنا ثغرات تمكنهم من ذلك. وإذا حدث شيء كهذا وزار المستخدم صفحة ويب تحتوي على شيفرة لمخترقين، فستُنفَّذ هذه الشيفرة وسيصل المخترق إلى document.cookie، وبالتالي إلى ملف الارتباط التي يحتوي على معلومات الاستيثاق. لكن إذا استُخدم الخيار httpOnly فلن تتمكن الخاصية document.cookie من رؤية ملف الارتباط، وسيبقى المستخدم محميًا. الدوال التي تتعامل مع ملفات الارتباط سنبين مجموعةً صغيرةً من الدوال التي تتعامل مع ملفات الارتباط، وهي ملاءمة أكثر من التعديلات اليدوية على الخاصية document.cookie، وستجد العديد من المكتبات التي ستساعدك في إنجاز ذلك، لكننا سنستعرض هذه الدوال على سبيل التجربة. الدالة (getCookie(name إنّ الطريقة الأقصر للوصول إلى ملف الارتباط ستكون باستخدام التعابير النظامية. حيث تعيد الدالة (getCookie(name ملف ارتباط باسم محدد name. // تعيد الدالة ملف ارتباط باسم محدد // أو تعيد كائنًا غير محدد إن لم يوجد الملف function getCookie(name) { let matches = document.cookie.match(new RegExp( "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)" )); return matches ? decodeURIComponent(matches[1]) : undefined; } يوّلد الكائن new RegExp ديناميكيًا ليُطابق اسم ملف الارتباط <name=<value. لاحظ أن قيمة ملف الارتباط مرمّزة، لذلك تستخدم الدالة getCookie الدالة decodeURIComponent لفك الترميز. الدالة (setCookie(name, value, options تجعل هذه الدالة القيمة value اسمًا لملف الارتباط name، وتضبط خيار المسار افتراضيًا على القيمة =/path، ويمكن تعديلها لإضافة قيم افتراضية لخيارات أخرى: function setCookie(name, value, options = {}) { options = { path: '/', // إضافة قيم افتراضية أخرى ...options }; if (options.expires instanceof Date) { options.expires = options.expires.toUTCString(); } let updatedCookie = encodeURIComponent(name) + "=" + encodeURIComponent(value); for (let optionKey in options) { updatedCookie += "; " + optionKey; let optionValue = options[optionKey]; if (optionValue !== true) { updatedCookie += "=" + optionValue; } } document.cookie = updatedCookie; } // :مثال الاستخدام setCookie('user', 'John', {secure: true, 'max-age': 3600}); الدالة (deleteCookie(name تُستدعى هذه الدالة بقيم سالبة لخيار فترة الصلاحية max-age: function deleteCookie(name) { setCookie(name, "", { 'max-age': -1 }) } ملفات ارتباط الطرف الثالث Third-party cookie يُعَد ملف الارتباط من "طرف ثالث" إذا أعدّه نطاق مختلف عن الصفحة التي تزورها، فمثلًا: تُحمِّل صفحة من الموقع hsoub.com شريطًا دعائيًا من موقع آخر <img src="https://ads.com/banner.png"‎>. يمكن أن يهيئ الخادم الذي يستضيف الموقع ads.com الترويسة Set-Cookie متضمنةً ملف الارتباط id=1234 مثلًا، وسيكون هذا الملف -الذي يعود أصلًا إلى النطاق ads.com- مرئيًا فقط لصفحات هذا النطاق. عندما ننتقل إلى ads.com في المرة القادمة، سيحصل الخادم على مُعرِّف id ملف الارتباط وسيميِّز المستخدم. سيظهر الأمر الأكثر أهميةً عند انتقال المستخدم من الموقع hsoub.com إلى موقع آخر يحوي شريطًا دعائيًا، إذ سيحصل النطاق ads.com على ملف الارتباط لأنه ينتمي إليه، وبالتالي سيميّز المستخدم وسيتعقبه عند التنقل بين المواقع. استُخدمت ملفات ارتباط الطرف الثالث تقليديًا لخدمات الدعاية وتعقب المستخدمين نظرًا لطبيعتها، إذ ترتبط هذه الملفات بالنطاقات التي أعدَّتها، حيث يمكن للنطاق ads.com أن يتعقب المستخدم ذاته عبر مواقع مختلفة إذا أمكن لهذه المواقع الوصول إلى هذا النطاق. تسمح المتصفحات بتعطيل عمل هذا النوع من ملفات الارتباط لأن المستخدم لا يحب أن يُتتبع عادةً، كما تتبع بعض المتصفحات الحديثة سياسات خاصةً بملفات الارتباط هذه، وهي: لا يسمح المتصفح Safari بملفات ارتباط الطرف الثالث. يأتي المتصفح Firefox بقائمة سوداء تضم مجموعة نطاقات تحجب ملفات الارتباط التي تعود لها. التشريع الأوروبي GDPR لا يتعلق الموضوع باللغة إطلاقًا، لكن انتبه إليه عندما تعمل على إعداد ملفات الارتباط، حيث يجبر تشريع أوروبي يُدعى "GDPR" صفحات الويب على احترام مجموعة من القواعد التي تضمن خصوصية المستخدم، وتتطلب إحدى هذه القواعد إذنًا صريحًا لتتبع ملفات الارتباط العائدة لمستخدم، وتذكر أنه يخص ملفات ارتباط التتبع أو التعريف أو الاستيثاق، فإذا أردنا إعداد ملف ارتباط لحفظ بعض المعلومات -لا لتتبع أو تحديد مستخدم- فلنا كامل الحرية، ولابدّ من إذن صريح من المستخدم في حال تتبعه أو التعرف عليه. تطبق مواقع الويب هذا التشريع بأسلوبين، ولا بدّ أنك لاحظتهما أثناء تصفحك للإنترنت: عندما تحاول صفحة الويب تتبع مستخدمين مستَوثَقين، فلا بدّ عندها أن يحتوي نموذج التسجيل على صندوق تحقق checkbox يحمل عنوانًا مثل "قبول سياسة الخصوصية"، والتي تصف كيفية استخدام ملفات الارتباط، ولا بدّ أن يختاره المستخدم حتى يتابع، عندها ستستخدم الصفحة ملفات ارتباط الاستيثاق بحرية. عندما تحاول صفحة الويب تتبع كل مستخدم فستعرض الصفحة -إن أرادت تنفيذ ذلك بصورة قانونية- شاشة بداية splash screen للقادمين الجدد، وتطلب منهم الموافقة على استخدام ملفات الارتباط. عندها ستتمكن الصفحة من إنشاء ملفات ارتباط، ثم ستسمح للمستخدم بمتابعة محتوى الصفحة. قد يكون الأمر مربكًا للمستخدم، فلا أحد يحب النقر الإجباري على شاشة بداية قبل متابعة المحتوى، لكن التشريع GDPR يتطلب موافقةً صريحةً. لا يتعلق التشريع GDPR بملفات الارتباط، بل بأمور أخرى تتعلق بالخصوصية أيضًا، لكنها خارج إطار موضوعنا. خلاصة توفِّر الخاصية document.cookie وصولًا إلى ملفات الارتباط cookies، مع ملاحظة ما يلي: لا تعدل عمليات الكتابة سوى ملفات الارتباط التي تذكرها الخاصية. لا بدّ من ترميز الزوج "اسم/قيمة" name/value. لا يتجاوز حجم ملف الارتباط عتبة 4 كيلوبايت، ولا يتجاوز عدد ملفات الارتباط 20 ملفًا في الموقع، وفقًا للمتصفح المستخدم. خيارات ملف الارتباط: /=path: قيمته الافتراضية هي المسار الحالي، ويجعل ملف الارتباط مرئيًا فقط تحت المسار المحدد. domain=site.com: يكون ملف الارتباط مرئيًا ضمن النطاق المحدد فقط افتراضيًا، لكن ستكون ملفات الارتباط مرئيةً ضمن النطاقات الفرعية أيضًا إذا صُرّح عن النطاق. expires أو max-age: تحدد زمن انتهاء صلاحية ملف الارتباط، وبدونها يُحذف ملف الارتباط عند إغلاق المتصفح. secure: لا بدّ من استخدام بروتوكول HTTPS حتى تُرى ملفات الارتباط. samesite: يمنع المتصفح من إرسال ملفات الارتباط مع الطلبات الواردة من خارج نطاق الموقع، وسيساعد ذلك في إيقاف هجمات XSRF. إضافةً إلى ذلك: يمكن أن يمنع المتصفح استخدام ملفات الارتباط القادمة من طرف ثالث افتراضيًا مثل المتصفح Safari. ينبغي التقيد بالتشريع GDPR عند إعداد ملفات ارتباط لتتبع مستخدمين من الاتحاد الأوروبي. ترجمة -وبتصرف- للفصل cookies, document.cookies من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: آليات الاتصال المستمر مع الخادم في جافاسكربت الجلسات وملفات تعريف الارتباط ومكتبة cURL في PHP الواجهة البرمجية Fetch API في جافاسكريبت احم موقعك من الاختراق
  22. من السهل رفع ملف باستخدام fetch، لكن كيف سنستأنف عملية الرفع بعد فقدان الاتصال؟ لا توجد خيارات مدمجة لتنفيذ هذا الأمر، لكن لدينا كل الأجزاء التي تمكننا من تنفيذ ذلك. ينبغي أن تأتي عمليات الرفع القابلة للاستئناف مع مؤشرات على تقدم العملية، خاصةً عندما يكون الملف ضخمًا -إن أردنا استئناف الرفع-، وطالما لن تسمح fetch بتعقب عملية الرفع؛ فسنحتاج إلى الكائن XMLHttpRequest. حدث تتبع غير مفيد كفاية لا بدّ من معرفة الكمية التي جرى رفعها قبل انقطاع الاتصال لاستئناف رفع الملف، حيث يمتلك الكائن xhr الحدث xhr.upload.onprogress لتتبع تقدم عملية الرفع، لكنه لن يفيدنا في استئناف الرفع -لسوء الحظ-، لأنه سيقع عند انتهاء إرسال البيانات، لكن لا يمكن للمتصفح تحديد هل استقبلها الخادم فعلًا أم لا، فمن الممكن أن تكون هذه البيانات قد خُزّنت مؤقتًا من قبل شبكة محلية وكيلة، وربما تكون العملية انتهت على الخادم البعيد قبل أن يتمكن من معالجة البيانات، أو أنها ببساطة قد فُقدت في مرحلة ما ولم تصل إلى المستقبل النهائي، لذلك لن تتعدى فائدة هذا الحدث إظهار شريط تقدم جميل على الصفحة. إذًا لا بدّ من معرفة عدد البايتات التي استقبلها الخادم بدقة حتى نتمكن من استئناف الرفع، والخادم فقط هو من يمتلك هذه المعلومة، لذا فلا بدّ من إرسال طلب إضافي. الخوارزمية ننشئ في البداية معرّفًا مميزًا id للملف الذي سنرفعه، بالشكل التالي: let fileId = file.name + '-' + file.size + '-' + file.lastModified; سنحتاج إليه لاستئناف الرفع، إذ لا بدّ من إبلاغ الخادم عن الملف الذي سنستأنف رفعه، حيث إذا تغير اسم أو حجم أو تاريخ آخر تعديل للبيانات، فسيتغيّر معرّف الملف fileId. ثم نرسل إلى الخادم طلبًا نسأله عن حجم البيانات التي وصلت إليه (مقدرًا بالبايت)، بالشكل التالي: let response = await fetch('status', { headers: { 'X-File-Id': fileId } }); // تلقى الخادم هذا العدد من البايتات let startByte = +await response.text(); هذا يفترض قدرة الخادم على تتبع تقدم عملية رفع الملف باستخدام الترويسة X-File-Id، وينبغي إنجاز ذلك في الواجهة الخلفية (من جانب الخادم)، فإذا لم يكن الملف موجودًا بعد على الخادم، فيستجيب الخادم بالرمز 0. يمكننا استخدام التابع slice العائد لكائن البيانات الثنائية Blob لإرسال الملف ابتداءً من البايت الذي سنتابع بعده startByte، بالشكل التالي: xhr.open("POST", "upload", true); // معرّف الملف ليعلم الخادم اسم الملف الذي يُرفع xhr.setRequestHeader('X-File-Id', fileId); // البايت الذي سنستأنف منه الرفع xhr.setRequestHeader('X-Start-Byte', startByte); xhr.upload.onprogress = (e) => { console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`); }; //أو أي مصدر input.files[0] قد يكون مصدر الملف من xhr.send(file.slice(startByte)); وهكذا سنرسل إلى الخادم معرّف الملف عبر الترويسة X-File-Id ليعرف الملف الذي نرفعه، كما سنرسل بايت البداية عبر الترويسة X-Start-Byte ليعرف الخادم بأننا لا نرفع الملف من البداية بل نستأنف عملية رفع سابقة، ولا بدّ أن يتحقق الخادم من سجلاته. فإذا جرت عملية رفع سابقة لهذا الملف وكان حجمه الحالي مساويًا تمامًا لقيمة X-Start-Byte؛ فسيُلحِق البيانات التي يستقبلها تاليًا به. إليك تطبيقًا نموذجيًا يضم شيفرتي الخادم والعميل وقد كتبت باستخدام Node.js، ويمكنك تنزيله وتشغيله محليًا لترى آلية عمله. شيفرة الخادم "server.js": let http = require('http'); let static = require('node-static'); let fileServer = new static.Server('.'); let path = require('path'); let fs = require('fs'); let debug = require('debug')('example:resume-upload'); let uploads = Object.create(null); function onUpload(req, res) { let fileId = req.headers['x-file-id']; let startByte = +req.headers['x-start-byte']; if (!fileId) { res.writeHead(400, "No file id"); res.end(); } // we'll files "nowhere" let filePath = '/dev/null'; // يمكن استخدام مسار حقيقي عوضًا عنه، مثل: // let filePath = path.join('/tmp', fileId); debug("onUpload fileId: ", fileId); // تهيئة عملية رفع جديدة if (!uploads[fileId]) uploads[fileId] = {}; let upload = uploads[fileId]; debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte) let fileStream; // صفرًا أو غير مهيئ فسننشئ ملفًا جديدًا، وإلا فسيختبر الحجم ويضمه إلى الملف الموجود startByte إذا كان if (!startByte) { upload.bytesReceived = 0; fileStream = fs.createWriteStream(filePath, { flags: 'w' }); debug("New file created: " + filePath); } else { // we can check on-disk file size as well to be sure if (upload.bytesReceived != startByte) { res.writeHead(400, "Wrong start byte"); res.end(upload.bytesReceived); return; } // ضمه إلى الملف الموجود fileStream = fs.createWriteStream(filePath, { flags: 'a' }); debug("File reopened: " + filePath); } req.on('data', function(data) { debug("bytes received", upload.bytesReceived); upload.bytesReceived += data.length; }); // إرسال جسم الطلب إلى الملف req.pipe(fileStream); // عندما ينتهي الطلب وتكتَب المعلومات كلها fileStream.on('close', function() { if (upload.bytesReceived == req.headers['x-file-size']) { debug("Upload finished"); delete uploads[fileId]; // يمكننا فعل شيء آخر بالملف المرفوع res.end("Success " + upload.bytesReceived); } else { // فقد الاتصال، نترك الملف غير المكتمل debug("File unfinished, stopped at " + upload.bytesReceived); res.end(); } }); // I/O أنهِ الطلب في حال خطأ fileStream.on('error', function(err) { debug("fileStream error"); res.writeHead(500, "File error"); res.end(); }); } function onStatus(req, res) { let fileId = req.headers['x-file-id']; let upload = uploads[fileId]; debug("onStatus fileId:", fileId, " upload:", upload); if (!upload) { res.end("0") } else { res.end(String(upload.bytesReceived)); } } function accept(req, res) { if (req.url == '/status') { onStatus(req, res); } else if (req.url == '/upload' && req.method == 'POST') { onUpload(req, res); } else { fileServer.serve(req, res); } } // ----------------------------------- if (!module.parent) { http.createServer(accept).listen(8080); console.log('Server listening at port 8080'); } else { exports.accept = accept; } شيفرة العميل "uploader.js": class Uploader { constructor({file, onProgress}) { this.file = file; this.onProgress = onProgress; // أنشئ معرّف الملف // يمكننا إضافة معرّف لجلسة المستخدم (عند وجوده ) لجعله فريدًا أكثر this.fileId = file.name + '-' + file.size + '-' + file.lastModified; } async getUploadedBytes() { let response = await fetch('status', { headers: { 'X-File-Id': this.fileId } }); if (response.status != 200) { throw new Error("Can't get uploaded bytes: " + response.statusText); } let text = await response.text(); return +text; } async upload() { this.startByte = await this.getUploadedBytes(); let xhr = this.xhr = new XMLHttpRequest(); xhr.open("POST", "upload", true); // send file id, so that the server knows which file to resume xhr.setRequestHeader('X-File-Id', this.fileId); // send the byte we're resuming from, so the server knows we're resuming xhr.setRequestHeader('X-Start-Byte', this.startByte); xhr.upload.onprogress = (e) => { this.onProgress(this.startByte + e.loaded, this.startByte + e.total); }; console.log("send the file, starting from", this.startByte); xhr.send(this.file.slice(this.startByte)); // return // true if upload was successful, // false if aborted // throw in case of an error return await new Promise((resolve, reject) => { xhr.onload = xhr.onerror = () => { console.log("upload end status:" + xhr.status + " text:" + xhr.statusText); if (xhr.status == 200) { resolve(true); } else { reject(new Error("Upload failed: " + xhr.statusText)); } }; // xhr.abort() فقط عندما يُستدعى onabort يقع xhr.onabort = () => resolve(false); }); } stop() { if (this.xhr) { this.xhr.abort(); } } } الملف "index.js": <!DOCTYPE HTML> <script src="uploader.js"></script> <form name="upload" method="POST" enctype="multipart/form-data" action="/upload"> <input type="file" name="myfile"> <input type="submit" name="submit" value="Upload (Resumes automatically)"> </form> <button onclick="uploader.stop()">Stop upload</button> <div id="log">Progress indication</div> <script> function log(html) { document.getElementById('log').innerHTML = html; console.log(html); } function onProgress(loaded, total) { log("progress " + loaded + ' / ' + total); } let uploader; document.forms.upload.onsubmit = async function(e) { e.preventDefault(); let file = this.elements.myfile.files[0]; if (!file) return; uploader = new Uploader({file, onProgress}); try { let uploaded = await uploader.upload(); if (uploaded) { log('success'); } else { log('stopped'); } } catch(err) { console.error(err); log('error'); } }; </script> وستكون النتيجة بالشكل التالي: تُعَد الأساليب الجديدة لإرسال الطلبات عبر الشبكة أقرب في إمكانياتها إلى إدارة الملفات كما رأينا، مثل التحكم بالترويسات ومؤشرات تقدم العمليات وإرسال الملفات وغير ذلك، وهذا ما مكّننا من تنفيذ شيفرات لاستئناف رفع الملفات وغيرها الكثير. ترجمة -وبتصرف- للفصل Resumable file upload من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت الحياة السرية للكائنات في جافاسكريبت ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
  23. لقد أخذنا فكرةً لا بأس بها عن fetch في المقالات السابقة من هذه السلسلة (بدءًا من مقال إرسال البيانات واستلامها عبر الشبكة وحتى مقال استخدام Fetch مع الطلبات ذات الأصل المختلط Cross-Origin)، والآن لنلق نظرةً على بقية مكوّنات الواجهة البرمجية لنغطي كل إمكاناتها. إليك قائمةً كاملةً بكل خيارت fetch الممكنة مع قيمها الافتراضية (وضعنا البدائل في تعليقات): let promise = fetch(url, { method: "GET", // POST, PUT, DELETE, etc. headers: { // the content type header value is usually auto-set // depending on the request body "Content-Type": "text/plain;charset=UTF-8" }, body: undefined // string, FormData, Blob, BufferSource, or URLSearchParams referrer: "about:client", // or "" to send no Referer header, // or an url from the current origin referrerPolicy: "no-referrer-when-downgrade", // no-referrer, origin, same-origin... mode: "cors", // same-origin, no-cors credentials: "same-origin", // omit, include cache: "default", // no-store, reload, no-cache, force-cache, or only-if-cached redirect: "follow", // manual, error integrity: "", // a hash, like "sha256-abcdef1234567890" keepalive: false, // true signal: undefined, // AbortController to abort request window: window // null }); لقد غطينا المفاهيم method وheaders وbody في مقال استخدام Fetch، كما غطينا signal في مقال إيقاف تنفيذ Fetch، وسنتعرف الآن على بقية الإمكانات. خيارا المحيل referrer وسياسة المحيل referrerPolicy يتحكم هذان الخياران بكيفية ضبط fetch للترويسة Referrer، وهي إحدى ترويسات HTTP، وتُضبط تلقائيًا لتحتوي على عنوان الصفحة التي ولّدت الطلب، ولا تعَد هامةً في معظم الأحيان، وقد يكون من المنطقي أحيانًا إزالتها أو تقصيرها لأسباب تتعلق بالأمان. يسمح الخيار referrer بتسمية أي مُحيل، وهو الصفحة أو الرابط الذي أحالك إلى الصفحة الحالية التي تعمل عليها، أو إزالته على أن يشترك بالأصل مع الصفحة الحالية، وإذا لم ترغب بإرسال أي محيل فأسند إليه نصًا فارغًا: fetch('/page', { referrer: "" // لا توجد توريسة محيل }); ولوضع عنوان مورد آخر من الأصل ذاته: fetch('/page', { // https://javascript.info بفرض أننا في // نستطيع ضبط أي ترويسة محيل، لكن ضمن الأصل الحالي referrer: "https://javascript.info/anotherpage" }); يضبط الخيار referrerPolicy بعض القواعد العامة للمُحيل Referer. تنقسم الطلبات إلى ثلاث مجموعات، هي الآتية: الطلب إلى مورد من الأصل ذاته. الطلب إلى مورد من أصل مختلف. الطلب من بروتوكول HTTPS إلى بروتوكول HTTP: أي من بروتوكول النقل الآمن إلى غير الآمن. يدل الخيار referrerPolicy المتصفح على القواعد الخاصة باستخدام المُحيل في كل مجموعة من الطلبات، ولا يسمح بضبط القيمة الدقيقة للمحيل. ستجد جميع القيم الممكنة في توصيف سياسة المحيل: no-referrer-when-downgrade: قيمتها الافتراضية "full"، حيث تُرسل قيمة المحُيل دومًا عدا الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP (إلى بروتوكول أقل أمانًا). no-referrer: لا يُرسَل المُحيل. origin: يُرسل الأصل فقط ضمن المُحيل وليس عنوان الصفحة المُحيلة الكامل، أي يُرسل العنوان على الشكل http://site.com وليس على الشكل http://site.com/path. origin-when-cross-origin: يُرسل العنوان الكامل للمحيل إلى المواقع ذات الأصل المشترك، بينما يُرسَل الأصل فقط إلى المواقع ذات الأصل المختلط. same-origin: يُرسل المُحيل كاملًا إلى المواقع التي تنتمي إلى نفس الأصل، ولايرُسل أبدًا إلى المواقع ذات الأصول المختلطة. strict-origin: يُرسل الأصل فقط وليس المُحيل كاملًا في الطلبات من HTTPS إلى HTTP. strict-origin-when-cross-origin: يُرسل المُحيل كاملًا إلى المواقع التي تنتمي إلى نفس الأصل، ويُرسل الأصل فقط إلى المواقع ذات الأصول المختلطة، عدا الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP، فلا يُرسل شيء. unsafe-url: يُرسل عنوان المُحيل كاملًا، حتى في الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP. يوضح الجدول التالي جميع الخيارات: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } القيمة إلى نفس الأصل إلى أصل مختلف HTTPS→HTTP "no-referrer" - - - no-referrer-when-downgrade أو "" وهي القيمة الافتراضية كاملًا كاملًا - "origin" الأصل الأصل الأصل "origin-when-cross-origin" كاملًا الأصل الأصل "same-origin" كاملًا - - "strict-origin" الأصل الأصل - "strict-origin-when-cross-origin" كاملًا الأصل - "unsafe-url" كاملًا كاملًا كاملًا لنفترض وجود صفحة بصلاحيات مدير، ولا ينبغي كشف عنوانها خارج نطاق الموقع، لذا فعند إرسال fetch، فسترسَل الترويسة Referer افتراضيًا مع عنوان صفحتنا كاملًا، عدا الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP. حيث لا توجد أي ترويسة Referer، فإذا كان العنوان هو Referer: https://javascript.info/admin/secret/paths مثلًا، وأردنا إرسال الأصل فقط وليس العنوان الكامل، فيمكن أن نرسل الخيار التالي: fetch('https://another.com/page', { // ... referrerPolicy: "origin-when-cross-origin" // Referer: https://javascript.info }); يمكن وضع الخيار السابق لكل استدعاءات fetch، كما يمكن أيضًا دمجه في مكتبة JavaScript التي نستخدمها في مشروعنا، والتي تنفّذ كل الطلبات التي تستخدم fetch، ويقتصر الفرق الوحيد بينه وبين الخيار الافتراضي في أنه يرسِل الجزء الأصلي من عنوان الموقع المُحيل، مثلًا: https://javascript.info ولا يُرسل المسار الكلي، وسنحصل على العنوان الكامل في الطلبات المُرسَلة إلى مواقع من نفس الأصل، فربما تكون مفيدةً لأغراض التنقيح. الخيار mode ويمثل هذا الخيار الحارس الذي يمنع الطلبات ذات الأصل المختلط التي تحدث فجأةً. cors: وهي القيمة الافتراضية، وتسمح بالطلبات ذات الأصل المختلط كما ورد في فصل استخدام Fetch في الطلبات ذات الأصل المختلط. same-origin: يمنع استخدام الطلبات ذات الأصل المختلط. no-cors: يسمح فقط لطلبات الأصل المختلط الآمنة. قد تظهر أهمية هذا الخيار عندما يأتي العنوان القادم مع fetch من طرف ثالث، ونريد آليةً للحد من الإمكانات المسموحة للأصول المختلطة. الخيار credentials ويحدد ما إذا كان على fetch إرسال ملفات تعريف الارتباط cookies، وترويسات استيثاق HTTP مع الطلب. same-origin: وهي القيمة الافتراضية، لا تُرسل الثبوتيات مع الطلبات ذات الأصول المختلطة. include: تُرسل الثبوتيات دومًا، ونحتاج إلى الترويسة Access-Control-Allow-Credentials من الخادم ذي الأصل المختلط لتتمكن جافا سكربت من الوصول إلى الاستجابة، وقد شرحنا ذلك في فصل استخدام Fetch في الطلبات ذات الأصل المختلط. omit: لا تُرسل الثبوتيات أبدًا، حتى للطلبات من الأصل نفسه. الخيار cache تستخدم طلبات fetch افتراضيًا ذاكرة HTTP المؤقتة المعيارية HTTP-cache، فهي تحترم الترويستين Expires وCache-Control، وترسل الترويسة If-Modified-Since تمامًا كما تفعله طلبات HTTP النظامية. يسمح الخيار cache بتجاهل "HTTP-cache" أو يضبط استخدامه: default: تستخدم fetch ترويسات وقواعد "HTTP-cache" المعيارية. no-store: يتجاهل الطلب قواعد "HTTP-cache" كليًا، وتصبح هذه القيمة افتراضيةً عند إرسال إحدى الترويسات التالية: If-Modified-Since أو If-None-Match أو If-Unmodified-Since أو If-Match أو If-Range. reload: لا يأخذ النتيجة من "HTTP-cache" -إن وجدت-، بل ينشر محتويات الذاكرة المؤقتة مع الاستجابة، إذا سمحت ترويسات الاستجابة بذلك. no-cache: يُنشئ طلبًا شرطيًا عند وجود استجابة مخزنة في الذاكرة المؤقتة، وطلبًا عاديًا في غير تلك الحالة، وينشر "HTTP-cache" مع الاستجابة. force-cache: يستخدم الاستجابة الموجودة في "HTTP-cache" حتى لو كانت قديمة، وسينشئ طلب HTTP نظاميًا إذا لم تحتوي على استجابة، كما سيسلك الطلب السلوك الطبيعي. only-if-cached: يستخدم الاستجابة الموجودة في "HTTP-cache" حتى لو كانت قديمةً، وسيرمي خطأً إذا لم تحتوي على استجابة، وتعمل فقط مع القيمة same-origin للخيار mode. الخيار redirect تخضع fetch بكل شفافية لإعادة التوجيه "HTTP-redirect" مثل الحالتين 301 (النقل النهائي لمحتوى) و302 (موجود ولكن يفضل الانتقال إلى العنوان الجديد). follow: وهي القيمة الافتراضية، ويخضع الطلب عندها لحالات إعادة التوجيه. error: يرمي خطأً عند محاولة إعادة توجيه الطلب. manual: يسمح بالتعامل مع إعادة توجيه الطلب يدويًا، وسنحصل عندها على كائن استجابة خاص من النوع "response.type="opaqueredirect، وتكون قيمة خاصية الحالة response.status صفرًا، وكذلك قيم أغلب خصائصه. الخيار integrity يسمح هذا الخيار بالتحقق من مطابقة الاستجابة للقيم الاختبارية Checksum المحددة مسبقًا، كما هو محدد في التوصيفات. وتُدعم دوال "hash" التالية: SHA-256 وSHA-384 وSHA-512، كما قد تتطلب بعض الاعتماديات وفقًا للمتصفح، فإذا كنا بصدد تنزيل ملف مثلًا، ونعلم أنّ القيمة الاختبارية له وفق SHA-256 هي "abcdef"، والتي ستكون أطول في الواقع، فيمكننا وضعها قيمةً للخيار integrity بالشكل التالي: fetch('http://site.com/file', { integrity: 'sha256-abcdef' }); ستحسب fetch قيمة SHA-256 بنفسها وتوازنها مع القيمة التي وضعناها، وسترمي خطأً عند عدم تطابق القيمتين. الخيار keepalive يسمح هذا الخيار ببقاء الطلب فعالًا خارج الصفحة التي أنشأتها. لنفترض مثلًا أننا نجمع إحصائيات عن سلوك المستخدم الحالي لصفحتنا (عدد نقرات الفأرة وأجزاء الصفحة التي زارها) لتحليل تجربة المستخدم وتطويرها، ونرغب بحفظ البيانات على الخادم عندما يغادر هذا المستخدم الصفحة، حيث يمكن أن ننفذ ذلك باستخدام الحدث window.onunload بالشكل التالي: window.onunload = function() { fetch('/analytics', { method: 'POST', body: "statistics", keepalive: true }); }; لكن ستُغلق كل طلبات الشبكة المتعلقة بالمستند عند إزالته، وهنا تظهر أهمية الخيار keepalive الذي يخبر المتصفح بإبقاء الطلبات حيةً في الخلفية حتى بعد أن يغادر الزائر الصفحة، لأن هذا الخيار أساسي لاستمرار الطلب ونجاحه. لكن بالطبع هناك بعض التقييدات في استخدامه، والمتمثلة في الآتي: لا يمكن إرسال أحجام بالميجابايت: لأن الحد الأعلى لحجم جسم الطلب مع خيار keepalive هو 64 كيلوبابت. إذا أردنا جمع إحصائيات كثيرةً عن الزائر، فلا بدّ من إرسالها بانتظام ضمن حزم متتالية، لكي لا تبقى الكثير من المعلومات التي لم ترسل بعد عند تنفيذ الطلب الأخير مع الحدث onunload. تطبق هذه التقييدات على كل الطلبات التي تحمل الخيار keepalive معًا، أي يمكن تنفيذ عدة طلبات من هذا النوع في الوقت نفسه، لكن يجب ألا يتجاوز مجموع أحجام أجسام هذه الطلبات حد 64 كيلوبايت. لا يمكن التعامل مع استجابة الخادم عند إزالة المستند، لذا سينجح استخدام fetch في مثالنا بوجود keepalive، لكن بالطبع لن تُنفَّذ الدوال اللاحقة. لن تظهر المشاكل في أغلب الأحيان عند إرسال بيانات مثل الإحصائيات، لأنّ الخادم سيقبل هذه البيانات وسيعيد غالبًا استجابةً فارغةً لطلبات مثل هذه. ترجمة -وبتصرف- للفصل Fetch: API من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: استخدام Fetch مع الطلبات ذات الأصل المختلط في جافاسكريبت
  24. من المحتمل أن يخفق الطلب fetch المرسَل إلى موقع ويب آخر، مثلًا: ستخفق محاولة الحصول على http://example.com: try { await fetch('http://example.com'); } catch(err) { alert(err); // Failed to fetch } لا بدّ من الإشارة أولًا إلى المفاهيم البنيوية لموضوعنا: الأصل origin: وهو الثلاثية (نطاق ومنفذ وبروتوكول). الطلبات ذات الأصل المختلط cross-origin requests: وهي الطلبات المرسَلة إلى نطاق (أو نطاق فرعي) آخر أو عبر منفذ آخر أو باستخدام بروتوكول آخر، وتتطلب ترويسات خاصةً من الجانب البعيد. تدعى هذه السياسة "CROS" وهو اختصار للعبارة "Cross-Origin Resource Sharing"، وتعني مشاركة الموارد ذات الأصول المختلطة. لماذا نحتاج إلى CROS؟ لمحة تاريخية موجزة وُجِدت هذه السياسة لحماية الإنترنت من المخترقين، فلسنوات عديدة لم يكن مسموحًا لسكربت من موقع ما أن يصل إلى محتوى موقع آخر، حيث لا يمكن لسكربت مشبوه من الموقع hacker.com مثلًا الوصول إلى صندوق البريد الإلكتروني لمستخدم على الموقع gmail.com، مما أشعَر مستخدمي الإنترنت في ذلك الوقت بالأمان لعدم امتلاك جافاسكريبت JavaScript، أي توابع خاصة لتنفيذ طلبات عبر الشبكة، فقد كانت عبارةً عن لغة للتسلية وتزيين صفحات الويب، إلا أن مطوري الويب احتاجوا سريعًا إلى قوة أكبر للتحكم بالصفحات، فاخترعوا أساليب متنوعةً للالتفاف على محدودية اللغة وإرسال الطلبات إلى مواقع أخرى. استخدام النماذج لقد كانت إحدى طرق التواصل مع خادم آخر هي إرسال نموذج <form> إليه، وذلك باستخدام الإطارات <iframe> لإبقاء الزوار ضمن الصفحة نفسها: <!-- form target --> <iframe name="iframe"></iframe> <!-- a form could be dynamically generated and submited by JavaScript --> <form target="iframe" method="POST" action="http://another.com/…"> ... </form> وهكذا تمكن الناس من إرسال طلبات GET/POST إلى مواقع أخرى دون وجود توابع لتنفيذ ذلك، لأنّ النماذج قادرة على إرسال البيانات إلى أي مكان، لكن لم يكن بالإمكان الحصول على الاستجابة لأنّ الوصول إلى محتويات الإطار <iframe> غير مسموح، ولنكون دقيقين؛ وُجِدت بعض الحيل للالتفاف على ذلك أيضًا، لكنها تطلبت سكربتًا خاصًا يوضع ضمن الإطار والصفحة، أي صار التواصل بينهما ممكنًا من الناحية التقنية. استخدام السكربتات اعتمدت إحدى الحيل المستخدَمة أيضًا على المعرّف <script>، إذ يمكن أن تكون قيمة الخاصية src لسكربت هي اسم أي نطاق أو موقع مثل: <script src="http://another.com/…"> وبالتالي يمكن تنفيذ سكربت أيًا كان مصدره، فإذا أراد موقع ما مثل another.com إتاحة أمكانية الوصول لبياناته، فسيستخدم البروتوكول "JSON with padding" واختصاره JSNOP، وإليك آلية عمله: لنفترض أننا نريد الوصول إلى بيانات الطقس على الموقع http://another.com إنطلاقًا من موقعنا: نعرّف في البداية دالةً عامةً لاستقبال البيانات ولتكن gotWeather. // صرح عن الدالة التي ستعالج البيانات المطلوبة function gotWeather({ temperature, humidity }) { alert(`temperature: ${temperature}, humidity: ${humidity}`); } ننشئ <script>، وتكون قيمة الخاصية src فيه هي src="http://another.com/weather.json?callback=gotWeather" وللمستخدمين اسم الدالة العامة كقيمة للمعامل callback الخاص بالعنوان URL. let script = document.createElement('script'); script.src = `http://another.com/weather.json?callback=gotWeather`; document.body.append(script); يوّلد الخادم البعيد another.com ديناميكيًا سكربتًا يستدعي الدالة ()gotWeatherبالبيانات التي يريدنا أن نحصل عليها. // ستبدو الاستجابة التي نتوقعها من الخادم كالتالي gotWeather({ temperature: 25, humidity: 78 }); عندما يُحمَّل السكربت الذي يولده الخادم ويُنفَّذ، ستُنفَّذ الدالة gotWeather ونحصل على البيانات. سيعمل الأسلوب السابق ولن يشكل خرقًا لأمن الموقع البعيد بسبب اتفاق كلا الطرفين على تبادل المعلومات بهذا الشكل، ولهذا لن تُعَدَّ العملية عندها اختراقًا، ولا زالت بعض الخدمات تتبع نفس الأسلوب في الوصول إلى البيانات البعيدة وتعمل حتى على المتصفحات القديمة جدًا. ظهرت بعد فترة من الزمن ضمن لغة JavaScript توابع الطلبات عبر الشبكة والتي ينفذها المتصفح، وقد رفضت الطلبات ذات الأصول المختلطة في البداية، إلا أنه سُمح باستخدامها نتيجة نقاشات طويلة، بشرط الحصول على سماحيات صريحة من الخادم لتنفيذ أي متطلبات، ويُعبَّر عنها من خلال ترويسات خاصة. الطلبات الآمنة هنالك نوعان من الطلبات ذات الأصل المختلط: الطلبات الآمنة safe requests. بقية الأنواع. من السهل إنشاء الطلبات الآمنة لذلك سنبدأ بها، إذ يُعَد الطلب آمنًا إذا حقق الشرطين التاليين: يستخدم نوعًا آمنًا مثل GET أو POST أو HEAD. يستخدم ترويسات آمنةً. ويسمح بالترويسات المخصصة التالية فقط: Accept. Accept-Language. Content-Language. Content-Type: بحيث تحمل إحدى القيم التالية application/x-www-form-urlencoded أو multipart/form-data أو text/plain. وتُعَد بقية الطلبات "غير آمنة"، حيث لا تطابق الطلبات باستخدام PUT أو باستخدام الترويسة API-Key معايير الأمان السابقة مثلًا. ويكمن الفرق الجوهري في إمكانية تنفيذ الطلبات الآمنة باستخدام معرِّف النموذج <form> أو معرّف السكربت <script> دون الحاجة لأي توابع خاصة، وبالتالي ستكون أقدم الخوادم قادرةً على استقبالها. لا يمكن في المقابل استخدام الطلبات التي لا تمتلك ترويسات معياريةً مثل DELETE بهذه الطريقة، ولفترة طويلة لم تكن JavaScript قادرةً على استخدام هذا النوع من الطلبات، وهكذا سيفترض الخادم القديم أن هذه الطلبات قادمة من مصدر مخوّل بذلك، لأنه يتوقع أنّ صفحة الويب غير قادرة على إرسال هذه الطلبات. عندما نرسل طلبًا غير آمن، فسيرسل المتصفح طلبًا تمهيديَا preflight، سيسأل الخادم فيه إن كان سيوافق على طلبات ذات أصول مختلطةً أم لا، فإن لم يؤكد الخادم ذلك صراحةً من خلال الترويسات، فلن يُرسَل الطلب غير الآمن. سياسة CROS للطلبات غير الآمنة سيضيف المتصفح دائمًا الترويسة Origin إلى الطلب من الأصول المختلطة، فإذا طلبنا المورد https://anywhere.com/request من الموقع https://javascript.info/page مثلًا؛ فستبدو الترويسات بالشكل التالي: GET /request Host: anywhere.com Origin: https://javascript.info ... تحتوي الترويسة origin كما نرى الأصل (نطاق وبروتوكول ومنفذ) كما هو لكن دون مسار، ويمكن للخادم أن يتحقق من الترويسة Origin، فإن وافق على قبول هذا الطلب، فسيضيف ترويسةً خاصةً هي Access-Control-Allow-Origin إلى الاستجابة، وينبغي أن تحتوي الترويسة على الأصل المقبول (https://javascript.info في حالتنا) أو رمز النجمة (*)، عندها سيكون الطلب ناجحًا وإلا فسيُعَد خاطئًا. يلعب المتصفح دور الوسيط الموثوق حيث: يضمن إرسال الأصل الصحيح في الطلب ذي الأصول المختلطة. يتحقق من وجود السماحية Access-Control-Allow-Origin في الاستجابة، فإذا وُجدَت فسيسمح لشيفرة JavaScript بالوصول إلى الاستجابة وإلا ستخفق العملية وسيحدث خطأ. 200 OK Content-Type:text/html; charset=UTF-8 Access-Control-Allow-Origin: https://javascript.info ترويسات الاستجابة افتراضيًا قد لا تتمكن JavaScript من الوصول إلا إلى الترويسات الآمنة للاستجابة عند إرسال طلبات ذات أصل مختلط، وهذه الترويسات هي: Cache-Control. Content-Language. Content-Type. Expires. Last-Modified. Pragma. ويسبب الدخول إلى أي ترويسات أخرى خطأً. لمنح إمكانية الوصول إلى ترويسة الاستجابة، ينبغي أن يُرسل الخادم الترويسة Access-Control-Expose-Headers، والتي تتضمن قائمةً بأسماء الترويسات غير الآمنة التي يُفترض جعلها قابلةً للوصول، وتفصل بينها فاصلة، كالمثال التالي: 200 OK Content-Type:text/html; charset=UTF-8 Content-Length: 12345 API-Key: 2c9de507f2c54aa1 Access-Control-Allow-Origin: https://javascript.info Access-Control-Expose-Headers: Content-Length,API-Key وبوجود ترويسة مثل Access-Control-Expose-Headers سيُسمح للسكربت بقراءة ترويستي الاستجابة Content-Length وAPI-Key. الطلبات غير الآمنة يمكن استخدام جميع طلبات HTTP مثل PATCH وDELETE وغيرها، وليس فقط GET/POST، ولم يتخيل أحد في السابق إمكانية تنفيذ صفحات الويب لهذه الطلبات، لذلك قد تجد بعض خدمات الويب التي تعامل الطلبات غير المعيارية مثل إشارة "بأنها طلبات مصدرها ليس المتصفح"، ويمكنها أن تأخذ هذا الأمر في الحسبان عندما تتحقق من حقوق الوصول، ولتفادي سوء الفهم، لن ينفِّذ المتصفح أي طلبات غير آمنة كانت قابلة للتنفيذ مباشرةً فيما مضى، وسيرسل طلبًا تمهيديًا preflight إلى الخادم لطلب الإذن، ويستخدم الطلب التمهيدي التابع OPTIONS دون جسم للطلب، وبوجود ترويستين: الترويسة Access-Control-Request-Method: وتؤمن تابعًا للطلب غير الآمن. الترويسة Access-Control-Request-Headers: وتؤمن قائمةً بترويسات غير آمنة تفصل بينها فاصلة. إذا وافق الخادم على تنفيذ الطلبات، فسيرسل استجابةً بجسم فارغ ورمز الحالة 200، بالإضافة إلى الترويسات التالية: Access-Control-Allow-Origin: ويجب أن تحمل القيمة (*) أو أصل الموقع الذي أرسل الطلب، مثل "https://javascript.info"، ليُسمح له بالوصول. Access-Control-Allow-Methods: ويجب أن تحتوي التوابع المسموحة. Access-Control-Allow-Headers: ويحب أن تضم قائمةً بالترويسات المسموحة. Access-Control-Max-Age: وهي ترويسة إضافية يمكنها تحديد الفترة الزمنية (ثوانٍ) للاحتفاظ بالإذن، لذا لن يكون على المتصفح إرسال طلبات تمهيدية للطلبات اللاحقة التي تحقق السماحيات الممنوحة سابقًا. لنلق نظرةً على آلية العمل خطوةً خطوةً، بمثال عن طلب PATCH ذي أصول مختلطة (والذي يُستخدَم غالبًا لتحديث البيانات): let response = await fetch('https://site.com/service.json', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'API-Key': 'secret' } }); لدينا ثلاثة أسباب لعدّ هذا الطلب غير آمن (ويكفي أحدها بالطبع): الطلب هو PATCH. لاتحمل الترويسة Content-Type إحدى القيم: application/x-www-form-urlencoded أو multipart/form-data أو text/plain. وجود الترويسة API-Key غير الآمنة. الخطوة 1: الطلب التمهيدي preflight يرسل المتصفح بنفسه -قبل إرسال طلب غير آمنٍ كهذا- طلبًا تمهيديًا له الشكل التالي: OPTIONS /service.json Host: site.com Origin: https://javascript.info Access-Control-Request-Method: PATCH Access-Control-Request-Headers: Content-Type,API-Key OPTIONS: تابع الطلب التمهيدي. ‎/service.json: المسار، ويطابق مسار الطلب الرئيسي تمامًا. الترويسات الخاصة بالطلب ذي الأصل المختلط: Origin: أصل مُرسل الطلب. Access-Control-Request-Method: نوع الطلب Access-Control-Request-Headers:قائمة بترويسات غير آمنة تفصل بينها فاصلة. الخطوة 2: الاستجابة للطلب التمهيدي ينبغي أن يستجيب الخادم برمز الحالة 200 والترويسات التالية: Access-Control-Allow-Origin: https://javascript.info Access-Control-Allow-Methods: PATCH Access-Control-Allow-Headers: Content-Type,API-Key. تسمح هذه الاستجابة بالتواصل المستقبلي مع الخادم وإلا فسيقع خطأ. إذا كنت سترسل طلبات أو ترويسات من أنواع أخرى مستقبلًا، فمن المنطقي أن تطلب الإذن مسبقًا، وذلك بإضافتهم إلى القائمة أثناء الطلب التمهيدي، يوضح المثال التالي استجابةً يُسمح فيها باستخدام PUT وDELETE بالإضافة إلى ترويسات أخرى: 200 OK Access-Control-Allow-Origin: https://javascript.info Access-Control-Allow-Methods: PUT,PATCH,DELETE Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control Access-Control-Max-Age: 86400 سيرى المتصفح الآن الطلب PATCH في قائمة الطلبات المسموحة Access-Control-Allow-Methods، كما سيرى الترويسة Content-Type,API-Key في قائمة الترويسات المسموحة، لذا لن يتردد بإرسال الطلب الرئيسي المبني عليهما. إذا رأى المتصفح الترويسة Access-Control-Max-Age وقد أسندت إليها قيمة بالثواني، فسيحتفظ بالسماحيات التي مُنحت للطلب التمهيدي خلال هذه المدة الزمنية، أي سيُحتفظ بالسماحيات في مثالنا السابق فترة 86400 ثانية (أي يوم كامل)، ولن تحتاج الطلبات اللاحقة إلى نفس الخادم طلبات تمهيديةً أخرى، بفرض أنها تتلاءم مع السماحيات الممنوحة، وستُرسل مباشرةً. الخطوة 3: الطلب الفعلي يرسِل المتصفح الطلب الفعلي عندما ينجح الطلب التمهيدي، وتُنفَّذ العملية يطريقة مماثلة لإرسال طلب آمن. سيتضمن الطلب الرئيسي الترويسة Origin (لأنه طلب ذو أصل مختلط): PATCH /service.json Host: site.com Content-Type: application/json API-Key: secret Origin: https://javascript.info الخطوة 4: الاستجابة الفعلية لا بدّ للخادم من إضافة الترويسة Access-Control-Allow-Origin إلى الاستجابة الرئيسية، ولن يُعفيه الطلب التمهيدي الناجح من هذه المهمة: Access-Control-Allow-Origin: https://javascript.info تستطيع بعد ذلك قراءة استجابة الخادم الفعلية. يمكن لشيفرة JavaScript الآن قراءة الاستجابة على الطلب الفعلي. الثبوتيات Credentials لا تحضر الطلبات ذات الأصل المختلط التي تنتج عن شيفرة JavaScript أية ثبوتيات (ملفات تعريف الارتباط cookies أو استيثاق HTTP)، وهذا ليس أمرًا شائعًا في طلبات HTTP، فعند إرسال طلب HTTP إلى الموقع http://site.com مثلَا، فسيحمل الطلب جميع ملفات تعريف الارتباط الموجودة في نطاق المُرسِل، لكن الطلبات ذات الأصل المختلط الناتجة عن JavaScript تُمثّل استثناءً، حيث لن يُرسل الأمر (fetch(http://another.com أي ملفات تعريف ارتباط حتى تلك التي تنتمي إلى النطاق another.com. لكن لماذا؟ لأنّ الطلبات التي تُزوَّد بثبوتيات أقوى بكثير، إذ يمكن لشيفرة JavaScript -إن سُمح لها- أن تعمل بكامل إمكانياتها بالنيابة عن المستخدم، وأن تصل إلى معلومات حساسة بالاستفادة من هذه الثبوتيات. لكن هل يثق الخادم بسكربت ما إلى هذا الحد؟ إن كان الأمر كذلك، فلا بدّ من السماح صراحةً بالطلبات التي تحمل ثبوتيات من خلال ترويسة إضافية، حيث سنحتاج إلى إضافة الخيار credentials: "include" عند إرسال الثبوتيات مع fetchبالشكل التالي: fetch('http://another.com', { credentials: "include" }); يمكن الآن إرسال ملفات تعريف الارتباط التي تنتمي إلى another.com عبر الطلب fetch إلى الموقع الهدف، وينبغي على الخادم إذا وافق على قبول الثبوتيات، إضافة الترويسة Access-Control-Allow-Credentials: true إلى استجابته بالإضافة إلى الترويسة Access-Control-Allow-Origin، مثلًا: 200 OK Access-Control-Allow-Origin: https://javascript.info Access-Control-Allow-Credentials: true لاحظ أنه يُمنع استخدام النجمة (*) كقيمة للترويسة Access-Control-Allow-Origin في الطلبات التي تحمل ثبوتيات، إذ لا بدّ -كما نرى في المثال السابق- من تحديد الأصل بدقة، وهذا معيار إضافي للتأكد من أنّ الخادم يعرف تمامًا الجهة التي يثق بها لتنفيذ طلبات مثل هذه. خلاصة هنالك نوعان من الطلبات ذات الأصول المختلطة من وجهة نظر المتصفح: آمنة وغير آمنة. لا بدّ للطلبات الآمنة من تحقيق الشرطين التاليين: أن تستخدم نوعًا آمنًا مثل: GET أو POST أو HEAD. أن تستخدم الترويسات الآمنة التالية: Accept Accept-Language Content-Language Content-Type: وتحمل إحدى القيم application/x-www-form-urlencoded أو multipart/form-data أو text/plain. الفرق الجوهري هو أن الطلبات الآمنة ومنذ وقت طويل، تُنفَّذ باستخدام معرِّف النموذج <form> أو معرِّف السكربت <script>، بينما مُنعت المتصفحات من تنفيذ الطلبات غير الآمنة لفترة طويلة. يظهر هذا الفرق عمليًا في إمكانية إرسال الطلبات الآمنة مع الترويسة Origin مباشرةً، بينما يحتاج المتصفح إلى إرسال طلب تمهيدي preflight عند إرسال الطلبات غير الآمنة، يطلب فيها إذنًا من الخادم. للطلبات الآمنة: يرسل المتصفح الترويسة مع الأصل Origin. بالنسبة للطلبات التي لا تحمل ثبوتيات (لا ترسَل الثبوتيات بشكل افتراضي) لا بدّ أن يضبط الخادم ما يلي: Access-Control-Allow-Origin: على القيمة (*) أو نفس قيمة الأصل Origin. بالنسبة للطلبات التي تحمل ثبوتيات لا بدّ أن يضبط الخادم: Access-Control-Allow-Origin: على نفس قيمة الأصل Origin. Access-Control-Allow-Credentials: على القيمة "true". ولمنح JavaScript الوصول إلى ترويسات الاستجابة عدا Cache-Control و Content-Language وContent-Type وExpires وLast-Modified وPragma، فلا بدّ أن يضع الخادم الترويسة التي يُسمح بالوصول إليها ضمن الترويسة Access-Control-Expose-Headers. يُرسل المتصفح طلبًا تمهيديًا قبل الطلب الفعلي عند إرسال طلبات غير آمنة: يرسل المتصفح الطلب OPTIONS إلى نفس العنوان الذي سيرسِل إليه الطلب الفعلي مزوّدًا بالترويسات التالية: Access-Control-Request-Method: ويحمل نوع الطلب. Access-Control-Request-Headers: ويحمل قائمةً بترويسات غير آمنة يُطلب الإذن باستخدامها. يستجيب الخادم برمز الحالة 200، وبالترويسات التالية: Access-Control-Allow-Methods: تضم قائمةً بأنواع الطلبات المسموحة. Access-Control-Allow-Headers: تضم قائمةً بالترويسات المسموحة. Access-Control-Max-Age: وتحتوي على قيمة تمثل الفترة الزمنية (مقدرةً بالثواني) التي يُحتفظ فيها بالسماحيات. يُرسَل الطلب الفعلي بعد ذلك، وتُطبق خطوات إرسال الطلب الآمن. ترجمة -وبتصرف- للفصل Fetch: Cross-origin Requests من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: تتبع تقدم عملية التنزيل باستخدام Fetch وإلغاء العملية Fetch
  25. ماذا لو كانت البيانات الثنائية معلومات نصيةً؟ أي ماذا لو تلقينا ملفًا يحتوي على نص؟ سيسمح لنا الكائن TextDecoder المدمج ضمن JavaScript بقراءة البيانات وتحويلها إلى نص فعلي، بعد تزويده بالمخزن المؤقت buffer الذي يحتوي البيانات الثنائية وطريقة فك الترميز، حيث علينا أوّلًا إنشاء مفكك الترميز Decoder: let decoder = new TextDecoder([label], [options]); حيث: label: يمثل طريقة الترميز، والتي ستكون utf-8 افتراضيًا، كما تدعم كلًا من الطريقتين big5 وwindows-1251 وغيرها. options: وتضم كائنات اختياريةً هي: fatal: قيمة منطقية boolean، حيث عندما تأخذ القيمة "true"، فسترمي استثناءً عند وجود محارف غير صالحة أي لا يمكن فك ترميزها، وإلا -وهي الحالة الافتراضية- فستستبدلها بالمحرف "uFFFD\". ignoreBOM: وهي قيمة منطقية ستتجاهل BOM (وهي علامة Unicode خاصة بترتيب البايتات) التي تُستخدم نادرًا، ويحدث ذلك عندما تأخذ القيمة "true". ومن ثم عملية فك الترميز Decoding: let str = decoder.decode([input], [options]); input: ويمثل كائن BufferSource الذي يحتوي البيانات الثنائية. options: وتضم كائنًا اختياريًا هو: stream: وتأخذ القيمة "true" عندما نريد فك ترميز مجرى تدفق دخل، وذلك عند استدعاء مفكك الترميز باستمرار عن طريق مجموعات البيانات القادمة، وفي هذه الحالة قد يوضع محرف من عدة بايتات multi-byte charecter ليفصل بين هذه المجموعات، ويخبر هذا الخيار مفكك الترميز بتذكر المحارف غير المرمزة، وأن يفك ترميزها عندما تصل المجموعة الجديدة من البيانات. فمثلًا: let uint8Array = new Uint8Array([72, 101, 108, 108, 111]); alert( new TextDecoder().decode(uint8Array) ); // Hello let uint8Array = new Uint8Array([228, 189, 160, 229, 165, 189]); alert( new TextDecoder().decode(uint8Array) ); // 你好 يمكن فك ترميز جزء من المخزن المؤقت بإنشاء مصفوفة ثانوية subarray بالطريقة التالية: let uint8Array = new Uint8Array([0, 72, 101, 108, 108, 111, 0]); // النص في الوسط // أنشئ تمثيلًا جديدًا دون نسخ أي شيء let binaryString = uint8Array.subarray(1, -1); alert( new TextDecoder().decode(binaryString) ); // Hello مرمز النصوص TextEncoder مهمته معاكسة لمهمة مفكك الترميز، إذ يحوّل المُرمِّز TextEncoder النص إلى بايتات. ويُستخدم كالتالي: let encoder = new TextEncoder(); كما يدعم طريقة الترميز "utf-8" فقط. وله تابعان هما: (encode(str: ويعيد كائنًا من النوع Uint8Array انطلاقًا من نص. (encodeInto(str, destination: يُرمز str إلى destination والتي ينبغي أن تكون من النوع Uint8Array. let encoder = new TextEncoder(); let uint8Array = encoder.encode("Hello"); alert(uint8Array); // 72,101,108,108,111 الكائنان File وFileReader يرث الكائن File الكائن Blob ويُوسَّع بإمكانيات تتعلق بنظام الملفات، وتوجد طريقتان لإنشائه: باستخدام الدالة البانية بشكل مشابه للكائن Blob: new File(fileParts, fileName, [options]) حيث: fileparts: يمثل مصفوفةً قد تكون قيمها نصية أو blob أو BufferSource. fileName: اسم الملف. options: وتضم الكائن الاختياري التالي: lastModified: تاريخ آخر تعديل (تاريخ من النوع الصحيح integer). الحصول على ملف باستخدام <"input type="file> أو أسلوب الجر والإفلات، أو غيرها من الواجهات التي يؤمنها المتصفح، حيث يأخذ الملف في هذه الحالات المعلومات السابقة التي استخدمَت بمثابة وسطاء من نظام التشغيل. بما أن الكائن File يرث الكائن Blob، فله خصائصه نفسها، بالإضافة إلى الآتي: name: اسم الملف. lastModified: تاريخ آخر تعديل. إليك مثالًا يصف الحصول على الكائن File باستخدام <"input type="file>: <input type="file" onchange="showFile(this)"> <script> function showFile(input) { let file = input.files[0]; alert(`File name: ${file.name}`); // e.g my.png alert(`Last modified: ${file.lastModified}`); // e.g 1552830408824 } </script> See the Pen JS-P3-02-File-and-FileReader-ex1 by Hsoub (@Hsoub) on CodePen. الكائن FileReader يمثل FileReader كائنًا يخدم غرضًا وحيدًا هو قراءة البيانات من الكائن Blob (وبالتالي من الكائن File أيضًا)، وينقل البيانات مستخدمًا الأحداث، لأن القراءة من القرص قد تستغرق وقتًا، وصيغة الدالة البانية له هي بالشكل: let reader = new FileReader(); // لا وسطاء لهذا الكائن عدة توابع، أهمها: (readAsArrayBuffer(blob: يقرأ البيانات بالصيغة الثنائية على شكل ArrayBuffer. ([readAsText(blob, [encoding: يقرأ البيانات مثل نص مستعمل للتشفير المحدد، وهو utf-8 افتراضيًا. (readAsDataURL(blob: يقرأ البيانات الثنائية ويحولها إلى عنوان بيانات data url مشفر بطريقة base64. ()abort: يلغي العملية. نحدد التابع الذي سيستعمَل في القراءة وفقًا لصيغة البيانات التي نفضلها، وكيف سنستخدمها. readAsArrayBuffer: يُستخدم لقراءة الملفات الثنائية وتنفيذ عمليات ثنائية منخفضة المستوى، بينما نستدعي الكائن File مباشرةً دون قراءة عند تنفيذ عمليات عالية المستوى (ليست ثنائيةً) مثل اقتطاع شرائح من البيانات slicing. readAsText: يُستخدم عند قراءة ملفات نصية والحصول على قيم نصية. readAsDataURL: يُستخدم عندما نريد استخدام البيانات المقروءة في الخاصية src للمعرف img وغيره من المعرّفات، كما توجد طريقة أخرى لقراءة الملف تعرفنا عليها في مقال "كائن البيانات الثنائية Blob"، وهي استخدام التابع (URL.createObjectURL(file يمكن الاستفادة من عدة أحداث تقع أثناء عملية القراءة: loadstart: يقع عند بداية التحميل. progress: يقع خلال القراءة. load: يقع عند انتهاء القراءة دون أخطاء. abort: يقع عند استدعاء التابع ()abort. error: يقع عند حدوث خطأ. loadend: يقع عند انتهاء القراءة بنجاح أو بفشل. يمكن الوصول إلى النتيجة عند انتهاء القراءة بالشكل التالي: reader.result: للحصول على النتيجة عند نجاح العملية. reader.error: للحصول على الخطأ عند إخفاق القراءة. وأكثر الأحداث استخدامًا هما الحدثان load وerror. إليك مثالًا عن قراءة ملف: <input type="file" onchange="readFile(this)"> <script> function readFile(input) { let file = input.files[0]; let reader = new FileReader(); reader.readAsText(file); reader.onload = function() { console.log(reader.result); }; reader.onerror = function() { console.log(reader.error); }; } </script> See the Pen JS-P3-02-File-and-FileReader-ex2 by Hsoub (@Hsoub) on CodePen. خلاصة تعرفنا في هذا المقال على كيفية ترميز النصوص وفك ترميزها بجافاسكريبت، مركزين على مرمز النصوص TextEncoder، بعدها انتقلنا للحديث عن كيفية التعامل مع كائنات الملفات، حيث توصلنا إلى الآتي: يرث الكائن File والكائن Blob. للكائن File الخاصيتين name وlastModified بالإضافة إلى خصائص الكائن Blob وتوابعه، مع إمكانية القراءة من نظام الملفات المرتبط بنظام التشغيل. يمكن الحصول على الكائنات File عن طريق مُدخلات المستخدم مثل <input> أو أحداث الجر والإفلات مثل ondragend. يمكن لكائن FileReader القراءة من ملف أو Blob بإحدى التنسيقات التالية: نص باستخدام التابع readAsText. ArrayBuffer باستخدام التابع readAsArrayBuffer. عنوان بيانات بتشفير base64 باستخدام التابع readAsDataURL. لا نحتاج في بعض الحالات إلى قراءة محتويات ملف، لذا وكما فعلنا مع الكائن blob سننشئ عنوانًا قصيرًا باستخدام الأمر (URL.createObjectURL(file ونسنده إلى المعرف <a> أو <img>، وبهذا يمكن تنزيل الملف أو عرضه كصورة أو كجزء من لوحة رسم canvas. سيكون الأمر بسيطًا إذا كنا سنرسل ملفًا عبر الشبكة، إذ يقبل الكائنان XMLHttpRequest و fetch الكائن File مباشرةً. ترجمة -وبتصرف- للفصلين text decoder and text encoder وFile and FileReader من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: مصفوفة المخزن المؤقت ArrayBuffer والمصفوفات الثنائية binary arrays هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت تطويع البيانات في جافاسكربت
×
×
  • أضف...