これまで 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;
-
template
やstyles
の代わりにtemplateUrl
やstyleUrls
を使うことで別の.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
の他にそれぞれReactNode
やComponentChildren
が用意されている。これらはJSX.Element
の他にstring
やnumber
,undefined
などの論理和型になったものである。後述する子要素を扱う際の型として利用することができる。 - React, Preact では同じ
JSX.Element
を何回でも使い回すことができるが、 Solid.js では使い回すことができない。複数の箇所で同じ JSX を使いたければ、同じ内容の JSX を生成するように工夫しなければならない。
Vue.js, Svelte
Vue.js や Svelte では、おそらく .vue
ファイルや .svelte
ファイルで定義されたコンポーネントを包括的に扱う型は存在する。それぞれ DefineComponent
や SvelteComponent
である。
// 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>
上記の例のように、 ref
に VNode
を与えることで、テンプレート中に埋め込んだり、後で仮想DOMの内容を書き換えたりすることができる。
Lit
Lit では LitElement
が全てのコンポーネントの親クラスになっている。これは DOM の HTMLElement
型の拡張となっているため、クラス定義から addEventListener
や className
などといったお馴染みの名前のメソッドやプロパティを使用することができる。
文字列の先頭に (関数である) 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
型の状態変数であれば、 counter
が number
型の値そのもの、 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
を使って同様のインターフェースで子でも読み書きができるようになっている。
ref
は Ref<T>
型、 defineModel
は ModelRef<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/store
の writable
をコンポーネント間で渡せば、コンポーネント間で双方向にやり取りはできそうである。
<!-- 子のコンポーネント (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回しか呼び出されないので、値を直接変数においてしまうと状態の変化に対応できないからだ。
- 逆に関数内の値とか参照オブジェクトに含まれる値とかであれば状態の変化に対応できる。
createSignal
がuseState
と異なり実際の値の代わりにゲッター関数を返すのはこれが理由だと考えられる。
- 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; };
});
createEffect
と createMemo
はどちらも依存関係を明示していないが、関数内で使用される状態変数を自動で検出し、依存する変数として扱うことができる。というのも、初めて createMemo
や createEffect
を実行した際にコールバック関数内でゲッター関数を呼び出すと、依存する変数としてカウントする機能を備えているから、このようなことができるようだ。
また、 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度しか呼び出されない。
なので、値も ref
や defineProps
から返されるオブジェクトを介して操作することになるし、また 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}`;
また、 readable
や writable
を使って状態変数を用意している場合は、 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}`;
}
}
useEffect
や createEffect
, 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 と同じく、実行されるタイミングを明示する必要はないだろう。
また、 useEffect
や createEffect
, 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
属性と label
の for
属性
多くの HTML 要素で指定できる class
属性と、 label
要素に対する for
属性は JavaScript の予約語であるため、 DOM ではそれぞれ className
と htmlFor
フィールドからアクセスできるようになっている。
しかし、フレームワークによっては class
や for
属性をテンプレートを記述できるようになっていたりする。
そこで、以下には各フレームワークの状況をまとめている。
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-box
や my-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つしか触っていないときに比べて視野が広くなったような気になった。