はじめに
Linuxコマンドの学習で、いちばん多い離脱ポイントはどこだと思いますか。
私の答えは「環境構築」です。ls も cd も打つ前に、VirtualBox のインストール、ISO のダウンロード、ネットワーク設定でつまずいて終わる。これを何とかしたくて、ブラウザだけで完結する疑似ターミナルを実装しました。
この記事では、その中身——とくに vi エディタをどう再現したか を中心に紹介します。
対象ページはこちらです。
作ったもの
ブラウザ上で以下が動きます。
$ ls
$ cd /var/log
$ grep error syslog
$ vi memo.txt
サーバーへの通信はゼロ。すべてクライアントサイドで完結します。
なぜ実機でもWASMでもないのか
選択肢は3つありました。
| 方式 | メリット | デメリット |
|---|---|---|
| 実機に接続 | 完全に本物 | 同時接続数に上限、コスト、予約制になる |
| WASM(v86等でLinuxを動かす) | 本物のカーネルが動く | 初回数十MBのダウンロード、低スペック端末で厳しい |
| 疑似ターミナル | 軽量、同時接続無制限 | 想定外のコマンドに対応できない |
学習サイトとして重視したのは「開いた瞬間に打てること」でした。数十MBのロードを待たせる時点で、環境構築で挫折する層を救えません。そこで疑似ターミナル方式を選びました。
仮想ファイルシステムの持ち方
まずファイルシステムをJSのオブジェクトで持ちます。
const fs = {
"/": {
type: "dir",
children: {
"home": {
type: "dir",
children: {
"user": {
type: "dir",
children: {
"memo.txt": { type: "file", content: "hello\nworld\n" }
}
}
}
},
"var": { type: "dir", children: { /* ... */ } }
}
}
};
let cwd = "/home/user";
ls や cd は、このツリーを辿って結果を返すだけです。
function resolvePath(path, cwd) {
const base = path.startsWith("/") ? [] : cwd.split("/").filter(Boolean);
for (const seg of path.split("/").filter(Boolean)) {
if (seg === ".") continue;
if (seg === "..") base.pop();
else base.push(seg);
}
return "/" + base.join("/");
}
.. の扱いさえ間違えなければ、ここは素直に書けます。
本題:vi をどう再現するか
ここが一番厄介でした。vi は「モードを持つ」からです。
モードの管理
const VI_MODE = {
NORMAL: "normal",
INSERT: "insert",
COMMAND: "command", // : を押した後
};
let viState = {
mode: VI_MODE.NORMAL,
lines: [], // 編集中のバッファ
cursor: { row: 0, col: 0 },
filename: "",
modified: false,
};
キー入力のハンドリング
通常のターミナルは「Enterが押されたら1行を解釈」でよいのですが、vi は1キーごとに反応する必要があります。
function handleViKey(e) {
if (viState.mode === VI_MODE.NORMAL) {
switch (e.key) {
case "i":
viState.mode = VI_MODE.INSERT;
e.preventDefault();
break;
case "a":
viState.cursor.col++;
viState.mode = VI_MODE.INSERT;
e.preventDefault();
break;
case "h": moveCursor(0, -1); break;
case "j": moveCursor(1, 0); break;
case "k": moveCursor(-1, 0); break;
case "l": moveCursor(0, 1); break;
case "x": deleteCharAtCursor(); break;
case ":":
viState.mode = VI_MODE.COMMAND;
commandBuffer = ":";
break;
}
} else if (viState.mode === VI_MODE.INSERT) {
if (e.key === "Escape") {
viState.mode = VI_MODE.NORMAL;
// vi の仕様に合わせてカーソルを1つ戻す
viState.cursor.col = Math.max(0, viState.cursor.col - 1);
} else if (e.key.length === 1) {
insertChar(e.key);
}
}
render();
}
:wq の解釈
コマンドモードの入力を溜めて、Enterで解釈します。
function execExCommand(cmd) {
switch (cmd) {
case ":w":
writeFile(viState.filename, viState.lines.join("\n"));
viState.modified = false;
return { exit: false };
case ":wq":
case ":x":
writeFile(viState.filename, viState.lines.join("\n"));
return { exit: true };
case ":q":
if (viState.modified) {
return { exit: false, message: "E37: No write since last change (add ! to override)" };
}
return { exit: true };
case ":q!":
return { exit: true }; // 保存せず破棄
default:
return { exit: false, message: `E492: Not an editor command: ${cmd.slice(1)}` };
}
}
エラーメッセージを本物と同じ E37: E492: にしているのは、学習者が実機で同じエラーに遭遇したときに見覚えがある状態を作るためです。ここは地味ですが効きます。
ハマったところ
① ブラウザのショートカットと衝突する
Ctrl+W(タブを閉じる)、Ctrl+D、/(Firefoxのクイック検索)など、vi のキーとブラウザ標準動作がぶつかります。
// ターミナルにフォーカスがある間だけ、特定キーを横取りする
term.addEventListener("keydown", (e) => {
const swallow = ["Tab", "/", "ArrowUp", "ArrowDown"];
if (swallow.includes(e.key) || (e.ctrlKey && "wdu".includes(e.key))) {
e.preventDefault();
}
handleKey(e);
});
Ctrl+W だけはブラウザ側が preventDefault を無視する環境があるため、そこは諦めて代替案内を出しています。
② IME(日本語入力)が入ると壊れる
compositionstart / compositionend を見て、変換中はキーハンドラを止めます。
let composing = false;
term.addEventListener("compositionstart", () => composing = true);
term.addEventListener("compositionend", () => composing = false);
③ モバイルでEscキーがない
スマホには Esc キーがありません。画面下部に Esc : Tab の仮想キーを並べて解決しました。ここは実機にない工夫ですが、実用を優先しています。
正直に書いていること
このターミナルは本物のカーネルではありません。想定していないコマンドには応答できません。
その点はサイト上でも明記しています。「実機に近い体験」を謳って期待値を上げすぎると、学習者の信頼を損なうためです。入口としての疑似体験という位置づけを、はっきり書くようにしています。
おわりに
ls から vi まで、順番に打って試せるページを公開しています。300本ほどレッスンがあるので、気になるコマンドから覗いてみてください。
実装で気になる点があれば、コメントで教えていただけると嬉しいです。