阅读 359

你不知道的 JavaScript 上卷 第一部分 笔记

第1章 作用域是什么

1.1 编译原理

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。与传统的编译语言相比,它不是提前编译的。
JavaScript 引擎进行编译的步骤和传统编译语言非常相似,在某些环节比预想的要复杂。
传统编译语言的流程中,程序中的一段代码在执行前会经历三个步骤,统称为“编译”。

  1. 分词/词法分析
  2. 解析/语法分析
  3. 代码生成

比起编译过程只有三个步骤的语言的编译器,JavaScript 引擎复杂得多。例如,在语法分析和代码生成阶段的特定步骤来对性能进行优化,包块对冗余元素进行优化等。

1.2 理解作用域

var a = 2;为例说明程序处理过程。各个功能模块:

  • 引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程
  • 编译器:引擎的好朋友之一,负责语法分析以及代码生成等脏活累活
  • 作用域:引擎的另一位好朋友,负责收集并维护所有声明的标识符(变量)组成的一系列查询,并实施一套严格规则,确定当前执行的代码对这些表夫的访问权限。

工作流程:

  1. 遇到 var a,编译器会访问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器忽略,继续编译,否则要求作用域在当前作用域生成一个新变量,并命名为 a
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用于处理 a = 2 这个赋值操作。如果是,引擎使用这个变量,如果否,引擎会继续查找该变量(查看1.3

在我们的例子中,引擎会为变量a进行LHS(Left Hand Side)查询,另外一个查询的类型叫做RHS(Right Hand Side)。
R和L相对于=来说的,判断是取值还是赋值。

var a = 2; // LHS
a; // RHS

var b = a; // b 为 LHS,a 为 RHS

function foo(val) {
    console.log(val);
}

foo(2);
// 1. foo, RHS 查询
// 2. 执行隐藏的 val = 2, LHS 查询
// 3. console, RHS 查询
// 4. console.log(val), RHS 查询
复制代码

注意:function foo{...}函数声明并不会进行LHS查询,在引擎执行代码时,并不会有线程专门用来将一个函数值“分配给”foo。因此将函数声明理解为LHS查询和赋值的形式并不合适。这个原理我还不知道。

1.3 作用域嵌套

当一个块或函数作用域嵌套在另一个块或函数中,就发生了作用域的嵌套。因此,当前作用域无法找到某个变量时,引擎就会在外层嵌套作用域找,直到找到该变量,或抵达最外层的作用域为止。

1.1_scope_chain.png

1.4 异常

区分 LHS 和 RHS 的原因是在变量没有声明的情况下,这两种查询行为是不一样的。
LHS 在非严格模式下,在所有嵌套作用域下找不到所需的变量,会在顶层(全局作用域)声明一个该变量,并返回给引擎(严格模式下,引擎会抛出 ReferenceError异常)。
RHS 查询所有嵌套作用域后,找不到所需变量抛出ReferenceError异常。

第2章 词法作用域

作用域共有两种工作模式。第一种是最普遍的,被大多数编程语言所采用的词法作用域;另一种叫作动态作用域。
更多内容:一文搞懂:词法作用域、动态作用域、回调函数、闭包

2.1 词法阶段

上面的连接已经详细说明了词法作用域,这里简单说明:词法作用域就是定义在词法阶段的作用域。

1.2_lexical_scope.png

上面的代码中有三个逐级嵌套的作用域:

  1. 全局作用域,只有一个标识符:foo。
  2. 包含着 foo 所创建的作用域,其中有 a、bar 和 b。
  3. 包含着 bar 所创建的作用域,其中只有一个标识符:c。

作用域气泡由其对应的作用域块代码写在哪里决定。下一章会讨论不同类型的作用域。

查找

作用域查找会在找到第一个匹配的标识符时停止。在多层嵌套作用域可以定义同名的标识符,这叫作“屏蔽效应”。

2.2 欺骗词法

如果说词法作用域完全由写代码期间函数所声明的位置来定义,怎么才能在运行时来“修改”词法作用域呢?
JavaScript 中有两种机制来实现这个目的。社区普遍认为这两种机制并不是什么好主意,但是关于他们的争论通常忽略掉最重要的点:欺骗词法作用域会导致性能下降。

2.2.1 eval

function foo(str, a) {
    eval(str); // 欺骗词法作用域
    console.log(a, b);
}

var b = 2;
foo('var b = 3;', 1); // 1 3
复制代码

**注意:**严格模式下,eval(...)在运行时有其自己的词法作用域,以为其中声明无法修改所在的作用域。

function foo(str) {
    'use strict';
    eval(str);
    console.log(a);
}

foo('var a = 2'); // ReferenceError: a is not defined
复制代码

JavaScript 还有其他一些功能效果和eval类似,如setTimeoutsetInterval的第一个参数可以是字符串,字符串可以被解释为一段动态生成的函数代码。这些功能已经过时,不要使用。
new Function函数行为也很类似,最后一个参数可以接受代码字符串,并将其转化为动态生成的函数。这种构建函数的语法比eval(...)略微安全一些,但也要尽量避免使用。
Function

2.2.2 with

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

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

obj.a = 2;
obj.b = 3;
obj.c = 4;

// 更简洁的方式
with (obj) {
    a = 3;
    b = 4;
    c = 5
}
复制代码

但是实际上着不仅仅是为了方便访问对象属性。

function foo(obj) {
    with(obj) {
        a = 2;
    }
    con
}

var o1 = {
    a: 1
}

var o2 = {
    b: 3
}

foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2—— a 被泄露到全局作用域上。
复制代码

with 可以将一个对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

2.2.3 性能

JavaScript 引擎在编译阶段进行性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但是如果引擎在代码中发现eval(...)with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段知道eval接收到什么代码,也无法知道with用来创建词法作用域的内容到底是什么。

第3章 函数作用域和块作用域

3.1 函数中的作用域

见上文2.1 词法阶段

3.2 隐藏内部实现

我们使用函数将一部分代码包装起来,对外部来说,这部分代码就是隐藏的。
这样带来的好处有将函数内的内容私有化,最小限度地暴露必要内容。 带来的另一个好处是规避冲突。

3.3 函数作用域

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

foo(); // 3
console.log(a); // 2
复制代码

虽然用函数将代码包装起来的这种技术解决了一些问题,但是它并不理想,因为会导致一些额外的问题,首先,必须声明一个具名函数foo意味着这个名称“污染”了所在的作用域。其次,必须通过函数名显式调用才能运行其中的代码。

如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行将会更加理想。
(个人理解:这一段是为了引出立即执行函数和匿名函数,真实情况下,声明具名函数,然后通过函数名调用的情况为大多数。)

// 能够解决提出问题的方案
var a = 2;
(function foo(){
    var a = 3;
    console.log(a);
})(); // 3

console.log(a); // 2
复制代码

在上面的代码中,foo会被当作函数表达式而不是一个函数声明,函数声明和函数表达式最重要的区别是他们的名称标识符将会绑定在何处。

**注意:**区分函数声明和表达式最简单的方法是function关键字出现在整个声明的位置,如果过function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

3.3.1 匿名和具名

setTimeout(function(){ // 匿名
    console.log('2')
}, 0)
复制代码

很多地方提倡使用匿名函数,但是它有缺点需要考虑:

  1. 栈追踪不会显示有用的名字,不利于调试
  2. 没有函数名,当需要使用自身时只能使用arguments.callee引用。
  3. 一个描述性的名称可以让代码不言自明,匿名不利于代码可读性。

3.3.2 立即执行函数表达式

(function IIFE(def) {
    def(window);
})(function def(global){
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
});
复制代码

(function IIFE(def){...}) 返回了一个函数表达式,(fuction def(global){...})则运行返回的表达式,并将def当参数传入IIFE中。

3.4 块作用域

尽管函数作用域是最常见的作用域单元,但是其他类型的作用域单元也是存在的。

for(var i = 0; i<3; i++){
    var a = 1;
    console.log(i); // 0 --> 1 --> 2
}

if (a != 1) {
   var b = 2; 
}

console.log(a, b, i); // 1 undefined 3
复制代码

上面的代码中,无论var声明的变量是否在forif内部,都可以在外部访问,原因在后面会解释。
但是,我们的期望是只在对应的块中声明该变量,表面上JavaScript没有块作用域的的相关功能。

3.4.1 with

2.2.2 with

3.4.2 try/catch

try {
    undefined();
} catch (e) {
    console.log(e); // 不报错:TypeError: undefined is not a function
}

console.log(e); // 报错:ReferenceError: e is not defined
复制代码

3.4.3 let

ES6 引入let用来声明变量,将变量绑定所在的块作用域。

for(let i = 0; i<3; i++){
    console.log(i); // 0 --> 1 --> 2
}

if (true) {
    let b = 2;
    console.log(b) // 2
}

console.log(i); // ReferenceError: i is not defined
console.log(b); // 执行不到这里
复制代码
1. 垃圾回收

另一个块作用域非常好用的原因和闭包及内存回收机制有关。
闭包见5.4 循环和闭包

2. let 循环

for循环头部的leti绑定到循环的每一次迭代中,可以理解为每一次循环都是重新声明并赋值。

3.4.4 const

const使用的方式与let一致,但是const声明后的变量不可修改。
注意:const声明的变量如果为对象,修改对象属性不会报错。

第4章 提升

4.1 先有鸡还是先有蛋

到底是声明(蛋)在前,还是赋值(鸡)在前?

// 声明在赋值后,但是仍正常使用 a
a = 2;
var a;
console.log(a);
复制代码
// 声明在console.log 后,但是正常使用 a,此时a为undefined
console.log(a);
var a = 2;
复制代码

4.2 编译器再度来袭

在 JavaScript 实际上将var a = 2看作两个语句var aa = 2,第一个定义声明在编译阶段进行,第二个被留在原地等待执行阶段。
之前的代码被处理为:

var a;
a = 2;
console.log(a);
复制代码
var a;
console.log(a);
a = 2;
复制代码

这个过程,函数和变量的声明都被“移动”到最上面,这个过程叫做提升

4.3 函数优先

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

foo(); // 1

function foo (){
    console.log(1);
}

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

foo(); // 2
复制代码

提升后的代码为:

function foo (){
    console.log(1);
}

foo(); // 1

foo = function (){
    console.log(2);
}

foo(); // 2
复制代码

因为之前声明了foo函数,后面的var foo =是重复声明则忽略,变为赋值语句。

foo(); // TypeError: foo is not a function

if (true) {
    function foo() {
        console.log("a");
    }
} else {
    function foo() {
        console.log("b");
    }
}
复制代码

一个普通块内部的函数声明通常会被提升到所在作用域的顶部。

第5章 作用域闭包

5.1 启示

5.2 实质问题

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

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

var baz = foo();
baz(); // 2 闭包
复制代码

函数barfoo执行后返回给baz,然后bar执行。
通常在foo函数执行完毕,就可以回收函数中的内容,但是此时baz仍引用着bar,而barfoo内,所以需要保留该作用域,以供baz使用。

5.4 循环和闭包

for(var i=1; i<=5; i++) {
    setTimeout(function timer(){
        console.log(i);
    }, i * 1000);
}
// 6
// 6
// 6
// 6
// 6
复制代码

每次都输出6,因为在setTimeout内的函数运行时,i的值已经是6了。
我们期望的行为是,每次循环都拷贝一个副本,然后打印处对应副本的值。但是现在只有一个i,所以每次输出一个6。

立即执行函数会通过声明并立即执行一个函数来创建作用域。

for(var i=1; i<=5; i++) {
    (function () {
        setTimeout(function timer(){
            console.log(i);
        }, i * 1000);
    })()
}
// 6
// 6
// 6
// 6
// 6
复制代码

虽然立即执行函数创建了新的词法作用域,但是作用域内并没有变量声明,仍然使用的是外部的i
修改后:

for(var i=1; i<=5; i++) {
    (function (j) {
        setTimeout(function timer(){
            console.log(j);
        }, j * 1000);
    })(i)
}
// 1
// 2
// 3
// 4
// 5
复制代码

两者的区别是修改后,每个迭代都会创建新的作用域且在作用域内声明了新的变量,延迟的回调函数都可以正确的访问每个迭代中正确值的变量。

重返块作用域

for(let i = 0; i <= 5; i++) {
    setTimeout(function(){
        console.log(i);
    }, i * 1000)
}
复制代码

每次迭代都创建一个新的块作用域,每次迭代都声明一次i,延迟的回调函数使用每次迭代的声明的变量。

个人理解:每次迭代都会产生一个块作用域,这个块作用域中有一个变量被setTimeout使用,然后不会回收这些块作用域。

5.5 模块

function CoolModule() {
    var something = 'cool';
    var another = [1, 2, 3];
    
    function doSomething() {
        console.log(something);
    }
    
    function doAnother() {
        console.log(another.join('!'));
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3
复制代码

这个模式在 JavaScript 中被称为模块。最常见的实现模块模式的方法通常称为模块暴露,这里展示的是其变体。

简单描述,模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数必须至少调用一次(每次调用都会创建一个新的模块实例)
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域形成闭包,并且可以访问或者修改私有的状态

5.5.1 现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的API。
通过简单的代码展示一些核心概念:

var MyModules = (function Manager() {
    var modules = {};
    
    function define(name, deps, impl) {
        for(var i=0;i<deps.length;i++){
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl, deps);
    }
    
    function get(name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    }
})();
复制代码
MyModules.define('bar', [], function() {
    function hello(who) {
       return "Let me introduce " + who;
    }
    
    return {
        hello: hello
    };
});
复制代码
MyModules.define('foo', ['bar'], function(bar) {
    var hungry = 'hippo';
    
    function awesome() {
        console.log(bar.hello(hungry).toUpperCase());
    }
    
    return {
        awesome: awesome
    }
});
复制代码
var bar = MyModules.get('bar');
var foo = MyModules.get('foo');

console.log(
    bar.hello('hippo') // Let me introduce hippo
);

foo.awesome(); // LET ME INTRODUCE HIPPO
复制代码

换句话说,模块就是模块,即时在它们外层加上一个友好的包装工具也不会发生任何变化。

5.5.2 未来的模块机制

ES6 中为模块增加了一级语法支持。

export、import

写在后面

写这篇有些赌气的成分在,昨天在社交账号上,发了一条说自己终于看完《你不知道的 JavaScript》,然后有一两个人阴阳怪气的。我自己的水平我心里一清二楚,就是因为我明白个人水平一般才想去通过努力去学习更多的东西,也许学完之后还是一般,但是我想,多试几次总会比现在强。共勉吧。
有时间了会整理后面的笔记的,不过下卷不会整理了,因为下卷主要是ES6的内容,直接看ES6的教程反而好些。

关注下面的标签,发现更多相似文章
评论