ไทย

คู่มือฉบับสมบูรณ์สำหรับนักพัฒนาทั่วโลกเพื่อการเรียนรู้ JavaScript Proxy API อย่างเชี่ยวชาญ เรียนรู้วิธีดักจับและปรับแต่งการทำงานของอ็อบเจกต์ พร้อมตัวอย่างที่ใช้งานได้จริง กรณีศึกษา และเคล็ดลับด้านประสิทธิภาพ

JavaScript Proxy API: เจาะลึกการปรับเปลี่ยนพฤติกรรมของอ็อบเจกต์

ในภูมิทัศน์ที่เปลี่ยนแปลงอยู่เสมอของ JavaScript สมัยใหม่ นักพัฒนาต่างมองหาวิธีการที่ทรงพลังและสวยงามยิ่งขึ้นในการจัดการและโต้ตอบกับข้อมูลอยู่เสมอ แม้ว่าฟีเจอร์อย่าง classes, modules และ async/await จะปฏิวัติวิธีการเขียนโค้ดของเราไปแล้ว แต่ก็ยังมีฟีเจอร์ metaprogramming อันทรงพลังที่เปิดตัวใน ECMAScript 2015 (ES6) ซึ่งมักถูกใช้งานน้อยกว่าที่ควรจะเป็น นั่นคือ Proxy API

Metaprogramming อาจฟังดูน่ากลัว แต่มันเป็นเพียงแนวคิดของการเขียนโค้ดที่ทำงานกับโค้ดอื่น ๆ Proxy API เป็นเครื่องมือหลักของ JavaScript สำหรับสิ่งนี้ ซึ่งช่วยให้คุณสร้าง 'พร็อกซี' สำหรับอ็อบเจกต์อื่น ที่สามารถดักจับและนิยามการทำงานพื้นฐานของอ็อบเจกต์นั้นใหม่ได้ มันเหมือนกับการวางยามเฝ้าประตูที่ปรับแต่งได้ไว้หน้าอ็อบเจกต์ ทำให้คุณควบคุมวิธีการเข้าถึงและแก้ไขได้อย่างสมบูรณ์

คู่มือฉบับสมบูรณ์นี้จะไขความกระจ่างเกี่ยวกับ Proxy API เราจะสำรวจแนวคิดหลักของมัน แยกแยะความสามารถต่าง ๆ พร้อมตัวอย่างที่ใช้งานได้จริง และอภิปรายถึงกรณีการใช้งานขั้นสูงและข้อควรพิจารณาด้านประสิทธิภาพ เมื่ออ่านจบ คุณจะเข้าใจว่าทำไม Proxies จึงเป็นรากฐานสำคัญของเฟรมเวิร์กสมัยใหม่ และคุณจะสามารถนำไปใช้เพื่อเขียนโค้ดที่สะอาดขึ้น ทรงพลังขึ้น และบำรุงรักษาได้ง่ายขึ้นได้อย่างไร

ทำความเข้าใจแนวคิดหลัก: Target, Handler และ Traps

Proxy API สร้างขึ้นจากองค์ประกอบพื้นฐานสามส่วน การทำความเข้าใจบทบาทของแต่ละส่วนคือกุญแจสำคัญในการเรียนรู้พร็อกซีอย่างเชี่ยวชาญ

ไวยากรณ์สำหรับการสร้างพร็อกซีนั้นตรงไปตรงมา:

const proxy = new Proxy(target, handler);

มาดูตัวอย่างพื้นฐานกัน เราจะสร้างพร็อกซีที่เพียงแค่ส่งผ่านการดำเนินการทั้งหมดไปยังอ็อบเจกต์ target โดยใช้ handler ที่ว่างเปล่า


// อ็อบเจกต์ดั้งเดิม
const target = {
  message: "Hello, World!"
};

// handler ที่ว่างเปล่า การดำเนินการทั้งหมดจะถูกส่งต่อไปยัง target
const handler = {};

// อ็อบเจกต์พร็อกซี
const proxy = new Proxy(target, handler);

// การเข้าถึง property บนพร็อกซี
console.log(proxy.message); // Output: Hello, World!

// การดำเนินการถูกส่งต่อไปยัง target
console.log(target.message); // Output: Hello, World!

// การแก้ไข property ผ่านพร็อกซี
proxy.anotherMessage = "Hello, Proxy!";

console.log(proxy.anotherMessage); // Output: Hello, Proxy!
console.log(target.anotherMessage); // Output: Hello, Proxy!

ในตัวอย่างนี้ พร็อกซีทำงานเหมือนกับอ็อบเจกต์ดั้งเดิมทุกประการ พลังที่แท้จริงจะเกิดขึ้นเมื่อเราเริ่มกำหนด traps ใน handler

โครงสร้างของ Proxy: สำรวจ Traps ที่ใช้บ่อย

อ็อบเจกต์ handler สามารถมี traps ที่แตกต่างกันได้ถึง 13 ตัว แต่ละตัวสอดคล้องกับเมธอดภายในพื้นฐานของอ็อบเจกต์ JavaScript เรามาสำรวจตัวที่ใช้บ่อยและมีประโยชน์ที่สุดกัน

Property Access Traps (Traps สำหรับการเข้าถึง Property)

1. `get(target, property, receiver)`

นี่น่าจะเป็น trap ที่ใช้บ่อยที่สุด มันจะถูกเรียกเมื่อมีการอ่าน property ของพร็อกซี

ตัวอย่าง: การกำหนดค่าเริ่มต้นสำหรับ property ที่ไม่มีอยู่


const user = {
  firstName: 'John',
  lastName: 'Doe',
  age: 30
};

const userHandler = {
  get(target, property) {
    // ถ้า property มีอยู่ใน target ให้ส่งค่ากลับไป
    // มิฉะนั้น ให้ส่งข้อความเริ่มต้นกลับไป
    return property in target ? target[property] : `Property '${property}' does not exist.`;
  }
};

const userProxy = new Proxy(user, userHandler);

console.log(userProxy.firstName); // Output: John
console.log(userProxy.age);       // Output: 30
console.log(userProxy.country);   // Output: Property 'country' does not exist.

2. `set(target, property, value, receiver)`

set trap จะถูกเรียกเมื่อมีการกำหนดค่าให้กับ property ของพร็อกซี เหมาะอย่างยิ่งสำหรับการตรวจสอบความถูกต้อง การบันทึก หรือการสร้างอ็อบเจกต์แบบอ่านอย่างเดียว

ตัวอย่าง: การตรวจสอบข้อมูล (Data validation)


const person = {
  name: 'Jane Doe',
  age: 25
};

const validationHandler = {
  set(target, property, value) {
    if (property === 'age') {
      if (typeof value !== 'number' || !Number.isInteger(value)) {
        throw new TypeError('Age must be an integer.');
      }
      if (value <= 0) {
        throw new RangeError('Age must be a positive number.');
      }
    }

    // หากการตรวจสอบผ่าน ให้กำหนดค่าบนอ็อบเจกต์ target
    target[property] = value;

    // บ่งบอกว่าสำเร็จ
    return true;
  }
};

const personProxy = new Proxy(person, validationHandler);

personProxy.age = 30; // ใช้ได้
console.log(personProxy.age); // Output: 30

try {
  personProxy.age = 'thirty'; // เกิด TypeError
} catch (e) {
  console.error(e.message); // Output: Age must be an integer.
}

try {
  personProxy.age = -5; // เกิด RangeError
} catch (e) {
  console.error(e.message); // Output: Age must be a positive number.
}

3. `has(target, property)`

trap นี้ดักจับตัวดำเนินการ in ช่วยให้คุณสามารถควบคุมได้ว่า property ใดจะปรากฏว่ามีอยู่ในอ็อบเจกต์

ตัวอย่าง: การซ่อน property ที่เป็น 'private'

ใน JavaScript ธรรมเนียมปฏิบัติทั่วไปคือการใส่คำนำหน้า property ที่เป็น private ด้วยเครื่องหมายขีดล่าง (_) เราสามารถใช้ has trap เพื่อซ่อนสิ่งเหล่านี้จากตัวดำเนินการ in


const secretData = {
  _apiKey: 'xyz123abc',
  publicKey: 'pub456def',
  id: 1
};

const hidingHandler = {
  has(target, property) {
    if (property.startsWith('_')) {
      return false; // แสร้งว่าไม่มีอยู่
    }
    return property in target;
  }
};

const dataProxy = new Proxy(secretData, hidingHandler);

console.log('publicKey' in dataProxy); // Output: true
console.log('_apiKey' in dataProxy);   // Output: false (แม้ว่าจะมีอยู่ใน target)
console.log('id' in dataProxy);        // Output: true

หมายเหตุ: สิ่งนี้มีผลต่อตัวดำเนินการ in เท่านั้น การเข้าถึงโดยตรงเช่น dataProxy._apiKey จะยังคงทำงานได้ เว้นแต่คุณจะใช้งาน get trap ที่สอดคล้องกันด้วย

4. `deleteProperty(target, property)`

trap นี้จะถูกเรียกเมื่อมีการลบ property โดยใช้ตัวดำเนินการ delete มีประโยชน์สำหรับการป้องกันการลบ property ที่สำคัญ

trap ต้องคืนค่า true สำหรับการลบที่สำเร็จ หรือ false สำหรับการลบที่ล้มเหลว

ตัวอย่าง: การป้องกันการลบ property


const immutableConfig = {
  databaseUrl: 'prod.db.server',
  port: 8080
};

const deletionGuardHandler = {
  deleteProperty(target, property) {
    if (property in target) {
      console.warn(`Attempted to delete protected property: '${property}'. Operation denied.`);
      return false;
    }
    return true; // Property ไม่มีอยู่แล้ว
  }
};

const configProxy = new Proxy(immutableConfig, deletionGuardHandler);

delete configProxy.port;
// Console output: Attempted to delete protected property: 'port'. Operation denied.

console.log(configProxy.port); // Output: 8080 (มันไม่ได้ถูกลบ)

Object Enumeration and Description Traps (Traps สำหรับการแจกแจงและอธิบายอ็อบเจกต์)

5. `ownKeys(target)`

trap นี้จะถูกเรียกโดยการดำเนินการที่ต้องการรายการ property ของอ็อบเจกต์เอง เช่น Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertySymbols() และ Reflect.ownKeys()

ตัวอย่าง: การกรองคีย์

ลองรวมสิ่งนี้เข้ากับตัวอย่าง property 'private' ก่อนหน้านี้เพื่อซ่อนมันอย่างสมบูรณ์


const secretData = {
  _apiKey: 'xyz123abc',
  publicKey: 'pub456def',
  id: 1
};

const keyHidingHandler = {
  has(target, property) {
    return !property.startsWith('_') && property in target;
  },
  ownKeys(target) {
    return Reflect.ownKeys(target).filter(key => !key.startsWith('_'));
  },
  get(target, property, receiver) {
    // ป้องกันการเข้าถึงโดยตรงด้วย
    if (property.startsWith('_')) {
      return undefined;
    }
    return Reflect.get(target, property, receiver);
  }
};

const fullProxy = new Proxy(secretData, keyHidingHandler);

console.log(Object.keys(fullProxy)); // Output: ['publicKey', 'id']
console.log('publicKey' in fullProxy); // Output: true
console.log('_apiKey' in fullProxy);   // Output: false
console.log(fullProxy._apiKey);      // Output: undefined

สังเกตว่าเราใช้ Reflect ที่นี่ อ็อบเจกต์ Reflect มีเมธอดสำหรับการดำเนินการ JavaScript ที่สามารถดักจับได้ และเมธอดของมันมีชื่อและ signature เดียวกันกับ proxy traps การใช้ Reflect เพื่อส่งต่อการดำเนินการดั้งเดิมไปยัง target เป็นแนวทางปฏิบัติที่ดีที่สุด เพื่อให้แน่ใจว่าพฤติกรรมเริ่มต้นจะยังคงอยู่

Function and Constructor Traps (Traps สำหรับฟังก์ชันและ Constructor)

Proxies ไม่ได้จำกัดอยู่แค่ plain objects เมื่อ target เป็นฟังก์ชัน คุณสามารถดักจับการเรียกและการสร้างอินสแตนซ์ได้

6. `apply(target, thisArg, argumentsList)`

trap นี้จะถูกเรียกเมื่อพร็อกซีของฟังก์ชันถูกเรียกใช้งาน มันจะดักจับการเรียกฟังก์ชัน

ตัวอย่าง: การบันทึกการเรียกฟังก์ชันและ arguments ของมัน


function sum(a, b) {
  return a + b;
}

const loggingHandler = {
  apply(target, thisArg, argumentsList) {
    console.log(`Calling function '${target.name}' with arguments: ${argumentsList}`);
    // เรียกใช้ฟังก์ชันดั้งเดิมด้วย context และ arguments ที่ถูกต้อง
    const result = Reflect.apply(target, thisArg, argumentsList);
    console.log(`Function '${target.name}' returned: ${result}`);
    return result;
  }
};

const proxiedSum = new Proxy(sum, loggingHandler);

proxiedSum(5, 10);
// Console output:
// Calling function 'sum' with arguments: 5,10
// Function 'sum' returned: 15

7. `construct(target, argumentsList, newTarget)`

trap นี้ดักจับการใช้ตัวดำเนินการ new บนพร็อกซีของคลาสหรือฟังก์ชัน

ตัวอย่าง: การใช้งานรูปแบบ Singleton


class MyDatabaseConnection {
  constructor(url) {
    this.url = url;
    console.log(`Connecting to ${this.url}...`);
  }
}

let instance;

const singletonHandler = {
  construct(target, argumentsList) {
    if (!instance) {
      console.log('Creating new instance.');
      instance = Reflect.construct(target, argumentsList);
    }
    console.log('Returning existing instance.');
    return instance;
  }
};

const ProxiedConnection = new Proxy(MyDatabaseConnection, singletonHandler);

const conn1 = new ProxiedConnection('db://primary');
// Console output:
// Creating new instance.
// Connecting to db://primary...
// Returning existing instance.

const conn2 = new ProxiedConnection('db://secondary'); // URL จะถูกละเลย
// Console output:
// Returning existing instance.

console.log(conn1 === conn2); // Output: true
console.log(conn1.url); // Output: db://primary
console.log(conn2.url); // Output: db://primary

กรณีการใช้งานจริงและรูปแบบขั้นสูง

ตอนนี้เราได้ครอบคลุม traps แต่ละตัวแล้ว มาดูกันว่าเราจะสามารถรวมมันเข้าด้วยกันเพื่อแก้ปัญหาในโลกแห่งความเป็นจริงได้อย่างไร

1. การทำ Abstraction ให้ API และการแปลงข้อมูล

API มักจะส่งคืนข้อมูลในรูปแบบที่ไม่ตรงกับธรรมเนียมปฏิบัติของแอปพลิเคชันของคุณ (เช่น snake_case กับ camelCase) พร็อกซีสามารถจัดการกับการแปลงนี้ได้อย่างโปร่งใส


function snakeToCamel(s) {
  return s.replace(/(_\w)/g, (m) => m[1].toUpperCase());
}

// สมมติว่านี่คือข้อมูลดิบจาก API
const apiResponse = {
  user_id: 123,
  first_name: 'Alice',
  last_name: 'Wonderland',
  account_status: 'active'
};

const camelCaseHandler = {
  get(target, property) {
    const camelCaseProperty = snakeToCamel(property);
    // ตรวจสอบว่ามีเวอร์ชัน camelCase อยู่โดยตรงหรือไม่
    if (camelCaseProperty in target) {
      return target[camelCaseProperty];
    }
    // หากไม่มี ให้ใช้ชื่อ property ดั้งเดิม
    if (property in target) {
      return target[property];
    }
    return undefined;
  }
};

const userModel = new Proxy(apiResponse, camelCaseHandler);

// ตอนนี้เราสามารถเข้าถึง property โดยใช้ camelCase ได้ แม้ว่าจะถูกเก็บเป็น snake_case ก็ตาม
console.log(userModel.userId);        // Output: 123
console.log(userModel.firstName);     // Output: Alice
console.log(userModel.accountStatus); // Output: active

2. Observables และ Data Binding (แกนหลักของเฟรมเวิร์กสมัยใหม่)

Proxies เป็นเครื่องยนต์ที่อยู่เบื้องหลังระบบ reactivity ในเฟรมเวิร์กสมัยใหม่อย่าง Vue 3 เมื่อคุณเปลี่ยนแปลง property บนอ็อบเจกต์ state ที่เป็นพร็อกซี set trap สามารถใช้เพื่อกระตุ้นการอัปเดตใน UI หรือส่วนอื่น ๆ ของแอปพลิเคชันได้

นี่คือตัวอย่างที่ง่ายมาก:


function createObservable(target, callback) {
  const handler = {
    set(obj, prop, value) {
      const result = Reflect.set(obj, prop, value);
      callback(prop, value); // เรียก callback เมื่อมีการเปลี่ยนแปลง
      return result;
    }
  };
  return new Proxy(target, handler);
}

const state = {
  count: 0,
  message: 'Hello'
};

function render(prop, value) {
  console.log(`CHANGE DETECTED: The property '${prop}' was set to '${value}'. Re-rendering UI...`);
}

const observableState = createObservable(state, render);

observableState.count = 1;
// Console output: CHANGE DETECTED: The property 'count' was set to '1'. Re-rendering UI...

observableState.message = 'Goodbye';
// Console output: CHANGE DETECTED: The property 'message' was set to 'Goodbye'. Re-rendering UI...

3. ดัชนีอาร์เรย์แบบติดลบ

ตัวอย่างคลาสสิกและสนุกคือการขยายพฤติกรรมของอาร์เรย์ดั้งเดิมเพื่อรองรับดัชนีติดลบ โดยที่ -1 หมายถึงองค์ประกอบสุดท้าย คล้ายกับภาษาอย่าง Python


function createNegativeArrayProxy(arr) {
  const handler = {
    get(target, property) {
      const index = Number(property);
      if (!Number.isNaN(index) && index < 0) {
        // แปลงดัชนีติดลบเป็นบวกจากท้ายสุด
        property = String(target.length + index);
      }
      return Reflect.get(target, property);
    }
  };
  return new Proxy(arr, handler);
}

const originalArray = ['a', 'b', 'c', 'd', 'e'];
const proxiedArray = createNegativeArrayProxy(originalArray);

console.log(proxiedArray[0]);  // Output: a
console.log(proxiedArray[-1]); // Output: e
console.log(proxiedArray[-2]); // Output: d
console.log(proxiedArray.length); // Output: 5

ข้อควรพิจารณาด้านประสิทธิภาพและแนวทางปฏิบัติที่ดีที่สุด

แม้ว่าพร็อกซีจะทรงพลังอย่างเหลือเชื่อ แต่มันก็ไม่ใช่กระสุนวิเศษ สิ่งสำคัญคือต้องเข้าใจผลกระทบของมัน

ค่าใช้จ่ายด้านประสิทธิภาพ

พร็อกซีจะเพิ่มชั้นของความซับซ้อนขึ้นมา ทุกการดำเนินการบนอ็อบเจกต์พร็อกซีจะต้องผ่าน handler ซึ่งเพิ่มค่าใช้จ่ายเล็กน้อยเมื่อเทียบกับการดำเนินการโดยตรงบนอ็อบเจกต์ธรรมดา สำหรับแอปพลิเคชันส่วนใหญ่ (เช่น การตรวจสอบข้อมูลหรือ reactivity ระดับเฟรมเวิร์ก) ค่าใช้จ่ายนี้แทบไม่มีนัยสำคัญ อย่างไรก็ตาม ในโค้ดที่ต้องการประสิทธิภาพสูง เช่น การวนลูปที่แน่นหนาเพื่อประมวลผลข้อมูลหลายล้านรายการ สิ่งนี้อาจกลายเป็นคอขวดได้ ควรทำการ benchmark เสมอหากประสิทธิภาพเป็นข้อกังวลหลัก

Proxy Invariants

trap ไม่สามารถโกหกเกี่ยวกับธรรมชาติของอ็อบเจกต์ target ได้อย่างสมบูรณ์ JavaScript บังคับใช้ชุดของกฎที่เรียกว่า 'invariants' ที่ proxy traps ต้องปฏิบัติตาม การละเมิด invariant จะส่งผลให้เกิด TypeError

ตัวอย่างเช่น invariant สำหรับ deleteProperty trap คือมันไม่สามารถคืนค่า true (ซึ่งบ่งชี้ว่าสำเร็จ) ได้ หาก property ที่สอดคล้องกันบนอ็อบเจกต์ target เป็นแบบ non-configurable สิ่งนี้ป้องกันไม่ให้พร็อกซีอ้างว่าได้ลบ property ที่ไม่สามารถลบได้


const target = {};
Object.defineProperty(target, 'unbreakable', { value: 10, configurable: false });

const handler = {
  deleteProperty(target, prop) {
    // สิ่งนี้จะละเมิด invariant
    return true;
  }
};

const proxy = new Proxy(target, handler);

try {
  delete proxy.unbreakable; // จะเกิดข้อผิดพลาด
} catch (e) {
  console.error(e.message);
  // Output: 'deleteProperty' on proxy: returned true for non-configurable property 'unbreakable'
}

เมื่อใดควรใช้ Proxies (และเมื่อใดไม่ควร)

Revocable Proxies

สำหรับสถานการณ์ที่คุณอาจต้องการ 'ปิด' พร็อกซี (เช่น ด้วยเหตุผลด้านความปลอดภัยหรือการจัดการหน่วยความจำ) JavaScript มี Proxy.revocable() มันจะคืนค่าอ็อบเจกต์ที่ประกอบด้วยทั้งพร็อกซีและฟังก์ชัน revoke


const target = { data: 'sensitive' };
const handler = {};

const { proxy, revoke } = Proxy.revocable(target, handler);

console.log(proxy.data); // Output: sensitive

// ตอนนี้เราเพิกถอนการเข้าถึงของพร็อกซี
revoke();

try {
  console.log(proxy.data); // จะเกิดข้อผิดพลาด
} catch (e) {
  console.error(e.message);
  // Output: Cannot perform 'get' on a proxy that has been revoked
}

Proxies เทียบกับเทคนิค Metaprogramming อื่น ๆ

ก่อนที่จะมี Proxies นักพัฒนาใช้วิธีอื่นเพื่อให้บรรลุเป้าหมายที่คล้ายกัน เป็นประโยชน์ที่จะเข้าใจว่า Proxies เปรียบเทียบกันอย่างไร

`Object.defineProperty()`

Object.defineProperty() แก้ไข้อ็อบเจกต์โดยตรงโดยการกำหนด getters และ setters สำหรับ property ที่เฉพาะเจาะจง ในทางกลับกัน Proxies ไม่ได้แก้ไขอ็อบเจกต์ดั้งเดิมเลย แต่เป็นการห่อหุ้มมันไว้

บทสรุป: พลังของการจำลองเสมือน (Virtualization)

JavaScript Proxy API เป็นมากกว่าฟีเจอร์ที่ชาญฉลาด มันคือการเปลี่ยนแปลงพื้นฐานในวิธีที่เราสามารถออกแบบและโต้ตอบกับอ็อบเจกต์ได้ โดยการอนุญาตให้เราดักจับและปรับแต่งการดำเนินการพื้นฐาน Proxies เปิดประตูสู่โลกแห่งรูปแบบที่ทรงพลัง: ตั้งแต่การตรวจสอบและแปลงข้อมูลอย่างราบรื่นไปจนถึงระบบ reactive ที่ขับเคลื่อนส่วนต่อประสานผู้ใช้ที่ทันสมัย

แม้ว่ามันจะมาพร้อมกับค่าใช้จ่ายด้านประสิทธิภาพเล็กน้อยและชุดของกฎที่ต้องปฏิบัติตาม แต่ความสามารถในการสร้าง abstractions ที่สะอาด ไม่ผูกมัด และทรงพลังนั้นไม่มีใครเทียบได้ ด้วยการจำลองเสมือนอ็อบเจกต์ คุณสามารถสร้างระบบที่มีความทนทาน บำรุงรักษาง่าย และสื่อความหมายได้ดีขึ้น ครั้งต่อไปที่คุณเผชิญกับความท้าทายที่ซับซ้อนซึ่งเกี่ยวข้องกับการจัดการข้อมูล การตรวจสอบ หรือการสังเกตการณ์ ลองพิจารณาว่า Proxy เป็นเครื่องมือที่เหมาะสมสำหรับงานนั้นหรือไม่ มันอาจเป็นโซลูชันที่สวยงามที่สุดในชุดเครื่องมือของคุณ