ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前后台共用页面的工程化实践:架构、权限与适配器设计

前后台共用页面的工程化实践:架构、权限与适配器设计

前后台共用页面这个命题,我刚入行那会儿觉得是个伪需求——后台就老老实实做管理,前台就花里胡哨做展示,俩界面风格差着十万八千里,共用不是找不自在吗?直到后来真遇到业务场景才明白,当你的后台管理的不再是“数据”而是“业务”本身时,前后台有不少页面本质上是同一份内容的两副面孔。比如商品页、订单详情页、文章详情页,甚至一套复杂的报名表单,前台用户看到的是“信息+操作”,后台审核员看到的也是“信息+操作”,区别只在谁能看全字段、谁能点某个按钮、谁能改某个数值。

这篇文章就围绕“前后台共用的页面要如何开发、如何共享”这件事,把我实践过的方案、踩过的坑、沉淀下来的设计套路一次讲清楚。适合正在做中后台系统、或者维护一个同时面向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): Promise<OrderDetail> // 执行某项操作 executeOperation(op: string, payload: unknown): Promise<void> }

页面组件只依赖这几个方法和属性,不关心实现它的是前台接口还是后台接口:

const orderPageContext = useOrderPageContext() useEffect(() => { orderPageContext.fetchOrderDetail(orderId).then(setOrder) }, [orderId]) // 渲染操作按钮时,根据 operations 描述渲染 {orderPageContext.operations.map(op => ( <ActionButton key={op.code} if="admin" 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 field="costPrice" mode="visible"> <AmountItem label="成本价" value={costPrice} /> </FieldGuard> <FieldGuard field="paidAmount" mode="editable"> <input value={paidAmount} onChange={updatePaidAmount} /> </FieldGuard>

FieldGuard内部只需要做一件事:从当前上下文里取出字段能力配置,看看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): Promise<NormalizedOrder> { 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): Promise<NormalizedOrder> { 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) => Promise<NormalizedOrder> executeOperation: (code: string, order: NormalizedOrder, payload?: unknown) => Promise<void> } export const OrderPageContext = createContext<OrderPageContextValue | 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: Record<string, 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] = useState<NormalizedOrder | 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 className="order-detail-page"> <OrderStatusHeader status={order.status} /> <OrderGoodsTable goodsList={order.goodsList} /> <div className="amount-panel"> {/* 渲染字段时,统一走 FieldGuard */} {Object.keys(fieldRenderers).map((fieldKey) => { if (!ctx.visibleFields.includes(fieldKey)) return null return ( <FieldGuard key={fieldKey} field={fieldKey} mode="visible"> {fieldRenderers[fieldKey](order)} </FieldGuard> ) })} </div> <OrderAddress address={order.address} /> <div className="action-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加载,按入口分离依赖
字段映射重复后端改字段名要改多个页面字段映射散落在各处通过适配器统一收口映射逻辑

这是我做前后台共用页面一年多以来沉淀下来最值得分享的几条经验。架构上的东西可以看文档、看博客,但这些坑是真的要自己踩过才会长记性。

如果让我说一个最核心的心法,那就是:共用页面不是把两套代码揉成一套,而是要做同一套业务视图不同环境的“适配宿主”。页面只是一个渲染器,真正的差异由上下文和适配器承载。想明白这个,前后台共用页面就不再是难题,而是一件顺理成章的事。

最后再分享一个小技巧:如果你一开始不能说服团队做完整的适配器架构,可以先小范围试验,找一个信息密集型页面,比如文章详情页,用前端路由参数或用户身份做简单的能力分发,把同一个组件同时挂到前台路由和后台路由上,跑一个迭代看看维护成本到底是升是降。实践会给你比任何理论都直接的答案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进