news 2026/9/30 4:06:45

MyBatis启动流程与拦截器原理:从XML解析到代理编织的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis启动流程与拦截器原理:从XML解析到代理编织的完整链路

总有人在看了几天 MyBatis 源码之后问我:启动流程到底该从哪儿看起?我的建议一直很明确——先把拦截器这条线拎出来。拦截器在 MyBatis 里的位置非常特殊:它既不参与 SQL 解析,也不负责连接管理,但它的注册、排序和代理编织恰恰把XMLConfigBuilder、Configuration、以及四大核心组件的创建过程串成了一条完整的链路。你顺着拦截器走一遍,相当于把 MyBatis 启动流程的骨架摸了个遍。

这篇文章就结合拦截器来拆解 MyBatis 启动流程。我会从SqlSessionFactoryBuilder开始,讲到XMLConfigBuilder如何解析<plugins>配置,再讲Configuration里InterceptorChain如何收集拦截器,最后落到Executor、StatementHandler等组件的代理编织时机。适合正在啃 MyBatis 源码、准备面试题、或者想搞懂自定义拦截器为什么"有时候生效有时候不生效"的读者。

1. 全景先行:启动流程里拦截器到底参与了哪几步

先不急着看代码,我用一句话把 MyBatis 启动流程的核心链路讲清楚:MyBatis 启动的本质,是把 XML 配置、注解信息、Mapper 接口绑定等内容全部转译成一个Configuration对象,再基于这个对象构建出SqlSessionFactory。至于拦截器,它在这个转译过程里只做两件事:

  • 第一,被XMLConfigBuilder解析出来,注册进Configuration.interceptorChain;
  • 第二,在后续创建Executor、StatementHandler、ParameterHandler、ResultSetHandler时,通过interceptorChain.pluginAll(target)对目标对象进行代理包装。

也就是说,拦截器真正"干活"是在启动完成之后的执行期,但它的身份和位置是在启动期被定死的。这就像新员工入职,启动流程负责录入系统、分配工位、确定汇报关系,真正开始产出是入职以后的事。

如果你跟着new SqlSessionFactoryBuilder().build(inputStream)这行代码往下走,实际会经过下面几个阶段:

  1. XMLConfigBuilder创建,并初始化XPathParser,准备读取 XML 文件;
  2. parse()方法开始解析<configuration>根节点;
  3. 按顺序解析properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、reflectorFactory、plugins、environments、databaseIdProvider、mappers;
  4. 解析完成后生成Configuration;
  5. SqlSessionFactoryBuilder根据这个Configuration创建DefaultSqlSessionFactory。

拦截器的身影出现在第 3 步的pluginsElement解析里,但它的影响却贯穿第 5 步之后的所有 SQL 执行路径。所以当你把 MyBatis 启动流程和拦截器放在一起看时,视角会非常独特:你既能看到"配置怎么变成对象",又能看到"对象怎么变成代理"。

2. 从 parse() 开始:XMLConfigBuilder 是如何把插件配置转成拦截器对象的

2.1 解析顺序里藏着依赖关系

很多人以为 MyBatis 解析 XML 就是从头到尾一顺溜读完,其实不是。XMLConfigBuilder.parse()里有一个明显的前后依赖顺序:

public Configuration parse() { if (this.parsed) { throw new BuilderException("Each XMLConfigBuilder can only be used once."); } this.parsed = true; parseConfiguration(parser.evalNode("/configuration")); return configuration; }

关键在于parseConfiguration:

private void parseConfiguration(XNode root) { try { propertiesElement(root.evalNode("properties")); Properties settings = settingsAsProperties(root.evalNode("settings")); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode("typeAliases")); pluginElement(root.evalNode("plugins")); objectFactoryElement(root.evalNode("objectFactory")); objectWrapperFactoryElement(root.evalNode("objectWrapperFactory")); reflectorFactoryElement(root.evalNode("reflectorFactory")); settingsElement(settings); environmentsElement(root.evalNode("environments")); databaseIdProviderElement(root.evalNode("databaseIdProvider")); typeHandlerElement(root.evalNode("typeHandlers")); mapperElement(root.evalNode("mappers")); } catch (Exception e) { throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e); } }

你注意看,pluginElement的执行时机是在typeAliases之后、objectFactory之前。这意味着什么?意味着解析插件时,拦截器类名刚刚能被别名机制解析完毕,但objectFactory、objectWrapperFactory这些组件还没有注册。这个顺序不是随便排的——MyBatis 希望拦截器能尽早进入Configuration,这样后面创建任何组件时,拦截器链都已经准备就绪。

2.2 pluginElement 内部到底做了什么

pluginElement的逻辑相当简单,核心只有三步:

private void pluginElement(XNode parent) throws Exception { if (parent != null) { for (XNode child : parent.getChildren()) { String interceptor = child.getStringAttribute("interceptor"); Properties properties = child.getChildrenAsProperties(); Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).getDeclaredConstructor().newInstance(); interceptorInstance.setProperties(properties); configuration.addInterceptor(interceptorInstance); } } }

逐行拆开看:

  • 先通过resolveClass把配置里的interceptor属性值转换成Class<?>对象;
  • 再用无参构造器newInstance()创建拦截器实例。所以你的拦截器类必须有无参构造器,否则启动直接抛异常;
  • 接着调用setProperties(properties),把<property>子标签注入进去;
  • 最后调用configuration.addInterceptor(interceptorInstance),把拦截器塞进Configuration。

这一步里最容易忽略的是child.getChildrenAsProperties()。它会把plugin标签下的所有property子标签解析成一个Properties对象。比如:

<plugins> <plugin interceptor="com.example.MyInterceptor"> <property name="prefix" value="biz_"/> </plugin> </plugins>

这样在setProperties里就能拿到prefix=biz_。我见过很多人写拦截器时把参数硬编码在代码里,其实直接在 XML 里配property更灵活,环境切换时不用重新编译。

2.3 一个关键认知:解析拦截器不等于创建代理

很多刚接触源码的人会在这一步产生误解,以为addInterceptor之后,Executor就已经被包装好了。其实还没有。pluginElement阶段只是把拦截器对象放进一个"待命列表"。真正对Executor、StatementHandler等组件进行代理包装,要等到DefaultSqlSessionFactory打开会话、组件被创建的那一瞬间。

这就引出一个很实用的排查思路:如果你想确认某个拦截器是否被 MyBatis 正确加载,第一步应该去看Configuration.interceptorChain的interceptors列表大小,而不是直接打断点在intercept方法上——因为如果配置阶段就出了问题,intercept根本不会被触达。

3. Configuration 中的工位图:InterceptorChain 与 addInterceptor 的机制

3.1 Configuration 持有的核心字段

Configuration是启动流程的"中央仓库",几乎所有解析产物都会汇聚到这里。其中与拦截器直接相关的是:

protected final InterceptorChain interceptorChain = new InterceptorChain();

这个InterceptorChain内部维护了一个List<Interceptor>:

public class InterceptorChain { private final List<Interceptor> interceptors = new ArrayList<>(); public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; } public void addInterceptor(Interceptor interceptor) { interceptors.add(interceptor); } public List<Interceptor> getInterceptors() { return Collections.unmodifiableList(interceptors); } }

如果你是跟着启动流程走,这里就是拦截器"入册"的最终位置。addInterceptor只是把拦截器追加到 List 尾部,没有任何排序策略、没有优先级概念。

3.2 两类注册路径:XML 配置 vs 编程式配置

除了通过XMLConfigBuilder.pluginElement自动注册,还有很多项目在 Spring 环境下通过SqlSessionFactoryBean手动追加插件:

@Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(new Interceptor[]{new MyInterceptor()}); return factoryBean.getObject(); }

无论走哪条路,最终都会落到configuration.addInterceptor(interceptor)。这也是为什么我一直强调:如果你在做框架集成,与其纠结 MyBatis 配置文件的加载顺序,不如直接去 SqlSessionFactoryBean 的实现里看它给 Configuration 挂了哪些钩子。

3.3 顺序问题:谁先被加入,谁先执行代理

InterceptorChain.pluginAll的循环逻辑决定了先注册的拦截器在嵌套代理的外层。这句话有点绕,我换种方式解释。

假设有两个拦截器 A 和 B,注册顺序是 A 先、B 后。那么pluginAll执行时:

target = A.plugin(target); // target 变成 A 的代理 target = B.plugin(target); // target 变成 B 的代理,且B包在A外面

最终调用链看起来像B -> A -> 原始对象。也就是说,外层拦截器先拿到执行机会,也先决定是否proceed();内层拦截器只有在外层放行后才有机会执行。

这个东西在启动流程里不容易暴露问题,因为启动流程只负责按顺序把拦截器加进 List。但到了运行期,只要两个拦截器的@Signature有重叠,顺序错误会直接导致结果不符合预期。比如分页拦截器和数据权限拦截器,如果把分页放在最外层,它拿到的BoundSql可能已经被内部拦截器改写过了,分页 SQL 的拼接顺序就会很怪。

3.4 为什么把 InterceptorChain 理解成"待命列表"很重要

因为Configuration.interceptorChain在启动流程里的角色是静态注册表。它不关心目标对象是谁、什么时候创建,只负责保存拦截器实例。等到newExecutor、newStatementHandler这些方法被触发时,才把注册表里的拦截器逐一应用到具体目标上。

这种设计有一个好处:MyBatis 组件的创建路径非常统一,只有有限的几个方法会 new 出核心对象。只要在这几个方法里统一调用interceptorChain.pluginAll(...),就能保证"无论哪个入口创建组件,拦截器都不会漏掉"。

4. 编织时刻:四大核心组件的代理生成与 Plugin.wrap 的匹配逻辑

4.1 启动后第一次触发:Executor 的创建

真正的代理编织发生在运行期第一次访问数据库的时候。以最经典的DefaultSqlSessionFactory.openSession()为例,它会调用:

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); }

里面那句configuration.newExecutor(tx, execType)是启动流程和运行流程的交汇点。Configuration.newExecutor源码大致如下:

public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }

注意这里有个容易忽略的点:如果配置了二级缓存,先包装CachingExecutor,然后再交给拦截器链。这会导致拦截器包在CachingExecutor外层还是内层?答案是:CachingExecutor先被包好,interceptorChain.pluginAll再对它做代理。所以拦截器能看到的是CachingExecutor的行为,而CachingExecutor内部还会调用被包装的SimpleExecutor。

4.2 StatementHandler、ParameterHandler、ResultSetHandler 的编织

Executor创建完成之后,MyBatis 开始执行 SQL,会继续创建StatementHandler。同样是在Configuration里:

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); statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }

ParameterHandler和ResultSetHandler的创建也类似,分别在newParameterHandler和newResultSetHandler中调用了interceptorChain.pluginAll。

所以,MyBatis 被拦截器覆盖的目标对象一共有四个:Executor、StatementHandler、ParameterHandler、ResultSetHandler。你用@Intercepts指定type时,也只能在这四个类型里选,不能拦截别的类。

4.3 Plugin.wrap 的签名匹配细节

Interceptor.plugin(Object target)通常返回Plugin.wrap(target, this)。Plugin.wrap做的事情是:拿到拦截器上的@Intercepts注解,解析出@Signature数组,然后判断当前target的类型是否匹配某个@Signature指定的type。

public static Object wrap(Object target, Interceptor interceptor) { Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor.getClass()); Class<?> type = target.getClass(); Set<Method> methods = signatureMap.get(type); if (methods != null && methods.size() > 0) { return Proxy.newProxyInstance( type.getClassLoader(), new Class<?>[]{type}, new Plugin(target, interceptor, methods)); } return target; }

这里有两个关键点:

  • 如果target的类型不在@Signature里,wrap直接返回原始 target,不生成代理;
  • 如果匹配,则用 JDK 动态代理生成一个实现了 target 接口的代理对象。注意 MyBatis 没有使用 CGLIB,所以Executor、StatementHandler 等都必须声明为接口类型,这也解释了为什么pluginAll的返回值经常需要强转成接口。

对于想深挖的人,Plugin.invoke里还有一个细节:它会根据方法名和参数类型去匹配签名。匹配成功就调用interceptor.intercept(Invocation),否则直接method.invoke(target, args)。这意味着你的@Signature里method和args必须和实际调用的方法完全一致,否则拦截器看着注册了,实际根本不触发。

4.4 一个实际案例:通过拦截器观察启动是否完整

我在实际项目中做过一个"SQL 审计拦截器",只拦截StatementHandler.prepare。结果发现,当某个 Mapper 方法走的是MyBatis二级缓存命中路径时,我的拦截器根本不会触发,因为命中缓存后不会走到StatementHandler创建流程。

这个现象恰恰印证了上文说的编织时机:拦截器是在组件创建时被代理的,如果某个组件压根不创建,拦截器自然无从插手。所以你设计拦截器时,必须想清楚自己到底要拦截哪一层、什么时候才会真正创建那一层。

5. 顺着启动流程补齐两块拼图:缓存配置与 TypeHandler 的初始化

5.1 cacheEnabled 和 CachingExecutor 的关系

如果只盯着拦截器,你可能会漏掉启动流程里另一个关键字段:cacheEnabled。它来自<settings>里的cacheEnabled配置,默认值是 true。

在newExecutor里,正是这个cacheEnabled决定了executor是否被包装为CachingExecutor:

if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor);

从启动流程角度理解:settingsElement配置的解析发生在pluginElement之后,但cacheEnabled的值最终反映到 Configuration 的字段上,只有等到newExecutor才会生效。如果你想判断一个自定义拦截器是否会被CachingExecutor影响,必须清楚这个包装顺序。

5.2 mapper.xml 里的<cache>解析

除了全局cacheEnabled,每个 Mapper 是否开启二级缓存,取决于 Mapper XML 文件里的<cache>标签。这一步在XMLMapperBuilder中被解析,它属于mapperElement阶段的一部分。注意mapperElement在整个启动流程里排在最后,也就是说,只有当拦截器、环境、TypeHandler 都配置好之后,Mapper 才会被加载。

这个顺序带来的实际体验是:你在写 Mapper XML 时,如果namespace写错或者cache-ref引用了不存在的 namespace,启动流程会一路查到这里才报错。很多人在报错日志里看到BuilderException时一脸懵,其实只要清楚 MyBatis 是"先全局配置、后 Mapper 绑定"的顺序,定位起来就很快。

5.3 TypeHandler 注册与拦截器之间的潜在坑

typeHandlerElement会把自定义 TypeHandler 注册进Configuration.typeHandlerRegistry。它属于启动流程中不那么起眼、但非常重要的一环。因为等到ParameterHandler给 SQL 参数赋值、ResultSetHandler从结果集映射字段时,都要通过typeHandlerRegistry.getTypeHandler(...)找到对应的 TypeHandler。

这里和拦截器有一个容易踩的坑:当你自定义 TypeHandler 并希望它也参与结果映射时,启动顺序必须是"TypeHandler 先注册,Mapper 后解析"。因为XMLMapperBuilder解析 Mapper 时,会直接根据 Java 类型去 TypeHandlerRegistry 找已经注册的 handler。如果顺序反了,Mapper 解析阶段拿不到对应的 TypeHandler,启动虽然不会报错,但映射结果会不符合预期。

更隐蔽的是,如果你在拦截器里修改了ParameterHandler的参数对象,但你自定义 TypeHandler 没有覆盖该类型的转换逻辑,执行时仍然会走默认 StringTypeHandler 之类的逻辑,改了半天参数,映射出来的值还是不对。这类问题的排查思路通常不是盯着拦截器,而是回头看 TypeHandler 注册有没有生效。

5.4 拦截器调试的日志入口

启动流程里还有一个容易被忽略的设置:<settings>里的logImpl。如果你在排查拦截器问题时发现intercept方法一直没被调用,可以先设置:

<setting name="logImpl" value="STDOUT_LOGGING"/>

然后观察 MyBatis 打印的 SQL 执行日志。日志里能看到Preparing: ...这样的输出,配合打断点就能确认StatementHandler.createCacheKey、prepare等方法的调用时机。我一度以为是拦截器没注册,最后发现是@Signature的method名称写错了,日志里 PREPARING 语句一直在走,但拦截器就是不触发。

6. 自定义 Configuration:想在启动时动脚手,可以从哪里突破

6.1 XMLConfigBuilder 里的 configuration 类型定制

MyBatis 允许你提供一个自定义Configuration子类。XMLConfigBuilder在构造时支持传入已有 Configuration:

XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, null, null, configuration);

如果你不用这种构造方式,MyBatis 会默认创建Configuration实例。如果想在启动流程中启用自己的 Configuration,一般两种做法:

  • 在SqlSessionFactoryBean里设置setConfiguration(configuration);
  • 或在源码集成时手动创建XMLConfigBuilder并传入自定义对象。

自定义 Configuration 的价值在于:你可以覆盖newExecutor、newStatementHandler等方法,在 MyBatis 启动流程的"组件加工厂"层面插入自定义逻辑。比如:

public class CustomConfiguration extends Configuration { @Override public Executor newExecutor(Transaction transaction, ExecutorType executorType) { Executor executor = super.newExecutor(transaction, executorType); return new MyExecutorProxy(executor); } }

这样就不需要靠Interceptor接口来切面,直接在组件创建源头做包装。当然,这种方式侵入性更强,需要你有足够把握,不建议第一版就玩这么花。

6.2 自定义 Configuration 和拦截器的协同

有一种很实用的组合:自定义 Configuration 里重写addInterceptor,可以在拦截器入册时打日志或者做校验。

public class CustomConfiguration extends Configuration { @Override public void addInterceptor(Interceptor interceptor) { System.out.println("interceptor registered: " + interceptor.getClass().getName()); super.addInterceptor(interceptor); } }

这能帮你在启动流程中确认真实注册顺序。尤其是当你的工程里既有 XML 配置又有编程式插件时,通过重写addInterceptor观察调用顺序,比在 XML 里反复试配置要高效得多。

只不过要注意:这种方式依赖DefaultSqlSessionFactory能正确接到你的自定义 Configuration。如果你是 Spring Boot 环境,注意不要同时设置configuration和configLocation,两者同时出现时有的版本会直接忽略其中一个,排查起来相当费劲。

6.3 常见的启动失败原因与排查点位

结合拦截器和启动流程,我总结一下我实际遇到过的启动失败场景:

  • 拦截器类没有无参构造器。因为pluginElement使用newInstance(),一旦构造器带参数,启动直接抛异常;
  • @Signature里的type写成了具体实现类而不是接口。由于Plugin.wrap判断的是接口类型,写成SimpleExecutor.class很可能根本不匹配,因为newExecutor返回等会经过强转,代理实际实现的是Executor接口;
  • setProperties里接收到的 property 值全是字符串。如果你要转 int、boolean 等类型,需要自己做转换,MyBatis 不会帮你做;
  • 多个拦截器嵌套后,target被包成了代理,有些拦截器再用instanceof判断类型会失真。如果你在一个拦截器里判断parameterHandler是不是某个自定义实现类,很可能因为外层代理而判断失败。

这几条里最隐蔽的是第三条和第四条。我见过一个团队在拦截器里读取properties的batchSize属性,直接用Integer.parseInt(properties.getProperty("batchSize"))没问题,但有人偷懒写成String batchSize = properties.getProperty("batchSize"),然后和数字比较,结果怎么都不对。

6.4 我的调试习惯

如果你也想从启动流程的角度去验证拦截器,我建议你在自己的项目里做一次最小化实验:

  1. 写一个最简单的拦截器,只拦截StatementHandler.prepare;
  2. 在拦截器intercept方法里打印invocation.getTarget().getClass();
  3. 在Configuration.newExecutor和Configuration.newStatementHandler两处打断点;
  4. 分别观察interceptorChain.pluginAll(...)前后的对象类型;
  5. 注意查看CachingExecutor被加入时的包装顺序。

跑通这一步,你对"拦截器和启动流程如何串联"的理解会比看十遍源码都深刻。之后遇到任何"拦截器不生效""启动时插件报错""多个插件顺序不对"的问题,都能从这条链路上找到对应环节,而不是靠瞎试。

我个人在实际操作中最受用的一个习惯是:永远先确认启动流程把拦截器注册到了哪个 List,再确认代理包装的顺序,最后才去查 @Signature 是否匹配。按这个顺序排查,基本没有解不开的拦截器问题。

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

学术合规性如何?8款AI论文网站排行榜,毕业冲刺必备!

论文选题无从下手&#xff0c;文献综述抓耳挠腮&#xff0c;写作过程反复修改&#xff1f;格式规范让人头大&#xff0c;查重率屡屡超标&#xff0c;毕业焦虑不断加剧&#xff1f; 别担心&#xff01;AI论文工具的出现&#xff0c;正在重新定义学术写作的效率与质量。本文将基于…

作者头像 李华
网站建设 2026/9/30 4:04:51

CL_ABAP_PARALLEL并行处理实战与调优

1. 为什么批量处理慢&#xff0c;根因不在数据量很多人一遇到大批量数据更新就下意识认为是数据库慢&#xff0c;实际上数据库这一层往往并不是真正的瓶颈。真正拖垮整个任务的是“串行”本身&#xff1a;一条一条地读、一条一条地算、一条一条地写&#xff0c;单挑整个流程&am…

作者头像 李华
网站建设 2026/9/30 4:04:51

TensorFlow实战指南:从安装到部署的深度学习全流程

1. 从零上手 TensorFlow&#xff1a;一个老手的实战拆解TensorFlow 这四个字&#xff0c;但凡接触过深度学习的人都不会陌生。它由 Google Brain 团队推出&#xff0c;2015 年开源&#xff0c;至今已经走过了近十个年头。简单说&#xff0c;它是一个端到端的开源机器学习平台&a…

作者头像 李华
网站建设 2026/9/30 4:04:50

JavaScript深浅拷贝与解构赋值的本质区别及内存原理

1. 前端面试绕不开的“拷贝题”&#xff1a;为什么深浅拷贝和解构赋值总被连着问&#xff1f;前端面试里&#xff0c;只要聊到数据类型、引用关系、对象操作&#xff0c;几乎必然撞上这组“铁三角”&#xff1a;深拷贝、浅拷贝、解构赋值。它不像算法题那样需要推导时间复杂度&…

作者头像 李华
网站建设 2026/9/30 4:04:17

Document对象属性全解析:从标题到运行状态,一篇搞懂DOM核心

如果你写过哪怕一行和页面交互的 JavaScript&#xff0c;就一定碰过document。说白了&#xff0c;Document 对象就是网页文档在 JS 世界里的“总代理”&#xff1a;标题、地址、表单、图片、加载状态&#xff0c;全都挂在这棵树上。但很多人查资料时只记住了document.getElemen…

作者头像 李华
网站建设 2026/9/30 4:04:03

从零手搓AI工程流水线:深度学习底层原理与工程化实践

1. 为什么我要从零手搓一套AI工程流水线第一次看到ai-engineering-from-scratch这个项目名的时候&#xff0c;我正被一堆“调包侠”式的教程搞得有点烦。满屏都是import torch、from transformers import ...&#xff0c;跑通一个 demo 只要十分钟&#xff0c;可真要把模型塞进…

作者头像 李华