js基础

作用域和闭包

2019-10-30  本文已影响0人  旭哥_

一、理解 JavaScript 的作用域、作用域链和内部原理

作用域

javascript 拥有一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量,这套规则被称为作用域

作用域就是代码的执行环境,全局执行环境就是全局作用域,函数的执行环境就是私有作用域,它们都是栈内存。

作用域链

当代码在一个环境中执行时,会创建变量对象的一个作用域链(作用域形成的链条),由于变量的查找是沿着作用域链来实现的,所以也称作用域链为变量查找的机制。

内部环境可以通过作用域链访问所有外部环境,但外部环境不能访问内部环境的任何变量和函数。

内部原理

二、理解词法作用域和动态作用域

词法作用域

编译器的第一个工作阶段叫作分词,就是把由字符组成的字符串分解成词法单元。这个概念是理解词法作用域的基础

简单地说,词法作用域就是定义在词法阶段的作用域,是由写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

function foo(a) {
  var b = a * 2
  function bar(c) {
    console.log(a, b, c)
  }
  bar(b * 3)
}
foo(2) // 2 4 12

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含的气泡

image

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的

气泡 1 包含着整个全局作用域,其中只有一个标识符:foo

气泡 2 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b

气泡 3 包含着 bar 所创建的作用域,其中只有一个标识符:c

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置

在代码片段中,引擎执行 console.log(...)声明,并查找 a、b 和 c 三个变量的引用。它首先从最内部的作用域,也就是 bar(...)函数的作用域开始查找。引擎无法在这里找到 a,因此会去上一级到所嵌套的 foo(...)的作用域中继续查找。在这里找到了 a,因此引擎使用了这个引用。对 b 来讲也一样。而对 c 来说,引擎在 bar(...)中找到了它

[注意]词法作用域查找只会查找一级标识符,如果代码引用了 foo.bar.baz,词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则分别接管对 bar 和 baz 属性的访问

foo = {
  bar: {
    baz: 1
  }
}
console.log(foo.bar.baz) // 1

作用域查找从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止

在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”,内部的标识符“遮蔽”了外部的标识符

var a = 0
function test() {
  var a = 1
  console.log(a) // 1
}
test()

全局变量会自动为全局对象的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问

var a = 0
function test() {
  var a = 1
  console.log(window.a) //0
}
test()

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到

动态作用域

javascript 使用的是词法作用域,它最重要的特征是它的定义过程发生在代码的书写阶段

那为什么要介绍动态作用域呢?实际上动态作用域是 javascript 另一个重要机制 this 的表亲。作用域混乱多数是因为词法作用域和 this 机制相混淆,傻傻分不清楚

动态作用域并不关心函数和作用域是如何声明以及在任何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套

var a = 2
function foo() {
  console.log(a)
}
function bar() {
  var a = 3
  foo()
}
bar()

【1】如果处于词法作用域,也就是现在的 javascript 环境。变量 a 首先在 foo()函数中查找,没有找到。于是顺着作用域链到全局作用域中查找,找到并赋值为 2。所以控制台输出 2

【2】如果处于动态作用域,同样地,变量 a 首先在 foo()中查找,没有找到。这里会顺着调用栈在调用 foo()函数的地方,也就是 bar()函数中查找,找到并赋值为 3。所以控制台输出 3

两种作用域的区别,简而言之,词法作用域是在定义时确定的,而动态作用域是在运行时确定的

三、理解 JavaScript 的执行上下文栈,可以应用堆栈信息快速定位问题

执行上下文

执行栈

执行栈,在其他编程语言中也被叫做调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。

当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行上下文并将其推入当前的执行栈。每当发生一个函数调用,引擎都会为该函数创建一个新的执行上下文并将其推到当前执行栈的顶端。

引擎会运行执行上下文在执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文。

让我们通过下面的代码示例来理解这一点:

let a = 'Hello World!';

function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}

function second() {
console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

当上述代码在浏览器中加载时,JavaScript 引擎会创建一个全局执行上下文并且将它推入当前的执行栈。当调用 first() 函数时,JavaScript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。

当在 first() 函数中调用 second() 函数时,Javascript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。当 second() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文,即 first() 函数的执行上下文。

first() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到全局执行上下文。一旦所有代码执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。

执行上下文是如何被创建的

到目前为止,我们已经看到了 JavaScript 引擎如何管理执行上下文,现在就让我们来理解 JavaScript 引擎是如何创建执行上下文的。

执行上下文分两个阶段创建: 1)创建阶段; 2)执行阶段

创建阶段

在任意的 JavaScript 代码被执行前,执行上下文处于创建阶段。在创建阶段中总共发生了三件事情:

因此,执行上下文可以在概念上表示如下:

ExecutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = { ... },
  VariableEnvironment = { ... },
}

This Binding:

在全局执行上下文中, this 的值指向全局对象,在浏览器中, this 的值指向 window 对象。

在函数执行上下文中, this 的值取决于函数的调用方式。如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值被设置为全局对象或 undefined (严格模式下)。例如:

let person = {
  name: 'peter',
  birthYear: 1994,
  calcAge: function() {
    console.log(2018 - this.birthYear);  
  }
}

person.calcAge();
// 'this' 指向 'person', 因为 'calcAge' 是被 'person' 对象引用调用的。

let calculateAge = person.calcAge;
calculateAge();
// 'this' 指向全局 window 对象,因为没有给出任何对象引用

词法环境(Lexical Environment)

官方 ES6 文档将词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系。词法环境由环境记录(environment record)和可能为空引用(null)的外部词法环境组成。

简而言之,词法环境是一个包含 标识符变量映射 的结构。(这里的 标识符 表示变量/函数的名称, 变量 是对实际对象【包括函数类型对象】或原始值的引用)

在词法环境中,有两个组成部分:(1) 环境记录(environment record) (2) 对外部环境的引用

  1. 环境记录 是存储变量和函数声明的实际位置。
  2. 对外部环境的引用 意味着它可以访问其外部词法环境。

词法环境有两种类型:

注意:对于 函数环境 而言, 环境记录 还包含了一个 arguments 对象,该对象包含了索引和传递给函数的参数之间的映射以及传递给函数的参数的 长度(数量) 。例如,下面函数的 arguments 对象如下所示:

function foo(a, b) {
var c = a + b;
}
foo(2, 3);

// arguments 对象
Arguments: {0: 2, 1: 3, length: 2},

环境记录同样有两种类型(如下所示):

抽象地说,词法环境在伪代码中看起来像这样:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里 
      outer: <null>
    }
  }
}

FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里 
      outer: <Global or outer function environment reference>
    }
  }
}

变量环境:

它也是一个词法环境,其 EnvironmentRecord 包含了由 VariableStatements 在此执行上下文创建的绑定。

如上所述,变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中, LexicalEnvironment 组件和 VariableEnvironment 组件的区别在于前者用于存储函数声明和变量( letconst )绑定,而后者仅用于存储变量( var )绑定。

让我们结合一些代码示例来理解上述概念:

let a = 20;
const b = 30;
var c;

function multiply(e, f) {
  var g = 20;
  return e *f *g;
}

c = multiply(20, 30);

执行上下文如下所示:

GlobalExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里  
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }  
    outer: <null>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符绑定在这里  
      c: undefined,
    }  
    outer: <null>
  }
}

FunctionExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里  
      Arguments: {0: 20, 1: 30, length: 2},
    },  
    outer: <GlobalLexicalEnvironment>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符绑定在这里  
      g: undefined
    },  
    outer: <GlobalLexicalEnvironment>
  }
}

注意:只有在遇到函数 multiply 的调用时才会创建函数执行上下文。

你可能已经注意到了 letconst 定义的变量没有任何与之关联的值,但 var 定义的变量设置为 undefined

这是因为在创建阶段,代码会被扫描并解析变量和函数声明,其中函数声明存储在环境中,而变量会被设置为 undefined (在 var 的情况下)或保持未初始化(在 letconst 的情况下)。

这就是为什么你可以在声明之前访问 var 定义的变量(尽管是 undefined ),但如果在声明之前访问 letconst 定义的变量就会提示引用错误的原因。

这就是我们所谓的变量提升。

执行阶段

这是整篇文章中最简单的部分。在此阶段,完成对所有变量的分配,最后执行代码。

注:在执行阶段,如果 Javascript 引擎在源代码中声明的实际位置找不到 let 变量的值,那么将为其分配 undefined 值。

错误堆栈的裁剪

Node.js 才支持这个特性,通过 Error.captureStackTrace 来实现,Error.captureStackTrace 接收一个 object 作为第 1 个参数,以及可选的 function 作为第 2 个参数。其作用是捕获当前的调用栈并对其进行裁剪,捕获到的调用栈会记录在第 1 个参数的 stack 属性上,裁剪的参照点是第 2 个参数,也就是说,此函数之前的调用会被记录到调用栈上面,而之后的不会。

让我们用代码来说明,首先,把当前的调用栈捕获并放到 myObj 上:

const myObj = {};
function c() {}
function b() {
  // 把当前调用栈写到 myObj 上
  Error.captureStackTrace(myObj);
  c();
}
function a() {
  b();
}

// 调用函数 a
a();

// 打印 myObj.stack
console.log(myObj.stack);

// 输出会是这样
//    at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
//    at a (repl:2:1)
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)

上面的调用栈中只有 a -> b,因为我们在 b 调用 c 之前就捕获了调用栈。现在对上面的代码稍作修改,然后看看会发生什么:

const myObj = {};
function d() {
  // 我们把当前调用栈存储到 myObj 上,但是会去掉 b 和 b 之后的部分
  Error.captureStackTrace(myObj, b);
}
function c() {
  d();
}
function b() {
  c();
}
function a() {
  b();
}

// 执行代码
a();

// 打印 myObj.stack
console.log(myObj.stack);

// 输出如下
//    at a (repl:2:1) <-- As you can see here we only get frames before b was called
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)
//    at emitOne (events.js:101:20)

在这段代码里面,因为我们在调用 Error.captureStackTrace 的时候传入了 b,这样 b 之后的调用栈都会被隐藏。

现在你可能会问,知道这些到底有啥用?如果你想对用户隐藏跟他业务无关的错误堆栈(比如某个库的内部实现)就可以试用这个技巧。

错误调试

1.Error对象和错误处理

当程序运行出现错误时, 通常会抛出一个 Error 对象. Error 对象可以作为用户自定义错误对象继承的原型.

Error.prototype 对象包含如下属性:

constructor–指向实例的构造函数

message–错误信息

name–错误的名字(类型)

上述是 Error.prototype 的标准属性, 此外, 不同的运行环境都有其特定的属性. 在例如 Node, Firefox, Chrome, Edge, IE 10+, Opera 以及 Safari 6+

这样的环境中, Error 对象具备 stack 属性, 该属性包含了错误的堆栈轨迹. 一个错误实例的堆栈轨迹包含了自构造函数之后的所有堆栈结构.

2.如何查看调用栈

只查看调用栈:console.trace

a()
function a() {
  b()
}
function b() {
  c()
}
function c() {
  let aa = 1
}
console.trace()

3.debugger打断点形式

四、this 的原理以及几种不同使用场景的取值

作为对象方法调用

在 JavaScript 中,函数也是对象,因此函数可以作为一个对象的属性,此时该函数被称为该对象的方法,在使用这种调用方式时,this 被自然绑定到该对象

var test = {
  a:0,
  b:0,
  get:function(){
    return this.a;
  }
}

作为函数调用

函数也可以直接被调用,此时 this 绑定到全局对象。在浏览器中,window 就是该全局对象。比如下面的例子:函数被调用时,this 被绑定到全局对象,

接下来执行赋值语句,相当于隐式的声明了一个全局变量,这显然不是调用者希望的。

function makeNoSense(x) {
  this.x = x;
}

作为构造函数调用

javaScript 支持面向对象式编程,与主流的面向对象式编程语言不同,JavaScript 并没有类(class)的概念,而是使用基于原型(prototype)的继承方式。

相应的,JavaScript 中的构造函数也很特殊,如果不使用 new 调用,则和普通函数一样。作为又一项约定俗成的准则,构造函数以大写字母开头,

提醒调用者使用正确的方式调用。如果调用正确,this 绑定到新创建的对象上。

function Point(x, y){
  this.x = x;
  this.y = y;
}

在call或者apply,bind中调用

让我们再一次重申,在 JavaScript 中函数也是对象,对象则有方法,apply 和 call 就是函数对象的方法。

这两个方法异常强大,他们允许切换函数执行的上下文环境(context),即 this 绑定的对象。

很多 JavaScript 中的技巧以及类库都用到了该方法。让我们看一个具体的例子:

function Point(x, y){
  this.x = x;
  this.y = y;
  this.moveTo = function(x, y){
    this.x = x;
    this.y = y;
  }
}

var p1 = new Point(0, 0);
var p2 = {x: 0, y: 0};
p1.moveTo(1, 1);
p1.moveTo.apply(p2, [10, 10])

五、闭包的实现原理和作用,可以列举几个开发中闭包的实际应用

闭包的概念

闭包的作用

因为函数内部声明 的变量是局部的,只能在函数内部访问到,但是函数外部的变量是对函数内部可见的,这就是作用域链的特点了。

子级可以向父级查找变量,逐级查找,找到为止

因此我们可以在函数内部再创建一个函数,这样对内部的函数来说,外层函数的变量都是可见的,然后我们就可以访问到他的变量了。

function  bar(){
    //外层函数声明的变量
    var value=1;

    function foo(){
        console.log(value);
    }
    return foo();
};
var bar2=bar;
//实际上bar()函数并没有因为执行完就被垃圾回收机制处理掉
//这就是闭包的作用,调用bar()函数,就会执行里面的foo函数,foo这时就会访问到外层的变量
bar2();

foo()包含bar()内部作用域的闭包,使得该作用域能够一直存活,不会被垃圾回收机制处理掉,这就是闭包的作用,以供foo()在任何时间进行引用。

闭包的优点

闭包的缺点

闭包在实际中的应用

function addFn(a,b){
    return(function(){
        console.log(a+"+"+b);
    })
}
var test =addFn(a,b);
setTimeout(test,3000);

一般setTimeout的第一个参数是个函数,但是不能传值。如果想传值进去,可以调用一个函数返回一个内部函数的调用,将内部函数的调用传给setTimeout。内部函数执行所需的参数,外部函数传给他,在setTimeout函数中也可以访问到外部函数。

六、理解堆栈溢出和内存泄漏的原理,如何防止

内存泄露

堆栈溢出

标记清除法

在一些编程软件中,比如c语言中,需要使用malloc来申请内存空间,再使用free释放掉,需要手动清除。而js中是有自己的垃圾回收机制的,一般常用的垃圾收集方法就是标记清除。

标记清除法:在一个变量进入执行环境后就给它添加一个标记:进入环境,进入环境的变量不会被释放,因为只要执行流进入响应的环境,就可能用到他们。当变量离开环境后,则将其标记为“离开环境”。

常见的内存泄露的原因

解决方法

七、如何处理循环的异步操作

使用自执行函数

1、当自执行函数在循环当中使用时,自执行函数会在循环结束之后才会运行。比如你在自执行函数外面定义一个数组,在自执行函数当中给这个数组追加内容,你在自执行函数之外输出时,会发现这个数组当中什么都没有,这就是因为自执行函数会在循环运行完后才会执行。

2、当自执行函数在循环当中使用时,要是自执行函数当中嵌套ajax,那么循环当中的下标i就不会传进ajax当中,需要在ajax外面把下标i赋值给一个变量,在ajax中直接调用这个变量就可以了。

例子:

$.ajax({
    type: "GET",
    dataType: "json",
    url: "***",
    success: function(data) {
        //console.log(data);               
        for (var i = 0; i < data.length; i++) {
            (function(i, abbreviation) {
                $.ajax({
                    type: "GET",
                    url: "/api/faults?abbreviation=" + encodeURI(abbreviation),
                    dataType: "json",
                    success: function(result) {
                        //获取数据后做的事情
                    }
                })
            })(i, data[i].abbreviation);
        }
    }
});

使用递归函数

所谓的递归函数就是在函数体内调用本函数。使用递归函数一定要注意,处理不当就会进入死循环。

const asyncDeal = (i) = > {
    if (i < 3) {
        $.get('/api/changeParts/change_part_standard?part=' + data[i].change_part_name, function(res) {
            //获取数据后做的事情
            i++;
            asyncDeal(i);
        })
    } else {
        //异步完成后做的事情
    }
};
asyncDeal(0);

使用async/await

async/await更加语义化,async 是“异步”的简写,async function 用于申明一个 function 是异步的; await,可以认为是async wait的简写, 用于等待一个异步方法执行完成;

async/await是一个用同步思维解决异步问题的方案(等结果出来之后,代码才会继续往下执行)

可以通过多层 async function 的同步写法代替传统的callback嵌套

自动将常规函数转换成Promise,返回值也是一个Promise对象

只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数

异步函数内部可以使用await

await 放置在Promise调用之前,await 强制后面点代码等待,直到Promise对象resolve,得到resolve的值作为await表达式的运算结果

await只能在async函数内部使用,用在普通函数里就会报错

const asyncFunc = function(i) {
    return new Promise(function(resolve) {
        $.get(url, function(res) {
            resolve(res);
        })
    });
}
const asyncDeal = async function() {
    for (let i = 0; i < data.length; i++) {
        let res = await asyncFunc(i);
        //获取数据后做的事情
    }
}
asyncDeal();

八、理解模块化解决的实际问题,可列举几个模块化方案并理解其中原理

CommonJS规范(同步加载模块)

允许模块通过require方法来同步加载所要依赖的其他模块,然后通过exports或module.exports来导出需要暴露的接口。

使用方式:

// 导入
require("module");
require("../app.js");
// 导出
exports.getStoreInfo = function() {};
module.exports = someValue;

优点:

缺点:

为什么浏览器不能使用同步加载,服务端可以?

参照CommonJs模块代表node.js的模块系统

AMD(异步加载模块)

采用异步方式加载模块,模块的加载不影响后面语句的运行。所有依赖模块的语句,都定义在一个回调函数中,等到加载完成之后,回调函数才执行。

使用实例:

// 定义
define("module", ["dep1", "dep2"], function(d1, d2) {...});
// 加载模块
require(["module", "../app"], function(module, app) {...});

加载模块require([module], callback);第一个参数[module],是一个数组,里面的成员就是要加载的模块;第二个参数callback是加载成功之后的回调函数。

优点:

缺点:

实现AMD规范代表require.js

RequireJS对模块的态度是预执行。由于 RequireJS 是执行的 AMD 规范, 因此所有的依赖模块都是先执行;即RequireJS是预先把依赖的模块执行,相当于把require提前了

RequireJS执行流程:

CMD规范(异步加载模块)

CMD规范和AMD很相似,简单,并与CommonJS和Node.js的 Modules 规范保持了很大的兼容性;在CMD规范中,一个模块就是一个文件。

定义模块使用全局函数define,其接收 factory 参数,factory 可以是一个函数,也可以是一个对象或字符串;

factory 是一个函数,有三个参数,function(require, exports, module):

实例:

define(function(require, exports, module) {
  var a = require('./a');
  a.doSomething();
  // 依赖就近书写,什么时候用到什么时候引入
  var b = require('./b');
  b.doSomething();
});

优点:

缺点:

AMD 与 CMD 的区别

// AMD
define(['./a', './b'], function(a, b) {  // 依赖必须一开始就写好  
   a.doSomething()    
   // 此处略去 100 行    
   b.doSomething()    
   ...
});
// CMD
define(function(require, exports, module) {
   var a = require('./a')   
   a.doSomething()   
   // 此处略去 100 行   
   var b = require('./b') 
   // 依赖可以就近书写   
   b.doSomething()
   // ... 
});

UMD

(function (window, factory) {
    if (typeof exports === 'object') {
    
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
    
        define(factory);
    } else {
    
        window.eventUtil = factory();
    }
})(this, function () {
    //module ...
});

ES6模块化

使用方式:

// 导入
import "/app";
import React from “react”;
import { Component } from “react”;
// 导出
export function multiply() {...};
export var year = 2018;
export default ...
...

优点:

回到问题“require与import的区别”

require使用与CommonJs规范,import使用于Es6模块规范;所以两者的区别实质是两种规范的区别;

CommonJS:

ES6模块

最后:require/exports 是必要通用且必须的;因为事实上,目前你编写的 import/export 最终都是编译为 require/exports 来执行的。

上一篇下一篇

猜你喜欢

热点阅读