news 2026/9/12 11:00:58

MQTT发布订阅、QoS与遗嘱消息实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MQTT发布订阅、QoS与遗嘱消息实战解析

1. 这不是教科书里的协议图,而是一套真实设备间“说人话”的通信系统

你手头正调试一块EC20 4G模块,想把它连上阿里云IoT平台;或者你在Vue3项目里写MQTT连接逻辑,connect之后死活收不到topic消息;又或者你在RuoYi框架里集成MQTT服务,发现设备离线时后台根本不知道——这些场景背后,真正卡住你的从来不是代码语法,而是对MQTT底层机制的模糊理解。MQTT、发布订阅、QoS、遗嘱消息,这四个词不是并列知识点,而是一套环环相扣的协作逻辑:发布订阅是骨架,QoS是肌肉张力调节器,遗嘱消息是紧急断电开关。它不像HTTP那样靠一次请求-响应就完事,而是让设备在信号抖动、网络中断、电源不稳的工业现场,依然能“有交代、有回音、有托底”。我做过三年物联网网关固件开发,亲手调通过STM32+ESP32双模MQTT接入、用Node-RED把OPC UA数据流实时转成MQTT topic、在MCIS组态软件里嵌入Qt MQTT客户端对接KepServer——所有这些落地动作,都建立在一个认知基础上:MQTT不是“能连上就行”,而是“连得明白、断得清楚、发得可靠、收得确定”。这篇文章不讲RFC文档里的定义,只讲我在产线调试时拧松又拧紧的那几颗螺丝:为什么QoS 1发两次包反而更省流量?为什么遗嘱消息的topic不能带通配符?为什么Vue3里用mqtt.js比用paho-mqtt更适配Composition API?接下来的内容,每一处都对应一个真实踩过的坑、一次深夜抓包分析、一份设备日志里的异常标记。

2. 核心机制设计逻辑:为什么MQTT要长成这个样子?

2.1 发布订阅不是“群聊”,而是“广播站+收音机”的分离式架构

很多人初学MQTT时,下意识把它类比成微信公众号——设备A发消息,所有订阅了topic的设备B/C/D都能收到。这个类比错在掩盖了一个关键事实:发布者和订阅者之间完全不知道彼此存在。在真实工业场景中,一台PLC(可编程逻辑控制器)作为发布者,可能向factory/line1/temperature这个topic每5秒推送一次温度值;而三台不同角色的接收端——HMI触摸屏(订阅factory/line1/#)、云端告警服务(订阅factory/+/temperature)、本地边缘计算盒子(订阅factory/line1/temperature)——它们各自启动、各自连接、各自订阅,但PLC从不关心谁在听,也不需要知道对方IP或端口。这种解耦带来的直接好处是:当HMI屏幕因触控故障重启时,PLC的发布节奏丝毫不受影响;当云端服务因扩容新增了两台实例,只需让新实例发起相同订阅,老PLC无需任何配置变更。

提示:这种解耦性正是MQTT在IoT领域不可替代的核心价值。对比HTTP轮询方案,它消除了“谁该主动查数据”的决策负担;对比CoAP的观察模式,它避免了每个设备都要维护大量观察者列表的内存开销。

我曾在某汽车焊装车间部署过这套架构。当时有12台ABB机器人,每台通过RS485采集焊枪电流数据,再经由EC20模块以MQTT上报。最初方案是让每台机器人直连阿里云,结果某天厂区4G信号受电磁干扰骤降,6台机器人同时重连失败,导致云平台数据断层。后来改成所有机器人统一上报到本地MQTT Broker(Mosquitto),再由一台边缘服务器聚合后单点上云——信号波动时,机器人只影响本地Broker连接,而边缘服务器凭借稳定有线网络持续向云端输送数据。这个改造没改一行业务代码,只调整了消息流向,却让数据可用率从92%提升到99.7%。

2.2 QoS等级不是“越高越好”,而是带宽、延迟与可靠性的三角权衡

QoS(Quality of Service)常被误解为“服务质量等级”,实则它是消息投递语义的精确声明。MQTT定义了三个级别,但它们的实现逻辑远比字面复杂:

  • QoS 0(最多一次):发送方发出消息后不做任何记录,不等确认。就像往邮筒里塞信,塞进去就走,不管信是否送达。适用于环境监测数据(如温湿度),丢失一帧影响极小,且能压到最低带宽占用。

  • QoS 1(至少一次):发送方保存消息副本,等待接收方返回PUBACK。若超时未收到,则重发。注意:重发机制可能导致接收方收到重复消息(如{"temp":25.3}被发两次)。这要求业务层必须做去重处理——常见做法是在payload里加时间戳或序列号,接收端缓存最近10秒内的序列号,重复则丢弃。

  • QoS 2(恰好一次):通过四步握手(PUBLISH → PUBREC → PUBREL → PUBCOMP)确保消息唯一送达。看似完美,但实测中会带来显著开销:一次QoS 2消息需4个TCP包往返,耗时约300ms(在200ms RTT网络下),而QoS 1仅需2个包、约150ms。在STM32F4系列MCU上,实现完整QoS 2状态机需额外占用1.2KB RAM和3.8KB Flash,这对资源紧张的嵌入式设备是沉重负担。

注意:QoS等级由发布者指定,但最终生效级别取发布者与订阅者协商后的较低值。例如设备以QoS 2发布,而某订阅客户端只支持QoS 1,则Broker会降级为QoS 1投递。这点在RuoYi集成MQTT时极易被忽略——后端服务设为QoS 2,但前端Vue3页面用mqtt.js订阅时未显式声明QoS,实际按默认QoS 0运行,导致关键告警消息可能丢失。

我曾为某智能电表项目选型QoS等级。电表需每15分钟上报电量数据(精度要求高),但4G模块流量按KB计费。最初全用QoS 2,月均流量达2.1MB;改为QoS 1后,配合payload内嵌序列号去重,流量降至0.8MB,且经三个月现场验证,重复率仅0.03%(源于基站切换瞬间的短暂重传),完全满足计量规范。

2.3 遗嘱消息(Will Message)不是“临终遗言”,而是设备状态的自动快照

遗嘱消息常被浪漫化解读为设备“死亡前的最后一句话”,但其工程本质是:当客户端异常断开(非正常发送DISCONNECT)时,Broker自动代为发布一条预设消息。这个机制解决了物联网中最棘手的问题之一:如何快速感知设备离线?传统方案需服务端定时ping设备,但海量设备下心跳包本身就会成为网络瓶颈。

遗嘱消息的关键参数有三个:

  • Will Topic:消息发布的topic,如device/status/EC20_8899
  • Will Payload:消息内容,通常为offline{"status":"offline","ts":1715823456}
  • Will QoS:该消息的QoS等级,建议设为1,确保离线通知必达
  • Will Retain:是否设为保留消息(Retained Message),设为true则新订阅者立即收到最后状态

警告:Will Topic严禁使用通配符(如device/status/+),否则Broker无法解析。某次在KepServer对接MQTT时,因配置了device/+/status作为Will Topic,导致设备离线时Broker报错拒绝发布,整个状态监控失效。

实战中,遗嘱消息必须与客户端Clean Session标志协同使用。Clean Session设为true时,每次重连Broker都会清除旧会话,此时遗嘱消息只在首次连接时注册;设为false时,Broker会持久化会话,设备断线重连后可接收离线期间的QoS 1/2消息。我们为某冷链运输车设计监控系统时,将Clean Session设为false,并设置Will Payload为{"status":"offline","location":"39.9042,116.4074"}(含最后定位),这样即使车辆在隧道中失联,平台也能立刻显示“离线”并标出最后位置,调度员可据此判断是否需启动应急流程。

3. 实战环节拆解:从零搭建可验证的MQTT通信链路

3.1 本地Broker搭建与基础验证(5分钟完成)

跳过云服务复杂配置,先用本地Mosquitto验证核心机制。以下步骤在Ubuntu 22.04实测通过,Windows用户可用Docker Desktop替代:

# 安装Mosquitto(含客户端工具) sudo apt update && sudo apt install mosquitto mosquitto-clients -y # 启动Broker(默认端口1883,启用日志便于调试) mosquitto -v -c /etc/mosquitto/mosquitto.conf # 新开终端,启动订阅者(监听所有topic) mosquitto_sub -h localhost -t '#' -v # 再开终端,发布一条QoS 1消息 mosquitto_pub -h localhost -t "test/qos1" -m "hello qos1" -q 1 # 观察订阅终端是否收到,然后手动Ctrl+C终止订阅者 # 此时发布者仍在运行,但订阅者已退出

关键验证点:

  • 订阅者退出后,发布者继续发消息,Broker应缓存QoS 1消息(因无活跃订阅者,实际不投递)
  • 重新启动订阅者:mosquitto_sub -h localhost -t "test/qos1",此时不会收到之前的消息——因为默认Clean Session为true,会话未持久化

实操心得:初学者常困惑“为什么重连后收不到离线消息”,根源在此。若需接收离线消息,订阅时必须加-c参数(clean session false)并指定client ID:mosquitto_sub -h localhost -t "test/qos1" -c -i "sub_client_001"。此时Broker会为该client ID持久化会话,重连后投递缓存消息。

3.2 遗嘱消息全流程验证(10分钟闭环)

延续上一步,用两个终端模拟设备上线/异常断开:

# 终端1:模拟设备连接,设置遗嘱消息 mosquitto_sub -h localhost -t "device/status/test_device" -v \ --will-topic "device/status/test_device" \ --will-payload "offline" \ --will-qos 1 \ --will-retain \ -i "test_device" \ -c # 启用clean session false,确保会话持久化 # 终端2:发布在线状态(模拟设备正常工作) mosquitto_pub -h localhost -t "device/status/test_device" -m "online" -r -q 1 # 此时终端1应收到"online" # 现在手动杀死终端1进程(模拟设备断电) # 立即在终端2执行: mosquitto_sub -h localhost -t "device/status/test_device" -v # 应立刻收到"offline"消息

这个验证揭示了遗嘱消息的触发条件:只有当TCP连接异常中断(如kill进程、断网)才会触发;若设备主动发送DISCONNECT包(如正常关机),Broker不会发布遗嘱消息。这也是为什么工业设备需在电源管理电路中加入掉电检测,触发MCU主动发送DISCONNECT,避免误报离线。

3.3 Vue3项目中MQTT连接的防抖与重连策略

在Vue3组合式API中集成MQTT,常见错误是把连接逻辑写在onMounted里,导致页面刷新后重复连接。正确做法是创建全局MQTT实例并管理生命周期:

// composables/useMqtt.ts import { ref, onUnmounted } from 'vue' import mqtt from 'mqtt' const client = ref<mqtt.MqttClient | null>(null) const isConnected = ref(false) export function useMqtt() { const connect = () => { if (client.value) return client.value = mqtt.connect('ws://localhost:9001', { clientId: `web_${Date.now()}`, username: 'user', password: 'pass', clean: true, reconnectPeriod: 1000, // 断线后1秒重连 connectTimeout: 3000, // 关键:设置遗嘱消息 will: { topic: 'web/status', payload: 'offline', qos: 1, retain: true } }) client.value.on('connect', () => { console.log('MQTT connected') isConnected.value = true // 连接成功后订阅关键topic client.value?.subscribe('sensor/temperature', { qos: 1 }) }) client.value.on('error', (err) => { console.error('MQTT error:', err) isConnected.value = false }) client.value.on('reconnect', () => { console.log('MQTT reconnecting...') }) client.value.on('close', () => { console.log('MQTT closed') isConnected.value = false }) } const disconnect = () => { client.value?.end() client.value = null isConnected.value = false } // 页面卸载时主动断开 onUnmounted(() => { disconnect() }) return { client, isConnected, connect, disconnect } }

实操心得:在4G模块(如EC20)连接阿里云时,务必在connect参数中添加keepalive: 60(单位秒)。阿里云IoT平台默认keepalive为300秒,但EC20在弱信号下维持长连接困难,设为60秒可让模块更频繁地发送PINGREQ,避免被平台误判为离线。我们曾因此问题导致某批设备在山区隧道中频繁上报“离线-上线”抖动,调整后稳定运行超6个月。

3.4 STM32移植MQTT协议栈的关键裁剪点

在STM32F103C8T6(20KB RAM,64KB Flash)上移植MQTT,不能直接用Paho Embedded C,必须深度裁剪:

  1. 禁用TLS:4G模块(EC20)本身支持SSL/TLS,MQTT库只需处理明文协议。移除所有ssl.h相关代码,节省约8KB Flash。

  2. 简化内存管理:原版Paho使用动态内存分配(malloc/free),在裸机环境下易碎片化。改为静态缓冲区:为每个MQTT连接预分配固定大小的RX/TX buffer(如512字节),超出则丢弃。

  3. QoS等级锁定:业务只需QoS 1,移除QoS 2状态机代码(MQTTPacket_acknowledgePublish等函数),减少1.5KB代码体积。

  4. Topic匹配优化:标准实现遍历所有订阅topic做字符串匹配,O(n)复杂度。改为哈希表存储,key为topic哈希值,value为回调函数指针,查找降至O(1)。

移植后实测资源占用:

  • Flash占用:12.3KB(原版24.7KB)
  • RAM占用:1.8KB(含256字节RX buffer + 128字节TX buffer)
  • 最大并发topic数:16个(通过数组而非链表管理)

注意:EC20模块AT指令中,AT+MQTTUSERCFG设置client ID时,长度不能超过32字符,否则连接失败。我们曾因生成UUID作为client ID(36字符)导致设备始终连不上阿里云,排查三天才发现是AT指令长度限制。

4. 常见问题排查手册:从日志、抓包到硬件信号

4.1 连接失败的三层诊断法

mosquitto_sub或设备固件提示“Connection refused”,按以下顺序排查:

层级检查项验证命令/方法典型现象
网络层TCP端口是否可达telnet localhost 1883nc -zv localhost 1883返回Connection refused→ Broker未启动或端口错误
协议层Broker认证配置检查/etc/mosquitto/mosquitto.confallow_anonymouspassword_fileConnection refused变为Connection accepted但立即断开 → 用户名密码错误
应用层Client ID冲突在Broker日志中搜索Client xxx already connected设备日志显示CONNACK 0x04(Connection Refused: Bad User Name or Password)但配置正确 → Client ID被其他设备占用

实操技巧:在Mosquitto配置中开启详细日志:log_type all+log_dest file /var/log/mosquitto/mosquitto.log,然后用tail -f /var/log/mosquitto/mosquitto.log实时观察。当看到New connection from 127.0.0.1 on port 1883后紧跟Client test_device already connected,即可确认ID冲突。

4.2 消息收不到的五种可能及验证步骤

这是最高频问题,按发生概率排序:

  1. Topic过滤错误:订阅sensor/+/temperature不会收到sensor/room1/humidity。验证:用mosquitto_sub -h localhost -t '#' -v捕获所有消息,确认消息是否真实发出。

  2. QoS等级不匹配:发布QoS 0,订阅QoS 1,但Broker按QoS 0投递,订阅端未收到(因QoS 1要求PUBACK,而QoS 0无此机制)。验证:在Broker日志中搜索Sending PUBLISH,确认QoS字段值。

  3. Clean Session为true:订阅者重启后丢失会话,Broker不投递离线消息。验证:订阅时加-c -i "test_sub",重连后检查是否收到历史消息。

  4. Retain标志未设置:发布消息时未加-r参数,新订阅者无法获取最新状态。验证:mosquitto_sub -h localhost -t "test/retain" -W 5(等待5秒),若无输出则说明无保留消息。

  5. 防火墙拦截:云服务器安全组未开放1883端口。验证:在服务器本地执行curl -v telnet://localhost:1883,若通则问题在外部网络。

4.3 使用Wireshark精准定位QoS行为

当需要确认QoS 1重传是否发生,或QoS 2四步握手是否完整,Wireshark是最直接工具:

  1. 启动Wireshark,过滤tcp.port == 1883
  2. 执行mosquitto_pub -h localhost -t "test/qos1" -m "test" -q 1
  3. 观察TCP流:
    • 第1帧:PUBLISH(QoS=1, PacketId=1)
    • 第2帧:PUBACK(PacketId=1)→ 若此帧缺失,则发布端将在200ms后重发PUBLISH
    • 若出现第3帧PUBLISH(PacketId=1),证明网络丢包,触发重传

关键技巧:在Wireshark中右键PUBLISH包 →FollowTCP Stream,可查看完整交互流程。曾发现某4G模块在信号-105dBm时,PUBACK包因ACK窗口过小被丢弃,导致无限重传。通过增大TCP接收窗口(AT+QICFG="recvwin",4096)解决。

4.4 EC20模块MQTT连接的硬件级调试

EC20的AT指令调试需结合串口日志与信号质量:

# 查询信号质量(关键!) AT+CSQ # 返回:+CSQ: 20,99 → 信号强度20(-75dBm),99表示未知误码率 # 信号强度参考:0=-113dBm, 31=-51dBm,低于10(-103dBm)时连接不稳定 # 查询网络注册状态 AT+CGREG? # 返回:+CGREG: 0,1 → 已注册到本地网络;+CGREG: 0,5 → 注册到漫游网络(延迟更高) # 强制重拨(当AT+MQTTCONN返回ERROR) AT+QIMUX=0 # 关闭多路复用 AT+QICLOSE=0 # 关闭当前连接 AT+QIOPEN=0,"TCP","xxxxxx.iot-as-mqtt.cn-shanghai.aliyuncs.com",1883

血泪教训:某次在新疆戈壁滩测试,EC20返回+CSQ: 5,99(-103dBm),但AT+QIOPEN始终超时。最终发现是SIM卡金属触点氧化,用橡皮擦清洁后信号升至+CSQ: 18,99,连接成功率从30%提升到100%。硬件问题永远排在软件问题之前。

5. 生产环境避坑指南:那些文档里不会写的细节

5.1 RuoYi框架集成MQTT的线程安全陷阱

RuoYi基于Spring Boot,若在Controller中直接new MQTT客户端,会导致连接对象被Spring容器管理外的线程持有,引发内存泄漏。正确做法是:

  1. 创建MqttClientFactoryBean,管理单例连接
  2. @PostConstruct中初始化连接,@PreDestroy中关闭
  3. 发布消息时,通过ExecutorService提交异步任务,避免阻塞Web线程
@Component public class MqttClientFactory { private MqttClient client; @PostConstruct public void init() throws Exception { String brokerUrl = "tcp://localhost:1883"; client = new MqttClient(brokerUrl, "ruoyi_mqtt"); MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(true); options.setUserName("user"); options.setPassword("pass".getBytes()); client.connect(options); } // 发布方法必须是线程安全的 public void publish(String topic, String payload) { try { client.publish(topic, new MqttMessage(payload.getBytes())); } catch (MqttException e) { log.error("MQTT publish failed", e); } } }

注意:RuoYi默认使用HikariCP连接池,若MQTT连接与数据库连接共用线程池,高并发时MQTT操作会抢占DB连接,导致页面加载超时。必须为MQTT单独配置线程池:spring.task.execution.pool.max-size=50

5.2 Node-RED实现OPC UA转MQTT的负载均衡策略

当Node-RED需对接上百个OPC UA节点时,单实例易成为瓶颈。解决方案:

  • 使用node-red-contrib-opcuaOPCUA-IIoT-Server节点,配置maxConnections: 50
  • MQTT输出节点启用QoS: 1,并勾选Retain message
  • 关键:在OPC UA输入节点中,将scanRate从默认100ms改为500ms,降低轮询频率
  • 部署多个Node-RED实例,通过Redis Pub/Sub同步状态,避免重复发布

实测数据:单实例处理50个OPC UA节点时CPU占用65%,改为三实例+Redis协调后,单实例CPU降至22%,且消息延迟从平均85ms降至32ms。

5.3 Qt MQTT客户端在Windows服务中的权限问题

用Qt编写Windows服务程序调用QMqttClient时,常遇到connectToHost失败。根源是Windows服务默认运行在LocalSystem账户,无网络访问权限。解决方案:

  1. 在服务安装脚本中指定登录账户:
    sc config "MyMQTTService" obj= "NT AUTHORITY\NetworkService"
  2. Qt代码中显式设置代理(即使不用代理):
    client->setHostname("localhost"); client->setPort(1883); // 关键:避免Qt内部尝试使用系统代理 client->setProxy(QNetworkProxy::NoProxy);

经验总结:所有MQTT生产部署,必须在/etc/mosquitto/mosquitto.conf中配置max_inflight_messages 100(默认20)。某次在阿里云ECS上部署,未修改此值,当1000台设备同时上报时,Broker因inflight队列满而拒绝新连接,导致大规模离线。调高至500后,系统平稳承载3200台设备。

6. 个人实战体会:协议理解比工具选择更重要

做了这么多年物联网,我越来越确信一个事实:MQTT的难点从来不在“怎么连”,而在“为什么这么连”。当你在Vue3里纠结用mqtt.js还是paho-mqtt时,真正该问的是:“我的业务需要QoS 1的去重保障,还是能接受QoS 0的轻量?”当你在STM32上为节省200字节RAM裁剪TLS时,该想的是:“设备是否真会暴露在公网?4G模块自身的SSL是否足够?”那些热词——ruoyi mqttec20 mqtt配置qt mqtt——只是具体场景的切片,而背后的发布订阅解耦思想、QoS语义权衡、遗嘱消息的状态快照逻辑,才是贯穿所有场景的主线。

去年帮一家做智能灌溉的客户做系统升级,他们原有方案是Arduino+ESP8266直连云平台,QoS全设为2。我接手后第一件事不是改代码,而是带着万用表去田间测量4G模块供电电压——发现水泵启动时电压跌落至2.8V,导致ESP8266复位,遗嘱消息被触发。最终方案是:改用QoS 1 + 外置稳压电路 + 遗嘱Payload中增加"power_loss":true字段。系统上线后,设备离线率从18%降至0.3%,而代码改动不足50行。

所以,别急着复制粘贴mqtt服务器搭建教程里的命令。先打开Wireshark抓个包,看看你的PUBLISH后面有没有PUBACK;在EC20串口里敲AT+CSQ,确认信号是不是真够强;在Mosquitto日志里搜Will,验证遗嘱消息是否真的注册成功。协议是死的,设备是活的,而你的经验,是在一次次真实信号波动、电压跌落、网络抖动中长出来的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 11:00:40

AI Agent实时对话意图识别技术解析与实践

1. 项目概述&#xff1a;AI Agent实时对话意图识别的核心价值在智能交互领域&#xff0c;实时对话意图识别系统如同给机器装上了"读心术"。这个我在多个工业级项目中反复验证过的技术&#xff0c;能够将用户零散的语音或文字输入&#xff0c;在300毫秒内转化为结构化…

作者头像 李华
网站建设 2026/9/12 10:58:48

ZLUDA完整教程:在AMD显卡上运行未修改的CUDA程序

ZLUDA完整教程&#xff1a;在AMD显卡上运行未修改的CUDA程序 【免费下载链接】ZLUDA CUDA on non-NVIDIA GPUs 项目地址: https://gitcode.com/GitHub_Trending/zl/ZLUDA 假设你的机器装着一张AMD显卡&#xff0c;而你想跑的软件是用NVIDIA的CUDA工具链编译的——比如CU…

作者头像 李华
网站建设 2026/9/12 10:58:29

Keysight 85033E校准件原理与应用全解析

1. 认识Keysight 85033E校准件的核心价值Keysight 85033E是射频测试领域中一款经典的机械校准套件&#xff0c;主要用于矢量网络分析仪&#xff08;VNA&#xff09;的校准工作。在微波测量领域&#xff0c;校准就像给测量仪器"配眼镜"——没有经过校准的VNA&#xff…

作者头像 李华
网站建设 2026/9/12 10:56:30

Python舆情监控系统:架构设计与实现要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 10:55:31

Spring Cache多租户与多级缓存架构实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 10:53:33

研发零基础也能做App宣传图:免费工具+尺寸规范全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华