Vue3のTodoMVCをGoogle Apps Scriptのウェブアプリとして作り変えてみた

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/c0c4ed27-0498-d89d-75f9-bb416e1361d6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/23e34a57-5b68-2808-f280-2cce02fb85ab.png)

Google Apps Script（GAS）を使うと（制限はあるものの）無料でウェブアプリを作って公開できます。

Vue.jsの公式ドキュメントには複数のアプリケーション例が掲載されています。そのうちの一つ、TodoMVCをGASを使って作り直してみました。

> TodoMVC | Vue.js
> https://v3.ja.vuejs.org/examples/todomvc.html

元のアプリはlocalStorageにToDoを保存していますが、保存先をGoogle Sheetsに変更しています。

GASを使ってVue.jsを動かす方法は次のページを参考にしました。丁寧な解説どうもありがとうございます！

> GASとJavaScriptフレームワークVue.jsを使ってWebアプリを作成するための最初の一歩
> https://tonari-it.com/gas-web-app-vue-js/

# GASを使ってVue.jsのアプリを無料で公開するコツ

先にまとめておきます。

- GASのウェブアプリはiframe内で動作します
  - \<base\>タグで`href`属性に`ScriptApp.getService().getUrl()`で取得したURLを設定し、`target`属性に`_top`を指定して、iframe内での動作をWebブラウザー全体に反映しましょう
  - クライアントは```google.script.history.setChangeHander()```を使ってURLの変更に追随しましょう
- Vue3はドキュメントにまだまだ不備があります
  - おかしいな、と思ったら[issue](https://github.com/vuejs/docs-next/issues)を探してみましょう

# Vue.jsのTodoMVCをGASで動かすのに苦労したこと

2021/02/13時点では公式ドキュメントで紹介している[TodoMVC](https://codepen.io/team/Vue/pen/Yzqyozj)には不具合がありました（その後、修正されました）。

> TodoMVC "hashtag navigation will not work" is the code's bug, nothing about CodePen's limitation · Issue #659 · vuejs/docs-next · GitHub
> https://github.com/vuejs/docs-next/issues/659

不具合の内容ですが、Vue2からVue3に移行する時に多くの人がハマりそうなので解説しておきます。

変数```app```で```createApp()```した結果を保持しています。dataの```visibility```には```all```を入れています。

```javascript
var app = Vue.createApp({
  data() { 
    return {
      ...
      visibility: "all"
    }
  },
```

URLハッシュの変化に合わせて、この```visibility```の値を変更します。

```javascript
function onHashChange(e) {
  var visibility = window.location.hash.replace(/#\/?/, ""); 
  if (filters[visibility]) {
    // 注意！これだと動作しません。
    app.visibility = visibility;
```

このコード、Vue2では動作していたんですが、Vue3では動きません。

> The Root Component | Application & Component Instances | Vue.js
> https://v3.vuejs.org/guide/instance.html#the-root-component

appはアプリケーションインスタンスです。visibilityの値を変化させて画面を更新したいなら、app.mount()で得られるルートコンポーネントのインスタンス経由で操作する必要があります。

```javascript
// mount
// 注意！修正前のコードです。これだと動作しません。
// app.mount(".todoapp");
var vm = app.mount(".todoapp");
```

```javascript
    // 注意！修正前のコードです。これだと動作しません。
    // app.visibility = visibility;
    vm.visibility = visibility;
```

この不具合を修正した上で、GASでURLハッシュを使ったハッシュナビゲーションを実現するには、クライアントのJavaScriptでgoogle.script.history.setChangeHandlerを利用する必要があります。

> Class google.script.history (Client-side API)  |  Apps Script
> https://developers.google.com/apps-script/guides/html/reference/history#setChangeHandler(Function)

GASのウェブアプリは公開しているURLとは異なるドメインのiframe内で、Vue.jsのクライアントが動作しているためです。

> google apps script - Where is my iframe in the published web application/sidebar? - Stack Overflow
> https://stackoverflow.com/questions/63551837/where-is-my-iframe-in-the-published-web-application-sidebar

iframeの中のスクリプトが、Webブラウザーが表示しているtopのURLが変化したことを検知する必要があります。

# GAS版のTodoMVC

Code.gs, index.html, css.html, js.html, app.htmlの5つのファイルに分けて実装しました。

## Code.gs

Code.gsでは、ウェブアプリ用の```doGet```、ToDoを取得する```fetchTodos```、ToDoを保存する```saveTodos```の3つの関数を定義しています。

```javascript:Code.gs
function doGet() {
  var template = HtmlService.createTemplateFromFile('index');
  template.url = ScriptApp.getService().getUrl();
  var htmlOutput = template.evaluate();
  htmlOutput
    .setTitle('TodoMVC')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
  return htmlOutput;
}

function fetchTodos() {
  const todos = [];
  SpreadsheetApp.getActiveSheet().getDataRange().getValues().forEach(row => {
    todos.push({title: row[0], completed: row[1]});
  });
  todos.splice(0, 1); // skip header row
  return todos;
}

function saveTodos(todos) {
  const values = [['title', 'completed']];
  todos.forEach(todo => {
    values.push([todo.title, todo.completed]);
  });
  SpreadsheetApp.getActiveSheet().clear().getRange(1, 1, values.length, 2).setValues(values);
}
```

WebブラウザーでウェブアプリのURLにHTTPのGETメソッドでアクセスすると、```doGet```によりindex.htmlを返します。index.htmlに記述されたクライアントは、google.script.runを使って```fetchTodos```や```saveTodos```にアクセスし、ToDoの取得・保存をします。

ウェブアプリのURLは、テスト用と本番用の二種類が払い出されます。テスト用は末尾が```#/dev```、本番用は末尾が```#/exec```です。どちらのURLで動作しているかは、ScriptApp.getSerivce().getUrl()で取得できます。

> Class Service  |  Apps Script  |  Google Developers
> https://developers.google.com/apps-script/reference/script/service#geturl

index.htmlはurlというテンプレート変数を受け取るように作ってあります。ウェブアプリが動作しているURLを渡しています。

google.script.runについては次をご覧ください。

> GASのWebアプリでクライアント側JavaScriptからサーバー側の関数を呼び出す方法
> https://tonari-it.com/gas-web-app-google-script-run/

## index.html

index.htmlでは、ウェブアプリが動作しているURLであるurl、Vue.jsのCSS、css.html、app.html、js.htmlをそれぞれ展開します。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <base href="<?= url ?>" target="_top">
    <link rel="stylesheet" href="//unpkg.com/todomvc-app-css@2.2.0/index.css">
    <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
  </head>
  <body>
    <?!= HtmlService.createHtmlOutputFromFile('app').getContent(); ?>
    <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
  </body>
</html>
```

GASのウェブアプリはiframe内で動作しています。iframe内のリンクは相対パスで記述していますが、そのベースとなるURLを\<base\>タグのhrefで定義します。また、target="_top"とすると、iframe内のURLをクリックした時にtopであるウェブブラウザーのURLを変更します。

## css.html

css.htmlでは、{{...}} と表記するmustacheタグ（テンプレートタグ）が展開するまで見えなくなるように設定してあります。

> ディレクティブ | Vue.js
> https://v3.ja.vuejs.org/api/directives.html#v-cloak

```html:css.html
<style>
[v-cloak] {
  display: none;
}
</style>
```

## js.html

js.htmlでは、Vue.jsの動作を定義しています。
元のアプリから変更したのは、次の4点です。

(1) Vue.jsのコードを取得する

```html
<script src="https://unpkg.com/vue@next"></script>
```

(2) 追加した mounted()の中で app.todos を初期化する。初期化にはgoogle.script.runを使って```Code.gs```で定義した```fetchTodos()```を呼び出す

```javascript
// app Vue instance
var app = Vue.createApp({
  // app initial state
  data() { 
    return {
      todos: /* todoStorage.fetch() */ [],
      newTodo: "",
      editedTodo: null,
      visibility: "all"
    }
  },
  // 追加
  mounted() {
    google.script.run.withSuccessHandler(json => {
      var todos = json;
      todos.forEach(function(todo, index) {
        todo.id = index;
      });
      todoStorage.uid = todos.length;
      this.todos = todos;
    }).fetchTodos();
  },
```

(3) ToDoの保存にはgoogle.script.runを使って```Code.gs```で定義した```saveTodos()```を呼び出す

```javascript
  watch: {
    todos: {
      handler: function(todos) {
        /* todoStorage.save(todos); */
        // 追加
        google.script.run.withSuccessHandler().saveTodos(todos);
      },
```

(4) google.script.history.setChangeHandlerを使ったハッシュナビゲーション

以上、4点を修正したjs.htmlは次のようになります。

```html:js.html
<script src="https://unpkg.com/vue@next"></script>
<script>
// 
// Full spec-compliant TodoMVC with localStorage persistence
// and hash-based routing in ~120 effective lines of JavaScript.

// localStorage persistence
var STORAGE_KEY = "todos-vuejs-2.0";
var todoStorage = {
  fetch: function() {
    var todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
    todos.forEach(function(todo, index) {
      todo.id = index;
    });
    todoStorage.uid = todos.length;
    return todos;
  },
  save: function(todos) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  }
};

// visibility filters
var filters = {
  all: function(todos) {
    return todos;
  },
  active: function(todos) {
    return todos.filter(function(todo) {
      return !todo.completed;
    });
  },
  completed: function(todos) {
    return todos.filter(function(todo) {
      return todo.completed;
    });
  }
}

// app Vue instance
var app = Vue.createApp({
  // app initial state
  data() { 
    return {
      todos: /* todoStorage.fetch() */ [],
      newTodo: "",
      editedTodo: null,
      visibility: "all"
    }
  },
  // 追加
  mounted() {
    google.script.run.withSuccessHandler(json => {
      var todos = json;
      todos.forEach(function(todo, index) {
        todo.id = index;
      });
      todoStorage.uid = todos.length;
      this.todos = todos;
    }).fetchTodos();
  },
  // watch todos change for localStorage persistence
  watch: {
    todos: {
      handler: function(todos) {
        /* todoStorage.save(todos); */
        // 追加
        google.script.run.withSuccessHandler().saveTodos(todos);
      },
      deep: true
    }
  },

  // computed properties
  // http://vuejs.org/guide/computed.html
  computed: {
    filteredTodos: function() {
      return filters[this.visibility](this.todos);
    },
    remaining: function() {
      return filters.active(this.todos).length;
    },
    allDone: {
      get: function() {
        return this.remaining === 0;
      },
      set: function(value) {
        this.todos.forEach(function(todo) {
          todo.completed = value;
        });
      }
    }
  },

  // methods that implement data logic.
  // note there's no DOM manipulation here at all.
  methods: {
    pluralize: function(n) {
      return n === 1 ? "item" : "items";
    },
    addTodo: function() {
      var value = this.newTodo && this.newTodo.trim();
      if (!value) {
        return;
      }
      this.todos.push({
        id: todoStorage.uid++,
        title: value,
        completed: false
      });
      this.newTodo = "";
    },

    removeTodo: function(todo) {
      this.todos.splice(this.todos.indexOf(todo), 1);
    },

    editTodo: function(todo) {
      this.beforeEditCache = todo.title;
      this.editedTodo = todo;
    },

    doneEdit: function(todo) {
      if (!this.editedTodo) {
        return;
      }
      this.editedTodo = null;
      todo.title = todo.title.trim();
      if (!todo.title) {
        this.removeTodo(todo);
      }
    },

    cancelEdit: function(todo) {
      this.editedTodo = null;
      todo.title = this.beforeEditCache;
    },

    removeCompleted: function() {
      this.todos = filters.active(this.todos);
    }
  },

  // a custom directive to wait for the DOM to be updated
  // before focusing on the input field.
  // http://vuejs.org/guide/custom-directive.html
  directives: {
    "todo-focus": {
      updated(el, binding) {
        if (binding.value) {
          el.focus();
        }
      }
    }
  }
});

// handle routing
function onHashChange(e) {
  /* var visibility = window.location.hash.replace(/#\/?/, ""); */
  var visibility = e.location.hash.replace(/#?\/?/, "");
  if (filters[visibility]) {
    /* app.visibility = visibility; */
    vm.visibility = visibility;
  } else {
    /* window.location.hash = ""; */
    e.location.hash = '';
    //app.visibility = "all";
    vm.visibility = 'all';
  }
}

/*
window.addEventListener("hashchange", onHashChange);
onHashChange();
*/

google.script.history.setChangeHandler(function (e) {
  onHashChange(e);
});

google.script.url.getLocation(location => {
  const e = {location};
  onHashChange(e);
});

// mount
/* app.mount(".todoapp"); */
var vm = app.mount(".todoapp");
</script>
```

## app.html

最後に、コンポーネントを定義したapp.htmlです。これは公式ドキュメントのTodoMVCをそのまま記述しています。

```html:app.html
<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input
           class="new-todo"
           autofocus
           autocomplete="off"
           placeholder="What needs to be done?"
           v-model="newTodo"
           @keyup.enter="addTodo"
           />
  </header>
  <section class="main" v-show="todos.length" v-cloak>
    <input
           id="toggle-all"
           class="toggle-all"
           type="checkbox"
           v-model="allDone"
           />
    <label for="toggle-all"></label>
    <ul class="todo-list">
      <li
          v-for="todo in filteredTodos"
          class="todo"
          :key="todo.id"
          :class="{ completed: todo.completed, editing: todo == editedTodo }"
          >
        <div class="view">
          <input class="toggle" type="checkbox" v-model="todo.completed" />
          <label @dblclick="editTodo(todo)">{{ todo.title }}</label>
          <button class="destroy" @click="removeTodo(todo)"></button>
        </div>
        <input
               class="edit"
               type="text"
               v-model="todo.title"
               v-todo-focus="todo == editedTodo"
               @blur="doneEdit(todo)"
               @keyup.enter="doneEdit(todo)"
               @keyup.esc="cancelEdit(todo)"
               />
      </li>
    </ul>
  </section>
  <footer class="footer" v-show="todos.length" v-cloak>
    <span class="todo-count">
      <strong>{{ remaining }}</strong> {{ remaining | pluralize }} left
    </span>
    <ul class="filters">
      <li>
        <a href="#/all" :class="{ selected: visibility == 'all' }">All</a>
      </li>
      <li>
        <a href="#/active" :class="{ selected: visibility == 'active' }">Active</a>
      </li>
      <li>
        <a
           href="#/completed" 
           :class="{ selected: visibility == 'completed' }">Completed</a>
      </li>
    </ul>
    <button
            class="clear-completed"
            @click="removeCompleted"
            v-show="todos.length > remaining"
            >
      Clear completed
    </button>
  </footer>
</section>
<footer class="info">
  <p>Double-click to edit a todo</p>
  <p>Written by <a href="http://evanyou.me">Evan You</a></p>
  <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
```

# おまけ（ハッシュナビゲーションを使わない方法）

ハッシュナビゲーション（google.script.history.setChangeHander）を使わない方法もご紹介しておきます。例えばリンク```#/active``がクリックされたときにURLを遷移せず、ToDo一覧からactiveなものをフィルタリングする動作です。

## app.html

URLハッシュがそれぞれクリックされたときに、visibilityに該当する値を入れるようにしています。```@click```だけだと、ページ遷移してしまうので、```@click.prevent```を使っています。

```html:app.html
<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input
           class="new-todo"
           autofocus
           autocomplete="off"
           placeholder="What needs to be done?"
           v-model="newTodo"
           @keyup.enter="addTodo"
           />
  </header>
  <section class="main" v-show="todos.length" v-cloak>
    <input
           id="toggle-all"
           class="toggle-all"
           type="checkbox"
           v-model="allDone"
           />
    <label for="toggle-all"></label>
    <ul class="todo-list">
      <li
          v-for="todo in filteredTodos"
          class="todo"
          :key="todo.id"
          :class="{ completed: todo.completed, editing: todo == editedTodo }"
          >
        <div class="view">
          <input class="toggle" type="checkbox" v-model="todo.completed" />
          <label @dblclick="editTodo(todo)">{{ todo.title }}</label>
          <button class="destroy" @click="removeTodo(todo)"></button>
        </div>
        <input
               class="edit"
               type="text"
               v-model="todo.title"
               v-todo-focus="todo == editedTodo"
               @blur="doneEdit(todo)"
               @keyup.enter="doneEdit(todo)"
               @keyup.esc="cancelEdit(todo)"
               />
      </li>
    </ul>
  </section>
  <footer class="footer" v-show="todos.length" v-cloak>
    <span class="todo-count">
      <strong>{{ remaining }}</strong> {{ remaining | pluralize }} left
    </span>
    <ul class="filters">
      <li>
        <a href="#/all" @click.prevent="visibility = 'all'" :class="{ selected: visibility == 'all' }">All</a>
      </li>
      <li>
        <a href="#/active" @click.prevent="visibility = 'active'" :class="{ selected: visibility == 'active' }">Active</a>
      </li>
      <li>
        <a
           href="#/completed" @click.prevent="visibility = 'completed'" 
           :class="{ selected: visibility == 'completed' }">Completed</a>
      </li>
    </ul>
    <button
            class="clear-completed"
            @click="removeCompleted"
            v-show="todos.length > remaining"
            >
      Clear completed
    </button>
  </footer>
</section>
<footer class="info">
  <p>Double-click to edit a todo</p>
  <p>Written by <a href="http://evanyou.me">Evan You</a></p>
  <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
```

# 感想

iframeでハマりまくりました...。

GASでウェブアプリを作るときには、index.htmlの<header>で<base>タグのhrefと、target="_top"を設定することをオススメします。