likes
comments
collection
share

作用域

作者站长头像
站长
· 阅读数 69

作用域

我们会从几个点来认识作用域,什么是作用域,它能干什么,需要注意什么。

概念

我们大概率会从字面意思去理解作用域,它就是一个区域,一个范围,在这个范围内我们能操作变量。但确切的概念应该是: 这是一套规则,一套可以用来存储变量的,且方便后续管理和查找变量的规则。 有些抽象,我们通过一个例子来理解。

“ var=1,里面发生了什么 ”,这里先认识几个概念

  • 引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程。
  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活(ast树就是它生成和处理的) 别看这条语句简单,背后其实有很复杂的一套流程,当中比较重要的编译器,它会将这段程序分解成词法单元,然后将词法单元解析成树结构。后再将ast转换成引擎可执行的代码。
  1. 遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的 集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a。
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值 操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量,局部找到全局,再找不到的话,会直接抛出异常

这里我们就能看出作用域的作用了,负责收集并维护由所有声明的标识符(变量),确定当前执行的代码对这些标识符的访问权限 也就是说,你定义的所有变量都这个规则内部,并受限于这个规则。你想查找得经过这个规则的允许。

作用域是可以嵌套的,因次形成了一套查找规则,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。且作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”

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

这个foo 打印 a 在本身内部是找不到的,作用域就会从外层全局作用域找给它。

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

这个foo 打印 a 在本身内部已经有一个a了,所以外部的a就不会被查找打印了。

词法作用域

作用域有两种主要工作模型:词法作用域和动态作用域,大部分编程语言用的都是词法作用域,也就是在你写代码时的变量和作用域在哪里,就决定了哪里是词法作用域。而动态作用域是代码在运行的时候才能确定的,少数编程语言会用到这个。

上述讲的所有特性都是词法作用域拥有的。但即是词法作用域是在写代码期间的位置来定义的,还是有办法可以欺骗它。

  • eval
function foo(str, a) {
  eval( str ); // 欺骗! console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

eval能将第一个参数字符串当做代码运行,在上述的例子中,这直接改变了foo的词法作用域,使得外部的b被遮蔽了。

  • with
with (obj) {
   a = 3;
   b = 4;
   c = 5;
}

function foo(obj) {
  with (obj) {
    a = 2;
  }
}
var o1 = { 
  a: 3
};
foo( o1 );
console.log( o1.a ); // 2
console.log( a )

这里莫名其妙多出来了a,因为with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,并且会被添加到with所处的函数作用域。

以上这两种处理方式会导致性能下降,而且代码不易理解,容易出bug,所以尽量少使用或者不使用这样的语法,当然大部分情况下这样的操作是用不到的。

函数作用域和块作用域

作用域类型的话,大致是有这两种。 函数作用域:属于这个函数的全部变量都可以在整个函数的范围内使用及复用

function  fn () {
 var a = 1
 console.log(a)
}

外部作用域是无法主动访问作用内部的变量的,除非使用闭包。它有几个特点

  • 隐藏内容实现,因为外部无法访问到,而且可以避免同名标识符的冲突
  • 有利于形成模块管理。 但同样的弊端就是,一旦定义了一个函数,它将势必“污染”全局作用域,其次无法自动执行,需要手动调动 fn()。

因此便衍生出了,一种概念 IIFE,又名立即执行函数

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

其实函数匿名的话也是有一些缺点的。

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  • 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用,比如,事件触发后事件监听器需要解绑自身。
  • 在代码可读性/可理解上面,不够具体明确。 IIFE有好几种写法,如用!,~,+,-等开头的。其原理是第一个()将里面包裹函数,成了一个表达式,第二个()执行这个表达式,于是函数就执行了。其他的操作符也是一样的。后一个()可协带参数
!function(){
   var a = 2;
   console.log(a)
}()

块作用域,代码块{},也能有属于自己的变量和函数 早在let,const 出来之前,js并没有具体的块作域的功能,但倒是有几个不显而易见的语法, 如with(),这个语法很少用,但是它声明出来的内容仅在里面有用,外面并不能用到。

var obj = {a: 1,b:2,c:3}
with(obj) {
  a = 3;
  b = 4;
  c = 5;
}

其次就是 try/catch,这里的catch分句也会形成一个块作用域。但是这两种算是很少用,也很难用,对于要是使用块作用来讲的话,直到ES6推出了let,const。

let,const 其所在的区域,大部分是{},会自行形成块级作用域,并且

  • 不会出现变量提升
  • 不可重复定义或赋值(const)
  • 形成暂时性死区(不声明,不可直接使用)(TDZ) 大大提升了代码的质量。
{
  console.log( bar ); // ReferenceError! let bar = 2;
}

提升

变量提升是js中非常有趣的一点,在不使用let或const的时候,你要好好分析,变量的执行顺序并适当用到变量提升的知识。

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

来看看这段代码会打印什么吧。答案是2,因为var a 在编译时被提升了,于是后续的赋值操作也能继续,相当于

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

再看一个

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

这里是undefined,还是变量提升,相当于

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

这种场景的思考思路是:包括变量和函数在内的所有声明都会在代码被执行前首先被处理,而这正是词法作用域的核心内容,但是不是单单存储而已。

而它们的声明从代码出现的位置被移动到最上面的过程,叫做“提升”。同样函数作为一等公民也会有同样的作用。

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

注意一点,函数声明会被提升,函数表达式却不会。

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

因为 foo 被提升了。但是运行foo()的话,因为没赋值,不知道foo实际是函数,所以会“foo is not a function。”

函数优先

当出现多个重复的声明时,函数会被优先提升,也就是说但凡同一个声明的变量和函数同时出现,函数的声明始终会覆盖变量, 当然函数声明也可以被后面的函数声明覆盖。

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

这一段实际运行如下

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

var foo 在这里可以理解成是重复声明,会被后面的foo覆盖。如过在执行 foo(),会打印2

foo(); // "b"
var a = true; 
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b");}
}

这里例子有点特殊,if..else的判断条件并不能阻止变量提升,所有最后执行的是后面一个foo。

闭包

这个概念在JS里面算是很重要的一点,早期的时候很多人讲了会将其解释的很深,闭包当然也可以变得很复杂。但按自己的方式来立即的话才是最好的方式。我的理解是,它无非是取到作用域内部变量的渠道。

概念:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这个函数持有所在作用域的引用,这个引用就是闭包

没错,闭包的表现就是一个函数。

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

闭包有几点特征

  • 为创建内部作用域而调用了一个包装函数
  • 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。 形式就是函数嵌套函数。 闭包的出现的场景很多,定时器,事件监听,ajax请求,Web Workers等实际上都是闭包的使用,因为持有引用,所以闭包访问的变量不会被垃圾回收机制回收。但是也因为没办法被回收,当代码里面有大量的闭包时,有可能导致内存泄露。 看个例子,
for (var i=1; i<=5; i++) { 
    setTimeout( function timer() {
    	console.log( i );
    }, i*1000 );
}

老题目了,打印结果是输出5次6,因为延时的回调会在循环结束之后执行,这时候的 i 是全局 i,也就是6。如何做到输出不同的值呢,创建更多的闭包区域,在每个循环中都有自己的一个区域。

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

IIFE配合闭包来处理。通过IIFE形成一个新的作用域,代码执行结束后,每个作用域依然引用着正确的变量。当然ES6更简单

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

let 自成一个块作用域。 好像就是这样了,关于闭包的一些知识。就单纯的基础知识,以上这些足够去认识一个闭包的具体样子了。那更深的应用需要我们在工作中去探索了。

转载自:https://juejin.cn/post/7248890696818901053
评论
请登录