前端面试的那些事儿(2)~ 不再害怕被问 JavaScript 对象

前言

对象是JavaScript语言最为复杂的概念,只要把对象理解透彻,JavaScript 就算是打通了任督二脉。

本文主要从面试的角度去讲解对象,而不会详细的讲解对象的API。

对象

对象和其他基本类型不同的是,对象是一种复合值:它将许多值(原始值或者其他对象)聚合在一起,可通过名字访问这些值。

于是,对象也可看做是属性的无序集合,每个属性都是一个名值对。属性名是字符串(或symbol),因此我们可以把对象看成是从字符串到值的映射。

var o = { a: 1 };
o.b = 2;
console.log(o.a, o.b); //1 2

JavaScript 中对象独有的特色是:对象具有高度的动态性,这是因为 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力。

JavaScript 对象的两类属性

对 JavaScript 来说,属性并非只是简单的名称和值,JavaScript 用一组特征(attribute)来描述属性(property)。

数据属性

  • value:就是属性的值。
  • writable:决定属性能否被赋值。
  • enumerable:决定 for in 能否枚举该属性。
  • configurable:决定该属性能否被删除或者改变特征值。

访问器(getter/setter)属性

  • getter:函数或 undefined,在取属性值时被调用。
  • setter:函数或 undefined,在设置属性值时被调用。
  • enumerable:决定 for in 能否枚举该属性。
  • configurable:决定该属性能否被删除或者改变特征值
var o = { a: 1 };
Object.defineProperty(o, "b", {value: 2, writable: false, enumerable: false, configurable: true});

//a和b都是数据属性,但特征值变化了
Object.getOwnPropertyDescriptor(o,"a"); // {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor(o,"b"); // {value: 2, writable: false, enumerable: false, configurable: true}

o.b = 3;
console.log(o.b); // 2

这里我们使用了 Object.defineProperty 来定义属性,这样定义属性可以改变属性的 writable 和 enumerable。

对象也可以理解成是一个属性的索引结构(索引结构是一类常见的数据结构,我们可以把它理解为一个能够以比较快的速度用 key 来查找 value 的字典)。

关联知识

  1. Vue3.0之前使用Object.defineProperty定义属性的,它也是Vue实现双向数据绑定的“核心”。
  2. JavaScript 用一组特征(attribute)来描述属性(property)是不是让你想起一道经典的问题?attribute和property的区别?这里就简单理解下:
    • 元素特性attribute是指HTML元素标签的特性<div id="id1" class="class1" title="title1" a='a1'></div> id、class、title等
    • 对象属性property是指元素节点的属性,也就是DOM化之后可以用JavaScript操作的属性。

对象与面向对象的区别

  • 对象是一种数据结构: var o = {a:1};
  • 面向对象则是描述了一种代码的组织结构形式,一种在软件中对真实世界中问题领域的建模方法

JavaScript 本身就是面向对象的,只是它实现面向对象的方式和主流的流派不太一样。JavaScript 是基于原型的面向对象系统。

而其它语言大多是基于类去描述面向对象的。

此时你心中肯定有个大大的疑问,什么是基于原型,什么又是基于类,它们分别是怎么实现的。

理解面向对象

面向对象程序设计(Object Oriented Programming,OOP)是一种计算机编程架构,OOP达到了软件工程的三个主要目标:重用性、灵活性和扩展性。

它的主要特征有

  • 继承
  • 封装
  • 多态

不要理解为对象具有这些特性,而是面向对象程序设计具有这几大特性。也就是说不论是通过类实现的面向对象还是通过原型实现的面向对象都应该具备这些特性。

本文的重点不是讲解清楚面向对象程序设计是什么,而是将关注点放在 JavaScript 的原型系统中。

通过类去实现面向对象

最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java 等流行的编程语言。这个流派叫做基于类的编程语言。

在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

const point = new Point(10,20);

通过原型去实现面向对象

基于原型的面向对象系统通过“复制”的方式来创建新对象。

原型系统的“复制操作”有两种实现思路:

  • 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;(浅复制)
  • 另一个是切实地复制对象,从此两个对象再无关联。(深复制)

JavaScript 显然选择了第一种方式(浅复制)。

var copyObj = Object.create(Object.prototype);
  • Object.proptotype 是原型对象
  • 通过 Object.create 基于原型原型对象复制了一个对象

从此copyObj对象便与Object.proptotype对象建立了关联

Object.prototype.toSay = function(){
    console.log("我的新技能");
}

copyObj.toSay(); // 我的新技能

我们在原型对象上添加一个方法,它的复制对象就可以立马拥有该方法。

抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说相当简单,我可以用两条概括:

  • 如果所有对象都有私有字段[[prototype]],就是对象的原型;
  • 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。

这个模型在 ES 的各个历史版本中并没有很大改变,但从 ES6 以来,JavaScript 提供了一系列内置函数,以便更为直接地访问操纵原型。

三个方法分别为:

  • Object.create 根据指定的原型创建新对象,原型可以是 null;
  • Object.getPrototypeOf 获得一个对象的原型;
  • Object.setPrototypeOf 设置一个对象的原型。

利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用。我用下面的代码展示了用原型来抽象猫和虎的例子。

var cat = {
    say(){
        console.log("meow~");
    },
    jump(){
        console.log("jump");
    }
}

var tiger = Object.create(cat,  {
    say:{
        writable:true,
        configurable:true,
        enumerable:true,
        value:function(){
            console.log("roar!");
        }
    }
})


var anotherCat = Object.create(cat);

anotherCat.say();

var anotherTiger = Object.create(tiger);

anotherTiger.say();

这段代码创建了一个“猫”对象,又根据猫做了一些修改创建了虎,之后我们完全可以用 Object.create 来创建另外的猫和虎对象,我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。

小结:

  • 通过指定的对象复制出一个新对象的方式,称之为基于原型创建对象
  • 通过类去实例化一个对象,并且类之间可以继承组合,称之为基于类创建对象

JavaScript “模拟基于类的面向对象”

你肯定好奇平时我们并不是这样写的。而是通过 function Cat(){}; const cat = new Cat(); 这样的形式去写代码的。

这便是 “模拟基于类的面向对象” 其关键点就是 new 运算符

new 运算符接受一个构造器和一组调用参数,实际上做了几件事:

  1. 以构造器的 prototype 属性为原型,创建新对象;
  2. 将 this 和调用参数传给构造器,执行;
  3. 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。

new 这样的行为,试图让函数对象在语法上跟类变得相似。

function Cat(){
    this.p1 = 1;
    this.p2 = function(){
        console.log(this.p1);
    }
} 
var o1 = new c1;
o1.p2(); // 1



function c2(){
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
    console.log(this.p1);
}

var o2 = new c2;
o2.p2(); // 1

到这里我们不禁会产生两个疑问?

  1. Cat 、Cat.prototype 、constructor 它们分别是什么呢?它们之间存在这什么联系呢?
  2. ES6 Class 是真的实现了类,还是它也是在模拟呢?我到底该用哪个呢?

纷繁复杂的关系

function Foo(){};
Foo.prototype.say = function(){
    console.log("say");
}
var f1 = new Foo;
console.log(f1.constructor === Foo); //true

这里出现了 Foo 、Foo.prototype、constructor,先来看一张图片:

  • Foo 函数是构造函数(类的意思)
  • var f1 = new Foo 实例化对象
  • 构造函数有一个prototype属性,指向实例对象的原型对象。因此 Foo.prototype 它就是 f1 的原型对象
  • 原型对象有一个constructor属性指向该原型对象对应的构造函数 Foo.prototype.constructor === Foo
  • 由于实例对象可以继承原型对象的属性,所以实例对象也拥有constructor属性,同样指向原型对象对应的构造函数 f1.constructor === Foo
  • 实例对象有一个proto属性,指向该实例对象对应的原型对象f1.__proto__ === Foo.prototypeproto 下划线开头是用来表示私有属性的概念的)

结合图片,相信你应该可以理清楚 JavaScript 对象这些复杂的关系。

new + 构造函数与ES6的Class有什么关系吗?

《ECMAScript 6 入门》中有这么一句话:ES6 的class可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

class Point {
  // ...
}

typeof Point // "function"
Point === Point.prototype.constructor // true
  • 类的数据类型就是函数,类本身就指向构造函数。
  • 使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。

构造函数的prototype属性,在 ES6 的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。

class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

// 等同于

Point.prototype = {
  constructor() {},
  toString() {},
  toValue() {},
};

在类的实例上面调用方法,其实就是调用原型上的方法。

在任何场景,都推荐使用 ES6 的语法来定义类,而令 function 回归原本的函数语义。

到这里相信您应该对 JavaScript 对象已经有一个全面的了解,但其实上面我们在讲述 JavaScript 面向对象中有一个重要的概念没有讲,那就是对象的继承。

对象继承

通俗的讲继承是指在原有对象的基础上,略作修改,得到一个新的对象。

function Super(name){
    this.name = name;
    this.colors = ["red","blue","green"];
}

Super.prototype.sayName = function(){
    return this.name;
};

看这段代码,假如我们想要继承它的话,我们肯定希望继承它的属性name、colors,它的方法sayName

先看一段ES5中比较推荐的继承方式:

function Sub(name,age){
    Super.call(this,name); // 通过call方法借用了Super函数的属性,这样继承到Super的属性了
    this.age = age; // 添加自己的属性
}
if(!Object.create){
    // 这是Object.create的简化版的pollfill,并没有去考虑null的情况
    Object.create = function(proto){
    function F(){}; // 临时构造函数
        F.prototype = proto; // 我们要复制的原型赋值给临时构造函数的原型
        return new F; // 输出一个对象实例
        
    }
}
Sub.prototype = Object.create(Super.prototype); // 这样就把Super.prototype上定义的属性都复制下来了
Sub.prototype.constructor = Sub; // 修改构造函数属性值指向自己

其实 JavaScript 对象的继承方式有很多种,但是其核心都是要复制父类的属性以及方法,并且还能继承到原型对象的属性。当然现在我们可以简单的使用ES6的extends实现继承,它隐藏了上面这些技术细节显得更加优雅。

jquery 中使用则是拷贝继承,通过拷贝函数将父例的属性和方法拷贝到子例

function extend(obj,cloneObj){
    if(typeof obj != 'object'){
        return false;
    }
    var cloneObj = cloneObj || {};
    for(var i in obj){
        if(typeof obj[i] === 'object'){
            cloneObj[i] = (obj[i] instanceof Array) ? [] : {};
            arguments.callee(obj[i],cloneObj[i]);
        }else{
            cloneObj[i] = obj[i]; 
        }  
    }
    return cloneObj;
}

var obj1={a:1,b:2,c:[1,2,3]};
var obj2=extend(obj1);
console.log(obj1.c); //[1,2,3]
console.log(obj2.c); //[1,2,3]
obj2.c.push(4);
console.log(obj2.c); //[1,2,3,4]
console.log(obj1.c); //[1,2,3]

这个便是JavaScript 对象的深拷贝浅拷贝的问题,由于面试中经常问到,所以还是深入学习下。

对象深浅拷贝

浅拷贝

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

const obj = {a:1,b:{c:2}};
const copyObj1 = Object.assign({},obj);
const copyObj2 = {...obj};

obj.b.c = 4;

// copyObj1,copyObj2都会跟着变化

深拷贝

将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。

深拷贝要处理的情况种类很多,这里列举一些常见要处理的情况:

  • 考虑对象的层层嵌套(递归解决)
  • 考虑数组
  • 考虑对象循环引用(通过引入缓存解决)
  • 考虑弱引用问题(通过weakMap解决缓存对象的强引用)
  • 考虑null
  • 考虑function
  • 考虑Map与Set等可继续遍历对象

这里我就不分步骤来推导了,可以直接看看完整版本的代码,都有详细注释。

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';
const argsTag = '[object Arguments]';

const boolTag = '[object Boolean]';
const dateTag = '[object Date]';
const numberTag = '[object Number]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';
const errorTag = '[object Error]';
const regexpTag = '[object RegExp]';
const funcTag = '[object Function]';

const deepTag = [mapTag, setTag, arrayTag, objectTag, argsTag];

// 手动实现 forEach
function forEach(array, iteratee) {
    let index = -1;
    const length = array.length;
    while (++index < length) {
        iteratee(array[index], index);
    }
    return array;
}
// 判断是否是可继续遍历对象的方法
function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}
// 输出具体数据类型
function getType(target) {
    return Object.prototype.toString.call(target);
}
// 例如:const target = {}就是const target = new Object()的语法糖。另外这种方法还有一个好处:因为我们还使用了原对象的构造方法,所以它可以保留对象原型上的数据,如果直接使用普通的{},那么原型必然是丢失了的。
function getInit(target) {
    const Ctor = target.constructor;
    return new Ctor();
}
// 克隆Symbol类型
function cloneSymbol(target) {
    return Object(Symbol.prototype.valueOf.call(target));
}
// 克隆正则表达式
function cloneReg(target) {
    const reFlags = /w*$/;
    const result = new target.constructor(target.source, reFlags.exec(target));
    result.lastIndex = target.lastIndex;
    return result;
}
// 克隆函数
function cloneFunction(func) {
    const bodyReg = /(?<={)(.|
)+(?=})/m;
    const paramReg = /(?<=().+(?=)s+{)/;
    const funcString = func.toString();
    if (func.prototype) {
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            if (param) {
                const paramArr = param[0].split(',');
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    } else {
        return eval(funcString);
    }
}
// 克隆其它类型
function cloneOtherType(target, type) {
    const Ctor = target.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(target);
        case regexpTag:
            return cloneReg(target);
        case symbolTag:
            return cloneSymbol(target);
        case funcTag:
            return cloneFunction(target);
        default:
            return null;
    }
}

function clone(target, map = new WeakMap()) {

    // 克隆原始类型
    if (!isObject(target)) {
        return target;
    }

    // 初始化
    const type = getType(target);
    let cloneTarget;
    if (deepTag.includes(type)) {
        cloneTarget = getInit(target);
    } else {
        return cloneOtherType(target, type);
    }

    // 防止循环引用
    if (map.get(target)) {
        return map.get(target);
    }
    map.set(target, cloneTarget);

    // 克隆set
    if (type === setTag) {
        target.forEach(value => {
            cloneTarget.add(clone(value, map));
        });
        return cloneTarget;
    }

    // 克隆map
    if (type === mapTag) {
        target.forEach((value, key) => {
            cloneTarget.set(key, clone(value, map));
        });
        return cloneTarget;
    }

    // 克隆对象和数组
    const keys = type === arrayTag ? undefined : Object.keys(target);
    forEach(keys || target, (value, key) => {
        if (keys) {
            key = value;
        }
        cloneTarget[key] = clone(target[key], map);
    });

    return cloneTarget;
}

对象深拷贝牵涉的知识面非常广阔,这也正是为什么它会成为面试时必问的一道面试题。

原文地址:https://www.cnblogs.com/shiyou00/p/12780297.html