摘要:為指定事件注冊一個單次監(jiān)聽器,即監(jiān)聽器最多只會觸發(fā)一次,觸發(fā)后立刻解除該監(jiān)聽器。移除指定事件的某個監(jiān)聽器,監(jiān)聽器必須是該事件已經(jīng)注冊過的監(jiān)聽器。返回指定事件的監(jiān)聽器數(shù)組。如何創(chuàng)建空對象我們已經(jīng)了解到,是要來儲存監(jiān)聽事件監(jiān)聽器數(shù)組的。
毫無疑問,nodeJS改變了整個前端開發(fā)生態(tài)。本文通過分析nodeJS當中events模塊源碼,由淺入深,動手實現(xiàn)了屬于自己的ES6事件觀察者系統(tǒng)。千萬不要被nodeJS的外表嚇到,不管你是寫nodeJS已經(jīng)輕車熟路的老司機,還是初入前端的小菜鳥,都不妨礙對這篇文章的閱讀和理解。
事件驅動設計理念nodeJS官方介紹中,第二句話便是:
"Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient"。
由此,“事件驅動(event-driven)”理念對nodeJS設計的重要性可見一斑。比如,我們對于文件的讀取,任務隊列的執(zhí)行等,都需要這樣一個觀察者模式來保障。
那個最熟悉的陌生人同時,作為前端開發(fā)人員,我們對于所謂的“事件驅動”理念——即“事件發(fā)布訂閱模式(Pub/Sub模式)”一定再熟悉不過了。這種模式在js里面有與生俱來的基因。我們可以認為JS本身就是事件驅動型語言:
比如,頁面上有一個button, 點擊一下就會觸發(fā)上面的click事件。這是因為此時有特定程序正在監(jiān)聽這個事件,隨之觸發(fā)了相關的處理程序。
這個模式最大的一個好處在于能夠解耦,實現(xiàn)“高內(nèi)聚、低耦合”的理念。那么這樣一個“熟悉的”模式應該怎么實現(xiàn)呢?
其實社區(qū)上已經(jīng)有不少前輩的實現(xiàn)了,但是都不能算特別完美,或者不能完全符合特定的場景需求。
本文通過解析nodeJS源碼中的events模塊,提取其精華,一步步打造了一個基于ES6的eventEmitter系統(tǒng)。
讀者有任何想法,歡迎與我交流。同時希望各路大神給予斧正。
背景簡介為了方便大家理解,我從一個很簡單的頁面實例說起。
百度某產(chǎn)品頁面中,存在兩處不同的收藏組件:
一處在頁面頂部;
一處在頁面詳情側欄。
第一次點擊一個收藏組件按鈕,發(fā)送異步請求,進行收藏,同時請求成功的回調(diào)函數(shù)里,需要將頁面中所有“收藏”按鈕轉換狀態(tài)為“已收藏”。以達到“當前文章”收藏狀態(tài)的全局同步。
完成這樣的設計很簡單,我們大可在業(yè)務代碼中進行混亂的操作處理,比如初學者常見的做法是:點擊第一處收藏,異步請求之后的回調(diào)邏輯里,修改頁面當中所有收藏按鈕狀態(tài)。
這樣做的問題在于耦合混亂,不僅僅是一個收藏組件,試想當代碼中所有組件全都是這樣的“隨意”操作,后期維護成本便一發(fā)不可收。
我的Github倉庫中,也有對于這么一個頁面實例的分析,讀者若想自己玩一下,可以訪問這里。
當然,更優(yōu)雅的做法就是使用事件訂閱發(fā)布系統(tǒng)。
我們先來看看nodeJS是怎么做的吧!
讀者可以自己去nodeJS倉庫查找源碼,不過更推薦參考我的Github-事件發(fā)布訂閱研究項目,里面不僅有自己實現(xiàn)的多套基于ES6的事件發(fā)布訂閱系統(tǒng),也“附贈”了nodeJS實現(xiàn)源碼。同時我對源碼加上了漢語注釋,方便大家理解。
在nodeJS中,引入eventEmitter的方式和實例化方法如下:
// 引入 events 模塊 var events = require("events"); // 創(chuàng)建 eventEmitter 對象 var eventEmitter = new events.EventEmitter();
我們要研究的,當然就是這個eventEmitter實例。先不急于深入源碼,我們需要在使用層面先有一個清晰的理解和認知。不然盲目閱讀源碼,便極易成為一只“無頭蒼蠅”。
一個eventEmitter實例,自身包含有四個屬性:
_events:
這是一個object,其實相當于一個哈希map。他用來保存一個eventEmitter實例中所有的注冊事件和事件所對應的處理函數(shù)。以鍵值對方式存儲,key為事件名;value分為兩種情況,當當前注冊事件只有一個注冊的監(jiān)聽函數(shù)時,value為這個監(jiān)聽函數(shù);如果此事件有多個注冊的監(jiān)聽函數(shù)時,value值為一個數(shù)組,數(shù)組每一項順序存儲了對應此事件的注冊函數(shù)。
需要說明的是,理解value值的這兩種情況,對于后面的源碼分析非常重要。我認為nodeJS之所以有這樣的設計,是出于性能上的考慮。因為很多情況(單一監(jiān)聽函數(shù)情況)并不需要在內(nèi)存上新建一個額外數(shù)組。
_eventsCount:整型,表示此eventEmitter實例中注冊的事件個數(shù)。
_maxListeners:整型,表示此eventEmitter實例中,一個事件最多所能承載的監(jiān)聽函數(shù)個數(shù)。
domain:在node v0.8+版本的時候,發(fā)布了一個模塊:domain。這個模塊做的是捕捉異步回調(diào)中出現(xiàn)的異常。這里與主題無關,不做展開。
同樣,eventEmitter實例的構造函數(shù)原型上,包含了一些更為重要的屬性和方法,包括但不限于:
addListener(event, listener):
為指定事件添加一個注冊函數(shù)(以下稱監(jiān)聽器)到監(jiān)聽器數(shù)組的尾部。他存在一個別名alias:on。
once(event, listener):
為指定事件注冊一個單次監(jiān)聽器,即監(jiān)聽器最多只會觸發(fā)一次,觸發(fā)后立刻解除該監(jiān)聽器。
removeListener(event, listener):
移除指定事件的某個監(jiān)聽器,監(jiān)聽器必須是該事件已經(jīng)注冊過的監(jiān)聽器。
removeAllListeners([event]):
移除所有事件的所有監(jiān)聽器。如果指定事件,則移除指定事件的所有監(jiān)聽器。
setMaxListeners(n):
默認情況下,如果你添加的監(jiān)聽器超過10個就會輸出警告信息。setMaxListeners 函數(shù)用于提高監(jiān)聽器的默認限制的數(shù)量。
listeners(event):返回指定事件的監(jiān)聽器數(shù)組。
emit(event, [arg1], [arg2], [...]):
按參數(shù)的順序執(zhí)行每個監(jiān)聽器,如果事件有注冊監(jiān)聽器返回true,否則返回false。
上一段其實簡要介紹了nodeJS中eventEmitter的使用方法。下面,我們要做的就是深入nodeJS events模塊源碼,了解并學習他的設計之美。
如何創(chuàng)建空對象?我們已經(jīng)了解到,_events是要來儲存監(jiān)聽事件(key)、監(jiān)聽器數(shù)組(value)的map。那么,他的初始值一定是一個空對象。直觀上,我們可以這樣創(chuàng)建一個空對象:
this._events = {};
但是nodeJS源碼中的實現(xiàn)方式卻是這樣:
function EventHandlers() {}; EventHandlers.prototype = Object.create(null); this._events = new EventHandlers();
官方稱,這么做的原因是出于性能上的考慮,經(jīng)過jsperf比較,在v8 v4.9版本中,后者性能有超出2倍的表現(xiàn)。
對此,作為一個“吹毛求疵”有態(tài)度的程序員,我寫了一個benchmark,對一個對象進行一千次取值操作,求平均時間進行驗證:
_events = {}; _events.test="test" for (let i = 0; i < 1000; i++) { window.performance.mark("test empty object start"); console.log(_events.test); window.performance.mark("test empty object end"); window.performance.measure("test empty object","test empty object start","test empty object end"); } let sum1 = 0 for (let k = 0; k < 1000; k++) { sum1 +=window.performance.getEntriesByName("test empty object")[k].duration } let averge1 = sum1/1000; console.log(averge1*1000); function EventHandlers() {}; EventHandlers.prototype = Object.create(null); _events = new EventHandlers();_events.test="test"; for (let i = 0; i < 1000; i++) { window.performance.mark("test empty object start"); console.log(_events.test); window.performance.mark("test empty object end"); window.performance.measure("test empty object","test empty object start","test empty object end"); } let sum1 = 0 for (let k = 0; k < 1000; k++) { sum1 +=window.performance.getEntriesByName("test empty object")[k].duration } let averge1 = sum1/1000; console.log(averge1*1000);
第一段執(zhí)行時間:111.86000000001695;
第二段執(zhí)行時間:108.37000000001353;
多執(zhí)行幾次會發(fā)現(xiàn),第一段也存在時間上短于第二段執(zhí)行時間的情況。總體來看,第二段時間上更短,但兩次時間比較相近。
我自己的想法是,使用nodeJS源碼中這樣創(chuàng)建空對象的方式,在對對象屬性的讀取上能夠節(jié)省原型鏈查找的時間。但是,如果一個屬性直接在該對象上,即hasOwnProperty()為true,是否還有節(jié)省查找時間,性能優(yōu)化的空間呢?
另外,不同瀏覽器引擎的處理可能也存在差別,即使是流行的V8引擎,處理機制也“深不可測”。同時,benchmark中都是對同一屬性的讀取,一般來講瀏覽器引擎對同樣的操作行為應該會有一個“cache”機制:據(jù)我了解JIT(just-in-time)實時匯編,會將重復執(zhí)行的"hot code"編譯為本地機器碼,極大增加效率。所以benchmark實現(xiàn)的purity也有被一定程度的干擾。不過好在測試實例都是在相同環(huán)境下執(zhí)行。
所以源碼中,此處性能優(yōu)化上的2倍數(shù)值,我持一定的保留態(tài)度。
addListener實現(xiàn)經(jīng)過整理,適當刪減后的源碼點擊這里查看,保留了我的注釋。我們來一步一步解讀下源碼。
判斷添加的監(jiān)聽器是否為函數(shù)類型,使用了typeof進行驗證:
if (typeof listener !== "function") { throw new TypeError(""listener" argument must be a function"); }
接下來,要分為幾種情況。
case1:
判斷_events表是否已經(jīng)存在,如果不存在,則說明是第一次為eventEmitter實例添加事件和監(jiān)聽器,需要新創(chuàng)建_events:
if (!events) { events = target._events = new EventHandlers(); target._eventsCount = 0; }
還記得EventHandlers是什么嗎?忘記了把屏幕往上滾動再看一下吧。
同時,添加指定的事件和此事件對應的監(jiān)聽器:
existing = events[type] = listener; ++target._eventsCount;
注意第一次創(chuàng)建時,為了節(jié)省內(nèi)存,提高性能,events[type]值是一個監(jiān)聽器函數(shù)。如果再次為相同的events[type]添加監(jiān)聽器時(下面case2),events[type]對應的值需要變成一個數(shù)組來存儲。
case2:
又啰嗦一遍:如果_events已存在,在為相關事件添加監(jiān)聽器時,需要判斷events[type]是函數(shù)類型(只存在一個監(jiān)聽函數(shù))還是已經(jīng)成為了一個數(shù)組類型(已經(jīng)存在一個以上監(jiān)聽函數(shù))。
并且根據(jù)相關參數(shù)prepend,分為監(jiān)聽器數(shù)組頭部插入和尾部插入兩種情況,以保證監(jiān)聽器的順序執(zhí)行:
if (typeof existing === "function") { existing = events[type] = prepend ? [listener, existing] : [existing, listener]; } else { if (prepend) { existing.unshift(listener); } else { existing.push(listener); } }
case3:
在閱讀源碼時,我還發(fā)現(xiàn)了一個很“詭異”的邏輯:
if (events.newListener) { target.emit("newListener", type, listener.listener ? listener.listener : listener); events = target._events; } existing = events[type];
仔細分析,他的目的是因為nodeJS默認:當所有的eventEmitter對象在添加新的監(jiān)聽函數(shù)時,都會發(fā)出newListener事件。這其實也并不奇怪,我個人認為這么設計還是非常合理的。
cae4:
之前介紹了我們可以設置一個事件對應的最大監(jiān)聽器個數(shù),nodeJS源碼中通過這樣的代碼來實現(xiàn):
EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) { if (typeof n !== "number" || n < 0 || isNaN(n)) { throw new TypeError(""n" argument must be a positive number"); } this._maxListeners = n; return this; };
當對這個值進行了設置之后,如果超過此閾值,將會進行報警:
if (!existing.warned) { m = $getMaxListeners(target); if (m && m > 0 && existing.length > m) { existing.warned = true; const w = new Error("Possible EventEmitter memory leak detected. " + `${existing.length} ${String(type)} listeners ` + "added. Use emitter.setMaxListeners() to " + "increase limit"); w.name = "MaxListenersExceededWarning"; w.emitter = target; w.type = type; w.count = existing.length; process.emitWarning(w); } }emit發(fā)射器實現(xiàn)
有了之前的注冊監(jiān)聽器過程,那么我們再來看看監(jiān)聽器是如何被觸發(fā)的。其實觸發(fā)過程直觀上并不難理解,核心思想就是將監(jiān)聽器數(shù)組中的每一項,即監(jiān)聽函數(shù)逐個執(zhí)行就好了。
經(jīng)過整理,適當刪減后的源碼同樣可以這里找到。源碼中,包含了較多的錯誤信息處理內(nèi)容,忽略不表。下面我挑出一些“出神入化”的細節(jié)來分析。
首先,有了上面的分析,我們現(xiàn)在可以清晰的意識到某個事件的監(jiān)聽處理可能是一個函數(shù)類型,表示該事件只有一個事件處理程序;也可能是個數(shù)組,表示該事件有多個事件處理程序,存儲在監(jiān)聽器數(shù)組中。(我又啰嗦了一遍,因為理解這個太重要了,不然你會看暈的)
同時,emit方法可以接受多個參數(shù)。第一個參數(shù)為事件類型:type,下面兩行代碼用于獲取某個事件的監(jiān)聽處理類型。用isFn布爾值來表示。
handler = events[type]; var isFn = typeof handler === "function";
isFn為true,表示該事件只有一個監(jiān)聽函數(shù)。否則,存在多個,儲存在數(shù)組中。
源碼中對于emit參數(shù)個數(shù)有判斷,并進行了switch分支處理:
switch (len) { case 1: emitNone(handler, isFn, this); break; case 2: emitOne(handler, isFn, this, arguments[1]); break; case 3: emitTwo(handler, isFn, this, arguments[1], arguments[2]); break; case 4: emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]); break; // slower default: args = new Array(len - 1); for (i = 1; i < len; i++) { args[i - 1] = arguments[i]; } emitMany(handler, isFn, this, args); }
我們挑一個相對最復雜的看一下——默認模式調(diào)用的emitMany:
function emitMany(handler, isFn, self, args) { if (isFn) { handler.apply(self, args); } else { var len = handler.length; var listeners = arrayClone(handler, len); for (var i = 0; i < len; ++i) { listeners[i].apply(self, args); } } }
對于只有一個事件處理程序的情況(isFn為true),直接執(zhí)行:
handler.apply(self, args);
否則,便使用for循環(huán),逐個調(diào)用:
listeners[i].apply(self, args);
非常有意思的一個細節(jié)在于:
var listeners = arrayClone(handler, len);
這里需要讀者細心體會。
源碼讀到這里,我不禁要感嘆設計的嚴謹精妙之處。上面代碼處理的意義在于:防止在一個事件監(jiān)聽器中監(jiān)聽同一個事件,從而導致死循環(huán)的出現(xiàn)。
如果您不理解,且看我這個例子:
let emitter = new eventEmitter; emitter.on("message1", function test () { // some codes here // ... emitter.on("message1", test} }); emit("message1");
講道理,正常來講,不經(jīng)過任何處理,上述代碼在事件處理程序內(nèi)部又添加了對于同一個事件的監(jiān)聽,這必然會帶來死循環(huán)問題。
因為在emit執(zhí)行處理程序的時候,我們又向監(jiān)聽器隊列添加了一項。這一項執(zhí)行時,又會“子子孫孫無窮匱也”的向監(jiān)聽器數(shù)組尾部添加。
源碼中對于這個問題的解決方案是:在執(zhí)行emit方法時,使用arrayClone方法拷貝出另一個一模一樣的數(shù)組,進而執(zhí)行它。這樣一來,當我們在監(jiān)聽器內(nèi)監(jiān)聽同一個事件時,的確給原監(jiān)聽器數(shù)組添加了新的監(jiān)聽函數(shù),但并沒有影響到當前這個被拷貝出來的副本數(shù)組。在循環(huán)中,我們執(zhí)行的也是這個副本函數(shù)。
單次監(jiān)聽器once實現(xiàn)once(event, listener)是為指定事件注冊一個單次事件處理程序,即監(jiān)聽器最多只會觸發(fā)一次,觸發(fā)后立刻解除該監(jiān)聽器。
實現(xiàn)方式主要是在進行監(jiān)聽器綁定時,對于監(jiān)聽函數(shù)進行一層包裝。該包裝方式在原有函數(shù)上添加一個flag標識位,并在觸發(fā)監(jiān)聽函數(shù)前就調(diào)用removeListener()方法,除掉此監(jiān)聽函數(shù)。我理解,這是一種“雙保險”的體現(xiàn)。
代碼里,我們可以抽絲剝繭(已進行刪減)學習一下:
EventEmitter.prototype.once = function once(type, listener) { this.on(type, _onceWrap(this, type, listener)); return this; };
once方法調(diào)用on方法(即addListener方法,on為別名),第二個參數(shù)即監(jiān)聽程序進行_onceWrap化包裝,包裝過程為:
this.target.removeListener(this.type, this.wrapFn); if (!this.fired) { this.fired = true; this.listener.apply(this.target, arguments); }
_onceWrap化的主要思想是將once第二個參數(shù)listener的執(zhí)行,包上了一次判斷,并在執(zhí)行前進行removeListener刪除該監(jiān)聽程序。:
this.listener.apply(this.target, arguments);removeListener的驚鴻一瞥
removeListener(type, listener)移除指定事件的某個監(jiān)聽器。其實這個實現(xiàn)思路也比較容易理解,我們已經(jīng)知道events[type]可能是函數(shù)類型,也可能是數(shù)組類型。如果是數(shù)組類型,只需要進行遍歷,找到相關的監(jiān)聽器進行刪除就可以了。
不過關鍵問題就在于對數(shù)組項的刪除。
平時開發(fā),我們常用splice進行數(shù)組中某一項的刪除,99%的case都會想到這個方法。可是nodeJS相關源碼中,對于刪除進行了優(yōu)化。自己封裝了一個spliceOne方法,用于刪除數(shù)組中指定角標。并且號稱這個方法比使用splice要快1.5倍。我們就來看一下他是如何實現(xiàn)的:
function spliceOne(list, index) { for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) { list[i] = list[k]; } list.pop(); }
傳統(tǒng)刪除方法:
list.splice(index, 1);
究竟是否計算更快,我也實現(xiàn)了一個benchmark,產(chǎn)生長度為1000的數(shù)組,刪除其第52項。反復執(zhí)行1000次求平均耗時:
let arr = Array.from(Array(100).keys()); for (let i = 0; i < 1000; i++) { window.performance.mark("test splice start"); arr.splice(52, 1); window.performance.mark("test splice end"); window.performance.measure("test splice","test splice start","test splice end"); } let sum1 = 0 for (let k = 0; k < 1000; k++) { sum1 +=window.performance.getEntriesByName("test splice")[k].duration } let averge1 = sum1/1000; console.log(averge1*1000); // 1.7749999999869034
let arr = Array.from(Array(100).keys()); for (let i = 0; i < 1000; i++) { window.performance.mark("test splice start"); spliceOne(arr, 52); window.performance.mark("test splice end"); window.performance.measure("test splice","test splice start","test splice end"); } let sum1 = 0 for (let k = 0; k < 1000; k++) { sum1 +=window.performance.getEntriesByName("test splice")[k].duration } let averge1 = sum1/1000; console.log(averge1*1000); // 1.5350000000089494
第一段執(zhí)行時間:1.7749999999869034;
第二段執(zhí)行時間:1.5350000000089494;
明顯使用spliceOne方法更快,時間上縮短了13.5%,不過依然沒有達到官方的1.5,需要說明的是我采用最新版本的Chrome進行測試。
自己造輪子前文我們感受了nodeJS中的eventEmitter實現(xiàn)方式。我也對于其中的核心方法,在源碼層面進行了剖析。學習到了“精華”之后,更重要的要學以致用,自己實現(xiàn)一個基于ES6的事件發(fā)布訂閱系統(tǒng)。
我的實現(xiàn)版本中充分利用了ES6語法特性,并且相對于nodeJS實現(xiàn)減少了一些“不必要的”優(yōu)化和判斷。
因為nodeJS的實現(xiàn)中,很多api在前端瀏覽器環(huán)境開發(fā)中并用不到。所以我對對外暴露的方法進行了精簡。最終實現(xiàn)上,除去注釋部分,只用了不到40行代碼。如果您有興趣,可以去[代碼倉庫]()訪問,整個邏輯還是很簡單的。
里面同時附贈了我同事@顏海鏡大神基于zepto實現(xiàn)版本,以及nodeJS events模塊源碼,方便讀者進行對比。
整個過程編寫時間倉促,其中必然不乏疏漏之處,還請您斧正并與我討論。
對于nodeJS源碼events模塊的閱讀,令我受益匪淺。設計層面上,優(yōu)秀的包裝和抽象思路對我一定的啟發(fā);實現(xiàn)層面上,很多“意想不到”的case處理,讓我“嘆為觀止”。
雖然業(yè)務上暫時使用不到nodeJS,但是對于每一個前端開發(fā)人員來說,這樣的學習我認為是有必要的。今后,我會整理出文章,總結對nodeJS源碼更多模塊的分析,希望同讀者能夠保持交流和探討。
整篇文章里面列出的benchmark,我認為并不完美。同時,對于瀏覽器引擎處理上,我存在知識盲點和漏洞,希望有大神給與斧正。
PS:百度知識搜索部大前端繼續(xù)招兵買馬,有意向者火速聯(lián)系。。。
文章版權歸作者所有,未經(jīng)允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://systransis.cn/yun/82152.html
摘要:插件開發(fā)前端掘金作者原文地址譯者插件是為應用添加全局功能的一種強大而且簡單的方式。提供了與使用掌控異步前端掘金教你使用在行代碼內(nèi)優(yōu)雅的實現(xiàn)文件分片斷點續(xù)傳。 Vue.js 插件開發(fā) - 前端 - 掘金作者:Joshua Bemenderfer原文地址: creating-custom-plugins譯者:jeneser Vue.js插件是為應用添加全局功能的一種強大而且簡單的方式。插....
摘要:平日學習接觸過的網(wǎng)站積累,以每月的形式發(fā)布。年以前看這個網(wǎng)址概況在線地址前端開發(fā)群月報提交原則技術文章新的為主。 平日學習接觸過的網(wǎng)站積累,以每月的形式發(fā)布。2017年以前看這個網(wǎng)址:http://www.kancloud.cn/jsfron... 概況 在線地址:http://www.kancloud.cn/jsfront/month/82796 JS前端開發(fā)群月報 提交原則: 技...
摘要:平日學習接觸過的網(wǎng)站積累,以每月的形式發(fā)布。年以前看這個網(wǎng)址概況在線地址前端開發(fā)群月報提交原則技術文章新的為主。 平日學習接觸過的網(wǎng)站積累,以每月的形式發(fā)布。2017年以前看這個網(wǎng)址:http://www.kancloud.cn/jsfron... 概況 在線地址:http://www.kancloud.cn/jsfront/month/82796 JS前端開發(fā)群月報 提交原則: 技...
摘要:平日學習接觸過的網(wǎng)站積累,以每月的形式發(fā)布。年以前看這個網(wǎng)址概況在線地址前端開發(fā)群月報提交原則技術文章新的為主。 平日學習接觸過的網(wǎng)站積累,以每月的形式發(fā)布。2017年以前看這個網(wǎng)址:http://www.kancloud.cn/jsfron... 概況 在線地址:http://www.kancloud.cn/jsfront/month/82796 JS前端開發(fā)群月報 提交原則: 技...
閱讀 3772·2021-11-24 09:39
閱讀 2971·2021-11-16 11:49
閱讀 2091·2019-08-30 13:54
閱讀 1115·2019-08-30 13:03
閱讀 1102·2019-08-30 11:10
閱讀 732·2019-08-29 17:10
閱讀 1259·2019-08-29 15:04
閱讀 1225·2019-08-29 13:02