📚 Web 終端機系列
- 什麼是 PTY?讓網頁跑出真正的 Terminal
- 後端架構:FastAPI + Socket.IO 雙向通訊
- 前端整合:xterm.js 打造完整終端體驗 ← 目前閱讀
這篇文章要解決什麼問題?
前兩篇我們完成了後端:PTY 提供真正的 shell,Socket.IO 實現即時通訊。但使用者看不到黑底白字的終端機畫面,也沒辦法輸入指令。
終端機不只是顯示文字,它還要處理:
- ANSI 轉義序列:顏色、游標移動、清除畫面
- 特殊按鍵:方向鍵、Ctrl+C、Tab 自動補全
- 視窗大小同步:拖曳縮放視窗,終端機行列數要跟著變
- 效能優化:大量輸出(如
cat大檔案)不能卡住
老闆:「我出差只帶 iPad,臨時要看 Server log 怎麼辦?」
後端工程師:「後端 PTY 和 Socket.IO 都做好了,現在要做前端介面。」
前端工程師:「我用 xterm.js,可以在網頁上顯示完整的終端機畫面,顏色、游標都有。」
老闆:「所以平板開瀏覽器就能用?」
前端工程師:「對,任何裝置都行,還能自訂主題配色。」
技術概念
xterm.js 是什麼?
xterm.js 是一個在瀏覽器中渲染終端機的函式庫,由 VS Code 團隊維護。它不是模擬終端機,而是一個完整的終端機 emulator:
使用者輸入 "ls" + Enter
│
▼
xterm.js
┌─────────────────────────┐
│ 1. 捕捉鍵盤事件 │
│ 2. 產生 ANSI 序列 │ ──> Socket.IO ──> PTY
│ 3. 解析後端回傳的 ANSI │ <── Socket.IO <── PTY
│ 4. 渲染到 Canvas/DOM │
└─────────────────────────┘
│
▼
瀏覽器畫面顯示彩色輸出
ANSI 轉義序列速查
終端機用特殊字元序列控制顏色和游標:
| 序列 | 效果 |
|---|---|
\x1b[31m |
紅色文字 |
\x1b[32m |
綠色文字 |
\x1b[0m |
重置樣式 |
\x1b[2J |
清除畫面 |
\x1b[H |
游標移到左上角 |
\r\n |
換行(終端機慣例) |
xterm.js 會自動解析這些序列並正確渲染。
xterm.js 外掛架構
xterm.js 使用 addon(外掛)擴展功能:
| Addon | 功能 |
|---|---|
xterm-addon-fit |
自動調整 cols/rows 以填滿容器 |
xterm-addon-web-links |
讓 URL 可點擊 |
xterm-addon-search |
搜尋終端機內容 |
xterm-addon-webgl |
WebGL 渲染(效能更好) |
跟著做:Step by Step
Step 1:引入 xterm.js
<!-- CDN 引入 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9/lib/xterm-addon-web-links.js"></script>
<!-- Socket.IO Client -->
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
Step 2:建立 Socket.IO 客戶端模組
// socket-client.js
const SocketClient = (function() {
let socket = null;
let isConnected = false;
// 連線到後端
function connect() {
if (socket && isConnected) return;
socket = io(window.location.origin, {
transports: ['websocket', 'polling'],
reconnection: true, // 自動重連
reconnectionAttempts: 5, // 重連次數
reconnectionDelay: 1000 // 重連間隔 (ms)
});
socket.on('connect', () => {
isConnected = true;
console.log('Socket connected:', socket.id);
});
socket.on('disconnect', (reason) => {
isConnected = false;
console.log('Socket disconnected:', reason);
});
socket.on('connect_error', (error) => {
console.error('Connection error:', error.message);
});
}
// 發送事件
function emit(event, data) {
if (!socket || !isConnected) {
console.error('Not connected');
return false;
}
socket.emit(event, data);
return true;
}
// 發送事件並等待回應
function emitWithAck(event, data) {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
reject(new Error('Not connected'));
return;
}
// Socket.IO v4 支援 callback 作為 acknowledgement
socket.emit(event, data, (response) => {
resolve(response);
});
});
}
// 監聽事件
function on(event, handler) {
if (socket) {
socket.on(event, handler);
}
}
// 移除監聽
function off(event, handler) {
if (socket) {
socket.off(event, handler);
}
}
return { connect, emit, emitWithAck, on, off };
})();
Step 3:建立終端機實例
// terminal-instance.js
class TerminalInstance {
constructor(containerId) {
this.containerId = containerId;
this.sessionId = null;
this.terminal = null;
this.fitAddon = null;
this.resizeObserver = null;
this.connected = false;
}
/**
* 初始化終端機
*/
init() {
const container = document.getElementById(this.containerId);
if (!container) {
throw new Error(`Container ${this.containerId} not found`);
}
// 建立 xterm 實例
this.terminal = new Terminal({
cursorBlink: true, // 游標閃爍
cursorStyle: 'block', // 游標樣式:block/underline/bar
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
fontSize: 14,
lineHeight: 1.2,
theme: {
background: '#1e1e2e', // 背景色
foreground: '#cdd6f4', // 前景色
cursor: '#f5e0dc', // 游標色
// ANSI 16 色
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8'
}
});
// 載入外掛
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
// 開啟終端機(渲染到容器)
this.terminal.open(container);
// 初始調整大小
setTimeout(() => this.fit(), 0);
// 監聽容器大小變化
this.resizeObserver = new ResizeObserver(() => this.fit());
this.resizeObserver.observe(container);
// 監聽使用者輸入
this.terminal.onData(data => {
if (this.sessionId && this.connected) {
SocketClient.emit('terminal:input', {
session_id: this.sessionId,
data: data
});
}
});
// 設定 Socket 事件處理
this.setupSocketHandlers();
// 建立 PTY session
this.createSession();
}
/**
* 設定 Socket.IO 事件處理
*/
setupSocketHandlers() {
// 接收 PTY 輸出
SocketClient.on('terminal:output', (data) => {
if (data.session_id === this.sessionId) {
this.terminal.write(data.data);
}
});
// 處理錯誤
SocketClient.on('terminal:error', (data) => {
if (data.session_id === this.sessionId) {
// 用紅色顯示錯誤
this.terminal.write(`\r\n\x1b[31mError: ${data.error}\x1b[0m\r\n`);
}
});
// Session 關閉
SocketClient.on('terminal:closed', (data) => {
if (data.session_id === this.sessionId) {
this.terminal.write('\r\n\x1b[33mSession ended.\x1b[0m\r\n');
this.connected = false;
}
});
}
/**
* 建立 PTY Session
*/
async createSession() {
this.terminal.write('Connecting...\r\n');
try {
const response = await SocketClient.emitWithAck('terminal:create', {
cols: this.terminal.cols,
rows: this.terminal.rows
});
if (response.success) {
this.sessionId = response.session_id;
this.connected = true;
console.log('Terminal session created:', this.sessionId);
} else {
this.terminal.write(`\x1b[31mFailed: ${response.error}\x1b[0m\r\n`);
}
} catch (e) {
this.terminal.write(`\x1b[31mConnection error: ${e.message}\x1b[0m\r\n`);
}
}
/**
* 調整終端機大小以填滿容器
*/
fit() {
if (!this.fitAddon || !this.terminal) return;
try {
this.fitAddon.fit();
// 通知後端調整 PTY 大小
if (this.sessionId && this.connected) {
SocketClient.emit('terminal:resize', {
session_id: this.sessionId,
cols: this.terminal.cols,
rows: this.terminal.rows
});
}
} catch (e) {
// 初始化時可能會失敗,忽略
}
}
/**
* 銷毀終端機
*/
destroy() {
// 關閉 PTY session
if (this.sessionId && this.connected) {
SocketClient.emit('terminal:close', {
session_id: this.sessionId
});
}
// 清理 ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
// 銷毀 xterm
if (this.terminal) {
this.terminal.dispose();
}
}
}
Step 4:整合使用
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5/css/xterm.css">
<style>
#terminal-container {
width: 800px;
height: 500px;
background: #1e1e2e;
}
</style>
</head>
<body>
<div id="terminal-container"></div>
<!-- Scripts -->
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9/lib/xterm-addon-web-links.js"></script>
<script src="socket-client.js"></script>
<script src="terminal-instance.js"></script>
<script>
// 連線並建立終端機
SocketClient.connect();
const terminal = new TerminalInstance('terminal-container');
terminal.init();
</script>
</body>
</html>
進階技巧與踩坑紀錄
1. 從 CSS 變數讀取主題色
讓終端機顏色跟著系統主題切換:
/**
* 從 CSS 變數建立 xterm 主題
*/
function getTerminalTheme() {
const getCSSVar = (name) =>
getComputedStyle(document.documentElement)
.getPropertyValue(name).trim();
return {
background: getCSSVar('--terminal-bg'),
foreground: getCSSVar('--terminal-fg'),
cursor: getCSSVar('--terminal-cursor'),
black: getCSSVar('--terminal-black'),
red: getCSSVar('--terminal-red'),
green: getCSSVar('--terminal-green'),
// ... 其他顏色
};
}
// 使用
this.terminal = new Terminal({
theme: getTerminalTheme()
});
// 主題切換時更新
document.addEventListener('themeChanged', () => {
this.terminal.options.theme = getTerminalTheme();
});
2. 斷線重連 UI
給使用者選擇恢復之前的 session 還是建立新的:
/**
* 檢查可恢復的 sessions
*/
async checkAndConnect() {
this.terminal.write('檢查連線狀態...\r\n');
try {
const response = await SocketClient.emitWithAck('terminal:list', {});
const sessions = response.sessions || [];
if (sessions.length > 0) {
this.showReconnectDialog(sessions);
} else {
this.createSession();
}
} catch (e) {
this.createSession();
}
}
/**
* 顯示重連對話框
*/
showReconnectDialog(sessions) {
// 建立 overlay
const overlay = document.createElement('div');
overlay.className = 'terminal-reconnect-overlay';
overlay.innerHTML = `
<div class="terminal-reconnect-dialog">
<h3>發現未關閉的終端機</h3>
<ul class="session-list">
${sessions.map(s => `
<li data-session-id="${s.session_id}">
<code>${s.cwd || '~'}</code>
<span>${new Date(s.last_activity).toLocaleTimeString()}</span>
</li>
`).join('')}
</ul>
<button class="btn-new">建立新的</button>
</div>
`;
// 處理點擊事件
overlay.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => {
overlay.remove();
this.reconnectSession(li.dataset.sessionId);
});
});
overlay.querySelector('.btn-new').addEventListener('click', () => {
overlay.remove();
this.createSession();
});
this.container.appendChild(overlay);
}
/**
* 重新連接到現有 session
*/
async reconnectSession(sessionId) {
this.terminal.write('正在恢復 session...\r\n');
const response = await SocketClient.emitWithAck('terminal:reconnect', {
session_id: sessionId
});
if (response.success) {
this.sessionId = response.session_id;
this.connected = true;
this.terminal.write('\x1b[32mSession 已恢復!\x1b[0m\r\n');
this.fit(); // 同步視窗大小
} else {
this.terminal.write('\x1b[33mSession 已過期\x1b[0m\r\n');
this.createSession();
}
}
3. 複製貼上支援
xterm.js 預設不支援 Ctrl+C/V 複製貼上(Ctrl+C 在終端機是中斷訊號),需要手動處理:
// 選取文字時自動複製
this.terminal.onSelectionChange(() => {
const selection = this.terminal.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
}
});
// 右鍵貼上
this.terminal.attachCustomKeyEventHandler((event) => {
// Ctrl+Shift+C 複製
if (event.ctrlKey && event.shiftKey && event.key === 'C') {
const selection = this.terminal.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
}
return false; // 阻止預設行為
}
// Ctrl+Shift+V 貼上
if (event.ctrlKey && event.shiftKey && event.key === 'V') {
navigator.clipboard.readText().then(text => {
if (text && this.connected) {
SocketClient.emit('terminal:input', {
session_id: this.sessionId,
data: text
});
}
});
return false;
}
return true; // 其他按鍵正常處理
});
4. 效能優化:大量輸出
執行 cat 大檔案或 find / 時,輸出非常快。用 requestAnimationFrame 批次渲染:
class TerminalInstance {
constructor() {
// ...
this.outputBuffer = '';
this.renderScheduled = false;
}
setupSocketHandlers() {
SocketClient.on('terminal:output', (data) => {
if (data.session_id === this.sessionId) {
// 累積到 buffer
this.outputBuffer += data.data;
this.scheduleRender();
}
});
}
scheduleRender() {
if (this.renderScheduled) return;
this.renderScheduled = true;
requestAnimationFrame(() => {
if (this.outputBuffer) {
this.terminal.write(this.outputBuffer);
this.outputBuffer = '';
}
this.renderScheduled = false;
});
}
}
5. 狀態列顯示
讓使用者知道目前的連線狀態和終端機大小:
/**
* 更新狀態列
*/
updateStatusBar() {
const statusDot = document.querySelector('.status-dot');
const statusText = document.querySelector('.status-text');
const sizeDisplay = document.querySelector('.terminal-size');
if (statusDot) {
statusDot.classList.toggle('connected', this.connected);
}
if (statusText) {
statusText.textContent = this.connected ? '已連線' : '已斷線';
}
if (sizeDisplay) {
sizeDisplay.textContent = `${this.terminal.cols}x${this.terminal.rows}`;
}
}
// 在 createSession 和 resize 後呼叫
小結
這篇我們完成了 Web 終端機的最後一塊:
- xterm.js 基礎:建立終端機、設定主題、載入外掛
- Socket.IO 整合:發送輸入、接收輸出、調整大小
- 進階功能:主題切換、斷線重連、複製貼上、效能優化
完整的資料流:
使用者按下按鍵
│
▼
xterm.js 捕捉 ─── terminal.onData() ───>
│
│ Socket.IO: terminal:input
▼
FastAPI Socket.IO Server
│
│ PTY write
▼
ptyprocess (bash)
│
│ PTY read (非同步)
▼
FastAPI Socket.IO Server
│
│ Socket.IO: terminal:output
▼
xterm.js ─── terminal.write() ───> 畫面顯示
恭喜!你現在擁有一個完整的 Web 終端機系統。
完整程式碼
HTML 結構
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>Web Terminal</title>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5/css/xterm.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #181825;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.terminal-window {
width: 900px;
height: 600px;
background: #1e1e2e;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
.terminal-header {
background: #313244;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
}
.terminal-title {
color: #cdd6f4;
font-size: 13px;
}
.terminal-container {
flex: 1;
padding: 4px;
}
.terminal-container .xterm {
height: 100%;
}
.terminal-status-bar {
background: #11111b;
padding: 4px 12px;
display: flex;
justify-content: space-between;
font-size: 11px;
color: #6c7086;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #fab387;
}
.status-dot.connected {
background: #a6e3a1;
}
/* 重連對話框 */
.terminal-reconnect-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.terminal-reconnect-dialog {
background: #1e1e2e;
border: 1px solid #45475a;
border-radius: 8px;
padding: 20px;
text-align: center;
color: #cdd6f4;
}
.session-list {
list-style: none;
margin: 16px 0;
}
.session-list li {
padding: 10px;
background: #313244;
border-radius: 4px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.2s;
}
.session-list li:hover {
background: #45475a;
}
.btn-new {
background: #89b4fa;
color: #1e1e2e;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="terminal-window">
<div class="terminal-header">
<span class="terminal-title">Terminal</span>
</div>
<div class="terminal-container" id="terminal"></div>
<div class="terminal-status-bar">
<div class="status-indicator">
<span class="status-dot"></span>
<span class="status-text">連線中...</span>
</div>
<span class="terminal-size"></span>
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9/lib/xterm-addon-web-links.js"></script>
<script>
// ===== Socket Client =====
const SocketClient = (function() {
let socket = null;
let isConnected = false;
function connect() {
socket = io(window.location.origin, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5
});
socket.on('connect', () => {
isConnected = true;
console.log('Connected:', socket.id);
});
socket.on('disconnect', () => {
isConnected = false;
console.log('Disconnected');
});
}
function emit(event, data) {
if (socket && isConnected) socket.emit(event, data);
}
function emitWithAck(event, data) {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) return reject(new Error('Not connected'));
socket.emit(event, data, resolve);
});
}
function on(event, handler) {
if (socket) socket.on(event, handler);
}
return { connect, emit, emitWithAck, on };
})();
// ===== Terminal Instance =====
class TerminalInstance {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.sessionId = null;
this.terminal = null;
this.fitAddon = null;
this.connected = false;
}
init() {
this.terminal = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
fontSize: 14,
theme: {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
black: '#45475a', red: '#f38ba8',
green: '#a6e3a1', yellow: '#f9e2af',
blue: '#89b4fa', magenta: '#f5c2e7',
cyan: '#94e2d5', white: '#bac2de'
}
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
this.terminal.open(this.container);
setTimeout(() => this.fit(), 0);
new ResizeObserver(() => this.fit()).observe(this.container);
this.terminal.onData(data => {
if (this.sessionId && this.connected) {
SocketClient.emit('terminal:input', {
session_id: this.sessionId,
data: data
});
}
});
this.terminal.onResize(({ cols, rows }) => {
document.querySelector('.terminal-size').textContent = `${cols}x${rows}`;
});
this.setupSocketHandlers();
this.createSession();
}
setupSocketHandlers() {
SocketClient.on('terminal:output', (data) => {
if (data.session_id === this.sessionId) {
this.terminal.write(data.data);
}
});
SocketClient.on('terminal:error', (data) => {
if (data.session_id === this.sessionId) {
this.terminal.write(`\r\n\x1b[31mError: ${data.error}\x1b[0m\r\n`);
}
});
}
async createSession() {
this.terminal.write('Connecting...\r\n');
try {
const response = await SocketClient.emitWithAck('terminal:create', {
cols: this.terminal.cols,
rows: this.terminal.rows
});
if (response.success) {
this.sessionId = response.session_id;
this.connected = true;
this.updateStatus();
} else {
this.terminal.write(`\x1b[31m${response.error}\x1b[0m\r\n`);
}
} catch (e) {
this.terminal.write(`\x1b[31m${e.message}\x1b[0m\r\n`);
}
}
fit() {
if (this.fitAddon) {
this.fitAddon.fit();
if (this.sessionId && this.connected) {
SocketClient.emit('terminal:resize', {
session_id: this.sessionId,
cols: this.terminal.cols,
rows: this.terminal.rows
});
}
}
}
updateStatus() {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.status-text');
if (dot) dot.classList.toggle('connected', this.connected);
if (text) text.textContent = this.connected ? '已連線' : '已斷線';
}
}
// ===== 啟動 =====
SocketClient.connect();
const term = new TerminalInstance('terminal');
term.init();
</script>
</body>
</html>
這個單一 HTML 檔案包含了完整的 Web 終端機前端實作,可以直接搭配前兩篇的後端程式碼使用。