zoukankan      html  css  js  c++  java
  • 读书笔记之

    代理(proxy)是一个对象,它可以用来控制对另一对象的访问。它与另外那个对象实现了同样的接口,并且会把任何方法调用传递给那个对象。另外那个对象通常称为本体。代理可以代替本体被实例化,并使其可被远程访问。它还可以把本体的实例化推迟到真正需要的时候,对于实例化比较费时的本体,或者因为尺寸较大以至于不用时不宜保存在内存中的本体,这特别有用。在处理那些需要较长时间才能把数据载入用户界面的类,代理也十分有用。

    代理模式最基本的形式就是对访问进行控制,代理对象和本体实现的是同样的接口。实际工作还是本体在做,它才是负责执行分派的任务的那个对象类。代理对象所做的不外乎节制对本体的访问。要注意,代理对象并不会在另一对象的基础上添加方法或者修改其方法(装饰者模式),也不会简化那个对象的接口(像门面模式),它实现的功能与本体完全相同,所有对他进行的方法调用都会被传递给本体。

    代理控制对本体的访问:

    那种根本不实现任何访问控制的代理最简单。它所做的只是把所有方法调用传给本体。

    下面例子中,我们创建一个代表图书馆的类。该类封装了一个Book对象。

    var Interface = function(){};
    var Publication = new Interface('Publication',['getIsbn','setIsbn','getTitle','setTitle','getAuthor','setAuthor','display']);
    
    var Book = function (isbn,title,author) {
        //TODO: ...
    };
    
    var Library = new Interface('Library',['findBooks','checkoutBook','returnBook']);
    var PublicLibrary = function (books) {
        this.catalog = {};
        for (var i= 0,len=books.length;i<len;i++) {
            this.catalog[books[i].getIsbn()] = {book: books[i], available: true};
        }
    };
    PublicLibrary.prototype = {
        findBooks:function(searchString){
            var result = [];
            for (var isbn in this.catalog) {
                if (!this.catalog.hasOwnProperty(isbn)) {
                    continue;
                }
                if (searchString.match(this.catalog[isbn].getTitle())|| searchString.match(this.catalog[isbn].getAuthor())) {
                    result.push(this.catalog[isbn]);
                }
    
            }
            return result;
        },
        checkoutBook: function () {
            var isbn = book.getIsbn();
            if (this.catalog[isbn]) {
                if (this.catalog[isbn].available) {
                    this.catalog[isbn].available = false;
                    return this.catalog[isbn];
                }else {
                    throw new Error('Public Libary:book'+book.getTitle()+'is not currently available.')
                }
            }else {
                throw new Error('Public Libary:book'+book.getTitle()+'not found.');
            }
        },
        returnBook: function (book) {
            var isbn = book.getIsbn();
            if(this.catalog[isbn]){
                this.catalog[isbn].available = true;
            }else {
                throw new Error('Public Libary:book'+book.getTitle()+'not found.');
            }
        }
    }

    这个类非常简单,它可以用来查书、借书和还书。下面是一个没有实现任何访问控制的PublicLibrary代理:

    var PublicLibraryProxy = function (catalog) {
        this.library = new PublicLibrary(catalog);
    };
    PublicLibraryProxy.prototype = {
        findBook: function (searchString) {
            return this.library.findBooks(searchString);
        },
        checkoutBook: function (book) {
            return this.library.checkoutBook(book);
        },
        returnBook: function (book) {
            return this.library.returnBook(book);
        }
    }

    PublicLibraryProxy 和 PublicLibrary实现了同样的接口和方法。这个类在实例化时会创建一个PublicLibrary实例并将其作为属性保存。这种类型的代理也可以通过检查本体的接口并为每一个方法创建对应方法这样一种方式动态地创建。这种代理没有什么用处,虚拟代理才是最有用的类型之一。虚拟代理用于控制对那种创建开销很大的本体的访问。它会把本体的实例化推迟到方法被调用的时候,有时还会提供关于实例化状态的反馈。它还可以在本体被加载之前扮演其替身的角色。作为一个例子,假设PublicLibrary的实例很慢,不能在网页加载的时候立即完成。我们可以为其创建一个虚拟代理,让他把PublicLibrary的实例化推迟到必要的时候。

    var PublicLibraryVirtualProxy = function (catalog) {
        this.library = null;
        this.catalog = catalog ;
    }
    PublicLibraryVirtualProxy.prototype = {
        _initializelibrary:function(){
            if (this.library===null) {
                this.library = new PublicLibrary(this.catalog);
            }
        },
        findBooks: function (searchString) {
            this._initializelibrary();
            return this.library.findBooks(searchString);
        },
        checkoutBook: function (book) {
            this._initializelibrary();
            return this.library.checkoutBook(book);
        },
        returnBook: function (book) {
            this._initializelibrary();
            return this.library.returnBook(book);
        }
    }

    PublicLibraryVirtualProxy和PublicLibraryProxy之间的关键区别在于后者不会立即创建PublicLibrary实例。

    PublicLibraryVirtualProxy 会把构造函数的参数保存起来,直到有方法调用的时候才真正执行本体实例化。这样一来,如果图书馆对象一直没有使用,那么它就不会被创建出来。虚拟代理通常具有某种能触发本体实例化的事件。在本例中,方法调用就是触发因素。

    代理在很多方面都很像装饰者。装饰者和虚拟代理都要对其他对象进行包装。都要实现与被包装对象相同的接口,而且都要把方法调用传递给被包装对象。

    最大的区别在于装饰者会对被包装对象的功能进行修改或者扩充,而代理只是控制对它的访问。除了随时添加一些控制代码之外,代理并不会对传递给本体的方法进行修改。而装饰者模式是为修改方法而生的。

    另一个区别表现在被包装对象的创建方式上。在装饰者模式中,被包装对象的实例化过程完全是独立的。这个对象创建出来之后,你可以随意为其裹上一个或者更多个装饰者。而在代理模式中,被包装对象的实例化是代理的实例化过程的一部分。在某些类型的虚拟代理中,这种实例化受到严格控制,它必须在代理内部进行。此外,代理不会像装饰者那样互相包装,它们一次只使用一个。

    代理模式的适用场合:

    虚拟代理是一个对象,用于控制对一个创建开销昂贵的资源的访问。虚拟代理是一种优化模式。如果有些类或者对象需要使用大量内存保存其数据,而你并不需要在实例化完成之后立即访问这些数据,或者,其构造函数需要进行大量计算,那就应该使用虚拟代理将设置开销的产生推迟到真正需要数据的时候。代理还可以在设置的进行过程中提供类似于‘正在加载。。。’这样的信息,这可以形成一个反应积极的用户界面,以提示用户。

    远程代理则没有这样清楚的用例。如果需要访问某种远程资源的话,那么最好用一个类或者对象来包装它,而不是一遍又一遍地手工设置XMLHttpRequest对象。问题在于应该用什么类型的对象来包装这个资源呢?如果包装对象实现了远程资源的所有方法,那么它就是一个远程代理。如果在运行期间增添一些方法,那它就是一个装饰者,如果简化了该远程资源,或者多个资源的接口,那它就是一个门面,远程代理是一种结构型的模式。它提供了一个访问位于其他环境中的资源的原生javascript API。总而言之,如果有些类或对象的创建开销较大,而且不需要实例化之后立即访问其数据,那么就应该使用虚拟代理。如果你有某种远程资源,并且要为该资源提供的所有功能实现对应的方法,那么应该使用远程代理。

    示例:网页统计

    本例将创建一个远程代理,它包装了一个用来提供网页统计数据的web服务,这个web服务由一系列URL组成,它们各相对于一个拥有可选参数的方法。它在服务器端用什么语言实现并不重要。数据以JSON格式返回。下面是这个web服务实现的5个方法:

    • http://mydomain.com/stats/getPageviews/
    • http://mydomain.com/stats/getUniques/
    • http://mydomain.com/stats/getBrowserShare/
    • http://mydomain.com/stats/getTopSearchTerms/
    • http://mydomain.com/stats/getMostVisitedPages/

    这几个方法都有用来限制搜集统计数据的时间范围的可选参数。(startDate和endDate),对于前面的4个方法还可以要求只要特定网页的统计数据。

    你希望在整个网页中都能显示这些统计数据。但是只在用户需要的时候才显示,目前的做法是为每个网页进行手工XHR的调用。

    var xhrHandler = xhrManager.createXhrHandler();
    
    //Get the pageview statistics
    var callback = {
        success: function (responseText) {
            var stats = eval('(' + responseText + ')');
            displayPageviews(stats);
        },
        failure: function (statusCode) {
            throw new Error('Asynchronous request for stats failed!');
        }
    }
    xhrHandler.request('GET', '/stats/getPageviews/?page=index.html', callback);
    
    //Get the browser statistics
    var callback = {
        success: function (responseText) {
            var stats = eval('(' + responseText + ')');
            displayBrowserShare(stats);
        },
        failure: function (statusCode) {
            throw new Error('Asynchronous request for stats failed!');
        }
    }
    xhrHandler.request('GET', '/stats/getBrowserShare/?page=index.html', callback);

    要是能把这些调用包装在一个对象中就好了,这个对象应该展现出一个用来访问数据的原生javascript 接口。这样就不会有前例中那样多的重复性代码。这个对象需要实现那个web服务中的5个方法。每个方法都会执行对web服务的XHR调用以获取数据,然后将其提供给回调函数。

    首先要做的是定义web服务接口,其目的在于以后有需要的时候能够换用其他类型的代理。

    var PageStats = new Interface('PageStats',['getPageviews','getUniques','getBrowserShare','getTopSearchTerms','getMostVisitedPages']);

    然后定义远程代理StatsProxy本身:

    var StatsProxy = (function () {
        var xhrHandler = xhrManager.createXhrHandler();
        var urls = {
            pageviews:'/stats/getPageviews/',
            uniques:'/stats/getUniques/',
            browserShare:'/stats/getBrowserShare/',
            topSearchTerms:'/stats/getTopSearchTerms/',
            mostVisitedPages:'/stats/getMostVisitedPages/'
        }
    
        function xhrFailure() {
            throw new Error('StatsProxy:Asynchronous request for stats failed.');
        }
    
        function fetchData(url,dataCallback,startDate,endDate,page) {
            var callback = {
                success: function (responseText) {
                    var stats = eval('(' + responseText + ')');
                    dataCallback(stats);
                },
                failure: xhrFailure
            }
    
            var getVars = [];
            if (startDate!=undefined) {
                getVars.push('startDate='+encodeURIComponent(startDate));
            }
            if (endDate!=undefined) {
                getVars.push('endDate='+encodeURIComponent(endDate));
            }
            if(!page) {
                getVars.push('page=' + page);
            }
            if (getVars.length>0) {
                url = url + '?' + getVars.join('&');
            }
            xhrHandler.request('GET',url,callback);
    
        };
        return {
            getPageviews:function(callback,startDate,endDate,page) {
                fetchData(urls.pageviews,callback,startDate,endDate,page);
            },
            getUniques:function(callback,startDate,endDate,page) {
                fetchData(urls.pageviews,callback,startDate,endDate,page);
            },
            getBrowserShare:function(callback,startDate,endDate,page) {
                fetchData(urls.pageviews,callback,startDate,endDate,page);
            },
            getTopSearchTerms:function(callback,startDate,endDate,page) {
                fetchData(urls.pageviews,callback,startDate,endDate,page);
            },
            getMostVisitedPages:function(callback,startDate,endDate,page) {
                fetchData(urls.pageviews,callback,startDate,endDate,page);
            }
        }
    
    })();

    这段代码使用了单体模式的俩种较高级的形式,这样可以创建私用属性和方法。接口所需要的那些方法被定义为公用方法,而助理方法则被定义为私用方法。所有公用方法都调用了detchData这个辅助方法,前面的手工实现版本中那些重复性的代码都被集中到这个方法中。

    尽管在设计这种代理时其具体实现细节会因web服务的类型而异,但是这个通用模式可以为你提供一个一般性的框架。由于javascript的同源性限制,这里使用的不是一个单体,而是一个拥有构造函数的普通类,以便以后进行扩展。

    var WebserviceProxy = function () {
        this.xhrHandler = xhrManager.createXhrHandler();
    };
    WebserviceProxy.prototype = {
        _xhrFailure: function (statusCode) {
            throw new Error('StatsProxy:Asynchronous request for stats failed.');
        },
        _fetchData: function (url,dataCallback,getVars) {
            var callback = {
                success: function (responseText) {
                    var stats = eval('(' + responseText + ')');
                    dataCallback(stats);
                },
                failure: xhrFailure
            }
    
            var getVars = [];
            if (startDate!=undefined) {
                getVars.push('startDate='+encodeURIComponent(startDate));
            }
            if (endDate!=undefined) {
                getVars.push('endDate='+encodeURIComponent(endDate));
            }
            if(!page) {
                getVars.push('page=' + page);
            }
            if (getVars.length>0) {
                url = url + '?' + getVars.join('&');
            }
            xhrHandler.request('GET',url,callback);
        }
    }

    使用这个通用模式时,只需要从WebserviceProxy派生一个子类,然后借助_fetchData方法实现需要的方法即可。如果要把StatsProxy类作为WebserviceProxy的子类,其结果大致如下:

    var StatsProxy = function () {
    
    };
    StatsProxy.prototype.getPageviews = function (callback,startDate,endDate,page) {
        this._fetchData('/stats/getPageviews/',callback,{
            'startDate':startDate,
            'endDate':endDate,
            'page':page
        });
    }
    StatsProxy.prototype.getUniques = function (callback,startDate,endDate,page) {
        this._fetchData('/stats/getUniques/',callback,{
            'startDate':startDate,
            'endDate':endDate,
            'page':page
        });
    }
    StatsProxy.prototype.getBrowserShare = function (callback,startDate,endDate,page) {
        this._fetchData('/stats/getBrowserShare/',callback,{
            'startDate':startDate,
            'endDate':endDate,
            'page':page
        });
    }
    StatsProxy.prototype.getTopSearchTerms = function (callback,startDate,endDate,page) {
        this._fetchData('/stats/getTopSearchTerms/',callback,{
            'startDate':startDate,
            'endDate':endDate,
            'page':page
        });
    }
    StatsProxy.prototype.getMostVisitedPages = function (callback,startDate,endDate,page) {
        this._fetchData('/stats/getMostVisitedPages/',callback,{
            'startDate':startDate,
            'endDate':endDate,
            'page':page
        });
    }

    目录查找:

    这次的任务是为公司的网站的主页添加一个可搜索的员工目录。它应该模仿实际的员工花名册中的页面,从A开始,显示其姓氏以特定字母开头的所有员工,由于网页的访问量很大,所以这个解决方案必须尽量节约带宽。

    因为在这个问题中,网页的大小很重要,所以我们决定只为那些需要查看员工资料的用户加载这种数据,这样一来,那些不关心这种信息的用户就不用下载额外的数据。这是虚拟代理可以大显身手的地方,因为它能够把需要占用大量带宽资源的加载延迟到必要的时候。我们还打算在加载员工目录的过程中向用户提供一些提示信息,以免他们盯着一个空白屏幕,猜想网站是不是出了什么问题。这种任务非常适合虚拟代理。

    首先要做的就是创建代理的那个本体类,它负责获取员工数据并生成用于网页上显示这些数据的HTML,其显示格式类似电话薄:

    var Directory = new Interface('Directory',['showPage']);
    
    var PersonnelDirectory = function (parent) {
        this.xhrHandler = xhrManager.createXhrHandler();
        this.parent = parent;
        this.data = null;
        this.currentPage = null;
    
        var _this = this;
        var callback = {
            success:_this._configure,
            failure: function () {
                throw  new Error('PersonnelDirectory:failure in data retrieval.');
            }
        }
        xhrHandler.request('GET', 'directoryData.php', callback);
    }
    
    PersonnelDirectory.prototype = {
        _configure: function (reponseText) {
            this.data = eval('(' + reponseText + ')');
            //TODO:生成HTML元素并向其中注入数据
            this.currentPage = 'a';
        },
        showPage: function (page) {
            $('page-' + this.currentPage).style.display = 'none';
            $('page-' + page).style.display = 'block';
            this.currentPage = page;
        }
    };

    该类的构造函数会发出一个XHR请求以获取员工数据。其中_configure方法会在数据返回的时候被调用。它会生成HTML元素并向其中注入数据。该类实现了一个目录应该具有的所有功能,那么为什么还要使用代理呢?原因在于,这个类在实例化过程中会加载大量的数据,如果在网页加载的时候实例化这个类,那么每一个用户都不得不加载这些数据,即是他根本不使用员工目录,代理的作用就是推迟这个实例化过程。

    下面先勾勒出虚拟代理类的大体轮廓,它包含了该类需要的所有方法。本类中只有showPage方法。

    function Directory(){}
    Directory.prototype = {
        showPage: function (page){}
    }

    下一步是先将这个类实现为一个无用的代理,他的每个方法只是调用本体的同名方法

    function DirectoryProxy(parent){
        this.directory = new Directory(parent);
    }
    DirectoryProxy.prototype = {
        showpage: function (){
            return this.directory.showPage(page);
        }
    }

    现在这个代理可以代替PersonnelDirectory的实例使用,他们可以透明的互换。不过,在此情况下,你丝毫没有享受到虚拟代理的好处,想要发挥虚拟代理的作用,需要创建一个用来实例化本体的方法,并注册一个用来触发这个实例化过程的事件监听器。

    function DirectoryProxy(parent){
        this.parent = parent;
        this.directory = null;
        var _this = this;
        addEvent(parent,'mouseover',_this._initialize);
    }
    DirectoryProxy.prototype = {
        _initialize:function(){
            this.directory = new PersonnelDirectory(this.parent);
        },
        showpage: function (){
            return this.directory.showPage(page);
        }
    }

    现在DirectoryProxy类的构造函数不再实例化本体。而是把这个工作推迟到_initialize中进行。我们注册了一个事件监听器,作为这个方法的触发器。触发器的作用在于通知代理对象用户需要实例化本体。在本例中,一旦用户把鼠标移动到目录的父容器上方,本例就会被实例化。在更复杂的解决方案中,可以先为目录用户生成一个空白的用户界面,一旦某个表单域处于焦点之下,它就会被初始化的本体透明地取代。

    这个例子已经接近完工,剩下的任务只有一件,那就是提示用户当前正在加载员工目录,并且在本体创建完毕之前阻止任何方法调用。

    function DirectoryProxy(parent){
        this.parent = parent;
        this.directory = null;
        this.warning = null;
        this.interval = null;
        this.initialized = false;
        var _this = this;
        addEvent(parent,'mouseover',_this._initialize);
    }
    DirectoryProxy.prototype = {
        _initialize:function(){
            this.warning = document.createElement('div');
            this.parent.appendChild(this.warning);
            this.warning.innerHTML = 'The company directory is loading...';
            this.directory = new PersonnelDirectory(this.parent);
            var _that = this;
            this.interval = setInterval(function(){
                _that._checkInitialization();
            },100);
        },
        _checkInitialization:function(){
            if (this.directory.currentPage!=null) {
                clearInterval(this.interval);
                this.initialized = true;
                this.parent.removeChild(this.warning);
            }
        },
        showpage: function (){
            if (!this.initialized) {
                return;
            }
            return this.directory.showPage(page);
        }
    }

    阻止对showpage的调用非常简单,只要检查一下initialized属性即可。

    创建虚拟代理的通用模式

    javascript 是一种非常灵活的语言,得益于此,你可以创建一个动态虚拟代理,它会检查提供给它的类的接口,创建自己的对应方法,并且将该类的实例化推迟到某些预定条件得到满足的时候,作为第一步,下面先创建这个动态代理类的壳体以及_initialize和_checkInitialization这俩个方法。这是个抽象类,需要派生子类并进行一些配置才能正常工作:

    var DynamicProxy = function () {
        this.args = arguments;
        this.initialized = false;
    }
    DynamicProxy.prototype = {
        _initiaLize: function () {
            this.subject = {};
            this.class.apply(this.subject,this.args);
            this.subject.__proto__ = this.class.prototype;
            var _this = this;
            this.interval = setInterval(function(){
                _this._checkInitialization();
            },100);
        },
        _checkInitialization:function(){
            if(this._initialized()){
                clearInterval(this.interval);
                this.initialized = true;
            }
        },
        _initialized: function () {
            throw new Error('Unsupported opearation on an abstract class')
        }
    }

    这个类实现了三个方法,_initiaLize用于触发本体的实例化过程。它可以被关联到各种触发器或者条件。_checkInitialization方法每隔一段预定的时间会被调用一次,它会调用_initialized方法,如果返回值为true,就将initialized属性设置为true,在初始化完成之前,代理将阻止对本体的所有方法的调用,而_initialized方法就是用来判断代理的初始化是否已经完成的,子类必须实现这个方法。因为对于不同的本体这个方法会有所不同。

    现在需要在构造函数中添加一些代码,以便针对本体类中的每一个方法为代理创建一个相应的方法,这与动态装饰者那个例子代码十分相似,但是也有一些区别

    var DynamicProxy = function () {
        this.args = arguments;
        this.initialized = false;
    
        if(typeof this.class!='function'){
            throw new Error('DynamicProxy:the class attr must be set before'+
                'calling the super-class constructor!');
        }
    
        //Create the methods needed to implement the same interface
        for (var key in this.class.prototype) {
            if(typeof this.class.prototype[key]!=='function') {
                continue;
            }
        }
        //Add the method
        _this = this;
        (function(methodName){
            _this[methodName] = function () {
                if(!this.initialized ){
                    return ;
                }
                return _this.subject[methodName].apply(_this.subject, arguments);
            }
        })(key);
    
    
    }
    DynamicProxy.prototype = {
        _initiaLize: function () {
            this.subject = {};
            this.class.apply(this.subject,this.args);
            this.subject.__proto__ = this.class.prototype;
            var _this = this;
            this.interval = setInterval(function(){
                _this._checkInitialization();
            },100);
        },
        _checkInitialization:function(){
            if(this._initialized()){
                clearInterval(this.interval);
                this.initialized = true;
            }
        },
        _initialized: function () {
            throw new Error('Unsupported opearation on an abstract class')
        }
    }

    最重要的区别在于,这里是在对本体类的prototype中的方法进行逐一检查,而不是对本体对象本身进行检查。这是因为此时本体还没有被实例化,自然还不存在本体对象。因此在决定需要实现一些什么方法时检查的是本体类而不是本体对象。在这个过程中所添加的每一个方法都由俩个部分组成:先执行一个检查,其目的在于确保本体已经初始化;随后是对本体中同名方法的调用。

    要想使用这个类,必须先从它派生子类。为了演示用法,我们创建了一个TestProxy类。它本用作虚构的 TestClass 的代理。

    var TestProxy = function () {
        this.class = TestClass;
        var _this = this;
        addEvent($('text-link'),'click', function () {
            _this._initialize();
        });
        TestProxy.superclass.constructor.apply(this, arguments);
    }
    extend(TestProxy,DynamicProxy);
    TestProxy.prototype._isInitialized = function () {
        //TODO:Initialize condition goes here.
    }

    在子类中必须做的事有4件:

    1. 将this.class设置为本体类;
    2. 创建某种实例化触发器;
    3. 调用超类的构造函数;
    4. 实现_isInitialized方法。

    这个动态代理会把本体的实例化推迟到你认为必要的时候,在实例化完成之前,代理的所有公用方法什么事都不会做。这个类可以用来包装那些需要大量计算或较长时间才能实例化的类。

    代理模式的好处:

    借助远程代理,可以把远程资源当做本地javascript对象使用。其益处显而易见,它减少了为访问远程资源而不得不编写粘合性代码的数量,并为此提供了单一的接口。它还把与远程资源相关的所有数据统一保存在一个地方,其中包括资源的URL,数据格式,命令和响应的结构。如果需要访问多个web服务,那么可以先创建一个抽象的远程代理类,然后针对每一种要访问的Web服务派生出一个子类。

    虚拟代理则有截然不同的作用,它并不会减少重复性的代码和提高对象的模块性,这种模式的作用体现在效率方面。他是一种优化模式,只有当资源的创建保有开销比较大的时候,才可派上用场。借助这种模式,你可以使用本体的所有功能而不必操心其实例化的事。它还可以在本体加载完毕之前显示‘正在加载...’这样的提示信息或者显示一个虚拟用户界面。在速度比较重要的网页中,虚拟代理可以用来把大对象实例化推迟到其他元素加载完毕之后。这往往能给最终用户带来速度提升的感觉。如果虚拟代理包装没有被用到,那么它根本就不会被加载。虚拟代理的好处在于,你可以用它替代本体,而不操心实例化开销问题。

    同时,代理模式掩盖了大量复杂的代码,同时给代码增加了复杂性。

  • 相关阅读:
    一文搞懂Raft算法
    设计数据密集型应用第三部分:派生数据
    对一次架构设计的总结和反思
    One take,可望而不可即
    设计数据密集型应用第二部分:分布式系统的机遇与挑战
    [代码重构]简化函数调用
    [代码重构]简化函数调用
    [Vue专题] 对比vue-cli2.x和vue-cli3.x的搭建
    npm ERR! code ENOLOCAL
    Jenkins配置基于角色的项目权限管理
  • 原文地址:https://www.cnblogs.com/mrsai/p/3941578.html
Copyright © 2011-2022 走看看