JS语言高级-函数

154 阅读28分钟

4. 函数进阶

在前面的ECMAScript的规范组成中,我们知道ES就是JS基本核心语法内置标准库构成。基本语法比如循环、条件判断语句。内置标准库主要是一系列内置对象、内置函数构成。所谓内置就是浏览器和引擎已经内置预备好的一些基础组件,我们可以拿来即用,例如内置对象ArrayMath,内置函数BooleanNumber

对于函数而言,在实际的JS应用开发中,自定义函数是最常见的,因此深入理解函数的特性是非常重要的。首先需要明白一点的就是函数也是特殊的对象。

4.1. 箭头函数

ES6中,新增了箭头函数,即使用胖箭头(=>)来定义函数表达式的能力。通过箭头函数定义实例化的函数对象和普通函数对象的行为是一致的,这也意味着,任何使用普通函数的地方都可以使用箭头函数。当然,新事物和旧事物总归是有差别的,我们将在后续说明,至于为什么要出现箭头函数,主要是消除函数的二义性,这一点也将在后续着重说明。下面来看看普通函数表达式和箭头函数的语法区别。

// 普通函数
let test = function (a,b){
  return a + b
}

// 箭头函数
let test = (a,b) => {
  return a + b
}
let list = [1,2,3,4]

console.log(list.map(function(i){return i + 1})) // [2,3,4,5]
console.log(list.map(i) => { return i + 1}) // [2,3,4,5]

4.2. 箭头函数的语法简写

1️⃣ 省略小括号:只有一个函数的情况下,可以省略小括号。只有无参数或者多个参数时需要小括号。

let test = (x) => { return x + 1} // 完整的箭头函数

let test = x => { return x + 1 } // 省略小括号的箭头函数
let mathNumber = () => { return Math.random() } // 无参数

let result = (a,b) => { return a + b } // 多个参数

2️⃣省略大括号:函数体如果只有一条语句,例如赋值操作,或者一个表达式,那么可以省略大括号,省略即意味着会隐式返回这一条语句的值。如果有多条语句必须使用大括号。

let test = x => x + 1
let a = test(1)
console.log(a) // 2

let obj = {}
let setValue = value => obj.name = value;
setValue('小红')
console.log(obj) // { name:'小红' }
4.3. 箭头函数的差异

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用:

  • arguments
  • super
  • new.target
  • 不能用作构造函数
  • 没有 prototype 属性。
4.4. 函数名

函数在JS中也是对象,而函数名在底层实际上是一个指向函数对象的引用(地址/指针)。就像对象和变量一样的关系,而地址可以存在多个变量中。因此函数也可以有多个名称,通过不同的名称都可以找到这个函数对象。

function test (a,b) {
  return a + b
}
console.log(test(1,3)) // 4

let test2 = test;
console.log(test2(1,3)) // 4

上面这个案例中:

  1. 声明了函数test,参数a,b,函数执行时返回a + b 的值
  2. 紧接着声明test2,将test对应函数对象的引用赋值给了test2
  3. 此时testtest2均指向同一个函数对象

下面,我们做一些修改:

test = null
console.log(test(1,3)) // test is not a function
console.log(test2(1,3)) // 4

在上面代码中,我们将test设置为了null,通过<<数据的使用和传递>>一章中我们知道,对象存在堆中,对象的地址存在栈中。test = null相当于把test栈空间清空了。因此console.log(test(1,3))会报错。如果注释掉此句,console.log(test2(1,3))会输出4,因为let test2 = test,因为test2存储了函数对象的地址。

4.4.1. 普通函数的name属性

JS中,函数对象均会暴露一个只读的name属性,name属性就是函数的标识符,一般都是一个字符串。

function test () {};

let test2 = () => {};
console.log(test.name) // 'test'
console.log(test2.name) // 'test2'

name属性除了得到字符串外,还将得到空字符串anonymous 两种结果,主要是体现在匿名函数

console.log((() => {}).name) // ''
console.log((new Function()).name) // 'anonymous '

同样是匿名函数,name的输出却是不一样的,这是因为引擎解析规则导致的。FunctionJS标准库中的对象,可以通过new操作符动态创建函数对象(非常罕见,几乎不使用)。

  • 对于匿名箭头函数,规范层面就让其name输出空字符串''
  • 而通过Funciton构造的函数,根据ECMA规范,其name属性输出为'anonymous'
4.4.2. 特殊函数的name属性

所谓特殊函数,就是基于函数的一些变异或者特定函数。这里简单说三个。getter/setter语法对应的get/set函数以及bind()方法,bind()后续再讲。

function foo () {}
console.log(foo.bind(null).name) // bound foo

let person = {
  year:1,
  get age () {
    return this.year + 1
  },
  set age (value) {
    this.year = value
  }
}

// 获取属性描述符对象
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age'); 
console.log(propertyDescriptor.get.name); // get age 
console.log(propertyDescriptor.set.name); // set age

通过上面代码知道,其实就是在函数name的前方加上了一些特殊标识,以区分函数的作用。

4.5. 函数参数argument

JS函数的参数和大部分语言都不同,它既不关心传入的参数个数,也不关注参数的数据类型。也就是说我定义了说那个参数,不一定非得传入三个参数,传一个、甚至不传都可以。这主要是因为在ECMA规范中,函数的参数在内部的表现实际上一个数组。函数在调用时总会接收到一个数组,函数不关心数组有多长,包含哪些元素。

在函数内部访问特殊对象-arguments即可得到参数列表。可以通过下标访问元素,也可以通过length访问参数数组长度,但不具备数组的方法,比如push()pop()。因为它是一个类数组。

💁 温馨提示

类数组Array-like Object,其本质是一个对象,它只具有数组一部分特征,比如length下标因此称为类数组。在ECMA中类数组不是数据类型,是一个约定俗称的术语概念,主要是由JS引擎实现。在操作DOM时,我们经常会看到NodeListHTMLCollection之类的数据结构。他们都是类数组,这是引擎为了提高性能以及方便开发者审查而实现的一种特殊的数据结构。我们可以通过一些方法将其转为真正的数组进行数组操作

function test (a,b) {
  console.log(arguments)
  console.log(arguments.length)
  console.log(arguments[0])
  console.log(arguments[1])
}
test(1,3)


arguments表示的是实际上是实参列表,实参就是函数调用时我们实际传入的参数,和形参无关。

function test () {
  console.log(arguments[0]) // undefined
  console.log(arguments[1]) // undefined
}
test()

function sayHi (){
  console.log(arguments[0]) // '小红'
  console.log(arguments[1]) // 30
}
sayHi('小红','30')

如果函数有形参,但是调用函数时没有实际传入参数,那arguments中对应的槽位就是undefined,相当于声明了变量但是没有进行初始化赋值,请看如下代码。

function test (a,b,c){
  console.log(arguments[0]) // 1
  console.log(arguments[1]) // 2
  console.log(arguments[2]) // undefined
}
test(1,2)

💁 温馨提示

在严格模式下,arguments是只读的,但是我们依旧可以修改arguments中每个槽位的值,这不会影响到我们传入的实际参数(对象类型除外)。同时,arguments在非严格模式下是可以整体修改的,在严格模式下会整体修改会报错,当然,这么做并没有实际意义。目前来说,arguments已经不怎么使用了,更多的是ES6rest参数。

4.6. 箭头函数中的参数

在箭头函数中,传递给函数的实际参数,只能通过形参访问,不支持arguments关键字访问。

let test = () => {
  console.log(arguments)
}
test() // arguments is not defined

如果是存在函数嵌套,在作用域的机制下箭头函数可以访问外层函数的arguments,如下:

function test () {
  let bar = () => {
    console.log(arguments[0]) // 5
  }
  bar()
}
foo(5)
4.7. 函数重载-签名

所谓函数重载就是指一个函数有多个版本,编译器或解释器可以根据函数签名(即参数数量、参数类型)的不同从而调用不同版本的方法。对于java/c++这类静态语言来说,它们内建了函数重载机制。通过类、和对象封装不同版本的函数,从而实现不同业务逻辑。在JS中,函数是不支持严格意义上函数重载的。这不是因为技术障碍,而是出于语言的设计思想,最大的原因就是JS是动态语言,因此没有内建函数重载机制。

class Calculator {
    
    // 重载 add 方法(两个整数相加)
    public int add(int a, int b) {
        return a + b;
    }

    // 重载 add 方法(一个整数和一个浮点数相加)
    public double add(int a, double b) {
        return a + b;
    }

    // 重载 add 方法(两个浮点数相加)
    public double add(double a, double b) {
        return a + b;
    }

    // 重载 add 方法(一个整数时,直接加 10)
    public int add(int a) {
        return a + 10;
    }
    
    public static void main(String[] args) {
        Calculator calc = new Calculator();
        
        System.out.println("Sum of 3 and 5 (int + int): " + calc.add(3, 5));          // 调用 add(int, int)
        System.out.println("Sum of 3 and 5.5 (int + double): " + calc.add(3, 5.5));   // 调用 add(int, double)
        System.out.println("Sum of 3.5 and 5.5 (double + double): " + calc.add(3.5, 5.5)); // 调用 add(double, double)
        System.out.println("Sum of 3 (int): " + calc.add(3)); // 调用 add(int)
    }
}

对于JS来说,我们只能通过参数的类型、值结合if语句或者通过arguments中的数据来模拟函数重载,但这不是真正意义上的重载。更多的是像条件判断。

4.8. 默认参数值

所谓参数默认,其实就是在函数调用时,没有传递实际参数,那么形参将使用一个默认值。在ES5之前,想给参数设定默认值只能在函数体内通过手动判断形参的方式来实现。

function test (name) {
  name = (typeof name !== 'undefined') ? name : 'Hery'
  console.log(name)
} 
test('小红') // '小红'
test() // 'Hery'

ES6之后,默认参数就无需手动的判断了,ES6支持显式定义默认参数,如下案例:

function test (name = 'Hery') {
  console.log(name)
}
test('小红') // '小红'
test() // 'Hery'
test(undefined) // 'Hery'

💁 温馨提示

  • 给参数显示传递undefined时,相当于没有传值,参数依旧采用默认值
  • 参数默认值不影响arguments实参列表
  • 默认参数支持原始值和对象值以及函数调用后的返回值
function test (name = 'Hery',age = 20, result = getTotal()) {
  console.log(arguments[1]) // undefined
  console.log(name) // 'Hery'
  console.log(result) // 10
}
function getTotal() {
  return 10;
}
test(undefined) 

默认参数只有在函数调用时,才会进行求值。默认参数是函数时只有在未传递该参数时并且函数调用时才会进行求值。箭头函数也支持默认参数。

4.8.1. 默认参数做作用域和暂时性死区

默认参数实际上是在函数作用域内,按定义顺序进行求值计算的求值的,实际上就像let声明变量,请看伪代码。

function test (name="小红",age=20){
  // 伪代码 name 和 age 在函数内部就像用Let声明变量一样
  let name = '小红'
  let age = 20;
  console.log(name + age) // 小20
}
test()
function test ( firstName="小红",lastName=firstName ) {
  let firstName = '小红' // 伪代码
  let lastName = firstName; // 伪代码
  console.log(firstName) // 小红
  console.log(lastName) // 小红
}
test()

如上面这一段代码,将firstName赋值给了lastName,这是可行的。因为firstNamelastName要先定义,但是反过来就不行,因为在前面提到过let声明的变量有暂时性死区,不能在声明前访问。

function test ( firstName=lastName,lastName="小红" ) {
  console.log(firstName) // 小红
  console.log(lastName) // 小红
}
test() // Uncaught ReferenceError: Cannot access 'lastName' before initialization
4.9. 参数扩展

ES6中,新增了扩展操作符,其作用就是展开数组和对象,因此也可以叫展开操作符,只不过在设计层面是为了扩展现有数据结构,因此取名扩展操作符,下面将展示示例:

const arr = [1,2]
console.log([...arr,3]) //[1,2,3] 
let person = {
  name:'小明',
  age:20
}
let people = {
  ...person,
  address:'成都'
}
console.log(people) // { name:'小明',age:'20',address:'成都' }

通过上面这两个案例,我们可以看出。扩展运算符类似解构赋值一样,可以将数组或者对象中的元素/成员提取出来,从而加入到另一个数据结构中以此达到扩展目的。因此,函数的参数列表也是使用扩展运算符的最佳场景,例如我们要传入一个数组到函数中去,我们通过...展开运算符,可以将数组元素作为单个参数传入函数。

let paramsList = [1,2,3]

function test (a,b,c) {
  console.log(a,b,c) // 1,2,3
  console.log(arguments) // [1,2,3]
}

test(...paramsList)

需要注意的是:

  • arguments始终表示传入的实参,并不知道...
  • 箭头函数也支持扩展操作符
  • 函数参数使用扩展参数,也同时支持默认值
let arr = [1,2]

let test = (a,b,c = 3) => {
  console.log(a + b + c) // 6
}
test(...arr)
4.10. 参数收集

扩展运算符...用于函数形参时,此时的作用就不在是扩展Spread 而是收集rest,收集意思就是指将函数调用时传入的实参,收集在一起,形成一个数组。

function test (..args) {
  console.log(args) // [1,2,3]
}
test(1,2,3)

使用参数收集时,还有一点需要注意,...只能用于形参最后一位,或者唯一形参上。因为JS引擎需要知道哪些是普通参数哪些常规参数,如果不放最后或者唯一形参上,那么引擎是这不知道哪些参数需要收集的,从而引起错误,导致脚本中断执行。此外args不是标准语法,名字自取,最佳实践建议args语义化。

function test (a,b,...args) {
  console.log(a) // 1
  console.log(b) // 2
  console.log(args) // 3,4
}
test (1,2,3,4)

💁 温馨提示

很多开发者对于扩展运算符和函数参数扩展和收集存在混淆。实际上无论是扩展对象或数组以及收集参数,使用的都是扩展运算符。只不过官方没有明确区分收集和展开的概念,而是采用一种统一的语法...来表示两种不同的用途。

在不同上下文中,...呈现的作用是不一样的:

  • 对象、数组、函数形参中使用扩展运算符,均是扩展操作
  • 函数形参使用扩展运算符则是收集形成数组的所用
4.11. 函数声明和函数表达式

函数声明和函数表达式在底层实际上是会被引擎区别对待的。在预编译一章中的,我们已经讲的很清楚了。对于函数声明,引擎会将其整体提升到作用域最顶部。

console.log(sum(10,10)) // 20
function sum (a,b) {
  return a + b
}

通过上面这个案例,第一句console.log()正常输出了,没有报错,这是因为在预编译环节,引擎将函数声明整体提升到了源代码最顶部。因此,实际上console.log(sum(10,10))在执行时,是在函数声明完成后执行的


4.11.1. 函数表达式

函数表达式,其实就如普通变量声明一样,只会提升声明部分,而赋值部分停留在原地。

sum() // 报错
let sum = function () {
  console.log(10)
}

💁 温馨提示

如上面这样创建的函数表达是,也叫匿名函数anonymous funtion,因为funciton关键字后面没有标识符,匿名函数也称兰姆达函数 - Lambda ,这是函数式编程中的一个概念,指的是没有名字的函数,箭头函数就是最为常见的Lambda函数,可以简化我们的函数式开发。

除了匿名函数表达式,还有命名函数表达式,就是在上面案例的基础上,在function关键字后加上名字。实际使用时并无差别。理解函数声明或函数表达式最重要的点就在于预编译的声明提升

4.12. 函数作为值

再次说明,函数也是对象,函数名是相当于函数对象的指针,因为在ECMA规范中。函数名就是变量,因此函数可以用于任何可以使用变量的地方。这也意味着我们可以把函数作为参数传递给另一个函数,并且还可以在一个函数中返回另一个函数。这是函数最为强大的特性,也是JS被称为函数式编程语言的原因之一。


function someFunction (fn,params) {
  return fn(params)
}

function getSum (num) {
  return num + 10
}
console.log(someFunction(getSum,10)) // 20

上面这段代码中,我们定义了someFunction函数,它接收两个参数,第一个参数是一个函数,第二个就是任意参数,在函数内部,返回了函数参数调用后的返回值。然后我们调用someFunction并将getSum函数作为参数传入,并传入一个10getSum调用后返回10 + 10,执行上下文回到someFunction,并再次返回。这里我们可以很好的感受到我们前面讲到的执行上下文和执行栈的工作流程和相关概念。


下面再来看一个相对复杂的案例:

function compareAge (propertyName) {
  return function (object1,object2) {
    let value1 = object1[propertyName]
    let value2 = object2[propertyName]
      if(value1 < value2){
        return -1;
      }else if (value1 > value2) {
        return 1;
      }else {
        return 0
      }
  }
}
let data = [
 {name: "Zachary", age: 28}, 
 {name: "Nicholas", age: 29}
]

data.sort(createComparisonFunction("name")); // 根据name属性进行排序
console.log(data[0].name); // Nicholas

data.sort(createComparisonFunction("age")); // 根据age属性进行排序
console.log(data[0].name); // Zachary

上面这段代码看似很复杂,实际上很简单,我们来拆解一下:

  • 首先定义一个比较函数compareAge,参数为propertyName
  • 比较函数内部犯返回一个匿名函数,该函数接收两个对象,并通过中括号语法取得相应属性的属性值
  • 然后进行比较,小于返回-1,大于返回1,相等返回0
  • 接着定义了一个数组data,包含两个同样结构的对象
  • sort()方法是标准库中数组的一个API,可以接收一个函数,用于执行比较操作,它有自己的规则,后续再说明,这里我们将自定义的比较函数传递给了sort,相当于采用我们自己的规则进行排序比较。
  • data是一个数组,调用sort()后就会进行排序,改规则改变数组元素的位置,因此按照name进行了排序,因此data[0].name输出'Nicholas',字符串也是可以比较的,排序规则后面说明,我们还可以传入age进行排序。

上面这个案例,主要为了进一步演示函数作为值的工作流程。

💁 温馨提示

术语【回调函数】补充:当我们把某个函数作为参数传递到另一个函数中进行调用时,那么这个函数就称为回调函数。在上面第一个例子中,getSum就是回调函数,以为它被作为参数传递给了someFunction函数,并在someFunction中进行了调用。在JS实际的开发场景中,我们会遇见也会抒写非常多的回调函数,因为JS是单线程,在执行异步任务时、定时器等,都依赖回调函数。

4.13. 函数内部的特殊属性

JS中,函数内部有三个特殊的属性:

  • this
  • arguments
  • new.target - ES6新增

这个三个属性均由JS引擎提供,在实际开发中都很重要,其中arguments是旧版中的特性,前面已经讲过,目前基本不怎么使用。最为重要、且最有难度的当属this,因为this涉及JS执行模型和底层环境,因此绝大部分的开发者都是混淆的,这里,我们将着重说明this

4.13.1. this⚠️

在执行上下文一章中,我们深入讨论了代码在底层的运行逻辑。简单回顾一下,JS中代码执行时在底层都会构建一个特殊的对象(上下文对象),最常见的就是全局上下文window局部上下文-函数。这个特殊对象中包含着代码运行时所需的各种数据信息,而this就是其中之一,它是引擎直接提供的,指的是函数执行时所在的环境,在全局层面我们可以理解为有一个无形的初始化函数,在浏览器脚本加载完后就开始执行。

首先,着重强调一点:this的值不是函数定义时决定的,而是函数运行时决定的,换言之this动态绑定的,它的值主要是取决于函数被如何调用。下面我们列出一些常见的this指向规则,不考虑Node环境。

  1. 在全局环境或者普通函数调用时:this指向的是全局上下文window对象
<script>
  console.log(this) // window
  
  function test () {
    console.log(this) // window
  }
  test()

  let test2 = () => {
    console.log(this) // window
  }
  test2() 
</script>

2. 严格模式下,普通函数中的this指向undefined

<script>
 'use strict'
  console.log(this) // window
  
  function test () {
    console.log(this) // undefined
  }
  test()
</script>

3. 在方法调用时:this指向调用该方法的对象。这也是为什么对象中的函数被称为方法的原因,主要是为了和普通函数进行区分,因为他们的this信息在底层时不一样的

<script>
  let person = {
    name:'小红',
    sayName(){
      console.log(this) // { name:'小红',sayName:f }
      console.log(this.name) // '小红'
    }
  }
  person.sayName()
</script>

4. 在构造函数中:this指向被构造函数创建出来的实例

<script>
  function CreatePerson (name,age) {
    this.name = name
    this.age = age
  }
  let p1 = new CreatePerson('小红',20)
  let p2 = new CreatePerson('小明',30)
  console.log(p1.age) // 20
  console.log(p2.age) // 30
</script>

5. 事件处理中的this,一般指向事件源对象

const button = document.querySelector('button');

button.addEventListener('click', function() {
  console.log(this); // `this` 指向触发事件的 DOM 元素(即 button 元素)
});

6. 箭头函数:这里是问题重灾区。箭头函数没有this信息,这是绝大部分开发者在各种技术论坛和文章上看到的。实际上,箭头函数是有this的,道理很简单,任何函数调用都会有执行上下文的创建,只要有执行上下文,那么就有this信息。只不过箭头函数的this比较特殊,它是静态绑定的,所谓静态绑定就是说箭头函数的this指向箭头函数被定义时的所在的上下文,换句话说就是箭头函数会从外部上下文中继承this信息。需要注意的是:外部上下文如果是非箭头函数,那么外部的this依然是动态的。如果外部上下文依然就是箭头函数,那么这个this就有点类似作用域链查找一样,直到找到this

let test = () => {
  console.log(this) // 指向外部的this - window
}
test()

const person = {
  name:'小红',
  sayName(){
    setTimeout(() => {
      console.log(this) // { name:'小红',sayName:f }
      console.log(this.name) // 小红
    },2000)
  }
}
person.sayName()

上面这个示例中,test箭头函数内访问了this,由于test外部上下文是全局作用域,因此访问this指向全局对象windowperson对象中的sayName方法内用了一个定时器延迟1S读取name属性,读取操作是在箭头函数中通过this进行的,而这个匿名箭头函数是在sayName函数中定义的,因此这个匿名箭头函数中访this,实际上访问的是sayName函数被调用时的this,方法调用中的this指向的是对象person


下面再来看一个案例:

'use strict'
function test () {
  console.log(this) // undefined
}

let test2 = () => {
  console.log(this) // window
}

test()
test2()

上面这个案例中,使用严格模式,同样都是普通函数testtest2访问this时得到的结果是不一样的,因为前者是普通函数,按规范,普通函数在严格模式下访问this得到undefined。这样的设计是为了避免指向信息混乱,而箭头函数访问却可以得到window,也印证了箭头函数中的this是在定义时继承的外部上下文的this


再来看一个案例:

 function outer() {
    this.name = 'Outer Function'; // `this` 在此是 `outer` 函数的上下文

    const arrowFunc = () => {
        console.log(this.name); // `this` 在此指向外部 `outer` 函数的 `this`
    };

    arrowFunc(); // 调用箭头函数
}

const obj = { name: 'My Object' };
outer.call(obj); // 使用 call 改变 `outer` 函数中的 `this`
  • 定义一个outer函数,内部再定义了一个arrowFunc函数执行this.name,然后调用arrowFunc
  • 定义一个对象objname属性为My Object
  • 通过call()方法调用outer函数,将obj传入call(用于改变this指向)
  • outer执行,arrowFunc执行最终输出Outer Function

如果outer不传参数直接调用,也会输出Outer Function,区别在于outer如果不传参数直接调用,其内部的this指的是window。加了call()方法再传入obj,那么outer内部的this就是指obj,而箭头函数arrowFunc定义在outer内部,因此arrowFunc中访问的this指向outerthis,也就是对象obj

4.13.2. new.target

前面说到,构造函数有一个非官方的俗称约定的规范,就是首字母大写,然后通过new调用。非官方就是说并非强制的,构造函数实际上就是普通函数。核心点在于是否通过new关键字调用。

  • 如果是普通函数调用,那么new.targetundefined
  • 如果是new调用,那么new.target就是指构造函数
function Test () {
    if(new.target){
        console.log('是new调用')
    }else{
        console.log('不是new调用')
    }
}

Test() // 普通调用 打印不是new调用

let obj = new Test() // new关键字调用 打印-是new调用
4.14. 函数的属性和方法

函数是对象,再次说明,函数是对象。因此函数有属性和方法,例如:

  • name - 通过函数名.name访问,前面已经说明
  • prototype - 这是面向对象的核心
  • length - 函数的形参个数
function sayName(name) { 
 console.log(name); 
}
console.log(sayName.length) // 1
4.14.1. prototype

众所周知,JS是函数式的编程语言,也是面向对象的语言。面向对象是一种编程思想或者叫编程范式,我们将在面向对象中讲解。JS是动态类型的语言,其面向对象是基于原型实现的,其优点是动态性强,可以在运行时改变。而prototype就是原型式OOP的核心。

函数的prototype是保存所有引用类型实例方法的地方,例如toString()valueOf()等,进而让所有实例共享这些方法。

4.14.2. call、apply

call()apply()方法也是定义在prototype上的,作用就是用于改变this指向,这两个方法会以传入的this值来调用函数。


apply(thisArg,argsArray)

  • thisArg
    • 我们指定的this值-表示我们希望函数执行时绑定的对象,可以是null或者undefined,如果是null或者undefined在非严格模式下,this依旧指向window
  • argsArra
    • 是一个数组,表示传递给函数的参数,支持传arguments,无参数时可以传null或者undefined
const obj = { name:"小明" }
function sum (num1,num2) {
  console.log(arguments)
  console.log(num1)
  console.log(num2)
  return num1 + num2
}

function applySum1 (num1,num2) {
  return sum.apply(obj,arguments) // arguments是类数组 可以直接传入
}

function applySum2 (num1,num2) {
  return sum.apply(obj,[num1,num2]) // 显示传递数组
}
console.log(applySum1(10,10)) // 20
console.log(applySum2(20,20)) // 40

上面这个案例中,就是利用apply()改变this信息,上面只是为了演示this改变,随意给了一个obj对象,并没有实际意义,因为我们的数学运算和obj不存在依赖关系,因此即使传nullundefined都可以。

需要注意的是,apply()传递的函数参数是通过数组的形式,但是目标函数,如上面的sum在接收参数时或者说叫定义形参时采用是单个参数,却依旧可以正常执行逻辑。这是因为apply()会将数组中的参数逐个传递给目标函数,而不是整体将数组给到目标函数。


call()方法apply()是一致的,唯一的区别是传参方式的不同,call()方法是逐个传递参数

function sum(num1, num2) { 
 return num1 + num2; 
} 
function callSum(num1, num2) { 
 return sum.call(this, num1, num2); // 这里暂时没有特殊对象,直接传this即可
} 
console.log(callSum(10, 10)); // 20

call()方法和apply()方法最核心的作用就是为了修改this指向:

window.color = 'red'

let myColor = {
  color:'blue'
}

function getColor () {
  console.log(this.color)
}

getColor() // 'red' 因为作为普通函数调用,其this指向全局window
getColor.call(myColor,null) // 'blue' this指向了myColor对象
getColor.apply(myColor,null) // 'blue' this指向了myColor对象

getColor.call(null,null) // 'red' this传空,默认指向window
getColor.call(undefined,null) // 'red' this传空,默认指向window
4.15. 递归

递归是函数调用的一种方式,也可以看做是一种算法。递归本质上就是函数在内部通过名称调用自身。

function test (num) {
  if(num <= 1){
    return 1
  }else{
    return num * test(num  - 1)
  }
}
test(5) // 120

上面这个案例就是经典的递归求阶乘,阶乘就是n与所有小于n的正整数的乘积,数学公式:n! = n * (n-1)!

递归函数一般包含两部分:

  1. 递归结束条件:当满足这个条件时,函数停止递归,返回结果
  2. 递归调用:寻找规律,函数通过自调用来处理有规律性的问题

理解递归是非常有难度的,主要是体现在执行栈方面,这里我们将用上面这个案例,来拆解一下它在底层的工作流程。。在执行栈一章中,我们知道函数调用时就会将执行上下文压入栈中,执行完毕后弹出栈。


我们从test(5)开始:

  1. 开始调用test(5)
    • test(5)压入栈中
    • num不满足停止条件,于是调用test(4)
  1. 调用test(4)
    • test(4)压入栈
    • num不满足停止条件,于是调用test(3)
  1. 调用test(3)
    • test(3)压入栈
    • num不满足停止条件,于是调用test(2)
  1. 调用test(2)
    • test(2)入栈
    • num不满足停止条件,于是调用test(1)
  1. 调用test(1)
    • test(1)入栈
    • num满足停止条件,返回1,执行栈开始逐个返回

返回流程:

  1. 返回test(1)
    • test(1)返回1test(1)弹出栈
  1. 返回test(2)
    • test(2)返回2 * 1 = 2,test(2)弹出栈
  1. 返回test(3)
    • test(3)返回3 * 2 = 6,test(3)弹出栈
  1. 返回test(4)
    • test(4)返回4 * 6 = 24,test(4)弹出栈
  1. 返回test(5)
    • test(5)返回5 * 24 = 120,test(5)弹出栈,栈清空,结束递归

在前面执行栈中,我们讲到return会终止函数执行并且将执行上下文弹出栈。但是,在递归中,return的是函数调用,因此会出现一直压栈直到满足结束条件才会逐个弹出栈的情况,因为初始化时,最外层的函数内部在不停的返回函数调用,因此最外层函数需要等待内部函数调用完毕,并将控制权逐步返回,最终才能出栈,因此递归是先进后出的顺序。

理解递归的关键在于理解函数调用和执行栈的工作机制,每次函数调用都会把当前函数的执行上下文压入栈中,直到满足递归结束条件,栈中的执行上下文才会逐步弹出。这也是为什么递归很容易造成栈溢出的原因所在,因为递归一直在不停压栈,而执行栈大小是有限制的,如果无限压栈,或者说因为递归层级过深很容易导致栈溢出,从而造成程序崩溃。

递归在更多的时候,被看做是一种强大的算法,因此要熟悉,需要不停的练习。

4.15.1. arguments.callee

在一些老旧的代码中,会通过arguments.callee来实现递归函数的调用,arguments.callee也是函数对象的指针,实际上和上面的函数名调用是一样的作用,目前已废弃,了解即可。

4.16. 立即调用函数表达式

立即调用函数表达式Immediately Invoked Function Expression,简称IIFE。表现形式通常为一个包含在一组小括号中()的匿名函数表达式。

(function () {
  // 该空间是一个块级作用域
})()

使用IIFE,在一个函数表达式中声明的变量,然后立即调用这个函数。位于这个函数表达式中的变量就像是在块级作用域一样,在ES6以前,模拟块级作用域采用的就是这种方式。

(function () {
  var a = 1;
  console.log(a) // 1
})
console.log(a) // 报错:a is not defined

<html>
    <div>1</div>
    <div>2</div>
    <div>3</div>
  <script>

    let divs = document.querySelectorAll('div'); // 获取页面上的div
    // 传统方式 var声明变量
    for (var i = 0; i < divs.length; ++i) { 
       divs[i].addEventListener('click', function() { 
       console.log(i); // 一直输出3 因为var是全局变量,循环过后就是3
     }); 
    }

    // 采用IIFE立即执行函数
    for (var i = 0; i < divs.length; ++i) {
        divs[i].addEventListener('click', (function(frozenCounter) {
            return function() {
                // 点击元素时 分别输出 0 1 2,因为立即执行函数将i即刻传入了
                console.log(frozenCounter); 
            };
        })(i));
    }
    
    // ES6块级作用域 简洁明了
    for (let i = 0; i < divs.length; ++i) { 
       divs[i].addEventListener('click', function() { 
       console.log(i); // 0 1 2 
     }); 
    }
  </script>
</html>