作用域、闭包与this指向问题

223 阅读15分钟

一、作用域

作用域概念

在js中,作用域就是变量和函数可访问范围,作用域控制着变量和函数的可见性生命周期

常见作用域

1. Global 作用域(常见)
<script>
var a = '曾小白'
</script>

通过var声明一个变量a,打断点的时候可以看到,scope里面有global类型的作用域,也就是全局作用域,里面保存了var声明的变量,在浏览器的console.log中,可以输入变量名a访问,也可以输window.a进行访问。

2. Local 作用域(常见)

声明一个函数,在函数内声明一个变量b,调用这个函数的时候,可以看到scope里面有local类型的作用域,也就是本地作用域

// 函数作用域
function functionScopeExample() {
    // 函数内部变量,只能在函数内部访问
    const b = 'I am a function variable';
    console.log('1: ', functionVariable);

    // 嵌套函数,展示作用域链
    function nestedFunction() {
        let nestedVariable = 'I am a nested function variable';
        console.log('2: ', nestedVariable);
        console.log('3: ', functionVariable);
    }

    nestedFunction();
}

// 下面是打印结果
1:  I am a function variable
2:  I am a nested function variable
3:  I am a function variable
3.block作用域(常见)

es6 加入了块语句,它也同样会生成作用域,在debugger的时候放到Block作用域里面,if、while、for 等语句都会生成 Block 作用域:

// 块级作用域(使用 let 和 const 声明)
{
    // 块级变量,只能在块内访问
    let blockVariable = 'I am a block variable';
    const constantVariable = 'I am a constant in block';
    console.log('Inside block: ', blockVariable);
    console.log('Inside block: ', constantVariable);
}
4. Script作用域
<script>
var a = '曾小白'
const b = 'I am a script variable'
let c = 'I am a script variable too'
</script>

以上代码访问

  • a: '曾小白'
  • b: 'I am a script variable'
  • c: 'I am a script variable'
  • window.a: '曾小白'
  • window.b: undefined
  • window.c: undefined

且在debugger的时候,a被放在Global作用域,b和c被放在Script作用域。 这就是浏览器环境下用 let const 声明全局变量时的特殊作用域,script 作用域。可以直接访问这个全局变量,但是却不能通过 window.xx 访问。

5. Catch Block 作用域
try{
  throw new Error('something went wrong')
}catch{
debugger
}.finally{
  const a = 'this is finally'
}

debugger的时候发现,catch语句会生成一个Catch Block 作用域,里面能访问具体的错误对象。finally里面的a会在Block 作用域里面。

6. Closure作用域

其实就是我们常说的闭包的作用域,一个函数返回另一个函数的,返回的函数引用了外层函数的变量,就会以闭包的形式保存下来。

// 闭包作用域
function outerFunction() {
    let outerVariable = 'I am from outer function';
    return function innerFunction() {
        // 内部函数可以访问外部函数的变量,形成闭包
        return outerVariable;
    };
}

上面的例子就是内部函数访问了外部函数的变量outerVariable,通过debugger可以发现变量outerVariable被保存在scops的Closure作用域里面,其实就是初始化js代码的时候,给闭包用到的变量储存到Closure作用域里,执行的时候就能从Closure作用域找到需要的变量。 注意:当返回的函数有 eval 的时候,会把所有的变量都放到里面。JS 引擎就会形成特别大的 Closure,会导致性能问题,所以在闭包里面尽量不要去使用eval

作用域链

概念:在 JavaScript 里,作用域链是一种机制,用于在代码执行过程中查找变量。当访问一个变量时,JavaScript 引擎会先在当前作用域里查找该变量,如果没找到,就会到包含当前作用域的外层作用域继续查找,如此层层递进,直到全局作用域。这个由当前作用域及其所有外层作用域组成的链条,就被称作作用域链。

作用域链的形成和函数的词法作用域紧密相关,函数在定义时就确定了其外层作用域,而不是在调用时。这意味着函数无论在哪里被调用,它都能访问其定义时所处作用域的变量。

// 全局作用域
let globalVariable = 'This is a global variable';

// 定义一个函数,该函数内部包含另一个函数
function outerFunction() {
    // 外层函数作用域
    let outerVariable = 'This is an outer variable';

    function innerFunction() {
        // 内层函数作用域
        let innerVariable = 'This is an inner variable';

        // 尝试访问不同作用域的变量
        console.log(innerVariable); // 访问当前作用域的变量
        console.log(outerVariable); // 访问外层函数作用域的变量
        console.log(globalVariable); // 访问全局作用域的变量
    }

    return innerFunction;
}

// 调用 outerFunction 并获取 innerFunction
const closureFunction = outerFunction();

// 调用 innerFunction
closureFunction();

在 innerFunction 中,当访问 innerVariable 时,JavaScript 引擎会在当前作用域(innerFunction 作用域)中找到该变量;当访问 outerVariable 时,当前作用域没有该变量,引擎会到外层作用域(outerFunction 作用域)中查找;当访问 globalVariable 时,前面的作用域都没有该变量,引擎会到全局作用域中查找。

通过这种方式,JavaScript 引擎利用作用域链完成了变量的查找。

二、闭包

 定义与形成条件

  • 定义:闭包是函数与其声明时的词法环境的组合,允许内部函数访问并保留外部作用域的变量 

  • 形成条件

    • 函数嵌套且内部函数访问外部变量。

    • 内部函数在外部函数外被调用(如通过返回值或事件回调) 

### 闭包的生命周期

  • 创建阶段:外部函数执行时生成变量对象。

  • 维持阶段:内部函数持有对外部变量的引用,阻止垃圾回收。

  • 释放阶段:当闭包不再被引用时,变量被回收 

闭包应用场景

  • 模块化开发:通过闭包封装私有变量(如计数器、缓存机制) 
// 定义一个函数,返回一个包含计数器操作方法的对象
function createCounter() {
    // 私有变量,用于存储计数
    let count = 0;

    // 返回一个对象,包含增加计数、减少计数和获取当前计数的方法
    return {
        increment: function() {
            count++;
            return count;
        },
        decrement: function() {
            count--;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
}

// 使用示例
const counter = createCounter();
console.log(counter.getCount()); // 输出: 0
counter.increment();
console.log(counter.getCount()); // 输出: 1
counter.decrement();
console.log(counter.getCount()); // 输出: 0

计数器可以记录调用次数,通过闭包将计数变量封装为私有变量,外部只能通过提供的方法来访问和修改计数。

// 定义一个函数,接受一个计算函数作为参数,返回一个带缓存功能的函数
function createCache(fn) {
    // 私有变量,用于存储缓存
    const cache = {};

    return function(...args) {
        // 将参数转换为字符串作为缓存的键
        const key = JSON.stringify(args);

        // 检查缓存中是否已经存在该键
        if (cache[key] === undefined) {
            // 如果不存在,调用原始计算函数进行计算,并将结果存入缓存
            cache[key] = fn(...args);
        }

        // 返回缓存中的结果
        return cache[key];
    };
}

// 示例计算函数:计算斐波那契数列
function fibonacci(n) {
    if (n <= 1) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// 创建带缓存功能的斐波那契计算函数
const cachedFibonacci = createCache(fibonacci);

// 使用示例
console.log(cachedFibonacci(10)); // 首次计算,会将结果存入缓存
console.log(cachedFibonacci(10)); // 直接从缓存中获取结果,避免重复计算

缓存机制可以避免重复计算,将计算结果存储在缓存中,下次需要相同计算结果时直接从缓存中获取。

  • 高阶函数:工厂函数生成定制化逻辑(如校验器、防抖/节流函数)
// 校验器工厂函数
function createValidator(rule) {
    // 闭包保存规则
    return function(value) {
        switch (rule) {
            case 'isEmail':
                const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                return emailRegex.test(value);
            case 'isPhone':
                const phoneRegex = /^1[3-9]\d{9}$/;
                return phoneRegex.test(value);
            case 'isNumber':
                return !isNaN(value) && isFinite(value);
            default:
                return false;
        }
    };
}

// 使用示例
const emailValidator = createValidator('isEmail');
console.log(emailValidator('test@example.com')); // 输出: true
console.log(emailValidator('invalid-email'));    // 输出: false

const phoneValidator = createValidator('isPhone');
console.log(phoneValidator('13800138000'));      // 输出: true
console.log(phoneValidator('1234567890'));       // 输出: false

校验器可以根据不同的规则对输入数据进行验证。通过工厂函数,我们可以根据不同的规则生成不同的校验器。

// 防抖函数工厂
function debounce(func, wait) {
    // 闭包保存定时器 ID
    let timeout;
    return function(...args) {
        const context = this;
        // 清除之前的定时器
        clearTimeout(timeout);
        // 设置新的定时器
        timeout = setTimeout(() => {
            func.apply(context, args);
        }, wait);
    };
}

// 使用示例
function search(input) {
    console.log(`Searching for: ${input}`);
}

const debouncedSearch = debounce(search, 300);

// 模拟多次输入
debouncedSearch('a');
debouncedSearch('ab');
debouncedSearch('abc');
// 只会在最后一次调用 300ms 后执行 search 函数

防抖函数用于限制函数的调用频率,在一定时间内如果多次触发函数,只会在最后一次触发后的一段时间后执行。

// 节流函数工厂
function throttle(func, limit) {
    // 闭包保存上次执行时间
    let inThrottle;
    return function(...args) {
        const context = this;
        if (!inThrottle) {
            func.apply(context, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
        }
    };
}

// 使用示例
function handleScroll() {
    console.log('Scroll event handled');
}

const throttledScroll = throttle(handleScroll, 500);

// 模拟频繁滚动事件
window.addEventListener('scroll', throttledScroll);

使用闭包时候的注意事项

闭包在 JavaScript 里十分实用,但使用时若不注意,可能会引发内存泄漏、变量共享等问题。

1. 内存泄漏问题

闭包会让函数保存对其外部作用域变量的引用,这就使得这些变量在外部函数执行完毕后也不会被垃圾回收机制回收。若闭包持续存在且引用了大量数据,就可能造成内存泄漏。

解决办法: 当闭包不再使用时,及时解除对闭包的引用,让垃圾回收机制可以回收相关变量。

function createLargeDataClosure() {
    const largeData = new Array(1000000).fill('data');
    return function() {
        console.log(largeData.length);
    };
}

const closure = createLargeDataClosure();
// 此时 largeData 不会被垃圾回收,因为闭包引用了它

let closure = createLargeDataClosure();
closure();
// 不再使用闭包,解除引用
closure = null;
2. 变量共享问题

多个闭包可能会共享同一个外部变量,若其中一个闭包修改了该变量,会影响到其他闭包。

function createClosures() {
    const result = [];
    for (var i = 0; i < 3; i++) {
        result.push(function() {
            console.log(i);
        });
    }
    return result;
}

const closures = createClosures();
closures.forEach(closure => closure()); 
// 输出 3, 3, 3,而不是预期的 0, 1, 2

解决办法:使用 let 替代 var 声明变量,因为 let 有块级作用域。

function createClosures() {
    const result = [];
    for (let i = 0; i < 3; i++) {
        result.push(function() {
            console.log(i);
        });
    }
    return result;
}

const closures = createClosures();
closures.forEach(closure => closure()); 
// 输出 0, 1, 2

或者借助立即执行函数表达式(IIFE)创建独立作用域。

function createClosures() {
    const result = [];
    for (var i = 0; i < 3; i++) {
        result.push((function(index) {
            return function() {
                console.log(index);
            };
        })(i));
    }
    return result;
}

const closures = createClosures();
closures.forEach(closure => closure()); 
// 输出 0, 1, 2
3. 性能问题

闭包的创建和使用会带来一定的性能开销,特别是在频繁创建闭包或者闭包逻辑复杂的情况下。

解决办法

  • 避免在循环或者高频事件处理函数中频繁创建闭包。
  • 对闭包内的复杂逻辑进行优化,减少不必要的计算。
4. 代码可读性和维护性

过多嵌套的闭包会让代码变得复杂,降低代码的可读性和维护性。

解决办法

  • 合理设计代码结构,避免过深的闭包嵌套。
  • 给闭包函数添加清晰的注释,解释其功能和作用。

三、this指向

this 指向调用函数的上下文对象,其值在函数调用时动态确定,而非定义时。

默认绑定

  • 直接调用函数时,非严格模式下 this 指向全局对象(浏览器中为 window),严格模式下为 undefined
function fn() { 
  console.log(this);
} 
fn(); // window(非严格模式)

隐式绑定

  • 函数作为对象方法调用时,this 指向调用该方法的对象。
const obj = { 
  name: "obj", 
  logName() { 
    console.log(this.name); 
  } 
}; 
obj.logName(); // "obj"

显式绑定

  • 通过 callapplybind 强制指定 this
function greet(){
  conssole.log(this.name)
}
const person = {
  name: '曾小白'
}
greet.call(person) // '曾小白'
1、手写call
原生用法

call方法的主要作用主要是this指向问题和参数处理。其作用是改变函数执行时的this指向,并且允许传递参数列表。比如,func.call(obj, arg1, arg2)会让func的this指向obj,然后传入arg1和arg2作为参数。

const person = { name: "Bob" }; 
function showInfo(age, job) { 
  console.log(this.name, age, job); 
} 
showInfo.call(person, 30, "Engineer"); // 输出:Bob 30 Engineer
注意点
  • call方法的主要作用主要是this指向问题和参数处理。其作用是改变函数执行时的this指向,并且允许传递参数列表。比如,func.call(obj, arg1, arg2)会让func的this指向obj,然后传入arg1和arg2作为参数。
  • 手写call的基本思路是将函数作为上下文对象的一个属性,然后调用这个属性,从而达到改变this的效果。具体步骤包括:将函数添加到context对象上,执行该函数,然后删除这个属性,避免污染context对象。
  • 原生call可以接收多个参数,第一个是context,后面是参数列表。可以使用arguments对象或者ES6的展开运算符来处理。比如,通过遍历arguments来收集参数,然后用eval或者展开运算符传递给函数。
  • 需要处理context为null或undefined的情况。这时候应该默认指向全局对象,比如window。,如果context是基本数据类型,比如数字或字符串,可能需要将它们包装成对象,但大部分实现可能暂时忽略这一点,只处理对象和null的情况。
  • 还有,避免属性冲突的问题。比如,如果context对象原本就有名为fn的属性,直接使用可能会导致问题。可以使用Symbol来生成唯一的属性名,这样可以避免覆盖原有属性。
  • 最后返回值的问题。原生的call方法会返回函数的执行结果,所以手写实现时也需要将执行结果保存并返回。
实现一个myCall函数
// 利用展开运算符简化参数处理
Function.prototype.myCall = function(context, ...args) { 
  context = context || window; 
  const fn = Symbol(); 
  context[fn] = this; 
  const result = context[fn](...args); // 展开参数 
  delete context[fn]; 
  return result; 
};
// 使用 `arguments` 对象收集参数(从第二个参数开始):
Function.prototype.myCall = function(context) { 
  context = context || window; 
  const fn = Symbol('fn'); 
  context[fn] = this; // 收集参数(ES5 兼容写法)
  const args = []; 
  for (let i = 1; i < arguments.length; i++) {
    args.push('arguments[' + i + ']'); 
  } // 通过 eval 拼接参数 
  const result = eval('context[fn](' + args + ')'); 
  delete context[fn]; 
  return result; 
};

关键问题与解决方案
  • context 为基本类型时如何处理?

    若 context 是数字、字符串等,需将其包装为对象(如 new Number()),但实际可简化处理为直接赋值 

  • 如何避免属性名冲突?

    使用 Symbol 生成唯一属性名,防止覆盖 context 原有属性 

  • 严格模式下的 this 问题

    若 context 为 null 且处于严格模式,应指向 undefined,但基础实现可统一处理为 window 

2、手写apply

apply 方法与 call 类似,但参数传递方式不同:apply 接收一个数组或类数组对象作为参数。其原生用法示例如下

// ##### ES6 优化版
function greet(age, job) { 
  console.log(this.name, age, job);
} 
const obj = { name: "Alice" }; 
greet.apply(obj, [25, "Engineer"]); // 输出:Alice 25 Engineer
Function.prototype.myApply = function(context, args) { 
  // 处理 context 默认值(非严格模式指向全局对象) 
  context = context || window; 
  // 生成唯一属性名,避免覆盖原有属性 
  const fn = Symbol(); 
  // 将当前函数绑定到 context 上(隐式绑定 this) 
  context[fn] = this; 
  // 执行函数,展开参数数组 
  const result = context[fn](...args || []); // 处理 args 为空的情况 
  // 删除临时属性 
  delete context[fn]; 
  return result; 
};
// ##### ES5 兼容版(处理类数组参数)
Function.prototype.myApply = function(context, args) {
  context = context || window; 
  const fn = Symbol(); 
  context[fn] = this; 
  // 处理参数:将数组拼接为字符串,通过 eval 执行 
  const params = []; 
  for (let i = 0; i < args.length; i++) { 
    params.push('args[' + i + ']'); 
  } 
  const result = eval('context[fn](' + params.join(',') + ')'); 
  delete context[fn]; 
  return result; 
};
3、手写bind
原生用法

bind 方法的主要功能是 创建一个新函数,永久绑定原函数的 this 指向,并支持参数合并(柯里化)。其原生用法示例如下

function greet(age, job) { 
  console.log(this.name, age, job); 
} 
const obj = { name: "Alice" }; 
const boundFn = greet.bind(obj, 25); 
boundFn("Engineer"); // 输出:Alice 25 Engineer
注意点
  1. 绑定 this 到指定对象
  2. 合并绑定时和调用时的参数
  3. 支持 new 操作符调用,此时忽略绑定的 this
  4. 保持原型链关系
实现一个myBind函数
Function.prototype.myBind = function(context, ...args1) { 
  // 校验调用者是否为函数 
  if (typeof this !== "function") { 
    throw new TypeError("必须由函数调用"); 
  } 
  const originFn = this; // 保存原函数 
  const boundArgs = args1; // 保存绑定时传入的参数 
  
  // 返回绑定函数 
  const boundFn = function(...args2) { 
    // 判断是否通过 new 调用 
    const isNewCall = this instanceof boundFn; 
    // 动态绑定 this:new 调用时忽略 context,否则使用 context         const target = isNewCall ? this : context || window; 
    // 合并参数(绑定时参数 + 调用时参数) 
    return originFn.apply(target, [...boundArgs, ...args2]);     }; 
  // 保持原型链关系(关键步骤) 
  boundFn.prototype =  originFn.prototype; return boundFn;
};
关键问题与解决方案
  • new 操作符处理 通过 this instanceof boundFn 判断是否通过 new 调用。若为 true,则 this 指向新创建的实例,此时需忽略绑定的 context 

  • 原型链继承 将 boundFn.prototype 指向原函数的 prototype,确保通过 new 调用时实例能正确继承原函数原型方法 

  • 参数合并 使用展开运算符 ... 合并绑定时参数和调用时参数,实现柯里化 

构造函数中的this

使用new调用构造函数时,this指向新创建的实例

function Person(name){
  this.name = name
}
const alice = new Person('alice')

箭头函数的this

  • 箭头函数无自身 this,其 this 由外层作用域决定(词法作用域)。
const obj = { 
  name: "obj", 
  logName: () => console.log(this.name) // 指向全局(此处为 window)
}; 
obj.logName(); // undefined(若全局无 name)

四、三者关系与区别总结

维度作用域闭包this 指向
本质变量访问的静态规则函数与词法环境的动态结合动态绑定的执行上下文
确定时机代码编写时(静态)函数创建时(依赖作用域链)函数调用时(动态)
核心功能隔离变量,控制可见性跨作用域保留变量状态指向调用对象或执行环境
内存影响无直接关联可能引发内存泄漏无直接影响
典型应用控制变量生命周期封装私有变量、高阶函数对象方法、事件回调