0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

ESP32 Web Server 網頁伺服器完整教學:從 HTML 到 REST API 實作

2026 年 6 月 10 日 687點熱度 0人點贊 0條評論

ESP32 Web Server 網頁伺服器完整教學封面

先講重點:為什麼要在 ESP32 上自己跑一個 Web Server

如果你手上有一塊 ESP32,卻只拿它來閃 LED 或把資料往雲端丟,其實有點浪費。它的 WiFi 是內建的,效能也足以同時應付好幾個 HTTP 連線,也就是說它可以身兼兩職:既是感測器節點,也是一台小型網頁伺服器。

這件事的價值在於:東西全都在你自己的區網裡,不用租雲端、不用第三方平台。我覺得最實用的大概這五項:

  • 無需雲端:瀏覽器直接連 ESP32,不依賴任何外部服務
  • 即時控制:點一下網頁按鈕就切 GPIO,延遲不到 100 ms
  • 儀表板:手機或電腦打開就能看感測器數據
  • 檔案下載:把 LittleFS 裡的 CSV 記錄檔抓下來
  • REST API:吐 JSON 給其他程式,或橋接到 MQTT

在動手寫程式之前,我建議先把「一台網頁伺服器在 ESP32 上是由哪幾層疊起來的」想清楚,不然等一下很容易卡在「這個函式到底屬於哪一層」:

ESP32 Web Server 軟體堆疊方塊圖

從上往下看:瀏覽器負責發請求,WiFi 與 TCP/IP 協定棧負責把封包送到,WebServer.h 負責解析 HTTP 並比對路由,最後才是你寫的處理函式去碰 GPIO、開檔案、讀感測器。這條線分清楚之後,後面所有的程式碼都會變得很好讀。

HTTP 是怎麼走完一趟的

Web Server 的核心就是 HTTP 這個「請求與回應」模型,而它建立在 TCP 之上。整件事拆開來只有四步:三次握手、送出請求、拿回回應、關掉連線。

HTTP 請求與回應週期循序圖

注意最後一步:短連線模式下,傳完就斷。所以每一個請求都是一次獨立的往返,瀏覽器要拿 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。看起來很囉唆,但這兩種錯誤在實務上發生的頻率極高,早點分開講清楚,前端才不會兩手一攤:

GET 與 POST 的差異表

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

HTTP 狀態碼對照表

路由怎麼排,決定你以後好不好改

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

從 HTML 頁面到 REST API 的流程圖

我自己習慣這樣分:

  • / —— 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\"}");
}

出事的時候,先看哪裡

這篇講的工具都很小,但它們出錯時的症狀長得很像 —— 頁面空白、轉圈圈、按了沒反應。我的習慣是先確認「問題在哪一層」,再決定要動什麼,不然很容易在一堆看起來都對的程式碼裡鬼打牆:

ESP32 Web Server 除錯排查表

順序的原則只有一句:先確認連線與 IP,再確認狀態碼,最後才懷疑邏輯。Serial 印出來的 IP 是你的第一個證據,HTTP 狀態碼是第二個,這兩個都對上了,問題才會落在你寫的處理函式裡。

總結

ESP32 的 Web Server 能力讓它不只是個感測器節點,而是一台完整的 IoT 閘道器。從最簡單的 HTML 控制頁,到 REST API 加上 AJAX 的即時儀表板,再到 AsyncWebServer 的高併發場景,它都能做。

我的建議是照這個順序走:先用 WebServer.h 把路由與 API 的形狀摸熟(這篇的 /api/temperature 與 /api/led 就夠你練了),真的被連線數或阻塞問題咬到之後,再換 AsyncWebServer。反過來先寫非同步,很容易在回呼地獄裡迷路,卻不知道自己在省什麼。

標籤: 教學
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang