先来看一个问题

  1. var name='rushneg';
  2. function test(){
  3. console.log(name);
  4. var name='zhangsan';
  5. function inner(){
  6. var age="18";
  7. }
  8. inner();
  9. console.log(name);
  10. console.log(age);
  11. }
  12. test();//

很多同学会不会一看这个简单呀。

  1. rusheng
  2. zhangsan
  3. 报错

但实际结果呢?

  1. undefined
  2. zhangsan
  3. 报错

why?

在JS中:”一切皆是对象, 函数也是”.
在JS中,作用域的概念和其他语言差不多, 在每次调用一个函数的时候 ,就会进入一个函数内的作用域,当从函数返回以后,就返回调用前的作用域.
JS的语法风格和C/C++类似, 但作用域的实现却和C/C++不同,并非用“堆栈”方式,而是使用列表,具体过程如下(ECMA262中所述):
任何执行上下文时刻的
作用域, 都是由作用域链(scope chain, 后面介绍)来实现.
在一个函数被定义的时候, 会将它定义时刻的scope chain链接到这个函数对象的
[[scope]]属性.
在一个函数对象被调用的时候,会创建一个
活动对象(也就是一个对象), 然后对于每一个函数的形参,都命名为该活动对象的命名属性, 然后将这个活动对象做为此时的作用域链(scope chain)最前端, 并将这个函数对象的[[scope]]加入到scope chain**中.

下面我们先理解下几个概念:

执行上下文

在Javascript中有一个执行上下文(execution context)的概念;它定义了函数/变量访问其它数据的权限,
每个执行环境都有一个与之关联的作用域对象(scope object),环境中定义的所有变量和函数都保存在这个对象中。
执行上下文,分为创建和执行 两个阶段;

创建阶段(解释阶段)

是指 函数被调用但未被执行任何代码时,此时创建了一个拥有3个属性的对象:

  • 词法分析
  • 语法分析
  • 作用域规则确定

变量初始化阶段

  • JS解释器如何找到我们定义的函数和变量?变量对象 (Variable Object, 缩写为VO) 是一个抽象概念中的“对象”,它用于存储执行上下文中的:
    • 变量
    • 函数声明
    • 函数参数
  • VO按照如下顺序填充:
    • 函数参数 (若未传入,初始化该参数值为undefined)
    • 函数声明 (若发生命名冲突,会覆盖)
    • 变量声明 (初始化变量值为 undefined,若发生命名冲突,会忽略。)

注:函数表达式不会影响

代码执行阶段

主要工作:1、分配变量、函数的引用、赋值 2、执行代码

  • 创建执行上下文
  • 执行函数代码
  • 垃圾回收

JavaScript解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是this的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。
作用域和执行上下文之间最大的区别是:执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变
同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值

这个阶段给 VO 对象内的变量赋值,VO对象变成AO对象 (Activation Object)

  1. console.log(x); // function
  2. var x = 10;
  3. console.log(x); // 10
  4. x = 20;
  5. function x() {}
  6. console.log(x); // 20
  7. if (true) {
  8. var a = 1;
  9. } else {
  10. var b = true;
  11. }
  12. console.log(a); // 1
  13. console.log(b); // undefined

作用域

什么是作用域:

作用域是在运行时代码中某些特定部分中的变量、函数和对象的可访问性。

  1. function testFunc1() {
  2. var inVariable = "内部变量";
  3. }
  4. testFunc1();// 要先执行这个函数,否则根本不知道里面是啥
  5. console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined

上面的例子可以看出,变量inVariable在全局作用域并没有声明,所以在全局作用域下取值会报错。
我们可以这样理解:作用域就是一个独立的空间,让变量不会被外面看到。也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

Javascript中有
全局作用域、函数作用域,es6中又增加了 块级作用域**。

全局作用域和函数作用域

在代码中任何地方都能访问到的对象拥有全局作用域,如下几种:
全局作用域(Global Scope)

  • 最外层函数 和在最外层函数外面定义的变量拥有全局作用域

    1. var globalStr = "我是全局变量我在最外层"; //最外层变量
    2. function outFun() { //最外层函数
    3. var inVariable = "我是内部变量";
    4. function innerFun() { //内层函数
    5. console.log(inVariable);
    6. }
    7. innerFun();
    8. }
    9. console.log(globalStr); //我是最外层变量
    10. outFun(); //内层变量
    11. console.log(inVariable); //inVariable is not defined
    12. innerFun(); //innerFun is not defined
  • 所有末定义直接赋值的变量自动声明为拥有全局作用域

    1. function outFun2() {
    2. undefinedStr = "未定义直接赋值的变量";
    3. var inVariable2 = "内部变量2";
    4. }
    5. outFun2();//要先执行这个函数,否则根本不知道里面是啥
    6. console.log(undefinedStr); //未定义直接赋值的变量
    7. console.log(inVariable2); //inVariable2 is not defined
  • 所有window对象的属性拥有全局作用域

一般情况下,window对象的内置属性都拥有全局作用域,例如window.history、window.location、window.top,window.name等。

函数作用域(Function Scope)
函数作用域,是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部。

  1. function doSomething(){
  2. var name="rusheng";
  3. function sayHi(){
  4. console.log('hello,',name);
  5. }
  6. sayHi();
  7. }
  8. alert(name); // Uncaught ReferenceError: name is not defined
  9. sayHi(); // Uncaught ReferenceError: sayHi is not defined

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行

  1. function test(a){
  2. var b = a*2;
  3. function fcinner(c){
  4. console.log(a,b,c)
  5. }
  6. fcinner(b*2);
  7. }
  8. test(1); // 1,2,4

块语句(大括号“{}”中间的语句),如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。在块语句中定义的变量将保留在它们已经存在的作用域中。

  1. if (true) {
  2. // 条件语句块不会创建一个新的作用域
  3. var name = 'rusheng'; // name 依然在全局作用域中
  4. }
  5. console.log(name); // rusheng

块级作用域

块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。
块级作用域在如下情况被创建:

  1. 在一个函数内部
  2. 在一个代码块(由一对花括号包裹)内部

let 声明的语法与 var 的语法一致。你基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:

声明变量不会提升到代码块顶部

let/const 声明并不会被提升到当前代码块的顶部,因此你需要手动将 let/const 声明放置到顶部,以便让变量在整个代码块内部可用。

  1. function getValue(condition) {
  2. if (condition) {
  3. let value = "blue";
  4. return value;
  5. } else {
  6. // value 在此处不可用
  7. return null;
  8. }
  9. // value 在此处不可用
  10. }
  • 禁止重复声明

如果一个标识符已经在代码块内部被定义,那么在此代码块内使用同一个标识符进行 let 声明就会导致抛出错误。例如:

  1. var count = 30;
  2. let count = 40; // Uncaught SyntaxError: Identifier 'count' has already been declared

在本例中, count 变量被声明了两次:一次使用 var ,另一次使用 let 。因为 let 不能在同一作用域内重复声明一个已有标识符,此处的 let 声明就会抛出错误。但如果在嵌套的作用域内使用 let 声明一个同名的新变量,则不会抛出错误。

  1. var count = 30;
  2. // 不会抛出错误
  3. if (condition) {
  4. let count = 40;
  5. // ...
  6. }
  • 循环中的绑定块作用域的妙用

开发者可能最希望实现for循环的块级作用域了,因为可以把声明的计数器变量限制在循环内,例如:

  1. for (let i = 0; i < 10; i++) {
  2. // ...
  3. }
  4. console.log(i);// ReferenceError: i is not defined

上面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错。

  1. var a = [];
  2. for (var i = 0; i < 10; i++) {
  3. a[i] = function () {
  4. console.log(i);
  5. };
  6. }
  7. a[6](); // 10

上面代码中,变量i是var命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生改变,而循环内被赋给数组a的函数内部的console.log(i),里面的i指向的就是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的是最后一轮的i的值,也就是 10。
如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。

  1. var a = [];
  2. for (let i = 0; i < 10; i++) {
  3. a[i] = function () {
  4. console.log(i);
  5. };
  6. }
  7. a[6](); // 6

上面代码中,变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。
另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

  1. for (let i = 0; i < 3; i++) {
  2. let i = 'abc';
  3. console.log(i);
  4. }
  5. // abc
  6. // abc
  7. // abc

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域。

作用域链(Scope Chain)

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。如果在当前作用域无法找到会向上一级作用域寻找,直到找到或抵达全局作用域,这样的链式关系就是 作用域链(Scope Chain)

在 JavaScript 中,函数也是对象,实际上,JavaScript 里一切都是对象。函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供 JavaScript 引擎访问的内部属性。其中一个内部属性是 [[Scope]],由 ECMA-262 标准第三版定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问。

  • 在函数创建时,它的作用域链中会填入一个全局对象,该全局对象包含了所有全局变量。
  • 函数执行时会创建一个称为“运行时上下文(execution context)”的内部对象,运行期上下文定义了函数执行时的环境。每个运行期上下文都有自己的作用域链,用于标识符解析,当运行期上下文被创建时,而它的作用域链初始化为当前运行函数的[[Scope]]所包含的对象。
  • 这些值按照它们出现在函数中的顺序被复制到运行期上下文的作用域链中。它们共同组成了一个新的对象,叫“活动对象(activation object)”,该对象包含了函数的所有局部变量、命名参数、参数集合以及this,然后此对象会被推入作用域链的前端。
  • 当运行期上下文被销毁,活动对象也随之销毁。

在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程以决定从哪里获取和存储数据。该过程从作用域链头部,也就是从活动对象开始搜索,查找同名的标识符,如果找到了就使用这个标识符对应的变量,如果没找到继续搜索作用域链中的下一个对象,如果搜索完所有对象都未找到,则认为该标识符未定义。函数执行过程中,每个标识符都要经历这样的搜索过程。
我们来看一个函数

  1. function add(num1,num2) {
  2. var sum = num1 + num2;
  3. return sum;
  4. }

在函数add创建时,它的作用域链中会填入一个全局对象,该全局对象包含了所有全局变量,如下图所示(注意:图片只例举了全部变量中的一部分):
image.png

特例-构造器创建的函数

  1. function test() {
  2. var i = 1;
  3. var func = new Function("console.log(typeof i);");
  4. func(); // undefined
  5. }
  6. test();

通过构造器创建的函数是访问不到外层的局部变量的。

延长作用域链

有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。有两种情况下会发生这种现象。

  • try-catch 语句中的 catch 块
  • with 语句

对 with 来说,将会指定对象添加到作用域链中。对 catch 来说,会创建一个新的变量对象,其中包含的是被抛出的错误对象的声明。

代码优化

从作用域链的结构可以看出,在运行期上下文的作用域链中,标识符所在的位置越深,读写速度就会越慢。因为全局变量总是存在于运行期上下文作用域链的最末端,因此在标识符解析的时候,查找全局变量是最慢的。所以,在编写代码的时候应尽量少使用全局变量,尽可能使用局部变量。一个好的经验法则是:如果一个跨作用域的对象被引用了一次以上,则先把它存储到局部变量里再使用。