1. 为什么要在鸿蒙上自研富文本编辑器
1.1 一个让人沉默的需求
被问到“React Native能不能在鸿蒙上做一个TextInput富文本编辑器”的时候,我第一反应是反问了一句:你确定要在现阶段做这个吗?这不是推脱,而是因为我太清楚里面有多少坑。
先说结论:能做,但绝对不能用常规的WebView + contentEditable方案直接套。在React Native for OpenHarmony(下面简称RNOH)生态下,TextInput组件的能力本就处于对齐的爬坡期,富文本编辑属于重交互、重光标控制、重文本测距的高级场景,哪一环薄弱都会让体验崩掉。
但这个需求确实存在。我接触到的几个团队,大致分为三类:
- 社区类和工具类App,需要用户编辑带格式的帖子、笔记、简介,比如加粗、标题、列表、引用。
- 企业内部协同工具,审批、日报、知识库,需要在鸿蒙端编辑富文本内容,再同步到服务端。
- 内容发布类产品,用户要在一段文字里插入图片和链接,类似微信公众号编辑器的基础版。
这三类场景的共同点是:用户不要求做到Notion那样极致复杂,但加粗、斜体、标题、列表、插入链接这些基础能力必须顺手,而且数据必须能结构化保存,不能是一段无法解析的HTML字符串。
所以这篇文章不是告诉你去抄一个完整编辑器,而是围绕“React Native鸿蒙版TextInput”这个场景,把富文本编辑器的可行方案、核心实现、踩过的坑、性能表现,真实地拆开。如果你正好要评估这个需求,或者已经决定动手,可以省下不少调研时间。
1.2 RNOH的TextInput,和你熟悉的TextInput之间差了多远
2019年之后,RN社区对TextInput的期望其实已经被iOS和Android两边养得挺高了。但RNOH这个项目还处于“先把基础组件跑通”的阶段,很多细节属性没有实现,或者实现了但行为有偏差。
我先列一下自己在鸿蒙DevEco Studio + RNOH 0.72版本上实测过、和富文本编辑强相关的TextInput能力现状:
| 能力项 | iOS/Android习惯表现 | RNOH实测表现 |
|---|---|---|
| onChangeText | 每次输入回调完整文本 | 可用,但高频输入下偶发丢字 |
| onSelectionChange | 稳定返回选区起止位置 | 有,但光标跳动时回调不够灵敏 |
| value受控 | 在两端可能引发光标跳动,仍可用 | 受控value下光标经常跳到末尾 |
| multiline | 正常 | 正常,但大文本下滚动不平滑 |
| placeholder | 正常 | 正常 |
| 自定义字体 | 可用 | 部分字体族不生效,需通过fontFamily逐个试 |
| maxLength | 正常 | 正常 |
| keyboardType | 正常 | 部分键盘类型在鸿蒙上表现不一致 |
这里最关键的是两个:onSelectionChange的可靠性和受控value下的光标保持。富文本编辑器只要一涉及光标定位,就离不开这两个。我第一次实现工具栏加粗时,就是受控value导致光标直接跳到文末,典型到不能再典型的坑。
记住一个原则:能用非受控就尽量非受控,需要做富文本渲染时,宁可把TextInput的value作为“镜像层”,也不要直接拿它做唯一数据源。这个思路后面实现时会细说。
提示:RNOH的模块名是
@react-native-oh/react-native套件,和原版RN不是同一套发布体。引入任何第三方RN组件库之前,都要先确认它是否兼容RNOH,否则编译期就能让你哭。
1.3 动手前,先定义“富文本”的边界
做编辑器最容易犯的错,就是一上来就想支持所有能力。真实现起来,文本测距、选区控制、排版换行、图片占位,每一项都能耗掉一周。
建议把需求拆成两类:
必需能力:加粗、斜体、删除线、标题层级、有序/无序列表、插入链接、插入图片、撤销重做。这是富文本的“公因数”,绝大多数场景逃不掉。
可选能力:表格、代码块高亮、多人协同光标、字数统计、@提醒、表情面板。这些能力没有独立的原生控件支持,复杂度指数级上升,RNOH下不建议第一个版本做。
还有一种情况:产品同学说“只要富文本,像微信公众号那样就行”。等他打开编辑器,第一句问的是“表格怎么没有?”所以开工之前,务必要把支持范围写死在PRD里,最好附上竞品截图,标明哪些能抄,哪些不能抄。
这个边界定义越清晰,技术选型越简单。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RNOH的技术底座:比原版RN多出来的那些认知
2.1 两个引擎,一套桥
RNOH并不是“鸿蒙版React Native”的唯一实现,但它是目前社区最活跃、官方推进力度最大的一个。它的基本原理,是把React Native的C++核心层通过OpenHarmony的N-API桥接到ArkTS运行环境,同时用JSVM替代原来React Native依赖的JavaScriptCore或Hermes。
这个过程导致一个结果:在很多API行为上,RNOH的表现和原版RN不完全一致。直接依赖JSC或Hermes特性的第三方库,很可能在RNOH上报错或静默失效。
比如,有些富文本库内部用document.execCommand来模拟选中区域格式化——这在WebView方案里是可行的,但在RNOH的纯组件方案里根本不存在。再比如,有些库依赖Hermes的Intl实现,RNOH上如果JSVM没启用相关特性,某些字符串处理就会出错。
所以,评估一个第三方库能否在RNOH上使用,不能只看它是不是纯JS实现,还要看它是否触碰到引擎差异层。稳妥的做法是:先在RNOH Demo工程里单独引入这个库,跑一个最小用例,再决定是否集成。
2.2 自定义原生模块,没有想象中那么可怕
富文本编辑器往往会用到原生层能力,比如获取已安装字体列表、测量文本宽度、弹出系统菜单、获取图片选择器的Uri。RNOH支持通过TurboModule和CustomComponent来做原生扩展,写法上和RN原版差别不大。
我举一个最简单的文本测量例子。富文本编辑器在光标右侧插入“加粗”标记时,最好预判替换后的文本宽度,这时候可以在ArkTS侧写一个原生模块:
typescript复制// MeasureTextModule.ets
import { turboModuleProxy } from '@rnoh/react-native-openharmony';
export interface MeasureTextSpec {
measureTextWidth(text: string, fontSize: number, fontWeight: string): number;
}
export class MeasureTextModule {
private spec = turboModuleProxy.get<MeasureTextSpec>('MeasureText');
public measure(text: string, fontSize: number, fontWeight: string): number {
return this.spec.measureTextWidth(text, fontSize, fontWeight);
}
}
然后在RN侧通过TurboModuleRegistry.get调用:
typescript复制import { TurboModuleRegistry } from 'react-native';
const MeasureText = TurboModuleRegistry.get('MeasureText');
export function measureTextWidth(text: string, fontSize: number, fontWeight: string) {
return MeasureText.measureTextWidth(text, fontSize, fontWeight);
}
这个模块很小,但解决了一个实际大问题:没有它,RN侧几乎没法准确拿到一段格式化文本的真实宽度,也就很难在光标附近定位浮动工具条的位置。
这里面还有一个容易被忽略的问题:原生模块返回值的类型限制。在RNOH上,TurboModule的返回值最好只使用基础类型和JSON序列化对象,别指望直接回传一个Native View实例。你是去拿数据,不是去搬控件。
2.3 从RN原版迁移,先过“N-API兼容性”这一关
RNOH官方维护了一份兼容性文档,但实际项目里,真正浪费时间的不是文档里列出的已知问题,而是没有列出的边界行为。我遇到的例子:
InteractionManager.runAfterInteractions在RNOH上有时不会在交互结束后触发,导致工具栏动画卡顿后逻辑没执行。Keyboard.dismiss()在鸿蒙上不是总能立即收起键盘,需要额外调用Keyboard.dismiss()两次,或者配合TextInput.blur()。- 部分动画API不支持
useNativeDriver: true,必须改成JS驱动,性能下降不少。
这些差异没法通过代码审查提前察觉,只能靠真机/模拟器逐项验证。我的建议是:在项目启动的第一周,把团队要用的RN API列表拉出来,逐项打勾,做成一个“RNOH兼容性自检清单”。后面再踩坑,就在清单里补一条。
3. 富文本编辑器的三种技术路线,鸿蒙上怎么选
3.1 路线对比:WebView、原生控件、TextInput自绘
富文本编辑器在跨端方案里有很多成熟做法,但鸿蒙上每种路线都有明显限制。
路线一:WebView + HTML编辑器
这是前端和后端开发者最熟悉的路线。用react-native-webview加载一个本地HTML编辑器(Quill、wangEditor、TipTap),通过postMessage和RN通信,格式化逻辑完全在WebView内部完成。
优点很明显:跨平台一致性好,图片、表格、链接、列表都能做,前端生态丰富。但鸿蒙上的问题也很突出:
- RNOH的WebView组件本身还在完善中,webview的键盘输入、焦点管理、滚动嵌套都可能有bug。
- WebView里的编辑器,在国产ROM的输入法适配上有天然劣势,尤其鸿蒙的输入法策略和Android不完全一致,中文输入时偶尔出现候选词不刷新。
- 性能上,长文档在WebView里的滚动掉帧,体验和原生差距明显。
路线二:完全原生控件
直接基于鸿蒙的TextInput或RichEditor组件实现,需要ArkTS直接参与渲染和交互。这个方案质量上限最高,但工作量大到和RN没有关系,基本属于“要么你深度参与RNOH的鸿蒙组件开发,要么放弃RN”。
路线三:TextInput自绘+分段渲染
核心思路是:用一个普通的多行TextInput作为输入容器,但不在TextInput内做任何富文本样式。用户在输入区写的所有内容,先在JS层转换成带标记的JSON结构,再通过嵌套的RN Text组件渲染一个“预览副本”,输入区本身保持纯文本。格式化操作通过工具栏按钮作用到当前选中范围,修改的是JS层的数据结构,再同步刷新预览。
这个路线牺牲了一部分“所见即所得”的直接性,但它完美避开了RNOH对TextInput富文本支持不足的问题。我的最终实现就是基于这个路线。
3.2 为什么“所见非所得”反而更容易落地
很多人一听“所见即所得”就兴奋,但在跨端组件不成熟的阶段,“所见即所得”等于“所见皆坑”。
我用一个反直觉的结论来概括:在RNOH上做富文本编辑器,越是追求原生渲染的一致性,越要接受“编辑区纯文本、预览区富文本”的分离模式。
这么做有几个实际好处:
- 彻底绕开光标问题。富文本编辑器的光标管理是老大难,尤其多段不同样式的文本,光标在样式边界处的行为很难统一。纯文本输入框的光标行为是系统标准行为,基本不需要额外处理。
- 数据结构完全可控。我们不直接操作HTML字符串,而是维护一个段块数组,每个段块带自己的样式标记。这种结构天然适合服务端存储,也方便做撤销重做。
- 工具栏操作逻辑简单清晰。加粗不是去修改DOM或TextView的属性,而是修改当前段块的Markdown标记,再触发预览层重渲染。
当然,代价也很直观:编辑区看起来就是普通文本框,用户在输入时看不到样式,出现“完成状态预览”和“编辑状态内容在视觉上割裂”的情况。我的处理方式是,在编辑区下方直接铺一个“实时预览卡片”,工具栏每次操作后自动滚动到预览位置。用得习惯了,用户其实不会有太多抱怨。
3.3 第三方富文本库能用吗
这个问题的答案随着RNOH迭代会变,但至少在我的实践节点上,结论是:绝大多数面向React Native的富文本库,不能直接平滑使用。
比较典型的几个:
react-native-slate:底层依赖一套自定义文本布局引擎,对TextInput内部实现要求极高,RNOH上跑不起来。react-native-pell-rich-editor:基于WebView实现,理论上RNOH能跑,但键盘输入和焦点管理需要大量适配,而且它很久没维护了。draft-js+ 自定义RN渲染:这是纯JS层的方案,理论上能跑,但它默认假设了DOM,需要大量shim,门槛高,不推荐。wangEditor、Quill、TipTap:这些是Web编辑器,必须放进WebView里,属于路线一的范畴。
所以我最终的结论是:现阶段RNOH上做富文本,不要依赖现成库,自己写一个轻量级分段渲染编辑器,反而是总成本最低的路径。
4. 核心实现:基于TextInput + 分段渲染的富文本编辑器
4.1 数据结构:先把内容模型定下来
我采用的模型是“段块数组(Block Array)”,每个段块包含一段连续文本和样式标记:
typescript复制type BlockType = 'paragraph' | 'heading1' | 'heading2' | 'blockquote' | 'listItem';
interface TextStyle {
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
link?: string;
}
interface TextBlock {
id: string;
type: BlockType;
text: string;
textStyle?: TextStyle;
}
为什么不用嵌套层级模型?因为太麻烦。Rich Text的标准做法是树形结构,但编辑器的光标操作在树形结构里需要处理父子关系、兄弟关系、嵌套越界,复杂度和bug率都指数上升。
段块数组是扁平结构,每个段落都是独立的块,样式只作用于块内文本,这样:
- 新增一个标题段块,就是插入一个
type: 'heading1'的块; - 对某段文本加粗,就是修改对应块的
textStyle.bold; - 撤销重做,只需要保存历史块数组快照,不用处理复杂树的差异。
注意:如果后续要支持“段内部分文字加粗、部分普通”,这个模型就不够了。你需要在
TextBlock里引入runs: Array<{ text: string; textStyle: TextStyle }>,也就是把一段文本拆成多个带样式的片段。这个扩展在第二步做很自然。
4.2 数据到渲染:嵌套Text组件实现富文本
这里有一个常见误解:很多人以为RN里展示富文本必须用WebView或第三方库。实际上,在原生的RN中,Text组件是支持嵌套的,这个能力在RNOH下也保留着。
嵌套Text的核心用法是:
tsx复制<Text style={block.type === 'heading1' ? styles.heading1 : styles.paragraph}>
{block.textStyle.bold ? <Text style={styles.bold}>{block.text}</Text> : block.text}
</Text>
但这只能处理最简情况。要支持“一段文本中部分加粗、部分斜体、部分链接”,需要把块内内容拆成runs数组,再逐个渲染:
tsx复制<Text style={blockStyle}>
{runs.map((run, index) => (
<Text
key={index}
style={[
run.textStyle.bold && styles.bold,
run.textStyle.italic && styles.italic,
run.textStyle.link && styles.link,
]}
onPress={run.textStyle.link ? () => openUrl(run.textStyle.link) : undefined}
>
{run.text}
</Text>
))}
</Text>
这套API从RN 0.60之后就很稳定,RNOH沿用了。所以你不用怀疑“鸿蒙上能不能支持”,只要RNOH的Text组件实现了嵌套渲染,这条路就是通的。
4.3 编辑区与预览区的状态同步
我的结构是这样的:
tsx复制const [blocks, setBlocks] = useState<TextBlock[]>([]);
const [draftText, setDraftText] = useState('');
const syncDraftToBlocks = useCallback((text: string) => {
// 把纯文本按换行拆成段落块,保留原有的样式状态
const lines = text.split('\n');
setBlocks(prevBlocks => {
return lines.map((line, index) => {
const prevBlock = prevBlocks[index];
return {
id: prevBlock?.id ?? generateId(),
type: prevBlock?.type ?? 'paragraph',
text: line,
textStyle: prevBlock?.textStyle ?? {},
};
});
});
}, []);
这个设计的关键点在于:编辑区的draftText只负责输入,真正驱动预览区的blocks才是数据源。每次输入都触发一次syncDraftToBlocks,把纯文本重排成块结构,再做节流渲染。
然后预览区:
tsx复制<View style={styles.previewContainer}>
{blocks.map(block => renderBlock(block))}
</View>
这个方案的实时性非常强,因为输入的同时预览就在刷新。如果觉得每次按键都重排性能压力大,可以加一个InteractionManager.runAfterInteractions或者setTimeout做300ms内的防抖。
4.4 工具栏交互与选区控制
工具栏是富文本编辑器的灵魂。我的方案里,工具栏按钮点击后需要做三件事:
- 获取编辑区
TextInput当前选择范围; - 把选中文本从
draftText中取出,应用样式标记,并替换回draftText; - 同步更新对应的
blocks,让预览层刷新。
重点是第二步,这地方容易踩的坑是:直接修改受控value会让光标跳动。
我的解法是:不用受控value,改用uncontrolled + ref。
tsx复制const inputRef = useRef<TextInput>(null);
const applyStyle = (style: TextStyle) => {
// 通过 ref 拿到当前输入框的 selection
const selection = getSelectionFromNative(inputRef.current);
if (!selection || selection.start === selection.end) {
// 没有选区,仅设置“后续输入将应用该样式”的模式
return;
}
const selectedText = draftRef.current.substring(selection.start, selection.end);
// 从 blocks 中找到当前选区对应的块,修改样式
updateBlockStyleByRange(selection.start, selection.end, style);
// 重新渲染
syncBlocksToDraft();
};
为了稳定拿选区,这里有一点和RN原版不一样:RNOH的onSelectionChange不一定能同步到每次拖动后的值。我在某些鸿蒙版本上发现,快速拖动光标时,selection事件只上报首尾位置,中间过程会丢。所以如果你要做类似Notion那种光标悬浮工具条,就得降低预期,等手指抬起后再获取最终选区。
还有一个细节:编辑器下方预览区里的内容,要从语义上映射到用户选中的内容。否则会出现“选中一段文本,点击加粗,结果预览区里高亮错行”的尴尬情况。我推荐用selection.start和selection.end换算到blocks的索引范围,而不是用字符串拼接。
这个换算逻辑是富文本编辑器里最绕的一部分,建议单独抽一个纯函数,并写单元测试。
4.5 从纯文本到Draft的“反向同步”
用户除了通过工具栏操作格式,还可能在编辑区直接删除或重排段落,这时需要把最新的draftText重新映射回blocks。这个反向同步是最容易出bug的地方。
我需要明确几个原则:
- 每个段落块在同步时,如果它的行号没变,尽量保留原有的
type和textStyle。 - 如果某一段被删除,删除之后的所有段落块类型保持不变,但文本内容要更新。
- 如果用户手动输入了Markdown符号(比如输入
#表示标题),需要识别并自动转换。
这个自动转换逻辑可以在onChangeText里拦截:
typescript复制const handleChange = (text: string) => {
const lines = text.split('\n');
const lastLine = lines[lines.length - 1];
if (/^#\s/.test(lastLine)) {
// 把最后一行转为标题类型,去掉 '# ' 前缀
transformLastLineToHeading();
return;
}
syncDraftToBlocks(text);
};
4.6 选择器的坑:onSelectionChange和focus的配合
如果编辑器是放在一个长页面里,你就可能遇到“点击编辑器后滚动页面、光标位置错乱”的问题。RNOH下,TextInput的焦点管理和Android原生有一些差异,实测下来,我觉得最稳妥的配合方式是:
tsx复制<TextInput
ref={inputRef}
multiline
style={styles.input}
onFocus={() => {
requestAnimationFrame(() => {
// 确保焦点后再获取一次selection
});
}}
onSelectionChange={e => {
selRef.current = e.nativeEvent.selection;
}}
/>
selRef是一个useRef,所有需要选区信息的地方统一从selRef取,避免直接从Event对象拿而拿到的可能是过期值。这个小改动让工具栏从按钮点击到选择区间的延迟从“不可控”变成“可预测”。
提示:避免在
onSelectionChange里直接调用setState,不然每次光标移动都会触发整个预览区重渲染。用ref保存选区,只有工具栏操作时才去读它。
5. 实测:性能、白屏和内存的真实表现
5.1 启动白屏问题,先排查再优化
“React Native启动白屏”在这几个月的搜索热度很高,RNOH下尤其严重。我第一次在鸿蒙模拟器上跑富文本编辑器Demo时,白屏时间大约3到5秒,这还不包括模拟器本身的启动时间。
排查白屏,我建议按顺序做:
- 确认JS Bundle加载路径。如果是Debug模式连Metro,白屏时间主要取决于Metro编译速度和USB传输速度。尽量把Metro和DevEco Studio跑在同一台机器,或者用
adb reverse反向转发端口。 - 确认原生侧的RootView加载时机。RNOH工程在MainAbility的
onWindowStageCreate里加载RN页面,如果原生页面还在做其他初始化,RN启动就会被阻塞。 - 确认是否缺少覆盖启动图。鸿蒙的冷启动首帧如果是空白,会被误判为白屏。加一张启动图的占位背景可以明显改善体验。
实测下来,Release包的白屏时间比Debug包缩短60%以上,所以正式环境不要用Debug模式连Metro发布。
5.2 长文档滚动性能:越复杂的Markup越卡
我做了一个压力测试:生成一篇1万字、带标题和加粗、链接的文章,在鸿蒙模拟器(arm64)上滚动。
结果:前2000字很流畅,5000字开始有明显掉帧,1万字时滚动像在泥里走。
原因不复杂:预览区渲染的是嵌套Text组件,每次setState都会让整棵Text树重渲染。优化方向有几种:
- 只渲染可视区域。这需要把整个文档按高度分页,复杂度高,不推荐第一期做。
- 减少重渲染频率。工具栏操作后只更新对应的block节点,而不是整个blocks数组。可以用
useMemo按block id缓存子组件。 - 用
memo包裹每个Block的渲染组件,避免无关块更新。
我用第三种方案后,1万字文档的滚动虽然谈不上丝滑,但基本达到了可用级别。
还有个容易被忽略的性能杀手:富文本里的链接onPress绑定。每个链接都是一个独立的Pressable或Text的onPress事件,如果一篇文章有几十个链接,挂载事件监听的开销不可小觑。我的做法是:预览区里的链接默认不响应点击,只有用户进入“预览模式”或“编辑模式”时,才动态开启链接点击。
5.3 键盘避让和焦点管理
RNOH下,键盘避让用KeyboardAvoidingView是有效的,但要设置对behavior。我的经验是:
tsx复制<KeyboardAvoidingView
behavior="padding"
keyboardVerticalOffset={headerHeight}
style={styles.container}
>
注意不要把keyboardVerticalOffset设死,不同鸿蒙设备系统导航栏高度不一样。一个可行的做法是动态获取:
typescript复制import { useSafeAreaInsets } from 'react-native-safe-area-context';
const insets = useSafeAreaInsets();
由于键盘是软键盘,底部高度会随输入法变化,RNOH的键盘事件有时会延迟上报。实测发现,在部分华为输入法上,键盘弹起时keyboardDidShow事件会晚大约200ms,这会导致避让动作一卡一卡。解决方案是加一个150ms的“缓冲期”,在键盘事件到达前先给页面加一个内边距。
6. 还想支持图片和Markdown?下一步的扩展思路
6.1 图片插入的折中做法
富文本编辑器如果完全不支持图片,基本等于废了一半。但RNOH下,TextInput内部无法直接嵌入Image组件,你不可能让图片“插在光标位置”。
我的折中做法是:图片作为独立块存在,插入图片时,将当前段落块之后插入一个image类型的块:
typescript复制interface ImageBlock {
id: string;
type: 'image';
uri: string;
width: number;
height: number;
}
预览区里,这个块渲染为<Image>,编辑区里,这个块渲染为一个只读的占位文本[图片]。这样,用户看到编辑区里的占位文本,预览区里是真实图片。
图片选择使用ArkTS侧的能力,通过@ohos.file.picker自行实现模块,也可以通过react-native-image-picker的RNOH兼容版。我的实践是用后者,省事很多。
图片大小需要处理。RNOH的图片加载对超大尺寸图片支持不好,直接展示会内存溢出。建议上传前先做压缩,限制最长边不超过2000px。
6.2 导出和导入:HTML与Markdown
编辑器数据结构是段块数组,服务端存储时,通常需要转成HTML或Markdown。这两个转换是纯JS逻辑,可以在前端直接完成。
Markdown导出更简单,RNOH上也能直接用一个轻量库,例如markdown-it或自己写转换器。
HTML导出需要自己做标签映射。我的做法是:
typescript复制function blockToHtml(block: TextBlock): string {
const content = escapeHtml(block.text);
switch (block.type) {
case 'heading1': return `<h1>${content}</h1>`;
case 'listItem': return `<li>${content}</li>`;
case 'blockquote': return `<blockquote>${content}</blockquote>`;
default: return `<p>${content}</p>`;
}
}
导入端也一样,从HTML取回段块时,注意过滤器标签白名单,防止XSS和数据污染。富文本编辑器最怕的就是存了一堆危险HTML,用户在前端展示时弹一堆脚本。虽然RNApp不执行HTML,但同步到Web端后就是大问题。
6.3 向WebView方案切换的预案
还有一种可能是:你的团队在鸿蒙端只做一个临时版本,未来核心还是放在Web端编辑器。那就不妨做好预案,把“分段渲染方案”的数据格式与“WebView方案”的数据格式统一。
我的做法是:约定一个统一的JSON schema,不管前端是RNOH的TextInput还是WebView里的Quill,都通过这个schema读写数据。这样,即使后续要换编辑器内核,前端数据层不用大改,只需要同步schema的解析器。
这个preparation看起来像是过度设计,但等到产品说要支持“多人协同”时,你会发现统一schema的价值无可替代。因为协同编辑的核心就是操作变换(OT/CRDT),而OT的每一步都要基于统一的数据结构。
7. 如果重新做一遍,我会怎么做
这是一个复盘段落,不是敷衍的“总结”。我按真实价值排序,说说哪些环节的优先级应该更高。
第一,在一开始就把schema定成runs结构,而不是先做扁平Block。我踩过的坑就是,第一版用块级样式,导致一个段落里无法混排不同样式,后来不得不增加runs结构,改造成本不小。如果一开始数据结构定义到位,后面很多扩展都是水到渠成。
第二,先把“反向同步”的测试写透。用户编辑、删除、粘贴、批量替换,这些场景在UI上看起来差不多,但数据结构的变化路径差异很大。一个错误的块映射会导致预览区内容错乱且很难定位。现在我的团队会把draftToBlocks和blocksToDraft两个纯函数作为最核心的UT,每次改动都跑一遍。
第三,不要过早做“所见即所得”。这是方向性错误,至少在RNOH当前阶段,这个方向会让你陷入无底洞。分段渲染方案虽然视觉上不够华丽,但稳定性和可维护性都远高于强绑定的方案,对我来说这就赢了。
最后,如果还有人问“能不能直接在鸿蒙上用TextInput做富文本编辑器”,我的回答是:能,但你要先接受它不可能做到和Web端完全一致的体验,再从合适的数据结构、合适的渲染模式入手,一步一个脚印地做。把“所见所得”的体验留给后续版本,这个不急。
