Javascript进阶

/ 前端

作用域(Scope)

在JavaScript中,作用域的概念是理解代码执行上下文和变量访问权限的关键。

全局作用域(Global Scope)

全局作用域是指在整个脚本或整个HTML文档的最外层定义的变量和函数的作用范围。这些变量和函数可以在任何地方被访问到,包括所有的函数内部。全局变量是在函数外部声明的,或者没有使用varletconst关键字直接赋值的变量。

1
2
3
4
5
6
7
var globalVar = "I'm a global variable"; // 全局变量

function test() {
console.log(globalVar); // 可以访问全局变量
}

console.log(globalVar); // 输出: I'm a global variable

局部作用域(Local Scope)

局部作用域指的是变量仅在其被定义的作用域内可访问,通常指函数内部或特定代码块内部。根据定义的方式不同,可以细分为函数作用域和块级作用域。

函数作用域(Function Scope)

在ES6之前,JavaScript主要通过函数来创建局部作用域。如果一个变量是在函数内部使用var关键字声明的,那么这个变量就只能在该函数内部访问,不能从外部访问。

1
2
3
4
5
6
function functionScopeExample() {
var localVar = "I'm local to the function";
console.log(localVar); // 正常工作
}

console.log(localVar); // 报错:localVar is not defined

块级作用域(Block Scope)

随着ES6的引入,JavaScript增加了对块级作用域的支持,这主要是通过letconst关键字实现的。块级作用域限制了变量的作用范围到最近的一对大括号{}内,比如在一个if语句、for循环或简单的代码块中。

1
2
3
4
5
6
7
8
9
if (true) {
let blockScopedVar = "I'm scoped to this block";
const anotherBlockScopedVar = "Also scoped to this block";
console.log(blockScopedVar); // 正常工作
console.log(anotherBlockScopedVar); // 正常工作
}

console.log(blockScopedVar); // 报错:blockScopedVar is not defined
console.log(anotherBlockScopedVar); // 报错:anotherBlockScopedVar is not defined

作用域链(Scope Chain)

作用域链(Scope Chain)是JavaScript中一个非常重要的概念,它与作用域紧密相关,并且决定了变量在程序中的可见性和生命周期。

什么是作用域链?

作用域链可以被理解为一个对象列表,这些对象被称为变量对象(Variable Object, VO),它们包含了当前执行上下文中定义的所有变量和函数。当JavaScript引擎需要查找某个变量时,它会首先在当前的作用域内寻找该变量。**如果找不到,则会沿着作用域链向上一级作用域继续查找,**直到找到全局作用域为止。如果在全局作用域中仍然找不到该变量,则会在非严格模式下隐式声明该变量,或是在严格模式下抛出错误。

作用域链的形成

作用域链的形成发生在函数创建的时候,而不是在函数调用时确定的。这是因为JavaScript采用的是词法作用域(Lexical Scope),这意味着变量的作用域是由变量在源代码中的位置决定的,而不是由函数调用的位置决定的。因此,在嵌套函数的情况下,内部函数可以访问其外部函数的变量,即使外部函数已经执行完毕并且其执行上下文已经从堆栈中移除。

作用域链的作用

作用域链有两个主要作用:

  1. 变量查找:当JavaScript引擎需要解析一个标识符(如变量名)时,它会从当前作用域开始搜索,如果没有找到则沿着作用域链向父级作用域继续搜索,直到找到匹配的标识符或到达全局作用域。

    1
    2
    3
    4
    5
    6
    7
    8
    function outer() {
    var x = "outer";
    function inner() {
    console.log(x); // 输出 "outer"
    }
    inner();
    }
    outer();
  2. 实现闭包:通过作用域链,JavaScript允许内部函数保持对外部函数作用域的引用,即使外部函数已经执行完毕。这种机制使得闭包成为可能,让内部函数可以在之后的任何时间点访问外部函数的变量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function createCounter() {
    let count = 0;
    return function() {
    count++;
    console.log(count);
    };
    }

    const counter = createCounter();
    counter(); // 输出 1
    counter(); // 输出 2

垃圾回收机制(Garbage Collection)

JavaScript中的内存生命周期

JavaScript中的内存生命周期主要分为三个阶段:分配内存、使用内存和释放内存。

1. 分配内存

在JavaScript中,当声明变量或创建对象时,JavaScript引擎会自动为这些值分配内存空间。根据数据类型的不同,内存分配的方式也有所不同:

此外,某些函数调用的结果也可能导致内存分配,比如:

1
const d = new Date(); // 为Date对象分配内存

2. 使用内存

一旦内存被分配,就可以通过读取或写入操作来使用它。这包括对变量的操作,如赋值、修改属性值,以及将对象作为参数传递给函数等。

例如:

1
2
3
4
5
6
console.log(o.a); // 读取内存中的值
o.b = 456; // 修改内存中的值
function modifyObject(obj) {
obj.c = 'new property'; // 对象作为参数传递给函数并修改其属性
}
modifyObject(o);

3. 释放内存

当内存不再需要时,应该将其释放以供后续使用。在JavaScript中,这个过程是自动化的,由垃圾回收器(Garbage Collector, GC)负责执行。GC的主要任务是识别那些不再使用的内存,并将其标记为可回收。

垃圾回收的基本概念

在JavaScript中,垃圾回收器负责监控内存分配并确定何时一块已分配的内存不再需要被使用。这个过程是自动化的,开发者通常不需要手动干预。

主要垃圾回收算法

1. 引用计数法(Reference Counting)

1
2
3
4
5
6
7
8
9
function referenceCountingExample() {
let objA = {};
let objB = {};
objA.ref = objB;
objB.ref = objA;
// 在函数结束时,objA 和 objB 形成了循环引用,
// 即使它们离开了作用域,引用计数也不会变为0。
}
referenceCountingExample();

2. 标记-清除法(Mark-and-Sweep)

1
2
3
4
let x = { a: 1 };
let y = x; // y 现在也指向了同一个对象
x = null; // 对象仍然可以通过 y 访问到,不会被回收
y = null; // 现在对象变得不可达,会被标记-清除算法回收

3. 分代收集(Generational Collection)

闭包(Closure)

JavaScript闭包是一个强大且复杂的概念,它允许一个函数访问其词法作用域(即定义该函数时的作用域)中的变量,即使这个函数是在其词法作用域之外被调用的。闭包使得函数能够“记住”并操作这些外部变量,即便在原始作用域已经执行完毕之后也是如此。

什么是闭包?

闭包是由一个函数和与其相关的引用环境组合而成的对象**。当一个函数嵌套在另一个函数内部,并且内层函数引用了外层函数的局部变量时,就形成了闭包。**简单来说,闭包让内层函数可以捕获并保存外层函数的变量状态。

1
2
3
4
5
6
7
8
9
function makeFunc() {
const name = "Mozilla";
function displayName() {
console.log(name);
}
return displayName;
}
const myFunc = makeFunc();
myFunc(); // 输出: Mozilla

在这个例子中,makeFunc返回了displayName函数,而displayName函数引用了makeFunc中的name变量。因此,当我们调用myFunc时,尽管makeFunc已经执行完毕,displayName仍然能访问到name变量的值,这就是闭包的效果。

工作原理

闭包之所以能够工作,是因为JavaScript采用的是词法作用域(lexical scoping),而不是动态作用域。这意味着函数的作用域是在定义时确定的,而不是在运行时。当创建闭包时,它不仅包含了函数体本身,还包含了函数创建时的作用域链。

应用场景

封装私有变量

闭包可以用来创建封装的模块,隐藏内部实现细节,保护变量不被外部直接访问或修改:

1
2
3
4
5
6
7
8
9
10
11
function createCounter() {
let count = 0;
return {
increment: function() { return ++count; },
decrement: function() { return --count; }
};
}

const counter = createCounter();
console.log(counter.increment()); // 输出: 1
console.log(counter.decrement()); // 输出: 0

数据缓存与惰性求值

利用闭包可以缓存计算结果或者延迟某些计算直到真正需要的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function expensiveOperation(n) {
console.log("Calculating...");
return n * n;
}

function createCachedOperation() {
const cache = {};
return function(n) {
if (!(n in cache)) {
cache[n] = expensiveOperation(n);
}
return cache[n];
};
}

const cachedOp = createCachedOperation();
console.log(cachedOp(4)); // 第一次调用会计算
console.log(cachedOp(4)); // 第二次调用直接从缓存读取

函数柯里化

闭包可以用于创建柯里化的函数,这是一种函数式编程技术,允许部分应用参数:

1
2
3
4
5
6
7
8
function add(x) {
return function(y) {
return x + y;
};
}

const add5 = add(5);
console.log(add5(3)); // 输出: 8

闭包与内存泄漏

由于闭包能够捕获并持有对外部作用域中变量的引用,**这就意味着只要闭包存在,它所引用的所有变量都不会被垃圾回收机制回收。**如果这种引用关系长时间保持,尤其是当这些变量包含大量数据或者频繁更新时,就可能造成内存泄漏。

1. 不必要的全局变量引用

如果闭包内部无意间创建了对全局对象的引用,并且这个引用没有被适当地清除,那么相关的全局对象就不会被垃圾回收,进而导致内存泄漏。

2. DOM元素引用

在Web开发中,如果通过闭包保存了对DOM元素的引用,并且之后该DOM元素从文档中移除,但如果仍然存在对该元素的引用,那么这个DOM元素就不会被回收,这也会导致内存泄漏。

3. 长时间运行的定时器或事件监听器

如果将一个闭包装入了一个长时间运行的定时器(如setInterval)或作为事件监听器添加到DOM元素上,并且在不需要的时候没有正确地清理这些定时器或监听器,那么闭包中的变量引用就会一直存在,导致内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var theThing = null;
var replaceThing = function () {
var originalThing = theThing; // 这里形成了对外部变量的引用
var unused = function () {
if (originalThing) // 对theThing的引用
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'), // 大量数据
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 100); // 定时器导致闭包不会被销毁

在这个例子中,每次调用replaceThing函数都会创建一个新的闭包,并且每个闭包都保留了对前一个theThing对象的引用。随着时间推移,这会导致大量的内存被占用而无法释放。

4. 循环引用

虽然现代浏览器已经优化了循环引用的问题,但在某些情况下,特别是涉及到DOM节点和JavaScript对象之间的循环引用时,仍有可能引发内存泄漏。

最佳实践

为了避免因闭包引起的内存泄漏,可以采取如下措施:

变量提升(Variable Hoisting)

变量提升是指使用var关键字声明的变量会被提升到当前作用域的顶部,但仅限于声明部分,初始化不会被提升。这意味着可以在声明之前访问该变量,但是它的值会是undefined,直到实际赋值操作被执行。

例如:

1
2
console.log(foo); // 输出: undefined
var foo = 42;

这段代码实际上会被JavaScript引擎解析为如下形式:

1
2
3
var foo; // 提升变量声明
console.log(foo); // undefined
foo = 42; // 初始化操作留在原地

函数提升(Function Hoisting)

函数提升分为两种情况:函数声明和函数表达式。

需要注意的(Hoisting else)

提升的优先级

需要注意的是,函数声明的提升优先级高于变量声明。如果有同名的函数声明和变量声明,函数声明会覆盖变量声明。然而,如果之后有变量赋值给相同名称,那么这个赋值操作将会覆盖之前的函数声明。

例如:

1
2
3
4
console.log(foo); // 输出: [Function: foo]
function foo() {}
var foo = 'bar';
console.log(foo); // 输出: 'bar'

在这个例子中,首先函数声明foo被提升并覆盖了任何可能存在的同名变量声明。然后,变量赋值操作将foo设置为字符串'bar',从而覆盖了函数声明。

ECMAScript 6的变化

自ECMAScript 6起,引入了letconst关键字来声明变量,它们具有块级作用域并且不会发生变量提升。这意味着在声明之前访问这些变量会导致引用错误(ReferenceError),因为它们存在于所谓的“暂时性死区”(Temporal Dead Zone, TDZ)内,直到声明语句被执行。

暂时性死区(Temporal Dead Zone)

暂时性死区(Temporal Dead Zone,简称TDZ)是ECMAScript 6(ES6或ES2015)引入的一个概念,主要与使用letconst关键字声明的变量相关。它定义了一个区域,在这个区域内尝试访问尚未声明的变量会导致运行时错误。

本质

当控制流进入一个新的作用域(如一个代码块),在这个作用域内用letconst声明的变量会被创建,但在此之前,这些变量不能被访问或使用。如果试图在声明之前访问它们,JavaScript引擎将抛出ReferenceError。换句话说,即使变量已经存在于作用域中,但在其声明之前访问它们是非法的,并且会导致错误。

示例

考虑以下代码:

1
2
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 2;

这里,在let a = 2;语句执行前,任何对a的访问都会导致错误。这是因为从进入作用域开始直到let声明语句的位置,构成了a的暂时性死区。

再来看一个稍微复杂一点的例子:

1
2
3
4
5
if (true) {
// TDZ starts at the beginning of this block
console.log(tmp); // ReferenceError
let tmp = 3; // TDZ ends here
}

在这个例子中,从if语句的开始到let tmp = 3;这一行之间,构成了tmp的暂时性死区。在这一区域内尝试访问tmp会导致ReferenceError

暂时性死区的影响

为什么需要暂时性死区?

暂时性死区的设计是为了减少编程错误。通过强制要求变量必须先声明后使用,可以避免一些由于变量提升带来的意外行为,尤其是在使用var关键字时可能出现的情况。

在JavaScript中,arguments对象和剩余参数(Rest Parameters)都是用于处理函数调用时传入的参数,但它们之间存在一些关键的区别。下面我将详细解释两者,并结合实际应用示例来说明它们的使用方法。

函数剩余参数(Rest Parameters)

arguments 对象

arguments 是一个类数组对象,它包含了传递给函数的所有实参,无论这些实参是否与函数定义中的形参匹配。arguments 对象的一个特点是它不是真正的数组这意味着你不能直接在其上使用数组的方法,如 .map().filter() 等 。

1
2
3
4
5
function example() {
console.log(arguments);
}

example(1, 2, 3); // 输出: [Arguments] { '0': 1, '1': 2, '2': 3 }

转换为数组

由于 arguments 不是真正的数组,若想使用数组的方法,你需要先将其转换为数组:

1
2
3
4
5
6
function example() {
var argsArray = Array.prototype.slice.call(arguments);
console.log(argsArray.map(x => x * 2)); // 使用数组的 map 方法
}

example(1, 2, 3); // 输出: [2, 4, 6]

或者使用 ES6 的 Array.from() 方法或展开运算符 ...

1
2
3
4
5
6
function example() {
const argsArray = Array.from(arguments);
console.log([...argsArray].map(x => x * 2));
}

example(1, 2, 3); // 输出: [2, 4, 6]

函数剩余参数(Rest Parameters)

函数剩余参数(Rest Parameters)是ES6(ECMAScript 2015)引入的一种语法特性,它提供了一种更简洁的方式来处理传递给函数的不定数量的参数。通过使用三个点 ... 前缀,剩余参数允许我们将一个不定数量的实参表示为一个数组。

基本概念

例如:

1
2
3
4
5
6
function sum(...theArgs) {
return theArgs.reduce((previous, current) => previous + current);
}

console.log(sum(1, 2, 3)); // 输出: 6
console.log(sum(4, 5, 6, 7, 8)); // 输出: 30

在这个例子中,sum 函数使用了剩余参数 theArgs 来接收任意数量的参数,并利用数组的 reduce 方法来计算这些参数的总和。

特性与优点

注意事项

实际应用示例

下面的例子展示了如何使用剩余参数实现一个简单的加法器函数,它可以对任意数量的数字进行求和:

1
2
3
4
5
6
7
8
9
10
11
function sum(first, ...rest) {
let result = first;
for (let number of rest) {
result += number;
}
return result;
}

console.log(sum(1, 2, 3)); // 输出: 6
console.log(sum(4, 5, 6, 7, 8)); // 输出: 30
console.log(sum()); // 输出: NaN,因为没有提供第一个参数

展开运算符(Spread Operator)

展开运算符(Spread Operator)是ES6(ECMAScript 2015)引入的一种语法特性,它允许数组、字符串或对象的元素被“展开”为独立的元素。展开运算符使用三个连续的点号 ... 来表示,并且可以应用于多种场合,包括函数调用、数组字面量构造、对象字面量构造等。

在函数调用时展开数组元素

当你需要将一个数组作为参数传递给一个函数时,可以使用展开运算符来代替 apply() 方法:

1
2
3
4
5
function sum(x, y, z) {
return x + y + z;
}
const numbers = [1, 2, 3];
console.log(sum(...numbers)); // 输出: 6

在数组字面量中合并多个数组

展开运算符可以用来轻松地合并两个或更多的数组:

1
2
3
4
const fruits = ['apple', 'banana'];
const moreFruits = ['orange', 'grape'];
const allFruits = [...fruits, ...moreFruits];
console.log(allFruits); // 输出: ['apple', 'banana', 'orange', 'grape']

复制数组

使用展开运算符可以创建一个现有数组的浅拷贝:

1
2
const arr = [1, 2, 3];
const arrCopy = [...arr]; // 创建arr的一个浅拷贝

需要注意的是,这种复制方式只适用于数组的第一层,对于嵌套的对象或数组,展开运算符只会复制引用而不是深层的内容。

在对象字面量中合并对象

在ES7及以后版本中,可以使用展开运算符来合并对象:

1
2
3
4
const obj1 = { foo: 'bar', x: 42 };
const obj2 = { foo: 'baz', y: 13 };
const mergedObj = { ...obj1, ...obj2 };
console.log(mergedObj); // 输出: { foo: 'baz', x: 42, y: 13 }

如果存在相同的键名,后出现的对象属性会覆盖前面的对象属性。

使用展开运算符代替 apply 方法

展开运算符还可以用于简化某些原本需要用 apply 方法实现的操作,比如求一组数中的最大值:

1
2
const numbers = [9, 3, 2];
const maxNumNew = Math.max(...numbers); // 使用展开运算符的写法

注意事项

箭头函数(Arrow Functions)

箭头函数(Arrow Functions)是ECMAScript 2015(ES6)引入的一种新的函数定义方式,它提供了一种更加简洁的语法来编写匿名函数。

箭头函数的基本语法

箭头函数的基本结构如下:

1
(param1, param2, ..., paramN) => { statements }

如果只有一个参数,可以省略括号:

1
param => { statements }

对于单个表达式返回值的情况,可以进一步简化为:

1
(param1, param2, ..., paramN) => expression

例如:

1
2
3
4
5
6
7
// 普通函数写法
var add = function(x, y) {
return x + y;
};

// 使用箭头函数简化
const add = (x, y) => x + y;

this 关键字的行为

箭头函数与普通函数的一个重要区别在于this关键字的行为。在普通函数中,this的值取决于函数是如何被调用的;而在箭头函数中,this是在函数创建时就确定了,并且总是指向其外层作用域中的this

1
2
3
4
5
6
7
8
9
const obj = {
method: function() {
// 这里的 'this' 指向 obj 对象
setTimeout(() => {
console.log(this); // 同样指向 obj 对象
}, 100);
}
};
obj.method(); // 输出 obj 对象两次

arguments 和剩余参数

箭头函数没有自己的arguments对象,取而代之的是可以通过剩余参数(Rest Parameters)来获取传入的所有参数。

1
2
3
4
5
6
7
8
9
10
function regularFunction() {
console.log(arguments);
}

const arrowFunction = (...args) => {
console.log(args);
};

regularFunction(1, 2, 3); // 输出 Arguments 对象
arrowFunction(1, 2, 3); // 输出 [1, 2, 3]

不能作为构造器

由于箭头函数没有自己的thisprototype属性,所以它们不能通过new关键字来实例化对象。

1
2
const ArrowFunc = () => {};
// new ArrowFunc(); // 抛出错误

其他特性

使用场景

箭头函数非常适合用于那些不需要独立上下文的小型回调函数,比如数组方法中的回调(如mapfilter等)、事件处理器等。

1
[1, 2, 3].map(n => n * 2); // 返回 [2, 4, 6]

总之,箭头函数以其简洁的语法和固定的this绑定机制,为JavaScript开发者提供了更加强大和灵活的工具。不过,在需要动态this或者需要使用arguments对象的情况下,还是应该选择普通函数。

数组解构(Array Destructuring)

数组解构是ES6(ECMAScript 2015)引入的一种语法特性,它提供了一种简洁的方式来从数组中提取数据,并将其赋值给变量。这种特性不仅使代码更加直观和易读,而且也提高了开发效率。

基本用法

最基本的数组解构形式是从一个已知结构的数组中提取元素并赋值给对应的变量。例如:

1
2
3
let [a, b] = [1, 2];
console.log(a); // 输出: 1
console.log(b); // 输出: 2

这里,[1, 2]是一个数组,而[a, b]是解构模式,用于将数组中的第一个元素赋值给变量a,第二个元素赋值给变量b

跳过元素

在解构过程中,如果不需要某些元素,可以通过跳过它们来实现:

1
2
3
let [a,,b] = [1, 2, 3];
console.log(a); // 输出: 1
console.log(b); // 输出: 3

这里,通过使用两个逗号,我们跳过了数组中的第二个元素。

使用默认值

当数组中的元素不存在或为undefined时,可以指定默认值:

1
2
3
let [a = 1, b = 2] = [undefined, 3];
console.log(a); // 输出: 1 (因为第一个元素是undefined,所以使用了默认值)
console.log(b); // 输出: 3

解构剩余部分

使用...操作符,我们可以将数组剩下的部分收集到一个新的数组中:

1
2
3
let [a, ...rest] = [1, 2, 3, 4];
console.log(a); // 输出: 1
console.log(rest); // 输出: [2, 3, 4]

函数返回值解构

函数也可以返回数组,然后你可以直接对返回值进行解构:

1
2
3
4
5
function returnArray() {
return [1, 2, 3];
}
let [a, b, c] = returnArray();
console.log(a, b, c); // 输出: 1 2 3

交换变量值

解构赋值使得交换两个变量的值变得非常简单:

1
2
3
4
5
let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a); // 输出: 2
console.log(b); // 输出: 1

复杂场景

对于更复杂的数据结构,比如嵌套数组,也可以使用解构赋值:

1
2
3
4
5
let nested = [1, [2, 3]];
let [a, [b, c]] = nested;
console.log(a); // 输出: 1
console.log(b); // 输出: 2
console.log(c); // 输出: 3

对象解构(Object Destructuring)

对象解构是ES6(ECMAScript 2015)引入的一种特性,它允许我们从对象中提取属性并将其赋值给变量。这种语法不仅让代码更加简洁和易读,而且提高了开发效率。

基本用法

对象解构的基本形式是从一个对象中提取属性,并将这些属性的值赋给同名的变量:

1
2
3
4
5
let person = { name: "Sarah", country: "Nigeria", job: "Developer" };
let { name, country, job } = person;
console.log(name); // 输出: Sarah
console.log(country); // 输出: Nigeria
console.log(job); // 输出: Developer

这里,{ name, country, job }是解构模式,用于从person对象中提取相应的属性并赋值给同名的变量。

设置别名

有时我们可能想要为提取的属性设置不同的变量名。这可以通过在解构模式中指定属性名后跟冒号和新的变量名来实现:

1
2
3
let { name: personName, country: personCountry } = person;
console.log(personName); // 输出: Sarah
console.log(personCountry); // 输出: Nigeria

在这个例子中,namecountry属性被分别赋值给了personNamepersonCountry变量。

默认值

当对象中没有某个属性或属性值为undefined时,可以提供默认值:

1
2
3
let { name = "Unknown", age = 30 } = {};
console.log(name); // 输出: Unknown
console.log(age); // 输出: 30

解构嵌套对象

对于包含嵌套结构的对象,也可以使用解构赋值:

1
2
3
4
5
6
7
8
9
10
11
let employee = {
name: "John",
address: {
city: "New York",
zipCode: "10001"
}
};
let { name, address: { city, zipCode } } = employee;
console.log(name); // 输出: John
console.log(city); // 输出: New York
console.log(zipCode); // 输出: 10001

函数参数解构

解构赋值同样可以应用于函数参数,从而简化函数签名和内部逻辑:

1
2
3
4
5
function printDetails({ name, job }) {
console.log(`${name} works as a ${job}`);
}

printDetails(person); // 输出: Sarah works as a Developer

使用场景

对象解构在许多情况下都非常有用,比如:

创建对象的方式(Creating object method)

原始方法

JavaScript提供了多种创建对象的方式,下面是五种主要方式:

  1. 使用Object构造函数
    这是最基础的对象创建方法。通过new Object()可以创建一个新的空对象,然后逐步添加属性和方法。

    1
    2
    3
    var person = new Object();
    person.name = 'Jason';
    person.age = 21;

    这种方式虽然简单直接,但不够灵活且容易导致代码冗余。

  2. 使用对象字面量
    对象字面量提供了一种更加简洁的方式来定义对象。它允许在一个步骤中同时定义对象及其属性。

    1
    2
    3
    4
    var person = {
    name: "Jason",
    age: 21
    };

    相较于第一种方式,这种方式更直观,减少了重复代码,并提高了代码的可读性。

  3. 工厂模式
    工厂模式是一种设计模式,它抽象了创建具体对象的过程,通过一个函数来封装以特定接口创建对象的细节。

    1
    2
    3
    4
    5
    6
    7
    function createPerson(name, age) {
    var o = new Object();
    o.name = name;
    o.age = age;
    return o;
    }
    var person1 = createPerson('Nike', 29);

    这种模式适用于需要批量生产相似对象的情况,但它无法识别对象的具体类型。

  4. 构造函数模式
    构造函数模式允许我们定义一个构造函数来初始化对象,不仅包含属性还包含了方法。构造函数的名字通常首字母大写。

    1
    2
    3
    4
    5
    function Person(name, age) {
    this.name = name;
    this.age = age;
    }
    var person1 = new Person('Nike', 29);

    使用这种方法创建的对象具有自定义类型,便于管理和扩展。

  5. 原型模式
    原型模式利用每个函数都有的prototype属性来为所有实例共享属性和方法,这有助于节省内存。

    1
    2
    3
    4
    function Person() {}
    Person.prototype.name = 'Nike';
    Person.prototype.age = 20;
    var person1 = new Person();

    这种模式非常适合用于创建大量具有相同行为的对象,因为这些行为只需在原型上定义一次即可被所有实例共享。

ES6新特性

ES6(ECMAScript 2015)引入了class关键字,为JavaScript带来了更接近传统面向对象编程语言的语法。尽管这种新的语法看起来像是引入了一种全新的机制来定义类和创建对象,但实际上它只是基于原型继承的一种“语法糖”,并没有改变JavaScript原有的原型继承的本质。

基本语法

在ES6中,你可以使用class关键字来声明一个类,并通过构造函数constructor来初始化实例对象。下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}

toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}

在这个例子中,Point类有一个构造方法constructor用于接收参数并初始化对象属性,还有一个名为toString的方法用于返回点的位置信息。

类的继承

ES6还引入了extends关键字来实现类的继承,使得子类可以继承父类的所有属性和方法。例如:

1
2
3
4
5
6
7
8
9
10
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的构造函数
this.color = color;
}

toString() {
return super.toString() + ' in ' + this.color;
}
}

这里,ColorPoint类继承自Point类,并添加了一个额外的属性color。它重写了toString方法,并通过super关键字调用了父类的同名方法。

静态方法

你还可以在类中定义静态方法,这些方法不会被实例化,而是直接通过类本身来调用:

1
2
3
4
5
6
7
class MyClass {
static myStaticMethod() {
return 'Hello World';
}
}

console.log(MyClass.myStaticMethod()); // 输出 "Hello World"

Getter 和 Setter

ES6中的类支持getter和setter方法,它们允许你控制对对象属性的访问和修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}

get fahrenheit() {
return this.celsius * 9 / 5 + 32;
}

set fahrenheit(value) {
this.celsius = (value - 32) * 5 / 9;
}
}

以上代码展示了如何定义获取和设置华氏温度的getter和setter方法。

注意事项

构造函数(Constructor)

在JavaScript中,构造函数是一种用于创建和初始化对象的特殊函数。通过使用new关键字调用构造函数,可以创建一个新实例,并且该实例会继承构造函数中定义的属性和方法。构造函数的名字通常首字母大写,以区别于普通函数。

构造函数的基本结构

下面是一个简单的构造函数示例,它用于创建一个包含nameage属性的对象:

1
2
3
4
function Person(name, age) {
this.name = name;
this.age = age;
}

在这个例子中,Person是一个构造函数,它接受两个参数:nameage,并将它们设置为新创建对象的属性。

创建对象实例

要使用构造函数创建一个新的对象实例,你需要使用new关键字:

1
var person1 = new Person('Nike', 29);

这行代码创建了一个名为person1的新对象,其name属性值为’Nike’,age属性值为29。

添加方法

除了属性外,你还可以在构造函数中添加方法。然而,在构造函数内部直接定义方法会导致每个实例都有自己的方法副本,浪费内存。因此,通常我们使用原型(prototype)来定义方法,这样所有实例共享同一个方法:

1
2
3
Person.prototype.sayName = function() {
console.log(this.name);
};

现在,所有由Person构造函数创建的实例都可以访问sayName方法,而不需要为每个实例单独复制这个方法。

Object静态方法(Object static method)

在JavaScript中,Object构造函数本身提供了一些静态方法,这些方法可以直接通过Object对象调用,而不必先创建一个具体的对象实例。

  1. Object.keys(obj)

    • 返回一个包含对象自身所有可枚举属性名称的数组。非常适用于需要遍历对象的键的情况。
    1
    2
    const obj = { name: 'Jason', age: 21 };
    console.log(Object.keys(obj)); // 输出 ["name", "age"]
  2. Object.values(obj)

    • 返回一个包含对象自身所有可枚举属性值的数组。适合于当你只关心对象的值而不需要键时。
    1
    2
    const obj = { name: 'Jason', age: 21 };
    console.log(Object.values(obj)); // 输出 ["Jason", 21]
  3. Object.entries(obj)

    • 返回一个给定对象自身可枚举属性的键值对数组。对于同时需要键和值的操作特别有用,比如在for...of循环中使用。
    1
    2
    const obj = { name: 'Jason', age: 21 };
    console.log(Object.entries(obj)); // 输出 [["name", "Jason"], ["age", 21]]
  4. Object.assign(target, ...sources)

    • 用于将所有可枚举属性的值从一个或多个源对象复制到目标对象,并返回目标对象。这是一个实现浅拷贝的有效方式。
    1
    2
    3
    4
    const target = { a: 1, b: 2 };
    const source = { b: 3, c: 4 };
    Object.assign(target, source);
    console.log(target); // 输出 { a: 1, b: 3, c: 4 }
  5. Object.freeze(obj)

    • 冻结对象,阻止添加新属性、移除已有属性、以及更改现有属性的可枚举性、可配置性或可写性。这是一种确保对象不可变的方法。
    1
    2
    3
    4
    const obj = { name: 'Jason' };
    Object.freeze(obj);
    obj.name = 'John'; // 静默失败或在严格模式下抛出TypeError
    console.log(obj.name); // 输出 "Jason"
  6. Object.is(value1, value2)

    • 判断两个值是否相同。与===相比,它能正确区分-0和+0,也能识别NaN是等于自身的。
    1
    2
    console.log(Object.is(-0, +0)); // 输出 false
    console.log(Object.is(NaN, NaN)); // 输出 true

基本包装类型(Basic type of Packaging)

在JavaScript中,基本包装类型是指为原始数据类型(如numberstringboolean)提供对象方法和属性的一种机制。尽管JavaScript中的原始数据类型不是对象,但有时我们需要对这些类型的值执行某些操作,比如调用方法或访问属性。这时,JavaScript会自动创建一个对应的基本包装类型的对象,使得我们可以像操作对象一样操作这些原始值。

JavaScript中有三种基本包装类型:

  1. Number:为数值提供了许多用于执行数学运算的方法。
  2. String:为字符串提供了多种用于操作文本的方法,例如查找子串、提取部分字符串等。
  3. Boolean:虽然不常用,但也为布尔值提供了一些方法。

一旦你尝试对一个原始值进行类似于对象的操作时,JavaScript引擎会在幕后创建一个对应的基本包装对象,该对象允许你访问其方法和属性。操作完成后,这个临时对象即被销毁。

String 包装对象

1
2
3
var str = 'Hello, world!';
var result = str.toUpperCase(); // 调用String包装对象的方法
console.log(result); // 输出 "HELLO, WORLD!"

在这个例子中,str是一个字符串原始值。当我们调用toUpperCase()方法时,JavaScript会自动将str转换成一个临时的String对象,然后在其上调用方法,最后再销毁这个临时对象。

Number 包装对象

1
2
3
var num = 123;
var result = num.toFixed(2); // 调用Number包装对象的方法
console.log(result); // 输出 "123.00"

这里,num是一个数字原始值。通过调用toFixed()方法,我们要求返回一个包含指定小数位数的字符串表示形式。这同样涉及到一个临时的Number对象的创建与销毁过程。

值得注意的是,这种自动创建和销毁包装对象的过程是隐式的,且仅在你需要对原始值使用对象方法时发生。如果你直接操作原始值(例如进行算术运算),则不会涉及基本包装类型。

原型对象 (prototype)和对象原型 (__proto__)

原型对象 (prototype)

在JavaScript中,每个函数(除了箭头函数)都有一个名为prototype的属性。这个属性是一个对象,它包含了该函数作为构造函数时创建的所有实例共享的属性和方法。也就是说,当你使用某个函数来创建对象实例时(通过new关键字),这些实例将继承该函数prototype上的所有成员。

1
2
3
4
5
6
7
8
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log("Hello, my name is " + this.name);
};
var person1 = new Person("Alice");
person1.sayHello(); // 输出: Hello, my name is Alice

在这个例子中,sayHello方法被定义在Person.prototype上,因此所有由Person构造函数创建的实例都可以访问到这个方法。

对象原型 (__proto__)

__proto__是每个JavaScript对象都有的一个内部链接,指向该对象的原型对象。它是对象的一个隐式引用,用于构建原型链。当尝试访问一个对象的属性时,如果该对象自身没有这个属性,JavaScript引擎会沿着__proto__指针向上查找,直到找到该属性或到达原型链的末端(即null)。

1
2
var person1 = new Person("Alice");
console.log(person1.__proto__ === Person.prototype); // 输出: true

这里,person1__proto__指向的是Person.prototype,这意味着你可以通过person1访问定义在Person.prototype上的sayHello方法。

prototype__proto__的区别

原型链(Prototype Chain)

原型链是什么?

原型链是一种机制,它允许JavaScript对象通过其原型对象继承属性和方法。每个对象都有一个指向另一个对象(即其原型)的内部链接(通常称为[[Prototype]],可以通过__proto__访问)。如果尝试访问一个对象的属性或方法而该对象自身没有这个属性或方法时,JavaScript引擎会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的末端(通常是null)。

原型链的工作原理

当创建一个新的对象实例时(比如使用构造函数),该实例的__proto__属性会被设置为指向构造函数的prototype属性。这意味着当你尝试访问一个对象的属性或方法时,如果该对象本身没有定义该属性或方法,JavaScript引擎会在其原型对象上查找。

例如:

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
}

Person.prototype.sayHello = function() {
console.log("Hello, my name is " + this.name);
};

var person1 = new Person("Alice");
person1.sayHello(); // 输出: Hello, my name is Alice

在这个例子中:

继承与原型链

JavaScript中的继承是通过原型链实现的。子类的实例不仅可以通过自身的原型访问父类的方法和属性,还可以通过原型链访问更高级别的原型上的方法和属性。这使得可以形成一个链条,从最具体的对象一直追溯到最通用的对象(通常是Object.prototype)。

例如,考虑一个简单的继承示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Student(name, grade) {
Person.call(this, name); // 调用父类构造函数初始化
this.grade = grade;
}

// 设置Student的原型为一个新的对象,该对象的原型是Person.prototype
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;

Student.prototype.study = function() {
console.log(this.name + " is studying in grade " + this.grade);
};

var student1 = new Student("Bob", 5);
student1.sayHello(); // 输出: Hello, my name is Bob
student1.study(); // 输出: Bob is studying in grade 5

在这个例子中:

原型链的终点

所有正常的对象最终都会链接到Object.prototype,它是普通对象的默认原型。Object.prototype__proto__值是null,这是原型链的终点。

1
2
console.log(Object.getPrototypeOf({}) === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

我的理解:prototype是用来跟函数捆绑的,相当于身份牌,因此放到构造函数里面可以由这“同一个”函数声明不同的对象,proto实际上就是将当前对象捆绑,指向当前对象的父亲,所以如果当前对象要是没有什么属性它会去它父亲那找

浅拷贝(Shallow Copy)

浅拷贝是指创建一个新的对象或数组,并将原始对象或数组的引用复制给它。这意味着新对象和原始对象将共享相同的内存地址,修改其中一个对象的属性或元素也会影响另一个对象。具体来说,浅拷贝只会复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。

浅拷贝可以通过多种方式实现:

例如:

1
2
3
4
let original = { a: 1, b: { c: 2 } };
let shallowCopy = Object.assign({}, original);
original.b.c = 3;
console.log(shallowCopy.b.c); // 输出 3,因为b是一个引用类型,两个对象共享同一个b对象

深拷贝(Deep Copy)

深拷贝是一种创建独立全新对象的方法,它递归地复制每个嵌套对象和数组,有效地避免了使用共享内存带来的修改问题。由于深拷贝与其源对象不共享引用,因此对深拷贝所做的任何更改都不会影响源对象。

实现深拷贝的方式包括:

例如,使用JSON方法进行深拷贝:

1
2
3
4
let original = { a: 1, b: { c: 2 } };
let deepCopy = JSON.parse(JSON.stringify(original));
original.b.c = 3;
console.log(deepCopy.b.c); // 输出 2,因为deepCopy是original的一个完全独立的副本

需要注意的是,使用JSON方法进行深拷贝有一些限制,比如不能正确处理函数、undefined、循环引用等复杂数据结构。对于更复杂的场景,可能需要使用第三方库如Lodash的cloneDeep方法,或者自己编写一个递归函数来处理这些情况。

控制函数执行时的this上下文(this value)

在JavaScript中,call()apply()bind()都是用于控制函数执行时的this上下文的方法。它们都允许你指定一个特定的对象作为函数调用时的this值,但它们之间有一些关键的区别。+

call()

apply()

bind()

总结

防抖(Debouncing)和节流(Throttling)

防抖(Debouncing)和节流(Throttling)是两种常见的技术,用于优化高频率事件触发时的回调函数调用。它们的主要目的是减少不必要的计算或网络请求,从而提高性能和用户体验。尽管它们的目标相似,但它们的工作方式和适用场景有所不同。

防抖(Debouncing)

概念与原理

应用场景

防抖适用于那些希望在用户停止输入一段时间后再进行处理的情况,比如搜索框自动补全、窗口调整大小等。它确保只有当用户完成一系列快速连续的动作后才会执行相应的回调函数,而不是对每个动作都作出反应。

节流(Throttling)

概念与原理

应用场景

节流适合于那些需要持续响应用户交互但不需要即时响应的场景,如无限滚动加载内容、监听窗口大小变化等。它保证了即使事件以很高的频率触发,回调函数也只会在规定的时间间隔内执行一次。

主要区别

防抖实现

1
2
3
4
5
6
7
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}

节流实现

1
2
3
4
5
6
7
8
9
10
11
12
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}