女王控的博客

你不知道的JS作用域与闭包

作用域是什么

LHS 与 RHS

LHS 和 RHS 的含义是赋值操作的左侧或右侧并不一定意味着就是= 赋值操作符的左侧或右侧。赋值操作还有其他几种形式,因此在概念上最好将其理解为赋值操作(LHS)以及获取变量值(RHS)

js 复制代码
function foo(a) {
  // a的LHS引用,把2赋给a
  console.log(a); // console.log的RHS引用,内置对象的调用;a的RHS应用,a的调用;log函数中第一个参数a的赋值,LHS引用
}
foo(2); // foo的RHS引用,foo的调用1
js 复制代码
function foo(a) {
  var b = a; //b赋值,LHS;a调用,RHS
  return a + b; //a,b调用,RHS两次
}
var c = foo(2); //c赋值,LHS;foo调用,RHS;参数a=2,LHS

作用域

LHS 和 RHS 引用都会在当前作用域进行查找,如果还是没有找到就继续向上,以此类推。

js 复制代码
function foo(a) {
  console.log(a + b);
}
var b = 2;
foo(2); // 4

对 b 进行的 RHS 引用无法在函数 foo 内部完成,但可以在上一级作用域(在这个例子中就是全局作用域)中完成。

异常

区分 LHS 和 RHS 的意义

  • 在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。
  • 如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。
  • 当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是运行在非“严格模式”下。严格模式下,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。
  • 如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError 。
  • ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
js 复制代码
function foo(a) {
  console.log(a + b);
  b = a;
}
foo(2);

第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。

词法作用域

词法阶段

定义在词法阶段的作用域,决定于代码的位置,大部分情况下保持作用域不变

js 复制代码
function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log(a, b, c);
  }
  bar(b * 3);
}
foo(2); //2,4,12
  1. 最外层包含全局作用域,只有一个标识符:foo
  2. 包含 foo 所创建的作用域,有三个标识符:a、bar、b
  3. 包含 bar 创建的作用域,只有一个标识符:c

查找

  • 作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇到第一个匹配的标识符为准
  • 全局变量会自动成为全局对象的属性,因此可以通过全局对象属性的引用来直接访问,即 window.a,但非全局的变量屏蔽了之后,怎么也访问不到

欺骗词法

运行时来修改词法作用域,通常会导致性能下降

eval

js 复制代码
function foo(str, a) {
  eval(str); //欺骗,屏蔽了外部的b
  console.log(a, b);
}
var b = 2;
foo('var b=3;', 1); //1,3

严格模式下,eval 有着自己的词法作用域,无法修改所在域。

js 复制代码
function foo(str) {
  'use strict';
  eval(str);
  console.log(a); //Uncaught ReferenceError: a is not defined
}
foo('var a=2;');

其它类似的还有 setTimeout()和 setInterval()第一个参数可以是字符串,new Function()最后一个参数可以是字符串,比 eval 安全一些。

with

with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不会限制在这个块的作用域中,而是在 with 所处的作用域中。

当 o1 传递给 with 时,with 所在的作用域是 o1,而这个作用域正好有一个 o1.a。但当 o2 作为作用域时,其中并没有 a 的标识符,因此进行 LHS 查询,此时 o2,foo(),全局作用域中都没有找到标识符 a,因此当 a=2 自动创建了一个全局变量。

js 复制代码
function foo(obj) {
  with (obj) {
    a = 2;
  }
  // var a=3; 此语句会覆盖obj.a的赋值,使得a在foo的作用域下,故此时从全局作用域访问不到该变量。
}
var o1 = {
  a: 3
};
var o2 = {
  b: 3
};
foo(o1);
console.log(o1.a); //2

foo(o2);
console.log(o2.a); //undefined
console.log(a); //2,a被泄露到全局作用域上

在严格模式下,with 被完全禁止,在保留核心功能的前提下,间接或非安全的使用 eval 也被禁止。

性能

js 引擎会在编译阶段进行多项优化,如果发现了 eval 或 with,它只能简单假设关于标识符位置的判断都是无效的,最悲观的情况是即使对 eval 或 with 做了优化,所有的优化可能是无意义的,因此代码中大量使用 eval 或 with,运行起来一定会变的非常慢。

函数作用域与块作用域

函数中的作用域

  • 无论标识符的声明出现在作用域何处,这个标识符所代表的变量或函数都附属于所处的作用域。
  • 函数的作用域是指,属于这个函数的全部变量都可以在整个函数范围内使用以及复用。

隐藏内部实现

用作用域来隐藏变量,遵守最小暴露原则

js 复制代码
function doSometing(a) {
  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}
function doSomethingElse(a) {
  return a - 1;
}
var b;
doSometing(2);

更理想的方式是将 b 和 doSometing 私有化,防止有意或无意的以非预期的方式使用

js 复制代码
function doSometing(a) {
  function doSomethingElse(a) {
    return a - 1;
  }
  var b;
  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}

doSometing(2);

规避冲突

避免同名标识符之间的冲突,防止变量的值被意外覆盖

js 复制代码
function foo() {
  function bar(a) {
    i = 3; //修改for循环所属作用域中的i,前面加上var可以遮蔽变量
    console.log(a + i);
  }
  for (var i = 0; i < 10; i++) {
    bar(i * 2);
  }
}
foo(); //无限循环

全局命名空间

js 复制代码
var MyReallyCoolLibrary = {
  awesome: 'stuff',
  doSomething: function() {
    //...
  },
  doAnotherThing: function() {
    //...
  }
};

模块管理

利用模块管理器,保持私有、无冲突的作用域

函数作用域

js 复制代码
var a = 2;
function foo() {
  var a = 3;
  console.log(a);
}
foo();
console.log(a);

缺点

  • foo 这个名称本身污染了所在作用域
  • 必须显式调用这个函数才能运行。
js 复制代码
var a = 2;
(function foo() {
  var a = 3;
  console.log(a); //3
})();
console.log(a); //2

优点

  • (function…开头确保是函数表达式,不是函数声明;
  • 第一个片段 foo 被绑定在所在作用域中,可以直接通过 foo()来调用;第二个 foo 被绑定在函数表达式自身的函数中而不是所在域中。

匿名和具名

函数表达式的缺点:

  1. 匿名函数在栈追踪中不会显示有意义的函数名,使得调试很困难
  2. 如果没有函数名,但函数引用自身只能使用已经过期的 arguments.callee 引用,比如在递归中。另一个函数需要自身的例子,是在事件触发后事件监听器需要解绑自身
  3. 匿名函数省略了对于代码可读性很重要的函数名

始终给函数表达式命名是一个最佳实践

立即执行函数表达式

js 复制代码
var a = 2;
(function() {
  var a = 3;
  console.log(a); //3
})();
console.log(a); //2
  • 第一个()将函数变成表达式,第二个()执行了这个函数,称为 IIFE(立即执行函数表达式)
  • 还有一种改进形式:(function(){…}()),功能上是一致的
js 复制代码
var a = 2;
(function IIFE(global) {
  var a = 3;
  console.log(a); //3
  console.log(global.a); //2
})(window);
console.log(a); //2

可以将 window 对象引用传递进去,使得内部可以访问重名的变量。

js 复制代码
undefined = true; //给其他代码挖了一个大坑,会覆盖undefined默认值,解决方法见下
(function IIFE(undefined) {
  var a;
  if (a === undefined) {
    console.log('Undefined is safe here');
  }
})();

上述代码将一个参数命名为 undefined,但是在对应的位置不传入任何值,保证 undefined 的标识符值就是 undefined

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

倒置代码执行顺序,将需要运行的函数放在第二位,在 UMD 中被广泛使用

块作用域

for 循环和 if 语句都不会产生块作用域,表面上看是没有块作用域的

with

with 生成块作用域,仅在 with 声明中有效。

try/catch

ES3 中规定 try/catch 的 catch 分句会创建一个块作用域

js 复制代码
try {
  undefined(); //执行一个非法操作来强制制造异常
} catch (err) {
  console.log(err); //正常执行
}
console.log(err); // Uncaught ReferenceError: err is not defined

let

let 会创建一个绑定的块,同时 let 声明不会在块作用域中进行提升

垃圾收集

js 复制代码
function process(data) {
  //...
}
var someRealllyBigData = {
  /*...*/
};
process(someRealllyBigData);
var btn = document.getElementById('my_button');
btn.addEventListener('click', function click(evt) {
  console.log('button clicked');
});

click 函数的点击回调不需要 someReallyBigData 变量,意味着 process 执行完后 someRealllyBigData 会被回收。但由于 click 函数形成了一个覆盖整个作用域的闭包,js 引擎极有可能保存这个数据结构。

块作用域可以解决这个问题,可以让引擎清除的知道没有必要保存 someRealllyBigData 了

js 复制代码
function process(data) {
  //...
}
{
  let someRealllyBigData = {
    /*...*/
  };
  process(someRealllyBigData);
}
var btn = document.getElementById('my_button');
btn.addEventListener('click', function click(evt) {
  console.log('button clicked');
});

let 循环

js 复制代码
for (let i = 0; i < 10; i++) {
  console.log(i);
}
console.log(i); //ReferenceError

for 循环中的 let 不仅绑定到 for 循环的块中,事实上它将其绑定到循环的每个迭代中。

js 复制代码
{
  let j;
  for (let j = 0; j < 10; j++) {
    let i = j; //每次迭代重新绑定
    console.log(i);
  }
}

相对应的如果考虑用 let 来替代 var 则需要在代码重构中付出额外的精力

js 复制代码
var foo = true,
  baz = 10;
if (foo) {
  var bar = 3;
  if (baz > bar) {
    console.log(baz);
  }
  //...
}

重构后:

js 复制代码
var foo = true,
  baz = 10;
if (foo) {
  var bar = 3;

  //...
}
if (baz > bar) {
  console.log(baz);
}

重构成 let 变量时:

js 复制代码
var foo = true,
  baz = 10;
if (foo) {
  let bar = 3;
  if (baz > bar) {
    console.log(baz);
  }
  //...
}

const

ES6 引入了 const,同样可以创建块作用域变量,但值是固定的。

js 复制代码
var foo = truel;
if (foo) {
  var a = 2;
  const b = 3;
  a = 3; //正常
  b = 4; //错误
}
console.log(a); //3
console.log(b); //ReferenceError

提升

变量和函数在内的所有声明都会在任何代码被执行前首先被处理,这个过程叫做提升。


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

编译成:

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

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

编译成:

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

js 复制代码
foo();
function foo() {
  console.log(a); //undefined
  var a = 2;
}

编译成:

js 复制代码
function foo() {
  var a;
  console.log(a);
  a = 2;
}
foo();

函数表达式不会提升

js 复制代码
foo(); //TypeError
var foo = function bar() {
  //...
};

编译为:

js 复制代码
var foo;
foo(); //TypeError
foo = function bar() {
  //...
};

即使是具名的函数表达式,名称标识符在赋值之前也无法使用

js 复制代码
foo(); //TypeError
bar(); //ReferenceError
var foo = function bar() {
  //...
};

编译成:

js 复制代码
var foo;
foo();
bar();
foo=function(){
    var bar = ...self...
    //...
}

函数优先

函数声明和变量声明都会被提升,但是函数会首先被提升,然后才是变量,冲突时重复的声明会被忽略


var foo 尽管出现在 function foo()…声明之前,但它是重复的声明(被忽略),因为函数声明会被提升到普通变量之前

js 复制代码
foo(); //1
var foo;
function foo() {
  console.log(1);
}
foo = function() {
  console.log(2);
};

编译成:

js 复制代码
function foo() {
  console.log(1);
}
foo();
foo = function() {
  console.log(2);
};

尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的

js 复制代码
foo(); //3
function foo() {
  console.log(1);
}
var foo = function() {
  console.log(2);
};
function foo() {
  console.log(3);
}

编译成:

js 复制代码
function foo() {
  console.log(3);
}
foo(); //3
foo = function() {
  console.log(2);
};

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,注意这个行为不可靠,在未来的版本中会发生改变,应该尽可能的避免在块内部声明函数

js{1} 复制代码
foo(); //TypeError,按照定义应该是b,此处有疑问var a = true;
if (a) {
  function foo() {
    console.log('a');
  }
} else {
  function foo() {
    console.log('b');
  }
}

编译成:

js 复制代码
function foo() {
  console.log('b');
}
foo();

作用域闭包

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


js 复制代码
function foo() {
  var a = 2;
  function bar() {
    console.log(a); //2
  }
  return bar;
}
var baz = foo();
baz(); //2
  • 函数 bar 的词法作用域能够访问 foo()的内部作用域,然后 bar()本身当做一个值类型传递
  • bar()涵盖 foo()内部作用域的闭包,使得该作用域能够一直存活,不会被垃圾回收器回收,以供 bar()在之后任何时间进行引用,bar 一直持有对该作用域引用,这个引用就叫做闭包

js 复制代码
function wait(message) {
  setTimeout(function timer() {
    //timer函数一直持有对wait作用域的闭包
    console.log(message);
  }, 1000);
}
wait('Hello,closure!');

js 复制代码
var a = 2;
(function IIFE() {
  console.log(a);
})();

IIFE 不是典型的闭包,因为函数并不是在它本身的词法作用域以外执行的。它是在定义时所在的作用域执行,也就是全局作用域也持有 a,a 是通过普通的词法作用域而非闭包被发现的。

循环和闭包

js 复制代码
for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i); //五个6
  }, i * 1000);
}
  • 这个循环的终止条件 i 不再<=5,条件首次成立时 i 的值是 6

  • 延迟函数的回调会在循环结束时才执行,同步环境执行 -> 事件循环 1(microtask queue 的 All)-> 事件循环 2(macrotask queue 中的一个) -> 事件循环 1(microtask queue 的 All)-> 事件循环 2(macrotask queue 中的一个)…

    1. 主线程读取 JS 代码,此时为同步环境,形成相应的堆和执行栈;
    2. 主线程遇到异步任务,指给对应的异步进程进行处理(WEB API);
    3. 异步进程处理完毕(Ajax 返回、DOM 事件处罚、Timer 等),将相应的异步任务推入任务队列;
    4. 主线程查询任务队列,执行 microtask queue(promise,MutationObserver),将其按序执行,全部执行完毕;
    5. 主线程查询任务队列,执行 macrotask queue(onclick,setTimeout,Ajax),取队首任务执行,执行完毕;
    6. 重复 step4、step5。

试图输出 1,2,3,4,5

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

此时闭包函数的作用域为空,i 向上进行 RHS 引用,直至最外层,得到 i=6,即 i 未记住所在的词法作用域时,不能形成闭包


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

每次迭代都会保存 i 的值,i 已记住所在的词法作用域,形成闭包


改进

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

重返块作用域

可以考虑将作用域转换为块作用域

js 复制代码
for (var i = 1; i <= 5; i++) {
  let j = i; //闭包的块作用域
  setTimeout(function timer() {
    console.log(j); //1,2,3,4,5
  }, j * 1000);
}

改进

js 复制代码
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    //块作用域与闭包运用
    console.log(i); //1,2,3,4,5
  }, i * 1000);
}

模块

js 复制代码
function foo() {
  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 = foo();
foo.doSomething(); //cool
foo.doAnother(); //1,2,3

这个模式就被称为模块,模块具备的必要条件:

  1. 必须有外部的封闭函数,该函数至少被调用一次
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并还可以访问或者修改私有的状态

可以对模块进行改进,只需要一个实例

js 复制代码
var foo = (function foo() {
  var something = 'cool';
  var another = [1, 2, 3];
  function doSomething() {
    console.log(something);
  }
  function doAnother() {
    console.log(another.join(','));
  }
  return {
    doSomething: doSomething,
    doAnother: doAnother
  };
})();
foo.doSomething(); //cool
foo.doAnother(); //1,2,3

命名将要作为公共 API 返回的对象

js 复制代码
var foo = (function Module(id) {
  function change() {
    publicAPI.identity = identity2;
  }
  function identity1() {
    console.log(id);
  }
  function identity2() {
    console.log(id.toUpperCase());
  }
  var publicAPI = {
    change: change,
    identity: identity1
  };
  return publicAPI;
})('foo fff');
foo.identity();
foo.change();
foo.identity();

现代的模块机制

js{7} 复制代码
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
  };
})();

定义模块:

js 复制代码
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'));
foo.awesome();

未来的模块机制

es6 的 import 和 export 类似于以上机制

评论

阅读上一篇

你不知道的JS之this
2018-05-27 14:34:18

阅读下一篇

CSS世界流、元素与基本尺寸
2018-05-22 12:21:01
0%