JavaScript 面试问题

190+ JavaScript 问答,采用测验形式,由前 FAANG 面试官解答
前面试官提供的问答和解答
涵盖关键主题

Tired of scrolling through low-quality JavaScript interview questions? You’ve found the right place!

Our JavaScript interview questions are crafted by experienced ex-FAANG senior / staff engineers, not random unverified sources or AI.

With over 190+ questions covering everything from core JavaScript concepts to advanced JavaScript features (async / await, promises, etc.), you’ll be fully prepared.

Each quiz question comes with:

  • Concise answers (TL;DR): Clear and to-the-point solutions to help you respond confidently during interviews.
  • Comprehensive explanations: In-depth insights to ensure you fully understand the concepts and can elaborate when required. Don’t waste time elsewhere—start practicing with the best!
如果您正在寻找 JavaScript 编码问题 -我们也会为您提供:
Javascript 编码
  • 280+ JavaScript 编码问题
  • 类似于真实面试环境的基于浏览器的编码工作区
  • 来自 Big Tech 前面试官的参考解决方案
  • 自动化测试用例
  • 立即预览您的 UI 问题代码
开始使用
加入 50,000+ 工程师

解释 JavaScript 中的“hoisting”(变量提升)概念

主题
JavaScript

TL;DR

Hoisting(变量提升)是一种 JavaScript 机制,在编译阶段,变量和函数声明会被“提升”到其包含作用域的顶部。

  • 变量声明 (var):声明被提升,但未初始化。如果在使用前访问变量,则变量的值为 undefined
  • 变量声明 (letconst):声明被提升,但未初始化。访问它们会导致 ReferenceError,直到遇到实际的声明。
  • 函数表达式 (var):声明被提升,但未初始化。如果在使用前访问变量,则变量的值为 undefined
  • 函数声明 (function):声明和定义都被完全提升。
  • 类声明 (class):声明被提升,但未初始化。访问它们会导致 ReferenceError,直到遇到实际的声明。
  • 导入声明 (import):声明被提升,并且在执行其余代码之前,会执行导入模块的副作用。

以下行为总结了在声明变量之前访问变量的结果。

声明在声明之前访问
var fooundefined
let fooReferenceError
const fooReferenceError
class FooReferenceError
var foo = function() { ... }undefined
function foo() { ... }正常
import正常

Hoisting(变量提升)

Hoisting(变量提升)是一个术语,用于解释 JavaScript 代码中变量声明的行为。

使用 var 关键字声明或初始化的变量将在编译期间将其声明“移动”到其包含作用域的顶部,我们将其称为变量提升。

只有声明被提升,初始化/赋值(如果存在)将保留在原来的位置。请注意,声明实际上并没有移动——JavaScript 引擎在编译期间会解析声明,并了解变量及其作用域,但是通过将声明可视化为“提升”到其作用域的顶部,更容易理解这种行为。

让我们用几个代码示例来解释。请注意,这些示例的代码应该在模块作用域内执行,而不是像浏览器控制台那样逐行输入到 REPL 中。

使用 var 声明的变量的变量提升

在这里可以看到变量提升的实际效果,即使在第一个 console.log() 之后才声明和初始化 foo,第一个 console.log() 也会将 foo 的值打印为 undefined

console.log(foo); // undefined
var foo = 1;
console.log(foo); // 1

您可以将代码可视化为:

var foo;
console.log(foo); // undefined
foo = 1;
console.log(foo); // 1

使用 letconstclass 声明的变量的变量提升

通过 letconstclass 声明的变量也会被提升。但是,与 varfunction 不同,它们没有被初始化,在声明之前访问它们将导致 ReferenceError 异常。该变量处于一个“暂时性死区”,从块的开始到声明被处理。

y; // ReferenceError: Cannot access 'y' before initialization
let y = 'local';
z; // ReferenceError: Cannot access 'z' before initialization
const z = 'local';
Foo; // ReferenceError: Cannot access 'Foo' before initialization
class Foo {
constructor() {}
}

函数表达式的提升

函数表达式是以变量声明的形式编写的函数。由于它们也是使用 var 声明的,因此只有变量声明被提升。

console.log(bar); // undefined
bar(); // Uncaught TypeError: bar is not a function
var bar = function () {
console.log('BARRRR');
};

函数声明的提升

函数声明使用 function 关键字。与函数表达式不同,函数声明同时提升声明和定义,因此即使在声明之前也可以调用它们。

console.log(foo); // [Function: foo]
foo(); // 'FOOOOO'
function foo() {
console.log('FOOOOO');
}

这同样适用于生成器 (function*)、异步函数 (async function) 和异步函数生成器 (async function*)。

import 语句的提升

Import 声明被提升。import 引入的标识符在整个模块范围内都可用,并且它们产生的副作用在模块的其余代码运行之前产生。

foo.doSomething(); // Works normally.
import foo from './modules/foo';

幕后

实际上,JavaScript 在尝试执行代码之前,会在当前作用域中创建所有变量。使用 var 关键字创建的变量的值将为 undefined,而使用 letconst 关键字创建的变量将被标记为 <value unavailable>。因此,在初始化之前访问它们将导致 ReferenceError,从而阻止您在初始化之前访问它们。

在 ECMAScript 规范中,letconst 声明解释如下

当它们包含的 Environment Record 被实例化时,变量被创建,但在变量的 LexicalBinding 被求值之前,可能无法以任何方式访问它们。

但是,对于 var 关键字,此语句略有不同

当它们包含的 Environment Record 被实例化时,Var 变量被创建,并在创建时被初始化为 undefined

现代实践

在实践中,现代代码库避免使用 var,并且仅使用 letconst。建议在包含范围/模块的顶部声明和初始化变量和 import 语句,以消除跟踪何时可以使用变量的心理负担。

ESLint 是一个静态代码分析器,可以发现违反此类情况,使用以下规则:

  • no-use-before-define: 当遇到对尚未声明的标识符的引用时,此规则将发出警告。
  • no-undef: 当遇到对尚未声明的标识符的引用时,此规则将发出警告。

延伸阅读

JavaScript 中使用 `let`、`var` 或 `const` 创建的变量有什么区别?

主题
JavaScript

TL;DR

在 JavaScript 中,letvarconst 都是用于声明变量的关键字,但它们在作用域、初始化规则、是否可以重新声明或重新赋值以及在声明前访问时的行为方面存在显着差异:

行为varletconst
作用域函数或全局
初始化可选可选必需
重新声明
重新赋值
声明前访问undefinedReferenceErrorReferenceError

行为差异

让我们看看 varletconst 之间的行为差异。

作用域

使用 var 关键字声明的变量的作用域限定于创建它们的函数,或者如果创建在任何函数之外,则限定于全局对象。letconst块作用域,这意味着它们只能在最近的一组花括号(函数、if-else 块或 for 循环)内访问。

function foo() {
// All variables are accessible within functions.
var bar = 1;
let baz = 2;
const qux = 3;
console.log(bar); // 1
console.log(baz); // 2
console.log(qux); // 3
}
foo(); // Prints each variable successfully
console.log(bar); // ReferenceError: bar is not defined
console.log(baz); // ReferenceError: baz is not defined
console.log(qux); // ReferenceError: qux is not defined

在下面的示例中,bar 可以在 if 块之外访问,但 bazquz 则不能。

if (true) {
var bar = 1;
let baz = 2;
const qux = 3;
}
// var variables are accessible anywhere in the function scope.
console.log(bar); // 1
// let and const variables are not accessible outside of the block they were defined in.
console.log(baz); // ReferenceError: baz is not defined
console.log(qux); // ReferenceError: qux is not defined

初始化

varlet 变量可以在没有值的情况下初始化,但 const 声明必须初始化。

var foo; // Ok
let bar; // Ok
const baz; // SyntaxError: Missing initializer in const declaration

重新声明

使用 var 重新声明变量不会抛出错误,但 letconst 会。

var foo = 1;
var foo = 2; // Ok
console.log(foo); // Should print 2, but SyntaxError from baz prevents the code executing.
let baz = 3;
let baz = 4; // Uncaught SyntaxError: Identifier 'baz' has already been declared

重新赋值

letconst 的区别在于,varlet 允许重新分配变量的值,而 const 不允许。

var foo = 1;
foo = 2; // This is fine.
let bar = 3;
bar = 4; // This is fine.
const baz = 5;
baz = 6; // Uncaught TypeError: Assignment to constant variable.

声明前访问

varletconst 声明的变量都会被提升。var 声明的变量会自动初始化为 undefined 值。但是,letconst 变量不会被初始化,在声明之前访问它们将导致 ReferenceError 异常,因为它们处于从块的开始到声明被处理的“暂时性死区”。

console.log(foo); // undefined
var foo = 'foo';
console.log(baz); // ReferenceError: Cannot access 'baz' before initialization
let baz = 'baz';
console.log(bar); // ReferenceError: Cannot access 'baz' before initialization
const bar = 'bar';

笔记

  • 在现代 JavaScript 中,通常建议默认对不需要重新赋值的变量使用 const。这可以提高不变性并防止意外更改。
  • 当您需要在其范围内重新分配变量时,请使用 let
  • 避免使用 var,因为它可能存在作用域问题和提升行为。
  • 如果您需要针对旧版浏览器,请使用 let/const 编写代码,并使用 Babel 等转译器将您的代码编译为旧语法。

延伸阅读

JavaScript 中 `==` 和 `===` 的区别是什么?

主题
JavaScript

TL;DR

== 是抽象相等运算符,而 === 是严格相等运算符。== 运算符会在进行任何必要的类型转换后比较是否相等。=== 运算符不会进行类型转换,因此如果两个值不是同一类型,=== 将直接返回 false

运算符=====
名称(宽松)相等运算符严格相等运算符
类型转换
比较值和类型

相等运算符 (==)

== 运算符检查两个值是否相等,但如果这些值的类型不同,则会执行类型转换。这意味着 JavaScript 将尝试在进行比较之前将这些值转换为通用类型。

console.log(42 == '42'); // true
console.log(0 == false); // true
console.log(null == undefined); // true
console.log([] == false); // true
console.log('' == false); // true

在这些示例中,JavaScript 在进行比较之前将操作数转换为相同的类型。例如,42 == '42' 为 true,因为字符串 '42' 在比较之前被转换为数字 42

但是,当使用 == 时,可能会发生不直观的结果:

console.log(1 == [1]); // true
console.log(0 == ''); // true
console.log(0 == '0'); // true
console.log('' == '0'); // false

作为一般经验法则,永远不要使用 == 运算符,除非为了方便与 nullundefined 进行比较,其中 a == null 将在 anullundefined 时返回 true

var a = null;
console.log(a == null); // true
console.log(a == undefined); // true

严格相等运算符 (===)

=== 运算符,也称为严格相等运算符,检查两个值是否相等,而不执行类型转换。这意味着,只有当值和类型都相同时,比较才会返回 true。

console.log(42 === '42'); // false
console.log(0 === false); // false
console.log(null === undefined); // false
console.log([] === false); // false
console.log('' === false); // false

对于这些比较,不执行类型转换,因此如果类型不同,该语句将返回 false。例如,42 === '42'false,因为类型(数字和字符串)不同。

// 带有类型转换的比较 (==)
console.log(42 == '42'); // true
console.log(0 == false); // true
console.log(null == undefined); // true
// 不带类型转换的严格比较 (===)
console.log(42 === '42'); // false
console.log(0 === false); // false
console.log(null === undefined); // false

额外内容:Object.is()

JavaScript 中最后一个值比较操作是 Object.is() 静态方法。Object.is()=== 之间唯一的区别在于它们如何处理带符号的零和 NaN 值。=== 运算符(和 == 运算符)将数字值 -0+0 视为相等,但将 NaN 视为彼此不相等。

结论

  • 当您希望通过类型转换比较值(并了解其含义)时,请使用==。 实际上,相等运算符的唯一合理用例是在单个比较中同时检查nullundefined以方便使用。
  • 当您希望确保值和类型都相同时,请使用===,这在大多数情况下是更安全、更可预测的选择。

笔记

  • 建议使用 ===(严格相等)以避免类型强制转换的缺陷,这可能导致代码中出现意外行为和错误。它使您的比较意图更清晰,并确保您同时比较值和类型。
  • ESLint 的 eqeqeq 规则强制使用严格相等运算符 ===!==,甚至提供了一个选项,始终强制使用严格相等,除非与 null 字面量进行比较。

延伸阅读

JavaScript 运行时中的事件循环是什么?

call stack 和 task queue 之间有什么区别?
主题
JavaScript

TL;DR

事件循环是JavaScript运行时环境中的一个概念,涉及异步操作如何在JavaScript引擎中执行。它的工作原理如下:

  1. JavaScript 引擎开始执行脚本,将同步操作放在调用堆栈上。
  2. 当遇到异步操作(例如,setTimeout()、HTTP 请求)时,它会被卸载到相应的 Web API 或 Node.js API,以在后台处理该操作。
  3. 一旦异步操作完成,其回调函数将被放置在相应的队列中——任务队列(也称为宏任务队列/回调队列)或微任务队列。从这里开始,我们将“任务队列”称为“宏任务队列”,以便更好地与微任务队列区分开来。
  4. 事件循环持续监视调用堆栈并执行调用堆栈上的项目。如果/当调用堆栈为空时:
    1. 处理微任务队列。微任务包括 promise 回调(thencatchfinally)、MutationObserver 回调和对 queueMicrotask() 的调用。事件循环从微任务队列中获取第一个回调并将其推送到调用堆栈以供执行。重复此操作,直到微任务队列为空。
    2. 处理宏任务队列。宏任务包括 Web API,如 setTimeout()、HTTP 请求、用户界面事件处理程序,如点击、滚动等。事件循环从宏任务队列中出列第一个回调并将其推送到调用堆栈以供执行。但是,在处理完宏任务队列回调后,事件循环不会继续处理下一个宏任务!事件循环首先检查微任务队列。检查微任务队列是必要的,因为微任务的优先级高于宏任务队列回调。刚刚执行的宏任务队列回调可能添加了更多微任务!
      1. 如果微任务队列非空,则按上一步处理它们。
      2. 如果微任务队列为空,则处理下一个宏任务队列回调。重复此操作,直到宏任务队列为空。
  5. 此过程无限期地持续进行,允许 JavaScript 引擎高效地处理同步和异步操作,而不会阻塞调用堆栈。

不幸的是,仅使用文本很难很好地解释事件循环。我们建议查看以下优秀的视频之一,解释事件循环:

我们建议观看 Lydia 的视频,因为它是最现代和简洁的解释,时长仅为 13 分钟,而其他视频至少需要 30 分钟。她的视频足以满足面试的目的。


JavaScript 中的事件循环

事件循环是 JavaScript 异步操作的核心。它是一种处理代码执行的机制,允许异步操作并确保 JavaScript 引擎的单线程性质不会阻塞程序的执行。

事件循环的组成部分

为了更好地理解它,我们需要了解系统的所有组成部分。这些组件是事件循环的一部分:

调用堆栈

调用堆栈跟踪程序中正在执行的函数。当调用一个函数时,它被添加到调用堆栈的顶部。当函数完成时,它将从调用堆栈中删除。这允许程序跟踪它在函数执行中的位置,并在函数完成时返回到正确的位置。顾名思义,它是一个遵循后进先出的堆栈数据结构。

Web API/Node.js API

异步操作,如 setTimeout()、HTTP 请求、文件 I/O 等,由 Web API(在浏览器中)或 C++ API(在 Node.js 中)处理。这些 API 不是 JavaScript 引擎的一部分,并且在单独的线程上运行,允许它们并发执行而不会阻塞调用堆栈。

任务队列 / 宏任务队列 / 回调队列

任务队列,也称为宏任务队列/回调队列/事件队列,是一个保存需要执行的任务的队列。这些任务通常是异步操作,例如传递给 Web API 的回调(setTimeout()setInterval()、HTTP 请求等)以及用户界面事件处理程序,如点击、滚动等。

微任务队列

Microtasks 是比 macrotasks 具有更高优先级的任务,它们在当前执行的脚本完成之后、下一个 macrotask 执行之前立即执行。Microtasks 通常用于更即时、轻量级的操作,这些操作应在当前操作完成后尽快执行。有一个专门用于 microtasks 的 microtask 队列。Microtasks 包括 promise 回调(then()catch()finally())、await 语句、queueMicrotask()MutationObserver 回调。

事件循环顺序

  1. JavaScript 引擎开始执行脚本,将同步操作放在调用堆栈上。
  2. 当遇到异步操作(例如,setTimeout()、HTTP 请求)时,它会被卸载到相应的 Web API 或 Node.js API,以在后台处理该操作。
  3. 一旦异步操作完成,其回调函数将被放置在相应的队列中——任务队列(也称为 macrotask 队列/回调队列)或 microtask 队列。从这里开始,我们将“任务队列”称为“macrotask 队列”,以便更好地与 microtask 队列区分开来。
  4. 事件循环持续监视调用堆栈并执行调用堆栈上的项目。如果/当调用堆栈为空时:
    1. microtask 队列被处理。事件循环从 microtask 队列中获取第一个回调,并将其推送到调用堆栈以供执行。这将重复进行,直到 microtask 队列为空。
    2. macrotask 队列被处理。事件循环从 macrotask 队列中取出第一个回调,并将其推送到调用堆栈上以供执行。但是,在处理完 macrotask 队列回调之后,事件循环不会继续处理下一个 macrotask!事件循环首先检查 microtask 队列。检查 microtask 队列是必要的,因为 microtasks 的优先级高于 macrotask 队列回调。刚刚执行的 macrotask 队列回调可能添加了更多 microtasks!
      1. 如果 microtask 队列非空,则按上一步处理它们。
      2. 如果 microtask 队列为空,则处理下一个 macrotask 队列回调。这将重复进行,直到 macrotask 队列为空。
  5. 此过程无限期地持续进行,允许 JavaScript 引擎高效地处理同步和异步操作,而不会阻塞调用堆栈。

示例

以下代码使用正常执行、macrotasks 和 microtasks 的组合来记录一些语句。

console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
}, 0);
Promise.resolve().then(() => {
console.log('Promise 1');
});
setTimeout(() => {
console.log('Timeout 2');
}, 0);
console.log('End');
// Console output:
// Start
// End
// Promise 1
// Timeout 1
// Timeout 2

输出说明:

  1. StartEnd 首先被记录,因为它们是初始脚本的一部分。
  2. Promise 1 接下来被记录,因为 promises 是 microtasks,并且 microtasks 在调用堆栈上的项目之后立即执行。
  3. Timeout 1Timeout 2 最后被记录,因为它们是 macrotasks,并且在 microtasks 之后被处理。

延伸阅读和资源

解释 JavaScript 中的事件委托

主题
Web APIJavaScript

TL;DR

事件委托是 JavaScript 中的一种技术,它将单个事件监听器附加到父元素,而不是将事件监听器附加到多个子元素。当子元素上发生事件时,该事件会冒泡到 DOM 树中,父元素的事件监听器会根据目标元素处理该事件。

事件委托具有以下优点:

  • 提高性能:附加单个事件监听器比将多个事件监听器附加到各个元素更有效,尤其是在处理大型或动态列表时。这减少了内存使用并提高了整体性能。
  • 简化事件处理:使用事件委托,您只需要在父元素的事件监听器中编写一次事件处理逻辑。这使得代码更易于维护和更新。
  • 动态元素支持:事件委托会自动处理父元素中动态添加或删除元素的事件。当 DOM 结构发生变化时,无需手动附加或删除事件监听器

但是,请注意:

  • 确定触发事件的目标元素非常重要。
  • 并非所有事件都可以被委托,因为它们不会冒泡。不冒泡的事件包括:focusblurscrollmouseentermouseleaveresize 等。

事件委托

事件委托是 JavaScript 中用于通过将单个事件监听器附加到公共祖先元素来有效管理和处理多个子元素上的事件的设计模式。此模式在您有大量相似元素(例如列表项)并希望优化事件处理的场景中特别有价值。

事件委托的工作原理

  1. 将监听器附加到公共祖先:您将单个事件监听器附加到 DOM 层次结构中较高的公共祖先元素,而不是将各个事件监听器附加到每个子元素。
  2. 事件冒泡:当子元素上发生事件时,它会通过 DOM 树冒泡到公共祖先元素。在此传播过程中,公共祖先上的事件监听器可以拦截和处理该事件。
  3. 确定目标:在事件监听器中,您可以检查事件对象以识别事件的实际目标(触发事件的子元素)。您可以使用 event.targetevent.currentTarget 等属性来确定与哪个特定的子元素进行了交互。
  4. 根据目标执行操作:根据目标元素,您可以执行所需的操作或执行特定于该元素的代码。这允许您使用单个事件监听器处理多个子元素的事件。

事件委托的优点

  1. 效率:事件委托减少了事件监听器的数量,从而提高了内存使用率和性能,尤其是在处理大量元素时。
  2. 动态元素:它可以与动态添加或删除的子元素无缝协作,因为公共祖先会继续侦听它们的事件。

例子

这是一个简单的例子:

// HTML:
// <ul id="item-list">
// <li>Item 1</li>
// <li>Item 2</li>
// <li>Item 3</li>
// </ul>
const itemList = document.getElementById('item-list');
itemList.addEventListener('click', (event) => {
if (event.target.tagName === 'LI') {
console.log(`Clicked on ${event.target.textContent}`);
}
});

在此示例中,单个 click 事件监听器附加到 <ul> 元素。当 <li> 元素上发生 click 事件时,该事件会冒泡到 <ul> 元素,事件监听器会检查目标元素的标签名称以确定是否单击了列表项。检查 event.target 的身份至关重要,因为 DOM 树中可能存在其他类型的元素。

用例

事件委托通常用于以下场景:

处理单页应用程序中的动态内容

// HTML:
// <div id="button-container">
// <button>Button 1</button>
// <button>Button 2</button>
// </div>
// <button id="add-button">Add Button</button>
const buttonContainer = document.getElementById('button-container');
const addButton = document.getElementById('add-button');
buttonContainer.addEventListener('click', (event) => {
if (event.target.tagName === 'BUTTON') {
console.log(`Clicked on ${event.target.textContent}`);
}
});
addButton.addEventListener('click', () => {
const newButton = document.createElement('button');
newButton.textContent = `Button ${buttonContainer.children.length + 1}`;
buttonContainer.appendChild(newButton);
});

在此示例中,click 事件侦听器附加到 <div> 容器。 当动态添加并单击一个新按钮时,容器上的事件侦听器会处理 click 事件。

通过避免为更改的元素附加和删除事件侦听器来简化代码

// HTML:
// <form id="user-form">
// <input type="text" name="username" placeholder="Username">
// <input type="email" name="email" placeholder="Email">
// <input type="password" name="password" placeholder="Password">
// </form>
const userForm = document.getElementById('user-form');
userForm.addEventListener('input', (event) => {
const { name, value } = event.target;
console.log(`Changed ${name}: ${value}`);
});

在这个例子中,单个输入事件监听器附加到表单元素。它可以响应所有子输入元素的输入更改,通过消除对每个<input>元素上单独监听器的需求来简化代码。

陷阱

请注意,事件委托会带来某些陷阱:

  • 目标处理不正确: 确保正确识别事件目标,以避免意外操作。
  • 并非所有事件都可以委托/冒泡:并非所有事件都可以委托,因为它们不会冒泡。 不冒泡的事件包括:focusblurscrollmouseentermouseleaveresize 等。
  • 事件开销: 虽然事件委托通常更有效,但需要在根事件侦听器中编写复杂的逻辑来识别触发元素并做出适当的响应。 如果管理不当,这可能会引入开销,并且可能更复杂。

JavaScript 框架中的事件委托

React 中,事件处理程序附加到 React 根的 DOM 容器中,React 树被渲染到该容器中。 即使将 onClick 添加到子元素,实际的事件侦听器也会附加到根 DOM 节点,利用事件委托来优化事件处理并提高性能。

当事件发生时,React 的事件侦听器会捕获它,并根据其内部簿记确定哪个 React 组件呈现了目标元素。 然后,React 通过使用合成事件对象调用处理程序函数,将事件分派给相应的组件的事件处理程序。 此合成事件对象包装了本机浏览器事件,提供了跨不同浏览器的一致接口,并捕获了有关事件的信息。

通过使用事件委托,React 避免将单个事件处理程序附加到每个组件实例,这将产生巨大的开销,尤其对于大型组件树。 相反,React 利用浏览器的本机事件冒泡机制来捕获根处的事件并将它们分发到相应的组件。

延伸阅读

解释 JavaScript 中 `this` 的工作原理

主题
JavaScriptOOP

TL;DR

this 没有简单的解释;它是 JavaScript 中最令人困惑的概念之一,因为它的行为与其他许多编程语言不同。this 关键字的单行解释是,它是一个对函数执行上下文的动态引用。

更长的解释是,this 遵循以下规则:

  1. 如果在调用函数时使用了 new 关键字,这意味着该函数被用作函数构造函数,则函数内部的 this 是新创建的对象实例。
  2. 如果在 class constructor 中使用了 this,则 constructor 内部的 this 是新创建的对象实例。
  3. 如果使用 apply()call()bind() 调用/创建函数,则函数内部的 this 是作为参数传入的对象。
  4. 如果一个函数被调用为一个方法(例如 obj.method())——this 是该函数所属的对象。
  5. 如果一个函数被调用为自由函数调用,这意味着它是在没有任何上述条件的情况下被调用的,this 是全局对象。在浏览器中,全局对象是 window 对象。如果在严格模式 ('use strict';) 中,this 将是 undefined 而不是全局对象。
  6. 如果应用了多个上述规则,则优先级较高的规则将获胜并设置 this 的值。
  7. 如果该函数是 ES2015 箭头函数,它会忽略上述所有规则,并在创建时接收其周围作用域的 this 值。

如需深入解释,请查看 Arnav Aggrawal 在 Medium 上的文章


this 关键字

在 JavaScript 中,this 是一个关键字,它引用函数或脚本的当前执行上下文。这是 JavaScript 中的一个基本概念,理解 this 的工作原理对于构建健壮且可维护的应用程序至关重要。

全局使用

在全局范围内,this 引用全局对象,在 Web 浏览器中是 window 对象,在 Node.js 环境中是 global 对象。

console.log(this); // 在浏览器中,这将记录 window 对象(对于非严格模式)。

在常规函数调用中

当一个函数在全局上下文中或作为独立函数被调用时,this 引用全局对象(在非严格模式下)或 undefined(在严格模式下)。

function showThis() {
console.log(this);
}
showThis(); // 在非严格模式下:Window(全局对象)。在严格模式下:undefined。

在方法调用中

当一个函数作为对象的方法被调用时,this 引用该方法被调用的对象。

const obj = {
name: 'John',
showThis: function () {
console.log(this);
},
};
obj.showThis(); // { name: 'John', showThis: ƒ }

请注意,如果你这样做,它就和常规函数调用一样,而不是方法调用。this 已经失去了它的上下文,不再指向 obj

const obj = {
name: 'John',
showThis: function () {
console.log(this);
},
};
const showThisStandalone = obj.showThis;
showThisStandalone(); // 在非严格模式下:Window(全局对象)。在严格模式下:undefined。

在函数构造器中

当一个函数被用作构造器(使用 new 关键字调用)时,this 指的是新创建的实例。在下面的例子中,this 指的是正在创建的 Person 对象,并且 name 属性是在该对象上设置的。

function Person(name) {
this.name = name;
}
const person = new Person('John');
console.log(person.name); // "John"

在类构造函数和方法中

在 ES2015 类中,this 的行为与在对象方法中一样。它指的是类的实例。

class Person {
constructor(name) {
this.name = name;
}
showThis() {
console.log(this);
}
}
const person = new Person('John');
person.showThis(); // Person {name: 'John'}
const showThisStandalone = person.showThis;
showThisStandalone(); // `undefined` because in JavaScript class bodies, all methods are strict mode by default, even if you don't add 'use strict'

显式绑定 this

你可以使用 bind()call()apply() 来显式设置函数的 this 的值。

使用 call()apply() 方法允许您在调用函数时显式设置 this 的值。

function showThis() {
console.log(this);
}
const obj = { name: 'John' };
showThis.call(obj); // { name: 'John' }
showThis.apply(obj); // { name: 'John' }

bind() 方法创建一个新函数,并将 this 绑定到指定的值。

function showThis() {
console.log(this);
}
const obj = { name: 'John' };
const boundFunc = showThis.bind(obj);
boundFunc(); // { name: 'John' }

在箭头函数中

箭头函数没有自己的 this 上下文。相反,this 是词法作用域的,这意味着它继承了定义时周围作用域的 this 值。

在这个例子中,this 指的是全局对象(window 或 global),因为箭头函数没有绑定到 person 对象。

const person = {
firstName: 'John',
sayHello: () => {
console.log(`Hello, my name is ${this.firstName}!`);
},
};
person.sayHello(); // "Hello, my name is undefined!"

在下面的例子中,箭头函数中的 this 将是其封闭上下文的 this 值,因此它取决于 showThis() 的调用方式。

const obj = {
name: 'John',
showThis: function () {
const arrowFunc = () => {
console.log(this);
};
arrowFunc();
},
};
obj.showThis(); // { name: 'John', showThis: ƒ }
const showThisStandalone = obj.showThis;
showThisStandalone(); // 在非严格模式下:Window(全局对象)。在严格模式下:undefined。

因此,箭头函数中的 this 值不能通过 bind()apply()call() 方法设置,它也不会指向对象方法中的当前对象。

const obj = {
name: 'Alice',
regularFunction: function () {
console.log('Regular function:', this.name);
},
arrowFunction: () => {
console.log('Arrow function:', this.name);
},
};
const anotherObj = {
name: 'Bob',
};
// 使用 call/apply/bind 和常规函数
obj.regularFunction.call(anotherObj); // Regular function: Bob
obj.regularFunction.apply(anotherObj); // Regular function: Bob
const boundRegularFunction = obj.regularFunction.bind(anotherObj);
boundRegularFunction(); // Regular function: Bob
// 使用 call/apply/bind 和箭头函数,`this` 指的是全局作用域,并且不能被修改。
obj.arrowFunction.call(anotherObj); // Arrow function: window/undefined (depending if strict mode)
obj.arrowFunction.apply(anotherObj); // Arrow function: window/undefined (depending if strict mode)
const boundArrowFunction = obj.arrowFunction.bind(anotherObj);
boundArrowFunction(); // Arrow function: window/undefined (depending if strict mode)

在事件处理程序中

当一个函数被调用为 DOM 事件处理程序时,this 指的是触发该事件的元素。在这个例子中,this 指的是被点击的 <button> 元素。

<button id="my-button" onclick="console.log(this)">Click me</button>
<!-- Logs the button element -->

使用 JavaScript 设置事件处理程序时,this 也指的是接收事件的元素。

document.getElementById('my-button').addEventListener('click', function () {
console.log(this); // Logs the button element
});

如上所述,ES2015 引入了 箭头函数,它使用 封闭的词法范围。这通常很方便,但确实阻止了调用者通过 .call/.apply/.bind 定义 this 上下文。其中一个后果是,如果您使用箭头函数定义 .addEventListener() 的回调参数,DOM 事件处理程序将无法在您的事件处理程序函数中正确绑定 this

document.getElementById('my-button').addEventListener('click', () => {
console.log(this); // Window / undefined (depending on whether strict mode) instead of the button element.
});

总而言之,JavaScript 中的 this 指的是函数或脚本的当前执行上下文,其值可以根据使用它的上下文而改变。理解 this 的工作方式对于构建健壮且可维护的 JavaScript 应用程序至关重要。

延伸阅读

描述浏览器中 cookie、`sessionStorage` 和 `localStorage` 之间的区别

主题
Web APIJavaScript

总结

以下都是在客户端(本例中为用户的浏览器)上存储数据的机制。localStoragesessionStorage 都实现了 Web Storage API 接口

  • Cookies:适用于服务器-客户端通信,存储容量小,可以是持久性的或基于会话的,特定于域。在每次请求时发送到服务器。
  • localStorage:适用于长期存储,即使在浏览器关闭后数据仍然存在,可在同一来源的所有选项卡和窗口中访问,在三者中存储容量最高。
  • sessionStorage:适用于单个页面会话中的临时数据,当选项卡或窗口关闭时数据将被清除,与 cookies 相比具有更高的存储容量。

以下表格总结了 3 种客户端存储机制。

属性CookielocalStoragesessionStorage
发起者客户端或服务器。服务器可以使用 Set-Cookie 标头客户端客户端
寿命如指定直到删除直到选项卡关闭
是否跨浏览器会话持久如果设置了未来的过期日期
是否随每个 HTTP 请求发送到服务器是,通过 Cookie 标头发送
总容量(每个域)4kb5MB5MB
访问跨窗口/选项卡跨窗口/选项卡相同选项卡
安全性JavaScript 无法访问 HttpOnly cookies

Web 上的存储

Cookies、localStoragesessionStorage 都是客户端(Web 浏览器)上的存储机制。在客户端存储数据对于仅客户端的状态很有用,例如访问令牌、主题、个性化布局,以便用户可以在跨选项卡和使用会话的网站上获得一致的体验。

这些客户端存储机制具有以下常见属性:

  • 这意味着客户端可以读取和修改值(HttpOnly cookies 除外)。
  • 基于键值对的存储。
  • 它们只能将值存储为字符串。非字符串必须被序列化为字符串(例如 JSON.stringify())才能被存储。

每种存储机制的用例

由于 cookies 的最大大小相对较小,因此不建议将所有客户端数据存储在 cookies 中。关于 cookies 的显著特性是 cookies 会在每个 HTTP 请求中发送到服务器,因此较小的最大大小是一个特性,可以防止您的 HTTP 请求由于 cookies 而变得过大。cookies 的自动过期也是一个有用的特性。

考虑到这一点,最适合存储在 cookies 中的数据是需要传输到服务器的小块数据,例如身份验证令牌、会话 ID、分析跟踪 ID、GDPR cookie 同意、对身份验证、授权和在服务器上呈现很重要的语言偏好。这些值有时很敏感,并且可以从 cookies 提供的 HttpOnlySecureExpires/Max-Age 功能中受益。

localStoragesessionStorage 都实现了 Web Storage API 接口。Web Storages 具有 5MB 的总容量,因此存储大小通常不是问题。关键的区别在于,存储在 Web Storage 中的值不会自动随 HTTP 请求一起发送。

虽然您可以在发出 AJAX/fetch() 请求时手动包含来自 Web Storage 的值,但浏览器不会在页面的初始请求/首次加载中包含它们。因此,如果使用服务器端渲染(通常是与身份验证/授权相关的信息),则不应使用 Web Storage 来存储服务器用于页面初始渲染所依赖的数据。localStorage 最适合不失效的用户偏好数据,例如主题和布局(如果服务器渲染最终布局并不重要)。sessionStorage 最适合只需要在当前浏览会话中访问的临时数据,例如表单数据(用于在意外重新加载期间保留数据)。

以下部分深入探讨了每种客户端存储机制。

Cookies

Cookies 用于在客户端存储小块数据,这些数据可以通过每个 HTTP 请求发送回服务器。

  • 存储容量:所有 cookies 限制在 4KB 左右。
  • 寿命:Cookies 可以使用 ExpiresMax-Age 属性设置特定的过期日期。如果未设置过期日期,则在浏览器关闭时删除 cookie(会话 cookie)。
  • 访问:Cookies 是特定于域的,可以在同一域内的不同页面和子域之间共享。
  • 安全性:Cookies 可以标记为 HttpOnly 以防止从 JavaScript 访问,从而降低 XSS 攻击的风险。它们也可以使用 Secure 标志进行保护,以确保仅在使用 HTTPS 时发送它们。
// Set a cookie for the name/key `auth_token` with an expiry.
document.cookie =
'auth_token=abc123def; expires=Fri, 31 Dec 2024 23:59:59 GMT; path=/';
// Read all cookies. There's no way to read specific cookies using `document.cookie`.
// You have to parse the string yourself.
console.log(document.cookie); // auth_token=abc123def
// Delete the cookie with the name/key `auth_token` by setting an
// expiry date in the past. The value doesn't matter.
document.cookie = 'auth_token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';

读取/写入 Cookie 是一件很痛苦的事情。document.cookie 返回一个包含所有由 ; 分隔的键/值对的字符串,您必须自己解析该字符串。js-cookie npm 库提供了一个简单轻量级的 API,用于在 JavaScript 中读写 Cookie。

访问 Cookie 的现代原生方式是通过 Cookie Store API,该 API 仅在 HTTPS 页面上可用。

// Set a cookie. More options are available too.
cookieStore.set('auth_token', 'abc123def');
// Async method to access a single cookie and do something with it.
cookieStore.get('auth_token').then(...);
// Async method to get all cookies.
cookieStore.getAll().then(...);
// Async method to delete a single cookie.
cookieStore.delete('auth_token').then(() =>
console.log('Cookie deleted')
);

CookieStore API 相对较新,可能并非所有浏览器都支持(截至 2024 年 6 月,在最新的 Chrome 和 Edge 中受支持)。请参阅 caniuse.com 以获取最新的兼容性信息。

localStorage

localStorage 用于存储即使在浏览器关闭并重新打开后仍保留的数据。它专为长期存储数据而设计。

  • 存储容量:通常每个来源约 5MB(因浏览器而异)。
  • 生命周期localStorage 中的数据会一直存在,直到用户或应用程序显式删除。
  • 访问:数据可在同一来源的所有选项卡和窗口中访问。
  • 安全性:页面上的所有 JavaScript 都可以访问 localStorage 中的值。
// Set a value in localStorage.
localStorage.setItem('key', 'value');
// Get a value from localStorage.
console.log(localStorage.getItem('key'));
// Remove a value from localStorage.
localStorage.removeItem('key');
// Clear all data in localStorage.
localStorage.clear();

sessionStorage

sessionStorage 用于存储页面会话期间的数据。它专为临时存储数据而设计。

  • 存储容量: 通常每个来源约 5MB(取决于浏览器)。
  • 生命周期: sessionStorage 中的数据在页面会话结束时清除(即,当浏览器或选项卡关闭时)。重新加载页面不会销毁 sessionStorage 中的数据。
  • 访问: 数据仅在当前选项卡(或浏览上下文)中可访问。不同的选项卡共享不同的 sessionStorage 对象,即使它们属于同一个浏览器窗口。在这种情况下,窗口指的是可以包含多个选项卡的浏览器窗口。
  • 安全性: 同一页面上的所有 JavaScript 都可以访问该页面 sessionStorage 中的值。
// Set a value in sessionStorage.
sessionStorage.setItem('key', 'value');
// Get a value from sessionStorage.
console.log(sessionStorage.getItem('key'));
// Remove a value from sessionStorage.
sessionStorage.removeItem('key');
// Clear all data in sessionStorage.
sessionStorage.clear();

注意事项

还有其他客户端存储机制,例如 IndexedDB,它比上述技术更强大,但使用起来更复杂。

参考

描述 `<script>`、`<script async>` 和 `<script defer>` 之间的区别

主题
HTMLJavaScript

TL;DR

所有这些方式(<script><script async><script defer>)都用于在 HTML 文档中加载和执行 JavaScript 文件,但它们在浏览器处理脚本的加载和执行方式上有所不同:

  • <script> 是包含 JavaScript 的默认方式。浏览器在下载和执行脚本时会阻止 HTML 解析。在脚本执行完毕之前,浏览器不会继续渲染页面。
  • <script async> 异步下载脚本,与解析 HTML 并行。在脚本可用后立即执行脚本,可能会中断 HTML 解析。<script async> 之间不会互相等待,并且以不特定的顺序执行。
  • <script defer> 异步下载脚本,与解析 HTML 并行。但是,脚本的执行被推迟到 HTML 解析完成后,按照它们在 HTML 中出现的顺序。

这是一个表格,总结了在 HTML 文档中加载 <script> 的 3 种方式。

特性<script><script async><script defer>
解析行为阻止 HTML 解析与解析并行运行与解析并行运行
执行顺序按照出现顺序不保证按照出现顺序
DOM 依赖是(等待 DOM)

<script> 标签的用途

<script> 标签用于在网页中包含 JavaScript。asyncdefer 属性用于更改脚本的加载和执行方式/时间。

<script>

对于没有任何 asyncdefer 的普通 <script> 标签,当遇到它们时,HTML 解析会被阻止,脚本会被立即获取和执行。HTML 解析在脚本执行完毕后恢复。如果脚本很大,这可能会阻止页面的渲染。

<script> 用于页面依赖于正确渲染的关键脚本。

<!doctype html>
<html>
<head>
<title>Regular Script</title>
</head>
<body>
<!-- Content before the script -->
<h1>Regular Script Example</h1>
<p>This content will be rendered before the script executes.</p>
<!-- Regular script -->
<script src="regular.js"></script>
<!-- Content after the script -->
<p>This content will be rendered after the script executes.</p>
</body>
</html>

<script async>

<script async> 中,浏览器异步下载脚本文件(与 HTML 解析并行),并在脚本可用后立即执行(可能在 HTML 解析完成之前)。执行不一定按照它在 HTML 文档中出现的顺序执行。这可以提高感知的性能,因为浏览器在继续渲染页面之前不必等待脚本下载。

当脚本独立于页面上的任何其他脚本时,使用 <script async>,例如分析和广告脚本。

<!doctype html>
<html>
<head>
<title>Async Script</title>
</head>
<body>
<!-- Content before the script -->
<h1>Async Script Example</h1>
<p>This content will be rendered before the async script executes.</p>
<!-- Async script -->
<script async src="async.js"></script>
<!-- Content after the script -->
<p>
This content may be rendered before or after the async script executes.
</p>
</body>
</html>

<script defer>

<script async> 类似,<script defer> 也会与 HTML 解析并行下载脚本,但脚本仅在文档被完全解析并且在触发 DOMContentLoaded 之前执行。如果有多个,则每个延迟脚本按照它们在 HTML 文档中出现的顺序执行。

如果脚本依赖于完全解析的 DOM,则 defer 属性将有助于确保在执行之前完全解析 HTML。

<!doctype html>
<html>
<head>
<title>Deferred Script</title>
</head>
<body>
<!-- Content before the script -->
<h1>Deferred Script Example</h1>
<p>This content will be rendered before the deferred script executes.</p>
<!-- Deferred script -->
<script defer src="deferred.js"></script>
<!-- Content after the script -->
<p>This content will be rendered before the deferred script executes.</p>
</body>
</html>

注意事项

  • async 属性应用于对页面初始渲染不关键且彼此不依赖的脚本,而 defer 属性应用于依赖于/被另一个脚本依赖的脚本。
  • 对于没有 src 属性的脚本,将忽略 asyncdefer 属性。
  • 包含 document.write() 的带有 deferasync<script> 将被忽略,并显示类似“从异步加载的外部脚本对 document.write() 的调用被忽略”的消息。
  • 即使 asyncdefer 有助于使脚本下载异步,但脚本最终仍在主线程上执行。如果这些脚本是计算密集型的,则可能导致 UI 滞后/冻结。Partytown 是一个库,它有助于将脚本执行重新定位到 web worker 并从 主线程 中移出,这对于您无法控制代码的第三方脚本非常有用。

延伸阅读

JavaScript 变量 `null`、`undefined` 和未声明的区别是什么?

如何检查这些状态?
主题
JavaScript

TL;DR

特性nullundefined未声明
含义由开发人员显式设置,表示变量没有值变量已声明但未赋值变量根本未声明
类型(通过 typeof 运算符)'object''undefined''undefined'
等式比较null == undefinedtrueundefined == nulltrue抛出 ReferenceError

未声明

未声明 变量是在您将值分配给先前未使用 varletconst 创建的标识符时创建的。未声明的变量将在全局范围内定义,在当前范围之外。在严格模式下,当您尝试分配给未声明的变量时,将抛出 ReferenceError。未声明的变量和全局变量一样糟糕。不惜一切代价避免它们!要检查它们,请将其用法包装在 try/catch 块中。

function foo() {
x = 1; // 在严格模式下抛出 ReferenceError
}
foo();
console.log(x); // 1 (如果不在严格模式下)

对未声明的变量使用 typeof 运算符将给出 'undefined'

console.log(typeof y === 'undefined'); // true

undefined

一个undefined的变量是已声明但未赋值的变量。它的类型是undefined。如果一个函数没有返回值,并且它的结果被分配给一个变量,那么该变量也将具有undefined值。要检查它,请使用严格相等运算符(===)或typeof进行比较,这将给出'undefined'字符串。请注意,您不应该使用宽松相等运算符(==)进行检查,因为它在值为null时也会返回true

let foo;
console.log(foo); // undefined
console.log(foo === undefined); // true
console.log(typeof foo === 'undefined'); // true
console.log(foo == null); // true. 错误,不要使用它来检查一个值是否为 undefined!
function bar() {} // 如果没有任何返回,则返回 undefined。
let baz = bar();
console.log(baz); // undefined

null

一个 null 的变量将被显式地赋值为 null 值。它表示没有值,并且与 undefined 的不同之处在于它已被显式赋值。要检查 null,只需使用严格相等运算符进行比较。请注意,与上述类似,您不应该使用松散相等运算符 (==) 进行检查,因为它在值为 undefined 时也会返回 true

const foo = null;
console.log(foo === null); // true
console.log(typeof foo === 'object'); // true
console.log(foo == undefined); // true. 错误,不要使用它来检查一个值是否为 null!

注意事项

  • 养成一个好习惯,永远不要让你的变量未声明或未赋值。如果您不打算使用它们,在声明后显式地将 null 赋值给它们。
  • 始终在变量使用前显式声明它们以防止错误。
  • 在您的工作流程中使用一些静态分析工具(例如 ESLint、TypeScript 编译器),将启用您未引用未声明变量的检查。

实践

在 GreatFrontEnd 上练习实现 检查 nullundefined 的类型实用程序

延伸阅读

JavaScript 中`.call` 和 `.apply` 有什么区别?

主题
JavaScript

TL;DR

.call.apply 都用于使用特定的 this 上下文和参数来调用函数。主要区别在于它们接受参数的方式:

  • .call(thisArg, arg1, arg2, ...):单独获取参数。
  • .apply(thisArg, [argsArray]):将参数作为数组获取。

假设我们有一个函数 add,可以使用以下方式使用 .call.apply 调用该函数:

function add(a, b) {
return a + b;
}
console.log(add.call(null, 1, 2)); // 3
console.log(add.apply(null, [1, 2])); // 3

Call vs Apply

.call.apply 都用于调用函数,第一个参数将用作函数中 this 的值。但是,.call 将逗号分隔的参数作为下一个参数,而 .apply 将参数数组作为下一个参数。

记住这一点的一个简单方法是 C 代表 call 和逗号分隔,A 代表 apply 和参数数组。

function add(a, b) {
return a + b;
}
console.log(add.call(null, 1, 2)); // 3
console.log(add.apply(null, [1, 2])); // 3

使用 ES6 语法,我们可以使用数组以及用于参数的扩展运算符来调用 call

function add(a, b) {
return a + b;
}
console.log(add.call(null, ...[1, 2])); // 3

用例

上下文管理

.call.apply 可以在调用不同对象上的方法时显式设置 this 上下文。

const person = {
name: 'John',
greet() {
console.log(`Hello, my name is ${this.name}`);
},
};
const anotherPerson = { name: 'Alice' };
person.greet.call(anotherPerson); // Hello, my name is Alice
person.greet.apply(anotherPerson); // Hello, my name is Alice

函数借用

.call.apply 都允许从一个对象借用方法并在另一个对象的上下文中使它们。当将函数作为参数(回调)传递并且原始 this 上下文丢失时,这很有用。.call.apply 允许使用预期的 this 值调用该函数。

function greet() {
console.log(`Hello, my name is ${this.name}`);
}
const person1 = { name: 'John' };
const person2 = { name: 'Alice' };
greet.call(person1); // Hello, my name is John
greet.apply(person2); // Hello, my name is Alice

调用对象方法的替代语法

.apply 可以通过将对象作为第一个参数传递,然后传递通常的参数来与对象方法一起使用。

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
Array.prototype.push.apply(arr1, arr2); // Same as arr1.push(4, 5, 6)
console.log(arr1); // [1, 2, 3, 4, 5, 6]

分解上述内容:

  1. 第一个对象 arr1 将用作 this 值。
  2. arr1 上调用 .push(),使用 arr2 作为参数数组,因为它使用 .apply()
  3. Array.prototype.push.apply(arr1, arr2) 等同于 arr1.push(...arr2)

可能并不明显,但 Array.prototype.push.apply(arr1, arr2) 会导致修改 arr1。如果可能,使用面向 OOP 的方式调用方法会更清晰。

后续问题

  • .call.applyFunction.prototype.bind 有什么不同?

实践

在 GreatFrontEnd 上实践实现你自己的 Function.prototype.call 方法Function.prototype.apply 方法

延伸阅读

解释 JavaScript 中的 `Function.prototype.bind`

主题
JavaScriptOOP

TL;DR

Function.prototype.bind 是 JavaScript 中的一个方法,它允许你创建一个新函数,该函数具有特定的 this 值和可选的初始参数。它的主要目的是:

  • 绑定 this 值以保留上下文bind 的主要目的是将函数的 this 值绑定到特定的对象。当你调用 func.bind(thisArg) 时,它会创建一个与 func 具有相同主体的的新函数,但 this 永久绑定到 thisArg
  • 部分应用参数bind 还允许你为新函数预先指定参数。传递给 bind 的任何参数(在 thisArg 之后)都将在调用新函数时添加到参数列表的前面。
  • 方法借用bind 允许你从一个对象借用方法并将其应用于另一个对象,即使它们最初并非设计为与该对象一起使用。

bind 方法在需要确保使用特定的 this 上下文调用函数的情况下特别有用,例如在事件处理程序、回调或方法借用中。


Function.prototype.bind

Function.prototype.bind 允许你创建一个具有特定 this 上下文的新函数,以及可选的预设参数。bind() 对于在要传递给其他函数的类方法中保留 this 的值非常有用。

bind 经常用于旧版 React 类组件方法,这些方法不是使用箭头函数定义的。

const john = {
age: 42,
getAge: function () {
return this.age;
},
};
console.log(john.getAge()); // 42
const unboundGetAge = john.getAge;
console.log(unboundGetAge()); // undefined
const boundGetAge = john.getAge.bind(john);
console.log(boundGetAge()); // 42
const mary = { age: 21 };
const boundGetAgeMary = john.getAge.bind(mary);
console.log(boundGetAgeMary()); // 21

在上面的例子中,当 getAge 方法在没有调用对象(作为 unboundGetAge)的情况下被调用时,该值为 undefined,因为 getAge() 中的 this 的值变为全局对象。boundGetAge()this 绑定到 john,因此它能够获取 johnage

我们甚至可以在另一个不是 john 的对象上使用 getAgeboundGetAgeMary 返回 maryage

用例

以下是经常使用 bind 的一些常见场景:

在回调中保留上下文并修复 this

当你将一个函数作为回调传递时,函数内部的 this 值可能无法预测,因为它由执行上下文决定。使用 bind() 有助于确保维护正确的 this 值。

class Person {
constructor(firstName) {
this.firstName = firstName;
}
greet() {
console.log(`Hello, my name is ${this.firstName}`);
}
}
const john = new Person('John');
// Without bind(), `this` inside the callback will be the global object
setTimeout(john.greet, 1000); // Output: "Hello, my name is undefined"
// Using bind() to fix the `this` value
setTimeout(john.greet.bind(john), 2000); // Output: "Hello, my name is John"

你也可以使用 箭头函数 来定义类方法,而不是使用 bind。箭头函数将 this 值绑定到其词法上下文。

class Person {
constructor(name) {
this.name = name;
}
greet = () => {
console.log(`Hello, my name is ${this.name}`);
};
}
const john = new Person('John Doe');
setTimeout(john.greet, 1000); // Output: "Hello, my name is John Doe"

函数的部分应用(柯里化)

bind 可以用来创建一个新函数,其中预先设置了一些参数。这被称为部分应用或柯里化。

function multiply(a, b) {
return a * b;
}
// 使用 bind() 创建一个新函数,其中预先设置了一些参数
const multiplyBy5 = multiply.bind(null, 5);
console.log(multiplyBy5(3)); // Output: 15

方法借用

bind 允许你从一个对象借用方法并将它们应用于另一个对象,即使它们最初不是为该对象设计的。当你需要在不同对象之间重用功能时,这会很有用

const person = {
name: 'John',
greet: function () {
console.log(`Hello, ${this.name}!`);
},
};
const greetPerson = person.greet.bind({ name: 'Alice' });
greetPerson(); // Output: Hello, Alice!

练习

尝试在 GreatFrontEnd 上实现你自己的 Function.prototype.bind() 方法

延伸阅读

在构造函数中使用 JavaScript 箭头语法作为方法有什么优势?

主题
JavaScript

TL;DR

在构造函数中使用箭头函数作为方法的主要优点是 this 的值在函数创建时设置,之后无法更改。当构造函数用于创建新对象时,this 将始终引用该对象。

例如,假设我们有一个 Person 构造函数,它接受一个名字作为参数,并有两个方法来 console.log() 该名称,一个作为常规函数,一个作为箭头函数:

const Person = function (name) {
this.firstName = name;
this.sayName1 = function () {
console.log(this.firstName);
};
this.sayName2 = () => {
console.log(this.firstName);
};
};
const john = new Person('John');
const dave = new Person('Dave');
john.sayName1(); // John
john.sayName2(); // John
// 常规函数可以更改其 `this` 值,但箭头函数不能
john.sayName1.call(dave); // Dave (因为 `this` 现在是 dave 对象)
john.sayName2.call(dave); // John
john.sayName1.apply(dave); // Dave (因为 `this` 现在是 dave 对象)
john.sayName2.apply(dave); // John
john.sayName1.bind(dave)(); // Dave (因为 `this` 现在是 dave 对象)
john.sayName2.bind(dave)(); // John
const sayNameFromWindow1 = john.sayName1;
sayNameFromWindow1(); // undefined (因为 `this` 现在是 window 对象)
const sayNameFromWindow2 = john.sayName2;
sayNameFromWindow2(); // John

这里的主要结论是,this 可以为普通函数更改,但 this 对于箭头函数始终保持不变。因此,即使您将箭头函数传递到应用程序的不同部分,您也不必担心 this 的值发生变化。


箭头函数

箭头函数是在 ES2015 中引入的,它提供了一种用 Javascript 编写函数的简洁方式。箭头函数的一个关键特性是它在词法上绑定了 this 值,这意味着它从封闭范围获取 this 值。

语法

箭头函数使用 => 语法代替 function 关键字。基本语法是:

const myFunction = (arg1, arg2, ...argN) => {
// function body
};

如果函数体只有一个表达式,则可以省略大括号和 return 关键字:

const myFunction = (arg1, arg2, ...argN) => expression;

例子

// 带有参数的箭头函数
const multiply = (x, y) => x * y;
console.log(multiply(2, 3)); // 输出:6
// 没有参数的箭头函数
const sayHello = () => 'Hello, World!';
console.log(sayHello()); // 输出:'Hello, World!'

优点

  • 简洁: 箭头函数提供更简洁的语法,尤其适用于短函数。
  • 隐式返回: 它们对单行函数具有隐式返回。
  • this 的值是可预测的: 箭头函数在词法上绑定 this 值,从封闭范围继承它。

局限性

箭头函数不能用作构造函数,并且与 new 关键字一起使用时会抛出错误。

const Foo = () => {};
const foo = new Foo(); // TypeError: Foo is not a constructor

它们也没有 arguments 关键字;参数必须通过在参数中使用 rest 运算符 (...) 来获取。

const arrowFunction = (...args) => {
console.log(arguments); // Throws a ReferenceError
console.log(args); // [1, 2, 3]
};
arrowFunction(1, 2, 3);

由于箭头函数没有自己的 this,因此它们不适合在对象中定义方法。 应该使用传统的函数表达式或函数声明。

const obj = {
value: 42,
getValue: () => this.value, // `this` does not refer to `obj`
};
console.log(obj.getValue()); // undefined

为什么箭头函数有用

箭头函数最显著的特征之一是它们与 this 的行为。与常规函数不同,箭头函数没有自己的 this。相反,它们在定义时从父作用域继承 this。这使得箭头函数特别适用于事件处理程序、回调和类中的方法等场景。

函数构造函数中的箭头函数

const Person = function (name) {
this.firstName = name;
this.sayName1 = function () {
console.log(this.firstName);
};
this.sayName2 = () => {
console.log(this.firstName);
};
};
const john = new Person('John');
const dave = new Person('Dave');
john.sayName1(); // John
john.sayName2(); // John
// 常规函数可以更改其 `this` 值,但箭头函数不能
john.sayName1.call(dave); // Dave (因为 `this` 现在是 dave 对象)
john.sayName2.call(dave); // John
john.sayName1.apply(dave); // Dave (因为 `this` 现在是 dave 对象)
john.sayName2.apply(dave); // John
john.sayName1.bind(dave)(); // Dave (因为 `this` 现在是 dave 对象)
john.sayName2.bind(dave)(); // John
const sayNameFromWindow1 = john.sayName1;
sayNameFromWindow1(); // undefined (因为 `this` 现在是 window 对象)
const sayNameFromWindow2 = john.sayName2;
sayNameFromWindow2(); // John

箭头函数在事件处理程序中

const button = document.getElementById('myButton');
button.addEventListener('click', function () {
console.log(this); // Output: Button
console.log(this === button); // Output: true
});
button.addEventListener('click', () => {
console.log(this); // Output: Window
console.log(this === window); // Output: true
});

这在 React 类组件中特别有用。 如果您使用普通函数定义一个类方法,例如单击处理程序,然后将该单击处理程序作为 prop 传递到子组件中,则还需要在父组件的构造函数中绑定 this。 如果您改为使用箭头函数,则无需绑定 this,因为该方法将自动从其封闭的词法上下文中获取其 this 值。 请参阅此 文章 以获得出色的演示和示例代码。

延伸阅读

解释 JavaScript 中原型继承的工作原理

主题
JavaScriptOOP

TL;DR

JavaScript 中的原型继承是对象从其他对象继承属性和方法的一种方式。每个 JavaScript 对象都有一个名为 [[Prototype]] 的特殊隐藏属性(通常通过 __proto__ 或使用 Object.getPrototypeOf() 访问),它引用另一个对象,该对象被称为对象的“原型”。

当访问对象的属性,并且在该对象上找不到该属性时,JavaScript 引擎会查看对象的 __proto__,以及 __proto____proto__,依此类推,直到它在其中一个 __proto__ 上找到定义的属性,或者直到它到达原型链的末尾。

这种行为模拟了经典继承,但实际上它更像是委托而不是继承

以下是原型继承的示例:

// Parent object constructor.
function Animal(name) {
this.name = name;
}
// Add a method to the parent object's prototype.
Animal.prototype.makeSound = function () {
console.log('The ' + this.constructor.name + ' makes a sound.');
};
// Child object constructor.
function Dog(name) {
Animal.call(this, name); // Call the parent constructor.
}
// Set the child object's prototype to be the parent's prototype.
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
// Add a method to the child object's prototype.
Dog.prototype.bark = function () {
console.log('Woof!');
};
// Create a new instance of Dog.
const bolt = new Dog('Bolt');
// Call methods on the child object.
console.log(bolt.name); // "Bolt"
bolt.makeSound(); // "The Dog makes a sound."
bolt.bark(); // "Woof!"

需要注意的是:

  • .makeSound 未在 Dog 上定义,因此 JavaScript 引擎会向上查找原型链,并在继承的 Animal 上找到 .makeSound
  • 不再推荐使用 Object.create() 来构建继承链。请改用 Object.setPrototypeOf()

Javascript 中的原型继承

原型继承是 JavaScript 中用于创建从其他对象继承属性和方法对象的特性。JavaScript 使用基于原型的模型,而不是基于类的继承模型,对象可以直接从其他对象继承。

关键概念

  1. 原型:JavaScript 中的每个对象都有一个原型,它也是一个对象。当您使用对象字面量或构造函数创建对象时,新对象将链接到其构造函数的原型,如果未指定原型,则链接到 Object.prototype。这通常使用 __proto__[[Prototype]] 引用。您还可以使用内置方法 Object.getPrototypeOf() 获取原型,并且可以通过 Object.setPrototypeOf() 设置对象的原型。
// Define a constructor function
function Person(name, age) {
this.name = name;
this.age = age;
}
// Add a method to the prototype
Person.prototype.sayHello = function () {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
};
// Create a new object using the constructor function
let john = new Person('John', 30);
// The new object has access to the methods defined on the prototype
john.sayHello(); // "Hello, my name is John and I am 30 years old."
// The prototype of the new object is the prototype of the constructor function
console.log(john.__proto__ === Person.prototype); // true
// You can also get the prototype using Object.getPrototypeOf()
console.log(Object.getPrototypeOf(john) === Person.prototype); // true
// You can set the prototype of an object using Object.setPrototypeOf()
let newProto = {
sayGoodbye: function () {
console.log(`Goodbye, my name is ${this.name}`);
},
};
Object.setPrototypeOf(john, newProto);
// Now john has access to the methods defined on the new prototype
john.sayGoodbye(); // "Goodbye, my name is John"
// But no longer has access to the methods defined on the old prototype
console.log(john.sayHello); // undefined
  1. 原型链:当访问对象的属性或方法时,JavaScript 首先在对象本身上查找它。如果找不到,它会查看对象的原型,然后是原型的原型,依此类推,直到找到该属性或到达链的末尾(即 null)。

  2. 构造函数:JavaScript 提供了构造函数来创建对象。当一个函数与 new 关键字一起用作构造函数时,新对象的原型 ([[Prototype]]) 将设置为构造函数 的原型属性。

// 定义一个构造函数
function Animal(name) {
this.name = name;
}
// 将一个方法添加到原型上
Animal.prototype.sayName = function () {
console.log(`我的名字是 ${this.name}`);
};
// 定义一个新的构造函数
function Dog(name, breed) {
Animal.call(this, name);
this.breed = breed;
}
// 设置 Dog 的原型以继承自 Animal 的原型
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
// 将一个方法添加到 Dog 的原型上
Dog.prototype.bark = function () {
console.log('汪!');
};
// 使用 Dog 构造函数创建一个新对象
let fido = new Dog('Fido', 'Labrador');
// 新对象可以访问在其自身原型和 Animal 原型上定义的方法
fido.bark(); // "汪!"
fido.sayName(); // "我的名字是 Fido"
// 如果我们尝试访问 Dog 原型或 Animal 原型上不存在的方法,JavaScript 将返回 undefined
console.log(fido.fly); // undefined
  1. Object.create(): 此方法创建一个新对象,其内部 [[Prototype]] 直接链接到指定的原型对象。这是创建从另一个特定对象继承的对象的最直接方法,无需涉及构造函数。如果通过 Object.create(null) 创建一个对象,它将不会从 Object.prototype 继承任何属性。这意味着该对象将没有任何内置属性或方法,如 toString()hasOwnProperty(),
// Define a prototype object
let proto = {
greet: function () {
console.log(`Hello, my name is ${this.name}`);
},
};
// Use `Object.create()` to create a new object with the specified prototype
let person = Object.create(proto);
person.name = 'John';
// The new object has access to the methods defined on the prototype
person.greet(); // "Hello, my name is John"
// Check if the object has a property
console.log(person.hasOwnProperty('name')); // true
// Create an object that does not inherit from Object.prototype
let animal = Object.create(null);
animal.name = 'Rocky';
// The new object does not have any built-in properties or methods
console.log(animal.toString); // undefined
console.log(animal.hasOwnProperty); // undefined
// But you can still add and access custom properties
animal.describe = function () {
console.log(`Name of the animal is ${this.name}`);
};
animal.describe(); // "Name of the animal is Rocky"

资源

JavaScript 中:`function Person(){}`、`const person = Person()` 和 `const person = new Person()` 的区别?

主题
JavaScriptOOP

TL;DR

  • function Person(){}:JavaScript 中的函数声明。它可以作为常规函数或构造函数使用。
  • const person = Person():将 Person 作为常规函数调用,而不是构造函数。如果 Person 旨在用作构造函数,这将导致意外行为。
  • const person = new Person():创建 Person 的新实例,正确利用构造函数来初始化新对象。
方面function Person(){}const person = Person()const person = new Person()
类型函数声明函数调用构造函数调用
用法定义一个函数Person 作为常规函数调用创建 Person 的新实例
实例创建未创建实例未创建实例创建新实例
常见错误N/A误用作构造函数,导致 undefined无(正确使用时)

函数声明

function Person(){} 是 JavaScript 中的标准函数声明。当用 PascalCase 编写时,它遵循旨在用作构造函数的函数的约定。

function Person(name) {
this.name = name;
}

此代码定义了一个名为 Person 的函数,该函数接受一个参数 name 并将其分配给从此构造函数创建的对象的 name 属性。当在构造函数中使用 this 关键字时,它指的是新创建的对象。

函数调用

const person = Person() 只是调用该函数的代码。当您将 Person 作为常规函数调用(即,不使用 new 关键字)时,该函数的行为不像构造函数。相反,它执行其代码,如果未指定返回值,则返回 undefined,并将其分配给作为实例的变量。如果该函数旨在用作构造函数,则这样调用是一个常见的错误。

function Person(name) {
this.name = name;
}
const person = Person('John'); // Throws error in strict mode
console.log(person); // undefined
console.log(person.name); // Uncaught TypeError: Cannot read property 'name' of undefined

在这种情况下,Person('John') 不会创建新对象。person 变量被赋值为 undefined,因为 Person 函数没有显式返回值。尝试访问 person.name 会抛出错误,因为 personundefined

构造函数调用

const person = new Person() 使用 new 运算符创建 Person 对象的实例,该实例继承自 Person.prototype。另一种方法是使用 Object.create,例如:Object.create(Person.prototype)Person.call(person, 'John') 初始化对象。

function Person(name) {
this.name = name;
}
const person = new Person('John');
console.log(person); // Person { name: 'John' }
console.log(person.name); // 'John'
// Alternative
const person1 = Object.create(Person.prototype);
Person.call(person1, 'John');
console.log(person1); // Person { name: 'John' }
console.log(person1.name); // 'John'

在这种情况下,new Person('John') 创建一个新对象,Person 中的 this 指的是这个新对象。name 属性在新对象上被正确设置。person 变量被分配了 Person 的新实例,其 name 属性设置为 'John'。对于备用对象创建,Object.create(Person.prototype) 创建一个新对象,并将 Person.prototype 作为其原型。Person.call(person, 'John') 初始化对象,设置 name 属性。

后续问题

  • 函数构造函数和 ES6 类语法之间有什么区别?
  • Object.create 的一些常见用例是什么?

延伸阅读

解释 JavaScript 中 `function foo() {}` 和 `var foo = function() {}` 之间 `foo` 的用法差异

主题
JavaScript

总结

function foo() {} 是一个函数声明,而 var foo = function() {} 是一个函数表达式。 关键区别在于函数声明具有函数体提升,但函数表达式的函数体没有(它们具有与 var 声明的变量相同的提升行为)。

如果尝试在函数表达式声明之前调用它,将会得到一个 Uncaught TypeError: XXX is not a function 错误。

即使在函数声明之前,也可以在封闭范围内调用函数声明。

foo(); // 'FOOOOO'
function foo() {
console.log('FOOOOO');
}

如果函数表达式在声明之前被调用,将会导致错误。

foo(); // Uncaught TypeError: foo is not a function
var foo = function () {
console.log('FOOOOO');
};

另一个关键区别在于函数名称的范围。 函数表达式可以通过在 function 之后和括号之前定义它来命名。 但是,当使用命名函数表达式时,函数名称只能在函数本身内部访问。 尝试在外部访问它将导致错误或 undefined

const myFunc = function namedFunc() {
console.log(namedFunc); // Works
};
myFunc(); // Runs the function and logs the function reference
console.log(namedFunc); // ReferenceError: namedFunc is not defined

注意:由于历史原因,示例使用 var。 函数表达式可以使用 letconst 定义,关键区别在于这些关键字的提升行为。


函数声明

函数声明是一个使用名称定义函数的语句。 它通常用于声明可以在封闭范围内多次调用的函数。

function foo() {
console.log('FOOOOO');
}
foo(); // 'FOOOOO'

函数表达式

函数表达式是一个定义函数并将其分配给变量的表达式。 当只需要一次或在特定上下文中使用函数时,通常使用它。

var foo = function () {
console.log('FOOOOO');
};
foo(); // 'FOOOOO'

注意:由于历史原因,示例使用 var。 函数表达式可以使用 letconst 定义,关键区别在于这些关键字的提升行为。

关键区别

提升

关键的区别在于函数声明有函数体提升,但函数表达式的函数体没有(它们具有与var声明的变量相同的提升行为)。有关提升的更多说明,请参阅关于提升的测验问题。如果尝试在函数表达式定义之前调用它,将会得到一个Uncaught TypeError: XXX is not a function错误。

函数声明:

foo(); // 'FOOOOO'
function foo() {
console.log('FOOOOO');
}

函数表达式:

foo(); // Uncaught TypeError: foo is not a function
var foo = function () {
console.log('FOOOOO');
};

命名范围

函数表达式可以通过在function之后和括号之前定义它来命名。但是,当使用命名函数表达式时,函数名称只能在函数本身内部访问。尝试在外部访问它将导致undefined,调用它将导致错误。

const myFunc = function namedFunc() {
console.log(namedFunc); // Works
};
myFunc(); // Runs the function and logs the function reference
console.log(namedFunc); // ReferenceError: namedFunc is not defined

何时使用

  • 函数声明:
    • 当您想在全局范围内创建一个函数,并在整个封闭范围内使用它时。
    • 如果一个函数是可重用的,并且需要多次调用。
  • 函数表达式:
    • 如果一个函数只需要一次或在特定上下文中。
    • 用于将函数可用性限制为后续代码,并保持封闭范围的清洁。

总的来说,最好使用函数声明来避免确定是否可以调用函数的精神负担。函数表达式的实际用法非常罕见。

延伸阅读

JavaScript 中匿名函数的典型用例是什么?

主题
JavaScript

TL;DR

Javascript 中的匿名函数是指没有关联名称的函数。它们通常用作其他函数的参数或分配给变量。

const arr = [-1, 0, 5, 6];
// The filter method is passed an anonymous function.
arr.filter((x) => x > 1); // [5, 6]

它们通常用作其他函数的参数,这些函数被称为高阶函数,可以接受函数作为输入并返回函数作为输出。匿名函数可以访问来自外部作用域的变量,这个概念被称为闭包,允许它们“封闭”并记住创建它们的环境。

// Encapsulating Code
(function () {
// Some code here.
})();
// Callbacks
setTimeout(function () {
console.log('Hello world!');
}, 1000);
// Functional programming constructs
const arr = [1, 2, 3];
const double = arr.map(function (el) {
return el * 2;
});
console.log(double); // [2, 4, 6]

匿名函数

匿名函数提供了一种更简洁的定义函数的方式,尤其适用于简单操作或回调。除此之外,它们还可以用于以下场景

立即执行

匿名函数通常用于立即调用函数表达式 (IIFE),以将代码封装在局部作用域内。这可以防止在函数内声明的变量泄漏到全局作用域并污染全局命名空间。

// This is an IIFE
(function () {
var x = 10;
console.log(x); // 10
})();
// x is not accessible here
console.log(typeof x); // undefined

在上面的例子中,IIFE 为变量 x 创建了一个局部作用域。因此,x 无法在 IIFE 之外访问,从而防止其泄漏到全局作用域。

回调

匿名函数可以用作仅使用一次且不需要在其他任何地方使用的回调。当处理程序在调用它们的代码内部定义时,代码看起来会更自包含且更具可读性,而不是必须在其他地方搜索以找到函数体。

setTimeout(() => {
console.log('Hello world!');
}, 1000);

高阶函数

它用作函数式编程构造(如高阶函数或 Lodash(类似于回调))的参数。高阶函数将其他函数作为参数或将它们作为结果返回。匿名函数通常与高阶函数(如 map()filter()reduce())一起使用。

const arr = [1, 2, 3];
const double = arr.map((el) => {
return el * 2;
});
console.log(double); // [2, 4, 6]

事件处理

在 React 中,匿名函数被广泛用于定义内联回调函数,用于处理事件和将回调作为 props 传递。

function App() {
return <button onClick={() => console.log('Clicked!')}>Click Me</button>;
}

后续问题

  • 你能解释一下箭头函数和匿名函数的区别吗?

JavaScript 中创建对象的各种方法是什么?

主题
JavaScript

TL;DR

在 JavaScript 中创建对象提供了几种方法:

  • 对象字面量 ({}):最简单、最流行的方法。在花括号内定义键值对。
  • Object() 构造函数:使用 new Object() 和点表示法添加属性。
  • Object.create():使用现有对象作为原型创建新对象,继承属性和方法。
  • 构造函数:使用函数定义对象的蓝图,使用 new 创建实例。
  • ES2015 类:类似于其他语言的结构化语法,使用 classconstructor 关键字。

JavaScript 中的对象

在 JavaScript 中创建对象涉及几种方法。以下是在 JavaScript 中创建对象的各种方法:

对象字面量 ({})

这是在 JavaScript 中创建对象的最简单、最流行的方法。它涉及在花括号 ({}) 中定义键值对的集合。当您需要创建一个具有固定属性集的单个对象时,可以使用它。

const person = {
firstName: 'John',
lastName: 'Doe',
age: 50,
eyeColor: 'blue',
};
console.log(person); // {firstName: "John", lastName: "Doe", age: 50, eyeColor: "blue"}

Object() 构造函数

此方法涉及将 new 关键字与内置的 Object 构造函数一起使用以创建对象。然后,您可以使用点表示法将属性添加到对象。当您需要从原始值创建对象或创建空对象时,可以使用它。

const person = new Object();
person.firstName = 'John';
person.lastName = 'Doe';
console.log(person); // {firstName: "John", lastName: "Doe"}

Object.create() 方法

此方法允许您使用现有对象作为原型来创建新对象。新对象从原型对象继承属性和方法。当您需要创建一个具有特定原型的新对象时,可以使用它。

// Object.create() 方法
const personPrototype = {
greet() {
console.log(
`Hello, my name is ${this.name} and I'm ${this.age} years old.`,
);
},
};
const person = Object.create(personPrototype);
person.name = 'John';
person.age = 30;
person.greet(); // Output: Hello, my name is John and I'm 30 years old.

可以通过执行 Object.create(null) 来创建没有原型的对象。

ES2015 类

类为创建对象提供了更结构化和熟悉的语法(类似于其他编程语言)。它们定义了一个蓝图,并使用方法与对象的属性进行交互。当您需要创建具有继承和封装的复杂对象时,可以使用它。

class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet = function () {
console.log(
`Hello, my name is ${this.name} and I'm ${this.age} years old.`,
);
};
}
const person1 = new Person('John', 30);
const person2 = new Person('Alice', 25);
person1.greet(); // Output: Hello, my name is John and I'm 30 years old.
person2.greet(); // Output: Hello, my name is Alice and I'm 25 years old.

构造函数

构造函数用于创建可重用的对象蓝图。它们定义了该类型的所有对象共享的属性和行为。您使用 new 关键字创建对象的实例。当您需要创建具有相似属性和方法的多个对象时,可以使用它。

然而,现在 ES2015 类在现代浏览器中得到了广泛支持,因此使用构造函数创建对象的原因已经不多了。

// Constructor function
function Person(name, age) {
this.name = name;
this.age = age;
this.greet = function () {
console.log(
`Hello, my name is ${this.name} and I'm ${this.age} years old.`,
);
};
}
const person1 = new Person('John', 30);
const person2 = new Person('Alice', 25);
person1.greet(); // Output: Hello, my name is John and I'm 30 years old.
person2.greet(); // Output: Hello, my name is Alice and I'm 25 years old.

延伸阅读

JavaScript 中的闭包是什么?你将如何/为什么要使用它?

主题
闭合JavaScript

TL;DR

在 Kyle Simpson 撰写的 "You Don't Know JS" (YDKJS) 一书中,闭包定义如下:

闭包是指一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行

简单来说,函数可以访问它们在创建时在其作用域中的变量。 这就是我们所说的函数的词法作用域。 闭包是一个函数,即使外部函数执行完毕后,它仍然保留对这些变量的访问权限。 这就像函数记住了它最初的环境。

function outerFunction() {
const outerVar = '我位于 innerFunction 之外';
function innerFunction() {
console.log(outerVar); // `innerFunction` 仍然可以访问 `outerVar`。
}
return innerFunction;
}
const inner = outerFunction(); // `inner` 现在持有对 `innerFunction` 的引用。
inner(); // "我位于 innerFunction 之外"
// 即使 `outerFunction` 已经执行完毕,`inner` 仍然可以访问在 `outerFunction` 内部定义的变量。

要记住的关键点:

  • 当内部函数可以访问其外部(词法)作用域中的变量时,就会发生闭包,即使外部函数已经执行完毕。
  • 闭包允许一个函数记住它被创建的环境,即使该环境不再存在。
  • 闭包在 JavaScript 中被广泛使用,例如在回调、事件处理程序和异步函数中。

了解 JavaScript 闭包

在 JavaScript 中,闭包是一个捕获其声明的词法作用域的函数,允许它访问和操作来自外部作用域的变量,即使该作用域已关闭。

以下是闭包的工作原理:

  1. 词法作用域:JavaScript 使用词法作用域,这意味着函数对变量的访问由其在源代码中的实际位置决定。
  2. 函数创建:当创建一个函数时,它会保留对其词法作用域的引用。 此作用域包含在创建闭包时在其作用域中的所有局部变量。
  3. 维护状态:闭包通常用于以安全的方式维护状态,因为闭包捕获的变量在函数外部不可访问。

ES6 语法和闭包

使用 ES6,可以使用箭头函数创建闭包,这提供了更简洁的语法并以词法方式绑定 this 值。 这是一个例子:

const createCounter = () => {
let count = 0;
return () => {
count += 1;
return count;
};
};
const counter = createCounter();
console.log(counter()); // 输出:1
console.log(counter()); // 输出:2

React 中的闭包

闭包无处不在。 下面的代码显示了一个在单击按钮时增加计数器的简单示例。 在此代码中,handleClick 形成一个闭包。 它可以访问其外部作用域变量 countsetCount

import React, { useState } from 'react';
function Counter() {
// 使用 useState 钩子定义一个状态变量
const [count, setCount] = useState(0);
// 此 handleClick 函数是一个闭包
function handleClick() {
// 它可以访问 'count' 状态变量
setCount(count + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
function App() {
return (
<div>
<h1>Counter App</h1>
<Counter />
</div>
);
}
export default App;

为什么使用闭包?

使用闭包提供以下好处:

  1. 数据封装:闭包提供了一种创建私有变量和函数的方法,这些变量和函数无法从闭包外部访问。这对于隐藏实现细节并以封装的方式维护状态非常有用。
  2. 函数式编程:闭包是函数式编程范例的基础,它们用于创建可以传递和稍后调用的函数,保留对创建它们的范围的访问权限,例如 部分应用或柯里化
  3. 事件处理程序和回调:在 JavaScript 中,闭包通常用于事件处理程序和回调,以维护状态或访问在定义处理程序或回调时作用域中的变量。
  4. 模块模式:闭包在 JavaScript 中启用 模块模式,允许创建具有私有和公共部分的模块。

延伸阅读

JavaScript 中高阶函数的定义是什么?

主题
JavaScript

TL;DR

高阶函数是指将一个或多个函数作为参数的函数,它使用这些函数对某些数据进行操作,和/或返回一个函数作为结果。

高阶函数旨在抽象一些重复执行的操作。 典型的例子是 Array.prototype.map(),它接受一个数组和一个函数作为参数。 然后,Array.prototype.map() 使用此函数转换数组中的每个项目,返回一个包含转换后的数据的新数组。 JavaScript 中其他常见的例子是 Array.prototype.forEach()Array.prototype.filter()Array.prototype.reduce()。 高阶函数不仅仅需要操作数组,因为从另一个函数返回一个函数有很多用例。 Function.prototype.bind() 就是一个返回另一个函数的例子。

想象一下,我们有一个需要转换为大写的名称数组。 命令式方法如下:

const names = ['irish', 'daisy', 'anna'];
function transformNamesToUppercase(names) {
const results = [];
for (let i = 0; i < names.length; i++) {
results.push(names[i].toUpperCase());
}
return results;
}
console.log(transformNamesToUppercase(names)); // ['IRISH', 'DAISY', 'ANNA']

使用 Array.prototype.map(transformerFn) 使代码更短、更具声明性。

const names = ['irish', 'daisy', 'anna'];
function transformNamesToUppercase(names) {
return names.map((name) => name.toUpperCase());
}
console.log(transformNamesToUppercase(names)); // ['IRISH', 'DAISY', 'ANNA']

高阶函数

高阶函数是一个将另一个函数作为参数或返回一个函数作为其结果的函数。

函数作为参数

高阶函数可以将另一个函数作为参数并执行它。

function greet(name) {
return `Hello, ${name}!`;
}
function greetName(greeter, name) {
console.log(greeter(name));
}
greetName(greet, 'Alice'); // Output: Hello, Alice!

在这个例子中,greetName函数是高阶函数,因为它将另一个函数(greet)作为参数,并使用它为给定的名称生成问候语。

函数作为返回值

高阶函数可以返回另一个函数。

function multiplier(factor) {
return function (num) {
return num * factor;
};
}
const double = multiplier(2);
const triple = multiplier(3);
console.log(double(5)); // Output: 10
console.log(triple(5)); // Output: 15

在此示例中,multiplier 函数返回一个新函数,该函数将任何数字乘以指定的因子。 返回的函数是一个闭包,它记住外部函数中的 factor 值。 multiplier 函数是一个高阶函数,因为它返回另一个函数。

实际例子

  1. 日志装饰器:一个高阶函数,它向另一个函数添加日志记录功能:
function withLogging(fn) {
return function (...args) {
console.log(`Calling ${fn.name} with arguments`, args);
return fn.apply(this, args);
};
}
function add(a, b) {
return a + b;
}
const loggedAdd = withLogging(add);
console.log(loggedAdd(2, 3));
// Output:
// Calling add with arguments [2, 3]
// 5

withLogging 函数是一个高阶函数,它接受一个函数 fn 作为参数,并返回一个新函数,该函数在执行原始函数之前记录函数调用

  1. 记忆化:一个高阶函数,它缓存函数的计算结果以避免冗余计算:
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const memoizedFibonacci = memoize(fibonacci);
console.log(memoizedFibonacci(10)); // Output: 55

memoize 函数是一个高阶函数,它接受一个函数 fn 作为参数,并返回一个新函数,该函数根据原始函数的参数缓存其结果。

  1. Lodash:Lodash 是一个实用程序库,它提供了广泛的函数,用于处理数组、对象、字符串等,其中大多数是高阶函数。
import _ from 'lodash';
const numbers = [1, 2, 3, 4, 5];
// Filter array
const evenNumbers = _.filter(numbers, (n) => n % 2 === 0); // [2, 4]
// Map array
const doubledNumbers = _.map(numbers, (n) => n * 2); // [2, 4, 6, 8, 10]
// Find the maximum value
const maxValue = _.max(numbers); // 5
// Sum all values
const sum = _.sum(numbers); // 15

延伸阅读

JavaScript ES2015 类和 ES5 函数构造函数之间有什么区别?

主题
JavaScriptOOP

TL;DR

ES2015 引入了一种创建类的新方法,与 ES5 函数构造函数语法相比,它提供了一种更直观、更简洁的方式来定义和使用对象和继承。以下是每个的示例:

// ES5 function constructor
function Person(name) {
this.name = name;
}
// ES2015 Class
class Person {
constructor(name) {
this.name = name;
}
}

对于简单的构造函数,它们看起来非常相似。构造函数的主要区别在于使用继承时。如果我们想创建一个 Student 类,该类是 Person 的子类,并添加一个 studentId 字段,这就是我们必须做的。

// ES5 inheritance
// Superclass
function Person1(name) {
this.name = name;
}
// Subclass
function Student1(name, studentId) {
// Call constructor of superclass to initialize superclass-derived members.
Person1.call(this, name);
// Initialize subclass's own members.
this.studentId = studentId;
}
Student1.prototype = Object.create(Person1.prototype);
Student1.prototype.constructor = Student1;
const student1 = new Student1('John', 1234);
console.log(student1.name, student1.studentId); // "John" 1234
// ES2015 inheritance
// Superclass
class Person2 {
constructor(name) {
this.name = name;
}
}
// Subclass
class Student2 extends Person2 {
constructor(name, studentId) {
super(name);
this.studentId = studentId;
}
}
const student2 = new Student2('Alice', 5678);
console.log(student2.name, student2.studentId); // "Alice" 5678

在 ES5 中使用继承要冗长得多,而 ES2015 版本更容易理解和记忆。

ES5 函数构造函数与 ES2015 类的比较

特性ES5 函数构造函数ES2015 类
语法使用函数构造函数和原型使用 class 关键字
构造函数使用 this 分配属性的函数类中的 constructor 方法
方法定义在原型上定义在类主体内定义
静态方法直接添加到构造函数使用 static 关键字定义
继承使用 Object.create() 并手动设置原型链使用 extends 关键字和 super 函数
可读性不太直观,更冗长更简洁直观

ES5 函数构造函数 vs ES2015 类

ES5 函数构造函数和 ES2015 类是 JavaScript 中定义类的两种不同方式。它们都服务于相同的目的,但它们具有不同的语法和行为。

ES5 函数构造函数

在 ES5 中,您可以使用函数构造函数和原型定义类似类的结构。这是一个例子:

// ES5 function constructor
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function () {
console.log(
'Hello, my name is ' + this.name + ' and I am ' + this.age + ' years old.',
);
};
// Creating an instance
var person1 = new Person('John', 30);
person1.greet(); // Hello, my name is John and I am 30 years old.

ES2015 类

ES2015 引入了 class 语法,它简化了类的定义,并支持更多功能,例如静态方法和子类化。以下是使用 ES2015 的相同示例:

// ES2015 Class
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(
`Hello, my name is ${this.name} and I am ${this.age} years old.`,
);
}
}
// Creating an instance
const person1 = new Person('John', 30);
person1.greet(); // Hello, my name is John and I am 30 years old.

主要区别

  1. 语法和可读性

    • ES5:使用函数构造函数和原型,这不太直观,也更难阅读。
    • ES2015:使用 class 关键字,使代码更简洁,更容易理解。
  2. 静态方法

    • ES5:静态方法直接添加到构造函数中。
    • ES2015:静态方法使用 static 关键字在类中定义。
    // ES5
    function Person1(name, age) {
    this.name = name;
    this.age = age;
    }
    Person1.sayHi = function () {
    console.log('Hi from ES5!');
    };
    Person1.sayHi(); // Hi from ES5!
    // ES2015
    class Person2 {
    static sayHi() {
    console.log('Hi from ES2015!');
    }
    }
    Person2.sayHi(); // Hi from ES2015!
  3. 继承

    • ES5:继承是使用 Object.create() 并手动设置原型链来实现的。
    • ES2015:使用 extends 关键字,继承更加简单直观。
    // ES5 Inheritance
    // ES5 function constructor
    function Person1(name, age) {
    this.name = name;
    this.age = age;
    }
    Person1.prototype.greet = function () {
    console.log(
    `Hello, my name is ${this.name} and I am ${this.age} years old.`,
    );
    };
    function Student1(name, age, grade) {
    Person1.call(this, name, age);
    this.grade = grade;
    }
    Student1.prototype = Object.create(Person1.prototype);
    Student1.prototype.constructor = Student1;
    Student1.prototype.study = function () {
    console.log(this.name + ' is studying.');
    };
    var student1 = new Student1('John', 22, 'B+');
    student1.greet(); // Hello, my name is John and I am 22 years old.
    student1.study(); // John is studying.
    // ES2015 Inheritance
    // ES2015 Class
    class Person2 {
    constructor(name, age) {
    this.name = name;
    this.age = age;
    }
    greet() {
    console.log(
    `Hello, my name is ${this.name} and I am ${this.age} years old.`,
    );
    }
    }
    class Student2 extends Person2 {
    constructor(name, age, grade) {
    super(name, age);
    this.grade = grade;
    }
    study() {
    console.log(`${this.name} is studying.`);
    }
    }
    const student2 = new Student2('Alice', 20, 'A');
    student2.greet(); // Hello, my name is Alice and I am 20 years old.
    student2.study(); // Alice is studying.
  4. super 调用:

    • ES5:手动调用父构造函数。
    • ES2015:使用 super 关键字调用父类的构造函数和方法。

结论

虽然 ES5 和 ES2015 方法都可以实现相同的功能,但 ES2015 类提供了一种更清晰、更简洁的方式来定义和使用 JavaScript 中的面向对象结构,这使得代码更容易编写、阅读和维护。 如果您正在使用现代 JavaScript,通常建议使用 ES2015 类而不是 ES5 函数构造函数。

资源

描述 JavaScript 和浏览器的事件冒泡

主题
Web APIJavaScript

TL;DR

事件冒泡是一种 DOM 事件传播机制,其中一个事件(例如点击)从目标元素开始,冒泡到文档的根。这允许祖先元素也响应事件。

事件冒泡对于事件委托至关重要,其中单个事件处理程序管理多个子元素的事件,从而提高性能和代码简洁性。虽然方便,但未能正确管理事件传播可能导致意外行为,例如多个处理程序为单个事件触发。


什么是事件冒泡?

事件冒泡是 DOM(文档对象模型)中的一种传播机制,其中一个事件(例如点击或键盘事件)首先在触发事件的目标元素上触发,然后向上(冒泡)通过 DOM 树传播到文档的根。

注意:甚至在事件冒泡阶段发生之前是 事件捕获 阶段,该阶段与冒泡相反,事件从文档根向下到目标元素。

冒泡阶段

在冒泡阶段,事件从目标元素开始,通过其在 DOM 层次结构中的祖先元素冒泡。这意味着附加到目标元素及其祖先的事件处理程序都可能接收并响应事件。

这里有一个使用现代 ES6 语法的示例,用于演示事件冒泡:

// HTML:
// <div id="parent">
// <button id="child">Click me!</button>
// </div>
const parentDiv = document.createElement('div');
parentDiv.id = 'parent';
const button = document.createElement('button');
button.id = 'child';
parentDiv.appendChild(button);
document.body.appendChild(parentDiv);
const parent = document.getElementById('parent');
const child = document.getElementById('child');
parent.addEventListener('click', () => {
console.log('Parent element clicked');
});
child.addEventListener('click', () => {
console.log('Child element clicked');
});
// Simulate clicking the button:
child.click();

当您点击“Click me!”按钮时,由于事件冒泡,子元素和父元素的事件处理程序都将被触发。

停止冒泡

可以使用 stopPropagation() 方法在冒泡阶段停止事件冒泡。如果事件处理程序调用 stopPropagation(),它会阻止事件进一步冒泡到 DOM 树中,确保仅执行层次结构中该点之前的元素的处理程序。

// HTML:
// <div id="parent">
// <button id="child">Click me!</button>
// </div>
const parentDiv = document.createElement('div');
parentDiv.id = 'parent';
const button = document.createElement('button');
button.id = 'child';
parentDiv.appendChild(button);
document.body.appendChild(parentDiv);
const parent = document.getElementById('parent');
const child = document.getElementById('child');
parent.addEventListener('click', () => {
console.log('Parent element clicked');
});
child.addEventListener('click', (event) => {
console.log('Child element clicked');
event.stopPropagation(); // Stops propagation to parent
});
// Simulate clicking the button:
child.click();

事件委托

事件冒泡是事件委托技术的基础,您可以在多个元素的公共祖先上附加单个事件处理程序,并使用事件委托来有效地处理这些元素的事件。当您有大量相似的元素(如项目列表)并且希望避免将单独的事件处理程序附加到每个项目时,这特别有用。

parent.addEventListener('click', (event) => {
if (event.target && event.target.id === 'child') {
console.log('Child element clicked');
}
});

优点

  • 更简洁的代码: 减少事件监听器的数量,提高代码可读性和可维护性。
  • 高效的事件处理: 通过附加更少的监听器,最大限度地减少性能开销。
  • 灵活性: 允许处理子元素上发生的事件,而无需直接将监听器附加到它们。

陷阱

  • 意外的事件处理: 请注意,父元素可能会无意中捕获子元素发生的事件。使用 event.target 来识别触发事件的特定元素。
  • 事件顺序: 事件以特定顺序冒泡。如果多个父元素有事件监听器,它们的执行顺序取决于 DOM 层次结构。
  • 过度委托: 虽然将事件委托给公共祖先是有效的,但在 DOM 树中附加一个过高的监听器可能会捕获意外的事件。

用例

以下是一些使用事件冒泡编写更好代码的实用方法。

使用事件委托减少代码

想象一下,您有一个产品列表,其中包含许多项目,每个项目都有一个“立即购买”按钮。 传统上,您可以将单独的点击事件监听器附加到每个按钮:

// HTML:
// <ul id="product-list">
// <li><button id="item1-buy">立即购买</button></li>
// <li><button id="item2-buy">立即购买</button></li>
// </ul>
const item1Buy = document.getElementById('item1-buy');
const item2Buy = document.getElementById('item2-buy');
item1Buy.addEventListener('click', handleBuyClick);
item2Buy.addEventListener('click', handleBuyClick);
// ... 对每个项目重复 ...
function handleBuyClick(event) {
console.log('点击了项目的购买按钮:', event.target.id);
}

随着项目数量的增加,这种方法变得很麻烦。 以下是事件冒泡如何简化事情的方法:

// HTML:
// <ul id="product-list">
// <li><button id="item1-buy">Buy Now</button></li>
// <li><button id="item2-buy">Buy Now</button></li>
// </ul>
const productList = document.getElementById('product-list');
productList.addEventListener('click', handleBuyClick);
function handleBuyClick(event) {
// Check if the clicked element is a button within the list
if (event.target.tagName.toLowerCase() === 'button') {
console.log('Buy button clicked for item:', event.target.id);
}
}

通过将监听器附加到父元素 (productList) 并在处理程序中检查被点击的元素 (event.target),您可以用更少的代码实现相同的功能。 当项目是动态的时,这种方法可以很好地扩展,因为当项目列表发生变化时,不需要添加或删除新的事件处理程序。

下拉菜单

考虑一个下拉菜单,点击菜单元素(父元素)的任何地方都应该关闭它。使用事件冒泡,您可以使用一个监听器来实现这一点:

// HTML:
// <div id="dropdown">
// <button>打开菜单</button>
// <ul>
// <li>项目 1</li>
// <li>项目 2</li>
// </ul>
// </div>
const dropdown = document.getElementById('dropdown');
dropdown.addEventListener('click', handleDropdownClick);
function handleDropdownClick(event) {
// 如果在按钮外部单击,则关闭下拉菜单
if (event.target !== dropdown.querySelector('button')) {
console.log('下拉菜单已关闭');
// 隐藏下拉菜单内容的逻辑
}
}

在这里,点击事件从被点击的元素(按钮或列表项)冒泡到 dropdown 元素。 处理程序检查被点击的元素是否不是 <button>,并相应地关闭菜单。

手风琴菜单

想象一个手风琴菜单,点击一个部分标题(父级)会展开或折叠其下方的部分内容(子级)。 事件冒泡使这变得简单:

// HTML:
// <div class="accordion">
// <div class="header">第 1 节</div>
// <div class="content">第 1 节的内容</div>
// <div class="header">第 2 节</div>
// <div class="content">第 2 节的内容</div>
// </div>
const accordion = document.querySelector('.accordion');
accordion.addEventListener('click', handleAccordionClick);
function handleAccordionClick(event) {
// 检查点击的元素是否是标题
if (event.target.classList.contains('header')) {
const content = event.target.nextElementSibling;
content.classList.toggle('active'); // 切换内容的显示
}
}

通过将监听器附加到 accordion 元素,单击任何标题都会触发该事件。 处理程序检查被点击的元素是否是标题,并切换相应内容部分的可见性。

延伸阅读

描述 JavaScript 和浏览器中的事件捕获

主题
Web APIJavaScript

TL;DR

事件捕获是 DOM 事件传播机制中较少使用的 事件冒泡 的对应部分。它遵循相反的顺序,事件首先在祖先元素上触发,然后向下传递到目标元素。

与事件冒泡相比,事件捕获很少使用,但它可用于特定场景,例如需要在事件到达目标元素之前拦截更高级别的事件。默认情况下,它是禁用的,但可以通过 addEventListener() 上的一个选项来启用。


什么是事件捕获?

事件捕获是 DOM(文档对象模型)中的一种传播机制,其中事件(例如单击或键盘事件)首先在文档的根处触发,然后通过 DOM 树向下流向目标元素。

捕获的优先级高于冒泡,这意味着捕获事件处理程序在冒泡事件处理程序之前执行,如事件传播的各个阶段所示:

  • 捕获阶段:事件向下移动到目标元素
  • 目标阶段:事件到达目标元素
  • 冒泡阶段:事件从目标元素冒泡

请注意,默认情况下禁用事件捕获。要启用它,您必须将捕获选项传递到 addEventListener()

捕获阶段

在捕获阶段,事件从文档根开始并向下传播到目标元素。此路径上任何祖先元素上的任何事件侦听器都将在目标元素的处理程序之前触发。但请注意,除非将 addEventListener() 的第三个参数设置为 true,否则事件捕获不会发生,如下所示(默认值为 false)。

以下是使用现代 ES2015 语法的示例,用于演示事件捕获:

// HTML:
// <div id="parent">
// <button id="child">Click me!</button>
// </div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');
parent.addEventListener(
'click',
() => {
console.log('Parent element clicked (capturing)');
},
true, // Set third argument to true for capturing
);
child.addEventListener('click', () => {
console.log('Child element clicked');
});

当您单击“Click me!”按钮时,它将首先触发父元素的捕获处理程序,然后触发子元素的处理程序。

停止传播

可以使用 stopPropagation() 方法在捕获阶段停止事件传播。这可以防止事件进一步向下传递 DOM 树。

// HTML:
// <div id="parent">
// <button id="child">Click me!</button>
// </div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');
parent.addEventListener(
'click',
(event) => {
console.log('Parent element clicked (capturing)');
event.stopPropagation(); // Stop event propagation
},
true,
);
child.addEventListener('click', () => {
console.log('Child element clicked');
});

由于停止了事件传播,当您单击“Click Me!”按钮时,现在将仅调用 parent 事件侦听器,并且永远不会调用 child 事件侦听器,因为事件传播已在 parent 元素处停止。

事件捕获的用途

与事件冒泡相比,事件捕获很少使用,但它可以在特定情况下使用,例如需要在事件到达目标元素之前在高层拦截事件。

  • 阻止事件冒泡: 想象一下,您有一个嵌套元素(如按钮)位于容器元素内。单击该按钮也可能触发容器上的单击事件。通过在容器的事件侦听器上启用事件捕获,您可以在那里捕获单击事件,并阻止其向下传播到按钮,从而可能导致意外行为。
  • 自定义下拉菜单: 在构建自定义下拉菜单时,您可能希望捕获菜单元素之外的点击以关闭菜单。在 document 对象上使用 capture: true 允许您侦听页面上的任何位置的点击,并在点击发生在菜单边界之外时关闭菜单。
  • 在某些情况下提高效率: 在某些情况下,事件捕获可能比依赖冒泡更有效。这是因为事件不需要在到达处理程序之前通过所有子元素传播。但是,对于大多数 Web 应用程序来说,性能差异通常可以忽略不计。

延伸阅读

JavaScript 和浏览器中 `mouseenter` 和 `mouseover` 事件有什么区别?

主题
Web APIHTMLJavaScript

TL;DR

主要区别在于 mouseentermouseover 事件的冒泡行为。mouseenter 不冒泡,而 mouseover 冒泡。

mouseenter 事件不冒泡。mouseenter 事件仅在鼠标指针进入元素本身时触发,而不是其后代元素。如果父元素有子元素,并且鼠标指针进入子元素,则不会再次在父元素上触发 mouseenter 事件,它仅在进入父元素时触发一次,而不考虑其内容。如果父元素和子元素都附加了 mouseenter 侦听器,并且鼠标指针从父元素移动到子元素,则 mouseenter 将仅为子元素触发。

mouseover 事件会在 DOM 树中冒泡。当鼠标指针进入元素或其后代时,会触发 mouseover 事件。如果父元素有子元素,并且鼠标指针进入子元素,则父元素也会再次触发 mouseover 事件。如果父元素有多个子元素,这可能导致多次触发事件回调。如果有子元素,并且鼠标指针从父元素移动到子元素,则 mouseover 将同时为父元素和子元素触发。

属性mouseentermouseover
冒泡
触发仅在进入自身时进入自身和进入后代时

mouseenter 事件:

  • 不冒泡mouseenter 事件不冒泡。它仅在鼠标指针进入附加了事件侦听器的元素时触发,而不是进入任何子元素时触发。
  • 触发一次:当鼠标指针进入元素时,mouseenter 事件仅触发一次,这使得它在某些情况下更可预测且更易于管理。

mouseenter 的一个用例是,当您希望检测鼠标进入元素而无需担心子元素多次触发事件时。

mouseover 事件:

  • 在 DOM 中冒泡mouseover 事件通过 DOM 冒泡。这意味着,如果您在父元素上有一个事件侦听器,当鼠标指针移动到任何子元素上时,它也会触发。
  • 多次触发:每次鼠标指针移动到元素或其任何子元素上时,都会触发 mouseover 事件。如果您有嵌套元素,这可能导致多次触发。

mouseover 的一个用例是,当您希望检测鼠标进入元素或其任何子元素,并且可以接受事件多次触发时。

示例

这是一个演示 mouseovermouseenter 事件之间区别的示例:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mouse Events Example</title>
<style>
.parent {
width: 200px;
height: 200px;
background-color: lightblue;
padding: 20px;
}
.child {
width: 100px;
height: 100px;
background-color: lightcoral;
}
</style>
</head>
<body>
<div class="parent">
Parent Element
<div class="child">Child Element</div>
</div>
<script>
const parent = document.querySelector('.parent');
const child = document.querySelector('.child');
// Mouseover event on parent.
parent.addEventListener('mouseover', () => {
console.log('Mouseover on parent');
});
// Mouseenter event on parent.
parent.addEventListener('mouseenter', () => {
console.log('Mouseenter on parent');
});
// Mouseover event on child.
child.addEventListener('mouseover', () => {
console.log('Mouseover on child');
});
// Mouseenter event on child.
child.addEventListener('mouseenter', () => {
console.log('Mouseenter on child');
});
</script>
</body>
</html>

预期行为

  • 当鼠标进入父元素时:
    • 父元素上的 mouseover 事件将触发。
    • 父元素上的 mouseenter 事件将触发。
  • 当鼠标进入子元素时:
    • 父元素上的 mouseover 事件将再次触发,因为 mouseover 从子元素冒泡。
    • 子元素上的 mouseover 事件将触发。
    • 子元素上的 mouseenter 事件将触发。
    • 父元素上的 mouseenter 事件将不会再次触发,因为 mouseenter 不冒泡。

延伸阅读

JavaScript 中的 `'use strict';` 是什么?

使用它有什么优点和缺点?
主题
JavaScript

TL;DR

'use strict' 是一条语句,用于对整个脚本或单个函数启用严格模式。严格模式是一种选择加入 JavaScript 限制变体的方式。

优点

  • 无法意外创建全局变量。
  • 使原本会静默失败的赋值抛出异常。
  • 使尝试删除不可删除的属性抛出异常(而之前尝试只会无效)。
  • 要求函数参数名称是唯一的。
  • this 在全局上下文中为 undefined
  • 它可以捕获一些常见的编码错误,抛出异常。
  • 它禁用了令人困惑或考虑不周的功能。

缺点

  • 许多开发人员可能习惯的缺失功能。
  • 无法再访问 function.callerfunction.arguments
  • 用不同严格模式编写的脚本的串联可能会导致问题。

总的来说,优点大于缺点,而且实际上没有必要依赖严格模式禁止的功能。我们都应该默认使用严格模式。


JavaScript 中的 "use strict" 是什么?

本质上,"use strict" 是 ECMAScript 5 (ES5) 中引入的一个指令,它向 JavaScript 引擎发出信号,表明它所包围的代码应该在“严格模式”下执行。严格模式施加了更严格的解析和错误处理规则,从本质上使您的代码更安全,更不容易出错。

当您使用“use strict”时,它有助于您编写更简洁的代码,例如阻止您使用未声明的变量。它还可以使您的代码更安全,因为它禁止某些潜在的不安全操作。

如何使用严格模式

  1. 全局范围:要在全局启用严格模式,请在 JavaScript 文件的开头添加指令:

    'use strict';
    // 此文件中的任何代码都将在严格模式下运行
    function add(a, b) {
    return a + b;
    }
  2. 局部范围:要在函数内启用严格模式,请在函数的开头添加指令:

    function myFunction() {
    'use strict';
    // 这将告诉 JavaScript 引擎仅对 `myFunction` 使用严格模式
    // 任何超出此函数范围的内容都将被视为非严格模式,除非指定使用严格模式
    }

严格模式的主要特点

  1. 错误预防:严格模式可防止常见错误,例如:
    • 使用未声明的变量。
    • 将值分配给不可写属性。
    • 使用不存在的属性或变量。
    • 删除不可删除的属性。
    • 将保留关键字用作标识符。
    • 在函数中复制参数名称。
  2. 提高安全性:严格模式通过以下方式帮助编写更安全的代码:
    • 阻止使用已弃用的功能,如 arguments.caller 和 arguments.callee。
    • 限制使用 eval() 以防止在调用范围内声明变量。
  3. 兼容性:严格模式通过阻止将保留关键字用作标识符来确保与未来版本的 JavaScript 的兼容性。

示例

  1. 防止意外创建全局变量:

    // 没有严格模式
    function defineNumber() {
    count = 123;
    }
    defineNumber();
    console.log(count); // 日志:123
    'use strict'; // 使用严格模式
    function strictFunc() {
    'use strict';
    strictVar = 123; // ReferenceError: strictVar is not defined
    }
    strictFunc();
    console.log(strictVar); // ReferenceError: strictVar is not defined
  2. 使原本会静默失败的赋值抛出异常:

    // 没有严格模式
    NaN = 'foo'; // 这会静默失败
    console.log(NaN); // 日志:NaN
    'use strict'; // 使用严格模式
    NaN = 'foo'; // Uncaught TypeError: Cannot assign to read only property 'NaN' of object '#<Window>'
  3. 使尝试删除不可删除的属性在严格模式下抛出错误:

    // 没有严格模式
    delete Object.prototype; // 这会静默失败
    'use strict'; // 使用严格模式
    delete Object.prototype; // TypeError: Cannot delete property 'prototype' of function Object() { [native code] }

真的有必要吗?

在 JavaScript 中添加 'use strict' 仍然是有益的,并且是推荐的,但在所有情况下不再严格需要:

  1. 模块:JavaScript 模块的全部内容会自动进入严格模式,无需 'use strict' 语句。这适用于 ES6 模块以及 Node.js CommonJS 模块。
  2. :类定义中的代码也会自动进入严格模式,即使没有 'use strict'

虽然由于模块和类中的自动严格模式强制执行,'use strict' 不再在所有上下文中是强制性的,但它仍然被广泛推荐作为最佳实践,尤其是在核心 JavaScript 文件、库以及使用较旧的浏览器环境或旧代码时。

注意事项

  1. 放置'use strict' 指令必须放置在文件或函数的开头。将其放置在其他任何地方都不会有任何效果。
  2. 兼容性:除了 Internet Explorer 9 及更低版本外,所有现代浏览器都支持严格模式。
  3. 不可逆:设置 'use strict' 后,无法取消它。

延伸阅读

解释 JavaScript 中同步和异步函数的区别

主题
异步JavaScript

总结

同步函数是阻塞的,而异步函数则不是。在同步函数中,语句在运行下一条语句之前完成。因此,仅包含同步代码的程序将完全按照语句的顺序进行评估。如果其中一条语句花费很长时间,程序的执行将暂停。

function sum(a, b) {
console.log('Inside sum function');
return a + b;
}
const result = sum(2, 3); // The program waits for sum() to complete before assigning the result
console.log('Result: ', result); // Output: 5

异步函数通常接受回调作为参数,并且在调用异步函数后立即继续执行到下一行。仅当异步操作完成且调用堆栈为空时,才会调用回调。诸如从 Web 服务器加载数据或查询数据库之类的繁重操作应异步完成,以便主线程可以继续执行其他操作,而不是阻塞直到该长时间操作完成(对于浏览器,UI 将冻结)。

function fetchData(callback) {
setTimeout(() => {
const data = { name: 'John', age: 30 };
callback(data); // Calling the callback function with data
}, 2000); // Simulating a 2-second delay
}
console.log('Fetching data...');
fetchData((data) => {
console.log(data); // Output: { name: 'John', age: 30 } (after 2 seconds)
});
console.log('Call made to fetch data'); // This will print before the data is fetched

同步函数与异步函数

在 JavaScript 中,同步和异步函数的概念是理解代码执行方式的基础,尤其是在处理 I/O 任务、API 调用和其他耗时进程的上下文中。

同步函数

同步函数按顺序执行,一个接一个。每个操作都必须等待前一个操作完成才能继续进行。

  • 同步代码是阻塞的,这意味着程序执行会暂停,直到当前操作完成。
  • 它遵循严格的顺序,逐行执行指令。
  • 同步函数更容易理解和调试,因为流程是可预测的。

同步函数示例

  1. 同步读取文件:使用 Node.js 中 fs 模块的同步 readFileSync 方法从文件系统读取文件时,程序执行将被阻塞,直到整个文件被读取。这可能会导致性能问题,尤其是在处理大文件或顺序读取多个文件时

    const fs = require('fs');
    const data = fs.readFileSync('large-file.txt', 'utf8');
    console.log(data); // Execution is blocked until the file is read.
    console.log('End of the program');
  2. 循环遍历大型数据集:同步迭代大型数组或数据集可能会冻结用户界面或浏览器选项卡,直到操作完成,导致应用程序无响应。

    const largeArray = new Array(1_000_000).fill(0);
    // Blocks the main thread until the million operations are completed.
    const result = largeArray.map((num) => num * 2);
    console.log(result);

异步函数

异步函数不会阻塞程序的执行。它们允许其他操作继续进行,同时等待响应或完成耗时的任务。

  • 异步代码是非阻塞的,允许程序继续运行,而无需等待特定操作完成。
  • 它支持并发执行,从而提高性能和响应能力。
  • 异步函数通常用于网络请求、文件 I/O 和计时器等任务。

异步函数示例

  1. 网络请求:发出网络请求(例如从 API 获取数据或向服务器发送数据)通常是异步完成的。这使得应用程序在等待响应时保持响应,从而防止用户界面冻结

    console.log('Start of the program'); // This will be printed first as program starts here
    fetch('https://jsonplaceholder.typicode.com/todos/1')
    .then((response) => response.json())
    .then((data) => {
    console.log(data);
    /** Process the data without blocking the main thread
    * and printed at the end if fetch call succeeds
    */
    })
    .catch((error) => console.error(error));
    console.log('End of program'); // This will be printed before the fetch callback
  2. 用户输入和事件:处理用户输入事件(例如单击、按键或鼠标移动)本质上是异步的。应用程序需要响应这些事件,而不会阻塞主线程,从而确保流畅的用户体验。

    const button = document.getElementById('myButton');
    button.addEventListener('click', () => {
    // Handle the click event asynchronously
    console.log('Button clicked');
    });
  3. 计时器和动画:计时器(setTimeout()setInterval())和动画(例如,requestAnimationFrame())是异步操作,允许应用程序调度任务或更新动画,而不会阻塞主线程。

    setTimeout(() => {
    console.log('This message is delayed by 2 seconds');
    }, 2000);
    setInterval(() => {
    console.log('Current time:', new Date().toLocaleString());
    }, 2000); // Interval runs every 2 seconds

通过使用异步函数和操作,JavaScript 可以处理耗时的任务,而不会冻结用户界面或阻塞主线程。

重要的是要注意,async 函数不会在不同的线程上运行。它们仍然在主线程上运行。但是,通过使用 Web workers,可以在 JavaScript 中实现并行性。

通过 Web worker 在 JavaScript 中实现并行性

Web worker 允许您生成单独的后台线程,这些线程可以与主线程并行执行 CPU 密集型任务。这些工作线程可以通过消息传递与主线程通信,但它们无法直接访问 DOM 或其他浏览器 API。

// main.js
const worker = new Worker('worker.js');
worker.onmessage = function (event) {
console.log('Result from worker:', event.data);
};
worker.postMessage('Start computation');
// worker.js
self.onmessage = function (event) {
const result = performHeavyComputation();
self.postMessage(result);
};
function performHeavyComputation() {
// CPU-intensive computation
return 'Computation result';
}

在此示例中,主线程创建一个新的 web worker,并向其发送一条消息以启动计算。worker 与主线程并行执行繁重的计算,并通过 postMessage() 将结果发送回去。

事件循环

JavaScript 的异步特性由 JavaScript 引擎的 事件循环 提供支持,即使 JavaScript 是单线程的,它也允许并发操作。 这是一个重要的概念,需要理解,因此我们强烈建议您也浏览该主题。

延伸阅读

在 JavaScript 中使用 Promise 而不是回调有什么优缺点?

主题
异步JavaScript

TL;DR

Promise 提供了一种比回调更简洁的替代方案,有助于避免回调地狱,并使异步代码更具可读性。它们有助于轻松编写顺序和并行的异步操作。但是,使用 Promise 可能会引入稍微复杂的代码。


优点

避免难以阅读的回调地狱。

回调地狱,也称为“厄运金字塔”,是指在代码中具有多个嵌套回调时发生的现象。这可能导致代码难以阅读、维护和调试。以下是回调地狱的示例:

function getFirstData(callback) {
setTimeout(() => {
callback({ id: 1, title: 'First Data' });
}, 1000);
}
function getSecondData(data, callback) {
setTimeout(() => {
callback({ id: data.id, title: data.title + ' Second Data' });
}, 1000);
}
function getThirdData(data, callback) {
setTimeout(() => {
callback({ id: data.id, title: data.title + ' Third Data' });
}, 1000);
}
// Callback hell
getFirstData((data) => {
getSecondData(data, (data) => {
getThirdData(data, (result) => {
console.log(result); // Output: {id: 1, title: "First Data Second Data Third Data"}
});
});
});

Promise 通过为代码提供更线性和可读的结构来解决回调地狱的问题。

// Example of sequential asynchronous code using setTimeout and Promises
function getFirstData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 1, title: 'First Data' });
}, 1000);
});
}
function getSecondData(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: data.id, title: data.title + ' Second Data' });
}, 1000);
});
}
function getThirdData(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: data.id, title: data.title + ' Third Data' });
}, 1000);
});
}
getFirstData()
.then(getSecondData)
.then(getThirdData)
.then((data) => {
console.log(data); // Output: {id: 1, title: "First Data Second Data Third Data"}
})
.catch((error) => console.error('Error:', error));

使用 .then() 轻松编写可读的顺序异步代码。

在上面的代码示例中,我们使用 .then() 方法将这些 Promise 链接在一起,从而允许代码按顺序执行。它提供了一种更简洁、更易于管理的方式来处理 JavaScript 中的异步操作。

使用 Promise.all() 轻松编写并行异步代码。

Promise.all() 和回调都可以用于编写并行异步代码。但是,Promise.all() 提供了一种更简洁、更易读的方式来处理多个 Promise,尤其是在处理复杂的异步工作流程时。

function getData1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 1, title: 'Data 1' });
}, 1000);
});
}
function getData2() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 2, title: 'Data 2' });
}, 1000);
});
}
function getData3() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 3, title: 'Data 3' });
}, 1000);
});
}
Promise.all([getData1(), getData2(), getData3()])
.then((results) => {
console.log(results); // Output: [{ id: 1, title: 'Data 1' }, { id: 2, title: 'Data 2' }, { id: 3, title: 'Data 3' }]
})
.catch((error) => {
console.error('Error:', error);
});

使用.catch()更容易处理错误,使用.finally()保证清理

Promise 通过允许您在链的末尾使用.catch()捕获错误,而不是在每个回调中手动检查错误,从而使错误处理更加简单。这使得代码更简洁、更易于维护。

此外,.finally() 允许您在 Promise 确定后运行代码,无论它是否成功,这对于清理任务(如隐藏微调器或重置 UI 状态)非常有用。

function getFirstData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: 1, title: 'First Data' });
}, 1000);
});
}
function getSecondData(data) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: data.id, title: data.title + ' -> Second Data' });
}, 1000);
});
}
getFirstData()
.then(getSecondData)
.then((data) => {
console.log('Success:', data);
})
.catch((error) => {
console.error('Error:', error);
})
.finally(() => {
console.log('This runs no matter what');
});

使用 Promise,以下情况不会发生,这些情况存在于仅使用回调的编码中:

  • 过早调用回调
  • 过晚调用回调(或从不调用)
  • 调用回调的次数太少或太多
  • 未能传递任何必要的环境/参数
  • 吞噬可能发生的任何错误/异常

缺点

  • 稍微复杂的代码(有争议)。

实践

Further reading

尽可能详细地解释 AJAX

主题
JavaScript网络

TL;DR

AJAX(异步 JavaScript 和 XML)促进客户端和服务器之间的异步通信,无需重新加载即可实现对网页的动态更新。它使用 XMLHttpRequestfetch() API 等技术在后台发送和接收数据。在现代 Web 应用程序中,fetch() API 更常用于实现 AJAX。

使用 XMLHttpRequest

let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
console.log(xhr.responseText);
} else {
console.error('Request failed: ' + xhr.status);
}
}
};
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);
xhr.send();

使用 fetch()

fetch('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then((data) => console.log(data))
.catch((error) => console.error('Fetch error:', error));

AJAX(异步 JavaScript 和 XML)

AJAX(异步 JavaScript 和 XML)是一组 Web 开发技术,在客户端使用许多 Web 技术来创建异步 Web 应用程序。与每次用户交互都会触发完全页面重新加载的传统 Web 应用程序不同,使用 AJAX,Web 应用程序可以异步地(在后台)向服务器发送数据并从服务器检索数据,而不会干扰现有页面的显示和行为。通过将数据交换层与表示层分离,AJAX 允许网页(以及扩展的 Web 应用程序)动态更改内容,而无需重新加载整个页面。实际上,由于 JSON 具有 JavaScript 原生的优势,现代实现通常使用 JSON 而不是 XML。

传统上,AJAX 是使用 XMLHttpRequest API 实现的,但 fetch() API 更适合现代 Web 应用程序,也更容易使用。

XMLHttpRequest API

以下是其使用方式的一个基本示例:

let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
console.log(xhr.responseText);
} else {
console.error('Request failed: ' + xhr.status);
}
}
};
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);
xhr.send();

fetch() API

或者,fetch() API 提供了一种基于 Promise 的现代方法来发出 AJAX 请求。它在现代 Web 应用程序中更常用。

以下是如何使用它:

fetch('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then((data) => console.log(data))
.catch((error) => console.error('Fetch error:', error));

AJAX 如何工作?

在现代浏览器中,AJAX 使用 fetch() API 而不是 XMLHTTPRequest 完成,因此我们将解释 fetch() API 的工作原理:

  1. 发出请求fetch() 函数启动异步请求,从 URL 获取资源。它接受一个强制性参数——要获取的资源的 URL,并可以选择接受第二个参数——一个 options 对象,该对象允许使用 HTTP 方法、标头、正文等选项配置 HTTP 请求。

    fetch('https://api.example.com/data', {
    method: 'GET', // or 'POST', 'PUT', 'DELETE', etc.
    headers: {
    'Content-Type': 'application/json',
    },
    });
  2. 返回一个 promisefetch() 函数返回一个 Promise,该 Promise 解析为表示来自服务器的响应的 Response 对象。此 Promise 需要使用 .then()async/await 进行处理。

  3. 处理响应Response 对象提供定义如何处理正文内容的方法,例如 .json() 用于解析 JSON 数据,.text() 用于纯文本,.blob() 用于二进制数据等。

    fetch('https://jsonplaceholder.typicode.com/todos/1')
    .then((response) => response.json())
    .then((data) => console.log(data))
    .catch((error) => console.error('Error:', error));
  4. 异步性质 fetch API 是异步的,允许浏览器在等待服务器响应时继续执行其他任务。这可以防止阻塞主线程并提供更好的用户体验。当作为事件循环的一部分执行时,then()catch() 回调被放入微任务队列中。

  5. 请求选项 fetch() 的可选第二个参数允许配置请求的各个方面,例如 HTTP 方法、标头、正文、凭据、缓存行为等。

  6. 错误处理 请求期间的错误(例如网络故障或无效响应)通过使用 .catch() 方法或带有 async/await 的 try/catch 块在 Promise 链中捕获并传播。

fetch() API 提供了一种基于 Promise 的现代方法,用于在 JavaScript 中发出 HTTP 请求,取代了旧的 XMLHttpRequest API。它提供了一种更简单、更灵活的方式来与 API 交互并从服务器获取资源,同时集成了 CORS 等高级 HTTP 概念和其他扩展。

AJAX 的优缺点

虽然 AJAX 很有用,但使用它也有一些需要考虑的地方。阅读更多关于 AJAX 的优缺点

延伸阅读

使用 AJAX 的优缺点是什么?

主题
JavaScript网络

TL;DR

AJAX(异步 JavaScript 和 XML)是 JavaScript 中的一种技术,它允许网页从服务器异步发送和检索数据,而无需刷新或重新加载整个页面。

优点

  • 更流畅的用户体验:更新发生在没有完全页面重新加载的情况下,就像在邮件和聊天应用程序中一样。

缺点

  • 依赖于 JavaScript:如果禁用,Ajax 功能会中断。

AJAX(异步 JavaScript 和 XML)

AJAX(异步 JavaScript 和 XML)是 JavaScript 中的一种技术,它允许网页从服务器异步发送和检索数据,而无需刷新或重新加载整个页面。 当它最初创建时,它彻底改变了 Web 开发,并带来了更流畅、响应更快的用户体验。 AJAX 在这个问题中进行了详细解释。

以下是 AJAX 优缺点的细分:

优点

  • 增强的用户体验:AJAX 允许部分页面更新,而无需完全重新加载。 这为用户创造了更流畅、更灵敏的感觉,因为他们不必等待整个页面在每次交互时刷新。

缺点

  • 增加复杂性:与传统的 Web 开发相比,开发支持 AJAX 的应用程序可能更复杂。 它需要处理异步通信以及请求和响应之间的潜在竞争条件。 由于页面没有重新加载,页面的一部分可能会随着时间的推移而过时,并且可能会造成混淆。

虽然 AJAX 在用户体验、性能和功能方面具有显着的优势,但它也带来了与开发、SEO、浏览器兼容性、安全性和导航相关的复杂性和潜在的缺点。

延伸阅读

JavaScript 和浏览器中 `XMLHttpRequest` 和 `fetch()` 之间有什么区别?

主题
JavaScript网络

TL;DR

XMLHttpRequest (XHR) 和 fetch() API 都用于 JavaScript (AJAX) 中的异步 HTTP 请求。与 XHR 相比,fetch() 提供了更简洁的语法、基于 Promise 的方法和更现代的功能集。但是,存在一些差异:

  • XMLHttpRequest 事件回调,而 fetch() 使用 promise 链。
  • fetch() 在标头和请求体方面提供了更大的灵活性。
  • fetch() 通过 catch() 支持更简洁的错误处理。
  • 使用 XMLHttpRequest 处理缓存很困难,但 fetch() 默认在 options.cache 对象(第二个参数的 cache 值)中支持缓存到 fetch()Request()
  • fetch() 需要 AbortController 来取消,而对于 XMLHttpRequest,它提供了 abort() 属性。
  • XMLHttpRequest 对进度跟踪有很好的支持,而 fetch() 缺乏。
  • XMLHttpRequest 仅在浏览器中可用,并且在 Node.js 环境中不受原生支持。另一方面,fetch() 是 JavaScript 语言的一部分,并且在所有现代 JavaScript 运行时中都受支持。

如今,由于其更简洁的语法和现代功能,更倾向于使用 fetch()


XMLHttpRequest vs fetch()

XMLHttpRequest (XHR) 和 fetch() 都是在 JavaScript 中进行异步 HTTP 请求的方式。但是,它们在语法、promise 处理和功能集方面存在显着差异。

语法和用法

XMLHttpRequest 是事件驱动的,需要附加事件侦听器来处理响应/错误状态。创建 XMLHttpRequest 对象和发送请求的基本语法如下:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);
xhr.responseType = 'json';
xhr.onload = function () {
if (xhr.status === 200) {
console.log(xhr.response);
}
};
xhr.send();

xhrXMLHttpRequest 类的实例。open 方法用于指定请求方法、URL 以及请求是否应为异步。onload 事件用于处理响应,send 方法用于发送请求。

fetch() 提供了一种更直接、更直观的方式来发出 HTTP 请求。它是基于 Promise 的,并返回一个 promise,该 promise 使用响应进行解析或使用错误进行拒绝。使用 fetch() 发出 GET 请求的基本语法如下:

fetch('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => response.text())
.then((data) => console.log(data));

请求标头

XMLHttpRequestfetch() 都支持设置请求标头。但是,fetch() 在设置标头方面提供了更大的灵活性,因为它支持自定义标头并允许更复杂的标头配置。

XMLHttpRequest 支持使用 setRequestHeader 方法设置请求标头:

xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer YOUR_TOKEN');

对于 fetch(),标头作为对象传递给 fetch() 的第二个参数:

fetch('https://jsonplaceholder.typicode.com/todos/1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_TOKEN',
},
body: JSON.stringify({
name: 'John Doe',
age: 30,
}),
});

请求体

XMLHttpRequestfetch() 都支持发送请求体。但是,fetch() 在发送请求体方面提供了更大的灵活性,因为它支持发送 JSON 数据、表单数据等。

XMLHttpRequest 支持使用 send 方法发送请求体:

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://jsonplaceholder.typicode.com/todos/1', true);
xhr.send(
JSON.stringify({
name: 'John Doe',
age: 30,
}),
);

fetch() 支持使用 fetch() 的第二个参数中的 body 属性发送请求体:

fetch('https://jsonplaceholder.typicode.com/todos/1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'John Doe',
age: 30,
}),
});

响应处理

XMLHttpRequest 提供了一个 responseType 属性来设置我们期望的响应格式。responseType 默认为 'text',但它支持 'text''arraybuffer''blob''document''json' 等类型。

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);
xhr.responseType = 'json'; // or 'text', 'blob', 'arraybuffer'
xhr.onload = function () {
if (xhr.status === 200) {
console.log(xhr.response);
}
};
xhr.send();

另一方面,fetch() 提供了一个统一的 Response 对象,它具有用于访问数据的 then 方法。

// JSON data
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => response.json())
.then((data) => console.log(data));
// Text data
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => response.text())
.then((data) => console.log(data));

错误处理

两者都支持错误处理,但 fetch() 在错误处理方面提供了更大的灵活性,因为它支持使用 .catch() 方法处理错误。

XMLHttpRequest 支持使用 onerror 事件进行错误处理:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicod.com/todos/1', true); // Typo in URL
xhr.responseType = 'json';
xhr.onload = function () {
if (xhr.status === 200) {
console.log(xhr.response);
}
};
xhr.onerror = function () {
console.error('Error occurred');
};
xhr.send();

fetch() 支持使用返回的 Promise 上的 catch() 方法进行错误处理:

fetch('https://jsonplaceholder.typicod.com/todos/1') // Typo in URL
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error('Error occurred: ' + error));

缓存控制

使用 XMLHttpRequest 处理缓存很困难,您可能需要向查询字符串添加一个随机值才能绕过浏览器缓存。 默认情况下,fetch()options 对象的第二个参数中支持缓存:

const res = await fetch('https://jsonplaceholder.typicode.com/todos/1', {
method: 'GET',
cache: 'default',
});

cache option 的其他值包括 defaultno-storereloadno-cacheforce-cacheonly-if-cached

取消

正在进行的 XMLHttpRequest 可以通过运行 XMLHttpRequestabort() 方法来取消。如果需要,可以通过将 .onabort 属性赋值来附加一个 abort 处理程序:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
xhr.send();
// ...
xhr.onabort = () => console.log('aborted');
xhr.abort();

中止 fetch() 需要创建一个 AbortController 对象,并在调用 fetch() 时将其作为 options 对象的 signal 属性传递。

const controller = new AbortController();
const signal = controller.signal;
fetch('https://jsonplaceholder.typicode.com/todos/1', { signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error('Error occurred: ' + error));
// Abort request.
controller.abort();

进度支持

XMLHttpRequest 通过将处理程序附加到 XMLHttpRequest 对象的进度事件来支持跟踪请求的进度。 这在上传大文件(例如视频)以跟踪上传进度时特别有用。

const xhr = new XMLHttpRequest();
// The callback is passed a `ProgressEvent`.
xhr.upload.onprogress = (event) => {
console.log(Math.round((event.loaded / event.total) * 100) + '%');
};

分配给 onprogress 的回调函数会传递一个 ProgressEvent:

  • ProgressEvent上的loaded字段是一个64位整数,表示底层进程已经完成的工作量(已上传/下载的字节数)。
  • ProgressEvent上的total字段是一个64位整数,表示底层进程正在执行的总工作量。下载资源时,这是HTTP响应的Content-Length值。

另一方面,fetch() API 并没有提供任何方便的方法来跟踪上传进度。 它可以实现为监视 Response 对象的 body 作为 Content-Length 标头的分数,但这非常复杂。

XMLHttpRequestfetch() 之间进行选择

在现代开发场景中,由于其更简洁的语法、基于 promise 的方法以及改进的错误处理、标头和 CORS 等功能处理,fetch() 是首选。

延伸阅读

如何在 JavaScript 中使用 `AbortController` 终止 Web 请求?

主题
JavaScript网络

TL;DR

AbortController 用于取消进行中的异步操作,例如 fetch 请求。

const controller = new AbortController();
const signal = controller.signal;
fetch('https://jsonplaceholder.typicode.com/todos/1', { signal })
.then((response) => {
// Handle response
})
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Error:', error);
}
});
// Call abort() to abort the request
controller.abort();

终止 Web 请求对于以下情况很有用:

  • 根据用户操作取消请求。
  • 在有多个并发请求的情况下,优先处理最新的请求。
  • 取消不再需要的请求,例如,在用户已从页面导航离开之后。

AbortControllers

AbortController 允许优雅地取消进行中的异步操作,例如 fetch 请求。 它提供了一种机制,向底层网络层发出信号,表明不再需要该请求,从而防止不必要的资源消耗并改善用户体验。

使用 AbortControllers

使用 AbortController 涉及以下步骤:

  1. 创建 AbortController 实例:初始化一个 AbortController 实例,它会创建一个可用于中止请求的信号。
  2. 将信号传递给请求:将信号传递给请求,通常通过请求选项中的 signal 属性。
  3. 中止请求:在 AbortController 实例上调用 abort() 方法以取消正在进行的请求。

以下是如何将 AbortControllerfetch() API 结合使用的示例:

const controller = new AbortController();
const signal = controller.signal;
fetch('https://jsonplaceholder.typicode.com/todos/1', { signal })
.then((response) => {
// Handle response
})
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Error:', error);
}
});
// Call abort() to abort the request
controller.abort();

用例

在用户操作时取消 fetch() 请求

取消由于用户交互(例如,用户取消上传一个大文件)而导致耗时过长或不再相关的请求。

// HTML: <button id='cancel-button'>Cancel upload</button>
const btn = document.createElement('button');
btn.id = 'cancel-button';
btn.innerHTML = 'Cancel upload';
document.body.appendChild(btn);
const controller = new AbortController();
const signal = controller.signal;
fetch('https://jsonplaceholder.typicode.com/todos/1', { signal })
.then((response) => {
// Handle successful response
})
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request canceled');
} else {
console.error('Network or other error:', error);
}
});
document.getElementById('cancel-button').addEventListener('click', () => {
controller.abort();
});
document.getElementById('cancel-button').click(); // Simulate clicking the cancel button

当您单击“取消上传”按钮时,正在进行的请求将被中止。

在竞争条件下优先处理最新请求

在为相同数据启动多个请求的情况下,使用 AbortController 优先处理最新请求并中止较早的请求。

let latestController = null; // Keeps track of the latest controller
function fetchData(url) {
if (latestController) {
latestController.abort(); // Abort any previous request
}
const controller = new AbortController();
latestController = controller;
const signal = controller.signal;
fetch(url, { signal })
.then((response) => response.json())
.then((data) => console.log('Fetched data:', data))
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request canceled');
} else {
console.error('Network or other error:', error);
}
});
}
fetchData('https://jsonplaceholder.typicode.com/posts/1');
// Simulate race conditions with new requests that quickly cancel the previous one
setTimeout(() => {
fetchData('https://jsonplaceholder.typicode.com/posts/2');
}, 5);
setTimeout(() => {
fetchData('https://jsonplaceholder.typicode.com/posts/3');
}, 5);
// Only the last request should (posts/3) will be allowed to complete

在本例中,当多次调用 fetchData() 函数触发多个 fetch 请求时,AbortController 将取消所有之前的请求,除了最新的请求。这在诸如类型提示搜索或无限滚动等场景中很常见,在这些场景中,会频繁触发新的请求。

取消不再需要的请求

在用户已从页面导航出去的情况下,中止请求可以防止不必要的操作(例如,成功回调处理),并通过降低内存泄漏的可能性来释放资源。

笔记

  • AbortController 不仅限于 fetch(),它也可以用于中止其他异步任务。
  • 一个单独的 AbortContoller 实例可以在多个异步任务上重复使用,并一次取消所有任务。
  • AbortController 上调用 abort() 不会向服务器发送任何通知或信号。服务器不知道取消操作,并将继续处理请求,直到它完成或超时。

延伸阅读

JavaScript 的 Polyfill 是什么?

主题
JavaScript

TL;DR

JavaScript 中的 Polyfill 是一段代码,它为较旧的浏览器提供现代功能,而这些浏览器本身并不原生支持这些功能。它们弥合了现代浏览器中可用的 JavaScript 语言特性和 API 与旧版本浏览器有限功能之间的差距。

它们可以手动实现或通过库引入,并且通常与功能检测结合使用。

常见用例包括:

  • 新的 JavaScript 方法:例如,Array.prototype.includes()Object.assign() 等。
  • 新的 API:例如 fetch()PromiseIntersectionObserver 等。现代浏览器现在支持这些,但很长一段时间内它们必须被 polyfilled。

用于 polyfill 的库和服务:

  • core-js:一个用于 JavaScript 的模块化标准库,其中包括各种 ECMAScript 特性的 polyfill。

    import 'core-js/actual/array/flat-map'; // With this, Array.prototype.flatMap is available to be used.
    [1, 2].flatMap((it) => [it, it]); // => [1, 1, 2, 2]
  • Polyfill.io:一项服务,根据请求中指定的功能和用户代理提供 polyfill。

    <script src="https://polyfill.io/v3/polyfill.min.js"></script>

JavaScript 中的 Polyfill

JavaScript 中的 Polyfill 是一段代码(通常是 JavaScript),它在较旧的浏览器上提供现代功能,而这些浏览器本身并不原生支持这些功能。它们使开发人员能够使用该语言和 API 的较新功能,同时保持与旧环境的兼容性。

Polyfill 的工作原理

Polyfill 检测浏览器中是否缺少某个功能或 API,并使用现有的 JavaScript 功能提供该功能的自定义实现。这使开发人员能够使用最新的 JavaScript 功能和 API 编写代码,而无需担心浏览器兼容性问题。

例如,让我们考虑一下 Array.prototype.includes() 方法,该方法确定数组是否包含特定元素。较旧的浏览器(如 Internet Explorer 11)不支持此方法。为了解决这个问题,我们可以使用 polyfill:

// Polyfill for Array.prototype.includes()
if (!Array.prototype.includes) {
Array.prototype.includes = function (searchElement) {
for (var i = 0; i < this.length; i++) {
if (this[i] === searchElement) {
return true;
}
}
return false;
};
}
console.log([1, 2, 3].includes(2)); // true
console.log([1, 2, 3].includes(4)); // false

通过包含此 polyfill,即使在原生不支持它的浏览器中,我们也可以安全地使用 Array.prototype.includes()

实现 polyfill

  1. 确定缺失的功能:确定该功能是否与目标浏览器兼容,或使用 typeofinwindow 等功能检测方法检测其是否存在。
  2. 编写后备实现:开发提供类似功能的后备实现,可以使用预先存在的 polyfill 库或纯 JavaScript 代码。
  3. 测试 polyfill:彻底测试 polyfill,以确保它在不同的上下文和浏览器中按预期运行。
  4. 实现 polyfill:将使用缺失功能的代码包含在 if 语句中,该语句检查功能支持。如果不支持,则运行 polyfill 代码。

考虑事项

  • 选择性加载:polyfill 应该仅针对需要它们的浏览器加载,以优化性能。
  • 功能检测:在应用 polyfill 之前执行功能检测,以避免覆盖原生实现或应用不必要的 polyfill。
  • 大小和性能:polyfill 可能会增加 JavaScript 捆绑包的大小,因此应使用缩小和压缩技术来减轻这种影响。
  • 现有库:考虑使用现有的库和工具,这些库和工具为多个功能提供全面的 polyfill 解决方案,有效地处理功能检测、条件加载和后备

用于 polyfill 的库和服务

  • core-js:一个用于 JavaScript 的模块化标准库,其中包括各种 ECMAScript 特性的 polyfill。

    import 'core-js/actual/array/flat-map'; // With this, Array.prototype.flatMap is available to be used.
    [1, 2].flatMap((it) => [it, it]); // => [1, 1, 2, 2]
  • Polyfill.io:一项服务,根据请求中指定的功能和用户代理提供 polyfill。

    <script src="https://polyfill.io/v3/polyfill.min.js"></script>

延伸阅读

为什么扩展内置 JavaScript 对象不是一个好主意?

主题
JavaScriptOOP

TL;DR

扩展内置/原生 JavaScript 对象意味着向其 prototype 添加属性/函数。虽然这乍一看可能是一个好主意,但实际上很危险。想象一下,您的代码使用了几个库,它们都通过添加相同的 contains 方法来扩展 Array.prototype,这些实现将相互覆盖,如果这两种方法的工作方式不同,您的代码将具有不可预测的行为。

只有当您想创建一个 polyfill 时,才可能需要扩展原生对象,本质上是为您自己的方法提供实现,该方法是 JavaScript 规范的一部分,但由于它是一个较旧的浏览器,可能不存在于用户的浏览器中。


扩展 JavaScript

在 JavaScript 中,扩展内置/原生对象非常容易。您只需通过向其 prototype 添加属性和函数来扩展内置对象。

String.prototype.reverseString = function () {
return this.split('').reverse().join('');
};
console.log('hello world'.reverseString()); // Outputs 'dlrow olleh'
// Instead of extending the built-in object, write a pure utility function to do it.
function reverseString(str) {
return str.split('').reverse().join('');
}
console.log(reverseString('hello world')); // Outputs 'dlrow olleh'

缺点

扩展内置 JavaScript 对象本质上是在修改全局范围,这不是一个好主意,因为:

  1. 面向未来:如果浏览器决定实现自己的方法版本,您的自定义扩展可能会被静默覆盖,从而导致意外行为或冲突。
  2. 冲突:向内置对象添加自定义方法可能会导致与未来的浏览器实现或其他库发生冲突,从而导致意外行为或错误。
  3. 维护和调试:扩展内置对象时,其他开发人员可能难以理解所做的更改,从而使维护和调试更具挑战性。
  4. 性能:扩展内置对象可能会影响性能,特别是如果扩展未针对特定用例进行优化。
  5. 安全性:在某些情况下,如果未正确执行,扩展内置对象可能会引入安全漏洞,例如添加可被恶意代码利用的可枚举属性。
  6. 兼容性:对内置对象的自定义扩展可能与并非所有浏览器或环境兼容,从而导致跨浏览器兼容性问题。
  7. 命名空间冲突:如果多个库或脚本以不同的方式扩展同一对象,扩展内置对象可能会导致命名空间冲突,从而导致冲突和意外行为。

我们深入探讨了为什么修改全局范围不是一个好主意

由于这些潜在问题,不建议扩展内置对象,而是建议使用组合或创建自定义类和实用程序函数来实现所需的功能。

扩展内置对象的替代方案

不要扩展内置对象,而是执行以下操作:

  1. 创建自定义实用程序函数:对于简单的任务,创建特定于您需求的小型实用程序函数可以成为更清晰、更易于维护的解决方案。
  2. 使用库和框架:许多库和框架都提供自己的辅助方法和扩展,从而无需直接修改内置对象。

作为有效原因的 Polyfilling

扩展内置对象的一个有效原因是为最新的 ECMAScript 标准和提案实现 polyfill。core-js 是一个流行的库,存在于大多数流行的网站上。它不仅会 polyfill 缺失的功能,还会修复各种浏览器和运行时中 JavaScript 功能的不正确或不兼容的实现。

import 'core-js/actual/array/flat-map'; // With this, Array.prototype.flatMap is available to be used.
[1, 2].flatMap((it) => [it, it]); // => [1, 1, 2, 2]

延伸阅读

为什么一般来说,最好保持网站的全局 JavaScript 作用域不变,并且永远不要触及它?

主题
JavaScript

总结

在浏览器中执行的 JavaScript 可以访问全局作用域(window 对象)。一般来说,不污染全局命名空间是一个很好的软件工程实践,除非你正在处理一个真正需要全局的特性——整个页面都需要它。避免触及全局作用域的几个原因:

  • 命名冲突:在脚本之间共享全局作用域可能导致冲突和错误,当引入新的全局变量或进行更改时。
  • 全局命名空间混乱:保持全局命名空间最小化可以避免使代码库难以管理和维护。
  • 作用域泄漏:在闭包或事件处理程序中无意中引用全局变量可能导致内存泄漏和性能问题。
  • 模块化和封装:良好的设计促进将变量和函数保持在其特定作用域内,从而增强组织性、可重用性和可维护性。
  • 安全问题:全局变量可被所有脚本访问,包括潜在的恶意脚本,这会带来安全风险,特别是如果敏感数据存储在其中。
  • 兼容性和可移植性:过度依赖全局变量会降低代码的可移植性,并降低与其他库或框架的集成难度。

遵循这些最佳实践以避免全局作用域污染:

  • 使用局部变量:使用 varletconst 在函数或代码块内声明变量以限制其作用域。
  • 将变量作为函数参数传递:通过将变量作为参数传递而不是全局访问它们来保持封装。
  • 使用立即调用函数表达式(IIFE):使用 IIFE 创建新作用域以防止将变量添加到全局作用域。
  • 使用模块:使用模块系统封装代码以保持独立的作用域和可管理性。

什么是全局作用域?

在浏览器中,全局作用域是顶级上下文,变量、函数和对象可以从代码中的任何位置访问。全局作用域由 window 对象表示。在任何函数或代码块(即不在任何模块内)之外声明的任何变量或函数都会添加到 window 对象中,并且可以在全局范围内访问。

例如:

// 假设这在全局作用域中运行,而不是在模块中。
var globalVariable = '我是全局变量';
function globalFunction() {
console.log('我是一个全局函数');
}
console.log(window.globalVariable); // '我是全局变量'
window.globalFunction(); // '我是一个全局函数'

在此示例中,globalVariableglobalFunction 被添加到 window 对象中,并且可以从全局上下文中的任何位置访问。

全局作用域的陷阱

一般来说,不污染全局命名空间是一个很好的软件工程实践,除非你正在处理一个真正需要全局的特性——整个页面都需要它。有很多原因可以避免触及全局作用域:

  • 命名冲突:全局作用域在网页上的所有脚本之间共享。如果你引入新的全局变量或修改现有的全局变量,你可能会导致与在同一页面上使用的其他脚本或库发生命名冲突。这可能导致意外行为和难以调试的问题。
  • 全局命名空间混乱:全局命名空间应保持尽可能干净和最小。添加不必要的全局变量或函数会使命名空间混乱,并使代码库随着时间的推移更难于管理和维护。
  • 作用域泄漏:在使用闭包或事件处理程序时,很容易意外地创建对全局变量的无意引用,从而导致内存泄漏和性能问题。通过完全避免全局变量,你可以防止这些类型的作用域泄漏。
  • 模块化和封装:良好软件设计原则之一是模块化和封装。通过将变量和函数保留在其各自的作用域内(例如,模块、函数或块作用域),你可以促进更好的代码组织、可重用性和可维护性。
  • 安全问题:全局变量可以被页面上运行的任何脚本访问和修改,包括潜在的恶意脚本。网站加载第三方脚本是很常见的,如果有人网络被入侵,这可能会带来安全风险,特别是如果敏感数据存储在全局变量中。但是,首先你不应该在客户端上暴露任何敏感数据。
  • 兼容性和可移植性:通过严重依赖全局变量,你的代码变得不太可移植,并且更依赖于编写它的特定环境。这可能会使它更难以与其他库或框架集成,或者在不同的环境中运行代码(例如,服务器端与浏览器)。

这是一个使用全局作用域的例子。

// 假设这在全局作用域中运行,而不是在模块中。
let count = 0;
function incrementCount() {
count++;
console.log(count);
}
function decrementCount() {
count--;
console.log(count);
}
incrementCount(); // 输出:1
decrementCount(); // 输出:0

在此示例中,countincrementCountdecrementCount 在全局作用域中定义。页面上的任何脚本都可以访问和修改 count,以及 window 上的所有变量。

避免全局作用域污染

到目前为止,我们希望您相信在全局范围内定义变量不是一个好主意。为了避免污染全局范围,建议遵循最佳实践,例如:

  • 使用局部变量:在函数或代码块内声明变量,以限制其作用域,防止它们被全局访问。使用 varletconst 在特定作用域内声明变量,确保它们不会被意外地设置为全局变量。
  • 将变量作为函数参数传递:不要直接从外部作用域访问变量,而是将它们作为参数传递给函数,以保持封装并避免全局作用域污染。
  • 使用模块:利用模块系统来封装你的代码,防止全局作用域污染。每个模块都有自己的作用域,这使得管理和维护你的代码更容易。
  • 使用立即调用函数表达式(IIFE):如果模块不可用,将你的代码包装在 IIFE 中以创建一个新的作用域,防止变量被添加到全局作用域,除非你明确地暴露它们。
// Assuming this is run in the global scope, not within a module.
(function () {
let count = 0;
window.incrementCount = function () {
count++;
console.log(count);
};
window.decrementCount = function () {
count--;
console.log(count);
};
})();
incrementCount(); // Output: 1
decrementCount(); // Output: 0

在这个例子中,count在全局范围内是不可访问的。它只能通过incrementCountdecrementCount函数访问和修改。这些函数通过将它们附加到window对象来暴露给全局范围,但它们仍然可以访问其父范围中的count变量。这提供了一种封装底层数据并仅公开必要操作的方法——不允许直接操作该值。


延伸阅读

解释 JavaScript 中 CommonJS 模块和 ES 模块的区别

主题
JavaScript

总结

在 JavaScript 中,模块是可重用的代码片段,封装了功能,使其更易于管理、维护和构建应用程序。模块允许您将代码分解为更小、更易于管理的部分,每个部分都有自己的作用域。

CommonJS 是一个较旧的模块系统,最初是为使用 Node.js 进行服务器端 JavaScript 开发而设计的。它使用 require() 函数来加载模块,并使用 module.exportsexports 对象来定义模块的导出。

// my-module.js
const value = 42;
module.exports = { value };
// main.js
const myModule = require('./my-module.js');
console.log(myModule.value); // 42

ES 模块(ECMAScript 模块)是 ES6(ECMAScript 2015)中引入的标准化模块系统。它们使用 importexport 语句来处理模块依赖关系。

// my-module.js
export const value = 42;
// main.js
import { value } from './my-module.js';
console.log(value); // 42

CommonJS vs ES 模块

特性CommonJSES 模块
模块语法require() 用于导入 module.exports 用于导出import 用于导入 export 用于导出
环境主要用于 Node.js 进行服务器端开发专为浏览器和服务器端 JavaScript (Node.js) 设计
加载模块的同步加载模块的异步加载
结构动态导入,可以有条件地调用顶层的静态导入/导出
文件扩展名.js (默认).mjs.js (在 package.json 中使用 type: "module")
浏览器支持浏览器中不原生支持现代浏览器中原生支持
优化由于其动态特性,优化有限允许进行树摇等优化,因为具有静态结构
兼容性广泛用于现有的 Node.js 代码库和库较新的标准,但在现代项目中得到越来越多的采用

Javascript 中的模块

JavaScript 中的模块是一种将代码组织和封装成可重用和可维护单元的方式。它们允许开发人员将他们的代码库分解成更小、自包含的部分,从而促进代码重用、关注点分离和更好的组织。JavaScript 中有两个主要的模块系统:CommonJS 和 ES 模块。

CommonJS

CommonJS 是一个较旧的模块系统,最初是为使用 Node.js 进行服务器端 JavaScript 开发而设计的。它使用 require 函数来加载模块,并使用 module.exportsexports 对象来定义模块的导出。

  • 语法:使用 require() 包含模块,使用 module.exports 导出模块。
  • 环境:主要用于 Node.js
  • 执行:模块是同步加载的。
  • 模块在运行时动态加载。
// my-module.js
const value = 42;
module.exports = { value };
// main.js
const myModule = require('./my-module.js');
console.log(myModule.value); // 42

ES 模块

ES 模块(ECMAScript 模块)是 ES6(ECMAScript 2015)中引入的标准化模块系统。它们使用 importexport 语句来处理模块依赖关系。

  • 语法:使用 import 导入模块,使用 export 导出模块。
  • 环境:可以在浏览器环境和 Node.js 中使用(使用某些配置)。
  • 执行:模块是异步加载的。
  • 支持:在 ES2015 中引入,现在在现代浏览器和 Node.js 中得到广泛支持。
  • 模块在编译时静态加载。
  • 由于静态分析和树摇,可以实现更好的性能。
// my-module.js
export const value = 42;
// main.js
import { value } from './my-module.js';
console.log(value); // 42

总结

虽然 CommonJS 最初是 Node.js 中的默认模块系统,但 ES 模块现在是新项目的推荐方法,因为它们提供了更好的工具、性能和生态系统兼容性。 然而,CommonJS 模块仍然广泛用于现有的代码库和库中,尤其是在遗留依赖项中。

延伸阅读

JavaScript 中的各种数据类型是什么?

主题
JavaScript

TL;DR

在 JavaScript 中,数据类型可以分为 原始非原始 类型:

原始数据类型

  • Number:表示整数和浮点数。
  • String:表示字符序列。
  • Boolean:表示 truefalse 值。
  • Undefined:已声明但未赋值的变量。
  • Null:表示有意缺失任何对象值。
  • Symbol:用作对象属性键的唯一且不可变的值。在我们的 关于 Symbol 的深入探讨 中阅读更多内容
  • BigInt:表示任意精度的整数。

非原始(引用)数据类型

  • Object:用于存储数据集合。
  • Array:数据的有序集合。
  • Function:可调用对象。
  • Date:表示日期和时间。
  • RegExp:表示正则表达式。
  • Map:键控数据项的集合。
  • Set:唯一值的集合。

原始类型存储单个值,而非原始类型可以存储数据集合或复杂实体。


JavaScript 中的数据类型

与许多编程语言一样,JavaScript 具有多种数据类型来表示不同种类的数据。 JavaScript 中的主要数据类型可以分为两类:原始类型和非原始(引用)类型。

原始数据类型

  1. Number:表示整数和浮点数。 JavaScript 只有一种数字类型。
let age = 25;
let price = 99.99;
console.log(price); // 99.99
  1. String:表示字符序列。 Strings 可以用单引号、双引号或反引号(用于模板字面量)括起来。
let myName = 'John Doe';
let greeting = 'Hello, world!';
let message = `Welcome, ${myName}!`;
console.log(message); // "Welcome, John Doe!"
  1. Boolean:表示逻辑实体,可以有两个值:truefalse
let isActive = true;
let isOver18 = false;
console.log(isOver18); // false
  1. Undefined:已声明但未赋值的变量的类型为 undefined
let user;
console.log(user); // undefined
  1. Null: 表示任何对象值有意缺失。它是一个原始值,被视为假值。
let user = null;
console.log(user); // null
if (!user) {
console.log('user is a falsy value');
}
  1. Symbol: 一种独特且不可变的原始值,通常用作对象属性的键。
let sym1 = Symbol();
let sym2 = Symbol('description');
console.log(sym1); // Symbol()
console.log(sym2); // Symbol(description)
  1. BigInt:用于表示任意精度的整数,对于处理非常大的数字很有用。
let bigNumber = BigInt(9007199254740991);
let anotherBigNumber = 1234567890123456789012345678901234567890n;
console.log(bigNumber); // 9007199254740991n
console.log(anotherBigNumber); // 1234567890123456789012345678901234567890n

非原始(引用)数据类型

  1. Object: 用于存储数据集合和更复杂的实体。Objects 使用大括号 {} 创建。
let person = {
name: 'Alice',
age: 30,
};
console.log(person); // {name: "Alice", age: 30}
  1. Array: 一种特殊类型的对象,用于存储有序的数据集合。Arrays 使用方括号 [] 创建。
let numbers = [1, 2, 3, 4, 5];
console.log(numbers);
  1. Function: JavaScript 中的函数对象。它们可以使用函数声明或表达式来定义。
function greet() {
console.log('Hello!');
}
let add = function (a, b) {
return a + b;
};
greet(); // "Hello!"
console.log(add(2, 3)); // 5
  1. Date: 表示日期和时间。Date 对象用于处理日期。
let today = new Date().toLocaleTimeString();
console.log(today);
  1. RegExp: 表示正则表达式,正则表达式是用于匹配字符串中字符组合的模式。
let pattern = /abc/;
let str = '123abc456';
console.log(pattern.test(str)); // true
  1. Map: 键控数据项的集合,类似于对象,但允许任何类型的键。
let map = new Map();
map.set('key1', 'value1');
console.log(map);
  1. Set: 唯一值的集合。
let set = new Set();
set.add(1);
set.add(2);
console.log(set); // { 1, 2 }

确定数据类型

JavaScript 是一种动态类型语言,这意味着变量可以随时间推移保存不同数据类型的值。typeof 运算符可用于确定值或变量的数据类型。

console.log(typeof 42); // "number"
console.log(typeof 'hello'); // "string"
console.log(typeof true); // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" (this is a historical bug in JavaScript)
console.log(typeof Symbol()); // "symbol"
console.log(typeof BigInt(123)); // "bigint"
console.log(typeof {}); // "object"
console.log(typeof []); // "object"
console.log(typeof function () {}); // "function"

陷阱

类型强制转换

JavaScript 经常执行类型强制转换,将值从一种类型转换为另一种类型,这可能导致意外结果。

let result = '5' + 2;
console.log(result, typeof result); // "52 string" (string concatenation)
let difference = '5' - 2;
console.log(difference, typeof difference); // 3 "number" (numeric subtraction)

在第一个例子中,由于字符串可以使用 + 运算符进行连接,因此该数字被转换为字符串,并且两个字符串连接在一起。在第二个例子中,字符串不能与减号运算符 (-) 一起使用,但两个数字可以相减,因此字符串首先被转换为数字,结果是差值。

延伸阅读

JavaScript 中使用什么语言结构来迭代对象属性和数组项?

主题
JavaScript

TL;DR

在 JavaScript 中,有多种方法可以迭代对象属性和数组:

for...in 循环

for...in 循环遍历对象的所有可枚举属性,包括继承的可枚举属性。因此,如果您只想遍历对象的自有属性,进行检查非常重要

const obj = {
a: 1,
b: 2,
c: 3,
};
for (const key in obj) {
// To avoid iterating over inherited properties
if (Object.hasOwn(obj, key)) {
console.log(`${key}: ${obj[key]}`);
}
}

Object.keys()

Object.keys() 返回一个由对象自身的可枚举属性名称组成的数组。然后,您可以使用 for...of 循环或 forEach 遍历此数组。

const obj = {
a: 1,
b: 2,
c: 3,
};
Object.keys(obj).forEach((key) => {
console.log(`${key}: ${obj[key]}`);
});

迭代数组最常见的方法是使用 for 循环和 Array.prototype.forEach 方法。

使用 for 循环

let array = [1, 2, 3, 4, 5, 6];
for (let index = 0; index < array.length; index++) {
console.log(array[index]);
}

使用 Array.prototype.forEach 方法

let array = [1, 2, 3, 4, 5, 6];
array.forEach((number, index) => {
console.log(`${number} at index ${index}`);
});

使用 for...of

此方法是迭代数组的最新和最方便的方法。它会自动迭代每个元素,而无需您管理索引。

const numbers = [1, 2, 3, 4, 5];
for (const number of numbers) {
console.log(number);
}

还有其他可用的内置方法,适用于特定场景,例如:

  • Array.prototype.filter:您可以使用 filter 方法创建一个新数组,其中仅包含满足特定条件的元素。
  • Array.prototype.map:您可以使用 map 方法基于现有数组创建一个新数组,并使用提供的函数转换每个元素。
  • Array.prototype.reduce:您可以使用 reduce 方法通过重复调用一个接受两个参数的函数(累积值和当前元素)将所有元素组合成一个值。

遍历对象

在 JavaScript 中,遍历对象属性和数组非常常见,我们有多种方法可以实现这一点。以下是实现此目的的一些方法:

for...in 语句

此循环遍历对象的所有可枚举属性,包括从其原型链继承的属性。

const obj = {
status: 'working',
hoursWorked: 3,
};
for (const property in obj) {
console.log(property);
}

由于 for...in 语句遍历对象的所有可枚举属性(包括继承的可枚举属性)。因此,大多数情况下,在使用属性之前,您应该通过 Object.hasOwn(object, property) 检查该属性是否存在于对象上。

const obj = {
status: 'working',
hoursWorked: 3,
};
for (const property in obj) {
if (Object.hasOwn(obj, property)) {
console.log(property);
}
}

请注意,不推荐使用 obj.hasOwnProperty(),因为它不适用于使用 Object.create(null) 创建的对象。建议在较新的浏览器中使用 Object.hasOwn(),或者使用旧的 Object.prototype.hasOwnProperty.call(object, key)

Object.keys()

Object.keys() 是一种静态方法,它将返回您传递给它的对象的所有可枚举属性名称的数组。由于 Object.keys() 返回一个数组,您也可以使用下面列出的数组迭代方法来遍历它。

const obj = {
status: 'working',
hoursWorked: 3,
};
Object.keys(obj).forEach((property) => {
console.log(property);
});

Object.entries():

此方法返回一个对象的可枚举属性的数组,以 [key, value] 对的形式。

const obj = { a: 1, b: 2, c: 3 };
Object.entries(obj).forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});

Object.getOwnPropertyNames()

const obj = { a: 1, b: 2, c: 3 };
Object.getOwnPropertyNames(obj).forEach((property) => {
console.log(property);
});

Object.getOwnPropertyNames() 是一种静态方法,它将列出您传递给它的对象的所有可枚举和不可枚举属性。由于 Object.getOwnPropertyNames() 返回一个数组,您也可以使用下面列出的数组迭代方法来遍历它。

数组

for 循环

const arr = [1, 2, 3, 4, 5];
for (var i = 0; i < arr.length; i++) {
console.log(arr[i]);
}

这里一个常见的陷阱是 var 在函数作用域中,而不是块作用域中,并且大多数情况下您希望使用块作用域迭代器变量。 ES2015 引入了具有块作用域的 let,建议使用 let 而不是 var

const arr = [1, 2, 3, 4, 5];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}

Array.prototype.forEach()

const arr = [1, 2, 3, 4, 5];
arr.forEach((element, index) => {
console.log(`${element} at index ${index}`);
});

Array.prototype.forEach() 方法有时会更方便,如果您不需要使用 index,而您只需要单个数组元素。 但是,缺点是您无法中途停止迭代,并且提供的函数将在元素上执行一次。 如果您需要更好地控制迭代,for 循环或 for...of 语句会更相关。

for...of 语句

const arr = [1, 2, 3, 4, 5];
for (let element of arr) {
console.log(element);
}

ES2015 引入了一种新的迭代方式,即 for-of 循环,它允许您循环访问符合 iterable protocol 的对象,例如 StringArrayMapSet 等。 它结合了 for 循环和 forEach() 方法的优点。 for 循环的优点是您可以从中跳出,而 forEach() 的优点是它比 for 循环更简洁,因为您不需要计数器变量。 使用 for...of 语句,您可以同时获得跳出循环的能力和更简洁的语法。

大多数情况下,更喜欢 .forEach 方法,但这确实取决于您要执行的操作。 在 ES2015 之前,当我们需要使用 break 提前终止循环时,我们使用 for 循环。 但现在有了 ES2015,我们可以使用 for...of 语句来做到这一点。 当您需要更大的灵活性时,例如每次循环递增迭代器一次以上时,请使用 for 循环。

此外,在使用 for...of 语句时,如果您需要访问每个数组元素的索引和值,您可以使用 ES2015 Array.prototype.entries() 方法来实现:

const arr = ['a', 'b', 'c'];
for (let [index, elem] of arr.entries()) {
console.log(index, elem);
}

延伸阅读

JavaScript 中使用展开语法有什么好处?它与 rest 语法有何不同?

主题
JavaScript

TL;DR

展开语法 (...) 允许将可迭代对象(如数组或字符串)扩展为单个元素。这通常被用作一种方便且现代的方式,通过组合现有数组或对象来创建新的数组或对象。

操作传统方式展开语法
数组克隆arr.slice()[...arr]
数组合并arr1.concat(arr2)[...arr1, ...arr2]
对象克隆Object.assign({}, obj){ ...obj }
对象合并Object.assign({}, obj1, obj2){ ...obj1, ...obj2 }

Rest 语法与展开语法的作用相反。它将可变数量的参数收集到一个数组中。这通常用于函数参数中,以处理动态数量的参数。

// Using rest syntax in a function
function sum(...numbers) {
return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum(1, 2, 3)); // Output: 6

展开语法

ES2015 的展开语法在函数式编程范式中非常有用,因为我们可以轻松地创建数组或对象的副本/合并,而无需使用 Object.createObject.assignArray.prototype.slice 或库函数。此语言特性经常用于 Redux 和 RxJS 项目中。

复制数组/对象

展开语法提供了一种简洁的方式来创建数组或对象的副本,而无需修改原始数组或对象。这对于创建不可变数据结构很有用。但是请注意,通过展开运算符复制的数组是浅拷贝的。

// Copying arrays
const array = [1, 2, 3];
const newArray = [...array];
console.log(newArray); // Output: [1, 2, 3]
// Copying objects
const person = { name: 'John', age: 30 };
const newObj = { ...person, city: 'New York' };
console.log(newObj); // Output: { name: 'John', age: 30, city: 'New York' }

合并数组/对象

展开语法允许您通过将其元素/属性展开到新的数组或对象中来合并数组或对象。

// Merging arrays
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const mergedArray = [...arr1, ...arr2];
console.log(mergedArray); // Output: [1, 2, 3, 4, 5, 6]
// Merging objects
const obj1 = {
foo: 'bar',
};
const obj2 = {
qux: 'baz',
};
const mergedObj = { ...obj1, ...obj2 };
console.log(mergedObj); // Output: { foo: "bar", qux: "baz" }

将参数传递给函数

使用展开语法将值数组作为单个参数传递给函数,避免了使用 apply() 的需要。

const numbers = [1, 2, 3];
const max = Math.max(...numbers); // Same as Math.max(1, 2, 3)
console.log(max); // Output: 3

数组与对象展开

只有可迭代的值(如 ArrayString)才可以在数组中展开。尝试展开不可迭代的值将导致 TypeError

将对象展开到数组中:

const person = {
name: 'Todd',
age: 29,
};
const array = [...person]; // Error: Uncaught TypeError: person is not iterable

另一方面,数组可以展开到对象中。

const array = [1, 2, 3];
const obj = { ...array };
console.log(obj); // { 0: 1, 1: 2, 2: 3 }

Rest 语法

JavaScript 中的 rest 语法 (...) 允许你将不定数量的元素表示为数组或对象。它就像展开语法的逆向操作,获取数据并将其放入数组中,而不是解包数组中的数据,它适用于函数参数以及数组和对象解构赋值。

函数中的 Rest 参数

Rest 语法可用于函数参数中,将所有剩余的参数收集到一个数组中。当你不知道将向函数传递多少个参数时,这特别有用。

function addFiveToABunchOfNumbers(...numbers) {
return numbers.map((x) => x + 5);
}
const result = addFiveToABunchOfNumbers(4, 5, 6, 7, 8, 9, 10);
console.log(result); // Output: [9, 10, 11, 12, 13, 14, 15]

与使用 arguments 对象相比,它提供了更简洁的语法,arguments 对象在箭头函数中不受支持,并且表示 所有 参数,而下面 rest 语法的用法允许 remaining 表示第三个参数及之后的参数。

const [first, second, ...remaining] = [1, 2, 3, 4, 5];
console.log(first); // Output: 1
console.log(second); // Output: 2
console.log(remaining); // Output: [3, 4, 5]

请注意,rest 参数必须位于末尾。rest 参数会收集所有剩余的参数,因此以下操作没有意义,并且会导致错误:

function addFiveToABunchOfNumbers(arg1, ...numbers, arg2) {
// Error: Rest parameter must be last formal parameter.
}

数组解构

Rest 语法可用于数组解构,将剩余的元素收集到一个新数组中。

const [a, b, ...rest] = [1, 2, 3, 4];
console.log(a); // Output: 1
console.log(b); // Output: 2
console.log(rest); // Output: [3, 4]

对象解构

Rest 语法可用于对象解构,将剩余的属性收集到一个新对象中。

const { e, f, ...others } = {
e: 1,
f: 2,
g: 3,
h: 4,
};
console.log(e); // Output: 1
console.log(f); // Output: 2
console.log(others); // Output: { g: 3, h: 4 }

延伸阅读

JavaScript 中的迭代器和生成器是什么,它们有什么用途?

主题
JavaScript

TL;DR

在 JavaScript 中,迭代器和生成器是用于管理数据序列和以更灵活的方式控制执行流程的强大工具。

迭代器是定义序列并可能在其终止时返回值的对象。它遵循一个特定的接口:

  • 迭代器对象必须实现一个 next() 方法。
  • next() 方法返回一个具有两个属性的对象:
    • value:序列中的下一个值。
    • done:一个布尔值,如果迭代器已完成其序列,则为 true,否则为 false

这是一个实现迭代器接口的对象的示例。

const iterator = {
current: 0,
last: 5,
next() {
if (this.current <= this.last) {
return { value: this.current++, done: false };
} else {
return { value: undefined, done: true };
}
},
};
let result = iterator.next();
while (!result.done) {
console.log(result.value); // Logs 0, 1, 2, 3, 4, 5
result = iterator.next();
}

生成器是一种特殊的函数,可以暂停执行并在稍后恢复。它使用 function* 语法和 yield 关键字来控制执行流程。当您调用生成器函数时,它不会像普通函数一样完全执行。相反,它返回一个迭代器对象。在返回的迭代器上调用 next() 方法会将生成器推进到下一个 yield 语句,并且 yield 之后的值将成为 next() 的返回值。

function* numberGenerator() {
let num = 0;
while (num <= 5) {
yield num++;
}
}
const gen = numberGenerator();
console.log(gen.next()); // { value: 0, done: false }
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: 4, done: false }
console.log(gen.next()); // { value: 5, done: false }
console.log(gen.next()); // { value: undefined, done: true }

生成器对于按需创建迭代器非常强大,尤其适用于无限序列或复杂的迭代逻辑。它们可用于:

  • 惰性求值 – 仅在需要时处理元素,提高大型数据集的内存效率。
  • 为自定义数据结构实现迭代器。
  • 创建异步迭代器以处理数据流。

迭代器

迭代器是定义序列并提供 next() 方法以访问序列中下一个值的对象。它们用于迭代 数组字符串 和自定义 对象 等数据结构。迭代器的主要用例包括:

  • 实现迭代器协议以使自定义对象可迭代,允许它们与 for...of 循环和其他期望可迭代对象的语言结构一起使用。
  • 提供一种标准方式来迭代不同的数据结构,使代码更具可重用性和可维护性。

为数字范围创建自定义迭代器

在 JavaScript 中,我们可以通过在任何自定义对象中实现 [Symbol.iterator]() 来为迭代器提供默认实现。

// 定义一个名为 Range 的类
class Range {
// 构造函数接受两个参数:start 和 end
constructor(start, end) {
// 将 start 和 end 值分配给实例
this.start = start;
this.end = end;
}
// 定义对象的默认迭代器
[Symbol.iterator]() {
// 将当前值初始化为起始值
let current = this.start;
const end = this.end;
// 返回一个带有 next 方法的对象
return {
// next 方法返回迭代中的下一个值
next() {
// 如果当前值小于或等于结束值...
if (current <= end) {
// ...返回一个带有当前值和 done 设置为 false 的对象
return { value: current++, done: false };
}
// ...否则,返回一个 value 设置为 undefined 且 done 设置为 true 的对象
return { value: undefined, done: true };
},
};
}
}
// 创建一个 start = 1 且 end = 3 的新 Range 对象
const range = new Range(1, 3);
// 迭代 range 对象
for (const number of range) {
// 将每个数字记录到控制台
console.log(number); // 1, 2, 3
}

使用迭代器协议的内置对象

在 JavaScript 中,几个内置对象实现了迭代器协议,这意味着它们具有默认的 @@iterator 方法。这使得它们可以在 for...of 循环和扩展运算符等结构中使用。以下是实现迭代器的一些关键内置对象:

  1. 数组:数组具有内置的迭代器,允许您遍历其元素。

    const array = [1, 2, 3];
    const iterator = array[Symbol.iterator]();
    console.log(iterator.next()); // { value: 1, done: false }
    console.log(iterator.next()); // { value: 2, done: false }
    console.log(iterator.next()); // { value: 3, done: false }
    console.log(iterator.next()); // { value: undefined, done: true }
    for (const value of array) {
    console.log(value); // Logs 1, 2, 3
    }
  2. 字符串:字符串具有内置的迭代器,允许您遍历其字符。

    const string = 'hello';
    const iterator = string[Symbol.iterator]();
    console.log(iterator.next()); // { value: "h", done: false }
    console.log(iterator.next()); // { value: "e", done: false }
    console.log(iterator.next()); // { value: "l", done: false }
    console.log(iterator.next()); // { value: "l", done: false }
    console.log(iterator.next()); // { value: "o", done: false }
    console.log(iterator.next()); // { value: undefined, done: true }
    for (const char of string) {
    console.log(char); // Logs h, e, l, l, o
    }
  3. DOM NodeLists

    // Create a new div and append it to the DOM
    const newDiv = document.createElement('div');
    newDiv.id = 'div1';
    document.body.appendChild(newDiv);
    const nodeList = document.querySelectorAll('div');
    const iterator = nodeList[Symbol.iterator]();
    console.log(iterator.next()); // { value: HTMLDivElement, done: false }
    console.log(iterator.next()); // { value: undefined, done: true }
    for (const node of nodeList) {
    console.log(node); // Logs each <div> element, in this case only div1
    }

MapSet 也有内置的迭代器。

生成器

生成器是一种特殊的函数,可以暂停和恢复其执行,允许它们动态生成一系列值。它们通常用于创建迭代器,但也有其他应用。生成器的主要用例包括:

  • 与手动实现迭代器协议相比,以更简洁易读的方式创建迭代器。
  • 实现惰性求值,仅在需要时生成值,从而节省内存和计算时间。
  • 通过使用 yieldawait 以同步风格编写代码,简化异步编程。

生成器提供了一些好处:

  • 惰性求值:它们动态生成值,并且仅在需要时才生成,这可以提高内存效率。
  • 暂停和恢复:生成器可以暂停执行(通过 yield),并且可以在恢复时接收新数据。
  • 异步迭代:随着 async/await 的出现,生成器可用于管理异步数据流。

使用生成器函数创建迭代器

我们可以重写我们的 Range 示例以使用生成器函数:

// Define a class named Range
class Range {
// The constructor takes two parameters: start and end
constructor(start, end) {
// Assign the start and end values to the instance
this.start = start;
this.end = end;
}
// Define the default iterator for the object using a generator
*[Symbol.iterator]() {
// Initialize the current value to the start value
let current = this.start;
// While the current value is less than or equal to the end value...
while (current <= this.end) {
// ...yield the current value
yield current++;
}
}
}
// Create a new Range object with start = 1 and end = 3
const range = new Range(1, 3);
// Iterate over the range object
for (const number of range) {
// Log each number to the console
console.log(number); // 1, 2, 3
}

迭代数据流

生成器非常适合迭代数据流,例如从 API 获取数据或读取文件。此示例演示了如何使用生成器分批从 API 获取数据:

async function* fetchDataInBatches(url, numBatches = 5, batchSize = 10) {
let startIndex = 0;
let currBatch = 0;
while (currBatch < numBatches) {
const response = await fetch(
`${url}?_start=${startIndex}&_limit=${batchSize}`,
);
const data = await response.json();
if (data.length === 0) break;
yield data;
startIndex += batchSize;
currBatch += 1;
}
}
async function fetchAndLogData() {
const dataGenerator = fetchDataInBatches(
'https://jsonplaceholder.typicode.com/todos',
);
for await (const batch of dataGenerator) {
console.log(batch);
}
}
fetchAndLogData();

此生成器函数 fetchDataInBatches 以指定大小的批次从 API 获取数据。它产生每批数据,允许您在获取下一批数据之前对其进行处理。这种方法可以比一次获取所有数据更节省内存。

实现异步迭代器

生成器可用于实现异步迭代器,这对于处理异步数据源很有用。此示例演示了用于从 API 获取数据的异步迭代器:

async function* fetchDataAsyncIterator(url, pagesToFetch = 3) {
let currPage = 1;
while (currPage <= pagesToFetch) {
const response = await fetch(`${url}?_page=${currPage}`);
const data = await response.json();
if (data.length === 0) break;
yield data;
currPage++;
}
}
async function fetchAndLogData() {
const asyncIterator = fetchDataAsyncIterator(
'https://jsonplaceholder.typicode.com/todos',
);
for await (const chunk of asyncIterator) {
console.log(chunk);
}
}
fetchAndLogData();

生成器函数 fetchDataAsyncIterator 是一个异步迭代器,它从 API 中分页获取数据。它产生每页数据,允许您在获取下一页之前对其进行处理。这种方法对于处理大型数据集或长时间运行的操作很有用。

生成器也广泛用于 JavaScript 库和框架中,例如 Redux-SagaRxJS,用于处理异步操作和响应式编程。

总结

迭代器和生成器提供了一种强大而灵活的方式来处理 JavaScript 中的数据集合。 迭代器定义了一种遍历数据序列的标准化方式,而生成器提供了一种更具表现力和效率的方式来创建迭代器、处理异步操作以及组合复杂的数据管道。

延伸阅读

解释 JavaScript 中可变对象和不可变对象的区别

主题
JavaScript

TL;DR

可变对象 允许在创建后修改属性和值,这是大多数对象的默认行为。

const mutableObject = {
name: 'John',
age: 30,
};
// Modify the object
mutableObject.name = 'Jane';
// The object has been modified
console.log(mutableObject); // Output: { name: 'Jane', age: 30 }

不可变对象 在创建后不能直接修改。在不创建全新值的情况下,其内容无法更改。

const immutableObject = Object.freeze({
name: 'John',
age: 30,
});
// Attempt to modify the object
immutableObject.name = 'Jane';
// The object remains unchanged
console.log(immutableObject); // Output: { name: 'John', age: 30 }

可变对象和不可变对象之间的主要区别在于可修改性。不可变对象在创建后无法修改,而可变对象可以。


不可变性

不可变性是函数式编程的核心原则,但它也为面向对象程序提供了很多东西。

可变对象

可变性是指对象在创建后可以更改其属性或元素的能力。可变对象是指其状态在创建后可以修改的对象。在 JavaScript 中,对象和数组默认是可变的。它们在内存中存储对其数据的引用。更改属性或元素会修改原始对象。这是一个可变对象的示例:

const mutableObject = {
name: 'John',
age: 30,
};
// Modify the object
mutableObject.name = 'Jane';
// The object has been modified
console.log(mutableObject); // Output: { name: 'Jane', age: 30 }

不可变对象

不可变对象是指其状态在创建后无法修改的对象。这是一个不可变对象的示例:

const immutableObject = Object.freeze({
name: 'John',
age: 30,
});
// Attempt to modify the object
immutableObject.name = 'Jane';
// The object remains unchanged
console.log(immutableObject); // Output: { name: 'John', age: 30 }

原始数据类型(如数字、字符串、布尔值、nullundefined)本质上是不可变的。一旦分配了值,就不能直接修改它们。

let name = 'Alice';
name.toUpperCase(); // This won't modify the original name variable
console.log(name); // Still prints "Alice"
// To change the value, you need to reassign a new string
name = name.toUpperCase();
console.log(name); // Now prints "ALICE"

一些内置的不可变 JavaScript 对象是 MathDate,但自定义对象通常是可变的。

const vs 不可变对象

一个常见的混淆/误解是使用 const 声明变量会使该值不可变,这根本不是真的。

const 阻止重新分配变量本身,但不会使其持有的值不可变。这意味着:

  • 对于原始值(数字、字符串、布尔值),const 使值不可变,因为原始值本质上是不可变的。
  • 对于非原始值,如对象和数组,const 仅阻止将新的对象/数组重新分配给变量,但现有对象/数组的属性/元素仍然可以被修改。

另一方面,不可变对象是指在创建后其状态(属性和值)无法修改的对象。这可以通过使用 Object.freeze() 等方法来实现,该方法通过阻止对其属性的任何更改来使对象不可变。

// 使用 const
const person = { name: 'John' };
person = { name: 'Jane' }; // 错误:赋值给常量变量
person.name = 'Jane'; // 允许,person.name 现在是 'Jane'
// 使用 Object.freeze() 创建一个不可变对象
const frozenPerson = Object.freeze({ name: 'John' });
frozenPerson.name = 'Jane'; // 静默失败(没有错误,但没有变化)
frozenPerson = { name: 'Jane' }; // 错误:赋值给常量变量

在第一个使用 const 的例子中,不允许将新对象重新分配给 person,但允许修改 name 属性。在第二个例子中,Object.freeze() 使 frozenPerson 对象不可变,从而阻止对其属性的任何更改。

需要注意的是,Object.freeze() 创建的是一个浅不可变对象。如果对象包含嵌套对象或数组,除非单独冻结,否则这些嵌套数据结构仍然是可变的。

因此,虽然 const 为原始值提供了不可变性,但创建真正不可变的对象需要使用 Object.freeze() 或其他不可变性技术,如深度冻结或使用来自 ImmerImmutable.js 等库的不可变数据结构。

在普通 JavaScript 对象中实现不可变的各种方法

以下是在普通 JavaScript 对象中添加/模拟不同形式的不可变性的几种方法。

不可变对象属性

通过结合 writable: falseconfigurable: false,您本质上可以创建一个常量(不能更改、重新定义或删除)作为对象属性,例如:

const myObject = {};
Object.defineProperty(myObject, 'number', {
value: 42,
writable: false,
configurable: false,
});
console.log(myObject.number); // 42
myObject.number = 43;
console.log(myObject.number); // 42

阻止对象扩展

如果您想阻止向对象添加新属性,但保持对象的其余属性不变,请调用 Object.preventExtensions(...)

let myObject = {
a: 2,
};
Object.preventExtensions(myObject);
myObject.b = 3;
console.log(myObject.b); // undefined

在非严格模式下,创建 b 会静默失败。在严格模式下,它会抛出 TypeError

密封一个对象

Object.seal() 创建一个“密封”对象,这意味着它会获取一个现有对象,并基本上在其上调用 Object.preventExtensions(),但也会将所有现有属性标记为 configurable: false。因此,您不仅不能添加更多属性,而且也不能重新配置或删除任何现有属性,尽管您仍然可以修改它们的值。

// 创建一个对象
const person = {
name: 'John Doe',
age: 30,
};
// 密封对象
Object.seal(person);
// 尝试添加新属性(这将静默失败)
person.city = 'New York'; // 这没有效果
// 尝试删除现有属性(这将静默失败)
delete person.age; // 这没有效果
// 修改现有属性(这将起作用)
person.age = 35;
console.log(person); // 输出:{ name: 'John Doe', age: 35 }
// 尝试重新配置现有属性描述符(这将静默失败)
Object.defineProperty(person, 'name', { writable: false }); // 在非严格模式下静默失败
// 检查对象是否被密封
console.log(Object.isSealed(person)); // 输出:true

冻结一个对象

Object.freeze() 创建一个冻结对象,这意味着它会获取一个现有对象,并基本上在其上调用 Object.seal(),但它也会将所有“数据访问器”属性标记为 writable:false,以便它们的值不能被更改。

这种方法是您可以为对象本身达到的最高级别的不可变性,因为它阻止了对对象或其任何直接属性的任何更改(尽管,如上所述,任何引用的其他对象的内容不受影响)。

let immutableObject = Object.freeze({});

冻结对象不允许向对象添加新属性,并阻止用户删除或更改现有属性。Object.freeze() 保留了对象的枚举性、可配置性、可写性和 prototype。它返回传递的对象,并且不会创建冻结副本。

Object.freeze() 使对象不可变。但是,它不一定是常量。Object.freeze 阻止对对象本身及其直接属性的修改,冻结对象内的嵌套对象仍然可以被修改。

let obj = {
user: {},
};
Object.freeze(obj);
obj.user.name = 'John';
console.log(obj.user.name); //Output: 'John'

不可变性的优缺点是什么?

优点

  • 更容易进行更改检测:可以通过引用相等性以高性能和简单的方式确定对象相等性。这对于比较 React 和 Redux 中的对象差异很有用。
  • 更简单:使用不可变对象的程序更易于思考,因为您无需担心对象可能随时间演变的方式。
  • 通过引用轻松共享:一个对象的副本与另一个对象一样好,因此您可以缓存对象或多次重用同一对象。
  • 线程安全:不可变对象可以在多线程环境中的线程之间安全使用,因为它们不会有在其他并发运行的线程中被修改的风险。在大多数情况下,JavaScript 在单线程环境中运行
  • 减少内存需求:使用 ImmerImmutable.js 等库,使用结构共享修改对象,并且对于具有相似结构的多个对象,所需的内存更少。
  • 无需防御性复制:当从不可变对象返回或传递给函数时,不再需要防御性副本,因为不可变对象不可能被它修改。

缺点

  • 难以自行创建:不可变数据结构及其操作的朴素实现可能会导致性能极差,因为每次都会创建新对象。建议使用库来实现高效的不可变数据结构和利用结构共享的操作。
  • 潜在的负面性能:分配(和释放)许多小对象而不是修改现有对象可能会导致性能影响。分配器或垃圾收集器的复杂性通常取决于堆上的对象数量。
  • 循环数据结构的复杂性:循环数据结构(如图形)难以实现。

延伸阅读

JavaScript 中 `Map` 对象和普通对象有什么区别?

主题
JavaScript

TL;DR

JavaScript 中的 Map 对象和普通对象都可以存储键值对,但它们有几个关键的区别:

特性Map普通对象
键类型任何数据类型字符串(或 Symbol)
键顺序保持不保证
大小属性是 (size)
迭代forEachkeys()values()entries()for...inObject.keys()
继承
性能通常更适合大型数据集和频繁的添加/删除适用于小型数据集和简单操作
可序列化

Map vs 普通 JavaScript 对象

在 JavaScript 中,Map 对象和普通对象(也称为“POJO”或“普通旧 JavaScript 对象”)都用于存储键值对,但它们具有不同的特性、用例和行为。

普通 JavaScript 对象 (POJO)

普通对象是使用 {} 语法创建的基本 JavaScript 对象。它是一个键值对的集合,其中每个键都是一个字符串(或现代 JavaScript 中的符号),每个值可以是任何类型的值,包括字符串、数字、布尔值、数组、对象等。

const person = { name: 'John', age: 30, occupation: 'Developer' };
console.log(person);

Map 对象

在 ECMAScript 2015 (ES6) 中引入的 Map 对象是一种更高级的数据结构,它允许您存储具有附加功能的键值对。Map 是可迭代的,这意味着您可以在 for...of 循环中使用它,并且它提供了用于常见操作(如 getsethasdelete)的方法。

const person = new Map([
['name', 'John'],
['age', 30],
['occupation', 'Developer'],
]);
console.log(person);

关键区别

以下是 Map 对象和普通对象之间的主要区别:

  1. 键类型:在普通对象中,键始终是字符串(或符号)。在 Map 中,键可以是任何类型的值,包括对象、数组,甚至其他 Map
  2. 键顺序:在普通对象中,键的顺序不保证。在 Map 中,键的顺序被保留,您可以按照它们插入的顺序进行迭代。
  3. 迭代Map 是可迭代的,这意味着您可以使用 for...of 循环来迭代其键值对。默认情况下,普通对象不可迭代,但您可以使用 Object.keys()Object.entries() 来迭代其属性。
  4. 性能Map 对象通常比普通对象更快、更高效,尤其是在处理大型数据集时。
  5. 方法Map 对象提供了其他方法,例如 getsethasdelete,这使得处理键值对更容易。
  6. 序列化:将 Map 对象序列化为 JSON 时,它将被转换为一个对象,但现有的 Map 属性可能会在转换过程中丢失。另一方面,普通对象被序列化为具有相同结构的 JSON 对象。

何时使用哪个

当您需要时使用普通对象 (POJO):

  • 您需要一个具有字符串键的简单、轻量级对象。
  • 您正在处理小型数据集。
  • 您需要将对象序列化为 JSON(例如,通过网络发送)。

当您需要时使用 Map 对象:

  • 您需要使用非字符串键(例如对象、数组)存储键值对。
  • 您需要保留键值对的顺序。
  • 您需要按特定顺序迭代键值对。
  • 您正在处理大型数据集,需要更好的性能。

总而言之,虽然普通对象和 Map 对象都可以用来存储键值对,但 Map 对象提供了更高级的功能、更好的性能和额外的方法,使其成为更复杂用例的更好选择。

注意事项

Map 对象无法被序列化以在 HTTP 请求中发送,但像 superjson 这样的库允许它们被序列化和反序列化。

延伸阅读

JavaScript 中 `Map`/`Set` 和 `WeakMap`/`WeakSet` 有什么区别?

主题
JavaScript

总结

JavaScript 中 Map/SetWeakMap/WeakSet 的主要区别在于它们如何处理键。以下是细分:

Map vs. WeakMap

Map 允许任何数据类型(字符串、数字、对象)作为键。只要引用 Map 对象本身,键值对就会保留在内存中。因此,它们适用于通用键值存储,您希望维护对键和值的引用。常见用例包括存储用户数据、配置设置或对象之间的关系。

WeakMap 仅允许对象作为键。但是,这些对象键被弱保存。这意味着垃圾收集器可以从内存中删除它们,即使 WeakMap 本身仍然存在,只要没有对这些对象的其他引用。WeakMap 非常适合您希望将数据与对象关联而不会阻止这些对象被垃圾回收的场景。这对于以下情况可能很有用:

  • 基于对象缓存数据,而不会阻止对象本身的垃圾回收。
  • 存储与 DOM 节点关联的私有数据,而不会影响其生命周期。

Set vs. WeakSet

Map 类似,Set 允许任何数据类型作为键。Set 中的元素必须是唯一的。Set 适用于存储唯一值并有效检查成员资格。常见用例包括从数组中删除重复项或跟踪已完成的任务。

另一方面,WeakSet 仅允许对象作为元素,并且这些对象元素被弱保存,类似于 WeakMap 键。WeakSet 很少使用,但当您需要一组唯一对象而不影响其垃圾回收时适用。这可能对于以下情况是必要的:

  • 跟踪已交互的 DOM 节点,而不会影响其内存管理。
  • 为特定用例实现自定义对象弱引用。

以下是一个总结关键差异的表格:

特性MapWeakMapSetWeakSet
键类型任何数据类型对象(弱引用)任何数据类型(唯一)对象(弱引用,唯一)
垃圾回收键和值不会被垃圾回收如果没有其他地方引用,键可以被垃圾回收元素不会被垃圾回收如果没有其他地方引用,元素可以被垃圾回收
用例通用键值存储缓存、私有 DOM 节点数据删除重复项、成员资格检查对象弱引用、自定义用例

在它们之间进行选择

  • 在大多数需要存储键值对或唯一元素并希望维护对键/元素和值的引用的情况下,使用 MapSet
  • 在特定情况下谨慎使用 WeakMapWeakSet,您希望将数据与对象关联而不会影响其垃圾回收。如果使用不当,请注意弱引用的含义和潜在的内存泄漏。

Map/Set vs WeakMap/WeakSet

JavaScript 中 Map/SetWeakMap/WeakSet 之间的主要区别在于:

  1. 键类型MapSet 可以具有任何类型的键(对象、原始值等),而 WeakMapWeakSet 只能将对象作为键。原始值(如字符串或数字)不允许作为 WeakMapWeakSet 中的键。
  2. 内存管理:主要区别在于它们如何处理内存。MapSet 具有对其键和值的强引用,这意味着它们将阻止对这些值进行垃圾回收。另一方面,WeakMapWeakSet 具有对其键(对象)的弱引用,如果对它们没有其他强引用,则允许对这些对象进行垃圾回收。
  3. 键枚举MapSet 中的键是可枚举的(可以迭代),而 WeakMapWeakSet 中的键不可枚举。这意味着您无法从 WeakMapWeakSet 获取键或值的列表。
  4. size 属性MapSet 具有一个 size 属性,该属性返回元素的数量,而 WeakMapWeakSet 没有 size 属性,因为它们的大小可能会因垃圾回收而改变。
  5. 用例MapSet 适用于通用数据结构和缓存,而 WeakMapWeakSet 主要用于存储与对象相关的元数据或其他数据,而不会阻止对这些对象进行垃圾回收。

MapSet 是维护对其键和值的强引用的常规数据结构,而 WeakMapWeakSet 专为希望将数据与对象关联而设计的场景,而不会阻止在不再需要这些对象时对它们进行垃圾回收。

WeakMapWeakSet 的用例

跟踪活跃用户

在聊天应用程序中,您可能希望跟踪当前处于活动状态的用户对象,而不会在用户注销或会话过期时阻止垃圾回收。我们使用 WeakSet 来跟踪活跃的用户对象。当用户注销或他们的会话过期时,如果没有对该用户的其他引用,则该用户对象可以被垃圾回收。

const activeUsers = new WeakSet();
// Function to mark a user as active
function markUserActive(user) {
activeUsers.add(user);
}
// Function to check if a user is active
function isUserActive(user) {
return activeUsers.has(user);
}
// Example usage
let user1 = { id: 1, name: 'Alice' };
let user2 = { id: 2, name: 'Bob' };
markUserActive(user1);
markUserActive(user2);
console.log(isUserActive(user1)); // true
console.log(isUserActive(user2)); // true
// Simulate user logging out
user1 = null;
// user1 is now eligible for garbage collection
console.log(isUserActive(user1)); // false

检测循环引用

WeakSet 提供了一种通过跟踪哪些对象已经被处理来防止循环数据结构的方法。

// Create a WeakSet to track visited objects
const visited = new WeakSet();
// Function to traverse an object recursively
function traverse(obj) {
// Check if the object has already been visited
if (visited.has(obj)) {
return;
}
// Add the object to the visited set
visited.add(obj);
// Traverse the object's properties
for (let prop in obj) {
if (obj.hasOwnProperty(prop)) {
let value = obj[prop];
if (typeof value === 'object' && value !== null) {
traverse(value);
}
}
}
// Process the object
console.log(obj);
}
// Create an object with a circular reference
const obj = {
name: 'John',
age: 30,
friends: [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 28 },
],
};
// Create a circular reference
obj.self = obj;
// Traverse the object
traverse(obj);

延伸阅读

为什么你可能想要在 JavaScript 中创建静态类成员?

主题
JavaScriptOOP

TL;DR

静态类成员(属性/方法)有一个前置的 static 关键字。此类成员不能直接在类的实例上访问。相反,它们在类本身上被访问。

class Car {
static noOfWheels = 4;
static compare() {
return 'Static method has been called.';
}
}
console.log(Car.noOfWheels); // 4

静态成员在以下情况下很有用:

  • 命名空间组织:静态属性可用于定义特定于类的常量或配置值。这有助于在类命名空间内组织相关数据,并防止与其他变量发生命名冲突。示例包括 Math.PIMath.SQRT2
  • 辅助函数:静态方法可用作对类本身或其实例进行操作的辅助函数。这可以通过将实用程序逻辑与类的核心功能分开来提高代码的可读性和可维护性。常用静态方法的示例包括 Object.assign()Math.max()
  • 单例模式:在极少数情况下,静态属性和方法可用于实现单例模式,其中只存在一个类的实例。但是,这种模式可能难以管理,通常不鼓励使用,而倾向于使用更现代的依赖注入技术。

静态类成员

静态类成员(属性/方法)不与类的特定实例相关联,并且无论哪个实例引用它,都具有相同的值。静态属性通常是配置变量,静态方法通常是纯实用程序函数,不依赖于实例的状态。此类属性有一个前置的 static 关键字。

class Car {
static noOfWheels = 4;
static compare() {
return 'static method has been called.';
}
}
console.log(Car.noOfWheels); // Output: 4
console.log(Car.compare()); // Output: static method has been called.

静态成员不能被类的特定实例访问。

class Car {
static noOfWheels = 4;
static compare() {
return 'static method has been called.';
}
}
const car = new Car();
console.log(car.noOfWheels); // Output: undefined
console.log(car.compare()); // Error: TypeError: car.compare is not a function

JavaScript 中的 Math 类是使用静态成员的常见库的一个很好的例子。JavaScript 中的 Math 类是一个内置对象,它提供了一组数学常量和函数。它是一个静态类,这意味着它的所有属性和方法都是静态的。以下是 Math 类如何使用静态成员的示例:

console.log(Math.PI); // Output: 3.141592653589793
console.log(Math.abs(-5)); // Output: 5
console.log(Math.max(1, 2, 3)); // Output: 3

在此示例中,Math.PIMath.abs()Math.max() 都是 Math 类的静态成员。它们可以直接在 Math 对象上访问,而无需创建类的实例。

使用静态类成员的原因

实用函数

静态类成员可用于定义不需要任何特定于实例(不使用 this)数据或行为的实用函数。例如,您可能有一个 Arithmetic 类,其中包含用于常见数学运算的静态方法。

class Arithmetic {
static add(a, b) {
return a + b;
}
static subtract(a, b) {
return a - b;
}
}
console.log(Arithmetic.add(2, 3)); // Output: 5
console.log(Arithmetic.subtract(5, 2)); // Output: 3

单例

静态类成员可用于实现单例模式,在单例模式中,您希望确保应用程序中只存在一个类的实例。

class Singleton {
static instance;
static getInstance() {
if (!this.instance) {
this.instance = new Singleton();
}
return this.instance;
}
}
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();
console.log(singleton1 === singleton2); // Output: true

配置

静态类成员可用于存储在类的所有实例之间共享的配置或设置。这对于 API 密钥、功能标志或其他全局设置非常有用。

class Config {
static API_KEY = 'your-api-key';
static FEATURE_FLAG = true;
}
console.log(Config.API_KEY); // Output: 'your-api-key'
console.log(Config.FEATURE_FLAG); // Output: true

性能

在某些情况下,使用静态类成员可以通过减少应用程序使用的内存量来提高性能。这是因为静态类成员在类的所有实例之间共享,而不是为每个实例复制。

延伸阅读

JavaScript 中 `Symbol` 的用途是什么?

主题
JavaScript

总结

JavaScript 中的 Symbol 是 ES6 (ECMAScript 2015) 中引入的一种新的原始数据类型。它们是唯一的、不可变的标识符,主要用于对象属性键,以避免名称冲突。这些值可以使用 Symbol(...) 函数创建,并且每个 Symbol 值都保证是唯一的,即使它们具有相同的键/描述。Symbol 属性在 for...in 循环或 Object.keys() 中不可枚举,这使得它们适合创建私有/内部对象状态。

let sym1 = Symbol();
let sym2 = Symbol('myKey');
console.log(typeof sym1); // "symbol"
console.log(sym1 === sym2); // false, because each symbol is unique
let obj = {};
let sym = Symbol('uniqueKey');
obj[sym] = 'value';
console.log(obj[sym]); // "value"

注意Symbol() 函数必须在没有 new 关键字的情况下调用。它并不完全是一个构造函数,因为它只能作为函数调用,而不是使用 new Symbol()


JavaScript 中的 Symbol

JavaScript 中的 Symbols 是一种独特且不可变的数据类型,主要用于对象属性键,以避免名称冲突。

关键特征

  • 唯一性:每个 Symbol 值都是唯一的,即使它们具有相同的描述。
  • 不可变性:Symbol 值是不可变的,这意味着它们的值不能被更改。
  • 不可枚举性:Symbol 属性不包含在 for...in 循环或 Object.keys() 中。

创建 Symbol

Symbol 可以使用 Symbol() 函数创建:

const sym1 = Symbol();
const sym2 = Symbol('uniqueKey');
console.log(typeof sym1); // "symbol"
console.log(sym1 === sym2); // false, because each symbol is unique

必须在没有 new 关键字的情况下调用 Symbol(..) 函数。

Symbol 用作 object 属性键

Symbol 可用于将属性添加到对象,而不会有名称冲突的风险:

const obj = {};
const sym = Symbol('uniqueKey');
obj[sym] = 'value';
console.log(obj[sym]); // "value"

Symbol 不可枚举

  • Symbol 属性不包含在 for...in 循环或 Object.keys() 中。
  • 这使得它们适合创建私有/内部对象状态。
  • 使用 Object.getOwnPropertySymbols(obj) 获取对象上的所有 symbol 属性。
const mySymbol = Symbol('privateProperty');
const obj = {
name: 'John',
[mySymbol]: 42,
};
console.log(Object.keys(obj)); // Output: ['name']
console.log(obj[mySymbol]); // Output: 42

全局 Symbol 注册表

您可以使用 Symbol.for('key') 创建全局 Symbol,如果全局注册表中不存在,则创建一个新的 Symbol,或者返回现有的 Symbol。这允许您在代码库的不同部分甚至不同的代码库之间重用 Symbol

const globalSym1 = Symbol.for('globalKey');
const globalSym2 = Symbol.for('globalKey');
console.log(globalSym1 === globalSym2); // true
const key = Symbol.keyFor(globalSym1);
console.log(key); // "globalKey"

众所周知的 Symbol

JavaScript 包含几个内置的 Symbol,称为众所周知的 Symbol

  • Symbol.iterator:定义对象的默认 iterator
  • Symbol.toStringTag:用于创建对象的字符串描述。
  • Symbol.hasInstance:用于确定对象是否是构造函数的实例。

Symbol.iterator

let iterable = {
[Symbol.iterator]() {
let step = 0;
return {
next() {
step++;
if (step <= 5) {
return { value: step, done: false };
}
return { done: true };
},
};
},
};
for (let value of iterable) {
console.log(value); // 1, 2, 3, 4, 5
}

Symbol.toStringTag

let myObj = {
[Symbol.toStringTag]: 'MyCustomObject',
};
console.log(Object.prototype.toString.call(myObj)); // "[object MyCustomObject]"

总结

Symbol 是 JavaScript 中一项强大的功能,尤其适用于创建唯一的对象属性和自定义对象行为。它们提供了一种创建隐藏属性的方法,防止意外访问或修改,这在大型应用程序和库中特别有益。

延伸阅读

什么是服务器发送事件?

主题
JavaScript网络

TL;DR

服务器发送事件 (SSE) 是一种标准,允许网页通过 HTTP 连接从服务器接收自动更新。服务器发送事件与 EventSource 实例一起使用,该实例打开与服务器的连接,并允许客户端从服务器接收事件。服务器发送事件创建的连接是持久的(类似于 WebSocket),但是有一些区别:

属性WebSocketEventSource
方向双向 – 客户端和服务器都可以交换消息单向 – 只有服务器发送数据
数据类型二进制和文本数据仅文本数据
协议WebSocket 协议 (ws://)常规 HTTP (http://)

创建事件源

const eventSource = new EventSource('/sse-stream');

监听事件

// 建立连接时触发。
eventSource.addEventListener('open', () => {
console.log('连接已打开');
});
// 从服务器收到消息时触发。
eventSource.addEventListener('message', (event) => {
console.log('收到消息:', event.data);
});
// 发生错误时触发。
eventSource.addEventListener('error', (error) => {
console.error('发生错误:', error);
});

从服务器发送事件

const express = require('express');
const app = express();
app.get('/sse-stream', (req, res) => {
// `Content-Type` 需要设置为 `text/event-stream`。
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 每条消息都应以 data 开头。
const sendEvent = (data) => res.write(`data: ${data}\n\n`);
sendEvent('来自服务器的问候');
const intervalId = setInterval(() => sendEvent(new Date().toString()), 1000);
res.on('close', () => {
console.log('客户端关闭连接');
clearInterval(intervalId);
});
});
app.listen(3000, () => console.log('服务器已在端口 3000 启动'));

在此示例中,服务器最初发送“来自服务器的问候”消息,然后每秒发送当前日期。连接保持活动状态,直到客户端将其关闭


服务器发送事件 (SSE)

服务器发送事件 (SSE) 是一种标准,允许服务器通过单个、长连接的 HTTP 连接将更新推送到 Web 客户端。它支持实时更新,而无需客户端不断轮询服务器以获取新数据。

SSE 的工作原理

  1. 客户端创建一个新的 EventSource 对象,传递将生成事件流的 服务器端 脚本的 URL:

    const eventSource = new EventSource('/event-stream');
  2. 服务器端脚本设置适当的标头以指示它将发送事件流 (Content-Type: text/event-stream),然后开始向客户端发送事件。

  3. 服务器发送的每个事件都遵循特定的格式,包含 eventdataid 等字段。例如:

    event: message
    data: Hello, world!
    event: update
    id: 123
    data: {"temperature": 25, "humidity": 60}
  4. 在客户端,EventSource 对象接收这些事件并将其作为浏览器事件分派,可以使用事件侦听器或 onmessage 事件处理程序来处理这些事件:

    eventSource.onmessage = function (event) {
    console.log('Received message:', event.data);
    };
    eventSource.addEventListener('update', function (event) {
    console.log('Received update:', JSON.parse(event.data));
    });
  5. 如果连接断开,EventSource 对象会自动处理重新连接,并且可以使用 Last-Event-ID HTTP 标头 从上次接收到的事件 ID 恢复事件流。

SSE 功能

  • 单向:只有服务器才能向客户端发送数据。对于双向通信,Web 套接字将更合适。
  • 重试机制:如果连接失败,客户端将重试连接,重试间隔由服务器的 retry: 字段指定。
  • 仅文本数据:SSE 只能传输文本数据,这意味着在传输之前需要对二进制数据进行编码(例如,Base64)。对于需要传输大型二进制有效负载的应用程序,这可能会导致开销增加和效率低下。
  • 内置浏览器支持:受大多数现代浏览器支持,无需其他库。
  • 事件类型:SSE 使用 event: 字段支持自定义事件类型,允许对消息进行分类。
  • Last-Event-Id:客户端在重新连接时发送 Last-Event-Id 标头,允许服务器从上次接收到的事件恢复流。但是,没有内置的机制来重放断开连接期间错过的事件。您可能需要实现一种机制来处理错过的事件,例如使用 Last-Event-Id 标头。
  • 连接限制:浏览器对并发 SSE 连接的最大数量有限制,通常每个域大约 6 个。如果需要从同一客户端建立多个 SSE 连接,这可能会成为瓶颈。使用 HTTP/2 将缓解此问题。

在 JavaScript 中实现 SSE

以下代码演示了客户端和服务器上 SSE 的最小实现:

  • 服务器设置适当的标头以建立 SSE 连接。
  • 消息每 5 秒发送到客户端。
  • 当客户端断开连接时,服务器清理间隔并结束响应。

在客户端上:

// Create a new EventSource object
const eventSource = new EventSource('/sse');
// Event listener for receiving messages
eventSource.onmessage = function (event) {
console.log('New message:', event.data);
};
// Event listener for errors
eventSource.onerror = function (error) {
console.error('Error occurred:', error);
};
// Optional: Event listener for open connection
eventSource.onopen = function () {
console.log('Connection opened');
};

在服务器上:

const http = require('http');
http
.createServer((req, res) => {
if (req.url === '/sse') {
// Set headers for SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// Function to send a message
const sendMessage = (message) => {
res.write(`data: ${message}\n\n`); // Messages are delimited with double line breaks.
};
// Send a message every 5 seconds
const intervalId = setInterval(() => {
sendMessage(`Current time: ${new Date().toLocaleTimeString()}`);
}, 5000);
// Handle client disconnect
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
} else {
res.writeHead(404);
res.end();
}
})
.listen(8080, () => {
console.log('SSE server running on port 8080');
});

总结

服务器发送事件提供了一种高效且直接的方式,可以将更新从服务器推送到客户端。它们特别适用于需要连续数据流但不需要完全双向通信的应用程序。由于现代浏览器内置了支持,SSE 是许多实时 Web 应用程序的可靠选择。

延伸阅读

解释 JavaScript 中的“hoisting”(变量提升)概念