image-20260208011656241

JavaScript进阶部分

前面我们为大家介绍了JS的函数和面向对象,我们知道在JS中,万物皆对象,随便什么类型都可以当做对象使用,本章我们将承接上文,继续介绍JavaScript的高级特性部分。

函数进阶

在讲解完对象之后,我们接着回顾之前的函数部分,介绍更多有关函数的高级特性。

函数的注释

函数的注释往往可以为函数增加更多辅助信息,我们先来看一个没有注释的函数:

js 复制代码
function fn(a, b) {
    return a * b / 2
}

你能看出来它是干什么的吗?也许是求三角形面积?也可能是别的逻辑。

此时我们可以考虑为函数添加注释,来介绍这个函数是做什么的。在 JavaScript 中,最常见、也最推荐的函数注释写法叫做 JSDoc 风格,特点是:

  • 使用 /** ... */
  • 每一行以 * 开头
  • 可以被编辑器自动识别(VS Code/WebStorm 会有提示)

我们来尝试添加一个看看:

js 复制代码
/**
 * 计算三角形的面积
 */
function fn(a, b) {
    return a * b / 2
}

当我们将鼠标移动到函数名称上时,就会自动显示它的注释信息,这样就很直观了:

image-20260208142122819

除此之外,我们还可以针对所有的参数进行描述,比如这里的ab是做什么的,需要什么类型的数据:

js 复制代码
/**
 * 计算三角形的面积
 * @param {number} a 底边长度
 * @param {number} b 高度
 */
function fn(a, b) {
    return a * b / 2
}
image-20260208154603815

使用@param来为函数参数进行细致化描述,后续可以在花括号中添加函数参数的类型。我们为函数的参数增加了特殊注释之后,用户在使用时,得到函数参数的信息会更加详细。

同样的,针对函数的返回值,我们也可以进行细致化描述:

js 复制代码
/**
 * 计算三角形的面积
 * @param {string} a 底边长度
 * @param {number} b 高度
 * @returns {number} 三角形面积
 */
function fn(a, b) {
    return a * b / 2
}

使用@returns来为返回值添加描述,通过这种方式也可以明确返回值的类型。

我们还可以为用户添加一个示例用法,使用@example即可:

js 复制代码
/**
 * 计算三角形的面积
 * @param {string} a 底边长度
 * @param {number} b 高度
 * @returns {number} 三角形面积
 * @example
 * fn(4, 2) // 4
 */

即时调用函数

在前面的学习中,我们已经掌握了函数的定义和调用,但有时候我们会遇到这样一种需求:

我只想执行一次代码,执行完就“消失”,不污染外部环境

这时,就轮到我们这一节的主角登场了:即时调用函数(Immediately Invoked Function Expression,简称 IIFE),它可以实现函数在定义完成的“同一时间”就立刻执行。

要定义这样的函数也非常简单,首先我们还是正常编写一个函数:

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

接着我们在这个函数的外部套上括号,并在后面添加括号和实际参数:

js 复制代码
(function hello(text) {
    console.log(`Hello World! ${text}`)
})("You")  //这里已经在调用了,就像急急国王一样

这样,一个即时调用函数就创建好了,在函数创建的之后就立即调用。一般情况下,这种函数不会再去其他地方调用了,所以我们可以直接把它写成匿名函数形式:

js 复制代码
(function (text) {
    console.log(`Hello World! ${text}`)
})("You")

不过,这种调用方式一般用于解决ES6之前的var作用域问题,在有了letconst之后,这种用法实际上很少了,这里仅做了解即可。

剩余参数

在之前的章节中,我们学习了如何给函数传递固定数量的参数。但在实际开发中,我们经常会遇到这样的情况:我不确定用户到底会传多少个参数进来

最典型的例子就是我们之前用过的console.log,我们发现可以传入任意个数的参数进去:

js 复制代码
console.log("A", "B", "C")

但是通过前面的学习我们知道,函数的参数数量是定义的时候确定,那么这种效果要怎么才能实现呢?比如,我们要写一个求和函数 sum,它可能需要计算 2 个数的和,也可能需要计算 10 个数的和。如果用之前的方法,我们只能死板地定义 sum(a, b, c...),这显然不够灵活。

实际上,在调用函数时,即使形参列表里面没有任何参数,也可以传递实参

js 复制代码
test("A", "B", "C")  //在调用函数时,即使形参列表里面没有任何参数,也可以传递实参

那么这些实际参数怎么获取到呢?在函数对象上,还有一个属性arguments,这个属性包含了在实际调用时所有传入的参数,并且我们可以在函数内部使用它:

js 复制代码
function test() {
    console.log(arguments);  //arguments就是实际参数列表
}

这个属性类似于我们前面讲解的数组的结构(但不是数组,因为它没有数组具有的很多方法)我们可以通过下标来访问里面的元素:

js 复制代码
function test() {
    console.log(arguments[0])
}

我们也可以使用arguments.length来获取传入了多少个参数:

js 复制代码
console.log(arguments.length)

这样,我们无论传入多少个参数,都可以通过它来快速获取了。虽然这种方式非常简单,但是它并不直观,因为我们并没有定义任何的形参,为了让函数的定义更加明确,在 ES6 之后引入了剩余参数语法:

js 复制代码
function test(...args) {  //剩余参数通过在参数名前面加上三个点,表示这里可以接受多个参数
    console.log(args)
}

test(1, 2, 4, 5)  //这样就可以选择传入0-N个参数了

同时,这种参数得到的结果是一个数组类型的值:

image-20260128205533041

由于是数组类型,我们可以对这些实际参数进行各种操作:

image-20260208171719284

注意,当函数存在其他参数时,剩余参数只能位于最后一位且只能出现一次,否则会出现歧义:

js 复制代码
function test(text, ...args) {  //剩余参数前面可以出现任意多的单参数
}

在ES6 之后,建议各位小伙伴优先使用剩余参数,而不是 arguments,它使用起来更加简单快捷,定义更明确。

箭头函数

在 ES6 中,引入了一种全新的函数写法:**箭头函数(Arrow Function)**它的语法更简洁,你可以将他看做是函数的一种替代写法。普通函数我们已经很熟悉了:

js 复制代码
function add(a, b) {
    return a + b
}

而现在,我们有了箭头函数,可以像这样写:

js 复制代码
//使用一个变量来代表箭头函数,就像之前的匿名函数一样
const add = (a, b) => {
    return a + b
}

箭头函数去掉了 function关键字,在参数和函数体之间,使用 =>进行连接,看起来更加简洁大气。当然,箭头函数本质上和函数是一样的,都可以正常调用:

js 复制代码
const add = (a, b) => {
    return a + b
}
console.log(add(3, 8))

特别的,当函数有且只有一个参数时,可以省略参数列表的小括号:

js 复制代码
const square = x => {  //x周围的小括号被省略了
    return x * x
}

特别的,当函数体只有一行 return 时,还可以省略大括号和 return

js 复制代码
const square = x => x * x  //省略大括号时,箭头后面的表达式结果会自动作为返回值

是不是感觉写起来很舒服?把我们之前臃肿的函数定义大大简化了。所以,大部分情况下,我们更建议各位小伙伴采用这种新的函数语法,它可以让我们写起来更加简洁优雅。

当然,这里也有很多箭头函数需要注意的点,首先就是如果我们返回一个对象:

js 复制代码
const fn = () => { name: "Tom" }  //错误的

虽然我们前面说了如果只有一行代码可以简化,但是在这种情况下返回一个对象会存在歧义,因为对象也是使用花括号作为其对象结构的声明,所以 {} 会被当成函数体,而不是对象。正确写法是:

js 复制代码
const fn = () => ({ name: "Tom" })

然后,箭头函数中也没有arguments,它不支持使用,我们只能选择使用剩余参数:

image-20260209000321035

接着,也是箭头函数最最重要的一点,箭头函数没有自己的 this,我们先来看普通函数中 this 的表现:

js 复制代码
const obj = {
    name: "小明",
    say() {
        console.log(this.name)
    }
}
obj.say()  // 小明

这里的 this 指向调用它的对象 obj,没问题。但是如果我们把方法改成箭头函数:

js 复制代码
const obj = {
    name: "小明",
    say: () => {
        console.log(this.name)
    }
}
obj.say()  //undefined

你会发现,这个函数的this作用域并不是对象本身,即使我们箭头函数是写在对象内的。箭头函数的 this 不会指向调用它的对象,而是等于它“外层最近的非箭头函数”的 this(也可以说,箭头函数 没有自己的 this,它只是“借用”外层作用域的 this)所以这里会向外层查找 this ,在大多数情况下,外层就是全局作用域(浏览器中是 window)这跟我们之前直接在最外层的函数中使用this是一样的效果,实际上:

  • 普通函数的 this调用时决定的,如果调用时作用域不在对应位置,则会出现问题
  • 箭头函数的 this定义时决定的,定义时的作用域是什么,后续即使传递也不会更改,甚至使用前面介绍的bindapplycall也无法改变

不管怎么调用,箭头函数的 this 都不会变,它只会指向在定义时确定的全局作用域中的对象:

js 复制代码
const fn = () => {
    console.log(this)
}

fn()          // window
obj.fn = fn
obj.fn()      // 仍然是 window,无论在哪里都不会被修改

当然,这并不代表箭头函数就没有作用,在很多情况下,如果我们不需要使用this,那么它还是非常好用的,比如:

js 复制代码
const arr = [1, 2, 3]
const result = arr.map(x => x * 2)  //使用前面讲解的的转换操作
console.log(result)
js 复制代码
const arr = [1, 2, 3]
arr.forEach(x => console.log(x))  //依次打印元素

这种写法在现代 JS 中几乎是标配,我们在后续的课程中遇到回调函数,我们一律使用箭头函数进行讲解。

解构语法

在前面的学习中,我们已经非常熟悉 数组对象 的使用了,但在使用它们的时候,你可能经常会写出这样的代码:

js 复制代码
const arr = [10, 20, 30]

const a = arr[0]
const b = arr[1]
const c = arr[2]

虽然代码本身没有任何问题,但你会发现写法是不是有点啰嗦,如果元素很多,就会很麻烦。但其实,这种操作本质上只是“拆包取值”的行为。包括对象也是类似的情况:

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

const name = person.name
const age = person.age

有没有一种方式,可以一次性把结构拆开来用?这正是我们这节课介绍的 解构语法(Destructuring) 要解决的问题。它不是新类型,也不是函数,而是一种语法糖,用来让代码写得更简洁、更清晰,它也是ES6新增的语法。解构语法主要分为两种:

  • 数组解构
  • 对象解构

数组解构是按位置来匹配的,在定义变量时,可以将多个变量使用[]囊括,后面直接使用要被解构的数组进行赋值:

js 复制代码
const arr = [10, 20, 30, 40]
const [a, b, c] = arr   //在定义变量时,可以将多个变量使用[]囊括
console.log(a, b, c) // 10 20 30

接着,数组中的值会按照变量在[]中的顺序进行依次赋值,这里就得到了数组中前三个元素的值。可能会有小伙伴说,那我只想要中间的某些值怎么办:

js 复制代码
const arr = [10, 20, 30, 40]
const [, b, c] = arr   //需要跳过的变量,直接不写就行了,直接加逗号
console.log(b, c) // 20 30

需要跳过的变量,不写就行了,直接加逗号即可,这里逗号的作用是:占位但不取值。这里需要注意的是,如果数组长度不够,解构出来的值会是 undefined

js 复制代码
const arr = [10]
const [a, b] = arr
console.log(b) // undefined

当然,为了能够在这种情况进行补救, 我们可以为解构语法中的变量设置默认值:

js 复制代码
const [a, b = 100] = arr
console.log(b) // 100

当对应位置没有值时,才会使用默认值。解构也可以和剩余参数一起使用:

js 复制代码
const arr = [1, 2, 3, 4]
const [a, ...rest] = arr  //...rest表示剩余的元素,和剩余参数一样,数组形式
console.log(a)    // 1
console.log(rest) // [2, 3, 4]

接下来我们来看看对象的结构如何使用,我们可以使用{}来进行解构,注意对象解构是按“属性名”,而不是按顺序,我们在解构列表中写的变量名字必须与对象中的保持一致:

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

const { name, age } = person  //使用{}来进行对象解构,顺序无所谓,可以不按顺序来
console.log(name, age)

如果对象中不存在该属性,那么会得到一个undefined作为解构变量的值。

如果你不想用原来的属性名,或是对象的某些属性与外部的某个变量名称发生了冲突,也可以在解构时重命名:

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

const { name: userName, age } = person  //使用冒号来重命名结构的属性
console.log(userName, age)

这里的含义是:name 属性,赋值给变量 userName(重新起的新名字),同样的,对象解构同样支持默认值:

js 复制代码
const person = {
    name: "小明"
}

const { name, age = 18 } = person
console.log(age) // 18

注意,当对象中不存在该属性时,默认值才会生效,注意,对象中存在这个属性是undefined,也会被视为不存在。

解构和剩余参数一起使用,在对象中同样适用:

js 复制代码
const obj = {
    name: "小明",
    age: 18,
    city: "北京"
}

const { name, ...others } = obj
console.log(others) // { age: 18, city: "北京" }

学习了数组和对象的结构语法之后,我们最后再来看看函数中如何使用解构语法,这是解构语法最常用、最实用的地方之一,这是一个很普通的函数:

js 复制代码
function printUser(user) {
    console.log(user.name)
    console.log(user.age)
}

在使用对象解构后:

js 复制代码
function printUser({ name, age }) {  //直接将形参写成结构后的样子
    console.log(name)
    console.log(age)
}
printUser(person)  //这里省略对象内的属性

我们可以直接将形式参数写为解构后的样子,这样,参数结构一目了然,函数内部更简洁,最主要的是能少写很多 .运算符,注意,如果存在多个形式参数,也可以一起使用:

js 复制代码
function printUser({ name, age }, text, { type }) {  //多种写法混合也可以的
    console.log(name)
    console.log(age)
}
js 复制代码
printUser(person, "666", anything)

函数参数的结构也可以使用默认值,因为之前已经讲解过,这里就不做演示了。

展开运算符

在上一节我们学习了解构语法,其中有一个符号你一定已经见过了:...,在解构中它表示 “剩余”,而在这一节,它有一个新的名字和用途:展开运算符(Spread Operator)虽然写法一样,但使用位置不同,含义也不同

我们先来看一个不用展开运算符的写法,假设我们现在想要合并两个数组,我们可以使用concat方法来实现:

js 复制代码
const arr1 = [1, 2, 3]
const arr2 = [4, 5]   //将第一个数组直接装到第二个数组中
const arr3 = arr1.concat(arr2)
console.log(arr3)  // [1, 2, 3, 4, 5]

可能各位小伙伴会觉得这样写没毛病啊,那再比如,我们现在需要合并四个数组:

js 复制代码
const arr1 = [1, 2, 3]
const arr2 = [4, 5]
const arr3 = [6, 7]
const arr4 = [8, 9]
const arr5 = arr1.concat(arr2).concat(arr3).concat(arr4)
console.log(arr5)  // [1, 2, 3, 4, 5, 6, 7, 8, 9]

大家有没有发现,再这样下去,concat都快变成贪吃蛇了。ES6之后,我们可以使用展开运算符来快速实现这样的多合一操作,展开运算符只需要对着数组使用...即可:

js 复制代码
const arr5 = [...arr1, ...arr2, ...arr3, ...arr4]

此时,...arr1 会把数组拆成 1, 2, 3,再按顺序放入新数组中,得益于这种特性,展开运算符可以非常自然地合并多个数组,相比以前使用 concat,这种写法更直观、更灵活、顺序一眼就能看明白。除此之外,很多新手会踩这样一个坑:

js 复制代码
const arr1 = [1, 2, 3]
const arr2 = arr1

arr2.push(4)
console.log(arr1) // [1, 2, 3, 4]

这是因为数组是引用类型,它本质上也是一个对象,简单的赋值使得arr2arr1 指向同一个地址。如果我们想要创建一个新数组,可以使用展开运算符:

js 复制代码
const arr1 = [1, 2, 3]
const arr2 = [...arr1]   //展开运算符会展开原数组所有内容

arr2.push(4)
console.log(arr1) // [1, 2, 3]

展开运算符会展开原数组所有内容,并直接放到新声明的数组中。注意这也是一种浅拷贝,因为结构出来的值还是原来数组中的,但在日常开发中非常常用。

在ES2018之后,展开运算符不仅可以用于数组,也可以用于对象:

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

const obj2 = {
    ...obj1,   //直接获得对象1中定义的全部属性
    city: "北京"
}

console.log(obj2)

对对象使用...的效果是,把对象的属性一个个展开并放进新对象中,这和数组比较类似。不过需要注意的是,当属性名重复时,后面的会覆盖前面的

js 复制代码
const base = { a: 1, b: 2 }
const extra = { b: 100, c: 3 }

const result = { ...base, ...extra }
console.log(result)
// { a: 1, b: 100, c: 3 }

当我们需要合并多个对象时,使用展开运算符就是一种非常推荐的做法。和数组一样,对象展开也可以用来拷贝对象:

js 复制代码
const obj1 = { name: "小明" }
const obj2 = { ...obj1 }

obj2.name = "小红"
console.log(obj1.name) // 小明

还是那个问题,只拷贝属性的值本身,它是浅拷贝。

最后需要提及的是,展开运算符还可以用在函数调用时,比如某个函数需要两个参数,我们可以直接让数组解构:

js 复制代码
const arr = [5, 8, 16]
function sum(a, b) {
    return a + b
}

console.log(sum(...arr))   //数组展开后,会自动使用其中的元素作为实际参数传入

数组展开后,会自动使用其中的元素作为实际参数传入,注意,超出形参长度的部分也会被一并作为后续参数传入,我们可以使用arguments来验证:

image-20260209000433384

需要注意的是,这种用法仅限于数组,对象即使可以使用展开运算符也是不能作为参数使用的。

标签模版

标签模板 (Tagged Templates)这是一种更高级的用法,你可以通过一个函数来“解析”模板字符串。常用于防止 XSS 攻击或国际化处理。

在前面的章节中,我们已经学习过 模板字符串,也就是使用反引号包裹的字符串,比如:

js 复制代码
const name = "小明"
const age = 18

const str = `我叫${name},今年${age}岁`
console.log(str)

其中字符串部分就是正常编写的字符串内容,而${}就是进行拼接的插值表达式。

模板字符串最大的好处是:可以在字符串中直接嵌入变量或表达式,不用再拼接 +,写起来非常直观。所谓“标签模板”,本质上就是,用一个函数,来“接管”模板字符串的解析过程,它的写法看起来有点特别,我们先直接看一个最简单的例子:

js 复制代码
function tag(strs, value) {
    console.log(strs)
    console.log(value)
}

这里,我们为函数定义一个变量strs来接收字符串,下一个变量value来接收插值表达式的结果。需要调用也非常简单,我们只需要直接使用函数名称拼接模版字符串即可:

js 复制代码
const name = "小明"
tag`你好,${name}`

接着,我们就可以在函数中得到模版字符串拆分的结果:

image-20260210012337373

字符串会按照插值表达式的位置进行自动分割,得到一个字符串数组的结果,而所有的插值表达式将作为后续变量传入。由于存在字符串分割,所以strs的长度永远比插值表达式多1

实际上,我们在上一章已经接触过标签模版相关的函数,String提供的raw函数可以实现对转译字符的忽略效果:

js 复制代码
console.log(String.raw`你好,${name} 我爱你 \n 你牛逼`);

我们可以自行编写处理,比如我们可以对插值表达式的结果进行国际化展示:

js 复制代码
function format(strs, ...values) {
    return strs.reduce((res, str, i) => {
        const value = values[i]
        return res + str + (typeof value === "number" ? value.toFixed(2) : value)
    }, "")
}

const price = 12.3456
const msg = format`商品价格:${price} 元`
console.log(msg)

实际上,很多框架也是使用的标签模版实现的自定义DSL,当然本质上,它就是通过标签模板,把字符串当成结构化数据来解析

生成器

在前面的学习中,我们已经掌握了箭头函数、解构、展开等特性,但你可能会发现一个问题:普通函数,要么不执行,要么一次性执行完,这里来看一个最简单的函数:

js 复制代码
function fn() {
    console.log(1)
    console.log(2)
    console.log(3)
}

fn()

函数一旦调用,里面的代码会从上到下全部执行完毕,中间没有任何“暂停”的机会。那有没有一种函数,可以实现在执行过程暂停呢?下次我们可以从暂停的位置继续执行,这正是ES6中新增的 **生成器(Generator)**的作用。

生成器的写法和普通函数非常像,我们需要在function后面添加一个*表示这是一个生成器:

js 复制代码
function* gen() {
  
}

接着,我们可以在生成器中设定不同的阶段:

js 复制代码
function* gen() {
    console.log("我是第一阶段")
    console.log("我是第二阶段")
    console.log("我是第三阶段")
}
gen()

我们可以来尝试调用一下这个函数,你会发现,这个函数并没有执行。因为调用这个函数返回的其实是一个 生成器对象,默认情况下是暂停状态,我们需要对它进行推进才可以正常执行,必须调用 next()

js 复制代码
const generator = gen()
generator.next()

可以看到,当我们调用next之后,函数才真正执行了,不过,这里三个阶段一起执行完成了,如果我们希望一个一个阶段执行,那么可以使用yield关键字:

js 复制代码
function* gen() {
    console.log("我是第一阶段")
    yield   //当遇到yield时,表示函数已经执行完一个阶段
    console.log("我是第二阶段")
    yield
    console.log("我是第三阶段")
}

const generator = gen()
generator.next()

当遇到yield时,表示函数已经执行完一个阶段,此时函数会再次进入暂停状态,等待我们下一次的next调用。当我们连续进行三次next调用时,函数才能正确执行完所有内容:

js 复制代码
const generator = gen()
generator.next()
generator.next()
generator.next()

这就是生成器的作用,它可以将任务分段,从而实现阶段性推进执行函数。实际上next的执行是有返回值的,我们可以来观察一下:

js 复制代码
const generator = gen()
console.log(generator.next());
image-20260209001707705

每完成一个阶段,都会得到当前阶段的执行结果,其中包含这个阶段的返回值,以及是否完成。其中,阶段的返回值由yield来指定:

js 复制代码
function* gen() {
    console.log("我是第一阶段")
    yield 233
    console.log("我是第二阶段")
    yield 666
    console.log("我是第三阶段")
}

const generator = gen()
console.log(generator.next());  //{value: 233, done: false}
console.log(generator.next());  //{value: 666, done: false}
console.log(generator.next());  //{value: undefined, done: true}

注意如果最后没有返回值,那么得到的的value就是undefined,如果有的话,则返回值作为value。正是得益于这种性质,生成器非常适合生成“无限或大型序列”,例如生成一个递增数字:

js 复制代码
function* counter() {
    let i = 0
    while (true) {
        yield i++
    }
}

const c = counter()
c.next().value // 0
c.next().value // 1
c.next().value // 2

需要注意的是,生成器并没有一次性生成所有数字,而是用一个给一个

除了我们手动调用next来获取下一个之外,我们也可以使用for循环来一次性执行所有的阶段:

js 复制代码
const generator = gen()
for (let value of generator) {  //这里的value就是每一个阶段的执行结果
    console.log(value)
}

就像对数组进行遍历一样,我们可以使用for来遍历执行每一个阶段,并且这里的value实际上就是每一个阶段执行完成之后得到的结果,循环会在所有阶段执行完成之后自动结束。

对象进阶

在上一章,我们为大家介绍了对象的相关特性,包括对象的使用、引用类型以及原型链的概念,这一章,我们将继续上一章的内容,为大家介绍更多关于对象的进阶内容。

属性的继承*

在现实世界中,继承是一个非常常见的概念,比如:

  • 学生
  • 老师
  • 动物

它们都有一些共同特征,但又各自拥有不同的能力,比如:

js 复制代码
function Student(name, age) {   //普通学生
    this.name = name
    this.age = age
}

function ArtStudent(name, age, level) {   //美术生
    this.name = name
    this.age = age
    this.level = level
}

这里我们定义了两种不同的学生,一种是普通学生,还有一种是美术生,其中,普通学生具有名字和年龄属性,而美术生不仅具有普通学生的名字年龄属性,而且还额外多了一个等级属性。

你会发现,两种构造函数存在大量重复代码,这显然不优雅,也不利于维护,此时我们就需要考虑继承关系了。在 JavaScript 中,由于并不存在像 Java、C++ 那样“真正的类型继承”,JS 的继承本质上是:对象之间通过原型链共享属性和方法

我们可以让 美术生的对象,通过原型链,去访问普通学生对象中的属性。在 JavaScript 中,实现这一点的核心方式,就是让子构造函数的 prototype 指向父构造函数创建的对象。我们先来看最基础的写法:

js 复制代码
function Student(name, age) {
    this.name = name
    this.age = age
}

function ArtStudent(name, age, level) {
    this.level = level
}

接着,我们需要建立 原型继承关系,首先我们让 ArtStudent 的原型指向 Student 的一个实例对象:

js 复制代码
ArtStudent.prototype = new Student()

这样一来,通过 ArtStudent 创建出来的对象,在自身找不到属性时,就会沿着原型链,去 Student 的实例对象中查找。此时再次测试:

js 复制代码
const a = new ArtStudent("小明", 18, "高级")
console.log(a.name) // undefined

你会发现,结果不对。这是因为,虽然我们建立了原型链关系,但 Student 构造函数并没有被执行,nameage 并没有真正初始化到当前对象中。

为了解决这个问题,我们需要在子构造函数中,借用父构造函数来初始化属性。可以通过 call 来完成:

js 复制代码
function ArtStudent(name, age, level) {
    Student.call(this, name, age)
    this.level = level
}

这样,在创建 ArtStudent 对象时,由于我们通过call手动指定this指代的值,所以Student 构造函数会在当前对象的作用域中执行,从而为对象添加 nameage 属性。

此时我们再次创建对象:

js 复制代码
const a = new ArtStudent("小明", 18, "高级")

console.log(a.name)   // 小明
console.log(a.age)    // 18
console.log(a.level)  // 高级

可以看到,美术生对象已经成功继承了普通学生的属性。不过,这里还存在一个小问题。我们来看一下 constructor 的指向:

js 复制代码
a.constructor === ArtStudent // false
a.constructor === Student    // true

这是因为我们直接重写了 ArtStudent.prototype,导致其 constructor 指向发生了变化。为了解决这个问题,需要手动修正 constructor

js 复制代码
ArtStudent.prototype.constructor = ArtStudent

到这里,我们已经完成了属性继承的基本实现

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