Posted at

あまり頭を使わずVue + Vuetify + TypeScriptの初期描画を高速化するためにやったこと


やりたいこと

Vue + Vuetify + TypeScriptで書かれたWebアプリをはじめて開いたときの描画を高速化したいです。初期描画が早いと第一印象が良く速いほどと嬉しいです。ロジック部分を高速化できそうなアルゴリズム書き換えたりせず、なるべく単純作業だけで高速化できそうなことをまとめます。

具体的には「マルチデバイス間でファイル転送を手軽にしたい!スマホでもPCでもcurlでも - Qiita」のWebアプリを高速化します。もともと、デスクトップ環境だとそれほど初期描画の速度は気にならないのでモバイルでの初期描画の比較を以下に載せます。


高速化の前と後での比較

最終的にどれぐらいの差が出たかの比較動画です。

左が高速化前で、右が高速化後です。

piping-ui-before-after-dynamic.mp4.opt.gif

画質が良い動画: https://youtu.be/I9VTpnpB6OM

体感でも描画までにかかる速度の時間がわかります。

iOS (iPadOS)のGoogle Chromeで、キャッシュなどを排除するためすべてのタブを消したシークレットモードで各ページを開いて録画しました。

(PWAのおかげで1度開けば、モバイルでも元々そこまで初期描画に時間はかかりませんでした。)

ブランチデプロイ出来るNetlifyを生かして高速化の前と後のPageSpeed Insghtsのスコアを比較します。


高速化前

https://before-dynamic-import--piping-ui.netlify.com/


高速化後

https://after-dynamic-import--piping-ui.netlify.com/

PageSpeed Insightsのスコアは多少変動あって同じサイトでも70台とか89とかのときもありました。

元々、dynamic importも非同期コンポーネントも一切使っていなかったのでそれらを使うと高速化したという話です。それに加えてフォント/アイコン関連も重かったのでそれらのインポートを変更したことについて書きます。


非同期コンポーネント

単純に以下のようにimport .. from ...const ... = () => import(...)のように書き換えます。

// 同期(普通)

import PipingUI from '@/components/PipingUI.vue';

// ==>

// 非同期
const PipingUI = () => import('@/components/PipingUI.vue');

@Components({components: {...}})の部分を変える必要などはありませんでした。描画に必要のない(v-ifで表示されないなど)コンポーネントはロードされないため、そのコンポーネントに関するJavaScriptやCSSが実行を初期にしなくてすみ高速化出来ます。

Vue公式: 動的 & 非同期コンポーネント — Vue.js


コンポーネント化する

コンポーネントとして切り出して上記の非同期コンポーネントとしてimportすると初期描画が高速化することがあります。コンポーネント複数回使ったり再利用しなくても役割でいくつか切り出すと速度に差が出ました。

具体的には、今回高速化対象のWebアプリはOSSライセンスを一覧する機能があって、そのライセンス情報をJSONと保持しています。コンポーネント化して、非同期コンポーネントとしてimportすると、特定のボタンが押されるまでライセンスのJSONロードなどが遅延され、初期描画が早くなりました。


Dynamic importする

以下のようにして書きなおしました。

// 同期(普通)

import * as FileSaver from 'file-saver';

// ==>

// 非同期
const FileSaverAsync = () => import('file-saver');

以下のように、使うときはawait FileSaverAsync();でライブラリがロード出来ます。async-awaitの文脈で行います。

(async () => {

// ロード
const FileSaver = await FileSaverAsync();
FileSaver.saveAs(myBlob, fileName);
})();

* as ...のようにしていないdefaultを読み込んでいる場合は以下のように書き直しました。

import JSZip from "jszip";

// ==>

const JSZipAsync = () => import('jszip').then(p => p.default);

Dynamic importするとasync-awaitが必要になるため、async-awaitな算出プロパティが欲しくなることがあります。その時は以下の方法を使います。


Dynamic import - 非同期な算出プロパティが欲しい

vue-async-computedというライブラリを使います。

TypeScriptでこのライブラリを使う方法は以前に以下の記事でまとめて、今回もこの方法を使いました。

Vue + TypeScriptで非同期な算出プロパティを使いたい! - Promiseとかasync-await - Qiita

ただし、Vuetify 2.xになってからTypeScriptとvue-async-computedの組み合わせで上記の記事通りやってもコンパイルエラーします(1.5だと大丈夫です)。

そのため、上記の記事でtypes/vue-async-computed.d.tsを作成するステップは飛ばして、以下のようにmain.ts@ts-ignoreをつけて無理やりvue-async-computedをインポートします。


main.ts

// @ts-ignore

import AsyncComputed from 'vue-async-computed';

対応するコミットはこれです: https://github.com/nwtgck/piping-ui-web/commit/0f5874e8658e634b6ab8b382dbc5fcaba4e36332#diff-8cfead41d88ad47d44509a8ab0a109ad


マテリアルデザインのアイコンで@mdi/jsを使う

お手軽な方法はnpm i -D @mdi/fontでインストールして、import '@mdi/font/css/materialdesignicons.cssする方法です。こうすれば<v-icon>mdi-account</v-icon>と書いたものが自動でmid-accountに対応するアイコンになってくれます。

ですが、これだと使ってないアイコンもbundleされてサイズが増えて初期描画時の負担になります。そこで@mdi/jsを使います。以下のようにインストールします。


インストール

npm install -D @mdi/js


Vuetify 2以降の場合は、src/plugins/vuetify.tsを以下のようにします。


src/plugins/vuetify.ts

import Vue from 'vue'

import Vuetify from 'vuetify/lib'

Vue.use(Vuetify)

export default new Vuetify({
icons: {
iconfont: 'mdiSvg',
},
})


以下のように書くことで、必要に応じてアイコンをインポートできるようです。変数mdiAccount"mdi-account"が入っています。

import { mdiAccount } from '@mdi/js'

変更のコミットです: https://github.com/nwtgck/piping-ui-web/commit/bf0d67279d253a8c5d07d9014ea78769279e2ce4。変更の参考になるかも知れません。

Vuetify公式: Icons — Vuetify.js


Font Awesomeのアイコンを最適化する

最適化前は以下のようにインポートしてました。


src/main.ts

// 最適化前

import '@fortawesome/fontawesome';
import '@fortawesome/fontawesome-free-brands';

ただ、今回のプロジェクトではGitHubのアイコンだけが欲しかっただけで上記だと不要なものがたくさんありました。そこで、以下のvue-fontawesomeのREADME.mdに書かれている方法を使って必要なアイコンだけのインポートに変更しました。

Fort Awesome公式: FortAwesome/vue-fontawesome at 700a86cb1a3726364de7137d0cbee2e00fcfd30d


src/main.ts

import { library } from '@fortawesome/fontawesome-svg-core'

// 好きなアイコンだけインポート(今回はGitHubロゴだけ)
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'

library.add(faGithub);
Vue.component('font-awesome-icon', FontAwesomeIcon);


Vueの<template>内では<font-awesome-icon :icon="['fab', 'github']" />のようにして使います。

変更のコミットです: https://github.com/nwtgck/piping-ui-web/commit/e05fee06486e35f805774b9f917e789331b13cb5


CSSの非同期インポート

TODO: レンダリングブロックすると思って非同期でインポートしましたが、効果があるかは要調査です(bundle後の自動生成のコードを未確認)。

import 'filepond/dist/filepond.min.css';

// ==>

(async () => require('filepond/dist/filepond.min.css'))();

PageSpeed Insights上で「上記のCSSでpreload使ったら早くなるよ」みたいな警告出てたのが消えたので効果があったかもしれないです。


おまけ: ビルドされたファイルの差異

以下のように、高速化前はDynamic importや非同期コンポーネントも使っていなくCode splittingされずに、chunk-vendorsがJS,CSS共にとても大きいです。これをパースして評価しないと初期描画出来ないはずなので、初期描画に少し時間がかかっていたのだと思います。


高速化前

  File                                      Size             Gzipped

dist/js/chunk-vendors.27ddbf66.js 1708.28 KiB 585.92 KiB
dist/js/app.7478db47.js 64.12 KiB 15.10 KiB
dist/service-worker.js 1.98 KiB 1.01 KiB
dist/precache-manifest.bbb4bb2d1c261e5 1.75 KiB 0.61 KiB
e8fbf1e2fbf16e9d7.js
dist/css/chunk-vendors.6b1b5fc2.css 643.07 KiB 89.40 KiB
dist/css/app.6564fce6.css 0.16 KiB 0.15 KiB


以下のように、高速化後はCode splittingされて各ファイルのサイズは小さくなっていました。

Code splittingの仕組みや生成コードに関しては「ちゃんと理解するCode Splitting - Qiita」が詳しかったです。


高速化後

  File                                      Size             Gzipped

dist/js/chunk-vendors.4899a913.js 257.19 KiB 84.77 KiB
dist/js/chunk-57b10861.d78f63ba.js 223.01 KiB 72.55 KiB
dist/js/chunk-1388286a.4144e147.js 117.97 KiB 35.20 KiB
dist/js/chunk-75fdcd66.a640feae.js 106.27 KiB 31.20 KiB
dist/js/chunk-ae9d74f2.9a4b9266.js 79.56 KiB 26.68 KiB
dist/js/chunk-de20c21e.9462a166.js 42.10 KiB 13.58 KiB
dist/js/chunk-c6eec2f4.490c54c6.js 40.36 KiB 11.39 KiB
dist/js/chunk-252228c4.ef579924.js 25.09 KiB 4.11 KiB
dist/js/chunk-6e83591c.92a60880.js 22.17 KiB 6.91 KiB
dist/js/chunk-40ef8714.aef70fd8.js 19.92 KiB 5.56 KiB
dist/js/chunk-079922cd.8eeb0fea.js 16.79 KiB 5.25 KiB
dist/js/chunk-4465fa4c.889f9ca9.js 15.26 KiB 6.11 KiB
dist/js/app.4a5ce537.js 14.16 KiB 5.54 KiB
dist/js/chunk-67a04d64.0157cdaf.js 11.99 KiB 3.71 KiB
dist/js/chunk-3acce350.a0a0e1d5.js 4.61 KiB 1.67 KiB
dist/precache-manifest.3f6d46fe310a44b 2.82 KiB 0.85 KiB
a711688f1aac2c93d.js
dist/js/chunk-2d0b1f6a.52cd552e.js 2.59 KiB 1.27 KiB
dist/service-worker.js 1.98 KiB 1.01 KiB
dist/js/chunk-2d0ccfb8.815a5c6d.js 1.06 KiB 0.57 KiB
dist/js/chunk-2d2100ac.72ca6675.js 0.95 KiB 0.52 KiB
dist/js/chunk-2d0aef64.323dc465.js 0.33 KiB 0.25 KiB
dist/css/chunk-vendors.37551f53.css 282.11 KiB 31.59 KiB
dist/css/chunk-c6eec2f4.52a2d441.css 39.09 KiB 5.24 KiB
dist/css/chunk-de20c21e.48c6bfcc.css 28.64 KiB 5.04 KiB
dist/css/chunk-40ef8714.47b3a938.css 24.87 KiB 3.80 KiB
dist/css/chunk-ae9d74f2.ad5a8e30.css 6.29 KiB 1.40 KiB
dist/css/chunk-079922cd.764a2af9.css 6.14 KiB 1.34 KiB
dist/css/app.e72ed97e.css 0.02 KiB 0.04 KiB


非同期でimportするのも効果があったと思いますが、今回の場合は必要なアイコンのインポートに変更したときが体感でパフォーマンスが上がった気がしました。