DataTablesの一覧にCSVダウンロードを付けるだけなら、Buttons拡張を使うのが一番早いです。
ただし、serverSide: trueでサーバー側処理にしている画面では、「画面に見えている分」と「CSVに出したい全量」がずれることがあります。
この記事では、Buttons拡張でCSVボタンを追加する基本形と、サーバー側処理を使っている場合に全量CSVを出す設計を整理します。
サーバー側で一度に全件CSVを作るとメモリ使用量が大きくなることもあるため、既存のページングAPIを繰り返し呼び出してクライアント側でCSVを組み立てる方法もあわせて扱います。
公式ページ
Buttons拡張を使う
DataTables本体だけではCSVダウンロードボタンは使えません。
CSV、Excel、PDF、コピーなどの出力機能を使うには、Buttons拡張を読み込みます。
CDNで読み込む場合の例です。
<link rel="stylesheet" href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/3.2.5/css/buttons.dataTables.min.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/buttons/3.2.5/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/3.2.5/js/buttons.html5.min.js"></script>
Buttonsを使う場合は、DataTables本体に加えて以下が必要です。
- Buttons本体
- HTML5出力用のButtonsプラグイン
- Buttons用のCSS
実際のバージョンや組み合わせは、DataTablesのDownload Builderで選ぶと間違いにくいです。
CSVボタンを追加する
DataTables 2系では、layoutオプションでボタンの表示位置を指定できます。
new DataTable('#usersTable', {
layout: {
topStart: {
buttons: ['csvHtml5']
}
}
});
これだけで、DataTablesの上部にCSV出力ボタンが表示されます。
csvHtml5の代わりに、短い別名としてcsvも使えます。
new DataTable('#usersTable', {
layout: {
topStart: {
buttons: ['csv']
}
}
});
DataTables 1.x系のサンプルでは、dom: 'Bfrtip'のような書き方もよく見かけます。
DataTables 2系でも互換のために使えますが、新しく書くならlayoutを使う方が分かりやすいです。
ボタン名やファイル名を変更する
ボタンの表示名やファイル名を変えたい場合は、ボタンをオブジェクトで指定します。
new DataTable('#usersTable', {
layout: {
topStart: {
buttons: [
{
extend: 'csvHtml5',
text: 'CSVダウンロード',
filename: 'users',
bom: true
}
]
}
}
});
textはボタンに表示する文字、filenameはダウンロードするファイル名です。bom: trueを指定すると、UTF-8 BOM付きのCSVになります。
Excelで日本語CSVを開く場合、環境によってはBOMがないと文字化けすることがあります。
そのため、日本語のCSVをExcelで開く想定がある場合は、bom: trueを付けておくと扱いやすいです。
出力する列を指定する
操作ボタン列やチェックボックス列など、CSVに出したくない列がある場合はexportOptions.columnsを使います。
new DataTable('#usersTable', {
layout: {
topStart: {
buttons: [
{
extend: 'csvHtml5',
text: 'CSVダウンロード',
bom: true,
exportOptions: {
columns: [0, 1, 2]
}
}
]
}
}
});
表示中の列だけを出力したい場合は、:visibleも使えます。
new DataTable('#usersTable', {
layout: {
topStart: {
buttons: [
{
extend: 'csvHtml5',
text: '表示列をCSV出力',
exportOptions: {
columns: ':visible'
}
}
]
}
}
});
画面に操作列がある場合は、以下のように操作列をCSV対象外にすることが多いです。
new DataTable('#usersTable', {
columns: [
{ data: 'id', title: 'ID' },
{ data: 'name', title: '名前' },
{ data: 'email', title: 'メール' },
{
data: null,
title: '操作',
searchable: false,
orderable: false,
render: function () {
return '<button type="button">詳細</button>';
}
}
],
layout: {
topStart: {
buttons: [
{
extend: 'csvHtml5',
text: 'CSVダウンロード',
exportOptions: {
columns: [0, 1, 2]
}
}
]
}
}
});
検索結果だけ出力する
DataTablesのCSV出力は、検索や並び替えの状態を反映できます。
標準では、検索条件や並び替えが反映されたデータが出力対象になります。
検索条件を無視して、DataTablesが持っている全データを出力したい場合は、exportOptions.modifier.searchをnoneにします。
new DataTable('#usersTable', {
layout: {
topStart: {
buttons: [
{
extend: 'csvHtml5',
text: '全データをCSV出力',
exportOptions: {
modifier: {
search: 'none'
}
}
}
]
}
}
});
この方法は、DataTablesがブラウザ側に全データを持っている場合に有効です。
たとえば、HTMLのtbodyに全件がある場合や、Ajaxで全件を読み込んでいる場合です。
serverSideでは現在ページ分しか出ないことがある
serverSide: trueを使っている場合、DataTablesはページング、検索、並び替えのたびにサーバーへAjaxリクエストを送ります。
ブラウザ側には、基本的に現在表示に必要な分のデータだけがあります。
そのため、ButtonsのCSV出力をそのまま使うと、現在のページにあるデータだけがCSV出力されることがあります。
new DataTable('#usersTable', {
processing: true,
serverSide: true,
ajax: '/api/users',
layout: {
topStart: {
buttons: ['csvHtml5']
}
},
columns: [
{ data: 'id', title: 'ID' },
{ data: 'name', title: '名前' },
{ data: 'email', title: 'メール' }
]
});
この状態でCSVボタンを押しても、DataTablesがブラウザ側に持っていない行は出力できません。exportOptions.modifier.search: 'none'を付けても、サーバー上にしかない全件データをブラウザだけで出力することはできません。
serverSideで全量CSVを出したい場合
サーバー側処理を使っている画面で全量CSVを出したい場合は、CSV出力用の専用エンドポイントを作るのが安全です。
考え方は以下です。
- DataTablesの画面表示は
serverSide: trueでページ単位に取得する - CSVボタンはDataTablesの標準CSV出力ではなく、独自ボタンにする
- 現在の検索条件や並び替え条件をCSV用APIへ渡す
- サーバー側で条件に合う全件をCSVとして返す
- ブラウザはCSVファイルをダウンロードする
フロント側の例です。
const table = new DataTable('#usersTable', {
processing: true,
serverSide: true,
ajax: {
url: '/api/users/search',
type: 'POST'
},
columns: [
{ data: 'id', title: 'ID', name: 'id' },
{ data: 'name', title: '名前', name: 'name' },
{ data: 'email', title: 'メール', name: 'email' },
{ data: 'createdAt', title: '登録日', name: 'created_at' }
],
layout: {
topStart: {
buttons: [
{
text: '全件CSVダウンロード',
action: function () {
downloadAllUsersCsv(table);
}
}
]
}
}
});
CSVダウンロード関数の例です。
function downloadAllUsersCsv(table) {
const params = table.ajax.params();
const query = new URLSearchParams();
query.set('search', params.search.value ?? '');
if (params.order.length > 0) {
const order = params.order[0];
const column = params.columns[order.column];
query.set('orderColumn', column.name);
query.set('orderDir', order.dir);
}
window.location.href = `/api/users/csv?${query.toString()}`;
}
table.ajax.params()を使うと、DataTablesが直近のAjaxリクエストで送ったパラメータを取得できます。
そこから検索条件や並び替え条件を取り出し、CSV用APIへ渡します。
この例ではGETでCSVを取得しています。
検索条件が多い場合や、条件が長くなる場合はPOSTでCSV作成リクエストを送る設計にしてもよいです。
サーバー側CSV APIの考え方
CSV用APIでは、DataTablesのページング用APIとは違い、条件に一致する全件を対象にします。
Node.jsの疑似コードで書くと、以下のようなイメージです。
app.get('/api/users/csv', async (req, res) => {
const search = String(req.query.search ?? '');
const orderColumn = getAllowedOrderColumn(req.query.orderColumn);
const orderDir = req.query.orderDir === 'desc' ? 'DESC' : 'ASC';
const rows = await userRepository.findAllForCsv({
search,
orderColumn,
orderDir
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="users.csv"');
res.write('\uFEFF');
res.write('ID,名前,メール,登録日\n');
for (const row of rows) {
res.write([
csvEscape(row.id),
csvEscape(row.name),
csvEscape(row.email),
csvEscape(row.createdAt)
].join(',') + '\n');
}
res.end();
});
CSVの1項目をエスケープする関数の例です。
function csvEscape(value) {
const text = String(value ?? '');
const escaped = text.replaceAll('"', '""');
return `"${escaped}"`;
}
CSVでは、カンマ、改行、ダブルクォートを含む値に注意が必要です。
基本的には各値をダブルクォートで囲み、値の中のダブルクォートは""にします。
既存のページングAPIを使ってクライアント側で全件CSVを作る
CSV専用APIでサーバー側が全件を取得してCSVを作る方法は分かりやすいですが、対象件数が多い場合はサーバー側のメモリ使用量が大きくなることがあります。
特に、全件を配列に読み込んでからCSV文字列を作る実装にすると、データ件数や列数によってはメモリを圧迫します。
すでにDataTables用のページングAPIがあるなら、そのAPIをクライアント側からページごとに呼び出し、取得したデータを少しずつCSV行へ変換する方法もあります。
この方法ではサーバーは通常のページ取得処理を繰り返すだけなので、サーバー側で巨大なCSVを一度に組み立てる必要がありません。
考え方は以下です。
- 現在の検索条件や並び替え条件を
table.ajax.params()から取得する startとlengthを指定して、既存のDataTables用APIをページごとに呼び出す- レスポンスの
dataをCSV行に変換して配列へ追加する recordsFilteredに達するまで繰り返す- 最後にBlobを作ってCSVとしてダウンロードする
例として、既存の/api/users/searchがDataTablesのサーバー側処理形式でレスポンスを返す場合を考えます。recordsFilteredには検索後の総件数、dataにはそのページ分のデータが入っている想定です。
async function downloadAllUsersCsvByPages(table) {
const baseParams = table.ajax.params();
const pageSize = 1000;
const rows = [['ID', '名前', 'メール', '登録日']];
let start = 0;
let total = null;
while (total === null || start < total) {
const params = {
...baseParams,
start,
length: pageSize
};
const response = await fetch('/api/users/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(params)
});
if (!response.ok) {
alert('CSV用データの取得に失敗しました。');
return;
}
const result = await response.json();
total = result.recordsFiltered;
for (const user of result.data) {
rows.push([
user.id,
user.name,
user.email,
user.createdAt
]);
}
start += pageSize;
}
downloadCsvRows(rows, 'users.csv');
}
CSVとしてダウンロードする処理は以下のように分けておくと、他の一覧でも使い回しやすくなります。
function downloadCsvRows(rows, filename) {
const csv = rows
.map(row => row.map(csvEscape).join(','))
.join('\n');
const blob = new Blob(['\uFEFF', csv], {
type: 'text/csv;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
この方式は、サーバー側のメモリ使用量を抑えやすい一方で、ブラウザ側には最終的なCSVデータが集まります。
そのため、数十万件のような大きなデータでは、今度はブラウザ側のメモリや処理時間が問題になります。
あくまで「サーバー側で巨大CSVを一括生成したくないが、既存のページングAPIで現実的に取得できる件数」のための選択肢として考えるのがよいです。
実運用では、以下のような制御も入れておくと安全です。
- CSV出力できる最大件数を決める
- 取得中はボタンを無効化して二重実行を防ぐ
- 進捗表示を出す
- 1回の
lengthを大きくしすぎない - API側でも
lengthの上限を固定する - 権限チェックは各ページ取得APIでも必ず行う
件数が非常に多い場合や、CSV作成に時間がかかる場合は、サーバー側でストリーミング出力する、またはバックグラウンドジョブでCSVを作成して後からダウンロードさせる方式も検討します。
どの方式でも、画面表示用のページングAPIとCSV出力の要件を分けて考えることが重要です。
並び替えカラムはホワイトリストで制限する
CSV用APIでは、画面から送られてきたorderColumnをそのままSQLに入れないようにします。
const ORDER_COLUMNS = {
id: 'id',
name: 'name',
email: 'email',
created_at: 'created_at'
};
function getAllowedOrderColumn(value) {
return ORDER_COLUMNS[value] ?? 'id';
}
DataTablesのcolumns.nameに、サーバー側で許可した名前を入れておくと扱いやすくなります。
columns: [
{ data: 'id', title: 'ID', name: 'id' },
{ data: 'name', title: '名前', name: 'name' },
{ data: 'email', title: 'メール', name: 'email' },
{ data: 'createdAt', title: '登録日', name: 'created_at' }
]
サーバー側では、受け取った値をホワイトリストに通してから使います。
SQLを組み立てる場合は、値の埋め込みにもプレースホルダやクエリビルダを使いましょう。
POSTでCSVを取得したい場合
検索条件が複雑な場合は、window.location.hrefではなくfetchでPOSTし、BlobとしてCSVをダウンロードする方法もあります。
async function downloadAllUsersCsv(table) {
const params = table.ajax.params();
const response = await fetch('/api/users/csv', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
search: params.search.value,
order: params.order,
columns: params.columns
})
});
if (!response.ok) {
alert('CSVの作成に失敗しました。');
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'users.csv';
link.click();
URL.revokeObjectURL(url);
}
POSTにする場合は、CSRF対策も忘れずに行います。
また、CSVの件数が非常に多い場合は、同期的にすぐ返すのではなく、バックグラウンドでCSVを作成して、完了後にダウンロードさせる設計も検討します。
画面表示用とCSV出力用の値を分ける
画面には「有効」「停止」のような表示用ラベルを出し、データ上はactiveやstoppedを持っている場合があります。
CSVにどちらを出したいかを決めておきます。
画面表示と同じ値をCSVに出す場合は、renderでdisplay以外の値も意識します。
const STATUS_LABELS = {
active: '有効',
stopped: '停止'
};
new DataTable('#usersTable', {
data: users,
columns: [
{
data: 'status',
title: 'ステータス',
render: function (data, type) {
if (type === 'display' || type === 'export') {
return STATUS_LABELS[data] ?? '不明';
}
return data;
}
}
],
layout: {
topStart: {
buttons: [
{
extend: 'csvHtml5',
text: 'CSVダウンロード',
exportOptions: {
orthogonal: 'export'
}
}
]
}
}
});
exportOptions.orthogonalを使うと、CSV出力時にrenderへ渡す種類を指定できます。
表示用、検索用、ソート用、CSV出力用を分けたい場合に便利です。
CSV出力時の注意点
CSVダウンロードでは、以下にも注意します。
- 日本語をExcelで開くなら
bom: trueを検討する - 操作列や非表示にしたい列は
exportOptions.columnsで除外する - HTMLを含む列は、CSVに出したい値に整える
serverSide: trueではブラウザ側に全件がない- 全量CSVはサーバー側で作る方法と、ページごとに取得してクライアント側で作る方法がある
- 大量CSVはサーバー側とブラウザ側の両方でタイムアウトやメモリ使用量に注意する
- CSVをExcelで開く場合は、数式インジェクションにも注意する
ButtonsのCSV出力では、HTMLを取り除いたり、HTMLエンティティをデコードしたりするためのオプションがあります。
大量データをブラウザ側で出力する場合は、変換処理自体が重くなることもあります。
まとめ
全量をブラウザ側で読み込んでいる一覧なら、Buttons拡張のcsvHtml5でCSVダウンロードを付けられます。
- CSV出力にはButtons拡張を使う
- DataTables 2系では
layoutにbuttonsを配置する - 日本語CSVをExcelで開くなら
bom: trueを検討する - 出力対象列は
exportOptions.columnsで制御する - 検索条件を無視して出力するなら
exportOptions.modifier.search: 'none'を使う serverSide: trueではブラウザ側に現在ページ分しかないことがある- サーバー側処理で全量CSVを出すなら、CSV専用APIを作る方法がある
- サーバー側のメモリが気になる場合は、既存のページングAPIを繰り返し呼び出してクライアント側でCSVを組み立てる方法もある
- 検索条件や並び替え条件をCSV API、またはページングAPIへ渡し、画面と同じ条件でCSVを生成する
小規模な一覧ならButtonsのCSVボタンだけで十分です。
一方、大量データやserverSide: trueの画面では、サーバー側でCSVを作る、ページごとにクライアント側で集める、バックグラウンドで作る、といった選択肢をデータ量に応じて使い分けます。
コメント