news 2026/10/15 1:39:53

Seasar DI容器实战:从依赖注入到AOP与数据库访问的完整教程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Seasar DI容器实战:从依赖注入到AOP与数据库访问的完整教程

简介:Seasar教程资料面向具备一定Java Web基础的开发者,定位是快速上手 Seasar2——一款在日本企业中使用广泛的轻量级Java框架,可视为Struts、Spring与Hibernate的简化整合方案。压缩包共92个文件,约8.14MB,其中包含32个jar依赖库、20个dicon依赖注入配置文件、10个java源文件与6个sql脚本,另有class、properties、html、xml等辅助资源,基本覆盖框架学习与运行所需。已有431人学习下载。包内以完整的示例项目为主线,带有工程配置文件、属性资源及示例页面,可在IDE中直接对照调试;从HTML/JSP页面到Java类、再到数据库操作的典型调用链,以及dicon配置文件的写法、项目目录结构与配套库的组织方式,都适合逐层拆解。读者可借助此包快速搭建一个Seasar2+JSF风格的演示环境,理解其核心的依赖注入与AOP思想,适合想拓展框架视野、了解海外企业技术栈的Java学习者。

1. 为什么还在用 Seasar:一套被低估的 Java DI 容器教程资源

提到 Java 开发框架,大多数人第一时间想到 Spring。但如果你接手过遗留系统,或者翻过一些海外软件项目的技术栈,大概率会撞见另一个名字——Seasar。我这次拆的这份 seasar 教程资源,不是把官方文档翻译一遍,而是直接从一个可运行的示例项目入手,把 DI 容器的启动、组件注入、AOP 切面和数据库访问完整串起来。Seasar 的容器比 Spring 更轻,它的 dicon 配置文件在调试依赖问题时非常直观。适合两类人:一类是维护老系统的工程师,另一类是刚学 Spring 但想搞懂“容器到底怎么把对象连起来”的新手,这份资源刚好可以把黑匣子打开给你看。

2. Seasar 的核心机制:从容器初始化到依赖注入的完整链路

Seasar 的容器叫 S2Container,它解决的还是那个老问题:对象由谁创建、对象之间的依赖怎么连、什么时候销毁。和 Spring 相比,它的核心设计全部围绕一个“注册表”展开。你用 dicon 文件或者注解把组件告诉容器,容器在启动时解析这些描述,然后通过构造器或字段把依赖填进去。理解这一层,后面所有配置都是顺理成章的事。

2.1 从 dicon 文件到对象图:Seasar 容器启动时做了什么

常见做法是,在一个 XML 格式的 .dicon 文件里声明组件。下面这段是我在教程资源里经常看到的入门配置,把它放到资源包的 src/main/resources 下,命名为 app.dicon:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE components PUBLIC "-//SEASAR//DTD S2Container 2.4//EN" "http://www.seasar.org/dtd/components.dtd"> <components namespace="app"> <component name="userDao" class="com.example.UserDaoImpl"/> <component name="userService" class="com.example.UserServiceImpl"> <arg name="dao" componentName="userDao"/> </component> </components>

这段配置干了这么几件事:先注册一个叫 userDao 的组件,实现类是 UserDaoImpl;再注册 userService,并且在创建实例时,把名为 userDao 的组件作为构造器参数传进去。注意<arg>的 componentName 属性,它指向的是容器里另一个组件的名字,不是 class 名。如果你写成arg name="dao"而构造器参数名不是 dao,容器会在启动时报依赖不匹配。我在第一次跑的时候就在这里卡过,后面避坑章节会细说。

初始化容器的方式非常简单。在 Java 入口里写:

import org.seasar.framework.container.S2Container; import org.seasar.framework.container.factory.SingletonS2ContainerFactory; public class Boot { public static void main(String[] args) { SingletonS2ContainerFactory.setConfigPath("app.dicon"); SingletonS2ContainerFactory.init(); S2Container container = SingletonS2ContainerFactory.getContainer(); UserService service = (UserService) container.getComponent("userService"); service.sayHello(); SingletonS2ContainerFactory.destroy(); } }

逻辑说明:第一步指定配置文件路径,第二步初始化,第三步获取容器实例,第四步按名字取出组件,第五步销毁容器。参数说明:setConfigPath里只要传类路径下的文件名,如果配置文件在子目录里,需要带路径,比如config/app.dicon。这里我用 SingletonS2ContainerFactory 而不是每次 new S2Container,是因为在同一个 JVM 内复用同一个容器实例,避免重复加载配置。如果你在写单元测试,更推荐每次都新建容器,不然上一个测试的组件状态会泄漏到下一个用例里。

2.2 注解式注入:@Component 与 @Resource 的使用边界

dicon 文件适合管理全局组件,但项目里类多起来之后,注解的方式更省事。Seasar 支持在类上标@Component,在字段上标@Resource。比如:

package com.example; import org.seasar.framework.container.annotation.tiger.Component; import javax.annotation.Resource; @Component public class UserServiceImpl implements UserService { @Resource private UserDao userDao; public String sayHello() { return userDao.queryName(); } }

这段代码的关键在于容器启动时会扫描指定包下的类,发现@Component就自动注册;然后扫描字段上的@Resource,如果字段类型能在容器里找到唯一匹配的组件,就注入进去。这里有个使用边界:@Resource是按字段名字和类型双重匹配的。如果容器里有两个实现 UserDao 接口的组件,比如 UserDaoImpl 和 MockUserDaoImpl,字段名 userDao 只能和其中一个组件名匹配,否则会抛 TooManyBean 异常。而@Inject之类的注解在这套框架里要求更严格,所以我一般在教程资源里直接告诉学员:字段名要和组件名保持一致,能省很多麻烦。

拿表格对比一下两种方式:

方式声明位置优点缺点
dicon 文件外部 XML配置集中,替换实现不需要改代码写多了容易手滑,标签大小写敏感
注解式类/字段就近查看依赖关系组件多时隐式匹配反而难查

这里的内容还属于原理层。真正让 Seasar 和 Spring 拉开差距的是它对“元数据”的态度。Spring 更强调注解与自动配置,Seasar 的注解则更像一种显式声明,没有复杂的自动装配规则。所以教程资源里给出的示例,基本都是先写 dicon 文件,再过渡到注解,让新手先看到连接的过程。

2.3 作用域与生命周期:singleton 和 prototype 的实际差异

组件注册时如果不指定作用域,默认是 singleton,也就是容器里只有一个实例,所有调用方共享。dicon 文件里可以显式声明:

<component name="counter" class="com.example.Counter" instance="prototype"/>

instance 属性有两个取值:singleton和prototype。singleton 在容器第一次获取组件时创建,之后每次 getComponent 都返回同一个对象。prototype 则每次 getComponent 都创建新实例。这个区别在实际业务里直接影响状态管理。比如你做一个请求级的 Service,内部持有可变的临时字段,如果用了 singleton,并发请求之间会互相污染。教程资源里有一个购物车的例子,就是因为误用 singleton 导致 A 用户能看到 B 用户的购物车数据,改成 prototype 之后才正常。

生命周期钩子也是一个容易忽略的配置。在 dicon 中指定:

<component name="connectionPool" class="com.example.ConnectionPool"> <initMethod name="init"/> <destroyMethod name="close"/> </component>

容器初始化时调用 init 方法,销毁时调用 close 方法。这里有个很隐蔽的坑:如果组件是 prototype 作用域,每次新实例不会自动执行 destroy 方法,必须由调用方自己管理资源释放。所以我通常建议,带资源打开的操作尽量用 singleton 加 destroyMethod,避免泄漏连接。

3. 把教程资源跑起来:环境搭建与第一个 Hello World 的详细步骤

拿到手的 tutorial 资源包,通常不会直接给你一个 Spring Boot 那样的一键工程。它是传统 Java Web 项目结构,你可能需要手动把 lib 下的 jar 放进 classpath。我第一次跑的时候被这一步卡住了二十分钟,所以这一章按实际顺序走一遍。

3.1 资源包里的目录结构与运行顺序

我拆的这份 seasar 教程,压缩包解开后大概是这样的:

seasar-tutorial/ ├── lib/ │ ├── s2container-xxx.jar │ ├── s2framework-xxx.jar │ └── ... ├── src/ │ ├── main/java/com/example/ │ │ ├── UserService.java │ │ ├── UserServiceImpl.java │ │ ├── UserDao.java │ │ └── Boot.java │ └── main/resources/ │ └── app.dicon └── web/ └── WEB-INF/ └── web.xml

注意:我这里没有写具体版本号,因为这类教程资源经常是“集齐一版能跑就行”的旧 jar。老老实实用资源里自带的 lib 目录,别去仓库里搜高版本,高版本可能改过包名。先把 lib 下的 jar 全部加入项目的 Build Path。如果你用的是 Maven,常见做法是把本地 jar 安装进本地仓库:

mvn install:install-file -Dfile=lib/s2container-xxx.jar \ -DgroupId=org.seasar -DartifactId=s2container \ -Dversion=2.4 -Dpackaging=jar

这样 pom.xml 里就可以统一管理依赖了。参数说明:-Dfile指向 jar 的位置,-DgroupId和-DartifactId是你在代码里引用时用的坐标,-Dversion自己定一个稳定的值。装完之后在 pom.xml 里写:

<dependency> <groupId>org.seasar</groupId> <artifactId>s2container</artifactId> <version>2.4</version> </dependency>

3.2 用 main 方法启动容器:不依赖 Web 容器的最小验证

为了快速验证容器本身,可以跳过 Tomcat,直接从 main 方法启动。建立 Boot.java,代码和第 2 章类似,但多了一个细节:如果 dicon 文件引用了外部 DTD,而电脑离线,启动会报错。

import org.seasar.framework.container.factory.SingletonS2ContainerFactory; public class Boot { public static void main(String[] args) { SingletonS2ContainerFactory .setConfigPath("app.dicon"); SingletonS2ContainerFactory.init(); System.out.println("container started"); SingletonS2ContainerFactory.destroy(); } }

这一步跑通,说明类路径没问题。然后再加上业务调用。如果启动时报FileNotFound,原因基本是配置文件路径不对。教程资源中 app.dicon 放在 resources 根目录,所以 setConfigPath 参数就是 app.dicon。如果放到 config 目录下,参数要变成config/app.dicon。

接下来我们用一个用户登录的 Demo。UserServiceImpl 里调用 UserDao 查密码,然后做校验:

public class UserServiceImpl implements UserService { private UserDao userDao; public UserServiceImpl(UserDao userDao) { this.userDao = userDao; } public String login(String name) { return userDao.findPassword(name); } }

注意这里我改用构造器注入,和上面的 dicon 文件里<arg>对应。如果 dicon 里没有配置<arg>,容器会尝试调用无参构造器,然后 userDao 就是 null,后面调用时必然空指针。所以跑 Demo 前检查两处:dicon 的<arg>是不是写了,以及构造器参数名是否和name属性一致。

3.3 部署到 Tomcat:监听器初始化与 classpath 的坑

独立 main 方法验证之后,要把 Demo 放到 Web 容器里跑。传统做法是在 web.xml 里配置一个监听器,让容器随 Web 应用启动:

<listener> <listener-class> org.seasar.framework.container.servlet.SingletonS2ContainerContextListener </listener-class> </listener>

这个监听器会在应用启动时读取WEB-INF/classes/app.dicon,创建全局容器。很多新人在这一步遇到 classpath 问题:配置文件明明在 src/main/resources 下,部署后却没有出现在 WEB-INF/classes 里。检查你的打包配置,确保 resources 目录被包含。

如果同时需要暴露 Seasar 组件给 JSP 或 Servlet,可以配置:

<filter> <filter-class>org.seasar.framework.container.filter.S2ContainerFilter</filter-class> </filter>

它的作用是在请求进来时,从容器里获取对应的业务组件。这里不需要额外参数,但顺序要注意:监听器在 filter 之前执行。刚学的时候别写反,否则 filter 会拿到一个还没初始化的容器,启动直接抛空指针。

4. AOP 与数据库模板:Seasar 的实践节选与参数说明

教程资源除了 DI,还通常包含 AOP 和数据库访问。Seasar 的 AOP 是典型的拦截器模型,和 Spring AOP 思路类似,但配置位置在 dicon 文件里。数据库部分用的是 S2Dao,它把 SQL 文件和 Java 接口绑定,非常符合“SQL 只写 SQL,Java 只写接口”的分层。

4.1 切面注册:用拦截器实现日志与事务

先写一个拦截器,实现MethodInterceptor接口:

package com.example.aspect; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; public class LogInterceptor implements MethodInterceptor { public Object invoke(MethodInvocation invocation) throws Throwable { long start = System.currentTimeMillis(); Object result = invocation.proceed(); System.out.println(invocation.getMethod().getName() + " cost " + (System.currentTimeMillis() - start)); return result; } }

然后在 dicon 文件里给 userService 组件挂上这个拦截器:

<component name="userService" class="com.example.UserServiceImpl"> <arg name="dao" componentName="userDao"/> <aspect name="log" componentName="logInterceptor" pointcut="login|check"/> </component>

逻辑说明:invocation.proceed()是链路的核心,调用它才会执行被拦截的方法。pointcut指定方法名正则,只有匹配的方法才被拦截。参数说明:aspect的 name 属性任意起名,componentName 指向容器里已经注册的 logInterceptor 组件。如果没定义 pointcut,默认拦截所有 public 方法。这里有个陷阱:Seasar 的拦截器是基于字节码增强的,私有方法不会经过代理,所以写了也拦不到。

事务切面也是同一个套路。教程资源里一般会提供一个S2TransactionInterceptor,把它配置在 service 层组件上,方法抛异常时自动回滚。事务切面必须配置在业务层组件的公开方法上,如果配置在 private 方法或者从同类内部调用另一个被增强方法,事务不会生效。这种同类调用绕过代理的问题,在用 Spring 时也存在,不是 Seasar 特有的玄学。

4.2 S2Dao 的 SQL 即代码协作:接口与 SQL 文件约定

S2Dao 的用法很特别。你只需要写一个 Dao 接口,然后把 SQL 写在同包下的类名同名 SQL 文件中。比如:

package com.example.dao; public interface UserDao { public String findPassword(String name); }

对应的UserDao.sql:

select password from user where name = /*name*/'dummy'

S2Dao 会把接口里的方法名findPassword和 SQL 文件里的对应条目绑定。/*name*/'dummy'是替换参数,后面的'dummy'是给 SQL 编辑器看的假值,运行时会被传入的 name 替换。

参数说明:/*name*/是注释形式,中间的 name 必须和方法参数名一致。如果参数名对不上,框架会报“parameter not found”。这个方法只支持单参数,如果两个参数就得写成/*param.name*/的形式。教程资源里这段代码我会重点标记,因为很多人拿 MyBatis 的习惯套,以为#{}能用,结果报 SQL 错误。

再配合实体查询:

select * from user where age > /*age*/0 order by id

返回的结果会自动映射为List<UserEntity>,前提是字段名和实体属性名使用下划线到驼峰的映射。这个约定在资源里有一张映射表,总之记住:数据库字段是 user_name,实体属性就是 userName。如果你不想遵守映射,也可以在 SQL 里用别名直接对属性名:

select id as id, user_name as userName from user

但这会破坏统一风格,我一般只在字段差异很大的时候用。

4.3 事务切面与异常回滚:参数与顺序的细节

把事务拦截器挂到 service 上的完整片段:

<component name="transactionInterceptor" class="org.seasar.framework.aop.interceptors.TransactionInterceptor"/> <component name="userService" class="com.example.UserServiceImpl"> <arg name="dao" componentName="userDao"/> <aspect name="tx" componentName="transactionInterceptor"/> </component>

这样 userService 的所有 public 方法都被事务切面包裹。默认情况下,只有 RuntimeException 会触发回滚。如果你希望受检异常也回滚,需要配置事务拦截器的 rollback 规则。常见做法是继承 TransactionInterceptor 覆写isRollbackThrowable方法。

事务切面与日志切面的配置顺序也有讲究。如果日志切面在外层,事务切面在内层,日志切面会先进入,然后进入事务。反过来,事务先开始,然后记录日志。推荐的顺序是事务在更外层,这样日志打印的结果是事务提交后的状态。配置顺序就是<aspect>出现的顺序,踩过坑的人都知道,这个顺序改起来比写代码还容易错。

5. 常见问题与避坑指南:Seasar 教程里没写清楚的四个雷区

我每次带人跑这类老框架,翻车点很集中。这一章直接把现象、原因、解决写清楚,省得到处搜。

5.1 坑一:dicon 文件放在 resources 目录下仍然报 FileNotFound

现象:setConfigPath("app.dicon")抛出文件找不到异常,但 IDE 里 resources/app.dicon 明明存在。

原因:IDE 的 classpath 没有包含 src/main/resources,或者配置文件没有被编译到 target/classes。旧项目经常出现只有 java 目录被标记为资源目录。

解决:检查 Build Path 中资源目录是否被排除,或者在 pom.xml 的 build 资源里显式加入:

<resources> <resource> <directory>src/main/resources</directory> </resource> </resources>

加入之后重新执行mvn clean compile,然后到 target/classes 下确认 app.dicon 存在。这个排查步骤看着简单,却能挡掉一半的启动问题。

5.2 坑二:@Resource 注入的字段是 null,容器却启动正常

现象:容器正常启动,日志没有错误,但调用 service 方法时 userDao 为 null,空指针。

原因:Seasar 的注解式扫描需要明确开启。如果你只是把@Component写在类上,而没有在初始化时告诉容器“去哪个包扫描注解”,注解不会生效。这不是注解写错,是扫描机制没有被激活。

解决:在初始化之前加一行:

ContainerFactory.setAutoRegistration(true);

或者更好的是在 S2Container 上设置注册 include 包名。我更推荐的方式是一开始用 dicon 文件显式注册组件,先跑通,再切成注解式。这样可以把“是不是扫描没开”这个变量去掉。

5.3 坑三:AOP 切面没有拦截到私有方法

现象:日志切面只打印了 public 方法,私有方法没有输出。

原因:Seasar 的 AOP 是基于代理的,private 方法无法被动态代理覆盖。这不是配置问题,是 JVM 机制限制。

解决:把需要拦截的方法改成 public,然后通过接口调用。如果必须拦截私有方法,需要把逻辑移到单独组件里,用 public 包一层。教程资源里有一个权限校验的例子,就是先把检查逻辑抽取成独立的 PermissionChecker,再在 service 方法里调用,这样切面能生效,职责也更清晰。

5.4 坑四:与 Spring 混合使用时组件被重复初始化

现象:同时引入 Spring 和 Seasar 时,同一个 Service 对象被创建了两次,事务边界完全混乱。

原因:两套容器各自管理自己的组件。如果代码里既有 Spring 的@Autowired又有 Seasar 的@Resource,同一个实例可能被两套代理包装,导致一个调用栈里出现两个不同的对象。

解决:在一个模块里只选一套容器。如果历史项目已经混用,那么让 Seasar 作为主容器,Spring 的 Bean 通过桥接接口暴露给 Seasar,反过来配置很容易死循环。这个坑我通常会建议直接在架构层面统一,而不是打补丁。补丁也许能跑过测试,但上线后组件初始化顺序一旦变化,问题就会重新冒出来。

5.5 坑五:改 dicon 配置后热部署不生效

现象:修改了 app.dicon 里的组件名,重新编译后容器还是按旧配置初始化。

原因:SingletonS2ContainerFactory 在同一个 ClassLoader 里只会初始化一次,热部署工具可能没有触发新的容器创建。

解决:开发时把配置改为每次都重新创建容器,或者调用SingletonS2ContainerFactory.destroy()后再 init。在 Servlet 容器中,需要确保监听器正确触发容器销毁。有些热部署插件会直接替换 class 文件,但不会重新执行监听器,这时候就需要你手动重启。这个问题很烦,但确实不是玄学。从那以后我每次改 dicon 之后第一件事就是检查容器是不是旧实例。

6. 进阶:自己封装一个 Seasar 风格的注入注解处理器

这一章做一个可以亲手敲出来的小实验:用反射和注解实现一个微型容器,模仿 Seasar 的@Component和@Resource。这能帮你真正理解教程资源里那些配置背后做的事,也方便你自己写工具的时候设计 API。

先定义两个注解:

package com.example.micro; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface MyComponent {} @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface MyResource {}

然后写一个简单的容器类:

package com.example.micro; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; public class MyContainer { private Map<String, Object> beans = new HashMap<>(); public void register(Class<?> clazz) throws Exception { if (clazz.isAnnotationPresent(MyComponent.class)) { Object instance = clazz.getDeclaredConstructor().newInstance(); beans.put(clazz.getName(), instance); } } public void injectDependencies() throws Exception { for (Object instance : beans.values()) { for (Field field : instance.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(MyResource.class)) { Object dependency = beans.get(field.getType().getName()); field.setAccessible(true); field.set(instance, dependency); } } } } public <T> T getComponent(Class<T> clazz) { return (T) beans.get(clazz.getName()); } }

逻辑说明:第一步先注册所有组件,把实例存进 Map;第二步遍历每个实例的字段,看到@MyResource就按字段类型从 Map 里找依赖并注入。这里我刻意把注册和注入拆成两个方法,是因为真实容器必须先把所有组件建好,再统一解析依赖,否则 A 需要 B、B 需要 A 时就会死循环。参数说明:beans的 key 使用类全限定名而不是简单名,避免不同包下同名类互相覆盖。如果你要更贴近 Seasar,可以改成按组件名匹配,但那样需要额外维护一个 name 属性。

这个微型容器能跑起来之后,你可以再给它加上循环依赖检测、prototype 作用域、接口匹配。我那时候就是靠写这个玩具容器,才彻底明白 Seasar 的 dicon 文件和 Spring 的 bean 工厂到底在做什么。从那以后,我每次接手老项目都先看一眼容器配置再动手改业务代码,这个习惯帮我少踩了很多底层坑,希望帮到你。

本文还有配套的精品资源,点击获取

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

Vue3+Cesium响应式集成实战:解决容器失联、相机抢权与实体不渲染

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

作者头像 李华
网站建设 2026/10/15 1:38:01

VTK点云处理三要素:加载、变换、合并实战指南

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

作者头像 李华
网站建设 2026/10/15 1:37:53

STM32+MP2642GR-Z实现2S电池主动均衡:储能BMS设计实战

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

作者头像 李华
网站建设 2026/10/15 1:37:37

Swagger不是补文档工具,而是API契约前置声明机制

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

作者头像 李华
网站建设 2026/10/15 1:37:30

ESP8266嵌入式开发避坑指南:从环境配置到稳定联网

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

作者头像 李华
网站建设 2026/10/15 1:37:30

Qt纯代码中英文切换实战:解决tr()翻译失效与动态刷新

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

作者头像 李华