14

五天之前我写了一个关于ES6标准中Class的文章。在里面我介绍了如何用现有的Javascript来模拟类并且介绍了ES6中类的用法,其实它只是一个语法糖。感谢Om Shakar以及Javascript Room中的各位,我的编程风格从那时候开始发生了改变;就像Dougla Crockford2006年做的一样,我也学习了很多来完全理解基于原型的编程方式。

Javascript是一个多样化的编程语言。它拥有面向对象和函数式的编程特点,你可以使用任何一种风格来编写代码。然而这两个编程风格并不能很好的融合。例如,你不无法同时使用new(典型的面向对象的特点)和apply(函数式编程的特点).原型继承一直都作为连接这两种风格的桥梁。

基于类继承的问题

大部分Javascript程序员会告诉你基于类的继承不好。然而它们中只有很少一部分知道其中的原因。事实实际上是基于类的基础并没有什么不好。Python是基于类继承的,并且它是一门很好的编程语言。但是,基于类的继承并不适合用于Javascript。Python正确的使用了类,它们只有简单的工厂方法不能当成构造函数使用。而在Javascript中任何函数都可以被当成构造函数使用。

Javascript中的问题是由于每个函数都可以被当成构造函数使用,所以我们需要区分普通的函数调用和构造函数调用;我们一般使用new关键字来进行区别。然而,这样就破坏了Javascript中的函数式特点,因为new是一个关键字而不是函数。因而函数式的特点无法和对象实例化一起使用。

function Person(firstname,lastname){
    this.firstname = firstname ;
    this.lastname = lastname ;
}

考虑上面这段程序。你可以通过new关键字来调用Person方法来创建一个函数Person的实例:

var author = new Person('Aadit','Shah') ;

然而,没有任何办法来使用apply方法来为构造函数指定参数列表:

var author = new Person.apply(null,['Aadit','Shah']);//error

但是,如果new是一个方法那么上面的需求就可以通过下面这种方式实现了:

var author = Person.new.apply(Person,['Aadit','Shah']) ;

幸运的是,因为Javascript有原型继承,所以我们可以实现一个new的函数:

Function.prototype.new = function () {
    function functor() { return constructor.apply(this, args); }
    var args = Array.prototype.slice.call(arguments);
    functor.prototype = this.prototype;
    var constructor = this;
    return new functor;
};

在像Java这样对象只能通过new关键字来实例化的语言中,上面这种方式是不可能实现的。

下面这张表列出了原型继承相比于基于类的基础的优点:

基于类的继承 原型继承
类是不可变的。在运行时,你无法修改或者添加新的方法 原型是灵活的。它们可以是不可变的也可以是可变的
类可能会不支持多重继承 对象可以继承多个原型对象
基于类的继承比较复杂。你需要使用抽象类,接口和final类等等 原型继承比较简洁。你只有对象,你只需要对对象进行扩展就可以了

不要再使用关键词new了

到现在你应该知道为什么我觉得new关键字是不会的了吧---你不能把它和函数式特点混合使用。然后,这并不代表你应该停止使用它。new关键字有合理的用处。但是我仍然建议你不要再使用它了。new关键字掩盖了Javascript中真正的原型继承,使得它更像是基于类的继承。就像Raynos说的:

new是Javascript在为了获得流行度而加入与Java类似的语法时期留下来的一个残留物

Javascript是一个源于Self的基于原型的语言。然而,为了市场需求,Brendan Eich把它当成Java的小兄弟推出:

并且我们当时把Javascript当成Java的一个小兄弟,就像在微软语言家庭中Visual Basic相对于C++一样。

这个设计决策导致了new的问题。当人们看到Javascript中的new关键字,他们就想到类,然后当他们使用继承时就遇到了傻了。就像Douglas Crockford说的:

这个间接的行为是为了使传统的程序员对这门语言更熟悉,但是却失败了,就像我们看到的很少Java程序员选择了Javascript。Javascript的构造模式并没有吸引传统的人群。它也掩盖了Javascript基于原型的本质。结果就是,很少的程序员知道如何高效的使用这门语言

因此我建议停止使用new关键字。Javascript在传统面向对象假象下面有着更加强大的原型系统。然大部分程序员并没有看见这些还处于黑暗中。

理解原型继承

原型继承很简单。在基于原型的语言中你只有对象。没有类。有两种方式来创建一个新对象---“无中生有”对象创建法或者通过现有对象创建。在Javascript中Object.create方法用来创建新的对象。新的对象之后会通过新的属性进行扩展。

“无中生有”对象创建法

Javascript中的Object.create方法用来从0开始创建一个对象,像下面这样:

var object = Object.create(null) ;

上面例子中新创建的object没有任何属性。

克隆一个现有的对象

Object.create方法也可以克隆一个现有的对象,像下面这样:

var rectangle = {
    area : function(){
        return this.width * this.height ;
    }
} ;
var rect = Object.create(rectangle) ;

上面例子中rectrectangle中继承了area方法。同时注意到rectangle是一个对象字面量。对象字面量是一个简洁的方法用来创建一个Object.prototype的克隆然后用新的属性来扩展它。它等价于:

var rectangle = Object.create(Object.prototype) ;
rectangle.area = function(){
    return this.width * this.height ;
} ;

扩展一个新创建的对象

上面的例子中我们克隆了rectangle对象命名为rect,但是在我们使用rectarea方法之前我们需要扩展它的widthheight属性,像下面这样:

rect.width = 5 ;
rect.height = 10 ;
alert(rect.area()) ;

然而这种方式来创建一个对象的克隆然后扩展它是一个非常傻缺的方法。我们需要在每个rectangle对象的克隆上手动定义widthheight属性。如果有一个方法能够为我们来完成这些工作就很好了。是不是听起来有点熟悉?确实是。我要来说说构造函数。我们把这个函数叫做create然后在rectangle对象上定义它:

var rectangle = {
    create : function(width,height){
        var self = Object.create(this) ;
        self.height = height ;
        self.width = width ;
        return self ;
    } ,
    area : function(){
        return this.width * this.height ;
    }
} ;
var rect = rectangle.create(5,10) ;
alert(rect.area()) ;

构造函数 VS 原型

等等。这看起来很像Javascript中的正常构造模式:

function Rectangle(width, height) {
    this.height = height;
    this.width = width;
} ;

Rectangle.prototype.area = function () {
    return this.width * this.height;
};

var rect = new Rectangle(5, 10);
 
alert(rect.area());

是的,确实很像。为了使得Javascript看起来更像Java原型模式被迫屈服于构造模式。因此每个Javascript中的函数都有一个prototype对象然后可以用来作为构造器(这里构造器的意思应该是说新的对象是在prototype对象的基础上进行构造的)。new关键字允许我们把函数当做构造函数使用。它会克隆构造函数的prototype属性然后把它绑定到this对象中,如果没有显式返回对象则会返回this

原型模式和构造模式都是平等的。因此你也许会怀疑为什么有人会困扰于是否应该使用原型模式而不是构造模式。毕竟构造模式比原型模式更加简洁。但是原型模式相比构造模式有许多优势。具体如下:

构造模式 原型模式
函数式特点无法与new关键字一起使用 函数式特点可以与create结合使用
忘记使用new会导致无法预期的bug并且会污染全局变量 由于create是一个函数,所以程序总是会按照预期工作
使用构造函数的原型继承比较复杂并且混乱 使用原型的原型继承简洁易懂

最后一点可能需要解释一下。使用构造函数的原型继承相比使用原型的原型继承更加复杂,我们先看看使用原型的原型继承:

var square = Object.create(rectangle);
square.create = function (side) {
    return rectangle.create.call(this, side, side);
} ;
var sq = square.create(5) ;
alert(sq.area()) ;

上面的代码很容易理解。首先我们创建一个rectangle的克隆然后命名为square。接着我们用新的create方法重写square对象的create方法。最终我们从新的create方法中调用rectanglecreate函数并且返回对象。相反的,使用构造函数的原型继承像下面这样:

function Square(){
    Rectangle.call(this,side,side) ;
} ;

Square.prototype = Object.create(Rectangle.prototype) ;

Square.prototype.constructor = Square ;

var sq = new Square(5) ;

alert(sq.area()) ;

当然,构造函数的方式更简单。然后这样的话,向一个不了解情况的人解释原型继承就变得非常困难。如果想一个了解类继承的人解释则会更加困难。

当使用原型模式时一个对象继承自另一个对象就变得很明显。当使用方法构造模式时就没有这么明显,因为你需要根据其他构造函数来考虑构造继承。

对象创建和扩展相结合

在上面的例子中我们创建一个rectangle的克隆然后命名为square。然后我们利用新的create属性扩展它,重写继承自rectangle对象的create方法。如果把这两个操作合并成一个就很好了,就像对象字面量是用来创建Object.prototype的克隆然后用新的属性扩展它。这个操作叫做extend,可以像下面这样实现:

Object.prototype.extend = function(extension){
    var hasOwnProperty = Object.hasOwnProperty ;
    var object = Object.create(this) ;
    
    for(var property in extension){
        if(hasOwnProperty.call(extension,property) ||
            typeof obejct[property] === 'undefined')
            //这段代码有问题,按照文章意思,这里应该使用深复制,而不是简单的浅复制,deepClone(extension[property],object[property]),deepClone的实现可以看我之前关于继承的博客
            object[properyty] = extension[property] ;
    }
    return object ;
} ;

译者注:我觉得博主这里的实现有点不符合逻辑,正常extend的实现应该是可以配置当被扩展对象和用来扩展的对象属性重复时是否覆盖原有属性,而博主的实现就只是简单的覆盖。同时博主的实现在if判断中的做法个人觉得是值得学习的,首先判断extension属性是否是对象自身的,如果是就直接复制到object上,否则再判断object上是否有这个属性,如果没有那么也会把属性复制到object上,这种实现的结果就使得被扩展的对象不仅仅只扩展了extension中的属性,还包括了extension原型中的属性。不难理解,extension原型中的属性会在extension中表现出来,所以它们也应该作为extension所具有的特性而被用来扩展object。所以我对这个方法进行了改写:

    Object.prototype.extend = function(extension,override){
    var hasOwnProperty = Object.hasOwnProperty ;
    var object = Object.create(this) ;
    for(var property in extension){
        if(hasOwnProperty.call(extension,property) || 
            typeof object[property] === 'undefined'){
            if(object[property] !== 'undefined'){
                if(override){
                    deepClone(extension[property],object[property]) ;
                }
            }else{
                deepClone(extension[property],object[property]) ;
            }    
        }
    }
}; 

利用上面的extend方法,我们可以重写square的代码:

var square = rectangle.extend({
    create : function(side){
        return rectangle.create.call(this,side,side) ;
    }
}) ;

var sq = square.create(5) ;
alert(sq.area()) ;

extend方法是原型继承中唯一需要的操作。它是Object.create函数的超集,因此它可以用在对象的创建和扩展上。因此我们可以用extend来重写rectangle,使得create函数更加结构化看起来就像模块模式

var rectangle = {
    create : function(width,height){
        return this.extend({
            height : height ,
            width : width
        }) ;
    }
} ;

var rect = rectangle.create(5,10) ;
alert(rect.area()) ;

原型继承的两种方法

一些人可能已经注意到extend函数返回的对象实际上是继承了两个对象的属性,一个是被扩展的对象,另一个是用来扩展的对象。另外从两个对象继承属性的方式也不一样。第一种情况下是通过委派来继承属性(也就是使用Object.create()来继承属性),第二种情况下使用合并属性的方式来继承属性。

委派(差异化继承)

很多Javascript程序员对于差别继承比较熟悉。维基百科是这么解释的:

大部分对象是从其他更一般的对象中得到的,只是在一些很小的地方进行了修改。每个对象通常在内部维护一个指向其他对象的引用列表,这些对象就是该对象本身进行差异化继承的对象。

Javascript中的原型继承是基于差异化继承的。每个对象都有个内部指针叫做[[proto]] (在大部分浏览器中可以通过__proto__属性访问),这个指针指向对象的原型。多个对象之间通过内部[[proto]]属性链接起来形成了原型链,链的最后指向null

当你试图获取一个对象的属性时Javascript引擎会首先查找对象自身的属性。如果在对象上没找到该属性,那么它就会去对象的原型中去查找。以此类推,它会沿着原型链一直查找知道找到或者到原型链的末尾。

function get(object,property){
    if(!Object.hasOwnProperty.call(object,property)){
        var prototype = Object.getPrototypeOf(object) ;
        if(prototype) return get(prototype,property) ;
    }else{
        return object[property] ;
    }
} ;

Javascript中属性查找的过程就像上面的程序那样。

克隆(合并式继承)

大多数Javascript程序员会觉得复制一个对象的属性到另一个对象上并不是一个正确的继承的方式,因为任何对原始对象的修改都不会反映在克隆的对象上。五天前我会同意这个观点。然而现在我相信合并式继承是原型继承的一种正确方式。对于原始对象的修改可以发送到它的副本来实现真正的原型继承。

合并式继承和代理有他们的优点和缺点。下表列出了它们的优缺点:

代理 合并
任何对于原型的修改都会反映在所有副本上 任何对于原型的修改都需要手动更新到副本中
属性查找效率较低因为需要进行原型链查找 属性查找更搞笑因为继承的属性是通过复制的方式附加在对象本身的
使用Object.create()方法只能继承单一对象 对象可以从任意数量的对象中通过复制继承属性

从多个原型继承

上表中最后一点告诉我们对象可以通过合并的方式从多个原型中继承属性。这是一个重要的特点因为这证明原型继承比Java中的类继承更强大并且与C++中的类继承一样强大。为了实现多重继承,你只需要修改extend方法来从多个原型中复制属性。

Object.prototype.extend = function(){
    var hasOwnProperty = Object.hasOwnProperty ;
    var object = Object.create(this) ;
    var length = arguments.length ;
    var index = length ;
    
    while(index){
        var extension = arguments[length - (index--)] ;
        for(var property in extension){
            if(hasOwnProperty.call(extension,property)||
                typeof object[property] === 'undefined'){
                //这里同样应该使用深复制
                object[property] = extension[property] ;
            }
        }
    }
    return object;
} ;

多重继承是非常有用的因为它提高了代码的可重用性和模块化。对象通过委派继承一个原型对象然后通过合并继承其他属性。比如说你有一个事件发射器的原型,像下面这样:

var eventEmitter = {
    on : function(event,listener){
        if(typeof this[event] !== 'undefined')
            this[event].push(listener) ;
        else
            this[event] = [listener] ;
    } ,
    emit : function(event){
        if(typeof this[event] !== 'undefined'){
            var listeners = this[event] ;
            var length = listeners.length,index = length ;
            var args = Array.prototype.slice.call(arguments,1) ;
            
            while(index){
                var listener = listeners[length - (index--)] ;
                listener.apply(this,args) ;
            }
        }
    }
} ;

现在你希望square表现得像一个事件发射器。因为square已经通过委派的方式继承了rectangle,所以它必须通过合并的方式继承eventEmitter。这个修改可以很容易地通过使用extend方法实现:

var square = rectangle.extend(eventEmitter,{
    create : function(side){
        return rectangle.create.call(this,side,side) ;
    } ,
    resize : function(newSize){
        var oldSize = this.width ;
        this.width = this.height = newSize ;
        this.emit('resize',oldSize,newSize) ;
    }
}) ;
var sq = square.create(5) ;
sq.on('resize',function(oldSize,newSize){
    alert('sq resized from ' + oldSize + 'to' + newSize + '.') ;
}) ;

sq.resize(10) ;
alert(sq.area()) ;

在Java中是不可能实现上面的程序的,因为它不支持多重继承。相应的你必须另外再创建一个EventEmitter类或者使用一个EventEmitter接口并且在每个实现该接口的类中分别实现onemit方法。当然你在C++中不需要面对这个问题。我们都知道Java sucks(呵呵呵)。

Mixin的蓝图(Buleprint)

在上面的例子中你肯定注意到eventEmitter原型并没有一个create方法。这是因为你不应该直接创建一个eventEmitter对象。相反eventEmitter是用来作为其他原型的原型。这类原型称为mixin。它们等价于抽象类。mixin用来通过提供一系列可重用的方法来扩展对象的功能。

然而有时候mixin需要私有的状态。例如eventEmitter如果能够把它的事件监听者列表放在私有变量中而不是放在this对象上会安全得多。但是mixin没有create方法来封装私有状态。因此我们需要为mixin创建一个蓝图(blueprint)来创建闭包。蓝图(blueprint)看起来会像是构造函数但是它们并不用像构造函数那样使用。例如:

function eventEmitter(){
    var evnets = Object.create(null) ;
    
    this.on = function(event,listener){
        if(typeof events[event] !== 'undefined')
            events[event].push(listener) ;
        else
            events[event] = [listener] ;
    } ;
    this.emit = function(event){
        if(typeof events[event] !== 'undefined'){
            var listeners = events[event] ;
            var length = listeners.length ,index = length ;
            var args = Array.prototype.slice.call(arguments,1) ;
        }
    } ;
} ;

一个蓝图用来在一个对象创建之后通过合并来扩展它(我觉得有点像装饰者模式)。Eric Elliot把它们叫做闭包原型。我们可以使用蓝图版本的eventEmitter来重写square的代码,如下:

var square = rectangle.extend({
    create : function(side){
        var self = rectangle.create.call(this,side,side) ;
        eventEmitter.call(self) ;
        return self ;
    } ,
    resize : function(newSize){
        var oldSize = this.width ;
        this.width = this.height = newSize ;
        this.emit('resize',oldSize,newSize) ;
    }
}) ;
var sq = square.create(5) ;

sq.on('resize',function(oldSize,newSize){
    alert('sq resized from ' + oldSize + 'to' + newSize + '.') ;
}) ;

sq.resize(10) ;

alert(sq.area()) ;

蓝图在Javascript中是独一无二的。它是一个很强大的特性。然而它们也有自己的缺点。下表列出了mixin和蓝图的优缺点:

Mixin 蓝图
它们用来扩展对象的原型。因此对象共享同一个原型 它们用来扩展新创建的对象。因此每个对象都是在自己对象本身进行修改
因为缺少封装方法所以不存在私有状态 它们是函数,所以可以封装私有状态
它们是静态原型并且不能被自定义 它们可以传递参数来自定义对象,可以向蓝图函数传递一些用来自定义的参数

修复instanceof操作

许多Javascript程序员会觉得使用原型模式来继承违背了语言的精髓。他们更偏向于构造模式因为他们觉得通过构造函数创建的对象才是真正的实例,因为instanceof操作会返回true。然而,这个争论是没有意义的,因为instanceof操作可以像下面这样实现:

Object.prototype.instanceof = function(prototype){
    var object = this ;
    do{
        if(object === prototype) return true ;
        var object = Object.getPrototypeOf(object) ;
    }while(object) ;
    return false ;
}

这个instanceof方法现在可以被用来测试一个对象是否是通过委派从一个原型继承的。例如:

sq.instanceof(square) ;

然而还是没有办法判断一个对象是否是通过合并的方式从一个原型继承的,因为实例的关联信息丢失了。为了解决这个问题我们将一个原型的所有克隆的引用保存在原型自身中,然后使用这个信息来判断一个对象是否是一个原型的实例。这个可以通过修改extend方法来实现:

Object.prototype.extend = function(){
    var hasOwnProperty = Object.hasOwnProperty ; 
    var object = Object.create(this) ;
    var length = arguments.lenght ;
    var index = length ;

    while(index){
        var extension = arguments[length - (index--)] ;

        for(var property in extension){
            if(property !== 'clones' &&
                hasOwnProperty.call(extension,property) ||
                typeof object[property] === 'undefined')
                object[property] = extension[property] ;

        if(hasOwnProperty.call(extension,'clones')})
            extension.clones.unshift(object) ;
        else
            extension.clones = [object] ;
        }
    }
    return object;
} ;

通过合并继承自原型的对象形成了一个克隆树,这些树从根对象开始然后向下一直到叶子对象。一个克隆链是一个从根对象到叶子对象的单一路径,这跟遍历原型链很相似。我们可以使用这个信息来判断一个对象是否是通过合并继承自一个原型。

Object.prototype.instanceof = function(prototype){
    if (Object.hasOwnProperty.call(prototype, "clones"))
        var clones = prototype.clones;
    var object = this;
    
    do {
        if (object === prototype ||
            clones && clones.indexOf(object) >= 0)
            return true;
        var object = Object.getPrototypeOf(o  bject);
    } while (object);

    return false;
} ;

这个instanceof方法现在可以用来判断一个对象是否是通过合并继承自一个原型。例如:

sq.instanceof(eventEmitter);

在上面的程序中instanceof会返回true如果我妈使用mixin版本的eventEmitter。然而如果我们使用蓝图版本的eventEmitter它会返回false。为了解决这个问题我创建了一个蓝图函数,这个函数接收一个蓝图作为参数,向它添加一个clones属性然后返回一个记录了它的克隆的新蓝图:

function blueprint(f){
    var g = function(){
        f.apply(this,arguments) ;
        g.clones.unshift(this) ;
    } ;
    g.clones = [] ;
    return g ;
} ;
var eventEmitter = blueprint(function(){
    var events = Object.create(null);
    this.on = function (event, listener) {
        if (typeof events[event] !== "undefined")
            events[event].push(listener);
        else events[event] = [listener];
    };

    this.emit = function (event) {
        if (typeof events[event] !== "undefined") {
            var listeners = events[event];
            var length = listeners.length, index = length;
            var args = Array.prototype.slice.call(arguments, 1);

            while (index) {
                var listener = listeners[length - (index--)];
                listener.apply(this, args);
            }
        }
    };
}) ;

向原型发送变化

上面例子中的clones属性有双重作用。它可以用来判断一个对象是否是通过合并继承自一个原型的,然后他可以用来发送原型改变给所有它的克隆。原型继承相比类继承最大的优势就是你可以修改一个原型在它创建之后。为了使克隆可以继承对于原型的修改,我们创建了一个叫做define的函数:

Object.prototype.define = function (property, value) {
    this[property] = value;

    if (Object.hasOwnProperty.call(this, "clones")) {
        var clones = this.clones;
        var length = clones.length;

        while (length) {
            var clone = clones[--length];
            if (typeof clone[property] === "undefined")
                clone.define(property, value);
        }
    }
};

现在我们可以修改原型然后这个修改会反映在所有的克隆上。例如我们可以创建创建一个别名addEventListener针对eventEmitter上的on方法:

var square = rectangle.extend(eventEmitter, {
    create: function (side) {
        return rectangle.create.call(this, side, side);
    },
    resize: function (newSize) {
        var oldSize = this.width;
        this.width = this.height = newSize;
        this.emit("resize", oldSize, newSize);
    }
});

var sq = square.create(5);

eventEmitter.define("addEventListener", eventEmitter.on);

sq.addEventListener("resize", function (oldSize, newSize) {
    alert("sq resized from " + oldSize + " to " + newSize + ".");
});

sq.resize(10);
 
alert(sq.area());

蓝图需要特别注意。尽管对于蓝图的修改会被发送到它的克隆,但是蓝图的新的克隆并不会反映这些修改。幸运的是这个问题的解决方法很简单。我们只需要对blueprint方法进行小小的修改,然后任何对于蓝图的修改就会反映在克隆上了。

function blueprint(f) {
    var g = function () {
        f.apply(this, arguments);
        g.clones.unshift(this);

        var hasOwnProperty = Object.hasOwnProperty;

        for (var property in g)
            if (property !== "clones" &&
                hasOwnProperty.call(g, property))
                    this[property] = g[property];
    };

    g.clones = [];

    return g;
};

结论

恭喜你。如果你读完了整篇文章并且理解了我所说的东西,你现在就了解了 原型继承并且为什么它很重要。很感谢你们看完了这篇文章。我希望这个博客能帮到你们。原型继承是强大的并且值得更多的信任。然后大部分人从来不明白这个因为Javascript中的原型继承被构造模式所掩盖了。

译者注

这篇文章针对几种继承方式进行了对比。文章中说到的几种扩展的方法我觉得是比较有用的。蓝图(blueprint,这个实在不知道该怎么翻译)的扩展方式比较像设计模式中的装饰者模式,通过函数对对象进行扩展,这个是一种比较好玩的扩展方式,可以跟原型继承配合使用。另外文中提到了new关键字的弊端,个人觉得主要的原因还是new关键字的出现掩盖了Javascript本身原型继承的特点,人们自然而然就会想到传统的类继承,这样就无法发挥原型继承的最大威力。最后说到的属性修改传播的问题也挺有意思的,应该会有相应的应用场景。总之,我觉得原型继承相比于传统的类继承提供了更大的灵活性,可以给我们开发者提供很大的发挥空间,不过不管怎样,到最后还是要涉及到基本的原型继承的原理上,所以掌握了原型继承的原理就可以根据不同的应用场景使用各种各样的扩展方式。


原文地址:http://aaditmshah.github.io/why-prototypal-inheritance-matters/

最后,安利下我的个人博客,欢迎访问: http://bin-playground.top


lakb248
2.8k 声望239 粉丝

Shopee(虾皮)深圳研发中心找前端/后端/移动开发,内推请投 lakb248@163.com