Monorepo 里的 shared、services 和 ui 到底怎么划分?

Monorepo 里的 shared、services 和 ui 到底怎么划分?

距离上一篇文章,已经快一个月了。

上一篇的最后,我原本预告要聊构建工具。但重新整理这条内容线时,我发现还有一个更基础的问题没有说透:

当我们把项目改造成 Monorepo,建好了 appspackages,代码到底应该怎么放?

很多 Monorepo 一开始都长得很漂亮:

text 复制代码
repo/
├── apps/
│   ├── admin/
│   ├── web/
│   └── h5/
└── packages/
    ├── shared/
    ├── services/
    └── ui/

目录一摆出来,架构感立刻有了。

但真正开始迁移代码时,问题就来了:

  • UserOrder 这些类型算不算 shared?
  • Axios 实例放 services,那业务接口是不是也全部放进去?
  • UserCard 被两个应用使用了,是否应该放进 ui?
  • Pinia store、权限判断、路由守卫放哪里?
  • 一个函数被复制了两次,是不是应该马上抽到 shared?
  • ui 能不能直接调 services?

如果这些问题没有答案,Monorepo 只是把原来一个项目里的 componentsutils 垃圾桶,升级成了仓库级的 sharedservicesui 三个更大的垃圾桶。

这篇文章不讲怎么配置 pnpm workspace,也不讲怎么发布 npm 包。

我想结合 LYStack 中真实存在的三个包,聊清楚一件事:

sharedservicesui 的边界,不应该由文件长什么样决定,而应该由它为什么变化、知道多少上下文,以及允许依赖谁决定。


一、把代码放进 packages,不等于完成了复用

先看一个很常见的 Monorepo。

项目里有三个应用:

text 复制代码
apps/
├── admin/   # 管理后台
├── web/     # PC 官网
└── h5/      # 移动端

为了避免复制粘贴,团队建立了三个共享包:

text 复制代码
packages/
├── shared/
├── services/
└── ui/

刚开始,每个包都很干净。

过了半年,shared 可能变成这样:

text 复制代码
shared/
├── utils/
│   ├── format-date.ts
│   ├── format-order-status.ts
│   ├── check-permission.ts
│   └── jump-login.ts
├── types/
│   ├── user.ts
│   ├── order.ts
│   ├── product.ts
│   └── table.ts
├── constants/
│   ├── storage.ts
│   ├── route.ts
│   └── order-status.ts
└── store/
    └── user.ts

services 可能变成这样:

text 复制代码
services/
├── request.ts
├── user.ts
├── order.ts
├── product.ts
├── permission.ts
└── upload.ts

ui 也越来越热闹:

text 复制代码
ui/
├── BaseButton.vue
├── UserCard.vue
├── OrderStatus.vue
├── PermissionButton.vue
├── ProductSelector.vue
└── AdminSearchForm.vue

这些文件确实被多个应用使用。

但它们放在 packages 里之后,新的问题开始出现:

  • ui 为了显示用户信息,开始依赖 services
  • services 为了处理 401,开始依赖 admin 的 router;
  • shared 里的权限函数需要读取 Pinia store;
  • OrderStatus 改一个业务状态,三个应用和 shared 类型一起变;
  • H5 不使用 Element Plus,却因为依赖 ui 被迫装上它;
  • web 想换一套登录方式,却发现 services 写死了 localStorage。

表面上,代码被复用了。

实际上,只是把耦合从应用内部搬到了共享包里。

以前改坏一个应用。

现在改坏整个仓库。

所以判断一个 Monorepo 是否真的设计得好,不能只看它有没有 packages,而要看:

共享包里放的是稳定能力,还是被多个应用共同依赖的业务泥团?


二、不要先问"它被几处使用",先问"它为什么变化"

很多人抽公共代码时,判断标准只有一个:

这个东西是不是出现了两次?

出现两次,就抽到 packages。

这个标准太弱了。

两个地方现在长得一样,不代表它们未来会因同一个原因变化。

比如 admin 和 web 都有一个用户卡片:

text 复制代码
admin 的 UserCard
├── 显示账号状态
├── 显示角色
├── 提供禁用操作
└── 跳转用户编辑页

web 的 UserCard
├── 显示头像
├── 显示昵称
├── 显示个人简介
└── 跳转个人主页

它们都叫 UserCard,甚至第一版可能长得一样。

但 admin 版本会因为后台管理需求变化,web 版本会因为用户展示需求变化。

变化原因不同,就不应该因为名字相同、结构相似而强行合并。

再看两个格式化函数:

ts 复制代码
export function formatDate(value: Date): string {
  return new Intl.DateTimeFormat('zh-CN').format(value);
}
ts 复制代码
export function formatOrderStatus(status: OrderStatus): string {
  return ORDER_STATUS_LABEL[status];
}

它们都叫 format,也都是纯函数。

但前者只依赖语言和日期规则,可以在任意业务里使用。

后者认识 OrderStatus,会随着订单业务规则一起变化。

所以前者可以进入通用 shared,后者更适合留在订单领域内部。

判断代码归属时,我更关心三个问题:

  1. 它知道什么? 是否认识具体业务、路由、状态管理、UI 框架或运行环境?
  2. 它为什么变化? 会因为通用能力、后端协议、视觉规范还是业务需求变化?
  3. 它依赖谁? 依赖箭头是否从上层应用指向下层能力,还是下层反过来认识了应用?

这三个问题,比"它被用了几次"更接近真正的边界。


三、shared:最低层的稳定能力,不是"大家都能往里放"

先说 shared

很多团队把 shared 理解为:

只要多个应用会用,就放 shared。

我更倾向于把它定义成:

不依赖具体业务和上层实现,可以被其他包安全依赖的最低层稳定能力。

在 LYStack 中,@repo/shared 当前包含四类内容:

text 复制代码
packages/shared/src/
├── env/
├── utils/
├── types/
└── constants/

它的 package.json 没有运行时 dependencies。

这不是为了追求"零依赖"这个数字,而是在表达一个边界:

shared 应该尽量处在依赖图的底部,别人可以依赖它,它不要反过来认识 services、ui 和具体 app。

1. 什么适合进入 shared

最典型的是不带业务语义的纯工具函数:

ts 复制代码
export function isDef<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

export function safeJsonParse<T>(raw: string, fallback: T): T {
  try {
    return JSON.parse(raw) as T;
  } catch {
    return fallback;
  }
}

这些函数:

  • 不认识用户、订单、商品;
  • 不依赖 Vue;
  • 不访问后端;
  • 不读取 store;
  • 不弹 UI 提示;
  • 输入和输出可以单独解释。

它们放进 shared,任何应用使用时都不需要连带理解一套业务上下文。

通用基础类型也适合:

ts 复制代码
export type Nullable<T> = T | null;

export type Maybe<T> = T | null | undefined;

export type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

还有那些确实需要跨应用统一,并且不绑定某个具体页面的基础常量。

2. shared 不等于只能放纯函数

LYStack 的 env 也在 shared 中。

环境变量读取显然不是纯函数,它需要感知运行环境。

为什么仍然放在 shared?

因为它承担的是一个底层平台边界:把 Vite、Rsbuild 和 Node 不同的环境变量机制,收口成应用可以稳定使用的接口。

ts 复制代码
import { getEnv } from '@repo/shared/env';

const apiBase = getEnv('PUBLIC_API_BASE_URL');

应用只认识 getEnv,不需要到处出现:

ts 复制代码
import.meta.env.VITE_API_BASE_URL;
process.env.PUBLIC_API_BASE_URL;

所以 shared 的核心不是"绝对无副作用",而是:

它提供的是低层、中立、稳定的能力,并且不依赖上层业务。

3. 业务类型不要因为"多个应用都用"就塞进 shared

类型文件是 shared 最容易膨胀的地方。

很多项目最后会出现:

text 复制代码
shared/types/
├── user.ts
├── order.ts
├── product.ts
├── invoice.ts
├── permission.ts
└── everything.ts

问题不是业务类型不能跨应用共享。

问题是把所有业务契约都塞进一个叫 shared 的通用包,会让这个包同时因为用户、订单、商品、权限等完全不同的业务原因变化。

更合理的判断是:

  • 只被一个应用使用的类型,留在应用内部;
  • 多个应用共享同一套后端契约,可以建立 contracts
  • 某个业务领域跨多个应用复用,可以建立 domain-orderdomain-user
  • 真正不带业务语义的基础类型,才进入通用 shared。

例如:

text 复制代码
packages/
├── shared/          # Nullable、DeepReadonly、通用工具
├── contracts/       # 前后端共享 DTO、API 契约
├── domain-order/    # 订单规则、订单类型、订单转换
├── services/
└── ui/

Monorepo 的目标不是永远只有三个包。

当业务规模增长时,宁可增加一个边界清晰的领域包,也不要把所有东西都倒进 shared。

4. 这几类东西通常不该进入 shared

ts 复制代码
// 认识具体业务状态
export function canCancelOrder(order: Order): boolean {}

// 认识具体路由
export function jumpToLogin(router: Router): void {}

// 认识具体 UI 框架
export function showError(message: string): void {
  ElMessage.error(message);
}

// 认识具体 store
export function hasPermission(code: string): boolean {
  return useUserStore().permissions.includes(code);
}

它们有的属于业务领域,有的属于应用装配,有的属于 services 和 UI 之间的协作。

把它们放进 shared,只是因为不知道该放哪里。


四、services:负责外部系统边界,不负责整个业务世界

services 也很容易被误解。

有的人看到 services,就把所有接口文件都搬进去:

text 复制代码
packages/services/
├── user-api.ts
├── order-api.ts
├── product-api.ts
├── report-api.ts
└── permission-api.ts

最后每个应用无论需要什么,都依赖整个 services。

我对 services 的定义是:

负责与后端、文件服务、第三方平台等外部系统通信,并把不稳定的协议细节限制在明确边界中。

它关注的是:

  • 请求怎么发;
  • 实例怎么隔离;
  • token 怎么注入;
  • 业务码怎么解析;
  • 网络错误怎么转换;
  • 不同后端协议怎么适配。

它不应该负责:

  • 当前页面跳到哪里;
  • 使用哪个 UI 组件提示错误;
  • 用户点击按钮后的完整业务流程;
  • 某个页面如何组合多个接口;
  • 某个业务状态应该显示什么颜色。

1. services 的核心是 I/O 和协议边界

LYStack 中的 AxiosFactory 负责创建独立请求实例:

ts 复制代码
const request = new AxiosFactory({
  timeout: 30 * 1000,
  interceptorHooks: apiInterceptor,
});

不同服务可以拥有不同的:

  • baseURL
  • 超时时间;
  • token 规则;
  • 业务成功码;
  • 401 策略;
  • 错误响应结构。

这属于 services,因为它会随着外部服务协议变化。

而 token 到底存在哪里,不属于 services。

它可能来自:

  • localStorage;
  • Cookie;
  • Electron Bridge;
  • SSO 宿主;
  • 内存状态。

所以 LYStack 让应用在 bootstrap 阶段注入:

ts 复制代码
configureServiceAuth({
  getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
  onAuthenticationFailure: () => {
    router.push('/login');
  },
});

services 只声明"我需要获取 token"和"认证失败时需要通知上层"。

应用决定具体怎么做。

同样,services 可以产生一条错误消息,但不应该直接依赖 Element Plus:

ts 复制代码
setErrorMessenger((message) => {
  ElMessage.error(message);
});

这样 admin 可以使用 ElMessage,web 可以换成自己的 Toast,Electron 甚至可以调用系统通知。

2. services 可以依赖 shared,但不能反过来

LYStack 当前的依赖关系很直接:

text 复制代码
@repo/services
├── @repo/shared
└── axios

services 可以使用 shared 提供的:

  • 环境读取;
  • 通用类型;
  • 基础工具;
  • 跨应用常量。

但 shared 不应该为了获取数据反过来依赖 services。

一旦出现:

text 复制代码
shared → services → shared

说明两层职责已经开始纠缠。

3. 不是所有业务接口都必须进入 packages/services

这一点非常重要。

请求内核可以跨应用共享,不代表所有业务 API 都应该跨应用共享。

假设后台有一个"禁用管理员"的接口:

ts 复制代码
export const disableAdmin = (id: number) => {
  return serviceCms.put(`/admins/${id}/disable`);
};

如果只有 admin 使用,它完全可以放在:

text 复制代码
apps/admin/src/api/modules/admin/
├── api.ts
└── interface.ts

它依赖共享的请求能力,但仍然属于 admin 的业务边界。

text 复制代码
admin 业务 API
      │
      ▼
@repo/services 请求能力
      │
      ▼
@repo/shared 基础能力

只有当多个应用确实共享同一个服务契约,并且变化原因一致时,才考虑把具体 client 下沉到 packages。

所以 services 可以分成两层理解:

text 复制代码
packages/services
└── 跨应用复用的请求内核、协议适配、通用 client

apps/*/src/api 或 apps/*/src/services
└── 当前应用的业务接口与业务编排

不要因为文件里出现了 Axios,就自动把它归入顶层 services。


五、ui:提供视觉契约,不承载业务流程

再看 ui

ui 的判断标准也不应该只是:

这个组件是否被两个应用使用?

更准确的定义是:

提供跨应用稳定复用的视觉组件、交互契约和设计令牌,但不认识具体业务流程。

LYStack 的 @repo/ui 当前很克制:

text 复制代码
packages/ui/src/
├── components/
│   └── demo-card/
└── styles/
    ├── tokens.css
    ├── theme.css
    └── base.css

它只把 Vue 声明为 peer dependency,没有依赖 services,也没有依赖某个应用。

1. 一个通用组件应该通过 Props 和 Emits 描述能力

例如一个通用卡片可以接受:

ts 复制代码
interface Props {
  title?: string;
  description?: string;
  tag?: string;
  shadow?: boolean;
  hoverable?: boolean;
}

它负责:

  • 布局;
  • 样式;
  • 插槽;
  • 基础交互;
  • 可访问性。

它不应该知道:

  • 当前用户是谁;
  • 数据从哪个接口加载;
  • 点击后跳到哪条业务路由;
  • 是否具有某个后台权限;
  • 订单状态如何流转。

一个容易失控的组件通常长这样:

vue 复制代码
<script setup lang="ts">
import { useUserStore } from '@/stores/user';
import { getOrderDetail } from '@repo/services';
import router from '@/router';

const props = defineProps<{ orderId: string }>();
const userStore = useUserStore();

const order = await getOrderDetail(props.orderId);

function handleEdit(): void {
  if (userStore.hasPermission('order:update')) {
    router.push(`/orders/${props.orderId}/edit`);
  }
}
</script>

它表面上是组件,实际上同时承担了:

  • 数据获取;
  • 权限判断;
  • 路由跳转;
  • 业务状态;
  • UI 展示。

这种组件进入 packages/ui 后,ui 就会被某个应用的业务模型绑死。

更合理的是让业务应用负责数据和行为:

vue 复制代码
<OrderCard
  :order="order"
  :editable="canEdit"
  @edit="handleEdit"
/>

但还要继续问一句:OrderCard 本身是否真的属于通用 ui?

它认识订单,很可能应该留在订单业务模块中。

ui 可以提供更底层的:

vue 复制代码
<BaseCard>
  <OrderSummary :order="order" />
</BaseCard>

BaseCard 属于通用 ui。

OrderSummary 属于业务组件。

2. 设计令牌可以共享,品牌页面不一定能共享

除了组件,ui 还适合维护跨应用一致的设计基础:

  • 颜色语义;
  • 字号;
  • 间距;
  • 圆角;
  • 阴影;
  • 主题切换规则。

LYStack 使用两层 CSS 变量:

text 复制代码
Palette 原始色阶
        ↓
Semantic 语义令牌
        ↓
组件引用语义变量

组件只认识:

css 复制代码
.demo-card {
  color: var(--color-text-base);
  background: var(--bg-color-panel);
  border: 1px solid var(--border-color);
  border-radius: var(--radius-md);
}

它不需要知道当前是亮色、暗色,也不需要写具体品牌色。

但一个完整首页 Hero、营销活动 Banner、后台订单详情页,即使多个应用看起来相似,也不应该轻易进入通用 ui。

它们更可能随着具体产品、运营活动或业务流程变化。

3. ui 最需要避免的依赖

通用 ui 包通常不应该直接依赖:

text 复制代码
router
Pinia 业务 store
services
业务 DTO
具体应用的 alias
具体页面权限

否则这个 ui 包就不能真正被其他应用独立使用。

如果一个组件离开 admin 就无法解释自己,它很可能不是通用 ui,而是 admin 的业务组件。


六、业务代码留在 apps,不代表架构失败

Monorepo 建好后,团队很容易产生一种冲动:

packages 越多、共享率越高,架构就越好。

这不一定成立。

apps 不只是几个空壳入口。

它本来就应该拥有具体业务:

text 复制代码
apps/admin/src/
├── api/
├── components/
│   ├── business/
│   └── order/
├── hooks/
├── router/
├── stores/
├── views/
└── bootstrap/

下面这些内容留在应用内部非常正常:

  • 页面私有组件;
  • 当前应用的路由;
  • 当前应用的 Pinia store;
  • 业务接口;
  • 权限编排;
  • 页面流程;
  • 只在当前应用成立的业务类型;
  • 对共享能力的具体装配。

应用层的重要职责之一,就是把下层能力组合成当前产品:

text 复制代码
shared 提供基础能力
services 提供外部通信能力
ui 提供视觉能力
app 把它们组装成业务

如果把所有业务都抽走,app 只剩一个 main.ts,不一定说明抽象成功,也可能说明业务被藏进了几个名字过于通用的共享包。

业务留在离业务最近的地方,通常更容易理解和修改。


七、真正的边界,写在依赖箭头里

文件放在哪个目录,只是表面。

真正决定边界的是依赖关系。

LYStack 当前与这篇文章相关的核心依赖,可以简化成:

text 复制代码
apps/*
  ├──> @repo/shared
  ├──> @repo/services ──> @repo/shared
  └──> @repo/ui

它表达了几个约束:

  1. 应用可以组合所有底层包。
  2. services 可以使用 shared 的基础能力。
  3. shared 不认识 services、ui 和 apps。
  4. ui 不认识 services、router、store 和具体 app。
  5. services 不认识 ui,也不决定页面行为。

错误的依赖关系通常长这样:

text 复制代码
@repo/ui
  └──> @repo/services
          └──> apps/admin/router
                  └──> @repo/ui

目录看起来分层了,依赖却绕成了一个环。

这时候任何一个包都无法独立理解、测试和替换。

package.json 也是架构文件

很多人只把 package.json 当作依赖清单。

在 Monorepo 中,它同时描述了包的边界。

例如 LYStack 的 services:

json 复制代码
{
  "name": "@repo/services",
  "dependencies": {
    "@repo/shared": "workspace:*",
    "axios": "catalog:"
  }
}

ui 则只声明 Vue:

json 复制代码
{
  "name": "@repo/ui",
  "peerDependencies": {
    "vue": "catalog:"
  }
}

shared 没有运行时依赖。

只看这三份依赖声明,就能大致判断仓库的依赖方向是否健康。

如果某天 @repo/shared 开始依赖 Vue、Axios、Element Plus、router 和某个业务包,应该立即停下来问:

shared 真的还在依赖图底部吗?


八、包的出口,也是在声明边界

除了 package 之间的依赖,包内部也需要区分公开能力和实现细节。

LYStack 为 shared 提供了明确子路径:

json 复制代码
{
  "exports": {
    ".": "./src/index.ts",
    "./env": "./src/env/index.ts",
    "./utils": "./src/utils/index.ts",
    "./types": "./src/types/index.ts",
    "./constants": "./src/constants/index.ts"
  }
}

使用方可以明确表达自己依赖哪一类能力:

ts 复制代码
import { getEnv } from '@repo/shared/env';
import { safeJsonParse } from '@repo/shared/utils';
import type { Nullable } from '@repo/shared/types';

services 也区分:

text 复制代码
@repo/services/core
@repo/services/types
@repo/services/api

这比跨包读取内部文件更安全:

ts 复制代码
// 不推荐:依赖包内部实现路径
import { AxiosFactory } from '../../packages/services/src/core/axios-factory';

一旦调用方可以任意进入包内部,目录虽然分开了,封装实际上并不存在。

exports 和 barrel 文件的意义,不只是少写几段路径。

它们是在声明:

哪些能力是这个包承诺长期提供的,哪些只是内部实现。

当然,也不要为了方便把所有内部文件全部 export 出去。

公开出口越大,未来重构时需要承担的兼容成本越高。


九、几个最容易放错的位置

下面这些例子,在实际项目里最容易争议。

代码 更合理的位置 原因
safeJsonParse shared/utils 无业务语义的基础纯函数
Nullable<T> shared/types 通用基础类型
formatOrderStatus 订单领域或应用内部 随订单规则变化
OrderDto contracts 或订单领域 是业务契约,不是通用基础类型
AxiosFactory services/core 跨应用请求内核
admin 的 disableUser apps/admin/src/api 只服务后台业务
第三方支付 client services 或独立 package 负责外部协议边界
BaseButton ui 通用视觉组件
OrderStatusBadge 订单领域组件 认识订单状态和业务颜色
AdminSearchForm admin 内部 绑定后台筛选场景
CSS 设计令牌 ui/styles 跨应用视觉基础
router 应用内部 每个应用路由不同
Pinia 业务 store 应用或领域包 绑定具体业务状态
bootstrap 应用内部 负责注入和组合具体实现

这个表不是绝对规则。

例如 OrderStatusBadge 如果确实被多个订单相关应用使用,可以进入 domain-order/ui

关键不是死守目录名,而是让包名和依赖关系真实表达它的业务边界。


十、一个实用的放置判断流程

以后再遇到一个不知道放哪里的文件,可以按下面的顺序判断。

flowchart TD A[这段代码属于具体页面或业务吗] -->|是| B[先放在对应 app 或业务领域] A -->|否| C[它主要负责外部通信吗] C -->|是| D[放入 services 或独立 client 包] C -->|否| E[它主要负责视觉和交互吗] E -->|是| F[确认不依赖业务后放入 ui] E -->|否| G[它是否是稳定的基础能力] G -->|是| H[放入 shared] G -->|否| I[暂时留在离使用方最近的位置]

其中最重要的是第一步:

它是否属于具体业务?

很多错误抽象,都是因为跳过了这一步,直接从"被重复使用"跳到了"放 shared"。

还可以再问六个问题:

  1. 离开当前业务,它还能独立解释吗?
  2. 它的输入输出是否包含具体业务概念?
  3. 它是否直接访问网络、存储或第三方平台?
  4. 它是否渲染界面,或者定义视觉规范?
  5. 它是否依赖 router、store、UI 框架或具体应用 alias?
  6. 具体实现变化时,哪些包需要跟着修改?

如果一段所谓 shared 代码需要先了解当前用户、当前路由、当前产品和当前 UI 框架才能使用,它就不是真的 shared。


十一、不要从"大搬家"开始改 Monorepo

面对一个已有项目,不建议一次性把所有重复代码搬进 packages。

更安全的顺序是从依赖图的叶子开始。

第一步:先标出代码的使用范围

给现有代码分四个范围:

text 复制代码
页面私有
→ 单应用共享
→ 同一业务领域跨应用共享
→ 全仓库通用

范围越大,进入通用包的理由才越充分。

第二步:先移动最稳定的 shared

优先处理:

  • 纯工具函数;
  • 基础类型;
  • 明确跨应用的常量;
  • 环境等平台能力的统一入口。

这些通常依赖最少,迁移风险最低。

第三步:再抽请求内核,不急着搬所有业务 API

先统一:

  • AxiosFactory;
  • 请求扩展类型;
  • 拦截器接口;
  • 认证能力注入;
  • 错误消息出口。

业务接口可以继续留在各 app,等真正出现稳定共性后再下沉。

第四步:最后处理 ui

组件比工具函数更容易出现"看起来一样,实际业务不同"。

先抽:

  • 设计令牌;
  • Button、Card、Dialog 等基础视觉组件;
  • 无业务语义的布局和交互原语。

业务组件宁可先重复,也不要过早合并成一个塞满 props 的超级组件。

第五步:用依赖约束保护边界

目录约定如果只写在文档里,很容易失效。

至少可以通过这些方式约束:

  • package.json 只声明允许的依赖;
  • exports 限制公开入口;
  • ESLint 禁止跨层导入;
  • TypeScript project references 或独立 typecheck;
  • CI 对每个 package 执行检查;
  • 代码评审时检查依赖方向。

Monorepo 的边界不是建完目录就永久存在。

它需要被工具和团队习惯持续保护。


十二、不是所有项目都需要拆成 shared、services 和 ui

最后还要说一个容易被忽略的前提。

不是用了 Monorepo,就必须拥有这三个包。

如果项目只有:

  • 一个应用;
  • 一套后端;
  • 少量公共组件;
  • 很短的生命周期;
  • 没有跨应用复用需求;

那么在应用内部维护:

text 复制代码
src/
├── services/
├── components/
├── types/
└── utils/

完全可以。

拆包本身有成本:

  • 需要维护 package 依赖;
  • 需要设计公开出口;
  • 需要独立 typecheck;
  • 需要处理构建和样式消费;
  • 需要团队理解边界。

只有当多个应用真的需要共享稳定能力时,packages 才开始产生价值。

不要为了拥有 Monorepo 的目录结构,提前制造并不存在的复用需求。

抽象应该跟着真实变化出现,而不是跟着目录模板出现。


十三、LYStack 当前是怎么做的

回到 LYStack。

它目前把三个包控制在比较明确的范围内。

@repo/shared

text 复制代码
env
utils
types
constants

负责构建工具无关的环境读取、通用工具、基础类型和跨应用常量。

它处在依赖图底部,不依赖 Vue、Axios 和具体应用。

@repo/services

text 复制代码
core
types
api

负责 Axios 实例工厂、拦截器扩展、认证能力注入、错误消息出口和通用 API 示例。

它可以依赖 shared,但不依赖 router、Pinia 和 UI 框架。

@repo/ui

text 复制代码
components
styles

负责通用 Vue 组件和设计令牌。

它不请求接口,不读取业务 store,也不决定业务跳转。

apps

应用负责:

  • 导入这些能力;
  • 注入 token 与错误展示方式;
  • 组织页面和业务流程;
  • 决定路由、状态管理和具体 UI 框架;
  • 承担无法通用化的业务代码。

这套划分不一定适合所有项目,也不是永远不变。

当 LYStack 以后出现真正跨应用的业务契约或领域能力时,可能需要增加新的 package,而不是继续扩大 shared。

边界不是为了把目录固定死。

它是为了让每一次新增代码时,我们知道自己正在增加哪一种复杂度。


最后

Monorepo 最容易制造的一种错觉,是:

代码只要被搬进 packages,就已经实现了架构复用。

但真正的复用,不是多个应用 import 了同一个文件。

真正的复用意味着:

  • 这个能力有清楚的职责;
  • 它的变化原因相对稳定;
  • 它不需要知道调用方的具体业务;
  • 它的依赖方向不会反过来绑住应用;
  • 它可以通过明确出口独立使用;
  • 不适合共享的代码被允许留在业务附近。

所以,当你下一次准备把一个文件放进 sharedservicesui 时,不妨先别问:

它是不是被多个地方用了?

先问:

它为什么会变化?它知道了什么?它允许依赖谁?

如果这三个问题能回答清楚,文件放在哪里通常不会太难。

如果回答不清楚,最安全的做法往往不是立刻抽象,而是让它暂时留在离业务最近的位置。

LYStack 项目地址:

text 复制代码
https://github.com/liangy0323/LYStack

源码对应位置:

text 复制代码
packages/shared
packages/services
packages/ui

下一篇

下一篇继续聊:

前端项目为什么需要一个配置单一真相源?

端口写在 package.json,应用名写在构建配置,页面入口写在目录,环境信息写在多个 .env,依赖版本又散落在每个子包里。

这些配置单独看都没有问题。

但当同一个事实出现多个版本时,项目就开始依赖人肉同步。

下一篇我会结合 LYStack 的 app.config.tspage.config.ts 和 pnpm catalog,继续拆解:

  • 什么叫配置的单一真相源;
  • 哪些配置应该统一,哪些不应该;
  • 为什么自动扫描目录不一定比显式登记更可靠;
  • 如何避免为了统一配置,造出一个谁都不敢改的超级配置中心。
相关推荐
PedroQue991 小时前
uni-router v2.1.0 升级:导航守卫全面支持返回值模式
前端·uni-app
猫七先生1 小时前
从零到一:个人博客自动化部署踩坑全记录
前端
淼澄研学1 小时前
Python调用通义千问API实现长尾搜题内容自动化生成实战
前端·react.js·架构
绿岛之北1 小时前
Electron 安全入门:为什么一个 XSS 可能变成 RCE?
前端·electron
舒灿1 小时前
DeepSeek Harness——Agent自我进化的实现途径?
前端·ai编程·deepseek
cindershade2 小时前
React Server Components 在真实项目中的边界:哪些组件该放在服务端
前端
OpenTiny社区2 小时前
GenUI SDK v1.3.0 开发者深度解读:当生成式 UI 开始"长出"工程化骨架
前端·ai编程
前端粉刷匠2 小时前
2025 年是 Agent 的,2026 年是 Harness 的——AI 编程 Harness 架构深度解析
前端·人工智能
张元清2 小时前
React useSessionStorage Hook:刷新不丢、只属于当前标签页的状态 (2026)
前端·javascript·react.js