در جاوااسکریپت، hoisting به ما اجازه میدهد تا توابع و متغیرها را قبل از اینکه آنها را تعریف کنیم، مورد استفاده قرار دهیم. در این مقاله قصد داریم تا در مورد این که hoisting چیست و چگونه کار میکند صحبت کنیم. همچنین در دوره آموزش جاوااسکریپت – مفاهیم پیشرفته نیز این مفهوم به شکل دقیق مورد بررسی قرار گرفته است.
در ادامه یک مثال کد داریم:
console.log(foo); var foo = 'foo';
نتیجه ممکن است شگفتانگیز باشد زیرا با این که foo
پس از console.log
تعریف شده است اما این کد در خروجی undefined
را نشان داده و خطایی ایجاد نمیکند.
دلیل آن این است که مفسر جاوااسکریپت تعریف و تخصیص توابع و متغیرها را تقسیمبندی میکند. یعنی توابع یا متغیرهایی که تعریف میکنیم قبل از اجرا در قسمت بالای scope حاوی آنها قرار میگیرد. این فرآیند hoisting نام دارد و به ما اجازه میدهد تا بتوانیم از foo
قبل از تعریف آن، استفاده کنیم.
همانطور که میدانیم یک متغیر را با استفاده از دستورات var
، let
و const
تعریف میکنیم. مثلا:
var foo; let bar;
با استفاده از عملگر انتساب مقداری را به یک متغیر اختصاص میدهیم:
// Declaration var foo; let bar; // Assignment foo = 'foo'; bar = 'bar';
در بسیاری از موارد میتوانیم تعریف و تخصیص را در یک مرحله انجام دهیم:
var foo = 'foo'; let bar = 'bar'; const baz = 'baz';
hoisting متغیر بسته به نحوه تعریف متغیرها رفتار متفاوتی دارد. در ادامه هر کدام از اینها را باهم بررسی میکنیم.
هنگامی که مفسر یک متغیر تعریف شده با var
را hoist میکند، value آن را با undefined
مقداردهی اولیه میکند. خروجی اولین خط کد زیر به صورت undefined
خواهد بود:
console.log(foo); // undefined var foo = 'bar'; console.log(foo); // "bar"
همانطور که قبلاً به آن اشاره کردیم، hoisting از تعریف و تخصیص متغیر جداکننده مفسر ناشی میشود. ما میتوانیم با تقسیم کردن تعریف و تخصیص به دو مرحله، به این رفتار مشابه دست پیدا کنیم:
var foo; console.log(foo); // undefined foo = 'foo'; console.log(foo); // "foo"
باید به یاد داشته باشیم که مقدار خروجی اولین console.log(foo)
برابر با undefined
خواهد بود زیرا foo
بالا میرود و یک مقدار پیش فرض میگیرد (نه به این دلیل که متغیر هرگز تعریف نمیشود). استفاده از یک متغیری که تعریف نشده است یک ReferenceError
ایجاد میکند:
console.log(foo); // Uncaught ReferenceError: foo is not defined
استفاده از یک متغیر تعریف نشده قبل از تخصیص نیز یک ReferenceError
ایجاد میکند زیرا هیچ مقداری وجود ندارد که hoist شود.
console.log(foo); // Uncaught ReferenceError: foo is not defined foo = 'foo'; // Assigning a variable that's not declared is valid
در حال حاضر ممکن است اینطور به نظر بیاید که این رفتار جاوااسکریپت به ما اجازه میدهد تا به متغیرها قبل از تعریف شدن آنها دسترسی داشته باشیم یک رفتار غیرعادی بوده و میتواند منجر به ایجاد خطا شود. بنابراین استفاده از یک متغیر قبل از تعریف شدن آن معمولاً مطلوب نیست. خوشبختانه متغیرهای let
و const
که در ECMAScript 2015 معرفی شدند رفتار متفاوتی دارند.
متغیرهای تعریف شده با let
و const
hoist میشوند اما value آنها بهطور پیشفرض مقداردهی اولیه نمیشوند. دسترسی به متغیر let
یا const
قبل از تعریف منجر به ایجاد ReferenceError
میشود:
console.log(foo); // Uncaught ReferenceError: Cannot access 'foo' before initialization let foo = 'bar'; // Same behavior for variables declared with const
باید به این موضوع توجه داشته باشیم که مفسر همچنان foo
را hoist میکند. پیام خطا به ما میگوید که متغیر در جایی مقداردهی اولیه شده است.
دلیل این اتفاق که وقتی میخواهیم به متغیر let
یا const
قبل از تعریف آن دسترسی پیدا کنیم خطای ReferenceError
دریافت میکنیم، the temporal dead zone (TDZ) است.
TDZ از ابتدای scope متغیر شروع میشود و با تعریف آن به پایان میرسد. دسترسی به متغیر در این TDZ یک ReferenceError ایجاد میکند. در ادامه یک مثال با یک block مشخص داریم که شروع و پایان TDZ مربوط به foo
را نشان میدهد:
{ // Start of foo's TDZ let bar = 'bar'; console.log(bar); // "bar" console.log(foo); // ReferenceError because we're in the TDZ let foo = 'foo'; // End of foo's TDZ }
TDZ همچنین در پارامترهای تابع پیشفرض وجود دارد که از چپ به راست ارزیابی میشوند. به عنوان مثال در کد زیر تا زمانی که مقدار پیشفرض برای bar
تنظیم شود در TDZ قرار دارد:
function foobar(foo = bar, bar = 'bar') { console.log(foo); } foobar(); // Uncaught ReferenceError: Cannot access 'bar' before initialization
اما کد زیر کار میکند زیرا میتوانیم خارج از TDZ به foo
دسترسی داشته باشیم:
function foobar(foo = 'foo', bar = foo) { console.log(bar); } foobar(); // "foo"
استفاده از متغیر let
یا const
به عنوان عملوند عملگر typeof
در TDZ یک خطا ایجاد میکند:
console.log(typeof foo); // Uncaught ReferenceError: Cannot access 'foo' before initialization let foo = 'foo';
این رفتار با سایر موارد let
و const
در TDZ که دیدهایم سازگار است. اما دلیل اینکه اینجا یک ReferenceError
دریافت میکنیم این است که foo
تعریف شده است اما مقداردهی اولیه نشده است که طبق گفته Axel Rauschmayer باید توجه داشته باشیم قبل از مقداردهی اولیه از آن استفاده کنیم.
با این حال این مورد هنگام استفاده از متغیر var
قبل از تعریف، صدق نمیکند. زیرا هنگامی که hoist میشود مقداردهی اولیه با undefined
انجام میگردد:
console.log(typeof foo); // "undefined" var foo = 'foo';
نکته بسیار جالبی که در این مورد وجود دارد این است که میتوانیم نوع متغیری که وجود ندارد را بدون هیچ خطایی بررسی کنیم. در مثال پایین typeof
یک رشته را برمیگرداند:
console.log(typeof foo); // "undefined"
تعریف توابع نیز همانند متغیرها از hoisting پیروی میکند. hoisting تابع به ما این اجازه را میدهد تا بتوانیم یک تابع را قبل از تعریف فراخوانی کنیم. به عنوان مثال، کد زیر با موفقیت اجرا میشود و "foo"
را در خروجی نشان میدهد:
foo(); // "foo" function foo() { console.log('foo'); }
باید به این نکته توجه داشته باشیم که فقط تعریف تابع هست که از hoisting پیروی میکند، نه عبارتهای تابع.
اگر بخواهیم متغیری که عبارت تابع به آن اختصاص داده شده است را فراخوانی کنیم، بسته به scope متغیر، یک TypeError
یا ReferenceError
دریافت خواهیم کرد:
foo(); // Uncaught TypeError: foo is not a function var foo = function () { } bar(); // Uncaught ReferenceError: Cannot access 'bar' before initialization let bar = function () { } baz(); // Uncaught ReferenceError: Cannot access 'baz' before initialization const baz = function () { }
این موضوع با فراخوانی تابعی که هرگز تعریف نشده است متفاوت است و یک ReferenceError
متفاوت ایجاد میکند:
foo(); // Uncaught ReferenceError: baz is not defined
به دلیل سردرگمی که var
در رابطه با hoisting میتواند ایجاد کند، بهتر است در پروژههای خود برای تعریف متغیرها به جای آن از let
و const
استفاده کنیم. اما اگر به هر دلیلی مجبور به استفاده از var
هستیم، MDN توصیه میکند که تعریف متغیرها را تا حد امکان نزدیک به بالای scope آنها انجام دهیم. این کار باعث میشود تا scope متغیرها واضحتر شود.
همچنین میتوانیم از قانون No-use-Before-Define
ESLint استفاده کنیم که اطمینان میدهد متغیری قبل از اینکه تعریف شود مورد استفاده قرار نمیگیرد.
hoisting تابع یک کار مفید است زیرا میتوانیم اجرای تابع را در فایلی پنهان کنیم و به خواننده اجازه دهیم تا روی کاری که کد انجام میدهد تمرکز کند. به عبارت دیگر ما میتوانیم بدون اینکه در ابتدا درک کنیم که کد چگونه پیادهسازی شده است، یک فایل را باز کنیم و ببینیم که آن کد چه کاری انجام میدهد. مثال زیر را در نظر بگیرید:
resetScore(); drawGameBoard(); populateGameBoard(); startGame(); function resetScore() { console.log("Resetting score"); } function drawGameBoard() { console.log("Drawing board"); } function populateGameBoard() { console.log("Populating board"); } function startGame() { console.log("Starting game"); }
در این مثال ما قبل از این که با تعریف توابع آشنا شویم، یک دید کلی از کاری که کد قرار است انجام دهد به دست میآوریم.
با این حال استفاده از توابع قبل از تعریف شدن آنها یک ترجیح شخصی است. برخی از توسعه دهندگان ترجیح میدهند از این کار اجتناب کنند و توابع را در ماژولهایی قرار داده و هنگام نیاز آنها را در برنامه خود import کنند.
اما بهطور کلی توصیه میشود که بیشتر از توابع تعریف شده استفاده کنیم تا با این کار از ارجاع قبل از تعریف جلوگیری کنیم. زیرا این ارجاع به خوانایی و قابلیت نگهداری کد آسیب میرساند.