LoginSignup
16
10

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-02-11

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

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

2021/02/13時点では公式ドキュメントで紹介している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

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

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

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

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 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

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

16
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
10