JavaScript是Web开发的核心语言,但是很多人对JavaScript的理解只停留在表面,会写一些简单的DOM操作和AJAX,却对JavaScript的核心概念理解不深。闭包、原型链、作用域,这三个概念是JavaScript的核心,也是面试中最常考的知识点,同时也是很多人感到困惑的地方。

今天就来深入讲解JavaScript的这三个核心概念,帮助大家真正理解JavaScript的工作原理,写出更优雅、更高效的代码。

一、作用域(Scope)

作用域是指变量和函数的可访问范围,也就是在代码的哪些地方可以访问到某个变量或函数。JavaScript的作用域分为全局作用域、函数作用域和块级作用域(ES6新增)。

1. 全局作用域

在代码最外层定义的变量和函数,拥有全局作用域,在代码的任何地方都可以访问。

var globalVar = "我是全局变量";

function globalFunc() {
    console.log("我是全局函数");
}

console.log(globalVar); // 可以访问
globalFunc(); // 可以调用

在浏览器中,全局作用域就是window对象,全局变量和函数都是window对象的属性和方法。

2. 函数作用域

在函数内部定义的变量和函数,拥有函数作用域,只能在函数内部访问,外部无法访问。

function test() {
    var localVar = "我是局部变量";
    console.log(localVar); // 可以访问
}

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

函数作用域的特点:

  • 函数内部可以访问外部的变量(包括全局变量)
  • 函数外部不能访问函数内部的变量
  • 不同函数之间的变量相互独立,互不影响

3. 块级作用域(ES6)

ES6之前,JavaScript只有全局作用域和函数作用域,没有块级作用域,这导致了很多问题。ES6引入了let和const关键字,支持块级作用域。

块级作用域是指在{}(代码块)内部定义的变量,只能在代码块内部访问。

if (true) {
    var a = "var定义的变量";
    let b = "let定义的变量";
    const c = "const定义的变量";
}

console.log(a); // 可以访问,var没有块级作用域
console.log(b); // 报错,b is not defined
console.log(c); // 报错,c is not defined

let和const的区别:

  • let:声明的变量可以重新赋值
  • const:声明的常量不能重新赋值(但是对象的属性可以修改)

4. 作用域链

当访问一个变量时,JavaScript引擎会先在当前作用域查找,如果找不到,就到上一级作用域查找,直到全局作用域。这种逐级查找的机制,就是作用域链。

var a = 1;

function outer() {
    var b = 2;
    
    function inner() {
        var c = 3;
        console.log(a); // 1,沿着作用域链找到全局变量a
        console.log(b); // 2,沿着作用域链找到outer函数的变量b
        console.log(c); // 3,当前作用域的变量c
    }
    
    inner();
}

outer();

作用域链的方向是从内到外,内部可以访问外部,外部不能访问内部。这也是闭包的基础。

二、闭包(Closure)

闭包是JavaScript中最重要也是最容易被误解的概念之一。简单来说,闭包是指有权访问另一个函数作用域中的变量的函数。

1. 什么是闭包

当一个内部函数引用了外部函数的变量,并且外部函数执行完毕后,内部函数依然存在(被返回或被引用),那么内部函数和它引用的变量就形成了闭包。

function outer() {
    var count = 0;
    
    function inner() {
        count++;
        console.log(count);
    }
    
    return inner;
}

var counter = outer();
counter(); // 1
counter(); // 2
counter(); // 3

在这个例子中,outer函数执行完毕后,它的变量count本应该被销毁,但是因为inner函数引用了count,并且inner被返回给了counter,所以count不会被销毁,而是一直保存在内存中。这就是闭包。

2. 闭包的用途

  • 私有变量:利用闭包可以实现私有变量,外部无法直接访问,只能通过特定的方法访问和修改。
function createCounter() {
    var count = 0; // 私有变量
    
    return {
        increment: function() {
            count++;
            return count;
        },
        decrement: function() {
            count--;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
}

var counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(counter.count); // undefined,无法直接访问私有变量
  • 函数柯里化:利用闭包可以实现函数柯里化,将多参数函数转化为单参数函数。
function add(a) {
    return function(b) {
        return a + b;
    };
}

var add5 = add(5);
console.log(add5(3)); // 8
console.log(add5(10)); // 15
  • 回调函数和事件处理:闭包在回调函数和事件处理中非常常见,可以保存状态。
for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2, 3, 4
        }, 1000);
    })(i);
}

如果不用闭包,直接写setTimeout,会输出5个5,因为i是全局变量,循环结束后i=5。

3. 闭包的注意事项

  • 内存泄漏:闭包会使得函数中的变量保存在内存中,不会被垃圾回收,如果使用不当,会导致内存泄漏。不需要的时候,应该手动释放引用。
counter = null; // 释放引用,让垃圾回收回收内存
  • 循环中的闭包:在循环中使用闭包要注意,上面的例子已经说明了,需要用立即执行函数(IIFE)或者let来解决。

三、原型链(Prototype Chain)

原型链是JavaScript实现继承的核心机制,也是JavaScript最独特的地方。理解原型链,才能真正理解JavaScript的面向对象。

1. 原型对象

在JavaScript中,每个函数都有一个prototype属性,指向一个对象,这个对象就是原型对象。原型对象包含了可以被所有实例共享的属性和方法。

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

Person.prototype.sayHello = function() {
    console.log("你好,我是" + this.name);
};

var p1 = new Person("张三");
var p2 = new Person("李四");

p1.sayHello(); // 你好,我是张三
p2.sayHello(); // 你好,我是李四

在这个例子中,sayHello方法定义在Person.prototype上,所有Person的实例都可以共享这个方法,不需要每个实例都创建一份,节省了内存。

2. proto和constructor

每个对象(除了null)都有一个proto属性,指向创建它的构造函数的prototype原型对象。

console.log(p1.__proto__ === Person.prototype); // true

每个原型对象都有一个constructor属性,指向对应的构造函数。

console.log(Person.prototype.constructor === Person); // true

3. 原型链

当访问一个对象的属性或方法时,JavaScript引擎会先在对象本身查找,如果找不到,就到它的proto(原型对象)查找,如果还找不到,就到原型对象的proto查找,直到找到或者到达null(Object.prototype.proto === null)。这种逐级查找的链,就是原型链。

console.log(p1.toString()); // [object Object]
// p1本身没有toString方法
// p1.__proto__(Person.prototype)也没有toString方法
// p1.__proto__.__proto__(Object.prototype)有toString方法
// 所以调用的是Object.prototype.toString

原型链的结构: p1 → Person.prototype → Object.prototype → null

4. 原型链实现继承

利用原型链,可以实现JavaScript的继承。

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

Animal.prototype.eat = function() {
    console.log(this.name + "在吃东西");
};

function Dog(name, breed) {
    Animal.call(this, name); // 调用父类构造函数,继承属性
    this.breed = breed;
}

// 原型链继承
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    console.log(this.name + "在汪汪叫");
};

var dog = new Dog("旺财", "金毛");
dog.eat(); // 旺财在吃东西(继承自Animal)
dog.bark(); // 旺财在汪汪叫(自己的方法)

这是ES6之前常用的继承方式,ES6引入了class语法糖,让继承更简单:

class Animal {
    constructor(name) {
        this.name = name;
    }
    
    eat() {
        console.log(this.name + "在吃东西");
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
    
    bark() {
        console.log(this.name + "在汪汪叫");
    }
}

但是要注意,class只是语法糖,底层依然是原型链。

四、三者的关系

作用域、闭包、原型链,这三个概念是相互关联的:

  • 作用域是基础:作用域决定了变量的可访问范围,作用域链是变量查找的机制。
  • 闭包是作用域的应用:闭包利用了作用域链的特性,让函数在外部依然能够访问内部变量。
  • 原型链是属性查找的机制:和作用域链类似,原型链是对象属性查找的机制,从对象本身沿着原型链向上查找。

理解了这三个概念,就理解了JavaScript的核心工作原理,能够写出更优雅、更高效的代码,也能在面试中从容应对。

总结

JavaScript的作用域、闭包、原型链是三个核心概念,也是很多人感到困惑的地方。本文从基础到进阶,详细讲解了这三个概念:

  1. 作用域:全局作用域、函数作用域、块级作用域,作用域链是变量查找的机制。
  2. 闭包:有权访问另一个函数作用域中变量的函数,用于实现私有变量、函数柯里化、回调函数等,注意内存泄漏问题。
  3. 原型链:JavaScript实现继承的核心机制,每个对象都有proto指向原型对象,属性查找沿着原型链向上,直到null。

掌握这三个概念,是成为一名优秀JavaScript开发者的必经之路。希望本文能够帮助大家深入理解JavaScript,写出更好的代码。