之前工作中碰到一个需求,需要根据从后台获取到的图片路径获得这些图片的 base64 文件。实现过程中遇到一个问题,代码如下:
var src=["http://www.w3school.com.cn/i/site_photoref.jpg",
"http://www.w3school.com.cn/i/site_photoexa.jpg",
"http://www.w3school.com.cn/i/site_photoqe.jpg"]
for(var i=0;i<3;i++){
var img=new Image();
img.src=src[i];
img.onload=function(){
console.log(img)
//最终打印出来都是最后一个图片
//<img src="http://www.w3school.com.cn/i/site_photoqe.jpg">
//<img src="http://www.w3school.com.cn/i/site_photoqe.jpg">
//<img src="http://www.w3school.com.cn/i/site_photoqe.jpg">
}
}
这个问题其实跟之前经常碰到的一个面试题本质上是一致的。我们可以在上面函数中打印索引值,会发现打印出来的值都是3。经过 segmentfault 上网友的点拨,这个问题涉及到 js 中的两个问题,作用域链和事件执行机制。
作用域链
在 js 中,每个函数都有自己的执行环境,每个执行环境都有一个与之关联的变量对象。当代码在一个环境中执行时,会创建变量对象的一个作用域链。每个作用域链的起点都是当前执行代码所在的执行环境的变量对象,作用域链的下一个变量对象来自包含环境,一直延续到全局执行环境(浏览器中是指 window 对象)。
如果执行环境是函数,那么它的变量对象就包括活动对象,活动对象在一开始只包括 arguments 对象(函数的参数对象)。
当执行环境中要用到某个变量或者函数时,会从自己作用域链的起点也就是自己的变量对象中开始搜索相应的变量名或者函数名,如果搜索不到就接着在作用域链的上一级搜索,一直到找到相关变量名或者到作用域链的末尾为止。
js 事件执行机制
js 是一种单线程语言,在主线程中同一时间只能执行一个任务。
浏览器内核线程
浏览器内核是多线程的,通常包含以下线程:
GUI 渲染线程:
负责渲染网页,当页面需要重绘时,该线程就会执行。JavaScript 引擎线程:
也就是 JS 内核,负责解析和运行 JS 代码。定时器触发器线程:
通过这个线程计时来确定什么时候触发定时器。事件触发线程:
监控某个事件是否触发,事件触发之后会被添加到任务队列中。异步 HTTP 请求线程:
监控 AJAX 的状态变更时,就会把相应的任务添加到任务队列中。
同步和异步
js 中每个任务的操作可以简化为发起调用和获得结果两步,根据这两步可以把js 中的任务可以分为同步任务和异步任务。所有任务的执行都在主线程进行。
同步任务:发起调用之后,立即就会执行来获取结果的任务。调用之后会一直等待直到返回结果,在这期间主线程不能进行其他操作。
异步任务:发起调用之后,并不会立即执行相关函数,而是需要额外的操作满足相关条件之后进行触发。相关任务被触发之后会进入任务队列等待主线程任务执行完成后按顺序进入主线程,调用和执行之间的时间可以介入其他异步任务。常见的异步任务有定时器、ajax和事件回调等。
事件循环机制(event loop)
js 中事件执行基本按照下面这三步进行循环。
主线程先按照代码顺序执行同步任务
在异步任务被注册之后,浏览器的其他线程(事件触发线程、定时器触发线程、异步 HTTP 请求线程)监控异步任务的触发条件,按照触发顺序把这些异步任务放在任务队列中
主线程上同步任务执行完之后,会依次执行任务队列中的任务
回到开头
在最上面的例子中,for 循环是同步任务,会立即执行,图片的 onload 事件是异步任务,需要等另行触发。这个例子中代码的执行顺序是这样:
同步任务:循环创建三张图片,每个图片赋予各自的 src 值,并且都注册了一个 onload 事件。
异步任务:三张图片的 onload 事件依次触发,回调函数进入任务队列,等主线程的 for 循环执行完毕之后,依次执行这三个任务。
当开始执行异步任务时,每个函数都需要用到 img 这个变量,就开始在自己的作用域链上开始寻找 img,自身变量对象中不存在,接着在包含环境中找到,由于 for 循环并不会创造一个新的执行环境,所以这个例子中包含环境其实就是全局执行环境。而在 for 循环完之后,img 变量的值已经经过两次覆盖变成了最后一个索引对应的图片。所以每个图片的 onload 函数都会打印出同一个 img 。打印 i 值出现的结果也是一样。
解决办法
弄清楚出现这个问题的原因,解决这个问题可以用下面的办法:
方法1:创建单独的执行环境
for(var i=0;i<3;i++){
(function(index){
var img=new Image();
img.src=src[i];
img.onload=function(){
console.log(index)
console.log(img)
}
})(i)
}
这个方法实现的原理是:for 循环中立即执行函数每次都会创建一个新的执行环境,三张图片的 onload 事件函数的作用域链的包含环境分别是这三个立即执行函数,这三个立即执行函数里面保存的是不同的 img 变量和不同的参数 index,当三个 onload 回调函数执行时,分别在自己的作用域链上寻找各自对应的 img 变量。利用同样的原理,也可以写成这样:
for(var i=0;i<3;i++){
var img=new Image();
img.src=src[i];
img.onload=function(index,img){
return function(){
console.log(index)
console.log(img)
}
}(i,img)
}
也可以不通过传参,而是在立即执行函数内部创建一个变量来接收每次循环中的 i 值,原理都是一样的。
方法2:访问事件触发节点
for(var i=0;i<3;i++){
var img=new Image();
img.src=src[i];
img.onload=function(){
console.log(this)
}
}
这个方法的原理是:函数内部在执行过程中会有一个默认的 this 变量会把函数的调用对象保存起来,通过函数内部的 this 就可以访问调用函数的对象。或者可以通过 event 事件对象的 currentTarget 属性访问到事件触发节点,原理是一样的。
方法3:ES6 的新语法 let
for(let i=0;i<3;i++){
let img=new Image();
img.src=src[i];
img.onload=function(){
console.log(img)
console.log(i)
}
}
在 ES6 中规定了一个新的变量声明命令 let,let 会创建一个块级作用域,用 let 声明的变量只在 let 所在的代码块中有效。这个例子中,三次循环会创建三个块级作用域,每个块级作用域中有各自的变量 i 和 img,互相独立,每个 onload 回调函数执行时都会获取各自代码块中的 i 和 img ,最终能实现我们想要的结果。