Vue.js製のSPAをGoogleAppsScriptにデプロイするためのWebpack設定

この記事のWebpack設定はVue CLI v2向けのものです。
Vue CLI v3のvue.config.jsに関しては下記の記事を参照してください。

GoogleAppsScriptのWebAppsでVue.jsするためのvue.config.js
https://qiita.com/clomie/items/ef10db03b9ecef29ca5d


# はじめに

vue-cliのwebpackテンプレートで作成したVue.jsのアプリケーションを、GoogleAppsScript(以下GAS)のHTMLServiceで動作可能な形にビルドする手順をまとめます。
最終的には`npm run build`でビルドしたファイルを、`clasp push`でそのままアップロード可能にすることが目的です。

Vueのプロジェクトを作成するところからスタートします。
プロジェクト名は仮に"vue-deploy-gas-sample"として進めます。

```
$ vue init webpack vue-deploy-gas-sample
$ cd vue-deploy-gas-sample
```


# 対応手順
- 出力ファイルをHTMLファイル１つにまとめる
- GAS+claspのセットアップ
- vue-routerとgoogle.script.historyの同期

セットアップ完了後のリポジトリです。
https://github.com/clomie/vue-deploy-gas-sample

手順通りにコミットを作ってあるので、追っていく感じで説明します。


# 出力ファイルをHTMLファイル１つにまとめる

GASでは、JSファイルやCSSファイルを分割して配信することはできません。
公式のガイドでは、styleタグやscriptタグとして分離したファイルを用意して、GAS内でインライン化してからレスポンスを返す方法が推奨されています。
https://developers.google.com/apps-script/guides/html/best-practices

今回はwebpackを使っているので、ビルドの段階ですべてHTMLファイルにインライン化します。

## HtmlWebpackInlineSourcePluginの導入

https://github.com/clomie/vue-deploy-gas-sample/commit/47b3b5101f7fb1bcc17a476dfe3f1b4324089e89

vue-cliのwebpackテンプレートではHtmlWebpackPluginを使ってindex.htmlを生成していますが、サブプラグインである[HtmlWebpackInlineSourcePlugin](https://github.com/DustinJackson/html-webpack-inline-source-plugin)でインライン化できるようなので、これを導入します。

```
$ npm install -D html-webpack-inline-source-plugin
```

```diff:build/webpack.prod.conf.js
 const HtmlWebpackPlugin = require('html-webpack-plugin')
+const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin')
 const ExtractTextPlugin = require('extract-text-webpack-plugin')
```

プロダクションビルド用のwebpack設定ファイルに、HtmlWebpackInlineSourcePluginを適用します。デフォルトでは何もインライン化されない設定になっているので、`inlineSource`プロパティの設定を忘れないように。
また、`minify`の設定で`removeAttributeQuotes`がtrueだとGAS上でエラーになってしまうのでOFFにします。

```diff:build/webpack.prod.conf.js
     // generate dist index.html with correct asset hash for caching.
     // you can customize output by editing /index.html
     // see https://github.com/ampedandwired/html-webpack-plugin
     new HtmlWebpackPlugin({
       filename: config.build.index,
       template: 'index.html',
       inject: true,
+      // embed inline all javascript and css
+      inlineSource: '.(js|css)$',
       minify: {
         removeComments: true,
         collapseWhitespace: true,
-        removeAttributeQuotes: true
         // more options:
         // https://github.com/kangax/html-minifier#options-quick-reference
       },
       // necessary to consistently work with multiple chunks via CommonsChunkPlugin
       chunksSortMode: 'dependency'
     }),
+    new HtmlWebpackInlineSourcePlugin(),
```

## SourceMapのインライン化

https://github.com/clomie/vue-deploy-gas-sample/commit/2dedd779f7992c65f9b569572eb0948c714e8e01

GAS上でSourceMapを見たい場合は、SourceMapもインライン化する必要があります。

```diff:build/webpack.prod.conf.js
     // Compress extracted CSS. We are using this plugin so that possible
     // duplicated CSS from different components can be deduped.
     new OptimizeCSSPlugin({
       cssProcessorOptions: config.build.productionSourceMap
-        ? { safe: true, map: { inline: false } }
+        ? { safe: true, map: { inline: true } }
         : { safe: true }
     }),
```

```diff:config/index.js
     productionSourceMap: true,
     // https://webpack.js.org/configuration/devtool/#production
-    devtool: '#source-map',
+    devtool: '#inline-source-map',
```

## 静的ファイルのインライン化

https://github.com/clomie/vue-deploy-gas-sample/commit/0004c3db119a68cc9041d25a045ec536bf4855de

フォントファイル、画像ファイルなどもインライン化する必要があるため、url-loaderのlimit設定を取り除きます。
また、webpackテンプレートではstaticディレクトリが用意されていますが、これもなかったことにします。

```diff:build/webpack.base.conf.js
       {
         test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
         loader: 'url-loader',
         options: {
-          limit: 10000,
           name: utils.assetsPath('img/[name].[hash:7].[ext]')
         }
       },
       {
         test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
         loader: 'url-loader',
         options: {
-          limit: 10000,
           name: utils.assetsPath('media/[name].[hash:7].[ext]')
         }
       },
       {
         test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
         loader: 'url-loader',
         options: {
-          limit: 10000,
           name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
         }
       }
```

```diff:build/webpack.prod.conf.js
-    // copy custom static assets
-    new CopyWebpackPlugin([
-      {
-        from: path.resolve(__dirname, '../static'),
-        to: config.dev.assetsSubDirectory,
-        ignore: ['.*']
-      }
-    ])
```

# GAS+claspのセットアップ

https://github.com/clomie/vue-deploy-gas-sample/commit/4506b4163ea6598530e592590941352641a86993

## GASプロジェクト作成

作成したプロジェクトのディレクトリで`clasp create`コマンドを実行します。

```
$ clasp create vue-deploy-gas-sample
```

GASのプロジェクトが作成されて、`.clasp.json`と`appsscript.json`が生成されます。
デフォルトの設定では、claspはプロジェクトフォルダ内のファイルをすべてGASプロジェクトにアップロードするため、ファイルの配置と内容を変更していきます。

## .clasp.json
claspでGASプロジェクトへファイルをアップロードするための設定ファイルです。デフォルトではGASプロジェクトのIDだけが書かれています。
`crasp push`コマンドを実行する際のカレントディレクトリに配置されている必要があるため、これはプロジェクトフォルダルートにこのまま置いておきます。
コマンド実行時に`npm run build`で生成したファイルがアップロードされるように、rootDirに`dist`を指定しておきます。

```diff:.clasp.json
-{"scriptId":"<Your GAS Project ID>"}
+{
+  "scriptId": "<Your GAS Project ID>",
+  "rootDir": "dist"
+}
```

## gas/appsscript.json
`.clasp.json`のrootDirで指定したディレクトリに配置されている必要があります。
ただし、`dist`ディレクトリにビルド成果物以外を置いておきたくないので、プロジェクトフォルダ内に`gas`ディレクトリを作ってそこに置くことにします。
webpack実行時に`gas`ディレクトリから`dist`ディレクトリにコピーする設定を追加します。

## gas/Code.js
GASプロジェクトでのエントリポイントとなるファイルです。
実際にGAS上でページを開いたときに呼び出される処理です。これも`gas`ディレクトリに配置します。

```javascript:gas/Code.js
function doGet() {
  return HtmlService.createHtmlOutputFromFile("index");
}
```

## .claspignore
claspでアップロードする際の無視ファイルリストです。
claspリポジトリのREADMEにも例がありますが、アップロードしたいファイルのほうが少ないので、ホワイトリスト方式で書いたほうが楽です。
これはプロジェクトフォルダルートに配置します。

```:.claspignore
**/**
!dist/appsscript.json
!dist/Code.js
!dist/index.html
```

## webpack設定
`gas`ディレクトリ内のファイルをビルド時に`dist`ディレクトリにコピーする設定を、webpackの設定ファイルに追記します。

```diff:build/webpack.prod.conf.js
       children: true,
       minChunks: 3
     }),

+    // copy google apps script files
+    new CopyWebpackPlugin([
+      {
+        from: path.resolve(__dirname, '../gas'),
+        ignore: ['.*']
+      }
+    ]),
+
     // copy custom static assets
     new CopyWebpackPlugin([
       {
```

# ここまででとりあえず動く

ひとまず、ここまでで最低限の設定は完了です。
ビルドすればGASプロジェクトにアップロードするファイルが生成されます。

```
$ npm run build

> vue-deploy-gas-sample@1.0.0 build /path/to/vue-deploy-gas-sample
> node build/build.js

Hash: d0dd6dffd9a3d968a929
Version: webpack 3.11.0
Time: 11674ms
                                              Asset      Size  Chunks                    Chunk Names
           static/js/vendor.7fed9fa7b7ba482410b7.js    850 kB       0  [emitted]  [big]  vendor
              static/js/app.b22ce679862c47a75225.js   41.2 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js    7.5 kB       2  [emitted]         manifest
static/css/app.30790115300ab27614ce176899523b62.css   1.52 kB       1  [emitted]         app
                                         index.html    900 kB          [emitted]  [big]
                                    appsscript.json  98 bytes          [emitted]
                                            Code.js  77 bytes          [emitted]

  Build complete.

  Tip: built files are meant to be served over an HTTP server.
  Opening index.html over file:// won't work.
```

ビルド結果にはJSファイル、CSSファイルも含まれていますが、index.htmlを見るとすべてインライン化されています。
GASプロジェクトにアップロードしてみましょう。

```
$ clasp push
└─ dist/Code.js
└─ dist/appsscript.json
└─ dist/index.html
Pushed 3 files.
```

あとはGASプロジェクトからWebアプリケーションとしてデプロイすれば動くはずです。
https://developers.google.com/apps-script/guides/web#deploying_a_script_as_a_web_app



# vue-routerとgoogle.script.historyの同期

https://github.com/clomie/vue-deploy-gas-sample/commit/0c5a01f3bb3a1262e2a6a6b38a94881c3752ef79

GASでデプロイしたアプリはiframe内で動作するため、vue-routerを使った場合、画面遷移とブラウザのURLが同期しません。
routerのナビゲーションを拾って、GASのClient-side APIからブラウザのURLを更新することで、これを解決できます。

```src/router/gas-router-sync.js
export const sync = router => {
  if (!window.google) {
    return
  }

  window.google.script.url.getLocation(location => {
    const path = location.hash
    const query = location.parameter
    router.replace({ path, query })
  })

  router.afterEach(route => {
    window.google.script.history.replace(null, route.query, route.path)
  })
}

```

`google.script.history`, `google.script.url`はGAS上のWebアプリでグローバルにアクセス可能なオブジェクトです。

# その他

この記事ではvue-cliのwebpackテンプレートをベースに説明しましたが、
出力ファイルが１つで良いのであれば、webpackの設定はよりシンプルにできるはずで、主にExtractTextPluginや、CommonsChunkPluginなどは不要になるかと思います。
このあたりもう少し詰めればビルド時間が短くなったりするかもしれません。


# 参考リンク
[google/clasp](https://github.com/google/clasp)
[Web Apps  \|  Apps Script  \|  Google Developers](https://developers.google.com/apps-script/guides/web)
[HTML Service  \|  Apps Script  \|  Google Developers](https://developers.google.com/apps-script/reference/html/)

[Google Apps Scriptの新しい3つの機能 その③ CLI Tool Clasp](https://qiita.com/soundTricker/items/354a993e354016945e44)
[Google Apps Script をローカル環境で快適に開発するためのテンプレートを作りました](https://qiita.com/howdy39/items/0e799a9bfc1d3bccf6e5)
[Google Apps Script でAngularJSを使った Single Page Application を構築・公開する方法\(基礎編\)](https://qiita.com/dq-ny/items/fefb3d1f4f3fa48db8f9)