代码的边界:从项目到包的重构实践
复用代码的冲动来得很自然。当你构建了一个功能完整的博客系统,自然会想"如果能在其他项目中快速使用就好了"。但这个想法从未真正简单。
问题不在于技术实现,而在于:什么应该被复用,什么必须留给使用者? 这是一个边界问题。
第一层边界:包含的代价
复用的第一步不是 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 的目标是"可用",而非"完美"。后者需要更多迭代。
相关思考
这次实践让我重新理解了"复用"这个概念。复用不是技术问题,而是设计问题——如何在通用性和特定性之间找到平衡。
边界的清晰度决定了复用的质量。模糊的边界导致过度耦合或过度抽象,清晰的边界让代码既灵活又简单。
下次当你构建可复用的东西,试着用"边界"这个视角思考。不是"我如何分离代码",而是"什么应该属于哪一侧"。
边界清晰,代码就自然分离了。