美文网首页javascript程序员
JavaScript基础篇(三)作用域和闭包

JavaScript基础篇(三)作用域和闭包

作者: 橙色流年 | 来源:发表于2020-09-24 16:03 被阅读0次

    作用域


    作用域是啥?相信很多小伙伴可能都清楚,但是说明白估计可能有点悬!老规矩,看图说话:

    01.png

    结合图我们先来总结一下作用域的概念:变量合法的使用范围。

    上图中红色区域的就是当前变量所能使用的合法范围。变量 a 在最外成,所以可以理解而为它的作用域在全局都可以合法使用。变量 a1 在函数 fn1 里面,即说明在 fn1 函数中的任何地方都可以合法使用 a1 这个变量,依次向下......

    作用域主要分为:
    • 全局作用域
      全局都可以使用,例如上图中的变量 a
    • 函数作用域
      只在声明变量的函数中可以使用,函数外访问不到该变量。
    function test() {
      const a = 111
    }
    test()
    console.log(a) // 报错 a is not defined
    
    • 块级作用域(ES6 新增)
      只要用 letconst 定义的变量,它的作用域其实就是在被 {} 包裹的这个区域。
    if (true) {
      let a = 222
    }
    console.log(a) // 报错 a is not defined
    
    自由变量

    自由变量指的是:一个变量在当前作用域没有定义,但被使用了,此时会向上级作用域一层一层依次寻找,直到找到为止。如果一直找到全局作用域还没找到该变量的定义声明,则报错 xx is not defined
    如上图中在 fn3() 函数中,aa1a2 就都是自由变量,因为他们在当前函数中没有被定义,所以会向上级作用于逐层寻找。

    闭包


    感觉看名字就觉得高大上,其实撕下它的外衣,本质上就是作用域应用的特殊情况而已,一般有两种表现:

    • 函数作为参数被传递
    • 函数作为返回值被返回

    干巴巴的文字总是让人难以理解,先来看一段代码:

    // 函数作为返回值被返回
    function create() {
      let a = 100
      return function () {
        console.log(a)
      }
    }
    const fn = create()
    let a = 200
    fn()
    

    猜猜上面的代码执行之后会打印什么结果?再来看下面一种情况:

    // 函数作为参数被传递
    function print(fn) {
      let a = 200
      fn()
    }
    let a = 100
    function fn() {
      console.log(a)
    }
    print(fn)
    

    这两段函数执行的结果其实都是 100,没答对的小伙伴那你对作用域的概念就可能还是没有理解太清楚。关于闭包中自由变量的值我们先来做一个小总结吧:

    总结:所有自由变量的查找,都是在函数定义的地方向上级作用域逐级进行查找,而不是在函数执行的地方查找。

    我们可以来看一个闭包的小栗子加深我们对闭包的应用和理解:

    function createCache() {
      const data = {} // 闭包中的数据,被隐藏,不被外界所访问
      return {
        set: function (k, val) {
          data[k] = val
        },
        get: function (k) {
          return data[k]
        }
      }
    }
    const a = createCache()
    a.set('a', 100)
    console.log(a.get('a'))
    

    这里写了一个生成缓存数据的小方法,我们将数据全部都保存到 data 中,但是我们不想该数据被外部直接访问到,外部只能通过我们开放的 API 访问我们愿意开放给它的数据,这时候就可以用闭包来实现。

    this


    首先我们总结一下 this 常用的场景有哪些:

    • 作为普通函数
    • 使用 callbindapply
    • 作为对象方法被调用
    • class 方法中调用
    • 箭头函数

    this 在各个场景中取什么值,是在函数执行的时候确定的,不是在函数定义的时候被确定的。

    先总结,再来看各个场景的栗子:

    • 普通函数
    function fn1() {
      console.log(this)
    }
    fn1()  // window
    // 全局函数执行其实等价于下面这种写法
    window.fn1()
    
    • 比如说 callbindapply 来执行
    function fn1() {
      console.log(this)
    }
    fn1.call({ x: 100 }) // {x: 100}
    fn1.apply({ x: 100 }) // {x: 100}
    fn1.bind({x: 100})() // {x: 100}
    

    callapplybind 都可以改变 this 的指向,这里我们先不追究它们的深入用法,后面会说。先了解基础用法:callapply 的第一个参数都是 this 的指向,bind 的第一个参数也是改变 this 指向,但是它会返回一个新的函数再去执行,所以我们上面的写法在最后面加了一个函数自执行的 ()

    • 作为对象方法被调用
    // 第一种情况
    const zhangsan = {
      name: '张三',
      sayHi() {
        console.log(this) // {name: "张三", sayHi: ƒ}
      }
    }
    zhangsan.sayHi()
    // 第二种情况
    const zhangsan = {
      name: '张三',
      wait() {
        setTimeout(function () {
          console.log(this) // window
        }, 1)
      }
    }
    zhangsan.wait()
    

    第一种情况很容易理解,第二种情况虽然我们调用的是 zhangsan.wait() 去进行执行的,但是其实它内部的 setTimeout 其实是挂载在全局 window 下的一个方法,所以这里 this 的指向其实指向的是执行 setTimeout 函数的 window 对象。那么问题来了,这肯定不符合我们的预期,如何将 this 指向重新指回 zhangsan 呢?那就来认识一下箭头函数吧!!!

    • 箭头函数
    const zhangsan = {
      name: '张三',
      wait() {
        setTimeout(() => {
          console.log(this) // {name: "张三", sayHi: ƒ, wait: ƒ}
        }, 1)
      }
    }
    zhangsan.wait()
    

    小朋友,你是否有很多问号?其实 箭头函数中的 this 永远是取它上一级作用域中的 this,它自己本身不会决定 this 的值。所以我们在构造函数中使用全局 window 的方法时,如果害怕 this 指向的问题,那么我们可以统一用箭头函数来写。

    • class 类中 this 指向
    class People {
      constructor(name) {
        this.name = name
        this.age = 20
        console.log(this) // People {name: "李四", age: 20}
      }
      sayHi() {
        console.log(this) // People {name: "李四", age: 20}
      }
    }
    const lisi = new People('李四')
    lisi.sayHi()
    

    class 本质上也是构造函数,所以其实 this 指向也比较简单,就是生成的实例对象,简单的说就是谁调用它,this 就指向谁。

    变量提升


    首先我们要知道,js的执行顺序是由上到下的,但这个顺序,并不完全取决于你,因为js中存在变量的声明提升。如下栗子:

    console.log(a)  //undefined
    var a = 100
    
    fn('zhangsan')
    function fn(name){
        age = 20
        console.log(name, age)  //zhangsan 20
        var age
    }
    

    聪明的你观察以上代码应该会察觉到一些问题,首先看变量 a 在我们未定义的情况下不是打印的 a is not defined 而是打印的 undefined ,这是为什么呢?再看 fn 函数,我们先执行了,但是我们还没有声明这个函数,结果却正确执行并打印出了我们想要的结果,这又是为什么呢?

    这就是变量的声明提升,代码虽然写成这样,但其实执行顺序是这样的。

    var a
    function fn(name){
        age = 20
        console.log(name, age)
    }
    console.log(a) 
    a = 100
    fn('zhangsan')
    

    js会把所有的声明提到前面,然后再顺序执行赋值等其它操作,因为在打印a之前已经存在a这个变量了,只是没有赋值,所以会打印出 undefined,而不是报错,fn同理。

    这里我们使用的 var 来进行定义的,但是现在日常的开发中,能用 letconst 定义就不要用 var,上述中关于变量提升的问题,如果我们使用 let 或者 const 就可以完美避开。

    console.log(a) // Cannot access 'a' before initialization
    console.log(b) // Cannot access 'b' before initialization
    let a = 20
    const b = 30
    

    踩雷提醒:这里要注意函数声明和函数表达式的区别。上例中的fn是函数声明。接下来通过代码区分一下。

    fn1('abc')
    function fn1(str){
        console.log(str) // abc
    }
    fn2('def') // fn2 is not a function
    var fn2 = function(str){
        console.log(str)
    }
    

    可以看到fn1被提升了,而fn2的函数体并没有被提升。其实在函数表达式中,代码的执行顺序是这样的:

    var fn2
    fn2('def')
    fn2 = function(str){
        console.log(str)
    }
    

    变量提升其实比较简单也好容易理解,这里顺便记录一下整理到一起方便查阅和复习。

    apply()、bind()、call() 的用法


    前面我们知道,这三个方法都是用来改变 this 指向的,我们先写一段代码来回顾 this 指向的问题:

    let name = 'zhangsan', age = 18;
    const obj = {
      name: 'lisi',
      age: 22,
      objAge: this.age,
      myFun() {
        console.log('姓名:' + this.name + ',年龄:' + this.age)
      }
    }
    console.log(obj.objAge) // undefined
    obj.myFun() // 姓名:lisi,年龄:22
    

    好吧,翻车了,貌似不符合我们的预期......obj.objAge 中的 this 理论上来说应该找 obj 作用域的上一层也就是 window ,然后我们全局定义了 age 这个属性,所以理论上来说应该是 18,结果得到了 undefined,有没有觉得啪啪打脸,所以我们还是要先把这个原因搞清楚?我们将全局的 let 定义改为 var 试试,如下栗子:

    var name = 'zhangsan', age = 18;
    const obj = {
      objAge: this.age,
    }
    console.log(obj.objAge) // 18
    

    初步定位到应该是全局使用 let 定义产生的问题,使用 var 进行全局变量声明的时候会将该变量注册到 window 中去,但是 let 定义的全局变量并不会注册到 window 中去。百度了一下,有网友给出的答案感觉说的比较好:

    • ES5声明变量只有两种方式:var和function。
    • ES6有let、const、import、class再加上ES5的var、function共有六种声明变量的方式。
    • 还需要了解顶层对象:浏览器环境中顶层对象是window,Node中是global对象。
    • ES5中,顶层对象的属性等价于全局变量。(敲黑板了啊)
    • ES6中,有所改变:var、function声明的全局变量,依然是顶层对象的属性;let、const、class声明的全局变量不属于顶层对象的属性,也就是说ES6开始,全局变量和顶层对象的属性开始分离、脱钩。

    好吧,说实话,以前真没注意到这点,这次整理笔记也算是一个额外的小收获。扩展就到这里,咱么了解就行。继续回到正题(先用 var 来定义,毕竟这里我们主要是了解)

    var name = 'zhangsan', age = 18;
    const obj = {
      name: 'lisi',
      age: 22,
      objAge: this.age,
      myFun() {
        console.log('姓名:' + this.name + ',年龄:' + this.age)
      }
    }
    console.log(obj.objAge) // 18
    obj.myFun() // 姓名:lisi,年龄:22
    obj.myFun.apply() // 姓名:zhangsan,年龄:18
    obj.myFun.call() // 姓名:zhangsan,年龄:18
    obj.myFun.bind()() // 姓名:zhangsan,年龄:18
    

    通过代码可以看到,加上这三个方法之后,myFun()this 的指向都变成了 window ,所以我们这里先得出一个初步结论:

    call()apply()bind() 都是用来重定义 this 对象的!如果三个方法里面默认不传参的话即默认会指向 window ,当然bind方法后面多了个(),这说明bind返回的是一个新的函数,我们必须调用它之后才会被执行。

    如果我们往这三个方法里面传参,那么第一个参数就是我们要绑定的 this,如下栗子:

    var name = 'zhangsan', age = 18;
    const obj = {
      name: 'lisi',
      age: 22,
      myFun() {
        console.log('姓名:' + this.name + ',年龄:' + this.age)
      }
    }
    const db = {
      name: 'zhaoliu',
      age: 66
    }
    obj.myFun.apply(db) // 姓名:zhaoliu,年龄:66
    obj.myFun.call(db) // 姓名:zhaoliu,年龄:66
    obj.myFun.bind(db)() // 姓名:zhaoliu,年龄:66
    

    通过上面栗子可以看到,我们已经成功将 myFun 中的 this 指向绑定到 db 这个新的对象中来了。相信小伙伴看了会觉得 call()apply() 的用法感觉都一模一样,为啥要用两个语法呢?

    其实它们三个后面也可以继续绑定参数,后面对应的就是我们想要传递的值。而 call()apply() 主要的区别就是绑定后面参数的方式不同。如下栗子:

    var name = 'zhangsan', age = 18;
    const obj = {
      name: 'lisi',
      age: 22,
      myFun(num1, num2) {
        console.log('姓名:' + this.name + ',年龄:' + this.age, num1, num2)
      }
    }
    const db = {
      name: 'zhaoliu',
      age: 66
    }
    obj.myFun.apply(db, [10, 100]) // 姓名:zhaoliu,年龄:66 10 100
    obj.myFun.call(db, 20, 200) // 姓名:zhaoliu,年龄:66 20 200
    obj.myFun.call(db, 30, 300)() // 姓名:zhaoliu,年龄:66 30 300
    

    微妙的差距!从上面四个结果不难看出:callbindapply 这三个函数的第一个参数都是this的指向对象,第二个参数差别就来了

    • call 的参数是直接放进去的,第二第三第 n 个参数全都用逗号分隔,
    • apply 的所有参数都必须放在一个数组里面传进去
    • bind 除了返回是函数以外,它的参数和 call 一样
    • 当然,三者的参数不限定是 number 类型,允许是各种类型,包括函数 、 object 等等!

    通过以上总结,相信大家对 applycallbind 这三个方法都有一个基本的了解呢,咱们作为程序员,还是要有拓展精神吗,知其然不知其所以然咋行。

    扩展练习:手写 bind()apply()call() 的实现

    该从哪里入手呢?我们可以先看上面的代码 obj.myFun.call() 很明显,obj.myFun 中肯定没有 call() 这个方法,那为啥它可以直接调用呢?这说明这个方法可能挂载在最顶层的 Function 中,而原型链的顶层 Function.prototype 中肯定是有 call 这个方法的。我们代码来验证一下:

    console.log(Function.prototype.call) // ƒ call() { [native code] }
    console.log(obj.myFun.__proto__ === Function.prototype) // true
    

    所以我们也应该有了自己的思路,实现属于自己的 call 方法就需要在 Function.prototype 中加入自己的方法,接下来就是代码时间了:

    // call()
    Function.prototype.myCall = function (context) {
      // 未传参的情况下默认为 window
      context = context || window
      // 将当前被调用的方法定义在 context.fn 上
     // 其实就是改变作用域,将 obj.eat 方法挂载在 obj1 上,保证 this 的指向从 obj 转移到 obj1上)
      context.fn = this
      // arguments 接收传递的参数,它自身是一个伪数组,通过 Array.from 转变成一个数组
     // 并使用 slice() 方法移除数组中的第一项
     // 因为第一个参数是我们要绑定的 this 对象,我们实际上只要后面的参数部分
      const args = Array.from(arguments).slice(1)
      // 判断传递的参数个数,如果只有 1 个就说明该参数为我们绑定的 this,执行函数即可
      // 如果大于 1 个说明有传参,将其解构绑定到函数中。
      let result = arguments.length > 1 ? context.fn(...args) : context.fn()
      // 删除该方法,不然会对传入对象造成污染
      delete context.fn
      return result
    }
    // 验证我们写的代码是否正确
    let obj = {
      name: 'cc',
      eat(num1, num2, obj2) {
        console.log(this, num1, num2, obj2) // {name: "wc", fn: ƒ} 100 200 {name: "zzz"}
        console.log(this.name) // wc
      }
    }
    let obj1 = {
      name: 'wc'
    }
    obj.eat.myCall(obj1, 100, 200, { name: 'zzz' })
    

    使用我们自己写的 myCall 方法终于成功完成了 call 方法的功能。那我们接下来认识 apply 方法吧。我们从用法中可以看出,其实 call()apply() 的用法只有些许区别,就是在传参上 apply() 需要指定传入的参数为数组类型。

    // apply()
    // 其实基本和 call() 差不多,就不累赘的写注释了
    Function.prototype.myApply = function (context) {
      context = context || window
      context.fn = this
      const args = Array.from(arguments).slice(1)
      // apply()方法的原则是后续参数要以数组形式传递
      let result = args.length > 0 ? context.fn(...args[0]) : context.fn()
      delete context.fn
      return result
    }
    // 简单验证
    let obj = {
      name: 'cc',
      eat(num1, num2, num3) {
        console.log(this.name, num1, num2, num3) // wc 1 2 3
      }
    }
    let obj1 = {
      name: 'wc'
    }
    obj.eat.myApply(obj1, [1, 2, 3])
    

    bind 方法并不会直接返回,而是返回一个函数,这里的情况可能比我们想象的要稍微复杂那么一丢丢,例如 obj.eat.bind(obj1, 18)() 这是一种,并直接接上要传递的参数,还有可能出现 obj.eat.bind(obj1)(18) 这种情况,参数附加在返回函数的形参中,是不是这个道理~~~那么我们就来研究研究:

    // bind()
    Function.prototype.myBind = function (context) {
      context = context || window
      context.fn = this
      const args = Array.from(arguments).slice(1)
      // 最后返回一个函数
      return function () {
        // 这里的 arguments 不要混淆,在这里属于当前 return 的 function 传递的参数集合
       //  对应的是 obj.eat.bind(obj1)(18) 这里的 18
        const allArgs = args.concat(Array.from(arguments))
        return allArgs.length > 0 ? context.fn(...allArgs) : context.fn()
      }
    }
    // 验证我们写的 myBind
    let obj = {
      name: 'cc',
      eat(age) {
        console.log(this.name, age)
      }
    }
    let obj1 = {
      name: 'wc'
    }
    obj.eat.myBind(obj1,18)() // wc 18
    obj.eat.myBind(obj1)(18) // wc 18
    

    好吧,基础总结就写这么多了,上述手写只是简单模拟,并不一定适用所有场景的使用。如果文中有不对的地方或者理解有误的地方欢迎大家提出并指正。每一天都要相对前一天进步一点,加油!!!

    相关文章

      网友评论

        本文标题:JavaScript基础篇(三)作用域和闭包

        本文链接:https://www.haomeiwen.com/subject/hwcoyktx.html