JavaScript デコレータ関数 Decorator Function
デコレータ関数は別の関数を受け取り、その関数の動作を変更せずに拡張する関数のことです。
以下は JavaScript のデコレータ関数の作り方や使い方に関する解説のような覚書です。
関連ページ:Debounce と Throttle
作成日:2023年1月14日
デコレータ(関数)とは
ここで言うデコレータ(関数)は関数を引数として受け取り、関数自体を変更せずに拡張して新しい関数(または結果)を返す関数のことです。以下では ECMAScript Decorators については言及していません。
デコレータとは、あるコードを別のコードでラップする方法のことで、コードを Decorate(装飾)します(Decorator はデザインパターンの1つです)。
関数を受け取ったり返したりする関数のことを高階関数(higher-order functions)と呼びます。
JavaScript では高階関数は first-class function(第一級関数)を引数として受け取り、関数を返すことができます。簡単に言うと、JavaScript では関数を引数に受け取り、関数を返すことができます。
関数をデコレートする関数なので、デコレータ関数(Decorator Function)や関数デコレータ(Function Decorator)と呼ばれますが、以降では単にデコレータと呼んでいます。
サンプル
以下の logs
は、引数に渡された文字列の前に'message : '
を追加してコンソールに出力する関数です。
// コンソールにメッセージを出力する関数 const logs = (msg) => { console.log('message : ' + msg); } logs('hello'); //message : hello
以下の関数 logDecorator
は、関数を引数に受け取って、実行時刻を出力する機能を追加するデコレータ(関数)です。このデコレータは引数に受け取った関数に機能を追加したラッパー関数を返します。
デコレータ logDecorator
の引数に関数 logs
を渡して実行すると、受け取った関数 logs
に実行時刻を出力する機能が追加された関数(ラッパー)が返されます。
function logDecorator (func) { //引数で受け取った関数を拡張して新たな関数(ラッパー)として返す return function (...args) { //受け取った関数を apply を使って呼び出す func.apply(this, args); //メッセージがログに記録された時刻を出力する機能を追加 console.log('logged at:', new Date().toLocaleString()); } } // 上記で定義したデコレータで関数 logs を拡張(デコレート) const decoratedLogs = logDecorator(logs);
デコレータ logDecorator
の戻り値 decoratedLogs
は関数なので、logs
と同様に実行することができます。
// デコレータの戻り値の関数を実行 decoratedLogs('hello from decoratedLogs');
機能の追加などの拡張をデコレータとして作成しておくことで、使い回すことができます。
以下は、引数に受け取った値を加算してアラート表示する関数 alertAdd
を上記のデコレータで拡張する例です。デコレータの戻り値の関数 decoratedAlert
を実行するとアラートが表示され、OK をクリックしてアラートを閉じると、その時点での時刻がコンソールに出力されます。
//引数に受け取った値を加算してアラート表示する関数 const alertAdd = (x, y) => { alert(x + y); } // デコレータで alertAdd に時刻を出力する機能を追加した関数を作成 const decoratedAlert = logDecorator(alertAdd); // デコレータの戻り値の関数を実行 decoratedAlert(3,4);
デコレータの作成
以下は関数を引数に受け取り、その関数の引数の数が適正かを検証する機能を追加するデコレータです。
デコレータ checkParams
は、引数に関数を受け取り、その関数に渡された引数の数をチェックして、正しくない場合はエラーをスローし、適正な場合は受け取った関数を呼び出すラッパー関数を返します。
関数の期待する引数の数は Function.length プロパティで取得することができます。
引数に渡される関数(func
)にはいくつの引数が渡されても良いように、Rest パラメータ(残余引数)...args
を使用し、args.length
と func.length
を比較して異なっていればエラーにします。
引数の数が正しければ受け取った関数を呼び出します。関数を呼び出すには ...args
の args
は配列なので、第二引数に配列を受け取る apply()
を使っています。また、apply() を使うことで、コンテキスト(this
)を渡すことができます。
checkParams
の戻り値は受け取った関数(func
)を呼び出すラッパー(関数)です。
//デコレータの定義(引数に関数を受け取る) function checkParams(func) { // function() を使って新たな関数(ラッパー)を返す return function (...args) { if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //apply() や call() を使って受け取った関数を呼び出す return func.apply(this, args); } }
上記では関数宣言を使って定義していますが、関数式やアロー関数を使って定義しても同じです。
但し、関数を返す部分はアロー関数ではなく function ()
を使うことをおすすめします。
const checkParams = function(func) { // function() を使って関数を返す return function (...args) { if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } return func.apply(this, args); } }
const checkParams = (func) => { // function() を使って関数を返す return function (...args) { if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } return func.apply(this, args); } }
使用例
以下は上記で作成したデコレータの使用例です。rectangleArea
は長方形の面積を算出する関数です。この関数にデコレータ checkParams
で引数をチェックする機能を追加します。
rectangleArea
をデコレータ checkParams
に渡して拡張した関数 rectangleAreaCP
を作成します。
rectangleAreaCP
は rectangleArea
と使い方は同じです。2つの数値を渡すと乗算した結果を返します。但し、引数の数が rectangleArea
が期待する数(2つ)でない場合はエラーにします。
//長方形の面積を算出する関数 rectangleArea const rectangleArea = (length, width) => { return length * width ; } // 上記の関数をデコレータに渡して拡張したラッパーを作成 const rectangleAreaCP = checkParams(rectangleArea); console.log(rectangleAreaCP(4, 8)); //32 console.log(rectangleAreaCP(5, 7, 8)); //引数の数が正しくないのでエラーになる
考慮する点
拡張する内容や対象とする関数にもよりますが、オブジェクトのメソッドなどを対象とするデコレータの作成では以下を考慮する必要があるかも知れません。
- 関数を返す際はアロー関数ではなく
function()
を使う(または以下で対応) - 受け取った関数の呼び出しでは apply や call を使ってコンテキスト(
this
)を渡す
例えば前述の例の場合、トップレベルの関数だけを対象にする場合は以下のように記述しても動作します。
const checkParams = (func) => { // アロー関数を使用 return (...args) => { if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //受け取った関数をそのまま呼び出す return func(...args); } } const rectangleArea = (length, width) => { return length * width ; } const rectangleAreaCP = checkParams(rectangleArea); console.log(rectangleAreaCP(4, 8)); //32 console.log(rectangleAreaCP(5, 7, 8)); //引数の数が正しくないのでエラーになる
但し、上記のデコレータはプロパティを参照している(this
を使用している)オブジェクトのメソッドなどでは期待通りに動作しない可能性があります。
以下は面積を返すだけでなく、オブジェクトのプロパティを出力する foo
オブジェクトのメソッドにデコレータを使用する例です。
この場合、ラッパーを実行すると foo
オブジェクトのプロパティ this.ver
が参照できない(this
はグローバルオブジェクトを参照する)ため Window
のプロパティを探しますが見つからないので undefined
になります。
const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { console.log('version: ' + this.ver); return length * width ; } } const checkParams = (func) => { // アロー関数を使用 return (...args) => { //console.log(this); //this は Window オブジェクト if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //受け取った関数をそのまま呼び出す return func(...args); } } // foo のメソッド rectangleArea をデコレータで拡張(ラッパーを作成) foo.rectangleArea = checkParams(foo.rectangleArea); // foo のメソッドとしてラッパーを実行 console.log(foo.rectangleArea(5,10)); // version: undefined (改行)50
use strict
を指定すると、this
が undefined
になるので以下のようにエラーになってしまいます。
最初に定義したデコレータに戻して、以下のように実行すればオブジェクトのプロパティも参照でき、正常に動作します。以下では this
の値を確認するための出力の記述を二箇所に追加しています。
"use strict"; const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { // this を出力 console.log('method this: ', this); // this は foo console.log('version: ' + this.ver); return length * width ; } } const checkParams = (func) => { return function (...args) { // this を出力 console.log('Decorator this: ', this); // this は foo if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } return func.apply(this, args); } } foo.rectangleArea = checkParams(foo.rectangleArea); console.log(foo.rectangleArea(5,10)); //version: 0.1 (改行)50
this
の値を確認するための出力では、{ver: '0.1', rectangleArea: ƒ}
(foo オブジェクト)になっているのが確認できます。
以下では、もう少し詳しく見ていきます。
アロー関数
以下は、前述のデコレータ checkParams
で、関数(ラッパー)を返す際にアロー関数を使用した場合の this
を確認する例です。
トップレベルで定義した関数をデコレータに渡す場合は問題ありませんが、オブジェクトのメソッドで this
を参照している場合は this
を参照できません。
アロー関数は this
を持っていないため、もし this
がアクセスされた場合は、外側のスコープを探索します。そのため、この場合、this
はグローバルオブジェクトの Window
になります。
use strict
を指定してもエラーにはなりませんが、foo
オブジェクトのプロパティ ver
を参照できず、undefined
になります(Window
に同じ名前のプロパティがあれば、そのプロパティが参照されます)。
"use strict"; const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { console.log('method this: ', this); // this の確認→ Window console.log('version: ' + this.ver); return length * width ; } } const checkParams = (func) => { // アロー関数 return (...args) => { console.log('Decorator this: ', this); // this の確認→ Window if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } return func.apply(this, args); } } foo.rectangleArea = checkParams(foo.rectangleArea); console.log(foo.rectangleArea(5,10)); //version: undefined (改行)50
デコレータに渡す関数に bind() を使う
以下のようにデコレータに渡す関数に bind() を使って this
を指定すれば正しく動作します。
"use strict"; const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { console.log('method this: ', this); // this の確認→ Window console.log('version: ' + this.ver); return length * width ; } } const checkParams = (func) => { // アロー関数 return (...args) => { console.log('Decorator this: ', this); // this の確認→ foo if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } return func.apply(this, args); } } //指定する関数に bind() でコンテキストをバインド foo.rectangleArea = checkParams(foo.rectangleArea.bind(foo)); console.log(foo.rectangleArea(5,10)); //version: 0.1 (改行)50
この場合、返す関数(ラッパー)の this
はグローバルオブジェクトの Window
になりますが、呼び出された元の関数の this
はバインドされたコンテキスト(この場合は foo
)になります。
デコレータにコンテキストを渡す
または、デコレータが受け取る引数にコンテキストを追加して、this
としたいコンテキストを指定すれば正しく動作します。以下ではデフォルト引数を使って、省略された場合は this
が適用されるようにしています。
"use strict"; const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { console.log('method this: ', this); // this の確認→ Window console.log('version: ' + this.ver); return length * width ; } } // 引数を追加してコンテキストを受け取る const checkParams = (func, context = this) => { // アロー関数 return (...args) => { console.log('Decorator this: ', this); // this の確認→ foo if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //引数で受け取ったコンテキストを第一引数に指定 return func.apply(context, args); } } //デコレータの第二引数にコンテキストを渡す foo.rectangleArea = checkParams(foo.rectangleArea, foo); console.log(foo.rectangleArea(5,10)); //version: 0.1 (改行)50
この場合も、返す関数(ラッパー)の this
はグローバルオブジェクトの Window
になりますが、呼び出された元の関数の this
は指定されたコンテキスト(この場合は foo
)になります。
apply()
以下は、ラッパー関数を返す際、apply() を使わずに、受け取った関数をそのまま呼び出す場合の例です。
オブジェクトのメソッドでない場合は問題ありませんが、デコレータにオブジェクトのメソッドを渡すと呼び出されたメソッドでは this
が undefined
になりエラーになります。
"use strict"; //ストリクトモード const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { console.log('method this: ', this); // this の確認→ undefined console.log('version: ' + this.ver); return length * width ; } } const checkParams = (func) => { // 関数式 function() を使う return function (...args) { console.log('Decorator this: ', this); // this の確認→ foo if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //受け取った関数をそのまま呼び出す return func(...args); } } foo.rectangleArea = checkParams(foo.rectangleArea); console.log(foo.rectangleArea(5,10)); //エラー
ストリクトモードでない場合は、this
はグローバルオブジェクトで置き換えられるのでエラーにはなりませんが、以下のように値は undefined
になります。
apply() の第一引数に null を渡す
this
の値が不要な場合は apply()
の第一引数に null
を渡すことができます。
デコレータの対象とする関数で this
を参照する必要がない(関数を直接呼び出せばよい)場合、apply()
の第一引数に null
を渡して return func.apply(null, args)
とすることができます。
但し、この例のメソッドの場合は、apply()
の第一引数に null
を渡すと正しく動作しません。
"use strict"; //ストリクトモード const foo = { ver: '0.1', //プロパティを参照する(this を使う)オブジェクトのメソッド rectangleArea (length, width) { console.log('method this: ', this); // this の確認→ undefined console.log('version: ' + this.ver); return length * width ; } } const checkParams = (func) => { return function (...args) { console.log('Decorator this: ', this); // this の確認→ foo if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //apply() の第一引数に null を指定(この場合はエラーになる) return func.apply(null, args); } } foo.rectangleArea = checkParams(foo.rectangleArea); console.log(foo.rectangleArea(5,10)); //エラー
apply() の第一引数に実行時のコンテキストを渡す
apply() の第一引数に this
を指定するのではなく、以下のように実行時のコンテキストを渡すこともできます。ほとんどの場合、this
を指定したのと同じになると思いますが、場合によっては有効な方法かもしれません(この例の場合は this
を指定するのと同様、動作します)。
const checkParams = (func) => { return function (...args) { // この時の this を変数 context に格納 const context = this; if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //上記で変数 context に格納した this(コンテキスト)を指定 return func.apply(context, args); } }
call()
call() と apply() の違いは呼び出す関数の引数の指定方法(第二引数)だけなので、関数の受け取る引数によっては call()
を使うこともできます。
call()
は第二引数には引数のリストを受け取り、apply()
は配列または配列のようなオブジェクトを受け取ります。
前述の例を call()
を使って書き換えると以下のようになります。この例の場合、引数を Rest パラメータ(...args
)で受け取っているので、args
を変換するためにスプレッド構文(...
)を使用しています。
const checkParams = (func) => { return function (...args) { console.log('Decorator this: ', this); // this の確認 if(args.length !== func.length) { throw new Error(`${func.name} の引数の数が正しくありません`); } //apply() の代わりに call() を使用。第二引数はリストなのでスプレッド演算子を使用 return func.call(this, ...args); } }
但し、この例の場合のように可変長の引数(Rest パラメータ)を取る場合や期待する引数が配列や配列のようなオブジェクトの場合は、apply()
のほうがスプレッド構文の処理がないため効率的です。
デコレータのサンプル
簡単なデコレータのサンプルとその使用例です。
遅延デコレータ
指定した関数の実行を遅延させるデコレータの例です。引数に関数と遅延時間(ミリ秒)を受け取り、ラッパーを返します。
//遅延デコレータ(関数と遅延時間を受け取る) const delay = (fn, ms) => { return function(...args) { setTimeout(() => { // ms で指定された時間後に関数を返す fn.apply(this, args); }, ms); }; } // 指定された数値を乗算した結果を返す関数 const logMultiply = (...args) => { console.log(args.reduce((a, b) => a * b)) ; } // 関数 logMultiply のラッパーを作成 const dLM1000 = delay(logMultiply, 1000); // 実行 dLM1000(3,4,5); //60 (1秒後) // メソッドを持つオブジェクト const bar = { ver: '0.12', logMultiply (...args) { console.log('version: ' + this.ver); console.log(args.reduce((a, b) => a * b)); } } // オブジェクトのメソッド bar.logMultipl のラッパーを作成 bar.dLM1000 = delay(bar.logMultiply, 2000); // 実行 bar.dLM1000(5,5,5,5); //version: 0.12(改行)625 (2秒後)
複数のデコレータの使用
必要に応じて、複数のデコレータを組み合わせることができます。
以下は前述の例に、関数の引数を検証するデコレータを追加して、2つのデコレータを組み合わせて利用する例です。
//引数が整数かをチェックするデコレータ const validateArgs = (fn) => { return function (...args) { //every() で引数の各要素が整数であるかを検証 const validArgs = args.every(arg => Number.isInteger(arg)); if (!validArgs) { //引数に指定された値が整数でなければエラーをスロー throw new TypeError('引数には整数を指定します'); } //全て整数であれば関数を返す return fn.apply(this, args); } } // 遅延デコレータ const delay = (fn, ms) => { return function(...args) { setTimeout(() => { fn.apply(this, args); }, ms); }; } // 指定された数値を乗算した結果を返す関数 const logMultiply = (...args) => { console.log(args.reduce((a, b) => a * b)) ; } // 引数が整数かをチェックするデコレータで拡張したラッパー const logMultiplyValidArgs = validateArgs(logMultiply); // 上記を更に遅延デコレータで拡張拡張したラッパー const dLogMultiplyValidArgs = delay(logMultiplyValidArgs, 1000) // 実行 dLogMultiplyValidArgs(1,2,3); //6 (1秒後) // 実行(引数が整数ではないのでエラー) dLogMultiplyValidArgs(1,2,3.5) //Uncaught TypeError: 引数には整数を指定します (1秒後)
Debounce デコレータ
Debounce は、一定期間にコールバック関数が呼び出される回数を制限するための仕組みの1つで、不必要な関数の実行(呼び出し)を制御することができます。
例えば、リサイズイベントやスクロールイベントなど連続して発生する頻度の高いイベントで、イベントの発生が停止した時にコールバック関数を呼び出す場合などに利用します。
以下はリサイズイベントでブラウザの幅を検出して出力する例ですが、Debounce を使わない場合はブラウザの幅を動かすと連続して幅の値が変わりますが、Debounce を使う場合は操作が終了すると幅の値が更新されます。
Debounce | window width |
---|---|
なしの場合 | px |
ありの場合 | px |
関連ページ:Debounce と Throttle(JavaScript)
以下はコールバック関数を一定時間待機させてから呼び出す Debounce デコレータの例です。
このデコレータで返されるラッパーは、まず、clearTimeout(timer)
でタイマーを解除して引数で指定された関数(func
)の実行のスケジュールをキャンセルします(初回の呼び出しの場合は、まだ何もスケジュールされていないので何もキャンセルされません)。
続いて setTimeout() で引数の timeout
で指定された時間が経過したら func
を呼び出すようにタイマーをセットします。
タイマーが完了する前に再度呼び出された場合は、タイマーが解除後再度セットして、func
は指定された時間 timeout
が経過するまで待機状態になります。
タイマーが完了すれば、func.apply(this, args)
で func
が呼び出され実行されます。
また、引数の timeout
にはデフォルト引数を使って 200
を指定しています。
const debounce = (func, timeout = 200) => { // タイマーID let timer; // 受け取った関数をラップして返す(ラッパーを返す) return function (...args) { // タイマーを解除 clearTimeout(timer); // タイマーをセット(func をスケジュール) timer = setTimeout(() => { //timeout で指定された時間が経過したら func を呼び出す func.apply(this, args); }, timeout); } }
以下は上記で定義した debounce デコレータを resize
イベントで利用する例です。以下の場合、ユーザがブラウザの幅を変更している間は幅の値は更新されませんが、操作を停止して 300ms 経過すると幅の値を更新します。
<p id="width-value"></p>
//ブラウザの幅を出力する要素(出力先) const widthValue = document.getElementById('width-value'); //現在のブラウザの幅を初期値として出力 widthValue.textContent = window.innerWidth; //幅の値を出力するコールバック関数 const callback = (e) => { widthValue.textContent = e.currentTarget.innerWidth; } //リサイズイベントのリスナーに debounce デコレータを適用したコールバックを指定 window.addEventListener('resize',debounce(callback, 300));
上記ではコールバックを別途定義していますが、以下のようにリスナーに直接記述することもできます。
window.addEventListener('resize',debounce( (e) => { widthValue.textContent = e.currentTarget.innerWidth; }, 300));
以下はオブジェクトのメソッドをコールバック関数として指定する場合の例です。
オブジェクトのメソッドでは this を使ってプロパティを参照し、その値をコンソールに出力します。
const debounce = (func, timeout = 200) => { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => { // this の確認用の出力 console.log('Decorator this: ', this); // Window func.apply(this, args); }, timeout); } } // オブジェクト const bar = { ver: '0.2', // コールバック関数 callback(e) { // this の確認用の出力 console.log('method this: ', this); // Window console.log('width: ', e.currentTarget.innerWidth); console.log('ver: ', this.ver); } } // オブジェクト bar のメソッドに debounce を適用して指定 window.addEventListener('resize',debounce(bar.callback, 300));
この場合、window
のリサイズイベントのリスナーなのでコールバック関数の this
は window
になるため、オブジェクトのプロパティを参照できず、undefined
になります。
以下のように、bind() を使って this
にオブジェクト bar
をバインドすれば、オブジェクトのプロパティを参照することができます。
window.addEventListener('resize',debounce(bar.callback.bind(bar), 300));
結果を返すデコレータ
以下は fetch() を使って Json データを取得する関数を渡すと、データの取得にかかった時間をコンソールに出力するデコレータです。
この場合、デコレータでは渡された関数を使ってデータ(結果)を取得するので、デコレータでは関数を返すのではなく、結果を返しています。
fetch()
を使って Json データを取得する関数は非同期(async
)なので、デコレータもラッパーも非同期関数になります。
また、デコレータの戻り値のラッパーの実行は非同期関数 logRequestData にまとめています。
// fetch() を使って Json データを取得する関数 let requestJsonData = async (url) => { try { // レスポンスを取得(await で fetch() が完了するまで待つ) const response = await fetch(url); // Json データを取得(await で json() が完了するまで待つ) const data = await response.json(); return data; } catch(err) { // エラーの場合はコンソールにエラーの内容を出力 console.error(err); } } // 非同期関数のデータの取得時間を出力するデコレータ const logResponseTime = (fn) => { // 非同期関数として返す return async (url) => { // data の取得開始時のタイムスタンプ const start = performance.now(); // 指定された関数を呼び出してデータを取得 const data = await fn(url); // data の取得にかかった時間を出力 console.log(`Elapsed: ${performance.now() - start} ms`) // この場合は、関数ではなく結果を返す return data; } } //取得したデータとその取得時間を出力する非同期関数 const logRequestData = async (url) => { // 関数 requestJsonData をデコレータでラップ requestJsonData = logResponseTime(requestJsonData); // ラッパーを呼び出す const data = await requestJsonData(url); // 取得したデータをコンソールに出力 console.log(data); } // 上記を実行 logRequestData('https://jsonplaceholder.typicode.com/users');
上記ではデータの取得に要する時間を performance.now() で取得していますが、以下は console.time() と console.timeEnd() を使って出力する例です。この場合、console.time()
と console.timeEnd()
には識別子として同じ文字列を指定します。
const logResponseTime = (fn) => { return async (url) => { // タイマーを開始 console.time('Elapsed: '); // 指定された関数を呼び出してデータを取得 const data = await fn(url); // タイマーを終了して data の取得にかかった時間を出力 console.timeEnd('Elapsed: '); // 関数ではなく結果を返す return data; } }
参考サイト
参考にさせていただいたサイトやページ