🛠️

Next.jsで学ぶ文字列変換の設計思想:camelCase変換ツールの実装から学んだこと

に公開

はじめに

プログラミングにおける命名規則(Naming Convention)は、コードの可読性を左右する重要な要素です。JavaScriptではcamelCase、Pythonではsnake_case、CSSではkebab-caseというように、言語やフレームワークによって推奨される命名規則が異なります。

本記事では、Next.jsで8種類の文字列変換に対応したツールを実装する過程で得られた設計上の学びと、実装のベストプラクティスを共有します。

実装する文字変換ツールの全体像

対応する変換形式

変換形式 主な用途
camelCase userName JavaScript/TypeScript変数名
PascalCase UserName React コンポーネント名、クラス名
snake_case user_name Python変数名、DB カラム名
kebab-case user-name CSS クラス名、URL スラッグ
UPPER CASE USER NAME 定数、見出し
lower case user name 通常の文章
Capitalize User Name タイトル表記
UPPER_SNAKE USER_NAME 環境変数、定数

アーキテクチャ設計のポイント

1. 状態管理の設計思想

const [inputText, setInputText] = useState('')
const [selectedCase, setSelectedCase] = useState<string>('camelCase')
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)

設計意図:

  • inputText: 入力データの単一ソース(Single Source of Truth)
  • selectedCase: ユーザーの選択状態を保持(UIとロジックの分離)
  • copiedIndex: UI フィードバック用の一時状態(UX向上)

この設計により、データフロー(入力 → 変換 → 出力)が明確になり、デバッグやテストが容易になります。

2. 変換ロジックの抽象化

変換処理をconvertCase関数として抽象化することで、以下のメリットが得られます:

  • テスタビリティ: 純粋関数なのでユニットテストが書きやすい
  • 再利用性: 他のコンポーネントでも使える
  • 保守性: ロジックの変更が1箇所で済む
const convertCase = (type: string): string => {
  if (!inputText) return ''

  const lines = inputText.split('\n').filter(line => line.trim().length > 0)
  const convertedLines = lines.map(line => {
    // 各変換処理
  })

  return convertedLines.join('\n')
}

3. 正規表現パターンの統一設計

各変換処理で共通する「単語分割」の処理を統一:

// 共通パターン:既存のケース形式を単語に分割
const words = trimmedLine
  .replace(/([A-Z])/g, ' $1')  // PascalCase/camelCase対応
  .split(/[\s_-]+/)             // 区切り文字で分割
  .filter(w => w.length > 0)    // 空文字を除外

なぜこの設計が重要か:

  • どの形式(fooBar, foo_bar, foo-bar)からでも変換可能
  • 変換処理のロジックが統一され、理解しやすい
  • バグの混入リスクが減る

実装時につまづきやすいポイント

ポイント1: 大文字の検出と単語分割

課題: camelCasePascalCase を単語に分割する方法

解決策:

.replace(/([A-Z])/g, ' $1')

この正規表現により、fooBarfoo Bar のように大文字の前にスペースを挿入します。

なぜこれが必要か:

split() だけでは camelCase を正しく分割できないため、事前に区切りを明示的に挿入する必要があります。

ポイント2: 余分な区切り文字の除去

課題: __hoge__puyo__ のような連続する区切り文字

解決策:

.replace(/^_+|_+$/g, '')  // 先頭と末尾の_を削除
.replace(/_+/g, '_')       // 連続する_を1つに

図解:

入力: __hoge__puyo__
  ↓ 先頭末尾削除
hoge__puyo
  ↓ 連続削除
hoge_puyo

ポイント3: 改行対応による一括変換

設計思想: ユーザーの作業効率を最大化

const lines = inputText.split('\n').filter(line => line.trim().length > 0)
const convertedLines = lines.map(line => {
  // 各行を個別に変換
})
return convertedLines.join('\n')

なぜ改行対応が重要か:

実務では複数の変数名を一度に変換したいケースが多く、1つずつ処理するのは非効率です。

UX設計の工夫

クリップボードAPIの活用

const copyToClipboard = async (text: string, index: number) => {
  await navigator.clipboard.writeText(text)
  setCopiedIndex(index)
  setTimeout(() => setCopiedIndex(null), 2000)
}

UX設計のポイント:

  • コピー成功を視覚的にフィードバック(2秒間表示)
  • ユーザーがコピーできたか不安にならない

レスポンシブデザインの考慮

<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
  • モバイル: 縦並び(1カラム)
  • デスクトップ: 横並び(2カラム)

完成コード

'use client'
export const dynamic = 'force-static'

import { useState } from 'react'
import { Check, Copy } from 'lucide-react'

export default function CaseConverter() {
  const [inputText, setInputText] = useState('')
  const [selectedCase, setSelectedCase] = useState<string>('camelCase')
  const [copiedIndex, setCopiedIndex] = useState<number | null>(null)

  const convertCase = (type: string): string => {
    if (!inputText) return ''

    const lines = inputText.split('\n').filter(line => line.trim().length > 0)

    const convertedLines = lines.map(line => {
      const trimmedLine = line.trim()
      let converted = ''

      switch (type) {
        case 'camelCase':
          const words = trimmedLine
            .replace(/([A-Z])/g, ' $1')
            .split(/[\s_-]+/)
            .filter(w => w.length > 0)
          converted = words
            .map((word, index) =>
              index === 0
                ? word.toLowerCase()
                : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
            )
            .join('')
          break
        case 'snake_case':
          converted = trimmedLine
            .replace(/([A-Z])/g, '_$1')
            .replace(/[\s-]+/g, '_')
            .toLowerCase()
            .replace(/^_+|_+$/g, '')
            .replace(/_+/g, '_')
          break
        // 他の変換処理も同様
      }
      return converted
    })

    return convertedLines.join('\n')
  }

  const copyToClipboard = async (text: string, index: number) => {
    await navigator.clipboard.writeText(text)
    setCopiedIndex(index)
    setTimeout(() => setCopiedIndex(null), 2000)
  }

  const cases = [
    { type: 'camelCase', label: 'camelCase' },
    { type: 'PascalCase', label: 'PascalCase' },
    { type: 'snake_case', label: 'snake_case' },
    { type: 'kebab-case', label: 'kebab-case' },
    { type: 'upper', label: 'UPPER CASE' },
    { type: 'lower', label: 'lower case' },
    { type: 'capitalize', label: 'Capitalize' },
    { type: 'UPPER_SNAKE', label: 'UPPER_SNAKE' },
  ]

  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
      {/* 入力側 */}
      <div className="flex flex-col">
        <label htmlFor="input">入力</label>
        <textarea
          id="input"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          className="flex-1 min-h-[450px] font-mono"
          placeholder="hoge_puyo&#10;fooBar&#10;kebab-case"
        />
        <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
          改行で単語を区切ってください
        </p>
      </div>

      {/* 出力側 */}
      <div className="flex flex-col">
        <div className="mb-4">
          <label>変換形式</label>
          <div className="flex flex-wrap gap-2">
            {cases.map((caseType) => (
              <button
                key={caseType.type}
                onClick={() => setSelectedCase(caseType.type)}
              >
                {caseType.label}
              </button>
            ))}
          </div>
        </div>

        <div className="flex flex-col flex-1">
          <div className="flex items-center justify-between mb-3">
            <label>出力</label>
            <button
              onClick={() => copyToClipboard(convertCase(selectedCase), 0)}
              disabled={!inputText}
            >
              {copiedIndex === 0 ? <Check /> : <Copy />}
            </button>
          </div>
          <textarea
            value={convertCase(selectedCase)}
            readOnly
            className="flex-1 min-h-[380px] font-mono bg-gray-50 dark:bg-gray-800"
          />
        </div>
      </div>
    </div>
  )
}

今後の展望

この実装を基に、以下のような拡張が考えられます:

  1. カスタム変換ルールの追加 - ユーザー定義の変換パターン対応
  2. 履歴機能 - 過去の変換結果を保存
  3. エクスポート機能 - JSON/CSVでの一括出力
  4. プラグイン対応 - VSCode拡張としての提供

得られた学び

  • 純粋関数による実装でテストと保守が容易になる
  • 正規表現の統一パターンにより変換ロジックが理解しやすくなる
  • UXへの配慮(コピー機能、改行対応)が実用性を大きく向上させる
  • Next.jsの'use client'ディレクティブでクライアントサイド処理を明示

文字列変換という一見シンプルな機能でも、設計思想やUX配慮を深めることで、実用的なツールに進化させることができます。

ツールの紹介

こちらで実際に動かして確認することができます。
是非活用してみてください。
文字変換

Discussion