WordPressのブロック開発メモ その15 エンティティ

WordPressのREST APIにはapiFetch()で簡単にアクセスすることはできます。
ただその対象が投稿タイプ(postsやpagesなど)タクソノミー(categoriesやtagsなど)の場合はコアデータ(@wordpress/core-data)を使った方がキャッシュ管理やローディング、Undo/Redoなどまでまとめて面倒みてくれるので便利です。

投稿タイプ、例えば投稿(/wp/v2/posts)やページ(/wp/v2/pages)などや、
タクソノミー、例えばカテゴリ(/wp/v2/categories)やタグ(/wp/v2/tags)などを取得したい場合は
GetEntityRecords()GetEntityRecord()を使うと便利です。

これについてはドキュメント(Gutenberg Dataを使用したアプリの作成)があるのでそちらを読んでください。

https://ja.wordpress.org/team/handbook/block-editor/how-to-guides/data-basics/

ここではちょっと深堀していきたいと思います。

コアデータ

@wordpress/core-dataのリファレンスを読んでいるといくつかの単語が引っかかります。

stateはReduxの知識があれば無視できます。

kindは種類のことでpostTypetaxonomyrootなどが確認出来ました。
nameは対象の名前(postpage,tag,categoryなどがある)です。
kindnameの組み合わせからアクセスするREST APIのベースURLを判断する仕組みのようです。

例えばkindpostTypeで、namepostなら以下のREST APIにアクセスします。

getEntityRecords('postType', 'post');

/wp/v2/posts

kindtaxonomyで、namecategoryなら、

getEntityRecords('taxonomy', 'category');

/wp/v2/categories

他にもいろんなnameが設定してあります。
kindに属する設定(Config)を取得するにはgetEntitiesConfig(kind)を使うといようです。

wp.data.select('core').getEntitiesConfig('postType');
wp.data.select('core').getEntitiesConfig('taxonomy');

以下は結果です(postType.post及びtaxonomy.categoryのみ展開してます)。

postTypeには「post, page, attachment, nav_menu_item, wp_block, wp_template, wp_template_part, wp_navigation」が確認出来ました。
taxonomyには「category, post_tag, nav_menu」が確認できます。

投稿のConfigを見ると、kindpostType, namepostbaseUrl/wp/v2/posts等の情報が得られています。

{kind: 'postType', baseURL: '/wp/v2/posts', baseURLParams: {…}, name: 'post', label: '投稿', …}

読み込み中とキャッシュ

コアデータは一度取得したデータをキャッシュしてます。
getEntityRecords()やgetEntityRecord()の引数(postType, name, クエリやIDなど)の組み合わせをキーにキャッシュしてます。
なので同じ引数の関数を二度目以降実行すると、実際にはサーバーにアクセスせずキャッシュを返します。

この引数の組み合わせが非常に重要です。

この組み合わせで、現在キャッシュが存在するかをhasEntityRecords()で取得出来たり、
読み込みが完了したかどうかの状態を知るhasFinishedResolution()が取得出来ます。

以下のgetEntityRecords()関数のシグネチャで実験してみます。

getEntityRecords(kind, name, query)

edit.tsx

import React from 'react';
import { useState } from '@wordpress/element';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { Post, store as coreStore} from '@wordpress/core-data';

import KurageExampleBlockProps from './props';
import { useSelect } from '@wordpress/data';
import { decodeEntities } from '@wordpress/html-entities';
import { Button, ButtonGroup, Spinner } from '@wordpress/components';

import './editor.scss';

const CenteringSpinner = () =>
{
    return (
        <div style={{textAlign: 'center', padding: 10 }}>
            <Spinner style={{ width: 100, height: 100 }} />
            <p>Loading...</p>
        </div>
    )
}

export default (props: BlockEditProps<KurageExampleBlockProps>) =>
{
    const [page, setPage] = useState(1);

    const { posts, postsResolved } = useSelect(select => {

        // クエリ作成
        const query = { page, per_page: 3 };

        // ログ
        console.log(['postType', 'post', query]);

        return {
            posts: select(coreStore).getEntityRecords('postType', 'post', query ) as Post[],
            // @ts-ignore
            postsResolved: select(coreStore).hasFinishedResolution('getEntityRecords', ['postType', 'post', query]) as boolean
        }
    }, [page]);

    return (
        <div {...useBlockProps()}>

            <ButtonGroup>
                <Button variant="primary" onClick={() => setPage(Math.max(1, page - 1))}>前へ</Button>
                <Button variant="primary" onClick={() => setPage(page + 1)}>次へ</Button>
            </ButtonGroup>

            <p>
                {page} ページ目
            </p>

            {
                !postsResolved ?  <CenteringSpinner />:
                !posts.length ? <p>投稿が存在しません</p>: 
                posts.map(post => (
                    <p key={post.id}>
                        {post.id} : {decodeEntities(post.title.rendered)}
                    </p>
                ))
            }
        </div>
    );
}
const query = { page, per_page: 3 };

getEntityRecords()に渡すkindはpostType, nameはpostですが、
queryは一度に取得する最大数(per_page)を3に固定、
そして現在のページ(page)に変化を加えて行きます。
ボタンを押してpageに変更を加えるようにします。

hasFinishedResolution('getEntityRecords', ['postType', 'post', query])

hasFinishedResolution()は解決されたら(サーバーから結果が帰ってきたら)trueになります。
つまりアクセス中はfalseになるのでこの結果を使ってローディングを実装します。
第二引数でまとめて組合せを配列指定することに注意してください。

ローディングを分かりやすくするため、サーバーサイドでレスポンスを遅延させます。
適切な箇所にsleep()を仕込んでおきます。

kurage-worker.php

add_action('rest_api_init', function(){
    sleep(3);
});

「次へ」ボタンを押すと、3秒ほどローディングが表示される。

「次へ」を押すたびにローディングが表示されますが、
「戻る」を押すとローディングは無く直ぐに結果が表示されるのが確認出来ます。

一度表示したページ(の組合せ)ではローディングは表示されません。
キャッシュが返されるのでPHP側でブレークポイントを張っていてもサーバにアクセスしていないことが確認出来ます。

これ以降は参考程度にしてください。

エンティティの追跡!?

ソースコードを詳しく読んでないので間違ってるかもしれませんが、ちょっと驚いたので書きます。

getEntityRecords()は投稿のリストを取得しますが、getEntityRecord()は主キーをもつ単体のレコードを取得します。
このgetEntityRecord()の動作にちょっと驚きだったので以下のコードを追加します。

edit.tsx

import React from 'react';
import { useState } from '@wordpress/element';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { Post, store as coreStore} from '@wordpress/core-data';

import KurageExampleBlockProps from './props';
import { useSelect } from '@wordpress/data';
import { decodeEntities } from '@wordpress/html-entities';
import { Button, ButtonGroup, Spinner, TextControl,  __experimentalVStack as VStack} from '@wordpress/components';

import './editor.scss';

const CenteringSpinner = () =>
{
    return (
        <div style={{textAlign: 'center', padding: 10 }}>
            <Spinner style={{ width: 100, height: 100 }} />
            <p>Loading...</p>
        </div>
    )
}

const ViewPage = ( {id} ) =>
{
    const { post, postResolved } = useSelect(select => {

        return {
            post: select(coreStore).getEntityRecord('postType', 'post', id) as Post,

            // @ts-ignore
            postResolved: select(coreStore).hasFinishedResolution('getEntityRecord', ['postType', 'post', id]) as boolean
        }
    }, [id]);

    return (
        <div style={{border: 'solid 3px green', margin: 15}}>
            {
                !postResolved ? <CenteringSpinner /> :
                !post ? <div>ページが存在しません</div> :

                <p>
                    {post.id} : {decodeEntities(post.title.rendered)}
                </p>
            }
        </div>
    )
}

export default (props: BlockEditProps<KurageExampleBlockProps>) =>
{
    const [page, setPage] = useState(1);

    // 追加
    const [id, setId] = useState(0);
    const [ipt, setIpt] = useState('');

    const { posts, postsResolved } = useSelect(select => {

        // クエリ作成
        const query = { page, per_page: 3 };

        // ログ
        console.log(['postType', 'post', query]);

        return {
            posts: select(coreStore).getEntityRecords('postType', 'post', query ) as Post[],

            // @ts-ignore
            postsResolved: select(coreStore).hasFinishedResolution('getEntityRecords', ['postType', 'post', query]) as boolean
        }
    }, [page]);

    return (
        <div {...useBlockProps()}>

            <ButtonGroup>
                <Button variant="primary" onClick={() => setPage(Math.max(1, page - 1))}>前へ</Button>
                <Button variant="primary" onClick={() => setPage(page + 1)}>次へ</Button>
            </ButtonGroup>

            <p>
                {page} ページ目
            </p>

            <VStack>
            {
                !postsResolved ?  <CenteringSpinner />:
                !posts.length ? <p>投稿が存在しません</p>: 
                posts.map(post => (
                    // p を Button に変更
                    <Button variant="primary" key={post.id} onClick={() => setId(post.id)}>
                        {post.id} : {decodeEntities(post.title.rendered)}
                    </Button>
                ))
            }
            </VStack>

            <ViewPage id={id} />

            <TextControl value={ipt} onChange={setIpt} />
            <Button variant="primary" onClick={() => setId(parseInt(ipt) || 0)}>入力したIDを表示</Button>

        </div>
    );
}

投稿単体を表示するViewPageコンポーネントを新たに作りました。
これまでのリスト表示をボタン化しました。
さらにテキストで直接入力出来るようにしました。
ボタンをクリックすると、そのIDをViewPageで表示します。

↑リストに表示されている3つのボタンのうち何れのボタンを押してもローディングは表示されません。
即座にビューが更新されます。

別の投稿(今回の例ではIDが282の投稿が存在するとする)のIDをテキストコントロールに入力します。
テキストに282を入力後、入力したIDを表示ボタンを押します。
getEntityRecords()で読み込んだリストに入ってないためローディングされます。
その後282の投稿が表示されます。

282の結果

saveEditedEntityRecord()を実行した時も、あー、なるほどって思うような動作をしますが、大変なのでこの辺は省略します。

内部の動作

Configの取得はgetEntitiesConfig()で出来ますが、では内部はどのような仕組みになっているんでしょう?

投稿タイプは/wp/v2/types?context=viewで取得出来るようです。

Object.entries(
    await wp.apiFetch({path: '/wp/v2/types?context=view'})
).map(([key, val]) => `${key}: ${val.rest_base} / ${val.name}`);

タクソノミーは/wp/v2/taxonomies?context=viewで。

Object.entries(
    await wp.apiFetch({path: '/wp/v2/taxonomies?context=view'})
).map(([key, val]) => `${key}: ${val.rest_base} / ${val.name}`);

ちょっと試してみましょう

0: post: posts / 投稿
1: page: pages / 固定ページ
2: attachment: media / メディア
3: nav_menu_item: menu-items / ナビゲーションメニューの項目
4: wp_block: blocks / 再利用ブロック
5: wp_template: templates / テンプレート
6: wp_template_part: template-parts / テンプレートパーツ
7: wp_navigation: navigation / ナビゲーションメニュー
0: category: categories / カテゴリー
1: post_tag: tags / タグ
2: nav_menu: menus / ナビゲーションメニュー

ちなみにこれらがどのタイミングで読み込まれているのでしょう!?

てっきりREST API/wp/v2/typesを取得しているのかと思い、
実装されているWP_REST_Block_Types_Controllerのコンストラクタにブレークポイントを張ったところ反応なし。
どうやら投稿の表示時(post.php)がREST APIを回避して間接的に呼び出しているようです。
取得されたデータは以下のようにレンダリングされてました。

ブラウザのDOMを検索すると以下のコードが埋め込まれていました。

REST API経由ではなくpost.phpを呼び出した時点でapiFetch()側ででキャッシュさせてたんですね。

さて、ではこのapiFetch()をどのタイミングで呼び出しているのでしょう!?
インライン記述してあるということはワードプレスのどこかの場所にその記述があるはずです。

探してみました。

block-editor.php

wp_add_inline_script(
    'wp-api-fetch',
    sprintf(
        'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
        wp_json_encode( $preload_data )
    ),
    'after'
);

ならばapiFetch()/wp/v2/typesを呼び出したタイミングで例外を発生させ邪魔させるようにミドルウェアを忍ばせてみます。

wp_add_inline_script(
    'wp-api-fetch',
    sprintf(
        'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );'

.'wp.apiFetch.use((op,nx)=>{ if(op.path.indexOf("types") !== -1){ throw 12345; } return nx(op);   });'
        ,
        wp_json_encode( $preload_data )
    ),
    'after'
);

pathtypesの文字列が含まれたら例外を発生させ、
ブラウザのデバッグからそれを拾い、コールスタックから呼び出し元をたどっていく作戦です。

ただ注意しないといけないのはGutenbergのコードはMinify化されているので可読性が悪すぎて理解不能です。
そこでMinify化されてないスクリプトを読み込ませるように以下のようにスクリプトをデバッグモードにします。

PHPの設定ファイル(wp-config.php)でSCRIPT_DEBUGを有効にします。

wp-config.php

define('WP_DEBUG', true);
define('SCRIPT_DEBUG', true); # ←これを追加。

実戦ではデバッグモードを無効にするのを忘れないように注意してください。

ブラウザの開発者ツールから例外を補足するようにしてページを更新すると、(throw 12345)が引っかかった。

コールスタックをさかのぼっていくと見おぼえある箇所を発見。

どうやらEditorコンポーネントのようでした。
このEditorコンポーネントは投稿編集ページそのものですね。
EditorgetEntityRecord()セレクタから指定のIDの投稿内容を取得しているんですね。
以下はEditorのソースコードです。

https://github.com/WordPress/gutenberg/blob/a8da93f061224a93ca6ea0c6ab311021e6dba7ba/packages/edit-post/src/editor.js

そしてコアデータはストアで以下のコードが存在します。

https://github.com/WordPress/gutenberg/blob/a8da93f061224a93ca6ea0c6ab311021e6dba7ba/packages/core-data/src/entities.js

コアデータストアは内部でgetOrLoadEntitiesConfig(kind)を定義してます。
この関数は引数から渡されたkindConfigを取得しキャッシュしてます。

kindがそれぞれ

  • postTypeの時はloadPostTypeEntities()
  • taxonomyの時はloadTaxonomyEntities()

よみこみます。

例えばloadPostTypeEntities()は内部で以下を実行しているのが確認出来ます。

const postTypes = await apiFetch( {
    path: '/wp/v2/types?context=view',
} );

実はこのgetOrLoadEntitiesConfig()getEntityRecord()から呼び出されてます。

エディタ(Editor)がgetEntityRecord()を使用。
getEntityRecord()getOrLoadEntitiesConfig()を使用。
/wp/v2/typesが読み込まれ、Configがキャッシュされる。

getOrLoadEntitiesConfig()は他にも

  • getEntityRecords()
  • canUserEditEntityRecord()
  • deleteEntityRecord()
  • saveEntityRecord()
  • saveEditedEntityRecord()

から呼ばれてます。

必要な時に/wp/v2/typesを読み込みキャッシュする。
何ならpost.phpを呼び出した時点でキャッシュしREST APIを回避する。

などかなりの工夫がされてました。

試しにgetEntitiesConfig()の結果を追加してみました。

wp.apiFetch.use((op,nx)=>{
    if (op.path.indexOf("types") !== -1) {
        console.log("XXXXXXXXXXX");
    }
    console.log(wp.data.select("core").getEntitiesConfig("postType"));
    console.log(op);
    return nx(op);
}

ソースコードを読んだだけでは理解出来なかったのですが、
ようやくエディタが投稿を取得するタイミング、コアデータのREST APIアクセス手法、Configの設定タイミングなど一通り理解することが出来ました。

certificate Docker Gutenberg Hyper-V openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク