写 JS 时,你是否遇到过这些困惑:同样是 “变量未定义”,为什么console.log(a)报ReferenceError,而a=2在非严格模式下却不报错?为什么var a声明的变量,在赋值前打印是undefined而不是报错?这些问题的答案,都藏在《你不知道的 JavaScript》(上卷)第一章的核心概念里 ——作用域与LHS/RHS 查询。
今天我们就扒开 JS 引擎的 “工作内幕”,用通俗的类比和案例,把这两个容易被忽略的底层逻辑讲透,让你从此不再被变量报错、变量提升等问题困扰。
一、作用域:JS 的 “变量查找规则手册”
先抛一个灵魂问题:当执行var a = 2时,JS 到底做了什么?你可能会说 “声明变量 a 并赋值 2”,但这个答案只说对了一半 —— 实际上,JS 引擎会分 “编译” 和 “执行” 两步完成这个操作,而作用域就是连接这两步的关键。
1. 用 “图书馆” 类比理解三大角色
要搞懂作用域,先记住三个核心角色的分工,我们用 “图书馆找书” 来类比:
- 引擎:相当于 “读者”,负责整个代码的执行(比如 “读这本书”“在书上写笔记”);
- 编译器:相当于 “图书管理员的助手”,在代码执行前做准备工作(比如 “给新书分类、贴标签、录入系统”);
- 作用域:相当于 “图书馆的藏书规则 + 书架”,规定了 “哪些书放在哪里”“读者能从哪些书架找书”,本质是变量的查找规则与存储位置的集合。
当执行var a = 2时,三个角色的配合流程是这样的:
- 编译阶段(执行前) :
编译器先进行 “分词 / 词法分析”,把代码拆成var、a、=、2这些 “单词”;
再通过 “解析 / 语法分析”,生成 “声明变量 a 并赋值 2” 的语法树;
最后 “代码生成” 时,编译器会问作用域:“书架上有叫‘a’的书吗?” 如果没有,就让作用域在当前书架(当前作用域)新增一本叫 “a” 的空书(声明变量);如果已有,就忽略声明(var 的重复声明会被忽略)。
- 执行阶段:
引擎会问作用域:“能借到‘a’这本书吗?”(查询变量 a),作用域找到后交给引擎;
引擎再把 “2” 这个 “笔记” 写进 “a” 这本书里(赋值操作)。
简单说:作用域决定了变量的 “藏身之处”,以及引擎能否找到它。没有作用域,引擎就像没头苍蝇,根本不知道去哪里找变量。
2. 作用域的核心价值:隔离变量,避免冲突
为什么需要作用域?举个例子:如果所有变量都放在 “全局书架” 上,团队协作时,你写的var name = "张三"很可能会覆盖同事写的var name = "李四",导致代码混乱。
而作用域通过 “分层书架” 解决了这个问题 —— 比如函数作用域、块级作用域(ES6+),让不同作用域的变量可以重名但互不干扰。比如:
function foo() {
var name = "函数内的name"; // 函数作用域的变量
console.log(name); // 输出“函数内的name”
}
foo();
var name = "全局的name"; // 全局作用域的变量
console.log(name); // 输出“全局的name”
这里两个name分别存放在 “函数书架” 和 “全局书架”,引擎查找时会优先找当前作用域的变量,避免了冲突。
二、LHS 与 RHS:引擎找变量的两种 “姿势”
理解了作用域是 “书架”,接下来要搞懂引擎找变量的具体 “姿势”——LHS 查询和RHS 查询。这两个概念是第一章的重中之重,也是解释 “为什么有些变量未声明却不报错” 的关键。
1. 先分清:赋值目标 vs 取值源头
简单来说,LHS 和 RHS 的区别取决于 “变量是被赋值,还是被取值”:
- LHS 查询(Left-hand Side) :变量是 “赋值操作的目标”,引擎要找的是 “存放值的容器”(比如 “把 2 放进 a 这个盒子里”,需要先找到 a 这个盒子);
- RHS 查询(Right-hand Side) :变量是 “取值操作的源头”,引擎要找的是 “变量的值”(比如 “从 a 这个盒子里拿值,打印出来”)。
举个例子,一句话里可能同时包含两种查询:
var a = b;
- 对a的查询是LHS:因为 a 是赋值的目标,引擎需要找到 a 的 “存放位置”,才能把 b 的值存进去;
- 对b的查询是RHS:因为 b 是赋值的源头,引擎需要找到 b 的值,才能赋给 a。
再看几个案例,强化理解:
| 代码片段 | 变量查询类型 | 原因分析 |
|---|---|---|
| console.log(x) | RHS | 需要取 x 的值,传给 console.log |
| x = 10 | LHS | 需要找 x 的位置,把 10 存进去 |
| function foo(x) {} | LHS | 调用 foo (5) 时,x 是参数,需把 5 存进 x |
| var y = x + 3 | LHS(y)、RHS(x) | y 是目标,x 是源头 |
2. 关键区别:查询失败时的报错不同
LHS 和 RHS 最核心的差异,体现在 “找不到变量” 时的报错行为上 —— 这直接解释了开头的困惑:为什么a=2在非严格模式下不报错?
(1)RHS 查询失败:报 ReferenceError
当引擎进行 RHS 查询(取值),但作用域链上(当前作用域→外层作用域→全局作用域)都找不到该变量时,会抛出ReferenceError(未定义错误)。
比如:
console.log(a); // Uncaught ReferenceError: a is not defined
这里引擎要取 a 的值(RHS 查询),但所有作用域都没有 a,所以报 ReferenceError。
(2)LHS 查询失败:非严格模式隐式全局,严格模式报 ReferenceError
当引擎进行 LHS 查询(赋值目标),但作用域链上找不到该变量时,行为会分两种模式:
- 非严格模式:作用域会 “好心” 在全局作用域创建一个同名变量,让引擎赋值(隐式全局变量);
- 严格模式(use strict) :直接抛出ReferenceError,不允许隐式创建全局变量。
看案例对比:
// 非严格模式
a = 2;
console.log(a); // 2(全局作用域被隐式创建了a)
// 严格模式
'use strict';
b = 3; // Uncaught ReferenceError: b is not defined
这就是为什么有些变量 “没声明却能赋值”—— 本质是 LHS 查询失败后,非严格模式下的隐式全局变量导致的。但这种写法非常危险(容易覆盖全局变量),所以实际开发中一定要用严格模式。
(3)额外注意:TypeError 不是查询失败
还有一种常见错误是TypeError,但要注意:它和 “变量找不到” 无关,而是 “找到了变量,但变量的类型不对”。
比如:
var c = 5;
c(); // Uncaught TypeError: c is not a function
这里引擎通过 RHS 查询找到了 c(值为 5),但试图把 c 当作函数调用(要求函数类型),类型不匹配,所以报 TypeError。
总结一下报错逻辑:
- 先看 “找不找得到变量”:找不到→ReferenceError(LHS 严格模式 / RHS 所有模式);
- 再看 “找到的变量对不对”:类型不匹配→TypeError。
3. 嵌套作用域下的查询顺序:从内到外
实际代码中,作用域往往是嵌套的(比如函数里套函数),这时引擎的查询顺序是 “从当前作用域开始,逐级向上查找,直到全局作用域,找不到就报错”—— 这就是 “作用域链” 的查询规则。
举个嵌套作用域的例子:
var globalVar = "全局变量";
function outer() {
var outerVar = "外层函数变量";
function inner() {
var innerVar = "内层函数变量";
// 查找顺序:inner作用域 → outer作用域 → 全局作用域
console.log(innerVar); // 内层函数变量(当前作用域找到)
console.log(outerVar); // 外层函数变量(向上一层找到)
console.log(globalVar); // 全局变量(向上两层找到)
console.log(notExistVar); // ReferenceError(全局作用域也没找到)
}
inner();
}
outer();
这里 inner 函数的作用域链是 “inner → outer → 全局”,引擎查询变量时会严格按照这个顺序,不会 “跳级”,也不会 “从外到内”。
三、为什么要懂这些?解决实际开发中的坑
看到这里,你可能会问:“搞懂作用域和 LHS/RHS 有什么用?我平时写代码也没用到啊?” 其实,很多常见的 JS “坑”,本质都是对这些底层逻辑不了解导致的。
1. 变量提升的本质:编译阶段提前声明
你一定遇到过 “变量在声明前就能访问” 的情况:
console.log(d); // undefined
var d = 10;
为什么console.log(d)不报错,而是输出undefined?这就是 “变量提升”,而它的底层逻辑和编译阶段的 LHS 查询有关:
- 编译阶段:编译器会先扫描代码,把var d的声明提前注册到当前作用域(相当于 “先在书架上贴好 d 的标签”);
- 执行阶段:引擎执行console.log(d)时,RHS 查询能找到 d(已经声明了),但此时还没执行d=10,所以值是undefined。
如果没有变量提升(比如用 let/const 声明),情况就不一样了:
console.log(e); // Uncaught ReferenceError: Cannot access 'e' before initialization
let e = 10;
因为 let/const 不会像 var 那样 “提前声明”,引擎执行console.log(e)时,RHS 查询找不到 e(处于 “暂时性死区”),所以报 ReferenceError。
2. 严格模式的必要性:避免隐式全局变量
前面提到,非严格模式下 LHS 查询失败会创建隐式全局变量,这是很多 bug 的源头。比如:
// 非严格模式
function foo() {
// 不小心把“var name”写成了“name”
name = "张三";
}
foo();
console.log(name); // 张三(全局变量被意外创建)
如果项目中其他地方也用了name全局变量,就会被意外覆盖。而开启严格模式后,这种情况会直接报 ReferenceError,帮我们提前发现错误。
3. 闭包的底层逻辑:作用域链的持续有效
闭包是 JS 的核心特性,而它的底层逻辑正是 “作用域链的查询规则”。比如:
function outer() {
var outerVar = "闭包变量";
// 内层函数引用了outerVar
return function inner() {
console.log(outerVar); // RHS查询outerVar
};
}
var innerFunc = outer();
innerFunc(); // 输出“闭包变量”
当 outer 执行完后,按常理 outer 的作用域应该被销毁,但因为 inner 函数还引用着 outerVar,引擎执行 innerFunc 时,会通过作用域链(inner → outer)找到 outerVar,这就是闭包的本质 ——作用域链没有因为函数执行完毕而失效,变量依然能被查询到。
四、核心知识点回顾
看到这里,《你不知道的 JavaScript》第一章的核心内容就梳理完了,我们再用 3 句话总结关键知识点:
- 作用域是变量的查找规则与存储位置,负责配合引擎和编译器完成 “找变量” 和 “存变量”;
- LHS 查询找 “赋值目标”,失败时非严格模式隐式全局、严格模式报 ReferenceError;RHS 查询找 “取值源头”,失败时直接报 ReferenceError;
- 作用域链查询顺序是 “从内到外”,变量提升、闭包等特性的底层逻辑都源于此。
理解这些底层逻辑,可能不会让你立刻写出更复杂的代码,但能让你在遇到变量报错、变量提升、闭包等问题时,快速定位原因,而不是靠 “试错” 解决。就像《你不知道的 JavaScript》作者所说:“理解 JS 的底层机制,是写出健壮代码的前提。”
你在开发中遇到过哪些因为作用域或 LHS/RHS 查询导致的 bug?欢迎在评论区分享~