Monorepo 里的 shared、services 和 ui 到底怎么划分?
距离上一篇文章,已经快一个月了。
上一篇的最后,我原本预告要聊构建工具。但重新整理这条内容线时,我发现还有一个更基础的问题没有说透:
当我们把项目改造成 Monorepo,建好了
apps和packages,代码到底应该怎么放?
很多 Monorepo 一开始都长得很漂亮:
text
repo/
├── apps/
│ ├── admin/
│ ├── web/
│ └── h5/
└── packages/
├── shared/
├── services/
└── ui/
目录一摆出来,架构感立刻有了。
但真正开始迁移代码时,问题就来了:
User、Order这些类型算不算 shared?- Axios 实例放 services,那业务接口是不是也全部放进去?
UserCard被两个应用使用了,是否应该放进 ui?- Pinia store、权限判断、路由守卫放哪里?
- 一个函数被复制了两次,是不是应该马上抽到 shared?
- ui 能不能直接调 services?
如果这些问题没有答案,Monorepo 只是把原来一个项目里的 components 和 utils 垃圾桶,升级成了仓库级的 shared、services 和 ui 三个更大的垃圾桶。
这篇文章不讲怎么配置 pnpm workspace,也不讲怎么发布 npm 包。
我想结合 LYStack 中真实存在的三个包,聊清楚一件事:
shared、services和ui的边界,不应该由文件长什么样决定,而应该由它为什么变化、知道多少上下文,以及允许依赖谁决定。
一、把代码放进 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,后者更适合留在订单领域内部。
判断代码归属时,我更关心三个问题:
- 它知道什么? 是否认识具体业务、路由、状态管理、UI 框架或运行环境?
- 它为什么变化? 会因为通用能力、后端协议、视觉规范还是业务需求变化?
- 它依赖谁? 依赖箭头是否从上层应用指向下层能力,还是下层反过来认识了应用?
这三个问题,比"它被用了几次"更接近真正的边界。
三、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-order、domain-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
它表达了几个约束:
- 应用可以组合所有底层包。
- services 可以使用 shared 的基础能力。
- shared 不认识 services、ui 和 apps。
- ui 不认识 services、router、store 和具体 app。
- 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。
关键不是死守目录名,而是让包名和依赖关系真实表达它的业务边界。
十、一个实用的放置判断流程
以后再遇到一个不知道放哪里的文件,可以按下面的顺序判断。
其中最重要的是第一步:
它是否属于具体业务?
很多错误抽象,都是因为跳过了这一步,直接从"被重复使用"跳到了"放 shared"。
还可以再问六个问题:
- 离开当前业务,它还能独立解释吗?
- 它的输入输出是否包含具体业务概念?
- 它是否直接访问网络、存储或第三方平台?
- 它是否渲染界面,或者定义视觉规范?
- 它是否依赖 router、store、UI 框架或具体应用 alias?
- 具体实现变化时,哪些包需要跟着修改?
如果一段所谓 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 了同一个文件。
真正的复用意味着:
- 这个能力有清楚的职责;
- 它的变化原因相对稳定;
- 它不需要知道调用方的具体业务;
- 它的依赖方向不会反过来绑住应用;
- 它可以通过明确出口独立使用;
- 不适合共享的代码被允许留在业务附近。
所以,当你下一次准备把一个文件放进 shared、services 或 ui 时,不妨先别问:
它是不是被多个地方用了?
先问:
它为什么会变化?它知道了什么?它允许依赖谁?
如果这三个问题能回答清楚,文件放在哪里通常不会太难。
如果回答不清楚,最安全的做法往往不是立刻抽象,而是让它暂时留在离业务最近的位置。
LYStack 项目地址:
text
https://github.com/liangy0323/LYStack
源码对应位置:
text
packages/shared
packages/services
packages/ui
下一篇
下一篇继续聊:
前端项目为什么需要一个配置单一真相源?
端口写在 package.json,应用名写在构建配置,页面入口写在目录,环境信息写在多个 .env,依赖版本又散落在每个子包里。
这些配置单独看都没有问题。
但当同一个事实出现多个版本时,项目就开始依赖人肉同步。
下一篇我会结合 LYStack 的 app.config.ts、page.config.ts 和 pnpm catalog,继续拆解:
- 什么叫配置的单一真相源;
- 哪些配置应该统一,哪些不应该;
- 为什么自动扫描目录不一定比显式登记更可靠;
- 如何避免为了统一配置,造出一个谁都不敢改的超级配置中心。