Node環境ならシェルではなくJavaScriptを書いてもいいんじゃね
最近、ビルド前のクリーンアップやデプロイ前処理を書くとき、毎回shやbashの書き方を調べている自分に気づきました。
「このプロジェクトはNode環境があるのに、なぜわざわざshellで書いているんだろう?」
この問いから、JavaScriptでスクリプトを書くというアプローチを試してみたところ、意外なほど快適だったので共有します。
前提:shellとNode.js、どちらを選ぶべきか
まず最初に断っておくと、shellスクリプトを全面的に置き換えようという話ではありません。
shellが向いているケース
- パイプや標準入出力の操作が中心
- システムコマンドを組み合わせるだけで済む
- サーバー環境で素早く処理を書きたい
- Unix系OSでしか動かない前提
Node.jsが向いているケース
- 複雑なエラーハンドリングが必要
- チーム全員がJavaScript/TypeScriptに慣れている
- クロスプラットフォーム対応が必須(Windows含む)
- 既存のnpmパッケージと連携したい
- 条件分岐やループが複雑になりそう
この記事では、後者のケースに焦点を当てて解説します。
Node.jsでスクリプトを書くという選択肢
Node.jsは「サーバーサイドJavaScript」として知られていますが、実は汎用的なスクリプトランタイムとして使うこともできます。
たとえば、scripts/build.mjs のようなファイルを用意して、
node scripts/build.mjs
と実行すれば、それがそのままタスクスクリプトになります。
JavaScriptで書く利点
1. チーム内での可読性が高い
多くのフロントエンド・バックエンドプロジェクトでは、チームメンバー全員がJavaScriptを読める状況が多いです。
一方で:
- shellスクリプトの文法は独特で、慣れていないと読みにくい
-
[[ ]]や$( )、パイプ処理などは初見だと戸惑う - Windowsユーザーはそもそもshellに触れない環境も多い
Node.jsなら、既存のJavaScriptの知識がそのまま活きます。
2. 開発ツールがそのまま使える
.mjsファイルであれば、プロジェクト内の以下のツールがそのまま適用されます:
- ESLint(静的解析)
- Prettier(コード整形)
- VS Code(補完・型推論)
「スクリプトだけコード品質がバラバラ」という状況を避けられます。
3. Node標準APIが実用的
Node.jsの標準モジュール(fs/promises, path, child_process, osなど)は、
ファイル操作からプロセス実行まで幅広くカバーしています。
| 処理 | shell | Node.js |
|---|---|---|
| ディレクトリ削除 | rm -rf dist |
fs.rm("dist", { recursive: true, force: true }) |
| ファイルコピー | cp src.txt dest.txt |
fs.copyFile("src.txt", "dest.txt") |
| コマンド実行 | npm run build |
child_process.exec("npm run build") |
| 環境変数参照 | $ENV_VAR |
process.env.ENV_VAR |
| ファイル存在確認 | [ -f path ] |
fs.access("path") |
Node APIの特徴:
- Promise/async-awaitベースで非同期処理が自然
- クロスプラットフォームでOS差を吸収
- エラーハンドリングが明示的で制御しやすい
実践例1:ビルド前クリーンアップスクリプト
shellで書く場合
#!/bin/bash
set -e # エラーで即時終了
rm -rf dist
echo "🧹 cleaned dist"
vite build
echo "✅ build complete"
問題点:
-
set -eを書き忘れると、エラーでも処理が続く - エラーメッセージの制御が難しい
- Windows環境では動かない(WSL必須)
Node.js(ESM)で書く場合
// scripts/build.mjs
import { rm } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function main() {
try {
// ディレクトリ削除(存在しなくてもエラーにならない)
await rm("dist", { recursive: true, force: true });
console.log("🧹 cleaned dist");
// ビルドコマンド実行
const { stdout, stderr } = await execAsync("vite build");
if (stderr) console.error(stderr);
console.log(stdout);
console.log("✅ build complete");
} catch (error) {
console.error("❌ ビルドに失敗しました");
console.error(error.message);
process.exit(1); // 終了コード1で終了
}
}
main();
package.jsonへの登録:
{
"scripts": {
"build": "node scripts/build.mjs"
}
}
これでnpm run buildで実行できます。
実践例2:環境別デプロイスクリプト(複雑なケース)
次は、条件分岐とエラーハンドリングが複雑になる実用例です。
要件
- 環境(staging/production)に応じて異なる処理を実行
-
.envファイルの存在チェック - ビルド → テスト → デプロイの順次実行
- 途中で失敗したら即座に停止し、わかりやすいエラーメッセージを表示
shellで書く場合
#!/bin/bash
set -e
ENV=${1:-staging}
if [ "$ENV" != "staging" ] && [ "$ENV" != "production" ]; then
echo "❌ 引数は staging または production を指定してください"
exit 1
fi
if [ ! -f ".env.$ENV" ]; then
echo "❌ .env.$ENV が見つかりません"
exit 1
fi
echo "📦 Building for $ENV..."
npm run build
echo "🧪 Running tests..."
npm test
if [ "$ENV" = "production" ]; then
echo "⚠️ 本番環境へデプロイします。続行しますか? (y/n)"
read -r response
if [ "$response" != "y" ]; then
echo "キャンセルしました"
exit 0
fi
fi
echo "🚀 Deploying to $ENV..."
rsync -avz dist/ user@server:/var/www/$ENV/
echo "✅ デプロイ完了: $ENV"
問題点:
- 条件分岐の構文が独特(
[ ]や&&の使い方) - 対話的入力(
read)の処理が複雑 - エラーメッセージの一元管理が難しい
- Windows環境では動かない
Node.js(ESM)で書く場合
// scripts/deploy.mjs
import { access } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import * as readline from "readline";
const execAsync = promisify(exec);
// 対話的入力を取得する関数
function askQuestion(query) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(query, (answer) => {
rl.close();
resolve(answer);
});
});
}
async function main() {
const env = process.argv[2] || "staging";
try {
// 環境の検証
if (!["staging", "production"].includes(env)) {
throw new Error("引数は staging または production を指定してください");
}
// .envファイルの存在確認
const envFile = `.env.${env}`;
try {
await access(envFile);
} catch {
throw new Error(`${envFile} が見つかりません`);
}
// ビルド
console.log(`📦 Building for ${env}...`);
await execAsync("npm run build");
// テスト実行
console.log("🧪 Running tests...");
await execAsync("npm test");
// 本番環境の場合は確認
if (env === "production") {
const answer = await askQuestion(
"⚠️ 本番環境へデプロイします。続行しますか? (y/n): "
);
if (answer.toLowerCase() !== "y") {
console.log("キャンセルしました");
process.exit(0);
}
}
// デプロイ実行
console.log(`🚀 Deploying to ${env}...`);
await execAsync(`rsync -avz dist/ user@server:/var/www/${env}/`);
console.log(`✅ デプロイ完了: ${env}`);
} catch (error) {
console.error("❌ デプロイに失敗しました");
console.error(error.message);
process.exit(1);
}
}
main();
package.jsonへの登録:
{
"scripts": {
"deploy:staging": "node scripts/deploy.mjs staging",
"deploy:prod": "node scripts/deploy.mjs production"
}
}
この例で分かること
| 観点 | shell | Node.js |
|---|---|---|
| 条件分岐 | if [ "$ENV" != "staging" ] |
if (!["staging", "production"].includes(env)) |
| ファイル存在確認 | [ ! -f ".env.$ENV" ] |
await access(envFile) でtry-catch |
| 対話的入力 | read -r response |
readlineモジュール(Promise化できる) |
| エラーメッセージ |
echoで個別出力 |
try-catchで一箇所にまとめられる |
| 可読性 | shell独特の構文 | JavaScriptの標準的な書き方 |
特に**「ファイル存在確認 → ビルド → テスト → 確認 → デプロイ」のような多段階処理**では、Node.jsの方が圧倒的に見通しが良くなります。
比較まとめ
| 観点 | shell (.sh) |
Node.js (.mjs) |
|---|---|---|
| エラー時停止 |
set -e が必要 |
try/catchで明示的に制御 |
| エラーメッセージ | 標準エラー出力に依存 | 整形・カスタマイズ可能 |
| クロスプラットフォーム | Linux/macOS中心 | Windows含め統一動作 |
| 非同期処理 | 複雑 |
async/awaitで自然 |
| 型補完 | なし | VS Code等で効く |
| 再利用性 | 関数化しづらい | モジュール化しやすい |
| 条件分岐 | 独特の構文 | JavaScript標準の構文 |
よくある疑問:「結局shellコマンドを呼んでるだけでは?」
その通りです。Node.jsスクリプトでもexecで外部コマンドを呼ぶ場合が多いです。
ただし、以下の点で価値があります:
-
エラーハンドリングの一元化
shellで複数コマンドを実行するとエラー処理が煩雑になりますが、Node.jsならtry/catchで一箇所にまとめられます。 -
条件分岐や繰り返しが書きやすい
shellのifやforは独特ですが、JavaScriptなら慣れた構文で書けます。 -
既存のnpmパッケージと統合できる
例:.envの読み込み、YAML/JSONの解析、HTTPリクエストなど。
発展:DenoやBunならさらに快適
Node.jsでも十分実用的ですが、DenoやBunを使うとさらにモダンになります。
Deno/Bunの利点
- TypeScriptがそのまま動く(トランスパイル不要)
- 標準で
fetchが使える - 高レベルなファイルAPIが充実
- 起動が高速
ただし、NodeのAPIとの互換性が完全ではない点に注意が必要です。
既存のNodeプロジェクトに導入する場合は、段階的に試すことをお勧めします。
まとめ:「小さなプログラム」としてのスクリプト
shellスクリプトを書く代わりに、
**「JavaScriptで小さなプログラムを書く」**という視点に変えると、
- 読みやすさ(チーム全員が読める)
- 保守性(linter/formatterが効く)
- クロスプラットフォーム性(Windows対応)
など、多くのメリットがあります。
もちろん、すべてのshellスクリプトをNode.jsに置き換える必要はありません。
しかし、複雑なエラーハンドリングが必要な場合や、チーム全員がJavaScriptに慣れている場合は、
.mjsファイルを一枚置いてみる価値があります。
「NodeをCLIランタイムとして使う」時代が、すでに始まっているのかもしれません。
Discussion