数据类型是基础中的基础,大家天天遇到,我们这次来讨论深一点,将我们认为理所当然的事情背后的原理发掘;继承也是前端基础一个大考点,看看继承的原理与使用场景。
本文讨论以下几个点:
- JavaScript数据类型
- 不同数据类型对应的数据结构
- 数据类型转换
- 数组与对象的api表
- new关键字背后干了些什么
- 原型与原型属性与构造函数区别于联系
- 实例化,混入,继承,多态是什么意思
数据类型
最新的 ECMAScript 标准定义了 7 种数据类型:
-
6 种原始类型:
- Boolean
- Null
- Undefined
- Number
- String
- Symbol (ECMAScript 6 新定义)
- 和 Object
这个分类我们应该是相当熟悉了,当时这是按照什么标准分类的。
数据类型对应的数据结构
事实上上面的分类标准是按照不同数据在计算机内存中的结构分类的。我们都知道JavaScript中的变量运行的时候是存在内存中的,如果接触过java的人应该知道,内存中也分为栈内存和堆内存。
栈(stack)
基本类型Undefined、Null、Boolean、Number 和String。这些类型在内存中分别占有固定大小的空间,他们的值保存在栈内存,他们的值保存在栈内存,我们通过按值来访问的。
var a = '1';
var b = '1';
a === b;
上述代码执行时候,可以理解为:
- 声明变量a,b,为a,b分配一个栈内存空间。(变量提升)
- 要赋值a,将a的字面量'1'作为值存储到a在栈内存的值中。
- 要赋值b,同样将'1'作为栈内存的值存储。
- 这种简单数据类型,都是在栈内存中保存其值。
JavaScript中的原始值(基本数据类型)都可预知其最大最小内存大小,所以创建的时候直接分配对应的内存空间。
堆(heap)
复杂的数据类型,如object,array,function等,无法提前预知其要占用多少内存空间,所以这个数据类型被放入了堆内存中,同时在栈内存中保存其堆内存的地址,访问这些变量的时候,在栈内存中获取到其内存地址,然后访问到该对象,这种方式叫按引用访问。
var a = 'hello world';
var b = 123;
var c = null;
var d = undefined;
var e = {};
var f = function(){console.log(1);};
var g = [1,2,a];
其在内存中的简易模型如下:
上面这个图并不是完全准确的,这里只是简单形容一下不同数据类型变量的存储关系,偏底层的知识真的需要单独开一篇来讲了。
通过上面的图我想应该一目了然了,基本数据类型都是存在栈内存中的,复杂对象则是存在堆内存中,栈内存变量保存的是其内存地址。这也应该想到了我们经常遇到的问题:对象之间赋值,赋值的是真正的内存地址;对象相互比较===,比较的是内存地址。
变量赋值
JavaScript 引用指向的是值。如果一个值有 10 个引用,这些引用指向的都是同一个值,它们相互之间没有引用 / 指向关系。
JavaScript 对值和引用的赋值 / 传递在语法上没有区别,完全根据值的类型来决定:
- 简单数据类型总是通过值复制的方式来赋值 / 传递。
- 复杂数据类型则总是通过引用复制的方式来赋值 / 传递。
包装类和类型转换
内置对象
JavaScript内置了一些对象,这些对象可以在全局任意地方调用,并且有各自的属性和方法。MDN上罗列了全部,这里只挑一部分对象说明:
- Object
- Array
- Function
- String
- Number
- Boolean
- Math
- Date
- RegExp
ok通过上面的几个内置对象就会发现一些问题:一些基本数据类型(String,Number,Boolean)有对应的内置对象,但是其他的一些(Null, Undefined)就没有,复杂数据类型则都有,这是为什么。
包装类
var a = 'hello world';
a[1]; // 'e'
a.length; // 11
a.toString(); // hello world
a.valueOf(); // hello world
a.split(' '); // ['hello', 'world']
有没有想过,变量a命名是个基本类型,不是对象,为什么会有这么多属性和方法。因为这些内置的属性和方法都在内置对象String上。
事实上当你调用这些基本数据类型上属性和方法时候,引擎会自动寻找其是否有对应的包装类,有的话生成一个包装类的实例供你使用(使用之后销毁),否则报错。
var a = 'hello world';
a.customAttribute // undefined
String.prototype.customAttribute = 'custom';
var b = 'hello world';
b.customAttribute // custom
我们现在想要访问属性customAttribute
,这个属性没有在内置对象上,所以获取到的值是undefined
;我们向内置对象的原型链上添加该属性,之后所有的string
上都可以获取到该值。
类型转换
JavaScript中的类型转换也是个大坑,不少面试都会问到。JavaScript 是一种动态类型语言,变量没有类型限制,可以随时赋予任意值。
显示转换
直接调用对应的包装类进行转换。具体可分成三种情况:
// 数值:转换后还是原来的值
Number(324) // 324
// 字符串:如果可以被解析为数值,则转换为相应的数值
Number('324') // 324
// 字符串:如果不可以被解析为数值,返回 NaN
Number('324abc') // NaN
// 空字符串转为0
Number('') // 0
// 布尔值:true 转成 1,false 转成 0
Number(true) // 1
Number(false) // 0
// undefined:转成 NaN
Number(undefined) // NaN
// null:转成0
Number(null) // 0
使用Number包装类来进行类型转换,隐藏的逻辑:
- 调用对象自身的
valueOf
方法。如果返回原始类型的值,则直接对该值使用Number
函数,不再进行后续步骤。 - 如果
valueOf
方法返回的还是对象,则改为调用对象自身的toString
方法。如果toString
方法返回原始类型的值,则对该值使用Number
函数,不再进行后续步骤。 - 如果
toString
方法返回的是对象,就报错。
var obj = {x: 1};
Number(obj) // NaN
// 等同于
if (typeof obj.valueOf() === 'object') {
Number(obj.toString());
} else {
Number(obj.valueOf());
}
var obj1 = {
valueOf: function () {
return {};
},
toString: function () {
return {};
}
};
Number(obj1)
// TypeError: Cannot convert object to primitive value
Number({
valueOf: function () {
return 2;
}
})
// 2
Number({
toString: function () {
return 3;
}
})
// 3
Number({
valueOf: function () {
return 2;
},
toString: function () {
return 3;
}
})
// 2
如果使用String则规则相对简单:
-
值为基本数据类型
- 数值:转为相应的字符串。
- 字符串:转换后还是原来的值。
- 布尔值:
true
转为字符串"true"
,false
转为字符串"false"
。 - undefined:转为字符串
"undefined"
。 - null:转为字符串
"null"
。
-
值为对象
- 先调用对象自身的
toString
方法。如果返回原始类型的值,则对该值使用String
函数,不再进行以下步骤。 - 如果
toString
方法返回的是对象,再调用原对象的valueOf
方法。如果valueOf
方法返回原始类型的值,则对该值使用String
函数,不再进行以下步骤。 - 如果
valueOf
方法返回的是对象,就报错。
- 先调用对象自身的
Boolean规则更简单:除了五个值(undefined,null,(+/-)0,NaN,‘’)的转换结果为false
,其他的值全部为true
。
隐式转换
隐式转换也分三种情况:
转布尔值
JavaScript 遇到预期为布尔值的地方(比如if
语句的条件部分),就会将非布尔值的参数自动转换为布值。系统内部会自动调用Boolean
函数。
所以跟上面一样,因此除了五个值(undefined,null,(+/-)0,NaN,‘’),其他都是自动转为true
。
转字符串
JavaScript 遇到预期为字符串的地方,就会将非字符串的值自动转为字符串。具体规则是,先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串。
字符串的自动转换,主要发生在字符串的加法运算时。当一个值为字符串,另一个值为非字符串,则后者转为字符串。
'5' + 1 // '51'
'5' + true // "5true"
'5' + false // "5false"
'5' + {} // "5[object Object]"
'5' + [] // "5"
'5' + function (){} // "5function (){}"
'5' + undefined // "5undefined"
'5' + null // "5null"
转数值
JavaScript 遇到预期为数值的地方,就会将参数值自动转换为数值。系统内部会自动调用 Number
函数。
除了加法运算符(+
)有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值。
'5' - '2' // 3
'5' * '2' // 10
true - 1 // 0
false - 1 // -1
'1' - 1 // 0
'5' * [] // 0
false / '5' // 0
'abc' - 1 // NaN
null + 1 // 1
undefined + 1 // NaN
具体参考阮一峰老师:JavaScript类型转换
数组和对象
这三个复杂对象我们太熟悉不过了,每天都在打交道。但是实际上我们也并不是完全掌握。
数组(Array)
数组方法很多,我们可以分类来整理记忆。
有哪些方法返回的是新数组
- concat
- slice
- filter
- map
- forEach
遍历数组方法有几种,区别在于什么
常见的有:
- map:返回新数组,数组的每一项都是测试函数的返回值。
- forEach:不返回任何值,只是单纯遍历一遍数组。
- every:遍历数组所有元素,直到测试函数返回第一个false停止。
- some:遍历数组所有元素,直到测试函数返回第一个true停止。
- for循环:写起来最麻烦,但是性能最好。
filter方法传入函数的参数有几个,都是什么含义
不只是filter方法,类似这种第一个参数为callback的方法如:some,every,forEach,map,find,findIndex的方法callback参数都一样:currentValue,Index,array。
github上参照了MDN整理了一份完整的文档,用于自己的查缺补漏。
对象(Object)
创建对象的方法有几种
-
字面量方式:
var person={ name:"SF", age:25 say:function(){ alert(this.name+"今年"+this.age); } }; person.say();
-
利用Object对象创建实例
var my = new Object(); my.name = "SF"; //JavaScript的发明者 my.age = 25; my.say = function() { alert("我是"+this.name+"今年"+my.age); } my.say(); var obj = Object.create(null); obj.name = 'SF';
-
构造函数
function Person(name,age) { this.name = name; this.age = age; this.say = function() { alert("我叫" + this.name + ",今年" + this.age + "岁); } } var my = new Person("SF",25); //实例化、创建对象 my.say(); //调用say()方法
-
原型模式
function Person() { } Person.prototype.name = 'aus'; Person.prototype.job = 'fe' Person.prototype.sayName = function() { console.log(this.name) } var person1 = new Person();
-
组合构造函数和原型
function Person( name, age, job ) { this.name = name; this.age = age; this.job = job; this.friends = ["Shelby","Court"]; } Person.prototype = { constructor: Person, sayName: function(){ alert(this.name); } } var person1 = new Person("Nicholas", 29, "software Engineer"); var person2 = new Person("Greg", 27, "Doctor"); person1.friends.push("Van"); alert(person1.friends); //"Shelby,Court,Van" alert(person2.friends); //"Shelby,Court" alert(person1.friends === person2.friends); //false alert(person1.sayName === person2.sayName); //true
对象的扩展密封和冻结有什么区别
-
扩展特性
-
Object.isExtensible
方法 -
Object.preventExtensions
方法
-
-
密封特性
-
Object.isSealed
方法 -
Object.seal
方法
-
-
冻结特性
-
Object.isFrozen
方法 -
Object.freeze
方法-
浅冻结
与深冻结
-
-
简单说就是对象有可扩展性(可以随意添加属性),限制对象的可扩展性(Object.preventExtensions)之后,对象不可添加新属性(但是现有属性可以修改和删除)。
密封对象(seal)指的是对象的属性不可增加或者删除,并且属性配置不可修改(属性值可修改)。
冻结对象(freeze)则更加严格,不可增加或者删除属性,并且属性完全不可修改。
这里不做过多介绍,详细可以看这里。
怎样快速实现浅拷贝以及深拷贝
Object.assign是常见的浅拷贝方法,怎样自己实现。
// 利用原生api
function shallowClone(obj) {
return Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
);
}
// 属性浅拷贝
function shallowCopy(copyObj) {
var obj = {};
for ( var i in copyObj) {
obj[i] = copyObj[i];
}
return obj;
}
深拷贝之前整理过:github。
对象的方法参照MDN整理了一份,github。
原型链
这节算是给继承铺垫基础知识了,js里最出名的原型和原型链,面试必考,日常开发也特别常见。
prototype
prototype中文译为'原型',大部分Object和Function都有prototype。个人觉得原型是一个特殊的普通对象,对象里面的属性和方法都用于指定的用途:共享。我们可以按照自己的意愿去修改原型,并且重新被共享。
当创建函数的时候,每一个函数都会自动有一个prototype属性,这个属性的值是空对象(空对象不是空)。
一旦你把这个函数当成构造函数调用(通过new调用)JS会创建构造函数的实例,实例是不具有原型的。
function A (){};
A.prototype // {}
var a = new A();
a.prototype // undefined
proto
中文翻译过来叫'原型属性',这是一个隐式属性,不可被枚举,但是他的用途至关重要。每个对象创建的时候,都会有一个隐式的属性__proto__,该属性的值是其对应的原型(其实就是说明 该对象的来源)。
function A (){};
A.__proto__ === Function.prototype; // true
var b = {};
b.__proto__ === Object.prototype; // true
var c = [];
c.__proto__ === Array.prototype; // true
可以确定的是,__proto__指向的是其构造函数的原型。
contructor
构造函数实例都拥有指向其构造函数的constructor属性。constructor属性的值是一个函数对象 为了将实例的构造器的原型对象暴露出来。
function A(){};
A.constructor === Function // true
var a = new A();
a.construtor === A // true
var obj = {};
obj.constructor === Object // true
可以确定的是,constructor属性指向其构造函数。
关系
上面三者的关系可以用下图表示:
这里就不得不提一句:使用new关键字实例化对象,内在过程到底发生了什么。
我们可以理解为将new关键字实例化对象拆成两步:
function A(){};
function create (base) {
var obj = {};
obj.__proto__ = base.prototype;
base.call(obj);
return obj;
}
var a = create(A);
a instanceof A // true
原型链
上面三个角色到期了之后,就到了另一个重点:原型链。
var a = Object.create(null);
a.a = 1;
var b = Object.create(a);
b.b = 2;
var c = Object.create(b);
c.c = 3;
c.a // 1
c.b // 2
c.c // 3
a.d = 4;
c.d;
c.a = 0;
c.a; // 0
上面这个例子用到了Object.create函数创建了一个原型为空的对象a。可以看到c并没有a,b属性,但是却可以读出该值来,这就是原型链。
当访问一个对象的属性(方法)的时候,如果对象自身没有该属性(方法),就会去该对象的__proto__上寻找,如果__proto__上也没有,就去__proto__.__proto__上寻找,以此类推,直到找到一个值返回;若没有则返回undefined。这种按照对象原型属性寻找形成一个类似链状的结构,叫做原型链。
画个图表示:
上图中的__proto__
红线可以理解为原型链。
这里要注意的是,对象的原型属性,保存的是对象的内存地址引用,需要读取原型属性的时候会找到该对象当时的状态,所以更改原型链上原型属性对象,会对该条原型链上的其他对象造成影响。
继承
ok经过这么多铺垫终于来到了继承,继承是面向对象里面最重要的概念之一。我们先来把相关概念介绍,再来看动手实现。
不管是实例,混入或者继承,他们的诞生都是为了解决同一个问题:代码复用。只不过实现方式不同。
实例
这个是我们日常开发中最常用的一种。
var date = new Date();
var instanceLightBox = new LightBox();
实例化一个对象可以理解为调用类的构造函数,返回一个拥有类所有属性和方法的对象。
这样说可能也不准确,我们以var a = new A();
为例,实例化一个对象有几个特点:
- a是一个object;
- a的构造函数是A;
- A构造函数中的非私有属性会被a获取到;
- A的原型是a的原型属性;
function A () {
this.a = 1;
};
A.prototype.getA = function(){
return this.a;
}
var a = new A();
a.a; // 1
a.getA(); // 1
事实上我们在上面已经讲解了调用new关键字发生了什么,这里原理不多讲。为什么要用实例化类:我们可以吧构造函数当做一个工厂,工厂产出了定制化模板(构造函数)和标准模板(构造函数的原型)的产品;我们可以通过多次实例化一个类,产出多个一样的产品,从而实现了代码复用。
混入(mixin)
混入更像是一个加工厂,对已有的对象进行添加新属性的操作。
function A (){
this.a = 1;
};
// 一个非常简单的mixin例子
function mixin(sourceObj, targetObj){
for (var key in sourceObj) {
// 只会在不存在的情况下复制
if (!(key in targetObj)) {
targetObj[key] = sourceObj[key];
}
}
}
var a = new A();
var b = {b:2};
mixin(b, a);
a.b; // 2
这个例子可以看到,targetObj混入了sourceObj的特有属性,如果属性是方法或者对象的话,targetObj保存的知识对象的引用,而不是自己独有的属性,这样sourceObject更改targetObj也会跟着更改。
继承(extend)
继承里面有两个角色,父类和子类。继承理解为得到父类所有的属性,并且可以重写这些属性。同样是获得一个function全部的属性和方法,我认为实例和继承的最大区别在于实例是构造函数实例对象,继承是类继承类,数据类型有明显区别。
我们先来看看ES6中的继承:
class Parent {
constructor (props) {
const {name, phone} = props;
this.name = name;
this.phone = phone;
}
getInfo(){
return this.name + ':' + this.phone;
}
}
class Child extends Parent {
constructor(props){
super(props);
const {gender} = props;
this.gender = gender;
}
getNewInfo(){
return this.name + ':' + this.gender + ':' + this.phone;
}
}
var childIns = new Child({
name: 'aus',
gender: 'male',
phone: '1888888888'
});
先不讨论继承是如何实现的,先来看看继承的结果。ES6中的继承,Child类拿到了Parent类的构造器里的非属性和原型上的所有属性,并且可以扩展自己的私有属性和原型属性。但是父类和子类仍然公用父类的原型。
继承有三个特点:
- 子类拥有父类非私有的属性和方法。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
多态
这里多态不详细介绍,我们来了解概念与实例。
多态:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。
举个例子,父类原型上有个方法a,子类原型上有个同名方法a,这样在子类实例上调用a方法必然是子类定义的a,但是我如果想用父类上的a怎么办。
class Parent {
constructor (props) {
const {name, phone} = props;
this.name = name;
this.phone = phone;
}
getInfo(){
return this.name + ':' + this.phone;
}
}
class Child extends Parent {
constructor(props){
super(props);
const {gender} = props;
this.gender = gender;
}
getInfo(from){
// 全完自定义
if('child' === from){
return this.getNewInfo();
} else {
return super.getInfo();
}
}
getNewInfo(){
return this.name + ':' + this.gender + ':' + this.phone;
}
}
var childIns = new Child({
name: 'aus',
gender: 'male',
phone: '1888888888'
});
多态是一个非常广泛的话题,我们现在所说的“相对”只是多态的一个方面:任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说“相对”是因为我们并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用“查找上一层”。
继承实现
一道非常常见的面试题,有多种方法,分成两个思路,篇幅有限,不过多介绍,详细的文档在github上,或者自行google。
参考
- 《JavaScript权威指南》
- 《JavaScript高级程序设计》
- 《你所不知道的JavaScript》
- JavaScript变量——栈内存or堆内存
- 内存管理
- 数据类型转换
- 面向对象编程三大特性------封装、继承、多态