ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

new和点运算符优先级怎么判?一道JavaScript面试题讲透语法解析

new和点运算符优先级怎么判?一道JavaScript面试题讲透语法解析 面试过一个写了好几年业务代码的候选人基础框架都聊得不错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.method、new (条件 ? 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最基础的一层比如Foo、1、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)();这个等价形式的意思是先执行new Foo()得到一个 Foo 实例从实例上取getName属性得到原型上的那个函数再把这个函数当作构造函数用外层 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); // 1new Foo().getName()的优先级解析结果我们已经很清楚了先构造 Foo 实例再访问实例上的getName最后调用。当 Foo 作为构造函数执行时count已经发生所以后续getName里的 count 是 1。这说明优先级告诉了你语法树长什么样求值顺序告诉了你副作用在什么时候发生。两者合在一起才能真正预测一段表达式的运行结果。面试时如果能把这段话讲出来基本已经超出“会背题”的层面了。我自己最看重这种理解层次因为它说明候选人不是把 JavaScript 当成一行行“魔法咒语”而是当成一个有明确结构、有规范依据的语言在掌握。在这个基础上你再回头去看各种复杂的 new 表达式就不会再逐字猜测了。先找语法边界再判断执行顺序最后检查函数返回值规则。三步走完绝大多数所谓八股题都只是基本功的简单应用。
返回列表