30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React のクラスコンポーネントの bind は何を解決しているのか

クラスコンポーネントでイベントハンドラを設定する際に必要になる、constructorメソッドでのバインド。
そもそもこれはなぜ必要なのか。どのような動作をすることで、どのような問題を解決しているのか。
Hooks が登場したことで今後はクラスコンポーネントを書く機会は減っていくと思うが、これは React 固有の話ではなく JavaScript の言語仕様の話なので、理解しておくに越したことはない。

React のコードは React のv16.10.2で、それ以外のコードは Node.js のv12.12.0で動作確認している。

問題のおさらい

bindしないと何が起こるのか。

以下は、ボタンを押すたびにオンオフが切り替わるコンポーネント。

import React from 'react';

class App extends React.Component {
  constructor() {
    super();
    this.state = {isOn: true};
    this.toggle = this.toggle.bind(this); // この行をコメントアウトすると...?
  }

  toggle() {
    this.setState(state => ({
      isOn: !state.isOn,
    }));
  }

  render() {
    const {state, toggle} = this;

    return (
      <>
        <h1>{state.isOn ? 'ON' : 'OFF'}</h1>
        <button type="button" onClick={toggle}>
          click me
        </button>
      </>
    );
  }
}

export default App;

constructor内のthis.toggle = this.toggle.bind(this);をコメントアウトした状態でボタンを押すと、以下のエラーが出る。

Cannot read property 'setState' of undefined at toggle

toggle内のthisundefinedになってしまい、undefined.setStateにアクセスしたことでエラーになっている。

つまり、イベントハンドラに設定したメソッドのなかで記述したthisが、意図したものとは違うものを指してしまっている。bindを使うことでそれを回避している。

この現象を理解するために、まずクラスについて見ていく。
次に、thisを見失ってしまう問題と、それを解決するbindやアロー関数について説明する。

クラスにおける this と プロトタイプチェーン

クラス定義のなかで書かれたthisは、そのクラスのインスタンスを指す。
なのでthis.xと書けば、そのインスタンスが持っているプロパティxにアクセスできる。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
  }

  sum(arg) {
    return this.x.value + arg;
  }
}

const instanceFoo = new MyClass();
console.log(instanceFoo.x); // { value: 1 }
console.log(instanceFoo.sum(3)); // 4

constructorで定義したプロパティはインスタンスのプロパティである。
一方、メソッドについては通常、プロトタイプメソッドとして定義する。MyClassの場合はxはインスタンス毎に保持し、sumはプロトタイプに格納され、全てのインスタンスがそれを参照する。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
  }

  sum(arg) {
    return this.x.value + arg;
  }
}

const instanceFoo = new MyClass();
const instanceBar = new MyClass();

console.log(instanceFoo.x !== instanceBar.x); // true
console.log(instanceFoo.sum === instanceBar.sum); // true

console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x' ]
console.log(Object.getOwnPropertyNames(MyClass.prototype)); // [ 'constructor', 'sum' ]

このことはつまり、instanceFoo.sumを呼び出したときに、プロトタイプチェーンが発生していることを意味する。
そのため、constructorthis.sumを定義することでオーバーライドすることが可能である。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
    this.sum = () => 'override';
  }

  sum(arg) {
    return this.x.value + arg;
  }
}

const instanceFoo = new MyClass();
console.log(instanceFoo.sum(3)); // "override"

console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x', 'sum' ]

instanceFoo自身がsumを持っているためそれが実行され、MyClass.prototype.sumは実行されない。

関数を入れ子にすると this を見失ってしまう問題とその解決法

JavaScript のthisの闇の深さはよく知られているが、この記事を理解する上で重要なのは「関数を入れ子にするとthisの値が変わってしまう」ということである。

// 入れ子の this が何を指すかは実行環境による
// 以下は Node.js v12.12.0 の場合

global.x = 9;

const obj = {
  x: 1,
  func() {
    const nestFunc = function() {
      console.log(this.x);
    };

    console.log(this.x); // 1
    nestFunc(); // 9
  },
};

obj.func();

thisが何に変わってしまうかは実行環境によって異なるのだが、重要なのは、thisが変わってしまうということ。これによって様々な問題が発生しうることは容易に想像がつく。

この問題を解決するための代表的な手段として、bindとアロー関数がある。

bindFunction.prototypeのメソッドで、そのため全ての関数が使うことが出来る。

console.log(Function.prototype.hasOwnProperty('bind')); // true

関数を返す関数であり、第一引数に渡した値でthisを固定した関数を、返してくれる。

function showThis() {
  console.log(this);
}

showThis.bind(1)(); // [Number: 1]
showThis.bind({value: 5})(); // { value: 5 }

先程の入れ子のケースでは、入れ子になっていない(thisが本来の値を指している)場所でbind(this)とすることで、thisの値が変わってしまうのを防ぐことが出来る。

global.x = 9;

const obj = {
  x: 1,
  func() {
    const nestFunc = function() {
      console.log(this.x);
    };
    const bindedFunc = nestFunc.bind(this); // bind(this) の this は入れ子になっていないので obj を指す

    console.log(this.x); // 1
    nestFunc(); // 9
    bindedFunc(); // 1

    const nestObj = {
      x: 2,
      nestObjFunc() {
        console.log(this.x);
      },
      bindedFunc,
    };
    nestObj.nestObjFunc(); // 2
    nestObj.bindedFunc(); // 1
  },
};

obj.func();

アロー関数を使うことでも、thisを固定することが出来る。アロー関数のなかのthisは、定義したときの場所におけるthisで固定され、変化しない。

global.x = 9;

const obj = {
  x: 1,
  func() {
    const nestFunc = () => {
      console.log(this.x);
    };

    console.log(this.x); // 1
    nestFunc(); // 1

    const nestObj = {
      x: 2,
      nestObjFunc() {
        console.log(this.x);
      },
      nestFunc,
    };
    nestObj.nestObjFunc(); // 2
    nestObj.nestFunc(); // 1
  },
};

obj.func();

クラス定義での this の取り扱い

ここまでの内容を踏まえて、クラスにおけるthisの問題を見ていく。

既述の通りクラス定義のなかのthisはインスタンスを指すが、関数を入れ子にするとやはり、thisの値は変わってしまう。
先程の例ではグローバルオブジェクトに変化していたが、クラス定義ではundefinedに変化する。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
  }

  methodA(callback) {
    console.log(this);
    console.log(this instanceof MyClass);
    callback();
  }

  methodB() {
    console.log(this);
  }
}

const instanceFoo = new MyClass();

// MyClass { x: { value: 1 } }
// true
// undefined
instanceFoo.methodA(instanceFoo.methodB);

イベントハンドラに設定したメソッドのなかでthisundefinedになってしまうクラスコンポーネントの問題も、同じ原因で発生している。

簡易な疑似コンポーネントのようなコードでこの問題を表現すると、以下のようになる。

class ButtonClass {
  setState() {
    console.log('called setState');
  }

  click(callback) {
    // click イベントに共通の処理をここで行う
    // それ以外に、コールバックで指定された任意の処理を行う
    callback();
  }

  toggle() {
    console.log(this);
    try {
      this.setState();
    } catch (e) {
      console.log(e.message);
    }
  }

  render() {
    return {
      onClick: () => {
        this.click(this.toggle);
      },
    };
  }
}

const buttonInstance = new ButtonClass();
const button = buttonInstance.render();

// undefined
// "Cannot read property 'setState' of undefined"
button.onClick();

renderでオブジェクトを返すのだが、そのときにonClickメソッドに対してクリックイベントと、コールバックで実行したいメソッド(this.toggle)を設定している。
そのため、renderによって生成されたbuttononClickを実行すると、toggleメソッドが実行される。
だが、clickのなかでtoggleをコールバックで実行する(clicktoggleもアロー関数ではない)、という入れ子構造になってしまっているため、thisundefinedになってしまい、setStateへのアクセスに失敗してしまう。

そこで、bindとオーバーライドの出番となる。

constructor直下のthisはインスタンスを指しているので、そこでbind(this)を行えば、バインドされた関数のなかのthisはインスタンス変数を指すことになる。

  constructor() {
    function showThis() {
      console.log(this);
    }
    showThis(); // undefined

    console.log(this); // ButtonClass {}

    const bindedFunc = showThis.bind(this);
    bindedFunc(); // ButtonClass {}
  }

そして、バインドした関数でプロトタイプメソッドをオーバーライドしてしまえば、そのメソッドを使うときは必ずthisがインスタンス変数を指すようになる。

class ButtonClass {
  constructor() {
    const bindedFunc = this.toggle.bind(this);
    this.toggle = bindedFunc;
  }

  setState() {
    console.log('called setState');
  }

  click(callback) {
    // click イベントに共通の処理をここで行う
    // それ以外に、コールバックで指定された任意の処理を行う
    callback();
  }

  toggle() {
    console.log(this);
    try {
      this.setState();
    } catch (e) {
      console.log(e.message);
    }
  }

  render() {
    return {
      onClick: () => {
        this.click(this.toggle);
      },
    };
  }
}

const buttonInstance = new ButtonClass();
const button = buttonInstance.render();

// ButtonClass { toggle: [Function: bound toggle] }
// "called setState"
button.onClick();

バインドされた関数によるオーバーライド、を使わずとも、コールバック関数をアロー関数で渡すことでも解決できる。
ButtonClassの場合はrenderメソッドを以下のように書き換えればよい。
既に述べたように、アロー関数のなかのthisはその場所におけるthisで固定される。

  render() {
    return {
      onClick: () => {
        this.click(() => {
          this.toggle();
        });
      },
    };
  }

しかし実際に React のコンポーネントでこの手法を使うと、パフォーマンスに影響を与える可能性がある。
例えば冒頭のAppコンポーネントのrenderを以下のようにすれば、thisを見失う問題は解決し正しく動くが、Appがレンダリングされる度にコールバック関数が新しく作られてしまう。
この例では問題にならないが、このコールバック関数が子コンポーネントのpropsとして渡されている場合、不要な処理が行われてしまう可能性がある。

  render() {
    const {state} = this;

    return (
      <>
        <h1>{state.isOn ? 'ON' : 'OFF'}</h1>
        <button
          type="button"
          onClick={() => {
            this.toggle();
          }}
        >
          click me
        </button>
      </>
    );
  }

パブリッククラスフィールド

React の公式ドキュメントでは、アロー関数とbindによる解決の他に、パブリッククラスフィールドを用いた方法も紹介している。
イベント処理 – React

これは、現在策定中の新しい構文を利用したもの。
2019年11月現在でstage3なので、まだ正式に採用されているわけではない。
tc39/proposal-class-fields: Orthogonally-informed combination of public and private fields proposals

この構文を使うと、constructorを使わずにインスタンスのプロパティを定義できる。

class MyClass {
  x = {
    value: 1,
  };
}

const instanceFoo = new MyClass();
console.log(instanceFoo.x); // { value: 1 }
console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x' ]

関数も定義できる。その場合は当然、インスタンスのメソッドになる。

class MyClass {
  x = {
    value: 1,
  };

  sum = function(arg) {
    return this.x.value + arg;
  };
}

const instanceFoo = new MyClass();

console.log(instanceFoo.sum(3)); // 4

console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x', 'sum' ]
console.log(Object.getOwnPropertyNames(MyClass.prototype)); // [ 'constructor' ]

アロー関数を割り当てることも出来る。何度も繰り返しているように、アロー関数でthisを使った場合は定義時にその中身が決まるため、入れ子になっても値は変わらない。

class MyClass {
  x = {
    value: 1,
  };

  methodA(callback) {
    console.log(this);
    console.log(this instanceof MyClass);
    callback();
  }

  methodB = () => {
    console.log(this);
  };
}

const instanceFoo = new MyClass();

// MyClass { x: { value: 1 }, methodB: [Function: methodB] }
// true
// MyClass { x: { value: 1 }, methodB: [Function: methodB] }
instanceFoo.methodA(instanceFoo.methodB);

以下はこの構文を使ったクラスコンポーネント。
toggleをアロー関数で定義している。

import React from 'react';

class App extends React.Component {
  constructor() {
    super();
    this.state = {isOn: true};
  }

  toggle = () => {
    this.setState(state => ({
      isOn: !state.isOn,
    }));
  };

  render() {
    const {state, toggle} = this;

    return (
      <>
        <h1>{state.isOn ? 'ON' : 'OFF'}</h1>
        <button type="button" onClick={toggle}>
          click me
        </button>
      </>
    );
  }
}

export default App;

まだ策定中の構文のためトランスパイルしないと使えないはずなので、注意。

babeljs.io