ブラウザの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としてデコードする
原因ははっきりしています。Blob(File はそのサブクラス)の 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-japanese の detect を併用する(判定だけライブラリに任せ、デコードは 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-japaneseのdetect→convertを通す共通デコーダ。UTF-8とSJISの両方を受けられます -
TextDecoder('shift_jis')は標準APIですが自動判定はできないため、入力が混在するなら判定の仕組みが別途必要です - テストのfixtureはSJISエンコードしたバイト列から作ること。UTF-8保存のサンプルCSVではバグを検知できません
文字コードの問題は「動いているように見える」時間が長いぶん、気づいたときの被害が大きくなりがちです。CSVアップロード機能を作る際は、最初から自動判定つきのデコーダを共通部品として用意しておくことをおすすめします。
