
JavaScript进阶部分
前面我们为大家介绍了JS的函数和面向对象,我们知道在JS中,万物皆对象,随便什么类型都可以当做对象使用,本章我们将承接上文,继续介绍JavaScript的高级特性部分。
函数进阶
在讲解完对象之后,我们接着回顾之前的函数部分,介绍更多有关函数的高级特性。
函数的注释
函数的注释往往可以为函数增加更多辅助信息,我们先来看一个没有注释的函数:
function fn(a, b) {
return a * b / 2
}
你能看出来它是干什么的吗?也许是求三角形面积?也可能是别的逻辑。
此时我们可以考虑为函数添加注释,来介绍这个函数是做什么的。在 JavaScript 中,最常见、也最推荐的函数注释写法叫做 JSDoc 风格,特点是:
- 使用
/** ... */ - 每一行以
*开头 - 可以被编辑器自动识别(VS Code/WebStorm 会有提示)
我们来尝试添加一个看看:
/**
* 计算三角形的面积
*/
function fn(a, b) {
return a * b / 2
}
当我们将鼠标移动到函数名称上时,就会自动显示它的注释信息,这样就很直观了:

除此之外,我们还可以针对所有的参数进行描述,比如这里的a和b是做什么的,需要什么类型的数据:
/**
* 计算三角形的面积
* @param {number} a 底边长度
* @param {number} b 高度
*/
function fn(a, b) {
return a * b / 2
}

使用@param来为函数参数进行细致化描述,后续可以在花括号中添加函数参数的类型。我们为函数的参数增加了特殊注释之后,用户在使用时,得到函数参数的信息会更加详细。
同样的,针对函数的返回值,我们也可以进行细致化描述:
/**
* 计算三角形的面积
* @param {string} a 底边长度
* @param {number} b 高度
* @returns {number} 三角形面积
*/
function fn(a, b) {
return a * b / 2
}
使用@returns来为返回值添加描述,通过这种方式也可以明确返回值的类型。
我们还可以为用户添加一个示例用法,使用@example即可:
/**
* 计算三角形的面积
* @param {string} a 底边长度
* @param {number} b 高度
* @returns {number} 三角形面积
* @example
* fn(4, 2) // 4
*/
即时调用函数
在前面的学习中,我们已经掌握了函数的定义和调用,但有时候我们会遇到这样一种需求:
我只想执行一次代码,执行完就“消失”,不污染外部环境
这时,就轮到我们这一节的主角登场了:即时调用函数(Immediately Invoked Function Expression,简称 IIFE),它可以实现函数在定义完成的“同一时间”就立刻执行。
要定义这样的函数也非常简单,首先我们还是正常编写一个函数:
function hello() {
console.log("Hello World!")
}
接着我们在这个函数的外部套上括号,并在后面添加括号和实际参数:
(function hello(text) {
console.log(`Hello World! ${text}`)
})("You") //这里已经在调用了,就像急急国王一样
这样,一个即时调用函数就创建好了,在函数创建的之后就立即调用。一般情况下,这种函数不会再去其他地方调用了,所以我们可以直接把它写成匿名函数形式:
(function (text) {
console.log(`Hello World! ${text}`)
})("You")
不过,这种调用方式一般用于解决ES6之前的var作用域问题,在有了let和const之后,这种用法实际上很少了,这里仅做了解即可。
剩余参数
在之前的章节中,我们学习了如何给函数传递固定数量的参数。但在实际开发中,我们经常会遇到这样的情况:我不确定用户到底会传多少个参数进来。
最典型的例子就是我们之前用过的console.log,我们发现可以传入任意个数的参数进去:
console.log("A", "B", "C")
但是通过前面的学习我们知道,函数的参数数量是定义的时候确定,那么这种效果要怎么才能实现呢?比如,我们要写一个求和函数 sum,它可能需要计算 2 个数的和,也可能需要计算 10 个数的和。如果用之前的方法,我们只能死板地定义 sum(a, b, c...),这显然不够灵活。
实际上,在调用函数时,即使形参列表里面没有任何参数,也可以传递实参
test("A", "B", "C") //在调用函数时,即使形参列表里面没有任何参数,也可以传递实参
那么这些实际参数怎么获取到呢?在函数对象上,还有一个属性arguments,这个属性包含了在实际调用时所有传入的参数,并且我们可以在函数内部使用它:
function test() {
console.log(arguments); //arguments就是实际参数列表
}
这个属性类似于我们前面讲解的数组的结构(但不是数组,因为它没有数组具有的很多方法)我们可以通过下标来访问里面的元素:
function test() {
console.log(arguments[0])
}
我们也可以使用arguments.length来获取传入了多少个参数:
console.log(arguments.length)
这样,我们无论传入多少个参数,都可以通过它来快速获取了。虽然这种方式非常简单,但是它并不直观,因为我们并没有定义任何的形参,为了让函数的定义更加明确,在 ES6 之后引入了剩余参数语法:
function test(...args) { //剩余参数通过在参数名前面加上三个点,表示这里可以接受多个参数
console.log(args)
}
test(1, 2, 4, 5) //这样就可以选择传入0-N个参数了
同时,这种参数得到的结果是一个数组类型的值:

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

注意,当函数存在其他参数时,剩余参数只能位于最后一位且只能出现一次,否则会出现歧义:
function test(text, ...args) { //剩余参数前面可以出现任意多的单参数
}
在ES6 之后,建议各位小伙伴优先使用剩余参数,而不是 arguments,它使用起来更加简单快捷,定义更明确。
箭头函数
在 ES6 中,引入了一种全新的函数写法:**箭头函数(Arrow Function)**它的语法更简洁,你可以将他看做是函数的一种替代写法。普通函数我们已经很熟悉了:
function add(a, b) {
return a + b
}
而现在,我们有了箭头函数,可以像这样写:
//使用一个变量来代表箭头函数,就像之前的匿名函数一样
const add = (a, b) => {
return a + b
}
箭头函数去掉了 function关键字,在参数和函数体之间,使用 =>进行连接,看起来更加简洁大气。当然,箭头函数本质上和函数是一样的,都可以正常调用:
const add = (a, b) => {
return a + b
}
console.log(add(3, 8))
特别的,当函数有且只有一个参数时,可以省略参数列表的小括号:
const square = x => { //x周围的小括号被省略了
return x * x
}
特别的,当函数体只有一行 return 时,还可以省略大括号和 return:
const square = x => x * x //省略大括号时,箭头后面的表达式结果会自动作为返回值
是不是感觉写起来很舒服?把我们之前臃肿的函数定义大大简化了。所以,大部分情况下,我们更建议各位小伙伴采用这种新的函数语法,它可以让我们写起来更加简洁优雅。
当然,这里也有很多箭头函数需要注意的点,首先就是如果我们返回一个对象:
const fn = () => { name: "Tom" } //错误的
虽然我们前面说了如果只有一行代码可以简化,但是在这种情况下返回一个对象会存在歧义,因为对象也是使用花括号作为其对象结构的声明,所以 {} 会被当成函数体,而不是对象。正确写法是:
const fn = () => ({ name: "Tom" })
然后,箭头函数中也没有arguments,它不支持使用,我们只能选择使用剩余参数:

接着,也是箭头函数最最重要的一点,箭头函数没有自己的 this,我们先来看普通函数中 this 的表现:
const obj = {
name: "小明",
say() {
console.log(this.name)
}
}
obj.say() // 小明
这里的 this 指向调用它的对象 obj,没问题。但是如果我们把方法改成箭头函数:
const obj = {
name: "小明",
say: () => {
console.log(this.name)
}
}
obj.say() //undefined
你会发现,这个函数的this作用域并不是对象本身,即使我们箭头函数是写在对象内的。箭头函数的 this 不会指向调用它的对象,而是等于它“外层最近的非箭头函数”的 this(也可以说,箭头函数 没有自己的 this,它只是“借用”外层作用域的 this)所以这里会向外层查找 this ,在大多数情况下,外层就是全局作用域(浏览器中是 window)这跟我们之前直接在最外层的函数中使用this是一样的效果,实际上:
- 普通函数的
this是 调用时决定的,如果调用时作用域不在对应位置,则会出现问题 - 箭头函数的
this是 定义时决定的,定义时的作用域是什么,后续即使传递也不会更改,甚至使用前面介绍的bind、apply和call也无法改变
不管怎么调用,箭头函数的 this 都不会变,它只会指向在定义时确定的全局作用域中的对象:
const fn = () => {
console.log(this)
}
fn() // window
obj.fn = fn
obj.fn() // 仍然是 window,无论在哪里都不会被修改
当然,这并不代表箭头函数就没有作用,在很多情况下,如果我们不需要使用this,那么它还是非常好用的,比如:
const arr = [1, 2, 3]
const result = arr.map(x => x * 2) //使用前面讲解的的转换操作
console.log(result)
const arr = [1, 2, 3]
arr.forEach(x => console.log(x)) //依次打印元素
这种写法在现代 JS 中几乎是标配,我们在后续的课程中遇到回调函数,我们一律使用箭头函数进行讲解。
解构语法
在前面的学习中,我们已经非常熟悉 数组 和 对象 的使用了,但在使用它们的时候,你可能经常会写出这样的代码:
const arr = [10, 20, 30]
const a = arr[0]
const b = arr[1]
const c = arr[2]
虽然代码本身没有任何问题,但你会发现写法是不是有点啰嗦,如果元素很多,就会很麻烦。但其实,这种操作本质上只是“拆包取值”的行为。包括对象也是类似的情况:
const person = {
name: "小明",
age: 18
}
const name = person.name
const age = person.age
有没有一种方式,可以一次性把结构拆开来用?这正是我们这节课介绍的 解构语法(Destructuring) 要解决的问题。它不是新类型,也不是函数,而是一种语法糖,用来让代码写得更简洁、更清晰,它也是ES6新增的语法。解构语法主要分为两种:
- 数组解构
- 对象解构
数组解构是按位置来匹配的,在定义变量时,可以将多个变量使用[]囊括,后面直接使用要被解构的数组进行赋值:
const arr = [10, 20, 30, 40]
const [a, b, c] = arr //在定义变量时,可以将多个变量使用[]囊括
console.log(a, b, c) // 10 20 30
接着,数组中的值会按照变量在[]中的顺序进行依次赋值,这里就得到了数组中前三个元素的值。可能会有小伙伴说,那我只想要中间的某些值怎么办:
const arr = [10, 20, 30, 40]
const [, b, c] = arr //需要跳过的变量,直接不写就行了,直接加逗号
console.log(b, c) // 20 30
需要跳过的变量,不写就行了,直接加逗号即可,这里逗号的作用是:占位但不取值。这里需要注意的是,如果数组长度不够,解构出来的值会是 undefined:
const arr = [10]
const [a, b] = arr
console.log(b) // undefined
当然,为了能够在这种情况进行补救, 我们可以为解构语法中的变量设置默认值:
const [a, b = 100] = arr
console.log(b) // 100
当对应位置没有值时,才会使用默认值。解构也可以和剩余参数一起使用:
const arr = [1, 2, 3, 4]
const [a, ...rest] = arr //...rest表示剩余的元素,和剩余参数一样,数组形式
console.log(a) // 1
console.log(rest) // [2, 3, 4]
接下来我们来看看对象的结构如何使用,我们可以使用{}来进行解构,注意对象解构是按“属性名”,而不是按顺序,我们在解构列表中写的变量名字必须与对象中的保持一致:
const person = {
name: "小明",
age: 18
}
const { name, age } = person //使用{}来进行对象解构,顺序无所谓,可以不按顺序来
console.log(name, age)
如果对象中不存在该属性,那么会得到一个undefined作为解构变量的值。
如果你不想用原来的属性名,或是对象的某些属性与外部的某个变量名称发生了冲突,也可以在解构时重命名:
const person = {
name: "小明",
age: 18
}
const { name: userName, age } = person //使用冒号来重命名结构的属性
console.log(userName, age)
这里的含义是:name 属性,赋值给变量 userName(重新起的新名字),同样的,对象解构同样支持默认值:
const person = {
name: "小明"
}
const { name, age = 18 } = person
console.log(age) // 18
注意,当对象中不存在该属性时,默认值才会生效,注意,对象中存在这个属性是undefined,也会被视为不存在。
解构和剩余参数一起使用,在对象中同样适用:
const obj = {
name: "小明",
age: 18,
city: "北京"
}
const { name, ...others } = obj
console.log(others) // { age: 18, city: "北京" }
学习了数组和对象的结构语法之后,我们最后再来看看函数中如何使用解构语法,这是解构语法最常用、最实用的地方之一,这是一个很普通的函数:
function printUser(user) {
console.log(user.name)
console.log(user.age)
}
在使用对象解构后:
function printUser({ name, age }) { //直接将形参写成结构后的样子
console.log(name)
console.log(age)
}
printUser(person) //这里省略对象内的属性
我们可以直接将形式参数写为解构后的样子,这样,参数结构一目了然,函数内部更简洁,最主要的是能少写很多 .运算符,注意,如果存在多个形式参数,也可以一起使用:
function printUser({ name, age }, text, { type }) { //多种写法混合也可以的
console.log(name)
console.log(age)
}
printUser(person, "666", anything)
函数参数的结构也可以使用默认值,因为之前已经讲解过,这里就不做演示了。
展开运算符
在上一节我们学习了解构语法,其中有一个符号你一定已经见过了:...,在解构中它表示 “剩余”,而在这一节,它有一个新的名字和用途:展开运算符(Spread Operator)虽然写法一样,但使用位置不同,含义也不同。
我们先来看一个不用展开运算符的写法,假设我们现在想要合并两个数组,我们可以使用concat方法来实现:
const arr1 = [1, 2, 3]
const arr2 = [4, 5] //将第一个数组直接装到第二个数组中
const arr3 = arr1.concat(arr2)
console.log(arr3) // [1, 2, 3, 4, 5]
可能各位小伙伴会觉得这样写没毛病啊,那再比如,我们现在需要合并四个数组:
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之后,我们可以使用展开运算符来快速实现这样的多合一操作,展开运算符只需要对着数组使用...即可:
const arr5 = [...arr1, ...arr2, ...arr3, ...arr4]
此时,...arr1 会把数组拆成 1, 2, 3,再按顺序放入新数组中,得益于这种特性,展开运算符可以非常自然地合并多个数组,相比以前使用 concat,这种写法更直观、更灵活、顺序一眼就能看明白。除此之外,很多新手会踩这样一个坑:
const arr1 = [1, 2, 3]
const arr2 = arr1
arr2.push(4)
console.log(arr1) // [1, 2, 3, 4]
这是因为数组是引用类型,它本质上也是一个对象,简单的赋值使得arr2 和 arr1 指向同一个地址。如果我们想要创建一个新数组,可以使用展开运算符:
const arr1 = [1, 2, 3]
const arr2 = [...arr1] //展开运算符会展开原数组所有内容
arr2.push(4)
console.log(arr1) // [1, 2, 3]
展开运算符会展开原数组所有内容,并直接放到新声明的数组中。注意这也是一种浅拷贝,因为结构出来的值还是原来数组中的,但在日常开发中非常常用。
在ES2018之后,展开运算符不仅可以用于数组,也可以用于对象:
const obj1 = {
name: "小明",
age: 18
}
const obj2 = {
...obj1, //直接获得对象1中定义的全部属性
city: "北京"
}
console.log(obj2)
对对象使用...的效果是,把对象的属性一个个展开并放进新对象中,这和数组比较类似。不过需要注意的是,当属性名重复时,后面的会覆盖前面的:
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 }
当我们需要合并多个对象时,使用展开运算符就是一种非常推荐的做法。和数组一样,对象展开也可以用来拷贝对象:
const obj1 = { name: "小明" }
const obj2 = { ...obj1 }
obj2.name = "小红"
console.log(obj1.name) // 小明
还是那个问题,只拷贝属性的值本身,它是浅拷贝。
最后需要提及的是,展开运算符还可以用在函数调用时,比如某个函数需要两个参数,我们可以直接让数组解构:
const arr = [5, 8, 16]
function sum(a, b) {
return a + b
}
console.log(sum(...arr)) //数组展开后,会自动使用其中的元素作为实际参数传入
数组展开后,会自动使用其中的元素作为实际参数传入,注意,超出形参长度的部分也会被一并作为后续参数传入,我们可以使用arguments来验证:

需要注意的是,这种用法仅限于数组,对象即使可以使用展开运算符也是不能作为参数使用的。
标签模版
标签模板 (Tagged Templates)这是一种更高级的用法,你可以通过一个函数来“解析”模板字符串。常用于防止 XSS 攻击或国际化处理。
在前面的章节中,我们已经学习过 模板字符串,也就是使用反引号包裹的字符串,比如:
const name = "小明"
const age = 18
const str = `我叫${name},今年${age}岁`
console.log(str)
其中字符串部分就是正常编写的字符串内容,而${}就是进行拼接的插值表达式。
模板字符串最大的好处是:可以在字符串中直接嵌入变量或表达式,不用再拼接 +,写起来非常直观。所谓“标签模板”,本质上就是,用一个函数,来“接管”模板字符串的解析过程,它的写法看起来有点特别,我们先直接看一个最简单的例子:
function tag(strs, value) {
console.log(strs)
console.log(value)
}
这里,我们为函数定义一个变量strs来接收字符串,下一个变量value来接收插值表达式的结果。需要调用也非常简单,我们只需要直接使用函数名称拼接模版字符串即可:
const name = "小明"
tag`你好,${name}`
接着,我们就可以在函数中得到模版字符串拆分的结果:

字符串会按照插值表达式的位置进行自动分割,得到一个字符串数组的结果,而所有的插值表达式将作为后续变量传入。由于存在字符串分割,所以strs的长度永远比插值表达式多1。
实际上,我们在上一章已经接触过标签模版相关的函数,String提供的raw函数可以实现对转译字符的忽略效果:
console.log(String.raw`你好,${name} 我爱你 \n 你牛逼`);
我们可以自行编写处理,比如我们可以对插值表达式的结果进行国际化展示:
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,当然本质上,它就是通过标签模板,把字符串当成结构化数据来解析。
生成器
在前面的学习中,我们已经掌握了箭头函数、解构、展开等特性,但你可能会发现一个问题:普通函数,要么不执行,要么一次性执行完,这里来看一个最简单的函数:
function fn() {
console.log(1)
console.log(2)
console.log(3)
}
fn()
函数一旦调用,里面的代码会从上到下全部执行完毕,中间没有任何“暂停”的机会。那有没有一种函数,可以实现在执行过程暂停呢?下次我们可以从暂停的位置继续执行,这正是ES6中新增的 **生成器(Generator)**的作用。
生成器的写法和普通函数非常像,我们需要在function后面添加一个*表示这是一个生成器:
function* gen() {
}
接着,我们可以在生成器中设定不同的阶段:
function* gen() {
console.log("我是第一阶段")
console.log("我是第二阶段")
console.log("我是第三阶段")
}
gen()
我们可以来尝试调用一下这个函数,你会发现,这个函数并没有执行。因为调用这个函数返回的其实是一个 生成器对象,默认情况下是暂停状态,我们需要对它进行推进才可以正常执行,必须调用 next():
const generator = gen()
generator.next()
可以看到,当我们调用next之后,函数才真正执行了,不过,这里三个阶段一起执行完成了,如果我们希望一个一个阶段执行,那么可以使用yield关键字:
function* gen() {
console.log("我是第一阶段")
yield //当遇到yield时,表示函数已经执行完一个阶段
console.log("我是第二阶段")
yield
console.log("我是第三阶段")
}
const generator = gen()
generator.next()
当遇到yield时,表示函数已经执行完一个阶段,此时函数会再次进入暂停状态,等待我们下一次的next调用。当我们连续进行三次next调用时,函数才能正确执行完所有内容:
const generator = gen()
generator.next()
generator.next()
generator.next()
这就是生成器的作用,它可以将任务分段,从而实现阶段性推进执行函数。实际上next的执行是有返回值的,我们可以来观察一下:
const generator = gen()
console.log(generator.next());

每完成一个阶段,都会得到当前阶段的执行结果,其中包含这个阶段的返回值,以及是否完成。其中,阶段的返回值由yield来指定:
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。正是得益于这种性质,生成器非常适合生成“无限或大型序列”,例如生成一个递增数字:
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循环来一次性执行所有的阶段:
const generator = gen()
for (let value of generator) { //这里的value就是每一个阶段的执行结果
console.log(value)
}
就像对数组进行遍历一样,我们可以使用for来遍历执行每一个阶段,并且这里的value实际上就是每一个阶段执行完成之后得到的结果,循环会在所有阶段执行完成之后自动结束。
对象进阶
在上一章,我们为大家介绍了对象的相关特性,包括对象的使用、引用类型以及原型链的概念,这一章,我们将继续上一章的内容,为大家介绍更多关于对象的进阶内容。
属性的继承*
在现实世界中,继承是一个非常常见的概念,比如:
- 学生 是 人
- 老师 是 人
- 狗 是 动物
它们都有一些共同特征,但又各自拥有不同的能力,比如:
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 指向父构造函数创建的对象。我们先来看最基础的写法:
function Student(name, age) {
this.name = name
this.age = age
}
function ArtStudent(name, age, level) {
this.level = level
}
接着,我们需要建立 原型继承关系,首先我们让 ArtStudent 的原型指向 Student 的一个实例对象:
ArtStudent.prototype = new Student()
这样一来,通过 ArtStudent 创建出来的对象,在自身找不到属性时,就会沿着原型链,去 Student 的实例对象中查找。此时再次测试:
const a = new ArtStudent("小明", 18, "高级")
console.log(a.name) // undefined
你会发现,结果不对。这是因为,虽然我们建立了原型链关系,但 Student 构造函数并没有被执行,name 和 age 并没有真正初始化到当前对象中。
为了解决这个问题,我们需要在子构造函数中,借用父构造函数来初始化属性。可以通过 call 来完成:
function ArtStudent(name, age, level) {
Student.call(this, name, age)
this.level = level
}
这样,在创建 ArtStudent 对象时,由于我们通过call手动指定this指代的值,所以Student 构造函数会在当前对象的作用域中执行,从而为对象添加 name 和 age 属性。
此时我们再次创建对象:
const a = new ArtStudent("小明", 18, "高级")
console.log(a.name) // 小明
console.log(a.age) // 18
console.log(a.level) // 高级
可以看到,美术生对象已经成功继承了普通学生的属性。不过,这里还存在一个小问题。我们来看一下 constructor 的指向:
a.constructor === ArtStudent // false
a.constructor === Student // true
这是因为我们直接重写了 ArtStudent.prototype,导致其 constructor 指向发生了变化。为了解决这个问题,需要手动修正 constructor:
ArtStudent.prototype.constructor = ArtStudent
到这里,我们已经完成了属性继承的基本实现。