[TOC]
作用域
转载整理自:https://mp.weixin.qq.com/s/hYODxBjccYP3EBrmWRBN6w
1. 什么是作用域?
作用域是你的代码在运行时,各个变量、函数和对象的可访问性。换句话说,作用域决定了你的代码里的变量和其他资源在各个区域中的可见性。
2. 为什么需要作用域?最小访问原则
限制变量的可见性,不允许你代码中所有的东西在任意地方都可用的好处:
- 其中一个优势,是作用域为你的代码提供了一个安全层级。计算机安全中,有个常规的原则是:用户只能访问他们当前需要的东西。
- 作用域在你的代码中可以提升性能,跟踪 bug 并减少 bug。作用域还解决不同范围的同名变量命名问题。记住不要弄混作用域和上下文。它们是不同的特性。
想想计算机管理员吧。他们在公司各个系统上拥有很多控制权,看起来甚至可以给予他们拥有全部权限的账号。假设你有一家公司,拥有三个管理员,他们都有系统的全部访问权限,并且一切运转正常。但是突然发生了一点意外,你的一个系统遭到恶意病毒攻击。现在你不知道这谁出的问题了吧?你这才意识到你应该只给他们基本用户的账号,并且只在需要时赋予他们完全的访问权。这能帮助你跟踪变化并记录每个人的操作。这叫做最小访问原则。眼熟吗?这个原则也应用于编程语言设计,在大多数编程语言(包括 JavaScript)中称为作用域,接下来我们就要学习它。
3. JavaScript中的作用域
在 JavaScript 中有两种作用域
- 全局作用域
- 局部作用域
当变量定义在一个函数中时,变量就在局部作用域中,而定义在函数之外的变量则从属于全局作用域。每个函数在调用的时候会创建一个新的作用域。
3.1 全局作用域
当你在文档中(document)编写 JavaScript 时,你就已经在全局作用域中了。JavaScript 文档中(document)只有一个全局作用域。定义在函数之外的变量会被保存在全局作用域中。
// the scope is by default global
var name = 'Hammad';
全局作用域里的变量能够在其他作用域中被访问和修改。
var name = 'jack';
console.log(name); // jack
function logName() {
console.log(name); // 'name' is accessible here and everywhere else
name = 'mack'
}
logName(); // jack
console.log(name); // mack
3.2 局部作用域
定义在函数中的变量就在局部作用域中。并且函数在每次调用时都有一个不同的作用域。这意味着同名变量可以用在不同的函数中。因为这些变量绑定在不同的函数中,拥有不同作用域,彼此之间不能访问。
// Global Scope
function someFunction() {
// Local Scope ##1
function someOtherFunction() {
// Local Scope ##2
}
}
// Global Scope
function anotherFunction() {
// Local Scope ##3
}
// Global Scope
3.3 块语句
块级声明包括if和switch,以及for和while循环,和函数不同,它们不会创建新的作用域。在块级声明中定义的变量从属于该块所在的作用域。
if (true) {
// this 'if' conditional block doesn't create a new scope
var name = 'Hammad'; // name is still in the global scope
}
console.log(name); // 'Hammad'
ECMAScript 6 引入了let和const关键字。这些关键字可以代替var。
在块语句中使用let和const关键字创建的变量,那么变量的作用域则为局部的,仅在块中有效。
if (true) {
// this 'if' conditional block doesn't create a scope
// name is in the global scope because of the 'var' keyword
var name = 'Hammad';
// likes is in the local scope because of the 'let' keyword
let likes = 'Coding';
// skills is in the local scope because of the 'const' keyword
const skills = 'JavaScript and PHP';
}
console.log(name); // logs 'Hammad'
console.log(likes); // Uncaught ReferenceError: likes is not defined
console.log(skills); // Uncaught ReferenceError: skills is not defined
3.4 作用域生存周期
一个应用中;
- 全局作用域的生存周期与该应用相同
- 局部作用域只在该函数调用执行期间存在
4. 上下文
很多开发者经常弄混作用域和上下文,似乎两者是一个概念。但并非如此。
作用域是我们上面讲到的那些,而上下文通常涉及到你代码某些特殊部分中的this值。作用域指的是变量的可见性,而上下文指的是在相同的作用域中的this的值。我们当然也可以使用函数方法改变上下文,这个之后我们再讨论。在全局作用域中,上下文总是 Window 对象。
/ logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
console.log(this);
function logFunction() {
console.log(this);
}
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// because logFunction() is not a property of an object
logFunction();
如果作用域定义在一个对象的方法中,上下文就是这个方法所在的那个对象。
class User {
logName() {
console.log(this);
}
}
(new User).logName(); // logs User {}
(new User).logName()是创建对象关联到变量并调用logName方法的一种简便形式。通过这种方式你并不需要创建一个新的变量。
你可能注意到一点,就是如果你使用new关键字调用函数时上下文的值会有差异。上下文会设置为被调用的函数的实例。考虑一下上面的这个例子,用new关键字调用的函数。
当在严格模式(strict mode)中调用函数时,上下文默认是 undefined。
5. 执行环境
为了解决掉我们从上面学习中会出现的各种困惑,“执行环境(context)”这个词中的“环境(context)”指的是作用域而并非上下文。这是一个怪异的命名约定,但由于 JavaScript 的文档如此,我们只好也这样约定。
JavaScript 是一种单线程语言,所以它同一时间只能执行单个任务。其他任务排列在执行环境中。当 JavaScript 解析器开始执行你的代码,环境(作用域)默认设为全局。全局环境添加到你的执行环境中,事实上这是执行环境里的第一个环境。
之后,每个函数调用都会添加它的环境到执行环境中。无论是函数内部还是其他地方调用函数,都会是相同的过程。
每个函数都会创建它自己的执行环境。
当浏览器执行完环境中的代码,这个环境会从执行环境中弹出,执行环境中当前环境的状态会转移到父级环境。浏览器总是先执行在执行栈顶的执行环境(事实上就是你代码最里层的作用域)。
全局环境只能有一个,函数环境可以有任意多个。
执行环境有两个阶段:创建和执行。
6 创建阶段
第一阶段是创建阶段,是函数刚被调用但代码并未执行的时候。创建阶段主要发生了 3 件事。
- 创建变量对象
- 创建作用域链
- 设置上下文(this)的值
6.1 变量对象
变量对象(Variable Object)也称为活动对象(activation object),包含所有变量、函数和其他在执行环境中定义的声明。当函数调用时,解析器扫描所有资源,包括函数参数、变量和其他声明。当所有东西装填进一个对象,这个对象就是变量对象。
'variableObject': {
// contains function arguments, inner variable and function declarations
}
6.2 作用域链
在执行环境创建阶段,作用域链在变量对象之后创建。作用域链包含变量对象。作用域链用于解析变量。当解析一个变量时,JavaScript 开始从最内层沿着父级寻找所需的变量或其他资源。作用域链包含自己执行环境以及所有父级环境中包含的变量对象。
'scopeChain': {
// contains its own variable object and other variable objects of the parent execution contexts
}
6.3 执行环境对象
执行环境可以用下面抽象对象表示:
executionContextObject = {
'scopeChain': {}, // contains its own variableObject and other variableObject of the parent execution contexts
'variableObject': {}, // contains function arguments, inner variable and function declarations
'this': valueOfThis
}
7. 代码执行阶段
执行环境的第二个阶段就是代码执行阶段,进行其他赋值操作并且代码最终被执行。
7.1 词法作用域
由于JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。函数的作用域在函数定义的时候就决定了。
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
value = 100;
bar();
函数的作用域在函数定义的时候就决定了,代码执行过程:
执行bar函数,bar函数作用域内声明了一个value变量,然后执行foo函数,在foo函数,输出变量value,由于foo作用域内部没有变量,则在foo声明的的位置,向上一层作用(全局作用域)去查找变量,找到value变量输出,由于全局作用域中value的值是100,所以会打印100.
7.2 闭包
闭包的概念和我们刚学习的词法作用域紧密相关。当内部函数试着访问外部函数的作用域链(词法作用域之外的变量)时产生闭包。闭包包括它们自己的作用域链、父级作用域链和全局作用域。
闭包不仅能访问外部函数的变量,也能访问外部函数的参数。
即使函数已经return,闭包仍然能访问外部函数的变量。这意味着return的函数允许持续访问外部函数的所有资源。
当你的外部函数return一个内部函数,调用外部函数时return的函数并不会被调用。你必须先用一个单独的变量保存外部函数的调用,然后将这个变量当做函数来调用。看下面这个例子:
function greet() {
name = 'Hammad';
return function () {
console.log('Hi ' + name);
}
}
greet(); // nothing happens, no errors
// the returned function from greet() gets saved in greetLetter
greetLetter = greet();
// calling greetLetter calls the returned function from the greet() function
greetLetter(); // logs 'Hi Hammad'
值得注意的是,即使在greet函数return后,greetLetter函数仍可以访问greet函数的name变量。如果不使用变量赋值来调用greet函数return的函数,一种方法是使用()两次()(),如下所示:
function greet() {
name = 'Hammad';
return function () {
console.log('Hi ' + name);
}
}
greet()(); // logs 'Hi Hammad'
8. 共有作用域和私有作用域
在许多其他编程语言中,你可以通过 public、private 和 protected 作用域来设置类中变量和方法的可见性。看下面这个 PHP 的例子
// Public Scope
public $property;
public function method() {
// ...
}
// Private Sccpe
private $property;
private function method() {
// ...
}
// Protected Scope
protected $property;
protected function method() {
// ...
}
将函数从公有(全局)作用域中封装,使它们免受攻击。但在 JavaScript 中,没有 共有作用域和私有作用域。然而我们可以用闭包实现这一特性。为了使每个函数从全局中分离出去,我们要将它们封装进如下所示的函数中:
(function () {
// private scope
})();
函数结尾的括号告诉解析器立即执行此函数。我们可以在其中加入变量和函数,外部无法访问。但如果我们想在外部访问它们,也就是说我们希望它们一部分是公开的,一部分是私有的。我们可以使用闭包的一种形式,称为模块模式(Module Pattern),它允许我们用一个对象中的公有作用域和私有作用域来划分函数。
8.1 模块模式
模块模式如下所示:
var Module = (function() {
function privateMethod() {
// do something
}
return {
publicMethod: function() {
// can call privateMethod();
}
};
})();
Module 的return语句包含了我们的公共函数。私有函数并没有被return。函数没有被return确保了它们在 Module 命名空间无法访问。但我们的共有函数可以访问我们的私有函数,方便它们使用有用的函数、AJAX 调用或其他东西。
Module.publicMethod(); // works
Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined
一种习惯是以下划线作为开始命名私有函数,并返回包含共有函数的匿名对象。这使它们在很长的对象中很容易被管理。向下面这样:
var Module = (function () {
function _privateMethod() {
// do something
}
function publicMethod() {
// do something
}
return {
publicMethod: publicMethod,
}
})();
8.2 立即执行函数表达式(IIFE)
另一种形式的闭包是立即执行函数表达式(Immediately-Invoked Function Expression,IIFE)。这是一种在 window 上下文中自调用的匿名函数,也就是说this的值是window。它暴露了一个单一全局接口用来交互。如下所示:
(function(window) {
// do anything
})(this);
9. 使用 .call(), .apply() 和 .bind() 改变上下文
Call 和 Apply 函数来改变函数调用时的上下文。这带给你神奇的编程能力(和终极统治世界的能力)。你只需要使用 call 和 apply 函数并把上下文当做第一个参数传入,而不是使用括号来调用函数。函数自己的参数可以在上下文后面传入。
function hello() {
// do something...
}
hello(); // the way you usually call it
hello.call(context); // here you can pass the context(value of this) as the first argument
hello.apply(context); // here you can pass the context(value of this) as the first argument
.call()和.apply()的区别是 Call 中其他参数用逗号分隔传入,而 Apply 允许你传入一个参数数组。
function introduce(name, interest) {
console.log('Hi! I\'m '+ name +' and I like '+ interest +'.');
console.log('The value of this is '+ this +'.')
}
introduce('Hammad', 'Coding'); // the way you usually call it
// Hi! I'm Hammad and I like Coding.
// The value of this is [object Window].
introduce.call(window, 'Batman', 'to save Gotham'); // pass the arguments one by one after the contextt
// Hi! I'm Batman and I like to save Gotham.
// The value of this is [object Window].
introduce.apply('Hi', ['Bruce Wayne', 'businesses']); // pass the arguments in an array after the context
据说:Call 比 Apply 的效率高一点。
对象可以有方法,同样函数对象也可以有方法。事实上,JavaScript 函数有 4 个内置方法:
- Function.prototype.apply()
- Function.prototype.bind() (Introduced in ECMAScript 5 (ES5))
- Function.prototype.call()
- Function.prototype.toString()
Function.prototype.toString()返回函数代码的字符串表示。
到现在为止,我们讨论了.call()、.apply()和toString()。与 Call 和 Apply 不同,Bind 并不是自己调用函数,它只是在函数调用之前绑定上下文和其他参数。在上面提到的例子中使用 Bind:
(function introduce(name, interest) {
console.log('Hi! I\'m '+ name +' and I like '+ interest +'.');
console.log('The value of this is '+ this +'.')
}).bind(window, 'Hammad', 'Cosmology')();
// Hi! I'm Hammad and I like Cosmology.
// The value of this is [object Window].
Bind 像call函数一样用逗号分隔其他传入参数,不像apply那样用数组传入参数。
参考资料
理解 JavaScript 作用域:https://mp.weixin.qq.com/s/hYODxBjccYP3EBrmWRBN6w