iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 19
0
Modern Web

30 天打造 MERN Stack Boilerplate系列 第 19

Day 19 - Infrastructure - Pagination

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

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

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

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

Backend Pagination

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

延續前述的 GET /api/users,用法如下:

import paginatePlugin from './plugins/paginate';

let UserSchema = new mongoose.Schema({
  name: String,
  // ...
});

UserSchema.plugin(paginatePlugin);

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

首先在 Schema 套用 Plugin。

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

接著就可以呼叫 Model 的 paginate 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.js

Frontend Pagination [1]

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

Normalizr

這時候就要介紹一個好東西了,normalizr 是一個把 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]

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

假設今天我們是要實作 Todo List 的 Frontend Pagination,那我們可以透過自訂的 SET_ENTITIES 這個 Action 來整併 entities,另外透過 SET_PAGES 這個 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));
};

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

假設我們拿過了 page 1,接著拿完 page 2 並且呼叫了 dispatch(setTodos(res)),那麼 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.js

取出 Paginated Data

架構部分已經明確,剩下的就只是如何把 Data 呈現在 Component 上,不過這部分通常是要看 App 的需求來撰寫,我們就只簡單提供了可以移動至上一頁、下一頁、第一頁、最後一頁的 Todo List Demo,完全支援每一頁的 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.js

實作瀑布流效果

其實在前述的架構規劃下,我們不只能夠實作正常的上一頁、下一頁,因為載入過的 Data 會被 Cache 起來,所以我們可以利用這個特性實作出瀑布流。延續前例,只要把 mapStateToProps 稍作修改,變成撈出 page.firstpage.current 的 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% 呢?只要把下一頁改成載入更多就完成了,實際上他們的功能是一模一樣的!

參考資料


上一篇
Day 18 - Infrastructure - i18n
下一篇
Day 20 - Infrastructure - Mail Service
系列文
30 天打造 MERN Stack Boilerplate30

尚未有邦友留言

立即登入留言