React ユーザーのための lit

thumbnail

lit を使う機会があったので、「React でやる、あれをどうするのか」というメモを残しておく。

前提

環境構築めんどくさいのでplayground で試す。

state

Reactive properties という章がドキュメントにある通り、Lit には Reactive が備わっている。

class MyElement extends LitElement {
  @property()
  name: string;
}

このようにクラスフィールドに @property() を付けるとそのフィールドが reactive になる。

これはクラスフィールドとしては public であるため、呼び出し元から書き換えることができてしまう。 つまり責務が閉じていない。

つまり、

const myElement = document.querySelector("my-element");
console.log(myElement.name); // world
myElement.name = "ojisan";

のようなコードを書けてしまう。これは React で言えば親が子のコンポーネントの状態を触れてしまうということで変に依存を生み、テストなどを考えるとあまり嬉しくない。

そこで lit ではこれの責務が閉じたモードも用意されている。これが React における state 相応のものだ。 そのためにはフィールドを protected にした上で、@property() ではなく @state() を使う。

@state()
protected _active = false;

こうすれば protected なので継承しない限りは触れなくなる。

この説明では public property を悪のように書いたが、実 DOM を手続的に操作したいときは使いたい機能ではあるので、なんでもかんでも @state を使えという主張はしない。

props

クラスフィールドで指定した値は親から受け取れる。 ただし @property が必要である。 それは親から受け渡す以上は、親からそのフィールドが見えている必要があるからだ。 つまり @state だと動かない。

OK

import {html, css, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  @property()
  protected name = 'Somebody';

  render() {
    return html`<p>Hello, ${this.name}!</p>`;
  }
}

NG

import {html, css, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  @state()
  protected name = 'Somebody';

  render() {
    return html`<p>Hello, ${this.name}!</p>`;
  }
}

ここでデータを渡す時の方法だが、普通に値を渡すとそれは文字列として渡されてしまう。 実際には JSON を渡したい、イベントハンドラを渡したいはずだ。もちろんそのようなユースケースにも対応できる。

それは渡すときの attribute に魔法の記号をつければ良い。

Expressionsにある通り、boolean なら ? , property(要するに data) なら ., event handler なら @ をつければ良い。

import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement("my-element")
class MyElement extends LitElement {
  @property()
  bodyText = "Text in child expression.";
  @property()
  label = "A label, for ARIA.";
  @property()
  editing = true;
  @property()
  value = 7;

  render() {
    return html`
      <!-- Child  expression -->
      <div>Child expression: ${this.bodyText}</div>

      <!-- attribute expression -->
      <div aria-label=${this.label}>Attribute expression.</div>

      <!-- Boolean attribute expression -->
      <div>
        Boolean attribute expression.
        <input type="text" ?disabled=${!this.editing} />
      </div>

      <!-- property expression -->
      <div>
        Property expression.
        <input type="number" .valueAsNumber=${this.value} />
      </div>

      <!-- event listener expression -->
      <div>
        Event listener expression.
        <button @click="${this.clickHandler}">Click</button>
      </div>
    `;
  }
  clickHandler(e: Event) {
    this.editing = !this.editing;
    console.log(e.target);
  }
}

イベントハンドラを渡すことができれば、親の状態更新を子が呼び出すといった react でよく見る dispatch を実現できるので覚えておこう。

lifecycle

React のライフサイクルと lit のそれは厳密には異なるが、考え方はほとんど同じなのでごちゃ混ぜにして説明する。厳密な仕様は公式ドキュメントを参考して欲しい。

FYI: https://lit.dev/docs/components/lifecycle

componentDidMount

React でいう componentDidMount は

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  @state()
  protected name = 'Somebody';

  connectedCallback() {
  super.connectedCallback()

  this.name = "ほげえ"
}

  render() {
    return html`<p>Hello, ${this.name}!</p>`;
  }
}

だ。こうすることで @state が付いたものを初期化できる。

もしくは constructor を使っても良い。

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  @state()
  protected name = 'Somebody';

  constructor(){
    super();
    this.name = "aaa"
  }


  render() {
    return html`<p>Hello, ${this.name}!</p>`;
  }
}

これは 「え、でもクラスフィールド宣言時に値を代入すれば良いだけじゃん」って思うかもしれない。 その意見は正しいが、decorator を使わない場合は class field には

class MyElement extends LitElement {
  static properties = {
    mode: { type: String },
    data: { attribute: false },
  };
}

しか現れないので、component の初期化する手順が必要となる。

それも「decorator 使えば良いじゃん」と思うかもしれないが、過去に decorator は仕様が巻き戻ったり、安定化するという確証を持てないので decorator を封じて開発したいモチベーションがあるかもしれない。実際 lit のドキュメントにも decorator を使わない場合の開発方法も指南 ()されているので、decorator を使う前提を絶対とするのはやめたほうが良い。

componentDidUpdate

componentDidUpdate は React では コンポーネントが更新されたときに DOM を操作する API だ。これは親から渡される props が変わった時に、その値に応じて何かしらの処理をトリガーしたい時に使える。

componentDidUpdate(prevProps) {
  // 典型的な使い方(props を比較することを忘れないでください)
  if (this.props.userID !== prevProps.userID) {
    this.fetchData(this.props.userID);
  }
}

このような処理は lit だと updated を使える。

updated(changedProperties) {
  if (changedProperties.has('collapsed')) {
    this._measureDOM();
  }
}

この例だと、 changedPropertiescollapsed を key にもつ Map であり、changedProperties.get('collapsed') とすればアクセスできる。ただ、何が入ってくるは静的に知りたいので TypeScript で型を付けよう。その型は PropertyValueMap<T> であり、T には 該当の Lit コンポーネントが入る。

class MyElement extends LitElement {
  @property()
  name: string;

  updated(changedProperties: PropertyValueMap<MyElement>) {
    if (changedProperties.has("name")) {
      const prevName = changedProperties.get("name");
      const currentName = this.name;
    }
  }
}

こうすると changedProperties には props の 型が MyElement のフィールドの型を伝って分かる。

おわりに

ひとまず state, props, lifecycle があれば React16.2 の DX で開発することができるので頑張ろう。