
先講重點:為什麼要在 ESP32 上自己跑一個 Web Server
如果你手上有一塊 ESP32,卻只拿它來閃 LED 或把資料往雲端丟,其實有點浪費。它的 WiFi 是內建的,效能也足以同時應付好幾個 HTTP 連線,也就是說它可以身兼兩職:既是感測器節點,也是一台小型網頁伺服器。
這件事的價值在於:東西全都在你自己的區網裡,不用租雲端、不用第三方平台。我覺得最實用的大概這五項:
- 無需雲端:瀏覽器直接連 ESP32,不依賴任何外部服務
- 即時控制:點一下網頁按鈕就切 GPIO,延遲不到 100 ms
- 儀表板:手機或電腦打開就能看感測器數據
- 檔案下載:把 LittleFS 裡的 CSV 記錄檔抓下來
- REST API:吐 JSON 給其他程式,或橋接到 MQTT
在動手寫程式之前,我建議先把「一台網頁伺服器在 ESP32 上是由哪幾層疊起來的」想清楚,不然等一下很容易卡在「這個函式到底屬於哪一層」:

從上往下看:瀏覽器負責發請求,WiFi 與 TCP/IP 協定棧負責把封包送到,WebServer.h 負責解析 HTTP 並比對路由,最後才是你寫的處理函式去碰 GPIO、開檔案、讀感測器。這條線分清楚之後,後面所有的程式碼都會變得很好讀。
HTTP 是怎麼走完一趟的
Web Server 的核心就是 HTTP 這個「請求與回應」模型,而它建立在 TCP 之上。整件事拆開來只有四步:三次握手、送出請求、拿回回應、關掉連線。

注意最後一步:短連線模式下,傳完就斷。所以每一個請求都是一次獨立的往返,瀏覽器要拿 CSS、JS、圖片,就是一連串的小往返。這也是為什麼「伺服器端能少做一件事就少做一件事」在 ESP32 上特別重要 —— 它可不是你的筆電。
最小可用的版本:Arduino 內建的 WebServer.h
先從最單純的開始:一個 HTML 頁面加上兩顆按鈕,按下去就切 GPIO 2 的 LED。這個版本監聽 80 埠,所以瀏覽器只要打 ESP32 的 IP 就能看到頁面,不用加任何路徑。
// ESP32 Arduino - 最簡單的 Web Server
#include <WiFi.h>
#include <WebServer.h>
const char* ssid = "YourSSID";
const char* password = "YourPassword";
WebServer server(80); // 監聽埠 80
// 首頁路由
void handleRoot() {
String html = "<!DOCTYPE html>";
html += "<html><head><meta charset='utf-8'>";
html += "<title>ESP32 Web Server</title></head><body>";
html += "<h1>ESP32 Web Server</h1>";
html += "<p>GPIO 2: <a href='/on'>ON</a> | <a href='/off'>OFF</a></p>";
html += "<p>目前燈狀態: " + String(digitalRead(2) ? "ON" : "OFF") + "</p>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleOn() {
digitalWrite(2, HIGH);
server.sendHeader("Location", "/");
server.send(302); // 重新導回首頁
}
void handleOff() {
digitalWrite(2, LOW);
server.sendHeader("Location", "/");
server.send(302);
}
void handleNotFound() {
server.send(404, "text/plain", "404 Not Found");
}
void setup() {
Serial.begin(115200);
pinMode(2, OUTPUT);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
}
Serial.printf("\nIP: %s\n", WiFi.localIP().toString().c_str());
// 註冊路由
server.on("/", handleRoot);
server.on("/on", handleOn);
server.on("/off", handleOff);
server.onNotFound(handleNotFound);
server.begin();
Serial.println("HTTP Server 已啟動");
}
void loop() {
server.handleClient(); // 處理 HTTP 請求
}
// ESP32 REST API - JSON 格式回應
#include <WiFi.h>
#include <ArduinoJson.h>
#include <WebServer.h>
// 讀取溫度 API
void handleGetTemp() {
float temp = readTemperature(); // 自訂感測器讀取函式
JsonDocument doc;
doc["temperature"] = temp;
doc["unit"] = "celsius";
doc["timestamp"] = millis() / 1000;
String response;
serializeJson(doc, response);
server.send(200, "application/json", response);
}
// 控制 LED API(接受 JSON Body)
void handlePostLed() {
if (!server.hasArg("plain")) {
server.send(400, "application/json", "{\"error\":\"no body\"}");
return;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, server.arg("plain"));
if (err) {
server.send(400, "application/json", "{\"error\":\"invalid json\"}");
return;
}
bool state = doc["state"] | false;
digitalWrite(2, state ? HIGH : LOW);
JsonDocument resp;
resp["status"] = "ok";
resp["led"] = state;
String out;
serializeJson(resp, out);
server.send(200, "application/json", out);
}
void setup() {
// ...
server.on("/api/temperature", HTTP_GET, handleGetTemp);
server.on("/api/led", HTTP_POST, handlePostLed);
// ...
}
看這段程式碼要注意兩件事。第一,讀資料的 /api/temperature 用 HTTP_GET,因為它不改變任何狀態;第二,改狀態的 /api/led 用 HTTP_POST,狀態是放在請求的 body 裡送進來的。這不是隨便訂的,是 REST 的慣例,也是很多前端框架(例如 fetch 的預設行為)賴以運作的基礎。
另一個很容易被忽略的小細節:這裡對「沒帶 body」與「JSON 格式錯」分別回了 400。看起來很囉唆,但這兩種錯誤在實務上發生的頻率極高,早點分開講清楚,前端才不會兩手一攤:

順便把這個專案會用到的狀態碼整理成一張表。你會發現它們全部都能在你剛剛讀過的程式碼裡找到出處,不是背下來的通則:

路由怎麼排,決定你以後好不好改
路由就是「哪個網址交給哪個函式」。東西少的時候看起來無所謂,等你多了三顆繼電器、兩個感測器、一份設定頁,沒有分類的路由表就會變成一團災難。

我自己習慣這樣分:
- / —— HTML 頁面(給人看的)
- /api/* —— REST API(給程式看的 JSON)
- /download —— 檔案下載
- /update —— OTA 韌體更新
關鍵是「給人看的」與「給程式看的」永遠分開。這樣你要換前端樣板時,完全不會動到 API;反過來也一樣。
把 HTML 丟進 LittleFS,別再拼字串
前面那個範例把整個 HTML 用字串拼接寫在程式碼裡,方便歸方便,但頁面一大就開始痛苦:每個引號都要跳脫、改一行要重新編譯、程式碼長到看不完。用 LittleFS 就乾淨多了 —— 檔案歸檔案,程式歸程式:
// 從 LittleFS 提供靜態檔案
#include <WiFi.h>
#include <LittleFS.h>
#include <WebServer.h>
void setup() {
LittleFS.begin(true);
// 提供 index.html(從 LittleFS 讀取)
server.on("/", []() {
File f = LittleFS.open("/index.html", FILE_READ);
if (!f) {
server.send(500, "text/plain", "Internal Error");
return;
}
server.streamFile(f, "text/html");
f.close();
});
// 提供靜態資源(CSS, JS, 圖片)
server.on("/style.css", []() {
File f = LittleFS.open("/style.css", FILE_READ);
if (f) {
server.streamFile(f, "text/css");
f.close();
}
});
server.serveStatic("/assets", LittleFS, "/assets");
}
這裡有三個函式值得記住。streamFile() 是邊讀邊送,不會把整個檔案塞進記憶體;serveStatic() 一行就把整個目錄的靜態資源包好;檔案開不起來時回 500,並且記得 f.close(),在這種記憶體吃緊的平台上,忘了關檔是真的會出事。
AJAX 輪詢:頁面不用重新整理也會更新
有了 JSON API 之後,前端就可以自己決定多久來問一次。最簡單的作法就是 setInterval 定時輪詢,這裡示範的是每 5 秒抓一次溫度與濕度:
// 前端 HTML + JavaScript(放在 LittleFS 的 index.html 中)
<script>
// 每 5 秒自動更新感測器資料
setInterval(function() {
fetch('/api/temperature')
.then(r => r.json())
.then(data => {
document.getElementById('temp').innerText = data.temperature + '°C';
});
fetch('/api/humidity')
.then(r => r.json())
.then(data => {
document.getElementById('hum').innerText = data.humidity + '%';
});
}, 5000);
// 控制 LED
function toggleLed(state) {
fetch('/api/led', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({state: state})
})
.then(r => r.json())
.then(data => console.log('LED:', data));
}
</script>
這招的優點是笨得可靠:不用維持長連線、不用處理斷線重連、也不怕手機切到背景。缺點就是浪費 —— 就算數值沒變,每一輪還是照樣發請求。資料變化很慢的感測器沒差,但如果你要做毫秒級的即時波形,就該換成 WebSocket(後面提到的 AsyncWebServer 有內建)。
把上面全部串起來:完整 IoT 儀表板
把 LittleFS、REST API、AJAX 三件事合起來,就是一個可以放上桌的即時監控儀表板:
// ESP32 完整 IoT 儀表板(合併範例)
#include <WiFi.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <WebServer.h>
#include
#include
#include
WebServer server(80);
// 模擬感測器資料
float readTemp() { return 25.0 + sin(millis()/10000.0) * 3; }
float readHum() { return 60.0 + cos(millis()/12000.0) * 5; }
void handleRoot() {
File f = LittleFS.open("/dashboard.html", FILE_READ);
if (f) { server.streamFile(f, "text/html"); f.close(); }
else { server.send(200, "text/plain", "dashboard.html not found"); }
}
void handleApiTemp() {
JsonDocument doc;
doc["temperature"] = readTemp();
doc["humidity"] = readHum();
doc["uptime"] = millis() / 1000;
String out; serializeJson(doc, out);
server.send(200, "application/json", out);
}
void handleApiLed() {
if (server.method() == HTTP_POST) {
JsonDocument doc;
deserializeJson(doc, server.arg("plain"));
bool state = doc["state"] | false;
digitalWrite(2, state ? HIGH : LOW);
JsonDocument resp;
resp["status"] = "ok";
resp["led"] = state;
String out; serializeJson(resp, out);
server.send(200, "application/json", out);
}
}
void setup() {
Serial.begin(115200);
pinMode(2, OUTPUT);
LittleFS.begin(true);
WiFi.begin("SSID", "PASSWORD");
while (WiFi.status() != WL_CONNECTED) delay(500);
server.on("/", handleRoot);
server.on("/api/data", handleApiTemp);
server.on("/api/led", handleApiLed);
server.serveStatic("/assets", LittleFS, "/assets");
server.begin();
Serial.printf("ESP32 Web: http://%s\n", WiFi.localIP().toString().c_str());
}
void loop() {
server.handleClient();
}
這段程式最值得看的是它的結構:setup() 只負責「接線」(把路由註冊好),loop() 只負責「跑」(呼叫 handleClient)。所有真正的工作都被拆進各自的處理函式裡。這種寫法在 ESP32 專案裡幾乎是通用的骨架,值得直接背下來。
安全性:開著 80 埠就是在裸奔
這一段大概是全文最無聊、但也最重要的地方。ESP32 Web Server 的預設狀態是同一個 WiFi 裡的任何人都能連,而且傳輸還是明文的。你在家用可能覺得無所謂,但把這東西放到辦公室或出租空間,那就是一場小災難:
| 問題 | 風險 | 解決方案 |
|---|---|---|
| 開放 80 埠 | 區域網路內任何人都可連線 | 加上密碼驗證(HTTP Basic Auth) |
| 明文傳輸 | WiFi 封包可被監聽 | 啟用 HTTPS(需 SSL 憑證,ESP32 可用) |
| CORS | 外部網站可呼叫 API | 檢查 Origin Header 或關閉 CORS |
| 輸入驗證 | 惡意 JSON 可能造成崩潰 | 使用 ArduinoJson 的 DeserializationError |
| DoS | 大量請求佔用 CPU | 限制連線數、加上 Rate Limiting |
不想大改架構的話,最快的一步就是加 HTTP Basic Auth:瀏覽器會自己跳出帳密視窗,程式這邊只要三個動作 —— 驗證、失敗就要求驗證、成功才做事。
// 簡易 HTTP Basic Auth
void handleRoot() {
if (!server.authenticate("admin", "password123")) {
server.requestAuthentication();
return;
}
// 驗證通過,提供網頁
server.send(200, "text/html", "<h1>秘密頁面</h1>");
}
要注意 Basic Auth 只是「擋一下」,帳密本身還是明文傳送的(只是做了 Base64)。真的要在公開網路上跑,就得走 HTTPS;在區網裡圖個方便,這樣已經夠了。
覺得不夠快?換 AsyncWebServer
WebServer.h 是同步(阻塞)模式,這句話的實際意思是:在它處理請求的期間,你的 loop() 是被卡住的。你可以撐幾個連線,但撐不了很多。AsyncWebServer 把這件事反過來做 —— 請求來了用中斷驅動,你的主迴圈完全不必等:
// ESPAsyncWebServer 範例
#include <WiFi.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <WebServer.h>
AsyncWebServer server(80);
void setup() {
// AsyncWebServer 不需在 loop() 中呼叫 handleClient()
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(200, "text/plain", "Hello from AsyncWebServer!");
});
// JSON API
server.on("/api/temp", HTTP_GET, [](AsyncWebServerRequest *request) {
String json = "{\"temp\":25.3}";
request->send(200, "application/json", json);
});
// LittleFS static files
server.serveStatic("/", LittleFS, "/");
server.begin();
}
void loop() {
// AsyncWebServer 不需要 handleClient()!
// CPU 可以專心做其他事,如讀感測器
}
注意 loop() 裡的註解:AsyncWebServer 不需要 handleClient()。所以你的 CPU 可以拿去做別的事,例如一直讀感測器、跑控制迴圈。代價是回呼函式變多,程式看起來比較不直觀。
WebServer.h 與 AsyncWebServer 該選誰
這大概是全篇最實用的一段。先看原始比較,我把它順手加上了「什麼時候會痛」的角度:
| 特性 | WebServer.h | AsyncWebServer |
|---|---|---|
| 運作方式 | 同步(loop 輪詢) | 非同步(中斷驅動) |
| 同時連線 | 有限(~4 個) | 大量(~20+ 個) |
| 主迴圈影響 | 需不斷呼叫 handleClient() | 背景執行,不阻塞 |
| WebSocket | 不支援 | 內建支援 |
| 檔案上傳 | 有限 | 完整支援 |
| 使用難度 | 簡單 | 中等(回呼較多) |

把兩張表放在一起看,結論其實很簡單:如果你只服務一兩個人、開三五個連線,WebServer.h 完全夠用,別為了炫技折磨自己。但只要你的場景會出現「多個人同時開頁面」或「一邊控制一邊跑其他任務」,直接上 AsyncWebServer,省下的除錯時間遠大於多寫幾行回呼的成本。
同場加映:Web Server 與 MQTT 一起跑
這兩個其實不是競爭關係,而是分工:Web Server 負責區網內的手動控制,MQTT 負責把資料送到外面。手機在家的時候開網頁直接控制,人出門之後靠 MQTT 讓雲端接手:
// Web Server + MQTT 雙工
// 區域網路: 瀏覽器 → Web Server → ESP32 GPIO
// 廣域網路: ESP32 → MQTT Broker → 手機 App
void onWebApiLed(AsyncWebServerRequest *request) {
// 控制本地 GPIO
digitalWrite(2, HIGH);
// 同時透過 MQTT 通知雲端
client.publish("esp32/led", "on");
request->send(200, "application/json", "{\"status\":\"ok\"}");
}
出事的時候,先看哪裡
這篇講的工具都很小,但它們出錯時的症狀長得很像 —— 頁面空白、轉圈圈、按了沒反應。我的習慣是先確認「問題在哪一層」,再決定要動什麼,不然很容易在一堆看起來都對的程式碼裡鬼打牆:

順序的原則只有一句:先確認連線與 IP,再確認狀態碼,最後才懷疑邏輯。Serial 印出來的 IP 是你的第一個證據,HTTP 狀態碼是第二個,這兩個都對上了,問題才會落在你寫的處理函式裡。
總結
ESP32 的 Web Server 能力讓它不只是個感測器節點,而是一台完整的 IoT 閘道器。從最簡單的 HTML 控制頁,到 REST API 加上 AJAX 的即時儀表板,再到 AsyncWebServer 的高併發場景,它都能做。
我的建議是照這個順序走:先用 WebServer.h 把路由與 API 的形狀摸熟(這篇的 /api/temperature 與 /api/led 就夠你練了),真的被連線數或阻塞問題咬到之後,再換 AsyncWebServer。反過來先寫非同步,很容易在回呼地獄裡迷路,卻不知道自己在省什麼。
文章評論