原型与原型链

340 阅读14分钟

1.什么是原型链

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.eat = function() {
    console.log(age + "岁的" + name + "在吃饭。");
  }
}

let p1 = new Person("jsliang", 24);
let p2 = new Person("jsliang", 24);

console.log(p1.eat === p2.eat); // false

可以看到,对于同一个函数,我们通过 new 生成出来的实例,都会开出新的一块堆区,所以上面代码中 person 1 和 person 2 的吃饭是不同的。

拥有属于自己的东西(例如房子、汽车),这样很好。但它也有不好,毕竟总共就那么点地儿(内存),你不停地建房子,到最后是不是没有空地了?(内存不足)

所以,咱要想个法子,建个类似于共享库的对象(例如把楼房建高),这样就可以在需要的时候,调用一个类似共享库的对象(社区),让实例能够沿着某个线索去找到自己归处。

而这个线索,在前端中就是原型链 prototype

每个构造函数constructor都有一个原型对象prototype,原型对象都包含一个指向构造函数的指针,而实例instance都包含一个指向原型对象的内部指针__proto__,举个例子,constructor1.prototype = instance2时,当constructor1实例化出一个instance1时并且实例1要寻找某个属性p1,首先会在instance1内部属性找一遍,如果没找到则沿着instance1.__proto__也就是constructor1.prototype也就是instance2中继续寻找,如果还是没找到会继续constructor1.prototype.__proto__中继续寻找,直到null为止,没有则返回undefined,在这通过__proto__一层层往上找的过程就好像一条长链,所以叫做原型链。

2.原型链的作用

每个函数或类都有显式原型prototype,原型的作用就是给这个构造函数所创建出的每一个实例添加一个统一的方法或者属性。

在对象使用属性或调用方法的时候,会优先在自身的属性中寻找,如果找不到就去隐式原型__proto__里面依次寻找,如果找不到就返回null,我们把__proto__ 与prototype 的链条关系称为“原型链”。js对象就是通过原型链,实现属性的继承。

Prototype

  • JavaScript中的函数都有一个特殊的prototype内置属性,其实就是对于其它对象的引用。
  • 当我们试图引用对象的属性时会触发[Get]操作,对于默认的[Get]来说,第一步是检查对象本身是否存在这个属性,如果有就使用本身的属性,没有就会使用对象的[prototype]链了。
  • 一般要给所有实例添加一个统一的方法或者属性就在prototype中定义

__proto__

  • 绝大多数浏览器都支持一种非标准的方法来访问内部的[Prototype]属性:a.__proto__ === Foo.prototype

  • .constructor一样,它实际上并不存在于正在使用的对象中。实际上它和其它的常用函数方法一样,存在于内置的Object.prototype中,这些都说不可枚举的。

  • 一般要查找实例方法则是通过__proto__

  • 调用过程

    • .__proto__看起来很像一个属性,但是实际上它更像一个getter/setter
    • 在获取a.__proto__,实际上是调用了a.__proto__()(调用getter函数)
    Object.defineProperty(Object.prototype,"__proto__",{
        get: function() {
            return Object.getPrototypeOf(this)
        },
        set: function(o) {
            Object.setPrototypeOf(this,o)
            return o
        }
    })
    

constructor

  • prototype.constructor是指向构造函数的一个指针,即Foo.prototype.constructor === Foo
  • 当我们修改prototype.constructor时,并不能修改真正的构造函数,而是修改了prototype.constructor指向的指针
  • constructor属性并不存在于构造函数本身,调用它时是通过查找原型链上的方法调用的

属性屏蔽

  • 举个例子obj.foo = "bar"

  • 如果foo不是直接存在于obj中,就会往原型链上遍历,类似于[Get]操作,如果整个原型链上都找不到foo则直接在obj上直接添加foo并赋值"bar"

  • 如果foo出现在原型链上层,就会发生属性屏蔽,也就是会屏蔽原型上层的所有属性,因为obj.foo总会选择最底层的foo属性,也就是就近原则。但是发生属性屏蔽时也是会遵循几个原理:

    • 如果原型链上层存在foo属性并且没有被标记为只读即writable: false时,那么就会直接在obj上添加名为foo的新属性并且屏蔽原型链上层的foo属性
    • 如果原型链上层存在foo属性并且标记为只读即writable: true时,如果在严格模式下会抛出错误,否则就被忽略,无法添加新属性,总之就是不会发生屏蔽。
    • 如果原型链上层有foo属性并且定义了setter,那么就一定会调用这个setter,并且foo不会被添加到obj上,也不会重新定义foo的setter
  • 有些情况也会隐式地发生屏蔽:

    let aobj = {a : 2}
    let bobj = Object.create(aobj)
    console.log(aobj.a) //2
    console.log(bobj.a) //2
    aobj.hasOwnProperty("a") //true
    bobj.hasOwnProperty("a") //false
    bobj.a++ //发生隐式屏蔽
    console.log(aobj.a) //2
    console.log(bobj.a) //3
    bobj.hasOwnProperty("a") //true
    

    因为++操作相当于bobj.a = bobj.a + 1因此++操作首先会在原型链上查找属性a并且从原型链上获取属性a的值2,然后再给这个值加1,接着用[Put]将值3赋值给bobj中新建的属性a

prototype与__proto__的关系

  • 一般,构造函数的prototype和其实例__proto__是指向同一个地方的,叫做原型对象
  • 隐式原型__proto__的属性值指向它的构造函数的显示原型prototype属性值

引用类型的四个规则

  • 都具有对象特性,即可自由扩展属性
  • 都有一个隐式原型__proto__属性,属性值是一个普通的对象
  • 隐式原型__proto__的属性值指向它的构造函数的显示原型prototype属性值
  • 当你试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会从它的隐式原型__proto__中寻找

原型污染

原型污染是指:攻击者通过某种手段修改 JavaScript 对象的原型。

什么意思呢,原理其实很简单。如果我们把 Object.prototype.toString 改成这样:

Object.prototype.toString = function () {alert('原型污染')};
let obj = {};
obj.toString();
复制代码

那么当我们运行这段代码的时候浏览器就会弹出一个 alert,对象原生的 toString 方法被改写了,所有对象当调用 toString 时都会受到影响。

你可能会说,怎么可能有人傻到在源码里写这种代码,这不是搬起石头砸自己的脚么?没错,没人会在源码里这么写,但是攻击者可能会通过表单或者修改请求内容等方式使用原型污染发起攻击,来看下面一种情况:

'use strict';
 
const express = require('express');
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
const path = require('path');
 
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
 
function merge(a, b) {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
 
function clone(a) {
    return merge({}, a);
}
 
// Constants
const PORT = 8080;
const HOST = '0.0.0.0';
const admin = {};
 
// App
const app = express();
app.use(bodyParser.json())
app.use(cookieParser());
 
app.use('/', express.static(path.join(__dirname, 'views')));
app.post('/signup', (req, res) => {
    var body = JSON.parse(JSON.stringify(req.body));
    var copybody = clone(body)
    if (copybody.name) {
        res.cookie('name', copybody.name).json({
            "done": "cookie set"
        });
    } else {
        res.json({
            "error": "cookie not set"
        })
    }
});
app.get('/getFlag', (req, res) => {
    var аdmin = JSON.parse(JSON.stringify(req.cookies))
    if (admin.аdmin == 1) {
        res.send("hackim19{}");
    } else {
        res.send("You are not authorized");
    }
});
app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);
复制代码

如果服务器中有上述的代码片段,攻击者只要将 cookie 设置成{__proto__: {admin: 1}} 就能完成系统的侵入。

原型污染的解决方案

在看原型污染的解决方案之前,我们可以看下 lodash 团队之前解决原型污染问题的手法:

代码很简单,只要是碰到有 constructor 或者 __proto__ 这样的敏感词汇,就直接退出执行了。这当然是一种防止原型污染的有效手段,当然我们还有其他手段:

  1. 使用 Object.create(null), 方法创建一个原型为 null 的新对象,这样无论对 原型做怎样的扩展都不会生效:
const obj = Object.create(null);
obj.__proto__ = { hack: '污染原型的属性' };
console.log(obj); // => {}
console.log(obj.hack); // => undefined
复制代码
  1. 使用 Object.freeze(obj) 冻结指定对象,使之不能被修改属性,成为不可扩展对象:

    Object.freeze(Object.prototype);
    
    Object.prototype.toString = 'evil';
    
    console.log(Object.prototype.toString);
    // => ƒ toString() { [native code] }
    复制代码
    
  2. 建立 JSON schema ,在解析用户输入内容时,通过 JSON schema 过滤敏感键名。

  3. 规避不安全的递归性合并。这一点类似 lodash 修复手段,完善了合并操作的安全性,对敏感键名跳过处理。

继承

1、原型链继承

构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个原型对象的指针。

继承的本质就是复制,即重写原型对象,代之以一个新类型的实例

function SuperType() {
    this.property = true;
}

SuperType.prototype.getSuperValue = function() {
    return this.property;
}

function SubType() {
    this.subproperty = false;
}

// 这里是关键,创建SuperType的实例,并将该实例赋值给SubType.prototype
SubType.prototype = new SuperType(); 

SubType.prototype.getSubValue = function() {
    return this.subproperty;
}

var instance = new SubType();
console.log(instance.getSuperValue()); // true
复制代码

原型链方案存在的缺点:多个实例对引用类型的操作会被篡改。

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

SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"

var instance2 = new SubType(); 
alert(instance2.colors); //"red,blue,green,black"
复制代码

2、借用构造函数继承

使用父类的构造函数来增强子类实例,等同于复制父类的实例给子类(不使用原型)

function  SuperType(){
    this.color=["red","green","blue"];
}
function  SubType(){
    //继承自SuperType
    SuperType.call(this);
}
var instance1 = new SubType();
instance1.color.push("black");
alert(instance1.color);//"red,green,blue,black"

var instance2 = new SubType();
alert(instance2.color);//"red,green,blue"
复制代码

核心代码是SuperType.call(this),创建子类实例时调用SuperType构造函数,于是SubType的每个实例都会将SuperType中的属性复制一份。

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性/方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能

3、组合继承

组合上述两种方法就是组合继承。用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承。

function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
  alert(this.name);
};

function SubType(name, age){
  // 继承属性
  // 第二次调用SuperType()
  SuperType.call(this, name);
  this.age = age;
}

// 继承方法
// 构建原型链
// 第一次调用SuperType()
SubType.prototype = new SuperType(); 
// 重写SubType.prototype的constructor属性,指向自己的构造函数SubType
SubType.prototype.constructor = SubType; 
SubType.prototype.sayAge = function(){
    alert(this.age);
};

var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29

var instance2 = new SubType("Greg", 27);
alert(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
复制代码

缺点:

  • 第一次调用SuperType():给SubType.prototype写入两个属性name,color。
  • 第二次调用SuperType():给instance1写入两个属性name,color。

实例对象instance1上的两个属性就屏蔽了其原型对象SubType.prototype的两个同名属性。所以,组合模式的缺点就是在使用子类创建实例对象时,其原型中会存在两份相同的属性/方法。

4、原型式继承

利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型。

function object(obj){
  function F(){}
  F.prototype = obj;
  return new F();
}
复制代码

object()对传入其中的对象执行了一次浅复制,将构造函数F的原型直接指向传入的对象。

var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

alert(person.friends);   //"Shelby,Court,Van,Rob,Barbie"
复制代码

缺点:

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

另外,ES5中存在Object.create()的方法,能够代替上面的object方法。

5、寄生式继承

核心:在原型式继承的基础上,增强对象,返回构造函数

function createAnother(original){
  var clone = object(original); // 通过调用 object() 函数创建一个新对象
  clone.sayHi = function(){  // 以某种方式来增强对象
    alert("hi");
  };
  return clone; // 返回这个对象
}
复制代码

函数的主要作用是为构造函数新增属性和方法,以增强函数

var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"
复制代码

缺点(同原型式继承):

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

6、寄生组合式继承

结合借用构造函数传递参数和寄生模式实现继承

function inheritPrototype(subType, superType){
  var prototype = Object.create(superType.prototype); // 创建对象,创建父类原型的一个副本
  prototype.constructor = subType;                    // 增强对象,弥补因重写原型而失去的默认的constructor 属性
  subType.prototype = prototype;                      // 指定对象,将新创建的对象赋值给子类的原型
}

// 父类初始化实例属性和原型属性
function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
  alert(this.name);
};

// 借用构造函数传递增强子类实例属性(支持传参和避免篡改)
function SubType(name, age){
  SuperType.call(this, name);
  this.age = age;
}

// 将父类原型指向子类
inheritPrototype(SubType, SuperType);

// 新增子类原型属性
SubType.prototype.sayAge = function(){
  alert(this.age);
}

var instance1 = new SubType("xyc", 23);
var instance2 = new SubType("lxy", 23);

instance1.colors.push("2"); // ["red", "blue", "green", "2"]
instance1.colors.push("3"); // ["red", "blue", "green", "3"]
复制代码

这个例子的高效率体现在它只调用了一次SuperType 构造函数,并且因此避免了在SubType.prototype 上创建不必要的、多余的属性。于此同时,原型链还能保持不变;因此,还能够正常使用instanceof 和isPrototypeOf()

这是最成熟的方法,也是现在库实现的方法

7、混入方式继承多个对象

function MyClass() {
     SuperClass.call(this);
     OtherSuperClass.call(this);
}

// 继承一个类
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;

MyClass.prototype.myMethod = function() {
     // do something
};
复制代码

Object.assign会把 OtherSuperClass原型上的函数拷贝到 MyClass原型上,使 MyClass 的所有实例都可用 OtherSuperClass 的方法。

8、ES6类继承extends

extends关键字主要用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。其中constructor表示构造函数,一个类中只能有一个构造函数,有多个会报出SyntaxError错误,如果没有显式指定构造方法,则会添加默认的 constructor方法,使用例子如下。

class Rectangle {
    // constructor
    constructor(height, width) {
        this.height = height;
        this.width = width;
    }
    
    // Getter
    get area() {
        return this.calcArea()
    }
    
    // Method
    calcArea() {
        return this.height * this.width;
    }
}

const rectangle = new Rectangle(10, 20);
console.log(rectangle.area);
// 输出 200

-----------------------------------------------------------------
// 继承
class Square extends Rectangle {

  constructor(length) {
    super(length, length);
    
    // 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
    this.name = 'Square';
  }

  get area() {
    return this.height * this.width;
  }
}

const square = new Square(10);
console.log(square.area);
// 输出 100
复制代码

extends继承的核心代码如下,其实现和上述的寄生组合式继承方式一样

function _inherits(subType, superType) {
  
    // 创建对象,创建父类原型的一个副本
    // 增强对象,弥补因重写原型而失去的默认的constructor 属性
    // 指定对象,将新创建的对象赋值给子类的原型
    subType.prototype = Object.create(superType && superType.prototype, {
        constructor: {
            value: subType,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    
    if (superType) {
        Object.setPrototypeOf 
            ? Object.setPrototypeOf(subType, superType) 
            : subType.__proto__ = superType;
    }
}
复制代码

总结

1、函数声明和类声明的区别

函数声明会提升,类声明不会。首先需要声明你的类,然后访问它,否则像下面的代码会抛出一个ReferenceError。

let p = new Rectangle(); 
// ReferenceError

class Rectangle {}
复制代码

2、ES5继承和ES6继承的区别

  • ES5的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.call(this)).
  • ES6的继承有所不同,实质上是先创建父类的实例对象this,然后再用子类的构造函数修改this。因为子类没有自己的this对象,所以必须先调用父类的super()方法,否则新建实例报错。

总结

//构造函数
function Foo() {}

//实例对象
let f1 = new Foo()

let o1 = new Object()

console.log(f1.__proto__ === Foo.prototype) //true
console.log(Foo.prototype.constructor === Foo) //true
console.log(Foo.prototype.__proto__ === Object.prototype) //true 沿着原型链往上找都会找到Object.prototype

console.log(o1.__proto__ === Object.prototype) //true
console.log(Object.prototype.__proto__) //null 原型链的终点指向null
console.log(Foo.prototype.constructor === Foo) //true

console.log(Function.prototype.constructor === Function) //true

console.log(Object.__proto__ === Function.prototype) //true 内置的引用类型(包括Object)其实也是一个函数对象,都是由Function创建的,所以原型会指向Function.prototype
console.log(Foo.__proto__ === Function.prototype) //true
console.log(Function.prototype.prototype === undefined) //true Function.prototype是个特例,它是函数对象,但是没有prototype属性。其他所有函数都有prototype属性。 

由上图可知:

  • 函数的显式原型指向的对象默认是空Object实例对象(但Object不满足)
console.log(Fn.prototype instanceof Object) //true
console.log(Object.prototype instanceof Object) //false
console.log(Function.prototype instanceof Object) //true
  • 所有函数都是Function的实例(包括Function)

**

console.log(Function.__proto__ === Function.prototype) //true
  • 题目 1
var A = function() {};
A.prototype.n = 1;
var b = new A();
A.prototype = {
  n: 2,
  m: 3
}
var c = new A();

console.log(b.n);
console.log(b.m);

console.log(c.n);
console.log(c.m);

请写出上面编程的输出结果是什么?

  • 题目 2
var F = function() {};

Object.prototype.a = function() {
  console.log('a');
};

Function.prototype.b = function() {
  console.log('b');
}

var f = new F();

f.a();
f.b();

F.a();
F.b();

请写出上面编程的输出结果是什么?

  • 题目 3
function Person(name) {
    this.name = name
}
let p = new Person('Tom');

问题1:1. p.__proto__等于什么?

问题2:Person.__proto__等于什么?

  • 题目 4
var foo = {},
    F = function(){};
Object.prototype.a = 'value a';
Function.prototype.b = 'value b';

console.log(foo.a);
console.log(foo.b);

console.log(F.a);
console.log(F.b);

请写出上面编程的输出结果是什么?

解题

  • 题目 1 答案:
b.n -> 1
b.m -> undefined;

c.n -> 2;
c.m -> 3;
  • 题目 2 答案:
f.a() -> a
f.b() -> f.b is not a function

F.a() -> a
F.b() -> b
  • 题目 3 答案

答案1:Person.prototype

答案2:Function.prototype

  • 题目 4 答案
foo.a => value a
foo.b => undefined
F.a => value a
F.b => value b
Object.prototype.__proto__ //null 
Function.prototype.__proto__ //Object.prototype 
Object.__proto__ //Function.prototype