什么是作用域

作用域是一组定义在何处储存变量以及如何访问变量的规则

编译器

javascript 是编译型语言。但是与传统编译型语言不同,它是边编译边执行的。编译型语言一般从源码到执行会经历三个步骤:

  • 分词/词法分析

    将一连串字符串打断成有意义的片段,成为 token(记号)。

  • 解析

    将一个 token 流(数组)转化为一个嵌套元素的树,即抽象语法树(AST)。

  • 代码生成

    将抽象语法树转化为可执行的代码。其实是转化成机器指令。

比如var a = 1的编译过程:

  1. 分词/词法分析: var a = 1这段程序可能会被打断成如下 token:vara=1,空格保留与否得看其是否具有意义。
  2. 解析:将第一步的 token 形成抽象树:大致如下:
    1. 变量声明: {
    2. 标识符: a
    3. 赋值表达式: {
    4. 数字字面量: 1
    5. }
    6. }
  3. 代码生成: 转化成机器命令:创建一个称为 a 的变量,并分配内存,存入一个值为数字 1。

理解作用域

作用域就是通过标识符名称查询变量的一组规则。

代码解析运行中的角色:

  • 引擎

    负责代码的编译和程序的执行。

  • 编译器

    协助引擎,主要负责解析和代码生成。

  • 作用域

    协助引擎,收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行的代码如何访问这些变量强制实施一组严格的规则。

比如var a = 1的运行:

  1. 编译器遇到var a,会首先让作用域去查询 a 是否已经存在,存在则忽略,不存在,则让作用域创建它;
  2. 编译器遇到a = 1,会编译成引擎稍后需要运行的代码;
  3. 引擎执行编译后的代码,会让当前查看是否存在变量a可以访问,存在则引用这个变量,不存在则查看其他其他。

上面过程中,引擎会对变量进行查询,而查询分为 RHS(right-hand Side)查询 和 LHS(left-hand Side)查询,它们根据变量出现在赋值操作的左手边还是右手边来判断查询方式。

  • RHS

    变量在赋值的右手边时采用这种方式查询,查不到会抛出错误 referenceError

  • LHS

    变量在赋值的左手边时采用这种方式查询,在非严格模式下,查不到会再顶层作用域创建这个变量

嵌套的作用域

实际工作中,通常会有多于一个的作用域需要考虑,会存在作用域嵌套在其他作用域中的情况。

嵌套作用域的规则:

从当前作用域开始查找,如果没有,则向上走一级继续查找,以此类推,直至到了最外层全局作用域,无论找到与否,都会停止。

词法作用域

作用域的工作方式一般有俩种模型:词法作用域和动态作用域。javascript 所采用的是词法作用域。

词法分析时

词法作用域是在词法分析时被定义的作用域。

上述定义的潜在含义即:词法作用域是基于写程序时变量和作用域的块儿在何处被编写所决定的。公认的最佳实践是将词法作用域看作是仅仅依靠词法的。

查询变量:

引擎查找标识符时会在当前作用域开始一直向最外层作用域查找,一旦匹配到第一个,作用域查询便停止。

相同名称的标识符可以在嵌套作用域的多个层中被指定,这成为“遮蔽”。

不管函数是从哪里被调用、如何调用,它的词法作用域是由这个函数被声明的位置唯一定义的。

欺骗词法作用域

javascript 提供了在运行时修改词法作用域的机制——with 和 eval,它们会欺骗词法作用域。实际工作中,这种做法并不被推荐,应当尽量避免使用。

欺骗词法作用域会导致更低下的性能。

引擎在编译阶段会对代码做许多优化工作,比如静态地分析代码。但如果代码存在 eval 和 with,导致词法作用域的不固定行为,这一切的优化都有可能毫无意义,所以引擎就会简单地不做任何优化。

  1. eval

eval函数接收一个字符串作为参数,并在运行时将该字符串的内容在当前位置运行。

  1. function foo(str, a) {
  2. eval(str); // 作弊!
  3. console.log(a, b);
  4. }
  5. var b = 2;
  6. foo("var b = 3", 1); //1,3

上面的代码,var b = 3会再 eval 位置运行,从而在 foo 作用域内创建了变量b。当console.log(a,b)调用发生时,引擎会直接访问 foo 作用域内的b,而不会再访问外部的b变量。

注意:使用严格模式,在 eval 中作出的声明不会实际上修改包围他的作用域

  1. with

我们通常使用 with 来引用一个对象的多个属性。

  1. var obj = {
  2. a: 1,
  3. b: 2,
  4. c: 3
  5. };
  6. with (obj) {
  7. a = 3;
  8. b = 4;
  9. c = 5;
  10. }
  11. console.log(obj); //{a: 3, b: 4, c: 5}

但是,with 会做的事,比这要多得多。

  1. var o1 = { a: 3 };
  2. var o2 = { b: 3 };
  3. function foo(obj) {
  4. with (obj) {
  5. a = 2;
  6. }
  7. }
  8. foo(o1);
  9. console.log(o1.a); //2
  10. foo(o2);
  11. console.log(o2.a); // undefined
  12. console.log(a); // 2 全局作用域泄漏

with 语句接受一个对象,并将这个对象视为一个完全隔离的词法作用域

但是 with 块内部的一个普通的var声明并不会归于这个with块儿的作用域,而是归于包含它的函数作用域。

所以,上面代码执行foo(o2)时,在执行到 a = 2 时,引擎会进行 LHS查找,但是一直到最外层都没有找到 a 变量,所以会在最外层创建这个变量,这里就造成了作用域泄漏。

函数与块作用域

javascript 中是不是只能通过函数创建新的作用域,有没有其他方式/结构创建作用域?

函数中的作用域

javascript 拥有基于函数的作用域

函数作用域支持着这样的想法:所有变量都属于函数,而去贯穿整个函数都可以使用或重用(包括嵌套的作用域中)。

这样以来,一个声明出现在作用域何处是无关紧要的。

隐藏标识符于普通作用域

我们可以通过将变量和函数围在一个函数的作用域中来“隐藏”它们。

为什么需要“隐藏”变量和函数?

如果允许外围的作用域访问一个工作的私有细节,不仅没必要,而且可能是危险的。所以软件设计中有一个最低权限原则原则:

最低权限原则:也称“最低授权”/“最少曝光”,在软件设计中,比如一个模块/对象的 API,你应当只暴露所需要的最低限度的东西,而隐藏其他一切。

将变量和函数隐藏可以避免多个同名但用处不同的标识符之间发生无意的冲突,从而导致值被意外的覆盖。

实际可操作的方式:

  1. 全局命名空间

    在引用多个库时,如果他们没有隐藏内部/私有函数和变量,那么它们十分容易出现相互冲突。所以,这些库通常会在全局作用域中使用一个特殊的名称来创建一个单读的变量声明。它经常是一个对象,然后这个对象被用作这个库一个命名空间,所有要暴露出来的功能都会作为属性挂载在这个对象上。

    比如,Jquery 的对象就是 jquery/$;

  2. 模块管理

    实现命名冲突的另一种方式是模块管理。

函数作为作用域

声明一个函数,可以拿来隐藏函数和变量,但这种方式同时也存在着问题:

  • 不得不声明一个命名函数,这个函数的标识符名称本身就污染了外围作用域
  • 不得不通过名称明确地调用这个函数

不需要名称,又能自动执行的,js 恰好提供了这样一种方式。

  1. (function(){
  2. ...
  3. })()

上面的代码使用了匿名函数和立即调用函数表达式:

  1. 匿名函数

函数表达式可以匿名,函数声明不能匿名。

匿名函数的缺点:

  • 在栈中没有有用的名称可以表示,调试困难;
  • 想要递归自己(arguments.callee)或者解绑事件处理器变得麻烦
  • 更不易代码阅读

最佳的方式总是命名你的函数表达式。

  1. 立即调用函数表达式

通过一个(),我们可以将函数作为表达式。末尾再加一个括号可以执行这个函数表达式。这种模式被成为 IIFE(立即调用函数表达式;Immediately Invoked Function Expression)

块作为作用域

大部门语言都支持块级作用域,从而将信息隐藏到我们的代码块中,块级作用域是一种扩展了最低权限原则的工具。

但是,表面上看来 javascript 没有块级作用域。

  1. for (var i = 0; i < 10; i++) {
  2. console.log(i);
  3. }
  4. console.log(i); // 10 变量i被划入了外围作用域中
  1. if (true) {
  2. var bar = 9;
  3. console.log(bar); //9
  4. }
  5. console.log(bar); //9 // 变量bar被划入了外围作用域中

但也有特殊情况:

  • with

    它从对象中创建的作用域仅存在于这个 with 语句的生命周期中。

  • try/catch

    ES3 明确指出 try/catch 中的 cathc 子语句中声明的变量,是属于 catch 块的块级作用域。

    1. try {
    2. var a = 1;
    3. } catch (e) {
    4. var c = 2;
    5. }
    6. console.log(a); //1
    7. console.log(c); //undefined
  • let/const

    let 将变量声明依附在它所在的块儿(通常是{...})作用域中。

    • 隐含使用现存得块儿
    1. if (true) {
    2. let bar = 1;
    3. console.log(bar); //1
    4. }
    5. console.log(bar); // ReferenceError
    • 创建明确块儿
    1. if (true) {
    2. {
    3. // 明确的块儿
    4. let bar = 1;
    5. console.log(bar); //1
    6. }
    7. }
    8. console.log(bar); // ReferenceError

    const 也创建一个块级作用域,但是它的值是固定的(常量)。

    注意: let/const 声明不进行变量提升。

块级作用域的用处:

  1. 垃圾回收

    可以处理闭包和释放内存的垃圾回收。

    1. function process() {
    2. // do something
    3. }
    4. var bigData = {...}; // 大体量数据
    5. process(bigData);
    6. var btn = document.getElementById('btn');
    7. btn.addEventListener("click",function(e){
    8. console.log('btn click');
    9. })

    点击事件的回调函数根本不需要 bigData 这个大体量数据。理论上讲,在执行完 process 函数后,这个消耗巨大内存的数据结构应该被作为垃圾而回收。然而因为 click 函数在整个函数作用域上拥有一个闭包,bigData 将会仍然保持一段事件。

    块级作用域可以解决这个问题:

    1. function process() {
    2. // do something
    3. }
    4. {
    5. let bigData = {...}; // 大体量数据
    6. process(bigData);
    7. }
    8. var btn = document.getElementById('btn');
    9. btn.addEventListener("click",function(e){
    10. console.log('btn click');
    11. })
  2. 循环

    对每一次循环的迭代重新绑定。

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

    也可以这样:

    1. {
    2. let j;
    3. for (j = 0; i < 10; i++) {
    4. let i = j; // 每次迭代重新绑定
    5. console.log(i);
    6. }
    7. }

提升

函数作用域还是块级作用域的行为都依赖于一个相同的规则: 在一个作用域中声明的任何变量都附着在这个作用域上。

但是出现一个作用域内各种位置的声明如何依附作用域?

先有鸡还是先有蛋?

我们倾向于认为代码是自上而下地被解释执行的。这大致上是对的,但也有一部分并非如此。

  1. a = 2;
  2. var a;
  3. console.log(a); // 2

如果代码自上而下的解释运行,预期应该输出 undefined ,因为 var aa = 2 之后,应该重新定义了变量 a。显然,结果并不是如此。

  1. console.log(a); // undefined
  2. var a = 2;

从上面的例子上,你也许会猜测这里会输出 2,或者认为这里会导致一个 ReferenceError 被抛出。不幸的是,结果却是 undefined。

代码究竟如何执行,是先有声明还是赋值?

编译器再次袭来

我们知道,引擎在 javascript 执行代码之前会先对代码进行编译,编译的其中一个工作就是找到所有的声明,并将它关联在合适的作用域上。

所以,在我们的代码被执行前,所有的声明,包括变量和函数,都会被首先处理。

对于var a = 2,我们认为是一个语句,但 javascript 实际上认为这是俩个语句:var aa = 2。第一句(声明)会在编译阶段处理,第二句(赋值)会在执行阶段处理。

知道了这些,我想对于上一节的疑惑也就迎刃而解了:先有声明,后有赋值

注意:提升是以作用域为单位的

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

  1. foo(); // 1
  2. goo(); // TypeError
  3. function foo() {
  4. console.log(1);
  5. }
  6. var goo = function() {
  7. console.log(2);
  8. };

变量 goo 被提升了,但表达式没有,所以调用 goo 时,goo 的值为 undefined。所以会报 TypeError。

函数优先

函数声明和变量都会提升。但是函数享有更高的优先级。

  1. console.log(typeof foo); // function
  2. var foo = 2;
  3. function foo() {
  4. console.log(1);
  5. }

从上面代码可以看出,结果输出 function 而不是 undefined 。说明函数声明优先于变量。

重复声明,后面的会覆盖前面的。

作用域闭包

必须要对作用域有健全和坚实的理解才能理解闭包。

启蒙

在 javascript 中闭包无处不在,你只是必须认出它并接纳它。它是依赖于词法作用域编写代码而产生的结果。

事实真相

闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在他的词法作用域之外执行时

  1. function foo() {
  2. var a = 2;
  3. function bar() {
  4. console.log(2);
  5. }
  6. bar();
  7. }

这种形式算闭包吗?技术上算,它实现了闭包,函数 bar 在函数 foo 的作用域上有一个闭包,即 bar 闭住了 foo 的作用域。但是在上面代码中并不是可以严格地观察到。

  1. function foo() {
  2. var a = 2;
  3. function bar() {
  4. console.log(2);
  5. }
  6. return bar;
  7. }
  8. var baz = foo();
  9. baz(); //2 这样使用才算真正意义上的闭包

bar 对于 foo 内的作用域拥有此法作用域访问权,当我们调用 foo 之后返回 bar 的引用。按理来说,foo 执行过后,我们一般会期望 foo 的整个内部作用域消失,因为垃圾回收机制会自动回收不再使用的内存。但 bar 拥有一个词法作用域的闭包,覆盖着 foo 的内部作用域,闭包为了能使 bar 在以后的任意时刻可以引用这个作用域而保持的它的存在。

所以,bar 在词法作用域之外依然拥有对那个作用域的引用,这个引用称为闭包。

闭包使一个函数可以继续访问它在编写时被定义的词法作用域。

  1. var a = 2;
  2. function bar() {
  3. console.log(a);
  4. }
  5. function foo(fn) {
  6. fn(); // 发现闭包!
  7. }
  8. foo(bar);

上面的代码,函数作为参数被传递,实际上这也是一种观察/使用闭包的例子。

无论我们使用什么方法将一个函数传送到它的词法作用域之外,它都将维护一个指向它被声明时的作用域的引用。

循环 + 闭包

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

这段代码的预期是每隔一秒分别打印数字:1,2,3,4,5。但是我们执行后发现结果一共输出了 5 次 6。

为什么达不到预期的效果?

定时器的回调函数会在循环完成之后执行(详见事件循环机制)。而 for 不是块级作用域,所以每次执行 timer 函数的时候,它们的闭包都在全局作用域上。而此时全局作用域环境中的变量 i 的值为 6。

我们的代码缺少了什么?

因为每一个 timer 函数执行的时候都是使用全局作用域,所以访问的变量必然是一致的,所以想要达到预期的结果,我们必须为每一个 timer 函数创建一个私有作用域,并在这个私有作用域内存在一个可供回调函数访问的变量。现在我们来改写一下:

  1. for (var i = 1; i <= 5; i++) {
  2. (function() {
  3. let j = i;
  4. setTimeout(function() {
  5. console.log(j); // 1,2,3,4,5
  6. }, i * 1000);
  7. })();
  8. }

我们使用 IIFE 为每次迭代创建新的作用域,并且保存每次迭代需要的值。

其实这里主要用到的原理是使用块级作用域,所以,理论上还有其他方式可以实现,比如:with,try/catch,let/const,大家都可以尝试下哦。

模块

模块也利用了闭包的力量。

  1. function coolModule() {
  2. var something = "cool";
  3. function doSomething() {
  4. console.log(something);
  5. }
  6. return {
  7. doSomething: doSomething
  8. };
  9. }
  10. var foo = coolModule()
  11. foo.doSomething() // cool

YDKJS:作用域与闭包的更多相关文章

  1. js闭包的作用域以及闭包案列的介绍:

    转载▼ 标签: it   js闭包的作用域以及闭包案列的介绍:   首先我们根据前面的介绍来分析js闭包有什么作用,他会给我们编程带来什么好处? 闭包是为了更方便我们在处理js函数的时候会遇到以下的几 ...

  2. 剖析JavaScript函数作用域与闭包

    在我们写代码写到一定阶段的时候,就会想深究一下js,javascript是一种弱类型的编程语言,而js中一个最为重要的概念就是执行环境,或者说作用域.作用域重要性体现在哪呢?首先,函数在执行时会创建作 ...

  3. JS教程:词法作用域和闭包 (网络资源)

    varclassA = function(){ ; } classA.prototype.func1 = function(){ var that = this, ; function a(){ re ...

  4. 《你不知道的JavaScript》第一部分:作用域和闭包

    第1章 作用域是什么 抛出问题:程序中的变量存储在哪里?程序需要时,如何找到它们? 设计 作用域 的目的:为了更好地存储和访问变量. 作用域:根据名称查找变量的一套规则,用于确定在何处以及如何查找变量 ...

  5. 你不知道的JavaScript(作用域和闭包)

    作用域和闭包 ・作用域 引擎:从头到尾负责整个JavaScript的编译及执行过程. 编译器:负责语法分析及代码生成等. 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非 ...

  6. JavaScript 函数作用域和闭包

    函数作用域和闭包  词法作用域   它们在定义它们的作用域里运行,而不是在执行的作用域运行,但是只有在运行时,作用域链中的属性才被 定义(调用对象),此时,可访问任何当前的绑定.   调用对象     ...

  7. 《JavaScript 闯关记》之作用域和闭包

    作用域和闭包是 JavaScript 最重要的概念之一,想要进一步学习 JavaScript,就必须理解 JavaScript 作用域和闭包的工作原理. 作用域 任何程序设计语言都有作用域的概念,简单 ...

  8. 原来JS是这样的 - 提升, 作用域 与 闭包

    引子 长久以来一直都没有专门学过 JS ,因为之前有自己啃过 C++ ,又打过一段时间的算法竞赛(写得一手好意大利面条),于是自己折腾自己的网站的时候,一直都把 JS 当 C 写.但写的时候总会遇到一 ...

  9. javascript作用域和闭包之我见

    javascript作用域和闭包之我见 看了<你不知道的JavaScript(上卷)>的第一部分--作用域和闭包,感受颇深,遂写一篇读书笔记加深印象.路过的大牛欢迎指点,对这方面不懂的同学 ...

随机推荐

  1. SpringBoot里mybatis查询结果为null的列不返回问题的解决方案

    对于mybatis里查询结果为null的列不返回的问题解决方案 在配置文件application.properties里增加 Mybatis.configuration.call-setters-on ...

  2. 原生js版分页插件

    之前我在自己的博客里发表了一篇用angularJs自定义指令实现的分页插件,今天简单改造了一下,改成了原生JavaScript版本的分页插件,可以自定义一些简单配置,特此记录下来.如有不足之处,欢迎指 ...

  3. 腾讯WeTest《2017中国移动游戏质量白皮书》开放预约,再为国内手游把把脉

    产品为王,质量先行.如果说2016年是爆款手游相继崛起的一年,那么2017年则更像是打磨精品.建立生态的高手切磋之年.守住一个游戏的质量生命线,方能建立健康生态,方能在如火如荼的行业竞争中角逐到最后. ...

  4. 【python】函数返回值

  5. Struts2学习---拦截器+struts的工作流程+struts声明式异常处理

    这一节我们来看看拦截器,在讲这个之前我是准备先看struts的声明式异常处理的,但是我发现这个声明式异常处理就是由拦截器实现的,所以就将拦截器的内容放到了前面. 这一节的内容是这样的: 拦截器的介绍 ...

  6. URL加载页面的过程

    总体过程: 1.DNS解析 2.TCP连接 3.发送HTTP请求 4.服务器处理请求并返回HTTP报文 5.浏览器解析渲染页面 6.连接结束 一.DNS解析 在互联网中,每一台机计算机的唯一 标识是他 ...

  7. 重温javascript数据类型

    在javaScript中,有五种简单的数据类型,分别是 Undefined Null Boolean Number String 还有一种复杂的数据类型object,object本质是有一组无序的名值 ...

  8. centOS7 mini配置linux服务器(五) 安装和配置tomcat和mysql

    配置java运行环境,少不了服务器这一块,而tomcat在服务器中占据了很大一部分份额,这里就简单记录下tomcat安装步骤. 下载 首先需要下载tomcat7的安装文件,地址如下: http://t ...

  9. 正则表达式与grep

    一.回溯引用 1.将页面中合法的标题找出来,使用回溯引用匹配 (需要使用 -E 或 -P 来扩展grep语法支持) 2.查找连续出现的单词 二.前后查找 (grep 只能使用 -P 选项) 1. 向前 ...

  10. css scroll bug

    滚动区域不能设置overflow var doc = $(document), win = $(window), h = $("#head"), b = $("#body ...