十分鐘完整理解 createAsyncThunk 如何處理非同步操作

閱讀時間約 19 分鐘
如何完整理解 createAsyncThunk?

如何完整理解 createAsyncThunk?

在學習 Redux 和 Redux Toolkit 時,最頭痛的莫過於處理非同步操作,也因此有了這一篇文章。在 Redux Toolkit 中的 createAsyncThunk 函數中,有個關鍵字 Thunk,其實就是來自於內建的工具 Redux-Thunk。

那麼,Thunk 是什麼?

在軟體領域中,"Thunk" 是一個常用的術語,它指的是一種用於延遲計算,或將運算延後執行的程式碼片段。它通常用於函數式編程,或編譯器的設計中。

如何在 Redux 中應用 Thunk

這裡就要稍微提到中介軟體(Middleware)的概念。Middleware 在 Redux 中,指的是在 dispatch 派發任務後、在 action 實際執行前,中間這段可以額外處理的過程。整個概念,其實就和 Thunk 本身的含意相似。

在 Redux Toolkit 裡,處理異步的資料處理,我們可以使用 createAsyncThunk 這個 API。createAsyncThunk 其實本質是一個 Action creator,但透過 dispatch 前處理異步的行動,再將資料提供給 dispatch 來執行派發,以達到 Middleware 處理資料的目的。

要實現 Thunk 總共有三個步驟,分別是:

  1. 處理資料:使用 createAsyncThunk 建立 action 與異步資料處理
  2. 派發行動:使用 extraReducer 處理 action 派發後續使用的 reducer
  3. 執行任務:使用 dispatch 實際派發,執行 reducer

在瞭解具體的案例前,先讓我們瞭解 createAsyncThunk 如何使用吧!



一、createAsyncThunk 的用法

究竟什麼是 createAsyncThunk?

究竟什麼是 createAsyncThunk?

在這個區段,為了讓讀者瞭解完整 Thunk 運作的流程,總共會說明三個部分。分別是:

A: createAsyncThunk 參數輸入
B: 如何處理 Thunk 返回值
C: 完整的 Thunk 派發過程


A. createAsyncThunk 參數輸入

createAsyncThunk

createAsyncThunk

createAsyncThunk 總共有三個參數,分別是:

  1. Type
  2. PayloaderCreator(arg, ThunkAPI)
  3. options

下面會逐步說明每一個參數的用途,並說明可以如何設定每個參數。

type 參數

用於設定額外的 Action Type,讓 Redux 可以辨識你使用的 Action 是什麼。通常都會以 “slice/action” 的寫法。且使用 Thunk 時,Redux 會自動生成額外的 pending/fulfilled/rejected 三個 Action,會於後續詳細說明。

payloadCreator 參數

需要傳入一個 Promise,該函式用於放置我們的異步操作,通常會使用 Async 函式來處理。payloadCreator 會傳入兩個參數,分別是 argThunkAPI

  • arg:便是我們在使用 Action 時,接收傳入資料作為參數的地方
  • ThunkAPI:則是 Redux 提供一整組的 API,例如獲取 getState、dispatch、extra 傳入其他參數等 API。

Options 參數

Options 會傳入一個物件,常見的有下列一些屬性,其他可以參考官方文件

  • condition:用於中斷建立 payload creator 的 condition 屬性。
  • dispatchConditionRejection: true,用於強制返回一個 rejected 的 Action 並派發。



B. 如何處理 Thunk 返回值

在處理完資料後,Thunk 會返回一個標準的 thunk Action creator。總共會返回四個 Action creator

  1. “slice/action”:會創建一個 Thunk 本身的 action creator,用於派發 Thunk 的 Action。
  2. “slice/action/pending”:會創建一個用於派發 pending 狀態的 Action creator。
  3. “slice/action/fulfilled”:會創建一個用於派發 fulfilled 狀態的 Action creator。
  4. “slice/action/rejected”:會創建一個用於派發 rejected 狀態的 Action creator。



C. 完整的 Thunk 派發過程

總共會有四個步驟,分別是:

  1. 等待:派發(dispatch)並執行 slice/action/pending
  2. 解析:呼叫 payloadCreator 並等待 Promise resolve
  3. 派發
    1. 若成功,派發 fulfilled 並傳遞值給 action.payload
    2. 若失敗,返回 ThunkAPI.rejectWithValue(value) 的錯誤值,並傳遞至 action.payload。可在 action.error.message 獲得 'Rejected' 字串;若未處理,則派發一個衍生錯誤至 action.error
  4. 返回總是返回一個 fulfilled 狀態的 Promise,可能包含 fulfilled / reject 狀態的資料。

在說明完 createAsyncThunk 的函式後,我們就以一個實際案例來說明。到底如何發揮 createAsyncThunk 的威力。



createAsyncThunk 實際案例說明

整個案例可以分成三個部分來理解,分別是:

  1. 建立 action 與異步資料處理
  2. cartSlice 處理 reducer 派發 Action
  3. 在元件中派發 Action

基本上所有的 Thunk 派發邏輯,都遵守上面的這個順序。讀者可以直接參考上方內容,並調整成自己希望使用的模式。


1. 建立 action 與異步資料處理

createAsyncThunk 函式使用

createAsyncThunk 函式使用

通常我們在撰寫 AsyncThunk 函式時,會將該函式放在 xxxActions.js 的檔案裡,用來特別區分 Action 相關的功能。此處,我們將 Thunk 提供的 typepayloadCreatoroptions 三個參數分開來看。

第一部份 type:我們設定在 cartSlice 中的 fetchCartData 這個 Action,故使用 "cart/fetchCartData" 來撰寫,這也符合 Redux 設定的規範。

第二部分 payloadCreator:此處傳入一個異步函式。因為這個 Thunk 是用來獲取資料,因此不需要傳入 Data,故用 _ 來忽略變數。之中對資料進行 json 解析,並處理部分狀態為 fulfilled 但 ok 為 false 的錯誤。

第三部分 options:可選參數,因為目前並沒有需要終止 Action 或其他需求,因此沒有設定第三個物件參數。



2. 在 cartSlice 處理 reducer 派發 Action

處理 Thunk 派生的 reducer

處理 Thunk 派生的 reducer

在這個階段,因為 reducer 並非直接設定在 slice 當中,因此 Redux 無法自動從 reducer 中生成 Action Creator,故需要使用 extraReducer 來處理 Thunk 派生的 Action Creator。

目前 Redux Toolkit 全面改用 builder 當作參數傳入,並使用 addCase 來接下 Action。

addCase 本身會有兩個參數:

  1. Action Type:用以傳入 Action 的 Type,此處其實可以手動輸入,但因為可能會有 Typo,因此慣例上都會使用 fetchCartDataThunk.fulfilled 寫法生成 Type。
  2. Callback:在派發之後,會實際執行 reducer 的地方。此處和內建的 reducers 享有 immer.js 帶來的便利,因此可以直接修改 state 來更新數據。



3. 在元件中派發 Action

在元件中讀取資料

在元件中讀取資料

附註:放入 dispatch 是為了避免 Prettier 跳出警示,但因為 dispatch 本身是讓 Redux 保證不會更新,因此可以放心放入。

因為該 Thunk 本身的目的,是在畫面讀取完後獲取資料,因此最合適在整個 App 處理完後,才進行下載的元件為 App.js

因此上述的 useEffect 程式碼,是放在 App.js 裡。確認後,在 App.js 讀取完後,就會執行 fetchCartDataThunk 的 Thunk 函式,並在更新後同步到元件中。

完整程式碼範例

// IN cartActions.js​
export const fetchCartDataThunk = createAsyncThunk(
"cart/fetchCartData",
async (_, { dispatch }) => {
try {
const response = await fetch(URL);
if (!response.ok) throw new Error("Fetch cart data failed.");
const data = await response.json();
return data;
} catch (error) {
console.error(error);
dispatch(notifyFor(messages.error));
}
}
);

// IN cartSlice.js
const cartSlice = createSlice({
name: "cart",
initialState: initialState,
reducers: [],
extraReducers: (builder) => {
builder
.addCase(fetchCartDataThunk.fulfilled, (state, action) => {
state.cartItems = action.payload?.cartItems || [];
state.cartQuantity = action.payload?.cartQuantity || 0;
})
.addCase(fetchCartDataThunk.rejected, (state, action) => {
state.status = "error";
state.error = action.error;
});
},
});

// IN App.js

const dispatch = useDispatch();

useEffect(() => {
dispatch(fetchCartDataThunk());
}, [dispatch]);

createAsyncThunk 整體的使用,是不是其實比想像中簡單呢!



如果元件需要知道錯誤資訊?

需要知道 Thunk 所使用的 payloadCreator 總是會返回 fulfilled 狀態的 Promise,因為他們不希望在開發者沒有 catch error 的情況下,就跳出 Uncaught Error。

因此 Thunk 會返回一個 fulfilled 的 Promise,並在該 Promise 中儲存 Fulfilled 或 Rejected 的資料,並讓我們獲取這些資料進行處理。

所以如果直接使用 then 來串接後續的行為,則永遠只會收到 fulfilled 的 Promise 而已。 

如何像一般 Promise 般使用 Thunk?

參考自 Redux Toolkit 官網

參考自 Redux Toolkit 官網

當我們希望直接在元件中獲取資料,而不希望隔一層從 Redux 的 store 獲取錯誤資料,這時就可以使用 unwrapunwrapResult,用來進一步處理後續的行動。使用的方式有兩個,分別是:

  • unwrap:會直接解打包 dispatch 回傳的資料,並像一般的 Promise 進行處理。
  • unwrapResult:相同目的,但以函式傳入 dispatch 後的返回值。

那如果中間有錯誤,Promise 要回傳的錯誤是怎麼來的呢?其實是我們需要從 ThunkAPI.rejectWithValue(value, [meta]) 特別設定傳入,不然 Redux 就會自動衍生一個序列化錯誤(Serialized Error)。

unwrap 的用法

參考自 Redux Toolkit 官網

參考自 Redux Toolkit 官網

unwrapResult 的用法

參考自 Redux Toolkit 官網

參考自 Redux Toolkit 官網

除此之外,還有更多的 API 可以使用,可以自行參考官方文件 的內容。



那 Middleware 在哪裡?

createAsyncThunk 除了能夠讓我們自動派發不同 Promise 的狀態,他也自動幫我們加入了其他三個 middleware 處理:

  1. 檢驗資料是否沒有被 mutate
  2. 是否有無法序列化的資料
  3. 確認 Action creator 是否有被錯誤執行

具體的返回值,在 configureStore 裡,有個參數是:middleware,此處可以手動設定所有的 middleware。

一旦設定該屬性,Redux 就只會讀取該陣列裡的 middleware。若要載入預設的 middleware,則可使用 getDefaultMiddleware() 來獲取。

const cartSlice = createSlice({
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});

// getDefaultMiddleware will return as follow
const middleware = [
actionCreatorInvariant,
immutableStateInvariant,
thunk,
serializableStateInvariant,
];



另一個完整實際案例

這個案例,透過 PUT 方式,來將購物車(Cart)的資料上傳到資料庫裡。整個案例分成兩個階段:

  1. 階段一:處理 Thunk 要上傳的函式
  2. 階段二:使用 useEffect 處理資料更動

並且我們為了讓畫面的 UI 可以根據異步狀態更新,因此使用了 ThunkAPI 來派發另一個負責處理 UI 的 Action 來派發任務。


1. 處理 Thunk 函式

raw-image

在這個階段,我們設定了 Typecart/sendCartData,並在 payloadCreator 傳入 cart 作為 arg;並從 ThunkAPI 抽取出 { dispatch } 的 API 來派發任務。

整個上傳的過程,分成三個階段:

  1. 開始上傳前,先更新 UI 為 loading
  2. 實際上傳 PUT 取代資料
  3. 若成功,則更新 UI 為 Success;若失敗,則更新 UI 為 Error



2.更新資料後,派發 Thunk 程式上傳

raw-image

接著在 App 元件裡,在 useEffect 中透過 dependencies,追蹤 cart 資料的更動。其中,為了讓第一次執行時,不要讓空白的 cart,直接上傳取代原本儲存的資料。故使用 isInitial 的變數,在第一次時就直接 return 跳出,後續才會上傳更新 cart 資料。

完整程式碼範例

// IN cartActions.js

export const sendCartDataThunk = createAsyncThunk(
"cart/sendCartData",
async (cart, { dispatch }) => {
try {
dispatch(uiActions.showNotification("Sending..."));
await fetch(URL, {
method: "PUT",
body: JSON.stringify({
cartItems: cart.cartItems,
cartQuantity: cart.cartQuantity,
}),
});
dispatch(uiActions.showNotification("Success!"));
} catch (error) {
dispatch(uiActions.showNotification("Error"));
console.log(error);
}
}
);

// IN App.js
let isInitial = true;

useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}

if (cart.isChanged) {
dispatch(sendCartDataThunk(cart));
}
}, [cart, dispatch]);



本文結論

在 Redux 裡面除了基礎的 state 更新外,算是非常複雜的功能和概念,也因此有了這一篇文章。createAsyncThunk 在異步資料的處理上,提供了完整且全面的設計與開發體驗。

希望讀者在使用 createAsyncThunk 時,可以少走一些彎路,並真正感受到 Thunk 帶來的便利與優勢。


延伸閱讀

技術文章

學習成長



Reference

此處作為整理前端(Frontend)和相關的 HTML、CSS、JavaScript、React 等前端觀念與技巧,全部都會收錄在這個專題之中。同時也會將相關的技術與反思記錄在此,歡迎各位讀者互相交流。
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
React 表單驗證是一種技術與使用者體驗的設計,讓使用者能夠即時檢查輸入的資料並修正,提升使用者的使用體驗,並確保資料的正確性。
useContext 是一種 React hook,讓我們能夠直接取用其他元件的 Context,而無須層層傳遞 props,進而使程式碼簡潔易讀。
接續上一篇 (上篇)為什麼目標設定又失敗?實戰目標管理 4 階段,讓你達成夢想目標!,本篇來說明四個階段的「階段三」和「階段四」: 目標設定 階段三:調整執行心態 目標設定 階段四:目標設定覆盤 這個階段,也是超過 70% 的機率,是採取正確的心態,與持續調整步伐。讓該專案意外順利堅持,並最終完成目
目標設定 階段一:確認當前目標 目標設定 階段二:設定可行目標 目標設定 階段三:調整執行心態 目標設定 階段四:目標設定覆盤
時間管理 技巧一:縱覽全局、明訂目標 時間管理 技巧二:善用零碎時間 時間管理 技巧三:根據目標校正心態
在轉職寫程式、自學程式語言的過程中,最害怕的莫過於遇到無從下手的問題。透過實際案例分享,讓零基礎從零到一的程式新手,也能快速學會如何解決複雜問題。
React 表單驗證是一種技術與使用者體驗的設計,讓使用者能夠即時檢查輸入的資料並修正,提升使用者的使用體驗,並確保資料的正確性。
useContext 是一種 React hook,讓我們能夠直接取用其他元件的 Context,而無須層層傳遞 props,進而使程式碼簡潔易讀。
接續上一篇 (上篇)為什麼目標設定又失敗?實戰目標管理 4 階段,讓你達成夢想目標!,本篇來說明四個階段的「階段三」和「階段四」: 目標設定 階段三:調整執行心態 目標設定 階段四:目標設定覆盤 這個階段,也是超過 70% 的機率,是採取正確的心態,與持續調整步伐。讓該專案意外順利堅持,並最終完成目
目標設定 階段一:確認當前目標 目標設定 階段二:設定可行目標 目標設定 階段三:調整執行心態 目標設定 階段四:目標設定覆盤
時間管理 技巧一:縱覽全局、明訂目標 時間管理 技巧二:善用零碎時間 時間管理 技巧三:根據目標校正心態
在轉職寫程式、自學程式語言的過程中,最害怕的莫過於遇到無從下手的問題。透過實際案例分享,讓零基礎從零到一的程式新手,也能快速學會如何解決複雜問題。
你可能也想看
Google News 追蹤
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
11/20日NVDA即將公布最新一期的財報, 今天Sell Side的分析師, 開始調高目標價, 市場的股價也開始反應, 未來一週NVDA將重新回到美股市場的焦點, 今天我們要分析NVDA Sell Side怎麼看待這次NVDA的財報預測, 以及實際上Buy Side的倉位及操作, 從
Thumbnail
Hi 大家好,我是Ethan😊 相近大家都知道保濕是皮膚保養中最基本,也是最重要的一步。無論是在畫室裡長時間對著畫布,還是在旅途中面對各種氣候變化,保持皮膚的水分平衡對我來說至關重要。保濕化妝水不僅能迅速為皮膚補水,還能提升後續保養品的吸收效率。 曾經,我的保養程序簡單到只包括清潔和隨意上乳液
Thumbnail
你是否一直在尋找快速瘦身的方法,卻苦於時間不夠?別擔心!今天我要介紹給大家一種高效而且省時的瘦身運動方式——高強度間歇訓練(High Intensity Interval Training)。透過只需十分鐘的訓練,你可以享受到比傳統運動更快速的瘦身效果。
Thumbnail
閩越是一片位於東南亞東北角的沿海地區,大致呈四邊形,整體地勢由西北向東南傾斜。北,西,南三面分別與吳越,贛,粵相鄰,東隔台灣海峽與台灣島對望,位於從東北亞通往東南亞的海路交通要道上。閩越的陸地面積約為124, 000平方千米,大小約與希臘相仿佛。閩越的大部分地區為海拔500-1500米的丘陵山地,有
Thumbnail
是什麼樣的情愫 使你急於擁我入懷 驚慌失措的 我 困在你強壯的 臂彎裡 無力的掙扎 我 只想逃脫此刻的錯亂 你緊緊擁著不肯鬆手 僅是喃喃訴說你的寂寞 懇求我給你十分鐘 我只能貼著你的胸膛 感受你的手指 在我臉頰上 輕輕的撫弄 感受你愛憐的吻 落在我的額頭
Thumbnail
上週福山寺內的美術館友舉辦紙雕展的開幕式,主辦人員與我熟識,邀請我去做開幕開場表演,主辦者明白告訴我,給我十分鐘,由我表演拿手的樂器表演,我不加思索地答應,準備以二胡、橫笛獨奏來表演。 準備過程中我更換一次曲目,最後決定以拿手的曲目上場,我考慮的是,避免上場的緊張心理,所以決定以自己熟悉的曲目,降低
有沒有試過別人讓你等他十分鐘, 結果一等是半小時, 甚至一小時? 唉, 很可惡, 是腦細叫都算, 但係如果係腦細的朋友呢? 我幫你是人情, 不幫你是道理吧~~ 上週傾盆大雨來之前, 我本來想盡快離開公司避過這半身濕透的結果, 但這腦細朋友話等佢十分鐘, 十分鐘內可以發電郵給我, 讓我蓋好公司章後馬上
Thumbnail
十分鐘時間,是學校一堂又一堂課之間的喘息瘋狂時間,學生們可以狂奔去福利社買冰棒麵包飲料,也可以去操場亂叫亂喊一下,或者到隔壁偷看暗戀的對象讓自己小鹿亂撞一番。而在 SpaceX 與 NASA 的任務裡,十分鐘,太空人已經從地表發射到外太空進行任務了。 T+00:10:14 Screenshot 距離
Thumbnail
一般人會對股市產生抗拒,主要有兩個原因,一是研究多年還是無法參透公司三大報表,放棄基本面選股,二是使用技術面選股常被突然其來的法人操控資金割韭菜。 外匯交易就沒有以上兩個困擾,並且具有流動性高、法人低干預、研究時間少的特性,基本上你只要對幾個貨幣兌有敏感度,勝率就會很高,若是擔心外匯獲利不夠多,也
Thumbnail
最近每天新聞一定都會看到新冠肺炎(武漢肺炎),今天我們就來練習十分鐘秒懂英文新聞標題吧! Italy prohibits travel and cancels all public events in its northern regions to contain coronavirus -- C
Thumbnail
「我跟死神搏鬥了十個月……請你給我十分鐘……」 一張既陌生又熟悉的小臉終於露出來了,兩眼緊閉安詳地熟睡著,像一個小天使。 「讓我看他最後一面吧。」站也站不穩的君頤,氣若游絲地說。
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
11/20日NVDA即將公布最新一期的財報, 今天Sell Side的分析師, 開始調高目標價, 市場的股價也開始反應, 未來一週NVDA將重新回到美股市場的焦點, 今天我們要分析NVDA Sell Side怎麼看待這次NVDA的財報預測, 以及實際上Buy Side的倉位及操作, 從
Thumbnail
Hi 大家好,我是Ethan😊 相近大家都知道保濕是皮膚保養中最基本,也是最重要的一步。無論是在畫室裡長時間對著畫布,還是在旅途中面對各種氣候變化,保持皮膚的水分平衡對我來說至關重要。保濕化妝水不僅能迅速為皮膚補水,還能提升後續保養品的吸收效率。 曾經,我的保養程序簡單到只包括清潔和隨意上乳液
Thumbnail
你是否一直在尋找快速瘦身的方法,卻苦於時間不夠?別擔心!今天我要介紹給大家一種高效而且省時的瘦身運動方式——高強度間歇訓練(High Intensity Interval Training)。透過只需十分鐘的訓練,你可以享受到比傳統運動更快速的瘦身效果。
Thumbnail
閩越是一片位於東南亞東北角的沿海地區,大致呈四邊形,整體地勢由西北向東南傾斜。北,西,南三面分別與吳越,贛,粵相鄰,東隔台灣海峽與台灣島對望,位於從東北亞通往東南亞的海路交通要道上。閩越的陸地面積約為124, 000平方千米,大小約與希臘相仿佛。閩越的大部分地區為海拔500-1500米的丘陵山地,有
Thumbnail
是什麼樣的情愫 使你急於擁我入懷 驚慌失措的 我 困在你強壯的 臂彎裡 無力的掙扎 我 只想逃脫此刻的錯亂 你緊緊擁著不肯鬆手 僅是喃喃訴說你的寂寞 懇求我給你十分鐘 我只能貼著你的胸膛 感受你的手指 在我臉頰上 輕輕的撫弄 感受你愛憐的吻 落在我的額頭
Thumbnail
上週福山寺內的美術館友舉辦紙雕展的開幕式,主辦人員與我熟識,邀請我去做開幕開場表演,主辦者明白告訴我,給我十分鐘,由我表演拿手的樂器表演,我不加思索地答應,準備以二胡、橫笛獨奏來表演。 準備過程中我更換一次曲目,最後決定以拿手的曲目上場,我考慮的是,避免上場的緊張心理,所以決定以自己熟悉的曲目,降低
有沒有試過別人讓你等他十分鐘, 結果一等是半小時, 甚至一小時? 唉, 很可惡, 是腦細叫都算, 但係如果係腦細的朋友呢? 我幫你是人情, 不幫你是道理吧~~ 上週傾盆大雨來之前, 我本來想盡快離開公司避過這半身濕透的結果, 但這腦細朋友話等佢十分鐘, 十分鐘內可以發電郵給我, 讓我蓋好公司章後馬上
Thumbnail
十分鐘時間,是學校一堂又一堂課之間的喘息瘋狂時間,學生們可以狂奔去福利社買冰棒麵包飲料,也可以去操場亂叫亂喊一下,或者到隔壁偷看暗戀的對象讓自己小鹿亂撞一番。而在 SpaceX 與 NASA 的任務裡,十分鐘,太空人已經從地表發射到外太空進行任務了。 T+00:10:14 Screenshot 距離
Thumbnail
一般人會對股市產生抗拒,主要有兩個原因,一是研究多年還是無法參透公司三大報表,放棄基本面選股,二是使用技術面選股常被突然其來的法人操控資金割韭菜。 外匯交易就沒有以上兩個困擾,並且具有流動性高、法人低干預、研究時間少的特性,基本上你只要對幾個貨幣兌有敏感度,勝率就會很高,若是擔心外匯獲利不夠多,也
Thumbnail
最近每天新聞一定都會看到新冠肺炎(武漢肺炎),今天我們就來練習十分鐘秒懂英文新聞標題吧! Italy prohibits travel and cancels all public events in its northern regions to contain coronavirus -- C
Thumbnail
「我跟死神搏鬥了十個月……請你給我十分鐘……」 一張既陌生又熟悉的小臉終於露出來了,兩眼緊閉安詳地熟睡著,像一個小天使。 「讓我看他最後一面吧。」站也站不穩的君頤,氣若游絲地說。