JS作用域和闭包

更新:慕课 前端技术一面 第六章

执行上下文

  • 范围:一段<script>或者一个函数,一段<script>生成一个全局上下文,一个函数声明一个函数上下文。
  • 全局:在执行全局上下文之前会先将 变量定义、函数声明提升
  • 函数:在函数执行之前(注意不是定义之前),会先将 变量定义、函数声明、thisarguments(所有参数的集合)提升
  • 注意:“函数声明”function fn(){...}和“函数表达式”var fn =function(){...}的区别。(“函数表达式”不会被提升)
  • “提升”详细内容可参考《函数相关知识点补充》的“变量与函数提升”

变量、函数提升

函数提升

  • 首先要明确,变量是声明被提升,函数在ES5中是整个函数被提升,在ES6中是只提升函数的声明(具体见例子),函数内容(变量初始化的值)是不会被提升的,他们将被留在原来的位置。
  • 这导致了JavaScript 函数能够在声明之前被调用,不过会undefined,还是建议规范书写代码。
  • ES6之前只有全局作用域和函数作用域,这两个作用域都存在变量提升
  • var命令声明的变量,不管在什么位置,变量声明都会被提升到当前作用域的头部。(变量的提升只会对var命令声明的变量有效,其他不是用var命令声明的变量,不会发生变量的提升。)
  • ES6中取代varlet命令的作用域限定在块级,使用let声明的变量不存在变量提升
  • 函数和变量相比,会被优先提升。这意味着函数会被提升到更靠前的位置。
  • ES5中说,在全局和函数作用域中定义的函数的声明和定义都将会被提前到当前作用域的顶部。
  • 补充:当出现多个同名变量与同名函数时,调用该变量名时的优先级为:变量声明< 函数声明 < 变量赋值(具体参考牛客网20191215第7题)
  • 通过函数声明方式创建的函数会被提升,通过表达式方法创建的函数不会被提升
    帮助理解

this

  • this要在函数执行时才能确认值,函数定义时无法确认。
    • 箭头函数除外,箭头函数的this指向外层作用域的this
  • 在全局的上下文中调用函数时,默认this绑定window
    • 注意: 严格模式下,默认this指向undefined
  • 实际上this最终指向的是那个调用它的对象谁调用函数的就指向谁
    • 注意:对象属性函数getName内部的非箭头函数的指向是window而不是调用对象obj,因为没有指定对象调用该函数test(见下方“this指向调用对象”例子)
  • this可作为 构造函数/对象属性/普通函数 执行,另外还有call/apply/bind
  • 优先级 new>显式>隐式>默认
  • 可参考《js中this指向的四种规则+ 箭头函数this指向》

默认this绑定window

  • 注意: 严格模式下,默认this指向undefined
    1
    2
    3
    let name = '哈哈'
    console.log(this) // this 处于全局上下文 指向window
    console.log(this.name) // 此时this.name 相当于window.name, 即全局作用域下的name ‘哈哈’

this指向调用对象(隐式绑定)

  • 注意:对象属性函数内部的非箭头函数的指向是window而不是对象obj
    • 如果test是箭头函数,则取上级作用域this,自然指向obj
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      let name = '哈哈'
      let obj = {
      name:"嘿嘿",
      getName: function(){
      console.log(this) // getName是obj属性,obj.getName(),this指向obj
      console.log(this.name) // 嘿嘿

      function test(){ // 注意:这里test()调用对象不是obj
      console.log(this) // 指向window
      console.log(this.name) // 哈哈
      }
      test()
      }
      }
      obj.getName()

构造函数new的对象

  • 使用构造函数创建对象时,this指向新创建的对象
    1
    var obj = new foo() // this指向新创建的对象obj

call、apply、bind显式绑定

  • 注:当指定的对象不存在时,this仍指向默认的window,比如:call(undefind)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    var obj3 = {
    name:'吼吼'
    }
    function f3(a,b,c){
    console.log(a,b,c)
    console.log("我的this是",this)
    }

    f3(1,2,3) // 1,2,3 window

    f3.call(obj3,4,5,"call") // 4,5,"call" {name:'吼吼'}
    f3.apply(obj3,[4,5,"apply"]) // 4,5,"apply" {name:'吼吼'}
    f3.bind(obj3)(4,5,"bind") // 注意:bind特殊点,返回的是个函数 4,5,"bind" {name:'吼吼'}

箭头函数的this指向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function foo(){
console.log(this) // 因被obj4调用,所以this指的是obj4

var test2 = function(){
console.log(this) // 注意,此处指的是 window,test2不属于哪个新定义对象调用的函数
}
var test = ()=> {
console.log(this) // 箭头函数,这里this 指向的仍是 obj4 与父级作用域的this是相同的
}

test()
test2()
}

var obj4 = {
a:"哦哦",
foo:foo
}

obj4.foo()

改变this的特殊情况

1
2
3
4
5
6
7
8
9
10
11
12
// 一些api可以指定this指向
var obj2 = {name:"嘻嘻"}
var arr=[1,2,12]

// 对于arr.forEach 函数 ,当不传第二个参数时,默认this指向window
arr.forEach(function(item,index,arr){
console.log(this) // window
})
// 当有第二个参数时,指向指定的obj2
arr.forEach(function(item,index,arr){
console.log(this) // obj2
},obj2)

与严格模式的关系

  • 鉴于 this 是词法层面上的,严格模式中与 this 相关的规则都将被忽略。
    • 严格模式下的this相对于非严格模式下的this的主要区别在于:对于JS代码中没有写执行主体(对象)的情况下,非严格模式默认都是window执行的,所以this指向的是window,但是在严格模式下,没有写执行主体,this指向是undefined
  • 语法:'use strict'; 为函数开启严格模式的语法
    1
    2
    3
    4
    var f = () => { 
    'use strict'; // 为函数开启严格模式的语法
    return this;
    };

作用域

JS在ES6之前没有块级作用域,只有函数和全局作用域。【ES6规定了块级作用域,相关知识可参考笔记[let命令](https://huanglizhu.github.io/2019/10/15/let/0】
函数和全局作用域

自由变量 和 作用域链

  • 父级作用域:在函数定义时规定的,不需要管执行顺序。(可看下方的第二个例子)
  • 自由变量:在当前作用域还没有定义的变量。
  • 作用域链:函数中找不到 自由变量 的值时一层层向上面的 父级作用域 进行查找的链式流程。

自由变量例子
自由变量

父级作用域(作用域链)例子
父级作用域
6:a在F2()中找不到,到F2()的父级作用域F1()中找,还是找不到就到F1()的父级作用域(也就是全局作用域)中找,找到,打印100.

函数作为参数传递的例子:
闭包作为参数传递的例子
函数fn1作为参数传入 立即执行函数 中,在执行到fn2(30)的时候,30作为参数传入fn1中,这时候if(x>num)中的num取的并不是立即执行函数中的num(100),而是取fn1函数中的自由变量num的父级作用域(全局作用域)下的num,即15,而30>15,所以打印30。
注意:父级作用域是函数定义时决定的,不是调用时决定的!所以fn1()的父级作用域是全局作用域


闭包

  • 闭包的本质就是在一个函数内部定义另一个函数,所以闭包可以理解成“定义在一个函数内部的函数”
  • 闭包作用:让一个函数有权访问另一个函数作用域中变量
  • MDN知乎上一个很好的解释和更多的例子
  • 「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包。
  • 实例:实例
    • 假设上面三行代码在一个立即执行函数中
    • 三行代码中,有一个局部变量 local(也就是“环境”),有一个函数 foo,foo 里面可以访问到 local 变量。
    • 在这里“局部变量 local 和 函数 foo ”就是一个闭包
  • 闭包的3个特性
    • 函数中定义函数
    • 封闭性外界无法访问闭包内部的数据,如果在闭包内声明变量,外界是无法访问的,除非闭包主动向外界提供访问接口;
    • 持久性:一般的函数调用完毕之后,系统自动注销函数,而对于闭包来说,在外部函数被调用之后,闭包结构依然保存在(参数和变量不会在函数被调用完后被垃圾回收机制回收

例子

游戏中的例子
例子2
假设我们在做一个游戏,在写其中关于「还剩几条命」的代码。如果不用闭包,你可以直接用一个全局变量:window.lives = 30 // 还有三十条命
但这样看起来很不妥。万一不小心把这个值改成 -1 了怎么办。所以我们不能让别人「直接访问」这个变量。怎么办呢?
用局部变量
但是用局部变量别人又访问不到,怎么办呢?
**暴露一个访问器(函数),让别人可以「间接访问」生命值lives**。
使用闭包后,在其他的 JS 文件中就可以使用 window.奖励一条命() 来涨命,使用 window.死一条命() 来让角色掉一条命。

作用

帮助js回调函数获取this

  • js回调函数中的this默认是指向window
  • 例子:在使用回调函数之前利用**闭包的特性**先使用一个变量_this保存this
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const xiaoming = {
    name:"xiaoming",
    age: null,
    getAge: function () {
    let _this = this;
    //...ajax
    setTimeout(function () {
    _this.age = 15;
    console.log(_this);
    }, 1000);
    }
    };
    xiaoming.getAge();//{name: "xiaoming", age: 15, getAge: f}

隐藏一个变量(闭包的封闭性)

  • 用于隐藏一个变量,让外界无法直接访问这个变量,但可以主动向外界提供访问接口,将更改变量的函数绑在window对象上使外界可以间接更改变量
  • 游戏中加减生命值的例子

保存一个变量特殊状态中的值(闭包的持久性)

  • 持久性:一般的函数调用完毕之后,系统自动注销函数,而对于闭包来说,在外部函数被调用之后,闭包结构依然保存在(参数和变量不会在函数被调用完后被垃圾回收机制回收)(例子如下“创建10个a标签点击弹出相应数字”的解决方案)普通函数与闭包对比

好处与坏处

  • 好处:
    • 保护函数内的变量安全 ,实现封装,防止变量流入其他环境发生命名冲突
    • 在内存中维持一个变量特殊状态中的值,可以做缓存(但使用多了同时也是一项缺点,消耗内存)
      • 可以使用 匿名自执行函数 来保存某个特殊状态中的值,调用完毕之后,系统自动注销函数,可以减少内存消耗
      • 注意:匿名函数自执行只是产生闭包的一种情况,闭包是现象或者情形,不实用匿名函数自执行也有很多情况产生闭包,所以两者之间根本就是两回事儿,不能混淆。
  • 坏处:
    • 由于闭包会使得函数中的变量都被保存在内存中内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
      • 外部无法访问到闭包里面的变量,但可以在闭包内部返回一个方法,该方法将闭包内部的变量设置为null,让变量失去引用,会被系统自动回收
      • 如果闭包不需要了,想删除闭包,直接将子函数设置为null就可以了。
  • 注意
    闭包不会造成内存泄漏(参考1参考2):
    知乎

相关题目

说一下对变量提升的理解

【变量定义、函数声明(注意和函数表达式的区别)】

  • 首先要明确,变量是声明被提升,函数在ES5中是整个函数被提升,在ES6中是只提升函数的声明(具体见例子),函数内容(变量初始化的值)是不会被提升的,他们将被留在原来的位置。
  • 这导致了JavaScript 函数能够在声明之前被调用,不过会undefined,还是建议规范书写代码。
  • ES6之前只有全局作用域和函数作用域,这两个作用域都存在变量提升
  • var命令声明的变量,不管在什么位置,变量声明都会被提升到当前作用域的头部。(变量的提升只会对var命令声明的变量有效,其他不是用var命令声明的变量,不会发生变量的提升。)
  • 注意:变量的提升只存在全局/函数作用域中,**if(){}for(){}都不是函数作用域,他们里面的变量会提升到包裹他们的全局/函数作用域的顶部**。
  • ES6中取代varlet命令的作用域限定在块级,使用let声明的变量不存在变量提升
  • 函数和变量相比,会被优先提升。这意味着函数会被提升到更靠前的位置。
  • ES5中说,在全局和函数作用域中定义的函数的声明和定义都将会被提前到当前作用域的顶部。
  • 补充:当出现多个同名变量与同名函数时,调用该变量名时的优先级为:变量声明< 函数声明 < 变量赋值(具体参考牛客网20191215第7题)
  • 通过函数声明方式创建的函数会被提升,通过表达式方法创建的函数不会被提升
    帮助理解

this的不同使用场景

  1. 普通函数中执行,**this指向window**
    • 例子window.fn1();fn1()效果一样,所以函数fn1()是由window对象调用的,根据谁调用函数的this就指向谁的原则,this指向window
  2. 借助call()/apply()/bind()绑定/改变this指向,绑定的this是谁就是谁
    • 注意:与call、apply不同,必须要使用**函数表达式创建才能使用bind()**,使用bind()传参的方式也不同,可参考笔记bind()bind
  3. 对象属性(方法)中 执行,this指向对象本身
  4. class/构造函数 里使用thisthis指向class实例/构造函数实例本身
  5. 箭头函数 里使用thisthis是该函数的上级作用域的this
    例子1

手写bind函数

类数组转换为数组的多种方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// fn1()的隐式原型 指向 Function的显式原型,所以fn1.bind1()在Function.prototype上定义
// 模拟 bind
Function.prototype.bind1 = function () {
// 将参数拆解为数组
const args = Array.prototype.slice.call(arguments)

// 获取 this(数组第一项)
const t = args.shift()

// fn1.bind(...) 中的 fn1
const self = this

// 返回一个函数
return function () {
return self.apply(t, args)
}
}

function fn1(a, b, c) {
console.log('this', this)
console.log(a, b, c)
return 'this is fn1'
}

const fn2 = fn1.bind1({x: 100}, 10, 20, 30)
const res = fn2()
console.log(res)

创建10个<a>标签,点击时弹出对应的序号

错误的例子

错误的例子
错误原因分析

  1. 确实可以得到0-9的10个<a>标签
  2. 但是**addEventListener()中添加的函数时要点击以后才会执行的,当我们10个标签都出现在页面上以后,i值变为10**。
  3. for循环这10下是个很快的过程,等循环结束我们点击a标签,触发click的函数时,i是个自由变量,他要去父级作用域中找i的值,此时i已经循环到了10
  4. 所以不管点击哪个标签,弹出的警示框的i都是10,并不是对应的序号

总结:之所以会错就是因为这里的i是 全局作用域 下的i。

理解方式2:
1.循环是同步的,所以创建标签时得到0-9是没问题的。
2.但 事件绑定是异步的,所以他会等循环全部结束以后才开始执行
3.可i是全局变量,也就是说每次循环改变的都是同一个i的值,循环结束后i值变为10,此时事件绑定函数才开始执行,那么他每次到父级作用域中取到的其实都是**i=10,所以不管点击哪个标签弹出的都是10**。

正确示例

  1. 方法1:【使用闭包的持久性,注意重点不在匿名自执行函数,将i传进函数中作为 函数作用域 的变量】:正确示例
    1. i=0时生成一个函数,i=1时又生成另外一个函数,总共通过循环创建了10个函数
    2. 那么第9行代码去获取i的值时就会到当前作用域(函数作用域)中找,自然也不会10个标签都找到同一个i了。
  2. 方法2:【for中使用let代替var定义i】
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //  let 是块级作用域,所以 let代替var定义i 能起到和闭包相同的效果
    // 注意:let i在for外面没用,那样i的块级作用域太大了
    var a;
    for (let i = 0; i < 10; i++) {
    a = document.createElement("a");
    a.innerHTML = i + "<br/>";
    a.addEventListener("click", function (e) {
    e.preventDefault();
    alert(i);
    })
    document.body.appendChild(a);
    }

如何理解作用域

  • 自由变量,即当前作用域还没有定义的变量
  • 作用域链,即自由变量的层层查找
  • 闭包的使用场景(也和作用域有关)

实际开发中闭包的应用

隐藏数据,如做一个简单的cache工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 闭包隐藏数据,只提供 API
function createCache() {
const data = {} // 闭包中的数据,被隐藏,不被外界访问
return {
set: function (key, val) {
data[key] = val
},
get: function (key) {
return data[key]
}
}
}

const c = createCache()
c.set('a', 100)
console.log( c.get('a') )

判断用户是不是第一次校验
实际应用1
好处:将存储用户id的 变量_list 封装起来,那么闭包外面就拿不到 变量_list,减少用户数据泄露的危险。

游戏中的作用
例子2
假设我们在做一个游戏,在写其中关于「还剩几条命」的代码。如果不用闭包,你可以直接用一个全局变量:window.lives = 30 // 还有三十条命
但这样看起来很不妥。万一不小心把这个值改成 -1 了怎么办。所以我们不能让别人「直接访问」这个变量。怎么办呢?
用局部变量
但是用局部变量别人又访问不到,怎么办呢?
**暴露一个访问器(函数),让别人可以「间接访问」生命值lives**。
使用闭包后,在其他的 JS 文件中就可以使用 window.奖励一条命() 来涨命,使用 window.死一条命() 来让角色掉一条命。


setTimeout()与let、闭包

(其实和上面的“创建10个<a>标签,点击时弹出对应的序号”类似,事件绑定和setTimeout都是异步。而他们到父级作用域(这里是全局作用域)中找i时拿到的都是同一个i,所以我们要想办法让他们的 父级作用域 变成 函数作用域,每个i都保存在不同的函数中,这样就不会重复了)

1
2
3
4
5
for (var i = 0; i < 10; i++) {
setTimeout(() => {
console.log(i);
}, i);
}

结果:打印10个10
原因:
因为循环是同步的,setTimeout 是异步的,所以循环结束以后才会执行setTimeout
而变量i是var命令声明的,在全局范围内都有效,所以全局只有一个变量i。所以每一次循环的i其实都是同一个,循环到最后时前面的i全部被最后的结果覆盖了。
所以最后,每次执行setTimeout时到父级作用域(这里是全局作用域)中找到的i都是10,10次打印的都是最后保存的i。

解决方法局部变量的话就是每循环一次产生一个新的i,就不会被覆盖。所以我们可以通过闭包(也就是把循环的过程放到一个函数中封闭起来)

闭包:函数a内部定义一个函数b,函数b可以访问函数a的变量,函数b中的变量就是局部变量

如何让他输出自增队列

自增队列

方法1:将var改为let

1
2
3
4
5
for (let i = 0; i < 10; i++) {
setTimeout(() => {
console.log(i);
}, i);
}

变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量

方法2:使用闭包(即局部变量)

1
2
3
4
5
6
7
for (var i = 0; i < 10; i++) {
((j) => {
setTimeout(function () {
console.log(j)//0-9
}, i)
})(i)
}

【将i传进函数中作为 函数作用域 的变量】

  1. i=0时生成一个函数,i=1时又生成另外一个函数,总共通过循环创建了10个函数循环了10次函数就会产生10个局部变量j,最后每个函数中的setTimeout都对应不同的j值。
  2. 那么setTimeout的参数函数去获取i的值时就会到当前的 父级作用域(函数作用域)中找,自然也不会10个标签都找到同一个i了。