پیش‌بینی گرفتن در Chrome DevTools: چرا سخت است و چگونه آن را بهتر کنیم

اریک لیز
Eric Leese

اشکال زدایی استثناها در برنامه های کاربردی وب ساده به نظر می رسد: زمانی که مشکلی پیش می آید اجرای را متوقف کنید و بررسی کنید. اما ماهیت ناهمزمان جاوا اسکریپت این را به طرز شگفت آوری پیچیده می کند. چگونه می‌تواند Chrome DevTools بداند زمانی که استثناها از طریق وعده‌ها و توابع ناهمزمان عبور می‌کنند، چه زمانی و کجا مکث کند؟

این پست به چالش‌های پیش‌بینی شکار می‌پردازد – توانایی DevTools برای پیش‌بینی اینکه آیا یک استثنا بعداً در کد شما ثبت می‌شود یا خیر. ما بررسی خواهیم کرد که چرا اینقدر مشکل است و چگونه پیشرفت‌های اخیر در V8 (موتور جاوا اسکریپت که کروم را تامین می‌کند) آن را دقیق‌تر می‌کند و منجر به تجربه اشکال‌زدایی روان‌تر می‌شود.

چرا پیش‌بینی مهم است

در Chrome DevTools، گزینه‌ای دارید که اجرای کد را فقط برای استثناهای کشف نشده متوقف کنید، و از مواردی که دستگیر شده‌اند رد شوید.

Chrome DevTools گزینه‌های جداگانه‌ای را برای مکث در موارد استثنا شده یا کشف نشده ارائه می‌کند

در پشت صحنه، هنگامی که یک استثنا برای حفظ متن رخ می دهد، اشکال زدا بلافاصله متوقف می شود. این یک پیش‌بینی است، زیرا در حال حاضر نمی‌توان مطمئن شد که آیا استثنا بعداً در کد ثبت می‌شود یا نه، به خصوص در سناریوهای ناهمزمان. این عدم قطعیت از دشواری ذاتی پیش‌بینی رفتار برنامه، مشابه مشکل توقف ، ناشی می‌شود.

مثال زیر را در نظر بگیرید: کجا باید دیباگر مکث کند؟ (در بخش بعدی به دنبال پاسخ باشید.)

async function inner() {
  throw new Error(); // Should the debugger pause here?
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ... or should the debugger pause here?
  }
}

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

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

  • منفی های کاذب (پیش بینی "غیر گیر" زمانی که گرفتار می شود) . توقف های غیر ضروری در دیباگر.
  • موارد مثبت کاذب (پیش‌بینی "گرفتار" زمانی که کشف نشود) . فرصت‌های از دست رفته برای دریافت خطاهای مهم، به طور بالقوه شما را مجبور می‌کند همه استثناها، از جمله موارد مورد انتظار را اشکال‌زدایی کنید.

روش دیگر برای کاهش وقفه‌های اشکال‌زدایی، استفاده از فهرست نادیده گرفته می‌شود، که از شکستن استثناها در کد شخص ثالث مشخص شده جلوگیری می‌کند. با این حال، پیش‌بینی دقیق صید هنوز در اینجا بسیار مهم است. اگر استثنایی که از کد شخص ثالث منشأ می گیرد فرار کرد و بر روی کد شما تأثیر گذاشت، باید بتوانید آن را اشکال زدایی کنید.

نحوه عملکرد کدهای ناهمزمان

Promises، async و await و سایر الگوهای ناهمزمان می‌توانند به سناریوهایی منجر شوند که در آن یک استثنا یا رد، قبل از رسیدگی، ممکن است مسیر اجرایی را طی کند که در زمان ایجاد استثنا، تعیین آن دشوار است. این به این دلیل است که ممکن است منتظر وعده‌ها نباشند یا تا زمانی که استثنا رخ داده باشد، کنترل‌کننده‌های catch اضافه شوند. بیایید به مثال قبلی خود نگاه کنیم:

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

در این مثال، outer() ابتدا inner() را فراخوانی می کند که بلافاصله یک استثنا ایجاد می کند. از این، دیباگر می‌تواند نتیجه بگیرد که inner() یک وعده رد شده را برمی‌گرداند، اما در حال حاضر چیزی در انتظار یا به‌طور دیگری آن وعده را مدیریت نمی‌کند. اشکال‌زدا می‌تواند حدس بزند که outer() احتمالاً منتظر آن خواهد بود و حدس می‌زند که این کار را در بلوک try فعلی‌اش انجام می‌دهد و بنابراین آن را مدیریت می‌کند، اما اشکال‌زدا نمی‌تواند از این موضوع مطمئن باشد تا زمانی که وعده رد شده بازگردانده شود و عبارت await باشد. در نهایت رسید.

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

در V8، یک Promise جاوا اسکریپت به عنوان یک شی نشان داده می شود که می تواند در یکی از سه حالت باشد: انجام شده، رد شده، یا در انتظار. اگر یک وعده در حالت انجام شده باشد و متد .then() را فراخوانی کنید، یک وعده جدید در انتظار ایجاد می‌شود و یک وظیفه واکنش وعده جدید برنامه‌ریزی می‌شود که کنترل کننده را اجرا می‌کند و سپس با نتیجه کنترل‌کننده، وعده را به صورت برآورده تنظیم می‌کند. یا در صورتی که کنترل کننده استثنایی ایجاد کند، آن را روی rejected تنظیم کنید. همین اتفاق می افتد اگر متد .catch() را روی یک وعده رد شده فراخوانی کنید. برعکس، فراخوانی .then() در یک وعده رد شده یا .catch() در یک وعده محقق شده، یک وعده را در همان حالت برمی گرداند و کنترل کننده را اجرا نمی کند.

یک وعده در انتظار شامل یک لیست واکنش است که در آن هر شی واکنش شامل یک کنترل کننده تحقق یا کنترل کننده رد (یا هر دو) و یک وعده واکنش است. بنابراین فراخوانی .then() روی یک وعده در انتظار، یک واکنش با یک کنترل کننده انجام شده و همچنین یک وعده در انتظار جدید برای وعده واکنش اضافه می کند، که .then() برمی گردد. فراخوانی .catch() یک واکنش مشابه اما با یک کنترل کننده رد اضافه می کند. فراخوانی .then() با دو آرگومان یک واکنش با هر دو هندلر ایجاد می کند و فراخوانی .finally() یا انتظار وعده، واکنشی با دو handler اضافه می کند که توابع داخلی مخصوص اجرای این ویژگی ها هستند.

وقتی وعده معلق در نهایت محقق شد یا رد شد، کارهای واکنش برای همه کنترل‌کننده‌های انجام‌شده یا همه کنترل‌کننده‌های رد شده برنامه‌ریزی می‌شود. سپس وعده‌های واکنش مربوطه به‌روزرسانی می‌شوند و به طور بالقوه مشاغل واکنش خود را آغاز می‌کنند.

نمونه ها

کد زیر را در نظر بگیرید:

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

ممکن است واضح نباشد که این کد شامل سه شیء Promise متمایز است. کد بالا معادل کد زیر می باشد:

const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;

در این مثال، مراحل زیر اتفاق می افتد:

  1. سازنده Promise نامیده می شود.
  2. یک Promise جدید در انتظار ایجاد شد.
  3. تابع ناشناس اجرا می شود.
  4. استثنا انداخته می شود. در این مرحله، دیباگر باید تصمیم بگیرد که متوقف شود یا نه.
  5. سازنده وعده این استثنا را می گیرد و سپس وضعیت وعده خود را به rejected با مقدار تنظیم شده آن به خطای پرتاب شده تغییر می دهد. این وعده را برمی گرداند که در promise1 ذخیره شده است.
  6. .then() هیچ عکس العملی را برنامه ریزی نمی کند زیرا promise1 در حالت rejected است. در عوض، یک وعده جدید ( promise2 ) برگردانده می شود که آن هم با همان خطا در حالت رد شده است.
  7. .catch() یک کار واکنش را با کنترل کننده ارائه شده و یک وعده واکنش معلق جدید را برنامه ریزی می کند که به عنوان promise3 برگردانده می شود. در این مرحله اشکال‌زدا می‌داند که خطا رسیدگی خواهد شد.
  8. هنگامی که وظیفه واکنش اجرا می شود، کنترل کننده به طور عادی برمی گردد و وضعیت promise3 به fulfilled تغییر می کند.

مثال بعدی ساختار مشابهی دارد اما اجرا کاملا متفاوت است:

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

این معادل است با:

const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;

در این مثال، مراحل زیر اتفاق می افتد:

  1. یک Promise در حالت fulfilled ایجاد می شود و در promise1 ذخیره می شود.
  2. یک وظیفه واکنش وعده با اولین تابع ناشناس برنامه ریزی می شود و وعده واکنش (pending) آن به عنوان promise2 برگردانده می شود.
  3. یک واکنش با یک کنترل کننده برآورده شده و وعده واکنش آن به promise2 اضافه می شود که به عنوان promise3 برگردانده می شود.
  4. یک واکنش با یک کنترل کننده رد شده به promise3 اضافه می شود و یک وعده واکنش دیگر که به عنوان promise4 برگردانده می شود.
  5. وظیفه واکنش برنامه ریزی شده در مرحله 2 اجرا می شود.
  6. کنترل کننده یک استثنا می اندازد. در این مرحله دیباگر باید تصمیم بگیرد که متوقف شود یا نه. در حال حاضر کنترل کننده تنها کد جاوا اسکریپت در حال اجرا شماست.
  7. از آنجایی که کار با یک استثنا به پایان می رسد، وعده واکنش مرتبط ( promise2 ) به حالت رد شده با مقدار آن روی خطای تنظیم شده تنظیم می شود.
  8. از آنجا که promise2 یک واکنش داشت و آن واکنش هیچ کنترل کننده رد شده ای نداشت، وعده واکنش آن ( promise3 ) نیز با همان خطا rejected می شود.
  9. از آنجا که promise3 یک واکنش داشت، و آن واکنش دارای یک کنترل کننده رد شده بود، یک وظیفه واکنش وعده با آن کنترل کننده و وعده واکنش آن برنامه ریزی می شود ( promise4 ).
  10. هنگامی که آن وظیفه واکنش اجرا می شود، کنترل کننده به طور عادی برمی گردد و حالت promise4 به تحقق یافته تغییر می کند.

روش های پیش بینی صید

دو منبع اطلاعاتی بالقوه برای پیش بینی صید وجود دارد. یکی پشته تماس است. این صدا برای استثناهای همزمان است: اشکال‌زدا می‌تواند پشته تماس را به همان روشی طی کند که کد بازگشایی استثنا انجام می‌شود و اگر فریمی را پیدا کند که در یک بلوک try...catch است، متوقف می‌شود. برای وعده‌ها یا استثناهای رد شده در سازنده‌های وعده یا در توابع ناهمزمان که هرگز تعلیق نشده‌اند، اشکال‌زدا نیز به پشته تماس متکی است، اما در این مورد، پیش‌بینی آن در همه موارد نمی‌تواند قابل اعتماد باشد. این به این دلیل است که به جای پرتاب یک استثنا به نزدیک‌ترین کنترل‌کننده، کد ناهمزمان یک استثنا رد شده را برمی‌گرداند و اشکال‌زدا باید چند فرض در مورد کاری که تماس‌گیرنده با آن انجام می‌دهد انجام دهد.

ابتدا، اشکال‌زدا فرض می‌کند که تابعی که یک وعده بازگشتی دریافت می‌کند احتمالاً آن وعده یا یک وعده مشتق شده را برمی‌گرداند، بنابراین توابع ناهمزمان بالاتر از پشته فرصتی برای انتظار آن را داشته باشند. دوم، اشکال‌زدا فرض می‌کند که اگر یک وعده به یک تابع ناهمزمان برگردانده شود، به زودی بدون ورود یا خروج از یک بلوک try...catch منتظر آن می‌ماند. هیچ یک از این فرضیات تضمین نمی شود که درست باشند، اما برای پیش بینی صحیح برای رایج ترین الگوهای کدگذاری با توابع ناهمزمان کافی هستند. در کروم نسخه 125، یک اکتشافی دیگر اضافه کردیم: اشکال‌زدا بررسی می‌کند که آیا فراخوانی می‌خواهد .catch() را روی مقداری که برگردانده می‌شود (یا .then() با دو آرگومان، یا زنجیره‌ای از فراخوانی‌ها به .then() یا .finally() به دنبال آن یک .catch() یا دو آرگومان .then() ). در این حالت، اشکال‌زدا فرض می‌کند که این‌ها روش‌هایی هستند که روی قولی که در حال ردیابی آن هستیم یا یکی از روش‌های مرتبط با آن هستند، بنابراین رد شدن صورت می‌گیرد.

منبع دوم اطلاعات درخت واکنش های وعده است. دیباگر با یک وعده ریشه شروع می شود. گاهی اوقات این یک وعده است که متد reject() آن به تازگی فراخوانی شده است. معمولاً، هنگامی که یک استثنا یا رد در طول یک کار واکنش وعده اتفاق می‌افتد، و به نظر می‌رسد چیزی در پشته تماس آن را نمی‌گیرد، اشکال‌زدا از قول مرتبط با واکنش ردیابی می‌کند. اشکال‌زدا همه واکنش‌های مربوط به وعده‌های معلق را بررسی می‌کند و می‌بیند که آیا آنها کنترل‌کننده‌های رد دارند یا خیر. اگر هر واکنشی انجام نشود، به وعده واکنش نگاه می کند و به صورت بازگشتی از آن ردیابی می کند. اگر همه واکنش‌ها در نهایت منجر به یک کنترل کننده رد شوند، اشکال‌زدا رد وعده را محرز می‌کند. موارد خاصی برای پوشش وجود دارد، به عنوان مثال، عدم احتساب کنترل کننده رد داخلی برای فراخوانی .finally() .

درخت واکنش وعده منبع اطلاعاتی معمولاً قابل اعتمادی را در صورت وجود اطلاعات فراهم می کند. در برخی موارد، مانند فراخوانی به Promise.reject() یا در سازنده Promise یا در یک تابع async که هنوز منتظر چیزی نیست، هیچ واکنشی برای ردیابی وجود نخواهد داشت و اشکال‌زدا باید به پشته تماس تکیه کند. در موارد دیگر، درخت واکنش وعده معمولاً شامل کنترل‌کننده‌های لازم برای استنتاج پیش‌بینی گرفتن است، اما همیشه این امکان وجود دارد که بعداً کنترل‌کننده‌های بیشتری اضافه شوند که استثنا را از catch به uncack یا برعکس تغییر دهند. همچنین وعده‌هایی مانند وعده‌هایی وجود دارد که توسط Promise.all/any/race ایجاد شده است، که در آن سایر وعده‌های گروه ممکن است بر نحوه برخورد با رد تأثیر بگذارد. برای این روش‌ها، اشکال‌زدا فرض می‌کند که در صورتی که وعده هنوز معلق باشد، رد قول ارسال می‌شود.

به دو مثال زیر توجه کنید:

دو مثال برای پیش بینی صید

در حالی که این دو نمونه از استثناهای گرفته شده مشابه به نظر می رسند، اما به اکتشافی پیش بینی گرفتن کاملاً متفاوتی نیاز دارند. در مثال اول، یک وعده حل‌شده ایجاد می‌شود، سپس یک واکنش واکنش برای .then() برنامه‌ریزی می‌شود که یک استثنا ایجاد می‌کند، سپس .catch() فراخوانی می‌شود تا یک کنترل کننده رد را به وعده واکنش متصل کند. هنگامی که وظیفه واکنش اجرا می‌شود، استثنا پرتاب می‌شود و درخت واکنش وعده حاوی کنترل‌کننده catch است، بنابراین به‌عنوان catch شناسایی می‌شود. در مثال دوم، قول بلافاصله قبل از اجرای کد اضافه کردن یک کنترل کننده رد می شود، بنابراین هیچ کنترل کننده رد در درخت واکنش وعده وجود ندارد. اشکال‌زدا باید به پشته تماس نگاه کند، اما بلوک‌های try...catch نیز وجود ندارد. برای پیش‌بینی صحیح این موضوع، اشکال‌زدا قبل از مکان فعلی در کد، فراخوانی به .catch() را اسکن می‌کند و بر این اساس فرض می‌کند که رد در نهایت انجام می‌شود.

خلاصه

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

  • الگوی کدگذاری را به چیزی ساده‌تر برای پیش‌بینی تغییر دهید، مانند استفاده از توابع async.
  • اگر DevTools در زمانی که باید متوقف نشود، برای شکستن همه استثناها انتخاب کنید.
  • اگر اشکال‌زدا در جایی که شما نمی‌خواهید متوقف می‌شود، از نقطه شکست «هرگز در اینجا مکث نکنید» یا نقطه شکست شرطی استفاده کنید.

قدردانی ها

عمیق ترین قدردانی ما از سوفیا املیانوا و جسلین ین برای کمک ارزشمند آنها در ویرایش این پست است!