كيف يعمل جافاسكريبت: نظرة عميقة داخل محرك V8 من جوجل
مقدمة: الغوص في أعماق محرك V8
اليوم، سنغوص في أعماق محرك V8 الخاص بلغة جافاسكريبت، ونكشف الستار عن كيفية تنفيذ هذه اللغة البرمجية بالضبط. في مقال سابق، استعرضنا بنية المتصفح وأخذنا لمحة عامة عن Chromium. دعونا نلخص بعض النقاط الأساسية لنكون مستعدين للانطلاق في رحلتنا التقنية هذه.
معايير الويب وبنية المتصفح الحديث
تُعد معايير الويب مجموعة من القواعد التي يلتزم بها المتصفح في تنفيذها، وهي تحدد وتصف جوانب شبكة الويب العالمية. W3C هي منظمة دولية تعمل على تطوير معايير مفتوحة للويب، وتضمن أن الجميع يتبع نفس الإرشادات لتجنب الحاجة إلى دعم عشرات البيئات المختلفة تمامًا.
المتصفح الحديث هو قطعة برمجية معقدة للغاية، تحتوي على ملايين الأسطر البرمجية. لذلك، يتم تقسيمها إلى العديد من الوحدات المسؤولة عن منطق مختلف. ومن أهم جزأين في المتصفح هما محرك جافاسكريبت ومحرك العرض.
Blink هو محرك عرض مسؤول عن مسار العرض بأكمله، بما في ذلك أشجار DOM، والأنماط، والأحداث، والتكامل مع V8. يقوم بتحليل شجرة DOM، وحل الأنماط، وتحديد الهندسة البصرية لجميع العناصر. ومع المراقبة المستمرة للتغيرات الديناميكية عبر إطارات الرسوم المتحركة، يرسم Blink المحتوى على شاشتك. بينما محرك JS هو جزء كبير من المتصفح، ولم نتعمق في تفاصيله بعد.
مقدمة إلى محرك جافاسكريبت
يقوم محرك جافاسكريبت بتنفيذ وتجميع أكواد جافاسكريبت وتحويلها إلى شيفرة آلة أصلية (native machine code). لقد طوّر كل متصفح رئيسي محرك JS خاصًا به: يستخدم جوجل كروم محرك V8، وسفاري يستخدم JavaScriptCore، وفايرفوكس يستخدم SpiderMonkey. سنركز بشكل خاص على محرك V8 نظرًا لاستخدامه في Node.js و Electron، ولكن المحركات الأخرى مبنية بنفس الطريقة الأساسية.
ستتضمن كل خطوة رابطًا إلى الكود المسؤول عنها، حتى تتمكن من التعرف على قاعدة الكود ومواصلة البحث بعد هذا المقال. سنعمل مع نسخة طبق الأصل من V8 على GitHub، حيث توفر واجهة مستخدم مريحة ومعروفة للتنقل في قاعدة الكود.
تحضير الشيفرة المصدرية: من النص إلى الشجرة
أول ما يحتاجه محرك V8 هو تنزيل الشيفرة المصدرية. يمكن أن يتم ذلك عبر الشبكة، أو من ذاكرة التخزين المؤقت (cache)، أو عبر عاملات الخدمة (service workers). بمجرد استلام الكود، نحتاج إلى تغييره بطريقة يمكن للمترجم (compiler) فهمها. تُسمى هذه العملية بـ parsing (التحليل النحوي) وتتكون من جزأين: الماسح الضوئي (scanner) والمحلل النحوي (parser) نفسه.
يأخذ الماسح الضوئي ملف JS ويحوله إلى قائمة من الرموز المميزة (tokens) المعروفة. توجد قائمة بجميع رموز JS المميزة في ملف keywords.txt. يلتقط المحلل النحوي هذه الرموز وينشئ شجرة بناء مجردة (Abstract Syntax Tree - AST): وهي تمثيل شجري للشيفرة المصدرية. تشير كل عقدة في الشجرة إلى بنية موجودة في الكود.
دعنا نلقي نظرة على مثال بسيط:
function foo ( ) {
let bar = 1 ;
return bar;
}
سينتج هذا الكود بنية الشجرة التالية:

يمكنك تنفيذ هذا الكود عن طريق تنفيذ اجتياز مسبق الترتيب (preorder traversal) (الجذر، اليسار، اليمين):
- تعريف الدالة
foo. - الإعلان عن المتغير
bar. - تعيين القيمة
1للمتغيرbar. - إرجاع المتغير
barمن الدالة.
سترى أيضًا VariableProxy – وهو عنصر يربط المتغير المجرد بمكان في الذاكرة. تُسمى عملية حل VariableProxy بـ Scope Analysis (تحليل النطاق). في مثالنا، ستكون نتيجة العملية هي أن جميع VariableProxy تشير إلى نفس المتغير bar.
نموذج التجميع في الوقت المناسب (JIT): سرعة ومرونة
بشكل عام، لكي يتم تنفيذ الكود الخاص بك، تحتاج لغة البرمجة إلى التحويل إلى شيفرة آلة. هناك عدة طرق لكيفية ومتى يمكن أن يحدث هذا التحويل. الطريقة الأكثر شيوعًا لتحويل الكود هي عن طريق إجراء التجميع المسبق (ahead-of-time compilation). تعمل هذه الطريقة تمامًا كما يوحي اسمها: يتم تحويل الكود إلى شيفرة آلة قبل تنفيذ برنامجك أثناء مرحلة التجميع. تُستخدم هذه الطريقة في العديد من لغات البرمجة مثل C++ و Java وغيرها.
على الجانب الآخر، لدينا الترجمة الفورية (interpretation): سيتم تنفيذ كل سطر من الكود في وقت التشغيل. تُستخدم هذه الطريقة عادةً في اللغات ذات الأنواع الديناميكية مثل جافاسكريبت و Python، لأنه من المستحيل معرفة النوع الدقيق قبل التنفيذ.
نظرًا لأن التجميع المسبق يمكنه تقييم الكود بالكامل معًا، فإنه يمكن أن يوفر تحسينًا أفضل وينتج في النهاية كودًا أكثر أداءً. أما الترجمة الفورية، فهي أبسط في التنفيذ، لكنها عادةً ما تكون أبطأ من الخيار المجمع.
لتحويل الكود بشكل أسرع وأكثر فعالية للغات الديناميكية، تم إنشاء نهج جديد يُسمى التجميع في الوقت المناسب (Just-in-Time (JIT) compilation). يجمع هذا النهج أفضل ما في الترجمة الفورية والتجميع. بينما يستخدم V8 الترجمة الفورية كطريقة أساسية، يمكنه اكتشاف الدوال التي تُستخدم بشكل متكرر أكثر من غيرها وتجميعها باستخدام معلومات النوع من عمليات التنفيذ السابقة. ومع ذلك، هناك احتمال أن يتغير النوع. في هذه الحالة، نحتاج إلى إلغاء تحسين الكود المجمع والعودة إلى الترجمة الفورية (بعد ذلك، يمكننا إعادة تجميع الدالة بعد الحصول على معلومات نوع جديدة). دعنا نستكشف كل جزء من تجميع JIT بمزيد من التفصيل.
المفسر (Ignition): توليد البايت كود
يستخدم محرك V8 مفسرًا يُدعى Ignition. في البداية، يأخذ هذا المفسر شجرة بناء مجردة (AST) ويولد شيفرة بايت (byte code). تتضمن تعليمات شيفرة البايت أيضًا بيانات وصفية (metadata)، مثل مواضع أسطر المصدر لأغراض التصحيح المستقبلي. بشكل عام، تتطابق تعليمات شيفرة البايت مع التجريدات الخاصة بـ JS.
الآن دعنا نأخذ مثالنا ونولد شيفرة بايت له يدويًا:
LdaSmi #1 // write 1 to accumulator
Star r0 // read to r0 (bar) from accumulator
Ldar r0 // write from r0 (bar) to accumulator
Return // returns accumulator
يحتوي Ignition على ما يسمى بـ accumulator (المراكم) – وهو مكان يمكنك تخزين/قراءة القيم فيه. يتجنب المراكم الحاجة إلى دفع وإخراج القيم من أعلى المكدس (stack). كما أنه وسيط ضمني للعديد من تعليمات شيفرة البايت ويحمل عادةً نتيجة العملية. Return تُرجع المراكم ضمنيًا.
يمكنك الاطلاع على جميع تعليمات شيفرة البايت المتاحة في الشيفرة المصدرية المقابلة. إذا كنت مهتمًا بكيفية تمثيل مفاهيم JS الأخرى (مثل الحلقات loops و async/await) في شيفرة البايت، أجد أنه من المفيد قراءة توقعات الاختبار هذه.
التنفيذ وجمع معلومات النوع: من البايت كود إلى التحسين
بعد التوليد، سيقوم Ignition بتفسير التعليمات باستخدام جدول من المعالجات (handlers) المفهرسة بواسطة شيفرة البايت. لكل شيفرة بايت، يمكن لـ Ignition البحث عن دوال المعالجة المقابلة وتنفيذها بالوسائط المقدمة. كما ذكرنا سابقًا، توفر مرحلة التنفيذ أيضًا معلومات النوع (type feedback) حول الكود. دعنا نكتشف كيف يتم جمعها وإدارتها.
أولاً، يجب أن نناقش كيف يمكن تمثيل كائنات جافاسكريبت في الذاكرة. في نهج ساذج، يمكننا إنشاء قاموس لكل كائن وربطه بالذاكرة.

ومع ذلك، عادةً ما يكون لدينا الكثير من الكائنات ذات نفس البنية، لذلك لن يكون فعالاً تخزين الكثير من القواميس المكررة. لحل هذه المشكلة، يفصل V8 بنية الكائن عن القيم نفسها باستخدام أشكال الكائنات (Object Shapes) (أو Maps داخليًا) ومتجه من القيم في الذاكرة. على سبيل المثال، نقوم بإنشاء كائن حرفي:
let c = { x : 3 }
let d = { x : 5 }
c.y = 4
في السطر الأول، سينتج شكل Map[c] يحتوي على الخاصية x بإزاحة 0. في السطر الثاني، سيعيد V8 استخدام نفس الشكل لمتغير جديد. بعد السطر الثالث، سينشئ شكلًا جديدًا Map[c1] للخاصية y بإزاحة 1 وينشئ رابطًا للشكل السابق Map[c].

في المثال أعلاه، يمكنك أن ترى أن كل كائن يمكن أن يكون له رابط إلى شكل الكائن حيث يمكن لـ V8 لكل اسم خاصية أن يجد إزاحة (offset) للقيمة في الذاكرة. أشكال الكائنات هي في الأساس قوائم مرتبطة (linked lists). لذا إذا كتبت c.x، سيذهب V8 إلى رأس القائمة، ويجد y هناك، ثم ينتقل إلى الشكل المتصل، وأخيرًا يحصل على x ويقرأ الإزاحة منه. ثم يذهب إلى متجه الذاكرة ويعيد العنصر الأول منه.
كما يمكنك أن تتخيل، في تطبيق ويب كبير سترى عددًا هائلاً من الأشكال المتصلة. في الوقت نفسه، يستغرق البحث في القائمة المرتبطة وقتًا خطيًا (linear time)، مما يجعل عمليات البحث عن الخصائص عملية مكلفة للغاية. لحل هذه المشكلة في V8، يمكنك استخدام ذاكرة التخزين المؤقت المضمنة (Inline Cache - IC). إنها تحفظ معلومات حول مكان العثور على الخصائص في الكائنات لتقليل عدد عمليات البحث. يمكنك التفكير في الأمر كموقع استماع في الكود الخاص بك: يتتبع جميع أحداث CALL و STORE و LOAD داخل دالة ويسجل جميع الأشكال التي تمر.
تُسمى بنية البيانات المستخدمة للاحتفاظ بـ IC بـ Feedback Vector (متجه التغذية الراجعة). إنه مجرد مصفوفة للاحتفاظ بجميع IC للدالة.
function load ( a ) {
return a.key;
}
للدالة أعلاه، سيبدو متجه التغذية الراجعة هكذا:
[{ slot : 0 , icType : LOAD, value : UNINIT }]
إنها دالة بسيطة تحتوي على IC واحد فقط من نوع LOAD وقيمة UNINIT. هذا يعني أنها غير مهيأة، ولا نعرف ما سيحدث بعد ذلك. دعنا نستدعي هذه الدالة بوسائط مختلفة ونرى كيف ستتغير ذاكرة التخزين المؤقت المضمنة.
let first = { key : 'first' } // shape A
let fast = { key : 'fast' } // the same shape A
let slow = { foo : 'slow' } // new shape B
load(first)
load(fast)
load(slow)
بعد الاستدعاء الأول للدالة load، ستحصل ذاكرة التخزين المؤقت المضمنة لدينا على قيمة محدثة:
[{ slot : 0 , icType : LOAD, value : MONO(A) }]
أصبحت هذه القيمة الآن أحادية الشكل (monomorphic)، مما يعني أن هذه الذاكرة المؤقتة يمكنها فقط الحل للشكل A. بعد الاستدعاء الثاني، سيتحقق V8 من قيمة IC وسيرى أنها أحادية الشكل ولها نفس شكل المتغير fast. لذلك سيعيد الإزاحة بسرعة ويحلها. في المرة الثالثة، يختلف الشكل عن الشكل المخزن. لذلك سيحل V8 المشكلة يدويًا ويحدث القيمة إلى حالة متعددة الأشكال (polymorphic) مع مصفوفة من شكلين محتملين.
[{ slot : 0 , icType : LOAD, value : POLY[A,B] }]
الآن في كل مرة نستدعي فيها هذه الدالة، يحتاج V8 إلى التحقق ليس فقط من شكل واحد ولكن التكرار عبر عدة احتمالات. للحصول على كود أسرع، يمكنك تهيئة الكائنات بنفس النوع وعدم تغيير هيكلها كثيرًا.
ملاحظة: يمكنك وضع هذا في الاعتبار، ولكن لا تفعله إذا أدى إلى تكرار الكود أو كود أقل تعبيرًا.
تحتفظ ذاكرات التخزين المؤقت المضمنة أيضًا بتتبع عدد مرات استدعائها لتحديد ما إذا كانت مرشحًا جيدًا لمترجم التحسين – Turbofan.
المُجمّع (Turbofan): التحسين الفائق
يأخذنا Ignition إلى حد معين فقط. إذا أصبحت الدالة “ساخنة” (hot) بما يكفي، فسيتم تحسينها في المُجمّع، Turbofan، لجعلها أسرع. يأخذ Turbofan شيفرة البايت من Ignition ومعلومات النوع (Feedback Vector) للدالة، ويطبق مجموعة من التخفيضات بناءً عليها، وينتج شيفرة آلة.
كما رأينا سابقًا، لا تضمن معلومات النوع أنها لن تتغير في المستقبل. على سبيل المثال، قام Turbofan بتحسين الكود بناءً على افتراض أن بعض عمليات الجمع تضيف دائمًا أعدادًا صحيحة. ولكن ماذا سيحدث إذا استقبلت سلسلة نصية؟ تُسمى هذه العملية بـ deoptimization (إلغاء التحسين). نتخلص من الكود المحسّن، ونعود إلى الكود المفسر، ونستأنف التنفيذ، ونحدّث معلومات النوع.
ملخص مسار تنفيذ جافاسكريبت في V8
في هذا المقال، ناقشنا تنفيذ محرك JS والخطوات الدقيقة لكيفية تنفيذ جافاسكريبت. لتلخيص ذلك، دعنا نلقي نظرة على مسار التجميع من الأعلى.

سنستعرضها خطوة بخطوة:
- يبدأ كل شيء بالحصول على كود جافاسكريبت من الشبكة.
- يقوم
V8بتحليل الشيفرة المصدرية ويحولها إلى شجرة بناء مجردة (AST). - بناءً على
ASTهذا، يمكن لمفسرIgnitionأن يبدأ عمله وينتج شيفرة بايت (bytecode). - عند هذه النقطة، يبدأ المحرك في تشغيل الكود وجمع معلومات النوع (
type feedback). - لجعله يعمل بشكل أسرع، يمكن إرسال شيفرة البايت إلى مُجمّع التحسين (
optimizing compiler) جنبًا إلى جنب مع بيانات التغذية الراجعة. - يقوم مُجمّع التحسين بوضع افتراضات معينة بناءً على ذلك ثم ينتج شيفرة آلة محسّنة للغاية.
- إذا تبين في مرحلة ما أن أحد الافتراضات غير صحيح، يقوم مُجمّع التحسين بإلغاء التحسين (
de-optimizes) والعودة إلى المفسر.
هذا كل شيء! إذا كان لديك أي أسئلة حول مرحلة معينة أو تريد معرفة المزيد من التفاصيل عنها، يمكنك الغوص في الشيفرة المصدرية أو التواصل معي على Twitter.
مصادر إضافية للاستزادة
- فيديو “Life of a script” من جوجل.
- دورة مكثفة في مجمعات
JITمن موزيلا. - شرح رائع لذاكرات التخزين المؤقت المضمنة (
Inline Caches) فيV8. - غوص عميق في أشكال الكائنات (
Object Shapes).
الخلاصة التقنية
يُعد محرك V8 قلبًا نابضًا لتنفيذ جافاسكريبت، حيث يجمع بين سرعة التجميع ومرونة الترجمة الفورية من خلال نموذج JIT. إن فهم هذه الآليات الداخلية، من تحليل الشيفرة الأولية إلى التحسينات الدقيقة التي يوفرها Turbofan، لا يثري معرفتنا التقنية فحسب، بل يمكن أن يوجه المطورين نحو كتابة أكواد جافاسكريبت أكثر كفاءة وأداءً. إن الفصل بين بنية الكائن وقيمه، واستخدام Inline Caches، كلها أمثلة على الهندسة المعقدة التي تهدف إلى تقديم تجربة ويب سريعة وسلسة للمستخدم النهائي.