Javascript进阶
作用域(Scope)
在JavaScript中,作用域的概念是理解代码执行上下文和变量访问权限的关键。
全局作用域(Global Scope)
全局作用域是指在整个脚本或整个HTML文档的最外层定义的变量和函数的作用范围。这些变量和函数可以在任何地方被访问到,包括所有的函数内部。全局变量是在函数外部声明的,或者没有使用var
、let
、const
关键字直接赋值的变量。
- 特点:
- 可以在任何地方访问。
- 在页面关闭之前一直存在。
- 所有未声明的变量自动成为全局变量(非严格模式下)。
- 在浏览器环境中,全局变量实际上是
window
对象的属性。
1 | var globalVar = "I'm a global variable"; // 全局变量 |
局部作用域(Local Scope)
局部作用域指的是变量仅在其被定义的作用域内可访问,通常指函数内部或特定代码块内部。根据定义的方式不同,可以细分为函数作用域和块级作用域。
函数作用域(Function Scope)
在ES6之前,JavaScript主要通过函数来创建局部作用域。如果一个变量是在函数内部使用var
关键字声明的,那么这个变量就只能在该函数内部访问,不能从外部访问。
- 特点:
- 使用
var
声明的变量具有函数作用域,而不是块作用域。 - 函数内的局部变量会在函数调用开始时创建,在函数执行完毕后销毁。
- 如果在函数内部未使用
var
、let
、const
声明变量,则该变量会成为全局变量。
- 使用
1 | function functionScopeExample() { |
块级作用域(Block Scope)
随着ES6的引入,JavaScript增加了对块级作用域的支持,这主要是通过let
和const
关键字实现的。块级作用域限制了变量的作用范围到最近的一对大括号{}
内,比如在一个if
语句、for
循环或简单的代码块中。
- 特点:
let
和const
声明的变量只在它们所在的块内有效。- 不允许重复声明相同的变量名。
- 解决了
var
带来的变量提升问题。
1 | if (true) { |
作用域链(Scope Chain)
作用域链(Scope Chain)是JavaScript中一个非常重要的概念,它与作用域紧密相关,并且决定了变量在程序中的可见性和生命周期。
什么是作用域链?
作用域链可以被理解为一个对象列表,这些对象被称为变量对象(Variable Object, VO),它们包含了当前执行上下文中定义的所有变量和函数。当JavaScript引擎需要查找某个变量时,它会首先在当前的作用域内寻找该变量。**如果找不到,则会沿着作用域链向上一级作用域继续查找,**直到找到全局作用域为止。如果在全局作用域中仍然找不到该变量,则会在非严格模式下隐式声明该变量,或是在严格模式下抛出错误。
作用域链的形成
作用域链的形成发生在函数创建的时候,而不是在函数调用时确定的。这是因为JavaScript采用的是词法作用域(Lexical Scope),这意味着变量的作用域是由变量在源代码中的位置决定的,而不是由函数调用的位置决定的。因此,在嵌套函数的情况下,内部函数可以访问其外部函数的变量,即使外部函数已经执行完毕并且其执行上下文已经从堆栈中移除。
作用域链的作用
作用域链有两个主要作用:
变量查找:当JavaScript引擎需要解析一个标识符(如变量名)时,它会从当前作用域开始搜索,如果没有找到则沿着作用域链向父级作用域继续搜索,直到找到匹配的标识符或到达全局作用域。
1
2
3
4
5
6
7
8function outer() {
var x = "outer";
function inner() {
console.log(x); // 输出 "outer"
}
inner();
}
outer();实现闭包:通过作用域链,JavaScript允许内部函数保持对外部函数作用域的引用,即使外部函数已经执行完毕。这种机制使得闭包成为可能,让内部函数可以在之后的任何时间点访问外部函数的变量。
1
2
3
4
5
6
7
8
9
10
11function createCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 输出 1
counter(); // 输出 2
垃圾回收机制(Garbage Collection)
JavaScript
中的内存生命周期
JavaScript
中的内存生命周期主要分为三个阶段:分配内存、使用内存和释放内存。
1. 分配内存
在JavaScript中,当声明变量或创建对象时,JavaScript引擎会自动为这些值分配内存空间。根据数据类型的不同,内存分配的方式也有所不同:
基本数据类型(如
number
,string
,boolean
,undefined
,null
,symbol
)通常存储在栈内存中。例如:1
2const n = 123; // 给数值变量分配内存
const s = "azerty"; // 给字符串分配内存引用数据类型(如对象、数组、函数等)则存储在堆内存中,栈内存中存放的是指向实际对象的引用地址。例如:
1
2
3
4const o = {
a: 1,
b: null,
}; // 为对象及其包含的值分配内存
此外,某些函数调用的结果也可能导致内存分配,比如:
1 | const d = new Date(); // 为Date对象分配内存 |
2. 使用内存
一旦内存被分配,就可以通过读取或写入操作来使用它。这包括对变量的操作,如赋值、修改属性值,以及将对象作为参数传递给函数等。
例如:
1 | console.log(o.a); // 读取内存中的值 |
3. 释放内存
当内存不再需要时,应该将其释放以供后续使用。在JavaScript中,这个过程是自动化的,由垃圾回收器(Garbage Collector, GC)负责执行。GC的主要任务是识别那些不再使用的内存,并将其标记为可回收。
垃圾回收的基本概念
在JavaScript中,垃圾回收器负责监控内存分配并确定何时一块已分配的内存不再需要被使用。这个过程是自动化的,开发者通常不需要手动干预。
主要垃圾回收算法
1. 引用计数法(Reference Counting)
- 工作原理:每当创建一个对象时,它都会有一个引用计数器,用于记录有多少个引用指向该对象。当引用数量降为零时,表示没有其他对象再引用此对象,因此可以安全地回收该对象占用的内存。
- 优点:简单直接。
- 缺点:无法处理循环引用的情况,即两个或多个对象互相引用形成闭环,导致即使这些对象实际上已经不可达也不会被回收。
1 | function referenceCountingExample() { |
2. 标记-清除法(Mark-and-Sweep)
- 工作原理:从根部开始遍历所有可达的对象,并给它们打上标记。然后清理未被标记的对象,因为它们被认为是不可达的,可以被安全地回收。
- 优点:解决了引用计数法中的循环引用问题。
- 缺点:可能导致内存碎片化,尽管现代引擎对此进行了优化。
1 | let x = { a: 1 }; |
3. 分代收集(Generational Collection)
- 工作原理:将对象分为新生代和老生代。新创建的对象首先放置于新生代区域,这里的对象生命周期较短,采用复制算法进行快速回收;如果对象经过多次垃圾回收后仍然存活,则移动至老生代区域,这里采用标记-清除或标记-整理算法。
- 优点:提高了垃圾回收效率,减少了对程序执行的影响。
闭包(Closure)
JavaScript闭包是一个强大且复杂的概念,它允许一个函数访问其词法作用域(即定义该函数时的作用域)中的变量,即使这个函数是在其词法作用域之外被调用的。闭包使得函数能够“记住”并操作这些外部变量,即便在原始作用域已经执行完毕之后也是如此。
什么是闭包?
闭包是由一个函数和与其相关的引用环境组合而成的对象**。当一个函数嵌套在另一个函数内部,并且内层函数引用了外层函数的局部变量时,就形成了闭包。**简单来说,闭包让内层函数可以捕获并保存外层函数的变量状态。
1 | function makeFunc() { |
在这个例子中,makeFunc
返回了displayName
函数,而displayName
函数引用了makeFunc
中的name
变量。因此,当我们调用myFunc
时,尽管makeFunc
已经执行完毕,displayName
仍然能访问到name
变量的值,这就是闭包的效果。
- 闭包=内层函数+外层函数的变量
工作原理
闭包之所以能够工作,是因为JavaScript采用的是词法作用域(lexical scoping),而不是动态作用域。这意味着函数的作用域是在定义时确定的,而不是在运行时。当创建闭包时,它不仅包含了函数体本身,还包含了函数创建时的作用域链。
应用场景
封装私有变量
闭包可以用来创建封装的模块,隐藏内部实现细节,保护变量不被外部直接访问或修改:
1 | function createCounter() { |
数据缓存与惰性求值
利用闭包可以缓存计算结果或者延迟某些计算直到真正需要的时候:
1 | function expensiveOperation(n) { |
函数柯里化
闭包可以用于创建柯里化的函数,这是一种函数式编程技术,允许部分应用参数:
1 | function add(x) { |
闭包与内存泄漏
由于闭包能够捕获并持有对外部作用域中变量的引用,**这就意味着只要闭包存在,它所引用的所有变量都不会被垃圾回收机制回收。**如果这种引用关系长时间保持,尤其是当这些变量包含大量数据或者频繁更新时,就可能造成内存泄漏。
1. 不必要的全局变量引用
如果闭包内部无意间创建了对全局对象的引用,并且这个引用没有被适当地清除,那么相关的全局对象就不会被垃圾回收,进而导致内存泄漏。
2. DOM元素引用
在Web开发中,如果通过闭包保存了对DOM元素的引用,并且之后该DOM元素从文档中移除,但如果仍然存在对该元素的引用,那么这个DOM元素就不会被回收,这也会导致内存泄漏。
3. 长时间运行的定时器或事件监听器
如果将一个闭包装入了一个长时间运行的定时器(如setInterval
)或作为事件监听器添加到DOM元素上,并且在不需要的时候没有正确地清理这些定时器或监听器,那么闭包中的变量引用就会一直存在,导致内存泄漏。
1 | var theThing = null; |
在这个例子中,每次调用replaceThing
函数都会创建一个新的闭包,并且每个闭包都保留了对前一个theThing
对象的引用。随着时间推移,这会导致大量的内存被占用而无法释放。
4. 循环引用
虽然现代浏览器已经优化了循环引用的问题,但在某些情况下,特别是涉及到DOM节点和JavaScript对象之间的循环引用时,仍有可能引发内存泄漏。
最佳实践
为了避免因闭包引起的内存泄漏,可以采取如下措施:
- 及时解除不必要的引用:一旦确定某个对象不再需要,应该尽早设置相关引用为
null
,以帮助垃圾回收机制识别并回收相应的内存。 - 清理定时器和事件监听器:确保在组件销毁之前,取消所有未使用的定时器和事件监听器。
- 谨慎处理大型数据结构:对于那些包含大量数据的对象,在它们不再需要时,应该显式地释放其资源。
变量提升(Variable Hoisting)
变量提升是指使用var
关键字声明的变量会被提升到当前作用域的顶部,但仅限于声明部分,初始化不会被提升。这意味着可以在声明之前访问该变量,但是它的值会是undefined
,直到实际赋值操作被执行。
例如:
1 | console.log(foo); // 输出: undefined |
这段代码实际上会被JavaScript引擎解析为如下形式:
1 | var foo; // 提升变量声明 |
函数提升(Function Hoisting)
函数提升分为两种情况:函数声明和函数表达式。
函数声明:整个函数体都会被提升到作用域的顶部。这意味着你可以先调用函数再定义它,并且能够正常工作。
1
2
3
4foo(); // 正常工作并输出 "Hello"
function foo() {
console.log("Hello");
}这段代码等价于:
1
2
3
4function foo() { // 函数声明被提升
console.log("Hello");
}
foo();函数表达式:只有变量声明被提升,而函数体(即赋值部分)不会被提升。如果尝试在定义前调用这样的函数,则会导致错误或返回
undefined
。1
2
3
4bar(); // TypeError: bar is not a function
var bar = function() {
console.log("World");
};实际上相当于:
1
2
3
4
5var bar; // 只有变量声明被提升
bar(); // 尝试调用未定义的函数
bar = function() { // 函数赋值没有被提升
console.log("World");
};
需要注意的(Hoisting else)
提升的优先级
需要注意的是,函数声明的提升优先级高于变量声明。如果有同名的函数声明和变量声明,函数声明会覆盖变量声明。然而,如果之后有变量赋值给相同名称,那么这个赋值操作将会覆盖之前的函数声明。
例如:
1 | console.log(foo); // 输出: [Function: foo] |
在这个例子中,首先函数声明foo
被提升并覆盖了任何可能存在的同名变量声明。然后,变量赋值操作将foo
设置为字符串'bar'
,从而覆盖了函数声明。
ECMAScript 6的变化
自ECMAScript 6起,引入了let
和const
关键字来声明变量,它们具有块级作用域并且不会发生变量提升。这意味着在声明之前访问这些变量会导致引用错误(ReferenceError),因为它们存在于所谓的“暂时性死区”(Temporal Dead Zone, TDZ)内,直到声明语句被执行。
暂时性死区(Temporal Dead Zone)
暂时性死区(Temporal Dead Zone,简称TDZ)是ECMAScript 6(ES6或ES2015)引入的一个概念,主要与使用let
和const
关键字声明的变量相关。它定义了一个区域,在这个区域内尝试访问尚未声明的变量会导致运行时错误。
本质
当控制流进入一个新的作用域(如一个代码块),在这个作用域内用let
或const
声明的变量会被创建,但在此之前,这些变量不能被访问或使用。如果试图在声明之前访问它们,JavaScript引擎将抛出ReferenceError
。换句话说,即使变量已经存在于作用域中,但在其声明之前访问它们是非法的,并且会导致错误。
示例
考虑以下代码:
1 | console.log(a); // ReferenceError: Cannot access 'a' before initialization |
这里,在let a = 2;
语句执行前,任何对a
的访问都会导致错误。这是因为从进入作用域开始直到let
声明语句的位置,构成了a
的暂时性死区。
再来看一个稍微复杂一点的例子:
1 | if (true) { |
在这个例子中,从if
语句的开始到let tmp = 3;
这一行之间,构成了tmp
的暂时性死区。在这一区域内尝试访问tmp
会导致ReferenceError
。
暂时性死区的影响
typeof操作符:通常情况下,
typeof
操作符对于未定义的变量不会抛出错误,而是返回"undefined"
。然而,对于处于暂时性死区中的变量,使用typeof
同样会抛出ReferenceError
。1
2typeof b; // ReferenceError: Cannot access 'b' before initialization
let b = 4;函数参数:如果函数参数依赖于其他参数的值作为默认值,而后者还未声明,则也可能遇到暂时性死区的问题。
1
2
3
4function foo(x = y, y = 2) {
return [x, y];
}
foo(); // ReferenceError: Cannot access 'y' before initialization
为什么需要暂时性死区?
暂时性死区的设计是为了减少编程错误。通过强制要求变量必须先声明后使用,可以避免一些由于变量提升带来的意外行为,尤其是在使用var
关键字时可能出现的情况。
在JavaScript中,arguments
对象和剩余参数(Rest Parameters)都是用于处理函数调用时传入的参数,但它们之间存在一些关键的区别。下面我将详细解释两者,并结合实际应用示例来说明它们的使用方法。
函数剩余参数(Rest Parameters)
arguments
对象
arguments
是一个类数组对象,它包含了传递给函数的所有实参,无论这些实参是否与函数定义中的形参匹配。arguments
对象的一个特点是它不是真正的数组,这意味着你不能直接在其上使用数组的方法,如 .map()
或 .filter()
等 。
1 | function example() { |
转换为数组
由于 arguments
不是真正的数组,若想使用数组的方法,你需要先将其转换为数组:
1 | function example() { |
或者使用 ES6 的 Array.from()
方法或展开运算符 ...
:
1 | function example() { |
函数剩余参数(Rest Parameters)
函数剩余参数(Rest Parameters)是ES6(ECMAScript 2015)引入的一种语法特性,它提供了一种更简洁的方式来处理传递给函数的不定数量的参数。通过使用三个点 ...
前缀,剩余参数允许我们将一个不定数量的实参表示为一个数组。
基本概念
- 剩余参数是一个真正的数组实例,与传统的
arguments
对象不同,后者不是一个真正的数组。 - 它只能出现在函数参数列表的最后,并且会收集从该位置开始的所有参数到一个数组中。
例如:
1 | function sum(...theArgs) { |
在这个例子中,sum
函数使用了剩余参数 theArgs
来接收任意数量的参数,并利用数组的 reduce
方法来计算这些参数的总和。
特性与优点
- 灵活性:剩余参数使得函数能够接受任意数量的参数,增加了函数的灵活性和可重用性。
- 简洁性:相比手动创建数组来存储参数,剩余参数减少了代码量并提高了可读性。
- 兼容性:可以与其他ES6特性如箭头函数、解构赋值等结合使用,以更加高效地编写代码。
注意事项
- 剩余参数必须是函数参数列表中的最后一个参数,因为它将捕获所有剩余的参数。
- 剩余参数不能用于箭头函数中,因为箭头函数没有自己的
this
,arguments
,super
或new.target
。 - 可以在剩余参数上使用任何数组方法,而
arguments
对象则不可以直接使用数组的方法。
实际应用示例
下面的例子展示了如何使用剩余参数实现一个简单的加法器函数,它可以对任意数量的数字进行求和:
1 | function sum(first, ...rest) { |
展开运算符(Spread Operator)
展开运算符(Spread Operator)是ES6(ECMAScript 2015)引入的一种语法特性,它允许数组、字符串或对象的元素被“展开”为独立的元素。展开运算符使用三个连续的点号 ...
来表示,并且可以应用于多种场合,包括函数调用、数组字面量构造、对象字面量构造等。
在函数调用时展开数组元素
当你需要将一个数组作为参数传递给一个函数时,可以使用展开运算符来代替 apply()
方法:
1 | function sum(x, y, z) { |
在数组字面量中合并多个数组
展开运算符可以用来轻松地合并两个或更多的数组:
1 | const fruits = ['apple', 'banana']; |
复制数组
使用展开运算符可以创建一个现有数组的浅拷贝:
1 | const arr = [1, 2, 3]; |
需要注意的是,这种复制方式只适用于数组的第一层,对于嵌套的对象或数组,展开运算符只会复制引用而不是深层的内容。
在对象字面量中合并对象
在ES7及以后版本中,可以使用展开运算符来合并对象:
1 | const obj1 = { foo: 'bar', x: 42 }; |
如果存在相同的键名,后出现的对象属性会覆盖前面的对象属性。
使用展开运算符代替 apply
方法
展开运算符还可以用于简化某些原本需要用 apply
方法实现的操作,比如求一组数中的最大值:
1 | const numbers = [9, 3, 2]; |
注意事项
- 展开运算符只能用于可迭代对象(如数组、字符串、Map 和 Set),不能直接用于普通对象。
- 对于对象的展开,只有对象自身的可枚举属性会被展开,不包括从原型链继承来的属性。
- 展开运算符进行的是浅拷贝,这意味着如果数组或对象中包含其他对象,那么这些内部对象的引用也会被复制,而不是深拷贝整个结构。
箭头函数(Arrow Functions)
箭头函数(Arrow Functions)是ECMAScript 2015(ES6)引入的一种新的函数定义方式,它提供了一种更加简洁的语法来编写匿名函数。
箭头函数的基本语法
箭头函数的基本结构如下:
1 | (param1, param2, ..., paramN) => { statements } |
如果只有一个参数,可以省略括号:
1 | param => { statements } |
对于单个表达式返回值的情况,可以进一步简化为:
1 | (param1, param2, ..., paramN) => expression |
例如:
1 | // 普通函数写法 |
this
关键字的行为
箭头函数与普通函数的一个重要区别在于this
关键字的行为。在普通函数中,this
的值取决于函数是如何被调用的;而在箭头函数中,this
是在函数创建时就确定了,并且总是指向其外层作用域中的this
。
1 | const obj = { |
arguments
和剩余参数
箭头函数没有自己的arguments
对象,取而代之的是可以通过剩余参数(Rest Parameters)来获取传入的所有参数。
1 | function regularFunction() { |
不能作为构造器
由于箭头函数没有自己的this
和prototype
属性,所以它们不能通过new
关键字来实例化对象。
1 | const ArrowFunc = () => {}; |
其他特性
- 无
super
、new.target
:箭头函数不支持这些关键字。 - 隐式的返回值:当箭头函数只包含一个表达式时,可以省略
return
关键字和大括号。 - 不能用作Generator函数:因为箭头函数不支持
yield
关键字。
使用场景
箭头函数非常适合用于那些不需要独立上下文的小型回调函数,比如数组方法中的回调(如map
、filter
等)、事件处理器等。
1 | [1, 2, 3].map(n => n * 2); // 返回 [2, 4, 6] |
总之,箭头函数以其简洁的语法和固定的this
绑定机制,为JavaScript开发者提供了更加强大和灵活的工具。不过,在需要动态this
或者需要使用arguments
对象的情况下,还是应该选择普通函数。
数组解构(Array Destructuring)
数组解构是ES6(ECMAScript 2015)引入的一种语法特性,它提供了一种简洁的方式来从数组中提取数据,并将其赋值给变量。这种特性不仅使代码更加直观和易读,而且也提高了开发效率。
基本用法
最基本的数组解构形式是从一个已知结构的数组中提取元素并赋值给对应的变量。例如:
1 | let [a, b] = [1, 2]; |
这里,[1, 2]
是一个数组,而[a, b]
是解构模式,用于将数组中的第一个元素赋值给变量a
,第二个元素赋值给变量b
。
跳过元素
在解构过程中,如果不需要某些元素,可以通过跳过它们来实现:
1 | let [a,,b] = [1, 2, 3]; |
这里,通过使用两个逗号,我们跳过了数组中的第二个元素。
使用默认值
当数组中的元素不存在或为undefined
时,可以指定默认值:
1 | let [a = 1, b = 2] = [undefined, 3]; |
解构剩余部分
使用...
操作符,我们可以将数组剩下的部分收集到一个新的数组中:
1 | let [a, ...rest] = [1, 2, 3, 4]; |
函数返回值解构
函数也可以返回数组,然后你可以直接对返回值进行解构:
1 | function returnArray() { |
交换变量值
解构赋值使得交换两个变量的值变得非常简单:
1 | let a = 1; |
复杂场景
对于更复杂的数据结构,比如嵌套数组,也可以使用解构赋值:
1 | let nested = [1, [2, 3]]; |
对象解构(Object Destructuring)
对象解构是ES6(ECMAScript 2015)引入的一种特性,它允许我们从对象中提取属性并将其赋值给变量。这种语法不仅让代码更加简洁和易读,而且提高了开发效率。
基本用法
对象解构的基本形式是从一个对象中提取属性,并将这些属性的值赋给同名的变量:
1 | let person = { name: "Sarah", country: "Nigeria", job: "Developer" }; |
这里,{ name, country, job }
是解构模式,用于从person
对象中提取相应的属性并赋值给同名的变量。
设置别名
有时我们可能想要为提取的属性设置不同的变量名。这可以通过在解构模式中指定属性名后跟冒号和新的变量名来实现:
1 | let { name: personName, country: personCountry } = person; |
在这个例子中,name
和country
属性被分别赋值给了personName
和personCountry
变量。
默认值
当对象中没有某个属性或属性值为undefined
时,可以提供默认值:
1 | let { name = "Unknown", age = 30 } = {}; |
解构嵌套对象
对于包含嵌套结构的对象,也可以使用解构赋值:
1 | let employee = { |
函数参数解构
解构赋值同样可以应用于函数参数,从而简化函数签名和内部逻辑:
1 | function printDetails({ name, job }) { |
使用场景
对象解构在许多情况下都非常有用,比如:
- 简化从复杂对象中提取数据的过程。
- 在函数参数中直接解构传入的对象,减少临时变量的创建。
- 结合扩展运算符(spread operator),灵活地处理对象中的剩余部分。
创建对象的方式(Creating object method)
原始方法
JavaScript提供了多种创建对象的方式,下面是五种主要方式:
使用Object构造函数:
这是最基础的对象创建方法。通过new Object()
可以创建一个新的空对象,然后逐步添加属性和方法。1
2
3var person = new Object();
person.name = 'Jason';
person.age = 21;这种方式虽然简单直接,但不够灵活且容易导致代码冗余。
使用对象字面量:
对象字面量提供了一种更加简洁的方式来定义对象。它允许在一个步骤中同时定义对象及其属性。1
2
3
4var person = {
name: "Jason",
age: 21
};相较于第一种方式,这种方式更直观,减少了重复代码,并提高了代码的可读性。
工厂模式:
工厂模式是一种设计模式,它抽象了创建具体对象的过程,通过一个函数来封装以特定接口创建对象的细节。1
2
3
4
5
6
7function createPerson(name, age) {
var o = new Object();
o.name = name;
o.age = age;
return o;
}
var person1 = createPerson('Nike', 29);这种模式适用于需要批量生产相似对象的情况,但它无法识别对象的具体类型。
构造函数模式:
构造函数模式允许我们定义一个构造函数来初始化对象,不仅包含属性还包含了方法。构造函数的名字通常首字母大写。1
2
3
4
5function Person(name, age) {
this.name = name;
this.age = age;
}
var person1 = new Person('Nike', 29);使用这种方法创建的对象具有自定义类型,便于管理和扩展。
原型模式:
原型模式利用每个函数都有的prototype
属性来为所有实例共享属性和方法,这有助于节省内存。1
2
3
4function Person() {}
Person.prototype.name = 'Nike';
Person.prototype.age = 20;
var person1 = new Person();这种模式非常适合用于创建大量具有相同行为的对象,因为这些行为只需在原型上定义一次即可被所有实例共享。
ES6新特性
ES6(ECMAScript 2015)引入了class
关键字,为JavaScript带来了更接近传统面向对象编程语言的语法。尽管这种新的语法看起来像是引入了一种全新的机制来定义类和创建对象,但实际上它只是基于原型继承的一种“语法糖”,并没有改变JavaScript原有的原型继承的本质。
基本语法
在ES6中,你可以使用class
关键字来声明一个类,并通过构造函数constructor
来初始化实例对象。下面是一个简单的例子:
1 | class Point { |
在这个例子中,Point
类有一个构造方法constructor
用于接收参数并初始化对象属性,还有一个名为toString
的方法用于返回点的位置信息。
类的继承
ES6还引入了extends
关键字来实现类的继承,使得子类可以继承父类的所有属性和方法。例如:
1 | class ColorPoint extends Point { |
这里,ColorPoint
类继承自Point
类,并添加了一个额外的属性color
。它重写了toString
方法,并通过super
关键字调用了父类的同名方法。
静态方法
你还可以在类中定义静态方法,这些方法不会被实例化,而是直接通过类本身来调用:
1 | class MyClass { |
Getter 和 Setter
ES6中的类支持getter和setter方法,它们允许你控制对对象属性的访问和修改:
1 | class Temperature { |
以上代码展示了如何定义获取和设置华氏温度的getter和setter方法。
注意事项
- 类的定义不会被提升到作用域顶部,这意味着必须在使用之前定义类。
- 在类的构造函数中,如果需要引用父类的构造函数,必须先调用
super()
。 - 类内部的方法不需要使用
function
关键字,并且方法之间不应该加分号。
构造函数(Constructor)
在JavaScript中,构造函数是一种用于创建和初始化对象的特殊函数。通过使用new
关键字调用构造函数,可以创建一个新实例,并且该实例会继承构造函数中定义的属性和方法。构造函数的名字通常首字母大写,以区别于普通函数。
构造函数的基本结构
下面是一个简单的构造函数示例,它用于创建一个包含name
和age
属性的对象:
1 | function Person(name, age) { |
在这个例子中,Person
是一个构造函数,它接受两个参数:name
和age
,并将它们设置为新创建对象的属性。
创建对象实例
要使用构造函数创建一个新的对象实例,你需要使用new
关键字:
1 | var person1 = new Person('Nike', 29); |
这行代码创建了一个名为person1
的新对象,其name
属性值为’Nike’,age
属性值为29。
添加方法
除了属性外,你还可以在构造函数中添加方法。然而,在构造函数内部直接定义方法会导致每个实例都有自己的方法副本,浪费内存。因此,通常我们使用原型(prototype
)来定义方法,这样所有实例共享同一个方法:
1 | Person.prototype.sayName = function() { |
现在,所有由Person
构造函数创建的实例都可以访问sayName
方法,而不需要为每个实例单独复制这个方法。
Object
静态方法(Object static method)
在JavaScript中,Object
构造函数本身提供了一些静态方法,这些方法可以直接通过Object
对象调用,而不必先创建一个具体的对象实例。
Object.keys(obj)
:- 返回一个包含对象自身所有可枚举属性名称的数组。非常适用于需要遍历对象的键的情况。
1
2const obj = { name: 'Jason', age: 21 };
console.log(Object.keys(obj)); // 输出 ["name", "age"]Object.values(obj)
:- 返回一个包含对象自身所有可枚举属性值的数组。适合于当你只关心对象的值而不需要键时。
1
2const obj = { name: 'Jason', age: 21 };
console.log(Object.values(obj)); // 输出 ["Jason", 21]Object.entries(obj)
:- 返回一个给定对象自身可枚举属性的键值对数组。对于同时需要键和值的操作特别有用,比如在
for...of
循环中使用。
1
2const obj = { name: 'Jason', age: 21 };
console.log(Object.entries(obj)); // 输出 [["name", "Jason"], ["age", 21]]- 返回一个给定对象自身可枚举属性的键值对数组。对于同时需要键和值的操作特别有用,比如在
Object.assign(target, ...sources)
:- 用于将所有可枚举属性的值从一个或多个源对象复制到目标对象,并返回目标对象。这是一个实现浅拷贝的有效方式。
1
2
3
4const target = { a: 1, b: 2 };
const source = { b: 3, c: 4 };
Object.assign(target, source);
console.log(target); // 输出 { a: 1, b: 3, c: 4 }Object.freeze(obj)
:- 冻结对象,阻止添加新属性、移除已有属性、以及更改现有属性的可枚举性、可配置性或可写性。这是一种确保对象不可变的方法。
1
2
3
4const obj = { name: 'Jason' };
Object.freeze(obj);
obj.name = 'John'; // 静默失败或在严格模式下抛出TypeError
console.log(obj.name); // 输出 "Jason"Object.is(value1, value2)
:- 判断两个值是否相同。与
===
相比,它能正确区分-0和+0,也能识别NaN是等于自身的。
1
2console.log(Object.is(-0, +0)); // 输出 false
console.log(Object.is(NaN, NaN)); // 输出 true- 判断两个值是否相同。与
基本包装类型(Basic type of Packaging)
在JavaScript中,基本包装类型是指为原始数据类型(如number
、string
和boolean
)提供对象方法和属性的一种机制。尽管JavaScript中的原始数据类型不是对象,但有时我们需要对这些类型的值执行某些操作,比如调用方法或访问属性。这时,JavaScript会自动创建一个对应的基本包装类型的对象,使得我们可以像操作对象一样操作这些原始值。
JavaScript中有三种基本包装类型:
- Number:为数值提供了许多用于执行数学运算的方法。
- String:为字符串提供了多种用于操作文本的方法,例如查找子串、提取部分字符串等。
- Boolean:虽然不常用,但也为布尔值提供了一些方法。
一旦你尝试对一个原始值进行类似于对象的操作时,JavaScript引擎会在幕后创建一个对应的基本包装对象,该对象允许你访问其方法和属性。操作完成后,这个临时对象即被销毁。
String 包装对象
1 | var str = 'Hello, world!'; |
在这个例子中,str
是一个字符串原始值。当我们调用toUpperCase()
方法时,JavaScript会自动将str
转换成一个临时的String
对象,然后在其上调用方法,最后再销毁这个临时对象。
Number 包装对象
1 | var num = 123; |
这里,num
是一个数字原始值。通过调用toFixed()
方法,我们要求返回一个包含指定小数位数的字符串表示形式。这同样涉及到一个临时的Number
对象的创建与销毁过程。
值得注意的是,这种自动创建和销毁包装对象的过程是隐式的,且仅在你需要对原始值使用对象方法时发生。如果你直接操作原始值(例如进行算术运算),则不会涉及基本包装类型。
原型对象 (prototype
)和对象原型 (__proto__
)
原型对象 (prototype
)
在JavaScript中,每个函数(除了箭头函数)都有一个名为prototype
的属性。这个属性是一个对象,它包含了该函数作为构造函数时创建的所有实例共享的属性和方法。也就是说,当你使用某个函数来创建对象实例时(通过new
关键字),这些实例将继承该函数prototype
上的所有成员。
1 | function Person(name) { |
在这个例子中,sayHello
方法被定义在Person.prototype
上,因此所有由Person
构造函数创建的实例都可以访问到这个方法。
对象原型 (__proto__
)
__proto__
是每个JavaScript对象都有的一个内部链接,指向该对象的原型对象。它是对象的一个隐式引用,用于构建原型链。当尝试访问一个对象的属性时,如果该对象自身没有这个属性,JavaScript引擎会沿着__proto__
指针向上查找,直到找到该属性或到达原型链的末端(即null
)。
1 | var person1 = new Person("Alice"); |
这里,person1
的__proto__
指向的是Person.prototype
,这意味着你可以通过person1
访问定义在Person.prototype
上的sayHello
方法。
prototype
与__proto__
的区别
prototype
是函数特有的属性,它定义了使用该函数作为构造函数创建的对象实例的原型。__proto__
是每个对象都有的属性,它指向该对象的原型对象,即创建该对象的构造函数的prototype
。
原型链(Prototype Chain)
原型链是什么?
原型链是一种机制,它允许JavaScript对象通过其原型对象继承属性和方法。每个对象都有一个指向另一个对象(即其原型)的内部链接(通常称为[[Prototype]]
,可以通过__proto__
访问)。如果尝试访问一个对象的属性或方法而该对象自身没有这个属性或方法时,JavaScript引擎会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的末端(通常是null
)。
原型链的工作原理
当创建一个新的对象实例时(比如使用构造函数),该实例的__proto__
属性会被设置为指向构造函数的prototype
属性。这意味着当你尝试访问一个对象的属性或方法时,如果该对象本身没有定义该属性或方法,JavaScript引擎会在其原型对象上查找。
例如:
1 | function Person(name) { |
在这个例子中:
person1
是一个对象实例,它的__proto__
指向Person.prototype
。- 当调用
person1.sayHello()
时,由于person1
本身没有sayHello
方法,JavaScript引擎会在Person.prototype
上查找并执行该方法。
继承与原型链
JavaScript中的继承是通过原型链实现的。子类的实例不仅可以通过自身的原型访问父类的方法和属性,还可以通过原型链访问更高级别的原型上的方法和属性。这使得可以形成一个链条,从最具体的对象一直追溯到最通用的对象(通常是Object.prototype
)。
例如,考虑一个简单的继承示例:
1 | function Student(name, grade) { |
在这个例子中:
Student.prototype
被设置为一个新对象,其原型是Person.prototype
,这样就建立了继承关系。student1
的__proto__
指向Student.prototype
,而Student.prototype
的__proto__
指向Person.prototype
,形成了一个原型链。- 当调用
student1.sayHello()
时,JavaScript引擎首先在student1
上查找,然后在Student.prototype
上查找,最后在Person.prototype
上找到了该方法。
原型链的终点
所有正常的对象最终都会链接到Object.prototype
,它是普通对象的默认原型。Object.prototype
的__proto__
值是null
,这是原型链的终点。
1 | console.log(Object.getPrototypeOf({}) === Object.prototype); // true |
我的理解:prototype是用来跟函数捆绑的,相当于身份牌,因此放到构造函数里面可以由这“同一个”函数声明不同的对象,proto实际上就是将当前对象捆绑,指向当前对象的父亲,所以如果当前对象要是没有什么属性它会去它父亲那找
浅拷贝(Shallow Copy)
浅拷贝是指创建一个新的对象或数组,并将原始对象或数组的引用复制给它。这意味着新对象和原始对象将共享相同的内存地址,修改其中一个对象的属性或元素也会影响另一个对象。具体来说,浅拷贝只会复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。
浅拷贝可以通过多种方式实现:
- 使用
Object.assign()
方法 - 使用扩展运算符(spread operator)
- 使用数组的
.slice()
或.concat()
方法
例如:
1 | let original = { a: 1, b: { c: 2 } }; |
深拷贝(Deep Copy)
深拷贝是一种创建独立全新对象的方法,它递归地复制每个嵌套对象和数组,有效地避免了使用共享内存带来的修改问题。由于深拷贝与其源对象不共享引用,因此对深拷贝所做的任何更改都不会影响源对象。
实现深拷贝的方式包括:
- 使用
JSON.stringify()
和JSON.parse()
进行序列化和反序列化。 - 使用
structuredClone()
函数(适用于某些环境)。 - 手动编写递归函数来复制对象的所有层级。
例如,使用JSON
方法进行深拷贝:
1 | let original = { a: 1, b: { c: 2 } }; |
需要注意的是,使用JSON
方法进行深拷贝有一些限制,比如不能正确处理函数、undefined
、循环引用等复杂数据结构。对于更复杂的场景,可能需要使用第三方库如Lodash的cloneDeep
方法,或者自己编写一个递归函数来处理这些情况。
控制函数执行时的this
上下文(this value)
在JavaScript中,call()
、apply()
和bind()
都是用于控制函数执行时的this
上下文的方法。它们都允许你指定一个特定的对象作为函数调用时的this
值,但它们之间有一些关键的区别。+
call()
用途:立即调用函数,并设置函数内部的
this
值。参数传递:第一个参数是
this
将要指向的对象,之后可以跟任意数量的参数列表。语法:
1
function.call(thisArg, arg1, arg2, ...);
示例:
1
2
3
4
5var obj = { name: "Alice" };
function greet(greeting) {
console.log(greeting + ', ' + this.name);
}
greet.call(obj, "Hello"); // 输出: Hello, Alice
apply()
用途:与
call()
类似,但是它接受一个参数数组(或类数组对象)而不是参数列表。参数传递:第一个参数同样是
this
将要指向的对象,第二个参数是一个包含多个参数的数组(或类数组对象)。语法:
1
function.apply(thisArg, [argsArray]);
示例:
1
2
3
4
5var obj = { name: "Alice" };
function greet(greeting, punctuation) {
console.log(greeting + ', ' + this.name + punctuation);
}
greet.apply(obj, ["Hello", "!"]); // 输出: Hello, Alice!
bind()
用途:创建一个新的函数,当这个新函数被调用时,其
this
值会被设置为提供的值,且任何传入bind()
的参数都会被预先附加到该函数的参数列表中。参数传递:第一个参数是
this
将要指向的对象,后续参数作为预置参数绑定给新函数。语法:
1
const boundFunc = function.bind(thisArg, arg1, arg2, ...);
示例:
1
2
3
4
5
6var obj = { name: "Alice" };
function greet(greeting, punctuation) {
console.log(greeting + ', ' + this.name + punctuation);
}
var greetAlice = greet.bind(obj, "Hello");
greetAlice("!"); // 输出: Hello, Alice!
总结
- 共同点:都可以用来改变函数执行时的
this
指向。 - 不同点:
call()
和apply()
会立即执行函数,而bind()
返回的是一个新的函数,需要手动调用。- 参数传递方式不同:
call()
使用参数列表,apply()
使用参数数组。 bind()
允许你在创建新函数时预设部分参数(称为“柯里化”),这对于某些场景非常有用。
防抖(Debouncing)和节流(Throttling)
防抖(Debouncing)和节流(Throttling)是两种常见的技术,用于优化高频率事件触发时的回调函数调用。它们的主要目的是减少不必要的计算或网络请求,从而提高性能和用户体验。尽管它们的目标相似,但它们的工作方式和适用场景有所不同。
防抖(Debouncing)
概念与原理
- 防抖是指在某个事件频繁触发时,延迟执行目标函数,直到事件停止触发后的指定时间间隔内没有再次触发该事件时才执行一次预定的操作。
- 如果在这个时间间隔结束前有新的事件触发,则重新计时。这种方式可以避免因短时间内多次触发同一事件而引起的不必要操作。
应用场景
防抖适用于那些希望在用户停止输入一段时间后再进行处理的情况,比如搜索框自动补全、窗口调整大小等。它确保只有当用户完成一系列快速连续的动作后才会执行相应的回调函数,而不是对每个动作都作出反应。
节流(Throttling)
概念与原理
- 节流则是限制一个函数在一定时间间隔内的调用次数。这意味着无论事件触发了多少次,在设定的时间段内只会执行一次该函数。
- 它通常用于需要定期更新的状态或者操作,例如滚动事件、拖拽事件等。通过节流,可以确保这些操作不会过于频繁地执行,从而节省资源并提升性能。
应用场景
节流适合于那些需要持续响应用户交互但不需要即时响应的场景,如无限滚动加载内容、监听窗口大小变化等。它保证了即使事件以很高的频率触发,回调函数也只会在规定的时间间隔内执行一次。
主要区别
- 执行时机:防抖是在事件停止触发后的延迟时间内没有新事件发生时执行;而节流则是在固定的时间间隔内至少执行一次回调。
- 实现机制:防抖使用定时器来延迟执行,并且每次触发都会重置定时器;节流则可以通过时间戳或者定时器来控制执行频率,确保在特定的时间间隔内最多执行一次回调。
- 适用场景:防抖更适合处理需要等待用户停止操作后再进行处理的场景;节流更适用于需要定期检查状态或更新界面的情况。
防抖实现
1 | function debounce(func, wait) { |
节流实现
1 | function throttle(func, limit) { |