📝

JavaScriptのthisキーワードを整理してみた

に公開1

https://adventar.org/calendars/11750

ポート株式会社、新卒1年目のruiy03です。現在、実務を通じて様々な技術をキャッチアップしています。その中で、JavaScriptのthisの挙動を勉強中につまずいたので、改めて調べてみました。

すると、ES6以降はthisを避けて書けるケースがかなり増えていて、Reactの関数コンポーネントやVue Composition APIなど、thisを使わない設計が主流になりつつあるようです。

とはいえ、ライブラリの内部コードを読んだり、少し複雑なDOM操作をVanilla JSで書いたり、あるいはクラス構文ベースのレガシーコードを改修する場面では、依然としてthisの理解が求められます。

うっかりハマりやすいthisの挙動について、改めて整理しておきます。

定義場所ではなく「呼び出し方」

JavaScriptのthisが他言語経験者を混乱させる最大の要因は、「どこに定義されたか」ではなく「どう呼び出されたか」によって値が決まる点です。

function showName() {
  console.log(this.name);
}

const tanaka = { name: "田中", showName };
const yamada = { name: "山田", showName };

tanaka.showName(); // "田中"
yamada.showName(); // "山田"

同じ関数でも、どのオブジェクトから呼んだかで結果が変わります。

4つのバインディングルール

複雑そうに見えますが、結局は4パターンです。優先順位が高い順に並べると:

  1. new 呼び出し → 新しく作られたオブジェクト
  2. call / apply / bind → 明示的に指定されたオブジェクト
  3. obj.method() 呼び出し → ドットの左側のオブジェクト
  4. 上記以外(単独呼び出し) → グローバルオブジェクト(非厳格モード)またはundefined(厳格モード)
const suzuki = { name: "鈴木" };

function greet() {
  console.log(this.name);
}

// ルール4: 関数としての単独呼び出し
// 現代の環境(Strict mode)なら undefined になりエラーになることが多い
greet();

// ルール3: メソッド呼び出し
// ドットの左側(suzuki)がthisになる
suzuki.greet = greet;
suzuki.greet(); // "鈴木"

// ルール2: callで明示的な指定
greet.call({ name: "佐藤" }); // "佐藤"

// ルール1: new 演算子
// 生成されるインスタンス自体がthisになる
function User(name) {
  this.name = name;
}
const yamamoto = new User("山本");
console.log(yamamoto.name); // "山本"

call / apply / bind

「どうしてもこのオブジェクトをthisとして扱いたい」という場合に使います。

function introduce(greeting) {
  console.log(`${greeting}${this.name}です`);
}

const member = { name: "伊藤" };

introduce.call(member, "こんにちは");   // "こんにちは、伊藤です"
introduce.apply(member, ["どうも"]);    // "どうも、伊藤です"

const boundIntro = introduce.bind(member);
boundIntro("やあ");                      // "やあ、伊藤です"

callapplyは即実行、bindは新しい関数を返します。callは引数を個別に、applyは配列で渡します。

コールバックでthisが消える問題

オブジェクトのメソッドをコールバック関数として渡すと、thisの情報が消失します。

const counter = {
  count: 0,
  increment() {
    this.count++;
    console.log(this.count);
  }
};

counter.increment(); // 1(これは動く)

// コールバックとして渡すと...
setTimeout(counter.increment, 100); // NaN

なぜ動かないのか?

setTimeout(counter.increment, 100)と書いたとき、渡しているのはincrementという関数そのものです。「counterのメソッドである」という情報は渡されません。そのため、setTimeoutの内部で実行されるときには「単なる関数」として実行され(ルール4)、thisundefined(またはグローバル)になってしまいます。

回避策

いくつか回避策がありますが、今はアロー関数を使うのが一般的です。

const obj = {
  name: "中村",

  // ES5以前: 変数にthisを退避
  greetOld() {
    var self = this;
    setTimeout(function() {
      console.log(self.name);
    }, 100);
  },

  // ES5: bind()でthisを固定した新しい関数を渡す
  greetBind() {
    setTimeout(function() {
      console.log(this.name);
    }.bind(this), 100);
  },

  // ES6以降: アロー関数を使う(推奨)
  greetArrow() {
    setTimeout(() => {
      console.log(this.name);
    }, 100);
  }
};

アロー関数のthisは特別

アロー関数には「自身のthisを持たない」という重要な特徴があります。アロー関数内のthisは、関数が定義されたスコープのthisを参照します(レキシカルスコープ)。

定義された場所のthisをそのまま引き継ぎ、後からcallbindで変えることもできません。

const timer = {
  seconds: 0,
  start() {
    // ここのthisはtimerオブジェクト

    setInterval(() => {
      // アロー関数なので、外側(startメソッド)のthisをそのまま使う
      this.seconds++;
      console.log(this.seconds);
    }, 1000);
  }
};

timer.start(); // 1, 2, 3, ... と正常に動作する

従来のfunctionだと、setIntervalのコールバック内でthiswindowundefinedになってしまう。アロー関数なら気にしなくていいです。

クラスとコンストラクター

コンストラクター

newで呼び出すと、thisは新しく作られるオブジェクトを指します。

function User(name) {
  this.name = name;
}

const sato = new User("佐藤");
console.log(sato.name); // "佐藤"

クラス

クラスのメソッド内では、thisはそのインスタンスを指します。一方、staticメソッド内では、クラス自体を指します。

class Counter {
  constructor(initial) {
    this.value = initial;
  }

  increment() {
    this.value++;
  }

  static create() {
    return new this(0); // thisはCounterクラス
  }
}

クラスでもコールバック問題は起きる

class Player {
  constructor(name) {
    this.name = name;
    this.score = 0;
  }

  // 通常のメソッド → コールバックに渡すとthisが消える
  addScore() {
    this.score += 100;
  }

  // アロー関数で定義 → thisが固定される
  addBonus = () => {
    this.score += 50;
  };
}

const p = new Player("田村");

// 通常メソッドは関数だけ渡すとthisが消える
setTimeout(p.addScore, 100); // エラーまたは意図しない動作

// アロー関数で定義したプロパティなら安全
setTimeout(p.addBonus, 100); // 正常動作

イベントハンドラとして渡すなら、アロー関数で定義するか、bindしておくのが安全です。

実際の活用例

Vanilla JS

DOMイベントハンドラー

addEventListenerに渡した通常の関数では、thisはイベントが発生した要素になります。

document.querySelectorAll(".tab").forEach(tab => {
  tab.addEventListener("click", function() {
    // thisはクリックされたtab要素
    this.classList.add("active");
  });
});

アロー関数を使う場合は、event.currentTargetで要素を取得します。

document.querySelectorAll(".tab").forEach(tab => {
  tab.addEventListener("click", (e) => {
    e.currentTarget.classList.add("active");
  });
});

メソッドチェーン

thisを返せばメソッドチェーンできます。

const calc = {
  value: 0,
  add(n) {
    this.value += n;
    return this;
  },
  multiply(n) {
    this.value *= n;
    return this;
  },
  result() {
    return this.value;
  }
};

const answer = calc.add(5).multiply(3).add(10).result();
console.log(answer); // 25

React

クラスコンポーネント(レガシー)

class Counter extends Component {
  state = { count: 0 };

  // アロー関数で定義してthisを固定
  handleClick = () => {
    this.setState({ count: this.state.count + 1 });
  };

  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

通常のメソッドで書くと、onClickに渡した時点でthisが消えます。アロー関数で定義するか、constructorbindする必要があります。

関数コンポーネント

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

Hooksの登場により、thisを意識する必要はなくなりました。

TypeScript

thisパラメータ

TypeScriptには、関数の第一引数にthisの型を指定できる機能があります(コンパイル後のJSからは消えます)。

function handleClick(this: HTMLButtonElement, e: MouseEvent) {
  console.log(this.textContent); // thisはHTMLButtonElement型
}

noImplicitThis

noImplicitThisthisの型注釈を必須にするコンパイラオプションです。strictオプションに含まれているため、strictを有効にすると自動的にtrueになります。

このオプションを有効にすると、thisの型が不明な場合にエラーになります。

function lengthOfDiagonal(): number {
  // エラー: thisの型が不明(any扱い)
  return (this.width ** 2 + this.height ** 2) ** (1 / 2);
}

thisパラメータで型を明示すれば解決できる:

type Area = {
  width: number;
  height: number;
  diagonal(): number;
};

function lengthOfDiagonal(this: Area): number {
  return (this.width ** 2 + this.height ** 2) ** (1 / 2);
}

一方、オブジェクトリテラルのメソッド内では、thisの型が推論されるためエラーにならない:

const user = {
  name: "田中",
  show() {
    console.log(this.name); // OK: thisの型が推論される
  }
};

まとめ

  • thisは「定義場所」ではなく「呼び出し方」で決まる
  • 基本優先順位は new > call/bind > obj.method > 単独
  • コールバックに渡すとthisが消える → アロー関数かbindで対処
  • アロー関数は外側のthisを引き継ぐ
  • モダンなフレームワークでは、thisの出番は減っている

補足

コンストラクターでオブジェクトをreturnした場合

コンストラクター内でオブジェクトを明示的にreturnすると、newで作られるオブジェクトの代わりにそちらが返されます。

function User(name) {
  this.name = name;
  return { name: "上書き" };
}

const user = new User("佐藤");
console.log(user.name); // "上書き"

配列メソッドのthisArg

filtermapforEachなどは、第2引数でthisを指定できます。

const rule = {
  min: 18,
  isAdult(user) {
    return user.age >= this.min;
  }
};

const users = [
  { name: "Aさん", age: 25 },
  { name: "Bさん", age: 16 }
];

// 第2引数でthisを指定
const adults = users.filter(rule.isAdult, rule);

今はアロー関数で書くことが多いですね。

const adults = users.filter(u => rule.isAdult(u));

参考資料

ポート株式会社 エンジニアブログ

Discussion

junerjuner

addEventListener に渡した通常の関数では、this はイベントが発生した要素になります。

アロー関数を使う場合は、event.currentTarget で要素を取得します。

currentTarget は イベントハンドラを登録した要素なので this に対比させるならば target ではないでしょうか?