@ledsun blog

無味の味は佳境に入らざればすなわち知れず

監視型MVCの残骸を解体するリファクタリング

監視型MVCで書いていたブラウザアプリケーションがあります。 個々のレンダリングロジックが高速化されたので、非同期にレンダリングするのをやめ、MVVM風に書き直しています。 レンダリングロジックをモデルの中に移動し、モデルの変更に応じて同期的に呼び出すように直しました。

課題

まだ、次のようなモデルの更新イベントに反応して、全インスタンス位置を再計算するレンダリングロジックを呼び出すイベントハンドラーが残っています。 この解体に取りかかりました。

https://github.com/pubannotation/textae/blob/803383bfc606c7077076e3c25bed58314347d06a/src/lib/editorize/start/View/index.js#L17-L51

    const debouncedUpdatePosition = debounce(() => {
      lineHeightAuto.updateLineHeight()
      this._annotationData.updatePosition()
    }, 100)
    const asyncUpdatePosition = () =>
      // If you delay the recalculation of the line height,
      // the span will move after the scrolling by the span focus.
      // This may cause the span to move out of the display area.
      // Calculate the line height with as little delay as possible
      // and after rendering the entities.
      requestAnimationFrame(() => {
        lineHeightAuto.updateLineHeight()
        this._annotationData.updatePosition()
      })

    eventEmitter
      .on('textae-event.annotation-data.all.change', debouncedUpdatePosition)
      .on('textae-event.annotation-data.entity.add', asyncUpdatePosition)
      .on('textae-event.annotation-data.entity.change', debouncedUpdatePosition)
      .on('textae-event.annotation-data.entity.remove', asyncUpdatePosition)
      .on('textae-event.annotation-data.entity.move', debouncedUpdatePosition)
      .on('textae-event.annotation-data.relation.add', debouncedUpdatePosition)
      .on('textae-event.annotation-data.attribute.add', debouncedUpdatePosition)
      .on(
        'textae-event.annotation-data.attribute.change',
        debouncedUpdatePosition
      )
      .on(
        'textae-event.annotation-data.attribute.remove',
        debouncedUpdatePosition
      )
      .on('textae-event.annotation-data.span.move', debouncedUpdatePosition)
      .on('textae-event.annotation-data.entity-gap.change', () =>
        this._annotationData.updatePosition()
      )

モデルの変更イベントを監視しています。 変更があると次のメソッドを呼び出して、すべてのインスタンスの位置を修正するレンダリングロジックを呼び出します。

this._annotationData.updatePosition()

すべてのインスタンスの位置を再計算すると負荷が馬鹿になりません。 負荷を減らすために、イベントを間引くdebounceを噛ましてあります。 これでも動くのですが、モデルのどの変更がどのレンダリングを引き起こすのかわかりにくいです。

また再計算の範囲が絞り込めれば、debounceする必要もなくなります。 debounceを使うと非同期処理になります。 非同期のレンダリングロジックは、モデルをいつ参照するかわかりません。 モデルが中途半端な状態な時にレンダリングをやめて再挑戦する工夫も必要になります*1。 これがなくなればソースコードも読みやすくなりますし、奇妙な挙動も減ります。

方法

イベントハンドラーを削除し、代わりにモデルのレンダリングロジックに必要最小限の処理を展開します。 たとえば、'textae-event.annotation-data.entity.add'イベントのイベントハンドラーを削除する代わりに、次のようにモデルのレンダリングロジックに変更を加えます。

https://github.com/pubannotation/textae/blob/803383bfc606c7077076e3c25bed58314347d06a/src/lib/editorize/EntityModel.js#L226

  render() {
    if (this._signboard) {
      return
    }

    if (this.span.isGridRendered) {
      const grid = this.span.gridElement

      // Append a new entity to the type
      this._signboard = this._createSignboardElement()
      grid.insertAdjacentElement('beforeend', this._signboard.element)

      this.reflectTypeGapInTheHeight()
    }
  }

を次のように書き換えます。

  render() {
    if (this._signboard) {
      return
    }

    if (this.span.isGridRendered) {
      // Append a new entity to the type
      this._signboard = this._createSignboardElement()
      this.span.addEntityElementToGridElement(this._signboard.element)

      this.reflectTypeGapInTheHeight()

      for (const entity of this.span.entities.filter((e) => e !== this)) {
        for (const relation of entity.relations) {
          relation.updateHighlighting()
        }
      }
    }
  }

最後のfor文で、自身と関連するインスタンスレンダリングロジックを呼び出しています*2。 いままでは全インスタンスを位置を再計算していたのを、モデルの変更が影響あるインスタンスだけに絞り込んでいます*3

grid.insertAdjacentElement('beforeend', this._signboard.element)

の後にも処理と追加しています。 これは次のように、別のモデルのロジック委譲しているので、見た目はあまり変わっていません。

this.span.addEntityElementToGridElement(this._signboard.element)

これを11イベント繰り返します。

*1:たとえば次のユーザーイベントで書き直したりします。マウスのカーソルを振ると乱れた表示が直るみたいなやつです。

*2:relation.updateHighlightingてメソッド名を移動のために呼び出しています。違和感はあります。

*3:forじゃなくてreduceつかってたたみ込んで重複を減らした方がわかりやすいかもしれません。