dangerouslySetInnerHTML
react提供了dangerouslySetInnerHTML
屬性隧膏,把html字符串轉(zhuǎn)成react元素:
安全性
通常來講,直接設(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焰扳,它主要做了兩件事情:
- html字符串轉(zhuǎn)成dom;
- 對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屬性栽烂,做了分類處理:
如果type 的值是 'script' 躏仇、'style' 和 'tag' 其中之一恋脚,執(zhí)行如下操作:
- 解碼所有屬性值;
- 執(zhí)行mapAttribute(把屬性轉(zhuǎn)成React屬性)焰手;
- 根據(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];
- 判斷當(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);
}
- 對children節(jié)點執(zhí)行toReactNode蚓挤;
- 如果存在transform,轉(zhuǎn)化成對應(yīng)ReactElement并返回驻子;
- 如果存在defaultTransform 灿意,調(diào)用defaultTransform 并返回;
- 如果不存在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;
}
從代碼分析:
- 通過正則
/^on.*/.test(attr)
判斷是否內(nèi)聯(lián)事件,如果是則忽略掉(所有內(nèi)聯(lián)事件都不會生效)犹褒。 - 轉(zhuǎn)化除了data-和aria- 并且不在preserveAttributes 數(shù)組內(nèi)的屬性成駝峰式抵窒。
- 把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"
}
- 轉(zhuǎn)行內(nèi)樣式成StyleObject叠骑;
-
轉(zhuǎn)化布爾屬性
什么是布爾屬性?
圖片.png
總結(jié)
htmr內(nèi)部對html字符串進行dom轉(zhuǎn)換李皇,接著遞歸遍歷所有節(jié)點,對節(jié)點(和屬性)過濾宙枷、轉(zhuǎn)換掉房,再通過React.createElement()把字符串轉(zhuǎn)成React組件。