javascript程序员

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

2020-09-24  本文已影响0人  橙色流年

作用域


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

01.png

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

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

作用域主要分为:
function test() {
  const a = 111
}
test()
console.log(a) // 报错 a is not defined
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 常用的场景有哪些:

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

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

function fn1() {
  console.log(this)
}
fn1()  // window
// 全局函数执行其实等价于下面这种写法
window.fn1()
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 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 中去。百度了一下,有网友给出的答案感觉说的比较好:

好吧,说实话,以前真没注意到这点,这次整理笔记也算是一个额外的小收获。扩展就到这里,咱么了解就行。继续回到正题(先用 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的指向对象,第二个参数差别就来了

通过以上总结,相信大家对 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

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

上一篇下一篇

猜你喜欢

热点阅读