WebpackでVue.jsをバンドルしたら画面に表示されない問題の解決(完全ビルドとランタイム限定ビルド関連)

7 min読了の目安(約6800字TECH技術記事

あらすじ

「ちょっとしたSPAを作るのにNuxt.jsを使うのは重いかな🤔」と思った砂糖は、WebpackでVue.jsをバンドルできるように実装を始めるのだった。

環境

webpack 5.11.0
vue 2.6.12

ここで扱うVue.jsは2.xだが、3.xの公式ドキュメントにも完全ビルドやランタイム限定ビルドについての記載があるので似たような問題を解決できるかもしれない🤔

事象

WebpackでVue.jsをバンドルしたファイルを読み込んだindex.htmlをブラウザから開いたところ、画面に何も表示されていなかった。Chromeの開発者ツールのConsoleには特にエラー出力されていない

同じく開発者ツールのElementsを見てみると、<!--function(e,n,r,o){return Fe(t,e,n,r,o,!0)}-->という謎のコメント文[1]が表示されていた。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>webpack-vue</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
  </head>
  <body>
    <!--function(e,n,r,o){return Fe(t,e,n,r,o,!0)}-->
    <script src="bundle.js"></script>
  </body>
</html>

作成したファイル(参考用)

package.json
{
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "html-webpack-plugin": "^4.5.0",
    "vue": "^2.6.12",
    "webpack": "^5.11.0",
    "webpack-cli": "^4.2.0"
  }
}
webpack.config.js

HTML Webpack Pluginでdist/index.htmlを生成している。

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [new HtmlWebpackPlugin()]
}
src/index.js

ネタバレ: 大体こいつが犯人

import Vue from 'vue'

new Vue({
  el: '#app',
  data: () => ({
    counter: 0
  }),
  template: '<div>Counter: {{ counter }}</div>'
})
src/index.ejs

HTML Webpack Pluginで生成されるindex.htmlのテンプレート。出力したJavaScriptファイルを読み込むscriptタグが自動で挿入される。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>webpack-vue</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

調査

minify化をやめてみる

出力されたコードを追ってみようにもminify化されていると読みにくいので、minify化しないようにwebpack.config.jsの設定を書き換える(optimization.minimizeを追加しfalseを指定した)。

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [new HtmlWebpackPlugin()],
  optimization: {
    minimize: false
  }
}

バンドル後にブラウザでdist/index.htmlを開き、開発者ツールで確認してみる。minify化されていたときは<!--function(e,n,r,o){return Fe(t,e,n,r,o,!0)}-->だったところが、 <!--function (a, b, c, d) { return createElement(vm, a, b, c, d, true); }--> になっていた。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>webpack-vue</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
  </head>
  <body>
    <!--function (a, b, c, d) { return createElement(vm, a, b, c, d, true); }-->
    <script src="bundle.js"></script>
  </body>
</html>

Vue.jsのビルドの種類

いくらか意味が分かりやすくなったため、<!--function (a, b, c, d) { return createElement(vm, a, b, c, d, true); }-->で検索をしてみると次のページが見つかった。

https://stackoverflow.com/questions/45654720/webpack-fails-to-mount-vue-components

ここで紹介されている方法では、import Vue from 'vue'と指定したときにvue/dist/vue.esm.jsを読み込むようにエイリアスを設定している(ちなみにvuepackage.jsonを見る限りエイリアスを設定しない場合はvue/dist/vue.runtime.esm.jsを読み込んでいる模様)。

vue.esm.jsvue.runtime.esm.jsの違いは「(テンプレート文字列をコンパイルするための)コンパイラのコードが含まれるかどうか」であり、問題が起きたときに読み込んでいたvue.runtime.esm.jsにはコンパイラのコードが含まれない(=コンパイルが行われない)。つまり、 「テンプレートをコンパイルしたらちゃんと表示される」 という意味になる。

webpack.config.jsにエイリアスを追加してバンドルしたところ、ひとまずブラウザで表示が確認できるようになった。

エイリアスを追加したwebpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [new HtmlWebpackPlugin()],
  optimization: {
    minimize: false
  },
  resolve: {
    alias: {
      vue$: 'vue/dist/vue.esm.js'
    }
  }
}

vue$(末尾に$)はvueの完全一致を示しており、$を付けない場合にvueから始まるライブラリをインポートしていた場合は大変なことになるかもしれない🤔

バンドルの軽量化

ランタイム限定ビルドと完全ビルド

公式ドキュメントでは、コンパイラを含まないvue.runtime.esm.jsランタイム限定ビルド、コンパイラを含むvue.esm.js完全ビルドと呼んでいる[2]

完全ビルドは次のコードのようなtemplateや、DOMのHTMLをレンダリング関数にコンパイルできる。

import Vue from 'vue'

new Vue({
  el: '#app',
  data: () => ({
    counter: 0
  }),
  template: '<div>Counter: {{ counter }}</div>'
})

ランタイム限定ビルドはコンパイル用のコードが除かれているため、レンダリング関数を使用する。

import Vue from 'vue'

new Vue({
  el: '#app',
  data: () => ({
    counter: 0
  }),
  render(h) {
    return h('div', `Counter: ${this.counter}`)
  }
})

完全ビルドはコンパイラのコードの分だけランタイム限定ビルドよりも大きくなるため、できればランタイム限定ビルドを使用したい。

それぞれminify化なしでWebpackを実行したところ、ランタイム限定ビルドで210KB完全ビルドで295KBのバンドルが生成された。

vue-loaderの使用

vue-loaderを使用するとテンプレートが事前にコンパイルされるそうなので、vue-loaderを用いて単一ファイルコンポーネントを導入してみることにする。

手順

1. パッケージの追加

単一ファイルコンポーネントに必要なvue-loadervue-template-compilerを追加する。

yarn add --dev vue-loader vue-template-compiler

2. バンドル設定の編集

webpack.config.jsからresolve.aliasを削除し、modulevue-loaderを、pluginsVueLoaderPluginを追加する。

vue-loaderとVueLoaderPluginを追加したwebpack.config.js
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [new VueLoaderPlugin(), new HtmlWebpackPlugin()],
  optimization: {
    minimize: false
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  }
}

module.rulesには単一ファイルコンポーネントのテンプレートに必要な部分しか設定していないため、CSS等を使用する場合は適宜追加する。

3. 単一ファイルコンポーネントの作成

src/App.vueにコンポーネントを実装する。

<template>
  <div>Counter: {{ counter }}</div>
</template>

<script>
export default {
  data: () => ({
    counter: 0
  })
}
</script>

4. エントリーファイルの編集

コンパイラを使用しないため、src/index.jstemplaterenderの形に書き換える。

import Vue from 'vue'
import App from './App.vue'

new Vue({
  el: '#app',
  components: {
    App
  },
  render(h) {
    return h('app')
  }
})

あとがき

普段Nuxt.jsを使っていてVue.jsのビルドを気にしたことがなかったのでハマった🙄 答えは公式ドキュメントに書いてあるものの、問題が起こってからドキュメントを一通り読んだとしても気付けなかったのではと感じている😵

参考

脚注
  1. minify化の影響で余計に読めないものになっているのもあるが🙄 ↩︎

  2. ES Module(*.esm.js)以外のビルドも存在する。 ↩︎