news 2026/10/13 1:12:22

深入解析JdkDynamicAopProxy:JDK动态代理与AOP调用链源码实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析JdkDynamicAopProxy:JDK动态代理与AOP调用链源码实战

1. 为什么我们要啃 JdkDynamicAopProxy 这块硬骨头

做 Java 后端开发的朋友,Spring 这套东西基本是绕不开的。平时写业务代码,@Transactional、@Cacheable、自定义注解切面,用起来确实顺手,一个注解下去,日志、事务、权限校验全给你安排得明明白白。但用归用,很多人对底层到底怎么把切面逻辑“织”进业务方法这件事,其实一直是模糊的。面试被问到“Spring AOP 底层怎么实现的”,能答出“动态代理”四个字,再往下问“JDK 动态代理和 CGLIB 有什么区别”“代理对象是怎么被创建的”“调用链是怎么串起来的”,就开始支支吾吾了。

我自己也是从这个阶段过来的。早些年排查一个事务失效的问题,明明方法上加了@Transactional,结果回滚死活不生效,最后发现是同类内部方法调用走了this,压根没经过代理对象。那次之后我才下决心把 Spring AOP 的源码从头捋了一遍,而JdkDynamicAopProxy就是这条链路上最核心的一环——它是基于 JDK 动态代理实现 AOP 的那个具体执行者,所有走接口代理的切面逻辑,最终都要经过它的invoke方法。

这篇文章适合谁看?如果你已经会用 Spring AOP,但对其内部机制一知半解,想搞清楚“代理对象怎么生成”“通知怎么被调用”“调用链怎么组织”这些问题,那这篇就是写给你的。我会从整体设计思路讲起,把JdkDynamicAopProxy的源码一段段拆开,配上可复现的实操代码和调试技巧,尽量做到你看完能自己动手验证,而不是停留在“好像懂了”的层面。核心关键词就三个:JDK 动态代理、AOP 调用链、源码调试。下面正式开始。

2. 整体设计思路:Spring 为什么选 JDK 动态代理

2.1 两种代理方式的分工与取舍

要理解JdkDynamicAopProxy,得先搞清楚 Spring 在代理这件事上的整体策略。Spring AOP 支持两种代理方式:JDK 动态代理和 CGLIB。前者要求目标类必须实现接口,生成的代理对象和目标对象实现同一套接口;后者通过继承目标类生成子类来代理,不要求接口。

那 Spring 是怎么选的?核心逻辑在DefaultAopProxyFactory里。判断规则其实不复杂:如果目标类实现了至少一个接口,默认走 JDK 动态代理;如果没实现任何接口,就走 CGLIB。当然,从 Spring Boot 2.x 开始,默认配置改成了proxyTargetClass=true,也就是优先用 CGLIB,这个变化很多人没注意到,导致一些老代码迁移时行为不一致。

为什么 Spring 一开始要优先用 JDK 动态代理?我理解主要有几点考虑。第一,JDK 动态代理是 JDK 原生支持的,不依赖第三方库,生成的代理类是java.lang.reflect.Proxy的子类,稳定可靠。第二,基于接口代理在语义上更“干净”,代理对象和目标对象是兄弟关系而非父子关系,不会出现子类覆盖父类方法带来的意外。第三,接口本身就是一种契约,基于契约做增强,符合面向接口编程的思路。

但 JDK 动态代理也有硬伤:只能代理接口中声明的方法。如果目标类有个 public 方法但没在接口里声明,那这个方法就代理不到。这也是为什么后来 Spring Boot 默认转向 CGLIB 的原因之一——业务代码里不写接口的情况太普遍了。

2.2 JdkDynamicAopProxy 在整条链路中的位置

Spring AOP 创建代理的入口是ProxyFactory,它内部持有AdvisedSupport(封装了目标对象、通知、切点等所有配置信息)。当你调用getProxy()时,ProxyFactory会把创建代理的活儿委托给AopProxyFactory,而DefaultAopProxyFactory根据前面说的规则,决定返回JdkDynamicAopProxy还是ObjenesisCglibAopProxy。

JdkDynamicAopProxy本身实现了AopProxy和InvocationHandler两个接口。实现AopProxy是为了提供getProxy()方法,让外部能拿到代理对象;实现InvocationHandler是为了在代理对象的方法被调用时,能接管调用并织入通知逻辑。这个设计很巧妙——它把“创建代理”和“处理调用”两件事集中在了一个类里,职责清晰。

整个调用链路大致是这样的:业务代码调用代理对象的方法 → 触发JdkDynamicAopProxy.invoke()→ 在invoke里根据方法匹配到对应的通知链 → 把通知链包装成一个ReflectiveMethodInvocation→ 沿着拦截器链依次执行通知 → 最终通过反射调用目标方法。理解了这个链路,再看源码就不会迷路。

2.3 一个容易被忽略的设计:Advised 接口的支持

JdkDynamicAopProxy的invoke方法里有一段逻辑,专门处理代理对象自身实现的方法,比如Advised接口里的getTargetSource()、getAdvisors()这些。这些方法不应该被切面拦截,而是直接调用advised对象的对应方法。这个细节很多人看源码时会跳过,但它其实体现了 Spring 的一个设计考量:代理对象不仅要能执行业务逻辑,还要能被框架自身查询和操作,比如运行期动态添加或移除通知。

如果没处理好这部分,就会出现“想给代理对象加个通知,结果调用addAdvisor时反而被切面拦截了”的诡异问题。所以这段逻辑虽然不起眼,但很关键。

3. 核心源码逐段拆解与实操要点

3.1 getProxy 方法:代理对象是怎么造出来的

先看getProxy()方法,这是创建代理对象的入口。核心代码大致是这样:

public Object getProxy(@Nullable ClassLoader classLoader) { if (logger.isTraceEnabled()) { logger.trace("Creating JDK dynamic proxy: " + this.advised.getTargetSource()); } Class<?>[] proxiedInterfaces = AopProxyUtils.completeProxiedInterfaces(this.advised, true); findDefinedEqualsAndHashCodeMethods(proxiedInterfaces); return Proxy.newProxyInstance(classLoader, proxiedInterfaces, this); }

这段代码看着简单,但每一步都有讲究。completeProxiedInterfaces负责收集所有需要代理的接口,它不只是拿目标类实现的接口,还会把SpringProxy、Advised、DecoratingProxy这些 Spring 内部接口加进去。为什么要加?因为 Spring 需要能识别出“这是一个 Spring 生成的代理对象”,并且在需要的时候能查询代理的配置信息。

findDefinedEqualsAndHashCodeMethods这个方法也很有意思。它检查代理接口里有没有显式声明equals和hashCode,如果有,就设置一个标志位。因为equals和hashCode是Object的方法,默认情况下代理对象会用自己的实现,但如果接口里重新声明了,就得特殊处理。这个细节平时根本感知不到,但在某些框架集成场景下会出问题。

最后一行Proxy.newProxyInstance就是 JDK 原生 API 了,传入类加载器、接口数组和InvocationHandler(也就是this),返回代理对象。这里有个坑:类加载器的选择。如果传错类加载器,可能导致代理对象和目标对象不在同一个类加载器命名空间下,出现ClassCastException。Spring 默认用目标类的类加载器,一般没问题,但在 OSGi 或自定义类加载器环境下要特别小心。

提示:调试getProxy时,可以在Proxy.newProxyInstance前后打日志,把proxiedInterfaces数组里的接口名都打印出来,你会清楚看到 Spring 到底给代理对象加了哪些接口,这对理解后续invoke的分支逻辑很有帮助。

3.2 invoke 方法总览:一个方法里的三个世界

invoke方法是JdkDynamicAopProxy的灵魂,也是整个类里最复杂的一段。它大致可以分成三个逻辑块:处理Object基础方法、处理 Spring 内部接口方法、处理真正的业务方法增强。我先把整体结构列出来,再逐个拆。

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Object oldProxy = null; boolean setProxyContext = false; TargetSource targetSource = this.advised.targetSource; Object target = null; try { // 块一:处理 equals / hashCode if (!this.equalsDefined && AopUtils.isEqualsMethod(method)) { return equals(args[0]); } if (!this.hashCodeDefined && AopUtils.isHashCodeMethod(method)) { return hashCode(); } // 块二:处理 DecoratingProxy 和 Advised 接口方法 if (!this.advised.opaque && method.getDeclaringClass().isInterface() && method.getDeclaringClass().isAssignableFrom(Advised.class)) { return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args); } // 块三:真正的业务方法增强 Object retVal; if (this.advised.exposeProxy) { oldProxy = AopContext.setCurrentProxy(proxy); setProxyContext = true; } target = targetSource.getTarget(); Class<?> targetClass = (target != null ? target.getClass() : null); List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass); if (chain.isEmpty()) { Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args); retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse); } else { MethodInvocation invocation = new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain); retVal = invocation.proceed(); } // 处理返回值 Class<?> returnType = method.getReturnType(); if (retVal != null && retVal == target && returnType != Object.class && returnType.isInstance(proxy) && !RawTargetAccess.class.isAssignableFrom(method.getDeclaringClass())) { retVal = proxy; } else if (retVal == null && returnType != Void.TYPE && returnType.isPrimitive()) { throw new AopInvocationException("Null return value from advice does not match primitive return type for: " + method); } return retVal; } finally { if (target != null && !targetSource.isStatic()) { targetSource.releaseTarget(target); } if (setProxyContext) { AopContext.setCurrentProxy(oldProxy); } } }

3.3 块一:equals 和 hashCode 的特殊处理

第一块逻辑处理的是equals和hashCode。为什么这两个方法要单独拎出来?因为它们是Object的方法,任何对象都有,代理对象也不例外。如果不特殊处理,调用代理对象的equals时,会走到后面的业务增强逻辑,可能被切面拦截,这显然不合理。

this.equalsDefined这个标志位就是前面findDefinedEqualsAndHashCodeMethods设置的。如果代理接口里没有显式声明equals,那AopUtils.isEqualsMethod(method)判断为真时,直接调用equals(args[0]),也就是JdkDynamicAopProxy自己的equals实现。这个实现比较的是advised对象,逻辑上说得通。

这里有个容易踩的坑:如果你在切面里对equals做了增强,或者依赖代理对象的equals做集合去重,一定要清楚这个行为。我见过有同事把代理对象放进HashSet,结果因为hashCode走的是JdkDynamicAopProxy的实现,和预期不一致,排查了半天。

3.4 块二:Advised 接口方法的直通处理

第二块逻辑处理的是Advised接口及其父接口的方法。判断条件有三个:!this.advised.opaque(没设置 opaque 标志)、method.getDeclaringClass().isInterface()(方法声明在接口上)、method.getDeclaringClass().isAssignableFrom(Advised.class)(这个接口是 Advised 或其子接口)。

满足条件时,直接通过反射调用this.advised的对应方法,不走通知链。这就是前面说的“框架自身操作代理对象”的通道。比如你调用((Advised) proxy).getAdvisors(),拿到的就是配置的通知列表,而不是被切面增强后的结果。

opaque这个标志位默认是 false,可以通过ProxyFactory.setOpaque(true)设置。设置成 true 后,代理对象就不再暴露Advised接口的方法了,相当于“不透明”。这个配置很少用,但在某些需要隐藏框架细节的场景下有用。

注意:如果你在业务代码里把代理对象强转成Advised来动态修改通知,一定要确认opaque是 false,否则会抛ClassCastException。这个坑我在一个动态限流的需求里踩过,当时想运行期给某个代理加个限流通知,结果死活转不过去,最后发现是框架某处把 opaque 设成了 true。

3.5 块三:通知链的获取与执行

第三块是真正的核心。先看exposeProxy的处理:如果开启了暴露代理,会把当前代理对象设置到AopContext的 ThreadLocal 里,这样在目标方法内部可以通过AopContext.currentProxy()拿到代理对象。这是解决“同类内部调用不走代理”问题的标准手段,但代价是每次调用都要操作 ThreadLocal,有轻微性能开销,而且必须开启exposeProxy配置。

接着是targetSource.getTarget()获取目标对象,然后this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass)获取这个方法对应的拦截器链。这个方法内部会做切点匹配,把符合条件的通知和拦截器收集起来,包装成InterceptorAndDynamicMethodMatcher列表。如果链为空,说明这个方法不需要增强,直接反射调用目标方法;否则创建ReflectiveMethodInvocation,调用proceed()沿着链执行。

ReflectiveMethodInvocation.proceed()是另一个核心类,它维护了一个currentInterceptorIndex游标,每次调用proceed就取出下一个拦截器执行,拦截器内部再调用invocation.proceed()就继续往下走,形成递归式的责任链。这个设计是 Spring AOP 通知执行顺序的基础,理解它才能搞清楚@Around、@Before、@After的执行时机。

最后是返回值的处理。有一段逻辑是:如果返回值就是目标对象本身,且返回类型能匹配代理对象,那就返回代理对象。这是为了处理方法返回this的场景,保证返回的始终是代理对象,避免后续调用绕过切面。还有一段是处理原始类型返回 null 的情况,直接抛异常,因为原始类型不能为 null。

4. 完整实操:手写一个 JDK 动态代理复现 AOP 效果

4.1 准备目标接口和实现类

光看源码容易飘,咱们动手写一个最小复现。先定义一个接口和实现类:

public interface OrderService { String createOrder(String orderId); void cancelOrder(String orderId); } public class OrderServiceImpl implements OrderService { @Override public String createOrder(String orderId) { System.out.println("执行创建订单,订单号:" + orderId); return "SUCCESS-" + orderId; } @Override public void cancelOrder(String orderId) { System.out.println("执行取消订单,订单号:" + orderId); } }

这两个方法一个带返回值一个不带,方便后面验证返回值处理逻辑。

4.2 用 Spring 的 ProxyFactory 创建代理

不直接手写InvocationHandler,而是用 Spring 的ProxyFactory,这样能完整走一遍JdkDynamicAopProxy的流程:

import org.springframework.aop.framework.ProxyFactory; import org.springframework.aop.MethodBeforeAdvice; import java.lang.reflect.Method; public class AopDemo { public static void main(String[] args) { OrderService target = new OrderServiceImpl(); ProxyFactory factory = new ProxyFactory(target); factory.addAdvice(new MethodBeforeAdvice() { @Override public void before(Method method, Object[] args, Object target) { System.out.println("[前置通知] 准备执行方法:" + method.getName()); } }); OrderService proxy = (OrderService) factory.getProxy(); System.out.println("代理对象类型:" + proxy.getClass().getName()); String result = proxy.createOrder("ORD-001"); System.out.println("返回结果:" + result); proxy.cancelOrder("ORD-002"); } }

运行这段代码,你会看到代理对象类型是com.sun.proxy.$Proxy0这样的名字,说明走的是 JDK 动态代理。输出里前置通知会在每个方法执行前打印,说明增强生效了。

4.3 断点调试:亲眼看看 invoke 的执行路径

光跑通还不够,得亲眼看看invoke是怎么走的。在JdkDynamicAopProxy.invoke方法的第一行打个断点,然后以 Debug 模式运行上面的main方法。你会看到:

第一次进入invoke,method是createOrder,args是["ORD-001"]。单步往下走,会经过equals/hashCode判断(不匹配),经过Advised接口判断(不匹配),然后走到getInterceptorsAndDynamicInterceptionAdvice,拿到一个包含我们那个MethodBeforeAdvice的链。接着创建ReflectiveMethodInvocation,调用proceed(),进入拦截器执行前置通知,最后反射调用目标方法。

第二次进入invoke,method是cancelOrder,流程类似,但因为返回类型是 void,最后返回值处理那段逻辑走的分支不同。

实操心得:调试时把chain变量加到 Watch 窗口,观察它的 size 和每个元素的类型。你会发现MethodBeforeAdvice被包装成了MethodBeforeAdviceInterceptor,这是 Spring 把通知适配成拦截器的标准做法。理解这层适配,对搞清楚通知执行顺序很关键。

4.4 验证同类内部调用不走代理的经典问题

现在改一下OrderServiceImpl,加一个内部调用:

public class OrderServiceImpl implements OrderService { @Override public String createOrder(String orderId) { System.out.println("执行创建订单,订单号:" + orderId); this.cancelOrder(orderId); // 内部调用 return "SUCCESS-" + orderId; } // cancelOrder 不变 }

再跑一遍,你会发现cancelOrder的前置通知没有打印。原因就是this.cancelOrder走的是目标对象自己的引用,压根没经过代理对象,自然也就不会触发invoke。这就是我开头提到的那个事务失效问题的根源。

解决办法有两个:一是通过AopContext.currentProxy()拿到代理对象再调用,但需要开启exposeProxy;二是把cancelOrder拆到另一个 Bean 里,通过依赖注入调用。前者配置简单但有 ThreadLocal 开销,后者更符合 Spring 的设计哲学,我一般推荐后者。

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

5.1 代理对象强转失败怎么办

最常见的报错就是ClassCastException,比如OrderServiceImpl cannot be cast to OrderService或者反过来。原因通常是代理方式和预期不一致。如果你期望 JDK 动态代理,但目标类没实现接口,Spring 会走 CGLIB,生成的代理对象是目标类的子类,强转成接口没问题,但强转成目标类本身在某些情况下会出问题。

排查思路:先打印proxy.getClass()和proxy.getClass().getInterfaces(),看清楚代理对象到底实现了哪些接口、继承了什么类。然后检查ProxyFactory的proxyTargetClass配置,确认代理方式。如果是 Spring Boot 项目,还要注意spring.aop.proxy-target-class这个配置项的默认值。

5.2 通知不生效的几种典型场景

通知不生效是另一个高频问题。我整理了一个速查表:

现象可能原因排查方法
所有方法都不增强切点表达式写错打印advised.getAdvisors()看通知是否注册
部分方法不增强方法没在接口中声明检查代理方式,考虑改用 CGLIB
内部调用不增强走的是 this 引用用AopContext.currentProxy()或拆分 Bean
私有方法不增强JDK 代理只能代理接口方法私有方法本身就不该被 AOP 增强
静态方法不增强静态方法不属于对象无法通过代理增强,需换思路

这个表基本覆盖了我这些年遇到的大部分情况。其中“部分方法不增强”最隐蔽,因为接口里没声明的方法,JDK 动态代理根本不会去代理它,调用时直接走目标对象,连invoke都不会进。

5.3 性能相关的注意事项

JDK 动态代理的性能开销主要在两块:一是反射调用,二是拦截器链的遍历。反射调用在 JDK 8 之后有MethodHandle和LambdaMetafactory的优化,但 Spring AOP 默认还是用Method.invoke。如果对性能极其敏感,可以考虑用 CGLIB 或者 AspectJ 编译期织入。

拦截器链的遍历本身开销不大,但如果切点表达式写得过于复杂,每次调用都要做模式匹配,累积起来也不容忽视。我的经验是,切点尽量精确到方法名或注解,避免用execution(* com.xxx..*.*(..))这种大范围匹配。

提示:可以用-Dspring.aop.proxy-target-class=true强制走 CGLIB,对比一下两种方式的性能差异。在方法调用密集的场景下,CGLIB 通常略快,因为它不需要接口方法查找,但生成代理类的开销更大,启动时会慢一点。

5.4 调试源码时的实用技巧

最后分享几个调试 Spring AOP 源码的技巧。第一,把org.springframework.aop包的日志级别调到 DEBUG,能看到很多内部状态。第二,在ReflectiveMethodInvocation.proceed()里打断点,观察currentInterceptorIndex的变化,能直观理解责任链的推进过程。第三,用 IDEA 的 Evaluate Expression 功能,在断点处直接调用advised.getAdvisors()查看通知列表,比翻代码快得多。

还有一个我常用的方法:写一个自定义的MethodInterceptor,在invoke方法里打印invocation.getMethod().getName()和invocation.getArguments(),然后加到ProxyFactory里。这样每次方法调用你都能看到完整的入参和调用顺序,排查通知顺序问题时特别有用。

6. 从 JdkDynamicAopProxy 延伸出去的几个思考

6.1 和 CGLIB 代理的本质差异

理解了JdkDynamicAopProxy,再看CglibAopProxy会轻松很多。两者的核心差异在于代理对象的生成方式:JDK 是“实现接口”,CGLIB 是“继承类”。这导致 CGLIB 能代理更多方法(包括没在接口里声明的 public 方法),但也有局限,比如无法代理 final 方法和 final 类。

从invoke的角度看,CGLIB 用的是MethodInterceptor.intercept(),参数里多了个MethodProxy,可以直接调用目标方法而不用反射,这是它性能优势的来源之一。但两者的通知链组织逻辑是共通的,都依赖Advised和ReflectiveMethodInvocation。所以吃透一个,另一个就是换个壳。

6.2 对日常开发的启发

搞懂JdkDynamicAopProxy之后,我对日常开发有几个实实在在的改变。第一,写业务代码时尽量面向接口,这样能享受 JDK 动态代理的稳定性和语义清晰。第二,遇到事务、缓存、异步这些依赖 AOP 的注解,会下意识检查调用路径有没有绕过代理。第三,自定义注解切面时,会注意切点表达式的精确度,避免误伤和性能损耗。

还有一个容易被忽略的点:代理对象的toString、equals、hashCode行为。前面说过equals和hashCode有特殊处理,但toString没有,它会走正常的增强逻辑。如果你在日志里打印代理对象,看到的是切面增强后的结果,而不是目标对象的toString。这个细节在排查问题时偶尔会带来困惑。

6.3 后续可以深入的方向

如果你已经把JdkDynamicAopProxy啃下来了,下一步可以往这几个方向走。一是ReflectiveMethodInvocation的完整源码,搞清楚通知的执行顺序和proceed的递归机制。二是AdvisedSupport.getInterceptorsAndDynamicInterceptionAdvice的切点匹配逻辑,理解MethodMatcher和Pointcut的协作。三是 Spring 事务管理里TransactionInterceptor是怎么和 AOP 结合的,这是 AOP 最经典的应用场景。

我个人在实际操作中的体会是,源码这东西,看一遍只能混个脸熟,真正理解得靠动手改、动手调。你可以试着给JdkDynamicAopProxy加几行日志,或者写个自定义的AopProxy实现,替换掉默认的,看看会发生什么。这种“破坏性”的实验,比老老实实读代码收获大得多。最后再分享一个小技巧:把 Spring 的源码下载到本地,用 IDEA 关联上,调试时能直接跳到源码行,比看反编译的 class 文件舒服太多。

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

Python安装与卸载全攻略:从环境配置到残留清理的避坑指南

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

作者头像 李华
网站建设 2026/10/13 1:12:17

群辉NAS部署Dify全指南:从离线安装包到容器化避坑

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

作者头像 李华
网站建设 2026/10/13 1:11:26

Word文档加密全攻略:打开密码、限制编辑与VBA工程保护

做办公软件支持这些年&#xff0c;被问得最多的问题之一就是&#xff1a;“Word能不能加密&#xff1f;”问这话的通常是三种人&#xff1a;写标书和合同的商务、管内部台账的行政、还有手里捏着一堆制度文件的部门负责人。他们不是真需要多高级的信息安全技术&#xff0c;只是…

作者头像 李华
网站建设 2026/10/13 1:09:25

ARM服务器国产化之路:比造芯片更难的是软件生态迁移

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

作者头像 李华
网站建设 2026/10/13 1:08:48

PHP+MySQL留言板源码:带数据库文件的可部署最小闭环系统

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

作者头像 李华
网站建设 2026/10/13 1:08:11

计算机硬件常识全解析:从CPU到电源的选购与装机避坑指南

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

作者头像 李华