小さな規模のアプリしか作らないこともあり、フロントエンドに関しては頑なにフレームワークを使わない勢の私は、FastAPI、webpack、wasm(Rust)までに手を出しつつも、未だに素のTypescriptで何とかしようとしている(Typescriptの段階で「素」では無いかも知れないが)。
とくにDOMに関しては以前の記事にあるようなヘルパ関数を使用して、比較的構造的に組み立てられる仕組みを自作し、利用している。下記のように作成・追加したエレメントを編集(属性や子要素を追加する)する関数で、引数の関数の中でさらに要素を追加して属性設定やさらに子要素追加するなど、入れ子構造でコーディング出来る。
/**
* ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
*/
HTMLElement.prototype.editElement = function(func) {
func(this);
return this;
}
/**
* ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
*/
HTMLElement.prototype.appendChain = function(child) {
if(child instanceof HTMLElement){
this.appendChild(child);
} else if (typeof(child) === "string") {
this.append(child);
}
return this;
}
/**
* ③ 子要素をクリアしたのち自身を返す
*/
HTMLElement.prototype.clearElement = function() {
this.innerHTML = "";
return this;
}
/**
* ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
*/
HTMLElement.prototype.appendElement = function(tag, func) {
const elm = document.createElement(tag);
if(func) func(elm);
this.appendChild(elm);
return this;
}
/**
* ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
*/
Document.prototype.createElementEdit = function(tag, func) {
const elm = document.createElement(tag);
if(func) func(elm);
return elm;
}
let body = document.querySelector('article').clearElement();
body.editElement(bdy => {
bdy.className = 'bg-secondary';
bdy.appendElement('p', para => {
para.style.color = 'red';
para.textContent = 'test string';
});
});
⇒
<article class="bg-secondary">
<p style="color: red;">
test string
</p>
</article>
これをTypescriptに移植するのは結構面倒だったので、今回のメインネタにしました。究極には全部anyの型にして押し切ってしまえば動くと言えば動くんですが、せっかくならしっかり作ってエディタサポートの恩恵も得られて何ならjavascriptよりも使いやすくしてしまおうということで下記のように移植しました。
/**
* インターフェース定義:組込みのHTMLElementやDocumentに追加するために declare global する
*/
declare global {
interface HTMLElement {
appendElement<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement
editElement<T extends HTMLElement>(func: (elem: T) => void): T
appendChain<T extends HTMLElement>(child: HTMLElement | string): T
clearElement<T extends HTMLElement>(): T
}
interface Document {
createElementEdit<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K]
}
}
/**
* ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
*/
HTMLElement.prototype.editElement = function<T extends HTMLElement>(func: (elem : T) => void) : T {
console.log(this.constructor.name)
func(this as T);
return this as T;
}
/**
* ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
*/
HTMLElement.prototype.appendChain = function<T extends HTMLElement>(child: HTMLElement | string) : T {
if(child instanceof HTMLElement){
this.appendChild(child);
} else if (typeof(child) === "string"){
this.appendChild(document.createTextNode(child));
} else {
throw new TypeError("parameter node: must be string or HTMLElement.");
}
return this as T;
}
/**
* ③ 子要素をクリアしたのち自身を返す
*/
HTMLElement.prototype.clearElement = function<T extends HTMLElement>() : T {
assertIsInstanceOf(this, HTMLElement);
this.innerHTML = "";
return this as T;
}
/**
* ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
*/
HTMLElement.prototype.appendElement = function<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement {
assertIsInstanceOf(this, HTMLElement);
const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
if(func) func(elm);
this.appendChild(elm);
return this;
}
/**
* ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
*/
Document.prototype.createElementEdit = function<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
func(elm);
return elm;
}
いろいろ試行錯誤した結果、上記のように落ち着きました。Typescirptでメソッド追加するためにはInterfaceを定義する必要があるけど、HTMLElementなど組込みのクラスに対して普通にインターフェースを追加で定義しようとしてもエラーになるようなので、上記のようにdeclare globalで定義する必要があるようです。あとは普通にprototypeで追加していくのだが、これも型定義が割と面倒で、createElement系などは引数のタグ名で戻り値の型が決まる変則ジェネリクスなので、typescriptのパッケージの中身を参考にほぼコピペしました(④、⑤)。
また、①や②は、関数でエレメントを編集したり、子要素を追加して自身を返す関数で、メソッドチェーン用に使用していたのですが、HTMLElementへのインターフェースとして実装している関係でthisが常にHTMLElementになってしまう問題があります。例えば<a></a>
のリンクタグを追加しようとしても、createElementしたときはHTMLAncorElementであっても関数内で利用する時はHTMLElementとして扱われるのでthis.href
メンバーが使えないなど。この場合、一旦unknownにしてHTMLAnchorElementにキャストし直す(element as unknown as HTMLAnchorElement
)か、下記のような関数でアサートすることで解決出来ます。
/**
* val が 指定のクラス cls のインスタンスであることをアサートする関数
* 例えば HTMLELementにキャストされた下位クラス(HTMLInputELementなど)を、もとのクラスとしてアサート
*/
export function assertIsInstanceOf<T>(val: any, cls: new() => T) : asserts val is T {
if(!(val instanceof cls)){
throw new Error(`${val} is not instance of cls`);
}
}
例)
document.createElement('a').editElement(link => {
link.href = '#'; // ---- × ここではまだlinkはHTMLElementでhrefメンバがないのでエラー
assertIsInstanceOf(link, HTMLAnchorElement);
link.href = '#'; // ---- ○ HTMLAnchorElementとしてアサートされているのでOK
});
とは言ってもイチイチアサートするのも面倒な仕様なので、イマイチ移植できていない感があります。
まぁ、実際使用していると、こういう引数が長くなりがちな関数に対してメソッドチェーンを利用しても読みにくくなってしまうだけなので、これらは使用しないということで封印の方向で進んでいますw
エレメントをサクッと作成するdocument.createElementEdit(tag, func)
と、構造的にサクッと子要素を追加するelement.appendElement(tag, fuc)
だけあれば全然問題がないという結論に達しつつある感じ。