2026年10月6日

2026年10月6日

WordPressのコメントに絵文字リアクション機能を実装する方法(REST API+nonce)

コメントごとに「👍」「❤️」などの絵文字ボタンを並べ、押した人数を数えるリアクション機能は、コメントメタとREST APIを組み合わせれば小さなコードで実装できます。この記事では、ログインユーザーが1コメントにつき各絵文字を1回だけ押せる仕組みを、nonce・権限チェック・入力検証・出力エスケープを省かずに作る手順を解説します。

実装の全体像と設計の考え方

最初に、どこに何を保存し、どう通信するかを決めておくと後の手戻りが減ります。この記事の構成は次のとおりです。

  • 保存先: コメントメタ _wpdr_reactions に「リアクションの種類 → 押したユーザーIDの配列」を保存する
  • 通信: 独自のREST APIエンドポイント(POST)を1本用意し、押すたびにオン/オフを切り替える
  • 表示: comment_text フィルターでコメント本文の下にボタンを追加する
  • 対象者: ログインユーザーのみ(未ログイン時はボタンを無効表示)

絵文字そのものではなく like や heart といったキーを保存するのがポイントです。WordPress のデータベースは utf8mb4 なら絵文字を保存できますが、許可するリアクションをキーで固定しておけば、想定外の文字列を送られても検証で弾けます。表示する絵文字は後から差し替えることもできます。

対象をログインユーザーに限るのは、重複押しを防ぐ基準(ユーザーID)がはっきりするためです。未ログインでも押せるようにすると、Cookie や IP アドレスで重複を判定することになり、連打やなりすましへの対策が一気に難しくなります。まずはログイン限定で作り、必要になってから広げるほうが安全です。

コメントの「いいね」や評価だけが目的なら、wpDiscuz などのコメント拡張プラグインにある投票機能で足りる場合もあります(機能の範囲や名称はバージョンで変わるため、導入前に公式の説明を確認してください)。自作するのは、見た目や挙動を自分で決めたいときです。

作業前の準備

コードはテーマの functions.php ではなく、mu-plugin(wp-content/mu-plugins/)に置きます。テーマを切り替えても機能が消えず、管理画面から誤って無効化されることもありません。ただし mu-plugin は読み込みエラーがあると全ページが止まるため、次の準備をしてから進めてください。

  • 本番ではなくステージング環境で動作を確かめる
  • 作業前にファイルとデータベースのバックアップを取る
  • FTP や SSH でファイルを削除・リネームできる状態にしておく(不具合時は mu-plugin のファイルを外せば元に戻ります)

ファイル構成は次の2つです。

wp-content/mu-plugins/wpdr-reactions.php
wp-content/mu-plugins/wpdr-reactions/reactions.js

手順1:リアクションの種類と保存形式を決める

許可するリアクションを1か所で定義し、表示と検証の両方から参照します。ラベルはスクリーンリーダー向けの読み上げにも使います。

<?php
/**
 * Plugin Name: WPDR Comment Reactions
 * Description: コメントに絵文字リアクションを追加する(mu-plugin)
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

function wpdr_reaction_types() {
	return array(
		'like'  => array( 'emoji' => '👍', 'label' => 'いいね' ),
		'heart' => array( 'emoji' => '❤️', 'label' => '好き' ),
		'laugh' => array( 'emoji' => '😄', 'label' => '笑った' ),
		'think' => array( 'emoji' => '🤔', 'label' => '考えさせられる' ),
	);
}

function wpdr_get_reactions( $comment_id ) {
	$data = get_comment_meta( $comment_id, '_wpdr_reactions', true );
	return is_array( $data ) ? $data : array();
}

function wpdr_count_reactions( array $data ) {
	$counts = array();
	foreach ( array_keys( wpdr_reaction_types() ) as $key ) {
		$counts[ $key ] = isset( $data[ $key ] ) ? count( (array) $data[ $key ] ) : 0;
	}
	return $counts;
}

メタキーの先頭を _ にすると、カスタムフィールド欄に表示されない保護メタとして扱われます。

手順2:REST APIエンドポイントを作る

押されたときの処理を REST API で受けます。register_rest_route() の permission_callback でログインを要求し、args で入力を検証・サニタイズします。

add_action( 'rest_api_init', function () {
	register_rest_route( 'wpdr/v1', '/reaction', array(
		'methods'             => 'POST',
		'callback'            => 'wpdr_toggle_reaction',
		'permission_callback' => function () {
			return is_user_logged_in();
		},
		'args'                => array(
			'comment_id' => array(
				'required'          => true,
				'validate_callback' => function ( $value ) {
					return is_numeric( $value ) && (int) $value > 0;
				},
				'sanitize_callback' => 'absint',
			),
			'reaction'   => array(
				'required'          => true,
				'validate_callback' => function ( $value ) {
					return is_string( $value ) && array_key_exists( $value, wpdr_reaction_types() );
				},
				'sanitize_callback' => 'sanitize_key',
			),
		),
	) );
} );

function wpdr_toggle_reaction( WP_REST_Request $request ) {
	$comment_id = (int) $request['comment_id'];
	$reaction   = $request['reaction'];
	$comment    = get_comment( $comment_id );

	if ( ! $comment || '1' !== (string) $comment->comment_approved ) {
		return new WP_Error( 'wpdr_not_found', 'コメントが見つかりません。', array( 'status' => 404 ) );
	}
	if ( 'publish' !== get_post_status( $comment->comment_post_ID ) ) {
		return new WP_Error( 'wpdr_forbidden', 'この投稿には反応できません。', array( 'status' => 403 ) );
	}

	// 簡易レート制限: 1ユーザーあたり1分間に30回まで
	$user_id = get_current_user_id();
	$rl_key  = 'wpdr_rl_' . $user_id;
	$tries   = (int) get_transient( $rl_key );
	if ( $tries >= 30 ) {
		return new WP_Error( 'wpdr_too_many', '操作が多すぎます。少し待ってから試してください。', array( 'status' => 429 ) );
	}
	set_transient( $rl_key, $tries + 1, MINUTE_IN_SECONDS );

	$data  = wpdr_get_reactions( $comment_id );
	$users = isset( $data[ $reaction ] ) ? array_map( 'intval', (array) $data[ $reaction ] ) : array();

	if ( in_array( $user_id, $users, true ) ) {
		$users  = array_values( array_diff( $users, array( $user_id ) ) );
		$active = false;
	} else {
		$users[] = $user_id;
		$active  = true;
	}
	$data[ $reaction ] = $users;
	update_comment_meta( $comment_id, '_wpdr_reactions', $data );

	return rest_ensure_response( array(
		'counts' => wpdr_count_reactions( $data ),
		'active' => $active,
	) );
}

ここで効いているのが nonce です。ログイン Cookie で REST API を呼ぶ場合、WordPress は X-WP-Nonce ヘッダーに wp_rest の nonce が付いていないリクエストを未ログイン扱いにします。そのため、他サイトから勝手に POST させる CSRF は is_user_logged_in() の時点で 401 になります。承認待ちのコメントや非公開投稿のコメントに反応できないよう、コメントの状態と投稿の状態も確かめています。

レート制限はトランジェントを使った簡易版です。オブジェクトキャッシュが無い環境では options テーブルに書き込まれるため、アクセスが多いサイトでは Redis などの永続オブジェクトキャッシュを併用してください。

手順3:コメント本文の下にボタンを表示する

comment_text フィルターは第2引数に WP_Comment を受け取れます。管理画面のコメント一覧や REST API の応答にボタンが混ざらないよう、フロント表示に限定します。出力する値はすべてエスケープします。

add_filter( 'comment_text', function ( $text, $comment = null ) {
	if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ! $comment instanceof WP_Comment ) {
		return $text;
	}
	$data    = wpdr_get_reactions( $comment->comment_ID );
	$user_id = get_current_user_id();

	$html = '<div class="wpdr-reactions" data-comment-id="' . esc_attr( $comment->comment_ID ) . '">';
	foreach ( wpdr_reaction_types() as $key => $type ) {
		$users  = isset( $data[ $key ] ) ? array_map( 'intval', (array) $data[ $key ] ) : array();
		$active = $user_id && in_array( $user_id, $users, true );
		$html  .= sprintf(
			'<button type="button" class="wpdr-reaction" data-reaction="%1$s" aria-pressed="%2$s" aria-label="%3$s"%4$s><span aria-hidden="true">%5$s</span> <span class="wpdr-count">%6$d</span></button>',
			esc_attr( $key ),
			$active ? 'true' : 'false',
			esc_attr( $type['label'] ),
			$user_id ? '' : ' disabled',
			esc_html( $type['emoji'] ),
			count( $users )
		);
	}
	if ( ! $user_id ) {
		$html .= '<span class="wpdr-note">' . esc_html( 'リアクションはログイン後に利用できます。' ) . '</span>';
	}
	$html .= '</div>';

	return $text . $html;
}, 10, 2 );

ブロックテーマのコメントテンプレート(コメント内容ブロック)でも comment_text フィルターは通常適用されますが、テーマやバージョンによって表示位置が変わることがあるため、表示を確かめてから本番に反映してください。コメント表示部分の作り方自体を見直したい場合は、まとめのリンク先も参考になります。

手順4:JavaScriptで送信し、件数を更新する

スクリプトは wp_enqueue_script() で読み込み、エンドポイントと nonce は wp_add_inline_script() で渡します。コメントがあるページだけに読み込むことで、ほかのページの表示速度に影響しません。

add_action( 'wp_enqueue_scripts', function () {
	if ( ! is_singular() || ( ! comments_open() && ! get_comments_number() ) ) {
		return;
	}
	wp_enqueue_script(
		'wpdr-reactions',
		plugins_url( 'wpdr-reactions/reactions.js', __FILE__ ),
		array(),
		'1.0.0',
		array( 'in_footer' => true, 'strategy' => 'defer' )
	);
	wp_add_inline_script(
		'wpdr-reactions',
		'const wpdrReactions = ' . wp_json_encode( array(
			'endpoint' => esc_url_raw( rest_url( 'wpdr/v1/reaction' ) ),
			'nonce'    => wp_create_nonce( 'wp_rest' ),
		) ) . ';',
		'before'
	);
} );
// wp-content/mu-plugins/wpdr-reactions/reactions.js
document.addEventListener('click', async (event) => {
  const button = event.target.closest('.wpdr-reaction');
  if (!button || button.disabled) return;

  const wrap = button.closest('.wpdr-reactions');
  button.disabled = true;
  try {
    const res = await fetch(wpdrReactions.endpoint, {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': wpdrReactions.nonce,
      },
      body: JSON.stringify({
        comment_id: Number(wrap.dataset.commentId),
        reaction: button.dataset.reaction,
      }),
    });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const data = await res.json();
    wrap.querySelectorAll('.wpdr-reaction').forEach((b) => {
      b.querySelector('.wpdr-count').textContent = data.counts[b.dataset.reaction] ?? 0;
    });
    button.setAttribute('aria-pressed', data.active ? 'true' : 'false');
  } catch (error) {
    console.warn('リアクションを送信できませんでした', error);
  } finally {
    button.disabled = false;
  }
});

'strategy' => 'defer' は WordPress 6.3 で追加された引数です。aria-pressed を切り替えることで、押した状態が支援技術にも伝わります。ボタンの見た目は子テーマの style.css で調整し、押した状態は [aria-pressed="true"] セレクタで表すと、状態とデザインがずれません。

確認方法とつまずきやすい点

動作確認は、ブラウザの開発者ツールのネットワーク欄で POST の応答を見るのが早道です。

  • 401 が返る: nonce が送られていない、または期限切れです。ページキャッシュに古い nonce が残っていないか確認します。多くのキャッシュプラグインはログインユーザーにキャッシュを返さない設定が既定ですが、念のため確かめてください。
  • 400 が返る: reaction のキーが定義と一致していないか、comment_id が数値になっていません。
  • 件数が他人の画面で更新されない: 未ログインの訪問者にはキャッシュ済みの HTML が配信されるため、件数はキャッシュの有効期限まで古いままです。厳密に合わせたい場合は、件数だけを GET で取得する読み取り用エンドポイントを追加します。
  • 同時に押されると件数がずれることがある: メタの読み込みと書き込みの間に別のリクエストが入ると上書きが起きます。小規模サイトでは実害は小さいものの、反応が多いサイトでは「コメントID・ユーザーID・種類」に一意キーを付けた独自テーブルに移すと整合性を保てます。

個人データの扱い

この実装は「誰がどのコメントに反応したか」をユーザーIDで保存します。これは個人データにあたる可能性があるため、プライバシーポリシーに保存する情報と目的を記載してください(2026年10月現在、WordPress には管理画面からプライバシーポリシーページを作成・指定する機能があります)。ユーザー削除時にリアクションも消したい場合は、delete_user アクションで該当ユーザーのIDを各メタから取り除く処理を追加します。他人が誰に反応したかを画面に一覧表示するかどうかも、公開前に決めておくとよいでしょう。

まとめ

  • リアクションは許可したキーで管理し、コメントメタにユーザーIDの配列として保存する
  • REST API は permission_callback でログインを要求し、X-WP-Nonce による nonce 検証で CSRF を防ぐ
  • 入力は validate_callback と sanitize_callback で検証し、表示は esc_attr / esc_html でエスケープする
  • トランジェントで簡易レート制限をかけ、規模が大きくなったら独自テーブルと永続オブジェクトキャッシュを検討する
  • mu-plugin に置き、ステージングとバックアップを用意してから反映する。不具合時はファイルを外せば戻せる
  • 保存する個人データはプライバシーポリシーに明記する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲