一文带你打通JavaScript的作用域链、闭包、this指向
在我每次试图搞清楚JavaScript
的作用域链、闭包、this
这些概念时,总会陷入一种孤立当中,无法理清这三者之间的联系,今天我试图用变量查找这一条线来串起这三者之间的联系,理解其中的缘由。
通过两道题弄清作用域链
function add(a) {
console.log(a + b)
}
var b = 1
add(2) // 3
这一道题答案是显而易见的,在 add
函数里访问变量 b
时,首先从函数作用域中查找是否存在变量 b
,显然是不存在的,那怎么办?
那就探出头去
,去上层作用域(全局作用域)查找 ,发现存在变量 b
,打印结果为3
。
在这个查找过程中,层层递进的作用域,就形成了一条作用域链
。
那作用域链
就这么简单吗?看下第二道题:
var name = 'xiaoming';
function showName() {
console.log(name);
}
function changeName() {
var name = 'xiaofang';
showName();
}
changeName();
思考10秒,changeName()
会打印什么?是xiaofang
吗? 还是xiaoming
?
答案是xiaoming
。
按照一般思维去理解,showName
执行时,首先会从自身函数作用域中查找name
变量,发现不存在,那么就探出头去
查找,发现showName
是在changeName
函数中执行的,所以到changeName
函数作用域中查找,发现它定义了变量name
,那么就打印xiaofang
。但是这与实际运行结果不符,这是为什么呢?
这是因为作用域链的查找模式分为:词法作用域和动态作用域。
什么是词法作用域呢?
官方的解释:根据函数书写的位置来决定查找的上一层作用域。
这么说可能不太好理解,还是以上面的那个例子为例。
当showName
没有变量name
时,需要探出头去
查找,那么上一层作用域是changeName
函数作用域呢?还是定义showName
函数所在的全局作用域?
实际上,JavaScript
默认采用的是词法作用域,也叫静态作用域。
现在showName
书写在全局作用域中,所以查找的时候按照词法作用域来查找,即探出头去
到全局作用域
,发现全局作用域中有name
变量,所以打印的结果是xiaoming
。
那什么是动态作用域呢?还是以上面的代码为例:
-
在
showName
函数的函数作用域内查找是否有局部变量name
; -
发现没找到,于是沿着
函数调用栈
,在调用了 showName 的地方继续找name
。沿着函数调用栈查找,它的上一层作用域就是changeName
函数作用域,刚好changeName
里有一个name
,于是就打印xiaofang
;
【总结一下】
词法作用域和动态作用域的区别其实在于划分作用域的时机
- 词法作用域:在代码书写的时候完成划分,作用域链沿着它定义的位置往外延伸;
- 动态作用域:在代码运行时完成划分,作用域链沿着它的调用栈往外延伸;
【变量查找规则】:JavaScript
变量查找是沿着作用域链查找变量,作用域链的确定又是按照词法作用域的规则来的。
由作用域链引出闭包
通过一段代码来认识闭包:
function addABC(){
var a = 1,b = 2;
function add(){
return a+b+c;
}
return add;
}
var c = 3
var globalAdd = addABC()
console.log(globalAdd()) // 6
在这个例子里,作用域嵌套的情况展示如下:
当执行globalAdd
时就是在执行add
方法,它访问了a
, b
, c
三个变量,但是add
函数没有定义这三个变量,所以需要探出头去
查找,根据第一节的内容,我们知道它是遵循词法作用域
的规则,即根据书写位置来确定查找的上一层作用域。
add
书写的位置在addABC
函数内,所以上一级作用域是addABC
函数作用域,刚好这个函数定义了a
, b
变量,但是没有定义c
变量,那么继续往上找,即到全局作用域中查找,发现了c
变量,这样打印的结果就是6
。
但是,这个例子有个特别之处是当执行globalAdd
时,addABC
函数已经执行完成了,变量a
, b
按道理来说已经被自动销毁了,但是我们现在仍然可以访问到a
, b
这两个变量,说明这两个变量没有被销毁,仍然存在内存当中。
这就是JavaScript
中鼎鼎大名的闭包
。那什么是闭包呢?
搞清楚什么是闭包,就需要搞清楚什么是自由变量
。
像 a
, b
这样在函数中被使用,但它既不是函数参数、也不是函数的局部变量,而是一个不属于当前作用域的变量,此时它相对于当前作用域来说,这就是自由变量
。
像 add
这样引用了自由变量的函数,这些自由变量就构成了一个闭包。
所以,闭包就是一个来存放自由变量的地方,它的查找规则也是遵循作用域链的规则,无非就是闭包始终存在内存当中。
那闭包
有什么用呢?下面通过两个例子来介绍闭包的应用。
模拟私有变量
下面通过一段代码来了解什么是私有变量:
class User {
constructor(username, password) {
// 用户名
this.username = username
// 密码
this.password = password
}
}
let user = new User('xiuyan', '123')
user.password // 123
像登录密码这么关键且敏感的信息,竟然通过一个简单的属性就可以拿到。这就意味着,只要能拿到 user
这个对象,就可以非常轻松地知道密码,甚至改写的密码。
像 password
这样的变量,希望它仅在对象内部生效,无法从外部触及,这样的变量,就是私有变量,怎么实现呢?
在 JavaScript
中,既然无法通过 private
这样的关键字直接在类里声明变量的私有性,就只能另寻它法。
大家想想,在内部可以拿到、外部拿不到,这难道不就是函数作用域的特性吗?
所以,思路就是把私有变量用函数作用域保护起来,形成一个闭包。
/ 利用闭包生成IIFE,返回 User 类
const User = (function() {
// 定义私有变量_password
let _password
class User {
constructor (username, password) {
// 初始化私有变量_password
_password = password
this.username = username
}
login() {
// 为了验证 login 里仍可以顺利拿到密码
console.log(this.username, _password)
...
}
}
return User
})()
let user = new User('xiuyan', '123')
console.log(user.username) // xiuyan
console.log(user.password) // undefined 外界拿不到
console.log(user._password) // undefiend 外界拿不到
user.login() // xiuyan 123 通过login函数可以拿到
现在,user
对外暴露的属性已经没有 password
变量了。现在只能通过user
内的login
方法才能访问到password
,也就是只能通过内部的方法才能访问,外部访问不到。
这样,通过闭包成功达到了用自由变量来模拟私有变量的效果。
到这里,我建议你停下来阅读后面的内容,仔细思考下上面闭包的使用,想一想在日常开发中你使用闭包的目的是什么?
其实,使用闭包的大部分场景都是为了构造一个私有变量,这个私有变量外部访问不到,只能内部才能访问,而且这个私有变量是始终存在内存中。很多开源库都使用了闭包这一特性。
函数柯里化
函数柯里化就是把接受 n 个参数的 1 个函数改造为只接受 1个参数的 n 个互相嵌套的函数的过程。即把fn(a, b, c)
变成 fn (a)(b)(c)
。
它的主要功能是:可以让函数在必要的情况下帮我们 "记住" 一部分入参。
原函数:
function generateName(prefix, type, itemName) {
return prefix + type + itemName
}
经过科里化后:
function generateName(prefix) {
return function(type) {
return function (itemName) {
return prefix + type + itemName
}
}
}
这样一来,原有的generateName(prefix, type, name)
现在经过柯里化已经变成了 generateName (prefix)(type)(itemName)
。
这样有什么好处呢?
好处是让你可以少传prefix, type
这两个参数,让程序记住这两个参数,这样每次调用你只需要传入name
参数即可。
this指向
首先思考下 JavaScript
为什么要有 this
?
this
的目的也是用来查找变量的,那为什么又作用域链查找变量,还需要this
来查找变量呢?
要解答上面的问题,首先看一段代码:
var bar = {
myName:"xiaoming",
printName: function () {
console.log(myName)
}
}
var myName = 'xiaofang'
bar.printName() // xiaofang
根据第一节所学的知识,可以很快得出答案是xiaofang
。
因为 JavaScript
作用域链是由词法作用域决定的,而词法作用域是由代码结构来确定的。printName
书写在全局作用域中,所以访问的myName
也是在全局作用域中的。
不过按照常理来说,调用bar.printName
方法时,该方法内部的变量 myName
应该使用 bar
对象中的,因为它们是一个整体,大多数面向对象语言都是这样设计。
所以,在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套 this 机制
。
所以,在 JavaScript
中可以使用 this
实现在 printName
函数中访问到 bar
对象的 myName
属性了。
具体该怎么操作呢?你可以调整 printName
的代码,如下所示:
printName: function () {
console.log(this.myName)
}
【总结】
现在我们知道了变量查找有两套机制:一套是遵循作用域链查找;一套是this
机制查找。
this 指向谁?
一般情况下,this
指向调用它的那个对象。
bar.print()
print()
第一行bar.print()
里面是bar
这个对象调用的,所以print
函数里的this
指向bar
对象。
第二行print()
可以看成是window.print()
,所以print
函数里的this
指向window
对象。
当调用方法没有明确对象时,this
就指向全局对象。在浏览器中,指向 window
;在 Node
中,指向 Global
在三种特殊情境下,this
是一定会指向 window
的:
- 立即执行函数(
IIFE
) setTimeout
中传入的函数setInterval
中传入的函数
箭头函数中的this
箭头函数中的this
比较特别,它认"死理" ———— 认"词法作用域"的家伙,即根据书写位置来约定this
指向。
所以你会在有些文章中看到这样一句话:箭头函数中的this
继承于它的上一层作用域的this
,即父级的this
。
因此,箭头函数中的 this
,和你如何调用它无关,由你书写它的位置决定。例如:
var name = 'BigBear'
var me = {
name: 'xiuyan',
// 书写位置
hello: () => {
console.log(this.name)
}
}
// 调用位置
me.hello() // BigBear
因为箭头函数在书写时,它所在的作用域是全局作用域,于是这个箭头函数里面的this
就和全局对象绑在了一起。
箭头函数有一些普通函数没有的特点:
- 没有
arguments
; - 无法通过
apply call
等改变this
;
因为箭头函数的特点,在某些场景下我们不能使用箭头函数。
1. 对象的方法不能使用
this
指向window
,而不是obj
const obj = {
name: 'xiaoming',
getName: () => {
return this.name
}
}
2. 动态上下文中如果有this,不能使用
this
指向不是btn
,而是window
btn.addEventListener('click', () => {
this.innerHTML = 'click'
})
3. vue中的生命周期和method不能使用
因为vue
组件本质上就是一个对象
,对象的方法是不能用箭头函数的,否则this
就不是指向当前对象了。
react
组件中是可以使用箭头函数的,因为它本质上是一个class
,class
里面是方法是可以使用箭头函数的。
如何改变this
指向
改变 this
的指向,主要有两条路:
1. 通过改变书写代码的方式:箭头函数
var a = 1
var obj = {
a: 2,
// 书写位置
showA: () => {
console.log(this.a)
}
}
// 调用位置
obj.showA() // 1
当我们将普通函数改写为箭头函数时,箭头函数的 this
会在书写阶段就绑定到它父作用域的 this
上。
无论后续我们如何调用它,都无法再为它指定目标对象,因为箭头函数的 this
指向是静态的,"一次便是一生"。
所以,虽然是obj.showA()
,对象obj
调用了showA
,但是this
仍然指向了window
。
2. 显式地调用call, apply, bind 方法
改变 this
指向,常用的是 call
、 apply
和 bind
方法:
call: fn.call(target, arg1, arg2)
改变后立即执行apply: fn.apply(target, [arg1, arg2])
改变后立即执行bind: fn.bind(target, arg1, arg2)
改变后不立即执行
先来看一个call
的调用范例:
var me = {
name: 'xiuyan'
}
function showName() {
console.log(this.name)
}
showName.call(me) // xiuyan
showName()
调用时,this
指向了window
,打印结果为undefined
;
showName.call(me)
调用时,改变了this
指向,此时this
指向了me
对象,所以,打印结果是xiuyan
;
那call
是如何改变this
执行的呢?
下面来实现一个call
函数,首先至少能想到以下两点:
call
是可以被所有的函数继承的,所以call
方法应该被定义在Function.prototype
上;call
方法做了两件事:- 改变
this
的指向,将this
绑到第一个入参指定的的对象上去; - 根据输入的参数,执行函数;
- 改变
所以,代码实现如下:
Function.prototype.myCall = function(context, ...args) {
// step1: 把函数挂到目标对象context上
context.func = this
// step2: 执行函数
context.func(...args)
// step3: 删除 step1 中挂到目标对象上的函数,把目标对象”完璧归赵”
delete context.func
}
- 函数赋值:
context.func = this
这一段代码稍微不好理解,当我们调用fn.myCall
时,此时myCall
函数里面的this
就指向fn
,在JavaScript
中函数也是对象。
所以,我们在context
上定义了一个属性func
,它的值为函数fn
;
- 调用函数:
context.func(...args)
此时func
函数是被context
调用的,所以func
里面的this
执行就指向了context
,也就是我们传入的第一个参数,这样就改变了this
指向。
- 删除函数
context.func
总结
本文通过变量查找这一条线开始讲述了JavaScript
是如何访问变量的。
首先,变量是根据作用域链查找,而作用域链的确定根据词法作用域的规则,即代码的书写位置来确定。
在根据作用域链查找过程中,又引出了一类特殊的变量,即自由变量。它们虽然定义在函数内,但是随着函数执行完,它们并不会被垃圾回收,这是因为有其他的函数引用了这些变量,这就是闭包
。
接着,使用私有变量的实现和函数柯里化两个例子阐述了闭包
在开发中使用场景。说到底闭包
就是一个私有变量,外部不能访问,只能内部访问。
但是,当对象调用自己的方法时,不能访问自己的属性,反而是访问了全局的属性,这就比较反人类了,所以就引出了this
访问机制,它和作用域链构成了JavaScript
访问变量的全部内容。
接着讨论了箭头函数中的this
,它是根据书写位置确定,一旦指定就不会改变this
指向,根据它的这个特性,所以需要在一些场景下不能使用箭头函数。
最后,通过一个模拟call
函数的实现,弄清楚了call, apply, bind
改变this
指向的原理。
转载自:https://juejin.cn/post/7212809255945601080