entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

vite で最高の開発体験を手に入れる

どうも、ピクシブでフロントエンドエンジニアをやっている @uzimaru0000 です。
今回は、vite を使ったプロダクト開発をしたのでそれの知見を共有したいと思います。

なぜ vite なのか

弊社のプロダクトのフロントエンドではモジュールバンドラーに webpack を利用しているケースが多いのですが、vite を使う選択をしました。
理由としては以下のような点があります。

  • 開発サーバーがとても速い
  • 設定ファイルがwebpackほど複雑じゃない

vite の特筆すべき特徴として開発サーバーがとても速いというものがあります。
これは、依存のあるモジュールをネイティブ ES モジュールとして読み込むようにすることで必要最低限のバンドルで済むようにしているためコードの変更がとても速く反映されます。
フロントエンドエンジニアが僕一人であったというのもあり、開発速度を早めるために vite のこの特徴を利用したかったのが大きな理由です。

vite と PHP を組み合わせる

pixiv はバックエンドに PHP を利用して開発しているので vite を PHP と組み合わせる(具体的には Smarty)必要があります。
基本的には公式ドキュメントの「バックエンドとの統合」の通りで良いのですが、pixiv のバックエンドはフレームワークが入っていないので vite との繋ぎこみを自分で書く必要がありました。

PHP 側にmanifest読み込みHelper実装

PHP 側に manifest.json を読み込む Helper を作ります。

viteは、データを manifest.json を吐き出すような設定があるのでそれを見て HTML にビルドした結果を埋め込みます。
それを PHP で読み込めるように Helper を作成します。
manifest.json からデータを取ってきて、CSS, JS を読み込むためのHTMLを作成しています。
getEntryPointHTML を呼び出すことでそのHTMLを取得できるので Smarty のプラグインなどを作って埋め込みます。
これで vite でビルドした結果を PHP から利用することができるようになりました!

final class Vite_Manifest_Production
{
        ...
    
        /**
         * @param string $name
         * @param string $sync_kind
         * @return string
         */
        public static function getEntryPointHTML(string $name, string $sync_kind): string
        {
            Util_Assert::string($name);
            Util_Assert::stringInArray($sync_kind, ['sync', 'async', 'defer']);
        
            $css_output = Vite_Manifest_Production::_getCssPath($name);
            $output = Vite_Manifest_Production::_getJsPath($name);
        
            return $css_output . $output;
        }
        
        /**
         * @param string $name
         * @return string
         */
        private static function _getCssPath(string $name): string
        {
            Util_Assert::string($name);
            if (!isset(Vite_Manifest_Production::$manifest[$name]['css'])) {
                return '';
            }
        
            $css_path = '';
            foreach (Vite_Manifest_Production::$manifest[$name]['css'] as $path) {
                $path = Util_String::h(Vite_Manifest_Production::$public_path . '/' . $path);
                $css_path .= "<link rel='stylesheet' href='{$path}' crossorigin='anonymous'/>";
            }
        
            return $css_path;
        }
        
        /**
         * @param string $name
         * @return string
         */
        private static function _getJsPath(string $name): string
        {
            Util_Assert::string($name);
            if (Vite_Manifest_Production::$manifest === null) {
                return '';
            }
            $public_path = Vite_Manifest_Production::getPublicPath();
            $js_file = Vite_Manifest_Production::$manifest[$name]['file'];
            $js_path = Util_String::h($public_path . '/' . $js_file);
            $js_script = "<script type='module' src='{$js_path}' charset='utf8' crossorigin='anonymous'></script>";
            $preload_script = '';
            if (isset(Vite_Manifest_Production::$manifest[$name]['imports'])) {
                foreach (Vite_Manifest_Production::$manifest[$name]['imports'] as $import) {
                    $path = Util_String::h($public_path . '/' . Vite_Manifest_Production::$manifest[$import]['file']);
                    $preload_script .= "<link rel='modulepreload' as='script' crossorigin='' href='{$path}'>";
                }
            }
            if (isset(Vite_Manifest_Production::$manifest[$name]['dynamicImports'])) {
                foreach (Vite_Manifest_Production::$manifest[$name]['dynamicImports'] as $import) {
                    $path = Util_String::h($public_path . '/' . Vite_Manifest_Production::$manifest[$import]['file']);
                    $preload_script .= "<link rel='modulepreload' as='script' crossorigin='' href='{$path}'>";
                }
            }
        
            return $js_script . $preload_script;
        }
    
        ...
}

HMR を使えるようにする

vite を使うのならやはり高速な開発サーバーと HMR を利用したいです。
pixiv では開発環境が開発サーバーで立ち上がっており、そこを見ることで動作確認を行っています。
そのため vite dev で立ち上がる開発サーバーはそのままでは利用できません。

vite dev時にどのポートで開いているのかわかるようにする

vite dev のどのポートで開発サーバーを開いているかという情報が取得できないので manifest.json というファイルで取れるようにします。
vite dev はビルドをしないので manifest.json を吐き出しませんが owlsdepartment/vite-plugin-dev-manifest を利用することで吐き出すことができます。
このとき吐き出される manifest.json は以下のような形式になります

{
    // url to base folder in your local dev server
  "url": "http://localhost:3000/",
  // inputs specified in 'build.rollupOptions.input'
    "inputs": {
        "main": "src/main.ts"
    }
}

この url を見て開発サーバーが立ち上がってることを確認します。
前項で書いた Helper でも manifest.json を読み取るようにしていたので開発サーバーの manifest.json なのかを判定する必要があります。
owlsdepartment/vite-plugin-dev-manifest はデフォルトで manifest.dev.json という名前で出力するので、そのファイルの有無で開発サーバーを利用するのかを判定します。

/**
 * @param string $path
 */
private static function _loadManifest(string $path): void
{
    Util_Assert::string($path);
    $dev_manifest_path = Vite_Common::_getDevJsonPath($path);
        // ファイルの有無で `dev` か `prod` かを判断する
    if (file_exists($dev_manifest_path)) {
        Vite_Common::$env = 'dev';
    } else {
        Vite_Common::$env = 'prod';
    }
}

/**
 * @param string $path
 * @return string
 */
private static function _getDevJsonPath(string $path): string
{
    return Vite_Common::$manifest_dir . '/' . $path . '.dev.json';
}

PHP 側にmanifest読み込みHelper実装

先程と同じように PHP 側に manifest.json を読み込む Helper を作成します。

final class Vite_Manifest_Development
{
        ...
        
        /**
         * @param string $name
         * @param string $sync_kind
         * @return string
         */
        public static function getEntryPointHTML(string $name, string $sync_kind): string
        {
            Util_Assert::string($name);
            Util_Assert::stringInArray($sync_kind, ['sync', 'async', 'defer']);
        
            return Vite_Manifest_Development::_getEntryPointHTML();
        }
        
        /**
         * @return string
         */
        private static function _getEntryPointHTML(): string
        {
            if (Vite_Manifest_Development::$manifest === null) {
                return '';
            }
            $localhost = Vite_Manifest_Development::$manifest['url'];
            $vite_client = "<script type='module' src='{$localhost}@vite/client'></script>";
            $entry_src = $localhost . Vite_Manifest_Development::$manifest['inputs']['main'];
            $entry_path = Util_String::h($entry_src);
            $entry_script = "<script type='module' src='{$entry_path}'></script>";
            $react_refresh = "<script type='module'>
               import RefreshRuntime from '{$localhost}@react-refresh'
               RefreshRuntime.injectIntoGlobalHook(window)
               window.\$RefreshReg$ = () => {}
               window.\$RefreshSig$ = () => (type) => type
               window.__vite_plugin_react_preamble_installed__ = true
           </script>";
            return $react_refresh . $vite_client . $entry_script;
        }
        
        ...
}

_getEntryPointHTML の中身は公式ドキュメントを参考にしました。
あとは本番と同じように getEntryPointHTML の中身を Smarty に埋め込みます。

HMR に使われる WebSocket の設定をする

基本的にはこの状態で動くと思うのですが、 pixiv の開発環境は https なので HMR に使われる WebSocket の設定をする必要があります。
vite の開発サーバーは、デフォルトだと WebSocket のプロトコルに location.protocol の値を参照しています。 (https://github.com/vitejs/vite/blob/main/packages/vite/src/client/client.ts#L23-L24) https のときは wss が使われるのですが、WebSocket の接続先は localhost で証明書を発行していないので ws にする必要があります。
そのために、 vite.config.tsserver.hmr.protocol を明示的に指定する必要があります。

{
    ...
    server: {
        hmr: {
            protocol: 'ws',
        }
    },
    ...
}

また、WebSocket の接続先に今の hostname が利用されてしまうので server.hmr.host も明示的に指定する必要があります。

{
    ...
    server: {
        hmr: {
            protocol: 'ws',
            host: 'localhost'
        }
    },
    ...
}

これで HMR が利用できるようになりました!

まとめ

少しだけ工夫をする必要がありましたがとても快適な開発体験を得ることができました!
vite を利用してますが、開発には React を使用しているので Vue 以外でも問題なく開発することができます。
みなさんも vite を使って最高の開発体験を手に入れてみてください!

uzimaru
2021年入社のフロントエンドエンジニア。pixiv のマンガ周りの機能開発をしてます。Rust とか Elm とか料理が好きです。