初步理解 JavaScript:作用域、变量与编译报错全解析

282 阅读10分钟

一、JS 作用域的奥秘

443a0bbd6c65467499abde86e02e60e8.png

(一)作用域的定义与分类

作用域是一套规则,用于规定如何查找变量以及确定当前执行代码对变量的访问权限。其主要类型如下:

1、全局作用域

作为最外围的作用域,在浏览器环境中,全局作用域即为 window 对象。具有全局作用域的变量能够在所有作用域中被访问,例如:

  1.    // window 的属性或方法拥有全局作用域
       window.name = "globalName"; 
       console.log(window.name); 
    
       // 最外层变量或函数拥有全局作用域
       var globalVar = 10; 
       function globalFunction() {
           console.log("This is a global function.");
       }
       globalFunction(); 
    
       // 未定义直接赋值的变量也拥有全局作用域
       a = 20; 
       console.log(a); 
    

2、函数作用域

限定于函数内部区域。此作用域内的变量可访问全局作用域变量,但全局作用域无法访问函数内变量,且不同函数作用域中的变量相互独立,不可访问,例如:

  1.    function outerFunction() {
           var outerVar = 30; 
           function innerFunction() {
               var innerVar = 40; 
               console.log(outerVar); // 可访问 outerFunction 中的 outerVar
           }
           innerFunction(); 
           console.log(innerVar); // 报错,无法访问 innerFunction 中的 innerVar
       }
       outerFunction(); 
    

3、块级作用域

由 ES6 的 let 和 const 引入。其具有临时死区特性,变量必须先声明后使用,例如:

  1.    if (true) {
           // 使用 let 声明变量,形成块级作用域
           let blockVar = 50; 
           console.log(blockVar); 
       }
       console.log(blockVar); // 报错,无法在块外访问 blockVar
    

2)词法作用域

词法作用域由代码书写位置决定,函数作用域在函数定义时确立。其具备遮蔽效应,子作用域可访问父作用域,反之则不可。例如:

    function outer() {
        var outerVar = 60; 
        function inner() {
            var innerVar = 70; 
            console.log(outerVar); // 先从 inner 作用域查找,未找到则向上查找 outer 作用域中的 outerVar
        }
        inner(); 
    }
    outer(); 


在多层嵌套作用域中可定义同名标识符,内部会遮蔽外部,如:

    var global = 80; 
    function outer() {
        var global = 90; 
        function inner() {
            console.log(global); // 输出 90,内部 global 遮蔽外部 global
        }
        inner(); 
    }
    outer(); 



全局变量会成为全局对象属性,ES6 前可间接访问,但 `let` 声明引入块级作用域后有暂时性死区,访问全局变量可能报错,例如:

    let globalLet = 100; 
    function test() {
        console.log(globalLet); // 报错,暂时性死区导致无法访问全局变量
    }
    test(); 

3)动态作用域与 JavaScript

JavaScript 采用词法作用域,在代码书写阶段确定。与动态作用域不同,动态作用域在运行时依据调用栈确定作用域。例如:

    function a() {
        var x = 110; 
        b();
    }
    function b() {
        console.log(x); 
    }
    a(); 



在词法作用域下,`b` 函数中访问 `x` 会沿作用域链向上查找,找不到则报错;若为动态作用域,`x` 会在调用 `b` 函数处查找。

4)函数作用域

函数内部区域构成函数作用域,可访问全局变量,但外部无法访问内部变量,借此实现变量私有化,规避冲突。例如:

    function privateFunction() {
        var privateVar = 120; 
        return privateVar; 
    }
    console.log(privateVar); // 报错,无法访问 privateFunction 中的 privateVar



函数声明会提升,函数表达式则不会。JavaScript 编译前处理声明,仅声明提升,赋值等逻辑保留原位,例如:

    funcDeclaration(); // 可正常调用
    function funcDeclaration() {
        console.log("Function declaration called.");
    }

    var funcExpression = function () {
        console.log("Function expression called.");
    };
    funcExpression(); 

5)块级作用域

ES6 借助 `let` 和 `const` 实现块级作用域,具备临时死区特性,变量需先声明再使用。

`let` 声明将变量绑定至所在块级作用域(通常为 `{}` 内部),块外不可访问,无提升且禁止重复声明,例如:

    {
        let blockLetVar = 130; 
        console.log(blockLetVar); 
    }
    console.log(blockLetVar); // 报错,无法访问块内变量

    let duplicateLet = 140; 
    let duplicateLet = 150; // 报错,重复声明



例如:`const` 同样创建块级作用域,值固定不可修改(对于基本类型),引用类型可修改属性值,例如:
const constVar = 160; 
constVar = 170; // 报错,无法重新赋值

const constObj = { prop: "value" }; 
constObj.prop = "newValue"; // 合法,可修改对象属性值

二、JS 变量的世界

556e769962884691b0731643f9060f1a.png

变量的类型

  1. 原始类型:undefined、string、number、boolean、symbol。

JavaScript 中的原始类型直接存储在栈中,占据空间小且大小固定。例如,let num = 10;这里的数字10就是 number 类型的原始值,存储在栈中。字符串类型如let str = "hello";,布尔类型let isTrue = true;,未定义类型let un;此时un的值为 undefined,还有 symbol 类型代表创建后独一无二且不可变的数据类型,主要用于解决可能出现的全局变量冲突问题。

  1. 引用类型:object、array、null、function。

引用类型存储在堆中,占据空间大且大小不固定。例如,let obj = { key: "value" };这里的对象存储在堆中,在栈中存储了指向堆中该实体的起始地址的指针。数组也是引用类型,如let arr = [1, 2, 3];。函数同样如此,function test() {}定义的函数存储在堆中,栈中存储其指针。而 null 虽然表示空值,但它也是一种特殊的引用类型。当定义了一个变量或函数但没有赋值或返回时,其值为 undefined;而 null 是赋值了但赋的是空值。

// 原始类型示例

let num = 10; number

let str = "hello"; string

let isTrue = true; boolean

let un;

console.log(un); undefined

let sym = Symbol("unique"); symbol

// 引用类型示例

let obj = { key: "value" };

console.log(typeof obj); // 输出 "object"

let arr = [1, 2, 3];

console.log(typeof arr); // 输出 "object"

function test() {

console.log("This is a function.");

}

console.log(typeof test); // 输出 "function"

let emptyValue = null;

console.log(emptyValue); // 输出 null

console.log(typeof emptyValue); // 输出 "object"

三、LHS 与 RHS 的探索

(一)LHS 和 RHS 的概念

  1. LHS 全称为 Left-hand Side,是赋值操作的左侧查询,试图找到变量的容器本身进行赋值。在 JavaScript 中,如果查找的目的是对变量进行赋值,就会使用 LHS 查询。例如在语句a = 2中,就是对变量a进行 LHS 查询,目的是为这个赋值操作找到目标容器a,以便将值 2 赋给它。
  2. RHS 全称为 Right-hand Side,是赋值操作的右侧查询,获取变量或函数的值。如果查找的目的是获取变量的值,就会使用 RHS 查询。比如在console.log(a)中,对变量a进行 RHS 查询,目的是取得a的值并传递给console.log函数。

(二)案例分析

  1. 通过多个代码案例分析 LHS 和 RHS 的查询过程及数量。
    • 例如以下代码:

function foo(num) {

console.log(num);

}

foo(100);

在这个例子中,function foo(num)这里存在一个 LHS 查询,因为在调用foo(100)时,将实参 100 赋值给形参num,相当于num = 100,会在函数作用域中先找出num这个变量容器。而foo(100)本身是在求返回值的操作,在作用域中进行 RHS 查找这个函数是否存在,没有对其进行赋值操作,所以这是一个 RHS 查找。最后console.log(num)这里是一个 RHS 查询,因为在这行代码中,只有一个变量num被使用,在作用域中查找用于获取num的值。

所以这个代码中有 1 个 LHS 查询和 2 个 RHS 查询。

 再看这个例子:

function test(num) {

var num2 = num;

return num + num2;

}

var num = test(100);

包含 LHS 的代码:var num = test(100)这一段代码是一个 LHS,因为num在赋值运算的左边,是赋值操作的目标,所以要对num变量进行 LHS 查询,查询过程由作用域(词法作用域)进行配合查找。在function test(num)中,形参num在调用test(100)时,将实参 100 赋值给形参num,也就是num = 100,因为形参num在赋值运算的左边,所以要对形参num进行 LHS 查询。var num2 = num这一段代码也是一个 LHS,因为num2在赋值运算的左边,是赋值操作的目标,所以要对num2进行 LHS 查询。

总共进行3次LHS查询。

包含 RHS 的代码:var num = test(100)这段代码虽然有 LHS 查询但同时也有 RHS,因为可以把这段代码分成两个部分来看,其中test(100)调用部分是要求得一个结果,需要知道test(100)的值是多少,根据谁是赋值操作的源头是谁则就是 RHS,从而获取test(100)的返回值。var num2 = num也同理,虽然有 LHS 查询但同时也有 RHS,因为其中num这个变量在赋值运算符右边,此时需要知道num的值,根据谁是赋值操作的源头是谁则就是 RHS。return num + num2这里按照词法作用域查找思维,需要知道num和num2的值,也就是想查找这两个变量并取得它们的值,然后进行求和操作,所以这里是 RHS 查找,需要分别对num和num2都进行 RHS 查询。

总共进行4次RHS查询。

(三)查找规则

  1. LHS 和 RHS 查找都会在当前执行作用域开始,若未找到则向上级作用域查找,直到全局作用域。在 JavaScript 中,无论是 LHS 查询还是 RHS 查询,都是从当前执行作用域开始进行查找。如果在当前作用域中没有找到所需的变量,就会向上一级作用域继续查找,如此一级一级向上,最后抵达全局作用域。
  2. 如果在全局作用域中仍然没有找到,对于 RHS 查询,引擎就会抛出ReferenceError异常;对于 LHS 查询,在非严格模式下,会在全局作用域中创建一个同名变量(如果在任何作用域中都无法找到该变量),严格模式下则会抛出ReferenceError异常。

四、编译报错的常见类型

  1. RangeError:范围错误,如数组索引越界、数值溢出等。例如,试图通过 Array 构造函数创建非法长度的数组,或者将错误的值传递给数字方法如 toExponential()、toPrecision()、toFixed() 等,以及将非法值传递给字符串函数如 normalize() 时,可能会引发 RangeError。
  2. ReferenceError:引用错误,尝试引用不存在的变量或标识符。比如引用了不存在的变量,如 a() 或 console.log(a)(其中 a 未定义),或者给一个无法被赋值的对象赋值,如 console.log("abc") = 1,都会产生 ReferenceError。
  3. SyntaxError:语法错误,代码不符合语言规范。常见的原因包括缺少引号、缺少右括号、大括号或其他字符对齐不当等。例如 var 2a(变量名错误)、console.log 'b')(括号不全)、function =3(关键字赋值错误)等都会引发 SyntaxError。
  4. TypeError:类型错误,操作涉及不兼容的数据类型。调用不是方法的对象、试图访问 null 或未定义对象的属性、将字符串视为数字或反之亦然、在使用 new 命令时参数不是构造函数等情况都会引发 TypeError。例如 var a = new 1(new 关键字后接基本类型)、123()(调用不存在的方法)、var o = {}; o.run()(调用对象不存在的方法)等。