eternitrance

JavaScript 原型链和一道面试题

August 05, 2018

继续啃高程中… 这次是关于 JS 原型链的问题,和在看某乎文章时提及的定时器的工作原理问题。

关键词:原型链、闭包、变量作用域(ES6语法)、IIFE、定时器原理…

原型链

原理

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

下面这个例子可以辅助理解这一问题:

// 构造函数
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 共享属性。

// 构造函数
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

所以总结上述,可得出以下结果:

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() 方法检查,用法见下例:

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

从上例可看出,country 不是 person1 的自有属性,而是用过原型链向上级(Person.prototype)查找得到的,也就是继承的属性。

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

继承

普通原型链继承有几大缺点:

  • 原型方法代码必须放在替换原型的语句之后
  • 不能使用对象字面量创建原型方法,这样会改写原型链
  • 当只想给实例定义的属性,将会变成原型的属性,继承的属性会被子类实例共享
  • 在创建子类型的实例时,不能向超类型的构造函数中传递函数

下面是几种原型链的继承方法:

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

    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. 组合继承:也称伪经典继承。将原型链和借用构造函数糅合起来。思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。

    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深入之从原型到原型链

一道面试题

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

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(声明即执行的函数表达式)

    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. 利用按值传递,拆分函数

    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,但此方法并不全对

    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 语法实现

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 秒
});

下例是更加简洁的代码:

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 与循环闭包

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


This is tocz9ea's personal blog.
He is a 'foolish' coder, writing lines without mind.