回调函数、作用域与闭包:从图片预览案例深入理解
回调函数、作用域与闭包:从图片预览案例深入理解
参考深度解析:JavaScript变量声明的演变与核心差异(var/let/隐式声明)
引言
在前端开发中,回调函数、作用域和闭包是三个紧密相关的概念,也是很多初学者容易混淆的地方。本文将通过一个实际的图片预览切换案例,详细剖析这些概念,帮助你掌握前端进阶必备知识。
案例介绍:图片预览切换功能
我们先来了解一下这个图片预览切换功能:
- 页面上有一个大图展示区域和下方的小图列表
- 鼠标悬停在任一小图上,大图区域会切换为对应的图片
- 当前选中的小图会显示红色边框
代码如下:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>大小图预览</title><style>*{margin: 0;padding: 0;box-sizing: border-box;}html,body,#page {width: 100%;height: 100%;background-color: rgba(0, 0, 0, .4);}/* 防止.display外边距穿透 *//* 法一 *//*#page {padding: 1px;}*//* 法二 */#page::before {content: '';display: block;height: 200px;}.display {background-color: rgb(255, 192, 203,.8);width: 430px;/* 430px大图100px小图20px间隔 */height: 550px;/* 法一margin设置 *//* margin: 200px auto; *//* 法二margin设置 */margin: 0 auto;background: url(./pic1.jpg) no-repeat;background-size: 430px 430px;}ul {list-style: none;/* 消除影响布局的空白换行 */font-size: 0;}ul::before {content: "";display: block;height: 430px;}ul>li {display: inline-block;}ul>li:not(:last-child) {margin-right: 10px;}ul>li>img {width: 100px;height: 100px;object-fit: cover;/* 透明边框留给active空间 */border: 1px solid transparent;cursor: pointer;}.active {border: 1px solid red;}</style>
</head>
<body onload="onLoad()"><div id="page"><div class="display"><ul><li><img src="./pic1.jpg" alt="1"></li><li><img src="./pic2.jpg" alt="2"></li><li><img src="./pic3.jpg" alt="3"></li><li><img src="./pic4.jpg" alt="4"></li></ul></div></div>
</body>
<script>onLoad = ()=>{var imgs = document.getElementsByTagName("img");var displayBox = document.getElementsByClassName("display")[0];var img1 = imgs[0];// console.log(img1.classList);// console.log(img1.className); // img1.setAttribute("class",`${img1.classList} active`);img1.classList.add("active");for(let i=0;i<imgs.length;i++){imgs[i].onmouseover = ()=>{for(let j=0;j<imgs.length;j++){imgs[j].classList.remove("active");}imgs[i].classList.add("active");var imgSrc = imgs[i].getAttribute("src");displayBox.style.background = `url(${imgSrc}) no-repeat`;displayBox.style.backgroundSize = `430px 430px`;}}}
</script>
</html>
一、回调函数详解
什么是回调函数?
回调函数是作为参数传递给另一个函数,并在特定时机被调用的函数。简单来说,就是"把函数A传给函数B,B在合适的时机调用A"。常用作事件触发函数。
在我们的案例中,每个图片的onmouseover
事件绑定的函数就是典型的回调函数:
imgs[i].onmouseover = ()=>{// 这段代码不会立即执行// 只有当鼠标移到图片上时才会被调用
}
回调函数的特点
- 延迟执行:定义时不执行,只有在特定条件满足时才执行
- 控制反转:将控制权交给调用者,由调用者决定何时执行
- 常见场景:事件处理、异步操作、定时器等
案例中的回调函数分析
在案例中,我们为每个图片元素绑定了一个鼠标悬停事件的回调函数。这些函数不会在页面加载时立即执行,而是在用户将鼠标移到对应图片上时才会被调用。
二、作用域深度解析
作用域的定义与类型
作用域决定了变量的可访问范围。JavaScript中主要有三种作用域:
- 全局作用域:在代码中任何地方都能访问
- 函数作用域:只在函数内部可访问(var声明的变量)
- 块级作用域:只在代码块内可访问(let/const声明的变量)
var与let的关键区别
// var的函数作用域
function demo1() {for(var i=0; i<3; i++) {console.log(i); // 0,1,2}console.log(i); // 3 (i在循环外仍可访问)
}// let的块级作用域
function demo2() {for(let j=0; j<3; j++) {console.log(j); // 0,1,2}console.log(j); // 报错:j未定义
}
案例中的作用域分析
在我们的图片预览案例中,关键的作用域问题出现在这里:
for(let i=0; i<imgs.length; i++){imgs[i].onmouseover = ()=>{// 这里使用了i变量imgs[i].classList.add("active");}
}
这里使用let i
声明,每次循环迭代都创建一个新的块级作用域变量i
。如果改为var i
,则会出现问题,因为回调函数执行时i
的值已经变成了imgs.length
。
三、闭包详解
什么是闭包?
闭包是指函数可以"记住"并访问它定义时的外部变量,即使该函数在其原始作用域之外执行。
简单来说,闭包就是一个函数和它所引用的外部变量环境的组合。
闭包的工作原理
当一个函数在另一个函数内部定义,并且内部函数引用了外部函数的变量时,内部函数就会形成闭包,即使外部函数已经执行完毕。
function createCounter() {let count = 0; // 外部函数的变量return function() { // 内部函数形成闭包return ++count; // 引用外部函数的变量};
}const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
案例中的闭包分析
在我们的图片预览案例中,每个鼠标悬停事件的回调函数都形成了闭包:
for(let i=0; i<imgs.length; i++){imgs[i].onmouseover = ()=>{// 形成闭包,"记住"了创建时的i值imgs[i].classList.add("active");var imgSrc = imgs[i].getAttribute("src");displayBox.style.background = `url(${imgSrc}) no-repeat`;}
}
每个回调函数"记住"了它被创建时对应的i
值,这就是闭包的作用。
四、为什么 var 和 let 会产生不同结果?
var的问题:共享同一个变量
如果我们将代码改为使用var
:
for(var i=0; i<imgs.length; i++){imgs[i].onmouseover = ()=>{imgs[i].classList.add("active");// ...}
}
这会导致以下问题:
- 循环结束后,
i
的值为imgs.length
(例如4) - 当用户将鼠标悬停在任何图片上,触发回调函数时
- 回调中访问的
i
都是同一个变量,值为4 imgs[4]
不存在,所以会得到undefined
这段代码会在控制台报错:Cannot read property 'classList' of undefined
let的解决方案:每次迭代创建新变量
使用let
时:
- 每次循环迭代,都会创建一个新的块级作用域变量
i
- 每个回调函数会捕获(形成闭包)它所在迭代的
i
值(0, 1, 2, 3…) - 当回调执行时,使用的是各自捕获的
i
值
这就解释了为什么使用let
可以正确工作,而var
会导致问题。
五、箭头函数与普通函数的影响
在我们讨论的闭包和作用域问题中,使用箭头函数()=>{}
或普通函数function(){}
不会影响结果:
// 两者在闭包行为上相同
for(let i=0; i<imgs.length; i++){// 箭头函数写法imgs[i].onmouseover = () => {imgs[i].classList.add("active");}// 普通函数写法 - 效果相同imgs[i].onmouseover = function() {imgs[i].classList.add("active");}
}
它们的主要区别在于this
的指向,而非闭包特性。
六、最佳实践总结
-
循环中创建回调函数时,使用
let
而非var
for(let i=0; i<arr.length; i++){element.addEventListener('click', ()=>{console.log(i);}); }
-
了解回调执行的时机
回调函数不是在定义时执行,而是在触发事件时执行 -
避免过度使用闭包
闭包会占用内存,过度使用可能导致性能问题 -
合理使用块级作用域
使用let
/const
创建块级作用域,避免变量污染
结语
通过这个图片预览切换案例,我们深入了解了回调函数、作用域和闭包这三个紧密相连的概念。理解它们的关系,是成为高级前端开发者的必经之路。希望这篇文章能帮助你更深入地理解这些概念,提升前端开发技能!
实践练习:
- 尝试将案例中的
let i
改为var i
,看看会发生什么? - 如何使用
forEach
或其他数组方法重写这段代码? - 如何在不使用闭包的情况下实现相同功能?
实践练习答案
1. 将案例中的let i
改为var i
,看看会发生什么?
如果将代码改为:
for(var i=0; i<imgs.length; i++){imgs[i].onmouseover = ()=>{for(let j=0; j<imgs.length; j++){imgs[j].classList.remove("active");}imgs[i].classList.add("active"); // 这里会出错var imgSrc = imgs[i].getAttribute("src");displayBox.style.background = `url(${imgSrc}) no-repeat`;displayBox.style.backgroundSize = `430px 430px`;}
}
结果:
- 当鼠标移到任何图片上时,浏览器会报错:
Cannot read properties of undefined (reading 'classList')
- 原因:循环结束后,
i
的值变为4
(即imgs.length
),而imgs[4]
不存在 - 所有回调函数引用的都是同一个
i
,值为4
,因此imgs[i]
会是undefined
2. 如何使用forEach
或其他数组方法重写这段代码?
onLoad = ()=>{var imgs = document.getElementsByTagName("img");var displayBox = document.getElementsByClassName("display")[0];var img1 = imgs[0];img1.classList.add("active");// 将HTMLCollection转为数组,然后使用forEachArray.from(imgs).forEach((img) => {img.onmouseover = () => {// 清除所有active样式Array.from(imgs).forEach((item) => item.classList.remove("active"));// 为当前图片添加active样式img.classList.add("active");// 更新大图var imgSrc = img.getAttribute("src");displayBox.style.background = `url(${imgSrc}) no-repeat`;displayBox.style.backgroundSize = `430px 430px`;}});
}
这种方法更简洁,不需要使用索引,直接使用当前元素引用,避免了索引问题。
3. 如何在不使用闭包的情况下实现相同功能?
方法一:使用事件委托
onLoad = ()=>{var imgs = document.getElementsByTagName("img");var displayBox = document.getElementsByClassName("display")[0];var img1 = imgs[0];img1.classList.add("active");// 获取ul元素const ul = document.querySelector("ul");// 使用事件委托,将事件监听器添加到父元素ul.addEventListener("mouseover", function(e) {// 检查事件来源是否为img元素if(e.target.tagName === "IMG") {// 移除所有active类for(let j=0; j<imgs.length; j++){imgs[j].classList.remove("active");}// 给当前元素添加active类e.target.classList.add("active");// 更新大图var imgSrc = e.target.getAttribute("src");displayBox.style.background = `url(${imgSrc}) no-repeat`;displayBox.style.backgroundSize = `430px 430px`;}});
}
方法二:使用自定义数据属性
onLoad = ()=>{var imgs = document.getElementsByTagName("img");var displayBox = document.getElementsByClassName("display")[0];var img1 = imgs[0];img1.classList.add("active");for(let i=0; i<imgs.length; i++){imgs[i].onmouseover = function() {// 使用this引用当前元素,而不是通过闭包捕获的ifor(let j=0; j<imgs.length; j++){imgs[j].classList.remove("active");}this.classList.add("active");var imgSrc = this.getAttribute("src");displayBox.style.background = `url(${imgSrc}) no-repeat`;displayBox.style.backgroundSize = `430px 430px`;}}
}
这两种方法都避免了依赖闭包捕获循环变量,前者通过事件委托减少了事件监听器的数量,后者通过使用this
关键字直接引用当前元素。