Stop using ngrx/effects for that(停止那樣使用ngrx/effects)

翻譯說(shuō)明: 意譯,中英文對(duì)照, 有特定含義的英文術(shù)語(yǔ)保留。
本文翻譯已取得作者 Michael Pearson 授權(quán)。
原文鏈接

Sometimes the simplest implementation for a feature ends up creating more complexity than it saves, only shoving the complexity elsewhere.
有時(shí)候某個(gè)功能最簡(jiǎn)單的實(shí)現(xiàn)最終比起它所帶來(lái)的的創(chuàng)建更多的復(fù)雜性,只是把復(fù)雜性推到別處。

The eventual result is buggy architecture that nobody wants to touch.
最終的結(jié)果是沒(méi)有人愿意碰的容易出bug的架構(gòu)。

Ngrx/store is an Angular library that helps to contain the complexity of individual features.
Ngrx/store 是個(gè) Angular 庫(kù), 它有助于控制單個(gè)功能的復(fù)雜性。

One reason is that ngrx/store embraces functional programming, which restricts what can be done inside a function in order to achieve more sanity outside of it.
一個(gè)原因是 ngrx/store 遵循函數(shù)式編程, 限制了在函數(shù)內(nèi)部可以做的事情,從而在其之外實(shí)現(xiàn)更健全的功能。

In ngrx/store, reducers, selectors, and RxJS operators are pure functions.
ngrx/store 中, reducers, selectors, 以及 RxJS 操作符都是純函數(shù)。

Pure functions are easier to test, debug, reason about, parallelize, and combine.
純函數(shù)非常容易測(cè)試, 調(diào)試, 理解, 并行處理 以及 組合。

A function is pure if
函數(shù)是純的, 如果

  • given the same input, it always returns the same output.
    給定同樣的輸入, 總是會(huì)返回相同的輸出。
  • it produces no side effects.
    不會(huì)產(chǎn)生副作用。

Side effects are impossible to avoid, but they are isolated in ngrx/store so that the rest of the application can be composed of pure functions.
副作用不可避免, 但是它們被隔離在 ngrx/store 中,因此,應(yīng)用的其余部分可以由純函數(shù)組成。

Side Effects 副作用

When a user submits a form, we need to make a change on the server.
當(dāng)用戶(hù)提交了表單, 我們需要在服務(wù)器上做出改變。

The change on the server and response to the client is a side effect.
服務(wù)器上的改變以及對(duì)客戶(hù)端的響應(yīng)就是一種副作用。

This could be handled in the component:
在組件中可以這樣處理:

this.store.dispatch({
  type: "SAVE_DATA",
  payload: data
});
this.saveData(data) // POST request to server
  .map(res => this.store.dispatch({type: "DATA_SAVED"}))
  .subscribe()

It would be nice if we could simply dispatch an action inside the component when the user submits the form and handle the side effect elsewhere.
當(dāng)用戶(hù)提交表單時(shí), 如果我們能夠在組件內(nèi)派發(fā) action, 而在其他地方處理副作用, 那就太美妙了。

Ngrx/effects is middleware for handling side effects in ngrx/store.
ngrx/store 中, Ngrx/effects 是處理副作用的中間件。

It listens for dispatched actions in an observable stream, performs side effects, and returns new actions either immediately or asynchronously.
它監(jiān)聽(tīng) observable 流中派發(fā)的 actions, 執(zhí)行副作用, 然后立即或者異步返回新的 actions。

The returned actions get passed along to the reducer.
返回的 actions 被傳給 reducer。

Being able to handle side effects in an RxJS-friendly way makes for cleaner code.
能夠以 RxjJS 友好的方式處理副作用,會(huì)使代碼更加清晰。

After dispatching the initial action SAVE_DATA from the component you create an effects class to handle the rest:
在組件里派發(fā) 最初的 action SAVE_DATA之后, 你可以創(chuàng)建 effects 類(lèi)處理其余部分:

@Effect() saveData$ = this.actions$
  .ofType('SAVE_DATA')
  .pluck('payload')
  .switchMap(data => this.saveData(data))
  .map(res => ({type: "DATA_SAVED"}))

This simplifies the job of the component to only dispatching actions and subscribing to observables.
這簡(jiǎn)化了組件的工作,組件只用于派發(fā)actions 以及訂閱 observables。

Ngrx/effects is easy to abuse (Ngrx/effects 很容易被濫用)

Ngrx/effects is a very powerful solution, so it is easy to abuse.
Ngrx/effects 是個(gè)很強(qiáng)大的解決方案, 所以它很容易被濫用。

Here are some common anti-patterns of ngrx/store that Ngrx/effects makes easy:
這里有一些 ngrx/store 的常見(jiàn)的反模式。

1、Duplicate/derived state (重復(fù)/派生的狀態(tài))

Let’s say you’re working on some kind of media playing app, and you have these properties in your state tree:
假設(shè)你在做某種媒體播放應(yīng)用,狀態(tài)樹(shù)中有這些屬性:

export interface State {
  mediaPlaying: boolean;
  audioPlaying: boolean;
  videoPlaying: boolean;
}

Because audio is a type of media, whenever audioPlaying is true, mediaPlaying must also be true.
因?yàn)橐纛l是媒體的一種類(lèi)型, 每當(dāng) audioPlayingtrue 時(shí), mediaPlaying 也必須是 true

So here’s the question: “How do I make sure mediaPlaying is updated whenever audioPlaying is updated?”
那么問(wèn)題來(lái)了: "每當(dāng)audioPlaying更新了, 我如何確保mediaPlaying 也更新了?"

Incorrect answer: Use an effect!
錯(cuò)誤回答: 使用 effect!

@Effect() playMediaWithAudio$ = this.actions$
 .ofType("PLAY_AUDIO")
 .map(() => ({type: "PLAY_MEDIA"}))

Correct answer: If the state of mediaPlaying is completely predicted by another part of the state tree, then it isn’t true state.It’s derived state. That belongs in a selector, not in the store.
正確答案: 如果 mediaPlaying 的狀態(tài)完全由狀態(tài)樹(shù)中的另一部分決定, 那么它就不是真正的狀態(tài)。它是派生的狀態(tài)。它應(yīng)歸于選擇器,而不是存儲(chǔ)。

audioPlaying$ = this.store.select('audioPlaying');
videoPlaying$ = this.store.select('videoPlaying');
mediaPlaying$ = Observable.combineLatest(
  this.audioPlaying$,
  this.videoPlaying$,
  (audioPlaying, videoPlaying) => audioPlaying || videoPlaying
)

Now our state can stay clean and normalized, and we’re not using ngrx/effects for something that isn’t a side effect.
現(xiàn)在 我們的狀態(tài)可以保持清晰以及歸一化, 我們不再對(duì)一些不是副作用的東西使用 ngrx/effects。

2. Coupling actions and reducers (耦合 actions 和 reducers)

Imagine you have these properties in your state tree:
假設(shè)在狀態(tài)樹(shù)中有這些屬性:

export interface State {
  items: {[index: number]: Item};
  favoriteItems: number[];
}

Then the user deletes an item.
然后用戶(hù)刪除了 一項(xiàng)條目。

When the delete request returns, the action DELETE_ITEM_SUCCESS is dispatched to update our app state.
當(dāng)刪除請(qǐng)求返回時(shí), 會(huì)派發(fā) action DELETE_ITEM_SUCCESS 來(lái)更新應(yīng)用的狀態(tài)。

In the items reducer the item is removed from the items object.
items reducer中, 該項(xiàng)條目會(huì)從 items 對(duì)象中刪除。

But if that item id was in the favoriteItems array, the item it’s referring to will be missing.
但是, 如果該條目的 id 是在 favoriteItems 數(shù)組中, 它所引用的條目將會(huì)丟失。

So the question is, how can I make sure the id is removed from favoriteItems whenever the DELETE_ITEM_SUCCESS action is dispatched?
因此, 問(wèn)題是: 每當(dāng) DELETE_ITEM_SUCCESS action 派發(fā)時(shí), 我如何確保 條目對(duì)應(yīng)的 id 也從 favoriteItems 中刪除

Incorrect answer: Use an Effect!
錯(cuò)誤回答: 使用 Effect!

@Effect() this.actions$
  .ofType("DELETE_ITEM_SUCCESS")
  .map(() => ({type: "REMOVE_FAVORITE_ITEM_ID"}))

So now we will have two actions dispatched back-to-back, and two reducers returning new states back-to-back.
現(xiàn)在, 我們連續(xù)派發(fā)了 2 個(gè) action, 2 個(gè) reducer 連續(xù)返回新的狀態(tài)。

Correct answer: DELETE_ITEM_SUCCESS can be handled by both the items reducer and the favoriteItems reducer.
正確答案: DELETE_ITEM_SUCCESS 可以同時(shí)被 items reducer 和 favoriteItems reducer 處理

export function favoriteItemsReducer(state = initialState, action: Action) {
  switch(action.type) {
    case 'REMOVE_FAVORITE_ITEM':
    case 'DELETE_ITEM_SUCCESS':
      const itemId = action.payload;
      return state.filter(id => id !== itemId);
    default: 
      return state;
  }
}

The purpose of actions is to decouple what happened from how state is supposed to change.
actions 的目的是將所發(fā)生的事情與狀態(tài)應(yīng)該改變的情況解耦。

What happened was DELETE_ITEM_SUCCESS.
所發(fā)生的事情是 DELETE_ITEM_SUCCESS.

It is the job of the reducers to cause the appropriate state change.
產(chǎn)生 合適的 狀態(tài)改變 是 reducers 的工作。

Removing an id from favoriteItems is not a side effect of deleting the item.
favoriteItems 中移除 id 并不是 刪除 條目的副作用。

The whole process is completely synchronous and can be handled by the reducers. Ngrx/effects is not needed.
整個(gè)過(guò)程完全是同步的, 可以被 reducers 處理。 并不需要 Ngrx/effects。

3. Fetching data for a component (為組件獲取數(shù)據(jù))

Your component needs data from the store, but the data needs to be fetched from the server first.
你的組件需要 store 中的數(shù)據(jù), 但是這個(gè)數(shù)據(jù)首先需要從服務(wù)器獲取。

The question is, how can we get the data into the store so the component can select it?
問(wèn)題是: 如何將數(shù)據(jù)導(dǎo)入到 store 中,以便組件可以選擇它?

Painful answer: Use an effect!
痛苦的回答: 使用 effect !

In the component we trigger the request by dispatching an action:
在組件中, 我們通過(guò)派發(fā) action 觸發(fā)請(qǐng)求:

ngOnInit() {
  this.store.dispatch({type: "GET_USERS"});
}

In the effects class we listen for GET_USERS:
在 effects 類(lèi)中, 我們監(jiān)聽(tīng) GET_USERS:

@Effect getUsers$ = this.actions$
  .ofType('GET_USERS')
  .withLatestFrom(this.userSelectors.needUsers$)
  .filter(([action, needUsers]) => needUsers)
  .switchMap(() => this.getUsers())
  .map(users => ({type: 'RECEIVE_USERS', users}))

Now let’s say a user decides it’s taking too long for the users route to load, so they navigate away.
現(xiàn)在假設(shè)用戶(hù)覺(jué)得加載 users 路由花費(fèi)的時(shí)間太長(zhǎng)了, 所以他們離開(kāi)了。

To be efficient and not load useless data, we want to cancel that request.
為了提高效率,不加載無(wú)用數(shù)據(jù),我們想要取消這個(gè)請(qǐng)求。

When the component is destroyed, we will unsubscribe from the request by dispatching an action:
當(dāng)組件銷(xiāo)毀時(shí), 我們會(huì)通過(guò)派發(fā) action 來(lái)取消對(duì)該請(qǐng)求的訂閱。

ngOnDestroy() {
  this.store.dispatch({type: "CANCEL_GET_USERS"})
}

In the effects class we listen for both actions now:
現(xiàn)在, 在 effects 類(lèi)中,我們監(jiān)聽(tīng) 2 個(gè) action:

@Effect getUsers$ = this.actions$
  .ofType('GET_USERS', 'CANCEL_GET_USERS')
  .withLatestFrom(this.userSelectors.needUsers$)
  .filter(([action, needUsers]) => needUsers)
  .map(([action, needUsers]) => action)
  .switchMap(action => action.type === 'CANCEL_GET_USERS' ? 
    Observable.of() :
    this.getUsers()
      .map(users => ({type: 'RECEIVE_USERS', users}))
  )

Okay. Now another developer adds a component that needs the same HTTP request to be made (no assumptions should be made about other components).
好的. 現(xiàn)在 又一個(gè) 開(kāi)發(fā)人員添加了一個(gè)組件, 這個(gè)組件需要發(fā)起同樣的請(qǐng)求(對(duì)其他組件不應(yīng)作任何假設(shè)。)

The component dispatches the same actions in the same places.
這個(gè)組件在同樣的地方派發(fā)了同樣的 action.

If both components are active at the same time, the first component to initialize will trigger the HTTP request.
如果 這 2 個(gè)組件同時(shí)是活躍的, 第一個(gè)初始化的組件會(huì)觸發(fā) HTTP 請(qǐng)求.

When the second component initializes, nothing additional will happen because needUsers will be false. Great!
當(dāng)?shù)诙€(gè)組件初始化時(shí),不會(huì)發(fā)生額外的事情,因?yàn)?needUsers 將是 false。太棒了!

Then, when the first component is destroyed, it will dispatch CANCEL_GET_USERS. But the second component still needs that data.
然后, 當(dāng)?shù)谝粋€(gè)組件銷(xiāo)毀時(shí)會(huì)派發(fā) CANCEL_GET_USERS。 但是第二個(gè)組件仍然需要數(shù)據(jù)。

How can we prevent the request from being canceled?
我們?cè)趺床拍茏柚拐?qǐng)求被取消?

Maybe have a count of all the subscribers?
或許再添加一些 訂閱者?

I won’t bother exploring that, but you get the point. We start to hope there is a better way of managing these data dependencies.
我不會(huì)去探究這個(gè)問(wèn)題,但你明白我的意思。我們開(kāi)始希望有一種更好的方法來(lái)管理這些數(shù)據(jù)依賴(lài)關(guān)系。

Now let’s say another component comes into the picture, and it depends on data that cannot be fetched until after the users data is in the store.
現(xiàn)在假設(shè)另一個(gè)組件出現(xiàn)在圖中,它依賴(lài)于users數(shù)據(jù), 該數(shù)據(jù)只有存在于 store 中之后才能獲取到。

It could be a websocket connection for a chat, or additional information about some of the users, or whatever.
它可能是 聊天的 websocket 連接, 或者是關(guān)于某些用戶(hù)的額外信息, 或者是其他什么東西。

We don’t know if this component will be initialized before or after the other two components subscribe to users.
我們并不知道該組件是在其他 2 個(gè)組件訂閱users之前還是之后初始化。

The best help I found for this particular scenario is this great post.
對(duì)于這個(gè)特殊的場(chǎng)景我找到的最好的幫助是這篇很棒的文章。

In his example, callApiY requires callApiX to have been completed already.
在他的例子中, callApiY 要求 callApiX 已經(jīng)完成。

I’ve stripped out the comments to make it look less intimidating, but feel free to read the original post to learn more:
我刪除了評(píng)論,讓它看起來(lái)不那么嚇人,但你可以閱讀原文了解更多:

@Effect() actionX$ = this.updates$
    .ofType('ACTION_X')
    .map(toPayload)
    .switchMap(payload => this.api.callApiX(payload)
        .map(data => ({type: 'ACTION_X_SUCCESS', payload: data}))
        .catch(err => Observable.of({type: 'ACTION_X_FAIL', payload: err}))
    );
@Effect() actionY$ = this.updates$
    .ofType('ACTION_Y')
    .map(toPayload)
    .withLatestFrom(this.store.select(state => state.someBoolean))
    .switchMap(([payload, someBoolean]) => {
        const callHttpY = v => {
            return this.api.callApiY(v)
                .map(data => ({
                    type: 'ACTION_Y_SUCCESS', 
                    payload: data
                }))
                .catch(err => Observable.of({
                    type: 'ACTION_Y_FAIL', 
                    payload: err
                 }));
        }
        
        if(someBoolean) {
            return callHttpY(payload);
        }
        return Observable.of({type: 'ACTION_X', payload})
            .merge(
                this.updates$
                    .ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL')
                    .first()
                    .switchMap(action => {
                       if(action.type === 'ACTION_X_FAIL') {
                          return Observable.of({
                            type: 'ACTION_Y_FAIL', 
                            payload: 'Because ACTION_X failed.'
                          });
                        }
                        return callHttpY(payload);
                    })
            );
    });

Now add on the requirement that HTTP requests should be canceled when components are no longer interested, and it gets more complicated.
現(xiàn)在添加一個(gè)要求,當(dāng)組件不再感興趣時(shí),HTTP請(qǐng)求應(yīng)該被取消,這變得更加復(fù)雜。


So why so much trouble for managing data dependencies when RxJS is supposed to make it really easy?
那么當(dāng) RxJS 應(yīng)當(dāng)使數(shù)據(jù)依賴(lài)管理更加簡(jiǎn)單時(shí),為什么會(huì)有這么多的問(wèn)題?

While data arriving from the server technically is a side effect, I don’t believe that ngrx/effects is the best way to manage this.
雖然從技術(shù)上來(lái)說(shuō),來(lái)自服務(wù)器的數(shù)據(jù)是個(gè)副作用,但我不認(rèn)為ngrx/effects 是管理這個(gè)的最好方法。

Components are I/O interfaces for the user. They show data and dispatch actions from users.
組件是用戶(hù)的I/O接口。它們顯示數(shù)據(jù)并派發(fā)用戶(hù)發(fā)出的 actions。

When a component loads, it is not dispatching an action from a user. It wants to show data.
當(dāng)組件加載時(shí),它不會(huì)派發(fā)來(lái)自用戶(hù)的 action。它想顯示數(shù)據(jù)。

That looks like a subscription, not the side effect of an action.
這看起來(lái)像是訂閱, 而不是 action 的副作用。

It’s very common to see apps using actions to trigger data fetches.
使用actions 來(lái)觸發(fā)數(shù)據(jù)獲取是很常見(jiàn)的。

These apps implement a custom interface to observables through side effects.
這些應(yīng)用通過(guò)副作用實(shí)現(xiàn)了 observable 的自定義接口。

And as we’ve seen, this interface can become very awkward and unwieldy.
正如我們所見(jiàn),這個(gè)接口會(huì)變得非常笨重。

Subscribing to, unsubscribing from, and chaining the observables themselves is much more straightforward.
對(duì) observables 的訂閱, 取消訂閱 以及 鏈接更加直觀。


Less painful answer: The component will register its interest in data by subscribing to an observable of the data.
不那么痛苦的答案: 組件會(huì)通過(guò)訂閱 observable 來(lái)獲取其感興趣的數(shù)據(jù)。

We will create observables that contain the HTTP requests we want to make.
我們會(huì)創(chuàng)建 包含 我們想發(fā)起的 http 請(qǐng)求的 observable。

We will see how much easier it is to manage multiple subscriptions and chain requests off of each other using pure RxJS than it is to do those things with effects.
我們將會(huì)看到,使用純粹的 RxJS 來(lái)管理多個(gè)訂閱和鏈請(qǐng)求比使用那些帶有 effects 的東西要容易得多。

Create these observables in a service:
在服務(wù)里創(chuàng)建這些 observables:

public requireUsers$ = this.userSelectors.needUsers$
  .filter(needUsers => needUsers)
  .do(() => this.store.dispatch({type: 'GET_USERS'}))
  .switchMap(() => this.getUsers())
  .do(users => this.store.dispatch({type: 'RECEIVE_USERS', users}))
  .finally(() => this.store.dispatch({type: 'CANCEL_GET_USERS'}))
  .share();

public users$ = this.muteFirst(
  this.requireUsers$.startWith(null), 
  this.userSelectors.users$
)

Subscriptions to users$ will be passed up to both requireUsers$ and userSelectors.users$, but will only receive the values from userSelectors.users$ (example implementation of ``.)
對(duì) users$ 的訂閱會(huì)同時(shí)傳給requireUsers$userSelectors.users$,
但是只會(huì)接收來(lái)自 userSelectors.users$ 的值。
In the component:
在組件中:

ngOnInit() {
  this.users$ = this.userService.users$;
}

Because this data dependency is now just an observable, we can subscribe and unsubscribe in the template using the async pipe and we no longer need to dispatch actions.
因?yàn)檫@個(gè)數(shù)據(jù)依賴(lài)現(xiàn)在僅僅是個(gè) observable, 我們可以在模板中使用 async 管道 訂閱 和取消訂閱, 我們不再需要派發(fā) actions。

If the app navigates away from the last component subscribed to the data, the HTTP request is canceled or the websocket is closed.
如果應(yīng)用從訂閱數(shù)據(jù)的最后一個(gè)組件中導(dǎo)航離開(kāi),則HTTP請(qǐng)求被取消,或者websocket被關(guān)閉。

Chains of data dependencies can be handled like this:
數(shù)據(jù)依賴(lài)鏈可以這樣處理:

public requireUsers$ = this.userSelectors.needUsers$
  .filter(needUsers => needUsers)
  .do(() => this.store.dispatch({type: 'GET_USERS'}))
  .switchMap(() => this.getUsers())
  .do(users => this.store.dispatch({type: 'RECEIVE_USERS', users}))
  .share();

public users$ = this.muteFirst(
  this.requireUsers$.startWith(null), 
  this.userSelectors.users$
)

public requireUsersExtraData$ = this.users$
  .withLatestFrom(this.userSelectors.needUsersExtraData$)
  .filter(([users, needData]) => Boolean(users.length) && needData)
  .do(() => this.store.dispatch({type: 'GET_USERS_EXTRA_DATA'}))
  .switchMap(() => this.getUsers())
  .do(users => this.store.dispatch({
    type: 'RECEIVE_USERS_EXTRA_DATA', 
    users
  }))
  .share();

public usersExtraData$ = this.muteFirst(
  this.requireUsersExtraData$.startWith(null),
  this.userSelectors.usersExtraData$
)

Here’s a side-by-side comparison of the above method vs this method:
下面是上述方法與此方法的并行比較:


對(duì)數(shù)據(jù)依賴(lài)鏈的處理 ngrx/effects vs 普通的 RxJS

Using plain observables requires fewer lines of code, and automatically unsubscribes from data dependencies all the way up the chain. (I omitted the finallys originally included in order to make the comparison clearer, but even without them the requests still get canceled appropriately.)
使用普通的 observables 需要的代碼行更少, 并且自動(dòng)地從數(shù)據(jù)依賴(lài)項(xiàng)中取消訂閱。(為了使對(duì)比更清楚,我省略了 “finally”,但是即使沒(méi)有它們,請(qǐng)求也會(huì)被適當(dāng)?shù)厝∠?

Conclusion 結(jié)論

Ngrx/effects is a great tool! But consider these questions before using it:
Ngrx/effects 是個(gè)很棒的工具! 但是在使用它之前考慮下這些問(wèn)題:

1、Is this really a side effect?
這真的是個(gè)副作用么?

2、Is ngrx/effects really the best way to handle this?
ngrx/effects 真的是處理這個(gè)問(wèn)題的最佳方式么?

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • rljs by sennchi Timeline of History Part One The Cognitiv...
    sennchi閱讀 7,858評(píng)論 0 10
  • 感賞自己今天在頭昏腦脹中,不斷調(diào)整,認(rèn)真答題,驚險(xiǎn)過(guò)關(guān)。感賞自己認(rèn)識(shí)到無(wú)論何事,都不能大意,一定要做到充分準(zhǔn)備,才...
    Ai琳琳_六中玩換閱讀 284評(píng)論 0 2
  • ----一款飛行射擊類(lèi)的游戲,畫(huà)面逼真,節(jié)奏刺激,全新玩法,百萬(wàn)玩家推薦入手 ----玩家可以選擇自己喜歡的戰(zhàn)機(jī)來(lái)...
    SGZKJYXGS閱讀 219評(píng)論 0 0
  • 「斜杠青年」這個(gè)名詞在前幾年火過(guò)一陣子,但如果你現(xiàn)在還在某些場(chǎng)合提起這個(gè),可能大家會(huì)以為你是穿越了。 斜杠青年一詞...
    韓德勝閱讀 323評(píng)論 0 1

友情鏈接更多精彩內(nèi)容