ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis核心属性深度解析:从配置到源码的排查指南

MyBatis核心属性深度解析:从配置到源码的排查指南 先说个场景。有一次我给项目组做技术评审讲到 MyBatis 时随口问了一句settings下面到底配了哪些核心属性它们分别影响什么结果现场安静了几秒。有人能说出mapUnderscoreToCamelCase有人记得cacheEnabled但很少有人能把这些属性和底层的执行行为串起来。这不是个例。很多同学用 MyBatis就是写 Mapper 接口、写 XML、调通接口跑起来就算完事。可一旦遇到缓存不生效、分页查询总条数不对、SQL 打印不出来、时间字段映射错乱这类问题就完全无从下手。所以我把这份“由浅入深”的 MyBatis 核心属性笔记系统性整理了一遍。从配置文件里的properties、settings到 XML 映射文件中的statementType、resultMap、useGeneratedKeys再到缓存、分页插件、拦截器甚至源码层面这些属性到底落在哪个类、哪个字段里。不管你是刚开始学 MyBatis 的新手还是在准备面试的 Java 开发又或者是已经在项目里踩过坑、想系统补课的工程师这份笔记都可以当查询手册用。文章里所有结论都来自实际项目和源码阅读没有一句是面试题答案式的空话。1. 从配置文件开始那些被忽略的属性决定了运行基调很多人第一次接触 MyBatis看的第一个文件就是mybatis-config.xml。说实话这个文件里的很多标签平时项目里可能一年都不动一次。但恰恰是这些“万年不动”的属性决定了框架整体怎么跑、能扛住什么场景、会在什么时刻给你挖坑。1.1 properties把“环境相关”从配置里剥离出去properties是配置文件中第一个值得认真对待的标签。它的作用是引入外部属性文件或者直接在 XML 里定义属性然后在其他地方用${}引用。最常见的就是数据库连接信息properties resourcedb.properties/ dataSource typePOOLED property namedriver value${db.driver}/ property nameurl value${db.url}/ property nameusername value${db.username}/ property namepassword value${db.password}/ /dataSource这样做的好处不用多说环境隔离。本地、测试、生产各一份db.properties打包时按环境替换Xml 文件本身不用改。这里有个很多人不知道的优先级问题如果properties标签内部直接定义了同名属性同时又通过resource引入了外部文件外部文件的值会覆盖标签内部的值。如果调用SqlSessionFactoryBuilder.build(configuration, props)时传入了 Java 属性那这个优先级最高。我见过有同事把连接信息同时写在两处结果某个环境连不上库排查了很久才发现是外部文件覆盖了内部配置。记住这个顺序方法参数 resource/url 引入的文件 properties 标签内部定义。另外一个经验是如果你在用 Spring Boot这个标签基本用不上因为 MyBatis 的配置可以整体收敛到application.yml里。但遇到一些遗留系统或者需要独立使用 MyBatis 的工具项目properties依然是最干净的做法。1.2 settings全局行为的大管家settings是 MyBatis 配置文件里最核心、也最容易翻车的区域。它管的是框架层面的全局行为。直接看几个高频且影响面大的属性默认值作用与坑点mapUnderscoreToCamelCasefalse开启后数据库下划线字段自动映射为 Java 驼峰属性cacheEnabledtrue二级缓存总开关lazyLoadingEnabledfalse延迟加载总开关aggressiveLazyLoadingfalse为 true 时任何方法调用都会触发完整加载localCacheScopeSESSION一级缓存范围改成 STATEMENT 可禁用jdbcTypeForNullOTHER插入 null 时告诉 JDBC 用哪种类型defaultExecutorTypeSIMPLE简单/复用/批量执行器defaultStatementTimeoutnullSQL 超时秒数logImpl不设置指定 SQL 日志输出实现autoMappingBehaviorPARTIAL控制自动映射级别先说mapUnderscoreToCamelCase。这个属性对老项目来说几乎是必开的。数据库的user_name要映射到 Java 的userName不开这个属性要么在 SQL 里写别名要么用resultMap一个一个戳字段。开了之后只要 SQL 返回的列名是下划线风格MyBatis 自动帮你对应到驼峰属性。但有个隐藏坑如果 Java 属性名和数据库列名完全对不上比如 Java 里叫userName数据库里该列本身就叫userName没有下划线开了这个属性反而可能让映射失效因为 MyBatis 会尝试把userName转成user_name去匹配列。实际项目中这种情况不多但遇到了要知道是这个属性在起作用。再说jdbcTypeForNull。老项目里最常见的报错之一就是Oracle 插入数据时某个字段为 null报无效的列类型。原因就是 MyBatis 默认把 null 标记为JdbcType.OTHER而 Oracle 驱动不认。解决方案有两种一种是把jdbcTypeForNull设成NULL另一种是在插入 SQL 的每个可能为 null 的字段上显式写jdbcTypeVARCHAR。显式指定更精准全局配置更省事。我个人的做法是全局配置改成NULL然后只在特殊字段上手动指定这样收益和风险最平衡。logImpl也是大家常问的。实际项目里想看 MyBatis 到底执行了什么 SQL、参数是什么、返回了几行这个属性是关键。直接配StdOutImpl会把 SQL 打到控制台方便但是有点粗暴。更推荐配Slf4jImpl然后通过日志框架控制输出级别生产环境调 warn开发环境调 debug不用改代码改配置。1.3 typeAliases 与 typeHandlers别小看取别名和时间映射typeAliases干的事很简单给 Java 类型起短名字。比如typeAliases package namecom.example.entity/ /typeAliases这样在 XML 里写resultTypeUserMyBatis 会自动去找com.example.entity.User。注意 MyBatis 对别名大小写不敏感user和User都能识别。但如果有两个包下存在同名类就会产生冲突启动时直接报错。见过一个项目就是因为 A 包和 B 包各有一个Order结果一堆人不知道到底映射到谁排查了一下午。所以我的习惯是不依赖包扫描别名重要映射一律写全限定类名虽然丑一点但启动即验证不迷路。typeHandlers是比别名更值得花时间研究的点。它负责 Java 类型和 JDBC 类型之间的转换。热词里提到的 “oracle 用 mybatis 查询时间映射” 基本就是它的问题。Oracle 的DATE和TIMESTAMP精度不同如果数据库字段是DATEJava 用的却是LocalDateTime很有可能查出来时间精度不对甚至直接映射失败。解决办法有两种思路一是 SQL 里用TO_CHAR转换格式二是自定义TypeHandler在getNullableResult里把Timestamp转成LocalDateTime。我现在偏向第二种因为问题在数据访问层就解决了业务层拿到的类型干净。1.4 environments 与 mappers多环境切换与映射注册environments是配置数据源和事务管理器的地方。日常开发一般只配一个但还是建议保留结构上的扩展性environments defaultdev environment iddev transactionManager typeJDBC/ dataSource typePOOLED !-- ... -- /dataSource /environment environment idprod !-- ... -- /environment /environments切换环境只需要改default属性。Spring Boot 项目里这块一般交给spring.datasource管理所以很多人没接触过。理解这个结构的好处在于当你看到老项目里的多环境配置时不会懵也能理解为什么有些框架自带“环境”这个概念。mappers则是把 Mapper 接口和 XML 文件注册到 MyBatis 里。四种方式resource指定类路径下的 XMLurl指定本地文件路径class直接注册接口package扫描整个包。用package最方便但有一个约束接口和 XML 必须同名且在同一个包下。我遇到过有同事把 XML 放在resources目录下跟接口不同包结果注册了半天扫不到浪费了很多时间。合理的做法是维持习惯接口在com.xxx.mapperXML 也放在com.xxx.mapper对应的资源目录下用package一把扫干净。2. 映射文件里的核心属性一条 SQL 从生到死的细节配置文件只是地基真正每天打交道的是 Mapper XML 文件。这里面的属性更多、更碎但每一个都直接影响 SQL 的生成和执行方式。2.1 statementType预编译和拼接的分水岭statementType对应 MyBatis 三种 Statement 类型STATEMENT、PREPARED、CALLABLE。默认是PREPARED这也是我强烈不建议随便改的一个属性。PREPARED走的是 JDBC 预编译?占位符由驱动发送到数据库端编译SQL 注入基本被阻断执行计划还能复用。改了STATEMENT则变成字符串拼接你传什么就拼什么看似灵活实则危险。比如动态表名、动态排序字段只能用${}拼这时候一旦外部输入没有严格白名单校验就是赤裸裸的注入口。那什么时候用STATEMENT值得我的经验是需要在数据库端做动态 SQL 生成或者某些分库分表中间件要求不能用预编译的场景。绝大多数业务系统都用不上。如果你发现某个 SQL 用PREPARED性能很差先怀疑执行计划和索引别急着改statementType。2.2 resultType 与 resultMap字段映射是出 bug 的重灾区resultType看起来简单里面学问不少。MyBatis 默认的自动映射规则是列名和 Java 属性名一致就直接映射开启了mapUnderscoreToCamelCase后下划线也能自动对应。但自动映射有个坑万一 SQL 查出来的列名和属性对不上MyBatis 不会报错只是默默把那个字段留空。这种“静默失败”在联调阶段特别害人数据看着查出来了某些值就是 null。resultMap是解决这类问题的正规手段。它不仅可以解决字段名不一致还能处理嵌套对象映射。比如Order里有User对象可以在resultMap里用association去映射一对一关系用collection映射一对多关系。处理这种复杂映射时有几个细节容易被忽略。第一id子标签要尽量声明一方面对性能有帮助另一方面是 MyBatis 判断相同对象去重的依据。第二关联查询的N1问题通常和resultMap懒加载配合不好有关。一个非常实际的建议对于查询字段超过 5 个的复杂结果不要图省事一直用resultType直接建resultMap把每个字段的对应关系写清楚。看起来麻烦但后期数据库加字段、改字段名、优化 SQL 的时候你就知道这个“麻烦”有多值钱。自动映射省的时间后面查 bug 会加倍还回来。2.3 useGeneratedKeys 与 keyProperty主键回填的正确姿势插入数据后要拿到自增主键这是最常见的需求。MySQL 下正确姿势是insert idinsertUser parameterTypeUser useGeneratedKeystrue keyPropertyid insert into user(name, age) values(#{name}, #{age}) /insertuseGeneratedKeystrue告诉 MyBatis 需要获取数据库自增键keyPropertyid指定把生成的键回填到对象的哪个属性。插入完成后你直接user.getId()就能拿到新主键。很多新手不写这两个属性插入完再查一次库完全没必要。Oracle 没有自增用的是序列。这时useGeneratedKeys也需要配合keyProperty但执行方式和 MySQL 不同需要事先执行一次序列查询。在 MyBatis 中可以通过selectKey实现insert idinsertUser parameterTypeUser selectKey keyPropertyid resultTypelong orderBEFORE select seq_user.nextval from dual /selectKey insert into user(id, name, age) values(#{id}, #{name}, #{age}) /insertorderBEFORE表示在插入前先查序列查到后作为参数传入 SQL。这个细节在面试中经常被问实际工作里也经常有人搞反BEFORE和AFTER。MySQL 自增用AFTEROracle 序列用BEFORE这个顺序不要记错。还有批量插入的场景。一次插入多条记录keyProperty支持点号写法比如keyPropertylist.id或者直接写下层对象属性名。不少人在批量插入时发现主键没回填多半是keyProperty写错了层级。这里我的经验是批量插入时先确认入参是不是一个 List对象内部的 id 字段有没有对应的 setter再检查属性路径一般都能解决。2.4 timeout 与 fetchSize容易被忽略的“体验类”属性timeout指一条 SQL 在数据库驱动层面最多执行多少秒超时就抛异常。默认不设等于无限等。说实话生产环境不设timeout是件很危险的事。有一次排查线上接口变慢数据库侧有个 SQL 因为表锁一直拿不到资源应用侧线程全部卡在那一条查询上。如果设置了timeout至少能快速失败触发重试或熔断不会把整个线程池拖死。所以我的习惯是在慢查询风险较高的select上加timeout10这样的兜底配置。fetchSize是每次从数据库游标中抓取的记录数。数据量小的时候没什么感觉但做大批量导出、几万甚至几十万行数据时fetchSize设置不当会导致内存暴涨或查询极慢。MySQL 默认驱动会把结果一次性拉回客户端Oracle 则靠游标一批批取。如果项目里有大数据量导出需求建议把fetchSize设到 500 或 1000配合resultSetTypeFORWARD_ONLY使用能明显降低内存压力。注意resultSetType有FORWARD_ONLY、SCROLL_INSENSITIVE、SCROLL_SENSITIVE三种普通查询用默认即可别乱开 SCROLL会额外占用内存。3. 缓存、分页插件与拦截器视角下的核心属性这一部分要回答两个高频问题MyBatis 的缓存到底由谁控制分页插件凭什么能把你的 SQL 改成分页 SQL这两个问题背后本质上都离不开核心属性在关键执行链路上的作用。3.1 缓存到底由谁控制cacheEnabled、localCacheScope、flushCache、useCache说到 “mybatis 缓存”很多人只知道一级缓存、二级缓存但一到“怎么关”“为什么不生效”“为什么数据脏了”就说不清。其实只要抓住几个属性就行。一级缓存是 SqlSession 级别的本身默认开启控制它的核心属性是localCacheScope默认值是SESSION。也就是说同一个 SqlSession 里执行相同的查询第二次会直接命中缓存不走数据库。很多人会问那我循环里查同一条数据怎么每次都走了数据库原因很简单——整合 Spring 之后每次 Mapper 方法调用可能都拿到了全新的 SqlSession一级缓存的生命周期只存在于一次方法调用里所以等于没缓存。只有当方法上加了事务整个事务共用同一个 SqlSession一级缓存才能真正生效。二级缓存是 namespace 级别的总开关是cacheEnabled默认 true。但光开启这个还不够你必须在对应 Mapper 的 XML 里显式加cache/标签二级缓存才真正启用。控制单条 SQL 走不走二级缓存的属性是useCache默认 true加在select上控制 SQL 执行后要不要清空缓存的属性是flushCache默认情况下select的flushCachefalseinsert/update/delete的flushCachetrue。有个真实踩坑案例同事在分布式环境开了二级缓存结果一个服务更新数据后另一个服务的缓存还是旧值。原因很简单MyBatis 的二级缓存是本地内存缓存多个应用实例各存各的没有跨进程同步机制。数据一变其他实例的缓存不会自动失效只能等过期。所以我的结论是分布式环境下老老实实把cacheEnabled关掉缓存交给 Redis 这些中间件管理。单体应用里二级缓存对低频修改、高频查询的数据有一定帮助但也要非常小心脏读。3.2 分页插件为什么能改 SQL拦截器与 Executor 的关系热词里 “mybatis 的分页插件的用法 springboot” 和 “mybatis 的分页插件的用法 java” 常年上榜说明这确实是高频需求。先说最简单的用法Spring Boot 项目引入pagehelper-spring-boot-starter后在查询前调一行代码PageHelper.startPage(pageNum, pageSize); ListUser list userMapper.selectAll(); PageInfoUser pageInfo new PageInfo(list);调用后分页插件会拦截接下来的第一条查询自动改写 SQL 加上 limit。注意PageHelper.startPage只对下一条 SQL 生效用完即止。很多人踩坑是因为在循环里调用或者中间穿插了其他查询导致分页作用到了错误的 SQL 上。再往深处看分页插件之所以能改 SQL是因为 MyBatis 提供了拦截器机制本质是通过Intercepts注解拦截Executor的 query 方法Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class PageInterceptor implements Interceptor { // ... }Executor是 MyBatis 执行 SQL 的核心组件MappedStatement里保存了这条 SQL 的所有元信息包括我们前面说的statementType、resultMap、timeout这些属性。分页插件拦截到查询后拿到原始 SQL通过方言类解析并重写最终生成 count 查询和带分页条件的查询。这里有个面试高频考点为什么框架层面还提供了一个RowBounds做内存分页却没人用它做真正的分页因为RowBounds是在 SQL 执行完之后在内存里做subList截取数据量一大就会产生严重的性能问题属于“假分页”。分页插件是改 SQL属于“真分页”。做二次开发的同学注意自己写拦截器时一定要分清哪些参数该在Executor层面拦截哪些该在StatementHandler层面拦截。分页、读写分离、SQL 日志这类横切逻辑放在Executor层比较好改 SQL 语句本身一般要拦截StatementHandler的prepare方法。如果选错拦截点轻则拿不到参数重则改写的 SQL 失效。3.3 配置打印 SQL 与 update 执行慢的排查“mybatis 配置打印”“mybatis log”这类热词说明很多人第一步就是想看到 SQL。前面说过在 mybatis-config 里设置settings setting namelogImpl valueSLF4J/ setting namelogPrefix valuemybatis./ /settings在application.yml里是mybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl然后对应 Mapper 包的日志级别设为 debug就能看到带参数值的 SQL。这里我有个提升效率的小技巧日志里显示的是Preparing: select * from user where id ?和Parameters: 1(String)这种格式看起来没问题但真正要排查慢 SQL 时建议把数据库端的慢查询日志也打开两边对照。有时候 MyBatis 打印的 SQL 很快但数据库实际执行慢那就是索引、锁、数据量的问题如果 MyBatis 打印的 SQL 本身就慢那就要看是不是N1查询、循环查库、或者fetchSize设置不合理。“mybatis update 执行慢”这个问题我也被问过很多次。典型场景是Update注解写的更新语句单条执行很快批量更新就慢得离谱。这里要区分两种情况。一种是一条 update 带多个条件走的是数据库索引和锁竞争另一种是应用侧循环调用 update每一条都走一次网络往返这种情况下真正该用的是 MyBatis 的批量执行器或者 JDBC 的rewriteBatchedStatementstrue。MySQL 批量写入优化参数很多项目没开导致批量插入和更新性能差一大截。配置方式是在 JDBC 连接串上加rewriteBatchedStatementstrue简单有效。4. 从源码角度看“核心属性”到底存到了哪只看配置不看源码很多问题只能停留在“经验”层面。所以这一节简单打开源码看看我们前面说的核心属性在 MyBatis 内部究竟存在哪里解析链路长什么样。这也是热词 “mybatis 源码”、“mybatis 源码深度复盘” 背后大家真正想弄明白的事。4.1 Configuration 是 MyBatis 全局属性的“总仓库”MyBatis 启动时XMLConfigBuilder会解析mybatis-config.xml把每个标签的值 setting 到Configuration对象上。Configuration这个类几乎是所有全局属性的“总仓库”。你可以去源码里翻一下mapUnderscoreToCamelCase、cacheEnabled、lazyLoadingEnabled、localCacheScope、jdbcTypeForNull、defaultStatementTimeout、logImpl这些设置最后都变成了Configuration类里的字段和 getter/setter。理解这一点对排错极有帮助。比如你在启动时看到某个属性没生效可以先确认是不是写错了标签路径再确认是不是被 Spring Boot 的application.yml覆盖。因为MybatisProperties本质上就是把配置文件里的值转成Configuration属性如果你两边都配了后加载的一方会覆盖先加载的一方。Configuration里还有一个从早年版本就存在的接口InterceptorChain所有拦截器都会注册到这个 chain 里。分页插件、自定义拦截器这些组件本质就是在给Configuration这个总仓库“加钩子”。你通过代码方式配置插件比如Configuration configuration new Configuration(); PageInterceptor pageInterceptor new PageInterceptor(); configuration.addInterceptor(pageInterceptor);效果和在 XML 里plugins标签注册是一样的源码入口都在Configuration上。4.2 XML 解析阶段核心属性如何变成 MappedStatement 与 ResultMapMapper XML 文件由XMLMapperBuilder解析每个select、insert、update、delete最终都会构建成一个MappedStatement对象。这个对象里有我们前面说的statementType、timeout、fetchSize、resultMap、parameterType等属性的映射字段。如果你在源码里打一个断点执行到MappedStatement构建完成的瞬间会看到这些属性整整齐齐躺在对象里。MappedStatement在 SQL 执行中几乎全程参与。Executor拿着它去获取 SQL缓存用它做 key 的组成部分分页插件改写 SQL 也要从它里面取出原始 SQL。所以面试官问“一条 SQL 在 MyBatis 中的执行链路”你只要把这条链路讲清楚MappedStatement 装载配置信息SqlSource 负责解析 SQLBoundSql 封装最终 SQL 和参数Executor 负责执行和缓存StatementHandler 负责数据库交互ResultSetHandler 负责结果映射。这里面几乎每一步都在使用我们前面提到的核心属性。ResultMap由ResultMapResolver在 XML 解析阶段构建它会把resultMap里定义的id、result、association、collection全部解析成ResultMapping对象。结果映射出问题时可以回源头看ResultMap的构建逻辑列名到属性的映射最终走的是ResultMapping里的column和property。如果不清楚底层对应关系调试起来就会一头雾水。4.3 一个典型问题的源码排查思路以“查询结果时间字段丢失”为例演示一下源码排查思路。第一步确认 Java 属性类型和数据库字段类型。如果数据库是DATEJava 是LocalDate走默认LocalDateTypeHandler通常没问题。但如果是TIMESTAMP映射到LocalDate或String就很容易出偏差。第二步在ResultSetHandler.applyAutomaticMappings或getPropertyMappingValue处下断点看目标列有没有取到值以及是通过哪个 TypeHandler 处理的。第三如果这里取到的值为 null就要继续往前看ResultSet中该列本身是否为 null这就需要回到 SQL 本身。我带新人时经常说深度学习 MyBatis不要一开始就扎进代理模式、插件机制这些高阶概念。先把Configuration、MappedStatement、ResultMap这条主干摸清楚把核心属性对应的源码位置找到之后就很容易触类旁通。框架这东西一旦把“配置”和“代码”对应上就不再是玄学。5. 面试高频与实战避坑速查表最后把常见问题和排查清单整理一下。这些东西笔试不一定考但工作中遇到一次就能省半天时间。5.1 八种常见属性相关故障与解决对照表问题现象根本原因解决方案下划线字段映射不到 Java 属性mapUnderscoreToCamelCase未开启开启该 setting或使用 resultMap 显式映射插入 null 报“无效的列类型”jdbcTypeForNullOTHER导致 Oracle 驱动不识别全局设为 NULL或 SQL 中显式指定jdbcType插入后主键拿不到缺少useGeneratedKeys或keyProperty写错加参数批量时注意属性路径Oracle 时间字段查询精度不对DATE/TIMESTAMP 与 Java 类型不匹配自定义 TypeHandler 或在 SQL 中转换二级缓存数据脏读多实例部署下本地缓存无法同步关闭cacheEnabled改用 Redis分页插件作用到错误 SQLPageHelper.startPage后执行了其他查询确保 startPage 后紧跟着目标查询SQL 打印不出来logImpl未配置或 Mapper 包日志级别不是 debug设置logImplSLF4J并将 mapper 包级别调整update 批量执行慢循环单条 update网络往返多批量执行器或 JDBC 参数rewriteBatchedStatementstrue5.2 面试聊 MyBatis 属性时的加分表达面试时聊到 MyBatis 核心属性不建议像背课文一样把 settings 全部背一遍。更有效的表达方式是“由点带面”比如从一个真实问题切入说“我在项目里遇到过缓存脏读排查后发现是二级缓存在多实例下不一致所以全局关掉了cacheEnabled同时把热点数据交给了 Redis”这比干巴巴地背属性名强得多。再说两个面试官常追问的细节。第一个是“一级缓存和二级缓存的生效范围”回答时要区分 SqlSession 级别和 namespace 级别同时点出整合 Spring 后一级缓存通常伴随事务生命周期存在因为你用了 SqlSessionTemplate它会动态代理 SqlSession。第二个是“分页插件原理”不要只答“拦截器改 SQL”能说出Executor和StatementHandler的区别、RowBounds是内存分页、MappedStatement承载 SQL 元信息面试官基本就能确认你对 MyBatis 的理解深度不是背出来的。如果还要再拔高一点可以提一句“核心属性背后全是 Configuration 和 MappedStatement 的字段”这句话基本就能把源码理解亮出来。再随手举一个例子defaultExecutorType改成BATCH后SqlSessionTemplate 代理时行为会变化批量插入场景下如果不调用flushStatements数据可能迟迟不落库。这种细节才是“由浅入深”和“停留在会用”的分水岭。最后再分享一个小技巧。排查 MyBatis 问题时我习惯先在本地写一个最小复现 Demo一条 Mapper、一个 XML、一个测试类把问题场景缩到最小。因为 MyBatis 的很多属性问题在复杂项目里会被 Spring 事务、连接池、缓存中间件层层掩盖最小复现能把变量降到最低。像缓存不生效、主键不回填、时间映射错乱这些问题一旦在最小 Demo 里稳定复现再对照源码看属性流向基本都能在半小时内定位。很多时候慢不是框架慢是你对核心属性的落点不够熟。这份笔记如果能帮你把“配置”和“代码”对应起来那就值了。
返回列表