news 2026/10/3 12:22:15

MQTT物联网通信协议实战:从Broker搭建到设备接入开发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MQTT物联网通信协议实战:从Broker搭建到设备接入开发

1. 认识MQTT:物联网设备通信的“通用语言”

做物联网开发这几年,我接触过的设备通信协议不算少。从早期的Modbus RTU、PLC私有协议,到后来接触到的CoAP、HTTP轮询,各有各的问题。直到用了MQTT之后,很多原本绕来绕去的通信方案一下就简化了。今天这篇文章,就基于“物联网协议MQTT快速开发与实践”这个主题,把我实际项目里用到的搭建、开发、对接经验完整梳理一遍,给正在做设备接入、数据采集或者远程控制的朋友一些可以直接落地的参考。

MQTT全称Message Queuing Telemetry Transport,是一种基于发布/订阅模式的轻量级物联网协议。它的核心设计目标只有一个:在低带宽、高延迟、网络不稳定的环境下,依然能可靠地传递消息。这不是什么新鲜概念,1999年就有人提出来了,但真正大规模应用是在近几年的物联网浪潮里——从智能家居里面的传感器上报,到工厂车间的PLC数据采集,到处都有它的影子。

什么场景适合用MQTT?我列几个典型的:设备端需要定期上报状态(温度、湿度、开关量这些);平台端需要实时下发指令给设备(继电器控制、参数配置);移动端需要实时感知设备状态变化(门锁开关、告警触发)。只要你的业务本质上属于“消息”层面的事情,而不是传输大文件,MQTT几乎都是第一选择。相比之下,HTTP轮询的实时性差、流量浪费严重;TCP长连接裸写,又要自己处理粘包拆包、心跳保活、重连机制,工程成本高。MQTT把这些底层细节都封装好了,协议本身自带QoS质量等级、心跳机制、遗嘱消息,开发起来省太多事。

适合谁来读这篇文章?如果你正准备做设备接入平台,或者手里的项目需要设备与服务器之间实时通信,又或者你只是想搞清楚手机上那些智能家居App是怎么远程控制家里传感器的——这篇文章基本可以覆盖你现阶段需要的知识。我会从协议核心概念讲起,再到服务器搭建、客户端开发、工业设备对接,最后是问题排查,全程用我实际操作过的项目例子来说话。

2. 协议核心机制:搞懂这几个概念就能上手开发

2.1 Broker、Topic、消息体:三方协作的基本关系

MQTT的通信模型有三个角色:发布者、订阅者和代理服务器(Broker)。Broker就是消息中转站,类似于邮局;发布者把消息丢给邮局,订阅者向邮局登记自己感兴趣的信件类型,邮局负责按地址投递。整个过程中,发布者和订阅者完全不需要认识对方,也不用维持直接的网络连接,这种解耦是MQTT最核心的设计思想。

Topic(主题)就是消息的“地址”。它用斜杠分级,比如建筑项目的环境监控数据,可以设计成building/floor1/temperature、building/floor1/humidity。订阅者可以用通配符一次订阅多个主题:building/floor1/#表示订阅floor1下的所有主题,building/+/temperature则表示订阅所有楼层的温度数据。这里的+匹配单层,#匹配剩余所有层级。

很多新手在topic设计上吃过亏。我见过有人在一条消息里包含设备类型、设备ID、业务类型、数据时间戳,全部拼成一个长字符串塞进topic里,然后订阅端再手工解析这个字符串。这种做法完全背离了topic的分层设计初衷,后期维护和权限控制都会变得非常痛苦。正确的做法是:topic承载路由信息(什么设备、什么业务),消息体(payload)承载数据内容(具体数值)。举个例子,上报温度应该用devices/{deviceId}/telemetry/temperature,消息体只放{"value": 25.6, "unit": "celsius"}。这样按设备控制权限、按业务类型控制白名单的时候,直接按topic前缀匹配就够了。

2.2 QoS等级:消息可靠性和性能之间的权衡

QoS(Quality of Service,服务质量)是MQTT面试和实战都被问烂了的概念,但真正用对的人不多。协议定义了三个等级:

  • QoS 0(至多一次):消息发出去了就不管了,不确认、不重发。性能最高,但可能丢消息。
  • QoS 1(至少一次):保证消息到达,但可能重复。由接收方返回PUBACK确认,发送方没收到确认就重发。
  • QoS 2(恰好一次):保证消息既不丢也不重,这是最严格的等级,需要四次握手流程,开销最大。

生产环境的选型原则,我一般这么定:环境传感器数据、告警频率高的数据,用QoS 0就行——这类数据本来就是周期性的,丢一包数据下个周期还能补上;设备控制指令、配置下发这类“发错一次就大事不好”的场景,用QoS 1,配合接收端的幂等处理来消化重复消息;QoS 2在绝大多数物联网场景里都用不到,除非是计费、订单这种有强一致性要求的数据,但真到了这个级别,我建议你先评估一下是否该走数据库事务而不是MQTT。

我在一个农业大棚项目里,把卷帘控制指令设成了QoS 0,结果现场信号一抖动,指令丢了,大棚卷帘没打开,差点把作物冻坏。后来所有控制指令统一改QoS 1,并让设备端对重复指令做幂等校验,这个问题才算根治。教训很简单:省流量不能省在控制链路上。

2.3 心跳、会话、遗嘱:设备不在线也能感知

MQTT还有一个很贴心的地方:它把网络连接的“活性检测”做成了协议内置能力。客户端定时发心跳包(KeepAlive),Broker在超时没收到心跳的情况下,就会认为这个客户端挂了,然后替它执行“遗嘱消息”的发布。

遗嘱消息(Last Will and Testament,简称LWT)可以理解为“设备临终前留给平台的话”。设备上线时,把它想说的遗嘱内容一并告诉Broker(比如“我是一号大棚的设备,如果我非正常断线了,请帮我发布一条离线消息”)。一旦Broker检测到客户端异常断开(心跳超时、网络断连),就会替这个设备发布这条遗嘱消息,平台收到后就能立刻感知设备离线。

这个机制我在实际项目中用来做设备在线状态管理,非常顺手。设备上线时发一条在线消息,同时设置遗嘱为离线消息;平台端订阅上下线主题,任何异常掉线都能秒级感知。需要提醒的是,正常发DISCONNECT报文注销的客户端,Broker不会发布遗嘱消息,这是区分“正常离线”和“异常掉线”的关键。

3. 服务器搭建:Windows环境装一个能跑的MQTT Broker

3.1 选型对比:Mosquitto、EMQX、VerneMQ怎么选

局域网里做实验,或者设备量几十台以内,选Eclipse Mosquitto就够了,轻量、部署快、资源占用极小。生产环境要上十万级连接、需要集群和规则引擎,推荐EMQX,功能全、性能强、可视化管理界面做得也不错。还有一个VerneMQ,Erlang写的,集群能力好,但社区资料相对少,上手成本略高。

我本人在Windows环境二次开发调试时,最常用的组合是:Mosquitto做本机Broker,MQTTX做可视化客户端调试工具。这个组合五分钟就能把环境跑起来,特别适合先验证协议逻辑。

3.2 Windows安装Mosquitto的完整步骤

Mosquitto在Windows上安装其实很简单,但很多新手卡在配置文件位置和权限问题上。下面是完整流程:

  1. 从官网下Windows安装包,选对应架构的msi文件,双击运行。安装路径建议选纯英文目录,比如D:\mqtt\mosquitto,避免后续配置路径出现编码问题。
  2. 安装完默认在C:\Program Files\mosquitto(如果选了默认路径),目录下面的mosquitto.conf是主配置文件。默认配置只允许本机连接,要允许局域网其他设备访问,需要编辑配置文件,加一行listener 1883 0.0.0.0。
  3. 修改完配置,打开命令行窗口,切到安装目录,执行mosquitto -c mosquitto.conf -v。-c指定配置文件,-v打开详细日志。看到mosquitto version X.X running就说明启动成功了。
  4. 打开另一个命令行窗口,执行mosquitto_sub -t test -v开一个订阅端,再执行mosquitto_pub -t test -m hello -q 1发一条测试消息。订阅窗口能收到hello,说明整套环境已经通了。

为了开发方便,可以把Broker注册成Windows服务,这样不用每次手动开命令行。安装目录下执行:

mosquitto install net start mosquitto

需要改配置的时候,先net stop mosquitto,改完再启动。

3.3 生产环境建议:认证、TLS与WebSocket

开发环境裸奔没问题,但一旦要在公网或者外部设备接入场景下使用,密码认证和TLS加密是必须做的。Mosquitto配置密码认证分两步:

:: 第一步:生成密码文件,user1是用户名,按提示输入密码 mosquitto_passwd -c pwfile user1 :: 第二步:在mosquitto.conf中加以下配置 allow_anonymous false password_file D:/mqtt/mosquitto/pwfile

TLS需要证书文件。自签名证书可以用OpenSSL生成用于测试,但真正的生产环境建议从正规CA机构申请证书,或者用内网自建CA统一管理设备证书。如果设备端计算能力很弱,可以考虑TLS和自定义加密消息体双选一,但公网环境最低限度也要把用户密码配好,别裸奔。

WebSocket支持现在也是很多物联网平台标配,因为浏览器可以直接用MQTT over WebSocket收发消息,做可视化大屏调试特别方便。Mosquitto配置中加一段即可:

listener 9001 0.0.0.0 protocol websockets

这样前端JavaScript就能通过WebSocket连接Broker,实现浏览器端实时监控。做智慧大棚、机房监控这类系统,这个配置几乎是必备的。

4. 客户端开发实战:Java快速接入MQTT

4.1 客户端库怎么选:Paho、HiveMQ Client、Spring Integration

Java生态下的MQTT客户端库,我用得最多的三个:Eclipse Paho Java Client,最老牌、资料丰富、兼容性好;HiveMQ MQTT Client,API设计更现代,用起来更顺手;Spring Integration MQTT,适合已经使用了Spring Boot的项目,可以走消息驱动的方式接入。

如果项目是Spring Boot,我推荐直接整合Spring Integration MQTT或者自己封装Paho。Paho的Maven依赖:

<dependency> <groupId>org.eclipse.paho</groupId> <artifactId>org.eclipse.paho.client.mqttv3</artifactId> <version>1.2.5</version> </dependency>

4.2 第一个Java MQTT程序:连接、订阅、发布

先写一个最朴素的连接示例,用Paho原生API:

import org.eclipse.paho.client.mqttv3.*; import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; public class MqttQuickStart { public static void main(String[] args) throws MqttException { String broker = "tcp://localhost:1883"; String clientId = "demo-client-" + System.currentTimeMillis(); MemoryPersistence persistence = new MemoryPersistence(); // 连接参数:心跳30秒,自动重连打开 MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(true); options.setKeepAliveInterval(30); options.setAutomaticReconnect(true); options.setUserName("admin"); options.setPassword("admin123".toCharArray()); MqttClient client = new MqttClient(broker, clientId, persistence); client.setCallback(new MqttCallback() { @Override public void connectionLost(Throwable cause) { System.err.println("连接断开:" + cause.getMessage()); } @Override public void messageArrived(String topic, MqttMessage message) { String payload = new String(message.getPayload()); System.out.println("收到消息:topic=" + topic + ", payload=" + payload); } @Override public void deliveryComplete(IMqttDeliveryToken token) { System.out.println("消息发送完成"); } }); client.connect(options); // 订阅设备上报主题 client.subscribe("devices/+/telemetry", 1); // 发布一条指令 String payload = "{\"action\":\"restart\"}"; MqttMessage message = new MqttMessage(payload.getBytes()); message.setQos(1); client.publish("devices/001/command", message); } }

这段代码有几个细节说清楚:

clientId在连接Broker时是唯一标识。同一个clientId重复连接会把旧连接踢掉,这在生产环境里是一把双刃剑:好处是设备重连时不会积累大量僵尸会话;坏处是如果你在多个地方用一个clientId同时连接,会互相顶掉。所以我通常在clientId里拼上时间戳或随机数避免冲突。setCleanSession(true)表示不保留会话状态,连接断开后订阅关系清空;如果设成false,Broker会帮客户端缓存离线期间的消息,等它下次上线再补发,适合控制指令下发场景。

4.3 订阅通配符与消息分发设计

物联网平台接入的设备很多,订阅策略决定了你的服务端能不能优雅地处理海量消息。我习惯的做法是主题按层级拆成三段:类型/设备ID/数据域。例如:

  • 设备上报温度:telemetry/{deviceId}/temperature
  • 设备上报开关状态:telemetry/{deviceId}/switch
  • 平台下发指令:command/{deviceId}/relay、command/{deviceId}/config
  • 系统事件通知:event/{deviceId}/online、event/{deviceId}/offline

服务端只订阅telemetry/+/temperature和event/#,具体某一台设备的数据要单独处理时,再动态订阅command/{deviceId}/#。这样设计的好处是:数据入库可以按主题前缀批量处理;控制指令只发给指定设备,不会串号;设备相关事件用通配符全量感知。

QoS 1订阅的消息如果设备处理不完,积压会越来越严重。这时候要分清情况:如果是高频传感器数据,就应该主动降级到QoS 0,宁可丢包也不能积压。这个决策要根据业务特点来做,不是越可靠越好。

5. 工业场景硬核实战:MQTT如何给RS485设备发指令、读取数据

5.1 场景拆解:从Modbus RTU到MQTT的桥接方案

做工厂数据采集的项目里,最经典的一个需求是:现场有一堆RS485接口的设备(电表、水表、温控器、变频器),走的是Modbus RTU协议。现在要把这些设备的数据接进物联网平台,用MQTT上云,同时平台还要能远程给设备下发指令。这就涉及两个协议域的转换。

简单解释一下Modbus RTU:它走串口,主站发请求帧(比如“读第1号设备、寄存器地址0x0000、读2个寄存器”),从站返回响应帧。整个通信是一问一答的Request-Response模式,和MQTT这种异步发布/订阅模式不一样。所以我不会让485设备直接去跑MQTT协议,而会加一个协议转换网关。网关一面用串口和485设备通信,另一面用MQTT和Broker通信。

网关可以是硬件(市面上的DTU、边缘网关盒子),也可以是一台工控机上跑的软件。如果设备数量不多、现场环境允许,软件方案成本更低、改动更灵活。核心串口操作可以用松下的jSerialComm或者更底层的modbus4j来做。我用得最多的是modbus4j,它把CRC校验、功能码封装好了,不用自己造轮子。

5.2 485总线参数和Modbus功能码:先搞定物理层

正式开始写代码之前,必须确认三件事:

  1. 串口参数:波特率(常见9600、19200)、数据位(8)、校验位(无校验/偶校验)、停止位(1),这四个参数必须与设备手册一致,否则收到的一定是乱码。
  2. 设备地址:Modbus RTU总线上每台设备有一个地址,范围1-247,同一条总线上不能重复。
  3. 功能码:Modbus常用的功能码有三个。03读保持寄存器(读设备参数和数据的90%场景);06写单个寄存器;10(十六进制0x10)写多个寄存器,对应16进制地址和数值。

我用一个具体例子来说明整个流程,比如采集一台温湿度变送器的数据,设备地址01,温度寄存器地址0x0001,湿度寄存器地址0x0002。

Modbus请求帧(读温度)结构是:设备地址01+ 功能码03+ 起始地址00 01+ 寄存器数量00 01+ CRC校验。用modbus4j写更简单:

import com.serotonin.modbus4j.ModbusFactory; import com.serotonin.modbus4j.ModbusMaster; import com.serotonin.modbus4j.ip.tcp.TcpMaster; import com.serotonin.modbus4j.serial.SerialPortWrapper; // 1. 初始化串口连接 ModbusFactory factory = new ModbusFactory(); SerialPortWrapper wrapper = new SerialPortWrapper() { // 实现getPortName()返回串口号,getBaudRate()返回波特率 }; ModbusMaster master = factory.createRtuMaster(wrapper); master.init(); // 2. 读温度寄存器,readHoldingRegisters(设备地址, 寄存器起始地址, 读取数量) Number temperature = master.getValue(1, 0x0001, 1, true); // 返回值还需要按设备手册的缩放系数换算成实际值,比如除以10就是实际温度 float realTemperature = temperature.floatValue() / 10.0f;

这里有个常见的坑:true参数表示使用「保持寄存器寄存器与输入寄存器解析方式」的浮点转换规范,具体要对照设备的Modbus寄存器表来定。比如有些设备用的是有符号整数、无符号整数、浮点数格式,解析方式不同,出来的结果就是天壤之别。

5.3 指令上行和下行的完整链路设计

设备数据读上来之后,网关要把数据转成MQTT消息发布到Broker。温度数据我一般这样上报:

String deviceId = "rs485_device_01"; String topic = "telemetry/" + deviceId + "/temperature"; String payload = "{\"value\":25.6,\"unit\":\"celsius\",\"timestamp\":1700000000000}"; MqttMessage mqttMessage = new MqttMessage(payload.getBytes()); mqttMessage.setQos(1); mqttClient.publish(topic, mqttMessage);

平台侧要下发指令给485设备,反向流程也一样:平台发布消息到command/{deviceId}/write,消息体用JSON指定设备寄存器地址和要写入的值。网关订阅这个主题,收到消息后解析JSON,组装Modbus写指令,通过串口发给目标设备。

// 网关收到平台下发指令后的处理伪代码 public void messageArrived(String topic, MqttMessage message) { if (topic.startsWith("command/")) { // 解析指令JSON:{ "register": 0x0001, "value": 35, "deviceAddr": 1 } int register = jsonNode.get("register").asInt(); int value = jsonNode.get("value").asInt(); deviceAddr = jsonNode.get("deviceAddr").asInt(); // 调用modbus4j写寄存器 master.writeRegister(deviceAddr, register, value); // 回执一条指令执行结果消息 mqttClient.publish("event/" + deviceId + "/commandResult", "{\"status\":\"ok\"}", 1, false); } }

这套上行、下行链路,我在实际项目中支撑过上百台485设备的数据采集和控制,稳定运行了非常久。关键点在于:控制类消息QoS必须用1,并让设备侧对重复指令做幂等处理;上行数据可以QoS 0,即使偶尔丢一帧,下一次采集周期也会补回来;485总线是半双工通信,同一时刻只能有一个主站发起传输,网关注入的读写请求之间要有适当的间隔,一般建议写入寄存器后延时50ms再读,避免总线冲突。

5.4 千万别踩的坑:数据格式、字节序、异常返回

Modbus对接的坑,十之八九出在数据解析上。我列几个最常见的给新手排雷:

  • 16位寄存器的数据类型:读取温度如果设备手册写的是signed int16,你就必须按Java有符号short类型来解析。用无符号类型解析负温度会得到一个巨大无比的值。
  • 32位浮点数:很多设备用两个连续寄存器存一个float(IEEE 754单精度),这时要注意字节序——有的设备是AB CD(高字节在前),有的是CD AB(低字节在前),解析错了一位,数据全是乱码。
  • 异常码响应:Modbus从站如果返回异常帧,比如功能码变成0x83(读异常),后面跟的异常码02表示非法数据地址。这通常意味着你请求的寄存器地址不存在或者数量超范围。排查时先对照设备寄存器说明表确认地址。
  • 串口占用:Windows下串口被串口调试助手或者别的程序占用了,你的Java代码就打开不了。开发调试时尽量做到“谁用串口谁抢占,用完立刻释放”。

这些都是血泪教训。一次采集系统上线后温度数据飘红,排查了很久发现不是设备坏了,是网关把两个寄存器的顺序解析反了,修正字节序之后所有数据恢复正常。

6. 常见问题与排查技巧实录

6.1 连接失败类:从代码到网络逐层定位

MQTT客户端连接不上Broker,这个问题出现的频率最高。常用的排查顺序是:

  1. 先确认Broker是否在运行。Windows下命令行执行netstat -an | findstr 1883,能看到LISTENING状态说明端口在监听。
  2. 确认防火墙没有拦截1883端口。局域网测试时Windows自带防火墙经常拦这个端口,可以临时加一条入站规则允许1883端口通过。
  3. 确认Broker地址和端口写对了。tcp://localhost:1883是本机测试,局域网设备访问要用Broker所在电脑的局域网IP。
  4. 如果配置了用户名密码,确认密码文件路径正确、allow_anonymous false生效。Mosquitto改完配置文件必须重启服务才生效。
  5. 看日志。Mosquitto加-v参数启动会打印详细连接日志,客户端连接时日志会出现New client connected from ...字样,报Connection REFUSED则是认证或clientId问题。

我刚接触MQTT时卡的最久的问题,就是改了配置文件忘了重启服务,日志里反复报连接被拒,实际是旧配置还在生效。

6.2 消息收发异常:能连上但收不到消息

能连接成功,但发布的消息订阅端收不到,这个问题排查起来比连接失败更让人抓狂。常见原因是topic不对。发布端发布到temp/1,订阅端订阅temp/#,中间看起来差不多,实际上一个差一个字符都收不到。遇到这个问题,第一步就是打开MQTTX客户端或者用mosquitto_sub -t '#' -v把所有消息打出来,看看消息究竟发到了哪里。

还有一个隐蔽问题:客户端设置了cleanSession=false,且订阅时设置了QoS 0,但Broker端在会话恢复时重新投递离线消息的时候QoS会按会话内的最大QoS处理。这个细节经常导致消息重复或者顺序错乱。

QoS级别的选择也会影响消息到达。如果发布端用的QoS 0,订阅端即使订阅用的是QoS 2,实际投递服务质量还是取决于发布端和订阅端协商的“较小值”。我就是因为发布端忘记设置QoS,全是默认0,导致一批控制指令丢在弱网环境里。核心指令发布前,一定显式设置setQos。

6.3 性能与稳定性:海量消息和弱网环境的两道坎

设备量上了规模之后,Broker的承载能力就会成为瓶颈。我这边实际验证过的经验数据是:单台Mosquitto默认配置跑几千个连接、每秒几千条消息没什么问题;但如果消息量达到每秒几万条以上,日志写入(如果开着verbose)会成为主要瓶颈。生产环境建议关掉verbose日志,消息持久化别依赖Broker的内存保留,直接转发给后端入库程序落库。

弱网环境下最容易踩的坑是重连风暴。设备数量多,网络波动时全部同时掉线同时重连,Broker压力瞬间拉满。记得合理配置心跳时间(心跳越短越灵敏,但开销越大,一般建议30到60秒之间)和重连退避策略(指数退避,不要立即重连)。设备上线时还要做错峰处理,比如给每个设备设置一个随机延时,再发起连接。

最后补充一个经验总结:先本地搭建一个最小环境,用MQTTX图形化工具把协议细节摸透,再写代码,能省掉大半周的调试时间。这个流程我重复了无数次,每一次都有效。物联网开发不像纯后端开发,它一定是硬件、通信、协议、平台多端联调的事情,把工具链用熟练,才能把复杂度控制住。

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

一个人怎么指挥一支 AI 队伍

开场&#xff1a;三个助手&#xff0c;一个人的项目经理日常 假设你电脑上跑着三个 AI 助手&#xff1a;一个画图的&#xff0c;一个查资料的&#xff0c;一个写代码的。 现在来了个活&#xff0c;需要三个人配合&#xff1a;画图的出一张产品矩阵图&#xff0c;查资料的把那…

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

裸金属驱动与PCIe透传排查:三类芯片适配经验全解

裸金属装驱动、做透传&#xff0c;这类问题我从入门踩到现在&#xff0c;少说也得有一百多次了。前几天在龙蜥社区的 SkillHub 上翻到一个 AI Skill&#xff0c;标题写得很直白&#xff1a;“驱动装不上、透传总报错&#xff1f;三类芯片裸金属适配经验全收进这里”。看了一眼里…

作者头像 李华
网站建设 2026/10/3 12:16:44

Codex Session 可视化:Codex Viz 实测教程与 TaoToken 接入配置

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

作者头像 李华
网站建设 2026/10/3 12:14:46

沐神学习笔记:GPT、GPT-2、GPT-3 的演进脉络与 TaoToken 统一调用实践

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

作者头像 李华