優秀組件設計的關鍵:自私原則
當把組件從設計轉化爲開發時,常常會發現一些屬性與內容有關,而與組件本身無關。這種考慮周到的組件設計方法導致了複雜的屬性、更陡峭的學習曲線和最終的技術債務。然而,避免這些陷阱的關鍵是自私或自我利益爲中心的組件設計。
在開發新功能時,是什麼決定了現有組件是否可行?當一個組件不能使用時,這究竟意味着什麼?
該組件在功能上是否沒有做它所期望的事情,比如一個標籤系統沒有切換到正確的面板?或者它太死板,不能支持設計的內容,比如一個在內容之後而不是之前有圖標的按鈕?或者是它太過預設和結構化,無法支持輕微的變體,比如一個一直有標題部分的模態,現在需要一個沒有標題的變體?
這就是組件的生活。很多時候,它們是爲了一個狹窄的目標而構建的,然後匆忙地爲一個接一個的小變化進行擴展,直到不再可行。在這個時候,會創建一個新組件,技術債務增長,入職學習曲線變得更陡峭,代碼庫的可維護性變得更具挑戰性。
這僅僅是組件不可避免的生命週期嗎?還是這種情況可以避免?最重要的是,如果可以避免,怎麼做?
自私。或者說,自利。更好的說法可能是兩者兼而有之。
很多時候,組件過於體貼。它們對彼此太體貼了,尤其是對它們自己的內容太體貼了。爲了創建能夠隨着產品擴展的組件,關鍵是自私地關注自己的利益——冷酷、自戀、世界環繞着我旋轉的自私。
本文並不打算解決自利和自私之間幾百年的爭論。坦白說,我沒有資格參與任何哲學辯論。然而,本文要做的是證明構建自私組件對其他組件、設計師、開發者和使用你內容的人來說是最有利的。事實上,自私的組件在它們周圍創造瞭如此多的好處,以至於你幾乎可以說它們是無私的。
注意:本文中的所有代碼示例和演示都將基於 React 和 TypeScript。然而,這些概念和模式是與框架無關的。
考慮的迭代
也許,展示一個體貼的組件的最好方式是通過走過一個組件的生命週期。我們將能夠看到它們是如何開始時很小,功能很強,但一旦設計發展起來就會變得很笨重。每一次迭代都會使組件進一步陷入困境,直到產品的設計和需求超過了組件本身的能力。
讓我們考慮一下謙虛的 Button 組件。它具有欺騙性的複雜性,而且經常被困在考慮模式中,因此,是一個很好的工作實例。
迭代 1#
雖然這些樣本設計相當簡陋,比如沒有顯示各種:hover
、:focus
和 :disabled
狀態,但它們確實展示了一個有兩種顏色主題的簡單按鈕。
乍一看,所產生的Button
組件有可能和設計一樣是赤裸裸的。
// 首先,從React擴展原生HTML按鈕屬性,如onClick和disabled。
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
text: string;
theme: 'primary' | 'secondary';
}
<Button
onClick={someFunction}
text="Add to cart"
theme="primary"
/>
有可能,甚至有可能,我們都見過這樣的一個Button
組件。也許我們甚至自己也做過這樣的一個。有些名字可能不一樣,但 props 或 Button 的 API 大致上是一樣的。
爲了滿足設計的要求,Button 爲 theme
和 text
定義了 props 。這第一個迭代工作,滿足了設計和產品的當前需求。
然而,設計和產品的當前需求很少是最終需求。當下次設計迭代時,添加到購物車的按鈕現在需要一個圖標。
迭代 2#
在驗證了產品的用戶界面後,決定在添加到購物車的按鈕上增加一個圖標,這將是有益的。不過,設計人員解釋說,不是每個按鈕都會包括一個圖標。
回到我們的 Button 組件,它的 props 可以用一個可選的 icon
來擴展,該 props 映射到一個圖標的名稱,以便有條件地渲染。
type ButtonProps = {
theme: 'primary' | 'secondary';
text: string;
icon?: 'cart' | '...all-other-potential-icon-names';
}
<Button
theme="primary"
onClick={someFunction}
text="Add to cart"
icon="cart"
/>
嗚呼! 危機解除了。
有了新的 icon prop,Button 現在可以支持帶或不帶圖標的變體。當然,這假定圖標總是顯示在文本的末尾,但出乎意料的是,在設計下一次迭代時,情況並非如此。
迭代 3#
以前的 Button 組件的實現包括文本末尾的圖標,但新的設計要求圖標可以選擇放在文本的開頭。單一的 icon prop 將不再適合最新設計要求的需要。
有幾個不同的方向可以用來滿足這個新產品的要求。也許可以給 Button 添加一個iconPosition
prop 。但如果需要在兩邊都有一個圖標呢?也許我們的 Button 組件可以領先於這個假設的要求,對 prop 做一些改變。
單一的 icon prop 將不再適合產品的需要,所以它被移除。取而代之的是兩個新 prop,iconAtStart
和iconAtEnd
。
type ButtonProps = {
theme: 'primary' | 'secondary' | 'tertiary';
text: string;
iconAtStart?: 'cart' | '...all-other-potential-icon-names';
iconAtEnd?: 'cart' | '...all-other-potential-icon-names';
}
在重構代碼庫中 Button 的現有用途以使用新的 props ,另一個危機被避免了。現在,Button 有了一些靈活性。所有這些都是硬編碼的,並被包裝在組件本身的條件中,但可以肯定的是,UI 不知道的東西不會傷害它。
到目前爲止,Button 圖標一直是與文本相同的顏色。這似乎是合理的,也是一個可靠的默認值,但讓我們通過定義一個具有對比色的圖標的變體來給這個運轉良好的組件帶來麻煩。
迭代 4#
爲了提供一種反饋感,這個確認用戶界面階段被設計爲在物品被成功添加到購物車時臨時顯示。
也許這個時候,開發團隊會選擇對產品需求進行反擊。但儘管如此,還是決定繼續推進爲 Button 圖標提供顏色靈活性。
同樣,可以採取多種方法來解決這個問題。也許一個iconClassName
prop 被傳遞到 Button 中,以便對圖標的外觀有更好的控制。但是,還有其他的產品開發重點,因此,只能做一個快速修復。
因此,一個 iconColor
prop 被添加到 Butto n 中。
type ButtonProps = {
theme: 'primary' | 'secondary' | 'tertiary';
text: string;
iconAtStart?: 'cart' | '...all-other-potential-icon-names';
iconAtEnd?: 'cart' | '...all-other-potential-icon-names';
iconColor?: 'green' | '...other-theme-color-names';
}
隨着快速修復的到位,Button 圖標現在可以採用與文本不同的風格。UI 可以提供所設計的確認,而產品可以再次向前推進。
當然,隨着產品要求的不斷增長和擴大,他們的設計也在不斷髮展。
迭代 5#
在最新的設計中,Button 現在必須只用一個圖標來使用。這可以用幾種不同的方法來完成,然而,所有這些方法都需要進行一定程度的重構。
也許一個新的IconButton
組件被創建,將所有其他的按鈕邏輯和樣式重複到兩個地方。或者,這些邏輯和樣式被集中起來,在兩個組件中共享。然而,在這個例子中,開發團隊決定將所有的變體放在同一個 Button 組件中。
相反, text prop 被標記爲可選。這可以像在 props 中標記爲可選一樣快速,但如果有任何期望文本存在的邏輯,可能需要額外的重構。
但是問題來了,如果 Button 只有一個圖標,應該使用哪個圖標道具?iconAtStart
和iconAtEnd
都沒有適當地描述 Button。最終,我們決定把原來的圖標道具帶回來,用於僅有圖標的變體。
type ButtonProps = {
theme: 'primary' | 'secondary' | 'tertiary';
iconAtStart?: 'cart' | '...all-other-potential-icon-names';
iconAtEnd?: 'cart' | '...all-other-potential-icon-names';
iconColor?: 'green' | '...other-theme-color-names';
icon?: 'cart' | '...all-other-potential-icon-names';
text?: string;
}
現在,Button 的 API 越來越令人困惑。也許在組件中留有一些註釋,以解釋何時和何時不使用特定的 prop,但學習曲線越來越陡峭,出錯的可能性也越來越大。
例如,如果不給 ButtonProps
類型增加巨大的複雜性,就無法阻止一個人同時使用 icon 和 text prop。這可能會破壞用戶界面,或者在 Button 組件本身中用更復雜的條件來解決。此外, icon
prop 也可以與iconAtStart
和IconAtEnd
prop 中的一個或兩個一起使用。同樣,這可能會破壞用戶界面,或者在組件內用更多的條件層來解決。
我們心愛的 Button 在這一點上已經變得相當難以管理了。希望產品已經達到一個穩定點,不會再有新的變化或要求發生。永遠。
迭代 6#
這麼說來,永遠不會有任何變化了。🤦
Button
的下一個也是最後一個迭代是傳說中壓垮駱駝的那根稻草。在添加到購物車的按鈕中,如果當前物品已經在購物車中,我們想在按鈕上顯示其中的數量。從表面上看,這是一個直接的變化,即動態地建立 text
prop 字符串。但是這個組件被打破了,因爲當前商品數量需要一個不同的字體重量和下劃線。因爲 Button 只接受純文本字符串,沒有其他子元素,所以這個組件不再工作。
這個設計如果是第二次迭代的話,會不會導致按鈕失效呢?也許不會。那時組件和代碼庫都還很年輕。但是到目前爲止,代碼庫已經增長了很多,要爲這個需求進行重構簡直就像是攀登高峯。
這時可能會發生以下事情之一。
-
做一個更大的重構,把 Button 從一個 text prop 移到接受 children 或接受一個組件或標記作爲
text
。 -
該 Button 被分割成一個單獨的 AddToCart 按鈕,有一個更嚴格的 API,專門用於這一個用例。這也是將任何按鈕的邏輯和樣式複製到多個地方,或者將它們提取到一個集中的文件中,以便到處共享。
-
按鈕被棄用,並創建了一個 ButtonNew 組件,分裂了代碼庫,引入了技術債務,並增加了入職學習曲線。
兩種結果都不理想。
那麼,"按鈕" 組件在哪裏出了問題?
分享是一種損害
HTML button 元素的職責究竟是什麼?縮小這個答案將照亮之前 Button 組件所面臨的問題。
原生的 HTML button 元素的職責不過如此:
-
顯示,沒有意見,無論什麼內容被傳入它。
-
處理本地功能和屬性,如
onClick
和disabled
。
是的,每個瀏覽器對按鈕元素的外觀和顯示內容都有自己的版本,但 CSS 重置通常被用來剝奪這些意見。因此,按鈕元素歸根結底只是一個用於觸發事件的功能性容器而已。
對按鈕內的任何內容進行格式化不是按鈕的責任,而是內容本身的責任。按鈕不應該關心。按鈕不應該分擔對其內容的責任。
體貼的組件設計的核心問題是,組件 prop 定義了內容而不是組件本身。
在以前的 Button 組件中,第一個主要限制是 text
prop 。從第一次迭代開始,就對 Button 的內容進行了限制。雖然 text prop 符合那個階段的設計,但它立即偏離了本地 HTML 按鈕的兩個核心職責。它立即迫使 Button 意識到並對其內容負責。
在接下來的迭代中,圖標被引入。雖然在 Button 中加入一個有條件的圖標似乎很合理,但這樣做也偏離了按鈕的核心職責。這樣做限制了該組件的使用情況。在後來的迭代中,圖標需要在不同的位置可用,而 Button 的 prop 也被迫擴展到圖標的樣式。
當組件對它所顯示的內容負責時,它需要一個能適應所有內容變化的 API。最終,這個 API 將被打破,因爲內容將永遠永遠地改變。
介紹一下團隊中的我 #。
在所有團隊運動中都有一句格言:"團隊中沒有'我'"。雖然這種心態很高尚,但一些最偉大的個人運動員卻體現了其他想法。
邁克爾 - 喬丹用他自己的觀點做出了著名的迴應:"勝利中有一個'我'"。已故的科比 - 布萊恩特也有類似的想法,"[團隊] 裏有一個'M-E'"。
我們最初的 Button 組件是一個團隊成員。它分擔了其內容的責任,直到它達到廢棄的地步。按鈕如何通過體現 "團隊中的 M-E" 的態度來避免這種限制?
我,我自己,還有 UI#
當組件對它所顯示的內容負責時,它就會崩潰,因爲內容將永遠永遠地改變。
一個自私的組件設計方法會如何改變我們最初的按鈕?
牢記 HTML 按鈕元素的兩個核心職責,我們的 Button 組件的結構會立即發生變化。
// 首先,從React擴展原生HTML按鈕屬性,如onClick和disabled
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
theme: 'primary' | 'secondary' | 'tertiary';
}
<Button
onClick={someFunction}
theme="primary"
>
<span>Add to cart</span>
</Button>
通過去掉原來的 text
prop 來代替無限的 children
,Button 能夠與它的核心職責保持一致。現在,Button 可以作爲一個觸發事件的容器而已。
通過將 Button 轉移到支持子內容的本地方法,不再需要各種與圖標相關的道具。現在,一個圖標可以在 Button 的任何地方呈現,無論其大小和顏色如何。也許各種與圖標相關的道具可以被提取到他們自己的自私的 Icon
組件中。
<Button
onClick={someFunction}
theme="primary"
>
<Icon />
<span>Add to cart</span>
</Button>
隨着特定內容的 prop 從 Button 中移除,它現在可以做所有自私的角色最擅長的事情,即思考自己。
// First, extend native HTML button attributes like onClick and disabled from React.
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
size: 'sm' | 'md' | 'lg';
theme: 'primary' | 'secondary' | 'tertiary';
variant: 'ghost' | 'solid' | 'outline' | 'link'
}
有了專門針對自身的 API 和獨立的內容,Button 現在是一個可維護的組件。自身的 props 使學習曲線最小化和直觀化,同時爲各種 Button 的使用案例保留了極大的靈活性。
按鈕圖標現在可以放置在內容的兩端:
<Button
onClick={someFunction}
size="md"
theme="primary"
variant="solid"
>
<Box display="flex" gap="2" alignItems="center">
<span>Add to cart</span>
<Icon />
</Box>
</Button>
或者,一個 Button 可以只有一個圖標:
<Button
onClick={someFunction}
size="sm"
theme="secondary"
variant="solid"
>
<Icon />
</Button>
然而,一個產品可能會隨着時間的推移而演變,而自私的組件設計可以提高與之一起演變的能力。讓我們超越 Button,進入自私的組件設計的基石。
自私設計的關鍵#
與創造一個虛構的人物時一樣,最好是向讀者展示,而不是告訴他們,他們是自私的。通過閱讀人物的思想和行動,可以瞭解他們的個性和特徵。組件設計也可以採取同樣的方法。
但是,我們究竟如何在一個組件的設計和使用中表明它是自私的?
HTML 驅動組件設計#
很多時候,組件是作爲本地 HTML 元素的直接抽象而構建的,比如 button
或 img
。在這種情況下,讓本地 HTML 元素來驅動組件的設計。
具體來說,如果本地 HTML 元素接受子元素,那麼抽象的組件也應該接受。一個組件的每一個方面如果偏離了它的原生元素,就必須重新學習。
當我們最初的 Button 組件因爲不支持子內容而偏離了按鈕元素的原生行爲時,它不僅變得僵硬,而且需要轉變思維模式才能使用該組件。
在 HTML 元素的結構和定義方面,已經投入了大量的時間和精力。輪子不需要每次都被重新發明。
children 自食其力#
如果你讀過《蠅王》,你就知道當一羣孩子被迫自食其力時,會有多危險。然而,在自私的組件設計案例中,我們要做的正是這樣。
正如我們最初的 Button 組件所顯示的那樣,它越是試圖對其內容進行樣式設計,它就越是僵硬和複雜。當我們去掉這個責任時,這個組件就能做得更多,但卻少了很多。
許多元素只不過是語義上的容器而已。我們並不經常期望一個章節元素能夠爲其內容提供樣式。一個按鈕元素只是一個非常特殊的語義容器類型。當把它抽象爲一個組件時,同樣的方法可以適用。
組件是單一的重點#
把自私的組件設計想象成安排一堆糟糕的第一次約會。一個組件的 prop 就像完全以他們和他們的直接責任爲中心的對話。
我看起來怎麼樣?
prop 需要滿足組件的自我要求。在我們重構的 Button 例子中,我們用大小、主題和變體等 prop 做到了這一點。
我在做什麼?
一個組件應該只對它,而且是它自己正在做的事情感興趣。同樣,在我們重構的 Button 組件中,我們用onClick
prop 來做這個。就 Button 而言,如果在其內容的某個地方有另一個點擊事件,那是內容的問題。按鈕並不關心。
我的下一站是什麼時候,在哪裏?
任何噴射性的旅行者都會很快談論他們的下一個目的地。對於像模態、抽屜和工具提示這樣的組件來說,它們何時何地也同樣重要。像這樣的組件並不總是在 DOM 中呈現的。這意味着,除了知道它們的外觀和作用之外,它們還需要知道何時何地。換句話說,這可以用isShown
和position
這樣的 props 來描述。
構圖爲王
一些組件,如模版和抽屜,往往可以包含不同的佈局變化。例如,有些模版會顯示一個標題欄,而其他模版則沒有。一些抽屜可能有一個帶有行動呼籲的頁腳。其他的可能根本沒有頁腳。
與其在單個模態或抽屜組件中用條件 props (如hasHeader
或showFooter
)定義每個佈局,不如將單個組件分解成多個可組合的子組件。
<Modal>
<Modal.CloseButton />
<Modal.Header> ... </Modal.Header>
<Modal.Main> ... <Modal.Main>
</Modal>
<Drawer>
<Drawer.Main> ... </Drawer.Main>
<Drawer.Footer> ... </Drawer.Footer>
</Drawer>
通過使用組件組合,每個單獨的組件可以像它想的那樣自私,只在需要的時候和地方使用。這樣可以保持根組件的 API 乾淨,並且可以將許多 prop 轉移到它們特定的子組件上。
當回顧我們的 Button 組件的演變時,也許自私的設計的關鍵是有意義的。然而,讓我們再把它們應用到另一個普遍存在問題的組件 -- 模態。
對於這個例子,我們在三個不同的模態佈局中得到了預見性的好處。這將有助於引導我們 Modal 的方向,同時沿途應用自私設計的每個關鍵。
首先,讓我們回顧一下我們的心理模型,並分解每個設計的佈局。
在 "Edit Profile" 模式中,有定義的頁眉、主頁和頁腳部分。也有一個關閉按鈕。在 Upload Successful 中,有一個修改過的頁眉,沒有關閉按鈕和一個類似英雄的圖像。頁腳的按鈕也被拉長了。最後,在 Friends 模態中,關閉按鈕返回,但現在內容區可以滾動,而且沒有頁腳。
那麼,我們學到了什麼?
我們瞭解到,頁眉、主頁和頁腳部分是可以互換的。它們可能存在於任何給定的視圖中,也可能不存在。我們還了解到,關閉按鈕的功能是獨立的,不與任何特定的佈局或部分相聯繫。
因爲我們的 Modal 可以由可互換的佈局和安排組成,這就是我們採取可組合的子組件方法的標誌。這將使我們能夠根據需要在模態中插入和播放部件。
這種方法允許我們非常狹隘地定義我們的根 Modal 組件的職責。
有條件地以任何內容佈局的組合進行渲染。
這就是了。只要我們的 Modal 只是一個有條件渲染的容器,它就永遠不需要關心或對其內容負責。
隨着我們的模態的核心職責被定義,以及可組合的子組件方法被決定,讓我們來分解每個可組合的部分和它的作用。
有了每個組件及其角色的定義,我們可以開始創建道具來支持這些角色和責任。
Modal
我們定義了 Modal 的基本職責,即知道何時有條件地渲染。這可以通過isShown
這樣的 prop 來實現。因此,我們可以使用這些 prop ,只要它是 `true``,Modal 和它的內容就會渲染。
type ModalProps = {
isShown: boolean;
}
<Modal isShown={showModal}>
...
</Modal>
任何造型和定位都可以直接用 CSS 在 Modal 組件中完成。目前不需要創建特定的 prop。
Modal.CloseButton
鑑於我們之前重構的Button
組件,我們知道CloseButton
應該如何工作。我們甚至可以用我們的 Button 來構建我們的CloseButton
組件。
import { Button, ButtonProps } from 'components/Button';
export function CloseButton({ onClick, ...props }: ButtonProps) {
return (
<Button {...props} onClick={onClick} variant="ghost" theme="primary" />
)
}
<Modal>
<Modal.CloseButton onClick={closeModal} />
</Modal>
Modal.Header, Modal.Main, Modal.Footer
每個單獨的佈局部分,Modal.Header
、Modal.Main
和Modal.Footer
,都可以從它們的 HTML 等價物,即header
、main
和footer
中獲取方向。這些元素中的每一個都支持子內容的任何變化,因此,我們的組件也會這樣做。
不需要特殊的 prop。它們只是作爲語義容器。
<Modal>
<Modal.CloseButton onClick={closeModal} />
<Modal.Header> ... </Modal.Header>
<Modal.Main> ... </Modal.Main>
<Modal.Footer> ... </Modal.Footer>
</Modal>
有了我們的 Modal 組件和它的子組件的定義,讓我們看看它們是如何被互換使用來創建這三種設計的。
注意:完整的標記和樣式沒有顯示出來,以便不影響核心的收穫。
EDIT PROFILE MODAL
在 Edit Profile 模態中,我們使用了每個Modal
組件。然而,每一個都只是作爲一個容器,它的樣式和位置都是自己的。這就是爲什麼我們沒有爲它們包含一個className
prop。任何內容的樣式都應該由內容本身來處理,而不是我們的容器組件。
<Modal>
<Modal.CloseButton onClick={closeModal} />
<Modal.Header>
<h1>Edit Profile</h1>
</Modal.Header>
<Modal.Main>
<div class> ... </div>
<form class> ... </form>
</Modal.Main>
<Modal.Footer>
<div class>
<Button onClick={closeModal} theme="tertiary">Cancel</Button>
<Button onClick={saveProfile} theme="secondary">Save</Button>
</div>
</Modal.Footer>
</Modal>
UPLOAD SUCCESSFUL MODAL
像前面的例子一樣,Upload Successful 模態使用其組件作爲無意見的容器。內容的樣式是由內容本身處理的。也許這意味着按鈕可以被modal-button-wrapper
類拉伸,或者我們可以給 Button 組件添加一個 "我看起來怎麼樣?" 的道具,比如isFullWidth
,以獲得更寬或全寬的尺寸。
<Modal>
<Modal.Header>
<img src="..." alt="..." />
<h1>Upload Successful</h1>
</Modal.Header>
<Modal.Main>
<p> ... </p>
<div class> ... </div>
</Modal.Main>
<Modal.Footer>
<div class>
<Button onClick={closeModal} theme="tertiary">Skip</Button>
<Button onClick={saveProfile} theme="secondary">Save</Button>
</div>
</Modal.Footer>
</Modal>
FRIENDS MODAL
最後,我們的 Friendsmodal 取消了 Modal.Footer 部分。在這裏,在 Modal.Main 上定義溢出樣式可能很誘人,但這是將容器的責任擴展到它的內容。相反,處理這些樣式在 modal-friends-wrapper 類中更合適。
<Modal>
<Modal.CloseButton onClick={closeModal} />
<Modal.Header>
<h1>AngusMcSix's Friends</h1>
</Modal.Header>
<Modal.Main>
<div class>
<div class> ... </div>
<div class> ... </div>
<div class> ... </div>
</div>
</Modal.Main>
</Modal>
總結
本文對組件設計中的一個重要概念——自私性進行了探討。自私性(Selfishness)在組件設計中是一種思維方式,意味着每個組件只關心其自身的功能和樣式,而不關心其他組件。該文章認爲,自私性可以幫助開發者創建更高效、易於維護的組件。
文章闡述了以下四個實踐自私性的方法:
單一職責原則:組件應該有一個明確的功能,並僅關注該功能。這使組件更容易理解、測試和複用。
避免外部依賴:組件應該減少對外部資源的依賴,這有助於提高組件的獨立性和複用性。
封裝樣式:組件的樣式應該內部定義,避免受到外部樣式影響。這樣做可以確保組件在不同的環境中保持一致性。
明確接口:組件應該具有清晰、明確的接口,以便其他開發者能夠容易地瞭解和使用組件。
作者強調,自私性並不意味着開發者應該孤立地工作,而是鼓勵他們關注組件本身,從而提高組件的質量。通過遵循上述原則,開發者可以創建更加健壯、可維護和可擴展的組件,爲整個項目帶來長遠的好處。
原文:https://www.smashingmagazine.com/2023/01/key-good-component-design-selfishness/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/PRZ-mraPa2RNVhOaq0HkEA