单独提出来是方便自己下次看到能快速浏览,之前的笔记太混乱了,链路逻辑有点混乱,一些细节也忽略掉了,借此重构一下;再者是自己本身对 nodejs 这一块的认识没有那么深刻

JavaScript 原型链

继承属性

JavaScript 对象有一条指向原型的链,当访问其属性时除了该对象,还会在其原型甚至是原型的原型中去查找该属性,直到到达原型链的末尾
想要访问一个对象的原型有两种方法,函数标准形式:通过 Object.getPrototypeOf() 和 Object.setPrototypeOf() 来访问和修改;非标准形式:JavaScript 访问器 __proto__

1
2
let o ={}  
console.log(o.__proto__ === Object.getPrototypeOf(o)); // true
Note

根据 ECMAScript 标准,符号 someObject.[[Prototype]] 用于指定 someObject 的原型


这不是代码中可写的语法,而是规范中指定一个对象的记法

对于以下例子,我们显式设置 o 的原型为 {b, 2}

1
2
3
4
5
let o ={  
a: 1,
__proto__: {b: 2}
}
console.log(o.b); // 2

对于这个对象字面量,a 键是自有属性,可以将 [[Prototype]] 理解为对象的一个“内部属性”,这里显然是通过 o 的原型找到了 b 属性
注意这里不管嵌套多少 __proto__,其原型链都为 {a, 1} -> {b, 2} -> ... -> [Object: null prototype] {} -> null

构造函数

构造函数的功能之一是复用,尤其是对于让多个实例共享方法
构造函数是使用 new 调用的函数,在使用 new 运算符调用函数时,构造函数的 prototype 属性将成为新实例对象的原型,其意义就是这个,避免每个实例都重复创建同一份函数

1
2
3
4
5
6
7
8
function O(value) {  
this.value = value;
}

O.prototype.name = "protoName";
let o1 = new O(1);
console.log(Object.getPrototypeOf(o1) === O.prototype); // true
console.log(o1.__proto__); // { name: 'protoName' }


Constructor.prototype 默认具有一个自有属性:constructor,返回一个引用,指向创建该实例对象的构造函数

1
2
3
4
5
6
7
8
9
function O(value) {  
this.value = value;
}

O.prototype.name = "protoName";
let o1 = new O(1);

console.log(O.prototype.constructor === O); // true
console.log(o1.constructor === O); // ture

可以和前文继承属性这一块结合起来理解,实例 o1 本身是没有 constructor 自有属性的,既然找不到只能去它的原型也就是构造函数上面去找

字面量的隐式构造函数

在 JavaScript 中,这些字面量语法会创建隐式设置原型([[Prototype]])的实例
这里其实很容易理解,因为关键字的底层就是这些构造函数

1
2
3
4
5
const o = {a: 1}  // 本来是 const o = new Object({ a: 1 });
console.log(Object.getPrototypeOf(o) === Object.prototype); // true

const a = [1, 2, 3] // 本来是 const array = new Array(1, 2, 3);
console.log(Object.getPrototypeOf(a) === Array.prototype); // true

总结一下:

  • 实例继承了(构造函数)Constructor.prototype
  • Array.prototypeString.prototypeFunction.prototype 等继承了 Object.prototype
  • Object.prototype 继承了 null

原型链污染原理

理解了 JavaScript 原型链,再看污染就好多了;因为 JavaScript 代码的接触面相对比较少,所以以前每次都要回顾自己的旧笔记,也是这一次在某比赛中遇到大量 nodejs 的题目,才这样系统性的梳理一遍

1
2
3
4
5
6
let o1 = {a: 1}  
let o2 = {}

o1.__proto__.b = 2

console.log(o1.b, o2.b) // 2 2

现在应该理解了,他俩的原型都是 Object.prototype,这里我们直接给原型一个属性 b 并赋值 2
虽然 o1 o2 的自有属性中都没有 b 键,但是他们的原型却有,所以依然输出 2

而一般的原型链污染都会伴随错误的 merge 合并操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function merge(target, source) {  
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}

let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');

merge(o1, o2);
let o3 = {};

console.log(o1.a, o1.b); // 1 2
console.log(o3.b) // 2



merge 函数的核心作用就是去遍历键名进行合并操作,这里显然 __proto__ 也是一个键,自然而然将 Object.prototype 也给污染了,那么所有继承自它的对象都会平白无故的有一个属性 b

Attention

这里 JSON.parse 是为了将原本的对象字面量 __proto__ 解析成真正的键名,否则是无法污染的;如果没有那么只会给 o2 实例增添一个原型 {"b", 2},当作设置对象原型的语法处理,并没有影响到 Object.prototype,我们可以从继承属性的例子得知这个结果


同样的,实例的 constructor 指向创建该实例对象的构造函数,我们可以通过 constructor.prototype 来访问其构造函数的原型达到污染的目的

1
2
let o1 = {};
console.log(o1.constructor.prototype); // Object.prototype

顺便提一句,console.log(o1.constructor.prototype.constructor); 又会循环回去到 o1 的构造函数 Object

1
2
3
4
5
6
7
8
let o1 = {};  
let o2 = {"constructor": {"prototype": {"b": 123}}};

merge(o1, o2);
let o3 = {};

console.log(o1.b); // 123
console.log(o3.b); // 123