JavaScript 一些进阶知识点与注意事项

一、JavaScript 执行机制:从同步到异步的深度解析

JavaScript 作为单线程语言,其执行机制直接决定了代码的运行顺序与性能表现。理解执行机制,是解决异步回调、定时器延迟等问题的关键。下面我们将深入探讨其核心概念、执行顺序以及实际应用中的注意事项。

1.1 核心概念:调用栈、任务队列与事件循环

调用栈(Call Stack)

调用栈是JavaScript引擎用来管理函数调用的一种数据结构,遵循"后进先出"(LIFO)原则。例如:

javascript 复制代码
function first() {
    console.log('first');
    second();
}
function second() {
    console.log('second');
}
first();

执行过程:

  1. first()被压入调用栈
  2. console.log('first')被压入并立即执行
  3. second()被压入栈顶
  4. console.log('second')被压入并执行
  5. 依次弹出完成的任务
任务队列(Task Queue)

JavaScript通过任务队列处理异步操作,分为两种类型:

宏任务(Macro Task)

  • 典型示例:setTimeout、setInterval
  • 执行时机:在事件循环的每个周期执行一个
  • 其他常见宏任务:I/O操作、UI渲染、script整体代码

微任务(Micro Task)

  • 典型示例:Promise回调
  • 执行时机:在每个宏任务执行后立即执行
  • 其他常见微任务:MutationObserver、queueMicrotask
事件循环(Event Loop)

事件循环的具体流程如下:

  1. 执行当前调用栈中的所有同步任务
  2. 检查微任务队列并执行所有微任务
  3. 执行一个宏任务
  4. 重复步骤2-3

1.2 实例分析:代码执行顺序判断

javascript 复制代码
console.log('1'); // 同步代码,立即执行

setTimeout(() => {
    console.log('2'); // 宏任务,加入队列
}, 0);

new Promise((resolve) => {
    console.log('3'); // 同步代码,立即执行
    resolve();
}).then(() => {
    console.log('4'); // 微任务,加入队列
});

console.log('5'); // 同步代码,立即执行

执行过程详解:

  1. 同步代码顺序执行:1、3、5
  2. 微任务队列:4
  3. 宏任务队列:2
  4. 最终输出顺序:1 → 3 → 5 → 4 → 2

1.3 注意事项

setTimeout的延迟问题
javascript 复制代码
console.log('开始');
setTimeout(() => {
    console.log('setTimeout');
}, 1000);
// 模拟耗时操作
const start = Date.now();
while(Date.now() - start < 2000) {}
console.log('结束');

实际输出:

  1. 开始
  2. 结束(约2秒后)
  3. setTimeout(总共约2秒后)

这表明setTimeout的延迟时间是从代码执行开始算起的最小延迟,而非精确延迟。

async/await的微任务特性
javascript 复制代码
async function example() {
    console.log('async start');
    await new Promise(resolve => resolve());
    console.log('async end');
}

console.log('script start');
example();
new Promise(resolve => {
    console.log('promise');
    resolve();
}).then(() => {
    console.log('then');
});
console.log('script end');

输出顺序:

  1. script start
  2. async start
  3. promise
  4. script end
  5. async end
  6. then

这说明:

  • await之后的代码会被包装成微任务
  • 微任务的执行顺序取决于它们在队列中的位置

二、作用域与作用域链:变量访问的规则与陷阱

作用域概述

作用域决定了变量的可访问范围,作用域链则是变量查找的路径。理解这两个概念,可有效避免变量污染与访问异常。在JavaScript中,作用域机制是代码执行的重要基础,直接影响变量的生命周期和访问方式。

2.1 作用域的分类

全局作用域

全局作用域是代码最外层的作用域,在浏览器环境中由window对象(Node.js中为global)代表。全局变量可在任何地方访问,但过度使用会导致变量污染。

  • 特点:生命周期贯穿整个程序运行期间

  • 示例:

    javascript 复制代码
    var globalVar = "我是全局变量";
    function test() {
      console.log(globalVar); // 可以访问
    }

函数作用域

函数内部形成的独立作用域,函数内声明的变量(var/let/const)仅在函数内可访问。

  • 特点:每次函数调用都会创建新的作用域

  • 示例:

    javascript 复制代码
    function myFunc() {
      var funcVar = "函数内部变量";
      console.log(funcVar); // 可以访问
    }
    console.log(funcVar); // 报错:funcVar未定义

块级作用域

由{}包裹的区域(如if、for、while语句块,或独立{}),仅let/const声明的变量会受块级作用域限制。

  • ES6新增特性

  • var声明的变量会"穿透"块级作用域(挂载到全局或函数作用域)

  • 示例:

    javascript 复制代码
    if (true) {
      let blockVar = "块级变量";
      var oldVar = "旧式变量";
    }
    console.log(oldVar); // 可以访问
    console.log(blockVar); // 报错:blockVar未定义

2.2 作用域链的形成与变量查找规则

当访问一个变量时,JavaScript引擎会按照以下步骤查找:

  1. 首先在当前作用域中查找
  2. 如果未找到,则沿"当前作用域→父级作用域→...→全局作用域"的路径向上查找
  3. 这条查找路径即为作用域链
  4. 查找结果:
    • 找到:使用该变量
    • 未找到:
      • 非严格模式下:隐式声明为全局变量
      • 严格模式下:抛出ReferenceError

作用域链示例

javascript 复制代码
const globalVar = '全局变量';

function parent() {
  const parentVar = '父级变量';
  
  function child() {
    const childVar = '子级变量';
    
    console.log(childVar); // 1.子级作用域找到,输出"子级变量"
    console.log(parentVar); // 2.沿作用域链向上,父级作用域找到,输出"父级变量"
    console.log(globalVar); // 3.继续向上,全局作用域找到,输出"全局变量"
    console.log(unknownVar); // 4.未找到,抛出ReferenceError
  }
  
  child();
}

parent();

作用域链的创建过程

  1. 函数定义时,会保存当前的作用域链
  2. 函数调用时,会创建新的执行上下文
  3. 新执行上下文的的作用域链 = 当前函数的作用域 + 保存的作用域链

2.3 注意事项与最佳实践

var的变量提升与作用域问题

  • var声明的变量会发生"变量提升"(声明被提升到作用域顶部,赋值保留在原地)

  • 不支持块级作用域

  • 示例:

    javascript 复制代码
    console.log(x); // undefined(不会报错)
    if (true) {
      var x = 10; // 穿透块级作用域,挂载到全局/函数作用域
    }
    console.log(x); // 10(无报错)

严格模式的影响

在严格模式('use strict')下:

  • 未声明直接赋值的变量会抛出ReferenceError

  • 禁止使用with语句(会破坏作用域链)

  • 禁止删除不可删除的属性

  • 示例:

    javascript 复制代码
    'use strict';
    undeclaredVar = 10; // 抛出ReferenceError

性能优化建议

  • 作用域链层级越深,变量查找效率越低
  • 优化方案:
    1. 避免在深层嵌套中频繁访问外层变量

    2. 使用"变量缓存"技术:

      javascript 复制代码
      function processData(data) {
        // 将外层变量缓存到局部变量
        const len = data.length;
        for(let i=0; i<len; i++) {
          // 使用缓存的len而不是每次都访问data.length
        }
      }
    3. 合理使用闭包,避免不必要的变量保留

ES6+最佳实践

  1. 优先使用let/const代替var
  2. 合理使用块级作用域
  3. 模块化编程减少全局变量污染
  4. 使用立即执行函数(IIFE)创建独立作用域(在ES6之前常用)

三、闭包:JavaScript 中的 "状态保存"

闭包的概念与重要性

闭包是 JavaScript 最具特色的特性之一,它允许函数访问其定义时所在的作用域,即使该函数在其他作用域中执行。这种机制使得 JavaScript 能够实现许多高级编程模式,如模块化、状态保持和函数工厂等。理解闭包对于掌握 JavaScript 的核心概念至关重要。

3.1 闭包的定义与形成条件

定义

闭包是指一个函数能够访问其外部函数(父函数)的变量,并且该函数在父函数外部被调用时,该函数与其外部环境共同构成的组合。闭包使得函数可以"记住"并访问其创建时的词法作用域,即使该函数在其原始作用域之外执行。

形成条件

闭包的形成需要满足以下三个条件:

  1. 函数嵌套:必须存在至少一个内部函数定义在外部函数内部
  2. 变量引用:内部函数必须引用外部函数的变量(包括参数)
  3. 外部调用:内部函数必须在外部函数外部被调用(通常通过返回内部函数实现)

3.2 实例:闭包的常见应用场景

场景 1:保存函数执行状态

javascript 复制代码
function createCounter() {
    let count = 0; // 外部函数变量,被内部函数引用
    
    return function() {
        count++; // 内部函数访问并修改外部变量
        return count;
    };
}

const counter1 = createCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2(count 状态被保存)

const counter2 = createCounter();
console.log(counter2()); // 1(与 counter1 独立,各自保存 count 状态)

这个例子展示了闭包如何保存状态:

  • 每次调用 createCounter() 都会创建一个新的词法环境
  • 返回的函数会"记住"它被创建时的环境
  • 不同的计数器实例完全独立,各自维护自己的 count 状态

场景 2:模块化封装(避免变量污染)

javascript 复制代码
const module = (function() {
    const privateVar = '私有变量'; // 仅内部可访问
    const secretKey = '12345';    // 另一个私有变量
    
    function privateFn() {
        // 私有方法
        return privateVar + ':' + secretKey;
    }
    
    return {
        publicFn: function() {
            // 对外暴露的公共方法,可访问私有变量/方法
            return privateFn();
        },
        anotherPublicFn: function() {
            // 另一个公共方法
            return '仅返回公共部分';
        }
    };
})();

console.log(module.publicFn());      // "私有变量:12345"
console.log(module.anotherPublicFn());// "仅返回公共部分"
console.log(module.privateVar);      // undefined(私有变量不可直接访问)
console.log(module.privateFn);       // undefined(私有方法不可直接访问)

这种模式称为"模块模式",它:

  • 使用立即执行函数(IIFE)创建私有作用域
  • 只暴露必要的公共接口
  • 保护内部实现细节不被外部直接访问
  • 避免全局命名空间污染

3.3 闭包使用注意事项

内存泄漏风险

闭包会持有外部函数的作用域,若闭包未被正确释放(如被全局变量引用),外部函数的变量会一直驻留在内存中,导致内存泄漏。

示例:

javascript 复制代码
function createHeavyObject() {
    const largeObject = new Array(1000000).fill('data'); // 大型数据
    
    return function() {
        return largeObject.length;
    };
}

let heavyClosure = createHeavyObject(); // largeObject 无法被回收

// 使用后应释放引用
heavyClosure = null; // 现在 largeObject 可以被垃圾回收

循环中的闭包陷阱(var 时代的问题)

问题代码:

javascript 复制代码
const arr = [];
for (var i = 0; i < 3; i++) {
    arr.push(function() { console.log(i); });
}
arr[0](); // 3(而非预期的 0)
arr[1](); // 3
arr[2](); // 3

原因分析:

  • var 没有块级作用域,所有闭包共享同一个 i
  • 当闭包执行时,循环已经结束,i 的值为 3

解决方案1:使用 let(推荐)

javascript 复制代码
const arr = [];
for (let i = 0; i < 3; i++) {
    arr.push(function() { console.log(i); });
}
arr[0](); // 0
arr[1](); // 1
arr[2](); // 2

解决方案2:使用立即执行函数(IIFE)

javascript 复制代码
const arr = [];
for (var i = 0; i < 3; i++) {
    (function(j) {
        arr.push(function() { console.log(j); });
    })(i);
}
arr[0](); // 0
arr[1](); // 1
arr[2](); // 2

解决方案3:使用函数参数

javascript 复制代码
const arr = [];
for (var i = 0; i < 3; i++) {
    arr.push(function(j) { 
        return function() { console.log(j); }
    }(i));
}
arr[0](); // 0
arr[1](); // 1
arr[2](); // 2

在现代JavaScript开发中,推荐使用 letconst 来避免这类问题,它们具有块级作用域特性,能更自然地处理循环中的闭包问题。

四、原型与原型链:JavaScript 继承的核心机制

JavaScript 在 ES6 之前并非基于类的语言,而是通过"原型(Prototype)"机制实现继承。理解原型与原型链是掌握对象继承和方法复用的关键基础。

4.1 核心概念深度解析

原型(Prototype)机制

每个函数(除箭头函数外)在创建时都会自动获得一个prototype属性,这个属性值是一个对象,我们称之为"原型对象"。这个原型对象中包含的方法和属性可以被该函数创建的所有实例共享。

实际应用场景 :当我们需要让某个构造函数创建的所有实例共享某些方法时,将这些方法定义在构造函数的prototype上是最佳实践。这样可以节省内存,因为所有实例共享同一份方法副本,而不是每个实例都创建自己的副本。

__proto__prototype的关系

  1. 实例对象的__proto__ :每个JavaScript对象(除null外)都有一个__proto__属性(非标准属性,ECMAScript标准中使用Object.getPrototypeOf()方法获取),指向创建该对象的构造函数的prototype属性。

    示例

    javascript 复制代码
    const arr = [];
    console.log(arr.__proto__ === Array.prototype); // true
  2. 构造函数的prototype :构造函数的prototype本身也是一个对象,它的__proto__指向Object.prototype(原型链的顶端)。

    示例

    javascript 复制代码
    function Foo() {}
    console.log(Foo.prototype.__proto__ === Object.prototype); // true

原型链的工作原理

当访问对象的属性或方法时,JavaScript引擎会按照以下顺序查找:

  1. 首先检查对象自身是否拥有该属性
  2. 如果没有,通过__proto__查找其原型对象
  3. 继续通过原型对象的__proto__向上查找
  4. 直到找到Object.prototype(原型链的顶端)
  5. 如果仍未找到,返回undefined

性能考虑:原型链越长,属性查找的时间就越长。因此在性能敏感的场景下,应尽量减少原型链的长度。

4.2 实例:原型链的继承与方法共享

javascript 复制代码
// 1. 定义构造函数
function Person(name) {
    this.name = name; // 实例属性(每个实例独立)
    this.species = 'human'; // 所有Person实例共享但可修改的属性
}

// 2. 在原型对象上定义方法(所有实例共享)
Person.prototype.sayHello = function() {
    console.log(`Hello, ${this.name}`);
};

// 3. 添加静态方法(直接定义在构造函数上)
Person.describe = function() {
    console.log('This is a Person constructor');
};

// 4. 创建实例
const person1 = new Person('Alice');
const person2 = new Person('Bob');

// 5. 验证原型链关系
console.log(person1.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

// 6. 属性查找顺序验证
console.log(person1.hasOwnProperty('name')); // true
console.log(person1.hasOwnProperty('sayHello')); // false
console.log('sayHello' in person1); // true

// 7. 方法共享验证
person1.sayHello(); // Hello, Alice
person2.sayHello(); // Hello, Bob

// 8. 静态方法调用
Person.describe(); // This is a Person constructor

4.3 注意事项与最佳实践

1. 原型操作的注意事项

  • 避免直接修改__proto____proto__是历史遗留的非标准属性,直接修改会破坏原型链的稳定性,并且性能较差。

    推荐做法

    • 使用Object.create()创建具有指定原型的新对象
    • 使用Object.getPrototypeOf()获取对象的原型
    • 使用Object.setPrototypeOf()设置对象的原型(但需谨慎,可能影响性能)
  • 原型链的顶端Object.prototype是所有普通对象的最终原型,它的__proto__指向null。这使得所有对象都能继承基本方法如toString()hasOwnProperty()等。

2. ES6 class与原型的关系

ES6引入的class语法实际上是原型继承的语法糖,并非真正的"类继承"。

比较示例

javascript 复制代码
// ES6 class语法
class PersonClass {
    constructor(name) {
        this.name = name;
    }
    
    sayHello() {
        console.log(`Hello, ${this.name}`);
    }
}

// 等效的ES5原型实现
function PersonFunc(name) {
    this.name = name;
}
PersonFunc.prototype.sayHello = function() {
    console.log(`Hello, ${this.name}`);
};

// 验证两者本质相同
console.log(typeof PersonClass); // "function"
console.log(PersonClass.prototype.hasOwnProperty('sayHello')); // true

3. 继承中的constructor指向问题

实现原型继承时常见的陷阱是constructor指向错误。正确的继承实现方式:

javascript 复制代码
function Parent(name) {
    this.name = name;
}
Parent.prototype.sayName = function() {
    console.log(this.name);
};

function Child(name, age) {
    Parent.call(this, name); // 继承父类实例属性
    this.age = age;
}

// 错误的继承实现方式
// Child.prototype = new Parent(); 
// 会导致Child.prototype.constructor指向Parent

// 正确的继承实现步骤:
// 1. 使用Object.create创建以父类prototype为原型的新对象
Child.prototype = Object.create(Parent.prototype);
// 2. 手动修正constructor指向
Child.prototype.constructor = Child;
// 3. 添加子类特有的方法
Child.prototype.sayAge = function() {
    console.log(this.age);
};

// 验证继承关系
const child = new Child('Alice', 10);
console.log(child instanceof Child); // true
console.log(child instanceof Parent); // true
console.log(Child.prototype.constructor === Child); // true

4. 性能优化建议

  • 避免过长的原型链:超过3层的原型链会明显影响属性查找性能
  • 谨慎使用动态原型修改:在运行时修改原型会导致JavaScript引擎无法优化属性访问
  • 优先使用标准方法 :避免使用__proto__,改用Object.getPrototypeOf()等标准方法

五、this 绑定:JavaScript 中最易混淆的关键字

函数执行时的上下文对象

this 是函数执行时的 "上下文对象",其指向并非由定义时决定,而是由调用方式决定。掌握 this 绑定规则可避免上下文混乱,是 JavaScript 开发中的核心概念。

四种核心绑定规则(优先级从高到低)

1. new 绑定(构造函数调用)

当使用 new 关键字调用函数时,this 指向新创建的实例对象。这是 JavaScript 中面向对象编程的基础。

javascript 复制代码
function Person(name) {
  // new 调用时,this 自动绑定到新创建的对象
  this.name = name;
  this.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
  };
}

const person = new Person('Charlie');
console.log(person.name); // Charlie
person.sayHello(); // Hello, I'm Charlie

2. 显式绑定(call/apply/bind)

通过以下方法强制指定函数执行时的 this:

  • call(obj, arg1, arg2...):立即调用函数,this 绑定到 obj
  • apply(obj, [arg1, arg2...]):与 call 类似,但参数以数组形式传递
  • bind(obj):返回一个新函数,this 永久绑定到 obj(不可修改)
javascript 复制代码
function introduce(age, hobby) {
  console.log(`${this.name} is ${age} years old, likes ${hobby}`);
}

const person = { name: 'David' };

// call 示例
introduce.call(person, 28, 'hiking'); 
// David is 28 years old, likes hiking

// apply 示例
introduce.apply(person, [28, 'hiking']); 
// 同上,参数格式不同

// bind 示例
const boundIntroduce = introduce.bind(person);
boundIntroduce(28, 'hiking'); 
// this 永久绑定到 person

3. 隐式绑定(对象方法调用)

当函数作为对象的方法被调用时,this 指向该对象。

javascript 复制代码
const company = {
  name: 'TechCorp',
  employees: ['Alice', 'Bob', 'Charlie'],
  showEmployees: function() {
    console.log(`${this.name}'s employees:`);
    this.employees.forEach(emp => {
      console.log(`- ${emp}`);
    });
  }
};

company.showEmployees(); 
// this 指向 company 对象

4. 默认绑定(独立函数调用)

若函数无上述绑定方式:

  • 非严格模式:this 指向全局对象(浏览器中为 window,Node.js 中为 global)
  • 严格模式:this 为 undefined
javascript 复制代码
function showThis() {
  console.log(this);
}

// 非严格模式
showThis(); // 浏览器中输出 window 对象

// 严格模式
"use strict";
function strictShowThis() {
  console.log(this);
}
strictShowThis(); // undefined

特殊场景的 this 指向

箭头函数

箭头函数没有自己的 this,其 this 继承自定义时所在的外层作用域的 this,且无法通过 call/apply/bind 或 new 修改。

javascript 复制代码
const team = {
  name: 'Alpha',
  members: ['Alice', 'Bob'],
  showMembers: function() {
    // 传统函数
    this.members.forEach(function(member) {
      console.log(`${member} from ${this.name}`); 
      // 这里的 this 是 undefined(严格模式)或全局对象
    });
    
    // 箭头函数
    this.members.forEach(member => {
      console.log(`${member} from ${this.name}`); 
      // 这里的 this 继承自 showMembers 方法的 this
    });
  }
};

team.showMembers();

事件处理函数中的 this

在 DOM 事件处理函数中,this 通常指向触发事件的元素:

javascript 复制代码
document.querySelector('button').addEventListener('click', function() {
  console.log(this); // 指向被点击的 button 元素
});

setTimeout/setInterval 中的 this

在这些函数中,this 默认指向全局对象:

javascript 复制代码
const obj = {
  name: 'Timer',
  start: function() {
    setTimeout(function() {
      console.log(this.name); // undefined(非严格模式指向 window)
    }, 1000);
    
    // 解决方案1:使用箭头函数
    setTimeout(() => {
      console.log(this.name); // Timer
    }, 1000);
    
    // 解决方案2:使用 bind
    setTimeout(function() {
      console.log(this.name); // Timer
    }.bind(this), 1000);
  }
};

obj.start();
复制代码