navidkhm
navidkhm
خواندن ۱۲ دقیقه·۵ ماه پیش

تاریخ عجیب JS - بخش دوم: JS چطور ساخته شد؟(ترجمه ویدیو fireship)

 تاریخ عجیب JS - بخش دوم: JS چطور ساخته شد؟(ترجمه ویدیو fireship)
تاریخ عجیب JS - بخش دوم: JS چطور ساخته شد؟(ترجمه ویدیو fireship)

جاوا اسکریپت؛ امروز قراره که ما به علم کامپیوتری پشتش نگاه کنیم و بعد از تموم شدن این ویدیو شما باید بدونید که یه زبون سطح بالای، تک ثردیِ (Single Thread)، Garbage Collected، مفسری یا در لحظه کامپایل شونده‌ی (Just In Time Compiled) پروتوتایپ بیسِ چند پارادایمیِ داینامیک در کنار حلقه‌رویدادِ غیر مسدود کننده‌ی (Non-blocking Event loop) کانکارنسی مدل(Concurrency Model) چیه.

هفته پیش (لینک مقاله قبلی) ما یاد گرفتیم که JS یه زبون برنامه‌نویسیه که مبناش بر اساس مشخصاتی که ECMA262 تعیین میکنه هست ولی برای اینکه بفهمیم واقعا چطوری روی یه سیستم کامپیوتری کار میکنه نیازه که تا حد خیلی زیادی پایین بریم و منظورم از این حرف خود CPU و Memory ئه. [شخصی برد کامپیوتری را نشان میدهد و در مورد چیپ‌ها صحبت میکند].

وقتی شما یه برنامه‌ی جاوااسکریپتی رو چه روی مرورگر یا یه چیز سمت سروری مثل Node.js اجرا میکنید نیاز داره که حافظه‌ای رو روی مموری رمِ شما اختصاص بده تا بتونه یه چیزهایی رو برای ران‌تایم و متغیرها و آبجکت‌هایی که بهش تو کدتون اشاره میکنید ذخیره کنه بعدش علاوه بر این به یه ثرد از CPUتون هم نیاز داره تا واقعا دستوراتی که تو کدتون نوشتید رو اجرا کنه اما اینجا اون چیزی که وجود داره اینه که شما به عنوان یه توسعه دهنده جاوا اسکریپت واقعاً مجبور نیستید به این چیزا فکر کنید چون که JS یه زبان "سطح بالا"ئه ولی چیزی که واقعا منظورمونه وقتی که میگیم سطح بالا در واقع درجه‌ی انتزاع یا سادگی‌ایه که زبون برای ما نسبت به سخت‌افزار کامپیوتر داره فراهم میکنه؛ پایین‌‌ترین سطح زبون، کدِ ماشینه.

کد ماشین یه زبون عددیه که میتونه مستقیما توسط CPU اجرا بشه ولی خیلی سخت میشه اگه بخوایم باهاش یه وبسایت بسازیم بخاطر اینکه باید عدد دونه‌دونه‌ی دستورالعمل‌هایی که میخواید اجرا کنید رو حفظ کنید.

اگه یه سطح بالاتر بریم به اسمبلی میرسیم که یه سری سینتکس کادوپیچ داریم ولی هر زبان اسمبلی مختص به یه CPU یا سیستم‌عامله پس میتونیم یه سطح بالاتر به زبون C بریم که سینتکس مدرن و قابلیت نوشتن برنامه‌های بین‌پلتفرمی رو فراهم میکنه ولی با این حال دولوپرا همچنان باید نگران مشکلات سطح پایین مثل تخصیص حافظه تو مموری باشن.

اگه یه سطح دیگه بالاتر بریم به سطح زبونایی مثل جاوااسکریپت و پایتون میرسیم که از انتراعاتی مثل Garbage Collectorها و تایپ‌های داینامیک استفاده میکنن تا راه دولوپرا برای نوشتن اپ‌هاشون ساده بشه.

پس حالا که ما تو این سطحِ بالاییم بیاید ادامه بدیم و یه سری اصطاحات اساسی‌ای که به JS مرتبط میشن رو رازگشایی کنیم.

خب ما ۲ راه کلی برای ترجمه‌ی کدایی که تو زبون برنامه‌نویسی مینویسیم به چیزی که CPU واقعا بتونه اجراش کنه داریم. یکیش به اسم مفسری شناخته میشه و اون یکی کامپایلری.

جاوااسکریپت یه زبون مسفریه به این معنی که نیاز به یه مفسر داره تا خودِ کد رو بخونه و اجراش کنه. ما میتونیم اینو به سادگی با رفتن به مرورگر و اجرا کردن یه سری کدجاوااسکریپت تو کنسول نشون بدیم.

[صدای مردی در پس‌زمینه انیمیشنی برای توضیح تفاوت مفسری و کامپایلری]: حالا توجه کنید که مفسر چطور کار میکنه؛ اون همیشه پیش شماست و هر دستورالعمل شمارو همون لحظه یکی بعد از اون یکی ترجمه میکنه.

و حالا این با یه زبون کامپایلری مثل جاوا یا سی که تمام کد شما رو از قبل به صورت ایستا تجزیه و تحلیل میکنه و بعدش اون را به شکل باینری کامپایل می کنه که در واقع می توانید روی ماشین اجرا کنید فرق میکنه.

[صدای مردی در پس‌زمینه انیمیشنی برای توضیح تفاوت مفسری و کامپایلری]: اون لیست کامل دستورالعمل‌هارو ازتون میگیره و بدون هیچ مقدمه‌ای کل اون رو ترجمه میکنه و بعد میره و شمارو به حال خودتون تنها میذاره.

جاوااسکریپت هیچ‌وقت برای اینکه یه زبون کامپایلری باشه طراحی نشده بود ولی تا چند دقیقه دیگه میبینیم که چطور موتورهای امروزی جاوااسکریپت میتونن از قابلیتای یه کامپایلر استفاده کنن تا عملکردای اضافی زبان رو از بین ببرن.

حالا یه چیز دیگه که شاید شنیده باشید اینه که جاوااسکریپت یه زبون تایپ‌متغیره(Dynamic Type) که معمولا یه ویژگی مشترک با زبونای سطح بالای مفسریه و این ینی اینکه ما از هیچ تعریف صریحی برای تایپ(منظور نوع متغیره) تو کد جاوااسکریپتمون استفاده نمیکنیم. ما میتونیم این تفاوتو با مقایسه یه سری کدِ تایپ‌ثابتِ Dart با یه سری کد تایپ‌متغیر جاوااسکریپت ببینیم. تو کد Dart متوجه میشید که ما چیزایی مثل عدد یا رشته بودن رو اشاره میکنیم ولی تایپای JS ناشناخته(unknown) یا implicit هستن و این بخاطر اینه که تایپ با مقدار در زمان اجرا مرتبطه و نه با خود متغیر یا فانکشنِ توی کد.

تفاوت زبان تایپ‌متغیر مثل JS با زبان تایپ‌ثابت مثل Dart
تفاوت زبان تایپ‌متغیر مثل JS با زبان تایپ‌ثابت مثل Dart

حالا ممکنه شما اینم شنیده باشید که جاوااسکریپت یه زبون چندپارادایمیه؛ تعداد قابل توجهی از زبونای برنامه‌نویسیِ همه‌منظوره چند پارادیمی‌ان که به شما اجازه میدن تا استایل‌های رویکرد اعلانی فانکشنال (declarative functional) یا دستوری شی‌گرا (imperative object-oriented) رو با هم ترکیب کنید.

حالا یکی از چیزای عجیب‌تری که خواهید شنید اینه که جاوااسکریپت بر اساس وراثت پروتوتایپیه (Prototypal Inheritence). این کورس یه ویدیو فقط مخصوص برای همین مفهوم داره ولی ایده کلی اینه که هر چیزی تو جاوااسکریپت یه آبجکته و هر آبجکت یه لینک به پروتوتایپش داره و این یه زنجیره پروتوتایپی ایجاد میکنه که آبجکت‌ها میتونن رفتارهارو از آبجکتای دیگه به ارث ببرن؛ این میتونه یه چیز عجیب باشه که بخواید بهش عادت کنید اگه که با وراثت برپایه کلاس آشنا باشید ولی این یکی از مفهومای سطح پایینیه که جاوااسکریپت رو یه زبون خیلی منعطف چندپارادایمی میکنه. حالا بیاید یه لحظه مرور کنیم ما میدونیم که JS یه زبونِ سطح بالای مفسریِ تایپ‌متغیرِ چند پارادایمیِ پروتوتایپ بیسه ولی همینطور یه زبون تک ثرده، Garbage Collected، غیر مسدودکننده با یه حلقه رویداده که میتونه درلحظه(Just In Time) کامپایل بشه.

مجموعه اول تعریفا بیشتر به ظاهر JS بر اساس چیزی که ECMA262 مشخص کرده برمیگرده ولی اون مشخص نمیکنه که مفسر چطور باید پیاده‌سازی بشه یا چطوری مموری رو مدیریت کنه و حتی اسمی از حلقه رویداد هم تو کل ۸۰۰ صفحه داکیومنتش نمیاره پس جزییات این پیاده‌سازیا به مرورگرا برمیگرده که بخوان چطوری هندلش کنن و ۲ تا از مهم‌ترین پیاده‌سازیا Spider Monkey از موزیلا و V8 از گوگله و نحوه‌ی کارشون یکم با هم متفاوته ولی هر دوشون یه کاری به اسم کامپایل در لحظه(Just In Time Compilation) رو انجام میدن.

در مورد V8، برخلاف مفسر معمولی که خط به خط کد رو تبدیل به بایت‌کد میکنه اون میاد کل JS شمارو قبل از اجرا به کد Native ماشین تبدیل میکنه.

پس این موتورهای JS از پایه نحوه نوشتن کدای دولوپرارو تغییر نمیدن ولی کامپایل در لحظه به بهبود پرفورمنس تو مرورگرا و Node کمک میکنه ولی مسئله اینجاست که JS تک ثرده و میتونه فقط یه محاسبه در لحظه انجام بده.

چیزی که همین الان ازتون میخوام انجام بدید اینه که تو همین مرورگرتون تب کنسول رو باز کنید و یه لوپ همیشه صادق بسازید(While true loop) که هیچ‌وقت تموم نمیشه و میبینید که دیگه هیچی تو این تب کار نمیکنه؛ حالا اگه سعی کنید رو یه چیزی کلیک کنید اون رویداد هیچ‌وقت ضبط نمیشه بخاطر اینکه اون تک ثرد، تو لوپ همیشه صادق گیر کرده و نمیتونه به رویداد(Event) بعدی بره. برید به بخش مدیریت تسک کروم و باید ببینید که اون تب مرورگر تقریبا ۱۰۰٪ منابع اون هسته‌ی CPU رو داره مصرف میکنه، ادامه بدید و تسک رو ببندید(تب بسته میشه با این کار) و بعدش دوباره برگردید اینجا (صفحه ویدیو) منو ببینید که بهتون بگم چرا این اتفاق افتاد؟

وقتی کد JSتون اجرا میشه ۲ بخش از حافظه به ماشینتون اختصاص داده میشه یکی CallStack (پشته تماس که به دلیل سخت‌فهم بودن من همون کال‌استک استفاده می‌کنم) و heap.

کال‌استک طراحی شده تا یه حافظه‌ی با راندمان بالای تو فضای‌ پشت‌سرهم باشه تا برای اجرای فانکشن‌هاتون استفاده بشه؛ وقتی یه فانکشن رو صدا میزنید یه فریم تو کال استک میسازه که شامل کپیِ متغیرای محلیِ اون فانکشنه. اگه یه فانکشن داخل اون فانکشن کال کنید یه فریم دیگه به استک اضافه میکنه ولی اگه return کنید (برگردونید) همون فریم رو از استک برمیداره؛ من فکر میکنم بهترین راه برای فهمیدن کال استک اینه که برید تو کد خودتون و فریم به فریم نگاه کنید. میتونید به تب sources تو کروم devtool برید و اجرای کد اسکریپت رو متوقف کنید و بعدش میتونید قدم به قدم کال استک رو دنبال کنید.

اگه به این پایین این تصویر نگاه کنید میبینید که این فانکشن CurrentStatus رو داریم صدا میزنیم، وقتی صداش میزنیم به داخل کدش میره که با یه کنسول شروع میشه و کنسول یه عملیات "یکبار برای همیشه" است و به استک اضافه میشه و بلافاصله بعدش هم برداشته میشه ولی بعدش اگه به خط بعدی بریم میبینید که یه فانکشن برمیگردونه که خودش به عنوان آرگومان ورودیش یه فانکشن صدا میزنه پس قدم بعدی اینه که فانکشن happy به عنوان ورودی فانکشن mood صدا زده بشه و شما میتونید ببینید که به استک اضافه میشه و این فانکشن happy متغیرای محلی خودشو به اسم foo داره که میتونیم تو قسمت اسکوپ محلیِ این فریم ببینیم و یه چیز باحال دیگه اینه که میتونیم Context عبارت This رو ببینیم که تو این مورد Window هست.

پس کال استک تا جایی که نیاز داشته باشه فریم اضافه میکنه و بعد وقتی کدها روی ماشین اجرا میشن شروع به برداشتنشون میکنه اما چه اتفاقی میوفته اگه تو یه موقعیتی باشیم که کال استک هیچ‌وقت به عبارت return نرسه (پایان فانکشن) مثلا یه فانکشن بازگشتی (Recursive).

تو فانکشن stackOverflow ما به ازای هر فریم تو کال استک یه دونه به متغیر count اضافه میکنیم و در نهایت کروم ارور بیشتر شدن سایز کال استک رو میده (call stack size exceeded) ولی یه نکته جالب توجه اینه که هر فریم تو کال استک یه کپی از متغیر count خودش داره که میتونیم با پیمایش کال استک بررسی‌ش کنیم.

ولی چی میشه اگه ما بریم سر وقت یه چیز یکم پیچیده‌تر مثل: یه آبجکت که توسط چندتا فانکشن بهش ارجاع داده شده و خارج از اسکوپ لوکال فانکشنا هم تعریف شده؟ اینجا جاییه که هیپ وارد بازی میشه. هیپ تقریبا یه استخر حافظه‌ی بدون ساختاره که ما چیزایی مثل آبجکت یا مقادیر اصلیِ (Primitive Values) داخل یه کلوژر(Closure) رو درش نگه میداریم.

تو این مثال از کد ما یه آبجکت به اسم myCounter داریم و با هر بار صدا زدن فانکشن یه دونه به مقدارش اضافه می‌کنیم از اونجا به تب مموریِ کروم میریم و یه اسنپ‌شات از هیپ میگیریم و بعدش میتونیم دنبال اسم متغیرمون بگردیم و تو هیپ پیداش کنیم.

چیز به‌خصوصی که در مورد هیپ وجود داره اینه که هیپ Garbage Collected هست که ینی V8 یا ران‌تایم JS سعی میکنه وقتی که به متغیر جایی تو کدتون ارجاع داده نشده حافظه رو پاکسازی کنه و فضا آزاد کنه. این به این معنی نیست که دیگه شما نیازی به نگرانی در مورد حافظه ندارید بلکه فقط به این معنیه که نیاز نیست مثل اون چیزی که تو زبون C انجام میدید خودتون دستی بیاید حافظه رو اختصاص بدید و آزاد کنید.

حالا که میدونید کال استک و هیپ چیه میتونیم حلقه رویداد رو معرفی کنیم. تا اینجا ما دیدیم که چطوری یه لوپ همیشه صادقِ ساده میتونه یه زبون تک ثردی رو خراب کنه پس سوالی که پیش میاد اینه که ما چطوری میتونیم هر نوع تسکی که اجراش طولانیه رو هندل کنیم. جوابش حلقه رویداده (Event loop).

پس بیاید بریم و از اول مال خودمون رو بنویسیم. تو نگاه ابتدایی این فقط یه لوپ While هست که منتظر پیام‌ها از Queueئه تا بعدش دستورات هم‌روند(Synchronous) رو تا پایانشون اجرا کنه.

تو مرورگر، شما همین حالاشم دارید تمام مدت اینکارو بدون اینکه حتی بهش فکر کنید انجام میدید. ممکنه یه شنونده رویداد (Event listener) برای کلیک روی دکمه تعریف کرده باشید. وقتی کاربر اون دکمه رو کلیک کرد یه پیام به Queue میفرسته و بعدش ران‌تایم میاد هر کد جاوااسکریپتی‌ای که به عنوان کال‌بک برای اون رویداد تعریف کردید رو اجرا میکنه و این چیزیه که JS رو غیرمسدود کننده (Non-blocking) میکنه بخاطر اینکه تنها اتفاقی که میوفته اینه که به رویدادها گوش میده و کال‌بک‌هاشون رو هندل میکنه پس اون درواقع هیچ‌وقت منتظر مقداری که از فانکشن برمیگردونیم نمیمونه(این جمله رو خودم اصلا مطمئن نیستم منظورش چیه و خیلی منطقی به نظرم نمیاد.شمام احتیاط بیشتری کنید). تنها چیزی که واقعا منتظرش میمونه CPU هست که کدای هم‌روندتون رو اجرا ‌می‌کنه و تقریبا برای بیشتر چیزا این وایسادن در مقیاس میلی‌ثانیه‌س.

حالا بیاید اولین دورِ حلقه‌ی رویداد رو تصور کنیم.حلقه، اولش میاد و تمام کدای هم‌روند(Synchronous) اسکریپت رو هندل میکنه و بعد از اینکه کارش تموم شد میاد چک میکنه که آیا پیام یا کال‌بکی تو Queue آماده اجرا شدن هست یا نه؛ ما میتونیم این رفتارو خیلی ساده با اضافه کردن یه setTimeout با زمان ۰ ثانیه به بالای اسکریپت نشون بدیم.

حالا شما ممکنه به طور شهودی فکر کنید که این تایم‌اوت باید اول اجرا بشه چرا که اول فایلمونه و یه تایم‌اوت با تاخیر زمانیِ صفر ثانیه‌س ولی در حقیقت حلقه رویداد تا زمانی که اولین دورِ اجرای کدای هم‌روند رو تموم نکرده باشه سر وقت اون نمیره.

حالا چیزی که حلقه رویداد رو خاص میکنه اینه که میتونید کارای طولانی رو به یه استخر ثرد کاملا جدا بسپارید. تو مرورگر، شما ممکنه یه ریکوئست HTTP بزنید که چند ثانیه طول میکشه تا پاسخ بده یا تو Node.js ممکنه بخواید با فایل‌سیستم(File System) کار کنید و با این حال شما میتونید اینکارارو بدون اینکه ثرد اصلی JS رو بلاک کنید انجام بدید و این تقریبا تمام چیزیه که نیاز دارید از حلقه رویداد بدونید ولی JS پیش میره و اوضاعو با معرفی پرامیس‌ها(قول‌ها) و micro task queue یکم بیشتر عجیب میکنه.

اگه ما برگردیم به کدمون و بعد از تایم‌اوتمون یه پرامیس بذاریم که بلافاصله پاسخ میده ممکنه فکر کنید که ما دوتا عملیات غیر‌همروند(Asynchronous) با تاخیر ۰ داریم پس setTimeout اول اجرا میشه و پرامیس دوم ولی در واقع اینجا اون micro task queue برای پرامیس‌ها رو داریم که اولویت بالاتری نسبت به task queue اصلی‌ای داره که برای DOM API و setTimeout و چیزای این شکلی استفاده میشه که این به این معنیه که کال‌بکی که برای پرامیس نوشتیم اول اجرا میشه.

وقتی که حلقه رویداد داره دور میزنه اول کدای هم‌روند رو اجرا میکنه بعد میره سروقت micro task queue و هر کدوم از کال‌بکایی که بعد از پاسخ پرامیس آماده اجران رو اجرا میکنه و در نهایت میره سروقت کال‌بکایی از setTimeout یا DOM API که آماده اجرا شدن رو اجرا میکنه و این جوریه که JS کار میکنه حدس میزنم.

اگه اینا به نظرتون برگ‌ریزون میاد خیلی نگران نباشید بخاطر اینکه خیلی نیازی به دونستن اینا برای شروع ساخت چیز میز با JS ندارید.


منبع: لینک ویدیو



jsجاوا اسکریپتevent loopjavascriptcall stack
شاید از این پست‌ها خوشتان بیاید