
前后台共用页面这个命题我刚入行那会儿觉得是个伪需求——后台就老老实实做管理前台就花里胡哨做展示俩界面风格差着十万八千里共用不是找不自在吗直到后来真遇到业务场景才明白当你的后台管理的不再是“数据”而是“业务”本身时前后台有不少页面本质上是同一份内容的两副面孔。比如商品页、订单详情页、文章详情页甚至一套复杂的报名表单前台用户看到的是“信息操作”后台审核员看到的也是“信息操作”区别只在谁能看全字段、谁能点某个按钮、谁能改某个数值。这篇文章就围绕“前后台共用的页面要如何开发、如何共享”这件事把我实践过的方案、踩过的坑、沉淀下来的设计套路一次讲清楚。适合正在做中后台系统、或者维护一个同时面向C端和内部员工的产品、又不想把相似页面写两遍的开发者参考。我会从场景边界、工程结构、权限适配、实操代码到问题排查一层层拆开讲。1. 前后台共用页面的核心场景与边界1.1 适合共用的页面都有哪些特征先别急着套架构第一步是判断“这个页面到底该不该共用”。我在实际项目里总结出一个规律适合共用的页面几乎都是信息密集型页面而不是操作密集型页面。信息密集型页面的特点是页面主体是同一份业务数据的结构性展示前后台的差异只是“展示哪些字段”“允许哪些操作”。最典型的就是详情页。一个订单详情前台用户想看到商品列表、金额明细、物流状态后台客服想看到的也是这些东西顶多多几个内部字段比如成本价、供应商备注、内部审核日志。一个商品详情前台看的是卖点和价格后台看的是库存、SKU结构、上架状态。这类页面天然适合共用因为核心渲染逻辑完全一致只是数据源或字段开关不同。而操作密集型页面就不太适合。比如前台用户的购物车、后台的订单批量审核台前者每分每秒都在处理用户交互状态后者是面向固定工位的表格批处理两者的信息架构和操作流差异太大硬凑到一起只会让代码里充满if判断最后谁都看不懂维护成本反而翻倍。还有一个需要警惕的伪共用场景只是长得像但语义完全不同。比如后台的“用户列表”和后台的“团队成员列表”虽然都是表格加搜索但一个管C端注册用户一个管内网账号领域模型压根不搭边。这种页面我更建议各自独立宁可复制一版也不强行抽象。共用的前提是共享同一份领域语义不是共享同一份CSS。1.2 共用前必须先想清楚的两件事想清楚场景之后开工前还有两个前置问题必须明确否则后面所有代码都是在沙滩上盖楼。第一件是字段权限边界。前后台共用页面最直接的冲突点在于同一个实体前后台能看到的字段不一样。前台用户不能看成本价后台管理员不能看用户隐私备注这是业务铁律。所以你在设计组件时不能假设“这个页面拿到的数据就是最终展示的数据”而是要设计成“组件只负责渲染字段可见性和可编辑性由外部上下文决定”。第二件是操作权限边界。共用页面里同一个按钮在前台和后台往往触发完全不同的行为。前台用户点“取消订单”是提交一个申请后台管理员点“取消订单”是直接执行并可能强制填写原因。按钮做了同样的事但接口、校验、二次确认逻辑都不一样。这意味着页面组件不应该直接去调用业务接口而是把“操作”抽象成事件具体执行逻辑交给所在环境的适配器处理。我见过很多共用页面项目做砸不是因为组件写得不好而是这两个边界没想明白最后导致页面里塞满了“if isAdmin”之类的分支组件和后端接口、权限代码全部耦合在一起改动一个需求要顺着三层代码翻找完全失去了共用本该有的维护优势。2. 整体架构设计怎么把“共用”落到工程里2.1 推荐的目录结构设计想清楚边界后工程结构就好定了。我一般采用“业务域为顶层、底座与入口分离”的方式。简单说业务页面代码收拢在业务域下不区分前台后台每个端只维护自己的入口、路由和依赖注入。举一个实际项目里的目录结构某电商产品同时有面向买家的H5商城和面向客服的运营工作台共用页面放在features里src/ ├── entries/ │ ├── customer/ # 前台(买家端)入口 │ │ ├── routes.ts │ │ ├── bootstrap.ts │ │ └── contextImpl/ # 前台的上下文实现 │ └── admin/ # 后台(客服工作台)入口 │ ├── routes.ts │ ├── bootstrap.ts │ └── contextImpl/ # 后台的上下文实现 ├── features/ │ └── order/ │ ├── pages/ │ │ └── OrderDetailPage.tsx # 共用页面组件 │ ├── components/ │ │ ├── OrderGoodsTable.tsx │ │ ├── OrderAmountPanel.tsx │ │ └── OrderActionBar.tsx # 操作区根据能力渲染 │ ├── context/ │ │ └── orderPageContext.ts # 页面上下文的类型定义 │ └── adapters/ │ ├── customerAdapter.ts # 前台数据/接口适配 │ └── adminAdapter.ts # 后台数据/接口适配 └── shared/ ├── hooks/ ├── ui/ # 基础组件库 └── utils/这里的关键是entries目录。每个端都有自己独立的入口入口负责注册路由、初始化全局状态、注入当前端特有的实现。而features目录下的页面组件不允许直接 import 任何跟某个端强相关的东西它只能依赖context目录里定义好的接口。这个结构的好处是当你打开前台入口代码你能清楚地看到这个端自己处理了什么、复用了什么当你要新加一个端比如再来一个移动端管理角色只需要新增一个entries下的目录页面几乎不动。2.2 路由与加载策略各自注册指向同源组件目录搭好了接下来是路由。很多人在这一步会犯一个错误做一个“共享路由表”让前后台共用一份路由配置。这看着省事实际上很快就扯皮——前台的菜单是用户看的后台的菜单是权限系统控制的两边路由守卫逻辑完全不同硬共用一个文件只会让路由守卫代码越来越臃肿。我的做法是路由各自注册组件同一来源。每个端的入口文件里各自维护自己的路由表但路由表的 component 字段指向features/order/pages/OrderDetailPage这个共用组件。比如前台路由表里有一项// entries/customer/routes.ts const customerRoutes [ { path: /order/:id, component: OrderDetailPage, meta: { title: 订单详情, auth: true } } ]后台路由表里同样可以有一项指向同一个组件// entries/admin/routes.ts const adminRoutes [ { path: /order/:id, component: OrderDetailPage, meta: { title: 订单详情, permission: order:view } } ]路由表分开组件却只有一个这个组合既有灵活性又避免了重复实现。另外记得使用动态 import 让共用页面按需加载别一股脑打进首屏包// 推荐写法 const OrderDetailPage lazy(() import(features/order/pages/OrderDetailPage))这样前台用户访问前台路由时只加载前台需要的 chunk后台加载后台的两个端互不拖累。2.3 依赖注入与全局上下文让共用页面不认“端”只认“能力”共用页面的核心设计原则可以概括成一句话页面不问“我在哪个端”只问“当前环境给了我什么能力”。为了实现这个我在页面组件里只用自己定义好的上下文接口具体实现在每个端入口注入。以一个订单详情页为例先定义好页面需要的上下文类型// features/order/context/orderPageContext.ts export interface OrderPageContext { // 当前环境能展示哪些字段(如成本价是否可见) visibleFields: string[] // 当前环境支持哪些操作(如取消订单、改价、备注) operations: OperationDescriptor[] // 获取订单数据 fetchOrderDetail(orderId: string): PromiseOrderDetail // 执行某项操作 executeOperation(op: string, payload: unknown): Promisevoid }页面组件只依赖这几个方法和属性不关心实现它的是前台接口还是后台接口const orderPageContext useOrderPageContext() useEffect(() { orderPageContext.fetchOrderDetail(orderId).then(setOrder) }, [orderId]) // 渲染操作按钮时根据 operations 描述渲染 {orderPageContext.operations.map(op ( ActionButton key{op.code} ifadmin disabled{!op.enabled(resource)} label{op.label} onClick{() handleExecute(op.code)} / ))}实际项目里我通常会在每个端的入口bootstrap.ts里调用上下文 Provider把当前端的具体实现注入进去。前台注入前台实现后台注入后台实现。这个模式的好处是新增一个端形态时你只需要新写一组 adapter 注入即可共用页面一行代码都不用改。这种思路不是新花样本质上是依赖倒置原则只不过在前后台共用页面的场景下收益尤其明显。一旦一个业务域页面跟具体端解耦你甚至可以在未来低成本地扩展出第三端、第四端比如商家端、外部供应商端。3. 关键机制权限与数据适配是共用页面的灵魂3.1 能力上下文与差异化的页面渲染说到权限适配我见过一个比较粗暴的方式在页面组件里判断当前路由的meta或当前用户身份然后条件渲染。这种写法在小项目里很直观但一旦共用页面多了组件里到处是if逻辑代码会迅速腐化。我自己的实践里更推荐把“当前端的能力”抽象成一组描述性数据让页面成为“渲染描述”的引擎。上下文里包含几个核心维度维度说明前台示例后台示例字段可见性哪些字段能展示不展示成本价展示成本价字段可编辑性哪些字段能改不可改数量可改实付金额操作可用性哪些按钮能点仅取消申请、确认收货改价、审核、备注、退款数据范围当前人能看哪些记录仅自己的订单全部客户订单行为结果操作触发什么副作用提交申请并通知客服直接改单并记录日志页面组件只需要照着这份描述渲染不需要知道自己在哪个端。比如同一个订单详情页前台渲染效果是“商品信息金额取消/确认收货按钮”后台渲染效果是“商品信息金额内部字段改价/退款/备注按钮”。视觉可以有差异但底层渲染逻辑是同一套——按钮区和字段区都是“根据描述渲染”的结果。这么设计的另外一个好处是前后台的能力差异可以通过配置接口返回比如后台管理员有一个“是否允许查看成本”的开关切换后只需要更新能力描述页面自动响应不必发版改代码。3.2 字段级权限控制的实现方案字段级权限是“最细颗粒度”的一层也是实际项目里最容易出bug的地方。我常用的方案是给每个字段打一个权限标识然后用一个统一的FieldGuard组件去包装。以订单详情页的金额区域为例// 使用方式 FieldGuard fieldcostPrice modevisible AmountItem label成本价 value{costPrice} / /FieldGuard FieldGuard fieldpaidAmount modeeditable input value{paidAmount} onChange{updatePaidAmount} / /FieldGuardFieldGuard内部只需要做一件事从当前上下文里取出字段能力配置看看visibleFields或editableFields是否包含目标字段如果不包含且是自己负责渲染的区块直接返回 null或者渲染一个占位符比如“无权限查看”。这里有一个容易踩的坑不要把字段权限和表单校验混在一起。字段不可见不等于字段无值。后台把成本价字段隐藏后提交数据时该字段的 value 仍然要正常传给接口只是界面上不展示。所以 FieldGuard 控制的只是渲染层数据层不做任何过滤。我见过团队在隐藏字段时把数据也给清空了结果误导了后端判断这个必须注意。此外字段级权限配置建议统一收拢成一张映射表而不是分散在各页面里。比如const fieldAccessMap { costPrice: { admin: { visible: true, editable: false }, customer: { visible: false, editable: false } }, remark: { admin: { visible: true, editable: true }, customer: { visible: true, editable: false } } }这张表可以作为配置下发前端只需要读配置渲染。如果你们项目没这么复杂写在代码里也行但至少要做到来源唯一别这个页面自己定义一份、那个页面又定义一份。3.3 接口数据映射与查询适配层前后台共用页面的另一大痛点同一份数据在两个端接口返回的结构不一样。前台接口可能只返回两个字段后台接口返回二十个字段前台接口把金额字段叫amount后台接口叫totalAmount。这些都是常规操作。如果让共用组件直接对接两个接口等于组件要感知两端的数据结构那共用也就名存实亡了。我的做法是在共用页面和接口之间加一层适配器Adapter。// features/order/adapters/customerAdapter.ts export class CustomerOrderAdapter implements OrderAdapter { async fetchOrder(orderId: string): PromiseNormalizedOrder { const raw await requestCustomerApi(/customer/orders/${orderId}) return { id: raw.id, goodsList: raw.items.map(it ({ name: it.title, qty: it.quantity, price: it.price })), totalAmount: raw.payAmount, ... } } } // features/order/adapters/adminAdapter.ts export class AdminOrderAdapter implements OrderAdapter { async fetchOrder(orderId: string): PromiseNormalizedOrder { const raw await requestAdminApi(/admin/orders/${orderId}) return { id: raw.id, goodsList: raw.skuList.map(it ({ name: it.skuName, qty: it.count, price: it.costPrice // 注意这里甚至可以映射出正常用户看不到的字段 })), totalAmount: raw.priceInfo.payAmount, costPrice: raw.priceInfo.cost, ... } } }NormalizedOrder是共用页面真正消费的数据结构两个适配器负责把各自端接口返回的数据统一成这个结构。这里有一个很重要的设计细节适配器不只是做字段改名还负责补充或过滤业务信息。比如前台适配器不返回成本价字段后台适配器返回成本价字段。这样页面拿到数据后配合前面的字段权限描述就能做到同一套组件在不同端展示出不同内容而且不需要写“如果是后台再拉一次成本价”这种逻辑。有人可能会觉得“多包一层适配器是不是过度设计”我的看法是只要你确认这个页面会同时给至少两个端使用这个适配器就值得做。它能防止互不兼容的接口结构污染共用的渲染代码也能在前台接口改字段时只改一个文件而不是在十几个页面里做文本替换。4. 实操落地以订单详情页为例跑通完整流程4.1 案例需求拆解与页面模块划分理论讲再多不如把一套代码写出来看得明白。这里我用一个虚构电商项目“某商城”做例子演示从0到1实现一个前后台共用的订单详情页。先明确需求。前台买家看到的订单详情包括订单状态、商品列表、金额明细、收货地址、取消订单按钮限未支付或未发货状态、确认收货按钮限已发货状态。后台客服看到的订单详情包括订单状态、用户信息、商品列表、金额明细含成本价、改价按钮、退款按钮、审核备注记录、添加内部备注的表单。页面模块划分如下订单状态头部共用但状态描述在不同端可以有不同的文案商品信息卡片共用字段明细有差异金额面板共用后台多了成本价字段收货信息共用后台多了“该用户历史订单”快捷入口操作区差异最大完全由能力描述驱动后台专属的处理记录时间轴仅后台渲染4.2 从0实现核心代码上下文、适配器与页面组件按上面的架构我先定义订单详情页的上下文类型// features/order/context/orderPageContext.ts import { createContext, useContext } from react export interface NormalizedOrder { id: string status: pending | paid | shipped | done | cancelled goodsList: Array{ name: string qty: number price: number } totalAmount: number costPrice?: number // 后台才有 customerRemark?: string // 后台特有 } export interface OperationDescriptor { code: cancel | confirm | adjustPrice | refund | remark label: string enabled: (order: NormalizedOrder) boolean danger?: boolean } export interface OrderPageContextValue { visibleFields: string[] editableFields: string[] operations: OperationDescriptor[] fetchOrder: (id: string) PromiseNormalizedOrder executeOperation: (code: string, order: NormalizedOrder, payload?: unknown) Promisevoid } export const OrderPageContext createContextOrderPageContextValue | null(null) export function useOrderPageContext() { const ctx useContext(OrderPageContext) if (!ctx) throw new Error(OrderPageContext is not injected) return ctx }再写前台适配器// entries/customer/contextImpl/customerOrderContext.ts import type { OrderPageContextValue, NormalizedOrder, OperationDescriptor } from features/order/context/orderPageContext const customerOperations: OperationDescriptor[] [ { code: cancel, label: 取消订单, enabled: (order) order.status pending || order.status paid }, { code: confirm, label: 确认收货, enabled: (order) order.status shipped } ] export const customerOrderContext: OrderPageContextValue { visibleFields: [totalAmount, goodsList, address], editableFields: [], operations: customerOperations, async fetchOrder(id) { // 调用前台买家的订单接口 const raw await fetch(/api/customer/orders/${id}).then(r r.json()) return { id: raw.id, status: raw.state, goodsList: raw.items.map((it: any) ({ name: it.name, qty: it.count, price: it.price })), totalAmount: raw.moneyInfo.payAmount } }, async executeOperation(code, order) { if (code cancel) { await fetch(/api/customer/orders/${order.id}/cancel, { method: POST }) return } if (code confirm) { await fetch(/api/customer/orders/${order.id}/confirm, { method: POST }) } } }后台适配器也差不多只是字段和操作更多数据从后台接口取// entries/admin/contextImpl/adminOrderContext.ts // (节选字段映射部分操作类似) export const adminOrderContext: OrderPageContextValue { visibleFields: [totalAmount, costPrice, goodsList, address, customerRemark], editableFields: [remark], operations: [ { code: adjustPrice, label: 改价, enabled: () true }, { code: refund, label: 退款, enabled: (order) order.status paid }, // ... 其他操作 ], async fetchOrder(id) { const raw await fetch(/api/admin/orders/${id}).then(r r.json()) return { id: raw.id, status: raw.status, goodsList: raw.skuList.map((it: any) ({ name: it.skuName, qty: it.count, price: it.salePrice })), totalAmount: raw.price.payAmount, costPrice: raw.price.costPrice, customerRemark: raw.remark } }, // executeOperation 实现略 }最后写共用页面组件// features/order/pages/OrderDetailPage.tsx import { useEffect, useState } from react import { useParams } from react-router-dom import { useOrderPageContext } from ../context/orderPageContext import type { NormalizedOrder } from ../context/orderPageContext type FieldRenderer (order: NormalizedOrder) React.ReactNode const fieldRenderers: Recordstring, FieldRenderer { totalAmount: (order) AmountItem label实付金额 value{order.totalAmount} /, costPrice: (order) AmountItem label成本价 value{order.costPrice} /, customerRemark: (order) div备注{order.customerRemark || 无}/div } export function OrderDetailPage() { const { id } useParams() const ctx useOrderPageContext() const [order, setOrder] useStateNormalizedOrder | null(null) const [loadError, setLoadError] useState() useEffect(() { ctx.fetchOrder(id).then(setOrder).catch((e) { setLoadError(e.message || 加载失败) }) }, [id, ctx]) if (!order) { return LoadingHint error{loadError} / } return ( div classNameorder-detail-page OrderStatusHeader status{order.status} / OrderGoodsTable goodsList{order.goodsList} / div classNameamount-panel {/* 渲染字段时统一走 FieldGuard */} {Object.keys(fieldRenderers).map((fieldKey) { if (!ctx.visibleFields.includes(fieldKey)) return null return ( FieldGuard key{fieldKey} field{fieldKey} modevisible {fieldRenderers[fieldKey](order)} /FieldGuard ) })} /div OrderAddress address{order.address} / div classNameaction-bar {ctx.operations.map((op) ( ActionButton key{op.code} danger{op.danger} disabled{!op.enabled(order!)} onClick{() ctx.executeOperation(op.code, order!)} {op.label} /ActionButton ))} /div /div ) }组件自身完全不知道自己是前台还是后台它只是从上下文拿“字段可见性”和“操作列表”来驱动渲染。前台适配器提供前台的字段和操作后台适配器提供后台的字段和操作共用页面代码保持稳定这就是核心机制的效果。4.3 构建与打包时的隔离要点共用页面在开发时很香但如果打包配置不做隔离很容易把两个端的东西搅在一起。主要注意三点一是入口文件必须分开。别让建置工具从一个main.ts同时打进两个端的代码。哪怕是同一个组件前台入口只引前台的 adapter后台入口只引后台的 adapter其他依赖交给摇树优化去剔除。二是路由懒加载。共用页面用 React.lazy 或 Vue 的 defineAsyncComponent 加载保证前台用户不会下载后台专用的代码。后台管理的接口或者组件如果只是偶尔用到还可以用import()做按需注入减少后台工作台的首屏体积。三是公共依赖版本的统一。前后台如果用了两套不同的UI库那共用页面只能依赖最底层的基础组件或自己封装的跨端组件绝对不能直接依赖某一端的业务组件库。我一般会把shared/ui独立出来包含按钮、卡片、表单控件、表格这些基础元素这些组件在两个端都保持一致。5. 真实项目里踩过的坑与排查思路5.1 样式污染与主题风格冲突的教训共用页面最实际的一个问题就是样式。前后台往往用的是两套主题风格前台偏品牌调性后台偏效率工具。如果共用组件里直接写死了颜色、间距、字号那么到了后台就会显得非常违和。我最开始做共用页面时偷过懒直接把前台详情页的样式搬进去了后台同事看了直摇头。后来我改成用设计令牌Design Tokens的方式——组件里不写具体颜色值而是用--color-primary、--spacing-md这类变量每个端在自己的入口定义一个主题变量文件。共用组件在DOM结构上完全一样但视觉表现由宿主环境决定。另外还要注意CSS作用域。如果项目的样式没有做模块化共用页面的class名很容易被后台全局样式干扰。我建议共用页面的样式尽量用CSS Modules或CSS-in-JS方案作用域隔离至少也要给共用页面一个独立前缀比如.order-detail__。5.2 权限只做前端隐藏后端口子留着便是灾难这是所有做共用页面项目时最容易忽视的问题。前台用户看不到“改价”按钮不代表他不能自己构造一个请求去调后台接口。共用页面的代码是双端共享的前端代码水涨船高攻击面也跟着变大。我的经验是前端的能力描述只是为了体验真正的权限控制永远在后端。前端通过visibleFields、operations控制界面展示后端必须对每个接口做字段级、操作级的鉴权。比如“查询订单详情”这个接口后台能返回成本价但前台调用时后端必须主动过滤掉成本价字段而不是依赖前端不展示。这个道理其实每个后端都懂但到了真实项目里就容易疏漏。尤其是共用页面容易给人造成“同一个页面就是一个接口”的错觉。实际上前后台接口应当完全独立后台接口的鉴权要严格不能让前台请求因为某个URL长得一样就蒙混过关。5.3 状态不同步切换端导致数据串味的排查记录还有一个我印象特别深的bug。当时项目里支持同一个用户同时拥有买家端和后台客服端的账号而且两边是同一个前端应用刷新路由就切换。结果有客服反馈偶尔能看到别人的订单备注。排查了半天最后定位到问题出在全局状态管理上。共用页面组件没有自己生命周期内的局部状态而是把订单数据放到了全局store里。客服在后台看了一个订单然后切到买家端看自己的订单时由于共用页面组件被复用旧状态没有清掉页面闪现了一下上一个订单的内容如果接口又慢用户就误以为看到了别人的数据。后来我定了一条规矩所有共用页面的数据状态必须是页面组件自己的局部状态或明确按页面作用域隔离的状态。任何跨页面共享的数据都要在路由切换时显式重置。虽然多写几行代码但至少不会出现数据串味的低级错误。还有个相关的问题前台和后台如果不只是路由切换而是两个独立域名或独立站点那么共用的代码就必须通过 npm 包或 monorepo 方式共享不能靠运行时全局状态。这种方式下适配器模式的好处更明显——两端的入口完全独立各自打包代码共享只发生在构建期不会在运行时互相污染。5.4 常见问题速查表我把实践过程中遇到的高频问题整理成了表格方便排查问题现象常见原因解决思路样式错乱共用页面在后台显示为前台风格组件里写死了主题色/间距改用设计令牌按端注入主题变量按钮显示异常前台出现改价/退款按钮操作列表没按端过滤操作列表全部由上下文注入页面不写死按钮接口串数据前台接口返回了后台字段共用页面直接调用了后台接口前后台接口独立必须通过适配器转换权限被绕过普通用户能调后台接口后端缺少字段级/操作级鉴权后端单独对每个接口鉴权不信任前端隐藏状态残留切换路由后出现上一页数据共用页面状态放到了全局store状态收敛到页面局部路由切换时重置打包体积大前台首屏包含后台代码路由未懒加载/副作用导入使用lazy加载按入口分离依赖字段映射重复后端改字段名要改多个页面字段映射散落在各处通过适配器统一收口映射逻辑这是我做前后台共用页面一年多以来沉淀下来最值得分享的几条经验。架构上的东西可以看文档、看博客但这些坑是真的要自己踩过才会长记性。如果让我说一个最核心的心法那就是共用页面不是把两套代码揉成一套而是要做同一套业务视图不同环境的“适配宿主”。页面只是一个渲染器真正的差异由上下文和适配器承载。想明白这个前后台共用页面就不再是难题而是一件顺理成章的事。最后再分享一个小技巧如果你一开始不能说服团队做完整的适配器架构可以先小范围试验找一个信息密集型页面比如文章详情页用前端路由参数或用户身份做简单的能力分发把同一个组件同时挂到前台路由和后台路由上跑一个迭代看看维护成本到底是升是降。实践会给你比任何理论都直接的答案。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。