Kỹ thuật decorator, forwarding của hàm trong JavaScript
JavaScript giúp bạn xử lý hàm rất linh động. Bạn có thể truyền hàm vào hàm khác qua tham số hoặc sử dụng hàm như object. Và sau đây, mình sẽ giới thiệu về kỹ thuật decorator và forwarding hàm trong JavaScript.
Caching hàm trong JavaScript
Giả sử bạn có một hàm khá nặng slow(x)
- tốn nhiều CPU khi chạy. Tuy nhiên, kết quả của hàm lại ổn định. Nghĩa là với mỗi giá trị của tham số x
giống nhau thì kết quả trả về của hàm slow(x)
cũng giống nhau.
Nếu hàm slow(x)
được gọi thường xuyên, bạn có thể sẽ muốn cache (ghi nhớ) kết quả để tránh việc phải tính toán lại.
Thay vì phải sửa logic của hàm slow(x)
, bạn có thể tạo ra một hàm wrapper. Hàm này nhận hàm slow()
làm tham số và xử lý thêm phần caching, ví dụ:
function slow(x) {
// có thể có các tác vụ nặng, tốn CPU
console.log(`Đã được gọi với ${x}`);
return x;
}
// hàm wrapper
function cachingDecorator(func) {
let cache = new Map(); // tạo map để cache
return function (x) {
if (cache.has(x)) {
// nếu tồn tại key là x thì trả về kết quả đã tương ứng
return cache.get(x);
}
let result = func(x); // ngược lại thì gọi hàm
cache.set(x, result); // thêm kết quả vào cache
return result; // trả về kết quả
};
}
slow = cachingDecorator(slow);
console.log(slow(1)); // cache lại kết quả của slow(1) rồi trả về kết quả
console.log("Gọi lại: " + slow(1)); // kết quả của slow(1) được lấy từ cache
console.log(slow(2)); // cache lại kết quả của slow(2) rồi trả về kết quả
console.log("Gọi lại: " + slow(2)); // kết quả của slow(1) được lấy từ cache
Kết quả:
Đã được gọi với 1
1
Gọi lại: 1
Đã được gọi với 2
2
Gọi lại: 2
Trong ví dụ trên, hàm cachingDecorator
được gọi là decorator - một hàm đặc biệt, nhận tham số đầu vào là một hàm khác và bổ sung thêm các tính năng.
Ý tưởng ở đây là bạn có thể gọi hàm cachingDecorator
với bất kỳ hàm nào và trả về một hàm wrapper đã có tính năng cache. Bởi vì thực tế, bạn sẽ có nhiều hàm cần sử dụng tính năng caching này.
Những lợi ích của việc sử dụng hàm cachingDecorator
là:
- Hàm
cachingDecorator
có thể tái sử dụng. Bạn có thể tách ra thành thư viện rồi sử dụng lại với nhiều hàm và nhiều nơi khác nhau. - Logic caching được tách riêng, tránh làm tăng độ phức tạp của hàm
slow()
. - Có thể kết hợp nhiều decorator khi cần.
Sử dụng func.call
Thực tế, cách triển khai hàm cachingDecorator
như trên là không phù hợp với phương thức của object, ví dụ:
"use strict";
// hàm slow là phương thức của object worker
let worker = {
someMethod() {
return 1;
},
slow(x) {
// có thể có thao tác tốn CPU
console.log("Được gọi với " + x);
return x * this.someMethod(); // (*) },
};
// code giống như trên
function cachingDecorator(func) {
let cache = new Map();
return function (x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func(x); // (**) cache.set(x, result);
return result;
};
}
console.log(worker.slow(1)); // gọi code bình thường vẫn đúng
worker.slow = cachingDecorator(worker.slow); // khi sử dụng với caching
console.log(worker.slow(2));
// Lỗi:
// Cannot read properties of undefined (reading 'someMethod')
Lỗi trên xảy ra ở (*)
khi gọi this.someMethod
. Bởi vì ở (**)
, hàm cachingDecorator
gọi hàm func(x)
theo cách này thì giá trị của this
sẽ là undefined
.
Bạn có thể thấy lỗi tương tự nếu làm như sau:
let func = worker.slow;
func(2);
Nguyên nhân gây ra lỗi trên là vì hàm wrapper đã gọi hàm gốc mà không có biến context this
.
Để giải quyết vấn đề trên, bạn có thể sử dụng phương thức func.call(context,...args)
. Phương thức này cho phép truyền vào giá trị của this
với cú pháp đầy đủ là:
func.call(context, arg1, arg2, ...)
Phương thức func.call
gọi hàm func
với tham số đầu tiên tương ứng giá trị của this
và theo sau là danh sách các tham số của hàm, ví dụ:
func(1, 2, 3);
func.call(obj, 1, 2, 3);
Hai cách gọi hàm trên cơ bản giống nhau, chỉ khác ở cách gọi func.call
thì giá trị của this
bằng obj
.
Ví dụ hàm sayHi
sau đây được gọi với hai ngữ cảnh this
khác nhau:
function sayHi() {
console.log(this.name);
}
let user = { name: "Alex" };
let admin = { name: "Admin" };
// gọi hàm sayHi với giá trị của `this` khác nhau
sayHi.call(user); // Alex
sayHi.call(admin); // Admin
Quay lại ví dụ về caching trên, bây giờ bạn có thể áp dụng phương thức func.call
như sau:
"use strict";
// hàm slow là phương thức của object worker
let worker = {
someMethod() {
return 1;
},
slow(x) {
// có thể có thao tác tốn CPU
console.log("Được gọi với " + x);
return x * this.someMethod(); // (*) },
};
// code giống như trên
function cachingDecorator(func) {
let cache = new Map();
return function (x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func.call(this, x); // (**) cache.set(x, result);
return result;
};
}
worker.slow = cachingDecorator(worker.slow);
console.log(worker.slow(2));
console.log(worker.slow(2));
Bây giờ, mọi thứ đã bình thường và không có lỗi xảy ra. Và đoạn code trên có thể hiểu như sau:
- Sau hàm
cachingDecorator
, hàmworker.slow
được gán bằng hàm wrapperfunction(){...}
. - Vì vậy, khi gọi
worker.slow(2)
, hàm wrapper nhận2
là tham số truyền vào vàthis=worker
vìworker
là object trước dấu chấm.
.
Caching hàm với nhiều tham số
Cách triển khai hàm cachingDecorator
như trên chỉ áp dụng được với hàm có 1 tham số đầu vào. Giả sử, mình muốn sử dụng cachingDecorator
với nhiều tham số thì sao?
let worker = {
slow(min, max) {
// giả sử có thao tác tốn CPU
return min + max;
},
};
// ghi nhớ kết quả với các tham số truyền vào
worker.slow = cachingDecorator(worker.slow);
Với hàm một tham số x
, bạn có thể sử dụng cache.set(x, result)
để lưu kết quả và dùng cache.get(x)
để lấy kết quả đã cache.
Nhưng với trường hợp bạn cần ghi nhớ hai tham số (min, max)
thì cách trên lại không dùng được ngay. Vì kiểu dữ liệu Map chỉ áp dụng được với 1 key.
Có nhiều cách để giải quyết vấn đề này:
- Triển khai (hoặc dùng thư viện) kiểu dữ liệu map-like hỗ trợ nhiều key.
- Sử dụng Map lồng nhau: ví dụ sử dụng
cache.set(min, value)
trong đóvalue
ứng với cặp(max, result)
. Để lấy kết quả, bạn chỉ cần dùngcache.get(min).get(max)
. - Ghép các giá trị thành một: ví dụ bạn dùng một string
min,max
để biểu diễn cho cặp giá trị(min,max)
. Và để linh động hơn, bạn có thể cho phép cung cấp hàmhash
vào hàm decorator làm nhiệm vụ chuyển đổi nhiều giá trị thành một giá trị.
Ví dụ cách sử dụng hàm hash
như sau:
let worker = {
slow(min, max) {
console.log(`Được gọi với ${min},${max}`);
return min + max;
},
};
function cachingDecorator(func, hash) {
let cache = new Map();
return function () {
let key = hash(arguments); // (*) if (cache.has(key)) {
return cache.get(key);
}
let result = func.call(this, ...arguments); // (**)
cache.set(key, result);
return result;
};
}
function hash(args) {
return args[0] + "," + args[1];
}
worker.slow = cachingDecorator(worker.slow, hash);
console.log(worker.slow(3, 5)); // hoạt động bình thường
console.log("Gọi lại " + worker.slow(3, 5)); // kết quả từ cache
Ví dụ trên có hai thay đổi là:
- Ở
(*)
:key
là kết quả của việc gọi hàmhash(arguments)
. Hàmhash
đơn giản là ghép hai tham số thành một string bằng dấu phẩy,
. - Sau đó ở
(**)
: sử dụngfunc.call(this, ...arguments)
để truyền vào cả giá trịthis
và tất cả các tham số qua cú pháp spread...
.
Sử dụng func.apply
Thay vì sử dụng cú pháp func.call(this, ...arguments)
, bạn có thể dùng func.apply(this, arguments)
với cú pháp đầu đủ là:
func.apply(context, args);
Phương thức trên gọi hàm func
với this=context
và tham số đầu vào là args
có kiểu dữ liệu array-like.
Khác nhau cơ bản giữa func.apply
và func.call
là ở cách truyền tham số:
func.apply
: nhận tham số đầu vào dạng array-like.func.call
: nhận vào là một danh sách các tham số.
Có thể bạn quan tâm: Phân biệt call, apply và bind.
func.call(context, ...args);
func.apply(context, args);
Trong hai cách gọi trên, func.apply
có vẻ nhanh hơn, bởi vì JavaScript Engine tối ưu cách này hơn.
Ngoài ra, cách chuyển hết các tham số từ hàm này sang hàm khác gọi là kỹ thuật forwarding, ví dụ:
let wrapper = function () {
return func.apply(this, arguments);
};
Khi hàm wrapper
được gọi, thực chất hàm này lại chuyển hết tham số sang hàm func
để gọi hàm func
.
Kỹ thuật mượn phương thức
Hàm hash
bên trên đang chỉ xử lý trường hợp có hai tham số đầu vào.
function hash(args) {
return args[0] + "," + args[1];
}
Nhưng tốt hơn hết là hàm hash
hoạt động được với số lượng tham số tùy ý. Và cách sử dụng cơ bản nhất là sử dụng phương thức arr.join
.
function hash(args) {
return args.join();
}
Tuy nhiên, cách trên lại không dùng được. Bởi vì cách gọi hash(arguments)
trước đó lại truyền vào tham số arguments
vừa là iterable object và vừa là array-like, nhưng không phải array.
function hash() {
console.log(arguments.join());}
hash(1, 2);
// Uncaught TypeError: arguments.join is not a function
Để giải quyết vấn đề trên, bạn có thể sửa lại như sau:
function hash() {
console.log([].join.call(arguments)); // 1,2}
hash(1, 2);
Cách trên gọi là kỹ thuật mượn phương thức - borrowing method.
Cụ thể là mình đã mượn phương thức [].join
của mảng []
và sử dụng [].join.call
với context là this=arguments
.
Decorator và thuộc tính của hàm
Nhìn chung, việc sử dụng decorator thể thay thế hàm hoặc phương thức của object là an toàn, ngoại trừ một trường hợp. Nếu hàm gốc sử dụng kiểu function object, nghĩa là có thuộc tính, kiểu func.calledCount
.
Nếu truyền hàm func
trên qua decorator thì hàm wrapper sẽ không có thuộc tính calledCount
. Vì thực chất là decorator đã tạo ra một hàm mới.
Tổng kết
Kĩ thuật decorator là cách tạo ra hàm wrapper chứa hàm gốc và bổ sung thêm một số tính năng khác, với ưu điểm:
- Thêm các tính năng vào hàm gốc mà không làm phức tạp logic của hàm gốc.
- Có thể tách biệt code để sử dụng lại.
- Và có thể kết hợp nhiều hàm decorator với nhau.
Để triển khai decorator, bạn có thể sử dụng các phương thức:
func.call(context, ...args)
để gọi hàmfunc
vớithis=context
và các tham sốargs
.func.apply(context, args)
để gọi hàmfunc
vớithis=context
và tham sốargs
kiểu array-like.
Kỹ thuật forwarding là cách chuyển toàn bộ tham số từ hàm này sang hàm khác:
let wrapper = function () {
return original.apply(this, arguments);
};
Kỹ thuật mượn phương thức là cách mượn phương thức của một object và gọi nó với object khác. Phổ biến nhất là cách lấy các phương thức hàm để áp dụng cho kiểu array-like như đối tượng arguments
.
Thực hành
Bài 1
Viết hàm decorator spy(func)
trả về hàm wrapper giúp lưu lại tất cả các tham số của các lời gọi hàm, ví dụ:
function work(a, b) {
console.log(a + b);
}
work = spy(work);
work(1, 2); // 3
work(4, 5); // 9
for (let args of work.calls) {
console.log("call:" + args.join()); // "call:1,2", "call:4,5"
}
function work(a, b) {
console.log(a + b);
}
function spy(func) { function f() { // push vào mảng các tham số f.calls.push([...arguments]); // forwarding lại về hàm func return func.apply(this, arguments); } // khai báo thuộc tính hàm f.calls = []; return f;}
work = spy(work);
work(1, 2); // 3
work(4, 5); // 9
for (let args of work.calls) {
console.log("call:" + args.join()); // "call:1,2", "call:4,5"
}
Bài 2
Viết hàm decorator delay(f, ms)
trì hoãn lại gọi hàm f
sau ms
mili giây, ví dụ:
function f(x) {
console.log(x);
}
// hàm wrapper
let f1000 = delay(f, 1000);let f1500 = delay(f, 1500);
f1000("test"); // hiển thị "test" sau 1000ms
f1500("test"); // hiển thị "test" sau 1500ms
function f(x) {
console.log(x);
}
function delay(func, ms) { return function () { setTimeout(() => func.apply(this, arguments), ms); };}
// hàm wrapper
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);
f1000("test"); // hiển thị "test" sau 1000ms
f1500("test"); // hiển thị "test" sau 1500ms
Tham khảo: Decorators and forwarding, call/apply
★ Nếu bạn thấy bài viết này hay thì hãy theo dõi mình trên Facebook để nhận được thông báo khi có bài viết mới nhất nhé:
- Facebook Fanpage: Complete JavaScript
- Facebook Group: Hỏi đáp JavaScript VN
Bình luận