原来我从未真正的理解过 JavaScript 闭包

一直对JavaScript 的闭包理解的模棱两可,今天偶然看到了一位兄弟翻译的一篇文章,读来感觉受益匪浅,怕文章消失于茫茫文海,所以转载过来以做备忘.

英文原文: I never understood JavaScript closures
翻译原文:我从没理解过 JavaScript 闭包
作者: Olivier De Meulder
译注:作者从 JavaScript 的原理出发,详细解读执行过程,通过“背包”的形象比喻,来解释闭包。

我从没理解过 JavaScript 闭包,直到有人这样跟我解释……

正如标题所说,JavaScript 闭包对我来说一直是个迷。我 看过 很多 文章,在工作中用过闭包,甚至有时候我都没有意识到我在使用闭包。

最近参加一个交流会,有人用某种方式向我解释了闭包,点醒了我。这篇文章我也将用这种方式来解释闭包。这里要称赞一下 CodeSmith 的优秀人才和他们的《JavaScript The Hard Parts》系列。

开始之前

在理解闭包之前,一些重要的概念需要理解。其中一个就是 执行上下文(execution context)

这篇文章 对执行上下文有很好的介绍。引用一下这篇文章:

JavaScript 代码在执行时,它的执行环境非常重要,它会被处理成下面的某一种情况:
全局代码(Global code) —— 代码开始执行时的默认环境。
函数代码(Function code) —— 当执行到函数体时。
(…)
(…), 我们把术语 执行上下文(execution context) 称为当前执行代码所处的 环境或者作用域。

换句话说,当我们开始执行程序时,首先处于全局上下文中。在全局上下文中声明的变量,称为全局变量。当程序调用函数时,会发生什么?发生下面这几步:

  1. JavaScript 创建一个新的执行上下文 —— 局部执行上下文。
  2. 这个局部执行上下文有属于它的变量集,这些变量是这个执行上下文的局部变量。
  3. 这个新的执行上下文被压入执行栈中。将执行栈当成是用来跟踪程序执行位置的一种机制。

函数什么时候执行完?当遇到 return 语句或者结束括号 } 时。函数结束时,发生下面情况:

  1. 局部执行上下文从执行栈弹出。
  2. 函数把返回值返回到调用上下文。调用上下文是指调用该函数的的执行上下文,它可以是全局执行上下文也可以是另外一个局部执行上下文。这里的返回值怎么处理取决于调用执行上下文。返回值可是 object, array, function, boolean 等任何类型。如果函数没有 return 语句,那么返回值是 undefined。
  3. 局部执行上下文被销毁。这点很重要 —— 被销毁。所有在局部执行上下文中声明的变量都被清除。这些变量不再可用。这也是为什么称它们为局部变量。

一个非常简单的例子

在开始学习闭包之前,我们先来看下下面这段代码。它看起来很简单,所有的读者应该都能清楚的知道它的作用。

1: let a = 3
2: function addTwo(x) {
3:   let ret = x + 2
4:   return ret
5: }
6: let b = addTwo(a)
7: console.log(b)

为了理解 JavaScript 引擎的真正工作原理,我们来详细解释一下。

  1. 在代码第一行,我们在全局执行上下文声明了一个新的变量 a,并赋值为 3。
  2. 接下来比较棘手了。第 2 到第 5 行属于一个整体。这里发生了什么呢?我们在全局执行上下文声明了一个变量,命名为 addTwo。然后我们怎么对它赋值的?通过函数定义。所有在两个括号 {} 之间的内容都被赋给 addTwo。函数里的代码不计算、不执行,只是保存在变量,留着后面使用。
  3. 现在我们到了第 6 行。看似很简单,其实这里有很多需要解读。首先我们在全局执行上下文声明了一个变量,标记为 b。当变量刚声明时,它的默认值是 undefined。
  4. 接着,还是在第 6 行,我们看到有个赋值运算符。我们准备给变量 b 赋新值。接着看到一个将要被调用的函数。当你看到变量后面跟着圆括号 (...) ,那就是函数调用的标识。提前说下后面的情况:每个函数都有返回值(一个值、一个对象或者是 undefined)。函数的返回值将被赋值给变量 b。
  5. 但是(在赋值前)我们首先要调用函数 addTwo。JavaScript 将在全局执行上下文内存中查找变量 addTwo。找到了!它在第 2 步(第 2-5 行)中定义,你瞧,变量 addTwo 包含函数定义。注意,变量 a 当做参数传给了函数。JavaScript 在全局执行上下文内存中寻找变量 a,找到并发现它的值是 3,然后把数值 3 做为参数传给函数。函数执行准备就绪。
  6. 现在执行上下文将会切换。一个新的局部执行上下文被创建,我们把它命名为 “addTwo 执行上下文”。该执行上下文被压入调用栈。在局部执行上下文中首先做些什么事呢?
  7. 你可能会想说:“在局部执行上下文中声明一个新的变量 ret ”。然后答案不是这样。正确答案是:我们首先需要查看函数的参数:在局部执行上下文中声明新的变量 x,因为值 3 作为参数传给函数,所以变量 x 赋值为数值 3。
  8. 下一步:局部执行上下文中声明新变量 ret。它的值默认为 undefined。(第3行)
  9. 还是第 3 行,准备执行加法。我们首先需要获取 x 的值。JavaScript 将寻找变量 x。首先在局部执行上下文中寻找。找到变量 x 的值为 3。第二个操作数是数值 2,加法的结果(5)赋值给变量 ret。
  10. 第 4 行。我们返回变量 ret 的值。在局部执行上下文中又进行查找 ret。ret 的值为 5。所以该函数返回数值 5,函数结束。
  11. 第 4-5 行。函数结束。局部执行上下文被销毁。变量 x 和 ret 被清除,不再存在。调用栈弹出该上下文,返回值返回给调用上下文。在这个例子中,调用上下文是全局执行上下文,因为函数 addTwo 是在全局执行上下文中调用的。
  12. 现在回到我们在第 4 步遗留的内容。返回值(数值 5)复制给变量 b。在这个小程序中,我们还在第 6 行。
  13. 下面我不再详细说明了。在第 7 行,变量 b 的值在 console 中打印出来。在我们的例子里将打印出数值 5。
    对一个简单的程序,这真是个冗长的解释!而且我们甚至还没涉及到闭包。我保证一定会讲解闭包的。但是我们还是需求绕一两次。

对一个简单的程序,这真是个冗长的解释!而且我们甚至还没涉及到闭包。我保证一定会讲解闭包的。但是我们还是需求绕一两次。

词法作用域 (Lexical scope)

我们需要理解词法作用域的一些知识点。看看下面的例子:

1: let val1 = 2
2: function multiplyThis(n) {
3:   let ret = n * val1
4:   return ret
5: }
6: let multiplied = multiplyThis(6)
7: console.log('example of scope:', multiplied)

例子中,在局部执行上下文和全局执行上下文各有一些变量。JavaScript 的一个难点是如何寻找变量。如果在局部执行上下文没找到某个变量,那么到它的调用上下文中去找。如果在它的调用上下文也没找到,重复上面的查找步骤,直到在全局执行上下文中找(如果也没找到,那么就是 undefined )。按照上面的例子来说明,它会验证这点。如果你理解作用域的原理,你可以跳过这部分。

  1. 在全局执行上下文声明一个新变量 val1 ,并赋值为数值 2。
  2. 第 2-5 行声明新变量 multiplyThis 并赋值为函数定义。
  3. 第 6 行,在全局执行上下文声明新变量 multiplied。
  4. 在全局执行上下文内存中获取变量 multiplyThis 并作为函数执行。传入参数数值 6。
  5. 新函数调用 = 新的执行上下文:创建新的局部执行上下文。
  6. 在局部执行上下文中,声明变量 n 并赋值为数值 6。
  7. 第 3 行,在局部执行上下文中声明变量 ret。
  8. 还是第 3 行,两个操作数——变量 n 和 val1 的值执行乘法运算。先在局部执行上下文查找变量 n,它是我们在第 6 步中声明的,值为数值 6。接着在局部执行上下文查找变量 val1,在局部执行上下文没有找到名为 val1 的变量,所以我们检查调用上下文中。这里调用上下文是全局执行上下文。我们在全局执行上下文中找到它,它在第 1 步中被定义,值为数值 2。
  9. 依旧是第 3 行。两个操作数相乘然后赋值给变量 ret。6 * 2 = 12。ret 现在值为 12。
  10. 返回变量 ret。局部执行上下文以及相应的变量 ret 和 n 一起被销毁。变量 val1 作为全局执行上下文的一部分没有被销毁。
  11. 回到第 6 行。在调用上下文中,变量 multiplied 被赋值为数值 12。
  12. 最后在第 7 行,我们在 console 中显示变量 multiplied 的值。

在这个例子中,我们需要记住,函数可以访问到它调用上下文中定义的变量。这种现象正式学名是 词法作用域

(译者注:觉得这里对词法作用域的解释限于此例,并不完全准确。词法作用域,函数的作用域是在函数定义的时候决定的,而不是调用时)。

返回值是函数的函数

在第一个例子里函数 addTwo 返回的是个数值。记得之前提过函数可以返回任何类型。我们来看个函数返回函数的例子,这个是理解闭包的关键点。下面是我们要分析的例子。

1: let val = 7
 2: function createAdder() {
 3:   function addNumbers(a, b) {
 4:     let ret = a + b
 5:     return ret
 6:   }
 7:   return addNumbers
 8: }
 9: let adder = createAdder()
10: let sum = adder(val, 8)
11: console.log('example of function returning a function: ', sum)

我们来一步一步分解:

  1. 第 1 行,我们在全局执行上下文声明变量 val 并赋值为数值 7。
  2. 第 2-8 行,我们在全局执行上下文声明变量 createAdder 并赋值为函数定义。第 3-7 行表示函数定义。和前面所说,这时候不会进入函数,我们只是把函数定义保存在变量 (createAdder)。
  3. 第 9 行,我们在全局执行上下文声明名为 adder 的新变量,暂时赋值为 undefined。
  4. 还是第 9 行,我们看到有括号 (),知道需要执行或者调用函数。我们从全局执行上下文的内存中查找变量 createAdder,它在第 2 步创建。ok,现在调用它
  5. 调用函数,我们现在处于第 2 行。新的局部执行上下文被创建。我们可以在新的执行上下文中创建局部变量。JavaScript 引擎把新的上下文压入调用栈。该函数没有参数,我们直接进入函数体。
  6. 还是在 3-6 行。我们声明了个新函数。我们在局部执行上下文中创建了新的变量 addNumbers,这点很重要,addNumbers 只在局部执行上下文中出现。我们使用局部变量 addNumbers 保存了函数定义。
  7. 现在到了第 7 行。我们返回变量 addNumbers 的值。JavaScript 引擎找到 addNumbers 这个变量,它是个函数定义。这没问题,函数可以返回任意类型,包括函数定义。所以我们返回了 addNumbers 这个函数定义。括号中的所有内容——第 4-5 行组成了函数定义。我们也从调用栈中移除了该局部执行上下文。
  8. 局部执行上下文在返回时销毁了。addNumbers 变量不存在了,但是函数定义还在,它被函数返回并赋值给了变量 adder —— 我们在第 3 步创建的变量。
  9. 现在到了第 10 行。我们在全局执行上下文中定义了新变量 sum,暂时赋值是 undefined。
  10. 接下来需要需要执行函数。函数定义在变量 adder 中。我们在全局执行上下文中查找并确保找到了它。这个函数带有两个参数。
  11. 我们获取这两个参数,以便能调用函数并传入正确的参数。第一个参数是变量 val,在第 1 步中定义,表示数值 7 , 第二个参数是数值 8。
  12. 现在我们开始执行函数。该函数在定义在 3-5 行。新的局部执行上下文被创建,同时创建了两个新变量:a 和 b,他们分别赋值为 7 和 8,这是上一步提到的传给函数的参数。
  13. 第 4 行,声明变量 ret。它是在局部执行上下文中声明的。
  14. 第 4 行,进行加法运算:我们让变量 a 和变量 b 的值相加。相加的结果(15)赋值给变量 ret。
  15. 函数返回变量 ret 。局部执行上下文销毁,从调用栈中移除,变量 a、b 和 ret 都不存在了。
  16. 返回值赋值给在第 9 步定义的变量 sum。
  17. 在 console 中打印 sum 的值。

正如所预期的,console 打印出 15,但是这个过程我们真的经历了很多困难。我想在这里说明几点。首先,函数定义可以保存在变量中,函数定义在执行前对程序是不可见的;第二点,每次函数调用,都会创建一个局部执行上下文(临时的),局部执行上下文在函数结束后消失,函数在遇到 return 语句或者右括号 } 时结束。

最后,闭包

看看下面的代码,会发生什么。

 1: function createCounter() {
 2:   let counter = 0
 3:   const myFunction = function() {
 4:     counter = counter + 1
 5:     return counter
 6:   }
 7:   return myFunction
 8: }
 9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)

通过之前的两个例子,我们应该掌握了其中的窍门,让我们按我们期望的执行方式来快速过一遍执行过程。

  1. 1-8 行。我们在全局执行上下文创建了变量 createCounter 并赋值为函数定义。
  2. 第 9 行。在全局执行上下文声明变量 increment。
  3. 还是第 9 行。我们需要调用函数 createCounter 并把它的返回值赋值给变量 increment。
  4. 1-8 行,函数调用,创建新的局部执行上下文。
  5. 第 2 行,在局部执行上下文中声明变量 counter,并赋值为数值 0。
  6. 3-6 行,声明名为 myFunction 的变量。该变量是在局部执行上下文声明的。变量的内容是另一个函数定义 —— 在 4-5 行定义。
  7. 第 7 行,返回变量 myFunction 的值。局部执行上下文被删除了,myFunction 和 counter 也不存在了。程序控制权回到调用上下文。
  8. 第 9 行。在调用上下文,也是全局执行上下文中,createCounter 的返回值赋给 increment。现在变量 increment 包含一个函数定义。该函数定义是 createCounter 返回的。它不再是标记为 myFunction,但是是同一个函数定义。在全局执行上下文中,它被命名为 increment。
  9. 第 10 行,声明变量 c1。
  10. 继续第 10 行,寻找变量 increment,它是个函数,调用函数。它包含之前返回的函数定义 —— 在 4-5 行定义的。
  11. 创建新的执行上下文,这里没有参数,开始执行函数。
  12. 第 4 行,counter = counter + 1。在局部执行上下文寻找 counter 的值。我们只是创建了上下文而没有声明任何局部变量。我们看看全局执行上下文,也没有变量 counter。JavaScript 会把这个转化成 counter = undefined + 1,声明新的局部变量 counter 并赋值为数值 1,因为 undefined 会转化成 0。
  13. 第 5 行,我们返回 counter 的值,或者说数值 1。销毁局部执行上下文和变量 counter。
  14. 回到第 10 行,返回值(1)赋给 c1。
  15. 第 11 行,重复第 10-14 的步骤,最后 c2 也赋值为 1。
  16. 第 12 行,重复第 10-14 的步骤,最后 c3 也赋值为 1。
  17. 第 13 行,我们打印出变量 c1、c2 和 c3 的值。

自己尝试一下这个,看看会发生什么。你会发现,打印出来的并不是上面解释的预期结果 1、 1 和 1,而是打印出 1、 2 和 3。所以发生了什么?

不知道为什么,increment 函数记住了 counter 的值。这是怎么实现的呢?

是不是因为 counter 是属于全局执行上下文?试试 console.log(counter),你会得到 undefined。所以它并不是。

或许,是因为当你调用 increment 时,它以某种方式返回创建它的函数(createCounter)的地方?这是怎么回事呢?变量 increment 包含函数定义,而不是它从哪里创建。所以并不是这个原因。

所以这里肯定存在另一种机制。它就是闭包。我们终于讲到它了,一直缺失的部分。

下面是它的工作原理。只要你声明一个新的函数并赋值给一个变量,你就保存了这个函数定义,也就形成了闭包。闭包包含函数创建时的作用域里的所有变量。这类似于一个背包。函数定义带着一个背包,包里保存了所有在函数定义创建时作用域里的变量。

所以我们上面的解释全错了。我们重新来一遍,这次是正确的。

1: function createCounter() {
 2:   let counter = 0
 3:   const myFunction = function() {
 4:     counter = counter + 1
 5:     return counter
 6:   }
 7:   return myFunction
 8: }
 9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
  1. 1-8 行。我们在全局执行上下文创建了变量 createCounter 并赋值为函数定义。同上。
  2. 第 9 行。在全局执行上下文声明变量 increment。同上。
  3. 还是第 9 行。我们需要调用函数 createCounter 并把它的返回值赋值给变量 increment。同上。
  4. 1-8 行,函数调用,创建新的局部执行上下文。同上。
  5. 第 2 行,在局部执行上下文中声明变量 counter,并赋值为数值 0。同上。
  6. 3-6 行,声明名为 myFunction 的变量。该变量是在局部执行上下文声明的。变量的内容是另一个函数定义 —— 在 4-5 行定义。现在我们同时 创建了一个闭包 并把它作为函数定义的一部分。闭包包含了当前作用域里的变量,在这里是变量 counter (值为 0)。
  7. 第 7 行,返回变量 myFunction 的值。局部执行上下文被删除了,myFunction 和 counter 也不存在了。程序控制权回到调用上下文。所以我们返回了函数定义和它的 闭包 —— 这个背包包含了函数创建时作用域里的变量。
  8. 第 9 行。在调用上下文,也是全局执行上下文中,createCounter 的返回值赋给 increment。现在变量 increment 包含一个函数定义(和闭包)。该函数定义是 createCounter 返回的。它不再是标记为 myFunction,但是是同一个函数定义。在全局执行上下文中,它被命名为 increment。
  9. 第 10 行,声明变量 c1。
  10. 继续第 10 行,寻找变量 increment,它是个函数,调用函数。它包含之前返回的函数定义 —— 在 4-5 行定义的。(同时它也有个包含变量的背包)
  11. 创建新的执行上下文,这里没有参数,开始执行函数。
  12. 第 4 行,counter = counter + 1。我们需要寻找变量 counter。我们在局部或者全局执行上下文寻找前,先查看我们的背包。我们检查闭包。你瞧!闭包里包含变量 counter,值为 0。通过第 4 行的表达式,它的值设为 1。它继续保存在背包里。现在闭包包含值为 1 的变量 counter。
  13. 第 5 行,我们返回 counter 的值,或者说数值 1。销毁局部执行上下文和变量 counter。
  14. 回到第 10 行,返回值(1)赋给 c1。
  15. 第 11 行,重复第 10-14 的步骤。这次,当我们查看闭包时,我们看到变量 counter 的值为 1。它是在第 12 步(程序第 4 行)设置的。通过 increment 函数,它的值增加并保存为 2。 最后 c2 也赋值为 2。
  16. 第 12 行,重复第 10-14 的步骤,最后 c3 也赋值为 3。
  17. 第 13 行,我们打印出变量 c1、c2 和 c3 的值。

现在我们理解它的原理了。需要记住的关键点是,但函数声明时,它包含函数定义和一个闭包。闭包是函数创建时作用域内所有变量的集合。

你可能会问,是不是所有函数都有闭包,即使是在全局作用域下创建的函数?答案是肯定的。全局作用域下创建的函数也生成闭包。但是既然函数是在全局作用域下创建的,他们可以访问全局作用域下的所有变量。所以这和闭包的概念不相关。

当函数的返回值是一个函数时,闭包的概念就变得更加相关了。返回的函数可以访问不在全局作用域里的变量,但它们只存在于闭包里。

并不简单的闭包

有时候,你可能都没有注意到闭包的生成。你可能在偏函数应用看到过例子,像下面这段代码:

  let c = 4
  const addX = x => n => n + x
  const addThree = addX(3)
  let d = addThree(c)
  console.log('example partial application', d)

如果箭头函数让你难以理解,下面是等价的代码:

let c = 4
function addX(x) {
  return function(n) {
     return n + x
  }
}
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)

我们声明了一个通用的相加函数 addX:传入一个参数(x)然后返回另一个函数。

返回的函数也带有一个参数,这个参数和变量 x 相加。

变量 x 是闭包的一部分。当变量 addThree 在局部上下文中声明时,被赋值为函数定义和闭包。该闭包包含变量 x。

所以现在调用执行 addThree 是,它可以从闭包中获取变量 x,而变量 n 是通过参数传入,所以函数可以返回相加的和。

这个例子 console 会打印出数值 7。

结论

我牢牢记住闭包的方法是通过 背包的比喻 。当一个函数被创建、传递或者从另一个函数中返回时,它就背着一个背包。背包里是函数声明时的作用域里的所有变量。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容