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の変更に追随しましょう
- <base>タグで
- 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に移行する時に多くの人がハマりそうなので解説しておきます。
変数app
でcreateApp()
した結果を保持しています。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つの関数を定義しています。
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をそれぞれ展開します。
<!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
<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は次のようになります。
<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をそのまま記述しています。
<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
を使っています。
<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"を設定することをオススメします。