前端高手进阶 - 前中兴软创主任工程师 - 拉勾教育

数据类型与函数是很多高级语言中最重要的两个概念,前者用来存储数据,后者用来存储代码。JavaScript 中的函数相对于数据类型而言更加复杂,它可以有属性,也可以被赋值给一个变量,还可以作为参数被传递…… 正是这些强大特性让它成了 JavaScript 的 “一等公民”。

下面我们就来详细了解函数的重要特性。

this 关键字

什么是 this?this 是 JavaScript 的一个关键字,一般指向调用它的对象

这句话其实有两层意思,首先 this 指向的应该是一个对象,更具体地说是函数执行的 “上下文对象”。其次这个对象指向的是“调用它” 的对象,如果调用它的不是对象或对象不存在,则会指向全局对象(严格模式下为 undefined)。

下面举几个例子来进行说明。

  • 当代码 1 执行 fn() 函数时,实际上就是通过对象 o 来调用的,所以 this 指向对象 o。
  • 代码 2 也是同样的道理,通过实例 a 来调用,this 指向类实例 a。
  • 代码 3 则可以看成是通过全局对象来调用,this 会指向全局对象(需要注意的是,严格模式下会是 undefined)。
  1. var o = {
  2. fn() {
  3. console.log(this)
  4. }
  5. }
  6. o.fn()
  7. class A {
  8. fn() {
  9. console.log(this)
  10. }
  11. }
  12. var a = new A()
  13. a.fn()
  14. function fn() {
  15. console.log(this)
  16. }
  17. fn()

是不是觉得 this 的用法很简单?别着急,我们再来看看其他例子以加深理解。

(1)如果在函数 fn2() 中调用函数 fn(),那么当调用函数 fn2() 的时候,函数 fn() 的 this 指向哪里呢?

  1. function fn() {console.log(this)}
  2. function fn2() {fn()}
  3. fn2()

由于没有找到调用 fn 的对象,所以 this 会指向全局对象,答案就是 window(Node.js 下是 global)。

(2)再把这段代码稍稍改变一下,让函数 fn2() 作为对象 obj 的属性,通过 obj 属性来调用 fn2,此时函数 fn() 的 this 指向哪里呢?

  1. function fn() {console.log(this)}
  2. function fn2() {fn()}
  3. var obj = {fn2}
  4. obj.fn2()

这里需要注意,调用函数 fn() 的是函数 fn2() 而不是 obj。虽然 fn2() 作为 obj 的属性调用,但 fn2() 中的 this 指向并不会传递给函数 fn(), 所以答案也是 window(Node.js 下是 global)。
(3)对象 dx 拥有数组属性 arr,在属性 arr 的 forEach 回调函数中输出 this,指向的是什么呢?

  1. var dx = {
  2. arr: [1]
  3. }
  4. dx.arr.forEach(function() {console.log(this)})

按照之前的说法,很多同学可能会觉得输出的应该是对象 dx 的属性 arr 数组。但其实仍然是全局对象。

如果你看过 forEach 的说明文档便会知道,它有两个参数,第一个是回调函数,第二个是 this 指向的对象,这里只传入了回调函数,第二个参数没有传入,默认为 undefined,所以正确答案应该是输出全局对象。

类似的,需要传入 this 指向的函数还有:every()、find()、findIndex()、map()、some(),在使用的时候需要特别注意。

(4)前面提到通过类实例来调用函数时,this 会指向实例。那么如果像下面的代码,创建一个 fun 变量来引用实例 b 的 fn() 函数,当调用 fun() 的时候 this 会指向什么呢?

  1. class B {
  2. fn() {
  3. console.log(this)
  4. }
  5. }
  6. var b = new B()
  7. var fun = b.fn
  8. fun()

这道题你可能会很容易回答出来:fun 是在全局下调用的,所以 this 应该指向的是全局对象。这个思路没有没问题,但是这里有个隐藏的知识点。那就是 ES6 下的 class 内部默认采用的是严格模式,实际上面代码的类定义部分可以理解为下面的形式。

  1. class B {
  2. 'use strict';
  3. fn() {
  4. console.log(this)
  5. }
  6. }

而严格模式下不会指定全局对象为默认调用对象,所以答案是 undefined。

(5)ES6 新加入的箭头函数不会创建自己的 this,它只会从自己的作用域链的上一层继承 this。可以简单地理解为箭头函数的 this 继承自上层的 this,但在全局环境下定义仍会指向全局对象。

  1. var arrow = {fn: () => {
  2. console.log(this)
  3. }}
  4. arrow.fn()

所以虽然通过对象 arrow 来调用箭头函数 fn(),那么 this 指向不是 arrow 对象,而是全局对象。如果要让 fn() 箭头函数指向 arrow 对象,我们还需要再加一层函数,让箭头函数的上层 this 指向 arrow 对象。

  1. var arrow = {
  2. fn() {
  3. const a = () => console.log(this)
  4. a()
  5. }
  6. }
  7. arrow.fn()

(6)前面提到 this 指向的要么是调用它的对象,要么是 undefined,那么如果将 this 指向一个基础类型的数据会发生什么呢?

比如下面的代码将 this 指向数字 0,打印出的 this 是什么呢?

  1. [0].forEach(function() {console.log(this)}, 0)

结合上一讲关于数据类型的知识,我们知道基础类型也可以转换成对应的引用对象。所以这里 this 指向的是一个值为 0 的 Number 类型对象。

(7)改变 this 指向的常见 3 种方式有 bind、call 和 apply。call 和 apply 用法功能基本类似,都是通过传入 this 指向的对象以及参数来调用函数。区别在于传参方式,前者为逐个参数传递,后者将参数放入一个数组,以数组的形式传递。bind 有些特殊,它不但可以绑定 this 指向也可以绑定函数参数并返回一个新的函数,当 c 调用新的函数时,绑定之后的 this 或参数将无法再被改变。

  1. function getName() {console.log(this.name)}
  2. var b = getName.bind({name: 'bind'})
  3. b()
  4. getName.call({name: 'call'})
  5. getName.apply({name: 'apply'})

由于 this 指向的不确定性,所以很容易在调用时发生意想不到的情况。在编写代码时,应尽量避免使用 this,比如可以写成纯函数的形式,也可以通过参数来传递上下文对象。实在要使用 this 的话,可以考虑使用 bind 等方式将其绑定。

补充 1:箭头函数

箭头函数和普通函数相比,有以下几个区别,在开发中应特别注意:

  • 不绑定 arguments 对象,也就是说在箭头函数内访问 arguments 对象会报错;
  • 不能用作构造器,也就是说不能通过关键字 new 来创建实例;
  • 默认不会创建 prototype 原型属性;
  • 不能用作 Generator() 函数,不能使用 yeild 关键字。

函数的转换

在讲函数转化之前,先来看一道题:编写一个 add() 函数,支持对多个参数求和以及多次调用求和。示例如下:

  1. add(1)
  2. add(1)(2)
  3. add(1, 2)(3, 4, 5)(6)

对于不定参数的求和处理比较简单,很容易想到通过 arguments 或者扩展符的方式获取数组形式的参数,然后通过 reduce 累加求和。但如果直接返回结果那么后面的调用肯定会报错,所以每次返回的必须是函数,才能保证可以连续调用。也就是说 add 返回值既是一个可调用的函数又是求和的数值结果。

要实现这个要求,我们必须知道函数相关的两个隐式转换函数 toString() 和 valueOf()。toString() 函数会在打印函数的时候调用,比如 console.log、valueOf 会在获取函数原始值时调用,比如加法操作。

具体代码实现如下,在 add() 函数内部定义一个 fn() 函数并返回。fn() 函数的主要职能就是拼接参数并返回自身,当调用 toString() 和 valueOf() 函数时对拼接好的参数进行累加求和并返回。

  1. function add(...args) {
  2. let arr = args
  3. function fn(...newArgs) {
  4. arr = [...arr, ...newArgs]
  5. return fn;
  6. }
  7. fn.toString = fn.valueOf = function() {
  8. return arr.reduce((acc, cur) => acc + parseInt(cur))
  9. }
  10. return fn
  11. }

原型

原型是 JavaScript 的重要特性之一,可以让对象从其他对象继承功能特性,所以 JavaScript 也被称为 “基于原型的语言”。

严格地说,原型应该是对象的特性,但函数其实也是一种特殊的对象。例如,我们对自定义的函数进行 instanceof Object 操作时,其结果是 true。

  1. function fn(){}
  2. fn instanceof Object

而且我们为了实现类的特性,更多的是在函数中使用它,所以在函数这一课时中来深入讲解原型。

什么是原型和原型链?

简单地理解,原型就是对象的属性,包括被称为隐式原型的 proto 属性和被称为显式原型的 prototype 属性

隐式原型通常在创建实例的时候就会自动指向构造函数的显式原型。例如,在下面的示例代码中,当创建对象 a 时,a 的隐式原型会指向构造函数 Object() 的显式原型。

  1. var a = {}
  2. a.__proto__ === Object.prototype
  3. var b= new Object()
  4. b.__proto__ === a.__proto__

显式原型是内置函数(比如 Date() 函数)的默认属性,在自定义函数时(箭头函数除外)也会默认生成,生成的显式原型对象只有一个属性 constructor ,该属性指向函数自身。通常配合 new 关键字一起使用,当通过 new 关键字创建函数实例时,会将实例的隐式原型指向构造函数的显式原型。

  1. function fn() {}
  2. fn.prototype.constructor === fn

看到这里,不少同学可能会产生一种错觉,那就是隐式原型必须和显式原型配合使用,这种想法是错误的。

下面的代码声明了 parent 和 child 两个对象,其中对象 child 定义了属性 name 和隐式原型 proto,隐式原型指向对象 parent,对象 parent 定义了 code 和 name 两个属性。

当打印 child.name 的时候会输出对象 child 的 name 属性值,当打印 child.code 时由于对象 child 没有属性 code,所以会找到原型对象 parent 的属性 code,将 parent.code 的值打印出来。同时可以通过打印结果看到,对象 parent 并没有显式原型属性。如果要区分对象 child 的属性是否继承自原型对象,可以通过 hasOwnProperty() 函数来判断。

  1. var parent = {code:'p',name:'parent'}
  2. var child = {__proto__: parent, name: 'child'}
  3. console.log(parent.prototype)
  4. console.log(child.name)
  5. console.log(child.code)
  6. child.hasOwnProperty('name')
  7. child.hasOwnProperty('code')

在这个例子中,如果对象 parent 也没有属性 code,那么会继续在对象 parent 的原型对象中寻找属性 code,以此类推,逐个原型对象依次进行查找,直到找到属性 code 或原型对象没有指向时停止。

这种类似递归的链式查找机制被称作 “原型链”。

new 操作符实现了什么?

前面提到显式原型对象在使用 new 关键字的时候会被自动创建。现在再来具体分析通过 new 关键字创建函数实例时到底发生了什么。

下面的代码通过 new 关键字创建了一个函数 F() 的实例。

  1. function F(init) {}
  2. var f = new F(args)

其中主要包含了 3 个步骤:

  1. 创建一个临时的空对象,为了表述方便,我们命名为 fn,让对象 fn 的隐式原型指向函数 F 的显式原型;
  2. 执行函数 F(),将 this 指向对象 fn,并传入参数 args,得到执行结果 result;
  3. 判断上一步的执行结果 result,如果 result 为非空对象,则返回 result,否则返回 fn。

具体可以表述为下面的代码:

  1. var fn = Object.create(F.prototype)
  2. var obj = F.apply(fn, args)
  3. var f = obj && typeof obj === 'object' ? obj : fn;

怎么通过原型链实现多层继承?

结合原型链和 new 操作符的相关知识,就可以实现多层继承特性了。下面通过一个简单的例子进行说明。

假设构造函数 B() 需要继承构造函数 A(),就可以通过将函数 B() 的显式原型指向一个函数 A() 的实例,然后再对 B 的显式原型进行扩展。那么通过函数 B() 创建的实例,既能访问用函数 B() 的属性 b,也能访问函数 A() 的属性 a,从而实现了多层继承。

  1. function A() {
  2. }
  3. A.prototype.a = function() {
  4. return 'a';
  5. }
  6. function B() {
  7. }
  8. B.prototype = new A()
  9. B.prototype.b = function() {
  10. return 'b';
  11. }
  12. var c = new B()
  13. c.b()
  14. c.a()

补充 2:typeof 和 instanceof

typeof

用来获取一个值的类型,可能的结果有下面几种:

类型 结果
Undefined “undefined”
Boolean “boolean”
Number “number”
BigInt “bigint”
String “string”
Symbol “symbol”
函数对象 “function”
其他对象及 null “object”

instanceof

用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。例如,在表达式 left instanceof right 中,会沿着 left 的原型链查找,看看是否存在 right 的 prototype 对象。

  1. left.__proto__.__proto__... =?= right.prototype

作用域

作用域是指赋值、取值操作的执行范围,通过作用域机制可以有效地防止变量、函数的重复定义,以及控制它们的可访问性。

虽然在浏览器端和 Node.js 端作用域的处理有所不同,比如对于全局作用域,浏览器会自动将未主动声明的变量提升到全局作用域,而 Node.js 则需要显式的挂载到 global 对象上。又比如在 ES6 之前,浏览器不提供模块级别的作用域,而 Node.js 的 CommonJS 模块机制就提供了模块级别的作用域。但在类型上,可以分为全局作用域(window/global)、块级作用域(let、const、try/catch)、模块作用域(ES6 Module、CommonJS)及本课时重点讨论的函数作用域。

命名提升

对于使用 var 关键字声明的变量以及创建命名函数的时候,JavaScript 在解释执行的时候都会将其声明内容提升到作用域顶部,这种机制称为 “命名提升”。

变量的命名提升允许我们在同(子)级作用域中,在变量声明之前进行引用,但要注意,得到的是未赋值的变量。而且仅限 var 关键字声明的变量,对于 let 和 const 在定义之前引用会报错。

  1. console.log(a)
  2. var a = 1
  3. console.log(b)
  4. let b = 2

函数的命名提升则意味着可以在同级作用域或者子级作用域里,在函数定义之前进行调用。

  1. fn()
  2. function fn() {
  3. return 2
  4. }

结合以上两点我们再来看看下面两种函数定义的区别,方式 1 将函数赋值给变量 f;方式 2 定义了一个函数 f()。

  1. var f = function() {...}
  2. function f() {...}

两种方式对于调用函数方式以及返回结果而言是没有区别的,但根据命名提升的规则,我们可以得知方式 1 创建了一个匿名函数,让变量 f 指向它,这里会发生变量的命名提升;如果我们在定义函数之前调用会报错,而方式 2 则不会。

闭包

在函数内部访问外部函数作用域时就会产生闭包。闭包很有用,因为它允许将函数与其所操作的某些数据(环境)关联起来。这种关联不只是跨作用域引用,也可以实现数据与函数的隔离。

比如下面的代码就通过闭包来实现单例模式。

  1. var SingleStudent = (function () {
  2. function Student() {}
  3. var _student;
  4. return function () {
  5. if (_student) return _student;
  6. _student = new Student()
  7. return _student;
  8. }
  9. }())
  10. var s = new SingleStudent()
  11. var s2 = new SingleStudent()
  12. s === s2

函数 SingleStudent 内部通过闭包创建了一个私有变量 _student,这个变量只能通过返回的匿名函数来访问,匿名函数在返回变量时对其进行判断,如果存在则直接返回,不存在则在创建保存后返回。

补充 3:经典笔试题

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

这是一道作用域相关的经典笔试题,需要实现的功能是每隔 1 秒控制台打印数字 0 到 4。但实际执行效果是每隔一秒打印的数字都是 5,为什么会这样呢?

如果把这段代码转换一下,手动对变量 i 进行命名提升,你就会发现 for 循环和打印函数共享了同一个变量 i,这就是问题所在。

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

要修复这段代码方法也有很多,比如将 var 关键字替换成 let,从而创建块级作用域。

  1. for(let i = 0; i < 5; i++ ) {
  2. setTimeout(() => {
  3. console.log(i);
  4. }, 1000 * i)
  5. }
  6. 等价于
  7. for(var i = 0; i < 5; i++ ) {
  8. let _i = i
  9. setTimeout(() => {
  10. console.log(_i);
  11. }, 1000 * i)
  12. }
  13. */

总结

本课时介绍了函数相关的重要内容,包括 this 关键字的指向、原型与原型链的使用、函数的隐式转换、函数和作用域的关系,希望大家能理解并记忆。

最后布置一道思考题:结合本课时的内容,思考一下修改函数的 this 指向,到底有多少种方式呢?