我把一个业务编辑器打磨成了可复用 npm 包:从“能用”到“能发布”,到底要补多少坑?
最近我把一个原本偏业务内用的富文本编辑器,逐步打磨成了一个可以独立复用、可发布到 npm 的 React 编辑器包。
目前已经发布:
npm install @chenglu1/xeditor-editor@0.0.8
这篇文章不只是想展示“我做了一个编辑器”,更想分享一条很真实的工程路径:
- 一个编辑器从“项目里能跑”,到“第三方可以放心接入”,中间到底差了什么
- 为什么很多编辑器 demo 看起来很完整,但一旦抽成公共包就问题不断
- 我这次是怎么一步步把它打磨成一个更像产品、而不是业务私有组件的
如果你也在做:
- React 富文本编辑器
- 设计系统组件库
- 业务组件公共化
- npm 包工程化
这篇应该会有一些参考价值。
一、起点:它最开始并不是一个“公共编辑器”
最开始,这个编辑器确实能满足业务使用,但如果要让第三方直接安装接入,问题其实不少:
- 入口组件过重,承担了太多职责
- Markdown 处理链分散在多个扩展里
- 编辑态和只读态边界不清晰
- 上传能力带有业务耦合
- 默认能力过重,包体偏大
- 国际化、样式、发布链都还不够产品化
简单说:
它更像“项目内部可用组件”,而不是“面向外部复用的公共包”。
而一个真正可复用的编辑器,至少要满足这些条件:
- API 明确,接入方式稳定
- 编辑态、禁用态、只读态职责清楚
- Markdown / HTML / JSON 的内容契约足够统一
- 上传、国际化、样式、扩展能力都能被宿主控制
- 测试、构建、发版链路要足够稳定
这也是我这次改造的核心方向。
二、先别急着重构,先补保护网
很多组件库后期越来越难改,根因不是“代码旧”,而是“没有保护网”。
所以这次我没有一开始就去拆实现,而是先补测试,优先覆盖这些高风险场景:
- 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
拆完之后,入口组件只负责三件事:
- 接收 props
- 选择渲染哪种视图
- 对外抛出更新 / 错误事件
这一步不是最“显眼”的改动,但它实际上是整个编辑器产品化的基础。
因为只有装配层拆出来,后面的测试、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 生成做成了完整降级链路:
- crypto.randomUUID
- crypto.getRandomValues
- 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