你认为你了解 JavaScript 吗?ToPrimitive

2025-05-24

那么你认为你了解 JavaScript 吗?

原始

JavaScript是一门有趣的语言,我们都因为它的本质而热爱它。浏览器是 JavaScript 的家园,两者在我们服务中协同工作。JS 中
有一些概念人们往往轻视,有时甚至会弄混。像原型、闭包和事件循环这样的概念仍然是大多数 JS 开发人员绕道而行的晦涩领域之一。正如我们所知,“一知半解是件坏事”,它可能会导致犯错。

我们来玩一个小游戏吧。我会问你几个问题,你需要尝试全部回答。即使你不知道答案,或者答案超出你的知识范围,也可以猜一下。记下你的答案,然后对照下面的答案。每答对一个问题,给自己打1分。开始吧。

免责声明:我特意在以下部分代码片段中使用了“var”,以便您可以将其复制粘贴到浏览器控制台中,而不会遇到 SyntaxError 错误。
但我并不赞同使用“var”声明变量。使用“let”和“const”声明变量可以使您的代码更健壮,更不容易出错。


问题1:浏览器控制台上会打印什么?

var a = 10;
function foo() {
    console.log(a); // ??
    var a = 20;
}
foo();

问题 2:如果我们使用 let 或 const 代替 var,输出会相同吗?

var a = 10;
function foo() {
    console.log(a); // ??
    let a = 20;
}
foo();

问题 3:“newArray”中将包含哪些元素?

var array = [];
for(var i = 0; i <3; i++) {
 array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // ??

问题4:如果我们在浏览器控制台中运行'foo'函数,是否会导致堆栈溢出错误?

function foo() {
  setTimeout(foo, 0); // will there by any stack overflow error?
};

问题 5:如果我们在控制台中运行以下函数,页面(选项卡)的 UI 是否保持响应?

function foo() {
  return Promise.resolve().then(foo);
};

问题 6:我们能否以某种方式对以下语句使用扩展语法而不导致 TypeError?

var obj = { x: 1, y: 2, z: 3 };
[...obj]; // TypeError

问题 7:运行以下代码片段时,控制台上会打印什么?

var obj = { a: 1, b: 2 };
Object.setPrototypeOf(obj, {c: 3});
Object.defineProperty(obj, 'd', { value: 4, enumerable: false });

// what properties will be printed when we run the for-in loop?
for(let prop in obj) {
    console.log(prop);
}

问题 8:xGetter() 将打印什么值?


var x = 10;
var foo = {
  x: 90,
  getX: function() {
    return this.x;
  }
};
foo.getX(); // prints 90
var xGetter = foo.getX;
xGetter(); // prints ??

答案

现在,让我们尝试从上到下回答每个问题。我会在尝试揭开这些行为神秘面纱的同时,提供一些简要的解释和参考。

答案 1: undefined
解释:用 var 关键字声明的变量在 JavaScript 中会被提升,并在内存中被赋予undefined的值。但初始化发生在你在代码中输入它们的位置。此外, var 声明的变量是函数作用域的,而letconst是块作用域的。因此,整个过程如下:

var a = 10; // global scope
function foo() {
// Declaration of var a will be hoisted to the top of function.
// Something like: var a;

console.log(a); // prints undefined

// actual initialisation of value 20 only happens here
   var a = 20; // local scope
}

答案 2: ReferenceError:a 未定义
解释: letconst允许声明作用域受限于使用它的块、语句或表达式的变量。与var不同,这些变量不会被提升,并且具有所谓的暂时死区(TDZ)。尝试在TDZ中访问这些变量将引发ReferenceError,因为它们只能在执行到声明处之前访问。阅读更多关于JavaScript 中的词法作用域执行上下文和堆栈的内容

var a = 10; // global scope
function foo() { // enter new scope, TDZ starts

// Uninitialised binding for 'a' is created
    console.log(a); // ReferenceError

// TDZ ends, 'a' is initialised with value of 20 here only
    let a = 20;
}

下表概述了 JavaScript 中使用的不同关键字的提升行为和作用域(来源:Axel Rauschmayer的博客文章)。

Keyword Hoisting Scope Creates global properties
var Declaration Function Yes
let Temporal dead zone Block No
const Temporal dead zone Block No
function Complete Block Yes
class No Block No
import Complete Module-global No
view raw hoisting.csv hosted with ❤ by GitHub

答案 3: [3, 3, 3]
解释:在for 循环开头使用var关键字声明变量会为该变量创建单一绑定(存储空间)。阅读更多关于闭包的内容。让我们再看一下 for 循环。

// Misunderstanding scope:thinking that block-level scope exist here
var array = [];
for (var i = 0; i < 3; i++) {
  // Every 'i' in the bodies of the three arrow functions
  // referes to the same binding, which is why they all
  // return the same value of '3' at the end of the loop.
  array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [3, 3, 3]

如果您使用 let 声明一个具有块级范围的变量,则会为每次循环迭代创建一个新的绑定。

// Using ES6 block-scoped binding
var array = [];
for (let i = 0; i < 3; i++) {
  // This time, each 'i' refers to the binding of one specific iteration
  // and preserves the value that was current at that time.
  // Therefore, each arrow function returns a different value.
  array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]

解决这个问题的另一种方法是使用闭包

// After understanding static scoping and thus closures.
// Without static scoping, there's no concept of closures.
let array = [];
for (var i = 0; i < 3; i++) {
  // invoking the function to capture (closure) the variable's current value in the loop.
  array[i] = (function(x) {
    return function() {
      return x;
    };
  })(i);
}
const newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]

答案 4: 不是
解释:JavaScript 并发模型基于“事件循环”。当我说“浏览器是 JS 的家”时,我真正的意思是浏览器提供了运行时环境来执行我们的 JavaScript 代码。浏览器的主要组件包括调用堆栈、事件循环、任务队列Web API 。诸如setTimeoutsetIntervalPromise之类的全局函数不是 JavaScript 的一部分,而是 Web API 的一部分。JavaScript 环境的可视化表示如下所示:

替代文本

JS 调用栈遵循后进先出 (LIFO) 的原则。引擎每次从栈中取出一个函数,并按从上到下的顺序运行代码。每当遇到一些异步代码(例如setTimeout )时,它就会将其交给 Web API(箭头 1)。因此,每当触发事件时,回调都会被发送到任务队列(箭头 2)。

事件循环持续监视任务队列,并按照队列顺序逐个处理回调。每当调用堆栈为空时,事件循环就会拾取回调并将其放入堆栈(箭头 3)进行处理。请记住,如果调用堆栈不为空,事件循环将不会将任何回调推送到堆栈。

想要更详细地了解 JavaScript 中事件循环的工作原理,我强烈推荐 Philip Roberts 的这个视频。此外,你还可以通过这个很棒的工具来可视化和理解调用堆栈。现在就运行“foo”函数,看看会发生什么吧!

现在,有了这些知识,让我们尝试回答上述问题:

步骤

  1. 调用foo()会将foo函数放入调用堆栈
  2. 在处理里面的代码时,JS 引擎遇到了setTimeout
  3. 然后,它将foo回调交给WebAPI(箭头 1),并从函数返回。调用堆栈再次为空。
  4. 计时器设置为 0,因此 foo 将被发送到任务队列(箭头 2)。
  5. 由于我们的调用堆栈是空的,事件循环将选择foo回调并将其推送到调用堆栈进行处理。
  6. 该过程再次重复,并且堆栈永远不会溢出

答案 5:
解释:大多数时候,我看到开发人员假设事件循环图中只有一个任务队列。但事实并非如此。我们可以有多个任务队列。浏览器可以选择任何队列并处理其中的回调

从高层次上讲,JavaScript 中存在宏任务和微任务。setTimeout回调是宏任务,而Promise回调是微任务它们的主要区别在于执行方式。宏任务在单个循环中一次一个地被推入堆栈,但微任务队列在执行返回事件循环(包括任何额外排队的项目)之前始终会被清空。因此,如果您以处理项目的速度将项目添加到此队列,那么您将永远在处理微任务。如需更深入的解释,请观看Jake Archibald视频文章

在执行返回事件循环之前,微任务队列始终是空的

现在,当您在控制台中运行以下代码片段时:

function foo() {
  return Promise.resolve().then(foo);
};

每次调用“foo”都会在微任务队列中继续添加另一个“foo”回调,因此事件循环无法继续处理其他事件(滚动、点击等),直到该队列完全清空。结果,它会阻塞渲染。


答案 6: 是的,通过使对象可迭代
说明扩展语法for-of语句迭代可迭代对象定义的要迭代的数据。数组或 Map 是具有默认迭代行为的内置可迭代对象。对象不是可迭代对象,但你可以使用可迭代协议迭代器协议使它们可迭代。

在 Mozilla 文档中,如果一个对象实现了 @@iterator 方法,那么它就被称为可迭代的,这意味着该对象(或其原型链上的一个对象)必须具有一个带有 @@iterator 键的属性,该属性可通过常量 Symbol.iterator 获得。

上述语句可能看起来有点冗长,但下面的例子会更有意义:

var obj = { x: 1, y: 2, z: 3 };
obj[Symbol.iterator] = function() {
  // An iterator is an object which has a next method,
  // which also returns an object with atleast
  // one of two properties: value & done.

  // returning an iterator object
  return {
    next: function() {
      if (this._countDown === 3) {
        return { value: this._countDown, done: true };
      }
      this._countDown = this._countDown + 1;
      return { value: this._countDown, done: false };
    },
    _countDown: 0
  };
};
[...obj]; // will print [1, 2, 3]

您还可以使用生成器函数来自定义对象的迭代行为:

var obj = { x: 1, y: 2, z: 3 };
obj[Symbol.iterator] = function*() {
  yield 1;
  yield 2;
  yield 3;
};
[...obj]; // print [1, 2, 3]

答案 7: a、b、c
解释:for-in 循环遍历对象本身的可枚举属性以及该对象从其原型继承的属性。可枚举属性是指可以在 for-in 循环中包含并访问的属性。

var obj = { a: 1, b: 2 };
var descriptor = Object.getOwnPropertyDescriptor(obj, "a");
console.log(descriptor.enumerable); // true
console.log(descriptor);
// { value: 1, writable: true, enumerable: true, configurable: true }

现在你有了这些知识,应该很容易理解为什么我们的代码会打印这些特定的属性:


var obj = { a: 1, b: 2 }; // a, b are both enumerables properties

// setting {c: 3} as the prototype of 'obj', and as we know
// for-in loop also iterates over the properties obj inherits
// from its prototype, 'c' will also be visited.
Object.setPrototypeOf(obj, { c: 3 });

// we are defining one more property 'd' into our 'obj', but
// we are setting the 'enumerable' to false. It means 'd' will be ignored.
Object.defineProperty(obj, "d", { value: 4, enumerable: false });

for (let prop in obj) {
  console.log(prop);
}
// it will print
// a
// b
// c

答案 8:10 解释:当我们将x初始化到全局作用域时,它就变成了window对象的属性(假设是浏览器环境,而不是严格模式)。请看下面的代码:

var x = 10; // global scope
var foo = {
  x: 90,
  getX: function() {
    return this.x;
  }
};
foo.getX(); // prints 90
let xGetter = foo.getX;
xGetter(); // prints 10

我们可以断言:

window.x === 10; // true

this始终指向调用该方法的对象。因此,在 foo.getX() 的例子中,this指向foo对象,并返回值 90。而在xGetter()的例子中,this指向window对象,并返回值 10。

要检索foo.x的值,我们可以通过使用Function.prototype.bind将this的值绑定foo对象来创建一个新函数。

let getFooX = foo.getX.bind(foo);
getFooX(); // prints 90

就这样!如果你所有答案都正确,那就恭喜你!我们都是在犯错中学习的。关键在于了解错误背后的“原因”。了解你的工具,并更好地理解它们。如果你喜欢这篇文章,给我点几声❤️,我一定会很高兴😀。

你的分数是多少?

文章来源:https://dev.to/aman_singh/so-you-think-you-know-javascript-5c26
PREV
图数据结构简介
NEXT
如何使用 Hooks 编写出更简洁 90% 的代码