Proxy
Proxy
Đối tượng Proxy được sử dụng để thay đổi hành vi mặc định của một số hoạt động (như tìm kiếm thuộc tính, gán giá trị, liệt kê, gọi hàm, v.v.), tương đương với việc thay đổi ngôn ngữ lập trình ở mức độ ngôn ngữ, do đó nó thuộc về một loại siêu lập trình (Meta Programming), tức là lập trình cho ngôn ngữ lập trình.
Proxy có thể hiểu là một đại diện được đặt trước đối tượng mục tiêu, mọi truy cập từ bên ngoài vào đối tượng đó đều phải thông qua lớp đại diện này, do đó cung cấp một cơ chế để lọc và thay đổi truy cập từ bên ngoài.
Từ "Proxy" có nghĩa là ủy quyền, được sử dụng ở đây để đại diện cho một số hoạt động, có thể dịch là đại diện.
target
: Đối tượng được ảo hóa bởi Proxy, thường được sử dụng làm lưu trữ đại diện, kiểm tra các invariants về không thể mở rộng hoặc thuộc tính không thể cấu hình của đối tượng (duy trì ý nghĩa không thay đổi)handler
: Đối tượng chứa các trình bắt (Trap), có thể dịch là đối tượng xử lýtraps
: Cung cấp các phương thức truy cập thuộc tính, tương tự như khái niệm trình bắt trong hệ điều hành
Cách sử dụng:
ES6 cung cấp Proxy constructor để tạo ra một instance của Proxy.
const proxy = new Proxy(target, handler);
Tất cả các cách sử dụng của đối tượng Proxy đều có cùng cú pháp như trên, chỉ khác nhau ở cách viết tham số handler
. Trong đó, new Proxy()
đại diện cho việc tạo ra một instance của Proxy, tham số target
đại diện cho đối tượng mục tiêu mà chúng ta muốn chặn, tham số handler
cũng là một đối tượng, được sử dụng để tùy chỉnh hành vi chặn.
Sử dụng cơ bản
const proxy = new Proxy(
{},
{
get: function (target, property, receiver) {
console.log(`Đang truy cập ${property}!`);
return Reflect.get(target, property, receiver);
},
set: function (target, property, value, receiver) {
console.log(`Đang gán giá trị cho ${property}!`);
return Reflect.set(target, property, value, receiver);
},
}
);
Đoạn mã trên tạo ra một lớp đại diện cho một đối tượng trống, và định nghĩa lại hành vi đọc (get) và gán giá trị (set) của thuộc tính. Ở đây tạm thời chưa giải thích cú pháp cụ thể, chỉ xem kết quả chạy. Khi đọc hoặc gán giá trị cho thuộc tính của đối tượng đại diện proxy
đã được định nghĩa hành vi chặn, kết quả nhận được sẽ như sau:
proxy.count = 1;
// Đang gán giá trị cho count!
++proxy.count;
// Đang truy cập count!
// Đang gán giá trị cho count!
// 2
Đoạn mã trên cho thấy, Proxy thực tế là nạp chồng (Overload) toán tử dấu chấm, tức là sử dụng định nghĩa của chính nó để ghi đè định nghĩa gốc của ngôn ngữ.
Vấn đề về ngữ cảnh tham chiếu của Proxy
Mặc dù Proxy có thể đại diện cho việc truy cập vào đối tượng mục tiêu, nhưng nó không phải là một đại diện trong suốt cho đối tượng mục tiêu, tức là không thể đảm bảo hành vi tương tự với đối tượng mục tiêu khi không có bất kỳ sự chặn nào. Nguyên nhân chính là khi Proxy đại diện, từ khóa this
bên trong đối tượng mục tiêu sẽ trỏ đến Proxy đại diện.
const target = {
foo: function () {
console.log(this === proxy);
},
};
const handler = {};
const proxy = new Proxy(target, handler);
console.log(target.foo());
// false
console.log(proxy.foo());
// true
Trong đoạn mã trên, khi proxy
đại diện cho target.foo
, từ khóa this
bên trong target.foo
sẽ trỏ đến proxy
, chứ không phải target
.
Hỗ trợ lồng nhau
Proxy cũng không hỗ trợ lồng nhau, điều này giống với Object.defineProperty()
. Do đó, để giải quyết vấn đề này, chúng ta cần duyệt qua từng cấp độ. Cách viết Proxy là sử dụng đệ quy trong get
và trả về một Proxy mới.
// Dữ liệu cần đại diện
const data = {
info: {
name: 'Eason',
blogs: ['Webpack', 'Babel', 'React'],
},
};
// Đối tượng xử lý
const handler = {
get(target, key, receiver) {
console.log('GET', key);
// Tạo và trả về đệ quy
if (typeof target[key] === 'object' && target[key] !== null) {
return new Proxy(target[key], handler);
}
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log('SET', key, value);
return Reflect.set(target, key, value, receiver);
},
};
const proxy = new Proxy(data, handler);
// Đoạn mã dưới đây sẽ thực hiện set
proxy.info.name = 'Zoe';
proxy.info.blogs.push('proxy');
Proxy và Object.defineProperty
ES5 cung cấp phương thức Object.defineProperty
, phương thức này cho phép định nghĩa một thuộc tính mới trên một đối tượng, hoặc sửa đổi một thuộc tính hiện có trên một đối tượng và trả về đối tượng đó.
Tuy nhiên, Object.defineProperty
có ba vấn đề chính:
- Không thể theo dõi sự thay đổi của mảng, Vue sử dụng Hack để ghi đè tám phương thức mảng để thực hiện điều này.
- Chỉ có thể can thiệp vào thuộc tính của đối tượng, do đó, các thuộc tính cần liên kết hai chiều phải được định nghĩa rõ ràng.
- Phải duyệt qua đối tượng lồng nhau một cách sâu để thực hiện can thiệp.
Khác biệt giữa Proxy và Object.defineProperty:
- Proxy có thể trực tiếp theo dõi sự thay đổi của mảng.
- Proxy có thể trực tiếp theo dõi đối tượng chứ không chỉ là thuộc tính.
- Proxy có thể can thiệp vào toàn bộ đối tượng và trả về một đối tượng mới, vượt trội so với
Object.defineProperty
cả về tính tiện lợi và chức năng cơ bản. - Proxy có tới 13 phương thức chặn, không giới hạn ở
apply
,ownKeys
,deleteProperty
,has
, v.v., trong khiObject.defineProperty
không có.
Nhược điểm của Proxy:
Nhược điểm của Proxy là sự tương thích, không thể sử dụng Polyfill để giải quyết vấn đề tương thích.
Ứng dụng
Pipeline
Trong các đề xuất ECMAScript mới nhất, đã xuất hiện toán tử pipeline |>
, có khái niệm tương tự trong RxJS và Node.js.
Sử dụng Proxy, chúng ta cũng có thể thực hiện chức năng pipe
, chỉ cần sử dụng get
để chặn việc truy cập thuộc tính, đặt tất cả các phương thức truy cập vào mảng stack
, và khi truy cập cuối cùng vào execute
, trả về kết quả.
const pipe = (value) => {
const stack = [];
const proxy = new Proxy(
{},
{
get(target, prop) {
if (prop === 'execute') {
return stack.reduce(function (val, fn) {
return fn(val);
}, value);
}
stack.push(window[prop]);
return proxy;
},
}
);
return proxy;
};
const double = (n) => n * 2;
const pow = (n) => n * n;
console.log(pipe(3).double.pow.execute);
Nạp chồng toán tử
Toán tử in
được sử dụng để kiểm tra xem một thuộc tính cụ thể có trong một đối tượng cụ thể hoặc chuỗi nguyên mẫu của nó hay không, nhưng nó cũng là toán tử được nạp chồng tinh vi nhất về cú pháp. Ví dụ này định nghĩa một hàm range
để so sánh các số trong một khoảng liên tục.
const range = (min, max) => {
return new Proxy(Object.create(null), {
has: (_, prop) => +prop >= min && +prop <= max,
});
};
Khác với Python, Python sử dụng các generator để so sánh với các chuỗi số nguyên hữu hạn, phương pháp này hỗ trợ so sánh số thập phân và có thể mở rộng để hỗ trợ các khoảng số khác.
const num = 11;
const data = [1, 5, num, 50, 100];
if (num in range(1, 100)) {
// làm gì đó
}
data.filter((n) => n in range(1, 10));
// [1, 5]
Mặc dù trường hợp sử dụng này có thể không giải quyết các vấn đề phức tạp, nhưng nó cung cấp mã nguồn sạch, dễ đọc và có thể tái sử dụng.
Ngoài toán tử in
, chúng ta cũng có thể nạp chồng delete
và new
.
Tìm kiếm đối tượng cụ thể trong mảng thông qua thuộc tính
Đoạn mã dưới đây mở rộng một số tiện ích cho một mảng thông qua Proxy. Như bạn có thể thấy, với Proxy, chúng ta có thể linh hoạt định nghĩa các thuộc tính mà không cần sử dụng phương thức Object.defineProperties
. Ví dụ dưới đây có thể được sử dụng để tìm kiếm một hàng trong bảng dựa trên một ô cụ thể.
const data = [
{ name: 'Firefox', type: 'browser' },
{ name: 'SeaMonkey', type: 'browser' },
{ name: 'Thunderbird', type: 'mailer' },
];
const products = new Proxy(data, {
get: function (target, prop) {
// Hành vi mặc định là trả về giá trị thuộc tính
if (prop in target) {
return target[prop];
}
// Lấy số lượng sản phẩm, đó là đồng nghĩa với target.length
if (typeof prop === 'number') {
return target.length;
}
let result,
types = {};
for (let item of target) {
if (item.name === prop) {
result = item;
}
if (types[item.type]) {
types[item.type].push(item);
} else {
types[item.type] = [item];
}
}
// Lấy item dựa trên tên
if (result) return result;
// Lấy item dựa trên loại
if (prop in types) return types[prop];
// Lấy danh sách loại sản phẩm
if (prop === 'types') {
return Object.keys(types);
}
return undefined;
},
});
console.log(products[0]);
// { name: 'Firefox', type: 'browser' }
console.log(products['Firefox']);
// { name: 'Firefox', type: 'browser' }
console.log(products['Chrome']);
// undefined
console.log(products.browser);
// [
// { name: 'Firefox', type: 'browser' },
// { name: 'SeaMonkey', type: 'browser' }
// ]
console.log(products.types);
// ['browser', 'mailer']
console.log(products.number);
// 3
Mở rộng hàm khởi tạo
Phương thức đại diện có thể dễ dàng mở rộng một hàm khởi tạo hiện có thông qua một hàm khởi tạo mới.
const extend = function (sup, base) {
const descriptor = Object.getOwnPropertyDescriptor(base.prototype, 'constructor');
base.prototype = Object.create(sup.prototype);
const handler = {
construct: function (target, args) {
const obj = Object.create(base.prototype);
this.apply(target, obj, args);
return obj;
},
apply: function (target, context, args) {
sup.apply(context, args);
base.apply(context, args);
},
};
const proxy = new Proxy(base, handler);
descriptor.value = proxy;
Object.defineProperty(base.prototype, 'constructor', descriptor);
return proxy;
};
Sử dụng ví dụ:
const Person = function (name) {
this.name = name;
};
const Boy = extend(Person, function (name, age) {
this.age = age;
});
Boy.prototype.sex = 'Male';
const Peter = new Boy('Peter', 20);
console.log(Peter.sex);
// 'Male'
console.log(Peter.name);
// 'Peter'
console.log(Peter.age);
// 20
Hiệu ứng phụ
Chúng ta có thể sử dụng Proxy để tạo ra hiệu ứng phụ khi đọc và ghi thuộc tính. Ý tưởng là khi truy cập hoặc ghi vào một số thuộc tính cụ thể, chúng ta có thể kích hoạt một số hàm.
const dosomething = () => {
console.log('Thực hiện một số công việc sau khi hoàn thành');
};
const handler = {
set: function (target, prop, value) {
if (prop === 'status' && value === 'complete') {
dosomething();
}
target[prop] = value;
},
};
const tasks = new Proxy({}, handler);
tasks.status = 'complete';
Khi thuộc tính status
được ghi và giá trị là 'complete'
, hàm hiệu ứng phụ dosomething()
sẽ được kích hoạt.
Bộ nhớ cache
Tận dụng khả năng can thiệp vào việc đọc và ghi thuộc tính của đối tượng, chúng ta có thể tạo ra một bộ nhớ cache dựa trên bộ nhớ, chỉ trả về giá trị khi nó chưa hết hạn:
const cacheTarget = (target, ttl = 60) => {
const CREATED_AT = Date.now();
const isExpired = () => (Date.now() - CREATED_AT) > (ttl * 1000);
const handler = {
get: (target, prop) => isExpired() ? undefined : target[prop],
};
return new Proxy(target, handler);
};
const cache = cacheTarget({ age: 25 }, 5);
console.log(cache.age);
setTimeout(() => {
console.log(cache.age);
}, 6 * 1000);
Ở đây, chúng ta tạo một hàm và trả về một Proxy. Trước khi truy cập vào thuộc tính của target
, handler
của Proxy này sẽ kiểm tra xem đối tượng target
có hết hạn không, và dựa trên điều này, chúng ta có thể thiết lập kiểm tra hết hạn cho mỗi khóa giá trị bằng cách sử dụng TTLs hoặc cơ chế khác.
Đối tượng Cookie
Nếu bạn đã từng làm việc với Cookie, bạn sẽ phải làm việc với document.cookie
. Đây là một API khá đặc biệt, vì API này là một chuỗi, nó đọc tất cả các Cookie và phân tách chúng bằng dấu chấm phẩy.
document.cookie
là một chuỗi có dạng như sau:
_octo=GH1.2.2591.47507; _ga=GA1.1.62208.4087; has_recent_activity=1
Đơn giản mà nói, việc xử lý document.cookie
khá phức tạp và dễ gây lỗi. Một cách để làm việc với nó là sử dụng một framework Cookie đơn giản, có thể được thực hiện bằng cách sử dụng Proxy.
const getCookie = () => {
const cookies = document.cookie.split(';').reduce((acc, item) => ({
[item.substr(0, item.indexOf('=')).trim()]: item.substr(item.indexOf('=') + 1),
...acc,
}));
const setCookie = (name, val) => (document.cookie = `${name}=${val}`);
const deleteCookie = (name) =>
(document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT;`);
return new Proxy(cookies, {
set: (obj, prop, val) => (setCookie(prop, val), Reflect.set(obj, prop, val)),
deleteProperty: (obj, prop) => (deleteCookie(prop), Reflect.deleteProperty(obj, prop)),
});
};
Hàm này trả về một đối tượng key-value, nhưng Proxy sẽ xử lý tất cả các thay đổi về Cookie.
let docCookies = getCookies();
docCookies.has_recent_activity;
// 1
docCookies.has_recent_activity = '2';
// 2
delete docCookies['has_recent_activity'];
// true
Trong 11 dòng mã này, việc sửa đổi Cookie được thực hiện một cách tốt hơn, mặc dù trong môi trường sản xuất, bạn cần thêm các tính năng bổ sung như chuẩn hóa chuỗi.
Nhật ký và thống kê
Trong việc phát triển phía máy chủ, chúng ta có thể sử dụng Proxy để làm proxy cho các hàm và nhật ký số lần gọi trong một khoảng thời gian nhất định.
Điều này có thể hữu ích khi phân tích hiệu năng sau này:
function noop() {}
const proxyFunction = new Proxy(noop, {
apply(target, context, args) {
logger();
return target.apply(context, args);
},
});
Hoặc:
const data = {
name: 'Jerry',
author: 'Lauren Weisberger'
}
const proxy = new Proxy(data, {
set(target, key, value) {
console.log('Đặt', key, ':', target[key], '->', value);
target[key] = value;
}
})
proxy.name = 'Notebook';
// Đặt name : The Devil wears prada -> Notebook
proxy.name = 'asdf';
// Đặt name : Notebook -> asdf
Như ví dụ trên, bạn có thể xác định chính xác khi nào thuộc tính của một đối tượng đã được thay đổi và bạn cũng có thể sử dụng các phương thức như console.trace
để xác định nơi nó đã được thay đổi.
Mở rộng đến các loại handler khác, sau khi bạn bọc một đối tượng trong một Proxy, bạn có thể biết khi nào và ở đâu thuộc tính của nó được đọc, được gọi, được khởi tạo, bị xóa, được truy cập vào thuộc tính, v.v.
Nghe có vẻ việc xác định vấn đề với các loại động có thể trở nên đơn giản hơn. Nếu có một thư viện giám sát đối tượng phổ biến, nhà phát triển chỉ cần nhập thư viện đó và bọc đối tượng cần giám sát để có thể in ra toàn bộ lịch sử hoạt động của đối tượng đó.
Đại diện động
Đại diện đơn giản:
const axios = require('axios');
const instance = axios.create({ baseURL: 'http://localhost:3000/api' });
const METHODS = ['get', 'post', 'patch'];
// proxy api
const api = new Proxy(
{},
{
// proxy api.${name}
get: (_, name) =>
new Proxy(
{},
{
// proxy api.${name}.${method}
get: (_, method) =>
METHODS.includes(method) &&
new Proxy(() => {}, {
// proxy api.${name}.${method}()
apply: (_, self, [config]) =>
instance.request({
url: name, // /api/${name}
method, // ${method}
...config,
}),
}),
}
),
}
);
Cách sử dụng có thể là:
// GET /api/user?id=12
api.user
.get({ params: { id: 12 } })
.then((user) => console.log(user))
.catch(console.error);
// POST /api/register
api.register
.post({ body: { username: 'xxx', passworld: 'xxxx' } })
.then((res) => console.log(res))
.catch(console.error);
Trong design pattern, có một mô hình trung gian gọi là mô hình trung gian (Mediator pattern), trong mô hình này, Proxy có thể được coi là trung gian trong giao tiếp giữa các đối tượng. Trong trường hợp này, chúng ta không cần xác định quan hệ giữa các đối tượng khác nhau, chỉ cần Proxy đảm bảo trải nghiệm nhất quán với bên ngoài.
Một ví dụ khác là trong tương lai, thông qua Proxy, chúng ta cũng có thể triển khai các kịch bản nạp lại nóng, chúng ta có thể thay thế phiên bản cũ bằng mã mới được yêu cầu bằng cách chỉ định Proxy để ẩn điều này khỏi nhà phát triển.