面试官:说说做用域和闭包吧

几乎全部语言的最基础模型之一就是在变量中存储值,而且在稍后取出或修改这些值。在变量中存储值和取出值的能力,给程序赋予了状态。这就引申出两个问题:这些变量被存储在哪里?程序如何在须要的时候找到它们?回答这些问题须要一组明肯定义的规则,它定义了如何存储变量,以及如何找到这些变量。咱们称这组规则为:做用域。javascript

LHS 和 RHS 查询

在说 javascript 中的做用域以前,我想应该先了解一下 LHS 和 RHS 查询,这对于做用域的理解有所帮助。 前端

虽然 javascript 被认为是一门解释型语言/动态语言,可是它实际上是一种编译型的语言。通常来讲,须要运行一段 javascript 代码,有两个必不可少的东西:JS 引擎编译器。前者相似于总管的角色,负责整个程序运行时所需的各类资源的调度;后者只是前者的一部分,负责将 javascript 源码编译成机器能识别的机器指令,而后交给引擎运行。java

编译

javascript 中,一段源码在被执行以前大概会经历如下三个步骤,这也被称之为 编译node

  1. 分词 / 词法分析:编译器会先将一连串字符打断成(对于语言来讲)有意义的片断,称为 token(记号),例如 var a = 2;。这段程序极可能会被打断成以下 token:vara=2,和 ;
  2. 解析 / 语法分析:编译器将一个 token 的流(数组)转换为一个“抽象语法树”(AST —— Abstract Syntax Tree),它表示了程序的语法结构。
  3. 代码生成:编译器将上一步中生成的抽象语法树转换为机器指令,等待引擎执行。

执行

编译器一顿操做猛如虎,生成了一堆机器指令,JS 引擎开心地拿到这堆指令,开始执行,这个时候咱们要说的 LHSRHS 就登场了。jquery

LHS (Left-hand Side)RHS (Right-hand Side) ,是在代码执行阶段 JS 引擎操做变量的两种方式,两者区别就是对变量的查询目的是 变量赋值 仍是 查询webpack

LHS 能够理解为变量在赋值操做符(=)的左侧,例如 a = 1,当前引擎对变量 a 查找的目的是变量赋值。这种状况下,引擎不关心变量 a 原始值是什么,只管将值 1 赋给 a 变量。git

RHS 能够理解为变量在赋值操做符(=)的右侧,例如:console.log(a),其中引擎对变量a的查找目的就是 查询,它须要找到变量 a 对应的实际值是什么,而后才能将它打印出来。es6

来看下面这段代码:github

var a = 2; // LHS 查询
复制代码

这段代码运行时,引擎作了一个 LHS 查询,找到 a ,并把新值 2 赋给它。再看下面一段:web

function foo(a) { // LHS 查询
  console.log( a ); // RHS 查询
}

foo( 2 ); // RHS 查询
复制代码

为了执行它,JS 引擎既作了 LHS 查询又作了 RHS 查询,只不过这里的 LHS 比较难发现。

总之,引擎想对变量进行获取 / 赋值,就离不开 LHSRHS ,然而这两个操做只是手段,到哪里去获取变量才是关键。LHSRHS 获取变量的位置就是 做用域

什么是做用域

简单来讲,做用域 指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。

javascript 中大部分状况下,只有两种做用域类型:

  • 全局做用域:全局做用域为程序的最外层做用域,一直存在。
  • 函数做用域:函数做用域只有函数被定义时才会建立,包含在父级函数做用域 / 全局做用域内。

因为做用域的限制,每段独立的执行代码块只能访问本身做用域和外层做用域中的变量,没法访问到内层做用域的变量。

/* 全局做用域开始 */
var a = 1;

function func () { /* func 函数做用域开始 */
  var a = 2;
  console.log(a);
}                  /* func 函数做用域结束 */

func(); // => 2

console.log(a); // => 1

/* 全局做用域结束 */
复制代码

做用域链

上面代码示范中,可执行代码块是可以在本身的做用域中找到变量的,那么若是在本身的做用域中找不到目标变量,程序可否正常运行?来看下面的代码:

function foo(a) {
  var b = a * 2;

  function bar(c) {
    console.log( a, b, c );
  }

  bar(b * 3);
}

foo(2); // 2 4 12
复制代码

结合前面的知识咱们知道,在 bar 函数内部,会作三次 RHS 查询从而分别获取到 a b c 三个变量的值。bar 内部做用域中只能获取到变量 c 的值,ab 都是从外部 foo 函数的做用域中获取到的。

当可执行代码内部访问变量时,会先查找本地做用域,若是找到目标变量即返回,不然会去父级做用域继续查找...一直找到全局做用域。咱们把这种做用域的嵌套机制,称为 做用域链。

用图片表示,上述代码一共有三层做用域嵌套,分别是:

  1. 全局做用域
  2. foo 做用域
  3. bar 做用域

须要注意,函数参数也在函数做用域中。

词法做用域

明白了做用域和做用域链的概念,咱们来看词法做用域。

词法做用域Lexical Scopes)是 javascript 中使用的做用域类型,词法做用域 也能够被叫作 静态做用域,与之相对的还有 动态做用域。那么 javascript 使用的 词法做用域动态做用域 的区别是什么呢?看下面这段代码:

var value = 1;

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

function bar() {
  var value = 2;
  foo();
}

bar();

// 结果是 ???
复制代码

上面这段代码中,一共有三个做用域:

  • 全局做用域
  • foo 的函数做用域
  • bar 的函数做用域

一直到这边都好理解,但是 foo 里访问了本地做用域中没有的变量 value 。根据前面说的,引擎为了拿到这个变量就要去 foo 的上层做用域查询,那么 foo 的上层做用域是什么呢?是它 调用时 所在的 bar 做用域?仍是它 定义时 所在的全局做用域?

这个关键的问题就是 javascript 中的做用域类型——词法做用域。

词法做用域,就意味着函数被定义的时候,它的做用域就已经肯定了,和拿到哪里执行没有关系,所以词法做用域也被称为 “静态做用域”。

若是是动态做用域类型,那么上面的代码运行结果应该是 bar 做用域中的 2 。也许你会好奇什么语言是动态做用域?bash 就是动态做用域,感兴趣的小伙伴能够了解一下。

块级做用域

什么是块级做用域呢?简单来讲,花括号内 {...} 的区域就是块级做用域区域。

不少语言自己都是支持块级做用域的。上面咱们说,javascript 中大部分状况下,只有两种做用域类型:全局做用域函数做用域,那么 javascript 中有没有块级做用域呢?来看下面的代码:

if (true) {
  var a = 1;
}

console.log(a); // 结果???
复制代码

运行后会发现,结果仍是 1,花括号内定义并赋值的 a 变量跑到全局了。这足以说明,javascript 不是原生支持块级做用域的,起码创做者创造这门语言的时候压根就没把块级做用域的事情考虑进去...(出来背锅!!)

可是 ES6 标准提出了使用 letconst 代替 var 关键字,来“建立块级做用域”。也就是说,上述代码改为以下方式,块级做用域是有效的:

if (true) {
  let a = 1;
}

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

关于 letconst 的更多细节,进入 传送门

建立做用域

javascript 中,咱们有几种建立 / 改变做用域的手段:

  1. 定义函数,建立函数做用(推荐):

    function foo () {
      // 建立了一个 foo 的函数做用域
    }
    复制代码
  2. 使用 letconst 建立块级做用域(推荐):

    for (let i = 0; i < 5; i++) {
      console.log(i);
    }
    
    console.log(i); // ReferenceError
    复制代码
  3. try catch 建立做用域(不推荐),err 仅存在于 catch 子句中:

    try {
     undefined(); // 强制产生异常
    }
    catch (err) {
     console.log( err ); // TypeError: undefined is not a function
    }
    
    console.log( err ); // ReferenceError: `err` not found
    复制代码
  4. 使用 eval “欺骗” 词法做用域(不推荐):

    function foo(str, a) {
     eval( str );
     console.log( a, b );
    }
    
    var b = 2;
    
    foo( "var b = 3;", 1 ); // 1 3
    复制代码
  5. 使用 with 欺骗词法做用域(不推荐):

    function foo(obj) {
     with (obj) {
       a = 2;
     }
    }
    
    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 -- 全局做用域被泄漏了!
    复制代码

总结下来,可以使用的建立做用域的方式就两种:定义函数建立 和 let const 建立。

做用域的应用场景

做用域的一个常见运用场景之一,就是 模块化

因为 javascript 并未原生支持模块化致使了不少使人口吐芬芳的问题,好比全局做用域污染和变量名冲突,代码结构臃肿且复用性不高。在正式的模块化方案出台以前,开发者为了解决这类问题,想到了使用函数做用域来建立模块的方案。

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

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

module1(); // => 1
module2(); // => 2
复制代码

上面的代码中,构建了 module1module2 两个表明模块的函数,两个函数内分别定义了一个同名变量 a ,因为函数做用域的隔离性质,这两个变量被保存在不一样的做用域中(不嵌套),JS 引擎在执行这两个函数时会去不一样的做用域中读取,而且外部做用域没法访问到函数内部的 a 变量。这样一来就巧妙地解决了 全局做用域污染变量名冲突 的问题;而且,因为函数的包裹写法,这种方式看起来封装性好多了。

然而上面的函数声明式写法,看起来仍是有些冗余,更重要的是,module1module2 的函数名自己就已经对全局做用域形成了污染。咱们来继续改写:

// module1.js
(function () {
  var a = 1;
  console.log(a);
})();

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

将函数声明改写成 当即调用函数表达式(Immediately Invoked Function Expression 简写 IIFE),封装性更好,代码也更简洁,解决了模块名污染全局做用域的问题。

函数声明和函数表达式,最简单的区分方法,就是看是否是 function 关键字开头:是 function 开头的就是函数声明,不然就是函数表达式。

上面的代码采用了 IIFE 的写法,已经进化不少了,咱们能够再把它强化一下,强化成后浪版,赋予它判断外部环境的权利——选择的权力

(function (global) {
  if (global...) {
    // is browser
  } else if (global...) {
    // is nodejs
  }
})(window);
复制代码

让后浪继续奔涌,咱们的想象力不足以想象 UMD 模块化的代码:

// UMD 模块化
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS-like
    module.exports = factory(require('jquery'));
  } else {
    // Browser globals (root is window)
    root.returnExports = factory(root.jQuery);
  }
}(this, function ($) {
  // methods
  function myFunc(){};

  // exposed public method
  return myFunc;
}));
复制代码

我看着做用域的模块化应用场景,真的是满怀羡慕。若是你也和我同样羡慕而且,想了解更多关于模块化的东西,请进入 传送门

闭包

说完了做用域,咱们来讲说 闭包

可以访问其余函数内部变量的函数,被称为 闭包

上面这个定义比较难理解,简单来讲,闭包就是函数内部定义的函数,被返回了出去并在外部调用。咱们能够用代码来表述一下:

function foo() {
  var a = 2;

  function bar() {
    console.log( a );
  }

  return bar;
}

var baz = foo();

baz(); // 这就造成了一个闭包
复制代码

咱们能够简单剖析一下上面代码的运行流程:

  1. 编译阶段,变量和函数被声明,做用域即被肯定。
  2. 运行函数 foo(),此时会建立一个 foo 函数的执行上下文,执行上下文内部存储了 foo 中声明的全部变量函数信息。
  3. 函数 foo 运行完毕,将内部函数 bar 的引用赋值给外部的变量 baz ,此时 baz 指针指向的仍是 bar ,所以哪怕它位于 foo 做用域以外,它仍是可以获取到 foo 的内部变量。
  4. baz 在外部被执行,baz 的内部可执行代码 console.log 向做用域请求获取 a 变量,本地做用域没有找到,继续请求父级做用域,找到了 foo 中的 a 变量,返回给 console.log,打印出 2

闭包的执行看起来像是开发者使用的一个小小的 “做弊手段” ——绕过了做用域的监管机制,从外部也能获取到内部做用域的信息。闭包的这一特性极大地丰富了开发人员的编码方式,也提供了不少有效的运用场景。

闭包的应用场景

闭包的应用,大多数是在须要维护内部变量的场景下。

单例模式

单例模式是一种常见的涉及模式,它保证了一个类只有一个实例。实现方法通常是先判断实例是否存在,若是存在就直接返回,不然就建立了再返回。单例模式的好处就是避免了重复实例化带来的内存开销:

// 单例模式
function Singleton(){
  this.data = 'singleton';
}

Singleton.getInstance = (function () {
  var instance;
    
  return function(){
    if (instance) {
      return instance;
    } else {
      instance = new Singleton();
      return instance;
    }
  }
})();

var sa = Singleton.getInstance();
var sb = Singleton.getInstance();
console.log(sa === sb); // true
console.log(sa.data); // 'singleton'
复制代码

模拟私有属性

javascript 没有 java 中那种 public private 的访问权限控制,对象中的所用方法和属性都可以访问,这就形成了安全隐患,内部的属性任何开发者均可以随意修改。虽然语言层面不支持私有属性的建立,可是咱们能够用闭包的手段来模拟出私有属性:

// 模拟私有属性
function getGeneratorFunc () {
  var _name = 'John';
  var _age = 22;
    
  return function () {
    return {
      getName: function () {return _name;},
      getAge: function() {return _age;}
    };
  };
}

var obj = getGeneratorFunc()();
obj.getName(); // John
obj.getAge(); // 22
obj._age; // undefined
复制代码

柯里化

柯里化(currying),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,而且返回接受余下的参数并且返回结果的新函数的技术。

这个概念有点抽象,实际上柯里化是高阶函数的一个用法,javascript 中常见的 bind 方法就能够用柯里化的方法来实现:

Function.prototype.myBind = function (context = window) {
    if (typeof this !== 'function') throw new Error('Error');
    let selfFunc = this;
    let args = [...arguments].slice(1);
    
    return function F () {
        // 由于返回了一个函数,能够 new F(),因此须要判断
        if (this instanceof F) {
            return new selfFunc(...args, arguments);
        } else  {
            // bind 能够实现相似这样的代码 f.bind(obj, 1)(2),因此须要将两边的参数拼接起来
            return selfFunc.apply(context, args.concat(arguments));
        }
    }
}
复制代码

柯里化的优点之一就是 参数的复用,它能够在传入参数的基础上生成另外一个全新的函数,来看下面这个类型判断函数:

function typeOf (value) {
    return function (obj) {
        const toString = Object.prototype.toString;
        const map = {
            '[object Boolean]'	 : 'boolean',
            '[object Number]' 	 : 'number',
            '[object String]' 	 : 'string',
            '[object Function]'  : 'function',
            '[object Array]'     : 'array',
            '[object Date]'      : 'date',
            '[object RegExp]'    : 'regExp',
            '[object Undefined]' : 'undefined',
            '[object Null]'      : 'null',
            '[object Object]' 	 : 'object'
        };
        return map[toString.call(obj)] === value;
    }
}

var isNumber = typeOf('number');
var isFunction = typeOf('function');
var isRegExp = typeOf('regExp');

isNumber(0); // => true
isFunction(function () {}); // true
isRegExp({}); // => false
复制代码

经过向 typeOf 里传入不一样的类型字符串参数,就能够生成对应的类型判断函数,做为语法糖在业务代码里重复使用。

闭包的问题

从上面的介绍中咱们能够得知,闭包的使用场景很是普遍,那咱们是否是能够大量使用闭包呢?不能够,由于闭包过分使用会致使性能问题,仍是看以前演示的一段代码:

function foo() {
  var a = 2;

  function bar() {
    console.log( a );
  }

  return bar;
}

var baz = foo();

baz(); // 这就造成了一个闭包
复制代码

乍一看,好像没什么问题,然而,它却有可能致使 内存泄露

咱们知道,javascript 内部的垃圾回收机制用的是引用计数收集:即当内存中的一个变量被引用一次,计数就加一。垃圾回收机制会以固定的时间轮询这些变量,将计数为 0 的变量标记为失效变量并将之清除从而释放内存。

上述代码中,理论上来讲, foo 函数做用域隔绝了外部环境,全部变量引用都在函数内部完成,foo 运行完成之后,内部的变量就应该被销毁,内存被回收。然而闭包致使了全局做用域始终存在一个 baz 的变量在引用着 foo 内部的 bar 函数,这就意味着 foo 内部定义的 bar 函数引用数始终为 1,垃圾运行机制就没法把它销毁。更糟糕的是,bar 有可能还要使用到父做用域 foo 中的变量信息,那它们天然也不能被销毁... JS 引擎没法判断你何时还会调用闭包函数,只能一直让这些数据占用着内存。

这种因为闭包使用过分而致使的内存占用没法释放的状况,咱们称之为:内存泄露。

内存泄露

内存泄露 是指当一块内存再也不被应用程序使用的时候,因为某种缘由,这块内存没有返还给操做系统或者内存池的现象。内存泄漏可能会致使应用程序卡顿或者崩溃。

形成内存泄露的缘由有不少,除了闭包之外,还有 全局变量的无心建立。开发者的本意是想将变量做为局部变量使用,然而忘记写 var 致使变量被泄露到全局中:

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

foo(); // 2

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

还有 DOM 的事件绑定,移除 DOM 元素前若是忘记了注销掉其中绑定的事件方法,也会形成内存泄露:

const wrapDOM = document.getElementById('wrap');
wrapDOM.onclick = function (e) {console.log(e);};

// some codes ...

// remove wrapDOM
wrapDOM.parentNode.removeChild(wrapDOM);
复制代码

内存泄露的排查手段

可能你们都听过臭名昭著的 “内存泄露”,然而面对茫茫祖传代码,如何找到形成内存泄露的地方,却让人无从下手。这边咱们仍是借助谷歌的开发者工具, Chrome 浏览器,F12 打开开发者工具,我找了阮一峰老师的 ES6 网站演示。

Performance

点击这个按钮启动记录,而后切换到网页进行操做,录制完成后点击 stop 按钮,开发者工具会从录制时刻开始记录当前应用的各项数据状况。

选中JS Heap,下面展示出来的一条蓝线,就是表明了这段记录过程当中,JS 堆内存信息的变化状况。

有大佬说,根据这条蓝线就能够判断是否存在内存泄漏的状况:若是这条蓝线一直成上升趋势,那基本就是内存泄漏了。其实我以为这么讲有失偏颇,JS 堆内存占用率上升并不必定就是内存泄漏,只能说明有不少未被释放的内存而已,至于这些内存是否真的在使用,仍是说确实是内存泄漏,还须要进一步排查。

memory

借助开发者工具的 Memory 选项,能够更精确地定位内存使用状况。

当生成了第一个快照的时候,开发者工具窗口已经显示了很详细的内存占用状况。

字段解释:

  • Constructor — 占用内存的资源类型
  • Distance — 当前对象到根的引用层级距离
  • Shallow Size — 对象所占内存(不包含内部引用的其它对象所占的内存)(单位:字节)
  • Retained Size — 对象所占总内存(包含内部引用的其它对象所占的内存)(单位:字节)

将每项展开能够查看更详细的数据信息。

咱们再次切回网页,继续操做几回,而后再次生成一个快照。

这边须要特别注意这个 #Delta ,若是是正值,就表明新生成的内存多,释放的内存少。其中的闭包项,若是是正值,就说明存在内存泄漏。

下面咱们到代码里找一个内存泄漏的问题:

内存泄露的解决方案

  1. 使用严格模式,避免不经意间的全局变量泄露:

    "use strict";
    
    function foo () {
    	b = 2;
    }
    
    foo(); // ReferenceError: b is not defined
    复制代码
  2. 关注 DOM 生命周期,在销毁阶段记得解绑相关事件:

    const wrapDOM = document.getElementById('wrap');
    wrapDOM.onclick = function (e) {console.log(e);};
    
    // some codes ...
    
    // remove wrapDOM
    wrapDOM.onclick = null;
    wrapDOM.parentNode.removeChild(wrapDOM);
    复制代码

    或者可使用事件委托的手段统一处理事件,减小因为事件绑定带来的额外内存开销:

    document.body.onclick = function (e) {
        if (isWrapDOM) {
            // ...
        } else {
            // ...
        }
    }
    复制代码
  3. 避免过分使用闭包。

大部分的内存泄漏仍是因为代码不规范致使的。代码千万条,规范第一条,代码不规范,开发两行泪。

总结

  1. javascript 语言层面只原生支持两种做用域类型:全局做用域函数做用域 。全局做用域程序运行就有,函数做用域只有定义函数的时候才有,它们之间是包含的关系。
  2. 做用域之间是能够嵌套的,咱们把这种嵌套关系称为 做用域链
  3. 可执行代码在做用域中查询变量时,只能查询 本地做用域上层做用域,不能查找内部的函数做用域。JS 引擎搜索变量时,会先询问本地做用域,找到即返回,找不到再去询问上层做用域...层层往上,直到全局做用域。
  4. javascript 中使用的是 “词法做用域”,所以函数做用域的范围在函数定义时就已经被肯定,和函数在哪执行没有关系。
  5. 有权访问另外一个函数内部变量的函数,咱们称为 闭包闭包的本质是利用了做用域的机制,来达到外部做用域访问内部做用域的目的。
  6. 闭包的使用场景很是普遍,然而过分使用会致使闭包内的变量所占用的内存空间没法释放,带来 内存泄露 的问题。
  7. 咱们能够借助于 chrome 开发者工具查找代码中致使了内存泄露的代码。
  8. 避免内存泄露的几种方法:避免使用全局变量、谨慎地为DOM 绑定事件、避免过分使用闭包。最重要的,仍是代码规范。 😃

本篇文章已收录入 前端面试指南专栏

相关参考

往期内容推荐

  1. 完全弄懂节流和防抖
  2. 【基础】HTTP、TCP/IP 协议的原理及应用
  3. 【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构
  4. 浏览器下的 Event Loop
  5. 面试官:说说执行上下文吧
  6. 面试官:说说原型链和继承吧
  7. 面试官:说说 JS 中的模块化吧
  8. 面试官:说说 let 和 const 吧
相关文章
相关标签/搜索