javascript 作用域——词法作用域

476 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第 5 天,点击查看活动详情

javascript 作用域——词法作用域

词法阶段

上篇文章说过,编译过程有三大阶段:词法阶段、语法阶段、语义阶段。词法阶段的主要任务是将我们书写的代码转成一个一个的词法单元,而词法化的过程当中,会对我们书写的代码进行检查,如果是有状态的解析,还会赋予词法单词语义。而这就是词法作用域的来历与基础。

通俗来讲,词法作用域就是定义在词法阶段的作用域,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此,当词法分析器处理代码时会保持作用域不变。

我们来看下面的代码

var a = 10;

function foo() {
  function bar() {
    var c = 5;
    console.log(a); // 10
    function baz() {
      console.log(c); // 5
    }

    baz();
  }

  bar();
}

foo();

上面的代码当中,根据变量和块级作用域的划分,可以分成四个词法作用域,分别是:全局作用域、foo 块级作用域、bar 块级作用域、baz 块级作用域。在进程词法解析的时候,会根据作用域的嵌套来对变量进行解析,这样就可以保证变量的作用域是正确的。 54vpX.png

作用域是由对应的作用域代码写在哪里所决定的,他们逐级包含,而且任何一个作用域不可以同时出现在两个外部作用域当中,就像没有任何函数可以同时部分的出现在两个父级函数当中。

查找

var a = 10;

function foo() {
  function bar() {
    var c = 5;
    console.log(a); // 10
    function baz() {
      console.log(c); // 5
    }

    baz();
  }

  bar();
}

foo();

作用域的查找是逐级向上的,按照上面的代码,console.log(c)是一个 RHS 查询,它会查询 c 这个变量的引用,首先会在baz块级作用域当中查询,如果没有,会向上一级的作用域进行查询,直到找到为止。但是假如没有找到,那么编译器会报错。

eval 和 with

之前我们说过,词法作用域是由代码书写时的变量和块级作用域所在的位置决定的,但是,通过evalwith可以在代码运行的时候,改变词法作用域,也可以说欺骗了词法作用域,让他以为这些代码一开始就应该书写在这里。

  • eval eval()函数接收一个字符串作为参数,并将其中的内容视为好像在书写阶段就已经在这里的代码进行执行。换句话说,可以在你写的代码当中使用程序生成代码。并且这个代码可以访问到你当前作用域的变量,也可以将变量添加到你的当前作用域当中。

    function foo(str, b) {
      eval(str);
      console.log(a, b); // 5 2
    }
    var b = 2;
    
    const str = `var a = 5`;
    
    foo(str, b);
    

    上面的代码当中,定义了一个函数foo(),函数接收两个参数,一个字符串strb,在函数内部,使用eval()对字符串进行执行,然后对其进行打印。 打印结果为5 2,说明我们定义的字符串str通过eval()进行了执行,并且在代码运行阶段,将变量a添加到了当前的foo函数作用域当中。正常情况下,如果eval()中所执行的代码包含一个或多个声明(无论是变量还是函数),就会对eval()所处的作用域进行修改,也就是欺骗`当前作用域。

    在严格模式下,eval()有自己的作用域,无法再干扰外部的作用域。在 javascript 当中,还有一些其他的功能效果和eval()类似,如setTimeout()setInterval()的第一个参数可以是字符串,字符串的内容也可以解析成一段代码。,但是这个功能已经不被提倡了,尽量不要去使用他们。

    New Function(...)函数的行为也很类似,最后一个参数可以接收字符串,并且将其转化为动态生成的函数。而前面传递的就是这个动态生成函数的形参。

  • with 在 javascript 当中,另外一个可以用来欺骗词法作用域的就是with了。with通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身,with在 vue 的 AST 语法树解析当中使用的很多。

看下列代码:

var obj = {
  a: 1,
  b: 2,
  c: 3,
};

with (obj) {
  a = 3;
  b = 4;
  c = 5;
}

console.log(obj); // { a: 3, b: 4, c: 5 }

上面我们定义了一个对象obj,但是现在我们想要修改对象当中的属性,我们常用的方法可以是使用.操作符来获取对象的单个属性然后修改对应的值,假如我们使用with来进行修改,我们可以在with的作用域当中直接使用属性名,而不需要通过obj.来获取属性。听起来很方便,不是吗?那么我们来看一下下面的代码:

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); //{b:3}
console.log(a); // 2 a被泄漏出来了

从上面的代码当中,我们可以看出,当 with作用域当中没有a属性的时候,会在全局作用域当中创建一个变量a,这是怎么回事呢?with 声明实际上是根据你传递给他的对象凭空捏造一个作用域,其中并没有 a 标识符,因此进行了正常的 LHS 查询,没有查询到,于是乎在全局作用域当中生成了一个a标识符。

性能

eval()with()会在运行的时候创建或修改作用域,以此来欺骗当前的词法作用域,但是 javascript 会在编译阶段进行性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并且预先确定所以的变量和函数定义的位置,才能在执行的的过程当中快速的找到标识符。如果引擎在执行的过程当中发现了eval()with它只能简单的假设关于标识符位置的判断是无效的,因为无法在词法分析阶段明确知道eval()会接收到什么样的代码,这些代码会对作用域进行什么样的修改。最坏的情况可能是之前编译状态所做的一切优化都是无用功。因此最简单的方法就是不做优化。所以我们尽量在代码当中减少使用eval()或者with,这样可以将性能优化的工作交给引擎。