folder_open

30 天打造 MERN Stack Boilerplate

arrow_right
article

Day 19 - Infrastructure - Pagination

Day 19 - Infrastructure - Pagination

試想今天你要提供一個 API 來列出 Server 上的所有 User,你會怎麼做?以交作業的心態,能動就好,挖靠哩,總共 5 行就寫完了:

app.get('/api/users', (req, res) => {
User.find({}, (err, users) => {
res.json({ users });
});
})

但是你是否曾經考慮過當你的 User 有一萬個、十萬個、甚至百萬個?難道你要全部都列出來嗎?我們一直在強調 Scale Up 的彈性,所以在真實的 Web App 中一定要考量到 這東西,我認為無論是 Backend 的 API 還是 Frontend 的 UI、Data Flow,從一開始 App 的建構就要把 納入考量。

它很難寫,但是如果今天不寫,日後要在 App 中加上 Pagination 的話,基本上整個專案只能打掉重刻,留下一個還不清的技術債。

Backend Pagination

#

Backend 要處理的分頁主要是針對 Mongoose 讀取的 Resources 切割,我個人認為 Github 上找到的 Mongoose Pagination Plugin 都不符合我的需求,這些 Plugin 只能拿到分頁後的最終結果,卻拿不到分頁過程運算所需的參數(例如總共幾筆、每頁幾筆、Skip 幾筆、...),這對 Backend 當然是很好寫,但是對 Frontend 而言將會是個苦力,最好的作法當然是兩邊均衡,所以我自己寫了 Pagination Pluginopen_in_new,用法也和目前開源的模組不同,但我覺得有兼顧到 Backend 的彈性與 Frontend 的可維護性。

延續前述的 ,用法如下:

import paginatePlugin from './plugins/paginate';
let UserSchema = new mongoose.Schema({
name: String,
// ...
});
UserSchema.plugin(paginatePlugin);

完整程式碼:src/server/models/User.jsopen_in_new

首先在 Schema 套用 Plugin。

User.paginate({ page: req.query.page }, handleDbError(res)((page) => {
// ...
}));

接著就可以呼叫 Model 的 Method,Callback 回傳參數是一個夾帶 Page 參數的資料結構,包括 Skip 了幾筆 Record、每頁 Limit 多少 Record、第一頁的 Page Id、目前頁面的 Page Id、最後一頁的 Page Id、總共有多少筆 Record:

page = {
skip: 20,
limit: 5,
first: 1,
current: 5,
last: 9,
total: 9,
}

完整用法可以傳入幾個 Options,並且利用得到的 Page 資料結構實作 Mongoose Query 的分頁:

export default {
listByGender(req, res) {
let condition = {
gender: 'MALE',
};
User.paginate({
page: req.query.page,
perPage: 10,
condition,
}, handleDbError(res)((page) => {
User
.find(condition)
.sort({ createdAt: 'desc' })
.limit(page.limit)
.skip(page.skip)
.exec(handleDbError(res)((users) => {
res.json({
users: users,
page: page,
});
}));
}));
},
};

完整程式碼:src/server/controllers/user.jsopen_in_new

Frontend Pagination [1]open_in_new

#

SPA 的一個好處就是,我們可以 Cache 住 AJAX 拿到的 Data,但是 API 回傳的結果可能是不同 Resource 巢狀混合後的結果,如果再搭配 Pagination 的使用,Store 中的結構很容易凌亂不堪,變得難以維護。

Normalizr

#

這時候就要介紹一個好東西了,normalizropen_in_new 是一個把 API Response 正規化的模組,其強大之處在於把巢狀的結構按照給定的 Schema 打平,例如這樣的原始 Response:

[{
id: 1,
title: 'Some Article',
author: {
id: 1,
name: 'Dan'
}
}, {
id: 2,
title: 'Other Article',
author: {
id: 1,
name: 'Dan'
}
}]

可以被正規化成:

{
result: [1, 2],
entities: {
articles: {
1: {
id: 1,
title: 'Some Article',
author: 1
},
2: {
id: 2,
title: 'Other Article',
author: 1
}
},
users: {
1: {
id: 1,
name: 'Dan'
}
}
}
}

詳細用法太繁瑣,不便在此處說明,請讀者們自行參考 Github 文件及教學,我們在 Store 中儲存的資料結構將會是經過 normalizr 正規化之後的結果。

Action & Reducer [2]open_in_new

#

我們將正規化之後的 儲存於 store.entity,正規化之後的 儲存於 store.pagination。

假設今天我們是要實作 Todo List 的 Frontend Pagination,那我們可以透過自訂的 這個 Action 來整併 entities,另外透過 這個 Action 來傳遞正規化之後的 API Response:

export const setTodos = (res) => (dispatch, getState) => {
let normalized = normalize(res.todos, arrayOf(todoSchema));
dispatch(setEntities(normalized));
dispatch(setPages(Resources.TODO, res.page, normalized.result));
};

其中 是指定一個用來識別目前要針對哪個資源處理分頁所用的 Key; 中的 可以告訴 Reducer 要將 儲存於哪一頁。

假設我們拿過了 page 1,接著拿完 page 2 並且呼叫了 ,那麼 Store 中的 entity 和 pagination 應該分別是以下的狀態:

store.entity = {
todos: {
todo1: {
id: 'todo1',
text: 'todo #1',
},
todo1: {
id: 'todo2',
text: 'todo #2',
},
todo1: {
id: 'todo3',
text: 'todo #3',
},
todo1: {
id: 'todo4',
text: 'todo #4',
},
},
};
store.pagination = {
todos: {
page: {
skip: 2,
limit: 2,
first: 1,
current: 2,
last: 9,
total: 9,
},
pages: {
1: {
ids: [ 'todo1', 'todo2' ],
},
2: {
ids: [ 'todo3', 'todo4' ],
},
... // and so on
}
},
};

paginationReducer 的程式碼也有點繁瑣,請直接參考原始碼:src/common/reducers/paginationReducer.jsopen_in_new

取出 Paginated Data

#

架構部分已經明確,剩下的就只是如何把 Data 呈現在 Component 上,不過這部分通常是要看 App 的需求來撰寫,我們就只簡單提供了可以移動至上一頁、下一頁、第一頁、最後一頁的 Todo List Demoopen_in_new,完全支援每一頁的 Server Side Render 和 Server Side State Fetching。

原理是在 mapStateToProps 中撈出目前頁面的 Todos,讓元件可以 Render 出來:

connect(({ pagination, entity }) => {
let { page } = pagination.todos;
let todoPages = pagination.todos.pages[page.current] || { ids: [] };
let todos = todoPages.ids.map(id => entity.todos[id]);
return {
todos,
page,
};
})(ListPage)

Data 則是在頁面載入或者是當頁面切換的時候下載的,並且已經下載過的就 Cache 住:

class ListPage extends Component {
// ...
componentDidMount() {
let { location } = this.props;
this.fetchTodos(location.query.page || 1);
}
componentDidUpdate(prevProps) {
let { page, todos } = this.props;
if (todos.length === 0 && prevProps.page.current !== page.current) {
this.fetchTodos(page.current);
}
}
fetchTodos(page) {
// ...
}
// ...
}

完整程式碼:src/common/components/pages/todo/ListPage.jsopen_in_new

實作瀑布流效果

#

其實在前述的架構規劃下,我們不只能夠實作正常的上一頁、下一頁,因為載入過的 Data 會被 Cache 起來,所以我們可以利用這個特性實作出瀑布流。延續前例,只要把 稍作修改,變成撈出 的 todos 就完成 95% 了:

connect(({ pagination, entity }) => {
let { page, pages } = pagination.todos;
let todoIds = flatten(
Object
.keys(pages)
.map(pageId => pages[pageId].ids)
);
let todos = todoIds.map(id => entity.todos[id]);
return {
todos,
page,
};
})(ListPage)

剩下的 5% 呢?只要把改成就完成了,實際上他們的功能是一模一樣的!

參考資料

#