今回はフレームワークなしでSPA(シングルページアプリケーション)を作る方法を書いていく。
まず初めに、SPAを作るうえで必要なものは何だろうか。筆者はFluxなどの設計思想とルーター(クライアントサイドルーター)だと思う。そこで今回は、Fluxを自分で実装する。ただ、クライアントサイドルーターはライブラリを使う。【1】
さて、Fluxをどのように実装するか。ボタンを押したらカウントが増えるアプリを例に考えてみる。なお以下の説明についていけない方はQiitaで丁寧に説明しているのでそちらも見てほしい。(フレームワークなしでSPAを作るために、WebComponentsを用いてFluxを実装する)
まずFluxのデータの流れを簡単に書くと以下のようになる。
component -> action -> dispatcher -> store -> component ->…
componentからactionを呼び出して、actionはdispatcherを使ってstoreにデータを流して、storeからcomponentへデータを流す。
まずdispatcherから見てみよう。dispatcherはただのEventEmitterだ。ここではEventEmitterの代わりにEventTargetを使う。EventTargetはブラウザ版EventEmitterのようなものだ。【2】
export default new EventTarget();
次にactionを見てみよう。
actionではdispatcherを使ってアクション名をstoreに伝える。
import dispatcher from 'dispatcher.mjs';
export default {
countUp() {
dispatcher.dispatchEvent(new CustomEvent('countUp'));
}
}
次にstoreを見てみよう。storeはactionから伝えられたアクション名によってstateを更新する。また、stateを更新したら、componentにstateが変更されたことを伝える。そのためstore自体もEventEmitterなのだ。
import dispatcher from 'dispatcher.mjs';
const initialState = {
count: 0
};
class Store extends EventTarget {
constructor() {
super();
Object.assign(this, initialState);
dispatcher.addEventListener('countUp', () => {
this.count++;
this.dispatchEvent(new Event('CHANGE')); //stateを変更したことをcomponentに伝える
});
}
}
export default new Store();
次にcomponentを見てみよう。componentはstoreのstateの変更を監視して、stateが更新されたらその都度描画する。また、ユーザーの行動に応じてactionを発行する。今回は、ボタンがクリックされたらcountUpアクションを呼ぶ。
一般にFluxのComponentにはReactなどの仮想DOMを取り入れたライブラリが使われる。しかし、今回はWebComponentsのCustomElementsを使う。つまり仮想DOMではなく生DOMを扱う。以下でその方法を見てみよう。
import actions from 'actions.mjs';
import store from 'store.mjs';
const html = `
<p>Count: <span>${store.count}</span></p>
<button type="button">Count Up</button>
`;
export default class Component extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = html;
this.span = shadowRoot.querySelector('span');
shadowRoot.querySelector('button').onclick = () => {
actions.countUp();
}
this.handleStoreChange = this.handleStoreChange.bind(this);
}
connectedCallback() {
//storeのstateの変更を監視する;
store.addEventListener('CHANGE', this.handleStoreChange);
//最初の描画;
this.handleStoreChange();
}
disconnectedCallback() {
store.removeEventListener('CHANGE', this.handleStoreChange);
}
handleStoreChange() {
//storeのstateが変更されたらcomponentのstateへ渡す;
this.count = store.count;
}
set count(value) {
//componentのstateが新しい値に置き換わった時のみ描画する
if(value === this._count) return;
this._count = value;
this.span.innerHTML = value;
}
}
CustomElementsなのでHTMLElementを継承したクラスを作る。
まずconstructorでcomponentの基礎となるHTMLを書いていく。ここではbutton要素がクリックされるとactionを呼ぶようにしている。constructorはCustomElementsがdocument.createElement()
やinnerHTML
で作られた時に呼ばれる。
そして、connectedCallbackにstoreの変更を監視するコードを書いている。storeのstateが変わり、CHANGEイベントが発火されると、handleStoreChangeメソッドが呼ばれる仕組みだ。
connectedCallbackはCustomElementsのライフサイクルの一つで、このcomponentがドキュメントに接続されたときに呼び出される。同様にdisconnectedCallbackはドキュメントから切断された時に呼び出される。ここではメモリリークを防止するためにドキュメントから切断されるとstoreの変更を監視するのを止めている。
また、componentは内部にstateを持つようにしている。storeのstateが変更されるとcomponentのstateに値が渡される。this.count = store.count
がその部分だ。そしてcomponentのstateが新しい値に変われば描画される仕組みだ。これでstateが変わった部分だけ差分でDOMを更新できる。DOMの更新はsetterの内部でしか行わないようにする。
流れとしては、storeのstateが更新されてCHANGEイベントが発火する → handleStoreChangeが呼ばれる → setterに値が渡される(コンポーネントのstateに値が渡される) → 描画されるという流れだ。
では実際にこのcomponentをレンダリングしよう。次のように書く。
import Component from 'Component.mjs';
customElements.define('x-component', Component);
document.body.innerHTML = '<x-component></x-component>';
作ったCustomElementsを利用するには、まず初めに1回だけcustomElements.define('要素名', Component)
とする。
結果は以下のようなアプリになる。なお、以下のJSコードをそのままブラウザのコンソールで実行してもカウントアップアプリができる。
さて、Fluxの実装を見てきたが、実際にSPAをどうやって作っていくのかサンプルを用意したのでそれを基に説明していく。
このサンプル(vanilla-spa-sample)は自分のサーバーで表示することもできる。以下をコンソールに入力して、http://localhost:4000/vanilla-spa-sampleにアクセスしよう。
git clone https://github.com/webkatu/vanilla-spa-sample
cd vanilla-spa-sample/
npm install
node index.js
このサンプルは、サーバーからブログの記事一覧と記事の内容を取得するだけのものだ。まずディレクトリ構成から見ていこう。
vanilla-spa-sample
|- public/
| |- index.html
| |- js/
| |- actions/
| |- common/
| |- components/
| |- stores/
| |- index.mjs
|
|- server/
|- index.js
public以下に実際にアクセスされるindex.htmlを置き、index.htmlから読み込まれるjavascriptも置く。jsディレクトリのcommonにはdispatcherやルーターなどの共通で使うコードを置く(今回、ルーターはCDNのURLを直接指定して読み込んでいるのでcommonには置いていない)。
またserver以下には、クライアントがサーバーから読み込みたいデータやコードを置く。例えばこのサンプルだとブログ記事一覧やブログ記事内容などだ。今回はjsonでデータを管理しているのでjsonを置いている。
index.jsはnodejsで実行するサーバーのコードだ。/vanilla-spa-sample/以下のアクセスは静的ファイルを除いて全部index.htmlにアクセスさせるようにしている。今回はnodejsを使っているが、Apacheを使う場合は、mod_rewriteを使ってアクセス制御する必要がある。
さて、ではindex.htmlを見てみよう。
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1">
<title></title>
<script src="https://unpkg.com/@corpuscule/custom-builtin-elements"></script>
<script src="https://unpkg.com/@ungap/event-target@0.2.0/min.js"></script>
<script type="module" src="/vanilla-spa-sample/js/index.mjs"></script>
</head>
<body>
</body>
</html>
ここでは、custom-builtin-elementsとevent-targetという2つのPolyfillを読み込んでいる。これはSafariに対応するためだ。(なお、このサンプルはIEには対応していない。)そして、index.mjsを読み込んでいる。パスは必ず絶対パスで指定しよう。(相対パスで指定しまうと例えば、/vanilla-spa-sample/hoge/にアクセスされた時に/vanilla-spa-sample/hoge/js/index.mjsとリソースを返してしまう。)
では、index.mjsを見てみよう。
//define componens;
import './components/index.mjs';
document.body.innerHTML = '<x-app></x-app>';
ここではcomponents/index.mjsを読み込んでるが、これは作ったCustomElementsをまとめてdefineしているファイルだ。customElements.define()
は各コンポーネント内に書くのではなく、最初に読み込まれるjsファイルでまとめて定義しよう。
そして、Appコンポーネントをbody.innerHTMLに代入している。CustomElementsの要素名は必ず一つの-(ハイフン)を含んでいないといけないため、x-appという要素名になっているが、これはAppコンポーネントのことで、components/App.mjsを指している。
では、components/App.mjsを見てみよう。
import page from 'https://unpkg.com/page@1.11.6/page.mjs';
import blogActions from '../actions/blogActions.mjs';
import { app, article } from '../stores/index.mjs';
const html = `
<app-header></app-header>
<main></main>
<style>
:host {
display: block;
max-width: 480px;
margin: 0 auto;
}
</style>
`;
export default class App extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = html;
const main = shadowRoot.querySelector('main');
const appIndex = document.createElement('app-index');
const blog = document.createElement('x-blog');
const blogIndex = document.createElement('blog-index');
const blogArticles = document.createElement('blog-articles');
const blogArticle = document.createElement('blog-article');
page.base(app.deploy);
page.strict(true);
page('*', (ctx, next) => {
main.innerHTML = '';
next();
});
page('/', () => {
main.append(appIndex);
document.title = app.title;
});
page('/blog/', () => {
blog.childComponent = blogIndex;
main.append(blog);
document.title = `Blog | ${app.title}`;
});
page('/blog/articles/', () => {
blogActions.fetchArticles();
blog.childComponent = blogArticles;
main.append(blog);
document.title = `Articles | ${app.title}`
});
page('/blog/articles/:id/', async (ctx) => {
blog.childComponent = blogArticle;
main.append(blog);
await blogActions.fetchArticle(ctx.params.id);
document.title = `${article.articleInfo.title} | ${app.title}`;
});
page('*', () => {
main.innerHTML = '<div>404 Not Found</div>';
document.title = `NotFound | ${app.title}`
});
page.start({ click: false });
}
}
少し長いが一つ一つ見ていこう。
まず、基礎となるhtmlをtemplate stringで書いていく。それをconstructor内でshadowRoot.innerHTMLに代入している。CustomElementsを作るときは基本的にshadowDOMを追加して、そこにhtmlを代入しよう。もしshadowDOMを作らず、constructor内で直接CustomElementsに要素を追加すると(例えばthis.appendChild(element)
とかすると)、document.createElement()で要素を作成するときにErrorを吐く。
ここでは、page.jsというルーターを使っている。使い方は簡単で、page('/path', callback)
と第一引数にパスを設定して、第二引数以降に関数を渡すと、callbackとパスが紐づいて登録される。このcallbackを呼び出したい時は、page('/path')
とするとcallbackが呼び出されURLも変わる。ルートコンポーネントであるAppコンポーネントでパスとcallbackを登録して、a要素をクリックされた時に、page(path)
としてやればよい。a要素をクリックした時の処理を毎回書くのは面倒なので、a要素を拡張するCustomElementsを作る。components/SPAAnchor.mjsを見てみよう。
import page from 'https://unpkg.com/page@1.11.6/page.mjs';
export default class SPAAnchor extends HTMLAnchorElement {
constructor() {
super();
this.onclick = (e) => {
e.preventDefault();
if(location.href === e.currentTarget.href) page.redirect(e.currentTarget.pathname + e.currentTarget.search + e.currentTarget.hash)
else page(e.currentTarget.pathname + e.currentTarget.search + e.currentTarget.hash);
}
}
}
これはクリックされた時、リンク先が現在のURLと同じだったら履歴を追加せずURLだけ変える。それ以外は履歴を追加したうえでURLも変更する。このCustomElementsをa要素に適用するには
customElements.define('spa-anchor', SPAAnchor, { extends: 'a' });
と定義したうえで、
<a is="spa-anchor"></a>
と書く。ちなみにこういう既存の要素を拡張するCustomElementsをcustomized built-in elementsと呼ぶ。
ではApp.mjsに戻ろう。page.base(app.deploy)
とあるが、ここではベースパスに/vanilla-spa-sampleを設定している。これでいちいちpage('/vanilla-spa-sample/blog/', callback)
と書かなくて済む。
page.strict()
は、URLのパスの末端の/(スラッシュ)をマッチさせるかどうかで、page.strict(true)
とすると、例えばここでは/vanilla-spa-sample/blog/はマッチするが、/vanilla-spa-sample/blogはマッチしない。
さて、pageでcallbackを登録しているところを見ていこう。ここでは、まず
page('*', (ctx, next) => {
main.innerHTML = '';
next();
});
として、ミドルウェアを登録している。この中では、main要素のinnerHTMLを消している。こうすることで、URLが変わるたび、mainの中のコンポーネントを入れ替えられる。またnext()
としないと次の登録済みcallbackが呼ばれずここで処理が終わってしまう。
App.mjsの続きを見ていく。
page('/blog/', () => {
blog.childComponent = blogIndex;
main.append(blog);
document.title = `Blog | ${app.title}`;
});
page('/blog/articles/', () => {
blogActions.fetchArticles();
blog.childComponent = blogArticles;
main.append(blog);
document.title = `Articles | ${app.title}`
});
page('/blog/articles/:id/', async (ctx) => {
blog.childComponent = blogArticle;
main.append(blog);
await blogActions.fetchArticle(ctx.params.id);
document.title = `${article.articleInfo.title} | ${app.title}`;
});
ここでは、/vanilla-spa-sample/blog/以下のパスにはmain要素にBlogコンポーネントを表示させるようにしている。また、/blog/以下のパスによってBlogコンポーネントの子コンポーネントも変えている。こういうようなコンポーネントの指定の仕方もあるという一例だ。
ではさらにApp.mjsの続きを見ていく。
page('*', () => {
main.innerHTML = '404 Not Found
';
document.title = `NotFound | ${app.title}`
});
page.start({ click: false });
route定義の最後に何もマッチしなかった時に404NotFoundを返すミドルウェアを登録している。
page.start()
というのは、アクセスされた時の一番最初のディスパッチで、これがないとアクセスされてもページが表示されない。{ click: false }
というのは、本当はpage.jsはa要素を拡張しなくてもクリックしたら自動でディスパッチしてくれる親切設計なのだが、今回はこの機能を使わないので{ click: false }
とする。
今回示している方法でSPAを作っていく場合、App.mjsのようなルートコンポーネントはこのような感じでrouteを定義して、パスに応じてコンポーネントを表示するという形になるだろう。
では次にBlogコンポーネントを見ていこう。components/Blog.mjsがそれだ。
import blogActions from '../actions/blogActions.mjs';
import { app, blog, user } from '../stores/index.mjs';
const html = `
<div class="blogHeader"><h2><a is="spa-anchor" href="${app.deploy}/blog/">BLOG</a></h2></div>
<div class="menu"></div>
<div class="blogMain"></div>
`;
export default class Blog extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = html;
this.menu = shadowRoot.querySelector('.menu');
this.blogMain = shadowRoot.querySelector('.blogMain');
this.blogSignedOutMenu = document.createElement('blog-signed-out-menu');
this.blogSignedInMenu = document.createElement('blog-signed-in-menu');
this.blogSignedOutMenu.handleSignInButtonClick = this.handleSignInButtonClick;
this.blogSignedInMenu.handleSignOutButtonClick = this.handleSignOutButtonClick;
this.handleBlogChange = this.handleBlogChange.bind(this);
this.handleUserChange = this.handleUserChange.bind(this);
}
connectedCallback() {
blog.addEventListener('CHANGE', this.handleBlogChange);
user.addEventListener('CHANGE', this.handleUserChange);
this.handleBlogChange();
this.handleUserChange();
}
disconnectedCallback() {
blog.removeEventListener('CHANGE', this.handleBlogChange);
user.removeEventListener('CHANGE', this.handleUserChange);
}
handleBlogChange() {
this.isSignedIn = blog.isSignedIn;
}
handleUserChange() {
this.userInfo = user.userInfo;
}
handleSignInButtonClick() {
blogActions.signIn();
}
handleSignOutButtonClick() {
blogActions.signOut();
}
set isSignedIn(val) {
if(val === this._isSignedIn) return;
this._isSignedIn = val;
this.menu.innerHTML = '';
if(val === true) {
this.menu.append(this.blogSignedInMenu);
}else if(val === false) {
this.menu.append(this.blogSignedOutMenu);
}
}
set userInfo(val) {
if(val === this._userInfo) return;
this._userInfo = val;
this.blogSignedInMenu.userName = val.userName;
}
set childComponent(val) {
if(val === this._childComponent) return;
this._childComponent = val;
this.blogMain.innerHTML = '';
this.blogMain.append(val)
}
}
まずconstructor内から見てみよう。ここでは、blog-signed-out-menu要素とblog-signed-in-menu要素を作って、その要素のプロパティに関数を渡している。set isSignedIn(val)
を見てみるとわかるとおり、この2つの要素は、サインアウトしてるときはblog-signed-out-menuを、サインインしているときはblog-signed-in-menuを.menu以下に表示するようにしている。components/BlogSignedOutMenu.mjsを見てみよう。
const html = `
<button>Sign In</button>
`;
export default class BlogSignedOutMenu extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = html;
this.button = shadowRoot.querySelector('button');
}
set handleSignInButtonClick(val) {
this.button.onclick = val;
}
}
ここではSignInボタンのonclickにBlog.mjsから渡された関数を登録している。このように親コンポーネントから子コンポーネントへと値を渡すこともできる。小さいコンポーネントの場合、親コンポーネントでstore連携して、子コンポーネントは親から値だけ受け取るという状況もあるかもしれない
ではBlog.mjsに戻って続きを見ていこう。
connectedCallback() {
blog.addEventListener('CHANGE', this.handleBlogChange);
user.addEventListener('CHANGE', this.handleUserChange);
this.handleBlogChange();
this.handleUserChange();
}
disconnectedCallback() {
blog.removeEventListener('CHANGE', this.handleBlogChange);
user.removeEventListener('CHANGE', this.handleUserChange);
}
handleBlogChange() {
this.isSignedIn = blog.isSignedIn;
}
handleUserChange() {
this.userInfo = user.userInfo;
}
ここは、冒頭のカウントアップアプリでFluxを実装する部分でも書いたようにstoreと連携するときに書くコードだ。storeのstateがCHANGEされたらhandle○○○Changeメソッドが呼ばれコンポーネントのstate(setter)に値が渡される。ここはお決まりなので、処理の流れがわからない方はカウントアップアプリのコードを見て、流れをつかんでほしい。またconstructor内で、this.handle○○○Change = this.handle○○○Change.bind(this)
とするのも忘れないようにしよう。
ではさらにBlog.mjsの続きを見てみよう。
set childComponent(val) {
if(val === this._childComponent) return;
this._childComponent = val;
this.blogMain.innerHTML = '';
this.blogMain.append(val)
}
このchildComponentの値は親コンポーネントから渡される。同じ値の場合はreturnして、違うコンポーネントが与えられたらそれを描画する。例えば、戻ってcomponents/App.mjsのコード(以下)を見てみると、/vanilla-spa-sample/blog/articles/にアクセスされるとchildComponentにBlogArticlesコンポーネントが渡されている。
page('/blog/articles/', () => {
blogActions.fetchArticles();
blog.childComponent = blogArticles;
main.append(blog);
});
では次にBlogArticles.mjsを見て実際にサーバーのデータをどう表示しているか見ていこう。
import { app, articles } from '../stores/index.mjs';
const html = `
<ul></ul>
`;
export default class BlogArticles extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = html;
this.ul = shadowRoot.querySelector('ul');
this.handleArticlesChange = this.handleArticlesChange.bind(this);
}
connectedCallback() {
articles.addEventListener('CHANGE', this.handleArticlesChange);
this.handleArticlesChange();
}
disconnectedCallback() {
articles.removeEventListener('CHANGE', this.handleArticlesChange);
}
handleArticlesChange() {
this.articleList = articles.articleList;
}
set articleList(val) {
if(this._articleList === val) return;
this._articleList = val;
this.ul.innerHTML = '';
val.forEach((article) => {
const li = document.createElement('li');
li.innerHTML = `<a is="spa-anchor" href=${app.deploy}/blog/articles/${article.id}/>${article.title}</a>`;
this.ul.append(li);
});
}
}
handleArticlesChangeを見ると、ストアからarticles.articleList
というstateを受け取っていることがわかる。このarticleListというのは記事一覧の情報が入った配列だ。articleListはサーバーから取ってくる。流れを見てみよう。
まず、ユーザーが/vanilla-spa-sample/blog/articles/にアクセスするとblogActions.fetchArticles()
が呼ばれる。以下のコードがそれだ。
page('/blog/articles/', () => {
blogActions.fetchArticles();
blog.childComponent = blogArticles;
main.append(blog);
});
blogActions.fetchArticlesでサーバーから記事一覧の情報を取得する。そして、ストアにdispatchしてサーバーからのデータを渡す。そのコードを見てみよう。
async fetchArticles() {
dispatcher.dispatchEvent(new Event('fetchArticles'));
let json;
try {
const response = await fetch(`${config.server}/articles.json`);
json = await response.json();
}catch(e) {
console.log(e);
return dispatcher.dispatchEvent(new Event('fetchArticlesFailed'));
}
json.length = Object.keys(json).length;
dispatcher.dispatchEvent(new CustomEvent('fetchArticlesSuccessful', { detail: json }));
}
ここではまず、’fetchArticles’というアクションをdispatchしている。そしてその後、サーバーにfetchして成功したら’fetchArticlesSuccessful’、失敗したら’fetchArticlesFailed’というアクションをdispatchしている。こうすることで、読み込み時、失敗時、成功時と処理を分けることができる。サーバーにfetchするアクションのパターンなので覚えてほしい。
そして、fetchに成功した時、返ってきたjsonをストアに流している。EventTargetを使ってデーターを流すときはnew CustomEvent('アクション名', { detail: data })
とCustomEventを使う。
ちなみに今回サーバーから返ってくるjsonは以下だ。
{
"0": {
"id": "1",
"title": "article1 title"
},
"1": {
"id": "16",
"title": "article2 title"
},
"2": {
"id": "27",
"title": "article3 title"
}
}
では’fetchArticlesSuccessful’アクションをlistenしたストアを見てみよう。
import dispatcher from '../common/dispatcher.mjs';
const initialState = {
articleList: [],
}
export default class Articles extends EventTarget {
constructor() {
super();
Object.assign(this, JSON.parse(JSON.stringify(initialState)));
dispatcher.addEventListener('fetchArticlesSuccessful', (e) => {
this.articleList = Array.from(e.detail);
this.dispatchEvent(new Event('CHANGE'));
})
}
}
アクションから渡されたデータをそのままarticleListに代入しているだけだ。そして、stateが変更されたことをコンポーネントへ伝える。
では最後にもう一度BlogArticlesコンポーネントを見てみよう。
set articleList(val) {
if(this._articleList === val) return;
this._articleList = val;
this.ul.innerHTML = '';
val.forEach((article) => {
const li = document.createElement('li');
li.innerHTML = `<a is="spa-anchor" href=${app.deploy}/blog/articles/${article.id}/>${article.title}</a>`;
this.ul.append(li);
});
}
articleListが渡されると、articleListのlengthに応じてli要素を作り、そこに内容を表示してレンダリングしている。このような流れで、サーバーから読み込んだデータを表示している。
このような、componentのどこかでactionが呼ばれ、actionでdispatcherを使って、actionをdispatchし、storeはそれをlistenして、storeのstateを変更、stateを変更したら、storeはCHANGEイベントをdispatchし、componentはそれをlistenして、storeからstateを取得して、それに応じて描画するという流れを掴めるとこの方法でコードを書いていくことができると思う。サンプルにはまだ続きがあるが、どれも同じような流れなのでここでサンプルのコードの説明は終了する。
終わりに
以上、フレームワークなしでSPAを作る方法を見てきた。この方法にはまだ考えなければならない点もある。例えば、ShadowDOMを使っているので、今までのcssの書き方とは全く異なってくる。共通のcssをどのように書くかが難しい(shadowRootのadoptedStyleSheetsというメソッドを使う方法があるが、対応していないブラウザもある)。また生DOMなので敬遠される方もおられるかもしれない。しかし、フレームワークなしで作るメリットもあると思う。なによりフレームワークの流行り廃りに左右されない。また、今回の方法ではbabelもwebpackも使わないのですぐコードを書ける。必要なのはSafariのための少しのpolyfillだ。この記事が、フレームワークなしでSPAを作ろうとしている方の参考になれば嬉しい。