近年来,HTML 的一项重要改进是引入了 loading="lazy" 属性,它可以应用于图像或 iframe 元素。这个属性会指示浏览器延迟加载资源,直到元素滚动进入视口为止。这极大地提升了页面加载性能。
<img src="/images/your-image.png" loading="lazy">
这种做法简单又实用。但你是否想过,能否对 JavaScript 脚本也实现类似的懒加载效果呢?这样一来,你就可以按需加载组件,只在它们真正被需要时才引入,从而进一步优化应用性能。
幸运的是,<img> 元素还提供了 onload 和 onerror 属性,允许我们在图片加载成功或失败时执行脚本。
<img
src="/images/your-image.png"
loading="lazy"
onload="() => console.log('image loaded')"
>
这里的 onload 回调只会在图片加载完成后触发。如果图片本身是懒加载的,那么这个回调也只会当图片进入视口时才执行。看,一个基于图片懒加载触发的脚本执行机制就出现了!
不过,上面的例子还不够完善。首先,页面上会多出一张你可能并不需要的图片;其次,你需要将 JavaScript 代码内联写入属性,这在一定程度上背离了模块化与懒加载的初衷。那么,我们该如何改进呢?
图片本身可以“什么都不是”。正如前面提到的,onerror 回调会在图片加载失败时触发。这并不意味着你必须将 src 指向一个不存在的文件,那样会导致控制台充满 404 错误。
如果 src 属性指向的“图片”实际上并非有效的图片格式,onerror 回调同样会被触发。最简单的方法是利用 data: 协议来“错误地”编码一个图片。这样做的好处是,不会向控制台输出任何缺失图片的警告。
<img
src="data:,"
loading="lazy"
onerror="() => console.log('image not loaded')"
>
虽然这仍会在页面上显示一个破损的图片图标,但稍后我们会解决它。现在,我们面临另一个问题:如何避免内联 JavaScript?答案就在现代 JavaScript 的 ES 模块动态导入功能中。
我们可以利用动态 import() 语法,在 onerror 事件触发后异步加载所需的脚本模块:
<img
src="data:,"
loading="lazy"
onerror="import('/js/some-component.js').then(_ => _.default(this))"
>
注意:此技巧同样适用于 onclick、onchange 等其他事件。
注意:代码中的下划线 _ 只是接收导入模块的一个简写,你也可以写成 .then(Module => Module.default(this))。
这行代码做了什么?它会在图片(实际上是 data: URL)“加载失败”时,动态导入 /js/some-component.js 模块,并执行其默认导出函数,同时将 this(即当前的 <img> 元素)作为参数传递进去。
那么,some-component.js 模块可能是什么样子呢?
// some-component.js
export default element => {
element.outerHTML = `
<div class="whatever">
<p>Hello world!</p>
</div>
`;
}
你可能会注意到,在 onerror 回调中,我们将 this 作为参数传给了默认导出函数。这是因为在事件处理函数的上下文中,this 指向触发事件的 <img> 元素本身。
现在,你可以在导入的模块中,轻松地使用 element.outerHTML 将这个“破损的图片”替换为你想要的任何 HTML 标记。这样一来,一个真正按需懒加载的脚本组件就实现了!
缓存和传递参数
如果你在同一个页面上多次使用这项技术,浏览器可能会因为 src 属性相同而进行缓存。为了避免这种情况,你需要为 data:, 添加一个“缓存破坏”参数,例如一个随机数:
<img
src="data:,abc123"
loading="lazy"
onerror="import('/js/some-component.js').then(_ => _.default(this))"
>
<img
src="data:,xyz789"
loading="lazy"
onerror="import('/js/some-other-component.js').then(_ => _.default(this))"
>
data:, 后面的字符串可以是任意内容,只要确保它们彼此不同即可。
向懒加载的组件传递参数也非常简单。你可以在 HTML 中使用 data-* 属性来存储数据:
<img
src="data:,"
loading="lazy"
data-message="hello world"
onerror="import('/js/some-component.js').then(_ => _.default(this))"
>
由于我们将 this(即 <img> 元素)传递给了函数,你可以在模块中通过 dataset 对象来访问这些自定义数据:
export default element => {
const { message } = element.dataset
element.outerHTML = `
<div class="whatever">
<p>${message}</p>
</div>
`;
}
这种方法提供了一种纯 HTML 声明式、无需构建工具即可实现组件懒加载的巧妙思路。如果你想深入探讨更多前端性能优化与模块化实践,欢迎在 云栈社区 与其他开发者交流分享。