2026年8月27日
2026年8月27日
WordPressのwp_list_pages()をカスタムクエリに置き換えて表示を最適化する
wp_list_pages() は固定ページの階層リストを手軽に出せる関数ですが、HTML の構造を細かく変えたい、現在のセクションの子ページだけを出したい、ページ数が多くて取得量を抑えたい、といった場面では、WP_Query(または get_posts())で必要なページだけを取り、自前で出力するほうが扱いやすくなります。この記事では、置き換えるべきかどうかの判断基準から、階層の深さに比例した回数だけ問い合わせる取得方法、エスケープとアクセシビリティを押さえた出力、キャッシュと計測の手順までを説明します。
置き換える前に:wp_list_pages() で足りるケース
wp_list_pages() は内部で get_pages() を使っており、get_pages() は WordPress 6.3 から内部的に WP_Query を使う実装に変わりました。これにより、永続オブジェクトキャッシュがある環境ではクエリ結果のキャッシュも効くようになっています。単にページ一覧を出すだけなら、関数そのものが遅いとは限りません。
次のような要望は、置き換えずに引数やフィルターで対応できます。
- 特定ページの子孫だけ出したい:
child_of引数 - 階層を浅くしたい:
depth引数 - 並び順を変えたい:
sort_column引数(既定はmenu_order, post_title) - 特定のページを除きたい:
exclude引数 - li に付くクラスを変えたい:
page_css_classフィルター - HTML 構造を変えたい:
Walker_Pageを継承した独自 Walker をwalker引数で渡す
ブロックテーマを使っているなら、ページリストブロックやナビゲーションブロックで要件を満たせることもあります。まずはこれらで足りるかを確認し、足りないときに置き換えを検討するのが無駄のない進め方です。
カスタムクエリに置き換えると良いケース
置き換えが効いてくるのは、主に次のような場合です。
- 取得範囲を絞りたい:
get_pages()のchild_ofは、該当する範囲のページをまとめて取ってから PHP 側で子孫を絞り込む作りです。固定ページが数千件あるサイトで、表示したいのが一部のセクションだけなら、必要な親に属するページだけを問い合わせたほうが取得量を抑えられます - ページごとに追加の情報を出したい:アイキャッチ画像や抜粋、カスタムフィールドを並べたい場合、自前のループのほうが素直に書けます
- マークアップを全面的に設計したい:ナビゲーションとして
nav要素やaria-currentを含む構造にしたい場合など
実装:現在のセクションのサブナビゲーション
ここでは、固定ページの最上位の親(セクション)を起点に、その配下を 2 階層まで表示するサブナビゲーションを作ります。コードは子テーマの functions.php か独自プラグインに置き、ステージング環境で表示を確かめてから本番に反映してください。
起点となるページを決める
get_post_ancestors() は、親、祖父母……の順に ID の配列を返します。配列の最後の要素が最上位の親です。
function my_get_section_root_id( $post = null ) {
$post = get_post( $post );
if ( ! $post || 'page' !== $post->post_type ) {
return 0;
}
$ancestors = get_post_ancestors( $post );
return $ancestors ? (int) end( $ancestors ) : (int) $post->ID;
}
階層ごとにまとめて取得する
子ページを親ごとに 1 回ずつ問い合わせると、ページ数に比例してクエリが増えます(いわゆる N+1 問題)。そこで、post_parent__in で同じ階層の親をまとめて渡し、「階層の深さ」の回数だけ問い合わせます。2 階層なら 2 回で済みます。
function my_get_page_tree( $root_id, $max_depth = 2 ) {
$tree = array(); // 親ID => 子ページの配列
$parents = array( (int) $root_id );
for ( $depth = 0; $depth < $max_depth && $parents; $depth++ ) {
$pages = get_posts( array(
'post_type' => 'page',
'post_status' => 'publish',
'post_parent__in' => $parents,
'posts_per_page' => 500, // 想定より多いときの上限
'orderby' => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
) );
$parents = array();
foreach ( $pages as $page ) {
$tree[ $page->post_parent ][] = $page;
$parents[] = $page->ID;
}
}
return $tree;
}
この引数を選んでいる理由は次のとおりです。
- **
get_posts()を使う**:get_posts()は既定でno_found_rowsが有効になり、ページ送り用の総件数を数える処理を省きます。一覧の総件数は要らないので、その分の負荷を減らせます。なお既定の取得件数は 5 件なので、posts_per_pageの指定を忘れると一部しか出ません - メタとタームのキャッシュ更新を止める:タイトルとリンクしか使わないなら、カスタムフィールドやカテゴリーの情報を事前に読み込む必要はありません。アイキャッチやカスタムフィールドを出す場合は、
update_post_meta_cacheをtrueに戻してください。そのほうが、ループ内で 1 件ずつ問い合わせるより少ないクエリで済みます - 並び順を明示する:管理画面の「順序」(menu_order)を優先し、同じ値ならタイトル順にしています。
wp_list_pages()の既定に近い並びです
エスケープとアクセシビリティを押さえて出力する
取得した配列を再帰的にたどって出力します。
function my_render_page_tree( $tree, $parent_id, $current_id ) {
if ( empty( $tree[ $parent_id ] ) ) {
return '';
}
$html = '<ul>';
foreach ( $tree[ $parent_id ] as $page ) {
$is_current = ( (int) $page->ID === (int) $current_id );
$html .= sprintf(
'<li%s><a href="%s"%s>%s</a>%s</li>',
$is_current ? ' class="is-current"' : '',
esc_url( get_permalink( $page ) ),
$is_current ? ' aria-current="page"' : '',
esc_html( get_the_title( $page ) ),
my_render_page_tree( $tree, $page->ID, $current_id )
);
}
return $html . '</ul>';
}
function my_section_nav() {
if ( ! is_page() ) {
return;
}
$current_id = get_queried_object_id();
$root_id = my_get_section_root_id( $current_id );
$tree = my_get_page_tree( $root_id, 2 );
$list = my_render_page_tree( $tree, $root_id, $current_id );
if ( '' === $list ) {
return;
}
printf(
'<nav class="section-nav" aria-label="%s"><p class="section-nav__title"><a href="%s">%s</a></p>%s</nav>',
esc_attr__( 'このセクションのページ', 'my-theme' ),
esc_url( get_permalink( $root_id ) ),
esc_html( get_the_title( $root_id ) ),
$list // 各要素は my_render_page_tree() 内でエスケープ済み
);
}
テンプレートの表示したい位置で my_section_nav(); を呼び出せば表示されます。
出力の際は、URL を esc_url()、タイトルを esc_html()、属性値を esc_attr() 系に通すのが原則です。aria-current="page" を付けると、スクリーンリーダーの利用者にも今いるページが伝わり、CSS でも [aria-current="page"] を目印にして強調表示できます。get_the_title() は投稿オブジェクトから取り出すだけなので、ここで追加のクエリは発生しません。
キャッシュで繰り返しの取得を減らす
サブナビゲーションはどのページでも同じ内容になりやすいので、結果をキャッシュすると効果的です。ポイントは、ページを更新したときに古いキャッシュを使わないようにすることです。
wp_cache_get_last_changed( 'posts' ) は、投稿や固定ページが更新されるたびに変わる値を返します。これをキャッシュキーに含めると、更新後は自動的に別のキーになり、明示的に削除しなくても新しい内容が使われます。
function my_get_page_tree_cached( $root_id, $max_depth = 2 ) {
$key = sprintf( 'tree:%d:%d:%s', $root_id, $max_depth, wp_cache_get_last_changed( 'posts' ) );
$tree = wp_cache_get( $key, 'my_section_nav' );
if ( false === $tree ) {
$tree = my_get_page_tree( $root_id, $max_depth );
wp_cache_set( $key, $tree, 'my_section_nav', HOUR_IN_SECONDS );
}
return $tree;
}
wp_cache_* 関数は、Redis や Memcached などの永続オブジェクトキャッシュが導入されていればリクエストをまたいで効きますが、導入されていない環境では 1 回のリクエストの中でしか保持されません。永続オブジェクトキャッシュがない場合に Transients API で代用すると、キーが変わるたびに options テーブルへ行が増えていくので、有効期限を短めに設定するなどの配慮が必要です。ページキャッシュ(HTML 全体のキャッシュ)が効いているサイトでは、そもそもこの処理が走る回数が少ないため、無理にオブジェクトキャッシュを足さなくてもよい場合があります。
効果を計測する
置き換えの効果は、推測ではなく数字で確かめます。Query Monitor プラグインを入れると、ページごとに実行されたクエリの数と所要時間、どの関数から呼ばれたかを確認できます。
- 置き換え前のページで、
wp_list_pages()やget_pages()から発行されたクエリの数と時間を控える - 置き換え後に同じページを開き、同じ項目を比べる
- 固定ページが少ないサイトでは差が小さいこともあるので、本番に近い件数のデータで試す
クエリ数は減っても、HTML の生成や後続の処理に時間がかかっていることもあります。サーバーの応答時間全体(TTFB)もあわせて見ておくと、改善の手応えを正しく判断できます。表示が変わる変更なので、メニューの並び順やリンク先が以前と一致しているかも、目視で確認しておきます。
なお、計測の数値は環境やバージョンによって変わります。この記事は WordPress 6.x 系を前提にしています(2026年10月現在)。
まとめ
wp_list_pages()は WordPress 6.3 以降WP_Queryベースになっており、引数やフィルター、独自 Walker で足りるならそのまま使うのが手軽- 取得範囲を絞りたい、追加情報を出したい、マークアップを設計したい場合は、カスタムクエリに置き換える
- 子ページは
post_parent__inで階層ごとにまとめて取得し、N+1 問題を避ける - 出力は
esc_url()・esc_html()でエスケープし、aria-current="page"で現在地を伝える - キャッシュキーに
wp_cache_get_last_changed( 'posts' )を含めると更新時に自動で切り替わる。効果は Query Monitor で計測して確かめる