JavaScript基础篇(三)作用域和闭包
作用域
作用域是啥?相信很多小伙伴可能都清楚,但是说明白估计可能有点悬!老规矩,看图说话:
01.png结合图我们先来总结一下作用域的概念:变量合法的使用范围。
上图中红色区域的就是当前变量所能使用的合法范围。变量 a
在最外成,所以可以理解而为它的作用域在全局都可以合法使用。变量 a1
在函数 fn1
里面,即说明在 fn1
函数中的任何地方都可以合法使用 a1
这个变量,依次向下......
作用域主要分为:
-
全局作用域
全局都可以使用,例如上图中的变量a
-
函数作用域
只在声明变量的函数中可以使用,函数外访问不到该变量。
function test() {
const a = 111
}
test()
console.log(a) // 报错 a is not defined
-
块级作用域(ES6 新增)
只要用let
和const
定义的变量,它的作用域其实就是在被{}
包裹的这个区域。
if (true) {
let a = 222
}
console.log(a) // 报错 a is not defined
自由变量
自由变量指的是:一个变量在当前作用域没有定义,但被使用了,此时会向上级作用域一层一层依次寻找,直到找到为止。如果一直找到全局作用域还没找到该变量的定义声明,则报错 xx is not defined
。
如上图中在 fn3()
函数中,a
、a1
、a2
就都是自由变量,因为他们在当前函数中没有被定义,所以会向上级作用于逐层寻找。
闭包
感觉看名字就觉得高大上,其实撕下它的外衣,本质上就是作用域应用的特殊情况而已,一般有两种表现:
- 函数作为参数被传递
- 函数作为返回值被返回
干巴巴的文字总是让人难以理解,先来看一段代码:
// 函数作为返回值被返回
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
常用的场景有哪些:
- 作为普通函数
- 使用
call
、bind
、apply
- 作为对象方法被调用
- 在
class
方法中调用 - 箭头函数
this
在各个场景中取什么值,是在函数执行的时候确定的,不是在函数定义的时候被确定的。
先总结,再来看各个场景的栗子:
- 普通函数
function fn1() {
console.log(this)
}
fn1() // window
// 全局函数执行其实等价于下面这种写法
window.fn1()
- 比如说
call
、bind
、apply
来执行
function fn1() {
console.log(this)
}
fn1.call({ x: 100 }) // {x: 100}
fn1.apply({ x: 100 }) // {x: 100}
fn1.bind({x: 100})() // {x: 100}
call
、apply
、bind
都可以改变 this
的指向,这里我们先不追究它们的深入用法,后面会说。先了解基础用法:call
和 apply
的第一个参数都是 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
来进行定义的,但是现在日常的开发中,能用 let
和 const
定义就不要用 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
微妙的差距!从上面四个结果不难看出:call
、bind
、 apply
这三个函数的第一个参数都是this的指向对象,第二个参数差别就来了
-
call
的参数是直接放进去的,第二第三第 n 个参数全都用逗号分隔, -
apply
的所有参数都必须放在一个数组里面传进去 -
bind
除了返回是函数以外,它的参数和call
一样 - 当然,三者的参数不限定是
number
类型,允许是各种类型,包括函数 、object
等等!
通过以上总结,相信大家对 apply
、call
、bind
这三个方法都有一个基本的了解呢,咱们作为程序员,还是要有拓展精神吗,知其然不知其所以然咋行。
扩展练习:手写
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
好吧,基础总结就写这么多了,上述手写只是简单模拟,并不一定适用所有场景的使用。如果文中有不对的地方或者理解有误的地方欢迎大家提出并指正。每一天都要相对前一天进步一点,加油!!!