摘要:最近新開了一個項目,采用來開發,在數據庫及路由管理方面用了不少的裝飾器,發覺這的確是一個好東西。在中的使用該裝飾器會在定義前調用,如果函數有返回值,則會認為是一個新的構造函數來替代之前的構造函數。函數參數裝飾器最后,還有一個用于函數參
最近新開了一個Node項目,采用TypeScript來開發,在數據庫及路由管理方面用了不少的裝飾器,發覺這的確是一個好東西。
裝飾器是一個還處于草案中的特性,目前木有直接支持該語法的環境,但是可以通過 babel 之類的進行轉換為舊語法來實現效果,所以在TypeScript中,可以放心的使用@Decorator。
裝飾器是對類、函數、屬性之類的一種裝飾,可以針對其添加一些額外的行為。
通俗的理解可以認為就是在原有代碼外層包裝了一層處理邏輯。
個人認為裝飾器是一種解決方案,而并非是狹義的@Decorator,后者僅僅是一個語法糖罷了。
裝飾器在身邊的例子隨處可見,一個簡單的例子,水龍頭上邊的起泡器就是一個裝飾器,在裝上以后就會把空氣混入水流中,摻雜很多泡泡在水里。
但是起泡器安裝與否對水龍頭本身并沒有什么影響,即使拆掉起泡器,也會照樣工作,水龍頭的作用在于閥門的控制,至于水中摻不摻雜氣泡則不是水龍頭需要關心的。
所以,對于裝飾器,可以簡單地理解為是非侵入式的行為修改。
為什么要用裝飾器可能有些時候,我們會對傳入參數的類型判斷、對返回值的排序、過濾,對函數添加節流、防抖或其他的功能性代碼,基于多個類的繼承,各種各樣的與函數邏輯本身無關的、重復性的代碼。
函數中的作用可以想像一下,我們有一個工具類,提供了一個獲取數據的函數:
class Model1 { getData() { // 此處省略獲取數據的邏輯 return [{ id: 1, name: "Niko" }, { id: 2, name: "Bellic" }] } } console.log(new Model1().getData()) // [ { id: 1, name: "Niko"}, { id: 2, name: "Bellic" } ] console.log(Model1.prototype.getData()) // [ { id: 1, name: "Niko"}, { id: 2, name: "Bellic" } ]
現在我們想要添加一個功能,記錄該函數執行的耗時。
因為這個函數被很多人使用,在調用方添加耗時統計邏輯是不可取的,所以我們要在Model1中進行修改:
class Model1 { getData() { + let start = new Date().valueOf() + try { // 此處省略獲取數據的邏輯 return [{ id: 1, name: "Niko" }, { id: 2, name: "Bellic" }] + } finally { + let end = new Date().valueOf() + console.log(`start: ${start} end: ${end} consume: ${end - start}`) + } } } // start: XXX end: XXX consume: XXX console.log(new Model1().getData()) // [ { id: 1, name: "Niko"}, { id: 2, name: "Bellic" } ] // start: XXX end: XXX consume: XXX console.log(Model1.prototype.getData()) // [ { id: 1, name: "Niko"}, { id: 2, name: "Bellic" } ]
這樣在調用方法后我們就可以在控制臺看到耗時的輸出了。
但是這樣直接修改原函數代碼有以下幾個問題:
統計耗時的相關代碼與函數本身邏輯并無一點關系,影響到了對原函數本身的理解,對函數結構造成了破壞性的修改
如果后期還有更多類似的函數需要添加統計耗時的代碼,在每個函數中都添加這樣的代碼顯然是低效的,維護成本太高
所以,為了讓統計耗時的邏輯變得更加靈活,我們將創建一個新的工具函數,用來包裝需要設置統計耗時的函數。
通過將Class與目標函數的name傳遞到函數中,實現了通用的耗時統計:
function wrap(Model, key) { // 獲取Class對應的原型 let target = Model.prototype // 獲取函數對應的描述符 let descriptor = Object.getOwnPropertyDescriptor(target, key) // 生成新的函數,添加耗時統計邏輯 let log = function (...arg) { let start = new Date().valueOf() try { return descriptor.value.apply(this, arg) // 調用之前的函數 } finally { let end = new Date().valueOf() console.log(`start: ${start} end: ${end} consume: ${end - start}`) } } // 將修改后的函數重新定義到原型鏈上 Object.defineProperty(target, key, { ...descriptor, value: log // 覆蓋描述符重的value }) } wrap(Model1, "getData") wrap(Model2, "getData") // start: XXX end: XXX consume: XXX console.log(new Model1().getData()) // [ { id: 1, name: "Niko"}, { id: 2, name: "Bellic" } ] // start: XXX end: XXX consume: XXX console.log(Model2.prototype.getData()) // [ { id: 1, name: "Niko"}, { id: 2, name: "Bellic" } ]
接下來,我們想控制其中一個Model的函數不可被其他人修改覆蓋,所以要添加一些新的邏輯:
function wrap(Model, key) { // 獲取Class對應的原型 let target = Model.prototype // 獲取函數對應的描述符 let descriptor = Object.getOwnPropertyDescriptor(target, key) Object.defineProperty(target, key, { ...descriptor, writable: false // 設置屬性不可被修改 }) } wrap(Model1, "getData") Model1.prototype.getData = 1 // 無效
可以看出,兩個wrap函數中有不少重復的地方,而修改程序行為的邏輯,實際上依賴的是Object.defineProperty中傳遞的三個參數。
所以,我們針對wrap在進行一次修改,將其變為一個通用類的轉換:
function wrap(decorator) { return function (Model, key) { let target = Model.prototype let dscriptor = Object.getOwnPropertyDescriptor(target, key) decorator(target, key, descriptor) } } let log = function (target, key, descriptor) { // 將修改后的函數重新定義到原型鏈上 Object.defineProperty(target, key, { ...descriptor, value: function (...arg) { let start = new Date().valueOf() try { return descriptor.value.apply(this, arg) // 調用之前的函數 } finally { let end = new Date().valueOf() console.log(`start: ${start} end: ${end} consume: ${end - start}`) } } }) } let seal = function (target, key, descriptor) { Object.defineProperty(target, key, { ...descriptor, writable: false }) } // 參數的轉換處理 log = wrap(log) seal = warp(seal) // 添加耗時統計 log(Model1, "getData") log(Model2, "getData") // 設置屬性不可被修改 seal(Model1, "getData")
到了這一步以后,我們就可以稱log和seal為裝飾器了,可以很方便的讓我們對一些函數添加行為。
而拆分出來的這些功能可以用于未來可能會有需要的地方,而不用重新開發一遍相同的邏輯。
就像上邊提到了,現階段在JS中繼承多個Class是一件頭疼的事情,沒有直接的語法能夠繼承多個 Class。
class A { say () { return 1 } } class B { hi () { return 2 } } class C extends A, B {} // Error class C extends A extends B {} // Error // 這樣才是可以的 class C {} for (let key of Object.getOwnPropertyNames(A.prototype)) { if (key === "constructor") continue Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(A.prototype, key)) } for (let key of Object.getOwnPropertyNames(B.prototype)) { if (key === "constructor") continue Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(B.prototype, key)) } let c = new C() console.log(c.say(), c.hi()) // 1, 2
所以,在React中就有了一個mixin的概念,用來將多個Class的功能復制到一個新的Class上。
大致思路就是上邊列出來的,但是這個mixin是React中內置的一個操作,我們可以將其轉換為更接近裝飾器的實現。
在不修改原Class的情況下,將其他Class的屬性復制過來:
function mixin(constructor) { return function (...args) { for (let arg of args) { for (let key of Object.getOwnPropertyNames(arg.prototype)) { if (key === "constructor") continue // 跳過構造函數 Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key)) } } } } mixin(C)(A, B) let c = new C() console.log(c.say(), c.hi()) // 1, 2
以上,就是裝飾器在函數、Class上的實現方法(至少目前是的),但是草案中還有一顆特別甜的語法糖,也就是@Decorator了。
能夠幫你省去很多繁瑣的步驟來用上裝飾器。
草案中的裝飾器、或者可以說是TS實現的裝飾器,將上邊的兩種進一步地封裝,將其拆分成為更細的裝飾器應用,目前支持以下幾處使用:
Class
函數
get set訪問器
實例屬性、靜態函數及屬性
函數參數
@Decorator的語法規定比較簡單,就是通過@符號后邊跟一個裝飾器函數的引用:
@tag class A { @method hi () {} } function tag(constructor) { console.log(constructor === A) // true } function method(target) { console.log(target.constructor === A, target === A.prototype) // true, true }
函數tag與method會在class A定義的時候執行。
@Decorator 在 Class 中的使用該裝飾器會在class定義前調用,如果函數有返回值,則會認為是一個新的構造函數來替代之前的構造函數。
函數接收一個參數:
constructor 之前的構造函數
我們可以針對原有的構造函數進行一些改造:
新增一些屬性如果想要新增一些屬性之類的,有兩種方案可以選擇:
創建一個新的class繼承自原有class,并添加屬性
針對當前class進行修改
后者的適用范圍更窄一些,更接近mixin的處理方式。
@name class Person { sayHi() { console.log(`My name is: ${this.name}`) } } // 創建一個繼承自Person的匿名類 // 直接返回并替換原有的構造函數 function name(constructor) { return class extends constructor { name = "Niko" } } new Person().sayHi()修改原有屬性的描述符
@seal class Person { sayHi() {} } function seal(constructor) { let descriptor = Object.getOwnPropertyDescriptor(constructor.prototype, "sayHi") Object.defineProperty(constructor.prototype, "sayHi", { ...descriptor, writable: false }) } Person.prototype.sayHi = 1 // 無效使用閉包來增強裝飾器的功能
在TS文檔中被稱為裝飾器工廠
因為@符號后邊跟的是一個函數的引用,所以對于mixin的實現,我們可以很輕易的使用閉包來實現:
class A { say() { return 1 } } class B { hi() { return 2 } } @mixin(A, B) class C { } function mixin(...args) { // 調用函數返回裝飾器實際應用的函數 return function(constructor) { for (let arg of args) { for (let key of Object.getOwnPropertyNames(arg.prototype)) { if (key === "constructor") continue // 跳過構造函數 Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key)) } } } } let c = new C() console.log(c.say(), c.hi()) // 1, 2多個裝飾器的應用
裝飾器是可以同時應用多個的(不然也就失去了最初的意義)。
用法如下:
@decorator1 @decorator2 class { }
執行的順序為decorator2 -> decorator1,離class定義最近的先執行。
可以想像成函數嵌套的形式:
decorator1(decorator2(class {}))@Decorator 在 Class 成員中的使用
類成員上的 @Decorator 應該是應用最為廣泛的一處了,函數,屬性,get、set訪問器,這幾處都可以認為是類成員。
在TS文檔中被分為了Method Decorator、Accessor Decorator和Property Decorator,實際上如出一轍。
關于這類裝飾器,會接收如下三個參數:
如果裝飾器掛載于靜態成員上,則會返回構造函數,如果掛載于實例成員上則會返回類的原型
裝飾器掛載的成員名稱
成員的描述符,也就是Object.getOwnPropertyDescriptor的返回值
Property Decorator不會返回第三個參數,但是可以自己手動獲取靜態成員與實例成員在返回值上的區別
前提是靜態成員,而非實例成員,因為裝飾器都是運行在類創建時,而實例成員是在實例化一個類的時候才會執行的,所以沒有辦法獲取對應的descriptor
可以稍微明確一下,靜態成員與實例成員的區別:
class Model { // 實例成員 method1 () {} method2 = () => {} // 靜態成員 static method3 () {} static method4 = () => {} }
method1和method2是實例成員,method1存在于prototype之上,而method2只在實例化對象以后才有。
作為靜態成員的method3和method4,兩者的區別在于是否可枚舉描述符的設置,所以可以簡單地認為,上述代碼轉換為ES5版本后是這樣子的:
function Model () { // 成員僅在實例化時賦值 this.method2 = function () {} } // 成員被定義在原型鏈上 Object.defineProperty(Model.prototype, "method1", { value: function () {}, writable: true, enumerable: false, // 設置不可被枚舉 configurable: true }) // 成員被定義在構造函數上,且是默認的可被枚舉 Model.method4 = function () {} // 成員被定義在構造函數上 Object.defineProperty(Model, "method3", { value: function () {}, writable: true, enumerable: false, // 設置不可被枚舉 configurable: true })
可以看出,只有method2是在實例化時才賦值的,一個不存在的屬性是不會有descriptor的,所以這就是為什么TS在針對Property Decorator不傳遞第三個參數的原因,至于為什么靜態成員也沒有傳遞descriptor,目前沒有找到合理的解釋,但是如果明確的要使用,是可以手動獲取的。
就像上述的示例,我們針對四個成員都添加了裝飾器以后,method1和method2第一個參數就是Model.prototype,而method3和method4的第一個參數就是Model。
class Model { // 實例成員 @instance method1 () {} @instance method2 = () => {} // 靜態成員 @static static method3 () {} @static static method4 = () => {} } function instance(target) { console.log(target.constructor === Model) } function static(target) { console.log(target === Model) }函數,訪問器,和屬性裝飾器三者之間的區別 函數
首先是函數,函數裝飾器的返回值會默認作為屬性的value描述符存在,如果返回值為undefined則會忽略,使用之前的descriptor引用作為函數的描述符。
所以針對我們最開始的統計耗時的邏輯可以這么來做:
class Model { @log1 getData1() {} @log2 getData2() {} } // 方案一,返回新的value描述符 function log1(tag, name, descriptor) { return { ...descriptor, value(...args) { let start = new Date().valueOf() try { return descriptor.value.apply(this, args) } finally { let end = new Date().valueOf() console.log(`start: ${start} end: ${end} consume: ${end - start}`) } } } } // 方案二、修改現有描述符 function log2(tag, name, descriptor) { let func = descriptor.value // 先獲取之前的函數 // 修改對應的value descriptor.value = function (...args) { let start = new Date().valueOf() try { return func.apply(this, args) } finally { let end = new Date().valueOf() console.log(`start: ${start} end: ${end} consume: ${end - start}`) } } }訪問器
訪問器就是添加有get、set前綴的函數,用于控制屬性的賦值及取值操作,在使用上與函數沒有什么區別,甚至在返回值的處理上也沒有什么區別。
只不過我們需要按照規定設置對應的get或者set描述符罷了:
class Modal { _name = "Niko" @prefix get name() { return this._name } } function prefix(target, name, descriptor) { return { ...descriptor, get () { return `wrap_${this._name}` } } } console.log(new Modal().name) // wrap_Niko屬性
對于屬性的裝飾器,是沒有返回descriptor的,并且裝飾器函數的返回值也會被忽略掉,如果我們想要修改某一個靜態屬性,則需要自己獲取descriptor:
class Modal { @prefix static name1 = "Niko" } function prefix(target, name) { let descriptor = Object.getOwnPropertyDescriptor(target, name) Object.defineProperty(target, name, { ...descriptor, value: `wrap_${descriptor.value}` }) } console.log(Modal.name1) // wrap_Niko
對于一個實例的屬性,則沒有直接修改的方案,不過我們可以結合著一些其他裝飾器來曲線救國。
比如,我們有一個類,會傳入姓名和年齡作為初始化的參數,然后我們要針對這兩個參數設置對應的格式校驗:
const validateConf = {} // 存儲校驗信息 @validator class Person { @validate("string") name @validate("number") age constructor(name, age) { this.name = name this.age = age } } function validator(constructor) { return class extends constructor { constructor(...args) { super(...args) // 遍歷所有的校驗信息進行驗證 for (let [key, type] of Object.entries(validateConf)) { if (typeof this[key] !== type) throw new Error(`${key} must be ${type}`) } } } } function validate(type) { return function (target, name, descriptor) { // 向全局對象中傳入要校驗的屬性名及類型 validateConf[name] = type } } new Person("Niko", "18") // throw new error: [age must be number]
首先,在類上邊添加裝飾器@validator,然后在需要校驗的兩個參數上添加@validate裝飾器,兩個裝飾器用來向一個全局對象傳入信息,來記錄哪些屬性是需要進行校驗的。
然后在validator中繼承原有的類對象,并在實例化之后遍歷剛才設置的所有校驗信息進行驗證,如果發現有類型錯誤的,直接拋出異常。
這個類型驗證的操作對于原Class來說幾乎是無感知的。
最后,還有一個用于函數參數的裝飾器,這個裝飾器也是像實例屬性一樣的,沒有辦法多帶帶使用,畢竟函數是在運行時調用的,而無論是何種裝飾器,都是在聲明類時(可以認為是偽編譯期)調用的。
函數參數裝飾器會接收三個參數:
類似上述的操作,類的原型或者類的構造函數
參數所處的函數名稱
參數在函數中形參中的位置(函數簽名中的第幾個參數)
一個簡單的示例,我們可以結合著函數裝飾器來完成對函數參數的類型轉換:
const parseConf = {} class Modal { @parseFunc addOne(@parse("number") num) { return num + 1 } } // 在函數調用前執行格式化操作 function parseFunc (target, name, descriptor) { return { ...descriptor, value (...arg) { // 獲取格式化配置 for (let [index, type] of parseConf) { switch (type) { case "number": arg[index] = Number(arg[index]) break case "string": arg[index] = String(arg[index]) break case "boolean": arg[index] = String(arg[index]) === "true" break } } return descriptor.value.apply(this, arg) } } } // 向全局對象中添加對應的格式化信息 function parse(type) { return function (target, name, index) { parseConf[index] = type } } console.log(new Modal().addOne("10")) // 11使用裝飾器實現一個有趣的Koa封裝
比如在寫Node接口時,可能是用的koa或者express,一般來說可能要處理很多的請求參數,有來自headers的,有來自body的,甚至有來自query、cookie的。
所以很有可能在router的開頭數行都是這樣的操作:
router.get("/", async (ctx, next) => { let id = ctx.query.id let uid = ctx.cookies.get("uid") let device = ctx.header["device"] })
以及如果我們有大量的接口,可能就會有大量的router.get、router.post。
以及如果要針對模塊進行分類,可能還會有大量的new Router的操作。
這些代碼都是與業務邏輯本身無關的,所以我們應該盡可能的簡化這些代碼的占比,而使用裝飾器就能夠幫助我們達到這個目的。
裝飾器的準備// 首先,我們要創建幾個用來存儲信息的全局List export const routerList = [] export const controllerList = [] export const parseList = [] export const paramList = [] // 雖說我們要有一個能夠創建Router實例的裝飾器 // 但是并不會直接去創建,而是在裝飾器執行的時候進行一次注冊 export function Router(basename = "") { return (constrcutor) => { routerList.push({ constrcutor, basename }) } } // 然后我們在創建對應的Get Post請求監聽的裝飾器 // 同樣的,我們并不打算去修改他的任何屬性,只是為了獲取函數的引用 export function Method(type) { return (path) => (target, name, descriptor) => { controllerList.push({ target, type, path, method: name, controller: descriptor.value }) } } // 接下來我們還需要用來格式化參數的裝飾器 export function Parse(type) { return (target, name, index) => { parseList.push({ target, type, method: name, index }) } } // 以及最后我們要處理的各種參數的獲取 export function Param(position) { return (key) => (target, name, index) => { paramList.push({ target, key, position, method: name, index }) } } export const Body = Param("body") export const Header = Param("header") export const Cookie = Param("cookie") export const Query = Param("query") export const Get = Method("get") export const Post = Method("post")Koa服務的處理
上邊是創建了所有需要用到的裝飾器,但是也僅僅是把我們所需要的各種信息存了起來,而怎么利用這些裝飾器則是下一步需要做的事情了:
const routers = [] // 遍歷所有添加了裝飾器的Class,并創建對應的Router對象 routerList.forEach(item => { let { basename, constrcutor } = item let router = new Router({ prefix: basename }) controllerList .filter(i => i.target === constrcutor.prototype) .forEach(controller => { router[controller.type](controller.path, async (ctx, next) => { let args = [] // 獲取當前函數對應的參數獲取 paramList .filter( param => param.target === constrcutor.prototype && param.method === controller.method ) .map(param => { let { index, key } = param switch (param.position) { case "body": args[index] = ctx.request.body[key] break case "header": args[index] = ctx.headers[key] break case "cookie": args[index] = ctx.cookies.get(key) break case "query": args[index] = ctx.query[key] break } }) // 獲取當前函數對應的參數格式化 parseList .filter( parse => parse.target === constrcutor.prototype && parse.method === controller.method ) .map(parse => { let { index } = parse switch (parse.type) { case "number": args[index] = Number(args[index]) break case "string": args[index] = String(args[index]) break case "boolean": args[index] = String(args[index]) === "true" break } }) // 調用實際的函數,處理業務邏輯 let results = controller.controller(...args) ctx.body = results }) }) routers.push(router.routes()) }) const app = new Koa() app.use(bodyParse()) app.use(compose(routers)) app.listen(12306, () => console.log("server run as http://127.0.0.1:12306"))
上邊的代碼就已經搭建出來了一個Koa的封裝,以及包含了對各種裝飾器的處理,接下來就是這些裝飾器的實際應用了:
import { Router, Get, Query, Parse } from "../decorators" @Router("") export default class { @Get("/") index (@Parse("number") @Query("id") id: number) { return { code: 200, id, type: typeof id } } @Post("/detail") detail ( @Parse("number") @Query("id") id: number, @Parse("number") @Body("age") age: number ) { return { code: 200, age: age + 1 } } }
很輕易的就實現了一個router的創建,路徑、method的處理,包括各種參數的獲取,類型轉換。
將各種非業務邏輯相關的代碼統統交由裝飾器來做,而函數本身只負責處理自身邏輯即可。
這里有完整的代碼:GitHub。安裝依賴后npm start即可看到效果。
這樣開發帶來的好處就是,讓代碼可讀性變得更高,在函數中更專注的做自己應該做的事情。
而且裝飾器本身如果名字起的足夠好的好,也是在一定程度上可以當作文檔注釋來看待了(Java中有個類似的玩意兒叫做注解)。
合理利用裝飾器可以極大的提高開發效率,對一些非邏輯相關的代碼進行封裝提煉能夠幫助我們快速完成重復性的工作,節省時間。
但是糖再好吃,也不要吃太多,容易壞牙齒的,同樣的濫用裝飾器也會使代碼本身邏輯變得撲朔迷離,如果確定一段代碼不會在其他地方用到,或者一個函數的核心邏輯就是這些代碼,那么就沒有必要將它取出來作為一個裝飾器來存在。
typescript | decorators
koa示例的原版,簡化代碼便于舉例
One more thing我司現在大量招人咯,前端、Node方向都有HC
公司名:Blued,坐標帝都朝陽雙井
主要技術棧是React,也會有機會玩ReactNative和Electron
Node方向8.x版本+koa 新項目會以TS為主
有興趣的小伙伴可以聯系我詳談:
email: jiashunming@blued.com
wechat: github_jiasm
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/96019.html
摘要:描述符必須是這兩種形式之一不能同時是兩者。可以是任何有效的值數值,對象,函數等。當且僅當該屬性的為時,才能被賦值運算符改變。特點就是不影響之前對象的特性,而新增額外的職責功能。 前言 原文鏈接:[Nealyang/personalBlog]() showImg(https://segmentfault.com/img/remote/1460000018958861); ES6 已經不必...
摘要:下面是偽元素和偽元素的具體用法和使用規則作用在標簽前后添加字符串使用規則后面一定要用把要添加的內容括起來,否則是無法顯示的括號不一定需要添加。場景: 假如有一天,你的在寫一個前端項目,是關于一份點餐商家電話信息表,你啪塔啪塔地寫完了,突然間項目經理跑過來找你,要求你在每一個商家的電話號碼前都添加一個電話符號,來使得電話號碼更直觀和頁面更美觀。這個時候你就糾結了,這不是折磨人嗎?這不是要我在...
摘要:想學好前端,真的要主動,然后對所有的英文文檔耐心一點。在年月日,國際組織發布了的第六版,該版本正式名稱為,但通常被稱為或者。自此,每年發布一次新標準。但保留了用于依賴注入的構造函數參數類型。必須在構造函數中聲明屬性,而不是在類的代碼體中。 從 TypeScript 到 ES6 到 ES5 在我初學前端的很長一段時間,不愿意碰git,不愿意碰框架,總是嫌麻煩,連ES6也沒有怎么去弄明白...
閱讀 3622·2021-11-24 10:25
閱讀 2548·2021-11-24 09:38
閱讀 1236·2021-09-08 10:41
閱讀 2919·2021-09-01 10:42
閱讀 2600·2021-07-25 21:37
閱讀 1996·2019-08-30 15:56
閱讀 927·2019-08-30 15:55
閱讀 2764·2019-08-30 15:54