1. 项目概述从“技能”到“可复用的开发资产”在移动应用开发领域尤其是使用 React Native 这类跨平台框架时我们常常会陷入一个循环每个新项目启动都要重新搭建一套基础架构。从导航配置、状态管理、网络请求封装到 UI 组件库的选型和集成再到诸如相机、推送、地图等原生模块的接入这些工作占据了项目初期大量的时间。更令人头疼的是这些“轮子”在不同项目间往往存在细微差异导致团队知识难以沉淀代码复用率低下。这就是expo/skills这个项目标题背后所指向的核心痛点。它不是一个具体的、已发布的 NPM 包而是一个极具启发性的概念或实践模式。我们可以将其理解为一套基于 Expo 框架将通用、可复用的功能模块、开发模式、最佳实践进行标准化封装和管理的体系。这里的“技能”Skills并非指开发者的个人能力而是指那些可以被“装配”到任何新项目中的、开箱即用的“能力单元”。想象一下你有一个工具箱里面不是散乱的螺丝刀和扳手而是一个个功能完整的“模块”比如“用户认证模块”、“支付集成模块”、“实时聊天模块”。expo/skills倡导的正是这种思路——将常见的应用功能沉淀为高内聚、低耦合的“技能包”。当启动新项目时你无需从零开始而是像搭积木一样选择并组合所需的“技能”快速构建出应用的核心骨架。这不仅能将项目初始化时间从数周缩短到几天更能确保团队技术栈的统一、代码质量的稳定以及后期维护的便捷。对于中小型团队、独立开发者或需要快速验证想法的创业者而言这种模式的价值尤为突出。它降低了高质量应用开发的门槛让开发者能将精力更集中于业务逻辑和创新而非重复的基础设施建设。接下来我将深入拆解如何从零开始构建属于你自己的expo/skills体系。2. 核心架构设计如何定义与组织“技能”构建expo/skills体系的第一步是确立清晰的设计原则和组织结构。一个混乱的“技能”仓库很快就会变得难以维护和使用。我们需要一个既能保证灵活性又能维持秩序的设计方案。2.1 “技能”的边界与定义一个合格的“技能”应该具备以下特征这决定了它的可复用性和易用性功能完整性一个“技能”应解决一个相对独立、完整的业务或技术问题。例如“图片选择与上传”是一个完整的技能它应包含UI组件相册选择器、裁剪器、权限处理相机、相册权限、文件处理压缩、格式转换、网络请求上传到指定CDN或服务器等一系列子功能。而“网络请求库”本身可能更偏向底层工具可以作为技能的内部依赖但不一定作为一个独立的顶级技能。接口标准化每个“技能”必须对外提供清晰、稳定的API接口。这通常包括初始化配置如何传入必要的密钥、服务器地址等配置项。核心方法提供哪些函数供业务调用如auth.login(),payment.createOrder()。事件/钩子是否提供事件监听或React Hooks以便业务方响应内部状态变化如onAuthStateChanged,usePaymentStatus。UI组件如果包含UI应提供属性props定义良好的React组件。依赖隔离性“技能”应尽可能封装其内部依赖。理想情况下业务项目通过安装该“技能”包就能获得全部所需功能无需再关心内部使用了哪个状态管理库、哪个图标库。这通常通过将第三方依赖声明为peerDependencies或打包成单一Bundle来实现。配置外部化所有与环境、项目相关的配置如API地址、App Key都不应硬编码在技能内部而必须通过初始化参数、环境变量或外部配置文件注入。基于以上原则我们可以为常见的应用场景定义一批基础技能skill-auth: 用户认证登录、注册、注销、Token管理、第三方OAuth集成。skill-network: 增强型网络层基于axios或fetch的封装包含请求拦截、响应处理、错误统一、加载状态管理。skill-storage: 数据持久化对AsyncStorage、SecureStore或MMKV的封装提供类型安全的存取接口。skill-ui-core: 基础UI组件库按钮、输入框、弹窗、主题提供者。skill-camera: 相机与图片处理集成expo-image-picker和expo-image-manipulator提供标准化拍照、选图、裁剪流程。skill-push-notification: 推送通知集成expo-notifications处理令牌注册、通知接收与点击事件。skill-analytics: 数据分析封装expo-analytics或第三方SDK提供页面追踪、事件打点接口。2.2 项目组织结构策略如何物理上存放这些“技能”代码这里有几种主流模式适用于不同规模的团队模式一Monorepo单仓库多包这是最契合expo/skills理念的结构。使用Turborepo或Nx等现代Monorepo工具管理。my-expo-skills-repo/ ├── apps/ │ └── demo-app/ # 用于演示和测试所有技能的示例应用 ├── packages/ │ ├── skill-auth/ # 认证技能包 │ │ ├── src/ │ │ ├── package.json │ │ └── README.md │ ├── skill-network/ │ ├── skill-ui-core/ │ └── tsconfig.json # 共享的TypeScript配置 ├── package.json └── turbo.json # Turborepo 配置优势代码共享极其方便原子提交一次提交可跨多个包工具链统一依赖管理清晰。劣势仓库体积会随时间增长对Git操作有一定要求。非常适合技能体系的核心建设阶段。模式二Multi-Repo多仓库每个“技能”都是一个独立的Git仓库和NPM包。https://github.com/your-org/skill-auth https://github.com/your-org/skill-network优势权限控制粒度细每个技能可以独立进行版本发布和CI/CD更符合微服务理念。劣势跨技能开发、调试和版本同步成本较高。当技能间有依赖时管理复杂度上升。模式三源码直接引用在项目初期或技能数量很少时可以直接将技能代码以源码形式放在项目的libs/或skills/目录下通过相对路径引用。my-app/ ├── src/ ├── skills/ # 技能源码目录 │ ├── auth/ │ ├── network/ │ └── index.ts # 统一导出 └── package.json优势零配置修改即时生效调试最简单。劣势无法实现跨项目复用技能代码与业务代码耦合不适合团队协作和长期发展。实操心得对于决心系统化建设技能体系的团队我强烈推荐从Monorepo (Turborepo)开始。它完美支持了“在独立包中开发在示例App中即时调试”的工作流。你可以很容易地在skill-auth中修改代码并立刻在apps/demo-app中看到效果这极大地提升了开发体验和重构信心。3. 开发规范与工具链建设统一的规范是保证多个“技能”包能够协同工作的基石。没有规范很快就会陷入“每个技能一套写法”的混乱局面。3.1 技术栈与编码规范强制统一语言与类型强制使用TypeScript。每个技能包的tsconfig.json应从根目录的共享配置扩展确保严格的类型检查。代码风格使用ESLint和Prettier。在Monorepo根目录配置所有子包继承此配置。可以选用react-native-community/eslint-config或eslint-config-expo作为基础。提交规范采用Conventional Commits约定式提交便于生成CHANGELOG和自动化版本管理。可以使用commitlint和husky在Git提交时进行校验。导出规范每个技能包应有清晰的入口文件通常是src/index.ts并只导出允许外部使用的API。内部工具函数或组件应避免污染公共接口。3.2 质量保障测试与文档单元测试每个技能包必须包含单元测试。使用Jest作为测试框架。对于涉及React组件或Hooks的技能使用React Native Testing Library进行测试。测试覆盖率应作为一个重要的质量门禁。集成测试/示例应用在Monorepo中维护一个demo-app至关重要。它不仅是技能的展示窗口更是最真实的集成测试环境。所有技能的新功能或改动都必须在demo-app中验证通过。文档即代码为每个技能包编写详细的README.md。内容应包括简介与安装API文档可通过TypeDoc自动从代码注释生成完整的使用示例配置说明常见问题 好的文档能降低技能的使用门槛是提高团队效率的关键。3.3 构建与发布流水线构建工具使用tsup、Rollup或esbuild对技能包进行打包。目标是将TypeScript源码转换为CommonJS和ESModule格式并生成类型声明文件.d.ts。配置应确保不打包第三方依赖标记为external。// skill-auth/package.json 片段 { main: ./dist/index.js, module: ./dist/index.mjs, types: ./dist/index.d.ts, exports: { .: { require: ./dist/index.js, import: ./dist/index.mjs, types: ./dist/index.d.ts } } }版本管理与发布使用Changesets管理版本和生成CHANGELOG。开发者通过changeset add命令描述修改工具会自动根据修改类型patch,minor,major计算新版本并在发布时更新版本号和生成日志。CI/CD在GitHub Actions或GitLab CI中配置自动化流程。通常包括代码检查Lint、类型检查TypeScript、运行测试、版本发布当代码合并到主分支并存在changeset时自动发布到NPM或私有仓库。注意事项技能包的版本号管理需要谨慎。对于内部使用的技能体系我建议在初期采用“同步大版本”策略即所有技能包的主版本号major version保持一致。例如当你对技能体系进行了一次不兼容的架构升级时将所有技能包从1.x.x统一升级到2.0.0。这可以避免项目在组合不同技能时陷入“依赖地狱”。当然每个技能包的次版本号minor和修订号patch可以独立变化。4. 实战从零构建一个“认证技能包”skill-auth让我们以最常见的skill-auth为例看看一个完整的技能包是如何从设计到实现的。我们将构建一个支持邮箱/密码登录、JWT令牌管理、自动刷新和登录状态持久化的技能。4.1 需求分析与接口设计首先明确这个技能要对外提供什么能力用户登录邮箱/密码用户注册用户登出获取当前用户信息监听认证状态变化自动在请求头中附加JWT令牌令牌过期自动刷新据此我们设计核心API接口// skill-auth/src/types.ts export interface User { id: string; email: string; name?: string; avatar?: string; } export interface AuthConfig { apiBaseUrl: string; // 后端API地址 tokenRefreshEndpoint?: string; // 令牌刷新端点 storageKey?: string; // 本地存储的key } export interface LoginCredentials { email: string; password: string; } // skill-auth/src/index.ts 导出的主要接口 export class AuthService { constructor(config: AuthConfig); initialize(): Promisevoid; // 初始化从存储中恢复登录状态 login(credentials: LoginCredentials): PromiseUser; register(credentials: LoginCredentials { name: string }): PromiseUser; logout(): Promisevoid; getCurrentUser(): User | null; isAuthenticated(): boolean; // 用于网络层拦截器的令牌获取方法 getAccessToken(): string | null; } // 提供一个React Context和Hook方便在组件中使用 export const AuthProvider: React.FC{children: React.ReactNode}; export const useAuth () { return { user: User | null, isLoading: boolean, login: (creds: LoginCredentials) Promisevoid, logout: () Promisevoid, // ... 其他方法 }; };4.2 核心实现细节状态管理技能内部需要一个状态来存储user,tokens,isLoading等。我们可以使用一个简单的观察者模式Pub/Sub或者直接使用Zustand这类轻量级状态库。为了减少外部依赖这里实现一个简易观察者// skill-auth/src/auth-store.ts type AuthState { user: User | null; accessToken: string | null; refreshToken: string | null; isInitialized: boolean; }; type Listener (state: AuthState) void; class AuthStore { private state: AuthState { /* 初始状态 */ }; private listeners: Listener[] []; setState(updater: (prev: AuthState) AuthState) { this.state updater(this.state); this.listeners.forEach(listener listener(this.state)); } subscribe(listener: Listener) { this.listeners.push(listener); return () { /* 取消订阅逻辑 */ }; } // ... getState 等方法 }令牌持久化与安全使用 Expo 的expo-secure-store来安全地存储刷新令牌。访问令牌可以存在内存或普通的AsyncStorage中因为它是短期的。import * as SecureStore from expo-secure-store; import AsyncStorage from react-native-async-storage/async-storage; const TOKEN_KEY myapp_refresh_token; const USER_KEY myapp_user; async function persistTokens(accessToken: string, refreshToken: string) { await AsyncStorage.setItem(access_token, accessToken); await SecureStore.setItemAsync(TOKEN_KEY, refreshToken); }重要提示SecureStore在iOS上使用Keychain在Android上使用EncryptedSharedPreferences提供了平台级的安全保障。但绝对不要在其中存储过于敏感的信息如原始密码且要处理好设备无安全锁屏的情况SecureStore可能不可用。自动令牌刷新这是提升用户体验的关键。在网络层如skill-network的请求拦截器中当收到401 Unauthorized响应时不应直接让用户重新登录而应尝试刷新令牌。// 在 skill-auth 中提供一个刷新方法 async function refreshAccessToken(): Promisestring { const refreshToken await SecureStore.getItemAsync(TOKEN_KEY); if (!refreshToken) throw new Error(No refresh token); const response await fetch(${config.apiBaseUrl}/auth/refresh, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) throw new Error(Token refresh failed); const { accessToken, refreshToken: newRefreshToken } await response.json(); // 更新存储 await persistTokens(accessToken, newRefreshToken); // 更新内存状态 authStore.setState(prev ({ ...prev, accessToken })); return accessToken; } // 在 skill-network 的拦截器中调用 axiosInstance.interceptors.response.use( (response) response, async (error) { const originalRequest error.config; if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true; try { const newToken await authService.refreshAccessToken(); originalRequest.headers.Authorization Bearer ${newToken}; return axiosInstance(originalRequest); // 重试原请求 } catch (refreshError) { // 刷新失败触发全局登出 authService.logout(); return Promise.reject(refreshError); } } return Promise.reject(error); } );与React集成创建AuthProvider和useAuthHook将认证状态和方法暴露给组件树。// skill-auth/src/auth-context.tsx import React, { createContext, useContext, useEffect, useState } from react; import { AuthService } from ./auth-service; import { AuthStore } from ./auth-store; const AuthContext createContextReturnTypetypeof useAuthValue | null(null); function useAuthValue(authService: AuthService) { const [state, setState] useState(authStore.getState()); useEffect(() { const unsubscribe authStore.subscribe(setState); return unsubscribe; }, []); return { ...state, login: authService.login, logout: authService.logout /* ... */ }; } export const AuthProvider: React.FC{service: AuthService; children: React.ReactNode} ({ service, children }) { const value useAuthValue(service); return AuthContext.Provider value{value}{children}/AuthContext.Provider; }; export const useAuth () { const ctx useContext(AuthContext); if (!ctx) throw new Error(useAuth must be used within AuthProvider); return ctx; };4.3 在业务项目中使用在最终的应用项目中使用变得非常简单// App.tsx import { AuthService, AuthProvider } from your-org/skill-auth; import { NetworkProvider } from your-org/skill-network; // 网络层也需要注入认证服务 const authService new AuthService({ apiBaseUrl: process.env.EXPO_PUBLIC_API_URL, }); export default function App() { useEffect(() { authService.initialize(); // 尝试从存储恢复登录状态 }, []); return ( AuthProvider service{authService} NetworkProvider authService{authService} {/* 将authService传给网络层用于拦截器 */} YourAppNavigation / /NetworkProvider /AuthProvider ); } // 在任意子组件中 import { useAuth } from your-org/skill-auth; function ProfileScreen() { const { user, isLoading, logout } useAuth(); if (isLoading) return LoadingSpinner /; return ( View TextHello, {user?.name}/Text Button titleLogout onPress{logout} / /View ); }5. 技能间的协同与高级模式当拥有多个技能后如何让它们优雅地协作而不是成为孤岛5.1 技能依赖与组合一个技能可以依赖另一个技能。例如skill-analytics数据分析可能依赖于skill-auth来获取当前用户ID以便关联事件。在Monorepo中这通过package.json的dependencies来管理。// packages/skill-analytics/package.json { name: your-org/skill-analytics, dependencies: { your-org/skill-auth: workspace:* // 在monorepo中使用workspace协议 } }在代码中skill-analytics可以设计为接收一个authService实例作为配置项而不是直接导入以保持松耦合。// skill-analytics/src/analytics-service.ts export class AnalyticsService { constructor(config: AnalyticsConfig, authService?: AuthService) { if (authService) { // 监听用户变化设置用户ID到分析SDK authService.onUserChanged((user) { this.setUserId(user?.id); }); } } }5.2 面向切面编程AOP与插件系统对于日志、性能监控、错误上报这类横切关注点可以设计成“插件”或“中间件”模式让技能或业务代码方便地接入。例如我们可以创建一个skill-logger它提供一个withLogging高阶函数或装饰器// skill-logger/src/index.ts export function withLoggingF extends (...args: any[]) any( fn: F, operationName: string ): (...args: ParametersF) ReturnTypeF { return async (...args) { const startTime Date.now(); try { const result await fn(...args); logSuccess(operationName, Date.now() - startTime, args); return result; } catch (error) { logError(operationName, error, args); throw error; } }; } // 在 skill-auth 中使用 import { withLogging } from your-org/skill-logger; class AuthService { login withLogging(this._login.bind(this), Auth.Login); private async _login(credentials: LoginCredentials) { // ... 实际的登录逻辑 } }5.3 动态加载与按需引入对于大型应用可能希望某些技能如某个复杂的业务模块只在特定条件下加载。这可以通过动态导入import()实现。我们可以将技能包构建成符合动态导入的格式并在应用中懒加载。// 在应用中使用动态加载 const loadPaymentSkill async () { const module await import(your-org/skill-payment); return new module.PaymentService(config); };这要求技能包的构建输出支持ES模块并且处理好其自身的依赖加载。6. 部署、维护与团队协作指南构建技能体系不是一蹴而就的它需要持续的维护和良好的团队协作流程。6.1 版本发布与变更管理语义化版本严格遵守major.minor.patch。破坏性更新升major新增功能升minor问题修复升patch。Changesets工作流开发者在功能分支开发。完成功能后运行npx changeset选择影响的包如skill-auth和变更类型patch,minor,major并编写对人类友好的变更描述。提交 changeset 文件。当PR合并到主分支后CI会检测到 changeset 文件自动创建版本发布PR更新版本号和CHANGELOG。维护者合并该PRCI会自动执行npm publish。6.2 私有NPM仓库与镜像对于公司内部技能需要搭建私有NPM仓库。Verdaccio是一个流行的、轻量级的开源方案可以轻松在内部服务器部署。在项目中配置.npmrc文件将内部包指向私有仓库。your-org:registryhttps://your-private-npm-registry.com/ //your-private-npm-registry.com/:_authToken${NPM_TOKEN}6.3 团队协作与知识共享技能目录与探索工具维护一个中心化的文档网站使用像Docusaurus或Nextra这样的工具自动从各技能包的README.md和 TypeDoc 注释生成统一的API文档。新成员可以在此快速了解现有能力。贡献指南在项目根目录提供清晰的CONTRIBUTING.md说明如何添加新技能、开发规范、测试要求和发布流程。定期复盘每季度或每半年对技能库进行一次复盘。讨论哪些技能使用率高、哪些需要重构、哪些可以废弃、社区出现了什么更好的替代方案。保持技能库的活力与先进性。6.4 常见陷阱与避坑指南过度设计在技能开发的早期避免追求“终极抽象”。先让技能跑起来解决实际问题再根据使用中暴露的问题进行重构。第一个版本的skill-auth可能只支持邮箱登录这完全没问题。循环依赖在Monorepo中如果skill-a依赖skill-b而skill-b又依赖skill-a就会形成循环依赖导致构建失败。设计时要仔细划分职责如果两个技能功能耦合紧密考虑将它们合并为一个技能。配置地狱每个技能都有配置如果项目要初始化十几个技能配置会非常冗长。解决方案是创建一个“技能管理器”或“应用配置中心”统一读取环境变量或配置文件然后分发配置给各个技能。// skill-config 或 app-config.ts import { AuthConfig } from your-org/skill-auth; import { NetworkConfig } from your-org/skill-network; // ... 其他技能配置类型 export interface AppConfig { auth: AuthConfig; network: NetworkConfig; // ... } export const config: AppConfig { auth: { apiBaseUrl: process.env.EXPO_PUBLIC_API_URL }, network: { timeout: 30000 }, // ... }; // 在App中统一初始化 const authService new AuthService(config.auth); const networkService new NetworkService(config.network, authService);测试数据隔离确保每个技能的测试是独立的不依赖外部服务如真实的API。使用jest.mock或模拟mocking工具来模拟网络请求和原生模块。构建和维护一个成熟的expo/skills体系是一项基础设施投资初期会有一定的学习和搭建成本。但一旦体系运转起来它所带来的开发效率提升、代码质量保障和团队知识沉淀的收益是巨大的。它让团队从重复劳动中解放出来更专注于创造产品本身的价值。