你不知道的javascript上-笔记

什么是 [[Prototype]]?

JavaScript 中的对象有一个特殊的[[Prototype]]内置属性, 其实就是对于其他对象的引用。几乎所有的对象在创建时[[Prototype]]属性都会被赋予一个非空的值。(也存在[[Prototype]]属性为空的情况, 虽然其不常见)。

const myObj = {
  a: 'hello prototype',
};
myObj.a; // 'hello prototype'

当我们使用 .引用 或者 []引用属性 a时, 会触发[[GET]]操作。对于默认[[GET]]操作来说, 首先会去检查对象本身是否有该属性, 如果有的话就是用它, 否则就需要使用对象[[Prototype]]链了。

const sourceObj = {
  a: 'hello prototype',
};
//创建一个关联到sourceObj的对象
const targetObj = Object.create(sourceObj);
targetObj.a; // 'hello prototype'

上述代码中显然属性a是不存在 targetObj 上的, 但是我们使用 Object.create方法创建该对象后, 将其[[Prototype]]关联到了sourceObj上, 便可正常的访问到属性a了。但是, 如果当sourceObj上也不存在属性a并且[[Prototype]]也不为空, 就会继续查找下去。这个过程会持续到找到匹配的属性或者查找完一条完整的[[Prototype]]链。如果是后者的话, [[GET]]操作的返回值为undefined

使用for...in遍历对象时原理和查找[[Prototype]]链类似, 任何可以通过[[Prototype]]链访问到并且是enumerable的属性都会被枚举。使用 in操作符时同样会查找完整的原型链(无论是否为enumerable)。

const sourceObj = {
  a: 'hello prototype',
};
//创建一个关联到sourceObj的对象
const targetObj = Object.create(sourceObj);

for (let key in targetObj) {
  console.log('key:' + key); //key: a
}
Object.defineProperty(myObj, 'a', {
  enumerable: false,
});
'a' in targetObj; //true

Object.prototype

所有普通的[[Prototype]]链最终都会指向内置的Object.prototype。由于所有普通的对象都源于(或者说把[[Prototype]]链的顶端设置为Object.prototype), 所以会包含 JavaScript 中一些内置的功能。比如说toString()valueOf()

属性设置与屏蔽

给一个对象设置属性并不仅仅是添加一个新属性或者是修改已有的属性值, 现在看一下完整的一个过程: myObj.bar = 'hello prototype'

  1. 如果myObj中存在普通数据访问属性bar, 则会直接修改已有的属性值。
  2. 如果bar既存在myObj中, 又存在于myObj[[Prototype]]链中, 则会发生属性屏蔽, myObj中的bar会屏蔽[[Prototype]]链上层中的所有bar, 因为myObj.bar总是会选择原型链中最底层的bar
  3. 如果bar不存在于myObj中, 也不存在与原型链中, 则会直接添加到myObj上。若bar不存在于myObj中, 但存在与原型链中, 则又有以下几种情况:
  • 如果原型链上层中存在普通数据访问属性bar, 并且没有被标记为只读(writable:true), 那么就会直接在myObj中添加bar, 它是屏蔽属性。

    const sourceObj = {
      bar: '2',
    };
    const myObj = Object.create(sourceObj);
    myObj.bar = '345';
    console.log(myObj, sourceObj); //'345', '2'
    
  • 如果原型链上层中存在bar, 且被标记为只读(writable:false), 那么便无法在myObj中添加bar, 如果为严格模式, 代码会抛出一个错误, 非严格模式下, 赋值语句会被跳过。总之, 不会发生屏蔽。

    const sourceObj = {
      bar: '2',
    };
    Object.defineProperty(sourceObj, 'bar', {
      writable: false,
    });
    const myObj = Object.create(sourceObj);
    
    myObj.bar = '345';
    console.log(myObj.bar, sourceObj.bar); //'2', '2'
    
  • 如果原型链上层中存在bar, 且为一个setter, 那么会调用这个setter, 不会添加在myObj上,也不会重新定义这个setter, 即不会发生屏蔽。

    const sourceObj = {
      set bar(val) {
        console.log('bar setter');
        this._val_ = val;
      },
    };
    
    const myObj = Object.create(sourceObj);
    myObj.bar = 2; // bar setter
    console.log(myObj.bar); // undefined
    

    如果希望在第二种和第三种中也屏蔽原型链上的bar, 就不能通过 = 操作符来赋值, 而是使用Object.defineProperty来添加。

’类‘

JavaScript和面向类的语言不同, 它没有类来作为对象的抽象模式。JavaScript只有对象。 实际上, JavaScript才是真正应该被称为‘面向对象’的语言, 因为它是少有的可以不通过类直接创造对象的语言。

类函数

JavaScript中一直有一种无耻的行为被滥用, 那就是模仿类。 这种奇怪的行为利用了函数的一种特殊性:所有函数都会拥有一个名为 prototype 的公有并且不可枚举的属性, 它会指向另一个对象。

function Bar() {}
Bar.prototype;

这个对象通常被称为Bar的原型。这个对象到底是什么? 最直接的解释就是, 这个对象是在调用 new Bar()时创建的, 最后会被关联到Bar.prototype这个对象上。

function Bar() {}
const bar = new Bar();
Object.getPrototypeOf(bar) === Bar.prototype; //true

解释一下javascriptnew操作符的作用:

  1. 创建(或者说构造)一个全新的对象。
  2. 这个对象会被执行原型链接。
  3. 这个对象会绑定到函数调用的this
  4. 如果函数没有返回其他对象, 那么 new 表达式中的函数调用会自动返回这个新对象。
function myNew(Bar, ...args) {
  const obj = {};
  Object.setPrototypeOf(obj, Bar.prototype);
  let result = Bar.apply(obj, args);
  return result instanceof Object ? result : obj;
}

在面向类的语言中, 类可以被实例化多次, 就像用模具制作东西一样。之所以为这样是因为实例化(或继承)一个类就意味着把类的行为复制到物理对象(实例)中去, 对于每一个新实例来说都会重复这个过程。 但是在 JavaScript中, 并没有类似的复制机制。我们并不能创建多个实例, 只能创建多个对象, 这些对象的[[Prototype]]关联的是同一个对象, 即Bar.prototype。但是在默认情况下并不会进行复制, 因此这些对象之间并不会完全失去联系, 它们是互相关联的。

关于名称

JavaScript中, 我们并不会将一个对象(类)复制到另一个对象(实例), 只是将它们关联了起来。从视觉角度来讲, [[Prototype]]机制如下:

image.png

这个机制通常被称为原型继承。 但是我觉得恰恰是因为这个名称影响了大家对于JavaScript机制真实原理的理解。 继承意味着复制操作, 但JavaScript(默认)并不会复制对象属性。相反, JavaScript会在两个对象之间创建一个关联, 这样一个对象就可以通过 委托 访问另一个对象的属性和函数。委托这个术语可以更加准确的描述JavaScript中对象的关联机制。

构造函数

function Bar() {}
const bar = new Bar();
bar.constructor === Bar; //true

上述代码很容易让人认为Bar是一个构造函数, 因为我们使用 new操作符调用它, 并且创建了一个对象 bar
实际上, Bar与其他一个正常函数没有任何区别, 这里能创建对象仅仅是因为我们使用了new操作符, 所以我的理解是 JavaScript中的构造函数是所有带new的函数调用。换句话说, 函数不是构造函数, 但是当且仅当使用new操作符时, 函数变成了构造函数。

bar.constructor === Bar很容易让人误解为bar有一个指向Barconstructor, 但实际上并不是这样的,bar.constructor也是委托给了Bar.prototype.constructor, 这和构造毫无关系。

举个栗子:

function Bar() {}
Bar.prototype = {};
const bar = new Bar();
console.log(bar.constructor === Bar); //false
console.log(bar.constructor === Object); //true

所以说, 只是因为在函数Bar定义时创建了Bar.prototype, Bar.prototype.constructor默认是指向Bar本身的, 又通过new创建的对象的[[Prototype]]会指向Bar.prototype, 可是当Bar.prototype的引用发生改变时, 便不能保证bar.constructor === Bar, 即使bar是通过 Bar new出来的一个对象, 所以说bar.constructor是一个不可靠且不安全的引用。

原型继承

下面这段代码就是典型的原型风格。

function Foo(name) {
  this.name = name;
}
Foo.prototype.getName = function () {
  return this.name;
};
function Bar(name, label) {
  Foo.call(this, name);
  this.label = label;
}
Bar.prototype = Object.create(Foo.prototype);

Bar.prototype.getLabel = function () {
  return this.label;
};

const a = new Bar('a', 'obj a');
a.getLabel(); // "obj a"
a.getName(); // "a"

这段代码的核心是 Bar.prototype = Object.create(Foo.prototype), 调用Object.create(...)会凭空创建一个新对象, 并把新对象内部的[[Prototype]]关联到你指定的对象, 换句话说, 这条语句的意思就是 创建一个新的 Bar.prototype 对象, 并把它关联到 Foo.prototype

注意, 下面这两种方式是常见的错误做法, 实际上它们都存在一些问题:

Bar.prototype = Foo.prototype

Bar.prototype = new Foo()

Bar.prototype = Foo.prototype 并不会创建一个关联到Bar.prototype的新对象, 它只是让Bar.prototype直接引用Foo.prototype对象。因此当你执行类似Bar.prototype.getLabel = ...赋值语句的时候会直接修改Foo.prototype本身。显然这不是你想要的结果, 否则你根本不需要Bar对象, 直接使用Foo就行了, 这样代码也更简单一些。 Bar.prototype = new Foo() 的确会创建一个关联到Bar.prototype的新对象。但是它使用了Foo的构造函数调用, 如果函数Foo有一些副作用的话, 就会影响到Bar()的后代, 后果不堪设想。

检查类关系

检查一个实例(JavaScript的对象)的继承祖先(JavaScript中的委托关联)通常被称为内审(或者反射)

function Bar() {}
Bar.prototype.name = 'Bar';

const bar = new Bar();

我们如何通过内审找到bar的委托关联呢?第一种方法是站在"类"的角度来判断: bar instanceof Bar instanceof操作符的左操作数是一个普通的对象, 右操作数是一个函数。instanceof回答的问题是:在bar的整条原型链中是否有指向Bar.prototype的对象。

如果是使用bind生成的硬绑定函数, 该函数是没有prototype属性的。在这样的函数上使用instanceof的话, 目标函数的prototype会代替硬绑定函数的prototype

function Bar(name) {
  this.name = name;
}

const obj = {};
const Baz = Bar.bind(obj);
console.log(Baz.prototype); //undefined

判断两个对象之间是否通过原型链关联: a.inPrototypeOf(b)

我们也可以直接获取一个对象的原型链。在 ES5 中的标准方法是: Object.getPrototypeOf(bar) 可以验证下这个对象是否和我们想的一样: Object.getPrototypeOf(bar) === Bar.prototype // true

绝大多数浏览器(并不是所有)也支持一种非标准的方法来访问: bar.__proto__ === Bar.prototype

.__proto__的大致实现:

Object.definePrototype(Object.prototype, '__proto__', {
  get: function () {
    return Object.getPrototypeOf(this);
  },
  set: function (o) {
    Object.setPrototypeOf(this, o);
    return o;
  },
});

对象关联

[[Prototype]]机制就是存在于对象中的一个内部链接, 它会引用其他对象。 通常来说, 这个链接的作用时: 如果在对象上没有找到需要的属性或者方法引用, 引擎就会继续在[[Prototype]]关联的对象上进行查找。同理, 如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]], 以此类推。这一系列对象的链接被称为原型链

创建关联

const foo = {
  something: function () {
    console.log('tell me something...');
  },
};
const bar = Object.create(foo);
bar.something(); // 'tell me something...'

Object.create(..)会创建一个新对象bar并把它关联到我们制定的对象foo, 这样我们就可以充分发挥[[Prototype]]机制的威力(委托)并且避免不必要的麻烦(比如使用new的构造函数调用会生成prototypeconstructor引用)。

Object.create(null)会创建一个拥有空(或者null)[[Prototype]]链接的对象, 这个对象无法进行委托。由于这个对象没有原型链, 所以instanceof操作符无法进行判断, 因此总是为返回false。这些特殊的空[[Prototype]]对象通常被称作字典, 它们完全不会受到原型链的干扰, 因此非常适合用来存储数据。

关联关系是备用

看起来对象之前的关联关系是用来处理缺失属性或者方法时的一种备用选项。这个说法有点道理, 但是我认为这并不是 [[Prototype]]的本质。

const foo = {
  something: function () {
    console.log('tell me something...');
  },
};
const bar = Object.create(foo);
bar.something(); // 'tell me something...'

虽然这段代码可以正常工作。但是如果你这么写只是为了让bar在无法处理属性或者方法时可以使用备用的foo, 那么这段代码后续就会很难理解和维护。

const foo = {
  something: function () {
    console.log('tell me something...');
  },
};
const bar = Object.create(foo);
bar.something = function () {
  this.something();
};
bar.something(); // 'tell me something...'

这里我们调用的bar.something()实际是存在于bar中的, 这可以让我们的API设计的更加清晰, 不那么的神奇。从内部来说, 我们实现遵循的是委托设计模式

总结

如果要访问对象中并不存在的一个属性, [[Get]]操作就会查找对象内部[[Prototype]]关联的对象。这个关联关系世纪上定义了一条 原型链, 在查找属性是会对它进行遍历。

所有普通对象都会有内置的__proto__, 指向原型链的顶端,如果在原型链中找不到制定的属性就会停止。一些通用的方法存在于Object.prototype上, 所以所有对象都可以使用它们。

关联两个对象最常用的方法时使用new关键字进行函数调用, 在调用的 4 个步骤走中会创建一个关联其他对象的新对象。

使用new调用函数时会把新对象的prototype属性关联到其他对象。带new的函数调用通常被称为构造函数调用, 尽管它们实际上和传统面向类语言中的类构造函数不一样。

虽然这些JavaScript机制和传统面向类语言中的类初始化和类继承很相似, 但是JavaScript中的机制有一个核心区别, 那就是不会进行复制, 对象之间是通过内部的原型链项=相关联的。

出于各种原因, 以继承结尾的术语和其他面向对象的术语都无法帮助你理解JavaScript的真实机制。

相比之下, 委托是一个更适合的术语, 因为对象之间的关系不是复制而是委托。

本文内容仅为个人学习时所做笔记, 文章内容参考与《你不知道的 JavaScript》。