前言
最近正在找工作,对于面试中各种奇怪的问题真是深恶痛绝,大多面试的问题,在实践中毫无意义。面试的难度很高,做事的要求很低,“面试造火箭,上班拧螺丝”,可见一斑。
以前我很讨厌这样的技术面试,明明能力很不错,做事很踏实,却总拿不到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行是一个函数声明,一上来就遇到难题,似乎看不懂。
-
getName = function(){ console.log(1);};
,这是个赋值语句,给getName赋值。要给getName赋值,首先要找到这个变量,函数体内有没有声明getName这个变量呢?没有。没有就往上一层,到全局环境中找。根据之前预编译的结果,我们知道全局变量中有getName属性,找到了,给它赋值。因此,执行这条语句,实际就是给全局变量getName赋值。当然现在还是函数声明阶段,没有生效,等执行Foo()函数的时候才会执行这条语句。 -
return this
,this指针指向的是一个对象,它的值取决于运行环境。- 函数作为对象的方法调用,函数中的this指向对象
- 函数以普通方式调用(即我们常见的函数调用方式),函数中的this指向全局对象
- 函数被call、apply调用,this为call、apply的第一个参数
- 作为构造函数调用,this指向新创建的实例对象
- 匿名函数中的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/a | new … ( … ) | |
函数调用 | 从左到右 | … ( … ) | |
可选链(Optional chaining) | 从左到右 | ?. | |
18 | new (无参数列表) | 从右到左 | 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 Dog
,name
为undefined
。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,这时就是展现你真正的技术的时候了。