Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单聊一聊闭包 #5

Open
hacker0limbo opened this issue Oct 15, 2019 · 0 comments
Open

简单聊一聊闭包 #5

hacker0limbo opened this issue Oct 15, 2019 · 0 comments
Labels
javascript 原生 JavaScript 笔记整理

Comments

@hacker0limbo
Copy link
Owner

hacker0limbo commented Oct 15, 2019

最近根据一些教程尝试实现一个简易的 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

这里有两个点需要注意:

  • 每次执行函数以后创建的闭包其环境都是独立的

  • 引用问题, 再调用完add1()函数以后, x已经改变了地址, 不再指向 100, 而是指向了 105, 然而x1还是指向原始的 100, 同理add2(). 不理解的可以看下面的示例代码:

    let a = 0
    let b = a // a 和 b 指向同一地址
    a = 100 // a 的指向改变, b 不变
    console.log({ a, b }) // { a: 100, b: 0 }

第二题

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. 相信你会非常震撼的.

参考

@hacker0limbo hacker0limbo added the javascript 原生 JavaScript 笔记整理 label Oct 15, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
javascript 原生 JavaScript 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant