Draft.js在后台系统的应用实践

Posted Qunar技术沙龙

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Draft.js在后台系统的应用实践相关的知识,希望对你有一定的参考价值。

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在后台系统的应用实践的主要内容,如果未能解决你的问题,请参考以下文章

替换或删除后台堆栈上现有片段的代码不起作用

react-draft-wysiwyg富文本编辑器使用心得

elementUI,iview开发后台管理系统的最佳实践是怎样的

Draft.js一个用React实现的富文本编辑器

Draft.js —— 基于 React 的富文本编辑框架(Facebook 出品)

将 contentState 渲染到编辑器后,Draft.js 提及插件不起作用