JavaScript 作用域和闭包

650 阅读12分钟

作用域和闭包

  我们继续讲js基础第三个比较重要的点:作用域和闭包 ,上一章讲了原型和原型链,说是比较重要的技术点,作用域和闭包同样也是,很多面试官会问,如果作用域和闭包过不了的话 基本都不会要的。这一点特别看重,我们写的东西,写的逻辑,写的函数,有可能是模块之间相互调用,可能会产生比较复杂的关系,如果这些关系你理不清,关系之间的作用域的问题,我是不敢要你的,你写的代码可能不知不觉就会写出很多Bug来。

废话不多说 开始!

作用域和自由变量

  作用域就是代表了某个变量的合法适用范围,比如说最外层的红框,a 可以在红框内的任何地方被使用,然后a1可以在第二个红框内的任何地方被使用,a2同理。反例,比如说我想把a2放在红框外去使用,那就会报错。作用域就类似于红框内变量的适用范围。

通过代码和这个红框应该很容易理解

全局作用域

函数作用域

块级作用域(es6新增)

// ES6 块级作用域
if (true) { // 大括号里面就是 块,如果在块外面之外使用就会报错 const和let是一样的
  	let x = 100
}
console.log(x) // 会报错

自由变量

1. 一个变量在当前作用域没有定义,但被使用了

2. 向上级作用域一层一层一次寻找,直至找到为止

3. 如果到全局作用域都没找到,则报错 xx is not defined

  再回顾上图,现在看第一条,比如说最里面的红框内 a, a1, a2都是自由变量。a3就不是 因为a3在当前作用域被定义了,当我执行fn3的时候,a根本没有被定义,没有定义怎么办呢,第二条,向上级作用域去寻找,父级作用域就是第二层红框,就找到了a2等于100,找到之后就停止,不再找,就算是再外层也有a2我们也不用管,再找a1,a1也向上依次寻找,找到a1等于100就不再找,a也是同理 找到最外层作用域就不再找。如果还有另外一个变量,依次找到最外层还是没有,那就说明没定义,就会报错,这就是自由变量。

闭包

闭包这个词听着很专业,它只是作用域应用的特殊情况,有两种表现:

1. 函数作为参数被传递

函数在一个地方被定义好后,传递到另外一个地方去执行

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

  分析:fn()函数执行,是在全局作用域,没有被函数包裹。a = 200也是全局作用域,然后函数是在create函数作用域,那a=100 也是在create函数作用域,也就是说我们在函数里面执行函数的时候打印a ,a是个自由变量,自由变量寻找的时候,它应该在它定义的地方去寻找,首先我们在函数内部打印a,a没有被定义我们就应该去上一级作用域寻找,那上级作用域应该是它函数定义的地方的上级作用域,也就是let a = 100

2. 函数作为返回值被返回

函数在一个地方被定义好以后,它会返回到另一个地方去执行。总之函数定义的地方和执行的地方不一样

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

  分析:我们在print内执行函数的时候,执行的时候是在print的作用域下面,执行的是一个fn函数,fn函数里面打印了一个a,它是个自由变量,自由变量它应该在fn函数的作用域下面,它应该像上级作用域寻找,它也是在定义的作用域像外层寻找,然后找到a = 100

所有的自由变量的查找,是在函数定义的地方像上级作用域查找,不是在执行的地方!!!

this

对于初学者来说,this比较复杂,也不能完全说它复杂,只能说它应用场景比较多!

function fn1() {
    console.log(this)
}
fn1() // window

fn1.call({ x: 100 }) // {x: 100}

const fn2 = fn1.bind({ x: 200 })	// bind会返回一个新的函数去执行
fn2() // {x: 200}

  重点!! 大家记住一句话:this在各个场景中取什么样的值,是在函数执行的时候决定的,不是在定义的时候决定的,适用于以下五种场景

1. 作为普通函数

function fn1() {
    console.log(this)
}
fn1() // window

2. 使用call apply bind

function fn1() {
    console.log(this)
}
fn1.call({ x: 100 }) // {x: 100}

const fn2 = fn1.bind({ x: 200 })	// bind会返回一个新的函数去执行
fn2() // {x: 200}

3. 作为对象方法被调用

const zhangsan = {
    name: "张三",
    sayHi() {
        // this 即当前对象
        console.log(this)
    },
    wait() {
        setTimeout(function () {
            // this === window
            console.log(this)
        });
    }
}

  此处需要注意:setTimeout里面有一个函数,里面有this,这个this就是window,因为这个时候,这个函数被执行就是跟第一种情况被当做普通函数来执行是一样的,也就是说,这个函数被执行是setTimeout本身触发的执行,它并不是我们zhangsan.sayHi() 这种方式来执行。所以说,这里面的this就是window,它是作为一个普通函数被执行的,它不是作为一个对象的方法被执行的。

4. 在class方法中被调用

class People {
    constructor(name) {
        this.name = name
        this.age = 20
    }
    sayHi() {
        console.log(this)
    }
}
const zhangsan = new People("张三")
zhangsan.sayHi() // zhangsan对象

constructor的this就是代表正在创建的这个实例,sayHi里面的this就是代表zhangsan这个实例

5. 箭头函数

const zhangsan = {
    name: "张三",
    sayHi() {
        // this 即当前对象
        console.log(this)
    },
    waitAgain() {
        setTimeout(() => {
            // this 即当前对象
            console.log(this)
        });
    }
}

  setTimeout里面如果写一个箭头函数,this就是当前对象了,为什么呢?我说过,如果在waitAgain里面直接去打印this,那肯定和sayHi的this是一样的,因为是当前对象。这个没有问题,那setTimeout里面如果有一个箭头函数,箭头函数是被setTimeout触发的,它不是对象方法,但是有一点记住:箭头函数它的this永远取它上级作用域的this,它自己本身不会决定this的值。所以说,箭头函数有这个特点,基于这个特点我们就可以去使用,比如说箭头函数里面的this和外面的this是一样的,和sayHi里面的this是一样的,所以说这个地方this就是当前对象。

this的不同应用场景,如何取值?

上述已讲,如果看懂了,这道题根本不是问题。

手写bind函数

改变this指向的方法之一,手写可以体现不止你会用bind函数,以及你会不会对this有一些概念,另外还能体现出你对代码逻辑的能力

Function.prototype.bind1 = function () {
    // 将参数解析为数组
    const args = Array.prototype.slice.call(arguments)

    // 获取this(取出数组第一项,数组剩余的就是传递的参数)
    const t = args.shift()
    const self = this // 当前函数

    // 返回一个函数
    return function () {
        // 执行原函数,并返回结果
        return self.apply(t, args)
    }
}

  解析:首先bind这个函数会接收多少个参数我们并不知道,第一个参数是this,后面的几个参数不知道会传几个,限制不了,所以说我们没法通过函数接收参数。所以我们应该去将参数拆解为数组,参数可以通过arguments来获取,arguments可以获取函数所有的参数,不管你传几个都能获取到,arguments是个列表的形式,但是它不是一个数组,所以我们把它变成数组(Array.prototype.slice.call),通过Array.prototype.slice.call这种方式就可以把一个列表变成一个数组,包括弹出dom列表也可以,slice是Array的原型上的api,call就是说,我通过Array.prototype.slice执行的时候,我们把arguments赋值成Array.prototype.slice的this,

  第二步获取this(数组第一项),比如说我们拿到数组之后,其实就是传入参数的数组,第一要获取this,第二还要把this从这个数组从剔除,剩下的参数是我们需要的,this获取完后this就不需要了,然后用shift(),shift的意思看下面代码

const arr = [1,2,3,4]
arr.shift() // 1
arr // (3) [2, 3, 4]

  也就是说const t = args.shift()把第一个数组取出来,而且是永远的挖出来,只剩下后面的参数。这样的话,this有了,const self = this,self就是说我们把this作为函数去执行。

  而后返回一个函数,返回一个函数之后是需要被执行的,我们通过apply去执行就可以了,apply第一个参数是this,第二个参数是一个数组,这个数组中就是剩下的参数。

实际开发中闭包的应用场景,举例说明

闭包在稍微复杂一点的项目中都会运用到

隐藏数据

如何做一个简单的 cache 工具

// 闭包隐藏数据, 只提供 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")) // 100

// data.b = 200 //直接报错未定义

  这种方式非常常见,一个函数里面去把数据隐藏,只提供api去使用,不管是缓存也好,还是其他的数据也好,比如说jQuery的事件绑定和自定义事件都是通过这种方式来去隐藏的,

// 创建10个 <a> 标签,点击的时候弹出来对应的序号!
let i, a

for (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)
}

  循环十个a标签,我们发现不管点击哪一个弹出来的都是10,结论就是我创建10个a标签,但是每个click弹出来的都是10,这肯定是不符合我们的要求的,我们的要求点击谁就弹出它对应的序号,首先我们分析一下为什么弹出来的会是10,因为我们的js代码执行很快就会执行完毕,遍历10遍,创建每一个a标签给它绑定事件,这个事件还没有开始执行,注意这一点:还没有开始执行,创建10个a标签可能几十毫秒都不到就搞定了,所以事件还没来得及执行,事件什么执行呢,当然是什么时候click什么时候执行,你如果不点击它永远不执行,所以说当每个a标签被click的时候,执行的时候,这个 i 早就变成了10了,为什么它变成10了,因为这个 i 的作用域是全局作用域。所以说全局作用域的 i 变成了10, 这个时候alert(i), i 是个自由变量,就会往上找,当你开始点击的时候,这个10遍循环早就完事了,完事之后这个 i 就变成了10, 所以你每次点击都是10 。addEventListener里面的函数并不会立马执行。那么怎么解决这个问题呢?

  很简单,删掉 i ,let i 放到for循环中

let 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)
}

  for里面块的作用域,每次for循环执行的时候都会生成一个新的块,然后这个i就会不一样,也就是说从0到1到2....到9,只要是这个i在寻找的时候都会去块级作用域里面寻找,所以说就不一样。块是针对每一块,每次循环都产生一个块作用域,然后全局作用域,i在上面定义的时候,全局作用域是针对所有的块的。

原型中的this

// 父类
class People {
    constructor(name) {
        this.name = name
    }
    eat() {
        console.log(`${this.name} eat something`)
    }
}

// 子类
class Student extends People {
    constructor(name, number) {
        super(name)
        this.number = number
    }
    sayHi() {
        console.log(`姓名 ${this.name} , 学号:${this.number}`)
    }
}

// 子类
class Teacher extends People {
    constructor(name, major) {
        super(name)
        this.major = major
    }

    teach() {
        console.log(`${this.name} 教授 ${this.major}`)
    }
}

// 学生实例
const xialuo = new Student("xialuo", 100)
console.log(xialuo.name)
console.log(xialuo.number)
xialuo.sayHi()
xialuo.eat()

// 老师实例
const wanglaoshi = new Teacher("王老师", "语文")
console.log(wanglaoshi.name)
console.log(wanglaoshi.major)
wanglaoshi.teach()
wanglaoshi.eat()

回顾之前代码:

  我们在执行Student里面的sayHi的时候,之前有一种情况是this.name是undefined,那我们再演示一下

  xialuo的隐式原型里面有sayHI,但是我们直接执行的话就会显示undefined,因为和this有关系,我说过代码中有this.name和this.number, 当我们执行xialuo.sayHi(),这么执行的话,这个sayHi是当做一个对象的方法去执行的,那这个this就是xialuo这个对象自己,所以说它有名称有学号,那如果我们xialuo.__proto__.sayHi()这么执行的话,它当做__proto__也是一个对象的方法名,但是这个对象方法是隐式原型的,它不是xialuo的,它是当做__proto__这个对象的方法来执行的,那它这个对象的name和number 是没有的 都是undefined,所以刚刚会输出undefined,这就是问题的原因所在。那其实这个xialuo.sayHi()执行的时候,因为它是去它的隐式原型中去找(之前讲过),它相当于去它的隐式原型中去找sayHi,然后执行的时候不是直接执行,类似于xialuo.__proto__.sayHi.call(xialuo)

xialuo.__proto__.sayHi.call(xialuo) // 姓名 xialuo , 学号:100

  只是类似,并不是说就是这么执行的,但是我们再使用的时候,一般是指用这种方法来去写,所以它就当做xialuo自己对象的方法来执行,所以说它输出的就是姓名 xialuo , 学号:100