JavaScript 原型鏈與一道面試題 [Update]

繼續啃高程中… 這次是關於 JS 原型鏈的問題,和在看某乎文章時提及的定時器的工作原理問題。

關鍵詞:原型鏈、閉包、變量作用域(ES6語法)、IIFE、定時器原理…

原型鏈

原理

構造函數、原型與實例的關係:每個構造函數都有一個原型對象,原型對象都包含一個指向構造函數的指針,而實例都包含一個指向原型對象的內部指針。

下面這個例子可以輔助理解這一問題:

1
2
3
4
5
6
7
8
9
// 構造函數
function Person(name, gender) {
this.name = name;
this.gender = gender;
}
// 生成實例對象
var person1 = new Person("John", "male");
var person2 = new Person("Ada", "female");
console.log(person1.name, person2.gender); // John Female

爲了生成 person1person2 兩個實例對象,首先要創建構造函數。

與普通函數不同是,只有通過 new 創造實例對象的函數才爲構造函數。

上例創建的構造函數不僅有需要定義的兩個屬性:namegender,還需具有實例間可共享的 prototype 屬性(原型)。

而此時兩個實例對象都具有一個特別的 __proto__ 屬性,用於鏈接構造函數的 prototype 共享屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 構造函數
function Person(name, gender) {
this.name = name;
this.gender = gender;
}
// 共享屬性 prototype
console.log(Person.prototype); // { }
Person.prototype = { country: "China" };
console.log(Person.prototype); // { country: "China" }
// 生成實例對象
var person1 = new Person("John", "male");
var person2 = new Person("Ada", "female");
console.log(person1.country, person2.country); // China China

上面繼續添加了 Personprototype 屬性,重新生成 person1person2 兩個實例對象後,發現兩個實例對象都擁有一個共同的屬性 country

所以總結上述,可得出以下結果:

1
2
3
4
person1.__proto__ == Person.prototype; // true
person1.__proto__.__proto__ === Person.prototype.__proto__ // true
Person.prototype.__proto__ === Object.prototype // true
person1.__proto__.__proto__ === Object.prototype // true

如果想要知道實例對象的屬性是否屬於 prototype,可以使用 hasOwnProperty() 方法檢查,用法見下例:

1
2
3
person1.hasOwnProperty('name'); //true
person2.hasOwnProperty('gender'); //true
person1.hasOwnProperty('country'); //false

從上例可看出,country 不是 person1 的自有屬性,而是用過原型鏈向上級(Person.prototype)查找得到的,也就是繼承的屬性。

需要注意的是,hasOwnProperty() 方法則是由最上層原型鏈(Object.prototype)查找得到的。

繼承

普通原型鏈繼承有幾大缺點:

  • 原型方法代碼必須放在替換原型的語句之後
  • 不能使用對象字面量創建原型方法,這樣會改寫原型鏈
  • 當只想給實例定義的屬性,將會變成原型的屬性,繼承的屬性會被子類實例共享
  • 在創建子類型的實例時,不能向超類型的構造函數中傳遞函數

下面是幾種原型鏈的繼承方法:

  1. 借用構造函數:也稱經典繼承。在子類型構造函數的內部調用超類型構造函數。(call(), apply())这种方法解决了原型链的两个主要的问题,为了确保 SuperType 构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function SuperType(name){
    this.colors = ["red", "blue", "green"];
    this.name = name;
    }
    function SubType(){
    //继承了 SuperType 同时还传递了参数
    SuperType.call(this, "Nicholas"); //注意引入方式
    this.age = 29;
    }
    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"
    alert(instance2.name);
    alert(instance2.age);
  2. 組合繼承:也稱僞經典繼承。將原型鏈和借用構造函數糅合起來。思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用構造函數來實現對實例屬性的繼承。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    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;
    }
    //继承方法
    SubType.prototype = new SuperType();
    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

參考鏈接:該來理解 JavaScript 的原型鏈了

JavaScript深入之从原型到原型链

一道面試題

在某乎上看到了一篇文章,代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}

console.log(new Date, i);
// 認爲的結果 實際的結果
// 5 5
// 0 5
// 1 5
// 2 5
// 3 5
// 4 5

當看到這道題的時候,認爲的結果是:5->0->1->2->3->4,但實際琢磨完后卻發現答案並不是,而是:5->5/5/5/5/5 。(首先輸出5,其次同時輸出五個5。)

首先要清楚的是,先輸出的是console.log(new Date, i);。因爲 for... 中設置了一個1秒鐘的定時器,並會在隨後輸出。

其次在 for 循环声明的五个setTimeout() 都有对变量 i引用,而不是拷贝。(这里的引用不是引用类型的引用,而是 i 作为函数作用域链的一个变量,由于闭包造成的)

因为5个 setTimeout() 都涉及到延迟执行的情况,所以当主线程执行完后,Timeout这些回调依次执行(队列:FIFO),此时 i 的值已经为5。

關於閉包的拓展閱讀,請看此篇

關於定時器的工作原理,詳讀此篇

那如何稍作改變,輸出 5->0->1->2->3->4 呢?

  1. IIFE(声明即执行的函数表达式 )

    1
    2
    3
    4
    5
    6
    7
    8
    9
    for (var i = 0; i < 5; i++) {
    (function(j) { // j = i
    setTimeout(function() {
    console.log(new Date, j);
    }, 1000);
    })(i);
    }

    console.log(new Date, i);
  2. 利用按值傳遞,拆分函數

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var output = function (i) {
    setTimeout(function() {
    console.log(new Date, i);
    }, 1000);
    };

    for (var i = 0; i < 5; i++) {
    output(i); // i 從此處複製並傳遞
    }

    console.log(new Date, i);
  3. 可使用ES6語法, let 替代 var,但此方法並不全對

    1
    2
    3
    4
    5
    6
    7
    for (let i = 0; i < 5; i++) {
    setTimeout(function() {
    console.log(new Date, i);
    }, 1000);
    } // i 值在此行后將不存在

    console.log(new Date, i); //此行無法執行,將會報錯

那再做一些改變,輸出 0->1->2->3->4->5 呢?

最好還是使用 ES6 的 Promise 語法實現

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const tasks = [];
for (var i = 0; i < 5; i++) { // 这里 i 的声明不能改成 let,如果要改该怎么做?
((j) => {
tasks.push(new Promise((resolve) => {
setTimeout(() => {
console.log(new Date, j);
resolve(); // 这里一定要 resolve,否则代码不会按预期 work
}, 1000 * j); // 定时器的超时时间逐步增加
}));
})(i);
}

Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(new Date, i);
}, 1000); // 注意这里只需要把超时设置为 1 秒
});

下例是更加簡潔的代碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const tasks = []; // 这里存放异步操作的 Promise
const output = (i) => new Promise((resolve) => {
setTimeout(() => {
console.log(new Date, i);
resolve();
}, 1000 * i);
});

// 生成全部的异步操作
for (var i = 0; i < 5; i++) {
tasks.push(output(i));
}

// 异步操作完成之后,输出最后的 i
Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(new Date, i);
}, 1000);
});

參考理解:

详解前端网红经典面试题:setTimeout 与循环闭包

破解前端面试:从闭包说起

0%