网络知识 娱乐 你不知道的JavaScrpit(上卷) 随记(一)

你不知道的JavaScrpit(上卷) 随记(一)

第一部分 作用域和闭包

1. 作用域是什么

编译原理: 三个步骤

  1. 分词/词法分析: 把字符串分解成有意义的代码块(词法单元)如: “var a = 2;”分解成var, a, =, 2, ; 这五个部分
  2. 解析/语法分析: 把词法单元转换成一个由元素逐级嵌套的程序语法结构的树(抽象语法树AST)
  3. 代码生成: 将AST转换成可执行的代码的过程,转换为一组机器指令。
  4. JS引擎在词法分析和代码生成阶段其实另外还有特定的步骤来对运行性能进行优化,包括对冗余元素优化等。

例子: var a = 2 分解实例

  1. 遇到 var a, 编译器会询问作用域是否已经有这个名称的变量存在。是 则 忽略, 否则要求声明
  2. a =2 的赋值操作,引擎运行时会首先询问作用域,当前作用域是否存在a的变量,否则会继续向上寻找。找不到会抛出一个异常

LHS 与 RHS

LHS: 变量出现在赋值操作的左侧(试图找到变量的容器本身)

RHS: 变量出现在右侧(其实就是查询取到这个变量的源值)

为什么区分LHS和RHS很重要: 因为在变量还没声明时这两种查询的行为是不同的。RHS在在所有作用域中查询失败会报出RerferenceError异常,LHS则会创建出一个新的变量(非严格模式下)。

RerferenceError异常同作用域判别失败关联,TypeError异常代表作用域判别成功但是操作是不合法的。

2. 词法作用域

词法作用域(其实就是静态作用域)是由你在写代码时将变量和块作用域写在哪里来决定的。

作用域会在查找到第一个匹配的标识符时停止,从最内层的向全局作用域查询,会有屏蔽效应。(非全局的变量被屏蔽了无论如何都访问不到)词法作用域只会查找一级标识符,如:foo.bar.baz 只会查找foo然后访问对应的属性。

欺骗词法:

  1. eval 通常用来执行动态创建的代码,如果包含声明类语句就可能会对词法作用域进行一定程度的修改(严格模式除外) new Function()的最后一个参数也可以是代码字符串 但是 都不提倡! 对性能有损耗!
  2. with function foo(obj){ with(obj) { a = 2 } } var o1 = { a: 3 } var 02 = { b: 3 } foo(o1) console.log(o1.a) // 2 ​ foo(o2) console.log(o2.a) // undefined console.log(a) // 2 泄漏到全局作用域了 /* 其实就是LHS引用,然后把2赋值给它。 实际是根据你传递给它的对象凭空创建了一个全新的词法作用域。 现以o1为词法作用域,进行a的LHS查询,然后赋值为2。再以o2为词法作用域,进行LHS查询, 没有找到向上查询也没有找到,所以创建了一个全局变量 */ 其实就是LHS引用,然后把2赋值给它。 实际是根据你传递给它的对象凭空创建了一个全新的词法作用域。 现以o1为词法作用域,进行a的LHS查询,然后赋值为2。再以o2为词法作用域,进行LHS查询,没有找到向上查询也没有找到,所以创建了一个全局变量。
  3. 性能影响 js引擎在编译阶段进行多项性能优化。其中有些优化依赖于根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。eval和with(无法明确知道eval会接受什么代码,会对作用域有什么影响,也不知道with对象的具体内容是什么)会对此有很大的影响。会使得代码运行变慢! 词法作用域意味着作用域是由书写代码是函数声明的位置决定的。编译的词法分析阶段基本能够知道全部标识符是在哪里以及如何声明的,从而能预测在执行过程中如何对它进行查找。

3.函数作用域和块作用域

  1. 函数中的作用域 function foo(a) { var b = 2; function bar() { // ... } var c = 3; } 在这个代码片段中,foo(...) 的作用域气泡中包含了标识符a, b, c 和 bar。无论标识符声明在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处的作用域的气泡。 bar 拥有自己的作用域气泡。全局作用域也有自己的作用域气泡,它只包含了一个标识符 foo 由于标识符 a, b, c 和 bar都属于 foo(...)的作用域气泡,因此无法从foo(...) 的外部对他们进行访问。也就是这些标识符都无法从全局作用域进行访问。但是他们是可以在foo(...)的内部访问的,同样在bar(...)内部也可以访问。 函数作用域的含义是指: 属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也是可以的)
  2. 隐藏内部实现 其实就是把代码用函数声明对他进行包装,不让外部用于他的访问权限,变成类似于私有的,更安全。
  3. IIFE立即执行函数表达式 (function() { var a = 2; // ... })() ​ // or (function() { var a = 2; // ... }()) 以上两种写法在功能上是完全一致的。
  4. 块作用域 es6之前并没有被广泛注意到的块作用域(不过其实with是一个块作用域的例子,用with从对象中创建出的作用域仅在with声明中而非外部作用域生效)(try catch的catch分句其实也会创建一个块作用域,其中声明的变量仅在catch内部有效)
  5. let和const 在这里不再做赘述。需要注意的是, let和const不存在变量提升功能。

4.变量提升

console.log(a);
var a = 2;
​
// 输出结果为 undefined
​
console.log(a)
let a = 2 
// 会报错

上面的情况就是变量提升行为。

引擎会在解释js代码之前首先对其进行编译。编译中的第一部分工作就是找到所有的声明,并用合适的作用域将他们关联起来。所以,包括变量和函数在内的所有声明都会在任何代码被执行之前首先被处理。

console.log(a);
var a = 2;
​
//实际的执行顺序为:
var a;
console.log(a)
a = 2;

这个过程就好像变量和函数声明从他们在代码中出现的位置被移动到了最上面,这个过程就是提升。

函数声明会被提升,但是函数表达式却不会被提升:

foo()
var foo = function() {
    // ....
}
​
// 实际解释为
var foo;
foo()
foo = function(){}
// 所以是TypeError,就是 找到了foo的变量 但是它不是一个函数不能运行。

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

foo() // 输出1
var foo;
function foo() {
    console.log(1);
}
​
// foo函数先被提升,然后再声明 var foo变量属于重复声明会被忽略。

5.作用域闭包

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar
}
var baz = foo()
baz() // 2 ----这就是闭包

接下来解释一下以上的代码片段:

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。在这个例子中我们把bar所引用的函数对象本身当作返回值。

在foo()执行后,其返回值(其实也就是内部的bar()函数)赋值给baz并调用baz(), 实际上只是通过不同的标识符引用调用了内部的函数bar()

这个例子中,bar在自己定义的词法作用域以外的地方执行。

在foo()执行后,通常会期待foo()的整个内部作用域被销毁,被垃圾回收机制回收。而闭包的神奇机制就在于会阻止这个功能。事实上内部作用域依然存在,因此没有被回收。谁在用这个内部作用域?其实就是bar()本身在使用。

拜bar()所声明的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫做闭包。这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

要说明闭包,foe循环是最常见的例子

for(var i=0; i<=5; i++) {
    setTimeout( function() {
        console.log(i)
    }, i*1000);
}
/** 
    我们的预期可能是 分别输出1~5,每秒一次,每次一个。但是实际上,代码运行的输出结果是 每秒一次的频率输出五个6。 因为循环结束的条件是i=6,由于异步执行,在控制台输出时循环已经结束了,i为6。这里的问题可能是,我们以为循环的每次迭代运行时都会给自己捕获一个i的副本。但是根据作用域的原理,实际情况尽管循环中的五个函数都是在迭代中分别定义的,但是他们都被封锁在一个共享的全局作用域,只有一个i。所有函数共享一个i。
*/
​
// 再次尝试
for(var i=0; i<=5; i++) {
    (function() {
        setTimeout( function() {
            console.log(i)
        }, i*1000);
    })();
}
/**
    IIFE会通过声明并立即执行一个函数来创建作用域。显然现在我们拥有更多词法作用域了,但是这样也不行。为什么呢?疑问作用域是空的,它要获取i最后还是会到全局中去拿。
*/
​
// 最后尝试
for(var i=0; i<=5; i++) {
    (function(j) {
        setTimeout( function() {
            console.log(j)
        }, j*1000);
    })(i);
}
// or 
for(var i=0; i<=5; i++) {
    (function() {
        var j = i;
        setTimeout( function() {
            console.log(j)
        }, j*1000);
    })();
}
// or (let 劫持块作用域)
for(let i=0; i<=5; i++) {
    setTimeout( function() {
        console.log(i)
    }, i*1000);
}
// 我们通过在词法作用域内每次保存一个i的副本就可以了。问题解决~

模块的特征:

  1. 为创建内部作用域而调用了一个包装函数
  2. 包装函数的返回值必须至少包含一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。