new和点运算符优先级怎么判?一道JavaScript面试题讲透语法解析
2026/9/12 11:52:58 网站建设 项目流程

面试过一个写了好几年业务代码的候选人,基础框架都聊得不错,Vue、React、Webpack、工程化都能接上话。结果我随口问了一道关于new运算符和点运算符优先级的八股面试题,他反而卡住了。题目本身不长,就是让他判断new Foo.getName()new Foo().getName()分别执行了什么,这是前端圈流传了很多年的经典优先级题。卡住的原因并不是他不会用 new,而是平时写业务代码根本不会这样写,一旦面对“这种写法到底怎么解析”的问题,就只能靠猜。

这道题的价值恰恰就在这里。它看起来像一道刁钻的八股,实际上考察的是JavaScript语法解析层面的基本功:运算符优先级决定表达式如何成树,语法树又决定运行时先做什么后做什么。理解它,不仅能应付面试,也能帮你在阅读源码、封装组件、写自定义指令时避开很多隐晦的语法坑。这篇文章我会从一道面试题出发,把 new 和点运算符的优先级关系彻底拆开,再带着你推演几个高频变体,最后给出一套可以直接用在面试现场的回答思路。不管你是刚学JS的初学者,还是工作多年的前端工程师,读完之后再遇到这类题都不会再靠蒙。

1. 从一个让不少人翻车的真实面试题说起

1.1 面试题原题与预期输出

先还原这道流传很广的题目。完整题目其实是这样的:

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); }; function getName() { console.log(5); } // 问下面这些分别输出什么 Foo.getName(); // ? getName(); // ? Foo().getName(); // ? getName(); // ? new Foo.getName(); // ? new Foo().getName(); // ? new new Foo().getName(); // ?

完整题目里混合了变量提升、this指向、原型链、运算符优先级四块内容,确实是一道非常典型的“全都要会”的题目。但如果你只看标题里的“new运算符和点运算符优先级”,真正需要重点处理的是最后三行,尤其是倒数第二行和倒数第三行。

如果你的视线只停留在“new 一个函数”这个直觉上,那么这道题大概率会答错。就算答对了,也有可能是因为以前背过答案,而不是真的理解为什么。所以我在面试里从来不会只问答案,我还会追问一句:你能用括号把这个表达式的等价形式写出来吗?这一问,基本就能筛掉大部分靠背题通过的候选人。

1.2 现场最常见的三种错误答案

我在面试中收集过很多错误答案,最典型的是下面三种:

错误理解错误输出错在哪
new Foo.getName()理解为(new Foo).getName()认为取到 Foo 实例上的 getName,或直接报错忽略了点运算符会先把Foo.getName绑成一个整体
new Foo().getName()理解为new (Foo().getName())认为先执行Foo(),再从返回值上取 getName 做构造调用分不清 new 后面到底“吞”到哪里为止
认为两者写法只是差个括号,结果一样两个都写成一样的输出没有意识到括号位置直接决定了构造函数是谁

先放结论:在不额外考虑变量提升干扰的情况下,new Foo.getName()等价于new (Foo.getName)(),也就是把Foo的静态方法getName当作构造函数来执行;new Foo().getName()等价于(new Foo()).getName(),也就是先构造出一个 Foo 实例,再访问实例上的getName。这两个表达式不仅执行顺序不同,最终关注的目标对象也完全不同。

可能有人会问:既然new Foo.getName()里面有(),new 难道不是“带参调用”吗?为什么优先级还要看点运算符?这就要说到 JavaScript 里 new 的语法边界问题了。别急,后面我会专门用一节来拆。

1.3 为什么八股题最能暴露基本功

很多候选人会抱怨:这种代码生产环境根本不会有人写,考它有什么意义?我倒是觉得,这类题目恰恰是区分“会用框架”和“懂语言”的试金石。

你平时使用 Vue 或 React,遇到的大部分问题都被框架封装好了,底层是对象创建、原型继承、成员访问这些基础机制在支撑。框架的源码里到处是new Xxx()Class.prototype.methodnew (条件 ? A : B)()这类表达式,如果你对运算符优先级没有确切认知,阅读源码时就会在语法解析层面卡壳,更别说做二次封装了。

八股题还有一个作用:它逼你把“我大概知道”变成“我确定知道”。运算符优先级不是什么高阶知识,它就是一张语言规范表,但很多人写了好几年代码,连点运算符和new的相对优先级都说不清楚。这种基础不牢靠,在实际项目里表现为“反正代码能跑就行”,一旦遇到诡异的报错或解析问题,排查思路就会非常低效。

2. 优先级表里的关键:new 其实有两种优先级

2.1 优先级表里 new 为什么是两行

在 JavaScript 的运算符优先级表里,new被分成了两行,这一点特别容易被忽略。我直接给你看一份简化过的常见优先级表:

优先级运算符结合性说明
19成员访问a.b/a[b]从左到右点、中括号都属于这类
19new Foo()(带参)从右到左的优先级记录,但这里关键不是结合性与成员访问同级
17new Foo(不带参)从右到左低于成员访问一级
17函数调用foo()从左到右函数调用也比成员访问低

注意第二行和第三行的区别:new Foo()带了参数列表括号,new Foo不带。ECMAScript 规范把这两种情况分成了不同的产生式,优先级也因此不同。很多人只记得“new 优先级很高”,却忘了高到哪一级、和谁同级,遇到具体表达式时就会开始混乱。

关键差异体现在new Foo.getName()这种写法里。如果 new 是不带参数的形式,那么点运算符优先级高于它,解析器会先读Foo.getName,再交给 new 去构造。但实际代码里new Foo.getName()的末尾是有()的,所以这其实是“new 带参数”的形式。那它为什么不是先new Foo再去.getName?这就引出了本节第二层理解:new 后面到底在“吞”什么。

2.2 点成员访问与 new 的“同级”关系

要理解new Foo().getName()为什么等价于(new Foo()).getName(),核心在于成员访问和带参 new 的优先级关系。

在规范里,new Foo()这种带参数形式产生的是MemberExpression,而Foo.getName这种成员访问产生的也是MemberExpression。两者处于同级,并且都要参与到“哪个表达式先生成”的语法结构判断里。当它们同时出现时,括号等价形式取决于语法结构:

  • new Foo().getName():new 后面接的是Foo()new Foo()本身已经是一个完整的MemberExpression,所以后面的.getName会附着在这个新表达式上,整体等价于(new Foo()).getName()
  • new Foo.getName():new 后面接的是成员表达式Foo.getName,由于点运算优先形成闭合的成员表达式,new 的操作数就是整个Foo.getName,所以等价于new (Foo.getName)()

你不需要去背规范里的生成式,只要记住一个核心结论:new的关键不是“优先级比点高还是低”,而是它后面的操作数边界在哪里。边界在成员表达式之后,点就先闭合;边界在 new 表达式之后,点就后访问。

2.3 一个不被绕晕的速记方法

根据我带人、面试和写业务代码的经验,我给你一套足够应付绝大多数场景的速记法:

  • 看到new Foo.bar这种没有在 Foo 后面立即加上()的写法,一律先读成Foo.bar,再让 new 去作用这个整体。
  • 看到new Foo().bar这种在Foo后面已经有()的写法,一律先执行new Foo(),再去访问.bar
  • 如果表达式里同时出现了多个点、多个 new,不要心算,先用括号手工划分等价形式。括号里的内容永远是“同一层”的执行单元。

这套速记方法不是从表格里背出来的,而是我从大量实际代码和面试题里总结出来的。它的本质是:优先判断 new 紧跟的是不是一个成员表达式,如果是,成员表达式先成立;如果不是,new 创建的实例就成了后续成员访问的基座。

3. 逐步推演:new Foo.getName() 到底做了什么

3.1 语法边界:new 后面的表达式吃到哪里

现在我们把new Foo.getName()当成一个语法问题来推演。先抛结论,它等价于:

new (Foo.getName)();

new Foo().getName()等价于:

(new Foo()).getName();

很多人会把第一行理解错,是因为潜意识里觉得new和它后面紧跟的Foo关系最近,但实际上 JavaScript 的语法解析器不是这么工作的。在new Foo.getName()中,Foo.getName是一个完整的成员访问表达式,它像一块磁铁一样,在 new 真正开始“吞”参数之前,就已经形成了独立的表达式节点。所以 new 只能把Foo.getName这个整体当作待构造操作数,外面的()是 new 的参数列表。

反过来看new Foo().getName(),在Foo后面紧跟着()new Foo()是一个完整的“new 表达式”,它本身可以充当后续成员访问的基座。因此解析器会先完成new Foo(),再在后面接入.getName。这就是语法解析的边界决定执行顺序的清晰例子。

3.2 规范里的新表达式与调用表达式

为了让你更扎实,我简单提一下 ECMAScript 规范里的几个概念。JavaScript 表达式不是靠优先级表一维排序决定的,真正的语法规则来自递归的生成式:

  • PrimaryExpression:最基础的一层,比如Foo1'a'this
  • MemberExpression:在基础表达式上通过点、中括号、带参 new 等运算符继续组合。
  • NewExpression:由new加上MemberExpression组成,表示构造调用(不带参)。
  • CallExpression:由函数调用()组成的表达式。

new Foo.getName()里,Foo.getName已经是MemberExpression,所以new (Foo.getName)()完全符合规范结构。而在new Foo().getName()里,new Foo()是一个MemberExpression,在这个基础上再通过.getName继续延伸,同样符合规范结构。

简单说,语法规范里的“成员表达式”是很能打的,它能不断向右延伸,也能在 new 后面担任操作数。理解这一点,比背诵优先级数字更能解释“为什么这样解析”。

3.3 可运行的验证代码:把每一步都打印出来

光说不练没有用。我建议你在 Node.js 环境或浏览器控制台里直接跑一下这段代码:

function Foo() {} Foo.getName = function () { console.log('Foo.getName called'); }; Foo.prototype.getName = function () { console.log('Foo.prototype.getName called'); }; new Foo.getName(); // 输出:Foo.getName called new Foo().getName(); // 输出:Foo.prototype.getName called

输出结果是:第一行调用的是Foo.getName,第二行调用的是原型上的getName。原因就是 3.1 里的等价转换:new Foo.getName()等价于new (Foo.getName)(),所以进入Foo.getName函数体;new Foo().getName()等价于(new Foo()).getName(),先创建 Foo 实例,再从实例上找到原型方法getName

如果你用console.log查看构造实例的类型,也能明显看出差异:

const a = new Foo.getName(); const b = new Foo().getName(); console.log(a instanceof Foo.getName); // true console.log(b instanceof Foo); // true console.log(b.getName === Foo.prototype.getName); // true

第一行的构造目标是Foo.getName,构造出来的实例属于Foo.getName的实例;第二行是先构造 Foo 实例,再去调用它的原型方法。两者完全不是一个东西。

3.4 容易被忽略的 this 绑定与返回值坑

理解了语法边界后,还有一个面试官很喜欢追问的点:当Foo.getName被当作构造函数调用时,函数内部的this指向哪里?

答案很明确:指向构造出来的新对象。在这个例子里,Foo.getName内部的this既不是 Foo 函数本身的静态对象,也不是全局对象,而是new (Foo.getName)创建出来的实例。

再往下挖,有两个边界情况值得注意:

第一,如果被 new 调用的函数显式返回了一个对象,那么这个对象会替代默认创建的新对象,成为整个 new 表达式的结果;如果返回的是原始值(数字、字符串、布尔等),返回值会被忽略,仍然使用默认新对象。很多人把这个规则背漏了,在函数里写return { ... }就会导致 new 结果发生变化。

第二,如果Foo.getName本身不存在,new Foo.getName()会直接抛TypeError: Foo.getName is not a constructor。注意看报错文案,它说的是“is not a constructor”,而不是“is not a function”,因为 new 要求操作数必须可构造。如果你写的是new Foo().getName(),而 Foo 实例上也没有 getName 方法,那么不会在取属性时报错,而是在后续调用报TypeError: ... is not a function。这两种报错位置完全不同,也是排查问题时的关键线索。

4. 一类题打一片:new 与点运算优先级的高频变体

4.1 变式一:new Foo().getName() 的完整拆解

理解了(new Foo()).getName()之后,变式一其实已经讲完了。但为了加深印象,我用一个带实例属性的例子来演示:

function Foo() { this.name = 'instance'; } const result1 = new Foo().name; console.log(result1); // instance

这里的执行顺序是:先执行new Foo()得到实例,再访问实例上的name属性,所以结果是instance

如果想做一个反面对比,可以这样写:

const result2 = new (Foo().name)();

这个表达式的语法是合法的,但执行时会先调用Foo()。在非严格模式下,Foo()里的this指向全局对象,所以在全局对象上挂了name属性;严格模式下Foo()里的 this 是 undefined,访问this.name会直接抛错。这个对比能非常直观地看出:括号放的位置不同,可能导致完全不同的运行结果,甚至直接报错。

4.2 变式二:构造函数显式返回对象的影响

如果把 4.1 里的 Foo 改成显式返回一个对象,整个结果又会变:

function Foo() { return { name: 'returned' }; } const result = new Foo().name; console.log(result); // returned

因为new Foo()的构造结果被返回对象覆盖了,所以new Foo().name等价于访问{ name: 'returned' }.name,得到returned

这个变体在面试里通常用来验证候选人是否真的知道 new 的返回值规则。它表面上是优先级题,实际上是在考“new 构造结果到底是什么”。如果候选人只能背出(new Foo()).getName()这种括号形式,却不知道返回值覆盖规则,依然会在这一步失分。

4.3 变式三:连续 new 与点运算的组合

原题最后一行的new new Foo().getName()是多数人最怕的一道。我来一步步拆:

new new Foo().getName();

先把整个表达式等价写成括号形式:

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

这个等价形式的意思是:

  1. 先执行new Foo(),得到一个 Foo 实例;
  2. 从实例上取getName属性,得到原型上的那个函数;
  3. 再把这个函数当作构造函数,用外层 new 去调用。

如果Foo.prototype.getName是一个普通函数,那么第三步会构造出一个新的对象,这个新对象的原型是Foo.prototype.getName.prototype。由于函数体内的代码会被执行,如果这个函数在内部打印了3,控制台会先输出3。整个过程和“先 new Foo 再访问方法”完全不同,它是一个先构造实例、再取方法、再二次构造的组合。

我给你一个可以实际验证的版本:

function Foo() {} Foo.prototype.getName = function () { console.log('prototype getName'); this.value = 42; }; const obj = new (new Foo().getName)(); console.log(obj.value); // 42 console.log(obj instanceof Foo.prototype.getName); // true

看到obj.value是 42,就能证明外层 new 确实是在调用Foo.prototype.getName,而不是 Foo 本身。

4.4 变式四:中括号和可选链的边界

点运算符换成中括号,优先级逻辑基本一致。比如:

new Foo['getName']();

等价于:

new (Foo['getName'])();

这仍然是把Foo上的静态方法getName当作构造目标。中括号与点运算符在这里没有本质区别,都属于 MemberExpression。

真正容易踩坑的是可选链?.。在 JavaScript 里,new Foo?.getName()这种写法会直接报语法错误,因为规范不允许 new 后面直接跟一个带可选链的表达式。如果你想用可选链,必须手动加括号:

new (Foo?.getName)();

这个括号的差别就是语法边界问题的最好体现。很多人在项目里为了防空,随手写出new target?.method(),结果发现语法解析直接挂掉,就是因为没有意识到可选链不能直接出现在 new 的操作数位置上。这类边界问题,优先级表里没有直观体现,只能靠对语法结构的理解来规避。

5. 面试时怎么答才算真正得分

5.1 一套可以直接照抄的回答话术

如果你面试时遇到这道题,我建议你用下面这个思路回答,既准确又显逻辑:

“我先把两个表达式做括号等价转换。new Foo.getName()等价于new (Foo.getName)(),因为成员访问Foo.getName是一个整体,new 把它当作构造目标;new Foo().getName()等价于(new Foo()).getName(),因为new Foo()先形成一个完整对象,点访问是在这个对象上进行的。所以前者的行为取决于 Foo 自身的静态 getName 属性,后者的行为取决于 Foo 实例上能访问到的 getName,通常是原型链上的方法。”

如果面试官追问“如果 Foo.getName 不存在会怎样”,你可以接着说:“那第一种写法会在 new 阶段报 TypeError,提示 is not a constructor;第二种写法在 new 阶段不会报错,但如果实例上拿不到 getName,会在继续调用时报 not a function。两者的报错位置不同,这也说明执行路径不同。”

这套话术的好处在于:你不需要去背“优先级19、18、17”这些数字,而是用“先形成整体”和“先构造实例”两个语义来描述,面试官一听就知道你是真的理解。

5.2 面试官真正想确认的三种能力

从我面试别人的经验来看,出这道题并不是为了刁难人,而是想确认三件事:

第一,你有没有语法敏感度。能不能一眼看出new Foo.getName()new Foo().getName()的括号等价形式,决定了你读源码时是不是只能依赖自动补全和调试器。

第二,你有没有边界意识。new 的构造返回值规则、可构造条件、报错位置,这些边界往往才是线上问题产生的地方。不知道边界,就只能在代码外圈打转。

第三,你有没有工程意识。真正专业的工程师不会在项目里堆这种含糊表达式,而是会明确加括号或拆分变量。如果你在回答完题目之后主动说一句“生产代码里我会写成new (Foo.getName)()或单独提取变量,避免可读性问题”,这通常在面试官心里是明显的加分项。

5.3 优先级只是语法,真正执行还要看求值顺序

最后给你一个进阶视角。运算符优先级决定的是表达式怎么解析成语法树,但语法树确定之后,子表达式的求值顺序还有它自己的一套规则。大多数情况下是从左到右,但也会受语法结构影响。

看这个例子:

let count = 0; function Foo() { count++; return { getName() { return count; } }; } const result = new Foo().getName(); console.log(result); // 1

new Foo().getName()的优先级解析结果我们已经很清楚了:先构造 Foo 实例,再访问实例上的getName,最后调用。当 Foo 作为构造函数执行时,count++已经发生,所以后续getName里的 count 是 1。这说明:优先级告诉了你语法树长什么样,求值顺序告诉了你副作用在什么时候发生。两者合在一起,才能真正预测一段表达式的运行结果。

面试时如果能把这段话讲出来,基本已经超出“会背题”的层面了。我自己最看重这种理解层次,因为它说明候选人不是把 JavaScript 当成一行行“魔法咒语”,而是当成一个有明确结构、有规范依据的语言在掌握。

在这个基础上,你再回头去看各种复杂的 new 表达式,就不会再逐字猜测了。先找语法边界,再判断执行顺序,最后检查函数返回值规则。三步走完,绝大多数所谓八股题都只是基本功的简单应用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询