# 实现类型判断

# 挑战准备

新建一个 getType.js 文件,在文件里写一个名为 getType 的函数,并导出这个函数,如下图所示:

1682436832882

这个文件在环境初始化时会自动生成,如果发现没有自动生成就按照上述图片自己创建文件和函数,函数代码如下:

function getType(target) {
  // 补充代码
}
module.exports = getType;

# 挑战内容

请封装一个函数,能够以字符串的形式精准地返回数据类型。

要求返回的类型全部由小写字母组成。

示例:

输入输出
true'boolean'
100'number'
'abc''string'
100n'bigint'
null'null'
undefined'undefined'
Symbol('a')'symbol'
[]'array'
{}'object'
function fn() {}'function'
new Date()'date'
/abc/'regexp'
new Error()'error'
new Map()'map'
new Set()'set'
new WeakMap()'weakmap'
new WeakSet()'weakset'

# 注意事项

  • 文件名、函数名不可随意更改。
  • 文件中编写的函数需要导出,否则将无法提交通过。

# 实验介绍

# 知识点

  • JavaScript 数据类型
  • 判断 JavaScript 数据类型的几种方法

# 题解

正式开始讲解前,先放上最终答案镇楼,代码如下:

function getType(target) {
  const type = typeof target;
  if (type !== "object") {
    return type;
  }
  return Object.prototype.toString
    .call(target)
    .replace(/^\[object (\S+)\]$/, "$1")
    .toLocaleLowerCase();
}

# 题解分析

判断一个数据的类型,比较常用的有下面几种方式:

  • typeof
  • instanceof
  • Object.prototype.toString.call(xxx)

# typeof

判断一个数据的类型,用得最多的就是 typeof 操作符, 但是使用 typeof 常常会遇到以下问题:

  • 无法判断 null
  • 无法判断除了 function 之外的引用类型。

同学们可以打开云课实验环境,亲自去写一写代码,在实践中学习。

打开实验环境后,分别创建 index.htmlindex.js 文件,在 index.js 中写入如下代码。

// 可以判断除了 null 之外的基础类型。
console.log(typeof true); // 'boolean'
console.log(typeof 100); // 'number'
console.log(typeof "abc"); // 'string'
console.log(typeof 100n); // 'bigint'
console.log(typeof undefined); // 'undefined'
console.log(typeof Symbol("a")); // 'symbol'
// 无法判断 null。
console.log(typeof null); // 输出 'object',原因在文章末尾解释。
// 无法判断除了 function 之外的引用类型。
console.log(typeof []); // 'object'
console.log(typeof {}); // 'object'

编写完成后,不要忘记在 index.html 中引入 index.js 文件。然后右键点击 index.html 文件,点击 Open with Live Server ,实验环境就会启动一个 Web 服务,供我们在外部访问。

在启动的 Web 服务的控制台里查看代码运行情况,如下图:

1682439398166

# instanceof

typeof 无法精确地判断引用类型,这时,可以使用 instanceof 运算符,如下代码所示:

console.log([] instanceof Array); // true
const obj = {};
console.log(obj instanceof Object); // true
const fn = function () {};
console.log(fn instanceof Function); // true
const date = new Date();
console.log(date instanceof Date); // true
const re = /abc/;
console.log(re instanceof RegExp); // true

但是 instanceof 运算符一定要是 判断对象实例的时候才是正确的 ,也就是说,它不能判断原始类型,如下代码所示:

const str1 = "qwe";
const str2 = new String("qwe");
console.log(str1 instanceof String); //false,无法判断原始类型。
console.log(str2 instanceof String); // true

有同学说,这不正好, typeof 可以判断原始类型, instanceof 可以判断引用类型,把它俩结合起来,就可以实现精准判断类型的 getType 函数了。

别忘了,还有个 null 得处理一下,它比较特殊,我们可以直接判断变量全等于 null ,如下代码所示:

function getType(target) {
  // ...
  if (target === null) {
    return "null";
  }
  // ...
}

现在,判断原始类型和引用类型的思路都有了,接下来就是动手写代码的事,但是真的去写就会发现,使用 instanceof 操作符来判断类型返回的是 true 或者 false ,写起来会非常麻烦。

其实, instanceof 运算符本来是用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上的,只是刚好可以用来判断类型而已,所以在这里才会讨论它,实际上用它来判断类型代码写起来不是很方便。

这时,Object.prototype.toString 出场了,实际项目中要封装判断类型的工具函数一般都是用的它。

# Object.prototype.toString.call(xxx)

调用 Object.prototype.toString 方法,会统一返回格式为 [object Xxx] 的字符串,用来表示该对象(原始类型是包装对象)的类型。

需要注意的是,在调用该方法时,需要加上 call 方法(原因后文解释),如下代码所示:

// 引用类型
console.log(Object.prototype.toString.call({})); // '[object Object]'
console.log(Object.prototype.toString.call(function () {})); // "[object Function]'
console.log(Object.prototype.toString.call(/123/g)); // '[object RegExp]'
console.log(Object.prototype.toString.call(new Date())); // '[object Date]'
console.log(Object.prototype.toString.call(new Error())); // '[object Error]'
console.log(Object.prototype.toString.call([])); // '[object Array]'
console.log(Object.prototype.toString.call(new Map())); // '[object Map]'
console.log(Object.prototype.toString.call(new Set())); // '[object Set]'
console.log(Object.prototype.toString.call(new WeakMap())); // '[object WeakMap]'
console.log(Object.prototype.toString.call(new WeakSet())); // '[object WeakSet]'
// 原始类型
console.log(Object.prototype.toString.call(1)); // '[object Number]'
console.log(Object.prototype.toString.call("abc")); // '[object String]'
console.log(Object.prototype.toString.call(true)); // '[object Boolean]'
console.log(Object.prototype.toString.call(1n)); // '[object BigInt]'
console.log(Object.prototype.toString.call(null)); // '[object Null]'
console.log(Object.prototype.toString.call(undefined)); // '[object Undefined]'
console.log(Object.prototype.toString.call(Symbol("a"))); // '[object Symbol]'

有了上面的基础,我们就可以统一调用 Object.prototype.toString 方法来获取数据具体的类型,然后把多余的字符去掉即可,只取 [object Xxx] 里的 Xxx

不过使用 Object.prototype.toString 判断原始类型时,会进行装箱操作,产生额外的临时对象,为了避免这一情况的发生,我们也可以结合 typeof 来判断除了 null 之外的原始类型,于是最后的代码实现如下:

function getType(target) {
  // 先进行 typeof 判断,如果是基础数据类型,直接返回。
  const type = typeof target;
  if (type !== "object") {
    return type;
  }
  // 如果是引用类型或者 null,再进行如下的判断,正则返回结果,注意最后返回的类型字符串要全小写。
  return Object.prototype.toString
    .call(target)
    .replace(/^\[object (\S+)\]$/, "$1")
    .toLocaleLowerCase();
}

上面代码中从 [object Xxx] 里取出 Xxx 用到了 replace 方法和正则,关于正则这种 “八股文”,想要进阶,躲是躲不掉的,但是不建议同学们去硬啃,太枯燥,可以了解一些用得较多的方法和写法,知道大概的写法后,再结合类似这个网站这样的正则可视化去分析该怎么写。关于正则,具体的内容会放到课程后面讲字符串、正则相关面试题时介绍。

其实,上面的函数还可以换一种写法,代码如下:

// ...
return Object.prototype.toString
  .call(target)
  .match(/\s([a-zA-Z]+)\]$/)[1] // 这种写法也可以。
  .toLocaleLowerCase();

不用正则也可以,只要能实现功能,想怎么写都行,代码如下:

// ...
return Object.prototype.toString
  .call(target)
  .slice(8, -1) // 这种写法也可以。
  .toLocaleLowerCase();

不过我个人觉得第一种写法的可读性最高,所以先介绍的是第一种。

这个函数还调用了 toLocaleLowerCase() 方法,把返回结果统一变成小写,因为 Object.prototype.toString 返回的类型字符串格式不是统一的,有些是首字母大写,有些是帕斯卡形式(单词中间也有大写字母),不好记,全部返回小写就没这些烦恼了。

至此, getType 方法就写完了,这个工具函数非常实用,有了它,再也不用去业务代码里写冗长的判断类型的代码了,赶紧在实际项目中用起来吧。

最容易的写法是

function getType(target) {
  return Object.prototype.toString
  .call(target)
  .slice(8, -1) // 这种写法也可以。
  .toLocaleLowerCase();
}

# 知识延伸

# 为什么要使用 call

解答一下上文留下的疑问,为什么要写成 Object.prototype.toString.call(xxx) 的形式来判断 xxx 的类型?

call 是函数的方法,是用来改变 this 指向的,用 apply 方法也可以。

如果不改变 this 指向为我们的目标变量 xxxthis 将永远指向调用的 Object.prototype ,也就是原型对象,对原型对象调用 toString 方法,结果永远都是 [object Object] ,如下代码所示:

Object.prototype.toString([]); // 输出 '[object Object]'  不调用 call,this 指向 Object.prototype,判断类型为 Object。
Object.prototype.toString.call([]); // 输出 '[object Array]'   调用 call,this 指向 [],判断类型为 Array
Object.prototype.toString(1); // 输出 '[object Object]' 不调用 call,this 指向 Object.prototype,判断类型为 Object。
Object.prototype.toString.call(1); // 输出 '[object Number]' 调用 call,this 指向包装对象 Number {1},判断类型为 Number

可以重写 Object.prototype.toString 方法,把 this 打印出来验证一下,代码如下所示:

// 重写 Object.prototype.toString 方法,只打印 this
Object.prototype.toString = function () {
  console.log(this);
};
// 引用类型
Object.prototype.toString([]); // 输出 Object.prototype
Object.prototype.toString.call([]); // 输出 []
// 原始类型
Object.prototype.toString(1); // 输出 Object.prototype
Object.prototype.toString.call(1); // 输出 Number {1},这里的 Number {1},是一个包装类,把基本类型用它们相应的引用类型包装起来,使其具有对象的性质

这下同学们清楚为什么该用 call 方法了吧,当然这里只是简单介绍下,如果同学们对于 thiscall 还有疑问,也不用担心,后面的真题挑战里就有 “实现 call 方法” 这道题,我们到时再做详尽的介绍。

另外,关于数据类型,还有一些常见的面试题,总结在文章末尾,供同学们参考。

# 常见面试题 1:JavaScript 有哪些数据类型

答: JavaScript 的数据类型分为原始类型和对象类型。

原始类型有 7 种,分别是:

  • Boolean
  • Number
  • BigInt
  • String
  • Null
  • Undefined
  • Symbol

对象类型(也称引用类型)是一个泛称,包括数组、对象、函数等一切对象。

# 常见面试题 2:typeof null 的结果是什么

答:

typeof null; // 'object'

null 作为一个基本数据类型为什么会被 typeof 运算符识别为对象类型呢?

事实上,这是第一版 JavaScript 留下来的一个 bug。

JavaScript 中不同对象在底层都表示为二进制,而 JavaScript 中会把二进制前三位都为 0 的判断为 object 类型,而 null 的二进制表示全都是 0,自然前三位也是 0,所以执行 typeof 时会返回 'object'

那为啥那一堆设计语言的大佬们会放任这个 bug 存在这么多年呢?

因为这个 bug 牵扯了太多的 Web 系统,一旦改了,会产生更多的 bug,令很多系统无法工作,也许这个 bug 永远都不会修复了。

判断一个类型为 null 可以这么写,直接判断变量全等于 null

let a = null;
if (a === null) {
  // do something
}

# 常见面试题 3:原始类型和引用类型的区别是什么

答:

类型原始类型对象类型
不可改变可以改变
属性和方法不能添加能添加
存储值地址(指针)
比较值的比较地址的比较

# 常见面试题 4:typeof 和 instanceof 的区别是什么

答:

  • typeof 运算符用来判断数据的类型。
  • instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,也可以用来判断数据的类型。
    • typeof 返回一个变量的类型字符串, instanceof 返回的是一个布尔值。
    • typeof 可以判断除了 null 以外的基础数据类型,但是判断引用类型时,除了 function 类型,其他的无法准确判断。
    • instanceof 可以准确地判断各种引用类型,但是不能正确判断原始数据类型。

# 常见面试题 5:Symbol 解决了什么问题

答: Symbol 是 ES6 时新增的特性, Symbol 是一个基本的数据类型,表示独一无二的值,主要用来防止对象属性名冲突问题。

ES5 的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的属性,新属性的名字就有可能与现有属性的名字产生冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是 ES6 引入 Symbol 的原因之一。

Symbol 值通过 Symbol 函数生成。这就是说,对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的 Symbol 类型。凡是属性名属于 Symbol 类型,就都是独一无二的,可以保证不会与其他属性名产生冲突。

代码演示如下:

const obj = {
  name: "lin",
  age: 18,
};
obj.name = "xxx"; // 给 obj.name 赋值,把以前的 name 覆盖了
console.log(obj); // { name: 'xxx', age: 18 }
const obj = {
  name: "lin",
  age: 18,
};
const name = Symbol("name");
obj[name] = "xxx"; // 使用 Symbol,不会覆盖
console.log(obj); // { name: 'lin', age: 18, Symbol(name): 'xxx' }
console.log(obj.name); // 'lin'
console.log(obj[name]); // 'xxx'

# 判断对象是否为空

# 挑战准备

新建一个 isEmptyObject.js 文件,在文件里写一个名为 isEmptyObject 的函数,并导出这个函数,如下图所示:

1684156419416

这个文件在环境初始化时会自动生成,如果发现没有自动生成就按照上述图片自己创建文件和函数,函数代码如下:

function isEmptyObject(obj) {
  // 补充代码
}
module.exports = isEmptyObject;

# 挑战内容

实现一个函数,判断传入的对象是否为空。如果对象为空返回 true,否则返回 false。

不必考虑传入原始类型的情况,本题测试用例中传入的参数都是对象类型。

示例:

输入:{}
输出:true
输入:{ name: 'lin' }
输出:false

# 本节介绍

本节是 “判断对象是否为空” 这道题的讲解,这道题看似非常简单,似乎一行代码就可以解决,但在真实的项目中还是不会直接在业务代码中判断,而是会封装成一个函数,本节将会介绍三种实现本题的方式。

# 知识点

  • for in
  • Object.keys()
  • JSON.stringify()

# 题解

# 1.for in

首先可以用遍历解决,用的是 for in,思路如下:

  • 遍历这个对象
    • 如果能被遍历,说明这个对象有属性,返回 false
    • 否则说明对象为空,返回 true

代码实现如下:

function isEmptyObject(obj) {
  for (let o in obj) {
    return false;
  }
  return true;
}

# 2.Object.keys()

还有一种解题方式就是使用 Object.keys(),先把对象转化为数组,然后再根据数组的长度是否为零来判断对象是否为空。

代码实现如下:

function isEmptyObject(obj) {
  return Object.keys(obj).length === 0;
}

虽然本题所有的测试用例都是引用类型,但是如果把本方法用到实际的项目中,传入的参数就是不可预知的了,很有可能就是一些奇怪的数据,比如传入 nullundefined ,就会出问题,如下代码所示:

function isEmptyObject(obj) {
  return Object.keys(obj).length === 0;
}
isEmptyObject(null); // Uncaught TypeError: Cannot convert undefined or null to object
isEmptyObject(undefined); // Uncaught TypeError: Cannot convert undefined or null to object

所以在实际项目中,可以用如下方式实现本函数:

function isEmptyObject(obj) {
  return Object.keys(obj || []).length === 0;
}

# 3.JSON.stringify()

还可以使用 JSON.stringify() 来解决本题,只需要判断对象转为 JSON 字符串之后,是不是等于 {} 即可,代码如下:

function isEmptyObject(obj) {
  return JSON.stringify(obj) === "{}";
}

这么写判断大多数对象都不会有问题,不过一旦遇到出现 “循环引用” 的对象,就会报错,如下代码所示:

function isEmptyObject(obj) {
  return JSON.stringify(obj) === "{}";
}
// 循环引用该对象。
const obj = {
  a: 1,
};
obj.a = obj;
isEmptyObject(obj); // 报错

1684156736945

所以在真实的项目中,这种写法也不是保险的,尽量不要用,虽然 bug 发生的概率特别小,但如果没有做容错处理,一旦发生了,就会阻断 js 代码执行,很有可能造成页面加载不出这种非常严重的 bug。

# 知识延伸

# 1. 如何判断原始数据为空

答:使用 ! 运算符,把原始数据隐式转换为 Boolean 类型。

判断原始数据为空一般不需要封装成一个方法,直接写进业务代码即可,比如:

if (!target) {
  // do something
}
while (!target) {
  // do something
}

# 2. 如何判断数组为空

答:直接判断数组长度为 0 即可,代码如下:

function isEmptyArray(arr) {
  return Array.isArray(arr) && arr.length === 0;
}

这种写法已经可以用到项目里了,不过,如果非要深入研究的话,JS 里的数组也是对象,所以数组下标可以是负数,也可以是小数,甚至是字符串都可以,只是这几种写法数组的 length 不会改变,代码如下:

const arr = [];
arr[-1] = "xxx";
arr[0.5] = "xxx";
arr["xxx"] = "xxx";
console.log(arr.length); // 0

所以说,判断数组是否为空最完善的方法就是把数组当对象处理,直接用上面判断对象是否为空的方式判断即可。

const arr = [];
arr[-1] = "xxx";
arr[0.5] = "xxx";
arr["xxx"] = "xxx";
function isEmptyObject(obj) {
  for (let o in obj) {
    return false;
  }
  return true;
}
console.log(isEmptyObject(arr)); // false

不过上述都是很边界的情况,在平时开发时直接判断数组长度为 0 也没什么问题。

# 本节总结

本节我们学习了如何判断一个数据为空,最终的结论是:

  • 判断对象为空,就使用 for in 或者 Object.keys 来判断,尽量不要使用 JSON.stringify
  • 判断数组为空,一般判断长度为 0,也可以用和判断对象为空一样的方法来判断。
  • 判断原始类型为空,就使用 ! 运算符来判断。

学完本节后,同学们可以把 isEmptyObject 这个工具函数在项目里用起来了。

# 知识讲解:this

JavaScript 中的 this 对初学者来说一直是一个棘手的问题,因为它的指向实在太灵活了,如果没有真的理解它,在面试或笔试中被问到 this 指向谁,就只能靠猜。不仅是面试,实际开发中用到 this 的场景也特别多,很多隐蔽的 bug 都有可能是它导致的,用好了 this ,也能写出更简洁的代码。

本节实验我们一起来学一学 this 吧~

# 知识点

  • this
  • call、apply、bind

# this 是什么

很多同学老是记不住 this 指向谁,我觉得这是非常正常的现象,因为 this 指向的情况本身就比较多,面对这种不太好记的知识点,我们可以延续之前的学习方式,采用敲代码实验 + 画图记忆 + 真题训练的方式,这种学习方式能让大家学知识更高效一点。

首先,学习一个知识,先要理解它是什么,在大多数面向对象语言中, this 表示当前对象的一个引用,而 JS 中的 this 是完全不同的概念。

我们查阅 MDN 文档可知, this 是当前执行上下文( globalfunctioneval )的一个属性。

也就是说,我们可以把 JS 中的 this 分为三种,分别是:

  • 全局上下文中的 this
  • 函数上下文中的 this
  • eval 上下文中的 this

关于 eval ,在 MDN 或很多书籍和文档中都建议永远不要使用它,我们就不讨论它了,接下来,我们将主要讨论全局上下文和函数上下文的 this

有一点需要注意的是, node 环境中的 thisweb 环境中的 this 是不同的,为了避免大家一开始接受太多概念,我们先只讨论 web 环境的 this

接下来,我们就通过做实验的方式,来学习一下 this 的指向。

# 全局上下文的 this

全局上下文的 this 指向非常明确, 不管有没有启用严格模式,都指向 window 对象

同学们可以打开右侧的实验环境,我们敲代码来验证一下。

随便新建一个 test.html 文件,在文件中写入如下代码:

<body>
  <script>
    console.log(this === window); // 输出 true
    ("use strict");
    console.log(this === window); // 输出 true
  </script>
</body>

然后可以启动一下环境中的 web server,查看代码输出情况,可以看到,的确是不管有没有启用严格模式,全局上下文的 this 都指向 window 对象。

this 添加属性,就相当于给 window 添加属性,给 window 添加属性,就相当于给 this 添加属性,如下代码所示:

this.userName = "zhangsan";
window.age = 18;
console.log(this.age); // 输出 18
console.log(window.userName); // 输出 'zhangsan'

# 函数上下文的 this

全局上下文的 this 很简单,记住无脑指向 window 就完事,复杂就复杂在函数上下文的 this ,函数上下文中的 thisarguments 一样,就是函数的隐式参数,可以在任意函数中调用,它的指向不是固定不变的,取决于函数 处于什么位置、以什么方式调用 ,可以总结成如下图:

1684157407478

接下来,我们一起敲代码来验证上图的内容。

# 全局上下文中的函数

直接调用全局上下文中的函数, this 指向默认情况下为 window

function fn() {
  console.log(this); // 输出 window
}
fn();
function fn() {
  let a = 1;
  console.log(this.a); // 输出 2
}
var a = 2;
fn();

但是在严格模式下, this 指向的就是 undefined

function fn() {
  "use strict";
  console.log(this); // 输出 undefined
}
fn();

# 对象中的函数

调用对象中的函数, this 指向为这个对象。

const obj = {
  a: 1,
  fn() {
    console.log("this :>> ", this); // 输出 obj
    console.log("this.a :>> ", this.a); // 输出 1
  },
};
obj.fn();

但是如果函数嵌套有函数,此时的 this 指向为 window ,就非常令人迷惑。

const obj = {
  a: 1,
  fn() {
    return function () {
      console.log("this :>> ", this); // 输出 window
      console.log("this.a :>> ", this.a); // 输出 100
    };
  },
};
var a = 100;
obj.fn()();

其实可以这么理解:

obj.fn()();
等价于;
const temp = obj.fn(); // 定义一个临时变量来存储 obj.fn 返回的函数
temp(); // 执行这个函数

上面代码示例中的 temp 在运行时是处在 window 环境中的,所以 this 指向 window

# 箭头函数

遇到对象里有函数嵌套函数的情况,想要 this 指向这个对象,es6 之前,可以用一个临时变量 _this 来暂存 this ,代码如下:

const obj = {
  a: 1,
  fn() {
    const _this = this;
    return function () {
      console.log("this :>> ", _this); // 输出 obj
      console.log("this.a :>> ", _this.a); // 输出 1
    };
  },
};
obj.fn()();

es6 推出了箭头函数的概念,之后我们就可以使用箭头函数解决这个问题,代码如下:

const obj = {
  a: 1,
  fn() {
    return () => {
      console.log("this :>> ", this); // 输出 obj
      console.log("this.a :>> ", this.a); // 输出 1
    };
  },
};
obj.fn()();

对于普通函数来说,内部的 this 指向函数运行时所在的对象。

对于箭头函数,它不会创建自己的 this ,它只会 从自己的作用域链的上一层继承 this

所以这里 fn 中嵌套的匿名箭头函数中的 this ,指向它作用域链的上一层的 this ,也就是函数 fnthis ,也就是 obj

其实,箭头函数内部实现也是定义临时变量 _this 来暂存 this ,用 babel 一试便知,如下图:

1684158070334

babel 在线地址

# 构造函数

构造函数内,如果返回值是一个对象, this 指向这个对象,否则 this 指向新建的实例。

function Person(name) {
  this.name = name;
}
const p = new Person("zhangsan");
console.log(p.name); // 'zhangsan'
function Person(name) {
  this.name = name;
  return {
    name: "xxx",
  };
}
const p = new Person("zhangsan");
console.log(p.name); // 'xxx'
function Person(name) {
  this.name = name;
  return {};
}
const p = new Person("zhangsan");
console.log(p.name); // 'undefined'

如果有返回值,但是不是一个对象, this 还是指向实例。

function Person(name) {
  this.name = name;
  return 123;
}
const p = new Person("zhangsan");
console.log(p.name); // 'zhangsan'

这一点,在实现 new 操作符那一节,我们详细讲过。

const obj = {
    a: 1,
    fn() {
        const _this = this;
        return function () {
            console.log("this :>> ", _this); // 输出 obj
            console.log("this.a :>> ", _this.a); // 输出 1
        };
    },
};
obj.fn()();  // 输出 obj 和 1
console.log("========");
let temp = obj.fn();
var a = 2;  // 没用
temp();  // 输出 obj 和 1
console.log("========");
obj.a = 3;
temp();  // 输出 obj 和 3
// 这里用到的是函数闭包
const obj = {
    a: 1,
    fn() {
        return () => {
            console.log("this :>> ", this); // 输出 obj
            console.log("this.a :>> ", this.a); // 输出 1
        };
    },
};
// obj.fn()();
obj.fn()();  // 输出 obj 和 1
console.log("========");
// let temp = obj.fn();
let fn_global = obj.fn;
let temp = fn_global();
var a = 2;  // 有用
temp();  // 输出 obj 和 2
console.log("========");
obj.a = 3;
temp();  // 输出 obj 和 2
// 这里用到的是函数闭包

# 显式改变函数上下文的 this

了解了函数中的 this 指向后,我们可以使用 callapplybind 来显式改变函数中的 this 指向。

# call

Function.prototype.call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

function fn() {
  console.log(this.name);
}
const obj = {
  name: "zhangsan",
};
fn.call(obj); // 指定 this 为 obj,输出 'zhangsan'

# apply

Function.prototype.apply() 方法调用一个具有给定 this 值的函数,以及以一个数组(或类数组对象)的形式提供的参数。

applycall 的功能完全一样,只是传参形式不一样, call 是传多个参数, apply 是只传参数集合。

function add(x, y, z) {
  return this.x + this.y + this.z;
}
const obj = {
  x: 1,
  y: 2,
  z: 3,
};
console.log(add.call(obj, 1, 2, 3)); // 输出 6
console.log(add.apply(obj, [1, 2, 3])); // 输出 6,只是传参形式不同而已

# bind

Function.prototype.bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

bindcallapply 的区别是,函数调用 callapply 会直接调用,而调用 bind 是创建一个新的函数,必须手动去再调用一次,才会生效。

function add(x, y, z) {
  return this.x + this.y + this.z;
}
const obj = {
  x: 1,
  y: 2,
  z: 3,
};
const add1 = add.bind(obj, 1, 2, 3); //bind 会返回一个新的函数
console.log(add1()); // 执行新的函数,输出 6

# 小测验

在之前的实验中,我们已经把 this 所有的输出情况都手敲了一遍,接下来,我们通过一些练习题,巩固一下之前学的知识。

# 第 1 题

function fn() {
  console.log(this); // ?
}
fn();

输出:

window;

原因:全局上下文中的函数,非严格模式指向 window

# 第 2 题

function fn() {
  "use strict";
  console.log(this); // ?
}
fn();

输出:

undefined;

原因:全局上下文中的函数,严格模式指向 undefined

# 第 3 题

const obj = {
  userName: "zhangsan",
  fn() {
    console.log(this); // ?
    console.log(this.userName); // ?
  },
};
obj.fn();

出:

{ userName: 'zhangsan', fn: ƒ}
'zhangsan'

原因:对象中的函数, this 指向这个对象。

# 第 4 题

const obj = {
  userName: "zhangsan",
  fn() {
    console.log(this); // ?
    console.log(this.userName); // ?
  },
};
const fn = obj.fn;
fn();

输出:

window;
undefined;

原因:函数 fn 是处在 window 环境下的,所以 this 指向为 windowwindow 上没有 userName 这个字段,所以为 undefined

# 第 5 题

const person = {
  userName: "zhangsan",
  wife: {
    userName: "xxx",
    fn() {
      console.log(this); // ?
      console.log(this.userName); // ?
    },
  },
};
person.wife.fn();

出:

{userName: 'xxx', fn: ƒ}
'xxx'

原因:函数 fn 调用的时候是 wife.fn() ,所以 this 指向为 wife 对象。

# 第 6 题

const o1 = {
  text: "o1",
  fn() {
    return this.text;
  },
};
const o2 = {
  text: "o2",
  fn() {
    return o1.fn();
  },
};
const o3 = {
  text: "o3",
  fn() {
    var fn = o1.fn;
    return fn();
  },
};
console.log(o1.fn()); // ?
console.log(o2.fn()); // ?
console.log(o3.fn()); // ?

输出:

"o1";
"o1";
undefined;

原因: o1.fn() 中的 this 指向 o1 对象,所以输出的 this.text'o1'

执行 o2.fn() 等价于执行 o1.fn() ,所以输出 'o1'

o3.fn() 理解起来稍微复杂一点, o3 对象中的代码等价于下面的代码:

const o3 = {
  text: "o3",
  fn() {
    return function () {
      return this.text;
    };
  },
};
console.log(o3.fn()());

所以 this 指向为 windowwindow.textundefined

# 第 7 题

const o1 = {
  text: "o1",
  fn() {
    return this.text;
  },
};
const o2 = {
  text: "o2",
  fn: o1.fn,
};
console.log(o1.fn()); // ?
console.log(o2.fn()); // ?

输出:

"o1";
"o2";

原因: o1.fn() 中的 this 指向 o1 对象,所以输出的 this.text'o1'

o2 对象中的代码等价于下面的代码:

const o2 = {
  text: "o2",
  fn() {
    return this.text;
  },
};

所以 this 指向为 o2 对象,所以输出的 this.text'o2'

# 第 8 题

const foo = {
  name: "zhangsan",
  sayName() {
    console.log(this.name);
  },
};
const bar = {
  name: "xxx",
};
foo.sayName.call(bar); // ?
foo.sayName.apply(bar); // ?
foo.sayName.bind(bar)(); // ?

输出:

"xxx";
"xxx";
"xxx";

原因:使用 callapplybind 都显式改变了 this 的指向,所以都指向 bar 对象。

# 第 9 题

function Person1(name) {
  this.name = name;
}
function Person2(name) {
  this.name = name;
  return {};
}
function Person3(name) {
  this.name = name;
  return {
    name: "xxx",
  };
}
function Person4(name) {
  this.name = name;
  return 1;
}
const p1 = new Person1("zhangsan");
const p2 = new Person2("zhangsan");
const p3 = new Person3("zhangsan");
const p4 = new Person4("zhangsan");
console.log(p1.name); // ?
console.log(p2.name); // ?
console.log(p3.name); // ?
console.log(p4.name); // ?

输出:

"zhangsan";
undefined;
"xxx";
"zhangsan";

原因:构造函数内,如果返回值是一个对象, this 指向这个对象,否则 this 指向新建的实例。

# 第 10 题

var userName = "xxx";
const obj1 = {
  userName: "zhangsan",
  fn() {
    setTimeout(function () {
      console.log(this.userName); // ?
    });
  },
};
const obj2 = {
  userName: "zhangsan",
  fn() {
    setTimeout(() => {
      console.log(this.userName); // ?
    });
  },
};
obj1.fn();
obj2.fn();

输出:

"xxx";
"zhangsan";

原因: obj1 中调用的 this 是函数嵌套函数中的 this ,所以指向 window ,输出 window.userName'xxx'

obj2 中因为箭头函数的原因, this 指向上层作用域,所以指向对象 obj2 ,输出 obj2.userName'zhangsan'

# 第 11 题

var userName = "xxx";
const obj1 = {
  userName: "lin",
  fn() {
    return () => {
      console.log(this.userName); // ?
    };
  },
};
const obj2 = {
  userName: "lin",
  fn() {
    return () => {
      return () => {
        console.log(this.userName); // ?
      };
    };
  },
};
obj1.fn()();
obj2.fn()()();

出:

"lin";
"lin";

原因:因为有箭头函数,嵌套多少层,最终都会一层一层地读到第一层,所以 this 指向为对象本身。

# 第 12 题

function foo() {
  return () => {
    console.log(this.a);
  };
}
const obj1 = {
  a: 2,
};
const obj2 = {
  a: 3,
};
const bar = foo.call(obj1); // ?
bar.call(obj2); // ?

输出:

2;

原因: foo.call(obj1) 输出 2 是因为 call 显式改变 this 指向。

bar.call(obj2) 啥也不输出,因为箭头函数的绑定无法被修改

# 第 13 题

var a = 100;
const foo = () => () => {
  console.log(this.a);
};
const obj1 = {
  a: 2,
};
const obj2 = {
  a: 3,
};
const bar = foo.call(obj1); // ?
bar.call(obj2); // ?

输出:

100;

原因同第 12 题。

# 一些参考资料

const o1 = {
    text: "o1",
    fn() {
        console.log(this);
        return this.text;
    },
};
const o2 = {
    text: "o2",
    fn() {
        return o1.fn();
    },
};
const o3 = {
    text: "o3",
    fn() {
        var fn = o1.fn;
        // console.log(this.text);
        let res = fn();
        return res;
        // return fn();
    },
};
console.log(o1.fn()); // ?
console.log(o2.fn()); // ?
console.log(o3.fn()); // ? undefined

1684204164738

JavaScript 中的 this 到底是什么? - 掘金 (juejin.cn)

彻底搞懂 JavaScript 中的 this 指向问题 - 西岭老湿的文章 - 知乎
https://zhuanlan.zhihu.com/p/42145138

1684204258690

const o1 = {
    text: "o1",
    fn() {
        console.log(this);
        return this.text;
    },
};
const o2 = {
    text: "o2",
    fn() {
        return o1.fn();
    },
};
const o3 = {
    text: "o3",
    fn() {
        var fn = o1.fn;
        // console.log(this.text);
        let res = fn();
        return res;
        // return fn();
    },
};
console.log(o1.fn()); // ?
console.log(o2.fn()); // ?
console.log(o3.fn()); // ? undefined

# 实现 call 函数

# 挑战介绍

本节我们来挑战一道大厂面试真题 —— 实现 call 函数。

# 挑战准备

新建一个 myCall.js 文件,在文件里写一个名为 myCall 的函数,并导出这个函数,如下图所示:

1683115092638

这个文件在环境初始化时会自动生成,如果发现没有自动生成就按照上述图片自己创建文件和函数,函数代码如下:

function myCall(fn, context) {
  // 补充代码
}
module.exports = myCall;

# 挑战内容

请封装一个 myCall 函数,用来实现 call 函数的功能。

myCall 函数接收多个参数,第一个参数是 fn ,代表要执行的函数,第二个参数是 context ,代表需要显式改变的 this 指向,之后的参数都是 fn 的参数。

示例:

const person = {
  userName: "zhangsan",
};
function fn() {
  return this.userName;
}
myCall(fn, person); // 执行函数 fn,返回 'zhangsan'
//fn 传参数的情况
const obj = {
  count: 10,
};
function fn(x, y, z) {
  return this.count + x + y + z;
}
myCall(fn, obj, 1, 2, 3); // 执行函数 fn,返回 16

暂时觉得这样做不行,这里是将函数设置为对象的属性的,假如对象本来有这个属性名,这样会不会覆盖了对象原来的属性值,

可以改写成继续使用函数的 call 的么?

# 本节介绍

本节是 “实现 call 函数” 这道题的讲解,在面试场景中,光是能说清楚 this 的指向是不够的,面试官很喜欢考查 callapplybind 这三个函数的用法和原理,本节我们就先从 call 这个函数开始学起,在讲解 call 函数的原理的同时,也会介绍一些它的常用使用场景。

# 知识点

  • call 原理
  • call 使用场景

# 题解

在讲解之前,先给出本题的答案,代码如下:

function myCall(fn, context = window) {
  context.fn = fn;
  const args = [...arguments].slice(2);
  const res = context.fn(...args);
  delete context.fn;
  return res;
}

# 题解分析

在尝试去实现 call 函数之前,我们再来回顾一下它的用法, call 函数是为了改变 this 的指向,代码如下:

var userName = "xxx";
const person = {
  userName: "zhangsan",
};
function fn() {
  console.log(this.userName);
}
fn.call(); // 直接调用,this 指向 window,输出 'xxx'
fn.call(person); // 用 call,this 指向 person,输出 'zhangsan'

call 是写到 Function.prototype 上的方法,而本节我们要实现的 myCall 是把函数当作参数传递进去,两者只是调用形式不同,原理都是一样的。

我们尝试来实现一下显式改变 this 指向的功能,调用对象中的函数, this 指向为这个对象,所以我们需要做的操作是:

  • 把函数 fn 挂载到要指向的对象 context 上。
  • 执行 context.fn ,执行完了删除 context 上的 fn 函数,避免对传入对象的属性造成污染。

代码实现如下:

function myCall(fn, context) {
  // 把函数 fn 挂载到对象 context 上。
  context.fn = fn;
  // 执行 context.fn
  context.fn();
  // 执行完了删除 context 上的 fn 函数,避免对传入对象的属性造成污染。
  delete context.fn;
}

测试一下:

var userName = "xxx";
const person = {
  userName: "zhangsan",
};
function fn() {
  return this.userName;
}
myCall(fn, person); // 输出 'zhangsan'
myCall(fn, window); // 输出 'xxx'

可以看到,仅仅三行代码,我们就实现了 call 函数的核心功能。

不过这里面有一些其他细节需要处理,比如:

  • 要处理 context 不传值的情况,传一个默认值 window
  • 处理函数 fn 的参数,执行 fn 函数时把参数携带进去。
  • 获取执行函数 fn 产生的返回值,最终返回这个返回值。

最终实现代码如下:

// 要处理 context 不传值的情况,传一个默认值 window。
function myCall(fn, context = window) {
  context.fn = fn;
  // 处理函数 fn 的参数,执行 fn 函数时把参数携带进去。
  const args = [...arguments].slice(2);
  // 获取执行函数 fn 产生的返回值。
  const res = context.fn(...args);
  delete context.fn;
  // 最终返回这个返回值
  return res;
}

测试一下:

const obj = {
  count: 10,
};
function fn(x, y, z) {
  console.log(this.count + x + y + z);
}
myCall(fn, obj, 1, 2, 3); // 执行函数 fn,输出 16

这样我们就实现了 call 函数该有的功能,原生的 call 函数是写到 Function.prototype 上的方法,我们也尝试在函数的原型上实现一个 myCall 函数,只需稍加改造即可,代码实现如下:

// 写到函数的原型上,就不需要把要执行的函数当作参数传递进去
Function.prototype.myCall = function (context = window) {
  // 这里的 this 就是这个要执行的函数
  context.fn = this;
  // 参数少了一个,slice (2) 改为 slice (1)
  const args = [...arguments].slice(1);
  const res = context.fn(...args);
  delete context.fn;
  return res;
};

测试一下:

const obj = {
  count: 10,
};
function fn(x, y, z) {
  console.log(this.count + x + y + z);
}
fn.myCall(obj, 1, 2, 3); // 执行函数 fn,输出 16

# 知识延伸

# 处理边缘情况

上文在函数原型上实现的 myCall 函数,还有优化的空间,有一些边缘的情况,可能会导致报错,比如把要指向的对象指向一个原始值,代码如下:

fn.myCall(0); // Uncaught TypeError: context.fn is not a function

1684204522322

这时,就需要参考一下原生的 call 函数是如何解决的这个问题,我们打印出来看一下:

var userName = "xxx";
const person = {
  userName: "zhangsan",
};
function fn(type) {
  console.log(type, "->", this.userName);
}
fn.call(0, "number");
fn.call(1n, "bigint");
fn.call(false, "boolean");
fn.call("123", "string");
fn.call(undefined, "undefined");
fn.call(null, "null");
const a = Symbol("a");
fn.call(a, "symbol");
fn.call([], "引用类型");

1684204546507

可以看到, undefinednull 指向了 window ,原始类型和引用类型都是 undefined

其实是因为,原始类型指向对应的包装类型,引用类型就指向这个引用类型,之所以输出值都是 undefined ,是因为这些对象上都没有 userName 属性。

改造一下我们的 myCall 函数,实现原始类型的兼容,代码如下:

Function.prototype.myCall = function (context = window) {
  if (context === null || context === undefined) {
    context = window; //undefined 和 null 指向 window
  } else {
    context = Object(context); // 原始类型就包装一下
  }
  context.fn = this;
  const args = [...arguments].slice(1);
  const res = context.fn(...args);
  delete context.fn;
  return res;
};

还有另外一种边缘情况,假设对象上本来就有一个 fn 属性,执行下面的调用,对象上的 fn 属性会被删除,代码如下:

const person = {
  userName: "zhangsan",
  fn: 123,
};
function fn() {
  console.log(this.userName);
}
fn.myCall(person);
console.log(person.fn); // 输出 undefined,本来应该输出 123

因为对象上本来的 fn 属性和 myCall 函数内部临时定义的 fn 属性重名了。

还记得 Symbol 的作用吗,可以用 Symbol 来防止对象属性名冲突问题,继续改造 myCall 函数,代码实现如下:

Function.prototype.myCall = function (context = window) {
  if (context === null || context === undefined) {
    context = window;
  } else {
    context = Object(context);
  }
  const fn = Symbol("fn"); // 用 symbol 处理一下
  context[fn] = this;
  const args = [...arguments].slice(1);
  const res = context[fn](...args);
  delete context[fn];
  return res;
};

至此,一个功能尽可能完善的 myCall 函数,终于写完了。

let a1 = Symbol("a");
let a2 = Symbol("a");
console.log(a1);
console.log(a2);
let obj = {};
obj[a1] = "hello";
console.log(obj[a1]); // "hello"
console.log(obj[a2]); // undefined

# call 使用场景

call 的使用场景非常多,所有调用 call 的使用场景都是为了显式地改变 this 的指向,能用 call 解决的问题也能用 apply 解决,因为它们俩只是传参形式不同。下面一起来看 call 常用的四个使用场景。

# 1. 精准判断一个数据类型

精准地判断一个数据的类型,可以用到 Object.prototype.toString.call(xxx)

调用该方法,统一返回格式 [object Xxx] 的字符串,用来表示该对象。

// 引用类型
console.log(Object.prototype.toString.call({})); // '[object Object]'
console.log(Object.prototype.toString.call(function () {})); // "[object Function]'
console.log(Object.prototype.toString.call(/123/g)); // '[object RegExp]'
console.log(Object.prototype.toString.call(new Date())); // '[object Date]'
console.log(Object.prototype.toString.call(new Error())); // '[object Error]'
console.log(Object.prototype.toString.call([])); // '[object Array]'
console.log(Object.prototype.toString.call(new Map())); // '[object Map]'
console.log(Object.prototype.toString.call(new Set())); // '[object Set]'
console.log(Object.prototype.toString.call(new WeakMap())); // '[object WeakMap]'
console.log(Object.prototype.toString.call(new WeakSet())); // '[object WeakSet]'
// 原始类型
console.log(Object.prototype.toString.call(1)); // '[object Number]'
console.log(Object.prototype.toString.call("abc")); // '[object String]'
console.log(Object.prototype.toString.call(true)); // '[object Boolean]'
console.log(Object.prototype.toString.call(1n)); // '[object BigInt]'
console.log(Object.prototype.toString.call(null)); // '[object Null]'
console.log(Object.prototype.toString.call(undefined)); // '[object Undefined]'
console.log(Object.prototype.toString.call(Symbol("a"))); // '[object Symbol]'

这里需要调用 call 就是为了显式地改变 this 指向为我们的目标变量。

如果不改变 this 指向为我们的目标变量 xxxthis 将永远指向调用的 Object.prototype ,也就是原型对象,对原型对象调用 toString 方法,结果永远都是 [object Object] ,如下代码所示:

Object.prototype.toString([]); // 输出 '[object Object]'  不调用 call,this 指向 Object.prototype,判断类型为 Object。
Object.prototype.toString.call([]); // 输出 '[object Array]'   调用 call,this 指向 [],判断类型为 Array
Object.prototype.toString(1); // 输出 '[object Object]' 不调用 call,this 指向 Object.prototype,判断类型为 Object。
Object.prototype.toString.call(1); // 输出 '[object Number]' 调用 call,this 指向包装对象 Number {1},判断类型为 Number

# 2. 伪数组转数组

伪数组转数组,在 es6 之前,可以使用 Array.prototype.slice.call(xxx)

function add() {
  const args = Array.prototype.slice.call(arguments);
  // 也可以这么写 const args = [].slice.call (arguments)
  args.push(1); // 可以使用数组上的方法了
}
add(1, 2, 3);

原理同精准判断一个数据类型相同,如果不改变 this 指向为目标伪数组, this 将永远指向调用的 Array.prototype ,就不会生效。

// 从 slice 方法原理理解为什么要调用 call
Array.prototype.slice = function (start, end) {
  const res = [];
  start = start || 0;
  end = end || this.length;
  for (let i = start; i < end; i++) {
    res.push(this[i]); // 这里的 this 就是伪数组,所以要调用 call
  }
  return res;
};

# 3.ES5 实现继承

在一个子构造函数中,你可以通过调用父构造函数的 call 方法来实现继承。

function Person(name) {
  this.name = name;
}
function Student(name, grade) {
  Person.call(this, name);
  this.grade = grade;
}
const p1 = new Person("zhangsan");
const s1 = new Student("zhangsan", 100);

上面的代码示例中,构造函数 Student 中会拥有构造函数 Person 中的 name 属性, grade 属性是 Student 自己的。

这里的代码如果换成 ES6 的,就等价于下面的代码:

class Person {
  constructor(name) {
    this.name = name;
  }
}
class Student extends Person {
  constructor(name, grade) {
    super(name);
    this.grade = grade;
  }
}
const p1 = new Person("zhangsan");
const s1 = new Student("zhangsan", 100);

关于继承,同学们掌握 ES6 的实现方式就好,ES5 的做了解即可,因为现在大家基本上都用 ES6 的写法了,如果想对 ES5 的继承有深入研究,可以去看一下《JavaScript 高级程序设计(第 4 版)》原型和原型链相关的章节。

# 4. 处理回调函数 this 丢失问题

执行下面的代码,回调函数会导致 this 丢失。

const obj = {
  userName: "zhangsan",
  sayName() {
    console.log(this.userName);
  },
};
obj.sayName(); // 输出 'zhangsan'
function fn(callback) {
  if (typeof callback === "function") {
    callback();
  }
}
fn(obj.sayName); // 输出 undefined

导致这样现象的原因是回调函数执行的时候 this 指向已经是 window 了,所以输出 undefined

可以使用 call 改变 this 指向,代码如下:

const obj = {
  userName: "zhangsan",
  sayName() {
    console.log(this.userName);
  },
};
obj.sayName(); // 输出 'zhangsan'
function fn(callback, context) {
  // 定义一个 context 参数,可以把上下文传进去
  if (typeof callback === "function") {
    callback.call(context); // 显式改变 this 值,指向传入的 context
  }
}
fn(obj.sayName, obj); // 输出 'zhangsan'

# 本节总结

本节我们介绍了 call 函数的实现原理,最关键的点就是要显式地改变 this 的指向,其余全部都是在处理一些边界情况。

在知识延伸部分,我们介绍了 call 函数一些常用的使用场景,所有调用 call 的使用场景还是为了显式地改变 this 的指向。

需要注意的是,能用 call 解决的问题也能用 apply 解决,因为它们俩只是传参形式不同。