在前端開發中,表格元件(Table)通常是用來展示大量資料的最佳方式。特別是當資料需要被排序、搜尋或分頁顯示時,構建一個高效且可擴展的表格元件變得尤為重要。本文將介紹如何在 React 和 Vue 中實作一個具備排序與搜尋功能的表格元件。
之後會實作一個動畫管理員,管理所有section的動畫,我們可以嘗試調閱動畫播放紀錄,來追蹤和分析使用的資源

在這個範例中,我們設計了一個名為 CookieTable 的元件,用於展示各類零食的詳細資訊。其資料由一組動態生成的JSON格式構成,搜尋、排序、分頁的功能則由 Table 元件完成:
function CookieTable(){
    const [rows, setRows] = useState([]);
    const columns = [
        { accessor: 'price', label: '售價' },
        { accessor: 'name', label: '品名' },
        { accessor: 'onsale', label: '在架上', 
            format: value => (value ? '✔️' : '  ')
        },
        //......
    ]
    //......
    return <section className="section" id="cookie">
            <Table columns={columns} rows={rows}/>
        </section>
}
<Table columns={columns} rows={rows}/>
資料篩選、排序、分頁是依次進行的,範例中有 23 種餅乾,使用者可以篩選出在架上的 13 種,再進行排序與分頁。
搜尋功能允許使用者根據特定欄位的內容來篩選顯示的表格列。filterRows 函數會接收現有的資料列和搜尋條件(filters),並根據條件逐個欄位進行篩選。這裡利用 lodash 來幫我們處理型別檢查和大小寫:
function filterRows(rows, filters) {
  if (_.isEmpty(filters)) return rows;
  return rows.filter(row => {
    return Object.keys(filters).every(accessor => {
      const value = row[accessor];
      const searchValue = filters[accessor];
      if (_.isString(value))
        return _.toLower(value).includes(_.toLower(searchValue));
      if (_.isBoolean(value))
        return (searchValue === "true" && value) || (searchValue === "false" && !value);
      if (_.isNumber(value)) return value == searchValue;
        return false;
        });
    });
}
這樣的搜尋方式允許多欄位同時篩選,不論欄位類型是文字、布林值或數字,都可以靈活處理。
排序功能使得使用者可以依照任意欄位,升序或降序排列資料列。sortRows 函數負責實現較複雜的排序邏輯:
function sortRows(rows, sort) {
    return rows.sort((a, b) => {
        const { order, orderBy } = sort;
        if (_.isNil(a[orderBy])) return 1;
        if (_.isNil(b[orderBy])) return -1;
        const aLocale = (a[orderBy]) + "";
        const bLocale = (b[orderBy]) + "";
        if (order === 'asc') {
            return aLocale.localeCompare(bLocale, 'en', 
                { numeric: _.isNumber(b[orderBy]) }
            );
        } else {
            return bLocale.localeCompare(aLocale, 'en', 
                { numeric: _.isNumber(a[orderBy]) }
            );
        }
    });
}
想了解更多可以看看 mdn 的文件: localeCompare
表格的分頁功能透過 activePage 和每頁顯示資料數量來計算當前頁面應顯示的資料:
function Table({ columns, rows }){
    const [activePage, setActivePage] = useState(1);
    const [filters, setFilters] = useState({});
    const [sort, setSort] = useState({ order: 'asc', orderBy: 'id' });
    
    const filteredRows = useMemo(() => filterRows(rows, filters), [rows, filters])
    const sortedRows = useMemo(() => sortRows(filteredRows, sort), [filteredRows, sort])
    
    const rowsPerPage = 8;
    const calculatedRows = sortedRows.slice(
        (activePage - 1) * rowsPerPage,
        activePage * rowsPerPage
    )
    const count = filteredRows.length;
    const totalPages = Math.ceil(count / rowsPerPage);
    // ......
}
現在我們可以用 calculatedRows 來為表格填入資料了
但是我們還需要 Pagination 元件提供導航,讓使用者快速跳轉頁面。
<Pagination
    activePage={activePage}
    count={count}
    rowsPerPage={rowsPerPage}
    totalPages={totalPages}
    setActivePage={setActivePage}
/>
將狀態渲染 setActivePage 交給 Pagination 元件處理
Pagination 元件可以顯示分頁資訊並提供翻頁按鈕:
const Pagination = ({ activePage, count, rowsPerPage, totalPages, setActivePage }) => {
    const beginning = rowsPerPage * (activePage - 1) + 1;
    const end = activePage === totalPages ? count : beginning + rowsPerPage - 1;
    return (
        <>
        <div className="pagination">
            <div className="pagDescription">
                <p>Page {activePage} of {totalPages}</p>
                <p>Rows: {beginning === end ? end : `${beginning} - ${end}`} of {count}</p>
            </div>
            <button onClick={() => setActivePage(1)}>🢀🢀  First</button>
            <button onClick={() => setActivePage(activePage-1)}>🢀 Previous</button>
            <button onClick={() => setActivePage(activePage+1)}>Next 🢂</button>
            <button onClick={() => setActivePage(totalPages)}>Last 🢂🢂</button>
        </div>
        </>
    )
}
由於我們不希望跑到第0頁或超出頁面,還要適時地禁用按鈕:
<button disabled={activePage === 1}>First</button>
<button disabled={activePage === totalPages}>Last</button>
完成以上大工程後,Table 元件已經具備基本功能,我們終於可以渲染資料了!不過,看到這大家應該也累了吧?讓我們稍作休息,明天再繼續完成渲染的部分。
最後的最後
和 React 的片段擺一起比對的話,怕大家看不懂,所以全部放一起,邏輯是相同的:
const props = defineProps({
    rows: Array,
    columns: Array
});
const activePage = ref(1);
const filters = ref({});
const sort = ref({ order: 'asc', orderBy: 'id' });
const rowsPerPage = 8;
const filteredRows = computed(() => filterRows(props.rows, filters.value));
const sortedRows = computed(() => sortRows(filteredRows.value, sort.value));
const calculatedRows = computed(() => sortedRows.value.slice(
    (activePage.value - 1) * rowsPerPage,
    activePage.value * rowsPerPage
));
const count = computed(() => filteredRows.value.length);
const totalPages = computed(() => Math.ceil(count.value / rowsPerPage));
要向子元件傳遞函式,通過 emit 傳遞頁面更新,這邊取名為 update:activePage
<Pagination 
    @update:activePage="(page) => {activePage.value = page;}"
    :activePage="activePage"
    :rowsPerPage="rowsPerPage"
    :count="count"
    :totalPages="totalPages"
/>
然後就要在子元件中設置 emit,並使用相同名稱,相比 React 複雜了一丟丟:
const props = defineProps({
    activePage: Number,
    count: Number,
    rowsPerPage: Number,
    totalPages: Number,
});
const emit = defineEmits(['update:activePage']);
const setActivePage = (page) => {
    emit('update:activePage', page);
    console.log(props.rowsPerPage, props.activePage);
};
表格元件真的是大工程哪!相較之下,我會推薦用 Vue,因為他有強大的 v-for 指令可以用,下一篇我們就會看出差異囉!
如果感興趣,可以參考 Github 上的原始碼:
Table.jsx
Table.vue
Pagination.vue