解析nodeJS模塊源碼 親手打造基于ES6的觀察者系統

毫無疑問,nodeJS改變了整個前端開發生態。本文通過分析nodeJS當中events模塊源碼,由淺入深,動手實現了屬于自己的ES6事件觀察者系統。千萬不要被nodeJS的外表嚇到,不管你是寫nodeJS已經輕車熟路的老司機,還是初入前端的小菜鳥,都不妨礙對這篇文章的閱讀和理解。

事件驅動設計理念

nodeJS官方介紹中,第二句話便是:

"Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient"。

由此,“事件驅動(event-driven)”理念對nodeJS設計的重要性可見一斑。比如,我們對于文件的讀取,任務隊列的執行等,都需要這樣一個觀察者模式來保障。

那個最熟悉的陌生人

同時,作為前端開發人員,我們對于所謂的“事件驅動”理念——即“事件發布訂閱模式(Pub/Sub模式)”一定再熟悉不過了。這種模式在js里面有與生俱來的基因。我們可以認為JS本身就是事件驅動型語言:
比如,頁面上有一個button, 點擊一下就會觸發上面的click事件。這是因為此時有特定程序正在監聽這個事件,隨之觸發了相關的處理程序。

這個模式最大的一個好處在于能夠解耦,實現“高內聚、低耦合”的理念。那么這樣一個“熟悉的”模式應該怎么實現呢?

其實社區上已經有不少前輩的實現了,但是都不能算特別完美,或者不能完全符合特定的場景需求。

本文通過解析nodeJS源碼中的events模塊,提取其精華,一步步打造了一個基于ES6的eventEmitter系統。

讀者有任何想法,歡迎與我交流。同時希望各路大神給予斧正。

背景簡介

為了方便大家理解,我從一個很簡單的頁面實例說起。

百度某產品頁面中,存在兩處不同的收藏組件:

  • 一處在頁面頂部;
  • 一處在頁面詳情側欄。

第一次點擊一個收藏組件按鈕,發送異步請求,進行收藏,同時請求成功的回調函數里,需要將頁面中所有“收藏”按鈕轉換狀態為“已收藏”。以達到“當前文章”收藏狀態的全局同步。

頁面實例

完成這樣的設計很簡單,我們大可在業務代碼中進行混亂的操作處理,比如初學者常見的做法是:點擊第一處收藏,異步請求之后的回調邏輯里,修改頁面當中所有收藏按鈕狀態。

這樣做的問題在于耦合混亂,不僅僅是一個收藏組件,試想當代碼中所有組件全都是這樣的“隨意”操作,后期維護成本便一發不可收。

我的Github倉庫中,也有對于這么一個頁面實例的分析,讀者若想自己玩一下,可以訪問這里。

當然,更優雅的做法就是使用事件訂閱發布系統。
我們先來看看nodeJS是怎么做的吧!

nodeJS方案

讀者可以自己去nodeJS倉庫查找源碼,不過更推薦參考我的Github-事件發布訂閱研究項目,里面不僅有自己實現的多套基于ES6的事件發布訂閱系統,也“附贈”了nodeJS實現源碼。同時我對源碼加上了漢語注釋,方便大家理解。

在nodeJS中,引入eventEmitter的方式和實例化方法如下:

// 引入 events 模塊
var events = require('events');
// 創建 eventEmitter 對象
var eventEmitter = new events.EventEmitter();

我們要研究的,當然就是這個eventEmitter實例。先不急于深入源碼,我們需要在使用層面先有一個清晰的理解和認知。不然盲目閱讀源碼,便極易成為一只“無頭蒼蠅”。

一個eventEmitter實例,自身包含有四個屬性:

  • _events:
    這是一個object,其實相當于一個哈希map。他用來保存一個eventEmitter實例中所有的注冊事件和事件所對應的處理函數。以鍵值對方式存儲,key為事件名;value分為兩種情況,當當前注冊事件只有一個注冊的監聽函數時,value為這個監聽函數;如果此事件有多個注冊的監聽函數時,value值為一個數組,數組每一項順序存儲了對應此事件的注冊函數。
    需要說明的是,理解value值的這兩種情況,對于后面的源碼分析非常重要。我認為nodeJS之所以有這樣的設計,是出于性能上的考慮。因為很多情況(單一監聽函數情況)并不需要在內存上新建一個額外數組。

  • _eventsCount:整型,表示此eventEmitter實例中注冊的事件個數。

  • _maxListeners:整型,表示此eventEmitter實例中,一個事件最多所能承載的監聽函數個數。

  • domain:在node v0.8+版本的時候,發布了一個模塊:domain。這個模塊做的是捕捉異步回調中出現的異常。這里與主題無關,不做展開。

同樣,eventEmitter實例的構造函數原型上,包含了一些更為重要的屬性和方法,包括但不限于:

  • addListener(event, listener):
    為指定事件添加一個注冊函數(以下稱監聽器)到監聽器數組的尾部。他存在一個別名alias:on。
  • once(event, listener):
    為指定事件注冊一個單次監聽器,即監聽器最多只會觸發一次,觸發后立刻解除該監聽器。
  • removeListener(event, listener):
    移除指定事件的某個監聽器,監聽器必須是該事件已經注冊過的監聽器。
  • removeAllListeners([event]):
    移除所有事件的所有監聽器。如果指定事件,則移除指定事件的所有監聽器。
  • setMaxListeners(n):
    默認情況下,如果你添加的監聽器超過10個就會輸出警告信息。setMaxListeners 函數用于提高監聽器的默認限制的數量。
  • listeners(event):返回指定事件的監聽器數組。
  • emit(event, [arg1], [arg2], [...]):
    按參數的順序執行每個監聽器,如果事件有注冊監聽器返回true,否則返回false。

nodeJS設計之美

上一段其實簡要介紹了nodeJS中eventEmitter的使用方法。下面,我們要做的就是深入nodeJS events模塊源碼,了解并學習他的設計之美。

如何創建空對象?

我們已經了解到,_events是要來儲存監聽事件(key)、監聽器數組(value)的map。那么,他的初始值一定是一個空對象。直觀上,我們可以這樣創建一個空對象:

this._events = {};

但是nodeJS源碼中的實現方式卻是這樣:

function EventHandlers() {};
EventHandlers.prototype = Object.create(null);
this._events = new EventHandlers();

官方稱,這么做的原因是出于性能上的考慮,經過jsperf比較,在v8 v4.9版本中,后者性能有超出2倍的表現。

對此,作為一個“吹毛求疵”有態度的程序員,我寫了一個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);
  • 第一段執行時間:111.86000000001695;
  • 第二段執行時間:108.37000000001353;

多執行幾次會發現,第一段也存在時間上短于第二段執行時間的情況。總體來看,第二段時間上更短,但兩次時間比較相近。

我自己的想法是,使用nodeJS源碼中這樣創建空對象的方式,在對對象屬性的讀取上能夠節省原型鏈查找的時間。但是,如果一個屬性直接在該對象上,即hasOwnProperty()為true,是否還有節省查找時間,性能優化的空間呢?

另外,不同瀏覽器引擎的處理可能也存在差別,即使是流行的V8引擎,處理機制也“深不可測”。同時,benchmark中都是對同一屬性的讀取,一般來講瀏覽器引擎對同樣的操作行為應該會有一個“cache”機制:據我了解JIT(just-in-time)實時匯編,會將重復執行的"hot code"編譯為本地機器碼,極大增加效率。所以benchmark實現的purity也有被一定程度的干擾。不過好在測試實例都是在相同環境下執行。

所以源碼中,此處性能優化上的2倍數值,我持一定的保留態度。

addListener實現

經過整理,適當刪減后的源碼點擊這里查看,保留了我的注釋。我們來一步一步解讀下源碼。

判斷添加的監聽器是否為函數類型,使用了typeof進行驗證:

if (typeof listener !== 'function') {
    throw new TypeError('"listener" argument must be a function');
}

接下來,要分為幾種情況。
case1:
判斷_events表是否已經存在,如果不存在,則說明是第一次為eventEmitter實例添加事件和監聽器,需要新創建_events:

if (!events) {
    events = target._events = new EventHandlers();
    target._eventsCount = 0;
} 

還記得EventHandlers是什么嗎?忘記了把屏幕往上滾動再看一下吧。

同時,添加指定的事件和此事件對應的監聽器:

existing = events[type] = listener;
++target._eventsCount;

注意第一次創建時,為了節省內存,提高性能,events[type]值是一個監聽器函數。如果再次為相同的events[type]添加監聽器時(下面case2),events[type]對應的值需要變成一個數組來存儲。

case2:
又啰嗦一遍:如果_events已存在,在為相關事件添加監聽器時,需要判斷events[type]是函數類型(只存在一個監聽函數)還是已經成為了一個數組類型(已經存在一個以上監聽函數)。
并且根據相關參數prepend,分為監聽器數組頭部插入和尾部插入兩種情況,以保證監聽器的順序執行:

if (typeof existing === 'function') {
    existing = events[type] = prepend ? [listener, existing] :
                                      [existing, listener];
} 
else {
    if (prepend) {
        existing.unshift(listener);
    } 
    else {
        existing.push(listener);
    }
}

case3:
在閱讀源碼時,我還發現了一個很“詭異”的邏輯:

 if (events.newListener) {
    target.emit('newListener', type,
              listener.listener ? listener.listener : listener);
    events = target._events;
}
existing = events[type];

仔細分析,他的目的是因為nodeJS默認:當所有的eventEmitter對象在添加新的監聽函數時,都會發出newListener事件。這其實也并不奇怪,我個人認為這么設計還是非常合理的。

cae4:
之前介紹了我們可以設置一個事件對應的最大監聽器個數,nodeJS源碼中通過這樣的代碼來實現:

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發射器實現

有了之前的注冊監聽器過程,那么我們再來看看監聽器是如何被觸發的。其實觸發過程直觀上并不難理解,核心思想就是將監聽器數組中的每一項,即監聽函數逐個執行就好了。

經過整理,適當刪減后的源碼同樣可以這里找到。源碼中,包含了較多的錯誤信息處理內容,忽略不表。下面我挑出一些“出神入化”的細節來分析。

首先,有了上面的分析,我們現在可以清晰的意識到某個事件的監聽處理可能是一個函數類型,表示該事件只有一個事件處理程序;也可能是個數組,表示該事件有多個事件處理程序,存儲在監聽器數組中。(我又啰嗦了一遍,因為理解這個太重要了,不然你會看暈的)

同時,emit方法可以接受多個參數。第一個參數為事件類型:type,下面兩行代碼用于獲取某個事件的監聽處理類型。用isFn布爾值來表示。

handler = events[type];
var isFn = typeof handler === 'function';

isFn為true,表示該事件只有一個監聽函數。否則,存在多個,儲存在數組中。

源碼中對于emit參數個數有判斷,并進行了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);
}

我們挑一個相對最復雜的看一下——默認模式調用的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),直接執行:

handler.apply(self, args);

否則,便使用for循環,逐個調用:

listeners[i].apply(self, args);

非常有意思的一個細節在于:

var listeners = arrayClone(handler, len);

這里需要讀者細心體會。

源碼讀到這里,我不禁要感嘆設計的嚴謹精妙之處。上面代碼處理的意義在于:防止在一個事件監聽器中監聽同一個事件,從而導致死循環的出現。
如果您不理解,且看我這個例子:

let emitter = new eventEmitter;
emitter.on('message1', function test () {
    // some codes here
    // ...
    emitter.on('message1', test}
});
emit('message1');

講道理,正常來講,不經過任何處理,上述代碼在事件處理程序內部又添加了對于同一個事件的監聽,這必然會帶來死循環問題。
因為在emit執行處理程序的時候,我們又向監聽器隊列添加了一項。這一項執行時,又會“子子孫孫無窮匱也”的向監聽器數組尾部添加。

源碼中對于這個問題的解決方案是:在執行emit方法時,使用arrayClone方法拷貝出另一個一模一樣的數組,進而執行它。這樣一來,當我們在監聽器內監聽同一個事件時,的確給原監聽器數組添加了新的監聽函數,但并沒有影響到當前這個被拷貝出來的副本數組。在循環中,我們執行的也是這個副本函數。

單次監聽器once實現

once(event, listener)是為指定事件注冊一個單次事件處理程序,即監聽器最多只會觸發一次,觸發后立刻解除該監聽器。

實現方式主要是在進行監聽器綁定時,對于監聽函數進行一層包裝。該包裝方式在原有函數上添加一個flag標識位,并在觸發監聽函數前就調用removeListener()方法,除掉此監聽函數。我理解,這是一種“雙保險”的體現。

代碼里,我們可以抽絲剝繭(已進行刪減)學習一下:

 EventEmitter.prototype.once = function once(type, listener) {
    this.on(type, _onceWrap(this, type, listener));
    return this;
};

once方法調用on方法(即addListener方法,on為別名),第二個參數即監聽程序進行_onceWrap化包裝,包裝過程為:

this.target.removeListener(this.type, this.wrapFn);
if (!this.fired) {
    this.fired = true;
    this.listener.apply(this.target, arguments);
}

_onceWrap化的主要思想是將once第二個參數listener的執行,包上了一次判斷,并在執行前進行removeListener刪除該監聽程序。:

 this.listener.apply(this.target, arguments);

removeListener的驚鴻一瞥

removeListener(type, listener)移除指定事件的某個監聽器。其實這個實現思路也比較容易理解,我們已經知道events[type]可能是函數類型,也可能是數組類型。如果是數組類型,只需要進行遍歷,找到相關的監聽器進行刪除就可以了。

不過關鍵問題就在于對數組項的刪除。

平時開發,我們常用splice進行數組中某一項的刪除,99%的case都會想到這個方法。可是nodeJS相關源碼中,對于刪除進行了優化。自己封裝了一個spliceOne方法,用于刪除數組中指定角標。并且號稱這個方法比使用splice要快1.5倍。我們就來看一下他是如何實現的:

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();
}

傳統刪除方法:

list.splice(index, 1);

究竟是否計算更快,我也實現了一個benchmark,產生長度為1000的數組,刪除其第52項。反復執行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
  • 第一段執行時間:1.7749999999869034;
  • 第二段執行時間:1.5350000000089494;

明顯使用spliceOne方法更快,時間上縮短了13.5%,不過依然沒有達到官方的1.5,需要說明的是我采用最新版本的Chrome進行測試。

自己造輪子

前文我們感受了nodeJS中的eventEmitter實現方式。我也對于其中的核心方法,在源碼層面進行了剖析。學習到了“精華”之后,更重要的要學以致用,自己實現一個基于ES6的事件發布訂閱系統。

我的實現版本中充分利用了ES6語法特性,并且相對于nodeJS實現減少了一些“不必要的”優化和判斷。

因為nodeJS的實現中,很多api在前端瀏覽器環境開發中并用不到。所以我對對外暴露的方法進行了精簡。最終實現上,除去注釋部分,只用了不到40行代碼。如果您有興趣,可以去代碼倉庫訪問,整個邏輯還是很簡單的。

里面同時附贈了我同事@顏海鏡大神基于zepto實現版本,以及nodeJS events模塊源碼,方便讀者進行對比。
整個過程編寫時間倉促,其中必然不乏疏漏之處,還請您斧正并與我討論。

總結

對于nodeJS源碼events模塊的閱讀,令我受益匪淺。設計層面上,優秀的包裝和抽象思路對我一定的啟發;實現層面上,很多“意想不到”的case處理,讓我“嘆為觀止”。

雖然業務上暫時使用不到nodeJS,但是對于每一個前端開發人員來說,這樣的學習我認為是有必要的。今后,我會整理出文章,總結對nodeJS源碼更多模塊的分析,希望同讀者能夠保持交流和探討。

整篇文章里面列出的benchmark,我認為并不完美。同時,對于瀏覽器引擎處理上,我存在知識盲點和漏洞,希望有大神給與斧正。

PS:百度知識搜索部大前端繼續招兵買馬,有意向者火速聯系。。。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容

  • https://nodejs.org/api/documentation.html 工具模塊 Assert 測試 ...
    KeKeMars閱讀 6,356評論 0 6
  • 本學習筆記是根據《Node.js開發指南》一書進行學習。 全局對象 JavaScript中有一個特殊的對象,稱為全...
    秋意思寒閱讀 1,376評論 0 2
  • 內容來自《Node.js開發指南》 核心模塊是 Node.js 的心臟,它由一些精簡而高效的庫組成,為 Node....
    angelwgh閱讀 907評論 0 1
  • Node.js EventEmitter Node.js 所有的異步 I/O 操作在完成時都會發送一個事件到事件隊...
    FTOLsXD閱讀 324評論 1 2
  • 近日有位親戚送了一箱泡面給我,差不多四年沒有吃「出前一丁」,忽然發現忘了味道,便泡了一包吃。嘗到第一口的時候發現和...
    Ubuay閱讀 559評論 6 1