Javascript 中的作用域

基本概念

作用域(Scope)是编程中的一个基本概念,它指的是变量和函数在代码中的可访问范围。简单来说就是程序在哪个部分可以访问这个变量或函数。作用域可以帮助控制和管理变量的生命周期,避免命名冲突。

作用域的类型

在 Javascript 中有三种作用域:

  1. 全局作用域(Global Scope):全局作用域指的是在代码的最外层定义的作用域。全局作用域中的变量可以在整个代码中访问。
  2. 函数作用域(Function Scope):函数作用域是指在函数内部定义的变量只能在该函数内部访问。函数内部的变量对外部不可见。
  3. 块级作用域(Block Scope):块级作用域指的是在代码块(由 {} 包围的部分)内部定义的变量只能在该块内部访问。块级作用域是在 ES6 引入的,使用 let 和 const 声明的变量具有块级作用域。

静态作用域与动态作用域

静态作用域(Lexical Scope)

  • 静态作用域是在编写代码时确定的作用域。这意味着变量的作用范围由其在源代码中的位置决定,而不是在程序运行时的调用上下文决定。JavaScript、Python、C、C++ 等语言都使用静态作用域。
  • 查找机制:当代码访问一个变量时,JavaScript(以及许多其他语言)会从当前作用域开始向上查找,直到找到该变量或到达全局作用域。查找过程遵循作用域链。

动态作用域(Dynamic Scope)

  • 动态作用域意味着变量的绑定在程序的运行时确定。在动态作用域中,变量的作用范围取决于函数调用的顺序,而不是它们在源代码中的位置。某些语言(如一些早期的 Lisp 方言、Bash 脚本等)采用动态作用域。

什么是作用域链

首先需要知道:在 Javascript 中作用域可以嵌套在另一个作用域中。

var value = '1';
function printValue() {
  var value2 = '2';
  {
    let value3 = '3';
    console.log(value, value2, value3);
  }
}
printValue(); // 1 2 3

在这个例子中,我们有三层作用域:

  • 第一层是块级作用域,由 let 声明的匿名函数作用域;
  • 第二层为 printValue 函数作用域;
  • 第三层是全局作用域;

当在 Javascript 中使用一个变量的时候,首先 Javascript 引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。我们称这种机制为作用域链。

举个例子:

var value = 1;
function func1() {
    console.log(value);
}
function func2() {
    var value = 2;
    console.log(value);
    func1();
}
func2();

// 打印结果:2 1
  1. 首先,全局作用域被创建,全局作用域的变量 value 被初始化为 1。
  2. func2 函数被调用,创建一个新的作用域,变量 value 被初始化为 2。
  3. func2 函数的作用域中已经找到 value,因此输出 2。
  4. func1 函数被调用,查找变量 value,由于在 func1 作用域中找不到,因此会继续向上查找作用域链,找到全局作用域。
  5. 由于全局作用域的变量 value 被初始化为 1,因此输出 1。
  6. 函数调用结束,作用域被销毁。

闭包

闭包是指有权访问另一个函数作用域的函数,创建闭包的函数“封闭”了这个作用域,使得外部函数可以访问内部函数的变量。闭包在 JavaScript 中被广泛使用,尤其是函数式库 React,在其中 useState 就是使用闭包实现。

举个例子:

function a() {
  let a1 = 0;
  function b() {
    a1 += 1;
    console.log(a1);
  }
  return b;
}
const c = a();
c(); // 1
c(); // 2

在这个例子中,每次调用 c 函数时,由于 b 函数是一个闭包,他获取了 a 函数的内部变量 a1,并且保存在自己的作用域中。所以每次调用 c 函数时,b 函数都会打印自增后的 a1 值。

基于这个闭包的特性,我们可以实现一些特殊功能

状态保存

上文说过 useState 是闭包的一种实现,接下来我们实现一个简单的 state。

function useState(initialValue) {
  let state = initialValue; 
  return [
    () => state,
    (updater) => {
      if (typeof updater === 'function') {
        state = updater(state);
      } else {
        state = updater;
      }
    },
  ];
}

const [count, setCount] = useState(0);

count();  // 0
setCount(1);
getCount();  // 1
setCount(prev => prev + 1);
getCount();  // 2

缓存机制

因为闭包可以记住外部函数的变量,因此可以实现一些缓存机制。

例如缓存函数,避免重复执行相同的函数:

function useFunctionCache() {
  const cache = new Map(); 

  return function(fn) {
    return function(...args) {
      const key = `${fn.name}(${JSON.stringify(args)})`;

      if (cache.has(key)) {
        console.log('Returning cached result for:', key);
        return cache.get(key);
      }

      const result = fn(...args);
      cache.set(key, result);
      console.log('Caching result for:', key);
      return result;
    };
  };
}
const cachedFunction = useFunctionCache();
function add(a, b) {
  return a + b;
}
const cachedAdd = cachedFunction(add);
cachedAdd(1, 2);  // Caching result for: add(1,2)
cachedAdd(1, 2);  // Returning cached result for: add(1,2)

私有变量

经常写后端的小伙伴应该很熟悉私有变量的概念,在很多语言中都有私有变量。但是 JavaScript 本身不直接支持传统的私有变量
(尽管 ES2022 引入了 # 符号来表示私有变量,但终归只是语法糖),我们也能用闭包来模拟私有变量。

function createPerson(name, age) {
  let _name = name;
  let _age = age;

  return {
    getName: function() {
      return _name;
    },
    getAge: function() {
      return _age;
    },
    setName: function(name) {
      _name = name;
    },
    setAge: function(age) {
      _age = age;
    }
  };
}

const person = createPerson('John', 18);
console.log(person.getName()); // John
console.log(person.getAge());  // 18

// 无法直接访问私有变量
console.log(person._name); // undefined

内存泄漏

闭包本身并不会直接导致内存泄漏,但是如果使用不当,闭包可能会导致内存泄漏。在闭包的情况下,内存泄漏通常发生在以下几种情况:

  1. 闭包持有对不再使用的资源的引用:当闭包持有对外部函数变量的引用时,即使外部函数已经返回,闭包中的内层函数依然能够访问这些变量。这意味着,如果闭包被意外长时间持有,而外部函数的变量已经不再需要,这些不再需要的变量依然会占据内存,导致系统内存逐渐耗尽
  2. 全局变量和闭包的作用:当一个闭包被分配给全局变量时,内存中的引用将持续存在,直到整个应用程序结束。
  3. 事件监听器和闭包:在一些情况下,闭包被用来定义事件监听器,如果事件监听器绑定的函数是一个闭包,并且没有正确移除,闭包会继续持有对外部变量的引用,造成内存泄漏。

因此为了减少闭包导致的泄漏问题,在使用闭包时应该注意:

  • 及时销毁不再使用的闭包。
  • 减小闭包的作用范围。
  • 避免全局变量。
  • 在适当的时机清理事件监听。

变量提升

最后我们再来聊聊与作用域有关的另一个概念——变量提升(Hoisting)。

JavaScript 有一个特性,既在代码执行前,声明的变量和函数会被提升到其作用域的顶部。

console.log(a); // undefined
var a = 1;
console.log(a); // 1

在这个例子中,执行并不会报错,尽管在变量 a 被声明前我们已经尝试调用它,这是因为 JavaScript 引擎在编译代码时,提升了 a 变量的声明,但是并不会提升变量的赋值,所以 log 输出 undefined。同理,函数声明也会被提升到作用域的顶部。

foo(); // 输出 "hello"
function foo() {
  console.log("hello");
}

上文说过声明的变量和函数会被提升到其作用域的顶部,这变量也包括 let 和 const 吗?很多人认为不会,因为在实际使用过程中表现形式是这样的:

console.log(a); // ReferenceError: a is not defined
let a = 1;

代码抛出错误的原因并不是因为 let 没有提升,虽然提升并没有被定义在规范里,但概念上,let 和 const 也存在提升行为,不过存在一些区别。

  • let 和 const 只会提升到块作用域
  • let 和 const 提升时会进入 uninitialized 状态,也就是暂时性死区。

暂时性死区(Temporal Dead Zone,简称 TDZ),在这个区间内,变量不能被访问,这就是为什么在声明前不能访问 let 和 const 变量的原因。