7.21 Spring、MySQL、MQ、前端面试题总结

目录

简历Java复习

介绍一下Spring自动装配

介绍一下Spring的Bean生命周期

介绍一下MySQL存储引擎

介绍一下MySQL三大日志

介绍一下MySQL中的MVCC

介绍一下RabbitMQ有序消费

介绍一下RabbitMQ可靠性投递

简历项目优化

HashMap改造树形结构

前端常见八股简单学习

[CSS 相关](#CSS 相关)

[flex 布局相关知识点](#flex 布局相关知识点)

垂直居中实现方案

响应式布局实现方式

[margin 坍塌原理与解决办法](#margin 坍塌原理与解决办法)

[CSS 选择器优先级规则](#CSS 选择器优先级规则)

[JS 相关](#JS 相关)

[var、let、const 三者区别](#var、let、const 三者区别)

闭包概念、作用、优缺点

数组常见方法及区分

[this 的绑定规则](#this 的绑定规则)

[call、apply、bind 的区别与使用场景](#call、apply、bind 的区别与使用场景)

简单介绍一下Promise

[async/await 原理与使用](#async/await 原理与使用)

[JS 事件循环机制](#JS 事件循环机制)

[JS 数据类型](#JS 数据类型)

事件冒泡、事件捕获、事件委托

浏览器完整渲染过程

跨域产生原因、常见跨域解决方案

箭头函数特性、与普通函数区别

[Vue 相关](#Vue 相关)

[Vue 组件之间所有通信方式](#Vue 组件之间所有通信方式)

[Vue2 和 Vue3 的核心区别](#Vue2 和 Vue3 的核心区别)

[Vue 生命周期及各阶段作用](#Vue 生命周期及各阶段作用)

[v-if 和 v-show 的区别与使用场景](#v-if 和 v-show 的区别与使用场景)

代码输出笔试题

[各种场景下 this 指向判断输出](#各种场景下 this 指向判断输出)

事件循环代码执行顺序输出题

简历Java复习

介绍一下Spring自动装配

Spring Boot 的自动装配,简单来说就是项目启动时,框架会自动把对应场景需要的配置类和 Bean 加载到 Spring 容器里,不用手动写大量 XML 或者配置类去声明组件,实现了 "开箱即用"。

Spring自动装配的整个流程可以从启动类上的@SpringBootApplication注解切入,这个注解是一个组合注解,本质上由三个核心注解构成:@SpringBootConfiguration@ComponentScan@EnableAutoConfiguration;

第一个@SpringBootConfiguration底层就是@Configuration,作用就是标记启动类本身是一个 Spring Boot 的配置类;

第二个@ComponentScan,作用是组件扫描,默认会扫描启动类所在的包以及它的所有子包,把加了@Controller@Service@Repository这些注解的业务 Bean 自动注册到容器里;

第三个@EnableAutoConfiguration,这个是开启自动装配的核心开关,又包含两个关键部分:@AutoConfigurationPackage@Import(AutoConfigurationImportSelector.class)。 先讲@AutoConfigurationPackage,它通过@Import导入了AutoConfigurationPackages.Registrar这个组件,作用就是把主启动类所在的包,设定为默认的组件扫描根包。这也就是为什么我们平时写的业务代码,默认都要放在启动类同包或者子包下才能被 Spring 识别的原因;而真正实现批量自动导入配置的,是AutoConfigurationImportSelector这个类。它实现了ImportSelector接口,重写了核心的selectImports方法,整个加载流程是这样的:1.项目启动时,会调用getAutoConfigurationEntry方法,来获取所有需要批量导入的组件 2.方法内部通过getCandidateConfigurations,配合 Spring 的工厂加载机制,去扫描项目 classpath 下所有META-INF/spring.factories文件 3.这个文件里以键值对的形式,配置了所有自动配置类的全限定类名,对应的 key 就是org.springframework.boot.autoconfigure.EnableAutoConfiguration 4.拿到所有候选的配置类之后,框架不会一股脑全部加载,会先做一层过滤和排除,最终把符合条件的配置类的全类名返回,注册成 Bean 放到 Spring 容器中;

其中条件装配不一次性把所有配置都加载进来。如果不加判断全量加载,会产生大量无用 Bean,非常占用内存。Spring Boot 就是靠@Conditional系列的条件注解,来实现 "按需装配" 的;常见的比如@ConditionalOnClass------ 类路径下存在指定类时才生效;@ConditionalOnMissingBean------ 容器里没有对应 Bean 时才注册默认的;@ConditionalOnBean------ 容器里存在指定 Bean 时才生效;比如我们引入了spring-boot-starter-web这个场景启动器,类路径下有了DispatcherServlet,对应的 Web 自动配置类才会生效。

这里有个很重要的原则:用户配置优先。如果我们自己手动配置了对应的 Bean,Spring Boot 的默认配置就会自动失效,完全以开发者自定义的为准。

介绍一下Spring的Bean生命周期

生命周期的第一个阶段是容器启动与 Bean 的生成,项目启动 ApplicationContext 之后,第一步会创建并初始化 BeanFactory 工厂,也就是 IOC 容器的核心。接下来会执行所有的 BeanFactory 后置处理器,也就是 BeanFactoryPostProcessor,组件扫描的注解@ComponentScan,其实就是在这个阶段执行的:扫描指定包路径下加了注解的类,把它们解析成一个个 BeanDefinition,也就是 Bean 的定义元数据,里面包含了 Bean 的类名、作用域、依赖关系这些信息,最后把这些 BeanDefinition 统一存到 BeanFactory 里;

第二个阶段是实例化阶段,Spring 会先合并 BeanDefinition,加载对应的 Class 类。在真正创建对象之前,会先执行实例化前的 BeanPostProcessor 前置回调;然后推断这个 Bean 要用哪个构造方法,通过反射调用构造方法,创建出 Bean 的实例对象,这一步就是实例化,这时候对象只是创建出来了,属性都还没赋值。实例化完成之后,会有 BeanDefinition 的后置处理,以及实例化后的 BeanPostProcessor 回调;

第三个阶段是属性填充阶段,也就是常说的依赖注入,Spring 会按照 BeanDefinition 里的依赖信息,把这个 Bean 依赖的其他 Bean、配置的属性值,通过反射注入到对应的字段或者 setter 方法里,完成属性赋值。属性填充完成后,还有一步填充属性后的处理逻辑;

第四个阶段是Bean 初始化阶段,第一步是执行各个类 Aware 接口的回调:如果这个 Bean 实现了 BeanNameAware 接口,Spring 就会调用 setBeanName 方法,把当前 Bean 的名称传递进去;如果实现了 BeanFactoryAware,就会调用 setBeanFactory 方法,把 BeanFactory 容器本身注入给 Bean;如果实现了 ApplicationContextAware,就会调用 setApplicationContext 方法,把整个应用上下文传进去。简单说,Aware 接口就是让 Bean 能主动获取到容器里的相关资源。执行完 Aware 之后,会执行所有 BeanPostProcessor 的postProcessBeforeInitialization方法,也就是初始化前的后置处理器回调。我们常用的 @PostConstruct 注解,就是在这个阶段被解析执行的。后面的话真正的初始化逻辑:如果 Bean 实现了 InitializingBean 接口,就会调用它的 afterPropertiesSet 方法;如果我们在 Bean 上通过 init-method 指定了自定义的初始化方法,也会在这一步执行。初始化方法执行完之后,会执行所有 BeanPostProcessor 的postProcessAfterInitialization方法,也就是初始化后的后置处理器回调。我们熟悉的 Spring AOP 动态代理,就是在这个阶段生成代理对象,替换掉原来的原生 Bean 的;

最后第五个阶段是Bean 销毁阶段。 当应用上下文关闭、容器销毁的时候,就会触发销毁流程:如果 Bean 实现了 DisposableBean 接口,就会调用它的 destroy 方法;如果我们配置了 destroy-method 自定义销毁方法,也会对应执行,用来释放资源、关闭连接这一类操作。

介绍一下MySQL存储引擎

首先MySQL 采用的是可插拔的存储引擎架构,存储引擎处于 Server 层之下,负责实际的数据存储、读取和并发控制,不同引擎有完全不同的特性,可以根据业务场景灵活选择,最常见的有InnoDB、MyISAM与Memory引擎。

首先介绍一下InnoDB,它是 MySQL 5.5 版本之后的默认存储引擎;第一它支持事务,满足 ACID 四大特性,靠 undo log 实现事务回滚、redo log 实现持久化,是业务系统数据一致性的基础;第二,锁粒度是行级锁,只有修改的行才会被锁住,高并发写入场景下的性能远优于表锁;第三,支持外键约束,可以保证数据的参照完整性;第四,自带崩溃恢复能力,服务意外宕机重启后,可以通过 redo log 把已经提交但还没刷到磁盘的数据恢复回来,最大程度避免数据丢失;索引结构上它采用的是聚簇索引。InnoDB也是用的最多的。

其次是MyISAM,这个是 MySQL 早期的默认引擎。它不支持事务,也不支持行级锁,只有表级锁,只要有写入操作就会锁住整张表,并发写入性能很差;也不支持外键和崩溃恢复,宕机后数据更容易损坏。但是他也有优点,表占用的存储空间更小,纯只读查询的场景下速度快。所以它只适合读多写极少、对事务没有要求的静态数据场景,现在基本上不常用。

第三个是 Memory 引擎,也叫内存引擎。它的数据全部存在内存里,读写速度极快,但代价是 MySQL 服务一旦重启,所有数据就会全部丢失。它也不支持事务和行级锁,一般用来存放临时数据,比如临时表、热点计数、可丢失的缓存数据这类场景。

所以就可以总结一下 InnoDB 和 MyISAM 的核心区别:第一是事务能力,InnoDB 支持完整 ACID 事务,MyISAM 完全不支持,这是最本质的差异;第二是锁粒度,InnoDB 是行级锁,并发读写性能好;MyISAM 只有表级锁,写入并发能力差;第三是可靠性,InnoDB 有 redo log 支持崩溃恢复,数据可靠性高;MyISAM 没有崩溃恢复能力,异常宕机容易损坏数据;第四是索引结构,InnoDB 采用聚簇索引,MyISAM 是非聚簇索引;第五是外键支持,InnoDB 支持外键约束,MyISAM 不支持。

MySQL 会把 InnoDB 设为默认引擎,首先是事务支持,它能保障数据的一致性;然后是并发性能,行级锁的设计让它能很好地支撑高并发读写;最后是数据可靠性,崩溃恢复机制能在服务异常后自动恢复数据。

介绍一下MySQL三大日志

三大日志分别是分别是 InnoDB 引擎层的undo log(回滚日志)、redo log(重做日志),以及 MySQL Server 层的 binlog(二进制日志);同时的话三大日志也共同支撑起事务这个特性:原子性靠 undo log 实现,持久性靠 redo log 实现,隔离性依靠 MVCC,再配合锁机制来实现;而一致性是最终的目标,它是由原子性、隔离性、持久性三者共同来保证的。

undo log 是 InnoDB 引擎自带的逻辑日志,核心作用是保障事务的原子性。简单说,事务执行中如果出错要回滚,就是靠它把数据恢复到修改之前的样子;它记录的是数据修改前的状态,比如说insert 操作会记录新插入数据的主键,回滚时直接按主键删掉这条记录;update 操作会记录修改前的旧值,回滚时逆向更新回去;delete 操作会保存整行待删除的数据,回滚时重新插回来。除了事务回滚,还有一个核心作用就是配合 ReadView 实现 MVCC 多版本并发控制。每行数据都会通过事务 ID 和回滚指针,把所有历史版本的 undo log 串成一条版本链,普通的快照读查询不用加锁,顺着版本链找到符合可见性的历史版本就能返回,大幅提升了并发读写性能。写入时机上,事务还没提交的时候,MySQL 就会先把修改前的数据写入 undo log,存在数据库的表空间里。

redo log 是 InnoDB 引擎层的日志,大部分属于物理日志,核心是保障事务的持久性,解决服务器宕机、掉电导致的数据丢失问题。MySQL 更新数据不会每次都直接写磁盘,而是先写到内存的 buffer pool 里,后台再异步把脏页刷到磁盘,这样能避免频繁的随机 IO,提升性能;但内存里的数据掉电就没了,redo log 就是用来解决这个问题的。redo log 在更新内存数据的同时,会把这次修改快速的记录到内存里的 redo log buffer,后台线程会把缓冲区的日志刷到磁盘的 redo log 文件里。这样如果脏页还没刷盘就宕机,重启后也能通过 redo log 把已提交的事务重做一遍,把数据恢复回来,保证已提交的修改不丢失。和 undo log 正好相反,redo log 存的是修改后的新状态。redo log 的文件结构是默认有两个固定大小的文件 ib_logfile0 和 ib_logfile1,采用循环写入的策略:一个写满就切到另一个,第二个也写满就回到第一个,覆盖最早的日志;这么做一是顺序写的性能远高于随机 IO,二是文件大小可控不会无限增长,三是崩溃恢复时只需要处理最近的日志,恢复速度更快。

最后一个就是 binlog二进制日志,binlog 是 MySQL Server 层实现的,所有存储引擎都可以使用,属于逻辑日志。binlog 核心作用有两个:第一个是数据恢复,比如误删库误删表,可以按时间点重放 binlog 把数据恢复回来;第二个是实现主从复制,MySQL 的主从集群本质就是靠 binlog 来做数据同步的。binlog 有三种记录格式:第一种是 STATEMENT 模式,直接记录执行的 SQL 语句,日志体积小,但用到 now () 这类非确定性函数时,主从会出现数据不一致;第二种是 ROW 模式,记录的是受影响的数据行的具体变更内容,没有一致性问题,但是日志体积会更大,MySQL 5.7.7 之后默认就是 ROW 模式;第三种 MIXED 混合模式,系统会根据 SQL 自动选择合适的格式。binlog 的写入方式是追加写,单个文件达到设定上限后,就会生成新的编号文件,比如 mysql-bin.000001、mysql-bin.000002 依次递增,不会覆盖旧文件。写入时机是在事务成功提交的时候,由 Server 层写入。

最后的话其实还有一个 relay log 中继日志,主要是主从复制里面会用到。主库把变更写到 binlog,从库的 IO 线程把 binlog 拉取过来,先写入自己的 relay log,再由 SQL 线程重放日志更新本地数据;从库写入 relay log 是主从同步的完成标志,不是数据真正落地,所以极端场景下会有短暂的主从延迟。

介绍一下MySQL中的MVCC

MVCC 全称是多版本并发控制,它是 InnoDB 存储引擎的核心机制之一,我们常用的读已提交、可重复读这两个事务隔离级别,底层都是靠 MVCC 实现的。它的核心思路就是不靠给行数据加锁来做并发控制,而是通过维护数据的历史版本,让读写操作互不冲突,以此提升数据库的并发性能。

MVCC 的底层是 undo log 版本链。在 InnoDB 里,我们存的每一行数据,除了我们自己定义的业务字段,还自带两个隐藏列:一个叫 TRX_ID,记录了最后一次修改这行数据的事务 ID;另一个叫 DB_ROLL_PTR,也就是回滚指针,指向 undo 日志里这行数据的上一个历史版本。每次有事务修改这行数据,都会把修改前的旧数据存到 undo log 里,靠这个回滚指针,就能把所有历史版本串成一条从新到旧的链表,也就是版本链。

MVCC依靠 ReadView ,也就是读视图来判断当前事务到底能读到版本链里的哪一个版本。这里的话又有两个概念:快照读和当前读。我们平时写的普通 select 查询语句,就属于快照读,走的是 MVCC 的逻辑;而像 insert、update、delete 操作,还有 select ... for update、select ... lock in share mode 这类加锁查询,属于当前读,它们会直接读取最新提交的数据,不走快照逻辑。

ReadView 本身有四个核心字段:第一个是 m_ids,就是生成 ReadView 的那一刻,系统里所有还没提交的活跃事务 ID 的集合;第二个是 min_trx_id,是这些活跃事务里最小的事务 ID;第三个是 max_trx_id,是系统预分配的下一个事务编号,也就是当前最大事务 ID 再加 1;第四个是 creator_trx_id,就是创建这个 ReadView 的当前事务自己的 ID。

有了 ReadView 之后,事务去版本链里找数据时,会从最新的版本开始,按固定规则挨个判断能不能读: 第一,如果当前版本的 trx_id 和 creator_trx_id 相等,说明这行数据就是当前事务自己改的,那肯定可以访问; 第二,如果 trx_id 小于 min_trx_id,说明修改这行数据的事务,在生成 ReadView 之前就已经提交了,也可以访问; 第三,如果 trx_id 大于等于 max_trx_id,说明这个修改事务是 ReadView 生成之后才开启的,不能访问; 第四,如果 trx_id 在 min_trx_id 和 max_trx_id 之间,就去 m_ids 集合里查:如果不在这个活跃集合里,说明生成 ReadView 的时候这个事务已经提交了,可以访问;如果还在集合里,说明事务还没提交,不能访问;如果当前版本不能读,就顺着回滚指针找上一个版本,直到找到符合条件的可见版本,或者遍历完整个链条都没找到就返回空。

说到这里的话也可以得到读已提交和可重复读两个隔离级别的核心差异,他们两个的区别就在于ReadView 的生成时机不一样。

读已提交也就是 RC 级别,事务里每执行一次快照读,都会重新生成一个全新的 ReadView。所以只要别的事务提交了修改,你下一次 select 就能立刻读到最新数据,所以 RC 级别会出现不可重复读的原因。 而可重复读也就是 RR 级别,只有事务里第一次执行快照读的时候,才会生成 ReadView,后面所有的快照读都会复用这同一个视图。所以不管别的事务提交了什么修改,当前事务前后读到的都是同一个版本的数据,自然就解决了不可重复读的问题。同时因为快照是固定的,普通查询也不会读到别的事务新插入的行,所以在纯快照读的场景下,InnoDB 的 RR 级别靠 MVCC 也解决了幻读问题。

RR 级别解决幻读不是绝对的。如果事务里两次快照读中间,执行了 update、delete 这类当前读操作,就会强制重新生成 ReadView。这时候再执行快照读,就能读到别的事务之前提交的新增数据,就会出现幻读。比如事务里先查表只有 1 条数据,中间执行了全表 update,再查就突然多了一条记录。

介绍一下RabbitMQ有序消费

常用的并发消费模式有两种,一种是并发消费模式,这也是绝大多数业务场景的默认选择。当消息对处理顺序没有要求时,就可以用这种模式。生产者会把消息轮询分发到不同的队列,多个消费者实例、多个消费线程可以并行处理,吞吐量就会变高。但因为不同消息的处理耗时有快有慢,完全没法保证消费的先后顺序,像普通的日志上报这类对时序没要求的场景就很适合并发消费模式。

另一种是有序消费模式,核心就是保证消息的消费顺序和发送入队的顺序保持一致。生产者端先按固定的哈希规则,把消息分配到指定队列;消费端保证每一个队列,只会有唯一的一个消费线程来处理。因为队列本身遵循先进先出的规则,单线程串行消费自然就能保证消费顺序和入队顺序一致。当然代价是会牺牲并发性能,所以只有业务强制要求顺序的场景才会使用。

但是现实中绝大多数场景用的都是局部有序,也就是去按照业务的具体情况去分析看是否要保证有序。举个例子,订单流程里同一个订单必须先创建、再扣库存、最后加积分,这个顺序不能乱,但不同订单之间谁先处理谁后处理没有影响。

代码层面去实现的话,首先从生产者端来看,需要选定一个业务唯一标识,比如订单 ID,用它对队列总数做哈希取模,根据计算出的索引,把同一个订单的所有消息都路由到同一个队列里;从消费者端来讲,就是用顺序专用的监听器,它会自动给每一个队列分配唯一的消费线程,保证同一个队列只会被一个线程处理;如果业务是要求全部有序的话,在生产者的队列选择器里,固定把所有消息都发到 0 号队列,全局只有一个队列,再配合顺序监听器的单线程消费,就能实现全局有序。但这种方式的弊端非常明显,完全丧失了并发能力,吞吐量极差。

有序消费是有使用限制的。有序消费只支持集群消费模式,不支持广播模式。因为集群模式下,一条消息只会被一个消费者实例处理,才能实现单队列单线程的消费逻辑;而广播模式下每条消息会复制推送给所有消费者实例,每个实例各自处理,根本没法保证全局的消费顺序,甚至会出现无法正常接收数据的问题。

介绍一下RabbitMQ可靠性投递

首先可靠性投递可以分为三个阶段:生产、存储与消费。

生产阶段就是生产者把消息发给 Broker 的过程:生产者发送消息后,会等待 Broker 返回 ACK 确认。如果遇到网络高延迟、超时没收到 ACK,生产者会按照配置的策略进行多次重发,直到收到 Broker 的 ACK 回执为止。Broker 端收到重复消息也会自动去重,不会在队列里存储多份。如果超过了最大重试时长依然收不到 ACK,生产者就会抛出异常,由业务代码捕获并做降级处理。

存储阶段就是 Broker 收到消息后的处理。这一步核心的过程就是先刷盘,再 ACK。Broker 收到消息后,会先把消息写入磁盘完成持久化,确认数据真正落盘了,再给生产者返回 ACK 确认。这样哪怕之后 Broker 服务宕机甚至服务器重启,消息已经存在磁盘上,重启后就能恢复,不会丢失。这个阶段也会有消息积压的风险:如果 ACK 回传的时候网络不好,生产者一直收不到 ACK,Broker 会持续重试发送 ACK,而这条没确认的消息会一直积压在队列里不会投递给消费者。

消费阶段就是把 Broker 把消息投递给消费者的过程。Broker 把消息推送给消费者后,并不会立刻删除消息,而是等待消费者返回 ACK 确认。如果一段时间内没收到 ACK,不管是网络波动、消费者处理慢,还是消费者服务宕机了,Broker 都会自动重新投递这条消息,直到收到消费者的 ACK 为止。也正因为 Broker 会自动重发,消费者大概率会收到重复的消息,所以消费者端必须做好幂等处理。比如用业务唯一 ID、消息 ID 去重,保证同一条消息哪怕被消费多次,业务结果也和消费一次完全一致,不会出现重复扣款、重复加积分这类数据错误。

保障这三个阶段的正常工作的话,首先刷盘方式要选同步刷盘,不要用异步刷盘。异步刷盘是 Broker 一收到消息就立刻返回 ACK,消息还在内存里没写到磁盘,这时候如果服务器突然掉电宕机,内存里的消息就直接丢了。同步刷盘虽然会牺牲一点写入性能,但必须等数据真正落盘才返回 ACK。其次就是存储介质要做冗余,避免单盘损坏丢数据。只靠单块硬盘存消息,硬盘坏了数据就全没了。生产环境建议用 RAID10 磁盘阵列,或者分布式存储。RAID10 简单说就是先做两组 RAID1 镜像盘,每组两块盘数据完全一致,坏一块不影响数据;再把两组盘拼成 RAID0 条带化提升读写性能,兼顾了可靠性和性能,单机存储的可靠性会高很多;最后,绝对不要开启自动 ACK,一定要用手动 ACK。自动 ACK 的逻辑是:只要消息发送给消费者,Broker 就默认处理完成,直接把消息删掉。这时候如果消费者的业务代码抛异常、服务直接宕机,消息就彻底丢了,不会再重发。正确的做法是用手动 ACK,等消费者的业务逻辑真正执行成功后,再手动调用方法确认消息,Broker 收到确认后才删除消息。如果处理失败或者没返回 ACK,Broker 就会重发消息,保证业务能处理到。

简历项目优化

HashMap改造树形结构

首先数据库设计上,我用了两个关键字段:一个是 parentId,根节点固定为 "0",这样就能通过父子关系把层级串起来;另一个是 path 路径字段(比如 ",0,1,2,")。这个 path 主要用来解决性能问题的------如果我想查某个阶段下的所有子孙任务,光用 parentId 去递归查询数据库,SQL得执行好几遍,效率太低了。但有了 path,我直接一个 like 就能把整棵子树的数据全捞出来,查询非常快。

数据拿到后端之后,返回的是一堆扁平化的记录。这时候前端要做的事情,就是把这一堆平级数据组装成带层级关系的树,交给组件去渲染。

这里就是我重点优化的地方了。 最传统的写法是用递归去挨个找父节点,那相当于双重循环,数据量一多(比如几百条)页面就卡了,复杂度是 O(n²)。我改用的 HashMap(即 JS 里的 Map 对象) 方案是这样的:

我不去遍历查找,而是先把所有记录过一遍,以 id 为键、当前节点的引用为值,全部塞进一个 Map 表里。这一步相当于给每个节点都建了个"门牌号索引"。然后我再过一遍这些记录,每个节点直接拿着自己的 parentId,去这个 Map 表里把父节点瞬间取出来,然后把自己挂到父节点的 children 属性下就行了。

这样一来,我根本不需要层层递归去找"爹在哪",而是靠 Map 的 O(1) 查询特性,直接"精准命中"父节点。整个过程就是把数据往 Map 里存一遍、再往外取一遍,整体复杂度直接降到了 O(n),哪怕上千条任务也是毫秒级完成,页面操作非常流畅。

等用户在界面上拖拽、编辑完这棵树之后,保存的时候我再反过来,把这棵树递归遍历"拍平"成扁平记录,传回给后端去更新数据库就行了。总之,这个方案的核心就是数据库用 parentId+path 兼顾存储和查询,前端用 HashMap 做 O(n) 级别的线性构建,保证大数据量下的性能不拉胯。

前端常见八股简单学习

CSS 相关

flex 布局相关知识点

垂直居中实现方案

响应式布局实现方式

margin 坍塌原理与解决办法

CSS 选择器优先级规则

JS 相关

var、let、const 三者区别

首先第一点 var 与 let 是块级作用域,也就是大括号 {} 内部定义的变量,在外部是访问不到的。而var 没有块级作用域,没有块级作用域就会出现问题,比如说循环里的计数器 i 泄露成全局变量,或者内层变量意外覆盖外层变量,用 letconst 就可以解决。

第二点是变量提升。var 存在变量提升,也就是不管你在哪里声明,代码执行时都会把它拉到作用域的最顶端。而 letconst 没有变量提升,必须先声明后使用。

第三点是是否会挂载到全局对象。如果在全局作用域用 var 声明,在浏览器环境下它会自动成为 window 的属性,在 Node 环境会成为 global 的属性。但是 letconst 声明的全局变量,不会加到全局对象身上,这样不容易污染全局命名空间。

第四点是是否可以重复声明。var 允许重复声明同一个变量,后面的会覆盖前面的,而 constlet 是绝对不允许重复声明的。

第五点是是否在暂时性死区访问。这其实和变量提升有关,letconst 在声明之前的区块内,这个变量是不可用的,这段区域就是暂时性死区,如果你在这期间访问就会报错。var 没有这个机制,访问到的会是 undefined

第六点是是否要赋初始值。varlet 声明的时候可以不赋初值,默认就是 undefined。但 const 声明必须同时赋值,不能后面再改,否则直接报错。

第七点是指针指向。let 声明的变量,它的指针是可以改变的,也就是你可以随时给它重新赋值。而 const 声明的变量,指针指向是不允许改变的。需要特别注意的,如果 const 声明的是一个对象,它只是保证这个变量指向的内存地址不变,对象内部的属性其实是可以修改的,只是你不能把这个变量重新赋值为另一个新对象。

一般开发的时候默认使用 const,只有当变量确实需要重新赋值时才改用 let。

闭包概念、作用、优缺点

闭包简单来讲,就是一个能够访问另一个函数作用域内部变量的函数。最常见写法就是在一个函数里面再创建一个内部函数,这个内部函数就可以访问外层函数里的局部变量。 举个例子,函数 A 里面定义函数 B,B 能够读取 A 中的变量,那函数 B 就形成了闭包。

闭包有两个用途,第一,我们可以在函数外部,访问到函数内部的局部变量。利用这个特性,我们可以实现私有变量,外部没办法直接修改,只能通过闭包函数间接操作。 第二,延长变量的生命周期。正常函数执行完毕之后,内部变量就会被垃圾回收;但如果存在闭包,闭包保留了变量对象的引用,这样的话外层函数执行结束,这些变量依旧保存在内存中,不会被回收。

闭包的优点就是可以实现私有变量,封装数据,控制外部访问;延长变量生命周期,可以保存状态;缺点是闭包会一直持有变量引用,对应的内存无法及时释放。如果滥用闭包,会造成内存占用过高,严重情况下引发内存泄漏;所以不用的时候,我们需要手动解除引用,方便垃圾回收。

数组常见方法及区分

首先是四种数据类型检测方案: 第一种,typeof 它可以检测大部分基础类型,像数字、布尔、字符串、undefined、函数都能正常识别。但是它有明显缺陷:数组、普通对象、null,全部都会返回object,所以没办法区分这三类,精度不足。

第二种,instanceof 它的原理是沿着原型链查找,判断构造函数的 prototype 能不能出现在实例的原型链上。 它只能判断引用类型,原始基本类型识别不了。比如字面量数字、字符串去判断 instanceof Number 结果都是 false;只能用来区分数组、函数、普通对象这类引用值。

第三种,constructor 实例可以通过 constructor 找到对应的构造函数,能够判断基础类型和引用类型。 但缺点很致命:原型可以被人为修改,一旦重写对象的 prototype,constructor 就会被篡改,判断结果失效,所以这个方法不太可靠。

第四种,Object.prototype.toString.call() 这是精度最高、最通用的检测方式,也是行业首选。 这里要解释一个关键点:数组、函数这些内置类型都重写了自己的 toString 方法,如果直接调用 obj.toString (),只会转换成字符串,拿不到类型。我们要用 call 改变 this 指向,调用 Object 原型上原生未被重写的 toString,它会返回[object 类型名]标准字符串,可以精准区分所有数据类型。

接下来讲判断数组的几种方式

  1. Object.prototype.toString.call() 最稳妥。执行结果类似[object Array],我们截取中间字符串判断是不是Array,跨窗口 iframe 环境也不受影响。

  2. obj.proto === Array.prototype 直接对比隐式原型和数组原型。缺点是不推荐直接操作__proto__这个非标准属性。

  3. ES6 Array.isArray() 日常开发最推荐使用的 API,简洁好用,同时可以兼容 iframe 跨上下文的数组实例,基本优先选它。

  4. obj instanceof Array 依靠原型链判断。有坑:如果页面存在多个 iframe,不同窗口的 Array 构造函数是不一样的,跨框架创建的数组会判断失误。

  5. Array.prototype.isPrototypeOf(obj) 作用和 instanceof 类似,同样是沿着原型链校验,同样存在跨 iframe 判断不准确的问题。

this 的绑定规则

call、apply、bind 的区别与使用场景

简单介绍一下Promise

async/await 原理与使用

JS 事件循环机制

JS 数据类型

事件冒泡、事件捕获、事件委托

浏览器完整渲染过程

跨域产生原因、常见跨域解决方案

箭头函数特性、与普通函数区别

箭头函数的特性有语法简洁,支持多种简写形式;没有自己的 this,不会新建 this,捕获定义时外层上下文的 this,一经捕获就固定不变,不会随着调用方式变化;call、apply、bind 无法修改它内部 this 指向;不存在 prototype 原型属性;不能作为构造函数,不能使用 new 调用;没有独立的 arguments 对象,访问 arguments 获取的是外层函数的 arguments;不能作为 Generator 函数,内部不可以使用 yield。

然后就是箭头函数与普通函数的区别,从语法层面来看箭头函数更简洁,无参数写 ();单个参数省略括号;单行返回可以省略大括号;单行不想返回值可以用 void;普通函数没有这些简写语法;从this 指向规则来看,普通函数 this 由调用方式决定,谁调用指向谁;箭头函数:没有独立 this,继承定义位置外层作用域的 this,固定不变。从 this 的修改能力来看,普通函数可以通过 call、apply、bind 手动改变 this; 箭头函数不管怎么调用这三个方法,this 都不会变化。从能否充当构造函数来看,普通函数可以 new,作为构造函数生成实例; 箭头函数禁止 new 调用,会直接报错。从是否有自身的 arguments 来看,普通函数自带 arguments 类数组对象; 箭头函数没有自身 arguments,取值来自外层函数,项目里一般用剩余参数...args替代。从是否拥有 prototype 属性来看,普通函数拥有 prototype 属性; 箭头函数不存在 prototype。最后从是否可以用作 Generator 生成器来看,普通函数可以加*变成 Generator,支持 yield; 箭头函数不能用作 Generator,不能使用 yield 关键字。

Vue 相关

Vue 组件之间所有通信方式

首先是最基本的父子组件通信,一共有四种方式:

第一种是 props / $emit,父传子靠 props,子传父就靠 $emit:父组件通过自定义属性把数据往下传,子组件用 props 选项接收。props是典型的单向数据流,数据只能由父组件向下更新,子组件不能直接修改 props。反过来子传父就是子组件触发一个自定义事件,把参数带出去,父组件用 v-on 监听这个事件,就能拿到子组件传递的数据。

第二种是 ref / $refs。父组件在子组件的标签上加上 ref 属性并命名,组件挂载完成后,通过this.$refs.名称就能拿到子组件的完整实例,这种方式直接,但只能父组件主动获取子组件,而且必须等组件挂载后才能拿到,否则会是 undefined。

第三种是 $parent / $children,相当于双向直接获取组件实例。子组件通过this.$parent可以拿到上一级父组件的实例,直接访问父级的数据和方法;父组件通过this.$children能拿到所有子组件的实例数组。这种方式虽然用起来方便,但组件耦合度很高,层级结构一变就容易出问题,一般不推荐在代码里用。

除了父子组件通信,还有跨代、祖孙组件之间的通信,组件嵌套层级也比父子组件通信更深,主要有两种方案:

第一种是 provide / inject,也叫依赖注入。祖先组件用 provide 选项提供数据或者方法,后代组件不管嵌套多少层,只要用 inject 就能直接注入拿到这些数据,不用一层一层传递 props。但需要注意的是,默认传递的数据不是响应式的,如果需要响应式能力,得传响应式对象才可以。适合深层组件共享全局配置、公共方法这类场景。

第二种是 $attrs / $listeners,核心作用是做属性和事件的透传。比如 A 是爷爷组件、B 是父组件、C 是孙子组件,A 要给 C 传属性和事件,不用 B 在中间一层一层写 props 转发。还有个配套属性叫inheritAttrs,默认值是 true,会把未声明的属性挂到组件根 DOM 上,通常我们会把它设为 false,配合 $attrs 使用会更干净。这种方式在二次封装组件、实现高阶组件的时候特别常用。

然后是兄弟组件、任意关系组件之间的通信 ,最典型的方案是 EventBus 事件总线。它的原理很简单:创建一个空的 Vue 实例作为中央消息中转站,所有需要通信的组件都引入这个实例。发送消息的组件调用EventBus.$emit触发自定义事件、携带参数;接收消息的组件在生命周期里用EventBus.$on监听对应事件,就能拿到数据。它的优势是不分组件关系,任意组件都能通信;但缺点是项目变大、事件变多之后非常难维护,容易出现命名冲突,而且一定要在组件销毁前用 $off 解绑事件,否则会造成内存泄漏。所以它只适合小型项目、简单的跨组件通信场景。

最后,如果是中大型项目、多组件共享全局状态的场景,可以用 Vuex(也可以用 Pinia)做全局状态管理。它的思路是把所有组件共享的公共数据抽离出来,统一放到全局仓库里管理,有一套严格的修改流程:state 存数据,mutations 修改同步状态,actions 处理异步逻辑,getters 做派生计算,还支持按业务模块拆分。

简单总结一下选型思路:日常父子通信首选 props + emit;需要直接操作子组件用 ref;深层跨代传值用 provide/inject,封装组件用 attrs/$listeners;小型简单跨组件场景用 EventBus;大型项目全局状态管理就上 Vuex 或者 Pinia。

Vue2 和 Vue3 的核心区别

首先最底层、最核心的就是响应式原理的改写 。Vue2 用的是 ES5 的 Object.defineProperty,初始化的时候遍历 data 里所有属性,给每个属性加上 getter 和 setter 来做拦截。但这个方案天生有局限:第一,它只能监听已有属性的读写,对象新增、删除属性是监听不到的,得靠 $set$delete 这种特殊 API 补救;第二,它没法原生监听数组的下标修改和长度变化,Vue2 是靠重写了 7 个数组方法来实现响应式的,属于补丁式的方案;第三,它完全不支持 Map、Set、WeakMap 这些集合类型。

到了 Vue3,就换成了 ES6 的 Proxy 代理方案。Proxy 是直接代理一整个对象,而不是逐个劫持属性,所以只需要一层代理,就能监听到同级所有属性的变化,包括新增属性、删除属性,也能原生监听数组下标和长度的变更,不用再额外封装 API 了。同时也原生支持 Map、Set、WeakMap、WeakSet 这些集合类型,响应式的覆盖范围更全,也省去了初始化遍历所有属性的开销,性能更好。

第二个核心区别是 API 风格的全面升级,也就是从 Options API 到 Composition API。Vue2 用的是选项式 API,写代码就是往配置里填 data、methods、computed、生命周期钩子这些选项,优点是新手好上手,结构规整;但缺点也很明显:一是同一个业务逻辑的代码会被拆分到不同选项里,组件复杂之后逻辑特别散,维护起来很费劲;二是逻辑复用很麻烦,之前通用方案是 mixin,很容易出现命名冲突,也分不清变量到底来自哪个 mixin。

Vue3 推出的组合式 API,核心就是 setup 函数,所有响应式数据、方法、生命周期逻辑都可以写在里面,相关的业务逻辑可以聚合在一起。我们还可以把一段通用逻辑抽成自定义 hooks,复用起来干净透明,不会有命名冲突的问题。而且组合式 API 都是普通函数,天然对 TypeScript 的类型推导非常友好,不用再写装饰器,和 TS 结合得很顺畅。

第三点是模板与渲染性能的优化。比如作用域插槽,Vue2 里只要作用域插槽的数据变了,父组件就得跟着重新渲染;Vue3 把作用域插槽改成了函数的形式,数据变化只会触发子组件重渲染,渲染性能提升了不少。另外 Vue3 也优化了 render 函数的写法,对手写 vdom 的开发者更友好。

Vue 生命周期及各阶段作用

Vue 生命周期整体可以分为初始化、挂载、更新、卸载四大阶段,每个阶段都对应 "之前" 和 "之后" 两个核心钩子。

初始化阶段包含 beforeCreatecreated 两个钩子,beforeCreate 就是创建前:实例刚启动初始化,数据的响应式追踪、methods、watch 这些都还没配置完成,此时访问不了 data 里的数据,也调用不了 methods 中的方法,这个阶段实际业务中用得比较少;created 创建后实例创建完成,data、computed、watch、methods 都已经初始化完毕,可以正常访问和调用。但此时还没开始编译模板,真实 DOM 还没挂载,拿不到页面上的 DOM 元素,这个阶段非常适合发送异步请求、初始化页面数据:一来能更早拿到服务端数据,减少页面白屏时间;二来服务端渲染 SSR 只支持到 created 阶段,放在这里兼容性更好。

第二个阶段就是挂载阶段,包含 beforeMountmounted 两个钩子。beforeMount就是挂载前,模板已经编译完成,生成了虚拟 DOM,但还没把真实 DOM 插入到页面中,页面上的 DOM 仍是未编译的状态;mounted就是挂载后,这个时候虚拟 DOM 已经转换成真实 DOM,成功插入到页面里,组件正式渲染完成。到这一步就可以获取真实 DOM 节点,执行 DOM 相关操作,比如初始化第三方 UI 插件、获取元素宽高位置等等。这里也可以发送异步请求,只是时间上会比 created 稍晚。

第三个阶段是更新阶段,包含beforeUpdateupdated 这个两个钩子,在组件的响应式数据发生变化时触发。beforeUpdate 更新前,数据已经改成了最新值,但页面上的 DOM 还没重新渲染,仍是旧内容,updated 更新后虚拟 DOM 已经完成重新打补丁,真实 DOM 也根据新数据更新完毕,页面和数据是同步的,但是不要在 updated 里直接修改数据,否则很容易触发无限循环更新。如果有依赖 DOM 更新完成的操作,可以放在这个阶段执行。

第四个阶段是卸载阶段,有 beforeUnmountunmounted 这两个钩子,beforeUnmount 卸载前组件还没销毁,实例完全可用,所有数据和方法都能正常访问,一般在这里做清理工作,比如清除定时器、移除全局事件监听、销毁第三方插件实例,避免出现内存泄露;unmounted 卸载后组件已经彻底销毁,所有事件监听器、子组件实例都被移除,对应的 DOM 也被销毁。

v-if 和 v-show 的区别与使用场景

从底层实现来看,v-show通过修改修改元素的 display 样式属性来实现元素的显示与隐藏,当条件为 true 时,元素正常渲染展示,当条件为 false 时,会给元素加上 display: none 的内联样式实现隐藏;而 v-if 是根据条件的真假,直接决定要不要渲染这个元素。

所以说使用 v-if,元素有可能渲染,也有可能不渲染;而使用 v-show 的话元素肯定要被渲染,而 CSS 样式的变化导致显示与隐藏。所以就可以得出结论:如果是频繁切换元素的显示隐藏的话就用 v-show,比如说弹窗;否则就用 v-if,就是元素绝大多数时间都不展示,切换频率很低的情况。

代码输出笔试题

各种场景下 this 指向判断输出

事件循环代码执行顺序输出题

相关推荐
GeekArch1 小时前
第28讲:避坑——AI堆栈分配错误、栈溢出BUG
c语言·人工智能·stm32·mcu·学习·bug
AI多Agent协作实战派2 小时前
AI多Agent协作系统实战(十七):凌晨4点,我的AI系统在“假装工作“——3个bug同时爆炸的5小时
java·前端·bug
gaolei_eit2 小时前
Java+Ai+vue
java·spring·maven
qq_2518364572 小时前
基于java Web 动漫视频网站毕业论文
java·开发语言·前端
LayZhangStrive2 小时前
JUC相关的函数、注解、变量杂记
java·面试·多线程·juc
未秃头的程序猿3 小时前
给公司做了个AI客服Agent,用的Spring AI 1.0,3天上线领导拍板了
java·后端·ai编程
曹牧3 小时前
Eclipse 批量文本替换
java·ide·eclipse
程序员清风3 小时前
OpenAI官方发布最新提示词技巧!
java·后端·面试
梅头脑3 小时前
写了3年CRUD,volatile和synchronized的区别还是答不清——直到我画出了这张三性两锁边界图
java
暮暮祈安3 小时前
Celery 新手入门指南
java·数据库·python·flask·httpx