「CustomElements」タグアーカイブ

WebComponentsでFlux

WebComponentsのCustomElementsを使うと新しいHTML要素を作れる。このCustomElementsはライフサイクルに反応するメソッドを用意していたりと中々高機能なコンポーネントだ。しかし、これだけでSPAなどのWebサイトを作るにはどうしたらいいだろうか。そこで今回は、Reactや他のフレームワークを使わず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 html = `
<span class="count"></span>
<button type="button">+</button>
`;

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

		const template = document.createElement('template');
		template.innerHTML = html;
		const content = template.content;

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

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

		store.on('CHANGE', () => {
			this.setState({ count: store.getCount() });
		});

		store.emit('CHANGE');
		this.appendChild(content);
	}

	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.count.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という名で上げてみた。

function 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]);
	});
}

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

	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 html = `
<span class="count"></span>
<button type="button">+</button>
`;

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

		const template = document.createElement('template');
		template.innerHTML = html;
		const content = template.content;

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

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

		store.on('CHANGE', () => {
			this.setState({ count: store.getCount() });
		});

		store.emit('CHANGE');
		this.appendChild(content);
	}

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

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

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

export default useState(Component)

dispatcherやstoreやactionの実装も載せておく。

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;
		this.test = 0;
		dispatcher.on('countUp', this.onCountUp.bind(this));
	}

	getCount() {
		return this.count;
	}

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

export default new Store(dispatcher);

(eventsモジュールはNode.jsの標準モジュール。中身はEventEmitter。)

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

ただ、フレームワークを使わずこの方法だけで開発するとなると少し面倒な部分も出てくるかもしれない。例えば、DOM操作でViewを更新するのは少し大変だ。特に要素を消したり追加したりする場合は工夫が必要だ。そういう時は、要素を消さずにdisplay: none;で対応する方法もあるだろう。それにしても、フレームワークに振り回されず標準に近い方法で開発していくメリットはあると思う。この方法自体がオレオレフレームワークと呼ばれるかもしれないが、ここまで薄ければ問題ないだろうと思う。ぜひ興味のある方はこの方法を試してもらいたい。

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

【WebComponents】CustomElements(v1)とbabelでトランスパイルしたコードを組み合わせる

CustomElements(v1)では下のコードのようにES6Classを使ってCustomElementsを作れるようになった。

class CustomElement extends HTMLElement {
	constructor() {
		super();
	}
}

しかし、ES5ClassでCustomElements(v1)は作れない。そのため、babelでES6ClassをES5Classに変換してしまうと、エラーが発生する。これは、native-shimというpolyfillを読み込ませることによって解決される。

(逆に、CustomElements(v1)に対応していないブラウザはCustomElementsのpolyfillを読み込ませればエラーは発生しない。)

しかし、このnative-shimはES6で書かれているため、IE11などの古いブラウザでは読み込み時にSyntaxErrorが発生してしまう。なんてこったい。babelを使う時というのは、新しいESの構文を使って書いたコードをIE11などの古いブラウザにも対応させる状況だというのに・・・

ということで、このnative-shimを読み込めるブラウザのみ読み込むという方法を取る。具体的にどうやるのかというと、native-shimのコードを全て文字列にして、try~catche句の中でeval()を使って実行させる。SyntaxErrorをcatchするパターンである。

native-shimを文字列にするにはminifyにかけて1行にしてやって、クォートで囲むのがいいだろう。(テンプレートストリングを使いたくなるが、古いブラウザはテンプレートストリングには対応していない。)

try {
	eval("(()=>{'use strict';if(!window.customElements)return;const NativeHTMLElement=window.HTMLElement;const nativeDefine=window.customElements.define;const nativeGet=window.customElements.get;const tagnameByConstructor=new Map();const constructorByTagname=new Map();let browserConstruction=!1;let userConstruction=!1;window.HTMLElement=function(){if(!browserConstruction){const tagname=tagnameByConstructor.get(this.constructor);const fakeClass=nativeGet.call(window.customElements,tagname);userConstruction=!0;const instance=new(fakeClass)();return instance}browserConstruction=!1};window.HTMLElement.prototype=NativeHTMLElement.prototype;window.customElements.define=(tagname,elementClass)=>{const elementProto=elementClass.prototype;const StandInElement=class extends NativeHTMLElement{constructor(){super();Object.setPrototypeOf(this,elementProto);if(!userConstruction){browserConstruction=!0;elementClass.call(this)}userConstruction=!1}};const standInProto=StandInElement.prototype;StandInElement.observedAttributes=elementClass.observedAttributes;standInProto.connectedCallback=elementProto.connectedCallback;standInProto.disconnectedCallback=elementProto.disconnectedCallback;standInProto.attributeChangedCallback=elementProto.attributeChangedCallback;standInProto.adoptedCallback=elementProto.adoptedCallback;tagnameByConstructor.set(elementClass,tagname);constructorByTagname.set(tagname,elementClass);nativeDefine.call(window.customElements,tagname,StandInElement)};window.customElements.get=(tagname)=>constructorByTagname.get(tagname)})()");
}catch(e) {}

これで、CustomElements(v1)に対応しているブラウザはnative-shimを読み込むので、ES5Classにトランスパイルされたコードが動くようになり、かつnative-shimを読み込めないIE11のようなブラウザも通常のCustomElementsのpolyfillを読み込ませることによってCustomElementsが使えるようになる。

なぜ、CustomElements(v1)がES5Classに対応していないかというのはたぶんCustomElements(v1)を実装しているブラウザはすでにES6Classも実装しているからだろう。

補足: こういうbabelのプラグインもあるらしい。試してないので何とも言えないが。 babel-plugin-transform-custom-element-classes