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

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

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

下面会介绍一个实现这个功能的库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,它主要做了两件事情:

  • html字符串转成dom;
  • 对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属性,做了分类处理:

    // 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];
  • 判断当前标签是否在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);
    
  • 对children节点执行toReactNode;
  • 如果存在transform,转化成对应ReactElement并返回;
  • 如果存在defaultTransform ,调用defaultTransform 并返回;
  • 如果不存在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) => {
        if (/^on.*/.test(attr)) {
          return result;
        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);
         const name = getPropName(originalTag, attributeName);
         if (name === 'style') {
          result[name] = convertStyle(attrs.style!);
        else {
          const value = attrs[attr]
          const isBooleanAttribute = value === '' || String(value).toLowerCase() === attributeName.toLowerCase();
          result[name] = isBooleanAttribute ? true : value;
        return result;
    

    从代码分析:

  • 通过正则/^on.*/.test(attr)判断是否内联事件,如果是则忽略掉(所有内联事件都不会生效)。
  • 转化除了data-和aria- 并且不在preserveAttributes 数组内的属性成驼峰式。
  • 把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"
  • 转行内样式成StyleObject;
  • 转化布尔属性
    什么是布尔属性❓