带你走进JS作用域(Scope)的世界

1,339 阅读14分钟

该文章是直接翻译国外一篇文章,关于作用域(Scope)。
都是基于原文处理的,其他的都是直接进行翻译可能有些生硬,所以为了行文方便,就做了一些简单的本地化处理。
同时也新增了自己的理解和对应的思考过程,如有不对,请在评论区指出
如果想直接根据原文学习,可以忽略此文。

如果你觉得可以,请多点赞,鼓励我写出更精彩的文章🙏。
如果你感觉有问题,也欢迎在评论区评论,三人行,必有我师焉

TL;DR

  • 概念介绍
  • 最少访问原则
  • JS中的作用域
  • 全局作用域
  • 局部作用域
  • 块级声明
  • 上下文(Context)
  • 执行上下文(Execution Context)
  • 词法作用域(Lexical Scope)
  • 闭包
  • 公共作用域和私有作用域
  • 通过.call(), .apply() and .bind()修改上下文

概念介绍

在JS中有一个比较特殊的特性:作用域(Scope)。尽管作用域这个概念对于许多新手不是很容易能掌握,但是我们通过一些简单的示例来,来解释这些概念。

Scope能够让你的代码在运行的时候,有权访问一些变量函数对象。换句话说,作用域决定:你所写代码中能够访问哪些变量或者其他资源数据。

最少访问原则

在你的代码中如何才可以实现让变量“私有化”,换句话说,如何让变量成为受限的,而不是随处可见的,这是Scope所关注的点。通过Scope我们可以让代码变的更加安全。而在电脑安全策略中一个很重要的原则就是只有拥有对应权限的用户才可以访问对应变量。而这个原则同样适用于编程。在大多数编程语言中,我们称其为作用域

JS中的作用域

在JS中存在两类作用域:

  • 全局作用域
  • 局部作用域

在函数中定义的变量称为局部作用域,在函数外包定义的变量称为全局作用域。函数在调用时,会新建一个作用域。

全局作用域

当你在一个文档中开始写代码的时候,其实已经位于全局作用域范围之内了。而贯穿整个JS文档,有且只有一个全局作用域。如果一个变量定义在函数体之外,那这个变量肯定是一个全局作用域。

var name = '北宸';

定义在全局作用域中的变量可以在其他作用域中被获取或者修改。

var name = '北宸';

console.log(name); //  '北宸'

function logName() {
    console.log(name); // 'name' 能够被获取也可以被修改
}

logName(); //  '北宸'

局部作用域

在函数中定义的变量是属于局部作用域的。并且,每次调用函数生成的局部作用域也是不一样的。也就是说,相同名字的变量可以出现在不同的函数中。这是因为,这些变量是和他们自己的作用域挂钩的,每一个函数被调用,都生成新的局部作用域,并且对其他函数的作用域没有访问权限。

// 全局作用域
function someFunction() {
    // 局部作用域#1
    function someOtherFunction() {
        //  局部作用域#2
    }
}

// 全局作用域
function anotherFunction() {
    // 局部作用域#3
}
// 全局作用域

块级声明

ifswitch或者是for循环、while被称为块级声明。他们和函数不同,通过他们包裹的代码,不会生成新的作用域。在块级声明中定义的变量还是属于块级声明所在作用域范围之内。

if (true) {
    // 不会新增作用域
    var name = '北宸'; // name 还是属于全局作用域
}

console.log(name); //  '北宸'

ECMAScript 6新添了letconst关键字。而该关键字可以替代var的使用。

var name = '北宸';

let likes = '南蓁';
const skills = '我是一个萌萌哒的汉子';

var关键字不同的是:letconst关键字可以在块级声明中定义变量,从而生成一个新的局部作用域

if (true) {
    
    // if语句,不会生成作用域t

    // 通过var定义的变量属于全局作用域范围
    var name = '北宸';
    // 局部作用域
    let likes = '南蓁';
    // 局部作用域
    const skills = '我是一个萌萌哒的汉子';
}

console.log(name); //  '北宸'
console.log(likes); // Uncaught ReferenceError: likes is not defined
console.log(skills); // Uncaught ReferenceError: skills is not defin

让我们分析一波,我们通过断点跟踪,发现通过letconst在块级声明中定义的变量,它的存放方式不用var定义的变量的存放方式是不一样的。

name是挂载在Window上也就是我们说的全局作用域(Global)。

全局作用域的生存时间是贯穿整个项目,而局部作用域是伴随函数的生命周期存在而存在。

上下文(Context)

许多开发者对作用域(Scope)和上下文(Context)不是很容易区分,总是认为他们是一个东西。但是实际上他们是不一样的。作用域是我们上面讨论的那样,说的直白一点,就是数据权限问题。而上下文指代this的值。

Scope侧重于变量的可见性,而Context指向this的值。我们可以通过一些方法来修改上下文。而在全局作用域中,上下文恒等于Window(在浏览器环境)

// Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
console.log(this);

function logFunction() {
    console.log(this);
}
//  Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// js中this的指向是在调用时候,确定的
logFunction(); 

如果函数定义在对象中,并且在对象作用域范围中调用,this就指向对象

class User {
    logName() {
        console.log(this);
    }
}

(new User).logName(); //  User {}

Notice:如果通过new关键字来调用函数,context将会被赋值为函数的实例。

function logFunction() {
    console.log(this);
}

new logFunction(); //  logFunction {}

在严格模式Strict Mode下调用函数,context的值为undefined

执行上下文(Execution Context)

为了不被上文中所讲的所影响,需要特别指出执行上下文(Execution Context)中的context指向的是作用域(Scope),而不是上下文(context)。这是一个让人很容易误会的名字,但是这是JS的规范或者是语法命名,我们只能去适应他。

JS是一个单线程的语言,所以在同一时刻,只能执行一个任务。而剩余的任务,就会在执行环境(Execution Context)中以队列的形式等待。正如上文说的,当JS编译器开始处理代码的时候,上下文(作用域)默认为全局作用域。该全局上下文被填充执行上下文(Execution Context)中,作为第一个上下文,随后开始执行对应代码。

随后,每当函数被调用,新建的上下文将会被以队列的形式追加到执行上下文中(这个追加过程是在函数调用的瞬间完成的)。另外一个函数调用,也会周而复始的执行如上追加操作。

每个函数创建属于自己的执行上下文。

当JS编译器处理完某个上下文中的代码时,该上下文将会被执行上下文移除,同时执行上下文中的current context指向被移除上下文的父级上下文。JS编译器总是处理位于执行堆栈顶层(这实际上是代码中作用域的最内层 )的上下文。

全局上下文只有一个,而函数上下文可以存在多个

执行上下文存在两个阶段:构建执行

构建阶段

构建阶段是处于函数被调用但是还未执行的时候。将依次发生如下过程:

  • 生成变量(活动)对象
  • 构建作用域链
  • 为上下文(this)赋值

变量(活动)对象

变量对象也被称为活动对象,它包含很所有在执行上下文的特定分支中定义的变量,函数还有其他的声明。当函数被调用,JS编译器就会扫描函数中的所有资源,包括函数参数,变量还有其他声明。然后将所有的资源打包到一个对象中,这个对象就是变量对象

'variableObject': {
    // 包含 函数参数, 内部变量 和函数声明
}

作用域链

在执行上下文的构建阶段,作用域链的创建在变量对象之后。作用域链包含变量对象。作用域链用于查找和定位变量。当代码中需要某个变量时,JS总是从代码的最内层开始查找,如果在当前作用域中没有找到,就继续向父级作用域查找,周而复始,直到查找定位到变量定义的作用域。通过查找变量的机制可知,作用域链可以简单定义为一个对象,在对象内包含了代表其执行上下文的变量对象,还有该执行上下文的父级变量对象。而父级变量对象又可以包含父级的父级的变量对象,直到某一级的父级变量对象为null时停止。这里涉及到作用域链的查找机制,从另外一个角度分析,作用域链是查找顶级变量对象,而JS中对象最后的归宿都是null。这一点可以参考理解JS中的原型(Prototypes)这篇文章。(也算是从另外一个角度来考虑作用域链)

'scopeChain': {
    // 包含它自己的变量对象和代表其父级执行上下文的变量对象
}

执行上下文对象

执行上下文可以简单的通过如下对象进行抽象表示:

executionContextObject = {
    'scopeChain': {}, //包含其自身的变量对象和其父级的执行上下文的变量对象 
    'variableObject': {}, // 包含函数参数,内部变量,还有方法定义
    'this': valueOfThis
}

代码执行阶段

在执行上下文的第二阶段为代码执行阶段,其他的值被赋值,还有代码最终执行

词法作用域(Lexical Scope)

词法作用域说的是,在一个函数的作用域中定义一个子函数,该子函数拥有对外层函数变量和其他资源的访问权限。也就是说,子函数在词法上是与外层函数的执行上下文耦合的。词法作用域有时候也被称为静态作用域(Static Scope)

function grandfather() {
    var name = 'Hammad';
    // likes 在此处不能被访问
    function parent() {
        // name 在此处能被访问
        // likes 在此处不能被访问
        function child() {
            //作用域链的最内层 
            // name 在此处能被访问
            var likes = 'Coding';
        }
    }
}

Notice:看上面的例子我们发现,词法作用域是向前可见的,也就说在父级中定义的变量能够在子级执行上下文中访问。例如,name。但是,词法作用域是向后不可见的,子级定义的变量不能够在父级执行上下文中访问。

也演变出一个变量查找规则,如果在不同的执行上下文存在相同的变量,而JS引擎在定位变量是按着由上到下的顺序遍历执行堆栈。在执行堆栈中存在多个同名变量,在最内层函数中(执行堆栈最顶层)拥有最高的访问权限。(在最内层执行上下文中访问变量)

闭包(Closures)

闭包的概念类似于词法作用域。当一个内层函数尝试访问外层函数的作用域链时,闭包产生了。这意味着变量位于词法作用域之外。闭包能访问内层函数自己的作用域链,它父级的作用域链和全局作用域。

闭包不仅能访问在外层函数定义的变量,而且可以访问外层函数的参数列表。

闭包也可以在外层函数被执行之后继续访问外层函数的变量。也意味着,被返回的函数拥有访问外层函数的所有资源。

当一个函数调用时,返回了一个内层函数,返回的内层函数不会立马被调用。你必须用一个变量去接收被返回的内层函数的引用。并且将接收返回值的变量作为函数去调用。

function greet() {
    name = '北宸';
    return function () {
        console.log('Hi ' + name);
    }
}

greet(); // 只是一个简单的函数调用

// 返回的内层函数被greetLetter接收
greetLetter = greet();

 // 用调用函数的方式来处理变量
greetLetter(); //  'Hi 北宸'

我们也可不用通过变量来接收返回的内层函数,直接利用()()调用:

function greet() {
    name = '北宸';
    return function () {
        console.log('Hi ' + name);
    }
}

greet()(); //  'Hi 北宸'

如果想详细了解,如何构建和使用闭包,可以参考JS闭包(Closures)了解一下

公共作用域和私有作用域

在许多其他编程语言中,你可以在class中设置属性和方法为publicprivate或者是protected的作用域。如下是PHP的使用方式

// 公共作用域
public $property;
public function method() {
  // ...
}

// 私有作用域
private $property;
private function method() {
  // ...
}

//受保护作用域
protected $property;
protected function method() {
  // ...
}

通过对函数使用方式进行限定,使得免受一些不必要的访问。但是在JS中,没有像publicprivate的关键词去限制变量。但是我们可以通过闭包模拟这种特性。为了能够将我们的代码与全局作用域分离,我们需要封装如下的函数格式:

(function () {
  // 私有作用域
})();

函数后面的括号表示,在函数定义的完成就告诉JS编译器立即调用该函数。我们可以在函数中定义一些在外部无法获取的变量和方法。如果我们想让其中定义的变量,部分对外部可见,我们可以通过闭包实现。而封装数据的方式也被称为Module Pattern(设计模式中的一种)。

模块模式(Module Pattern)

var Module = (function() {
    function privateMethod() {
        // 私有
    }

    return {
        publicMethod: function() {
            // 此处对私有变量拥有访问权限
        }
    };
})();

模块模式通过利用闭包,返回了一个匿名对象,在该对象中就是对应模块能被外界访问的公共(public)属性,而公共属性或者方法具有对模块中私有属性的访问权限。

在项目开发或者模块定义中,我们可以将私有属性用_开头的变量与公共属性做区分。

var Module = (function () {
    function _privateMethod() {
        // do something
    }
    function publicMethod() {
        // do something
    }
    return {
        publicMethod: publicMethod,
    }
})();

如果想对模块模式了解更多,或者了解JS的模块发展历程,可以参考骚年,你对前端模块化了解多少

通过.call(), .apply() and .bind()修改上下文

CallApply函数用于改变调用函数的上下文。

function hello() {
    // do something...
}

hello(); // 正常调用
hello.call(context); // 将context作为第一个参数传入
hello.apply(context); // 将context作为第一个参数传入

.call().apply()之间的区别是,call接收的是以逗号分隔的参数list,而apply接收的是数组。

function introduce(name, interest) {
    console.log('Hi! 我是'+ name +' ,我喜欢'+ interest +'.');
    console.log('this' +'值为'+this)
}

introduce('北宸', 'Coding'); //
introduce.call(window, 'Batman', 'to save Gotham'); // 在context之后,以逗号分隔传递
introduce.apply('Hi', ['Bruce Wayne', 'businesses']); //在context之后,传入数组

call的运行速度比apply

在JS中一切皆对象,作为函数也不例外,而JavaScript函数带有四个内置方法,它们是:

  • Function.prototype.apply()
  • Function.prototype.bind()(在ECMAScript 5(ES5)中引入)
  • Function.prototype.call()
  • Function.prototype.toString()

Function.prototype.toString()返回该函数的源代码的字符串表示形式。

我们已经讨论过.call().apply()能够改变context。与Call 和 Apply不同,Bind本身不会调用该函数,它只能用于在调用该函数之前绑定上下文值和其他参数。

(function introduce(name, interest) {
  console.log('Hi! 我是'+ name +' ,我喜欢'+ interest +'.');
    console.log('this' +'值为'+this)
}).bind(window, '北宸', '南蓁')();

如果想对applybindcall有一个更深的了解可以参考this、apply、call、bind