【爆速だが地獄】WordPressでSSR + Vue.jsでSPAなウェブサイトを作った

2022/08/11に公開

はじめに

  • SPAはSEOに弱い
  • でもSPAは遷移が早い
  • バックエンドは慣れてるWordPressをベースにしたい

これにうまく対処するため、WordPressでSSRをし、後のサイト内遷移はVue.jsで描画するという手法をとることにしました。

これにより、SPAの弱点(SEO・読み込み速度など)をカバーすることに成功したのですが、お察しの通り(?)実装が軽く地獄だったので真似しないでください。 よければ良い実装方法を教えてくれ…

あと、実装とか開発環境は割と我流です。

詳細な仕様

  • WordPressテーマ … CSS・JSファイルはwp-admin用を除き含まない。本番環境ではdistフォルダにViteでビルドしたアセットを放り込む
  • Vue.js … Viteを使用。開発時アセットはすべてこちらから読み込み
    • 完全なSPAにするページと、SSR・SPA併用ページ(Hybridと呼ぶ)を設けた
      (全部ハイブリッドにすると実装作業が死ぬので)

SSR+SPA と SPA オンリーの出し分け

  • トップページ … Hybrid
  • 記事ページ … Hybrid
  • 固定ページ … Hybrid
  • アーカイブページ … SPA
  • 検索結果ページ … SPA

開発環境の構築

WordPressテーマと、Vue.jsプロジェクトを別々に作成しました。Vue.js側はViteを使用。開発環境ではWordPressにViteの開発サーバーのJSファイルを直接読み込ませました。

Vite コンフィグ

vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'url'

export default defineConfig(({ command, mode }) => {
  let base = "/";
  if (command == "serve") {
    // 開発環境ではパスはそのまま
    base = "/";
  } else {
    // ビルドの際は、WordPressテーマのdistフォルダにパスが当たるようにする
    base = "/wp-content/themes/my_theme/dist/";
  }

  return {
    plugins: [vue()],
    base,
    resolve: {
      // 大したこと書いてないので省略
    },
    server: {
      port: 9000, //WordPressから読み込みたいのでポートを明示的に設定
      origin: 'http://127.0.0.1:9000'
    },
    build: {
      rollupOptions: {
        input: {
	  // それぞれのレンダリングモードに合わせたロジックを組んで出力
          hybrid: fileURLToPath(new URL('./hybrid.html', import.meta.url)),
          spa: fileURLToPath(new URL('./spa.html', import.meta.url)),
        },
      // 大したこと書いてないので省略
      },
    }  
  }
});

Vite で出力するHTMLファイル

ビルド時に出力されるhtmlには、Viteがバンドルするファイルの読み込みタグのみを含むようにしています。これをWordPressでそのまま読み込ませます(後述)。

hybrid.html
<!-- 開発時に書くのはこれだけ! -->
<script type="module" src="/src/hybrid.js"></script>

ハイブリッドページにおける Vue のマウントのタイミング

ハイブリッドを採用するページは、WordPressテーマ側で内部リンクにdata-to属性をつけて、その中にパスを書いておきます。

example.html
<body>
    <a href="https://example.com/internal/path" data-to="/internal/path">
        内部リンク
    </a>
    <a href="https://www.google.com/">
        外部リンク(イベント発火なし)
    </a>
    <div id="app">
	<!-- まだVueはマウントされておらずSSRされたコンテンツが入っている -->
        <a href="https://example.com/internal/path" data-to="/internal/path">
            内部リンク
        </a>
    </div>
</body>

ページ読み込み時にVueはマウントさせず、VanilaのJavascriptでイベントリスナーをつけ、それの発火とともに まずはVue Routerを動かしてから、 Vueをマウントします。こうしないと、遷移前のパスで一度ページが描画されてしまうため、勿体ないことになります。

hybrid.js
import { app, router, base } from "./common";

// Ctrlなどが押されていないことを確認する関数
const isModifiedEvent = (event) => {
    return event.ctrlKey || event.shiftKey || event.altKey || event.metaKey
};

let isLocallyMounted = false;

document.querySelectorAll("a[data-to]").forEach(function (e) {
    
    // data-to 属性のあるリンクがクリックされたとき
    e.addEventListener("click", function (ev) {
        // 左クリックかつCtrlなどが押されていないことを確認して
        if (ev.button === 0 && !isModifiedEvent(ev)) {
            ev.preventDefault();
            const to = e.dataset.to; // 移動先のパスを取得
	    
	    // まだVueがマウントしていないことと、
	    // 同じパスに移動していないことを確認
            if (!isLocallyMounted && location.pathname != "/" + base + to) {
	        
		// 一応、#app内をすっからかんにしておく
                const node = document.getElementById("app");
                node.removeAttribute("class");
                node.setAttribute("id", "app");
                while (node.firstChild) {
                    node.removeChild(node.firstChild);
                }
		
		// マウントされたと定義
                isLocallyMounted = true;
		
		// さきにrouterを動かさないと、
		// 現在のパスでマウントされ無駄に読み込まれる
                router.push(to)
                    .then(() => {
                        app.mount('#app');
                    });
            } else {
	        // #appの外のリンクが、マウント時にクリックされたとき
                router.push(to);
            }    
        }
    });
});

こうすることで、SSRとSPAを両立させることに加えて、#appの外にある内部リンクも<router-link>同様に扱えるようにしています。

WordPress側

開発環境ではViteサーバーから直接スクリプトを参照します。WordPressには使うスクリプトを「エンキュー」する機能がついていますが、現時点でmoduleには対応していなかったため、直書きしています。

本番環境では、Viteがcssなどをバンドルして出力するため、ビルド時に出力されるhtmlをそのまま読み込ませています。

common-header.php
<!-- ▼開発環境向け▼ -->
<?php if(MY_THEME_IS_DEV && !(is_archive() || is_search() || ($args['js'] == "hybrid"))) { ?>
    <script type="module" src="http://localhost:9000/src/hybrid.js"></script>
<?php } else if(MY_THEME_IS_DEV && (is_archive() || is_search() || ($args['js'] == "spa")) ) {?>
    <script type="module" src="http://localhost:9000/src/spa.js"></script>
<?php } 
//   ▲開発環境向け▲  //

//   ▼本番環境向け▼  //
    if(!MY_THEME_IS_DEV) {
        if((is_archive() || is_search() || ($args['js'] == "spa")) && file_exists(dirname(__FILE__). '/dist/spa.html')) {
            echo file_get_contents(dirname(__FILE__). '/dist/spa.html'). "\n";
        }
        if(!(is_archive() || is_search() || ($args['js'] == "spa")) && file_exists(dirname(__FILE__). '/dist/hybrid.html')) {
            echo file_get_contents(dirname(__FILE__). '/dist/hybrid.html'). "\n";
        }
     }
//   ▲本番環境向け▲  //
?>

あとは…?

あとは、ハイブリッド出力させるものはWordPress・Vue両方で同じロジックを実装するだけです。簡単でしょ?

効果

WordPressでSSRさせることにより、OGPやSEOについては心配する必要がなくなりました。WP Super Cacheとかと組み合わせれば、ページの読み込みもより早くなりそうです。

https://ja.wordpress.org/plugins/wp-super-cache/

また、その後の遷移にVueとWP Rest APIを使用することで、転送されるデータを少なくでき素早い表示が可能になりました。

ただ、実装は普通の工数の約2倍かけていることになるので、今後もこの方法をやりたいかと聞かれれば微妙ですね(正直、WP Super Cacheが使えればふつうのWordPressテーマで遅さを感じることはない)。

【参考】Lighthouse

SEOの調整にはまだ入っていないので参考までに。各3回測定した平均値です

  • サーバー: Coreserver V2 Core-X
  • プラグイン: なし
Mobile PC
記事ページ (Hybrid) Performance: 87
SEO: 93
Performance: 99
SEO: 92

終わりに

もうちょっとうまいやり方があったかもしれませんが、今の私にはこれが限界でした。ご指摘やアドバイスなどありましたらぜひお寄せください。

あと、たぶんこれReactとかでも応用できる気がする(JSXにノックアウトされて以降触ってないので知らない)ので、地獄を味わいたい皆さんはぜひお試しください。

Discussion