如何把html字符串转成一个React组件?

dangerouslySetInnerHTML

react提供了dangerouslySetInnerHTML属性,把html字符串转成react元素:

image

安全性

通常来讲,直接设置dangerouslySetInnerHTML存在风险,因为很容易无意中使用户暴露于跨站脚本(XSS) 的攻击。因此,你可以直接在 React 中设置 HTML,但当你想设置 dangerouslySetInnerHTML 时,需要向其传递包含 key 为 __html 的对象,以此来警示你。

正如上面所说,直接使用dangerouslySetInnerHTML存在xss的风险

所以我们需要先对html字符串进行过滤、转换,再通过React.createElement()把字符串转成React组件。如果需要自己去实现这一步骤的话,可能会比较麻烦(因为还涉及字符串转dom、属性转React属性等操作)。

下面会介绍一个实现这个功能的库htmr和内部原理。

htmr

简单轻便的HTML字符串到组件的转换库。

安装这里不会介绍,如果要用到自己去npm上看文档。

在介绍内部原理前,我们需要先看看如何使用,方便对代码内变量和方法的解读。

使用

htmr接收两个参数,html字符串和一个配置对象options。

  • html:string
  • options:Partial<HtmrOptions>={}

下面着重介绍下HtmrOptions里面各个属性:

  • preserveAttributes:Array<String | RegExp> - 默认情况下,htmr会将符合要求的html属性转换为React要求的驼峰式属性,如果某些属性不想转换,可以通过该属性来阻止React这个行为。
  • transform - 接受键值对,这些键值对将用于 将节点(键)转换为自定义组件(值),可以使用它来通过自定义组件呈现特定的标签名称。

例如,下面这个例子。
定义了transform对象,目的是把p标签转成Paragraph组件,把a标签转成span标签:

const transform = {
        p: Paragraph,
        a: 'span'
}

htmr('<p><a>Hello, world!</a></p>', {transform})
// 结果 => <Paragraph><span>Custom component</span></Paragraph>

transform里面有一个参数叫做defaultTransform, 以符号 _表示,它接受的参数跟React.createElement一致。这个参数非常有用,例如可以在富文本里面处理图片,把图片转成我们自定义的图片组件:

const transform = {
  // 参数跟React.createElement一致
    _: (nodeName, props, children) => {
    if(nodeName === 'img) {
        let src = props.src;
      return <Image src={src}>
    }
    return React.createElement(nodeName, props, children);
  }
}

transform里面还有一个参数叫 dangerouslySetChildren ,出于安全原因,默认情况下,htmr仅将危险标签内的样式标记的子项呈现在危险地设置为InnerHTML中。
例如,下面例子设置dangerouslySetChildren:['code']:

const html = '<div><code><span>xxx</span></code></div>'
htmr(html, { dangerouslySetChildren: ['code'] });

// <div><code dangerouslySetInnerHTML={{__html: encode('<span>xxx</span>')}}>

工具函数

hypenColonToCamelCase

把带中划线或者冒号的字符串转成驼峰式,如 color-profile => colorProfile,xlink:role => xlinkRole 。

function hypenColonToCamelCase(str: string): string {
  return str.replace(/(-|:)(.)/g, (match, symbol, char) => {
    return char.toUpperCase();
  });
}

convertValue

数字字符串转成数字类型,单引号转双引号。

  function convertValue(value: string): number | string {
    if (/^\d+$/.test(value)) {
      return Number(value);
    }
  
    return value.replace(/'/g, '"');
  }

convertStyle

把行内样式字符串转成StyleObject类型:

function convertStyle(styleStr: string): StyleObject {
  const style = {} as StyleObject;

  styleStr
    .split(';')
    .filter(style => style.trim() !== '')
    .forEach(declaration => {
      const rules = declaration.split(':');
      if (rules.length > 1) {
        // 属性名
        const prop = hypenColonToCamelCase(rules[0].trim());
        const val = convertValue(
          rules
            .slice(1)
            .join(':')
            .trim()
        );
        style[prop] = val;
      }
    });

  return style;
}

内部原理

htmlServer

我们在上面例子用到的htmr函数其实就是htmlServer,它主要做了两件事情:

  1. html字符串转成dom;
  2. 对dom所有节点做转换成符合要求的ReactElement;
export default function htmrServer(
  html: string,
  options: Partial<HtmrOptions> = {}
) {
  if (typeof html !== 'string') {
    throw new TypeError('Expected HTML string');
  }

  const doc = parseDocument(html.trim(), {});  // 1.
  const nodes = doc.childNodes.map((node, index) =>  // 2.
    toReactNode(node, index.toString(), options)
  );
  return nodes.length === 1 ? nodes[0] : nodes;
}

htmlServer用到一个parseDocument方法,它是 htmlparser2导出的一个函数,能把html字符串转化成dom:

  import { parseDocument } from 'htmlparser2';

toReactNode

顾名思义,toReactNode是把dom转成ReactNode,也是这个库的核心。
根据dom节点的type属性,做了分类处理:


图片.png

如果type 的值是 'script' 、'style' 和 'tag' 其中之一,执行如下操作:

  1. 解码所有属性值;
  2. 执行mapAttribute(把属性转成React属性);
  3. 根据transform转化标签;
  const node: HTMLNode = childNode as any;
      const { name, attribs } = node;

      // decode all attribute value
      Object.keys(attribs).forEach((key) => {
        attribs[key] = decode(attribs[key]);
      });

      const props = Object.assign(
        {},
        mapAttribute(name, attribs, preserveAttributes, getPropName),
        { key }
      );

      /**
       * const transform = {
       *   p: Paragraph,
       *   a: 'span',
       * };
       * 例如把 p标签转成 Paragraph标签,a转成span
       */
      const customElement = transform[name];
  1. 判断当前标签是否在dangerouslySetChildren列表,是的话塞到dangerouslySetInnerHTML
if (dangerouslySetChildren.indexOf(name) > -1) {
    // Tag can have empty children
    if (node.children.length > 0) {
      const childNode: TextNode = node.children[0] as any;
      const html =
        name === 'style' || name === 'script'
          ? // preserve encoding on style & script tag
            childNode.data.trim()
          : encode(childNode.data.trim());
          
      props.dangerouslySetInnerHTML = { __html: html };
    }

    return customElement
      ? React.createElement(customElement as any, props, null)
      : defaultTransform
      ? defaultTransform(name, props, null)
      : React.createElement(name, props, null);
  }
  1. 对children节点执行toReactNode;
  2. 如果存在transform,转化成对应ReactElement并返回;
  3. 如果存在defaultTransform ,调用defaultTransform 并返回;
  4. 如果不存在transform和defaultTransform,执行React.createElement;
// 5.
const childNodes = node.children
.map((node, index) => toReactNode(node, index.toString(), options))
.filter(Boolean);
// self closing component doesn't have children
const children = childNodes.length === 0 ? null : childNodes;

// 6.
if (customElement) {
    return React.createElement(customElement as any, props, children);
}

// 7.
if (defaultTransform) {
    return defaultTransform(name, props, children);
}

// 8.
return React.createElement(name, props, children);

如果type是'text',则处理很简单:

const node: TextNode = childNode as any;
let str = node.data;

if (node.parent && TABLE_ELEMENTS.indexOf(node.parent.name) > -1) {
  str = str.trim();         
  if (str === '') {
    return null;
  }
}

str = decode(str);
return defaultTransform ? defaultTransform(str) : str;

接下来,了解一下第2步提到的mapAttribute是如何把html属性转成React属性的。

mapAttribute

首先,先贴上代码:

  Object.keys(attrs).reduce((result, attr) => {
    // 1
    if (/^on.*/.test(attr)) {
      return result;
    }

    // 2
    let attributeName = attr;
    if (!/^(data|aria)-/.test(attr)) {
      // Allow preserving non-standard attribute, e.g: `ng-if`
      const preserved = preserveAttributes.filter(at => {
        if (at instanceof RegExp) {
          return at.test(attr);
        }

        return at === attr;
      });

      if (preserved.length === 0) {
        attributeName = hypenColonToCamelCase(attr);
      }
    }
     
     // 3
     const name = getPropName(originalTag, attributeName);
     
     // 4 
     if (name === 'style') {
      result[name] = convertStyle(attrs.style!);
    } 
    
    // 5
    else {
      const value = attrs[attr]
      const isBooleanAttribute = value === '' || String(value).toLowerCase() === attributeName.toLowerCase();
      result[name] = isBooleanAttribute ? true : value;
    }

    return result;
 }

从代码分析:

  1. 通过正则/^on.*/.test(attr)判断是否内联事件,如果是则忽略掉(所有内联事件都不会生效)。
  2. 转化除了data-和aria- 并且不在preserveAttributes 数组内的属性成驼峰式。
  3. 把html属性转化为符合React规范的属性,具体如何转化的下面提供了一个JSON文件:
{
  "for": "htmlFor",
  "class": "className",
  "acceptcharset": "acceptCharset",
  "accesskey": "accessKey",
  "allowfullscreen": "allowFullScreen",
  "autocomplete": "autoComplete",
  "autofocus": "autoFocus",
  "autoplay": "autoPlay",
  "cellpadding": "cellPadding",
  "cellspacing": "cellSpacing",
  "charset": "charSet",
  "classid": "classID",
  "classname": "className",
  "colspan": "colSpan",
  "contenteditable": "contentEditable",
  "contextmenu": "contextMenu",
  "crossorigin": "crossOrigin",
  "datetime": "dateTime",
  "enctype": "encType",
  "formaction": "formAction",
  "formenctype": "formEncType",
  "formmethod": "formMethod",
  "formnovalidate": "formNoValidate",
  "formtarget": "formTarget",
  "frameborder": "frameBorder",
  "hreflang": "hrefLang",
  "htmlfor": "htmlFor",
  "httpequiv": "httpEquiv",
  "inputmode": "inputMode",
  "keyparams": "keyParams",
  "keytype": "keyType",
  "marginheight": "marginHeight",
  "marginwidth": "marginWidth",
  "maxlength": "maxLength",
  "mediagroup": "mediaGroup",
  "minlength": "minLength",
  "novalidate": "noValidate",
  "radiogroup": "radioGroup",
  "readonly": "readOnly",
  "rowspan": "rowSpan",
  "spellcheck": "spellCheck",
  "srcdoc": "srcDoc",
  "srclang": "srcLang",
  "srcset": "srcSet",
  "tabindex": "tabIndex",
  "usemap": "useMap",
  "viewbox": "viewBox"
}
  1. 转行内样式成StyleObject;
  2. 转化布尔属性
    什么是布尔属性❓


    图片.png

总结

htmr内部对html字符串进行dom转换,接着递归遍历所有节点,对节点(和属性)过滤、转换,再通过React.createElement()把字符串转成React组件。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容