还在为 not defined 而苦恼吗?

56.gif

陈晨,微医第一利润中心前端组,一位“生命在于静止”的程序员。

书写 JavaScript 语言时,

是否经常见到这种提示报错 * is not defined

是否经常出现 undefined?

image.png

这些都是因为此时变量的访问是无效或者不可用的,而限定变量的可用性的代码范围的就是这个变量的作用域。那什么是作用域呢?

作用域

编程语言最基本的就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改,而作用域就是变量与函数的可访问范围。

作用域共有两种主要的工作模型,词法作用域(静态作用域)和动态作用域:

  • 词法作用域:作用域在定义时确认的,即写代码时将变量和块作用域写在哪里来决定的,JavaScript 使用的就是词法作用域。
  • 动态作用域:作用域在运行时确定的,比如 bashPerl

JavaScript 一共有三种作用域:

  • 全局作用域:代码最外层。
  • 函数作用域:创建一个函数就创建了一个作用域,无论你调用不调用,函数只要创建了,它就有独立的作用域。
  • ES6 的块级作用域:ES6 引入了 letconst 关键字和 {} 结合从而使 JavaScript 拥有了块级作用域,下面会详细介绍。

全局作用域

最外层是全局作用域,在脚本的任意位置都可以访问到,拥有全局作用域的变量也被称为“全局变量”。

下面看下哪些变量拥有全局作用域:

  • 浏览器中,全局作用域中有一个全局对象 window,可以直接使用。
// 获取窗口的文档显示区的高度
window.innerHeight
复制代码
  • 最外层定义的函数和变量
var a = 1
console.log(window.a) // 1 --- var 声明的 a 成为了 window 的属性,为全局变量

function func1 () {
    console.log('hello')
}
func1() // hello
window.func1() // hello
复制代码
  • 不使用关键字直接赋值的变量自动声明为拥有全局作用域,挂载在 window 对象上。
b = 2 // 全局变量
function func1 () {
    c = 2
}
func1()
console.log(window.b) // 2
console.log(window.c) // 2
复制代码

省略了关键字的变量,不管是函数外面的 b 还是函数里面的 c 都是全局变量,且挂载在 window 上,但是这种省略关键字是不规范和不利于维护的 ,不推荐使用。

变量提升

把上面的 a 代码反过来如下:

console.log(window.a) //  输出  undefined
var a = 1
复制代码

这个时候 a 在声明之前是可访问的,只是输出了 undefined,即为经常提到的“变量提升”。

变量提升:var 关键字声明的变量,无论实际声明的位置在何处,都会被视为声明在当前作用域的顶部(包括在函数和全局作用域)

因为 JS 引擎的工作方式是分为编译和执行两个阶段:

  1. 先解析代码,获取所有被声明的变量;
  2. 然后再运行。

所以下面两段代码是等价的:

console.log(a); //  输出undefined
var a =1;

// 等价于
var a;
console.log(a); // 输出undefined
a =1;
复制代码
  • 对于var a = 1,编译器遇到 var a 会在作用域中声明新的变量 a
  • 然后编译器为引擎生成运行时所需的代码,处理console.log(a)a = 1
  • 引擎运行时,从当前的作用域集合中获取变量 a(此时是 undefined ) 和给 a 赋值1

函数作用域

函数作用域内的变量或者内部函数,作用域都是函数作用域,对外都是封闭的,从外层的作用域无法直接访问函数内部的作用域,否则会报引用错误异常。如下:

function func1 () {
    var a = 1;
    return a
}
func1() // 1 函数内部是能够访问的
console.log(a) // Uncaught ReferenceError: a is not defined
复制代码

函数声明

函数声明中,JS 引擎会在代码执行之前获取函数声明,并在执行上下文中生成函数定义。

console.log(add(10, 10)) // 正常返回20
function add (a, b) {
    return a + b
}
复制代码

代码正常运行,函数声明可以在任何代码执行之前先被读取并添加执行上下文,即函数声明提升(和前面的变量声明提升一样)。

函数表达式

函数表达式必须等待代码执行到那一行,才会在执行上下文中生成函数定义。

console.log(add(10, 10)) // Uncaught TypeError: add is not a function
var add = function (a, b) {
    return a + b
}
复制代码

函数表达式 var add = function(){} 是变量声明提升。在这种情况下,add 是一个变量,因此这个变量的声明也将提升到顶部,而变量的赋值依然保留在原来的位置,所以此时的报错是变量 add 类型不对。

函数声明和变量声明

前面提到函数声明提升和变量声明提升,以及使用的现象,下面看一下两者共同使用的例子:

test() // “执行函数声明”
var test = function () {
    console.log('执行函数表达式')
}
function test (a, b) {
    console.log('执行函数声明')
}
test() // “执行函数表达式”
复制代码

第一个 test() 输出“执行函数声明”,第二个 test() 输出“执行函数表达式”,是因为经历了函数声明提升和变量声明提升(函数提升优先于变量提升),代码等价于:

// 函数声明提升到顶部
function test (a, b) {
    console.log('执行函数声明')
}

// 变量提升,变量提升不会覆盖(同名)函数提升,只有变量再次赋值时,才会被覆盖
var test

// 还在原处
test() // “执行函数声明”

test = function () {
    console.log('执行函数表达式')
}
test() // “执行函数表达式”
复制代码

块级作用域

ES6 新增的 letconst 作用域是块级作用域,由最近的一对花括号 {} 界定,以 let 为例如下:

{
  var a = 1
  let b = 2
}
console.log(a) // 1
console.log(b) // Uncaught ReferenceError: b is not defined
复制代码

在花括号内使用 let 声明的变量,在外部是无法访问的,即块级作用域。

当使用 let 关键字声明的变量提前访问时:

{
    console.log(a) // 报错 Uncaught ReferenceError: a is not defined
    let a = 1
}

复制代码

上述之所以报错是因为 let 有“暂时性死区”

暂时性死区:声明变量之前,该变量都是不可用的,只要进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

上述用代码可以等价理解为:

{
  //let a ,暂时性死区开始的地方
  console.log(a) // 由于 a = 2 在暂时性死区中,所以报错
  a = 1 // 暂时性死区结束的地方
}
复制代码

const

constlet 是一样的,也有“暂时性死区”,只是有以下限制:

  • const 声明变量的时候必须同时初始化为某个值,且不能重新赋值。
<!--Uncaught SyntaxError: Missing initializer in const declaration -->
const a 
复制代码
  • 赋值为对象的 const 变量不能再赋值其他的引用值,但是对象的键不受限制( Object.freeze() 可以完全冻结对象,键值对也不能修改)。
const a = 1
a = 1 // Uncaught TypeError: Assignment to constant variable.

//  对象
const obj = {
    a: 1,
    b: 2
}
obj.b = 3
console.log // 3
复制代码

var、let、const差别

差别 var let const
作用域 函数作用域 块作用域 块作用域
声明 同一个作用域可多次声明 同一个作用域不可多次声明 同一个作用域不可多次声明且要同时赋值,后续不可更改
特性 变量提升(且不加var是全局变量) 暂时性死区 暂时性死区

常见例子:for 循环

for (var i = 0; i< 5; i++) {
  setTimeout(function() {
    console.log(i)
  })
}
// 5 5 5 5 5
 
for (let i = 0; i< 5; i++) {
  setTimeout(function() {
    console.log(i)
  })
}
// 0 1 2 3 4
复制代码

var:全局变量,退出循环时迭代变量保存的是循环退出的时候的值,在执行超时回调的时候,所有的 i 都是同一个变量。

let:块作用域,JS 引擎为每个迭代循环声明了一个新的变量,每个超时回调调用的都是不同的变量实例。

const 不能修改值,所以不能使用 for 循环 i++const的应用如下:

// 遍历对象key
for (const key in {a: 1, b: 1}) {
  console.log(key) // a b
}

// 遍历数字
for (const val of [1, 2, 3]) {
  console.log(val) // 1 2 3
}
复制代码

eval

eval 由于性能不好、不安全、代码逻辑混乱等各种问题,一般不支持在代码里使用它,但是还是要了解下的,用网友的话就是:可以远离它,但是要了解它

这个方法就是一个完整的 ES 解释器,它接收一个参数, 即一个要执行的 ES(JavaScript)字符串,把对应的字符串解析成 JavaScript 代码并运行(将 json 的字符串解析成为 JSON 对象)。
eval 的简单用法:

  • 如果参数是字符串表达式,则对表达式进行求值
  • 如果参数是字符串且表示一个或多个 JavaScript 语句,那么就会执行这些语句
  • 如果参数不是字符串,参数将原封不动地返回
eval("2 + 2") // 输出 4
eval("console.log('hi')") // 输出 hi
eval(new String("2 + 2")) // String {'2 + 2'}
复制代码
eval 对作用域的影响

evalJavaScript 中有两种调用方式:直接调用和间接调用。

  • 直接调用时:eval 内代码块的作用域绑定到当前作用域,直接使用 eval()
function testEval () {
    eval('var a = 111')
    console.log(a) // 111
}
testEval()
console.log(a)  // 报错
复制代码

上面在 testEval 函数内部是可以获取到 a 的,所以 eval 修改了 testEval 函数作用域。

  • 间接调用时:eval 内代码块的作用域绑定到全局作用域,使用 window.eval()(IE8兼容性问题),window.execScript(支持IE8及以下的版本),为了解决兼容性问题,也可以在全局赋值给变量,然后在函数内使用。
// 有IE兼容问题
function testEval () {
    window.eval('var a = 111')
    console.log(a) // 111
}
testEval()
console.log(a)  // 111  eval定义的变量绑定到了全局作用域

// 解决兼容性问题
var evalExp = eval
function testEval () {
    evalExp('var a = 111')
    console.log(a) // 111
}
testEval()
console.log(a) // 111 eval定义的变量绑定到了全局作用域
复制代码
eval 的变量提升问题

通过 eval() 定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在 eval() 执行的时候才会被创建。

下面是letvar和函数的不同效果,如下:

// 函数
sayHi() // error: sayHi is not defined,没有函数声明提升

eval("function sayHi() { console.log('hi'); }"); 

sayHi() // hi


// var
msg // error: msg is not defined,没有变量声明提升

eval("var msg = 'hello world'") 

console.log(msg) // hello world


// let
eval("let msg = 'hello world';console.log(msg)")  // // hello world

console.log(msg) // 报错  let 作用域只能是eval内部
复制代码

作用域链

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。作用域嵌套的查询规则如下:

  • 首先,JS 引擎从当前的执行作用域开始查找变量。
  • 然后,如果找不到,引擎会在外层嵌套的作用域中继续查找。
  • 最后,直到找到该变量,或抵达最外层的全局作用域为止。

这样由多个作用域构成的链表就叫做作用域链。

例如:

var c = 1

function func () {
  var b = 2
  function add (a) {
      return a + b + c
  }
  return add
}
const addTest = func()
addTest(3) // 6
复制代码

作用域链为:

image.png

执行funcTest()的时候:

  1. 查找 add 函数作用域,查询是否有 a,有即获取传进作用域的值 3
  2. 此时获取 a 的值,继续查找 b 的值,查找 add 函数作用域,查询是否有 b,没有
  3. 查找上层作用域 func,查询是否有 b,有即获取当前作用域的值 2
  4. 此时获取到 b 的值之后,再查找 c 的值,在 add 函数作用域查询不到 c
  5. 查找上层作用域 func,依然查询不到 c
  6. 再往上一层作用域查找,即全局作用域,查询 a,查询到则获取作用域的值 1
  7. 返回 6

闭包

不知道大家有没有注意到,之前说 JavaScript 作用域是在定义时确认的,即在定义的函数外面是访问不到函数里面的变量的,但是上面作用域嵌套的例子中,addTest 却能够访问到函数 func 的内部变量,这就是因为“闭包”的存在。

闭包就是函数内部定义的函数,可以记住并访问所在的作用域,即使函数是在当前词法作用域之外执行,也可以访问内部变量。

var c = 1

function func () {
  var b = 2
  function add (a) {
      return a + b + c
  }
  return add
}
const addTest = func()
addTest(3) // 6
复制代码
  • 首先,函数 add() 的作用域能够访问 func() 的内部作用域
  • 执行 func,将内部函数 add 的引用赋值给外部的变量 addTest ,此时 addTest 指针指向的还是 add
  • add 依然持有对 func 作用域的引用,而这个引用就叫作闭包
  • 在外部执行 addTest,即外部执行 add,通过闭包能访问到定义时的作用域。

使用闭包的时候原函数 func 不会被回收,还被包含在 add 的作用域里,因此会比其他函数占用更多的内存,容易造成内存泄漏

闭包的使用

如上可知,闭包在代码里随处可见,下面看下使用场景:

回调

如上面所举的 let 循环的例子:

for (let i = 0; i< 5; i++) {
  setTimeout(function() {
    console.log(i)
  })
}
复制代码

setTimeout的回调函数记住了当前的词法作用域,当循环结束,执行函数的时候,能够访问到当时的作用域的 i

模块化

// 获取数组中的正序和逆序排列
function arrOperate () {
  let errorMsg = '请传入一个数组'
  // 正序
  function getPositiveArr(arr) {
    if (Array.isArray(arr)) {
      return arr.sort((a, b) => {
        return a - b
      })
    } else {
      throw errorMsg
    }
  }
  // 逆序
  function getBackArr(arr) {
    if (Array.isArray(arr)) {
      return arr.sort((a, b) => {
          return b - a
      })
    } else {
      throw errorMsg
    }
  }
  return {
      getPositiveArr,
      getBackArr
  }
}
const arrObj = arrOperate()
arrObj.getPositiveArr([1, 10, 5, 89, 46]) // [1, 5, 10, 46, 89]
arrObj.getBackArr([1, 10, 5, 89, 46]) // [89, 46, 10, 5, 1]
arrObj.getPositiveArr(123) // Uncaught 请传入一个数组
复制代码

这个模式在 JavaScript 中被称为模块,arrOperate() 返回一个对象,包含对内部函数的引用, 而内部函数getPositiveArr()getBackArr() 函数具有涵盖模块实例内部作用域的闭包,可访问 errorMsg

总结

作用域决定着变量的可访问范围,代码随处可见,了解作用域,避免使用访问不到的变量,减少文章开头的报错,代码质量直线上升哦。

参考资料

  • 《你不知道的 JavaScript》
  • 《JaveScript 高级程序设计》

副本_副本_未命名_自定义px_2022-02-23+16_08_59.gif

猜你喜欢

转载自juejin.im/post/7068091620922490894