一、 为什么要做图片懒加载?
1. 核心动机:解决首屏资源抢占
- 痛点场景:如果一篇文章包含 20 张高清大图,用户初次进入页面时,浏览器会瞬间并发 20 个图片请求。这些高带宽的网络请求会严重挤占关键 CSS 和 JS 的下载通道,导致首屏白屏时间显著延长。
- 思考路径:作为博主,我发现很多读者习惯快速浏览,甚至只看篇首就滑走。如果在页面初始化时就全量加载,不仅是对流量的极大浪费,更会拖慢用户的整体首屏体验。
2. 技术选型:IntersectionObserver vs 传统 Scroll
- 传统做法:通过监听
window.scroll事件,在主线程同步调用getBoundingClientRect().top或offsetTop来手动计算元素位置。 - 底层隐患:
scroll事件触发频率极高。由于计算位置的属性是同步 API,读取它们会强制浏览器为了数据准确性而立刻重排(Reflow),这种**强迫同步布局(Forced Synchronous Layout)**是导致长列表滚动掉帧的主要元凶。 - 我的选择:拥抱现代浏览器内置的
IntersectionObserver。它采用异步监听机制,将交叉检测的时机交由浏览器底层渲染引擎调度。它只在元素“进出”视口时发送通知,不占用 JS 主线程资源,性能开销几乎可以忽略不计。
3. 本方案的优势与不足
- 优势:
- 按需加载:实现真正意义上的“即看即得”,有效缩减了首屏 60% 以上的无效请求。
- 极致流畅:从渲染流水线底层规避了不必要的重构,即使在低端移动设备上也能保持丝滑的滚动体验。
- 不足与改进方向:
- 预感性加载缺失:仅在“看到”时才加载,会导致用户滑得过快时看到瞬时白屏。改进:可通过配置
rootMargin(例如设为200px)实现图片的提前静默预加载。 - 观察器实例开销:当前指令为每个图片元素都
new了一个实例。改进:在面对极长列表时,可考虑采用单例模式(全局共用一个 Observer 实例),进一步压缩内存占用。
- 预感性加载缺失:仅在“看到”时才加载,会导致用户滑得过快时看到瞬时白屏。改进:可通过配置
二、 核心代码解析
1. 核心指令源码 (components/directives/lazy.ts)
import type { Directive } from 'vue'
// 定义指令:绑定目标为 HTMLImageElement,传入值为 string (图片URL)
export const lazyDirective: Directive<HTMLImageElement, string> = {
// 【生命周期】mounted:DOM 元素被插入页面时触发
mounted(el, binding) {
// 1. 预设占位图 (防白屏、防碎图图标、减小重排)
// 使用 1x1 极小透明 Base64 GIF 占坑,不触发额外网络请求
el.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
// 2. 实例化交叉观察器 (单体监听模式)
// 传入异步回调函数,将执行时机的控制权交接给浏览器底层
const observer = new IntersectionObserver((entries) => {
// entries 为浏览器收集的状态变化目标数组
entries.forEach(entry => {
// 【核心拦截】只有当元素真正进入可视区域时才执行加载
if (entry.isIntersecting) {
// 替换真实 src,触发浏览器内核的 HTTP 图片请求
el.src = binding.value;
// 【防御性编程】处理图片加载失败的边界情况
el.onerror = () => {
el.src = 'https://via.placeholder.com/150'; // 兜底图
};
// 【性能优化】加载完成后立刻解除对该 DOM 的监听
observer.unobserve(el);
}
});
});
// 3. 启动监听:将当前 img 元素加入观察队列
observer.observe(el);
// 4. 跨生命周期状态挂载 (核心工程技巧)
// 将 observer 实例强制挂载到真实 DOM 节点 el 上。
// 使用 (el as any) 绕过 TS 的 HTMLImageElement 类型校验。
// 目的:为 unmounted 阶段的内存清理提供实例引用。
(el as any)._observer = observer;
},
// 【生命周期】unmounted:组件/元素被销毁时触发
unmounted(el) {
// 提取挂载的实例
const observer = (el as any)._observer;
if (observer) {
// 【防内存泄漏】彻底关停并销毁观察器
observer.disconnect();
}
}
};2. 全局注册机制 (main.ts / setup)
import { defineAppSetup } from 'valaxy'
import { lazyDirective } from '../components/directives/lazy'
export default defineAppSetup((ctx) => {
const { app } = ctx
// 【全局注册】将 lazyDirective 注册为全局指令 'lazy'
// 模板编译原理:Vue 编译器遇到带 v- 前缀的属性 (v-lazy) 时,
// 会自动映射到此处注册的 'lazy' 指令逻辑。
app.directive('lazy', lazyDirective)
})3. 模板使用规范与解析
<img v-lazy="'https://picsum.photos/1000/600?random=1'" />v-lazy: 触发自定义指令。双引号套单引号 (
"'...'"): Vue 模板内执行的是 JS 表达式。外层双引号代表表达式区域,内层单引号严格声明这是一个字符串字面量常量。如果不加单引号,Vue 会将其当作响应式变量去当前作用域(Data/Setup)中查找,导致 undefined 报错。参数映射: 解析后,外层的
<img ...>真实 DOM 节点会被注入到钩子函数的el参数中;字符串'https://...'会被封装进binding.value中。
三、 模拟面试深挖
模块一:架构设计与 Vue 机制
Q1:为什么把懒加载封装成“自定义指令(Directive)”而不是直接写在组件里?
- 答:为了解耦和高复用性。懒加载本质是对底层 DOM 的纯粹操作,与具体的业务逻辑毫无关联。将其封装成指令(
v-lazy)具有极低的侵入性,任何组件只要给<img>标签加个属性就能实现优化。这代码复用率极高,也完美符合 Vue “组件负责业务,指令负责底层 DOM” 的最佳工程实践。
Q2:模板里的 v-lazy="'https...'" 为什么要外层双引号,内层还要加单引号?
- 答:因为 Vue 模板中等号右侧执行的是 JS 表达式。如果不加单引号,Vue 会把 URL 当作一个“响应式变量”去上下文(Data/Setup)里查找,从而报错 undefined。加上单引号是明确告诉 Vue 编译器:这是一个字符串字面量常量,请直接把它传递给指令的
binding.value。
模块二:浏览器渲染机制与重排 (Reflow) 陷阱
Q3:你在简历中提到“频繁获取 offsetTop 会引起浏览器重排风险”。请结合浏览器的底层渲染机制解释一下,什么是重排?为什么仅仅是“读取”一个属性值就会引发页面的严重卡顿?你的 IntersectionObserver 方案是如何解决这个问题的?
满分对答话术: 这个问题涉及到浏览器的核心渲染管线(Rendering Pipeline)机制。 首先,浏览器渲染页面通常会经历:解析 DOM 树、解析 CSSOM、合并生成渲染树,然后进入开销极其巨大的 Layout(布局) 阶段。
在正常情况下,浏览器的渲染引擎存在一种**“懒惰优化”**机制。当我们使用 JavaScript 修改元素的样式时,浏览器并不会立刻重新计算布局,而是将这些修改放入一个“渲染队列”中,等到下一帧(Frame)再一次性批量处理。
但是,当我们通过 JavaScript 去读取
offsetTop、clientWidth、getBoundingClientRect()等几何属性时,彻底打破了这个优化机制。因为浏览器为了保证能返回当前最真实、最准确的像素坐标,它不得不立刻清空渲染队列,强行中断当前的 JavaScript 执行,提前触发一次全局或局部的布局计算。这种现象在前端工程上被称为**“强制同步布局(Forced Synchronous Layout)”**。如果在传统的
window.scroll滚动事件里监听图片位置,一秒钟内可能会高频触发几十次offsetTop的读取。这会导致浏览器疯狂重排(Reflow),CPU 占用率暴涨,主线程被严重阻塞,最终反映在用户体验上就是页面滑动严重掉帧、卡顿。而我项目里自研的
v-lazy指令所使用的IntersectionObserver,属于浏览器底层提供的异步原生 API。它将交叉状态的计算交给了浏览器底层去统一定期调度(通常在事件循环的特定阶段执行),完全避开了前端代码主动读取几何属性所带来的“强制同步布局”灾难,实现了性能的降维打击。
模块三:HTTP 网络协议与请求抢占
Q4:你简历中提到了“减少不必要的网络请求抢占”。假设一篇文章有 20 张高清大图,如果不做懒加载,这 20 张图片的加载到底“抢占”了谁的资源?会导致什么样的严重后果?你的方案又是怎么解决这个“抢占”问题的?
满分对答话术: 这里的“抢占”主要发生在浏览器的网络传输层,其根本原因在于 HTTP/1.1 协议的底层限制。
基于 HTTP/1.1 协议,现代主流浏览器(如 Chrome)对同一个域名的并发 TCP 连接数是有严格限制的,通常最大并发数在 6 个左右。
如果一篇文章有 20 张高清图,在首屏加载的瞬间,这 20 个图片请求会立刻塞满并阻塞这 6 个宝贵的 TCP 通道。这导致的严重后果是:决定页面核心骨架和交互的关键资源(如核心 CSS 文件、首屏业务 JS 脚本)会被迫处于 Stalled(停滞/排队等待) 状态。这些关键资源下不来,页面就无法完成渲染,最终导致首屏白屏时间过长,也就是性能指标中的 FCP(首次内容绘制) 极差。
我的懒加载方案本质上是对网络资源的优先级调度优化:
- 零请求占坑:在初始状态下,我使用了一个极小的 1x1 像素的 Base64 透明占位图。由于 Base64 是直接硬编码在 HTML 或 JS 中的,它不需要发起任何真实的 HTTP 网络请求,这就把宝贵的 TCP 并发通道完全让给了核心的 HTML、CSS 和 JS 文件,保障了页面的极速首屏渲染。
- 异步传输缓冲:等到页面骨架渲染完毕,用户向下滚动时,再通过
IntersectionObserver异步触发真实图片 URL 的替换,按需发起下载,达到了性能与体验的最佳平衡。
模块四:底层机制与边界场景优化
Q5:如果不手动写 unmounted 里的代码会发生什么?为什么要挂载到 el 上?
- 答:会导致内存泄漏。
IntersectionObserver实例如果不手动调用disconnect(),即使 DOM 被销毁,它也会一直驻留在内存中。因为mounted和unmounted是两个独立的作用域,所以我通过(el as any)._observer = observer将实例挂载到原生的 DOM 节点上作为跨生命周期的桥梁,确保在组件卸载时能精准提取并销毁它。
Q6:你的方案是一张图片 new 一个观察器,如果页面有 1000 张图片怎么办?会不会很占内存?
- 答:在轻量的博客文章场景下单实例是可以的,但在长列表极限场景下,我会采用**“观察器单例模式(Singleton)”**。即在全局只创建一个共享的
IntersectionObserver实例,并维护一个全局的 Map 映射表。所有图片共用这一个“保安大队负责人”,触发回调时通过比对entries里的目标节点去 Map 中找对应的 URL 进行加载,这样能极大降低内存开销。
Q7:网速慢的时候,或者用户滑动太快,图片还没加载完就划走了会出现短暂白屏闪烁,怎么优化用户体验?
- 答:这其实也是懒加载方案的一个优势,因为我们可以做精细化控制。如果用户划走太快,我们可以在
unobserve之后甚至中止尚未发出的网络请求。为了解决白屏体验,我会利用IntersectionObserver的配置项rootMargin属性。将其设置为类似'0px 0px 200px 0px',人为扩大底部的交叉侦测范围。这样在图片距离屏幕底部还有 200px 时就会提前触发回调发起网络请求,利用这个预加载缓冲带(Buffer)抵消加载时延,实现用户滑动时的无感加载,让用户感官上觉得“图一直都在”。
模块五:JS 异步编程基础
Q8:这个交叉观察器的回调函数为什么不需要写 return?它的参数是怎么传进来的?
- 答:这里利用了控制反转的思想。回调函数是交由浏览器底层异步触发的,目的是执行“产生副作用”的操作(修改 DOM 的 src、取消监听),而不是计算某个值,所以不需要
return。它的参数entries也是由浏览器在底层监听状态变化后,主动打包并作为入参注入到函数中的。
Q9:为什么要用箭头函数写回调?
- 答:箭头函数没有自己的
this,它会继承外层词法作用域的this。虽然这个指令逻辑中暂未涉及复杂的this调用,但在工程规范中,使用箭头函数能有效避免在复杂异步场景下this指向丢失(指向 window 或 undefined)的致命 Bug,保证逻辑的严谨。
四、 八股关键词联想映射
| 考点关键词 | 你的回答关键词 |
|---|---|
| 浏览器渲染流水线 | 回流重排 (Reflow)、强制同步布局、渲染队列屏蔽 |
| 性能优化指标 | LCP / FCP 优化、网络请求抢占、TCP 并发通道限制 |
| JS 设计模式 | 观察者模式、单例模式、控制反转 |
| Vue 指令生命周期 | mounted vs unmounted 防内存泄漏、作用域生命周期桥梁 |
| 底层 API vs 同步 API | 异步调度机制、Event Loop 批量处理避开主线程阻塞 |
留言板