テキストエリアで入力した文章を画面に表示したとき、改行が無視されて1行にまとまってしまう——JavaScript でこの問題を解決するには、改行コードを <br> タグに変換する方法と、CSS で改行を保持する方法の2つのアプローチがあります。
ただし、変換した文字列を innerHTML にセットする実装は、XSS(クロスサイトスクリプティング)の脆弱性を生みやすい落とし穴があります。この記事では安全な実装方法を中心に、改行変換の基礎から実践パターンまで解説します。
改行コードの種類を理解する
テキストの改行は OS や入力方法によって3種類の文字コードが使われます。変換前に把握しておきましょう。
| 改行コード | エスケープ表記 | 主な環境 |
|---|---|---|
| LF(Line Feed) | \n |
Linux・macOS・Web標準 |
| CR(Carriage Return) | \r |
古いMac(macOS 9以前) |
| CRLF(CR+LF) | \r\n |
Windows・HTTPヘッダー |
<textarea> に入力した値は、ブラウザが自動的に LF(\n)に正規化します。ただし外部からテキストを取得する場合(APIレスポンス、ファイル読み込みなど)は CRLF が含まれることがあります。確実に対応するために、変換前に CRLF → LF の正規化を行うのが安全です。基本:replace で改行を <br> に変換する
String.prototype.replace() に正規表現を使って改行コードを <br> に置き換えます。
/**
* テキストの改行コードを <br> に変換する
* \r\n → \n に正規化してから \n → <br> に変換
*/
function nl2br(text) {
return text
.replace(/\r\n/g, '\n') // CRLF を LF に統一
.replace(/\r/g, '\n') // CR のみも LF に統一
.replace(/\n/g, '<br>'); // LF を <br> に変換
}
// 使い方
const text = '1行目\n2行目\r\n3行目';
console.log(nl2br(text));
// '1行目<br>2行目<br>3行目'
\r\n(CRLF)を先に変換しないと、\r と \n がそれぞれ別の <br> に変換されて二重の改行になります。必ず CRLF → LF → <br> の順で処理してください。innerHTML へのセットと XSS の危険性
変換した文字列を innerHTML にセットすると改行が反映されますが、ユーザーが入力したテキストをそのまま渡すと XSS 攻撃の入口になります。
// NG: ユーザー入力をエスケープせずに innerHTML にセット
const userInput = '<img src=x onerror=alert("XSS")>\n悪意ある入力';
element.innerHTML = nl2br(userInput);
// → <img src=x onerror=alert('XSS')> がHTMLとして実行されてしまう!
innerHTML に渡す前に <・>・&・"・'の5文字をHTMLエンティティに変換(エスケープ)してください。/**
* HTMLの特殊文字をエスケープする
*/
function escapeHtml(text) {
return text
.replace(/&/g, '&') // & は最初に変換(順番重要)
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* エスケープしてから <br> 変換する(安全版)
*/
function safeNl2br(text) {
return escapeHtml(text)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n/g, '<br>');
}
// 使い方
const userInput = '<script>alert("XSS")</script>\n普通のテキスト';
element.innerHTML = safeNl2br(userInput);
// → <script>alert("XSS")</script><br>普通のテキスト
// → スクリプトは実行されず、テキストとして表示される
< を後で変換した場合、すでに変換済みの & が & に二重変換されてしまいます。& → & を必ず最初に処理してください。より安全な方法:textContent + white-space: pre-wrap
XSS を根本的に防ぐには、innerHTML を使わず textContent を使う方法が最も安全です。textContent はHTMLとして解釈されないため、スクリプトが実行されません。CSS の white-space: pre-wrap を組み合わせると、<br> 変換なしで改行を表示できます。
<div id="output" class="pre-wrap"></div>
.pre-wrap {
white-space: pre-wrap; /* 改行・スペースを保持しつつ折り返す */
word-break: break-word; /* 長い単語も折り返す */
}
const output = document.getElementById('output');
const userInput = '<script>alert("XSS")</script>\n普通のテキスト';
// textContent はHTMLを解釈しないため XSS の心配がない
output.textContent = userInput;
// CSS の white-space: pre-wrap によって
// \n がそのまま改行として表示される(<br> 変換不要!)
textContent + white-space: pre-wrap の組み合わせは、エスケープ処理も <br> 変換も不要で、XSS リスクがゼロです。innerHTML を使う必要があるのは、変換後に 自分でコントロールできるHTMLタグ(太字・リンクなど)を含めたいときだけにしましょう。テキストエリア入力を安全に表示する完全実装
2つのアプローチを使った実装例をまとめます。
<div class="editor">
<textarea id="inputText" rows="6" placeholder="テキストを入力してください..."></textarea>
<div class="preview-tabs">
<button type="button" id="previewBtn">プレビュー</button>
</div>
<div id="preview" class="preview pre-wrap"></div>
</div>
document.getElementById('previewBtn').addEventListener('click', () => {
const text = document.getElementById('inputText').value;
const preview = document.getElementById('preview');
// XSS リスクなし・エスケープ不要・<br> 変換不要
preview.textContent = text;
});
document.getElementById('previewBtn').addEventListener('click', () => {
const text = document.getElementById('inputText').value;
const preview = document.getElementById('preview');
// エスケープ → <br> 変換 → innerHTML
preview.innerHTML = safeNl2br(text);
// ※ white-space: pre-wrap の CSS は不要になる
});
.preview {
margin-top: 12px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
min-height: 80px;
}
.pre-wrap {
white-space: pre-wrap;
word-break: break-word;
}
リアルタイムプレビュー(input イベント版)
ボタンを押さなくても入力に応じてリアルタイムにプレビューを更新するパターンです。
const inputText = document.getElementById('inputText');
const preview = document.getElementById('preview');
inputText.addEventListener('input', () => {
// textContent 方式(推奨)
preview.textContent = inputText.value;
});
末尾・連続改行の制御
入力末尾の余分な改行や、連続する改行を制御したいケースの処理です。
function normalizeNewlines(text, options = {}) {
const {
trimEnd = true, // 末尾の改行を除去
maxConsecutive = 0, // 連続改行の最大数(0 = 制限なし)
} = options;
// CRLF / CR を LF に統一
let result = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// 末尾の改行を除去
if (trimEnd) result = result.replace(/\n+$/, '');
// 連続改行を N 個に圧縮
if (maxConsecutive > 0) {
const pattern = new RegExp(`\\n{${maxConsecutive + 1},}`, 'g');
result = result.replace(pattern, '\n'.repeat(maxConsecutive));
}
return result;
}
// 使い方:末尾改行除去 + 連続改行を2つまでに圧縮
const cleaned = normalizeNewlines(text, { trimEnd: true, maxConsecutive: 2 });
preview.textContent = cleaned;
よくある質問
const msg = "Hello\nWorld")であればXSS リスクはありません。「どこからそのテキストが来るか」で判断してください。API・フォーム・URLパラメータ・localStorage など外部由来のテキストは必ずエスケープしてください。pre-wrap は改行・スペースをそのまま保持しつつ、コンテナの幅で折り返します。pre-line は改行は保持しますが、連続するスペースは1つに圧縮します。ユーザーが入力したテキストをそのまま表示するなら pre-wrap が適切です。dangerouslySetInnerHTML を使うと innerHTML と同様の処理ができますが、名前の通り危険です。ユーザー入力には <div style={{whiteSpace:"pre-wrap"}}>{text}</div> のように JSX で直接テキストを渡す方が安全です。Vue では v-html が innerHTML 相当ですが同様にエスケープが必要です。{{ text }}(Mustache構文)はデフォルトでエスケープされるため安全です。nl2br() は改行の直前に <br /> を挿入し、改行コード自体は残します。JavaScript で完全再現するには: text.replace(/(\r\n|\r|\n)/g, '<br />$1') です。単純に改行を <br> に置換するだけであれば冒頭の nl2br() 関数で十分です。<br> を含む文字列を JSON.stringify() すると < や > はそのまま保存されます。ただし後からJSON文字列を再度 innerHTML にセットする場合は、二重エスケープに注意してください。一般的には、保存時はオリジナルのテキスト(改行コードのまま)を保存し、表示するたびに変換する設計が保守しやすいです。まとめ
改行の扱いと安全な実装方法をまとめます。
- 最も安全な方法は
textContent+ CSSwhite-space: pre-wrap。XSS リスクなし・変換コードなし <br>変換してinnerHTMLに渡す場合は、必ず事前に HTML エスケープする- CRLF・CR・LF の3種類があるため、変換前に
\r\n → \n → \r → \nの順で正規化する &のエスケープは最初に行う(二重変換防止)- 外部由来のテキスト(API・フォーム・URLパラメータ)は必ずエスケープを疑う
フォームバリデーションと組み合わせた実装は【JavaScript】フォームバリデーション完全ガイド、要素の動的追加については【JavaScript】マウスオーバーで要素を動的に追加・削除する実践ガイドもあわせてご覧ください。
