news 2026/8/12 17:31:05

深入MyBatis源码:从核心原理到插件机制,掌握ORM框架精髓

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入MyBatis源码:从核心原理到插件机制,掌握ORM框架精髓

1. 从“会用”到“懂它”:我们为什么要读MyBatis源码?

如果你是一个Java后端开发者,MyBatis这个名字你肯定不陌生。从早期的iBATIS到现在的MyBatis 3,它几乎成了处理关系型数据库的“标准答案”之一。我们每天都在写Mapper接口、定义XML映射文件、调用SqlSession执行查询。框架用起来很顺手,配置也日渐熟悉,直到有一天,你遇到了一个奇怪的问题:为什么我写的这个动态SQL,在某些条件下生成的语句不对?为什么我配置的插件(Plugin)没有按照预期的顺序执行?又或者,面试官冷不丁地问你:“MyBatis的一级缓存和二级缓存是怎么工作的?在分布式环境下有什么坑?”

这时候,仅仅停留在“会用”的层面就显得捉襟见肘了。阅读源码,不是为了炫技,而是为了在关键时刻能“破局”。它能帮你:

  1. 精准排错:当遇到诡异的问题时,不再依赖于盲目的Google和Stack Overflow,而是能直接定位到框架内部的执行链路,找到问题的根源。
  2. 深度定制:理解扩展点(如插件、类型处理器、对象工厂)的设计,让你能游刃有余地编写符合自己业务需求的定制化组件。
  3. 优化性能:明白缓存机制、执行器(Executor)的工作流程,有助于你在编写SQL和设计数据访问层时做出更优的决策,避免性能陷阱。
  4. 应对面试:对核心机制的理解,是区分普通开发者和资深开发者的重要标尺。

很多人对源码望而却步,觉得它庞大、复杂。但MyBatis的源码结构清晰,核心流程相对集中,是一个非常好的“源码入门”选择。它不像Spring那样拥有庞大的生态和复杂的抽象层次,MyBatis的目标很纯粹:简化JDBC操作,将Java对象和数据库记录进行灵活映射。接下来,我们就抛开那些枯燥的API文档,直接深入到代码内部,看看这个我们每天都在用的工具,到底是如何运转起来的。

2. 核心架构总览:一张图看懂MyBatis的“五脏六腑”

在深入细节之前,我们需要先建立一个宏观的认知。MyBatis的核心架构可以概括为几个关键组件,它们像精密的齿轮一样协同工作。你可以把一次数据库操作想象成一次“太空发射”:

  • 配置系统(Configuration):这是发射控制中心。它加载并解析mybatis-config.xml全局配置文件以及所有的Mapper.xml文件,将里面的所有信息(数据源、事务管理器、类型别名、插件、映射语句等)统统解析、校验,并最终构建成一个内存中的Configuration对象。这个对象是单例的,是整个MyBatis运行时唯一的核心配置仓库。
  • SqlSession:这是面向用户的“指挥舱”接口。开发者通过SqlSession来执行命令(增删改查)、获取映射器(Mapper)、管理事务。它代表了与数据库的一次会话。但请注意,SqlSession本身只是个门面(Facade),真正的脏活累活都委托给了后面的组件。
  • Executor(执行器):这是真正的“火箭发动机”。SqlSession将命令传递给ExecutorExecutor负责维护一级缓存(Session级别)、管理Statement、通过StatementHandler与JDBC交互、处理二级缓存(如果启用)。MyBatis有三种基本的执行器:SimpleExecutor(每次执行都创建新的Statement)、ReuseExecutor(复用Statement)、BatchExecutor(批处理)。
  • StatementHandler:这是“燃料管路和姿态控制器”。它负责创建java.sql.Statement(或PreparedStatementCallableStatement)对象,对SQL语句进行参数化(ParameterHandler介入),以及将结果集映射为Java对象(ResultSetHandler介入)。它是与JDBC API直接对话的桥梁。
  • ParameterHandler & ResultSetHandler:这是两个关键的“辅助系统”。
    • ParameterHandler:负责将传入的Java参数,按照映射规则,设置到PreparedStatement的占位符(?)中。
    • ResultSetHandler:负责将执行SQL后返回的ResultSet结果集,根据映射文件或注解的配置,转换成List<E>或单个Java对象。
  • MappedStatement:这是存储在Configuration中的“飞行任务手册”。每一个<select|insert|update|delete>标签,都会被解析成一个MappedStatement对象,它包含了这条SQL语句的所有元信息:SQL源(可能是动态SQL解析后的)、参数映射、结果映射、缓存配置、语句类型等。

它们之间的协作流程,简化后是这样的:

  1. 应用启动,SqlSessionFactoryBuilder读取配置文件,构建出包含完整ConfigurationSqlSessionFactory
  2. 每次数据库操作,从SqlSessionFactory中获取一个新的SqlSession
  3. SqlSession根据方法签名(如selectOne)或Mapper接口的方法,找到对应的MappedStatement
  4. SqlSessionMappedStatement和参数交给Executor
  5. Executor先查一级缓存(如果适用),未命中则委托StatementHandler去准备语句。
  6. StatementHandler使用ParameterHandler设置参数,执行JDBC。
  7. 执行完毕后,StatementHandler使用ResultSetHandler处理结果集。
  8. ResultSetHandler将结果返回给ExecutorExecutor可能会放入一级缓存,然后返回给SqlSession,最终返回给调用者。

理解了这个主干流程,我们再去看任何具体模块的源码,都不会迷失方向。

3. 源码入口与初始化:SqlSessionFactory的诞生记

一切的故事都从SqlSessionFactory开始。我们通常在代码里这样写:

String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

这行简单的代码背后,隐藏着复杂的初始化过程。SqlSessionFactoryBuilder.build()方法是我们的第一个源码阅读入口。

3.1 配置文件的解析之旅

SqlSessionFactoryBuilder会创建一个XMLConfigBuilder对象。这个类,顾名思义,就是用来解析XML配置的。它的工作流程是典型的“解析-绑定”模式:

  1. 解析<configuration>根标签:它会按顺序解析其下的所有子标签:

    • <properties>:加载外部属性文件,用于后续的占位符替换(比如${jdbc.url})。
    • <settings>:解析几十个运行时行为设置,如是否开启缓存、是否启用延迟加载、日志实现等。每个设置都有默认值,解析后会覆盖默认值。
    • <typeAliases>:为冗长的Java类名起一个简短的别名,方便在映射文件中使用。
    • <plugins>这是理解MyBatis扩展性的关键。这里配置的拦截器(Interceptor),将会被包装成Plugin对象,并插入到ExecutorStatementHandlerParameterHandlerResultSetHandler这四个核心组件的创建链中。解析时,会通过Interceptor.plugin()方法返回目标对象的代理。这是责任链模式和动态代理的经典应用。
    • <environments>:配置数据源(DataSource)和事务管理器(TransactionFactory)。这是支持多数据源的基础。
    • <mappers>重头戏。这里告诉MyBatis去哪里找SQL映射定义。解析器会根据配置(resource, url, class, package)找到对应的Mapper XML文件或接口类,然后交给XMLMapperBuilderMapperAnnotationBuilder进行下一步解析。
  2. 解析Mapper XML文件:对于每一个Mapper.xml文件,XMLMapperBuilder会:

    • 解析<mapper>命名空间。
    • 解析其中的每一个SQL语句标签(<select>,<insert>等)。对于每个标签,会创建一个MappedStatement对象,其id为“命名空间.标签id”,并注册到ConfigurationmappedStatements(一个Map<String, MappedStatement>)中。
    • 在这个过程中,会处理<cache><cache-ref>定义二级缓存,解析<resultMap>定义复杂的结果映射,解析<sql>片段等。
  3. 处理动态SQL:在解析SQL语句时,如果遇到<if>,<choose>,<foreach>等标签,MyBatis并不会在这里就生成最终的SQL字符串。它会把整个标签树解析成一个SqlSource对象。SqlSource是一个接口,主要有两种实现:

    • DynamicSqlSource:对应包含动态标签(OGNL表达式)的SQL。它内部保存了解析后的SQL节点树。
    • RawSqlSource:对应静态的、不含动态标签的SQL。它会在初始化阶段就完成#{}占位符的解析,并预编译成StaticSqlSource,性能更高。

一个重要的实操心得:很多人疑惑#{}${}的区别在源码层面如何体现。简单说,#{}SqlSource被解析时,会被替换成?占位符,然后由ParameterHandlerPreparedStatement.setXXX()来安全设置参数,防止SQL注入。而${}则是在SQL解析阶段就被直接替换成字符串拼接进SQL语句中。所以,绝对不要在用户输入可控的地方使用${}

3.2Configuration对象的最终成型

当所有配置文件解析完毕,一个包含了全局所有配置信息、所有Mapper语句定义的Configuration对象就构建完成了。SqlSessionFactoryBuilder会用这个Configuration对象实例化一个DefaultSqlSessionFactory(这是SqlSessionFactory的默认实现),并返回给我们。

至此,MyBatis的“静态”初始化工作全部完成。接下来,就是“动态”的运行时了。

4. 一次SQL执行的完整生命周期剖析

假设我们调用sqlSession.selectOne("com.example.BlogMapper.selectBlog", 1),让我们跟随代码,看看这个请求是如何走完一生的。

4.1SqlSessionExecutor的交接

DefaultSqlSession.selectOne()方法内部,实际上会调用selectList(),并取返回列表的第一个元素。在selectList()中,核心代码如下:

public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) { try { // 1. 根据statement id,从Configuration中获取对应的MappedStatement MappedStatement ms = configuration.getMappedStatement(statement); // 2. 调用Executor执行查询 return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); } catch (Exception e) { throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } }

关键点在于第2步:executor.query()。这个executor是在创建SqlSession时,由Configuration里的ExecutorType和设置决定的。它可能被我们配置的插件层层代理。

4.2Executor:缓存与调度中心

我们以默认的SimpleExecutor为例(忽略缓存和插件代理的细节,先看主干):

  1. 创建StatementHandlerExecutor会先根据MappedStatement中的信息,创建一个StatementHandler。这里用到了策略模式:根据语句类型(STATEMENT, PREPARED, CALLABLE)创建不同的处理器(SimpleStatementHandler,PreparedStatementHandler,CallableStatementHandler)。
  2. 实例化StatementExecutor调用StatementHandler.prepare()方法,该方法内部会调用Connection.prepareStatement()来创建JDBC的PreparedStatement对象。
  3. 参数化Executor调用StatementHandler.parameterize()方法。这个方法会调用ParameterHandler.setParameters(),将我们传入的Java参数,按照MappedStatement中定义的参数映射(ParameterMapping),一个个地设置到PreparedStatement的占位符上。
  4. 执行与结果映射Executor调用StatementHandler.query()。这个方法会执行PreparedStatement.execute(),然后调用ResultSetHandler.handleResultSets()来处理返回的ResultSet

4.3StatementHandler及其左膀右臂

StatementHandler是承上启下的关键。在创建StatementHandler时(通常是通过Configuration.newStatementHandler()),MyBatis会做一件至关重要的事情:插件注入

public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql); // 应用所有插件(Plugin.wrap),返回一个代理对象 statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }

interceptorChain.pluginAll()会遍历所有已注册的拦截器(Interceptor),调用其plugin()方法。通常,拦截器会使用JDK动态代理或CGLIB,对目标对象(这里是StatementHandler)进行包装。这就是为什么我们自定义的插件能够拦截prepareparameterizequery等方法。

ParameterHandlerResultSetHandler的创建过程也类似,也会被插件链包装。因此,一次SQL执行可能会经过多个插件的拦截处理。

4.4ResultSetHandler:从结果集到Java对象的魔法

这是ORM(对象关系映射)最核心的一步。DefaultResultSetHandler.handleResultSets()方法逻辑非常复杂,但主干清晰:

  1. 获取MappedStatement中定义的ResultMap(结果映射)。
  2. 遍历ResultSet
  3. 根据ResultMap的配置,创建目标Java对象(通过对象工厂ObjectFactory)。
  4. 根据映射规则,将结果集中的列值,通过类型处理器TypeHandler,设置到Java对象的对应属性中。这个过程会处理简单属性、复杂关联(一对一<association>)、集合关联(一对多<collection>),以及嵌套查询(select属性)带来的N+1问题。
  5. 如果启用了延迟加载(懒加载),对于关联属性,MyBatis会创建代理对象(通常是Javassist或CGLIB代理),只有在真正访问该属性时,才会触发额外的查询。

一个重要的避坑经验:在处理复杂关联映射时,务必理解“嵌套结果”(resultMap内联定义)和“嵌套查询”(使用select属性)的区别。嵌套结果通过单表(或连接查询)一次性查出所有数据,在内存中组装对象,性能通常更好,但SQL可能复杂。嵌套查询写法简单,但容易引发“N+1查询问题”(主查询返回N条记录,每条记录再发起一次关联查询)。务必根据数据量和业务场景谨慎选择。

4.5 回到Executor:缓存处理

如果是查询操作,在Executor.query()方法返回前,它会将结果放入一级缓存LocalCache),其作用域是同一个SqlSession。这意味着,在同一个会话中,完全相同的查询(相同的MappedStatement ID、相同的参数、相同的分页条件等)会直接返回缓存结果,不会再次访问数据库。但要注意,任何insertupdatedelete操作都会清空当前SqlSession的一级缓存,这是为了保证数据一致性。

如果配置了二级缓存<cache/>标签),Executor本身会被CachingExecutor装饰。CachingExecutor会在执行查询前先去二级缓存(一个PerpetualCache实例,通常与Mapper命名空间绑定)查找,命中则返回,未命中则委托给底层Executor(如SimpleExecutor)去数据库查询,并将结果存入二级缓存。二级缓存是跨SqlSession的,多个会话可以共享,因此它的数据一致性需要更小心地维护,通常需要实现序列化接口,并注意事务提交后才更新缓存等细节。

至此,一次简单的selectOne调用,就完成了它在MyBatis内部世界的奇幻漂流。

5. 动态SQL的生成原理:从标签到最终SQL语句

我们经常在XML里写这样的动态SQL:

<select id="findActiveBlogWithTitleLike" resultType="Blog"> SELECT * FROM BLOG WHERE state = ‘ACTIVE’ <if test="title != null"> AND title like #{title} </if> </select>

MyBatis是如何把这段XML变成可执行的SQL字符串的呢?关键在于SqlSourceSqlNode

在初始化解析XML时,包含动态标签的SQL块会被解析成一个MixedSqlNode对象,它包含了一系列SqlNode子节点(如IfSqlNode,TextSqlNode,ForEachSqlNode等)。这个MixedSqlNode被包装在DynamicSqlSource里。

当需要执行SQL时(即调用StatementHandler.prepare()之前),DynamicSqlSource会被调用其getBoundSql()方法:

  1. 创建DynamicContext:这是一个上下文对象,持有参数对象和一个StringJoiner(用于拼接SQL)。
  2. 应用SqlNode:遍历MixedSqlNode中的每一个SqlNode,调用其apply()方法。IfSqlNode会使用OGNL引擎评估test表达式,决定是否拼接其内部的SQL片段;ForEachSqlNode会遍历集合,生成(item1, item2, ...)这样的片段,并处理参数映射。
  3. 生成原始SQL字符串:经过所有SqlNode的处理后,DynamicContext中就得到了一条完整的、但可能还包含#{}占位符的SQL字符串。
  4. 创建BoundSql:将上一步的SQL字符串,以及解析出的参数映射关系(List<ParameterMapping>),封装成一个BoundSql对象。BoundSql是最终提供给StatementHandlerParameterHandler使用的对象,它包含了要执行的SQL和对应的参数信息。

一个调试技巧:在开发中,我们经常想看MyBatis最终执行的SQL是什么。除了开启日志(配置logImplSTDOUT_LOGGING或集成Logback等),还可以通过编写一个拦截StatementHandler.prepare方法的插件,在方法执行后,从BoundSql中获取getSql()方法返回的SQL字符串(此时#{}已被替换成?),并结合参数值,手动拼接出完整的、可直接在数据库客户端执行的SQL,这对于复杂动态SQL的调试非常有帮助。

6. 插件(Plugin)机制深度解析:如何优雅地“插手”核心流程

插件是MyBatis框架留给用户的“后门”,功能极其强大。通过实现Interceptor接口,我们可以拦截四大核心组件的方法调用。

6.1 插件的工作原理:动态代理与责任链

  1. 声明与配置:在mybatis-config.xml中配置<plugin interceptor="com.example.MyPlugin">
  2. 初始化包装:在Configuration初始化组件(newExecutor,newStatementHandler,newParameterHandler,newResultSetHandler)时,会调用InterceptorChain.pluginAll()
    public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; }
  3. 创建代理:通常,我们会在自定义拦截器的plugin()方法中调用Plugin.wrap(target, this)Plugin类实现了InvocationHandler接口,它内部维护了一个Interceptor实例和一个Map<Class<?>, Set<Method>>(表示该拦截器声明要拦截的接口和方法)。wrap方法会判断目标对象是否实现了拦截器注解@Intercepts所声明的接口,如果是,就为其创建一个JDK动态代理。
  4. 方法拦截:当代理对象的方法被调用时,会触发Plugin.invoke()。它会检查当前调用的方法是否在拦截范围内。如果是,则调用拦截器的intercept()方法,并将一个Invocation对象(包含了目标对象、方法、参数)传递进去;如果不是,则直接调用目标对象的原方法。

6.2 编写一个实用的分页插件示例

虽然已有PageHelper这样优秀的分页插件,但理解其原理很有必要。一个最简单的分页插件思路是拦截Executor的查询方法,在原始SQL上拼接LIMIT ?, ?(以MySQL为例)。

@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class SimplePaginationInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); RowBounds rowBounds = (RowBounds) args[2]; // 如果使用了默认的RowBounds(不翻页),则直接放行 if (rowBounds == RowBounds.DEFAULT) { return invocation.proceed(); } // 获取原始的MappedStatement和参数 MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 获取原始的BoundSql BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 拼接分页SQL String paginationSql = originalSql + " LIMIT " + rowBounds.getOffset() + ", " + rowBounds.getLimit(); // 创建一个新的BoundSql(注意,这里需要反射修改sql属性,因为BoundSql的sql字段是final的,实际插件中会使用MetaObject工具类) // ... 省略反射修改代码 ... // 创建一个新的MappedStatement(通常是原语句的一个副本,但使用新的BoundSql) // ... 省略创建新MappedStatement的代码 ... // 将新的MappedStatement设置回参数中,然后继续执行 args[0] = newMappedStatement; return invocation.proceed(); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }

这个示例简化了很多细节(如数据库方言、总数查询、线程安全等),但它清晰地展示了插件的核心逻辑:在调用链的某个环节,修改传入的参数(如SQL),从而改变最终的执行行为

重要注意事项:插件虽然强大,但要慎用。多个插件会形成代理链,执行顺序与配置顺序有关。过度使用或编写不当的插件会严重影响框架性能和稳定性。务必确保插件逻辑高效、无副作用,并做好充分的测试。

7. 结合热点问题与实战思考

回顾我们开头提到的那些热搜词和常见问题,现在可以从源码层面得到更深刻的理解:

  • #{}${}的区别:根源在于SqlSource解析阶段,#{}被处理为占位符,由ParameterHandler安全设参;${}则是简单的字符串替换。永远优先使用#{}
  • 一级/二级缓存:一级缓存是SqlSession级别的HashMap,默认开启。二级缓存需手动配置,是Mapper命名空间级别的,底层也是PerpetualCache,但可以通过<cache>标签配置淘汰策略、序列化器等。分布式环境下,一级缓存无影响,二级缓存需要解决数据一致性问题,通常建议使用Redis等集中式缓存替代。
  • 插件执行顺序:取决于在mybatis-config.xml中的配置顺序,因为InterceptorChain是按顺序包装的,执行时也是按包装的逆序进行intercept调用(类似栈)。
  • 动态SQL中的<, >转义:在XML中,<>是特殊字符,需要转义为&lt;&gt;,或者将SQL片段包裹在<![CDATA[ ... ]]>中。MyBatis在解析XML时处理的是转义后的字符或CDATA区的内容,生成SQL字符串时不会再有这个问题。
  • MyBatis PlusremoveBatchByIdsremoveByIds区别:虽然这是MP的功能,但原理相通。removeByIds接受的通常是一个集合参数,生成DELETE FROM table WHERE id IN (?, ?, ...)。而removeBatchByIds从名字上看更倾向于进行批量删除操作,可能通过ExecutorType.BATCH执行器来执行,或者在内部对超长ID列表进行分批处理,避免IN语句过长。具体需要查看MP的源码实现。

阅读源码不是一蹴而就的,最好的方式是带着问题去读。下次当你再遇到MyBatis的疑难杂症时,试着打开IDE,沿着本文梳理的主干流程,设置几个断点,一步步跟踪下去。你会发现,源码之下,了无秘密。这份通过自己探索得来的理解,远比背诵面试题要牢固和深刻得多。

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

从零构建体育赛事数据分析系统:以乒乓球比赛为例

这次我们来看一个关于乒乓球比赛结果分析的技术项目。虽然标题看起来像是体育新闻&#xff0c;但背后涉及的是比赛数据分析、运动员状态评估和赛事预测的技术实现。这个项目通过分析孙颖莎、王楚钦等顶尖运动员在“全锦赛”等赛事中的表现数据&#xff0c;旨在构建一个能够客观…

作者头像 李华
网站建设 2026/8/12 17:23:21

研0LBM学习|第7周

这周接着推进&#xff0c;学到1.3.3平衡分布函数和1.3.4 Boltzmann方程啦&#xff08;ω&#xff09; 几个有收获的点&#xff1a;通过练习1.8验证了Maxwell-Boltzmann分布确实能恢复密度、速度和内能这些宏观量&#x1f4a1; 关键是高斯积分要算对&#xff0c;以及利用球对称…

作者头像 李华
网站建设 2026/8/12 17:22:47

基础模型Bid2X如何革新广告竞价建模

1. 项目概述&#xff1a;当基础模型遇见广告竞价 在数字广告生态中&#xff0c;竞价环境建模一直是个"黑箱难题"。传统方法要么依赖简化假设导致失真&#xff0c;要么陷入特征工程的泥潭。Bid2X的出现&#xff0c;标志着基础模型技术开始系统性地重塑这个领域。这个来…

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

SV学习记录(三)

目录 3.1 过程语句 3.2 task、function以及void函数 3.3 task、function 3.4 子程序参数 C-style 参数方向 高级参数类型 参数缺省值 用名字传递参数 常见错误 3.5 子程序的返回 返回语句 从函数中返回一个数组 3.6 局部数据存储 自动存储 变量初始化 3.7 时间…

作者头像 李华
网站建设 2026/8/12 17:19:12

八、Vue组件通信详解

一、vue2组件通信汇总表通信方式适用场景/范围通信方向核心原理与特点Props / $ emit父子组件父传子 / 子传父最核心的单向数据流。父传子用 props&#xff0c;子传父通过 this.$emit 触发自定义事件。v-model父子组件双向绑定语法糖。本质是 props 接收 value&#xff0c;子组…

作者头像 李华
网站建设 2026/8/12 17:18:26

Windows注册表清理指南:彻底删除顽固快捷方式

1. 问题缘起&#xff1a;那些删不掉的“幽灵”快捷方式 你有没有遇到过这种情况&#xff1f;在Windows的文件资源管理器左侧导航栏&#xff0c;或者右键菜单的“发送到”列表里&#xff0c;总是挂着几个早已卸载软件的快捷方式。它们像顽固的“幽灵”一样&#xff0c;既占地方又…

作者头像 李华