词法作用域与JavaScript的欺骗词法

820 阅读7分钟

在开始这篇文章的内容前,我们先来看这样的代码片段

// 控制台输出的是 2 还是 3 呢?
var a = 2

function foo() {
  console.log(a)
}

function bar() {
  var a = 3
  foo()
}

bar()

如果你不知道答案或者你知道答案却不知道为什么,那请花一些时间阅读这篇文章吧!

上篇文章中讲述了一些作用域和词法分析的概念。作用域是一套规则,用来管理变量在不同代码中的可用性范围。它同时是个语言无关的概念,主要有两种主要的工作模型:

  • 词法作用域:最为普遍的,被大多数编程语言所采用
  • 动态作用域:仍有一些编程语言在使用(Bash,Perl的一些模式等)

词法作用域VS动态作用域

词法作用域

词法作用域也叫静态作用域,它的作用域在词法分析阶段就确定了。换句话说,词法作用域是由你的代码中将变量和块作用域写在哪里来决定的。 Image

从上边这幅图我们可以看出词法作用域模型中作用域块的划分是由代码结构决定的,它们是逐级包含的。相同层级的 foobar 就没有办法访问到彼此块作用域中的变量。所以这段JavaScript代码的输出是 2 (变量 afoo 的父级作用域也就是全局作用域中找到了)。

动态作用域

动态作用域是在运行时根据程序的流程信息来动态确定的,而不是在词法分析阶段确定的。

下边我们用一段shell的代码重新实现文章开头的代码片段

#!/bin/bash

a=2

foo() {
  echo $a
}

bar() {
  a=3
  foo
}

bar

对你没有猜错,这段代码的输出是 3 。动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用

用一张图来解释 Image

foo 方法中需要引用 a 变量的时候,会顺着调用栈去查找变量 a。在调用栈中逐层查找会在最近的地方找到值为3的变量 a

小结

Javascript只有词法作用域,简单明了。但是,它的 eval(...)withthis 机制某种程度上很像动态作用域,下边会介绍 eval(...)with

主要区别:词法作用域是在写代码或者定义时确定的,而动态作用域是在运行时确定的(this也是!)。词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用

变量查找

作用域块之间的包含关系给了JavaScript引擎充足的位置信息,引擎使用这些信息来查找标识符的位置。

对于一个变量的查找,引擎会在当前作用域中开始查找,若无法找到则会去上一级的作用域中查找。

作用域会在找到第一个匹配的变量时停止。多层嵌套的作用域中也可以定义同名的变量,但处于更高层级作用域中的同名变量事实上无法被访问到,这叫做“遮蔽效应”(内部的变量遮蔽了外部的变量)。

var a = 1

function foo() {
  var a = 2 // 被遮蔽的变量 a
  function bar() {
    var a = 3
    console.log(a) // 3
    console.log(global.a) // 1
  }
  return bar
}

foo()()

注意:在JavaScript中全局变量会自动成为全局对象的属性,因此可以通过全局对象(node中的global或浏览器的window)对其直接访问。 通过这种技术可以访问那些被遮蔽的全局变量,但非全局变量被遮蔽的话无论如何都无法访问。

同时,词法作用域只会查找一级标识符。例如 foo.bar.baz ,词法作用域只会试图查找foo标识符,找到这个变量后,对象属性访问规则会接管对bar和baz的访问。

欺骗词法

词法作用域完全由词法解析期间函数声明的位置来定义,但JavaScript中有两个机制来实现在运行时来“修改”(或者说欺骗)词法作用域。

社区普遍认为使用这两种机制不是什么好主意。但我们可以来看看这两种机制的原理。

eval

eval(...) 函数接收一个字符串作为参数,并且将其中的内容当作代码(非纯字符串)来处理。(怎么有种XSS的味道了)

在执行 eval 函数后边的代码时,引擎并无感知前边的代码是以动态形式插入并对词法作用域的环境产生了修改的。引擎只会一如往常的去查找。

function foo(codeSnippet, a) {
  eval(codeSnippet)
  return a + b
}

var b = 2

foo('var b = 3', 1) // 返回值是 4

eval函数中调用的 'var b = 3' 这段代码会被当作原本就写在那里一样来处理。

注意:在示例中为了简洁传入eval的代码片段是固定不变的,但实际情况中可以很容易的根据逻辑来修改 eval(...) 的入参。在严格模式中,eval在运行时会有其自己的词法作用域,意味着它无法修改当前所在的作用域。

function foo(codeSnippet) {
  "use strict"
  eval(codeSnippet)
  console.log(a) // ReferenceError: a is not defined
}

foo('var a = 2')

with

JavaScript另外一个现在不推荐使用的用来欺骗词法作用域的功能是 with 关键字。

with通常被当作重复引用同一个对象中多个属性的快捷方法,可以不需要重复引用对象本身。

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

// 普通的修改方式
obj.a = 2
obj.b = 3
obj.c = 4

// 使用with
with(obj) {
  a = 3
  b = 4
  c = 5
}

但是with并不仅仅是为了方便访问对象属性,使用它时有可能会有意想不到的事情发生。

function foo(obj) {
  with(obj) {
    a = 1
  }
}

var obj1 = {
  a: 0,
}

var obj2 = {}

foo(obj1)
console.log(obj1.a) // 1

foo(obj2)
console.log(obj2.a) // undefined
console.log(a) // 1 -> 变量a泄露到了全局作用域

当对象中有with操作中相同的属性时,一切操作看起来很正常。但是当对象中没有对应名称属性可以去修改的时候却不是为此对象添加一个属性。这是为什么呢?

with本质上会把对象处理为一个完全隔离的词法作用域,所以对象的属性会被处理为定义在当前作用域中的变量。这时with关键字中的操作相当于上篇文章中提到的LHS查询,如果在当前作用域中找到该变量会进行赋值操作,找不到的话会持续向上层查找。本例中查找到全局作用域都没有发现a变量,所以引擎在全局作用域中新建了a变量并赋值为1

相比eval是接受代码片段,修改其所处的词法作用域,with则是根据传入的对象凭空创建了一个全新的作用域。

性能

虽然 eval(...)with 能帮我们实现更复杂的功能,加强代码的扩展性。但JavaScript引擎在编译阶段做的若干的性能优化都是依赖于代码词法解释时的静态分析来的。

引擎预先确定所有的所有变量和函数的定义位置,才能在执行过程中快速找到变量。如果用了eval(...)with,引擎只能假设之前关于变量位置的判断都是无效的,最悲观的情况就是如果使用它们俩,所有的优化可能都是无意义的。

所以使用它们对于程序性能的影响较大,而且不合理的使用会造成意想不到结果的产生,在使用中应该尽量避免。

小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。

JavaScript有两个机制可以“欺骗”词法作用域:eval(...)with。他们的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。这两个机制的使用必然会导致代码运行变慢,不要使用它们