几乎全部语言的最基础模型之一就是在变量中存储值,而且在稍后取出或修改这些值。在变量中存储值和取出值的能力,给程序赋予了状态。这就引申出两个问题:这些变量被存储在哪里?程序如何在须要的时候找到它们?回答这些问题须要一组明肯定义的规则,它定义了如何存储变量,以及如何找到这些变量。咱们称这组规则为:做用域。javascript
在说 javascript 中的做用域以前,我想应该先了解一下 LHS 和 RHS 查询,这对于做用域的理解有所帮助。 前端
虽然 javascript
被认为是一门解释型语言/动态语言,可是它实际上是一种编译型的语言。通常来讲,须要运行一段 javascript
代码,有两个必不可少的东西:JS 引擎 和 编译器。前者相似于总管的角色,负责整个程序运行时所需的各类资源的调度;后者只是前者的一部分,负责将 javascript
源码编译成机器能识别的机器指令,而后交给引擎运行。java
在 javascript
中,一段源码在被执行以前大概会经历如下三个步骤,这也被称之为 编译:node
var a = 2;
。这段程序极可能会被打断成以下 token:var
,a
,=
,2
,和 ;
。token
的流(数组)转换为一个“抽象语法树”(AST —— Abstract Syntax Tree
),它表示了程序的语法结构。编译器一顿操做猛如虎,生成了一堆机器指令,JS 引擎开心地拿到这堆指令,开始执行,这个时候咱们要说的 LHS
和 RHS
就登场了。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
比较难发现。
总之,引擎想对变量进行获取 / 赋值,就离不开 LHS
和 RHS
,然而这两个操做只是手段,到哪里去获取变量才是关键。LHS
和 RHS
获取变量的位置就是 做用域。
简单来讲,做用域 指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。
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
的值,a
和 b
都是从外部 foo
函数的做用域中获取到的。
当可执行代码内部访问变量时,会先查找本地做用域,若是找到目标变量即返回,不然会去父级做用域继续查找...一直找到全局做用域。咱们把这种做用域的嵌套机制,称为 做用域链。
用图片表示,上述代码一共有三层做用域嵌套,分别是:
foo
做用域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
标准提出了使用 let
和 const
代替 var
关键字,来“建立块级做用域”。也就是说,上述代码改为以下方式,块级做用域是有效的:
if (true) { let a = 1; } console.log(a); // ReferenceError 复制代码
关于
let
和const
的更多细节,进入 传送门
在 javascript
中,咱们有几种建立 / 改变做用域的手段:
定义函数,建立函数做用(推荐):
function foo () { // 建立了一个 foo 的函数做用域 } 复制代码
使用 let
和 const
建立块级做用域(推荐):
for (let i = 0; i < 5; i++) { console.log(i); } console.log(i); // ReferenceError 复制代码
try catch
建立做用域(不推荐),err
仅存在于 catch
子句中:
try { undefined(); // 强制产生异常 } catch (err) { console.log( err ); // TypeError: undefined is not a function } console.log( err ); // ReferenceError: `err` not found 复制代码
使用 eval
“欺骗” 词法做用域(不推荐):
function foo(str, a) { eval( str ); console.log( a, b ); } var b = 2; foo( "var b = 3;", 1 ); // 1 3 复制代码
使用 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 复制代码
上面的代码中,构建了 module1
和 module2
两个表明模块的函数,两个函数内分别定义了一个同名变量 a
,因为函数做用域的隔离性质,这两个变量被保存在不一样的做用域中(不嵌套),JS 引擎在执行这两个函数时会去不一样的做用域中读取,而且外部做用域没法访问到函数内部的 a
变量。这样一来就巧妙地解决了 全局做用域污染 和 变量名冲突 的问题;而且,因为函数的包裹写法,这种方式看起来封装性好多了。
然而上面的函数声明式写法,看起来仍是有些冗余,更重要的是,module1
和 module2
的函数名自己就已经对全局做用域形成了污染。咱们来继续改写:
// 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(); // 这就造成了一个闭包 复制代码
咱们能够简单剖析一下上面代码的运行流程:
foo()
,此时会建立一个 foo
函数的执行上下文,执行上下文内部存储了 foo
中声明的全部变量函数信息。foo
运行完毕,将内部函数 bar
的引用赋值给外部的变量 baz
,此时 baz
指针指向的仍是 bar
,所以哪怕它位于 foo
做用域以外,它仍是可以获取到 foo
的内部变量。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 网站演示。
点击这个按钮启动记录,而后切换到网页进行操做,录制完成后点击 stop
按钮,开发者工具会从录制时刻开始记录当前应用的各项数据状况。
选中JS Heap
,下面展示出来的一条蓝线,就是表明了这段记录过程当中,JS 堆内存信息的变化状况。
有大佬说,根据这条蓝线就能够判断是否存在内存泄漏的状况:若是这条蓝线一直成上升趋势,那基本就是内存泄漏了。其实我以为这么讲有失偏颇,JS 堆内存占用率上升并不必定就是内存泄漏,只能说明有不少未被释放的内存而已,至于这些内存是否真的在使用,仍是说确实是内存泄漏,还须要进一步排查。
借助开发者工具的 Memory 选项,能够更精确地定位内存使用状况。
当生成了第一个快照的时候,开发者工具窗口已经显示了很详细的内存占用状况。
字段解释:
Constructor
— 占用内存的资源类型Distance
— 当前对象到根的引用层级距离Shallow Size
— 对象所占内存(不包含内部引用的其它对象所占的内存)(单位:字节)Retained Size
— 对象所占总内存(包含内部引用的其它对象所占的内存)(单位:字节)将每项展开能够查看更详细的数据信息。
咱们再次切回网页,继续操做几回,而后再次生成一个快照。
这边须要特别注意这个 #Delta
,若是是正值,就表明新生成的内存多,释放的内存少。其中的闭包项,若是是正值,就说明存在内存泄漏。
下面咱们到代码里找一个内存泄漏的问题:
使用严格模式,避免不经意间的全局变量泄露:
"use strict"; function foo () { b = 2; } foo(); // ReferenceError: b is not defined 复制代码
关注 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 { // ... } } 复制代码
避免过分使用闭包。
大部分的内存泄漏仍是因为代码不规范致使的。代码千万条,规范第一条,代码不规范,开发两行泪。
javascript
语言层面只原生支持两种做用域类型:全局做用域 和 函数做用域 。全局做用域程序运行就有,函数做用域只有定义函数的时候才有,它们之间是包含的关系。javascript
中使用的是 “词法做用域”,所以函数做用域的范围在函数定义时就已经被肯定,和函数在哪执行没有关系。chrome
开发者工具查找代码中致使了内存泄露的代码。DOM
绑定事件、避免过分使用闭包。最重要的,仍是代码规范。 😃本篇文章已收录入 前端面试指南专栏