2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Web フレームワーク初心者が複数のフレームワークを触ってみて気づいたことまとめ

Last updated at Posted at 2024-10-31

これまで Web フレームワークをほぼ触ったことがなく、純粋に HTML + CSS + JavaScript だけを使って Web ページを書いてきた。だが最近になって React に触る機会があり、 React を触っていると、他のフレームワークはどうなっているんだろう、という興味が自ずと湧いてきた。
そこで、今時の CSR の Web フレームワークを少しでも理解するために、それぞれのフレームワークを使ってなるべく同じ動作をするような簡単な作品を作ることにした。
ここではこの作品を作る際に感じた、それぞれのフレームワークごとの特徴の違いをまとめる。

※ 作品についての解説記事ではありません。作品の公開や作品についての説明には手が回ってないので、やっていません。
※ Vue と Svelte の比較については、ここ最近の Vuefes でも語られているようです: Vue 3 と Svelte 5 のランタイムを比較する 〜技術を一段深く理解する〜

対象としたフレームワーク

以下のフレームワークを対象とした。

  • React
  • Preact
  • Solid.js
  • Vue.js (Vue 3)
  • Svelte (Svelte 5)
  • Lit
  • Angular (AngularJS ではない)

※ いずれも TypeScript を使用する。
※ また新しいフレームワークで作品を作り、特徴を調べたら、ここに書き足す可能性はある。

基本的なコンポーネントの組み立て方

どのフレームワークにあっても次の点は変わりない。

  • コンポーネント単位で構成する。
  • コンポーネントは次のような HTML 要素の記法により呼び出され、要素の属性によりパラメータやイベントが渡される。
     <my-compoment key1="value1" key2="value2" />
    
  • コンポーネント内の実装では JavaScript のロジックが実行された後、 HTML の記法によりレンダーされる内容が決まる。

そして、このコンポーネントの宣言の仕方はフレームワークによって異なる。そこでまず、それぞれのフレームワークのコンポーネントの作り方について説明する。

React, Preact, Solid.js

これら3つのフレームワークでは、1つのコンポーネントを関数によって記述する。属性の内容を引数で受け取り、コンポーネントに対応するレンダー内容を戻り値として返すようになっている。レンダー内容は JavaScript 中に HTML 記法を直接記述する JSX 拡張により記述できる。 TypeScript で JSX を使用するファイルの拡張子は .tsx である。

  • React, Preact の場合

     import { useState } from "react";
     // or
     import { useState } from "preact/hooks";
    
     interface Props {
     	label: string;
     	disabled: boolean;
     	onButtonClick: () => void;
     };
     const MyButton = (props: Props) => {
     	const [counter, setCounter] = useState<number>(0);
    
     	const handleClick = () => {
     		setCounter(counter+1);
     		props.onButtonClick();
     	};
    
     	return (
     		<button
     			type="button"
     			title={`${counter} times clicked`}
     			disabled={props.disabled}
     			onClick={handleClick}
     			style={{
     				color: "#007AFF",
     				backgroundColor: "transparent",
     				border: "none"
     			}}
     		>{props.label}</button>
     	);
     };
     export default MyButton;
    
  • Solid.js の場合

     import { createSignal } from "solid-js";
    
     interface Props {
     	label: () => string;
     	disabled: () => boolean;
     	onButtonClick: () => void;
     };
     const MyButton = (props: Props) => {
     	const [counter, setCounter] = createSignal<number>(0);
    
     	const handleClick = () => {
     		setCounter(counter+1);
     		props.onButtonClick();
     	};
    
     	return (
     		<button
     			type="button"
     			title={`${counter()} times clicked`}
     			disabled={props.disabled()}
     			onClick={handleClick}
     			style={{
     				color: "#007AFF",
     				backgroundColor: "transparent",
     				border: "none"
     			}}
     		>{props.label()}</button>
     	);
     };
     export default MyButton;
    

React や Preact では返された JSX から仮想 DOM が生成され、実際の DOM との差分を取り、差分だけを実際の DOM に反映させる機構をもつ。但し、 Preact の方が軽量化されている。一方で Solid.js では JSX が実際の DOM に直接反映される。

Vue.js, Svelte

これらのフレームワークでは、1つのコンポーネントを1つの専用ファイルで記述する。 (逆に言えば1つのファイルで複数のコンポーネントを定義することはできない) このファイルでは HTML のテンプレート、 CSS、 JavaScript の3つの領域に分かれており、ロジックとスタイルとレンダー内容を明確に分けることができる。引数の内容は JavaScript 内で規定する。コンポーネントを記述する専用ファイルの拡張子は .vue.svelte である。
Vue.js には Options API と Composition API があるが、ここでは Copmposition API のみに言及する。

  • Vue.js の場合

     <script setup lang="ts">
     import { ref } from "vue";
     const props = defineProps<{ label: string; disabled: boolean; }>();
     const emits = defineEmits<{ buttonClick: [] }>();
    
     const counter = ref<number>(0);
    
     const handleClick = () => {
     	counter.value++;
     	emits("buttonClick");
     };
     </script>
    
     <template>
     	<button
     		type="button"
     		:title="`${counter.value} times clicked`"
     		:disabled="props.disabled"
     		@click="handleClick"
     	>{{ props.label }}</button>
     </template>
    
     <style lang="css">
     button {
     	color: #007AFF;
     	background-color: transparent;
     	border: none;
     }
     </style>
    
  • Svelte の場合

     <script lang="ts">
     	import { createEventDispatcher } from "svelte";
     	const dispatch = createEventDispatch();
    
     	export let label: string;
     	export let disabled: boolean;
     	let counter: number = 0;
    
     	const handleClick = () => {
     		counter++;
     		dispatch("buttonClick");
     	};
     </script>
    
     <button
     	type="button"
     	title={`${counter} times clicked`}
     	disabled={disabled}
     	on:click={handleClick}
     >{label}</button>
    
     <style>
     button {
     	color: #007AFF;
     	background-color: transparent;
     	border: none;
     }
     </style>
    

Lit, Angular

これらのフレームワークでは、1つのコンポーネントを1つの JavaScript のクラス定義によって記述する。引数はクラスのメンバ変数として規定し、レンダー内容は文字列により記述される。
文字列で指定するためか、エディタによっては HTML や CSS の補完が効かないし、テンプレート中の要素にホバーしてもコンポーネントの情報を参照することができなかったりする。また、 TypeScript として引数の型チェックが作動しない。

  • Lit の場合

     import { LitElement, html, css } from "lit";
     import { customElement, property, property } from "lit/decorators.js";
    
     @customElement("my-button")
     export class MyButtonImpl extends LitElement {
     	@property({ attribute: "label" })
     	label!: string;
     	@property({ attribute: "disabled" })
     	disabled!: boolean;
    
     	private counter: number = 0;
    
     	private handleClick() {
     		this.counter++;
     		const event = new CustomEvent("buttonClick", { bubbles: false, composed: true });
     		this.dispatchEvent(event);
     	}
    
     	render() {
     		return html`
     			<button
     				type="button"
     				title="${this.counter} times clicked"
     				.disabled=${this.disabled}
     				@click=${this.handleClick}
     			>${this.label}</button>
     		`;
     	}
    
     	static styles = css`
     		button {
     			color: #007AFF;
     			background-color: transparent;
     			border: none;
     		}
     	`;
     }
     export default MyButtonImpl;
    

    html, css, svg などを文字列の前に付加することで、その文字列は HTML などとして解析される。

  • Angular の場合

     import { Component, Input, Output, EventEmitter } from "@angular/core";
    
     @Component({
     	selector: "my-button",
     	standalone: true,
     	template: `
     		<button
     			type="button"
     			[title]="counter + ' times clicked'"
     			[disabled]="disabled"
     			(click)="handleClick"
     		>{{ label }}</button>
     	`,
     	styles: `
     		button {
     			color: #007AFF;
     			background-color: transparent;
     			border: none;
     		}
     	`
     })
     export class MyButtonImpl {
     	@Input("label")
     	label!: string;
     	@Input("disabled")
     	disabled: boolean;
     	@Output("button-click")
     	buttonClick = new EventEmitter<undefined>();
    
     	protected counter: number = 0;
    
     	protected handleClick() {
     		counter++;
     		this.buttonClick.emit();
     	}
     }
     export default MyButtonImpl;
    
    • templatestyles の代わりに templateUrlstyleUrls を使うことで別の .html ファイルや .css ファイルに記述されている内容をテンプレートやスタイルとして使用することができる。 (むしろ昔はその方法しかなかったか)
    • 他所で使わない場合でもクラス定義に export を付けるのは必須である。クラスの変数やメソッドで private を付加するのも認められておらず、せめて protected にしなければならない。

特徴的な違い

以下では、自分が気付いた範囲でコンポーネントごとに特異な違いが生まれる箇所を個別に説明していく。

コンポーネントの参照の仕方

React, Preact, Solid.js

これらのコンポーネントは関数であり、関数名を要素名とした HTML タグにより参照することができる。

// MyButton.tsx
const MyButton: FC<Props> = (props) => { ... };
export default MyButton;

// App.tsx
import MyButton from "./MyButton.tsx";

const MyButton2: FC<Props> = (props) => { ... };

const App: FC = () => {
	return (
		<MyButton label="Click Me" />
		<MyButton2 label="Click Me" />
	);
};

故に、要素名には JavaScript の変数名として利用可能な PascalCase や camelCase、 snake_case を利用することができる。おそらく PascalCase が推奨されている。

Vue, Svelte

Vue, Svelte でもコンポーネントファイルからインポートすることを踏まえると、変数名と同じルールで PascalCase、 camelCase、 snake_case のいずれかである。

<script setup lang="ts">
import MyButton from "./MyButton.vue";
</script>

<template>
	<MyButton label="Click Me" />
</template>
<script lang="ts">
import MyButton from "./MyButton.svelte";
</script>

<MyButton label="Click Me" />

Angular

Angular ではクラスに対する @Component デコレータにおいて文字列を使って要素名を指定するために、 kebab-case でも snake_case でも PascalCase でも camelCase でも利用可能である。おそらく kebab-case が推奨されている。
別のコンポーネントを使用するには imports でクラスを与えなければ認識されない。

// MyButton.ts
@Component({
	selector: "my-button",
	standalone: true,
	template: ``
})
export class MyButton { ... }
export default MyButton;

// App.ts
import MyButton from "./MyButton.ts";

@Component({
	selector: "app",
	standalone: true,
	imports: [ MyButton, MyButton2 ]
	template: `
		<my-button label="Click Me" />
		<my-button-2 label="Click Me" />
	`
})
export class App { ... }

Lit

Lit は一番ややこしい。 Angular と同様に @customElement デコレータ内で要素名を文字列で指定できるのたが、次の制約を受ける。

  • 必ず kebab-case にしなければならない。
  • HTML 標準の要素と区別するために、必ずハイフンを使って2語以上を繋いだような要素名にしなければならない。
    * つまり、コンポーネント名として app は許されず、必ず app-view などとしなければならない。

また、 Angular では imports にクラスを指定したコンポーネントのみを使用することができたが、 Lit では imports のような機構がないため、特に指定しなくてもあらゆるカスタムコンポーネントを使用することができそうである。言い方を変えれば...

  • import "./MyButton.ts"; のような JavaScript の import 文でコンポーネントが確実に1回は読み込まれていたら、どこにあっても使用できそう。
  • プロジェクト全体で要素名が衝突するようなことがあってはならない。
  • Angular と同様に要素の属性は TypeScript による型チェックが行われないが、 Lit ではさらに、指定した要素名のコンポーネントの存在までもチェックされない。

さらにややこしいことに、 Lit のテンプレート中では input 要素や img 要素のように「内側に内容を持たない要素」であっても、 <input /> のように記載することはできず、必ず <input></input> などのように記載しなければならない。もちろんこれは内側に内容を持たないカスタムコンポーネントに対しても同様である。

// MyButton.ts
@customElement("my-button")
export class MyButton extends LitElement { ... }
export default MyButton;

// App.ts
import MyButton from "./MyButton.ts"; // どこかで import していればそれで十分

@customElement("app-view")
export class App extends LitElement {
	render() {
		return html`
			<my-button label="Click Me"></my-button>
		`;
	}
}

コンポーネント・ノードの型

コンポーネントを変数として扱ったりする場合もあるので、コンポーネントを表す型が用意されていた方が良い。

React, Preact, Solid.js

React, Preact, Solid.js では、関数がコンポーネントを構成することで共通しており、この関数を表すための型が用意されている。

React FC<P> from "react"
Preact Preact.FunctionalComponent<P> from "preact"
Solid.js Component<P> from "solid-js"
  • 型パラメータ P には受け取れる属性に該当するオブジェクトの型を指定する。
  • 基本的には (props: P) -> [JSX の型] のエイリアスである。特に Solid.js は他の引数などもなく、純粋にこの形の関数として型定義されている。

JSX 記法で生成される、単一の要素を表す型として、次のものがそれぞれ用意されている。

React JSX.Element from "react"
Preact JSX.Element from "preact"
Solid.js JSX.Element from "solid-js"

これらは関数コンポーネントの型として FC などを使わずに自作する必要がある場合に、返値の型として使用することができる。

  • React, Preact には JSX.Element の他にそれぞれ ReactNodeComponentChildren が用意されている。これらは JSX.Element の他に stringnumber, undefined などの論理和型になったものである。後述する子要素を扱う際の型として利用することができる。
  • React, Preact では同じ JSX.Element を何回でも使い回すことができるが、 Solid.js では使い回すことができない。複数の箇所で同じ JSX を使いたければ、同じ内容の JSX を生成するように工夫しなければならない。

Vue.js, Svelte

Vue.js や Svelte では、おそらく .vue ファイルや .svelte ファイルで定義されたコンポーネントを包括的に扱う型は存在する。それぞれ DefineComponentSvelteComponent である。

// Vue.js
import type { DefineComponent } from "vue";
// Svelte
import type { SvelteComponent } from "svelte";

これらの型は型パラメータを持ち、コンポーネントごとに特有の型が当てはまると考えられるが、 React の FC 型のように単一の型パラメータだけ渡せば使えるという簡単なものではなさそうだ。ゆえにこれらの型を直接扱うというのは難しそうだ。

一方で、ファイルからインポートしたコンポーネント自体の型を取るのは簡単である。 TypeScript 組み込みの InstanceType 型と、 typeof キーワードを使って取り出せる。

import Buttons from "./Buttons.vue";
// or
import Buttons from "./Buttons.svelte";

type ButtonsType = InstanceType<typeof Buttons>;

インポートされる Buttons コンポーネントがどのような JavaScript のデータ構造なのかよく分からないが、 InstanceType を挟むことで型が得られるということから、 Buttons はクラス定義であり、そのインスタンスの型を取ったと考えられる。

あるコンポーネントに対し、取りうる全ての属性を1つにまとめたオブジェクトの型は、上記で取り出した型を使って次のように得られる。

// Vue.js
type ButtonsProps = ButtonsType["$props"];
// Svelte
import { CompomnentProps } from "svelte";
type ButtonsProps = ComponentProps<ButtonsType>;

但し、 Vue.js の場合にこの方法で型を得ると、実際の型以上に結構複雑な型が返ってくるので、あまり使い勝手は良くないかもしれない。

React.FC<Props> のように、特定の形式の属性を持った任意のコンポーネントを受け取るための型を定義することはできる。
また、こうして変数として受け取ったコンポーネントをテンプレート中で使用するための方法も存在する。
以下には特定の属性形式を持ったコンポーネントを属性として受け取り、それをそのまま表示するだけのコンポーネントの実装例を示している。

  • Vue.js の場合

     <!-- Wrapper.vue -->
     <script setup lang="ts">
     import type { DefineComponent, Component } from "vue";
    
     // 対象コンポーネントの属性の形式
     type ButtonProps = {
     	label: string;
     	disabled?: boolean;
     };
     // 対象コンポーネントの型
     type ButtonComp = DefineComponent<ButtonProps>;
    
     // このコンポーネントの属性の形式
     // 対象コンポーネントにそのまま渡す
     type Props = {
     	buttonComp: ButtonComp;
     } & BBProps & Component;
     </script>
    
     <template>
     	<component :is="props.buttonComp" :="props" />
     </template>
    
     <!-- Parent.vue -->
     <script setup lang="ts">
     import Button from "./Button.vue";
     import Wrapper from "./Wrapper.vue";
     </script>
    
     <template>
     	<Wrapper :buttonComp="Button" label="Click Me" :disabled="false" />
     </template>
    
  • Svelte

     <!-- Wrapper.svelte -->
     <script lang="ts">
     	import type { SvelteComponent } from "svelte";
    
     	// 対象コンポーネントの属性の形式
     	type ButtonProps = {
     		label: string;
     		disabled?: boolean;
     	};
     	// 対象コンポーネントの型
     	type ButtonComp = $$Generic<typeof SvelteComponent<ButtonProps>>;
     	
     	// このコンポーネントの属性
     	export let buttonComp: ButtonComp;
     	// 対象コンポーネントにそのまま渡す属性
     	export let props: ButtonProps;
     </script>
     
     <svelte:component this={buttonComp} {...props} />
    
     <!-- parent.svelte -->
     <script lang="ts">
     	import Button from "./Button.svelte";
     	import Wrapper from "./Wrapper.svelte";
     </script>
    
     <!-- `props` の中身を直接属性として表す方法はなさそう -->
     <Wrapper buttonComp={Button} props={{ label: "Click Me", disabled: false }} />
    

Vue.js (仮想DOM)

一方で Vue.js には .vue で定義されるコンポーネントとは別に、 React と同様の仮想 DOM の機構が用意されており、値として扱うことができる。
また、 JSX でも表現可能。

<script setup lang="tsx">
import { h, type VNode, ref } from "vue";

const vNode1: VNode = h("div", { "id": "vnode-div-1"}, "Hello from VNode");
const vNode2: VNode = <div id="vnode-div-2">Hello from VNode</div>;

const vNode1Ref = ref<VNode>(vNode1);
const vNode2Ref = ref<VNode>(vNode2);
</script>

<template>
	<!-- 仮想DOMをテンプレート中に埋め込む -->
	<component :is="vNode1Ref" />
	<component :is="vNode2Ref" />
</template>

上記の例のように、 refVNode を与えることで、テンプレート中に埋め込んだり、後で仮想DOMの内容を書き換えたりすることができる。

Lit

Lit では LitElement が全てのコンポーネントの親クラスになっている。これは DOM の HTMLElement 型の拡張となっているため、クラス定義から addEventListenerclassName などといったお馴染みの名前のメソッドやプロパティを使用することができる。

文字列の先頭に (関数である) html, css, svg を付加することで HTML などとして解析されるということであったが、 html などを付加した文字列は専用の型で扱われる。

関数 返値の型
html TemplateResult<1>
svg TemplateResult<2>
css CSSResult

svg<svg></svg> の内部のデータを扱うのに特化した型で、 <svg></svg> 要素も含む全体を扱うのであれば通常通り html を使用した方が良さそうである。

これらは最初に示したコード例のように render() メソッドや styles プロパティで返したり与えたりするのみならず、 React の JSX.Element のようにコード断片の値として扱うことができ、別の html を付加した文字列中に埋め込むこともできる。

その他

Angular ではおそらくコンポーネントを包括的に扱う型は用意されていないし、 React の JSX.Element のように要素のツリーを扱うための型もおそらく用意されていない。
但し、 Vue.js では JSX が使用可能なので、それについては単独で扱うことは可能だろう。

子要素へのアクセスの仕方

<div> <input /> </div> のように、 div 要素は内側にコンテンツを含めることができ、カスタムコンポーネントであっても内部にコンテンツを含めることができる。

React, Preact

React, Preact では関数コンポーネントの引数で children フィールドを加えることにより受け取れる。
children フィールドは、 React では reactNode 、 Preact では ChildComponent にしておく。

import { ReactNode } from "react";

interface Props {
	children: ReactNode;
};
const MyBox: FC<Props> = (props) => {
	return (
		<div id="box">
			{props.children}
		</div>
	);
};

const App: FC = () => {
	return (
		<MyBox>
			<input type="button" value="Click Me" />
		</MyBox>
	);
};

Solid.js

Solid.js は React, Preact と似ているが微妙に異なる。
引数で children フィールドを使って受け取り、 children 関数を通す。

import { JSX, children } from "solid-js";

interface Props {
	children: JSX.Element;
};
const MyBox: FC<Props> = (props) => {
	const getChildren = children(() => props.children);

	return (
		<div id="box">
			{getChildren()}
		</div>
	);
};

Vue.js, Svelte

Vue.js や Svelte ではテンプレート中で <slot /> を記載することにより、その部分に展開される。

<script setup lang="ts">
</script>

<template>
	<div id="box">
		<slot />
	</div>
</template>
<script lang="ts">
</script>

<div id="box">
	<slot />
</div>

公式ドキュメントには複数個の slot を設定する場合も説明されているが、自分の中では使い道がなかったので調べていない。

Angular

Angular では <ng-content /> と記載すると展開される。

@Component({
	selector: "my-box",
	standalone: true,
	template: `
		<div id="box">
			<ng-content />
		</div>
	`
})
export class MyBox {}

Lit

Lit では <slot></slot> と記載すると展開される。

@customElement("my-box")
export class MyBox extends LitElement {
	render() {
		return html`
			<div id="box">
				<slot></slot>
			</div>
		`;
	}
}

しかし、後述する Shadow DOM を使わない場合では ` の箇所に展開されじ、代わりにテンプレートの先頭に展開されてしまうことが知られている。なので、代わりの方法を用意しなくてはならない。例えば次のように属性として与えるという方法でもさほど苦しいものにはならない。

@customElement("my-box")
export class MyBox extends LitElement {
	@property()
	private inner!: TemplateResult;

	createRenderRoot() { return this; }
	render() {
		return html`
			<div id="box">
				${this.inner}
			</div>
		`;
	}
}

@customElement("app-view")
export class App extends LitElement {
	render() {
		return html`
			<my-box .inner=${html`
				<input type="button" value="Click Me"></input>
			`}></my-box>
		`;
	}
}

状態変数の定義の仕方

状態変数は、コンポーネント内部で保持される変数であり、この値が変化すると再度コンポーネントがレンダリングされる。子コンポーネントに渡すこともできる。

React, Preact

React, Preact では useState 関数により状態変数やそのセッターを用意することができる。

import { useState } from "react";
// または
import { useState } from "preact/hooks";

const [counter, setCounter] = useState<number>(0);

上記の number 型の状態変数であれば、 counternumber 型の値そのもの、 setCounter がセッターとなっており、引数として新しい値を渡すか、新しい値を返す関数を与える。

setCounter(1);
// 或いは
setCounter( (previousValue) => previousValue + 1 );
セッター関数の型
React Dispatch<SetStateAction<T>> from "react"
Preact Dispatch<StateUpdater<T>> from "preact/hooks"

ここで現れる型は具体的には次のように定義されている。

Dispatch<T> = (value: T) => void
SetStateAction<T>
StateUpdater<T>
= T | ((prevValue: T) => T)

つまり、セッターを実行する際には新しい値か、或いは前の値を受け取って新しい値を返す関数を渡す。

Solid.js

React, Preact に似ているが、実際の値の代わりにゲッター関数が返される。

import { createSignal } from "solid-js";

const [getCounter, setCounter] = createSignal<number>(0);
// 値を読み出す時は
getCounter();
// 値を書き込む時は
setCounter(1);
setCounter((previousValue) => previousValue + 1);

Vue

ref 関数により与えられるオブジェクトを介して管理できる。

import { ref } from "vue";

const counter = ref<number>(0);
// 値を読み出す時は
counter.value;
// 値を書き込む時は
counter.value = 1;

Svelte

単純に let を使って変数宣言するだけで状態変数として扱われる。

let counter: number = 0;

状態変数としては他に svelte/store で定義されている Writable, Readable を使用する方法もある。これは Svelte に用意されている、コンポーネント間で状態変数を簡単に共有できるようにする仕組みである。2者の違いは他所から状態変数に書き込みできるか否かである。

<script lang="ts">
	import { type Writable, type Readable, readable, writable } from "svelte/store";

	// 変数の定義
	const counterW: Writable<number> = writable(0);
	const counterR: Readable<number> = readable(0);

	// writable は `set` や `update` を使って値を設定することができる。
	counterW.set(1); // 新しい値の指定
	counterW.update((oldVal) => oldVal + 1); // 現在の値から新しい値を生成

	// readable も writable も subscribe を使って値を読み出すことができる
	// 値の更新時に関数が実行されて返される
	counterR.subscribe((newVal) => {
		console.log(newVal);
	});
	let currentValue: number;
	counterW.subscribe((newVal) => {
		currentVal = newVal;
	});

	// 或いは簡単に get で値を取得することもできる (内部的には subscribe を使ってる?)
	const doubledCounter = () => counterR.get() * 2;
</script>

<!-- テンプレート中からは get の代わりに $ を使って取り出すこともできる -->
<div>The current value is: {$counterW}</div>

readable でも writable でも定義時に第2引数でコールバック関数を指定できる。これは初めて subscribe が呼び出された際のみに実行される関数で、渡されたセッター関数を使って値を書き換えることができる。というか readable の値を書き換える唯一の方法っぽい。

const counterR: Readable<number> = readable<number>(0, (setCount) => {
	setInterval(() => {
		setCount(counterR.get() + 1);
	}, 1000);
});

Angular

クラスのインスタンス変数として定義すれば状態変数となる。

@Component({ ... })
export class MyButton {
	protected counter: number = 0;
}

Lit

@state() デコレータを付したインスタンス定数として定義すれば状態変数になる。

@customElement("my-button")
export class MyButton extends LitElement {
	@state()
	private counter: number = 0;
}

テンプレート中での値の埋め込み方

React, Preact, Solid.js, Svelte

React, Preact, Solid.js はどれも JSX で表記するため同じである。
Svelte は JSX と大体同じであるため、ここでまとめて扱う。

JSX や Svelte テンプレート中では属性を HTML と同じように記述することができ、 string 型の値として渡される。

<button title="Click here to run the action">Click Me</button>

変数や式を与える場合は {} で記載する。
string 以外の型の値を与えることもできる。

const [isInactive, setIsInactive] = useState<boolean>(false);
return (
	<button
		disabled={isInactive}
		title={`The button${isInactive ? " (inactive)" : ""}`}
	>Click Me</button>
);

boolean | undefined 型の属性であれば、属性指定の有無によって値を決定することもできる。

JSX でイベントハンドラは onClick のような名前の属性になっている。

const handleClick = () => { ... };
return (
	<button onClick={handleClick}>
		Click Me
	</button>
);

Svelte の場合、 on:click のような名前の属性になっている。

<script lang="ts">
	const handleClick = () => { ... };
</script>
<button on:click={handleClick}>
	Click Me
</button>

オブジェクトで属性のキーと値を定義して、まとめて渡すことができる。

const [isInactive, setIsInactive] = useState<boolean>(false);

const buttonProps = {
	disabled: isInactive,
	title: `The button${isInactive ? " (inactive)" : ""}`,
}
return <button {...buttonProps}>Click Me</button>;

属性だけでなく、テキストや HTML 自体も構築できる。
JSX 中や Svelte のテンプレート中では ${} ではなく単純に {} で埋め込むことができる。

const label = "Click Me";
return (
	<button title="Click here to run the action">
		{label}
	</button>
);

Vue.js

テンプレート中で HTML と同じように記述すると string 型の値として渡される。

<template>
	<button title="Click here to run the action">Click Me</button>
</template>

変数や式などを扱う場合は属性名の先頭に : を付ける。
string 以外の型の値を与えることもできる。

<script setup lang="ts">
const isInactive = ref<boolean>(false);
</script>
<template>
	<button
		:disabled="isInactive.value"
		:title="`The button${isInactive.value ? " (inactive)" : ""}`"
	>Click Me</button>
</template>

イベントハンドラは @click のような表記になる。

<script setup lang="ts">
	const handleClick = () => { ... };
</script>
<template>
	<button @click="handleClick">
		Click Me
	</button>
</template>

オブジェクトで属性のキーと値を定義して、まとめて渡すことができる。

<script setup lang="ts">
const isInactive = ref<boolean>(false);
const buttonProps = computed(() => (
	{
		disabled: isInactive.value,
		title: `The button${isInactive.value ? " (inactive)" : ""}`,
	}
));
</script>
<template>
	<button :="buttonProps.value">Click Me</button>
</template>

属性だけでなく、テキストや HTML 自体も構築できる。
Vue のテンプレート中では ${} ではなく {{}} で埋め込むことができる。

<script setup lang="ts">
const label = "Click Me";
</script>
<template>
	<button title="Click here to run the action">
		{{ label }}
	</button>
</template>

Angular

テンプレート中で HTML と同じように記述すると string 型の値として渡される。

@Component({
	...
	template: `
		<button title="Click here to run the action">
			Click Me
		</button>
	`
})
export class App { ... }

変数や式などを扱う場合は属性名を [] で囲む。
string 以外の型の値を与えることもできる。
this. を付ける必要はない。

@Component({
	...
	template: `
		<button
			[disabled]="isInactive"
			[title]="titleText"
		>Click Me</button>
	`
})
export class App {
	protected isInactive: boolean = false;
	protected get titleText(): string {
		return `The button${this.isInactive ? " (inactive)" : ""}`;
	}
}

イベントハンドラは () で囲んで (click) のような表記になる。
第1引数が $event 変数で与えられる。

@Component({
	...
	template: `
		<button (click)="handleClick($event)">
			Click Me
		</button>
	`
})
export class App {
	protected handleClick() { ... }
}

属性だけでなく、テキストや HTML 自体も構築できる。

@Compoment({
	...
	template: `
		<button title="Click here to run the action">
			{{ label }}
		</button>
	`
})
export class App {
	protected label = "Click Me";
}

Lit

テンプレート中で HTML と同じように記述すると string 型の値として渡される。

@customElement("app-view")
export class AppView extends LitElement {
	render() {
		html`
			<button title="Click here to run the action">
				Click Me
			</button>
		`;
	}
}

変数や式などを扱う場合は通常の JavaScript と同じように ${} の記法を使う。
string 以外の型の値を与えることもできる。値をタブルクォートで囲むか否かは自由である。
通常は HTML の属性として値が与えられるが、 JavaScript のプロパティとして与える (= string 以外の値を与えることもできる) には属性名の先頭に . (ドット) を付ける。

@customElement("app-view")
export class AppView extends LitElement {
	@state()
	private isInactive: boolean = false;
	private get titleText(): string {
		return `The button${this.isInactive ? " (inactive)" : ""}`;
	}
	render() {
		return html`
			<button
				title="${this.title}"
				.disabled=${this.isInactive}
			>Click Me</button>
		`;
	}
}

イベントハンドラは @click のような表記になる。

@customElement("app-view")
export class AppView extends LitElement {
	private handleClick() { ... }
	render() {
		return html`
			<button @click=${this.handleClick}>
				Click Me
			</button>
		`;
	}
}

属性だけでなく、テキストや HTML 自体も構築できる。

@customElement("app-view")
export class AppView extends LitElement {
	private label = "Click Me";
	render() {
		return html`
			<button title="Click here to run the action">
				${this.label}
			</button>
		`;
	}
}

親子コンポーネント間でデータの受け渡しを行う方法

React, Preact, Solid.js

親子間のデータのやり取りは、子コンポーネントの Props の定義を介して行われる。
親→子では値をフィールドに含めて渡す。 useState / createSignal を介して値を用意して子に渡すことで、値の変化時には適切に再描画が実行されるだろう。
子→親では、 useState / createSignal のセッターや、親側で用意した関数を子に渡し、子がこれら関数を実行することで親に渡すことができ、適切に再描画がされたりするだろう。

type Props = {
	count: number;
	setCount: React.Dispatch<React.SetStateAction<number>>;
};
export const MyButtons: FC<Props> = (props) => {
	const increment = () => props.setCount(props.count+1);
	const reset = () => props.setCount(0);
	return (
		<button onClick={increment}>
			Counter: {props.count}
		</button>
		<button onClick={reset}>Reset</button>
	);
};

export const App: FC = () => {
	const [count, setCount] = useState<number>(0);
	return <MyButtons count={count} setCount={setCount} />
};

Vue.js

親→子の受け渡しは、子で defineProps を使って用意された props を介して行われる。 props の型指定により受け取る値を定義できる。
子→親では defineEmits で生成された emit でイベントを発火させて、イベントハンドラで親の関数が実行されることにより、親にデータが渡る。 React のように props で関数を渡しても機能しないはず。

<!-- 子のコンポーネント (MyButtons.vue) -->
<script setup lang="ts">
type Props = {
	count: number;
};
const props = defineProps<Props>();

type Emits = {
	setCount: [number];
};
const emits = defineEmits<Emits>();

const increment = () => emits("setCount", props.count + 1);
const reset = () => emits("setCount", 0);
</script>

<template>
	<button @click="increment">
		Counter: {{ props.count }}
	</button>
	<button @click="reset">Reset</button>
</template>

<!-- 親のコンポーネント (App.vue) -->
<script setup lang="ts">
import { ref } from "vue";
import MyButtons from "./MyButtons.vue";

const count = ref<number>(0);
const setCount = (newVal: number) => count.value = newVal;
</script>

<template>
	<MyButtons
		:count="count.value"
		@setCount="setCount"
	/>
</template>

defineProps に使用する Props 型の各フィールドは JavaScript の規約に合わせて camelCase になっているだろうが、対応する親コンポーネントでの属性は Vue.js により kebab-case で扱われることに注意する。つまり、 props で myAttr というフィールドを用意したら、属性では my-attr でアクセスできるようになる。

Vue.js (双方向バインディング)

上記の例のように、同じ変数に対して親子間双方でやりとりしている場合、両方向のやり取りをひとまとめにして扱うことができる。
親では ref を使って読み書き可能な参照を用意しているが、 defineModel を使って同様のインターフェースで子でも読み書きができるようになっている。
refRef<T> 型、 defineModelModelRef<T> 型を返す。

<!-- 子のコンポーネント (MyButton.vue) -->
<script setup lang="ts">
// `defineProps` と `defineEmits` はまとめて `defineModel` で扱えるようになる。
// 一方で、 `defineProps` と `defineEmits` は全ての変数とイベントをまとめて扱えたが、 `defineModel` は1つ1つの変数に対して用意する必要がある。
const count = defineModel<number>("count");

const increment = () => count.value += 1;
const reset = () => count.value = 0;
</script>

<template>
	<button @click="increment">
		Counter: {{ count.value }}
	</button>
	<button @click="reset">Reset</button>
</template>

<!-- 親のコンポーネント (App.vue) -->
<script setup lang="ts">
import { ref } from "vue";
import MyButton from "./MyButton.vue";

const count = ref<number>(0);
</script>

<template>
	<!-- `count` の参照を直に渡すだけで済む -->
	<MyButtons :count="count" />
</template>

defineModel を使うにあたっての注意点が2つある。
*defineModel の引数で指定される、属性名に対応する文字列は camelCase で記載しないと認識されない。そして、親での属性指定は kebab-case を使う。
* 文字列で与えるんだし、親の属性指定は kebab-case なんだから、 defineModel にも kebab-case で与えたくなるが、動作しない。

  • defineModel に指定する型はおそらくプリミティブ型でなければならない。

    • どうやら defineModel は型バリテーションを行っているようで、指定した型情報を参照しているらしい。プリミティブでない型を使用すると defineModel が機能しない。
    • 例えば type RGB = "Red" | "Green" | "Blue"; のような列挙型を扱いたい場合は、次のように一旦プリミティブな string 型を通してからキャストして扱うようにした方が良さそうだ。
     const colorChannel = defineModel<string>("colorChannel") as ModelRef<RGB>;
    

Ref<T>ModelRef<T> は、言わばゲッターとセッターを兼ね備えたものだと考えられるが、ゲッターとセッターを分離したり、逆にゲッターとセッターから Ref<T> を生成することもできる。これにより、単に値を横流しするだけでなく、何か処理を間に挟む、といったこともできる。

import { ref, computed } from "vue";

// ゲッター + セッター
const countRef = ref<number>(0);

// ゲッターとセッターに分離
const getCount = () => countRef.value;
const setCount = (newVal: number) => { countRef.value = newVal; };

// ゲッターとセッターを合わせた `Ref` を作成する
const countRef2 = computed({
	get: getCount, set: setCount
});

Svelte

親→子の受け渡しは、子において export が付加された変数を介して行われる。
子→親では、 createEventDispatcher で生成された dispatch 関数からイベントを発火させて、イベントハンドラで親の関数が実行されることにより、親にデータが渡る。 React のように関数を渡しても機能しないはず。

<!-- 子のコンポーネント (MyButtons.svelte) -->
<script lang="ts">
	import { createEventDispatcher } from "svelte";
	const dispatch = createEventDispatcher();

	export let count: number;

	const increment = () => dispatch("setCount", count + 1);
	const reset = () => dispatch("setCount", 0);
</script>

<button on:click="increment">
	Counter: {count}
</button>
<button on:click="reset">Reset</button>

<!-- 親のコンポーネント (App.svelte) -->
<script lang="ts">
	import { MyButtons } from "./MyButtons.svelte";
	
	let count: number = 0;
	const setCount = (newVal: number) => count = newVal;
</script>

<MyButtons
	count={count}
	on:setCount={setCount}
/>

Svelte (双方向バインディング)

Vue.js と同様に親子間の双方向にやり取りできる方法がある。親での属性指定で bind: を付けることで実現する。

<!-- 子のコンポーネント (MyButtons.svelte) -->
<script lang="ts">
	export let count: number;

	const increment = () => { count += 1; };
	const reset = () => { count = 0; };
</script>

<button on:click="increment">
	Counter: {count}
</button>
<button on:click="reset">Reset</button>

<!-- 親のコンポーネント (App.svelte) -->
<script lang="ts">
	import { MyButtons } from "./MyButtons.svelte";

	let count: number = 0;
</script>

<MyButtons bind:count={count} />

しかし、 Vue.js と違って、既存のゲッター、セッターをまとめる方法はない。

Svelte (writable を使う方法)

svelte/storewritable をコンポーネント間で渡せば、コンポーネント間で双方向にやり取りはできそうである。

<!-- 子のコンポーネント (MyButtons.svelte) -->
<script lang="ts">
	import type { Writable } from "svelte/store";

	export let count: Writable<number>;

	const increment = () => { count.update(($count) => $count + 1); };
	const reset = () => { count.set(0); };
</script>

<button on:click="increment">
	Counter: {count}
</button>
<button on:click="reset">Reset</button>

<!-- 親のコンポーネント (App.svelte) -->
<script lang="ts">
	import { writable } from "svelte/store";
	import { MyButtons } from "./MyButtons.svelte";

	let count = writable<number>(0);
</script>

<MyButtons count={count} />

Angular

親→子の受け渡しは、子のクラス中で @Input デコレータが付加されたインスタンス変数を介して行われる。
子→親では、 EventEmitter をインスタンス変数で保持し、そこに @Output デコレータを付加し、EventEmitter 関数からイベントを発火させて、イベントハンドラで親の関数が実行されることにより、親にデータが渡る。
@Input, @Output デコレータの引数には親からアクセスするときの属性としての名称を指定する。

import { Component, Input, Output, EventEmitter } from "@angular/core";

@Component({
	selector: "my-buttons",
	standalone: true,
	template: `
		<button (click)="increment">
			Counter: {{ count }}
		</button>
		<button (click)="reset">Reset</button>
	`
})
export class MyButtons {
	@Input("count")
	protected count!: number;

	@Output("set-count")
	protected setCountEmitter = new EventEmitter<number>();
	protected setCount(newVal: number) {
		this.setCountEmitter.emit(newVal);
	}

	protected increment() {
		this.setCount(this.count + 1);
	}
	protected reset() {
		this.setCount(0);
	}
}

@Component({
	selector: "app",
	standalone: true,
	imports: [ MyButtons ]
	// 第1引数は `$event` 変数で与えられる。たとえ `number` 型であっても。
	template: `
		<my-buttons [count]="count" (set-count)="count = $event">
	`
})
export class App {
	protected count: number = 0;
}

Lit

親→子の受け渡しは、子のクラス中で @property デコレータが付加されたインスタンス変数を介して行われる。
子→親では、 Lit コンポーネントが HTMLElement クラスを継承していることを利用して、イベントを作成し。 dispatchEvent メソッドでイベントを発火させ、イベントハンドラで親の関数が実行されることにより、親にデータが渡る。

import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";

@customElement("my-buttons")
class MyButtons extends LitElement {
	@property()
	count!: number;

	private setCount(newVal: number) {
		const event = new CustomEvent("set-count", {
			detail: newVal,
			bubbles: false, composed: true
		});
		this.dispatchEvent(event);
	}

	private increment() { this.setCount(this.count+1); }
	private reset() { this.setCount(0); }

	render() {
		return html`
			<button @click=${this.increment}>
				Counter: ${this.count}
			</button>
			<button @click=${this.reset}>Reset</button>
		`;
	}
}

@customElement("app-view")
class AppView extends LitElement {
	@state()
	private count: number = 0;

	// 引数は `number` 型そのものではなく Event の形式になっていることに注意
	private countReceiver(event: { detail: number }) {
		this.count = event.detail;
	}

	render() {
		return html`
			<my-buttons
				.count=${this.count}
				@setCount=${this.countReceiver}
			></my-buttons>
		`;
	}
}

コンポーネントの再レンダリング時の挙動

React, Preact

コンポーネント関数は useState で与えられた状態変数が変化する度に実行される。実行する度に得られる JSX から仮想 DOM を構成して差分を実際の DOM に反映させている。

一部の状態変数が変化した場合にのみ作用する処理を記述するには useEffect を使用する。第2引数には変化を監視する対象となる変数のリストを与える。

import { useState, useEffect } from "react";
// or
import { useState, useEffect } from "preact/hooks";

const [selected, setSelected] = useState<1|2|3>(1);

useEffect(() => {
	const previousTitle = document.title;
	document.title = `item ${selected} is selected`;
	console.log(`selected: ${selected}`);

	return () => { document.title = previousTitle; };
}, [selected]);

React コンポーネントは状態変数が同じであればレンダーされる DOM は同じになる「純粋関数」であることが期待されており、状態変数の変化に伴うレンダー以外の変化を記述するために useEffect 関数を使うこととされている。上記の例のように document.title の変更がまさにそれである。
純粋性を保つために監視対象の変数としては useEffect の関数内部で使用されている変数全てを本来は示さなくてはならないし、使用されていない変数は含んではいけないのだが、実際は純粋性を気にせず、呼び出されるタイミングを制御するために使用されることが多い。
useEffect のコールバック関数は戻り値として関数を返すことが任意でできる。これは次に状態が変化した際やコンポーネントが破棄された際に呼び出される関数で、「 useEffect で作用させた変化を元に戻す」ことに対応する。

状態変数に依存して与えられる値や関数は普通にコンポーネント関数内で変数定義すれば、状態変数が変化した際も新しい値を用いて定義し直される。しかし、こうした値や関数の再生成のコストが大きい場合や、不用意に再生成して欲しくない場合はそれぞれ useMemo, useCallback を使うことで、特定の状態変数が変化した場合にのみ再生成させる、といった制御が可能になる。

const [selected, setSelected] = useState<1|2|3>(1);

const description = useMemo(() => `The current value is: ${selected}`, [selected]);
const explain = useCallback(() => { console.log(description); }, [description]);

Solid.js

React と同じようなコンポーネント関数に見えるが、この関数は1度しか呼び出されない。状態変化しても呼び出されることはない。
これを踏まえると、以下の2点のことが言える。

  • コンポーネント関数内で値を変数に置くような場面はあまり見かけられないだろう。
    • 1回しか呼び出されないので、値を直接変数においてしまうと状態の変化に対応できないからだ。
    • 逆に関数内の値とか参照オブジェクトに含まれる値とかであれば状態の変化に対応できる。 createSignaluseState と異なり実際の値の代わりにゲッター関数を返すのはこれが理由だと考えられる。
  • React の useCallback に対応する概念は存在しない。
    • コンポーネント関数は1回しか呼び出されないのだから、その中で定義された関数も変化することはない。
    • つまり useCallback を通さずに直接関数を定義すればよい。

状態変数から値を計算する場合は、関数となるように記述する。

// React なら
const [firstName, setFirstName] = useState<string>("");
const [lastName, setLastName] = useState<string>("");
const fullName = `${firstName} ${lastName}`;
// firstName, lastName, fullName: string

// Solid.js なら
const [firstName, setFirstName] = createSignal<string>("");
const [lastName, setLastName] = createSignal<string>("");
// firstName, lastName: () => string
const fullName = () => `${firstName()} ${lastName()}`;
// firstName, lastName, fullName: () => string

React の useMemo に対応する概念として、 createMemo が存在する。依存関係に応じて必要な場合のみ計算される。
useMemo より軽量なので、割と多くのケースで createMemo を使うことができそう。 (流石に下記の例はオーバースペックだけど)

const fullName = createMemo(() => `${firstName()} ${lastName()}`);

また、 useEffect に対応する概念として createEffect が存在する。

const [selected, setSelected] = createSignal<1|2|3>(1);

createEffect(() => {
	const previousTitle = document.title;
	document.title = `item ${selected()} is selected`;
	console.log(`selected: ${selected()}`);

	return () => { document.title = previousTitle; };
});

createEffectcreateMemo はどちらも依存関係を明示していないが、関数内で使用される状態変数を自動で検出し、依存する変数として扱うことができる。というのも、初めて createMemocreateEffect を実行した際にコールバック関数内でゲッター関数を呼び出すと、依存する変数としてカウントする機能を備えているから、このようなことができるようだ。

また、 on 関数を間に挟むことで、依存する変数を手動で指定することもできる。

createEffect(
	on([selected], () => {
		const previousTitle = document.title;
		document.title = `item ${selected()} is selected`;
		console.log(`selected: ${selected()}`);

		return () => { document.title = previousTitle; };
	})
);

最後にコンポーネント関数の返値である JSX について説明する。

const MyStatus: Component = () => {
	const [selected, setSelected] = createSignal<1|2|3>(1);

	return (
		<div>The selected item is {selected()}</div>
	);
};

状態変数に依存してレンダーされる場合、上記のようにゲッター関数を実行した結果を埋め込む、という形式になっている。
1回しかコンポーネント関数が呼び出されないことを踏まえると、返値も1回しか評価されないため、上記を素直に JavaScript のコードとして読めば、 selected は (値が変化しても) JSX 上では最初の値が使い回されているようになるはずだ。だけど実際にはこんなおかしなコードでもちゃんと値の変化に対応したコンポーネントが生成される。
実は Solid.js ではビルド時に上記コードを状態変数の変化に対応するコードに変換してしまうのだ。

Vue.js

Vue.js の <script></script> の部分は Composition API を使っている場合、 Solid.js と同様に1度しか呼び出されない。
なので、値も refdefineProps から返されるオブジェクトを介して操作することになるし、また React の useCallback に相当する概念はなさそう。

状態変数から値を計算する場合は、 computed を使用する。

import { ref, computed } from "vue";

const firstName = ref<string>("");
const lastName = ref<string>("");
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

React の useEffect のように、状態変数の変化を監視して必要な場合のみ実行される関数を定義するには watch 或いは watchEffect を使用する。
watch は依存する変数を指定して扱うが、 watchEffect では自動追跡される。

const selected = ref<1|2|3>(1);

watch([selected], ([s]) => {
	// 第1引数で変化後の値を返す
	// 第2引数で変化前の値を返す
	document.title = `item ${s} is selected`;
	console.log(`selected: ${s}`);
});
// 或いは
watchEffect(() => {
	// 第1引数で変化後の値を返す
	// 第2引数で変化前の値を返す
	document.title = `item ${selected.value} is selected`;
	console.log(`selected: ${selected.value}`);
});

Svelte

Svelte の <script></script> の部分は少々扱いが特殊ではあるが、基本的には1度しか呼び出されないと考えた方が良い。
const, let などの定義を後で新しく生やすことはできないが、 let は状態変数に使用するので、その値は後で書き換えられる。

状態変数から値を計算する場合は、次のように予め計算後の値を格納する変数を用意しておき、 $: を付けた行で変数に代入を行う。
複数行ある場合は $: { ... } で囲んで使用する。

let firstName: string = "";
let lastName: string = "";
let fullName: string;
$: fullName = `${firstName} ${lastName}`;

また、 readablewritable を使って状態変数を用意している場合は、 derived 関数を使って値を計算できる。

import { writable, derived } from "svelte/store";

const firstName = writable<string>("");
const lastName = writable<string>("");

const fullName = derived(
	[firstName, lastName],
	($firstname, $lastName) => `${$firstName} ${$lastName}`
);

$: で与える方法では依存する変数を手動で指定することはできないが、 derived であれば依存する変数を手動で指定でき、 React の useMemo と同等の機能を果たす。

Angular

Angular はクラスによってコンポーネントを規定するから、特に実行されるタイミングを言及するまでもないだろう。

状態変数から計算して値を得る場合について特に決まりはないが、例えばゲッターを使うと良さそうである。

@Component({ ... })
export class App {
	protected firstName: string = "";
	protected lastName: string = "";

	protected get fullName(): string {
		return `${this.firstName} ${this.lastName}`;
	}
}

useEffectcreateEffect, watch に相当する機能は持っていない。なので手動で実装する必要があるだろう。
この時、状態変数の値が変更されたことをトリガーにして作動させることになるだろう。同一クラス内ならインスタンス変数に直接値をセットするのではなく、セッター関数を使って値を設定するようにすればトリガーでき、親コンポーネントとかからは ngOnChanges メソッドを使ってトリガーさせる必要がある。

import { Component, OnChanges, type SimpleChanges } from "@angular/core";

@Component({ ... })
export class App extends OnChanges {
	// `ngOnChanges` で監視するには `OnChanges` を継承する必要がある

	@Input("selected")
	selected: 1|2|3 = 1;

	// このコンポーネント内から書き換える場合には、このセッターを介して呼び出す
	protected setSelected(newVal: 1|2|3) {
		this.selected = newVal;
		this.onChangeSelected();
	}

	// 親コンポーネントから書き換えられた場合は、このイベントが呼び出される
	ngOnChanges(changes: SimpleChanges) {
		// selected が書き換わった場合
		if (changes["selected"]) this.onChangeSelected();
	}

	// 実際に呼び出される内容
	onChangeSelected() {
		document.title = `item ${this.selected} is selected`;
		console.log(`selected: ${this.selected}`);
	}
}

Lit

Lit も Angular と同じく、実行されるタイミングを明示する必要はないだろう。
また、 useEffectcreateEffect, watch に相当する機能も持っていない。
Angular と同様に、セッター関数と、親コンポーネントからの変更を受け取るメソッドを用意して対応する。

import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement("app-view")
export class AppView extends LitElement {

	@property()
	selected: 1|2|3 = 1;

	// このコンポーネント内から書き換える場合には、このセッターを介して呼び出す
	private setSelected(newVal: 1|2|3) {
		this.selected = newVal;
		this.onChangeSelected();
	}

	// 親コンポーネントから書き換えられた場合は、このイベントが呼び出される
	private updated(chagedProperties: PropertyValues) {
		// selected が書き換わった場合
		if (changedProperties.has("selected")) this.onChangeSelected();
	}

	// 実際に呼び出される内容
	onChangeSelected() {
		document.title = `item ${this.selected} is selected`;
		console.log(`selected: ${this.selected}`);
	}
}

class 属性と labelfor 属性

多くの HTML 要素で指定できる class 属性と、 label 要素に対する for 属性は JavaScript の予約語であるため、 DOM ではそれぞれ classNamehtmlFor フィールドからアクセスできるようになっている。
しかし、フレームワークによっては classfor 属性をテンプレートを記述できるようになっていたりする。
そこで、以下には各フレームワークの状況をまとめている。

class 属性 for 属性
React className htmlFor
Preact class / className for / htmlFor
Solid.js class for
Vue.js class for
Svelte class for
Angular class for
Lit class for

基本的に React 以外は class, for を使うことになっている。 Preact は互換性から両方に対応していると考えられる。

要素のDOMへのアクセス

テンプレート中の要素の DOM にアクセスする方法が用意されており、 DOM を直接操作することもできる。
TypeScript を使う場合は要素に対応する DOM での型を予め知っている必要がある。

React, Preact

useRef で DOM 型が入る参照を作っておいて ref 属性に与えることで、レンダー時に代入された状態になる。

import { useRef } from "react";
// or
import { useRef } from "preact/hooks";

const MyTextBox = () => {
	const inputRef = useRef<HTMLInputElement|null>(null);

	const handleChange = () => {
		const input = inputRef.current;
		if (input == null) return;
		console.log(input.value);
	};

	return (
		<input
			ref={inputRef}
			type="text" onChange={handleChange}
		/>
	);
};

Solid.js

DOM 型の変数を作っておいて ref 属性に与えることで、レンダー時に代入された状態になる。

const MyTextBox: Component = () => {
	let input!: HTMLInputElement;

	const handleChange = () => {
		console.log(input.value);
	};

	return (
		<input
			ref={input}
			type="text" onChange={handleChange}
		/>
	);
};

Vue.js

ref で DOM 型が入る参照を作っておいて ref 属性に与えることで、レンダー時に代入された状態になる。 :ref ではない。

<script setup lang="ts">
import { ref } from "vue";

const inputRef = ref<HTMLInputElement|null>(null);

const handleChange = () => {
	const input = inputRef.value;
	if (input == null) return;
	console.log(input.value);
};
</script>

<template>
	<input
		ref="inputRef"
		type="text" @change="handleChange"
	/>
</template>

Svelte

DOM 型の変数を作っておいて bind:this 属性に与えることで、レンダー時に代入された状態になる。

<script lang="ts">
	let input: HTMLInputElement;

	const onChange = () => {
		console.log(input.value);
	};
</script>

<input
	bind:this={input}
	type="text" on:change={handleChange}
/>

Angular

参照のインスタンス変数を用意し、 @ViewChild("selector") デコレータを付け、テンプレート中で #selector の形でセレクタを指定することで、レンダー時に代入された状態になる。

import { Component. ViewChild, ElementRef } from "@angular/core";

@Component({
	selector: "my-text-box",
	standalone: true,
	template: `
		<input
			#myInputElement
			type="text"
			(change)="handleChange"
		/>
	`
})
export class Input {
	@ViewChild("myInputElement")
	inputRef!: ElementRef<HTMLInputElement>;

	protected handleChange() {
		const input = this.inputRef.nativeElement;
		console.log(input.value);
	}
}

Lit

DOM 型のインスタンス変数を用意し、 @query デコレータで対象要素にマッチする CSS セレクタを指定することで、レンダー時に代入された状態になる。
後述するように Shadow DOM で構築されることから、セレクタにマッチするのはコンポーネント内の要素のみだと考えられる。

import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";

@customElement("my-text-box")
export class MyTextBox extends LitElement {
	@query('input[type="text"]')
	private input!: HTMLInputElement;

	private handleChange() {
		console.log(this.input.value);
	}

	render() {
		return html`
			<input type="text" @change=${this.handleChange} />
		`;
	}
}

テンプレート中の制御構文 (条件分岐やループ)

条件にレンダーされる内容を変えたり、有限回同じコンポーネントを作ったり、リストに対応するコンポーネントを作ったりする必要がある。そういうケースに対処するためにテンプレート内で if 構文や for 構文が使えるようになっている。

どのフレームワークにおいても for 構文を使う際には key の指定が推奨される。これは要素ごとに一意に決まるプリミティブ型の値を key として与えることで、再描画の前後で要素の対応付けを行い、対応した要素同士を比較することで効率よく描画を行えるようにする仕組みである。要素の増減にも対応しやすいだろう。
for 構文の外で key を指定する場合もある。例えば意図的に key の値を書き換えることで強制的に再描画を発生させる、といった使い方がされている。

React, Preact, Solid.js

これらは JSX を使用しており、制御構文は JavaScript の記法に従う。

条件分岐であれば次のように if 文や3項演算子で表現可能。

const isExpired: boolean = false;
const isInvalid: boolean = false;

if (isExpired) return <span>Timed out</span>;
else if (isInvalid) return <span>Invalid request</span>;
else return <button>Proceed</button>;
// or
return (
	{
		isExpired ? <span>Timed out</span> :
		isInvalid ? <span>Invalid request</span> :
		<button>Proceed</button>
	}
);

リストからコンポーネントを生成する場合は、リストに対して map を実行して、返値のリストを埋め込む。
キーは key 属性で与える。
但し Solid.js においては key 属性を与える必要はない。

const numbers = ["One", "Two", "Three"];
return (
	{numbers.map((numStr) => (
		<div key={numStr}>{numStr}</div>
	))}
);

整数個の要素を作る場合は、 Array.from を使う方法でリストを作成して map する。

const num = 4;

return (
	{Array.from({ length: num }).map((,index) => (
		<span key={index}></span>
	))}
);

Vue.js

条件分岐であれば次のように対象となる要素に v-if, v-else-if, v-else 属性で条件を与える。

<script setup lang="ts">
const isExpired: boolean = false;
const isInvalid: boolean = false;
</script>
<template>
	<span v-if="isExpired">Timed out</span>
	<span v-else-if="isInvalid">Invalid request</span>
	<button v-else>Proceed</button>
</template>

複数の要素を条件分岐に組み込みたい場合や、構造を明確化したい場合には template 要素の中に内容を入れて使う。

<template>
	<template v-if="isExpired">
		<span>Timed out</span>
	</template>
	<template v-else-if="isInvalid">
		<span>Invalid request</span>
	</template>
	<template v-else>
		<button>Proceed</button>
	</template>
</template>

リストからコンポーネントを生成する場合は、対象とする要素 or template 要素 (複数要素の場合とか) に v-for 属性を加える。キーは同じ要素に対する :key 属性を使用する。

<script setup lang="ts">
const numbers = ["One", "Two", "Three"];
</script>
<template>
	<div
		v-for="numStr in numbers"
		:key="numStr"
	>{{ numStr }}</div>
</template>

整数個の要素を作る場合は、リストの代わりに整数を与えれば同様にできる。

<script setup lang="ts">
const num = 4;
</script>
<template>
	<span v-for="index in num" :key="index"></span>
</template>

Svelte

条件分岐であれば次のように #if - :else if - :else - /if 節を使用する。

<script lang="ts">
	const isExpired: boolean = false;
	const isInvalid: boolean = false;
</script>

{#if isExpired}
	<span>Timed out</span>
{:else if isInvalid}
	<span>Invalid request</span>
{:else}
	<button>Proceed</button>
{/if}

リストからコンポーネントを生成する場合は #each - /each 節を使用する。キーは #each 節の末尾の括弧 () で指定する。

<script lang="ts">
	const numbers = ["One", "Two", "Three"];
</script>

{#each numbers as numStr (numStr)}
	<div>{numStr}</div>
{/each}

整数個の要素を作る場合は、 Array.from を使う方法でリストを作成して #each 節を使用する。

<script lang="ts">
	const num = 4;
</script>

{#each Array.from({ length: 4 }) as _,index (index)}
	<span></span>
{/each}

他のフレームワークと異なり、キーは属性によるものではないので、再描画するかどうかを決めるためのキーを指定するための以下のような専用の構文がある。
currentKey の値が変更されたら再描画される。

{#key currentKey}
	<div>{new Date()}</div>
{/key}

Angular

Angular テンプレート中で使用する変数は全てインスタンス変数か、テンプレート中で @let を使って定義された変数でなければならない。つまり、 static のついたクラス変数や、クラス外のどこかで定義された変数にはアクセスできないようである。
また、オブジェクトのメンバーにアクセスするためのドット演算子も使えない場合がある。

条件分岐であれば次のように @if - @else if - @else 節を使用する。

@Component({
	...
	template: `
		@let isExpired = false;
		@let isInvalid = false;
		@if (isExpired) {
			<span>Timed out</span>
		}
		@else if (isInvalid) {
			<span>Invalid request</span>
		}
		@else {
			<button>Proceed</button>
		}
	`
})
export class CondState {}

また、 JavaScript の switch 文に相当する @switch 節も存在する。

@Component({
	...
	template: `
		@switch (index) {
			@case (1) { <span>First item</span> }
			@case (2) { <span>Second item</span> }
			@case (3) { <span>Third item</span> }
			@default { <span>Other item</span> }
		}
	`
})
export class CondState {
	const index: number = 1;
}

リストからコンポーネントを生成する場合は @for 節を使用する。キーは @for 節の引数の2つ目の項の track で指定する。

@Component({
	...
	template: `
		@let numbers = ["One", "Two", "Three"];
		@for (numStr of numbers; track numStr) {
			<div>{{ numStr }}</div>
		}
	`
})
export class CondState {}

通常は @for 構文を使えば良いが、たまにうまくいかないことがある。その場合は次の *ngFor 属性を使用する方法もある。

import { CommonModule } from "@angular/common";

@Component({
	...
	imports: [ CommonModule ],
	template: `
		<div *ngFor="numStr of numbers; trackBy: numStrForTracking">{{ numStr }}</div>
	`
})
export class CondState {
	protected numbers = ["One", "Two", "Three"];
	// trackBy にはインスタンスで定義された関数を入れないと機能しないので、こんな恒等関数を用意して渡す必要がある。
	protected numStrForTracking(numStr: string) { return numStr; }
}

整数個の要素を作る場合は、 Array.from を使う方法でリストをインスタンス変数で作成して @for 節を使用する。

@Component({
	...
	template: `
		@for (index of indexes; track index) {
			<span></span>
		}
	`
})
export class CondState {
	// `Array` はインスタンス変数ではないので、 `@let` 式で `indexes` を用意できない。
	protected indexes = Array.from({ length: 4 });
}

Lit

Lit では JSX と同様に JavaScript に依拠している。

条件分岐であれば次のように if 文や3項演算子で表現可能。

@customElement("cond-state")
class CondState extends LitElement {
	private isExpired: boolean = false;
	private isInvalid: boolean = false;
	
	render() {
		if (isExpired) return html`<span>Timed out</span>`;
		else if (isInvalid) return html`<span>Invalid request</span>`;
		else return html`<button>Proceed</button>`;
	}
	// or
	render() {
		return html`
			${
				isExpired ? html`<span>Timed out</span>` :
				isInvalid ? html`<span>Invalid request</span>` :
				html`<button>Proceed</button>`
			}
		`;
	}
}

リストからコンポーネントを生成する場合は、専用の repeat 関数を用いて処理を行い、返値を埋め込む。
キーは第2引数の関数の戻り値として指定できる。

import { repeat } from "lit/directives/repeat.js";

const numbers = ["One", "Two", "Three"];

@customElement("cond-state")
class CondState extends LitElement {
	render() {
		return html` ${repeat(
			// 配列
			numbers,
			// キーを返す関数
			(numStr) => numStr,
			// レンダー内容を返す関数
			(numStr) => html`<div>${numStr}</div>`,
		)} `;
	}
}

整数個の要素を作る場合は、 Array.from を使う方法でリストを作成して repeat 関数を用いる。

const num = 4;

@customElement("cond-state")
class CondState extends LitElement {
	render() {
		return html` ${repeat(
			Array.from({ length: num }),
			(_,index) => index,
			() => html`<span></span>`,
		)} `;
	}
}

展開された時の DOM ツリー

コンポーネントは多くのフレームワークでは次のように展開される。

<!-- こんな表記で -->
<MyBox>
	<MyButton label="Click Me" />
</MyBox>
<!-- MyBox と MyButton を適切に定義していればこんな風に -->
<div id="box">
	<button type="button">Click Me</button>
</div>

ここではこの通りに展開されないフレームワークについて説明する。

Angular

Angular では my-boxmy-button はそのまま残り、それぞれの内側に展開される。

<!-- こんな表記で -->
<my-box>
	<my-button label="Click Me" />
</my-box>
<!-- my-box と my-button を適切に定義していればこんな風に -->
<my-box>
	<div id="box">
		<my-button>
			<button type="button">Click Me</button>
		</my-button>
	</div>
</my-box>

Lit

Lit ではデフォルトで Shadow DOM として展開される。つまり CSS のスタイル指定や DOM ツリーが外界と隔絶された環境でコンポーネントが構築される。

CSS が隔絶されることで、 div > button のようなセレクタは跨った状態で指定できないし、外側でインポートしたスタイルシートで宣言されるスタイルは内側には適用されない。
DOM の隔絶も childNodes などを使って内部の要素の DOM へアクセスする方法が一切ないことを示す。

<my-box>
	<!-- my-box の内側と外側で隔絶される -->
	<div id="box">
		<my-button>
			<!-- my-button の内側と外側で隔絶される -->
			<button type="button">Click Me</button>
		</my-button>
	</div>
</my-box>

Shadow DOM にしないようにするためには、コンポーネント中で次のように createRenderRoot メソッドを規定する。
これにより、 Angular と同様のツリー構造が得られる。

@customElement("my-box")
export class MyBox extends LitElement {
	createRenderRoot() { return this; }
	render() { ... }
}

コンポーネントに型パラメータを与える

複数の場所でコンポーネントを共用したい場合などに、引数で受け取る値の型を場所ごとに変えたいという需要も考えられる。特に列挙型を引数で受け取るケースだと、受け取る列挙型の種類を変えられるのは意外と便利である。
コンポーネント自体が型パラメータを取り、引数がその型で与えられるようにすれば実現できそうである。ここでは、それぞれのフレームワークの型パラメータの対応状況を説明する。

React, Preact, Solid.js

これらのフレームワークでは関数によりコンポーネントを構成するので、 TypeScript で通常の関数に型パラメータを与えるのと同じ手法で型パラメータを与えられる。

const Describe = <T extends string>({ value: T }) => {
	return <div>value is : {value}</div>;
};

上記の例のように extends を付けている場合や、複数の型パラメータを使っている場合は特に問題ないのだが、 <T> のように単一の型パラメータで制約条件がない場合は .tsx ファイル (TypeScript + JSX) では HTML 要素との見分けがつかないことから <T,> などと末尾にコンマをつける必要がある。

また、関数コンポーネントを型として扱う時には標準の FC, FunctionalComponent, Component を使う限りは型パラメータを設定することはできないので、次のように手動で関数コンポーネントの型を定義する必要がある。

const DescribeFC = <T extends string>(props: { value: T }) => JSX.Element;

React.FC の型定義を見ると、関数の返値の型は ReactNode になっており、それに合わせてカスタム型定義の返値も ReactNode にしてしまうと、この型に従うコンポーネントを別のコンポーネントの JSX に埋め込む際に型エラーが発生する。何故かはよく分かっていない。

Vue.js, Svelte

コンポーネントの <script> タグに Vue.js なら generic を、 Svelte なら generics 属性で型パラメータを定義することにより、型パラメータを使用することができる。

<script setup lang="ts" generic="T extends string">
type Props = {
	value: T;
};
const props = defineProps<Props>();
</script>

<template>
	<div>value is: {{ props.value }}</div>
</template>
<script lang="ts" generics="T extends string">
	let value: T;
</script>

<div>value is: {value}</div>

公式ドキュメントでは複数の型パラメータを持つ場合にどのように記載すればいいのか説明がなされていないが、おそらくコンマ区切りで generic / generics に指定するのだと思われる。

Angular / Lit

これらのフレームワークではクラスがコンポーネントを構成することから、クラス名に型パラメータを与えれば簡単に実現できる。

@Component({ ... })
export class Describe<T> { ... }

HTML への埋め込み方

コンポーネントはフレームワーク内でツリー構造をなすが、ルートコンポーネントは適切な手段で DOM 内や HTML 内に展開されなければ動作しない。
ここではそれぞれのフレームワークでのルートコンポーネントを HTML へ埋め込む方法を説明する。

React

ルートコンポーネントを App とし、 body 要素中に <div id="root"></div> が存在する状態で以下を実行する。

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";

createRoot(document.getElementById("root")!)
.render(
	<StrictMode>
		<App />
	</StrictMode>
);

Preact

ルートコンポーネントを App とし、 body 要素中に <div id="root"></div> が存在する状態で以下を実行する。

import { render } from "preact";
import { App } from "./App.tsx";

render(
	<App />,
	document.getElementById("root")!
);

Solid.js

ルートコンポーネントを App とし、 body 要素中に <div id="root"></div> が存在する状態で以下を実行する。

import { render } from "solid-js/web";
import { App } from "./App.tsx";

render(
	() => <App />,
	document.getElementById("root")!
);

Vue.js

ルートコンポーネントを App とし、 body 要素中に <div id="root"></div> が存在する状態で以下を実行する。

import { createApp } from "vue";
import App from "./App.vue";

createApp(App)
.mount(document.getElementById("root")!);

Svelte

ルートコンポーネントを App とし、 body 要素中に <div id="root"></div> が存在する状態で以下を実行する。

import App from "./App.svelte";

new App({
	target: document.getElementById("root")!
});

Angular

要素名が app であるルートコンポーネントを App とし、 HTML の body 要素中に <app></app> が存在する状態で以下を実行する。

import { bootstrapApplication } from "@angular/platform-browser";
import App from "./App.ts";

bootstrapApplication(
  App, { providers: [] }
);

Lit

要素名が app-view であるコンポーネントを用意し、 HTML の body 要素中に <app-view></app-view> が存在している状態であれば、コンポーネントを含む JavaScript ファイルを読み込むことで描画される。

埋め込みに関する所感

React, Preact, Solid.js, Vue.js, Svelte では <body></body> 中に既にある <div id="root"></div>document.getElementById("root") で取ってきて、そこにコンポーネントを生やす、ということを上記でやっているため、実質的に HTMLElement 型の DOM 要素が与えられれば何でも良さそうで、オフスクリーンでフレームワークを走らせる、といったことも十分やれそうである。
一方、 Angular, Lit では HTML 中に既にルートコンポーネントに対応する要素が存在する前提になっているコードだから、あまり変なことはできなそう。

まとめ

1つのフレームワークで使われている考え方が意外と他のフレームワークでも通用したり、或いはしなかったり、というのを学ぶことができて割と面白い。1つしか触っていないときに比べて視野が広くなったような気になった。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?