しぐれ のすべての投稿

【SublimeText】JavaScriptのtemplate stringsの中のHTMLをsyntax highlightする方法

JavaScriptのtemplate stringsを使ってHTMLを書いている人というのは少なくないと思う。

そこで今回は、sublime text 3でtemplate stringsの中のHTMLをsyntax highlightする方法とtemplate stringsの中にHTMLを書く時、自動で閉じタグを表示する方法を書いていく。

template stringsの中のHTMLをsyntax highlight

まず、PackageControllからBabelをインストールする。

次に、jsファイルのデフォルトのsyntaxをJavaScript(Babel)にする。

(jsファイルを開いてから、View -> Syntax -> Open all with current extension as… -> Babel -> JavaScript(Babel)を選択)

次に、Babel Packageディレクトリを開く。(Preferences -> Browse Packages -> Babel)

その中にある「JavaScript (Babel).sublime-syntax」を開く。

「template-string-body:」というセクションを探し、その先頭に次の2行を追加

- meta_content_scope: text.html.basic.embedded.js
- include: scope:text.html.basic

これでtemplate stringsの中のHTMLがsyntax highlightされるようになる。

template stringsの中にHTMLを書く時、自動で閉じタグを表示する方法

これには2つの方法がある。

一つ目は、「<h1」と打った時にtabキーを押す。

もう一つの方法は、「<h1></」と/(スラッシュ)を打った瞬間に閉じタグを表示する方法だ。

Preferences -> Key Bindingsを開きUserの方に以下を追加する。

{
	"keys": ["/"],
	"command": "close_tag",
	"args": { "insert_slash": true },
	"context": [
		{
			"key": "selector",
			"operator": "equal",
			"operand": "(text.html, text.xml, source.js) source.js string.template - string - comment",
			"match_all": true
		},
		{
			"key": "preceding_text",
			"operator": "regex_match",
			"operand": ".*<$",
			"match_all": true
		},
		{ "key": "setting.auto_close_tags" }
	]
}

これでtemplate stringsの中でもスラッシュを打った時に自動で閉じタグが表示されるはずだ。

フレームワークなしで作るSPA

今回はフレームワークなしで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コードをそのままブラウザのコンソールで実行してもカウントアップアプリができる。

See the Pen CustomElements-Flux-sample by shigure (@webkatu) on CodePen.

さて、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-elementsevent-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を作ろうとしている方の参考になれば嬉しい。

puppeteerで取得できるcookieのexpiresの単位

puppeteerで取得できるcookieのexpiresはUNIX時間を表しています。単位は秒です。

JavaScriptのDateインスタンスのgetTimeメソッド((new Date()).getTime())はUNIX時間をミリ秒で取得します。そのため、puppeteerで取得したcookieのexpiresの期限を知りたい時や、setCookie()でexpiresをセットしたい時は変換しなければいけません。

以下はgoogle.comのcookieの一部です。

[
  {
    name: '1P_JAR',
    value: '2020-5-23-8',
    domain: '.google.com',
    path: '/',
    expires: 1592816050,
    size: 17,
    httpOnly: false,
    secure: true,
    session: false
  }
]

このcookieのexpiresの期限を知りたい時は以下のようなコードを書きます。expiresをミリ秒に変換してDateコンストラクタの引数に渡しています。

const cookies = await page.cookies();
const date = new Date(cookies[0].expires * 1000);
console.log(date);

getTime()を使ってexpiresをセットするときは / 1000をします。以下のコードは1日後の秒単位のUNIX時間です。

const date = new Date();
const expires = (date.getTime() + 1000 * 60 * 60 * 24) / 1000;

WindowsでiOSのSafariで開いたページをデバッグする

iPhoneやiPad(以下、iOSデバイス)を持っているなら、Macを持っていなくても、WindowsからSafariで開いたページのデバッグができる。その方法を書いていく。

検証環境

  • Windows10
  • iOS13

手順

  1. iOSデバイスのSafariのWebインスペクタを有効化する。設定->Safari->詳細

  2. WindowsPCにiTunesをインストール。Store版ではなく従来のソフト版をダウンロードする。

  3. ios-webkit-debug-proxyやremotedebug-ios-webkit-adapterをインストールするためにscoopをインストール

    scoopとは、簡単に例えると、Homebrew、apt-get、yumなどのWindows版だ。

    WindowsでPowerShellを開き以下のコマンドを実行するとscoopがインストールされる。

    Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
    iwr -useb get.scoop.sh | iex
    

    インストールできたらscoopからgitをインストールする

    scoop install git
    

    scoopにextras bucketを追加して、様々なアプリをインストールできるようにする。

    scoop bucket add extras
    
  4. scoopからios-webkit-debug-proxyをインストール

    scoop install ios-webkit-debug-proxy
    
  5. scoopからnodejsをインストール

    scoop install nodejs
    

    npmからremotedebug-ios-webkit-adapterをインストール

    npm install remotedebug-ios-webkit-adapter -g
    
  6. iOSデバイスをPCに繋ぎ、iTunesを開き、「このコンピュータを信頼する」

  7. remotedebug_ios_webkit_adapterを起動

    remotedebug_ios_webkit_adapter --port=9000
    
  8. Chromeを開き、アドレスバーに chrome://inspect/#devices と入力

    Configureをクリックし、Target discovery settingsにlocalhost:9000を追加

  9. Safariでデバッグしたいページを開くとchrome://inspect/#devicesのRemote TargetにそのページのURLが表示されるのでinspectをクリック。するとDevToolsが立ち上がってそこからデバッグできる。

関連

WebComponentsでFlux

(この記事は実験的な記事です。フレームワークなしでSPAを作るために、WebComponentsを用いてFluxを実装するを参照してください。)

今回は、Reactや他のフレームワークを使わず、WebComponentsのCustomElementsだけでFluxのコンポーネントを実装する方法を書いていく。

(初めに、今回のサンプルで示すコードは全てGitHubで見れる。サンプルはサンプルページで見れる(polyfillを使ってないのでChromeが必要)。そして、今回のFlux実装は10分で実装するFluxを参考に書いているので、そちらも参照してもらいたい。)

さて、Fluxのコンポーネントの役割には、状態(state)が変更されたらViewを更新するというのがある。これをどのようにCustomElementsで行えるか。

CustomElementsにはattributeChangedCallbackというメソッドがある。これは、attributeが変更された時、それがobservedAttributesで監視しているattributeであった場合に呼ばれるメソッドだ。これを利用する方法もあるかもしれないが、attributeには文字列しか指定できないため、文字列以外の状態(state)の変更を受け取るには難しい。

ただ、この一連の流れは応用できそうだ。attributeが変更された時に、attributeChangedCallbackが呼ばれるなら、状態(state)が変更された時に、stateChangedCallbackというメソッドを呼ぶようにしたらどうだろうか。setStateでstateを変更して、observedStateで監視対象のstateを指定して、監視対象のstateが変更されたらstateChangedCallbackを呼ぶという流れだ。

import dispatcher from './dispatcher';
import action from './action';
import store from './store';

const template = document.createElement('template');
template.innerHTML = `
<span></span>
<button type="button">+</button>
`;

export default class Component extends HTMLElement {
	constructor() {
		super();

		const content = template.content.cloneNode(true);

		this.span = content.querySelector('span');
		const incrementButton = content.querySelectorAll('button')[0];

		incrementButton.addEventListener('click', (e) => {
			action.countUp();
		});

		this.appendChild(content);

		store.on('CHANGE', () => {
			this.setState({ count: store.count });
			//this.setState(store)と書いても問題ない;
		});
		
		//初期処理;
		this.setState({ count: store.count });
	}

	setState(state) {
		if(typeof state !== 'object' || state === null) return;
		
		if(typeof this.state !== 'object' || this.state === null) this.state = {};

		const oldState = this.state;
		const newState = this.state = Object.assign({}, this.state, state);

		if(Array.isArray(this.constructor.observedState) === false) return;
		if(typeof this.stateChangedCallback !== 'function') return;

		this.constructor.observedState.forEach((name) => {
			if(oldState[name] === newState[name]) return;
			this.stateChangedCallback(name, oldState[name], newState[name]);
		});
	}

	stateChangedCallback(name, oldValue, newValue) {
		switch(name) {
			case 'count':
				this.span.textContent = newValue;
				break;
		}
	}

	static get observedState() {
		return ['count'];
	}
}

customElements.define('x-component', Component);

このコードはstoreに変更があると、storeから必要なstateを取得して、setStateを実行している。setStateが実行されると監視対象のstate(この場合’count’)に変更がないか調べる。もし変更があれば、stateChangedCallbackを呼ぶようになっている。stateChangedCallbackで、変更のあったstateに応じてViewを更新する。

このように、コンポーネントに新しいライフサイクルを追加することによって、Fluxのコンポーネントの役割である「状態(state)が変更されたらViewを更新する」を実現できるようになる。

さて、このsetStateメソッドをコンポーネント毎に一々実装していられないので、モジュール化してみる。これをコンポーネントが必要に応じて呼び出すのがいいだろう。npmにもusestateという名で上げてみた。

const map = new WeakMap();
const privates = (object) => {
	if(! map.has(object)) {
		map.set(object, {});
	}
	return map.get(object);
};

function getState() { return privates(this).state; }

function setState(state) {
	if(typeof state !== 'object' || state === null) return;
	
	const that = privates(this);
	if(typeof that.state !== 'object' || that.state === null) that.state = {};
	const oldState = that.state;
	const newState = that.state = Object.assign({}, that.state, state);

	if(Array.isArray(this.constructor.observedState) === false) return;
	if(typeof this.stateChangedCallback !== 'function') return;

	this.constructor.observedState.forEach((name) => {
		if(oldState[name] === newState[name]) return;
		this.stateChangedCallback(name, oldState[name], newState[name]);
	});
}

export default function useState(target) {
	if(typeof target !== 'function') throw new TypeError();

	target.prototype.getState = getState;
	target.prototype.setState = setState;
	return target;
}

これを以下のように呼び出すと、コンポーネントはsetStateメソッドを使えるようになる。

import useState from './useState';
class Component extends HTMLElement { /* ... * /}

export default useState(Component)

また、現在ES Proposal stage2のDecoratorsを使って書くこともできる。(要babel)

import useState from './useState';
@useState
export default class Component extends HTMLElement { /* ... */}

さて、先程例示した、Component.jsは最終的に以下のようなコードになった。実際に先程のコードのように書いてしまうとメモリリークを起こしてしまう可能性があるためその点も含め若干変わっている

import dispatcher from './dispatcher';
import action from './action';
import store from './store';
import useState from './useState';

const template = document.createElement('template');
template.innerHTML = `
<span></span>
<button type="button">+</button>
`;

class Component extends HTMLElement {
	constructor() {
		super();

		this.handleStoreChange = this.handleStoreChange.bind(this);

		const content = template.content.cloneNode(true);
		this.span = content.querySelector('span');
		const incrementButton = content.querySelectorAll('button')[0];

		incrementButton.addEventListener('click', (e) => {
			action.countUp();
		});

		this.appendChild(content);
	}

	handleStoreChange() {
		this.setState({ count: store.count });
	}

	connectedCallback() {
		store.on('CHANGE', this.handleStoreChange);
		//初期処理
		this.handleStoreChange();
	}

	disconnectedCallback() {
		store.removeListener('CHANGE', this.handleStoreChange);
	}

	stateChangedCallback(name, oldValue, newValue) {
		switch(name) {
			case 'count':
				this.span.textContent = newValue;
				break;
		}
	}

	static get observedState() {
		return ['count'];
	}
}

customElements.define('x-component', Component);

export default useState(Component)

dispatcherやstoreやactionの実装も載せておく。 (eventsモジュールはNode.jsの標準モジュール。中身はEventEmitter)

import Emitter from 'events';

export default new Emitter();
import dispatcher from './dispatcher';

export default {
	countUp() {
		dispatcher.emit('countUp');
	},
}
import Emitter from 'events';
import dispatcher from './dispatcher';

class Store extends Emitter {
	constructor(dispatcher) {
		super();

		this.count = 0;
		dispatcher.on('countUp', {});
	}

	onCountUp() {
		this.count++;
		this.emit('CHANGE');
	}
}

export default new Store(dispatcher);
このコンポーネントを例えば以下のように描画してやれば、サンプルページ(要chrome)のようになる
import Component from './Component';

const component = new Component();
document.body.appendChild(component);

これで。Fluxの一連の流れをフレームワークを使わなくても実現できそうだ。これは、データーバインディングの方法を例示したに過ぎないからCustomElementsに限らず使える。ただ、今回の肝は、監視対象のattributeが変更されたらattributeChangedCallbackが呼ばれるというCustomElementsのライフサイクルに習って、監視対象のstateが変更されたらstateChangedCallbackを呼ぶというライフサイクルを追加したことにある。だから、CustomElementsとは親和性が高く、理解しやすいだろう。

しかし、上の方法はある種オレオレフレームワークだと思う方がいるかもしれない。もし、この方法が気に入らないのであれば、個別にsetterを用意して状態をまとめてではなく個別に監視することも出来るだろう。Componentが必要とするstateが多くなれば少し冗長な書き方になってしまうが、全く標準な方法で実装ができる。useStateを使ったComponentとの違いを見比べてほしい。

import dispatcher from './dispatcher';
import action from './action';
import store from './store';

const template = document.createElement('template');
template.innerHTML = `
<span></span>
<button type="button">+</button>
`;

class Component extends HTMLElement {
	constructor() {
		super();

		this.handleStoreChange = this.handleStoreChange.bind(this);

		const content = template.content.cloneNode(true);
		this.span = content.querySelector('span');
		const incrementButton = content.querySelectorAll('button')[0];

		incrementButton.addEventListener('click', (e) => {
			action.countUp();
		});

		this.appendChild(content);
	}

	set count(value) {
		if(value === this._count) return; //変更がないなら何もしない
		this._count = value;
		this.span.textContent = value;
	}

	handleStoreChange() {
		this.count = store.count;
	}

	connectedCallback() {
		store.on('CHANGE', this.handleStoreChange);
		//初期処理
		this.handleStoreChange();
	}

	disconnectedCallback() {
		store.removeListener('CHANGE', this.handleStoreChange);
	}
}

customElements.define('x-component', Component);

export default Component

これらの実装は、状態(state)を監視しているので、変更のあった部分だけDOM操作するのを助ける。そして、これは生DOMを操作して開発する時の方法である。だから、今流行りの仮想DOMと比べて面倒くささもあるだろう。例えば、状態(state)の差分を測るのは自分でやらなければならない。それにしても、フレームワークに振り回されず標準に近い方法で開発していくメリットは大きいと思う。ぜひ興味のある方はこの方法を試してほしい。

先程のusestatenpm i -S usestate でインストールできる。