WordPressで投稿を表示するループの書き方・利用シーンまとめ

WordPressのループの仕様はCodexのループに書いてあるとおりですが、ループの利用シーンがいまいち分かりづらいです。
この記事では、Codexや他ブログ記事を参考にし、自分でも試した結果を踏まえてループの書き方と利用シーンについてまとめてみました。

本題に入る前に、まずループの種類について前提知識を2つ。

前提知識1: メインループとサブループ

Codex ループには「メインループ」という言葉が登場します。一方で「サブループ」という言葉はCodexのドキュメント群では見つけられませんでしたが、多数のブログ記事で、メインループと対になってよく利用されています。
メインループの定義については、下記のトピックが参考になると思います。
[解決済み] 「メインループ」の定義は何ですか?

メインループは、各テンプレートにつき1つではなく、正確に言えば各URLにつき1つです。

URLが /?p=123 ならIDが123の投稿を1件表示するループがメインループ
URLが /?cat=4 ならIDが4のカテゴリーの投稿を管理画面で指定した件数表示するループがメインループ
URLが /?m=201206 なら2012年6月の投稿を管理画面で指定した件数表示するループがメインループ

どのURLでどのテンプレートが使われるかは、テンプレート階層のルールに従って決定されます

つまりメインループとは、投稿や固定ページ、アーカイブページなどテンプレートごとに、WordPressが標準で用意しているループと言えるでしょう。

一方サブループとは、下記のようなものを指していると考えられます。

URLが /?cat=xx のようなカテゴリーの一覧のとき、IDが4のときだけ20件表示固定にしたい
URLが /?m=201206 の様な日付の一覧のとき、特定のIDが一覧に出ないようにしたい

サブループは、このようにメインループとは別に、より詳細に条件を指定したい場合に利用します。
例えば利用シーンとしては、カテゴリページでメインのコンテンツにはメインループで新着記事を10件表示し、サイドバーにも新着記事5件を表示したい場合など。

前提知識2: メインループは$wp_queryが利用されている

ループの利用シーンを知るためにもう一つ重要なのが、$wp_queryの存在です。
$wp_queryオブジェクトとは、WP_Queryクラスのグローバルインスタンス。WP_Queryは投稿など様々なデータを色々な条件で取得するためのクラスです。
メインループでは、WordPressがデフォルトで用意している$wp_queryオブジェクトが裏で使われており、投稿の様々なデータが格納されます。

具体例を見てみましょう。
テーマ内のテンプレートにはよく下記のようなコードが使われています。
これはTwenty Fifteenのindex.phpに書かれているループ。

while ( have_posts() ) : the_post();
get_template_part( 'content', get_post_format() );
endwhile;

have_posts()は、$wp_queryにループできる結果があるかどうかをチェックする関数です。
have_postsのソースは下記のようになっています。

function have_posts() {
global $wp_query;
return $wp_query->have_posts();
}

$wp_queryオブジェクトが呼び出され、そのhave_posts()メソッドを呼び出しています。

the_postのソースも見てみましょう。
the_post()は、グローバルのpost変数にループの次のアイテムをセットする関数です。
こちらも同様に、$wp_queryのメソッドを呼び出しています。

function the_post() {
global $wp_query;
$wp_query->the_post();
}

さらに$wp_query->the_post()で呼び出されているWP_Queryクラスのthe_post()を見てみます。

public function the_post() {
global $post; // グローバルのpost変数を宣言。
$this->in_the_loop = true;

if ( $this->current_post == -1 ) // loop has just started
do_action_ref_array( 'loop_start', array( &$this ) );

$post = $this->next_post(); // $postに次のアイテムをセット
$this->setup_postdata( $post );
}

つまり、上記のTwenty Fifteenのindex.phpのループの例から、メインループでは、WP_Queryクラスのグローバルオブジェクトである$wp_queryがメインループの処理を担当し、その中で同じくグローバルオブジェクトの$postを書き換える処理を行っていることが分かります。

投稿を表示するループの種類と使い分け

投稿のループの書き方は3つあります。

  • query_posts()
  • get_posts()
  • WP_Queryクラスを直接使う

3つの書き方の相互関係性

WP_Query

それぞれを見る前に、どのような相関関係になっているのか整理してみましょう。
まず、すべての根底にあるのがWP_Queryクラス。
WP_Queryクラスは、記事数や順番など様々な条件で投稿、固定ページなどの種類別にデータベースからデータを取得できるというもの。

query_posts()は、このWP_Queryクラスのグローバルオブジェクト、$wp_queryを使うもの。$wp_queryは、WordPressがメインループ用に標準で用意しているもので、つまりquery_posts()を使うということは、メインループを直接書き換えるということになります。

get_posts()は、中で’WP_Query’クラスのオブジェクトを新しく作成しています。投稿の表示に特化したWP_Queryのオブジェクトを作る関数という位置づけです。WP_Post オブジェクトのリストが返ります。
グローバルの$postを汚染しない書き方をすることもできます。

最後にWP_Queryオブジェクトを自分でnewする方法。自分で色々記述する必要はありますが、post_typeが投稿以外のものでも使えたり、get_post()より幅広いデータの取得に向いていると考えられます。

では、それぞれの使い方を見ていきましょう。

query_posts()はなるべく使わない

codex: テンプレートタグ/query postsには、下記のように書かれています。

この関数はプラグインまたはテーマの中で使われることを想定されていません

query_posts() はページ内のメインクエリーを書き換え、新しいクエリーのインスタンスと置き換えるために使う関数としては過度に単純化され、問題が発生しやすい方法です。

ひとことで言うと、query_posts() は決して使うべきではありません。

ここまで書かれている以上、query_posts()は原則として使用しないのがよいでしょう。
どうしてもメインループを変えたい場合は、代わりにpre_get_postフックを使う方法が推奨されています。

function five_posts_on_homepage( $query ) {
  if ( $query->is_home() && $query->is_main_query() ) { // メインクエリーかどうかを必ずチェック
    $query->set( 'posts_per_page', '5' );
  }
}
add_action( 'pre_get_posts', 'five_posts_on_homepage' );

pre_get_postフックではクエリーが実行される前にクエリーの条件を変えることができます。
何がいいのかというと、要するにループのリセット処理を記述しなくていいということだと解釈できます。
query_posts()を使う場合、リセットをしなければならないのにしていなかったり、誤って他の関数用のリセット関数を呼び出してしまっていたり、混乱を招くので、こちらのやり方を推奨しているのでしょう。

get_posts()を使う

get_posts()は、メインループ以外にもループを使う場合に利用しましょう。
例えば、下記のような実装。

//メインループ
<?php 
  while ( have_posts() ) : the_post(); 
    get_template_part( 'content', get_post_format() ); 
  endwhile; 
?>
...
//サブループ
<ul>
<?php 
  global $post; // グローバル変数postを宣言する 
  $args = array( 'posts_per_page' => 5 );
  $posts = get_posts( $args );

  foreach ( $posts as $post ) : // ローカル変数postの値でグローバル変数postを上書き
    setup_postdata( $post ); // 結果としてグローバル変数postの参照を渡す
?> 
<li><?php the_title(); ?></li>
</ul>
<?php 
  endforeach;
  wp_reset_postdata();
?>

get_posts()を使う場合は、グローバル変数postを宣言し、foreachの行でローカル変数postを宣言することにより、グローバル変数postを上書きする、ということを行います。
これにより、the_permalink()やthe_title()などループ内でグローバル変数postを利用する関数を使うことができます。
また、setup_postdata()は、get_postsデフォルトの状態では取得できない記事データを使用できるようにするために必要です。例えばthe_content()などは、setup_postdataをしないと使えません。また、setup_postdat()の引数はグローバル変数postを参照するものでなければなりません。そうしないと、ループ内での他データとの整合性が取れなくなります。上のコードで言うと、setup_postdataの行のローカル変数$postは、グローバル変数を上書きした後なので、結果としてグローバル変数への参照を渡していることになります。
最後にwp_reset_postdataでリセットを行います。wp_reset_postdata()は、$wp_queryオブジェクトが保持している$postをグローバル変数のpostにセットし直すことで、ループ開始前の状態にもどす処理をしています。続けてループを記述する場合以外は必要ないかもしれませんが、不具合を防止するために常に記述するのがよいでしょう。

まとめると、get_posts()を使う場合は下記が必要です。

  • グローバル変数postを宣言する
  • foreachを使い、asの後ろの変数名はグローバル変数を上書きするためにpostにする
  • setup_postdata($post)を記述する。$postはグローバル変数postへの参照である
  • ループの終了後はwp_reset_postdata()でグローバル変数postをリセットする

WP_Queryクラスから直接オブジェクトを作る

ループを作る最後の方法はWP_Queryから直接オブジェクトを作る方法です。

<?php $the_query = new WP_Query( $args ); 
if ( $the_query->have_posts() ) {
    echo '<ul>';
    while ( $the_query->have_posts() ) {
      $the_query->the_post();
      echo '<li>' . get_the_title() . '</li>';
    }
    echo '</ul>';
}
wp_reset_postdata();

$the_query->the_post()でグローバル変数postに現在のpostをセットしています。
そのため、最後にwp_reset_postdata()でもとの値にリセットしなければなりません。

管理画面でループを使う場合

ここまで紹介した3つの方法は、ユーザー画面でのループ処理を前提にお話してきました。
一方、管理画面でループを使う場合は注意が必要です。
なぜなら、現在のWordPressの作りではwp_reset_postdata()が使えないから。
実は、codex WP_Queryに下記のような記述があります。

参考: Ticket #18408 管理画面内で投稿をクエリする場合、wp_reset_postdata() が期待どおり動かないかもしれないので get_posts() を利用するとよいでしょう。

Ticket#18408: Can’t wp_reset_postdata after custom WP_Query in an admin edit pageのページで書かれていることを要約すると、下記のとおり。

  • 管理画面でwp_reset_post_data()が効かないのはバグだ。wp_reset_postdata()では$wp_queryオブジェクトのpostオブジェクトによりグローバル変数postをリセットするが、ユーザーが見る投稿や固定ページとは違い、管理画面のページでは$wp_queryオブジェクトのpostオブジェクトは最初から定義されない。そのため、リセット処理ができない。
  • WP_Queryの代わりにget_posts()を使うことで問題は解決できるが、根本的解決にはならないため、直されるべきである(という意見が多数)

直されるべき、という意見が多数あるにも関わらず、このチケットはすでにオープンから4年が経過しています。
今のところは、get_posts()を使うやり方を採用するのが一番安全と言えそうです。

WP_Queryクラスを直接newする方法が推奨されないのは、ループの中で、グローバルのpost変数を参照するテンプレートタグ(the_title()など)を使用しなければならないためだと思われます。リセットができない以上、グローバル変数postを書き換えること自体を避けるべき、という意見なのでしょう。(get_posts()の中の処理と同じ実装にすれば話は別ですが、それならget_posts()を使うのがよいでしょう。)

具体例で考えてみます。例えば、投稿の編集画面で記事一覧を表示し、関連記事として選択できるようなプラグインがあるとします。

下記はダメな例。
今までと同じように下記のようなコードを書くと、グローバル変数postはリセットされません。

// 投稿編集画面のメタボックスで記事一覧を表示する処理
// この実装は不具合を発生させる恐れがある
<?php 
global $post; 
echo $post->ID // 1
$the_query = new WP_Query( $args );

if ( $the_query->have_posts() ) { // 記事がID=1から5まであるとする
    echo '<ul>';
    while ( $the_query->have_posts() ) {
      $the_query->the_post(); //グローバル変数postを書き換えてしまう</ul>
      echo '<li>' . get_the_title() . '</li>';
    }
    echo '</ul>';
}
wp_reset_postdata(); //グローバル変数postのリセットが効かない
echo $post->ID // 5

このままリセットされない状態だと、他のプラグインのメタボックスを追加するときに、この投稿のID(上記の場合は1)を取得したいのに、5として取得されてしまうなど動作に不具合が出る可能性があります。

下記が望ましい書き方。

// get_postsを使う場合
// global $post は宣言しない
$myposts = get_posts( $args );
foreach( $myposts as $post ) :
echo $post->ID; //IDを出力
echo apply_filters( 'the_content', $post->post_content ); // コンテンツを出力
...
endforeach:
// グローバル変数は上書きされない

1つ気を付けたいのは、get_posts()で取得した記事データの一部の値はそのまま使えず、上記のようにフィルターをかけないといけないということです。
WP_Postクラスのページに下記の記述がある。

上記のメソッドは投稿 ID を取得する際には良いですが、post_content やフィルターされた要素 (post_title など) を表示するためには使うべきではありません。代わりにループ内なら the_content を、ループ外なら apply_filters を使って以下のようにしてください。

コンテンツやタイトルなどをget_posts()のpostからそのまま取り出すのは推奨されていないので、書き方には注意が必要です。

あるいは、下記のように手動でリセットを行う書き方もTicket#18408で紹介されていました。
最初にグローバル変数postを宣言し、$original_post変数に格納しておき、ループ後に、各種変数及びグローバル変数postを$original_post変数でリセットする方法です。

// get_posts()を使うが
// $postを上書きしてリセットする方法
global $post;
$original_post = $post; // 最初に$postを覚えておく
$myposts = get_posts( $args );
foreach( $myposts as $post ) : setup_postdata($post);//グローバルの$postを上書き
...
endforeach:
setup_postdata( $original_post); // 投稿情報に関する各種グローバル変数をリセット
$post = $original_post; // グローバル変数postをリセット

これでも大丈夫な気はします。
apply_filterを使わなくてよいぶん、記述忘れがないかもしれません。
ここまでくると好みでしょうか。
ちなみにWP_Queryで同様のことを書くと、下記のようになります。

// WP_Queryを直接使う場合
global $post;
$original_post = $post;
$loop = new WP_Query( $args );
while( $loop->have_posts() ): $loop->the_post();
...
endwhile;
setup_postdata( $original_post );
$post = $original_post;

むしろ、グローバルの$postを上書きするのだったらこちらの方がすっきりしている気がしますね。
どちらも動作することは確認済みです。

まとめ

まとめると、下記のとおりです。

  • メインループを書き換えるquery_posts()は基本的に使用するべきではない。
  • メインループを変えたい場合は、pre_get_postsフックを使う。この書き方により、投稿データのリセットし忘れが防げる
  • ユーザー画面でメインループ以外に他のループを使用したい場合は、get_posts()WP_Query直利用で実現する。リセットは忘れずに。
  • 管理画面でループを使う場合は、get_posts()WP_Queryを利用する。リセット関数は使えないため、はじめからグローバルの$postを更新しないか、または自分でリセットする処理を加える必要がある。
  • メインループはユーザーが閲覧するテンプレートのために用意されている仕組み。管理画面では使われるべきではない

WordPressのループは色々な書き方があり、本当に迷います。
この記事が少しでもみなさまのお役に立てば幸いです。

参考資料

WordPressの投稿情報を表示 メインループとサブループについて
query_postsを捨てよ、pre_get_postsを使おう【追記あり】【報告あり】
Make sense of WordPress query functions