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

URIError در جاوااسکریپت دقیقاً چیست؟

URIError یکی از هشت خطای داخلی استاندارد ECMAScript است؛ در کنار Error، TypeError، RangeError، ReferenceError، SyntaxError، EvalError و AggregateError. این خطا از یک خانواده باریک از توابع پرتاب می‌شود: توابعی که کارشان تبدیل بین رشته خام و کدگذاری درصدی است. کدگذاری درصدی همان چیزی است که در نوار آدرس سایت‌ها به شکل %20 یا %D8%A2 می‌بینید و مستندات استاندارد آن در Percent-encoding قابل مرور است.

وقتی صحبت از خطا در جاوااسکریپت می‌شود، معمولاً ذهن به سمت TypeError یا ReferenceError می‌رود؛ یعنی خطاهایی که از اشتباه در منطق کد می‌آیند. اما URIError جنس متفاوتی دارد. این خطا از ورودی می‌آید، نه از کد. همان تابع، با ورودی سالم نتیجه سالم می‌دهد و با ورودی خراب منفجر می‌شود. همین ویژگی، دیباگش را سخت‌تر از چیزی می‌کند که در نگاه اول به نظر می‌رسد؛ چون کد شما بی‌عیب است و فقط داده خراب است.

طبقه‌بندی داخلی URIError ساده است: یک نمونه از Error است که مقدار name آن "URIError" است و پیام پیش‌فرض پیاده‌سازی‌های مدرن — هم در مرورگرها و هم در Node.js — معمولاً "URI malformed" است. این پیام کوتاه، کم‌ترین اطلاعات را به شما می‌دهد، چون خود موتور جاوااسکریپت نمی‌داند که آن % از کجا آمده و چه قصدی داشته. وظیفه پیدا کردن منبع خرابی روی دوش شماست و به همین دلیل لازم است از قبل، جای درست را بشناسید.

URIError از ورودی می‌آید، نه از کد؛ همین است که رفعش را به یک تمرین مهندسی ورودی‌محور تبدیل می‌کند، نه یک تعمیر سطحی تابع.

نکته‌ای که در تجربه دیباگ زیاد به کارم آمده: URIError تنها خطای خانواده URI نیست که ممکن است ببینید. encodeURI و encodeURIComponent هم می‌توانند این خطا را پرتاب کنند، اما به دلیلی کاملاً متفاوت. در ادامه دقیقاً همان تفکیک را باز می‌کنم؛ چون در نیمی از پرونده‌هایی که به من رسیده، تیم فنی فرض کرده بود خطا از decode آمده، در حالی که منبع در encode بوده.

کدام توابع جاوااسکریپت URIError پرتاب می‌کنند؟

خانواده توابع URI در جاوااسکریپت هفت عضو دارد و فقط چهارتای آن‌ها می‌توانند URIError پرتاب کنند. دانستن این مرزبندی، نیمی از عیب‌یابی است:

تابعکارچه زمانی URIError می‌دهد؟
decodeURIرمزگشایی URI در حالی که کاراکترهای رزرو حفظ می‌شوندوجود یک % بدون دو رقم هگز صحیح، یا یک توالی UTF-8 ناقص
decodeURIComponentرمزگشایی یک جزء URIهمانند decodeURI، و به‌علاوه پرمصرف‌ترین منبع این خطا در پروژه‌ها
encodeURIکدگذاری URI بدون دست زدن به کاراکترهای رزروحضور یک surrogate تنها (lone surrogate) در رشته ورودی
encodeURIComponentکدگذاری یک جزء URIهمانند encodeURI
escapeمنسوخ (deprecated)هرگز — ولی هیچ‌وقت به عنوان راه‌حل جایگزین استفاده نشود
unescapeمنسوخ (deprecated)هرگز
String.rawساخت رشته خامربطی به URI ندارد؛ فقط برای مثال در جدول آمده که با آن اشتباه گرفته نشود

تفاوت decodeURI با decodeURIComponent شاید بی‌اهمیت به نظر برسد، ولی در پروژه‌های واقعی فرق آن‌ها یک باگ کامل است. decodeURI کاراکترهای رزرو مانند /، ?، :، @، &، =، +، $، , و # را رمزگشایی نمی‌کند تا معنای ساختاری URI حفظ شود. اما decodeURIComponent همه‌چیز را رمزگشایی می‌کند، چون فرض بر این است که ورودی‌اش صرفاً یک تکه از URI است، نه یک URI کامل. اگر برای یک مقدار پارامتر از decodeURI استفاده کنید، کاراکترهایی مثل %2F (معادل /) رمزگشایی نمی‌شوند و به‌عنوان باقیمانده در متن شما می‌مانند.

از آن مهم‌تر، مکانیزم پرتاب خطا در دو سمت متفاوت است. در decodeURI و decodeURIComponent، این کدگذاری درصدی ناقص است که خطا را فعال می‌کند: % بدون دو رقم هگز، یا توالی بایتی که UTF-8 معتبر نمی‌سازد. اما در encodeURI و encodeURIComponent، علت کاملاً چیز دیگری است: اگر رشته ورودی شامل یک surrogate باشد که جفتش در رشته نیست — مثلاً حاصل بریدن (slice) یک زوج surrogate در وسط — این توابع نمی‌توانند آن را به UTF-8 معتبر تبدیل کنند و خطا می‌دهند.

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

مرز پرتاب URIError را گم نکنید: در decode مقصر percent بدشکل است، در encode مقصر surrogate تنها. این دو مسیر درمان کاملاً متفاوت دارند.

چهار سناریوی واقعی که URIError را فعال می‌کنند

در عمل، تعداد الگوهایی که به URIError منتهی می‌شوند کمتر از تصور عمومی است. چهار الگوی زیر تقریباً همه پرونده‌هایی را که من دیده‌ام پوشش می‌دهند.

سناریو اول: کاراکتر خام % در ورودی کاربر

کاربر در فیلد جستجو یک نماد درصد تایپ می‌کند — مثلاً برای نوشتن «۵۰٪ تخفیف» — و فرم بدون encodeURIComponent مقدار را در querystring می‌گذارد. نوار آدرس تبدیل به ?q=50% می‌شود و به‌محض ورود به کد decodeURIComponent، جاوااسکریپت خطا می‌دهد. همین الگو در فرم‌های جستجوی محصول، جستجوی مقاله و حتی پیام‌های خطای سرور دیده می‌شود. نکته کلیدی این است که منشأ خرابی، خود کاراکتر نیست؛ بلکه نبود encode صحیح در سمت ارسال است.

سناریو دوم: رمزگشایی مضاعف توسط دو لایه سرور و کلاینت

در پروژه‌های بزرگ که یک لایه API در میان است، گاهی سرور مقداری را که خودش یک‌بار رمزگشایی کرده، به شکل خام در پاسخ می‌گذارد و کلاینت آن را دوباره decodeURIComponent می‌کند. اگر مقدار اولیه «%2520» بوده و سرور یک‌بار آن را به «%20» تبدیل کرده باشد، تلاش کلاینت برای رمزگشایی دوباره، آن را به یک % ناقص تبدیل می‌کند که کل خطا را فعال می‌کند. این الگو در میان تیم‌هایی که مالکیت encode/decode بین چند لایه تقسیم شده، بسیار رایج است.

سناریو سوم: توکن‌های base64 که در URL جا می‌گیرند

توکن‌های base64 وقتی در URL قرار می‌گیرند، اگر به‌درستی encode نشوند، کاراکترهای +، / و = را وارد مسیر می‌کنند. در برخی کتابخانه‌های مسیریابی، این ناهمگونی باعث decode مضاعف یا تلاش برای تفسیر نامعتبر می‌شود و در نهایت URIError ظاهر می‌شود. این پرونده‌ها معمولاً روی مسیرهایی رخ می‌دهند که توکن را در پارامتر مسیر می‌خواهند، نه در querystring.

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

اگر روی متن کاربر عملیات slice، substring یا truncate انجام دهید و مرز برش روی وسط یک زوج surrogate بیفتد، رشته حاصل یک surrogate تنها دارد. سپس وقتی این رشته به encodeURIComponent سپرده شود، تابع نمی‌تواند به UTF-8 معتبر تبدیلش کند و URIError می‌دهد. این الگو در امپوجی‌ها، پیام‌های کوتاه‌شده و فیلدهای preview بسیار شایع است و معمولاً در تست دستی دیده نمی‌شود، چون سازندگان متن، همیشه با کاراکترهای ASCII آزمایش می‌کنند.

سناریوها را که کنار هم می‌گذارم، یک الگوی مشترک می‌بینم: در هر چهار مورد، ورودی از یک مرز (boundary) گذشته و در همان مرز، encode یا decode به‌درستی اعمال نشده است. اگر روی همان مرزها تمرکز کنید، خطاهای این خانواده به شکل چشمگیری کم می‌شوند. برای اینکه بفهمید در کدام نقطه از زنجیره این مرز شکسته، لازم است بتوانید خطا را در کنسول مرورگر پیدا و ردیابی کنید؛ روش گام‌به‌گام آن در چگونه خطاهای جاوااسکریپت را در کنسول مرورگر پیدا کنیم توضیح داده شده است.

یک سناریوی پنجم هم وجود دارد که بیشتر در کدهای شبکه‌ای دیده می‌شود: وقتی پاسخ سرور از طریق fetch گرفته می‌شود و پاسخ، شامل querystring شکسته است، تلاش برای رمزگشایی آن به خطا می‌رسد. اگر پروژه‌تان روی fetch کار می‌کند و مکانیزم خطا در همان لایه شکل می‌گیرد، مرور «fetch api در جاوااسکریپت» می‌تواند دید بهتری به شما بدهد که چرا این خطا در لایه انتقال ظاهر می‌شود.

ریشه فنی: رمزگشایی درصدی چرا می‌شکند؟

برای اینکه بتوانید این خطا را بدون حدس و گمان رفع کنید، باید مکانیزم داخلی decodeURIComponent را بلد باشید. این تابع، در حقیقت، یک رمزگشای دقیق است. یعنی ورودی را به‌عنوان یک گرامر رسمی می‌بیند و هر جای این گرامر که نقض شود، به‌جای «سعی می‌کنم درستش کنم»، URIError پرتاب می‌کند.

گرامر پذیرفته‌شده در سه سطح قابل توصیف است:

  1. هر کاراکتر % باید دقیقاً با دو رقم شانزده‌شانزدهی (hex) از مجموعه 0-9A-Fa-f دنبال شود. کم یا زیاد، یا حرف غیر hex، خطا می‌سازد.
  2. پس از تبدیل‌های درصدی، بایت‌های حاصل باید یک توالی UTF-8 معتبر تشکیل بدهند؛ نه یک بایت تنها از یک کاراکتر چندبایتی، و نه یک توالی با بایت اضافی.
  3. در صورت موفقیت، معادل کاراکتر یونیکد به رشته برگردانده می‌شود.

این گرامر سخت‌گیرانه، انتخاب عامدانه طراحان ECMAScript بوده است؛ چون هرگونه حدس‌زنی روی داده خراب، می‌تواند به باگ‌های نامرئی در لایه‌های بالاتر تبدیل شود. اگر decodeURIComponent به‌جای پرتاب خطا، کاراکتر ناقص را نادیده بگیرد، شما نمی‌فهمید که ورودی مشکوک بوده و در ادامه منطق کسب‌وکار، رفتار غلطی شکل می‌گیرد.

مثال‌های زیر تفاوت رفتار را نشان می‌دهند. این‌ها را می‌توانید در کنسول مرورگر امتحان کنید تا درک عملی‌تری پیدا کنید:

decodeURIComponent(`%41`);              // "A"
decodeURIComponent(`%D8%A2%D8%B1%D8%B4`); // "آرش"
decodeURIComponent(`%E2%82%AC`);        // "€"
decodeURIComponent(`%25`);              // "%"

decodeURIComponent(`50%`);              // URIError
decodeURIComponent(`a%2`);              // URIError
decodeURIComponent(`%D8`);              // URIError (توالی ناقص UTF-8)
decodeURIComponent(`%zz`);              // URIError

سه الگوی خطا در این مثال‌ها نمایان است: کمبود یک رقم هگز بعد از %، استفاده از کاراکتر غیر hex بعد از %، و ارسال توالی ناقص UTF-8. این سه الگو، تقریباً همه منابع واقعی URIError در سمت decode هستند.

یک نکته کاربردی: در نسخه‌های جدید ECMAScript، امضای خطا در بعضی موتورها پیام‌های دقیق‌تری دارد؛ اما تا زمان نگارش این متن، پیام استاندارد تقریباً همیشه همان "URI malformed" است و اطلاعات بیشتری از خود موتور قابل استخراج نیست. بنابراین تکیه بر پیام خطا برای پیدا کردن ورودی مقصر، کافی نیست.

در مقابل، همیشه یک منبع گمراه‌کننده در ذهن تیم‌ها وجود دارد: خطاهای مرتبط با JSON. چون ورودی‌های شکسته در نوار آدرس اغلب از یک payload JSON خراب آمده‌اند و تشخیص اینکه خطا از URI آمده یا از JSON، نیازمند دو ابزار جداگانه است. اگر شک دارید، مرور خطای هم‌خانواده دیگر در خطای Unexpected token in JSON در جاوااسکریپت می‌تواند کمک کند مرز این دو خطا را تفکیک کنید.

عدم حدس‌زنی در decodeURIComponent یک باگ نیست، یک انتخاب عامدانه است؛ پس راه‌حل هم باید در سمت ورودی باشد، نه در سمت تابع.

چطور خطای فعلی را در پروژه ایزوله کنیم؟

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

گام اول: نگاه به stack trace

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

گام دوم: ثبت ورودی قبل از decode

در همان لحظه، پیش از فراخوانی تابع مشکوک، مقدار ورودی را با console.log یا console.warn ثبت می‌کنم، ولی با یک شرط مهم: خروجی را بدون پاک‌سازی نگه می‌دارم. یعنی همان رشته خام با کاراکترهای ناخواسته. بسیاری از تیم‌ها عادت دارند رشته را قبل از لاگ کردن trim یا sanitize کنند و همین کار باعث می‌شود منبع خرابی گم شود.

گام سوم: بازتولید در کنسول

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

گام چهارم: ردیابی منبع بالا دست

بعد از اینکه مشخص شد کدام کاراکتر در ورودی مشکل دارد، باید بفهمم آن کاراکتر از کجا آمده. سه مسیر معمول وجود دارد: کاربر، سرور، یا کد خودتان. برای تشخیص، یک نقطه شکست (breakpoint) در همان تابع می‌گذارم و مقدار ورودی را در چند مرحله قبل‌تر هم بررسی می‌کنم. اگر ورودی در مرحله اول مشکوک بوده و در مرحله دوم خراب‌تر شده، احتمالاً یک encode/decode مضاعف رخ داده است.

گام پنجم: تشخیص مسیر decode مضاعف

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

در نهایت، اگر به خطای خانواده نزدیکی برخوردید که از جنس متفاوتی است — مثلاً فراخوانی یک چیز ناموجود — ممکن است منبع واقعی جای دیگری باشد. مثلاً «خطای is not a function در جاوااسکریپت» گاهی در زنجیره‌ای که خطای URI را تولید می‌کند ظاهر می‌شود و باعث می‌شود منبع اصلی را گم کنید.

الگوهای امن decode کردن در جاوااسکریپت مدرن

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

الگوی اول: try/catch محافظ

ساده‌ترین و در بسیاری از پروژه‌ها کافی‌ترین راه‌حل، پوشاندن decode در یک try/catch است که در صورت شکست، مقدار خام را برگرداند. این کار هم برنامه را از سقوط نجات می‌دهد و هم در بعضی سناریوها — که مقدار خام قابل استفاده است — بازدهی بیشتری می‌دهد:

function safeDecodeComponent(value) {
  if (typeof value !== "string") return value;
  try {
    return decodeURIComponent(value);
  } catch (error) {
    if (error instanceof URIError) {
      return value;
    }
    throw error;
  }
}

نکته ظریف این الگو، محدود ماندن catch به URIError است. اگر catch را باز بگذارید، هر خطای دیگری را هم بی‌سروصدا بلعیده‌اید و این خودش تبدیل به یک باگ بزرگ‌تر می‌شود.

الگوی دوم: پاک‌سازی پیش از decode

اگر می‌خواهید درصدهای ناقص بدون حذف شدن، به درصد صحیح تبدیل شوند، می‌توانید پیش از فراخوانی تابع، هر % بدون دو رقم هگز را به %25 (معادل خود %) تبدیل کنید:

function lenientDecode(value) {
  return decodeURIComponent(
    value.replace(/%(?![0-9A-Fa-f]{2})/g, "%25")
  );
}

این الگو داده را حفظ می‌کند، ولی یک تذکر مهم دارد: هرگز نباید روی داده‌ای که بعداً برای تصمیم‌های امنیتی استفاده می‌شود — مثل مسیر فایل، ریدایرکت یا مجوز — اعمال شود. در آن بافت‌ها، باید الگوی زیر را ترجیح دهید.

الگوی سوم: استفاده از URLSearchParams

URLSearchParams از استاندارد WHATWG URL پیروی می‌کند و برخلاف decodeURIComponent، در برابر درصدهای ناقص خطا نمی‌دهد؛ آن‌ها را دست‌نخورده باقی می‌گذارد. این ویژگی آن را برای پارس کردن querystring بسیار امن‌تر می‌کند:

const params = new URLSearchParams(window.location.search);
const q = params.get("q"); // بدون پرتاب خطا حتی با % ناقص

توجه کنید که URLSearchParams علامت + را به فاصله تبدیل می‌کند، چون بر پایه فرمت application/x-www-form-urlencoded ساخته شده است. این تفاوت را در تصمیم‌گیری لحاظ کنید.

الگوی چهارم: استفاده از URL object

اگر به کل آدرس نیاز دارید، URL و URLSearchParams را روی هم بگذارید. URL خودش درصدهای نامعتبر را در حد استاندارد نرمال می‌کند و معمولاً خطا نمی‌دهد، مگر آنکه آدرس پایه نامعتبر باشد:

const url = new URL(window.location.href);
const raw = url.searchParams.get("next");

الگوی پنجم: پارسر مخصوص فریم‌ورک

هر فریم‌ورک یا کتابخانه مسیریابی، پارسر خودش را دارد: در Express معمولاً req.query با کتابخانه qs پارس می‌شود، در Next.js مسیریاب از URLSearchParams استفاده می‌کند و در React Router پارامترها به‌صورت خودکار رمزگشایی می‌شوند. اگر از پارسر خود فریم‌ورک استفاده کنید، بار مدیریت خطای low-level را از دوش خودتان برمی‌دارید.

الگومزیتهزینه
try/catch محافظساده و بدون وابستگینیازمند توجه به نوع خطا
پاک‌سازی پیش از decodeحفظ داده اصلینامناسب برای تصمیم‌های امنیتی
URLSearchParamsمطابق استاندارد، بدون پرتابرفتار خاص با +
URL objectمدیریت کل آدرسنیازمند آدرس کامل
پارسر فریم‌ورککاهش کد دستیوابستگی به رفتار کتابخانه

در کنار انتخاب یکی از این الگوها، به الگوی کلی مدیریت خطا هم توجه کنید؛ چون نحو try/catch فقط یکی از ابزارهای در دسترس است. مرور دقیق‌تر «مدیریت خطا در جاوااسکریپت» به شما کمک می‌کند تا بین «بلعیدن خطا» و «مدیریت خطا» تفاوت بگذارید.

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

سمت encode: چطور داده خراب تولید نکنیم

نیمی از URIErrorهایی که در پروژه‌ها می‌بینم، ریشه‌شان در سمت encode است، نه decode. اگر این سمت را درست ببندید، بخش بزرگی از خطاها هرگز به وجود نمی‌آید.

قانون اول: همیشه encodeURIComponent برای مقادیر

هر مقداری که قرار است در querystring یا پارامتر مسیر قرار بگیرد، باید از encodeURIComponent بگذرد. برای مثال:

const q = "50% off";
const url = `/search?q=${encodeURIComponent(q)}`;

این کار عملاً درصد خام را به %25 تبدیل می‌کند و از بروز خطا در سمت مقابل جلوگیری می‌کند.

قانون دوم: ساخت querystring با URLSearchParams

به‌جای دستی ساختن رشته، از URLSearchParams استفاده کنید. این API خودش مسئولیت encode کردن را برمی‌دارد و از باگ‌های فراموش‌کاری جلوگیری می‌کند:

const params = new URLSearchParams();
params.append("q", "50% off");
params.append("page", "2");
const url = `/search?${params.toString()}`;

قانون سوم: از encodeURIComponent روی object پرهیز کنید

اشتباه رایجی که در بازبینی کد زیاد می‌بینم: سپردن یک object به encodeURIComponent. جاوااسکریپت object را به [object Object] تبدیل می‌کند و بعد آن را encode می‌کند. نتیجه نه URIError می‌دهد و نه کار درست انجام می‌دهد؛ فقط یک داده بی‌معنا تولید می‌کند که به سرور می‌رود. این کلاس باگ‌ها به همین دلیل خطرناک است: بی‌سروصدا خراب می‌کند.

قانون چهارم: مراقب slice روی رشته‌های حاوی امپوجی باشید

اگر روی متن کاربر slice یا substring اعمال می‌کنید، از متدهای آگاه به کدپوینت مثل Array.from یا Intl.Segmenter استفاده کنید. این تفاوت به ظاهر کوچک، از تولید surrogateهای تنها که encodeURIComponent را می‌شکنند جلوگیری می‌کند. اگر در حال کار با ساختارهای مدرن‌تر زبان هستید، مرور «آموزش es6 در جاوااسکریپت» می‌تواند به یادآوری این ابزارها کمک کند.

یک تذکر پایانی برای سمت encode: هرگز کاراکترهای Unicode را به‌عنوان «قبلاً درست است» از کنار encode رد نکنید. حتی در سایت‌هایی که همه متن‌ها فارسی‌اند، ورودی کاربر می‌تواند شامل کاراکترهای عربی، امپوجی و نمادهای ریاضی باشد که همه آن‌ها به encode شدن نیاز دارند.

اشتباهاتی که این خطا را تشدید می‌کنند

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

  • استفاده از unescape به‌عنوان راه‌حل جایگزین. unescape منسوخ است و کاراکترهای غیر ASCII را به‌شکل متفاوتی از decodeURIComponent تفسیر می‌کند. نتیجه، داده‌ای است که «کار می‌کند» ولی با استاندارد فرق دارد.
  • catch بی‌قید و شرط روی همه خطاها. وقتی شما catch را باز می‌گذارید، خطاهای واقعی برنامه را هم بلعیده‌اید و باگ در جای دیگری ظاهر می‌شود.
  • لاگ کردن مقدار decode شده بدون ذخیره مقدار خام. در بیشتر پرونده‌ها، تیم فنی فقط مقدار decode شده را لاگ می‌کند و بعد که خطا می‌آید، ورودی اصلی گم شده است. همیشه ورودی خام و خروجی موفق را در یک ساختار کنار هم نگه دارید.
  • دستکاری URL بدون توجه به لایه‌های میانی. وقتی یک نوار آدرس از سه لایه عبور می‌کند (روتر، سرویس، کامپوننت)، ممکن است هر لایه یک‌بار decode کند و decode مضاعف شکل بگیرد.
  • فرض اینکه چون در تست دستی خطا نیامد، در تولید هم نمی‌آید. ورودی کاربر واقعی معمولاً شامل کاراکترهایی است که در تست شما نبوده — از جمله %، +، # و امپوجی‌های چسبیده.

در کنار این پنج اشتباه، یکی از هم‌خانواده‌های نزدیک این خطا هم عادت دارد به‌جای خودش را نشان دهد و منبع را اشتباه جلوه بدهد. اگر در لاگ‌های خود الگوی undefined یا null کنار URIError دیدید، مرور «خطای Cannot read property of undefined» می‌تواند به تفکیک کمک کند؛ چون این دو خطا اغلب در یک زنجیره مشترک ظاهر می‌شوند.

در مواجهه با URIError، فهرست چیزهایی که «نباید انجام دهید» به همان اندازه فهرست کارهای درست اهمیت دارد.

URIError و پیامدهای امنیتی نامرئی

این بخش را جدی بگیرید. خطاهایی که با ورودی خراب URL سروکار دارند، تنها یک مسئله کیفیت کد نیستند؛ در بعضی سناریوها، همان ورودی خراب می‌تواند پل ورود به حمله باشد.

حمله decode مضاعف

اگر برنامه شما به‌شکل ناخواسته ورودی را دو بار decode کند، مسیرهای امنیتی می‌توانند دور زده شوند. مثلاً درخواستی با %252e%252e%252f اگر دو بار decode شود، به ../ تبدیل می‌شود و مسیر پیمایش (path traversal) شکل می‌گیرد. این حمله وقتی مؤثر است که یکی از لایه‌های میانی بیش از حد مهربان باشد.

ریدایرکت باز

اگر ?next= را از کاربر بگیرید و بعد از decode، بدون اعتبارسنجی به location.href بدهید، یک ورودی می‌تواند کاربر را به دامنه مخرب هدایت کند. نکته ظریف اینجاست که حمله‌کننده از خود URIError استفاده نمی‌کند، بلکه از نبود خطا در مسیر امنیتی استفاده می‌کند.

تزریق از طریق داده رمزگشایی‌شده

داده‌ای که از URL می‌آید و رمزگشایی می‌شود، تا زمانی که اعتبارسنجی نشده، قابل اعتماد نیست. اگر آن داده به innerHTML، دستور SQL یا مسیر فایل برود، می‌تواند کانال تزریق شود. قاعده ثابت من: هرچه از URL می‌آید، پیش از استفاده، یا از یک لیست مجاز عبور کند یا از طریق مکانیزم امن (مثل textContent) به DOM برود.

در طراحی خطوط لوله پردازش داده، این نگاه تفاوت بین «کد کار می‌کند» و «کد در برابر ورودی خصمانه مقاوم است» را می‌سازد. برای درک اینکه چرا مدیریت درست جریان داده اهمیت دارد، مرور «Promise در جاوااسکریپت» در بافت زنجیره‌های async می‌تواند زاویه دید مفیدی بدهد؛ چون در زنجیره‌های طولانی، یک قطعه داده که در نقطه‌ای decode شده، می‌تواند چند لایه بعد تبدیل به تزریق شود.

تست کردن رفتار decode با ماتریس ورودی

چیزی که در پروژه‌ها کم می‌بینم و در عوض تأثیرش را زیاد می‌بینم، تست واحد برای همین مرزهای رمزگشایی است. ماتریس زیر همان چیزی است که من در هر پروژه‌ای که ورودی URL دارد، در تست‌ها می‌گنجانم:

ورودیخروجی مورد انتظارنوع سناریو
"hello""hello"ASCII ساده
"%41""A"تک‌بایت hex
"%D8%A2%D8%B1%D8%B4""آرش"چندبایت UTF-8
"100%"URIErrorدرصد خام انتهای رشته
"%2"URIErrorدرصد با یک رقم
"%zz"URIErrorدرصد با کاراکتر غیر hex
"%D8"URIErrorتوالی ناقص
"%25""%"درصد encode‌شده
"a+b""a+b"علامت + در decodeURIComponent
"a+b" در URLSearchParams"a b"علامت + در query parser
surrogate تنها از String.fromCharCode(0xD800)URIError روی encodeسمت encode

یک نکته در مورد تست‌های async: اگر decode درون یک زنجیره Promise یا async/await انجام می‌شود، مطمئن شوید خطا در همان لایه‌ای که انتظار دارید گرفته می‌شود. این تفاوت رفتار، خودش یک منبع کلاسیک باگ در پروژه‌های مدرن است؛ برای درک دقیق‌تر مسیر، مرور «async و await در جاوااسکریپت» می‌تواند به شما کمک کند.

علاوه بر تست‌های واحد، پیشنهاد می‌کنم روی هر تابعی که با URL کار می‌کند، یک تست کوچک fuzz بگذارید؛ یعنی ورودی تصادفی از کاراکترهای %، +، #، ? و کاراکترهای چندبایتی بسازید و مطمئن شوید برنامه سقوط نمی‌کند. همین تمرین کوچک، در چند پروژه کلاس کاملی از باگ‌ها را قبل از انتشار گرفته است.

پرسش‌های پرتکرار درباره خطای URIError

این بخش به سؤالاتی می‌پردازد که بیشترین تکرار را در پروژه‌ها، تیکت‌های پشتیبانی و پرسش‌های تیم‌های فنی داشته‌اند. هدف این است که بتوانید به‌سرعت پاسخ دقیق را پیدا کنید، بدون اینکه نیاز باشد مسیر کامل مقاله را دوباره از ابتدا بخوانید.

چرا decodeURIComponent روی ورودی سالم خطا نمی‌دهد ولی روی ورودی مشابه خطا می‌دهد؟

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

آیا decodeURI و decodeURIComponent رفتار یکسانی دارند؟

خیر. decodeURI کاراکترهای رزرو را رمزگشایی نمی‌کند تا ساختار URI حفظ شود، در حالی که decodeURIComponent این محدودیت را ندارد. اگر به‌جای هم استفاده شوند، نتیجه، داده‌ای است که به‌ظاهر سالم است ولی با انتظار شما فرق دارد.

آیا URIError در همه مرورگرها یکسان است؟

مکانیزم پایه یکسان است، ولی پیام‌ها و جزئیات ممکن است در مرورگرها و Node.js تفاوت داشته باشند. به همین دلیل، هیچ‌وقت روی پیام خطا به‌عنوان شناسه تکیه نکنید؛ روی instanceof URIError و رفتار واقعی تابع تکیه کنید.

آیا استفاده از try/catch کافی است؟

به‌عنوان گام اول، بله؛ ولی به‌عنوان راه‌حل نهایی، خیر. catch فقط جلوی سقوط را می‌گیرد؛ منبع ورودی خراب باید در همان مرز تولید، مسدود شود. برای پروژه‌های جدی، ترکیب try/catch با اعتبارسنجی ورودی و پارسرهای استاندارد مثل URLSearchParams، نقطه تعادل خوبی است.

چرا این خطا در محیط توسعه نمی‌آید ولی در تولید ظاهر می‌شود؟

به احتمال زیاد در تست شما ورودی‌ها از یک مجموعه محدود آمده‌اند و در تولید، کاربران واقعی کاراکترهایی مثل %، + یا امپوجی وارد می‌کنند. اضافه کردن تست fuzz و تست با کاراکترهای غیر ASCII به مجموعه تست‌های شما، این تفاوت را از بین می‌برد.

آیا خطا در fetch یا در XMLHttpRequest هم می‌تواند رخ بدهد؟

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

آیا می‌توان با unescape جایگزین کرد؟

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

چطور بفهمم مشکل از encode است یا از decode؟

یک علامت خوب این است: اگر در ورودی تعداد %ها دو برابر چیزی است که انتظار دارید، احتمالاً یک لایه اضافی encode کرده و لایه بعدی سعی می‌کند آن را decode کند. اگر ورودی حاوی surrogate تنهاست، منبع در سمت encode است، نه decode.

اگر روی خطای هم‌خانواده دیگری گیر کرده‌اید که با ورودی عددی یا طول رشته سروکار دارد، مرور «خطای RangeError در جاوااسکریپت» می‌تواند در تفکیک این دو دسته خطا کمک کند.

معماران وب چطور این کلاس خطا را حذف می‌کنند

در پروژه‌های بالغ، URIError به‌ندرت به‌عنوان یک اتفاق تصادفی ظاهر می‌شود؛ چون تیم‌ها با چند تصمیم معماری، احتمال وقوع آن را به‌شکل چشمگیری پایین می‌آورند. این تصمیم‌ها را در سه لایه می‌بینم.

لایه اول: مرزهای صریح داده

تیم‌های حرفه‌ای ورودی‌های خود را در همان مرز (مرز شبکه، مرز مسیریاب، مرز کامپوننت) اعتبارسنجی می‌کنند. یعنی وقتی داده از URL وارد سیستم می‌شود، همان‌جا یک بار و فقط یک بار decode می‌شود و از آن به بعد، داده به‌عنوان داده رمزگشایی‌شده علامت‌گذاری می‌شود. این رویکرد، decode مضاعف را عملاً غیرممکن می‌کند.

لایه دوم: تایپ‌های متمایز برای داده خام و رمزگشایی‌شده

در پروژه‌های TypeScript یا JSDoc، می‌توانید دو تایپ جداگانه تعریف کنید: یکی برای RawQueryString و یکی برای DecodedQueryString. این تمایز باعث می‌شود یک تابع نتواند به‌طور تصادفی یک داده رمزگشایی‌شده را دوباره به تابع decode بدهد. تجربه من این است که این تغییر کوچک، در طول یک سال، تعداد خطاهای خانواده URI را به‌شکل محسوسی کاهش می‌دهد.

لایه سوم: اعتبارسنجی پیش از مصرف

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

در کنار این سه لایه، یک قاعده رفتاری هم هست که در تیم‌های خوب می‌بینم: هر بار که یک کلاس خطای خاص در تولید ظاهر می‌شود، به‌جای «رفع سریع»، یک بازبینی کوچک روی مسیرهایی که به آن خطا منتهی شدند انجام می‌شود. این عادت، در طول چند ماه، خطاهای تکراری را از سیستم حذف می‌کند، نه اینکه صرفاً بپوشاند.

حذف کامل یک کلاس خطا از سیستم، نتیجه یک تصمیم معماری است، نه یک توصیه تک‌خطی؛ هرچه مرزها واضح‌تر باشند، خطاها نادرتر می‌شوند.

یک عادت کوچک، یک کلاس خطای از‌یادرفته

URIError در نگاه اول یک خطای کوچک به‌نظر می‌رسد، اما در عمل، آینه‌ای است که نشان می‌دهد ورودی‌های برنامه‌تان چقدر کنترل‌شده هستند. اگر این خطا در تولید ظاهر می‌شود، به احتمال زیاد جای دیگری از سیستم هم ورودی‌های کنترل‌نشده دارد. به همین دلیل، توصیه عملی من سه چیز است: اول، در هر مرز داده یک‌بار و فقط یک‌بار decode کنید؛ دوم، برای پارامترهای URL از URLSearchParams یا پارسر فریم‌ورک استفاده کنید، نه از decode دستی؛ سوم، روی توابعی که با URL سروکار دارند، تست fuzz و تست واحد بگذارید.

اگر خطای مشابهی را در یک پروژه واقعی تجربه کرده‌اید — مخصوصاً جایی که منبع اصلی از انتظار شما دور بوده — برای من جالب است بدانم کدام لایه مقصر بود. تجربه‌تان را در دیدگاه‌ها بنویسید؛ به‌خصوص اگر الگوی دیگری برای رفع یا پیشگیری پیدا کرده‌اید که می‌تواند برای خواننده بعدی همین متن، مسیر را کوتاه‌تر کند. 🧭