React 思維進化:
打破常見的學習門檻與觀念誤解
Zet Chou
-
iCHEF Lead Front-End Engineer
-
近十年前端開發經驗,九年 React 開發經
-
曾任 SITCON、JSDC 主議程講者
-
JSDC 2019 / 2020 議程組工作人員
-
第 14 屆 IThome 鐵人賽 Modern Web 組冠軍
-
《一次打破 React 常見的學習門檻與觀念誤解》
-
-
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》筆者
Zet
周昱安
About me
寫這本書的動機
為什麼 React 這麼難學的好
- React 有大量機制依賴了 JavaScript 的核心特性
- 所以 JS 基礎不穩的人在學習 React 時通常會非常辛苦
- React 的學習會比其他前端技術更依賴於設計模式等「心智模型的認知」
- React 的學習不適合「從實踐中出真知」,這些核心觀念並不會因為寫的 React 專案越多就突然自己憑空開竅
- React 的設計精神導致它很難確保開發者的下限,而是盡量拉高上限與減少黑魔法
- 在確保下限以及降低學習門檻的面向,Vue 就比 React 做得好很多
常見的 React 學習瓶頸
-
只會呼叫語法,卻對 React 運作的原理與觀念一知半解
-
JSX、Reconciliation、immutable update、useEffect...
-
-
觀念與觀念之間的關聯破碎,無法融會貫通變成一個知識體系
-
以上的問題導致實作了很多專案但技術理解卻沒有什麼進展,很常憑感覺以及模糊的經驗在寫程式,為專案的程式碼品質和可靠性埋下隱患
source: 9GAG
目前已有的學習資源的問題
- 現有的 React 學習資源品質參差不齊,甚至錯誤資訊比正確資訊還多,學習者容易接觸到不準確的觀念
- 觀念正確且優秀的 React 技術文章稀有,尤其是台灣正體中文的相關資源更少,而且這些文章通常偏向單點、碎片化知識時,缺乏足夠前提理解的學習者難以形成完整的知識框架與體系
- 因此,我才會想要透過參加鐵人賽以及出書來建立一個「連續性、系統性」的 React 核心觀念學習資源,將重點擺在技術背後的運作流程與觀念、設計思維與脈絡
常見的觀念誤解
- 在 React 中一切都是 component,component 是構成畫面的最小單位
- JSX 語法是在 JavaScript 當中寫 HTML
- useEffect 是 function component 的生命週期 API,可以用來模擬 class component 的
componentDidMount
、componentWillUnmount
常見的觀念誤解
在 React 中一切都是 component,component 是構成畫面的最小單位JSX 語法是在 JavaScript 當中寫 HTMLuseEffect 是 function component 的生命週期 API,可以用來模擬 class component 的componentDidMount
、componentWillUnmount
更好的 React 學習脈絡
學習一門程式技術的四大面向
核心觀念與原理
基礎語法、工具、API 的使用方法 & 基礎操作練習
專案實戰情境經驗累積
抽象化設計 & patterns
學習核心知識,了解程式運作的核心概念與原理。這是後續所有學習階段的重要根基,沒有先把觀念基本功打穩的話,後續的學習則大多都是在做白工,難以突破
學習實務操作的基本方法並動手練習,並且連結回核心觀念與知識,達到理論與實作的融會貫通
在實戰情境需求中實踐技術的應用,並從其中累積面對不同情境時需要額外注意並處理的面向的經驗
學習如何在正確滿足商業規格需求的同時,進一步讓程式碼設計得更有擴充彈性、可讀性,並易於測試
掌握基本技術能力
有一定的即戰力
通往 senior level 必須的能力之一
學習 React 應該從哪裡開始
- 絕大多數學習資源都會從 JSX 或 component 直接開始講起,而這會造成學習脈絡的斷層:
- JSX 語法的本質到底是什麼?為什麼可以在 JavaScript 裡面寫像是 HTML 的東西?
- 為什麼需要 component?它跟 React 畫面的管理機制有什麼關聯?
- 這些東西與過去未接觸前端框架時手動操作 DOM 的開發模式有什麼關聯?
學習 React 應該從哪裡開始
- 應該從完全沒有接觸過 React 或任何前端框架的程度開始銜接,並以循序漸進的脈絡慢慢延伸
- 有一定的 JavaScript 掌握程度
- 了解怎麼透過操作 DOM 來更新瀏覽器畫面
- 必須先從零一步一步解析 React 的「畫面管理機制」
- 過去未使用前端框架或解決方案時,處理 UI 畫面管理的痛點
- 了解 React 透過怎樣的策略以及實踐,來代替開發者操作 DOM 並管理畫面
畫面管理機制 Part.1:
基礎畫面構成與操作代理
DOM 與 Virtual DOM
- DOM 是一種樹狀資料結構,用於表示瀏覽器中的畫面元素
- 操作 DOM 會連動瀏覽器的渲染引擎重繪畫面因而效能成本昂貴
- Virtual DOM 是一種程式設計概念,它透過創建一個虛擬的畫面結構來模擬實際的 DOM,這種虛擬結構會持續同步化到與實際的 DOM,為 UI 管理提供了便利和效能優勢
- Virtual DOM 並非從實際 DOM 複製而來。Virtual DOM 就像是開發者描繪的畫面試做品,用於先定義期望的畫面結構,然後程式再依據 Virtual DOM 的結構來操作實 際的 DOM,使其與 Virtual DOM 的結構保持一致,所以同步化的方向為由 Virtual DOM ⇒ DOM 單向。
Virtual DOM ⇒ DOM 的轉換、同步化流程
畫面更新的策略:在新舊畫面的彩排之間尋找差異之處
React element
-
React element 是 React 基於 Virtual DOM 概念所實現的虛擬畫面結構元素,因此它是一種普通的 JavaScript 物件資料,用於描述一個預期的實際 DOM element 結構,同時也是作為在 React 中畫面結構描述的最小單位。
-
React element 可以透過呼叫
createElement
方法被建立,並且在經過 React 的處理轉換之後,就能自動產生對應的實際 DOM element。
建立 React element
Render React element
JSX
-
雖然可以透過呼叫
createElement
方法來產生 React element 以定義預期的畫面結構,但是相較於以往習慣的 HTML 標籤語法的開發體驗與便利性,還是有段明顯的差距 -
JSX 是一種語法糖,用於以更好的語法體驗來協助開發者定義並建立 Reactelement
-
JSX 的語法長得很像 HTML 只是因為它被刻意設計成在模仿 HTML 的撰寫以及閱讀體驗,但其本質既不是 HTML 也不是 DOM element,而是 React element 建立方法的呼叫。
-
可以透過如 Babel 等 transpiler 將 JSX 語法轉譯成實際可執行的
React.createElement()
呼叫語法。
DOM、Virtual DOM、React element、JSX 綜合關係圖
更多觀念與思維脈絡....
- Reconciler 與 renderer
- 新版 JSX transformer 與 jsx-runtime
- JSX 的語法設計脈絡
- 為什麼 JSX 語法的第一層只能有一個節點?
- ...以及更多額外知識點補充
畫面管理機制 Part.2:
單向資料流與渲染策略
單向資料流
資料驅動畫面
- 畫面結果是原始資料透過模板與渲染邏輯所產生的延伸結果
- 當資料發生更新時,畫面才會產生對應的更新,以資料去驅動畫面
資料與畫面的分離管理:單一資料來源
限縮變因的價值
- 在單向資料流的設計模式下,資料變動基本上只會來自於開發者手動的觸發資料更新,而畫面結果也只會由原始資料與模板邏輯這兩種變因所構成
- 限縮變因能帶來的好處:
-
提高可維護性
-
提升程式碼的可讀性
-
減少資料意外出錯的風險
-
更好的效能優化
-
實現單向資料流的 DOM 渲染策略
策略一:當資料更新後,人工判斷並手動修改所有
應受到連動更新的 DOM element
-
優點:
-
只要開發者 DOM 操作的夠簡潔精準的話,可以盡可能的減少因多餘DOM 操作造成的效能浪費
-
-
缺點:
-
完全依賴人為周全的判斷以及精確的操作 DOM 來維持單向資料流,在複雜的前端應用程式中非常困難
-
策略二:當資料更新後,一律將整個畫面的 DOM element
全部清除,再以最新的原始資料來全部重繪
-
優點:
-
開發者只需要關注模板定義以及資料更新的處理,不需要手動去維護資料連動的畫面操作,要維持單向資料流非常直覺簡單
-
-
缺點:
-
隨著應用程式的龐大與複雜,一律重繪的方式會因為大量不必要的 DOM操作而遇到明顯的效能問題,影響使用者體驗
-
前端框架的處理策略
-
無論選擇以上的哪一種渲染策略,都有著明顯且難以解決的缺點
-
大多數前端框架或解決方案都能透過一些特殊的架構設計來幫助我們解決資料連動畫面更新的需求,在保留這些渲染策略的優點的同時解決其缺點
React 中的一律重繪渲染策略
React 中的一律重繪渲染策略
-
Virtual DOM 這種概念在效能優化上的效益,是當畫面需要更新時,可以透過產生新的 Virtual DOM 畫面結構,然後比較新舊 Virtual DOM 畫面結構上的差異,並根據差異之處來執行最小範圍的實際 DOM 操作,以減少效能成本的浪費
-
「資料更新後,一律清空畫面再重繪畫面」的 DOM 渲染策略,雖然開發者只需關注模板定義及資料更新的處理,不需手動維護資料連動的 DOM 細節操作,而使得單向資料流的維持變得直覺簡單,但是一律重繪畫面的方式會因為大量且不必要的 DOM element 增刪而造成效能問題,則也是無法忽略的缺點
既然一律重繪實際的 DOM 很浪費效能的話,
那我們改成一律重繪虛擬的 DOM 不就好了嗎?
既然一律重繪實際的 DOM 會導致大量的效能浪費,
那麼就改為一律重繪虛擬的畫面結構資料,也就是 React element
初始畫面的渲染
更新畫面的渲染
更新畫面的渲染
更新畫面的渲染
React 透過 Virtual DOM 概念與一律重繪渲染策略的結合,實現了可預測性高、易於維護且可靠的單向資料流,並同時避免了不必要的大量 DOM 操作所帶來的效能問題
畫面管理機制 Part.3:
component & state
Component
Component 是什麼
- 是一種由開發者自定義的畫面元件藍圖,是可重用的程式碼片段,負責裝載特定意義範圍的畫面內容或邏輯。
- React component 可以透過一個普通的 JavaScript 函式來定義,並透過建立 React element 來呼叫。
藍圖與實例
- Component function 本身是描述特徵和行為的藍圖,而根據這個藍圖產生的實際個體,被稱為實例。每個實例都有其獨立的狀態,並不會受到相同藍圖的其他實例的影響
-
值得一提的是,component 類型的 React element(例如
<MyComponent />
)嚴格上來說並不是 component 的實例,而是「描述畫面中的該處需要維持一個 component 實例的標記」,React 在判讀到這個 React element 之後,才會在內部機制中自動建立並維持一個真正的 component 實例,這種實例被稱作「fiber node」
Props
- Props 是一種 React 的內建機制,在呼叫 component 的時候可以將特定的參數 從外部傳遞給 component 這個藍圖的內部,使其能夠根據傳入的參數來進行一 些畫面產生流程的客製化,以應付更多的需求情境。
- Props 是唯讀的,以維護單向資料流的穩定性,確保資料來源的唯一性和可追蹤性,避免資料異動源頭難以追蹤以及修改後引發的連鎖問題。
Component 的一次 render
-
當我們在畫面中呼叫一個 component 時,會執行 component function 來產生區塊畫面的 React element 結果,這個過程被稱為「一次 render」
-
如果 component 的回傳內容包含其他子 component,則父 component 的 render 會觸發子 component 的 render,形成層次化結構,且整個過程由上而下、由外而內進行
從一律重繪策略到 component 的 re-render
-
當一個 component function 首次被呼叫並執行時,它會進行第一次的 render 來產生初始狀態的畫面的 React element
-
當 component 內部狀態更新,React 會重新執行 component function 以新版資料來產生構成對應新版畫面的 React element,即「re-render」
-
Re-render 的過程中,React 不會去修改舊的 React element,而是根據新的資料重新產生一份新版的 React element,然後透過比較新舊元素結構來找出差異,並針對這些差異之處更新實際 DOM
State
State 是什麼
- State 是前端應用程式中用於記憶狀態的臨時、可更新資料,並且在資料更新時更新對應的畫面
- React 採用單向資料流以及一律重繪的策略,且將更新 state 資料的動作作為發起重繪機制的啟動點
- 在 React 中,state 必須依附在 component 內才能記憶並維持狀態資料,而發起該 state 資料的更新並啟動重繪時,也只會重繪該 component 以內(包含子 孫代 component)的畫面區塊
useState
-
useState
接收一個initialState
參數作為該 state 的初始預設值,並回傳一個陣列,該陣列會包含兩個項目:- 第一個是「該次 render 的當前 state 值」。
- 第二個是「用來更新 state 值的
setState
方法」,是一個普通的 JavaScript 函式
呼叫 setState
方法
-
透過呼叫
setState
方法來更新 state,並觸發該 component 的 re-render
更多觀念與思維脈絡....
-
Component 抽象化設計的基本概念
-
修改 props 但無法被 React 偵測到的危害案例
children
prop 在抽象化設計中的常見用途-
為什麼 component 命名中的首字母必須為大寫
-
為什麼
useState
的回傳值是一個陣列 -
React 如何辨認同一個 component 中的多個 state
-
...以及更多額外知識點補充
畫面管理機制總結:
reconciliation
目前為止的畫面管理機制觀念複習
- React 以 Virtual DOM 的概念來描述畫面結構,也就是 React element。而 React element 可以透過 React 的轉換,成為對應的實際 DOM element
- React 採用了一律重繪的策略來實踐單向資料流,並透過新舊畫面的比較(也就是新舊 React element 的比較)來尋找差異之處,並只操作更新這些差異之處所對應的實際 DOM element,以避免多餘的 DOM 操作所帶來的效能問題
-
Component 是畫面組成的區塊藍圖,而 state 是依附於 component 來運作的「狀態資料」。State 作為 React 中單向資料流的起點,其 setState 方法也是發起畫面重繪的啟動點
-
Component 也是一律重繪的界線。當 component 中的 state 發起更新時,只會重繪該 component 以內(包含子孫代 component)的畫面區塊
首次 render 時
更新畫面時:reconciliation
步驟一:呼叫 setState 方法更新 state 資料,並發起 re-render
- 呼叫 setState 方法並傳入新的 state,React 會以
Object.is()
檢查新舊 state 是否相同:- 如果判定相同則直接中斷流程,不會啟動後續的 reconciliation
- 如果判定不同則觸發 component 的 re-render
步驟二:更新 state 資料並 re-render component function
-
此時 React 會根據稍早呼叫
setState
方法時所指定的新值來更新 state,並再次執行 component function,以新版本的 props 與 state 等資料來 render 出一份新版的 React element
步驟三:將新舊版本的 React element 進行結構比較,
並更新差異之處所對應的實際 DOM element
-
將新版 React element 與前一次 render 時產生的舊版 React element,以 diffing 演算法進行樹狀結構的比較
-
操作更新那些新舊 React element 結構差異之處所對應的實際 DOM element,以完成畫面的更新。
步驟三:將新舊版本的 React element 進行結構比較,
並更新差異之處所對應的實際 DOM element
-
將新版 React element 與前一次 render 時產生的舊版 React element,以 diffing 演算法進行樹狀結構的比較
-
操作更新那些新舊 React element 結構差異之處所對應的實際 DOM element,以完成畫面的更新。
Component render 資料流
Function component 與 Class component
你可能不知道的關鍵區別
Consider this demo...
Class component 的 this.props
在非同步事件中的存取陷阱
-
在 class component 中,儘管 props 是 immutable 的,但 this 不是。每次 rerender 時,React 會用新的 props 覆蓋 this 中的舊版 props。因此,使用 this.props 將獲得最新的 props 資料。這可能導致在非同步事件中,錯誤的讀取到最新版的 props,而非預期的舊版資料
Function component 會自動 「捕捉」
render 時的資料
每次 render 都有其自己版本的 props 與 state
-
React 不會去監聽資料的變化,你必須自己主動告知 React(也就是呼叫
setState
方法)有資料需要更新並觸發 re-render -
React 不會在
setState
方法被呼叫時檢查新舊資料之間的詳細差異之處(例如陣列或物件裡的細節內容差異),而是只會以Object.is
方法簡單的比較一下來決定是否繼續發起 reconciliation -
一次 render 做的事情就是以當下版本的 props 與 state 重新執行一次 component function
-
每次 render 時都會捕捉到屬於它自己版本的 props 與 state 值作為快照,這個值是個只存在於該次 render 中的常數,其內容永遠不會改變
目前為止的 render 資料流觀念整理
每次 render 都有其自己版本的 event handler 函式
-
本次 render 所宣告的
count
變數為 2,且這個變數的值永遠不會改變 -
本次 render 宣告了一個新的
handleAlertButtonClick
函式作為 event handler,而其內容中會連帶宣告了一個新的setTimeout
callback 函式,且該 callback 函式存取了本身作用域外的變數count
,並且會因為 closure 的特性而一直記得這個變數。而由於count
變數的值永遠不變的緣故,所以等同於這個setTimeout
callback 函式所讀取到的資料也永遠不變 -
本次 render 最後會產生一份新的 React element,其中在按鈕的
onClick
上綁定本次 render 新產生的handleAlertButtonClick
函式
更多觀念與思維脈絡....
-
Component 的生命週期
-
Immutable 資料與 closure 的結合
-
...以及更多額外知識點補充
Effect
什麼是 effect
-
副作用(effect)是指一個函式除了回傳結果值之外,還會與函式外部環境互動或產生其他影響,例如修改全域變數或進行網路請求
副作用所帶來的負面影響
-
在程式設計中,一個函式中帶有副作用並不一定是絕對的壞事。在某些情境下,這些被認為是「副作用」的行為所帶來的影響與效果,反而才是該函式主要的執行作用。不過,比起沒有副作用的函式,帶有副作用的函式確實可能會造成一些負面的影響:
-
可預測性降低
-
測試困難
-
高耦合度
-
難以維護和理解
-
優化限制
-
React component function 中的副作用
函式多次執行所疊加造成的副作用影響難以預測
副作用可能會拖慢甚至阻塞函式本身的計算流程
為什麼我們需要 useEffect
來處理副作用
-
若在 component function 中直接執行副作用的處理,則當它隨著應用程式狀態 更新而多次 re-render 後,這些副作用所造成的影響就會不斷疊加,且其疊加次數難以預期
-
直接在 component function 中執行副作用的處理可能會阻塞其產生 React element 的過程,導致畫面更新的卡頓而引發效能問題
useEffect
-
effectFunction
:-
是一個函式,可以在裡面放置你所需要的副作用處理邏輯。而如果這個副作用所造成的影響是需要被清理或逆轉的話,你可以讓
effectFunction
這個函式本身回傳另一個包含清理副作用流程的 cleanup 函式 -
會在 component 每次 render 完成且實際 DOM 被更新後被執行一次。每當一次 render 完成後,React 會先執行前一次 render 所對應的 cleanup 函式(如果有
提供的話),然後才執行本次 render 的 effect 函式
-
Component unmount 時也會執行最後一次 render 的 cleanup 函式
-
useEffect
-
dependencies
:-
是一個可選填的陣列參數。這個陣列應包含在
effectFunction
中所有依賴到的component 資料項目,例如:props、state 或任何會受到資料流影響的延伸資料
-
如果不提供
dependencies
陣列這個參數的話,effectFunction
預設會在每一次render 之後都被執行一次。而如果有提供此參數的話,則 React 會在 re-render
時以 Object.is 方法來一一比較陣列中所有依賴項目的值與前一次 render 時的
版本是否相同,如果都相同的話則會跳過執行本次 render 的
effectFunction
-
步驟一:定義一個 effect 函式來處理副作用
步驟二:加上 cleanup 函式來清理副作用
(如果有需要的話)
步驟三:指定 effect 函式的依賴陣列,
以跳過不必要的副作用處理
每次 render 都有其自己版本的 effect 函式
每次 render 都有其自己版本的 effect 函式
每次 render 都有其自己版本的 effect 函式
每次 render 都有其自己版本的 cleanup 函式
每次 render 都有其自己版本的 cleanup 函式
Component 的每次 render 都有其自己版本的 props 與 state 快照,它們的值是永遠不變的
而 render 中所定義產生的各種函式 —— 包括 event handler 函式、effect 函式、cleanup 函式⋯等等,都會透過 closure「捕捉並記住」該次 render 中的 props 與 state 快照,因此無論這些函式在多久之後才被執行,它所讀取到的 props 與 state 內容永遠是固定不變的
useEffect 其實不是 function component 的生命週期 API
useEffect
是一種宣告式的同步化,而不是 function component 的生命週期 APIuseEffect
讓你根據 render 中的 props 和 state 資料來同步化那些與畫面無關的其他東西,也就是副作用的處理-
這個副作用處理無論隨著 render 重複執行了多少次,你的資料流以及程式邏輯都應該保持同步化且正常運作
-
如果你嘗試去控制 effect 函式只會在第一次的 render 才執行的話,其實是違反了
useEffect
本身的設計思維
Dependencies 是一種效能優化,而非執行時機的控制
Dependencies 是一種效能優化,而非執行時機的控制
-
首次 render 時,
props.name
的值是'foo'
,state count 的值則是0
:-
Render 出畫面 React element,瀏覽器完成實際畫面 DOM 的處理。
-
執行本次 render 對應 props.name 的值為
'foo'
的 effect 函式 -
記憶這個
useEffect
的dependencies
陣列中所有變數的值,也就是「props.
name
的值為'foo'
」,以供下一次 render 時作為依賴比較的依據
-
-
經過使用者的按鈕點擊觸發
setCount
方法,而引發第二次 render 時,props.name
的值沒有改變仍是'foo'
,statecount
的值則是變成1
:-
Render 出畫面 React element,瀏覽器完成實際畫面 DOM 的處理
-
執行本次 render 對應 props.name 的值為
'foo'
的 effect 函式 -
記憶這個
useEffect
的dependencies
陣列中所有變數的值,也就是「props.
name
的值為'foo'
」,以供下一次 render 時作為依賴比較的依據
-
-
如果父 component re-render 而連帶引發第三次 render 時,假設
props.name
的值變為'bar'
,statecount
的值則仍是1
:- Render 出畫面 React element,瀏覽器完成實際畫面 DOM 的處理
-
檢查
useEffect
的dependencies
陣列中所有項目的值(這個範例中只有props.name
一個依賴),與前一次 render 時的版本是否全部相同(在之前的 render 時有記憶下來,所以可以拿出來比較) -
前一次 render 版本的
props.name
為'foo'
,而本次 render 版本的props.name
為'bar'
,因此判定依賴資料與前一次 render 版本的值有所不同,所以應該照常執行本次 render 的 effect 函式。 -
執行本次 render 對應 props.name 的值為
'bar'
的 effect 函式 -
記憶這個
useEffect
的dependencies
陣列中所有變數的值,也就是「props.name
的值為'bar'
」,以供下一次 render 時作為依賴比較的依據
副作用沒有任何依賴資料時的 dependencies
Dependencies 是用來判斷「何時可以安全的跳過」,
而不是指定「只有何時才會執行」
這個觀念的邏輯其實很簡單,因為 dependencies 這種「依賴沒更新時可以安全的跳過」機制其實並不等同於「依賴沒更新時就保證會跳過」的效果。「可以安全的跳過」嚴謹的意思是「如果跳過也不會造成任何問題,但即使因為某些原因所以放棄該 次優化而不跳過、照常執行的話也不會有問題」。
欺騙 dependencies 會造成什麼問題
更多觀念與思維脈絡....
-
讓 effect 函式對於依賴的資料自給自足
-
處理函式型別的依賴
-
以 linter 來輔助填寫 dependencies
-
Effect dependencies 常見的錯誤用法
-
React 18 的 effect 函式在 mount 時為何會執行兩次?
-
副作用處理的常見情境設計技巧
-
...以及更多額外知識點補充
關於《React 思維進化》
全彩的視覺閱讀體驗+軟精裝限量
- 注重觀念理解
- 深入剖析 React 核心觀念與運作原理
- 脈絡循序漸進
- 從零堆疊技術脈絡,新手老手都適用
- 額外技術知識點專欄
- 補充文中提及的各種技術知識點
- 筆者思維分享
- 分享筆者對於 React 學習脈絡的個人獨到見解
本書特色
目錄一覽
Q & A 交流
活動現場限定價
- 天瓏書局為本次活動提供現場限定價:550 元
- 現場限量 30 本
- 相當於打七折,門市或線上買的話正常通路價是七八折 616 元
- 現場買還附贈本書同款書籤小禮物(已經有買的朋友憑書也可以來找我領書籤)
Thanks!
React
By tz5514
React
- 165