Open4

Laravel, Vite, Vueで古いブラウザをサポートする

okita kamegorookita kamegoro

※ 明確にどうするといった記述はなく、どのような手法が取れるかなるべく網羅的に書くようにしている

ある時、iPadを利用しちるお客さんからサービスが正常に動作しない指摘をいただいた。
聞いてみると詳細な原因については特定には至らなかったが、端末依存の問題らしい。

実機を持っておらず、詳細を調査することが困難であったため、クラウドで実機テストできるサービスを利用してみた。

https://www.browserstack.com/
https://app.lambdatest.com/

フリートライアルで利用できるのはわずか30秒〜2分程度であるがその間に「ページが動作しないこと」、「DevToolでエラーが発生していること」を確認しおそらくiOSのバージョン(つまりSafariのバージョン)に依存した問題であることまでわかった。
(本当に便利なサービスなのでもし今後も使うようになりそうなら課金したい($7~40くらいだった))

特に気になったのはDevToolで出力されていた以下のエラー

syntaxError Unexpected token '?'

おそらくであるが、オプショナルチェーンやnull合体演算子が利用できないか何かだろう。
例えばモバイルSafariでは null合体演算子のサポートは 13.4以降 [1][2] のようで、お客様のiOSバージョンよりも高いことからこれらが問題であるとした。

[1] https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing
[2] https://caniuse.com/mdn-javascript_operators_nullish_coalescing

ここまでわかれば、あとは対応を行うのみである。

Vue.js のレガシー環境への対応方法

https://cli.vuejs.org/guide/browser-compatibility.html

browserslist を使った対応ブラウザの指定

レガシー環境への対応とはやや異なるかもしれないが、プロジェクトでサポートしているブラウザなどを指定することでビルドの最適化を図ることができる。

browserslist は polyfill系 に用いられるプロジェクトで対応しているブラウザやバージョンを指定するための仕組み?ツール。

browserslist を解釈して対応してくれるようなビルドツールは以下の通りである。

  • Autoprefixer
  • Babel
  • postcss-preset-env
  • eslint-plugin-compat
  • stylelint-no-unsupported-browser-features
  • postcss-normalize
  • obsolete-webpack-plugin

ref: https://github.com/browserslist/browserslist

例えば、Autoprefixerはbrowserslistの設定を読んで、CSSにどのベンダープレフィックスを付けるかを決める。

これらの設定は package.json.browserslistrc に指定する。
例えば vue3 ではプロジェクトを作成した段階で、以下のような設定が package.json に記載されている。

{
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

またプロジェクトでサポートしているブラウザは以下で確認が行える

npx browserslist

Modern Mode

Vue.js のモダンモードでは最新のブラウザの仕様と古いブラウザの仕様をうまく利用した対応を行なっている。
結論から言うと、このモダンモードを適用した場合、出力されるjsとHTMLに埋め込まれるscriptタグは以下のようになる

https://caniuse.com/es6-module

<script defer="defer" src="/js/chunk-vendors.b94429f8.js" type="module"></script>
<script defer="defer" src="/js/app.52172fba.js" type="module"></script>

<script defer="defer" src="/js/chunk-vendors-legacy.88d135c4.js" nomodule></script>
<script defer="defer" src="/js/app-legacy.7a0f538a.js" nomodule></script>

主な違いは、app.jsapp-legacy.js のような2パターンのjsが出力されており、scriptタグの属性が異なるものが指定されている点にある。

比較的モダンなES2015以上をサポートしたブラウザではモジュールを利用することができ、 script タグ のtype属性にmoduleプロパティを指定することでモジュールとして読み込みが行われる。 一方でモジュールをサポートしていないレガシーなブラウザではtype="module"を解釈できず scriptタグの読み込みを行わない。 一方で、モジュールをサポートしているモダンなブラウザはnomodule` 属性を持った scriptタグの読み込みをスキップするため2重でスクリプトを読み込むことがない。
ref: https://html.spec.whatwg.org/multipage/scripting.html#attr-script-nomodule

通常、Babelを用いることで Polyfill を適用してレガシーなブラウザでも最新の機能を利用できるようにすることができる。
ただしこの方法ではポリフィルのバンドルをリリースすることとなり単にビルドするよりも冗長になるだけでなく、パースや実行速度が遅くなるといったデメリットがある。
上記のように2つのスクリプトをビルドして読み込ませるものをブラウザごとに分けることで非常に簡単にレガシーなブラウザの対応ができるようになるのでかなり面白い方法だと思う。
この方法はPhillip Walton'sさんが提唱したものらしいが内容は読んでいないので下記にLinkを貼っておく。
https://philipwalton.com/articles/deploying-es2015-code-in-production-today/

okita kamegorookita kamegoro

Laravel, Vite, Vue3 のレガシーブラウザへの対応

上記の話を前提に、Lararvel と Vite, Vue3 を使っているプロジェクトでの対応を行う

主に以下のステップにより完了できる

  1. @vitejs/plugin-legacyterser のインストール
  2. vite.config.js の修正
  3. @vite ディレクティブの修正
  4. ServiceProviderの作成

1. @vitejs/plugin-legacyterser のインストール

ESMに対応していないレガシーなブラウザ向けにビルドを行ってくれるライブラリ

https://github.com/vitejs/vite/tree/main/packages/plugin-legacy#readme

また依存関係として terser が必要になる

yarn add @vitejs/plugin-legacy --dev
yarn add terser --dev

@vitejs/plugin-legacy は nodeが ^18.0.0 || >=20.0.0 のみのサポートとなり v19.0.0 などは利用できないので要注意

2. vite.config.js の修正

@vitejs/plugin-legacy の基本的な利用方法は以下の通り
https://github.com/vitejs/vite/tree/main/packages/plugin-legacy#usage

どのブラウザをサポートするかターゲットを指定する。

import legacy from '@vitejs/plugin-legacy'

export default defineConfig({
    plugins: [
        vue(),
        laravel(src),
        legacy({
            targets: ['defaults', 'ios_saf >= 12', 'not IE 11'],
            modernPolyfills: true
        })
    ],
})

ここの設定は以下の記事を参考にしており、今回の対応にとても役立った。

https://techblog.lycorp.co.jp/ja/20231218a

vite.config.js を修正したことにより、app.js に加えて、 app-legacy.jsのようにレガシーブラウザ向けのjsがビルドされるようになる。

vite build

出力されるファイルやmanifest.json-legacy.js が含まれているか確認する。
出力されていればいったんは大丈夫そう。

3. @vite ディレクティブの修正

*-legacy.js が生成されたのであれば、 laravelのblade上で読み込みを行えるようにする必要がある。

通常、viteで生成された js や css は@viteディレクティブを用いてbladeに埋め込みを行う。

// 例えば app.js を読み込む場合
@vite(['resources/js/app.js'])

これに *-legacy.js を追加する

// 例えば app.js を読み込む場合
- @vite(['resources/js/app.js'])
+ @vite(['resources/js/app.js', 'resources/js/app-legacy.js'])

この時、manifest.jsonapp-legacy.js が存在しないとエラーになるので要注意
これで app-legacy.js がbladeに反映されるようになる。

4. ServiceProviderの作成

通常、 @vitejs/plugin-legacy でレガシーブラウザ向けの対応を行うと、吐き出された html上で type="module"nomodule の対応が行われる。
しかしLaravelを用いたプロジェクトの場合は laravel-vite-plugin を用いており、 @vite ディレクティブがうまいことスクリプトタグを生成することで動作している。

laravel-vite-plugin では @vitejs/plugin-legacy の対応を行う様子がなく、自分でなんとかする必要がある。

driesvints: Right now, we don't aim to be compatible with this library, sorry.
https://github.com/laravel/vite-plugin/issues/114

この対応というのが前述した「Modern Mode」への対応である。
通常の app.jstype="module で読み込み、 app-legacy.jsnomodule で読み込むように対応するものである。

例えば何も対応しない状態ではどのようになっているか確認してみる。

vite build

viteのビルドを行い、public/build/assets にビルドファイルが出力されている状態でviteの開発サーバーを起動せずにページを開く。

すると、app.jsapp-legacy.jstype="module" で読み込まれていることがわかる。
さらに <link rel="modulepreload" href="................/app-legacy-0000.js"> のようにプリロードも行われている。

app-legacy.jsnomodule 属性になるようにしなければならなく、app-legacy.js をモダンブラウザでプリロードするのはあまり好ましくないので行わないように変更したい。

そこで ServiceProvider で Vite の書き出しを制御する方法をとる。

下記の ServiceProvider を作成して config/app.php に登録する

app/Providers/ViteServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;

class ViteServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        // -legacy が含まれているもののみ preload しないようにする
        Vite::usePreloadTagAttributes(function ($src) {
            if (str_contains($src, '-legacy')) {
                return false;
            }
            return [];
        });
        // -legacy が含まれているもののみ nomodule 属性を付与する
        Vite::useScriptTagAttributes(function (string $src, string $url) {
            if (str_contains($src, '-legacy')) {
                return [
                    'type' => 'text/javascript',
                    'nomodule',
                ];
            }
            return [
                'type' => 'module',
            ];
        });
    }
}

この対応を行ったのちに再度確認すると、 app.js には type="module" が付与され、 app-legacy.js には nomodule が付与される状態となっているはずである。

これで対応がおそらく完了するはず