الفصل 14
نموذج كائن المستند
يا للأسف! القصة القديمة نفسها! فبمجرد أن تنتهي من بناء منزلك تدرك أنك تعلمت بالصدفة شيئاً كان ينبغي أن تعرفه حقاً — قبل أن تبدأ.
— فريدريك نيتشه، ما وراء الخير والشر

عندما تفتح صفحة ويب، يسترجع متصفحك نص HTML الخاص بالصفحة ويحلله، تماماً كما حلّل محللنا من الفصل 12 البرامج. ويبني المتصفح نموذجاً لبنية المستند ويستخدم هذا النموذج لرسم الصفحة على الشاشة.
هذا التمثيل للمستند هو إحدى اللعب المتاحة لبرنامج JavaScript في صندوق الرمل الخاص به. فهو بنية بيانات يمكنك قراءتها أو تعديلها. وهو يعمل كبنية بيانات حيّة: فعندما يُعدَّل، تُحدَّث الصفحة المعروضة على الشاشة لتعكس التغييرات.
بنية المستند
يمكنك تخيل مستند HTML كمجموعة متداخلة من الصناديق. فوسوم مثل <body> و</body> تحيط بوسوم أخرى، التي تحتوي بدورها على وسوم أو نص آخر. إليك المستند المثال من الفصل السابق:
<!doctype html>
<html>
<head>
<title>My home page</title>
</head>
<body>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.</p>
<p>I also wrote a book! Read it
<a href="http://eloquentjavascript.net">here</a>.</p>
</body>
</html>
لهذه الصفحة البنية الآتية:
تتبع بنية البيانات التي يستخدمها المتصفح لتمثيل المستند هذا الشكل. فمقابل كل صندوق يوجد كائن يمكننا التفاعل معه لمعرفة أشياء مثل وسم HTML الذي يمثله والصناديق والنصوص التي يحتويها. ويُسمى هذا التمثيل نموذج كائن المستند (Document Object Model)، أو DOM اختصاراً.
يمنحنا الارتباط العام document وصولاً إلى هذه الكائنات. وتشير خاصيته documentElement إلى الكائن الذي يمثل وسم <html>. ولأن لكل مستند HTML رأساً وجسماً، فله أيضاً خاصيتا head وbody اللتان تشيران إلى هذين العنصرين.
الأشجار
تذكّر للحظة أشجار الصياغة من الفصل 12. فبنيتها شبيهة إلى حد لافت ببنية مستند المتصفح. إذ يمكن لكل عقدة أن تشير إلى عقد أخرى، هي الأبناء (children)، التي قد يكون لها بدورها أبناؤها. وهذا الشكل نموذجي للبنى المتداخلة، حيث يمكن للعناصر أن تحتوي عناصر فرعية شبيهة بها.
نسمي بنية البيانات شجرة عندما تكون ذات بنية متفرعة، وبلا دورات (فلا يجوز أن تحتوي عقدة نفسها، مباشرة أو غير مباشرة)، ولها جذر واحد محدد جيداً. وفي حالة DOM، يعمل document.documentElement جذراً.
تظهر الأشجار كثيراً في علم الحاسوب. فإضافة إلى تمثيل البنى التعاودية مثل مستندات HTML أو البرامج، كثيراً ما تُستخدم للحفاظ على مجموعات بيانات مرتبة، لأن العثور على العناصر أو إدراجها يكون عادةً أكثر كفاءة في شجرة منه في مصفوفة مسطحة.
للشجرة النموذجية أنواع مختلفة من العقد. فشجرة الصياغة الخاصة بلغة Egg كانت تحتوي عقد معرّفات وقيم واستدعاءات. وقد يكون لعقد الاستدعاء أبناء، بينما المعرّفات والقيم هي أوراق، أي عقد بلا أبناء.
وينطبق الأمر نفسه على DOM. فعقد العناصر، التي تمثل وسوم HTML، تحدد بنية المستند. وقد يكون لهذه العقد عقد أبناء. ومن أمثلة هذه العقد document.body. وبعض هؤلاء الأبناء قد يكون عقداً ورقية، مثل قطع نصية أو عقد تعليقات.
لكل كائن عقدة في DOM خاصية nodeType، تحتوي رمزاً (عدداً) يحدد نوع العقدة. فالعناصر رمزها 1، وهو معرّف أيضاً كخاصية ثابتة Node.ELEMENT_NODE. أما عقد النصوص، التي تمثل مقطعاً نصياً في المستند، فرمزها 3 (Node.TEXT_NODE). وللتعليقات الرمز 8 (Node.COMMENT_NODE).
وثمة طريقة أخرى لتصور شجرة مستندنا على النحو الآتي:
الأوراق هي عقد النصوص، وتشير الأسهم إلى علاقات الأبوة والبنوة بين العقد.
المعيار
استخدام رموز عددية غامضة لتمثيل أنواع العقد ليس أسلوباً يشبه JavaScript كثيراً. وسنرى لاحقاً في هذا الفصل أن أجزاء أخرى من واجهة DOM تبدو أيضاً مرهقة وغريبة. ويعود السبب في ذلك إلى أن واجهة DOM لم تُصمم لـ JavaScript وحدها. بل تحاول أن تكون واجهة محايدة تجاه اللغات يمكن استخدامها في أنظمة أخرى أيضاً — لا في HTML فقط، بل في XML كذلك، وهو صيغة بيانات عامة ذات صياغة شبيهة بـ HTML.
وهذا مؤسف. فالمعايير مفيدة غالباً. لكن في هذه الحالة، ليست الميزة (الاتساق بين اللغات) مقنعة إلى هذا الحد. فامتلاك واجهة مندمجة اندماجاً سليماً مع اللغة التي تستخدمها سيوفر عليك وقتاً أكثر من امتلاك واجهة مألوفة عبر اللغات.
ومن أمثلة هذا الاندماج الضعيف خاصية childNodes التي تمتلكها عقد العناصر في DOM. تحمل هذه الخاصية كائناً شبيهًا بالمصفوفة له خاصية length وخصائص مرقّمة للوصول إلى العقد الأبناء. لكنه نسخة من النوع NodeList، لا مصفوفة حقيقية، ولذلك لا يمتلك طرقاً مثل slice وmap.
ثم هناك مشكلات ناتجة ببساطة عن تصميم رديء. فمثلاً، لا توجد طريقة لإنشاء عقدة جديدة وإضافة أبناء أو خصائص إليها فوراً. بل عليك أولاً إنشاءها ثم إضافة الأبناء والخصائص واحداً واحداً، باستخدام تأثيرات جانبية. والشيفرة التي تتفاعل كثيراً مع DOM تميل إلى أن تصبح طويلة ومكررة وقبيحة.
لكن هذه العيوب ليست قاتلة. ولأن JavaScript تتيح لنا إنشاء تجريداتنا الخاصة، فمن الممكن تصميم طرق محسّنة للتعبير عن العمليات التي ننفذها. وكثير من المكتبات المخصصة للبرمجة في المتصفح تأتي بأدوات كهذه.
التنقل في الشجرة
تحتوي عقد DOM على ثروة من الروابط إلى العقد المجاورة الأخرى. يوضح المخطط الآتي هذه الروابط:
مع أن المخطط يُظهر رابطاً واحداً فقط من كل نوع، فإن لكل عقدة خاصية parentNode تشير إلى العقدة التي تنتمي إليها، إن وُجدت. وبالمثل، لكل عقدة عنصر (نوع العقدة 1) خاصية childNodes تشير إلى كائن شبيه بالمصفوفة يحمل أبناءها.
من حيث المبدأ، يمكنك التنقل إلى أي مكان في الشجرة باستخدام رابطي الأب والابن هذين فحسب. لكن JavaScript تمنحك أيضاً وصولاً إلى عدد من الروابط الإضافية المريحة. فخاصيتا firstChild وlastChild تشيران إلى أول عنصر ابن وآخره، أو تكون قيمتهما null للعقد التي لا أبناء لها. وبالمثل، تشير previousSibling وnextSibling إلى العقد المجاورة، أي العقد التي لها الأب نفسه وتظهر مباشرة قبل العقدة نفسها أو بعدها. فبالنسبة للابن الأول، ستكون previousSibling هي null، وبالنسبة للابن الأخير، ستكون nextSibling هي null.
وثمة أيضاً خاصية children، وهي مثل childNodes لكنها تحتوي على الأبناء من العناصر (النوع 1) فقط، لا على أنواع أخرى من العقد الأبناء. وقد تكون مفيدة عندما لا يهمك أمر عقد النصوص.
عند التعامل مع بنية بيانات متداخلة كهذه، غالباً ما تكون الدوال التعاودية مفيدة. تفحص الدالة الآتية مستنداً بحثاً عن عقد نصوص تحتوي نصاً معطى وتُرجع true عند العثور على واحدة:
function talksAbout(node, string) {
if (node.nodeType == Node.ELEMENT_NODE) {
for (let child of node.childNodes) {
if (talksAbout(child, string)) {
return true;
}
}
return false;
} else if (node.nodeType == Node.TEXT_NODE) {
return node.nodeValue.indexOf(string) > -1;
}
}
console.log(talksAbout(document.body, "book"));
// → true
تحمل خاصية nodeValue لعقدة النص النصَّ الذي تمثله.
العثور على العناصر
التنقل عبر هذه الروابط بين الآباء والأبناء والأشقاء مفيد غالباً. لكن إذا أردنا العثور على عقدة محددة في المستند، فبلوغها بالانطلاق من document.body واتباع مسار ثابت من الخصائص فكرة سيئة. فالقيام بذلك يرسّخ في برنامجنا افتراضات حول البنية الدقيقة للمستند — بنية قد ترغب في تغييرها لاحقاً. وثمة عامل معقّد آخر هو أن عقد النصوص تُنشأ حتى للفراغات البيضاء بين العقد. فوسم <body> في المستند المثال ليس له ثلاثة أبناء فحسب (<h1> وعنصرا <p>)، بل سبعة: هؤلاء الثلاثة، إضافة إلى الفراغات قبلها وبعدها وبينها.
إذا أردنا الحصول على خاصية href للرابط في ذلك المستند، فلا نريد أن نقول شيئاً مثل «أحضِر الابن الثاني للابن السادس لجسم المستند». سيكون أفضل لو استطعنا أن نقول «أحضِر أول رابط في المستند». وهذا ممكن.
let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);
لكل عقد عنصر طريقة getElementsByTagName، تجمع كل العناصر ذات اسم الوسم المعطى التي هي من سلالة تلك العقدة (أبناء مباشرون أو غير مباشرين) وتُرجعها ككائن شبيه بالمصفوفة.
للعثور على عقدة واحدة محددة، يمكنك إعطاؤها خاصية id واستخدام document.getElementById بدلاً من ذلك.
<p>My ostrich Gertrude:</p>
<p><img id="gertrude" src="img/ostrich.png"></p>
<script>
let ostrich = document.getElementById("gertrude");
console.log(ostrich.src);
</script>
وثمة طريقة ثالثة مشابهة هي getElementsByClassName، التي تبحث، مثل getElementsByTagName، في محتويات عقدة عنصر وتسترجع كل العناصر التي تحتوي النص المعطى في خاصية class لديها.
تغيير المستند
يمكن تغيير كل شيء تقريباً في بنية بيانات DOM. ويمكن تعديل شكل شجرة المستند بتغيير علاقات الأبوة والبنوة. فللعقد طريقة remove لإزالتها من عقدة أبيها الحالية. ولإضافة عقدة ابن إلى عقدة عنصر، يمكننا استخدام appendChild، التي تضعها في نهاية قائمة الأبناء، أو insertBefore، التي تُدرج العقدة المعطاة كمعطى أول قبل العقدة المعطاة كمعطى ثانٍ.
<p>One</p>
<p>Two</p>
<p>Three</p>
<script>
let paragraphs = document.body.getElementsByTagName("p");
document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>
لا يمكن للعقدة أن توجد في المستند إلا في مكان واحد. ولذلك فإن إدراج الفقرة Three أمام الفقرة One سيزيلها أولاً من نهاية المستند ثم يُدرجها في المقدمة، فتكون النتيجة Three/One/Two. وكل العمليات التي تُدرج عقدة في مكان ما ستتسبب، كتأثير جانبي، في إزالتها من موضعها الحالي (إن كان لها موضع).
تُستخدم طريقة replaceChild لاستبدال عقدة ابن بأخرى. وتأخذ كمعطيين عقدتين: عقدة جديدة والعقدة المراد استبدالها. ويجب أن تكون العقدة المستبدَلة ابناً للعنصر الذي تُستدعى الطريقة عليه. لاحظ أن كلاً من replaceChild وinsertBefore تتوقعان العقدة الجديدة كمعطى أول لهما.
إنشاء العقد
لنفترض أننا نريد كتابة سكربت يستبدل كل الصور (وسوم <img>) في المستند بالنص الموجود في خصائص alt الخاصة بها، والتي تحدد تمثيلاً نصياً بديلاً للصورة. وهذا يتضمن لا إزالة الصور فحسب، بل إضافة عقدة نص جديدة لتحل محلها أيضاً.
<p>The <img src="img/cat.png" alt="Cat"> in the
<img src="img/hat.png" alt="Hat">.</p>
<p><button onclick="replaceImages()">Replace</button></p>
<script>
function replaceImages() {
let images = document.body.getElementsByTagName("img");
for (let i = images.length - 1; i >= 0; i--) {
let image = images[i];
if (image.alt) {
let text = document.createTextNode(image.alt);
image.parentNode.replaceChild(text, image);
}
}
}
</script>
إذا أعطينا createTextNode نصاً، فإنها تمنحنا عقدة نص يمكننا إدراجها في المستند لتظهر على الشاشة.
تبدأ الحلقة التي تمر على الصور من نهاية القائمة. وهذا ضروري لأن قائمة العقد التي تُرجعها طريقة مثل getElementsByTagName (أو خاصية مثل childNodes) حيّة. أي أنها تُحدَّث مع تغيّر المستند. فلو بدأنا من المقدمة، لأدى إزالة الصورة الأولى إلى فقدان القائمة عنصرها الأول، بحيث تتوقف الحلقة عند تكرارها الثاني، حيث تكون i مساوية 1، لأن طول المجموعة أصبح 1 أيضاً.
إذا أردت مجموعة عقد صلبة، لا حيّة، يمكنك تحويل المجموعة إلى مصفوفة حقيقية باستدعاء Array.from.
let arrayish = {0: "one", 1: "two", length: 2};
let array = Array.from(arrayish);
console.log(array.map(s => s.toUpperCase()));
// → ["ONE", "TWO"]
لإنشاء عقد عناصر، يمكنك استخدام طريقة document.createElement. تأخذ هذه الطريقة اسم وسم وتُرجع عقدة فارغة جديدة من النوع المعطى.
يعرّف المثال الآتي دالة مساعدة elt، تُنشئ عقدة عنصر وتتعامل مع بقية معطياتها كأبناء لتلك العقدة. ثم تُستخدم هذه الدالة لإضافة إسناد إلى اقتباس.
<blockquote id="quote">
No book can ever be finished. While working on it we learn
just enough to find it immature the moment we turn away
from it.
</blockquote>
<script>
function elt(type, ...children) {
let node = document.createElement(type);
for (let child of children) {
if (typeof child != "string") node.appendChild(child);
else node.appendChild(document.createTextNode(child));
}
return node;
}
document.getElementById("quote").appendChild(
elt("footer", "—",
elt("strong", "Karl Popper"),
", preface to the second edition of ",
elt("em", "The Open Society and Its Enemies"),
", 1950"));
</script>
الخصائص
يمكن الوصول إلى بعض خصائص العناصر، مثل href للروابط، عبر خاصية تحمل الاسم نفسه على كائن DOM الخاص بالعنصر. وهذا هو الحال في معظم الخصائص القياسية الشائعة الاستخدام.
تتيح لك HTML تعيين أي خاصية تريدها على العقد. وقد يكون هذا مفيداً لأنه يتيح لك تخزين معلومات إضافية في المستند. ولقراءة الخصائص المخصصة أو تغييرها، وهي غير متاحة كخصائص كائن عادية، عليك استخدام الطريقتين getAttribute وsetAttribute.
<p data-classified="secret">The launch code is 00000000.</p>
<p data-classified="unclassified">I have two feet.</p>
<script>
let paras = document.body.getElementsByTagName("p");
for (let para of Array.from(paras)) {
if (para.getAttribute("data-classified") == "secret") {
para.remove();
}
}
</script>
يُوصى بجعل أسماء هذه الخصائص المبتكرة تبدأ بـdata- لضمان عدم تعارضها مع أي خصائص أخرى.
وثمة خاصية شائعة الاستخدام هي class، وهي كلمة محجوزة في لغة JavaScript. ولأسباب تاريخية — إذ لم تستطع بعض تطبيقات JavaScript القديمة التعامل مع أسماء خصائص تطابق كلمات محجوزة — تُسمى الخاصية المستخدمة للوصول إلى هذه الخاصية className. ويمكنك أيضاً الوصول إليها باسمها الحقيقي، "class"، عبر الطريقتين getAttribute وsetAttribute.
التخطيط
ربما لاحظت أن أنواعاً مختلفة من العناصر تُخطَّط بطرق مختلفة. فبعضها، مثل الفقرات (<p>) أو العناوين (<h1>)، يشغل عرض المستند كله ويُعرض في أسطر منفصلة. وتُسمى هذه عناصر الكتلة (block). وبعضها الآخر، مثل الروابط (<a>) أو العنصر <strong>، يُعرض في السطر نفسه مع النص المحيط به. وتُسمى هذه العناصر العناصر المضمّنة (inline).
بالنسبة لأي مستند معطى، تستطيع المتصفحات حساب تخطيط يمنح كل عنصر حجماً وموضعاً بناءً على نوعه ومحتواه. ثم يُستخدم هذا التخطيط لرسم المستند فعلياً.
يمكن الوصول إلى حجم العنصر وموضعه من JavaScript. فخاصيتا offsetWidth وoffsetHeight تمنحانك المساحة التي يشغلها العنصر بوحدة البكسل. والبكسل هو وحدة القياس الأساسية في المتصفح. وقد كان يقابل تقليدياً أصغر نقطة يمكن للشاشة رسمها، لكن على الشاشات الحديثة، التي تستطيع رسم نقاط صغيرة جداً، قد لا يبقى الأمر كذلك، إذ قد يمتد بكسل المتصفح على عدة نقاط عرض.
وبالمثل، تمنحك clientWidth وclientHeight حجم المساحة داخل العنصر، متجاهلة عرض الحدود.
<p style="border: 3px solid red">
I'm boxed in
</p>
<script>
let para = document.body.getElementsByTagName("p")[0];
console.log("clientHeight:", para.clientHeight);
// → 19
console.log("offsetHeight:", para.offsetHeight);
// → 25
</script>
أكثر الطرق فعالية للعثور على الموضع الدقيق لعنصر على الشاشة هي طريقة getBoundingClientRect. فهي تُرجع كائناً بخصائص top وbottom وleft وright، تشير إلى مواضع البكسل لجوانب العنصر نسبةً إلى أعلى يسار الشاشة. وإذا أردت مواضع بكسل نسبةً إلى المستند كله، فيجب أن تضيف موضع التمرير الحالي، الذي تجده في الارتباطين pageXOffset وpageYOffset.
قد يكون تخطيط المستند عملاً شاقاً. ومن أجل السرعة، لا تعيد محركات المتصفحات تخطيط المستند فوراً في كل مرة تغيّره، بل تنتظر أطول وقت ممكن قبل فعل ذلك. وعندما ينتهي برنامج JavaScript غيّر المستند من التنفيذ، سيكون على المتصفح حساب تخطيط جديد لرسم المستند المتغير على الشاشة. وعندما يطلب برنامج موضع شيء أو حجمه بقراءة خصائص مثل offsetHeight أو باستدعاء getBoundingClientRect، فإن توفير تلك المعلومات يتطلب أيضاً حساب تخطيط.
البرنامج الذي يتبادل مراراً بين قراءة معلومات تخطيط DOM وتغيير DOM يجبر عدداً كبيراً من حسابات التخطيط على الحدوث، وبالتالي سيعمل ببطء شديد. والشيفرة الآتية مثال على ذلك. فهي تحتوي برنامجين مختلفين يبنيان سطراً من محارف X بعرض 2000 بكسل ويقيسان الوقت الذي يستغرقه كل منهما.
<p><span id="one"></span></p>
<p><span id="two"></span></p>
<script>
function time(name, action) {
let start = Date.now(); // الوقت الحالي بالمللي ثانية
action();
console.log(name, "took", Date.now() - start, "ms");
}
time("naive", () => {
let target = document.getElementById("one");
while (target.offsetWidth < 2000) {
target.appendChild(document.createTextNode("X"));
}
});
// → naive took 32 ms
time("clever", function() {
let target = document.getElementById("two");
target.appendChild(document.createTextNode("XXXXX"));
let total = Math.ceil(2000 / (target.offsetWidth / 5));
target.firstChild.nodeValue = "X".repeat(total);
});
// → clever took 1 ms
</script>
التنسيق
رأينا أن عناصر HTML المختلفة تُرسم بطرق مختلفة. فبعضها يُعرض ككتل، وبعضها مضمّناً. وبعضها يضيف تنسيقاً — فـ<strong> يجعل محتواه عريضاً، و<a> يجعله أزرق ويضع تحته خطاً.
الطريقة التي يُظهر بها وسم <img> صورة، أو التي يجعل بها وسم <a> الرابط يُتبع عند النقر عليه، مرتبطة ارتباطاً وثيقاً بنوع العنصر. لكن يمكننا تغيير التنسيق المرتبط بعنصر، مثل لون النص أو الخط تحته. إليك مثالاً يستخدم خاصية style:
<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>
قد تحتوي خاصية style على تعريف واحد أو أكثر، وهو خاصية (مثل color) تتبعها نقطتان ثم قيمة (مثل green). وعندما يكون هناك أكثر من تعريف واحد، يجب فصلها بفواصل منقوطة، كما في "color: red; border: none".
يمكن التأثير في كثير من جوانب المستند عبر التنسيق. فعلى سبيل المثال، تتحكم خاصية display فيما إذا كان العنصر يُعرض ككتلة أم كعنصر مضمّن.
This text is displayed <strong>inline</strong>,
<strong style="display: block">as a block</strong>, and
<strong style="display: none">not at all</strong>.
سينتهي وسم block في سطر خاص به، لأن عناصر الكتلة لا تُعرض مضمّنة مع النص المحيط بها. أما الوسم الأخير فلا يُعرض إطلاقاً — فـdisplay: none يمنع العنصر من الظهور على الشاشة. وهذه إحدى طرق إخفاء العناصر. وغالباً ما تكون مفضلة على إزالتها من المستند بالكامل لأنها تسهّل إظهارها مجدداً لاحقاً.
يمكن لشيفرة JavaScript التعامل مباشرة مع تنسيق عنصر عبر خاصية style الخاصة بالعنصر. تحمل هذه الخاصية كائناً له خصائص لكل خصائص التنسيق الممكنة. وقيم هذه الخصائص نصوص، يمكننا الكتابة فيها لتغيير جانب معين من تنسيق العنصر.
<p id="para" style="color: purple">
Nice text
</p>
<script>
let para = document.getElementById("para");
console.log(para.style.color);
para.style.color = "magenta";
</script>
تحتوي بعض أسماء خصائص التنسيق على شرطات، مثل font-family. ولأن التعامل مع أسماء الخصائص هذه مرهق في JavaScript (إذ سيتعين عليك كتابة style["font-family"])، فإن أسماء الخصائص في كائن style لمثل هذه الخصائص تُحذف منها الشرطات وتُكتب الحروف بعدها كبيرة (style.fontFamily).
التنسيقات المتتالية
يُسمى نظام التنسيق في HTML CSS، اختصار Cascading Style Sheets (أوراق الأنماط المتتالية). وورقة الأنماط مجموعة من القواعد لتحديد كيفية تنسيق العناصر في مستند. ويمكن إعطاؤها داخل وسم <style>.
<style>
strong {
font-style: italic;
color: gray;
}
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>
تشير كلمة المتتالية (cascading) في الاسم إلى أن عدة قواعد كهذه تُدمج لإنتاج التنسيق النهائي للعنصر. في المثال، يُغطّى التنسيق الافتراضي لوسوم <strong>، الذي يمنحها font-weight: bold، بالقاعدة الموجودة في وسم <style>، التي تضيف font-style وcolor.
عندما تحدد قواعد متعددة قيمة للخاصية نفسها، تحصل القاعدة المقروءة الأحدث على أسبقية أعلى وتفوز. فمثلاً، لو كانت القاعدة في وسم <style> تتضمن font-weight: normal، مناقضةً قاعدة font-weight الافتراضية، لكان النص عادياً، لا عريضاً. أما التنسيقات في خاصية style المطبقة مباشرة على العقدة فلها أعلى أسبقية وتفوز دائماً.
يمكن استهداف أشياء غير أسماء الوسوم في قواعد CSS. فالقاعدة الخاصة بـ.abc تنطبق على كل العناصر التي تحتوي "abc" في خاصية class لديها. والقاعدة الخاصة بـ#xyz تنطبق على العنصر الذي له خاصية id تساوي "xyz" (والتي ينبغي أن تكون فريدة داخل المستند).
.subtle {
color: gray;
font-size: 80%;
}
#header {
background: blue;
color: white;
}
/* عناصر p ذات المعرّف main والأصناف a و b */
p#main.a.b {
margin-bottom: 20px;
}
لا تنطبق قاعدة الأسبقية التي تفضّل القاعدة المعرّفة الأحدث إلا عندما تكون للقواعد نوعية (specificity) متساوية. ونوعية القاعدة مقياس لمدى دقة وصفها للعناصر المطابقة، وتُحدد بعدد وجنس (وسم أو صنف أو ID) جوانب العنصر التي تتطلبها. فمثلاً، قاعدة تستهدف p.a أكثر نوعية من القواعد التي تستهدف p أو .a فقط، وبالتالي تأخذ أسبقية عليها.
الصيغة p > a {…} تطبق التنسيقات المعطاة على كل وسوم <a> التي هي أبناء مباشرون لوسوم <p>. وبالمثل، تنطبق p a {…} على كل وسوم <a> الموجودة داخل وسوم <p>، سواء كانت أبناء مباشرين أو غير مباشرين.
محددات الاستعلام
لن نستخدم أوراق الأنماط كثيراً في هذا الكتاب. وفهمها مفيد عند البرمجة في المتصفح، لكنها معقدة بما يكفي لتستحق كتاباً منفصلاً. والسبب الرئيسي في تقديمي صياغة المحدد (selector) — وهي الصيغة المستخدمة في أوراق الأنماط لتحديد العناصر التي تنطبق عليها مجموعة تنسيقات — هو أننا نستطيع استخدام هذه اللغة المصغرة نفسها طريقةً فعالة للعثور على عناصر DOM.
تأخذ طريقة querySelectorAll، المعرّفة على كائن document وعلى عقد العناصر معاً، نص محدد وتُرجع NodeList يحتوي كل العناصر التي يطابقها.
<p>And if you go chasing
<span class="animal">rabbits</span></p>
<p>And you know you're going to fall</p>
<p>Tell 'em a <span class="character">hookah smoking
<span class="animal">caterpillar</span></span></p>
<p>Has given you the call</p>
<script>
function count(selector) {
return document.querySelectorAll(selector).length;
}
console.log(count("p")); // كل عناصر <p>
// → 4
console.log(count(".animal")); // الصنف animal
// → 2
console.log(count("p .animal")); // animal داخل <p>
// → 2
console.log(count("p > .animal")); // ابن مباشر لـ<p>
// → 1
</script>
على عكس طرق مثل getElementsByTagName، الكائن الذي تُرجعه querySelectorAll ليس حيّاً. فلن يتغير عندما تغيّر المستند. لكنه ما زال ليس مصفوفة حقيقية، لذا تحتاج إلى استدعاء Array.from إذا أردت التعامل معه كذلك.
تعمل طريقة querySelector (دون الجزء All) بطريقة مشابهة. وتكون مفيدة إذا أردت عنصراً واحداً محدداً. فهي تُرجع أول عنصر مطابق فقط، أو null عندما لا يطابق أي عنصر.
تحديد الموضع والتحريك
تؤثر خاصية التنسيق position في التخطيط تأثيراً قوياً. وقيمتها الافتراضية static، بمعنى أن العنصر يستقر في مكانه الطبيعي في المستند. وعند ضبطها على relative، يظل العنصر شاغلاً لمساحة في المستند، لكن يمكن الآن استخدام خاصيتي التنسيق top وleft لتحريكه نسبةً إلى ذلك المكان الطبيعي. وعند ضبط position على absolute، يُزال العنصر من تدفق المستند الطبيعي — أي أنه لم يعد يشغل مساحة وقد يتداخل مع عناصر أخرى. ويمكن استخدام خاصيتي top وleft لوضعه وضعاً مطلقاً نسبةً إلى الزاوية العليا اليسرى لأقرب عنصر حاو ليس لخاصية position لديه قيمة static، أو نسبةً إلى المستند إن لم يوجد عنصر حاو كهذا.
يمكننا استخدام هذا لإنشاء تحريك. يعرض المستند الآتي صورة قط يتحرك في مسار بيضاوي:
<p style="text-align: center">
<img src="img/cat.png" style="position: relative">
</p>
<script>
let cat = document.querySelector("img");
let angle = Math.PI / 2;
function animate(time, lastTime) {
if (lastTime != null) {
angle += (time - lastTime) * 0.001;
}
cat.style.top = (Math.sin(angle) * 20) + "px";
cat.style.left = (Math.cos(angle) * 200) + "px";
requestAnimationFrame(newTime => animate(newTime, time));
}
requestAnimationFrame(animate);
</script>
صورتنا موسّطة في الصفحة ومُعطاة position بقيمة relative. وسنحدّث مراراً تنسيقي top وleft لتلك الصورة لتحريكها.
يستخدم السكربت requestAnimationFrame لجدولة تشغيل دالة animate كلما كان المتصفح مستعداً لإعادة رسم الشاشة. وتستدعي دالة animate نفسها requestAnimationFrame مجدداً لجدولة التحديث التالي. وعندما تكون نافذة المتصفح (أو التبويب) نشطة، سيؤدي هذا إلى حدوث التحديثات بمعدل نحو 60 مرة في الثانية، وهو ما ينتج عادةً تحريكاً جميل المظهر.
لو حدّثنا DOM في حلقة فحسب، لتجمّدت الصفحة ولما ظهر شيء على الشاشة. فالمتصفحات لا تحدّث عرضها أثناء تشغيل برنامج JavaScript، ولا تسمح بأي تفاعل مع الصفحة. ولهذا نحتاج إلى requestAnimationFrame — فهي تُعلم المتصفح أننا انتهينا في الوقت الحالي، فيمكنه المضي قدماً وفعل ما تفعله المتصفحات، مثل تحديث الشاشة والاستجابة لأفعال المستخدم.
تُمرَّر إلى دالة التحريك الوقت الحالي كمعطى. ولضمان ثبات حركة القط في كل مللي ثانية، فهي تبني سرعة تغيّر الزاوية على الفرق بين الوقت الحالي وآخر مرة نُفذت فيها الدالة. فلو حرّكت الزاوية بمقدار ثابت في كل خطوة فقط، لتقطّعت الحركة عندما يمنعها مثلاً مهمة ثقيلة أخرى تعمل على الحاسوب نفسه من التنفيذ لجزء من الثانية.
تتم الحركة في دوائر باستخدام الدالتين المثلثيتين Math.cos وMath.sin. ولمن ليس على دراية بهما، سأعرّفهما تعريفاً موجزاً، لأننا سنستخدمهما من حين إلى آخر في هذا الكتاب.
تفيد Math.cos وMath.sin في إيجاد النقاط الواقعة على دائرة حول النقطة (0، 0) نصف قطرها 1. وتفسّر كلتا الدالتين معطاها كموضع على هذه الدائرة، حيث يدل 0 على النقطة الواقعة في أقصى يمين الدائرة، سيراً في اتجاه عقارب الساعة حتى تأخذنا 2π (نحو 6.28) حول الدائرة كاملة. وتخبرك Math.cos بالإحداثي x للنقطة المقابلة للموضع المعطى، بينما تعطي Math.sin الإحداثي y. والمواضع (أو الزوايا) الأكبر من 2π أو الأصغر من 0 صحيحة — فالدوران يتكرر بحيث تشير a+2π إلى الزاوية نفسها التي تشير إليها a.
تُسمى وحدة قياس الزوايا هذه الراديان — فالدائرة الكاملة 2π راديان، على غرار كونها 360 درجة عند القياس بالدرجات. والثابت π متاح باسم Math.PI في JavaScript.
تحتفظ شيفرة تحريك القط بعدّاد، angle، للزاوية الحالية للتحريك وتزيده في كل مرة تُستدعى فيها دالة animate. ثم يمكنها استخدام هذه الزاوية لحساب الموضع الحالي لعنصر الصورة. ويُحسب تنسيق top بـMath.sin مضروباً في 20، وهو نصف القطر الرأسي لمسارنا البيضاوي. ويُبنى تنسيق left على Math.cos مضروباً في 200 بحيث يكون المسار البيضاوي أعرض بكثير مما هو مرتفع.
لاحظ أن التنسيقات تحتاج عادةً إلى وحدات. وفي هذه الحالة، علينا إلحاق "px" بالعدد لنخبر المتصفح أننا نعدّ بالبكسل (لا بالسنتيمترات أو «ems» أو وحدات أخرى). ومن السهل نسيان ذلك. واستخدام أعداد بلا وحدات سيؤدي إلى تجاهل تنسيقك — إلا إذا كان العدد 0، الذي يعني الشيء نفسه دائماً، بصرف النظر عن وحدته.
الملخص
يمكن لبرامج JavaScript أن تفحص المستند الذي يعرضه المتصفح وأن تتدخل فيه عبر بنية بيانات تُسمى DOM. تمثل بنية البيانات هذه نموذج المتصفح للمستند، ويمكن لبرنامج JavaScript تعديلها لتغيير المستند المرئي.
ينظَّم DOM كشجرة، حيث تُرتَّب العناصر ترتيباً هرمياً وفق بنية المستند. وللكائنات التي تمثل العناصر خصائص مثل parentNode وchildNodes، يمكن استخدامها للتنقل عبر هذه الشجرة.
يمكن التأثير في طريقة عرض المستند عبر التنسيق، سواء بإرفاق تنسيقات بالعقد مباشرة أو بتعريف قواعد تطابق عقداً معينة. وهناك خصائص تنسيق مختلفة كثيرة، مثل color أو display. ويمكن لشيفرة JavaScript التعامل مع تنسيق عنصر مباشرة عبر خاصية style الخاصة به.
التمارين
بناء جدول
يُبنى جدول HTML ببنية الوسوم الآتية:
<table>
<tr>
<th>name</th>
<th>height</th>
<th>place</th>
</tr>
<tr>
<td>Kilimanjaro</td>
<td>5895</td>
<td>Tanzania</td>
</tr>
</table>
لكل صف، يحتوي وسم <table> على وسم <tr>. وداخل وسوم <tr> هذه يمكننا وضع عناصر خلايا: إما خلايا عناوين (<th>) أو خلايا عادية (<td>).
إذا أُعطيت مجموعة بيانات لجبال، وهي مصفوفة كائنات لها خصائص name وheight وplace، فأنشئ بنية DOM لجدول يعدّد هذه الكائنات. وله عمود لكل مفتاح وصف لكل كائن، إضافة إلى صف رأس يحتوي عناصر <th> في الأعلى تسرد أسماء الأعمدة.
اكتب ذلك بحيث تُشتق الأعمدة تلقائياً من الكائنات، بأخذ أسماء خصائص الكائن الأول في البيانات.
اعرض الجدول الناتج في المستند بإضافته إلى العنصر الذي له خاصية id تساوي "mountains".
بعد أن يعمل هذا، حاذِ الخلايا التي تحتوي قيماً عددية إلى اليمين بضبط خاصية style.textAlign لديها على "right".
<h1>Mountains</h1>
<div id="mountains"></div>
<script>
const MOUNTAINS = [
{name: "Kilimanjaro", height: 5895, place: "Tanzania"},
{name: "Everest", height: 8848, place: "Nepal"},
{name: "Mount Fuji", height: 3776, place: "Japan"},
{name: "Vaalserberg", height: 323, place: "Netherlands"},
{name: "Denali", height: 6168, place: "United States"},
{name: "Popocatepetl", height: 5465, place: "Mexico"},
{name: "Mont Blanc", height: 4808, place: "Italy/France"}
];
// شيفرتك هنا
</script>
إظهار التلميح
يمكنك استخدام document.createElement لإنشاء عقد عناصر جديدة، وdocument.createTextNode لإنشاء عقد نصوص، وطريقة appendChild لوضع العقد داخل عقد أخرى.
ستحتاج إلى المرور على أسماء المفاتيح مرة لملء الصف العلوي، ثم مرة أخرى لكل كائن في المصفوفة لبناء صفوف البيانات. وللحصول على مصفوفة من أسماء المفاتيح من الكائن الأول، ستكون Object.keys مفيدة.
لإضافة الجدول إلى عقدة الأب الصحيحة، يمكنك استخدام document.getElementById أو document.querySelector مع "#mountains" للعثور على العقدة.
العناصر حسب اسم الوسم
تُرجع طريقة document.getElementsByTagName كل العناصر الأبناء ذات اسم وسم معطى. نفّذ نسختك الخاصة من هذه الطريقة كدالة تأخذ عقدة ونصاً (اسم الوسم) كمعطيين وتُرجع مصفوفة تحتوي كل عقد العناصر من السلالة ذات اسم الوسم المعطى. وينبغي أن تمر دالتك على المستند بنفسها. ولا يجوز لها استخدام طريقة مثل querySelectorAll للقيام بالعمل.
للعثور على اسم وسم عنصر، استخدم خاصية nodeName الخاصة به. لكن لاحظ أن هذه ستعيد اسم الوسم بحروف كبيرة كلها. استخدم طريقتي النصوص toLowerCase أو toUpperCase للتعويض عن ذلك.
<h1>Heading with a <span>span</span> element.</h1>
<p>A paragraph with <span>one</span>, <span>two</span>
spans.</p>
<script>
function byTagName(node, tagName) {
// شيفرتك هنا.
}
console.log(byTagName(document.body, "h1").length);
// → 1
console.log(byTagName(document.body, "span").length);
// → 3
let para = document.querySelector("p");
console.log(byTagName(para, "span").length);
// → 2
</script>
إظهار التلميح
التعبير عن الحل أسهل باستخدام دالة تعاودية، مشابهة لدالة talksAbout المعرّفة سابقاً في هذا الفصل.
يمكنك استدعاء byTagname نفسها تعاودياً، مع دمج المصفوفات الناتجة لإنتاج المخرجات. أو يمكنك إنشاء دالة داخلية تستدعي نفسها تعاودياً ولها وصول إلى ارتباط مصفوفة معرّف في الدالة الخارجية، تضيف إليه العناصر المطابقة التي تجدها. لا تنسَ استدعاء الدالة الداخلية مرة واحدة من الدالة الخارجية لبدء العملية.
يجب أن تتحقق الدالة التعاودية من نوع العقدة. ونحن هنا معنيون فقط بنوع العقدة 1 (Node.ELEMENT_NODE). ولهذه العقد، يجب أن نمر على أبنائها، ولكل ابن أن نرى هل يطابق الاستعلام مع إجراء استدعاء تعاودي عليه أيضاً لفحص أبنائه.
قبعة القط
وسّع تحريك القط المعرّف سابقاً بحيث يدور كل من القط وقبعته (<img src="img/hat.png">) في جانبين متقابلين من المسار البيضاوي.
أو اجعل القبعة تدور حول القط. أو غيّر التحريك بطريقة أخرى مثيرة للاهتمام.
لتسهيل تحديد موضع عدة كائنات، سترغب على الأرجح في التحول إلى تحديد الموضع المطلق. وهذا يعني أن top وleft تُحسبان نسبةً إلى أعلى يسار المستند. ولتجنب استخدام إحداثيات سالبة، التي ستؤدي إلى خروج الصورة من الصفحة المرئية، يمكنك إضافة عدد ثابت من البكسلات إلى قيم الموضع.
<style>body { min-height: 200px }</style>
<img src="img/cat.png" id="cat" style="position: absolute">
<img src="img/hat.png" id="hat" style="position: absolute">
<script>
let cat = document.querySelector("#cat");
let hat = document.querySelector("#hat");
let angle = 0;
let lastTime = null;
function animate(time) {
if (lastTime != null) angle += (time - lastTime) * 0.001;
lastTime = time;
cat.style.top = (Math.sin(angle) * 40 + 40) + "px";
cat.style.left = (Math.cos(angle) * 200 + 230) + "px";
// امتداداتك هنا.
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
</script>
إظهار التلميح
تقيس Math.cos وMath.sin الزوايا بالراديان، حيث الدائرة الكاملة 2π. ولزاوية معطاة، يمكنك الحصول على الزاوية المقابلة بإضافة نصف هذا، وهو Math.PI. وقد يكون ذلك مفيداً لوضع القبعة في الجانب المقابل من المدار.