👾

JavaScriptにおける関数宣言とアロー関数の使い分け

に公開
1

JavaScriptで関数を定義する際、functionキーワードによる関数宣言とconstを使ったアロー関数の2つの方法があります。本記事では、それぞれの特徴と使い分けについて解説します。

基本的な書き方の比較

function宣言

export function getPrismaClient(): PrismaClient {
  if (!prisma) {
    prisma = new PrismaClient();
  }
  return prisma;
}

アロー関数

export const getPrismaClient = (): PrismaClient => {
  if (!prisma) {
    prisma = new PrismaClient();
  }
  return prisma;
};

主な違いの比較表

特徴 function宣言 アロー関数 (const)
ホイスティング あり(宣言前でも呼び出し可能) なし(宣言後のみ呼び出し可能)
thisの扱い 呼び出し元に依存(実行時に決定) 自身のthisを持たず、外側のスコープのthisを参照
constructorとして使用 可能 不可能
型定義の書き方 シンプル やや冗長
可読性 高い(従来の関数らしい) モダンでコンパクト
argumentsオブジェクト 使用可能 使用不可(rest parametersを使う)

ホイスティングの違い

// function宣言:ファイルのどこからでも呼び出せる
console.log(add(2, 3)); // ✅ 5

export function add(a: number, b: number): number {
  return a + b;
}

// アロー関数:宣言前には呼び出せない
console.log(multiply(2, 3)); // ❌ ReferenceError

export const multiply = (a: number, b: number): number => {
  return a * b;
};

thisの挙動の違い

基本的な動作例

class Counter {
  count = 0;

  // function宣言:thisは呼び出し元に依存
  incrementFunction() {
    this.count++;
  }

  // アロー関数:自身のthisを持たず、外側のスコープ(Counterクラス)のthisを参照
  incrementArrow = () => {
    this.count++;
  };
}

// ✅ Promiseベースのdelay関数を定義
const delay = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

(async () => {
  const counter = new Counter();

  // ---- 直接呼び出し ----
  counter.incrementFunction();
  console.log("1:Counter count:", counter.count); // 1
  counter.incrementArrow();
  console.log("2:Counter count:", counter.count); // 2

  // ---- コールバックとして渡す ----
  // function版: thisがundefinedになる
  await delay(100);
  setTimeout(counter.incrementFunction, 0); 
  await delay(50); // 少し待ってログ出力
  console.log("3:Counter count:", counter.count); // 2

  // arrow版: 外側のスコープ(Counter)のthisを参照するため正常に動作
  await delay(100);
  setTimeout(counter.incrementArrow, 0);
  await delay(50);
  console.log("4:Counter count:", counter.count); // 3
})();

function宣言だとコールバックでthisが失われてしまう
この理由を理解するには、「関数参照」と「メソッド呼び出し」の違いを知る必要があります。


補足:関数とメソッドの違い、そして()の利用

関数 vs メソッド

JavaScriptでは、同じ関数でも呼び出し方によって挙動が変わります。

function greet() {
  console.log(`Hi, I'm ${this?.name}`);
}

const user = {
  name: "Alice",
  greet  // プロパティとして登録
};

greet();      // thisはundefined(関数として呼び出し)
user.greet(); // thisはuser(メソッドとして呼び出し)
用語 定義 thisの扱い 呼び出し方
関数 オブジェクトに属さない独立した処理 呼び出し方によって決まる(多くの場合undefined fn()
メソッド オブジェクトのプロパティとして登録された関数 呼び出し元のオブジェクトがthisになる obj.fn()

()は「コンテキストの結合」

関数呼び出しの()は、単に「関数を実行する」だけでなく、**「実行コンテキスト(this)を結合して実行する」**という役割を持ちます。

const user = {
  name: "Bob",
  greet() {
    console.log(this.name);
  }
};

// メソッド呼び出し:thisが結合される
user.greet();  // "Bob" ← user.greet() の()が「thisをuserに結合」

// 関数参照を取り出す:thisの情報が失われる
const fn = user.greet;
fn();  // undefined ← fn()の()は「thisを結合できない」

コールバック関数でthisが失われる理由

setTimeout(counter.incrementFunction, 100);

このコードで何が起きているか、ステップごとに見てみましょう:

  1. counter.incrementFunction を評価 → 関数オブジェクトだけを取得counterとの結びつきは失われる)
  2. その関数をsetTimeoutに渡す
  3. 100ms後、setTimeout内部で callback() として呼び出される
  4. この時点で呼び出し元オブジェクトがないため、thisundefined
// setTimeoutの内部イメージ
function setTimeout(callback, delay) {
  // 指定時間後に...
  callback(); // ← 呼び出し元(レシーバ)がない!
}

解決方法

1. アロー関数でラップする

setTimeout(() => counter.incrementFunction(), 100);
// ラップ内で counter.incrementFunction() と呼ぶため、
// ()がthisをcounterに結合してくれる

2. bindで明示的に束縛する

setTimeout(counter.incrementFunction.bind(counter), 100);
// bindで「この関数のthisは常にcounter」と固定

3. 最初からアロー関数で定義する

class Counter {
  count = 0;
  incrementArrow = () => {
    this.count++;
  };
}
// アロー関数は自身のthisを持たないため、
// thisを参照すると外側のスコープ(この例ではCounterクラス)のthisを参照します

再度振り返り:なぜfunction宣言だけthisが失われるのか

// どちらも「関数参照」を渡している
setTimeout(counter.incrementFunction, 0);  // function版
setTimeout(counter.incrementArrow, 0);     // arrow版

どちらもcounter.incrementFunctioncounter.incrementArrowという関数オブジェクトへの参照setTimeoutに渡しています。この時点でメソッドとしての呼び出しコンテキスト(counter)は失われます

では、なぜ挙動が違うのか?違いは関数の定義時点にあります。

1. function宣言:thisは「実行時」に決まる

incrementFunction: function() {
  this.count++;  // このthisは「実行時」に決定される
}

2. アロー関数:「外側のスコープのthis」を参照する

incrementArrow: () => {
  this.count++;  // アロー関数は自身のthisを持たず、外側のスコープ(Counter)のthisを参照
}

コードで確認

class Counter {
  count = 0;

  incrementFunction = function() {
    console.log("function内のthis:", this);
    this.count++;
  };

  incrementArrow = () => {
    console.log("arrow内のthis:", this);
    this.count++;
  };
}

const counter = new Counter();

// 直接呼び出し(メソッドとして)
counter.incrementFunction();  // this = counter ✓
counter.incrementArrow();     // this = counter ✓

// 関数参照として取り出す
const funcRef = counter.incrementFunction;
const arrowRef = counter.incrementArrow;

// 関数として呼び出し
funcRef();   // this = undefined ✗
arrowRef();  // this = counter ✓(外側のスコープのthisを参照)

まとめ:なぜfunction宣言だけthisが失われるか

  1. 両方とも関数参照になる - setTimeoutに渡す時点で、どちらも「関数オブジェクトの参照」として渡される
  2. function宣言 - thisは実行時に決まるため、callback()として呼ばれた時に呼び出し元がなくundefinedになる
  3. アロー関数 - 自身のthisを持たず、定義された場所の外側のスコープのthisを参照するため、関数参照として渡されても正しく動作する

つまり、関数参照として渡されるのは同じですが、thisの扱い方が異なるため、結果が変わるのです。

使い分けのガイドライン

function宣言を使うべきケース

  1. トップレベルのユーティリティ関数
// ✅ 推奨
export function formatDate(date: Date): string {
  return date.toISOString();
}

export async function fetchUserData(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
  1. 名前付きエクスポート関数
// ✅ シンプルで読みやすい
export function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}
  1. ホイスティングを活用したい場合
// ✅ 宣言前でも呼び出せる
const result = processData(data);

function processData(data: string): string {
  return data.trim().toLowerCase();
}

アロー関数を使うべきケース

  1. コールバック関数
// ✅ 推奨
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
const evens = numbers.filter(n => n % 2 === 0);
  1. 外側のスコープのthisを「参照」したい場合
class DataProcessor {
  private prefix = 'data_';

  // ✅ アロー関数は自身のthisを持たず、外側のスコープ(DataProcessor)のthisを参照する
  formatItems = (items: string[]) => {
    return items.map(item => `${this.prefix}${item}`);
  };

  // イベントハンドラなどで有用
  handleClick = () => {
    console.log(this.prefix); // 常にインスタンスのthisを参照
  };
}
  1. 高階関数を返す場合
// ✅ クロージャーを活用
const createMultiplier = (factor: number) => {
  return (value: number) => value * factor;
};

const double = createMultiplier(2);
const triple = createMultiplier(3);
  1. 関数を変数として扱う場合
// ✅ 条件によって関数を切り替える
const logger = process.env.NODE_ENV === 'development'
  ? (msg: string) => console.log(`[DEV] ${msg}`)
  : (msg: string) => console.log(msg);

実践例:Prismaクライアントのシングルトン

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

// ✅ function宣言が適している
// - トップレベルのユーティリティ関数
// - thisを使わない
// - シンプルで読みやすい
export function getPrismaClient(): PrismaClient {
  if (!prisma) {
    prisma = new PrismaClient({
      log: process.env.NODE_ENV === 'development' 
        ? ['query', 'error', 'warn'] 
        : ['error'],
    });
  }
  return prisma;
}

export async function disconnectPrisma(): Promise<void> {
  if (prisma) {
    await prisma.$disconnect();
  }
}

この例では、以下の理由からfunction宣言が適しています:

  • ユーティリティ関数として利用される
  • thisを使用しない
  • 型定義がシンプルで読みやすい
  • モジュールレベルの関数として自然

実用的な使い分けパターン

// ✅ トップレベル関数:function宣言
export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// ✅ 配列操作:アロー関数
const users = await prisma.user.findMany();
const emails = users.map(user => user.email);
const activeUsers = users.filter(user => user.isActive);

// ✅ クラスメソッド(thisを使う):アロー関数
class UserService {
  private apiUrl = 'https://api.example.com';

  fetchUser = async (id: string) => {
    const response = await fetch(`${this.apiUrl}/users/${id}`);
    return response.json();
  };
}

// ✅ 非同期ユーティリティ:function宣言
export async function fetchWithRetry(
  url: string, 
  maxRetries = 3
): Promise<Response> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fetch(url);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
    }
  }
  throw new Error('Max retries reached');
}

まとめ

用途 推奨スタイル 理由
トップレベル関数 function宣言 シンプルで読みやすい
コールバック アロー関数 簡潔に書ける
クラスメソッド(thisを使う) ケースバイケース ※補足を参照
配列メソッド アロー関数 短く書ける
ユーティリティ関数 function宣言 型定義が明確
イベントハンドラ アロー関数 thisの問題を回避

基本的には、トップレベルの関数はfunction宣言コールバックやthisを扱う場合はアロー関数という使い分けをすると、コードの可読性と保守性が向上します。

ただし、最終的にはチームのコーディング規約に従うことが重要です。一貫性のあるコードスタイルを保つことで、チーム全体の開発効率が向上します。

よくある間違い

❌ 「アロー関数はthisを固定する」
✅ 「アロー関数は自身のthisを持たず、外側のthisを参照する」

補足

メモリ効率とthisの参照のトレードオフを考慮する

クラスメソッドを定義する際は、メモリ効率thisの安全性のバランスを考慮する必要があります。
上手くthisが取れないなどのバグを考慮するならアロー関数のが良いと思います。

アロー関数とfunctionメソッドの内部的な違い

定義方法 保存場所 関数オブジェクトの数
method() {} プロトタイプ(共有) 1個(全インスタンスで共有)
method = () => {} 各インスタンス N個(インスタンス数分)

なぜこの差が生まれるのか?

class Example {
  // function版: プロトタイプに1つだけ存在
  method1() { }
  
  // アロー関数版: 各インスタンスが独自の関数を持つ
  method2 = () => { }
}

// 内部的なイメージ
Example.prototype.method1 = function() { }  // ← 全インスタンスで共有

// インスタンス生成時に毎回以下が実行される
const instance = new Example();
instance.method2 = () => { }  // ← インスタンスごとに新しい関数が生成

パフォーマンスが問題になるケース

アロー関数はインスタンス毎に作成されるため、大量のインスタンスを生成するとメモリを圧迫します。
ただし、以下のような大規模なケースを除いて、アロー関数のメモリオーバーヘッドは通常問題になりません

  • ゲームエンジンで数万~のエンティティを扱う
  • リアルタイムデータ処理で大量のオブジェクトを生成
  • メモリ制限の厳しい環境(組み込みやIoT)

一般的なWebアプリケーションでは気にする必要はありませんが、大量のインスタンスを扱う場合やパフォーマンスを意識する場合は考慮する価値があります。

具体例:RPGゲームのキャラクタークラス

// 🎮 100万体のモンスターが登場するRPGゲームを想定

// ❌ 非効率:アロー関数をクラスプロパティとして定義
class Monster_Inefficient {
  name: string;
  hp = 100;
  
  // 各インスタンスごとに関数が生成される
  attack = () => {
    console.log(`${this.name} attacks!`);
    return Math.floor(Math.random() * 10) + 1;
  };
  
  takeDamage = (damage: number) => {
    this.hp -= damage;
    console.log(`${this.name} takes ${damage} damage!`);
  };
}

// ✅ 効率的:メソッドをプロトタイプに定義
class Monster_Efficient {
  name: string;
  hp = 100;
  
  constructor(name: string) {
    this.name = name;
  }
  
  // プロトタイプに1つだけ存在(全インスタンスで共有)
  attack() {
    console.log(`${this.name} attacks!`);
    return Math.floor(Math.random() * 10) + 1;
  }
  
  takeDamage(damage: number) {
    this.hp -= damage;
    console.log(`${this.name} takes ${damage} damage!`);
  }
}

// 実際のメモリ使用量の比較
// 100万個のインスタンスをそれぞれ作ってみる
const used1 = process.memoryUsage().heapUsed;
const arrows = Array.from({length:1000000},()=> new Monster_Inefficient());
const used2 = process.memoryUsage().heapUsed;
const functions = Array.from({length:1000000},()=> new Monster_Efficient("function"));
const used3 = process.memoryUsage().heapUsed;
console.log(`Arrow instances: ${((used2 - used1) / 1024 / 1024).toFixed(2)} MB`);
console.log(`Function instances: ${((used3 - used2) / 1024 / 1024).toFixed(2)} MB`);

// 結果例:
// Arrow instances: 212.90 MB
// Function instances: 46.24 MB

メモリ使用量の差は約5倍になりました。大規模なシステムでは、この差が積み重なって大きな影響を与える可能性があります。

じゃあクラスメソッドはメモリ効率意識してfunctionをつかうべきか?

結論: ケースバイケース
functionメソッドを使う場合、利用側で必ず()をつけて呼び出す(コンテキストの結合)ことを統一できるなら良い選択です。

class UserService {
  users: User[] = [];
  
  // functionメソッド
  getUser(id: string) {
    return this.users.find(u => u.id === id);
  }
}

const service = new UserService();

// ✅ 直接呼び出し: thisが正しく結合される
service.getUser('123');

// ❌ 関数参照として渡す: thisが失われる
const finder = service.getUser;
finder('123'); // エラー: thisがundefined

// ✅ 明示的にthisを結合
setTimeout(() => service.getUser('123'), 1000);

ドメイン駆動設計(DDD)の観点
ドメインオブジェクトのメソッドは「そのエンティティ自身の責務を表す」ため、基本的にthisを内部で使う前提です(ドメイン境界を揃えておくべき)。

そのため、アプリケーション層などでは「文脈ごと呼び出す = ()を使う」設計を統一することが重要になります。

補足まとめ

クラスメソッドを定義する際は、メモリ効率thisの安全性のバランスを考慮して判断しましょう。
最終的には、プロジェクトの規模・要件・チームの方針に応じて選択することが重要です。

Discussion

Hidden comment