リビーリングモジュールパターンをクラス構文で置き変える


背景

JavaScriptにクラス構文が導入される以前の時代、JavaScriptプログラマーはメソッドを持ったオブジェクトを生成するために、それぞれ工夫した構文を使っていました。

その一つに書籍「JavaScriptデザインパターン」で紹介されているリビーリングモジュールパターンがありました。
例えば次のようなメソッドを持ったハッシュオブジェクトを返す関数です。

import getAnnotationBox from '../getAnnotationBox'
import createGrid from './createGrid'
import adaptWidthToSpan from './adaptWidthToSpan'
import getGridElement from './getGridElement'

export default function(editor, domPositionCache) {
  const container = getAnnotationBox(editor)

  return {
    render: (spanId) => createGrid(editor[0], domPositionCache, container[0], spanId),
    remove: (spanId) => {
      const gridElement = getGridElement(spanId)

      if (gridElement)
        gridElement.parentNode.removeChild(gridElement)

      domPositionCache.gridPositionCache.delete(spanId)
    },
    changeId: ({oldId, newId}) => {
      const gridElement = getGridElement(oldId)

      // Since block span has no grid, there may not be a grid.
      if (gridElement) {
        gridElement.setAttribute('id', `G${newId}`)

        for (const type of gridElement.querySelectorAll('.textae-editor__type, .textae-editor__entity-pane')) {
          type.setAttribute('id', type.getAttribute('id').replace(oldId, newId))
        }

        adaptWidthToSpan(gridElement, domPositionCache, newId)
      }

      domPositionCache.gridPositionCache.delete(oldId)
    },
    updateWidth(spanId) {
      const gridElement = getGridElement(spanId)

      // Since block span has no grid, there may not be a grid.
      if (gridElement) {
        adaptWidthToSpan(gridElement, domPositionCache, spanId)
      }
    }
  }
}

課題

リビーリングモジュールパターンは、古いJavaScript文法の特徴(ブロックスコープがなく、関数スコープがある)に対応するための目的で用いられました。現代ではスコープに関しては、CommonJSモジュールを使ったときも、ES modulesを使ったときも、トランスパイラが自動的に作成してくれます。

現代のJavaScriptプログラマーが理解するには不要な文脈が多く含まれます。

リファクタリング手法

ES6で導入されたクラス構文で置き換えることで、現代のJavaScriptプログラマーに読みやすいソースコードに置き換えます。

手段1 export defaultexport default classに置き換える

exportするものを関数からクラスに変更します。

export default function(editor, domPositionCache) {

export default class {

に変えます。

手段2 functionconstructorに置き換える

関数をコンストラクタに変更します。

export default function(editor, domPositionCache) {

  constructor(editor, domPositionCache) {

に変えます。

手段3 プライベート変数をthisに代入する

リビーリングモジュールパターンでは同一のスコープだったので、生成するオブジェクトのプロパティから変数が参照できました。
クラス構文では直接参照することはできないので、this.をつけて参照します。

生成するオブジェクトのプロパティから参照する変数をthisのプロパティにします。
次の二種類の変数があります。

  • 関数の引数
  • 生成してローカル変数に代入した値
export default function(editor, domPositionCache) {
  const container = getAnnotationBox(editor)

  constructor(editor, domPositionCache) {
    this.editor = editor
    this.domPositionCache = domPositionCache
    this.container = getAnnotationBox(editor)

に変えます。

手段4 プロパティをメソッドに置き換える

生成するオブジェクトのプロパティに設定している関数をメソッドに置き換えます。
無名関数を設定している場合はメソッド形式に書き換えます。

  return {
    render: (spanId) => createGrid(editor[0], domPositionCache, container[0], spanId),

  render(spanId) {
    return createGrid(this.editor[0], this.domPositionCache, this.container[0], spanId)
  }

に変えます。
メソッド形式にの場合は、ほとんどそのまま、インデントを直すぐらいです。

    updateWidth(spanId) {
      const gridElement = getGridElement(spanId)

      // Since block span has no grid, there may not be a grid.
      if (gridElement) {
        adaptWidthToSpan(gridElement, domPositionCache, spanId)
      }
    }

  updateWidth(spanId) {
    const gridElement = getGridElement(spanId)

    // Since block span has no grid, there may not be a grid.
    if (gridElement) {
      adaptWidthToSpan(gridElement, this.domPositionCache, spanId)
    }
  }

に変えます。

手段5 プライベート変数への参照にthisをつける

リビーリングモジュールパターンでは同一のスコープだったので、関数の引数とローカル変数が参照できました。
クラス構文では直接参照することはできないので、this.をつけて参照します。

    render: (spanId) => createGrid(editor[0], domPositionCache, container[0], spanId),

    return createGrid(this.editor[0], this.domPositionCache, this.container[0], spanId)

に変えます。

完成形

import getAnnotationBox from '../getAnnotationBox'
import createGrid from './createGrid'
import adaptWidthToSpan from './adaptWidthToSpan'
import getGridElement from './getGridElement'

export default class {
  constructor(editor, domPositionCache) {
    this.editor = editor
    this.domPositionCache = domPositionCache
    this.container = getAnnotationBox(editor)
  }

  render(spanId) {
    return createGrid(this.editor[0], this.domPositionCache, this.container[0], spanId)
  }

  remove(spanId) {
    const gridElement = getGridElement(spanId)

    if (gridElement) {
      gridElement.parentNode.removeChild(gridElement)
    }

    this.domPositionCache.gridPositionCache.delete(spanId)
  }

  changeId({oldId, newId}) {
    const gridElement = getGridElement(oldId)

    // Since block span has no grid, there may not be a grid.
    if (gridElement) {
      gridElement.setAttribute('id', `G${newId}`)

      for (const type of gridElement.querySelectorAll('.textae-editor__type, .textae-editor__entity-pane')) {
        type.setAttribute('id', type.getAttribute('id').replace(oldId, newId))
      }

      adaptWidthToSpan(gridElement, this.domPositionCache, newId)
    }

    this.domPositionCache.gridPositionCache.delete(oldId)
  }

  updateWidth(spanId) {
    const gridElement = getGridElement(spanId)

    // Since block span has no grid, there may not be a grid.
    if (gridElement) {
      adaptWidthToSpan(gridElement, this.domPositionCache, spanId)
    }
  }
}

参考