4

javaScript这门语言真的很有意思,就算你对他不怎么了解,只是简单的知道一点点,在日常使用中也完全没问题,毕竟我就是这样的工作了一段时间,虽然我不是很懂他,但是完成业务也没什么压力,可是为了能多赚一点钱,我决定把javaScript搞懂,就从作用域开始吧。

1、作用域是什么

声明一个变量是再常做不过的事情了,但是这些变量是储存在哪里呢?需要的时候是怎么找到它们的呢?

其实javaScript不会像其他语言编译器有那么多的时间进行优化,大部分情况下编译发生在代码执行前的几微妙,因此声明一个变量var name = ’shuting’;,javaScript编译器首先会对var name = ’shuting’;这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。

当你声明var name = ’shuting’时,其实是发生了以下步骤1.png

总结:变量的赋值操作会执行两个动作, 首先编译器会在当前作用域中声明一个变量(如果之前没有声明过), 然后在运行时引擎会在作用域中查找该变量, 如果能够找到就会对它赋值。

实际上,当变量出现在赋值操作的左侧时进行 LHS 查询, 出现在非左侧时进行 RHS 查询(如果查找的目的是对变量进行赋值, 那么就会使用 LHS 查询; 如果目的是获取变量的值, 就会使用 RHS 查询。赋值操作符会导致 LHS 查询。 = 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作),所以在上面的例子里引擎会为变量name进行 LHS 查询。

一个简单的例子,这段代码的处理过程是怎样的呢?

function f(name) {
    console.log(name);  // shuting
}
f(’shuting’);

编译器声明f函数,name为f的形式参数 => 引擎运行,询问作用域要对f进行RHS引用 => 对name进行LHS引用 => 为console(内置对象)进行RHS引用 => 还有个log(…)是个函数,再对name进行RHS引用,拿到name的值也就是shuting,传进log(…)。

当多个作用域发生嵌套时,如果在当前作用域中无法找到某个变量时, 引擎就会在外层嵌套的作用域中继续查找, 直到找到该变量,或抵达最外层的作用域(也就是全局作用域) 为止。

遍历嵌套作用域链的规则很简单:比如下面这个例子

function fullName(firstName) {
    console.log(firstName + lastName);
}
var lastName = ‘yang';
fullName(’shuting’);

引擎先在fullName作用域要对lastName进行RHS引用,但是没找到 => 就去fullName作用域的上一级也就是全局作用域查找,找到了

如果一个变量或者其他表达式不在”当前作用域”,那么javaScript机制会继续沿着作用域链上查找直到全局作用域,如果找不到将不可被使用。作用域也可以根据代码层次分层,以便子作用域可以访问父作用域,通常是指沿着链式的作用域链查找,而不能从父作用域引用子作用域中的变量和引用。

为什么要区分LHS查询和RHS查询呢?
LHS 和 RHS 查询都会在当前执行作用域中开始, 如果有需要(也就是说它们没有找到所需的标识符), 就会向上级作用域继续查找目标标识符, 这样每次上升一级作用域(一层楼), 最后抵达全局作用域(顶层), 无论找到或没找到都将停止。
不成功的 RHS 引用会导致抛出 ReferenceError 异常。 不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下), 该变量使用 LHS 引用的目标作为标识符, 或者抛出 ReferenceError 异常(严格模式下)。

2、常见作用域

全局作用域:

变量在函数或者代码块{}外定义,即为全局作用域。不过,在函数或者代码块{}内未定义的变量也是拥有全局作用域的(不推荐)。

var carName = "Volvo";
// 此处可调用 carName 变量
function myFunction() {
    // 函数内可调用 carName 变量
}

上述代码中变量carName就是在函数外定义的,它拥有全局作用域,这个变量可以在任意地方被读取或者修改。如果变量在函数内没有声明,该变量依然为全局变量

// 此处可调用 carName 变量
function myFunction() {
     carName = "Volvo";
    // 此处可调用 carName 变量
}

实际上carName在函数内,拥有全局作用域,它将作为global或者window的属性存在。
在函数内部或代码块中没有定义的变量实际上是作为window/global的属性存在,而不是全局变量。没有使用var定义的变量虽然拥有全局作用域,但是它是可以被delete的,而全局变量不可以。

函数作用域:

在函数内部定义的变量,就是局部作用域。函数作用域内,对外是封闭的,从外层的作用域无法直接访问函数内部的作用域。

function out() {
    var a = 1;
    function inner() {
        var b = 2;
        console.log(’this is inner');
 }
 inner();    // this is inner
 var c = 3;
}
inner();   // ReferenceError 错误
console.log(a,b,c)    // ReferenceError 错误

由于标识符 a、 b、 c 和 inner 都附属于 out(..) 的作用域气泡, 因此无法从 out(..) 的外部对它们进行访问。但是在out内部是可以被访问的。out函数的全部变量都可以在整个函数的范围内使用及复用。

function doSomething(a) {
    b = a + doSomethingElse( a \* 2 );
    console.log( b \* 3 );
}
function doSomethingElse(a) {
    return a - 1;
}
var b;
doSomething( 2 ); // 15

上面这个例子有个很大的问题是:变量b和函数doSomethingElse应该是函数doSomething私有的,像现在这样给予外部作用域对b和doSomethingElse的访问权限是没有必要且危险的,下面的例子使doSomethingElse和b都无法在外部访问,更合理。

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomethingElse( a \* 2 );
    console.log( b \* 3 );
}
doSomething( 2 ); // 15

通过上面的例子可以发现,在任意代码片段外部添加包装函数, 可以将内部的变量和函数定义“隐藏” 起来, 外部作用域无法访问包装函数内部的任何内容。

块状作用域

关于什么是块,认识{}就好

if(true){
  let a = 1
  console.log(a)
}

在这个代码中, if 后 {} 就是“块”,这个里面的变量就是拥有这个块状作用域,按照规则,{} 之外是无法访问这个变量的。
ES6引入了let/const关键字,提供除了var以外的另一种变量声明方式,let/const关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。 换句话说, let为其声明的变量隐式地了所在的块作用域。
对比下面两段代码

for (var i=0; i<10; i++) {
    console.log( i );
}

上面例子:
我们在 for 循环的头部直接定义了变量 i, 通常是因为只想在 for 循环内部的上下文中使用 i, 而忽略了 i 会被绑定在外部作用域(函数或全局) 中的事实。

for (let i=0; i<10; i++) {
    console.log( i );
}

上面例子:
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中, 事实上它将其重新绑定到了循环的每一个迭代中, 确保使用上一个循环迭代结束时的值重新进行赋值。
用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。使用 let 进行的声明不会在块作用域中进行提升。 声明的代码被运行之前, 声明并不“存在”。

下面是实际行为的例子

{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // 每个迭代重新绑定!
        console.log( i );
    }
}
动态作用域

只在执行阶段才能决定变量的作用域,那就是动态作用域

实际上我准备在下一篇文章的时候好好说一说this,等写好了,再把链接贴过来,嘿嘿。

结合作用域会对 this 有一个清晰的理解。看下这段代码:

window.a = 3
function test () {
  console.log(this.a)
}

test.bind({ a: 2 })() // 2
test() // 3

在这里 bind 已经把作用域的范围进行了修改指向了 { a: 2 },而 this 指向的是当前作用域对象。

function foo() {
    console.log(a); // 2  (不是 3!)
}

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

如果按照动态作用域分析:当 foo() 不能为 a 解析出一个变量引用时,它不会沿着嵌套的作用域链向上走一层,而是沿着调用栈向上走,以找到 foo() 是 从何处 被调用的。因为 foo() 是从 bar() 中被调用的,它就会在 bar() 的作用域中检查变量,并且在这里找到持有值 3 的 a。

如果按照静态作用域分析:foo执行的时候没有找到 a 这个变量,它会按照代码书写的顺序往上找,也就是 foo 定义的外层,就找到了 var a=2 ,而不是 foo 调用的 bar 内找。所以结果就是 2。

从这个示例可以看出 JavaScript 默认采用词法(静态)作用域,如果要开启动态作用域请借助 bind、with、eval 等。

3、提升

javaScript代码在执行时是由上到下一行一行执行的。但实际上并不完全正确,比如下面的例子

a = 2;
var a;
console.log( a ); // 2
console.log( a ); // undefined
var a = 2;

为什么会得出上面的结果呢,因为引擎会在解释 JavaScript 代码之前首先对其进行编译。 编译阶段中的一部分工作就是找到所有的声明, 并用合适的作用域将它们关联起来。实际上是这样的执行顺序

var a;
a = 2;
console.log( a );
var a;
console.log( a );
a = 2;

这个过程就是提升,只有声明本身会被提升, 而赋值或其他运行逻辑会留在原地。

我们习惯将 var a = 2; 看作一个声明, 而实际上 JavaScript 引擎并不这么认为。 它将 var a和 a = 2 当作两个单独的声明, 第一个是编译阶段的任务, 而第二个则是执行阶段的任务。

foo();
function foo() {
    console.log(a);    // undefined
    var a = 2
}

实际上是以下代码

function foo() {
    var a;
    console.log( a ); // undefined
    a = 2;
} 
foo();

函数声明是会被提升的,但是函数表达式并不行哦

foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() {
    // ...
};

函数声明和变量声明都会被提升。 但是函数会首先被提升, 然后才是变量。

4、作用域闭包

当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行。有两种表现:

  • 函数作为参数被传递
  • 函数作为返回值被返回

下面这个例子就是函数作为返回值被传递的闭包效果

function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz(); // 2

但正常来讲引擎有垃圾回收器用来释放不再使用的内存空间,而闭包的“神奇” 之处正是可以阻止这件事情的发生,因为bar() 本身在使用 foo()的作用域,内部作用域依然存在, 因此没有被回收。bar() 依然持有对该作用域的引用, 而这个引用就叫作闭包。

函数作为参数被传递的闭包效果

function print(fn) {
    const a = 200
    fn()
}
const a = 100
function fn() {
    console.log(a)
}
print(fn)       // 100

for循环也是个很常见的闭包的例子

for (var i=1; i<=5; i++) {
 setTimeout( function timer() {
 console.log( i );
    }, i*1000 );
}

// 以每秒一次的频率输出五次 6

根据作用域的工作原理, 实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中, 因此实际上只有一个 i。

想要解决这个问题,我们需要更多的闭包作用域, 特别是在循环的过程中每个迭代都需要一个闭包作用域。这让我想到了每次迭代我们都需要一个块级作用域,我们的好朋友let就派上了用场。

for (let i=1; i<=5; i++) {
 setTimeout( function timer() {
    console.log( i );
 }, i*1000 );
}
// 得到了我们想要的结果,每秒间隔输出1,2,3,4,5

以上就是我关于作用域的理解啦~下篇文章再见哦~

参考:
什么是作用域
JavaScript 作用域


姝婷同学
33 声望5 粉丝