[译] JavaScript的作用域和上下文
背景
对于一个前端工程师来说,JavaScript
的基础非常重要。
笔者在学习 JavaScript基础
的过程中读了一篇外文文章,文中详细解释了 JS
的 作用域
和 上下文
,把这两个概念写的通俗易懂,使笔者大为触动。
为了加深印象,笔者消化知识后,🔥索性输出
了一篇译文,现分享给大家,但由于水平有限,译注不对之处,还请掘友批评指正~
原文介绍
原文地址:Understanding Scope And Context In JavaScript
原文作者:Ryan Morr
作者简介:
Ryan Morr 是一名居住在加拿大安大略省巴里市的程序员。他对与前端 Web 开发相关的一切充满热情。 JavaScript 是他的初恋,他觉得这种语言似乎总是保持新鲜,并且不断激发出创造力和新技术,从而完成新的解决方案。
他还有一些其他兴趣,比如足球。不过由于他是一名自豪的加拿大人,曲棍球才是他的第一体育项目。同时他还是一个巨大的电影迷,最喜欢的电影有《现代启示录》、《肖申克的救赎》和《拯救大兵瑞恩》。
译文
JavaScript
对作用域
和上下文
的实现,是它的一项独有特性,这是JS
灵活性的一种体现。
函数可以在各种上下文
中执行,作用域
可以被封装和保留,这些概念支撑着JS
,形成了她强大的设计模式。
然而,这也给开发人员造成了巨大困惑。
下面是对作用域
和上下文
的全面解释,包括它们的概念、区别,还有各种设计模式下如何使用它们。
上下文 vs 作用域
首先要清晰地认识到,上下文
和作用域
是两个不同的概念。
我发现大量工作多年的开发者经常混淆这两个概念,将其中一个错误地描述为另一个。
老实说,这几年术语变得相当混乱。
每个函数调用
都有属于自己的作用域
,以及一个与之关联的上下文
。
从根本上说,作用域
是基于函数
的,而上下文
是基于对象
的。
换句话说,作用域
与函数被调用时的变量访问
有关,并且每次调用一个函数(不管是不是同一个函数),都会产生一个独一无二的作用域。
而上下文
始终是this
关键字的值,它是对拥有当前执行代码的对象
的引用
。
作用域中的变量
一个变量可以在局部作用域
或者全局作用域
中定义,这在运行时
确定了从不同作用域
是否可以访问该变量。
任何全局变量
,也就是在函数体之外定义的变量,将在整个运行时
存活,并且从任何作用域
都可以访问
和修改
它们。
本地变量
只存在于定义它们的函数内部。每次调用该函数,都将产生不同的作用域
。本地变量仅仅能在本次调用的作用域
(函数内部)进行值分配、检索和控制,在该作用域
之外不能访问
到它们。
目前JavaScript
不支持块级作用域
。所谓块级作用域
,指的是if
语句、swtich
语句、for
循环以及while
循环形成的作用域
,块级作用域
意味着在该块
的左大括号和右大括号之外,不能访问块
内部的变量。
目前的JS
中,从块的外部可以访问到块内部的变量,然而,这很快就会改变,let
关键字已经添加到ES6
规范中,它可以作为var
关键字的替代,从而支持定义块级作用域
的本地变量
。
什么是"this"上下文
上下文
通常由函数的调用方式决定。当一个函数作为对象的方法
被调用时,this
会被设置为调用这个方法的对象:
var object = {
foo: function(){
alert(this === object);
}
};
object.foo(); // true
当使用new
操作符调用函数时,会创建一个对象实例
,这时也符合上面的原则。这种情况下,作用域
中this
的值会被设置为新创建的对象实例
:
function foo(){
alert(this);
}
foo() // window
new foo() // foo
当作为没有任何绑定的函数
调用时,this
默认指向全局上下文
,比如浏览器环境下的window
对象。然而,如果函数在严格模式
下执行,这个上下文
默认为undefined
。
执行上下文和作用域链
JavaScript
是单线程语言。这意味着浏览器一次只能做一件事。
JavaScript
解释器开始执行代码时,首先会创建全局执行上下文
,后面每次调用一个函数,都会创建一个新的执行上下文
。
这就是出现混淆的地方。实际上,术语执行上下文
的表达目的在于作用域
,而不是前面讨论的上下文
。
这是一个不幸的命名约定。然而,它是由ECMAScript
规范定义的术语,所以大家还是这么称呼它。
每次创建一个执行上下文
时,该执行上下文
就会被添加到作用域链
的顶部。作用域链
有时候也被称为执行栈
或者调用栈
。浏览器总是执行位于作用域链
顶部的执行上下文
,一旦执行完毕,该执行上下文
就会从作用域链
中被移除,然后控制权来到链的下一个执行上下文
中。
观察下面例子:
function first(){
second();
function second(){
third();
function third(){
fourth();
function fourth(){
// do something
}
}
}
}
first();
运行上面的代码将导致嵌套函数一直执行到函数fourth
。这时候作用域链
从上到下依次是: fourth
、third
、second
、first
、global
。函数fourth
将有权限访问全局变量
、所有first
、second
、third
函数中定义的变量以及函数本身
。当函数fourth
执行完毕,它产生的作用域
将从作用域链
中被移除,然后执行权来到函数third
。这个过程将重复进行,直到所有代码执行完毕。
不同执行上下文
中可能存在同样的变量名。区分的方法是沿着作用域链,从当前到全局查找。这意味着,如果存在同名变量,优先使用当前执行上下文
的变量,如果当前执行上下文
找不到,再沿着作用域链
往下查找。
一个执行上下文
可以被分为创建阶段
和执行阶段
。
在创建阶段
,JS解释器
首先会创建一个变量对象
(也称为激活对象)。变量对象由执行上下文
中定义的所有变量、函数以及参数组成。然后,初始化作用域链
。最后确定this
的值。
到了执行阶段
,代码被解释和执行。
简而言之,每次您尝试在一个执行上下文
中访问变量时,查找过程总是从这个执行上下文
自己的变量对象
开始。如果在当前变量对象
中没有找到这个要访问的变量,程序就会继续在作用域链
中查找。它会沿着作用域链
,检查剩下的变量对象
,寻找与目标变量名匹配的项。
闭包
当一个内部函数在定义它的函数之外可以被访问时,就会形成一个闭包。通常这个内部函数会在外部函数执行返回后,被调用。它维护了外部函数定义的本地变量、参数以及函数。
这样的封装使得我们在暴露一个公共接口的同时,隐藏、保留了外部函数的执行上下文。因此,我们可以进一步操作,观察下面这个简单的例子:
function foo(){
var local = 'private variable';
return function bar(){
return local;
}
}
var getLocalVariable = foo();
getLocalVariable() // private variable
最流行的闭包
模式之一是众所周知的模块
。它允许您模拟public
、private
、以及privileged
成员:
var Module = (function(){
var privateProperty = 'foo';
function privateMethod(args){
//do something
}
return {
publicProperty: "",
publicMethod: function(args){
//do something
},
privilegedMethod: function(args){
privateMethod(args);
}
}
})();
可以看到,函数末尾有()
,因此该模块就像一个单例一样,在编译器解释它时立即执行。
闭包执行上下文之外唯一可用的成员们是您返回的公共方法和公共属性(比如Module.publicMethod
),然而,由于执行上下文被保留,所有的私有属性和私有方法都将在整个应用的生命周期中存在。
这意味着,后面的代码中,私有变量
可以通过模块暴露的公共方法
进行交互。
另一种类型的闭包是IIFE
(立即执行函数表达式),它是在window
上下文中执行的自调用匿名函数
:
function(window){
var a = 'foo', b = 'bar';
function private(){
// do something
}
window.Module = {
public: function(){
// do something
}
};
})(this);
当尝试保留全局命名空间时,这个表达式非常有用,因为在立即执行函数体内声明的所有变量,都将成为闭包的本地变量,它们在整个运行时都是存活的。
这是封装应用程序以及框架源代码的流行方式,通常暴露一个全局接口以进行交互。
call&apply
这两个简单的方法是所有函数固有的,通过它们您可以指定一个上下文,函数会在其中执行。
方法call
需要将参数一个一个列出来,而方法apply
需要将参数组合成一个数组传递进来:
function user(first, last, age){
// do something
}
user.call(window, 'John', 'Doe', 30);
user.apply(window, ['John', 'Doe', 30]);
两次调用的结果完全相同。函数user
在window
上下文中被执行两次,接收的是同一批参数。
ECMAScript 5(ES5)
引入了一种操作上下文的方法——Function.prototype.bind
。它返回一个新函数,无论如何使用这个新函数,它的上下文
都永久绑定在bind
的第一个参数上。bind内部使用了一个闭包,该闭包负责在适当的上下文中重定向调用。对于不受支持的浏览器,观察下面的polyfill
实现:
if(!('bind' in Function.prototype)){
Function.prototype.bind = function(){
var fn = this, context = arguments[0], args = Array.prototype.slice.call(arguments, 1);
return function(){
return fn.apply(context, args);
}
}
}
bind
通常用于上下文缺失的地方: 面向对象和事件处理。由于node
的addEventListener
方法,应该在一个node
的上下文中执行回调,所以bind
是必要的。如果您使用高级面向对象技术,并且要求您的回调是一个实例的方法,您就需要手动调整上下文,这就是bind
的用武之地:
function MyClass(){
this.element = document.createElement('div');
this.element.addEventListener('click', this.onClick.bind(this), false);
}
MyClass.prototype.onClick = function(e){
// do something
};
回看上面bind
函数的polyfill
源代码,您可能会注意到一行看起来相对简单的代码,其中包含一个Array
的方法:
Array.prototype.slice.call(arguments, 1);
有趣的是,这里的arguments
实际上不是数组,但它通常被描述为一个类似数组的对象。
就像执行document.getElementsByTagName
方法返回的node
列表一样。这种类数组对象
包含一个length
属性,以及索引值,但是它们仍然不是数组,因此不支持数组的任何特有方法,如slice
和push
。
不过,由于类数组对象和数组的相似特征,可以劫持数组的特有方法,使它在类数组对象的上下文中执行,就像上面代码所做的那样。
在JavaScript
中模拟后端语言的经典继承模式时,这种"借用"另一个对象方法的技术同样适用:
MyClass.prototype.init = function() {
// call the superclass init method in the context of the "MyClass" instance
MySuperClass.prototype.init.apply(this, arguments);
}
通过在子类(MyClass
)的上下文
中调用父类(MySuperClass
)的方法,我们可以模拟出这种强大的设计模式。
结论
在开始接触高级设计模式
之前,理解这些概念很重要,因为作用域
和上下文
在现代JavaScript
中扮演着重要的基础的角色。无论是谈论闭包
、面向对象
、继承
,还是各种原生实现
,上下文
和作用域
都在其中发挥着重要作用。如果您的目标是掌握JavaScript
语言,并理解它包含的所有内容,那么作用域
和上下文
应该是您的起点之一。
转载自:https://juejin.cn/post/7195175285711437884