架构设计
适配器模式
让核心包跨 Web / RN / 小程序的关键设计:抽象接口 + 端实现
问题
仓库的核心能力(请求、日志、存储)希望:
- 写一份代码,跨 Web / RN / 小程序运行;
- 各端的平台 API(
fetchvsXMLHttpRequestvswx.request、localStoragevsAsyncStoragevswx.setStorage)不污染核心; - 类型安全。
直接 if (Platform.OS === 'web') 会让核心包:
- 绑定到平台 SDK(依赖膨胀);
- 难以 tree-shake;
- 单元测试需要模拟平台环境;
- 新增平台时核心要改。
解决:适配器模式
+--------------------+ +-----------------------+
| Core (业务核心) | ─uses→ | Adapter Interface |
| 无平台 API | | (TS interface / 抽象)|
+--------------------+ +-----------------------+
↑
┌──────────────────┼──────────────────┐
▼ ▼ ▼
+--------------+ +--------------+ +--------------+
| Web Adapter | | RN Adapter | | MiniApp Ad. |
| localStorage | | AsyncStorage | | wx.setStorage|
+--------------+ +--------------+ +--------------+- Core 只面向
interface; - 端项目在启动时注入具体实现;
- 核心不再认识具体平台,平台不再侵入核心。
仓库内的真实例子
1. @skyroc/service 的 RequestAdapter
@skyroc/service 提供请求 + 错误处理 + token 刷新流水线,但自己不发请求:
// @skyroc/service 内部
export interface RequestAdapter {
request<T>(config: RequestConfig): Promise<T>;
// ... cancel, etc.
}Web 端注入 @skyroc/axios 实现:
import { createAppRequest } from '@skyroc/service';
import { createAxiosAdapter } from '@skyroc/axios/service'; // 仅 Web 用
const request = createAppRequest({
adapter: createAxiosAdapter({ baseURL, timeout: 10000 }),
// ...
});RN 端可以注入自己的 fetch / okhttp 适配器,service 流水线完全复用。
详见 @skyroc/service。
2. @skyroc/logger 的 StorageAdapter
LogLayer 透出本地日志能力,但本地存储跨端不同:
// @skyroc/logger 内部
export interface StorageAdapter {
set(key: string, value: string): Promise<void>;
get(key: string): Promise<string | null>;
remove(key: string): Promise<void>;
clear(): Promise<void>;
}包内提供两套实现:
| 子入口 | 实现 | 平台 |
|---|---|---|
@skyroc/logger/web | WebStorageAdapter(IndexedDB / localStorage) | Web |
@skyroc/logger/native | RNStorageAdapter(AsyncStorage) | RN |
业务方按需 import 对应实现并注入。详见 @skyroc/logger。
3. @skyroc/utils/storage 的 IStorage
export interface IStorage {
getItem<T>(key: string): T | null;
setItem<T>(key: string, value: T, expireSeconds?: number): void;
removeItem(key: string): void;
clear(): void;
}Web 端实现:基于 localStorage / sessionStorage 的包装,附带过期时间、JSON 序列化。
RN 端实现:基于 AsyncStorage,相同接口语义。
@skyroc/core-state 的 atomWithStorage 接受 IStorage,因此跨平台 Jotai 状态持久化只需注入正确的实现。
适配器在仓库的标准结构
@core/<pkg>/
├── src/
│ ├── adapter.ts # 抽象接口(核心定义)
│ ├── core.ts # 核心流水线(消费 adapter)
│ ├── web/ # 平台子入口
│ │ └── index.ts # 暴露 createWebAdapter
│ ├── native/
│ │ └── index.ts
│ └── index.ts # 主入口(仅 adapter + core,不带任何平台实现)
└── package.json
"exports": {
".": { "default": "./dist/index.mjs" },
"./web": { "default": "./dist/web.mjs" },
"./native": { "default": "./dist/native.mjs" }
}关键约定:主入口绝不能 import 任何平台 API。这是 RN 项目能正常打包的前提(任何 import 走到 window.localStorage 都会爆炸)。
何时该引入适配器
| 场景 | 是否引入 |
|---|---|
| 模块只在 Web 用 | ❌(直接放 web/) |
| 模块只在 RN 用 | ❌(直接放 native/) |
| 同一业务逻辑,平台 API 不同 | ✅ 必须 |
| 同一业务逻辑,平台 API 相同 | ❌(放 shared/ / @core/) |
| 跨平台但实现完全相同 | ❌(不需要 adapter,写一次即可) |
注入时机
在应用启动序列中,端项目把适配器注入到核心包:
// apps/admin/src/setup/service.ts(示意)
import { createAppRequest } from '@skyroc/service';
import { createAxiosAdapter } from '@skyroc/axios/service';
export const request = createAppRequest({
adapter: createAxiosAdapter({ baseURL: import.meta.env.VITE_BASE_URL })
});// apps/admin/src/setup/logger.ts(示意)
import { createLogger } from '@skyroc/logger';
import { createWebStorageAdapter } from '@skyroc/logger/web';
export const logger = createLogger({
storage: createWebStorageAdapter({ name: 'admin-logs' })
});各端的 setup 通常集中在 src/setup/* 或 src/bootstrap.tsx。
收益对比
| 维度 | 直接耦合平台 API | 适配器模式 |
|---|---|---|
| 跨端复用 | 需要分支判断或维护多份 | 一份核心,多套适配器 |
| 测试 | 必须模拟平台环境 | 注入内存版 adapter 即可 |
| 维护 | 平台 API 变化影响核心 | 仅改对应适配器 |
| 扩展 | 新增平台要改核心 | 新增平台仅写适配器 |
与 包分层、平台优先 的关系
- 包分层定义:抽象 / 实现 / 装配 的纵向层;
- 平台优先定义:横向上每个平台一个目录;
- 适配器模式连接两者:核心包定义抽象(在纵向中靠上层),各平台目录提供实现(在横向中分散)。
如果一个包想跨平台,它的「平台 API」必须用适配器隔离——这是仓库最重要的硬约束之一。