Draft.js在后台系统的应用实践
Posted Qunar技术沙龙
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Draft.js在后台系统的应用实践相关的知识,希望对你有一定的参考价值。
严皓亮
去哪儿旅游度假事业部前端工程师,一直从事移动前端开发工作,热衷于新技术的探索与实践,致力于提供更好的用户体验与更高的开发效率。
Draft.js 是 facebook 推出的基于 React 的富文本编辑器构建框架。
Draft.js 的使用场景
我们遇到的场景有以下特点:
文章带段落与简单格式,数据存储、传输、展示要求格式化,编辑过程交互类富文本
支持插入图片、产品信息等结构化数据
整体项目基于React技术栈
Draft.js 进入我们的视线主要有几个方面原因:
Draft.js 的实现是纯函数式的,编辑器的状态都被封装到 EditorState 类型的 immutable 对象中,一旦内容确定,编辑器的状态也是确定的
Draft.js 仅提供了丰富的操作编辑器状态的方法,业务代码可以灵活定制其中的功能,使得我们方便进行编辑器状态的控制,避免引入非必须样式信息
Draft.js 可以方便控制各快捷键、粘贴、拖拽等操作带来的副作用,尽可能保持编辑器状态“干净”。
总的来说,使用 Draft.js 类似于声明一个 input 框:
class QEditor extends React.Component {
constructor(props) {
super(props);
this.state = {editorState: EditorState.createEmpty()};
this.onChange = editorState => this.setState({editorState});
}
render() {
return <Editor editorState={editorState} onChange={this.onChange} />
}
}
相较传统的富文本编辑器,Draft.js 更像一个 “状态翻译机”,每一次的用户操作都会对应到 editorState 的更新,而 Draft.js 充当的就是从状态到展现的翻译;同时,如果禁用了状态更新的反馈,那么 Draft.js 也可以退化为一个展示组件。
EditorState 与 ContentState
EditorState 是如何对编辑器的状态进行封装的呢?如下图所示
基本概念
一个 editorState 实例包含核心的 currentState 状态,其它属性记录了编辑器的修改历史等信息,我们着重关注 currentState 这个 ContentState 的实例。一个 ContentState 包含一个 ContentBlock 的集合 (Immutable.OrderedSet) 以及一个实体 Map(Immutable.Map); 我们选择性遗忘 inlineStyleRanges,因为 inlineStyleRanges 用来表达行内样式,而我们需要的格式化文本只存在加粗文本段落与普通文本段落。
{
"entityMap": {
"0": {
"type": "PRODUCT",
"mutability": "IMMUTABLE",
"data": {
"thumb": "*.png",
"productType": "跟团游",
"title": "#北极探梦之旅#芬兰",
"score": 5,
"soldCount": 27,
"price": 12000,
"id": "3918890467"
}
},
"1": {
"type": "IMG",
"mutability": "IMMUTABLE",
"data": {
"url": "**.jpg",
"smallUrl": "**.jpg",
"baseUrl": "**.jpg"
}
}
},
"blocks": [
{
"key": "55nji",
"text": " ",
"type": "atomic",
"depth": 0,
"entityRanges": [
{
"offset": 0,
"length": 1,
"key": 0
}
],
"data": {}
},
{
"key": "e2od7",
"text": "Hello Draft.js",
"type": "unstyled",
"depth": 0,
"entityRanges": [],
"data": {}
},
{
"key": "a3aql",
"text": " ",
"type": "atomic",
"depth": 0,
"entityRanges": [
{
"offset": 0,
"length": 1,
"key": 1
}
],
"data": {}
}
]
}
文本段落 (加粗段落)通过 type 来标识文本类型,text 包含不带样式的文本内容,entityRanges 为空;自定义段落 (图片与产品)的 type 为 atomic,text 为空,对应的实体内容由 entityRanges 映射到 entityMap 中的实体对象;entityMap 中的 Entity 实例包含 typetype,mutability,data 三个字段,其中 mutability 表达是否可编辑,另两个字段分别为自定义的实体类型与数据。
自定义模块渲染
draft.js 通过 blockRendererFn 方法进行自定义模块渲染。blockRendererFn 方法接受 block 实例作为参数,根据自定义的类型与数据集,可以返回自定义的组件实例,从而实现自定义的内容。
function entityBlockRenderer(block) {
if(block.getType() === 'atomic') {
return {
component: Entity,
editable: false,
props: {
key: block.key
}
};
}
}
我们只针对类型为 atomic 的块自定义渲染,并且在 Entity 的构造方法中选择对应的实例进行渲染。因此,对于自定义的内容块,展示内容完全由我们的代码进行控制。
数据转化
Draft.js 提供了 draft.js 与 html 之间的转换方法,由于我们的文章为格式化文本,因此需要自己定义加载与导出的方式。从 ContentState 到 Object 的实现比较简单,只要遍历上述的 block 列表,按照不同的 block 类型处理即可。如下所示:
function convertToArticle(contentState) {
const blocks = contentState.getBlocksAsArray();
return blocks.map(block => {
let text;
let type = 'TEXT';
switch (block.type) {
case 'header-two':
type = 'STRONG_TEXT';
case 'unstyled':
text = block.text.trim();
if (text) {
return {
type: type,
content: text
};
} else {
return null; // 去除空行
}
case 'atomic':
let entityKey = block.getEntityAt(0);
if(!entityKey) {
return null;
}
let entity = contentState.getEntity(entityKey);
return {
type: entity.type,
content: entity.data
};
default:
return null;
}
}).filter(item => !!item);
}
从原始内容转化为 editorState 略显复杂。我们需要构造每一块的内容,然后通过 ContentState.createFromBlockArray 方法创建一个新的编辑状态。比较难理解的属性是 characterList,它是一个 CharacterMetadata 的列表,用来定义每一个字符的样式与实体内容信息。对于文本块,由于我们的内容都不包含行内样式,所以可以简单地通过创建内容长度个空样式来实现,同时文本快不包含实体,可以直接置空;自定义内容块由于不可编辑,所以可以视为长度为 1,同样只需要传入空样式,并且在对应的位置置入实体 ID。Entity 通过 createEntity 方法进行构造,IMMUTABLE 表示不可编辑。另外,对于图片等内容,为了便于光标控制等,我们手动在实体内容后插入了一个空行。
function convertFromArticle(article) {
const emptyEditor = EditorState.createEmpty();
let contentWithEntity = emptyEditor.getCurrentContent();
return {
contentBlocks: article.reduce(function(last, paragraph) {
let type = 'unstyled';
let characterList;
switch (paragraph.type) {
case 'IMG':
case 'PRODUCT':
contentWithEntity = contentWithEntity.createEntity(paragraph.type, 'IMMUTABLE', paragraph.data);
characterList = List([CharacterMetadata.create({
style: OrderedSet(),
entity: contentWithEntity.getLastCreatedEntityKey()
})]);
return last.concat([new ContentBlock({ // 自定义内容块
key: genKey(),
type: 'atomic',
depth: 0,
text: '',
characterList: characterList
}), new ContentBlock({ // 内容块后插入空行,方便用户操作
key: genKey(),
type: 'unstyled',
text: '\r',
depth: 0,
characterList: List([CharacterMetadata.create([{
style: OrderedSet(),
entity: null
}])])
})]);
case 'STRONG_TEXT':
type = 'header-two';
case 'TEXT':
characterList = List(new Array(paragraph.data.length).fill('').map(() => {
return CharacterMetadata.create({
style: OrderedSet(),
entity: null
});
}));
last.push(new ContentBlock({
key: genKey(),
type: type,
depth: 0,
text: paragraph.data,
characterList: characterList
}));
return last;
}
}, []),
entityMap: contentWithEntity.getEntityMap()
};
}
钩子的使用
由于我们的功能需求并不完全符合富文本编辑的标准,例如在加粗文本块按回车后,需要将加粗样式置空,此时可以使用 Draft.js 提供的一系列钩子。各钩子通过 handleKeyCommand 属性方法暴露。
handleKeyCommand(command, editorState) {
switch (command) {
case 'split-block':
setTimeout(() => {
// 换行时,需要将加粗状态去除
const {editorState} = this.state;
this.onChange(
RichUtils.toggleBlockType(editorState, 'unstyled')
);
}, 0);
break;
}
return false;
}
小结
至此,我们已经完成了一个支持自定义内容的新建与导入编辑的适用于 React 的富文本编辑器。我们将进一步发掘 Draft.js 的特性,以应对更加细粒度的富文本编辑场景。
以上是关于Draft.js在后台系统的应用实践的主要内容,如果未能解决你的问题,请参考以下文章
elementUI,iview开发后台管理系统的最佳实践是怎样的