美文网首页前端技巧
如何把html字符串转成一个React组件?

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

作者: 相遇一头猪 | 来源:发表于2021-07-19 23:27 被阅读0次

    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组件。

    相关文章

      网友评论

        本文标题:如何把html字符串转成一个React组件?

        本文链接:https://www.haomeiwen.com/subject/klekmltx.html