0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

i18nの翻訳が特定画面だけ消える — JSONファイル分割時のshallow mergeという罠

0
Posted at

症状

ある日、本番環境で「特定の画面だけ翻訳キーがそのまま表示される」という報告が上がった。

settings.dialog.confirmDelete
toast.failedToSave

こんな生のキー文字列がUIにそのまま出ている。他の画面は全部正常。翻訳ファイルを開いてみると、該当のキーはちゃんと定義されている。typoでもない。t() の呼び出しも正しい。

で、全画面を確認してみると、特定の画面だけで起きている。調べていくと、翻訳ファイルのマージ順序によって一部のキーが消えていた。

原因

うちのプロジェクトでは、i18nの翻訳JSONを機能ごとに分割管理していた。

public/locales/en/
  common.json
  ui.json
  settings.json
  adminSettings.json
  dashboard.json
  ...(10ファイル以上)

これらをアプリ起動時に1つのオブジェクトにマージして使う、という構成。マージ処理はこう書いていた。

// こうやって順番にspreadでマージしていた
const merged = files.reduce(
  (acc, { source }) => ({ ...acc, ...source }),
  {}
)

一見問題なさそうだが、これがshallow mergeであることが全ての元凶だった。

何が起きていたか

例えば adminSettings.jsonsettings.json が、どちらもトップレベルに settings というキーを持っている。

adminSettings.json(管理画面用)

{
  "settings": {
    "title": "Settings",
    "dialog": {
      "confirmDelete": "Delete this item?"
    },
    "actions": {
      "cancel": "CANCEL",
      "delete": "DELETE"
    }
  }
}
settings.json(一般ユーザー用)

{
  "settings": {
    "title": "My Settings",
    "profile": {
      "displayName": "Display Name"
    }
  }
}

shallow mergeだと、settings.jsonsettings オブジェクトが adminSettings.jsonsettings オブジェクトを丸ごと上書きする。中身のマージではなく、オブジェクトの参照ごと差し替え。

結果、settings.dialogsettings.actions も消える。settings.json 側にあるキーだけが生き残る。

同じことが toast キー(dashboard.json vs ui.json)や buttons キー(common.json vs ui.json)でも起きていた。調べたらトップレベルキーの衝突が十数箇所あった。

修正: deep mergeに変えるだけ

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function deepMerge(
  target: Record<string, unknown>,
  source: Record<string, unknown>
): Record<string, unknown> {
  const result: Record<string, unknown> = { ...target }
  for (const [key, sourceValue] of Object.entries(source)) {
    const targetValue = result[key]
    result[key] =
      isPlainObject(targetValue) && isPlainObject(sourceValue)
        ? deepMerge(targetValue, sourceValue)
        : sourceValue
  }
  return result
}

マージ処理を差し替える。

// Before
const merged = files.reduce(
  (acc, { source }) => ({ ...acc, ...source }),
  {}
)

// After
const merged = files.reduce(
  (acc, { source }) => deepMerge(acc, source),
  {} as Record<string, unknown>
)

これだけ。ロジックとしては「両方がオブジェクトなら再帰的にマージ、それ以外は後勝ち」という素朴なdeep merge。lodashの merge を使ってもいい。

なぜ気づきにくいか

この問題がたちが悪いのは、以下の条件が全部揃わないと顕在化しないところにある。

  • 複数のJSONファイルが同じトップレベルキーを持っている
  • そのキーの中身が異なる(片方にしかないネストキーがある)
  • 消えたキーを実際に使っている画面がある

ファイルを分割した時点ではキーの衝突がなくても、後から別チームが同じトップレベルキーを使い始めた瞬間にサイレントに壊れる。TypeScriptの型チェックも通るし、t() 関数自体はエラーを出さずにキー文字列をそのまま返すだけ。テストで全キーの存在確認をしていない限り、CIも通る。

衝突検知も入れておくと安心

deep mergeで直るとはいえ、意図しない衝突は把握しておきたい。開発時だけ警告を出す仕組みも入れた。

// ネストされたオブジェクトをドットパスのMapに展開する
// { settings: { dialog: { title: "OK" } } } → Map { "settings.dialog.title" => "OK" }
function flattenLeaves(
  obj: Record<string, unknown>,
  prefix = ''
): Map<string, unknown> {
  const result = new Map<string, unknown>()
  for (const [key, value] of Object.entries(obj)) {
    const path = prefix ? `${prefix}.${key}` : key
    if (isPlainObject(value)) {
      for (const [k, v] of flattenLeaves(value, path)) {
        result.set(k, v)
      }
    } else {
      result.set(path, value)
    }
  }
  return result
}

function detectKeyCollisions(
  target: Record<string, unknown>,
  source: Record<string, unknown>,
  sourceName: string
): void {
  if (process.env.NODE_ENV !== 'development') return

  const targetLeaves = flattenLeaves(target)
  const sourceLeaves = flattenLeaves(source)

  for (const [key, sourceValue] of sourceLeaves) {
    if (!targetLeaves.has(key)) continue
    const targetValue = targetLeaves.get(key)
    // 値が同じなら単なる重複定義なので無視
    if (JSON.stringify(targetValue) === JSON.stringify(sourceValue)) continue
    console.warn(`[i18n] "${key}" from ${sourceName} overrides existing key`)
  }
}

flattenLeaves はネストされたオブジェクトを "settings.dialog.title" のようなドットパスのMapに展開する関数。末端の値(リーフ)だけを収集するので、中間のオブジェクトノード同士の比較にはならない。isPlainObject は前述の deepMerge で定義したものをそのまま使う。

呼び出しタイミングは、deepMerge の前に挟む。マージ済みの累積結果と、これから合体するファイルを比較する形。

const merged = files.reduce(
  (acc, { source, name }) => {
    detectKeyCollisions(acc, source, name)
    return deepMerge(acc, source)
  },
  {} as Record<string, unknown>
)

ポイントは値が同じ重複は無視すること。最初は「キーが被ったら全部警告」にしていたが、開発コンソールに警告が284件出て使い物にならなかった。同じキーで同じ値なら実害がないので、値が実際に異なるケースだけに絞ったら0件になった。

next-i18next / react-i18next を使っている場合

next-i18nextreact-i18next を使っていてnamespace分割している場合は、t('namespace:key') のように名前空間を明示するので、この問題は基本的に起きない。

ハマるのはnamespaceを使わずに自前で翻訳ファイルをマージしている構成。小規模プロジェクトで「namespace管理は大げさだから全部フラットに合体させよう」とやると、ファイルが増えた段階でこの罠にかかる。

所感

正直、shallow mergeだと気づくまでに結構かかった。最初は翻訳ファイルのキーのtypoを疑って、次にビルドキャッシュを疑って、JSONのパースエラーを疑って…と遠回りした。「spread構文でオブジェクトをマージしている」コードは日常的に書くし読むので、そこが壊れているとは思わなかった。

翻訳ファイルを分割管理するなら、最初からnamespace方式にするか、マージ処理はdeep mergeにしておくか、どちらかをやっておいた方がいい。後から直すと、すでに消えていた翻訳キーを全画面チェックし直す羽目になる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?