深入理解JavaScript:从闭包到防抖节流与面向对象封装

80 阅读15分钟

前言

作为一名前端开发者,我们每天都在与JavaScript打交道。然而,你是否真正理解了这门语言的底层机制?本文将带你深入探索JavaScript中几个核心且容易混淆的概念:闭包、防抖、节流以及面向对象封装。我们将从原理出发,结合实际代码案例,力求将这些知识点讲透彻,帮助你构建更健壮、高性能的Web应用。

一、闭包:JavaScript的“魔法”

闭包是JavaScript中一个强大且经常被误解的特性。简单来说,当一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行时,就形成了闭包。这意味着内部函数可以访问外部函数的变量,即使外部函数已经执行完毕。

1.1 闭包的形成与原理

要理解闭包,首先要明白JavaScript的作用域链。当一个函数被定义时,它会创建一个作用域链,这个作用域链包含了它自己的作用域以及所有父级作用域。当函数执行时,它会沿着作用域链查找变量。闭包的“魔法”在于,即使外部函数执行完毕,其内部函数仍然保留着对外部函数作用域的引用,从而能够访问外部函数的变量。

让我们通过一个简单的例子来理解闭包的形成:

function outerFunction() {
    let outerVariable = 'I am from outer function';
​
    function innerFunction() {
        console.log(outerVariable); // innerFunction 访问 outerFunction 的变量
    }
​
    return innerFunction;
}
​
const myInnerFunction = outerFunction();
myInnerFunction(); // 输出: I am from outer function

在这个例子中,innerFunctionouterFunction 返回并在外部执行。尽管 outerFunction 已经执行完毕,但 myInnerFunction(实际上就是 innerFunction)仍然能够访问 outerVariable。这就是闭包的体现。

1.2 闭包的应用场景

闭包在实际开发中有着广泛的应用,以下是一些常见的场景:

1.2.1 私有变量与方法

闭包可以用来创建私有变量和方法,从而实现数据封装。外部无法直接访问这些私有成员,只能通过暴露的公共方法进行操作。这在面向对象编程中非常有用,可以保护数据不被随意修改。

例子中 CreateCounter 函数:

function CreateCounter(num) {
    this.num = num; // public
    let count = 0; // 私有变量 private 
​
    return {
        num: num,
        increment: () => {
            count++;
        },
        decrement: () => {
            count--;
        },
        getCount: () => {
            console.log('count 被访问了');
            return count;
        }
    }
}
​
const counter = CreateCounter(1);
console.log(counter.num);
console.log(counter.getCount()); // 0
counter.increment();
console.log(counter.getCount()); // 1

在这个例子中,count 是一个私有变量,外部无法直接访问 counter.count。我们只能通过 incrementdecrementgetCount 这些公共方法来操作和获取 count 的值。这有效地实现了数据的封装和保护。

类似的,下面例子中的 Book 构造函数也展示了如何使用闭包实现私有属性和方法:

function Book(title, author, year) {
    let _title = title; 
    let _author = author;
    let _year = year;
​
    this.getTitle = function(){
        return _title;
    }
​
    function getFullTitle(){
        return `${_title} by ${_author} `
    }
​
    this.getFullInfo = function(){
        return `${getFullTitle()},published in ${_year}`
    }
    
    // ... 其他方法
}
​
let book = new Book("JavaScript高级程序设计","Nicholas C.Zakas",2011);
console.log(book.getTitle());
console.log(book.getFullInfo());

这里的 _title, _author, _year 以及 getFullTitle 都是私有的,外部无法直接访问。通过 getTitlegetFullInfo 等公共方法,我们可以间接地获取和操作这些私有数据。

1.2.2 记忆函数(Memoization)

记忆函数是一种优化技术,通过缓存函数的计算结果,避免重复计算,从而提高性能。闭包在这里的作用是为缓存提供一个持久化的存储空间。

1.2.3 柯里化(Currying)

柯里化是一种将接受多个参数的函数转换为一系列只接受一个参数的函数的技术。闭包在柯里化中用于保存每次调用时传入的参数,直到所有参数都收集完毕。

1.2.4 偏函数(Partial Application)

偏函数与柯里化类似,但它不要求每次只接受一个参数,而是固定函数的部分参数,返回一个新函数。闭包同样用于保存已固定的参数。

1.2.5 立即执行函数(IIFE)

立即执行函数(Immediately Invoked Function Expression)通常用于创建一个独立的作用域,避免变量污染全局环境。虽然IIFE本身不是闭包,但它经常与闭包结合使用,例如在循环中创建独立的闭包来捕获每次迭代的变量值。

二、函数防抖(Debounce):优化事件触发频率

在前端开发中,我们经常会遇到一些高频率触发的事件,例如窗口的 resizescroll、输入框的 keyupmousemove 等。如果这些事件的回调函数执行的开销较大,频繁触发会导致页面卡顿,用户体验下降。函数防抖就是解决这类问题的有效方案。

2.1 防抖的原理

防抖的核心思想是:在事件被触发后,不立即执行回调函数,而是设置一个延迟时间。如果在延迟时间内再次触发了该事件,则清除上一次的定时器,重新开始计时。直到事件停止触发,并且超过了延迟时间,才执行回调函数。简单来说,就是“你尽管触发,我只在最后一次触发后执行”。

让我们分析 1.html2.htmldebounce 函数的实现:

function debounce(fn, delay) {
    let timeoutId; // 用于存储定时器ID
    return function(...args) {
        const context = this; // 保存函数执行时的this上下文
        clearTimeout(timeoutId); // 清除上一次的定时器
        timeoutId = setTimeout(function() {
            fn.apply(context, args); // 延迟执行回调函数,并绑定正确的this和参数
        }, delay);
    };
}

原理分析:

  1. timeoutId 这是一个在 debounce 函数作用域内的变量,被返回的匿名函数(闭包)所引用。它用于存储 setTimeout 返回的定时器ID,以便在事件再次触发时清除上一次的定时器。
  2. return function(...args) debounce 函数返回一个新函数。这个新函数就是我们实际会绑定到事件监听器上的函数。每次事件触发时,都会执行这个新函数。
  3. const context = this; 在返回的函数内部,this 的指向取决于函数的调用方式。为了确保原始回调函数 fn 在执行时能够正确地获取到 this(例如,在事件监听器中,this 指向触发事件的DOM元素),我们使用 context 变量保存当前的 this
  4. clearTimeout(timeoutId); 这是防抖的关键。每次事件触发时,都会先清除之前设置的定时器。这意味着,如果在 delay 时间内事件再次触发,上一次的延迟执行就会被取消。
  5. timeoutId = setTimeout(...) 重新设置一个新的定时器。只有当事件在 delay 时间内不再触发时,这个定时器才会真正执行其回调函数。
  6. fn.apply(context, args); 在定时器回调中,使用 apply 方法来调用原始函数 fnapply 的第一个参数用于指定 fn 执行时的 this 上下文,第二个参数是一个数组,包含了传递给 fn 的所有参数。

2.2 防抖的应用场景

  • 搜索框输入: 用户在搜索框中输入内容时,我们通常不希望每输入一个字符就立即发送一次AJAX请求。使用防抖可以确保只有在用户停止输入一段时间后才发送请求,减少服务器压力。 1.html 中的 inputB 示例就是典型的搜索建议防抖应用。
  • 窗口 resize 调整浏览器窗口大小时,如果需要重新计算布局或执行其他昂贵的操作,可以使用防抖来避免频繁触发。
  • 表单验证: 在用户输入表单内容时,实时验证通常在用户停止输入后进行。

三、函数节流(Throttle):控制事件执行频率

与防抖不同,函数节流的目的是在一定时间内,只允许函数执行一次。它控制的是函数的执行频率,而不是像防抖那样只在最后一次触发后执行。节流常用于那些需要持续响应但又不能过于频繁的场景。

3.1 节流的原理

节流的核心思想是:在事件被触发后,立即执行回调函数,并设置一个冷却时间。在冷却时间内,即使事件再次触发,也不会执行回调函数。直到冷却时间结束后,才能再次执行。简单来说,就是“我在冷却中,你等等再触发”。

让我们分析以下例子 中 throttle 函数的实现:

function throttle(fn, delay) {
    let last; // 上一次的执行时间
    let deferTimer; // timeout id
​
    return function(...args) {
        const context = this; 
        const now = +new Date(); // 获取当前时间戳
​
        if (last && now < last + delay) {
            // 如果在冷却时间内,则清除之前的定时器(如果有),并重新设置一个定时器
            // 确保在冷却时间结束后,至少会执行一次
            clearTimeout(deferTimer);
            deferTimer = setTimeout(function() {
                last = now;
                fn.apply(context, args);
            }, delay);
        } else {
            // 如果不在冷却时间内,立即执行
            last = now;
            fn.apply(context, args);
        }
    };
}

原理分析:

  1. last 记录上一次函数执行的时间戳。这是节流的关键变量。
  2. deferTimer 用于存储定时器ID,在某些情况下(例如,当事件在冷却时间内持续触发时),我们需要一个定时器来确保函数在冷却结束后至少执行一次。
  3. const now = +new Date(); 获取当前时间戳,+new Date()new Date().getTime() 的简写形式,用于快速获取时间戳。
  4. if (last && now < last + delay) 判断当前时间是否在冷却时间内。如果 last 存在(表示之前执行过),并且当前时间距离上次执行的时间小于 delay,则说明还在冷却中。
  5. clearTimeout(deferTimer); deferTimer = setTimeout(...) 如果在冷却时间内,我们清除可能存在的旧定时器,并设置一个新的定时器。这个定时器的作用是,如果在 delay 时间内事件持续触发,那么在 delay 时间结束后,函数会执行一次。这是一种“延迟执行”的策略,确保在冷却期结束后能够响应最新的事件。
  6. else { last = now; fn.apply(context, args); } 如果不在冷却时间内,说明可以立即执行函数。此时,更新 last 为当前时间,并立即执行 fn

3.2 节流的应用场景

  • 游戏中的技能CD: 玩家释放技能后,需要等待一段时间才能再次释放。这与节流的原理非常吻合。
  • 页面滚动(scroll)事件: 当用户滚动页面时,如果需要执行一些计算或DOM操作,可以使用节流来限制其执行频率,避免页面卡顿。 4.html 中的 inputC 示例虽然是 keyup 事件,但其 throttle 的实现逻辑适用于 scroll 等需要持续响应的场景。
  • 拖拽事件(mousemove): 在实现拖拽功能时,mousemove 事件会频繁触发。使用节流可以平滑拖拽体验,减少不必要的计算。

四、this 绑定:JavaScript的“迷”

this 是JavaScript中一个非常重要的关键字,但其指向却常常让人感到困惑。this 的值在函数执行时才确定,并且取决于函数的调用方式。理解 this 的绑定规则对于编写正确的JavaScript代码至关重要。

4.1 this 的绑定规则

this 的绑定规则主要有以下几种:

  1. 默认绑定: 在非严格模式下,独立函数调用时,this 默认指向全局对象(浏览器中是 window,Node.js中是 global)。在严格模式下,this 会是 undefined

  2. 隐式绑定: 当函数作为对象的方法被调用时,this 指向该对象。

  3. 显式绑定: 使用 call()apply()bind() 方法可以强制改变 this 的指向。

    • call()apply() 会立即执行函数,并接受第一个参数作为 this 的值。call() 接受独立的参数,apply() 接受一个参数数组。
    • bind() 不会立即执行函数,而是返回一个绑定了 this 值的新函数。
  4. new 绑定: 当使用 new 关键字调用构造函数时,this 会指向新创建的对象。

  5. 箭头函数绑定: 箭头函数没有自己的 this,它会捕获其外层(词法)作用域的 this 值。一旦确定,this 的指向就不会改变。

4.2 解决 this 丢失问题

在实际开发中,尤其是在事件回调、异步操作或高阶函数中,this 的指向很容易丢失。以下是几种常见的解决方案:

4.2.1 使用 that = this 缓存 this

这是ES6之前常用的方法,通过在外部作用域中将 this 赋值给一个变量(通常命名为 thatself),然后在内部函数中使用这个变量来访问正确的 this

5.html 中的事件监听器示例:

const obj ={
    message: "Hello from the object!",
    init : function(){
        const button = document.getElementById('myButton');
        const that =this; // 缓存this
        button.addEventListener('click',function(){
            console.log(that.message); // 使用缓存的that
            console.log(this); // 这里的this指向button元素
        })
    }
}
obj.init();

在这个例子中,init 方法中的 this 指向 obj 对象。但在 click 事件的回调函数中,this 默认指向触发事件的DOM元素(button)。通过 const that = this;,我们成功地在回调函数中访问到了 obj.message

4.2.2 使用 bind() 方法

bind() 方法会创建一个新函数,该函数在被调用时,其 this 值会被绑定到 bind() 的第一个参数。这是一种显式绑定 this 的方式。

const obj ={
    message: "Hello from the object!",
    init : function(){
        const button = document.getElementById('myButton');
        button.addEventListener('click', this.handleClick.bind(this)); // 绑定this
    },
    handleClick: function(){
        console.log(this.message);
    }
}
obj.init();

4.2.3 使用箭头函数

箭头函数是ES6引入的新特性,它没有自己的 this 绑定。箭头函数中的 this 会继承其外层(词法)作用域的 this。这意味着箭头函数内部的 this 始终与定义它时所处的 this 相同。

1.js 中的 CreateCounter 函数内部的 incrementdecrementgetCount 方法都使用了箭头函数,从而确保它们内部的 this(尽管这里没有直接使用 this,但如果使用了,也会指向 CreateCounter 内部的 this,即新创建的对象)是正确的。

// ... CreateCounter 函数内部
        increment: () => {
            count++;
        },
        decrement: () =>{
            count--;
        },
        getCount: () => {
            console.log('count 被访问了')
            return count;
        }
// ...

debouncethrottle 函数的实现中,我们也看到了 fn.apply(context, args); 的用法,这里的 context 就是为了确保原始函数 fn 在执行时能够正确地绑定 this

五、面向对象封装:构建可维护的代码

面向对象编程(OOP)是一种编程范式,它将程序设计为对象,这些对象包含数据(属性)和操作数据的代码(方法)。封装是OOP的三大特性之一(另外两个是继承和多态),它指的是将数据和操作数据的方法捆绑在一起,并对外部隐藏对象的内部实现细节。

5.1 封装的意义

封装的主要目的是提高代码的模块化、可维护性和安全性:

  • 隐藏实现细节: 用户只需要知道如何使用对象的公共接口,而不需要关心其内部是如何实现的。这降低了系统的复杂性。
  • 保护数据: 通过将数据设为私有,可以防止外部代码随意修改对象的状态,从而保证数据的完整性和一致性。
  • 提高可维护性: 当内部实现发生变化时,只要公共接口不变,外部代码就不需要修改。

5.2 JavaScript中的封装实现

JavaScript本身是一门基于原型的语言,但它也提供了多种方式来实现面向对象的封装。

5.2.1 构造函数与闭包

前面在闭包部分已经展示了如何使用构造函数和闭包来实现私有变量和方法。例如 CreateCounterBook 的例子,通过闭包,我们可以创建无法从外部直接访问的“私有”成员。

5.2.2 ES6 Class

ES6引入了 class 关键字,为JavaScript带来了更接近传统面向对象语言的语法糖。虽然 class 本质上仍然是基于原型的,但它提供了更清晰、更简洁的方式来定义类和实现封装。

class MyClass {
    #privateField; // 私有字段 (ES2022)

    constructor(value) {
        this.#privateField = value;
    }

    publicMethod() {
        console.log(this.#privateField);
    }
}

const instance = new MyClass('Hello');
instance.publicMethod(); // 输出: Hello
// console.log(instance.#privateField); // 报错:私有字段不能直接访问

ES2022引入了私有字段(以 # 开头),这使得在JavaScript中实现真正的私有属性成为可能。私有方法(同样以 # 开头)也遵循相同的规则。

六、总结

本文深入探讨了JavaScript中闭包、防抖、节流以及面向对象封装等核心概念。我们了解到:

  • 闭包 使得函数能够记住并访问其词法作用域,即使在外部执行,从而实现私有变量、记忆函数等高级功能。
  • 防抖 旨在减少事件触发频率,只在事件停止触发后执行一次,适用于搜索建议、窗口resize等场景。
  • 节流 旨在控制事件执行频率,在一定时间内只允许执行一次,适用于游戏技能CD、页面滚动等场景。
  • this 绑定 是JavaScript中一个复杂但重要的概念,理解其绑定规则并掌握 that=thisbind() 和箭头函数等解决方案至关重要。
  • 面向对象封装 通过隐藏实现细节和保护数据,提高了代码的模块化、可维护性和安全性。

掌握这些知识点,将帮助你编写出更优雅、更健壮、性能更优的JavaScript代码。希望本文能为你带来启发,让你在前端开发的道路上更进一步!