👁️

Beako.jsのライフサイクルとイベント

14 min read

開発中の仮想DOMライブラリBeako.jsにカスタムイベントdestroyおよびpatchイベントをv0.9.14にて実装しました。
VueやReactのライフサイクルメソッドに該当します。
DOMの直接操作(いわゆるRef)もイベントで実装できます。

Beako.jsの使い方はこちら
Denoで使えるJavaScript仮想DOMライブラリを作った

https://github.com/ittedev/beako

本記事ではコンポーネントと要素の関係やカスタムイベントを使う際の注意点まで細かく説明しています。

更新歴

2022/1/19 エンティティのプロパティnodeをcssに合わせてhostに変更しました。
2022/1/19 エンティティのonoffメソッドを廃止しました。

コンポーネントの実体

各カスタムイベントの説明に移る前にコンポーネントの実体について説明します。
Beako.jsのコンポーネントはカスタム要素として定義されたり、<beako-entity>タグのcomponent属性に指定したりして実体を持ちます。そのため一つのコンポーネントが複数の実体を持つことがあります。
次のようにHTMLを書くと、同じコンポーネントを使ったそれぞれ別のオブジェクトがそれぞれのカスタム要素と一緒に生成されます。

  <custom-element />
  <custom-element />
  ...

このオブジェクトのことをBeako.jsではエンティティと名付けています。エンティティは必ず1つのHTML要素に紐づいており、プロパティを受け取るのも、イベントを受け取るのも紐づいたDOM要素を介して行われます。

flowchart LR
    c1[Component]
    c1--->e1
    c1--->e2
    subgraph n1[Element]
    e1[Entity]
    end
    subgraph n2[Element]
    e2[Entity]
    end

コンポーネント、エンティティ、エンティティに紐づいたDOM要素は厳密には異なりますが区別せずにコンポーネントと呼ぶことが多いです。

コンストラクタ

エンティティが生成されたときに、コンポーネントがコンストラクタを持つ場合、そのコンストラクタが呼び出されます。
compact関数の第2引数にデータではなく関数またはプロミスを返す関数(async関数)を渡すと、コンストラクタとなります。

const component = compact(
  'Template here',
  () => {
    /* 処理 */
  }
)

コンストラクタは戻り値にテンプレート内で利用するデータを指定することができます。

const component = compact(
  'Hello {{ name }}',
  () => {
    return { name: 'Beako' }
  }
)

コンストラクタの特徴として、プロミスを返す関数(async関数)のときはプロミスが解決するまでテンプレートの描画を行いません。これにより、外部からのデータをインポートする必要があるときなどに、テンプレート描画時にデータがあることを保証します。

次の例はipify APIを使って、IPアドレスが取得できるのを待ってから、ipアドレスを表示します。

ipify API使った例
const component = compact(
  'IP: {{ ip }}',
  () => fetch('https://api.ipify.org?format=json').then(res => res.json())
)

エンティティを参照する

コンストラクタの第1引数としてエンティティ参照できます。

const component = compact(
  'Template here',
  entity => {
    /* 処理 */
  }
)

エンティティが持つプロパティは分割代入で受け取って利用できます。

const component = compact(
  'Template here',
  ({ host, root }) => {
    /* 処理 */
  }
)

このとき、hostプロパティはエンティティに紐づいた要素です。rootプロパティは仮想DOMを展開しているルートとなります。
現時点でBeako.jsは要素のシャドウルートに仮想DOMを展開しているためhost.shadowRoot === roottrueとなります。host === roottrueとなる状況は検討段階です。

host.addEventListener

コンストラクタ内でエンティティのhost.addEventListenerを使って、エンティティに紐づいた要素でイベントをキャッチできます。

const component = compact(
  '<a>Click me</a>',
  ({ host }) => {
    host.addEventListener('click', event => {
      console.log('target:', event.target) // event.target === host --> true
    })
  }
)

Beako.jsを使用する上ではあまり意識する必要は無いかもしれませんが、上手くイベントをキャッチできないとき、注意していただきたいことは、hostはテンプレートが展開されているシャドウツリーの外に位置しているということです。上記のclickイベントのターゲットはaタグからエンティティと紐づいている要素すなわちhostに置き換えられます。
もし、シャドウツリー内でクリックされた要素を取得したいなら、rootを使うことができます

const component = compact(
  '<a>Click me</a>',
  ({ root }) => {
    root.addEventListener('click', event => {
      console.log('target:', event.target) // <a>Click me</a>
    })
  }
)

こういったシャドウツリーの内と外でイベントがどのように伝播するかについて、日本語で詳しく解説してくれているページがありますので参照ください。

Shadow DOMとイベント

host.removeEventListener

host.addEventListenerで設定するイベントハンドラは仮想DOMで管理されていません。もし、動的にイベントハンドラを削除する場合は、host.removeEventListenerを利用します。

const component = compact(
  '<a>Click me</a>',
  ({ host }) => {
    const clicked = event => {
      console.log('target:', event.target)
    }
    host.addEventListener('click', clicked)
    host.removeEventListener('click', clicked)
  }
)

Beako.jsのカスタムイベント

Beako.jsは独自のカスタムイベントを発行します。

destroyイベント

destroyイベントは、仮想DOMがDOMに反映されたときにDOMから削除されたすべての要素に送られます。event.detailは削除された仮想要素となるveプロパティのみをもつオブジェクトです。
なお、このイベントは伝播しません。次の例では2秒後にdestroyイベントが発火します。

const local = compact(
  'Template here',
  ({ host }) => {
    host.addEventListener('destroy', event => { // コンポーネントが破棄されたときにキャッチ
      console.log('destroyed:', event.detail.ve)
    })
  }
)

const component = compact(
  '<local @if="isActive"></local>',
  () => {
    const data = watch({
      isActive: true
    })
    setTimeout(() => data.isActive = false, 2000)

    return [data, { local }]
  }
)

destroyイベントはテンプレート外では発生しない

destroyイベントはあくまで仮想DOM内で要素が削除されたときに送られます。言い換えれば、テンプレート内に書いた要素でのみ発生します。hack関数でターゲットにした要素や、define関数で作ったカスタム要素をテンプレートを介さずHTMLに直に書いたりdocument.createElement()メソッドを使って呼び出されたりした場合は発生しません。
JavaScriptは言語仕様でオブジェクトが破棄されるタイミングを検知できず、それはカスタム要素でも同様です。カスタム要素にはdisconnectedCallbackがありますが、それは破棄とは全く異なり、DOMから要素が切り離されたときに発生するもので、例えば要素の位置を移動するだけのときも発生する可能性があります。
仮想DOMでDOMが管理されている場合は仮想DOMから要素が失われるタイミングがはっきり検知できますのでdestroyイベントが必ず発生します。
もし、setInterval()WebSocketを使っていて、必ずdestroyイベントをキャッチする必要がある場合は他のコンポーネントのテンプレート内でローカルコンポーネントとして利用してください。ローカルコンポーネント以外でのコンポーネント利用を制限する場合はseal関数のオプションでisLocaleOnlyプロパティをtrueにしてください。

seal(component, { localeOnly: true })

patchイベント

patchイベントは、仮想DOMがDOMに反映されたときに仮想DOMツリーのルートに送られます。Beako.jsのWebコンポーネントを使っているとき、対象はシャドウルートになります。
event.detailは仮想DOMツリーのクローンとなるtreeプロパティのみをもつオブジェクトです。なお、このイベントはShadow DOMを突き抜けて伝播します。

const component = compact(
  'Template here',
  ({ host }) => {
    host.addEventListener('patch', event => {
      console.log('patched:', event.detail.tree)
    })
  }
)

patchイベントは、伝播するという特性から、ローカルコンポーネントのpatchイベントも取得してしまいます。自コンポーネントのみに絞り込むにはevent.composedPath()[0]がrootと一致するか検証します。

const local = compact('Template here')

const component = compact(
  '<local>Template here</local>',
  ({ host, root }) => {
    host.addEventListener('patch', event => {
      if (event.composedPath()[0] === root) {
        console.log('patched:', event.detail.tree)
      }
    })
    return { local }
  }
)

patchイベントで子コンポーネントの仮想DOMツリーにアクセスする場合は多くの注意点があります。本記事最後の「patchイベントを利用した仮想DOMアクセスの注意点」を参照してください。

connectイベント

このイベントは検討段階で未実装です。

カスタム要素のライフサイクルのうち、connectedCallbackが呼ばれたら自身にconnectイベントを発行します。

disconnectイベント

このイベントは検討段階で未実装です。

カスタム要素のライフサイクルのうち、disconnectedCallbackが呼ばれたら自身にdisconnectイベントを発行します。

カスタムイベントのタイプ名変更

Beako.jsはよく使われる英単語であるdestroypatchといったイベントタイプ名を使います。この名称は他のライブラリ等のカスタムイベントと競合する可能性があります。eventTypesオブジェクトで名称を変更することができます。

import { compact, eventTypes } from './beako.js'

eventTypes.destroy = 'beako-destroy'
eventTypes.patch = 'beako-patch'

const component = compact(
  'Template here',
  ({ host }) => {
    host.addEventListener('beako-patch', event => {
      console.log('patched:', event.detail.tree)
    })
  }
)

eventTypesオブジェクトはObject.seal()されているため値の変更以外の操作ができません。また、1文字以上の文字列以外を設定しようとするとエラーをスローします。
もし、複数の環境で利用される可能性があるコンポーネントを作る場合は、eventTypesオブジェクトを使ってイベントタイプを参照してください。

import { compact, eventTypes } from './beako.js'

const component = compact(
  'Template here',
  ({ host }) => {
    host.addEventListener(eventTypes.patch, event => {
      console.log('patched:', event.detail.tree)
    })
  }
)

Beako.jsでDOM要素を直接操作する

patchイベントは仮想DOMの差分が修正された、最新状態の仮想DOMツリーを引数として受け取ります。そのため、仮想DOMを介して要素を探すことができます。もちろん、rootから、querySelector()メソッド等で参照することも可能です。利用する際は仮想DOMを破壊しないように注意してください。
これは主に次のような用途で利用します。

  • 子コンポーネントを参照してメソッドを実行する場合
  • <canvas>のように子を持たない要素を参照して独自の機能を構築する場合

Beako.jsはReactやVue.jsのRefに該当する機能を提供していません。リアルDOMもしくは仮想DOMから自身の用途に合わせて要素を検索する必要があります。理由はBeako.jsにRefsをどのように導入するかの検討を参考ください。良い方法が見つかったら実装します。

以降は仮想DOMツリーから要素を検索する方法です。

一つの要素を探す

find関数は仮想DOMツリーから条件に一致した最初の要素を取得します。

JavaScript
import { compact, find } from './beako.js'

const component = compact(
  '<div ref="target">abc</div>',
  ({ host, root }) => {
    let ref = null

    host.addEventListener('patch', function setRef(event) {
      if (event.composedPath()[0] === root) {
        const ve = find(event.detail.tree, ve => ve?.props?.ref === 'target')
        if (ve) {
          ref = ve.node
          console.log('ref:', ref)
          host.removeEventListener('patch', setRef)
        }
      }
    })
  }
)
TypeScript
import type { LinkedVirtualElement } from 'https://deno.land/x/beako/mod.ts'
import { compact, find } from './beako.ts'

const component = compact(
  '<div ref="target">abc</div>',
  ({ host, root }) => {
    let ref = null

    host.addEventListener('patch', function setRef(event: CustomEvent): void {
      if (event.composedPath()[0] === root) {
        const ve = find(event.detail.tree, ve => ve?.props?.ref === 'target') as LinkedVirtualElement
        if (ve) {
          ref = ve.node
          host.removeEventListener('patch', setRef)
        }
      }
    })
  }
)

複数の要素を探す

trace関数は仮想DOMツリー内の全ての要素を辿り、コールバック関数を一度ずつ実行します。

import { compact, trace } from './beako.js'

const component = compact(
  `
    <div ref="target">abc</div>
    <div ref="target">def</div>
  `,
  ({ host, root }) => {
    const refs = []
  
    host.addEventListener('patch', function setRef(event) {
      if (event.composedPath()[0] === root) {
        trace(event.detail.tree, ve => {
          if (ve?.props?.ref) {
            refs.push(ve.node)
          }
        })
        console.log('refs:', refs)
        host.removeEventListener('patch', setRef)
      }
    })
  }
)

子コンポーネントも含めて要素を検索する

event.composedPath()の検証を行わなければ、他のコンポーネントの仮想DOM内にある要素も検索できます。これには注意点がありますので、本記事最後の**patchイベントを利用した仮想DOMアクセスの注意点**を参照してください。

import { compact, find } from './beako.js'

define('local-component', '<group @expand="content"></group>')

const component = compact(
  `
  <local-component>
    <div ref="target">abc</div>
  </local-component>
  `,
  ({ host, root }) => {
    host.addEventListener('patch', event => {
      const ve = find(event.detail.tree, ve => {
        return ve.props?.ref === 'target'
      })
      if (ve) {
        console.log(ve.node.innerHTML) // abc
      }
    })
  }
)

要素の変更を検知する

watch関数と合わせて使えば、要素の変更を検知できます。次の例ではdiv要素とp要素が1秒ごとに切り替わる際に仮想DOMが要素を作り直すため、refsの変更が発生します。もし、どちらも同じdiv要素の場合、中のテキストのみが更新されるため、refsの変更は初回のみしか発生しません。

import { compact, watch, trace } from './beako.js'

const component = compact(
  `
    <div ref="target" @if="isActive">abc</div>
    <p ref="target" @else>def</p>
  `,
  ({ host, root }) => {
    const data = watch({ isActive: true })
    const refs = watch({})
    
    watch(refs, 'target', el => {
      console.log('Changed', el)
    })

    host.addEventListener('patch', event => {
      if (event.composedPath()[0] === root) {
        const removeNames = new Set(Object.keys(refs))
        trace(event.detail.tree, ve => {
          if (ve?.props?.ref) {
            refs[ve.props.ref] = ve.node
            removeNames.delete(ve.props.ref)
          }
        })
        removeNames.forEach(name => refs[name] = undefined )
      }
    })
    
    setInterval(() => { data.isActive = !data.isActive }, 1000)

    return data
  }
)

子コンポーネントのメソッドを実行する

コンストラクタの処理が終了したエンティティは、データに渡された関数をカスタム要素のメソッドとして持ちます。例えばこのようにメソッドにアクセスできます。

define(
  'custom-element',
  'Counter: {{ count }}',
  () => {
    const data = watch({ count: 0 })
    const countUp = () => data.count++
    return [data, { countUp }]
  }
)

const el = document.createElement('custom-element')
el.whenConstructed().then(() => {
  el.countUp()
})

ここで、whenConstructed()はコンストラクタでデータが作成されたときに解決されるPromiseを返します。<beako-entity>の場合はcomponent属性が指定されるまでコンストラクタも実行されないため、whenConstructed()nullを返します。

関数以外のデータをコンポーネントに渡しても要素はプロパティとして持つことはありません。また、要素が持つ既存のプロパティ・メソッド名と名前が重複した場合は上書きされません。

よって、仮想DOMツリーから見つけた要素のメソッドを実行することができます。

JavaScript
define(
  'custom-element',
  'Counter: {{ count }}',
  () => {
    const data = watch({ count: 0 })
    const countUp = () => data.count++
    return [data, { countUp }]
  }
)

const component = compact(
  `
    <custom-element ref="target"></custom-element>
    <button onclick="countUp()">Count up</button>
  `,
  ({ host, root }) => {
    let ref = null

    host.addEventListener('patch', function setRef(event) {
      if (event.composedPath()[0] === root) {
        const ve = find(event.detail.tree, ve => ve?.props?.ref === 'target')
        if (ve) {
          ref = ve.node
          host.removeEventListener('patch', setRef)
        }
      }
    })
    
    const countUp = () => {
      if (ref) {
        ref.countUp()
      }
    }
    return { countUp }
  }
)

patchイベントが実行されるときはコンストラクタが解決されていることが保証されているため、whenConstructed()を待つ必要はありません。

patchイベントを利用した仮想DOMアクセスの注意点

patchイベントを利用して仮想DOMを参照すると、子コンポーネントのシャドウツリーにもアクセスできてしまう、非常に強力な手段です。しかしその分、隠蔽されたはずのシャドウツリーにアクセスしてしまうため、予期せぬ挙動が起こる可能性があります。
例えばref="target"属性が付いた要素を探すときに、自身のテンプレート内だけでなく子コンポーネントの中でもref="target"属性が利用されていた場合、その要素を取得してしまう恐れがあります。

コンポーネントを作る際に、親コンポーネントにそのようにしてDOMを触られたくない場合があるでしょう。
root.addEventListenerevent.stopPropagation()を使って伝播を止めることで、親コンポーネントに仮想DOMツリーを参照されることはなくなります。

const component = compact(
  'Template here',
  ({ root }) => {
    root.addEventListener('patch', event => {
      event.stopPropagation()
    })
  }
)

コンポーネントがseal関数で保護されている場合、Beako.jsは自動的にpatchイベントにevent.stopPropagation()を付与して伝搬を止めます。

seal(component)

このとき、親コンポーネント側ではevent.composedPath()[0]の検証を外しても子コンポーネントにアクセスできなくなります。子コンポーネントに@expand-@asで渡したコンテンツも参照できなくなります。slotとして渡す要素は依然として親コンポーネントの一部ですので参照できます。

Discussion

ログインするとコメントできます