Javascript 中的作用域
基本概念
作用域(Scope)是编程中的一个基本概念,它指的是变量和函数在代码中的可访问范围。简单来说就是程序在哪个部分可以访问这个变量或函数。作用域可以帮助控制和管理变量的生命周期,避免命名冲突。
作用域的类型
在 Javascript 中有三种作用域:
- 全局作用域(Global Scope):全局作用域指的是在代码的最外层定义的作用域。全局作用域中的变量可以在整个代码中访问。
- 函数作用域(Function Scope):函数作用域是指在函数内部定义的变量只能在该函数内部访问。函数内部的变量对外部不可见。
- 块级作用域(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
- 首先,全局作用域被创建,全局作用域的变量
value
被初始化为 1。 func2
函数被调用,创建一个新的作用域,变量value
被初始化为 2。- 在
func2
函数的作用域中已经找到value
,因此输出 2。 func1
函数被调用,查找变量value
,由于在func1
作用域中找不到,因此会继续向上查找作用域链,找到全局作用域。- 由于全局作用域的变量
value
被初始化为 1,因此输出 1。 - 函数调用结束,作用域被销毁。
闭包
闭包是指有权访问另一个函数作用域的函数,创建闭包的函数“封闭”了这个作用域,使得外部函数可以访问内部函数的变量。闭包在 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
内存泄漏
闭包本身并不会直接导致内存泄漏,但是如果使用不当,闭包可能会导致内存泄漏。在闭包的情况下,内存泄漏通常发生在以下几种情况:
- 闭包持有对不再使用的资源的引用:当闭包持有对外部函数变量的引用时,即使外部函数已经返回,闭包中的内层函数依然能够访问这些变量。这意味着,如果闭包被意外长时间持有,而外部函数的变量已经不再需要,这些不再需要的变量依然会占据内存,导致系统内存逐渐耗尽
- 全局变量和闭包的作用:当一个闭包被分配给全局变量时,内存中的引用将持续存在,直到整个应用程序结束。
- 事件监听器和闭包:在一些情况下,闭包被用来定义事件监听器,如果事件监听器绑定的函数是一个闭包,并且没有正确移除,闭包会继续持有对外部变量的引用,造成内存泄漏。
因此为了减少闭包导致的泄漏问题,在使用闭包时应该注意:
- 及时销毁不再使用的闭包。
- 减小闭包的作用范围。
- 避免全局变量。
- 在适当的时机清理事件监听。
变量提升
最后我们再来聊聊与作用域有关的另一个概念——变量提升(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 变量的原因。