解释事件委托(Event Delegation)
事件委托是一种技术,它涉及到将事件监听器添加到父元素而不是子元素上。当事件由于事件冒泡在子元素上触发时,监听器就会触发。这项技术的好处是:
- 内存占用减少,因为父元素上只需要一个处理程序,而不需要在每个子元素上附加事件处理程序。
- 无需从被移除的元素中解除绑定处理程序,也无需为新元素绑定事件。
事件委托是一种技术,它涉及到将事件监听器添加到父元素而不是子元素上。当事件由于事件冒泡在子元素上触发时,监听器就会触发。这项技术的好处是:
this
没有简单的解释;它是 JavaScript 中最令人困惑的概念之一。一个模糊的解释是,this
的值取决于函数的调用方式。我阅读了许多关于 this
的在线解释,我发现 [Arnav Aggrawal] 的解释最清晰。应用以下规则:
new
关键字,函数内部的 this
是一个全新的对象。apply
、call
或 bind
调用/创建函数,函数内部的 this
是作为参数传入的对象。obj.method()
,那么 this
是该函数所属的对象。this
是全局对象。在浏览器中,它是 window
对象。如果在严格模式下 ('use strict'
),this
将是 undefined
而不是全局对象。this
值。this
值。有关深入解释,请查看他的 [Medium 上的文章]。
this
工作方式发生变化的例子吗?ES6 允许你使用 [箭头函数],它使用 [封闭的词法作用域]。这通常很方便,但确实会阻止调用者通过 .call
或 .apply
控制上下文——结果是像 jQuery
这样的库将无法在你的事件处理函数中正确绑定 this
。因此,在重构大型遗留应用程序时,记住这一点很重要。
所有 JavaScript 对象都有一个 __proto__
属性(除了使用 Object.create(null)
创建的对象),它是一个对另一个对象的引用,该对象称为该对象的“原型”。当访问对象上的属性时,如果该属性未在该对象上找到,JavaScript 引擎会查看该对象的 __proto__
,然后是 __proto__
的 __proto__
,依此类推,直到在其中一个 __proto__
上找到该属性,或者直到到达原型链的末尾。这种行为模拟了经典继承,但它实际上更像是 [委托而不是继承]。
// 父对象构造函数。 function Animal(name) { this.name = name; } // 向父对象的原型添加一个方法。 Animal.prototype.makeSound = function () { console.log('The ' + this.constructor.name + ' makes a sound.'); }; // 子对象构造函数。 function Dog(name) { Animal.call(this, name); // 调用父构造函数。 } // 将子对象的原型设置为父对象的原型。 Object.setPrototypeOf(Dog.prototype, Animal.prototype); // 向子对象的原型添加一个方法。 Dog.prototype.bark = function () { console.log('Woof!'); }; // 创建 Dog 的新实例。 const bolt = new Dog('Bolt'); // 调用子对象上的方法。 console.log(bolt.name); // "Bolt" bolt.makeSound(); // "The Dog makes a sound." bolt.bark(); // "Woof!"
需要注意的事项是:
.makeSound
未在 Dog
上定义,因此引擎会沿原型链向上查找,并在继承的 Animal
上找到 .makeSound
。Object.create
构建继承链已不再推荐。请改用 Object.setPrototypeOf
。两者都是实现模块系统的方法,在 ES2015 出现之前,JavaScript 中并没有原生模块系统。CommonJS 是同步的,而 AMD(Asynchronous Module Definition)显然是异步的。CommonJS 的设计考虑了服务器端开发,而 AMD 凭借其对模块异步加载的支持,更适用于浏览器。
我认为 AMD 语法相当冗长,而 CommonJS 更接近于其他语言中编写导入语句的风格。大多数时候,我发现 AMD 没有必要,因为如果你将所有 JavaScript 捆绑到一个连接文件中,你就不会从异步加载属性中受益。此外,CommonJS 语法更接近 Node 模块的编写风格,在客户端和服务器端 JavaScript 开发之间切换时,上下文切换开销更小。
我很高兴 ES2015 模块既支持同步加载又支持异步加载,我们终于可以坚持一种方法了。尽管它尚未在浏览器和 Node 中完全推出,但我们始终可以使用转译器来转换我们的代码。
IIFE 代表立即调用函数表达式(Immediately Invoked Function Expressions)。JavaScript 解析器将 function foo(){ }();
读取为 function foo(){ }
和 ();
,其中前者是 函数声明,后者(一对括号)是尝试调用函数,但没有指定名称,因此会抛出 Uncaught SyntaxError: Unexpected token )
。
这里有两种通过添加更多括号来修复它的方法:(function foo(){ })()
和 (function foo(){ }())
。以 function
开头的语句被认为是 函数声明;通过将此函数包装在 ()
中,它就变成了一个 函数表达式,然后可以使用随后的 ()
执行。这些函数不会暴露在全局作用域中,如果你不需要在函数体中引用它自身,甚至可以省略它的名称。
你也可以使用 void
运算符:void function foo(){ }();
。不幸的是,这种方法有一个问题。给定表达式的计算结果总是 undefined
,因此如果你的 IIFE 函数返回任何内容,你将无法使用它。例如:
const foo = void (function bar() { return 'foo'; })(); console.log(foo); // undefined
未声明的变量是在你为未事先使用 var
、let
或 const
创建的标识符赋值时创建的。未声明的变量将在全局范围内定义,超出当前作用域。在严格模式下,当你尝试为未声明的变量赋值时,将抛出 ReferenceError
。未声明的变量与全局变量一样糟糕。不惜一切代价避免它们!要检查它们,请将其用法包装在 try
/catch
块中。
function foo() { x = 1; // 在严格模式下抛出 ReferenceError } foo(); console.log(x); // 1
undefined
的变量是已声明但未赋值的变量。它的类型是 undefined
。如果一个函数执行后没有返回任何值并将其赋给一个变量,那么该变量的值也为 undefined
。要检查它,请使用严格相等 (===
) 运算符或 typeof
,它将返回 'undefined'
字符串。请注意,你不应该使用抽象相等运算符进行检查,因为它在值为 null
时也会返回 true
。
var foo; console.log(foo); // undefined console.log(foo === undefined); // true console.log(typeof foo === 'undefined'); // true console.log(foo == null); // true. 错误,不要用这个检查! function bar() {} var baz = bar(); console.log(baz); // undefined
值为 null
的变量将已被显式赋值为 null
。它表示没有值,与 undefined
的不同之处在于它已被显式赋值。要检查 null
,只需使用严格相等运算符进行比较。请注意,与上面一样,你不应该使用抽象相等运算符 (==
) 进行检查,因为它在值为 undefined
时也会返回 true
。
var foo = null; console.log(foo === null); // true console.log(typeof foo === 'object'); // true console.log(foo == undefined); // true. 错误,不要用这个检查!
作为个人习惯,我从不让我的变量未声明或未赋值。如果我暂时不打算使用它们,我会在声明后显式地将它们赋值为 null
。如果你在工作流程中使用 Linter,它通常也能够检查你是否没有引用未声明的变量。
闭包是函数和该函数声明时所处的词法环境的组合。“词法”一词指的是词法作用域使用变量在源代码中声明的位置来确定该变量可用的位置。闭包是那些能够访问外部(封闭)函数变量(作用域链)的函数,即使外部函数已经返回之后也如此。
你为什么要使用它?
为了理解两者之间的区别,让我们看看每个函数的作用。
forEach
const a = [1, 2, 3]; const doubled = a.forEach((num, index) => { // 对 num 和/或 index 进行一些操作。 }); // doubled = undefined
map
const a = [1, 2, 3]; const doubled = a.map((num) => { return num * 2; }); // doubled = [2, 4, 6]
.forEach
和 .map()
之间的主要区别在于 .map()
返回一个新数组。如果你需要结果,但又不想改变原始数组,那么 .map()
是明确的选择。如果你只是需要遍历数组,forEach
是一个不错的选择。
它们可以用于 IIFE 中,以将一些代码封装在局部作用域中,从而使其中声明的变量不会泄漏到全局作用域。
(function () { // 某些代码。 })();
作为一次性使用的回调函数,并且不需要在其他地方使用。当处理程序直接在调用它们的代码中定义时,代码会显得更具自包含性和可读性,而无需在其他地方搜索函数体。
setTimeout(function () { console.log('Hello world!'); }, 1000);
函数式编程构造或 Lodash 的参数(类似于回调)。
const arr = [1, 2, 3]; const double = arr.map(function (el) { return el * 2; }); console.log(double); // [2, 4, 6]
过去,我使用 Backbone 作为我的模型,它鼓励一种更面向对象的方法,创建 Backbone 模型并向它们附加方法。
模块模式仍然很棒,但现在,我使用 React/Redux,它们利用基于 Flux 架构的单向数据流。我将使用普通对象表示我的应用程序模型,并编写实用纯函数来操作这些对象。状态通过动作和 reducer 进行操作,就像任何其他 Redux 应用程序一样。
我尽可能避免使用经典继承。如果我确实使用了,我也会遵守 [这些规则]。
原生对象是 ECMAScript 规范定义的 JavaScript 语言的一部分的对象,例如 String
、Math
、RegExp
、Object
、Function
等。
宿主对象由运行时环境(浏览器或 Node)提供,例如 window
、XMLHTTPRequest
等。
这个问题相当模糊。我最能猜测它的意图是它在询问 JavaScript 中的构造函数。从技术上讲,function Person(){}
只是一个普通的函数声明。约定是使用 PascalCase 来命名那些旨在用作构造函数的函数。
var person = Person()
将 Person
作为函数而不是构造函数调用。如果函数旨在用作构造函数,这样调用是一个常见的错误。通常,构造函数不返回任何内容,因此像普通函数一样调用构造函数将返回 undefined
,并将其分配给旨在作为实例的变量。
var person = new Person()
使用 new
运算符创建 Person
对象的实例,该实例继承自 Person.prototype
。另一种方法是使用 Object.create
,例如:Object.create(Person.prototype)
。
function Person(name) { this.name = name; } var person = Person('John'); console.log(person); // undefined console.log(person.name); // Uncaught TypeError: Cannot read property 'name' of undefined var person = new Person('John'); console.log(person); // Person { name: "John" } console.log(person.name); // "john"
.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
逐字取自 [MDN]:
bind()
方法创建一个新函数,当调用该新函数时,其this
关键字设置为提供的值,并且在调用新函数时提供的任何参数之前,会带有一系列给定的参数。
根据我的经验,它最常用于在你想传递给其他函数的类方法中绑定 this
的值。这在 React 组件中经常用到。
document.write()
将文本字符串写入由 document.open()
打开的文档流。当页面加载后执行 document.write()
时,它将调用 document.open
,这将清除整个文档(<head>
和 <body>
被移除!),并将内容替换为给定参数值。因此,它通常被认为是危险且容易被滥用的。
网上有一些答案解释说 document.write()
被用于分析代码或 [当你只想在 JavaScript 启用时才生效的样式] 时。它甚至被用于 HTML5 boilerplate 中以 [并行加载脚本并保持执行顺序]!然而,我怀疑这些原因可能已经过时,在现代,无需使用 document.write()
也能实现这些功能。如果我错了,请纠正我。
特性检测
特性检测涉及判断浏览器是否支持某个代码块,并根据其支持(或不支持)的情况运行不同的代码,以便浏览器始终能够提供可用的体验,而不是在某些浏览器中崩溃/报错。例如:
if ('geolocation' in navigator) { // 可以使用 navigator.geolocation } else { // 处理缺少特性 }
[Modernizr] 是一个很好的处理特性检测的库。
特性推断
特性推断像特性检测一样检查特性,但它使用另一个函数,因为它假定该函数也会存在,例如:
if (document.getElementsByTagName) { element = document.getElementById(id); }
这不是很推荐。特性检测更万无一失。
UA 字符串
这是一个浏览器报告的字符串,允许网络协议对等体识别请求软件用户代理的应用程序类型、操作系统、软件供应商或软件版本。它可以通过 navigator.userAgent
访问。然而,该字符串难以解析且可能被欺骗。例如,Chrome 既报告为 Chrome 又报告为 Safari。因此,要检测 Safari,你必须检查 Safari 字符串并且不存在 Chrome 字符串。避免使用此方法。
Ajax(异步 JavaScript 和 XML)是一组使用客户端多种 Web 技术来创建异步 Web 应用程序的 Web 开发技术。通过 Ajax,Web 应用程序可以异步(在后台)向服务器发送数据和从服务器检索数据,而不会干扰现有页面的显示和行为。通过将数据交换层与表示层分离,Ajax 允许网页(以及扩展的 Web 应用程序)动态更改内容,而无需重新加载整个页面。实际上,现代实现通常使用 JSON 而不是 XML,因为 JSON 对 JavaScript 而言是原生的,具有优势。
XMLHttpRequest
API 经常用于异步通信,或者现在可以使用 fetch()
API。
优点
缺点
JSONP(JSON with Padding)是一种常用的方法,用于绕过网络浏览器中的跨域策略,因为当前页面到跨域域的 Ajax 请求是不允许的。
JSONP 通过 <script>
标签向跨域域发出请求,通常带有 callback
查询参数,例如:https://example.com?callback=printData
。然后服务器会将数据包装在名为 printData
的函数中,并将其返回给客户端。
<script> function printData(data) { console.log(`My name is ${data.name}!`); } </script> <script src="[https://example.com?callback=printData](https://example.com?callback=printData)"></script>
printData({ name: 'Yang Shun' });
客户端必须在其全局作用域中拥有 printData
函数,并且当从跨域域接收到响应时,该函数将由客户端执行。
JSONP 可能不安全并存在一些安全隐患。由于 JSONP 实际上是 JavaScript,它可以做 JavaScript 所能做的一切,因此你需要信任 JSONP 数据的提供者。
如今,[CORS] 是推荐的方法,JSONP 被视为一种 hack。
是的。Handlebars、Underscore、Lodash、AngularJS 和 JSX。我不喜欢 AngularJS 中的模板,因为它大量使用了指令中的字符串,而且拼写错误不会被发现。JSX 是我的新宠,因为它更接近 JavaScript,几乎没有语法需要学习。现在,你甚至可以使用 ES2015 模板字符串字面量作为创建模板的快速方法,而无需依赖第三方代码。
const template = `<div>My name is: ${name}</div>`;
但是,请注意上述方法中潜在的 XSS,因为内容不会为你转义,这与模板库不同。
变量提升是一个用于解释代码中变量声明行为的术语。使用 var
关键字声明或初始化的变量,其声明会被“移动”到其模块/函数级作用域的顶部,我们称之为变量提升。然而,只有声明被提升,赋值(如果有的话)会留在原地。
请注意,声明实际上并没有移动——JavaScript 引擎在编译期间解析声明并了解声明及其作用域。通过将声明可视化为提升到其作用域的顶部,更容易理解这种行为。让我们用几个例子来解释。
console.log(foo); // undefined var foo = 1; console.log(foo); // 1
函数声明会将其函数体提升,而函数表达式(以变量声明的形式编写)只会将其变量声明提升。
// 函数声明 console.log(foo); // [Function: foo] foo(); // 'FOOOOO' function foo() { console.log('FOOOOO'); } console.log(foo); // [Function: foo] // 函数表达式 console.log(bar); // undefined bar(); // Uncaught TypeError: bar is not a function var bar = function () { console.log('BARRRR'); }; console.log(bar); // [Function: bar]
通过 let
和 const
声明的变量也会被提升。但是,与 var
和 function
不同,它们不会被初始化,并且在声明之前访问它们将导致 ReferenceError
异常。变量从块的开头到声明被处理之前都处于“暂时性死区”。
x; // undefined y; // Reference error: y is not defined var x = 'local'; let y = 'local';
当事件在 DOM 元素上触发时,如果附加了监听器,它会尝试处理该事件,然后事件会冒泡到其父级,并发生同样的事情。这种冒泡会沿着元素的祖先一直向上到 document
。事件冒泡是事件委托背后的机制。
属性在 HTML 标记上定义,但特性在 DOM 上定义。为了说明区别,假设我们的 HTML 中有这个文本字段:<input type="text" value="Hello">
。
const input = document.querySelector('input'); console.log(input.getAttribute('value')); // Hello console.log(input.value); // Hello
但是当你通过向其中添加“World!”来更改文本字段的值后,它变成:
console.log(input.getAttribute('value')); // Hello console.log(input.value); // Hello World!
扩展内置/原生 JavaScript 对象意味着向其 prototype
添加属性/函数。虽然这乍一看是个好主意,但在实践中是危险的。想象一下你的代码使用了几个库,它们都通过添加相同的 contains
方法来扩展 Array.prototype
,如果这两个方法的行为不一致,它们的实现将相互覆盖,你的代码将中断。
你可能只想扩展原生对象的唯一情况是,你想创建一个 polyfill,本质上是为 JavaScript 规范中但可能不存在于用户浏览器中的方法(因为它是一个旧浏览器)提供自己的实现。
DOMContentLoaded
事件在初始 HTML 文档完全加载和解析后触发,无需等待样式表、图像和子帧完成加载。
window
的 load
事件仅在 DOM 和所有依赖资源和资产加载完毕后触发。
==
是抽象相等运算符,而 ===
是严格相等运算符。==
运算符在进行任何必要的类型转换后比较相等性。===
运算符不会进行类型转换,因此如果两个值类型不同,===
将直接返回 false
。当使用 ==
时,可能会发生奇怪的事情,例如:
1 == '1'; // true 1 == [1]; // true 1 == true; // true 0 == ''; // true 0 == '0'; // true 0 == false; // true
我的建议是永远不要使用 ==
运算符,除了在与 null
或 undefined
进行比较时方便,其中 a == null
在 a
为 null
或 undefined
时返回 true
。
var a = null; console.log(a == null); // true console.log(a == undefined); // true
同源策略阻止 JavaScript 跨域边界发出请求。源被定义为 URI 方案、主机名和端口号的组合。此策略可防止一个页面上的恶意脚本通过该页面的文档对象模型获取对另一个网页上敏感数据的访问权限。
duplicate([1, 2, 3, 4, 5]); // [1,2,3,4,5,1,2,3,4,5]
function duplicate(arr) { return arr.concat(arr); } duplicate([1, 2, 3, 4, 5]); // [1,2,3,4,5,1,2,3,4,5]
或者使用 ES6:
const duplicate = (arr) => [...arr, ...arr]; duplicate([1, 2, 3, 4, 5]); // [1,2,3,4,5,1,2,3,4,5]
“三元”表示三,三元表达式接受三个操作数:测试条件、“then”表达式和“else”表达式。三元表达式并非 JavaScript 特有,我也不确定为什么它会出现在这个列表中。
'use strict' 是一个用于对整个脚本或单个函数启用严格模式的语句。严格模式是一种选择受限的 JavaScript 变体的方法。
优点:
this
是 undefined。缺点:
function.caller
和 function.arguments
。总的来说,我认为优点 outweigh 缺点,我从不需要依赖严格模式阻止的功能。我建议使用严格模式。
看看 [Paul Irish] 的 FizzBuzz 版本。
for (let i = 1; i <= 100; i++) { let f = i % 3 == 0, b = i % 5 == 0; console.log(f ? (b ? 'FizzBuzz' : 'Fizz') : b ? 'Buzz' : i); }
我不建议你在面试中写上面这个。坚持使用冗长但清晰的方法。有关 FizzBuzz 的更多奇特版本,请查看下面的参考链接。
每个脚本都可以访问全局作用域,如果每个人都使用全局命名空间来定义他们的变量,很可能会发生冲突。使用模块模式(IIFE)将你的变量封装在局部命名空间中。
load
事件在文档加载过程结束时触发。此时,文档中的所有对象都在 DOM 中,并且所有图像、脚本、链接和子帧都已完成加载。
DOM 事件 DOMContentLoaded
在页面 DOM 构建完成后触发,但不会等待其他资源完成加载。在某些情况下,当你不需要在初始化之前加载完整页面时,这是首选。
以下内容摘自很棒的 [Grab 前端指南],巧合的是,它是我写的!
如今,Web 开发人员将他们构建的产品称为 Web 应用程序,而不是网站。尽管这两个术语之间没有严格的区别,但 Web 应用程序往往具有高度交互性和动态性,允许用户执行操作并接收对其操作的响应。传统上,浏览器从服务器接收 HTML 并对其进行渲染。当用户导航到另一个 URL 时,需要进行全页面刷新,服务器会向新页面发送全新的 HTML。这称为服务器端渲染。
然而,在现代 SPA 中,Instead 使用客户端渲染。浏览器从服务器加载初始页面,以及整个应用程序所需的脚本(框架、库、应用程序代码)和样式表。当用户导航到其他页面时,不会触发页面刷新。页面 URL 通过 [HTML5 History API] 进行更新。新页面所需的新数据(通常为 JSON 格式)通过 [AJAX] 请求从浏览器获取到服务器。然后,SPA 通过 JavaScript 动态更新页面中的数据,这些数据已在初始页面加载时下载。此模型类似于原生移动应用程序的工作方式。
优点:
缺点:
拥有其工作知识。Promise 是一个对象,它可能在未来的某个时间点产生一个单一的值:要么是已解决的值,要么是未解决的原因(例如,发生网络错误)。Promise 可能处于以下 3 种可能状态之一:已完成 (fulfilled)、已拒绝 (rejected) 或待定 (pending)。Promise 用户可以附加回调函数来处理已完成的值或拒绝的原因。
一些常见的 polyfills 是 $.deferred
、Q 和 Bluebird,但并非所有都符合规范。ES2015 原生支持 Promise,因此现在通常不需要 polyfills。
优点
.then()
轻松编写可读的顺序异步代码。Promise.all()
轻松编写并行异步代码。缺点
一些编译成 JavaScript 的语言包括 CoffeeScript、Elm、ClojureScript、PureScript 和 TypeScript。
优点:
缺点:
实际上,ES2015 极大地改进了 JavaScript,使其编写起来更加愉快。如今,我真的看不出 CoffeeScript 的必要性。
debugger
语句
- 老旧的 console.log
调试对于对象:
for-in
循环 - for (var property in obj) { console.log(property); }
。但是,这也会遍历其继承的属性,你需要在使用之前添加 obj.hasOwnProperty(property)
检查。Object.keys()
- Object.keys(obj).forEach(function (property) { ... })
。Object.keys()
是一个静态方法,它会列出你传入的对象的 所有可枚举属性。Object.getOwnPropertyNames()
- Object.getOwnPropertyNames(obj).forEach(function (property) { ... })
。Object.getOwnPropertyNames()
是一个静态方法,它会列出你传入的对象的 所有可枚举和不可枚举属性。对于数组:
for
循环 - for (var i = 0; i < arr.length; i++)
。这里的常见陷阱是 var
位于函数作用域而不是块作用域,大多数时候你想要块作用域的迭代器变量。ES2015 引入了具有块作用域的 let
,建议改用它。因此变成:for (let i = 0; i < arr.length; i++)
。forEach
- arr.forEach(function (el, index) { ... })
。这种构造有时更方便,因为如果你只需要数组元素,就不必使用 index
。还有 every
和 some
方法,它们允许你提前终止迭代。for-of
循环 - for (let elem of arr) { ... }
。ES6 引入了一种新的循环,for-of
循环,它允许你遍历符合可迭代协议的对象,例如 String
、Array
、Map
、Set
等。它结合了 for
循环和 forEach()
方法的优点。for
循环的优点是你可以从中跳出,forEach()
的优点是它比 for
循环更简洁,因为你不需要计数器变量。使用 for-of
循环,你可以同时获得跳出循环的能力和更简洁的语法。大多数时候,我更喜欢 .forEach
方法,但这实际上取决于你想要做什么。在 ES6 之前,当我们使用 break
需要提前终止循环时,我们使用 for
循环。但现在有了 ES6,我们可以使用 for-of
循环做到这一点。当我需要更大的灵活性时,例如每次循环迭代多次增加迭代器,我将使用 for
循环。
另外,当使用 for-of
循环时,如果你需要访问每个数组元素的索引和值,你可以使用 ES6 数组的 entries()
方法和解构来实现:
const arr = ['a', 'b', 'c']; for (let [index, elem] of arr.entries()) { console.log(index, ': ', elem); }
不变性是函数式编程的核心原则,对面向对象程序也有很多益处。可变对象是创建后其状态可以修改的对象。不可变对象是创建后其状态不能修改的对象。
在 JavaScript 中,一些内置类型(数字、字符串)是不可变的,但自定义对象通常是可变的。
一些内置的不可变 JavaScript 对象是 Math
、Date
。
这里有几种在普通 JavaScript 对象上添加/模拟不可变性的方法。
对象常量属性
通过结合 writable: false
和 configurable: false
,你可以本质上创建一个常量(不能更改、重新定义或删除)作为对象属性,如下所示:
let 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(...)
:
var myObject = { a: 2, }; Object.preventExtensions(myObject); myObject.b = 3; myObject.b; // undefined
在非严格模式下,b
的创建会静默失败。在严格模式下,它会抛出 TypeError
。
密封
Object.seal()
创建一个“密封”对象,这意味着它接受一个现有对象,并对其调用 Object.preventExtensions()
,但也会将其所有现有属性标记为 configurable: false
。
因此,你不仅不能再添加任何属性,也不能重新配置或删除任何现有属性(尽管你仍然可以修改它们的值)。
冻结
Object.freeze()
创建一个冻结对象,这意味着它接受一个现有对象,并对其调用 Object.seal()
,但它也会将所有“数据访问器”属性标记为 writable:false
,以便它们的值不能更改。
这种方法是你可以为对象本身达到的最高级别的不可变性,因为它阻止对对象或其任何直接属性的任何更改(尽管,如上所述,任何引用的其他对象的内容不受影响)。
var immutable = Object.freeze({});
冻结对象不允许向对象添加新属性,并阻止删除或更改现有属性。Object.freeze()
保留了对象的枚举性、可配置性、可写性和原型。它返回传入的对象,并且不创建冻结副本。
优点
缺点
替代方法是使用 const
声明结合上面提到的创建技术。对于“修改”对象,请使用扩展运算符、Object.assign
、Array.concat()
等来创建新对象,而不是修改原始对象。
示例:
// 数组示例 const arr = [1, 2, 3]; const newArr = [...arr, 4]; // [1, 2, 3, 4] // 对象示例 const human = Object.freeze({ race: 'human' }); const john = { ...human, name: 'John' }; // {race: "human", name: "John"} const alienJohn = { ...john, race: 'alien' }; // {race: "alien", name: "John"}
同步函数是阻塞的,而异步函数不是。在同步函数中,语句在下一条语句运行之前完成。在这种情况下,程序完全按照语句的顺序进行评估,如果其中一条语句花费很长时间,程序的执行就会暂停。
异步函数通常接受一个回调函数作为参数,并且在异步函数被调用后,执行会立即继续到下一行。回调函数只有在异步操作完成且调用堆栈为空时才会被调用。加载来自 Web 服务器的数据或查询数据库等繁重操作应该异步完成,以便主线程可以继续执行其他操作,而不是阻塞直到该长时间操作完成(在浏览器中,UI 将会冻结)。
事件循环是一个单线程循环,它监视调用堆栈并检查任务队列中是否有待完成的工作。如果调用堆栈为空并且任务队列中有回调函数,则会将一个函数出队并推送到调用堆栈上执行。
如果你还没有看过 Philip Robert 关于事件循环的演讲,你应该看看。它是 JavaScript 上观看次数最多的视频之一。
前者是函数声明,后者是函数表达式。关键区别在于函数声明的函数体会被提升,而函数表达式的函数体则不会(它们与变量具有相同的提升行为)。有关提升的更多解释,请参阅上面 关于提升 的问题。如果你尝试在函数表达式定义之前调用它,你将得到一个 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'); };
使用 var
关键字声明的变量作用域限定于它们创建的函数,如果创建在任何函数之外,则作用域限定于全局对象。let
和 const
是 块级作用域,这意味着它们只能在最近的一组大括号(函数、if-else 块或 for 循环)中访问。
function foo() { // 所有变量都可以在函数内部访问。 var bar = 'bar'; let baz = 'baz'; const qux = 'qux'; console.log(bar); // bar console.log(baz); // baz console.log(qux); // qux } console.log(bar); // ReferenceError: bar is not defined console.log(baz); // ReferenceError: baz is not defined console.log(qux); // ReferenceError: qux is not defined
if (true) { var bar = 'bar'; let baz = 'baz'; const qux = 'qux'; } // var 声明的变量可以在函数作用域的任何地方访问。 console.log(bar); // bar // let 和 const 定义的变量在它们定义的块之外无法访问。 console.log(baz); // ReferenceError: baz is not defined console.log(qux); // ReferenceError: qux is not defined
var
允许变量提升,这意味着它们可以在声明之前在代码中引用。let
和 const
不允许这样做,而是会抛出错误。
console.log(foo); // undefined var foo = 'foo'; console.log(baz); // ReferenceError: can't access lexical declaration 'baz' before initialization let baz = 'baz'; console.log(bar); // ReferenceError: can't access lexical declaration 'bar' before initialization const bar = 'bar';
使用 var
重新声明变量不会抛出错误,但 let
和 const
会。
var foo = 'foo'; var foo = 'bar'; console.log(foo); // "bar" let baz = 'baz'; let baz = 'qux'; // Uncaught SyntaxError: Identifier 'baz' has already been declared
let
和 const
的区别在于 let
允许重新分配变量的值,而 const
不允许。
// 这很好。 let foo = 'foo'; foo = 'bar'; // 这会导致异常。 const baz = 'baz'; baz = 'qux';
让我们先看看每种情况的例子:
// ES5 函数构造函数 function Person(name) { this.name = name; } // ES6 类 class Person { constructor(name) { this.name = name; } }
对于简单的构造函数,它们看起来非常相似。
构造函数的主要区别在于使用继承时。如果我们想创建一个 Student
类,它继承自 Person
并添加一个 studentId
字段,除了上面之外,我们还需要做以下事情。
// ES5 函数构造函数 function Student(name, studentId) { // 调用超类的构造函数来初始化超类派生的成员。 Person.call(this, name); // 初始化子类自己的成员。 this.studentId = studentId; } Student.prototype = Object.create(Person.prototype); Student.prototype.constructor = Student; // ES6 类 class Student extends Person { constructor(name, studentId) { super(name); this.studentId = studentId; } }
ES5 中使用继承要冗长得多,而 ES6 版本更容易理解和记忆。
箭头函数的一个明显好处是简化了创建函数所需的语法,无需 function
关键字。箭头函数中的 this
也绑定到封闭作用域,这与常规函数不同,常规函数中的 this
由调用它的对象决定。词法作用域的 this
在调用回调函数时很有用,尤其是在 React 组件中。
在构造函数中将箭头函数用作方法的主要优点是 this
的值在函数创建时就被设置,之后不能更改。因此,当构造函数用于创建新对象时,this
将始终引用该对象。例如,假设我们有一个 Person
构造函数,它接受一个名字作为参数,并有两个方法可以 console.log
该名字,一个是常规函数,一个是箭头函数:
const Person = function (firstName) { this.firstName = firstName; 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 var sayNameFromWindow1 = john.sayName1; sayNameFromWindow1(); // undefined (因为 'this' 现在是 window 对象) var sayNameFromWindow2 = john.sayName2; sayNameFromWindow2(); // John
这里的关键在于,对于普通函数,this
可以改变,但对于箭头函数,上下文始终保持不变。因此,即使你将箭头函数传递到应用程序的不同部分,也无需担心上下文会改变。
这在 React 类组件中特别有用。如果你使用普通函数定义了一个类方法(例如点击处理程序),然后将该点击处理程序作为 prop 传递给子组件,你还需要在父组件的构造函数中绑定 this
。如果你改用箭头函数,则无需绑定 this
,因为该方法将自动从其封闭的词法上下文中获取其 this
值。
高阶函数是任何接受一个或多个函数作为参数(并使用它们来操作某些数据)和/或返回一个函数作为结果的函数。高阶函数旨在抽象一些重复执行的操作。经典的例子是 map
,它接受一个数组和一个函数作为参数。然后 map
使用此函数转换数组中的每个项,返回一个包含转换后数据的新数组。JavaScript 中其他流行的例子是 forEach
、filter
和 reduce
。高阶函数不仅需要操作数组,因为从另一个函数返回函数有很多用例。Function.prototype.bind
是 JavaScript 中的一个这样的例子。
Map
假设我们有一个名称数组,我们需要将每个字符串转换为大写。
const names = ['irish', 'daisy', 'anna'];
命令式方式将如下所示:
const transformNamesToUppercase = function (names) { const results = []; for (let i = 0; i < names.length; i++) { results.push(names[i].toUpperCase()); } return results; }; transformNamesToUppercase(names); // ['IRISH', 'DAISY', 'ANNA']
使用 .map(transformerFn)
使代码更短且更具声明性。
const transformNamesToUppercase = function (names) { return names.map((name) => name.toUpperCase()); }; transformNamesToUppercase(names); // ['IRISH', 'DAISY', 'ANNA']
解构是 ES6 中可用的一种表达式,它提供了一种简洁方便的方式来提取对象或数组的值并将它们放入不同的变量中。
数组解构
// 变量赋值。 const foo = ['one', 'two', 'three']; const [one, two, three] = foo; console.log(one); // "one" console.log(two); // "two" console.log(three); // "three"
// 交换变量 let a = 1; let b = 3; [a, b] = [b, a]; console.log(a); // 3 console.log(b); // 1
对象解构
// 变量赋值。 const o = { p: 42, q: true }; const { p, q } = o; console.log(p); // 42 console.log(q); // true
模板字面量有助于简化字符串插值,或在字符串中包含变量。在 ES2015 之前,通常会这样做:
var person = { name: 'Tyler', age: 28 }; console.log( 'Hi, my name is ' + person.name + ' and I am ' + person.age + ' years old!', ); // 'Hi, my name is Tyler and I am 28 years old!'
使用模板字面量,你现在可以像这样创建相同的输出:
const person = { name: 'Tyler', age: 28 }; console.log(`Hi, my name is ${person.name} and I am ${person.age} years old!`); // 'Hi, my name is Tyler and I am 28 years old!'
请注意,你使用反引号,而不是引号,来表示你正在使用模板字面量,并且你可以在 ${}
占位符内插入表达式。
第二个有用的用例是创建多行字符串。在 ES2015 之前,你可以像这样创建多行字符串:
console.log('This is line one.\nThis is line two.'); // This is line one. // This is line two.
或者如果你想在代码中将其分成多行,这样你就无需在文本编辑器中向右滚动来阅读长字符串,你也可以像这样编写它:
console.log('This is line one.\n' + 'This is line two.'); // This is line one. // This is line two.
然而,模板字面量保留你添加的任何间距。例如,要创建我们上面创建的相同多行输出,你只需执行以下操作:
console.log(`This is line one. This is line two.`); // This is line one. // This is line two.
模板字面量的另一个用例是作为简单变量插值的模板库的替代品:
const person = { name: 'Tyler', age: 28 }; document.body.innerHTML = ` <div> <p>Name: ${person.name}</p> <p>Age: ${person.age}</p> </div> `;
请注意,你的代码通过使用 .innerHTML
可能容易受到 XSS 攻击。如果数据来自用户,请在显示之前对其进行清理!
柯里化是一种模式,其中具有多个参数的函数被分解为多个函数,当按系列调用时,它们将一次累积所有必需的参数。此技术对于使以函数式风格编写的代码更易于阅读和组合很有用。重要的是要注意,要使函数柯里化,它需要首先是一个函数,然后分解为一系列每个接受一个参数的函数。
function curry(fn) { if (fn.length === 0) { return fn; } function _curried(depth, args) { return function (newArgument) { if (depth - 1 === 0) { return fn(...args, newArgument); } return _curried(depth - 1, [...args, newArgument]); }; } return _curried(fn.length, []); } function add(a, b) { return a + b; } var curriedAdd = curry(add); var addFive = curriedAdd(5); var result = [0, 1, 2, 3, 4, 5].map(addFive); // [5, 6, 7, 8, 9, 10]
ES6 的扩展语法在函数式编程中非常有用,因为我们可以轻松创建数组或对象的副本,而无需借助 Object.create
、slice
或库函数。此语言特性在 Redux 和 RxJS 项目中经常使用。
function putDookieInAnyArray(arr) { return [...arr, 'dookie']; } const result = putDookieInAnyArray(['I', 'really', "don't", 'like']); // ["I", "really", "don't", "like", "dookie"] const person = { name: 'Todd', age: 29, }; const copyOfTodd = { ...person };
ES6 的剩余语法提供了一种速记方式,用于包含任意数量的参数以传递给函数。它类似于扩展语法的反向操作,它接受数据并将其填充到数组中,而不是解包数据数组,并且它在函数参数以及数组和对象解构赋值中都有效。
function addFiveToABunchOfNumbers(...numbers) { return numbers.map((x) => x + 5); } const result = addFiveToABunchOfNumbers(4, 5, 6, 7, 8, 9, 10); // [9, 10, 11, 12, 13, 14, 15] const [a, b, ...rest] = [1, 2, 3, 4]; // a: 1, b: 2, rest: [3, 4] const { e, f, ...others } = { e: 1, f: 2, g: 3, h: 4, }; // e: 1, f: 2, others: { g: 3, h: 4 }
这取决于 JavaScript 环境。
在客户端(浏览器环境)中,只要变量/函数在全局作用域 (window
) 中声明,所有脚本都可以引用它们。或者,通过 RequireJS 采用异步模块定义(AMD)以实现更模块化的方法。
在服务器端(Node.js),常用方法是使用 CommonJS。每个文件都被视为一个模块,它可以通过将变量和函数附加到 module.exports
对象来导出它们。
ES2015 定义了一种模块语法,旨在取代 AMD 和 CommonJS。这最终将在浏览器和 Node 环境中得到支持。
静态类成员(属性/方法)不与类的特定实例绑定,无论哪个实例引用它,它们都具有相同的值。静态属性通常是配置变量,静态方法通常是纯实用函数,不依赖于实例的状态。