如何把html字符串轉(zhuǎn)成一個React組件绘证?

dangerouslySetInnerHTML

react提供了dangerouslySetInnerHTML屬性隧膏,把html字符串轉(zhuǎn)成react元素:

image

安全性

通常來講,直接設(shè)置dangerouslySetInnerHTML存在風(fēng)險嚷那,因為很容易無意中使用戶暴露于跨站腳本(XSS) 的攻擊胞枕。因此,你可以直接在 React 中設(shè)置 HTML魏宽,但當(dāng)你想設(shè)置 dangerouslySetInnerHTML 時腐泻,需要向其傳遞包含 key 為 __html 的對象,以此來警示你湖员。

正如上面所說贫悄,直接使用dangerouslySetInnerHTML存在xss的風(fēng)險

所以我們需要先對html字符串進行過濾娘摔、轉(zhuǎn)換窄坦,再通過React.createElement()把字符串轉(zhuǎn)成React組件。如果需要自己去實現(xiàn)這一步驟的話凳寺,可能會比較麻煩(因為還涉及字符串轉(zhuǎn)dom鸭津、屬性轉(zhuǎn)React屬性等操作)。

下面會介紹一個實現(xiàn)這個功能的庫htmr和內(nèi)部原理肠缨。

htmr

簡單輕便的HTML字符串到組件的轉(zhuǎn)換庫逆趋。

安裝這里不會介紹,如果要用到自己去npm上看文檔晒奕。

在介紹內(nèi)部原理前闻书,我們需要先看看如何使用名斟,方便對代碼內(nèi)變量和方法的解讀。

使用

htmr接收兩個參數(shù)魄眉,html字符串和一個配置對象options砰盐。

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

下面著重介紹下HtmrOptions里面各個屬性:

  • preserveAttributes:Array<String | RegExp> - 默認情況下,htmr會將符合要求的html屬性轉(zhuǎn)換為React要求的駝峰式屬性坑律,如果某些屬性不想轉(zhuǎn)換岩梳,可以通過該屬性來阻止React這個行為。
  • transform - 接受鍵值對晃择,這些鍵值對將用于 將節(jié)點(鍵)轉(zhuǎn)換為自定義組件(值)冀值,可以使用它來通過自定義組件呈現(xiàn)特定的標簽名稱。

例如宫屠,下面這個例子列疗。
定義了transform對象,目的是把p標簽轉(zhuǎn)成Paragraph組件激况,把a標簽轉(zhuǎn)成span標簽:

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

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

transform里面有一個參數(shù)叫做defaultTransform, 以符號 _表示作彤,它接受的參數(shù)跟React.createElement一致膘魄。這個參數(shù)非常有用乌逐,例如可以在富文本里面處理圖片,把圖片轉(zhuǎn)成我們自定義的圖片組件:

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

transform里面還有一個參數(shù)叫 dangerouslySetChildren 创葡,出于安全原因浙踢,默認情況下,htmr僅將危險標簽內(nèi)的樣式標記的子項呈現(xiàn)在危險地設(shè)置為InnerHTML中灿渴。
例如洛波,下面例子設(shè)置dangerouslySetChildren:['code']:

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

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

工具函數(shù)

hypenColonToCamelCase

把帶中劃線或者冒號的字符串轉(zhuǎn)成駝峰式,如 color-profile => colorProfile骚露,xlink:role => xlinkRole 蹬挤。

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

convertValue

數(shù)字字符串轉(zhuǎn)成數(shù)字類型,單引號轉(zhuǎn)雙引號棘幸。

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

convertStyle

把行內(nèi)樣式字符串轉(zhuǎn)成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;
}

內(nèi)部原理

htmlServer

我們在上面例子用到的htmr函數(shù)其實就是htmlServer焰扳,它主要做了兩件事情:

  1. html字符串轉(zhuǎn)成dom;
  2. 對dom所有節(jié)點做轉(zhuǎn)換成符合要求的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導(dǎo)出的一個函數(shù),能把html字符串轉(zhuǎn)化成dom:

  import { parseDocument } from 'htmlparser2';

toReactNode

顧名思義蹋嵌,toReactNode是把dom轉(zhuǎn)成ReactNode育瓜,也是這個庫的核心。
根據(jù)dom節(jié)點的type屬性栽烂,做了分類處理:


圖片.png

如果type 的值是 'script' 躏仇、'style' 和 'tag' 其中之一恋脚,執(zhí)行如下操作:

  1. 解碼所有屬性值;
  2. 執(zhí)行mapAttribute(把屬性轉(zhuǎn)成React屬性)焰手;
  3. 根據(jù)transform轉(zhuǎn)化標簽慧起;
  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標簽轉(zhuǎn)成 Paragraph標簽,a轉(zhuǎn)成span
       */
      const customElement = transform[name];
  1. 判斷當(dāng)前標簽是否在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節(jié)點執(zhí)行toReactNode蚓挤;
  2. 如果存在transform,轉(zhuǎn)化成對應(yīng)ReactElement并返回驻子;
  3. 如果存在defaultTransform 灿意,調(diào)用defaultTransform 并返回;
  4. 如果不存在transform和defaultTransform崇呵,執(zhí)行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屬性轉(zhuǎn)成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)判斷是否內(nèi)聯(lián)事件,如果是則忽略掉(所有內(nèi)聯(lián)事件都不會生效)犹褒。
  2. 轉(zhuǎn)化除了data-和aria- 并且不在preserveAttributes 數(shù)組內(nèi)的屬性成駝峰式抵窒。
  3. 把html屬性轉(zhuǎn)化為符合React規(guī)范的屬性,具體如何轉(zhuǎn)化的下面提供了一個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. 轉(zhuǎn)行內(nèi)樣式成StyleObject叠骑;
  2. 轉(zhuǎn)化布爾屬性
    什么是布爾屬性?


    圖片.png

總結(jié)

htmr內(nèi)部對html字符串進行dom轉(zhuǎn)換李皇,接著遞歸遍歷所有節(jié)點,對節(jié)點(和屬性)過濾宙枷、轉(zhuǎn)換掉房,再通過React.createElement()把字符串轉(zhuǎn)成React組件。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末慰丛,一起剝皮案震驚了整個濱河市卓囚,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌诅病,老刑警劉巖哪亿,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異睬隶,居然都是意外死亡锣夹,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門苏潜,熙熙樓的掌柜王于貴愁眉苦臉地迎上來银萍,“玉大人,你說我怎么就攤上這事恤左√剑” “怎么了搀绣?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長戳气。 經(jīng)常有香客問我链患,道長,這世上最難降的妖魔是什么瓶您? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任麻捻,我火速辦了婚禮,結(jié)果婚禮上呀袱,老公的妹妹穿的比我還像新娘贸毕。我一直安慰自己,他們只是感情好夜赵,可當(dāng)我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布明棍。 她就那樣靜靜地躺著,像睡著了一般寇僧。 火紅的嫁衣襯著肌膚如雪摊腋。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天嘁傀,我揣著相機與錄音兴蒸,去河邊找鬼。 笑死心包,一個胖子當(dāng)著我的面吹牛类咧,可吹牛的內(nèi)容都是我干的馒铃。 我是一名探鬼主播蟹腾,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼区宇!你這毒婦竟也來了娃殖?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤议谷,失蹤者是張志新(化名)和其女友劉穎炉爆,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體卧晓,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡芬首,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了逼裆。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片郁稍。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖胜宇,靈堂內(nèi)的尸體忽然破棺而出耀怜,到底是詐尸還是另有隱情恢着,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布财破,位于F島的核電站掰派,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏左痢。R本人自食惡果不足惜靡羡,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望俊性。 院中可真熱鬧亿眠,春花似錦、人聲如沸磅废。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽拯勉。三九已至竟趾,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間宫峦,已是汗流浹背岔帽。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留导绷,地道東北人犀勒。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓,卻偏偏與公主長得像妥曲,于是被迫代替她去往敵國和親贾费。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容