توسعه فرانت‌اند

JavaScript Proxy و Reflect: متاپروگرامینگ

رفتار get و set، حفظ receiver با Reflect، محدودیت validation و invariant، revocable و نمونه کامل Node برای مشاهده مرزهای Proxy.

1404/12/21 3 دقیقه مطالعه 1,352 بازدید

وقتی عملیات روی object نیاز به رفتار اضافه دارد

فرض کنید می‌خواهید هنگام تغییر سن در یک object، مقدار نامعتبر رد شود. می‌توان تابع setter مشخص نوشت یا رفتار عمومی assignment را با Proxy مشاهده کرد. Proxy یک object واسط است که برخی عملیات را از طریق handler دریافت می‌کند؛ مثل خواندن property، نوشتن آن یا حذف. این امکان برای adapter، مشاهدهٔ تغییر و ابزارهای خاص مفید است. اما پیچیده کردن همهٔ objectهای برنامه با trap، مسیر خواندن کد را پنهان می‌کند. ابتدا روشن کنید چه عملیاتی واقعاً نیاز به واسطه دارد.

Reflect مجموعه‌ای از methodها برای انجام عملیات معمول object است. در یک trap می‌توان بررسی اضافه انجام داد و سپس عملیات استاندارد را به Reflect سپرد. Reflect ابزار ساخت Proxy نیست و بدون Proxy هم کاربرد دارد. به جای عنوان قدرتمندترین ابزار، بهتر است این تقسیم مسئولیت را بشناسیم: Proxy فرصت دخالت می‌دهد و Reflect راه انجام عملیات پایه را فراهم می‌کند. مرجع Proxy ساخت target و handler و محدودیت‌های آن را توضیح می‌دهد.

Target و proxy دو reference متفاوت‌اند

ساختن Proxy از یک object، خود object اصلی را به شکل جادویی تغییر نمی‌دهد. عملیات روی reference اصلی ممکن است از trap عبور نکند. اگر قرار است مصرف‌کنندگان از واسطه استفاده کنند، باید reference مناسب را به آن‌ها بدهید. این تفاوت در سیستم مشاهدهٔ داده اهمیت دارد: تغییر از مسیر اصلی می‌تواند notification مورد انتظار را ایجاد نکند. همچنین proxy و target از نظر identity یکی نیستند؛ مقایسهٔ مستقیم آن‌ها با برابری سخت، همان نتیجهٔ مقایسهٔ یک reference با خودش را نمی‌دهد.

این موضوع دلیل خوبی برای تعیین مرز API است. اگر object خام از چند مسیر بیرون داده شود، نمی‌توان انتظار داشت همهٔ تغییرها از یک handler دیده شوند. اگر فقط به validation چند ورودی نیاز دارید، یک تابع صریح ممکن است ساده‌تر باشد. Proxy برای ساخت قراردادی عمومی‌تر مناسب است، ولی آن قرارداد باید به خوانندهٔ کد معرفی شود. مخفی کردن رفتار assignment می‌تواند debug را دشوار کند، مخصوصاً وقتی یک تغییر ظاهراً ساده چند اثر جانبی دارد.

Get، set و receiver

Trap خواندن، target، نام property و receiver را دریافت می‌کند. Receiver در اجرای getter اهمیت دارد، چون this می‌تواند به object دریافت‌کننده مربوط باشد. نوشتن target[key] در هر get ممکن است همین معنا را عوض کند. Reflect.get با دریافت receiver امکان حفظ رفتار مناسب را فراهم می‌کند. مستندات Reflect.get نقش پارامتر سوم را نشان می‌دهد. در نمونهٔ فرزند، getter باید نام فرزند را بخواند، نه همیشه نام object پایه را.

Trap نوشتن باید نتیجهٔ موفقیت عملیات را با قرارداد Boolean برگرداند. اگر نوشتن مجاز نیست، بازگرداندن false در strict mode می‌تواند TypeError ایجاد کند. Reflect.set نتیجهٔ عملیات پایه را برمی‌گرداند؛ بنابراین بهتر است آن را بدون دلیل به true تبدیل نکنیم. در مثال، ورودی نامعتبر با خطای مشخص رد می‌شود و ورودی معتبر به Reflect سپرده می‌شود. مرجع handler.set این قرارداد و محدودیت‌های نوشتن را توضیح می‌دهد.

نمونهٔ کامل برای Node

کد زیر را با نام proxy-demo.mjs ذخیره کنید. Node 24 یا نسخهٔ سازگار جدیدتر لازم است و وابستگی خارجی ندارد. فرمان node proxy-demo.mjs آن را اجرا می‌کند. نمونه command-line است و به HTML یا CSS نیاز ندارد. پیام‌ها عمداً رفتارهای مختلف را جدا نشان می‌دهند: assignment معتبر، ورودی ردشده، تغییر از reference خام، تغییر descriptor، receiver یک getter، invariant و revoke. این مثال آموزشی security wrapper کامل نیست؛ دو مسیر عبور از validation را عمداً نشان می‌دهد.

const target = { name: 'سارا', age: 24 };
const person = new Proxy(target, {
  get(object, key, receiver) {
    return Reflect.get(object, key, receiver);
  },
  set(object, key, value, receiver) {
    if (key === 'age' && (!Number.isInteger(value) || value < 0 || value > 130)) {
      throw new RangeError('Age must be an integer from 0 to 130');
    }
    if (key === 'name' && (typeof value !== 'string' || !value.trim())) {
      throw new TypeError('Name must be a nonempty string');
    }
    return Reflect.set(object, key, value, receiver);
  },
});
person.age = 25;
console.log('Valid change:', person.age);
try { person.age = -1; } catch (error) { console.log('Rejected:', error.name); }

// A raw reference bypasses this assignment handler.
target.age = -10;
console.log('Raw target bypass:', person.age);
target.age = 25;

// defineProperty is a separate operation from assignment.
Reflect.defineProperty(person, 'age', { value: 200, writable: true, enumerable: true, configurable: true });
console.log('Descriptor bypass:', person.age);
target.age = 25;

const base = {
  first: 'سارا',
  get greeting() { return `سلام ${this.first}`; },
};
const traced = new Proxy(base, { get: (object, key, receiver) => Reflect.get(object, key, receiver) });
const child = Object.create(traced);
child.first = 'نیما';
console.log('Getter receiver:', child.greeting);

const fixed = {};
Object.defineProperty(fixed, 'id', { value: 7, writable: false, configurable: false });
const invalid = new Proxy(fixed, { get: () => 99 });
try { console.log(invalid.id); } catch (error) { console.log('Invariant:', error.name); }

const { proxy: temporary, revoke } = Proxy.revocable({ message: 'دسترسی موقت' }, {});
console.log(temporary.message);
revoke();
try { console.log(temporary.message); } catch (error) { console.log('Revoked:', error.name); }

پسوند mjs اجرای module و strict mode را روشن می‌کند. مقدار سن در بازهٔ فرضی صفر تا ۱۳۰ پذیرفته می‌شود و نام باید رشتهٔ غیرخالی باشد. این قرارداد آموزشی است و ادعای سیاست معتبر همهٔ سامانه‌ها ندارد. تغییر مستقیم target به مقدار منفی، trap assignment را دور می‌زند. سپس Reflect.defineProperty نیز عملیات دیگری انجام می‌دهد و validation موجود را فراخوانی نمی‌کند. بعد از هر نمایش، مقدار دوباره به نمونهٔ معتبر برمی‌گردد تا بخش بعد مستقل‌تر خوانده شود.

کد آخر یک proxy قابل revoke می‌سازد، مقدار آن را می‌خواند و پس از revoke خطای دسترسی را catch می‌کند. این فایل روش اجرا را ارائه می‌دهد و گزارشی از اجرای تست یا خروجی اندازه‌گیری‌شده ندارد. نام متغیرها و ترتیب پیام‌ها کمک می‌کند هنگام اجرای محلی، هر رفتار به بخش مشخص مرتبط شود. اگر یک handler را تغییر می‌دهید، اثر آن بر بخش‌های دیگر را هم بخوانید؛ افزودن trap ممکن است چند عملیات را که قبلاً به target می‌رسیدند تغییر دهد.

Invariantها مرز دخالت را تعیین می‌کنند

Proxy اجازه ندارد هر پاسخی را برای هر property جعل کند. برای property داده‌ای که non-configurable و non-writable است، get باید مقدار واقعی را رعایت کند. در نمونه، id با مقدار ۷ ثابت شده و trap نادرست ۹۹ می‌دهد؛ دسترسی موجب خطا می‌شود. این رفتار نشان می‌دهد handler بالاتر از قواعد object قرار نگرفته است. مرجع handler.get invariantهای خواندن را مشخص می‌کند.

عملیات‌های دیگر مثل ownKeys، deleteProperty و defineProperty هم محدودیت‌های مخصوص دارند. مخفی کردن یک کلید non-configurable از فهرست کلیدها می‌تواند قرارداد را نقض کند. اگر target غیرقابل گسترش شد، handler نمی‌تواند بدون توجه به آن وجود property تازه را اعلام کند. پیش از افزودن trap، مرجع همان operation را بخوانید. پوشاندن خطا با try/catch بدون شناخت علت، API را درست نمی‌کند و ممکن است نتیجهٔ اشتباه را به بخش بعدی بدهد.

Validation با یک trap کامل نمی‌شود

نمونه سن را هنگام assignment بررسی می‌کند، اما object خام و عملیات descriptor مسیرهای دیگرند. Object تو در تو هم خودکار proxy نمی‌شود؛ person.address.city می‌تواند روی object دیگری عمل کند. اگر هدف validation ورودی API است، باید داده در مرز دریافت معتبر بررسی شود. اگر هدف مدل دامنه است، API صریح و محدود می‌تواند قابل فهم‌تر باشد. Proxy ابزار کمک به قرارداد است و جای تعیین تمام مسیرهای تغییر داده را نمی‌گیرد.

استفاده از Proxy برای پنهان کردن رمز یا اطلاعات حساس هم مرز امنیتی قابل اتکایی نمی‌سازد. هر کد دارای reference خام یا دسترسی به context مناسب ممکن است مسیر دیگری داشته باشد. همان‌طور که Module Federation کد مستقل را خودکار sandbox نمی‌کند، proxy هم isolation امنیتی عمومی فراهم نمی‌کند. برای کنترل دسترسی، باید معماری و مرز اجرایی مناسب مشخص باشد؛ صرف تغییر رفتار خواندن یک property کافی نیست.

همهٔ objectها با wrapper ساده سازگار نیستند

Objectهایی که از internal slot یا private field استفاده می‌کنند، ممکن است با receiver واسط رفتار متفاوتی داشته باشند. Map یا یک instance کلاس دارای field خصوصی را نباید بدون بررسی مثل object معمولی wrap کرد. Method ممکن است this مخصوص target بخواهد. Binding همهٔ methodها به target هم تصمیم بی‌اثر نیست؛ هویت تابع و انتظار consumer می‌تواند تغییر کند. بخش مربوط به forwarding در مرجع Proxy این محدودیت را توضیح داده است و هر adapter باید با نوع target طراحی شود.

گاهی یک wrapper صریح که فقط چند method را ارائه می‌دهد، بهتر از proxy عمومی است. مثلاً adapter ذخیره‌سازی می‌تواند getItem و setItem مشخص داشته باشد و validation را در همان‌ها انجام دهد. خواننده فوراً محل رفتار اضافه را می‌بیند. Proxy زمانی ارزش بیشتری دارد که عملیات عمومی property جزئی از قرارداد مورد نیاز باشد، مانند مشاهدهٔ دسترسی در یک ابزار خاص. کوتاه‌تر شدن کد مصرف‌کننده نباید به قیمت نامشخص شدن رفتار پایه تمام شود.

Revoke دسترسی واسطه را قطع می‌کند

Proxy.revocable یک proxy و تابع revoke می‌دهد. پس از revoke، عملیات مربوط روی آن proxy خطا می‌دهد. این کار برای پایان عمر یک واسطه مفید است، اما target اصلی را نابود یا همهٔ referenceهای بیرون را باطل نمی‌کند. مستندات revocable رفتار آن را شرح می‌دهد. در API واقعی باید مصرف‌کننده بداند دسترسی چه زمانی تمام می‌شود و شکست پس از پایان چگونه مدیریت خواهد شد.

Reflect مسیر عادی را قابل مشاهده نگه می‌دارد

Reflect تنها برای get و set نیست؛ methodهایی برای بررسی، حذف، تعریف property و عملیات دیگر دارد. بعضی نتیجه‌ها Boolean هستند و بعضی عملیات همچنان می‌توانند خطا بدهند؛ نباید همهٔ آن‌ها را بی‌خطا فرض کرد. مرجع Reflect مجموعهٔ methodها را معرفی می‌کند. استفادهٔ درست در handler معمولاً یعنی فقط رفتار اضافهٔ لازم را بنویسید و باقی قرارداد را به عملیات استاندارد بسپارید.

اگر proxy برای state واکنش‌پذیر استفاده می‌شود، tracking وابستگی، زمان notification و پاک‌سازی subscription مسئولیت‌های جدا هستند. نام Proxy به تنهایی یک سیستم state کامل نمی‌سازد. نوشتهٔ Svelte و واکنش‌پذیری و مقالهٔ Zustand و مالکیت state این تصمیم‌ها را در سطح رابط بررسی می‌کنند. برای کاربرد بعدی، ابتدا operation مورد نیاز، رفتار پایه و پایان عمر واسطه را بنویسید؛ سپس معلوم می‌شود Proxy از API صریح مناسب‌تر است یا نه.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!