重点内容提要:JS 函数的执行时机

1)为什么如下代码会打印 6 个 6

1
2
3
4
5
6
let i = 0
for(i = 0; i<6; i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}

我认为本篇正文涉及到的知识(作用域、闭包的定义、 调用时机、调用栈等)不足以解释

任何试图解释这个现象的尝试目前都是徒劳

所以我直接搜了并总结了各类答案:setTimeout函数在队列任务执行完后执行console.log(i),在此之前,for的判断表达式与自增语句就早已全部走完,i值为6

1
2
3
4
5
6
7
8
9
let i = 0
for(i = 0; i < 6;console.log('自增语句前的i:' + i) || i++ && console.log('自增语句后的i:' + i)){
  console.log('循环体中setTimeOut()前的i:' + i) // 0、1、2、3、4、5
  setTimeout(()=>{
    console.log('setTimeOut延时打印出的i:' + i) // 6个6
  },0);
  console.log('循环体中setTimeOut()后的i:' + i) // 0、1、2、3、4、5
}
console.log('循环外打印出的i:' + i) // 6个6

可能至少但不限于先要了解的知识,如下

提供了几个思考的维度

以后慢慢填坑,还瞥见了Promise、V8

先交次作业再说

2)让上面代码打印 0、1、2、3、4、5 的方法

  • ES6方法

利用 for let 配合

  • 最简洁的办法,最省脑子,最易上手,代码量最少,工作效率

    1
    2
    3
    4
    5
    
    for(let i = 0; i<6; i++){
    setTimeout(()=>{
    console.log(i)
    },0)
    }
    

利用 const 声明常量

1
2
3
4
5
6
7
let i
for(i = 0; i<6; i++){
    const x = i
    setTimeout(()=>{
      console.log(x)
    })
}

3)可以打印出 0、1、2、3、4、5的方法汇总

试图让变量 i 执行看起来“符合人们的对逻辑(的幻想)”

1.利用 ES 6 方法

具体代码实现见上文

  • for 循环头部的 let 声明还会有一个特殊的行为。
  • 这个行为指出变量在循环过程中不止被声明一次
  • 每次迭代都会重新声明一次i,然后保存在,然后销毁
  • 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

之后全是ES6前的方法,所以原本想只用var,但是考虑到var可能引起的问题,还是老实地用let

两者都可通过在浏览器控制台打出代码验证有效

以下都是变通方法,将要要消耗更多资源,见翻译不完整的MDN window.setTimeout

2.用闭包与自执行函数

1
2
3
4
5
6
7
8
let i = 0
for(i = 0;i<6;i ++) {
    (function(i){
        setTimeout(function() {
            console.log(i)
        }, 0 /* 想慢点就改成 `i * 1000 ` */);
    })(i);
}

使用匿名函数包裹你的回调函数

利用函数创建一个局部变量,保留了调用时的局部变量 i

ES6引入更简洁的箭头函数,可完全代替这里的所有匿名函数

箭头函数 声明的匿名函数

1
2
3
4
5
6
7
8
let i = 0
for(i = 0;i<6;i ++) {
    (function(i){
        setTimeout(( /* 这里不能瞎写i,我试过,传入形参就相当于重新声明,会覆盖掉函数体里的全局变量 */ )=> {
            console.log(i)
        }, 0);
    })(i);
}

3.利用 bind 函数

1
2
3
4
5
6
let i = 0;
for (i = 0; i < 6; i++) {
    setTimeout(function(i) { //这里使用匿名函数,具名函数也可,只是得不偿失
        console.log(i)
    }.bind(null,i), 0 )
}
  • 如果 bind 方法的第一个参数是 nullundefined
  • 等于将 this 绑定到全局对象
  • 函数运行时 this 指向顶层对象(浏览器为 window
  • bind 还可以接受更多的参数,将这些参数绑定原函数的参数

4.利用 setTimeout 的第三个参数

1
2
3
4
5
6
let i = 0;
 for (i = 1; i < 6; i++) {
     setTimeout(function(i) { //这里使用匿名函数
         console.log(i)
     }, 0, i)
 }
  • setTimeout函数的第三个参数及以后的参数,都可以作为传入setTimeout的匿名函数函数的参数

其他参考文章与相关文章

MDN 合作异步JavaScript: 超时和间隔

网道 / WangDoc.com 异步操作概述

从setTimeout和Promise比较js执行机制

JS setTimeout 问题 贺师俊的回答

Event Loop、计时器、nextTick By 方应杭

JSConfEU的一个油管视频解释了一部分原理


正文开始


结构看起来不像对象,但函数,就只是一种内置对象

四种方法定义一个函数

基本语法

1
2
3
4
function 函数名(形式函数1,形式函数n){
    语句
    return 返回值
}

定义匿名函数

  • 上面的具名函数,去掉函数名就是匿名函数
  • let a = function(x,y){return x+y}
  • 也叫函数表达式,注意函数表达式是指=右边的部分

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    function 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

箭头右边有两个或两个以上语句,则要用花括号括起来

1
2
3
4
5
6
7
let f1 = x => x*x
let f2 = (x,y) => x+y //形参的圆括号不可省略,没有返回值的话,花括号可以省,并且必须省略`return`
let f3 = (x,y) => {
    console.log('hi')
    return x+y
} //有返回值,花括号不可省略
let f4 = (x,y) => ({name:x, age:y})

箭头函数字节返回对象会出错,需要加个圆括号

JS中,{}优先被当做代码块,即{当做块的起始

1
2
3
4
5
let f4 = x => {name:x} // 语法错误
let f5 = x => ({name:x}) // 语法对了,就显得有些Zz

f4('frank') // undefined ,JS解释器认为是一个label 标签语句
f5('frank') // {name: "frank"}

用构造函数

1
let ff = new Function('x','y','return x+y') 
  • 基本没人用,但是能让你知道函数时谁构造的
  • 所有函数都是Function构造出来的,是JS世界的创世神
  • 包括ObjectArrayFunction自身也是

函数自身 V.S. 函数调用

fn V.S. fn()

函数自身

代码

1
2
let fn = () => console.log('hi')
fn

结果

  • 不会有任何结果
  • 因为fn没有执行

函数调用

代码

1
2
let fn = () => console.log('hi')
fn()

结果

  • 打印出hi
  • 有圆括号才是调用,即执行fn()

再进一步

fnfn()本质

1
2
3
let fn = () => console.log('hi')
let fn2 = fn
fn2()

结果

  • let fn = () =>({})表示fn保存了匿名函数的地址
  • let fn2 = fn表示这个地址被赋值给了fn2
  • fn2()调用了匿名函数
  • fnfn2都是匿名函数的引用而已
  • 真正的函数既不是fn也不是fn2,而是存在内存中的代码() => console.log('hi')

函数的要素

每个函数都有这些独特的属性

  • 调用时机
  • 作用域
  • 闭包
  • 形式参数
  • 返回值
  • 调用栈
  • 函数提升
  • arguments(除了箭头函数没有)
  • this(除了箭头函数没有)

下面开始逐一初步解释


执行时机

函数调用时机不同,结果不同,联想刻舟求剑

例1:

1
2
3
4
let a = 1
function fn(){
    console.log(a)
}

问打印出多少

答:不打印,因为没有调用代码

例2:

1
2
3
4
5
let a = 1
function fn(){
    console.log(a)
}
fn()

问打印出多少

答:打印1

例3:

1
2
3
4
5
6
let a = 1
function fn(){
    console.log(a)
}
a = 2 //此时,fn()没有执行,不要刻舟求剑,a已经被赋了新的值2
fn()

问打印出多少

答:打印2

  • 例4

    1
    2
    3
    4
    5
    6
    
    let a = 1
    function fn(){
    console.log(a)
    }
    fn()
    a = 2
    

问打印出多少

答:打印1

  • 例5

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    let a = 1
    function fn(){
    setTimeout(()=>{
        console.log(a)
    },0)
    // 在一段时间后,执行,多少时间根本不是关键
    // 而是setTimeout()里的代码的执行时机
    // 要等后续代码执行完后,再执行setTimeout()
    // 期间setTimeout()里的所有变量都受到后续代码的影响
    }
    fn()
    a = 2 // 打印的时机是在 a = 2 之后
    

问打印出多少

答:打印2

  • 例6

    1
    2
    3
    4
    5
    6
    
    let 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
    5
    
    for(let i = 0; i < 6; i++){
    setTimeout(()=>{
        console.log(i)
    },0)
    }
    

问打印出多少

答:打印0、1、2、3、4、5

  • 因为JS在forlet一起使用的时候会加料
  • 每次循环多创建一个i,留在setTimeout()里,保留i的值,不跟随新的i值变化
  • 即生成6个新的i,加上原本的初始,一共有7个i
  • 刻舟求剑实现啦

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    var 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
2
3
4
5
6
7
8
9
let i = 0
function print(i) {
    setTimeout(() => {
        console.log(i)
    },0)
}
for (i = 0; i < 6; i++){
    print(i)
}

使用闭包 自执行函数

作用域

每个函数都会默认创建一个作用域

例1:

1
2
3
4
function fn(){
    let a = 1 // let 的作用域就是离左边最近不成对的 '{',离右边最近不成对的 '}',两者之内的区域
}
console.log(a) //a不存在

问:是不是因为fn没执行导致

答:就算fn执行了,也访问不到作用域里面的变量a

fn执行完了,作用域内的变量就被JS垃圾回收机制干掉了

例2:

1
2
3
4
5
function fn(){
    let a = 1
}
fn()
console.log(a) //a还是不存在

全局变量 V.S. 局部变量

1
2
3
4
5
6
let b = 1
window.c = 2
function f1(){
    console.log(c)
}
f1()

1.在顶级作用域声明的变量是全局变量

2.window的属性是全局变量,不管你在什么地方定义变量,挂在window上的自动提升为全局变量

1
2
3
4
5
6
7
8
9
let b = 1
function f2(){
    window.c = 2
}
f2() //要执行,才能把c挂到window上
function f1(){
    console.log(c)
}
f1() //2  在f1内可以访问到c
  • 包括window.Objectwindow.Arraywindow.Functionwindow.parseInt()等都是全局变量,哪里都可以用

除了这两种情况,其他都是局部变量

函数可嵌套,作用域也可嵌套

例3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function f1(){
    let a = 1
    const b = 0
    function f2(){
        let a = 2
        console.log(a)
    } // f2()
    console.log(a)
    a = 3 //就是唬你的
    f2()
} // f1()
f1() //f1()也是唬你的
console.log('a:' + a)

作用域规则

如果多个作用域有同名变量a

  • 查找a的声明时,向上取最近的作用域,简称「最近原则」
  • 干掉(无视)此作用域中嵌套的作用域
  • 查找a的过程与函数执行无关
  • a的值与函数执行有关

例4:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function f1(){
    let a = 1
    function f2(){
        let a = 2
        function f3(){
            console.log(a)  //向上取最近的作用域,即f2()中,let a = 2
        }
        a = 22
        f3() // 当要确定`a`的值的时候,才与函数执行有关
    }
    console.log(a)
    a = 100
    f2()
}
f1()

和函数执行无关的作用域,成为静态作用域,也叫词法作用域,JS目前(2020)只有这一类作用域,涉及到编译原理的知识

闭包

上文已经包含了「闭包」(Closure),变量a和函数f3形成了闭包

JS函数会就近寻找“最近的”变量,这就是闭包~

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function f1(){
    let a = 1
    function f2(){
====================
        let a = 2
        function f3(){
            console.log(a)  //f3用到了外部的变量a,那么,这个f3加上a,就形成「闭包」
        }
====================
        a = 22
        f3()
    }
    console.log(a)
    a = 100
    f2()
}
f1()

问:什么是闭包

答:如果一个函数用到了外部的变量,那么,这个函数加上,这个变量,就叫做「闭包」

作用以后介绍,现在只说定义

  • 「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包。

闭包的用途

看这篇文章

JS 中的闭包是什么?

举例:下例中,闭包在哪

1
2
3
4
5
6
for (let i = 0; i < 6; i++) {
  setTimeout(() => {
    console.log(i)
  }, 0)
  console.log('我先运行了第' + i + '次 i')
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let i = 0
function doSetTimeout(i) {
  setTimeout(() => {
    console.log(i)
  }, 0)
}
for (i = 0; i < 6; i++) {
  doSetTimeout(i)
  console.log('我先运行了第' + i + '次 i')
}

形式参数

形式参数就是非实际参数

1
2
3
function add(x,y){
    return x+y
}
  • 其中xy就是形参,因为并不是实际的参数(指定了调用时传入函数的参数的顺序)
  • 调用add(1,2)时,12就是实际参数,会被分别按次序赋值给xy

形参的本质是变量声明

1
2
3
4
5
6
//上面的代码近似等价于下面的代码
function add(){
    var x = arguments[0]
    var y = arguments[1]
    return x+y
}

形参只是语法糖

形参可多可少,只是给参数取名字,比较随意,形参类型无语法层面上的限制,和TypeScipt不同

“值传递”和“地址传递”?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function add(x,y){
    return x+y
}
add(1,2) //3

function add2(x){
    return x + arguments[1]
}
add2(1,2)

/* 将内存里Stack保存的地址,传给形参 */
let a = {value:1} //内存里Stack保存的地址
let b = {value:2}
function addObject(x,y){ //内存里Stack保存的地址拷贝给x,y
    x.name = 'xxx' //改变对象的属性,根据内存图全部复制
    return x.value + y.value
}
addObject(a,b)//3

返回值

每个函数都有返回值

1
2
3
4
function hi(){
    console.log('hi')
}
hi()

没写return,所以返回值是undefined

1
2
3
4
function hi(){ // 不执行,就不存在返回值
    return console.log('hi')
}
hi() //undefined

返回值为console.log('hi')的值,console.log()返回值仍为undefined,打印值和返回值不同

  • 只有函数执行完了后才会有返回值
  • 不执行,就不存在返回值
  • 只有函数有返回值
  • 1+2的返回值为3

小结

作用域:就近原则 & 闭包


调用栈

函数执行时要进入一另个环境,执行完,再返回回来

有时函数执行,进入嵌套函数,返回路径是怎样

调用栈就是记录函数执行完后,回去到哪里

什么是调用栈(Call stack)

  • JS引擎在调用一个函数前
  • 需要把函数所在的环境push到一个数组里
  • 这个数组叫做调用栈
  • 等函数执行完了,就会把环境pop出来
  • 然后return到之前的环境,继续执行后续代码

举例

1
2
3
console.log(1)
console.log('1+2的结果为' + add(1,2))
console.log(2)
  • 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行

  • 每次调用函数,都要记录一次返回地址,写到调用栈里,如果还要进入嵌套的函数,调用栈再次记录里一个返回地址,等函数执行完,就弹栈,回去记录的返回地址

Call stack(调用栈)MDN的解释

  • 1.函数将要执行
  • 2.压栈(记录执行的函数到调用栈)
  • 3.执行函数体代码,执行完了
  • 4.弹栈(删除调用栈中的函数)
  • 如果有嵌套的函数,重复执行1234

递归函数

使用递归函数有可能把栈压满,即爆栈

  • 阶乘

    1
    2
    3
    
    function 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
    8
    
    function 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
    17
    
    add() //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


argumentsthis(除了箭头函数没有)

代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function fn(){
    console.log(arguments)
    console.log(this)
    console.log(Array.from(arguments)) //变为真·数组
}
fn(1)
fn(1,'b')

fn.call(1)
fn.call('xxx') //'xxx'会被自动转化(封装)成对象,即包装对象

如何传arguments

  • 包含所有参数的伪数组
  • 调用fn即可传值给arguments
  • fn(1,2,3),那么arguments就是[1,2,3]的伪数组
  • 没有JS数组的方法,__proto__的构造函数是Object(原型链直接指向Object.prototype的地址)
  • Array.from(arguments)变为真·数组

代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function fn(){
    /* 'use strict ' */ //强制 this 传值时 不封装为对象
    console.log('this:')
    console.log(this)
}

fn() //没有其他任何条件,this 默认指向window
fn.call(1) // 返回Number对象
fn.call('xxx') // 返回String对象 //'xxx'会被自动转化(封装)成对象,即包装对象
fn.call(undefined) //默认window

如何传this

  • 目前可以用fn.call('xxx',1,2,3)thisarguments,其中xxx,就是传给this的值,后面的参数会被传给arguments
  • 而且xxx会被自动转化(封装)成对象,即包装对象,例如Number {1}String {''}
  • 除非在fn代码块顶部写一句use strict,但会带来许多其他问题
  • 这是JS的糟粕之一

this默认指向window(控制台打出的是Window),一般不需要

this是隐藏参数,arguments是普通参数

this是参数,个人观点

假设没有this

1
2
3
4
5
6
let person = {
    name: 'You',
    sayFuck(){
        console.log(`您好,我叫` + person.name)
    }
}

分析

  • 我们可以用直接赋值(保存了对象地址)的变量获取name属性
  • 我们可以吧这种办法简称为引用

  • 问题一

    1
    2
    3
    4
    5
    6
    7
    
    let sayHi = function(){
    console.log(`您好,我叫` + /*person.*/name)
    }
    let person = {
    name: 'frank',
    'sayHi': sayHi
    }
    

分析

  • 用函数表达式,变量名未知
  • person如果改名,sayHi函数就挂了
  • sayHi函数甚至有可能在另一个文件里面
  • 所以不希望sayHi函数里出现person引用

问题二

1
2
3
4
5
6
7
8
class Person{
    constructor(name){
        this.name = name //这里的this是new强制指定的
    }
    sayHi(){
        console.log(/* ??? */) //没有实例化,无法引用
    }
}

分析

  • 这里只有类,还没创建对象,所以不可能获取对象的引用
  • 那么如何拿到对象的name属性?

需要有一种办法拿到对象,这样才能获取对象的name属性

  • 一种土办法:用参数,用形式参数

对象

1
2
3
4
5
6
7
let person = {
    name: 'frank',
    sayHi(p){
        console.log(`您好,我叫` + p.name)
    }
}
person.sayHi(person) //丑

1
2
3
4
5
6
7
class Person{
    constructor(name){ this.name = name}
    sayHi(p){
        console.log(`您好,我叫` + p.name)
    }
}
let person = new Person(person) //丑

Python就用了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Person:
    def __init__(self, name): # 构造函数 声明一个self 参数,即未来声明新对象
        self.name = name
    
    # 回车
    
    def sayHi(self):
        print('Hi,I am' + self.name)

person = Person('frank')
person.sayHi() # 自动传参 self

特点

  • 每个函数都接受一个额外的self
  • 这个self就是传入的新对象
  • 只不过Python会偷偷帮你传对象
  • Pythonperson.sayHi()等价于person.sayHi(person)
  • Pythonperson就被传给self

解决的问题是:怎么得到未来要创建的新对象的引用

JS没有模仿Python的思路,走了另一条更难理解的思路

JS在每个函数里加了this

  • this获取那个未被创建出,还不知道名字的对象

    1
    2
    3
    4
    5
    6
    
    let person = {
    name: 'frank',
    sayHi(/* this */){ //JS 也自动把this 传给函数sayHi
        console.log(`你好,我叫` + this.name)
    } //淘宝号的this 接受了person
    }
    

分析

  • JSperson.sayHi()相当于person.sayHi(person)
  • 然后person被自动传给this了(person是个地址)
  • 这样,每个函数都能用this来获取一个 未知对象的引用

person.sayHi()会隐式地把person作为this传给sayHi

方便sayHi获取person对应的对象


this小结

  • 想让函数获取未知对象的引用
  • 但是并不想通过变量名做到,变量名未知
  • Python通过额外地self参数做到
  • JS通过额外的this做到

1.person.sayHi()会把person自动传给sayHi

2.sayHi可以通过this引用person

其他

  • 注意person.sayHiperson.sayHi()的区别
  • 注意person.sayHi()的断句(person.sayHi)()

class也一样

1
2
3
4
5
6
7
class Person{
    sayHi(){
        console.log(this.name)
    }
}
let p = new Person()
p.sayHi() // JS引擎自动把 this = p
  • this就是最终调用sayHi()的对象

这就引出另一个问题

  • 到底哪个对

    1
    2
    3
    4
    5
    6
    7
    8
    
    let person = {
    name: 'frank',
    sayHi( /* this */ ){
        console.log(`你好,我叫` + this.name)
    }
    }
    person.sayHi()
    person.sayHi(person)
    
  • 省略形式反而对了,完整形式是错的

传到哪里了

person.sayHi()对,JS怎么解决这种不和谐

  • 提供两种调用模式

函数两种调用模式

小白调用法

会自动把person传到函数里

1
person.sayHi() //隐藏太多细节,不清不楚
  • 会自动把person传到函数里,作为this

老油条调用法

  • 需要手动吧person传到函数里,作为this

    1
    
    person.sayHi.call(person)
    

该学那种?默认用第二种

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let person = {
    name : 'frank',
    sayHi(){
        console.log(this.name)
    }
}
person.sayHi.call({name:1}) //1 //使得调用变得清晰

person.sayHi.call({name:'Jack'}) //Jack
person.sayHi.call(person) //frank
  • 为了记忆,之后调用函数都用 object.fn.call() 写法

例1

未用到this

1
2
3
4
function add(x,y){
    return x+y
}
add.call(undefined,1,2) // 3

为什么要多写一个undefined

  • 因为第一个参数要作为this
  • 但是代码里没有this
  • 所以只能用undefined占位
  • 其实用null也可以

例2

1
2
3
4
5
6
Array.prototype.forEach2 = function(){
    console.log(this)
}
let arr = [1,2,3]
arr.forEach2.call(arr) //[1,2,3]
arr.forEach2() //[1,2,3]

arr.forEach2.call(arr)等价于arr.forEach2()

完整版模拟forEach()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Array.prototype.forEach2 = function(fn){
    for(let i = 0; i < this.length; i++){
        fn(this[i],i,this)
        console.log('this[i]: ' + this[i])
        console.log('i: ' + i)
        console.log('this: ' + this)
        console.dir(this)
    }
}

let arr = [1,2,3]
arr.forEach2.call(arr,(item)=>console.log(item)) // 手动把arr作为this,手动[滑稽]
/* 等价于 */
arr.forEach((item)=>console.log(item)) // 自动把arr作为this

Array.prototype.forEach.call({0: '1', 1: 'b',length:2})
Array.prototype.forEach2.call({0:'a',length:3},(item)=>console.log(item))
Array.prototype.forEach2.call({0:'a',length:1},(item)=>console.log(item))

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
    6
    
    fn(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
    2
    
    fn.call(undefined,1,2)
    fn.apply(undefined,[1,2]) //参数要用数组的形式传值
    

强制绑定this

如果不能确定this到底是什么

  • 可以使用.bind可以让this不被改变

    1
    2
    3
    4
    5
    6
    
    function 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
    7
    
    function 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 
           //之后传入的形式参数就从第二个开始传了
    

箭头函数

没有argumentsthis

  • 箭头函数的this就是普通变量
  • 里面的this就是外面的this

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    console.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
    7
    
    let 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
    38
    
    var 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


·未完待续·

参考文章

相关文章