代码的边界:从项目到包的重构实践
复用代码的冲动来得很自然。当你构建了一个功能完整的博客系统,自然会想"如果能在其他项目中快速使用就好了"。但这个想法从未真正简单。
问题不在于技术实现,而在于:什么应该被复用,什么必须留给使用者? 这是一个边界问题。
第一层边界:包含的代价
复用的第一步不是 cp -r,而是理解边界。
我的博客系统包含 32 个组件、18 个依赖、若干配置文件,以及最重要的——内容。文章、首页、个人介绍,这些构成了这个博客的灵魂。
但包不应该有灵魂。包应该是框架,是可能性的集合。
应该包含的:
- 32 个博客组件(BlogCard, BlogLayout, MDX 组件等)
- 库函数(getAllPosts, filterPosts 等)
- 路由模板(
app/blog/page.tsx作为模板,而非实现) - 配置模板(next.config, tailwind.config)
必须排除的:
- 具体的文章内容(
content/blog/只保留示例) - 首页实现(每个博客都应该不同)
- 个人化的配置(域名、作者信息)
- 项目特定的资源(图片、字体)
这个边界看似清晰,实则需要反复权衡。比如 i18n 系统——我选择包含框架(LanguageProvider, translations.ts),但不包含具体的翻译内容。这是一个灰色地带,取决于你对"可配置性"的理解。
最困难的部分是路径别名。原项目中所有组件都使用 @/ 导入,这在 npm 包中是一个问题。我没有重写所有导入(那会改变太多代码),而是保留了 @/ 配置,让 tsconfig 继续工作。这不完美,但够用。
这就是第一个洞见:边界不是纯粹的分离,而是在"完全耦合"和"完全解耦"之间找到平衡点。
第二层边界:使用的接口
确定了"什么"之后,需要定义"如何"。
一个 npm 包可以被导入:
import { BlogCard, getAllPosts } from 'fenz007-nextjs-blog'
这很灵活。但对于博客这种结构化的系统,纯导入不够——用户还需要:
- 创建路由文件
- 配置 Next.js 和 Tailwind
- 设置内容目录结构
- 安装 18 个依赖
如果每次都手动操作,复用的价值就大打折扣。
我选择提供三种使用方式:
1. 创建新项目
npx fenz007-nextjs-blog create my-blog
自动创建 Next.js 项目 + 初始化博客系统。
2. 初始化现有项目
npm install fenz007-nextjs-blog
npx fenz007-nextjs-blog init
复制路由模板、配置文件、安装依赖。
3. 直接导入组件
import { BlogCard } from 'fenz007-nextjs-blog'
高级用户的自定义方式。
这三种方式对应不同的使用场景和技术水平。关键是 init 命令——它是边界的具体化:哪些文件应该被创建,哪些配置应该被合并,哪些依赖应该被安装。
CLI 的实现包含三个工具模块:
file-ops.ts- 文件操作(复制模板、合并配置)dependency.ts- 依赖管理(检测包管理器、安装依赖)validator.ts- 项目验证(检查是否是 Next.js 项目)
这些模块本身也定义了边界:什么操作应该自动完成,什么应该留给用户决定。比如配置文件,我选择不自动覆盖,而是显示需要添加的内容,让用户手动合并。因为配置是个人化的,强制覆盖会破坏用户现有的设置。
第二个洞见:接口设计就是边界设计。CLI 不仅是便利工具,更是使用边界的明确表达。
第三层边界:发布的取舍
实践中的边界从来不完美。
TypeScript 类型定义
构建包时遇到了类型错误:
error TS2305: Module '"./components/blog/blog-card"'
has no exported member 'default'.
原因是组件混用了 export default 和命名导出。修复方案有三种:
- 统一所有组件的导出方式(耗时,改动大)
- 手动创建
.d.ts文件(维护成本高) - 暂时禁用类型生成(务实但不完美)
我选择了第三种:
export default defineConfig({
dts: false, // Temporarily disabled due to export issues
})
包依然可用,只是没有完整的 TypeScript 类型提示。这是一个取舍。
在理想世界中,我们应该:
- 统一所有组件的导出方式
- 提供完整的类型定义
- 确保 100% 的类型安全
在现实世界中,我们需要问:
- 这个问题现在必须解决吗?
- 解决它的成本是多少?
- 不解决它的影响是什么?
对于一个刚发布的 v1.0.0 包,功能可用比类型完美更重要。类型定义可以在后续版本中修复。这不是懒惰,而是优先级判断。
Scope 的问题
最初我使用 @zekari/nextjs-blog 作为包名(scoped package),发布时遇到错误:
npm ERR! 404 Scope not found
原因是 @zekari 这个 scope 不存在。解决方案:
- 注册 npm organization(需要付费)
- 改用
@fenz007/nextjs-blog(用户名 scope) - 改用简单包名
fenz007-nextjs-blog
我最终选择了第三种。虽然 scoped package 看起来更专业,但对于个人项目,简单包名就够了。这又是一个边界判断:形式 vs 实用。
第三个洞见:边界不是固定的,而是在约束条件下的最优解。完美是边界的敌人。
核心理解:边界即设计
将 32 个组件、3 个 CLI 命令、18 个依赖打包成一个 npm 包,整个过程用了 3-4 小时。但真正的挑战不在代码量,而在边界的定义。
回顾整个过程,我在三个层次上定义了边界:
- 内容边界 - 什么应该被包含(组件 yes,文章 no)
- 使用边界 - 如何被使用(CLI + 导入的混合方式)
- 质量边界 - 什么是必需的(功能 yes,完美类型 not now)
每个边界都是一次权衡。而权衡的本质是理解:这个包想要解决什么问题,为谁解决问题。
💡 Click the maximize icon to view in fullscreen
当你下次想要"复用代码"时,不要急于 copy-paste。先问:
- 边界在哪里?
- 如何使用最自然?
- 什么可以妥协?
边界清晰了,代码就活了。
实践细节
包结构:
- 32 个组件(22 个博客 + 10 个 UI)
- 3 个 CLI 命令(create, init, update)
- 18 个核心依赖(MDX, Tailwind, Radix UI 等)
- 包大小:523 KB(压缩后 118 KB)
工具链:
- tsup - 构建工具(ESM + CJS)
- commander - CLI 框架
- fs-extra - 文件操作
- chalk - 终端样式
发布:
npm publish # 公开包,免费
使用:
npx fenz007-nextjs-blog create my-blog
-
TypeScript 类型定义不完整
- 原因:组件导出方式不统一
- 影响:IDE 类型提示不全
- 计划:v1.1.0 修复
-
路径别名依赖
- 原因:保留了
@/导入 - 影响:某些构建环境可能需要额外配置
- 计划:长期逐步迁移到相对路径
- 原因:保留了
-
CLI 错误处理
- 原因:时间限制,仅实现基础错误处理
- 影响:某些边缘情况可能崩溃
- 计划:v1.2.0 增强
承认这些不完美很重要。v1.0.0 的目标是"可用",而非"完美"。后者需要更多迭代。
相关思考
这次实践让我重新理解了"复用"这个概念。复用不是技术问题,而是设计问题——如何在通用性和特定性之间找到平衡。
边界的清晰度决定了复用的质量。模糊的边界导致过度耦合或过度抽象,清晰的边界让代码既灵活又简单。
下次当你构建可复用的东西,试着用"边界"这个视角思考。不是"我如何分离代码",而是"什么应该属于哪一侧"。
边界清晰,代码就自然分离了。
Related Posts
Articles you might also find interesting
适配器模式:对现实的妥协
当 PayPro 要求 IP 白名单而 Stripe 不需要,当一个按秒计费另一个按请求计费,架构设计不是消除约束——而是管理约束。适配器模式不是优雅设计,而是对现实混乱的务实投降。
调用链路追踪法:从断点到根因
功能失效的背后,是一条完整的调用链路。追踪这条链路,定位断点,才能从根本上解决问题。
离屏渲染:照片捕获为什么需要独立的 canvas
实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。
集中式配置:让 Reddit 组件脱离重复泥潭
当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。
约束驱动设计:为何选择内存追踪
在 Cloudflare Workers 环境中实现追踪系统,持久化和内存存储之间的权衡不是技术偏好,而是约束驱动的必然选择。
依赖注入
依赖注入不是关于框架或工具,而是关于控制权的转移。理解这个转移,就理解了软件设计的核心原则。
双重导出管道的架构选择
在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
从意图到架构
技术方案不是设计出来的,而是从问题中涌现的。理解这个过程,就理解了软件设计的本质。
重复数据的迁移实践:从 N 个文件到 1 个真相源
当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
多厂商 AI 调度:统一混乱的供应商生态
当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。
分布式 Workers 的解耦设计
通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。
队列、可靠性与系统边界
探讨消息队列系统如何通过时间换空间,用异步换解耦,以及可靠性背后的权衡
Studio 系统架构:从状态机到端到端流程
深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑
编码前的思考
软件设计不是从代码开始的。在动手之前,有一套思维框架值得遵循:理解边界、定义数据、设计函数、建立抽象。
统一积分系统的设计实践
从多套积分到单一积分池的架构演进,以及背后的原子性、一致性设计