美文网首页
作用域和闭包

作用域和闭包

作者: 阿羡吖 | 来源:发表于2020-04-26 09:48 被阅读0次

    1.理解词法作用域和动态作用域

    作用域
    静态作用域

    作用域是指程序源码中定义变量的区域
    作用于规定如何查找变量,也就是确定当前执行代码对变量的访问权限
    JavaScript采用词法作用域,也就是静态作用域。

    function f1(){
      alert(v);
    }
    function f2(){
      var v =100;
      f1()
    }
    f2();
    

    执行上述代码,会发现程序报错 v:undefined


    image.png

    这就和js的作用域有关了。
    f1在定义的时候,js解析器会给f1定义一个内部的属性叫scope,按照f1定义时的词法环境,scope是指向window的,所以,当f2调用f1时,程序会先在f1的函数体内去寻找变量v,没找到,就去window中寻找,没有v,所以程序报错。
    简言之,f1的作用域是在定义的时候就已经决定了,而不是在调用时决定的,这也就是所谓js的静态作用域。

    动态作用域

    因为JavaScript采用的是词法作用域,函数的作用域在函数定义的时候就决定了。
    而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

    // 动态作用域
    function foo(){
      console.log(a)
    }
    
    function bar(){
      var a = 3;
      foo()
    }
    var a =2
    bar(); //2
    
    image.png

    bar 调用,bar里面foo被调用,foo函数需要查找变量a,由于JavaScript是词法作用域,foo被解析时在全局作用域。
    所以,只能在全局作用越中找a,输出为2,而非bar作用域中a。如果js采用的是动态作用域,那么foo在bar中调用,就会先在bar中查询a,输出为3

    2.理解JavaScript的作用域和作用域链

    作用域
    全局作用域(globe scope)和局部作用域(local scope)
    var name1 = "肖战";
    function changeName(){
      var name2 ="王一博";
      console.log(name1); //肖战
      console.log(name2); //王一博
    }
    changeName();
    console.log(name1); //肖战
    console.log(name2);//Uncaught ReferenceError: name2 is not defined
    
    image.png

    其中,name1具有全局作用域,因此在第4行和第8行都会在控制台上输出“肖战”。name2定义在changeName()函数内部,具有局部作用域。因此在第9行,解析器找不到变量name2.抛出错误。
    另,在函数中声明变量时,如果省略var 操作符,那么声明变量就成了全局变量,拥有全局作用域,但是不推荐这种做法,因为局部作用域中很难维护定义的全局变量。
    再者,window对象的内置属性都是拥有全局作用域,
    局部作用域一般只在固定的代码片段内可以得到,如上述代码中的name2,只有在函数内部可以访问得到

    作用域链(scope chain)

    全局作用域和局部作用域中变量的访问权限,其实是由作用域链决定的。
    每次进入一个新的执行环境,都会创建一个用于搜索变量和函数的作用域,

    作用域链是函数被创建的作用域中对象的集合,作用域链可以保证对执行环境有权访问的所有变量和函数的有序访问。
    作用域的最前端始终是当前执行的代码所在环境的变量对象(如果该环境是函数,则将其活动对象作为变量对象);
    下一个变量对象来自包含环境,(包含当前运行环境的环境),下一个变量对象来自包含环境的包含环境
    依次往上,直到全局执行环境的变量对象,全局执行环境的变量对象始终是作用域中最后一个对象。
    标识符解析是沿着作用域一级一级的向上搜索标识符的过程,搜索过程始终是从作用域的前端逐渐的向后回溯,直到找到标识符(找不到,就会导致错误发生)。

    var name1 = "肖战"
    function changeName(){
      var name2 = "王一博";
      function swapName(){
        console.log(name1); // 肖战
        console.log(name2); // 王一博
        var tempName = name2;
        name2 = name1
        name1 = tempName
        console.log(name1); //王一博
        console.log(name2); // 肖战
        console.log(tempName); //王一博
      }
      swapName();
      console.log(name1); //肖战
      console.log(name2); //王一博
      //console.log(tempName);抛出错误:Uncaught ReferenceError: tempName is not defined
    }
    changeName();
    console.log(name1); //肖战
    //console.log(name2); 抛出错误:Uncaught ReferenceError: name2 is not defined
    //console.log(tempName);抛出错误:Uncaught ReferenceError: tempName is not defined
    

    上述代码中,一共有三个执行环境,全局环境、changeName()的局部环境和swapName()的局部环境。
    1、函数swapName()的作用域链包含三个对象:自己的变量对象--->changeName()局部环境的变量对象--->全局环境的变量对象。
    2、函数changeName()的作用域包含两个对象:自己的变量对象-->全局环境的变量对象。
    就上述程序出现的变量和函数来讲(不考虑隐形变量):
    1、swapName()是局部环境的变量对象中存放变量tempName
    2、changeName()局部环境的变量对象中存放变量name2和函数swapName();
    3、全局环境的变量对象中存放变量name1、函数changeName();
    在 swapName()的执行环境中,在执行第5句代码时,解析器沿着函数swapName()的作用域链一级级向后回溯变量name1,直到在全局环境中找到name1,并输出在控制台,同样的,在执行第6句代码时,解析器沿着函数swapName()的作用域链一级级向后回溯,在函数changeName()的变量对象中发现变量name2 通过代码对name1和name2进行交换,并输出在控制台,根据结果发现,这两个变量的值确实交换了,因此得出结论,函数的局部环境可以访问函数作用域中的变量,也可以访问和操作父环境中(包含环境)乃至全局环境中的变量。

    在changName()的执行环境中,执行第15行代码和第16行代码时,可以正确的输出name1和name2和两个变量的值(调用了函数swapName(),所以俩变量的值已相互交换),那是因为name1在changName()的父环境中,name2在它自己的局部环境,即name1和name2都在其作用域链上,但当执行到第17行代码发生错误,因为解析器沿着函数changeName()的作用域一级级的查找变量 tempName时,并不能找到该变量的存在(变量tempName不在其作用域链上)所以抛出错误。因此,得出结论:父环境只能访问其包含环境和自己环境中的变量和函数,不能访问其子环境中的变量函数。

    同理,在全局环境中,其变量对象中存放变量name1、函数changeName();解析器只能访问变量name1和函数changeName(),而不能访问和操作函数changeName()和函数swapName()中定义的变量或函数。因此,在执行第21行和第22行代码时抛出变量没有定义的错误。so,全局环境只能访问全局环境中的变量和函数,不能直接访问局部环境中的任何数据。

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

    JavaScript的this原理

    参考阮一峰:http://www.ruanyifeng.com/blog/2018/06/javascript-this.html
    总结:this 代表了当前函数的执行上下文,方便开发者在函数中使用上下文变量

    this的几种不同使用场景的取值

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

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

    2、作为函数调用
    函数也可以直接被调用,此时this绑定到全局对象。在浏览器中,window就是该全局对象,如下,函数被调用时,this被绑定到了全局对象。
    接下来 执行赋值语句,相当于一个隐式的声明了一个全局变量,显然不是调用者希望的

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

    3、作为构造函数调用
    JavaScript支持面向对象编程,与主流的面向对象编程语言不同,JavaScript并没有类(class)的概念。而是使用基于原型(prototype)的继承方式。
    相对应的,JavaScript中的构造函数也很特殊,如果不使用new调用,则和普通函数一样,作为又一项约定俗成的准则,构造函数以大写字母开头
    提醒调用者使用正确的方式调用,如果调用正确,this绑定到心创建的对象上。

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

    4、在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])
    

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

    JavaScript的执行上下文栈

    JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。
    执行上下文:也叫一个执行环境,有 全局执行环境 和 函数执行环境 和 eval。
    每个执行环境中包含这三部分:变量对象/活动对象,作用域链,this的值。

    JS 中有三种执行上下文
    全局执行上下文,默认的,在浏览器中是 window 对象,并且 this 在非严格模式下指向它。
    函数执行上下文,JS 的函数每当被调用时会创建一个上下文。
    Eval 执行上下文,eval 函数会产生自己的上下文,这里不讨论

    通常,我们的代码中都不止一个上下文,那这些上下文的执行顺序应该是怎样的?从上往下依次执行?
    栈,是一种数据结构,具有先进后出的原则。JS 中的执行栈就具有这样的结构,当引擎第一次遇到 JS 代码时,
    会产生一个全局执行上下文并压入执行栈,每遇到一个函数调用,就会往栈中压入一个新的上下文。
    引擎执行栈顶的函数,执行完毕,弹出当前执行上下文。

    为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:
    ECStack = [];
    试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以 ECStack 最底部永远有个 globalContext。

    ECStack = [
      globalContext
    ]
    //现在 JavaScript 遇到下面的这段代码了:
    function fun3() {
      console.log('fun3')
    }
    function fun2() {
      fun3();
    }
    function fun1() {
      fun2();
    }
    fun1();
    //执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,
    //就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:
    // 伪代码
    // fun1()
    
    ECStack.push(<fun1> functionContext);
    
    // fun1中调用了fun2,还要创建fun2的执行上下文
    ECStack.push(<fun2> functionContext);
    
    // fun3执行完毕
    ECStack.pop();
    
    // fun2执行完毕
    ECStack.pop();
    
    // fun1执行完毕
    ECStack.pop();
    // javascript接着执行下面的代码,但是ECStack底层永远有个globalContext
    
    预处理

    函数执行上下文
    在调用函数, 准备执行函数体之前, 创建对应的函数执行上下文对象
    对局部数据进行预处理:
    1.形参变量==>赋值(实参)
    2.arguments==>赋值(实参列表)
    3.处理 var定义的局部变量
    4.处理 function声明的函数
    5.提升
    6.this==>赋值(调用函数的对象)
    开始执行函数体代码

    应用堆栈信息快速定位问题
    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打断点形式

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

    1.闭包:指有权访问另一个函数作用域中的变量的函数,一般情况就是在一个函数中包含另一个函数。
    2.闭包的作用:访问函数内部变量,保持函数在环境中一直存在。不会被垃圾收回机制处理。
    因为函数内部声明 的变量是局部的,只能在函数内部访问到,外层函数的变量都是可见的,这就是作用域链的特点了。
    子级可以向父级查找变量,逐级查找,找到为止,
    因此我们可以在函数内部再创建一个函数,这样对内部函数来说,外层函数的变量都是可见的,然后我们就可以访问到它的变量了。

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

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

    1. 闭包的优点:

    方便调用上下文中声明的局部变量
    逻辑紧密,可以在一个函数中在创建个函数,避免了传参的问题

    1. 闭包的缺点

    因为使用闭包,可以使函数在执行完不被销毁,保留在内存中,如大量使用闭包会造成内存泄漏,内存消耗很大。

    1. 闭包在实际中的应用
    function addFn(a,b){
      return(function(){
       console.log(a+b);
      })
    }
    var test = addFn(1,2)
     setTimeout(test,3000)
    

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

    1.内存泄漏

    是指申请得内存执行完后没有及时的清理或者销毁,占用空闲内存,内存泄漏过多的话,就会导致后面的程序申请不到内存,因此内存泄漏会导致内部内存溢出

    1. 堆栈溢出

    是指内存空间已经被申请完,没有足够的内存提供了。
    在一些编程语言中,比如c,需要使用malloc来申请内存空间,在使用free释放掉,需要手动清除,而js中是有自己的垃圾回收机制的,一般常用的垃圾收集办法就是标记清除。

    标记清除法

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

    常见的内存泄漏原因

    全局变量引起的内存泄漏
    闭包
    没有被清除的定时器

    解决办法

    减少不必要的全局变量
    减少闭包的使用(因为闭包会导致内存泄漏)
    避免死循环的发生

    7.如何处理循环的异步操作

    1、回调函数(callback)
    function f1(callback){
      setTimeout(function(){
        callback()
      },1000)
       console.log('1111')
    }
    function f2(){
       console.log('李子柒')
    }
    f1(f2)
    

    采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。
    回调函数是异步编程最基本的方法,其优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(Coupling),流程会很混乱,而且每个任务只能指定一个回调函数。
    注意 区分 回调函数和异步
    回调并不一定就是异步。他们自己并没有直接关系。
    简单区分 同步回调 和 异步回调
    同步回调 :

    function A(callback){
      console.log("I am A");
      callback(); //调用该函数
    }
    function B(){
      console.log("I am B");
    }
    

    异步回调:因为js是单线程的,但是有很多情况的执行步骤(ajax请求远程数据,IO等)是非常耗时的,如果一直单线程的堵塞下去会导致程序的等待时间过长页面失去响应,影响用户体验了。
    如何去解决这个问题呢,我们可以这么想。耗时的我们都扔给异步去做,做好了再通知下我们做完了,我们拿到数据继续往下走

    var xhr = new XMLHttpRequest();
    xhr.open('POST',url,true);//第三个参数决定是否采用异步的方式
    xhr.send(data);
    xhr.onreadystatechange = function(){
      if(xhr.readystate === 4 && xhr.status === 200){
        console.log("哈哈哈")
      }
    }
    
    事件监听

    采用时间驱动模式

    任务的执行不取决代码的顺序,而取决于某一个事件是否发生。
    监听函数有:on、bind、listen、addEventListener、observe

    还是以f1和f2为例。首先,为f1绑定一个事件(采用jquery写法)
    f1.on('done',f2)
    上面代码的意思是:当f1发生done事件,就执行f2
    然后对f1进行改写
    function f1(){
    setTimeout(function(){
    //f1的代码
    f1.trigger('done')
    },1000)
    }
    f1.trigger('done')表示,执行完后,立即触发done事件,从而开始执行f2

    这种方法的优点:比较容易理解,可以绑定多个事件,每一个事件可以指定多个回调函数,而且可以去耦合,有利于实现模块化。
    这种方法的缺点:整个程序都要变成事件驱动型,运行流程会变得不清晰。

    事件监听方法:
    1、onclick方法
    2、attachEvent和addEvenListener方法

    发布订阅

    假定,存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号
    从而知道什么时候可以自己开始执行。这就叫做:“发布/订阅模式”(publish-subscribe pattern),又称为“观察者模式”(observe pattern)
    这个模式有多种实现,下面采用的是Ben Alman 的Timy Pub/ Sub这是jQuery的一个插件。

    首先,f2向“信号中心”jQuery订阅"done"信号
    jQuery.subscribe('done',f2);
    function f1(){
    setTimeout(function(){
    //f1的代码
    jQuery.publish('done');
    },1000);
    }

    jQuery.publish('done')的意思,f1执行完成后,向“信号中心”jQuery发布“done”信号。从而引发f2的执行。
    此外:f2完成执行后,也可以取消订阅(unsubscribe)
    jQuery.unsubscribe(‘done’,f2)

    这种方法的性质与"事件监听"类似,但是明显优于后者。因为我们可以通过查看"消息中心",了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

    promise对象(promise模式)

    1、promise 对象commonJS工作组提出的一种规范,一种模式,目的是为了异步编程提供统一接口。
    2、promise是一种模式,promise可以帮助管理异步方式返回的代码,它将代码进行封装并添加一个类似于时间处理的管理层。可以使用promise来注册代码,这些代码会在promise成功或失败后运行。
    3、promise完成之后,对应的代码会执行。可以注册任意数量的函数在成功或失败后运行,也可以在任何时候注册事件处理程序。
    4、promise有两种状态:1、等待(pending);2、完成(settled)。
    promise 会一直处于等待状态,直到它所包装的异步调用返回/超时/结束
    5、这时候promise状态变成完成,完成状态分为两类:
    1、解决(resolved)、2、拒绝(rejected)
    6、promise解决(resolved):意味着顺利结束。promise拒绝(rejected)意味着没有顺利结束。

    优雅的async/await

    参考资料:http://www.ruanyifeng.com/blog/2015/05/async.html

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

    立即执行函数

    可以通过立即执行函数,来达到隐藏细节的目的

    var myModule = (function(){
      var var1 = 1;
      var var2 = 2;
      function fn1(){
      }
      function fn2(){ 
      }
      return {
        fn1:fn1,
        fn2:fn2
      }
    })();
    
    CommonJS

    CommonJS规范是由NodeJS发扬光大,这标志着JavaScript模块化编程正式登上舞台。
    1、定义模块:
    根据CommonJS规范,一个单独的文件就是一个模块,每一个模块都是一个单独的作用域,也就是说,在该模块内部定义的变量,无法被其他模块读取,除非定义为global对象的属性。
    2、模块输出:
    模块只有一个出口,module.exports对象,我们需要把模块希望输出的内容放入该对象。
    3、加载模块:
    加载模块使用require方法,该方法读取一个文件并执行,返回文件内部的module.exports对象。

    var name ="Byron"
    function printName(){  
      console.log(name)
    }
    function printFullName(firstName){
      console.log(firstName + name)
    }
    module.exports = {
      printName:printName,
      printFullName:printFullName
    }
    

    然后加载模块
    var nameModule = require('./myModel.js');
    nameModule.printName()
    不同的实现对require时的路径有不同的要求,一般情况可以省略js扩展名,可以使用相对路径也可以使用绝对路径,甚至可以省略路径直接使用模块名(前提是该模块是系统内置模块)

    AMD

    AMD即Asynchronous Module Definition,中文名是异步模块定义的意思,它是一个在浏览器端模块化开发的规范。
    由于不是JavaScript原生支持,使用AMD规范进行页面开发需要用到对应的库函数,也就是RequireJS
    实际上AMD是RequireJS在推广过程中对模块定义的规范化的产出。

    requireJS主要解决两个问题
    一、多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
    二、js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长。

    // 定义模块 myModule,js
    define(['dependency'],function(){
      var name = 'Byron'
      function printName(){
        console.log(name)
      }
      return{
        printName:printName
      }
    })
    // 加载模块
    require(['myModule'],function(my){
      my.printName();
    })
    

    requireJS定义了一个函数define,它是全部变量,用来定义模块。
    define(id?,dependencies?,factory)
    -----id:可选参数,用来定义模块标识,如果没有提供该参数,脚本文件名(去掉拓展名);
    ----dependencies:是一个当前模块依赖的模块名称数组
    ----factory:工厂方法,模块初始化要执行的函数或对象,如果为函数,它应该只被执行一次,如果是对象,此对象应该为模块输出的值;

    在页面上使用require函数加载模块;
    require([dependencies],function(){});
    require()函数接受两个参数:
    第一个是一个数组,表示所依赖的模块;
    第二个是一个回调函数,当前面指定的模块都加载成功之后,它将被调用。加载的模块会以参数心思传入该函数,从而在回调函数内部可以使用这些模块。
    require() 函数在加载依赖的函数的时候是异步加载的,这样浏览器不会失去响应,它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性问题。

    CMD

    CMD即Common Module Definition 通用模块定义,CMD规范是国内发展出来的,就像AMD有个requireJS,CMD有个浏览器的实现SeaJS
    SeaJS要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以运行、解析)时机上有所不同

    语法
    Sea.js 推崇一个模块一个文件,遵循统一的写法
    define
    define(id?, deps?, factory)
    因为CMD推崇一个文件一个模块,所以经常就用文件名作为模块id;CMD推崇依赖就近,所以一般不在define的参数中写依赖,而是在factory中写。
    factory有三个参数:
    function(require, exports, module){}
    一,require
    require 是 factory 函数的第一个参数,require 是一个方法,接受 模块标识 作为唯一参数,用来获取其他模块提供的接口;
    二,exports
    exports 是一个对象,用来向外提供模块接口;
    三,module
    module 是一个对象,上面存储了与当前模块相关联的一些属性和方法。

    // 定义模块 myModule.js
    define(function(require, exports, module) {
      var $ = require('jquery.js')
      $('div').addClass('active');
    });
    // 加载模块
    seajs.use(['myModule.js'], function(my){
    });
    
    ES6模块化

    ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
    ES6 模块设计思想:尽量的静态化、使得编译时就能确定模块的依赖关系,以及输入和输出的变量(CommonJS和AMD模块,都只能在运行时确定这些东西)。
    使用方式:

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

    优点:容易进行静态分析 面向未来的EcmaScript标准
    缺点:原生浏览器还没有实现该标准 全新的命令字,新版的Node.js才支持。

    相关文章

      网友评论

          本文标题:作用域和闭包

          本文链接:https://www.haomeiwen.com/subject/gntoihtx.html