谈论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对象后执行给定函数
应用场景举例:
- 可以实现观察者模式,即观察数据的变化,一旦发生变化,自动执行对应的函数。