深入理解js作用域-你真的懂js吗

参考内容

强烈推荐阅读原文,文章只是书籍内容的搬运和再加工

  • 《你不知道的 JavaScript》:作用域和闭包(作者觉得这也是这本书比较精彩的部分)
  • JavaScript 教程 - 网道 (阮一峰大佬出品,作者强烈推荐)

一段奇怪的代码

不卖关子,这其实是面试时很喜欢问的八股题,其实涵盖了大量作用域相关的知识,可以先看看自己能不能推出结果,再结合本文来看,会不会更简单。

js 复制代码
var a = 0;
console.log(a, window.a);
if (true) {
	console.log(a, window.a);
	a = 1;
	console.log(a, window.a);
	function a() {}
	console.log(a, window.a);
	a = 21;
	console.log(a, window.a);
	console.log("里面", a);
}
console.log("外部", a);

输出结果

javascript 复制代码
0 0
ƒ a() {} 0
1 0
1 1
21 1
里面 21
外部 1

我们来分析这个代码

js 复制代码
// 变量声明,也叫LHS 查询,同时使用了var声明,window.a = 0
var a = 0;
// a,window.a 变量引用,也叫RHS查询,查找 "a"会沿着作用域一直查找到全局作用域 因此这里为 0 0
console.log(a, window.a);
if (true) {
	// 注意,这里隐藏了一个函数声明 function a() {}
	// 因此 a 为 func {}, window.a 当然不变,还是 0
	console.log(a, window.a);
	// 这里的 a = 1 是一个伪造的 "不成功的 LHS 查询",因为有函数声明存在 a = 1实际上替换了 function a() {}
	// 不成功的 LHS 查询,当前作用域没有,就会泄露到上一级作用域
	// 这里还有一个需要注意的是,JS是静态作用域,也就是运行前确定了,像 a = 1这种代码,不会在编译时影响作用域的确定
	a = 1;
	// 因此这里 a = 1, window.a = 0
	console.log(a, window.a);
	function a() {}
	// 一样 a = 1 window.a = 0
	console.log(a, window.a);
	// 修改当前作用域的 a
	a = 21;
	// a = 21, window.a = 0
	console.log(a, window.a);
	// 里面 a = 21
	console.log("里面", a);
}
// 外面 a = 1
console.log("外部", a);

很理所应当的对吧,但是其实这里还埋了一个大坑,那就是 块级作用域 Web 兼容语义

js 复制代码
// 'use strict'
var a = 0;
console.log(a, window.a);
if (true) {
	console.log(a, window.a);
	a = 1;
	console.log(a, window.a);
	// 注意这里的函数声明,因为 ES6 块内的函数声明应该只在块内可见,导致了函数声明提升会到 if 顶端
	// 也就导致第一个 log 是 "f a{} 0"
	// 而第二个 a = 1 修改了这个函数声明,导致了第二个 log 是 "1 0"
	// 真正执行到函数体这里时,发生了兼容行为,函数a赋值给了外层的 a 变量
	// 但是if块语句中的a其实被修改了,所以导致外层的a也就是window.a也等于1
	// 所以第三个log意外的是 "1 1"
	// 最后的 a = 21还是在修改if块语句中的 a变量
	// 所以第四个log是"21 1"
	// 后面的都能猜到是什么了
	function a() {}
	console.log(a, window.a);
	a = 21;
	console.log(a, window.a);
	console.log("里面", a);
}
console.log("外部", a);

js 引擎 V8

是什么:一个庞大的函数,提供了执行 js 代码的能力

有什么用:读取网页中的 JavaScript 代码,对其处理后运行

JavaScript 是一种解释型语言,也就是说,它不需要编译,由解释器实时运行。这样的好处是运行和修改都比较方便,刷新页面就可以重新解释;缺点是每次运行都要调用解释器,系统开销较大,运行速度慢于编译型语言。

为了提高运行速度,目前的浏览器都将 JavaScript 进行一定程度的编译,生成类似字节码(bytecode)的中间代码,以提高运行速度

现代浏览器改为采用"即时编译" (Just In Time compiler,缩写 JIT),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(inline cache)。通常,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升

运行环境

现代 js 代码运行在浏览器和 node 环境中

  1. 浏览器
  2. node

v8执行js代码的过程

v8运行代码分为两步

  1. 预编译
  2. 运行代码

预编译过程大概概括为:

  1. 分词/词法分析
  2. 解析/语法分析 --- AST
  3. 代码生成

RHS 查询 和 LHS 查询

js 中对变量的操作可以概括为 LHS 查询和 RHS 查询两种操作

LHS 查询:如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询

RHS 查询:如果目的是获取变量的值,就会使用 RHS 查询

js 复制代码
function foo(a) {
	var b = a;
	return a + b;
}
var c = foo(2); // foo(2) 是为了获取返回值,赋值给c,所以是RHS
// 1. 找出所有的 LHS 查询(这里有 3 处!)
// c = ..;、a = 2(隐式变量分配)、b = ..
// 2. 找出所有的 RHS 查询(这里有 4 处!)
// foo(2..、= a;、a ..、.. b

实参赋值,就是一种隐式变量分配

function 函数名(形参1, 形参2,...) { //函数声明的小括号里的是形参 // 函数体代码 } 函数名(实参1, 实参2,...); //函数调用的小括号里的是实参

注意:

  1. 不成功的 RHS 引用会导致抛出 ReferenceError 异常。
  2. 不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。

变量声明

js 中,声明变量有:var、let、const,三种方法

var 和 let 以及 const 的区别

var let/const
存在变量提升 ,可以在变量声明前使用,读取值时为 undefined 不存在变量提升,不可以在变量声明前使用变量,否则会造成暂时性死区(TDZ),发生报错
在同一作用域中,允许变量重复声明 在同一作用域中,不允许变量重复声明
在全局作用域中,var 声明的变量会挂在到全局对象(globalThis)上,成为它的属性 let/const 声明的对象不会有这个问题
var 声明的变量没有块级作用域,只有全局作用域和函数作用域 let/const 声明的变量具有块级作用域,函数作用域和全局作用域

const 和 let 的区别

const let
const 声明的变量的值后续不可修改 let 声明的变量的值后续可以修改
在使用 const 声明变量时必须同时进行初始化 不用

声明提升

声明提升 :是指像 var 或者 function 关键字 定义的标识符,在声明之前就可以使用

js 复制代码
// 变量提升
function foo() {
	a = 3;
}
// 在声明之前使用RHS,结果是undefined
console.log(a); // undefined
foo();
// foo内执行了LHS,a被提升到全局作用域,修改了a,因此值为3
console.log(a); // 3
// 代码执行到这,才真正赋值a=2
var a = 2;
console.log(a); // 2

函数提升不同的是,函数的函数体也会提升,因此在函数声明之前就可以使用函数

js 复制代码
// 函数提升
foo(); // foo

function foo() {
  console.log('foo');
}

暂时性死区

当作用域中使用 let/const 声明了变量,就会形成暂时性死区 ,在变量声明之前不能使用变量,即便是外部已经声明过,也会报错 ReferenceError: Cannot access 'a' before initialization

js 复制代码
let a = 1
function foo() {
  console.log(a);
  let a = 2;
}

foo() // ReferenceError: Cannot access 'a' before initialization

重复声明

同一个作用域中,使用 var 可以重复声明,使用 let/const 不能重复声明,会报错 SyntaxError: Identifier 'a' has already been declared

js 复制代码
var a = 1
var a = 2 // 不报错,可以重复声明
console.log(a)
js 复制代码
// 预编译阶段报错
let b = 1
let b = 2 // SyntaxError: Identifier 'b' has already been declared
console.log(b)

不成功的 LHS 查询

  • 一般就是指 a=2 这种赋值,如果标识符找不到,就会隐式创建全局变量
js 复制代码
function foo() {
	a = 2; // LHS 查询失败,自动创建一个全局变量
}
foo();
console.log(a);

作用域

作用域的定义:

  • 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。
  • (或者是一个容器,包含了标识符的定义,和查找规则)

作用域有哪些

  1. 全局作用域
  2. 函数作用域
  3. 块级作用域

作用域查找规则

  1. 由内向外查找:现在当前作用域中查找,找的到就返回,找不到就去外层作用域中查找
  2. 不能由外向内查找:外层的作用域无法查找内层作用域的变量

遮蔽效应

作用域查找会在找到第一个匹配的标识符时停止

在多层的嵌套作用域中可以定义同名的标识符,这叫作"遮蔽效应"(内部的标识符"遮蔽"了外部的标识符)。

js 复制代码
var a = 1;

function foo() {
	var a = 2; // 遮蔽外层 a = 1
	function bar() {
		var a = 3; // 遮蔽外层 a = 2
		console.log("bar", a);
	}
	bar();
	console.log("foo", a);
}

foo()

作用域链

因为作用域"由内向外"的查找规则,查找某个变量时,会有如下特点:

  1. 现在当前作用域中查找,如果找到则结束查找,如果没找到则向外查找
  2. 在外层作用域中查找到,则结束查找,如果没有则一直找到全局作用域为止

作用域的两种工作模型

作用域共有两种主要的工作模型

  1. 第一种是最为普遍的,被大多数编程语言所采用的词法作用域(也叫静态作用域)。
  2. 另外一种叫作动态作用域,仍有一些编程语言在使用(比如 Bash 脚本、Perl 中的一些模式等)。

词法作用域

大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)

词法化的过程会对源代码中的字符进行检查 ,如果是有状态的解析过程,还会赋予单词语义

词法作用域就是定义在词法阶段的作用域 。词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的 ,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

欺骗词法

欺骗词法是什么

作用域是在词法阶段形成的,因此在运行代码时应该不会改变,而欺骗词法 是一种手段,可以在运行代码时修改作用域

  1. with --- 当 with 修改对象中不存在的属性时,会导致该属泄露到了全局(本质上是不成功的 LHS 查询,导致泄露)
js 复制代码
function foo(obj) {
	with (obj) {
		a = 2;
	}
}

var o1 = { a: 3 };
var o2 = { b: 3 };

foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2------不好,a 被泄漏到全局作用域上了!
  1. eval --- 将本不属于当前作用域的代码,强行添加到当前作用域中执行
js 复制代码
function foo(str, a) {
	eval(str); // 欺骗! 
	console.log( a, b );
}

var b = 2;

foo("var b = 3;", 1); // 1, 3
  1. eval(..) 函数如果接受了含有一个或多个声明的代码 ,就会修改其所处的词法作用域
  2. with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域

性能问题

  • 这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢

函数作用域和块级作用域

函数作用域的含义是:属于这个函数的全部变量都可以在整个函数的范围内使用及复用

js 复制代码
function foo() {
  var a = 1
  console.log(a) // 1
}

foo()
console.log(a) // ReferenceError: a is not defined

块级作用域的定义 是:{} 声明的标识符,在 {} 范围内使用

  • with 关键字创建的作用域,只能在 with{} 内使用
  • try catchcatch err 只能在 catch{} 内使用
  • let/const 关键字可以将变量绑定到所在的任意作用域(也包含块级作用域)中,因此几乎是任意 {} 内都可以使用 let/const 声明变量而不泄漏到外部作用域
js 复制代码
function foo() {
	var a = 1;
	if (true) {
		let b = 2; // let/const 在
		var c = 3;
		console.log(a, b, c); // 1, 2, 3
	}
  console.log(a, b, c);
}

foo()

块级作用域 Web 兼容语义

ECMAScript® 2026 Language Specification

其实这个规范很难看懂,我向 AI 问了问,他的回答是这样

ES5时期的行为

在 ES5中,没有块级作用域,所有函数声明都会被提升到包含它们的函数作用域顶部:

javascript 复制代码
function test() {
    console.log(typeof foo); // "function" - 函数声明被提升
    
    if (false) { // 即使条件为false
        function foo() {
            return "hello";
        }
    }
    
    console.log(typeof foo); // "function" - 在ES5中仍然可以访问
}

ES6块级作用域的冲突

ES6引入块级作用域后,按严格规范,块内的函数声明应该只在块内可见

javascript 复制代码
// ES6严格模式下的预期行为
function test() {
    if (true) {
        function foo() {
            return "hello";
        }
        // foo只在这个块内可见
    }
    // 这里应该访问不到foo
}

Web 遗留兼容性方案

为了不破坏现有代码,引入了双重绑定机制:

javascript 复制代码
// 实际的兼容性行为
function test() {
    var foo; // 隐式创建var绑定(值为undefined)
    
    if (true) {
        function foo() { // 块内函数声明
            return "hello";
        }
        // 值得注意的是:如果在块级作用域中修改了 foo,执行到声明函数的语句,也会把被修改的函数声明赋值给外层
        // 执行到这里时,将块内函数赋值给外层var
    }
    
    console.log(foo); // 可以访问到函数
}

值得注意的是:如果在块级作用域中修改了 foo,执行到声明函数的语句,也会把被修改的函数声明赋值给外层

严格模式的区别

严格模式下,行为完全不同:

javascript 复制代码
"use strict";

function test() {
    if (true) {
        function foo() {
            return "hello";
        }
        console.log(foo); // 正常工作
    }
    
    console.log(foo); // ReferenceError: foo is not defined
}

对比总结

模式 块外访问性 提升行为
ES5 ✅ 可访问 完全提升到函数顶部
ES6非严格模式 ✅ 可访问 双重绑定兼容机制
ES6严格模式 ❌ 不可访问 仅在块内有效

实际测试

javascript 复制代码
// 非严格模式
function nonStrict() {
    console.log(typeof foo); // "undefined"
    if (true) {
        function foo() { return "hello"; }
    }
    console.log(typeof foo); // "function" - 兼容性行为
}

// 严格模式
function strictMode() {
    "use strict";
    if (true) {
        function foo() { return "hello"; }
    }
    // console.log(foo); // ReferenceError
}
相关推荐
小妖666几秒前
react-router 怎么设置 basepath 设置网站基础路径
前端·react.js·前端框架
xvmingjiang6 分钟前
Element Plus 中 el-input 限制为数值输入的方法
前端·javascript·vue.js
XboxYan23 分钟前
借助CSS实现自适应屏幕边缘的tooltip
前端·css
极客小俊24 分钟前
iconfont 阿里巴巴免费矢量图标库超级好用!
前端
小杨 想拼31 分钟前
使用js完成抽奖项目 效果和内容自定义,可以模仿游戏抽奖页面
前端·游戏
yvvvy34 分钟前
🐙 Git 从入门到面试能吹的那些事
前端·trae
狂炫一碗大米饭34 分钟前
事件委托的深层逻辑:当冒泡不够时⁉️
javascript·面试
张柏慈1 小时前
JavaScript性能优化30招
开发语言·javascript·性能优化
EmmaGuo20151 小时前
flutter3.7.12版本设置TextField的contextMenuBuilder的文字颜色
前端·flutter
pepedd8642 小时前
全面解析this-理解this指向的原理
前端·javascript·trae