摘要:接下來,我們換一種思路,用一個相對較新的來實現方法。從這道題目看出,相比考察死記硬背,這樣的實現更有意義。對數組的操作我們不能陌生,其中方法更要做到駕輕就熟。最后,我們再看下社區上著名的和的實現。
有不少剛入行的同學跟我說:“JavaScript 很多 API 記不清楚怎么辦?數組的這方法、那方法總是傻傻分不清楚,該如何是好?操作 DOM 的方式今天記,明天忘,真讓人奔潰!”
甚至有的開發者在討論面試時,總向我抱怨:“面試官總愛糾結 API 的使用,甚至 jQuery 某些方法的參數順序都需要讓我說清楚!”
我認為,對于反復使用的方法,所有人都要做到“機械記憶”,能夠反手寫出。一些貌似永遠記不清的 API 只是因為用得不夠多而已。
在做面試官時,我從來不強求開發者準確無誤地“背誦” API。相反,我喜歡從另外一個角度來考察面試者:“既然記不清使用方法,那么我告訴你它的使用方法,你來實現一個吧!”實現一個 API,除了可以考察面試者對這個 API 的理解,更能體現開發者的編程思維和代碼能力。對于積極上進的前端工程師,模仿并實現一些經典方法,應該是“家常便飯”,這是比較基本的要求。
本小節,我根據了解的面試題目和作為面試官的經歷,挑了幾個典型的 API,通過對其不同程度,不同方式的實現,來覆蓋 JavaScript 中的部分知識點和編程要領。通過學習本節內容,期待你不僅能領會代碼奧義,更應該學習舉一反三的方法。
API 主題的相關知識點如下:
jQuery offset 實現這個話題演變自今日頭條某部門面試題。當時面試官提問:“如何獲取文檔中任意一個元素距離文檔 document 頂部的距離?”
熟悉 jQuery 的同學應該對 offset 方法并不陌生,它返回或設置匹配元素相對于文檔的偏移(位置)。這個方法返回的對象包含兩個整型屬性:top 和 left,以像素計。如果可以使用 jQuery, 我們可以直接調取該 API 獲得結果。但是,如果用原生 JavaScript 實現,也就是說手動實現 jQuery offset 方法,該如何著手呢?
主要有兩種思路:
通過遞歸實現
通過 getBoundingClientRect API 實現
遞歸實現方案我們通過遍歷目標元素、目標元素的父節點、父節點的父節點......依次溯源,并累加這些遍歷過的節點相對于其最近祖先節點(且 position 屬性非 static)的偏移量,向上直到 document,累加即可得到結果。
其中,我們需要使用 JavaScript 的 offsetTop 來訪問一個 DOM 節點上邊框相對離其本身最近、且 position 值為非 static 的祖先元素的垂直偏移量。具體實現為:
const offset = ele => { let result = { top: 0, left: 0 } // 當前 DOM 節點的 display === "none" 時, 直接返回 {top: 0, left: 0} if (window.getComputedStyle(ele)["display"] === "none") { return result } let position const getOffset = (node, init) => { if (node.nodeType !== 1) { return } position = window.getComputedStyle(node)["position"] if (typeof(init) === "undefined" && position === "static") { getOffset(node.parentNode) return } result.top = node.offsetTop + result.top - node.scrollTop result.left = node.offsetLeft + result.left - node.scrollLeft if (position === "fixed") { return } getOffset(node.parentNode) } getOffset(ele, true) return result }
上述代碼并不難理解,使用遞歸實現。如果節點 node.nodeType 類型不是 Element(1),則跳出;如果相關節點的 position 屬性為 static,則不計入計算,進入下一個節點(其父節點)的遞歸。如果相關屬性的 display 屬性為 none,則應該直接返回 0 作為結果。
這個實現很好地考察了開發者對于遞歸的初級應用、以及對 JavaScript 方法的掌握程度。
接下來,我們換一種思路,用一個相對較新的 API: getBoundingClientRect 來實現 jQuery offset 方法。
getBoundingClientRect 方法getBoundingClientRect 方法用來描述一個元素的具體位置,這個位置的下面四個屬性都是相對于視口左上角的位置而言的。對某一節點執行該方法,它的返回值是一個 DOMRect 類型的對象。這個對象表示一個矩形盒子,它含有:left、top、right 和 bottom 等只讀屬性。
請參考實現:
const offset = ele => { let result = { top: 0, left: 0 } // 當前為 IE11 以下,直接返回 {top: 0, left: 0} if (!ele.getClientRects().length) { return result } // 當前 DOM 節點的 display === "none" 時,直接返回 {top: 0, left: 0} if (window.getComputedStyle(ele)["display"] === "none") { return result } result = ele.getBoundingClientRect() var docElement = ele.ownerDocument.documentElement return { top: result.top + window.pageYOffset - docElement.clientTop, left: result.left + window.pageXOffset - docElement.clientLeft } }
需要注意的細節有:
node.ownerDocument.documentElement 的用法可能大家比較陌生,ownerDocument 是 DOM 節點的一個屬性,它返回當前節點的頂層的 document 對象。ownerDocument 是文檔,documentElement 是根節點。事實上,ownerDocument 下含 2 個節點:
documentElement
docElement.clientTop,clientTop 是一個元素頂部邊框的寬度,不包括頂部外邊距或內邊距。
除此之外,該方法實現就是簡單的幾何運算,邊界 case 和兼容性處理,也并不難理解。
從這道題目看出,相比考察“死記硬背” API,這樣的實現更有意義。站在面試官的角度,我往往會給面試者(開發者)提供相關的方法提示,以引導其給出最后的方案實現。
數組 reduce 方法的相關實現數組方法非常重要:因為數組就是數據,數據就是狀態,狀態反應著視圖。對數組的操作我們不能陌生,其中 reduce 方法更要做到駕輕就熟。我認為這個方法很好地體現了“函數式”理念,也是當前非常熱門的考察點之一。
我們知道 reduce 方法是 ES5 引入的,reduce 英文解釋翻譯過來為“減少,縮小,使還原,使變弱”,MDN 對該方法直述為:
The reduce method applies a function against an accumulator and each value of the array (from left-to-right) to reduce it to a single value.
它的使用語法:
arr.reduce(callback[, initialValue])
這里我們簡要介紹一下。
reduce 第一個參數 callback 是核心,它對數組的每一項進行“疊加加工”,其最后一次返回值將作為 reduce 方法的最終返回值。 它包含 4 個參數:
previousValue 表示“上一次” callback 函數的返回值
currentValue 數組遍歷中正在處理的元素
currentIndex 可選,表示 currentValue 在數組中對應的索引。如果提供了 initialValue,則起始索引號為 0,否則為 1
array 可選,調用 reduce() 的數組
initialValue 可選,作為第一次調用 callback 時的第一個參數。如果沒有提供 initialValue,那么數組中的第一個元素將作為 callback 的第一個參數。
reduce 實現 runPromiseInSequence我們看它的一個典型應用:按順序運行 Promise:
const runPromiseInSequence = (array, value) => array.reduce( (promiseChain, currentFunction) => promiseChain.then(currentFunction), Promise.resolve(value) )
runPromiseInSequence 方法將會被一個每一項都返回一個 Promise 的數組調用,并且依次執行數組中的每一個 Promise,請讀者仔細體會。如果覺得晦澀,可以參考示例:
const f1 = () => new Promise((resolve, reject) => { setTimeout(() => { console.log("p1 running") resolve(1) }, 1000) }) const f2 = () => new Promise((resolve, reject) => { setTimeout(() => { console.log("p2 running") resolve(2) }, 1000) }) const array = [f1, f2] const runPromiseInSequence = (array, value) => array.reduce( (promiseChain, currentFunction) => promiseChain.then(currentFunction), Promise.resolve(value) ) runPromiseInSequence(array, "init")
執行結果如下圖:
reduce 實現 pipereduce 的另外一個典型應用可以參考函數式方法 pipe 的實現:pipe(f, g, h) 是一個 curry 化函數,它返回一個新的函數,這個新的函數將會完成 (...args) => h(g(f(...args))) 的調用。即 pipe 方法返回的函數會接收一個參數,這個參數傳遞給 pipe 方法第一個參數,以供其調用。
const pipe = (...functions) => input => functions.reduce( (acc, fn) => fn(acc), input )
仔細體會 runPromiseInSequence 和 pipe 這兩個方法,它們都是 reduce 應用的典型場景。
實現一個 reduce那么我們該如何實現一個 reduce 呢?參考來自 MDN 的 polyfill:
if (!Array.prototype.reduce) { Object.defineProperty(Array.prototype, "reduce", { value: function(callback /*, initialValue*/) { if (this === null) { throw new TypeError( "Array.prototype.reduce " + "called on null or undefined" ) } if (typeof callback !== "function") { throw new TypeError( callback + " is not a function") } var o = Object(this) var len = o.length >>> 0 var k = 0 var value if (arguments.length >= 2) { value = arguments[1] } else { while (k < len && !(k in o)) { k++ } if (k >= len) { throw new TypeError( "Reduce of empty array " + "with no initial value" ) } value = o[k++] } while (k < len) { if (k in o) { value = callback(value, o[k], k, o) } k++ } return value } }) }
上述代碼中使用了 value 作為初始值,并通過 while 循環,依次累加計算出 value 結果并輸出。但是相比 MDN 上述實現,我個人更喜歡的實現方案是:
Array.prototype.reduce = Array.prototype.reduce || function(func, initialValue) { var arr = this var base = typeof initialValue === "undefined" ? arr[0] : initialValue var startPoint = typeof initialValue === "undefined" ? 1 : 0 arr.slice(startPoint) .forEach(function(val, index) { base = func(base, val, index + startPoint, arr) }) return base }
核心原理就是使用 forEach 來代替 while 實現結果的累加,它們本質上是相同的。
我也同樣看了下 ES5-shim 里的 pollyfill,跟上述思路完全一致。唯一的區別在于:我用了 forEach 迭代而 ES5-shim 使用的是簡單的 for 循環。實際上,如果“杠精”一些,我們會指出數組的 forEach 方法也是 ES5 新增的。因此,用 ES5 的一個 API(forEach),去實現另外一個 ES5 的 API(reduce),這并沒什么實際意義——這里的 pollyfill 就是在不兼容 ES5 的情況下,模擬的降級方案。此處不多做追究,因為根本目的還是希望讀者對 reduce 有一個全面透徹的了解。
通過 Koa only 模塊源碼認識 reduce通過了解并實現 reduce 方法,我們對它已經有了比較深入的認識。最后,我們再來看一個 reduce 使用示例——通過 Koa 源碼的 only 模塊,加深印象:
var o = { a: "a", b: "b", c: "c" } only(o, ["a","b"]) // {a: "a", b: "b"}
該方法返回一個經過指定篩選屬性的新對象。
?
only 模塊實現:
var only = function(obj, keys){ obj = obj || {} if ("string" == typeof keys) keys = keys.split(/ +/) return keys.reduce(function(ret, key) { if (null == obj[key]) return ret ret[key] = obj[key] return ret }, {}) }
小小的 reduce 及其衍生場景有很多值得我們玩味、探究的地方。舉一反三,活學活用是技術進階的關鍵。
compose 實現的幾種方案函數式理念——這一古老的概念如今在前端領域“遍地開花”。函數式很多思想都值得借鑒,其中一個細節:compose 因為其巧妙的設計而被廣泛運用。對于它的實現,從面向過程式到函數式實現,風格迥異,值得我們探究。在面試當中,也經常有面試官要求實現 compose 方法,我們先看什么是 compose。
compose 其實和前面提到的 pipe 一樣,就是執行一連串不定長度的任務(方法),比如:
let funcs = [fn1, fn2, fn3, fn4] let composeFunc = compose(...funcs)
執行:
composeFunc(args)
就相當于:
fn1(fn2(fn3(fn4(args))))
總結一下 compose 方法的關鍵點:
compose 的參數是函數數組,返回的也是一個函數
compose 的參數是任意長度的,所有的參數都是函數,執行方向是自右向左的,因此初始函數一定放到參數的最右面
compose 執行后返回的函數可以接收參數,這個參數將作為初始函數的參數,所以初始函數的參數是多元的,初始函數的返回結果將作為下一個函數的參數,以此類推。因此除了初始函數之外,其他函數的接收值是一元的。
我們發現,實際上,compose 和 pipe 的差別只在于調用順序的不同:
// compose fn1(fn2(fn3(fn4(args)))) // pipe fn4(fn3(fn2(fn1(args))))
即然跟我們先前實現的 pipe 方法如出一轍,那么還有什么好深入分析的呢?請繼續閱讀,看看還能玩出什么花兒來。
compose 最簡單的實現是面向過程的:
const compose = function(...args) { let length = args.length let count = length - 1 let result return function f1 (...arg1) { result = args[count].apply(this, arg1) if (count <= 0) { count = length - 1 return result } count-- return f1.call(null, result) } }
這里的關鍵是用到了閉包,使用閉包變量儲存結果 result 和函數數組長度以及遍歷索引,并利用遞歸思想,進行結果的累加計算。整體實現符合正常的面向過程思維,不難理解。
聰明的同學可能也會意識到,利用上文所講的 reduce 方法,應該能更函數式地解決問題:
const reduceFunc = (f, g) => (...arg) => g.call(this, f.apply(this, arg)) const compose = (...args) => args.reverse().reduce(reduceFunc, args.shift())
通過前面的學習,結合 call、apply 方法,這樣的實現并不難理解。
我們繼續開拓思路,“既然涉及串聯和流程控制”,那么我們還可以使用 Promise 實現:
const compose = (...args) => { let init = args.pop() return (...arg) => args.reverse().reduce((sequence, func) => sequence.then(result => func.call(null, result)) , Promise.resolve(init.apply(null, arg))) }
這種實現利用了 Promise 特性:首先通過 Promise.resolve(init.apply(null, arg)) 啟動邏輯,啟動一個 resolve 值為最后一個函數接收參數后的返回值,依次執行函數。因為 promise.then() 仍然返回一個 Promise 類型值,所以 reduce 完全可以按照 Promise 實例執行下去。
既然能夠使用 Promise 實現,那么 generator 當然應該也可以實現。這里給大家留一個思考題,感興趣的同學可以嘗試,歡迎在評論區討論。
最后,我們再看下社區上著名的 lodash 和 Redux 的實現。
lodash 版本
// lodash 版本 var compose = function(funcs) { var length = funcs.length var index = length while (index--) { if (typeof funcs[index] !== "function") { throw new TypeError("Expected a function"); } } return function(...args) { var index = 0 var result = length ? funcs.reverse()[index].apply(this, args) : args[0] while (++index < length) { result = funcs[index].call(this, result) } return result } }
lodash 版本更像我們的第一種實現方式,理解起來也更容易。
Redux 版本
// Redux 版本 function compose(...funcs) { if (funcs.length === 0) { return arg => arg } if (funcs.length === 1) { return funcs[0] } return funcs.reduce((a, b) => (...args) => a(b(...args))) }
總之,還是充分利用了數組的 reduce 方法。
函數式概念確實有些抽象,需要開發者仔細琢磨,并動手調試。一旦頓悟,必然會感受到其中的優雅和簡潔。
apply、bind 進階實現面試中關于 this 綁定的相關話題如今已經“泛濫”,同時對 bind 方法的實現,社區上也有相關討論。但是很多內容尚不系統,且存在一些瑕疵。這里簡單摘錄我 2017 年年初寫的文章 從一道面試題,到“我可能看了假源碼” 來遞進討論。在《一網打盡 this》一課,我們介紹過對 bind 的實現,這里我們進一步展開。
此處不再贅述 bind 函數的使用,尚不清楚的讀者可以自行補充一下基礎知識。我們先來看一個初級實現版本:
Function.prototype.bind = Function.prototype.bind || function (context) { var me = this; var argsArray = Array.prototype.slice.call(arguments); return function () { return me.apply(context, argsArray.slice(1)) } }
這是一般合格開發者提供的答案,如果面試者能寫到這里,給他 60 分。
先簡要解讀一下:
基本原理是使用 apply 進行模擬 bind。函數體內的 this 就是需要綁定 this 的函數,或者說是原函數。最后使用 apply 來進行參數(context)綁定,并返回。
與此同時,將第一個參數(context)以外的其他參數,作為提供給原函數的預設參數,這也是基本的“ curry 化”基礎。
上述實現方式,我們返回的參數列表里包含:argsArray.slice(1),它的問題在于存在預置參數功能丟失的現象。
想象我們返回的綁定函數中,如果想實現預設傳參(就像 bind 所實現的那樣),就面臨尷尬的局面。真正實現“ curry 化”的“完美方式”是:
Function.prototype.bind = Function.prototype.bind || function (context) { var me = this; var args = Array.prototype.slice.call(arguments, 1); return function () { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return me.apply(context, finalArgs); } }
但繼續探究,我們注意 bind 方法中:bind 返回的函數如果作為構造函數,搭配 new 關鍵字出現的話,我們的綁定 this 就需要“被忽略”,this 要綁定在實例上。也就是說,new 的操作符要高于 bind 綁定,兼容這種情況的實現:
Function.prototype.bind = Function.prototype.bind || function (context) { var me = this; var args = Array.prototype.slice.call(arguments, 1); var F = function () {}; F.prototype = this.prototype; var bound = function () { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return me.apply(this instanceof F ? this : context || this, finalArgs); } bound.prototype = new F(); return bound; }
如果你認為這樣就完了,其實我會告訴你說,高潮才剛要上演。曾經的我也認為上述方法已經比較完美了,直到我看了 es5-shim 源碼(已適當刪減):
function bind(that) { var target = this; if (!isCallable(target)) { throw new TypeError("Function.prototype.bind called on incompatible " + target); } var args = array_slice.call(arguments, 1); var bound; var binder = function () { if (this instanceof bound) { var result = target.apply( this, array_concat.call(args, array_slice.call(arguments)) ); if ($Object(result) === result) { return result; } return this; } else { return target.apply( that, array_concat.call(args, array_slice.call(arguments)) ); } }; var boundLength = max(0, target.length - args.length); var boundArgs = []; for (var i = 0; i < boundLength; i++) { array_push.call(boundArgs, "$" + i); } bound = Function("binder", "return function (" + boundArgs.join(",") + "){ return binder.apply(this, arguments); }")(binder); if (target.prototype) { Empty.prototype = target.prototype; bound.prototype = new Empty(); Empty.prototype = null; } return bound; }
es5-shim 的實現到底在”搞什么鬼“呢?你可能不知道,其實每個函數都有 length 屬性。對,就像數組和字符串那樣。函數的 length 屬性,用于表示函數的形參個數。更重要的是函數的 length 屬性值是不可重寫的。我寫了個測試代碼來證明:
function test (){} test.length // 輸出 0 test.hasOwnProperty("length") // 輸出 true Object.getOwnPropertyDescriptor("test", "length") // 輸出: // configurable: false, // enumerable: false, // value: 4, // writable: false
說到這里,那就好解釋了:es5-shim 是為了最大限度地進行兼容,包括對返回函數 length 屬性的還原。而如果按照我們之前實現的那種方式,length 值始終為零。因此,既然不能修改 length 的屬性值,那么在初始化時賦值總可以吧!于是我們可通過 eval 和 new Function 的方式動態定義函數。但是出于安全考慮,在某些瀏覽器中使用 eval 或者 Function() 構造函數都會拋出異常。然而巧合的是,這些無法兼容的瀏覽器基本上都實現了 bind 函數,這些異常又不會被觸發。上述代碼里,重設綁定函數的 length 屬性:
var boundLength = max(0, target.length - args.length)
構造函數調用情況,在 binder 中也有效兼容:
if (this instanceof bound) { ... // 構造函數調用情況 } else { ... // 正常方式調用 } if (target.prototype) { Empty.prototype = target.prototype; bound.prototype = new Empty(); // 進行垃圾回收清理 Empty.prototype = null; }
對比過幾版的 polyfill 實現,對于 bind 應該有了比較深刻的認識。這一系列實現有效地考察了很重要的知識點:比如 this 的指向、JavaScript 閉包、原型與原型鏈,設計程序上的邊界 case 和兼容性考慮經驗等硬素質。
一道更好的面試題最后,現如今在很多面試中,面試官都會以“實現 bind”作為題目。如果是我,現在可能會規避這個很容易“應試”的題目,而是別出心裁,讓面試者實現一個 “call/apply”。我們往往用 call/apply 模擬實現 bind,而直接實現 call/apply 也算簡單:
Function.prototype.applyFn = function (targetObject, argsArray) { if(typeof argsArray === "undefined" || argsArray === null) { argsArray = [] } if(typeof targetObject === "undefined" || targetObject === null){ targetObject = this } targetObject = new Object(targetObject) const targetFnKey = "targetFnKey" targetObject[targetFnKey] = this const result = targetObject[targetFnKey](...argsArray) delete targetObject[targetFnKey] return result }
這樣的代碼不難理解,函數體內的 this 指向了調用 applyFn 的函數。為了將該函數體內的 this 綁定在 targetObject 上,我們采用了隱式綁定的方法: targetObject[targetFnKey](...argsArray)。
細心的讀者會發現,這里存在一個問題:如果 targetObject 對象本身就存在 targetFnKey 這樣的屬性,那么在使用 applyFn 函數時,原有的 targetFnKey 屬性值就會被覆蓋,之后被刪除。解決方案可以使用 ES6 Sybmol() 來保證鍵的唯一性;另一種解決方案是用 Math.random() 實現獨一無二的 key,這里我們不再贅述。
實現這些 API 帶來的啟示這些 API 的實現并不算復雜,卻能恰如其分地考驗開發者的 JavaScript 基礎。基礎是地基,是探究更深入內容的鑰匙,是進階之路上最重要的一環,需要每個開發者重視。在前端技術快速發展迭代的今天,在“前端市場是否飽和”,“前端求職火爆異常”,“前端入門簡單,錢多人傻”等眾說紛紜的浮躁環境下,對基礎內功的修煉就顯得尤為重要。這也是你在前端路上能走多遠、走多久的關鍵。
從面試的角度看,面試題歸根結底是對基礎的考察,只有對基礎爛熟于胸,才能具備突破面試的基本條件。
分享交流本篇文章出自我的課程:前端開發核心知識進階 當中的一篇基礎部分章節。
感興趣的讀者可以:
PC 端點擊了解更多《前端開發核心知識進階》
移動端點擊了解更多:
大綱內容:
Happy coding!
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/53938.html
摘要:接下來,我們換一種思路,用一個相對較新的來實現方法。從這道題目看出,相比考察死記硬背,這樣的實現更有意義。對數組的操作我們不能陌生,其中方法更要做到駕輕就熟。最后,我們再看下社區上著名的和的實現。 有不少剛入行的同學跟我說:JavaScript 很多 API 記不清楚怎么辦?數組的這方法、那方法總是傻傻分不清楚,該如何是好?操作 DOM 的方式今天記,明天忘,真讓人奔潰! 甚至有的開發...
摘要:聲明語句是可選部分如果存在需要放在文檔的第一行所謂的文檔聲明就是告訴解析器當前文檔格式版本號以及編碼格式。所有的元素都必須是成對閉合標簽非閉合標簽是非法的,解析器將報錯,不無正常解析標簽對大小寫敏感必須頭尾標簽一致。 前言 一直想系統性的學XML,就沒時間學,今晚抽出幾個小時時間學完了XML。過幾天再過來看看,背一背應該就差不多,記得東西較多,沒什么難理解的。 XML數據傳輸格式 第一...
摘要:聲明語句是可選部分如果存在需要放在文檔的第一行所謂的文檔聲明就是告訴解析器當前文檔格式版本號以及編碼格式。所有的元素都必須是成對閉合標簽非閉合標簽是非法的,解析器將報錯,不無正常解析標簽對大小寫敏感必須頭尾標簽一致。 前言 一直想系統性的學XML,就沒時間學,今晚抽出幾個小時時間學完了XML。過幾天再過來看看,背一背應該就差不多,記得東西較多,沒什么難理解的。 XML數據傳輸格式 第一...
摘要:在項目中,為滿足以上要求,我們將大量的參數配置在或文件中,通過注解,我們可以方便的獲取這些參數值使用配置模塊假設我們正在搭建一個發送郵件的模塊。這使得在不影響其他模塊的情況下重構一個模塊中的屬性變得容易。 在編寫項目代碼時,我們要求更靈活的配置,更好的模塊化整合。在 Spring Boot 項目中,為滿足以上要求,我們將大量的參數配置在 application.properties 或...
閱讀 2187·2021-11-18 10:02
閱讀 3289·2021-11-11 16:55
閱讀 2694·2021-09-14 18:02
閱讀 2426·2021-09-04 16:41
閱讀 2056·2021-09-04 16:40
閱讀 1165·2019-08-30 15:56
閱讀 2213·2019-08-30 15:54
閱讀 3161·2019-08-30 14:15