بعض الحيل لتسريع تطبيق JavaScript الخاص بك JavaScript
بارتوش سليز
Software Engineer
مع تقدم تكنولوجيا المتصفحات، بدأت تطبيقات الويب في نقل المزيد والمزيد من المنطق إلى الواجهة الأمامية، وبالتالي التخفيف عن الخادم وخفض عدد العمليات التي يتعين عليه القيام بها. في CRUDs الأساسية، ينحصر دور الخادم في التفويض والتحقق من الصحة والتواصل مع قواعد البيانات ومنطق الأعمال المطلوب. أما بقية منطق البيانات، كما اتضح، فيمكن معالجتها بسهولة بواسطة الكود المسؤول عن تمثيل التطبيق على جانب واجهة المستخدم.
في هذا المقال، سأحاول أن أعرض لكم بعض الأمثلة والأنماط التي ستساعد في الحفاظ على الكود فعّال وأنيق وسريع.
قبل أن نتعمق أكثر في أمثلة محددة - في هذه المقالة، أود التركيز فقط على عرض الحالات التي، في رأيي، يمكن أن تؤثر على سرعة التطبيق بطريقة مدهشة. ومع ذلك، هذا لا يعني أن استخدام الحلول الأسرع هو الخيار الأفضل في كل حالة ممكنة. بدلاً من ذلك، يجب التعامل مع النصائح أدناه على أنها شيء يجب أن تنظر إليه عندما يكون تطبيقنا يعمل ببطء، على سبيل المثال، في المنتجات التي تتطلب عرض الألعاب أو الرسوم البيانية الأكثر تقدمًا على اللوحة أو عمليات الفيديو أو الأنشطة التي تريد مزامنتها في الوقت الفعلي في أسرع وقت ممكن.
أولاً وقبل كل شيء - طرق النموذج الأولي للمصفوفة
نحن نبني جزءًا كبيرًا من منطق التطبيق على المصفوفات - تخطيطها وفرزها وتصفيتها وجمع العناصر وما إلى ذلك. وبطريقة سهلة وشفافة وطبيعية، نستخدم طرقها المدمجة التي تسمح لنا ببساطة بإجراء أنواع مختلفة من العمليات الحسابية والتجميعات وما إلى ذلك. وهي تعمل بشكل متشابه في كل حالة - كوسيطة، نمرر دالة حيث، في معظم الحالات، يتم دفع قيمة العنصر والفهرس والمصفوفة بالتناوب خلال كل تكرار. تُنفَّذ الدالة المحددة لكل عنصر في المصفوفة وتُفسَّر النتيجة بشكل مختلف اعتمادًا على الطريقة. لن أتوسع في شرح طرائق Array.prototype لأنني أريد التركيز على سبب بطء تشغيلها في عدد كبير من الحالات.
طرق المصفوفات بطيئة لأنها تؤدي دالة لكل عنصر. يجب على الدالة التي يتم استدعاؤها من منظور المحرك أن تعد استدعاءً جديدًا، وتوفر النطاق المناسب والكثير من التبعيات الأخرى، مما يجعل العملية أطول بكثير من تكرار كتلة محددة من الشيفرة في نطاق محدد. وهذه على الأرجح معرفة خلفية كافية تسمح لنا بفهم المثال التالي:
(() => {
const randomArray = [...Array(1E6).keys()].map(()) => ({ value: Math.random() }));
console.time('sum by reduce');
const reduceSum = randomArray
.map(({القيمة })) => القيمة)
.reduce((a, b) => a + b);
console.timeEnd('sum by reduce');
console.time('sum by for loop');
دع المجموع = randomArray[0].value;
بالنسبة إلى (دع الفهرس = 1؛ الفهرس < randomArray.length؛ فهرس ++) {
forSum += randomArray[index].value;
}
console.timeEnd('المجموع حسب حلقة التكرار');
console.log(reduceSum === forSum);
})();
أعلم أن هذا الاختبار ليس موثوقًا مثل المعايير القياسية (سنعود إليها لاحقًا)، لكنه يُطلق ضوءًا تحذيريًا. بالنسبة لحالة عشوائية على حاسوبي، اتضح أن الشيفرة التي تحتوي على حلقة for loop يمكن أن تكون أسرع بحوالي 50 مرة إذا ما قورنت بتعيين ثم تقليل العناصر التي تحقق نفس التأثير! هذا يتعلق بالتشغيل على كائن غريب تم إنشاؤه فقط للوصول إلى هدف معين من العمليات الحسابية. لذا، دعونا ننشئ شيئًا أكثر شرعية لنكون موضوعيين بشأن طرائق المصفوفات:
(() => {
const randomArray = [...Array(1E6).keys()].map(()) => ({ value: Math.random() }));
console.time('sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value }))).value
console.timeEnd('sum by reduce');
console.time('sum by for loop');
دع المجموع = randomArray[0].value;
بالنسبة إلى (دع الفهرس = 1؛ الفهرس < randomArray.length؛ فهرس ++) {
forSum += randomArray[index].value;
}
وحدة التحكم.timeEnd('المجموع حسب حلقة التكرار');
console.log(reduceSum === forSum);
})();
أعلم أن هذا الاختبار ليس موثوقًا مثل المعايير القياسية (سنعود إليها لاحقًا)، لكنه يُطلق ضوءًا تحذيريًا. بالنسبة لحالة عشوائية على حاسوبي، اتضح أن الشيفرة باستخدام طريقة for loop يمكن أن تكون أسرع بحوالي 50 مرة إذا ما قورنت بتعيين العناصر ثم اختزالها التي تحقق نفس التأثير! هذا لأن الحصول على المجموع في هذه الحالة تحديدًا باستخدام طريقة الاختزال يتطلب تعيين المصفوفة للقيم النقية التي نريد تلخيصها. لذا، دعنا ننشئ شيئًا أكثر شرعية لنكون موضوعيين حول طرائق المصفوفات:
(() => {
const randomArray = [...Array(1E6).keys()].map(()) => ({ value: Math.random() }));
console.time('sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value }))).value
console.timeEnd('sum by reduce');
console.time('sum by for loop');
دع المجموع = randomArray[0].value;
بالنسبة إلى (دع الفهرس = 1؛ الفهرس < randomArray.length؛ فهرس ++) {
forSum += randomArray[index].value;
}
وحدة التحكم.timeEnd('المجموع حسب حلقة التكرار');
console.log(reduceSum === forSum);
})();
وكما اتضح، انخفضت التعزيز 50 ضعفًا إلى 4 أضعاف. أعتذر إذا كنت تشعر بخيبة أمل! لنبقى موضوعيين حتى النهاية، دعونا نحلل كلا الرمزين مرة أخرى. أولاً وقبل كل شيء - الاختلافات التي تبدو بريئة ضاعفت من انخفاض التعقيد الحسابي النظري لدينا؛ فبدلاً من التعيين أولاً ثم جمع العناصر النقية ما زلنا نعمل على كائنات وحقل معين، لنحصل في النهاية على المجموع الذي يهمنا. تنشأ المشكلة عندما يقوم مبرمج آخر بإلقاء نظرة على الشيفرة البرمجية - عندها، مقارنةً بالشيفرات الموضحة سابقًا، تفقد الأخيرة تجريدها في مرحلة ما.
هذا لأنّه منذ العملية الثانية التي نعمل فيها على كائن غريب، مع الحقل الذي يهمنا والكائن الثاني القياسي للمصفوفة المكررة. لا أعرف ما رأيك في ذلك، ولكن من وجهة نظري، في المثال البرمجي الثاني، منطق حلقة التكرار أوضح بكثير وأكثر قصدًا من هذا الاختزال الغريب المظهر. ومع ذلك، على الرغم من أنها لم تعد 50 الأسطورية بعد الآن، إلا أنها لا تزال أسرع 4 مرات عندما يتعلق الأمر بوقت الحساب! بما أن كل جزء من الثانية له قيمة، فالخيار في هذه الحالة بسيط.
المثال الأكثر إثارة للدهشة
الأمر الثاني الذي أردت مقارنته يتعلق بطريقة Math.max أو بتعبير أدق، حشو مليون عنصر واستخراج أكبرها وأصغرها. لقد أعددت الكود وطريقة قياس الوقت أيضًا، ثم قمت بتشغيل الكود وحصلت على خطأ غريب جدًا - تم تجاوز حجم المكدس. ها هي الشيفرة البرمجية
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max مع مشغل الانتشار ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max with ES6 spread operator');
console.time('Math.max مع مشغل الانتشار')؛
دع maxByFor = randomValues[0];
بالنسبة إلى (دع الفهرس = 1؛ الفهرس maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max مع مشغل الانتشار ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max with ES6 spread operator');
console.time('Math.max مع مشغل الانتشار')؛
دع maxByFor = randomValues[0];
بالنسبة إلى (دع الفهرس = 1؛ الفهرس maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
اتضح أن الطرق الأصلية تستخدم التكرار، والذي في v8 مقيد بمكدسات الاستدعاء وعددها يعتمد على البيئة. هذا أمر أدهشني كثيرًا، لكنه يحمل استنتاجًا: الطريقة الأصلية أسرع، طالما أن المصفوفة لا تتجاوز عددًا سحريًا معينًا من العناصر، والذي اتضح في حالتي أنه 125375. بالنسبة لهذا العدد من العناصر، كانت النتيجة أسرع بـ 5 أضعاف إذا ما قورنت بالحلقة. ومع ذلك، فوق العدد المذكور من العناصر، تفوز حلقة for بالتأكيد - على عكس الخصم، فهي تسمح لنا بالحصول على نتائج صحيحة.
التكرار
المفهوم الذي أريد أن أذكره في هذه الفقرة هو العودية. في المثال السابق، رأيناه في طريقة Math.max وطي الوسيطة حيث اتضح أنه من المستحيل الحصول على نتيجة للمكالمات العودية التي تتجاوز رقمًا محددًا بسبب قيود حجم المكدس.
سنرى الآن كيف يبدو التكرار في سياق التعليمات البرمجية المكتوبة بلغة JS، وليس بالطرق المدمجة، ولعل أكثر شيء كلاسيكي يمكننا عرضه هنا هو بالطبع إيجاد الحد النوني في متتابعة فيبوناتشي. إذًا، لنكتب هذا!
(() => {
const const fiboIterative = (n) => {
دع [أ، ب] = [0، 1];
بالنسبة إلى (دع i = 0؛ i {
إذا (ن < 2) {
أرجع n;
}
إرجاع fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('تسلسل فيبوناتشي بواسطة حلقة');
const resultIterative = فيبوايتراتي(30);
console.timeEnd('تسلسل فيبوناتشي حسب الحلقة');
console.time('تسلسل فيبوناتشي بالتكرار')؛
const resultRecursive = فيبوناتشي بالتكرار(30);
وحدة التحكم.timeEnd('متتابعة فيبوناتشي بالتكرار')؛
console.log(resultRecursive === resultIterative);
})();
حسنًا، في هذه الحالة الخاصة بحساب العنصر الثلاثين من التسلسل على حاسوبي، نحصل على النتيجة في وقت أقصر بحوالي 200 مرة باستخدام الخوارزمية التكرارية.
ومع ذلك، هناك شيء واحد يمكن تصحيحه في الخوارزمية التكرارية - حيث اتضح أنها تعمل بكفاءة أكبر بكثير عندما نستخدم تكتيكًا يسمى العودية الذيلية. هذا يعني أننا نمرر النتيجة التي حصلنا عليها في التكرار السابق باستخدام الوسيطات لاستدعاءات أعمق. هذا يسمح لنا بتقليل عدد المكالمات المطلوبة، ونتيجةً لذلك يُسرِّع النتيجة. لنصحح شفرتنا وفقًا لذلك!
(() => {
const const fiboIterative = (n) => {
دع [أ، ب] = [0، 1];
بالنسبة إلى (دع i = 0؛ i {
إذا (n === 0) {
أرجع الأول;
}
إرجاع fiboTailRecursive(n - 1، الثانية، الأولى + الثانية);
};
console.time('تسلسل فيبوناتشي بواسطة حلقة');
const resultIterative = فيبوايتراتي(30);
console.timeEnd('تسلسل فيبوناتشي حسب الحلقة');
console.time('متتابعة فيبوناتشي حسب تكرار الذيل');
const resultRecursive = فيبوتيل العودية(30);
console.timeEnd('تسلسل فيبوناتشي بتكرار الذيل')؛
console.log(resultRecursive === resultIterative);
})();
لقد حدث شيء لم أتوقعه تمامًا هنا - كانت نتيجة خوارزمية تكرار الذيل قادرة على تقديم النتيجة (حساب العنصر الثلاثين من التسلسل) أسرع مرتين تقريبًا من الخوارزمية التكرارية في بعض الحالات. لستُ متأكدًا تمامًا ما إذا كان هذا يرجع إلى تحسين التكرار الذيلي من جانب الإصدار 8 أو عدم تحسين حلقة التكرار لهذا العدد المحدد من التكرارات، لكن النتيجة لا لبس فيها - يفوز التكرار الذيلي.
هذا أمر غريب لأن حلقة التكرار تفرض بشكل أساسي تجريدًا أقل بكثير على أنشطة الحوسبة ذات المستوى الأدنى، ويمكنك القول إنها أقرب إلى عملية الكمبيوتر الأساسية. ومع ذلك، فالنتائج لا يمكن إنكارها - فقد تبين أن التكرار المصمم بذكاء أسرع من التكرار.
استخدم عبارات الاستدعاء غير المتزامن بقدر ما تستطيع
أود أن أخصص الفقرة الأخيرة لتذكير قصير حول طريقة تنفيذ العمليات التي يمكن أن تؤثر أيضًا بشكل كبير على سرعة تطبيقنا. كما يجب أن تعلموا JavaScript هي لغة أحادية الخيط تحافظ على جميع العمليات بآلية حلقة الحدث. الأمر كله يدور حول دورة تتكرر مرارًا وتكرارًا وجميع الخطوات في هذه الدورة تدور حول إجراءات محددة مخصصة.
لجعل هذه الحلقة سريعة والسماح لجميع الدورات بانتظار دورهم بشكل أقل، يجب أن تكون جميع العناصر سريعة قدر الإمكان. تجنب تشغيل العمليات الطويلة على الخيط الرئيسي - إذا كان هناك شيء يستغرق وقتًا طويلًا، حاول نقل هذه العمليات الحسابية إلى WebWorker أو تقسيمها إلى أجزاء تعمل بشكل غير متزامن. قد يؤدي ذلك إلى إبطاء بعض العمليات ولكنه يعزز نظام JS بأكمله، بما في ذلك عمليات الإدخال والإخراج، مثل التعامل مع تحريك الفأرة أو طلب HTTP المعلق.
الملخص
في الختام، كما ذكرنا سابقاً، فإن مطاردة الميلي ثانية التي يمكن توفيرها عن طريق اختيار خوارزمية ما قد تكون غير مجدية في بعض الحالات. من ناحية أخرى، قد يكون إهمال مثل هذه الأمور في التطبيقات التي تتطلب تشغيلًا سلسًا ونتائج سريعة مميتًا لتطبيقك. في بعض الحالات، وبصرف النظر عن سرعة الخوارزمية، يجب طرح سؤال إضافي واحد - هل يتم تشغيل التجريد على المستوى الصحيح؟ هل سيتمكن المبرمج الذي يقرأ الكود من فهمه دون أي مشاكل؟
والطريقة الوحيدة هي ضمان التوازن بين الأداء وسهولة التنفيذ والتجريد المناسب، وأن تكون واثقًا من أن الخوارزمية تعمل بشكل صحيح لكل من الكميات الصغيرة والكبيرة من البيانات. والطريقة للقيام بذلك بسيطة للغاية - كن ذكيًا، ضع في اعتبارك الحالات المختلفة عند تصميم الخوارزمية ورتبها لتتصرف بأكبر قدر ممكن من الكفاءة في عمليات التنفيذ المتوسطة. أيضًا، يُنصح بتصميم الاختبارات - تأكد من أن الخوارزمية تُرجع المعلومات المناسبة للبيانات المختلفة، بغض النظر عن كيفية عملها. الاعتناء بالواجهات الصحيحة - بحيث تكون مدخلات ومخرجات الطرق قابلة للقراءة وواضحة وتعكس بالضبط ما تقوم به.
ذكرت سابقًا أنني سأعود إلى موثوقية قياس سرعة الخوارزميات في الأمثلة أعلاه. إن قياسها باستخدام console.time ليس موثوقًا للغاية، ولكنه يعكس حالة الاستخدام القياسية بشكل أفضل. على أي حال، أقدم المعايير أدناه - بعضها يبدو مختلفًا قليلًا عن التنفيذ الفردي نظرًا لحقيقة أن المعايير تكرر ببساطة نشاطًا معينًا في وقت معين وتستخدم تحسين v8 للحلقات.
https://jsben.ch/KhAqb - التصغير مقابل التكرار
https://jsben.ch/F4kLY - الاختزال المحسّن مقابل حلقة التكرار
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - فيبو التكراري مقابل فيبو التكراري