Google Apps ScriptでVue3を検証してみた【GAS】

2023年12月31日、現在主流で使われているVue.jsのversion2がサポート終了ということで、今後はVue3を使ってねという宣言が出ています。自分もGoogle Apps Scriptでウェブアプリを使うに当たって、Vue.js + Vuetifyというのを由しとして採用していますが、いよいよVue3に移行するか?それとも別のフレームワークに移行するか?という検証が必要になっています。

そこで、まずはVue3が現状どんな状況なのか?を検証すべく以前のVue.jsの記事をなぞる形で検証してみることにしました。

Google Apps ScriptでVue.jsを使ってみる【GAS】

今回使用するサンプルファイル

CDNを参照して利用するので、Head部分に以下の1行を追加して利用します。

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

Vuetify3の検証については以下のエントリーにまとめてあります。

Google Apps ScriptでVuetify3を検証する【GAS】

Vue3にまつわるお話

今回のVue2 => Vue3への大きなメジャーアップデートによって、界隈では様々なことが語られています。Python2 => Python3でも同様に大規模な破壊的変更があって、現在はそれを乗り越えて古い過去を捨て去ることが出来ています。

しかし、Vueの場合はPythonとは違い、フレームワークである点とこれまで謳われていた点が怪しくなってきてるという点で、「衰退」の2文字が語られています。この件についてまとめてみました。

Vue衰退と言われてる件

概要

Vue3では最も主要な構文そのものに破壊的変更が入っています。よって、単純にロードするライブラリを差し替えたからといって動くわけじゃなく、必ずコードの書き直しが発生します。しかしそれは今回の話題の根幹ではありません。

問題なのは、これまでVueが謳ってきた内容と方向性が怪しくなって来た点と、世界シェアで既に数字に現れている点。自らの利点を捨ててしまった場合、当然比較対象と同じ土俵になるわけで、その場合Vueを使い続けるのか?という疑義が生じるわけです。

世界シェア

React, Vue, Angularこれらが主要なウェブフレームワークとして使われていますが、2018年移行これらの世界シェアは圧倒的にReactがトップで、Angularについてはこのまま消えていくのではないかと思われます。特に2020年〜2021年付近を堺に大きな変動が起きていて、Vueに関しても検索機ワード上では一気に数字を落としてるのが現状です。

日本に絞ってみても、同様の傾向でVueへの注目度が年々下がってきており、その中でのVue3の破壊的変更がReactへの移行をむしろ促してるというのが読み取れます。

利点を捨てることのデメリット

今回、衰退と揶揄されている理由は単に世界シェアの話しだけではありません。元来、Vueは学習コストが低くライブラリも豊富にあるという導入の楽ちんさと書きやすさという他のフレームワークにはない大きなメリットがありました。しかし、今回のアップデートでこの2点が損なわれたと感じられ、またVue側もそれを全面に打ち出してしまったのが、「だったら、Reactでいいじゃん」という疑義が生じたことにあります。

ウェブ上に於いて今回のVueの衰退の件を語ってるエントリーの多くは技術的なお話ばかりが目立ちますが、技術的観点から見て正しいということと、結果シェアを失って消えていく事は全く別のお話です。技術的に正しいからと言って、前述のメリットが失われるのであれば、たとえどんなに技術的に正しいといったところで、それは技術的なお話なのであって、経済的なデメリットが強くなるならばそれはもう利用するメリットが無いという技術論の外側の要因によって、消えていく事になります。

事実、Vue2 => Vue3への移行で膨大な書き直しや苦痛、コストが発生してもそれは利用するユーザにとっては何のプラスにもなりません(どちらでも良いことなのです)。ソレを経営上層部に語っても承認されないでしょう。スバルがトヨタを目指して量産車でカローラを倒そうといった所で、だったらトヨタ車で良いのです(自分自身はスバルの車大好きですが)。スバルがトヨタを目指したら間違いなく会社は消えます。

世の技術的なエントリーの多くはこうした「経済的要因」を無視して語ってるケースが多く、それらは技術オタクの論に過ぎず世の中は非常にシビアなのです。Vueは現在まだこれまでの系譜(Options APIと呼びます)を維持しているのでここまで至ってるとは言えないですが、今後Option APIを廃止して、破壊的変更の1つであるCompositionAPIに完全移行するといったような判断をするならば、Vueは間違いなく衰退するでしょう(それだったらReactで良いのです)。自分の利点を捨てるってそういう事なのです。それが技術的にいくら正しかろうとも。

破壊的変更について

前述の内容を踏まえた上で今回の破壊的変更をみた場合の大きなポイントは

  • Vueのサイト自体がデフォルトでCompositionAPIでの書き方をデフォルトとしている
  • またTypeScriptサポートが加わったので、更に書き方が分岐している
  • これまでの書き方であるOptionAPIも継続して使えるけれど、これも主要構文の書き方が変更されている
  • CompositionAPIとOptionAPIを比較したときにOptionAPIでの書き方はすべてをサポートしてるわけじゃない(v3.4x以降の新機能など)
  • 結果殆どのVueを基軸としたライブラリはVue3対応しない限り動かないし、Vuetify3も移行に相当な時間を要した
  • Pythonと違いバージョンでバッサリ代わりましたというのと違って、ウェブ上での資料がv2, v3, typescript, compositionapi, optionapi, vue単一コンポーネント, ESでの書き方が混在する事となり割りと面倒な事になってる
  • Vueが今回、将来的なOptionAPIの存続に関して怪しい匂いを出した事により、初見でReact選んでおいたほうが安全と思われるキッカケになってる

とりわけ、本エントリーのテーマであるGoogle Apps Scriptのウェブアプリで使う場合には、第一に「シンプルで手軽に構築出来る事」を考えた場合、CompositionAPIで書くメリットは全く無い上に、TypeScriptで書けるわけじゃないのでほぼメリットが無い。故に今回このエントリーではOptionAPIでしか検証は行いません。

Vue3とGASでの利用上の注意点

さて、今回GAS + Vue3を見ていくことになるわけですが、前述の破壊的変更によりOptionAPIによる書き方が変わってる箇所が随所にあります。気がついたら追記していきますが、以下のように変わっています。こちらの移行ガイドをみながら書き直しが必要です。

基本スタイルの変更

これまでの基本スタイルの書き方は以下のような書き方で初期化を行い、dataやmethod, watch, mountedなどを定義していました。

var vm = new Vue({
        el: '#app',
        data: {
          cookman: "キノコを調理するよ"
        },
        methods: {
          cookpad: function () {
            //GAS内のコード読み出し
            vm.cookman = "キノコのシチューは如何?";
          }
        }
      })

これがVue3では以下のように変わっています。ロードするCDNのライブラリはこちらになります。

//ビューの初期化
const { createApp } = Vue

var vm = createApp({
  data() {
    return {
      spread: null, //初期値は空にしておく
    }
  },
  methods: {
    sheetman: function () {
      //スプレッドシートのデータを取得
      google.script.run.withSuccessHandler(
        function (data) {
          //返り値をspreadにそのまま流し込む
          vm.spread = data;
        }
      ).getsheetman();
    }
  },
  computed: {
    preflength: function() {
      if(this.spread == null){
        return 0;
      }else{
        return this.spread.length;
      }
    }
  },
}).mount('#app')
  • Vueを元にcreateAppを定義する。
  • その中にこれまでと同じようなスタイルで記述していく(ここはそこまで劇的に変わっていない
  • 最期にmountにて#appを指定して、Vueを初期化する(elで指定していたのが最期に来るようになった)

カスタムコンポーネントのv-model

これまでカスタムコンポーネントを構築し、コンポーネントのv-modelに指定する値はpropsの中にあるvalueを指定するのが構文でした。これがpropsの中にあるmodelValueを指定するという仕様に代わりました。これは特に自身で独自に作るコンポーネントやライブラリ側での影響が大きかった項目です。

props: {
    modelValue: String // 以前は `value: String`
},

またこれに伴い、@inputで実装していた部分については@update:modelValueという指定方法に変わっています。この変更をした理由は、1つのコンポーネントに複数のmodelをもたせる(これまでは必ず1個だけしか指定できない)ことが出来るようになったという点です。

<ChildComponent
  :title="pageTitle"
  @update:title="pageTitle = $event"
  :content="pageContent"
  @update:content="pageContent = $event"
/>


changePageTitle(title) {
      this.$emit('update:modelValue', title) // 以前は `this.$emit('input', title)`
}

またこれに伴い、v-bind.syncが廃止されています。

method内の関数を別のメソッドから呼び出す

Vue2では、Vueのインスタンスの外側からFunctionにてdataの中身を書き換えたり、methodの中の関数を実行することが出来ました。グローバル変数のvmに対して、vueを入れて、外部からvm.hogehoge("てすと")みたいな形です。

さて、この時にこちらのサイトにもあるように、vm = createApp().use(vuetify).mount("#app")した時にはこれまで通り、変数を弄ったり関数を叩いたり出来るのですが、vm.use(vuetify)、vm..mount("#app")とメソッドチェーンではない形で実行すると、外部からvm.hogehoge("てすと")で叩けなくなりエラーになります。

ハマりどころですので要注意です

Vue3での実装例

さて、ここまで割りとネガティブな話題をまとめて、おおまかな把握が出来ました。実際にはOptionAPIというコレまでの書き方そのものは大規模に変更されていて・・・というわけではないのと、サポートを打ち切られたわけでもないので、GASのウェブアプリという特性を考えた際の「シンプルで楽ちん」というのは失われたか?というと疑問符です。Vuetifyの対応が遅れたという点も、現在はVuetify3がリリースされており、Vuetify2から比較するとまだまだな点は多いのですが、現状移行することは出来るのではないかと思います。

実際にVue2から変更してみた感想

ということで、OptionAPIの仕様にしたがって、もっとも基本的な項目ですが、前回のVue.jsのサンプルファイルをそのまま書き直してみましたが、以下見ていただけるとわかるように、HTML構文そのものは書き直しをしていません。内容がもっとも根本的で基本的な項目なので、手直しナシで問題ありません。

Vue初期化の部分についても前項の破壊的変更項目は最小限になってるので、初期化の中身の部分についてはコピペだけです。GAS側は一切変更する必要性はありません。

CompositionAPIの話しが先行した結果、忌避されている現状はあります。しかしGASでウェブアプリの場合大規模開発をするわけではないので、そのような仕様を使う必要性はありません。OptionAPIが廃止されたり、VuetifyやVueRouterのようなライブラリ群がサポートしなくなったのであれば、素直にReactに移住したほうが良いかとは思いますが、現状はまだ時期尚早の考えかなと思います。

ループでデータを生成

Vueのタグの中でデータをループさせて生成するよく使う機能の1つ。データ部分をv-forで回して、HTMLを生成し描画します。GAS側でタイトル行まで含めてのJSON形式でデータを取得して返し、それをそのままループで回して描画します。

GoogleスプレッドシートのデータをJSONで取得する【GAS】

GAS側

//シートデータを取得して返す
function getsheetman() {
  //スプレッドシートのデータを取得
  var rows = SpreadsheetApp.openById(sheetid).getSheetByName("都道府県").getRange("A1:D").getValues();

  //1行目をJSONのkeyとして設定
  var keys = rows.splice(0, 1)[0];
  
  //keyに対応するデータを元にJSONを構築
  return rows.map(function(row) {
    var obj = {};
    row.map(function(item, index) {
      obj[String(keys[index])] = String(item);
    });
    
    //JSONを返す
    return obj;
  });
}

HTML側

<!-- 基本となるビュー -->
<div id="app">
  <button @click="sheetman" class="create">データ取得</button>
  <p>{{ preflength }} 件</p>
  <table>
    <tr>
      <th>都道府県</th>
      <th>エリア</th>
      <th>政令指定都市</th>
      <th>人口</th>
    </tr>
    <tr v-for="rec in spread">
      <td>{{rec.都道府県名}}</td>
      <td>{{rec.エリア}}</td>
      <td>{{rec.政令指定都市}}</td>
      <td>{{rec.人口}}</td>
    <tr>
  </table>
</div>
  • 変更点は無いので、特に書き換えの必要はありませんでした。

Vue3側

//ビューの初期化
const { createApp } = Vue

var vm = createApp({
  data() {
    return {
      spread: null, //初期値は空にしておく
    }
  },
  methods: {
    sheetman: function () {
      //スプレッドシートのデータを取得
      google.script.run.withSuccessHandler(
        function (data) {
          //返り値をspreadにそのまま流し込む
          vm.spread = data;
        }
      ).getsheetman();
    }
  },
  computed: {
    preflength: function() {
      if(this.spread == null){
        return 0;
      }else{
        return this.spread.length;
      }
    }
  },
}).mount('#app')
  • spread変数にはGAS側からのJSONデータを格納します。格納するとv-forによってレンダリングが自動で行われます。
  • sheetman関数がGAS側からのリクエストを処理。これまで通り、vm変数にcreateAppを格納してるので、vm.spreadで変数にアクセスする事が可能です。
  • computedはspread変数に値が入ると自動で計算開始、計算結果をpreflengthという変数として返しています

図:無事リストレンダリング出来た

入力データの反映と取得

テキストボックスに値を入れて検索を実行。それを元にGAS側で絞り込みをした結果をalertで表示するといった仕組み。検索ワードは即時にHTML上の変数として反映します。

GAS側

//シートから一致するデータを返す
function chksheetman(kenmei){
  //スプレッドシートのデータを取得
  var rows = SpreadsheetApp.openById(sheetid).getSheetByName("都道府県").getRange("A2:D").getValues();

  //値を調べる
  var jinkou = 0;
  for(var i = 0; i<rows.length;i++){
    //都道府県名が一致したら人口を取得
    if(rows[i][0] == kenmei){
      jinkou = rows[i][3];
    }
  }
  
  //値を返す
  return jinkou;
}

HTML側

<!-- 基本となるビュー -->
<div id="app">
  <input type="text" v-model="spread" placeholder="都道府県を入力"/>
  <p><button @click="sheetchk()">人口検索</button>
  <div>{{ spread }}を検索</div>
</div>
  • 単純なテキストボックスと検索窓の仕組みです
  • sheetchk関数はテキストボックスの変数spreadを元にGAS側へ送り、返り値をalertで表示するだけ

Vue3側

//ビューの初期化
const { createApp } = Vue

var vm = createApp({
  data() {
    return {
      spread: null, //初期値は空にしておく
    }
  },
  methods: {
    sheetchk: function () {
      //スプレッドシートのデータを検索
      google.script.run.withSuccessHandler(
        function(data){
          //人口をalert表示する
          alert(vm.spread + "の人口は" + data + "千人です。");
        }
      ).chksheetman(vm.spread);
    }
  },
}).mount('#app')
  • spread変数がv-modelで連結されてるので即時に検索ワードは反映する
  • GASからの返り値を元に組み立ててAlertで表示
  • chksheetmanがGAS側の関数で、引数はthisではなくvm.spreadで渡してあげる

図:検索結果が返ってきた

タグの属性に連結させる

今度は、起動時にGASでデータを取得後にVueを初期化して、その後v-bind:hrefでURLを連結させて、ハイパーリンクを表示するといった仕組みをテストしてみます。

GAS側

//全国47都道府県のHPリストを返す
function getpreflist(){
  //スプレッドシートのデータを取得
  var rows = SpreadsheetApp.openById(sheetid).getSheetByName("都道府県").getRange("A2:E").getValues();
  
  //配列に都道府県名とURLを格納する
  var array = [];
  
  //二次元配列を作成
  for(var i = 0;i<rows.length;i++){
    //一時配列を用意
    var temparray = [];
    
    //都道府県名とURLをpushする
    temparray.push(rows[i][0]);
    temparray.push(rows[i][4]);
    
    //配列にpushする
    array.push(temparray);
  }
  
  //配列データを返す
  return JSON.stringify(array);
}

HTML側

<!-- 基本となるビュー -->
<div id="app">
  <h2 v-cloak>{{ title }}</h2>
  <p v-cloak>{{ description }}</p>
  <ol v-cloak>
    <li v-for="list in prefs"><a v-bind:href="list[1]" target="_blank">{{ list[0] }}</a></li>
  </ol>
</div>
  • 都道府県リストを元にループでliタグを生成しつつ、v-bind:hrefとURLを連結させています

Vue3側

//グローバル変数
var vm;

//ビューの初期化
const { createApp } = Vue

//起動時にGAS側からデータを取得する
google.script.run.withSuccessHandler(function(ret) {
  //データを取得する
  var result = JSON.parse(ret);
  
  //ビューの初期化
  vm = createApp({
    data() {
      return {
        title: "都道府県のページ",
        description: "全国47都道府県のホームページへのリンク集です。",
        prefs: result  
      }
    },
  }).mount('#app')

}).getpreflist();
  • GASからの返り値はただの二次元配列としています。
  • GASでデータ取得後にVueを初期化してるので、vmとcreateAppはGAS構文の外側にグローバルで宣言しておく
  • GAS側からのデータの取得がもたつくので、表示は工夫が必要(v-ifやv-cloakを使うと良いかも)

図:リスト表示で都道府県リンクを生成

インライン画面遷移を実現する

Vue + VueRouterなどで実現するのが定石ですが、単純にHTMLデータを入れ替えて表現する方法もあります。それがv-htmlですがコレは使い方を間違えるとXSS問題を引き起こす事があるので、安全なソースや環境下でだけ使うようにと、公式サイトにも記述されているので、限定的な場面で使うようにしましょう。

innerHTMLのような動きをするわけですが、呼び出し先にもVueを仕込んでる入れ子の場合は色々と注意が必要です。

GAS側

//指定のHTMLの中身を取得して返す
function retvmnormal(htmlname){
  var html = HtmlService.createHtmlOutputFromFile(htmlname).getContent();
  return html;
}
  • GAS側で指定のhtmlnameのHTMLデータを取得して返すだけ

HTML側

<div id="app">
  <button @click="transPage('vm_normal')" class="create">Home</button>
  <button @click="transPage('vm_table')" class="create">Table</button>
  <button @click="transPage('vm_link')" class="create">Link</button>
  <div v-html="rawHtml"></div>
</div>
  • ボタンが3つ。それぞれhtmlnameを引数で取るtransPage関数を叩いて、rawHtmlの中身を書き換える仕組み
  • vm_linkの時だけ、makelink関数をHTMLのロード後に実行しています

Vue3側

var vm;
var vm2;

//ビューの初期化
const { createApp } = Vue

vm = createApp({
  data() {
    return {
      rawHtml : null,
    }
  },
  methods: {
    transPage : function(page){
      //GAS側からHTMLを取得する
      google.script.run.withSuccessHandler(function(ret){
        //HTMLを返す
        vm.rawHtml = ret;
        
        //vm_linkの時はmakelinkを実行する
        if(page == "vm_link"){
          makelink();
        }
        
      }).retvmnormal(page);
    }
  },
}).mount('#app')

//リンクを生成する
function makelink(){
  //起動時にGAS側からデータを取得する
  google.script.run.withSuccessHandler(function(ret) {
    //データを取得する
    var result = JSON.parse(ret);

    vm2 = createApp({
      data() {
        return {
          title: "都道府県のページ",
          description: "全国47都道府県のホームページへのリンク集です。",
          prefs: result
        }
      },
    }).mount('#app2')
  }).getpreflist();    
}
  • transPageを叩いた返り値をrawHtml変数にそのまま格納します。
  • vm_linkの時だけは続けてmakelink関数を叩いています。
  • makelink関数は取得してきたHTMLの中身の子app2に対してvueの初期化をさらに実行して適用しています。

図:HTML入れ替えで遷移を実現

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)