重点内容提要:JS 函数的执行时机
1)为什么如下代码会打印 6 个 6
|
|
我认为本篇正文涉及到的知识(作用域、闭包的定义、 调用时机、调用栈等)不足以解释
任何试图解释这个现象的尝试目前都是徒劳
所以我直接搜了并总结了各类答案:setTimeout函数在队列任务执行完后执行
console.log(i),在此之前,for的判断表达式与自增语句就早已全部走完,i值为6
|
|
可能至少但不限于先要了解的知识,如下
提供了几个思考的维度
以后慢慢填坑,还瞥见了Promise、V8
先交次作业再说
2)让上面代码打印 0、1、2、3、4、5 的方法
- ES6方法
利用
for let配合
最简洁的办法,最省脑子,最易上手,代码量最少,工作效率
1 2 3 4 5for(let i = 0; i<6; i++){ setTimeout(()=>{ console.log(i) },0) }
利用 const 声明常量
|
|
3)可以打印出 0、1、2、3、4、5的方法汇总
试图让变量
i执行看起来“符合人们的对逻辑(的幻想)”
1.利用 ES 6 方法
具体代码实现见上文
for循环头部的let声明还会有一个特殊的行为。- 这个行为指出变量在循环过程中不止被声明一次
- 每次迭代都会重新声明一次
i,然后保存在,然后销毁 - 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
之后全是
ES6前的方法,所以原本想只用var,但是考虑到var可能引起的问题,还是老实地用let两者都可通过在浏览器控制台打出代码验证有效
以下都是变通方法,将要要消耗更多资源,见翻译不完整的MDN window.setTimeout
2.用闭包与自执行函数
|
|
使用匿名函数包裹你的回调函数
利用函数创建一个局部变量,保留了调用时的局部变量
i
ES6引入更简洁的箭头函数,可完全代替这里的所有匿名函数箭头函数 声明的匿名函数
|
|
3.利用 bind 函数
|
|
- 如果
bind方法的第一个参数是null或undefined - 等于将
this绑定到全局对象 - 函数运行时
this指向顶层对象(浏览器为window) - bind 还可以接受更多的参数,将这些参数绑定原函数的参数
4.利用 setTimeout 的第三个参数
|
|
setTimeout函数的第三个参数及以后的参数,都可以作为传入setTimeout的匿名函数函数的参数
其他参考文章与相关文章
Event Loop、计时器、nextTick By 方应杭
JSConfEU的一个油管视频解释了一部分原理
正文开始
结构看起来不像对象,但函数,就只是一种内置对象
四种方法定义一个函数
基本语法
|
|
定义匿名函数
- 上面的具名函数,去掉函数名就是匿名函数
- 而
let a = function(x,y){return x+y} 也叫函数表达式,注意函数表达式是指
=右边的部分1 2 3 4 5 6 7 8 9 10 11function fn(x,y){ return x+y } let a = function(x,y){ return x+y } let a1 = function fn1(x,y){ return x+y } fn1() // 报错
函数表达式中,如果函数
fn()在等号右边,那么fn()的作用域就只在等号右边,超过函数体,fn()就不存在,就只能用a1()没有等于号,作用域就在当前作用域的全局下
用ES6箭头函数
箭头函数,箭头左边就是输入参数,右边就是输出参数
两个或两个以上输入或输出,用括号括起来,而且有返回值的话,不能省略
return箭头右边有两个或两个以上语句,则要用花括号括起来
|
|
箭头函数字节返回对象会出错,需要加个圆括号
JS中,
{}优先被当做代码块,即{当做块的起始
|
|
用构造函数
|
|
- 基本没人用,但是能让你知道函数时谁构造的
- 所有函数都是
Function构造出来的,是JS世界的创世神 - 包括
Object、Array、Function自身也是
函数自身 V.S. 函数调用
fn V.S. fn()
函数自身
代码
|
|
结果
- 不会有任何结果
- 因为
fn没有执行
函数调用
代码
|
|
结果
- 打印出
hi - 有圆括号才是调用,即执行
fn()
再进一步
fn和fn()本质
|
|
结果
let fn = () =>({})表示fn保存了匿名函数的地址let fn2 = fn表示这个地址被赋值给了fn2fn2()调用了匿名函数fn和fn2都是匿名函数的引用而已- 真正的函数既不是
fn也不是fn2,而是存在内存中的代码() => console.log('hi')
函数的要素
每个函数都有这些独特的属性
- 调用时机
- 作用域
- 闭包
- 形式参数
- 返回值
- 调用栈
- 函数提升
arguments(除了箭头函数没有)this(除了箭头函数没有)
下面开始逐一初步解释
执行时机
函数调用时机不同,结果不同,联想刻舟求剑
例1:
|
|
问打印出多少
答:不打印,因为没有调用代码
例2:
|
|
问打印出多少
答:打印1
例3:
|
|
问打印出多少
答:打印2
例4
1 2 3 4 5 6let a = 1 function fn(){ console.log(a) } fn() a = 2
问打印出多少
答:打印1
例5
1 2 3 4 5 6 7 8 9 10 11 12let a = 1 function fn(){ setTimeout(()=>{ console.log(a) },0) // 在一段时间后,执行,多少时间根本不是关键 // 而是setTimeout()里的代码的执行时机 // 要等后续代码执行完后,再执行setTimeout() // 期间setTimeout()里的所有变量都受到后续代码的影响 } fn() a = 2 // 打印的时机是在 a = 2 之后
问打印出多少
答:打印2
例6
1 2 3 4 5 6let i = 0 for(i = 0; i < 6; i++){ setTimeout(()=>{ console.log(i) },0) } // 先执行完setTimeout()外的代码,这里是for循环,最终 i = 6,之后setTimeout()执行6次
问打印出多少
答:打印6个6,而不是0、1、2、3、4、5
例6
1 2 3 4 5for(let i = 0; i < 6; i++){ setTimeout(()=>{ console.log(i) },0) }
问打印出多少
答:打印0、1、2、3、4、5
- 因为JS在
for和let一起使用的时候会加料 - 每次循环多创建一个
i,留在setTimeout()里,保留i的值,不跟随新的i值变化 - 即生成6个新的
i,加上原本的初始,一共有7个i 刻舟求剑实现啦
1 2 3 4 5 6 7 8 9 10 11 12 13var i = 0 function print() { // 用函数作用域包裹 var p = i console.log('p: '+p, 'i: '+i) setTimeout(() => { console.log(p) },0) console.log('p: '+p, 'i: '+i) } for (i = 0; i < 6; i++){ console.log('i: '+i) print(i) }
每次求值、取值的时候,都要想想执行时机,代码顺序,值可能是以前的,错的或是非预期的,可用
console.log确认
|
|
使用闭包 自执行函数
作用域
每个函数都会默认创建一个作用域
例1:
|
|
问:是不是因为
fn没执行导致答:就算
fn执行了,也访问不到作用域里面的变量a
fn执行完了,作用域内的变量就被JS垃圾回收机制干掉了
例2:
|
|
全局变量 V.S. 局部变量
|
|
1.在顶级作用域声明的变量是全局变量
2.
window的属性是全局变量,不管你在什么地方定义变量,挂在window上的自动提升为全局变量
|
|
- 包括
window.Object、window.Array、window.Function、window.parseInt()等都是全局变量,哪里都可以用
除了这两种情况,其他都是局部变量
函数可嵌套,作用域也可嵌套
例3:
|
|
作用域规则
如果多个作用域有同名变量
a
- 查找
a的声明时,向上取最近的作用域,简称「最近原则」 - 干掉(无视)此作用域中嵌套的作用域
- 查找
a的过程与函数执行无关 - 但
a的值与函数执行有关
例4:
|
|
和函数执行无关的作用域,成为静态作用域,也叫词法作用域,JS目前(2020)只有这一类作用域,涉及到编译原理的知识
闭包
上文已经包含了「闭包」(Closure),变量
a和函数f3形成了闭包JS函数会就近寻找“最近的”变量,这就是闭包~
|
|
问:什么是闭包
答:如果一个函数用到了外部的变量,那么,这个函数加上,这个变量,就叫做「闭包」
作用以后介绍,现在只说定义
- 「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包。
闭包的用途
看这篇文章
举例:下例中,闭包在哪
|
|
|
|
形式参数
形式参数就是非实际参数
|
|
- 其中
x和y就是形参,因为并不是实际的参数(指定了调用时传入函数的参数的顺序) - 调用
add(1,2)时,1和2就是实际参数,会被分别按次序赋值给x和y
形参的本质是变量声明
|
|
形参只是语法糖
形参可多可少,只是给参数取名字,比较随意,形参类型无语法层面上的限制,和TypeScipt不同
“值传递”和“地址传递”?
|
|
返回值
每个函数都有返回值
|
|
没写
return,所以返回值是undefined
|
|
返回值为
console.log('hi')的值,console.log()返回值仍为undefined,打印值和返回值不同
- 只有函数执行完了后才会有返回值
- 不执行,就不存在返回值
- 只有函数有返回值
1+2的返回值为3
小结
作用域:就近原则 & 闭包
调用栈
函数执行时要进入一另个环境,执行完,再返回回来
有时函数执行,进入嵌套函数,返回路径是怎样
调用栈就是记录函数执行完后,回去到哪里
什么是调用栈(Call stack)
- JS引擎在调用一个函数前
- 需要把函数所在的环境
push到一个数组里 - 这个数组叫做调用栈
- 等函数执行完了,就会把环境
pop出来 - 然后
return到之前的环境,继续执行后续代码
举例
|
|
frankFang的理解
执行
console.log(1)的过程:进入log函数,压入栈,调用栈记录回到第1行(地址)->打印1->弹栈,读取调用栈->回到第1行执行
console.log('1+2的结果为' + add(1,2))的过程:压栈,调用栈记录回到第2行/21列->执行add(1+2)->压栈,计算结果3->弹栈,回2/21->得到字符串'1+2'的结果为3->进入console.log函数,压栈,调用栈记录回到第2行->打印字符串'1+2'的结果为3->弹栈,读取调用栈->回到第2行
- 每次调用函数,都要记录一次返回地址,写到调用栈里,如果还要进入嵌套的函数,调用栈再次记录里一个返回地址,等函数执行完,就弹栈,回去记录的返回地址
- 1.函数将要执行
- 2.压栈(记录执行的函数到调用栈)
- 3.执行函数体代码,执行完了
- 4.弹栈(删除调用栈中的函数)
- 如果有嵌套的函数,重复执行
1234
递归函数
使用递归函数有可能把栈压满,即爆栈
阶乘
1 2 3function f(n){ return n !== 1 ? n*f(n-1) : 1 }
理解递归
f(4)
= 4 * f(3)
= 4 * (3 * f(2)
= 4 * (3 * (2 * f(1)))
= 4 * (3 * (2 * (1)))
= 4 * (3 * (2))
= 4 * (6)
= 24
- 递归就是:先递进,再回归
递归函数的调用栈
- 递归函数而调用栈很长
画出阶乘(6)的调用栈
压6次栈,弹6次栈(递进6次,回归6次)
调用栈最长有多少
1 2 3 4 5 6 7 8function computeMaxCallStackSize(){ try{ return 1 + computeMaxCallStackSize(); } catch (e){ //报错说明stack overflow了 return 1; } }
Chrome 12578
Firefox 26773
Node 12536
Safari 31713
爆栈:如果调用栈中压入的帧过多,程序就会崩溃
函数提升
什么是函数提升
function fn(){}- 不管吧具名函数声明在哪里,都会提升到第一行
什么不是函数提升
let fn = function(){}函数表达式是赋值,右面的函数声明不会提升
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17add() //fine function add(){} let add1 = 1 //let 不允许重复声明 function add1(){} //SyntaxError: redeclaration of let add1 ,声明提前,和let声明的add1重复 var add2 = 1 //var 允许重复声明,所以有风险,不可用 function add2(){} var add3 function add3(){} //>add3 <f add3(){} fucntion add4(){} var add4 = 1 //>add3 <1 add5() let add5 = function(){} //报错抛弃
var,用let
arguments和this(除了箭头函数没有)
代码
|
|
如何传
arguments
- 包含所有参数的伪数组
- 调用
fn即可传值给arguments fn(1,2,3),那么arguments就是[1,2,3]的伪数组- 没有JS数组的方法,
__proto__的构造函数是Object(原型链直接指向Object.prototype的地址) - 用
Array.from(arguments)变为真·数组
代码
|
|
如何传
this
- 目前可以用
fn.call('xxx',1,2,3)传this和arguments,其中xxx,就是传给this的值,后面的参数会被传给arguments - 而且
xxx会被自动转化(封装)成对象,即包装对象,例如Number {1}、String {''} - 除非在
fn代码块顶部写一句use strict,但会带来许多其他问题 - 这是JS的糟粕之一
this默认指向window(控制台打出的是Window),一般不需要
this是隐藏参数,arguments是普通参数
this是参数,个人观点假设没有
this
|
|
分析
- 我们可以用直接赋值(保存了对象地址)的变量获取
name属性 - 我们可以吧这种办法简称为引用
问题一
1 2 3 4 5 6 7let sayHi = function(){ console.log(`您好,我叫` + /*person.*/name) } let person = { name: 'frank', 'sayHi': sayHi }
分析
- 用函数表达式,变量名未知
person如果改名,sayHi函数就挂了sayHi函数甚至有可能在另一个文件里面- 所以不希望
sayHi函数里出现person引用
问题二
|
|
分析
- 这里只有类,还没创建对象,所以不可能获取对象的引用
- 那么如何拿到对象的
name属性?
需要有一种办法拿到对象,这样才能获取对象的
name属性
- 一种土办法:用参数,用形式参数
对象
|
|
类
|
|
Python就用了
|
|
特点
- 每个函数都接受一个额外的
self - 这个
self就是传入的新对象 - 只不过
Python会偷偷帮你传对象 Python中person.sayHi()等价于person.sayHi(person)Python中person就被传给self了
解决的问题是:怎么得到未来要创建的新对象的引用
JS没有模仿
Python的思路,走了另一条更难理解的思路JS在每个函数里加了
this
用
this获取那个未被创建出,还不知道名字的对象1 2 3 4 5 6let person = { name: 'frank', sayHi(/* this */){ //JS 也自动把this 传给函数sayHi console.log(`你好,我叫` + this.name) } //淘宝号的this 接受了person }
分析
JS中person.sayHi()相当于person.sayHi(person)- 然后
person被自动传给this了(person是个地址) - 这样,每个函数都能用
this来获取一个 未知对象的引用 了
person.sayHi()会隐式地把person作为this传给sayHi方便
sayHi获取person对应的对象
this小结
- 想让函数获取未知对象的引用
- 但是并不想通过变量名做到,变量名未知
Python通过额外地self参数做到JS通过额外的this做到
1.
person.sayHi()会把person自动传给sayHi2.
sayHi可以通过this引用person
其他
- 注意
person.sayHi和person.sayHi()的区别 - 注意
person.sayHi()的断句(person.sayHi)()
class也一样
|
|
this就是最终调用sayHi()的对象
这就引出另一个问题
到底哪个对
1 2 3 4 5 6 7 8let person = { name: 'frank', sayHi( /* this */ ){ console.log(`你好,我叫` + this.name) } } person.sayHi() person.sayHi(person)省略形式反而对了,完整形式是错的
传到哪里了
person.sayHi()对,JS怎么解决这种不和谐
- 提供两种调用模式
函数两种调用模式
小白调用法
会自动把
person传到函数里
|
|
- 会自动把
person传到函数里,作为this
老油条调用法
需要手动吧
person传到函数里,作为this1person.sayHi.call(person)
该学那种?默认用第二种
|
|
- 为了记忆,之后调用函数都用
object.fn.call()写法
例1
未用到
this
|
|
为什么要多写一个
undefined
- 因为第一个参数要作为
this - 但是代码里没有
this - 所以只能用
undefined占位 - 其实用
null也可以
例2
|
|
arr.forEach2.call(arr)等价于arr.forEach2()
完整版模拟
forEach()
|
|
this是什么
- 一回答就错了,还没有传值,只有在
call(xxx)的时候才能确定,所以不知道this是什么 - 由于大家使用
forEach2的时候总是会用arr.forEach2 - 所以
arr就被自动传给forEach2,作为this
this一定是数组吗
- 不一定,比如
Array.prototype.forEach.call({0: '1', 1: 'b',length:2})这样的伪数组 this只是一个可以任意指定的参数而已1 2[].forEach2.call({0:'a',length:1},(item)=>console.log(item)) [].forEach2.call([{},[],'',NaN,undefined,null],(item)=>console.log(item))
this的两种使用方法
隐式传递
1 2 3 4 5 6fn(1,2) //等价于 fn.call(undefined,1,2) fn.call(undefined,1,2) /* 如果是对象的属性的函数,this就传此对象的此属性 */ obj.child.fn(1) //等价于 obj.child.fn.call(obj.child,1) obj.child.fn.call(obj.child,1)
不管用什么方式调用函数,都传递一个this对应的值
问题在于,是否知道传的this是什么
显示传递
1 2fn.call(undefined,1,2) fn.apply(undefined,[1,2]) //参数要用数组的形式传值
强制绑定this
如果不能确定this到底是什么
可以使用
.bind可以让this不被改变1 2 3 4 5 6function f1(p1,p2){ console.log(this,p1,p2) } let f2 = f1.bind({name: 'frank'}) // f2就是 f1 绑定了 this 之后的新函数 f2() // 调用f2,就等价于 f1.call({name: 'frank'}) f1.call({name: 'frank'}) //f2是f1强制绑定了参数{name: 'frank'}的版本.bind还可以绑定其他参数1 2 3 4 5 6 7function f1(p1,p2){ console.log(this,p1,p2) } let f3 = f1.bind({name: 'frank'}, 'hi') // this是{name: 'frank'},第一个参数是'hi',已经绑死了 f3() //等价于 f1.call({name: 'frank'}, 'hi') f3('fuck') // Object { name: "frank" } hi fuck //之后传入的形式参数就从第二个开始传了
箭头函数
没有
arguments和this
- 箭头函数的this就是普通变量
里面的
this就是外面的this1 2 3 4 5 6 7 8 9 10console.log(this) //window console.log(this === window) //true let fn = () => console.log(this) let b = 1 let fn = () => console.log(b) console.log(this) let fn2 = ()=> console.log(this) fn2()就算你加
call或者bind都没有1 2 3 4 5 6 7let fn = () => console.log(this) fn.call({name: 'frank'}) //window fn() //window fn.call(1) //window let fn3 = ()=> console.log(arguments) fn3(1,2,3)
总结
每个函数都有这些独特的属性
- 调用时机
- 作用域
- 闭包
- 形式参数
- 返回值
- 调用栈
- 函数提升
arguments(除了箭头函数没有)this(除了箭头函数没有)
立即执行函数
只有JS有的变态玩法,ES6后用得少
原理
- ES5时代,为了得到局部变量,必须引入一个函数
- 但是这个函数如果有名字,就声明了一个全局函数,得不偿失
- 于是这个函数必须是匿名函数
- 声明匿名函数,然后立即加个
()执行它 - 但JS标准认为这种语法不合法
- 最终发现,只要在匿名函数前加个运算符即可
!、~、()、+、-都可以- 往上走
只用
!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38var a = 1 // 全局变量 function fn(){ // 全局函数 var a = 2 // 局部变量 console.log(a) } console.log(a) fn() /* 声明一个匿名函数 直接调用 */ function(){ var a = 2 console.log(a) } () //报错 !function(){ var a = 2 // 局部作用域 console.log(a) } () //true 不报错 虽然返回了一个不需要的值 /* 带来的问题 */ console.log('hi') // undefined (function(){console.log('hi')})() // JS里回车没有意义,相当于 undefined(function(){console.log('hi')})() /* 除非加个“;” ,也是JS里少数几个必须加分号的地方 还有 `([]) !== ([]);({}) !== ({});`等设计圆括号的 */ console.log('hi') ;(function(){ var a = 2 // 局部作用域 console.log(a) } )() /* ES6 局部变量 */ { let a = 3 console.log(a) } console.log(a)
关于 this
这篇Blog初步讲了 this,不过相信 this 作为 JS 的第二座大山,不是那么容易能理解的。
前期以记忆为主,后面几节才有可能真正理解 this。
从下节开始,所有函数调用使用 call 的形式,直到理解 this
参考文章
- JS 函数.pdf
- 闭包的作用
- 异步与JS单线程模型
- window.setTimeout
- 事件循环与任务队列
- JS引擎的垃圾回收机制
- MDN 合作异步JavaScript: 超时和间隔
- 网道 / WangDoc.com 异步操作概述
- 从setTimeout和Promise比较js执行机制
- JS setTimeout 问题 贺师俊的回答
- Event Loop、计时器、nextTick By 方应杭
- Call stack(调用栈)MDN的解释
相关文章
- 无