3 VComponentベースのWebコンポーネントの理解

Oracle JETは、仮想DOMレンダリングを使用するWebコンポーネントを作成するためのWebコンポーネントAPI、VComponentを提供します。

VComponentを使用して作成するWebコンポーネントでは、仮想DOMレンダリングが使用されます。以前にコンポジット・コンポーネント・アーキテクチャ(CCA)を使用してWebコンポーネントを開発したユーザーには、多くの違いがあることがわかります。Oracle JET仮想DOMアーキテクチャを支えるPreactライブラリに精通しているユーザーには、なじみのある概念が見られます。この章では、VComponentベースのWebコンポーネントを開発するために知っておく必要がある概念を紹介します。

1つの違いは、CCAベースのWebコンポーネントとは異なり、VComponentベースのWebコンポーネントではKnockoutや組込み式評価機能を使用して式を評価しない点です。かわりに、VComponentベースのWebコンポーネントでは、JETのCspExpressionEvaluatorを使用して、使用する式がコンテンツ・セキュリティ・ポリシーに準拠するようにします。CspExpressionEvaluatorは、コンテンツ・セキュリティ・ポリシーへのコンプライアンスを確保するために、限定された式セットをサポートしています。VComponentベースのWebコンポーネントで式を使用する場合、JETのCspExpressionEvaluatorでサポートされる構文について理解します。CspExpressionEvaluator APIドキュメントを参照してください。

JETツールは、Webコンポーネントの作成、パッケージ化および公開に役立ちます。JETツールの使用法は、CCAベースのWebコンポーネント開発の場合と同じままですが、出力は異なります。ここでは、スタンドアロンのVComponentベースのWebコンポーネントおよび次の章のJETパックに含める一連のWebコンポーネントを作成します。

ここでは、このガイドの概要の章で説明した前提条件に関する知識をすでに取得していることを前提として、Webコンポーネントを作成するためのVComponentの使用法を確認します。

VComponentの概要

VComponentベースのWebコンポーネントは、.tsxファイル拡張子を持つファイルにTypeScriptモジュールとして記述します。

次の例は、hello-world.tsxという名前のファイル内にある、カスタム要素名がhello-worldHelloWorldという名前のVComponentクラスを示しています。hello-world.tsxファイルのエントリについては、次の点に注意してください:

  • JSX要素は、仮想DOMツリー(<p>{props.message}</p>)の内容を表します。
  • preactモジュールからインポートしたh関数は、JSX要素を仮想DOM要素に変換します。
import { ExtendGlobalProps, registerCustomElement } from "ojs/ojvcomponent";
import { h, ComponentProps, ComponentType } from "preact";
import componentStrings = require("ojL10n!./resources/nls/hello-world-strings");
import "css!./hello-world-styles.css";

type Props = Readonly<{
  message?: string;
}>;

/**
 *
 * @ojmetadata version "1.0.0"
 * @ojmetadata displayName "A user friendly, translatable name of the component"
 * @ojmetadata description "A translatable high-level description for the component"
 *
 */
function HelloWorldImpl({ message = "Hello from  hello-world" }: Props) {
  return <p>{message}</p>;
}

export const HelloWorld: ComponentType<ExtendGlobalProps<ComponentProps<typeof HelloWorldImpl>>> 
                                           = registerCustomElement("hello-world", HelloWorldImpl);

Oracle JETツールを使用すると、テンプレートの.tsxファイルと、コンポーネントをサポートするためのリソースを含む追加のファイルおよびフォルダを生成することで、VComponent Webコンポーネントを作成できます。hello-worldというカスタム要素名で示した例は、次のコマンドによって作成されました:

ojet create component hello-world

仮想DOMアーキテクチャを使用しないアプリケーションでVComponentベースのWebコンポーネントを作成する場合は、コマンドに--vcomponentを含めてコンポーネント(ojet create component hello-world --vcomponent)を作成する必要があります。classオプションを--vcomponentパラメータ(ojet create component hello-world --vcomponent=class)に追加すると、Oracle JETツールでは、クラスベースのWebコンポーネントの作成もサポートされます。デフォルトの動作は、関数ベースのVComponentの作成です。

作成するVComponentのタイプ(クラスまたは関数)に関係なく、ツールにより、appRootDir/oraclejetconfig.jsonファイルのcomponentsプロパティによって参照されるディレクトリにこれらのファイルが生成されます。デフォルトでは、componentsプロパティの値もcomponentsです。

appRootDir/components/hello-world/
|   loader.ts
|   hello-world-styles.css
|   hello-world.tsx
|   README.md
+---resources
+---themes

CCAベースのWebコンポーネントを以前開発したリーダーは、Oracle JETツールに含まれるloader.tsファイルを認識して、コンポーネント交換、Oracle Visual BuilderおよびOracle JETツール自体でコンポーネントを使用できます。VComponentベースのWebコンポーネントの場合、loader.tsファイルには、次の例のようにVComponentモジュールをエクスポートするためのエントリが含まれています:

export { HelloWorld } from "./hello-world";

VComponent Webコンポーネントを作成したら、それをアプリケーションにインポートして、そこで使用できます。次の例は、仮想DOMアーキテクチャ・スタータ・テンプレートを使用してスキャフォールドされたアプリケーションのcontentコンポーネントにサンプル・コンポーネントをインポートする方法を示しています:

import { h } from "preact";
import { HelloWorld } from "hello-world/loader";

export function Content() {
  return (
    <div class="oj-web-applayout-max-width oj-web-applayout-content">
      <HelloWorld />
    </div>
  );
}
周囲のテキストで説明された画像

VComponentのメタデータ

JETメタデータは、作成するVComponentベースのWebコンポーネントのツールとコンシューマの両方に役立つ可能性のある情報を表現します。

VComponentのモジュール・クラスにメタデータを記述します。このメタデータの例は、前に紹介したHelloWorld VComponentで確認しました。具体的には、HelloWorld VComponentには、実行時にVComponentにカスタム要素動作を追加するためのTypeScriptデコレータ@customElement("hello-world")が含まれ、これはビルド時にコンポーネントの"name"メタデータのソースとしても使用されます。もう1つの例は、次の例のように、一連のエントリによってバージョン、表示名および説明情報が提供される@ojmetadataドキュメント注釈の使用です:

* @ojmetadata version "1.0.0"
* @ojmetadata displayName "A user friendly, translatable name of the component"
* @ojmetadata description "A translatable high-level description for the component"

@ojmetadata注釈で単一の名前と値のペアが指定されていることがわかります。値は、有効なJSON値である必要があります。前述のように、文字列値は二重引用符で囲む必要があります。オブジェクト、配列およびプリミティブの値は、注釈内で直接指定できます(引用符なし)。次の例に示すように、メタデータを拡張して拡張フィールドに追加情報を追加することもできます。

* @ojmetadata extension {
*    vbdt: {
*      someVisualBuilderDesignTimeField: true
*    }
* }

JETメタデータの参照情報は、JETメタデータに関する項を参照してください。

VComponentのネスト

カスタム要素ベースのVComponentは、HTMLに直接埋め込むことができます。

これにより、VComponentを既存のOracle JETコンテンツ(コンポジット・コンポーネント、oj-moduleコンテンツ、Oracle Visual Builderで作成されたページなど)に統合できます。HTML内でホストされる他に、VComponentは他のVComponentの内部にネストできます。親VComponentは、コンポーネント・クラス名を使用して子VComponentを参照できます。次の例では、VComponentクラスであるHelloParentが子VComponentであるHelloをネストしています。

import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
import { Hello } from "oj-greet/hello/loader";

type Props = {
  message?: string;
};

/**
 * @ojmetadata pack "oj-greet"
 * ...
 */
@customElement("oj-greet-hello-parent")
export class HelloParent extends Component<ExtendGlobalProps<Props>> {
  static defaultProps: Partial<Props> = {
    message: "Hello from oj-greet-hello-parent!",
  };

  render(props: Props): ComponentChild {
    return (
      <div>
        <p>{props.message}</p>
        <p>The HelloParent VComponent nests the Hello VComponent class in the
          next line:</p>
        <Hello />
      </div>
    );
  }
}

その結果のHTMLの内容は、次のとおりです:

<div class="oj-web-applayout-max-width oj-web-applayout-content">
 <oj-greet-hello-parent class="oj-complete">
    <div>
      <p>Hello from oj-greet-hello-parent!</p>
        <p>The HelloParent VComponent nests the Hello VComponent class in the next line:</p>
          <oj-greet-hello class="oj-complete"><p>Hello from oj-greet-hello!</p></oj-greet-hello>
    </div>
 </oj-greet-hello-parent>
</div>

<oj-greet-hello>カスタム要素はライブDOMで終了します。

VComponentプロパティ

プロパティは、VComponentのインスタンスに渡すVComponentクラスの読取り専用引数です。

宣言するプロパティは、HTML属性としてWebコンポーネントに渡される場合もあります。基本的に、VComponent APIコンポーネント・モジュールのプロパティは、JSXでの関数引数やHTML用途の属性と同様です。

VComponentプロパティの宣言

VComponentプロパティは、慣例により、Propsという名前のタイプ・エイリアスを使用して宣言します。

タイプ内の各フィールドは、単一のパブリック・コンポーネント・プロパティを表します。フィールドでは、プロパティの名前、タイプ、およびフィールドの値がオプションか必須かを指定します。デフォルト値は、コンポーネント・クラスのstatic defaultPropsフィールドで指定されます。

次の例では、文字列型の単一のプロパティ(preferredGreeting)を宣言します。TypeScriptのオプションのインジケータ(?)では、それがオプションのプロパティとして識別され、Helloのデフォルト値はstatic defaultPropsフィールドで指定されます。

見逃しやすい1つの小さな要件: プロパティ・クラスをVComponent実装に関連付けるには、VComponentの最初のタイプ・パラメータ(export class WithProps extends Component<ExtendGlobalProps<Props>>)の値としてクラスを指定する必要があります。

import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";

type Props = {
  preferredGreeting?: string;
};

/**
 * @ojmetadata pack "oj-greet"
 * ...
 */
@customElement("oj-greet-with-props")
export class WithProps extends Component<ExtendGlobalProps<Props>> {
  static defaultProps: Partial<Props> = {
    preferredGreeting: "Hello",
  };

  render(props: Props): ComponentChild {
    return <p>{props.preferredGreeting}, World!</p>;
  }
}

JSXでのプロパティの参照

プロパティを使用するには、VComponentで最初にプロパティ・クラスをVComponentインスタンス実装に関連付ける必要があります。

import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";

type Props = {
  preferredGreeting?: string;
};

/**
 * @ojmetadata pack "oj-greet"
 */
@customElement("oj-greet-with-props")
export class GreetWithProps extends Component<ExtendGlobalProps<Props>> {
  static defaultProps: Partial<Props> = {
    preferredGreeting: "Hello from oj-greet-with-props!"
  };

  render(props: Readonly<Props>): ComponentChild {
    return <p>{props.preferredGreeting}</p>;
  }
}

プロパティへのアクセス

VComponent APIコンポーネント実装で、特殊なオブジェクトthis.propsを使用して宣言済プロパティにアクセスできます。この使用方法の例は、行12で確認できます。this.propsフィールドは、preferredGreetingプロパティの値を変数greetingに抽出します。この変数は、その後レンダリングされた仮想DOMツリーの状態に影響を与えます。このツリーでは、preferredGreetingプロパティの値が行16の仮想DOMに埋め込まれます。

1つの留意事項として、this.propsのプロパティ値は常にVComponent APIコンポーネントのコンシューマによって定義されます。HTMLの場合、this.propsは、アプリケーションによってカスタム要素で指定された属性/プロパティ値に基づいて移入されます。親VComponent APIコンポーネント内でVComponent APIコンポーネントが使用されている場合、プロパティ値は親コンポーネントによって提供されます。VComponent APIコンポーネントの実装では、これらのプロパティ値を読み取ることができますが、this.propsオブジェクトは変更しないでください。

JSXでの子コンポーネントのプロパティの参照

VComponent APIカスタム要素は、他の親VComponent APIカスタム要素内に埋め込むこともできます。

「VComponentのネスト」で説明したように、VComponent APIコンポーネントの親は、VComponent APIコンポーネントの実装クラス名を直接使用して子を参照できます。JSX内では、常にcamelCaseプロパティ名を使用してコンポーネント・プロパティを指定します。

次に、これを示すVComponent GreetWithPropsParentの例を示します:

import { h, Component } from "preact";
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { GreetWithProps } from "oj-greet/with-props/loader";

/**
 * @ojmetadata pack "oj-greet"
 * ...
 */
@customElement("oj-greet-with-props-parent")
export class GreetWithPropsParent extends Component<ExtendGlobalProps<Props>> {
  render() {
    return (
      <div>
        <GreetWithProps preferredGreeting="Hola" />
      </div>
    );
  }
}

サンプルでは、preferred-greeting属性名ではなくpreferredGreetingプロパティ名がどのように使用されているかに注意してください。

タイプ・チェックのサポート

HTMLとJSXマークアップの間に異なる命名規則を使用すると、最初はわかりにくいように見えますが、JSX内のプロパティ名だけを使用してタイプ・チェックを維持することが重要です。

次のようなJSX要素を指定する場合:

<oj-greet-with-props preferredGreeting="Hey there"/>

または:

<GreetWithProps preferredGreeting="Hola"/>

各JSX要素のプロパティは、最終的に子コンポーネントのthis.propsフィールドに移入するpropsオブジェクトに移入されます。このpropsオブジェクトのタイプは、子VComponent APIコンポーネント・インスタンスのpropsタイプ・パラメータに基づいています。そのため、VComponent APIコンポーネント・インスタンスのプロパティ・タイプによって宣言されているプロパティ名を使用すると、親コンポーネントのJSXでタイプ・チェック(およびエラーの捕捉)が行われます。

グローバルHTML属性

camelCaseの命名規則では、JSX内からのコンポーネント・プロパティの参照がサポートされています。理想的なことに、idtabIndexなどのグローバルHTML属性でも、この規則が機能できます。ただし、すべてのグローバルHTML属性がプロパティとして公開されるわけではありません。たとえば、aria-およびdata-属性には同等のプロパティがありません。

これは、グローバルHTMLプロパティ/属性を操作するための次のルールにつながります:

  • グローバルHTML属性をプロパティとして使用できる場合は、プロパティ名を使用します。
  • グローバルHTML属性をプロパティとして使用できない場合は、属性名を使用します。

多くの場合、グローバルHTML属性名はプロパティ名(idtitlestyleなど)と同一になります。ただし、属性とプロパティ名が異なる場合や、プロパティ名に特定の大/小文字が必要な場合があります。たとえば、属性では大文字と小文字が区別されないため、HTMLではtabindex属性の大文字表記が可能です。ただし、JSXでは、実際のプロパティ名tabIndexを使用する必要があります:

protected render() {
  // While "tabindex" is a valid way to specify the tab index
  // in an HTML document, in JSX, the property name "tabIndex"
  // must be used.
  return <div tabIndex="0" />
 }

プロパティ名参照を制御するルールには、1つの例外があります。スタイル・クラスを指定するプロパティ名はclassNameですが、この名前は一般には認識されません。VComponentでは、より使い慣れた属性名classを使用できます:

protected render() {
  // Use "class" instead of "className"
  return <div class="awesome-class" />
}

子およびスロットのコンテンツ

コンポーネントでは、プロパティの公開に加えて、子を渡せるようにすることもできます。VComponent APIカスタム要素を使用すると、子は次の2つの方法のいずれかで指定されます:

  1. slot属性がない直接の子として。これは、デフォルト・スロットとも呼ばれます。
  2. 名前がslot属性で設定された名前付きスロットとして。

コンポーネントは、これらの両方のアプローチを活用できます。たとえば、次のoj-c-collapsible要素は、デフォルトのスロットのコンテンツと、headerの名前付きスロットのコンテンツの両方で構成されます:

<oj-c-collapsible>
  <h3 slot='header'>This is named slot content</h3>
     <span>This is default slot content</span>
    </oj-c-collapsible>

VComponent APIは、デフォルトのスロット、名前付きスロットまたはその両方を公開するカスタム要素の作成をサポートしています。

デフォルトのスロット

VComponent APIは、コンポーネントのパブリックAPIを定義するために外部メタデータに対してコード構成を使用することを優先します。JSONメタデータを介してVComponent APIカスタム要素の子/スロット・コントラクトを宣言する必要はありません。かわりに、コードの記述によりデフォルト・スロットが追加されます。

VComponent APIカスタム要素の子/スロット・コントラクトは、Propsクラスにフィールドを追加することによって定義されます。具体的には、ComponentChildrenタイプの子プロパティを宣言することで、コンポーネントがデフォルトのスロット・コンテンツを受け入れられることを示します:

import { h, Component, ComponentChildren } from 'preact';
     type Props = {
      preferredGreeting?: string;
      children?: ComponentChildren;
    }

また、Propsタイプ・パラメータを使用して、PropsクラスをVComponentに関連付けることができます:

@customElement(‘oj-greet-with-children’)
   export class GreetWithChildren extends Component<ExtendGlobal<Props>> {
  }

これを行うと、デフォルト・スロットの子がprops.childrenを介してVComponent APIコンポーネント実装で使用できるようになります。これは、コンポーネント実装がHTMLドキュメント内のカスタム要素、JSX内のカスタム要素、またはJSX内のVComponentコンポーネント実装クラスのいずれとして使用されているかに関係なく当てはまります。

VComponentコンポーネントの実装では、コンポーネントの仮想DOMツリー内の任意の場所にデフォルト・スロットの子を自由に配置できます。たとえば、VComponent APIボタンは、これらの子をHTMLの<button>要素内に配置する可能性があります:

protected render() {
      return <button> { props.children } </button>;
 }

名前付きスロット

デフォルト・スロットと同様に、名前付きスロットもpropsクラスのフィールドとして宣言されます。

名前付きスロット宣言は、次の2つの規則に従う必要があります:

  • 名前付きスロット・フィールドには、Slotタイプを使用する必要があります。
  • フィールド名はスロット名と一致する必要があります。

startIconという名前のスロットの宣言は次のようになります:

import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
import "oj-c/avatar";
import { GreetWithChildren } from 'oj-greet/with-children/loader'

type Props = {
    startIcon?: Slot;
}

/**
 * @ojmetadata pack "oj-greet"
 * @ojmetadata dependencies {
 *   "oj-greet-with-children": "^1.0.0"
 * }
 */
@customElement('oj-greet-with-children-parent')
export class GreetWithChildrenParent extends Component<ExtendGlobalProps<Props>> {
  render() {
    return (
      <div>
        <p>This child is rendered as a VComponent class:</p>
        <GreetWithChildren startIcon={<oj-c-avatar initials="HW" size="xs" />}>
          World
          </GreetWithChildren>
      </div>
    );
  }

VComponentがそのクラスを介して参照される場合、次の親VComponentに示すように、仮想DOMノードをスロット・プロパティの値として直接指定することで、名前付きスロットのコンテンツが提供されます。

import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { h, Component, ComponentChild } from "preact";
import "oj-c/avatar";
import { GreetWithChildren } from "oj-greet/with-children/loader";

/**
 * @ojmetadata pack "oj-greet"
 * @ojmetadata dependencies {
 *   "oj-greet-with-children": "^1.0.0"
 * }
 */
@customElement("oj-greet-with-children-parent")
export class GreetWithChildrenParent extends Component<ExtendGlobalProps<Props>> {
  render() {
    return (
      <div>
        <p>This child is rendered as a VComponent class:</p>
        <GreetWithChildren startIcon={<oj-c-avatar initials="HW" size="xs" />}>
          World
        </GreetWithChildren>
      </div>
    );
  }
}

動的子およびスロットのコンテンツによるカスタム要素のリフレッシュ

仮想DOMアーキテクチャは、子またはスロットのコンテンツが動的に変更されたときにカスタム要素が正しく再レンダリングされるように、Remounterコンポーネントを提供します。

具体的な例は、Remounterコンポーネントを使用するタイミングを示しています。次の例では、開始アイコンのoj-c-button要素の表示は、showStartIconプロパティの値によって異なります。showStartIconプロパティが変更されたときに、oj-c-buttonによって開始アイコンが正しい位置にレンダリングされるようにする必要があります。

type Props = {
  showStartIcon?: boolean
}
 
function ButtonStartIconSometimes(props: Props) {
  return (
    <oj-c-button label="Click Me!">
      { props.showStartIcon && <span slot="startIcon" class="some-icon-class" /> }
    </oj-c-button>
  )
}

VDOMアーキテクチャのより適切なアプローチは、oj-c-buttonshowStartIconプロパティの様々な状態を反映する一意キーの割当てです。上の例では、可能な状態は2つしかないため、一意キーの割当ては簡単です。ただし、可能なすべての子状態に対して一意キーを生成する方が難しい場合もあります。このような難しいケースでは、Remounterコンポーネントを使用して、単一のカスタム要素の子をラップし、現在の子セットに基づいて一意のキーを生成します。次の修正後の例では、Remounterコンポーネントを使用して、oj-c-buttonが開始アイコンを正しい場所に再レンダリングするようにする方法を示します:

import { h } from 'preact';
import { Remounter } from 'ojs/ojvcomponent-remounter';
import "oj-c/button";
 
type Props = {
  showStartIcon?: boolean
}
 
function ButtonStartIconSometimes(props: Props) {
  return (
    <Remounter>
      <oj-c-button label="Click Me!">
        { props.showStartIcon && <span slot="startIcon">Start Icon</span> }
      </oj-c-button>
    </Remounter>
  )
}
 
export { ButtonStartIconSometimes };

Remounterコンポーネントを使用する必要があるのは、レンダリング間で子のタイプの数が変化するカスタム要素を構成する場合のみです。

テンプレート・スロット

JETコンポーネントは、単純な非コンテキスト・スロットに加えて、コンテキストを受信できるスロットもサポートしています。これらはテンプレート・スロットと呼ばれます。

テンプレート・スロットは、通常、データ・セットの反復処理を行うコレクション・コンポーネントにあり、アイテムまたは行ごとにコンテンツをスタンプ・アウトします。たとえば、<oj-list-view>は、各リスト・アイテムのコンテンツのレンダリング方法を制御するitemTemplateスロットを公開します。HTML内では、slot属性を持つtemplate要素は、次の例のようにテンプレート・スロットを指定します:

<oj-list-view  data="[[ items ]]">
  <template slot="itemTemplate" data-oj-as="item">
    <div>
      <oj-bind-text value="[[item.data.value]]"></oj-bind-text>
    </div>
  </template>
</oj-list-view>

VComponent APIカスタム要素では、テンプレート・スロットを公開することもできます。この動作を理解するには、挨拶する名前の配列を取得し、名前ごとに挨拶をレンダリングするグリーティング・コンポーネントを検討します。プロパティ宣言は次のようになります:

class Props {
  names: Array<string>
}

名前を反復して各項目のコンテンツをレンダリングできます:

protected render() {
  return (
    <div>
      { this.props.names.map(name => <div>Hello, {name}!</div>) }
    </div>
  );
}  

上記のアプローチでは、各挨拶のレンダリング方法に関する決定がコンポーネント実装にハードコーディングされます。かわりに、アプリケーションで各挨拶のレンダリング方法をカスタマイズできるようにするテンプレート・スロットを公開することで、より柔軟にすることができます。

シンプルな非コンテキスト・スロットと同様に、テンプレート・スロットは、よく知られたタイプTemplateSlotのプロパティとして宣言されます。このタイプのエイリアスを見てみましょう:

export type TemplateSlot<Data> = (data: Data) => Slot;

TemplateSlotは、単一の引数(テンプレートの特定のインスタンスをレンダリングするときに使用するデータ)を受け入れる汎用関数タイプです。このタイプのデータは、Dataタイプ・パラメータを通じて定義されます。これは、TemplateSlotプロパティの宣言時に指定する必要があります。

レンダリングをオプションのgreetingTemplateスロットに委任する、グリーティング・コンポーネントの新しいバージョンを次に示します:
oj-greet/hello-many.tsx:    
1    import { h, Component } from 'preact';
2    import { customElement, ExtendGlobalProps, TemplateSlot } from 'ojs/ojvcomponent';
3
4    export type GreetingContext = {
5      name: string;
6    }
7
8    type Props = {
9      names: Array<string>;
10      greetingTemplate?: TemplateSlot<GreetingContext>;
11    }
12
13    /**
14     * @ojmetadata pack "oj-greet"
15     */
16    @customElement('oj-greet-hello-many')
17    export class GreetHelloMany extends Component<ExtendGlobalProps<Props>> {
18      render() {
19        return (
20          <div>
21            {
22              this.props.names.map((name) => {
23                return this.props.greetingTemplate?.({ name }) ||
24                         <div>Hello, { name }!</div>
25              })
26            }
27          </div>
28        );
29      }
30    }

このサンプルでは、行10のgreetingTemplateスロットを宣言しています。Dataタイプ・パラメータはオブジェクト・タイプである必要があります。サンプルでは、行4で宣言したGreetingContextタイプを使用しています。

行23で、names配列の各項目に対してテンプレート・スロット(null以外の場合)が起動されます。サンプルでは、起動のたびにGreetingContextタイプのオブジェクトが渡されます。または、スロットが指定されていない場合は、行24のデフォルトのコンテンツが返されます。

HTML内でのテンプレート・スロット・コンテンツの指定

VComponent実装でテンプレート・スロットを公開した後、HTML内で、slot属性を使用して<template>要素を指定することにより、JETカスタム要素と同じスロット・コンテンツを指定します。template要素内で、JETバインディング式と要素を使用して目的の挨拶をレンダリングします:

<oj-greet-hello-many names="[[ ['Joel', 'Mike', 'Jonah' ] ]]">
  <template slot="greetingTemplate" data-oj-as="greeting">
    <div>
      Hi, <oj-bind-text value="[[ greeting.name ]]"></oj-bind-text>!
    </div>
  </template>
</oj-greet-hello-many>    

JSXのテンプレート・スロット

VComponent APIコンポーネント・クラス(<GreetHelloMany>など)を介してJSXでコンポーネントをレンダリングする場合、テンプレート・スロットはTemplateSlotコントラクトに準拠する関数として渡されます。つまり、データを受け取る関数としてテンプレート・スロットを実装し、単一の仮想DOMノードまたはノードの配列を返す必要があります。

これは次のようになります:

<GreetHelloMany names={names}
    greetingTemplate={ (data) => <div>Hello, { data.name}!</div> } 

もちろん、カスタム要素のタグ名を使用してGreetHelloManyコンポーネントを参照することもできます。

前のHTMLサンプルが示すように、カスタム要素テンプレート・スロットは、JETバインディング式(value="[[ greeting.name ]]"など)と、<template>要素内の要素(oj-bind-textなど)を使用して指定されます。このアプローチは、JETバインディングを使用して構成された他のコンテンツとともに、HTMLドキュメント内で適合しますが、JSXレンダリング関数の内部には適合しません。JSX内では、JETバインディング構文を使用してテンプレート・スロット・コンテンツを構成するのではなく、JSX構文が推奨されます。

JSXベースのレンダリング関数を使用してテンプレート・スロットのコンテンツを指定できるように、VComponent APIでは、<template>要素にVComponent固有の特別なプロパティ(renderプロパティ)が導入されています。このプロパティのタイプはTemplateSlotです。

これにより、JSXベースのレンダリング関数を使用して、カスタム要素上にテンプレート・スロットを構成できます。たとえば:

<oj-greet-hello-many names={names}>
    <template slot="greetingTemplate"
       render={ (data) => <div>Hello, { data.name}!</div> }/>
  </oj-greet-hello-many>

依然としてslot属性を指定して<template>要素を指定する必要があります。JETのバインディング構文でテンプレート・スロットを構成するのではなく、仮想DOMを返すTemplateSlot関数を指定します。

より完全な親コンポーネントでは次の情報が示されます:

oj-greet/hello-many-parent.tsx:
    
1     import { h, Component } from 'preact';
2     import { customElement, GlobalProps } from 'ojs/ojvcomponent';
3     import "oj-c/avatar";
4     import { GreetHelloMany, GreetingContext } from 'oj-greet/hello-many/loader';
5
6     /**
7      * @ojmetadata pack "oj-greet"
8      */
9     @customElement('oj-greet-hello-many-parent')
10    export class GreetHelloManyParent extends Component<ExtendGlobalProps<Props>> {
11      render() {
12
13        const names = [ 'Joel', 'Mike', 'Jonah' ];
14
15        return (
16          <div>
17            <p>This child is rendered as a custom element:</p>
18            <oj-greet-hello-many names={names}>
19              <template slot="greetingTemplate" render={ this.renderGreeting }/>
20           </oj-greet-hello-many>
21           <br />
22            <p>This child is rendered as a VComponent class:</p>
23            <GreetHelloMany names={names} greetingTemplate={ this.renderGreeting }/>
24          </div>
25        );
26      }
27
28      private renderGreeting(data: GreetingContext) {
29        const name = data.name;
30        const firstInitial = name.charAt(0);
31        const greeting = name.length < 5 ? 'Hey' : 'Hi';
32
33        return (
34          <p class="centerAlignVertical">
35            <oj-c-avatar size="xxs" initials={ firstInitial } />
36            {greeting}, { name }!
37          </p>
38        );
39      }
40    }

前述の例では、renderプロパティによって、<oj-greet-hello-many>カスタム要素にJSXベースのコンテンツが指定され、これは期せずしてVComponent APIで実装されます。ただし、このプロパティは、JETコンポーネントのテンプレート・スロット・コンテンツを構成する際にも使用できます。たとえば、このカスタム要素がVComponent APIで実装されていない場合でも、次のように<oj-list-view> itemTemplateスロットを構成できます:

protected render() {
  <oj-list-view  data={ this.props.items }>
    <template slot="itemTemplate"
     render={ ( item ) => { return <div>{ item.data.value }</div> } } />
  </oj-list-view>  

イベントおよびアクションの理解

2つの用語は、最初は相互に代替可能なように見えますが、VComponent APIではeventactionは2つの異なる意味を持ちます:

  • eventは、dispatchEventをコールすることでディスパッチされるDOMイベントを明示的に指します。
  • actionは、イベントに似たAPIに対する高レベルの抽象化であり、DOMレベルでのイベントのディスパッチに実際に関与する場合とそうでない場合があります。

この差異は、VComponent APIコンポーネント・インスタンスが2通りの方法で使用できるために発生します:

  • カスタム要素として、文字列タグ名を使用します。
  • VComponent APIコンポーネントとして、コンポーネント実装クラスを使用します。

VComponent APIコンポーネントをカスタム要素として使用した場合、アクションを呼び出すとDOMイベントがディスパッチされます。

ただし、実装クラスを介してVComponent APIコンポーネントを参照する場合、DOMイベントは作成またはディスパッチされません。かわりに、親コンポーネントによって提供されるアクション・コールバックが直接呼び出されます。

これらの異なる使用モデルをサポートするには、DOMイベントより高いレベルの抽象化が必要です。VComponent APIアクションは、その抽象化を提供します。

簡略化のために、アクションという用語の使用は、VComponent APIコンポーネント・インスタンスがアクティビティを外部に通知する一般的な動作を指します。イベントという用語の使用は、カスタム(またはプレーンな古いHTML)要素によってディスパッチされるDOMイベント専用に予約されています。

リスナー

イベント・リスナーは、DOM eventを受け取り、戻り値がない関数です。VComponentは、標準のHTMLイベントとカスタム要素のカスタム・イベントをリスニングして応答できます。

イベント・リスナーに使用する命名規則は、標準のHTMLイベントとカスタム・イベントのどちらをリスニングするかによって異なります。

clickchangemouseoverなどの標準のHTMLイベントの場合は、命名規則on<UpperCaseStandardEventName>を使用するプロパティ名を追加します。次の例は、clickイベントのイベント・リスナーを登録する方法を示しています。

render() {
  return <div onClick={this._handleClick}>Click Me!</div>
}

<oj-c-button>ojActionイベントなどのカスタム・イベントの場合は、on<customEventName>命名規則を使用します。次の例は、ojActionイベントのイベント・リスナーを登録する方法を示しています。

protected render() {
   return <oj-c-button label="Click Me!" onojAction={this._handleAction}>
                <oj-c-button>
  }

標準イベントと比べると、カスタム・イベント名の最初の文字は大文字にならないことに注意してください(onojActiononClick)。

VComponentインスタンスへのイベント・リスナー・アクセスを有効にする方法はいくつかあります。たとえば、イベント・リスナー関数でbind(this)を明示的にコールするか、または次のいずれかの方法を使用できます:

  • render()メソッドで、矢印関数をインラインで定義して使用します。
  • クラス・メソッドは、コンストラクタ内でバインドおよび保存できます。
  • 矢印関数は、クラス・フィールドで宣言および格納できます。

最後の2つのオプションは、render()メソッドのコールごとに新しい関数を作成することを避け、すべてのrender()メソッドにわたって同じ関数インスタンスを使用することで、render()メソッドのコールごとにDOM addEventListenerおよびremoveEventListenerコールが発生する原因となる仮想DOMの差異を回避します。次の例に示すクラス・フィールド・アプローチ(private _handleEvent)は、もう少し簡潔です。

import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";
import { Component } from "preact";
import "oj-c/button";

type Props = Readonly<{
  message?: string;
}>;

@customElement("oj-greet-with-listeners")
export class GreetWithListeners extends Component<ExtendGlobalProps<Props>> {

  render() {
    return (
      <div>
        <div onClick={this._handleEvent}>
          <p>Hello, World!</p>
        </div>
        <oj-c-button label="Click Me!" onojAction={this._handleEvent}></oj-c-button>
      </div>
    );
  }

  private _handleEvent = (event: Event) => {
    console.log(`Received ${event.type} event`);
  };
}

アクション

カスタム要素またはコンポーネント・クラスとしてVComponentを使用する場合の動作が異なるため、eventactionを区別する必要があります。

VComponentでは、eventdispatchEventのコールを介してディスパッチされるDOM Eventsを指します。actionはイベントに類似したAPIに対する上位レベルの抽象化であり、DOMレベルでのイベントのディスパッチが実際に関係する場合とそうでない場合があります。VComponentをカスタム要素として使用する場合、アクションを呼び出すとDOMイベントがディスパッチされますが、コンポーネント・クラスを使用したときにDOMイベントは作成またはディスパッチされません。かわりに、後者の場合、親VComponetによって提供されるアクション・コールバックは直接呼び出されます。VComponentアクションは、この2つの使用モデルをサポートするために必要なDOMイベントより高いレベルの抽象化を提供します。

アクションの宣言

VComponent定義イベントに応答して、ディスパッチするアクションを定義できる必要があります。

次の例は、クリックなどのユーザー・アクションに応答してディスパッチされるresponseDetectedイベント・アクションをVComponentに追加する方法を示しています。Propsクラスにフィールドを追加します。追加するフィールドは、標準のイベント・リスナー・プロパティ命名規則(on<UpperCaseEventName>)に従い、ojs/ojvcomponentモジュールで定義されたアクション・タイプを使用する必要があります。

import { customElement, ExtendGlobalProps, Action } from 'ojs/ojvcomponent';
    type Props = {
      preferredGreeting?: string;
      // This is an action declaration:
      onResponseDetected?: Action;
    }

アクションのディスパッチ

VComponentのアクション・タイプはコールバック関数です。

export type Action<Detail extends object = {}> = (detail?: Detail) => void;

アクションをディスパッチするために、VComponentはアクション・タイプのプロパティを関数として呼び出します。アクションは通常、いくつかの基礎となるイベントに応答してディスパッチされます。次の例では、VComponentインスタンスはクリックに応答してresponseDetectedアクションをディスパッチします:

private _handleClick = (event: MouseEvent) => {
   this.props.onResponseDetected?.();
 }

コンポーネントのコンシューマがonResponseDetectedの値を指定する必要はありません。そのため、this.props.onResponseDetectedをNULL値から保護する必要があります。これを行うには、オプションの連鎖演算子(?.)に対するTypeScriptのサポートを利用できます。これにより、提供されている場合にアクション・コールバックを呼び出し、そうでない場合は短絡することができます。より詳細なnullチェックは不要です。

アクションへの応答

呼び出されたアクションへの応答は使用方法によって異なります。

VComponentが(HTMLドキュメント内、または親VComponentのJSX内で)カスタム要素として使用されている場合、VComponentフレームワークでは、カスタム要素を介してディスパッチされるDOM CustomEventが作成されます。イベント・タイプは、on接頭辞を削除し、最初の文字を小文字にすることで、アクション・プロパティの名前から導出されます。たとえば、onResponseDetectedアクションを実行すると、responseDetected DOMイベント・タイプがディスパッチされます。

VComponentが親VComponentによって使用され、カスタム要素名ではなくクラス名によって参照されている場合、DOMイベントは作成されません。親VComponentがアクション・プロパティの値を指定する場合、これは直接呼び出されます。

JSXでは、アクション・コールバックは常にアクション・プロパティ名を使用して指定されます。これは、親がクラスのカスタム要素名によって子VComponentを参照するかどうかに関係なく当てはまります:

<p>This child is rendered as a custom element:</p>
<oj-greet-with-actions onresponseDetected={this.handleResponse}/>

<p>This child is rendered as a VComponent class:</p>
<GreetWithActions onResponseDetected={this.handleResponse}

ただし、カスタム要素がHTMLドキュメント内に存在する場合、イベント・リスナーは通常、JETのイベント・バインディング構文で登録されます。これは次のようになります:

<oj-greet-with-actions on-response-detected="[[ expressionPointingToEventHandler ]]">…      
            </oj-greet-with-actions>

DOM addEventListener APIを直接コールすることもできます。

アクション・ペイロード

アクションがDetailタイプ・パラメータを持つ汎用タイプであることにお気づきかもしれません。Detailタイプ・パラメータは、アクションがアクション・タイプ以外に追加情報を提供する必要がある場合に役立ちます。

たとえば、グリーティング・コンポーネントは、緊急性を示すためにresponseDetectedアクションとともにフラグを含めることができます。これは、次のようにDetailタイプ・パラメータを使用して宣言されます:

type Props = {
  preferredGreeting?: string;
  onResponseDetected?: Action<{
  urgent: boolean;
 }>;
}

アクションを呼び出すと、次の例のように、詳細ペイロードがアクション・コールバックに引数として渡されます:

private _handleClick = (event: MouseEvent) => {
   // Pass in a detail payload.  Determine urgency based on
   // number of clicks.
   this.props.onResponseDetected?.({
     urgent: event.detail > 1
  });
}

次の例のように、Detailタイプ・パラメータはタイプ・エイリアスを使用して指定することもできます:

export type ResponseDetectedDetail = {
  urgent: boolean;
};

type Props = {
  preferredGreeting?: string;
  onResponseDetected?: Action<ResponseDetectedDetail>;
};
 

消費側では、詳細ペイロードへのアクセス方法がわずか1つあります。カスタム要素の場合、アクション・コールバックはDOM EventListenerとして登録されます。つまり、次の形式を使用する場合です:

<oj-greet-with-actions onresponseDetected={this.handleEventResponse}/>

コールバックは、実際のDOMイベント・リスナーとして機能するため、CustomEvent<Detail>タイプの単一のevent引数を受け取ります。ただし、VComponentクラス・フォームを使用する場合:

<GreetWithActions onResponseDetected={this.handleActionResponse}/>

コールバックは、CustomEvent<Detail>ではなくDetailタイプの1つの引数を再び受け取ります。カスタム要素の使用法とVComponentクラスの使用法の違いは、確かに明らかではありません。使用可能な場合は、VComponentクラス・フォームを使用することをお薦めします。

状態プロパティの管理

コンポーネントは、プロパティを介して反映されない内部状態を追跡できます。VComponent APIは、これをサポートする状態メカニズムを提供します。

VComponent APIカスタム要素は、親コンポーネントによってコンポーネントに渡されるプロパティのみに基づいて、レンダリングするコンテンツを決定できます。これは、親コンポーネントによって完全に制御されるVComponent APIコンポーネントに役立ちます。ただし、一部のコンポーネントは、渡されないがコンポーネント内にローカルに存在し、状態の変化に基づいてコンテンツをレンダリングする、独自の内部状態プロパティを利用できます。VComponentの作成者は、これらの場合にPreactのローカル状態メカニズムを利用できます。

状態の宣言

ローカル状態フィールドを宣言するプロセスは、VComponent APIプロパティを定義する方法と非常によく似ています。どちらの場合も、タイプを宣言することから始めます。

この例では、ユーザーによる「完了」ボタンのクリックに応答して終了メッセージが表示されるように、コンポーネントを更新します。例では、この状態に達したかどうかを追跡するために実行されたブールのローカル状態フィールドを使用しています。

type State = {
   done: boolean;
   // Other state fields go here
};

プロパティ・タイプと同様に、ジェネリクスを利用して状態タイプをVComponent実装に関連付けます。VComponent APIコンポーネント・クラスは、次の2つのタイプのパラメータを公開します:

  • Props: 最初のタイプ・パラメータにより、プロパティ・オブジェクト・タイプが指定されます
  • State: 2つ目のタイプ・パラメータでは、Stateオブジェクト・タイプを指定します

両方のタイプ・パラメータを指定した新しい宣言は次のようになります:

export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
    }

各VComponent APIコンポーネントは、this.stateフィールドを介してローカル状態にアクセスできます。初期化は次の2つの方法のいずれかで行うことができます。初期化は次の2つの方法のいずれかで行います。

VComponent APIコンポーネント・インスタンスにコンストラクタがある場合は、そこでローカル状態を初期化します:

export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
      constructor() {
        this.state = {
          done: false
        };

        // Do other construction-time work here
      }

または、TypeScriptではクラス・フィールドのインライン初期化がサポートされます。コンストラクタが不要な場合は、この若干コンパクトな形式の方が適切に機能します:

export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
      state = {
        done: false
      };

      // Component implementation goes here
   }

ローカル状態を宣言および初期化すると、レンダリング関数内から参照して、仮想DOMコンテンツのレンダリング方法を調整できます。たとえば、このサンプルは、会話が"完了"した場合に別のメッセージをレンダリングするグリーティング・コンポーネントを示しています:

render(props: Props, state: State) {
        // Derive greeting message off of the "done" state field
        const greeting = state.done ?
          'Goodbye' :
          props.preferredGreeting;

        return (
          <div onClick={this._handleClick}>
            <p>{greeting}, World!</p>
          </div>
        );
      }

状態の更新

ローカル状態の更新には、コンポーネントの再レンダリングをトリガーするという重要な副次的効果があります。

this.stateReadonlyタイプとして宣言されています。コンストラクタ内のthis.stateへの初期割当て(またはクラス・フィールドの初期化)以外のthis.stateおよびthis.stateのフィールドは直接変更しないでください。

かわりに、状態の更新は、PreactコンポーネントのsetStateメソッドをコールすることで実行されます。このメソッドには2つの形式があります。最初の形式は、単に新しい状態を表すオブジェクトを取得します。たとえば、次のようにコールしてdone状態フィールドを更新できます:

this.setState({ done: true });

このコールによって状態の更新がキューに入れられ、新しい状態を持つコンポーネントの再レンダリング(非同期)がトリガーされます。

サンプルには単一の状態フィールドのみがありますが、コンポーネントは任意の数のフィールドを持つことができます。setState()メソッドは、疎移入されたオブジェクトを受け入れます。すべての状態フィールドの値を指定する必要はありません。新しい値を指定すると、現在の状態の上にマージされます。

次のバージョンのグリーティング・コンポーネントは、列挙型に基づく数値カウンタを使用して、エンド・ユーザーのエンゲージメントを追跡するように変更されました。3回のクリック後、グリーティング・コンポーネントは会話を終了します。

oj-greet/with-state/with-state.tsx:

import { h, Component } from "preact";
import { customElement, ExtendGlobalProps } from "ojs/ojvcomponent";

type Props = {
  preferredGreeting?: string;
};

enum EngagementLevel {
  Interested,
  Bored,
  Impatient,
  Done,
}

type State = {
  engagement: EngagementLevel;
};

/**
 * @ojmetadata pack "oj-greet"
 */
@customElement("oj-greet-with-state")
export class GreetWithState extends Component<ExtendGlobalProps<Props>, State> {
  state = {
    engagement: EngagementLevel.Interested,
  };

  render() {
    const greeting = this.getGreeting();

    return (
      <div onClick={this._handleClick}>
        <p>{greeting}, World!</p>
      </div>
    );
  }

  private getGreeting() {
    let greeting;

    switch (this.state.engagement) {
      case EngagementLevel.Bored:
        greeting = "Okay";
        break;
      case EngagementLevel.Impatient:
        greeting = "Whatever";
        break;
      case EngagementLevel.Done:
        greeting = "Later";
        break;
      default:
        greeting = this.props.preferredGreeting;
        break;
    }

    return greeting;
  }

  private _handleClick = (event: MouseEvent) => {
    this.setState((state: Readonly<State>) => {
      // Once we have reached the Done state, we return null
      // to indicate that no state update is needed.
      return state.engagement === EngagementLevel.Done
        ? null
        : { engagement: state.engagement + 1 };
    });
  };

  static defaultProps: Partial<Props> = {
    preferredGreeting: "Hello",
  };
}

状態メカニズムの理解

setState()のオブジェクトベースの形式の潜在的な問題の1つは、状態フィールドの新しい値が以前の値から導出された場合に発生します。このメソッドの非同期的な性質を考慮すると、this.stateを検査するだけでは、次の値を決定するのに十分ではない可能性があります。まだ完全には処理されていないsetState()への未処理のコールがある場合、this.stateは保留中の更新を反映しない可能性があります。

状態フィールドの次の値が前の値に依存しているケースのサポートを向上させるために、setState()はコールバック・フォームをサポートしています。コール元は、新しい状態を表すオブジェクトを渡すのではなく、現在の状態とプロパティの2つの引数を受け取る関数を渡します。このコールバック関数は、状態とプロパティを調べ、次のいずれかの値を返すことができます:

  • 適用する状態更新を表す疎移入されたオブジェクト

    または:

  • null (状態の更新が不要な場合)。

setState()への複数のコールが発行されると、状態の更新が連鎖されます。つまり、1回のコール(オブジェクトまたはコールバック形式)の結果は、次のコールバックにフィードされます。これにより、コールバックは常に最新の値を参照し、この情報を使用して次の値を正しく生成できます。

値による子VComponentの参照

VComponent子コンポーネントは、JSX内から次の2つの方法で参照できます:

// Use intrinsic element name
function Parent() {
   return <some-comp />;
}

// Use value-based element to reference 
// the VComponent class or function value
function Parent() {
   return <SomeComp />
}

次の理由から、可能な場合は常に値ベースの要素を使用することをお薦めします:

  1. VComponentのDOM要素が作成される前でも仮想DOMでレンダリングを行うことができるため、若干効率的です。
  2. スロットやリスナーなど、特定のAPIを使用するための、React/Preact中心のアプローチを提供します。(詳細については後述します。)
  3. 単一のプロジェクト内で作業する場合(つまり、VComponentと消費コードの両方が同じプロジェクトにある場合)、プロジェクト・ソースでVComponentクラス・タイプ情報に直接アクセスできます。クラスの型定義を生成するために、ビルドに依存することはありません。

ポイント2について詳しく説明すると、VComponentをその固有の要素名で参照する場合、次の例に示すように、この構文をスロットに使用するように制限されます:

function Parent() {
 <some-comp>
  // This is a plain slot:
  <img src="foo.png" slot="startIcon" />

  // This is a template slot:
  <template slot="itemTemplate render={ renderItem } />
 </some-comp>
}

値ベースの要素アプローチを使用すると、同じ結果をより簡潔に、React/Preactのバックグラウンドを持つアプリケーション開発者にとって使い慣れた形式で実現できます:

function Parent() {
   <SomeComp startIcon={ <img src="foo.png" } itemTemplate={ renderItem }>
}

値ベースの要素フォームを使用する際のイベント・リスナーへの参照も、React/Preact開発者が期待する内容とより合致したものになります。たとえば、固有の要素の場合、Preactのカスタム・イベントの命名規則に従う必要があります。したがって、イベント名がsomeCustomEventの場合、最終的に次のリスナー・プロパティになります:

function Parent() {
 <some-comp onsomeCustomEvent={ handleSomeCustomEvent }>
}

値ベースの要素を参照する場合、次のようになります:

function Parent() {
 <SomeComp onSomeCustomEvent={ handleSomeCustomEvent }>
}

結論として、可能なかぎり値ベースの要素を使用してください。DOM要素と直接対話する必要がある場合は、次の例のように、固有の要素フォームを使用し、その参照を取得します:

function Parent() {
    const someRef = useRef(null);
    return <some-comp ref={ someRef } />;
}