hacker0limbo / my-blog

个人博客
5 stars 1 forks source link

简单聊一聊闭包 #5

Open hacker0limbo opened 5 years ago

hacker0limbo commented 5 years ago

最近根据一些教程尝试实现一个简易的 react-hooks. 里面用到了大量的闭包, 翻阅了一些文章加上一些自己的思考, 整理一下

这篇文章更多的是想谈一谈为什么要用闭包, 以及我们可以用闭包来做些什么. 很多网上的教程只是讲一下什么是闭包(很多甚至都没有讲明白), 而对于闭包的实际应用往往是一笔带过. 知乎上有这么一个问题: Python 所谓的“闭包”是不是本着故意把人搞晕的态度发明出来的?

本人也是初学者, 可能很多地方理解的也不是很准确, 也欢迎和我交流

从函数的生命周期讲起

函数也是可以看做有生命周期的, 如下图:

函数的生命周期

具体可以参考这篇文章, 这里就不多描述了

什么是闭包

网上看到一个比较精简的解释:

一个函数在调用的时候, 内部的自由变量, 要到这个函数被定义的地方去找, 而不是在这个函数当前被调用的地方去找 这个函数连同它被定义时的环境一起, 构成了一个数据结构, 就是闭包

真正想要去了解闭包的整个执行流程以及基本原理, 我推荐看这篇教程. 讲的非常深入浅出, 从词法环境, 讲到作用域链和活动对象, 是非常清晰的.

不过这里还是总结一下, 有这么几点:

  1. 每个函数创建的时候, 就已经会创建一个词法环境. 当在运行的时候, 创建一个新的词法环境, 这两个词法环境很有可能是不同的. 所以分析的时候一定是要看最新的词法环境, 比如如下的例子:

    let phrase = 'Hello'
    
    function say(name) {
      alert(`${phrase}, ${name}`)
    }
    
    say('John') // Hello, John

    f2

    上图是函数say创建时的词法环境

    f1

    上图是函数say()调用时的词法环境

    所以可以看出来, 创建时和运行时的词法环境是截然不同的, 同时由于引用到了全局环境下的phrase变量, 如果在say()调用前修改该变量, 那么调用say()的时候, 其词法环境又会发生变化, 比如将代码改成如下形式:

    let phrase = 'Hello'
    
    function say(name) {
      alert(`${phrase}, ${name}`)
    }
    
    phrase = 'World'
    say('John') // World, John

    很明显的, 在调用say('John')这个函数的时候, 其词法环境中引用的phrase变量已经被修改, 因此最后结果为 "World, John"

    当然如果将phrase的修改放到函数执行结束后, 那么词法环境并不会改变, 毕竟变量的修改是在函数结束之后发生的, 在执行say()函数的时候, 其词法环境中的phrase并没有被修改掉, 代码如下:

    let phrase = 'Hello'
    
    function say(name) {
      alert(`${phrase}, ${name}`)
    }
    
    say('John') // Hello, John
    phrase = 'World'

    所以总结来说, 一定要去看函数被调用时的词法环境, 由于词法环境里面往往引用到了外部的变量, 环境等, 很有可能在被调用时已经和创建的时候发生了很大的变化(例如上面的say函数例子)

  2. 词法环境包括自己内部的环境, 和引用的外部的环境. 当存在嵌套函数的时候, 外部环境可能还引用更加外部的环境, 就形成了一条作用域链. 最最里层的函数在执行的时候, 会根据这条链子, 由内而外寻找需要的变量. 然后可以对找到的变量进行修改, 修改是在该变量所在的作用域内修改, 也就是说对于闭包来讲, 修改的地方在该函数的外部环境修改, 而非克隆一份放低自己的内部环境内修改. 比如下面代码:

    function makeCounter() {
      let count = 0
    
      return function() {
        return count++
      }
    }
    
    let counter = makeCounter()
    alert(counter())

    context1 执行闭包, 创建对外部环境的引用

    context2 修改外部环境变量

  3. 每次调用一个函数, 如果这个函数存在闭包, 那么都会创建一个单独的闭包环境, 里面有该闭包的状态. 多次调用这个函数, 会创建多个闭包, 这些闭包内部的环境状态都是独立的. 类似于有一个类, 你可以进行多次实例化, 每次实例化出来的都是不同的对象. 例如下面的例子:

    function makeCounter() {
      let count = 0
      return function() {
        return count++
      }
    }
    
    let counter1 = makeCounter();
    let counter2 = makeCounter();
    
    alert(counter1()) // 0
    alert(counter1()) // 1
    
    alert(counter2()) // 0 (独立的)

如果想要深入了解整个的流程还是需要去看上面推荐的那篇文章, 也有英文版的. 另提一句, 这个教程的其他内容质量也是不错的, 也有对应的中文翻译, 可以参考

闭包的几个小练习

两道闭包的小练习题:

第一题

function foo() {
  let x = 100

  function add(_x) {
    x += _x
    console.log('change closure x', x)
  }

  return [x, add]
}

const [x1, add1] = foo()
console.log('x1 before', x1) // x1 before, 100
add1(5) // change closure x, 105
console.log('x1 after', x1) // x1 after, 100

const [x2, add2] = foo() 
console.log('x2 before', x2) // x2 before, 100
add2(10) // change closure x, 110
console.log('x2 after', x2) // x2 after, 100

这里有两个点需要注意:

第二题

function foo() {
  let x = 100

  function render() {
    const _x = x

    function inner() {
      x += 5
      console.log('innerrender', { _x, x })
    }
    console.log('render', { _x, x })
    return inner
  }

  function clear() {
    x = 0
  }

  return [add, render, clear]
}

const [add, render, clear] = foo()

inner = render() // render { _x: 100, x: 100 }
clear()
inner() // innerrender { _x: 100, x: 5 }

这里存在多个嵌套的闭包, 在调用最里层的函数inner()的时候需要分析清楚此时它所在的环境里引用的x_x的值是怎样的

闭包的应用

上面花了很大的篇幅将闭包是什么, 这里谈一谈闭包的实际应用

在之前, 先总结一下闭包的几个特点:

可以想到, 普通(纯)函数是没有状态的, 闭包可以使得函数有状态, 有状态就意味着有记忆, 毕竟状态可以发生改变. 当然, 这里的状态, 往往是在外部环境所提供的, 不过内部函数可以读取并操作这个状态.

所以...如果你想进行一些状态的管理与保存, 你实际上可以有两个选择:

  1. 用类
  2. 用闭包

闭包和类都是保存状态用的

如果熟悉React, 类和闭包好像有点 class 组件React hooks 的味道!

不过这里先不讲React, 先考虑如下例子, 想要实现一个计数器, 有这么两个功能:

假设我们想要用普通纯函数实现, 可能会这么写:

const Counter = function() {
  let count = 0
  count++
  return count
}

Counter() // 1
Counter() // 1

这里的函数是没有状态的, 调用多少次永远都是 1. 同时内部变量count在函数调用完毕之后也被垃圾回收了. 所以这个计数器的实现是失败的

那如果用类实现, 可能代码是这样:

class Counter {
  constructor() {
    this.count = 0
  }

  add() {
    this.count += 1
  }

  getCount() {
    return this.count
  }
}

c1 = new Counter()
c1.add()
c1.add()
c1.getCount() // 2

c2 = new Counter()
c2.add()
c2.getCount() // 1

使用类模拟后, 每一次实例化都新生成了一个新的对象, 每个对象里面的count属性都是独立的, 我们可以对获取它, 也可以操作它

如果用闭包来实现:

const Counter = function() {
  let count = 0

  const add = function() {
    count += 1
  }

  const getCount = function() {
    return count
  }

  // return { getCount, add }
  return [getCount, add] // 返回一个数组或者对象都可以
}

const c1 = Counter()
c1[1]()
c1[1]()
c1[0]() // 2

const c2 = Counter()
c2[1]()
c2[0]() // 1

使用闭包模拟, 每次运行Counter()产生的闭包环境都是独立的, 不受其他闭包影响. 注意这里的闭包指的是getCountadd两个闭包, Counter只是提供这两个闭包的一部分环境(count), count作为状态, 被这两个函数读取操作

通过以上例子, 我们用闭包, 实现了类才能实现的功能, 这是一件非常神奇的事情.

闭包和 React Hooks

当然, 闭包的实际应用还有很多, 比如React Hooks, 写这篇文章的原因很大程度上也是因为Hooks的使用还是有很多心智负担的, 想要更好的使用还是需要去了解一些稍微底层的原理.

如果你对 React-hooks 的实现比较感兴趣, 可以参考这篇文章, 这篇文章使用原生JavaScript实现了最最简单的Hooks. 不过还是有很多地方说的比较简略. 后续可能会再写一篇博客将里面代码做更加详细的分析

如果对 React 或者 hooks 不了解的, 我非常推荐去看一看 Redux 作者 Dan Abramov 在 2018 年 React Conf 上关于 hooks 介绍的一篇演讲: React Today and Tomorrow and 90% Cleaner React With Hooks. 相信你会非常震撼的.

参考