趣味のコンポーネント生活 2023-09
趣味のコンポーネント生活 / 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層」を実現していて目から鱗、早速まねをしてみたいと。..
趣味のコンポーネント生活 / 2023-09-09.liv /
( ... 可変長引数 ) の文法さえ分かれば.. と思って、VanJSのコードを覗いてみることに。
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」を見ると..
...
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エレメントが返される仕組みのようで、これも真似してみたくなりました。
可変長引数のやり方も分かり、めでたし。
「型」を考慮したエレメントファクトリー。
VanJSでは、JSで自由なエレメント生成関数[群]をつくり、TS側で「エレメントの種類の数だけ」定義を記述していましたが、「エレメントの種類の数だけ」を避けるためにつかえそうな定義群が、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 で「嘘をついて」、コーディング時の入力候補など、型ガードの恩恵を受けながらエレメント生成関数が作れそうです。
入力候補もばっちり。
ためしに、メンバーの型が boolean で HTMLElementTagNameMap のキーを持つオブジェクトで、入力候補ごっこを。..
type Factory < T > =
{
[ type in keyof T ] : boolean ;
};
const ef = {} as Factory < HTMLElementTagNameMap >;
入力候補もばっちり出てくれて、TSのありがたみ。
エレメントタイプごとのプロパティーを。
「Factory」の boolean を Partial < T[ type ] > に変えて、<エレメント名>.<プロパティー名>を出してみます。
思い通りの候補が出てくれました。
( as でズルしているので実行時にはエラーになります。)
さて、よりファクトリーをよりファクトリーにするため、メンバーを関数にしてみます。
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」ライクになってきました。(喜び)。
実エレメントをつくるか、定義オブジェクトだけ先につくるか ..
趣味のコンポーネント生活 / 2023-09-09.liv /
さて、型サポートもそれなりにある、HTMLエレメント作成🌲のめどがたってきましたが、「コンポーネント関数」で実エレメントを作ってしまうか、「このように作って」と「エレメント定義の木」を先に作り、あとで「実エレメント作成関数」に一括で渡すか..
実エレメントだけではなく、HTMLテキストもレンダリングさせてみたいなら後者がいいですし、資源のオーバーヘッドは増しますが、好みでもあるので後者で行ってみましょう。
「エレメント作成指示オブジェクト」のデータ型をまず定義。
グローバル空間の Element と名前がかぶったりするので「Defs」という名前空間に「Element」とその内容である「Props」と「Part ( childNode )」を作ります。
ファクトリーから生やす関数の定義が「CreateElement」で、ここで可変長の引数への「Props」と「Part[]」の詰め込みを定義しています。
「Props < E >」は、a や input など、エレメントタイプ< E >に応じたプロパティを持ちます。
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": []
}
]
}