خلاصه‌ای از کتاب You Don't Know JS Scope & Closures

این نوشته خلاصه‌ای است از کتاب You Don't Know JS Scope & Closures که میتونه به شما در درک بهتر جاوااسکریپت کمک کنه. البته که من خوندن خود کتاب رو به شما پیشنهاد می‌کنم اما مطالعه این خلاصه هم خالی از لطف نیست امیدوارم مفید باشه.

Scope چیه؟

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

خب گفتیم کامپایل، اما جاوااسکریپت مگه زبان کامپایلیه؟ جواب آره است. اما نباید تصور کنید داریم در مورد زبانی مشابه C, Java, C++ و.. صحبت میکنیم. جاوااسکریپت یک زبان برنامه‌نویسی کامپایلیه اما نه یک زبان کامپایلی سنتی.

نکته جذاب در مورد جاوااسکریپت اینه که برخلاف زبان‌های کامپایلی دیگه که طی کامپایل کلی زمان و منابع میتونن هدر بدن یا بهتره بگم صرف کنن، جاوااسکریپت بخاطر ماهیتش این امکان رو نداره و اکثرن کامپایل برنامه‌های جاوااسکریپتیِ ما تو کم‌تر از ماکرو ثانیه اتفاق می‌افته!

اما چه مراحلی برای کامپایل یک خط کد جاوااسکریپت طی میشه؟

برنامه var a = 2; رو تصور کنید. این برنامه وارد سه مرحله کلی از کامپایل میشه:

1.Tokenizing/Lexing: تمامی حروف معنی داری که ما در برنامه خودمون نوشتیم var و a و = و 2 و ; به شکل قطعه‌ها کوچیکی (token) در میان تا در مرحله بعد مورد استفاده قرار بگیرن. حالا ممکنه white space ها هم در زبانی خاص معنی دار به حساب بیان که باز میتونن تو این قطعات قرار بگیرن.

2.Parsing: آرایه‌ای از token‌ها تبدیل میشن به درختی به اسم AST (Abstraction Syntax Tree) که شامل ساختاری گرامری از خود زبان مبدا میشه؛ برای نمونه برنامه خودمون var بالاترین گره از درخت رو شامل میشه که VariableDeclaration نامیده میشه، که دو فرزند داره یکی a که Identifier هست و = که AssignmentExpression. و در نهایت ۲ که NumericLiteral.

3.Code-Generation: مرحله آخر تبدیل درخت AST به کدی قابل اجرا است. این مرحله با توجه به زبان‌های مختلف میتونه پروسه‌های متفاوتی رو طی کنه.

اجرای برنامه var a = 2; در JS

سه بخش مهم تو جاوااسکریپت با هم همکاری میکنن:

1.engine: که وظیفه‌اش شروع کردن و پایان دادن به اجرای یک برنامه جاوااسکریپته.

2.compiler: که وظیفه‌اش را بالاتر گفتیم طی چند مرحله چه کار‌هایی انجام میده.

3.و اما scope: اسکوپ رو میشه یک مجموعه‌ای تصور کرد که تمامی متغیر‌های تعریف شده identifiers یا همون متغیر‌ها داخلش نگهداری میشه و از طرفی یک سری قوانین و دستور العمل‌هایی رو تعریف کرده که تعیین کننده نحوه دستیابی به این مقادیره.

و اما مراحلی که این سه بخش طی میکنن:

۱. اول از همه کامپایلر با var a مواجه میشه و از scope میپرسه که آیا متغیر a برای این بخش خاص از scope وجود داره؟ اگر آره که ازش رد میشه، اما اگر وجود نداشته باشه از scope میخواد که متغیر a رو داخل اسکوپ قرار بده.

۲. حالا کامپایلر کدی رو که برای engine قابل فهمه رو تولید میکنه، تا بخش assign کردن مقدار ۲ به متغیر a توسط engine اتفاق بی‌افته. حالا engine از scope میپرسه آیا متغیر aای وجود داره داخل همینجایی که من هستم (اسکوپ کالکشن فعلی) اگر آره که مقدار ۲ رو بهش assign می‌کنه در غیر اینصورت اررور برمیگردونه مبنی بر اینکه متغیر a اصلا وجود نداره که من بخوام مقدار a رو بهش assign کنم.

نکته اول: میشه تعریف متغیر a رو دو بخش جدا از هم تصور کرد. اول اینکه کامپایلر متغیر a رو میزاره داخل scope و در ادامه و حین اجرا engine میاد متغیر رو از scope پیدا میکنه و مقدار رو بهش assing میکنه.

نکته دوم: اسکوپ رو اگه به یک آبجکت تو در تو تشبیه کنیم engine اگر متغیر a رو داخل اسکوپی که هست پیدا نکنه به آبجکت بالاتر از خودش میره تا بلکه متغیر a رو اونجا پیدا کنه همینطور بالا و بالاتر میره تا به اسکوپ global برسه و اگر وجود نداشته باشه در حالت strict mode اررور برمیگردونه. البته دیده شده در حالت غیر strcit ممکنه engine دست به تعریف این متغیر بزنه! و اونو در محیط مرورگر به آبجکت عمومی که window است وصل کنه چیزی شبیه به این window.a = 2.

Lexical Scope

دو مدل کلی وجود داره که اسکوپ چطور کار میکنه، اولی lexical scope که تعداد زیادی از زبان‌های برنامه‌نویسی از اون بهره می‌برن. و مدل دیگه dynamic scope که یک سری از زبان‌ها مثل bash و perl و... از اون استفاده میکنن.

بالاتر در مورد مراحل کامپایل که صحبت کردیم اولین مرحله از کامپایل tokenizing/lexing نام داشت و کلمات و نشانه‌های با معنی برنامه به شکل قطعاتی در میومد این مرحله رو تو کتاب‌های دانشگاهی با عنوان <<تحلیلگر لغوی>> ترجمه کردن و تو رفرنس‌های خارجی lexing یا مرحله lex-time ازش نام برده میشه که به صورت کلی مفهوم نوشتن یا زمانی که کد نوشته شده رو در ذهن تداعی می‌کنه. این نوع از اسکوپ غیرقابل تغییره.

راحت‌ترش << lexical scope>> دقیقا همونجاییه که شما دارید یک متغیر رو تعریف میکنید و engine و هیچکس دیگه‌ای تو هوویت اون تاثیری ندارن اگه فقط قرار باشه بگم که در مقابلش چه اسکوپی دیگه‌ای وچود داره اون اسکوپ نامش است runtime scope.

alt

مثال بالا رو در نظر بگیرید:

قسمت 1 که شامل تابع foo میشه یک متغیر یا identifier داره که اون هم foo هستش.

قسمت 2 که شامل تابع bar میشه شامل ۳ تا identifier میشه یکی a, b, bar.

قسمت 3 که داخل بدنه bar میشه شامل تنها یک identifier میشه که اون هم c.

گفتیم که lexical scope غیر‌قابل تغییره و از طرفی یک متغیر یا identifier نمیتونه داخل دو تا lexical scope باشه engine با دیدن اولین متغیر اون و درنظر میگره و بالاتر نمیره.

Block Scope

براکت، بدنه حلقه‌‌ها، یک شرط و ... همه و همه یک block هستن ... اما نه در جاوااسکریپت!

زمانی که ما متغییری رو داخل block تعریف می‌کنیم انتظار داریم تنها در همون بلاک وجود داشته باشه. و یا اینکه اگر بیرون اون block متغیری برای نمونه a رو تعریف می‌کنیم انتظار نداریم که اگر داخل بلاک باز متغیری با نام a تعریف کردیم با ارور مواجه شیم، اما متاسفانه میشیم. چرا؟

جاوااسکریپت یک زبان function scope base هست یعنی اینکه تنها جا یا ساختاری که قابلیت اینو داره که اسکوپ خودش رو داشته باشه اون توابع هستند.

for (var i = 0; i < 5; i++) {
  console.log("hello var!")
}
console.log("i: ", i) // i: 5 !!! 🤔

حلقه for یکبار اجرا شده و انتظار میره که i فقط داخل بدنه حلقه قابل دستیابی و به نوعی زنده باشه.
اما چیزی که ما شاهدش هستیم اینه که متغیر همچنان موجوده و حتی الان دارای مقدار ۵!

راه حل اول

خب بالاتر گفتیم جاوااسکریپت قابلیت block scope رو به ما میده فقط در توابع میشه از توابع self invoke استفاده کرد به مثال زیر توجه کنید:

;(function() {
  for (var i = 0; i < 5; i++) {
    console.log("hello var!")
  }
})()

console.log("i ", i) // ReferenceError: i is not defined

خب به ظاهر مشکل حل شد اما داخل تابع چی؟ متاسفانه i همچنان با مقدار ۵ زنده است.

راه حل دوم استفاده از let به جای var. همونطور که میدونید let و const دو روشی هستند که تقریبا جایگزین var برای تعریف متغیر شدن مثال زیر رو ببینیم:

for (let i = 0; i < 5; i++) {
  console.log("hello var!")
}

console.log("i ", i) // ReferenceError: i is not defined

اما چه اتفاقی داره می‌افته؟ یک جای دیگه‌ای هم در JS هست که بلاک در اون وجود داره ‌و اون try/catch جالبه بدونید نحوه‌ای که کد JS برای تعریف متغیر به روش let و const استفاده می‌کنه مستقیما وابسته به ساختار try/catch و کدها با استفاده از این ویژگی توسط ابزاری مثل pollyfill , babel میشن.

پیشنهاد می‌کنم این توضیحات رو از دست ندین.

Hoisting

تو خیلی از مقالات شاید با این عنوان مواجه شده باشید که فلان چیز hoist شده اما به راستی یعنی چی؟

خب بریم بالاتر جایی که اسکوپ و engine دو تا کار جدا از هم رو برای اجرای برنامه var a = 2; انجام میدادن.

چیزی که تا الان فهمیدیم اینه که جاوااسکریپت این برنامه رو دو بخش جدا از هم میدونه یعنی var a و a = 2. این یعنی تعریف متغیر در یک زمان و assign شدن مقدار به اون در زمان دیگری اتفاق می‌افته. پس حالا اگه به قطعه کد زیر توجه کنیم نباید خیلی از نتیجه بدست اومده تعجب کنیم.

a = 2

console.log("a is", a) // 2!

var a

این به این معنی است که قبل از اجرای کد در جاوااسکریپت تمام declaration ها یا همون تعاریف متغیر‌ها و توابع و... کامپایل شدن و رسیدن به دست engine. برای نمونه در مثال زیر تابع foo صدا زده شده در صورتی که تعریف اون تابع پایین‌تر اتفاق افتاده:

foo() // FOO

function foo() {
  console.log("FOO")
}

اما یه نکته مهم در مورد hoisting توابع هست و اون اینه که یک تابع رو شما میتونید به دو صورت تعریف کنید یکی اینکه اون رو داخل متغیر بریزید یعنی function declarations تعریف تابع به وسیله مقدار دهی به یک متعغیر و یا اینکه از function expressions استفاده کنید از همون کلمه فانکشن استفاده کنید. نکته اینجاست که اگر تابع داخل یک متغیر تعریف بشه. اگر قبل از نوشتن اون تابع رو صدا بزنید با همچین ارروری مواجه میشید TypeError: foo is not a function دقت کنید که مقدار undefined به شما نشون داده نمیشه چرا؟ چون متغیر foo داخل اسکوپ تعریف شده اما مقداری که همون بدنه تابع هست به اون assign نشده. در صورتی که نیاز به‌ hoisting در تعاریف توابع‌تون دارید حتما از function expressions استفاده کنید.

کلوژر(Closure)

بعد از بحث‌هایی که تا الان داشتیم میرسیم به اصل مطلب کلوژر که فهم درست اون منبوط هست به فهم و دونستن مطالبی که تا الان گفتیم.

کلوژر: کلوژر جاییه که یک تابع بتونه به lexical scope خودش دسترسی داشته باشه حتی زمانی که خارج از اون (lexical scope) در حال اجراست. به مثال زیر توجه کنید.

function foo() {
  var a = 2

  function bar() {
    console.log(a) // 2
  }

  bar()
}

foo()

رای نمونه اگه به تابع foo نگاه کنید داخلش ما تابع bar رو داریم که کارش لاگ کردن متغیر a هست. متغیری که داخل lexical scope تابع foo تعریف شده. بنظر میرسه خیلی شبیه بحث scopeهای تو در تو هست اما اگه یه خورده دقت کنیم از لحاظ فنی بعد از اجرای foo این تابع جای دیگه‌ای در حال استفاده(in use) نیست این یعنی باید این تابع از مموری حذف بشه اصطلاح فنیش (Garbage Collect) بشه. اما کلوژر به کامپایلر میگه که با توجه به حضور bar داخل تابع foo این تابع همچنان in use به حساب میاد پس اون و داخل مموری نگه میداره.

نکته: در اینجا bar همچنان یک رفرنس داره به ‌اسکوپ foo، و این رفرنس رو بهش میگیم کلوژر.

پایان

برای خودم بار‌ها پیش می‌اومد که میرفتم سراغ مطالب داخل مدیوم یا سایت‌های دیگه که بفهمم this کجاست یا hoisting یعنی چی و یا کلوژر ... هر بار با یک سری مطالب مواجه میشدم که خیلی سطحی و کلی با بیان حداکثر کلمات سعی در تعریف یکی از این اصطلاحات داشتن و من هم ناگزیر هر بار برای مدت کوتاهی این کلمات رو تو ذهنم نگه می‌داشتم که بعد از مدتی اون مطالب گاربژ کالت میشد. این شد که تصمیم گرفتم برم سراغ مطلبی عمیق‌تر از چیزایی که تا اون موقع خوندم و همین موضوع تو درک اتفاقی‌ که واقعا در حال افتادن بود بیشتر کمکم کرد حالا تعریف دقیق‌تری از اون مطالب دارم.

بگذیریم که این پایان هم شبیه آغاز شد :)