谈论JavaScript对象——个人总结

前言

疑惑、怀疑与思考

JavaScript到底是面向对象还是基于对象?

与其它的语言相比,JavaScript总是显得不那么合群。比如:

  • 不同于其它的面向对象语言,JavaScript一直没有类的概念(ES6之前),ES6的到来也并没有改变它是基于原型的本质,这点是最让开发人员困惑的地方
  • _proto_ 和 prototype 傻傻分不清
  • 对象可以是由 new 关键字实例化,也可以直接由花括号定义
  • JavaScript对象可以自由添加属性,而其他的语言不行

在被诟病和争论中,有人喊出JavaScript并非“非面向对象”的语言,而是“基于对象”的语言。但是,对于如何定义“面向对象”和“基于对象”,基本上很难有人能够回答。

JavaScript到底是否需要模拟类class?

这需要明白JavaScript语言的设计思想,才能更清楚到底是否需要模拟类,以及为什么需要模拟类。在早期人们习惯于其他语言的面向对象编程方式,而对JavaScript感到困惑,并尝试用贴近类的方式去编程。

溯源与再思考

什么是面向对象?

我们先看看JavaScript对对象的定义:“语言和宿主的基础设施由对象来提供,并且 JavaScript 程序即是一系列互相通讯的对象集合”。这里的意思根本不是表达弱化面向对象的意思,反而是表达对象对于语言的重要性。

到底什么是面向对象?Objcet在英文中,是一切事物的总称,这和面向对象编程的抽象思维有相通之处。中文翻译“对象”却没有这样的普适性。在不同的编程语言中,设计者也利用各种不同的语言特性来描述对象,最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如C++、Java等流行的编程语言。

而JavaScript早年则选择了一个更为冷门的流派:原型。这是不合群的原因之一。

然而不幸的是,因为一些公司政治原因,JavaScript推出之时受管理层之命被要求模仿 Java,所以,JavaScript 创始人 Brendan Eich 在“原型运行时”的基础上引入了 new、this 等语言特性,使之“看起来更像 Java”。

因此,我们至少需要明白一件事,我们之前所熟知的“面向对象”的编程方式,其实是“基于类”的面向对象,它并不是面向对象的全部,确切地说,基于类只是面向对象编程的一个流派而已。而想要理解JavaScript对象,就必须清空我们认识“基于类的面向对象”相关的概念,回到人类对对象的朴素认识和无关语言的基础理论,我们就能够理解JavaScript面向对象设计的思路。

什么是原型?什么是类?

“基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。

与此相对,“基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。

基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。这就像专业人士可能喜欢在看到老虎的时候,喜欢用猫科豹属豹亚种来描述它,但是对一些不那么正式的场合,“大猫”可能更为接近直观的感受一些。我们的 JavaScript 并非第一个使用原型的语言,在它之前,self、kevo 等语言已经开始使用原型来描述对象了。

JavaScript的原型与对象

JavaScript原型:

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

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

这个模型在以前的各个历史版本中并没有大的改变,在ES6提供了一些列内置函数,可以更直接地访问操作原型:

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

利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用。例如:

// 这段代码创建了一个“猫”对象,又根据猫做了一些修改创建了虎,之后我们完全可以用 Object.create 来创建另外的猫和虎对象,我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为
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 来创建另外的猫和虎对象,我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。但是,在更早的版本中,程序员只能通过 Java 风格的类接口来操纵原型运行时,可以说非常别扭。

JavaScript对象的特征:

  • 对象具有唯一标识性:这个标识不是看变量名,而是内存地址
  • 对象有状态
  • 对象具有行为
  • 对象具有动态性

关于唯一标识性,如果分别定义两个结构和值一模一样的对象,他们两个是不相等的(a1 === a2为false)

关于状态和行为,不同语言会有不同的描述,Java中称他们为“属性”和“方法”,在JavaScript中,状态和行为统一抽象为“属性”。

前三个特征是任何面向对象语言都具备的,而JavaScript对象独有的特色是:对象具有高度的动态性,赋予了使用者在运行时为对象添改状态和行为的能力。

例如,下面的例子展示了向一个对象添加属性,这样操作完全OK:

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

同时,JavaScript的属性被设计成比别的语言更加复杂的形式,它提供了数据属性和访问器属性两类属性描述符(可以理解为针对属性的属性,这两类属性描述符不能同时存在,只能选取一种)

数据属性在很早的版本中实现,访问器属性在ES6新增。详情可见下方总结,简单来说就是:数据属性可以规定某属性的值、是否可写、是否可枚举、是否能被修改配置(包含是否能删除);访问器属性则主要定义了访问时的行为和结果。

既然ES6有了class,我们是不是可以抛弃原型了?

现在可以回答JavaScript是否需要模拟类这个问题了。其实,JavaScript本不需要模拟类,但是因为大家习惯于类的编程方式(以及一些其他原因,总结在下方),ES6正式开始使用class关键字,new + function的怪异搭配可以完全抛弃了。我们推荐在任何场景都使用ES6的语法来定义类。但在这里需要说明,class关键字的使用,其本质还是基于原型,class extends 只是语法糖,完全不存在抛弃原型一说。

现在,我们可以总结一下为什么JavaScript对象让人困惑了:

1.大部分面向对象语言都是基于类的流派,而基于原型的比较小众。

  基于原型本是一个优秀的抽象对象的形式,但是“基于类”的面向对象已经先入为主成为大部分人的思维,在缺乏系统性学习的前提下,尝试用基于类的思想去理解并掌握基于原型的处理方式,只会让人更加怀疑和困惑。

  其实,不止有人对基于原型有疑问,也有人对基于类表达过疑惑,只是对于大部分人来说质疑一个如此成功的流派显得多余,慢慢地认为面向对象理所应当就是这样。

2.早期由于公司政治原因,模仿java的一些语法和方式,不仅怪异,更加深了人们的困惑。

全面认识JavaScript对象

JavaScript对象分类

JavaScript 对象并非只有一种,比如在浏览器环境中我们无法单纯依靠js代码实现 div 对象,只能靠 document.createElement 来创建,这说明了 JavaScript 的对象机制并非简单的属性集合 + 原型。

JavaScript 中的对象可以分为以下几类,这也与 JavaScript 语言的组成有关:

  • 宿主对象(host Objects):由 JavaScript 宿主环境提供的对象,它们的行为完全由宿主环境决定。
  • 内置对象(Built-in Objects):由 JavaScript 语言提供的对象。
    • 固有对象(Intrinsic Objects):由标准规定,随着 JavaScript 运行时自动创建的对象
    • 原生对象(Native Objects):可以由用户通过内置构造器创建的对象
    • 普通对象(Ordinary Objects):由 {} 语句、Object 构造器或者 class 关键字定义的对象,它能够被原型继承

宿主对象

在浏览器环境中,我们知道全局对象是 window ,window 上又有很多属性,这里的属性一部分来自 JavaScript 语言,一部分来自浏览器环境。JavaScript 标准中规定了全局对象属性,来自浏览器宿主部分的可以理解为 W3C 的 HTML 标准(或非标准)的API,例如DOM、BOM。

固有对象

固有对象是由标准规定,随着 JavaScript 运行时而自动创建的对象实例。这些对象在任何JavaScript代码执行之前就已经被创建出来了,他们通常扮演类似基础库的角色,例如 global 对象(浏览器环境中是 window 对象)、JSON、Math、Number等。

ECMA标准提供了一份并不全面的固有对象表 ECMA

原生对象

能够通过语言本身的构造器创建的对象称作原生对象。在JavaScript标准中,提供了30多个构造器,如下:

几乎所有这些构造器的能力都是无法用纯JavaScript代码实现的,它们也无法用 class/extend 语法来继承。我们可以认为,所有这些原生对象都是为了特定能力或者性能,设计出来的“特权对象”。

ES6以来的扩展(普通对象)

1.简洁的表示方法

let name = "...", age = 20;
let obj = { name: name, age: age, func: function () { ... } }; // ES5表示
let obj = { name, age, func() { ... } }; // ES6表示

2.属性名表达式

使用方括号,属性名称可以用表达式表示,也可以用变量名。

let name = "abc", obj = {};
obj[name] = "123";    // obj :{ abc: "123" }
obj["h" + "ello"] = "aa";  // obj:{ abc: "123", "hello": "aa" }

3.方法的 name 属性

函数有 name 属性,返回函数名。对象内的方法也是函数,也有 name 属性。

const person = {
  sayName() {
    console.log('hello!');
  },
};

person.sayName.name   // "sayName"

4.属性描述符

对象内的每个属性都有一个属性描述符,分为两类:数据属性、访问器属性。两者不能一起用。

数据属性

我们通过 Object.getOwnPropertyDescriptor() 方法来获取某个对象的某个属性的描述符。

let obj = { a: "hello" };
const descriptor = Object.getOwnPropertyDescriptor(obj, 'a');
console.log(descriptor);
{
    value: "hello"
    writable: true
    enumerable: true
    configurable: true
}

可以看到,属性描述符(数据属性)由四个值组成:

  • value  该属性的值
  • writable  属性是否可写,如果为false就成为了只读属性。默认为true
  • enumerable  属性是否可枚举,默认为true
  • configurable  属性是否可配置。为true表示此属性的属性描述符可以被修改,属性也可以被删除,false则不可删除不可修改。默认为true

如何设置属性描述符呢?通过 Object.defineProperty()/Object.defineProperties() 方法:

Object.defineProperty(obj, prop, descriptor) 在对象上定义/修改一个属性,并返回该对象
例:
    let obj = {};
    let des = Object.defineProperty(obj, "a", {value: 123, writable: false});
    obj.a // 123
    console.log(des)  // {value: 123, writable: false,enumerable: true,configurable: true} // enumerable和configurable默认为true

Object.defineProperties(obj, props) 在对象上定义/修改多个属性,并返回该对象
例:
    let obj = {};
    let des = Object.defineProperty(obj, {
        a: {value: 123, writable: true},
        b: {value: 456, writable: true},
    });

访问器属性

访问器属性同样有四个:value、writable、get、set

其中最有用的是 getter/setter 函数,当通过对象取值/赋值时,会触发对应的函数。例如:

let obj = {
    _name: "hello",
    get name() { return this._name },
    set name(value) { this._name = value },
};
obj.name // "hello"
obj.name = "haha";
obj.name // "haha"

需要注意,当定义了 setter/getter 函数后,name属性真实存在,但在取值/赋值函数内部无法获取到同名属性 name ,也就是说,不能将 getter/setter 函数名和属性名相同,这点与 Proxy 不同。在上面的例子中,当访问 name 属性时实际访问的是 _name 属性。

另外,上面提到,不能同时使用两种属性描述符,否则会报错,如:

let obj = {};
Object.defineProperty(obj, "a", {
  get : function(){
    return bValue;
  },
  set : function(newValue){
    bValue = newValue;
  },
  writable: true,  // 该属性只能用于数据描述符
});
// throws a TypeError: value appears only in data descriptors, get appears only in accessor descriptors

5.super关键字

我们知道,this 关键字总是指向函数所在的当前对象,ES6新增了一个类似的关键字 super,指向当前对象的原型对象。

const proto = {
  foo: 'hello'
};

const obj = {
  foo: 'world',
  find() {
    return super.foo;
  }
};

Object.setPrototypeOf(obj, proto);
obj.find() // "hello" obj对象通过super关键字引用了原型对象的foo属性

注意:当super关键字表示原型对象时,只能用在对象的方法之中,用在其他地方都会报错。

6.遍历

遍历涉及到属性是否可枚举、是否是自身的属性、键是否是Symbol等问题。

以下四个操作无法遍历对象的不可枚举属性:

for ... in循环:只遍历对象自身的和继承的可枚举属性(不包含Symbol属性)
Object.keys():返回自身可枚举属性的键(不包含Symbol属性)
JSON.stringify():只序列化自身的可枚举属性(不包含Symbol属性)
Object.assign():只拷贝自身的可枚举属性(包含Symbol属性)

另外还有三种操作可以遍历不可枚举属性:

Object.getOwnPropertyNames():返回自身的所有属性(不含Symbol属性)
Object.getOwnPropertySymbols():返回自身所有的Symbol属性
Reflect.ownKeys():返回自身的所有属性(包含Symbol)

以上7种方法,除了JSON.stringify()和assign(),另外五种遍历,遵循同样的次序规则:

  • 首先遍历所有数值键,按照数值升序排列。
  • 其次遍历所有字符串键,按照加入时间升序排列。
  • 最后遍历所有 Symbol 键,按照加入时间升序排列。

7.扩展方法

一览表:

Object.is()        判断两个值是否相同,与===基本一致,可以说是对其的完善
Object.assign()      复制、合并对象,返回新对象。(1.只会复制可枚举属性2.属性都是浅拷贝)
Object.getOwnPropertyDescriptor()    返回某对象的某个自有属性的描述属性
Object.getPrototypeOf()      返回指定对象的原型对象
Object.setPrototypeOf()      设置指定对象的原型对象
Object.keys()          返回一个对象的所有可枚举属性名称
Object.values()         返回一个对象的所有可枚举属性的值
Object.entries()        返回一个对象的所有可枚举属性的键值对数组(可以看做是上面两个方法的结合)
Object.fromEntries()    entries()的逆操作,用于将一个键值对数组还原为对象,因此特别适合将Map结构转为对象

详情见这篇博客 JavaScript字符串、数组、对象方法总结

Class及继承

class关键字的使用正是迎合了“模拟类”的需求,但不改变其基于原型的本质,class及extends只是语法糖。详见 ECMAScript新语法、特性总结

Proxy

Proxy使得我们拥有强大的对象操作能力。Proxy英文意思为“代理”,表示它可以代理某些操作。Proxy 在目标对象前架设一层拦截,外界对该对象的访问,都必须先经过这层拦截,它提供了一种机制,可以对外界的访问进行过滤和改写。这等同于在语言层面做出修改,属于一种“元编程”(meta programmin),即对编程语言进行编程。

const proxy = new Proxy(target, handler);  // 生成 Proxy 实例

栗子(拦截读取操作):

var person = {
  name: "张三"
};
 
var proxy = new Proxy(person, {
  get: function(target, propKey, receiver) {
    if (property in target) {
      return target[property];
    } else {
      throw new ReferenceError("不存在的");
    }
  },
  set: function(target, propKey, value, receiver) {
    console.log("setter", target, propKey, value);
    retrun target[propKey] = value;
  }
});
 
proxy.name // "张三"
proxy.age // 抛出一个错误
proxy.name = "李四"
proxy.name // "李四"

Proxy支持的拦截操作一览表,一共13种:

get(target, propKey, receiver):拦截对象属性的读取
set(target, propKey, value, receiver):拦截对象属性的设置,返回一个布尔值。
has(target, propKey):拦截propKey in proxy的操作,返回一个布尔值。
deleteProperty(target, propKey):拦截delete proxy[propKey]的操作,返回一个布尔值。
ownKeys(target):拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。
preventExtensions(target):拦截Object.preventExtensions(proxy),返回一个布尔值。
getPrototypeOf(target):拦截Object.getPrototypeOf(proxy),返回一个对象。
isExtensible(target):拦截Object.isExtensible(proxy),返回一个布尔值。
setPrototypeOf(target, proto):拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。
construct(target, args):拦截 Proxy 实例作为构造函数调用的操作(new命令),比如new proxy(...args)。

Reflect

Reflect 对象与 Proxy 对象一样,也是 ES6 为了操作对象而提供的新的 API。

Reflect 的作用:

  • 将 Object 语言内部的方法拿出来放到 Reflect 对象上,即从 Reflect 对象上拿 Ojbect 对象内部方法。现阶段这些方法同时存在 Object 和 Reflect 上,未来新的方法将只部署在 Reflect 上。
  • 原 Object 方法报错的情况,在 Reflect 上会返回 false。使得代码运行更稳定。
  • 让 Object 操作都变成函数行为。原 Object 操作有一些是命令式,比如 in 和 delete,Reflect 用 has() 和 deleteProperty() 替代。
  • Reflect 对象上的方法与 Proxy 行为一一对应,只要是 Proxy 上的方法就会对应地出现在Reflect上。这可以使得两种对象相互配合完成默认的行为,作为修改行为的基础。即,不管 Proxy 如何修改默认行为,你总可以在 Reflect 上获取默认行为。

辅助说明示例:

2.某些Object方法调用可能会抛出异常,在Reflect上会返回false
// 老写法
try {
  Object.defineProperty(target, property, attributes);
  // success
} catch (e) {
  // failure
}

// 新写法
if (Reflect.defineProperty(target, property, attributes)) {
  // success
} else {
  // failure
}
3.将命令式操作改成函数行为
// 老写法
'assign' in Object // true

// 新写法
Reflect.has(Object, 'assign') // true
4.与Proxy配合,获取对象的一些默认行为
var loggedObj = new Proxy(obj, {
  get(target, name) {
    console.log('get', target, name);
    return Reflect.get(target, name);
  },
  deleteProperty(target, name) {
    console.log('delete' + name);
    return Reflect.deleteProperty(target, name);
  },
  has(target, name) {
    console.log('has' + name);
    return Reflect.has(target, name);
  }
});

Reflect对象方法

一共有13个静态方法:

Reflect.get(target, name, receiver)           查找并返回target对象的name属性,如果没有该属性,则返回undefined。
Reflect.set(target, name, value, receiver)    设置target对象的name属性等于value
Reflect.defineProperty(target, name, desc)    基本等同于Object.defineProperty,用来为对象定义属性。未来,后者会被逐渐废除,请从现在开始就使用Reflect.defineProperty代替它。
Reflect.deleteProperty(target, name)          等同于delete obj[name],删除对象的属性
Reflect.has(target, name)                     对应name in obj里面的in运算符,判断属性是否存在于对象中
Reflect.construct(target, args)               等同于new target(...args),这提供了一种不使用new,来调用构造函数的方法。
Reflect.ownKeys(target)                       用于返回对象的所有属性,基本等同于Object.getOwnPropertyNames与Object.getOwnPropertySymbols之和。
Reflect.isExtensible(target)                  对应Object.isExtensible,返回一个布尔值,表示当前对象是否可扩展。
Reflect.preventExtensions(target)             对应Object.preventExtensions方法,用于让一个对象变为不可扩展。它返回一个布尔值,表示是否操作成功。
Reflect.getOwnPropertyDescriptor(target, name) 基本等同于Object.getOwnPropertyDescriptor,用于得到指定属性的描述对象,将来会替代掉后者。
Reflect.getPrototypeOf(target)                用于读取对象的__proto__属性,对应Object.getPrototypeOf(obj)。
Reflect.setPrototypeOf(target, prototype)     用于设置目标对象的原型(prototype),对应Object.setPrototypeOf(obj, newProto)方法,返回布尔值,表示是否设置成功。
Reflect.apply(target, thisArg, args)          用于绑定this对象后执行给定函数

应用场景举例:

  • 可以实现观察者模式,即观察数据的变化,一旦发生变化,自动执行对应的函数。
posted @ 2020-01-04 21:25  学霸初养成  阅读(478)  评论(0编辑  收藏  举报