我把一个业务编辑器打磨成了可复用 npm 包:从“能用”到“能发布”,到底要补多少坑

4 阅读10分钟

我把一个业务编辑器打磨成了可复用 npm 包:从“能用”到“能发布”,到底要补多少坑?

最近我把一个原本偏业务内用的富文本编辑器,逐步打磨成了一个可以独立复用、可发布到 npm 的 React 编辑器包。

目前已经发布:

npm install @chenglu1/xeditor-editor@0.0.8

这篇文章不只是想展示“我做了一个编辑器”,更想分享一条很真实的工程路径:

  • 一个编辑器从“项目里能跑”,到“第三方可以放心接入”,中间到底差了什么
  • 为什么很多编辑器 demo 看起来很完整,但一旦抽成公共包就问题不断
  • 我这次是怎么一步步把它打磨成一个更像产品、而不是业务私有组件的

如果你也在做:

  • React 富文本编辑器
  • 设计系统组件库
  • 业务组件公共化
  • npm 包工程化

这篇应该会有一些参考价值。


一、起点:它最开始并不是一个“公共编辑器”

最开始,这个编辑器确实能满足业务使用,但如果要让第三方直接安装接入,问题其实不少:

  • 入口组件过重,承担了太多职责
  • Markdown 处理链分散在多个扩展里
  • 编辑态和只读态边界不清晰
  • 上传能力带有业务耦合
  • 默认能力过重,包体偏大
  • 国际化、样式、发布链都还不够产品化

简单说:

它更像“项目内部可用组件”,而不是“面向外部复用的公共包”。

而一个真正可复用的编辑器,至少要满足这些条件:

  1. API 明确,接入方式稳定
  2. 编辑态、禁用态、只读态职责清楚
  3. Markdown / HTML / JSON 的内容契约足够统一
  4. 上传、国际化、样式、扩展能力都能被宿主控制
  5. 测试、构建、发版链路要足够稳定

这也是我这次改造的核心方向。


二、先别急着重构,先补保护网

很多组件库后期越来越难改,根因不是“代码旧”,而是“没有保护网”。

所以这次我没有一开始就去拆实现,而是先补测试,优先覆盖这些高风险场景:

  • Markdown round-trip
  • dual view 的双向同步
  • 上传节点替换
  • 静态 viewer 渲染
  • heading / list dropdown 行为
  • disabled / readOnly 模式切换
  • i18n 适配
  • UUID 兼容兜底

这一步的价值非常直接:

  • 后续可以放心拆装配层
  • 改造时更容易定位回归
  • 发 npm 包前可以真正做发布前校验

说实话,编辑器类组件最怕的不是“逻辑复杂”,而是“改了一点点,不知道哪里坏了”。


三、把“巨石入口组件”拆开

编辑器这类组件,最容易写着写着就变成一个巨石入口:

  • props 兼容在这里
  • editor 初始化在这里
  • toolbar 拼装在这里
  • 内容同步在这里
  • 上传处理在这里
  • 单双视图切换还在这里

短期很快,长期一定很难维护。

所以我把核心逻辑拆成了几个内部模块:

  • core/useConfigurableEditor
  • core/createToolbarConfig
  • core/editor-content
  • core/createImageUploadHandler

拆完之后,入口组件只负责三件事:

  1. 接收 props
  2. 选择渲染哪种视图
  3. 对外抛出更新 / 错误事件

这一步不是最“显眼”的改动,但它实际上是整个编辑器产品化的基础。

因为只有装配层拆出来,后面的测试、viewer 分离、API 演进、发布稳定性才有空间做对。


四、把 dualView 明确收口:只支持 Markdown

之前有个很典型的问题:

dualView + html

这个组合从语义上就不够稳定。

因为双栏源码编辑天然对应的是 Markdown,不是 HTML。
如果硬把 HTML 也塞进双视图,最后只会变成:

  • 同步逻辑越来越复杂
  • 用户预期越来越混乱
  • 边界越来越难维护

所以这次我直接把它收口了:

  • dualView 只支持 markdown
  • 如果传入 html,自动降级为单视图
  • 同时保留告警能力,方便宿主定位问题

做公共组件时,我越来越认同一件事:

不是“支持越多组合越好”,而是“哪些组合明确支持,哪些组合明确拒绝”。


五、把 Markdown 处理链尽量收成一个 Adapter

编辑器很容易积累一种隐性技术债:

  • 一个扩展 preprocess 一点 Markdown
  • 另一个扩展 serialize 再补一点
  • 静态 viewer 又自己写一套逻辑
  • 最后编辑态和只读态对同一份内容的解释开始漂移

这次我重点把 Markdown 处理往统一 adapter 收口,尤其是这些问题:

  • 表格预处理
  • 列表缩进规范化
  • 文本对齐语法
  • 独立图片空行补齐

这里最典型的一个坑是:

standaloneImageSpacing 虽然暴露成了 API,但一开始只在一半链路里生效

也就是说:

  • 你能传配置
  • 但 parse / serialize 没有完全统一
  • static viewer 和 live editor 也不完全一致

这种问题在业务项目里可能暂时不明显,但一旦发成 npm 包,马上就会变成接入方的问题。

所以我后面把它补成了真正完整的契约:

  • parse 前生效
  • serialize 后也受控
  • static viewer 和 live editor 走同一套共享预处理逻辑

这类修复特别重要,因为它让 API 不再只是“看起来有”,而是真正兑现了它的行为承诺。


六、默认能力瘦身,比“默认全开”更重要

很多编辑器一开始喜欢默认把能力开满:

  • 表格
  • 数学公式
  • 图片上传
  • details / 折叠块
  • 一整套 Markdown 方言支持

但一旦它变成公共包,这种默认策略就不一定合理了。

因为默认能力越重,意味着:

  • 包体更大
  • 初始化更慢
  • 接入成本更高
  • 宿主要为自己不需要的能力买单

所以我这次把默认 preset 收成了 lean core:

  • base
  • formatting
  • table
  • markdownDialect

而 math、media、details 这类能力改成按需开启。

同时把数学样式拆成了独立导出,避免宿主不需要公式能力时也被动引入整套 KaTeX 相关资源。

这一点其实特别像设计系统建设:

一个好的基础组件,不是默认什么都有,而是默认足够轻、足够稳。


七、真正的公共编辑器,一定要尊重宿主边界

这次打磨下来,我感受最深的一点就是:

一个组件是否适合复用,不只看它自己能不能跑,还要看它会不会伤害宿主。

我这次修掉了几个很典型的宿主风险。

1. 图片预览不能粗暴改全局页面滚动

之前图片预览会直接操作 document.body.style.overflow,这在业务页面里也许“能用”,但在公共组件里风险很大:

  • 容易覆盖宿主自己的滚动锁
  • 多实例场景下容易互相影响
  • 关闭时恢复不当,会破坏宿主页面状态

所以后面我把它改成了更稳妥的滚动锁管理方式,而不是简单粗暴地改全局样式。

2. 数学公式默认 trust: true 不够保守

对于一个准备公开复用的编辑器来说,默认安全策略不能太松。

所以我把 KaTeX 的默认配置改成了:

trust: false

如果宿主明确知道自己的内容可信,可以再显式放开。

这才更符合公共组件的默认安全基线。

3. 上传能力不能再绑业务实现

上传能力这次也做了明显收口,不再依赖业务地址、业务 token 或业务上下文,而是交给宿主显式注入:

  • uploadHandler
  • uploadUrl
  • mediaUpload hooks

这样之后,上传能力才真正属于编辑器本身,而不是业务私有逻辑的“搬运层”。


八、国际化不是“把文案提出来”就结束了

这次还有一个很值得记录的小坑:

包内 react-i18next 子入口的中文默认资源一度是乱码的。

这件事看起来像小问题,但对公共包来说很致命:

  • 子入口导出了,不代表真的可用
  • 第三方直接用 react-i18next 子入口时,默认中文体验会直接坏掉
  • 这会严重影响“开箱即用”的第一印象

所以我后面把这部分重新整理成了真正干净、可测试的内置资源,并补了对应测试。

这也让我更明确了一件事:

公共组件的完成度,往往就体现在这些“别人第一次接入就会遇到”的细节上。


九、低版本浏览器 / 非安全上下文兼容,也不能只做一半

还有一个我专门补的点,是 UUID 生成。

很多实现会直接写:

crypto.randomUUID()

这个在现代浏览器 + 安全上下文里没问题,但如果你要做成公共组件,还得考虑:

  • 低版本浏览器
  • 非安全上下文
  • 某些宿主环境不支持 randomUUID

所以我最后把 UUID 生成做成了完整降级链路:

  1. crypto.randomUUID
  2. crypto.getRandomValues
  3. Math.random 作为最终兜底

这件事很小,但它背后其实反映的是一个公共包该有的思路:

不要只考虑“我本地能跑”,而要考虑“别人项目里会不会炸”。


十、发 npm 包之前,我补了哪些工程化能力?

从“本地能 build”到“能稳定发布 npm 包”,中间其实还有不少工程工作。

这次我补了这些内容:

  • 更干净的 dist 清理
  • 显式样式导出
  • README / USAGE / MIGRATION
  • verify:release 发布前校验
  • pack:check
  • 包导出 smoke test
  • 本地 release 脚本
  • 版本、tag、发布链闭环

最后,我把发布前验证收成了一条命令:

npm run verify:release

它会依次校验:

  • lint
  • test
  • build
  • bundle exports
  • pack check

对我来说,这一步非常重要。

因为从这个时刻开始,发版不再是“我 build 成功了,应该没问题”,而是“这条发布链已经真正被验证过了”。


十一、现在怎么用?

最基础的安装方式:

npm install @chenglu1/xeditor-editor@0.0.8

最简单的编辑态用法:

import { useState } from 'react';
import { ConfigurableTiptapEditor } from '@chenglu1/xeditor-editor';
import '@chenglu1/xeditor-editor/styles.css';

export default function Demo() {
  const [value, setValue] = useState('# Hello Xeditor');

  return (
    <ConfigurableTiptapEditor
      value={value}
      valueType="markdown"
      onUpdate={(event) => {
        if (event.valueType === 'markdown') {
          setValue(event.value as string);
        }
      }}
    />
  );
}

如果只是做只读内容展示,也可以直接用 viewer 子入口:

import { StaticContentViewer } from '@chenglu1/xeditor-editor/viewer';
import '@chenglu1/xeditor-editor/styles.css';

export default function ArticlePreview() {
  return (
    <StaticContentViewer
      value="# 只读内容"
      valueType="markdown"
    />
  );
}

如果项目本身已经接了 react-i18next,也可以直接使用对应子入口进行国际化整合。


十二、这轮改造让我更确定:所谓“精品组件”,不是功能堆出来的

以前我做组件,会更关注这些:

  • 功能多不多
  • demo 漂不漂亮
  • 交互够不够炫

但这次真正把它打磨成 npm 包之后,我更在意的是:

  • 它能不能被别人稳定接入
  • 宿主边界清不清楚
  • 默认行为是不是足够保守
  • API 是否真正兑现了承诺
  • 测试、构建、发布链有没有闭环
  • 文档能不能让别人真正上手

我现在越来越觉得,一个编辑器要变成“精品”,不是再多加几个按钮,而是把这些基础能力打磨到别人敢放心用。


十三、结语

这次把 Xeditor 从一个偏业务内用的编辑器,逐步打磨成一个可发布、可复用、可验证的 npm 包,对我来说最大的收获不是“我做了一个编辑器”,而是:

我更清楚,一个组件从项目代码走向产品化,到底要补哪些真正重要的东西。

如果你也在做:

  • React 富文本编辑器
  • 设计系统组件库
  • 业务组件公共化
  • npm 包工程化

欢迎交流。

包名:

@chenglu1/xeditor-editor

当前版本:

0.0.8