尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

一道JS面试题拆解:运算符优先级、new与this绑定

一道JS面试题拆解:运算符优先级、new与this绑定 前几天有个学弟给我发来一道 JavaScript 面试题说他挂在二面上。我扫了一眼代码不长就一个构造函数加几行调用但密密麻麻全是考点new 运算符、点运算符、变量提升、this 绑定、原型链一次全考了一遍。这种题在圈子里被叫“八股”可说实话能把八股讲透的人写业务代码时对 JavaScript 的理解通常也不差。这篇文章我就把这题完整拆一遍从运算符优先级表开始推到每一行代码的执行过程最后再聊聊这种题在生产代码里到底还有没有用。文章比较长建议先自己把答案写出来再往下对。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();一共七行调用。如果你在浏览器控制台里直接跑会得到这样一组输出2 4 1 1 2 3 3先别急着往后翻我建议你拿张纸或者直接在编辑器里写注释把这七行答案一个个推出来再对照后面的分析。因为这道题最大的难点不是某一个知识点不会而是多个知识点叠在一起时你很容易在第一步“运算符优先级分组”上就走偏后面全错。1.2 环境假设很重要非严格模式下的浏览器全局作用域分析这类题之前必须声明一个默认环境浏览器全局作用域、非严格模式。为什么非要强调因为代码里Foo函数内部有一句getName function () {}注意它前面没有var、没有let、没有const。在非严格模式下给一个未声明的变量赋值会隐式地创建全局变量或者覆盖已有的全局变量。在浏览器里就是window.getName ...。但在严格模式下这一行会直接抛ReferenceError导致后面所有结果全部失效。在 Node 的模块作用域里行为也不一样因为模块作用域不是全局作用域未声明赋值同样会抛错。所以这题的“标准答案”默认就是在浏览器控制台、非严格模式下得出来的。后面我会单独讲严格模式和 Node 环境下会发生什么变化。2. 题眼new 和点运算符到底谁先执行2.1 优先级表里的三行关键信息这道题最核心的题眼是new运算符和点运算符的优先级关系。很多人学运算符优先级时只记得“括号最高、赋值最低”中间一大段全是模糊地带。实际上 MDN 的运算符优先级表里有三个信息跟这道题直接相关。我挑关键几行整理一下优先级运算符结合性说明19new Foo()从左到右带参数列表的 new19foo.bar从左到右点运算符成员访问19foo()从左到右函数调用18new Foo从右到左不带参数列表的 new重点来了带参数列表的 new、点运算符、函数调用三者优先级相同都是 19而且是从左到右结合。不带参数列表的 new 优先级是 18比它们低一级。很多人记的是“new 优先级很高”这句话只对了一半。new Foo()确实和点运算符同级但new Foo没有括号比点运算符低。这一级之差就是这道题的灵魂。2.2 三行看似差不多的代码三种不同分组题里最容易被搞混的三行代码是new Foo.getName(); new Foo().getName(); new new Foo().getName();这三行代码在视觉上特别像就是括号位置不一样但解析方式完全不同。第一行new Foo.getName()由于点运算符优先级是 19无参 new 优先级是 18所以Foo.getName先结合然后再执行 new。它等价于new (Foo.getName)();也就是把Foo.getName这个函数当作构造函数来 new。第二行new Foo().getName()new Foo()是带参 new优先级是 19和点运算符同级从左到右结合所以先执行new Foo()再访问它的 getName。等价于(new Foo()).getName();第三行new new Foo().getName()这行最绕。它整体分成两层内层new Foo()先执行得到实例后取.getName然后外层再 new 一次。等价于new ((new Foo()).getName)();注意外层这个 new 是带参数列表的末尾那个()是 new 的参数列表不是内层 getName 的调用括号。这三行代码的差异本质就是“先 new 再取方法”还是“先取方法再 new”的差异。优先级表就决定了 JavaScript 引擎会往哪个方向解析。2.3 无参 new 和带参 new 为什么不是一回事很多教材讲 new 的时候都默认写new Foo()很少提new Foo这种不带括号的写法。实际上new Foo是合法的 JavaScript 语法它和new Foo()的差异不只影响优先级还影响整个表达式的解析方式。这里可以打个比方把new看成一个“制造机器的操作”括号里的内容相当于“启动参数”。new Foo相当于只拿着机器的图纸没有立即带参数启动而new Foo()是图纸加参数一起给整个动作是完整的。JavaScript 语法里优先级更高的“点运算符”有资格先和对象绑定所以new Foo.getName会先读Foo.getName这个属性再去 new 这个属性值。我面试过不少候选人很多人能记住new Foo()和new Foo优先级不同但一看到new Foo.getName()这种组合就懵了因为脑子里只记得“new 的优先级很高”忘了括号是区分级别的一个关键信号。实战中遇到这类表达式最稳妥的办法就是拿优先级表对照别靠感觉猜。3. 前置知识变量提升、this 绑定与 new 的底层过程3.1 函数提升和 var 提升同名时谁赢题目代码里有一段很经典的“同名覆盖”var getName function () { console.log(4); }; function getName() { console.log(5); }很多人的第一反应是两个都叫 getName那后面的覆盖前面的所以最终应该是打印 4 的那个函数因为var getName function...这行在代码里排在function getName()下面。这个答案对了一半。最终结果确实是打印 4 的函数但原因不是简单的“后面的覆盖前面的”而是 JavaScript 引擎在编译阶段就把函数声明提升到了作用域顶部然后再执行 var 赋值。具体过程是这样的。JavaScript 在执行代码之前会先做变量环境和函数环境的初始化。函数声明function getName() {}会整体提升并且在提升阶段就完成绑定var getName的声明也会提升但赋值不会提升赋值留在原来的位置。当提升结束后作用域里已经存在一个getName它指向打印 5 的那个函数。然后代码开始逐行执行执行到var getName function () { console.log(4); };这一行时才把打印 4 的函数赋值给getName覆盖掉之前的打印 5 函数。所以后面的getName()输出的是 4而不是 5。这里的关键是函数声明和 var 声明同名时var 声明不会覆盖函数声明但 var 的赋值语句会覆盖。如果面试官换个写法把 var 赋值放在函数声明之前结果也一样因为提升阶段和赋值阶段是分开的。3.2 全局对象上的隐式赋值一个容易被忽略的“状态修改”再看Foo函数内部function Foo() { getName function () { console.log(1); }; return this; }这一行getName function () {}没有声明关键字在浏览器非严格模式下它操作的其实是全局变量等价于window.getName function () {}。这就会导致一个现象每次调用Foo()函数全局的getName都会被替换成打印 1 的函数。这是很多人推这题时最容易漏掉的一步。因为人会习惯性地认为“函数内部修改的变量是局部变量”但这里没有var所以不是局部变量而是全局变量的覆盖。而且Foo函数在题里被调用了好几次每一次调用都会重新执行这一行把全局getName重置成打印 1 的函数。3.3 this 的绑定规则这题考到了两种JavaScript 的 this 绑定规则主要有四种默认绑定、隐式绑定、显式绑定、new 绑定。这道题考到了其中三种。普通函数调用getName()时this 指向全局对象非严格模式下这叫默认绑定。Foo().getName()里Foo()的 this 也是全局对象所以return this返回的是全局对象然后全局对象再调用 getName这里既有默认绑定也有隐式绑定。new Foo()时this 指向新建的对象这叫 new 绑定。最容易被绕晕的是Foo().getName()这行。Foo()作为普通函数调用内部 this 是全局对象return this返回全局对象所以Foo()的结果是全局对象。紧接着.getName()访问的是全局对象上的 getName而全局 getName 刚被Foo()内部那句无 var 赋值改成了打印 1 的函数。这一行输出的 1实际上是“全局状态被函数副作用修改”的连锁反应。3.4 new 内部到底做了什么四步走new 运算符的执行过程标准做法可以拆成四步第一步创建一个全新的空对象。第二步把这个空对象的原型指向构造函数的 prototype 属性也就是执行对象.__proto__ 构造函数.prototype。第三步把构造函数内部的 this 绑定到新对象上并执行构造函数。第四步如果构造函数显式返回了一个对象那么 new 的结果是那个对象否则返回第一步创建的新对象。这个四步模型能解释很多事情。比如new Foo().getName()里new Foo()返回的是新对象因为Foo函数内return this中的 this 在 new 调用时指向新对象而新对象不是显式返回的对象所以 new 表达式的结果是这个新对象。尽管Foo函数体里写了return this这个 this 已经被 new 改变了指向。再比如new Foo.getName()里Foo.getName被当作构造函数新对象的原型指向Foo.getName.prototype而不是Foo.prototype。这两个原型链完全不同所以即使Foo.getName函数体里写了this.xxx设置的属性也会落在新对象上而不是 Foo 的其他实例上。4. 七行输出逐行推演4.1 前两行静态方法和全局函数第一行Foo.getName()。Foo.getName是定义在构造函数 Foo 上的静态方法直接调用它this 指向 Foo函数体打印 2。这一行没有悬念输出 2。第二行getName()。这里要看当前全局的 getName 指向谁。经过变量提升和代码执行全局 getName 已经被var getName function () { console.log(4); };覆盖成了打印 4 的函数。所以直接调用输出 4。有个容易踩的坑是有人会把函数声明function getName() { console.log(5); }当成了最终状态认为输出 5。但只要记住“提升归提升赋值归赋值”就不会错。函数声明先被提升到作用域顶部但它只是占了个位置后面 var 赋值的函数会把它替换掉。4.2 第三四行一次普通函数调用改变了全局状态第三行Foo().getName()。拆开看Foo()是一个普通函数调用this 指向全局对象。进入 Foo 内部执行getName function () { console.log(1); };把全局 getName 改成打印 1 的函数。然后执行return this返回全局对象。关键在于回到外部后.getName()访问的是全局对象的 getName 属性。而全局对象的 getName 刚被改成了打印 1 的函数。所以第三行输出 1。第四行getName()。此时全局 getName 已经被第三行的副作用覆盖成打印 1 的函数所以这一行也输出 1。这里有一个很反直觉的点第三行明明是在调用Foo().getName()看起来像是在访问 Foo 返回的对象的 getName。但由于普通函数调用中 this 是全局对象Foo 返回的其实就是全局对象所以最终调用的还是全局对象上的全局函数。如果return this返回的是一个普通对象那结果就不一样了。4.3 后三行new 和点运算符优先级真正发力的地方第五行new Foo.getName()。按优先级分组等价于new (Foo.getName)()。Foo.getName是定义在 Foo 上的静态方法打印 2。new 它的时候函数体执行一次输出 2this 指向新建对象但函数体不依赖 this所以结果还是 2。这一行的干扰项是有人以为它等价于(new Foo()).getName()然后推出输出 3。这种理解错在把无参 new 的优先级误判为高于点运算符。实际优先级表告诉我们点运算符先结合new 后作用。第六行new Foo().getName()。按优先级分组等价于(new Foo()).getName()。先new Foo()新对象的原型指向Foo.prototype然后访问新对象上的 getName。新对象本身没有 getName 属性所以从原型链上找到Foo.prototype.getName也就是打印 3 的那个函数。因此输出 3。需要提醒的是new Foo()的过程中Foo 内部还会执行getName function () { console.log(1); };把全局 getName 再次覆盖成打印 1 的函数。这一步虽然不影响第六行的输出但它会影响全局状态只是此时后面不再有依赖全局 getName 的调用了。第七行new new Foo().getName()。这行是最容易让人眼前一黑的。按优先级和语法规则拆等价于new ((new Foo()).getName)()。先new Foo()得到实例然后取实例的.getName从原型链上拿到打印 3 的函数。最后外层 new 这个函数函数体执行一次输出 3。有个细节要说明这行代码的末尾只有一个括号它实际上是外层 new 的参数列表而不是内层 getName 的调用括号。所以内层(new Foo()).getName只是取到函数本身没有立即调用外层 new 才会触发函数执行。4.4 最终输出对照表把上面七行的推演串起来最终答案就是调用表达式等价分组输出Foo.getName()Foo.getName()2getName()getName()4Foo().getName()(全局对象).getName()1getName()getName()1new Foo.getName()new (Foo.getName)()2new Foo().getName()(new Foo()).getName()3new new Foo().getName()new ((new Foo()).getName)()3如果你第一遍做的时候第三行和第七行错了那太正常了。这两行几乎集中了这道题全部的心机。5. 从题目到生产优先级问题在真实代码中的样子5.1 常见写法里的优先级陷阱有人会说这种题就是纯八股实际写业务代码谁这么干这话我只能同意一半。确实是没人会故意写new new Foo().getName()但类似的优先级问题在真实代码里很容易出现。比如最常见的new Date().getTime()。因为new Date()是带参 new优先级和点运算符同级从左到右结合所以它等价于(new Date()).getTime()这没问题。但如果你不小心写成new Date.getTime()那就变成new (Date.getTime)()了而Date.getTime多半不是构造函数直接报错。这类问题在代码 review 里经常出现。还有一个经典场景是 Promise 和 new 混用new Promise((resolve) resolve()).then(...)如果少写一个括号变成new Promise().then(...)和new Promise.then(...)解析结果完全不同。前者是 new 一个 Promise 实例再调用 then后者是想把Promise.then当作构造函数几乎必然报错。所以运算符优先级不只是在面试题里刷存在感它直接决定你写出的代码会不会在运行时跟你的直觉对着干。我在实际开发中养成了一个习惯只要一个表达式里同时出现 new 和点运算符我一定加括号把意图写清楚哪怕只是多两个括号也能避免后来维护的人包括三个月后的自己读代码时产生误解。5.2 面试官真正想考察的能力这道题看着是考优先级其实面试官真正想看的是几件事。第一你清不清楚 JavaScript 代码的执行顺序。变量提升发生在编译阶段赋值发生在执行阶段这俩不是一回事。很多人写代码多年遇到“为什么这个变量是 undefined”还要靠 console.log 一点点试就是因为对执行顺序没有系统性的理解。第二你清不清楚 this 的绑定规则。这道题里Foo()的 this 是全局对象new Foo()的 this 是新对象foo.getName()的 this 是调用对象。一个函数因为调用方式不同this 就不同这是 JavaScript 最核心的机制之一。第三你清不清楚原型链的查找路径。new Foo().getName()能找到方法靠的是实例 → 原型 → 原型的原型这条链。如果对原型链不熟你会想不通为什么实例上明明没有定义 getName却能调用成功。第四也是容易被忽略的一点你有没有读规范、查资料的习惯。运算符优先级这种东西聪明人不靠背诵靠查表。面试时如果候选人能准确说出“无参 new 优先级比点运算符低一级”并且告诉我这是在 MDN 优先级表里看到的我会觉得这个人是有方法论的。5.3 快速拆题方法论遇到这种“多知识点叠加”的题我建议按这个顺序拆能避免 90% 的误判。第一步先看优先级。把所有表达式按运算符优先级分组用括号明确写出真实的计算顺序。不要跳步不要凭感觉。第二步再看作用域和提升。把函数声明、var 声明、let/const 声明分别标注出来确认每一时刻某个变量名指向哪个函数或值。这一步要特别留意未声明赋值它会影响全局状态。第三步确定 this。这一步要结合调用方式来推断是普通函数调用、方法调用、new 调用还是 call/apply 显式调用。每一种调用方式的 this 绑定规则都不同。第四步沿着原型链或作用域链追踪目标函数。比如new Foo().getName()要去 Foo.prototype 上找 getName而不是在实例上找。把这四步走完这类题目基本不会有意外。尤其是第一步优先级一旦分错后面每一步都是在错误的地基上盖楼最后只能得到一套自洽但错误的答案。6. 常见问题与避坑记录6.1 为什么new Foo.getName()有时会报 not a constructor有读者可能会遇到这种情况在某些变体题目里new Foo.getName()会直接报Foo.getName is not a constructor。原因很简单new的操作数必须是一个构造函数。如果Foo.getName是一个普通对象、箭头函数、类字段class field等不具备构造能力的东西new 它就会报错。在原题里Foo.getName是一个普通函数所以能成功 new。如果把静态方法改成箭头函数Foo.getName () { console.log(2); };再执行new Foo.getName()就会报Foo.getName is not a constructor。因为箭头函数没有自己的 constructor 行为不能当构造函数用。这个知识在实际开发里也有用。有些库会导出工厂函数如果你用new去调用一个工厂函数结果往往出乎意料。特别是当函数内部 return 了一个对象时new会把那个对象当作结果返回和普通调用的返回值可能一致也可能不一致具体要看 return 的是什么。6.2 严格模式下结果为什么变了如果把整段代码放在use strict;后面执行情况会大不一样。严格模式下普通函数调用中的 this 是 undefined而不是全局对象。所以Foo()内部的return this返回的是 undefined接着.getName()会抛TypeError: Cannot read properties of undefined (reading getName)。另外严格模式下给未声明的变量赋值会抛ReferenceError所以Foo内部那句getName function () {}在严格模式下走到一行就会炸。整个题后面几行根本执行不到。这就是为什么我前面强调这道题的标准答案基于非严格模式、浏览器全局作用域。如果你在做题先确认面试官问的是哪种环境再开始推。不同环境下同一个表达式的行为完全不同这在 JavaScript 里非常常见。6.3 在 Node 环境或模块里还能这么考吗Node 环境下要分两种情况。如果是在 CommonJS 模块里每个文件都有自己的模块作用域var getName不会挂到 global 上getName function () {}也会因为隐式全局赋值在模块作用域中报错。如果是在 Node 的 REPL 交互环境里行为和浏览器全局作用域比较接近但细微差异仍然存在。如果代码在 ES Module 里情况更特殊。ES Module 默认就是严格模式所以前面说的严格模式问题会全部出现。也就是说这道原题如果放到import/export的环境里根本跑不通。所以面试中如果遇到这题你可以反问面试官题目预期的运行环境是什么是非严格模式的浏览器全局作用域还是 Node 模块这一问本身就是一个加分点说明你对环境差异有敏感度。6.4 一个记忆口诀帮你快速判断优先级很多同学记不住无参 new 和点运算符的优先级关系我这里提供一个口诀点比 new 先括号能翻盘。“点比 new 先”指的是在new Foo.getName()这种组合中点运算符的优先级比无参 new 高JavaScript 引擎会先解析Foo.getName再执行 new。“括号能翻盘”指的是一旦写成(new Foo()).getName()带参 new 通过括号和点运算符同级就可以按照从左到右的顺序先创建实例再取方法。这个口诀不能覆盖所有情况但对付 80% 的 JavaScript 优先级问题够用了。剩下的情况老实查 MDN 优先级表比背任何口诀都靠谱。我在实际工作中也一直这样做的谁都会偶尔拿不准某个运算符的优先级查表不丢人瞎猜才危险。最后再分享一个小建议遇到这种综合性的面试题别急着背答案试着把每道题当成一次小型的代码执行模拟。自己动手在控制台跑一遍再对照理论分析找到自己认知里的偏差。这个过程比刷十道题都管用。我对 JavaScript 的很多底层理解就是靠这种“先推演、再验证、再修偏”的方式建立起来的。
返回列表