ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

Vue3 Grid Layout实战:构建可拖拽、响应式仪表盘的完整指南

Vue3 Grid Layout实战:构建可拖拽、响应式仪表盘的完整指南 1. 项目概述为什么Vue Grid Layout是Vue3项目布局的“瑞士军刀”在Vue.js生态里尤其是从Vue2升级到Vue3之后很多开发者都会遇到一个共同的痛点如何快速、优雅地实现一个可拖拽、可缩放、响应式的仪表盘或者管理后台布局你可能尝试过手写CSS Grid或Flexbox但很快就会发现要实现动态的拖拽排序、实时调整尺寸、以及在不同断点下的自适应代码量会急剧膨胀维护起来也异常头疼。这时候一个专门为Vue设计的栅格布局系统就显得尤为重要。Vue Grid Layout这个在Vue2时代就备受好评的库如今在Vue3上表现如何它是否依然是那个能让你从繁琐的布局逻辑中解脱出来的利器答案是肯定的而且它在Vue3的组合式API加持下用起来更加得心应手。简单来说Vue Grid Layout是一个基于Vue.js的栅格布局系统它的核心能力是让你能够通过简单的配置就创建出类似Windows桌面或Trello看板那样的可拖拽、可调整大小的网格布局。它底层依赖于一个名为gridstack.js的库但提供了完全Vue化的组件式API让你可以用声明式的方式来描述你的布局数据驱动视图状态管理变得异常清晰。无论是构建数据可视化大屏、仪表盘、门户网站还是任何需要灵活排版的应用它都能大幅提升开发效率。对于正在使用Vue3的开发者而言Vue Grid Layout的v3版本提供了完整的Composition API支持这意味着你可以无缝地将其集成到你的script setup单文件组件中享受更简洁的语法和更好的类型推断。同时它保留了Vue2版本中所有强大的功能响应式栅格、拖拽放置、边界限制、静态项锁定等。接下来我将以一个实际的仪表盘搭建过程为例带你从零开始深入理解如何在Vue3项目中集成并使用Vue Grid Layout并分享一些官方文档里不会写的实战技巧和避坑指南。2. 环境搭建与基础集成从安装到第一个可拖拽的方块在开始写代码之前我们需要先把环境和依赖准备好。这里假设你已经有一个基于Vite或Vue CLI创建的Vue3项目。Vue Grid Layout的Vue3版本包名是vue3-grid-layout别装错了。2.1 安装依赖与基础配置首先通过npm或yarn安装核心包npm install vue3-grid-layout # 或 yarn add vue3-grid-layout安装完成后你可以在项目的入口文件如main.js或main.ts中全局注册组件也可以在具体的页面中按需引入。为了保持灵活性我通常推荐按需引入。但为了演示方便我们先看看全局注册的方式// main.js import { createApp } from vue; import App from ./App.vue; import VueGridLayout from vue3-grid-layout; const app createApp(App); app.use(VueGridLayout); // 这会全局注册 grid-layout 和 grid-item 组件 app.mount(#app);我更倾向于在组件内按需引入这样能更好地控制打包体积也更符合组合式API的哲学。在你的Vue组件中可以这样写template !-- 你的布局代码将在这里 -- /template script setup import { GridLayout, GridItem } from vue3-grid-layout; // 现在你可以在模板中直接使用 grid-layout 和 grid-item 了 /script基础环境就绪后我们来创建一个最简单的布局。Vue Grid Layout的核心是两个组件grid-layout作为容器grid-item作为容器内的每一个可摆放的项。每个GridItem都需要一些核心属性来定义其在栅格中的位置和大小这些属性是x,y,w,h分别代表项在网格中的列起始位置、行起始位置、宽度占多少列和高度占多少行。2.2 实现第一个可拖拽布局让我们创建一个基础的仪表盘组件Dashboard.vue。我们将定义一个响应式的布局数据layout它是一个数组每个元素对应一个GridItem的配置。template div classdashboard-container grid-layout :layoutlayout :col-num12 :row-height30 :is-draggabletrue :is-resizabletrue :vertical-compacttrue :margin[10, 10] :use-css-transformstrue layout-updatedlayoutUpdatedEvent grid-item v-foritem in layout :keyitem.i :xitem.x :yitem.y :witem.w :hitem.h :iitem.i resizedresizedEvent movedmovedEvent div classgrid-item-content {{ item.i }} - ({{ item.x }}, {{ item.y }}) {{ item.w }}x{{ item.h }} /div /grid-item /grid-layout /div /template script setup import { ref } from vue; import { GridLayout, GridItem } from vue3-grid-layout; // 定义布局数据 const layout ref([ { x: 0, y: 0, w: 2, h: 2, i: 0 }, { x: 2, y: 0, w: 4, h: 4, i: 1 }, { x: 6, y: 0, w: 2, h: 3, i: 2 }, { x: 8, y: 0, w: 4, h: 2, i: 3 }, { x: 0, y: 2, w: 2, h: 4, i: 4 }, ]); // 布局更新事件 const layoutUpdatedEvent (newLayout) { console.log(布局更新了:, newLayout); // 通常在这里将新的布局数据保存到后端或本地存储 layout.value newLayout; }; // 单个项尺寸改变事件 const resizedEvent (i, newH, newW, newHPx, newWPx) { console.log(项 ${i} 尺寸改变: ${newW}x${newH}); }; // 单个项移动事件 const movedEvent (i, newX, newY) { console.log(项 ${i} 移动到: (${newX}, ${newY})); }; /script style scoped .dashboard-container { padding: 20px; background-color: #f5f5f5; min-height: 100vh; } .grid-item-content { height: 100%; background-color: #fff; border: 1px solid #ddd; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 1.2em; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } /style运行这段代码你会在页面上看到5个可以自由拖拽和调整大小的白色卡片。这就是Vue Grid Layout最基础的能力。我们来拆解一下grid-layout上的几个关键属性:col-num12 这是栅格系统的总列数默认为12。这是一个非常经典的设计借鉴了Bootstrap等框架提供了灵活的布局分割可能性。:row-height30 定义了每一行的高度像素。所有GridItem的高度h都是这个基础值的倍数。:is-draggabletrue和:is-resizabletrue 控制是否允许拖拽和调整大小。你可以根据用户权限动态切换这两个属性。:margin[10, 10] 定义项与项之间的边距第一个值是水平间距第二个是垂直间距。:vertical-compacttrue 这是一个非常重要的属性。当设为true时如果上方的项被移走或删除下方的项会自动向上紧凑排列填补空隙避免出现“空洞”。:use-css-transformstrue 使用CSS的transform属性来进行定位和动画性能通常比直接操作top/left更好。注意layout数据中的i属性是每个GridItem的唯一标识符必须是字符串类型。它是v-for循环中:key和组件内部追踪项状态的依据务必保证其唯一性和稳定性。3. 核心功能深度解析响应式、持久化与动态增删一个能上生产环境的仪表盘绝不仅仅是能拖拽那么简单。用户期望布局能适应不同屏幕尺寸刷新页面后布局不会重置并且可以动态地添加或移除组件。这正是Vue Grid Layout的进阶能力所在。3.1 实现真正的响应式布局基础的栅格布局在固定宽度下工作良好但在移动端或不同尺寸的桌面显示器上固定列数可能导致内容过挤或过宽。Vue Grid Layout提供了响应式断点功能。你可以为不同的屏幕宽度定义不同的col-num列数和layout布局。首先我们需要准备不同断点下的布局配置。通常我们会定义几个常见的断点例如lg(大屏幕)、md(中等屏幕)、sm(小屏幕/平板)、xs(超小屏幕/手机)、xxs(极小型手机)。template grid-layout :layoutlayout :col-numcolNum :row-height30 :is-draggabletrue :is-resizabletrue :responsivetrue :responsive-layoutsresponsiveLayouts :breakpointsbreakpoints :colscols breakpoint-changedbreakpointChanged !-- ... grid-item ... -- /grid-layout /template script setup import { ref, computed } from vue; // 定义响应式断点像素值 const breakpoints ref({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); // 定义不同断点下的列数 const cols ref({ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }); // 初始化一个针对大屏幕lg的布局 const initialLayout [ { x: 0, y: 0, w: 6, h: 4, i: chart1 }, { x: 6, y: 0, w: 6, h: 4, i: chart2 }, { x: 0, y: 4, w: 12, h: 5, i: table1 }, ]; // 定义所有断点的布局。通常我们先设计好lg布局其他布局可以通过事件自动生成也可以手动精细调整。 const responsiveLayouts ref({ lg: initialLayout, md: [ { x: 0, y: 0, w: 5, h: 4, i: chart1 }, { x: 5, y: 0, w: 5, h: 4, i: chart2 }, { x: 0, y: 4, w: 10, h: 6, i: table1 }, // 表格在中等屏幕可能更高 ], sm: [ { x: 0, y: 0, w: 6, h: 3, i: chart1 }, { x: 0, y: 3, w: 6, h: 3, i: chart2 }, { x: 0, y: 6, w: 6, h: 5, i: table1 }, ], // xs 和 xxs 可以继续定义更紧凑的布局 }); // 当前激活的布局和列数由组件内部根据屏幕宽度自动管理 const layout ref(initialLayout); const colNum ref(12); // 断点变化事件 const breakpointChanged (newBreakpoint) { console.log(当前断点变为:, newBreakpoint); // 当断点变化时layout和colNum会自动更新为responsiveLayouts中对应的值 // 你可以在这里做一些额外的逻辑比如加载对应尺寸的图表组件 }; /script关键属性解析:responsivetrue 开启响应式模式。:responsive-layoutsresponsiveLayouts 传入一个对象键是断点名lg,md等值是对应的布局数组。:breakpoints和:cols 这两个对象定义了断点的像素阈值和各断点的列数。组件会监听窗口大小当宽度低于某个阈值时就切换到对应的断点并使用cols中定义的列数和responsiveLayouts中定义的布局。实操心得手动为每个断点设计布局非常耗时。一个高效的策略是只精心设计lg桌面端布局。然后在开发阶段手动调整浏览器窗口到各个断点拖拽出你觉得合适的布局通过layout-updated事件将新的布局数据打印出来直接复制粘贴到responsiveLayouts对应的属性里。这比凭空想象要快得多。3.2 布局状态持久化刷新页面不丢失用户辛辛苦苦调整好的仪表盘一刷新就没了体验极差。因此我们必须将布局状态保存起来。通常有两种选择本地存储LocalStorage和远程后端存储。本地存储方案简单快捷我们可以利用Vue Grid Layout提供的layout-updated事件在每次布局变化时将最新的layout数据存入LocalStorage。script setup import { ref, onMounted } from vue; const layout ref([]); // 从本地存储加载布局 const loadLayout () { const saved localStorage.getItem(my-dashboard-layout); if (saved) { try { layout.value JSON.parse(saved); } catch (e) { console.error(解析保存的布局失败使用默认布局, e); layout.value getDefaultLayout(); } } else { layout.value getDefaultLayout(); } }; // 保存布局到本地存储 const saveLayout (newLayout) { localStorage.setItem(my-dashboard-layout, JSON.stringify(newLayout)); }; const layoutUpdatedEvent (newLayout) { console.log(布局更新自动保存); saveLayout(newLayout); layout.value newLayout; // 更新响应式数据 }; // 组件挂载时加载 onMounted(() { loadLayout(); }); // 提供一个重置按钮的功能 const resetLayout () { const defaultLayout getDefaultLayout(); layout.value defaultLayout; saveLayout(defaultLayout); }; /script后端存储方案多端同步对于需要用户登录、在多设备间同步布局的场景就需要将布局数据保存到后端数据库。流程类似只是在layoutUpdatedEvent中发起一个API请求将newLayout发送到服务器。为了优化体验可以考虑加入防抖debounce逻辑避免短时间内的频繁拖拽产生大量请求。import { debounce } from lodash-es; // 或自己实现一个简单的debounce const saveLayoutToServer debounce(async (layoutData) { try { await api.post(/user/dashboard/layout, { layout: layoutData }); } catch (error) { console.error(保存布局失败:, error); // 可以在这里添加错误提示如Toast } }, 1000); // 延迟1秒保存 const layoutUpdatedEvent (newLayout) { layout.value newLayout; saveLayoutToServer(newLayout); };3.3 动态添加与移除布局项一个交互完善的仪表盘应该允许用户添加新的小部件Widget或移除现有的。这需要动态地操作layout数组。template div button clickaddNewItem添加新图表/button button clickresetLayout重置布局/button grid-layout :layoutlayout layout-updatedlayoutUpdatedEvent grid-item v-foritem in layout :keyitem.i :xitem.x :yitem.y :witem.w :hitem.h :iitem.i container-resizedresizedEvent div classitem-header span{{ item.title }}/span button classclose-btn clickremoveItem(item.i)×/button /div div classitem-body !-- 这里根据 item.type 渲染不同的图表或组件 -- component :isgetComponent(item.type) / /div /grid-item /grid-layout /div /template script setup import { ref, computed } from vue; import ChartA from ./components/ChartA.vue; import ChartB from ./components/ChartB.vue; import DataTable from ./components/DataTable.vue; const layout ref([...]); // 初始布局 // 用于生成唯一ID let idCounter ref(layout.value.length); const addNewItem () { const newId item_${idCounter.value}; const newItem { i: newId, x: (layout.value.length * 2) % 12, // 简单计算一个初始位置避免完全重叠 y: Infinity, // 设置为Infinity会自动将项目放在底部 w: 4, h: 3, title: 新图表 ${idCounter.value}, type: ChartA // 假设新添加的都是ChartA类型 }; layout.value.push(newItem); // 注意直接push后由于 y: InfinityVue Grid Layout 会自动计算一个合适的位置通常是底部新行 // 如果你希望有更精确的初始位置控制可以自己计算一个空的网格位置。 }; const removeItem (itemId) { const index layout.value.findIndex(item item.i itemId); if (index -1) { layout.value.splice(index, 1); } }; const getComponent (type) { const componentMap { ChartA: ChartA, ChartB: ChartB, DataTable: DataTable, }; return componentMap[type] || div; }; /script style scoped .item-header { display: flex; justify-content: space-between; align-items: center; padding: 8px; background: #f0f0f0; border-bottom: 1px solid #ddd; cursor: move; /* 提示此处可拖拽 */ } .close-btn { border: none; background: transparent; font-size: 1.5em; cursor: pointer; line-height: 1; } .item-body { padding: 16px; height: calc(100% - 40px); /* 减去header高度 */ overflow: auto; } /style这里有几个关键点唯一标识符动态添加项时必须确保i的唯一性。可以使用时间戳、递增计数器或UUID。初始位置设置y: Infinity是一个常用技巧它告诉布局系统“把我放在最下面有空位的地方”。这比手动计算空闲网格要简单可靠。删除项直接从layout数组中移除对应项即可。由于布局是响应式的移除后如果开启了:vertical-compacttrue下方的项会自动上移填补空缺。组件动态渲染通过component :is...可以根据布局项中存储的类型信息动态渲染不同的子组件这使得你的仪表盘可以容纳多种类型的小部件。4. 高级特性与实战避坑指南掌握了基础和核心功能后我们来看看一些高级配置和实际开发中容易踩的“坑”。这些经验往往需要真正在项目里用过才能体会到。4.1 静态项与拖拽手柄提升交互精度有时候你希望布局中的某些项是固定的不允许用户移动或调整大小比如一个重要的标题栏或Logo区域。这可以通过设置static属性来实现。grid-item :ilogo :x0 :y0 :w2 :h2 :statictrue div公司Logo不可移动/div /grid-item设置为static: true后该项将无法被拖拽和调整大小并且会固定在初始位置不受其他项移动和紧凑算法的影响。另一个常见的需求是限制拖拽区域。默认情况下拖拽整个GridItem内容区域都可以触发移动这可能会和内容内部的点击事件冲突比如图表内部的点击。Vue Grid Layout支持定义拖拽手柄drag handle。grid-item ... div classcustom-dashboard-item !-- 只有这个header部分可以拖拽 -- div classitem-drag-handle 拖拽这里来移动 button clickcloseItem关闭/button /div div classitem-content clickhandleContentClick 这里是实际内容点击不会触发拖拽 /div /div /grid-item// 在模板中需要告诉GridItem拖拽手柄的选择器 grid-item :drag-allow-from.item-drag-handle ...在CSS中你需要为拖拽手柄添加cursor: move;样式来提供视觉提示。通过drag-allow-from属性指定一个CSS选择器只有匹配该选择器的元素才能触发拖拽完美解决了内容交互冲突的问题。对应的也有drag-ignore-from属性来指定哪些元素不允许触发拖拽。4.2 性能优化与边界情况处理当布局项数量很多比如超过20个或者每个项内部渲染了非常复杂的图表如ECharts时拖拽和调整大小可能会出现卡顿。以下是一些优化建议使用use-css-transforms确保这个属性为true默认就是它利用GPU加速性能远优于基于top/left的定位。拖拽时降低子组件渲染精度在复杂图表项中可以监听GridItem的dragstart和dragend事件。在开始拖拽时将图表替换为一个简单的占位图或纯色背景拖拽结束后再恢复渲染真实图表。这能极大提升拖拽流畅度。避免深层响应式数据layout数组本身是响应式的但确保数组内的每个对象属性不要嵌套过深。如果布局项包含大量用于其内部组件的数据考虑将其分离只将布局定位信息x, y, w, h, i放在layout中。谨慎使用watch深度监听如果你因为其他逻辑用watch深度监听了layout在项很多时任何微小的坐标变化都会触发昂贵的回调。考虑使用特定的更新事件如layout-updated来代替全局监听。边界情况处理项的重叠与边界默认情况下Vue Grid Layout会防止项重叠并限制项不能拖出容器边界。但如果你手动修改layout数据导致重叠视图可能显示异常。始终通过组件事件来更新布局数据是最安全的。最小/最大宽度高度你可以通过minW、maxW、minH、maxH属性来限制某个GridItem可调整的尺寸范围这对于保证某些图表的长宽比或最小可读性非常有用。grid-item :ichart1 :x0 :y0 :w4 :h3 :min-w2 :max-w6 :min-h2 :max-h5初始化时的布局错乱有时在复杂场景下组件挂载时布局会短暂错乱然后恢复正常。这通常是因为子组件如图表异步渲染导致的高度计算问题。可以尝试在grid-layout上设置:auto-sizetrue或者确保子组件渲染完成后再初始化布局数据例如在onMounted钩子中从后端获取数据后再赋值给layout。4.3 与Vue3生态的深度集成TypeScript与状态管理对于使用TypeScript的Vue3项目Vue Grid Layout提供了良好的类型支持。安装后类型定义会自动包含在内。你可以为你的布局项定义一个接口让代码更清晰。// types/dashboard.ts export interface DashboardLayoutItem { i: string; // 唯一标识 x: number; y: number; w: number; h: number; title: string; componentType: LineChart | BarChart | PieChart | Table; // 其他自定义属性 props?: Recordstring, any; } // 在你的组件中 import type { DashboardLayoutItem } from /types/dashboard; const layout refDashboardLayoutItem[]([...]);在与PiniaVue3推荐的状态管理库集成时你可以将布局状态存储在store中方便在不同组件间共享和持久化。// stores/dashboard.ts import { defineStore } from pinia; import { ref } from vue; import type { DashboardLayoutItem } from /types/dashboard; export const useDashboardStore defineStore(dashboard, () { const layout refDashboardLayoutItem[]([]); const loadLayout async () { // 从API加载布局 }; const updateLayout (newLayout: DashboardLayoutItem[]) { layout.value newLayout; // 触发保存到后端 }; return { layout, loadLayout, updateLayout }; }); // 在组件中使用 import { useDashboardStore } from /stores/dashboard; const dashboardStore useDashboardStore(); const layout computed(() dashboardStore.layout); const layoutUpdatedEvent (newLayout) { dashboardStore.updateLayout(newLayout); };这种模式将布局数据、业务逻辑和持久化操作集中管理使组件更加简洁也更容易进行单元测试。5. 从“能用”到“好用”自定义样式与交互增强原生的Vue Grid Layout组件提供的样式比较基础在实际项目中我们通常需要深度定制其外观和交互以符合产品设计规范。5.1 深度定制拖拽与调整大小的视觉反馈默认的拖拽预览和调整大小手柄样式可能不符合你的UI设计。你可以通过CSS覆盖来自定义。/* 自定义拖拽时的预览样式 */ .vue-grid-layout .vue-grid-item.vue-draggable-dragging { opacity: 0.7; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 1000; transition: opacity 0.2s; } /* 自定义调整大小手柄 */ .vue-grid-layout .vue-resizable-handle { background-color: #1890ff; /* 主色调 */ border-radius: 50%; width: 12px !important; height: 12px !important; bottom: -6px !important; right: -6px !important; opacity: 0; transition: opacity 0.2s; } .vue-grid-item:hover .vue-resizable-handle { opacity: 1; } /* 隐藏默认的四个角的手柄只保留右下角 */ .vue-grid-layout .vue-resizable-handle::after { display: none; } /* 单独为右下角手柄添加图标 */ .vue-grid-layout .vue-resizable-handle.se { background-image: url(data:image/svgxml;utf8,svg xmlnshttp://www.w3.org/2000/svg width12 height12 viewBox0 0 24 24path fillwhite dM22,22H20V20H22V22M22,18H20V16H22V18M18,22H16V20H18V22M18,18H16V16H18V18M14,22H12V20H14V22M22,14H20V12H22V14Z//svg); background-repeat: no-repeat; background-position: center; }你还可以通过监听drag-start和drag-end事件在拖拽开始时给body添加一个类来全局修改光标样式或禁用文本选择。const onDragStart () { document.body.classList.add(grid-dragging); }; const onDragEnd () { document.body.classList.remove(grid-dragging); };body.grid-dragging { cursor: move !important; user-select: none; }5.2 实现布局的导入、导出与重置功能对于高级用户提供布局的导入、导出和重置功能非常实用。template div button clickexportLayout导出布局/button button clickimportLayout导入布局/button button clickconfirmReset重置为默认/button input typefile reffileInput changehandleFileUpload styledisplay: none; accept.json/ !-- ... grid-layout ... -- /div /template script setup import { ref } from vue; const layout ref([...]); const fileInput ref(null); const exportLayout () { const layoutStr JSON.stringify(layout.value, null, 2); const blob new Blob([layoutStr], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download dashboard-layout-${new Date().toISOString().split(T)[0]}.json; a.click(); URL.revokeObjectURL(url); }; const importLayout () { // 触发隐藏的文件输入框点击 fileInput.value.click(); }; const handleFileUpload (event) { const file event.target.files[0]; if (!file) return; const reader new FileReader(); reader.onload (e) { try { const importedLayout JSON.parse(e.target.result); // 这里可以添加验证确保导入的数据结构正确 if (Array.isArray(importedLayout) importedLayout.every(item item.i item.x ! undefined)) { layout.value importedLayout; alert(布局导入成功); } else { alert(文件格式无效); } } catch (error) { alert(解析JSON文件失败); console.error(error); } // 清空input以便再次选择同一文件 event.target.value ; }; reader.readAsText(file); }; const confirmReset () { if (confirm(确定要重置为默认布局吗当前布局将丢失。)) { layout.value getDefaultLayout(); saveLayout(layout.value); // 同时持久化 } }; /script5.3 处理复杂内容与滚动当一个GridItem内部内容很高需要滚动时可能会和拖拽、调整大小操作冲突。一个常见的解决方案是只在项的非滚动区域如标题栏设置拖拽手柄并为内容区域设置overflow: auto。grid-item ... :drag-allow-from.drag-handle div classcustom-item div classdrag-handle标题栏可拖拽/div div classcontent-scrollable !-- 很长的内容可以滚动 -- p v-forn in 50 :keyn内容行 {{ n }}/p /div /div /grid-item.custom-item { height: 100%; display: flex; flex-direction: column; } .drag-handle { padding: 8px; background: #eee; cursor: move; flex-shrink: 0; /* 防止被压缩 */ } .content-scrollable { flex: 1; /* 占据剩余空间 */ overflow-y: auto; padding: 8px; }这样用户可以在内容区域自由滚动而拖拽操作只通过标题栏触发两者互不干扰。这套组合拳打下来你的Vue3仪表盘不仅功能强大而且交互细腻足以应对复杂的生产环境需求。记住好的工具是基础但结合具体业务场景的细节打磨才是让产品脱颖而出的关键。
返回列表