可以 —— 控制页面就跑在 ESP32 自己身上:烧录下面这份代码,在同一个 WiFi 下用任何浏览器打开 http://esp32.local/ 或板子的 IP 地址,页面上两个按钮就能切换一块只用三条杜邦线接好的 5 V 继电器模块。手机上什么都不用装,不用注册账号,也不需要互联网 —— 这个请求根本不会离开你家路由器。
用浏览器控制继电器,需要哪些零件?
ESP32 DevKit 在这个方案里就是服务器本身,而整条链路都留在你家里:浏览器向它要一个页面,板子透过你的 WiFi 把页面答回去,然后它的一个引脚去驱动继电器模块,由模块的触点去开关真正的负载 —— 一把 12 V 风扇、一条灯带或者一个水泵。
材料清单 —— 浏览器控制继电器,不用焊接也不用面包板
选配 —— 一个继电器变成三个时的扩展底板
| Item | Price | Qty | |
|---|---|---|---|
NodeMCU ESP32 Wi-Fi + Bluetooth Development Board CH340/CP2012 - For IOT Project - ESP32 30PIN EXPANSION BOARD32DEVBD-X32ECH34 | RM8.90 |
选配的 30 针扩展底板,正好坐得下这一款 DevKit:一个可接 6.5–16 V 输入的 DC 圆头插座,每个引脚旁边都引出了各自的 5 V 和 GND 排针。等一个继电器变成三个的时候,它就是让线路整齐起来的那块底板。
那块扩展板引出来的是公针,所以要配母对母杜邦线。另外有两样很接近的零件是特意没有放进清单的:紫色的 ESP32-C2 是一块走 ESP-IDF 的板子,标准 Arduino 开发板管理器并不收录它;而同一款红色继电器板还有12 V 线圈的版本 —— 只有当你的系统本来就有一路 12 V 电源时,才去买那一款。
一个网页服务器怎么塞得进 ESP32 里?
ESP32 core 自带的 WebServer 库,说穿了就是一个 socket 加一张查找表。WebServer server(80) 告诉芯片去监听 TCP 的 80 端口,也就是浏览器打开普通 http:// 地址时用的那个端口。你在浏览器里打开 http://192.168.1.50/on,浏览器送出去的其实只是一行文字:GET /on HTTP/1.1。
每写一句 server.on("/on", handleOn),就是往那张表里加一行 —— 路径 /on 对应到 C 函数 handleOn。这一对配对关系就叫一条路由。在 loop() 里被调用的 server.handleClient() 会看看有没有请求在等,读出它的路径,找到对应的那一行并调用那个函数;函数再用 server.send() 把文字沿同一条连接写回去,浏览器就把页面画出来。
整条路上再没有别的东西参与,所以这里根本没有账号要注册。数据包只走 浏览器 → 路由器 → ESP32 再原路回来,所以就算你家宽带断了,这个开关照样能用 —— 只要 WiFi 还活着就行。代价也在同一句话里:它只在你自己的网络里有效,而我们那篇 Blynk 手机控制教程正是拿这一点去换一次云端中转,好让你用手机流量在外面也能控制。这同时也意味着,你的 WiFi 密码就是这个页面唯一的一道锁:任何已经连上这个网络的人都能打开它,所以请把板子放在你自己掌控的网络里,而不是那个到处给人用的网络。

想先看看一块板子吐出它第一个页面的样子?
继电器模块要怎么接到 ESP32 上?
1 路继电器模块的控制端是一个标着 IN、DC− 和 DC+ 的三位螺丝端子,不是排针 —— 所以清单里配的是公对母杜邦线:母头那端插在 DevKit 的引脚上,裸露的公头那端压进螺丝底下拧紧。三条线,不用面包板,也不用焊。
| 继电器端子 | DevKit 引脚 | 为什么 |
|---|---|---|
| DC+ | VIN | 线圈是一块 5 V 电磁铁;VIN 把 USB 口的 5 V 直接引过来 |
| DC− | GND | 线圈电流和控制信号共用的回流路径 |
| IN | D26 | GPIO26 —— 一个普通的完整输出脚,开机全程都很安静 |
线圈要从 VIN 供电,不要接 3V3 引脚,数字本身就说明了原因。SRD-05VDC 的线圈电阻约 70 Ω,5 V 大约推 71 mA 过去 —— 这点电流对 USB 供电来说不算什么 —— 但松乐规定的吸合电压是额定线圈电压的 75%,也就是约 3.75 V,所以 3.3 V 很可能永远拉不动那片衔铁。供电这一侧,我们那篇 ESP32 供电指南讲得更细。
把模块上那颗黑色的触发跳线帽拨到 H,也就是高电平那一挡,RELAY_ON 就是 HIGH。这颗跳线在板子下缘、紧挨着 IN/DC−/DC+ 端子的那排三针上:中间一针是公共针,两侧的针丝印分别是左边的 L 和右边的 H,所以黑色跳线帽要把中间针和右边那一针短接起来。在 H 挡,光耦里的 LED 是由 IN 经过模块自带的 1 kΩ 电阻流向 DC− 的,所以一个 3.3 V 高电平大约推 2 mA 过去 —— 这点负载 ESP32 的引脚带起来毫不吃力 —— 而 0 V 就把它彻底截止。L 挡才是 3.3 V 板子的陷阱:在那一挡,同一颗 LED 改挂在模块的 DC+ 电源轨上,要等 IN 爬到距离那条轨大约 1.5 V 以内才会截止,在 5 V 模块上大概是 3.5 V(这款模块的实测电路图)。3.3 V 的高电平永远够不到这条线,所以跳线坐在 L 挡时,继电器从模块一上电就吸合,而且再也不会释放。
选 GPIO26 也是同一个道理。复位时 ESP32 会去读它的 strapping 引脚 —— GPIO0、2、5、12 和 15 —— 来决定怎么启动,而在它做决定的那段时间里这些脚的电平会晃动,于是继电器每次重启都要「咔」一声。GPIO34、35、36 和 39 则是另一种坑:它们是只能输入的引脚,没有输出驱动器,所以什么都开关不了。GPIO26 这两个毛病都没有;而且它在上电之初还是一个未配置的输入脚时,也不会往 H 挡那条链路里灌出任何电流,所以继电器一开始就是断开的。
负载接在板子的另一头:从它自己的电源引正极进 COM,从 NO(常开)出来接到负载,负载的负极再直接回到同一个电源 —— 在继电器吸合之前,这条回路是断的。改接到 NC 就反过来,负载一直在跑,直到继电器动作才断开。这三个端子是密封在那颗蓝色方块里的机械触点,和线圈、和 ESP32 都是隔离的,所以负载的电源不需要跟板子共地。请只用低压直流:蓝色方块上印着的触点额定值确实有市电级别,但要开关市电,就得有外壳、爬电距离和线缆固定,这些是一块裸露在桌面上的板子给不了的。这一侧的问题,我们那篇继电器安全指南讲得比较全。

ESP32 网页服务器的代码长什么样?
这份 ESP32 代码只用到 ESP32 core 本身就带的库 —— WiFi.h、WebServer.h 和 ESPmDNS.h。而这个 core,正是一个全新的 Arduino IDE 唯一还缺的东西:把乐鑫的开发板包网址贴进「偏好设置 → 附加开发板管理器网址」,再到开发板管理器里安装 esp32 by Espressif Systems。没有它,就没有 ESP32 Dev Module 这一项,也没有 WebServer.h。选好那块板,把你家 2.4 GHz 网络的名称和密码填进去,上传即可。有一个设计上的取舍值得照抄:/on 和 /off 自己并不画页面。它们只改 relayState,然后回一个 HTTP 303 重定向把浏览器送回 /,由 / 依据那个变量重新生成页面 —— 于是页面报出来的永远是继电器真实的状态,而刷新一下只是重新请求 /,不会把上一条命令再执行一遍。
// ESP32 网页服务器继电器 - 在自己家的 WiFi 下用任何浏览器切换继电器。
// 不用 App,不用云端账号,也不需要互联网。开发板:ESP32 Dev Module。
#include <WiFi.h>
#include <WebServer.h> // ESP32 core 自带 - 不用另外安装
#include <ESPmDNS.h> // 让板子能应答 esp32.local 这个名字
const char WIFI_SSID[] = "YourWiFiName"; // 必须是 2.4 GHz 网络
const char WIFI_PASS[] = "YourWiFiPassword";
const char HOSTNAME[] = "esp32"; // 会变成 http://esp32.local/
const int RELAY_PIN = 26; // 丝印 D26 - 不是 strapping 引脚
const int RELAY_ON = HIGH; // 触发跳线在 H 挡:把 IN 拉高 = 吸合
const int RELAY_OFF = LOW;
bool relayState = false; // 唯一的真实状态,保存在板子上
WebServer server(80); // HTTP 监听 80 端口
void applyRelay() {
digitalWrite(RELAY_PIN, relayState ? RELAY_ON : RELAY_OFF);
}
// 每次有人来要页面,页面都是依据 relayState 现场生成的,
// 所以刷新出来的永远是继电器真实的状态。
String buildPage() {
String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>ESP32 Relay</title><style>"
"body{font-family:sans-serif;text-align:center;margin-top:12vh}"
"a{display:inline-block;padding:18px 40px;margin:8px;border-radius:12px;"
"color:#fff;font-size:20px;text-decoration:none}"
".on{background:#0a7d5a}.off{background:#9b2226}</style></head><body>";
html += "<h1>Relay is ";
html += relayState ? "ON" : "OFF";
html += "</h1><p><a class='on' href='/on'>Turn ON</a>"
"<a class='off' href='/off'>Turn OFF</a></p></body></html>";
return html;
}
// 动作做完后,把浏览器送回 "/",这样刷新页面
// 不会再把同一条命令执行一遍。
void redirectHome() {
server.sendHeader("Location", "/");
server.send(303, "text/plain", "");
}
void handleRoot() { server.send(200, "text/html", buildPage()); }
void handleOn() { relayState = true; applyRelay(); redirectHome(); }
void handleOff() { relayState = false; applyRelay(); redirectHome(); }
void setup() {
pinMode(RELAY_PIN, OUTPUT);
applyRelay(); // 别的代码跑起来之前,先让继电器断开
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.setHostname(HOSTNAME); // 必须在 begin() 之前设置
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Open http://");
Serial.print(WiFi.localIP()); // 把这个地址记下来
Serial.println("/ in any browser on this WiFi");
if (MDNS.begin(HOSTNAME)) { // WiFi 连上之后才有效
MDNS.addService("http", "tcp", 80);
Serial.println("Also try http://esp32.local/");
}
// 每个 server.on() 把一条 URL 路径映射到一个 C 函数 - 这就是一条「路由」
server.on("/", handleRoot);
server.on("/on", handleOn);
server.on("/off", handleOff);
server.onNotFound([]() { server.send(404, "text/plain", "No such page"); });
server.begin();
}
void loop() {
server.handleClient(); // 看看有没有请求在等,有就答复它
}
这份代码编译出来是 956,512 字节 —— 占默认 4 MB 分区方案里那块 1,310,720 字节应用分区的 72%,所以完全不必重新分区。上传完把串口监视器切到 115200 波特率:板子会把地址打印出来,你在同一个 WiFi 下的任何浏览器里输入它就行。
要怎么找到这块板 —— esp32.local 还是固定 IP?
ESPmDNS 这个库能在整个网络里没有任何 DNS 服务器的情况下,给板子一个名字。MDNS.begin("esp32") 会加入 224.0.0.251 这个组播组,并监听 UDP 的 5353 端口;当局域网里有设备大声问一句「谁是 esp32.local?」,板子就把自己的地址答回去。一喊一应而已 —— 这也正是它永远不会越过你家路由器的原因。
问题在于发问的那台设备必须会讲 mDNS。macOS、iOS、Windows 10 及以上,还有大多数 Linux 桌面都会。Android 要到 Android 12 才在浏览器里支持解析 .local,而且就算支持也时灵时不灵 —— 在不少手机上,这个地址就是一直超时。访客网络,以及开了客户端隔离的路由器,则会直接把组播整个挡掉。
所以把 esp32.local 当成方便,把固定地址当成可靠。因为这份代码在连线之前先调用了 WiFi.setHostname("esp32"),板子会以这个名字出现在路由器的已连接设备列表里;把它旁边的 MAC 地址记下来,再加一条 DHCP 保留,把那个 MAC 绑到一个你自己挑的地址上。之后路由器每次开机都会把同一个地址发给这块板 —— 这比在代码里写死一个静态 IP 稳妥,写死的那个有可能和路由器自己的地址池撞车。
每一款路由器的管理页面,摆放的位置都不一样:
WebServer.h 还是 ESPAsyncWebServer,该用哪一个?
ESPAsyncWebServer 是大多数 ESP32 网页服务器示例首选的库,可是对一个开关灯这种活儿,它换来的只有两样额外要装的东西:服务器本体,加上它底下的 AsyncTCP,而且两者之间、以及它们和你的 core 之间,版本都要对得上。WebServer.h 则是跟着 ESP32 core 一起来的。
| WebServer.h(core 自带) | ESPAsyncWebServer | |
|---|---|---|
| 需要额外安装 | 不用 | 两个库 |
| 由谁驱动 | 你 loop() 里的 handleClient() |
TCP 协议栈的回调 |
| 同时服务几个客户端 | 一个 | 好几个 |
loop() 变慢时 |
每个请求都被拖慢 | 不影响请求的处理 |
| WebSocket、文件上传 | 很别扭 | 内建支持 |
差别在于「等待」这件事归谁管。handleClient() 是在你的 loop() 里把一整个请求处理完的,所以你自己的代码一忙起来,就没人应答 —— 不过在没有请求排队时它几乎立刻就返回,这也是两个链接加一个继电器怎么用都不觉得慢的原因。ESPAsyncWebServer 跑在它自己的 FreeRTOS 任务上,由 TCP 协议栈在数据包到达时把它唤醒,所以你的 loop() 根本不在这条路径上。上面那一行「不影响请求的处理」,说的就是这件事。等到你要用 WebSocket 推实时传感器数值、要从 LittleFS 里读文件,或者预期全家的手机同时来访,再升级过去也不迟。
我们从真实客户那里看到的常见错误
想去连 5 GHz 网络。ESP32 的射频只有 2.4 GHz,所以一个只发 5 GHz 的 SSID 在它眼里根本不存在,串口监视器就会一直打点下去。把两个频段用同一个名字广播的路由器通常没问题;万一不行,就把两个频段拆成两个名字,把 2.4 GHz 那个给板子。
用手机流量或访客网络去开这个页面。这个页面没有公网地址 —— 它只存在于你的局域网里 —— 所以一台掉回 4G 的手机,或者连上了访客 SSID 的手机,超时的样子和板子死了一模一样。
在 loop() 里放 delay()。请求只有在 handleClient() 跑到的时候才会被应答,所以旁边一句 delay(2000) 就能让页面最多要两秒才有反应。慢的活儿请用 millis() 来计时。
用只能充电的 USB 线上传。板子会通电、看起来一切正常,可是串口就是不出现 —— 那种线四根线里少了两根。材料清单里那条 USB-C 数据线就是为这件事准备的。如果端口出来了、上传却卡在 Connecting........,按住板子上的 BOOT 键不放,直到那排点开始往前走;要是端口还是不肯出现,我们那篇 ESP32 无法识别或上传失败指南把整条链路都走了一遍。
常见问题
为什么 esp32.local 在我的笔记本上能用,在 Android 手机上却不行?
因为解析这个名字的是手机,不是板子。Android 要到 Android 12 才在浏览器里加入对 .local 的查找,而且支持得并不齐整。在那台手机上直接用板子的 IP 地址,再到路由器里加一条 DHCP 保留,把这个地址固定下来。
人不在家的时候,我能切换这个继电器吗?
用这份代码不能,而且这是刻意的。也不要为它做端口转发 —— 页面上没有任何登录,谁找到了地址谁就能操作你的继电器。要从外面控制,请改用带账号保护的云端链路,例如我们那篇 Blynk 手机控制方案。
为什么 ESP32 一重启,我的继电器就自己响一声?
IN 那条线接在了 strapping 引脚上 —— GPIO0、2、5、12 或 15 —— 芯片每次复位、在判断该怎么启动的那一刻都会去动它们。把它挪到一个安静的完整输出脚 —— GPIO26、27、25 或 33。
我可以改用 12 V 的继电器模块吗?
如果只是要开关一个 12 V 的负载,通常并不需要。线圈电压和负载电压是两回事 —— 5 V 模块的触点印着 10 A 30 V DC,所以线圈跑在 USB 上的同时,它照样能开关一把 12 V 风扇。12 V 那个版本是给整套系统只有一路 12 V 电源的场合用的:DC+ 接那路电源,DC− 接一条和 ESP32 共用的地,IN 照样接 D26,跳线也照样放在 H —— 在 H 挡,光耦是在高出 DC− 约 1.5 V 的地方翻转的,跟线圈电压无关,所以 3.3 V 逻辑驱动起来完全一样。把 12 V 模块的跳线拨到 L,继电器就再也不会释放,因为 IN 得爬到距离 12 V 约 1.5 V 以内才停得住 —— 而那时模块以 12 V 为基准的输入端就顶在一个 3.3 V 的引脚上,那不是一个该让 GPIO 待着的状态。至于用 DevKit 的 5 V VIN 脚去喂一颗 12 V 线圈,那是根本吸合不了的。
同一个页面上可以再加几个继电器吗?
可以。给每个继电器各自一个引脚和一个状态变量,每个继电器加一对 server.on() 路由,再依据全部状态变量去生成页面。每颗 5 V 线圈大约多吃 71 mA,所以超过两三个之后,就让这些模块吃它们自己的 5 V 电源,再把两边的地接在一起。

一旦板子能自己吐出页面,剩下的无非就是再加路由:按钮旁边多一个传感器读数、多接一个继电器、加一段到了傍晚自动切换的时间表。想改成用说话来控制?我们那篇离线语音控制继电器,不靠任何网络就能带动同一款模块。
最后更新:2026 年 8 月。有问题?WhatsApp 联系我们。



NodeMCU ESP32 Wi-Fi + Bluetooth Development Board CH340/CP2012 - For IOT Project - ESP-32 (CH340)
Relay Module 3.3V 5V 12V 1/2/4/8 Ways Optocoupler Trigger Relay Module 1 2 4 8 Channel Relay Module - 5V RELAY MODULE(1WAY)
40pcs Dupont Wire 10cm 20cm 30cm for Breadboard DIY Experiment Jumper Wire Breadboard wire - DUPONT WIRE M-F 20CM
Data Cable Type-A Type-C MicroUSB Type-B 0.5m 1m 30cm 0.3m 100cm Data Transfer Upload Code - TYPE-A TO TYPE-C CABLE (1.0M)
NodeMCU ESP32 Wi-Fi + Bluetooth Development Board CH340/CP2012 - For IOT Project - ESP32 30PIN EXPANSION BOARD