アルアカ - Arcadia Academia

Arcadia Academiaは「エンジニアリングを楽しむ」を合言葉に日本のデジタル競争力を高めることをミッションとするテックコミュニティです。

JavaScriptでShift_JISのCSVが文字化けする原因と対処 — File.text()はUTF-8固定、encoding-japaneseで解決する

Featured image of the post

ブラウザのCSVアップロード機能で file.text() を使ったら、日本語の列だけが「�」だらけになってしまった——そんな経験はないでしょうか。筆者も国内の業務システムから出力されたCSVを取り込む機能を実装した際、まったく同じ現象に遭遇しました。この記事では、File.text() がUTF-8固定でデコードされる仕様と、encoding-japanese を使ってUTF-8/Shift_JIS両対応の共通デコーダを作る方法、そして「一見動いているのにデータの一部だけ壊れる」というこの問題特有の怖さをバイトレベルで解説します。

📣
システム開発・業務効率化のご相談はお気軽にどうぞ!

[目次を開く]

1. 症状: CSVを読み込むと日本語の列だけが文字化けする

まず典型的な症状から確認します。次のような、ごく普通のCSV読み込みコードがあるとします。

const input = document.querySelector('#csv-input');
input.addEventListener('change', async (event) => {
  const file = event.target.files[0];
  const text = await file.text();
  console.log(text);
});

自分で作ったサンプルCSVでは問題なく動くのに、実際のユーザーがアップロードしたファイルでは日本語が次のように化けます。

商品コード,商品名,数量,更新日
A-001,������,120,2026-07-01
A-002,����������,45,2026-07-02

注目してほしいのは、「商品コード」「数量」「更新日」のようなASCIIだけの列は正常に読めている点です。壊れるのは日本語テキストの列だけ。この「部分的に正しい」挙動が、後述するとおりこの問題を厄介にしています。

2. 原因: File.text() は常にUTF-8としてデコードする

原因ははっきりしています。BlobFile はそのサブクラス)の text() メソッドは、仕様上、常にUTF-8としてデコードするからです。エンコーディングを指定する引数は存在せず、ファイルの実際の文字コードが何であろうと問答無用でUTF-8として解釈されます。

一方、日本の業務システム・基幹システム由来のCSVは、いまだにShift_JIS(正確にはその拡張であるCP932)で出力されるものが少なくありません。ExcelでCSV保存した場合も、歴史的経緯からShift_JISになっているケースが多くあります。筆者が扱った国内の倉庫管理システム由来のCSVもShift_JISでした。

つまり「Shift_JISの実ファイル × UTF-8固定のデコーダ」という組み合わせが、この文字化けの正体です。ファイル側を直せない(ユーザーの手元のシステムは変えられない)以上、受け取る側で文字コードを吸収する必要があります。

3. なぜ「日本語の列だけ」化けるのか — バイトレベルで見る

この問題でいちばん怖いのは、CSVとしての構造は一切壊れないことです。理由をバイトレベルで見てみます。

Shift_JISの2バイト文字は、1バイト目が 0x81–0x9F / 0xE0–0xFC、2バイト目が 0x40–0x7E / 0x80–0xFC の範囲を取ります。ここで重要なのは、カンマ(0x2C)は2バイト目の範囲に決して現れないという点です。同様にダブルクォート(0x22)や改行(0x0A)も範囲外です。

この性質から、UTF-8として誤デコードしても次のことが保証されます。

  • 区切り文字はすべて「本物のカンマ」なので、列の数・位置は正しく保たれる
  • ASCIIのみのフィールド(商品コード、数値、日付など)は、ASCIIがUTF-8のサブセットであるためバイト単位で完全に一致し、正常に読める
  • 日本語フィールドのバイト列だけがUTF-8として不正なシーケンスとなり、置換文字 U+FFFD(�)に化ける

結果として「パースは成功する」「件数も合う」「コード値・金額・日付は正しい」「日本語の名称列だけ壊れている」という状態でデータが取り込まれます。バリデーションが数値やコード列中心だと検知されず、壊れたデータが静かにDBまで到達することすらあり得ます。派手にクラッシュしてくれるバグより、よほどたちが悪いと筆者は思います。

4. 解決: encoding-japanese で自動判定つき共通デコーダを作る

対処の基本方針は、file.text() を使わず file.arrayBuffer()生のバイト列を取得し、文字コードを自動判定してからデコードすることです。定番ライブラリの encoding-japanese を使います。

npm install encoding-japanese

共通デコーダの完成形はこちらです。そのままコピペで動きます。

import Encoding from 'encoding-japanese';

/**
 * File を文字コード自動判定つきでテキスト化する共通デコーダ。
 * UTF-8 / Shift_JIS(CP932) どちらのCSVも受け付けられる。
 */
export async function readFileAsText(file) {
  const buffer = await file.arrayBuffer();
  const bytes = new Uint8Array(buffer);

  // 文字コードを自動判定('SJIS' / 'UTF8' などが返る)
  const detected = Encoding.detect(bytes);

  // Unicodeコードポイント配列へ変換して文字列化
  const unicodeArray = Encoding.convert(bytes, {
    to: 'UNICODE',
    from: detected || 'AUTO',
  });
  return Encoding.codeToString(unicodeArray);
}

呼び出し側は file.text() を置き換えるだけです。

const file = event.target.files[0];
const text = await readFileAsText(file); // ← file.text() の代わり

処理の流れを図にすると次のとおりです。

flowchart TD
    upload["ファイル選択"] --> buf["file.arrayBuffer() でバイト列を取得"]
    buf --> detect{"Encoding.detect で文字コード判定"}
    detect -->|"SJIS"| conv["Encoding.convert で UNICODE へ変換"]
    detect -->|"UTF8"| conv
    conv --> mkstr["Encoding.codeToString で文字列化"]
    mkstr --> parse["CSVパース処理へ"]

ポイントは、Shift_JIS決め打ちにしないことです。同じアップロード窓口に、基幹システム由来のSJISファイルと、モダンなツールが吐いたUTF-8ファイルの両方が来るのが現実です。自動判定を挾んでおけば、どちらが来ても同じコードで受けられます。

5. TextDecoder('shift_jis') という選択肢と使い分け

「ライブラリを足したくない」場合、ブラウザ標準の TextDecoder もShift_JISに対応しています。

const buffer = await file.arrayBuffer();
const text = new TextDecoder('shift_jis').decode(buffer);

ただし TextDecoder指定した文字コードでデコードするだけで、自動判定はできません。この方法でSJIS決め打ちにすると、今度はUTF-8のファイルが来たときに化けます。「入力が必ずSJISだと保証できる」ケース以外では、encoding-japanesedetect を併用する(判定だけライブラリに任せ、デコードは TextDecoder に渡す構成も可能です)価値があります。筆者は判定と変換を1ライブラリで完結できる前掲の共通デコーダに落ち着きました。

6. テストの罠: サンプルCSVをUTF-8で保存していると素通りする

もうひとつ、筆者が実際に踏んだ罠がテストです。E2Eテスト用のサンプルCSVをエディタで作ると、ほぼ確実にUTF-8で保存されます。UTF-8のファイルなら file.text() のままでも化けないので、バグがあってもテストは緑のまま通ってしまいます。

テストは「SJISエンコードしたバイト列から正しく復元できるか」で書くべきです。Node側で encoding-japanese(あるいは iconv-lite)を使ってSJISバイト列をfixtureとして生成します。

import Encoding from 'encoding-japanese';
import { readFileAsText } from '../src/readFileAsText';

// テスト内でSJISバイト列のfixtureを作る
function toSjisBytes(text) {
  const unicodeArray = Encoding.stringToCode(text);
  const sjisArray = Encoding.convert(unicodeArray, {
    to: 'SJIS',
    from: 'UNICODE',
  });
  return new Uint8Array(sjisArray);
}

test('Shift_JISのCSVから日本語を復元できる', async () => {
  const sjisBytes = toSjisBytes('商品コード,商品名\nA-001,鉛筆');
  const file = new File([sjisBytes], 'items.csv', { type: 'text/csv' });

  const text = await readFileAsText(file);
  expect(text).toContain('鉛筆');
});

「実データと同じエンコーディングでテストする」——当たり前のようで、文字コードの問題では特に見落としやすいポイントです。

7. よくある質問

Q. 文字コードの自動判定は100%正確ですか?

いいえ、原理的に100%ではありません。短いテキストや記号中心のデータでは誤判定の余地があります。ただしCSVのように日本語がある程度含まれる実データでは、実用上ほぼ問題ないというのが筆者の体感です。心配な場合は、判定結果をUIに表示してユーザーが手動で切り替えられる逃げ道を用意しておくと安心です。

Q. アップロードされるCSVをUTF-8に統一してもらうことはできませんか?

相手が既存の業務システムだと、出力エンコーディングの変更は現実的に難しいことが多いです。ExcelのCSV出力も歴史的にSJIS(CP932)が多く、「送り手を直す」より「受け手で吸収する」ほうが早くて確実です。なお、システム間連携で形式から選べるなら、エンコーディング問題の少ないJSONを検討するのも手です。JSONの基礎はJSONとは何かを解説した記事にまとめています。

Q. FileReader.readAsText() なら文字コードを指定できますよね?

はい、readAsText(file, 'Shift_JIS') と第2引数で指定できます。ただしこちらも指定固定で自動判定はできないため、UTF-8/SJIS混在の入力には対応できません。またコールバックベースのAPIなので、いま新規に書くなら arrayBuffer() + 判定つきデコードのほうが素直です。

Q. 化けたデータがすでにDBに入ってしまいました。復元できますか?

U+FFFD(�)への置換は不可逆で、化けた文字列から元のバイト列は復元できません。元のCSVファイルから再取り込みする必要があります。だからこそ、取り込み時点で正しくデコードしておくことが重要です。

8. まとめ

  • File.text() は仕様上常にUTF-8でデコードするため、Shift_JISのCSV(国内の業務システム由来に多い)では日本語が化けます
  • Shift_JISの2バイト目にカンマ(0x2C)は現れないため、列構造やASCII列は壊れず日本語列だけが化ける。一見動いて見えるぶん発見が遅れやすい問題です
  • 対処は file.arrayBuffer() でバイト列を取り、encoding-japanesedetectconvert を通す共通デコーダ。UTF-8とSJISの両方を受けられます
  • TextDecoder('shift_jis') は標準APIですが自動判定はできないため、入力が混在するなら判定の仕組みが別途必要です
  • テストのfixtureはSJISエンコードしたバイト列から作ること。UTF-8保存のサンプルCSVではバグを検知できません

文字コードの問題は「動いているように見える」時間が長いぶん、気づいたときの被害が大きくなりがちです。CSVアップロード機能を作る際は、最初から自動判定つきのデコーダを共通部品として用意しておくことをおすすめします。

フロントエンド開発を学びたいですか?

React・Next.jsなど、モダンなフロントエンド開発をサポートします。

  • Reactの基礎から実践まで学びたい
  • Next.jsでアプリを作りたい
  • TypeScriptを使いこなしたい
まずは30分の無料相談

相談は完全無料・オンラインで気軽に

あなたを爆速で成長させるメンタリングプログラムはこちら

メンタープログラムバナー

学習・開発のお悩みは現役エンジニアに相談

メンタープログラムの詳細を見る

エンジニアの基礎学習ゲーム

プログラミング学習支援

無料相談はこちら