ES6とDOMの件:Typescript編

小さな規模のアプリしか作らないこともあり、フロントエンドに関しては頑なにフレームワークを使わない勢の私は、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)だけあれば全然問題がないという結論に達しつつある感じ。

Webpackその他

メモ記事とはいえなかなか書いてる時間がとれず一月以上の間が空いてしまった。FastAPIによるバックエンド実装からTypescriptとSASSによるフロントエンド実装の勉強に入った所、WebAssemblyとその有力言語のRustに興味が写ってしまい、そのあたりを勉強しながらフロントエンドに組み込む技術を調査していたら、Webpackという技術に突き当たった。

WebAssemblyとRustに関しては別の機会に書くとして、今回はWebpack。いままで何度か単語程度に見かけはしたが、なんとなくお腹一杯感でスルーしていたが、必要に応じて調べてみると目からウロコ的な技術だった。

従来の静的コンテンツ(ブラウザ側で処理をする所謂フロントエンド)は、HTMLベースでスタールシート(CSS)やJavascriptを必要に応じて読み込む形式だったが、Webpack化することで、大まかには一つのJavascriptにまとめられる。

例えば下記のようなindex.htmlでは

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
  <style type="text/css">
    @import "/static/css/bootstrap-custom.css";
  </style>
  <script type="module">
    import { Alert, Button, Carousel, Collapse, Dropdown, Modal, Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip } from "/static/js/bootstrap.esm.js"
  </script>
</head>
<body class="base">
  <script type="module">
    import {init} from "/static/js/app.js"
    window.addEventListener("load", init());
  </script>
</body>
</html>

6行目〜でbootstrapのカスタムスタイルシート(これは別途SASSでコンパイルしている)を読み込んで、その下9行目〜でbootstrap用のスクリプトをモジュールとして読み込んでいる。Bodyタグを読み込んだあとで14行目〜でシングルページアプリケーション用スクリプトapp.jsを読み込んでページロード完了後にinitを実行(window.addEventListener)。app.jsもTypescriptのソースから別途コンパイルしておりアプリケーションの規模によっては多くのモジュールに分かれてロードされる。

こういった構成のものが、Webpack化することで下記のように出来る

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
    <script src="/static/dist/main.js"></script>
</head>
<body class="base">
</body>
</html>

6行目のmain.js を読み込むだけとなる。main.jsはWebpackによってコンパイルされたjavascriptで読み込むべきリソースが全てパック化された生成物となる。ソースファイルは一般にindex.jsとされ、下記のような内容となる

import '../scss/bootstrap-custom.scss';
import 'bootstrap/dist/js/bootstrap.esm.js';

import {init, build} from '../ts/app';

init();

window.addEventListener("load", () => {
  build();
});

一行目でbootstrapのカスタムスタイルシートを読み込むが、ここではscssのソースファイルを直接インポートして、WebpackのプラグインでSassコンパイラを起動する。生成したスタイルシートをインポートすると呼び出し側のHTMLのHEADタグ内にSTYLEタグを生成して直接コーディングするため、CSSファイルは生成されない。

2行目はbootstrap用のスクリプトをインポート。4行目でシングルページアプリケーションスクリプトを読み込むが、これもTypescriptのソースを直接していして内部でコンパイラを起動している。また、従来構成と違って読み込んだ時点ではBODYタグが生成されていないため、DOM関連の初期化があると失敗する。DOM関連の初期化はloadイベントをトリガとして始めるよう工夫が必要。

とりあえず、Webpackを導入するとこうなるというだけのメモで、Webpackの具体的な設定などはまた別の機会に。