Open6

趣味のコンポーネント生活 2023-09

hirospherehirosphere

趣味のコンポーネント生活 / 2023-09-03.liv /

JavaScriptで個人用のHTMLアプリケーションを作りたいけれど、「そのための『記述・実現スタイル』をどうすべきか」あたりを延々とさまよっている中年男性の日記です。

作ろうと思っているアプリケーションは、自宅鉄道模型の制御や自動運転、地震観測波形をオーディオとして再生するアプリなどです。

はじめからSPA指向で、

VC++ 6.0でMFCやDirectXなどを理解しようと数年触れ、2005年頃からHTML・JavaScript・PHP(Apache)を知り、C++ではとてもわずわらしい「部品・要素クラスからのメッセージ返送」が劇的に楽になるJavaScriptが大変気に入り、IE6.0でMFCのような「機能する画面」をつくりたく、「spanにspanを入れ子した、無茶flex」で簡単なSPAメモをつくったり..

Windows MFCプログラミングの経験から、HTML・JavaScriptでも自然とSPA指向で、MFCの「Doc-View」のように「モデル→ビューの自動反映」なども取り入れようと試行。

「いつかExcelに取って替わる万能シングルページアプリケーションを!」などと夢想しながら、いままで「ナビゲーション+コンテント」だけのメモSPA程度の実績です。

React? Vue?

さて昨今、ReactやVueなどのコンポーネントライブラリの文字が目を惹く、高額報酬の求人を目にするようになり、SPAが世の中にひろく認められるようになってうれしく思い、ちょっとReactを試してみましたが、..

「ハロー・ワールド」をするだけでいきなり300MBほどのプロジェクトサイズに閉口し、「宣言的記述と、さらにその関数化で楽ができる」はずなのに、仮想DOMとコンポーネント関数化のためのフックのせいで、見通しがシュレッダーにかけられたような混乱を感じ、「簡単なことを簡単に実現する」からは遠ざかるので、やはり「バニラ」なJSで「リアクティブ」な「コンポーネント」を追求しようと、気が向いた休日に少しずつ河原の石積みをしています。

VanJS

入れ子・コンポジションである「エレメントの記述」をJSONでやろうとすると、

const def =
{
	type: "div",
	childNodes:
	[
		{ type: "p", childNodes: [ "こんにちは!" ] }
	]
};

のように、HTMLでは1層で済むインデントが2層になってしまうなやみがありますが、先日知った軽量コンポーネントライブラリ「VanJS」では、関数の引数に「attributes」も「childNode」もリストしてしまうやり方で「インデント1層」を実現していて目から鱗、早速まねをしてみたいと。..

https://zenn.dev/ryo_kawamata/articles/1ad6e51eed13ae

hirospherehirosphere

趣味のコンポーネント生活 / 2023-09-09.liv /

( ... 可変長引数 ) の文法さえ分かれば.. と思って、VanJSのコードを覗いてみることに。

van-1.2.0.d.ts の抜粋
export type TagFunc<Result> = (first?: Props | ChildDom, ...rest: readonly ChildDom[]) => Result

interface Tags extends Readonly<Record<string, TagFunc<Element>>> {
  // Register known element types
  // Source: https://developer.mozilla.org/en-US/docs/Web/HTML/Element

  // Main root
  readonly html: TagFunc<HTMLHtmlElement>

  // Document metadata
  readonly base: TagFunc<HTMLBaseElement>
  readonly head: TagFunc<HTMLHeadElement>
  readonly link: TagFunc<HTMLLinkElement>
  readonly meta: TagFunc<HTMLMetaElement>
  readonly style: TagFunc<HTMLStyleElement>
  readonly title: TagFunc<HTMLTitleElement>

  // Sectioning root
  readonly body: TagFunc<HTMLBodyElement>

  // Content sectioning
  readonly h1: TagFunc<HTMLHeadingElement>
  readonly h2: TagFunc<HTMLHeadingElement>
  readonly h3: TagFunc<HTMLHeadingElement>
  readonly h4: TagFunc<HTMLHeadingElement>
  readonly h5: TagFunc<HTMLHeadingElement>
  readonly h6: TagFunc<HTMLHeadingElement>

  // Text content
  readonly blockquote: TagFunc<HTMLQuoteElement>
  readonly div: TagFunc<HTMLDivElement>
  readonly dl: TagFunc<HTMLDListElement>

  ...
}

export interface Van {
  readonly state: <T>(initVal: T) => State<T>
  readonly val: <T>(s: T | StateView<T>) => T
  readonly oldVal: <T>(s: T | StateView<T>) => T
  readonly derive: <T>(f: () => T) => State<T>
  readonly add: (dom: Element, ...children: readonly ChildDom[]) => Element
  readonly _: (f: () => PropValue) => () => PropValue
  readonly tags: Tags
  readonly tagsNS: (namespaceURI: string) => Readonly<Record<string, TagFunc<Element>>>
  readonly hydrate: <T extends Node>(dom: T, f: (dom: T) => T | null | undefined) => T
}

declare const van: Van

export default van

TypeScriptも覚えたいと4月から始めているので、とりあえず.d.ts を覗くと「tagFunc<>」がエレメント作成関数だな!、と実装の「van-...js」を見ると..

van-1.2.0.js の抜粋
...

let tagsNS = ns => new Proxy((name, ...args) => {
  let [props, ...children] = protoOf(args[0] ?? 0) === objProto ? args : [{}, ...args]
  let dom = ns ? doc.createElementNS(ns, name) : doc.createElement(name)
  for (let [k, v] of Obj.entries(props)) {
    let getPropDescriptor = proto => proto ?
      Obj.getOwnPropertyDescriptor(proto, k) ?? getPropDescriptor(protoOf(proto)) :
      _undefined
    let cacheKey = name + "," + k
    let propSetter = propSetterCache[cacheKey] ??
      (propSetterCache[cacheKey] = getPropDescriptor(protoOf(dom))?.set ?? 0)
    let setter = propSetter ? propSetter.bind(dom) : dom.setAttribute.bind(dom, k)
    let protoOfV = protoOf(v ?? 0)
    if (protoOfV === stateProto) bind(() => (setter(v.val), dom))
    else if (protoOfV === funcProto && (!k.startsWith("on") || v._isBindingFunc))
      bind(() => (setter(v()), dom))
    else setter(v)
  }
  return add(dom, ...children)
}, {get: (tag, name) => tag.bind(_undefined, name)})

...

export default {add, _, tags: tagsNS(), tagsNS, state, val, oldVal, derive, hydrate}

「tagFunc」という名前の関数はなく、その実体らしい関数をさしたProxyが「tags」としてエクスポートされており..

「tags」オブジェクト(関数)に「p」や「div」「h1」など任意の名前でアクセスすると、そのHTMLエレメントが返される仕組みのようで、これも真似してみたくなりました。

可変長引数のやり方も分かり、めでたし。

hirospherehirosphere

「型」を考慮したエレメントファクトリー。

VanJSでは、JSで自由なエレメント生成関数[群]をつくり、TS側で「エレメントの種類の数だけ」定義を記述していましたが、「エレメントの種類の数だけ」を避けるためにつかえそうな定義群が、TSライブラリの「lib.dom.d.ts」にあった気がしたので見てみると ..

lib.dom.d.ts の一部
interface HTMLElementTagNameMap {
    "a": HTMLAnchorElement;
    "abbr": HTMLElement;
    "address": HTMLElement;
    "area": HTMLAreaElement;
    "article": HTMLElement;
    "aside": HTMLElement;
    "audio": HTMLAudioElement;
    "b": HTMLElement;
    "base": HTMLBaseElement;
    "bdi": HTMLElement;
    "bdo": HTMLElement;
    "blockquote": HTMLQuoteElement;
...

ありました!

これを基に as と Proxy で「嘘をついて」、コーディング時の入力候補など、型ガードの恩恵を受けながらエレメント生成関数が作れそうです。

hirospherehirosphere

入力候補もばっちり。

ためしに、メンバーの型が boolean で HTMLElementTagNameMap のキーを持つオブジェクトで、入力候補ごっこを。..

type Factory < T > =
{
	[ type in keyof T ] : boolean ;
};

const ef = {} as Factory < HTMLElementTagNameMap >;

入力候補もばっちり出てくれて、TSのありがたみ。

エレメントタイプごとのプロパティーを。

「Factory」の boolean を Partial < T[ type ] > に変えて、<エレメント名>.<プロパティー名>を出してみます。

思い通りの候補が出てくれました。
( as でズルしているので実行時にはエラーになります。)

hirospherehirosphere

さて、よりファクトリーをよりファクトリーにするため、メンバーを関数にしてみます。

Proxy を導入し、get をトラップして( ... any[] ) ながら本当の関数を返します。

type CreateElement < E > = ( ... args : any [] ) => void ;

type ElementFactory < E > =
{
	[ type in keyof E ] : CreateElement < E[ type ] > ;
};

const handler : ProxyHandler < ElementFactory < HTMLElementTagNameMap > > =
{
	get( factory, type )
	{
		console.log( type );
		return ( ... args : any []) => { console.log( type, args ); }
	}
}

const ef = new Proxy( {} as ElementFactory < HTMLElementTagNameMap >, handler );

const { div, h1, p, a } = ef;

div
(
	{ class: "app" },
	h1( "かんたん シングルページアプリ" ),
	p( "所沢の、その先 Reactivity..", a( { href: "./" }, "クリック" ) ),
)

いい感じに「VanJS」ライクになってきました。(喜び)。

hirospherehirosphere

実エレメントをつくるか、定義オブジェクトだけ先につくるか ..

趣味のコンポーネント生活 / 2023-09-09.liv /

さて、型サポートもそれなりにある、HTMLエレメント作成🌲のめどがたってきましたが、「コンポーネント関数」で実エレメントを作ってしまうか、「このように作って」と「エレメント定義の木」を先に作り、あとで「実エレメント作成関数」に一括で渡すか..

実エレメントだけではなく、HTMLテキストもレンダリングさせてみたいなら後者がいいですし、資源のオーバーヘッドは増しますが、好みでもあるので後者で行ってみましょう。

「エレメント作成指示オブジェクト」のデータ型をまず定義。

グローバル空間の Element と名前がかぶったりするので「Defs」という名前空間に「Element」とその内容である「Props」と「Part ( childNode )」を作ります。

ファクトリーから生やす関数の定義が「CreateElement」で、ここで可変長の引数への「Props」と「Part[]」の詰め込みを定義しています。

「Props < E >」は、a や input など、エレメントタイプ< E >に応じたプロパティを持ちます。

component.ts
type gE = globalThis.Element;

namespace Defs
{
	export type CreateElement < E > = ( first ? : Props < E > | Part, ... rest : Part [] ) => Element ;

	export type Element < E extends gE = gE > =
	{
		type : string ;			// "div" "p" などの「タグ名」
		props ? : Props < E > ;	// "id"  "href"  "value" などのプロパティー
		parts ? : Part [] ;		// "childNodes" と名づけるべきですが、好みで。
		[ isElement ] : true ;	// Props とのユニオンやその他の識別のため。
	};

	const isElement = Symbol();

	export type Props < E = {} > =
	{
		[ prop in keyof E ] ? : E [ prop ] ;
	};

	export type Part = Element | Value ;

	type Value = string ;

	//  //

	export const createElement = ( type : string, first ? : Props | Part, ... rest : Part [] ) : Element =>
	{
		if( typeof first == "object" && ! ( isElement in first ) )
		{
			return { type, props: first, parts: rest, [ isElement ]: true };
		}
		return { type, parts: first ? [ first, ...rest ] : undefined, [ isElement ]: true };
	}
}

そして、要の関数「createElement()」は、type とその後の可変長引数に応じた

{ type: "a", props: { href: "./" }, parts: [ "クリック!" ] }

のような、Defs.Element オブジェクトを返します。

プロキシ利用のファクトリー

TypeScriptで型を考慮しながら使う「Proxy」とは、どんな具合になるのかと思いましたが、以下のコードでコンパイルは通りました。

type ElementFactory < Es > =
{
	[ type in keyof Es ] : Defs.CreateElement < Es[ type ] > ;
};

const handler : ProxyHandler < ElementFactory < HTMLElementTagNameMap > > =
{
	get( factory, type )
	{
		console.log( type );

		if( typeof type != "string" ) return;

		const fn = ( first : Defs.Props | Defs.Part, ... rest : Defs.Part [] ) =>
		{
			console.log( `* ${ type }` );
			return Defs.createElement( type, first, ... rest );
		};

		return fn;
	}
}

const ef = new Proxy( {} as ElementFactory < HTMLElementTagNameMap >, handler );

「ファクトリー」の利用例

そして、ここで出来た「ef : ElementFactory < HTMLElementTagNameMap > 」オブジェクトの利用例です。

const { div, h1, p, a, input } = ef;

const def = div
(
	{ className: "app" },
	h1( "かんたん シングルページアプリ" ),
	p( "所沢の、小手指の、その先、Reactivity..", a( { href: "./", }, "クリック" ) ),
	input
	(
		{
			type: "range",
			max: "500",
			tabIndex: 4,
			oninput( ev )
			{
				ev instanceof HTMLInputElement && console.log( ev.value )
			}
		}
	)
);

console.log( JSON.stringify( def, null, "    " ) );

無事、「エレメント作成指示オブジェクト」が生成されました..

{
    "type": "div",
    "props": {
        "className": "app"
    },
    "parts": [
        {
            "type": "h1",
            "parts": [
                "かんたん シングルページアプリ"
            ]
        },
        {
            "type": "p",
            "parts": [
                "所沢の、小手指の、その先、Reactivity..",
                {
                    "type": "a",
                    "props": {
                        "href": "./"
                    },
                    "parts": [
                        "クリック"
                    ]
                }
            ]
        },
        {
            "type": "input",
            "props": {
                "type": "range",
                "max": "500",
                "tabIndex": 4
            },
            "parts": []
        }
    ]
}