Top View


Author Naoya Miyagawa

CakePHPのMPAにViteを導入して開発を加速させる⚡️

2022/03/03

🌱 はじめに

こんにちは。
自社プロダクト「360(さんろくまる)」を手掛けている技術開発第一部門の みやがわ です。

今日は、昨年2021年からフロントエンド界隈を盛り上げている
高速ビルドツール「Vite」を非SPAな従来のWebアプリに導入してみる、という内容をご紹介します。

タイトル通りバックエンドフレームワークにCakePHPを題材としてあげていますが、
対応方針はどのフレームワークでも変わらないのでLaravelやRailsを使ってる方にも参考になるかと思います。


🌱 内容まとめ

  • MPAでもHMRとホットリロードの恩恵を受けられる✨
  • CakePHPに導入するためのヘルパーを作ろう✨
  • フロントエンドの世界はViteに、バックエンドの世界はvite-plugin-live-reloadに頼る✨

🌱 環境

  • CakePHP: 4.3
  • Vite: 2.8.4
  • vite-plugin-live-reload: 2.1.0
  • その他(Tailwind CSS: 3.0.23, Sass: 1.49.9, Vue: 3.2.31)

🌱 ゴール

メインは「MPAの画面UI作成/改修時の開発効率を上げること」ですが、副次的に下記の恩恵もあります。

  • モダンな技術を導入しやすくなる(TypeScript, Vue, Tailwind CSS, ...)
  • ネットワークオーバーヘッド改善(読み込みファイル数削減による)

想定しているディレクトリ構成は、cakephp4 インストール時のものを基本にしています。

最終的には、resources 配下に置いたバンドル前のファイルを、
ディレクトリ構成を保ったまま、webroot/js 配下にバンドル結果を出力します。

各画面に対して 1 js,1 css ファイルが生成されるイメージで、
それらを今回作成する1ヘルパーメソッドによる読み込みで完結させます。


- src/
  - Controller/
    - HogesController

- templates/
  - Hoges/
    - index.php  // ( $this->Vite->script('Hoges/index') で読み込み )

- resources/     // input
  - js/
    - Hoges/
      - index.js (.ts)
  - css/
    - Hoges/
      - index.css (.scss)

- webroot/      // output
  - js/
    - manifest.json
    - Hoges/
      - index.[hash].js
      - index.[hash].css

🌱 ViteのMPAアプリ統合の方針

Viteのドキュメントに「Backend Integration」という項目があり、Laravelライクな構文で紹介されています。


要約

  • 開発では、Vite サーバー( localhost:3000 )を介して js ファイルへアクセスする。
    • localhost:3000/@vite/client)
    • localhost:3000/[asset-name].js
  • 本番では、manifest ファイルを参照してバンドル後の js,css にアクセスする。
    • /[asset-dir]/[bundled-asset-name].js
    • /[asset-dir]/[bundled-asset-name].css

開発時と本番で参照方法が異なるのは下記の差があるためです。

  • 開発時: Vite サーバーが esbuild を利用し、エントリーポイントとなる .html や .js/.ts を起点に関連モジュールを読み込む
  • 本番時: rollup を用いたバンドルを行う

なおビルド時に manifest.json が生成されます。
これはエントリーポイントをキーとし、バンドル後の js/css ファイルのパスを要素に持つ json ですので、間接的なファイル参照に利用できます。

manifest.json (例)

{
  "resources/js/Hoges/hoge.ts": {
    "file": "Hoges/hoge.ts.465900fa.js",
    "src": "resources/js/Hoges/hoge.ts",
    "isEntry": true,
    "imports": [
      "_vendor.c46450cb.js"
    ],
    "css": [
      "Hoges/hoge.ts.6192ceee.css"
    ]
  },
  "_vendor.c46450cb.js": {
    "file": "vendor.c46450cb.js"
  }
}

つまり読み込み方法の切り替えさせ出来れば、SPA/MPA 関係なく、Vite を導入できるということですね。分かれば簡単です。


🌱 Vite インストール

npm, yarn などのパッケージマネージャーにてインストールできます。今回は pnpm を利用します。

pnpm add vite glob path @types/glob @types/node

開発用および本番ビルド用のコマンドを package.json にスクリプト化しておきます。

  • package.json

    {
    
    	"scripts": {
            "dev": "vite",
            "build": "vite build",
        },
    
    }
    

🌱 Vite 設定 複数エントリーポイント

MPA に導入する際の肝となる部分が、複数エントリーポイントの登録です。
これを実現するために、vite の rolleupOptions に glob で取得したビルド前ファイルのパスを渡します。
下記の entries がそれに当たります。

  • vite.config.ts
    import { defineConfig } from "vite";
    import path from "path";
    import glob from "glob";
    
    const entries = {};
    const srcDir = "./resources/js";
    const distDir = `./webroot/js`;
    
    const srcFileKeys = glob.sync("**/*.+(js|ts)", { cwd: srcDir });
    srcFileKeys.map((key) => {
      const srcFilepath = path.join(srcDir, key);
      entries[key] = srcFilepath;
    });
    
    export default defineConfig({
      build: {
        outDir: distDir,
        emptyOutDir: true,
        assetsDir: "",
        manifest: true,
        rollupOptions: {
          input: entries,
        },
      },
      // optional
    	alias: {
        "@": "/resources",
      },
    });
    

entriesの形式はこのようになります。

entries: { 'Hoges/hoge.ts': 'resources/js/Hoges/hoge.ts' }

🌱 Vite 用の View ヘルパーを作る

開発/本番時の各読み込み方法を切り変える処理をこれから作成する Vite 用の ViewHelper に閉じ込めます。

<?php
$this->Vite->script('Hoge/index', ['block' => true]);

この ViteHelper でやることは前述の要約の通りですが、もう少し詳細を書くと下記です。


debug モード(開発時)の場合

  • script() で指定されたファイルを module として読み込む script タグを出力
  • vite client未読込時は、vite client用のscriptタグも出力

非 debug モード(本番時)の場合

  • webroot/js/manifest.json を読み込む
  • script() で指定されたファイル名をキーとし、manifest データからバンドル後のjs,cssファイルのパスを取得
  • バンドル後の js ファイルを module として読み込む script タグと css ファイルを読み込む link タグを出力

ViteHelper

  • src/View/AppView.php

    <?php
    namespace App\View;
    
    use Cake\View\View;
    
    /**
     * @property \App\View\Helper\ViteHelper $Vite
     */
    class AppView extends View
    {
        public function initialize(): void
        {
            $this->loadHelper('Vite', ['className' => 'App\View\Helper\ViteHelper']);
        }
    }
    
  • src/View/Helper/ViteHelper.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\View\Helper;
    
    use Cake\Core\Configure;
    use Cake\View\Helper;
    use InvalidArgumentException;
    use RuntimeException;
    
    /**
     * @property \Cake\View\Helper\HtmlHelper $Html
     */
    class ViteHelper extends Helper
    {
        /** vite server */
        private const VITE_SERVER_URL = 'http://localhost:3000/';
        /** vite client */
        private const VITE_CLIENT = self::VITE_SERVER_URL . '@vite/client';
        /** js pre-build assets path */
        private const JS_PREBUILD_PATH = 'resources/js/';
    
        /** @var string[] */
        protected $helpers = ['Html'];
    
        /** @var string[] */
        protected $_defaultConfig = [
            'manifestFile' => WWW_ROOT . 'js/manifest.json',
        ];
    
        /** @var array manifest.json */
        protected $manifest = [];
    
        /** @var bool whether emitted vite client script */
        protected $hasViteClientEmitted = false;
    
        /**
         * initialize
         */
        public function initialize(array $config): void
        {
            parent::initialize($config);
    
            if (!Configure::read('debug')) {
                $this->manifest = $this->readManifestData();
            }
        }
    
        /**
         * Creates a script element for vite-bundled JS file.
         * @param string $name
         * @param ?array $options
         * @return string
         */
        public function script(string $name, $options = []): string
        {
            if (isset($options['type'])) {
                throw new InvalidArgumentException('Not allowed to set \'type\' option.');
            }
    
            $scriptOptions = $options + ['type' => 'module'];
    
            if (Configure::read('debug')) {
                $filePath = self::VITE_SERVER_URL . self::JS_PREBUILD_PATH . $name;
                $scriptTag = '';
    
                if (!$this->hasViteClientEmitted) {
                    $this->hasViteClientEmitted = true;
                    $scriptTag .= $this->Html->script(self::VITE_CLIENT, $scriptOptions) . '\n';
                }
    
                return $scriptTag . (string)$this->Html->script($filePath, $scriptOptions);
            }
    
            $asset = $this->getAssetOnManifest($name);
            if (empty($asset['file'])) {
                throw new RuntimeException("The `{$name}` asset has no file attribute in the manifest.");
            }
    
            return
                (string)$this->Html->script($asset['file'], $scriptOptions)
                . (string)$this->css($asset, $options);
        }
    
        ##############################################################################
        # Private Methods
        ##############################################################################
    
        /**
         * Creates a link element for vite-bundled CSS stylesheets.
         * @param array asset
         * @param ?array $options
         * @return string
         */
        private function css(array $asset, $options = []): string
        {
            if (empty($asset['css'])) {
                return '';
            }
    
            $cssTags = [];
            foreach ($asset['css'] as $css) {
                $cssTags[] = (string)$this->Html->css('/js/' . $css, $options);
            }
    
            return implode("\n", $cssTags);
        }
    
        /**
         * Get asset path object from manifest data
         * @return array
         */
        private function getAssetOnManifest(string $name): array
        {
            $pathInTs = self::JS_PREBUILD_PATH . $name . '.ts';
            if (isset($this->manifest[$pathInTs])) {
                return $this->manifest[$pathInTs];
            }
    
            $pathInJs = self::JS_PREBUILD_PATH . $name . '.js';
            if (isset($this->manifest[$pathInJs])) {
                return $this->manifest[$pathInJs];
            }
    
            throw new RuntimeException("No known asset with `{$name}`");
        }
    
        /**
         * Load manifest file and return decoded data
         * @return array
         */
        private function readManifestData(): array
        {
            $manifestFile = $this->getConfig('manifestFile');
    
            $contents = file_get_contents($manifestFile);
            if (!$contents) {
                throw new RuntimeException("Could not read manifest file `{$manifestFile}`");
            }
    
            $data = json_decode($contents, true);
            if (json_last_error()) {
                throw new RuntimeException("Could not parse JSON in `{$manifestFile}`");
            }
    
            return $data;
        }
    }
    

デモ(開発/本番環境でのassetファイル読み込み)

ページ表示用の適当な view ファイルを作り、cakephp のビルトインサーバーで起動します。

  • src/Controller/HogesController.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Controller;
    
    class HogesController extends AppController
    {
        public function index()
        {
            $this->viewBuilder()->disableAutoLayout();
        }
    }
    
  • src/templates/Hoges/index.php

    <?php
    $this->Vite->script('Hoges/index', ['block' => true]);
    ?>
    
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <?= $this->fetch('meta') ?>
        <?= $this->fetch('css') ?>
    </head>
    
    <body>
        <div class="el_btn">Count: 0</div>
    
        <?= $this->fetch('script') ?>
    </body>
    
    </html>
    
  • resources/js/Hoges/index.js

    import '@/css/Hoges/index.css';
    
    let count = 0;
    const btn = document.getElementsByClassName('el_btn')[0];
    
    btn.addEventListener('click', (event) => {
        count++;
        event.target.innerText = `Count: ${count}`;
    });
    
    
  • resources/css/Hoges/index.css

    .el_btn {
        width: 100px;
        padding: 20px;
        color: white;
        background-color: rgb(127, 201, 127);
        text-align: center;
        border-radius: 30px;
        user-select: none;
    }
    .el_btn:active {
        background-color: rgb(94, 151, 94);
        transform: translateY(2px);
    }
    

開発モード

pnpm dev

js ファイルが通常通り読み込まれ、追加で @vite/client が読み込まれています。
エントリーポイント自体の更新に対してはホットリロードが適用されます。

Image from Gyazo


一方で、css を含む外部モジュールの読み込みや変更に関しては HMR が適用されます。
ページリロードを発生させずに、デザインを当てることができるので快適です✨

Image from Gyazo


本番モード

 pnpm build

vite v2.8.4 building for production...
 3 modules transformed.
webroot/js/manifest.json                 0.38 KiB
webroot/js/Hoges/_index.ts.63b6758e.js   0.16 KiB / gzip: 0.15 KiB
webroot/js/Hoges/index.js.51648039.js    0.16 KiB / gzip: 0.15 KiB
webroot/js/index.13afb624.css            0.25 KiB / gzip: 0.17 KiB

cakephpの config/.env のデバッグモードを OFF にしてビルトインサーバーを再起動します。

@vite/client の読み込みがなくなり、js,css の読み込みパスがハッシュ付きのものに変わっていますね。


Image from Gyazo


Tailwind CSS 導入

Vite 用のインストールガイドに沿ってインストールし、設定ファイルを作成します。

pnpm add tailwindcss autoprefixer postcss
  • tailwind.config.js

    module.exports = {
        content: [
            "./resources/**/*.{js,jsx,ts,tsx,vue}",
            "./templates/**/*.php",
        ],
        theme: {
            extend: {},
        },
        plugins: [],
    };
    

デモ(Tailwind CSS)

  • resources/css/tailwind.css
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
  • resources/css/Hoges/index.css
    @import "@/css/tailwind.css"; // 追加
    
    

Tailwind CSS の読み込みにより内部の reset.css が読み込まれ、またユーティリティクラスも適用されますね✨


Image from Gyazo


🌱 TypeScript 導入

Vite はデフォルトで TypeScript をサポートしているので、js ファイルの拡張子を .ts に書き換えるだけで OK です。 Webpack のときのような、ts-loader を入れて設定ファイル編集する、という作業が不要になります。すてきですね。 https://vitejs.dev/guide/features.html#typescript

ファイル名変更

・resources/js/Hoges/index.js ../index.ts

🌱 Scss 導入

Vite は Scss 用に特別なプラグインをインストールする必要はないので、導入したい CSS プリプロセッサをインストールすれば OK です。 https://vitejs.dev/guide/features.html#css-pre-processors

pnpm add sass

ファイル名変更

・resources/css/Hoges/index.css ../index.scss

Tailwind CSS の導入と合わせて、index.css をリファクタリングするとこのようにできます。

``

@import "@/css/tailwind.css";

.el_btn {
    @apply text-white text-center w-[200px] bg-[#7f9dc9] p-5 rounded-[30px] select-none;

    &:active {
        @apply bg-[#5e965e] translate-y-[2px];
    }
}

🌱 Vue 導入

Vite で Vue を利用する際は、Vite 用の Vue プラグインをインストールして、plugins に登録するだけです。

pnpm add @vitejs/plugin-vue

vite.config.ts

import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()]
});

デモ(Vue)

前のデモで作ったファイルに vue の読み込みと SFC(Single-File Component)ファイルを追加します。

  • src/templates/Hoges/index.php

     ︙
    <body>
        <div id="app"></div> // 追加
      ︙
    
  • src/resources/js/Hoges/index.ts

    import "@/css/Hoges/index.scss";
    import { createApp } from "vue"; // 追加
    import App from "./App.vue"; // 追加
    
    const app = createApp(App); // 追加
    app.mount("#app"); // 追加
    
  • src/resources/js/Hoges/App.vue

    <script setup lang="ts">
    import { ref } from "vue";
    
    const count = ref(0);
    </script>
    
    <template>
      <div class="el_btn" @click="count++">Count: {{ count }}</div>
    </template>
    
    <style lang="scss" scoped>
    .el_btn {
      @apply m-4;
    }
    </style>
    

Vue に対してももちろん HMR が適用されます。とても強力です。


Image from Gyazo


🌱 vite-plugin-live-reload 導入

ここまででフロントエンドの世界になれば Vite の力で即時反映ができました。
欲を言えばバックエンドのテンプレートエンジンで DOM を変更したときにも即時反映されるとうれしいです。

そこで vite-plugin-live-reload のご紹介。
https://github.com/arnoson/vite-plugin-live-reload

readme に書いてある通り、 vite.config.ts に追加します。
今回は view の変更だけを見たいので templates のみにしますが、プロジェクトの状況に応じて他ディレクトリを追加するとよさそうです。

import liveReload from 'vite-plugin-live-reload'

export default defineConfig({
  // ...
  plugins: [
    liveReload('./src/templates/**/*.php'),
  ],
})

デモ(vite-plugin-live-reload)

devtool のコンソールセクションがリフレッシュされている通り、
templates/**/*.php の変更時に自動でページリロードを起こしてくれています。

このホットリロード機能にあたるものは、「BrowserSync x Gulp/Laravel Mix」の組み合わせで以前記事にしていますが、Vite ベースのものは軽量で高速です。


Image from Gyazo


おわりに

Vite に関して SPA での導入ガイドや記事が多いですが、
実情として息の長いサービスや開発メンバーの技術スタックに合わせて MPA を採用するケースは少なくないと思います。

そんなケースにおいて、この記事が Vite 導入の足がかりになればうれしいです😋

こういった基盤改善はその後の開発効率に大きく影響するので、
トレンドをキャッチアップして目的に合わせて柔軟に取り入れていきたいですね✨


参考

CakePHPで導入する際にCakeFestの動画を参考にさせていただきました🙏 Thanks alot :>

今回サンプルで作成したリポジトリ



補足

その他バックエンドフレームワークとVite統合

CakePHPを含め、LaravelやRailsにはVite統合用のプラグインがすでにあるようです。

ちゃんとは見ていませんが SPA 寄りな印象を受けたので、この記事は参考になると思います :)

Tailwind CSS と Vite

どうやら Tailwind CSS を使っている場合、バックエンドのテンプレートエンジンを更新したときもページリロードが走るようです。

上のデモの例ではすでに Tailwind CSS を使っているので vite-plugin-live-reload は最悪無くてもいいですが、抜け漏れが無いほうが幸せなので導入しておくのが吉だと思います。


Naoya Miyagawa

Naoya Miyagawa

Twitter X

Hi🙋🏻‍♂️ Web Developer in Fukuoka, Japan🇯🇵 ❄️ Fusic Co., Ltd.|360 (さんろくまる) ❄️ PHP: CakePHP ❄️ JS: Vue.js ❄️ AWS: SAP ❄️ FAV: nm7/🍣/SG🇸🇬