• 前端架构师之02_ES6_高级


    1 类和继承

    1.1 class类

    JavaScript 语言中,生成实例对象的传统方法是通过构造函数。

    // ES5 创建对象
    // 创建一个类,用户名 密码
    function User(name,pass){
        // 添加属性
        this.name = name;
        this.pass = pass;
    }
    // 用 原型 添加方法
    User.prototype.showName=function(){
        // 输出名字
        alert(this.name);
    }
    User.prototype.showPass=function(){
        alert(this.pass);
    }
    // new 出来一个对象,用户名是admin,密码是123
    var u1 = new User('admin','123');
    u1.showName();
    u1.showPass();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    上面这种写法跟传统的面向对象语言(比如 C++ 和 Java)差异很大,很容易让新学习这门语言的程序员感到困惑。

    ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。

    新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用 ES6 的class改写,就是下面这样。

    // ES6 创建对象
    class User {
        // constructor 构造函数
        // 构造器:等价于es5中的构造函数
        constructor (name,pass){
            this.name = name;
            this.pass = pass;
        }
        // 注意,定义方法的时候,前面不需要加上function这个关键字
        // 直接把函数定义放进去了就可以了。
        // 另外,方法与方法之间不需要逗号分隔,加了会报错。
        showName() {
            alert('my name is ' + this.name);
        }
        showPass() {
            alert('my password is ' + this.pass);
        }
    }
    
    var u1 = new User('admin','123');
    u1.showName();
    u1.showPass();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    ES6 的类,完全可以看作构造函数的另一种写法。

    // 类的数据类型就是函数,类本身就指向构造函数
    class User {
        // ...
    }
    
    typeof User // "function"
    User === User.prototype.constructor // true
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。

    构造函数的prototype属性,在 ES6 的“类”上面继续存在。

    事实上,类的所有方法都定义在类的prototype属性上面。

    constructor 方法

    constructor()方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。

    一个类必须有constructor()方法,如果没有显式定义,一个空的constructor()方法会被默认添加。

    class User {
    }
    
    // 等同于
    class User {
        constructor() {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    注:实例的属性除非显式定义在其本身(即定义在this对象上),否则都是定义在原型上(即定义在class上)。

    1.2 static静态方法

    类相当于实例的原型,所有在类中定义的方法,都会被实例继承。

    如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

    class Box{
        static a(){
            return "我是Box类中的,实例方法,无须实例化,可直接调用!"
        }
    }
    // 通过类名直接调用
    // 我是Box类中的,实例方法,无须实例化,可直接调用!
    console.log(Box.a());
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    注意:静态方法只能在静态方法中调用,不能在实例方法中调用

    class Box {
        static a() {
            return "我只允许被静态方法调用哦!"
        }
        static b() {
            // 通过静态方法b来调用静态方法a
            console.log(this.a());
        }
    }
    // 输出:我只允许被静态方法调用哦
    Box.b();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    1.3 继承

    Class 可以通过extends关键字实现继承,让子类继承父类的属性和方法。extends 的写法比 ES5 的原型链继承,要清晰和方便很多。

    // ES5继承
    // 创造一个VIP用户,继承User普通用户
    function User(name,pass){
        this.name=name;
        this.pass=pass;
    }
    User.prototype.showName=function(){
        alert(this.name);
    }
    User.prototype.showPass=function(){
        alert(this.pass);
    }
    
    // 继承用户
    function VipUser(name,pass,level){
        User.call(this,name,pass);
        this.level=level; // VipUser 的属性
    }
    // 接收User里面的原型对象
    VipUser.prototype=new User();
    // 创建VipUser自己的原型对象
    VipUser.prototype.showLevel=function(){
        alert(this.level);
    }
    // 传入参数,用户名,密码,等级
    var v1=new VipUser('zs','111','3');
    v1.showName();
    v1.showPass();
    v1.showLevel();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    // ES6中的继承
    class User {
        constructor (name,pass){
            this.name=name;
            this.pass=pass;
        }
        
        showName(){
            return this.name;
        }
        
        showPass(){
            alert(this.pass);
        }
    }
    
    // extends 继承,扩展
    class VipUser extends User{
        // constructor 创建属性 子类也有自己的类,也有自己的属性(继承的属性在前,新创建的在后)
        constructor(name,pass,level){
            // 调用父类的 constructor(x, y)
            super(name,pass);
            // 自己的属性
            this.level=level;
        }
        // 方法在这里不需要继承原来的方法了,因为super已经把父级的方法都继承过来了
        // 直接添加新东西就行了
        showName(){
            return this.name + ' ' + super.showName();
        }
        showLevel(){
            alert(this.level);
            super.show
        }
    }
    // 直接调用就可以了
    var v1=new VipUser('zs','111','3');
    
    v1.showName();
    v1.showPass();
    v1.showLevel();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    super在这里表示父类的构造函数,用来新建一个父类的实例对象。

    子类必须在constructor()方法中调用super(),否则就会报错。

    为什么子类的构造函数,一定要调用super()?

    • 原因就在于 ES6 的继承机制,与 ES5 完全不同。
    • ES5 的继承机制,是先创造一个独立的子类的实例对象,然后再将父类的方法添加到这个对象上面,即“实例在前,继承在后”。
    • ES6 的继承机制,则是先将父类的属性和方法,加到一个空的对象上面,然后再将该对象作为子类的实例,即“继承在前,实例在后”。
    • 这就是为什么 ES6 的继承必须先调用super()方法,因为这一步会生成一个继承父类的this对象,没有这一步就无法继承父类。
    • 注意,这意味着新建子类实例时,父类的构造函数必定会先运行一次。
    • 另外,在子类的构造函数中,只有调用super()之后,才可以使用this关键字,否则会报错。
    • 这是因为子类实例的构建,必须先完成父类的继承,只有super()方法才能让子类实例继承父类。

    2 Promise对象

    Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6将其写进了语言标准,统一了用法,原生提供了Promise对象。

    在使用ES5的时候,在多层嵌套回调时,写完的代码层次过多,很难进行维护和二次开发,ES6认识到了这点问题,现在promise的使用,完美解决了这个问题。

    2.1 回调地狱

    现在有6个div,我想给每个div都添加一个移动的动画,并且先执行第一个,再执行第二个,再执行第三个,以此类推。

    // 回调地狱 : 回调函数多层嵌套
    $(".scene p").eq(0).animate({
        marginTop: '-200px'
    }, 1000, function () {
        $(".scene p").eq(1).animate({
            marginTop: '200px'
        }, 1000, function () {
            $(".scene p").eq(2).animate({
                width: '200px'
            }, 1000, function () {
                $(".scene p").eq(3).animate({
                    height: '200px'
                }, 1000, function () {
                    $(".scene p").eq(4).animate({
                        marginLeft: '-200px'
                    }, 1000, function () {
                        $(".scene p").eq(5).animate({
                            marginTop: '200px'
                        },1000)
                    })
                })
            })
        })
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    虽然可以实现效果,但是需要嵌套很多层的回调函数,如果需求量增多,回调层级会更多,我们把这种多次的回调称之为 “回调地狱”。

    promise用来解决回调地狱的问题,把异步的代码用同步的方式来实现。

    2.2 基本使用

    Promise对象是一个构造函数,用来生成Promise实例。

    const promise = new Promise(function(resolve, reject) {
        // ... some code
        
        if (/* 异步操作成功 */){
            resolve(value);
        } else {
            reject(error);
        }
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。

    Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。

    Promise执行多步操作非常好用,那我们就来模仿一个多步操作的过程。

    比如,早上起床上课分了几个步骤:

    1. 起床洗漱
    2. 食堂吃饭
    3. 教室上课
    let state = 1;
    function step1(resolve, reject) {
        console.log('起床洗漱');
        if (state == 1) {
            resolve('洗漱完成');
        } else {
            reject('洗漱失败');
        }
    }
    
    function step2(resolve, reject) {
        console.log('食堂吃饭');
        if (state == 1) {
            resolve('吃饭完成');
        } else {
            reject('吃饭失败');
        }
    }
    
    function step3(resolve, reject) {
        console.log('教室上课');
        if (state == 1) {
            resolve('上课完成');
        } else {
            reject('上课失败');
        }
    }
    
    new Promise(step1).then(function (val) {
        console.log(val);
        return new Promise(step2);
    }).then(function (val) {
        console.log(val);
        return new Promise(step3);
    }).then(function (val) {
        console.log(val);
        return val;
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    2.3 promise方法

    promise的all方法和race方法

    • all:当两个异步操作都成功完成后,再执行的逻辑
    • race:比赛;谁快获取谁;最先得到的异步操作,即执行下面的业务逻辑
    • all()和race()中的参数必须是promise实例
    // all和race的区别
    // all:100个人跑步跑步:等100个跑到终点才结束
    // race:只要第一个跑到终点就结束,后面的99个就不管了
    Promise.all([new Promise(step1), new Promise(step2)]).then(function (res) {
        console.log(res);
    });
    
    Promise.race([new Promise(step1), new Promise(step2)]).then(function (res) {
        console.log(res);
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    3 Proxy预处理

    当我们在操作一个对象或者方法时会有几种动作,比如:在运行函数前初始化一些数据,在改变对象值后做一些善后处理。这些都是预处理函数,也叫做钩子函数。

    Proxy的存在就可以让我们给函数加上这样的钩子函数,你也可以理解为在执行方法前预处理一些代码。你可以简单的理解为他是函数或者对象的生命周期。

    声明Proxy

    我们用new的方法对Proxy进行声明。可以看一下声明Proxy的基本形式。

    new Proxy({},{});
    
    • 1

    这里有两个花括号,第一个花括号就相当于我们方法的主体,后边的花括号就是Proxy代理处理区域,相当于我们写钩子函数的地方。

    实现一个Proxy函数

    var pro = new Proxy({
        add: function (val) {
            return val + 10;
        },
        name: 'Hello World!'
    }, {
        get:function(target,key,property){
            console.log('调用Get方法');
            return target[key];
        }
    });
    
    console.log(pro.name);
    // 先输出了come in Get。相当于在方法调用前的钩子函数。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    get属性

    get属性是在你得到某对象属性值时预处理的方法,他接受三个参数

    • target:目标对象
    • key:属性名
    • property:proxy 实例本身

    set属性

    set属性是值你要改变Proxy属性值时,进行的预先处理。它接收四个参数

    • target:目标对象
    • key:属性名
    • value:属性值
    • receiver:Proxy 实例本身
    var pro = new Proxy({
        add: function (val) {
            return val + 10;
        },
        name: 'Hello World!'
    }, {
        get: function (target, key) {
            console.log('调用Get方法');
            return target[key];
        },
        set: function (target, key, value, receiver) {
            console.log(`调用Set方法,设置值 ${key} = ${value}`);
            return target[key] = value;
        }
    });
    
    console.log(pro.name);
    pro.name = '你好世界!';
    console.log(pro.name);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    4 初识模块化开发

    模块化是软件的一种开发方式,利用模块化可以把一个非常复杂的系统结构细化到具体的功能点,每个功能点看作一个模块,然后通过某种规则把这些小的模块组合到一起,构成模块化系统。

    我们从一开始学习前端,我们没有使用模块化,主要在我们对应的js这里的代码。我们就是把相关功能放在我们对应的js中,在页面中引入js来完成相关的功能。

    4.1 传统JavaScript开发的弊端

    传统浏览器端JavaScript在使用的时候存在的两大问题

    • 文件依赖
      • 在JavaScript中文件的依赖关系是由文件的引入先后顺序决定的。在开发过程中,一个页面可能需要多个文件依赖,但是仅从代码上是看不出来各个文件之间的依赖关系,这种依赖关系存在不确定性。如果更改文件的引入先后顺序,就很有可能导致程序错误。
    • 命名冲突
      • 在JavaScript中,文件与文件之间是完全开放的,并且语法本身不严谨,如果在后续引入的文件中声明了一个同名变量,则后面文件的变量会覆盖前面文件中的同名变量,这样会导致程序存在潜在的不确定性。

    4.2 模块化的概念

    **现实生活中手机的模块化 **

    从生产角度来看,模块化是一种生产方式,体现了以下两个特点:

    • 生产效率高:灵活架构,焦点分离;多人协作互不干扰;方便模块间组合、分解。
    • 维护成本低:可分单元测试;方便单个模块功能调试、升级。

    软件中的模块化开发

    从程序开发角度,模块化是一种开发模式,有以下两个特点:

    • 生产效率高:方便代码重用,别人开发好的模块功能可以直接拿过来使用,不需要重复开发类似的功能。
    • 维护成本低:软件开发周期中,由于需求经常发生变化,最长的阶段并不是开发阶段,而是维护阶段,使用模块化开发的方式更容易维护。

    5 模块成员的导入和导出

    5.1 exports和require()

    在模块化开发中,一个JavaScript文件就是一个模块,模块内部定义的变量和函数默认情况下在外部无法得到。

    如何得到模块内部定义的变量和函数呢?

    Node.js为开发者提供了一个简单的模块系统,exports是模块公开的接口,require()用于从外部获取一个模块的接口,即获取模块的exports对象。

    如何在一个文件模块中获取其他文件模块的内容?

    • 首先需要使用require()方法加载模块;
    • 然后在被加载的模块中使用exports或者module.exports对象向外开放变量、函数等;require()函数的作用是加载文件并获取该文件中的module.exports对象接口。
    // 新建info.js文件作为被加载模块
    // 声明一个add()函数用来实现加法功能
    const add = (n1, n2) => n1 + n2;
    // exports对象向模块外开放add()函数
    exports.add = add;
    
    // 新建b.js文件,实现在b.js模块中导入info.js模块
    // 模块导入时,模块的后缀.js可以省略
    const info = require('./info');
    // 结果为:30
    console.log(info.add(10, 20));
    
    // 打开命令行工具,切换到b.js文件所在的目录,并输入“node b.js”命令。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    总结Node.js的模块化开发的步骤:

    • 通过exports对象对模块内部的成员进行导出。
    • 通过require()方法对依赖的模块进行导入操作。

    5.2 module.exports

    // 新建info.js文件作为被加载模块
    // 声明一个greeting()函数用于实现打招呼功能
    const greeting = name => `hello ${name}`;
    // 使用module.exports对象向模块外开放greeting()函数
    module.exports.greeting = greeting;
    
    // 新建a.js文件,实现在a.js模块中导入info.js模块
    // 模块导入时,模块的后缀.js可以省略
    const a = require('./info');
    // 输出模块中函数的值:hello zhangsan
    console.log(a.greeting('zhangsan'));
    
    // 打开命令行工具,切换到a.js文件所在的目录,并输入“node a.js”命令。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    5.3 exports和module.exports的区别

    exports和module.exports都可以对外开放变量或函数,那么它们之间有什么区别?

    exports和module.exports的区别

    • Node.js提供的exports对象是module.exports对象的别名(地址引用关系),导出对象最终以module.exports对象为准。
    • 在使用上,module.exports对象可以单独定义返回数据类型,而exports对象只能是返回一个object对象。
    • 默认情况下,exports和module.exports指向同一个对象,也就是说指向同一个内存空间;
    • 当exports和module.exports指向两个不同对象时,导出对象最终以module.exports对象的导出为准。

    exports和module.exports指向同一个对象的情况

    // 新建info.js文件作为被加载模块
    const greeting = name => `hello ${name}`;
    const x = 100;
    // 使用exports对象导出x变量
    exports.x = x;
    // 使用module.exports对象导出greeting()函数
    module.exports.greeting = greeting;
    
    // 新建a.js文件,实现在a.js模块中导入info.js模块
    // 模块导入时,模块的后缀.js可以省略
    const a = require('./info');
    // 输出模块中函数的值
    console.log(a);
    
    // 打开命令行工具,切换到a.js文件所在的目录,并输入“node a.js”命令。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    当exports和module.exports指向同一个对象时,以下两种写法是等价的:

    • exports.属性名 = 属性值;
    • module.exports.属性名 = 属性值;

    exports和module.exports指向不同对象时的情况

    // 新建info.js文件作为被加载模块
    const greeting = name => `hello ${name}`;
    const x = 100;
    exports.x = x;
    module.exports.greeting = greeting;
    // 使用module.exports重新指向一个属性名为name,值为zhangsan的对象
    module.exports = {
        name: 'zhangsan',
    };
    
    // 新建a.js文件,实现在a.js模块中导入info.js模块
    // 模块导入时,模块的后缀.js可以省略
    const a = require('./info');
    // 输出模块中函数的值
    console.log(a);
    
    // 打开命令行工具,切换到a.js文件所在的目录,并输入“node a.js”命令。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    当exports和module.exports指向不同对象时,以module.exports对象的导出结果为准。

    6 模块化操作

    在ES5中我们要进行模块华操作需要引入第三方类库,随着前后端分离,前端的业务日渐复杂,ES6为我们增加了模块化操作。

    现在前端开发的主角,是基于ESM(ES6 Module),利用ESM操作,可以让我们更方便的进行模块化开发。

    模块化操作主要包括两个方面。

    • export:负责进行模块化,也是模块的输出。
    • import:负责把模块引,也是模块的引入操作。

    想要使用ES6语法需要给script标签添加上 type="module" 属性,但是这个方式只适合测试使用,因为兼容性比较差,建议项目中使用插件解决对应的转化,可以使用nodejs解决。

    基本用法

    export可以让我们把变量,函数,对象进行模块话,提供外部调用接口,让外部进行引用。

    注:关键词 export {} 后面的花括号不能少。

    //temp.js
    export var a = 'Hello World!';
    
    // 然后在index.js中以import的形式引入。
    // index.js
    import { a } from './temp.js';
    console.log(a);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    注:引入进来的内容必须用{}包括起来,路径中必须用必须添加./或者…/或者/ 否则会报错。

    这就是一个最简单的模块的输出和引入。

    多变量输出

    声明3个变量,需要把这3个变量都进行模块化输出,这时候我们给他们包装成对象就可以了。

    var a = '你好世界';
    var b = 'HelloWorld';
    var c = 'H5website';
    
    export {a,b,c}
    
    • 1
    • 2
    • 3
    • 4
    • 5

    函数的模块化输出

    export function add(a,b){
        return a + b;
    }
    
    • 1
    • 2
    • 3

    as的用法

    有些时候我们并不想暴露模块里边的变量名称,而给模块起一个更语义话的名称,这时候我们就可以使用as来操作。

    var a = '你好世界';
    var b = 'HelloWorld';
    var c = 'H5website';
    
    export {
        a as x,
        b as y,
        c as z
    }
    
    import * as abc from './地址';
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    导入时使用 星号 表示全部

    default的使用

    加上default相当是一个默认的入口。在一个文件里export default只能有一个。

    默认入口导入时不需要再加大括号,因为只可能对应一个默认的。

    我们来对比一下export和export default的区别。

    export var a ='HelloWorld';
    export function add(a,b){
        return a+b;
    }
    
    // 对应导入方式
    import { a,add } form './temp';//也可以分开写
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    let a = 'HelloWorld';
    export default a;
    
    // 对应导入方式
    import str from './temp';
    
    • 1
    • 2
    • 3
    • 4
    • 5
  • 相关阅读:
    el-date-picker 禁用时分秒选择(包括禁用下拉框展示)
    GraphQL & Go,graphql基本知识,go-graphql使用
    代码执行相关函数以及简单例题
    基于改进海洋捕食者算法求解单目标优化问题附matlab代码(NMPA)
    java基于SpringBoot+vue的餐厅点餐外卖系统 elementui 前后端分离
    魅族回应被吉利收购:已签署协议;腾讯下架QQ影音所有版本;PyPI多个软件包因拼写错误包含后门|极客头条
    大气环境一站式技能提升:SMOKE-CMAQ实践技术
    【前端】Vue+Element UI案例:通用后台管理系统-导航栏
    Java6种单例模式写法
    【语音识别】搭建本地的语音转文字系统:FunASR
  • 原文地址:https://blog.csdn.net/zhangchen124/article/details/133386864