image-20260127113616394

JavaScript核心知识

在上一章我们介绍了JS的基础语法,包括变量的创建、数据类型、运算符以及流程控制语句,只不过,仅仅有这些东西还不足以实现高级程序设计,我们还需要学习JavaScript的更多特性,本章我们将为大家介绍函数、面向对象编程以及JS常用对象的使用。

函数初步

很多编程语言都有函数的概念,JS也不例外,实际上,在上一章我们一直使用的console.logalert就是函数,不过这个函数是标准库中已经实现好了的,这一部分我们就来为大家详细介绍一下如何创建和使用函数。

首先,函数的具体定义是什么呢?

函数是完成特定任务的独立程序代码单元。

其实简单来说,函数时为了完成某件任务而生的,可能我们要完成某个任务并不是一行代码就可以搞定的,现在可能会遇到这种情况:

js 复制代码
let a = 10;

console.log("H");   //比如下面这三行代码就是我们要做的任务
console.log("A");
a += 10;

if(a > 20) {
    console.log("H");   //这里我们还需要执行这个任务
    console.log("A");
    a += 10;
}

switch (a) {
    case 30:
        console.log("H");   //这里又要执行这个任务
        console.log("A");
        a += 10;
}

可以看到,这个任务执行的操作其实是相同的,但是我们每次要执行这个任务时,都要完完整整地将任务的每一行代码重新写一遍,如果我们的程序中多处都需要执行这个任务,每个地方都去完整地写一遍,这实在是太臃肿了,那有没有一种更好的办法能优化我们的代码呢?

这时我们就可以考虑使用函数了,我们可以将我们的程序逻辑代码全部编写到函数中,当我们执行函数时,实际上执行的就是函数中的全部内容,也就是执行对应的任务,每次需要做这个任务时,只需要调用函数即可。

创建和使用函数

首先我们来看看如何创建一个函数,其实创建一个函数是很简单的,格式如下:

c 复制代码
function 函数名称([函数参数...]) { 函数体 }

这里函数名称也是有要求的,并不是所有的字符都可以用作函数名称,它的命名规则与变量的命名规则基本一致,所以这里就不一一列出了,函数里面就是我们要写的函数执行逻辑了,也就是我们我们可以将一系列的代码全部封装到函数里面,我们来尝试创建一个简单的函数:

js 复制代码
function test() {
    console.log("我是第一句")
    console.log("我是第二句")
    console.log("我是第三句")
}

这个函数包含了我们封装的三句代码,那么怎么才能使用这个函数呢,调用函数非常简单,和之前一样,我们只需要写下函数的名字再加上花括号即可:

js 复制代码
test()

这样,当执行到这一行代码时,就会自动调用前面声明好的函数了,调用函数时,会自动执行函数体内的代码。

不过需要注意的是,函数声明不一定需要放到调用它之前,我们可以在任何位置调用函数:

js 复制代码
sum()
function sum() {
    console.log("66666")
}

这是因为 JavaScript 引擎在执行代码前,会先扫描整个作用域,将所有函数声明提升到作用域的顶部。

你可以简单地理解为,函数无论写到哪里,在执行之前都会被扫描一遍,然后把函数声明自动排到前面去。

此外,我们还可以给函数一些参数,让函数拿到我们给定的参数进行使用,函数参数可以直接在小括号内声明,多个参数可以使用逗号隔开:

js 复制代码
function test(a) {  //定义参数只需要写上参数名即可,后续在函数中就可以直接使用这个参数了
    console.log(`a + 10 = ${a + 10}`)
    console.log(`a - 10 = ${a - 10}`)
    console.log(`a ** 10 = ${a ** 10}`)
}

test(2)  //调用函数时,需要传入实际参数

像这样在函数声明中定义的参数我们称为形式参数,而在调用函数时传入的实际值,我们称为实际参数,形式参数就像是函数中声明的一个变量一样,在调用函数时传入的实际参数会自动赋值给形式参数,所以我们可以在函数中将其当做一个变量来使用:

js 复制代码
function test(a) {
    a = a + 20
    console.log(a)
}

但是注意,函数的形式参数作用域仅限函数体以内,也就是函数后面紧跟的代码块内部,超出此区域是无法访问的,这和前面的流程控制语句是一样的,函数的形式参数相当于重新创建的一个新的变量,很多小伙伴认为里面用的就是外面传进去的,就容易出现:

js 复制代码
function swap(a, b) {
    const tmp = a;
    a = b;
    b = tmp;
}
let a = 6, b = 9
swap(a, b)

这是初学者很容易犯的错误,实际上这种操作根本不能交换外面的实际参数变量的值。

注意,即使函数存在形式参数,在调用函数时也可以不传递实际参数:

js 复制代码
function test(a) {
    console.log(a)  //undefined
}

test()

因为这里的函数调用并未传入一个实际参数,所以相当于函数的形式参数未被赋值,默认情况下就和变量一样,是undefined,不过,在ES6之后,我们也可以为函数形参设置一个默认值:

js 复制代码
function test(a = 6) {  //使用赋值运算符为其指定默认值
    console.log(a)
}

当调用函数时没有传递实际参数,此时就会使用默认值作为形式参数的值。

函数除了接受参数进行操作外,我们也可以让函数返回一个结果,使用return关键字来将函数结果进行返回:

js 复制代码
function sum(a, b) {
    return a + b  //return表示返回结果,这里返回的是a+b结果
}

const result = sum(10, 20)  //我们可以使用一个变量来接收函数的返回结果
console.log(result)

可以看到,当我们从函数中返回结果时,外部可以得到这个结果,你可以使用变量接收它,也可以把他当做另一个函数的实际参数去使用:

js 复制代码
const a = sum(sum(10, 20), 30)
console.log(sum(10, 20))  //想怎么玩都可以,但是注意这个是一个右值

默认情况下,如果一个函数没有任何return语句或者走到其某一个分支下没有return语句,那么默认的返回值是undefined

js 复制代码
function test() {
    console.log("什么都不返回")
}
console.log(test())

到这里,我们就可以解释之前的问题了,为什么在之前我们在控制台使用console.log的时候会输出一个undefined呢?就是因为这个函数它没有返回值:

image-20260128175136770

我们在控制台调用函数之后,会自动展示函数的返回值。

需要注意的是,函数一旦返回,后续所有内容都不会再执行了,因为返回代表着这个函数已经结束任务了:

js 复制代码
function test(a) {
    if(a > 0) {
        return "6666"
    }
    console.log("HelloWorld")
    return "7777"
}

console.log(test(10))

这里由于if判断提前执行了return语句,所以后续的操作都不会执行了。

**思考:**下面的函数会怎么执行?

js 复制代码
function test() {
    for (let i = 0; i < 5; i++) {
        if(i === 3) {
            return
        }
        console.log(i)
    }
}

递归调用(选学)

在之前的章节中,我们已经学习了函数的定义与调用、作用域以及如何向函数传递参数。这一节,我们将深入探讨一个在编程中非常有意思,同时也极具挑战性的概念:递归(Recursion)

什么是递归呢?简单来说,递归就是函数在内部调用了它自己,就像下面这个隧道镜一样,随着调用的进行越来越深:

image-20260128182848462

很多小伙伴第一次听到这个定义可能会觉得:这不就变成死循环了吗?函数 A 调 A,A 内部又调 A……这不就没完没了了吗?其实,递归非常像我们生活中的“套娃”或者是两面相对的镜子产生的无限镜像。但在编程中,我们必须给这个“套娃”设置一个终点,否则程序确实会因为没完没了的调用而崩溃。

我们先来看一个最简单的、错误的递归例子(没有出口):

js 复制代码
function test() {
    console.log("我正在调用自己...");
    test(); // 内部再次调用自己
}

test();
image-20260128182337298

执行这段代码,你会发现控制台很快就会报错:Uncaught RangeError: Maximum call stack size exceeded,这就是所谓的栈溢出,因为计算机的内存是有限的,它不能无限制地存储还没执行完的函数。

不过,只要我们加以控制,设置一个合适的出口,就可以达成事半功倍的效果,其中一个比较经典的例子就是:阶乘计算,在数学中,一个正整数 n 的阶乘(记作 n!)定义为:n!=n×(n−1)×(n−2)×⋯×1 ,比如5的阶乘就是: 5!=5×4×3×2×1=120

我们可以发现一个规律:5!=5×4!,而 4!=4×3!。 推广开来,就是:f(n)=n×f(n−1),这就是我们的递归表达式,那么终点在哪里呢?当 n=1 时,1!=1,这就是我们的递归出口,因为已经不用继续向下了:

js 复制代码
function factorial(n) {
    // 1. 首先设置出口:如果 n 是 1,直接返回 1,不再递归
    if (n === 1) {
        return 1;
    }
    // 2. 递归表达式:n 乘以 (n-1) 的阶乘
    return n * factorial(n - 1);
}

console.log(factorial(5)); // 输出 120

为了让各位小伙伴彻底理解递归,我们拆解一下 factorial(3) 的执行过程:

  1. 调用 factorial(3)n 不等于 1,进入递归,准备返回 3 * factorial(2)。此时函数还没算完,卡在这等 factorial(2) 的结果。
  2. 进入 factorial(2)n 不等于 1,准备返回 2 * factorial(1)。同样卡在这等 factorial(1)
  3. 进入 factorial(1):此时触发了 if (n === 1),直接返回 1
  4. 开始回溯
    • factorial(2) 拿到了 factorial(1) 的结果 1,计算 2 * 1,返回 2
    • factorial(3) 拿到了 factorial(2) 的结果 2,计算 3 * 2,返回 6

递归就像是你去问路:你问路人甲,路人甲说“我不知道,你去问路人乙”,乙说“问丙”,丙说“就在这!”,然后丙告诉乙,乙再跑回来告诉你。

对象和引用

我们在前面已经学习了面向过程编程,也可以自行编写出简单的程序了。我们接着就需要认识 面向对象程序设计(Object Oriented Programming)它是我们在JavaScript语言中要学习的重要内容,面向对象也是高级语言的一大重要特性。

在 JavaScript 中,几乎所有的东西都可以看作是对象。如果说函数是完成任务的“动作”,那么对象就是拥有数据和行为的“实体”。

创建对象

在现实生活中,对象就是一个具体的“东西”。比如你的手机、电脑、甚至你自己都是对象。每个对象都有:

  • 属性:描述它的特征(比如手机的品牌、颜色、屏幕尺寸)
  • 行为/方法:它能做的事情(比如手机能打电话、拍照、发消息)

比如你自己,有你自己的名字、年龄、性别,你的手机有它的颜色、尺寸、内存容量等,所有的对象都有各自的属性,甚至还有一些行为,比如你自己有学习的行为,吃饭的行为,你的手机由上网的功能、打电话的功能、拍照的功能。

在 JavaScript 中,对象就是对现实世界物体的抽象,用代码把这些“东西”的特征和功能封装起来,让程序能像操作真实物品一样操作它们。JavaScript 的对象是一种复合数据类型,用于存储多个相关的数据和功能。其中最简单的对象可以像这样声明:

js 复制代码
const obj = {}
console.log(typeof obj)  //得到object类型

这就是所谓的对象字面量语法,使用一对花括号就可以创建一个空对象,在打印其类型名称时,得到的是object,注意,对象并不属于基本数据类型,它属于引用类型(我们会在后续介绍引用类型和基本类型的区别)不过,空对象并没有什么实际用途,我们通常需要在对象中存储一些数据。

对象中存储的数据我们称为属性,每个属性都有一个名称和对应的值。我们可以在创建对象时直接定义属性,比如我们想要创建一个人的对象,那么这个人肯定有名字、年龄、性别等属性:

js 复制代码
const person = {
    name: "张三",
    age: 18,
    gender: "男"
}

可以看到,属性的定义格式是 属性名: 属性值,多个属性之间用逗号分隔,就像定义一个变量一样。属性值可以是任意类型的数据(包括上一章学习的数字、字符串、布尔值,甚至可以是另一个对象或函数)需要注意的是,属性名可以是任何有效的字符串,它的命名规则不像变量那样有着严格约束:

js 复制代码
const person = {
    "2$name": "张三",  //当对象的属性名称存在特殊字符干扰时,可以直接使用字符串形式表示,效果完全一样
    age: 18,
    gender: "男"   //如果是最后一个属性可以不接逗号
}

如果字符串是变量存储的,我们也可以直接将一个变量的值作为属性名称使用:

js 复制代码
const key = "name"  //如果不是字符串,在作为属性名称时会发生隐式类型转换
const person = {
    [key]: "小明",
  	age: 18,
    gender: "男"
}

不过需要注意的是,如果变量存储的不是字符串,而是其他类型,那么会自动转换为字符串类型再作为属性名称使用。下一节我们会详细介绍对象的属性如何使用。

对象的属性

前面我们体验了如何创建一个对象,对象可以具有多种属性,我们可以把它当做一个真正的现实世界的对象来看。

那么创建好对象后,我们如何访问其中的属性呢?有两种方式,其中第一种方式是使用.运算符来访问对象的属性,比如这里我们要访问person的年龄和性别,就可以直接.age或者.gender访问:

js 复制代码
console.log(person.gender)   //输出:男
console.log(person.age)    //输出:18

除此之外,我们也可以使用方括号来访问:

js 复制代码
console.log(person["2$name"])   //只需在方括号中传入字符串形式的变量名称即可
console.log(person["age"])    //这对于一些特殊名称的属性或是需要动态获取名称访问的属性,就非常好用

这种方式将属性名作为字符串放在方括号中。虽然写起来麻烦一些,但它有一个独特的优势:当属性名包含特殊字符或需要动态确定时,必须使用方括号表示法。利用这种机制,我们还可以让方括号使用变量来实现动态访问属性:

js 复制代码
const key = "2$name"
console.log(person[key])

这在很多复杂场景下都非常灵活,不过,在简单情况下我们还是更建议大家选择.运算符的方式进行属性访问,它看起来更直观简洁。

对象的属性也可以参与到运算当中,或是给其他变量或对象的属性赋值,就像使用变量那样:

js 复制代码
const name = person.name  //作为结果赋值
console.log(person.name + "666")  //也可以参与运算

需要注意的是,如果我们访问了一个不存在的属性,并不会出现报错:

js 复制代码
console.log(person.title)

此时得到的结果是一个undefined而不是直接报错。

除了读取对象的属性值之外,也可以修改对象的属性,我们可以直接使用之前的赋值运算符来进行赋值:

js 复制代码
person.age = 16

可以看到,无论是修改还是添加,语法都是一样的:对象名.属性名 = 新值,当然,我们也可以使用方括号的形式:

js 复制代码
person["age"] = 16

可以看到,当我们直接对已存在的属性赋值时,就会覆盖原来的值。不过需要注意的是,我们也可以对一些对象中不存在的属性赋值,如果对象中没有这个属性,那么就会自动创建:

js 复制代码
person.school = "重庆邮电大学移通学院"
console.log(person)
image-20260203152127999

既然可以凭空创建属性,那么也可以凭空删除属性,如果不再需要某个属性,我们可以使用 delete 关键字将其删除:

js 复制代码
delete person.age
image-20260203153215604

如果你学习过Java的Map,你会觉得JS中的对象更像是一个Map,而不是Java中的对象。

此外,delete运算也是有结果的,如果结果为true表示在删除属性之前对象中确实存在这个属性或是这个属性本来就不存在于对象中,如果结果为false,那么表示这个属性是无法被删除的,也就无法进行删除操作(后续我们介绍的一些内置对象的某些属性就是不可删除的)不过,虽然delete删除方便,但是它的弊端也有很多:

  1. 现代 JS 引擎(V8、SpiderMonkey 等)会给对象做结构优化(Hidden Class / Shape)比如一开始的时候对象里面有ab两个属性,那么引擎会认为 “这个对象永远有 a 和 b” 从而进行各种优化,一旦你进行了属性删除,引擎会发现这个对象结构变了,之前的优化全部作废。
  2. 影响属性的查找,暴露原型链(有关原型链的内容我们会在后续课程中介绍)

因此,在正常情况下,我们更建议大家直接为对象上某个不需要的值设置一个undefined作为值,表示不存在的同时也能使得属性得以正常保留。

对象的方法

对象不仅可以存储数据,还可以存储函数,它就像是我们为对象赋予了一种行为。存储在对象中的函数我们称为方法(Method):

js 复制代码
const person = {
    name: "张三",
    age: 18,
    gender: "男",
    //由于存在属性名称,对象中的方法可以不带函数名称,直接编写参数列表和函数体
  	//这种没有函数名的函数,我们称为匿名函数
    say: function () {
        console.log("大家好")
    }
}

如果对象中存在函数属性,那么我们也可以通过对象来调用这个函数,这里依然是使用.运算符:

js 复制代码
person.say()  //函数的名称就是属性的名称

调用对象的方法,实际上就像是在让这个对象执行这个行为,就好像是我们真的让一个人去做一件事情一样。既然对象有自己的属性,那么我们能不能联动一下,让对象在执行方法的时候,顺便访问一下自己的属性呢?我们可以使用this关键字来代表当前对象本身:

js 复制代码
const person = {
    name: "张三",
    age: 18,
    gender: "男",
    say: function () {
        console.log(`大家好,我叫${this.name}`)   //this.name表示访问当前对象的name属性
    }
}

这样,在我们调用say方法的时候,就会去获取当前对象的name属性,并打印到控制台。

在ES6之后,官方简化了对象方法的声明方式,我们可以直接像这样写:

js 复制代码
const person = {
    name: "张三",
    age: 18,
    gender: "男",
    say() {   //无需function关键字,直接在属性名称后添加参数列表和函数体即可
        console.log(`大家好,我叫${this.name}`)
    }
}

思考:下面的代码执行结果是什么呢?

js 复制代码
const person = {
    name: "小明",
    say() {
        console.log(`大家好,我叫${this.name}`)
    }
}

const person2 = {
    name: "小红",
    say() {
        console.log(`大家好,我叫${this.name}`)
    }
}

person.say()
person2.say()

由于此时的函数调用是基于对象的,因此,不同的对象调用方法,那么对象属性的作用域也是在各自的空间内,我们让小明执行say的动作的时候,相当于是让小明打印自己的名字出来。而我们让小红执行时,就是小红自己的名字。所以,大家在使用对象时,一定要时刻记得自己操作的是一个真正的实体。

属性的遍历

前面我们已经学习了如何访问、修改、添加和删除对象的属性,但在实际开发中,还有一种非常常见的需求:我不知道对象里到底有多少个属性,想把它们一个个“拿出来看看”,比如,我们有这样一个对象:

js 复制代码
const person = {
		name: "小明",
    age: 18,
    gender: "男",
    school: "深圳职业技术学院"
}

如果我们想把这个对象里的所有属性都打印出来,难道要一个个手写吗?

js 复制代码
console.log(person.name)
console.log(person.age)
console.log(person.gender)
console.log(person.school)

这样写显然不现实,一旦属性多了或者属性是动态变化的,代码就完全没法维护了。这时,我们就需要使用 对象属性的遍历

前面我们为大家介绍了for循环语句,它可以实现循环执行某个任务,而在这里我们可以是一种新的for语法来实现对对象属性遍历。在 JavaScript 中,最经典、最常见的对象遍历方式,就是使用 for...in 循环:

js 复制代码
for (let key in 对象) {
    // key 表示属性名
}

我们来直接遍历刚才的 person 对象试试:

js 复制代码
for (let key in person) {
    console.log(key)   //依次打印每一个属性的字符串名称
}

可以看到,for...in依次取出对象中所有的属性名,并赋值给循环变量 key,一定要注意 key 是属性名(字符串)不是属性值。既然 key 是属性名,那么我们就可以利用之前学过的 方括号访问方式,拿到对应的属性值:

js 复制代码
for (let key in person) {
    console.log(key, person[key])  //这样就可以同时访问属性名称和值了
}

在遍历过程中,我们不仅可以读取属性,还可以修改属性值

js 复制代码
for (let key in person) {
    person[key] = "已处理"
}
console.log(person)

当然,for语句除了能够遍历普通对象之外,还可以遍历我们后续学习的数组类型,有关数组的知识点我们会在后续章节中介绍。

符号类型

Symbol 是 ES6 引入的一种新的基本数据类型,它的主要作用是创建唯一的标识符,避免属性名冲突。在 Symbol 出现之前,对象的属性名只能是一个字符形式的名称,比如name属性:

js 复制代码
const person = { name: "小明" }
// 在使用过程中可能会被不经意地覆盖掉
person.name = "小红"

虽然正常情况下没问题,但是如果别人在不知道有这个属性的情况下进行赋值,就会导致原来的 name 被覆盖。在大型项目 / 库 / 框架中,可能会出现很多人操作同一个对象,就很容易出现“撞属性名”,而Symbol就是为了解决这个问题的。

创建一个Symbol类型的值非常简单,类似于进行一个函数调用:

js 复制代码
const s = Symbol()

或是在其中添加参数,来为符号增加备注:

js 复制代码
const s1 = Symbol("id")   //可以加字符串描述(注意这里只是备注,没有实际效果)
const s2 = Symbol("id")

这里相当于我们创建了两个新的Symbol,它就像是我们创造的计算机中不存在的新符号一样,由于是从未存在的新符号,所以它是独一无二的:

js 复制代码
console.log(s1 === s2)   //false,备注一样没用,因为创建的就是两个新符号

我们可以直接将其作为属性名称:

js 复制代码
const s2 = Symbol()
const s1 = Symbol()
const person = {
    name: "小明",
    [s1]: 666
}
person[s2] = 888
image-20260203182345263

此时,撞车现象就消失了,两个符号都作为属性名称存在。不过需要注意的是,由于这个符号是我们自己创造的,那么当我们需要访问这个符号的属性时,也需要使用这个符号去拿:

js 复制代码
console.log(person[s1])

除此之外,没有其他任何直接访问这个属性的方式,必须拿到符号才可以。

不过有时候,我们可能也 希望大家用的是同一个 Symbol,在JS 内部有一个 全局 Symbol 注册表,我们可以使用Symbol.for("xxx")来访问,传入的字符串作为ID进行辨识:

js 复制代码
const s1 = Symbol.for("token")  //没有就新建
const s2 = Symbol.for("token")  //有就直接拿现成的

可以看到,这里拿到的两个符号实际上就是同一个。

JS官方库也内置了一些提前创建好的符号,我们可以通过Symbol.xxx的形式直接获取:

js 复制代码
const test = Symbol.toPrimitive  //获取JS预置符号

不过,符号类型使用的频率非常少,只有一些库开发者可能会用到,这里我们只做了解就行。

引用类型

在 JavaScript 中,数据类型主要分为两大类:基本类型(Primitive Types)引用类型(Reference Types)

  • 基本数据类型(number、string、boolean、undefined、null、symbol、bigint)
  • 引用类型(object、array、function 等)

基本类型也被称为“原始值”,占据固定大小的空间,基本类型的变量,保存的是具体的值本身,比如:

js 复制代码
let a = 10
const b = a   //把a赋值给b实际上进行的是值交换
console.log(a, b)

这里的b得到的实际上是a所代表值的拷贝,比如这里是10,那么b也会存储一份10这个数据,在计算机底层就是一堆0和1了。

而引用类型(Object 类型)是保存在堆(Heap)内存中的对象,它可以动态地添加、修改或删除属性和方法,引用类型的变量在内存中存储的实际上是一个引用,这个引用(指针)指向堆内存中的实际对象。比如:

js 复制代码
const p1 = {
    name: "小明",
    age: 18
}
const p2 = p1

这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)

image-20220919211443657

那么如何验证呢?我们可以对着p1进行属性修改,然后直接打印p2看看:

js 复制代码
p1.name = "东北雨姐"
console.log(p2)
image-20260203174242546

可以看到,由于这两个变量指向的都是同一个对象,因此我们无论对着哪一个变量进行对象的属性访问,实际上操作的都是同一个对象,改变的也是同一个对象的属性,所以就出现了上面的效果。引用类型存储的不是对象本身,而是对象的引用,复制时也不是对象的拷贝,而是引用的拷贝。

当然,引用类型的变量虽然表示的是指向某个对象,但是有些时候,我们也可以让其不指向任何对象,如果一个变量应该保存引用类型的数据同时又不指向任何一个对象,我们可以使用null这个特殊值来表示:

js 复制代码
const p2 = null

除了表示没有引用外,我们也可以用它来表示没有值,它的意义和我们之前介绍的undefined比较类似,有些时候很多小伙伴不知道该在什么时候用,建议各位小伙伴参考下面的场景进行合理使用:

  • undefined适合系统默认状态,比如方法无返回值,变量未初始化,也就是还不存在这个东西。
  • null表示人为、有意识地表示“空”,常用于对象、数据结构、接口返回,也就是这里本该有东西,这个东西是存在的,只是现在没有。

所以这里建议各位小伙伴如果想让某个变量或属性不代表任何值,就尽可能使用null而不是undefined,还需要注意的是,null也是基本数据类型的一种,虽然typeof运算的结果是object但是它本质是基本类型的一种(历史遗留问题)

此外,需要注意的是,引用类型之间的比较和基本类型不同:

js 复制代码
const p1 = { name: "小明" }
const p2 = p1
const p3 = { name: "小明" }

console.log(p2 === p1)
console.log(p3 === p1)

引用类型比较的是两个变量是否指向同一个对象,而不是比较对象的内容是否相同,所以这里的p3p1虽然内容相同,但是它们不是同一个对象,所以结果是false,同时,无论使用双等号还是三等号,都是对引用进行比较。

不过,引用类型在进行其他运算时,也会按照我们上一章介绍的隐式类型转换规则进行转换后再计算:

  • 如果是对象,先转换成基本类型。
  • 如果是基本类型但是不是需要的类型,根据运算符,将基本类型转为最终需要的格式。

比如,加法在之前既可以进行“数字相加”,也可以进行“字符串拼接”,当遇到对象时,首先会将其转换为基本类型。那么怎么转换成基本类型的呢?实际上,它会按照以下顺序查找并调用对象的方法:

  1. [Symbol.toPrimitive](hint): ES6新增,如果定义了这个方法,直接由它说了算,推荐这种方式。
  2. valueOf(): 返回对象自身的原始值。
  3. toString(): 返回对象的字符串表示。

我们可以通过方法来自由定义类型转换的过程,方法的返回值就是它发生类型转换之后的结果,当需要类型转换时,JS会自动调用我们这里编写的方法,比如我们这里可以设置对象的Symbol.toPrimitive属性:

js 复制代码
p2[Symbol.toPrimitive] = function (hint) {
    console.log(hint)  //查看期待类型
    return this.name  //这里就直接返回对象的name属性作为类型转换之后的值吧
}
console.log(p2 + "AAA")

这里有一个参数 hint,它是一个字符串类型的值,表示转换的期望类型,有三种可能的值:

  • "string":表示需要将对象转换为字符串(如调用 String(obj) 或在模板字符串中使用)
  • "number":表示需要将对象转换为数字(如调用 Number(obj) 或使用数学运算符)
  • "default":表示没有明确的转换类型期望(如使用 + 运算符或 == 比较)
image-20260203190444208

注意,如果这里不返回一个基本类型,会导致程序出现错误,因为无法正常进行转换。

默认情况下,如果我们不手动为对象添加以上三种方法的任何一个,那么会根据场景进行调用,比如期待值是一个字符串的情况下,那么就会优先使用toString方法,在对象中toStirng()有一个默认的实现(即使我们什么都不写,也存在一个默认的行为)对象会自动转换为[object 类型]这样的字符串,这里的类型是它的构造函数:

js 复制代码
console.log(p2 + "")
image-20260203190959948

至于为什么默认转换出来长这样,由于JS出的比较早,在 90 年代,遍历一个深层嵌套的大对象会消耗大量内存和计算资源。所以默认只给一个标签,性能开销最小,此设计一直留存至今。

如果期待的值是一个数字类型或是没有明确期望的结果,那么这里会优先调用valueOf方法,在对象中valueOf也有一个默认实现,就是返回对象本身:

js 复制代码
console.log(p2.valueOf())
image-20260203192006836

可见,在 ES6 引入 Symbol.toPrimitive 之前,valueOftoString 的互相调用逻辑非常复杂且不够直观(比如加法运算有时表现得很怪异)引入 [Symbol.toPrimitive] 后,开发者可以通过一个方法统一处理所有转换逻辑,并且根据 hint 参数精准控制对象在不同场景下的表现,这大大减少了隐式转换带来的一些潜在问题。

最后,这里还要提及的是,在ES2020推出了一种全新的空处理安全调用机制,专用于对象引用可能为空的情况。我们来看下面这个例子,正常情况下,下面的操作都会执行成功:

js 复制代码
let obj = {
    name: "小明",
    say() {
        console.log(`你好, 我叫${this.name}`)
    }
}

obj.say()  //调用对象方法
console.log(obj.name.length)  //获取名字长度

那如果我们在后续过程中吧obj变量变成null呢?

js 复制代码
obj = null

obj.say()   //此时由于变量没有指向任何对象,会出现错误
console.log(obj.name.length)
image-20260205110540015

可以看到,如果变量没有指向任何对象,那么这里将无法正确调用对象的方法。很简单的道理,人都没有确定是谁,我让谁去say?不可能对着空气吧。所以,如果引用没有指向任何对象,那么这里方法调用、属性获取都将失败,这也是很多程序里面都会出现的空指针问题。

很多时候,我们可能并不明确某个变量是否不为null,所以,对于这种不明确的变量,我们需要在使用之前,对变量进行空判断:

js 复制代码
if(obj != null) {   //判断不为空时,才执行后续的代码,也可以直接写为if(obj),因为null是假值
    console.log(obj.name.length)
}

这样,一旦判断到objnull,那么这里将无法继续获取属性name。在ES2020之后,这种写法得到了简化,我们可以直接在.运算符前面添加问号来实现为null自动停止,不为null正常获取:

js 复制代码
console.log(obj?.name.length)  //不会报错

当发现objnull时,本该获取name属性的操作会直接返回一个undefined作为结果,而不是继续向后执行。

同样的,针对于这种情况,我们还能连续使用:

js 复制代码
let obj = {
    name: null,   //现在对象的属性也有可能是null
    say() {
        console.log(`你好, 我叫${this.name}`)
    }
}

console.log(obj?.name?.length)  //可以连续判断

当表达式中出现了一串连续的?.运算符时,依然是从左往右进行操作,中途任何一个位置出现null,都会直接返回undefined。这种操作叫做可选链

同样的,对于方法来说,也可以像这样处理:

js 复制代码
obj?.say()  //如果对象为null,同样直接返回undefined

但是注意,如果方法也有可能为空,在调用方法时同样需要加上?.运算符:

js 复制代码
let obj = {
    name: null,
    say: null
}
obj?.say?.()  //此时如果say为null,将不会调用函数,并直接返回undefined

有了空安全的特殊处理运算符,我们就可以更加简单地进行可能为空的代码编写了。

函数类型

在前面我们一直把函数当作“可以调用的一段代码”来使用,但实际上,在 JavaScript 中,函数本身也是一种数据。换句话说: 函数不是特殊存在,它和数字、字符串一样,也是一种“值”,这一点是 JavaScript 非常重要、也非常“灵魂”的特性。

我们先来做一件很简单的事情,用 typeof 看一下函数的类型:

js 复制代码
function test() {
    console.log("Hello")
}

console.log(typeof test)
image-20260203170503891

也就是说,函数拥有自己独立的类型:function

不过需要注意的是,在 JavaScript 内部实现中,函数本质上是一种**对象(Object)**类型,只是引擎为它单独区分出了 function 这个类型,方便我们识别和使用,它是一种特殊的对象。

你可以简单理解为:函数 = 一个“可以被调用的对象”

既然函数是一种值,那么它就可以做所有“值”能做的事情,比如:

  • 赋值给变量
  • 作为参数传递
  • 作为返回值返回

我们先从最简单的开始,我们来看看函数赋值是怎么个玩法:

js 复制代码
function sayHello() {
    console.log("Hello World")
}

const fn = sayHello   // 注意:这里没有加 (),只使用了函数名字

此时,变量 fn 保存的就是这个函数本身,我们可以像调用原函数一样调用它:

js 复制代码
fn()   // Hello World

是不是感觉很神奇?相当于一个函数,可以同时拥有多个“名字”,这也是很多初学者第一次觉得 JS “有点怪”的地方,但请记住一句话:函数名本质上就是一个变量名,只是写法比较特殊罢了。

既然函数可以赋值,那它自然也可以作为参数传递给另一个函数:

js 复制代码
function sayHello() {
    console.log("Hello World")
}

function test(say) {
    say()  //拿到外部传入的函数,并且可以在这里调用
}
test(sayHello)  //直接将函数作为参数传递,同样只传递函数名称

在 JavaScript 中,这种“被传进去、再被调用的函数”(比如这里的sayHello函数)我们称为 回调函数(callback),既然函数可以作为参数传递,有些时候为了简单,我们甚至还能这样写:

js 复制代码
function test(say) {
    say()
}
test(function () {   //直接编写一个匿名函数,传递进去
    console.log("Hello World")
})

同样的,函数不仅可以作为参数“被传进去”,还可以作为返回值“被返回出来”:

js 复制代码
function createFn() {
    return function () {
        console.log("我是被返回的函数")
    }
}

const fn = createFn()   //调用函数返回一个函数
fn()  //在调用函数返回的函数

可能各位小伙伴会觉得这种玩法好像没啥用,但是注意,这一点是后面学习闭包时的核心基础。

函数的属性

前面我们说,函数本质也是一个对象,只是比较特殊,那么它是否也存在一些属性呢?答案是肯定的,我们先来做个最简单的实验:

js 复制代码
function test() {
    console.log("Hello")
}

test.a = 10   //直接给函数设置属性,就像使用普通对象那样
test.b = "我是函数的属性"

console.log(test.a)
console.log(test.b)

你会发现,这种用法完全没问题,函数就像普通对象一样,可以随意添加属性。当然,除了我们手动添加的属性,JavaScript 还为函数内置了一些常用属性,我们来重点认识几个。

length 属性表示:函数定义时的形参个数

js 复制代码
function sum(a, b, c) {}
console.log(sum.length)  // 3,因为函数形参列表中有3个参数

这个属性在一些函数工具库、参数校验场景中非常有用。

函数还有一个 name 属性,用来表示函数的名字:

js 复制代码
function test() {}
console.log(test.name)  // "test" 就是函数的名字

即使是匿名函数,在某些情况下也会自动拥有名字:

js 复制代码
const fn = function () {}  //使用变量fn接收一个匿名函数
console.log(fn.name)  // 使用变量调用,那么"fn"就是它的名字

是 JavaScript 引擎为了调试友好而做的优化,方便在报错或调用栈中定位函数来源。

除了属性之外,函数也具有一些自己的方法,虽然听着有些绕,但是确实是这样的:

js 复制代码
function test() {
    console.log("Hello World")
}
console.log(test.toString())   //函数对象也自带了toString方法
image-20260203232146396

函数类型的toString()方法会直接得到函数的源代码字符串。

此外,函数还有一个callapply方法,它可以实现和调用函数一样的效果:

js 复制代码
test.call()   //效果和下面一样
test.apply()  //效果和下面一样
test()  //直接调用
js 复制代码
test.call(null, 1, "HHH")   //如果函数存在参数,这里第一个可以临时使用null(后面马上介绍这个是干嘛的)然后再依次填入要传递的实参即可
test.apply(null, [1, "HHH"])  //效果和上面一样,但是后续参数传递需要使用数组(目前还没学)
test()  //直接调用

是不是感觉JS越来越神奇,各种奇葩用法都是源于函数本质也是一个对象。

最后,这里需要注意的是,和前面的普通函数一样,对象的方法也可以赋值给一个变量,此时这个变量代表的就是对象的方法:

js 复制代码
const person = {
    name: "小明",
    say() {
        console.log(`大家好,我叫${this.name}`)
    }
}

const func = person.say   //直接让一个变量代表这个函数,得到函数类型值
func()

因为对象的方法本质上存储的也是一个函数,所以这也是允许的。

但是这个函数实际上是存在问题的,因为我们在对象方法中使用了this,而此时我们通过赋值操作已经把这个函数带到了对象的外面,那么对于对象的指代也会跟着失效:

image-20260203164857691

可以看到,由于this脱离了它原本的作用域,此时就无效了,要解决这种问题也很简单,我们可以手动使用函数对象的call或是apply方法来进行函数调用,并通过参数形式传递我们需要指定的this目标:

js 复制代码
const func = person.say   //直接让一个变量代表这个函数,得到函数类型值
func.call(person)   //call的第一个参数代表this的目标,我们手动吧需要作为this指代的对象传入即可
func.apply(person)  //效果和上面一样,但是后续参数传递需要使用数组(目前还没学)

通过手动指定this指代的对象,就可以使得这个函数能够正确得到结果了。除此之外,我们也可以使用bind方法来生成一个已指定目标对象的新函数:

js 复制代码
const func = person.say.bind(person)  //将拿出来的函数绑定到对象上,并生成一个内容一样的新函数
func()  //绑定之后,this直接代表的就是绑定时的对象了,所有可以直接调用

这种写法效果也是一样的,至此,有关函数类型的补充介绍就暂时到这里,在后续的课程中我们还会进一步介绍JS中的函数,让大家解锁更多高级玩法。

正在加载页面,请稍后...