ES6 变量做用域与提高:变量的生命周期详解

ES6 变量做用域与提高:变量的生命周期详解从属于笔者的现代 JavaScript 开发:语法基础与实践技巧系列文章。本文详细讨论了 JavaScript 中做用域、执行上下文、不一样做用域下变量提高与函数提高的表现、顶层对象以及如何避免建立全局对象等内容;建议阅读前文 ES6 变量声明与赋值javascript

变量做用域与提高

在 ES6 以前,JavaScript 中只存在着函数做用域;而在 ES6 中,JavaScript 引入了 let、const 等变量声明关键字与块级做用域,在不一样做用域下变量与函数的提高表现也是不一致的。在 JavaScript 中,全部绑定的声明会在控制流到达它们出现的做用域时被初始化;这里的做用域其实就是所谓的执行上下文(Execution Context),每一个执行上下文分为内存分配(Memory Creation Phase)与执行(Execution)这两个阶段。在执行上下文的内存分配阶段会进行变量建立,即开始进入了变量的生命周期;变量的生命周期包含了声明(Declaration phase)、初始化(Initialization phase)与赋值(Assignment phase)过程这三个过程。java

传统的 var 关键字声明的变量容许在声明以前使用,此时该变量被赋值为 undefined;而函数做用域中声明的函数一样能够在声明前使用,其函数体也被提高到了头部。这种特性表现也就是所谓的提高(Hoisting);虽然在 ES6 中以 let 与 const 关键字声明的变量一样会在做用域头部被初始化,不过这些变量仅容许在实际声明以后使用。在做用域头部与变量实际声明处之间的区域就称为所谓的暂时死域(Temporal Dead Zone),TDZ 可以避免传统的提高引起的潜在问题。另外一方面,因为 ES6 引入了块级做用域,在块级做用域中声明的函数会被提高到该做用域头部,即容许在实际声明前使用;而在部分实现中该函数同时被提高到了所处函数做用域的头部,不过此时被赋值为 undefined。编程

做用域

做用域(Scope)即代码执行过程当中的变量、函数或者对象的可访问区域,做用域决定了变量或者其余资源的可见性;计算机安全中一条基本原则便是用户只应该访问他们须要的资源,而做用域就是在编程中遵循该原则来保证代码的安全性。除此以外,做用域还可以帮助咱们提高代码性能、追踪错误而且修复它们。JavaScript 中的做用域主要分为全局做用域(Global Scope)与局部做用域(Local Scope)两大类,在 ES5 中定义在函数内的变量便是属于某个局部做用域,而定义在函数外的变量便是属于全局做用域。浏览器

全局做用域

当咱们在浏览器控制台或者 Node.js 交互终端中开始编写 JavaScript 时,即进入了所谓的全局做用域:安全

// the scope is by default global
var name = 'Hammad';

定义在全局做用域中的变量可以被任意的其余做用域中访问:闭包

var name = 'Hammad';

console.log(name); // logs 'Hammad'

function logName() {
    console.log(name); // 'name' is accessible here and everywhere else
}

logName(); // logs 'Hammad'

函数做用域

定义在某个函数内的变量即从属于当前函数做用域,在每次函数调用中都会建立出新的上下文;换言之,咱们能够在不一样的函数中定义同名变量,这些变量会被绑定到各自的函数做用域中:dom

// Global Scope
function someFunction() {
    // Local Scope #1
    function someOtherFunction() {
        // Local Scope #2
    }
}

// Global Scope
function anotherFunction() {
    // Local Scope #3
}
// Global Scope

函数做用域的缺陷在于粒度过大,在使用闭包或者其余特性时致使异常的变量传递:异步

var callbacks = [];

// 这里的 i 被提高到了当前函数做用域头部
for (var i = 0; i <= 2; i++) {
    callbacks[i] = function () {
            return i * 2;
        };
}

console.log(callbacks[0]()); //6
console.log(callbacks[1]()); //6
console.log(callbacks[2]()); //6

块级做用域

相似于 if、switch 条件选择或者 for、while 这样的循环体便是所谓的块级做用域;在 ES5 中,要实现块级做用域,即须要在原来的函数做用域上包裹一层,即在须要限制变量提高的地方手动设置一个变量来替代原来的全局变量,譬如:编程语言

var callbacks = [];
for (var i = 0; i <= 2; i++) {
    (function (i) {
        // 这里的 i 仅归属于该函数做用域
        callbacks[i] = function () {
            return i * 2;
        };
    })(i);
}
callbacks[0]() === 0;
callbacks[1]() === 2;
callbacks[2]() === 4;

而在 ES6 中,能够直接利用 let 关键字达成这一点:模块化

let callbacks = []
for (let i = 0; i <= 2; i++) {
    // 这里的 i 属于当前块做用域
    callbacks[i] = function () {
        return i * 2
    }
}
callbacks[0]() === 0
callbacks[1]() === 2
callbacks[2]() === 4

词法做用域

词法做用域是 JavaScript 闭包特性的重要保证,笔者在基于 JSX 的动态数据绑定一文中也介绍了如何利用词法做用域的特性来实现动态数据绑定。通常来讲,在编程语言里咱们常见的变量做用域就是词法做用域与动态做用域(Dynamic Scope),绝大部分的编程语言都是使用的词法做用域。词法做用域注重的是所谓的 Write-Time,即编程时的上下文,而动态做用域以及常见的 this 的用法,都是 Run-Time,即运行时上下文。词法做用域关注的是函数在何处被定义,而动态做用域关注的是函数在何处被调用。JavaScript 是典型的词法做用域的语言,即一个符号参照到语境中符号名字出现的地方,局部变量缺省有着词法做用域。此两者的对比能够参考以下这个例子:

function foo() {
    console.log( a ); // 2 in Lexical Scope ,But 3 in Dynamic Scope
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;

bar();

执行上下文与提高

做用域(Scope)与上下文(Context)经常被用来描述相同的概念,不过上下文更多的关注于代码中 this 的使用,而做用域则与变量的可见性相关;而 JavaScript 规范中的执行上下文(Execution Context)其实描述的是变量的做用域。众所周知,JavaScript 是单线程语言,同时刻仅有单任务在执行,而其余任务则会被压入执行上下文队列中(更多知识能够阅读 Event Loop 机制详解与实践应用);每次函数调用时都会建立出新的上下文,并将其添加到执行上下文队列中。

执行上下文

每一个执行上下文又会分为内存建立(Creation Phase)与代码执行(Code Execution Phase)两个步骤,在建立步骤中会进行变量对象的建立(Variable Object)、做用域链的建立以及设置当前上下文中的 this 对象。所谓的 Variable Object ,又称为 Activation Object,包含了当前执行上下文中的全部变量、函数以及具体分支中的定义。当某个函数被执行时,解释器会先扫描全部的函数参数、变量以及其余声明:

'variableObject': {
    // contains function arguments, inner variable and function declarations
}

在 Variable Object 建立以后,解释器会继续建立做用域链(Scope Chain);做用域链每每指向其反作用域,每每被用于解析变量。当须要解析某个具体的变量时,JavaScript 解释器会在做用域链上递归查找,直到找到合适的变量或者任何其余须要的资源。做用域链能够被认为是包含了其自身 Variable Object 引用以及全部的父 Variable Object 引用的对象:

'scopeChain': {
    // contains its own variable object and other variable objects of the parent execution contexts
}

而执行上下文则能够表述为以下抽象对象:

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
}

变量的生命周期与提高

变量的生命周期包含着变量声明(Declaration Phase)、变量初始化(Initialization Phase)以及变量赋值(Assignment Phase)三个步骤;其中声明步骤会在做用域中注册变量,初始化步骤负责为变量分配内存而且建立做用域绑定,此时变量会被初始化为 undefined,最后的分配步骤则会将开发者指定的值分配给该变量。传统的使用 var 关键字声明的变量的生命周期以下:

而 let 关键字声明的变量生命周期以下:

如上文所说,咱们能够在某个变量或者函数定义以前访问这些变量,这便是所谓的变量提高(Hoisting)。传统的 var 关键字声明的变量会被提高到做用域头部,并被赋值为 undefined:

// var hoisting
num;     // => undefined  
var num;  
num = 10;  
num;     // => 10  
// function hoisting
getPi;   // => function getPi() {...}  
getPi(); // => 3.14  
function getPi() {  
  return 3.14;
}

变量提高只对 var 命令声明的变量有效,若是一个变量不是用 var 命令声明的,就不会发生变量提高。

console.log(b);
b = 1;

上面的语句将会报错,提示 ReferenceError: b is not defined,即变量 b 未声明,这是由于 b 不是用 var 命令声明的,JavaScript 引擎不会将其提高,而只是视为对顶层对象的 b 属性的赋值。ES6 引入了块级做用域,块级做用域中使用 let 声明的变量一样会被提高,只不过不容许在实际声明语句前使用:

> let x = x;
ReferenceError: x is not defined
    at repl:1:9
    at ContextifyScript.Script.runInThisContext (vm.js:44:33)
    at REPLServer.defaultEval (repl.js:239:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:433:10)
    at emitOne (events.js:120:20)
    at REPLServer.emit (events.js:210:7)
    at REPLServer.Interface._onLine (readline.js:278:10)
    at REPLServer.Interface._line (readline.js:625:8)
> let x = 1;
SyntaxError: Identifier 'x' has already been declared

函数的生命周期与提高

基础的函数提高一样会将声明提高至做用域头部,不过不一样于变量提高,函数一样会将其函数体定义提高至头部;譬如:

function b() {  
   a = 10;  
   return;  
   function a() {} 
}

会被编译器修改成以下模式:

function b() {
  function a() {}
  a = 10;
  return;
}

在内存建立步骤中,JavaScript 解释器会经过 function 关键字识别出函数声明而且将其提高至头部;函数的生命周期则比较简单,声明、初始化与赋值三个步骤都被提高到了做用域头部:

若是咱们在做用域中重复地声明同名函数,则会由后者覆盖前者:

sayHello();

function sayHello () {
    function hello () {
        console.log('Hello!');
    }
    
    hello();
    
    function hello () {
        console.log('Hey!');
    }
}

// Hey!

而 JavaScript 中提供了两种函数的建立方式,函数声明(Function Declaration)与函数表达式(Function Expression);函数声明便是以 function 关键字开始,跟随者函数名与函数体。而函数表达式则是先声明函数名,而后赋值匿名函数给它;典型的函数表达式以下所示:

var sayHello = function() {
  console.log('Hello!');
};

sayHello();

// Hello!

函数表达式遵循变量提高的规则,函数体并不会被提高至做用域头部:

sayHello();

function sayHello () {
    function hello () {
        console.log('Hello!');
    }
    
    hello();
    
    var hello = function () {
        console.log('Hey!');
    }
}

// Hello!

在 ES5 中,是不容许在块级做用域中建立函数的;而 ES6 中容许在块级做用域中建立函数,块级做用域中建立的函数一样会被提高至当前块级做用域头部与函数做用域头部。不一样的是函数体并不会再被提高至函数做用域头部,而仅会被提高到块级做用域头部:

f; // Uncaught ReferenceError: f is not defined
(function () {
  f; // undefined
  x; // Uncaught ReferenceError: x is not defined
  if (true) {
    f();
    let x;
    function f() { console.log('I am function!'); }
  }
  
}());

避免全局变量

在计算机编程中,全局变量指的是在全部做用域中都能访问的变量。全局变量是一种很差的实践,由于它会致使一些问题,好比一个已经存在的方法和全局变量的覆盖,当咱们不知道变量在哪里被定义的时候,代码就变得很难理解和维护了。在 ES6 中能够利用 let 关键字来声明本地变量,好的 JavaScript 代码就是没有定义全局变量的。在 JavaScript 中,咱们有时候会无心间建立出全局变量,即若是咱们在使用某个变量以前忘了进行声明操做,那么该变量会被自动认为是全局变量,譬如:

function sayHello(){
  hello = "Hello World";
  return hello;
}
sayHello();
console.log(hello);

在上述代码中由于咱们在使用 sayHello 函数的时候并无声明 hello 变量,所以其会建立做为某个全局变量。若是咱们想要避免这种偶然建立全局变量的错误,能够经过强制使用 strict mode 来禁止建立全局变量。

函数包裹

为了不全局变量,第一件事情就是要确保全部的代码都被包在函数中。最简单的办法就是把全部的代码都直接放到一个函数中去:

(function(win) {
    "use strict"; // 进一步避免建立全局变量
    var doc = window.document;
    // 在这里声明你的变量
    // 一些其余的代码
}(window));

声明命名空间

var MyApp = {
    namespace: function(ns) {
        var parts = ns.split("."),
            object = this, i, len;
        for(i = 0, len = parts.lenght; i < len; i ++) {
            if(!object[parts[i]]) {
                object[parts[i]] = {};
            }
            object = object[parts[i]];
        }
    return object;
    }
};

// 定义命名空间
MyApp.namespace("Helpers.Parsing");

// 你如今可使用该命名空间了
MyApp.Helpers.Parsing.DateParser = function() {
    //作一些事情
};

模块化

另外一项开发者用来避免全局变量的技术就是封装到模块 Module 中。一个模块就是不须要建立新的全局变量或者命名空间的通用的功能。不要将全部的代码都放一个负责执行任务或者发布接口的函数中。这里以异步模块定义 Asynchronous Module Definition (AMD) 为例,更详细的 JavaScript 模块化相关知识参考 JavaScript 模块演化简史

//定义
define( "parsing", //模块名字
        [ "dependency1", "dependency2" ], // 模块依赖
        function( dependency1, dependency2) { //工厂方法

            // Instead of creating a namespace AMD modules
            // are expected to return their public interface
            var Parsing = {};
            Parsing.DateParser = function() {
              //do something
            };
            return Parsing;
        }
);

// 经过 Require.js 加载模块
require(["parsing"], function(Parsing) {
    Parsing.DateParser(); // 使用模块
});
相关文章
相关标签/搜索