PVZ这款经典游戏相信大家都不陌生,最近杂交版的火爆又让它重新辉煌了一把。今天我们就从PVZ聊起,开启一段JS旅程!
想象一下,你是屋子的主人,面对越来越多的僵尸大军,你需要大量的坚果来进行防御,我们先来定义一个最简单的坚果对象,想想坚果有哪些属性呢?
js
let nut = {
maxHealth: 4000,
health: 4000,
type: "nut"
};
一个坚果墙,最大血量是4000,当前血量初始即为最大血量,类型我们设置为'nut'。
嗯,很合理。但是对于像柱子一样的僵尸大军,一个坚果哪里够用?我们需要批量生产大量的坚果,难道得像上面一个一个创建嘛?这会产生大量重复无意义的代码。
js
let nut1 = {
maxHealth: 4000,
health: 4000,
type: "nut"
};
let nut2 = {
maxHealth: 4000,
health: 4000,
type: "nut"
};
// ......
// 第n个坚果
当然不行,这可太不优雅了!每个坚果都拥有一样的属性,我们要是有一个模具,保存了这些属性,按着这个模子去生产坚果,效率不就高起来了吗?
为了解决上述问题,我们可以使用构造函数。构造函数是一种特殊类型的函数,用于创建和初始化具有相同属性和方法的对象实例。让我们创建一个构造函数来生成"坚果"对象:
js
function Nut() {
this.maxHealth = 4000;
this.health = 4000;
this.type = "nut";
}
接下来,我们只需要用这个已经建好的模具来批量生产实例对象就好了:
js
let nut1 = new Nut();
let nut2 = new Nut();
// ......省略n个坚果
OK,我们已经完成了大批量生产"五香蛋"的任务。但是,感觉成千上万个五香蛋,千篇一律的,没有个性,如果想要自己定义一个自己喜欢的坚果墙,加一点有意思的属性,改改颜色,改改形状,有办法吗?
在JS中,除了系统自带的(例如Object()、String()、Number()、Boolean())等用来创建原始类型和对象的构造函数外,还允许我们调用自定义的构造函数(构造函数首字母约定要大写)。
类就像一个模具,对象就是我们通过类批量生产出的坚果,坚果的血量、阳光花费都是已经确定的,我们写在了构造函数里,这个构造函数还允许我们自定义一些其他的属性,比如如果我们还想定义坚果的颜色和形状:
js
function Nut(color,shape) {
this.maxHealth = 4000;
this.health = 4000;
this.type = "nut";
this.cost = 50;
this.color = color;
this.shape = shape
}
我们使构造函数能传两个参数,来分别定义颜色和形状,在使用new调用构造函数时,传入参数,由于构造函数中的this指向实例对象,就能使传入的参数作为新实例对象的属性。
js
let nut1 = new Nut('green','square');
let nut2 = new Nut('purple','round');
console.log(nut1);
console.log(nut2);
这样,我们就得到了一个绿色方形坚果和一个紫色圆形坚果了,我们输出来看看他们长啥样。
js
// 输出结果
Nut {
maxHealth: 4000,
health: 4000,
type: 'nut',
cost: 50,
color: 'green',
shape: 'square'
}
Nut {
maxHealth: 4000,
health: 4000,
type: 'nut',
cost: 50,
color: 'purple',
shape: 'round'
}
没毛病,因为没有美工,展示不了图片,大家就想象一下长什么样吧(doge)。
有时候,调用构造函数时传入的参数可能和我们在构造函数中定义的参数在数量上并不尽相同:
js
let nut3 = new Nut('yellow')
let nut4 = new Nut('pink','oval','free')
console.log(nut3);
console.log(nut4);
// 输出结果:
{
maxHealth: 4000,
health: 4000,
type: 'nut',
cost: 50,
color: 'yellow',
shape: undefined
}
{
maxHealth: 4000,
health: 4000,
type: 'nut',
cost: 50,
color: 'pink',
shape: 'oval'
}
构造函数接收参数是按传入顺序接收的,如果构造函数只接收两个参数,但传入参数的比两个还多,它只会接收前两个参数。
而当调用构造函数没有定义shape属性时,相信你也不想看到undefined吧,我们在构造函数中设置一个默认值,(定义在构造函数原型上,被实例对象隐式继承,当传入新的参数时就会优先显示那个属性,没有传入参数时,则会通过原型链找到隐式继承的默认值),就可以避免这个问题:
js
// 通过设置默认参数,就解决了上面的问题
function Nut(color = 'brown', shape = 'oval') {
this.maxHealth = 4000;
this.health = 4000;
this.type = "nut";
this.cost = 50;
this.color = color;
this.shape = shape
}
在 ES6 中,我们还可以用class来定义类:
js
class Nut {
constructor(color = 'brown', shape = 'oval') {
this.maxHealth = 4000;
this.health = 4000;
this.type = "nut";
this.cost = 50;
this.color = color;
this.shape = shape;
}
和上面的的Nut创建实例对象的用法一样,这个class我们在后面讲继承的时候还会用到。
那么,回到JS,聊了这么久构造函数,构造函数在被 new 调用时的过程是怎么样的呢?
当使用new
关键字调用构造函数时,会发生以下几个步骤:
- new 会在构造函数中创建一个 this 对象 (创建一个对象,让构造函数的this指到这个对象)
- 执行函数中的逻辑代码 (相当于往this对象中添加属性)
- this. proto = Foo.prototype (让 this对象的隐式原型等于构造函数的显式原型)
- 返回 this 对象
这么讲还是有点抽象,我们用代码来大概模拟一下详细的过程:
js
// 这是构造函数
function Nut(color,shape) {
this.maxHealth = 4000;
this.health = 4000;
this.type = "nut";
this.cost = 50;
this.color = color;
this.shape = shape
}
// 执行过程:
function Nut(color,shape) {
// 首先,创建一个this对象 ()
// 实际是使用不了this关键字的 这里是方便理解
var this = {}
// 执行函数中的逻辑代码,把传入的参数赋给对应属性 左边是this对象的属性,右边是传入的参数
this.maxHealth = 4000;
this.health = 4000;
this.type = "nut";
this.cost = 50;
this.color = color;
this.shape = shape;
// 让 this对象的隐式原型等于构造函数的显式原型
// 这样,创建实例对象可以通过原型链访问到从构造函数原型上隐式继承到的属性
this._proto_ = Nut.prototype
// 最后,返回this对象
return this;
}
这样,就解释了构造函数创建对象的原理,我们自己写一个that对象来验证一下:
js
function Nut(color,shape) {
var that = {}
that.maxHealth = 4000;
that.health = 4000;
that.type = "nut";
that.cost = 50;
that.color = color
that.shape = shape
return that
}
let nut1 = Nut('green','square');
let nut2 = Nut('purple','round');
console.log(nut1);
console.log(nut2);
// 输出结果:
{
maxHealth: 4000,
health: 4000,
type: 'nut',
cost: 50,
color: 'green',
shape: 'square'
}
{
maxHealth: 4000,
health: 4000,
type: 'nut',
cost: 50,
color: 'purple',
shape: 'round'
}
上面的代码没有用new,和前面new构造函数执行结果完全一样,说明new做的就是和上面的代码一样的操作。
接下来,我们抛开坚果,最后来聊聊重点------包装类。
我们都知道,原始值是不能拥有属性和方法的。属性和方法是对象独有的。我们定义一个数字 num = 123。
js
var num = 123 // new Number(123) 实例对象
num.abc = 'hello' // 往原始类型上添加属性
console.log(num.abc); // 输出undefined,不报错
我们往原始值上添加了属性abc为'hello',但当我们去访问这个属性时,返回的却是undefined,也没有报错。
我们来解释一下:
首先,在JavaScript中,尝试访问一个对象中不存在的属性不会直接导致程序报错,这是因为JavaScript语言本身设计如此,以支持动态属性访问。这一特性使得开发者可以更灵活地处理对象,特别是在不知道对象确切结构的情况下。当你尝试访问一个对象中未定义的属性时,JavaScript不会抛出错���,而是返回undefined。这意味着你可以安全地尝试读取一个属性,而无需事先检查它是否存在。
那么,返回undefined,说明这个属性并没有被定义,但我们之前明明就添加了abc这个属性啊。难道发生了什么我们看不见的过程导致这个属性被移除了吗?对!而这就是包装类,它的执行逻辑如下:
js
// 执行逻辑
// 这个隐式的过程 ------> 包装类
new Number(123).abc = 'hello' // 代码本身并没有创建一个实例对象,是v8引擎这么执行了
delete new Number(123).abc // 当v8引擎发现原本定义的是一个原始值时,又把它上面的属性移除掉了
console.log(num.abc); // 访问对象中不存在的值 ���出undefined
console.log(num); // 输出123
原来,一开始我们确实趁v8引擎不注意给num添加上了属性,但当v8引擎去创建num时,反应过来:"不对啊,这明明是个原始类型,怎么还有属性呢?",这个属性便被移除了,我们再去访问,得到的当然就是undefined。
有什么办法骗过v8引擎吗?
js
var num = new Number(123) // 数值的实例对象 人为的去写一个实例对象 就不会有delete操作了
console.log(num); // 在浏览器中,输出Number对象 Number {123}
num.abc = 'hello'
console.log(num); // [Number: 123] { abc: 'hello' } 可以加属性
console.log(num.abc); // 'hello'
当我们真的人为去创建一个Number对象,不由v8引擎来创建,它也就不会去执行delete操作,这个Number对象就真的可以添加属性了。
我们把创建的这个实例对象num拿来使用:
js
console.log(num * 2); // 输出246 如果参与四则运算,会把它当作原始值
({}) * 2 // NaN 如果对象参与运算,会返回NaN Number类型下的一个值 not a number
var str = 'abc' // str = new String('abc')
console.log(str.length); // 输出3 说明被当成对象来用了 内置有属性length
// 执行过程:
// var str = new String('abc')
// str.length = xxx length属性是内定的,不会被移除 为什么会有这个属性,因为它真的被当成对象来创建了
// 一个东西被创建出来,可以当做对象也可以当做原始值,取决于如何使用
num*2 时,输出246,参与四则运算时,它是原始值;前面添加属性时,他又是对象,是引用类型。
我们定义一个字符串str,在我们的认知中,它应该是个原始类型。而输出它的长度时,能访问它的length属性,它又是对象。
凌乱了,一个东西,它在创建时是原始类型,在访问属性时又是对象,它到底是什么?
在JavaScript中,原始类型(如数字、字符串和布尔值)本身不具有属性和方法。然而,当你试图访问这些原始类型的属性或方法时,JavaScript引擎会临时创建一个对应的包装对象,并让你能够访问这些属性或方法。一旦访问完成,这个包装对象就会被销毁,原始值仍然是原始值。
js
var arr = [1, 2, 3, 4, 5]; // 复杂数据类型,有属性
arr.length = 2 // 人为的把长度修改为了2
arr.length = 4
console.log(arr); // [ 1, 2, <2 empty items> ]
var str = 'abcde' // 原始数据类型,没有属性
str.length = 2 // 人为增加的length 会执行包装类的执行过程,被delete
console.log(str.length); // 内置的length 5
// 执行过程:
// new String('abcde').length = 2
// delete new String('abcde').length
// console.log(new String('abcde').length) // 访问到这个length 5
在上面,我们对比了修改复杂数据类型数组的属性和增加原始数据类型的属性,发现复杂数据类型的属性是可以修改的,比如,把一个数组的长度从5修改到2再改回4,它先变成了只有1,2两个元素的数组,元素3, 4, 5由于长度的修改被移除掉了,又把长度改为4,多了两个空元素。
而修改原始类型字符串的长度并不奏效,会因为包装类的执行过程而被delete,访问length时,包装类临时创建一个对应的包装对象,并让你能够访问到该属性,访问完成之后销毁。
相信你已经了解了包装类的执行过程了,最后来道题吧:
js
var str = 'abc'
str += 1
var test = typeof(str)
if (test.length == 6) {
test.sign = 'typeof 的返回结果是string'
}
console.log(test.sign);
上面的代码输出结果是什么?有兴趣的朋友们评论区讨论,下期揭晓。