代码的边界:从项目到包的重构实践

3 min read
Zekari
npmrefactoringabstractionarchitecture

复用代码的冲动来得很自然。当你构建了一个功能完整的博客系统,自然会想"如果能在其他项目中快速使用就好了"。但这个想法从未真正简单。

问题不在于技术实现,而在于:什么应该被复用,什么必须留给使用者? 这是一个边界问题。

第一层边界:包含的代价

复用的第一步不是 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 和命名导出。修复方案有三种:

  1. 统一所有组件的导出方式(耗时,改动大)
  2. 手动创建 .d.ts 文件(维护成本高)
  3. 暂时禁用类型生成(务实但不完美)

我选择了第三种:

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 不存在。解决方案:

  1. 注册 npm organization(需要付费)
  2. 改用 @fenz007/nextjs-blog(用户名 scope)
  3. 改用简单包名 fenz007-nextjs-blog

我最终选择了第三种。虽然 scoped package 看起来更专业,但对于个人项目,简单包名就够了。这又是一个边界判断:形式 vs 实用。

第三个洞见:边界不是固定的,而是在约束条件下的最优解。完美是边界的敌人。

核心理解:边界即设计

将 32 个组件、3 个 CLI 命令、18 个依赖打包成一个 npm 包,整个过程用了 3-4 小时。但真正的挑战不在代码量,而在边界的定义。

回顾整个过程,我在三个层次上定义了边界:

  1. 内容边界 - 什么应该被包含(组件 yes,文章 no)
  2. 使用边界 - 如何被使用(CLI + 导入的混合方式)
  3. 质量边界 - 什么是必需的(功能 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
  1. TypeScript 类型定义不完整

    • 原因:组件导出方式不统一
    • 影响:IDE 类型提示不全
    • 计划:v1.1.0 修复
  2. 路径别名依赖

    • 原因:保留了 @/ 导入
    • 影响:某些构建环境可能需要额外配置
    • 计划:长期逐步迁移到相对路径
  3. CLI 错误处理

    • 原因:时间限制,仅实现基础错误处理
    • 影响:某些边缘情况可能崩溃
    • 计划:v1.2.0 增强

承认这些不完美很重要。v1.0.0 的目标是"可用",而非"完美"。后者需要更多迭代。

相关思考

这次实践让我重新理解了"复用"这个概念。复用不是技术问题,而是设计问题——如何在通用性和特定性之间找到平衡。

边界的清晰度决定了复用的质量。模糊的边界导致过度耦合或过度抽象,清晰的边界让代码既灵活又简单。

下次当你构建可复用的东西,试着用"边界"这个视角思考。不是"我如何分离代码",而是"什么应该属于哪一侧"。

边界清晰,代码就自然分离了。