Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

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

image.png

image.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>タグでherftarget="_top"を指定して、iframe内での動作をWebブラウザー全体に反映しましょう
    • クライアントはgoogle.script.history.setChangeHander()を使ってURLの変更に追随しましょう
  • Vue3はドキュメントにまだまだ不備があります
    • おかしいな、と思ったらissueを探してみましょう

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

公式ドキュメントで紹介しているTodoMVCには不具合があります。

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

肝心のCodePenのバグが修正されずに、issueがクローズされてました(2021/02/13時点)。「CodePen の制約によってハッシュナビゲーションは動作しません。」と書いてあるのも間違っています。

ハッシュナビゲーションとは、URLハッシュを使ってアプリを操作する方法です。アプリがhttps://vue.example.com/todosというURLで動作しているときに、アプリ内のリンク #/activeをクリックしてhttps://vue.example.com/todos#/activeに遷移すると、ToDo一覧からactiveな(completedではない)ものだけをフィルタリングして表示します。

どのToDoを表示するかは、visibilityというdataで制御しています。

TodoMVCのバグについて説明します。Vue2からVue3に移行した時に、かなりの人がハマりそうなので。

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

公式のTodoMVCでは、URLハッシュが変化したときに、このvisibilityの値を変更します。

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()で得られるルートコンポーネントのインスタンス経由で操作する必要があります。

// mount
// 注意!これだと動作しません。
// app.mount(".todoapp");
var vm = app.mount(".todoapp");
    // 注意!これだと動作しません。
    // 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つの関数を定義しています。

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を使ってfetchTodossaveTodosにアクセスし、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をそれぞれ展開します。

index.html
<!DOCTYPE html>
<html>
  <head>
    <base herf="<?= 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

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

js.html

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

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

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

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

// 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()を呼び出す

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

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

以上、4点を修正したjs.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をそのまま記述しています。

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を使っています。

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のヘッダーでbaseタグのhrefと、target="_top"を設定することをオススメします。

takatama
東京で働くソフトウェアエンジニアです。
https://twitter.com/takatama_jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away