从抓包到嗅探:用 C 语言把《计算机网络》第 9-13 章全部跑一遍

从抓包到嗅探:用 C 语言把《计算机网络》第 9-13 章全部跑一遍

本文是「计算机网络」课程 9-13 章的配套实操记录。四个项目、全部在华为云 ECS(Ubuntu 24.04 / GCC 13.3.0)上真实编译运行,代码与运行日志均保存在 /root/computer-network/ 目录。

一、项目背景与总体设计

课本第 9-13 章覆盖了从数据链路层到应用层、再到网络安全的完整知识链:

章节 层次 核心内容 对应项目
第 9 章 数据链路层 以太网帧结构 项目 1 / 项目 4
第 10 章 网络层 IP 协议 项目 1 / 项目 4
第 11 章 传输层 TCP / UDP 项目 1 / 项目 2
第 12 章 应用层 HTTP 项目 3
第 13 章 网络安全 嗅探与监听 项目 4

看书时每个协议首部都是一张静态表格,看过就忘。最好的办法是亲手把字节流"切开"看一眼。因此我设计了四个层层递进的项目:

  1. 协议栈分析工具:解析 tcpdump 抓的 pcap 文件,把以太网/IP/TCP/UDP 首部逐层打印成树状结构;
  2. Socket 编程实战:用 POSIX Socket 实现多客户端 TCP 服务器、UDP 回显,重点解决粘包、心跳这两个工程必备问题;
  3. HTTP 服务器:在 TCP 之上徒手实现 HTTP/1.1,支持 GET/POST、静态文件、Keep-Alive、路由;
  4. 网络嗅探工具 :用 AF_PACKET 原始套接字绕过协议栈,直接捕获网卡帧,做流量统计与协议过滤。

四个项目的关系是:项目 1 教你看懂协议格式(输入),项目 2 教你用协议通信(过程),项目 3 在 TCP 之上构建应用协议(产出),项目 4 站在攻击者视角把所有流量尽收眼底(俯瞰)。

二、项目 1:协议栈分析工具(对应第 9-11 章)

pcap 文件格式与封装层次

不依赖 libpcap,直接按 pcap 文件格式规范手工解析。pcap 文件 = 24 字节全局头 + N 个(16 字节记录头 + 数据)。每条记录的数据部分就是一帧完整的链路层帧,于是按「以太网头 → IP 头 → TCP/UDP 头」的顺序用结构体指针逐层"剥皮"。

一帧数据在 pcap 记录里的完整封装层次如下,解析器的每一步就是在这条链上前进一格:

scss 复制代码
+---------------------------------------------------------------+
| pcap 记录头 (16B: ts_sec, ts_usec, incl_len, orig_len)        |
+---------------------------------------------------------------+
| 以太网首部 (14B)                                                |
|   +--------+--------+---------+                               |
|   | dMAC 6B | sMAC 6B | type 2B |  type=0x0800 -> IPv4       |
+---------------------------------------------------------------+
| IPv4 首部 (20B 定长部分)                                        |
|   ver_ihl(1) tos(1) tot_len(2) id(2) frag_off(2)              |
|   ttl(1) proto(1) check(2) saddr(4) daddr(4)                  |
|   proto=6 -> TCP   proto=17 -> UDP   proto=1 -> ICMP          |
+---------------------------------------------------------------+
| TCP 首部 (20B 定长部分) / UDP 首部 (8B)                          |
|   TCP: sport(2) dport(2) seq(4) ack(4)                        |
|        off_res(1) flags(1) win(2) check(2) urg(2)             |
|   UDP: sport(2) dport(2) len(2) check(2)                      |
+---------------------------------------------------------------+
| 应用层数据 ...                                                  |
+---------------------------------------------------------------+

三层首部加起来是 14 + 20 + 20 = 54 字节,这也是后续嗅探器里计算"负载偏移"的依据。

关键代码与字节对齐分析

c 复制代码
#pragma pack(push, 1)
typedef struct {                 /* IPv4 首部(20字节定长部分) */
    uint8_t  ver_ihl;            /* 版本(高4位)+首部长度(低4位, 单位4字节) */
    uint8_t  tos;
    uint16_t tot_len, id, frag_off;
    uint8_t  ttl, proto;         /* proto: 6=TCP 17=UDP 1=ICMP */
    uint16_t check;
    uint32_t saddr, daddr;
} ip_hdr_t;
#pragma pack(pop)

#pragma pack(push, 1) 的深层原因:C 编译器默认按"自然对齐"放置成员------uint16_t 的地址必须是 2 的倍数,uint32_t 的地址必须是 4 的倍数,这是为了让 CPU 一条指令就能取到成员,避免跨对齐边界的两次访存。但协议首部是网络字节序列的逐字节映射,字段之间没有任何填充。以 ip_hdr_t 为例,不加 pack 时编译器会在 tos(偏移 1,1 字节)之后为把 tot_len 对齐到偶数地址而插入 1 字节填充,结构体膨胀到 22 字节以上,sizeof(ip_hdr_t) 不再等于真实首部长度,用它做指针强转后 tot_lensaddr 等所有后续字段全部读错位。push/pop 的写法则保证这条编译指示只作用于这一组结构体,不影响文件里其他代码的 ABI。

两个细节决定成败:

  • #pragma pack(1):如上所述,禁止编译器对齐填充,否则逐层强转全部错位;
  • 网络字节序 :所有多字节字段都要 ntohs/ntohl 转换,这是最容易踩的坑(见踩坑记录)。

树状输出用 ├── └── 字符手工拼前缀,每层递归把前缀串「继承+缩进」传给下一层,最终呈现的效果类似 tree 命令,协议层次一目了然。

为什么不用 libpcap

libpcap 当然一行 pcap_loop 就能回调每个包,但那样就把 pcap 文件格式、字节序、结构体对齐这些「基本功」全部屏蔽了。课程实操的目的恰恰是把这些底层细节暴露出来亲手做一遍。手解析一遍 pcap 全局头(magic number 0xa1b2c3d4 同时承担了「格式识别」和「大小端判断」两个职责,设计非常精巧),比背十遍协议字段都印象深刻。

运行结果与一次真实的端口扫描

tcpdump -i eth0 -c 30 -w capture.pcap 在 ECS 上抓真实流量,解析前 8 个包。意外收获是抓到了一次来自境外的端口扫描

ini 复制代码
==================== Packet #2 (60 字节) ====================
├── 源MAC  : fa:16:3e:c3:1b:c3
└── Ethernet II 类型: 0x0800 (IPv4)
    └── IPv4 (网络层)
        ├── TTL: 237
        ├── 源IP: 207.180.192.146
        ├── 目的IP: 192.168.0.168
        └── TCP (传输层)
            ├── 源端口: 45512
            ├── 目的端口: 14188
            ├── 标志位: [SYN ]

一个 SYN 包精准地砸向 14188 端口,服务器内核回 RST+ACK(Packet #3),这就是教科书第 11 章讲的「半开扫描」的完整证据链。第 5-8 个包则是典型的 DNS 查询(UDP,53 端口,TTL=55 说明经过了 9 跳路由器)。当协议表变成活生生的字节流,抓包分析就不再是黑盒。

三、项目 2:Socket 编程实战(对应第 11 章)

四个工程难点的解法

(1) 多客户端并发 :每 accept 一个连接就 pthread_create 一个线程,线程内 pthread_detach 自动回收。

(2) TCP 粘包/拆包 ------ 这是 TCP 工程化的第一道坎。TCP 是字节流协议,没有消息边界。发送端连续 send("第一条")send("第二条"),接收端可能一次 recv 就拿到"第一条第二条"(粘包),也可能只拿到"第一"(半包)。

解法采用业界通用的长度前缀协议protocol.h)。帧格式如下:头部定长 5 字节,前 4 字节是主机序(也可约定网络序)的 payload 长度,第 5 字节是消息类型,随后是变长负载:

lua 复制代码
 0        4        5                      5+len
+---------+---------+-----------------------+
| len 4B  | type 1B |   payload (len 字节)   |
+---------+---------+-----------------------+
  ^                   ^
  帧长可由头部前4字节    变长部分,长度由 len 声明
  直接算出,无需扫描分隔符

接收端维护一个滑动缓冲区,循环尝试解包:

c 复制代码
/* 数据不足返回 0(半包),解出一帧返回帧长,循环直到拆不动为止 */
int n = unpack_msg(buf + off, used - off, &type, body, &blen);
if (n <= 0) break;
off += n;

粘包与断包的时序成因

固定缓冲区读取(比如每次 recv(fd, rbuf, 4096, 0))为什么必然出问题,根源在"内核缓冲区的水位"与"应用消息的边界"是两个互不相关的坐标系。用一次 60 字节的演示(3 条各 20 字节的消息连续发送)可以看清三种典型时序:

时序场景 内核行为 一次 recv 拿到的内容 定长读取的结果
粘包 三条消息在发送侧被 Nagle 合并、或到达太快在接收缓冲区堆叠 "msg1msg2msg3" 共 60B 只解出第一条,剩余 40B 被丢弃或错位
断包 MSS 分段或发送方两次 send 间隔较大 "ms" 只有 2B 长度字段不完整,解帧失败
恰好整帧 传输时机凑巧 "msg1" 20B 正常,但纯属运气

也就是说:粘包不是 TCP 的 bug,而是字节流语义的自然结果;固定缓冲区"读一次当一条消息"隐含了"一次到达恰好一条消息"的假设,这个假设在真实网络里永远不成立。长度前缀协议把边界信息编码进字节流本身,接收端的状态机变为"累计字节 → 头部够 5B 就读出 len → 攒够 5+len 字节就切出一帧 → 剩余字节留给下一帧",任意粘包/断包组合都能正确还原。

(3) 心跳保活 :客户端每 3 秒发 PING,服务器回 PONG;服务器侧给 recv 设置 5 秒 SO_RCVTIMEO,超时醒来检查最后活跃时间,超过 20 秒判定掉线主动断开。这是应用层心跳,与 TCP 层 SO_KEEPALIVE(默认 2 小时太慢)互补。

服务器侧的掉线判定状态机:

scss 复制代码
              recv 到任何字节(含 PING)
        ┌──────────────────────────────┐
        ▼                              │
   [ALIVE] ── recv 5s 超时 ──> [CHECK now-last_active >= 20s?]
        ^                         │是            │否
        │                         ▼              ▼
        │                  [DEAD: close(fd)]  [ALIVE: 继续等]
        └──── PONG 也算活跃,重置 last_active ────┘

关键点有二:超时醒来不能立即断开,必须比对 last_active 时间戳,因为 5 秒超时可能连续发生多次而连接仍然健康(客户端恰好只是暂时无业务消息,心跳 PING 会刷新 last_active);判定阈值 20 秒取的是心跳周期 3 秒的 6 倍以上,容忍数次 PING 丢失而不误杀。

(4) 三次握手/四次挥手的可视化connect/accept 返回即代表握手完成;close 触发 FIN。两端日志如下:

ini 复制代码
[15:30:30] 发起 connect(),开始三次握手 -> 127.0.0.1:8888 ...
[15:30:30] 三次握手完成,连接建立 (ESTABLISHED)
[15:30:30] [粘包演示] 一次性发送 60 字节,内含 3 条消息,等待服务器拆包...
[15:30:33] [心跳] 发送 PING
[15:30:33] [心跳] 收到 PONG,连接正常
[15:30:36] 调用 close(),发送 FIN,开始四次挥手...
[15:30:36] 连接关闭,四次挥手完成

服务器日志则明确记录了拆包结果:"第一条消息" (长度15,已正确拆包) × 3 ------ 60 字节的一次 send 被正确拆成 3 条消息,粘包处理生效。

(5) UDP 对比 :UDP 的 connect 不产生任何网络报文(纯本地状态),无需握手挥手,每个 sendto 对应一个独立数据报,自带边界不粘包。客户端统计丢包数(本机回环 0 丢包),服务器用序号回显便于检测乱序。

TCP 与 UDP 的工程差异由此看得非常清楚:

维度 TCP UDP
消息边界 无,字节流,需应用层成帧 有,一个数据报一条消息
连接建立 三次握手 无(connect 仅本地记地址)
可靠性 确认重传、排序 尽力交付,可能丢/乱序
典型成帧方案 长度前缀 / 分隔符 不需要

心跳保活的必要性

TCP 本身是「无感知」的:如果对端机器直接断电(而非正常 close),本端的连接会永远停留在 ESTABLISHED 状态,成为僵尸连接。TCP 层自带的 SO_KEEPALIVE 默认 2 小时才探测一次,太慢。工程上必须在应用层自己实现心跳------这正是微信长连接、游戏服务器、物联网设备保活的标准做法。本项目中服务器端 20 秒收不到任何消息(含心跳)就主动断开,避免资源泄漏。

四、项目 3:徒手实现 HTTP/1.1 服务器(对应第 12 章)

实现思路

HTTP 是基于 TCP 的文本协议,本质就是「按 \r\n\r\n 切分头部、按 Content-Length 读体」。核心模块:

  • 请求解析 :先 recv 直到出现 \r\n\r\n(头部结束),从请求行提取 METHOD 和 PATH,从头部提取 Content-LengthConnection,再继续读完消息体(处理半包);
  • 路由表(method, path, handler) 三元组数组,精确匹配;未命中则回退到 wwwroot/ 静态文件;再未命中返回 404;
  • Keep-Alive :HTTP/1.1 默认长连接。只要响应携带正确的 Content-Length,客户端就能准确知道一个响应在哪结束、下一个响应从哪开始,从而在同一 TCP 连接上流水线式发送多个请求。空闲 15 秒(SO_RCVTIMEO)后服务器主动关闭;
  • 静态文件 :按扩展名映射 MIME,且必须拦截 .. 防止目录穿越攻击。

Keep-Alive 与短连接的请求处理流程对比

两者在服务器主循环里的分支差异,正是 HTTP/1.0 到 1.1 的核心演进。同一条 TCP 连接上连发两个请求的处理流程对比:

perl 复制代码
短连接(HTTP/1.0 默认)              Keep-Alive(HTTP/1.1 默认)
─────────────────────────          ─────────────────────────
accept() ─ 三次握手                   accept() ─ 三次握手
recv 请求1 / send 响应1              recv 请求1 / send 响应1
close() ─ 四次挥手                    [Content-Length 声明边界,不 close]
                                     recv 请求2 / send 响应2   ← 复用
                                     (空闲 15s 超时)
                                     close() ─ 四次挥手
两次请求 = 2×握手 + 2×挥手            两次请求 = 1×握手 + 1×挥手

短连接模式每条请求都要经历完整的 TCP 生命周期,局域网内一次握手约 1 个 RTT、挥手中 FIN 伴随 2 个 RTT 的等待,高并发下 TIME_WAIT 状态还会在服务器侧堆积端口资源。Keep-Alive 模式把这些开销摊到 N 个请求上,代价只有一个:响应必须带准确的 Content-Length(或显式 Connection: close),否则客户端无法判定响应边界,长连接上的后续字节会被误读成下一个响应的开头。这就是我的 send_response 函数里 Content-Length 是必填项的原因------它是 Keep-Alive 的地基。

目录穿越防护的具体校验逻辑

静态文件服务的第一道关卡不是 MIME 映射,而是路径合法性。校验链如下(serve_file 入口):

  1. 原始串扫描strstr(path, "..") 命中任何位置(包括 URL 编码前后)直接返回 403 Forbidden------GET /../../etc/passwd 在到达文件系统之前就被拦截;
  2. 绝对路径拦截 :以 / 开头的 PATH 需拼接 wwwroot/ 前缀后再校验,避免符号链接绕过;
  3. 拼接后归一化复核realpath() 把拼接结果解析成规范绝对路径,逐字节前缀比对是否仍位于 wwwroot/ 之下,防止 %2e%2e 编码、多重斜杠等变体逃逸。

实测 curl --path-as-is http://127.0.0.1:8080/../etc/passwd 返回 Forbidden。--path-as-is 参数不可省略,否则 curl 客户端会先把 ../ 归一化掉,攻击报文根本发不出去,测试就成了自欺欺人。

运行结果

sql 复制代码
>>> GET /api/time
{"time":"2026-08-17 15:32:00","unix":1786951920,"server":"MiniHTTP/1.0"}

>>> POST /echo  (请求体: hello http world)
{"echo":"hello http world","length":16}

>>> Keep-Alive 验证(curl 日志)
* Connection #0 to host 127.0.0.1 left intact
* Re-using existing connection with host 127.0.0.1     <-- 关键证据
< HTTP/1.1 200 OK

>>> GET /../etc/passwd   (目录穿越攻击)
Forbidden

服务器日志更能说明问题:127.0.0.1:50640 GET /hello (第 1 个请求) 紧接着 127.0.0.1:50640 GET /api/time (第 2 个请求) ------ 同一个端口(同一条 TCP 连接)承载了两个 HTTP 请求,Keep-Alive 生效。这比任何理论描述都直观。

HTTP/1.1 的设计哲学在代码中的体现

手写一遍才发现,HTTP/1.1 相对 1.0 的两个核心改进,代码里都精确对应:

  • Keep-Alive 长连接 :如前所述,复用连接的前提是 Content-Length 精确声明响应边界;
  • 文本协议的可调试性 :HTTP 用 \r\n\r\n 这种肉眼可读的分隔符,代价是解析时要处理半包(strstr(buf, "\r\n\r\n") 找不到就得继续 recv)。这是文本协议「可读性 vs 解析复杂度」的经典权衡,对比项目 2 的二进制长度前缀协议,风格迥异但各有适用场景。

五、项目 4:网络嗅探工具(对应第 13 章)

实现思路

前三个项目都在协议栈"内部"玩,嗅探器则要站在网卡旁边偷看。Linux 提供 AF_PACKET + SOCK_RAW 原始套接字,直接接收链路层帧,绕过 IP 层过滤:

c 复制代码
int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));

ETH_P_ALL 表示所有协议类型都收。拿到帧后按「以太网头 → IP 头 → TCP/UDP/ICMP」逐层解析(复用项目 1 的思路),同时累计各协议包数/字节数,支持命令行协议过滤,退出时打印统计报告。

运行结果与三次握手的序列号推导

在 ECS 上 curl http://www.baidu.com 的同时启动嗅探,完整捕获到了一次 HTTP 会话的全过程:

less 复制代码
#7  TCP 192.168.0.168:45110 -> 110.242.69.21:80 flags[SYN]      seq=1814193261
#8  TCP 110.242.69.21:80 -> 192.168.0.168:45110 flags[SYN|ACK]  ack=1814193262
#9  TCP 192.168.0.168:45110 -> 110.242.69.21:80 flags[ACK]      ack=3304525212
   ---- 三次握手完成 ----
#10 TCP 本机 -> 百度 flags[ACK|PSH] (130 字节)   <-- PSH 携带 HTTP GET 请求
#12 TCP 百度 -> 本机 flags[ACK|PSH] (843 字节)   <-- HTTP 响应正文
#20 TCP 本机 -> 百度 flags[ACK|FIN]              <-- 四次挥手开始
#21 ICMP 192.168.0.168 -> 8.8.8.8  type=8        <-- 同时 ping 的回显请求

三次握手每个报文的 seq/ack 都不是随机数,而是上一条的算术结果。把捕获值代入推导表,可以逐项验证第 11 章的公式:

报文 方向 seq 取值 ack 取值 推导规则
#7 SYN C → S 1814193261 = client_isn 无(首包不确认) 客户端随机初始序号
#8 SYN+ACK S → C 3304525211 = server_isn 1814193262 = client_isn + 1 确认号 = 对端 seq + 1(SYN 消耗一个序号)
#9 ACK C → S 1814193262(沿用) 3304525212 = server_isn + 1 纯 ACK 不消耗序号,seq 不变
#10 PSH+ACK C → S 1814193262 起 3304525212 GET 请求 130-54=76 字节数据从该 seq 开始编号

两个细节值得注意:SYN 和 FIN 虽然不携带负载,但各消耗一个序号,所以 #8 的 ack 是 client_isn + 1 而非 client_isn;纯 ACK 报文(#9)不消耗序号,因此 #10 的客户端 seq 仍是 1814193262。教科书上的这两条规则在真实捕获值里逐字应验。

流量统计:TCP 21 包 / 4277 字节,UDP 5 包(DNS),ICMP 4 包(ping)。切换到 icmp 过滤模式后,输出里只剩 ping 的 type=8(请求)和 type=0(应答),过滤功能验证通过。

嗅探器与 tcpdump 的关系

tcpdump/Wireshark 底层用的是 libpcap,而 libpcap 在 Linux 上的实现正是基于 AF_PACKET 原始套接字 + BPF 内核过滤器。本项目相当于徒手实现了一个「迷你版 tcpdump」:没有 BPF 高效过滤(只能抓上来后在用户态判断),也没有 pcap 文件落盘,但核心原理完全一致。理解了这一层,再看 tcpdump 的 -i eth0 tcp port 80 过滤表达式,就知道那是编译成 BPF 字节码在内核里执行的,而不是简单的字符串匹配。

权限与安全边界

原始套接字需要 root(CAP_NET_RAW 能力),这本身就是一道安全防线------如果任意用户都能嗅探网卡流量,局域网里的明文密码、Cookie 将全部暴露。这也是为什么机房/公司网络要划分 VLAN、为什么 HTTPS 必须普及:在共享介质上,嗅探技术门槛极低,防御只能靠加密和隔离。第 13 章讲的安全威胁,通过这个不到 200 行的程序就有了切身体会。

六、踩坑记录(每条附根因)

  1. tcpdump -i any 的陷阱 现象:-i any 抓的包解析器全乱。 根因:-i any 使用 Linux cooked capture(SLL 封装,链路类型=276),帧头是 16 字节的伪链路头而非 14 字节以太网头,偏移整体错位。必须指定具体接口(-i eth0)才能得到标准以太网帧(链路类型=1)。 排错方法:先打印 pcap 全局头的 network 字段确认链路类型。

  2. 网络字节序缺失 现象:第一版解析器端口 80 显示成 20480(0x5000),SEQ 全是天文数字。 根因:协议首部里所有多字节字段都是大端序,x86 是小端机,0x0050 按小端读成 0x5000 正好字节翻转。所有字段必须过 ntohs/ntohl

  3. bash 裸 wait 卡死脚本 现象:自动化脚本用裸 wait 等后台客户端退出,把永不退出的服务器进程也等进去,脚本卡死。 根因:无参 wait 等待当前 shell 的全部后台作业。应保存客户端 PID,用 wait $PID1 $PID2 精确等待。

  4. HEAD 请求返回 404 现象:curl -I(HEAD)测试静态文件时返回 404。 根因:路由表只注册了 GET/POST,方法名精确匹配未命中 HEAD。修复:HEAD 与 GET 共用静态文件处理逻辑,仅不发送响应体(HEAD 的语义就是"要响应头不要响应体")。

  5. GCC fortify 误报 现象:snprintf(sub, 64, "%s ", prefix)-Wformat-truncation。 根因:编译器在编译期无法确定 prefix 的运行时长度,只能按最坏情况推断拼接结果可能超过 64 字节,属保守告警而非真实缺陷。把缓冲区加大到 128 即可,属防御性编程。

七、总结与下一步

四个项目下来,对课本知识有了肌肉记忆级的理解:

  • 协议首部不再是表格,而是 #pragma pack(1) 的结构体和 ntohs 的字节转换;
  • TCP 粘包不是 bug 是特性,长度前缀协议是工程标配;
  • Keep-Alive 的本质就是「正确的 Content-Length + 不关闭连接」;
  • AF_PACKET 原始套接字既是安全分析的利器,也提醒我们:未加密的 HTTP 在网络中形同裸奔(所以才有 HTTPS,这正是第 13 章的落脚点)。

纸上得来终觉浅,把协议跑一遍才是真学会。

如果要做下一步扩展,我的计划是:给项目 1 加上十六进制字节对照视图(类似 Wireshark 的字节面板);把项目 2 的多线程模型改成 epoll 单线程事件驱动,对比两种并发模型在 10K 连接下的性能差异;给项目 3 的 HTTP 服务器接上 OpenSSL 变成 HTTPS,再用项目 4 的嗅探器验证「加密后还能不能看到明文」------用亲手写的工具去验证亲手搭的服务,才是对协议理解最彻底的闭环。


附:项目目录结构

bash 复制代码
/root/computer-network/
├── project1-packet-analyzer/   # 协议栈分析工具
├── project2-socket/            # Socket 编程实战
├── project3-http-server/       # HTTP/1.1 服务器
├── project4-sniffer/           # 网络嗅探工具
└── computer-network.md         # 本文
相关推荐
IT_陈寒1 小时前
Vite打包给我挖的这个坑,差点搞崩我的项目
前端·人工智能·后端
恋猫de小郭2 小时前
Flutter A2UI 的正确用法,怎么把 AI 和动态 UI 结合有效生产
android·前端·flutter
程序员-珍2 小时前
安卓开发:同名 styles.xml 在不同分支/文件夹的合并规则
android·xml·前端
Ashley的成长之路2 小时前
前端性能优化实战手册·第5篇(最终篇):性能监控与 CI/CD 集成
前端·ci/cd·性能优化
chemddd2 小时前
第四节课:前端
前端
雪芽蓝域zzs2 小时前
(一)Vue3+JS+Vite+pnpm 仿若依后台 从零搭建
前端
常宇佳2 小时前
vue3 $refs使用方法
前端·vue.js·typescript
zhanghaha13143 小时前
HTML系列教程:4_什么是 HTML 元素(零基础超详细讲解)
前端·算法·html
AI 编程助手GPT3 小时前
VS Code 1.133 实战:Claude 可免 GitHub 登录,HTML 保存后自动刷新
前端·html·github