一道new运算符和点运算符优先级的八股面试题

这是一篇关于JavaScript面试题的解析文章,主要讨论了一道涉及new运算符和点运算符优先级的问题。文章详细分析了代码的执行过程,包括预编译、函数调用、this指针、原型链等方面,揭示了JavaScript中new运算符与点运算符的执行顺序和作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

最近正在找工作,对于面试中各种奇怪的问题真是深恶痛绝,大多面试的问题,在实践中毫无意义。面试的难度很高,做事的要求很低,“面试造火箭,上班拧螺丝”,可见一斑。

以前我很讨厌这样的技术面试,明明能力很不错,做事很踏实,却总拿不到offer,很气人。几次面试后,查缺补漏,慢慢理解了他们的“良苦用心”,毕竟面试只能通过技术考察和交谈来了解一个人,否则怎么知道你行呢?因此出一些面试题来考察基础知识就在所难免。

有些面试题因为考察知识全面,综合性强而成为经典。不过这样的面试题如果重复出,那就有点像科举考试写八股文了,套路总是那几样。

最近就遇到了一道这样的八股题,仔细分析下来确实有意思,考察了许多基础知识,相信都见过,一起来看看。

题目

问:这段代码输出了什么,为什么?

function Foo(){
  getName=function(){console.log(1);};
  return this;
}
Foo.getName=function(){ console.log(2);};
Foo.prototype.getName=function(){console.log(3);};
var getName=function(){cnsole.log(4);};
function getName(){console.log(5);}

Foo.getName();
getName();
Foo().getName();
getName();
new Foo.getName();
new Foo().getName();
new new Foo().getName();

这道题考察了全局对象、函数声明提升、变量提升、this指针、原型、构造函数、new运算符与点运算符的优先级。

解析

为了方便解析,给代码加入行号

1   function Foo(){
2       getName = function(){ console.log(1);};
3       return this;
4   }
5   Foo.getName = function(){ console.log(2);};
6   Foo.prototype.getName = function(){ console.log(3);};
7   var getName = function(){ console.log(4);};
8   function getName(){ console.log(5);}
9
10  Foo.getName();
11  getName();
12  Foo().getName();
13  getName();
14  new Foo.getName();
15  new Foo().getName();
16  new new Foo().getName();

0. 预编译

js在解析运行脚本前会进行预编译:创建全局对象,函数声明提升,变量声明提升。函数和变量的声明提升,其实是他们被设置为全局对象的属性。设置的过程中,函数的优先级高于变量,即如果有与函数同命的变量,变量的声明被忽略;相同名称的函数,后声明的函数覆盖之前的函数。

我们用GO (Global Object)代指全局对象,根据上述过程,先将两个声明函数(Foo[1-4行], getName[第8行])设置为全局对象的属性, 然后将var声明的变量(getName[第7行])设置为全局对象的属性,由于变量与声明的函数同名,该变量被忽略,即全局对象上的getName属性还是第8行的函数。

预编译后得到的全局对象是这样的:

GO = {
    Foo: function() { // 函数声明提升
        getName = function(){ console.log(1);};
        return this;
    },
    getName: function() { console.log(5)} // 函数声明提升
}

1. 第1-4行

1   function Foo(){
2       getName = function(){ console.log(1);}; 
3       return this;
4   }

第1-4行是一个函数声明,一上来就遇到难题,似乎看不懂。

  1. getName = function(){ console.log(1);};,这是个赋值语句,给getName赋值。要给getName赋值,首先要找到这个变量,函数体内有没有声明getName这个变量呢?没有。没有就往上一层,到全局环境中找。根据之前预编译的结果,我们知道全局变量中有getName属性,找到了,给它赋值。因此,执行这条语句,实际就是给全局变量getName赋值。当然现在还是函数声明阶段,没有生效,等执行Foo()函数的时候才会执行这条语句。

  2. return this,this指针指向的是一个对象,它的值取决于运行环境。

    1. 函数作为对象的方法调用,函数中的this指向对象
    2. 函数以普通方式调用(即我们常见的函数调用方式),函数中的this指向全局对象
    3. 函数被call、apply调用,this为call、apply的第一个参数
    4. 作为构造函数调用,this指向新创建的实例对象
    5. 匿名函数中的this指向全局对象

    根据第2条,执行Foo()就是以普通函数的方式调用,这里的this将指向全局对象。

    顺带说一句,如果执行new Foo(),执行的是构造函数的调用,this指向的就是新创建的实例对象。

2. 第5行

5   Foo.getName = function(){ console.log(2);};

给函数Foo设置getName属性。javascript中一切皆对象,函数是一种特殊的对象,因此可以给它设置属性。

3. 第6行

6   Foo.prototype.getName = function(){ console.log(3);};

给Foo函数的原型设置getName方法。

这里涉及到原型的知识。当Foo作为构造函数进行实例的创建时(new Foo()),Foo的原型上的方法将会共享给这些实例。如果实例对象中没有这个方法,那么会从实例的原型链上寻找方法。例如:

var foo = new Foo()
// 虽然foo没有定义getName, 但会从原型上获得共享方法 
console.log(foo.getName) // 输出 function(){ console.log(3);};

4. 第7行

7   var getName = function(){ console.log(4);};

这一行代码实际编译时相当于拆分成了两句:

// 在脚本第一行代码之前执行(变量声明提升)
var getName;
// 实际执行,对getName赋值
getName = function(){ console.log(4);};

getName的变量声明提升因为和getName函数同命而被忽略,这在预编译那部分我们已经说过了,这句代码略过。所以这条语句执行的就是对getName的赋值操作,getName被改写,即全局对象的getName属性被设置为这条语句的函数了,此时全局对象变为:

GO = {
    Foo: function() {
        getName = function(){ console.log(1);};
        return this;
    },
    getName: function() { console.log(4)} // getName被改写了,预编译阶段这里是打印5
}

5. 第8行

8   function getName(){ console.log(5);}

这是个函数声明,在预编译阶段被提升了,比其他代码先声明。

6. 第10行

10  Foo.getName();

这一句是取得Foo函数的getName属性,并执行这个属性值的函数。Foo的getName属性在第5行代码设置过

Foo.getName = function(){ console.log(2);};

执行Foo.getName() 就是执行这个函数。因此这里打印出2

7. 第11行

11  getName();

执行getName()函数,就是在全局环境里找到getName变量指向的函数,也就是全局对象的getName属性。代码运行到现在,全局对象的值如下:

GO = {
    Foo: function() {
        getName = function(){ console.log(1);};
        return this;
    },
    getName: function() { console.log(4)} // getName被改写了,预编译阶段这里是打印5
}

可以看到全局getName函数是: getName: function() { console.log(4)}。因此这里打印4

8. 第12行

12  Foo().getName();

这里有意思,先执行Foo()函数,然后调用getName方法。

执行Foo()

先看Foo()的执行。

1   function Foo(){
2       getName = function(){ console.log(1);};
3       return this;
4   }
1). getName = function(){ console.log(1);};

前面解释这个函数时说到,因为函数寻找变量时会逐层往上寻找,一直找到全局变量,因此第一句

getName = function(){ console.log(1);};

会给全局变量getName赋值。于是这句代码执行完毕,全局对象的getName属性的值又变了,此时全局对象变成:

GO = {
    Foo: function() {
        getName = function(){ console.log(1);};
        return this;
    },
    getName: function() { console.log(1)} // getName又被改写了,之前这里是打印4
}

可见执行Foo()会改变全局变量getName的值。

2). return this

Foo()的执行是以普通函数的方式执行,函数中的this指向全局对象,return this就把全局对象GO返回了,因此Foo()的执行后返回的是全局对象GO。

执行Foo().getName()

Foo()返回的是全局对象,那么语句相当于GO.getName(),也就是取得全局对象中此时的getName属性,全局对象中的getName最后一次被Foo函数修改了,它的值为:

getName: function() { console.log(1)}

那么执行这条语句就是执行上面这个函数,因此输出结果为1

9. 第13行

13  getName();

回马枪!getName函数又执行一遍,不过今时不同往日,全局变量getName的值因为Foo函数的执行而改变了,输出结果跟上条语句一样,结果为1

10.第14行

14  new Foo.getName();

这是第二难的地方,最难的地方是最后一行。这里搞懂了后面的就容易了。这里令人困惑的地方是,到底先new Foo还是先计算Foo.getName,也就是new和点运算符的优先级。

优先级运算类型关联性运算符
20圆括号n/a(不相关)( … )
19成员访问从左到右… . …
需计算的成员访问从左到右… [ … ]
new (带参数列表)n/anew … ( … )
函数调用从左到右… ( … )
可选链(Optional chaining)从左到右?.
18new (无参数列表)从右到左new …

上表详情见 运算符优先级

这里我们重点关注new (带参数列表)、new (无参数列表)与点运算符的优先级。

  • new (带参数列表) 和成员访问(点运算符)的优先级是一样的(都是19),优先级一样的情况下,表达式从左向右运算,就好像加和减的优先级一样,计算时从左向右算。因此出现new Foo().getName这样的表达式时,从左向右,先计算new Foo(), 再计算 **.getName,相当于(new Foo()).getName。就像3+2-1,就相当于(3+2)-1,我们知道这里的括号可以省略,是因为熟悉这样的运算顺序。

    带参数列表不一定要有实参,带了括号就表示能带参数的表达式,new Foo()和new Foo(10)是一样的表达式,前者的参数为0个而已。

  • new (无参数列表)的优先级是18,成员访问(点运算符)的优先级是19,点运算符优先级更高,所以new Foo.getName先计算优先级更高的,也就是计算Foo.getName,再计算new **

到这里明白了,new Foo.getName();先求得Foo.getName, 然后以所得结果进行new操作。第5行语句定义了

Foo.getName = function(){ console.log(2);};

为一个函数,以这个函数为构造函数,创建一个实例。 当然这里没有对创建的实例怎样,只是执行了一个实例的创建过程,而创建实例的过程要执行一遍构造函数,因此这里输出的结果为2

11. 第15行

15  new Foo().getName();

根据上一行代码的分析,这里new和点运算符的优先级一致,从左右到右计算,所以先运行new Foo(),再运行.getName

new Foo()通过Foo构造函数创建了一个实例对象,我们假设obj = new Foo(), 接着就是执行obj.getName()obj是一个空对象,没有任何属性,要找getName属性就从原型上找,它的原型就是构造函数Foo的原型,第6行在Foo的原型上设置过getName属性,就是它了!

obj.getName = function(){ console.log(3);};

所以输出结果为3

12. 第16行

16  new new Foo().getName();

要看懂这个表达式,要弄懂两点:

  • 首先,要明白一元运算符的关联顺序。当有多个一元运算符连接时,从右向左执行。new是一元运算符,对new new expression这样的表达式,先计算右边的new expression,然后将计算结果作为第一个new的表达式,相当于new (new expression)
  • 其次,new 运算符可以带括号也可以不带括号,带括号表示带参数列表,也就是传进构造函数设置对象属性的那些参数,如new Dog('pappy'),传入name,或者new Dog(), 预备带参数,只是没传;不带括号则是无参数列表,如new Dognameundefined
    function Dog(name) {
        this.name = name
    }
    

对于new new Foo().getName();,先计算右边表达式new Foo().getName,得出结果再作为第一个new的表达式,相当于new (new Foo().getName)()

这里容易产生疑问,为什么不是new (new Foo().getName())?试想一下,执行new Dog()这个语句,是先执行Dog(),然后将结果和new结合吗?不是。实际上这里是js new语法的解析,new Dog是一个表达式,()是执行这个表达式,()执行运算符的优先级最低。再想想,Dog.run(),是不是先求Dog.run,然后再执行这个函数,而不是先执行run(),再执行Dog.**? 实际上,new new Foo().getName()把括号去掉也可以,就是new new Foo().getName,变成无参数的new表达式。

那好了,最后表达式变成new (new Foo().getName)(), 在第15行代码中已经得知

new Foo().getName = function(){ console.log(3);}

为了方便阅读,假设这个函数叫myfunc,

myfunc = new Foo().getName = function(){ console.log(3);}

表达式变成new myfunc(),执行一次构造函数myfunc,创建一个实例,因此这里结果也是输出3

结果输出

function Foo(){
  getName = function(){ console.log(1);};
  return this;
}
Foo.getName = function(){ console.log(2);};
Foo.prototype.getName = function(){ console.log(3);};
var getName = function(){ console.log(4);}; // ovverride declared getName
function getName(){ console.log(5);}

Foo.getName(); // 2
getName(); // 4  => GO.getName()
Foo().getName(); // 1 => reassign GO.getName, GO.getName()
getName(); // 1 => GO.getName()
new Foo.getName(); // 2 =? new (Foo.getName)()
new Foo().getName(); // 3 => (new Foo()).getName()
new new Foo().getName(); // 3 => new (new Foo().getName)()

结语

写完这篇文章,我也学习了不少,最后一条语句其实我一开始也很迷糊,最后括号的问题琢磨了半天。查一查资料,思考思考,对构造函数和new表达的认识更深了。《认知·天性》里说,学习就是要时不时的进行测试,回想,思考,应用,这样的学习最牢固,记忆最永久。多做些面试题,当做测试;多写一写东西,当做思考,这些都是很有好处的。

虽然上面这样的代码工作中不多见,谁看到这样的代码都得摔显示器,但保不齐你遇到一些水平一般的同事呢?写出来一些令他自己都迷糊的代码,要你帮找问题,修bug,这时就是展现你真正的技术的时候了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值