tiantingrui / daily-harvest

记录每日收获
MIT License
2 stars 0 forks source link

你如何理解JavaScript中的“闭包”? #16

Open tiantingrui opened 2 years ago

tiantingrui commented 2 years ago

闭包 - 从编译原理的角度理解作用域

当面试官和你聊“什么是闭包”,多半是 在跟你聊 作用域、作用域链 等JS核心知识点。

聪明的面试官,还会借机引出 变量提升、暂时性死区、执行上下文 等话题。

甚至想问问你 JS中的不同异常之间的本质区别在哪里?词法模型是什么?

理解作用域的实现机制

几乎每一种编程语言,它最基本的能力都是能够存储变量当中的值、并且允许我们对这个变量的值进行访问和修改。那么有了变量之后,应该把它放在那里、程序如何找到它们?这是不是需要我们提前约定好一套存储变量、访问变量的规则?这套规则,就是我们常说的作用域。更多时候,我们提到作用域的时候,指的是这个规则约束下的一个变量、函数、标识符可以被访问的区域(这时它就更具体了)。 要理解作用域的实现机制,我们需要结合 JS 的编译原理一起来看。

看一个简单的声明语句:

var name = 'terry'

那么JS 会怎么理解这句话?

在我们看了,这只是一句声明语句。但是在JS引擎眼里,它却包含两个声明:

何为编译时、何为运行时?难道 JS 不是不存在编译阶段的 “动态语言” 吗?

事实上,JS 也是有编译阶段的,它和传统语言的区别在于,JS 不会早早地把编译工作做完,而是一边编译一边执行。简单来说,所有的 JS 代码片段在执行之前都会被编译,只是这个编译的过程非常短暂(可能就只有几微妙、或者更短的时间),紧接着这段代码就会被执行。

回到我们这个语句上来,我们来看看编译阶段和执行阶段阶段都发生了什么事情:

作用域套作用域,就有了作用域链

现在我们已经知道,作用域本质上就是程序存储和访问变量的规则

在JS世界中,目前有三种作用域:

作用域链

在我们实际开发中,通常不止用到一种作用域。当一个块或者一个函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。比如这样:

function addA(a) {
  console.log(a + b)
  console.log(c) // 报错
}

var b = 1

addA(2) //3
tiantingrui commented 2 years ago

补充:LHS、RHS - 面试官到底在问啥?

LHS、RHS,是引擎在执行代码的时候,查询变量的两种方式。其中的 L、R,分别意味着 Left、Right。这个“左”和“右”,是相对于赋值操作来说的。当变量出现在赋值操作的左侧时,执行的就是 LHS 操作,右侧则执行 RHS 操作:

name = 'terry'

在这个例子里,name 变量出现在赋值操作的左侧,它就属于 LHS。LHS 意味着 变量赋值或写入内存, 它强调的是一个写入的动作,所以 LHS 查询查的是这个变量的“家”(对应的内存空间)在哪。

var myName = name 
console.log(name)

在这个例子里,第一行有赋值操作,但是 name 在操作的右侧,所以是 RHS;第二行没有赋值操作,name 就可以理解为没有出现在赋值操作的左侧,这种情况下我们也认为 name 的查询是 RHS。RHS 意味着 变量查找或从内存中读取,它强调的是读这个动作,查询的是变量的内容

tiantingrui commented 2 years ago

闭包 - 探究此法作用域模型

词法作用域和动态作用域

当我们在 JavaScript 语言的范畴里讨论“作用域”这个概念的时候,确实不需要区分它是“词法”还是“动态”,因为我们 JS 的作用域遵循的就是词法作用域模型

但是站在语言的层面来看,作用域其实有两种主要的工作模型:

想要理解词法作用域本身,我们就不得不从 JS 的框框里跳出来,把它和它的对立面“动态作用域 ”放在一起来看。为了使两者的概念更加直观,我们直接来看一段代码:

var name = 'terry';

function showName() {
    console.log(name);
}

function changeName() {
    var name = 'BigBear';
    showName();
}

changeName();

这是一段 JS 代码,基于我们上节对 JS 作用域的学习,不难答出它的运行结果是 ‘terry’。这是因为 JS 采取的就是词法(静态)作用域,这段代码运行过程中,经历了这样的变量定位流程:

这里我们作用域的划分,是在书写的过程中(例子中也就是在函数定义的时候,块作用域同理是在代码块定义的时候),根据你把它写在哪个位置来决定的。像这样划分出来的作用域,遵循的就是词法作用域模型。

那什么是动态作用域呢?动态作用域机制下,同样的一段代码,会发生下面的事情:

所以如果是动态作用域,那么这段代码运行的结果就会是 ‘BigBear’ 了~

词法(静态)作用域和动态作用域的区别

区别就在于划分作用域的时机:

修改词法作用域

  1. eval
  2. with

不推荐使用

eval 对作用域修改

function showName(str) {
  eval(str)
  console.log(name)
}

var name = 'xiuyan'
var str = 'var name = "BigBear"'

showName(str) // 输出 BigBear

大家知道,eval 函数的入参是一个字符串。当 eval 拿到一个字符串入参后,它会把这段字符串的内容当做一段 js 代码(不管它是不是一段 js 代码),插入自己被调用的那个位置。所以上面这个例子里,被 eval “改造” 过后的 showName 函数其实长这样了:

function showName(str) {
  var name = 'BigBear'
  console.log(name)
}

此时当我们尝试输出 name 的时候, 函数作用域内的 name 已经被 eval 传入的这行代码给修改掉了,所以作用域内 name 的值就从 ‘xiuyan’ 变成了 ‘BigBear’(eval 带来的改变如下图所示)。而这个改变确实只有在 eval (str) 这行代码被执行后才发生 ——eval 在运行时改变了作用域的内容,它成功地 “修改” 了词法作用域规则约束下在书写阶段就划分好的作用域。

with 对作用域的修改


var me = {
  name: 'xiuyan',
  career: 'coder',
  hobbies: ['coding', 'footbal']
}

// 假如我们想输出对象 me 中的变量,没有 with 可能会这样做:
console.log(me.name)
console.log(me.career)
console.log(me.hobbies)

// 但 with 可以帮我们省去写前缀的时间
with(me) {
  console.log(name)
  console.log(career)
  console.log(hobbies)
}

with 改变作用域的方式:

  1. with 会原地创建一个全新的作用域,这个作用域内的变量集合,其实就是传入 with 的目标对象的属性集合。
  2. 因为 “创建” 这个动作,是在 with 代码实际已经被执行后发生的,所以这个新作用域确实是在运行时被添加的, with 因此也实现了对书写阶段就划分好的作用域进行修改。

这里面需要注意的是,“改变” 仅仅是描述 “创建” 这个动作 —— 创建出来的这个新的作用域。因此它的作用域查询机制仍然是遵循词法作用域模型的。

tips:不要用 with 和 eval 写代码

tiantingrui commented 2 years ago

闭包的应用

  1. 模拟私有变量的实现

    
    // 利用闭包生成IIFE,返回 User 类
    const User = (function() {
    // 定义私有变量_password
    let _password
    
    class User {
        constructor (username, password) {
            // 初始化私有变量_password
            _password = password
            this.username = username
        }
    
       login() {
           // 这里我们增加一行 console,为了验证 login 里仍可以顺利拿到密码
           console.log(this.username, _password)
           // 使用 fetch 进行登录请求,同上,此处省略
       }
    }
    
    return User
    })()

let user = new User('terry', 'terry123')

console.log(user.username) // terry console.log(user.password) // undefined console.log(user._password) // undefined user.login() // terry terry123

我们看到不管是 password,还是 _password,都被好好地保护在了 User 这个立即执行函数的内部。
通过闭包,我们成功达到了用自由变量来模拟私有变量的效果!

2. **偏函数与柯里化**
#### 柯里化
柯里化是把接受 n 个参数的 1 个函数改造为只接受 1个参数的 n 个互相嵌套的函数的过程。也就是 fn (a, b, c)fn(a,b,c) 会变成 fn (a)(b)(c)fn(a)(b)(c)。

#### 偏函数
除了约束条件与柯里化略有不同,偏函数在动机和实现思路上都与柯里化一致 —— 动机就是为了 “记住” 函数的一部分参数,实现思路就是走闭包。

原有的函数形式与调用方法
```js
function generateName(prefix, type, itemName) {
    return prefix + type + itemName
}

// 调用时一口气传入3个入参
var itemFullName = generateName('大卖网', '母婴', '奶瓶')

偏函数应用改造:

function generateName(prefix) {
    return function(type, itemName) {
        return prefix + type + itemName
    }
}

// 把3个参数分两部分传入
var itemFullName = generateName('大卖网')('母婴', '奶瓶')