54
41

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【JavaScript/Node.js】クラスに定義したメソッドをコールバックとして実行した際にコールバック内の this がクラスを参照しない件

Last updated at Posted at 2019-03-11

JavaScript におけるthisがコンテキストによって参照するものが異なるということはこの言語のハマり所としてよく知られていると思います。
今回は Node.js で クラスに定義したプロトタイプメソッド1 を同クラスの別のメソッドでコールバックとして実行した際に、コールバック内の this の挙動が想定通りの挙動にならず悩まされたので対処方法と原因を調査しました。

もし誤りがあればコメント欄などでご指摘いただければ幸いです。

前提

クラス記法を使用するため、バージョンは ES6 を想定しています。

以下のSampleクラスを例とします。

class Sample {
  constructor(prop) {
    this.prop = prop;
  }

  /**
   * `prop`の値をコンソールに出力する
   */
  printProp() {
    console.log(this.prop);
  }

  /**
   * `printProp`をコールバックとして実行する
   */
  printPropAsCallback() {
    this.executeCallback(this.printProp);
  }

  /**
   * 引数で渡されたコールバック関数を実行する
   */
  executeCallback(callback) {
    callback();
  }
}

const sample = new Sample("Sample Class");

sample.printProp(); // => ”Sample Class”と表示される

sample.printPropAsCallback(); // => TypeError: Cannot read property 'prop' of undefined

Sampleクラスのインスタンスを生成しprintPropを実行するとコンソールにpropの値であるSample Classが表示されます。
一方、printPropAsCallbackを実行した場合、printPropexecuteCallbackのコールバックとして実行されて上記と同じくSample Classと表示されることを期待しますが、エラーとなってしまいます。

解決方法

解決方法としては以下の 2 つがあります。

  1. コールバックとして引数に渡す関数内で参照されるthisbindメソッドで指定する
  2. callまたはapplyメソッドを使用して、コールバックとして渡された関数のthisを指定して実行する
  3. (3/13 追記) printPropをアロー関数でラップしてコールバックとして渡す。

1. コールバックとして引数に渡す関数内で参照されるthisbindメソッドで指定する

1 つ目の解決方法はbind()を使用して this を指定した上で引数に渡す方法です。
bindメソッドは、任意の関数につなげて実行すると、bindの第一引数で指定したオブジェクトをthisとして参照する関数を新たに生成します。

class Sample {
  constructor(prop) {
    this.prop = prop;
  }

  /**
   * `prop`の値をコンソールに出力する
   */
  printProp() {
    console.log(this.prop);
  }

  /**
   * `printProp`をコールバックとして実行する
   */
  printPropAsCallback() {
    // this.printProp を this.printProp.bind(this) に変更
    this.executeCallback(this.printProp.bind(this));
  }

  /**
   * 引数で渡されたコールバック関数を実行する
   */
  executeCallback(callback) {
    callback();
  }
}

const sample = new Sample("Sample Class");

sample.printProp(); // => ”Sample Class”と表示される

sample.printPropAsCallback(); // => ”Sample Class”と表示される

上記の例ではbindメソッドの第一引数にSampleクラスのインスタンスを指すthisを渡しています。
これによってexecuteCallback内でprintPropがコールバックとして実行された際、printProp内部ではthisSampleクラスを指すようになります。

2. callまたはapplyメソッドを使用して、コールバックとして渡された関数のthisを指定して実行する

2 つ目の解決方法は、call()またはapply()により関数内の this の参照を指定した上でコールバックを実行する方法です。
callおよびapplyメソッドは任意の関数につなげて実行すると、call/applyの第一引数で指定したオブジェクトをthisの参照として関数を実行します2

class Sample {
  constructor(prop) {
    this.prop = prop;
  }

  /**
   * `prop`の値をコンソールに出力する
   */
  printProp() {
    console.log(this.prop);
  }

  /**
   * `printProp`をコールバックとして実行する
   */
  printPropAsCallback() {
    this.executeCallback(this.printProp);
  }

  /**
   * 引数で渡されたコールバック関数を実行する
   */
  executeCallback(callback) {
    // callback(); を callback.call(this); に変更
    callback.call(this);
  }
}

const sample = new Sample("Sample Class");

sample.printProp(); // => ”Sample Class”と表示される

sample.printPropAsCallback(); // => ”Sample Class”と表示される

上記例ではcallメソッドの第一引数に、bindの時と同じくthisを渡しています。
これによってexecuteCallback関数に渡された関数の内部で this はSampleクラスを参照するよう指定されているので、printPropAsCallback実行時にprintProp内でSampleクラスのプロパティであるpropの値を取得して出力します。

(3/13 追記) printPropをアロー関数でラップしてコールバックとして渡す。

@htsign さんのコメントにある通り、アロー関数にラップして渡す方法でもthisがクラスを参照するようにすることが可能です。

class Sample {
  constructor(prop) {
    this.prop = prop;
  }

  /**
   * `prop`の値をコンソールに出力する
   */
  printProp() {
    console.log(this.prop);
  }

  /**
   * `printProp`をコールバックとして実行する
   */
  printPropAsCallback() {
    // this.printProp を () => this.printProp() に変更
    this.executeCallback(() => this.printProp());
  }

  /**
   * 引数で渡されたコールバック関数を実行する
   */
  executeCallback(callback) {
    callback();
  }
}

const sample = new Sample("Sample Class");

sample.printProp(); // => ”Sample Class”と表示される

sample.printPropAsCallback(); // => ”Sample Class”と表示される

そもそもなぜこの事象が発生するのか

プロトタイプメソッドを直接実行した際にメソッド内のthisがクラスを参照するため、コールバックとして実行した際にも同じようにthisがクラスを参照してくれるように思えますが、実際の挙動は両者で異なりました。
原因としては以下の要素が関わっています。

  1. 関数の呼び出し方による関数内のthisの参照
  2. strict モードと非 strict モードにおける関数内のthisの参照

1. 関数の呼び出し方による関数内のthisの参照

JavaScript では、関数呼び出し方によってその関数内のthisの参照先が決定されます。

以下 MDN: this - オブジェクトのメソッドとして より引用

関数がオブジェクトのメソッドとして呼び出されるとき、その this にはメソッドが呼び出されたオブジェクトが設定されます。
次の例では、o.f() が起動したとき、関数内の this には、o オブジェクトが関連付けられます。

var o = {
  prop: 37,
  f: function() {
    return this.prop;
  }
};
console.log(o.f()); // logs 37

本記事の例でいうと、sample.printProp();のように、sampleのメソッドとしてprintPropを直接呼び出した場合は、printProp内のthisは、このメソッドが呼び出されたオブジェクトであるsampleを指します。
そのため、printPropsampleのプロパティであるpropを参照し、その値をコンソールに出力します。

2. strict モードと非 strict モードにおける関数内のthisの参照

一方、通常の関数呼び出し時にはthisの値がセットされません。
その場合には、非 strict モードの場合はグローバルオブジェクト(ブラウザではwindow, Node.js ではglobal)、strict モードの場合はundefinedthisとして設定されます。(以下 MDN: this - 単純な呼び出し より)

function f1() {
  return this;
}

// ブラウザー上で
f1() === window; // true

// Node 上で
f1() === global; // true
function f2() {
  "use strict"; // strict モードを見てください。
  return this;
}

f2() === undefined; // true

ここで、クラス内部のコードは常に Strict モードで実行されるという仕様があります。(以下MDN: クラス - プロトタイプと静的メソッドによるボクシングより)

this に値が付けられずに静的メソッドまたはプロトタイプメソッドが呼ばれると、this の値はメソッド内で undefined になります。たとえ "use strict" ディレクティブがなくても同じふるまいになります。なぜなら、class 本体の中のコードは常に Strict モードで実行されるからです

本記事の初めの例では、executeCallbackは引数に渡された関数callbackを実行していますが、
callbackという値はexecuteCallbackから見たらただの関数なので、たとえクラス内のメソッドを引数として渡したとしても、渡した関数内のthisの参照は保持されません。
また、クラス内部のコードに常に strict モードが適用されることから、callback実行時にthisの参照はundefinedとなります。

つまり、printPropAsCallback実行時にexecuteCallback内部でコールバックとしてprintPropが実行された際には、printProp内部のthisundefinedとなっているため、undefinedのプロパティを参照しようとしてエラーが起きたということになります。

また、クラスをprototypeで定義した場合、非 strict モードではprintProp内部のthisはグローバルオブジェクトになります。

// Node.jsで実行

function Sample(prop) {
  // コンストラクタ
  // 引数で渡された値をpropに設定する
  this.prop = prop;
}

Sample.prototype.printProp = function() {
  console.log(this.prop);
};

Sample.prototype.executeCallback = function(callback) {
  callback();
};

Sample.prototype.printPropAsCallback = function() {
  this.executeCallback(this.printProp);
};

const sample = new Sample("Sample Class");

sample.printProp(); // => ”Sample Class”と表示される

global.prop = "Global Object"; // グローバルオブジェクトにpropプロパティを定義
sample.printPropAsCallback(); // => "Global Object"と表示される

まとめ

  • 関数内のthisの参照は実行時のコンテクストによって決定する
    • 実行される関数がオブジェクトのメソッドである場合、実行されたオブジェクトがthisになる
    • そうでない場合には、strict モードと非 strict モードで動作が異なる。
      • 非 strict モードではグローバルオブジェクト (ブラウザではwindow, Node.js ではglobal)
      • strict モードではundefined
  • プロトタイプメソッドをコールバックとして実行する際にはthisの参照は保持されない
  • そのため以下の方法で関数内のthisの値を明示的に指定する必要がある
    • コールバックとして渡す関数のthisbindメソッドで指定する
    • call, applyを使用して関数内のthisを指定した上でコールバックを実行する

思ったより非常に高度な内容だったので、繰り返しにはなりますが、誤りがありましたらご指摘いただければ幸いです。
長くなりましたが、ここまで読んでくださりありがとうございました。

  1. Java などのオブジェクト指向言語におけるインスタンスメソッド。JS のクラス記法はprototypeの糖衣構文なので、クラス記法で定義したメソッドもプロトタイプメソッドである。

  2. callapplyの違いは第 2 引数以降の形式が異なる点です。両メソッドは第 2 引数以降の値を関数の引数として実行します。本サンプルでは引数を使用しないためどちらの関数を使用しても結果は変わりません。ですがbindは実行時に新たな関数を生成するので、前述の 2 つの関数とは用途が異なります。

54
41
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
54
41

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?