摘要:我們模擬這種模式的時候也構建一個類,名字就叫,在使用時同框架類似,需要通過指令創建的實例并傳入。文本節點的內容有可能存在,正則匹配默認是貪婪的,為了防止第一個和最后一個進行匹配,所以在正則表達式中應使用非貪婪匹配。
MVVM 設計模式,是由 MVC(最早來源于后端)、MVP 等設計模式進化而來,M - 數據模型(Model),VM - 視圖模型(ViewModel),V - 視圖層(View)。
在 MVC 模式中,除了 Model 和 View 層以外,其他所有的邏輯都在 Controller 中,Controller 負責顯示頁面、響應用戶操作、網絡請求及與 Model 的交互,隨著業務的增加和產品的迭代,Controller 中的處理邏輯越來越多、越來越復雜,難以維護。為了更好的管理代碼,為了更方便的擴展業務,必須要為 Controller “瘦身”,需要更清晰的將用戶界面(UI)開發從應用程序的業務邏輯與行為中分離,MVVM 為此而生。
很多 MVVM 的實現都是通過數據綁定來將 View 的邏輯從其他層分離,可以用下圖來簡略的表示:
使用 MVVM 設計模式的前端框架很多,其中漸進式框架 Vue 是典型的代表,并在開發使用中深得廣大前端開發者的青睞,我們這篇就根據 Vue 對于 MVVM 的實現方式來簡單模擬一版 MVVM 庫。
在 Vue 的 MVVM 設計中,我們主要針對 Compile(模板編譯)、Observer(數據劫持)、Watcher(數據監聽)和 Dep(發布訂閱)幾個部分來實現,核心邏輯流程可參照下圖:
類似這種 “造輪子” 的代碼毋庸置疑一定是通過面向對象編程來實現的,并嚴格遵循開放封閉原則,由于 ES5 的面向對象編程比較繁瑣,所以,在接下來的代碼中統一使用 ES6 的 class 來實現。
在 Vue 中,對外只暴露了一個名為 Vue 的構造函數,在使用的時候 new 一個 Vue 實例,然后傳入了一個 options 參數,類型為一個對象,包括當前 Vue 實例的作用域 el、模板綁定的數據 data 等等。
我們模擬這種 MVVM 模式的時候也構建一個類,名字就叫 MVVM,在使用時同 Vue 框架類似,需要通過 new 指令創建 MVVM 的實例并傳入 options。
// 文件:MVVM.js class MVVM { constructor(options) { // 先把 el 和 data 掛在 MVVM 實例上 this.$el = options.el; this.$data = options.data; // 如果有要編譯的模板就開始編譯 if (this.$el) { // 數據劫持,就是把對象所有的屬性添加 get 和 set new Observer(this.$data); // 將數據代理到實例上 this.proxyData(this.$data); // 用數據和元素進行編譯 new Compile(this.el, this); } } proxyData(data) { // 代理數據的方法 Object.keys(data).forEach(key => { Object.defineProperty(this, key, { get() { return data[key]; } set(newVal) { data[key] = newVal; } }); }); } }
通過上面代碼,我們可以看出,在我們 new 一個 MVVM 的時候,在參數 options 中傳入了一個 Dom 的根元素節點和數據 data 并掛在了當前的 MVVM 實例上。
當存在根節點的時候,通過 Observer 類對 data 數據進行了劫持,并通過 MVVM 實例的方法 proxyData 把 data 中的數據掛在當前 MVVM 實例上,同樣對數據進行了劫持,是因為我們在獲取和修改數據的時候可以直接通過 this 或 this.$data,在 Vue 中實現數據劫持的核心方法是 Object.defineProperty,我們也使用這個方式通過添加 getter 和 setter 來實現數據劫持。
最后使用 Compile 類對模板和綁定的數據進行了解析和編譯,并渲染在根節點上,之所以數據劫持和模板解析都使用類的方式實現,是因為代碼方便維護和擴展,其實不難看出,MVVM 類其實作為了 Compile 類和 Observer 類的一個橋梁。
Compile 類在創建實例的時候需要傳入兩個參數,第一個參數是當前 MVVM 實例作用的根節點,第二個參數就是 MVVM 實例,之所以傳入 MVVM 的實例是為了更方便的獲取 MVVM 實例上的屬性。
在 Compile 類中,我們會盡量的把一些公共的邏輯抽取出來進行最大限度的復用,避免冗余代碼,提高維護性和擴展性,我們把 Compile 類抽取出的實例方法主要分為兩大類,輔助方法和核心方法,在代碼中用注釋標明。
1、解析根節點內的 Dom 結構// 文件:Compile.js class Compile { constructor(el, vm) { this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 如過傳入的根元素存在,才開始編譯 if (this.el) { // 1、把這些真實的 Dom 移動到內存中,即 fragment(文檔碎片) let fragment = this.node2fragment(this.el); } } /* 輔助方法 */ // 判斷是否是元素節點 isElementNode(node) { return node.nodeType === 1; } /* 核心方法 */ // 將根節點轉移至文檔碎片 node2fragment(el) { // 創建文檔碎片 let fragment = document.createDocumentFragment(); // 第一個子節點 let firstChild; // 循環取出根節點中的節點并放入文檔碎片中 while (firstChild = el.firstChild) { fragment.appendChild(firstChild); } return fragment; } }
上面編譯模板的過程中,前提條件是必須存在根元素節點,傳入的根元素節點允許是一個真實的 Dom 元素,也可以是一個選擇器,所以我們創建了輔助方法 isElementNode 來幫我們判斷傳入的元素是否是 Dom,如果是就直接使用,是選擇器就獲取這個 Dom,最終將這個根節點存入 this.el 屬性中。
解析模板的過程中為了性能,我們應取出根節點內的子節點存放在文檔碎片中(內存),需要注意的是將一個 Dom 節點內的子節點存入文檔碎片的過程中,會在原來的 Dom 容器中刪除這個節點,所以在遍歷根節點的子節點時,永遠是將第一個節點取出存入文檔碎片,直到節點不存在為止。
2、編譯文檔碎片中的結構在 Vue 中的模板編譯的主要就是兩部分,也是瀏覽器無法解析的部分,元素節點中的指令和文本節點中的 Mustache 語法(雙大括號)。
// 文件:Compile.js —— 完善 class Compile { constructor(el, vm) { this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 如過傳入的根元素存在,才開始編譯 if (this.el) { // 1、把這些真實的 Dom 移動到內存中,即 fragment(文檔碎片) let fragment = this.node2fragment(this.el); // ********** 以下為新增代碼 ********** // 2、將模板中的指令中的變量和 {{}} 中的變量替換成真實的數據 this.compile(fragment); // 3、把編譯好的 fragment 再塞回頁面中 this.el.appendChild(fragment); // ********** 以上為新增代碼 ********** } } /* 輔助方法 */ // 判斷是否是元素節點 isElementNode(node) { return node.nodeType === 1; } // ********** 以下為新增代碼 ********** // 判斷屬性是否為指令 isDirective(name) { return name.includes("v-"); } // ********** 以上為新增代碼 ********** /* 核心方法 */ // 將根節點轉移至文檔碎片 node2fragment(el) { // 創建文檔碎片 let fragment = document.createDocumentFragment(); // 第一個子節點 let firstChild; // 循環取出根節點中的節點并放入文檔碎片中 while (firstChild = el.firstChild) { fragment.appendChild(firstChild); } return fragment; } // ********** 以下為新增代碼 ********** // 解析文檔碎片 compile(fragment) { // 當前父節點節點的子節點,包含文本節點,類數組對象 let childNodes = fragment.childNodes; // 轉換成數組并循環判斷每一個節點的類型 Array.from(childNodes).forEach(node => { if (this.isElementNode(node)) { // 是元素節點 // 遞歸編譯子節點 this.compile(node); // 編譯元素節點的方法 this.compileElement(node); } else { // 是文本節點 // 編譯文本節點的方法 this.compileText(node); } }); } // 編譯元素 compileElement(node) { // 取出當前節點的屬性,類數組 let attrs = node.attributes; Array.form(attrs).forEach(attr => { // 獲取屬性名,判斷屬性是否為指令,即含 v- let attrName = attr.name; if (this.isDirective(attrName)) { // 如果是指令,取到該屬性值得變量在 data 中對應得值,替換到節點中 let exp = attr.value; // 取出方法名 let [, type] = attrName.split("-"); // 調用指令對應得方法 CompileUtil[type](node, this.vm, exp); } }); } // 編譯文本 compileText(node) { // 獲取文本節點的內容 let exp = node.contentText; // 創建匹配 {{}} 的正則表達式 let reg = /{{([^}+])}}/g; // 如果存在 {{}} 則使用 text 指令的方法 if (reg.test(exp)) { CompileUtil["text"](node, this.vm, exp); } } // ********** 以上為新增代碼 ********** }
上面代碼新增內容得主要邏輯就是做了兩件事:
調用 compile 方法對 fragment 文檔碎片進行編譯,即替換內部指令和 Mustache 語法中變量對應的值;
將編譯好的 fragment 文檔碎片塞回根節點。
在第一個步驟當中邏輯是比較繁瑣的,首先在 compile 方法中獲取所有的子節點,循環進行編譯,如果是元素節點需要遞歸 compile,傳入當前元素節點。在這個過程當中抽取出了兩個方法,compileElement 和 compileText 用來對元素節點的屬性和文本節點進行處理。
compileElement 中的核心邏輯就是處理指令,取出元素節點所有的屬性判斷是否是指令,是指令則調用指令對應的方法。compileText 中的核心邏輯就是取出文本的內容通過正則表達式匹配出被 Mustache 語法的 “{{ }}” 包裹的內容,并調用處理文本的 text 方法。
文本節點的內容有可能存在 “{{ }} {{ }} {{ }}”,正則匹配默認是貪婪的,為了防止第一個 “{” 和最后一個 “}” 進行匹配,所以在正則表達式中應使用非貪婪匹配。
在調用指令的方法時都是調用的 CompileUtil 下對應的方法,我們之所以多帶帶把這些指令對應的方法抽離出來存儲在 CompileUtil 對象下的目的是為了解耦,因為后面其他的類還要使用。
3、CompileUtil 對象中指令方法的實現CompileUtil 中存儲著所有的指令方法及指令對應的更新方法,由于 Vue 的指令很多,我們這里只實現比較典型的 v-model 和 “{{ }}” 對應的方法,考慮到后續更新的情況,我們統一把設置值到 Dom 中的邏輯抽取出對應上面兩種情況的方法,存放到 CompileUtil 的 updater 對象中。
// 文件:CompileUtil.js CompileUtil = {}; // 更新節點數據的方法 CompileUti.updater = { // 文本更新 textUpdater(node, value) { node.textContent = value; }, // 輸入框更新 modelUpdater(node, value) { node.value = value; } };
這部分的整個思路就是在 Compile 編譯模板后處理 v-model 和 “{{ }}” 時,其實都是用 data 中的數據替換掉 fragment 文檔碎片中對應的節點中的變量。因此會經常性的獲取 data 中的值,在更新節點時又會重新設置 data 中的值,所以我們抽離出了三個方法 getVal、getTextVal 和 setVal 掛在了 CompileUtil 對象下。
// 文件:CompileUtil.js —— 取值方法 // 獲取 data 值的方法 CompileUtil.getVal = function (vm, exp) { // 將匹配的值用 . 分割開,如 vm.data.a.b exp = exp.split("."); // 歸并取值 return exp.reduce((prev, next) => { return prev[next]; }, vm.$data); }; // 獲取文本 {{}} 中變量在 data 對應的值 CompileUtil.getTextVal = function (vm, exp) { // 使用正則匹配出 {{ }} 間的變量名,再調用 getVal 獲取值 return exp.replace(/{{([^}]+)}}/g, (...args) => { return this.getVal(vm, args[1]); }); }; // 設置 data 值的方法 CompileUtil.setVal = function (vm, exp, newVal) { exp = exp.split("."); return exp.reduce((prev, next, currentIndex) => { // 如果當前歸并的為數組的最后一項,則將新值設置到該屬性 if(currentIndex === exp.length - 1) { return prev[next] = newVal } // 繼續歸并 return prev[next]; }, vm.$data); }
獲取和設置 data 的值兩個方法 getVal 和 setVal 思路相似,由于獲取的變量層級不定,可能是 data.a,也可能是 data.obj.a.b,所以都是使用歸并的思路,借用 reduce 方法實現的,區別在于 setVal 方法在歸并過程中需要判斷是不是歸并到最后一級,如果是則設置新值,而 getTextVal 就是在 getVal 外包了一層處理 “{{ }}” 的邏輯。
在這些準備工作就緒以后就可以實現我們的主邏輯,即對 Compile 類中解析的文本節點和元素節點指令中的變量用 data 值進行替換,還記得前面說針對 v-model 和 “{{ }}” 進行處理,因此設計了 model 和 text 兩個核心方法。
CompileUtil.model 方法的實現:
// 文件:CompileUtil.js —— model 方法 // 處理 v-model 指令的方法 CompileUtil.model = function (node, vm, exp) { // 獲取賦值的方法 let updateFn = this.updater["modelUpdater"]; // 獲取 data 中對應的變量的值 let value = this.getVal(vm, exp); // 添加觀察者,作用與 text 方法相同 new Watcher(vm, exp, newValue => { updateFn && updateFn(node, newValue); }); // v-model 雙向數據綁定,對 input 添加事件監聽 node.addEventListener("input", e => { // 獲取輸入的新值 let newValue = e.target.value; // 更新到節點 this.setVal(vm, exp, newValue); }); // 第一次設置值 updateFn && updateFn(vm, value); };
CompileUtil.text 方法的實現:
// 文件:CompileUtil.js —— text 方法 // 處理文本節點 {{}} 的方法 CompileUtil.text = function (node, vm, exp) { // 獲取賦值的方法 let updateFn = this.updater["textUpdater"]; // 獲取 data 中對應的變量的值 let value = this.getTextVal(vm, exp); // 通過正則替換,將取到數據中的值替換掉 {{ }} exp.replace(/{{([^}]+)}}/g, (...args) => { // 解析時遇到了模板中需要替換為數據值的變量時,應該添加一個觀察者 // 當變量重新賦值時,調用更新值節點到 Dom 的方法 new Watcher(vm, arg[1], newValue => { // 如果數據發生變化,重新獲取新值 updateFn && updateFn(node, newValue); }); }); // 第一次設置值 updateFn && updateFn(vm, value); };
上面兩個方法邏輯相似,都獲取了各自的 updater 中的方法,對值進行設置,并且在設置的同時為了后續 data 中的數據修改,視圖的更新,創建了 Watcher 的實例,并在內部用新值重新更新節點,不同的是 Vue 的 v-model 指令在表單中實現了雙向數據綁定,只要表單元素的 value 值發生變化,就需要將新值更新到 data 中,并響應到頁面上。
所以我們的實現方式是給這個綁定了 v-model 的表單元素監聽了 input 事件,并在事件中實時的將新的 value 值更新到 data 中,至于 data 中的改變后響應到頁面中需要另外三個類 Watcher、Observer 和 Dep 共同實現,我們下面就來實現 Watcher 類。
在 CompileUtil 對象的方法中創建 Watcher 實例的時候傳入了三個參數,即 MVVM 的實例、模板綁定數據的變量名 exp 和一個 callback,這個 callback 內部邏輯是為了更新數據到 Dom,所以我們的 Watcher 類內部要做的事情就清晰了,獲取更改前的值存儲起來,并創建一個 update 實例方法,在值被更改時去執行實例的 callback 以達到視圖的更新。
// 文件:Watcher.js class Watcher { constructor(vm, exp, callback) { this.vm = vm; this.exp = exp; this.callback = callback; // 更改前的值 this.value = this.get(); } get() { // 將當前的 watcher 添加到 Dep 類的靜態屬性上 Dep.target = this; // 獲取值觸發數據劫持 let value = CompileUtil.getVal(this.vm, this.exp); // 清空 Dep 上的 Watcher,防止重復添加 Dep.target = null; return value; } update() { // 獲取新值 let newValue = CompileUtil.getVal(this.vm, this.exp); // 獲取舊值 let oldValue = this.value; // 如果新值和舊值不相等,就執行 callback 對 dom 進行更新 if(newValue !== oldValue) { this.callback(); } } }
看到上面代碼一定有兩個疑問:
使用 get 方法獲取舊值得時候為什么要將當前的實例掛在 Dep 上,在獲取值后為什么又清空了;
update 方法內部執行了 callback 函數,但是 update 在什么時候執行。
這就是后面兩個類 Dep 和 observer 要做的事情,我們首先來介紹 Dep,再介紹 Observer 最后把他們之間的關系整個串聯起來。
其實發布訂閱說白了就是把要執行的函數統一存儲在一個數組中管理,當達到某個執行條件時,循環這個數組并執行每一個成員。
// 文件:Dep.js class Dep { constructor() { this.subs = []; } // 添加訂閱 addSub(watcher) { this.subs.push(watcher); } // 通知 notify() { this.subs.forEach(watcher => watcher.update()); } }
在 Dep 類中只有一個屬性,就是一個名為 subs 的數組,用來管理每一個 watcher,即 Watcher 類的實例,而 addSub 就是用來將 watcher 添加到 subs 數組中的,我們看到 notify 方法就解決了上面的一個疑問,Watcher 類的 update 方法是怎么執行的,就是這樣循環執行的。
接下來我們整合一下盲點:
Dep 實例在哪里創建聲明,又是在哪里將 watcher 添加進 subs 數組的;
Dep 的 notify 方法應該在哪里調用;
Watcher 內容中,使用 get 方法獲取舊值得時候為什么要將當前的實例掛在 Dep 上,在獲取值后為什么又清空了。
這些問題在最后一個類 Observer 實現的時候都將清晰,下面我們重點來看最后一部分核心邏輯。
還記得實現 MVVM 類的時候就創建了這個類的實例,當時傳入的參數是 MVVM 實例的 data 屬性,在 MVVM 中把數據通過 Object.defineProperty 掛到了實例上,并添加了 getter 和 setter,其實 Observer 類主要目的就是給 data 內的所有層級的數據都進行這樣的操作。
// 文件:Observer.js class Observer { constructor (data) { this.observe(data); } // 添加數據監聽 observe(data) { // 驗證 data if(!data || typeof data !== "object") { return; } // 要對這個 data 數據將原有的屬性改成 set 和 get 的形式 // 要將數據一一劫持,先獲取到 data 的 key 和 value Object.keys(data).forEach(key => { // 劫持(實現數據響應式) this.defineReactive(data, key, data[key]); this.observe(data[key]); // 深度劫持 }); } // 數據響應式 defineReactive (object, key, value) { let _this = this; // 每個變化的數據都會對應一個數組,這個數組是存放所有更新的操作 let dep = new Dep(); // 獲取某個值被監聽到 Object.defineProperty(object, key, { enumerable: true, configurable: true, get () { // 當取值時調用的方法 Dep.target && dep.addSub(Dep.target); return value; }, set (newValue) { // 當給 data 屬性中設置的值適合,更改獲取的屬性的值 if(newValue !== value) { _this.observe(newValue); // 重新賦值如果是對象進行深度劫持 value = newValue; dep.notify(); // 通知所有人數據更新了 } } }); } }
在的代碼中 observe 的目的是遍歷對象,在內部對數據進行劫持,即添加 getter 和 setter,我們把劫持的邏輯多帶帶抽取成 defineReactive 方法,需要注意的是 observe 方法在執行最初就對當前的數據進行了數據類型驗證,然后再循環對象每一個屬性進行劫持,目的是給同為 Object 類型的子屬性遞歸調用 observe 進行深度劫持。
在 defineReactive 方法中,創建了 Dep 的實例,并對 data 的數據使用 get 和 set 進行劫持,還記得在模板編譯的過程中,遇到模板中綁定的變量,就會解析,并創建 watcher,會在 Watcher 類的內部獲取舊值,即當前的值,這樣就觸發了 get,在 get 中就可以將這個 watcher 添加到 Dep 的 subs 數組中進行統一管理,因為在代碼中獲取 data 中的值操作比較多,會經常觸發 get,我們又要保證 watcher 不會被重復添加,所以在 Watcher 類中,獲取舊值并保存后,立即將 Dep.target 賦值為 null,并且在觸發 get 時對 Dep.target 進行了短路操作,存在才調用 Dep 的 addSub 進行添加。
而 data 中的值被更改時,會觸發 set,在 set 中做了性能優化,即判斷重新賦的值與舊值是否相等,如果相等就不重新渲染頁面,不等的情況有兩種,如果原來這個被改變的值是基本數據類型沒什么影響,如果是引用類型,我們需要對這個引用類型內部的數據進行劫持,因此遞歸調用了 observe,最后調用 Dep 的 notify 方法進行通知,執行 notify 就會執行 subs 中所有被管理的 watcher 的 update,就會執行創建 watcher 時的傳入的 callback,就會更新頁面。
在 MVVM 類將 data 的屬性掛在 MVVM 實例上并劫持與通過 Observer 類對 data 的劫持還有一層聯系,因為整個發布訂閱的邏輯都是在 data 的 get 和 set 上,只要觸發了 MVVM 中的 get 和 set 內部會自動返回或設置 data 對應的值,就會觸發 data 的 get 和 set,就會執行發布訂閱的邏輯。
通過上面長篇大論的敘述后,這個 MVVM 模式用到的幾個類的關系應該完全敘述清晰了,雖然比較抽象,但是細心琢磨還是會明白之間的關系和邏輯,下面我們就來對我們自己實現的這個 MVVM 進行驗證。
我們按照 Vue 的方式根據自己的 MVVM 實現的內容簡單的寫了一個模板如下:
MVVM {{message}}{{message}}
- {{message}}
打開 Chrom 瀏覽器的控制臺,在上面通過下面操作來驗證:
輸入 vm.message = "hello" 看頁面是否更新;
輸入 vm.$data.message = "hello" 看頁面是否更新;
改變文本輸入框內的值,看頁面的其他元素是否更新。
通過上面的測試,相信應該理解了 MVVM 模式對于前端開發重大的意義,實現了雙向數據綁定,實時保證 View 層與 Model 層的數據同步,并可以讓我們在開發時基于數據編程,而最少的操作 Dom,這樣大大提高了頁面渲染的性能,也可以使我們把更多的精力用于業務邏輯的開發上。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/98285.html
摘要:先說下我面試情況,我一共面試了家公司。篇在我面試的眾多公司里,只有同城的面問到相關問題,其他公司壓根沒問。我自己回答的是自己開發組件面臨的問題。完全不用擔心對方到時候打電話核對的問題。 2019的5月9號,離發工資還有1天的時候,我的領導親切把我叫到辦公室跟我說:阿郭,我們公司要倒閉了,錢是沒有的啦,為了不耽誤你,你趕緊出去找工作吧。聽到這話,我虎軀一震,這已經是第2個月沒工資了。 公...
摘要:看這篇之前,如果沒有看過之前的文章,移步拉到文章末尾查看之前的文章。而該組件實例的父實例卻并不固定,所以我們將這些在使用時才能確定的參數在組件實例化的時候傳入。系列文章地址優化優化總結 看這篇之前,如果沒有看過之前的文章,移步拉到文章末尾查看之前的文章。 前言 在上一步,我們實現 extend 方法,用于擴展 Vue 類,而我們知道子組件需要通過 extend 方法來實現,我們從測試例...
摘要:五六月份推薦集合查看最新的請點擊集前端最近很火的框架資源定時更新,歡迎一下。蘇幕遮燎沈香宋周邦彥燎沈香,消溽暑。鳥雀呼晴,侵曉窺檐語。葉上初陽乾宿雨,水面清圓,一一風荷舉。家住吳門,久作長安旅。五月漁郎相憶否。小楫輕舟,夢入芙蓉浦。 五、六月份推薦集合 查看github最新的Vue weekly;請::點擊::集web前端最近很火的vue2框架資源;定時更新,歡迎 Star 一下。 蘇...
摘要:五六月份推薦集合查看最新的請點擊集前端最近很火的框架資源定時更新,歡迎一下。蘇幕遮燎沈香宋周邦彥燎沈香,消溽暑。鳥雀呼晴,侵曉窺檐語。葉上初陽乾宿雨,水面清圓,一一風荷舉。家住吳門,久作長安旅。五月漁郎相憶否。小楫輕舟,夢入芙蓉浦。 五、六月份推薦集合 查看github最新的Vue weekly;請::點擊::集web前端最近很火的vue2框架資源;定時更新,歡迎 Star 一下。 蘇...
閱讀 2785·2021-11-04 16:15
閱讀 3458·2021-09-29 09:35
閱讀 4032·2021-09-22 15:45
閱讀 1417·2019-08-30 15:55
閱讀 1689·2019-08-30 15:44
閱讀 2711·2019-08-29 12:56
閱讀 2697·2019-08-26 13:30
閱讀 2169·2019-08-23 17:00