سلاسل النماذج الأولية لجافا سكريبت وسلاسل النطاق والأداء: ما تحتاج إلى معرفته
نشرت: 2022-03-11جافا سكريبت: أكثر مما تراه العين
يمكن أن تبدو JavaScript لغة سهلة التعلم في البداية. ربما بسبب مرونة تركيبها. أو ربما بسبب تشابهها مع لغات أخرى معروفة مثل Java. أو ربما لأنه يحتوي على عدد قليل جدًا من أنواع البيانات مقارنة بلغات مثل Java أو Ruby أو .NET.
ولكن في الحقيقة ، تعد JavaScript أقل بساطة بكثير وأكثر دقة مما يدركه معظم المطورين في البداية. حتى بالنسبة للمطورين الذين يتمتعون بخبرة أكبر ، لا تزال بعض الميزات البارزة في JavaScript يساء فهمها وتؤدي إلى الارتباك. إحدى هذه الميزات هي الطريقة التي يتم بها إجراء عمليات البحث عن البيانات (الخصائص والمتغيرات) وعواقب أداء JavaScript التي يجب أن تكون على دراية بها.
في JavaScript ، تخضع عمليات البحث عن البيانات لشيئين: الوراثة النموذجية وسلسلة النطاق . بصفتك مطورًا ، يعد فهم هاتين الآليتين بوضوح أمرًا ضروريًا ، لأن القيام بذلك يمكن أن يحسن بنية التعليمات البرمجية الخاصة بك ، وفي كثير من الأحيان أداءها.
عمليات البحث عن الممتلكات من خلال سلسلة النموذج الأولي
عند الوصول إلى خاصية في لغة قائمة على النموذج الأولي مثل JavaScript ، يأخذ البحث الديناميكي أماكن تتضمن طبقات مختلفة داخل شجرة النموذج الأولي للكائن.
في JavaScript ، كل وظيفة هي كائن. عندما يتم استدعاء دالة بواسطة عامل التشغيل new
، يتم إنشاء كائن جديد. علي سبيل المثال:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');
في المثال أعلاه ، p1
و p2
هما كائنان مختلفان ، كل منهما تم إنشاؤه باستخدام وظيفة Person
. إنها حالات مستقلة عن Person
، كما هو موضح في مقتطف الشفرة هذا:
console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'
نظرًا لأن وظائف JavaScript كائنات ، يمكن أن يكون لها خصائص. الخاصية المهمة بشكل خاص لكل وظيفة تسمى prototype
.
prototype
، الذي هو في حد ذاته كائن ، يرث من النموذج الأولي للوالد ، والذي يرث من النموذج الأصلي للوالد ، وهكذا. غالبًا ما يشار إلى هذا باسم سلسلة النموذج الأولي . Object.prototype
، الموجود دائمًا في نهاية سلسلة النموذج الأولي (على سبيل المثال ، أعلى شجرة الوراثة النموذجية) ، على طرق مثل toString()
و hasProperty()
و isPrototypeOf()
وما إلى ذلك.
يمكن تمديد النموذج الأولي لكل دالة لتحديد خصائصها وطرقها المخصصة.
عند إنشاء كائن (عن طريق استدعاء الوظيفة باستخدام عامل التشغيل new
) ، فإنه يرث جميع الخصائص في النموذج الأولي لتلك الوظيفة. ضع في اعتبارك ، مع ذلك ، أن هذه الحالات لن يكون لها وصول مباشر إلى كائن prototype
ولكن فقط إلى خصائصه. علي سبيل المثال:
// Extending the Person prototype from our earlier example to // also include a 'getFullName' method: Person.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName; } // Referencing the p1 object from our earlier example console.log(p1.getFullName()); // prints 'John Doe' // but p1 can't directly access the 'prototype' object... console.log(p1.prototype); // prints 'undefined' console.log(p1.prototype.getFullName()); // generates an error
هناك نقطة مهمة ودقيقة إلى حد ما هنا: حتى إذا تم إنشاء p1
قبل تعريف طريقة getFullName
، فسيظل بإمكانها الوصول إليها لأن نموذجها الأولي هو نموذج Person
.
(من الجدير بالذكر أن المتصفحات تخزن أيضًا مرجعًا للنموذج الأولي لأي كائن في خاصية __proto__
، ولكن من الممارسات السيئة حقًا الوصول مباشرة إلى النموذج الأولي عبر خاصية __proto__
، نظرًا لأنها ليست جزءًا من مواصفات لغة ECMAScript القياسية ، لذا لا لا تفعل ذلك! )
نظرًا لأن مثيل p1
من كائن Person
لا يمتلك بحد ذاته وصولاً مباشرًا إلى كائن prototype
، إذا أردنا الكتابة فوق getFullName
في p1
، فسنقوم بذلك على النحو التالي:
// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }
الآن p1
لها خاصية getFullName
الخاصة بها. لكن مثيل p2
(الذي تم إنشاؤه في مثالنا السابق) ليس له أي خاصية خاصة به. لذلك ، يؤدي استدعاء p1.getFullName()
إلى الوصول إلى طريقة getFullName
لمثيل p1
نفسه ، بينما يؤدي استدعاء p2.getFullName()
إلى صعود سلسلة النموذج الأولي إلى كائن النموذج الأولي Person
لحل getFullName
:
console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe'
شيء آخر مهم يجب أن تكون على دراية به هو أنه من الممكن أيضًا تغيير النموذج الأولي للكائن ديناميكيًا . علي سبيل المثال:
function Parent() { this.someVar = 'someValue'; }; // extend Parent's prototype to define a 'sayHello' method Parent.prototype.sayHello = function(){ console.log('Hello'); }; function Child(){ // this makes sure that the parent's constructor is called and that // any state is initialized correctly. Parent.call(this); }; // extend Child's prototype to define an 'otherVar' property... Child.prototype.otherVar = 'otherValue'; // ... but then set the Child's prototype to the Parent prototype // (whose prototype doesn't have any 'otherVar' property defined, // so the Child prototype no longer has 'otherVar' defined!) Child.prototype = Object.create(Parent.prototype); var child = new Child(); child.sayHello(); // prints 'Hello' console.log(child.someVar); // prints 'someValue' console.log(child.otherVar); // prints 'undefined'
عند استخدام الوراثة النموذجية ، تذكر تحديد الخصائص في النموذج الأولي بعد أن تكون إما موروثة من الفئة الأصلية أو تحديد نموذج أولي بديل.
للتلخيص ، تعمل عمليات البحث عن الممتلكات من خلال سلسلة النماذج الأولية لجافا سكريبت على النحو التالي:
- إذا كان للكائن خاصية بالاسم المحدد ، يتم إرجاع تلك القيمة. (يمكن استخدام طريقة
hasOwnProperty
للتحقق مما إذا كان الكائن له خاصية معينة مسماة.) - إذا كان الكائن لا يحتوي على الخاصية المسماة ، يتم فحص النموذج الأولي للكائن
- نظرًا لأن النموذج الأولي هو كائن أيضًا ، إذا لم يكن يحتوي على الخاصية أيضًا ، فسيتم فحص النموذج الأولي الخاص به.
- تستمر هذه العملية في سلسلة النموذج الأولي حتى يتم العثور على الخاصية.
- إذا تم الوصول إلى
Object.prototype
ولم يكن لديه الخاصية أيضًا ، فسيتم اعتبار الخاصية غيرundefined
.
يعد فهم كيفية عمل نماذج الميراث وعمليات البحث عن الممتلكات أمرًا مهمًا بشكل عام للمطورين ولكنه ضروري أيضًا بسبب تداعيات أداء JavaScript (المهمة في بعض الأحيان). كما هو مذكور في وثائق V8 (محرك جافا سكريبت مفتوح المصدر وعالي الأداء من Google) ، تستخدم معظم محركات جافا سكريبت بنية بيانات تشبه القاموس لتخزين خصائص الكائن. لذلك يتطلب كل وصول إلى الممتلكات بحثًا ديناميكيًا في هيكل البيانات هذا لحل الخاصية. هذا الأسلوب يجعل الوصول إلى الخصائص في JavaScript عادةً أبطأ بكثير من الوصول إلى متغيرات المثيل في لغات البرمجة مثل Java و Smalltalk.
عمليات البحث المتغيرة من خلال سلسلة النطاق
تعتمد آلية بحث أخرى في JavaScript على النطاق.
لفهم كيفية عمل ذلك ، من الضروري تقديم مفهوم سياق التنفيذ.
في JavaScript ، هناك نوعان من سياقات التنفيذ:
- سياق عام ، يتم إنشاؤه عند بدء تشغيل عملية JavaScript
- السياق المحلي ، يتم إنشاؤه عند استدعاء دالة
يتم تنظيم سياقات التنفيذ في مكدس. في الجزء السفلي من المكدس ، يوجد دائمًا السياق العام ، وهو فريد لكل برنامج JavaScript. في كل مرة يتم فيها مواجهة وظيفة ، يتم إنشاء سياق تنفيذ جديد ودفعه إلى أعلى المكدس. بمجرد انتهاء الوظيفة من التنفيذ ، يظهر سياقها خارج المكدس.
ضع في اعتبارك الكود التالي:
// global context var message = 'Hello World'; var sayHello = function(n){ // local context 1 created and pushed onto context stack var i = 0; var innerSayHello = function() { // local context 2 created and pushed onto context stack console.log((i + 1) + ': ' + message); // local context 2 popped off of context stack } for (i = 0; i < n; i++) { innerSayHello(); } // local context 1 popped off of context stack }; sayHello(3); // Prints: // 1: Hello World // 2: Hello World // 3: Hello World
داخل كل سياق تنفيذي يوجد كائن خاص يسمى سلسلة النطاق والتي تستخدم لحل المتغيرات. سلسلة النطاق هي في الأساس مجموعة من النطاقات التي يمكن الوصول إليها حاليًا ، من السياق المباشر إلى السياق العالمي. (لكي تكون أكثر دقة ، يُطلق على الكائن الموجود أعلى المكدس اسم كائن التنشيط الذي يحتوي على مراجع للمتغيرات المحلية للوظيفة التي يتم تنفيذها ، ووسيطات الوظيفة المسماة ، وكائنين "خاصين": this
arguments
. ) علي سبيل المثال:

لاحظ في الرسم التخطيطي أعلاه كيف يشير this
إلى كائن window
افتراضيًا وأيضًا كيف يحتوي السياق العام على أمثلة لكائنات أخرى مثل console
location
.
عند محاولة حل المتغيرات عبر سلسلة النطاق ، يتم التحقق أولاً من السياق المباشر لمتغير مطابق. إذا لم يتم العثور على تطابق ، فسيتم فحص كائن السياق التالي في سلسلة النطاق ، وهكذا ، حتى يتم العثور على تطابق. إذا لم يتم العثور على تطابق ، يتم طرح خطأ ReferenceError
.
من المهم أيضًا ملاحظة أنه يتم إضافة نطاق جديد إلى سلسلة النطاق عند with
كتلة try-catch
أو block. في أي من هاتين الحالتين ، يتم إنشاء كائن جديد ووضعه في أعلى سلسلة النطاق:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; }; function persist(person) { with (person) { // The 'person' object was pushed onto the scope chain when we // entered this "with" block, so we can simply reference // 'firstName' and 'lastName', rather than person.firstName and // person.lastName if (!firstName) { throw new Error('FirstName is mandatory'); } if (!lastName) { throw new Error('LastName is mandatory'); } } try { person.save(); } catch(error) { // A new scope containing the 'error' object is accessible here console.log('Impossible to store ' + person + ', Reason: ' + error); } } var p1 = new Person('John', 'Doe'); persist(p1);
لفهم كيفية حدوث عمليات البحث المتغيرة القائمة على النطاق تمامًا ، من المهم أن تضع في اعتبارك أنه في JavaScript لا توجد حاليًا نطاقات على مستوى الكتلة. علي سبيل المثال:
for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'
في معظم اللغات الأخرى ، قد يؤدي الكود أعلاه إلى خطأ لأن "الحياة" (أي النطاق) للمتغير i
ستقتصر على الكتلة for. على الرغم من ذلك ، في JavaScript ، ليس هذا هو الحال. بدلاً من ذلك ، تتم i
إلى كائن التنشيط أعلى سلسلة النطاق وسيبقى هناك حتى تتم إزالة هذا الكائن من النطاق ، والذي يحدث عند إزالة سياق التنفيذ المقابل من المكدس. يُعرف هذا السلوك بالرفع المتغير.
ومع ذلك ، تجدر الإشارة إلى أن دعم النطاقات على مستوى الكتلة يشق طريقه إلى JavaScript من خلال الكلمة الرئيسية let
الجديدة. الكلمة الرئيسية let
متاحة بالفعل في JavaScript 1.7 ومن المقرر أن تصبح كلمة رئيسية JavaScript مدعومة رسميًا اعتبارًا من ECMAScript 6.
انعكاسات أداء جافا سكريبت
الطريقة التي تعمل بها عمليات البحث الخاصة بالممتلكات والمتغيرات ، باستخدام سلسلة النموذج الأولي وسلسلة النطاق على التوالي ، في JavaScript هي إحدى الميزات الرئيسية للغة ، ومع ذلك فهي واحدة من أصعب الميزات وأكثرها دقة من حيث الفهم.
تتكرر عمليات البحث التي وصفناها في هذا المثال ، سواء كانت تستند إلى سلسلة النموذج الأولي أو سلسلة النطاق ، في كل مرة يتم فيها الوصول إلى خاصية أو متغير. عندما يحدث هذا البحث داخل حلقات أو عمليات مكثفة أخرى ، يمكن أن يكون له تداعيات مهمة على أداء JavaScript ، لا سيما في ضوء طبيعة الخيوط المفردة للغة التي تمنع حدوث عمليات متعددة بشكل متزامن.
ضع في اعتبارك المثال التالي:
var start = new Date().getTime(); function Parent() { this.delta = 10; }; function ChildA(){}; ChildA.prototype = new Parent(); function ChildB(){} ChildB.prototype = new ChildA(); function ChildC(){} ChildC.prototype = new ChildB(); function ChildD(){}; ChildD.prototype = new ChildC(); function ChildE(){}; ChildE.prototype = new ChildD(); function nestedFn() { var child = new ChildE(); var counter = 0; for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += child.delta; } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');
في هذا المثال ، لدينا شجرة وراثة طويلة وثلاث حلقات متداخلة. داخل الحلقة الأعمق ، يتم زيادة متغير العداد بقيمة delta
. لكن delta
تقع تقريبًا في أعلى شجرة الوراثة! هذا يعني أنه في كل مرة يتم فيها الوصول إلى child.delta
، يجب التنقل في الشجرة الكاملة من أسفل إلى أعلى. يمكن أن يكون لهذا تأثير سلبي حقًا على الأداء.
لفهم هذا ، يمكننا بسهولة تحسين أداء وظيفة nestedFn
أعلاه باستخدام متغير delta
محلي لتخزين القيمة في child.delta
(وبالتالي تجنب الحاجة إلى الاجتياز المتكرر لشجرة الوراثة بأكملها) على النحو التالي:
function nestedFn() { var child = new ChildE(); var counter = 0; var delta = child.delta; // cache child.delta value in current scope for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += delta; // no inheritance tree traversal needed! } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');
بالطبع ، هذه التقنية المعينة قابلة للتطبيق فقط في سيناريو حيث من المعروف أن قيمة child.delta
لن تتغير أثناء تنفيذ حلقات for ؛ وإلا ، فستحتاج النسخة المحلية إلى التحديث بالقيمة الحالية.
حسنًا ، دعنا نشغل كلا الإصدارين من طريقة nestedFn
ونرى ما إذا كان هناك أي فرق ملحوظ في الأداء بين الاثنين.
سنبدأ بتشغيل المثال الأول في node.js REPL:
diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
لذلك يستغرق ذلك حوالي 8 ثوانٍ للتشغيل. هذا وقت طويل.
لنرى الآن ما سيحدث عند تشغيل الإصدار المحسن:
diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds
هذه المرة استغرق الأمر ثانية واحدة فقط. أسرع بكثير!
لاحظ أن استخدام المتغيرات المحلية لتجنب عمليات البحث المكلفة هو أسلوب يمكن تطبيقه للبحث عن الخاصية (عبر سلسلة النموذج الأولي) وعمليات البحث المتغيرة (عبر سلسلة النطاق).
علاوة على ذلك ، يمكن أن يكون هذا النوع من "التخزين المؤقت" للقيم (أي في المتغيرات في النطاق المحلي) مفيدًا أيضًا عند استخدام بعض مكتبات JavaScript الأكثر شيوعًا. خذ jQuery ، على سبيل المثال. يدعم jQuery فكرة "المحددات" ، والتي تعد في الأساس آلية لاسترداد عنصر أو أكثر من العناصر المطابقة في DOM. السهولة التي يمكن بها تحديد المحددات في jQuery يمكن أن تجعل المرء ينسى مدى تكلفة (من وجهة نظر الأداء) كل بحث عن محدد. وفقًا لذلك ، يمكن أن يكون تخزين نتائج بحث المحدد في متغير محلي مفيدًا للغاية للأداء. علي سبيل المثال:
// this does the DOM search for $('.container') "n" times for (var i = 0; i < n; i++) { $('.container').append(“Line “+i+”<br />”); } // this accomplishes the same thing... // but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"<br />"); } // or even better yet... // this version only does the DOM search for $('.container') once // AND only modifies the DOM once var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '<br />'; } $('.container').append($html);
على صفحة الويب التي تحتوي على عدد كبير من العناصر على وجه الخصوص ، من المحتمل أن يؤدي الأسلوب الثاني في نموذج التعليمات البرمجية أعلاه إلى أداء أفضل بشكل ملحوظ من الأول.
يتم إحتوائه
يختلف البحث عن البيانات في JavaScript تمامًا عما هو عليه في معظم اللغات الأخرى ، وهو شديد الدقة. لذلك من الضروري فهم هذه المفاهيم بشكل كامل وصحيح من أجل إتقان اللغة حقًا. يجب تجنب البحث عن البيانات وأخطاء JavaScript الشائعة الأخرى كلما أمكن ذلك. من المحتمل أن ينتج عن هذا الفهم رمز أنظف وأكثر قوة يحقق أداء JavaScript محسنًا.