フロントエンドを勉強する中でフレームワークで使われてる要素技術を調べたので、Vanilla JS でフロントエンド フレームワークっぽいものを自作してみました。その時のメモです。

フロントエンド フレームワークに必要なもの

フロントエンド フレームワークっぽいものを作るとき、以下の技術を使うと便利と思います。

Custom Element と Shadow DOM は Web Component と呼ばれる一連の技術の1つです。詳細は MDN のサイトを参照してください。

Custom Element は名前の通り、HTML 標準では定義されていない独自のタグを定義する方法です。

Shadow DOM は HTML (<style> や <script> 含む) を元の HTML から分離するもので、コンポーネントで定義する HTML/CSS/JS を外部の HTML から分離できます。

Template Literal は文字列の中に変数やコードを埋め込むことができる機能です。Closure を使うことで柔軟な文字列操作ができるため、HTML コードを生成するのに使えます。

最後に、Proxy はオブジェクトのプロパティの変更を検知するために使います。プロパティが変化した時にイベント発行し、再描画することで画面上の表示を変える、といった操作ができます。

まずはこれらを1つずつ説明した後で、まとめて使う方法を見てみたいと思います。

Custom Element と Shadow DOM

Custom ElementShadow DOM は関連性が高いので一緒に説明します。

Custom Element

Custom ElementcustomElements.define(tag, constructor) というメソッドを呼ぶことで独自の HTML タグを定義できます。customElements 自体は window オブジェクトのプロパティのため、特に何も指定せずに使用できます。

引数の tag は定義するHTMLタグです。タグ名にはハイフン “-” が必要です。例えば <my-component> というタグを定義する場合、 "my-component" を渡します。

constructor 引数はタグの動作を定義するクラスです。通常このクラスは HTMLElement を継承します。何もしないとタグの中身は空なので、コンストラクタで必要な element をロードしたりします。コンストラクタは customElements.define() が呼ばれる時に実行されますが、Custom Element が DOM にロードされた時に呼ばれる connectedCallback() 等いくつかのライフサイクル コールバックを定義できます。

以下の例で見てみます。

html

<!DOCTYPE html>
<html>
  <head>
    <title>Custom Frontend Framework</title>
    <script type="text/javascript" src="./js/my-component.js"></script>
  </head>
  <body>
    <p>Below is the custom element</p>
    <my-component></my-component>
    <p>This is some random text</p>
  </body>
</html>

my-component.js

class MyComponent extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    this.innerHTML = `
      <style>
        p {
          font-size: 16px;
          color: #a00;
        }
      </style>
      <div>
        <p>My component inner text</p>
      </div>
    `;
  }
}

customElements.define("my-component", MyComponent);

この HTML をブラウザで表示すると、 <my-component> 内に Javascript で定義した HTML が表示されているのが確認できると思います。

Shadow DOM

上記の <my-component> の例では、全ての文字 (<p>) が赤く表示されたと思います。これは Custom Element 内で <style> を定義しているためで、MyComponent で定義した CSS が、元の HTML にも適用されてしまっています。

フロントエンド フレームワークでは、コンポーネント内で定義された CSS や JS はそのコンポーネントにのみ適用され、他のコンポーネントや外側の HTML には影響しない方が便利です。

このような場合に、Shadow DOM を使って HTML を分離することができます。

Shadow DOM では、ある要素を外側の HTML から分離します。分離される要素の root となる要素を Shadow Root と呼びます。Shadow Root を定義するには、分離する要素に対して attachShadow() を呼びます。

attachShadow() は引数にオブジェクトを取り、モードを指定することができます。open モードの Shadow DOM は、外側の Javascript から Shadow DOM 内の要素にアクセスできます。close モードの場合は、Shadow DOM 内の要素は外からアクセスすることはできません。

上記で作った <my-component> を Shadow DOM として定義してみます。

html

<!DOCTYPE html>
<html>
  <head>
    <title>Custom Frontend Framework</title>
    <script type="text/javascript" src="./js/my-component.js"></script>
  </head>
  <body>
    <p>Below is the custom element</p>
    <my-component></my-component>
    <p>This is some random text</p>
  </body>
</html>

my-component.js

class MyComponent extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({mode: 'closed'});
    shadowRoot.innerHTML = `
      <style>
        p {
          font-size: 16px;
          color: #a00;
        }
      </style>
      <div>
        <p>My component inner text</p>
      </div>
    `;
  }
}

customElements.define("my-component", MyComponent);

今回は全ての文字が赤くなるのではなく、<my-component> 内の文字だけ赤く表示されていると思います。これはShadow DOM を使うことで、 MyComponent で定義した <style> が MyComponent にのみ適用されたためです。

なお、今回はコンストラクタ内で innerHTML を設定しています。Shadow DOM を使わない場合、要素が DOM にロードされるまで (= connectedCallback() が呼ばれるまで) innerHTML を設定できないのですが、Shadow DOM として定義した場合は DOM にロードする前でも innerHTML を設定できるようになります。

Template Literals

Template Literals は Javascript で文字列内に変数やコードを埋め込める機能です。例えば以下のように使用できます。

Javascript

const myValue = "abc";
console.log(`My value is: ${myVlaue}`);
// 出力: My value is: abc

Template Literals には Tagged Template という機能もあり、Javascript の function をテンプレートの “タグ” として使用できます。この function を “Tag function” と呼び、引数にテンプレート文字列内に記述された string リテラルと expression が配列として渡されます。

以下の例で説明します。

Javascript

const myName = "John Smith";
const myAge = 20;
function myTagFunc(strings, ...args) {
  console.log(strings);
  console.log(args);
  return strings[0] + args[0] + strings[1] + args[1] + strings[2];
}
const output = myTagFunc `My name is ${myName}, I am ${myAge} years old.`;
console.log(output);

// 出力:
// [ "My name is ", ", I am ", " years old." ]
// [ "John Smith", 20 ]
// My name is John Smith, I am 20 years old.

この例では、myTagFunc() という Tag function を定義して Tagged Template を使っています。myTagFunc() に渡された引数を見ると、テンプレートの文字列が string リテラルの部分 ("My name is"", I am " など) と expression の部分 (${myName}${myAge}) に分けられて、それぞれの部分が配列で渡されているのがわかると思います。

string リテラルの配列と expression の配列を交互に連結すると、元のテンプレートで表現したかった文字列 ("My name is John Smith, I am 20 years old.") を得ることができます。

これを更に発展させ、Tag function で function を返すようにすることで、より柔軟な表現が可能になります。

Javascript

const myName = "John Smith";
const myAge = 20;
function myTagFunc(strings, ...args) {
  return function(additionalValue) {
    return strings[0] + args[0] + strings[1] + args[1] + strings[2] + additionalValue;  
  };
}
const templateFunc = myTagFunc `My name is ${myName}, I am ${myAge} years old.`;
const output = templateFunc(" This is additional text.");
console.log(output);
// 出力: My name is John Smith, I am 20 years old. This is additional text.

上記の例では、Tag function は別の function を返し、返却された function を実行することで新たな文字列をテンプレートに追加できました。Tag function から返却される function は Closure としてテンプレート内の string リテラルや expression を保持しているため、このような動作が可能となります。

これを使って、例えば以下のようなことができるようになります。

Javascript

function myTagFunc(strings, ...args) {
  return (arr) => {
    const results = [];
    arr.map((item) => {
      const result = [strings[0]];
      args.map((key, i) => {
        result.push(item[key]);
        result.push(strings[i+1]);
      });
      results.push(result.join(''));
    });
    return results.join('');
  };
}

const myValues = [{name: "name1", value: "value1"}, {name: "name2", value: "value2"}];
const myHtml = `<ul>` + myTagFunc`<li>Name: ${"name"}, Value: ${"value"}</li>`(myValues) + `</ul>`;
console.log(myHtml);
// 出力: <ul><li>Name: name1, Value: value1</li><li>Name: name2, Value: value2</li></ul>

これで、例えば配列の中身を HTML に埋め込むことも少しやりやすくなるでしょう。

Proxy

最後に Proxy です。Proxy はオブジェクトのプロパティへのアクセスに介入する仕組みを提供します。

Javascript

const myObj = {id: 1, value: "my value"};
const handler = { 
  get: function(target, prop, receiver) {
    return `Value returned from proxy: ${target[prop]}`;
  },
}
const proxy = new Proxy(myObj, handler);
console.log(proxy.value);
// 出力: Value returned from proxy: my value

上記の例では、handler に get() function が定義されています。これでプロパティの値を取得する時に処理を差し込むことができます。get() の引数には、Proxy の元となるオブジェクト (target)、アクセスされたプロパティ名 (prop)、そして Proxy オブジェクト自身 (receiver) が渡されます。

Proxy を介してオブジェクトのプロパティの値を取得しようとすると、handler に定義された get() が呼ばれる、という仕組みです。

同様にプロパティの値を設定する時にも、handler に set() を定義することで処理を差し込むことができます。

Javascript

const myObj = {id: 1, value: "my value"};
const handler = { 
  set: function(target, prop, value, receiver) {
    const newValue = `Value set from proxy: ${value}`;
    Reflect.set(target, prop, newValue, receiver);
  },
}
const proxy = new Proxy(myObj, handler);
proxy.value = "new value";
console.log(proxy.value);
//出力: Value set from proxy: new value 

set() では get() の引数に加え、value というパラメータも追加されています。これは新しく設定されようとしている値を表します。なお Reflect を使うと、Proxy での get、set を楽に実装できます。

Proxy の set() ハンドラーを使うことで、オブジェクトの値が変更された時に再描画を行い新しい値を表示する、といったことが可能になります。

まとめ

この記事ではフロントエンド フレームワークを自作する時に便利そうな機能を説明しました。Custom Element で独自タグを定義し、Shadow DOM でコンポーネントの HTML を分離し、Template Literals で HTML への配列の埋め込みを簡単にし、Proxy で値の変更を検知して再描画を行う、といった仕組みを使うことで、自前のフレームワークも作れるんじゃないかと思います。

最後に、ごくごく単純に実装した例を載せておきます。

my-frontend.js

export function register(tag, constructor) {
  customElements.define(tag, constructor);
}

export function tplFor(strings, ...props) {
  return (arr) => {
    const results = [];
    arr.map((item) => {
      const result = [strings[0]];
      props.map((key, i) => {
        result.push(item[key]);
        result.push(strings[i+1]);
      });
      results.push(result.join(''));
    });
    return results.join('');
  };
}

export class ComponentBase extends HTMLElement {
  #shadowRoot = null;
  #proxy = null;
  #isBinded = false;

  constructor() {
    super();
    this.#shadowRoot = this.attachShadow({mode: 'closed'});
  } 

  bind() {
    if (this.#isBinded) {
      return;
    }

    this.#proxy = this.#convertToProxy(this);
    this.#isBinded = true;
  }

  #convertToProxy(obj) {
    if (!(obj instanceof Object)) {
      return null;
    }

    const handler = {
      set: (target, prop, value, receiver) => {
        const result = Reflect.set(target, prop, value, receiver);
        this.render();
        return result;
      },
    };

    Object.entries(obj).map(([key, value]) => {
      if (obj[key] instanceof Object) {
        obj[key] = new Proxy(value, handler);
        this.#convertToProxy(value);
      }
    });

    return new Proxy(obj, handler);
  }

  rootElement() {
    return this.#shadowRoot;
  }

  proxy() {
    return this.#proxy;
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.#shadowRoot.innerHTML = this.html();
    this.onRender();
  }

  html() { }
  onRender() { }
}

my-component.js

import { ComponentBase, register, tplFor } from "./my-frontend.js";

export class MyComponent extends ComponentBase {
  count = 0;
  myArray = [{value: "value1"},{value: "value2"}];

  constructor() {
    super();
    this.bind();
  }

  html() {
    return `
      <style>
        li { font-size: 16px; color: #a00; }
      </style>` +
      `<button id="testButton">Test Button</button>` +
      `<ul>` +
        tplFor`<li>My value: ${"value"}</li>`(this.myArray) +
      `</ul>`;
  }

  onRender() {
    const root = this.rootElement();
    if (root) {
      root.getElementById("testButton").addEventListener("click", () => { 
          this.count++;
          this.proxy().myArray.push({value: this.count});
        });
    }
  }
}

register("my-component", MyComponent);

html

<!DOCTYPE html>
<html>
  <head>
    <title>Custom Frontend Framework</title>
    <script type="module" src="./js/my-component.js"></script>
  </head>
  <body>
    <my-component></my-component>
  </body>
</html>