Vue+Typescript+FirebaseでChrome拡張を作っているのですが、バンドルサイズが大きいよと警告が出たので対応しようと思います。

使っている技術

  • Chrome Extension
  • Vue+Vuetify
  • TypeScript
  • Firebase
    • Authentication
    • Cloud Firestore

ワーニングの内容

npm run buildcross-env NODE_ENV=production webpack --hide-modules)で本番用のコードを出力してみたところ、バンドルサイズが推奨サイズ(244 KB)を超えているというワーニングが出ました。

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets: 
  /fonts/_/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot (842 KiB)
  /fonts/_/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2 (276 KiB)
  /fonts/_/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff (390 KiB)
  /fonts/_/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf (842 KiB)
  options/options.js (578 KiB)
  popup/popup.js (1.17 MiB)

WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
Entrypoints:
  popup/popup (1.17 MiB)
      popup/popup.js
  options/options (578 KiB)
      options/options.js

バンドル構成を可視化

webpack-bundle-analyzerをインストールして、

npm install --save-dev webpack-bundle-analyzer

webpack.config.jsにpluginを追加します。

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

const config = {
  plugins: [
    new BundleAnalyzerPlugin(),
  ]
}

あとは、npm run buildを実行するだけで、自動的にブラウザが立ち上がり、バンドルの構成要素を確認できます。

どうやら、firebase vuetify @mdi/font vue を減らせば良さそうです。

そもそも、バンドルにモジュールが含められるメカニズムは?

バンドルからモジュールを削除するためには、どうやったらバンドルにモジュールが含められるかを知っておきたいですが、 自分が知る範囲で、以下の二つのメカニズムがあるようです。

エントリーポイントから依存関係

webpackはwebpack.config.jsentryで指定したエントリーポイントから依存関係を解析して、依存関係にあるモジュールをバンドルに含めるようです。
これをバンドルから除外したい場合、以下のような対応を取ることになります。

  • 実際には使ってないケース
    • 単純にimport文やrequire()文を削除する
    • こんなのないよと思うかもしれませんが、削除し忘れたimport文や、バンドルに含めるためだけのimport文など、結構ありますw
  • 実際に使っているケース
    • webpack.config.jsexternalsで含めないモジュールを明示的に除外設定する

バンドル時に実行されるPlugin

Pluginの中で、バンドルにモジュールを含めるようなことをするものもあります。
その場合、Plugin自体を削除したり、動作を変えたりすることになります。

バンドルから除外

エントリーポイントから依存関係

vue・vuetify・@mdi/font

vue関係の3つのライブラリをCDNから読み込み、バンドルから除外するようにします。

CDNからの読み込み

Chrome Extensionなので、アイコンをクリックした時に呼び出されるpopup.htmlがエントリーポイントになるのですが、そこでCDNから vuevuetify@mdi/font を読み込むようにしました。

vueとvuetifyはmin.jsファイルが提供されているので、NODE_ENV === productionの場合はそちらを読み込むようにしてあります。

 <!DOCTYPE html>
 <html lang="en">
 <head>
   <meta charset="UTF-8">
   <title>Title</title>
+  <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
+  <link href="https://cdn.jsdelivr.net/npm/@mdi/font@5.1.45/css/materialdesignicons.min.css" rel="stylesheet">
+  <link href="https://cdn.jsdelivr.net/npm/vuetify@2.2.27/dist/vuetify.min.css" rel="stylesheet">
 </head>
 <body>
   <div style="width: 500px;">
     <div id="app">/div>
   </div>  
+  <% if (NODE_ENV === 'production') { %>
+    <script defer src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
+    <script defer src="https://cdn.jsdelivr.net/npm/vuetify@2.2.27/dist/vuetify.min.js"></script>
+  <% } else { %>
+    <script defer src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.js"></script>
+    <script defer src="https://cdn.jsdelivr.net/npm/vuetify@2.2.27/dist/vuetify.js"></script>
+  <% } %>
   <script defer src="popup.js"></script>
 </body>
 </html>
バンドルに含めない設定

vueとvuetifyは実装の中で依存しているので、webpack.config.jsexternalsでバンドルに含めないように指定します。keyにモジュール名(=node_modulesの下のフォルダ名)、valueにグローバル変数名を指定します。グローバル変数名は一回動かしてみて、Chromeのconsoleで確認しました。

+  externals: {
+    vue: 'Vue',
+    vuetify: 'Vuetify',
+  }

この設定を入れることによって、バンドルファイルpopup.js上は以下のように、node_modulesの下のソースコードではなく、単純にグローバル変数をモジュールとして定義してくれるようになります。

[function(e, r) {
    e.exports = Vue
}
, function(e, r) {
    e.exports = Vuetify
}
, function(e, r) {
(中略) 
]
ライブラリ利用箇所の修正

vuetifyの初期化を行なっているvuetify.tsの修正を行います。

  • node_modulesのvuetify/libではなく、externalsで指定したvuetifyに変更
  • バンドルに含めるためにimportしていた@mdi/fontを削除

    import Vue from 'vue'
    -import Vuetify from 'vuetify/lib'
    +import Vuetify from 'vuetify'
    -import '@mdi/font/css/materialdesignicons.css'
    
    Vue.use(Vuetify)
    
    export default new Vuetify({
    icons: {
     iconfont: 'mdi',
    },
    })
開発時も参照しないライブラリを削除

vuevuetifyは開発時に参照しているので削除できませんが、@mdi/fontはもともとバンドルに含めるためだけにinstallしてたので削除します。

npm uninstall @mdi/font --save
Chrome拡張で外部スクリプトを読み込めるようにする

普通のアプリではこの設定は不要ですが、Chrome拡張の場合はアプリ内で不正なjavascriptコードが実行されないように、content_security_policyによって実行可能なjavascriptコードを制限する仕組みがあるので、この設定をmanifest.jsonに記載します。

-  "content_security_policy": "script-src 'self' ; object-src 'self'"
+  "content_security_policy": "script-src 'self' https://cdn.jsdelivr.net/npm/vue@2.6.11/ https://cdn.jsdelivr.net/npm/vuetify@2.2.27/; object-src 'self'"

ちなみに、vue-web-extensionを使っている場合は、webpack.config.jsでこの設定を上書きするようになっているので、そちらを修正します。

firebase

firebaseも同様にCDNから読み込み、バンドルから除外していきます。

CDNからの読み込み

こちらもpopup.htmlを修正します。min.jsが存在しないっぽいので、こちらを参考にそのまま読み込みます。ちなみにdeferをつけると非同期でスクリプトを読み込み、DOMContentLoadedの直前でスクリプトを実行してくれるらしいのですが、こちらの方がChromeのperformanceでチェックしたところ少しだけ早く初期画面が表示されるようでしたので、deferを設定してあります。

+  <script defer src="https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js"></script>
+  <script defer src="https://www.gstatic.com/firebasejs/7.14.2/firebase-auth.js"></script>
+  <script defer src="https://www.gstatic.com/firebasejs/7.14.2/firebase-firestore.js"></script>
   <script defer src="popup.js"></script>
 </body>
バンドルに含めない設定

webpack.config.jsのexternalsを修正します。探すといろんな記事でいろんな指定方法が紹介されてますが、結局以下の設定で問題なく除外できました。

   externals: {
     vue: 'Vue',
     vuetify: 'Vuetify',
+    firebase: 'firebase',
   }
  • トップレベルのモジュール名(@はユーザ名を表すnamespeceであることを示しているだけなので不要)を指定する
    • firebase/authやfirebase/firestoreは、firebaseオブジェクトを通じて呼び出すので個別設定は不要
  • グローバル変数名はfirebaseを指定する
ライブラリ利用箇所の修正

もともと、node_modulesの下のfirebase/appをimportしてましたが、externalsで指定したfirebaseに変更します。
その上で、firebase/authfirebase/firestoreはグローバル変数のfirebaseオブジェクトを利用することになるので、ここのimport文は削除します。

-import * as firebase from 'firebase/app'
+import * as firebase from 'firebase'
-import 'firebase/auth'
-import 'firebase/firestore'

 const firebaseConfig: { [key: string]: string } = {
 }

 firebase.initializeApp(firebaseConfig)

 export default firebase
Chrome拡張で外部スクリプトを読み込めるようにする

こちらもChrome拡張で上記のスクリプトを実行できるように、content_security_poricyの設定をmanifest.jsonに記載します。ちなみに、指定したURLの最後の/は必要です。

-  "content_security_policy": "script-src 'self' https://cdn.jsdelivr.net/npm/vue@2.6.11/ https://cdn.jsdelivr.net/npm/vuetify@2.2.27/; object-src 'self'"
+  "content_security_policy": "script-src 'self' https://www.gstatic.com/firebasejs/ https://cdn.jsdelivr.net/npm/vue@2.6.11/ https://cdn.jsdelivr.net/npm/vuetify@2.2.27/; object-src 'self'"

結果確認

ここまでの対応で、エントリーポイントからの依存関係については一通り除外できたと思いますので、npm run buildで本番用のコードを出力してみました。すると、バンドルサイズが推奨サイズ(244 KB)を超えているというワーニングは出なくなりました。

  options/options.js (48.3 KiB)
  popup/popup.js (145 KiB)

になったので、まあ良さそうです。

で、バンドルの構成は以下のようになりました。

よく見ると、vuetifyのcomponentが残っています。たぶんこれはvuetify-loaderVuetifyLoaderPluginを使っているためと考えられますので、次はこれを対応しようと思います。

バンドル時に実行されるPlugin

VuetifyLoaderPlugin

このプラグインはvuetifyのUIコンポーネントを利用する箇所を自動的に検知して、自動的にUIコンポーネントのimportを行ってくれるPluginでかなり便利なので導入していましたが、CDN化した後はそもそもこのimportが不要になります。

ちなみに、importの処理自体は、loader.jsの中の以下のあたりで実行されているようです。

function install (install, content, imports) {
  if (imports.length) {
    let newContent = '/* vuetify-loader */\n'
    newContent += `import ${install} from ${loaderUtils.stringifyRequest(this, '!' + runtimePaths[install])}\n`
    newContent += imports.map(i => i[1]).join('\n') + '\n'
    newContent += `${install}(component, {${imports.map(i => i[0]).join(',')}})\n`

    // Insert our modification before the HMR code
    const hotReload = content.indexOf('/* hot reload */')
    if (hotReload > -1) {
      content = content.slice(0, hotReload) + newContent + '\n\n' + content.slice(hotReload)
    } else {
      content += '\n\n' + newContent
    }
  }

  return content
}

Pluginの削除

なので、単純にwebpack.config.jsから該当のPluginを削除します。

 const { version } = require('./package.json');
-const VuetifyLoaderPlugin = require('vuetify-loader/lib/plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 (中略)
   plugins: [
     new BundleAnalyzerPlugin(),
-    new VuetifyLoaderPlugin(),

さらに、package.jsonからも削除します。

npm uninstall vuetify-loader --save

結果確認

改めて、npm run buildでバンドルファイルを出力しなおすと、さらにバンドルサイズが削減できていました。

  options/options.js (16.5 KiB)
  popup/popup.js (21.2 KiB)

バンドルの構成はこんな感じです。

vuetifyのUI コンポーネント関係もなくなりました。

これにて対応完了!!!