Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

JavaScriptのif/else地獄をリファクタリングで解消する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

利便性のため、コードのdiffも追加しています。

JavaScript: if/else地獄をリファクタリングで解消する(翻訳)

本記事は、私の近著『JavaScript for Rails Developers』から抜粋してWeb用に手を加えたものです。ぜひお買い求めください✌️。


コードを書くという作業は、直線的には進みません。真っ白なキャンバスをにらみつけていれば何を書けばよいかがわかるとは限りません。
本記事は、私が大規模なコードベースでメソッドを書いたときの手順をドキュメント化したいと思います。ifelse分岐だらけで見通しの悪い非常に手続き的に書かれたコードが、追いかけやすく素直にメンテできる、よりオブジェクト指向的なコードに進化する様子を本記事でご覧いただけます。

背景情報: このコードの機能は、私の著書で構築するコードエディタで、矢印キーで行を上下に移動可能にします。viewlineAtdispatchなどの参照は、コードの背後で使われているCodeMirrorというライブラリ固有のものです。

codemirror/dev - GitHub

とりあえずの注意点: リファクタリング作業では、コードの振る舞いを変更してはいけません。本記事の場合、コードの振る舞いの結果や、親コンテキストにおけるmoveLineメソッドの使われ方を変更してはいけないという意味です。

それでは最初にリファクタリング開始前の初期バージョンのコードを見てみましょう。このコードが私の思考プロセスに沿っていて、その機能で実現したいことに注目していることがわかります。

  • 矢印キーで指定された方向が上の場合、「duplicateがtrueでない」場合にのみ変更をディスパッチする
  • 矢印キーで指定された方向が下の場合も同様だが、方向のみ異なる
export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const line = document.lineAt(selection.from)
  const lineCount = document.lines

  if (direction === "up") {
    if (duplicate) {
      const changes = {
        from: line.from,
        to: line.from,
        insert: line.text + "\n"
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor,
          head: selection.head
        }
      })

    } else if (line.number > 1) {
      const prevLine = document.lineAt(line.from - 1)
      const changes = {
        from: prevLine.from,
        to: line.to,
        insert: line.text + "\n" + prevLine.text
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor - prevLine.length - 1,
          head: selection.head - prevLine.length - 1
        }
      })
    }

    return true

  } else if (direction === "down") {
    if (duplicate) {
      const changes = {
        from: line.to,
        to: line.to,
        insert: "\n" + line.text
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor + line.length + 1,
          head: selection.head + line.length + 1
        }
      })
    } else if (line.number < lineCount) {
      const nextLine = document.lineAt(line.to + 1)
      const changes = {
        from: line.from,
        to: nextLine.to,
        insert: nextLine.text + "\n" + line.text
      }

      view.dispatch({
        changes,
        selection: {
          anchor: selection.anchor + nextLine.length + 1,
          head: selection.head + nextLine.length + 1
        }
      })
    }

    return true
  }

  return false
}

リファクタリングの最初のステップは、個別の分岐からロジックを抽出して専用のメソッドに切り出すことです。こうすることで、コード内にあるパターンが見えてくるようになります。

  • ディスパッチパターン(view.dispatch()
  • オブジェクトの変更(view.dispatch()
  • 選択範囲を計算する(selection.anchor/head - length - 1
export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  if (direction === "up") {
    return moveLineUp(view, document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    return moveLineDown(view, document, selection, currentLine, totalLines, duplicate)
  }

  return false
}

const moveLineUp = (view, document, selection, currentLine, duplicate) => {
  if (duplicate) {
    return duplicateLineUp(view, selection, currentLine)
  } else if (currentLine.number > 1) {
    return shiftLineUp(view, document, selection, currentLine)
  }

  return false
}

const moveLineDown = (view, document, selection, currentLine, totalLines, duplicate) => {
  if (duplicate) {
    return duplicateLineDown(view, selection, currentLine)
  } else if (currentLine.number < totalLines) {
    return shiftLineDown(view, document, selection, currentLine)
  }

  return false
}

const duplicateLineUp = (view, selection, currentLine) => {
  const changes = {
    from: currentLine.from,
    to: currentLine.from,
    insert: `${currentLine.text}\n`
  }

  view.dispatch({
    changes,
    selection: { anchor: selection.anchor, head: selection.head }
  })

  return true
}

const duplicateLineDown = (view, selection, currentLine) => {
  const changes = {
    from: currentLine.to,
    to: currentLine.to,
    insert: `\n${currentLine.text}`
  }

  view.dispatch({
    changes,
    selection: {
      anchor: selection.anchor + currentLine.length + 1,
      head: selection.head + currentLine.length + 1
    }
  })

  return true
}

const shiftLineUp = (view, document, selection, currentLine) => {
  const previousLine = document.lineAt(currentLine.from - 1)

  const changes = {
    from: previousLine.from,
    to: currentLine.to,
    insert: `${currentLine.text}\n${previousLine.text}`
  }

  view.dispatch({
    changes,
    selection: {
      anchor: selection.anchor - previousLine.length - 1,
      head: selection.head - previousLine.length - 1
    }
  })

  return true
}

const shiftLineDown = (view, document, selection, currentLine) => {
  const nextLine = document.lineAt(currentLine.to + 1)

  const changes = {
    from: currentLine.from,
    to: nextLine.to,
    insert: `${nextLine.text}\n${currentLine.text}`
  }

  view.dispatch({
    changes,
    selection: {
      anchor: selection.anchor + nextLine.length + 1,
      head: selection.head + nextLine.length + 1
    }
  })

  return true
}
▶1つ前との差分(クリックで展開)
 export const moveLine = (view, direction, { duplicate = false } = {}) => {  
   const document = view.state.doc  
   const selection = view.state.selection.main  
-  const line = document.lineAt(selection.from)  
-  const lineCount = document.lines  
+  const currentLine = document.lineAt(selection.from)  
+  const totalLines = document.lines  

   if (direction === "up") {  
-    if (duplicate) {  
-      const changes = {  
-        from: line.from,  
-        to: line.from,  
-        insert: line.text + "\n"  
-      }  
-  
-      view.dispatch({  
-        changes,  
-        selection: {  
-          anchor: selection.anchor,  
-          head: selection.head  
-        }  
-      })  
+    return moveLineUp(view, document, selection, currentLine, duplicate)  
+  } else if (direction === "down") {  
+    return moveLineDown(view, document, selection, currentLine, totalLines, duplicate)  
+  }  
+  
+  return false  
+}  
+  
+const moveLineUp = (view, document, selection, currentLine, duplicate) => {  
+  if (duplicate) {  
+    return duplicateLineUp(view, selection, currentLine)  
+  } else if (currentLine.number > 1) {  
+    return shiftLineUp(view, document, selection, currentLine)  
+  }  

-    } else if (line.number > 1) {  
-      const prevLine = document.lineAt(line.from - 1)  
-      const changes = {  
-        from: prevLine.from,  
-        to: line.to,  
-        insert: line.text + "\n" + prevLine.text  
-      }  
-  
-      view.dispatch({  
-        changes,  
-        selection: {  
-          anchor: selection.anchor - prevLine.length - 1,  
-          head: selection.head - prevLine.length - 1  
-        }  
-      })  
-    }  
-  
-    return true  
-  
-  } else if (direction === "down") {  
-    if (duplicate) {  
-      const changes = {  
-        from: line.to,  
-        to: line.to,  
-        insert: "\n" + line.text  
-      }  
-  
-      view.dispatch({  
-        changes,  
-        selection: {  
-          anchor: selection.anchor + line.length + 1,  
-          head: selection.head + line.length + 1  
-        }  
-      })  
-    } else if (line.number < lineCount) {  
-      const nextLine = document.lineAt(line.to + 1)  
-      const changes = {  
-        from: line.from,  
-        to: nextLine.to,  
-        insert: nextLine.text + "\n" + line.text  
-      }  
+  return false  
+}  
+  
+const moveLineDown = (view, document, selection, currentLine, totalLines, duplicate) => {  
+  if (duplicate) {  
+    return duplicateLineDown(view, selection, currentLine)  
+  } else if (currentLine.number < totalLines) {  
+    return shiftLineDown(view, document, selection, currentLine)  
+  }  
+  
+  return false  
+}  
+  
+const duplicateLineUp = (view, selection, currentLine) => {  
+  const changes = {  
+    from: currentLine.from,  
+    to: currentLine.from,  
+    insert: `${currentLine.text}\n`  
+  }  
+  
+  view.dispatch({  
+    changes,  
+    selection: { anchor: selection.anchor, head: selection.head }  
+  })  
+  
+  return true  
+}  
+  
+const duplicateLineDown = (view, selection, currentLine) => {  
+  const changes = {  
+    from: currentLine.to,  
+    to: currentLine.to,  
+    insert: `\n${currentLine.text}`  
+  }  
+  
+  view.dispatch({  
+    changes,  
+    selection: {  
+      anchor: selection.anchor + currentLine.length + 1,  
+      head: selection.head + currentLine.length + 1  
+    }  
+  })  

-      view.dispatch({  
-        changes,  
-        selection: {  
-          anchor: selection.anchor + nextLine.length + 1,  
-          head: selection.head + nextLine.length + 1  
-        }  
-      })  
-    }  
+  return true  
+}  
+  
+const shiftLineUp = (view, document, selection, currentLine) => {  
+  const previousLine = document.lineAt(currentLine.from - 1)  
+  
+  const changes = {  
+    from: previousLine.from,  
+    to: currentLine.to,  
+    insert: `${currentLine.text}\n${previousLine.text}`  
+  }  
+  
+  view.dispatch({  
+    changes,  
+    selection: {  
+      anchor: selection.anchor - previousLine.length - 1,  
+      head: selection.head - previousLine.length - 1  
+    }  
+  })  
+  
+  return true  
+}  
+  
+const shiftLineDown = (view, document, selection, currentLine) => {  
+  const nextLine = document.lineAt(currentLine.to + 1)  
+  
+  const changes = {  
+    from: currentLine.from,  
+    to: nextLine.to,  
+    insert: `${nextLine.text}\n${currentLine.text}`  
+  }  
+  
+  view.dispatch({  
+    changes,  
+    selection: {  
+      anchor: selection.anchor + nextLine.length + 1,  
+      head: selection.head + nextLine.length + 1  
+    }  
+  })  

-    return true  
-  }  
-  
-  return false  
+  return true  
 }  

次に、重複したディスパッチパターンを削除して、moveLineメソッド内でディスパッチパターンを1回だけ使うようにしました。これにより、メソッドから返されるオブジェクトのパターンがさらに明確になりました。

export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  let operation

  if (direction === "up") {
    operation = moveLineUp(document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
  }

  if (!operation) return false

  view.dispatch({
    changes: operation.changes,
    selection: operation.selection
  })

  return true
}

const moveLineUp = (document, selection, currentLine, duplicate) => {
  if (duplicate) {
    return duplicateLineUp(selection, currentLine)
  } else if (currentLine.number > 1) {
    return shiftLineUp(document, selection, currentLine)
  }

  return null
}

const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
  if (duplicate) {
    return duplicateLineDown(selection, currentLine)
  } else if (currentLine.number < totalLines) {
    return shiftLineDown(document, selection, currentLine)
  }

  return null
}

const duplicateLineUp = (selection, currentLine) => ({
  changes: {
    from: currentLine.from,
    to: currentLine.from,
    insert: `${currentLine.text}\n`
  },

  selection: {
    anchor: selection.anchor,
    head: selection.head
  }
})

const duplicateLineDown = (selection, currentLine) => ({
  changes: {
    from: currentLine.to,
    to: currentLine.to,
    insert: `\n${currentLine.text}`
  },

  selection: {
    anchor: selection.anchor + currentLine.length + 1,
    head: selection.head + currentLine.length + 1
  }
})

const shiftLineUp = (document, selection, currentLine) => {
  const previousLine = document.lineAt(currentLine.from - 1)

  return {
    changes: {
      from: previousLine.from,
      to: currentLine.to,
      insert: `${currentLine.text}\n${previousLine.text}`
    },

    selection: {
      anchor: selection.anchor - previousLine.length - 1,
      head: selection.head - previousLine.length - 1
    }
  }
}

const shiftLineDown = (document, selection, currentLine) => {
  const nextLine = document.lineAt(currentLine.to + 1)

  return {
    changes: {
      from: currentLine.from,
      to: nextLine.to,
      insert: `${nextLine.text}\n${currentLine.text}`
    },

    selection: {
      anchor: selection.anchor + nextLine.length + 1,
      head: selection.head + nextLine.length + 1
    }
  }
}
▶1つ前との差分(クリックで展開)
 export const moveLine = (view, direction, { duplicate = false } = {}) => {
   const document = view.state.doc
   const selection = view.state.selection.main
   const currentLine = document.lineAt(selection.from)
   const totalLines = document.lines

-  if (direction === "up") {
-    return moveLineUp(view, document, selection, currentLine, duplicate)
-  } else if (direction === "down") {
-    return moveLineDown(view, document, selection, currentLine, totalLines, duplicate)
-  }
-
-  return false
-}
-
-const moveLineUp = (view, document, selection, currentLine, duplicate) => {
-  if (duplicate) {
-    return duplicateLineUp(view, selection, currentLine)
-  } else if (currentLine.number > 1) {
-    return shiftLineUp(view, document, selection, currentLine)
-  }
-
-  return false
-}
-
-const moveLineDown = (view, document, selection, currentLine, totalLines, duplicate) => {
-  if (duplicate) {
-    return duplicateLineDown(view, selection, currentLine)
-  } else if (currentLine.number < totalLines) {
-    return shiftLineDown(view, document, selection, currentLine)
-  }
-
-  return false
-}
-
-const duplicateLineUp = (view, selection, currentLine) => {
-  const changes = {
-    from: currentLine.from,
-    to: currentLine.from,
-    insert: `${currentLine.text}\n`
-  }
-
-  view.dispatch({
-    changes,
-    selection: { anchor: selection.anchor, head: selection.head }
-  })
-
-  return true
-}
-
-const duplicateLineDown = (view, selection, currentLine) => {
-  const changes = {
-    from: currentLine.to,
-    to: currentLine.to,
-    insert: `\n${currentLine.text}`
-  }
+  let operation
+
+  if (direction === "up") {
+    operation = moveLineUp(document, selection, currentLine, duplicate)
+  } else if (direction === "down") {
+    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
+  }
+
+  if (!operation) return false
+
+  view.dispatch({
+    changes: operation.changes,
+    selection: operation.selection
+  })
+
+  return true
+}
+
+const moveLineUp = (document, selection, currentLine, duplicate) => {
+  if (duplicate) {
+    return duplicateLineUp(selection, currentLine)
+  } else if (currentLine.number > 1) {
+    return shiftLineUp(document, selection, currentLine)
+  }
+
+  return null
+}
+
+const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
+  if (duplicate) {
+    return duplicateLineDown(selection, currentLine)
+  } else if (currentLine.number < totalLines) {
+    return shiftLineDown(document, selection, currentLine)
+  }
+
+  return null
+}
+
+const duplicateLineUp = (selection, currentLine) => ({
+  changes: {
+    from: currentLine.from,
+    to: currentLine.from,
+    insert: `${currentLine.text}\n`
+  },
+
+  selection: {
+    anchor: selection.anchor,
+    head: selection.head
+  }
+})

-  view.dispatch({
-    changes,
-    selection: {
-      anchor: selection.anchor + currentLine.length + 1,
-      head: selection.head + currentLine.length + 1
-    }
-  })
-
-  return true
-}
-
-const shiftLineUp = (view, document, selection, currentLine) => {
-  const previousLine = document.lineAt(currentLine.from - 1)
-
-  const changes = {
-    from: previousLine.from,
-    to: currentLine.to,
-    insert: `${currentLine.text}\n${previousLine.text}`
-  }
-
-  view.dispatch({
-    changes,
-    selection: {
-      anchor: selection.anchor - previousLine.length - 1,
-      head: selection.head - previousLine.length - 1
-    }
-  })
-
-  return true
+const duplicateLineDown = (selection, currentLine) => ({
+  changes: {
+    from: currentLine.to,
+    to: currentLine.to,
+    insert: `\n${currentLine.text}`
+  },
+
+  selection: {
+    anchor: selection.anchor + currentLine.length + 1,
+    head: selection.head + currentLine.length + 1
+  }
+})
+
+const shiftLineUp = (document, selection, currentLine) => {
+  const previousLine = document.lineAt(currentLine.from - 1)
+
+  return {
+    changes: {
+      from: previousLine.from,
+      to: currentLine.to,
+      insert: `${currentLine.text}\n${previousLine.text}`
+    },
+
+    selection: {
+      anchor: selection.anchor - previousLine.length - 1,
+      head: selection.head - previousLine.length - 1
+    }
+  }
 }

-const shiftLineDown = (view, document, selection, currentLine) => {
+const shiftLineDown = (document, selection, currentLine) => {
   const nextLine = document.lineAt(currentLine.to + 1)

-  const changes = {
-    from: currentLine.from,
-    to: nextLine.to,
-    insert: `${nextLine.text}\n${currentLine.text}`
-  }
+  return {
+    changes: {
+      from: currentLine.from,
+      to: nextLine.to,
+      insert: `${nextLine.text}\n${currentLine.text}`
+    },

-  view.dispatch({
-    changes,
     selection: {
       anchor: selection.anchor + nextLine.length + 1,
       head: selection.head + nextLine.length + 1
     }
-  })
-
-  return true
+  }
 }

今度は、duplicateメソッドを方向のロジックに移動して、どう動くかを見てみたいと思います。

export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  let operation

  if (direction === "up") {
    operation = moveLineUp(document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
  }

  if (!operation) return false

  view.dispatch({
    changes: operation.changes,
    selection: operation.selection
  })

  return true
}

const moveLineUp = (document, selection, currentLine, duplicate) => {
  if (currentLine.number === 1 && !duplicate) return null

  if (duplicate) {
    return {
      changes: {
        from: currentLine.from,
        to: currentLine.from,
        insert: `${currentLine.text}\n`
      },

      selection: {
        anchor: selection.anchor,
        head: selection.head
      }
    }
  }

  const previousLine = document.lineAt(currentLine.from - 1)

  return {
    changes: {
      from: previousLine.from,
      to: currentLine.to,
      insert: `${currentLine.text}\n${previousLine.text}`
    },

    selection: {
      anchor: selection.anchor - previousLine.length - 1,
      head: selection.head - previousLine.length - 1
    }
  }
}

const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
  if (currentLine.number === totalLines && !duplicate) return null

  if (duplicate) {
    return {
      changes: {
        from: currentLine.to,
        to: currentLine.to,
        insert: `\n${currentLine.text}`
      },

      selection: {
        anchor: selection.anchor + currentLine.length + 1,
        head: selection.head + currentLine.length + 1
      }
    }
  }

  const nextLine = document.lineAt(currentLine.to + 1)

  return {
    changes: {
      from: currentLine.from,
      to: nextLine.to,
      insert: `${nextLine.text}\n${currentLine.text}`
    },

    selection: {
      anchor: selection.anchor + nextLine.length + 1,
      head: selection.head + nextLine.length + 1
    }
  }
}
▶1つ前との差分(クリックで展開)
export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const document = view.state.doc
  const selection = view.state.selection.main
  const currentLine = document.lineAt(selection.from)
  const totalLines = document.lines

  let operation

  if (direction === "up") {
    operation = moveLineUp(document, selection, currentLine, duplicate)
  } else if (direction === "down") {
    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
  }

  if (!operation) return false

  view.dispatch({
    changes: operation.changes,
    selection: operation.selection
  })

  return true
}

const moveLineUp = (document, selection, currentLine, duplicate) => {
-  if (duplicate) {
-    return duplicateLineUp(selection, currentLine)
-  } else if (currentLine.number > 1) {
-    return shiftLineUp(document, selection, currentLine)
-  }
-
-  return null
-}
-
-const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
-  if (duplicate) {
-    return duplicateLineDown(selection, currentLine)
-  } else if (currentLine.number < totalLines) {
-    return shiftLineDown(document, selection, currentLine)
-  }
-
-  return null
-}
+  if (currentLine.number === 1 && !duplicate) return null
+
+  if (duplicate) {
+    return {
+      changes: {
+        from: currentLine.from,
+        to: currentLine.from,
+        insert: `${currentLine.text}\n`
+      },
+
+      selection: {
+        anchor: selection.anchor,
+        head: selection.head
+      }
+    }
+  }
+
+  const previousLine = document.lineAt(currentLine.from - 1)

-const duplicateLineUp = (selection, currentLine) => ({
-  changes: {
-    from: currentLine.from,
-    to: currentLine.from,
-    insert: `${currentLine.text}\n`
-  },
+  return {
+    changes: {
+      from: previousLine.from,
+      to: currentLine.to,
+      insert: `${currentLine.text}\n${previousLine.text}`
+    },

-  selection: {
-    anchor: selection.anchor,
-    head: selection.head
-  }
-})
-
-const duplicateLineDown = (selection, currentLine) => ({
-  changes: {
-    from: currentLine.to,
-    to: currentLine.to,
-    insert: `\n${currentLine.text}`
-  },
-
-  selection: {
-    anchor: selection.anchor + currentLine.length + 1,
-    head: selection.head + currentLine.length + 1
-  }
-})
-
-const shiftLineUp = (document, selection, currentLine) => {
-  const previousLine = document.lineAt(currentLine.from - 1)
-
-  return {
-    changes: {
-      from: previousLine.from,
-      to: currentLine.to,
-      insert: `${currentLine.text}\n${previousLine.text}`
-    },
+    selection: {
+      anchor: selection.anchor - previousLine.length - 1,
+      head: selection.head - previousLine.length - 1
+    }
+  }
+}
+
+const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
+  if (currentLine.number === totalLines && !duplicate) return null
+
+  if (duplicate) {
+    return {
+      changes: {
+        from: currentLine.to,
+        to: currentLine.to,
+        insert: `\n${currentLine.text}`
+      },
+
+      selection: {
+        anchor: selection.anchor + currentLine.length + 1,
+        head: selection.head + currentLine.length + 1
+      }
+    }
+  }

-    selection: {
-      anchor: selection.anchor - previousLine.length - 1,
-      head: selection.head - previousLine.length - 1
-    }
-  }
-}
-
-const shiftLineDown = (document, selection, currentLine) => {
   const nextLine = document.lineAt(currentLine.to + 1)

   return {
     changes: {
       from: currentLine.from,
       to: nextLine.to,

ここで行き詰まってしまいました。処理内容がまだまだ多いので理解するのが大変です。
そこで、今度はコードをクラス化してみたいと思います(リファクタリングのルールを1つ変更していますが、今は目をつぶりましょう)。

class Line {
  constructor(view, direction, duplicate = false) {
    this.view = view
    this.direction = direction
    this.duplicate = duplicate
  }

  move() {
    if (!this.duplicate && this.#atBoundary) return false

    const operation = this.duplicate
      ? this.#duplicate()
      : this.#swap()

    this.view.dispatch(operation)

    return true
  }

  // private

  #duplicate() {
    return {
      changes: this.#duplicateChanges,
      selection: this.#newSelection
    }
  }

  #swap() {
    return {
      changes: this.#swapChanges,
      selection: this.#newSelection
    }
  }

  get #document() {
    return this.view.state.doc
  }

  get #selection() {
    return this.view.state.selection.main
  }

  get #currentLine() {
    return this.#document.lineAt(this.#selection.from)
  }

  get #moveUp() {
    return this.direction === "up"
  }

  get #targetLine() {
    return this.#document.lineAt(
      this.#moveUp ? this.#currentLine.from - 1 : this.#currentLine.to + 1
    )
  }

  get #atBoundary() {
    return this.#moveUp
      ? this.#currentLine.number === 1
      : this.#currentLine.number === this.#document.lines
  }

  get #selectionOffset() {
    if (this.duplicate && this.#moveUp) return 0

    const lineLength = this.duplicate ? this.#currentLine.length : this.#targetLine.length

    return (lineLength + 1) _ (this.#moveUp ? -1 : 1)
  }

  get #duplicateChanges() {
    return {
      from: this.#moveUp ? this.#currentLine.from : this.#currentLine.to,
      to: this.#moveUp ? this.#currentLine.from : this.#currentLine.to,
      insert: this.#moveUp ? `${this.#currentLine.text}\n` : `\n${this.#currentLine.text}`
    }
  }

  get #swapChanges() {
    return {
      from: this.#moveUp ? this.#targetLine.from : this.#currentLine.from,
      to: this.#moveUp ? this.#currentLine.to : this.#targetLine.to,
      insert: this.#moveUp
        ? `${this.#currentLine.text}\n${this.#targetLine.text}`
        : `${this.#targetLine.text}\n${this.#currentLine.text}`
    }
  }

  get #newSelection() {
    return {
      anchor: this.#selection.anchor + this.#selectionOffset,
      head: this.#selection.head + this.#selectionOffset
    }
  }
}

export const moveLine = (view, direction, { duplicate = false } = {}) => {
  const line = new Line(view, direction, duplicate)

  return line.move()
}
▶1つ前との差分(クリックで展開)
-export const moveLine = (view, direction, { duplicate = false } = {}) => {
-  const document = view.state.doc
-  const selection = view.state.selection.main
-  const currentLine = document.lineAt(selection.from)
-  const totalLines = document.lines
-
-  let operation
-
-  if (direction === "up") {
-    operation = moveLineUp(document, selection, currentLine, duplicate)
-  } else if (direction === "down") {
-    operation = moveLineDown(document, selection, currentLine, totalLines, duplicate)
-  }
+class Line {
+  constructor(view, direction, duplicate = false) {
+    this.view = view
+    this.direction = direction
+    this.duplicate = duplicate
+  }
+
+  move() {
+    if (!this.duplicate && this.#atBoundary) return false
+
+    const operation = this.duplicate
+      ? this.#duplicate()
+      : this.#swap()

-  if (!operation) return false
+    this.view.dispatch(operation)

-  view.dispatch({
-    changes: operation.changes,
-    selection: operation.selection
-  })
+    return true
+  }
+
+  // private

-  return true
-}
-
-const moveLineUp = (document, selection, currentLine, duplicate) => {
-  if (currentLine.number === 1 && !duplicate) return null
-
-  if (duplicate) {
-    return {
-      changes: {
-        from: currentLine.from,
-        to: currentLine.from,
-        insert: `${currentLine.text}\n`
-      },
+  #duplicate() {
+    return {
+      changes: this.#duplicateChanges,
+      selection: this.#newSelection
+    }
+  }
+
+  #swap() {
+    return {
+      changes: this.#swapChanges,
+      selection: this.#newSelection
+    }
+  }

-      selection: {
-        anchor: selection.anchor,
-        head: selection.head
-      }
-    }
-  }
-
-  const previousLine = document.lineAt(currentLine.from - 1)
-
-  return {
-    changes: {
-      from: previousLine.from,
-      to: currentLine.to,
-      insert: `${currentLine.text}\n${previousLine.text}`
-    },
+  get #document() {
+    return this.view.state.doc
+  }
+
+  get #selection() {
+    return this.view.state.selection.main
+  }
+
+  get #currentLine() {
+    return this.#document.lineAt(this.#selection.from)
+  }
+
+  get #moveUp() {
+    return this.direction === "up"
+  }

-    selection: {
-      anchor: selection.anchor - previousLine.length - 1,
-      head: selection.head - previousLine.length - 1
-    }
+  get #targetLine() {
+    return this.#document.lineAt(
+      this.#moveUp ? this.#currentLine.from - 1 : this.#currentLine.to + 1
+    )
   }
-}
-
-const moveLineDown = (document, selection, currentLine, totalLines, duplicate) => {
-  if (currentLine.number === totalLines && !duplicate) return null
-
-  if (duplicate) {
-    return {
-      changes: {
-        from: currentLine.to,
-        to: currentLine.to,
-        insert: `\n${currentLine.text}`
-      },
-
-      selection: {
-        anchor: selection.anchor + currentLine.length + 1,
-        head: selection.head + currentLine.length + 1
-      }
-    }
-  }
-
-  const nextLine = document.lineAt(currentLine.to + 1)
-
-  return {
-    changes: {
-      from: currentLine.from,
-      to: nextLine.to,
-      insert: `${nextLine.text}\n${currentLine.text}`
-    },
-
-    selection: {
-      anchor: selection.anchor + nextLine.length + 1,
-      head: selection.head + nextLine.length + 1
-    }
-  }
-}
+
+  get #atBoundary() {
+    return this.#moveUp
+      ? this.#currentLine.number === 1
+      : this.#currentLine.number === this.#document.lines
+  }
+
+  get #selectionOffset() {
+    if (this.duplicate && this.#moveUp) return 0
+
+    const lineLength = this.duplicate ? this.#currentLine.length : this.#targetLine.length
+
+    return (lineLength + 1) _ (this.#moveUp ? -1 : 1)
+  }
+
+  get #duplicateChanges() {
+    return {
+      from: this.#moveUp ? this.#currentLine.from : this.#currentLine.to,
+      to: this.#moveUp ? this.#currentLine.from : this.#currentLine.to,
+      insert: this.#moveUp ? `${this.#currentLine.text}\n` : `\n${this.#currentLine.text}`
+    }
+  }
+
+  get #swapChanges() {
+    return {
+      from: this.#moveUp ? this.#targetLine.from : this.#currentLine.from,
+      to: this.#moveUp ? this.#currentLine.to : this.#targetLine.to,
+      insert: this.#moveUp
+        ? `${this.#currentLine.text}\n${this.#targetLine.text}`
+        : `${this.#targetLine.text}\n${this.#currentLine.text}`
+    }
+  }
+
+  get #newSelection() {
+    return {
+      anchor: this.#selection.anchor + this.#selectionOffset,
+      head: this.#selection.head + this.#selectionOffset
+    }
+  }
+}
+
+export const moveLine = (view, direction, { duplicate = false } = {}) => {
+  const line = new Line(view, direction, duplicate)
+
+  return line.move()
+}

おぉ、このクラスは実に読みやすくなりました!
リファクタリング前は、メソッドの中身を端から読んでいって処理を理解しなければなりませんでしたが、このクラスではmoveメソッドに注目するだけで様子がわかります。処理をもっと詳しく追いたければ、private以下のゲッターメソッドを深堀りしていけばよいのです。もちろんロジックの大半は変わっていませんが、より小さなメソッドにカプセル化されているおかげで「客観的に」理解しやすくなっています。

このクラスが実際に動く理由は、コードの最後でエクスポートしているmoveLineLineクラスのmoveメソッドを返しているからです。これによって、その親でもmoveLineとしてインポートされるようになります。

これは、「数行以上の長いコードを書くときはクラスを書く必要がある可能性が高い」というアドバイスに最初から私自身が従っていれば、そもそもリファクタリングをせずに済んだという典型的な例です。


JavaScriptを2番目に大好きな言語にしませんか?ぜひ『JavaScript for Rails Developers』をどうぞ。

関連記事

Rubyのクラスメソッドがリファクタリングに抵抗する理由(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。