講清楚 JavaScript 原型

記得之前知乎上看到過一個問題:面試一個 5 年的前端,卻連原型鏈也搞不清楚,滿口都是 Vue,React 之類的實現,這樣的人該用嗎? 。寫文章的時候又回去看了下這個問題,300 多個回答,有不少大佬都回答了這個問題,說明這個問題仍是挺受關注的。最近幾年,隨着 ES6 ,TypeScript 及相似的中間語言的流行,咱們平時作業務開發不多能接觸到原型,基本上都是用 ES6 class 來去更簡單的,更直觀的實現之前構造器加原型作的事情。javascript

其實在我看來,我以爲原型鏈是一個很是重要的基礎知識。若是一我的說他 C 語言很精通,可是他彙編不熟,你信嗎?我以爲 winter 說的挺簡潔到位的:前端

這又涉及到我以前講過的面試官技巧,面試,是對一我的的能力系統性評價,搞清楚一我的擅長什麼不會什麼,因此問知識性問題,爲了不誤判,必定要大量問、系統化地問。java

不會原型很能說明問題,至少他在庫的設計方面會有極大劣勢,並且可能學習習慣上是有問題的,也有可能他根本就不太會 JS 語言,可是這不意味着憑藉一個問題就能夠斷定這我的不能用。node

本文包括如下內容:python

  1. JavaScript 原型
  2. 構造器和 prototype
  3. 原型鏈
  4. 原型的用途
  5. ES6 class 和構造器的關係
  6. 原型繼承
  7. JavaScript 和原型相關語法特性
  8. 原型污染

JavaScript 原型

原型的英文應該叫作 prototype,任何一個對象都有原型,咱們能夠經過非標準屬性 __proto__ 來訪問一個對象的原型:jquery

// 純對象的原型
console.log({}.__proto__); // => {}

function Student(name, grade) {
  this.name = name;
  this.grade = grade;
}

const stu = new Student('xiaoMing', 6);
// Student 類型實例的原型,默認也是一個空對象
console.log(stu.__proto__); // => Student {}
複製代碼

__proto__ 是非標準屬性,若是要訪問一個對象的原型,建議使用 ES6 新增的 Object.getPrototypeOf() 方法。非標準屬性意味着將來可能直接會修改或者移除該屬性,說不定之後出了個新標準用 Symbol.proto 做爲 key 來訪問對象的原型,那這個非標準屬性可能就要被移除了。ios

console.log({}.__proto__ === Object.getPrototypeOf({})); // => true
複製代碼

咱們能夠直接修改對象的原型,不過被設置的值的類型只能是對象或者 null,其它類型不起做用:git

const obj = { name: 'xiaoMing' };
// 原型爲空對象
console.log(obj.__proto__); // => {}

obj.__proto__ = 666;
// 非對象和 null 不生效
console.log(obj.__proto__); // => {}

obj.__proto__ = null;
// 設置爲 null 返回 undefined
console.log(obj.__proto__); // undefined

// 設置原型爲對象
obj.__proto__ = { constructor: 'Function Student' };
console.log(obj.__proto__); // => { constructor: 'Function Student' }
複製代碼

若是被設置的值是不可擴展的,將拋出 TypeError:github

const frozenObj = Object.freeze({});
// Object.isExtensible(obj) 能夠判斷 obj 是否是可擴展的
console.log(Object.isExtensible(frozenObj)); // => false
frozenObj.__proto__ = null; // => TypeError: #<Object> is not extensible
複製代碼

原型上的屬性都是不可枚舉的:面試

const obj = {};
obj.__proto__.xxx = 666;
console.log(Object.keys(obj)); // => []
複製代碼

其實 __proto__ 是個訪問器屬性(getter 和 setter 都有),經過 __proto__ 訪問器咱們能夠訪問對象的[[Prototype]], 也就是原型。簡單實現一下:

Object.prototype = {
  get __proto__() {
    return this['[[prototype]]'];
  },
  set __proto__(newPrototype) {
    if (!Object.isExtensible(newPrototype)) throw new TypeError(`${newPrototype} is not extensible`);

    if (newPrototype === null) {
      this['[[prototype]]'] = undefined;
      return;
    }

    const isObject = typeof newPrototype === 'object' || typeof newPrototype === 'function';

    if (isObject) {
      this['[[prototype]]'] = newPrototype;
    }
  },
  // ... 其它屬性如 toString,hasOwnProperty 等
};
複製代碼

構造器和 prototype

構造器的英文就是 constructor,在 JavaScript 中,函數均可以用做構造器。構造器咱們也能夠稱之爲類,Student 構造器不就能夠稱之爲 Student 類嘛。咱們能夠經過 new 構造器來構造一個實例。習慣上咱們對用做構造器的函數使用大駝峯命名:

function Apple() {}
const apple = new Apple();
console.log(apple instanceof Apple); // => true
複製代碼

任何構造器都有一個 prototype 屬性,默認是一個空的純對象,全部由構造器構造的實例的原型都是指向它。

// 實例的原型即 apple1.__proto__
console.log(apple1.__proto__ === Apple.prototype); // => true
console.log(apple2.__proto__ === Apple.prototype); // => true
複製代碼

下面的測試結果能夠證實構造器的 prototype 屬性默認是個空對象:

console.log(Apple.prototype); // => Apple {}
console.log(Object.keys(Apple.prototype)); // => []
console.log(Apple.prototype.__proto__ === {}.__proto__); // true
複製代碼

構造器的 prototype 有一個 constructor 屬性,指向構造器自己:

console.log(Apple.prototype.constructor === Apple); // => true
複製代碼

__proto__prototypeconstructorApple函數,實例 apple 和原型對象 [[prototype]] 之間的關係:

relationship.png

有些人可能會把 __proto__prototype 搞混淆。從翻譯的角度來講,它們均可以叫原型,可是實際上是徹底不一樣的兩個東西。

__proto__ 存在於全部的對象上,prototype 存在於全部的函數上,他倆的關係就是:函數的 prototype 是全部使用 new 這個函數構造的實例的 __proto__。函數也是對象,因此函數同時有 __proto__prototype

注意:若是我文章中提到了構造器的原型,指的是構造器的 __proto__,而不是構造器的 prototype 屬性。

原型鏈

那麼對象的原型有什麼特色呢?

當在一個對象 obj 上訪問某個屬性時,若是不存在於 obj,那麼便會去對象的原型也就是 obj.__proto__ 上去找這個屬性。若是有則返回這個屬性,沒有則去對象 obj 的原型的原型也就是 obj.__proto__.__proto__去找,重複以上步驟。一直到訪問純對象的原型的原型{}.__proto.__proto__,也就是 null,直接返回 undefined。

舉個例子:

function Student(name, grade) {
  this.name = name;
  this.grade = grade;
}

const stu = new Student();
console.log(stu.notExists); // => undefined
複製代碼

訪問 stu.notExists 的整個過程是:

  1. 先看 stu 上是否存在 notExists,不存在,因此看 stu.__proto__
  2. stu.__proto__ 上也不存在 notExists 屬性,再看 stu.__proto__.__proto__,其實就是純對象的原型:{}.__proto__
  3. 純對象的原型上也不存在 notExists 屬性,再往上,到 stu.__proto__.__proto__.__proto__ 上去找,其實就是 null
  4. null 不存在 notExists 屬性,返回 undefined

可能有讀者看了上面會有疑問,對象的原型一直查找最後會找到純對象的原型?測試一下就知道了:

console.log(stu.__proto__.__proto__ === {}.__proto__); // => true
複製代碼

純對象的原型的原型是 null:

console.log(new Object().__proto__.__proto__); // => null
複製代碼

各個原型之間構成的鏈,咱們稱之爲原型鏈。

prototypeChain1.png

想一想看,函數 Student 的原型鏈應該是怎樣的?

functionPrototypeChain.png

原型的用途

在使用構造器定義一個類型的時候,咱們通常會將類的方法定義在原型上,和 this 的指向特性簡直是絕配。

function Engineer(workingYears) {
  this.workingYears = workingYears;
}

// 不能使用箭頭函數,箭頭函數的 this 在聲明的時候就根據上下文肯定了
Engineer.prototype.built = function() {
  console.log(`我已經工做 ${this.workingYears} 年了, 個人工做是擰螺絲...`);
};

const engineer = new Engineer(5);
// this 會正確指向實例,因此 this.workingYears 是 5
engineer.built(); // => 我已經工做 5 年了, 個人工做是擰螺絲...
console.log(Object.keys(engineer)); // => [ 'workingYears' ]
複製代碼

經過這種方式,全部的實例均可以訪問到這個方法,而且這個方法只須要佔用一分內存,節省內存,this 的指向還能正確指向類的實例。

不過這種方式定義的方法都是不可枚舉的,畢竟不是自身的屬性:

const obj = {
  func() {}
};

console.log(Object.keys(obj)); // => [ 'func' ]

function Func() {}
Func.prototype.func = function() {};
// 空數組,說明 func 不可枚舉
console.log(Object.keys(new Func())); // => []
複製代碼

若是你就是要定義實例屬性的話仍是隻能經過 this.xxx = xxx 的方式定義實例方法了:

function Engineer(workingYears) {
  this.workingYears = workingYears;
  this.built = function() {
    console.log(`我已經工做 ${this.workingYears} 年了, 個人工做是擰螺絲...`);
  };
}

const engineer = new Engineer(5);
console.log(Object.keys(engineer)); // => [ 'workingYears', 'built' ]
複製代碼

其實 JavaScript 中不少方法都定義在構造器的原型上,例如 Array.prototype.slice,Object.prototype.toString 等。

ES6 class 和構造器的關係

不少語言都有擁有面向對象編程範式,例如 java, c#, python 等。ES6 class 讓從它們轉到 JavaScript 的開發者更容易進行面向對象編程。

ES6 class

其實,ES6 class 就是構造器的語法糖。 咱們來看一下 babel 將 ES6 class 編譯成了啥:

原代碼:

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

  draw() {
    console.log(`畫個座標爲 (${this.x}, ${this.y}),半徑爲 ${this.r} 的圓`);
  }
}
複製代碼

babel + babel-preset-es2015-loose 編譯出的結果:

'use strict';

// Circle class 能夠理解爲就是一個構造器函數
var Circle = (function() {
  function Circle(x, y, r) {
    this.x = x;
    this.y = y;
    this.r = r;
  }

  var _proto = Circle.prototype;

  // class 方法定義在 prototype 上
  _proto.draw = function draw() {
    console.log(
      '\u753B\u4E2A\u5750\u6807\u4E3A (' +
        this.x +
        ', ' +
        this.y +
        ')\uFF0C\u534A\u5F84\u4E3A ' +
        this.r +
        ' \u7684\u5706'
    );
  };

  return Circle;
})();
複製代碼

一看就明白了, ES6 的 class 就是構造器,class 上的方法定義在構造器的 prototype 上,所以你也能夠理解爲何 class 的方法是不可枚舉的。

extends 繼承

咱們再來看一下使用 extends 繼承時是怎樣轉換的。

原代碼:

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

class Circle extends Shape {
  constructor(x, y, r) {
    super(x, y);
    this.r = r;
  }

  draw() {
    console.log(`畫個座標爲 (${this.x}, ${this.y}),半徑爲 ${this.r} 的圓`);
  }
}
複製代碼

babel + babel-preset-es2015-loose 編譯出的結果:

'use strict';

// 原型繼承
function _inheritsLoose(subClass, superClass) {
  subClass.prototype = Object.create(superClass.prototype);
  subClass.prototype.constructor = subClass;
  subClass.__proto__ = superClass;
}

var Shape = function Shape(x, y) {
  this.x = x;
  this.y = y;
};

var Circle = (function(_Shape) {
  _inheritsLoose(Circle, _Shape);

  function Circle(x, y, r) {
    var _this;

    // 組合繼承
    _this = _Shape.call(this, x, y) || this;
    _this.r = r;
    return _this;
  }

  var _proto = Circle.prototype;

  _proto.draw = function draw() {
    console.log(
      '\u753B\u4E2A\u5750\u6807\u4E3A (' +
        this.x +
        ', ' +
        this.y +
        ')\uFF0C\u534A\u5F84\u4E3A ' +
        this.r +
        ' \u7684\u5706'
    );
  };

  return Circle;
})(Shape);
複製代碼

整個 ES6 的 extends 實現的是原型繼承 + 組合繼承。

子類構造器中調用了父類構造器並將 this 指向子類實例達到將父類的實例屬性組合到子類實例上

// 組合繼承
_this = _Shape.call(this, x, y) || this;
複製代碼

_inheritsLoose 這個函數就是實現了下一節要講的原型繼承。

原型繼承

在講原型繼承 以前咱們先講講繼承這個詞。我以爲,通俗意義上的繼承是說:若是類 A 繼承自類 B,那麼 A 的實例繼承了 B 的實例屬性

原型繼承的這個繼承和通俗意義上的繼承還不太同樣,它是要:A 的實例可以繼承 B 的原型上的屬性

給原型繼承下個定義:

對於類 A 和類 B,若是知足 A.prototype.__proto__ === B.prototype,那麼 A 原型繼承 B
複製代碼

prototypeExtends.png

如何實現原型繼承呢?最簡單的方式就是直接設置 A.prototype === new B(),讓 A 的 prototype 是 B 的實例便可:

function A() {}
function B() {
  this.xxx = '污染 A 的原型';
}

A.prototype = new B();

console.log(A.prototype.__proto__ === B.prototype); // => true
複製代碼

可是這種方式會致使 B 的實例屬性污染 A 的原型。解決辦法就是經過一個空的函數橋接一下,空的函數總不會有實例屬性污染原型鏈嘍:

function A(p) {
  this.p = p;
}

function B() {
  this.xxx = '污染原型';
}

// 空函數
function Empty() {}

Empty.prototype = B.prototype;
A.prototype = new Empty();
// 修正 constructor 指向
A.prototype.constructor = A;

// 知足原型繼承的定義
console.log(A.prototype.__proto__ === B.prototype); // => true

const a = new A('p');
console.log(a instanceof A); // => true

const b = new B();
console.log(b instanceof B); // => true

// a 也是 B 的實例
console.log(a instanceof B); // => true
console.log(a.__proto__.__proto__ === B.prototype); // => true
複製代碼

用 Windows 自帶的畫圖軟件畫的原型鏈_〆(´Д ` ):

prototypeChain.png

利用 Object.create,咱們能夠更簡單的實現原型繼承,也就是上面的 babel 用到的工具函數 _inheritsLoose

function _inheritsLoose(subClass, superClass) {
  // Object.create(prototype) 返回一個以 prototype 爲原型的對象
  subClass.prototype = Object.create(superClass.prototype);
  subClass.prototype.constructor = subClass;
  // 咱們上面實現的原型繼承沒有設置這個,可是 class 的繼承會設置子類的原型爲父類
  subClass.__proto__ = superClass;
}
複製代碼

JavaScript 和原型相關語法特性

其實由不少語法特性是和原型有關係的,講到原型那麼咱們就再繼續講講 JavaScrip 語法特性中涉及到原型的一些知識點。

new 運算符原理

當咱們對函數使用 new 的時候發生了什麼。

使用代碼來描述就是:

function isObject(value) {
  const type = typeof value;
  return value !== null && (type === 'object' || type === 'function');
}

/** * constructor 表示 new 的構造器 * args 表示傳給構造器的參數 */
function New(constructor, ...args) {
  // new 的對象不是函數就拋 TypeError
  if (typeof constructor !== 'function') throw new TypeError(`${constructor} is not a constructor`);

  // 建立一個原型爲構造器的 prototype 的空對象 target
  const target = Object.create(constructor.prototype);
  // 將構造器的 this 指向上一步建立的空對象,並執行,爲了給 this 添加實例屬性
  const result = constructor.apply(target, args);

  // 上一步的返回若是是對象就直接返回,不然返回 target
  return isObject(result) ? result : target;
}
複製代碼

簡單測試一下:

function Computer(brand) {
  this.brand = brand;
}

const c = New(Computer, 'Apple');
console.log(c); // => Computer { brand: 'Apple' }
複製代碼

instanceof 運算符原理

instanceof 用於判斷對象是不是某個類的實例,若是 obj instance A,咱們就說 obj 是 A 的實例。

它的原理很簡單,一句話歸納就是:obj instanceof 構造器 A,等同於判斷 A 的 prototype 是否是 obj 的原型(也多是二級原型)

代碼實現:

function instanceOf(obj, constructor) {
  if (!isObject(constructor)) {
    throw new TypeError(`Right-hand side of 'instanceof' is not an object`);
  } else if (typeof constructor !== 'function') {
    throw new TypeError(`Right-hand side of 'instanceof' is not callable`);
  }

  // 主要就這一句
  return constructor.prototype.isPrototypeOf(obj);
}
複製代碼

簡單測試一下:

function A() {}
const a = new A();

console.log(a instanceof A); // => true
console.log(instanceOf(a, A)); // => true
複製代碼

原型污染

在去年 2019 年秋天我還在國內某大廠實習的時候,lodash 爆出了一個嚴重的安全漏洞:Lodash 庫爆出嚴重安全漏洞,波及 400 萬+項目。這個安全漏洞就是因爲原型污染致使的。

原型污染指的是:

攻擊者經過某種手段修改 JavaScript 對象的原型

雖說任何一個原型被污染了都有可能致使問題,可是咱們通常提原型污染說的就是 Object.prototype 被污染。

原型污染的危害

性能問題

舉個最簡單的例子:

Object.prototype.hack = '污染原型的屬性';
const obj = { name: 'xiaoHong', age: 18 };
for (const key in obj) {
  if (obj.hasOwnProperty(key)) {
    console.log(obj[key]);
  }
}

/* => xiaoHong 18 */
複製代碼

原型被污染會增長遍歷的次數,每次訪問對象自身不存在的屬性時也要訪問下原型上被污染的屬性。

致使意外的邏輯 bug

看一個具體的 node 安全漏洞案例:

'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 = '127.0.0.1';
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}`);
複製代碼

這段代碼的漏洞就在於 merge 函數上,咱們能夠這樣攻擊:

curl -vv --header 'Content-type: application/json' -d '{"__proto__": {"admin": 1}}' 'http://127.0.0.1:4000/signup';

curl -vv 'http://127.0.0.1/getFlag'
複製代碼

首先請求 /signup 接口,在 NodeJS 服務中,咱們調用了有漏洞的 merge 方法,並經過 __proto__Object.prototype(由於 {}.__proto__ === Object.prototype) 添加上一個新的屬性 admin,且值爲 1。

再次請求 getFlag 接口,訪問了 Object 原型上的admin,條件語句 admin.аdmin == 1true,服務被攻擊。

預防原型污染

其實原型污染大多發生在調用會修改或者擴展對象屬性的函數時,例如 lodash 的 defaults,jquery 的 extend。預防原型污染最主要仍是要有防患意識,養成良好的編碼習慣。

Object.create(null)

筆者看過一些類庫的源碼時,常常能看到這種操做,例如 EventEmitter3。經過 Object.create(null) 建立沒有原型的對象,即使你對它設置__proto__ 也沒有用,由於它的原型一開始就是 null,沒有 __proro__setter

const obj = Object.create(null);
obj.__proto__ = { hack: '污染原型的屬性' };
const obj1 = {};
console.log(obj1.__proto__); // => {}
複製代碼

Object.freeze(obj)

能夠經過 Object.freeze(obj) 凍結對象 obj,被凍結的對象不能被修改屬性,成爲不可擴展對象。前面也說過不能修改不可擴展對象的原型,會拋 TypeError:

const obj = Object.freeze({ name: 'xiaoHong' });
obj.xxx = 666;
console.log(obj); // => { name: 'xiaoHong' }
console.log(Object.isExtensible(obj)); // => false
obj.__proto__ = null; // => TypeError: #<Object> is not extensible
複製代碼

距離我從以前實習的公司離職也有將近三個月了,我記得那時候每次 npm install 都顯示檢查出幾十個依賴漏洞。確定是很久都沒升級纔會積累那麼多漏洞,反正我是不敢隨便升級,以前一個 bug 查了好半天結果是由於 axios 的升級致使的。也不知道到如今有沒有升級過😄。

參考資料:

  1. 最新:Lodash 嚴重安全漏洞背後你不得不知道的 JavaScript 知識

本文爲原創內容,首發於我的博客,轉載請註明出處。

相關文章
相關標籤/搜索